ethercalc
Version:
Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)
491 lines (458 loc) • 19.1 kB
text/typescript
/**
* Spreadsheet binary exporters — xlsx / ods / fods.
*
* We use SheetJS (`xlsx` npm package) rather than the legacy `j` lib:
* - `xlsx` publishes a pure-JS ESM entry (`xlsx.mjs`) that works under
* workerd with `nodejs_compat` (verified via the workers-pool
* integration tests in `test/exports.test.ts`).
* - `j` depends on Node `Buffer` and has stale transitive deps that break
* in modern environments (§7 item 3).
*
* Single-sheet pipeline:
* csvText → parseCSV → string[][] → XLSX.utils.aoa_to_sheet
* → XLSX.utils.book_new + book_append_sheet (name: "Sheet1")
* → XLSX.write({ bookType, type: 'array' }) → Uint8Array.
*
* The CSV intermediate is the same one SocialCalc emits for `exportCSV`, so
* every value that survived SocialCalc survives here. Purely numeric cells
* become JS numbers (SheetJS infers type from the AOA); everything else is
* left as a string, which is how the legacy `j` pipeline also treated
* non-numeric cells (it re-parsed the text-based intermediate anyway).
*
* Multi-sheet pipeline (Phase 8.1):
* - `buildMultiSheetWorkbook`: an array of `{name, csv}` → one sheet per
* entry, names passed through `sanitizeSheetName` so SheetJS accepts
* them (31-char cap + `:\/?*[]` forbidden + unique).
* - `parseMultiSheetWorkbook`: inverse; bytes → array of `{name, csv}`
* keyed by the original SheetNames order. Used by `PUT /=:room.xlsx`.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
// xlsx ships its own d.ts. We reach through `any` rather than importing
// the individual typed symbols because the public ESM surface we use
// (aoa_to_sheet + book_new + book_append_sheet + write) is typed more
// liberally in the library than what we actually hit.
import * as XLSX from '@e965/xlsx';
import { parseCSV } from './csv-parse.ts';
/** Binary workbook formats we support (single-sheet for Phase 8). */
export type BinaryFormat = 'xlsx' | 'ods' | 'fods';
// ─── Cells-based high-fidelity exporter ───────────────────────────────────
//
// The CSV intermediate at `csvToBinaryWorkbook` flattens everything to
// strings, so formulas, number formats, and merged ranges are lost. For
// rooms where the user cares about roundtrip ("open in Excel, edit,
// re-import") we walk `sheet.cells` directly and emit SheetJS
// `{v, t, f, z, c}` cells. Graceful-degrade: unknown valuetype → string
// with stringified datavalue; unrecognized format → omitted. Excel still
// shows the cached computed value alongside a preserved formula, so even
// if a SocialCalc-specific function doesn't exist in Excel's formula
// dialect the user sees the right value on open.
/** Single cell view from a SocialCalc sheet. */
export interface SocialCalcCell {
readonly coord?: string;
readonly datavalue?: string | number;
readonly datatype?: 'v' | 't' | 'f' | 'c' | null;
readonly formula?: string;
readonly valuetype?: string;
readonly nontextvalueformat?: number;
readonly textvalueformat?: number;
readonly colspan?: number;
readonly rowspan?: number;
readonly comment?: string;
}
/**
* Structural sheet view — matches `@ethercalc/socialcalc-headless`'s
* `SheetData`. Redeclared here so this lib stays dependency-free of
* the headless package (pure Node unit-testable).
*/
export interface SocialCalcSheetView {
// Typed as `unknown` to accept the structurally-compatible `SheetData`
// from `@ethercalc/socialcalc-headless` without needing a shared type
// import. Each entry is cast to `SocialCalcCell` inside the walker.
readonly cells: Readonly<Record<string, unknown>>;
readonly valueformats?: readonly string[];
readonly cellformats?: readonly string[];
readonly attribs?: Readonly<Record<string, unknown>>;
}
/** Parse "A1" / "AA12" into zero-based `{r, c}`. Returns null on invalid input. */
export function parseCoord(coord: string): { r: number; c: number } | null {
const m = /^([A-Z]+)(\d+)$/.exec(coord);
if (!m) return null;
const letters = m[1] as string;
const rowStr = m[2] as string;
let col = 0;
for (let i = 0; i < letters.length; i++) {
col = col * 26 + (letters.charCodeAt(i) - 64);
}
const row = parseInt(rowStr, 10) - 1;
if (row < 0) return null;
return { r: row, c: col - 1 };
}
/** Inverse of `parseCoord` for the column part. */
export function encodeColumn(c: number): string {
let out = '';
let n = c;
while (n >= 0) {
out = String.fromCharCode(65 + (n % 26)) + out;
n = Math.floor(n / 26) - 1;
}
return out;
}
/**
* Translate one SocialCalc cell into a SheetJS cell object. Returns `null`
* when the cell should be omitted (truly blank — `valuetype` starts with
* 'b' AND no formula). Rules:
*
* - Numeric (`valuetype[0] === 'n'`):
* - `nl` → `t: 'b'` (logical / boolean)
* - anything else → `t: 'n'` with `cell.datavalue` as the value
* - `nontextvalueformat` pointing at a non-'General' / non-`text-*`
* entry → `z: <format string>` (SocialCalc's format dialect already
* matches Excel's for the common subset: decimals, percents,
* currency, dates)
* - Text (`valuetype[0] === 't'`): `t: 's'`, `v: datavalue`. Subtypes
* (`th`/`tl`/`tw`) degrade to plain text — the raw markup survives.
* - Error (`valuetype[0] === 'e'`): `t: 'e'`, `v: 0x2A` (#N/A).
* - Formula (`datatype === 'f'`): adds `f: <formula>` alongside the cached
* value. SocialCalc stores formulas without a leading `=`, matching
* SheetJS's expected shape.
* - Merge (`colspan > 1 || rowspan > 1`): caller collects into `!merges`.
* - Comment: `c: [{t: <comment>}]`.
*
* Unknown valuetypes degrade to string via `String(datavalue)`.
*/
export function translateCell(
cell: SocialCalcCell,
valueformats: readonly string[] = [],
): Record<string, unknown> | null {
const valuetype = String(cell.valuetype ?? '');
const main = valuetype[0] ?? '';
const isFormula = cell.datatype === 'f' && typeof cell.formula === 'string' && cell.formula.length > 0;
// Skip truly blank non-formula cells.
if ((main === 'b' || main === '') && !isFormula) return null;
const out: Record<string, unknown> = {};
if (main === 'n') {
const raw = cell.datavalue;
// Stryker disable next-line all : parseFloat(String(x)) on a JS number
// is identity for every representable value we receive from SocialCalc,
// so the `typeof raw === 'number'` fast-path and the `raw ?? ''`
// fallback produce identical numeric output. Equivalent mutants.
const n = typeof raw === 'number' ? raw : parseFloat(String(raw ?? ''));
if (valuetype === 'nl') {
// SocialCalc stores 1/0 as numeric for logical values.
out.v = Number.isFinite(n) ? n !== 0 : false;
out.t = 'b';
} else {
out.v = Number.isFinite(n) ? n : 0;
out.t = 'n';
}
} else if (main === 't') {
out.v = cell.datavalue ?? '';
out.t = 's';
} else if (main === 'e') {
out.v = 0x2a; // xlsx code for #N/A
out.t = 'e';
} else if (isFormula) {
// Formula with blank / unknown value — Excel will show #N/A-ish until
// it recalculates on open. Seed with 0 so the cell is numeric-typed.
out.v = 0;
out.t = 'n';
} else {
// Graceful degrade: stringify.
out.v = String(cell.datavalue ?? '');
out.t = 's';
}
if (isFormula) {
out.f = cell.formula;
}
// Number format pass-through.
const fmtIdx = cell.nontextvalueformat;
if (typeof fmtIdx === 'number' && fmtIdx > 0) {
const fmt = valueformats[fmtIdx];
if (fmt && fmt !== 'General' && !fmt.startsWith('text-')) {
out.z = fmt;
}
}
if (cell.comment) {
out.c = [{ t: String(cell.comment) }];
}
return out;
}
/**
* Build a SheetJS worksheet object from a SocialCalc sheet view. The
* returned object has `!ref`, `!merges` (when any), and one cell entry per
* non-blank coord. Suitable for `book_append_sheet`.
*/
export function sheetViewToWorksheet(view: SocialCalcSheetView): Record<string, unknown> {
const ws: Record<string, unknown> = {};
const merges: Array<{
s: { r: number; c: number };
e: { r: number; c: number };
}> = [];
let minR = Infinity;
let minC = Infinity;
let maxR = -Infinity;
let maxC = -Infinity;
for (const [coord, cell] of Object.entries(view.cells)) {
const rc = parseCoord(coord);
if (!rc) continue;
const scCell = cell as SocialCalcCell;
const xcell = translateCell(scCell, view.valueformats);
if (!xcell) continue;
ws[coord] = xcell;
// Stryker disable EqualityOperator : flipping `<` / `>` to `<=` / `>=`
// is a no-op at equality because the value being assigned equals the
// current accumulator. Equivalent mutants on all four guards.
if (rc.r < minR) minR = rc.r;
if (rc.c < minC) minC = rc.c;
if (rc.r > maxR) maxR = rc.r;
if (rc.c > maxC) maxC = rc.c;
// Stryker restore EqualityOperator
const colspan = Number(scCell.colspan ?? 1);
const rowspan = Number(scCell.rowspan ?? 1);
if (colspan > 1 || rowspan > 1) {
merges.push({
s: { r: rc.r, c: rc.c },
e: { r: rc.r + rowspan - 1, c: rc.c + colspan - 1 },
});
}
}
if (maxR === -Infinity) {
// No cells — emit a minimal 1x1 blank so readers don't reject the file.
ws['!ref'] = 'A1';
ws['A1'] = { t: 's', v: '' };
} else {
// parseCoord guarantees r >= 0 and c >= 0 for every cell we kept,
// so minR/minC are always valid zero-based indices here.
ws['!ref'] = `${encodeColumn(minC)}${minR + 1}:${encodeColumn(maxC)}${maxR + 1}`;
}
if (merges.length > 0) {
ws['!merges'] = merges;
}
return ws;
}
/** Single-sheet high-fidelity exporter. Replaces the CSV-based pipeline. */
export function sheetViewToBinaryWorkbook(
view: SocialCalcSheetView,
format: BinaryFormat,
sheetName: string = 'Sheet1',
): Uint8Array {
const ws = sheetViewToWorksheet(view);
const book = (XLSX as any).utils.book_new();
(XLSX as any).utils.book_append_sheet(book, ws, sheetName);
const out = (XLSX as any).write(book, {
bookType: format,
type: 'array',
compression: true,
});
return new Uint8Array(out as ArrayBufferLike);
}
/** Multi-sheet high-fidelity exporter. Parallels `buildMultiSheetWorkbook`. */
export function buildMultiSheetWorkbookFromSheets(
sheets: ReadonlyArray<{ readonly name: string; readonly view: SocialCalcSheetView }>,
format: BinaryFormat,
): Uint8Array {
const book = (XLSX as any).utils.book_new();
const used: string[] = [];
if (sheets.length === 0) {
const blank = (XLSX as any).utils.aoa_to_sheet([['']]);
(XLSX as any).utils.book_append_sheet(book, blank, 'Sheet1');
}
for (const s of sheets) {
const ws = sheetViewToWorksheet(s.view);
const name = sanitizeSheetName(s.name, used);
used.push(name);
(XLSX as any).utils.book_append_sheet(book, ws, name);
}
const out = (XLSX as any).write(book, {
bookType: format,
type: 'array',
compression: true,
});
return new Uint8Array(out as ArrayBufferLike);
}
/**
* Convert a CSV string (typically from `socialcalc.exportCSV()`) to a binary
* workbook in the requested format. Returns a freshly-allocated Uint8Array.
*/
export function csvToBinaryWorkbook(csv: string, format: BinaryFormat): Uint8Array {
const grid = parseCSV(csv);
// SheetJS requires at least one row/cell to infer dimensions; an empty
// export produces a minimal 1x1 blank sheet so the output is a valid file
// readers won't reject. Matches the legacy behavior of `j` which emitted
// an empty-but-valid Sheet1 for an empty room.
const aoa: unknown[][] = grid.length === 0 ? [['']] : coerceNumericCells(grid);
const sheet = (XLSX as any).utils.aoa_to_sheet(aoa);
const book = (XLSX as any).utils.book_new();
(XLSX as any).utils.book_append_sheet(book, sheet, 'Sheet1');
const out = (XLSX as any).write(book, {
bookType: format,
type: 'array',
compression: true,
});
return new Uint8Array(out as ArrayBufferLike);
}
/**
* SheetJS infers cell types from JS values — strings stay strings, numbers
* stay numbers. SocialCalc's CSV output is all strings; to get "real" numeric
* cells in the output .xlsx (so SUM etc still work in Excel) we coerce values
* that parse cleanly as numbers.
*
* Coercion rules:
* - A value matching `/^-?\d+(\.\d+)?$/` becomes a JS number.
* - Everything else (including `""`) stays as a string.
*
* This matches the legacy `j` lib's behavior: it also looked at the text
* intermediate and promoted decimal strings to numbers.
*/
function coerceNumericCells(grid: readonly (readonly string[])[]): unknown[][] {
const out: unknown[][] = [];
for (const row of grid) {
const cells: unknown[] = [];
for (const value of row) {
if (value === '') {
cells.push('');
} else if (/^-?\d+(\.\d+)?$/.test(value)) {
cells.push(Number(value));
} else {
cells.push(value);
}
}
out.push(cells);
}
return out;
}
/** MIME type table for the binary formats. Re-exported for the route layer. */
export const BINARY_CONTENT_TYPES: Readonly<Record<BinaryFormat, string>> = {
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ods: 'application/vnd.oasis.opendocument.spreadsheet',
// fods is the flat (single-XML) ODS variant; same MIME type legally.
fods: 'application/vnd.oasis.opendocument.spreadsheet',
};
// ─── Multi-sheet helpers (Phase 8.1) ──────────────────────────────────────
//
// Multi-sheet rooms (prefix `=`) in legacy EtherCalc are a TOC sheet whose
// rows are `<url>,<title>` pairs pointing at sub-sheet rooms. The TOC is
// itself a SocialCalc save. For export we:
// 1. fetch the TOC's CSV + each sub-room's CSV save;
// 2. feed them into SheetJS as a multi-sheet workbook with one worksheet
// per sub-sheet (the TOC itself is NOT exported — it's an internal
// routing artifact);
// 3. serialize to xlsx/ods/fods bytes.
//
// For import we run the pipeline in reverse — split the workbook into one
// CSV per sheet, give each one to `csvToSave` in the caller (that lives in
// `@ethercalc/socialcalc-headless` and isn't a dependency of this pure
// lib), and PUT the saves into individual sub-room DOs.
/**
* Sanitize a sheet name so SheetJS `book_append_sheet` accepts it.
*
* Rules (from SheetJS's `check_ws_name`):
* - must not exceed 31 characters;
* - must not contain any of `:`, `\`, `/`, `?`, `*`, `[`, `]`;
* - must be unique within the workbook.
*
* Strategy:
* - replace every forbidden character with `_`;
* - if the result is empty, fall back to `Sheet`;
* - truncate to 31 characters;
* - deduplicate by appending ` (n)` (legacy spreadsheet convention,
* same as Excel's own save-as dedupe) — truncating again if that
* suffix pushes the length past 31.
*
* The dedupe logic reserves room for the ` (n)` suffix by shortening the
* base before appending. We only bump `n` up to the number of existing
* sheets — guaranteed to terminate because each new candidate gets a
* different suffix.
*/
export function sanitizeSheetName(
raw: string,
existing: readonly string[] = [],
): string {
// SheetJS forbids: : \ / ? * [ ]
const replaced = raw.replace(/[:\\/?*[\]]/g, '_');
const base = replaced.length === 0 ? 'Sheet' : replaced;
const truncated = base.length > 31 ? base.slice(0, 31) : base;
if (!existing.includes(truncated)) return truncated;
// Dedupe: append " (n)". The suffix width (including the space + parens)
// is 3 + log10(n); we conservatively keep the first 27 chars of the base
// so we can fit " (n)" up through n=999 under the 31-char cap. The loop
// must find a free candidate within `existing.length + 1` iterations
// (pigeonhole); the while(true) avoids an unreachable loop-exit branch.
let n = 2;
// eslint-disable-next-line no-constant-condition
while (true) {
const suffix = ` (${n})`;
const budget = 31 - suffix.length;
const candidate = truncated.slice(0, budget) + suffix;
if (!existing.includes(candidate)) return candidate;
n++;
}
}
/**
* Build a multi-sheet workbook from an array of `{name, csv}` entries.
*
* Sheet names are sanitized via `sanitizeSheetName` so SheetJS accepts them
* without throwing (see FINDINGS for the drop list). Empty CSVs are coerced
* to a `1x1` blank cell so every worksheet has at least one cell — this is
* a legacy convention (an empty sheet in xlsx is technically valid but some
* readers, notably older LibreOffice, reject it).
*/
export function buildMultiSheetWorkbook(
sheets: ReadonlyArray<{ readonly name: string; readonly csv: string }>,
format: BinaryFormat,
): Uint8Array {
const book = (XLSX as any).utils.book_new();
const used: string[] = [];
// If the caller handed us zero sheets, produce a single blank Sheet1 so
// the output is still a valid workbook (matches legacy `j` behavior for
// empty TOC sheets — the route layer is expected to 404 on an empty
// TOC; this is a defensive fallback).
if (sheets.length === 0) {
const blank = (XLSX as any).utils.aoa_to_sheet([['']]);
(XLSX as any).utils.book_append_sheet(book, blank, 'Sheet1');
}
for (const sheet of sheets) {
const grid = parseCSV(sheet.csv);
const aoa: unknown[][] = grid.length === 0 ? [['']] : coerceNumericCells(grid);
const ws = (XLSX as any).utils.aoa_to_sheet(aoa);
const name = sanitizeSheetName(sheet.name, used);
used.push(name);
(XLSX as any).utils.book_append_sheet(book, ws, name);
}
const out = (XLSX as any).write(book, {
bookType: format,
type: 'array',
compression: true,
});
return new Uint8Array(out as ArrayBufferLike);
}
/**
* Parse a workbook (any format SheetJS can read — xlsx/ods/fods) and return
* one `{name, csv}` entry per sheet, in workbook order.
*
* CSV values are produced via `XLSX.utils.sheet_to_csv` with default FS/RS
* (`,` and `\n`) so the result round-trips through `parseCSV`.
*/
export function parseMultiSheetWorkbook(
bytes: Uint8Array,
readFn: (data: any, opts: any) => any = (XLSX as any).read,
): Array<{ name: string; csv: string }> {
const wb = readFn(bytes, { type: 'array' });
// Defensive fallback: SheetJS always produces an Array for SheetNames,
// but a hand-forged workbook might not. The `: []` branch is covered
// by the tests.
const names: string[] = Array.isArray(wb.SheetNames) ? wb.SheetNames : [];
const out: Array<{ name: string; csv: string }> = [];
for (const name of names) {
const sheet = wb.Sheets[name];
// Same defensive guard: a SheetName without a matching Sheets entry
// would throw on `sheet_to_csv`.
if (!sheet) continue;
const csv: string = (XLSX as any).utils.sheet_to_csv(sheet, {
FS: ',',
RS: '\n',
});
out.push({ name, csv });
}
return out;
}