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

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