feat: add notesnook-importer support

This commit is contained in:
thecodrr
2021-12-20 11:20:32 +05:00
parent 0856d0ec90
commit 0e7455e79d
10 changed files with 426 additions and 16 deletions

View File

@@ -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

View File

@@ -576,3 +576,9 @@ export function showIssueDialog() {
/>
));
}
export function showImportDialog() {
return showDialog((Dialogs, perform) => (
<Dialogs.ImportDialog onClose={(res) => perform(res)} />
));
}

View 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>
);
}

View 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;

View File

@@ -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;

View File

@@ -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;
}

View 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 };
}

View File

@@ -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}