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
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run dev # Vite-Devserver
|
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 build # Typecheck + Produktionsbuild
|
||||||
npm run lint # ESLint (flat config)
|
npm run lint # ESLint (flat config)
|
||||||
```
|
```
|
||||||
@@ -1088,15 +1088,16 @@ Alles clientseitig (`Blob` + `URL.createObjectURL` + `<a download>` + revoke).
|
|||||||
### Export
|
### Export
|
||||||
| Format | Inhalt |
|
| 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. |
|
| `.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. |
|
| `.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()`. |
|
| `.png` | Gewähltes Diagramm-Objekt über `chart.toBase64Image()`. |
|
||||||
|
|
||||||
### Import
|
### 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
|
**v1-Dokumente** (altes Ein-Raster-Format) werden automatisch nach v2
|
||||||
migriert (je altes Blatt ein Blatt mit einer gleichnamigen Tabelle). Schema
|
migriert (je altes Blatt ein Blatt mit einer gleichnamigen Tabelle). Schema
|
||||||
wird validiert; bei Fehler klare Meldung, **kein Teil-Import**.
|
wird validiert; bei Fehler klare Meldung, **kein Teil-Import**.
|
||||||
@@ -1132,7 +1133,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
|
|||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
`npm run test` — Vitest, 566 Tests:
|
`npm run test` — Vitest, 570 Tests:
|
||||||
|
|
||||||
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
|
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
|
||||||
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
|
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/
|
src/
|
||||||
engine/ # Formel-Engine (Eigenbau) + Unit-Tests
|
engine/ # Formel-Engine (Eigenbau) + Unit-Tests
|
||||||
store/ # reaktiver Dokument-Store (v2 Canvas-Modell), Formatierung
|
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,
|
components/ # AppToolbar, SettingsPanel, FormulaBar, SheetCanvas, CanvasObject,
|
||||||
# TableGrid, ChartView, TextBox, ButtonView, MacrosModal, SheetTabs,
|
# TableGrid, ChartView, TextBox, ButtonView, MacrosModal, SheetTabs,
|
||||||
# StatusBar, TheLogo, …
|
# StatusBar, TheLogo, …
|
||||||
|
|||||||
Generated
+7
@@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chart.js": "^4.4.6",
|
"chart.js": "^4.4.6",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
@@ -2952,6 +2953,12 @@
|
|||||||
"reusify": "^1.0.4"
|
"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": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chart.js": "^4.4.6",
|
"chart.js": "^4.4.6",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import ToolbarIcon, { type ToolbarIconName } from './ToolbarIcon.vue';
|
|||||||
import { WICOM, WICOM_SOCIAL } from '../config/brand';
|
import { WICOM, WICOM_SOCIAL } from '../config/brand';
|
||||||
import {
|
import {
|
||||||
exportJson,
|
exportJson,
|
||||||
|
exportNum,
|
||||||
|
readNumFile,
|
||||||
exportActiveTableCsv,
|
exportActiveTableCsv,
|
||||||
exportXlsx,
|
exportXlsx,
|
||||||
importJsonReplace,
|
importJsonReplace,
|
||||||
@@ -107,6 +109,15 @@ function runCsvExport() {
|
|||||||
toasts.error((err as Error).message);
|
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() {
|
function runJsonExport() {
|
||||||
exportJson(store);
|
exportJson(store);
|
||||||
toasts.success('Dokument als JSON exportiert.');
|
toasts.success('Dokument als JSON exportiert.');
|
||||||
@@ -159,6 +170,9 @@ async function handleFile(file: File) {
|
|||||||
try {
|
try {
|
||||||
if (ext === 'json') {
|
if (ext === 'json') {
|
||||||
importCtx.value = { kind: 'json', text: await readAsText(file), fileName: file.name };
|
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') {
|
} else if (ext === 'csv') {
|
||||||
importCtx.value = {
|
importCtx.value = {
|
||||||
kind: 'csv',
|
kind: 'csv',
|
||||||
@@ -324,7 +338,8 @@ function onSample() {
|
|||||||
<li><button @click="onNew">Neu</button></li>
|
<li><button @click="onNew">Neu</button></li>
|
||||||
<li><button @click="onSample">Muster · Umsatzanalyse</button></li>
|
<li><button @click="onSample">Muster · Umsatzanalyse</button></li>
|
||||||
<li class="sep" />
|
<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 class="sep" />
|
||||||
<li><button @click="runJsonExport">Export · Dokument (.json)</button></li>
|
<li><button @click="runJsonExport">Export · Dokument (.json)</button></li>
|
||||||
<li><button @click="openCsvExport">Export · aktive Tabelle (.csv) …</button></li>
|
<li><button @click="openCsvExport">Export · aktive Tabelle (.csv) …</button></li>
|
||||||
@@ -344,7 +359,7 @@ function onSample() {
|
|||||||
<ToolbarIcon :name="themeIcon" />
|
<ToolbarIcon :name="themeIcon" />
|
||||||
</button>
|
</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>
|
<span v-if="busy" class="busy">Verarbeite …</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ function selectObj(id: string) {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</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');
|
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 {
|
export function exportActiveTableCsv(store: DocumentStore, opts: CsvExportOptions): void {
|
||||||
const table = store.activeTable.value;
|
const table = store.activeTable.value;
|
||||||
if (!table) throw new Error('Keine Tabelle ausgewählt.');
|
if (!table) throw new Error('Keine Tabelle ausgewählt.');
|
||||||
|
|||||||
Reference in New Issue
Block a user