@mulmoclaude/core 0.13.1 → 0.15.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 (59) hide show
  1. package/assets/helps/billing-clients-worklog.md +15 -12
  2. package/assets/helps/collection-skills.md +67 -22
  3. package/assets/helps/todo-collection.md +13 -7
  4. package/dist/{deriveAll-BQ_p5HSB.cjs → calendarGrid-CaR-q36Y.js} +352 -74
  5. package/dist/calendarGrid-CaR-q36Y.js.map +1 -0
  6. package/dist/calendarGrid-gN4sei7s.cjs +1070 -0
  7. package/dist/calendarGrid-gN4sei7s.cjs.map +1 -0
  8. package/dist/collection/core/backlinks.d.ts +20 -0
  9. package/dist/collection/core/ids.d.ts +12 -0
  10. package/dist/collection/core/recordZ.d.ts +23 -0
  11. package/dist/collection/core/schema.d.ts +72 -418
  12. package/dist/collection/core/schemaZ.d.ts +878 -0
  13. package/dist/collection/core/templatePath.d.ts +34 -0
  14. package/dist/collection/core/uiTypes.d.ts +22 -0
  15. package/dist/collection/core/where.d.ts +9 -15
  16. package/dist/collection/index.cjs +50 -330
  17. package/dist/collection/index.cjs.map +1 -1
  18. package/dist/collection/index.d.ts +2 -0
  19. package/dist/collection/index.js +14 -301
  20. package/dist/collection/index.js.map +1 -1
  21. package/dist/collection/paths.cjs +1 -1
  22. package/dist/collection/paths.cjs.map +1 -1
  23. package/dist/collection/paths.js +1 -1
  24. package/dist/collection/paths.js.map +1 -1
  25. package/dist/collection/registry/server/index.cjs +1 -1
  26. package/dist/collection/registry/server/index.js +1 -1
  27. package/dist/collection/server/discovery.d.ts +2 -244
  28. package/dist/collection/server/index.cjs +8 -2
  29. package/dist/collection/server/index.d.ts +1 -0
  30. package/dist/collection/server/index.js +3 -2
  31. package/dist/collection/server/io.d.ts +9 -0
  32. package/dist/collection/server/ontology.d.ts +35 -0
  33. package/dist/collection/server/templatePath.d.ts +1 -34
  34. package/dist/collection/server/validate.d.ts +11 -7
  35. package/dist/collection-watchers/index.cjs +5 -4
  36. package/dist/collection-watchers/index.cjs.map +1 -1
  37. package/dist/collection-watchers/index.js +3 -2
  38. package/dist/collection-watchers/index.js.map +1 -1
  39. package/dist/feeds/index.cjs +4 -4
  40. package/dist/feeds/index.js +2 -2
  41. package/dist/feeds/ingestTypes.d.ts +15 -40
  42. package/dist/feeds/server/index.cjs +5 -5
  43. package/dist/feeds/server/index.js +3 -3
  44. package/dist/{ingestTypes-C7EheYZX.cjs → ingestTypes-DG8RExWt.cjs} +3 -3
  45. package/dist/ingestTypes-DG8RExWt.cjs.map +1 -0
  46. package/dist/{ingestTypes-CmJeOUJc.js → ingestTypes-V4upKeBx.js} +2 -2
  47. package/dist/ingestTypes-V4upKeBx.js.map +1 -0
  48. package/dist/{server-DKXXFTbw.js → server-BNTR7ySK.js} +531 -196
  49. package/dist/server-BNTR7ySK.js.map +1 -0
  50. package/dist/{server-G6GtOdaW.cjs → server-bsNrf2yW.cjs} +572 -213
  51. package/dist/server-bsNrf2yW.cjs.map +1 -0
  52. package/package.json +1 -1
  53. package/dist/deriveAll-BQ_p5HSB.cjs.map +0 -1
  54. package/dist/deriveAll-CMFXDQ_G.js +0 -506
  55. package/dist/deriveAll-CMFXDQ_G.js.map +0 -1
  56. package/dist/ingestTypes-C7EheYZX.cjs.map +0 -1
  57. package/dist/ingestTypes-CmJeOUJc.js.map +0 -1
  58. package/dist/server-DKXXFTbw.js.map +0 -1
  59. package/dist/server-G6GtOdaW.cjs.map +0 -1
