Export notes as Markdown or PDF

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 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-08 14:46:53 +02:00
1 parent 311e992496
commit 2611d87f8c
14 files changed
+1707 -2

No files matched your search

+184
View File
@@ -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);
}
+1 -1
View File
@@ -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
+31
View File
@@ -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 && (
<ExportDialog
notes={exporting.notes}
notebooks={notebooks}
label={exporting.label}
dark={dark}
onClose={() => setExporting(null)}
/>
)}
{showAdmin && user?.isAdmin && (
<AdminPanel currentUser={user} onClose={() => setShowAdmin(false)} dark={dark} />
)}
+268
View File
@@ -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<Uint8Array | null> {
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<Blob | null>((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<string>();
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<Loaded> {
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>): 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<void> {
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<string> =>
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<void> {
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<string, string>();
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<Uint8Array | null> {
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<void> {
const total = notes.length + 1;
const entries: ZipEntry[] = [];
const taken = new Set<string>();
const filenames = new Map<string, string>();
// 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<string | null, NoteMeta[]>();
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`);
}
+218
View File
@@ -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<string, unknown> }[]): 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("<u>unter</u>");
});
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"g<h>i|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);
});
});
+237
View File
@@ -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 = `<u>${out}</u>`;
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;
}
+209
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
/* ── 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 "<br>";
if (n.type === "noteLink") return `<span class="note-link">${esc(n.attrs?.label || "Untitled")}</span>`;
if (n.type === "image") {
const src = n.attrs?.attachmentId ? resolveImage(n.attrs.attachmentId) : n.attrs?.src;
return src ? `<img src="${esc(src)}" alt="${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 = `<strong>${html}</strong>`;
else if (mark.type === "italic") html = `<em>${html}</em>`;
else if (mark.type === "underline") html = `<u>${html}</u>`;
else if (mark.type === "strike") html = `<s>${html}</s>`;
else if (mark.type === "code") html = `<code>${html}</code>`;
else if (mark.type === "link") html = `<a href="${esc(mark.attrs?.href)}">${html}</a>`;
}
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() ? `<p>${body}</p>` : "<p>&nbsp;</p>";
}
case "heading": {
const l = Math.min(6, Math.max(1, Number(n.attrs?.level) || 1));
return `<h${l}>${inline(n.content, img)}</h${l}>`;
}
case "blockquote":
return `<blockquote>${kids()}</blockquote>`;
case "codeBlock":
return `<pre><code>${esc((n.content || []).map((c) => c.text || "").join(""))}</code></pre>`;
case "horizontalRule":
return "<hr>";
case "bulletList":
return `<ul>${kids()}</ul>`;
case "orderedList":
return `<ol${n.attrs?.start ? ` start="${Number(n.attrs.start)}"` : ""}>${kids()}</ol>`;
case "listItem":
return `<li>${kids()}</li>`;
case "taskList":
return `<ul class="tasks">${kids()}</ul>`;
case "taskItem":
return `<li class="task"><span class="box">${n.attrs?.checked ? "☑" : "☐"}</span><div>${kids()}</div></li>`;
case "table":
return `<table>${kids()}</table>`;
case "tableRow":
return `<tr>${kids()}</tr>`;
case "tableHeader":
return `<th>${kids()}</th>`;
case "tableCell":
return `<td>${kids()}</td>`;
case "image":
case "noteLink":
return `<p>${inline([n], img)}</p>`;
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<string, string>;
}
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 `<article>
<h1>${esc(note.meta.title || "Untitled")}</h1>
${when ? `<div class="meta">${esc(when)}</div>` : ""}
${blocks(doc.content, resolve)}
${note.drawing ? `<div class="drawing"><img src="${note.drawing}" alt="Zeichnung"></div>` : ""}
</article>`;
}
export function buildPrintDocument(notes: PrintableNote[], title: string): string {
return `<!doctype html><html lang="de"><head><meta charset="utf-8">
<title>${esc(title)}</title><style>${STYLES}</style></head>
<body>${notes.map(articleFor).join("\n")}</body></html>`;
}
/**
* 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<void> {
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();
});
}
+112
View File
@@ -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);
});
});
+150
View File
@@ -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 };
@@ -149,6 +149,7 @@ export function EditorPane({
onRestore,
onPermanentDelete,
onOpenHistory,
onExport,
onCreateNote,
}) {
if (!note)
@@ -195,6 +196,9 @@ export function EditorPane({
minWidth: 0,
}}
/>
<button onClick={onExport} title="Export" style={toolbarButton(T, T.faint)}>
{I.export}
</button>
<button onClick={onOpenHistory} title="Version history" style={toolbarButton(T, T.faint)}>
{I.history}
</button>
+22 -1
View File
@@ -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
)}
</div>
<div style={{ padding: "0 22px 18px" }}>
<button
onClick={onExportAll}
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
padding: "10px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: "transparent",
cursor: "pointer",
color: T.muted,
fontSize: 13,
fontWeight: 600,
justifyContent: "center",
marginBottom: 8,
}}
>
{I.export} Alle Notizen exportieren
</button>
{user?.isAdmin && (
<button
onClick={onOpenAdmin}
+217
View File
@@ -0,0 +1,217 @@
import { useEffect, useMemo, useState } from "react";
import { exportNoteAsMarkdown, exportNotesAsPdf, exportNotesAsZip } from "../export/exportNotes";
import { I } from "./icons.jsx";
import { FONT_SERIF, FONT_UI } from "./theme";
/**
* Export as Markdown or PDF — used for a single note, a notebook, or everything.
*
* The formats are deliberately both offered rather than one being chosen for
* you: Markdown keeps the notes editable somewhere else, PDF keeps them looking
* the way they look here. Drawings only survive in PDF and as a PNG beside the
* Markdown, which the dialog says out loud rather than leaving to be discovered.
*/
export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
const [busy, setBusy] = useState(null);
const [progress, setProgress] = useState(null);
const [err, setErr] = useState("");
const [done, setDone] = useState("");
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
surface: dark ? "#0d1117" : "#f6f8fa",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
faint: dark ? "#484f58" : "#afb8c1",
accent: dark ? "#388bfd" : "#0969da",
danger: dark ? "#f85149" : "#cf222e",
success: dark ? "#3fb950" : "#1a7f37",
}),
[dark]
);
useEffect(() => {
const onKey = (e) => e.key === "Escape" && !busy && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [busy, onClose]);
const single = notes.length === 1;
const run = async (kind) => {
setErr("");
setDone("");
setBusy(kind);
setProgress(null);
try {
if (kind === "markdown") {
if (single) await exportNoteAsMarkdown(notes[0]);
else await exportNotesAsZip({ label, notes, notebooks, onProgress: setProgress });
} else {
await exportNotesAsPdf(notes, label, setProgress);
}
setDone(
kind === "markdown"
? single
? "Datei wurde heruntergeladen."
: "Archiv wurde heruntergeladen."
: "Druckansicht war geöffnet — wähle dort „Als PDF sichern“."
);
} catch (e) {
setErr(e?.message || "Export fehlgeschlagen");
}
setBusy(null);
setProgress(null);
};
const option = (kind, title, lines) => (
<button
onClick={() => run(kind)}
disabled={!!busy}
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: 4,
width: "100%",
padding: "14px 16px",
borderRadius: 12,
border: `1px solid ${T.border}`,
background: T.surface,
cursor: busy ? "default" : "pointer",
textAlign: "left",
opacity: busy && busy !== kind ? 0.45 : 1,
marginBottom: 10,
fontFamily: FONT_UI,
}}
>
<span style={{ fontSize: 14, fontWeight: 600, color: T.text }}>{busy === kind ? "Läuft…" : title}</span>
{lines.map((l, i) => (
<span key={i} style={{ fontSize: 12, color: T.muted, lineHeight: 1.5 }}>
{l}
</span>
))}
</button>
);
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.55)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 2200,
backdropFilter: "blur(6px)",
fontFamily: FONT_UI,
}}
onClick={() => !busy && onClose()}
>
<div
data-export-dialog
style={{
background: T.bg,
borderRadius: 20,
width: 460,
maxWidth: "92vw",
border: `1px solid ${T.border}`,
boxShadow: "0 24px 48px rgba(0,0,0,0.3)",
overflow: "hidden",
}}
onClick={(e) => e.stopPropagation()}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "18px 22px",
borderBottom: `1px solid ${T.border}`,
}}
>
<div>
<h3 style={{ margin: 0, fontSize: 17, fontWeight: 600, color: T.text, fontFamily: FONT_SERIF }}>
Exportieren
</h3>
<div style={{ fontSize: 12, color: T.muted, marginTop: 2 }}>
{single ? label : `${label} · ${notes.length} Notizen`}
</div>
</div>
<button
onClick={() => !busy && onClose()}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 28,
height: 28,
borderRadius: 8,
border: "none",
background: "transparent",
cursor: busy ? "default" : "pointer",
color: T.muted,
}}
>
{I.x}
</button>
</div>
<div style={{ padding: "16px 22px 8px" }}>
{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.",
])}
</div>
{(progress || err || done) && (
<div style={{ padding: "0 22px 8px" }}>
{progress && (
<div style={{ fontSize: 12, color: T.muted }}>
{progress.done}/{progress.total} — {progress.label}
<div
style={{
height: 4,
borderRadius: 2,
background: T.border,
marginTop: 6,
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${Math.round((progress.done / Math.max(1, progress.total)) * 100)}%`,
background: T.accent,
transition: "width .2s",
}}
/>
</div>
</div>
)}
{err && <div style={{ fontSize: 12, color: T.danger }}>{err}</div>}
{done && (
<div data-export-done style={{ fontSize: 12, color: T.success }}>
{done}
</div>
)}
</div>
)}
<div style={{ padding: "8px 22px 18px", fontSize: 11, color: T.faint, lineHeight: 1.5 }}>
Alles läuft im Browser — es geht nichts an einen fremden Dienst.
</div>
</div>
</div>
);
}
+16
View File
@@ -453,6 +453,22 @@ export const I = {
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
),
export: (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="16 6 12 2 8 6" />
<line x1="12" y1="2" x2="12" y2="15" />
</svg>
),
users: (
<svg
width="14"
+38
View File
@@ -18,6 +18,7 @@ A cross-platform note-taking application with rich text editing, freehand drawin
- **PDF Import & Annotation** — Import PDFs, overlay drawing/highlighting, persistent annotations
- **Notebooks & Folders** — Create notebooks with custom gradient covers, organize notes by folder
- **Star/Favorite** — Quick access to important notes
- **Export** — Any note, notebook or the whole account as Markdown or PDF, entirely in the browser
- **Dark & Light Mode** — Full theme support with refined color schemes
### Sync & Storage
@@ -57,6 +58,7 @@ inknote/
│ │ ├── offlineStore.ts # IndexedDB wrapper
│ │ ├── search.ts # Local full-text index
│ │ ├── hooks/ # Note data, search, layout, history, drag
│ │ ├── export/ # Markdown, ZIP and print-to-PDF
│ │ ├── ui/ # Sidebar, dialogs, icons, palette
│ │ ├── features/notes/ # List, editor pane, version history
│ │ ├── features/notebooks/ # Dashboard
@@ -65,6 +67,7 @@ inknote/
│ ├── public/sw.js # PWA service worker
│ ├── nginx.conf # Static serving + API/WS proxy
│ └── dockerfile # Web container
├── e2e/ # Browser suites — run with ./e2e/run.sh
└── readme.md # This file
```
@@ -329,6 +332,7 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
| `search-attachments` | 12 | body-only matches, ranking, umlauts; images as blob URLs, not inline bytes |
| `history` | 10 | a large deletion is snapshotted, restoring works, restoring is itself undoable |
| `drawing` | 7 | strokes survive the poller, and match across four viewport widths |
| `export` | 12 | the downloaded bytes are real Markdown and a real ZIP, unpacked and read back |
Each suite creates a throwaway account through the admin API and deletes it
afterwards, so runs leave the database as they found it. They need an admin to
@@ -475,6 +479,40 @@ rows behind.
---
## Export
Notes should not be hostage to this program, so everything can leave it — from a
single note up to every note in the account, reachable from the note's toolbar,
a notebook's context menu, and Account → *Alle Notizen exportieren*.
**Markdown** keeps the notes editable elsewhere. Headings, lists, quotes, code,
tables and task state all have equivalents and are carried over; a link to
another exported note becomes a relative link to its file, and a link to a note
outside the export degrades to its plain title rather than a dead reference.
A note with images or a drawing arrives as a ZIP so the files travel with it,
and a whole notebook gets a `README.md` index grouped by notebook.
**PDF** goes through the browser's own print dialog rather than a bundled PDF
library — every browser already has a good one behind *Save as PDF*, and it
handles fonts and page breaks better than anything that would fit in here. This
is the format that keeps drawings inside the document.
Two things Markdown cannot represent, and what happens instead:
| | |
| --- | --- |
| drawings | rendered to PNG beside the note, with a line in the text saying so |
| underline | written as `<u>`, 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