@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.
- package/LICENSE.md +135 -0
- package/package.json +41 -0
- package/src/block-diff.test.ts +190 -0
- package/src/block-diff.ts +163 -0
- package/src/block-ids.test.ts +358 -0
- package/src/block-ids.ts +242 -0
- package/src/block-list.test.ts +241 -0
- package/src/block-list.ts +177 -0
- package/src/contacts-format.ts +260 -0
- package/src/doc-to-markdown.test.ts +194 -0
- package/src/doc-to-markdown.ts +315 -0
- package/src/formula-dimensions.test.ts +103 -0
- package/src/formula-dimensions.ts +231 -0
- package/src/formula-eval.ts +294 -0
- package/src/formula-seed.test.ts +175 -0
- package/src/formula-seed.ts +466 -0
- package/src/formula-signature.test.ts +336 -0
- package/src/formula-signature.ts +435 -0
- package/src/formula-spec.test.ts +458 -0
- package/src/formula-spec.ts +566 -0
- package/src/journal-options.test.ts +57 -0
- package/src/journal-options.ts +77 -0
- package/src/markdown-refs.test.ts +143 -0
- package/src/markdown-refs.ts +172 -0
- package/src/markdown-to-doc.test.ts +179 -0
- package/src/markdown-to-doc.ts +567 -0
- package/src/onboarding-questions.test.ts +75 -0
- package/src/onboarding-questions.ts +90 -0
- package/src/page-diff.test.ts +82 -0
- package/src/page-diff.ts +120 -0
- package/src/page-split.test.ts +141 -0
- package/src/page-split.ts +128 -0
- package/src/page-toc.test.ts +58 -0
- package/src/page-toc.ts +89 -0
- package/src/persona-bank.test.ts +67 -0
- package/src/persona-bank.ts +234 -0
- package/src/table-formula-mathjs.ts +259 -0
- package/src/table-formula.test.ts +157 -0
- package/src/table-formula.ts +496 -0
- package/src/table-model.test.ts +429 -0
- package/src/table-model.ts +870 -0
- package/src/thinking-tiers.ts +56 -0
- package/tsconfig.json +4 -0
- 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';
|