@crossworks/content-core 0.230.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/LICENSE.md +135 -0
  2. package/package.json +41 -0
  3. package/src/block-diff.test.ts +190 -0
  4. package/src/block-diff.ts +163 -0
  5. package/src/block-ids.test.ts +358 -0
  6. package/src/block-ids.ts +242 -0
  7. package/src/block-list.test.ts +241 -0
  8. package/src/block-list.ts +177 -0
  9. package/src/contacts-format.ts +260 -0
  10. package/src/doc-to-markdown.test.ts +194 -0
  11. package/src/doc-to-markdown.ts +315 -0
  12. package/src/formula-dimensions.test.ts +103 -0
  13. package/src/formula-dimensions.ts +231 -0
  14. package/src/formula-eval.ts +294 -0
  15. package/src/formula-seed.test.ts +175 -0
  16. package/src/formula-seed.ts +466 -0
  17. package/src/formula-signature.test.ts +336 -0
  18. package/src/formula-signature.ts +435 -0
  19. package/src/formula-spec.test.ts +458 -0
  20. package/src/formula-spec.ts +566 -0
  21. package/src/journal-options.test.ts +57 -0
  22. package/src/journal-options.ts +77 -0
  23. package/src/markdown-refs.test.ts +143 -0
  24. package/src/markdown-refs.ts +172 -0
  25. package/src/markdown-to-doc.test.ts +179 -0
  26. package/src/markdown-to-doc.ts +567 -0
  27. package/src/onboarding-questions.test.ts +75 -0
  28. package/src/onboarding-questions.ts +90 -0
  29. package/src/page-diff.test.ts +82 -0
  30. package/src/page-diff.ts +120 -0
  31. package/src/page-split.test.ts +141 -0
  32. package/src/page-split.ts +128 -0
  33. package/src/page-toc.test.ts +58 -0
  34. package/src/page-toc.ts +89 -0
  35. package/src/persona-bank.test.ts +67 -0
  36. package/src/persona-bank.ts +234 -0
  37. package/src/table-formula-mathjs.ts +259 -0
  38. package/src/table-formula.test.ts +157 -0
  39. package/src/table-formula.ts +496 -0
  40. package/src/table-model.test.ts +429 -0
  41. package/src/table-model.ts +870 -0
  42. package/src/thinking-tiers.ts +56 -0
  43. package/tsconfig.json +4 -0
  44. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,870 @@
