Save and open documents as .num (zipped JSON)

Datei › Speichern writes a .num file: a ZIP (fflate) laid out like
OpenDocument -- an uncompressed "mimetype" entry first
(application/x-numolus), meta.json (format version, ISO 8601 creation
time) and document.json, the same JSON the .json export writes. Opening a
.num (file dialog or drag & drop) unpacks it and offers the same
replace/append choice as a .json. Non-ZIPs, foreign ZIPs, a missing
document.json and a newer format version fail with a clear message.

The zip code is loaded on first use, like xlsx, so the main bundle does
not grow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-28 02:33:05 +02:00
co-authored by Claude Opus 5.5
parent 452f13e774
commit a0680c595e
8 changed files with 158 additions and 10 deletions
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate';
import { useDocument } from '../../store/document';
import { serializeDocument } from '../json';
import { importJsonReplace, readNumFile } from '../operations';
import { NUM_MIMETYPE, NumFileError, packNum, unpackNum } from '../num';
describe('.num files', () => {
it('round-trip the document JSON exactly', async () => {
const s = useDocument();
s.resetToSeed();
const json = serializeDocument(s.snapshot());
const bytes = packNum(json);
expect(unpackNum(bytes)).toBe(json);
// and import back into the store like a .json file
s.newDocument();
importJsonReplace(s, await readNumFile(bytes.slice().buffer));
expect(serializeDocument(s.snapshot())).toBe(json);
});
it('are smaller than the JSON they carry', () => {
const s = useDocument();
s.resetToSeed();
const json = serializeDocument(s.snapshot());
expect(packNum(json).length).toBeLessThan(json.length);
});
it('start with an uncompressed mimetype entry, plus meta.json with an ISO timestamp', () => {
const bytes = packNum('{}', new Date('2026-09-28T10:00:00Z'));
// local file header (30 bytes) + "mimetype" (8) -> the stored content
expect(strFromU8(bytes.subarray(30, 38))).toBe('mimetype');
expect(strFromU8(bytes.subarray(38, 38 + NUM_MIMETYPE.length))).toBe(NUM_MIMETYPE);
const meta = JSON.parse(strFromU8(unzipSync(bytes)['meta.json']));
expect(meta).toEqual({ format: 'numolus', formatVersion: 1, created: '2026-09-28T10:00:00.000Z' });
});
it('reject what is not a numolus file', () => {
expect(() => unpackNum(strToU8('{"version":2}'))).toThrow(NumFileError);
expect(() => unpackNum(zipSync({ mimetype: strToU8('application/zip') }))).toThrow(/Keine numolus-Datei/);
expect(() => unpackNum(zipSync({ mimetype: strToU8(NUM_MIMETYPE) }))).toThrow(/kein document.json/);
const newer = zipSync({
mimetype: strToU8(NUM_MIMETYPE),
'meta.json': strToU8('{"formatVersion": 99}'),
'document.json': strToU8('{}'),
});
expect(() => unpackNum(newer)).toThrow(/neueren numolus-Version/);
});
});
+62
View File
@@ -0,0 +1,62 @@
/**
* The native `.num` document file: a ZIP container around the same JSON the
* `.json` export writes (json.ts), so it's smaller on disk and leaves room
* for more parts later (images, attachments) without changing the format.
*
* Layout, modelled on OpenDocument:
* mimetype "application/x-numolus", stored uncompressed as the
* first entry, so a file sniffer can identify the format
* from a fixed offset
* meta.json { format, formatVersion, created (ISO 8601) }
* document.json the document, exactly as `serializeDocument()` writes it
*
* Reading only needs `document.json`; `mimetype` is checked when present so
* a random ZIP renamed to `.num` fails with a clear message.
*/
import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate';
export const NUM_MIMETYPE = 'application/x-numolus';
export const NUM_FORMAT_VERSION = 1;
export class NumFileError extends Error {}
export function packNum(documentJson: string, now: Date = new Date()): Uint8Array {
const meta = { format: 'numolus', formatVersion: NUM_FORMAT_VERSION, created: now.toISOString() };
return zipSync({
mimetype: [strToU8(NUM_MIMETYPE), { level: 0 }],
'meta.json': strToU8(JSON.stringify(meta, null, 2)),
'document.json': strToU8(documentJson),
});
}
/** The `document.json` text inside a `.num` file. */
export function unpackNum(bytes: Uint8Array): string {
let files: Record<string, Uint8Array>;
try {
files = unzipSync(bytes);
} catch {
throw new NumFileError('Keine gültige .num-Datei (kein ZIP-Archiv).');
}
const mimetype = files.mimetype ? strFromU8(files.mimetype).trim() : null;
if (mimetype !== null && mimetype !== NUM_MIMETYPE) {
throw new NumFileError(`Keine numolus-Datei (Typ "${mimetype}").`);
}
const doc = files['document.json'];
if (!doc) throw new NumFileError('Die .num-Datei enthält kein document.json.');
const meta = files['meta.json'] ? safeJson(strFromU8(files['meta.json'])) : null;
const version = meta && typeof meta === 'object' ? (meta as { formatVersion?: unknown }).formatVersion : undefined;
if (typeof version === 'number' && version > NUM_FORMAT_VERSION) {
throw new NumFileError(
`Die Datei stammt aus einer neueren numolus-Version (Format ${version}); bitte die App aktualisieren.`,
);
}
return strFromU8(doc);
}
function safeJson(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
+14
View File
@@ -48,6 +48,20 @@ export function exportJson(store: DocumentStore): void {
downloadText('dokument.json', serializeDocument(store.snapshot()), 'application/json');
}
/** Save as the native `.num` file (the JSON document, zipped -- see num.ts).
* The zip code loads on first use, like the xlsx support. */
export async function exportNum(store: DocumentStore): Promise<void> {
const { packNum, NUM_MIMETYPE } = await import('./num');
const bytes = packNum(serializeDocument(store.snapshot()));
downloadBlob('dokument.num', new Blob([bytes], { type: NUM_MIMETYPE }));
}
/** The document JSON inside a `.num` file -- then imported like a `.json`. */
export async function readNumFile(buffer: ArrayBuffer): Promise<string> {
const { unpackNum } = await import('./num');
return unpackNum(new Uint8Array(buffer));
}
export function exportActiveTableCsv(store: DocumentStore, opts: CsvExportOptions): void {
const table = store.activeTable.value;
if (!table) throw new Error('Keine Tabelle ausgewählt.');