feat: Bulk HTML export (#4620)

* wip

* Working bulk html export

* Refactor

* test

* test
This commit is contained in:
Tom Moor
2022-12-30 17:42:20 +00:00
committed by GitHub
parent 1b8dd9399c
commit f3469d25fe
32 changed files with 485 additions and 258 deletions

View File

@@ -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);
},
});

View File

@@ -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>

View 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>
);
}

View File

@@ -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();

View File

@@ -21,7 +21,7 @@ export default function InputSelectPermission(
value = "";
}
onChange(value);
onChange?.(value);
},
[onChange]
);

View File

@@ -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,

View File

@@ -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;

View File

@@ -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

View File

@@ -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>

View 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);

View File

@@ -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

View File

@@ -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,
});
};
}

View File

@@ -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",