UNPKG

ethercalc

Version:

Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)

248 lines (230 loc) 9.69 kB
import { ShimNode } from './dom-shim'; import { createSocialCalcFactory } from './socialcalc.bundled'; type SocialCalcNamespace = Record<string, unknown> & { SpreadsheetControl: new (idPrefix?: string) => SpreadsheetControl; ConvertSaveToOtherFormat: (savestr: string, format: string, dorecalc?: boolean) => string; ConvertOtherFormatToSave: (inputstr: string, inputformat: string) => string; Parse: new (cmdstr: string) => { EOF: () => boolean; NextLine: () => void; }; ExecuteSheetCommand: (sheet: unknown, cmd: unknown, saveundo: boolean) => string | undefined; ScheduleSheetCommands: (sheet: unknown, cmdstr: string, saveundo: boolean) => void; RecalcSheet: (sheet: unknown) => void; OffsetFormulaCoords: (formula: string, coloffset: number, rowoffset: number) => string; DetermineValueType: (rawvalue: string) => { type: string }; Clipboard: { clipboard: string }; Formula?: { AddSheetToCache: (name: string, str: string, live?: boolean) => unknown; }; document: { createElement: (tag: string) => ShimNode }; }; interface SpreadsheetControl { sheet: Sheet; context: { sheetobj: Sheet }; CreateSheetSave(): string; CreateSpreadsheetSave(): string; CreateSheetHTML(): string; DecodeSpreadsheetSave(snapshot: string): { sheet?: { start: number; end: number } } | null; ParseSheetSave(str: string): void; ExecuteCommand(cmdstr: string, saveundo?: boolean): void; } interface Sheet { cells: Record<string, unknown>; attribs: { needsrecalc?: string } & Record<string, unknown>; valueformats?: readonly string[]; cellformats?: readonly string[]; recalconce?: boolean; sci: { sheetobj: Sheet }; statuscallback?: StatusCallback; statuscallbackparams?: unknown; ResetSheet(): void; ScheduleSheetCommands(cmdstr: string, saveundo: boolean, isRemote?: boolean): void; } /** * Structural view of a SocialCalc sheet sufficient to build a high-fidelity * xlsx/ods export (formulas, number formats, merges, comments). Keep this * shape stable — `packages/worker/src/lib/xlsx-build.ts` depends on it. */ export interface SheetData { cells: Record<string, unknown>; valueformats: readonly string[]; cellformats: readonly string[]; attribs: Record<string, unknown>; } type StatusCallback = (data: unknown, status: string, arg: unknown, params: unknown) => void; let cachedNamespace: SocialCalcNamespace | null = null; export function loadSocialCalc(): SocialCalcNamespace { if (cachedNamespace) return cachedNamespace; // Double-cast through `unknown`: the bundled factory is `@ts-nocheck` // so tsgo infers its return type as void/undefined, but at runtime it // always yields the SocialCalc namespace (see `socialcalc.bundled.ts` // bottom — `return SocialCalc`). cachedNamespace = createSocialCalcFactory() as unknown as SocialCalcNamespace; return cachedNamespace; } export interface HeadlessSpreadsheetOptions { snapshot?: string; log?: readonly string[]; } export class HeadlessSpreadsheet { readonly #SC: SocialCalcNamespace; readonly #ss: SpreadsheetControl; constructor(SC: SocialCalcNamespace, ss: SpreadsheetControl) { this.#SC = SC; this.#ss = ss; } executeCommand(cmdstr: string, saveundo = false): void { const trimmed = cmdstr.replace(/\n\n+/g, '\n'); if (!/\S/.test(trimmed)) return; const sheet = this.#ss.context.sheetobj; const parse = new this.#SC.Parse(trimmed); while (!parse.EOF()) { try { this.#SC.ExecuteSheetCommand(sheet, parse, saveundo); } catch (e) { console.error("Error in ExecuteSheetCommand:", e); } parse.NextLine(); } if (sheet.attribs.needsrecalc === 'yes' || sheet.recalconce === true) { try { this.#SC.RecalcSheet(sheet); } catch (e) { console.error("Error in RecalcSheet:", e); } sheet.attribs.needsrecalc = 'no'; } } createSheetSave(): string { return this.#ss.CreateSheetSave(); } createSpreadsheetSave(): string { return this.#ss.CreateSpreadsheetSave(); } createSheetHTML(): string { return this.#ss.CreateSheetHTML(); } exportCSV(): string { return this.#SC.ConvertSaveToOtherFormat(this.#ss.CreateSheetSave(), 'csv'); } exportCells(): Record<string, unknown> { return this.#ss.sheet.cells; } exportCell(coord: string): unknown { const cell = this.#ss.sheet.cells[coord]; return cell === undefined ? null : cell; } /** * Full structural sheet view for high-fidelity export (formulas + number * formats + merges). Unlike `exportCSV()`, which flattens to string values, * this exposes the raw cell attributes that an xlsx/ods writer can walk. */ exportSheetData(): SheetData { const sheet = this.#ss.sheet; return { cells: sheet.cells, valueformats: sheet.valueformats ?? [], cellformats: sheet.cellformats ?? [], attribs: sheet.attribs, }; } /** * Enumerate unique sheet names referenced by any formula in this * spreadsheet — e.g. `"other"!A1`, `'other'!A1`, or `other!A1`. Used by the DO to * pre-fetch sibling sheets into SocialCalc's cache before recalc, so * cross-sheet formulas resolve to real values instead of `#NAME?`. */ findCrossSheetRefs(): string[] { const seen = new Set<string>(); const cells = this.#ss.sheet.cells as Record<string, { formula?: string }>; for (const cell of Object.values(cells)) { const formula = cell?.formula; if (typeof formula !== 'string' || formula.length === 0) continue; // Match double-quoted "name"!, single-quoted 'name'!, and bare // name! references. The capture groups are mutually exclusive; // take whichever matched. const re = /(?:"([^"]+)"|'([^']+)'|([A-Za-z_][A-Za-z0-9_.-]*))!/g; let m: RegExpExecArray | null; while ((m = re.exec(formula)) !== null) { const name = m[1] ?? m[2] ?? m[3]; if (name) seen.add(name); } } return [...seen]; } /** * Inject a sibling sheet's SocialCalc save into the formula-evaluator * cache. Must be called *before* `recalc()` for cross-sheet refs to * resolve. Idempotent — SocialCalc replaces any existing cache entry * under the same (normalized) name. */ addSiblingSheet(name: string, save: string): void { const Formula = this.#SC.Formula as unknown as { AddSheetToCache: (name: string, str: string, live?: boolean) => unknown; }; if (!Formula?.AddSheetToCache) return; Formula.AddSheetToCache(name, save, false); } /** * Re-run recalc. Useful after `addSiblingSheet(...)` populates the * formula-evaluator cache so cross-sheet refs that previously returned * `#NAME?` compute to real values. */ recalc(): void { const sheet = this.#ss.context.sheetobj; sheet.attribs.needsrecalc = 'yes'; try { this.#SC.RecalcSheet(sheet); } catch (e) { console.error('Error in RecalcSheet:', e); } sheet.attribs.needsrecalc = 'no'; } } /** * Convert CSV text into a FULL SocialCalc save (multipart envelope with * sheet + edit + audit parts), suitable for storage as a room snapshot. * * `ConvertOtherFormatToSave(csv, 'csv')` alone only returns a clipboard- * style bare-sheet snippet with a `copiedfrom:A1:C2` trailer — fine for * paste but NOT a valid room snapshot (DecodeSpreadsheetSave can't parse * it; exporters return empty CSV when rehydrating). Legacy used * `J.utils.to_socialcalc(J.read(csv))` for this path, which delegated * through a different decoder that produced a full save. * * We reproduce the legacy shape by: (a) parsing the CSV via SocialCalc's * clipboard decoder, (b) pasting it into a fresh SpreadsheetControl at * A1, (c) emitting `CreateSpreadsheetSave` — the same format a live * client's PUT /_/:room would produce. * * Found while browser-smoke-testing PUT /_/room with CSV bodies on * 2026-04-20: GET /_/:room/csv returned empty after a successful PUT * because the rehydrated DO couldn't parse its own stored snapshot. */ export function csvToSave(csv: string): string { const SC = loadSocialCalc(); const clipboardSave = SC.ConvertOtherFormatToSave(csv, 'csv'); const ss = new SC.SpreadsheetControl(); // The legacy `loadclipboard <encoded-save>` command decodes the save // through `SocialCalc.decodeFromSave` then assigns Clipboard.clipboard. // We skip the round-trip through encode/decode since we already have // the decoded form; Clipboard is a plain namespace value (see socialcalc // asset `Clipboard = { clipboard: "" }`). SC.Clipboard.clipboard = clipboardSave; const sheet = ss.context.sheetobj; const parse = new SC.Parse('paste A1 all'); SC.ExecuteSheetCommand(sheet, parse, false); if (sheet.attribs.needsrecalc === 'yes' || sheet.recalconce === true) { try { SC.RecalcSheet(sheet); } catch (e) { console.error("Error in RecalcSheet:", e); } sheet.attribs.needsrecalc = 'no'; } return ss.CreateSpreadsheetSave(); } export function createSpreadsheet(opts: HeadlessSpreadsheetOptions = {}): HeadlessSpreadsheet { const SC = loadSocialCalc(); const ss = new SC.SpreadsheetControl(); if (opts.snapshot) { try { const parts = ss.DecodeSpreadsheetSave(opts.snapshot); if (parts?.sheet) { ss.sheet.ResetSheet(); ss.ParseSheetSave(opts.snapshot.substring(parts.sheet.start, parts.sheet.end)); } } catch (e) { console.error("Error in DecodeSpreadsheetSave:", e); } } const wrapped = new HeadlessSpreadsheet(SC, ss); if (opts.log) { for (const line of opts.log) { if (!line) continue; if (/^re(calc|display)$/.test(line)) continue; wrapped.executeCommand(line); } } return wrapped; }