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:
@@ -12,7 +12,7 @@ Eine browserbasierte Tabellenkalkulation nach dem Vorbild von **Apple Numbers**.
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # Vite-Devserver
|
||||
npm run test # Vitest-Suite (Engine + Store + IO), 566 Tests
|
||||
npm run test # Vitest-Suite (Engine + Store + IO), 570 Tests
|
||||
npm run build # Typecheck + Produktionsbuild
|
||||
npm run lint # ESLint (flat config)
|
||||
```
|
||||
@@ -1088,15 +1088,16 @@ Alles clientseitig (`Blob` + `URL.createObjectURL` + `<a download>` + revoke).
|
||||
### Export
|
||||
| Format | Inhalt |
|
||||
|--------|--------|
|
||||
| `.json` | **Natives Format**, verlustfreier Round-Trip: alle Blätter, alle Tabellen-/Diagramm-Objekte mit Position & Größe, Rohformeln, Spaltenbreiten, Tab-Reihenfolge, aktives Objekt, Zellauswahl. |
|
||||
| `.num` | **Speicherformat** (Datei › Speichern): ZIP-Container mit demselben JSON wie `.json`, nur kleiner. Aufbau wie OpenDocument: zuerst `mimetype` (`application/x-numolus`, unkomprimiert), dann `meta.json` (Format-Version, Erstellzeit als ISO 8601) und `document.json`. Eine Datei aus einer neueren Format-Version wird mit klarer Meldung abgelehnt. |
|
||||
| `.json` | **Natives Format** (unkomprimiert), verlustfreier Round-Trip: alle Blätter, alle Tabellen-/Diagramm-Objekte mit Position & Größe, Rohformeln, Spaltenbreiten, Tab-Reihenfolge, aktives Objekt, Zellauswahl. |
|
||||
| `.csv` | Aktive Tabelle, **berechnete Werte**, RFC-4180-Quoting, Trennzeichen `,`/`;` wählbar, `\r\n`, optional UTF-8-BOM. |
|
||||
| `.xlsx` | Arbeitsmappe via SheetJS, **ein Worksheet je Tabellen-Objekt**. Formeln als echte Excel-Formeln (`;`→`,`, `AVG`→`AVERAGE`), immer mit gecachtem Wert. |
|
||||
| `.png` | Gewähltes Diagramm-Objekt über `chart.toBase64Image()`. |
|
||||
|
||||
### Import
|
||||
`<input type="file">` **und** Drag & Drop auf die Canvas; `.json` `.csv` `.xlsx`.
|
||||
`<input type="file">` **und** Drag & Drop auf die Canvas; `.num` `.json` `.csv` `.xlsx`.
|
||||
|
||||
- **`.json`** → Dokument *ersetzen* (Rückfrage) oder Blätter *anhängen*.
|
||||
- **`.num`** / **`.json`** → Dokument *ersetzen* (Rückfrage) oder Blätter *anhängen*.
|
||||
**v1-Dokumente** (altes Ein-Raster-Format) werden automatisch nach v2
|
||||
migriert (je altes Blatt ein Blatt mit einer gleichnamigen Tabelle). Schema
|
||||
wird validiert; bei Fehler klare Meldung, **kein Teil-Import**.
|
||||
@@ -1132,7 +1133,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
|
||||
|
||||
## Tests
|
||||
|
||||
`npm run test` — Vitest, 566 Tests:
|
||||
`npm run test` — Vitest, 570 Tests:
|
||||
|
||||
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
|
||||
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
|
||||
@@ -1500,7 +1501,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
|
||||
src/
|
||||
engine/ # Formel-Engine (Eigenbau) + Unit-Tests
|
||||
store/ # reaktiver Dokument-Store (v2 Canvas-Modell), Formatierung
|
||||
io/ # csv / json (Eigenbau, inkl. v1→v2), xlsx (SheetJS, lazy)
|
||||
io/ # csv / json (Eigenbau, inkl. v1→v2), num (ZIP via fflate, lazy), xlsx (SheetJS, lazy)
|
||||
components/ # AppToolbar, SettingsPanel, FormulaBar, SheetCanvas, CanvasObject,
|
||||
# TableGrid, ChartView, TextBox, ButtonView, MacrosModal, SheetTabs,
|
||||
# StatusBar, TheLogo, …
|
||||
|
||||
Generated
+7
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"chart.js": "^4.4.6",
|
||||
"fflate": "^0.8.3",
|
||||
"vue": "^3.5.13",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
@@ -2952,6 +2953,12 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.4.6",
|
||||
"fflate": "^0.8.3",
|
||||
"vue": "^3.5.13",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
|
||||
Reference in New Issue
Block a user