Files
numolus/app/frontend/io/xlsx.ts
T
wkhandClaude Opus 5.5 e14bada114 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>
2026-09-28 20:09:06 +02:00

184 lines
5.9 KiB
TypeScript

/**
* .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] };
}