ethercalc
Version:
Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)
325 lines (295 loc) • 10.2 kB
text/typescript
/**
* Port of `multi/foldr.ls` (`HackFoldr`) from LiveScript/superagent to TS/fetch.
*
* Semantics preserved:
* - Strip trailing slashes from `base` on construction.
* - `fetch(id)` GETs `{base}/_/{id}/csv.json`, drops the header row, and
* builds `rows = [{link, title, row}]` for each body row where `link` is
* non-empty and not `#…`-prefixed. Missing titles become `SheetN` where
* `N = idx+2` in the source sheet (row `2` onwards, matching legacy).
* - If the room was never-before-seen (response body empty), the first
* write of a row writes two CSV lines (`#url/#title` header + row).
* - If the room is known but empty, the first push writes two CSV lines
* (header, then row).
* - `push(row)` appends a row, POSTs CSV, and updates `row.row` from the
* server's returned `paste A<N>` command.
* - `setAt(idx, {title})` sends `set B{row} text t {title}` via POST.
* - `deleteAt(idx)` sends `set A{row}:B{row} empty multi-cascade`.
*
* Any behavior below not marked "legacy bug" is a faithful port.
*
* Error handling: the legacy code silently ignored POST failures (the
* superagent callback didn't check status). We preserve that — HTTP errors
* don't throw, they only prevent the `row.row` update in `push`.
*/
export interface FoldrRow {
link: string;
title: string;
row: number;
}
export interface FoldrPushResponse {
// Command server may echo back a `paste A<n>` in the body; legacy reads
// `res.body.command[1]`. Its body is sometimes an array (`[status,cmd]`).
command?: unknown;
}
export type FetchImpl = typeof fetch;
export interface FoldrOptions {
/** Override `fetch` (e.g. test mock). Defaults to the global `fetch`. */
readonly fetchImpl?: FetchImpl;
}
/**
* `HackFoldr` — the name is kept from the legacy source for grep-ability.
* This class is intentionally framework-free so it can be unit-tested
* exhaustively without a DOM.
*/
export class HackFoldr {
readonly base: string;
id = '';
rows: FoldrRow[] = [];
wasNonExistent = false;
wasEmpty = false;
private readonly fetchImpl: FetchImpl;
constructor(base: string, options: FoldrOptions = {}) {
this.base = base.replace(/\/+$/, '');
this.fetchImpl = options.fetchImpl ?? fetch.bind(globalThis);
}
size(): number {
return this.rows.length;
}
lastIndex(): number {
return this.rows.length - 1;
}
lastRow(): { link?: string; title?: string; row?: number } {
return this.rows.length ? (this.rows[this.rows.length - 1] as FoldrRow) : {};
}
links(): string[] {
return this.rows.map((r) => r.link);
}
titles(): string[] {
return this.rows.map((r) => r.title);
}
at(idx: number): { link?: string; title?: string; row?: number } {
return this.rows[idx] ?? {};
}
/**
* Load TOC from the CSV-as-JSON endpoint. Resolves when the Foldr is ready.
*/
async fetch(id: string): Promise<this> {
this.id = id;
const body = await this.loadTocJson();
if (Array.isArray(body) && body.length > 0) {
this.rows = parseTocBody(body);
} else {
this.wasNonExistent = true;
}
if (this.rows.length === 0) {
this.wasEmpty = true;
const seed: FoldrRow = { link: `/${this.id}.1`, title: 'Sheet1', row: 2 };
this.rows = [];
await this.push(seed);
}
return this;
}
/**
* Re-fetch the TOC without seeding or touching init flags. Returns `true`
* when the in-memory row list changed (add/rename/delete from a peer).
*/
async refreshToc(): Promise<boolean> {
if (!this.id) return false;
const body = await this.loadTocJson();
if (!Array.isArray(body) || body.length === 0) return false;
const next = parseTocBody(body);
if (tocRowsEqual(this.rows, next)) return false;
this.rows = next;
return true;
}
private async loadTocJson(): Promise<unknown> {
const url = `${this.base}/_/${this.id}/csv.json`;
try {
const res = await this.fetchImpl(url);
return res.ok ? await res.json() : null;
} catch {
return null;
}
}
/** Append a new row (writes to server, then pushes locally). */
async push(row: FoldrRow): Promise<this> {
await this.initIfNeeded(row);
const res = await this.postCsv(row.link, row.title);
const command = extractCommand(res);
if (typeof command === 'string') {
const m = /paste A(\d+) all/.exec(command);
if (m) {
row.row = parseInt(m[1] as string, 10);
}
}
this.rows.push(row);
return this;
}
/**
* Update a row in-place. When `title` is set, dispatches a
* `set B<row> text t <title>` command to the server. Returns `this`.
*/
async setAt(idx: number, patch: Partial<FoldrRow>): Promise<this> {
const existing = this.rows[idx];
if (!existing) return this;
if (patch.title !== undefined) {
await this.sendCmd(`set B${existing.row} text t ${patch.title}`);
}
Object.assign(existing, patch);
return this;
}
/**
* Remove a row. Sends `set A<row>:B<row> empty multi-cascade` to let the
* server cascade-clear the TOC entry + its associated sub-sheet blob.
*/
async deleteAt(idx: number): Promise<this> {
const existing = this.rows[idx];
if (!existing) return this;
await this.sendCmd(`set A${existing.row}:B${existing.row} empty multi-cascade`);
this.rows.splice(idx, 1);
return this;
}
/** Send a raw SocialCalc command string via text/plain POST. */
async sendCmd(cmd: string): Promise<void> {
await this.initIfNeeded(null);
try {
await this.fetchImpl(`${this.base}/_/${this.id}`, {
method: 'POST',
headers: { 'content-type': 'text/plain' },
body: cmd,
});
} catch {
// Legacy swallowed errors silently.
}
}
private async initIfNeeded(row: FoldrRow | null): Promise<void> {
if (this.wasNonExistent) {
this.wasNonExistent = false;
this.wasEmpty = false;
if (row) {
row.row = 2;
await this.postInitCsv(
'#url',
'#title',
`/${this.id}.1`,
'Sheet1',
row.link,
row.title,
);
} else {
await this.postRawCsv('#url', '#title', `/${this.id}.1`, 'Sheet1');
}
return;
}
if (this.wasEmpty) {
this.wasEmpty = false;
if (row) {
row.row = 2;
await this.postRawCsv(`/${this.id}.1`, 'Sheet1', row.link, row.title);
} else {
await this.postCsv(`/${this.id}.1`, 'Sheet1');
}
return;
}
// Nothing to do.
}
/** Single-row CSV POST (used for incremental `push` on an existing sheet). */
async postCsv(a = '', b = ''): Promise<FoldrPushResponse | null> {
const body = `"${escapeCsv(a)}","${escapeCsv(b)}"`;
return this.postCsvBody(body);
}
/** Two-row CSV POST (header + row for sheets that were empty on load). */
async postRawCsv(a = '', b = '', c = '', d = ''): Promise<FoldrPushResponse | null> {
const body = `"${escapeCsv(a)}","${escapeCsv(b)}"\n"${escapeCsv(c)}","${escapeCsv(d)}"`;
return this.postCsvBody(body);
}
/** Three-row CSV POST used the first time we touch a never-before-existed sheet. */
async postInitCsv(
a = '',
b = '',
c = '',
d = '',
e = '',
f = '',
): Promise<FoldrPushResponse | null> {
const body = `"${escapeCsv(a)}","${escapeCsv(b)}"\n"${escapeCsv(c)}","${escapeCsv(d)}"\n"${escapeCsv(e)}","${escapeCsv(f)}"`;
return this.postCsvBody(body);
}
private async postCsvBody(body: string): Promise<FoldrPushResponse | null> {
try {
const res = await this.fetchImpl(`${this.base}/_/${this.id}`, {
method: 'POST',
headers: {
'content-type': 'text/csv',
accept: 'application/json',
},
body,
});
if (!res.ok) return null;
const parsed = await res.json().catch(() => null);
return parsed as FoldrPushResponse | null;
} catch {
return null;
}
}
}
/**
* Parse a `csv.json` body (array-of-arrays) into deduped TOC rows.
* Exported for unit tests; `fetch` and `refreshToc` both use this.
*/
export function parseTocBody(body: unknown): FoldrRow[] {
if (!Array.isArray(body) || body.length === 0) return [];
const rowsIn = body.slice(1) as unknown[];
const parsed: FoldrRow[] = [];
rowsIn.forEach((raw, idx) => {
if (!Array.isArray(raw)) return;
const link = typeof raw[0] === 'string' ? raw[0] : '';
let title = typeof raw[1] === 'string' ? raw[1] : '';
if (!link || link.startsWith('#')) return;
if (!title) title = 'Sheet' + (idx + 1);
parsed.push({ link, title, row: idx + 2 });
});
// Legacy seeding can POST the same link more than once (see FINDINGS.md).
// Duplicate React keys made every tab but one vanish on rename (#635); a
// stale "Sheet1" row also reappeared as a ghost tab after delete/rename
// cycles (#727). Keep one entry per link, preferring the last server row.
const byLink = new Map<string, number>();
const deduped: FoldrRow[] = [];
for (const row of parsed) {
const at = byLink.get(row.link);
if (at !== undefined) {
deduped[at] = row;
} else {
byLink.set(row.link, deduped.length);
deduped.push(row);
}
}
return deduped;
}
/** Shallow compare of two TOC row lists (link, title, row index). */
export function tocRowsEqual(a: readonly FoldrRow[], b: readonly FoldrRow[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const left = a[i]!;
const right = b[i]!;
if (left.link !== right.link || left.title !== right.title || left.row !== right.row) {
return false;
}
}
return true;
}
function escapeCsv(s: string): string {
return s.replace(/"/g, '""');
}
/**
* The legacy code read `res.body.command[1]` — body.command is an array
* shaped `[status, commandString]`. Pull the string out defensively.
*/
function extractCommand(res: FoldrPushResponse | null): string | undefined {
if (!res) return undefined;
const cmd = res.command;
if (Array.isArray(cmd) && typeof cmd[1] === 'string') return cmd[1];
if (typeof cmd === 'string') return cmd;
return undefined;
}