Bulk export (#684)
* First pass (working) collection export to zip * Add export confirmation screen * 👕 * Refactor * Job for team export, move to tmp file, settings UI * Export all collections job * 👕 * Add specs * Clarify UI
This commit is contained in:
parent
cedd31c9ea
commit
b9e0668d7d
|
@ -6,6 +6,7 @@ import UiStore from 'stores/UiStore';
|
|||
import CollectionNew from 'scenes/CollectionNew';
|
||||
import CollectionEdit from 'scenes/CollectionEdit';
|
||||
import CollectionDelete from 'scenes/CollectionDelete';
|
||||
import CollectionExport from 'scenes/CollectionExport';
|
||||
import DocumentDelete from 'scenes/DocumentDelete';
|
||||
import DocumentShare from 'scenes/DocumentShare';
|
||||
import KeyboardShortcuts from 'scenes/KeyboardShortcuts';
|
||||
|
@ -45,6 +46,9 @@ class Modals extends React.Component<Props> {
|
|||
<Modal name="collection-delete" title="Delete collection">
|
||||
<CollectionDelete onSubmit={this.handleClose} />
|
||||
</Modal>
|
||||
<Modal name="collection-export" title="Export collection">
|
||||
<CollectionExport onSubmit={this.handleClose} />
|
||||
</Modal>
|
||||
<Modal name="document-share" title="Share document">
|
||||
<DocumentShare onSubmit={this.handleClose} />
|
||||
</Modal>
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
import * as React from 'react';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import {
|
||||
DocumentIcon,
|
||||
ProfileIcon,
|
||||
SettingsIcon,
|
||||
CodeIcon,
|
||||
|
@ -74,6 +75,11 @@ class SettingsSidebar extends React.Component<Props> {
|
|||
Integrations
|
||||
</SidebarLink>
|
||||
)}
|
||||
{user.isAdmin && (
|
||||
<SidebarLink to="/settings/export" icon={<DocumentIcon />}>
|
||||
Export Data
|
||||
</SidebarLink>
|
||||
)}
|
||||
</Section>
|
||||
</Scrollable>
|
||||
</Flex>
|
||||
|
|
|
@ -28,6 +28,7 @@ import People from 'scenes/Settings/People';
|
|||
import Slack from 'scenes/Settings/Slack';
|
||||
import Shares from 'scenes/Settings/Shares';
|
||||
import Tokens from 'scenes/Settings/Tokens';
|
||||
import Export from 'scenes/Settings/Export';
|
||||
import Error404 from 'scenes/Error404';
|
||||
|
||||
import ErrorBoundary from 'components/ErrorBoundary';
|
||||
|
@ -96,6 +97,11 @@ if (element) {
|
|||
path="/settings/integrations/slack"
|
||||
component={Slack}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/settings/export"
|
||||
component={Export}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/collections/:id"
|
||||
|
|
|
@ -61,6 +61,12 @@ class CollectionMenu extends React.Component<Props> {
|
|||
this.props.ui.setActiveModal('collection-delete', { collection });
|
||||
};
|
||||
|
||||
onExport = (ev: SyntheticEvent<*>) => {
|
||||
ev.preventDefault();
|
||||
const { collection } = this.props;
|
||||
this.props.ui.setActiveModal('collection-export', { collection });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { collection, label, onOpen, onClose } = this.props;
|
||||
|
||||
|
@ -87,6 +93,9 @@ class CollectionMenu extends React.Component<Props> {
|
|||
</DropdownMenuItem>
|
||||
<hr />
|
||||
<DropdownMenuItem onClick={this.onEdit}>Edit…</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={this.onExport}>
|
||||
Export…
|
||||
</DropdownMenuItem>
|
||||
</React.Fragment>
|
||||
)}
|
||||
<DropdownMenuItem onClick={this.onDelete}>Delete…</DropdownMenuItem>
|
||||
|
|
|
@ -133,6 +133,11 @@ class Collection extends BaseModel {
|
|||
return false;
|
||||
};
|
||||
|
||||
@action
|
||||
export = async () => {
|
||||
await client.post('/collections.export', { id: this.id });
|
||||
};
|
||||
|
||||
@action
|
||||
updateData(data: Object = {}) {
|
||||
this.data = data;
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observable } from 'mobx';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import Button from 'components/Button';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import HelpText from 'components/HelpText';
|
||||
import Collection from 'models/Collection';
|
||||
import AuthStore from 'stores/AuthStore';
|
||||
import UiStore from 'stores/UiStore';
|
||||
|
||||
type Props = {
|
||||
collection: Collection,
|
||||
auth: AuthStore,
|
||||
ui: UiStore,
|
||||
onSubmit: () => void,
|
||||
};
|
||||
|
||||
@observer
|
||||
class CollectionExport extends React.Component<Props> {
|
||||
@observable isLoading: boolean = false;
|
||||
|
||||
handleSubmit = async (ev: SyntheticEvent<*>) => {
|
||||
ev.preventDefault();
|
||||
|
||||
this.isLoading = true;
|
||||
await this.props.collection.export();
|
||||
this.isLoading = false;
|
||||
|
||||
this.props.ui.showToast('Export in progress…', 'success');
|
||||
this.props.onSubmit();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { collection, auth } = this.props;
|
||||
if (!auth.user) return;
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<HelpText>
|
||||
Exporting the collection <strong>{collection.name}</strong> may take
|
||||
a few minutes. We’ll put together a zip file of your documents in
|
||||
Markdown format and email it to <strong>{auth.user.email}</strong>.
|
||||
</HelpText>
|
||||
<Button type="submit" disabled={this.isLoading} primary>
|
||||
{this.isLoading ? 'Requesting Export…' : 'Export Collection'}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default inject('ui', 'auth')(CollectionExport);
|
|
@ -0,0 +1,3 @@
|
|||
// @flow
|
||||
import CollectionExport from './CollectionExport';
|
||||
export default CollectionExport;
|
|
@ -0,0 +1,71 @@
|
|||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observable } from 'mobx';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import AuthStore from 'stores/AuthStore';
|
||||
import CollectionsStore from 'stores/CollectionsStore';
|
||||
import UiStore from 'stores/UiStore';
|
||||
|
||||
import CenteredContent from 'components/CenteredContent';
|
||||
import PageTitle from 'components/PageTitle';
|
||||
import HelpText from 'components/HelpText';
|
||||
import Button from 'components/Button';
|
||||
|
||||
type Props = {
|
||||
auth: AuthStore,
|
||||
collections: CollectionsStore,
|
||||
ui: UiStore,
|
||||
};
|
||||
|
||||
@observer
|
||||
class Export extends React.Component<Props> {
|
||||
@observable isLoading: boolean = false;
|
||||
@observable isExporting: boolean = false;
|
||||
|
||||
handleSubmit = async (ev: SyntheticEvent<*>) => {
|
||||
ev.preventDefault();
|
||||
this.isLoading = true;
|
||||
|
||||
const success = await this.props.collections.export();
|
||||
|
||||
if (success) {
|
||||
this.isExporting = true;
|
||||
this.props.ui.showToast('Export in progress…', 'success');
|
||||
}
|
||||
this.isLoading = false;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { auth } = this.props;
|
||||
if (!auth.user) return;
|
||||
|
||||
return (
|
||||
<CenteredContent>
|
||||
<PageTitle title="Export Data" />
|
||||
<h1>Export Data</h1>
|
||||
<HelpText>
|
||||
Exporting your teams documents may take a little time depending on the
|
||||
size of your knowledgebase. Consider exporting a single document or
|
||||
collection instead.
|
||||
</HelpText>
|
||||
<HelpText>
|
||||
Still want to export everything in your wiki? We’ll put together a zip
|
||||
file of your collections and documents in Markdown format and email it
|
||||
to <strong>{auth.user.email}</strong>.
|
||||
</HelpText>
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={this.handleSubmit}
|
||||
disabled={this.isLoading || this.isExporting}
|
||||
primary
|
||||
>
|
||||
{this.isExporting
|
||||
? 'Export Requested'
|
||||
: this.isLoading ? 'Requesting Export…' : 'Export All Data'}
|
||||
</Button>
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default inject('auth', 'ui', 'collections')(Export);
|
|
@ -137,6 +137,16 @@ class CollectionsStore extends BaseStore {
|
|||
}
|
||||
};
|
||||
|
||||
@action
|
||||
export = async () => {
|
||||
try {
|
||||
await client.post('/collections.exportAll');
|
||||
return true;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
@action
|
||||
add = (collection: Collection): void => {
|
||||
this.data.set(collection.id, collection);
|
||||
|
|
|
@ -108,6 +108,7 @@
|
|||
"imports-loader": "0.6.5",
|
||||
"invariant": "^2.2.2",
|
||||
"isomorphic-fetch": "2.2.1",
|
||||
"jszip": "3.1.5",
|
||||
"js-cookie": "^2.1.4",
|
||||
"js-search": "^1.4.2",
|
||||
"json-loader": "0.5.4",
|
||||
|
@ -134,7 +135,7 @@
|
|||
"nodemailer": "^4.4.0",
|
||||
"normalize.css": "^7.0.0",
|
||||
"normalizr": "2.0.1",
|
||||
"outline-icons": "^1.3.1",
|
||||
"outline-icons": "^1.3.2",
|
||||
"oy-vey": "^0.10.0",
|
||||
"pg": "^6.1.5",
|
||||
"pg-hstore": "2.3.2",
|
||||
|
@ -170,6 +171,7 @@
|
|||
"styled-components-breakpoint": "^1.0.1",
|
||||
"styled-components-grid": "^1.0.0-preview.15",
|
||||
"styled-normalize": "^2.2.1",
|
||||
"tmp": "0.0.33",
|
||||
"uglifyjs-webpack-plugin": "1.2.5",
|
||||
"url-loader": "^0.6.2",
|
||||
"uuid": "2.0.2",
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
exports[`Mailer #welcome 1`] = `
|
||||
Object {
|
||||
"attachments": undefined,
|
||||
"from": "hello@example.com",
|
||||
"html": "
|
||||
<!DOCTYPE html PUBLIC \\"-//W3C//DTD XHTML 1.0 Strict//EN\\" \\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd\\">
|
||||
|
|
|
@ -18,6 +18,24 @@ Object {
|
|||
}
|
||||
`;
|
||||
|
||||
exports[`#collections.export should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#collections.exportAll should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#collections.info should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
|
|
|
@ -4,8 +4,9 @@ import Router from 'koa-router';
|
|||
import auth from '../middlewares/authentication';
|
||||
import pagination from './middlewares/pagination';
|
||||
import { presentCollection } from '../presenters';
|
||||
import { Collection } from '../models';
|
||||
import { Collection, Team } from '../models';
|
||||
import { ValidationError } from '../errors';
|
||||
import { exportCollection, exportCollections } from '../logistics';
|
||||
import policy from '../policies';
|
||||
|
||||
const { authorize } = policy;
|
||||
|
@ -46,6 +47,35 @@ router.post('collections.info', auth(), async ctx => {
|
|||
};
|
||||
});
|
||||
|
||||
router.post('collections.export', auth(), async ctx => {
|
||||
const { id } = ctx.body;
|
||||
ctx.assertPresent(id, 'id is required');
|
||||
|
||||
const user = ctx.state.user;
|
||||
const collection = await Collection.findById(id);
|
||||
authorize(user, 'export', collection);
|
||||
|
||||
// async operation to create zip archive and email user
|
||||
exportCollection(id, user.email);
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
});
|
||||
|
||||
router.post('collections.exportAll', auth(), async ctx => {
|
||||
const user = ctx.state.user;
|
||||
const team = await Team.findById(user.teamId);
|
||||
authorize(user, 'export', team);
|
||||
|
||||
// async operation to create zip archive and email user
|
||||
exportCollections(user.teamId, user.email);
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
});
|
||||
|
||||
router.post('collections.update', auth(), async ctx => {
|
||||
const { id, name, color } = ctx.body;
|
||||
ctx.assertPresent(name, 'name is required');
|
||||
|
|
|
@ -31,6 +31,52 @@ describe('#collections.list', async () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('#collections.export', async () => {
|
||||
it('should require authentication', async () => {
|
||||
const res = await server.post('/api/collections.export');
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should return success', async () => {
|
||||
const { user, collection } = await seed();
|
||||
const res = await server.post('/api/collections.export', {
|
||||
body: { token: user.getJwtToken(), id: collection.id },
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#collections.exportAll', async () => {
|
||||
it('should require authentication', async () => {
|
||||
const res = await server.post('/api/collections.exportAll');
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should require authorization', async () => {
|
||||
const user = await buildUser();
|
||||
const res = await server.post('/api/collections.exportAll', {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it('should return success', async () => {
|
||||
const { admin } = await seed();
|
||||
const res = await server.post('/api/collections.exportAll', {
|
||||
body: { token: admin.getJwtToken() },
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#collections.info', async () => {
|
||||
it('should return collection', async () => {
|
||||
const { user, collection } = await seed();
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
// @flow
|
||||
import * as React from 'react';
|
||||
import EmailTemplate from './components/EmailLayout';
|
||||
import Body from './components/Body';
|
||||
import Button from './components/Button';
|
||||
import Heading from './components/Heading';
|
||||
import Header from './components/Header';
|
||||
import Footer from './components/Footer';
|
||||
import EmptySpace from './components/EmptySpace';
|
||||
|
||||
export const exportEmailText = `
|
||||
Your Data Export
|
||||
|
||||
Your requested data export is attached as a zip file to this email.
|
||||
`;
|
||||
|
||||
export const ExportEmail = () => {
|
||||
return (
|
||||
<EmailTemplate>
|
||||
<Header />
|
||||
|
||||
<Body>
|
||||
<Heading>Your Data Export</Heading>
|
||||
<p>
|
||||
Your requested data export is attached as a zip file to this email.
|
||||
</p>
|
||||
<EmptySpace height={10} />
|
||||
<p>
|
||||
<Button href={`${process.env.URL}/dashboard`}>Go to dashboard</Button>
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
<Footer />
|
||||
</EmailTemplate>
|
||||
);
|
||||
};
|
|
@ -27,7 +27,6 @@ export const WelcomeEmail = () => {
|
|||
|
||||
<Body>
|
||||
<Heading>Welcome to Outline!</Heading>
|
||||
|
||||
<p>Outline is a place for your team to build and share knowledge.</p>
|
||||
<p>
|
||||
To get started, head to your dashboard and try creating a collection
|
||||
|
@ -38,9 +37,7 @@ export const WelcomeEmail = () => {
|
|||
You can also import existing Markdown document by drag and dropping
|
||||
them to your collections
|
||||
</p>
|
||||
|
||||
<EmptySpace height={10} />
|
||||
|
||||
<p>
|
||||
<Button href={`${process.env.URL}/dashboard`}>
|
||||
View my dashboard
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
import Koa from 'koa';
|
||||
import Router from 'koa-router';
|
||||
import { NotFoundError } from '../errors';
|
||||
import { Mailer } from '../mailer';
|
||||
import Mailer from '../mailer';
|
||||
|
||||
const emailPreviews = new Koa();
|
||||
const router = new Router();
|
||||
|
|
|
@ -0,0 +1,94 @@
|
|||
// @flow
|
||||
import Queue from 'bull';
|
||||
import debug from 'debug';
|
||||
import Mailer from './mailer';
|
||||
import { Collection, Team } from './models';
|
||||
import { archiveCollection, archiveCollections } from './utils/zip';
|
||||
|
||||
const log = debug('logistics');
|
||||
const logisticsQueue = new Queue('logistics', process.env.REDIS_URL);
|
||||
const mailer = new Mailer();
|
||||
const queueOptions = {
|
||||
attempts: 2,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 60 * 1000,
|
||||
},
|
||||
};
|
||||
|
||||
async function exportAndEmailCollection(collectionId: string, email: string) {
|
||||
log('Archiving collection', collectionId);
|
||||
const collection = await Collection.findById(collectionId);
|
||||
const filePath = await archiveCollection(collection);
|
||||
|
||||
log('Archive path', filePath);
|
||||
|
||||
mailer.export({
|
||||
to: email,
|
||||
attachments: [
|
||||
{
|
||||
filename: `${collection.name} Export.zip`,
|
||||
path: filePath,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function exportAndEmailCollections(teamId: string, email: string) {
|
||||
log('Archiving team', teamId);
|
||||
const team = await Team.findById(teamId);
|
||||
const collections = await Collection.findAll({
|
||||
where: { teamId },
|
||||
order: [['name', 'ASC']],
|
||||
});
|
||||
const filePath = await archiveCollections(collections);
|
||||
|
||||
log('Archive path', filePath);
|
||||
|
||||
mailer.export({
|
||||
to: email,
|
||||
attachments: [
|
||||
{
|
||||
filename: `${team.name} Export.zip`,
|
||||
path: filePath,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
logisticsQueue.process(async job => {
|
||||
log('Process', job.data);
|
||||
|
||||
switch (job.data.type) {
|
||||
case 'export-collection':
|
||||
return await exportAndEmailCollection(
|
||||
job.data.collectionId,
|
||||
job.data.email
|
||||
);
|
||||
case 'export-collections':
|
||||
return await exportAndEmailCollections(job.data.teamId, job.data.email);
|
||||
default:
|
||||
}
|
||||
});
|
||||
|
||||
export const exportCollection = (collectionId: string, email: string) => {
|
||||
logisticsQueue.add(
|
||||
{
|
||||
type: 'export-collection',
|
||||
collectionId,
|
||||
email,
|
||||
},
|
||||
queueOptions
|
||||
);
|
||||
};
|
||||
|
||||
export const exportCollections = (teamId: string, email: string) => {
|
||||
logisticsQueue.add(
|
||||
{
|
||||
type: 'export-collections',
|
||||
teamId,
|
||||
email,
|
||||
},
|
||||
queueOptions
|
||||
);
|
||||
};
|
|
@ -1,10 +1,17 @@
|
|||
// @flow
|
||||
import * as React from 'react';
|
||||
import debug from 'debug';
|
||||
import bugsnag from 'bugsnag';
|
||||
import nodemailer from 'nodemailer';
|
||||
import Oy from 'oy-vey';
|
||||
import Queue from 'bull';
|
||||
import { baseStyles } from './emails/components/EmailLayout';
|
||||
import { WelcomeEmail, welcomeEmailText } from './emails/WelcomeEmail';
|
||||
import { ExportEmail, exportEmailText } from './emails/ExportEmail';
|
||||
|
||||
const log = debug('emails');
|
||||
|
||||
type Emails = 'welcome' | 'export';
|
||||
|
||||
type SendMailType = {
|
||||
to: string,
|
||||
|
@ -14,6 +21,14 @@ type SendMailType = {
|
|||
text: string,
|
||||
html: React.Node,
|
||||
headCSS?: string,
|
||||
attachments?: Object[],
|
||||
};
|
||||
|
||||
type EmailJob = {
|
||||
data: {
|
||||
type: Emails,
|
||||
opts: SendMailType,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -27,7 +42,7 @@ type SendMailType = {
|
|||
* HTML: http://localhost:3000/email/:email_type/html
|
||||
* TEXT: http://localhost:3000/email/:email_type/text
|
||||
*/
|
||||
class Mailer {
|
||||
export default class Mailer {
|
||||
transporter: ?any;
|
||||
|
||||
/**
|
||||
|
@ -44,6 +59,7 @@ class Mailer {
|
|||
});
|
||||
|
||||
try {
|
||||
log(`Sending email "${data.title}" to ${data.to}`);
|
||||
await transporter.sendMail({
|
||||
from: process.env.SMTP_FROM_EMAIL,
|
||||
replyTo: process.env.SMTP_REPLY_EMAIL || process.env.SMTP_FROM_EMAIL,
|
||||
|
@ -51,10 +67,11 @@ class Mailer {
|
|||
subject: data.title,
|
||||
html: html,
|
||||
text: data.text,
|
||||
attachments: data.attachments,
|
||||
});
|
||||
} catch (e) {
|
||||
Bugsnag.notifyException(e);
|
||||
throw e; // Re-throw for queue to re-try
|
||||
} catch (err) {
|
||||
bugsnag.notify(err);
|
||||
throw err; // Re-throw for queue to re-try
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -70,17 +87,32 @@ class Mailer {
|
|||
});
|
||||
};
|
||||
|
||||
export = async (opts: { to: string, attachments: Object[] }) => {
|
||||
this.sendMail({
|
||||
to: opts.to,
|
||||
attachments: opts.attachments,
|
||||
title: 'Your requested export',
|
||||
previewText: "Here's your request data export from Outline",
|
||||
html: <ExportEmail />,
|
||||
text: exportEmailText,
|
||||
});
|
||||
};
|
||||
|
||||
constructor() {
|
||||
if (process.env.SMTP_HOST) {
|
||||
let smtpConfig = {
|
||||
host: process.env.SMTP_HOST,
|
||||
port: process.env.SMTP_PORT,
|
||||
secure: true,
|
||||
auth: {
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
auth: undefined,
|
||||
};
|
||||
|
||||
if (process.env.SMTP_USERNAME) {
|
||||
smtpConfig.auth = {
|
||||
user: process.env.SMTP_USERNAME,
|
||||
pass: process.env.SMTP_PASSWORD,
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
this.transporter = nodemailer.createTransport(smtpConfig);
|
||||
}
|
||||
|
@ -88,14 +120,14 @@ class Mailer {
|
|||
}
|
||||
|
||||
const mailer = new Mailer();
|
||||
const mailerQueue = new Queue('email', process.env.REDIS_URL);
|
||||
export const mailerQueue = new Queue('email', process.env.REDIS_URL);
|
||||
|
||||
mailerQueue.process(async function(job) {
|
||||
mailerQueue.process(async (job: EmailJob) => {
|
||||
// $FlowIssue flow doesn't like dynamic values
|
||||
await mailer[job.data.type](job.data.opts);
|
||||
});
|
||||
|
||||
const sendEmail = (type: string, to: string, options?: Object = {}) => {
|
||||
export const sendEmail = (type: Emails, to: string, options?: Object = {}) => {
|
||||
mailerQueue.add(
|
||||
{
|
||||
type,
|
||||
|
@ -113,5 +145,3 @@ const sendEmail = (type: string, to: string, options?: Object = {}) => {
|
|||
}
|
||||
);
|
||||
};
|
||||
|
||||
export { Mailer, mailerQueue, sendEmail };
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import { Mailer } from './mailer';
|
||||
import Mailer from './mailer';
|
||||
|
||||
describe('Mailer', () => {
|
||||
let fakeMailer;
|
||||
|
|
|
@ -9,7 +9,7 @@ allow(User, 'create', Collection);
|
|||
|
||||
allow(
|
||||
User,
|
||||
['read', 'publish', 'update'],
|
||||
['read', 'publish', 'update', 'export'],
|
||||
Collection,
|
||||
(user, collection) => collection && user.teamId === collection.teamId
|
||||
);
|
||||
|
|
|
@ -7,7 +7,7 @@ const { allow } = policy;
|
|||
|
||||
allow(User, 'read', Team, (user, team) => team && user.teamId === team.id);
|
||||
|
||||
allow(User, 'update', Team, (user, team) => {
|
||||
allow(User, ['update', 'export'], Team, (user, team) => {
|
||||
if (!team || user.teamId !== team.id) return false;
|
||||
if (user.isAdmin) return true;
|
||||
throw new AdminRequiredError();
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
// @flow
|
||||
import fs from 'fs';
|
||||
import JSZip from 'jszip';
|
||||
import tmp from 'tmp';
|
||||
import unescape from '../../shared/utils/unescape';
|
||||
import { Collection, Document } from '../models';
|
||||
|
||||
async function addToArchive(zip, documents) {
|
||||
for (const doc of documents) {
|
||||
const document = await Document.findById(doc.id);
|
||||
|
||||
zip.file(`${document.title}.md`, unescape(document.text));
|
||||
|
||||
if (doc.children && doc.children.length) {
|
||||
const folder = zip.folder(document.title);
|
||||
await addToArchive(folder, doc.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveToPath(zip) {
|
||||
return new Promise((resolve, reject) => {
|
||||
tmp.file({ prefix: 'export-', postfix: '.zip' }, (err, path) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
zip
|
||||
.generateNodeStream({ type: 'nodebuffer', streamFiles: true })
|
||||
.pipe(fs.createWriteStream(path))
|
||||
.on('finish', () => resolve(path))
|
||||
.on('error', reject);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function archiveCollection(collection: Collection) {
|
||||
const zip = new JSZip();
|
||||
await addToArchive(zip, collection.documentStructure);
|
||||
return archiveToPath(zip);
|
||||
}
|
||||
|
||||
export async function archiveCollections(collections: Collection[]) {
|
||||
const zip = new JSZip();
|
||||
|
||||
for (const collection of collections) {
|
||||
const folder = zip.folder(collection.name);
|
||||
await addToArchive(folder, collection.documentStructure);
|
||||
}
|
||||
return archiveToPath(zip);
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
// @flow
|
||||
import emojiRegex from 'emoji-regex';
|
||||
import unescape from './unescape';
|
||||
|
||||
export default function parseTitle(text: string = '') {
|
||||
const regex = emojiRegex();
|
||||
|
@ -9,7 +10,7 @@ export default function parseTitle(text: string = '') {
|
|||
const trimmedTitle = firstLine.replace(/^#/, '').trim();
|
||||
|
||||
// remove any escape characters
|
||||
const title = trimmedTitle.replace(/\\([\\`*{}[\]()#+\-.!_>])/g, '$1');
|
||||
const title = unescape(trimmedTitle);
|
||||
|
||||
// find and extract first emoji
|
||||
const matches = regex.exec(title);
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
// @flow
|
||||
|
||||
const unescape = (text: string) => {
|
||||
return text.replace(/\\([\\`*{}[\]()#+\-.!_>])/g, '$1');
|
||||
};
|
||||
|
||||
export default unescape;
|
45
yarn.lock
45
yarn.lock
|
@ -2211,6 +2211,10 @@ core-js@^2.4.0, core-js@^2.5.0:
|
|||
version "2.5.1"
|
||||
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.1.tgz#ae6874dc66937789b80754ff5428df66819ca50b"
|
||||
|
||||
core-js@~2.3.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.3.0.tgz#fab83fbb0b2d8dc85fa636c4b9d34c75420c6d65"
|
||||
|
||||
core-util-is@1.0.2, core-util-is@~1.0.0:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
|
||||
|
@ -3073,6 +3077,10 @@ es6-promise@^4.0.5:
|
|||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.1.1.tgz#8811e90915d9a0dba36274f0b242dbda78f9c92a"
|
||||
|
||||
es6-promise@~3.0.2:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.0.2.tgz#010d5858423a5f118979665f46486a95c6ee2bb6"
|
||||
|
||||
es6-set@~0.1.5:
|
||||
version "0.1.5"
|
||||
resolved "https://registry.yarnpkg.com/es6-set/-/es6-set-0.1.5.tgz#d2b3ec5d4d800ced818db538d28974db0a73ccb1"
|
||||
|
@ -5718,6 +5726,16 @@ jsx-ast-utils@^2.0.0:
|
|||
dependencies:
|
||||
array-includes "^3.0.3"
|
||||
|
||||
jszip@3.1.5:
|
||||
version "3.1.5"
|
||||
resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.1.5.tgz#e3c2a6c6d706ac6e603314036d43cd40beefdf37"
|
||||
dependencies:
|
||||
core-js "~2.3.0"
|
||||
es6-promise "~3.0.2"
|
||||
lie "~3.1.0"
|
||||
pako "~1.0.2"
|
||||
readable-stream "~2.0.6"
|
||||
|
||||
jwa@^1.1.4:
|
||||
version "1.1.5"
|
||||
resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.1.5.tgz#a0552ce0220742cd52e153774a32905c30e756e5"
|
||||
|
@ -5956,6 +5974,12 @@ lie@3.0.2:
|
|||
inline-process-browser "^1.0.0"
|
||||
unreachable-branch-transform "^0.3.0"
|
||||
|
||||
lie@~3.1.0:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e"
|
||||
dependencies:
|
||||
immediate "~3.0.5"
|
||||
|
||||
liftoff@^2.1.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/liftoff/-/liftoff-2.3.0.tgz#a98f2ff67183d8ba7cfaca10548bd7ff0550b385"
|
||||
|
@ -7326,9 +7350,9 @@ outline-icons@^1.0.0:
|
|||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.0.3.tgz#f0928a8bbc7e7ff4ea6762eee8fb2995d477941e"
|
||||
|
||||
outline-icons@^1.3.1:
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.3.1.tgz#1a6e79d49d63d029ef7c7e7f390eb0215b3b2d48"
|
||||
outline-icons@^1.3.2:
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.3.2.tgz#6c79a55081cd33925b324e2ad8bbf17a24ba2655"
|
||||
|
||||
oy-vey@^0.10.0:
|
||||
version "0.10.0"
|
||||
|
@ -7377,7 +7401,7 @@ packet-reader@0.3.1:
|
|||
version "0.3.1"
|
||||
resolved "https://registry.yarnpkg.com/packet-reader/-/packet-reader-0.3.1.tgz#cd62e60af8d7fea8a705ec4ff990871c46871f27"
|
||||
|
||||
pako@~1.0.5:
|
||||
pako@~1.0.2, pako@~1.0.5:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.6.tgz#0101211baa70c4bca4a0f63f2206e97b7dfaf258"
|
||||
|
||||
|
@ -8443,6 +8467,17 @@ readable-stream@~1.1.8, readable-stream@~1.1.9:
|
|||
isarray "0.0.1"
|
||||
string_decoder "~0.10.x"
|
||||
|
||||
readable-stream@~2.0.6:
|
||||
version "2.0.6"
|
||||
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.0.6.tgz#8f90341e68a53ccc928788dacfcd11b36eb9b78e"
|
||||
dependencies:
|
||||
core-util-is "~1.0.0"
|
||||
inherits "~2.0.1"
|
||||
isarray "~1.0.0"
|
||||
process-nextick-args "~1.0.6"
|
||||
string_decoder "~0.10.x"
|
||||
util-deprecate "~1.0.1"
|
||||
|
||||
readable-stream@~2.1.5:
|
||||
version "2.1.5"
|
||||
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.1.5.tgz#66fa8b720e1438b364681f2ad1a63c618448c9d0"
|
||||
|
@ -9881,7 +9916,7 @@ title-case@^2.1.0:
|
|||
no-case "^2.2.0"
|
||||
upper-case "^1.0.3"
|
||||
|
||||
tmp@^0.0.33:
|
||||
tmp@0.0.33, tmp@^0.0.33:
|
||||
version "0.0.33"
|
||||
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
|
||||
dependencies:
|
||||
|
|
Reference in New Issue