Add Rails backend: accounts via magic link, server documents, sharing
The app becomes a Rails 8 application; the Vue frontend moves to app/frontend and is served through vite_rails. - Without an account documents stay in the browser (IndexedDB). Sign-in is by magic link or the 6-digit code from the same mail; afterwards local documents can be uploaded into the account. - Server documents (/d/<id>) with versioned saves and a single-editor lock per browser tab; others see them read-only and follow by polling. - Sharing via link (works without an account) and via e-mail invitation, roles viewer/editor; the owner manages links and people. - Store gains a read-only mode that blocks all content changes. - Solid Queue, Cache and Cable share the one PostgreSQL database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { IDBFactory } from 'fake-indexeddb';
|
||||
import { DocumentLibrary, newDocId, type DocMeta } from '../library';
|
||||
|
||||
const meta = (id: string, title: string, updatedAt: number): DocMeta => ({
|
||||
id,
|
||||
title,
|
||||
createdAt: 1,
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
/** a fresh, empty in-memory IndexedDB per test */
|
||||
const openLib = () => DocumentLibrary.open(new IDBFactory());
|
||||
|
||||
describe('DocumentLibrary', () => {
|
||||
it('stores meta and content separately and reads them back', async () => {
|
||||
const lib = await openLib();
|
||||
await lib.put(meta('a', 'Budget', 10), '{"x":1}');
|
||||
expect(await lib.getMeta('a')).toEqual(meta('a', 'Budget', 10));
|
||||
expect(await lib.getContent('a')).toBe('{"x":1}');
|
||||
expect(await lib.getContent('missing')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lists documents most recently changed first', async () => {
|
||||
const lib = await openLib();
|
||||
await lib.put(meta('old', 'Alt', 10), '{}');
|
||||
await lib.put(meta('new', 'Neu', 30), '{}');
|
||||
await lib.put(meta('mid', 'Mitte', 20), '{}');
|
||||
expect((await lib.list()).map((d) => d.id)).toEqual(['new', 'mid', 'old']);
|
||||
});
|
||||
|
||||
it('patchMeta renames without touching content; unknown ids are ignored', async () => {
|
||||
const lib = await openLib();
|
||||
await lib.put(meta('a', 'Alt', 10), '{"keep":true}');
|
||||
await lib.patchMeta('a', { title: 'Neu' });
|
||||
await lib.patchMeta('nope', { title: 'X' });
|
||||
expect((await lib.getMeta('a'))?.title).toBe('Neu');
|
||||
expect(await lib.getContent('a')).toBe('{"keep":true}');
|
||||
expect(await lib.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('remove deletes meta and content', async () => {
|
||||
const lib = await openLib();
|
||||
await lib.put(meta('a', 'A', 10), '{}');
|
||||
await lib.remove('a');
|
||||
expect(await lib.getMeta('a')).toBeUndefined();
|
||||
expect(await lib.getContent('a')).toBeUndefined();
|
||||
expect(await lib.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps data across re-opening the same database', async () => {
|
||||
const factory = new IDBFactory();
|
||||
await (await DocumentLibrary.open(factory)).put(meta('a', 'A', 10), '{"v":2}');
|
||||
expect(await (await DocumentLibrary.open(factory)).getContent('a')).toBe('{"v":2}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('newDocId', () => {
|
||||
it('produces distinct v4 UUIDs', () => {
|
||||
const a = newDocId();
|
||||
expect(a).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||
expect(newDocId()).not.toBe(a);
|
||||
});
|
||||
});
|
||||
@@ -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,178 @@
|
||||
/**
|
||||
* Import/export acceptance checks at the pure (de)serialisation layer -- the
|
||||
* `<a download>` glue is trivial and browser-only, so it is not exercised here.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { useDocument, type DocumentStore } from '../../store/document';
|
||||
import { isError } from '../../engine';
|
||||
import { serializeDocument, parseDocument, JsonImportError } from '../json';
|
||||
import { tableToCsv, parseCsv, matrixToCells } from '../csv';
|
||||
import { importJsonReplace } from '../operations';
|
||||
|
||||
function fresh(): DocumentStore {
|
||||
const s = useDocument();
|
||||
s.resetToSeed();
|
||||
return s;
|
||||
}
|
||||
function tableId(s: DocumentStore, name: string): string {
|
||||
for (const sh of s.doc.sheets) {
|
||||
const o = sh.objects.find((x) => x.kind === 'table' && x.name === name);
|
||||
if (o) return o.id;
|
||||
}
|
||||
throw new Error(name);
|
||||
}
|
||||
const coords = (a1: string) => {
|
||||
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
|
||||
return {
|
||||
col: m[1].split('').reduce((n, c) => n * 26 + (c.charCodeAt(0) - 64), 0) - 1,
|
||||
row: Number(m[2]) - 1,
|
||||
};
|
||||
};
|
||||
|
||||
describe('JSON native format', () => {
|
||||
it('round-trips a document losslessly (export → reset → import → identical)', () => {
|
||||
const s = fresh();
|
||||
const before = s.snapshot();
|
||||
const json = serializeDocument(before);
|
||||
|
||||
s.addSheet('scratch');
|
||||
s.setCellInput(tableId(s, 'Umsatz'), 1, 1, '999999');
|
||||
|
||||
s.replaceDocument(parseDocument(json));
|
||||
expect(s.snapshot()).toEqual(before);
|
||||
});
|
||||
|
||||
it('rejects corrupt / empty input and leaves the document untouched', () => {
|
||||
const s = fresh();
|
||||
const before = s.snapshot();
|
||||
|
||||
expect(() => importJsonReplace(s, '{ not json')).toThrow(JsonImportError);
|
||||
expect(() => importJsonReplace(s, JSON.stringify({ version: 2, sheets: [] }))).toThrow(
|
||||
JsonImportError,
|
||||
);
|
||||
expect(() => importJsonReplace(s, JSON.stringify({ version: 7 }))).toThrow(JsonImportError);
|
||||
|
||||
expect(s.snapshot()).toEqual(before);
|
||||
});
|
||||
|
||||
it('an unknown table name in an imported formula yields #REF, no crash', () => {
|
||||
const s = fresh();
|
||||
const doc = {
|
||||
version: 2,
|
||||
sheets: [
|
||||
{
|
||||
id: 's1',
|
||||
name: 'S1',
|
||||
objects: [
|
||||
{
|
||||
kind: 'table',
|
||||
id: 't1',
|
||||
name: 'T1',
|
||||
x: 0,
|
||||
y: 0,
|
||||
rows: 4,
|
||||
cols: 4,
|
||||
colWidths: {},
|
||||
cells: { '0,0': '=Nirgends!A1', '1,0': '=A1+1' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
activeSheetId: 's1',
|
||||
activeObjectId: null,
|
||||
selection: null,
|
||||
};
|
||||
importJsonReplace(s, JSON.stringify(doc));
|
||||
const a1 = s.cellValue('t1', 0, 0);
|
||||
const b1 = s.cellValue('t1', 1, 0);
|
||||
expect(isError(a1) && a1.code).toBe('#REF');
|
||||
expect(isError(b1) && b1.code).toBe('#REF'); // error propagates
|
||||
});
|
||||
|
||||
it('normalises a v2 doc saved by an older build (missing table.view)', () => {
|
||||
const stale = {
|
||||
version: 2,
|
||||
sheets: [
|
||||
{
|
||||
id: 's1',
|
||||
name: 'S1',
|
||||
objects: [
|
||||
// no `view` key -- as written before the frozen-panes feature
|
||||
{ kind: 'table', id: 't1', name: 'T1', x: 10, y: 10, rows: 8, cols: 5, colWidths: {}, cells: {} },
|
||||
],
|
||||
},
|
||||
],
|
||||
activeSheetId: 's1',
|
||||
activeObjectId: null,
|
||||
selection: null,
|
||||
};
|
||||
const doc = parseDocument(JSON.stringify(stale));
|
||||
const t = doc.sheets[0].objects[0];
|
||||
expect(t.kind).toBe('table');
|
||||
if (t.kind === 'table') {
|
||||
expect(t.view).toEqual({ mode: 'auto', width: 640, height: 320, frozenRows: 0, frozenCols: 0 });
|
||||
}
|
||||
});
|
||||
|
||||
it('migrates a v1 document on import', () => {
|
||||
const s = fresh();
|
||||
const v1 = JSON.stringify({
|
||||
version: 1,
|
||||
sheets: [
|
||||
{ id: 'a', name: 'Zahlen', rows: 5, cols: 3, colWidths: {}, cells: { '0,0': '10', '0,1': '=A1*3' } },
|
||||
],
|
||||
activeSheetId: 'a',
|
||||
selection: null,
|
||||
});
|
||||
importJsonReplace(s, v1);
|
||||
expect(s.doc.version).toBe(2);
|
||||
const id = tableId(s, 'Zahlen');
|
||||
expect(s.cellValue(id, 0, 1)).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSV', () => {
|
||||
it('exports computed values of "Übersicht" and re-imports to the same numbers', () => {
|
||||
const s = fresh();
|
||||
const src = tableId(s, 'Übersicht');
|
||||
const csv = tableToCsv(s, src, { delimiter: ',', bom: false, crlf: true });
|
||||
|
||||
expect(csv.includes('\r\n')).toBe(true);
|
||||
// header row is text, body carries the computed numbers
|
||||
expect(csv.split('\r\n')[0]).toBe('Region,Umsatz Jahr,Kosten Jahr,Deckungsbeitrag,Marge %');
|
||||
|
||||
const { cells, rows, cols } = matrixToCells(parseCsv(csv, ','), true);
|
||||
const copyId = s.addTableFromCells(s.doc.sheets[1].id, 'Kopie', cells, rows, cols);
|
||||
|
||||
for (const a1 of ['B2', 'C2', 'D2', 'B8', 'C8', 'D8']) {
|
||||
const { col, row } = coords(a1);
|
||||
expect(s.cellValue(copyId, col, row)).toBe(s.cellValue(src, col, row));
|
||||
}
|
||||
});
|
||||
|
||||
it('RFC-4180 quoting: a field with a comma is wrapped and survives the round-trip', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.sheets[1].id);
|
||||
s.setCellInput(id, 0, 0, 'Müller, Hans');
|
||||
s.setCellInput(id, 1, 0, 'x"y');
|
||||
const csv = tableToCsv(s, id, { delimiter: ',', bom: false, crlf: true });
|
||||
expect(csv).toContain('"Müller, Hans"');
|
||||
expect(csv).toContain('"x""y"');
|
||||
const [line] = parseCsv(csv, ',');
|
||||
expect(line).toEqual(['Müller, Hans', 'x"y']);
|
||||
});
|
||||
|
||||
it('a CSV cell "=1+2" becomes a formula that evaluates to 3', () => {
|
||||
const s = fresh();
|
||||
const { cells, rows, cols } = matrixToCells(parseCsv('=1+2\n', ','), true);
|
||||
const id = s.addTableFromCells(s.doc.sheets[1].id, 'Rechnen', cells, rows, cols);
|
||||
expect(s.cellValue(id, 0, 0)).toBe(3);
|
||||
});
|
||||
|
||||
it('locale number detection (de vs en)', () => {
|
||||
const { cells } = matrixToCells(parseCsv('1.234,56;7,89;1,234.50\n', ';'), true);
|
||||
expect(cells['0,0']).toBe('1234.56');
|
||||
expect(cells['1,0']).toBe('7.89');
|
||||
expect(cells['2,0']).toBe('1234.50');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* The numolus server API (Rails, /api). JSON in and out, the session cookie
|
||||
* for the signed-in user, the CSRF token from the page's meta tag, and two
|
||||
* per-request extras: X-Client-Id (this browser tab, the edit-lock holder)
|
||||
* and X-Share-Token (a share link's token, for access via link).
|
||||
*/
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public body: Record<string, unknown>,
|
||||
) {
|
||||
super(ApiError.describe(status, body));
|
||||
}
|
||||
static describe(status: number, body: Record<string, unknown>): string {
|
||||
if (status === 0) return 'Keine Verbindung zum Server.';
|
||||
if (status === 401) return 'Nicht angemeldet.';
|
||||
if (status === 404) return 'Nicht gefunden oder kein Zugriff.';
|
||||
if (status === 429) return 'Zu viele Versuche – bitte später noch einmal.';
|
||||
const msgs = body.messages;
|
||||
if (Array.isArray(msgs) && msgs.length) return msgs.join(', ');
|
||||
return `Serverfehler (${status}).`;
|
||||
}
|
||||
}
|
||||
|
||||
export type Role = 'owner' | 'editor' | 'viewer';
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
display_name: string | null;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface ServerDocMeta {
|
||||
id: string;
|
||||
title: string;
|
||||
version: number;
|
||||
role: Role;
|
||||
owner_name: string;
|
||||
updated_at: string;
|
||||
updated_by_name: string | null;
|
||||
}
|
||||
|
||||
export interface ServerDoc extends ServerDocMeta {
|
||||
content?: string;
|
||||
lock?: { locked_by_other: boolean; holder_name: string | null };
|
||||
}
|
||||
|
||||
export interface ShareInfo {
|
||||
links: { id: number; role: 'viewer' | 'editor'; token: string; created_at: string }[];
|
||||
members: { user_id: number; name: string; email: string; role: 'viewer' | 'editor'; source: 'invite' | 'link' }[];
|
||||
invitations: { email: string; role: 'viewer' | 'editor'; expires_at: string }[];
|
||||
}
|
||||
|
||||
/** One id per browser tab, kept across reloads of that tab. */
|
||||
export const clientId: string = (() => {
|
||||
const key = 'numolus:client-id';
|
||||
try {
|
||||
const existing = sessionStorage.getItem(key);
|
||||
if (existing) return existing;
|
||||
const id = Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
sessionStorage.setItem(key, id);
|
||||
return id;
|
||||
} catch {
|
||||
return Math.random().toString(36).slice(2);
|
||||
}
|
||||
})();
|
||||
|
||||
function csrfToken(): string {
|
||||
return document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
|
||||
}
|
||||
|
||||
interface RequestOpts {
|
||||
body?: unknown;
|
||||
shareToken?: string | null;
|
||||
/** survives the page being closed (small bodies only, < 64 KB) */
|
||||
keepalive?: boolean;
|
||||
}
|
||||
|
||||
export async function api<T = Record<string, unknown>>(method: string, path: string, opts: RequestOpts = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-Token': csrfToken(),
|
||||
'X-Client-Id': clientId,
|
||||
};
|
||||
if (opts.body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
if (opts.shareToken) headers['X-Share-Token'] = opts.shareToken;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`/api${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
||||
credentials: 'same-origin',
|
||||
keepalive: opts.keepalive,
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(0, {});
|
||||
}
|
||||
if (res.status === 204) return {} as T;
|
||||
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
if (!res.ok) throw new ApiError(res.status, body);
|
||||
return body as T;
|
||||
}
|
||||
|
||||
/** Data the server put into the page (see app/views/spa/show.html.erb). */
|
||||
export function bootData(): { user: User | null } {
|
||||
try {
|
||||
const el = document.getElementById('numolus-boot');
|
||||
return el ? JSON.parse(el.textContent || '{}') : { user: null };
|
||||
} catch {
|
||||
return { user: null };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* CSV import/export -- hand-written, no library.
|
||||
*
|
||||
* Export follows RFC 4180: fields containing the delimiter, a double quote, CR
|
||||
* or LF are wrapped in double quotes and inner quotes are doubled. Line ending
|
||||
* is CRLF, optional UTF-8 BOM for Excel. Delimiter is "," or ";".
|
||||
*
|
||||
* Import: a small state-machine parser that understands quoted fields with
|
||||
* embedded delimiters / newlines, plus delimiter sniffing and de/en locale
|
||||
* number normalisation. A leading "=" makes the cell a formula.
|
||||
*/
|
||||
|
||||
import type { DocumentStore } from '../store/document';
|
||||
import { rawString } from '../store/format';
|
||||
|
||||
export type Delimiter = ',' | ';' | '\t';
|
||||
|
||||
export interface CsvExportOptions {
|
||||
delimiter: Delimiter;
|
||||
bom: boolean;
|
||||
crlf: boolean;
|
||||
}
|
||||
|
||||
// ---- export --------------------------------------------------------------
|
||||
|
||||
function needsQuoting(field: string, delimiter: string): boolean {
|
||||
return (
|
||||
field.includes(delimiter) ||
|
||||
field.includes('"') ||
|
||||
field.includes('\n') ||
|
||||
field.includes('\r')
|
||||
);
|
||||
}
|
||||
|
||||
function quoteField(field: string, delimiter: string): string {
|
||||
if (!needsQuoting(field, delimiter)) return field;
|
||||
return `"${field.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
/** Bounding box of the used area of a sheet. */
|
||||
function usedBounds(cells: Record<string, string>): { rows: number; cols: number } {
|
||||
let rows = 0;
|
||||
let cols = 0;
|
||||
for (const key of Object.keys(cells)) {
|
||||
const [c, r] = key.split(',').map(Number);
|
||||
if (c + 1 > cols) cols = c + 1;
|
||||
if (r + 1 > rows) rows = r + 1;
|
||||
}
|
||||
return { rows, cols };
|
||||
}
|
||||
|
||||
export function tableToCsv(
|
||||
store: DocumentStore,
|
||||
tableId: string,
|
||||
opts: CsvExportOptions,
|
||||
): string {
|
||||
const table = store.tableById(tableId);
|
||||
if (!table) return '';
|
||||
const bounds = usedBounds(table.cells);
|
||||
const rows = Math.max(bounds.rows, 1);
|
||||
const cols = Math.max(bounds.cols, 1);
|
||||
const eol = opts.crlf ? '\r\n' : '\n';
|
||||
|
||||
const lines: string[] = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const fields: string[] = [];
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const value = rawString(store.cellValue(tableId, c, r));
|
||||
fields.push(quoteField(value, opts.delimiter));
|
||||
}
|
||||
lines.push(fields.join(opts.delimiter));
|
||||
}
|
||||
return (opts.bom ? '' : '') + lines.join(eol) + eol;
|
||||
}
|
||||
|
||||
// ---- import --------------------------------------------------------------
|
||||
|
||||
export function sniffDelimiter(text: string): Delimiter {
|
||||
const firstLine = text.slice(0, text.search(/\r?\n/) >>> 0 || text.length);
|
||||
const counts: Record<Delimiter, number> = { ',': 0, ';': 0, '\t': 0 };
|
||||
let inQuotes = false;
|
||||
for (const ch of firstLine) {
|
||||
if (ch === '"') inQuotes = !inQuotes;
|
||||
else if (!inQuotes && (ch === ',' || ch === ';' || ch === '\t')) counts[ch]++;
|
||||
}
|
||||
const best = (Object.keys(counts) as Delimiter[]).sort(
|
||||
(a, b) => counts[b] - counts[a],
|
||||
)[0];
|
||||
return counts[best] > 0 ? best : ',';
|
||||
}
|
||||
|
||||
/** RFC-4180-ish parser. Returns a matrix of raw string cells. */
|
||||
export function parseCsv(text: string, delimiter: Delimiter): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let field = '';
|
||||
let inQuotes = false;
|
||||
let i = 0;
|
||||
const n = text.length;
|
||||
// strip BOM
|
||||
if (text.charCodeAt(0) === 0xfeff) i = 1;
|
||||
|
||||
const endField = () => {
|
||||
row.push(field);
|
||||
field = '';
|
||||
};
|
||||
const endRow = () => {
|
||||
endField();
|
||||
rows.push(row);
|
||||
row = [];
|
||||
};
|
||||
|
||||
while (i < n) {
|
||||
const ch = text[i];
|
||||
if (inQuotes) {
|
||||
if (ch === '"') {
|
||||
if (text[i + 1] === '"') {
|
||||
field += '"';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
inQuotes = false;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
field += ch;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inQuotes = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === delimiter) {
|
||||
endField();
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\r') {
|
||||
if (text[i + 1] === '\n') i++;
|
||||
endRow();
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\n') {
|
||||
endRow();
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
field += ch;
|
||||
i++;
|
||||
}
|
||||
// trailing field / row (unless the file ended exactly on a newline)
|
||||
if (field !== '' || row.length > 0) endRow();
|
||||
return rows;
|
||||
}
|
||||
|
||||
const DE_NUMBER = /^-?\d{1,3}(\.\d{3})+(,\d+)?$|^-?\d+,\d+$/;
|
||||
const EN_NUMBER = /^-?\d{1,3}(,\d{3})+(\.\d+)?$|^-?\d+\.\d+$/;
|
||||
|
||||
/** Normalise a locale-formatted number to a plain JS number literal string. */
|
||||
export function normalizeNumber(value: string): string {
|
||||
const v = value.trim();
|
||||
if (v === '' || v.startsWith('=')) return value;
|
||||
if (/^-?\d+$/.test(v)) return v;
|
||||
if (DE_NUMBER.test(v)) return v.replace(/\./g, '').replace(',', '.');
|
||||
if (EN_NUMBER.test(v)) return v.replace(/,/g, '');
|
||||
return value;
|
||||
}
|
||||
|
||||
export interface CsvToCellsResult {
|
||||
cells: Record<string, string>;
|
||||
rows: number;
|
||||
cols: number;
|
||||
}
|
||||
|
||||
export function matrixToCells(
|
||||
matrix: string[][],
|
||||
detectNumbers = true,
|
||||
): CsvToCellsResult {
|
||||
const cells: Record<string, string> = {};
|
||||
let cols = 0;
|
||||
matrix.forEach((line, r) => {
|
||||
if (line.length > cols) cols = line.length;
|
||||
line.forEach((cell, c) => {
|
||||
let raw = cell;
|
||||
if (detectNumbers) raw = normalizeNumber(raw);
|
||||
if (raw !== '') cells[`${c},${r}`] = raw;
|
||||
});
|
||||
});
|
||||
return { cells, rows: matrix.length, cols: Math.max(cols, 1) };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Trigger a client-side file download from a Blob. */
|
||||
export function downloadBlob(filename: string, blob: Blob): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
// revoke on the next tick so the download has started
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
export function downloadText(
|
||||
filename: string,
|
||||
text: string,
|
||||
mime = 'text/plain;charset=utf-8',
|
||||
): void {
|
||||
downloadBlob(filename, new Blob([text], { type: mime }));
|
||||
}
|
||||
|
||||
export function downloadDataUrl(filename: string, dataUrl: string): void {
|
||||
const a = document.createElement('a');
|
||||
a.href = dataUrl;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* Native JSON format -- full-fidelity, lossless round-trip.
|
||||
* Contains every sheet, every table/chart object with position & size, raw
|
||||
* formulas, column widths, tab order, the active object and the cell selection.
|
||||
* No library: `JSON.stringify` / `JSON.parse` plus schema validation.
|
||||
*
|
||||
* Version 1 documents (one grid per sheet) are migrated to version 2
|
||||
* (canvas + objects) on import.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ButtonObject,
|
||||
CellCoord,
|
||||
CellFormat,
|
||||
ChartObject,
|
||||
CustomFunction,
|
||||
DocumentState,
|
||||
HAlign,
|
||||
Macro,
|
||||
NamedRange,
|
||||
Sheet,
|
||||
SheetObject,
|
||||
TableObject,
|
||||
TableView,
|
||||
TextObject,
|
||||
} from '../store/types';
|
||||
import { defaultTableView, MIN_GRID_SIZE, MAX_GRID_SIZE } from '../store/types';
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
isCellNumFmt,
|
||||
isNumberLocale,
|
||||
type NumberLocale,
|
||||
} from '../store/format';
|
||||
|
||||
/** Keep a valid locale override, drop anything else (undefined = inherit). */
|
||||
function localeOrUndef(x: unknown): NumberLocale | undefined {
|
||||
return isNumberLocale(x) ? x : undefined;
|
||||
}
|
||||
|
||||
/** Defaults to `[]` -- absent in every document saved before this feature
|
||||
* existed (and in v1 documents entirely) -- and drops any malformed entry
|
||||
* rather than rejecting the whole import over one bad function. */
|
||||
function normaliseCustomFunctions(x: unknown): CustomFunction[] {
|
||||
if (!Array.isArray(x)) return [];
|
||||
const out: CustomFunction[] = [];
|
||||
for (const f of x) {
|
||||
if (
|
||||
!f ||
|
||||
typeof f !== 'object' ||
|
||||
typeof (f as { id?: unknown }).id !== 'string' ||
|
||||
typeof (f as { name?: unknown }).name !== 'string' ||
|
||||
typeof (f as { body?: unknown }).body !== 'string' ||
|
||||
!Array.isArray((f as { params?: unknown }).params) ||
|
||||
!(f as { params: unknown[] }).params.every((p) => typeof p === 'string')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const rec = f as CustomFunction;
|
||||
const kind = rec.kind === 'block' ? 'block' : 'expression'; // default for anything missing/invalid
|
||||
out.push({ id: rec.id, name: rec.name, params: [...rec.params], body: rec.body, kind });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isCellCoord(x: unknown): x is CellCoord {
|
||||
return (
|
||||
!!x &&
|
||||
typeof x === 'object' &&
|
||||
typeof (x as CellCoord).col === 'number' &&
|
||||
typeof (x as CellCoord).row === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
/** Defaults to `[]` -- absent in every document saved before this feature
|
||||
* existed. Drops a malformed entry, and any entry whose `tableId` doesn't
|
||||
* match a real table *in this document* (a dangling reference -- e.g. a
|
||||
* hand-edited file, or a table that no longer exists) -- rather than
|
||||
* leaving it to resolve to `#NAME?` forever; the live "delete a table"
|
||||
* path (`document.ts`) does leave such entries in place, for consistency
|
||||
* with how a deleted table's ordinary formula references are never
|
||||
* auto-pruned either -- this is stricter only because *importing* JSON is
|
||||
* already a validating boundary for everything else in the document. */
|
||||
function normaliseNamedRanges(x: unknown, tableIds: ReadonlySet<string>): NamedRange[] {
|
||||
if (!Array.isArray(x)) return [];
|
||||
const out: NamedRange[] = [];
|
||||
for (const r of x) {
|
||||
if (
|
||||
!r ||
|
||||
typeof r !== 'object' ||
|
||||
typeof (r as { id?: unknown }).id !== 'string' ||
|
||||
typeof (r as { name?: unknown }).name !== 'string' ||
|
||||
typeof (r as { tableId?: unknown }).tableId !== 'string' ||
|
||||
!isCellCoord((r as { start?: unknown }).start) ||
|
||||
!isCellCoord((r as { end?: unknown }).end)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const rec = r as NamedRange;
|
||||
if (!tableIds.has(rec.tableId)) continue;
|
||||
out.push({ id: rec.id, name: rec.name, tableId: rec.tableId, start: { ...rec.start }, end: { ...rec.end } });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Defaults to `[]` -- absent in every document saved before this feature
|
||||
* existed. Unlike a named range, a macro carries no `tableId`/other
|
||||
* document-internal reference to validate against -- its body is a plain
|
||||
* string, parsed and validated lazily (store/document.ts's `validateMacro`)
|
||||
* only when actually edited/run, same lenient convention as a custom
|
||||
* function's `body` (an import doesn't re-run every save-time check, only
|
||||
* shape validation). */
|
||||
function normaliseMacros(x: unknown): Macro[] {
|
||||
if (!Array.isArray(x)) return [];
|
||||
const out: Macro[] = [];
|
||||
for (const m of x) {
|
||||
if (
|
||||
!m ||
|
||||
typeof m !== 'object' ||
|
||||
typeof (m as { id?: unknown }).id !== 'string' ||
|
||||
typeof (m as { name?: unknown }).name !== 'string' ||
|
||||
typeof (m as { body?: unknown }).body !== 'string'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const rec = m as Macro;
|
||||
const approvedHosts =
|
||||
Array.isArray(rec.approvedHosts) && rec.approvedHosts.every((h) => typeof h === 'string')
|
||||
? rec.approvedHosts
|
||||
: undefined;
|
||||
out.push({ id: rec.id, name: rec.name, body: rec.body, ...(approvedHosts ? { approvedHosts } : {}) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
||||
function colorOrUndef(x: unknown): string | undefined {
|
||||
return typeof x === 'string' && HEX_COLOR.test(x) ? x : undefined;
|
||||
}
|
||||
function boolOrUndef(x: unknown): boolean | undefined {
|
||||
return typeof x === 'boolean' ? x : undefined;
|
||||
}
|
||||
function zoomOrUndef(x: unknown): number | undefined {
|
||||
return typeof x === 'number' && Number.isFinite(x)
|
||||
? Math.min(2, Math.max(0.4, Math.round(x * 20) / 20))
|
||||
: undefined;
|
||||
}
|
||||
function gridSizeOrUndef(x: unknown): number | undefined {
|
||||
return typeof x === 'number' && Number.isFinite(x)
|
||||
? Math.round(Math.min(MAX_GRID_SIZE, Math.max(MIN_GRID_SIZE, x)))
|
||||
: undefined;
|
||||
}
|
||||
function clampNum(x: unknown, lo: number, hi: number, fallback: number): number {
|
||||
return typeof x === 'number' && Number.isFinite(x)
|
||||
? Math.min(hi, Math.max(lo, Math.round(x)))
|
||||
: fallback;
|
||||
}
|
||||
const H_ALIGNS = new Set<HAlign>(['left', 'center', 'right']);
|
||||
function alignOrUndef(x: unknown): HAlign | undefined {
|
||||
return H_ALIGNS.has(x as HAlign) ? (x as HAlign) : undefined;
|
||||
}
|
||||
|
||||
/** Validate + trim one CellFormat; returns undefined if nothing valid remains. */
|
||||
function normaliseCellFormat(x: unknown): CellFormat | undefined {
|
||||
if (!x || typeof x !== 'object') return undefined;
|
||||
const f = x as CellFormat;
|
||||
const out: CellFormat = {};
|
||||
if (alignOrUndef(f.align)) out.align = f.align;
|
||||
if (f.valign === 'top' || f.valign === 'middle' || f.valign === 'bottom') out.valign = f.valign;
|
||||
if (isCellNumFmt(f.numFmt) && f.numFmt !== 'auto') out.numFmt = f.numFmt;
|
||||
if (colorOrUndef(f.color)) out.color = f.color;
|
||||
if (colorOrUndef(f.bg)) out.bg = f.bg;
|
||||
if (f.bold === true) out.bold = true;
|
||||
if (f.italic === true) out.italic = true;
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
function normaliseFormats(x: unknown): Record<string, CellFormat> | undefined {
|
||||
if (!x || typeof x !== 'object') return undefined;
|
||||
const out: Record<string, CellFormat> = {};
|
||||
for (const [k, v] of Object.entries(x as Record<string, unknown>)) {
|
||||
if (!/^\d+,\d+$/.test(k)) continue;
|
||||
const f = normaliseCellFormat(v);
|
||||
if (f) out[k] = f;
|
||||
}
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
function normaliseRowHeights(x: unknown): Record<number, number> | undefined {
|
||||
if (!x || typeof x !== 'object') return undefined;
|
||||
const out: Record<number, number> = {};
|
||||
for (const [k, v] of Object.entries(x as Record<string, unknown>)) {
|
||||
if (!/^\d+$/.test(k)) continue;
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
out[Number(k)] = Math.min(240, Math.max(14, Math.round(v)));
|
||||
}
|
||||
}
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
function coord(x: unknown): { col: number; row: number } | null {
|
||||
const c = x as { col?: unknown; row?: unknown };
|
||||
if (!c || typeof c !== 'object') return null;
|
||||
if (typeof c.col !== 'number' || typeof c.row !== 'number') return null;
|
||||
return { col: Math.max(0, Math.floor(c.col)), row: Math.max(0, Math.floor(c.row)) };
|
||||
}
|
||||
function normaliseSelection(x: unknown): DocumentState['selection'] {
|
||||
const s = x as { tableId?: unknown; anchor?: unknown; focus?: unknown; ranges?: unknown };
|
||||
const anchor = coord(s.anchor);
|
||||
const focus = coord(s.focus);
|
||||
if (typeof s.tableId !== 'string' || !anchor || !focus) return null;
|
||||
const ranges = Array.isArray(s.ranges)
|
||||
? s.ranges
|
||||
.map((r) => {
|
||||
const a = coord((r as { anchor?: unknown }).anchor);
|
||||
const f = coord((r as { focus?: unknown }).focus);
|
||||
return a && f ? { anchor: a, focus: f } : null;
|
||||
})
|
||||
.filter((r): r is { anchor: { col: number; row: number }; focus: { col: number; row: number } } => !!r)
|
||||
: [];
|
||||
return { tableId: s.tableId, anchor, focus, ranges: ranges.length ? ranges : undefined };
|
||||
}
|
||||
function numMapOrUndef(x: unknown): Record<number, number> | undefined {
|
||||
if (!x || typeof x !== 'object') return undefined;
|
||||
const out: Record<number, number> = {};
|
||||
for (const [k, v] of Object.entries(x as Record<string, unknown>)) {
|
||||
if (/^\d+$/.test(k) && typeof v === 'number' && Number.isFinite(v)) {
|
||||
out[Number(k)] = Math.max(20, Math.round(v));
|
||||
}
|
||||
}
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
|
||||
export class JsonImportError extends Error {}
|
||||
|
||||
// ---- v2 validation ----------------------------------------------------
|
||||
|
||||
function isRecordOfString(x: unknown): x is Record<string, string> {
|
||||
return (
|
||||
!!x &&
|
||||
typeof x === 'object' &&
|
||||
Object.values(x as Record<string, unknown>).every((v) => typeof v === 'string')
|
||||
);
|
||||
}
|
||||
|
||||
function isTableObject(x: unknown): x is TableObject {
|
||||
const o = x as TableObject;
|
||||
return (
|
||||
!!o &&
|
||||
o.kind === 'table' &&
|
||||
typeof o.id === 'string' &&
|
||||
typeof o.name === 'string' &&
|
||||
Number.isFinite(o.x) &&
|
||||
Number.isFinite(o.y) &&
|
||||
Number.isFinite(o.rows) &&
|
||||
Number.isFinite(o.cols) &&
|
||||
!!o.colWidths &&
|
||||
typeof o.colWidths === 'object' &&
|
||||
isRecordOfString(o.cells) &&
|
||||
Object.keys(o.cells).every((k) => /^\d+,\d+$/.test(k))
|
||||
);
|
||||
}
|
||||
|
||||
function isChartObject(x: unknown): x is ChartObject {
|
||||
const o = x as ChartObject;
|
||||
return (
|
||||
!!o &&
|
||||
o.kind === 'chart' &&
|
||||
typeof o.id === 'string' &&
|
||||
typeof o.name === 'string' &&
|
||||
Number.isFinite(o.x) &&
|
||||
Number.isFinite(o.y) &&
|
||||
Number.isFinite(o.width) &&
|
||||
Number.isFinite(o.height) &&
|
||||
(o.view === 'bar' || o.view === 'line')
|
||||
);
|
||||
}
|
||||
|
||||
function isTextObject(x: unknown): x is TextObject {
|
||||
const o = x as TextObject;
|
||||
return (
|
||||
!!o &&
|
||||
o.kind === 'text' &&
|
||||
typeof o.id === 'string' &&
|
||||
typeof o.name === 'string' &&
|
||||
Number.isFinite(o.x) &&
|
||||
Number.isFinite(o.y) &&
|
||||
Number.isFinite(o.width) &&
|
||||
Number.isFinite(o.height) &&
|
||||
typeof o.text === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function isButtonObject(x: unknown): x is ButtonObject {
|
||||
const o = x as ButtonObject;
|
||||
return (
|
||||
!!o &&
|
||||
o.kind === 'button' &&
|
||||
typeof o.id === 'string' &&
|
||||
typeof o.name === 'string' &&
|
||||
Number.isFinite(o.x) &&
|
||||
Number.isFinite(o.y) &&
|
||||
Number.isFinite(o.width) &&
|
||||
Number.isFinite(o.height) &&
|
||||
typeof o.label === 'string'
|
||||
// macroId is intentionally NOT checked against a real macro here --
|
||||
// same lenient "dangling id renders as unassigned" convention as a
|
||||
// chart's sourceTableId, see ButtonObject's doc comment.
|
||||
);
|
||||
}
|
||||
|
||||
function normaliseTableView(v: Partial<TableView> | undefined, rows: number, cols: number): TableView {
|
||||
const d = defaultTableView();
|
||||
const mode = v?.mode === 'fixed' ? 'fixed' : 'auto';
|
||||
return {
|
||||
mode,
|
||||
width: Number.isFinite(v?.width) ? Math.max(200, Math.round(v!.width!)) : d.width,
|
||||
height: Number.isFinite(v?.height) ? Math.max(120, Math.round(v!.height!)) : d.height,
|
||||
frozenRows: Math.min(Math.max(0, Math.floor(v?.frozenRows ?? 0)), Math.max(0, rows - 1)),
|
||||
frozenCols: Math.min(Math.max(0, Math.floor(v?.frozenCols ?? 0)), Math.max(0, cols - 1)),
|
||||
};
|
||||
}
|
||||
|
||||
function normaliseObject(o: SheetObject): SheetObject {
|
||||
if (o.kind === 'table') {
|
||||
const rows = Math.max(1, Math.floor(o.rows));
|
||||
const cols = Math.max(1, Math.floor(o.cols));
|
||||
return {
|
||||
kind: 'table',
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
x: Math.max(0, Math.round(o.x)),
|
||||
y: Math.max(0, Math.round(o.y)),
|
||||
rows,
|
||||
cols,
|
||||
colWidths: numMapOrUndef(o.colWidths) ?? {},
|
||||
rowHeight:
|
||||
typeof o.rowHeight === 'number' && Number.isFinite(o.rowHeight)
|
||||
? Math.min(240, Math.max(14, Math.round(o.rowHeight)))
|
||||
: undefined,
|
||||
rowHeights: normaliseRowHeights(o.rowHeights),
|
||||
cells: { ...o.cells },
|
||||
formats: normaliseFormats(o.formats),
|
||||
view: normaliseTableView(o.view, rows, cols),
|
||||
locale: localeOrUndef(o.locale),
|
||||
bg: colorOrUndef(o.bg),
|
||||
fg: colorOrUndef(o.fg),
|
||||
hideChromeUnfocused: boolOrUndef(o.hideChromeUnfocused),
|
||||
};
|
||||
}
|
||||
if (o.kind === 'text') {
|
||||
return {
|
||||
kind: 'text',
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
x: Math.max(0, Math.round(o.x)),
|
||||
y: Math.max(0, Math.round(o.y)),
|
||||
width: Math.max(80, Math.round(o.width)),
|
||||
height: Math.max(32, Math.round(o.height)),
|
||||
text: String(o.text ?? ''),
|
||||
fontSize: clampNum(o.fontSize, 9, 96, 15),
|
||||
align: alignOrUndef(o.align) ?? 'left',
|
||||
bold: o.bold === true || undefined,
|
||||
italic: o.italic === true || undefined,
|
||||
color: colorOrUndef(o.color),
|
||||
bg: colorOrUndef(o.bg),
|
||||
};
|
||||
}
|
||||
if (o.kind === 'button') {
|
||||
return {
|
||||
kind: 'button',
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
x: Math.max(0, Math.round(o.x)),
|
||||
y: Math.max(0, Math.round(o.y)),
|
||||
width: Math.max(60, Math.round(o.width)),
|
||||
height: Math.max(28, Math.round(o.height)),
|
||||
label: String(o.label ?? ''),
|
||||
macroId: typeof o.macroId === 'string' ? o.macroId : null,
|
||||
bg: colorOrUndef(o.bg),
|
||||
fg: colorOrUndef(o.fg),
|
||||
};
|
||||
}
|
||||
if (o.kind === 'chart') {
|
||||
return {
|
||||
kind: 'chart',
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
x: Math.max(0, Math.round(o.x)),
|
||||
y: Math.max(0, Math.round(o.y)),
|
||||
width: Math.max(200, Math.round(o.width)),
|
||||
height: Math.max(140, Math.round(o.height)),
|
||||
view: o.view,
|
||||
sourceTableId: o.sourceTableId ?? null,
|
||||
locale: localeOrUndef(o.locale),
|
||||
bg: colorOrUndef(o.bg),
|
||||
fg: colorOrUndef(o.fg),
|
||||
};
|
||||
}
|
||||
// Every SheetObject kind is handled above -- reachable only if a new kind
|
||||
// is added to the union without a branch here (a compile-time error at
|
||||
// the call site would already catch that; this is the runtime backstop).
|
||||
throw new JsonImportError('Unbekannte Objektart.');
|
||||
}
|
||||
|
||||
export function validateDocument(x: unknown): DocumentState {
|
||||
if (!x || typeof x !== 'object') throw new JsonImportError('Keine gültige JSON-Struktur.');
|
||||
const d = x as { version?: unknown };
|
||||
|
||||
if (d.version === 1) return migrateToV2(x);
|
||||
if (d.version !== 2) throw new JsonImportError(`Nicht unterstützte Version: ${String(d.version)}`);
|
||||
|
||||
const doc = x as DocumentState;
|
||||
if (!Array.isArray(doc.sheets) || doc.sheets.length === 0)
|
||||
throw new JsonImportError('Dokument enthält keine Blätter.');
|
||||
|
||||
const sheetIds = new Set<string>();
|
||||
const objectIds = new Set<string>();
|
||||
const tableIds = new Set<string>();
|
||||
for (const s of doc.sheets) {
|
||||
if (!s || typeof s.id !== 'string' || typeof s.name !== 'string' || !Array.isArray(s.objects))
|
||||
throw new JsonImportError('Mindestens ein Blatt ist fehlerhaft.');
|
||||
if (sheetIds.has(s.id)) throw new JsonImportError('Doppelte Blatt-ID.');
|
||||
sheetIds.add(s.id);
|
||||
for (const o of s.objects) {
|
||||
if (!isTableObject(o) && !isChartObject(o) && !isTextObject(o) && !isButtonObject(o))
|
||||
throw new JsonImportError('Mindestens ein Objekt ist fehlerhaft.');
|
||||
if (objectIds.has(o.id)) throw new JsonImportError('Doppelte Objekt-ID.');
|
||||
objectIds.add(o.id);
|
||||
if (isTableObject(o)) tableIds.add(o.id);
|
||||
}
|
||||
}
|
||||
if (typeof doc.activeSheetId !== 'string' || !sheetIds.has(doc.activeSheetId))
|
||||
throw new JsonImportError('activeSheetId zeigt auf kein vorhandenes Blatt.');
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
sheets: doc.sheets.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
objects: s.objects.map(normaliseObject),
|
||||
locale: localeOrUndef(s.locale),
|
||||
background: colorOrUndef(s.background),
|
||||
foreground: colorOrUndef(s.foreground),
|
||||
showGrid: boolOrUndef(s.showGrid),
|
||||
gridSize: gridSizeOrUndef(s.gridSize),
|
||||
zoom: zoomOrUndef(s.zoom),
|
||||
})),
|
||||
activeSheetId: doc.activeSheetId,
|
||||
activeObjectId:
|
||||
doc.activeObjectId && objectIds.has(doc.activeObjectId) ? doc.activeObjectId : null,
|
||||
selection:
|
||||
doc.selection && objectIds.has(doc.selection.tableId)
|
||||
? normaliseSelection(doc.selection)
|
||||
: null,
|
||||
locale: isNumberLocale(doc.locale) ? doc.locale : DEFAULT_LOCALE,
|
||||
csvDelimiter: doc.csvDelimiter === ';' ? ';' : ',',
|
||||
customFunctions: normaliseCustomFunctions(doc.customFunctions),
|
||||
namedRanges: normaliseNamedRanges(doc.namedRanges, tableIds),
|
||||
macros: normaliseMacros(doc.macros),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- v1 -> v2 migration --------------------------------------------
|
||||
|
||||
interface V1Sheet {
|
||||
id: string;
|
||||
name: string;
|
||||
rows: number;
|
||||
cols: number;
|
||||
colWidths?: Record<number, number>;
|
||||
cells: Record<string, string>;
|
||||
}
|
||||
interface V1Doc {
|
||||
version: 1;
|
||||
sheets: V1Sheet[];
|
||||
activeSheetId: string;
|
||||
selection?: { sheetId: string; anchor: { col: number; row: number }; focus: { col: number; row: number } } | null;
|
||||
}
|
||||
|
||||
let migSeq = 0;
|
||||
const migId = (p: string) => `${p}-mig-${Date.now().toString(36)}-${migSeq++}`;
|
||||
|
||||
export function migrateToV2(x: unknown): DocumentState {
|
||||
const v1 = x as V1Doc;
|
||||
if (!Array.isArray(v1.sheets) || v1.sheets.length === 0)
|
||||
throw new JsonImportError('v1-Dokument enthält keine Tabellen.');
|
||||
|
||||
const sheetIdMap = new Map<string, { sheetId: string; tableId: string }>();
|
||||
const sheets: Sheet[] = v1.sheets.map((s) => {
|
||||
const sheetId = migId('sheet');
|
||||
const tableId = migId('tbl');
|
||||
sheetIdMap.set(s.id, { sheetId, tableId });
|
||||
const table: TableObject = {
|
||||
kind: 'table',
|
||||
id: tableId,
|
||||
name: s.name, // keep the name -> cross-sheet formulas still resolve
|
||||
x: 40,
|
||||
y: 40,
|
||||
rows: Math.max(1, Math.floor(s.rows ?? 20)),
|
||||
cols: Math.max(1, Math.floor(s.cols ?? 8)),
|
||||
colWidths: { ...(s.colWidths ?? {}) },
|
||||
cells: { ...(s.cells ?? {}) },
|
||||
view: defaultTableView(),
|
||||
};
|
||||
return { id: sheetId, name: s.name, objects: [table] };
|
||||
});
|
||||
|
||||
const active = sheetIdMap.get(v1.activeSheetId) ?? [...sheetIdMap.values()][0];
|
||||
const selSheet = v1.selection ? sheetIdMap.get(v1.selection.sheetId) : undefined;
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
sheets,
|
||||
activeSheetId: active.sheetId,
|
||||
activeObjectId: active.tableId,
|
||||
selection:
|
||||
v1.selection && selSheet
|
||||
? { tableId: selSheet.tableId, anchor: v1.selection.anchor, focus: v1.selection.focus }
|
||||
: null,
|
||||
locale: DEFAULT_LOCALE,
|
||||
csvDelimiter: ',',
|
||||
customFunctions: [],
|
||||
namedRanges: [],
|
||||
macros: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ---- (de)serialise ------------------------------------------------
|
||||
|
||||
export function serializeDocument(doc: DocumentState): string {
|
||||
return JSON.stringify(doc, null, 2);
|
||||
}
|
||||
|
||||
export function parseDocument(text: string): DocumentState {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
throw new JsonImportError('Datei ist kein gültiges JSON.');
|
||||
}
|
||||
return validateDocument(data);
|
||||
}
|
||||
|
||||
/** Give imported sheets & their objects fresh ids so they can be appended. */
|
||||
export function reidSheets(doc: DocumentState): Sheet[] {
|
||||
return doc.sheets.map((s, i) => {
|
||||
const idMap = new Map<string, string>();
|
||||
const objects = s.objects.map((o, j) => {
|
||||
const nid = `imp-${Date.now().toString(36)}-${i}-${j}`;
|
||||
idMap.set(o.id, nid);
|
||||
return { ...o, id: nid } as SheetObject;
|
||||
});
|
||||
// repoint chart sources that referenced a sibling table in the same import
|
||||
for (const o of objects) {
|
||||
if (o.kind === 'chart' && o.sourceTableId && idMap.has(o.sourceTableId)) {
|
||||
o.sourceTableId = idMap.get(o.sourceTableId)!;
|
||||
}
|
||||
}
|
||||
return { id: `imp-sheet-${Date.now().toString(36)}-${i}`, name: s.name, objects };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* The browser's document library: every numolus document the user has in
|
||||
* this browser, stored in IndexedDB. Two object stores, so listing documents
|
||||
* never has to read their (possibly large) contents:
|
||||
*
|
||||
* meta { id, title, createdAt, updatedAt } -- the document list
|
||||
* content { id, json } -- the document itself
|
||||
*
|
||||
* `json` is the serialised DocumentState (the same JSON a .num file holds).
|
||||
* Plain promise wrapper over the IndexedDB API, no library; `factory` is
|
||||
* injectable so tests can run against an in-memory implementation.
|
||||
*/
|
||||
|
||||
export interface DocMeta {
|
||||
id: string;
|
||||
title: string;
|
||||
/** epoch ms */
|
||||
createdAt: number;
|
||||
/** epoch ms of the last *content* change (not cursor moves) */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface ContentRecord {
|
||||
id: string;
|
||||
json: string;
|
||||
}
|
||||
|
||||
const DB_NAME = 'numolus';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
function req<T>(r: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
r.onsuccess = () => resolve(r.result);
|
||||
r.onerror = () => reject(r.error);
|
||||
});
|
||||
}
|
||||
|
||||
function done(tx: IDBTransaction): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error ?? new Error('Transaktion abgebrochen.'));
|
||||
});
|
||||
}
|
||||
|
||||
export class DocumentLibrary {
|
||||
private constructor(private db: IDBDatabase) {}
|
||||
|
||||
static async open(factory: IDBFactory = indexedDB): Promise<DocumentLibrary> {
|
||||
const r = factory.open(DB_NAME, DB_VERSION);
|
||||
r.onupgradeneeded = () => {
|
||||
const db = r.result;
|
||||
if (!db.objectStoreNames.contains('meta')) db.createObjectStore('meta', { keyPath: 'id' });
|
||||
if (!db.objectStoreNames.contains('content')) db.createObjectStore('content', { keyPath: 'id' });
|
||||
};
|
||||
const db = await req(r);
|
||||
// another tab upgrading the schema later must not be blocked by us
|
||||
db.onversionchange = () => db.close();
|
||||
return new DocumentLibrary(db);
|
||||
}
|
||||
|
||||
/** All documents, most recently changed first. */
|
||||
async list(): Promise<DocMeta[]> {
|
||||
const tx = this.db.transaction('meta', 'readonly');
|
||||
const all = await req(tx.objectStore('meta').getAll() as IDBRequest<DocMeta[]>);
|
||||
return all.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
}
|
||||
|
||||
async getMeta(id: string): Promise<DocMeta | undefined> {
|
||||
const tx = this.db.transaction('meta', 'readonly');
|
||||
return req(tx.objectStore('meta').get(id) as IDBRequest<DocMeta | undefined>);
|
||||
}
|
||||
|
||||
async getContent(id: string): Promise<string | undefined> {
|
||||
const tx = this.db.transaction('content', 'readonly');
|
||||
const rec = await req(tx.objectStore('content').get(id) as IDBRequest<ContentRecord | undefined>);
|
||||
return rec?.json;
|
||||
}
|
||||
|
||||
/** Create or overwrite a document -- meta and content in one transaction. */
|
||||
async put(meta: DocMeta, json: string): Promise<void> {
|
||||
const tx = this.db.transaction(['meta', 'content'], 'readwrite');
|
||||
tx.objectStore('meta').put(meta);
|
||||
tx.objectStore('content').put({ id: meta.id, json } satisfies ContentRecord);
|
||||
await done(tx);
|
||||
}
|
||||
|
||||
/** Update only the listing data (rename). No-op for an unknown id. */
|
||||
async patchMeta(id: string, patch: Partial<Omit<DocMeta, 'id'>>): Promise<void> {
|
||||
const tx = this.db.transaction('meta', 'readwrite');
|
||||
const store = tx.objectStore('meta');
|
||||
const cur = await req(store.get(id) as IDBRequest<DocMeta | undefined>);
|
||||
if (cur) store.put({ ...cur, ...patch, id });
|
||||
await done(tx);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const tx = this.db.transaction(['meta', 'content'], 'readwrite');
|
||||
tx.objectStore('meta').delete(id);
|
||||
tx.objectStore('content').delete(id);
|
||||
await done(tx);
|
||||
}
|
||||
}
|
||||
|
||||
export function newDocId(): string {
|
||||
// randomUUID exists only in secure contexts (https, localhost)
|
||||
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
|
||||
const b = crypto.getRandomValues(new Uint8Array(16));
|
||||
b[6] = (b[6] & 0x0f) | 0x40;
|
||||
b[8] = (b[8] & 0x3f) | 0x80;
|
||||
const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
|
||||
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* High-level import/export operations. Each one is atomic: on any failure it
|
||||
* throws and leaves the document untouched (the UI catches and shows a toast).
|
||||
*/
|
||||
|
||||
import type { DocumentStore } from '../store/document';
|
||||
import { downloadBlob, downloadText } from './download';
|
||||
import { serializeDocument, parseDocument, reidSheets } from './json';
|
||||
import {
|
||||
parseCsv,
|
||||
sniffDelimiter,
|
||||
matrixToCells,
|
||||
tableToCsv,
|
||||
type Delimiter,
|
||||
type CsvExportOptions,
|
||||
} from './csv';
|
||||
import { documentToXlsx, xlsxToSheets, importedToSheet } from './xlsx';
|
||||
|
||||
export function readAsText(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onerror = () => reject(new Error('Datei konnte nicht gelesen werden.'));
|
||||
fr.onload = () => resolve(String(fr.result));
|
||||
fr.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function readAsArrayBuffer(file: File): Promise<ArrayBuffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onerror = () => reject(new Error('Datei konnte nicht gelesen werden.'));
|
||||
fr.onload = () => resolve(fr.result as ArrayBuffer);
|
||||
fr.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function extensionOf(name: string): string {
|
||||
const i = name.lastIndexOf('.');
|
||||
return i >= 0 ? name.slice(i + 1).toLowerCase() : '';
|
||||
}
|
||||
|
||||
/** Yield to the event loop so a large parse doesn't freeze the UI. */
|
||||
const breathe = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
// ---- export ---------------------------------------------------------
|
||||
|
||||
/** A document title as a file name (without extension). */
|
||||
export function fileBaseName(title: string): string {
|
||||
return title.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '-').trim() || 'dokument';
|
||||
}
|
||||
|
||||
export function exportJson(store: DocumentStore, name = 'dokument'): void {
|
||||
downloadText(`${fileBaseName(name)}.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, name = 'dokument'): Promise<void> {
|
||||
const { packNum, NUM_MIMETYPE } = await import('./num');
|
||||
const bytes = packNum(serializeDocument(store.snapshot()));
|
||||
downloadBlob(`${fileBaseName(name)}.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.');
|
||||
downloadText(`${table.name}.csv`, tableToCsv(store, table.id, opts), 'text/csv;charset=utf-8');
|
||||
}
|
||||
|
||||
export async function exportXlsx(store: DocumentStore, name = 'arbeitsmappe'): Promise<void> {
|
||||
downloadBlob(`${fileBaseName(name)}.xlsx`, await documentToXlsx(store));
|
||||
}
|
||||
|
||||
// ---- import: JSON ------------------------------------------------
|
||||
|
||||
export function importJsonReplace(store: DocumentStore, text: string): void {
|
||||
store.replaceDocument(parseDocument(text)); // throws JsonImportError on bad schema
|
||||
}
|
||||
|
||||
export function importJsonAppend(store: DocumentStore, text: string): void {
|
||||
store.appendSheets(reidSheets(parseDocument(text)));
|
||||
}
|
||||
|
||||
// ---- import: CSV -----------------------------------------------
|
||||
|
||||
export interface CsvImportConfig {
|
||||
mode: 'new-table' | 'replace-active';
|
||||
delimiter: Delimiter | 'auto';
|
||||
detectNumbers: boolean;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export async function importCsv(
|
||||
store: DocumentStore,
|
||||
text: string,
|
||||
cfg: CsvImportConfig,
|
||||
): Promise<void> {
|
||||
const delimiter: Delimiter = cfg.delimiter === 'auto' ? sniffDelimiter(text) : cfg.delimiter;
|
||||
const matrix = parseCsv(text, delimiter);
|
||||
if (matrix.length > 2000) await breathe();
|
||||
const { cells, rows, cols } = matrixToCells(matrix, cfg.detectNumbers);
|
||||
|
||||
if (cfg.mode === 'replace-active') {
|
||||
const table = store.activeTable.value;
|
||||
if (!table) throw new Error('Keine Zieltabelle ausgewählt.');
|
||||
store.replaceTableCells(table.id, cells, Math.max(rows, 12), Math.max(cols, 6));
|
||||
} else {
|
||||
store.addTableFromCells(
|
||||
store.doc.activeSheetId,
|
||||
cfg.name?.trim() || 'Import',
|
||||
cells,
|
||||
Math.max(rows, 12),
|
||||
Math.max(cols, 6),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- import: XLSX --------------------------------------------
|
||||
|
||||
export async function importXlsxFile(store: DocumentStore, buffer: ArrayBuffer): Promise<void> {
|
||||
const imported = await xlsxToSheets(buffer);
|
||||
if (imported.length === 0) throw new Error('Die Arbeitsmappe enthält keine Tabellen.');
|
||||
await breathe();
|
||||
store.appendSheets(imported.map((s, i) => importedToSheet(s, i)));
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* .xlsx import/export via SheetJS (the *only* place a spreadsheet library is
|
||||
* used, and never for formula evaluation -- that stays in our engine).
|
||||
*
|
||||
* export: one worksheet per table. Formula cells are written as real Excel
|
||||
* formulas when they translate cleanly (`;` -> `,`, `AVG` ->
|
||||
* `AVERAGE`), always accompanied by the cached value from our engine.
|
||||
* import: one table per worksheet. `cell.f` becomes a formula when our parser
|
||||
* accepts it, otherwise the cached value is taken.
|
||||
*/
|
||||
|
||||
import type * as XLSXNS from 'xlsx';
|
||||
import { parseFormula } from '../engine';
|
||||
import type { DocumentStore } from '../store/document';
|
||||
import type { Sheet, TableObject } from '../store/types';
|
||||
import { defaultTableView } from '../store/types';
|
||||
import { rawString } from '../store/format';
|
||||
|
||||
/** SheetJS is the only spreadsheet library and it is loaded on demand. */
|
||||
let xlsxModule: typeof XLSXNS | null = null;
|
||||
async function loadXLSX(): Promise<typeof XLSXNS> {
|
||||
if (!xlsxModule) xlsxModule = await import('xlsx');
|
||||
return xlsxModule;
|
||||
}
|
||||
|
||||
/** Swap top-level separators while respecting "..." and '...' quoting. */
|
||||
function swapOutsideQuotes(src: string, from: string, to: string): string {
|
||||
let out = '';
|
||||
let q: '"' | "'" | null = null;
|
||||
for (const ch of src) {
|
||||
if (q) {
|
||||
out += ch;
|
||||
if (ch === q) q = null;
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
q = ch;
|
||||
out += ch;
|
||||
} else if (ch === from) {
|
||||
out += to;
|
||||
} else {
|
||||
out += ch;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toExcelFormula(input: string): string | null {
|
||||
// input starts with '='
|
||||
let body = input.slice(1);
|
||||
body = swapOutsideQuotes(body, ';', ',');
|
||||
body = body.replace(/\bAVG\s*\(/gi, 'AVERAGE(');
|
||||
return body;
|
||||
}
|
||||
|
||||
function fromExcelFormula(f: string): string | null {
|
||||
const body = swapOutsideQuotes(f, ',', ';');
|
||||
const candidate = '=' + body;
|
||||
try {
|
||||
parseFormula(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeSheetName(name: string, taken: Set<string>): string {
|
||||
const clean = name.replace(/[[\]:*?/\\]/g, ' ').trim().slice(0, 31) || 'Tabelle';
|
||||
let candidate = clean;
|
||||
let i = 2;
|
||||
while (taken.has(candidate.toLowerCase())) {
|
||||
candidate = `${clean.slice(0, 28)} ${i++}`;
|
||||
}
|
||||
taken.add(candidate.toLowerCase());
|
||||
return candidate;
|
||||
}
|
||||
|
||||
// ---- export -----------------------------------------------------------
|
||||
|
||||
export async function documentToXlsx(store: DocumentStore): Promise<Blob> {
|
||||
const XLSX = await loadXLSX();
|
||||
const wb = XLSX.utils.book_new();
|
||||
const taken = new Set<string>();
|
||||
|
||||
// one worksheet per table object, across all sheets
|
||||
const tables = store.doc.sheets.flatMap((s) =>
|
||||
s.objects.filter((o): o is TableObject => o.kind === 'table'),
|
||||
);
|
||||
for (const table of tables) {
|
||||
const ws: XLSXNS.WorkSheet = {};
|
||||
let maxC = 0;
|
||||
let maxR = 0;
|
||||
|
||||
for (const key of Object.keys(table.cells)) {
|
||||
const [c, r] = key.split(',').map(Number);
|
||||
const raw = table.cells[key];
|
||||
const addr = XLSX.utils.encode_cell({ c, r });
|
||||
const value = store.cellValue(table.id, c, r);
|
||||
|
||||
if (raw.startsWith('=')) {
|
||||
const f = toExcelFormula(raw);
|
||||
const num = typeof value === 'number' ? value : undefined;
|
||||
ws[addr] =
|
||||
num !== undefined
|
||||
? { t: 'n', v: num, f: f ?? undefined }
|
||||
: { t: 's', v: rawString(value), f: f ?? undefined };
|
||||
} else if (typeof value === 'number') {
|
||||
ws[addr] = { t: 'n', v: value };
|
||||
} else {
|
||||
ws[addr] = { t: 's', v: raw };
|
||||
}
|
||||
if (c > maxC) maxC = c;
|
||||
if (r > maxR) maxR = r;
|
||||
}
|
||||
|
||||
ws['!ref'] = XLSX.utils.encode_range({ s: { c: 0, r: 0 }, e: { c: maxC, r: maxR } });
|
||||
XLSX.utils.book_append_sheet(wb, ws, sanitizeSheetName(table.name, taken));
|
||||
}
|
||||
|
||||
const out = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer;
|
||||
return new Blob([out], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
});
|
||||
}
|
||||
|
||||
// ---- import -----------------------------------------------------------
|
||||
|
||||
export interface ImportedSheet {
|
||||
name: string;
|
||||
rows: number;
|
||||
cols: number;
|
||||
cells: Record<string, string>;
|
||||
}
|
||||
|
||||
export async function xlsxToSheets(buffer: ArrayBuffer): Promise<ImportedSheet[]> {
|
||||
const XLSX = await loadXLSX();
|
||||
const wb = XLSX.read(buffer, { type: 'array', cellFormula: true, cellNF: false });
|
||||
const result: ImportedSheet[] = [];
|
||||
|
||||
for (const name of wb.SheetNames) {
|
||||
const ws = wb.Sheets[name];
|
||||
const cells: Record<string, string> = {};
|
||||
let rows = 1;
|
||||
let cols = 1;
|
||||
if (ws && ws['!ref']) {
|
||||
const range = XLSX.utils.decode_range(ws['!ref']);
|
||||
for (let r = range.s.r; r <= range.e.r; r++) {
|
||||
for (let c = range.s.c; c <= range.e.c; c++) {
|
||||
const cell = ws[XLSX.utils.encode_cell({ r, c })] as XLSXNS.CellObject | undefined;
|
||||
if (!cell) continue;
|
||||
let raw: string | null = null;
|
||||
if (cell.f) raw = fromExcelFormula(String(cell.f));
|
||||
if (raw == null) {
|
||||
if (cell.v == null) continue;
|
||||
raw = cell.t === 'n' ? String(cell.v) : String(cell.w ?? cell.v);
|
||||
}
|
||||
if (raw === '') continue;
|
||||
cells[`${c},${r}`] = raw;
|
||||
rows = Math.max(rows, r + 1);
|
||||
cols = Math.max(cols, c + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
result.push({ name, rows: Math.max(rows, 20), cols: Math.max(cols, 8), cells });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Wrap an imported worksheet as a canvas sheet holding one table object. */
|
||||
export function importedToSheet(imported: ImportedSheet, idx: number): Sheet {
|
||||
const stamp = Date.now().toString(36);
|
||||
const table: TableObject = {
|
||||
kind: 'table',
|
||||
id: `xlsx-tbl-${stamp}-${idx}`,
|
||||
name: imported.name,
|
||||
x: 40,
|
||||
y: 40,
|
||||
rows: imported.rows,
|
||||
cols: imported.cols,
|
||||
colWidths: {},
|
||||
cells: imported.cells,
|
||||
view: defaultTableView(),
|
||||
};
|
||||
return { id: `xlsx-sheet-${stamp}-${idx}`, name: imported.name, objects: [table] };
|
||||
}
|
||||
Reference in New Issue
Block a user