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
+17 -2
View File
@@ -12,6 +12,8 @@ import ToolbarIcon, { type ToolbarIconName } from './ToolbarIcon.vue';
import { WICOM, WICOM_SOCIAL } from '../config/brand';
import {
exportJson,
exportNum,
readNumFile,
exportActiveTableCsv,
exportXlsx,
importJsonReplace,
@@ -107,6 +109,15 @@ function runCsvExport() {
toasts.error((err as Error).message);
}
}
async function runNumExport() {
try {
await exportNum(store);
toasts.success('Dokument als .num gespeichert.');
} catch (err) {
toasts.error(`Speichern fehlgeschlagen: ${(err as Error).message}`);
}
}
function runJsonExport() {
exportJson(store);
toasts.success('Dokument als JSON exportiert.');
@@ -159,6 +170,9 @@ async function handleFile(file: File) {
try {
if (ext === 'json') {
importCtx.value = { kind: 'json', text: await readAsText(file), fileName: file.name };
} else if (ext === 'num') {
// a zipped JSON document -- unpacked, then the same replace/append choice
importCtx.value = { kind: 'json', text: await readNumFile(await readAsArrayBuffer(file)), fileName: file.name };
} else if (ext === 'csv') {
importCtx.value = {
kind: 'csv',
@@ -324,7 +338,8 @@ function onSample() {
<li><button @click="onNew">Neu</button></li>
<li><button @click="onSample">Muster · Umsatzanalyse</button></li>
<li class="sep" />
<li><button @click="pickFile">Importieren … (.json .csv .xlsx)</button></li>
<li><button @click="pickFile">Öffnen / Importieren … (.num .json .csv .xlsx)</button></li>
<li><button @click="runNumExport">Speichern · Dokument (.num)</button></li>
<li class="sep" />
<li><button @click="runJsonExport">Export · Dokument (.json)</button></li>
<li><button @click="openCsvExport">Export · aktive Tabelle (.csv) …</button></li>
@@ -344,7 +359,7 @@ function onSample() {
<ToolbarIcon :name="themeIcon" />
</button>
<input ref="fileInput" type="file" accept=".json,.csv,.xlsx" hidden @change="onFileChosen" />
<input ref="fileInput" type="file" accept=".num,.json,.csv,.xlsx" hidden @change="onFileChosen" />
<span v-if="busy" class="busy">Verarbeite …</span>
</header>
+1 -1
View File
@@ -133,7 +133,7 @@ function selectObj(id: string) {
</div>
</div>
<div v-if="dropActive" class="drop">Datei ablegen (.json .csv .xlsx)</div>
<div v-if="dropActive" class="drop">Datei ablegen (.num .json .csv .xlsx)</div>
</div>
</template>
+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.');