diff --git a/README.md b/README.md index da2bfc2..db092f6 100644 --- a/README.md +++ b/README.md @@ -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` + `` + 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 -`` **und** Drag & Drop auf die Canvas; `.json` `.csv` `.xlsx`. +`` **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, … diff --git a/package-lock.json b/package-lock.json index 55a347c..a8cb3ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index d188172..c0bd151 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "chart.js": "^4.4.6", + "fflate": "^0.8.3", "vue": "^3.5.13", "xlsx": "^0.18.5" }, @@ -32,4 +33,4 @@ "vitest": "^2.1.5", "vue-tsc": "^2.1.10" } -} \ No newline at end of file +} diff --git a/src/components/AppToolbar.vue b/src/components/AppToolbar.vue index fdc18f5..84d1ead 100644 --- a/src/components/AppToolbar.vue +++ b/src/components/AppToolbar.vue @@ -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() {
  • -
  • +
  • +
  • @@ -344,7 +359,7 @@ function onSample() { - + Verarbeite … diff --git a/src/components/SheetCanvas.vue b/src/components/SheetCanvas.vue index f1ab11a..0fd3da6 100644 --- a/src/components/SheetCanvas.vue +++ b/src/components/SheetCanvas.vue @@ -133,7 +133,7 @@ function selectObj(id: string) { -
    Datei ablegen (.json .csv .xlsx)
    +
    Datei ablegen (.num .json .csv .xlsx)
    diff --git a/src/io/__tests__/num.spec.ts b/src/io/__tests__/num.spec.ts new file mode 100644 index 0000000..6eebdd7 --- /dev/null +++ b/src/io/__tests__/num.spec.ts @@ -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/); + }); +}); diff --git a/src/io/num.ts b/src/io/num.ts new file mode 100644 index 0000000..92564fc --- /dev/null +++ b/src/io/num.ts @@ -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; + 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; + } +} diff --git a/src/io/operations.ts b/src/io/operations.ts index 626f539..7e6055b 100644 --- a/src/io/operations.ts +++ b/src/io/operations.ts @@ -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 { + 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 { + 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.');