feat: Bulk HTML export for collection
This commit is contained in:
@@ -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
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) => {
|
||||
})}
|
||||
|
||||
<Time dateTime={fileOperation.createdAt} addSuffix shorten />
|
||||
{format ? <> • {format}</> : ""}
|
||||
• {fileOperation.sizeInMB}
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
fileOperation.state === "complete" && handleDelete ? (
|
||||
fileOperation.state === FileOperationState.Complete && handleDelete ? (
|
||||
<Action>
|
||||
<FileOperationMenu
|
||||
id={fileOperation.id}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user