feat: Bulk HTML export for collection

This commit is contained in:
Tom Moor
2022-12-30 20:13:29 -05:00
parent 1328162921
commit 7a1e6a1b73
10 changed files with 74 additions and 101 deletions

View File

@@ -4,23 +4,31 @@ import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import { FileOperationFormat } from "@shared/types";
import Collection from "~/models/Collection";
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";
import useToasts from "~/hooks/useToasts";
type Props = {
collection?: Collection;
onSubmit: () => void;
};
function ExportDialog({ onSubmit }: Props) {
function ExportDialog({ collection, onSubmit }: Props) {
const [format, setFormat] = React.useState<FileOperationFormat>(
FileOperationFormat.MarkdownZip
);
const { collections } = useStores();
const { showToast } = useToasts();
const { collections, notificationSettings } = useStores();
const { t } = useTranslation();
React.useEffect(() => {
notificationSettings.fetchPage({});
}, [notificationSettings]);
const handleFormatChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setFormat(ev.target.value as FileOperationFormat);
@@ -28,13 +36,33 @@ function ExportDialog({ onSubmit }: Props) {
[]
);
const handleSubmit = React.useCallback(async () => {
await collections.export(format);
const handleSubmit = async () => {
if (collection) {
await collection.export(format);
} else {
await collections.export(format);
}
onSubmit();
}, [collections, format, onSubmit]);
showToast(t("Export started"), { type: "success" });
};
return (
<ConfirmationDialog onSubmit={handleSubmit} submitText={t("Export")}>
{collection && (
<Text>
<Trans
defaults="Exporting the collection <em>{{collectionName}}</em> may take some time."
values={{
collectionName: collection.name,
}}
components={{
em: <strong />,
}}
/>{" "}
{notificationSettings.getByEvent("emails.export_completed") &&
t("You will receive an email when it's complete.")}
</Text>
)}
<Flex gap={12} column>
<Option>
<Input

View File

@@ -16,11 +16,11 @@ import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import { getEventFiles } from "@shared/utils/files";
import Collection from "~/models/Collection";
import CollectionExport from "~/scenes/CollectionExport";
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
import ContextMenu, { Placement } from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import ExportDialog from "~/components/ExportDialog";
import { actionToMenuItem } from "~/actions";
import {
editCollection,
@@ -67,7 +67,7 @@ function CollectionMenu({
title: t("Export collection"),
isCentered: true,
content: (
<CollectionExport
<ExportDialog
collection={collection}
onSubmit={dialogs.closeAllModals}
/>

View File

@@ -1,6 +1,6 @@
import { trim } from "lodash";
import { action, computed, observable } from "mobx";
import { CollectionPermission } from "@shared/types";
import { CollectionPermission, FileOperationFormat } from "@shared/types";
import { sortNavigationNodes } from "@shared/utils/collections";
import CollectionsStore from "~/stores/CollectionsStore";
import Document from "~/models/Document";
@@ -215,9 +215,10 @@ export default class Collection extends ParanoidModel {
return this.store.unstar(this);
};
export = () => {
export = (format: FileOperationFormat) => {
return client.post("/collections.export", {
id: this.id,
format,
});
};
}

View File

@@ -1,65 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import useToasts from "~/hooks/useToasts";
type Props = {
collection: Collection;
onSubmit: () => void;
};
function CollectionExport({ collection, onSubmit }: Props) {
const [isLoading, setIsLoading] = React.useState(false);
const { t } = useTranslation();
const { showToast } = useToasts();
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setIsLoading(true);
await collection.export();
setIsLoading(false);
showToast(
t(
"Export started. If you have notifications enabled, you will receive an email when it's complete."
)
);
onSubmit();
},
[collection, onSubmit, showToast, t]
);
return (
<Flex column>
<form onSubmit={handleSubmit}>
<Text type="secondary">
<Trans
defaults="Exporting the collection <em>{{collectionName}}</em> may take some time."
values={{
collectionName: collection.name,
}}
components={{
em: <strong />,
}}
/>
</Text>
<Text type="secondary">
<Trans>
Your documents will be a zip of folders with files in Markdown
format. Please visit the Export section in Settings to get the zip.
</Trans>
</Text>
<Button type="submit" disabled={isLoading}>
{isLoading ? `${t("Exporting")}` : t("Export collection")}
</Button>
</form>
</Flex>
);
}
export default observer(CollectionExport);

View File

@@ -11,7 +11,7 @@ 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 ExportDialog from "../../components/ExportDialog";
import FileOperationListItem from "./components/FileOperationListItem";
function Export() {

View File

@@ -3,7 +3,11 @@ 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 {
FileOperationFormat,
FileOperationState,
FileOperationType,
} from "@shared/types";
import FileOperation from "~/models/FileOperation";
import { Action } from "~/components/Actions";
import ListItem from "~/components/List/Item";
@@ -22,34 +26,33 @@ const FileOperationListItem = ({ fileOperation, handleDelete }: Props) => {
const user = useCurrentUser();
const theme = useTheme();
const stateMapping = {
complete: t("Completed"),
creating: t("Processing"),
expired: t("Expired"),
uploading: t("Processing"),
error: t("Failed"),
[FileOperationState.Creating]: t("Processing"),
[FileOperationState.Uploading]: t("Processing"),
[FileOperationState.Expired]: t("Expired"),
[FileOperationState.Complete]: t("Completed"),
[FileOperationState.Error]: t("Failed"),
};
const iconMapping = {
creating: <Spinner />,
uploading: <Spinner />,
expired: <ArchiveIcon color={theme.textTertiary} />,
complete: <DoneIcon color={theme.primary} />,
error: <WarningIcon color={theme.danger} />,
[FileOperationState.Creating]: <Spinner />,
[FileOperationState.Uploading]: <Spinner />,
[FileOperationState.Expired]: <ArchiveIcon color={theme.textTertiary} />,
[FileOperationState.Complete]: <DoneIcon color={theme.primary} />,
[FileOperationState.Error]: <WarningIcon color={theme.danger} />,
};
const formatToReadable = {
const formatMapping = {
[FileOperationFormat.MarkdownZip]: "Markdown",
[FileOperationFormat.HTMLZip]: "HTML",
[FileOperationFormat.PDFZip]: "PDF",
};
const format = formatToReadable[fileOperation.format];
const format = formatMapping[fileOperation.format];
const title =
fileOperation.type === FileOperationType.Import ||
fileOperation.collectionId
? fileOperation.name
: t("All collections") + (format ? `${format}` : "");
: t("All collections");
return (
<ListItem
@@ -67,11 +70,12 @@ const FileOperationListItem = ({ fileOperation, handleDelete }: Props) => {
})}
&nbsp;
<Time dateTime={fileOperation.createdAt} addSuffix shorten />
{format ? <>&nbsp;&nbsp;{format}</> : ""}
&nbsp;&nbsp;{fileOperation.sizeInMB}
</>
}
actions={
fileOperation.state === "complete" && handleDelete ? (
fileOperation.state === FileOperationState.Complete && handleDelete ? (
<Action>
<FileOperationMenu
id={fileOperation.id}

View File

@@ -1,7 +1,7 @@
import invariant from "invariant";
import { concat, find, last } from "lodash";
import { computed, action } from "mobx";
import { CollectionPermission } from "@shared/types";
import { CollectionPermission, FileOperationFormat } from "@shared/types";
import Collection from "~/models/Collection";
import { NavigationNode } from "~/types";
import { client } from "~/utils/ApiClient";
@@ -215,7 +215,7 @@ export default class CollectionsStore extends BaseStore<Collection> {
this.rootStore.documents.fetchRecentlyViewed();
};
export = (format: string) => {
export = (format: FileOperationFormat) => {
return client.post("/collections.export_all", {
format,
});

View File

@@ -544,7 +544,11 @@ router.post(
rateLimiter(RateLimiterStrategy.TenPerHour),
async (ctx) => {
const { id } = ctx.request.body;
const { format = FileOperationFormat.MarkdownZip } = ctx.request.body;
assertUuid(id, "id is required");
assertIn(format, Object.values(FileOperationFormat), "Invalid format");
const { user } = ctx.state;
const team = await Team.findByPk(user.teamId);
authorize(user, "createExport", team);
@@ -559,6 +563,7 @@ router.post(
collection,
user,
team,
format,
ip: ctx.request.ip,
transaction,
});

View File

@@ -52,7 +52,7 @@ async function addDocumentTreeToArchive(
let text =
format === FileOperationFormat.HTMLZip
? await DocumentHelper.toHTML(document)
? await DocumentHelper.toHTML(document, { centered: true })
: await DocumentHelper.toMarkdown(document);
const attachments = await Attachment.findAll({
where: {

View File

@@ -162,6 +162,12 @@
"{{userName}} published": "{{userName}} published",
"{{userName}} unpublished": "{{userName}} unpublished",
"{{userName}} moved": "{{userName}} moved",
"Export started": "Export started",
"Export": "Export",
"Exporting the collection <em>{{collectionName}}</em> may take some time.": "Exporting the collection <em>{{collectionName}}</em> may take some time.",
"You will receive an email when it's complete.": "You will receive an email when it's complete.",
"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.",
"Icon": "Icon",
"Show menu": "Show menu",
"Choose icon": "Choose icon",
@@ -225,7 +231,6 @@
"Groups": "Groups",
"Shared Links": "Shared Links",
"Import": "Import",
"Export": "Export",
"Webhooks": "Webhooks",
"Integrations": "Integrations",
"Self Hosted": "Self Hosted",
@@ -370,9 +375,6 @@
"Name": "Name",
"Sort": "Sort",
"Save": "Save",
"Export started. If you have notifications enabled, you will receive an email when it's complete.": "Export started. If you have notifications enabled, you will receive an email when it's complete.",
"Exporting the collection <em>{{collectionName}}</em> may take some time.": "Exporting the collection <em>{{collectionName}}</em> may take some time.",
"Your documents will be a zip of folders with files in Markdown format. Please visit the Export section in Settings to get the zip.": "Your documents will be a zip of folders with files in Markdown format. Please visit the Export section in Settings to get the zip.",
"Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.": "Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.",
"This is the default level of access, you can give individual users or groups more access once the collection is created.": "This is the default level of access, you can give individual users or groups more access once the collection is created.",
"Public document sharing": "Public document sharing",
@@ -627,11 +629,9 @@
"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",
"Completed": "Completed",
"Failed": "Failed",
"All collections": "All collections",
"{{userName}} requested": "{{userName}} requested",