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("Import")}
+
+
+
+
+ {isImporting ? `${t("Importing")}…` : t("Import Data")}
{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}}+K1> shortcut to search from anywhere in your knowledge base": "Use the <1>{{meta}}+K1> shortcut to search from anywhere in your knowledge base",
"No documents found for your search filters. <1>1>Create a new document?": "No documents found for your search filters. <1>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}}2>.": "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}}2>.",
+ "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",