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 CollectionNew from 'scenes/CollectionNew';
|
||||||
import CollectionEdit from 'scenes/CollectionEdit';
|
import CollectionEdit from 'scenes/CollectionEdit';
|
||||||
import CollectionDelete from 'scenes/CollectionDelete';
|
import CollectionDelete from 'scenes/CollectionDelete';
|
||||||
|
import CollectionExport from 'scenes/CollectionExport';
|
||||||
import DocumentDelete from 'scenes/DocumentDelete';
|
import DocumentDelete from 'scenes/DocumentDelete';
|
||||||
import DocumentShare from 'scenes/DocumentShare';
|
import DocumentShare from 'scenes/DocumentShare';
|
||||||
import KeyboardShortcuts from 'scenes/KeyboardShortcuts';
|
import KeyboardShortcuts from 'scenes/KeyboardShortcuts';
|
||||||
|
@ -45,6 +46,9 @@ class Modals extends React.Component<Props> {
|
||||||
<Modal name="collection-delete" title="Delete collection">
|
<Modal name="collection-delete" title="Delete collection">
|
||||||
<CollectionDelete onSubmit={this.handleClose} />
|
<CollectionDelete onSubmit={this.handleClose} />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<Modal name="collection-export" title="Export collection">
|
||||||
|
<CollectionExport onSubmit={this.handleClose} />
|
||||||
|
</Modal>
|
||||||
<Modal name="document-share" title="Share document">
|
<Modal name="document-share" title="Share document">
|
||||||
<DocumentShare onSubmit={this.handleClose} />
|
<DocumentShare onSubmit={this.handleClose} />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { observer, inject } from 'mobx-react';
|
import { observer, inject } from 'mobx-react';
|
||||||
import {
|
import {
|
||||||
|
DocumentIcon,
|
||||||
ProfileIcon,
|
ProfileIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
CodeIcon,
|
CodeIcon,
|
||||||
|
@ -74,6 +75,11 @@ class SettingsSidebar extends React.Component<Props> {
|
||||||
Integrations
|
Integrations
|
||||||
</SidebarLink>
|
</SidebarLink>
|
||||||
)}
|
)}
|
||||||
|
{user.isAdmin && (
|
||||||
|
<SidebarLink to="/settings/export" icon={<DocumentIcon />}>
|
||||||
|
Export Data
|
||||||
|
</SidebarLink>
|
||||||
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
</Scrollable>
|
</Scrollable>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
|
@ -28,6 +28,7 @@ import People from 'scenes/Settings/People';
|
||||||
import Slack from 'scenes/Settings/Slack';
|
import Slack from 'scenes/Settings/Slack';
|
||||||
import Shares from 'scenes/Settings/Shares';
|
import Shares from 'scenes/Settings/Shares';
|
||||||
import Tokens from 'scenes/Settings/Tokens';
|
import Tokens from 'scenes/Settings/Tokens';
|
||||||
|
import Export from 'scenes/Settings/Export';
|
||||||
import Error404 from 'scenes/Error404';
|
import Error404 from 'scenes/Error404';
|
||||||
|
|
||||||
import ErrorBoundary from 'components/ErrorBoundary';
|
import ErrorBoundary from 'components/ErrorBoundary';
|
||||||
|
@ -96,6 +97,11 @@ if (element) {
|
||||||
path="/settings/integrations/slack"
|
path="/settings/integrations/slack"
|
||||||
component={Slack}
|
component={Slack}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
exact
|
||||||
|
path="/settings/export"
|
||||||
|
component={Export}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
exact
|
exact
|
||||||
path="/collections/:id"
|
path="/collections/:id"
|
||||||
|
|
|
@ -61,6 +61,12 @@ class CollectionMenu extends React.Component<Props> {
|
||||||
this.props.ui.setActiveModal('collection-delete', { collection });
|
this.props.ui.setActiveModal('collection-delete', { collection });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onExport = (ev: SyntheticEvent<*>) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const { collection } = this.props;
|
||||||
|
this.props.ui.setActiveModal('collection-export', { collection });
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { collection, label, onOpen, onClose } = this.props;
|
const { collection, label, onOpen, onClose } = this.props;
|
||||||
|
|
||||||
|
@ -87,6 +93,9 @@ class CollectionMenu extends React.Component<Props> {
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<hr />
|
<hr />
|
||||||
<DropdownMenuItem onClick={this.onEdit}>Edit…</DropdownMenuItem>
|
<DropdownMenuItem onClick={this.onEdit}>Edit…</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={this.onExport}>
|
||||||
|
Export…
|
||||||
|
</DropdownMenuItem>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuItem onClick={this.onDelete}>Delete…</DropdownMenuItem>
|
<DropdownMenuItem onClick={this.onDelete}>Delete…</DropdownMenuItem>
|
||||||
|
|
|
@ -133,6 +133,11 @@ class Collection extends BaseModel {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@action
|
||||||
|
export = async () => {
|
||||||
|
await client.post('/collections.export', { id: this.id });
|
||||||
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
updateData(data: Object = {}) {
|
updateData(data: Object = {}) {
|
||||||
this.data = data;
|
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
|
@action
|
||||||
add = (collection: Collection): void => {
|
add = (collection: Collection): void => {
|
||||||
this.data.set(collection.id, collection);
|
this.data.set(collection.id, collection);
|
||||||
|
|
|
@ -108,6 +108,7 @@
|
||||||
"imports-loader": "0.6.5",
|
"imports-loader": "0.6.5",
|
||||||
"invariant": "^2.2.2",
|
"invariant": "^2.2.2",
|
||||||
"isomorphic-fetch": "2.2.1",
|
"isomorphic-fetch": "2.2.1",
|
||||||
|
"jszip": "3.1.5",
|
||||||
"js-cookie": "^2.1.4",
|
"js-cookie": "^2.1.4",
|
||||||
"js-search": "^1.4.2",
|
"js-search": "^1.4.2",
|
||||||
"json-loader": "0.5.4",
|
"json-loader": "0.5.4",
|
||||||
|
@ -134,7 +135,7 @@
|
||||||
"nodemailer": "^4.4.0",
|
"nodemailer": "^4.4.0",
|
||||||
"normalize.css": "^7.0.0",
|
"normalize.css": "^7.0.0",
|
||||||
"normalizr": "2.0.1",
|
"normalizr": "2.0.1",
|
||||||
"outline-icons": "^1.3.1",
|
"outline-icons": "^1.3.2",
|
||||||
"oy-vey": "^0.10.0",
|
"oy-vey": "^0.10.0",
|
||||||
"pg": "^6.1.5",
|
"pg": "^6.1.5",
|
||||||
"pg-hstore": "2.3.2",
|
"pg-hstore": "2.3.2",
|
||||||
|
@ -170,6 +171,7 @@
|
||||||
"styled-components-breakpoint": "^1.0.1",
|
"styled-components-breakpoint": "^1.0.1",
|
||||||
"styled-components-grid": "^1.0.0-preview.15",
|
"styled-components-grid": "^1.0.0-preview.15",
|
||||||
"styled-normalize": "^2.2.1",
|
"styled-normalize": "^2.2.1",
|
||||||
|
"tmp": "0.0.33",
|
||||||
"uglifyjs-webpack-plugin": "1.2.5",
|
"uglifyjs-webpack-plugin": "1.2.5",
|
||||||
"url-loader": "^0.6.2",
|
"url-loader": "^0.6.2",
|
||||||
"uuid": "2.0.2",
|
"uuid": "2.0.2",
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
exports[`Mailer #welcome 1`] = `
|
exports[`Mailer #welcome 1`] = `
|
||||||
Object {
|
Object {
|
||||||
|
"attachments": undefined,
|
||||||
"from": "hello@example.com",
|
"from": "hello@example.com",
|
||||||
"html": "
|
"html": "
|
||||||
<!DOCTYPE html PUBLIC \\"-//W3C//DTD XHTML 1.0 Strict//EN\\" \\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd\\">
|
<!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`] = `
|
exports[`#collections.info should require authentication 1`] = `
|
||||||
Object {
|
Object {
|
||||||
"error": "authentication_required",
|
"error": "authentication_required",
|
||||||
|
|
|
@ -4,8 +4,9 @@ import Router from 'koa-router';
|
||||||
import auth from '../middlewares/authentication';
|
import auth from '../middlewares/authentication';
|
||||||
import pagination from './middlewares/pagination';
|
import pagination from './middlewares/pagination';
|
||||||
import { presentCollection } from '../presenters';
|
import { presentCollection } from '../presenters';
|
||||||
import { Collection } from '../models';
|
import { Collection, Team } from '../models';
|
||||||
import { ValidationError } from '../errors';
|
import { ValidationError } from '../errors';
|
||||||
|
import { exportCollection, exportCollections } from '../logistics';
|
||||||
import policy from '../policies';
|
import policy from '../policies';
|
||||||
|
|
||||||
const { authorize } = policy;
|
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 => {
|
router.post('collections.update', auth(), async ctx => {
|
||||||
const { id, name, color } = ctx.body;
|
const { id, name, color } = ctx.body;
|
||||||
ctx.assertPresent(name, 'name is required');
|
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 () => {
|
describe('#collections.info', async () => {
|
||||||
it('should return collection', async () => {
|
it('should return collection', async () => {
|
||||||
const { user, collection } = await seed();
|
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>
|
<Body>
|
||||||
<Heading>Welcome to Outline!</Heading>
|
<Heading>Welcome to Outline!</Heading>
|
||||||
|
|
||||||
<p>Outline is a place for your team to build and share knowledge.</p>
|
<p>Outline is a place for your team to build and share knowledge.</p>
|
||||||
<p>
|
<p>
|
||||||
To get started, head to your dashboard and try creating a collection
|
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
|
You can also import existing Markdown document by drag and dropping
|
||||||
them to your collections
|
them to your collections
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<EmptySpace height={10} />
|
<EmptySpace height={10} />
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<Button href={`${process.env.URL}/dashboard`}>
|
<Button href={`${process.env.URL}/dashboard`}>
|
||||||
View my dashboard
|
View my dashboard
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
import Koa from 'koa';
|
import Koa from 'koa';
|
||||||
import Router from 'koa-router';
|
import Router from 'koa-router';
|
||||||
import { NotFoundError } from '../errors';
|
import { NotFoundError } from '../errors';
|
||||||
import { Mailer } from '../mailer';
|
import Mailer from '../mailer';
|
||||||
|
|
||||||
const emailPreviews = new Koa();
|
const emailPreviews = new Koa();
|
||||||
const router = new Router();
|
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
|
// @flow
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import debug from 'debug';
|
||||||
|
import bugsnag from 'bugsnag';
|
||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
import Oy from 'oy-vey';
|
import Oy from 'oy-vey';
|
||||||
import Queue from 'bull';
|
import Queue from 'bull';
|
||||||
import { baseStyles } from './emails/components/EmailLayout';
|
import { baseStyles } from './emails/components/EmailLayout';
|
||||||
import { WelcomeEmail, welcomeEmailText } from './emails/WelcomeEmail';
|
import { WelcomeEmail, welcomeEmailText } from './emails/WelcomeEmail';
|
||||||
|
import { ExportEmail, exportEmailText } from './emails/ExportEmail';
|
||||||
|
|
||||||
|
const log = debug('emails');
|
||||||
|
|
||||||
|
type Emails = 'welcome' | 'export';
|
||||||
|
|
||||||
type SendMailType = {
|
type SendMailType = {
|
||||||
to: string,
|
to: string,
|
||||||
|
@ -14,6 +21,14 @@ type SendMailType = {
|
||||||
text: string,
|
text: string,
|
||||||
html: React.Node,
|
html: React.Node,
|
||||||
headCSS?: string,
|
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
|
* HTML: http://localhost:3000/email/:email_type/html
|
||||||
* TEXT: http://localhost:3000/email/:email_type/text
|
* TEXT: http://localhost:3000/email/:email_type/text
|
||||||
*/
|
*/
|
||||||
class Mailer {
|
export default class Mailer {
|
||||||
transporter: ?any;
|
transporter: ?any;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -44,6 +59,7 @@ class Mailer {
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
log(`Sending email "${data.title}" to ${data.to}`);
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from: process.env.SMTP_FROM_EMAIL,
|
from: process.env.SMTP_FROM_EMAIL,
|
||||||
replyTo: process.env.SMTP_REPLY_EMAIL || 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,
|
subject: data.title,
|
||||||
html: html,
|
html: html,
|
||||||
text: data.text,
|
text: data.text,
|
||||||
|
attachments: data.attachments,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (err) {
|
||||||
Bugsnag.notifyException(e);
|
bugsnag.notify(err);
|
||||||
throw e; // Re-throw for queue to re-try
|
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() {
|
constructor() {
|
||||||
if (process.env.SMTP_HOST) {
|
if (process.env.SMTP_HOST) {
|
||||||
let smtpConfig = {
|
let smtpConfig = {
|
||||||
host: process.env.SMTP_HOST,
|
host: process.env.SMTP_HOST,
|
||||||
port: process.env.SMTP_PORT,
|
port: process.env.SMTP_PORT,
|
||||||
secure: true,
|
secure: process.env.NODE_ENV === 'production',
|
||||||
auth: {
|
auth: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (process.env.SMTP_USERNAME) {
|
||||||
|
smtpConfig.auth = {
|
||||||
user: process.env.SMTP_USERNAME,
|
user: process.env.SMTP_USERNAME,
|
||||||
pass: process.env.SMTP_PASSWORD,
|
pass: process.env.SMTP_PASSWORD,
|
||||||
},
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
this.transporter = nodemailer.createTransport(smtpConfig);
|
this.transporter = nodemailer.createTransport(smtpConfig);
|
||||||
}
|
}
|
||||||
|
@ -88,14 +120,14 @@ class Mailer {
|
||||||
}
|
}
|
||||||
|
|
||||||
const mailer = new 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
|
// $FlowIssue flow doesn't like dynamic values
|
||||||
await mailer[job.data.type](job.data.opts);
|
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(
|
mailerQueue.add(
|
||||||
{
|
{
|
||||||
type,
|
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 */
|
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||||
import { Mailer } from './mailer';
|
import Mailer from './mailer';
|
||||||
|
|
||||||
describe('Mailer', () => {
|
describe('Mailer', () => {
|
||||||
let fakeMailer;
|
let fakeMailer;
|
||||||
|
|
|
@ -9,7 +9,7 @@ allow(User, 'create', Collection);
|
||||||
|
|
||||||
allow(
|
allow(
|
||||||
User,
|
User,
|
||||||
['read', 'publish', 'update'],
|
['read', 'publish', 'update', 'export'],
|
||||||
Collection,
|
Collection,
|
||||||
(user, collection) => collection && user.teamId === collection.teamId
|
(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, '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 (!team || user.teamId !== team.id) return false;
|
||||||
if (user.isAdmin) return true;
|
if (user.isAdmin) return true;
|
||||||
throw new AdminRequiredError();
|
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
|
// @flow
|
||||||
import emojiRegex from 'emoji-regex';
|
import emojiRegex from 'emoji-regex';
|
||||||
|
import unescape from './unescape';
|
||||||
|
|
||||||
export default function parseTitle(text: string = '') {
|
export default function parseTitle(text: string = '') {
|
||||||
const regex = emojiRegex();
|
const regex = emojiRegex();
|
||||||
|
@ -9,7 +10,7 @@ export default function parseTitle(text: string = '') {
|
||||||
const trimmedTitle = firstLine.replace(/^#/, '').trim();
|
const trimmedTitle = firstLine.replace(/^#/, '').trim();
|
||||||
|
|
||||||
// remove any escape characters
|
// remove any escape characters
|
||||||
const title = trimmedTitle.replace(/\\([\\`*{}[\]()#+\-.!_>])/g, '$1');
|
const title = unescape(trimmedTitle);
|
||||||
|
|
||||||
// find and extract first emoji
|
// find and extract first emoji
|
||||||
const matches = regex.exec(title);
|
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"
|
version "2.5.1"
|
||||||
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.1.tgz#ae6874dc66937789b80754ff5428df66819ca50b"
|
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:
|
core-util-is@1.0.2, core-util-is@~1.0.0:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
|
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"
|
version "4.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.1.1.tgz#8811e90915d9a0dba36274f0b242dbda78f9c92a"
|
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:
|
es6-set@~0.1.5:
|
||||||
version "0.1.5"
|
version "0.1.5"
|
||||||
resolved "https://registry.yarnpkg.com/es6-set/-/es6-set-0.1.5.tgz#d2b3ec5d4d800ced818db538d28974db0a73ccb1"
|
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:
|
dependencies:
|
||||||
array-includes "^3.0.3"
|
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:
|
jwa@^1.1.4:
|
||||||
version "1.1.5"
|
version "1.1.5"
|
||||||
resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.1.5.tgz#a0552ce0220742cd52e153774a32905c30e756e5"
|
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"
|
inline-process-browser "^1.0.0"
|
||||||
unreachable-branch-transform "^0.3.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:
|
liftoff@^2.1.0:
|
||||||
version "2.3.0"
|
version "2.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/liftoff/-/liftoff-2.3.0.tgz#a98f2ff67183d8ba7cfaca10548bd7ff0550b385"
|
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"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.0.3.tgz#f0928a8bbc7e7ff4ea6762eee8fb2995d477941e"
|
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.0.3.tgz#f0928a8bbc7e7ff4ea6762eee8fb2995d477941e"
|
||||||
|
|
||||||
outline-icons@^1.3.1:
|
outline-icons@^1.3.2:
|
||||||
version "1.3.1"
|
version "1.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.3.1.tgz#1a6e79d49d63d029ef7c7e7f390eb0215b3b2d48"
|
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.3.2.tgz#6c79a55081cd33925b324e2ad8bbf17a24ba2655"
|
||||||
|
|
||||||
oy-vey@^0.10.0:
|
oy-vey@^0.10.0:
|
||||||
version "0.10.0"
|
version "0.10.0"
|
||||||
|
@ -7377,7 +7401,7 @@ packet-reader@0.3.1:
|
||||||
version "0.3.1"
|
version "0.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/packet-reader/-/packet-reader-0.3.1.tgz#cd62e60af8d7fea8a705ec4ff990871c46871f27"
|
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"
|
version "1.0.6"
|
||||||
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.6.tgz#0101211baa70c4bca4a0f63f2206e97b7dfaf258"
|
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"
|
isarray "0.0.1"
|
||||||
string_decoder "~0.10.x"
|
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:
|
readable-stream@~2.1.5:
|
||||||
version "2.1.5"
|
version "2.1.5"
|
||||||
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.1.5.tgz#66fa8b720e1438b364681f2ad1a63c618448c9d0"
|
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"
|
no-case "^2.2.0"
|
||||||
upper-case "^1.0.3"
|
upper-case "^1.0.3"
|
||||||
|
|
||||||
tmp@^0.0.33:
|
tmp@0.0.33, tmp@^0.0.33:
|
||||||
version "0.0.33"
|
version "0.0.33"
|
||||||
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
|
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
Reference in New Issue