diff --git a/app/scenes/Settings/ImportExport.js b/app/scenes/Settings/ImportExport.js index 69460a79e..6d4686d50 100644 --- a/app/scenes/Settings/ImportExport.js +++ b/app/scenes/Settings/ImportExport.js @@ -6,20 +6,47 @@ import Button from "components/Button"; import CenteredContent from "components/CenteredContent"; import HelpText from "components/HelpText"; import PageTitle from "components/PageTitle"; +import VisuallyHidden from "components/VisuallyHidden"; import useCurrentUser from "hooks/useCurrentUser"; import useStores from "hooks/useStores"; +import getDataTransferFiles from "utils/getDataTransferFiles"; function ImportExport() { const { t } = useTranslation(); const user = useCurrentUser(); - const { ui, collections } = useStores(); + const fileRef = React.useRef(); + const { ui, collections, documents } = useStores(); const { showToast } = ui; const [isLoading, setLoading] = React.useState(false); + const [isImporting, setImporting] = React.useState(false); const [isExporting, setExporting] = React.useState(false); - const handleImport = React.useCallback(async () => { - // TODO - }, []); + const handleFilePicked = React.useCallback( + async (ev) => { + const files = getDataTransferFiles(ev); + setImporting(true); + + try { + const file = files[0]; + await documents.batchImport(file); + showToast(t("Import completed")); + } catch (err) { + showToast(err.message); + } finally { + if (fileRef.current) { + fileRef.current.value = ""; + } + setImporting(false); + } + }, + [t, documents, showToast] + ); + + const handleImport = React.useCallback(() => { + if (fileRef.current) { + fileRef.current.click(); + } + }, [fileRef]); const handleExport = React.useCallback( async (ev: SyntheticEvent<>) => { @@ -43,11 +70,26 @@ function ImportExport() {

{t("Import")}

- It is possible to import a zip file of folders and Markdown files. + It is possible to import a zip file of folders and Markdown files + previously exported from an Outline instance. Support will soon be + added for importing from other services. -

{t("Export")}

diff --git a/app/stores/DocumentsStore.js b/app/stores/DocumentsStore.js index 4d4e77a0c..2d845255b 100644 --- a/app/stores/DocumentsStore.js +++ b/app/stores/DocumentsStore.js @@ -497,6 +497,15 @@ export default class DocumentsStore extends BaseStore { return this.add(res.data); }; + @action + batchImport = async (file: File) => { + const formData = new FormData(); + formData.append("type", "outline"); + formData.append("file", file); + + await client.post("/documents.batchImport", formData); + }; + @action import = async ( file: File, diff --git a/server/commands/documentBatchImporter.js b/server/commands/documentBatchImporter.js index aa1f12096..0f1628d1c 100644 --- a/server/commands/documentBatchImporter.js +++ b/server/commands/documentBatchImporter.js @@ -5,6 +5,7 @@ import File from "formidable/lib/file"; import invariant from "invariant"; import JSZip from "jszip"; import { values, keys } from "lodash"; +import { InvalidRequestError } from "../errors"; import { Attachment, Document, Collection, User } from "../models"; import attachmentCreator from "./attachmentCreator"; import documentCreator from "./documentCreator"; @@ -33,20 +34,31 @@ export default async function documentBatchImporter({ // this is so we can use async / await a little easier let folders = []; zip.forEach(async function (path, item) { + // known skippable items + if (path.startsWith("__MACOSX") || path.endsWith(".DS_Store")) { + return; + } + folders.push([path, item]); }); + for (const [rawPath, item] of folders) { + const itemPath = rawPath.replace(/\/$/, ""); + const depth = itemPath.split("/").length - 1; + + if (depth === 0 && !item.dir) { + throw new InvalidRequestError( + "Root of zip file must only contain folders representing collections" + ); + } + } + for (const [rawPath, item] of folders) { const itemPath = rawPath.replace(/\/$/, ""); const itemDir = path.dirname(itemPath); const name = path.basename(item.name); const depth = itemPath.split("/").length - 1; - // known skippable items - if (itemPath.startsWith("__MACOSX") || itemPath.endsWith(".DS_Store")) { - continue; - } - if (depth === 0 && item.dir && name) { // check if collection with name exists let [collection, isCreated] = await Collection.findOrCreate({ @@ -142,12 +154,17 @@ export default async function documentBatchImporter({ const attachment = attachments[attachmentPath]; for (const document of values(documents)) { + // pull the collection out of the path name + const pathParts = attachmentPath.split("/"); + const normalizedAttachmentPath = pathParts.splice(1).join("/"); + document.text = document.text .replace(attachmentPath, attachment.redirectUrl) - .replace(`/${attachmentPath}`, attachment.redirectUrl); + .replace(normalizedAttachmentPath, attachment.redirectUrl) + .replace(`/${normalizedAttachmentPath}`, attachment.redirectUrl); // does nothing if the document text is unchanged - await document.save(); + await document.save({ fields: ["text"] }); } } diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 7dc40db3b..54f26f60f 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -275,6 +275,16 @@ "Use the <1>{{meta}}+K shortcut to search from anywhere in your knowledge base": "Use the <1>{{meta}}+K shortcut to search from anywhere in your knowledge base", "No documents found for your search filters. <1>Create a new document?": "No documents found for your search filters. <1>Create a new document?", "Clear filters": "Clear filters", + "Import completed": "Import completed", + "Export in progress…": "Export in progress…", + "Import": "Import", + "It is possible to import a zip file of folders and Markdown files previously exported from an Outline instance. We’ll soon add support for importing from other services.": "It is possible to import a zip file of folders and Markdown files previously exported from an Outline instance. We’ll soon add support for importing from other services.", + "Importing": "Importing", + "Import Data": "Import Data", + "A full export might take some time, consider exporting a single document or collection if possible. We’ll put together a zip of all your documents in Markdown format and email it to <2>{{userEmail}}.": "A full export might take some time, consider exporting a single document or collection if possible. We’ll put together a zip of all your documents in Markdown format and email it to <2>{{userEmail}}.", + "Export Requested": "Export Requested", + "Requesting Export": "Requesting Export", + "Export Data": "Export Data", "Profile saved": "Profile saved", "Profile picture updated": "Profile picture updated", "Unable to upload new profile picture": "Unable to upload new profile picture",