feat: Bulk HTML export (#4620)
* wip * Working bulk html export * Refactor * test * test
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
LightBulbIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { ExportContentType } from "@shared/types";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import DocumentDelete from "~/scenes/DocumentDelete";
|
||||
import DocumentMove from "~/scenes/DocumentMove";
|
||||
@@ -203,7 +204,7 @@ export const downloadDocumentAsHTML = createAction({
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document?.download("text/html");
|
||||
document?.download(ExportContentType.Html);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -229,7 +230,7 @@ export const downloadDocumentAsPDF = createAction({
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document
|
||||
?.download("application/pdf")
|
||||
?.download(ExportContentType.Pdf)
|
||||
.finally(() => id && stores.toasts.hideToast(id));
|
||||
},
|
||||
});
|
||||
@@ -248,7 +249,7 @@ export const downloadDocumentAsMarkdown = createAction({
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document?.download("text/markdown");
|
||||
document?.download(ExportContentType.Markdown);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ const ConfirmationDialog: React.FC<Props> = ({
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text type="secondary">{children}</Text>
|
||||
<Button type="submit" disabled={isSaving} danger={danger} autoFocus>
|
||||
{isSaving ? savingText : submitText}
|
||||
{isSaving && savingText ? savingText : submitText}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
|
||||
31
app/components/Icons/Markdown.tsx
Normal file
31
app/components/Icons/Markdown.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export default function MarkdownIcon({
|
||||
size = 24,
|
||||
color = "currentColor",
|
||||
}: Props) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19.2692 7H3.86538C3.38745 7 3 7.38476 3 7.85938V16.2812C3 16.7559 3.38745 17.1406 3.86538 17.1406H19.2692C19.7472 17.1406 20.1346 16.7559 20.1346 16.2812V7.85938C20.1346 7.38476 19.7472 7 19.2692 7Z"
|
||||
stroke={color}
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
d="M5.16345 14.9922V9.14844H6.89422L8.62499 11.2969L10.3558 9.14844H12.0865V14.9922H10.3558V11.6406L8.62499 13.7891L6.89422 11.6406V14.9922H5.16345ZM15.9808 14.9922L13.3846 12.1562H15.1154V9.14844H16.8461V12.1562H18.5769L15.9808 14.9922Z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export type Props = {
|
||||
icon?: React.ReactNode;
|
||||
options: Option[];
|
||||
note?: React.ReactNode;
|
||||
onChange: (value: string | null) => void;
|
||||
onChange?: (value: string | null) => void;
|
||||
};
|
||||
|
||||
const getOptionFromValue = (options: Option[], value: string | null) => {
|
||||
@@ -109,7 +109,7 @@ const InputSelect = (props: Props) => {
|
||||
previousValue.current = select.selectedValue;
|
||||
|
||||
async function load() {
|
||||
await onChange(select.selectedValue);
|
||||
await onChange?.(select.selectedValue);
|
||||
}
|
||||
|
||||
load();
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function InputSelectPermission(
|
||||
value = "";
|
||||
}
|
||||
|
||||
onChange(value);
|
||||
onChange?.(value);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { addDays, differenceInDays } from "date-fns";
|
||||
import { floor } from "lodash";
|
||||
import { action, autorun, computed, observable, set } from "mobx";
|
||||
import { ExportContentType } from "@shared/types";
|
||||
import parseTitle from "@shared/utils/parseTitle";
|
||||
import { isRTL } from "@shared/utils/rtl";
|
||||
import DocumentsStore from "~/stores/DocumentsStore";
|
||||
@@ -419,10 +420,8 @@ export default class Document extends ParanoidModel {
|
||||
};
|
||||
}
|
||||
|
||||
download = async (
|
||||
contentType: "text/html" | "text/markdown" | "application/pdf"
|
||||
) => {
|
||||
await client.post(
|
||||
download = (contentType: ExportContentType) => {
|
||||
return client.post(
|
||||
`/documents.export`,
|
||||
{
|
||||
id: this.id,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed } from "mobx";
|
||||
import { FileOperationFormat, FileOperationType } from "@shared/types";
|
||||
import { bytesToHumanReadable } from "@shared/utils/files";
|
||||
import BaseModel from "./BaseModel";
|
||||
import User from "./User";
|
||||
@@ -16,7 +17,9 @@ class FileOperation extends BaseModel {
|
||||
|
||||
size: number;
|
||||
|
||||
type: "import" | "export";
|
||||
type: FileOperationType;
|
||||
|
||||
format: FileOperationFormat;
|
||||
|
||||
user: User;
|
||||
|
||||
|
||||
@@ -11,30 +11,26 @@ import Text from "~/components/Text";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import ExportDialog from "./components/ExportDialog";
|
||||
import FileOperationListItem from "./components/FileOperationListItem";
|
||||
|
||||
function Export() {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const { fileOperations, collections } = useStores();
|
||||
const { fileOperations, dialogs } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const [isLoading, setLoading] = React.useState(false);
|
||||
const [isExporting, setExporting] = React.useState(false);
|
||||
|
||||
const handleExport = React.useCallback(
|
||||
const handleOpenDialog = React.useCallback(
|
||||
async (ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await collections.export();
|
||||
setExporting(true);
|
||||
showToast(t("Export in progress…"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
dialogs.openModal({
|
||||
title: t("Export data"),
|
||||
isCentered: true,
|
||||
content: <ExportDialog onSubmit={dialogs.closeAllModals} />,
|
||||
});
|
||||
},
|
||||
[t, collections, showToast]
|
||||
[dialogs, t]
|
||||
);
|
||||
|
||||
const handleDelete = React.useCallback(
|
||||
@@ -65,16 +61,8 @@ function Export() {
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={handleExport}
|
||||
disabled={isLoading || isExporting}
|
||||
>
|
||||
{isExporting
|
||||
? t("Export Requested")
|
||||
: isLoading
|
||||
? `${t("Requesting Export")}…`
|
||||
: t("Export Data")}
|
||||
<Button type="submit" onClick={handleOpenDialog}>
|
||||
{t("Export data")}…
|
||||
</Button>
|
||||
<br />
|
||||
<PaginatedList
|
||||
|
||||
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
|
||||
import { NewDocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { FileOperationType } from "@shared/types";
|
||||
import { cdnPath } from "@shared/utils/urls";
|
||||
import FileOperation from "~/models/FileOperation";
|
||||
import Button from "~/components/Button";
|
||||
@@ -93,7 +94,7 @@ function Import() {
|
||||
items={fileOperations.imports}
|
||||
fetch={fileOperations.fetchPage}
|
||||
options={{
|
||||
type: "import",
|
||||
type: FileOperationType.Import,
|
||||
}}
|
||||
heading={
|
||||
<h2>
|
||||
|
||||
114
app/scenes/Settings/components/ExportDialog.tsx
Normal file
114
app/scenes/Settings/components/ExportDialog.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { CodeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { FileOperationFormat } from "@shared/types";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Flex from "~/components/Flex";
|
||||
import MarkdownIcon from "~/components/Icons/Markdown";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
function ExportDialog({ onSubmit }: Props) {
|
||||
const [format, setFormat] = React.useState<FileOperationFormat>(
|
||||
FileOperationFormat.MarkdownZip
|
||||
);
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleFormatChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormat(ev.target.value as FileOperationFormat);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSubmit = React.useCallback(async () => {
|
||||
await collections.export(format);
|
||||
onSubmit();
|
||||
}, [collections, format, onSubmit]);
|
||||
|
||||
return (
|
||||
<ConfirmationDialog onSubmit={handleSubmit} submitText={t("Export")}>
|
||||
<Flex gap={12} column>
|
||||
<Option>
|
||||
<Input
|
||||
type="radio"
|
||||
name="format"
|
||||
value={FileOperationFormat.MarkdownZip}
|
||||
checked={format === FileOperationFormat.MarkdownZip}
|
||||
onChange={handleFormatChange}
|
||||
/>
|
||||
<Format>
|
||||
<MarkdownIcon size={32} color="currentColor" />
|
||||
Markdown
|
||||
</Format>
|
||||
<Text size="small">
|
||||
<Trans>
|
||||
A ZIP file containing the images, and documents in the Markdown
|
||||
format.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Option>
|
||||
<Option>
|
||||
<Input
|
||||
type="radio"
|
||||
name="format"
|
||||
value={FileOperationFormat.HTMLZip}
|
||||
checked={format === FileOperationFormat.HTMLZip}
|
||||
onChange={handleFormatChange}
|
||||
/>
|
||||
<Format>
|
||||
<CodeIcon size={32} color="currentColor" />
|
||||
HTML
|
||||
</Format>
|
||||
<Text size="small">
|
||||
<Trans>
|
||||
A ZIP file containing the images, and documents as HTML files.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Option>
|
||||
</Flex>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
const Format = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
background: ${(props) => props.theme.secondaryBackground};
|
||||
border-radius: 6px;
|
||||
width: 25%;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
padding: 10px 8px;
|
||||
cursor: var(--pointer);
|
||||
`;
|
||||
|
||||
const Option = styled.label`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const Input = styled.input`
|
||||
display: none;
|
||||
|
||||
&:checked + ${Format} {
|
||||
box-shadow: inset 0 0 0 2px ${(props) => props.theme.inputBorderFocused};
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(ExportDialog);
|
||||
@@ -3,6 +3,7 @@ import { ArchiveIcon, DoneIcon, WarningIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "styled-components";
|
||||
import { FileOperationFormat, FileOperationType } from "@shared/types";
|
||||
import FileOperation from "~/models/FileOperation";
|
||||
import { Action } from "~/components/Actions";
|
||||
import ListItem from "~/components/List/Item";
|
||||
@@ -36,10 +37,19 @@ const FileOperationListItem = ({ fileOperation, handleDelete }: Props) => {
|
||||
error: <WarningIcon color={theme.danger} />,
|
||||
};
|
||||
|
||||
const formatToReadable = {
|
||||
[FileOperationFormat.MarkdownZip]: "Markdown",
|
||||
[FileOperationFormat.HTMLZip]: "HTML",
|
||||
[FileOperationFormat.PDFZip]: "PDF",
|
||||
};
|
||||
|
||||
const format = formatToReadable[fileOperation.format];
|
||||
|
||||
const title =
|
||||
fileOperation.type === "import" || fileOperation.collectionId
|
||||
fileOperation.type === FileOperationType.Import ||
|
||||
fileOperation.collectionId
|
||||
? fileOperation.name
|
||||
: t("All collections");
|
||||
: t("All collections") + (format ? ` • ${format}` : "");
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
|
||||
@@ -215,7 +215,9 @@ export default class CollectionsStore extends BaseStore<Collection> {
|
||||
this.rootStore.documents.fetchRecentlyViewed();
|
||||
};
|
||||
|
||||
export = () => {
|
||||
return client.post("/collections.export_all");
|
||||
export = (format: string) => {
|
||||
return client.post("/collections.export_all", {
|
||||
format,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { orderBy } from "lodash";
|
||||
import { computed } from "mobx";
|
||||
import { FileOperationType } from "@shared/types";
|
||||
import FileOperation from "~/models/FileOperation";
|
||||
import BaseStore, { RPCAction } from "./BaseStore";
|
||||
import RootStore from "./RootStore";
|
||||
@@ -15,7 +16,8 @@ export default class FileOperationsStore extends BaseStore<FileOperation> {
|
||||
get imports(): FileOperation[] {
|
||||
return orderBy(
|
||||
Array.from(this.data.values()).reduce(
|
||||
(acc, fileOp) => (fileOp.type === "import" ? [...acc, fileOp] : acc),
|
||||
(acc, fileOp) =>
|
||||
fileOp.type === FileOperationType.Import ? [...acc, fileOp] : acc,
|
||||
[]
|
||||
),
|
||||
"createdAt",
|
||||
@@ -27,7 +29,8 @@ export default class FileOperationsStore extends BaseStore<FileOperation> {
|
||||
get exports(): FileOperation[] {
|
||||
return orderBy(
|
||||
Array.from(this.data.values()).reduce(
|
||||
(acc, fileOp) => (fileOp.type === "export" ? [...acc, fileOp] : acc),
|
||||
(acc, fileOp) =>
|
||||
fileOp.type === FileOperationType.Export ? [...acc, fileOp] : acc,
|
||||
[]
|
||||
),
|
||||
"createdAt",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { Attachment, Event, User } from "@server/models";
|
||||
import { uploadToS3FromBuffer } from "@server/utils/s3";
|
||||
import { uploadToS3 } from "@server/utils/s3";
|
||||
|
||||
export default async function attachmentCreator({
|
||||
id,
|
||||
@@ -24,7 +24,13 @@ export default async function attachmentCreator({
|
||||
}) {
|
||||
const key = `uploads/${user.id}/${uuidv4()}/${name}`;
|
||||
const acl = process.env.AWS_S3_ACL || "private";
|
||||
const url = await uploadToS3FromBuffer(buffer, type, key, acl);
|
||||
const url = await uploadToS3({
|
||||
body: buffer,
|
||||
contentType: type,
|
||||
contentLength: buffer.length,
|
||||
key,
|
||||
acl,
|
||||
});
|
||||
const attachment = await Attachment.create(
|
||||
{
|
||||
id,
|
||||
|
||||
@@ -1,33 +1,37 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { APM } from "@server/logging/tracing";
|
||||
import { Collection, Event, Team, User, FileOperation } from "@server/models";
|
||||
import {
|
||||
FileOperationFormat,
|
||||
FileOperationType,
|
||||
FileOperationState,
|
||||
FileOperationFormat,
|
||||
} from "@server/models/FileOperation";
|
||||
} from "@shared/types";
|
||||
import { APM } from "@server/logging/tracing";
|
||||
import { Collection, Event, Team, User, FileOperation } from "@server/models";
|
||||
import { getAWSKeyForFileOp } from "@server/utils/s3";
|
||||
|
||||
type Props = {
|
||||
collection?: Collection;
|
||||
team: Team;
|
||||
user: User;
|
||||
format?: FileOperationFormat;
|
||||
ip: string;
|
||||
transaction: Transaction;
|
||||
};
|
||||
|
||||
async function collectionExporter({
|
||||
collection,
|
||||
team,
|
||||
user,
|
||||
format = FileOperationFormat.MarkdownZip,
|
||||
ip,
|
||||
transaction,
|
||||
}: {
|
||||
collection?: Collection;
|
||||
team: Team;
|
||||
user: User;
|
||||
ip: string;
|
||||
transaction: Transaction;
|
||||
}) {
|
||||
}: Props) {
|
||||
const collectionId = collection?.id;
|
||||
const key = getAWSKeyForFileOp(user.teamId, collection?.name || team.name);
|
||||
const fileOperation = await FileOperation.create(
|
||||
{
|
||||
type: FileOperationType.Export,
|
||||
state: FileOperationState.Creating,
|
||||
format: FileOperationFormat.MarkdownZip,
|
||||
format,
|
||||
key,
|
||||
url: null,
|
||||
size: 0,
|
||||
@@ -49,7 +53,8 @@ async function collectionExporter({
|
||||
collectionId,
|
||||
ip,
|
||||
data: {
|
||||
type: FileOperationType.Import,
|
||||
type: FileOperationType.Export,
|
||||
format,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -8,6 +8,11 @@ import {
|
||||
Table,
|
||||
DataType,
|
||||
} from "sequelize-typescript";
|
||||
import {
|
||||
FileOperationFormat,
|
||||
FileOperationState,
|
||||
FileOperationType,
|
||||
} from "@shared/types";
|
||||
import { deleteFromS3, getFileByKey } from "@server/utils/s3";
|
||||
import Collection from "./Collection";
|
||||
import Team from "./Team";
|
||||
@@ -15,24 +20,6 @@ import User from "./User";
|
||||
import IdModel from "./base/IdModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
export enum FileOperationType {
|
||||
Import = "import",
|
||||
Export = "export",
|
||||
}
|
||||
|
||||
export enum FileOperationFormat {
|
||||
MarkdownZip = "outline-markdown",
|
||||
Notion = "notion",
|
||||
}
|
||||
|
||||
export enum FileOperationState {
|
||||
Creating = "creating",
|
||||
Uploading = "uploading",
|
||||
Complete = "complete",
|
||||
Error = "error",
|
||||
Expired = "expired",
|
||||
}
|
||||
|
||||
@DefaultScope(() => ({
|
||||
include: [
|
||||
{
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import invariant from "invariant";
|
||||
import { FileOperationFormat, FileOperationType } from "@shared/types";
|
||||
import { FileOperation } from "@server/models";
|
||||
import {
|
||||
FileOperationFormat,
|
||||
FileOperationType,
|
||||
} from "@server/models/FileOperation";
|
||||
import { Event as TEvent, FileOperationEvent } from "@server/types";
|
||||
import ExportHTMLZipTask from "../tasks/ExportHTMLZipTask";
|
||||
import ExportMarkdownZipTask from "../tasks/ExportMarkdownZipTask";
|
||||
import ImportMarkdownZipTask from "../tasks/ImportMarkdownZipTask";
|
||||
import ImportNotionTask from "../tasks/ImportNotionTask";
|
||||
@@ -40,6 +38,11 @@ export default class FileOperationsProcessor extends BaseProcessor {
|
||||
|
||||
if (fileOperation.type === FileOperationType.Export) {
|
||||
switch (fileOperation.format) {
|
||||
case FileOperationFormat.HTMLZip:
|
||||
await ExportHTMLZipTask.schedule({
|
||||
fileOperationId: event.modelId,
|
||||
});
|
||||
break;
|
||||
case FileOperationFormat.MarkdownZip:
|
||||
await ExportMarkdownZipTask.schedule({
|
||||
fileOperationId: event.modelId,
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { subDays } from "date-fns";
|
||||
import { FileOperationState, FileOperationType } from "@shared/types";
|
||||
import { FileOperation } from "@server/models";
|
||||
import {
|
||||
FileOperationState,
|
||||
FileOperationType,
|
||||
} from "@server/models/FileOperation";
|
||||
import { buildFileOperation } from "@server/test/factories";
|
||||
import { setupTestDatabase } from "@server/test/support";
|
||||
import CleanupExpiredFileOperationsTask from "./CleanupExpiredFileOperationsTask";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { subDays } from "date-fns";
|
||||
import { Op } from "sequelize";
|
||||
import { FileOperationState } from "@shared/types";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { FileOperation } from "@server/models";
|
||||
import { FileOperationState } from "@server/models/FileOperation";
|
||||
import BaseTask, { TaskPriority } from "./BaseTask";
|
||||
|
||||
type Props = {
|
||||
|
||||
10
server/queues/tasks/ExportHTMLZipTask.ts
Normal file
10
server/queues/tasks/ExportHTMLZipTask.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { FileOperationFormat } from "@shared/types";
|
||||
import { Collection } from "@server/models";
|
||||
import { archiveCollections } from "@server/utils/zip";
|
||||
import ExportTask from "./ExportTask";
|
||||
|
||||
export default class ExportHTMLZipTask extends ExportTask {
|
||||
public async export(collections: Collection[]) {
|
||||
return await archiveCollections(collections, FileOperationFormat.HTMLZip);
|
||||
}
|
||||
}
|
||||
@@ -1,130 +1,13 @@
|
||||
import fs from "fs";
|
||||
import { truncate } from "lodash";
|
||||
import ExportFailureEmail from "@server/emails/templates/ExportFailureEmail";
|
||||
import ExportSuccessEmail from "@server/emails/templates/ExportSuccessEmail";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Collection, Event, FileOperation, Team, User } from "@server/models";
|
||||
import { FileOperationState } from "@server/models/FileOperation";
|
||||
import fileOperationPresenter from "@server/presenters/fileOperation";
|
||||
import { uploadToS3FromBuffer } from "@server/utils/s3";
|
||||
import { FileOperationFormat } from "@shared/types";
|
||||
import { Collection } from "@server/models";
|
||||
import { archiveCollections } from "@server/utils/zip";
|
||||
import BaseTask, { TaskPriority } from "./BaseTask";
|
||||
import ExportTask from "./ExportTask";
|
||||
|
||||
type Props = {
|
||||
fileOperationId: string;
|
||||
};
|
||||
|
||||
export default class ExportMarkdownZipTask extends BaseTask<Props> {
|
||||
/**
|
||||
* Runs the export task.
|
||||
*
|
||||
* @param props The props
|
||||
*/
|
||||
public async perform({ fileOperationId }: Props) {
|
||||
const fileOperation = await FileOperation.findByPk(fileOperationId, {
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
|
||||
const [team, user] = await Promise.all([
|
||||
Team.findByPk(fileOperation.teamId, { rejectOnEmpty: true }),
|
||||
User.findByPk(fileOperation.userId, { rejectOnEmpty: true }),
|
||||
]);
|
||||
|
||||
const collectionIds = fileOperation.collectionId
|
||||
? [fileOperation.collectionId]
|
||||
: await user.collectionIds();
|
||||
|
||||
const collections = await Collection.findAll({
|
||||
where: {
|
||||
id: collectionIds,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
Logger.info("task", `ExportTask processing data for ${fileOperationId}`);
|
||||
|
||||
await this.updateFileOperation(fileOperation, {
|
||||
state: FileOperationState.Creating,
|
||||
});
|
||||
|
||||
const filePath = await archiveCollections(collections);
|
||||
|
||||
Logger.info("task", `ExportTask uploading data for ${fileOperationId}`);
|
||||
|
||||
await this.updateFileOperation(fileOperation, {
|
||||
state: FileOperationState.Uploading,
|
||||
});
|
||||
|
||||
const fileBuffer = await fs.promises.readFile(filePath);
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
const url = await uploadToS3FromBuffer(
|
||||
fileBuffer,
|
||||
"application/zip",
|
||||
fileOperation.key,
|
||||
"private"
|
||||
);
|
||||
|
||||
await this.updateFileOperation(fileOperation, {
|
||||
size: stat.size,
|
||||
state: FileOperationState.Complete,
|
||||
url,
|
||||
});
|
||||
|
||||
await ExportSuccessEmail.schedule({
|
||||
to: user.email,
|
||||
userId: user.id,
|
||||
id: fileOperation.id,
|
||||
teamUrl: team.url,
|
||||
teamId: team.id,
|
||||
});
|
||||
} catch (error) {
|
||||
await this.updateFileOperation(fileOperation, {
|
||||
state: FileOperationState.Error,
|
||||
error,
|
||||
});
|
||||
await ExportFailureEmail.schedule({
|
||||
to: user.email,
|
||||
userId: user.id,
|
||||
teamUrl: team.url,
|
||||
teamId: team.id,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the state of the underlying FileOperation in the database and send
|
||||
* an event to the client.
|
||||
*
|
||||
* @param fileOperation The FileOperation to update
|
||||
*/
|
||||
private async updateFileOperation(
|
||||
fileOperation: FileOperation,
|
||||
options: Partial<FileOperation> & { error?: Error }
|
||||
) {
|
||||
await fileOperation.update({
|
||||
...options,
|
||||
error: options.error
|
||||
? truncate(options.error.message, { length: 255 })
|
||||
: undefined,
|
||||
});
|
||||
|
||||
await Event.schedule({
|
||||
name: "fileOperations.update",
|
||||
modelId: fileOperation.id,
|
||||
teamId: fileOperation.teamId,
|
||||
actorId: fileOperation.userId,
|
||||
data: fileOperationPresenter(fileOperation),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Job options such as priority and retry strategy, as defined by Bull.
|
||||
*/
|
||||
public get options() {
|
||||
return {
|
||||
priority: TaskPriority.Background,
|
||||
attempts: 1,
|
||||
};
|
||||
export default class ExportMarkdownZipTask extends ExportTask {
|
||||
public async export(collections: Collection[]) {
|
||||
return await archiveCollections(
|
||||
collections,
|
||||
FileOperationFormat.MarkdownZip
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
138
server/queues/tasks/ExportTask.ts
Normal file
138
server/queues/tasks/ExportTask.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import fs from "fs";
|
||||
import { truncate } from "lodash";
|
||||
import { FileOperationState } from "@shared/types";
|
||||
import ExportFailureEmail from "@server/emails/templates/ExportFailureEmail";
|
||||
import ExportSuccessEmail from "@server/emails/templates/ExportSuccessEmail";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Collection, Event, FileOperation, Team, User } from "@server/models";
|
||||
import fileOperationPresenter from "@server/presenters/fileOperation";
|
||||
import { uploadToS3 } from "@server/utils/s3";
|
||||
import BaseTask, { TaskPriority } from "./BaseTask";
|
||||
|
||||
type Props = {
|
||||
fileOperationId: string;
|
||||
};
|
||||
|
||||
export default abstract class ExportTask extends BaseTask<Props> {
|
||||
/**
|
||||
* Transforms the data to be exported, uploads, and notifies user.
|
||||
*
|
||||
* @param props The props
|
||||
*/
|
||||
public async perform({ fileOperationId }: Props) {
|
||||
Logger.info("task", `ExportTask fetching data for ${fileOperationId}`);
|
||||
const fileOperation = await FileOperation.findByPk(fileOperationId, {
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
|
||||
const [team, user] = await Promise.all([
|
||||
Team.findByPk(fileOperation.teamId, { rejectOnEmpty: true }),
|
||||
User.findByPk(fileOperation.userId, { rejectOnEmpty: true }),
|
||||
]);
|
||||
|
||||
const collectionIds = fileOperation.collectionId
|
||||
? [fileOperation.collectionId]
|
||||
: await user.collectionIds();
|
||||
|
||||
const collections = await Collection.findAll({
|
||||
where: {
|
||||
id: collectionIds,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
Logger.info("task", `ExportTask processing data for ${fileOperationId}`);
|
||||
|
||||
await this.updateFileOperation(fileOperation, {
|
||||
state: FileOperationState.Creating,
|
||||
});
|
||||
|
||||
const filePath = await this.export(collections);
|
||||
|
||||
Logger.info("task", `ExportTask uploading data for ${fileOperationId}`);
|
||||
|
||||
await this.updateFileOperation(fileOperation, {
|
||||
state: FileOperationState.Uploading,
|
||||
});
|
||||
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
const url = await uploadToS3({
|
||||
body: fs.createReadStream(filePath),
|
||||
contentLength: stat.size,
|
||||
contentType: "application/zip",
|
||||
key: fileOperation.key,
|
||||
acl: "private",
|
||||
});
|
||||
|
||||
await this.updateFileOperation(fileOperation, {
|
||||
size: stat.size,
|
||||
state: FileOperationState.Complete,
|
||||
url,
|
||||
});
|
||||
|
||||
await ExportSuccessEmail.schedule({
|
||||
to: user.email,
|
||||
userId: user.id,
|
||||
id: fileOperation.id,
|
||||
teamUrl: team.url,
|
||||
teamId: team.id,
|
||||
});
|
||||
} catch (error) {
|
||||
await this.updateFileOperation(fileOperation, {
|
||||
state: FileOperationState.Error,
|
||||
error,
|
||||
});
|
||||
await ExportFailureEmail.schedule({
|
||||
to: user.email,
|
||||
userId: user.id,
|
||||
teamUrl: team.url,
|
||||
teamId: team.id,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the data in all of the passed collections into a single Buffer.
|
||||
*
|
||||
* @param collections The collections to export
|
||||
* @returns A promise that resolves to a temporary file path
|
||||
*/
|
||||
protected abstract export(collections: Collection[]): Promise<string>;
|
||||
|
||||
/**
|
||||
* Update the state of the underlying FileOperation in the database and send
|
||||
* an event to the client.
|
||||
*
|
||||
* @param fileOperation The FileOperation to update
|
||||
*/
|
||||
private async updateFileOperation(
|
||||
fileOperation: FileOperation,
|
||||
options: Partial<FileOperation> & { error?: Error }
|
||||
) {
|
||||
await fileOperation.update({
|
||||
...options,
|
||||
error: options.error
|
||||
? truncate(options.error.message, { length: 255 })
|
||||
: undefined,
|
||||
});
|
||||
|
||||
await Event.schedule({
|
||||
name: "fileOperations.update",
|
||||
modelId: fileOperation.id,
|
||||
teamId: fileOperation.teamId,
|
||||
actorId: fileOperation.userId,
|
||||
data: fileOperationPresenter(fileOperation),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Job options such as priority and retry strategy, as defined by Bull.
|
||||
*/
|
||||
public get options() {
|
||||
return {
|
||||
priority: TaskPriority.Background,
|
||||
attempts: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { S3 } from "aws-sdk";
|
||||
import { truncate } from "lodash";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import { CollectionPermission, FileOperationState } from "@shared/types";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import attachmentCreator from "@server/commands/attachmentCreator";
|
||||
import documentCreator from "@server/commands/documentCreator";
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
FileOperation,
|
||||
Attachment,
|
||||
} from "@server/models";
|
||||
import { FileOperationState } from "@server/models/FileOperation";
|
||||
import BaseTask, { TaskPriority } from "./BaseTask";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -3,7 +3,12 @@ import invariant from "invariant";
|
||||
import Router from "koa-router";
|
||||
import { Sequelize, Op, WhereOptions } from "sequelize";
|
||||
import { randomElement } from "@shared/random";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import {
|
||||
CollectionPermission,
|
||||
FileOperationFormat,
|
||||
FileOperationState,
|
||||
FileOperationType,
|
||||
} from "@shared/types";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import { RateLimiterStrategy } from "@server/RateLimiter";
|
||||
import collectionExporter from "@server/commands/collectionExporter";
|
||||
@@ -27,11 +32,6 @@ import {
|
||||
Attachment,
|
||||
FileOperation,
|
||||
} from "@server/models";
|
||||
import {
|
||||
FileOperationFormat,
|
||||
FileOperationState,
|
||||
FileOperationType,
|
||||
} from "@server/models/FileOperation";
|
||||
import { authorize } from "@server/policies";
|
||||
import {
|
||||
presentCollection,
|
||||
@@ -576,16 +576,20 @@ router.post(
|
||||
router.post(
|
||||
"collections.export_all",
|
||||
auth(),
|
||||
rateLimiter(RateLimiterStrategy.TenPerHour),
|
||||
rateLimiter(RateLimiterStrategy.FivePerHour),
|
||||
async (ctx) => {
|
||||
const { format = FileOperationFormat.MarkdownZip } = ctx.request.body;
|
||||
const { user } = ctx.state;
|
||||
const team = await Team.findByPk(user.teamId);
|
||||
authorize(user, "createExport", team);
|
||||
|
||||
assertIn(format, Object.values(FileOperationFormat), "Invalid format");
|
||||
|
||||
const fileOperation = await sequelize.transaction(async (transaction) => {
|
||||
return collectionExporter({
|
||||
user,
|
||||
team,
|
||||
format,
|
||||
ip: ctx.request.ip,
|
||||
transaction,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { FileOperationState, FileOperationType } from "@shared/types";
|
||||
import { Collection, User, Event, FileOperation } from "@server/models";
|
||||
import {
|
||||
FileOperationState,
|
||||
FileOperationType,
|
||||
} from "@server/models/FileOperation";
|
||||
import {
|
||||
buildAdmin,
|
||||
buildCollection,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import Router from "koa-router";
|
||||
import { WhereOptions } from "sequelize/types";
|
||||
import { FileOperationType } from "@shared/types";
|
||||
import fileOperationDeleter from "@server/commands/fileOperationDeleter";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { FileOperation, Team } from "@server/models";
|
||||
import { FileOperationType } from "@server/models/FileOperation";
|
||||
import { authorize } from "@server/policies";
|
||||
import { presentFileOperation } from "@server/presenters";
|
||||
import { ContextWithState } from "@server/types";
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { isNull } from "lodash";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import {
|
||||
CollectionPermission,
|
||||
FileOperationState,
|
||||
FileOperationType,
|
||||
} from "@shared/types";
|
||||
import {
|
||||
Share,
|
||||
Team,
|
||||
@@ -21,10 +25,6 @@ import {
|
||||
ApiKey,
|
||||
Subscription,
|
||||
} from "@server/models";
|
||||
import {
|
||||
FileOperationState,
|
||||
FileOperationType,
|
||||
} from "@server/models/FileOperation";
|
||||
|
||||
let count = 1;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const uploadToS3FromBuffer = jest.fn().mockReturnValue("/endpoint/key");
|
||||
export const uploadToS3 = jest.fn().mockReturnValue("/endpoint/key");
|
||||
|
||||
export const publicS3Endpoint = jest.fn().mockReturnValue("http://mock");
|
||||
|
||||
|
||||
@@ -85,21 +85,28 @@ export const publicS3Endpoint = (isServerUpload?: boolean) => {
|
||||
}${AWS_S3_UPLOAD_BUCKET_NAME}`;
|
||||
};
|
||||
|
||||
export const uploadToS3FromBuffer = async (
|
||||
buffer: Buffer,
|
||||
contentType: string,
|
||||
key: string,
|
||||
acl: string
|
||||
) => {
|
||||
export const uploadToS3 = async ({
|
||||
body,
|
||||
contentLength,
|
||||
contentType,
|
||||
key,
|
||||
acl,
|
||||
}: {
|
||||
body: S3.Body;
|
||||
contentLength: number;
|
||||
contentType: string;
|
||||
key: string;
|
||||
acl: string;
|
||||
}) => {
|
||||
await s3
|
||||
.putObject({
|
||||
ACL: acl,
|
||||
Bucket: AWS_S3_UPLOAD_BUCKET_NAME,
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
ContentLength: buffer.length,
|
||||
ContentLength: contentLength,
|
||||
ContentDisposition: "attachment",
|
||||
Body: buffer,
|
||||
Body: body,
|
||||
})
|
||||
.promise();
|
||||
const endpoint = publicS3Endpoint(true);
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "path";
|
||||
import JSZip, { JSZipObject } from "jszip";
|
||||
import { find } from "lodash";
|
||||
import tmp from "tmp";
|
||||
import { FileOperationFormat } from "@shared/types";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import Attachment from "@server/models/Attachment";
|
||||
@@ -26,9 +27,21 @@ export type Item = {
|
||||
item: JSZipObject;
|
||||
};
|
||||
|
||||
export type FileTreeNode = {
|
||||
/** The title, extracted from the file name */
|
||||
title: string;
|
||||
/** The file name including extension */
|
||||
name: string;
|
||||
/** The full path to within the zip file */
|
||||
path: string;
|
||||
/** The nested children */
|
||||
children: FileTreeNode[];
|
||||
};
|
||||
|
||||
async function addDocumentTreeToArchive(
|
||||
zip: JSZip,
|
||||
documents: NavigationNode[]
|
||||
documents: NavigationNode[],
|
||||
format = FileOperationFormat.MarkdownZip
|
||||
) {
|
||||
for (const doc of documents) {
|
||||
const document = await Document.findByPk(doc.id);
|
||||
@@ -37,7 +50,10 @@ async function addDocumentTreeToArchive(
|
||||
continue;
|
||||
}
|
||||
|
||||
let text = DocumentHelper.toMarkdown(document);
|
||||
let text =
|
||||
format === FileOperationFormat.HTMLZip
|
||||
? await DocumentHelper.toHTML(document)
|
||||
: await DocumentHelper.toMarkdown(document);
|
||||
const attachments = await Attachment.findAll({
|
||||
where: {
|
||||
teamId: document.teamId,
|
||||
@@ -52,7 +68,9 @@ async function addDocumentTreeToArchive(
|
||||
|
||||
let title = serializeFilename(document.title) || "Untitled";
|
||||
|
||||
title = safeAddFileToArchive(zip, `${title}.md`, text, {
|
||||
const extension = format === FileOperationFormat.HTMLZip ? "html" : "md";
|
||||
|
||||
title = safeAddFileToArchive(zip, `${title}.${extension}`, text, {
|
||||
date: document.updatedAt,
|
||||
comment: JSON.stringify({
|
||||
createdAt: document.createdAt,
|
||||
@@ -161,7 +179,10 @@ async function archiveToPath(zip: JSZip): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function archiveCollections(collections: Collection[]) {
|
||||
export async function archiveCollections(
|
||||
collections: Collection[],
|
||||
format: FileOperationFormat
|
||||
) {
|
||||
const zip = new JSZip();
|
||||
|
||||
for (const collection of collections) {
|
||||
@@ -169,7 +190,11 @@ export async function archiveCollections(collections: Collection[]) {
|
||||
const folder = zip.folder(serializeFilename(collection.name));
|
||||
|
||||
if (folder) {
|
||||
await addDocumentTreeToArchive(folder, collection.documentStructure);
|
||||
await addDocumentTreeToArchive(
|
||||
folder,
|
||||
collection.documentStructure,
|
||||
format
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,17 +202,6 @@ export async function archiveCollections(collections: Collection[]) {
|
||||
return archiveToPath(zip);
|
||||
}
|
||||
|
||||
export type FileTreeNode = {
|
||||
/** The title, extracted from the file name */
|
||||
title: string;
|
||||
/** The file name including extension */
|
||||
name: string;
|
||||
/** The full path to within the zip file */
|
||||
path: string;
|
||||
/** The nested children */
|
||||
children: FileTreeNode[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts the flat structure returned by JSZIP into a nested file structure
|
||||
* for easier processing.
|
||||
|
||||
@@ -627,6 +627,8 @@
|
||||
"Please choose a single file to import": "Please choose a single file to import",
|
||||
"Your import is being processed, you can safely leave this page": "Your import is being processed, you can safely leave this page",
|
||||
"File not supported – please upload a valid ZIP file": "File not supported – please upload a valid ZIP file",
|
||||
"A ZIP file containing the images, and documents in the Markdown format.": "A ZIP file containing the images, and documents in the Markdown format.",
|
||||
"A ZIP file containing the images, and documents as HTML files.": "A ZIP file containing the images, and documents as HTML files.",
|
||||
"Completed": "Completed",
|
||||
"Processing": "Processing",
|
||||
"Expired": "Expired",
|
||||
@@ -691,12 +693,9 @@
|
||||
"Choose a subdomain to enable a login page just for your team.": "Choose a subdomain to enable a login page just for your team.",
|
||||
"Start view": "Start view",
|
||||
"This is the screen that workspace members will first see when they sign in.": "This is the screen that workspace members will first see when they sign in.",
|
||||
"Export in progress…": "Export in progress…",
|
||||
"Export data": "Export data",
|
||||
"Export deleted": "Export deleted",
|
||||
"A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started – if you have notifications enabled, we will email a link to <em>{{ userEmail }}</em> when it’s complete.": "A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started – if you have notifications enabled, we will email a link to <em>{{ userEmail }}</em> when it’s complete.",
|
||||
"Export Requested": "Export Requested",
|
||||
"Requesting Export": "Requesting Export",
|
||||
"Export Data": "Export Data",
|
||||
"Recent exports": "Recent exports",
|
||||
"Manage optional and beta features. Changing these settings will affect the experience for all members of the workspace.": "Manage optional and beta features. Changing these settings will affect the experience for all members of the workspace.",
|
||||
"Seamless editing": "Seamless editing",
|
||||
|
||||
@@ -7,6 +7,32 @@ export enum Client {
|
||||
Desktop = "desktop",
|
||||
}
|
||||
|
||||
export enum ExportContentType {
|
||||
Markdown = "text/markdown",
|
||||
Html = "text/html",
|
||||
Pdf = "application/pdf",
|
||||
}
|
||||
|
||||
export enum FileOperationFormat {
|
||||
MarkdownZip = "outline-markdown",
|
||||
HTMLZip = "html",
|
||||
PDFZip = "pdf",
|
||||
Notion = "notion",
|
||||
}
|
||||
|
||||
export enum FileOperationType {
|
||||
Import = "import",
|
||||
Export = "export",
|
||||
}
|
||||
|
||||
export enum FileOperationState {
|
||||
Creating = "creating",
|
||||
Uploading = "uploading",
|
||||
Complete = "complete",
|
||||
Error = "error",
|
||||
Expired = "expired",
|
||||
}
|
||||
|
||||
export type PublicEnv = {
|
||||
URL: string;
|
||||
CDN_URL: string;
|
||||
|
||||
Reference in New Issue
Block a user