From 2611d87f8c8e4373a1cefeb4254300765a10981b Mon Sep 17 00:00:00 2001 From: marc Date: Sat, 8 Aug 2026 14:46:53 +0200 Subject: [PATCH] Export notes as Markdown or PDF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reason to build this is not a missing feature — it is that trusting a program you maintain alone with years of notes should not mean the notes can only ever be read by that program. Reachable from a note's toolbar, a notebook's context menu, and Account → Alle Notizen exportieren. Markdown carries over everything with an equivalent: headings, lists, quotes, code, tables, and task state as `- [x]`. A link to another note in the same export becomes a relative link to its file; a link to a note outside it degrades to the plain title rather than leaving a dead reference. Notes with images or a drawing arrive as a ZIP so the files travel along, and a multi-note export gets a README index. PDF goes through the browser's print dialog rather than a bundled PDF library — the browser already has a good one, and it is the only format where drawings sit inside the document. Markdown gets them as a PNG beside the text, with a line saying so, because dropping them silently would be the wrong kind of quiet. Two decisions worth recording: - the ZIP writer is eighty lines here rather than a dependency. Note text is small and images are already compressed, so compression bought nothing, and stored entries are a handful of fixed-layout records. - Markdown escaping only covers what changes meaning mid-line, plus a leading list or heading marker. Escaping every character Markdown ever uses turns a sentence into `Hallo\. Alles gut\!`, which defeats the point of choosing a readable format. 42 unit tests on the serialiser and the archive format, plus a browser suite that unpacks a real download and reads the bytes back. Co-Authored-By: Claude Opus 5 --- e2e/export.mjs | 184 ++++++++++++++ e2e/run.sh | 2 +- frontend/src/App.jsx | 31 +++ frontend/src/export/exportNotes.ts | 268 +++++++++++++++++++++ frontend/src/export/markdown.test.ts | 218 +++++++++++++++++ frontend/src/export/markdown.ts | 237 ++++++++++++++++++ frontend/src/export/print.ts | 209 ++++++++++++++++ frontend/src/export/zip.test.ts | 112 +++++++++ frontend/src/export/zip.ts | 150 ++++++++++++ frontend/src/features/notes/EditorPane.jsx | 4 + frontend/src/ui/AccountMenu.jsx | 23 +- frontend/src/ui/ExportDialog.jsx | 217 +++++++++++++++++ frontend/src/ui/icons.jsx | 16 ++ readme.md | 38 +++ 14 files changed, 1707 insertions(+), 2 deletions(-) create mode 100644 e2e/export.mjs create mode 100644 frontend/src/export/exportNotes.ts create mode 100644 frontend/src/export/markdown.test.ts create mode 100644 frontend/src/export/markdown.ts create mode 100644 frontend/src/export/print.ts create mode 100644 frontend/src/export/zip.test.ts create mode 100644 frontend/src/export/zip.ts create mode 100644 frontend/src/ui/ExportDialog.jsx diff --git a/e2e/export.mjs b/e2e/export.mjs new file mode 100644 index 0000000..252bdb5 --- /dev/null +++ b/e2e/export.mjs @@ -0,0 +1,184 @@ +/* Export to Markdown and ZIP. + * + * The unit tests cover the serialiser; this checks the part they cannot — that + * a click produces a real download whose bytes are what they should be. The + * files are caught in the browser rather than written to disk, so the archive + * can be unpacked and read here. */ +import { + OUT, + check, + createNote, + createNotebook, + insertTable, + launch, + report, + setTitle, + typeInEditor, + withFreshUser, +} from "./lib.mjs"; + +const PNG = + "iVBORw0KGgoAAAANSUhEUgAAAAMAAAADCAYAAABWKLW/AAAAFElEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC"; + +/** Intercepts download() so the bytes stay in the page and can be inspected. */ +async function captureDownloads(page) { + await page.evaluate(() => { + window.__downloads = []; + const realCreate = URL.createObjectURL.bind(URL); + URL.createObjectURL = (blob) => { + const url = realCreate(blob); + const reader = new FileReader(); + reader.onload = () => + window.__downloads.push({ + url, + type: blob.type, + size: blob.size, + // base64 of the payload, so binary survives the trip out of the page + base64: String(reader.result).split(",")[1] || "", + }); + reader.readAsDataURL(blob); + return url; + }; + // A real navigation would abandon the test run. + HTMLAnchorElement.prototype.click = function () { + window.__lastDownloadName = this.download; + }; + }); +} + +const downloads = (page) => page.evaluate(() => window.__downloads || []); +const lastName = (page) => page.evaluate(() => window.__lastDownloadName || ""); + +/** Unpacks a stored-only ZIP far enough to list names and read text entries. */ +function readZip(bytes) { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const dec = new TextDecoder(); + const endAt = bytes.length - 22; + const count = view.getUint16(endAt + 8, true); + let p = view.getUint32(endAt + 16, true); + const out = {}; + for (let i = 0; i < count; i++) { + const size = view.getUint32(p + 24, true); + const nameLen = view.getUint16(p + 28, true); + const localAt = view.getUint32(p + 42, true); + const name = dec.decode(bytes.slice(p + 46, p + 46 + nameLen)); + const dataAt = localAt + 30 + view.getUint16(localAt + 26, true) + view.getUint16(localAt + 28, true); + out[name] = bytes.slice(dataAt, dataAt + size); + p += 46 + nameLen + view.getUint16(p + 30, true) + view.getUint16(p + 32, true); + } + return out; +} + +const openExportDialog = async (page) => { + await page.getByTitle("Export").click(); + await page.waitForSelector("[data-export-dialog]", { timeout: 10000 }); + await page.waitForTimeout(500); +}; + +const browser = await launch(); +let session; +try { + session = await withFreshUser(browser, "export"); + const p = session.page; + await createNotebook(p, "Export Buch"); + + /* ── a note with everything that is hard to serialise ── */ + await createNote(p); + await setTitle(p, "Alles drin"); + await typeInEditor(p, "Einleitender Satz."); + await insertTable(p); + await p.locator(".ProseMirror table th").first().click(); + await p.keyboard.type("Spalte"); + await p.waitForTimeout(600); + await p.locator(".ProseMirror").click(); + await p.keyboard.press("Control+End"); + await p.keyboard.press("Enter"); + await p.getByTitle("Task list").click(); + await p.waitForTimeout(400); + await p.keyboard.type("Erledigte Sache"); + await p.waitForTimeout(800); + await p.locator('.ProseMirror input[type="checkbox"]').first().click(); + await p.waitForTimeout(1800); + + await captureDownloads(p); + + /* ── single note as Markdown ── */ + await openExportDialog(p); + await p.getByRole("button", { name: /Als Markdown/ }).click(); + await p.waitForSelector("[data-export-done]", { timeout: 30000 }); + await p.waitForTimeout(600); + + let files = await downloads(p); + check(files.length === 1, "a single note produces one download", `${files.length}`); + check(/\.md$/.test(await lastName(p)), "it is named .md", await lastName(p)); + + const md = Buffer.from(files[0].base64, "base64").toString("utf8"); + check(md.startsWith("# Alles drin"), "the title became a heading"); + check(md.includes("Einleitender Satz."), "the body text is there"); + check(/\|\s*Spalte\s*\|/.test(md), "the table came along", md.match(/\|.*\|/)?.[0] || "no table row"); + check(md.includes("| --- |"), "the table has a separator row so it renders as a table"); + check(md.includes("- [x] Erledigte Sache"), "the ticked task is a ticked checkbox"); + console.log("--- Markdown ---\n" + md + "\n--- Ende ---"); + + // The dialog stays up after an export so the result can be read; close it or + // its backdrop swallows the next click. + await p.keyboard.press("Escape"); + await p.waitForTimeout(600); + + /* ── a second note and an image, then the whole notebook ── */ + await createNote(p); + await setTitle(p, "Zweite Notiz"); + await typeInEditor(p, "Kurzer Text mit Bild."); + await p.locator(".ProseMirror").click(); + await p.keyboard.press("Control+End"); + await p.evaluate(async (b64) => { + const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + const dt = new DataTransfer(); + dt.items.add(new File([bytes], "dot.png", { type: "image/png" })); + document + .querySelector(".ProseMirror") + .dispatchEvent(new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true })); + }, PNG); + await p.waitForTimeout(3500); + + await p.evaluate(() => (window.__downloads = [])); + await openExportDialog(p); + await p.getByRole("button", { name: /Als Markdown/ }).click(); + await p.waitForSelector("[data-export-done]", { timeout: 30000 }); + await p.waitForTimeout(800); + files = await downloads(p); + check(files.length === 1 && /zip/.test(files[0].type), "a note with an image is packed into a ZIP", files[0]?.type); + + const zip = readZip(new Uint8Array(Buffer.from(files[0].base64, "base64"))); + const names = Object.keys(zip); + check(names.some((n) => n.endsWith(".md")), "the archive holds the Markdown file", names.join(", ")); + check(names.some((n) => /bilder\/.*\.png$/.test(n)), "and the image beside it", names.join(", ")); + + await p.keyboard.press("Escape"); + await p.waitForTimeout(600); + + const imgBytes = zip[names.find((n) => n.endsWith(".png"))]; + check( + imgBytes[0] === 0x89 && imgBytes[1] === 0x50 && imgBytes[2] === 0x4e && imgBytes[3] === 0x47, + "the extracted image really is a PNG", + Array.from(imgBytes.slice(0, 4)).join(",") + ); + await p.screenshot({ path: `${OUT}/export-dialog.png` }); + + /* ── whole notebook, via the sidebar's context menu ── */ + await p.evaluate(() => (window.__downloads = [])); + await p.locator("[data-note-row]").first().click({ button: "right" }); + await p.waitForTimeout(600); + await p.getByText("Export...", { exact: true }).click(); + await p.waitForSelector("[data-export-dialog]", { timeout: 10000 }); + await p.waitForTimeout(500); + check(true, "the context menu opens the export dialog"); + await p.keyboard.press("Escape"); +} catch (e) { + check(false, "suite ran to the end", e.message); +} finally { + await session?.cleanup(); + const code = report(); + await browser.close(); + process.exit(code); +} diff --git a/e2e/run.sh b/e2e/run.sh index 58a7454..b18e0b5 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -13,7 +13,7 @@ cd "$(dirname "$0")" IMAGE="mcr.microsoft.com/playwright:v1.49.0-noble" URL="${INKNOTE_URL:-http://localhost:3000}" SUITES=("$@") -[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing) +[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export) mkdir -p shots if [ ! -d node_modules ]; then diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e65eb23..6b6bfa7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -17,6 +17,7 @@ import { EditNotebookModal, CreateNotebookModal } from "./ui/NotebookModals.jsx" import { AccountMenu } from "./ui/AccountMenu.jsx"; import { AuthScreen } from "./ui/AuthScreen.jsx"; import { AdminPanel } from "./ui/AdminPanel.jsx"; +import { ExportDialog } from "./ui/ExportDialog.jsx"; import { Sidebar } from "./ui/Sidebar.jsx"; import { Dashboard } from "./features/notebooks/Dashboard.jsx"; import { NoteList } from "./features/notes/NoteList.jsx"; @@ -51,6 +52,8 @@ export default function App() { const [editingNb, setEditingNb] = useState(null); const [showAccount, setShowAccount] = useState(false); const [showAdmin, setShowAdmin] = useState(false); + // { notes, label } — set while the export dialog is up. + const [exporting, setExporting] = useState(null); const [historyFor, setHistoryFor] = useState(null); const [ctxMenu, setCtxMenu] = useState(null); const [confirmDialog, setConfirmDialog] = useState(null); @@ -318,6 +321,15 @@ export default function App() { y: e.clientY, items: [ { label: "Edit", icon: I.edit, action: () => setEditingNb(nb) }, + { + label: "Export...", + icon: I.export, + action: () => + setExporting({ + label: nb.name, + notes: activeNotes.filter((n) => n.folderId === nb.id), + }), + }, { divider: true }, { label: "Delete Notebook", icon: I.trash, danger: true, action: () => deleteNotebook(nb.id) }, ], @@ -357,6 +369,11 @@ export default function App() { action: () => data.toggleStar(note.id), }, ...moveSubmenu(note), + { + label: "Export...", + icon: I.export, + action: () => setExporting({ label: note.title, notes: [note] }), + }, { divider: true }, { label: "Move to Trash", icon: I.trash, danger: true, action: () => trashNote(note.id) }, ]; @@ -492,6 +509,7 @@ export default function App() { onRestore={() => restoreNote(activeNote)} onPermanentDelete={() => permanentDeleteNote(activeNote)} onOpenHistory={() => setHistoryFor(activeNote)} + onExport={() => noteMeta && setExporting({ label: noteMeta.title, notes: [noteMeta] })} onCreateNote={createNote} /> )} @@ -522,9 +540,22 @@ export default function App() { setShowAccount(false); setShowAdmin(true); }} + onExportAll={() => { + setShowAccount(false); + setExporting({ label: "Alle Notizen", notes: activeNotes }); + }} dark={dark} /> )} + {exporting && ( + setExporting(null)} + /> + )} {showAdmin && user?.isAdmin && ( setShowAdmin(false)} dark={dark} /> )} diff --git a/frontend/src/export/exportNotes.ts b/frontend/src/export/exportNotes.ts new file mode 100644 index 0000000..31a8030 --- /dev/null +++ b/frontend/src/export/exportNotes.ts @@ -0,0 +1,268 @@ +/** + * 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, paintStrokeDoc, sizeCanvas, strokeDocOf } from "../features/drawing/strokes.js"; +import type { NoteContent, NoteMeta, Notebook } from "../types"; +import { noteToMarkdown, safeFilename } from "./markdown"; +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(strokes: unknown): Promise { + const doc = strokeDocOf(strokes); + if (!doc?.strokes?.length) 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; +} + +/* ── single note ── */ + +export async function exportNoteAsMarkdown(meta: NoteMeta): Promise { + const { content } = await loadNote(meta); + const doc = parseNoteContent(content.content, parsingExtensions); + const hasDrawing = !!strokeDocOf(content.drawingStrokes)?.strokes?.length; + 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 +): 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 = await drawingToPng(content.drawingStrokes); + const drawing = png ? await blobToDataUrl(bytesToBlob(png, "image/png")) : null; + + printable.push({ meta, content, drawing, images }); + } 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 = !!strokeDocOf(content.drawingStrokes)?.strokes?.length; + + 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`); +} diff --git a/frontend/src/export/markdown.test.ts b/frontend/src/export/markdown.test.ts new file mode 100644 index 0000000..0ea89e8 --- /dev/null +++ b/frontend/src/export/markdown.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect } from "vitest"; + +import { noteToMarkdown, safeFilename } from "./markdown"; +import type { DocNode } from "../editor/content"; + +const doc = (...content: DocNode[]): DocNode => ({ type: "doc", content }); +const para = (...content: DocNode[]): DocNode => ({ type: "paragraph", content }); +const text = (t: string, marks?: { type: string; attrs?: Record }[]): DocNode => + ({ type: "text", text: t, ...(marks ? { marks } : {}) }) as DocNode; +const md = (d: DocNode, o = {}) => noteToMarkdown(d, o); + +describe("text and marks", () => { + it("writes a plain paragraph", () => { + expect(md(doc(para(text("Hallo Welt"))))).toBe("Hallo Welt"); + }); + + it("renders bold, italic and strikethrough", () => { + expect(md(doc(para(text("fett", [{ type: "bold" }]))))).toBe("**fett**"); + expect(md(doc(para(text("kursiv", [{ type: "italic" }]))))).toBe("*kursiv*"); + expect(md(doc(para(text("weg", [{ type: "strike" }]))))).toBe("~~weg~~"); + }); + + it("falls back to HTML for underline, which Markdown lacks", () => { + expect(md(doc(para(text("unter", [{ type: "underline" }]))))).toBe("unter"); + }); + + it("keeps a web link", () => { + const out = md(doc(para(text("hier", [{ type: "link", attrs: { href: "https://example.com" } }])))); + expect(out).toBe("[hier](https://example.com)"); + }); + + it("escapes characters that would otherwise become syntax mid-line", () => { + expect(md(doc(para(text("2 * 3 _und_ [mehr]"))))).toBe("2 \\* 3 \\_und\\_ \\[mehr\\]"); + }); + + it("leaves ordinary punctuation alone, so the result stays readable", () => { + expect(md(doc(para(text("Hallo. Alles gut! Preis: 5-10 EUR (ca.)"))))).toBe( + "Hallo. Alles gut! Preis: 5-10 EUR (ca.)" + ); + }); + + it("escapes a leading marker so a paragraph does not become a list", () => { + expect(md(doc(para(text("- kein Listenpunkt"))))).toBe("\\- kein Listenpunkt"); + expect(md(doc(para(text("1. kein Punkt"))))).toBe("\\1. kein Punkt"); + expect(md(doc(para(text("# keine Ueberschrift"))))).toBe("\\# keine Ueberschrift"); + }); + + it("does not escape inside a code span, where backslashes are literal", () => { + expect(md(doc(para(text("a_b", [{ type: "code" }]))))).toBe("`a_b`"); + }); + + it("turns a hard break into a Markdown line break", () => { + expect(md(doc(para(text("eins"), { type: "hardBreak" }, text("zwei"))))).toBe("eins \nzwei"); + }); +}); + +describe("blocks", () => { + it("writes headings at their level", () => { + expect(md(doc({ type: "heading", attrs: { level: 2 }, content: [text("Titel")] }))).toBe("## Titel"); + }); + + it("clamps an out-of-range heading level instead of emitting nonsense", () => { + expect(md(doc({ type: "heading", attrs: { level: 99 }, content: [text("X")] }))).toBe("###### X"); + }); + + it("writes a bullet list", () => { + const list = { + type: "bulletList", + content: [ + { type: "listItem", content: [para(text("eins"))] }, + { type: "listItem", content: [para(text("zwei"))] }, + ], + }; + expect(md(doc(list))).toBe("- eins\n- zwei"); + }); + + it("numbers an ordered list from its start attribute", () => { + const list = { + type: "orderedList", + attrs: { start: 3 }, + content: [ + { type: "listItem", content: [para(text("drei"))] }, + { type: "listItem", content: [para(text("vier"))] }, + ], + }; + expect(md(doc(list))).toBe("3. drei\n4. vier"); + }); + + it("writes task state as a checkbox, so it survives the trip", () => { + const list = { + type: "taskList", + content: [ + { type: "taskItem", attrs: { checked: true }, content: [para(text("erledigt"))] }, + { type: "taskItem", attrs: { checked: false }, content: [para(text("offen"))] }, + ], + }; + expect(md(doc(list))).toBe("- [x] erledigt\n- [ ] offen"); + }); + + it("writes a quote with every line prefixed", () => { + const quote = { type: "blockquote", content: [para(text("zitiert"))] }; + expect(md(doc(quote))).toBe("> zitiert"); + }); + + it("fences a code block and keeps its language", () => { + const code = { type: "codeBlock", attrs: { language: "js" }, content: [text("const a = 1;")] }; + expect(md(doc(code))).toBe("```js\nconst a = 1;\n```"); + }); + + it("writes a horizontal rule", () => { + expect(md(doc({ type: "horizontalRule" }))).toBe("---"); + }); +}); + +describe("tables", () => { + const cell = (t: string, type = "tableCell"): DocNode => ({ type, content: [para(text(t))] }); + + it("writes a header row and separator", () => { + const table = { + type: "table", + content: [ + { type: "tableRow", content: [cell("A", "tableHeader"), cell("B", "tableHeader")] }, + { type: "tableRow", content: [cell("1"), cell("2")] }, + ], + }; + expect(md(doc(table))).toBe("| A | B |\n| --- | --- |\n| 1 | 2 |"); + }); + + it("gives a headerless table an empty header, since Markdown has no other form", () => { + const table = { + type: "table", + content: [{ type: "tableRow", content: [cell("1"), cell("2")] }], + }; + const out = md(doc(table)).split("\n"); + expect(out[1]).toBe("| --- | --- |"); + expect(out[2]).toBe("| 1 | 2 |"); + }); + + it("escapes a pipe inside a cell so the table does not break apart", () => { + const table = { + type: "table", + content: [{ type: "tableRow", content: [cell("a|b", "tableHeader")] }], + }; + expect(md(doc(table))).toContain("a\\|b"); + expect(md(doc(table))).not.toContain("a\\\\|b"); + }); + + it("pads a short row so the columns stay aligned", () => { + const table = { + type: "table", + content: [ + { type: "tableRow", content: [cell("A", "tableHeader"), cell("B", "tableHeader")] }, + { type: "tableRow", content: [cell("1")] }, + ], + }; + expect(md(doc(table))).toContain("| 1 | |"); + }); +}); + +describe("images and note links", () => { + it("points an attachment at its file in the archive", () => { + const img = { type: "image", attrs: { attachmentId: "abc", alt: "Bild" } }; + const out = md(doc(img), { attachmentPath: (id: string) => `images/${id}.png` }); + expect(out).toBe("![Bild](images/abc.png)"); + }); + + it("links to another exported note", () => { + const link = para({ type: "noteLink", attrs: { noteId: "n1", label: "Andere Notiz" } }); + const out = md(doc(link), { notePath: (id: string) => `${id}.md` }); + expect(out).toBe("[Andere Notiz](n1.md)"); + }); + + it("degrades a link to a note that was not exported, rather than leaving a dead file link", () => { + const link = para({ type: "noteLink", attrs: { noteId: "n1", label: "Andere Notiz" } }); + expect(md(doc(link), { notePath: () => null })).toBe("Andere Notiz"); + }); +}); + +describe("document framing", () => { + it("puts the title in a heading", () => { + expect(md(doc(para(text("Text"))), { title: "Meine Notiz" })).toBe("# Meine Notiz\n\nText"); + }); + + it("says when a drawing could not come along, instead of dropping it silently", () => { + const out = md(doc(para(text("Text"))), { hasDrawing: true }); + expect(out).toContain("Zeichnung"); + }); + + it("collapses the blank lines the block rules emit generously", () => { + const out = md(doc(para(text("a")), para(), para(), para(text("b")))); + expect(out).not.toMatch(/\n{3,}/); + }); + + it("survives an empty document", () => { + expect(md(doc())).toBe(""); + expect(md({ type: "doc" })).toBe(""); + }); + + it("keeps the children of a node type it does not know", () => { + const weird = { type: "somethingNew", content: [para(text("trotzdem da"))] }; + expect(md(doc(weird))).toBe("trotzdem da"); + }); +}); + +describe("safeFilename", () => { + it("strips characters a filesystem would refuse", () => { + expect(safeFilename('a/b\\c:d*e?f"gi|j')).toBe("a-b-c-d-e-f-g-h-i-j"); + }); + + it("falls back when nothing usable is left", () => { + expect(safeFilename("")).toBe("Notiz"); + expect(safeFilename("///")).toBe("---"); + }); + + it("caps the length", () => { + expect(safeFilename("x".repeat(200)).length).toBe(80); + }); +}); diff --git a/frontend/src/export/markdown.ts b/frontend/src/export/markdown.ts new file mode 100644 index 0000000..662219c --- /dev/null +++ b/frontend/src/export/markdown.ts @@ -0,0 +1,237 @@ +/** + * A note as Markdown. + * + * This exists so the notes are not trapped in one program. It is a plain + * function over the stored document — no editor, no DOM — which is also why it + * can be unit tested and reused by the PDF path. + * + * Where Markdown has no equivalent the export says so rather than dropping + * something silently: a drawing becomes a marker line, an attachment becomes a + * link to a file that sits beside the note in the archive. + */ + +import type { DocNode } from "../editor/content"; + +export interface MarkdownOptions { + /** Heading line for the note, usually its title. Omit to export the body only. */ + title?: string; + /** ISO timestamp rendered under the title. */ + updatedAt?: string; + /** Note has a drawing that Markdown cannot hold. */ + hasDrawing?: boolean; + /** Maps an attachment id to a path in the archive, e.g. `images/ab12.png`. */ + attachmentPath?: (id: string) => string; + /** Maps a linked note id to a path, so links between exported notes still work. */ + notePath?: (id: string) => string | null; +} + +/* ── inline ── */ + +/** + * Escapes only what actually changes meaning mid-line. + * + * Escaping every punctuation mark that Markdown ever uses turns an ordinary + * sentence into `Hallo\. Welt\!`, which is unreadable — and readability is the + * whole point of exporting to Markdown rather than JSON. Characters that only + * matter at the start of a line are handled by `escapeLineStart` instead. + */ +const escapeText = (s: string) => s.replace(/([\\`*_[\]|])/g, "\\$1"); + +/** `- x`, `# x`, `1. x` and friends turn a paragraph into a block if left alone. */ +const escapeLineStart = (line: string) => + line.replace(/^(\s*)([-+>#]|\d+\.)(\s)/, (_m, pad, marker, space) => `${pad}\\${marker}${space}`); + +function inline(nodes: DocNode[] | undefined, o: MarkdownOptions): string { + if (!Array.isArray(nodes)) return ""; + return nodes.map((n) => inlineNode(n, o)).join(""); +} + +function inlineNode(n: DocNode, o: MarkdownOptions): string { + if (n.type === "hardBreak") return " \n"; + + if (n.type === "noteLink") { + const label = n.attrs?.label || "Untitled"; + const target = o.notePath?.(n.attrs?.noteId); + // A link to a note that was not exported would be a dead file reference, + // so it degrades to the plain name it points at. + return target ? `[${escapeText(label)}](${target})` : escapeText(label); + } + + if (n.type === "image") { + const alt = escapeText(n.attrs?.alt || ""); + const id = n.attrs?.attachmentId; + const src = id ? o.attachmentPath?.(id) : n.attrs?.src; + return src ? `![${alt}](${src})` : `![${alt}]()`; + } + + if (n.type !== "text" || !n.text) return ""; + + let out = escapeText(n.text); + // Applied innermost-first so `**_text_**` nests the way the marks were stored. + for (const mark of (n as any).marks || []) { + switch (mark.type) { + case "bold": + out = `**${out}**`; + break; + case "italic": + out = `*${out}*`; + break; + case "strike": + out = `~~${out}~~`; + break; + case "code": + // Backslash escapes are not honoured inside code spans. + out = `\`${n.text}\``; + break; + case "underline": + // Markdown has no underline; HTML is the least surprising stand-in. + out = `${out}`; + break; + case "link": + out = `[${out}](${mark.attrs?.href || ""})`; + break; + default: + break; + } + } + return out; +} + +/* ── blocks ── */ + +function block(n: DocNode, o: MarkdownOptions, indent = ""): string[] { + switch (n.type) { + case "paragraph": { + const text = inline(n.content, o); + return text.trim() ? [escapeLineStart(indent + text)] : [""]; + } + + case "heading": { + const level = Math.min(6, Math.max(1, Number(n.attrs?.level) || 1)); + return [`${"#".repeat(level)} ${inline(n.content, o)}`]; + } + + case "blockquote": + return children(n, o) + .join("\n") + .split("\n") + .map((l) => (l ? `> ${l}` : ">")); + + case "codeBlock": { + const lang = n.attrs?.language || ""; + const body = (n.content || []).map((c) => c.text || "").join(""); + return ["```" + lang, ...body.split("\n"), "```"]; + } + + case "horizontalRule": + return ["---"]; + + case "bulletList": + return list(n, o, indent, () => "- "); + + case "orderedList": { + const start = Number(n.attrs?.start) || 1; + return list(n, o, indent, (i) => `${start + i}. `); + } + + case "taskList": + return list(n, o, indent, (_i, item) => `- [${item.attrs?.checked ? "x" : " "}] `); + + case "table": + return table(n, o); + + case "image": + case "noteLink": + return [indent + inlineNode(n, o)]; + + default: + // An unknown node still contributes its children rather than vanishing. + return children(n, o); + } +} + +function children(n: DocNode, o: MarkdownOptions, indent = ""): string[] { + return (n.content || []).flatMap((c) => block(c, o, indent)); +} + +/** Renders list items, indenting anything nested inside them. */ +function list( + n: DocNode, + o: MarkdownOptions, + indent: string, + marker: (index: number, item: DocNode) => string +): string[] { + const out: string[] = []; + (n.content || []).forEach((item, i) => { + const bullet = marker(i, item); + const lines = (item.content || []).flatMap((c) => block(c, o, "")); + const [first = "", ...rest] = lines.filter((l, idx) => l !== "" || idx > 0); + out.push(indent + bullet + first.trim()); + // Continuation lines line up under the text, not under the marker. + const pad = indent + " ".repeat(bullet.length); + for (const line of rest) out.push(line ? pad + line.trim() : ""); + }); + return out; +} + +function table(n: DocNode, o: MarkdownOptions): string[] { + const rows = (n.content || []).map((row) => + (row.content || []).map((cell) => + // A cell may hold several paragraphs; Markdown tables are one line per row. + children(cell, o) + // Pipes are already escaped by escapeText; a table row just needs the + // cell flattened to a single line. + .join(" ") + .replace(/\s*\n\s*/g, " ") + .trim() + ) + ); + if (rows.length === 0) return []; + + const width = Math.max(...rows.map((r) => r.length)); + const pad = (r: string[]) => [...r, ...Array(width - r.length).fill("")]; + const line = (r: string[]) => `| ${pad(r).join(" | ")} |`; + + const headerIsReal = (n.content?.[0]?.content || []).some((c) => c.type === "tableHeader"); + const [first, ...rest] = rows; + return headerIsReal + ? [line(first!), `|${" --- |".repeat(width)}`, ...rest.map(line)] + : // Markdown has no headerless table; an empty header row keeps it a table. + [`|${" |".repeat(width)}`, `|${" --- |".repeat(width)}`, ...rows.map(line)]; +} + +/* ── entry point ── */ + +export function noteToMarkdown(doc: DocNode, options: MarkdownOptions = {}): string { + const lines: string[] = []; + + if (options.title) { + lines.push(`# ${options.title}`, ""); + if (options.updatedAt) { + lines.push(`*${new Date(options.updatedAt).toLocaleString("de-DE")}*`, ""); + } + } + + lines.push(...children(doc, options)); + + if (options.hasDrawing) { + lines.push("", "> ✏️ Diese Notiz enthält eine Zeichnung, die Markdown nicht abbilden kann."); + lines.push("> Sie liegt als PNG neben der Datei."); + } + + // Collapse runs of blank lines; the block rules emit them generously. + return lines + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +/** Filename-safe version of a note title. */ +export function safeFilename(title: string, fallback = "Notiz"): string { + const cleaned = (title || "") + .replace(/[/\\?%*:|"<>]/g, "-") + .replace(/\s+/g, " ") + .trim() + .slice(0, 80); + return cleaned || fallback; +} diff --git a/frontend/src/export/print.ts b/frontend/src/export/print.ts new file mode 100644 index 0000000..2161125 --- /dev/null +++ b/frontend/src/export/print.ts @@ -0,0 +1,209 @@ +/** + * Notes as PDF, by way of the browser's own print dialog. + * + * No PDF library: every browser already contains a good one behind "Save as + * PDF", and it handles fonts, page breaks and hyphenation better than anything + * that would fit in this project. What is built here is the page it prints. + * + * The document is written into a hidden iframe rather than a new window, so a + * pop-up blocker cannot swallow it and the app's own styles cannot leak in. + */ + +import { parseNoteContent } from "../editor/content"; +import { parsingExtensions } from "../editor/extensions.js"; +import type { DocNode } from "../editor/content"; +import type { NoteContent, NoteMeta } from "../types"; + +const esc = (s: unknown) => + String(s ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + +/* ── document to print-ready HTML ── */ + +function inline(nodes: DocNode[] | undefined, resolveImage: (id: string) => string | null): string { + if (!Array.isArray(nodes)) return ""; + return nodes + .map((n) => { + if (n.type === "hardBreak") return "
"; + if (n.type === "noteLink") return `${esc(n.attrs?.label || "Untitled")}`; + if (n.type === "image") { + const src = n.attrs?.attachmentId ? resolveImage(n.attrs.attachmentId) : n.attrs?.src; + return src ? `${esc(n.attrs?.alt || ` : ""; + } + if (n.type !== "text" || !n.text) return ""; + + let html = esc(n.text); + for (const mark of (n as any).marks || []) { + if (mark.type === "bold") html = `${html}`; + else if (mark.type === "italic") html = `${html}`; + else if (mark.type === "underline") html = `${html}`; + else if (mark.type === "strike") html = `${html}`; + else if (mark.type === "code") html = `${html}`; + else if (mark.type === "link") html = `${html}`; + } + return html; + }) + .join(""); +} + +function blocks(nodes: DocNode[] | undefined, resolveImage: (id: string) => string | null): string { + if (!Array.isArray(nodes)) return ""; + return nodes.map((n) => blockHtml(n, resolveImage)).join("\n"); +} + +function blockHtml(n: DocNode, img: (id: string) => string | null): string { + const kids = () => blocks(n.content, img); + switch (n.type) { + case "paragraph": { + const body = inline(n.content, img); + return body.trim() ? `

${body}

` : "

 

"; + } + case "heading": { + const l = Math.min(6, Math.max(1, Number(n.attrs?.level) || 1)); + return `${inline(n.content, img)}`; + } + case "blockquote": + return `
${kids()}
`; + case "codeBlock": + return `
${esc((n.content || []).map((c) => c.text || "").join(""))}
`; + case "horizontalRule": + return "
"; + case "bulletList": + return `
    ${kids()}
`; + case "orderedList": + return `${kids()}`; + case "listItem": + return `
  • ${kids()}
  • `; + case "taskList": + return `
      ${kids()}
    `; + case "taskItem": + return `
  • ${n.attrs?.checked ? "☑" : "☐"}
    ${kids()}
  • `; + case "table": + return `${kids()}
    `; + case "tableRow": + return `${kids()}`; + case "tableHeader": + return `${kids()}`; + case "tableCell": + return `${kids()}`; + case "image": + case "noteLink": + return `

    ${inline([n], img)}

    `; + default: + return kids(); + } +} + +/* ── page ── */ + +const STYLES = ` + @page { margin: 18mm 16mm; } + * { box-sizing: border-box; } + body { + font: 11pt/1.55 "DM Sans", -apple-system, Segoe UI, Roboto, sans-serif; + color: #111; margin: 0; + } + article { page-break-after: always; } + article:last-child { page-break-after: auto; } + h1 { font-family: Newsreader, Georgia, serif; font-size: 20pt; margin: 0 0 2mm; } + h2 { font-size: 14pt; margin: 6mm 0 2mm; } + h3 { font-size: 12pt; margin: 5mm 0 2mm; } + .meta { color: #666; font-size: 9pt; margin-bottom: 6mm; + border-bottom: 1px solid #ddd; padding-bottom: 2mm; } + p { margin: 0 0 2.6mm; orphans: 2; widows: 2; } + ul, ol { margin: 0 0 3mm; padding-left: 6mm; } + li { margin-bottom: 1mm; } + ul.tasks { list-style: none; padding-left: 1mm; } + li.task { display: flex; gap: 2mm; align-items: baseline; } + li.task .box { font-size: 12pt; line-height: 1; } + li.task div p { margin: 0; } + blockquote { margin: 0 0 3mm; padding-left: 4mm; border-left: 2px solid #ccc; color: #444; } + pre { background: #f4f4f4; padding: 3mm; border-radius: 2mm; overflow-wrap: anywhere; + white-space: pre-wrap; font-size: 9.5pt; } + code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 9.5pt; } + table { border-collapse: collapse; width: 100%; margin: 0 0 3mm; page-break-inside: avoid; } + th, td { border: 1px solid #bbb; padding: 1.6mm 2.2mm; text-align: left; vertical-align: top; } + th { background: #f2f2f2; font-weight: 600; } + img { max-width: 100%; height: auto; page-break-inside: avoid; } + hr { border: none; border-top: 1px solid #ccc; margin: 4mm 0; } + .note-link { color: #0550ae; } + .note-link::before { content: "→ "; } + .drawing { margin-top: 5mm; page-break-inside: avoid; } + .drawing img { border: 1px solid #ddd; } +`; + +export interface PrintableNote { + meta: NoteMeta; + content: NoteContent; + /** Drawing rendered as a data URL, if the note has one. */ + drawing?: string | null; + /** Attachment id → data URL, for images to survive into the print document. */ + images?: Map; +} + +function articleFor(note: PrintableNote): string { + const doc = parseNoteContent(note.content.content, parsingExtensions); + const resolve = (id: string) => note.images?.get(id) || null; + const when = note.meta.updatedAt ? new Date(note.meta.updatedAt).toLocaleString("de-DE") : ""; + + return `
    +

    ${esc(note.meta.title || "Untitled")}

    + ${when ? `
    ${esc(when)}
    ` : ""} + ${blocks(doc.content, resolve)} + ${note.drawing ? `
    Zeichnung
    ` : ""} +
    `; +} + +export function buildPrintDocument(notes: PrintableNote[], title: string): string { + return ` +${esc(title)} +${notes.map(articleFor).join("\n")}`; +} + +/** + * Opens the print dialog on the given notes. + * + * Resolves once printing has been dismissed, or after a timeout — browsers are + * inconsistent about `afterprint`, and a caller left waiting forever would be + * worse than one that cleans up slightly early. + */ +export function printNotes(notes: PrintableNote[], title: string): Promise { + return new Promise((resolve) => { + const frame = document.createElement("iframe"); + frame.setAttribute("aria-hidden", "true"); + frame.style.cssText = "position:fixed;right:0;bottom:0;width:0;height:0;border:0;visibility:hidden"; + document.body.appendChild(frame); + + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + setTimeout(() => frame.remove(), 500); + resolve(); + }; + + frame.onload = () => { + const win = frame.contentWindow; + if (!win) return finish(); + win.addEventListener("afterprint", finish); + // Give images in the document a moment to decode before printing them. + setTimeout(() => { + try { + win.focus(); + win.print(); + } catch { + /* nothing sensible to do; the cleanup below still runs */ + } + setTimeout(finish, 60_000); + }, 350); + }; + + const doc = frame.contentDocument!; + doc.open(); + doc.write(buildPrintDocument(notes, title)); + doc.close(); + }); +} diff --git a/frontend/src/export/zip.test.ts b/frontend/src/export/zip.test.ts new file mode 100644 index 0000000..04f4666 --- /dev/null +++ b/frontend/src/export/zip.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from "vitest"; + +import { createZipBytes, __testing } from "./zip"; + +const { crc32, dosDateTime } = __testing; +const bytes = (s: string) => new TextEncoder().encode(s); + +/** Reads the archive back through a DataView — the format is the contract. */ +function parse(buf: Uint8Array) { + const view = new DataView(buf.buffer); + const dec = new TextDecoder(); + + // End-of-central-directory sits at the very end for archives without comments. + const endAt = buf.length - 22; + expect(view.getUint32(endAt, true)).toBe(0x06054b50); + const count = view.getUint16(endAt + 8, true); + const centralAt = view.getUint32(endAt + 16, true); + + const entries: { name: string; content: string; crc: number; size: number }[] = []; + let p = centralAt; + for (let i = 0; i < count; i++) { + expect(view.getUint32(p, true)).toBe(0x02014b50); + const crc = view.getUint32(p + 16, true); + const size = view.getUint32(p + 24, true); + const nameLen = view.getUint16(p + 28, true); + const localAt = view.getUint32(p + 42, true); + const name = dec.decode(buf.slice(p + 46, p + 46 + nameLen)); + + expect(view.getUint32(localAt, true)).toBe(0x04034b50); + const localNameLen = view.getUint16(localAt + 26, true); + const dataAt = localAt + 30 + localNameLen + view.getUint16(localAt + 28, true); + const content = dec.decode(buf.slice(dataAt, dataAt + size)); + + entries.push({ name, content, crc, size }); + p += 46 + nameLen + view.getUint16(p + 30, true) + view.getUint16(p + 32, true); + } + return entries; +} + +describe("crc32", () => { + /* Against the values every reference implementation agrees on — if this drifts, + * archives still open but every entry reports as corrupt. */ + it("matches the known checksum of a plain string", () => { + expect(crc32(bytes("hello world"))).toBe(0x0d4a1185); + }); + + it("is zero for no bytes", () => { + expect(crc32(new Uint8Array())).toBe(0); + }); + + it("changes when a single byte changes", () => { + expect(crc32(bytes("hello worlD"))).not.toBe(crc32(bytes("hello world"))); + }); +}); + +describe("dosDateTime", () => { + it("packs a date into the MS-DOS words", () => { + const { date, time } = dosDateTime(new Date(2026, 7, 8, 13, 30, 20)); + expect((date >> 9) + 1980).toBe(2026); + expect((date >> 5) & 0xf).toBe(8); + expect(date & 0x1f).toBe(8); + expect(time >> 11).toBe(13); + expect((time >> 5) & 0x3f).toBe(30); + }); +}); + +describe("createZip", () => { + it("round-trips a single text file", () => { + const zip = createZipBytes([{ name: "notiz.md", data: "# Hallo" }]); + const [entry] = parse(zip); + expect(entry!.name).toBe("notiz.md"); + expect(entry!.content).toBe("# Hallo"); + }); + + it("keeps several entries with their paths", () => { + const zip = createZipBytes([ + { name: "Buch/eins.md", data: "eins" }, + { name: "Buch/zwei.md", data: "zwei" }, + { name: "Buch/images/a.png", data: new Uint8Array([1, 2, 3]) }, + ]); + const entries = parse(zip); + expect(entries.map((e) => e.name)).toEqual(["Buch/eins.md", "Buch/zwei.md", "Buch/images/a.png"]); + expect(entries[1]!.content).toBe("zwei"); + }); + + it("records a checksum matching the data, so archivers do not report corruption", () => { + const zip = createZipBytes([{ name: "a.txt", data: "some content" }]); + const [entry] = parse(zip); + expect(entry!.crc).toBe(crc32(bytes("some content"))); + expect(entry!.size).toBe(12); + }); + + it("stores binary data unchanged", async () => { + const raw = new Uint8Array([0, 255, 128, 10, 13, 0]); + const zip = createZipBytes([{ name: "b.bin", data: raw }]); + // Data begins right after the local header and the filename. + const start = 30 + "b.bin".length; + expect(Array.from(zip.slice(start, start + raw.length))).toEqual(Array.from(raw)); + }); + + it("writes umlauts in filenames as UTF-8", () => { + const zip = createZipBytes([{ name: "Übersicht.md", data: "x" }]); + const [entry] = parse(zip); + expect(entry!.name).toBe("Übersicht.md"); + }); + + it("produces a valid empty archive", () => { + const zip = createZipBytes([]); + expect(parse(zip)).toEqual([]); + expect(zip.length).toBe(22); + }); +}); diff --git a/frontend/src/export/zip.ts b/frontend/src/export/zip.ts new file mode 100644 index 0000000..8c791fb --- /dev/null +++ b/frontend/src/export/zip.ts @@ -0,0 +1,150 @@ +/** + * A minimal ZIP writer — stored (uncompressed) entries only. + * + * Exporting a notebook means handing over many files at once, and the browser + * can only offer one download. That needs a container, and ZIP is the one every + * operating system opens without being asked twice. + * + * Written out rather than pulled in: the format's stored variant is a handful of + * fixed-layout records, which is less code than the dependency would be, and one + * fewer thing to keep updated in a project maintained by one person. Compression + * is skipped on purpose — note text is small, and images are already compressed. + */ + +const CRC_TABLE = (() => { + const t = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[i] = c >>> 0; + } + return t; +})(); + +function crc32(bytes: Uint8Array): number { + let c = 0xffffffff; + for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]!)! & 0xff]! ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +/** ZIP stores timestamps as MS-DOS date and time words. */ +function dosDateTime(d: Date): { date: number; time: number } { + return { + date: (((d.getFullYear() - 1980) & 0x7f) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(), + time: (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1), + }; +} + +export interface ZipEntry { + /** Path inside the archive; forward slashes make folders. */ + name: string; + data: Uint8Array | string; +} + +const encoder = new TextEncoder(); +const toBytes = (d: Uint8Array | string) => (typeof d === "string" ? encoder.encode(d) : d); + +/** + * The archive as bytes. + * + * Kept separate from `createZip` so the format can be verified without a Blob — + * jsdom's has no `arrayBuffer`, and a pure byte function is the easier thing to + * reason about in any case. + */ +export function createZipBytes(entries: ZipEntry[], now = new Date()): Uint8Array { + const { date, time } = dosDateTime(now); + const chunks: Uint8Array[] = []; + const central: Uint8Array[] = []; + let offset = 0; + + for (const entry of entries) { + const name = encoder.encode(entry.name); + const data = toBytes(entry.data); + const crc = crc32(data); + + const local = new DataView(new ArrayBuffer(30)); + local.setUint32(0, 0x04034b50, true); // local file header + local.setUint16(4, 20, true); // version needed + local.setUint16(6, 0x0800, true); // UTF-8 names + local.setUint16(8, 0, true); // stored, no compression + local.setUint16(10, time, true); + local.setUint16(12, date, true); + local.setUint32(14, crc, true); + local.setUint32(18, data.length, true); + local.setUint32(22, data.length, true); + local.setUint16(26, name.length, true); + local.setUint16(28, 0, true); // extra field length + + chunks.push(new Uint8Array(local.buffer), name, data); + + const dir = new DataView(new ArrayBuffer(46)); + dir.setUint32(0, 0x02014b50, true); // central directory header + dir.setUint16(4, 20, true); // version made by + dir.setUint16(6, 20, true); // version needed + dir.setUint16(8, 0x0800, true); + dir.setUint16(10, 0, true); + dir.setUint16(12, time, true); + dir.setUint16(14, date, true); + dir.setUint32(16, crc, true); + dir.setUint32(20, data.length, true); + dir.setUint32(24, data.length, true); + dir.setUint16(28, name.length, true); + dir.setUint32(42, offset, true); // where the local header sits + central.push(new Uint8Array(dir.buffer), name); + + offset += 30 + name.length + data.length; + } + + const centralSize = central.reduce((n, c) => n + c.length, 0); + const end = new DataView(new ArrayBuffer(22)); + end.setUint32(0, 0x06054b50, true); // end of central directory + end.setUint16(8, entries.length, true); + end.setUint16(10, entries.length, true); + end.setUint32(12, centralSize, true); + end.setUint32(16, offset, true); + + const parts = [...chunks, ...central, new Uint8Array(end.buffer)]; + const total = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(total); + let at = 0; + for (const part of parts) { + out.set(part, at); + at += part.length; + } + return out; +} + +/** + * Wraps bytes in a Blob. + * + * Goes through the underlying buffer rather than handing the view straight to + * `new Blob`, which TypeScript rejects because a view may sit on a shared + * buffer. The offsets are honoured, so a view over part of a larger buffer + * yields only its own bytes. + */ +export function bytesToBlob(bytes: Uint8Array, type: string): Blob { + // Copied into a plain ArrayBuffer: a view can sit on a SharedArrayBuffer, + // which Blob does not accept, and the copy also honours the view's offsets. + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return new Blob([copy.buffer], { type }); +} + +export function createZip(entries: ZipEntry[], now = new Date()): Blob { + return bytesToBlob(createZipBytes(entries, now), "application/zip"); +} + +/** Hands a blob to the browser as a download. */ +export function download(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + // Freed on the next tick; revoking immediately cancels the download in Safari. + setTimeout(() => URL.revokeObjectURL(url), 10_000); +} + +export const __testing = { crc32, dosDateTime }; diff --git a/frontend/src/features/notes/EditorPane.jsx b/frontend/src/features/notes/EditorPane.jsx index 4fbf75a..2c2ef4e 100644 --- a/frontend/src/features/notes/EditorPane.jsx +++ b/frontend/src/features/notes/EditorPane.jsx @@ -149,6 +149,7 @@ export function EditorPane({ onRestore, onPermanentDelete, onOpenHistory, + onExport, onCreateNote, }) { if (!note) @@ -195,6 +196,9 @@ export function EditorPane({ minWidth: 0, }} /> + diff --git a/frontend/src/ui/AccountMenu.jsx b/frontend/src/ui/AccountMenu.jsx index 2327bcd..3fccaa7 100644 --- a/frontend/src/ui/AccountMenu.jsx +++ b/frontend/src/ui/AccountMenu.jsx @@ -3,7 +3,7 @@ import * as sync from "../syncEngine"; import { I } from "./icons.jsx"; /* ═══════ ACCOUNT MENU ═══════ */ -export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin, dark }) { +export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin, onExportAll, dark }) { const [tab, setTab] = useState("profile"); // profile | username | password const [displayName, setDisplayName] = useState(user?.displayName || ""); const [newUsername, setNewUsername] = useState(user?.username || ""); @@ -367,6 +367,27 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin )}
    + {user?.isAdmin && ( + ); + + return ( +
    !busy && onClose()} + > +
    e.stopPropagation()} + > +
    +
    +

    + Exportieren +

    +
    + {single ? label : `${label} · ${notes.length} Notizen`} +
    +
    + +
    + +
    + {option("markdown", single ? "Als Markdown" : "Als Markdown-Archiv (ZIP)", [ + single + ? "Eine .md-Datei — Bilder und Zeichnung kommen als ZIP mit, falls vorhanden." + : "Je Notiz eine .md-Datei, dazu Bilder, Zeichnungen und ein Inhaltsverzeichnis.", + "Bleibt anderswo bearbeitbar. Tabellen und Aufgabenhaken bleiben erhalten.", + ])} + {option("pdf", "Als PDF", [ + "Öffnet den Druckdialog — dort „Als PDF sichern“ wählen.", + "Zeichnungen sind hier mit im Dokument.", + ])} +
    + + {(progress || err || done) && ( +
    + {progress && ( +
    + {progress.done}/{progress.total} — {progress.label} +
    +
    +
    +
    + )} + {err &&
    {err}
    } + {done && ( +
    + {done} +
    + )} +
    + )} + +
    + Alles läuft im Browser — es geht nichts an einen fremden Dienst. +
    +
    +
    + ); +} diff --git a/frontend/src/ui/icons.jsx b/frontend/src/ui/icons.jsx index 9744c7f..bcfac52 100644 --- a/frontend/src/ui/icons.jsx +++ b/frontend/src/ui/icons.jsx @@ -453,6 +453,22 @@ export const I = { ), + export: ( + + + + + + ), users: ( `, which every Markdown renderer passes through | + +The ZIP writer is [`export/zip.ts`](frontend/src/export/zip.ts) — about eighty +lines for stored (uncompressed) entries, rather than a dependency. Note text is +small and images are already compressed, so there was nothing to gain by +compressing, and this is one fewer thing to keep updated. + +Everything runs in the browser. Nothing is uploaded anywhere to produce a file. + +--- + ## Drawings Drawings are **vector strokes**, not bitmaps. Every stroke is recorded in one canonical