@book.dev/sdk 1.60.0

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 (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -0
  3. package/dist/account.d.ts +57 -0
  4. package/dist/account.js +104 -0
  5. package/dist/account.js.map +1 -0
  6. package/dist/ai.d.ts +264 -0
  7. package/dist/ai.js +36 -0
  8. package/dist/ai.js.map +1 -0
  9. package/dist/backup.d.ts +52 -0
  10. package/dist/backup.js +35 -0
  11. package/dist/backup.js.map +1 -0
  12. package/dist/bookFolder.d.ts +44 -0
  13. package/dist/bookFolder.js +98 -0
  14. package/dist/bookFolder.js.map +1 -0
  15. package/dist/bookfile.d.ts +42 -0
  16. package/dist/bookfile.js +159 -0
  17. package/dist/bookfile.js.map +1 -0
  18. package/dist/client.d.ts +279 -0
  19. package/dist/client.js +502 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/connection.d.ts +20 -0
  22. package/dist/connection.js +77 -0
  23. package/dist/connection.js.map +1 -0
  24. package/dist/content.d.ts +38 -0
  25. package/dist/content.js +107 -0
  26. package/dist/content.js.map +1 -0
  27. package/dist/database.d.ts +687 -0
  28. package/dist/database.js +1082 -0
  29. package/dist/database.js.map +1 -0
  30. package/dist/formula.d.ts +41 -0
  31. package/dist/formula.js +469 -0
  32. package/dist/formula.js.map +1 -0
  33. package/dist/forwarding/challenge.d.ts +35 -0
  34. package/dist/forwarding/challenge.js +45 -0
  35. package/dist/forwarding/challenge.js.map +1 -0
  36. package/dist/forwarding/encoding.d.ts +14 -0
  37. package/dist/forwarding/encoding.js +54 -0
  38. package/dist/forwarding/encoding.js.map +1 -0
  39. package/dist/forwarding/forwardingClient.d.ts +76 -0
  40. package/dist/forwarding/forwardingClient.js +128 -0
  41. package/dist/forwarding/forwardingClient.js.map +1 -0
  42. package/dist/forwarding/index.d.ts +5 -0
  43. package/dist/forwarding/index.js +14 -0
  44. package/dist/forwarding/index.js.map +1 -0
  45. package/dist/forwarding/siteKey.d.ts +12 -0
  46. package/dist/forwarding/siteKey.js +36 -0
  47. package/dist/forwarding/siteKey.js.map +1 -0
  48. package/dist/forwarding/tunnelClient.d.ts +56 -0
  49. package/dist/forwarding/tunnelClient.js +206 -0
  50. package/dist/forwarding/tunnelClient.js.map +1 -0
  51. package/dist/forwarding/tunnelProtocol.d.ts +45 -0
  52. package/dist/forwarding/tunnelProtocol.js +29 -0
  53. package/dist/forwarding/tunnelProtocol.js.map +1 -0
  54. package/dist/index.d.ts +23 -0
  55. package/dist/index.js +19 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/mtime.d.ts +51 -0
  58. package/dist/mtime.js +84 -0
  59. package/dist/mtime.js.map +1 -0
  60. package/dist/pageProperties.d.ts +80 -0
  61. package/dist/pageProperties.js +97 -0
  62. package/dist/pageProperties.js.map +1 -0
  63. package/dist/plugins.d.ts +110 -0
  64. package/dist/plugins.js +113 -0
  65. package/dist/plugins.js.map +1 -0
  66. package/dist/routes.d.ts +89 -0
  67. package/dist/routes.js +89 -0
  68. package/dist/routes.js.map +1 -0
  69. package/dist/sampleDocument.d.ts +25 -0
  70. package/dist/sampleDocument.js +67 -0
  71. package/dist/sampleDocument.js.map +1 -0
  72. package/dist/suggestions.d.ts +126 -0
  73. package/dist/suggestions.js +21 -0
  74. package/dist/suggestions.js.map +1 -0
  75. package/dist/templates.d.ts +35 -0
  76. package/dist/templates.js +555 -0
  77. package/dist/templates.js.map +1 -0
  78. package/dist/types.d.ts +146 -0
  79. package/dist/types.js +16 -0
  80. package/dist/types.js.map +1 -0
  81. package/package.json +33 -0
@@ -0,0 +1,1082 @@
1
+ /**
2
+ * Full-featured databases — the second unit of storage layered over {@link
3
+ * StoredPage}. A **database** is a collection of pages (its *rows*) managed by
4
+ * typed *properties* and presented through one or more configurable *views*
5
+ * (table, board, gallery, calendar, list, or a bar/pie chart).
6
+ *
7
+ * Three ideas make OpenBook databases different from a plain spreadsheet:
8
+ *
9
+ * 1. **Rows are real pages.** Each row is an ordinary page in the `pages`
10
+ * table with its own editable document — so a row can itself contain text,
11
+ * reactive sliders, charts, even another database. Opening a row in the
12
+ * split pane edits that page directly.
13
+ *
14
+ * 2. **Columns can be reactive.** A property of type `expr` reads a *named
15
+ * exported cell* from the row page's reactive store (the `names`/`values`
16
+ * pairs in its {@link PageSnapshot}). A "Total" column can therefore show
17
+ * the live result of an expression block inside each row, and the table can
18
+ * filter and sort on it.
19
+ *
20
+ * 3. **Columns can compute.** A property of type `formula` evaluates a small
21
+ * expression over the row's *other* properties (`prop("Price") * prop("Qty")`)
22
+ * via the pure evaluator in {@link ./formula}. Filters/sorts/charts read the
23
+ * computed value just like any stored one.
24
+ *
25
+ * The host page (the page that *contains* the database) is itself a regular
26
+ * page with its own content; it merely points at the database. The database
27
+ * record — properties, views, filters — lives in its own `databases` table.
28
+ */
29
+ import { evaluateFormula, FormulaError } from './formula';
30
+ /** Whether each side of a relation cardinality holds a single row. `1:1` is
31
+ * single both ways; `1:n` links many but each target points back to one; `n:n`
32
+ * is many both ways. */
33
+ export function relationSides(card) {
34
+ return { forwardSingle: card === '1:1', reverseSingle: card !== 'n:n' };
35
+ }
36
+ /** Read a `{lat, lng, …}` location value from a cell, or null when unresolvable. */
37
+ export function asLocation(value) {
38
+ if (!value || typeof value !== 'object')
39
+ return null;
40
+ const v = value;
41
+ const lat = typeof v.lat === 'number' ? v.lat : Number(v.lat);
42
+ const lng = typeof v.lng === 'number' ? v.lng : Number(v.lng);
43
+ if (!Number.isFinite(lat) || !Number.isFinite(lng))
44
+ return null;
45
+ return {
46
+ lat,
47
+ lng,
48
+ ...(typeof v.label === 'string' && v.label.trim() ? { label: v.label } : {}),
49
+ ...(typeof v.address === 'string' && v.address.trim() ? { address: v.address } : {}),
50
+ };
51
+ }
52
+ /** The value-less, relative-to-today date operators. */
53
+ export const RELATIVE_DATE_OPS = [
54
+ 'is_today',
55
+ 'is_this_week',
56
+ 'is_past_week',
57
+ 'is_next_week',
58
+ 'is_this_month',
59
+ ];
60
+ /** True when a filter node is a group (vs. a leaf condition). */
61
+ export function isFilterGroup(node) {
62
+ return node.conjunction !== undefined;
63
+ }
64
+ export function buildRowTree(rows) {
65
+ const byParent = new Map();
66
+ const ids = new Set(rows.map((r) => r.id));
67
+ for (const row of rows) {
68
+ const parent = row.parentId && ids.has(row.parentId) ? row.parentId : null;
69
+ const list = byParent.get(parent) ?? [];
70
+ list.push(row);
71
+ byParent.set(parent, list);
72
+ }
73
+ const build = (parent, depth) => (byParent.get(parent) ?? []).map((row) => ({ row, depth, children: build(row.id, depth + 1) }));
74
+ return build(null, 0);
75
+ }
76
+ /** Flatten a row tree to a list (depth-first), dropping the children of collapsed rows. */
77
+ export function flattenRowTree(nodes, collapsed) {
78
+ const out = [];
79
+ const walk = (list) => {
80
+ for (const node of list) {
81
+ out.push(node);
82
+ if (node.children.length > 0 && !collapsed.has(node.row.id))
83
+ walk(node.children);
84
+ }
85
+ };
86
+ walk(nodes);
87
+ return out;
88
+ }
89
+ // ── Constants ────────────────────────────────────────────────────────────────
90
+ /** Reserved property id addressing the row's page title in filters/sorts/views. */
91
+ export const TITLE_PROPERTY_ID = 'title';
92
+ /** Swatch tokens for `select` options; resolved to colors by the UI. */
93
+ export const SELECT_COLORS = [
94
+ 'gray',
95
+ 'brown',
96
+ 'orange',
97
+ 'yellow',
98
+ 'green',
99
+ 'blue',
100
+ 'purple',
101
+ 'pink',
102
+ 'red',
103
+ ];
104
+ // ── Projection + view evaluation (shared by server and client) ───────────────
105
+ /**
106
+ * Project a page snapshot's reactive store into a `{name: value}` map. This is
107
+ * how `expr` columns get their values without shipping the whole document: the
108
+ * `names` index maps each exported name to a cellId, and `values` holds the
109
+ * cellId → value pairs.
110
+ */
111
+ export function projectExports(snapshot) {
112
+ const valueByCell = new Map(snapshot.values);
113
+ const out = {};
114
+ for (const [name, cellId] of snapshot.names) {
115
+ out[name] = valueByCell.get(cellId);
116
+ }
117
+ return out;
118
+ }
119
+ /** True when a URL looks like an image (by extension), for thumbnail rendering. */
120
+ export function isImageUrl(url) {
121
+ return /\.(png|jpe?g|gif|webp|avif|svg|bmp)(\?.*)?$/i.test(url.trim());
122
+ }
123
+ /** The first image URL in a `files`/`url` cell value (a string or string[]), or null. */
124
+ export function firstImageUrl(value) {
125
+ const urls = Array.isArray(value) ? value : typeof value === 'string' ? [value] : [];
126
+ for (const u of urls) {
127
+ if (typeof u === 'string' && isImageUrl(u))
128
+ return u;
129
+ }
130
+ return null;
131
+ }
132
+ /**
133
+ * The URL a gallery cover should try: the first extension-detected image, else
134
+ * the first http(s) URL — CDN/signed image URLs often carry no extension, and
135
+ * the property was explicitly chosen as the cover, so any URL in it is worth
136
+ * attempting (the UI falls back to the placeholder if it fails to load).
137
+ */
138
+ export function coverImageUrl(value) {
139
+ const urls = Array.isArray(value) ? value : typeof value === 'string' ? [value] : [];
140
+ const strings = urls.filter((u) => typeof u === 'string');
141
+ return strings.find(isImageUrl) ?? strings.find((u) => /^https?:\/\//i.test(u.trim())) ?? null;
142
+ }
143
+ /**
144
+ * The friendly value a `formula` reads when it references another property by
145
+ * name: a `select` resolves to its option *label* (not the opaque id), a
146
+ * verification to its boolean flag, multi-selects/relations to a comma list. So
147
+ * `prop("Status")` in a formula sees `"Done"`, matching what the cell shows.
148
+ */
149
+ function formulaFacingValue(row, property) {
150
+ switch (property.type) {
151
+ case 'select':
152
+ case 'status': {
153
+ const opt = property.options?.find((o) => o.id === row.properties[property.id]);
154
+ return opt ? opt.label : '';
155
+ }
156
+ case 'multi_select': {
157
+ const ids = Array.isArray(row.properties[property.id]) ? row.properties[property.id] : [];
158
+ return ids.map((id) => property.options?.find((o) => o.id === id)?.label ?? '').filter(Boolean).join(', ');
159
+ }
160
+ case 'checkbox':
161
+ return row.properties[property.id] === true;
162
+ case 'created_time':
163
+ return row.createdAt;
164
+ case 'last_edited_time':
165
+ return row.updatedAt;
166
+ case 'expr':
167
+ return row.exports[property.cellName ?? property.name];
168
+ case 'verification': {
169
+ const v = row.properties[property.id];
170
+ return !!(v && typeof v === 'object' && v.verified);
171
+ }
172
+ case 'date':
173
+ return dateStart(row.properties[property.id]) ?? '';
174
+ default:
175
+ return row.properties[property.id];
176
+ }
177
+ }
178
+ /**
179
+ * A {@link FormulaResolver} that looks a property up by name within one row and
180
+ * returns its formula-facing value. Resolves nested formulas recursively and
181
+ * guards against reference cycles (a cyclic ref yields a {@link FormulaError}).
182
+ * The reserved names `Name`/`Title` map to the row's page title.
183
+ */
184
+ function rowFormulaResolver(row, properties, rows) {
185
+ const byName = new Map(properties.map((p) => [p.name.toLowerCase(), p]));
186
+ const visiting = new Set();
187
+ const resolve = (name) => {
188
+ const key = name.toLowerCase();
189
+ const property = byName.get(key);
190
+ if (!property) {
191
+ if (key === 'name' || key === 'title')
192
+ return row.name ?? '';
193
+ return null;
194
+ }
195
+ if (property.type === 'formula') {
196
+ if (visiting.has(property.id))
197
+ return new FormulaError('Circular formula');
198
+ visiting.add(property.id);
199
+ try {
200
+ return evaluateFormula(property.formula ?? '', resolve);
201
+ }
202
+ finally {
203
+ visiting.delete(property.id);
204
+ }
205
+ }
206
+ // A `rollup` resolves to its computed value so formulas can build on rollups.
207
+ if (property.type === 'rollup')
208
+ return rowValue(row, property, properties, rows);
209
+ return formulaFacingValue(row, property);
210
+ };
211
+ return resolve;
212
+ }
213
+ /**
214
+ * Resolve the value a row holds for a given property (title / manual / derived).
215
+ * `properties` is needed only to evaluate `formula` columns (which read other
216
+ * properties); pass it from any caller that has the schema (filters, sorts,
217
+ * cells). Without it, a formula resolves to `undefined`.
218
+ */
219
+ export function rowValue(row, property, properties, rows) {
220
+ if (property === TITLE_PROPERTY_ID)
221
+ return row.name ?? '';
222
+ if (property.type === 'expr')
223
+ return row.exports[property.cellName ?? property.name];
224
+ if (property.type === 'formula') {
225
+ if (!properties)
226
+ return undefined;
227
+ return evaluateFormula(property.formula ?? '', rowFormulaResolver(row, properties, rows));
228
+ }
229
+ // A `rollup` folds a target property across the rows a relation points to.
230
+ if (property.type === 'rollup') {
231
+ if (!properties || !rows || !property.rollup)
232
+ return undefined;
233
+ return computeRollup(row, property.rollup, properties, rows);
234
+ }
235
+ // Timestamps are derived from the row page, not stored in `properties`.
236
+ if (property.type === 'created_time')
237
+ return row.createdAt;
238
+ if (property.type === 'last_edited_time')
239
+ return row.updatedAt;
240
+ // Verification filters/sorts on the boolean flag (so `is checked` works); the
241
+ // full {verified, by, at} object is read directly by the cell renderer.
242
+ if (property.type === 'verification') {
243
+ const v = row.properties[property.id];
244
+ return !!(v && typeof v === 'object' && v.verified);
245
+ }
246
+ // A `date` may hold a `{start, end}` range; filters/sorts compare the start.
247
+ if (property.type === 'date')
248
+ return dateStart(row.properties[property.id]) ?? '';
249
+ return row.properties[property.id];
250
+ }
251
+ /** Numeric folds shared by rollups, chart aggregates and summaries. */
252
+ const numericFold = (nums, fn) => {
253
+ if (nums.length === 0)
254
+ return 0;
255
+ const sorted = [...nums].sort((a, b) => a - b);
256
+ switch (fn) {
257
+ case 'sum':
258
+ return sorted.reduce((a, b) => a + b, 0);
259
+ case 'avg':
260
+ return sorted.reduce((a, b) => a + b, 0) / sorted.length;
261
+ case 'min':
262
+ return sorted[0];
263
+ case 'max':
264
+ return sorted[sorted.length - 1];
265
+ case 'range':
266
+ return sorted[sorted.length - 1] - sorted[0];
267
+ case 'median': {
268
+ const mid = Math.floor(sorted.length / 2);
269
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
270
+ }
271
+ }
272
+ };
273
+ /** Compute a rollup value: gather the related rows, fold the target property. */
274
+ function computeRollup(row, cfg, properties, rows) {
275
+ const relProp = properties.find((p) => p.id === cfg.relationPropertyId);
276
+ const raw = relProp ? row.properties[relProp.id] : undefined;
277
+ const ids = Array.isArray(raw) ? raw.filter((x) => typeof x === 'string') : [];
278
+ const byId = new Map(rows.map((r) => [r.id, r]));
279
+ const related = ids.map((id) => byId.get(id)).filter((r) => !!r);
280
+ if (cfg.function === 'count')
281
+ return related.length;
282
+ const target = cfg.targetPropertyId === TITLE_PROPERTY_ID ? TITLE_PROPERTY_ID : properties.find((p) => p.id === cfg.targetPropertyId);
283
+ if (!target)
284
+ return undefined;
285
+ const values = related.map((r) => rowValue(r, target, properties, rows));
286
+ switch (cfg.function) {
287
+ case 'show_original':
288
+ return values;
289
+ case 'count_values':
290
+ return values.filter((v) => !isEmpty(v)).length;
291
+ case 'count_unique':
292
+ return new Set(values.filter((v) => !isEmpty(v)).map((v) => (Array.isArray(v) ? JSON.stringify(v) : String(v)))).size;
293
+ case 'checked':
294
+ return values.filter((v) => v === true).length;
295
+ case 'percent_checked':
296
+ return values.length ? Math.round((values.filter((v) => v === true).length / values.length) * 100) : 0;
297
+ default: {
298
+ const nums = values.map(asNumber).filter((n) => !Number.isNaN(n));
299
+ return numericFold(nums, cfg.function);
300
+ }
301
+ }
302
+ }
303
+ // ── Dates & timeline spans ───────────────────────────────────────────────────
304
+ /** The `start` day of a date value (a plain `YYYY-MM-DD` string or a {@link DateRange}). */
305
+ export function dateStart(value) {
306
+ if (typeof value === 'string')
307
+ return value.trim() ? value : null;
308
+ if (value && typeof value === 'object') {
309
+ const s = value.start;
310
+ return typeof s === 'string' && s.trim() ? s : null;
311
+ }
312
+ return null;
313
+ }
314
+ /** The `end` day of a {@link DateRange} value (null for a single-day date). */
315
+ export function dateEnd(value) {
316
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
317
+ const e = value.end;
318
+ return typeof e === 'string' && e.trim() ? e : null;
319
+ }
320
+ return null;
321
+ }
322
+ /** Parse a `YYYY-MM-DD` (or any Date-parseable) string to a *local* midnight Date. */
323
+ export function parseDay(value) {
324
+ if (!value)
325
+ return null;
326
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
327
+ if (m)
328
+ return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
329
+ const d = new Date(value);
330
+ return Number.isNaN(d.getTime()) ? null : d;
331
+ }
332
+ /**
333
+ * The timeline bar span for a row: `[start, end]` resolved from the view's date
334
+ * configuration — a `dateRange` property (start+end in one), or a start date
335
+ * property plus an optional `endDatePropertyId`. Returns `null` when the row has
336
+ * no start date. A missing/earlier end collapses to a single-day bar.
337
+ */
338
+ export function rowDateSpan(row, view, properties) {
339
+ const startProp = properties.find((p) => p.id === view.datePropertyId);
340
+ if (!startProp)
341
+ return null;
342
+ const raw = row.properties[startProp.id];
343
+ const start = parseDay(dateStart(raw));
344
+ if (!start)
345
+ return null;
346
+ const endProp = view.endDatePropertyId ? properties.find((p) => p.id === view.endDatePropertyId) : undefined;
347
+ const endStr = endProp ? dateStart(row.properties[endProp.id]) : dateEnd(raw);
348
+ const end = parseDay(endStr);
349
+ return { start, end: end && end >= start ? end : start };
350
+ }
351
+ /**
352
+ * The map marker location for a row: the resolved coords of the view's
353
+ * `geoPropertyId` location cell, or null when the row has no usable coords (an
354
+ * empty cell, or an address that hasn't been geocoded). Pure — drives the map
355
+ * view's placed/unplaced split and is unit-testable.
356
+ */
357
+ export function rowLocation(row, view, properties) {
358
+ const geoProp = view.geoPropertyId ? properties.find((p) => p.id === view.geoPropertyId) : undefined;
359
+ if (!geoProp)
360
+ return null;
361
+ return asLocation(row.properties[geoProp.id]);
362
+ }
363
+ /**
364
+ * Lay a database's rows out as a dependency DAG: each row is a node, and its
365
+ * `dependency` property lists the predecessors it points back to (edge
366
+ * predecessor → dependent). Nodes are assigned a **layer** by longest path from a
367
+ * root (a row with no predecessors) so dependents always sit to the right of
368
+ * everything they depend on, and an **order** within the layer (stable, by row
369
+ * order). Pure and cycle-safe — a back-edge simply doesn't deepen the layer — so
370
+ * it can drive the graph view and be unit-tested directly.
371
+ */
372
+ export function dependencyGraph(rows, dependencyPropertyId) {
373
+ const ids = new Set(rows.map((r) => r.id));
374
+ const predsOf = new Map();
375
+ for (const r of rows) {
376
+ const raw = dependencyPropertyId ? r.properties[dependencyPropertyId] : undefined;
377
+ const deps = Array.isArray(raw) ? raw.filter((d) => typeof d === 'string') : [];
378
+ predsOf.set(r.id, deps.filter((id) => ids.has(id) && id !== r.id));
379
+ }
380
+ const layerCache = new Map();
381
+ const computing = new Set();
382
+ const layerOf = (id) => {
383
+ const cached = layerCache.get(id);
384
+ if (cached !== undefined)
385
+ return cached;
386
+ if (computing.has(id))
387
+ return 0; // cycle — break without deepening
388
+ computing.add(id);
389
+ const preds = predsOf.get(id) ?? [];
390
+ const layer = preds.length === 0 ? 0 : Math.max(...preds.map(layerOf)) + 1;
391
+ computing.delete(id);
392
+ layerCache.set(id, layer);
393
+ return layer;
394
+ };
395
+ const perLayer = new Map();
396
+ const nodes = rows.map((r) => {
397
+ const layer = layerOf(r.id);
398
+ const order = perLayer.get(layer) ?? 0;
399
+ perLayer.set(layer, order + 1);
400
+ return { id: r.id, layer, order };
401
+ });
402
+ const edges = [];
403
+ for (const r of rows)
404
+ for (const p of predsOf.get(r.id) ?? [])
405
+ edges.push({ from: p, to: r.id });
406
+ return {
407
+ nodes,
408
+ edges,
409
+ layerCount: Math.max(0, ...nodes.map((n) => n.layer)) + 1,
410
+ maxLayerSize: Math.max(1, ...[...perLayer.values()]),
411
+ };
412
+ }
413
+ /**
414
+ * Compute the inverse-property writes for a two-way `dependency` change: when
415
+ * `rowId`'s links go from `oldIds` to `newIds`, each newly-added related row gains
416
+ * `rowId` in its `inversePropertyId`, and each removed one loses it. Pure — the
417
+ * hook applies the returned updates — so the sync logic is unit-tested directly.
418
+ */
419
+ export function syncInverseUpdates(rowId, oldIds, newIds, relatedRows, inversePropertyId) {
420
+ const byId = new Map(relatedRows.map((r) => [r.id, r]));
421
+ const inverseOf = (r) => Array.isArray(r.properties[inversePropertyId]) ? r.properties[inversePropertyId] : [];
422
+ const updates = [];
423
+ for (const id of newIds.filter((x) => !oldIds.includes(x))) {
424
+ const r = byId.get(id);
425
+ if (r && !inverseOf(r).includes(rowId))
426
+ updates.push({ rowId: id, value: [...inverseOf(r), rowId] });
427
+ }
428
+ for (const id of oldIds.filter((x) => !newIds.includes(x))) {
429
+ const r = byId.get(id);
430
+ if (r && inverseOf(r).includes(rowId))
431
+ updates.push({ rowId: id, value: inverseOf(r).filter((x) => x !== rowId) });
432
+ }
433
+ return updates;
434
+ }
435
+ const isEmpty = (v) => v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0);
436
+ const asNumber = (v) => {
437
+ if (typeof v === 'number')
438
+ return v;
439
+ if (typeof v === 'string' && v.trim() !== '')
440
+ return Number(v);
441
+ return NaN;
442
+ };
443
+ const startOfDay = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
444
+ const shiftDays = (d, n) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
445
+ /** Evaluate a relative-to-`now` date operator against a cell's day value. */
446
+ function matchesRelativeDate(operator, cell, now) {
447
+ const day = parseDay(typeof cell === 'string' ? cell : '');
448
+ if (!day)
449
+ return false;
450
+ const today = startOfDay(now);
451
+ switch (operator) {
452
+ case 'is_today':
453
+ return day.getTime() === today.getTime();
454
+ case 'is_this_week': {
455
+ const start = shiftDays(today, -today.getDay()); // week starts Sunday
456
+ return day >= start && day < shiftDays(start, 7);
457
+ }
458
+ case 'is_past_week':
459
+ return day >= shiftDays(today, -7) && day <= today;
460
+ case 'is_next_week':
461
+ return day > today && day <= shiftDays(today, 7);
462
+ case 'is_this_month':
463
+ return day.getFullYear() === today.getFullYear() && day.getMonth() === today.getMonth();
464
+ default:
465
+ return false;
466
+ }
467
+ }
468
+ /**
469
+ * Evaluate a single filter against a resolved value. The optional `now`
470
+ * (defaulting to the current date) anchors the relative date operators
471
+ * (`is_today`, `is_this_week`, …) — pass it explicitly to keep tests deterministic.
472
+ */
473
+ export function matchesFilter(operator, cell, target, now) {
474
+ if (RELATIVE_DATE_OPS.includes(operator))
475
+ return matchesRelativeDate(operator, cell, now ?? new Date());
476
+ // Array cells (multi-select / relation) test membership / emptiness.
477
+ if (Array.isArray(cell)) {
478
+ const needle = String(target ?? '').toLowerCase();
479
+ const has = cell.some((x) => String(x).toLowerCase().includes(needle));
480
+ switch (operator) {
481
+ case 'contains':
482
+ return has;
483
+ case 'not_contains':
484
+ return !has;
485
+ case 'is_empty':
486
+ return cell.length === 0;
487
+ case 'is_not_empty':
488
+ return cell.length > 0;
489
+ default:
490
+ return true;
491
+ }
492
+ }
493
+ switch (operator) {
494
+ case 'equals':
495
+ return String(cell ?? '') === String(target ?? '');
496
+ case 'not_equals':
497
+ return String(cell ?? '') !== String(target ?? '');
498
+ case 'contains':
499
+ return String(cell ?? '').toLowerCase().includes(String(target ?? '').toLowerCase());
500
+ case 'not_contains':
501
+ return !String(cell ?? '').toLowerCase().includes(String(target ?? '').toLowerCase());
502
+ case 'starts_with':
503
+ return String(cell ?? '').toLowerCase().startsWith(String(target ?? '').toLowerCase());
504
+ case 'ends_with':
505
+ return String(cell ?? '').toLowerCase().endsWith(String(target ?? '').toLowerCase());
506
+ case 'gt':
507
+ return asNumber(cell) > asNumber(target);
508
+ case 'lt':
509
+ return asNumber(cell) < asNumber(target);
510
+ case 'gte':
511
+ return asNumber(cell) >= asNumber(target);
512
+ case 'lte':
513
+ return asNumber(cell) <= asNumber(target);
514
+ case 'before':
515
+ case 'after':
516
+ case 'on_or_before':
517
+ case 'on_or_after': {
518
+ const c = parseDay(String(cell ?? ''));
519
+ const t = parseDay(String(target ?? ''));
520
+ if (!c || !t)
521
+ return false;
522
+ const d = c.getTime() - t.getTime();
523
+ return operator === 'before' ? d < 0 : operator === 'after' ? d > 0 : operator === 'on_or_before' ? d <= 0 : d >= 0;
524
+ }
525
+ case 'is_empty':
526
+ return isEmpty(cell);
527
+ case 'is_not_empty':
528
+ return !isEmpty(cell);
529
+ case 'is_checked':
530
+ return cell === true;
531
+ case 'is_unchecked':
532
+ return cell !== true;
533
+ default:
534
+ return true;
535
+ }
536
+ }
537
+ const propertyById = (properties, id) => id === TITLE_PROPERTY_ID ? TITLE_PROPERTY_ID : properties.find((p) => p.id === id);
538
+ /** Compare two resolved cell values for sorting, numeric when both look numeric. */
539
+ function compareValues(a, b) {
540
+ if (isEmpty(a) && isEmpty(b))
541
+ return 0;
542
+ if (isEmpty(a))
543
+ return 1; // empties sort last
544
+ if (isEmpty(b))
545
+ return -1;
546
+ const na = asNumber(a);
547
+ const nb = asNumber(b);
548
+ if (!Number.isNaN(na) && !Number.isNaN(nb))
549
+ return na - nb;
550
+ return String(a).localeCompare(String(b));
551
+ }
552
+ /**
553
+ * The effective filter tree for a view: its `filterRoot`, or a synthesised
554
+ * all-`and` group wrapping the legacy flat `filters`. Lets the UI always edit a
555
+ * single tree while old views keep working.
556
+ */
557
+ export function viewFilterRoot(view) {
558
+ return view.filterRoot ?? { id: 'root', conjunction: 'and', filters: view.filters ?? [] };
559
+ }
560
+ /**
561
+ * Apply a view's filters and sorts to a row set, returning a new array. Filters
562
+ * are evaluated as a nested and/or tree ({@link viewFilterRoot}); sorts are
563
+ * applied in order (first sort is primary). Pure and side-effect free so it can
564
+ * run identically on the server or in the table UI.
565
+ */
566
+ /**
567
+ * True when a single leaf condition holds for a row — the same per-row test
568
+ * {@link applyView} uses for filtering, exposed for conditional formatting
569
+ * (color rules). Returns false when the condition's property no longer exists.
570
+ */
571
+ export function rowMatchesCondition(row, condition, properties, rows) {
572
+ const prop = properties.find((p) => p.id === condition.propertyId);
573
+ if (!prop)
574
+ return false;
575
+ return matchesFilter(condition.operator, rowValue(row, prop, properties, rows), condition.value);
576
+ }
577
+ export function applyView(rows, view, properties) {
578
+ const root = viewFilterRoot(view);
579
+ const evalNode = (node, row) => {
580
+ if (isFilterGroup(node)) {
581
+ if (node.filters.length === 0)
582
+ return true; // empty group matches everything
583
+ const results = node.filters.map((child) => evalNode(child, row));
584
+ return node.conjunction === 'or' ? results.some(Boolean) : results.every(Boolean);
585
+ }
586
+ const prop = propertyById(properties, node.propertyId);
587
+ if (!prop)
588
+ return true;
589
+ return matchesFilter(node.operator, rowValue(row, prop, properties, rows), node.value);
590
+ };
591
+ const filtered = rows.filter((row) => evalNode(root, row));
592
+ const sorts = view.sorts ?? [];
593
+ if (sorts.length === 0)
594
+ return filtered;
595
+ // Stable multi-key sort: index-tagged to keep equal rows in original order.
596
+ return filtered
597
+ .map((row, index) => ({ row, index }))
598
+ .sort((a, b) => {
599
+ for (const sort of sorts) {
600
+ const prop = propertyById(properties, sort.propertyId);
601
+ if (!prop)
602
+ continue;
603
+ const cmp = compareValues(rowValue(a.row, prop, properties, rows), rowValue(b.row, prop, properties, rows));
604
+ if (cmp !== 0)
605
+ return sort.direction === 'desc' ? -cmp : cmp;
606
+ }
607
+ return a.index - b.index;
608
+ })
609
+ .map((entry) => entry.row);
610
+ }
611
+ // ── Defaults ─────────────────────────────────────────────────────────────────
612
+ let counter = 0;
613
+ /** Short non-cryptographic id for properties/views/options/filters. */
614
+ export const shortId = (prefix) => {
615
+ counter += 1;
616
+ const rand = typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID().slice(0, 8) : `${counter}`;
617
+ return `${prefix}_${rand}`;
618
+ };
619
+ /**
620
+ * A sensible starting schema for a brand-new database: a couple of manual
621
+ * properties and both a table and a list view, so the view switcher has
622
+ * something to switch between out of the box.
623
+ */
624
+ export function defaultDatabaseSchema() {
625
+ const status = {
626
+ id: shortId('prop'),
627
+ name: 'Status',
628
+ type: 'select',
629
+ options: [
630
+ { id: shortId('opt'), label: 'Todo', color: 'gray' },
631
+ { id: shortId('opt'), label: 'In progress', color: 'blue' },
632
+ { id: shortId('opt'), label: 'Done', color: 'green' },
633
+ ],
634
+ };
635
+ const notes = { id: shortId('prop'), name: 'Notes', type: 'text' };
636
+ return {
637
+ properties: [status, notes],
638
+ views: [
639
+ { id: shortId('view'), name: 'Table', type: 'table', filters: [], sorts: [] },
640
+ { id: shortId('view'), name: 'Board', type: 'board', filters: [], sorts: [], groupByPropertyId: status.id },
641
+ { id: shortId('view'), name: 'List', type: 'list', filters: [], sorts: [] },
642
+ ],
643
+ };
644
+ }
645
+ /** A fresh view of a given type with sensible defaults for its layout. */
646
+ export function defaultView(type, name, properties) {
647
+ const view = { id: shortId('view'), name, type, filters: [], sorts: [] };
648
+ if (type === 'board' || type === 'bar' || type === 'pie') {
649
+ // Default the grouping to the first select/status property (kanban columns /
650
+ // chart categories read best off one), falling back to any property.
651
+ const select = properties.find((p) => p.type === 'select' || p.type === 'status');
652
+ view.groupByPropertyId = (select ?? properties[0])?.id;
653
+ }
654
+ if (type === 'calendar' || type === 'timeline') {
655
+ const date = properties.find((p) => p.type === 'date' || p.type === 'created_time' || p.type === 'last_edited_time');
656
+ view.datePropertyId = date?.id;
657
+ }
658
+ if (type === 'timeline') {
659
+ // A non-range start date pairs with a second date property for the bar end;
660
+ // a `dependency` property (if any) draws the arrows between bars.
661
+ const dates = properties.filter((p) => p.type === 'date');
662
+ if (dates.length >= 2 && !dates[0].dateRange)
663
+ view.endDatePropertyId = dates[1].id;
664
+ view.dependencyPropertyId = properties.find((p) => p.type === 'dependency')?.id;
665
+ }
666
+ if (type === 'graph') {
667
+ view.dependencyPropertyId = properties.find((p) => p.type === 'dependency')?.id;
668
+ }
669
+ if (type === 'map') {
670
+ // Place markers off the first location property; clustering on by default.
671
+ view.geoPropertyId = properties.find((p) => p.type === 'location')?.id;
672
+ view.mapClustered = true;
673
+ }
674
+ return view;
675
+ }
676
+ /** The starting options for a `status` property: one per lifecycle bucket. */
677
+ export function defaultStatusOptions() {
678
+ return [
679
+ { id: shortId('opt'), label: 'Not started', color: 'gray', group: 'todo' },
680
+ { id: shortId('opt'), label: 'In progress', color: 'blue', group: 'in_progress' },
681
+ { id: shortId('opt'), label: 'Done', color: 'green', group: 'complete' },
682
+ ];
683
+ }
684
+ /** The lifecycle buckets a `status` property groups its options under, in order. */
685
+ export const STATUS_GROUPS = [
686
+ { id: 'todo', label: 'To-do' },
687
+ { id: 'in_progress', label: 'In progress' },
688
+ { id: 'complete', label: 'Complete' },
689
+ ];
690
+ /**
691
+ * Remove a property from a schema and scrub **every** dangling reference to it:
692
+ * each view's filters (flat list *and* the nested {@link filterRoot} tree),
693
+ * sorts, visible columns, summaries, and the group-by / date / cover config; plus
694
+ * any `rollup` on another property that aggregated through or over it. Pure —
695
+ * returns a fresh schema — so it can be unit-tested and shared by the delete
696
+ * action. (Renders already tolerate stale refs; this keeps the schema clean.)
697
+ */
698
+ export function removeProperty(schema, propertyId) {
699
+ const pruneNode = (node) => {
700
+ if (isFilterGroup(node)) {
701
+ return { ...node, filters: node.filters.map(pruneNode).filter((n) => n !== null) };
702
+ }
703
+ return node.propertyId === propertyId ? null : node;
704
+ };
705
+ return {
706
+ ...schema,
707
+ properties: schema.properties
708
+ .filter((p) => p.id !== propertyId)
709
+ .map((p) => {
710
+ let next = p;
711
+ if (p.rollup && (p.rollup.relationPropertyId === propertyId || p.rollup.targetPropertyId === propertyId)) {
712
+ next = { ...next, rollup: undefined };
713
+ }
714
+ // Break a two-way dependency pairing when its partner is removed.
715
+ if (p.syncedPropertyId === propertyId)
716
+ next = { ...next, syncedPropertyId: undefined };
717
+ return next;
718
+ }),
719
+ views: schema.views.map((v) => {
720
+ const summaries = v.summaries
721
+ ? Object.fromEntries(Object.entries(v.summaries).filter(([k]) => k !== propertyId))
722
+ : undefined;
723
+ return {
724
+ ...v,
725
+ filters: (v.filters ?? []).filter((f) => f.propertyId !== propertyId),
726
+ filterRoot: v.filterRoot ? pruneNode(v.filterRoot) : undefined,
727
+ sorts: (v.sorts ?? []).filter((s) => s.propertyId !== propertyId),
728
+ visiblePropertyIds: v.visiblePropertyIds?.filter((id) => id !== propertyId),
729
+ summaries,
730
+ groupByPropertyId: v.groupByPropertyId === propertyId ? undefined : v.groupByPropertyId,
731
+ subGroupByPropertyId: v.subGroupByPropertyId === propertyId ? undefined : v.subGroupByPropertyId,
732
+ datePropertyId: v.datePropertyId === propertyId ? undefined : v.datePropertyId,
733
+ endDatePropertyId: v.endDatePropertyId === propertyId ? undefined : v.endDatePropertyId,
734
+ dependencyPropertyId: v.dependencyPropertyId === propertyId ? undefined : v.dependencyPropertyId,
735
+ coverPropertyId: v.coverPropertyId === propertyId ? undefined : v.coverPropertyId,
736
+ geoPropertyId: v.geoPropertyId === propertyId ? undefined : v.geoPropertyId,
737
+ addressPropertyId: v.addressPropertyId === propertyId ? undefined : v.addressPropertyId,
738
+ };
739
+ }),
740
+ // Drop the removed property's seed value from every row template.
741
+ templates: schema.templates?.map((t) => propertyId in t.properties
742
+ ? { ...t, properties: Object.fromEntries(Object.entries(t.properties).filter(([k]) => k !== propertyId)) }
743
+ : t),
744
+ };
745
+ }
746
+ // ── Number formatting ────────────────────────────────────────────────────────
747
+ const FORMAT_PREFIX = { dollar: '$', euro: '€', pound: '£', rupee: '₹' };
748
+ /** Format a numeric value for display per a {@link NumberFormat}. Non-numbers pass through as text. */
749
+ export function formatNumber(value, format) {
750
+ const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN;
751
+ if (Number.isNaN(n))
752
+ return value === undefined || value === null ? '' : String(value);
753
+ switch (format) {
754
+ case 'integer':
755
+ return Math.round(n).toLocaleString();
756
+ case 'decimal':
757
+ return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
758
+ case 'percent':
759
+ return `${(n * 100).toLocaleString(undefined, { maximumFractionDigits: 2 })}%`;
760
+ case 'yen':
761
+ return `¥${Math.round(n).toLocaleString()}`; // yen is conventionally whole-number
762
+ case 'dollar':
763
+ case 'euro':
764
+ case 'pound':
765
+ case 'rupee':
766
+ return `${FORMAT_PREFIX[format]}${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
767
+ default:
768
+ return Number.isInteger(n) ? String(n) : String(Number(n.toFixed(4)));
769
+ }
770
+ }
771
+ /**
772
+ * Format a `unique_id` value for display: an integer, optionally prefixed
773
+ * (`TASK` → `TASK-3`). Empty for unassigned (non-numeric) values. Pure.
774
+ */
775
+ export function formatUniqueId(value, prefix) {
776
+ if (typeof value !== 'number' || !Number.isFinite(value))
777
+ return '';
778
+ const p = prefix?.trim();
779
+ return p ? `${p}-${value}` : String(value);
780
+ }
781
+ /**
782
+ * The clamped 0..1 fraction of a number cell relative to its `target` (the
783
+ * value that fills a `bar`/`ring`). Non-numbers and a non-positive target read
784
+ * as 0; the target defaults to 100. Pure — drives the bar/ring cell and tests.
785
+ */
786
+ export function numberProgress(value, target) {
787
+ const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN;
788
+ const max = typeof target === 'number' && target > 0 ? target : 100;
789
+ if (!Number.isFinite(n))
790
+ return 0;
791
+ return Math.max(0, Math.min(1, n / max));
792
+ }
793
+ // ── Grouping + aggregation (board columns, charts) ───────────────────────────
794
+ /** The label shown for rows that have no value for the grouping property. */
795
+ export const NO_VALUE_GROUP = 'No value';
796
+ /** Display string for a row's value of a property (option labels, joined lists). */
797
+ function displayValue(row, property, properties) {
798
+ const v = rowValue(row, property, properties);
799
+ if (property.type === 'select' || property.type === 'status') {
800
+ return property.options?.find((o) => o.id === row.properties[property.id])?.label ?? '';
801
+ }
802
+ if (v instanceof FormulaError)
803
+ return v.message;
804
+ if (property.type === 'location') {
805
+ const loc = asLocation(v);
806
+ return loc ? loc.label ?? loc.address ?? `${loc.lat.toFixed(4)}, ${loc.lng.toFixed(4)}` : '';
807
+ }
808
+ if (Array.isArray(v))
809
+ return v.map(String).join(', ');
810
+ if (v === undefined || v === null)
811
+ return '';
812
+ if (typeof v === 'boolean')
813
+ return v ? 'Checked' : 'Unchecked';
814
+ return String(v);
815
+ }
816
+ /**
817
+ * Group rows by a property for the board (kanban) layout. When the property is a
818
+ * `select`, columns follow the option order (including empty ones) so the board
819
+ * is stable as rows move; otherwise columns are the distinct displayed values.
820
+ * Rows with no value collect into a trailing {@link NO_VALUE_GROUP} column.
821
+ */
822
+ export function groupRows(rows, property, properties) {
823
+ if (!property)
824
+ return [{ key: '__all__', label: 'All', rows }];
825
+ if (property.type === 'select' || property.type === 'status') {
826
+ const groups = (property.options ?? []).map((o) => ({ key: o.id, label: o.label, color: o.color, rows: [] }));
827
+ const byId = new Map(groups.map((g) => [g.key, g]));
828
+ const none = { key: '__none__', label: NO_VALUE_GROUP, rows: [] };
829
+ for (const row of rows) {
830
+ const id = row.properties[property.id];
831
+ const group = typeof id === 'string' ? byId.get(id) : undefined;
832
+ (group ?? none).rows.push(row);
833
+ }
834
+ return none.rows.length ? [...groups, none] : groups;
835
+ }
836
+ // Relation: one group per *linked page* (keyed by its row/page id), so a board
837
+ // or timeline grouped by a relation gets a column/band per related page. A row
838
+ // with several links appears in each; the UI resolves the key to a page
839
+ // title + icon. Rows with no link collect into the trailing "No value" group.
840
+ if (property.type === 'relation') {
841
+ const order = [];
842
+ const byId = new Map();
843
+ const none = { key: '__none__', label: NO_VALUE_GROUP, rows: [] };
844
+ for (const row of rows) {
845
+ const raw = row.properties[property.id];
846
+ const ids = Array.isArray(raw) ? raw.filter((x) => typeof x === 'string') : [];
847
+ if (ids.length === 0) {
848
+ none.rows.push(row);
849
+ continue;
850
+ }
851
+ for (const id of ids) {
852
+ let group = byId.get(id);
853
+ if (!group) {
854
+ // Label is the page id; the UI swaps in the resolved title.
855
+ group = { key: id, label: id, rows: [] };
856
+ byId.set(id, group);
857
+ order.push(id);
858
+ }
859
+ group.rows.push(row);
860
+ }
861
+ }
862
+ const groups = order.map((id) => byId.get(id));
863
+ return none.rows.length ? [...groups, none] : groups;
864
+ }
865
+ // Generic: bucket by displayed value, preserving first-seen order.
866
+ const order = [];
867
+ const byLabel = new Map();
868
+ for (const row of rows) {
869
+ const label = displayValue(row, property, properties) || NO_VALUE_GROUP;
870
+ let group = byLabel.get(label);
871
+ if (!group) {
872
+ group = { key: label, label, rows: [] };
873
+ byLabel.set(label, group);
874
+ order.push(label);
875
+ }
876
+ group.rows.push(row);
877
+ }
878
+ return order.map((label) => byLabel.get(label));
879
+ }
880
+ /**
881
+ * Sentinel `groupByPropertyId` meaning "group by parent item" (sub-items).
882
+ * Not a real property id — {@link groupRowsBy} dispatches on it.
883
+ */
884
+ export const PARENT_GROUP_ID = '__parent__';
885
+ /** The label of the trailing group for rows that are nobody's sub-item. */
886
+ export const NO_PARENT_GROUP = 'No parent';
887
+ /**
888
+ * Group rows by their parent row (sub-items): one group per row with at least
889
+ * one direct child in the set (key = the parent's row id, in row order), plus a
890
+ * trailing {@link NO_PARENT_GROUP} group for loose rows — rows that neither
891
+ * have a parent in the set nor children of their own. A row that is both a
892
+ * parent and a sub-item appears in its parent's group *and* heads its own.
893
+ * Parents outside the set (filtered out, or the host page) don't count.
894
+ */
895
+ export function groupRowsByParent(rows) {
896
+ const ids = new Set(rows.map((r) => r.id));
897
+ const children = new Map();
898
+ for (const row of rows) {
899
+ if (row.parentId && ids.has(row.parentId)) {
900
+ const list = children.get(row.parentId);
901
+ if (list)
902
+ list.push(row);
903
+ else
904
+ children.set(row.parentId, [row]);
905
+ }
906
+ }
907
+ const groups = [];
908
+ const none = { key: '__none__', label: NO_PARENT_GROUP, rows: [] };
909
+ for (const row of rows) {
910
+ const kids = children.get(row.id);
911
+ if (kids)
912
+ groups.push({ key: row.id, label: row.name?.trim() || 'Untitled', rows: kids });
913
+ else if (!(row.parentId && ids.has(row.parentId)))
914
+ none.rows.push(row);
915
+ }
916
+ return none.rows.length ? [...groups, none] : groups;
917
+ }
918
+ /**
919
+ * Group rows by a view's `groupByPropertyId`: the {@link PARENT_GROUP_ID}
920
+ * sentinel groups by parent item ({@link groupRowsByParent}); anything else
921
+ * resolves to a property and falls through to {@link groupRows} (an unset or
922
+ * unknown id yields the single "All" group). The one dispatch shared by the
923
+ * board, table, list, gallery, and the chart aggregations.
924
+ */
925
+ export function groupRowsBy(rows, groupByPropertyId, properties) {
926
+ if (groupByPropertyId === PARENT_GROUP_ID)
927
+ return groupRowsByParent(rows);
928
+ return groupRows(rows, properties.find((p) => p.id === groupByPropertyId), properties);
929
+ }
930
+ const foldAggregate = (rows, agg, properties) => {
931
+ if (agg.type === 'count' || !agg.propertyId)
932
+ return rows.length;
933
+ const prop = properties.find((p) => p.id === agg.propertyId);
934
+ if (!prop)
935
+ return rows.length;
936
+ const nums = rows
937
+ .map((r) => rowValue(r, prop, properties, rows))
938
+ .map((v) => (typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN))
939
+ .filter((n) => !Number.isNaN(n));
940
+ if (nums.length === 0)
941
+ return 0;
942
+ switch (agg.type) {
943
+ case 'sum':
944
+ return nums.reduce((a, b) => a + b, 0);
945
+ case 'avg':
946
+ return nums.reduce((a, b) => a + b, 0) / nums.length;
947
+ case 'min':
948
+ return Math.min(...nums);
949
+ case 'max':
950
+ return Math.max(...nums);
951
+ default:
952
+ return rows.length;
953
+ }
954
+ };
955
+ /**
956
+ * Aggregate rows into chart data: one datum per group of the view's
957
+ * `groupByPropertyId`, with the bar/slice height computed by the view's
958
+ * `aggregate` (count by default, else sum/avg/min/max of a numeric property).
959
+ */
960
+ export function aggregateRows(rows, view, properties) {
961
+ const agg = view.aggregate ?? { type: 'count' };
962
+ return groupRowsBy(rows, view.groupByPropertyId, properties).map((g) => ({
963
+ key: g.key,
964
+ label: g.label,
965
+ color: g.color,
966
+ value: foldAggregate(g.rows, agg, properties),
967
+ }));
968
+ }
969
+ /** Synthetic series used when a chart has no breakdown (a single full-height bar). */
970
+ export const CHART_TOTAL_SERIES = '__total__';
971
+ /**
972
+ * Aggregate rows into a {@link ChartMatrix}: one group per value of the view's
973
+ * `groupByPropertyId`, each split into segments by `breakdownPropertyId` (the
974
+ * second-level group). The series are derived once across all rows so every group
975
+ * shares the same ordered, coloured set — a group with no rows for a series gets a
976
+ * zero segment, keeping stacked bars aligned. Without a breakdown each group has a
977
+ * single {@link CHART_TOTAL_SERIES} segment equal to its total. Pure — drives the
978
+ * bar and pie charts and their drill-downs.
979
+ */
980
+ export function aggregateMatrix(rows, view, properties) {
981
+ const agg = view.aggregate ?? { type: 'count' };
982
+ const groups = groupRowsBy(rows, view.groupByPropertyId, properties);
983
+ // A breakdown id is honoured when it differs from the primary grouping and
984
+ // resolves to something real (a property, or the parent-item sentinel).
985
+ const breakdownId = view.breakdownPropertyId &&
986
+ view.breakdownPropertyId !== view.groupByPropertyId &&
987
+ (view.breakdownPropertyId === PARENT_GROUP_ID || properties.some((p) => p.id === view.breakdownPropertyId))
988
+ ? view.breakdownPropertyId
989
+ : undefined;
990
+ if (!breakdownId) {
991
+ return {
992
+ series: [{ key: CHART_TOTAL_SERIES, label: '' }],
993
+ groups: groups.map((g) => {
994
+ const total = foldAggregate(g.rows, agg, properties);
995
+ return {
996
+ key: g.key,
997
+ label: g.label,
998
+ color: g.color,
999
+ total,
1000
+ rows: g.rows,
1001
+ segments: [{ seriesKey: CHART_TOTAL_SERIES, value: total, rows: g.rows }],
1002
+ };
1003
+ }),
1004
+ };
1005
+ }
1006
+ // Derive the shared series from the breakdown across every row (stable order).
1007
+ // Segments intersect a series' full-set rows with the group's rows rather than
1008
+ // re-grouping the subset: for property breakdowns the two are equivalent, but a
1009
+ // parent-item breakdown needs the full set (a subset loses the parents that
1010
+ // anchor its groups).
1011
+ const seriesGroups = groupRowsBy(rows, breakdownId, properties);
1012
+ const series = seriesGroups.map((s) => ({ key: s.key, label: s.label, color: s.color }));
1013
+ return {
1014
+ series,
1015
+ groups: groups.map((g) => {
1016
+ const inGroup = new Set(g.rows.map((r) => r.id));
1017
+ const segments = seriesGroups.map((s) => {
1018
+ const segRows = s.rows.filter((r) => inGroup.has(r.id));
1019
+ return { seriesKey: s.key, value: foldAggregate(segRows, agg, properties), rows: segRows };
1020
+ });
1021
+ return { key: g.key, label: g.label, color: g.color, total: foldAggregate(g.rows, agg, properties), rows: g.rows, segments };
1022
+ }),
1023
+ };
1024
+ }
1025
+ // ── Column summaries (table footers) ─────────────────────────────────────────
1026
+ const toNum = (v) => typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN;
1027
+ /**
1028
+ * Compute a column footer summary over a row set: counts (all / values / empty /
1029
+ * filled / unique), percentages, or numeric folds (sum / avg / min / max / range
1030
+ * / median). Returns a display string ('' for `none`). `property` is
1031
+ * {@link TITLE_PROPERTY_ID} for the title column; numeric folds honour a
1032
+ * property's `numberFormat`. Pure — shared by the table footer UI and tests.
1033
+ */
1034
+ export function summarizeColumn(rows, property, type, properties) {
1035
+ if (type === 'none')
1036
+ return '';
1037
+ if (type === 'count_all')
1038
+ return String(rows.length);
1039
+ const values = rows.map((r) => rowValue(r, property, properties, rows));
1040
+ const filled = values.filter((v) => !isEmpty(v));
1041
+ const total = rows.length || 1;
1042
+ switch (type) {
1043
+ case 'count_values':
1044
+ case 'count_filled':
1045
+ return String(filled.length);
1046
+ case 'count_empty':
1047
+ return String(values.length - filled.length);
1048
+ case 'count_unique':
1049
+ return String(new Set(filled.map((v) => (Array.isArray(v) ? JSON.stringify(v) : String(v)))).size);
1050
+ case 'percent_empty':
1051
+ return `${Math.round(((values.length - filled.length) / total) * 100)}%`;
1052
+ case 'percent_filled':
1053
+ return `${Math.round((filled.length / total) * 100)}%`;
1054
+ default:
1055
+ break;
1056
+ }
1057
+ // Numeric folds.
1058
+ const nums = filled.map(toNum).filter((n) => !Number.isNaN(n)).sort((a, b) => a - b);
1059
+ if (nums.length === 0)
1060
+ return '—';
1061
+ const format = property !== TITLE_PROPERTY_ID ? property.numberFormat : undefined;
1062
+ const fmt = (n) => formatNumber(n, format);
1063
+ switch (type) {
1064
+ case 'sum':
1065
+ return fmt(nums.reduce((a, b) => a + b, 0));
1066
+ case 'avg':
1067
+ return fmt(nums.reduce((a, b) => a + b, 0) / nums.length);
1068
+ case 'min':
1069
+ return fmt(nums[0]);
1070
+ case 'max':
1071
+ return fmt(nums[nums.length - 1]);
1072
+ case 'range':
1073
+ return fmt(nums[nums.length - 1] - nums[0]);
1074
+ case 'median': {
1075
+ const mid = Math.floor(nums.length / 2);
1076
+ return fmt(nums.length % 2 ? nums[mid] : (nums[mid - 1] + nums[mid]) / 2);
1077
+ }
1078
+ default:
1079
+ return '';
1080
+ }
1081
+ }
1082
+ //# sourceMappingURL=database.js.map