/** * Turning notes into files on disk. * * The point of this is that the notes are not hostage to this program. A single * note becomes one Markdown file; a notebook becomes a ZIP with the notes, their * images, and any drawings rendered as PNG — everything readable without InkNote * ever running again. */ import * as sync from "../syncEngine"; import { parseNoteContent } from "../editor/content"; import { parsingExtensions } from "../editor/extensions.js"; import { CANON_W, CANON_H, docStrokes, paintStrokeDoc, sizeCanvas } from "../features/drawing/strokes.js"; import type { NoteContent, NoteMeta, Notebook } from "../types"; import { noteToMarkdown, safeFilename } from "./markdown"; import { asPdfStrokeDoc, hasOverlay } from "../features/pdf/pageStrokes"; import { stampStrokesIntoPdf } from "../features/pdf/stampPdf"; import { printNotes, type PrintableNote } from "./print"; import { bytesToBlob, createZip, download, type ZipEntry } from "./zip"; /** Width the exported drawing is rendered at; the page ratio is kept. */ const DRAWING_EXPORT_W = 1200; export interface ExportProgress { done: number; total: number; label: string; } type Loaded = { meta: NoteMeta; content: NoteContent }; /* ── pieces ── */ /** Renders a stroke document to PNG bytes, so a drawing survives as a picture. */ async function drawingToPng(doc: unknown): Promise { // `doc` is already a stroke document as stored — strokeDocOf() builds one from // a bare array and would wrap this one a second time, hiding the strokes. if (docStrokes(doc).length === 0) return null; const canvas = document.createElement("canvas"); const height = Math.round((DRAWING_EXPORT_W * CANON_H) / CANON_W); const { bw, bh } = sizeCanvas(canvas, DRAWING_EXPORT_W, height); const ctx = canvas.getContext("2d"); if (!ctx) return null; paintStrokeDoc(ctx, doc, bw, bh); // Strokes are drawn on nothing, which would export as a transparent PNG and // leave black ink invisible in a dark image viewer. The paper goes in behind // afterwards, because paintStrokeDoc begins by clearing the canvas. ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalCompositeOperation = "destination-over"; ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, bw, bh); ctx.globalCompositeOperation = "source-over"; const blob = await new Promise((r) => canvas.toBlob(r, "image/png")); if (!blob) return null; return new Uint8Array(await blob.arrayBuffer()); } /** Every attachment id the document refers to. */ function attachmentIds(doc: unknown): string[] { const ids = new Set(); const walk = (n: any) => { if (!n) return; if (n.type === "image" && n.attrs?.attachmentId) ids.add(n.attrs.attachmentId); if (Array.isArray(n.content)) n.content.forEach(walk); }; walk(doc); return [...ids]; } async function loadNote(meta: NoteMeta): Promise { return { meta, content: await sync.loadNoteContent(meta.id) }; } /** Unique filename per note — two notes may well share a title. */ function nameFor(meta: NoteMeta, taken: Set): string { const base = safeFilename(meta.title, "Notiz"); let name = base; for (let i = 2; taken.has(name.toLowerCase()); i++) name = `${base} (${i})`; taken.add(name.toLowerCase()); return name; } /* ── PDF notes ── */ /** * Exports the document behind a PDF note. * * `withInk` decides whether the drawings come along. Without it the bytes are * handed over exactly as imported — the original is never modified in storage, * so a clean copy stays available however often it has been drawn on. */ export async function exportPdfNote(meta: NoteMeta, withInk: boolean): Promise { if (!meta.pdfAttachmentId) throw new Error("Zu dieser Notiz gehört keine PDF-Datei."); const bytes = await sync.loadPdfBytes(meta.pdfAttachmentId); const name = safeFilename(meta.title, "Dokument"); if (!withInk) { download(bytesToBlob(bytes, "application/pdf"), `${name}.pdf`); return; } const { content } = await loadNote(meta); const ink = asPdfStrokeDoc(content.drawingStrokes); // Pinned notes count as much as strokes here: both are the reader's own work // on the document, and an export that dropped either would lose it silently. if (!hasOverlay(ink)) { download(bytesToBlob(bytes, "application/pdf"), `${name}.pdf`); return; } const stamped = await stampStrokesIntoPdf(bytes, ink); download(bytesToBlob(stamped, "application/pdf"), `${name} (mit Notizen).pdf`); } /** True when the PDF carries drawings or pinned notes, so the wizard can say so. */ export async function pdfNoteHasInk(meta: NoteMeta): Promise { try { const { content } = await loadNote(meta); return hasOverlay(asPdfStrokeDoc(content.drawingStrokes)); } catch { return false; } } /* ── single note ── */ /** What a single-note export should contain. */ export type ExportParts = "text" | "drawing" | "both"; /** Whether this note has a drawing — decides which options the wizard offers. */ export async function noteHasDrawing(meta: NoteMeta): Promise { try { const { content } = await loadNote(meta); return docStrokes(content.drawingStrokes).length > 0; } catch { return false; } } /** The drawing on its own, as a PNG on white paper. */ export async function exportDrawingAsPng(meta: NoteMeta): Promise { const { content } = await loadNote(meta); const png = await drawingToPng(content.drawingStrokes); if (!png) throw new Error("Diese Notiz enthält keine Zeichnung."); download(bytesToBlob(png, "image/png"), `${safeFilename(meta.title)}.png`); } export async function exportNoteAsMarkdown(meta: NoteMeta): Promise { const { content } = await loadNote(meta); const doc = parseNoteContent(content.content, parsingExtensions); const hasDrawing = docStrokes(content.drawingStrokes).length > 0; const ids = attachmentIds(doc); const md = noteToMarkdown(doc, { title: meta.title, updatedAt: meta.updatedAt, hasDrawing, attachmentPath: (id) => `${safeFilename(meta.title)}-bilder/${id}.png`, notePath: () => null, // a lone note has nothing to link to }); // Only worth an archive if there is something to put beside the text. if (ids.length === 0 && !hasDrawing) { download(new Blob([md], { type: "text/markdown;charset=utf-8" }), `${safeFilename(meta.title)}.md`); return; } const folder = safeFilename(meta.title); const entries: ZipEntry[] = [{ name: `${folder}.md`, data: md }]; for (const id of ids) { const bytes = await fetchAttachment(id); if (bytes) entries.push({ name: `${folder}-bilder/${id}.png`, data: bytes }); } const png = await drawingToPng(content.drawingStrokes); if (png) entries.push({ name: `${folder}-zeichnung.png`, data: png }); download(createZip(entries), `${folder}.zip`); } /* ── PDF ── */ const blobToDataUrl = (blob: Blob): Promise => new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(String(r.result)); r.onerror = () => reject(r.error); r.readAsDataURL(blob); }); /** * Prepares notes for the print dialog. * * Images have to travel as data URLs: the print document lives in its own * iframe, where a blob: URL minted by the app is not resolvable, and an * authenticated request is not something a printed page can make. */ export async function exportNotesAsPdf( metas: NoteMeta[], title: string, onProgress?: (p: ExportProgress) => void, parts: ExportParts = "both" ): Promise { const printable: PrintableNote[] = []; const total = metas.length; for (const [i, meta] of metas.entries()) { onProgress?.({ done: i, total, label: meta.title }); try { const content = await sync.loadNoteContent(meta.id); const doc = parseNoteContent(content.content, parsingExtensions); const images = new Map(); for (const id of attachmentIds(doc)) { try { images.set(id, await blobToDataUrl(await sync.fetchAttachmentBlob(id))); } catch { /* a missing image should not stop the rest of the page */ } } const png = parts === "text" ? null : await drawingToPng(content.drawingStrokes); const drawing = png ? await blobToDataUrl(bytesToBlob(png, "image/png")) : null; printable.push({ meta, content, drawing, images, includeText: parts !== "drawing" }); } catch { /* an unreadable note is left out rather than aborting the whole print */ } } onProgress?.({ done: total, total, label: "Druckansicht wird geöffnet" }); await printNotes(printable, title); } async function fetchAttachment(id: string): Promise { try { const blob = await sync.fetchAttachmentBlob(id); return new Uint8Array(await blob.arrayBuffer()); } catch { // One unreachable image must not cost the whole export. return null; } } /* ── many notes ── */ export interface ExportSetOptions { /** Shown in the archive name and its index file. */ label: string; notes: NoteMeta[]; notebooks: Notebook[]; onProgress?: (p: ExportProgress) => void; } export async function exportNotesAsZip({ label, notes, notebooks, onProgress, }: ExportSetOptions): Promise { const total = notes.length + 1; const entries: ZipEntry[] = []; const taken = new Set(); const filenames = new Map(); // Names are settled first, so a link from the first note to the last one can // already point at the right file. for (const meta of notes) filenames.set(meta.id, nameFor(meta, taken)); const notePath = (id: string) => { const name = filenames.get(id); return name ? `${encodeURIComponent(name)}.md` : null; }; const index: string[] = [ `# ${label}`, "", `${notes.length} Notizen, exportiert am ${new Date().toLocaleString("de-DE")}`, "", ]; const byNotebook = new Map(); for (const n of notes) { const key = n.folderId ?? null; if (!byNotebook.has(key)) byNotebook.set(key, []); byNotebook.get(key)!.push(n); } let done = 0; for (const meta of notes) { onProgress?.({ done, total, label: meta.title }); const name = filenames.get(meta.id)!; try { const { content } = await loadNote(meta); const doc = parseNoteContent(content.content, parsingExtensions); const hasDrawing = docStrokes(content.drawingStrokes).length > 0; entries.push({ name: `${name}.md`, data: noteToMarkdown(doc, { title: meta.title, updatedAt: meta.updatedAt, hasDrawing, attachmentPath: (id) => `bilder/${id}.png`, notePath, }), }); for (const id of attachmentIds(doc)) { if (entries.some((e) => e.name === `bilder/${id}.png`)) continue; const bytes = await fetchAttachment(id); if (bytes) entries.push({ name: `bilder/${id}.png`, data: bytes }); } const png = await drawingToPng(content.drawingStrokes); if (png) entries.push({ name: `zeichnungen/${name}.png`, data: png }); } catch (err) { // A note that cannot be read is named in the index rather than skipped in // silence — an export that quietly drops things is worse than useless. entries.push({ name: `${name}.md`, data: `# ${meta.title}\n\n> Diese Notiz konnte beim Export nicht gelesen werden.\n> (${(err as Error).message})\n`, }); } done++; } for (const [notebookId, group] of byNotebook) { const nb = notebooks.find((b) => b.id === notebookId); index.push("", `## ${nb?.name || "Ohne Notizbuch"}`, ""); for (const n of group) index.push(`- [${n.title || "Untitled"}](${notePath(n.id)})`); } entries.unshift({ name: "README.md", data: index.join("\n") + "\n" }); onProgress?.({ done: total, total, label: "Archiv wird gepackt" }); download(createZip(entries), `${safeFilename(label, "InkNote")}.zip`); }