1
+ /**
2
+ * The TableDoc model — the typed grid that is a table's source of truth, plus
3
+ * the pure operations the API, tools, and UI all share. No DB, no IO: every
4
+ * function takes a doc and returns a new doc (or a derived value), so the whole
5
+ * model is unit-testable and safe to run inside a tool handler.
6
+ *
7
+ * This is the Tables analog of block-ids.ts / block-edit.ts for Pages, but the
8
+ * addressing primitive is native: every column and row carries a stable id, so
9
+ * "update row X" / "add a total to column Y" map straight onto `row.id` /
10
+ * `column.id` — no tree walking.
11
+ *
12
+ * columns[] typed column definitions (id, name, type, format, options, …)
13
+ * rows[] { id, cells: { [columnId]: CellValue } }
14
+ * aggregates { [columnId]: 'sum' | 'avg' | … } — footer totals
15
+ * views[] saved sort + filter configurations
16
+ *
17
+ * Formula evaluation lives in table-formula.ts; this module calls into it via
18
+ * `resolveCell` so callers always see computed values for formula columns.
19
+ */
20
+ // The mathjs-backed engine (see table-formula-mathjs.ts). `table-formula.ts`
21
+ // remains for one release as a revertible fallback and as the differential
22
+ // baseline in table-formula-diff.test.ts; nothing else should import it.
23
+ import { evalFormulaMath as evalFormula } from './table-formula-mathjs';
24
+
25
+ /** Isomorphic UUID — no `node:crypto`, no DB, so this module stays a
26
+ * browser-safe leaf the client grid can import directly. `randomUUID`
27
+ * itself only exists in secure contexts (HTTPS/localhost); on a plain-HTTP
28
+ * install (bare-IP, no-domain mode) it's absent, so fall back to a v4 built
29
+ * from `getRandomValues`, which is available everywhere. */
30
+ function randomUUID(): string {
31
+ const c = globalThis.crypto;
32
+ if (typeof c.randomUUID === 'function') return c.randomUUID();
33
+ const b = c.getRandomValues(new Uint8Array(16));
34
+ b[6] = (b[6]! & 0x0f) | 0x40;
35
+ b[8] = (b[8]! & 0x3f) | 0x80;
36
+ const h = Array.from(b, (x) => x.toString(16).padStart(2, '0'));
37
+ return `${h.slice(0, 4).join('')}-${h.slice(4, 6).join('')}-${h.slice(6, 8).join('')}-${h.slice(8, 10).join('')}-${h.slice(10).join('')}`;
38
+ }
39
+
40
+ export type ColumnType =
41
+ | 'text'
42
+ | 'number'
43
+ | 'currency'
44
+ | 'percent'
45
+ | 'date'
46
+ | 'datetime'
47
+ | 'checkbox'
48
+ | 'select'
49
+ | 'multiselect'
50
+ | 'url'
51
+ | 'formula'
52
+ | 'reference';
53
+
54
+ export const COLUMN_TYPES: readonly ColumnType[] = [
55
+ 'text',
56
+ 'number',
57
+ 'currency',
58
+ 'percent',
59
+ 'date',
60
+ 'datetime',
61
+ 'checkbox',
62
+ 'select',
63
+ 'multiselect',
64
+ 'url',
65
+ 'formula',
66
+ 'reference',
67
+ ];
68
+
69
+ /** Aggregations available for a column's footer total. */
70
+ export type AggregateKind = 'none' | 'sum' | 'avg' | 'count' | 'min' | 'max' | 'empty' | 'filled';
71
+
72
+ export const AGGREGATE_KINDS: readonly AggregateKind[] = [
73
+ 'none',
74
+ 'sum',
75
+ 'avg',
76
+ 'count',
77
+ 'min',
78
+ 'max',
79
+ 'empty',
80
+ 'filled',
81
+ ];
82
+
83
+ export type SelectOption = { id: string; label: string; color?: string };
84
+
85
+ export type ColumnFormat = {
86
+ /** ISO 4217 code for currency columns (e.g. 'USD', 'ZAR'). */
87
+ currency?: string;
88
+ /** Fixed decimal places for number/currency/percent rendering. */
89
+ decimals?: number;
90
+ };
91
+
92
+ /** Cross-tab reference target for type='reference' (v2.1 P4): the column
93
+ * offers VALUES from another tab's column, Excel data-validation style — a
94
+ * convenience picker (values copied as plain text/boolean at pick time, no
95
+ * joins, no live-follow; v2.2), same workbook only. */
96
+ export type ColumnRef = { tabId: string; columnId: string };
97
+
98
+ export type Column = {
99
+ id: string;
100
+ name: string;
101
+ type: ColumnType;
102
+ format?: ColumnFormat;
103
+ /** Options for select / multiselect columns. */
104
+ options?: SelectOption[];
105
+ /** Expression for formula columns, e.g. "{Qty} * {Price}". */
106
+ formula?: string;
107
+ /** Persisted pixel width (UI only). */
108
+ width?: number;
109
+ /** Source column for reference (linked) columns. */
110
+ ref?: ColumnRef;
111
+ };
112
+
113
+ /** The base type a column STORES / COERCES as — a linked (reference) column
114
+ * stores as 'select' (text); everything else is itself. Callers coercing a
115
+ * cell use this, not the raw `type`. */
116
+ export function storageType(col: Pick<Column, 'type'>): ColumnType {
117
+ return col.type === 'reference' ? 'select' : col.type;
118
+ }
119
+
120
+ /** A single cell's stored value. Formula cells are never stored — they're
121
+ * derived on read via `resolveCell`. */
122
+ export type CellValue = string | number | boolean | string[] | null;
123
+
124
+ export type Row = {
125
+ id: string;
126
+ cells: Record<string, CellValue>;
127
+ };
128
+
129
+ export type SortSpec = { colId: string; dir: 'asc' | 'desc' };
130
+
131
+ export type FilterOp =
132
+ 'eq' | 'neq' | 'contains' | 'gt' | 'lt' | 'gte' | 'lte' | 'empty' | 'notEmpty';
133
+
134
+ export const FILTER_OPS: readonly FilterOp[] = [
135
+ 'eq',
136
+ 'neq',
137
+ 'contains',
138
+ 'gt',
139
+ 'lt',
140
+ 'gte',
141
+ 'lte',
142
+ 'empty',
143
+ 'notEmpty',
144
+ ];
145
+
146
+ export type Filter = { colId: string; op: FilterOp; value?: CellValue };
147
+
148
+ export type View = {
149
+ id: string;
150
+ name: string;
151
+ sort?: SortSpec[];
152
+ filters?: Filter[];
153
+ };
154
+
155
+ export type TableDoc = {
156
+ columns: Column[];
157
+ rows: Row[];
158
+ aggregates?: Record<string, AggregateKind>;
159
+ views?: View[];
160
+ };
161
+
162
+ /** One tab of a multi-tab workbook (v2.1): a TableDoc plus its tab identity.
163
+ * Mirrors tabledb's WorkbookTabDoc structurally (one-way dep, same tripwire
164
+ * as TableDoc/TableDocLike). */
165
+ export type WorkbookTab = TableDoc & { id?: string; name: string };
166
+
167
+ /** Multi-tab write shape for import + tab-aware whole-doc writes. */
168
+ export type WorkbookDoc = { tabs: WorkbookTab[] };
169
+
170
+ /** Normalize a workbook input: each tab's doc through ensureTableDoc, names
171
+ * trimmed and defaulted ('Sheet1', 'Sheet2', …). Never returns zero tabs. */
172
+ export function ensureWorkbookDoc(input: WorkbookDoc): WorkbookDoc {
173
+ const tabs = (Array.isArray(input.tabs) ? input.tabs : []).map((t, i) => ({
174
+ ...ensureTableDoc(t),
175
+ ...(t.id ? { id: t.id } : {}),
176
+ name: (typeof t.name === 'string' && t.name.trim()) || `Sheet${i + 1}`,
177
+ }));
178
+ return { tabs: tabs.length > 0 ? tabs : [{ ...emptyTableDoc(), name: 'Sheet1' }] };
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Construction + id stability
183
+ // ---------------------------------------------------------------------------
184
+
185
+ /** A fresh blank grid: two text columns, three empty rows. */
186
+ export function emptyTableDoc(): TableDoc {
187
+ const columns: Column[] = [
188
+ { id: randomUUID(), name: 'Name', type: 'text' },
189
+ { id: randomUUID(), name: 'Notes', type: 'text' },
190
+ ];
191
+ const rows: Row[] = [0, 1, 2].map(() => ({ id: randomUUID(), cells: {} }));
192
+ return { columns, rows, aggregates: {}, views: [] };
193
+ }
194
+
195
+ /** Structural grid input (e.g. from a parsed spreadsheet) — column names +
196
+ * coarse types and row values aligned to the columns. `type` is a plain string
197
+ * so lower-level producers (like @mantle/files) needn't depend on this module;
198
+ * unknown types fall back to 'text'. */
199
+ export type GridInput = {
200
+ columns: { name: string; type?: string }[];
201
+ rows: (string | number | boolean | null)[][];
202
+ };
203
+
204
+ /** Build a TableDoc from a structural grid (the import path). Assigns stable
205
+ * ids and coerces every cell to its column type. */
206
+ export function tableDocFromGrid(input: GridInput): TableDoc {
207
+ const columns: Column[] = input.columns.map((c) => ({
208
+ id: randomUUID(),
209
+ name: c.name?.trim() || 'Column',
210
+ type: COLUMN_TYPES.includes(c.type as ColumnType) ? (c.type as ColumnType) : 'text',
211
+ }));
212
+ const rows: Row[] = input.rows.map((values) => {
213
+ const cells: Record<string, CellValue> = {};
214
+ columns.forEach((col, i) => {
215
+ const v = coerceCell(values[i] ?? null, storageType(col));
216
+ if (v !== null) cells[col.id] = v;
217
+ });
218
+ return { id: randomUUID(), cells };
219
+ });
220
+ return { columns, rows, aggregates: {}, views: [] };
221
+ }
222
+
223
+ /** Coerce an unknown/partial value into a well-formed TableDoc. Tolerant of
224
+ * legacy / hand-authored shapes — missing arrays become empty, every column
225
+ * and row gets a stable id, and cells are pruned to known columns. Returns the
226
+ * SAME reference when nothing changed (so callers can cheaply detect a no-op,
227
+ * mirroring `ensureBlockIds`). */
228
+ export function ensureTableDoc(input: unknown): TableDoc {
229
+ const doc = (input ?? {}) as Partial<TableDoc>;
230
+ const rawColumns = Array.isArray(doc.columns) ? doc.columns : [];
231
+ const rawRows = Array.isArray(doc.rows) ? doc.rows : [];
232
+
233
+ let changed = !Array.isArray(doc.columns) || !Array.isArray(doc.rows);
234
+
235
+ const columns: Column[] = rawColumns.map((c) => {
236
+ const col = (c ?? {}) as Partial<Column>;
237
+ const id = typeof col.id === 'string' && col.id ? col.id : ((changed = true), randomUUID());
238
+ const type = COLUMN_TYPES.includes(col.type as ColumnType)
239
+ ? (col.type as ColumnType)
240
+ : ((changed = true), 'text');
241
+ const out: Column = { id, name: typeof col.name === 'string' ? col.name : 'Column', type };
242
+ if (col.format && typeof col.format === 'object') out.format = col.format;
243
+ if (Array.isArray(col.options)) out.options = col.options;
244
+ if (typeof col.formula === 'string') out.formula = col.formula;
245
+ if (typeof col.width === 'number') out.width = col.width;
246
+ if (
247
+ col.ref &&
248
+ typeof col.ref === 'object' &&
249
+ typeof col.ref.tabId === 'string' &&
250
+ typeof col.ref.columnId === 'string'
251
+ ) {
252
+ out.ref = { tabId: col.ref.tabId, columnId: col.ref.columnId };
253
+ }
254
+ return out;
255
+ });
256
+
257
+ const colIds = new Set(columns.map((c) => c.id));
258
+ const rows: Row[] = rawRows.map((r) => {
259
+ const row = (r ?? {}) as Partial<Row>;
260
+ const id = typeof row.id === 'string' && row.id ? row.id : ((changed = true), randomUUID());
261
+ const rawCells = (row.cells ?? {}) as Record<string, CellValue>;
262
+ const cells: Record<string, CellValue> = {};
263
+ for (const [k, v] of Object.entries(rawCells)) {
264
+ if (colIds.has(k)) cells[k] = v;
265
+ else changed = true; // dropped a cell for a removed column
266
+ }
267
+ return { id, cells };
268
+ });
269
+
270
+ if (!changed) return input as TableDoc;
271
+ return {
272
+ columns,
273
+ rows,
274
+ aggregates: (doc.aggregates as Record<string, AggregateKind>) ?? {},
275
+ views: Array.isArray(doc.views) ? (doc.views as View[]) : [],
276
+ };
277
+ }
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // Lookups
281
+ // ---------------------------------------------------------------------------
282
+
283
+ export function findColumn(doc: TableDoc, columnId: string): Column | null {
284
+ return doc.columns.find((c) => c.id === columnId) ?? null;
285
+ }
286
+
287
+ export function findColumnByName(doc: TableDoc, name: string): Column | null {
288
+ const lower = name.trim().toLowerCase();
289
+ return doc.columns.find((c) => c.name.trim().toLowerCase() === lower) ?? null;
290
+ }
291
+
292
+ export function findRow(doc: TableDoc, rowId: string): Row | null {
293
+ return doc.rows.find((r) => r.id === rowId) ?? null;
294
+ }
295
+
296
+ export function rowIndex(doc: TableDoc, rowId: string): number {
297
+ return doc.rows.findIndex((r) => r.id === rowId);
298
+ }
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // Cell typing
302
+ // ---------------------------------------------------------------------------
303
+
304
+ /** Coerce a raw value into the storage shape for a column's type. Returns null
305
+ * for blanks. Pure — no locale formatting (that's the UI / table-to-text). */
306
+ export function coerceCell(value: unknown, type: ColumnType): CellValue {
307
+ if (value === null || value === undefined || value === '') return null;
308
+ switch (type) {
309
+ case 'number':
310
+ case 'currency':
311
+ case 'percent': {
312
+ const n = typeof value === 'number' ? value : Number(String(value).replace(/[, ]/g, ''));
313
+ return Number.isFinite(n) ? n : null;
314
+ }
315
+ case 'checkbox':
316
+ if (typeof value === 'boolean') return value;
317
+ return ['true', '1', 'yes', 'y', 'x', '✓'].includes(String(value).trim().toLowerCase());
318
+ case 'multiselect':
319
+ if (Array.isArray(value)) return value.map((v) => String(v));
320
+ return String(value)
321
+ .split(',')
322
+ .map((s) => s.trim())
323
+ .filter(Boolean);
324
+ case 'date':
325
+ case 'datetime':
326
+ case 'text':
327
+ case 'url':
328
+ case 'select':
329
+ case 'reference':
330
+ case 'formula':
331
+ default:
332
+ return String(value);
333
+ }
334
+ }
335
+
336
+ /** The value to use in computations / rendering for a (row, column): the stored
337
+ * cell, or — for formula columns — the evaluated result. */
338
+ export function resolveCell(doc: TableDoc, row: Row, col: Column): CellValue {
339
+ if (col.type === 'formula') {
340
+ return evalFormula(col.formula ?? '', doc, row);
341
+ }
342
+ return row.cells[col.id] ?? null;
343
+ }
344
+
345
+ /** Best-effort numeric reading of a resolved cell (for aggregates). */
346
+ export function cellNumber(value: CellValue): number | null {
347
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
348
+ if (typeof value === 'boolean') return value ? 1 : 0;
349
+ if (typeof value === 'string' && value.trim() !== '') {
350
+ const n = Number(value.replace(/[, ]/g, ''));
351
+ return Number.isFinite(n) ? n : null;
352
+ }
353
+ return null;
354
+ }
355
+
356
+ export function cellIsEmpty(value: CellValue): boolean {
357
+ if (value === null || value === undefined) return true;
358
+ if (typeof value === 'string') return value.trim() === '';
359
+ if (Array.isArray(value)) return value.length === 0;
360
+ return false;
361
+ }
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // Row operations (return a NEW doc)
365
+ // ---------------------------------------------------------------------------
366
+
367
+ /** Add a row. `cells` are coerced to their column types. `afterRowId` inserts
368
+ * after that row; omit to append. */
369
+ export function addRow(
370
+ doc: TableDoc,
371
+ cells: Record<string, CellValue> = {},
372
+ afterRowId?: string | null,
373
+ ): { doc: TableDoc; row: Row } {
374
+ const coerced: Record<string, CellValue> = {};
375
+ for (const col of doc.columns) {
376
+ if (col.type === 'formula') continue;
377
+ if (col.id in cells) coerced[col.id] = coerceCell(cells[col.id], storageType(col));
378
+ }
379
+ const row: Row = { id: randomUUID(), cells: coerced };
380
+ const rows = [...doc.rows];
381
+ const at = afterRowId ? rows.findIndex((r) => r.id === afterRowId) : -1;
382
+ if (at >= 0) rows.splice(at + 1, 0, row);
383
+ else rows.push(row);
384
+ return { doc: { ...doc, rows }, row };
385
+ }
386
+
387
+ /** Patch an existing row's cells (merge — unspecified cells are untouched). */
388
+ export function updateRow(
389
+ doc: TableDoc,
390
+ rowId: string,
391
+ cells: Record<string, CellValue>,
392
+ ): TableDoc {
393
+ const rows = doc.rows.map((r) => {
394
+ if (r.id !== rowId) return r;
395
+ const next = { ...r.cells };
396
+ for (const [colId, value] of Object.entries(cells)) {
397
+ const col = findColumn(doc, colId);
398
+ if (!col || col.type === 'formula') continue;
399
+ const v = coerceCell(value, storageType(col));
400
+ if (v === null) delete next[colId];
401
+ else next[colId] = v;
402
+ }
403
+ return { ...r, cells: next };
404
+ });
405
+ return { ...doc, rows };
406
+ }
407
+
408
+ export function deleteRow(doc: TableDoc, rowId: string): TableDoc {
409
+ return { ...doc, rows: doc.rows.filter((r) => r.id !== rowId) };
410
+ }
411
+
412
+ /** Set a single cell (the surgical "update row X column Y" primitive). */
413
+ export function setCell(
414
+ doc: TableDoc,
415
+ rowId: string,
416
+ columnId: string,
417
+ value: CellValue,
418
+ ): TableDoc {
419
+ return updateRow(doc, rowId, { [columnId]: value });
420
+ }
421
+
422
+ // ---------------------------------------------------------------------------
423
+ // Column operations (return a NEW doc)
424
+ // ---------------------------------------------------------------------------
425
+
426
+ export function addColumn(
427
+ doc: TableDoc,
428
+ spec: Omit<Column, 'id'> & { id?: string },
429
+ afterColumnId?: string | null,
430
+ ): { doc: TableDoc; column: Column } {
431
+ const column: Column = { ...spec, id: spec.id ?? randomUUID() };
432
+ const columns = [...doc.columns];
433
+ const at = afterColumnId ? columns.findIndex((c) => c.id === afterColumnId) : -1;
434
+ if (at >= 0) columns.splice(at + 1, 0, column);
435
+ else columns.push(column);
436
+ return { doc: { ...doc, columns }, column };
437
+ }
438
+
439
+ /** Patch a column definition. When the type changes, existing cells are
440
+ * re-coerced to the new type so the grid stays well-typed. */
441
+ export function updateColumn(
442
+ doc: TableDoc,
443
+ columnId: string,
444
+ patch: Partial<Omit<Column, 'id'>>,
445
+ ): TableDoc {
446
+ const current = findColumn(doc, columnId);
447
+ if (!current) return doc;
448
+ const next: Column = { ...current, ...patch, id: columnId };
449
+ const columns = doc.columns.map((c) => (c.id === columnId ? next : c));
450
+ let rows = doc.rows;
451
+ // Re-coerce cells when the STORAGE shape changes on a type change. Keyed on
452
+ // storageType so it matches the server's op-path recoerce, and so a text↔
453
+ // reference retype (both store as text/select) needn't rewrite cells.
454
+ const prevStorage = storageType(current);
455
+ const nextStorage = storageType(next);
456
+ if (prevStorage !== nextStorage) {
457
+ rows = doc.rows.map((r) => {
458
+ if (!(columnId in r.cells)) return r;
459
+ const v = coerceCell(r.cells[columnId], nextStorage);
460
+ const cells = { ...r.cells };
461
+ if (v === null) delete cells[columnId];
462
+ else cells[columnId] = v;
463
+ return { ...r, cells };
464
+ });
465
+ }
466
+ return { ...doc, columns, rows };
467
+ }
468
+
469
+ /** Remove a column and prune its cells + any aggregate / formula references. */
470
+ export function deleteColumn(doc: TableDoc, columnId: string): TableDoc {
471
+ const columns = doc.columns.filter((c) => c.id !== columnId);
472
+ const rows = doc.rows.map((r) => {
473
+ if (!(columnId in r.cells)) return r;
474
+ const cells = { ...r.cells };
475
+ delete cells[columnId];
476
+ return { ...r, cells };
477
+ });
478
+ const aggregates = { ...(doc.aggregates ?? {}) };
479
+ delete aggregates[columnId];
480
+ return { ...doc, columns, rows, aggregates };
481
+ }
482
+
483
+ /** Append a select/multiselect option to a column (deduped by label,
484
+ * case-insensitive). No-op if the label already exists or the column is
485
+ * missing. Used by the grid's combobox cell when the user creates a value
486
+ * inline. */
487
+ export function addSelectOption(doc: TableDoc, columnId: string, label: string): TableDoc {
488
+ const col = findColumn(doc, columnId);
489
+ if (!col) return doc;
490
+ const trimmed = label.trim();
491
+ if (!trimmed) return doc;
492
+ const options = col.options ?? [];
493
+ if (options.some((o) => o.label.toLowerCase() === trimmed.toLowerCase())) return doc;
494
+ const slug = trimmed
495
+ .toLowerCase()
496
+ .replace(/[^a-z0-9]+/g, '_')
497
+ .replace(/^_+|_+$/g, '');
498
+ const id = slug && !options.some((o) => o.id === slug) ? slug : randomUUID();
499
+ return updateColumn(doc, columnId, { options: [...options, { id, label: trimmed }] });
500
+ }
501
+
502
+ // ---------------------------------------------------------------------------
503
+ // Aggregates (footer totals)
504
+ // ---------------------------------------------------------------------------
505
+
506
+ export function setAggregate(doc: TableDoc, columnId: string, kind: AggregateKind): TableDoc {
507
+ const aggregates = { ...(doc.aggregates ?? {}) };
508
+ if (kind === 'none') delete aggregates[columnId];
509
+ else aggregates[columnId] = kind;
510
+ return { ...doc, aggregates };
511
+ }
512
+
513
+ /** Compute a column's aggregate over the given rows (defaults to all rows).
514
+ * Numeric kinds ignore non-numeric cells; count/filled/empty count cells. */
515
+ export function computeAggregate(
516
+ doc: TableDoc,
517
+ columnId: string,
518
+ kind: AggregateKind,
519
+ rows: Row[] = doc.rows,
520
+ ): number | null {
521
+ const col = findColumn(doc, columnId);
522
+ if (!col || kind === 'none') return null;
523
+ if (kind === 'count') return rows.length;
524
+ if (kind === 'filled') return rows.filter((r) => !cellIsEmpty(resolveCell(doc, r, col))).length;
525
+ if (kind === 'empty') return rows.filter((r) => cellIsEmpty(resolveCell(doc, r, col))).length;
526
+ const nums = rows
527
+ .map((r) => cellNumber(resolveCell(doc, r, col)))
528
+ .filter((n): n is number => n !== null);
529
+ if (nums.length === 0) return null;
530
+ switch (kind) {
531
+ case 'sum':
532
+ return nums.reduce((a, b) => a + b, 0);
533
+ case 'avg':
534
+ return nums.reduce((a, b) => a + b, 0) / nums.length;
535
+ case 'min':
536
+ return Math.min(...nums);
537
+ case 'max':
538
+ return Math.max(...nums);
539
+ default:
540
+ return null;
541
+ }
542
+ }
543
+
544
+ // ---------------------------------------------------------------------------
545
+ // Views (sort + filter) — non-mutating; returns the row slice to render
546
+ // ---------------------------------------------------------------------------
547
+
548
+ function matchesFilter(doc: TableDoc, row: Row, f: Filter): boolean {
549
+ const col = findColumn(doc, f.colId);
550
+ if (!col) return true;
551
+ const value = resolveCell(doc, row, col);
552
+ if (f.op === 'empty') return cellIsEmpty(value);
553
+ if (f.op === 'notEmpty') return !cellIsEmpty(value);
554
+ // Ordered comparisons never hold for an empty cell — it has no magnitude or
555
+ // order (SQL NULL semantics). Without this, `lt 30000` on a blank cell would
556
+ // fall through to the string path below (`'' < '30000'` === true) and wrongly
557
+ // match every empty-valued row. eq/neq/contains keep their string behaviour.
558
+ if (f.op === 'gt' || f.op === 'lt' || f.op === 'gte' || f.op === 'lte') {
559
+ if (cellIsEmpty(value)) return false;
560
+ }
561
+ const target = f.value ?? null;
562
+ const numA = cellNumber(value);
563
+ const numB = cellNumber(target);
564
+ const bothNumeric = numA !== null && numB !== null;
565
+ switch (f.op) {
566
+ case 'eq':
567
+ return String(value ?? '') === String(target ?? '');
568
+ case 'neq':
569
+ return String(value ?? '') !== String(target ?? '');
570
+ case 'contains':
571
+ return String(value ?? '')
572
+ .toLowerCase()
573
+ .includes(String(target ?? '').toLowerCase());
574
+ case 'gt':
575
+ return bothNumeric ? numA > numB : String(value ?? '') > String(target ?? '');
576
+ case 'lt':
577
+ return bothNumeric ? numA < numB : String(value ?? '') < String(target ?? '');
578
+ case 'gte':
579
+ return bothNumeric ? numA >= numB : String(value ?? '') >= String(target ?? '');
580
+ case 'lte':
581
+ return bothNumeric ? numA <= numB : String(value ?? '') <= String(target ?? '');
582
+ default:
583
+ return true;
584
+ }
585
+ }
586
+
587
+ function compareRows(doc: TableDoc, a: Row, b: Row, sort: SortSpec[]): number {
588
+ for (const s of sort) {
589
+ const col = findColumn(doc, s.colId);
590
+ if (!col) continue;
591
+ const va = resolveCell(doc, a, col);
592
+ const vb = resolveCell(doc, b, col);
593
+ const na = cellNumber(va);
594
+ const nb = cellNumber(vb);
595
+ let cmp: number;
596
+ if (na !== null && nb !== null) cmp = na - nb;
597
+ else cmp = String(va ?? '').localeCompare(String(vb ?? ''));
598
+ if (cmp !== 0) return s.dir === 'desc' ? -cmp : cmp;
599
+ }
600
+ return 0;
601
+ }
602
+
603
+ /** Apply a saved view's filters + sort, returning the rows to render. Does not
604
+ * mutate the doc. Unknown view id → all rows, document order. */
605
+ export function applyView(doc: TableDoc, viewId?: string | null): Row[] {
606
+ const view = viewId ? doc.views?.find((v) => v.id === viewId) : null;
607
+ let rows = doc.rows;
608
+ if (view?.filters?.length) {
609
+ rows = rows.filter((r) => view.filters!.every((f) => matchesFilter(doc, r, f)));
610
+ }
611
+ if (view?.sort?.length) {
612
+ rows = [...rows].sort((a, b) => compareRows(doc, a, b, view.sort!));
613
+ }
614
+ return rows;
615
+ }
616
+
617
+ /** An ad-hoc query over the grid — the same filter + sort a saved View carries,
618
+ * but supplied at call time and never persisted. */
619
+ export type RowQuery = {
620
+ filters?: Filter[];
621
+ /** 'all' (default) ANDs the filters; 'any' ORs them. */
622
+ match?: 'all' | 'any';
623
+ sort?: SortSpec[];
624
+ };
625
+
626
+ /** Filter + sort rows by an ad-hoc query, returning the matching rows (no view
627
+ * saved, doc unchanged). Reuses the exact predicate + comparator that saved
628
+ * views use, so `table_query` and a saved view agree. Pure. */
629
+ export function queryRows(doc: TableDoc, q: RowQuery = {}): Row[] {
630
+ const filters = q.filters ?? [];
631
+ let rows = doc.rows;
632
+ if (filters.length) {
633
+ const any = q.match === 'any';
634
+ rows = rows.filter((r) =>
635
+ any
636
+ ? filters.some((f) => matchesFilter(doc, r, f))
637
+ : filters.every((f) => matchesFilter(doc, r, f)),
638
+ );
639
+ }
640
+ if (q.sort?.length) {
641
+ rows = [...rows].sort((a, b) => compareRows(doc, a, b, q.sort!));
642
+ }
643
+ return rows;
644
+ }
645
+
646
+ /** One group bucket: the group-key cell values (aligned, in order, to the
647
+ * requested group columns) plus the rows that fell into it. */
648
+ export type GroupBucket = { key: CellValue[]; rows: Row[] };
649
+
650
+ /** Group rows by one or more columns, after an optional filter — the SQL
651
+ * GROUP BY analog. Buckets keep first-seen order; callers compute per-group
652
+ * aggregates over `bucket.rows` with `computeAggregate`. Pure. Lets a caller
653
+ * answer "count of circuits by metallurgy" or "max design pressure per
654
+ * service" in one pass instead of paging the whole grid and grouping by hand. */
655
+ export function groupRows(
656
+ doc: TableDoc,
657
+ opts: { groupColIds: string[]; filters?: Filter[]; match?: 'all' | 'any' },
658
+ ): GroupBucket[] {
659
+ const cols = opts.groupColIds
660
+ .map((id) => findColumn(doc, id))
661
+ .filter((c): c is Column => c !== null);
662
+ const rows = queryRows(doc, { filters: opts.filters, match: opts.match });
663
+ const order: string[] = [];
664
+ const buckets = new Map<string, GroupBucket>();
665
+ for (const r of rows) {
666
+ const key = cols.map((c) => resolveCell(doc, r, c));
667
+ const k = JSON.stringify(key);
668
+ let bucket = buckets.get(k);
669
+ if (!bucket) {
670
+ bucket = { key, rows: [] };
671
+ buckets.set(k, bucket);
672
+ order.push(k);
673
+ }
674
+ bucket.rows.push(r);
675
+ }
676
+ return order.map((k) => buckets.get(k)!);
677
+ }
678
+
679
+ /** Upsert a saved view by id (or append a new one). */
680
+ export function setView(doc: TableDoc, view: View): TableDoc {
681
+ const views = [...(doc.views ?? [])];
682
+ const at = views.findIndex((v) => v.id === view.id);
683
+ if (at >= 0) views[at] = view;
684
+ else views.push({ ...view, id: view.id || randomUUID() });
685
+ return { ...doc, views };
686
+ }
687
+
688
+ // ---------------------------------------------------------------------------
689
+ // Doc diff → draft ops (v2.1 P5)
690
+ // ---------------------------------------------------------------------------
691
+
692
+ /** The subset of tabledb's TableOp the differ emits (structurally identical —
693
+ * same one-way-dep tripwire as TableDoc/TableDocLike). */
694
+ /** column_update patch: absent key = keep, explicit `null` = CLEAR. JSON
695
+ * transport drops undefined keys, so removals MUST travel as null. */
696
+ export type TableColumnPatch = {
697
+ name?: string;
698
+ type?: ColumnType;
699
+ format?: ColumnFormat | null;
700
+ options?: Column['options'] | null;
701
+ formula?: string | null;
702
+ width?: number | null;
703
+ ref?: ColumnRef | null;
704
+ };
705
+
706
+ export type TableDocOp =
707
+ | {
708
+ op: 'row_add';
709
+ rowId: string;
710
+ cells?: Record<string, CellValue>;
711
+ afterRowId?: string | null;
712
+ atStart?: boolean;
713
+ }
714
+ | { op: 'row_update'; rowId: string; cells: Record<string, CellValue> }
715
+ | { op: 'row_delete'; rowId: string }
716
+ | { op: 'column_add'; column: Column; afterColumnId?: string | null }
717
+ | { op: 'column_update'; columnId: string; patch: TableColumnPatch }
718
+ | { op: 'column_delete'; columnId: string }
719
+ | { op: 'aggregate_set'; columnId: string; kind: AggregateKind }
720
+ | { op: 'view_set'; view: View };
721
+
722
+ const cellEq = (a: CellValue | undefined, b: CellValue | undefined): boolean =>
723
+ JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
724
+
725
+ /**
726
+ * Diff two docs into the draft ops that transform `prev` into `next` — the
727
+ * grid's whole-doc onChange becomes an op batch (tab-targetable, scales past
728
+ * the window). Returns NULL when the change isn't expressible as ops (row or
729
+ * column reordering, view deletion) — the caller falls back to a whole-doc
730
+ * save (single-tab tables) or surfaces the limitation.
731
+ */
732
+ export function diffTableDocs(prev: TableDoc, next: TableDoc): TableDocOp[] | null {
733
+ const ops: TableDocOp[] = [];
734
+
735
+ // ── Columns ──
736
+ const prevCols = new Map(prev.columns.map((c) => [c.id, c]));
737
+ const nextCols = new Map(next.columns.map((c) => [c.id, c]));
738
+ for (const c of prev.columns)
739
+ if (!nextCols.has(c.id)) ops.push({ op: 'column_delete', columnId: c.id });
740
+ // Reorder detection: surviving columns must keep their relative order.
741
+ const survivingPrev = prev.columns.filter((c) => nextCols.has(c.id)).map((c) => c.id);
742
+ const survivingNext = next.columns.filter((c) => prevCols.has(c.id)).map((c) => c.id);
743
+ if (survivingPrev.join(' ') !== survivingNext.join(' ')) return null;
744
+ next.columns.forEach((c, i) => {
745
+ const old = prevCols.get(c.id);
746
+ if (!old) {
747
+ const afterColumnId = i > 0 ? next.columns[i - 1]!.id : null;
748
+ ops.push({ op: 'column_add', column: c, afterColumnId });
749
+ return;
750
+ }
751
+ // Removals travel as explicit null — `undefined` disappears in JSON and
752
+ // the server would keep the old value (audit: width/format/ref clears
753
+ // were silently lost).
754
+ const patch: TableColumnPatch = {};
755
+ if (old.name !== c.name) patch.name = c.name;
756
+ if (old.type !== c.type) patch.type = c.type;
757
+ if (JSON.stringify(old.format ?? null) !== JSON.stringify(c.format ?? null))
758
+ patch.format = c.format ?? null;
759
+ if (JSON.stringify(old.options ?? null) !== JSON.stringify(c.options ?? null))
760
+ patch.options = c.options ?? null;
761
+ if ((old.formula ?? '') !== (c.formula ?? '')) patch.formula = c.formula ?? null;
762
+ if ((old.width ?? null) !== (c.width ?? null)) patch.width = c.width ?? null;
763
+ if (JSON.stringify(old.ref ?? null) !== JSON.stringify(c.ref ?? null))
764
+ patch.ref = c.ref ?? null;
765
+ if (Object.keys(patch).length > 0) ops.push({ op: 'column_update', columnId: c.id, patch });
766
+ });
767
+
768
+ // ── Rows ──
769
+ const prevRows = new Map(prev.rows.map((r) => [r.id, r]));
770
+ const nextRows = new Map(next.rows.map((r) => [r.id, r]));
771
+ for (const r of prev.rows) if (!nextRows.has(r.id)) ops.push({ op: 'row_delete', rowId: r.id });
772
+ const rowSurvivingPrev = prev.rows.filter((r) => nextRows.has(r.id)).map((r) => r.id);
773
+ const rowSurvivingNext = next.rows.filter((r) => prevRows.has(r.id)).map((r) => r.id);
774
+ if (rowSurvivingPrev.join(' ') !== rowSurvivingNext.join(' ')) return null;
775
+ const colIds = next.columns.filter((c) => c.type !== 'formula').map((c) => c.id);
776
+ next.rows.forEach((r, i) => {
777
+ const old = prevRows.get(r.id);
778
+ if (!old) {
779
+ // Anchor to the IMMEDIATE predecessor in `next` — including another
780
+ // new row: ops apply in batch order, so the predecessor already exists
781
+ // when this op runs. (Anchoring a run to one shared pre-existing row
782
+ // reversed it: the engine midpoint-inserts directly after the anchor,
783
+ // so each op landed BEFORE the previous — audit.) A new FIRST row is an
784
+ // explicit front insert; `afterRowId: null` means append to the engine.
785
+ if (i === 0) ops.push({ op: 'row_add', rowId: r.id, cells: r.cells, atStart: true });
786
+ else
787
+ ops.push({ op: 'row_add', rowId: r.id, cells: r.cells, afterRowId: next.rows[i - 1]!.id });
788
+ return;
789
+ }
790
+ const changed: Record<string, CellValue> = {};
791
+ for (const colId of colIds) {
792
+ if (!cellEq(old.cells[colId], r.cells[colId])) changed[colId] = r.cells[colId] ?? null;
793
+ }
794
+ if (Object.keys(changed).length > 0)
795
+ ops.push({ op: 'row_update', rowId: r.id, cells: changed });
796
+ });
797
+
798
+ // ── Aggregates ──
799
+ const prevAgg = prev.aggregates ?? {};
800
+ const nextAgg = next.aggregates ?? {};
801
+ for (const colId of new Set([...Object.keys(prevAgg), ...Object.keys(nextAgg)])) {
802
+ if (!nextCols.has(colId)) continue; // column_delete already cleans up
803
+ const a = prevAgg[colId] ?? 'none';
804
+ const b = nextAgg[colId] ?? 'none';
805
+ if (a !== b) ops.push({ op: 'aggregate_set', columnId: colId, kind: b });
806
+ }
807
+
808
+ // ── Views (upsert only — deletion and reordering have no op) ──
809
+ const prevViews = new Map((prev.views ?? []).map((v) => [v.id, v]));
810
+ const nextViewIds = new Set((next.views ?? []).map((v) => v.id));
811
+ for (const v of next.views ?? []) {
812
+ const old = prevViews.get(v.id);
813
+ if (!old || JSON.stringify(old) !== JSON.stringify(v)) ops.push({ op: 'view_set', view: v });
814
+ }
815
+ if ((prev.views ?? []).some((v) => !nextViewIds.has(v.id))) return null;
816
+ const viewOrderPrev = (prev.views ?? []).filter((v) => nextViewIds.has(v.id)).map((v) => v.id);
817
+ const viewOrderNext = (next.views ?? []).filter((v) => prevViews.has(v.id)).map((v) => v.id);
818
+ if (viewOrderPrev.join(' ') !== viewOrderNext.join(' ')) return null;
819
+
820
+ return ops;
821
+ }
822
+
823
+ // ── Table wire DTOs (jackdaw split P0 follow-up) ──────────────────────────────
824
+ // Moved from @mantle/content's tables surface; they live here beside TableDoc
825
+ // because the client grid needs the real doc shape and @mantle/client-types is
826
+ // zero-dep by rule.
827
+
828
+ export type TableVisibility = 'private' | 'public';
829
+
830
+ export type TableTabInfo = { id: string; name: string; rows: number; columns: number };
831
+
832
+ export type TableRow = {
833
+ id: string;
834
+ title: string;
835
+ icon: string | null;
836
+ tags: string[];
837
+ summary: string | null;
838
+ /** Author-set caveat shown to a reader BEFORE they query — see
839
+ * `CreateTableInput.description`. Distinct from `summary`, which the
840
+ * extractor generates and may replace. */
841
+ description: string | null;
842
+ visibility: TableVisibility;
843
+ /** Quick stats for the list (cheap to compute from the doc). */
844
+ columnCount: number;
845
+ rowCount: number;
846
+ createdAt: string;
847
+ updatedAt: string;
848
+ };
849
+
850
+ export type TableDetail = TableRow & {
851
+ /** Published grid — what's rendered everywhere and what the extractor
852
+ * indexes. Only changes on commit. For tables past the materialize window
853
+ * this is a LEADING WINDOW (`docClipped`) — page the rest via the rows
854
+ * route / windowed readers; `rowCount` stays the true total. For multi-tab
855
+ * workbooks this is ONE tab (the requested one, default first). */
856
+ data: TableDoc;
857
+ /** Autosaved working copy if uncommitted edits exist, else null. */
858
+ draft: TableDoc | null;
859
+ /** True when data/draft rows were clipped at the materialize window. */
860
+ docClipped?: boolean;
861
+ /** Draft-op etag: send back as if_rev so a stale client loses loudly. */
862
+ draftRev?: number;
863
+ /** Workbook tabs in position order (from registry stats; absent for legacy
864
+ * JSONB tables). `data`/`draft` carry the tab identified by `tabId`. */
865
+ tabs?: TableTabInfo[];
866
+ /** Which tab `data`/`draft` materialize (multi-tab workbooks). */
867
+ tabId?: string;
868
+ };
869
+
870
+ export type TableSort = 'edited' | 'newest' | 'oldest' | 'title';