@@ -0,0 +1,1070 @@
1
+ //#region src/collection/core/schema.ts
2
+ /** Declarative retriever kinds a Feed's `ingest.kind` may declare. The host's
3
+ * feeds engine dispatches on these; they live here (with the schema contract)
4
+ * so the schema validator can enforce them. The host re-exports these from
5
+ * `server/workspace/feeds/ingestTypes.ts`. */
6
+ var INGEST_KINDS = [
7
+ "rss",
8
+ "atom",
9
+ "http-json"
10
+ ];
11
+ /** The agent-performed ingest kind. Instead of a declarative fetch, the host
12
+ * dispatches a hidden background chat (origin `system`) in `ingest.role`,
13
+ * seeded with `ingest.template` + a summary of every record, on the
14
+ * `ingest.schedule` cadence; the worker edits records via the collections io
15
+ * layer. Kept separate from {@link INGEST_KINDS} (which the declarative
16
+ * retriever registry keys on) so the schema validator can model `ingest` as a
17
+ * discriminated union without the feeds engine gaining an "agent" retriever. */
18
+ var AGENT_INGEST_KIND = "agent";
19
+ /** Refresh cadences a Feed's `ingest.schedule` may declare. */
20
+ var FEED_SCHEDULES = [
21
+ "hourly",
22
+ "daily",
23
+ "weekly",
24
+ "on-demand"
25
+ ];
26
+ /** derived/embed/backlinks/toggle are host-computed or projected — never
27
+ * written to the record JSON, so required / value checks and edit-draft
28
+ * slots must not apply to them. THE single source for "computed" —
29
+ * lives here (zod-free at runtime) so browser code (`./draft`) and the
30
+ * zod record compiler (`./recordZ`, which re-exports it) share one set
31
+ * instead of drifting copies. */
32
+ var COMPUTED_TYPES = /* @__PURE__ */ new Set([
33
+ "derived",
34
+ "embed",
35
+ "backlinks",
36
+ "toggle"
37
+ ]);
38
+ /** Narrowing guard: true when `every` is the field-driven arm. */
39
+ function isFieldDrivenEvery(every) {
40
+ return "fromField" in every;
41
+ }
42
+ /** Resolve an `embed` field's target record id: the fixed `id`, or the value
43
+ * of the sibling `idField` on this record (empty string when neither applies
44
+ * — the caller renders that as "no record"). Pure + isomorphic so the server
45
+ * projection (`derive.ts`) and the client preview (`useCollectionRendering`)
46
+ * resolve embeds identically. Non-`embed` fields resolve to "no record". */
47
+ function embedTargetId(field, record) {
48
+ if (field.type !== "embed") return "";
49
+ if (field.id) return field.id;
50
+ if (field.idField && record) return String(record[field.idField] ?? "");
51
+ return "";
52
+ }
53
+ //#endregion
54
+ //#region src/collection/core/ids.ts
55
+ var SAFE_SLUG_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9_-]*[a-zA-Z0-9])?$/;
56
+ var SAFE_RECORD_ID_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/;
57
+ /** True when `value` is a well-formed collection slug (alphanumeric /
58
+ * hyphen / underscore, no path separators). The pattern admits no `/`,
59
+ * `\`, or `.`, so a passing value is trivially also a safe basename —
60
+ * validation callers need no `path.basename` round-trip (path-building
61
+ * callers use `../server/paths#safeSlugName`, which adds it). */
62
+ function isSafeSlug(value) {
63
+ return typeof value === "string" && SAFE_SLUG_PATTERN.test(value);
64
+ }
65
+ /** True when `value` is a well-formed record id (slug charset plus interior
66
+ * dots), with any `..` substring rejected explicitly. Validation-only
67
+ * counterpart of `../server/paths#safeRecordId`. */
68
+ function isSafeRecordId(value) {
69
+ if (typeof value !== "string" || !SAFE_RECORD_ID_PATTERN.test(value)) return false;
70
+ return !value.includes("..");
71
+ }
72
+ //#endregion
73
+ //#region src/collection/core/actionVisible.ts
74
+ /** Core matcher:
75
+ * - no `when` ⇒ always true (visible);
76
+ * - otherwise true only when `record[when.field]` is present and its
77
+ * stringified value is one of `when.in`.
78
+ * A missing/undefined/null field is treated as "not a match"
79
+ * (hidden), so a status-gated target never shows on a record that
80
+ * lacks the status. */
81
+ function whenMatches(when, record) {
82
+ if (!when) return true;
83
+ const value = record[when.field];
84
+ if (value === void 0 || value === null) return false;
85
+ return when.in.includes(String(value));
86
+ }
87
+ /** True when the action's button should render against `record`
88
+ * (see whenMatches). */
89
+ function actionVisible(action, record) {
90
+ return whenMatches(action.when, record);
91
+ }
92
+ /** True when the field should render against `record`. A field with
93
+ * no `when` is always shown; otherwise it's shown only when the
94
+ * record matches (e.g. hide a rating field until `visited` is true).
95
+ * Purely presentational — a hidden field's stored value is never
96
+ * altered, so toggling the gate back on restores it. */
97
+ function fieldVisible(field, record) {
98
+ return whenMatches(field.when, record);
99
+ }
100
+ //#endregion
101
+ //#region src/collection/core/backlinks.ts
102
+ /** The SOURCE records whose `via` field stores `recordId` (compared as
103
+ * strings, like every ref deref), with the optional `filter` applied —
104
+ * in the source items' given order. Fail-soft by construction: a `via`
105
+ * key that doesn't exist on the source records simply matches nothing.
106
+ * Callers pass DERIVED source records, so a `filter`/`display` on a
107
+ * derived column works when its formula is SELF-CONTAINED (an invoice
108
+ * `total` = sum over its own line items); a source column that derefs
109
+ * yet another collection stays absent — the same each-record-derives-
110
+ * against-itself rule ref targets follow. */
111
+ function backlinkRows(spec, recordId, sourceItems) {
112
+ if (!recordId) return [];
113
+ return sourceItems.filter((item) => String(item[spec.via] ?? "") === recordId && whenMatches(spec.filter, item));
114
+ }
115
+ /** Project one backlink row to the keys consumers surface: the source
116
+ * collection's primaryKey (rows must stay addressable — it's the link
117
+ * target) plus the declared `display` columns. Keys the row doesn't
118
+ * carry are simply absent, mirroring `projectFields` in getItems. */
119
+ function projectBacklinkRow(row, display, primaryKey) {
120
+ const keys = display.includes(primaryKey) ? display : [primaryKey, ...display];
121
+ return Object.fromEntries(keys.filter((key) => key in row).map((key) => [key, row[key]]));
122
+ }
123
+ //#endregion
124
+ //#region src/collection/core/where.ts
125
+ /** True when `record[field]` is absent (`undefined`/`null`) — the only case
126
+ * where `ne` and every other op disagree on the result. */
127
+ function isMissing(raw) {
128
+ return raw === void 0 || raw === null;
129
+ }
130
+ /** The effective comparison value for `cond`: its literal `value`, or — for
131
+ * a `valueFrom` reference — the target field read out of `recordsById`.
132
+ * `undefined` means UNRESOLVED (no such record, or the field on it is
133
+ * missing); the caller must treat that as "never matches", not as a
134
+ * literal `undefined` value to compare against. */
135
+ function resolveValue(cond, record, recordsById) {
136
+ if (!cond.valueFrom) return cond.value;
137
+ const { record: refRecord, field } = cond.valueFrom;
138
+ const raw = (refRecord === void 0 ? record : recordsById[refRecord])?.[field];
139
+ return isMissing(raw) ? void 0 : String(raw);
140
+ }
141
+ function matchesNumericOp(operator, left, right) {
142
+ if (operator === "gt") return left > right;
143
+ if (operator === "gte") return left >= right;
144
+ if (operator === "lt") return left < right;
145
+ return left <= right;
146
+ }
147
+ /** `Number("")` / `Number(" ")` are `0`, not `NaN`, so treat a blank string
148
+ * as non-numeric explicitly — an empty field must fail a numeric compare,
149
+ * not read as zero. */
150
+ function toNumber(raw) {
151
+ return raw.trim() === "" ? NaN : Number(raw);
152
+ }
153
+ function matchesNumeric(operator, raw, value) {
154
+ if (Array.isArray(value)) return false;
155
+ const left = toNumber(raw);
156
+ const right = toNumber(value);
157
+ if (Number.isNaN(left) || Number.isNaN(right)) return false;
158
+ return matchesNumericOp(operator, left, right);
159
+ }
160
+ /** True when the present string `raw` satisfies `operator` against the
161
+ * resolved `value` (field known to exist — MISSING is handled by the
162
+ * caller before this runs, and an UNRESOLVED `valueFrom` never reaches
163
+ * here either). */
164
+ function matchesPresent(operator, raw, value) {
165
+ switch (operator) {
166
+ case "eq": return raw === String(value);
167
+ case "ne": return raw !== String(value);
168
+ case "in": return Array.isArray(value) && value.includes(raw);
169
+ case "contains": return raw.includes(String(value));
170
+ case "gt":
171
+ case "gte":
172
+ case "lt":
173
+ case "lte": return matchesNumeric(operator, raw, value);
174
+ default: return false;
175
+ }
176
+ }
177
+ /** True when `record` satisfies one condition, given `recordsById` to
178
+ * resolve a `valueFrom` reference. Two independent MISSING cases, checked
179
+ * in order:
180
+ * - `record[cond.field]` absent (`undefined`/`null`) → matches only `ne`
181
+ * (vacuously true — "not equal to X" holds when there's no value at
182
+ * all); every other op is false. Unchanged from the literal-`value`
183
+ * behaviour, regardless of whether `valueFrom` would also resolve.
184
+ * - the resolved comparison value is UNRESOLVED (a `valueFrom` whose
185
+ * target record/field doesn't exist) → false for EVERY op, including
186
+ * `ne` — a broken reference must never spuriously match. */
187
+ function matchesCond(cond, record, recordsById) {
188
+ const raw = record[cond.field];
189
+ if (isMissing(raw)) return cond.op === "ne";
190
+ const value = resolveValue(cond, record, recordsById);
191
+ if (value === void 0) return false;
192
+ return matchesPresent(cond.op, String(raw), value);
193
+ }
194
+ /** True when `record` satisfies every condition in `where` (AND). An empty
195
+ * `where` matches everything. `recordsById` — the source collection's
196
+ * records keyed by primaryKey — resolves any `valueFrom` reference;
197
+ * omitted (default `{}`) for callers with no cross-record lookups, in
198
+ * which case every `valueFrom` condition is UNRESOLVED and so never
199
+ * matches. */
200
+ function matchesWhere(where, record, recordsById = {}) {
201
+ return where.every((cond) => matchesCond(cond, record, recordsById));
202
+ }
203
+ //#endregion
204
+ //#region src/collection/core/dynamicIcon.ts
205
+ /** The record with the greatest `String(record[field])` (localeCompare) —
206
+ * ties keep the first-seen record (stable left-to-right `reduce`). */
207
+ function latestByField(pool, field) {
208
+ return pool.reduce((latest, candidate) => String(candidate[field] ?? "").localeCompare(String(latest[field] ?? "")) > 0 ? candidate : latest);
209
+ }
210
+ /** Reduce `records` to the one record that decides the effective icon, per
211
+ * `source`'s `where` filter + `from` strategy:
212
+ * - pool = `source.where`-filtered records, or every record when unset;
213
+ * - an empty pool resolves to `null` (no source record → fallback);
214
+ * - `from: "first"` / `"when"` → the first pool record (storage order);
215
+ * - `from: "latest"` (default), with `orderBy` given → the pool record
216
+ * whose `String(record[orderBy])` sorts highest;
217
+ * - `from: "latest"`, with no `orderBy` → the last pool record.
218
+ * `recordsById` (the source collection's records keyed by primaryKey)
219
+ * resolves any `valueFrom` reference inside `source.where`; omitted for
220
+ * callers with no cross-record lookups. */
221
+ function selectDynamicRecord(records, source, orderBy, recordsById = {}) {
222
+ const { where } = source;
223
+ const pool = where ? records.filter((record) => matchesWhere(where, record, recordsById)) : records;
224
+ if (pool.length === 0) return null;
225
+ if (source.from === "first" || source.from === "when") return pool[0];
226
+ return orderBy ? latestByField(pool, orderBy) : pool[pool.length - 1];
227
+ }
228
+ /** Map a resolved source record to the effective icon: `spec.fallback`
229
+ * (or the collection's own static `icon`) when there's no record or no
230
+ * rule matches; otherwise the `icon` of the first rule whose `where`
231
+ * matches the record. `recordsById` resolves any `valueFrom` reference
232
+ * inside a rule's `where`, same as `selectDynamicRecord`. */
233
+ function resolveIcon(record, spec, staticIcon, recordsById = {}) {
234
+ const fallback = spec.fallback ?? staticIcon;
235
+ if (!record) return fallback;
236
+ const matched = spec.rules.find((rule) => matchesWhere(rule.where, record, recordsById));
237
+ return matched ? matched.icon : fallback;
238
+ }
239
+ var isDateLikeField = (field) => field.type === "date" || field.type === "datetime";
240
+ /** The first field key (declaration order) whose type is `date` or
241
+ * `datetime` — the default `orderBy` for `from: "latest"` when a
242
+ * `DynamicIconSource` doesn't name one. `undefined` when the schema has
243
+ * no date-like field. */
244
+ function firstDateField(schema) {
245
+ return Object.entries(schema.fields).find(([, field]) => isDateLikeField(field))?.[0];
246
+ }
247
+ //#endregion
248
+ //#region src/collection/core/derivedFormula.ts
249
+ function evaluateDerived(formula, ctx) {
250
+ let tokens;
251
+ try {
252
+ tokens = tokenize(formula);
253
+ } catch {
254
+ return null;
255
+ }
256
+ const parser = new Parser(tokens);
257
+ let ast;
258
+ try {
259
+ ast = parser.parseExpr();
260
+ if (!parser.atEnd()) return null;
261
+ } catch {
262
+ return null;
263
+ }
264
+ const value = evaluate(ast, ctx);
265
+ return Number.isFinite(value) ? value : null;
266
+ }
267
+ var SINGLE_CHAR_PUNCT = /* @__PURE__ */ new Set([
268
+ "(",
269
+ ")",
270
+ "+",
271
+ "-",
272
+ "*",
273
+ "/",
274
+ "."
275
+ ]);
276
+ function consumeWhitespace(cur) {
277
+ const char = cur.input[cur.index];
278
+ if (char === " " || char === " " || char === "\n") {
279
+ cur.index++;
280
+ return true;
281
+ }
282
+ return false;
283
+ }
284
+ function consumeNumber(cur) {
285
+ const char = cur.input[cur.index] ?? "";
286
+ const next = cur.input[cur.index + 1] ?? "";
287
+ if (!isDigit(char) && !(char === "." && isDigit(next))) return null;
288
+ let raw = "";
289
+ while (cur.index < cur.input.length) {
290
+ const here = cur.input[cur.index] ?? "";
291
+ if (!isDigit(here) && here !== ".") break;
292
+ raw += here;
293
+ cur.index++;
294
+ }
295
+ const num = Number(raw);
296
+ if (!Number.isFinite(num)) throw new Error("bad number");
297
+ return {
298
+ kind: "number",
299
+ value: num
300
+ };
301
+ }
302
+ function consumeIdent(cur) {
303
+ if (!isIdentStart(cur.input[cur.index] ?? "")) return null;
304
+ let raw = "";
305
+ while (cur.index < cur.input.length && isIdentChar(cur.input[cur.index] ?? "")) {
306
+ raw += cur.input[cur.index];
307
+ cur.index++;
308
+ }
309
+ return {
310
+ kind: "ident",
311
+ value: raw
312
+ };
313
+ }
314
+ function consumePunct(cur) {
315
+ const char = cur.input[cur.index] ?? "";
316
+ if (char === "[" && cur.input[cur.index + 1] === "]") {
317
+ cur.index += 2;
318
+ return { kind: "[]" };
319
+ }
320
+ if (SINGLE_CHAR_PUNCT.has(char)) {
321
+ cur.index++;
322
+ return { kind: char };
323
+ }
324
+ return null;
325
+ }
326
+ function tokenize(input) {
327
+ const tokens = [];
328
+ const cur = {
329
+ input,
330
+ index: 0
331
+ };
332
+ while (cur.index < input.length) {
333
+ if (consumeWhitespace(cur)) continue;
334
+ const numTok = consumeNumber(cur);
335
+ if (numTok) {
336
+ tokens.push(numTok);
337
+ continue;
338
+ }
339
+ const punctTok = consumePunct(cur);
340
+ if (punctTok) {
341
+ tokens.push(punctTok);
342
+ continue;
343
+ }
344
+ const identTok = consumeIdent(cur);
345
+ if (identTok) {
346
+ tokens.push(identTok);
347
+ continue;
348
+ }
349
+ throw new Error(`unexpected char ${input[cur.index]}`);
350
+ }
351
+ return tokens;
352
+ }
353
+ function isDigit(char) {
354
+ return char >= "0" && char <= "9";
355
+ }
356
+ function isIdentStart(char) {
357
+ return char >= "a" && char <= "z" || char >= "A" && char <= "Z" || char === "_";
358
+ }
359
+ function isIdentChar(char) {
360
+ return isIdentStart(char) || isDigit(char);
361
+ }
362
+ var Parser = class {
363
+ tokens;
364
+ cursor = 0;
365
+ constructor(tokens) {
366
+ this.tokens = tokens;
367
+ }
368
+ atEnd() {
369
+ return this.cursor >= this.tokens.length;
370
+ }
371
+ peek() {
372
+ return this.tokens[this.cursor];
373
+ }
374
+ consume() {
375
+ const tok = this.tokens[this.cursor++];
376
+ if (!tok) throw new Error("unexpected end of input");
377
+ return tok;
378
+ }
379
+ expect(kind) {
380
+ const tok = this.consume();
381
+ if (tok.kind !== kind) throw new Error(`expected ${kind}, got ${tok.kind}`);
382
+ return tok;
383
+ }
384
+ parseExpr() {
385
+ let left = this.parseTerm();
386
+ while (this.peek()?.kind === "+" || this.peek()?.kind === "-") {
387
+ const operator = this.consume().kind;
388
+ const right = this.parseTerm();
389
+ left = {
390
+ kind: "binop",
391
+ operator,
392
+ left,
393
+ right
394
+ };
395
+ }
396
+ return left;
397
+ }
398
+ parseTerm() {
399
+ let left = this.parseFactor();
400
+ while (this.peek()?.kind === "*" || this.peek()?.kind === "/") {
401
+ const operator = this.consume().kind;
402
+ const right = this.parseFactor();
403
+ left = {
404
+ kind: "binop",
405
+ operator,
406
+ left,
407
+ right
408
+ };
409
+ }
410
+ return left;
411
+ }
412
+ parseFactor() {
413
+ const tok = this.peek();
414
+ if (!tok) throw new Error("unexpected end in factor");
415
+ if (tok.kind === "number") {
416
+ this.consume();
417
+ return {
418
+ kind: "num",
419
+ value: tok.value
420
+ };
421
+ }
422
+ if (tok.kind === "(") {
423
+ this.consume();
424
+ const inner = this.parseExpr();
425
+ this.expect(")");
426
+ return inner;
427
+ }
428
+ if (tok.kind === "ident") {
429
+ const name = tok.value ?? "";
430
+ if (name === "sum" && this.tokens[this.cursor + 1]?.kind === "(") {
431
+ this.consume();
432
+ this.expect("(");
433
+ const arg = this.parseSumArg();
434
+ this.expect(")");
435
+ return {
436
+ kind: "sum",
437
+ arg
438
+ };
439
+ }
440
+ this.consume();
441
+ if (this.peek()?.kind === ".") {
442
+ this.consume();
443
+ return {
444
+ kind: "ref",
445
+ field: name,
446
+ col: this.expect("ident").value
447
+ };
448
+ }
449
+ return {
450
+ kind: "ident",
451
+ name
452
+ };
453
+ }
454
+ throw new Error(`unexpected token ${tok.kind} in factor`);
455
+ }
456
+ parseSumArg() {
457
+ const factors = [];
458
+ const operators = [];
459
+ factors.push(this.parseTableCol());
460
+ while (this.peek()?.kind === "*" || this.peek()?.kind === "/") {
461
+ const operator = this.consume().kind;
462
+ operators.push(operator);
463
+ factors.push(this.parseTableCol());
464
+ }
465
+ return {
466
+ factors,
467
+ operators
468
+ };
469
+ }
470
+ parseTableCol() {
471
+ const tableTok = this.expect("ident");
472
+ this.expect("[]");
473
+ this.expect(".");
474
+ const colTok = this.expect("ident");
475
+ return {
476
+ table: tableTok.value,
477
+ col: colTok.value
478
+ };
479
+ }
480
+ };
481
+ function evaluate(node, ctx) {
482
+ if (node.kind === "num") return node.value;
483
+ if (node.kind === "ident") {
484
+ const raw = ctx.record[node.name];
485
+ return toFiniteNumber(raw);
486
+ }
487
+ if (node.kind === "ref") {
488
+ const target = ctx.refs?.[node.field] ?? null;
489
+ if (!target) return NaN;
490
+ return toFiniteNumber(target[node.col]);
491
+ }
492
+ if (node.kind === "binop") {
493
+ const left = evaluate(node.left, ctx);
494
+ const right = evaluate(node.right, ctx);
495
+ return applyBinop(node.operator, left, right);
496
+ }
497
+ if (node.kind === "sum") return evaluateSum(node.arg, ctx);
498
+ throw new Error(`unknown node`);
499
+ }
500
+ function applyBinop(operator, left, right) {
501
+ if (!Number.isFinite(left) || !Number.isFinite(right)) return NaN;
502
+ if (operator === "+") return left + right;
503
+ if (operator === "-") return left - right;
504
+ if (operator === "*") return left * right;
505
+ if (right === 0) return NaN;
506
+ return left / right;
507
+ }
508
+ function evaluateSum(arg, ctx) {
509
+ if (arg.factors.length === 0) return 0;
510
+ const tableName = arg.factors[0].table;
511
+ for (const factor of arg.factors) if (factor.table !== tableName) return NaN;
512
+ const rows = ctx.record[tableName];
513
+ if (!Array.isArray(rows)) return 0;
514
+ let total = 0;
515
+ for (const row of rows) {
516
+ if (!row || typeof row !== "object") continue;
517
+ let product = toFiniteNumber(row[arg.factors[0].col]);
518
+ if (!Number.isFinite(product)) return NaN;
519
+ for (let i = 1; i < arg.factors.length; i++) {
520
+ const value = toFiniteNumber(row[arg.factors[i].col]);
521
+ if (!Number.isFinite(value)) return NaN;
522
+ product = applyBinop(arg.operators[i - 1], product, value);
523
+ }
524
+ total += product;
525
+ }
526
+ return total;
527
+ }
528
+ function toFiniteNumber(value) {
529
+ if (typeof value === "number") return Number.isFinite(value) ? value : NaN;
530
+ if (typeof value === "string" && value.length > 0) {
531
+ const num = Number(value);
532
+ return Number.isFinite(num) ? num : NaN;
533
+ }
534
+ return NaN;
535
+ }
536
+ //#endregion
537
+ //#region src/collection/core/deriveAll.ts
538
+ /** Map each `ref` field's stored slug to its loaded target record (or
539
+ * null when dangling / not loaded), keyed by the LOCAL field name —
540
+ * the shape `evaluateDerived` reads for `<field>.<col>` derefs. */
541
+ function resolveRowRefs(schema, record, refRecords) {
542
+ const refs = {};
543
+ for (const [key, field] of Object.entries(schema.fields)) {
544
+ if (field.type !== "ref" || !field.to) continue;
545
+ const slug = record[key];
546
+ refs[key] = typeof slug === "string" ? refRecords[field.to]?.[slug] ?? null : null;
547
+ }
548
+ return refs;
549
+ }
550
+ /** Evaluate every `derived` field against `base`, saturating so a
551
+ * derived field can read another derived field computed in an earlier
552
+ * pass (`subtotal → tax → total` converges in ≤ field-count passes).
553
+ * Cycles can't loop forever — passes are bounded by the number of
554
+ * derived fields and the loop breaks as soon as a pass changes
555
+ * nothing. Failed formulas stay ABSENT (the UI renders them as
556
+ * em-dash). Returns a copy; `base` is never mutated.
557
+ *
558
+ * Derived keys already present in `base` are stripped before
559
+ * evaluation: computed output is host-truth, never persisted-input
560
+ * fallback. A record JSON can carry a stale (or forged) derived value
561
+ * — raw Write/Edit, legacy data — and without the strip, a failing
562
+ * formula would silently surface that value as if the host computed
563
+ * it. */
564
+ function deriveAll(schema, base, refRecords) {
565
+ const derivedKeys = new Set(Object.keys(schema.fields).filter((key) => schema.fields[key]?.type === "derived"));
566
+ const enriched = Object.fromEntries(Object.entries(base).filter(([key]) => !derivedKeys.has(key)));
567
+ const refs = resolveRowRefs(schema, base, refRecords);
568
+ const maxPasses = Object.values(schema.fields).filter((field) => field.type === "derived").length;
569
+ for (let pass = 0; pass < maxPasses; pass++) {
570
+ let mutated = false;
571
+ for (const [key, field] of Object.entries(schema.fields)) {
572
+ if (field.type !== "derived" || !field.formula) continue;
573
+ const next = evaluateDerived(field.formula, {
574
+ record: enriched,
575
+ refs
576
+ });
577
+ if (next !== null && enriched[key] !== next) {
578
+ enriched[key] = next;
579
+ mutated = true;
580
+ }
581
+ }
582
+ if (!mutated) break;
583
+ }
584
+ return enriched;
585
+ }
586
+ //#endregion
587
+ //#region src/collection/core/calendarGrid.ts
588
+ var MS_PER_DAY = 864e5;
589
+ var ISO_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
590
+ var TWO_DIGIT_RE = /^\d{2}$/;
591
+ var CLOCK_RE = /(\d{1,2}):(\d{2})/g;
592
+ var RANGE_SEP_RE = /[-–—~〜~]/;
593
+ /** Minutes in a full day — the timeline's vertical extent. */
594
+ var MINUTES_PER_DAY = 1440;
595
+ function pad2(value) {
596
+ return String(value).padStart(2, "0");
597
+ }
598
+ /** Canonical `YYYY-MM-DD` string for a civil date. */
599
+ function ymdKey(ymd) {
600
+ return `${String(ymd.year).padStart(4, "0")}-${pad2(ymd.month)}-${pad2(ymd.day)}`;
601
+ }
602
+ /** Strictly parse a `YYYY-MM-DD` string into a civil date, rejecting
603
+ * anything that isn't a real calendar day (e.g. `2026-02-30`, `2026-13-01`).
604
+ * Returns null for non-strings and malformed values so callers can route
605
+ * records with no usable date into the "no date" tray rather than crash. */
606
+ function parseIsoDate(value) {
607
+ if (typeof value !== "string") return null;
608
+ const match = ISO_DATE_RE.exec(value.trim());
609
+ if (!match) return null;
610
+ const year = Number(match[1]);
611
+ const month = Number(match[2]);
612
+ const day = Number(match[3]);
613
+ const probe = new Date(Date.UTC(year, month - 1, day));
614
+ if (probe.getUTCFullYear() !== year || probe.getUTCMonth() !== month - 1 || probe.getUTCDate() !== day) return null;
615
+ return {
616
+ year,
617
+ month,
618
+ day
619
+ };
620
+ }
621
+ /** Minutes-of-day for an `HH:MM` pair, or null when out of range. */
622
+ function clockToMinutes(hours, minutes) {
623
+ if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null;
624
+ return hours * 60 + minutes;
625
+ }
626
+ /** Strictly parse a `YYYY-MM-DDTHH:MM` (optional `:SS`) datetime into its
627
+ * civil date and minutes-of-day. Returns null for anything that isn't a real
628
+ * calendar day or a valid 24h clock. */
629
+ function parseIsoDateTime(value) {
630
+ if (typeof value !== "string") return null;
631
+ const trimmed = value.trim();
632
+ const tIndex = trimmed.indexOf("T");
633
+ if (tIndex === -1) return null;
634
+ const ymd = parseIsoDate(trimmed.slice(0, tIndex));
635
+ if (!ymd) return null;
636
+ const parts = trimmed.slice(tIndex + 1).split(":");
637
+ if (parts.length < 2 || parts.length > 3 || !parts.every((part) => TWO_DIGIT_RE.test(part))) return null;
638
+ const minutes = clockToMinutes(Number(parts[0]), Number(parts[1]));
639
+ if (minutes === null) return null;
640
+ return {
641
+ ymd,
642
+ minutes
643
+ };
644
+ }
645
+ /** Civil date from either a `YYYY-MM-DD` or a `YYYY-MM-DDTHH:MM` value, so the
646
+ * month grid buckets date-only and datetime anchors alike. */
647
+ function dateOf(value) {
648
+ return parseIsoDate(value) ?? parseIsoDateTime(value)?.ymd ?? null;
649
+ }
650
+ /** Minutes-of-day from a datetime value, or null for date-only / invalid. */
651
+ function timeOf(value) {
652
+ return parseIsoDateTime(value)?.minutes ?? null;
653
+ }
654
+ /** Parse a free-form time-string field into start/end minutes-of-day.
655
+ * Handles the common shapes in user data:
656
+ * "14:00-17:00" → { start: 840, end: 1020 } (range → block)
657
+ * "17:00-" → { start: 1020, end: null } (open end → single line)
658
+ * "16:30" → { start: 990, end: null } (point in time → single line)
659
+ * "終日" / "" → null (no clock → all-day)
660
+ * Returns null when no clock token is parseable. */
661
+ function parseTimeRange(value) {
662
+ if (typeof value !== "string") return null;
663
+ const text = value.trim();
664
+ if (!text) return null;
665
+ const tokens = [...text.matchAll(CLOCK_RE)];
666
+ if (tokens.length === 0) return null;
667
+ const minutesOf = (match) => clockToMinutes(Number(match[1]), Number(match[2]));
668
+ if (!RANGE_SEP_RE.test(text)) {
669
+ const startMin = minutesOf(tokens[0]);
670
+ return startMin === null ? null : {
671
+ startMin,
672
+ endMin: null
673
+ };
674
+ }
675
+ const sepIndex = text.search(RANGE_SEP_RE);
676
+ let startMin = null;
677
+ let endMin = null;
678
+ for (const token of tokens) if ((token.index ?? 0) < sepIndex) startMin = minutesOf(token);
679
+ else endMin = minutesOf(token);
680
+ if (startMin === null) return null;
681
+ return {
682
+ startMin,
683
+ endMin
684
+ };
685
+ }
686
+ function ymdToUtcMs(ymd) {
687
+ return Date.UTC(ymd.year, ymd.month - 1, ymd.day);
688
+ }
689
+ function utcMsToYmd(epochMs) {
690
+ const date = new Date(epochMs);
691
+ return {
692
+ year: date.getUTCFullYear(),
693
+ month: date.getUTCMonth() + 1,
694
+ day: date.getUTCDate()
695
+ };
696
+ }
697
+ /** Chronological comparison: negative if `left` precedes `right`, 0 if the
698
+ * same day, positive if after. */
699
+ function compareYmd(left, right) {
700
+ return ymdToUtcMs(left) - ymdToUtcMs(right);
701
+ }
702
+ /** True iff `day` falls within the inclusive span `[span.start, span.end]`. */
703
+ function spanCoversDay(span, day) {
704
+ return compareYmd(span.start, day) <= 0 && compareYmd(day, span.end) <= 0;
705
+ }
706
+ /** Build the 6×7 (42-cell) grid for the given month, including the
707
+ * leading/trailing days of the adjacent months so every week is full.
708
+ * `month` is 1-12. `weekStartsOn` is 0 (Sunday) … 6 (Saturday). */
709
+ function buildMonthGrid(year, month, weekStartsOn = 0) {
710
+ const lead = (new Date(Date.UTC(year, month - 1, 1)).getUTCDay() - weekStartsOn + 7) % 7;
711
+ const startMs = Date.UTC(year, month - 1, 1) - lead * MS_PER_DAY;
712
+ const cells = [];
713
+ for (let i = 0; i < 42; i++) {
714
+ const ymd = utcMsToYmd(startMs + i * MS_PER_DAY);
715
+ cells.push({
716
+ ymd,
717
+ inMonth: ymd.year === year && ymd.month === month,
718
+ key: ymdKey(ymd)
719
+ });
720
+ }
721
+ return cells;
722
+ }
723
+ /** Resolve a record's calendar span from its date/datetime fields. Returns
724
+ * null when the anchor date is missing/invalid (→ the caller's "no date"
725
+ * tray). An end date that is missing, invalid, or earlier than the start
726
+ * collapses to a single-day span — never an inverted range.
727
+ *
728
+ * Times for the day (time-allocation) view come from, in priority order:
729
+ * 1. the clock on a `datetime` anchor/end value, else
730
+ * 2. `timeField` — a separate free-form time-string column (e.g. "14:00-17:00").
731
+ * A record with no resolvable clock has `startMin === endMin === null`. */
732
+ function recordSpan(item, anchorField, endField, timeField) {
733
+ const startRaw = item[anchorField];
734
+ const start = dateOf(startRaw);
735
+ if (!start) return null;
736
+ let end = start;
737
+ let startMin = timeOf(startRaw);
738
+ let endMin = null;
739
+ if (endField) {
740
+ const endRaw = item[endField];
741
+ const parsedEnd = dateOf(endRaw);
742
+ if (parsedEnd && compareYmd(parsedEnd, start) >= 0) {
743
+ end = parsedEnd;
744
+ endMin = timeOf(endRaw);
745
+ }
746
+ }
747
+ if (timeField && startMin === null && endMin === null) {
748
+ const range = parseTimeRange(item[timeField]);
749
+ if (range) ({startMin, endMin} = range);
750
+ }
751
+ return {
752
+ item,
753
+ start,
754
+ end,
755
+ startMin,
756
+ endMin
757
+ };
758
+ }
759
+ /** Split records into those that land on the calendar (with their spans)
760
+ * and those with no usable anchor date (the "no date" tray). Spans are
761
+ * sorted by start day so same-day stacking is stable across renders. */
762
+ function bucketRecords(items, anchorField, endField, timeField) {
763
+ const spans = [];
764
+ const noDate = [];
765
+ for (const item of items) {
766
+ const span = recordSpan(item, anchorField, endField, timeField);
767
+ if (span) spans.push(span);
768
+ else noDate.push(item);
769
+ }
770
+ spans.sort((left, right) => compareYmd(left.start, right.start));
771
+ return {
772
+ spans,
773
+ noDate
774
+ };
775
+ }
776
+ /** Project a record's span onto a single day for the time-allocation view, or
777
+ * null when the span doesn't cover that day. */
778
+ function daySlice(span, day) {
779
+ if (!spanCoversDay(span, day)) return null;
780
+ const hasStart = span.startMin !== null;
781
+ const hasEnd = span.endMin !== null;
782
+ if (!hasStart && !hasEnd) return {
783
+ kind: "allDay",
784
+ startMin: 0,
785
+ endMin: MINUTES_PER_DAY,
786
+ bleedsBefore: false,
787
+ bleedsAfter: false
788
+ };
789
+ const singleDay = compareYmd(span.start, span.end) === 0;
790
+ const isStartDay = compareYmd(day, span.start) === 0;
791
+ const isEndDay = compareYmd(day, span.end) === 0;
792
+ if (singleDay && hasStart && !hasEnd) return {
793
+ kind: "line",
794
+ startMin: span.startMin,
795
+ endMin: span.startMin,
796
+ bleedsBefore: false,
797
+ bleedsAfter: false
798
+ };
799
+ const startMin = isStartDay && hasStart ? span.startMin : 0;
800
+ const endMin = isEndDay && hasEnd ? span.endMin : MINUTES_PER_DAY;
801
+ if (singleDay && endMin <= startMin) return {
802
+ kind: "line",
803
+ startMin,
804
+ endMin: startMin,
805
+ bleedsBefore: false,
806
+ bleedsAfter: false
807
+ };
808
+ return {
809
+ kind: "block",
810
+ startMin,
811
+ endMin,
812
+ bleedsBefore: !isStartDay,
813
+ bleedsAfter: !isEndDay
814
+ };
815
+ }
816
+ function assignLanes(blocks) {
817
+ const order = [...blocks.keys()].sort((left, right) => blocks[left].startMin - blocks[right].startMin || blocks[left].endMin - blocks[right].endMin);
818
+ const result = blocks.map(() => ({
819
+ lane: 0,
820
+ lanes: 1
821
+ }));
822
+ let cluster = [];
823
+ let clusterEnd = Number.NEGATIVE_INFINITY;
824
+ const laneEnds = [];
825
+ const flush = () => {
826
+ for (const index of cluster) result[index].lanes = laneEnds.length;
827
+ cluster = [];
828
+ laneEnds.length = 0;
829
+ clusterEnd = Number.NEGATIVE_INFINITY;
830
+ };
831
+ for (const index of order) {
832
+ const block = blocks[index];
833
+ if (cluster.length > 0 && block.startMin >= clusterEnd) flush();
834
+ let lane = laneEnds.findIndex((end) => end <= block.startMin);
835
+ if (lane === -1) {
836
+ lane = laneEnds.length;
837
+ laneEnds.push(block.endMin);
838
+ } else laneEnds[lane] = block.endMin;
839
+ result[index].lane = lane;
840
+ cluster.push(index);
841
+ clusterEnd = Math.max(clusterEnd, block.endMin);
842
+ }
843
+ flush();
844
+ return result;
845
+ }
846
+ /** Month label key inputs — returns the 1st of the month as a `Date` so the
847
+ * component can feed it to `Intl.DateTimeFormat(locale, …)` for a localized
848
+ * "April 2026" header without this module taking a locale dependency. */
849
+ function monthAnchorDate(year, month) {
850
+ return new Date(Date.UTC(year, month - 1, 1));
851
+ }
852
+ //#endregion
853
+ Object.defineProperty(exports, "AGENT_INGEST_KIND", {
854
+ enumerable: true,
855
+ get: function() {
856
+ return AGENT_INGEST_KIND;
857
+ }
858
+ });
859
+ Object.defineProperty(exports, "COMPUTED_TYPES", {
860
+ enumerable: true,
861
+ get: function() {
862
+ return COMPUTED_TYPES;
863
+ }
864
+ });
865
+ Object.defineProperty(exports, "FEED_SCHEDULES", {
866
+ enumerable: true,
867
+ get: function() {
868
+ return FEED_SCHEDULES;
869
+ }
870
+ });
871
+ Object.defineProperty(exports, "INGEST_KINDS", {
872
+ enumerable: true,
873
+ get: function() {
874
+ return INGEST_KINDS;
875
+ }
876
+ });
877
+ Object.defineProperty(exports, "MINUTES_PER_DAY", {
878
+ enumerable: true,
879
+ get: function() {
880
+ return MINUTES_PER_DAY;
881
+ }
882
+ });
883
+ Object.defineProperty(exports, "SAFE_RECORD_ID_PATTERN", {
884
+ enumerable: true,
885
+ get: function() {
886
+ return SAFE_RECORD_ID_PATTERN;
887
+ }
888
+ });
889
+ Object.defineProperty(exports, "SAFE_SLUG_PATTERN", {
890
+ enumerable: true,
891
+ get: function() {
892
+ return SAFE_SLUG_PATTERN;
893
+ }
894
+ });
895
+ Object.defineProperty(exports, "actionVisible", {
896
+ enumerable: true,
897
+ get: function() {
898
+ return actionVisible;
899
+ }
900
+ });
901
+ Object.defineProperty(exports, "assignLanes", {
902
+ enumerable: true,
903
+ get: function() {
904
+ return assignLanes;
905
+ }
906
+ });
907
+ Object.defineProperty(exports, "backlinkRows", {
908
+ enumerable: true,
909
+ get: function() {
910
+ return backlinkRows;
911
+ }
912
+ });
913
+ Object.defineProperty(exports, "bucketRecords", {
914
+ enumerable: true,
915
+ get: function() {
916
+ return bucketRecords;
917
+ }
918
+ });
919
+ Object.defineProperty(exports, "buildMonthGrid", {
920
+ enumerable: true,
921
+ get: function() {
922
+ return buildMonthGrid;
923
+ }
924
+ });
925
+ Object.defineProperty(exports, "compareYmd", {
926
+ enumerable: true,
927
+ get: function() {
928
+ return compareYmd;
929
+ }
930
+ });
931
+ Object.defineProperty(exports, "dateOf", {
932
+ enumerable: true,
933
+ get: function() {
934
+ return dateOf;
935
+ }
936
+ });
937
+ Object.defineProperty(exports, "daySlice", {
938
+ enumerable: true,
939
+ get: function() {
940
+ return daySlice;
941
+ }
942
+ });
943
+ Object.defineProperty(exports, "deriveAll", {
944
+ enumerable: true,
945
+ get: function() {
946
+ return deriveAll;
947
+ }
948
+ });
949
+ Object.defineProperty(exports, "embedTargetId", {
950
+ enumerable: true,
951
+ get: function() {
952
+ return embedTargetId;
953
+ }
954
+ });
955
+ Object.defineProperty(exports, "evaluateDerived", {
956
+ enumerable: true,
957
+ get: function() {
958
+ return evaluateDerived;
959
+ }
960
+ });
961
+ Object.defineProperty(exports, "fieldVisible", {
962
+ enumerable: true,
963
+ get: function() {
964
+ return fieldVisible;
965
+ }
966
+ });
967
+ Object.defineProperty(exports, "firstDateField", {
968
+ enumerable: true,
969
+ get: function() {
970
+ return firstDateField;
971
+ }
972
+ });
973
+ Object.defineProperty(exports, "isFieldDrivenEvery", {
974
+ enumerable: true,
975
+ get: function() {
976
+ return isFieldDrivenEvery;
977
+ }
978
+ });
979
+ Object.defineProperty(exports, "isSafeRecordId", {
980
+ enumerable: true,
981
+ get: function() {
982
+ return isSafeRecordId;
983
+ }
984
+ });
985
+ Object.defineProperty(exports, "isSafeSlug", {
986
+ enumerable: true,
987
+ get: function() {
988
+ return isSafeSlug;
989
+ }
990
+ });
991
+ Object.defineProperty(exports, "matchesWhere", {
992
+ enumerable: true,
993
+ get: function() {
994
+ return matchesWhere;
995
+ }
996
+ });
997
+ Object.defineProperty(exports, "monthAnchorDate", {
998
+ enumerable: true,
999
+ get: function() {
1000
+ return monthAnchorDate;
1001
+ }
1002
+ });
1003
+ Object.defineProperty(exports, "parseIsoDate", {
1004
+ enumerable: true,
1005
+ get: function() {
1006
+ return parseIsoDate;
1007
+ }
1008
+ });
1009
+ Object.defineProperty(exports, "parseIsoDateTime", {
1010
+ enumerable: true,
1011
+ get: function() {
1012
+ return parseIsoDateTime;
1013
+ }
1014
+ });
1015
+ Object.defineProperty(exports, "parseTimeRange", {
1016
+ enumerable: true,
1017
+ get: function() {
1018
+ return parseTimeRange;
1019
+ }
1020
+ });
1021
+ Object.defineProperty(exports, "projectBacklinkRow", {
1022
+ enumerable: true,
1023
+ get: function() {
1024
+ return projectBacklinkRow;
1025
+ }
1026
+ });
1027
+ Object.defineProperty(exports, "recordSpan", {
1028
+ enumerable: true,
1029
+ get: function() {
1030
+ return recordSpan;
1031
+ }
1032
+ });
1033
+ Object.defineProperty(exports, "resolveIcon", {
1034
+ enumerable: true,
1035
+ get: function() {
1036
+ return resolveIcon;
1037
+ }
1038
+ });
1039
+ Object.defineProperty(exports, "resolveRowRefs", {
1040
+ enumerable: true,
1041
+ get: function() {
1042
+ return resolveRowRefs;
1043
+ }
1044
+ });
1045
+ Object.defineProperty(exports, "selectDynamicRecord", {
1046
+ enumerable: true,
1047
+ get: function() {
1048
+ return selectDynamicRecord;
1049
+ }
1050
+ });
1051
+ Object.defineProperty(exports, "spanCoversDay", {
1052
+ enumerable: true,
1053
+ get: function() {
1054
+ return spanCoversDay;
1055
+ }
1056
+ });
1057
+ Object.defineProperty(exports, "whenMatches", {
1058
+ enumerable: true,
1059
+ get: function() {
1060
+ return whenMatches;
1061
+ }
1062
+ });
1063
+ Object.defineProperty(exports, "ymdKey", {
1064
+ enumerable: true,
1065
+ get: function() {
1066
+ return ymdKey;
1067
+ }
1068
+ });
1069
+
1070
+ //# sourceMappingURL=calendarGrid-gN4sei7s.cjs.map