mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-02-24 04:00:59 +01:00
feat: add notesnook-importer support
This commit is contained in:
@@ -24,6 +24,7 @@
|
||||
"event-source-polyfill": "^1.0.25",
|
||||
"fast-sort": "^2.1.1",
|
||||
"fetch-jsonp": "^1.2.1",
|
||||
"fflate": "^0.7.2",
|
||||
"file-saver": "^2.0.5",
|
||||
"framer-motion": "^4.1.17",
|
||||
"hash-wasm": "^4.9.0",
|
||||
@@ -42,6 +43,7 @@
|
||||
"rc-scrollbars": "^1.1.3",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-dropzone": "^11.4.2",
|
||||
"react-modal": "^3.12.1",
|
||||
"react-qrcode-logo": "^2.2.1",
|
||||
"react-scripts": "^4.0.3",
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -576,3 +576,9 @@ export function showIssueDialog() {
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
export function showImportDialog() {
|
||||
return showDialog((Dialogs, perform) => (
|
||||
<Dialogs.ImportDialog onClose={(res) => perform(res)} />
|
||||
));
|
||||
}
|
||||
|
||||
46
apps/web/src/components/accordion/index.js
Normal file
46
apps/web/src/components/accordion/index.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Flex, Text } from "rebass";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ChevronDown, ChevronUp } from "../icons";
|
||||
|
||||
export default function Accordion({
|
||||
title,
|
||||
children,
|
||||
sx,
|
||||
color,
|
||||
isClosed = true,
|
||||
...restProps
|
||||
}) {
|
||||
const [isContentHidden, setIsContentHidden] = useState();
|
||||
|
||||
useEffect(() => {
|
||||
setIsContentHidden(isClosed);
|
||||
}, [isClosed]);
|
||||
|
||||
return (
|
||||
<Flex sx={{ flexDirection: "column", ...sx }} {...restProps}>
|
||||
<Flex
|
||||
sx={{
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
bg: "bgSecondary",
|
||||
p: 1,
|
||||
borderRadius: "default",
|
||||
}}
|
||||
onClick={() => {
|
||||
setIsContentHidden((state) => !state);
|
||||
}}
|
||||
>
|
||||
<Text variant="subtitle" sx={{ color }}>
|
||||
{title}
|
||||
</Text>
|
||||
{isContentHidden ? (
|
||||
<ChevronDown size={16} color={color} />
|
||||
) : (
|
||||
<ChevronUp size={16} color={color} />
|
||||
)}
|
||||
</Flex>
|
||||
{!isContentHidden && children}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
213
apps/web/src/components/dialogs/import-dialog.js
Normal file
213
apps/web/src/components/dialogs/import-dialog.js
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Flex, Text } from "rebass";
|
||||
import * as Icon from "../icons";
|
||||
import Dialog from "./dialog";
|
||||
import { db } from "../../common/db";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { Input } from "@rebass/forms";
|
||||
import Accordion from "../accordion";
|
||||
import { store as appStore } from "../../stores/app-store";
|
||||
import { Importer } from "../../utils/importer";
|
||||
|
||||
function ImportDialog(props) {
|
||||
const [progress, setProgress] = useState({
|
||||
total: 0,
|
||||
current: 0,
|
||||
done: false,
|
||||
});
|
||||
const [files, setFiles] = useState([]);
|
||||
const [notes, setNotes] = useState([]);
|
||||
const [errors, setErrors] = useState([]);
|
||||
const selectedNotes = useMemo(() => notes.filter((n) => n.selected), [notes]);
|
||||
|
||||
const onDrop = useCallback((acceptedFiles) => {
|
||||
setFiles((files) => {
|
||||
const newFiles = [...acceptedFiles, ...files];
|
||||
return newFiles;
|
||||
});
|
||||
}, []);
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: [".zip"],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const notes = await Importer.getNotesFromImport(files);
|
||||
setNotes(
|
||||
notes.map((note) => {
|
||||
note.selected = true;
|
||||
return note;
|
||||
})
|
||||
);
|
||||
})();
|
||||
}, [files]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
title={"Import notes"}
|
||||
description="Notesnook Importer allows you to import your notes from any notes app into Notesnook"
|
||||
onClose={props.onClose}
|
||||
negativeButton={
|
||||
progress.done
|
||||
? undefined
|
||||
: {
|
||||
text: "Cancel",
|
||||
onClick: props.onClose,
|
||||
}
|
||||
}
|
||||
positiveButton={
|
||||
progress.done
|
||||
? {
|
||||
onClick: props.onClose,
|
||||
text: "Close",
|
||||
}
|
||||
: {
|
||||
onClick: async () => {
|
||||
await db.syncer.acquireLock(async () => {
|
||||
setProgress({ total: selectedNotes.length, current: 0 });
|
||||
for (let note of selectedNotes) {
|
||||
try {
|
||||
await Importer.importNote(note);
|
||||
} catch (e) {
|
||||
e.message = `${e.message} (${note.title})`;
|
||||
setErrors((errors) => [...errors, e]);
|
||||
} finally {
|
||||
setProgress((p) => ({ ...p, current: ++p.current }));
|
||||
}
|
||||
}
|
||||
await appStore.refresh();
|
||||
setProgress({ done: true });
|
||||
});
|
||||
},
|
||||
text:
|
||||
notes.length > 0
|
||||
? `Import ${selectedNotes.length} notes`
|
||||
: "Import",
|
||||
loading: progress.current > 0,
|
||||
disabled: notes.length <= 0 || progress.current > 0,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Flex flexDirection="column" justifyContent="center">
|
||||
{progress.current > 0 ? (
|
||||
<>
|
||||
<Text variant="body" my={4} sx={{ textAlign: "center" }}>
|
||||
Importing notes {progress.current} of {progress.total}...
|
||||
</Text>
|
||||
</>
|
||||
) : progress.done ? (
|
||||
<>
|
||||
<Text variant="body" my={2} sx={{ textAlign: "center" }}>
|
||||
Imported {notes.length - errors.length} notes. {errors.length}{" "}
|
||||
errors occured.
|
||||
</Text>
|
||||
{errors.length > 0 && (
|
||||
<Flex flexDirection="column" my={1} bg="errorBg" p={1}>
|
||||
{errors.map((error) => (
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{
|
||||
color: "error",
|
||||
}}
|
||||
>
|
||||
{error.message}
|
||||
</Text>
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Accordion
|
||||
title={`${files.length} files selected`}
|
||||
isClosed={false}
|
||||
>
|
||||
<Flex
|
||||
{...getRootProps()}
|
||||
flexDirection="column"
|
||||
sx={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: 200,
|
||||
width: "full",
|
||||
border: "2px dashed var(--border)",
|
||||
borderRadius: "default",
|
||||
mt: 1,
|
||||
}}
|
||||
>
|
||||
<Input {...getInputProps()} />
|
||||
<Text variant="body" sx={{ textAlign: "center" }}>
|
||||
{isDragActive
|
||||
? "Drop the files here"
|
||||
: "Drag & drop files here, or click to select files"}
|
||||
<br />
|
||||
<Text variant="subBody">Only .zip files are supported.</Text>
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex flexDirection="column" mt={1}>
|
||||
{files.map((file, i) => (
|
||||
<Text
|
||||
p={1}
|
||||
sx={{
|
||||
":hover": { bg: "hover" },
|
||||
cursor: "pointer",
|
||||
borderRadius: "default",
|
||||
}}
|
||||
onClick={() => {
|
||||
setFiles((files) => {
|
||||
const cloned = files.slice();
|
||||
cloned.splice(i, 1);
|
||||
return cloned;
|
||||
});
|
||||
}}
|
||||
variant="body"
|
||||
ml={1}
|
||||
title="Click to remove"
|
||||
>
|
||||
{file.name}
|
||||
</Text>
|
||||
))}
|
||||
</Flex>
|
||||
</Accordion>
|
||||
|
||||
{files.length > 0 && (
|
||||
<Accordion title={`${notes.length} notes found`} sx={{ mt: 1 }}>
|
||||
<Flex flexDirection="column" mt={1}>
|
||||
{notes.map((note, i) => (
|
||||
<Flex
|
||||
p={1}
|
||||
sx={{
|
||||
":hover": { bg: "hover" },
|
||||
cursor: "pointer",
|
||||
borderRadius: "default",
|
||||
}}
|
||||
onClick={() => {
|
||||
setNotes((notes) => {
|
||||
const cloned = notes.slice();
|
||||
cloned[i].selected = !cloned[i].selected;
|
||||
return cloned;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{note.selected ? (
|
||||
<Icon.CheckCircle color="primary" size={16} />
|
||||
) : (
|
||||
<Icon.CheckCircleOutline size={16} />
|
||||
)}
|
||||
<Text variant="body" ml={1}>
|
||||
{note.title}
|
||||
</Text>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
</Accordion>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
export default ImportDialog;
|
||||
@@ -3,6 +3,7 @@ import BuyDialog from "./buy-dialog";
|
||||
import Confirm from "./confirm";
|
||||
import EmailVerificationDialog from "./emailverificationdialog";
|
||||
import ExportDialog from "./exportdialog";
|
||||
import ImportDialog from "./importdialog";
|
||||
import LoadingDialog from "./loadingdialog";
|
||||
import ProgressDialog from "./progressdialog";
|
||||
import MoveDialog from "./movenotedialog";
|
||||
@@ -32,5 +33,6 @@ const Dialogs = {
|
||||
ReminderDialog,
|
||||
AnnouncementDialog,
|
||||
IssueDialog,
|
||||
ImportDialog,
|
||||
};
|
||||
export default Dialogs;
|
||||
|
||||
@@ -179,3 +179,31 @@
|
||||
.mce-content-body p:empty {
|
||||
min-height: 22.4px;
|
||||
}
|
||||
|
||||
.tox-checklist > li,
|
||||
.checklist > li {
|
||||
list-style: none;
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.tox-checklist > li::before,
|
||||
.checklist > li::before {
|
||||
content: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-unchecked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2215%22%20height%3D%2215%22%20x%3D%22.5%22%20y%3D%22.5%22%20fill-rule%3D%22nonzero%22%20stroke%3D%22%234C4C4C%22%20rx%3D%222%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A");
|
||||
cursor: pointer;
|
||||
height: 1em;
|
||||
margin-left: -1.5em;
|
||||
margin-top: 0.125em;
|
||||
position: absolute;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
.tox-checklist li.tox-checklist--checked::before,
|
||||
.checklist li.checked::before {
|
||||
content: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-checked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2216%22%20height%3D%2216%22%20fill%3D%22%234099FF%22%20fill-rule%3D%22nonzero%22%20rx%3D%222%22%2F%3E%3Cpath%20id%3D%22Path%22%20fill%3D%22%23FFF%22%20fill-rule%3D%22nonzero%22%20d%3D%22M11.5703186%2C3.14417309%20C11.8516238%2C2.73724603%2012.4164781%2C2.62829933%2012.83558%2C2.89774797%20C13.260121%2C3.17069355%2013.3759736%2C3.72932262%2013.0909105%2C4.14168582%20L7.7580587%2C11.8560195%20C7.43776896%2C12.3193404%206.76483983%2C12.3852142%206.35607322%2C11.9948725%20L3.02491697%2C8.8138662%20C2.66090143%2C8.46625845%202.65798871%2C7.89594698%203.01850234%2C7.54483354%20C3.373942%2C7.19866177%203.94940006%2C7.19592841%204.30829608%2C7.5386474%20L6.85276923%2C9.9684299%20L11.5703186%2C3.14417309%20Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A");
|
||||
}
|
||||
|
||||
[dir="rtl"] .tox-checklist > li::before,
|
||||
[dir="rtl"] .checklist > li::before {
|
||||
margin-left: 0;
|
||||
margin-right: -1.5em;
|
||||
}
|
||||
|
||||
101
apps/web/src/utils/importer.js
Normal file
101
apps/web/src/utils/importer.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import { unzipSync } from "fflate";
|
||||
import { db } from "../common/db";
|
||||
import FS from "../interfaces/fs";
|
||||
|
||||
const textDecoder = new TextDecoder();
|
||||
async function getNotesFromImport(files) {
|
||||
let notes = [];
|
||||
for (let file of files) {
|
||||
const unzipped = unzipSync(new Uint8Array(await file.arrayBuffer()));
|
||||
|
||||
let metadata = binaryToJson(unzipped["metadata.json"], undefined);
|
||||
if (!metadata) continue;
|
||||
|
||||
const noteIds = metadata["notes"];
|
||||
if (!noteIds) continue;
|
||||
|
||||
for (let noteId of noteIds) {
|
||||
const path = `${noteId}/note.json`;
|
||||
let note = binaryToJson(files[path]);
|
||||
if (!note) continue;
|
||||
|
||||
const attachments = note.attachments?.slice() || [];
|
||||
note.attachments = [];
|
||||
for (let attachment of attachments) {
|
||||
const attachmentPath = `${noteId}/attachments/${attachment.hash}`;
|
||||
if (!files[attachmentPath]) continue;
|
||||
|
||||
attachment.filename = attachment.filename || attachment.hash;
|
||||
attachment.data = files[attachmentPath];
|
||||
|
||||
note.attachments.push(attachment);
|
||||
}
|
||||
|
||||
notes.push(note);
|
||||
}
|
||||
}
|
||||
|
||||
return notes;
|
||||
}
|
||||
|
||||
async function importNote(note) {
|
||||
if (note.content.type === "html") note.content.type = "tiny";
|
||||
else throw new Error("Invalid content type: " + note.content.type);
|
||||
|
||||
if (note.attachments) await importAttachments(note.attachments);
|
||||
|
||||
const notebooks = note.notebooks?.slice() || [];
|
||||
note.notebooks = [];
|
||||
const noteId = await db.notes.add(note);
|
||||
|
||||
for (let notebook of notebooks) {
|
||||
await db.notes.move(await importNotebook(notebook), noteId);
|
||||
}
|
||||
}
|
||||
|
||||
export const Importer = { importNote, getNotesFromImport };
|
||||
|
||||
function binaryToJson(binary, def) {
|
||||
if (!binary) return def;
|
||||
return JSON.parse(textDecoder.decode(binary));
|
||||
}
|
||||
|
||||
async function importAttachments(attachments) {
|
||||
for (let attachment of attachments) {
|
||||
const file = new File([attachment.data.buffer], attachment.filename, {
|
||||
type: attachment.mime,
|
||||
});
|
||||
if (db.attachments.exists(attachment.hash)) continue;
|
||||
|
||||
const key = await db.attachments.generateKey();
|
||||
let output = await FS.writeEncryptedFile(file, key, attachment.hash);
|
||||
await db.attachments.add({
|
||||
...output,
|
||||
key,
|
||||
hash: attachment.hash,
|
||||
hashType: attachment.hashType,
|
||||
filename: attachment.filename,
|
||||
type: attachment.mime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function importNotebook(notebook) {
|
||||
let nb = db.notebooks.all.find((nb) => nb.title === notebook.notebook);
|
||||
if (!nb) {
|
||||
const nbId = await db.notebooks.add({
|
||||
title: notebook.notebook,
|
||||
topics: [notebook.topic],
|
||||
});
|
||||
nb = db.notebooks.notebook(nbId).data;
|
||||
}
|
||||
|
||||
let topic = nb?.topics.find((t) => t.title === notebook.topic);
|
||||
if (!topic) {
|
||||
const topics = db.notebooks.notebook(nb).topics;
|
||||
await topics.add(notebook.topic);
|
||||
topic = topics.all.find((t) => t.title === notebook.topic);
|
||||
}
|
||||
|
||||
return { id: nb.id, topic: topic.id };
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import AccentItem from "../components/accent-item";
|
||||
import accents from "../theme/accents";
|
||||
import {
|
||||
showEmailVerificationDialog,
|
||||
showImportDialog,
|
||||
showIssueDialog,
|
||||
showTrackingDetailsDialog,
|
||||
} from "../common/dialog-controller";
|
||||
@@ -126,6 +127,7 @@ function Settings(props) {
|
||||
const [groups, setGroups] = useState({
|
||||
appearance: false,
|
||||
backup: false,
|
||||
importer: false,
|
||||
privacy: false,
|
||||
developer: false,
|
||||
other: true,
|
||||
@@ -424,20 +426,6 @@ function Settings(props) {
|
||||
tip="Restore data from a backup file"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
key={"importer"}
|
||||
variant="list"
|
||||
onClick={() => {
|
||||
window.open("https://importer.notesnook.com/", "_blank");
|
||||
}}
|
||||
>
|
||||
<Tip
|
||||
text={"Import from other apps"}
|
||||
tip={
|
||||
"Import all your notes from other note taking apps with Notesnook Importer."
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
<Toggle
|
||||
title="Encrypt backups"
|
||||
onTip="All backup files will be encrypted"
|
||||
@@ -484,6 +472,30 @@ function Settings(props) {
|
||||
</>
|
||||
)}
|
||||
|
||||
<Header
|
||||
title="Notesnook Importer"
|
||||
isOpen={groups.importer}
|
||||
onClick={() => {
|
||||
setGroups((g) => ({ ...g, importer: !g.importer }));
|
||||
}}
|
||||
/>
|
||||
{groups.importer && (
|
||||
<>
|
||||
<Button
|
||||
key={"importer"}
|
||||
variant="list"
|
||||
onClick={() => showImportDialog()}
|
||||
>
|
||||
<Tip
|
||||
text={"Import from ZIP file"}
|
||||
tip={
|
||||
"Import your notes from other notes apps using Notesnook Importer."
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Header
|
||||
title="Privacy & security"
|
||||
isOpen={groups.privacy}
|
||||
|
||||
Reference in New Issue
Block a user