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>
151 lines
5.3 KiB
TypeScript
151 lines
5.3 KiB
TypeScript
/**
|
|
* 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 };
|