diff --git a/apps/web/src/common/export.ts b/apps/web/src/common/export.ts index 5d6a996d0..6718f993c 100644 --- a/apps/web/src/common/export.ts +++ b/apps/web/src/common/export.ts @@ -22,6 +22,10 @@ import { ExportStream } from "../utils/streams/export-stream"; import { createZipStream } from "../utils/streams/zip-stream"; import { createWriteStream } from "../utils/stream-saver"; import { showToast } from "../utils/toast"; +import Vault from "./vault"; +import { db } from "./db"; +import Note from "@notesnook/core/dist/models/note"; +import { sanitizeFilename } from "@notesnook/common"; export async function exportToPDF( title: string, @@ -76,9 +80,21 @@ export async function exportNotes( subtitle: "Please wait while your notes are exported.", action: async (report) => { try { - await new ExportStream(noteIds, format, undefined, (c, text) => - report({ total: noteIds.length, current: c, text }) - ) + let progress = 0; + await createNoteStream(noteIds) + .pipeThrough( + new TransformStream({ + transform(note, controller) { + controller.enqueue(note); + report({ + total: noteIds.length, + current: progress++, + text: `Exporting "${note?.title || "Unknown note"}"` + }); + } + }) + ) + .pipeThrough(new ExportStream(format)) .pipeThrough(createZipStream()) .pipeTo(await createWriteStream("notes.zip")); return true; @@ -89,3 +105,65 @@ export async function exportNotes( } }); } + +function createNoteStream(noteIds: string[]) { + let i = 0; + return new ReadableStream({ + start() {}, + async pull(controller) { + const noteId = noteIds[i++]; + if (!noteId) controller.close(); + else controller.enqueue(db.notes?.note(noteId)); + }, + async cancel(reason) { + throw new Error(reason); + } + }); +} + +const FORMAT_TO_EXT = { + pdf: "pdf", + md: "md", + txt: "txt", + html: "html", + "md-frontmatter": "md" +} as const; + +export async function exportNote( + note: Note, + format: keyof typeof FORMAT_TO_EXT, + disableTemplate = false +) { + if (!db.vault?.unlocked && note.data.locked && !(await Vault.unlockVault())) { + showToast("error", `Skipping note "${note.title}" as it is locked.`); + return false; + } + + const rawContent = note.data.contentId + ? await db.content?.raw(note.data.contentId) + : undefined; + + const content = + rawContent && + !rawContent.deleted && + (typeof rawContent.data === "object" + ? await db.vault?.decryptContent(rawContent) + : rawContent); + + const exported = await note + .export(format === "pdf" ? "html" : format, content, disableTemplate) + .catch((e: Error) => { + console.error(note.data, e); + showToast("error", `Failed to export note "${note.title}": ${e.message}`); + return false as const; + }); + + if (!exported) return false; + + const filename = sanitizeFilename(note.title, { replacement: "-" }); + const ext = FORMAT_TO_EXT[format]; + return { + filename: [filename, ext].join("."), + content: exported + }; +} diff --git a/apps/web/src/components/note/index.tsx b/apps/web/src/components/note/index.tsx index c42dcf61f..883001128 100644 --- a/apps/web/src/components/note/index.tsx +++ b/apps/web/src/components/note/index.tsx @@ -70,7 +70,7 @@ import { hashNavigate, navigate } from "../../navigation"; import { showPublishView } from "../publish-view"; import IconTag from "../icon-tag"; import { COLORS } from "../../common/constants"; -import { exportNotes } from "../../common/export"; +import { exportNote, exportNotes, exportToPDF } from "../../common/export"; import { Multiselect } from "../../common/multi-select"; import { store as selectionStore } from "../../stores/selection-store"; import { @@ -86,7 +86,7 @@ import { ReferencesWithDateEdited } from "../list-container/types"; import { SchemeColors } from "@notesnook/theme"; -import Vault from "../../common/vault"; +import FileSaver from "file-saver"; type NoteProps = { tags: Item[]; @@ -422,7 +422,12 @@ const menuItems: (note: any, items?: any[]) => MenuItem[] = ( isDisabled: !isSynced, icon: Print.path, onClick: async () => { - await exportNotes("pdf", [note.id]); + const item = db.notes?.note(note); + if (!item) return; + + const result = await exportNote(item, "pdf"); + if (!result) return; + await exportToPDF(note.title, result.content); } }, { @@ -456,7 +461,24 @@ const menuItems: (note: any, items?: any[]) => MenuItem[] = ( // ? "Multiple notes cannot be exported as PDF." // : false, isPro: format.type !== "txt", - onClick: () => exportNotes(format.type, ids) + onClick: async () => { + if (ids.length === 1) { + const item = db.notes?.note(note); + if (!item) return; + + const result = await exportNote(item, format.type); + if (!result) return; + if (format.type === "pdf") + return exportToPDF(note.title, result.content); + + return FileSaver.saveAs( + new Blob([new TextEncoder().encode(result.content)]), + result.filename + ); + } + + await exportNotes(format.type, ids); + } })) }, multiSelect: true, @@ -706,17 +728,11 @@ async function copyNote(noteId: string, format: "md" | "txt") { try { const note = db.notes?.note(noteId); if (!note) throw new Error("No note with this id exists."); - if (note?.data.locked && !(await Vault.unlockVault())) - throw new Error("Please unlock this note to copy it."); - const rawContent = await db.content?.raw(note.data.contentId); - const content = note?.data.locked - ? await db.vault?.decryptContent(rawContent) - : rawContent; + const result = await exportNote(note, format, true); + if (!result) return; - const text = await note.export(format, content, false); - if (!text) throw new Error(`Could not convert note to ${format}.`); - await navigator.clipboard.writeText(text); + await navigator.clipboard.writeText(result.content); showToast("success", "Copied!"); } catch (e) { if (e instanceof Error) diff --git a/apps/web/src/utils/streams/export-stream.ts b/apps/web/src/utils/streams/export-stream.ts index 8619a68aa..78fed97f8 100644 --- a/apps/web/src/utils/streams/export-stream.ts +++ b/apps/web/src/utils/streams/export-stream.ts @@ -17,93 +17,38 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import { sanitizeFilename } from "@notesnook/common"; +import Note from "@notesnook/core/dist/models/note"; import { db } from "../../common/db"; -import { exportToPDF } from "../../common/export"; -import Vault from "../../common/vault"; -import { showToast } from "../toast"; +import { exportNote } from "../../common/export"; import { makeUniqueFilename } from "./utils"; import { ZipFile } from "./zip-stream"; -const FORMAT_TO_EXT = { - pdf: "pdf", - md: "md", - txt: "txt", - html: "html", - "md-frontmatter": "md" -} as const; - -export class ExportStream extends ReadableStream { +export class ExportStream extends TransformStream { constructor( - noteIds: string[], format: "pdf" | "md" | "txt" | "html" | "md-frontmatter", - signal?: AbortSignal, - onProgress?: (current: number, text: string) => void + signal?: AbortSignal ) { - let index = 0; const counters: Record = {}; - let vaultUnlocked = false; super({ - async start() { - if (noteIds.length === 1 && db.notes?.note(noteIds[0])?.data.locked) { - vaultUnlocked = await Vault.unlockVault(); - if (!vaultUnlocked) return false; - } else if (noteIds.length > 1 && (await db.vault?.exists())) { - vaultUnlocked = await Vault.unlockVault(); - if (!vaultUnlocked) - showToast( - "error", - "Failed to unlock vault. Locked notes will be skipped." - ); - } - }, - async pull(controller) { + async transform(note, controller) { try { if (signal?.aborted) { - controller.close(); + controller.terminate(); return; } - const note = db.notes?.note(noteIds[index++]); - if (!note) return; - if (!vaultUnlocked && note.data.locked) return; + if (!note || format === "pdf") return; - onProgress && onProgress(index, `Exporting "${note.title}"...`); + const result = await exportNote(note, format); + if (!result) return; - const rawContent = await db.content?.raw(note.data.contentId); - const content = note.data.locked - ? await db.vault?.decryptContent(rawContent) - : rawContent; - - const exported = await note - .export(format === "pdf" ? "html" : format, content) - .catch((e: Error) => { - console.error(note.data, e); - showToast( - "error", - `Failed to export note "${note.title}": ${e.message}` - ); - }); - - if (typeof exported !== "string") { - showToast("error", `Failed to export note "${note.title}"`); - return; - } - - if (format === "pdf") { - await exportToPDF(note.title, exported); - controller.error("PDF export."); - return; - } - - const filename = sanitizeFilename(note.title, { replacement: "-" }); - const ext = FORMAT_TO_EXT[format]; - const filenameWithExtension = [filename, ext].join("."); + const { filename, content } = result; const notebooks = [ - ...(db.relations?.to({ id: note.id, type: "note" }, "notebook") || - []), + ...( + db.relations?.to({ id: note.id, type: "note" }, "notebook") || [] + ).map((n) => ({ title: n.title, topics: [] })), ...(note.notebooks || []).map( (ref: { id: string; topics: string[] }) => { const notebook = db.notebooks?.notebook(ref.id); @@ -127,31 +72,23 @@ export class ExportStream extends ReadableStream { .map((notebook) => { if (notebook.topics.length > 0) return notebook.topics.map((topic: { title: string }) => - [ - notebook.title, - topic.title, - filenameWithExtension - ].join("/") + [notebook.title, topic.title, filename].join("/") ); - return [notebook.title, filenameWithExtension].join("/"); + return [notebook.title, filename].join("/"); }) .flat() - : [filenameWithExtension]; + : [filename]; filePaths.forEach((filePath) => { controller.enqueue({ path: makeUniqueFilename(filePath, counters), - data: exported, + data: content, mtime: new Date(note.data.dateEdited), ctime: new Date(note.data.dateCreated) }); }); } catch (e) { controller.error(e); - } finally { - if (index === noteIds.length) { - controller.close(); - } } } }); diff --git a/packages/core/src/api/vault.js b/packages/core/src/api/vault.js index 9c59cfebb..8eeb81238 100644 --- a/packages/core/src/api/vault.js +++ b/packages/core/src/api/vault.js @@ -61,6 +61,10 @@ export default class Vault { }); } + get unlocked() { + return !!this._vaultPassword; + } + /** * Creates a new vault * @param {string} password The password