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:
1 parent
311e992496
commit
2611d87f8c
14 files changed
+1707
-2
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user