things-api 0.12.0 → 0.14.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.
- package/README.md +2 -2
- package/dist/cli/canonical-ref.d.ts +21 -0
- package/dist/cli/canonical-ref.js +23 -0
- package/dist/cli/canonical-ref.js.map +1 -0
- package/dist/cli/commands/area.d.ts +5 -5
- package/dist/cli/commands/area.js +39 -18
- package/dist/cli/commands/area.js.map +1 -1
- package/dist/cli/commands/install-skill.d.ts +2 -2
- package/dist/cli/commands/install-skill.js +29 -0
- package/dist/cli/commands/install-skill.js.map +1 -1
- package/dist/cli/commands/mcp.js +12 -1
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/commands/project.js +74 -22
- package/dist/cli/commands/project.js.map +1 -1
- package/dist/cli/commands/reads.d.ts +5 -1
- package/dist/cli/commands/reads.js +31 -15
- package/dist/cli/commands/reads.js.map +1 -1
- package/dist/cli/commands/setup.js +11 -4
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/commands/show.js +51 -32
- package/dist/cli/commands/show.js.map +1 -1
- package/dist/cli/commands/todo.js +2 -2
- package/dist/cli/commands/todo.js.map +1 -1
- package/dist/cli/commands/writes.js +138 -67
- package/dist/cli/commands/writes.js.map +1 -1
- package/dist/cli/did-you-mean.js +19 -5
- package/dist/cli/did-you-mean.js.map +1 -1
- package/dist/cli/dry-run.d.ts +55 -0
- package/dist/cli/dry-run.js +92 -0
- package/dist/cli/dry-run.js.map +1 -0
- package/dist/cli/glyphs.d.ts +9 -2
- package/dist/cli/glyphs.js +11 -5
- package/dist/cli/glyphs.js.map +1 -1
- package/dist/cli/help.js +7 -6
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/main.js +7 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/read-driver.d.ts +47 -7
- package/dist/cli/read-driver.js +43 -21
- package/dist/cli/read-driver.js.map +1 -1
- package/dist/cli/ref-render.d.ts +14 -0
- package/dist/cli/ref-render.js +9 -0
- package/dist/cli/ref-render.js.map +1 -0
- package/dist/cli/render.d.ts +39 -25
- package/dist/cli/render.js +102 -62
- package/dist/cli/render.js.map +1 -1
- package/dist/cli/resolve-invocation.d.ts +5 -3
- package/dist/cli/resolve-invocation.js +13 -4
- package/dist/cli/resolve-invocation.js.map +1 -1
- package/dist/client.d.ts +100 -32
- package/dist/client.js +63 -19
- package/dist/client.js.map +1 -1
- package/dist/contracts.d.ts +34 -33
- package/dist/contracts.js +1 -1
- package/dist/contracts.js.map +1 -1
- package/dist/db/baselines/db-v26.js +4 -1
- package/dist/db/baselines/db-v26.js.map +1 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.js +9 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +192 -139
- package/dist/mcp/server.js.map +1 -1
- package/dist/model/dates.d.ts +26 -0
- package/dist/model/dates.js +46 -4
- package/dist/model/dates.js.map +1 -1
- package/dist/model/entities.d.ts +11 -1
- package/dist/model/entities.js.map +1 -1
- package/dist/model/mappers.js +3 -0
- package/dist/model/mappers.js.map +1 -1
- package/dist/model/serialize.js +15 -3
- package/dist/model/serialize.js.map +1 -1
- package/dist/read/area-filter.d.ts +2 -2
- package/dist/read/area-filter.js +3 -3
- package/dist/read/area-filter.js.map +1 -1
- package/dist/read/project-view.d.ts +76 -1
- package/dist/read/project-view.js +80 -9
- package/dist/read/project-view.js.map +1 -1
- package/dist/read/queries.d.ts +71 -2
- package/dist/read/queries.js +199 -10
- package/dist/read/queries.js.map +1 -1
- package/dist/read/scope.d.ts +2 -2
- package/dist/read/scope.js +3 -3
- package/dist/read/scope.js.map +1 -1
- package/dist/read/shape.d.ts +87 -9
- package/dist/read/shape.js +483 -124
- package/dist/read/shape.js.map +1 -1
- package/dist/read/show-target.js +52 -33
- package/dist/read/show-target.js.map +1 -1
- package/dist/read/truncation.d.ts +73 -7
- package/dist/read/truncation.js +62 -29
- package/dist/read/truncation.js.map +1 -1
- package/dist/read/views.d.ts +20 -7
- package/dist/read/views.js +7 -7
- package/dist/read/views.js.map +1 -1
- package/dist/surface-copy.d.ts +13 -0
- package/dist/surface-copy.js +13 -0
- package/dist/surface-copy.js.map +1 -1
- package/dist/write/batch.js +0 -1
- package/dist/write/batch.js.map +1 -1
- package/dist/write/commands.d.ts +25 -1
- package/dist/write/commands.js +259 -122
- package/dist/write/commands.js.map +1 -1
- package/dist/write/guards.js +27 -11
- package/dist/write/guards.js.map +1 -1
- package/dist/write/move.d.ts +34 -9
- package/dist/write/move.js +730 -78
- package/dist/write/move.js.map +1 -1
- package/dist/write/operations.d.ts +50 -20
- package/dist/write/operations.js +2 -2
- package/dist/write/operations.js.map +1 -1
- package/dist/write/pipeline.js +25 -4
- package/dist/write/pipeline.js.map +1 -1
- package/dist/write/pre-state.d.ts +58 -28
- package/dist/write/pre-state.js +247 -49
- package/dist/write/pre-state.js.map +1 -1
- package/dist/write/reorder.js +266 -32
- package/dist/write/reorder.js.map +1 -1
- package/dist/write/resolution-timestamps.d.ts +34 -0
- package/dist/write/resolution-timestamps.js +286 -0
- package/dist/write/resolution-timestamps.js.map +1 -0
- package/dist/write/reversibility.js +5 -5
- package/dist/write/reversibility.js.map +1 -1
- package/dist/write/scope-guard.js +0 -5
- package/dist/write/scope-guard.js.map +1 -1
- package/dist/write/undo.d.ts +0 -6
- package/dist/write/undo.js +44 -4
- package/dist/write/undo.js.map +1 -1
- package/dist/write/vectors/applescript.js +14 -4
- package/dist/write/vectors/applescript.js.map +1 -1
- package/dist/write/vectors/url-scheme.js +0 -8
- package/dist/write/vectors/url-scheme.js.map +1 -1
- package/dist/write/verify/delta.d.ts +16 -2
- package/dist/write/verify/delta.js +21 -2
- package/dist/write/verify/delta.js.map +1 -1
- package/package.json +1 -1
- package/schema/envelope.schema.json +16 -78
- package/skills/things-cli/SKILL.md +10 -6
- package/skills/things-cli/references/contracts.md +3 -2
- package/skills/things-cli/references/errors.md +9 -1
- package/skills/things-cli/references/model.md +9 -7
- package/skills/things-cli/references/ordering.md +36 -10
package/dist/read/shape.js
CHANGED
|
@@ -56,9 +56,10 @@
|
|
|
56
56
|
* - a template's `repeating.nextOccurrence` is GONE from the wire — `when` replaces
|
|
57
57
|
* it (same fact, one word); the resting-templates `{date: null}` group is
|
|
58
58
|
* unchanged (an unprojected template has no `when`).
|
|
59
|
-
* - `when` is DROPPED inside the `today` view's
|
|
60
|
-
* today
|
|
61
|
-
* whose `when` equals the group's date (the group states
|
|
59
|
+
* - `when` is DROPPED inside the `today` view's two `children` bucket records (the
|
|
60
|
+
* bucket key `today`/`evening` states it) and inside any card/heading `upcoming`
|
|
61
|
+
* DATE-GROUP for a member whose `when` equals the group's date (the group states
|
|
62
|
+
* it). KEPT everywhere
|
|
62
63
|
* else it is present — including the flat `upcoming`/`anytime`/`inbox`/`someday`
|
|
63
64
|
* catalogues, search, changes (a deadline-pulled row reads `when: "today"` in the
|
|
64
65
|
* mixed search/changes surfaces, informatively; note R13 re-files it to stage
|
|
@@ -77,9 +78,9 @@
|
|
|
77
78
|
* side effect our read cannot perform (watchers beware).
|
|
78
79
|
* - **stage `anytime` for a deadline pull** — a due-deadline pull re-files an undated
|
|
79
80
|
* Inbox/Someday row into Anytime (deriveStage step 2½, L-A). So EVERY Today member
|
|
80
|
-
* derives stage `anytime`, and the `today` view's
|
|
81
|
-
* `stage` is DROPPED there (TODAY_SECTION_DROP), alongside the
|
|
82
|
-
* `when`. The flat someday/inbox views EXCLUDE pulled rows and the anytime view
|
|
81
|
+
* derives stage `anytime`, and the `today` view's two `children` buckets become
|
|
82
|
+
* stage-PURE → `stage` is DROPPED there (TODAY_SECTION_DROP), alongside the
|
|
83
|
+
* key-implied `when`. The flat someday/inbox views EXCLUDE pulled rows and the anytime view
|
|
83
84
|
* INCLUDES them (src/read/views.ts + predicates.ts DEADLINE_PULLED) — GUI fidelity.
|
|
84
85
|
*
|
|
85
86
|
* ## Universal item-DTO reshapes (R9 — EVERY tier, EVERY read kind incl. detail)
|
|
@@ -113,6 +114,46 @@
|
|
|
113
114
|
* universal reshapes, and R10.
|
|
114
115
|
*/
|
|
115
116
|
import { deriveStage, deriveWhen, whenIsProvisional } from "./stage.js";
|
|
117
|
+
/** The DB-less default: assume every title round-trips (bare title, no uuid sibling). */
|
|
118
|
+
const ALWAYS_ROUND_TRIPS = { roundTrips: () => true };
|
|
119
|
+
/** The uuid of a `{uuid,title}` container Ref, or undefined for a non-object / string. */
|
|
120
|
+
function refUuid(v) {
|
|
121
|
+
if (v !== null && typeof v === "object" && typeof v["uuid"] === "string") {
|
|
122
|
+
return v["uuid"];
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Whether a still-unflattened container Ref carries the repeating-TEMPLATE mark.
|
|
128
|
+
* Only project/container refs ever set it (area/heading refs never do — see
|
|
129
|
+
* entities.Ref), so this reads true only for a template PROJECT container.
|
|
130
|
+
*/
|
|
131
|
+
function refIsTemplate(v) {
|
|
132
|
+
return v !== null && typeof v === "object" && v["isRepeatingTemplate"] === true;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Flatten ONE container ref `o[key]` from a `{uuid,title}` object to its bare
|
|
136
|
+
* TITLE string, adding a flat sibling `o[uuidKey]` = the full uuid ONLY when the
|
|
137
|
+
* round-trip law demands it: `forceUuid` (the FULL/detail tier — uuid siblings
|
|
138
|
+
* unconditional) OR the bare title does not resolve back to this exact entity
|
|
139
|
+
* (`!promoter.roundTrips`). A null/absent ref, or one already flattened to a
|
|
140
|
+
* string, is left untouched. The container's `isRepeatingTemplate` marker (a
|
|
141
|
+
* TTY-render disambiguator on the internal entity) does not survive the flatten
|
|
142
|
+
* — the human render reads the unshaped entity. {@link shapeItem} re-emits that
|
|
143
|
+
* fact for the JSON container PROJECT as the flat presence-keyed sibling
|
|
144
|
+
* `projectIsTemplate: true` BEFORE this flatten runs.
|
|
145
|
+
*/
|
|
146
|
+
function flattenRef(o, key, uuidKey, kind, forceUuid, promoter, projectUuid) {
|
|
147
|
+
const ref = o[key];
|
|
148
|
+
if (ref === null || typeof ref !== "object")
|
|
149
|
+
return; // absent, or already a bare string
|
|
150
|
+
const r = ref;
|
|
151
|
+
const uuid = typeof r["uuid"] === "string" ? r["uuid"] : "";
|
|
152
|
+
const title = typeof r["title"] === "string" ? r["title"] : "";
|
|
153
|
+
o[key] = title;
|
|
154
|
+
if (forceUuid || !promoter.roundTrips(kind, title, uuid, projectUuid))
|
|
155
|
+
o[uuidKey] = uuid;
|
|
156
|
+
}
|
|
116
157
|
/**
|
|
117
158
|
* Fold the flat `tags` / `inheritedTags` arrays of `{title}` objects into plain
|
|
118
159
|
* arrays of tag NAMES (universal across tiers and kinds). Tag uuids were never
|
|
@@ -260,7 +301,7 @@ function whenOf(s, stage) {
|
|
|
260
301
|
* (`changeKind` on a changes row, `match` on a search hit) pass through
|
|
261
302
|
* untouched. Non-task values (areas, tags, refs, headings) are returned as-is.
|
|
262
303
|
*/
|
|
263
|
-
function shapeItem(src, drop, compact) {
|
|
304
|
+
function shapeItem(src, drop, compact, promoter) {
|
|
264
305
|
if (src === null || typeof src !== "object")
|
|
265
306
|
return src;
|
|
266
307
|
const s = src;
|
|
@@ -318,6 +359,12 @@ function shapeItem(src, drop, compact) {
|
|
|
318
359
|
// R13 — the provisional banner marker (never dropped; presence-keyed).
|
|
319
360
|
if (provisional)
|
|
320
361
|
o["provisional"] = true;
|
|
362
|
+
// The owning project's uuid scopes the heading round-trip (headings resolve
|
|
363
|
+
// within their project). Captured BEFORE the R6 project-drop so a project-view
|
|
364
|
+
// LOGBOOK row — whose `project` is dropped as redundant, yet which KEEPS its
|
|
365
|
+
// heading ref (drop.keepHeading) — can still promote its `headingUuid` in the
|
|
366
|
+
// project's scope.
|
|
367
|
+
const projectUuid = refUuid(o["project"]);
|
|
321
368
|
// R6 — drop redundant ancestry (both tiers).
|
|
322
369
|
if (drop.project === true)
|
|
323
370
|
delete o["project"];
|
|
@@ -325,6 +372,30 @@ function shapeItem(src, drop, compact) {
|
|
|
325
372
|
delete o["area"];
|
|
326
373
|
if (drop.heading === true)
|
|
327
374
|
delete o["heading"];
|
|
375
|
+
// Absent `type` = to-do — omit it on to-do rows (project/heading keep theirs).
|
|
376
|
+
if (o["type"] === "to-do")
|
|
377
|
+
delete o["type"];
|
|
378
|
+
// Flatten the surviving container refs to bare TITLE strings, adding a flat
|
|
379
|
+
// `*Uuid` sibling per the round-trip law (FULL tier: unconditional; compact:
|
|
380
|
+
// only when the title would not resolve back to this entity). The `heading`
|
|
381
|
+
// ref is compact-dropped below (except drop.keepHeading), so it is flattened
|
|
382
|
+
// on the FULL tier OR when a logbook row explicitly keeps it.
|
|
383
|
+
const forceUuid = !compact;
|
|
384
|
+
// The container PROJECT's repeating-TEMPLATE fact — the JSON twin of the TTY ↻
|
|
385
|
+
// glyph (src/cli/render.ts). flattenRef discards the internal ref's
|
|
386
|
+
// `isRepeatingTemplate` marker, so re-emit it here as a flat presence-keyed
|
|
387
|
+
// sibling of the `project` ref (never `false`), riding wherever `project`
|
|
388
|
+
// rides. A heading-nested row already merged its owning project into `project`
|
|
389
|
+
// above, so direct AND headed template children mark; the R6 project-drop
|
|
390
|
+
// above already removed `project` where the view implies it, so a project-view
|
|
391
|
+
// child carries no orphaned marker. Both tiers — it is a correctness signal,
|
|
392
|
+
// not detail. Only project refs ever carry the flag (area/heading never do).
|
|
393
|
+
if (refIsTemplate(o["project"]))
|
|
394
|
+
o["projectIsTemplate"] = true;
|
|
395
|
+
flattenRef(o, "project", "projectUuid", "project", forceUuid, promoter);
|
|
396
|
+
flattenRef(o, "area", "areaUuid", "area", forceUuid, promoter);
|
|
397
|
+
if (!compact || drop.keepHeading === true)
|
|
398
|
+
flattenRef(o, "heading", "headingUuid", "heading", forceUuid, promoter, projectUuid);
|
|
328
399
|
// R12 — FULL/DETAIL keep the raw `startDate` beside `when` as the SUBSTRATE
|
|
329
400
|
// (`startDate` = what is stored, `when` = where it sits). COMPACT drops it below
|
|
330
401
|
// (the position `when` carries is what a list needs).
|
|
@@ -341,20 +412,22 @@ function shapeItem(src, drop, compact) {
|
|
|
341
412
|
if (notes !== "")
|
|
342
413
|
o["hasNotes"] = true;
|
|
343
414
|
// The heading ref is compact-dropped everywhere (the GUI shows the project,
|
|
344
|
-
// never the heading, outside a project view). Full tier / detail keep it
|
|
345
|
-
|
|
415
|
+
// never the heading, outside a project view). Full tier / detail keep it; a
|
|
416
|
+
// project-view logbook row (drop.keepHeading) keeps it too — flattened above.
|
|
417
|
+
if (drop.keepHeading !== true)
|
|
418
|
+
delete o["heading"];
|
|
346
419
|
return o;
|
|
347
420
|
}
|
|
348
421
|
/** Map a plain array of items with the item shaper. */
|
|
349
|
-
function shapeList(items, drop, compact) {
|
|
422
|
+
function shapeList(items, drop, compact, promoter) {
|
|
350
423
|
if (!Array.isArray(items))
|
|
351
424
|
return items;
|
|
352
|
-
return items.map((i) => shapeItem(i, drop, compact));
|
|
425
|
+
return items.map((i) => shapeItem(i, drop, compact, promoter));
|
|
353
426
|
}
|
|
354
427
|
/** Copy `base` and overwrite `items` with the shaped list (avoids spread-in-map). */
|
|
355
|
-
function withShapedItems(base, drop, compact) {
|
|
428
|
+
function withShapedItems(base, drop, compact, promoter) {
|
|
356
429
|
const out = { ...base };
|
|
357
|
-
out["items"] = shapeList(base["items"], drop, compact);
|
|
430
|
+
out["items"] = shapeList(base["items"], drop, compact, promoter);
|
|
358
431
|
return out;
|
|
359
432
|
}
|
|
360
433
|
/**
|
|
@@ -372,13 +445,13 @@ function withShapedItems(base, drop, compact) {
|
|
|
372
445
|
* then run through {@link shapeItem} with the section drop (ancestry + `stage`,
|
|
373
446
|
* since the bucket states it).
|
|
374
447
|
*/
|
|
375
|
-
function rebucketChildren(children, drop, compact) {
|
|
448
|
+
function rebucketChildren(children, drop, compact, promoter) {
|
|
376
449
|
const anytime = [];
|
|
377
450
|
const someday = [];
|
|
378
451
|
const datedByKey = new Map();
|
|
379
452
|
const datedOrder = [];
|
|
380
453
|
const restingTemplates = [];
|
|
381
|
-
const shape = (c) => shapeItem(c, drop, compact);
|
|
454
|
+
const shape = (c) => shapeItem(c, drop, compact, promoter);
|
|
382
455
|
for (const raw of children) {
|
|
383
456
|
if (raw === null || typeof raw !== "object")
|
|
384
457
|
continue;
|
|
@@ -441,9 +514,22 @@ function flattenGroups(groups) {
|
|
|
441
514
|
}
|
|
442
515
|
/** Coerce an unknown value to an array (empty when absent). */
|
|
443
516
|
const asArray = (v) => (Array.isArray(v) ? v : []);
|
|
444
|
-
/**
|
|
445
|
-
|
|
446
|
-
|
|
517
|
+
/**
|
|
518
|
+
* The R6 ref drop for the un-headed BODY's four `children` bucket records (v2):
|
|
519
|
+
* every body child drops project/area (the card states them), the bucket-implied
|
|
520
|
+
* stage (each of `anytime`/`upcoming`/`someday`/`logbook` is stage-pure), and the
|
|
521
|
+
* heading ref — a body child is by construction un-headed (its `heading` is null),
|
|
522
|
+
* and a project view surfaces no bare `heading: null` (drop it explicitly rather
|
|
523
|
+
* than leaning on omit-empty).
|
|
524
|
+
*/
|
|
525
|
+
const PROJECT_CHILD_DROP = { project: true, area: true, heading: true, stage: true };
|
|
526
|
+
/**
|
|
527
|
+
* The R6 ref drop for a HEADING's four `children` bucket records (v2): a headed
|
|
528
|
+
* child drops project/area (the card states them), the heading ref (its position
|
|
529
|
+
* UNDER `headings[].children` states membership — structural, #362 / task item 6),
|
|
530
|
+
* and the bucket-implied stage. Applied uniformly to the heading's live buckets
|
|
531
|
+
* AND its `logbook` — the logbook is stage-pure (all logged), so stage drops too.
|
|
532
|
+
*/
|
|
447
533
|
const HEADING_MEMBER_DROP = { project: true, area: true, heading: true, stage: true };
|
|
448
534
|
/** Area-view child-item buckets drop their area (the card states it) + the bucket-implied stage. */
|
|
449
535
|
const AREA_CHILD_DROP = { area: true, stage: true };
|
|
@@ -465,101 +551,327 @@ const SOMEDAY_SECTION_DROP = { area: true, stage: true };
|
|
|
465
551
|
/** The card NODE / detail / mixed lists: keep every ref, `stage`, and `when`. */
|
|
466
552
|
const NO_DROP = {};
|
|
467
553
|
/**
|
|
468
|
-
* The today view's
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
* in test/unit/stage.test.ts).
|
|
475
|
-
*
|
|
554
|
+
* The today view's two `children` bucket records: drop the key-implied `when`
|
|
555
|
+
* (R12 — the `today`/`evening` bucket key states it) AND the bucket-implied
|
|
556
|
+
* `stage` (R13). Every Today member now derives stage `anytime` by construction —
|
|
557
|
+
* an ARRIVED `startDate` (step 5) or a DEADLINE PULL (step 2½) both derive
|
|
558
|
+
* `anytime`, and there are no future-dated or undated-someday Today members — so
|
|
559
|
+
* both buckets are provably stage-PURE `anytime` and the field is redundant there
|
|
560
|
+
* (verified strict by the today purity property test in test/unit/stage.test.ts).
|
|
561
|
+
* `provisional` is NOT a drop — the banner is not a bucket, so nothing implies it.
|
|
476
562
|
*/
|
|
477
563
|
const TODAY_SECTION_DROP = { when: true, stage: true };
|
|
478
|
-
/**
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
564
|
+
/**
|
|
565
|
+
* Shape a heading GROUP node (the `headings[].heading` / `loggedHeadings[].heading`
|
|
566
|
+
* keyed sub-object). The type is triply implied by position, and a heading has no
|
|
567
|
+
* open/canceled/completed vocabulary the reader needs — so:
|
|
568
|
+
* - DROP `type` (positional: this slot is always a heading; the "absent type =
|
|
569
|
+
* to-do" convention is scoped to ROWS/candidates, never this keyed sub-object);
|
|
570
|
+
* - DROP `project` (the card states it);
|
|
571
|
+
* - REPLACE `status` with the presence-keyed `archived` (the stopDate, an ISO
|
|
572
|
+
* date-time following the `stopped`/logged-row convention) — emitted ONLY when
|
|
573
|
+
* the heading is archived (status "completed"), OMITTED when open. Region
|
|
574
|
+
* membership (live `headings` vs the logged region) expresses sweep state; the
|
|
575
|
+
* node carries only whether-and-when it was archived.
|
|
576
|
+
*/
|
|
577
|
+
function shapeHeadingNode(src) {
|
|
578
|
+
if (src === null || typeof src !== "object")
|
|
579
|
+
return src;
|
|
580
|
+
const h = { ...src };
|
|
581
|
+
delete h["project"];
|
|
582
|
+
delete h["type"];
|
|
583
|
+
const isArchived = h["status"] !== undefined && h["status"] !== "open";
|
|
584
|
+
const stopped = h["stopped"];
|
|
585
|
+
delete h["status"];
|
|
586
|
+
delete h["stopped"];
|
|
587
|
+
// Presence-keyed `archived` — the ISO archive timestamp (past-participle twin of
|
|
588
|
+
// `stopped`/`created`/`modified`), full-datetime serialization like `stopped`.
|
|
589
|
+
if (isArchived && stopped != null)
|
|
590
|
+
h["archived"] = stopped;
|
|
591
|
+
return h;
|
|
592
|
+
}
|
|
593
|
+
/** A bucket record `{items, total?}` (v2 R1): `total` present IFF the bucket was capped. */
|
|
594
|
+
export function bucketRecord(items, total) {
|
|
595
|
+
return total !== undefined && items.length < total ? { items, total } : { items };
|
|
596
|
+
}
|
|
597
|
+
/** The `stopped` epoch of an internal entity (a Date pre-shaping), or 0 — for logbook DESC ordering. */
|
|
598
|
+
function stoppedMs(o) {
|
|
599
|
+
const s = o["stopped"];
|
|
600
|
+
return s instanceof Date ? s.getTime() : 0;
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Build ONE container's four v2 `children` bucket records (PR 2) from its flat
|
|
604
|
+
* child set (live AND logged alike). Every child is routed by DERIVED STAGE, so
|
|
605
|
+
* one entity lands in exactly one place (R5/#V12):
|
|
606
|
+
* - `logbook: {items, total?}` — the swept/resolved children (`logged` flag),
|
|
607
|
+
* most-recently-completed first (`stopped` DESC — the certified HEADARC3/logbook
|
|
608
|
+
* ordering), stage-pure so `stage` drops;
|
|
609
|
+
* - `anytime` / `someday: {items, total?}` — stage-pure records;
|
|
610
|
+
* - `upcoming: [{when, items, total?} …]` — the day-block ARRAY (R3): dated blocks
|
|
611
|
+
* chronological, then a single trailing `{when: null, items}` resting block for
|
|
612
|
+
* date-less recurring templates (#V8). An open child stranded under an archived
|
|
613
|
+
* heading (HEADARC2-C anomaly) is NOT logged, so it rides `anytime` here — its
|
|
614
|
+
* presence in a live bucket under an `archived` heading node is self-evident.
|
|
615
|
+
* `drop` carries the container's ancestry drops (body vs heading); the day-block
|
|
616
|
+
* `when` renames the internal date group's `date` (rebucketChildren) — `null` for
|
|
617
|
+
* the resting block. No bucket is capped in the project view today, so every
|
|
618
|
+
* `total` is absent (R1: an untruncated bucket never restates its length); the
|
|
619
|
+
* `total?` argument keeps the record + day-block shape ready for PR 5's sweep and
|
|
620
|
+
* is exercised by the unit tests.
|
|
621
|
+
*/
|
|
622
|
+
function shapeContainerChildren(children, drop, compact, promoter) {
|
|
623
|
+
const live = [];
|
|
624
|
+
const logged = [];
|
|
625
|
+
for (const c of asArray(children)) {
|
|
626
|
+
if (c !== null && typeof c === "object" && c["logged"] === true)
|
|
627
|
+
logged.push(c);
|
|
628
|
+
else
|
|
629
|
+
live.push(c);
|
|
630
|
+
}
|
|
631
|
+
const { anytime, upcoming, someday } = rebucketChildren(live, drop, compact, promoter);
|
|
632
|
+
const loggedSorted = logged.toSorted((a, b) => stoppedMs(b) - stoppedMs(a));
|
|
633
|
+
const logbook = shapeList(loggedSorted, drop, compact, promoter);
|
|
634
|
+
return {
|
|
635
|
+
anytime: bucketRecord(anytime),
|
|
636
|
+
// The day-block ARRAY: `date` → `when` (R3); `null` is the resting block (#V8).
|
|
637
|
+
upcoming: upcoming.map((g) => ({ when: g.date, items: g.items })),
|
|
638
|
+
someday: bucketRecord(someday),
|
|
639
|
+
logbook: bucketRecord(logbook),
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Shape a project view into the read-shape v2 wire (PR 2):
|
|
644
|
+
* `{ project, children, headings[] }` — NOTHING else at this level. `children` is
|
|
645
|
+
* the un-headed BODY's four stage-keyed bucket records; `headings[]` is EVERY
|
|
646
|
+
* heading (index order, all lifecycle classes — R5) as `{uuid, title, archived?,
|
|
647
|
+
* children}` with the SAME recursive `children` shape. The per-container `logbook`
|
|
648
|
+
* lives inside each `children` (R6, no root logbook); the v1-era `logbookHeadings`
|
|
649
|
+
* and BOTH advisory keys (`openChildrenWhileResolved` /
|
|
650
|
+
* `openChildrenUnderArchivedHeading`) are DELETED (#V12) — anomalous open children
|
|
651
|
+
* seat in the normal recursive buckets, the heading's `archived` mark making the
|
|
652
|
+
* anomaly self-evident. The card node keeps everything (children derive their
|
|
653
|
+
* container from it). `out` is built fresh, so no render-only field leaks.
|
|
654
|
+
*/
|
|
655
|
+
function shapeProjectView(view, compact, promoter) {
|
|
656
|
+
const headingContainers = asArray(view["headingContainers"]).map((c) => {
|
|
657
|
+
const grp = (c ?? {});
|
|
658
|
+
// The heading NODE (`{uuid, title, archived?}`) gains the recursive `children`
|
|
659
|
+
// (last key, so it reads after the identity). Object.assign mutates the fresh
|
|
660
|
+
// node copy shapeHeadingNode already returns — no spread-in-map.
|
|
661
|
+
const node = shapeHeadingNode(grp["heading"]);
|
|
662
|
+
return Object.assign(node, {
|
|
663
|
+
children: shapeContainerChildren(grp["children"], HEADING_MEMBER_DROP, compact, promoter),
|
|
664
|
+
});
|
|
665
|
+
});
|
|
666
|
+
return {
|
|
667
|
+
// The card NODE keeps everything but is still an item DTO (universal + R10 reshapes).
|
|
668
|
+
project: shapeItem(view["project"], NO_DROP, false, promoter),
|
|
669
|
+
children: shapeContainerChildren(view["bodyChildren"], PROJECT_CHILD_DROP, compact, promoter),
|
|
670
|
+
headings: headingContainers,
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Build an area's THREE v2 `children` bucket records (PR 3) from its flat direct
|
|
675
|
+
* to-do set (live only — an area has NO logged-children region, so no `logbook`
|
|
676
|
+
* key; the area logbook is the bounded query `things logbook --area <ref>`, #346).
|
|
677
|
+
* The same stage-derived bucketing as {@link shapeContainerChildren} minus the
|
|
678
|
+
* logbook split: `anytime`/`someday` are `{items, total?}` records, `upcoming` is
|
|
679
|
+
* the day-block ARRAY (R3, `date` → `when`) with the trailing `{when: null, items}`
|
|
680
|
+
* resting block for date-less recurring templates (#V8). Inline `total` is stamped
|
|
681
|
+
* downstream by {@link withAreaBucketTotals} (only `anytime` can be capped — the
|
|
682
|
+
* `--area-limit` scope; the scheduled/someday direct to-dos always survive).
|
|
683
|
+
*/
|
|
684
|
+
function shapeAreaChildren(members, drop, compact, promoter) {
|
|
685
|
+
const { anytime, upcoming, someday } = rebucketChildren(members, drop, compact, promoter);
|
|
686
|
+
return {
|
|
687
|
+
anytime: bucketRecord(anytime),
|
|
688
|
+
// The day-block ARRAY: `date` → `when` (R3); `null` is the resting block (#V8).
|
|
689
|
+
upcoming: upcoming.map((g) => ({ when: g.date, items: g.items })),
|
|
690
|
+
someday: bucketRecord(someday),
|
|
507
691
|
};
|
|
508
|
-
const headings = Array.isArray(view["headings"])
|
|
509
|
-
? view["headings"].map(shapeHeadingGroup)
|
|
510
|
-
: view["headings"];
|
|
511
|
-
const looseMembers = [
|
|
512
|
-
...asArray(view["active"]),
|
|
513
|
-
...flattenGroups(view["scheduled"]),
|
|
514
|
-
...asArray(view["someday"]),
|
|
515
|
-
...asArray(view["repeating"]),
|
|
516
|
-
];
|
|
517
|
-
const { anytime, upcoming, someday } = rebucketChildren(looseMembers, cd, compact);
|
|
518
|
-
const out = { ...view };
|
|
519
|
-
delete out["active"];
|
|
520
|
-
delete out["scheduled"];
|
|
521
|
-
delete out["repeating"];
|
|
522
|
-
delete out["logged"];
|
|
523
|
-
// Trashed children live only in `things trash` — never a project-view bucket.
|
|
524
|
-
// Delete defensively in case an untyped source carries the old key.
|
|
525
|
-
delete out["trashed"];
|
|
526
|
-
// The project card NODE keeps everything (children derive their container from
|
|
527
|
-
// it), but is still an item DTO, so the universal + R10 reshapes apply.
|
|
528
|
-
out["project"] = shapeItem(view["project"], NO_DROP, false);
|
|
529
|
-
out["anytime"] = anytime;
|
|
530
|
-
out["upcoming"] = upcoming;
|
|
531
|
-
out["someday"] = someday;
|
|
532
|
-
out["headings"] = headings;
|
|
533
|
-
// A project keeps its in-context `logbook` (a project is a bounded object with
|
|
534
|
-
// a real done-state); trashed children live only in `things trash`.
|
|
535
|
-
out["logbook"] = shapeList(view["logged"], cd, compact);
|
|
536
|
-
return out;
|
|
537
692
|
}
|
|
538
|
-
/**
|
|
539
|
-
|
|
693
|
+
/**
|
|
694
|
+
* Shape an area view into the read-shape v2 wire (PR 3):
|
|
695
|
+
* `{ area | null, children, projects }` — NOTHING else at this level. `children`
|
|
696
|
+
* is the area's direct to-dos as three stage-keyed bucket records (`anytime`,
|
|
697
|
+
* `upcoming[]`, `someday` — NO `logbook`, #346); `projects` is the child-project
|
|
698
|
+
* sidebar-rank scope as a bucket record `{items, total?}` — a mixed-stage listing
|
|
699
|
+
* that KEEPS `stage`/`when` (the someday-projects / active split is TTY-only). The
|
|
700
|
+
* loose pseudo-area keeps `area: null`. The area node keeps its identity (tags
|
|
701
|
+
* folded to names); each direct-to-do row drops `area` (the node states it) + the
|
|
702
|
+
* bucket-implied `stage`. Inline `total` (present iff a scope was capped, R1) is
|
|
703
|
+
* injected downstream by {@link withAreaBucketTotals}, where the pre-cap sizes are
|
|
704
|
+
* known. `out` is built fresh, so no render-only field leaks.
|
|
705
|
+
*/
|
|
706
|
+
function shapeAreaView(view, compact, promoter) {
|
|
540
707
|
const looseMembers = [
|
|
541
708
|
...asArray(view["active"]),
|
|
542
709
|
...flattenGroups(view["scheduled"]),
|
|
543
710
|
...asArray(view["someday"]),
|
|
544
711
|
...asArray(view["repeating"]),
|
|
545
712
|
];
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
713
|
+
return {
|
|
714
|
+
// The area NODE, or `null` for the loose pseudo-area (shapeArea passes null).
|
|
715
|
+
area: shapeArea(view["area"]),
|
|
716
|
+
children: shapeAreaChildren(looseMembers, AREA_CHILD_DROP, compact, promoter),
|
|
717
|
+
// The projects list is a mixed listing of the area's project rows — keep stage.
|
|
718
|
+
projects: bucketRecord(shapeList(view["projects"], AREA_PROJECTS_DROP, compact, promoter)),
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Inject the area view's inline scope `total`s (read-shape v2 R1, PR 3): present
|
|
723
|
+
* iff the scope was capped (`items.length < total`), absent otherwise — no
|
|
724
|
+
* `meta.truncation.blocks[]` sidecar. `children.anytime` carries the direct-to-dos
|
|
725
|
+
* (`--area-limit`) total; `projects` carries the project-rows (`--project-limit`)
|
|
726
|
+
* total. The scheduled/someday direct-to-do blocks and the scheduled/someday
|
|
727
|
+
* project rows are never capped, so they never gain a `total`. Both the CLI `view`
|
|
728
|
+
* wrapper and the MCP data block run the shaped view through this so completeness
|
|
729
|
+
* is answerable locally. Returns the view unchanged when it is not the expected
|
|
730
|
+
* shape.
|
|
731
|
+
*/
|
|
732
|
+
export function withAreaBucketTotals(view, totals) {
|
|
733
|
+
if (view === null || typeof view !== "object")
|
|
734
|
+
return view;
|
|
735
|
+
const v = view;
|
|
736
|
+
const children = v["children"];
|
|
737
|
+
const withChildTotals = children !== null && typeof children === "object"
|
|
738
|
+
? {
|
|
739
|
+
...children,
|
|
740
|
+
anytime: withBucketTotal(children["anytime"], totals.anytime),
|
|
741
|
+
}
|
|
742
|
+
: children;
|
|
743
|
+
// Spread-then-override keeps the `area` / `children` / `projects` key order.
|
|
744
|
+
return {
|
|
745
|
+
...v,
|
|
746
|
+
children: withChildTotals,
|
|
747
|
+
projects: withBucketTotal(v["projects"], totals.projects),
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* The global `upcoming` view's DAY-BLOCK key for one raw item (read-shape v2 PR 4):
|
|
752
|
+
* its `startDate` when scheduled; else, for a NON-template, its `deadline` (a
|
|
753
|
+
* deadline-forecast row appears at its due day — cohort 2, UPC1); else `null` —
|
|
754
|
+
* a date-LESS recurring template rides the trailing resting block (#V8). This is
|
|
755
|
+
* the emit-boundary twin of the renderer's `groupDate` (src/cli/render.ts) and of
|
|
756
|
+
* {@link upcomingBlockTotals} (the pre-cap sizer), so the wire's day blocks match
|
|
757
|
+
* the TTY grouping row-for-row and each block's inline `total` lines up with its
|
|
758
|
+
* scope. The library keeps its own day grouping; only the wire reshapes here.
|
|
759
|
+
*/
|
|
760
|
+
function upcomingBlockKey(o) {
|
|
761
|
+
const startDate = o["startDate"] ?? null;
|
|
762
|
+
if (startDate !== null)
|
|
763
|
+
return startDate;
|
|
764
|
+
const repeating = o["repeating"];
|
|
765
|
+
const isTemplate = repeating !== null &&
|
|
766
|
+
typeof repeating === "object" &&
|
|
767
|
+
repeating["isTemplate"] === true;
|
|
768
|
+
if (isTemplate)
|
|
769
|
+
return null; // a date-less template → the resting block (#V8)
|
|
770
|
+
return o["deadline"] ?? null; // a forecast row appears at its deadline
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Reshape the global `upcoming` view into the read-shape v2 day-block sections
|
|
774
|
+
* (PR 4): `[{ when, items, total? } …]` — chronological dated blocks keyed by
|
|
775
|
+
* {@link upcomingBlockKey} (each the COMPLETE global day scope, its `when` doubling
|
|
776
|
+
* as the `--in <when>` reorder token), then ONE trailing `{ when: null, items }`
|
|
777
|
+
* block holding the date-less resting recurring templates (#V8) when any exist.
|
|
778
|
+
* The incoming stream is already day-ordered (COALESCE(startDate, deadline) ASC,
|
|
779
|
+
* then the UI's within-day drag order), so encounter order preserves both the
|
|
780
|
+
* block chronology and the within-block order — no re-sort, matching the renderer.
|
|
781
|
+
* Rows KEEP `stage` (the view is projection-side stage-MIXED, R7: future-dated
|
|
782
|
+
* `upcoming` rows beside deadline-forecast `anytime`/`someday` ones) and drop
|
|
783
|
+
* `when` only when it equals the block's date (the block states it — the same rule
|
|
784
|
+
* {@link rebucketChildren} applies to a container day block); a forecast row's
|
|
785
|
+
* `when` is absent already, and a divergent projected `when` (horizon > 1) is kept.
|
|
786
|
+
* Every row keeps its container refs (a global mixed view — NO_DROP). Inline
|
|
787
|
+
* `total` is stamped downstream by {@link withUpcomingBlockTotals}.
|
|
788
|
+
*/
|
|
789
|
+
function shapeUpcomingView(items, compact, promoter) {
|
|
790
|
+
const datedByKey = new Map();
|
|
791
|
+
const datedOrder = [];
|
|
792
|
+
const resting = [];
|
|
793
|
+
for (const raw of items) {
|
|
794
|
+
if (raw === null || typeof raw !== "object")
|
|
795
|
+
continue;
|
|
796
|
+
const key = upcomingBlockKey(raw);
|
|
797
|
+
const shaped = shapeItem(raw, NO_DROP, compact, promoter);
|
|
798
|
+
if (key === null) {
|
|
799
|
+
resting.push(shaped);
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
if (!datedByKey.has(key)) {
|
|
803
|
+
datedByKey.set(key, []);
|
|
804
|
+
datedOrder.push(key);
|
|
805
|
+
}
|
|
806
|
+
// R12 — inside a dated block the block states the date, so a member whose
|
|
807
|
+
// `when` equals it drops it (a scheduled row's when IS the key). A forecast
|
|
808
|
+
// row has no `when`; a horizon-projected row whose `when` diverges keeps it.
|
|
809
|
+
if (shaped !== null && typeof shaped === "object" && shaped["when"] === key) {
|
|
810
|
+
delete shaped["when"];
|
|
811
|
+
}
|
|
812
|
+
datedByKey.get(key).push(shaped);
|
|
813
|
+
}
|
|
814
|
+
const sections = datedOrder.map((when) => ({ when, items: datedByKey.get(when) }));
|
|
815
|
+
if (resting.length > 0)
|
|
816
|
+
sections.push({ when: null, items: resting });
|
|
817
|
+
return sections;
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Inject each global-`upcoming` day block's inline `total` (read-shape v2 R1,
|
|
821
|
+
* PR 4): present iff that day's scope was capped by the flat row limit
|
|
822
|
+
* (`items.length < total`), absent otherwise — no `meta.truncation.blocks[]`
|
|
823
|
+
* sidecar (the whole-view `{shown,total,limit,truncated}` rollup still rides
|
|
824
|
+
* `meta.truncation` for the row hint). The flat cut across the day-ordered stream
|
|
825
|
+
* leaves at most ONE straddling block partial (its pre-cap size looked up by
|
|
826
|
+
* `when` from `totals`); blocks fully before the cut are complete (no `total`),
|
|
827
|
+
* and blocks fully past it never appear. The resting block keys on `null`. Both
|
|
828
|
+
* the CLI `sections` wrapper and the MCP data block run the shaped sections
|
|
829
|
+
* through this so completeness is answerable locally. Returns the input unchanged
|
|
830
|
+
* when it is not the expected sections array.
|
|
831
|
+
*/
|
|
832
|
+
export function withUpcomingBlockTotals(sections, totals) {
|
|
833
|
+
if (!Array.isArray(sections))
|
|
834
|
+
return sections;
|
|
835
|
+
return sections.map((s) => {
|
|
836
|
+
if (s === null || typeof s !== "object")
|
|
837
|
+
return s;
|
|
838
|
+
const sec = s;
|
|
839
|
+
const when = (sec["when"] ?? null);
|
|
840
|
+
const total = totals.get(when);
|
|
841
|
+
const items = sec["items"];
|
|
842
|
+
const shown = Array.isArray(items) ? items.length : 0;
|
|
843
|
+
// Spread-then-add keeps the `when` / `items` / `total` key order.
|
|
844
|
+
return total !== undefined && shown < total ? { ...sec, total } : sec;
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Inject each global anytime/someday section's inline `total` (read-shape v2 R1,
|
|
849
|
+
* PR 5): present iff that section's `items` were capped (`items.length < total`),
|
|
850
|
+
* absent otherwise — an untruncated section never restates its own length, and
|
|
851
|
+
* the pre-v2 `meta.truncation.blocks[]` descriptor-join sidecar is RETIRED. The
|
|
852
|
+
* pre-cap section sizes come from {@link previewSections}/{@link
|
|
853
|
+
* previewSomedaySections} keyed by area uuid (`null` for the loose section); the
|
|
854
|
+
* per-block "… N more" render detail is carried separately (internal
|
|
855
|
+
* {@link GroupBlock}[]), never on the wire. Both the CLI `sections` wrapper and
|
|
856
|
+
* the MCP data block run the shaped sections through this so completeness is
|
|
857
|
+
* answerable locally. Returns the input unchanged when it is not the expected
|
|
858
|
+
* sections array.
|
|
859
|
+
*/
|
|
860
|
+
export function withSectionTotals(sections, totals) {
|
|
861
|
+
if (!Array.isArray(sections))
|
|
862
|
+
return sections;
|
|
863
|
+
return sections.map((s) => {
|
|
864
|
+
if (s === null || typeof s !== "object")
|
|
865
|
+
return s;
|
|
866
|
+
const sec = s;
|
|
867
|
+
const area = sec["area"];
|
|
868
|
+
const key = area !== null && typeof area === "object" ? area["uuid"] : null;
|
|
869
|
+
const total = totals.get(key);
|
|
870
|
+
const items = sec["items"];
|
|
871
|
+
const shown = Array.isArray(items) ? items.length : 0;
|
|
872
|
+
// Spread-then-add keeps the `area` / `items` / `total` key order.
|
|
873
|
+
return total !== undefined && shown < total ? { ...sec, total } : sec;
|
|
874
|
+
});
|
|
563
875
|
}
|
|
564
876
|
/** Fold an area entity's tags to string names in place (returns a shallow copy). */
|
|
565
877
|
function shapeArea(src) {
|
|
@@ -569,32 +881,70 @@ function shapeArea(src) {
|
|
|
569
881
|
flattenTags(o);
|
|
570
882
|
return o;
|
|
571
883
|
}
|
|
572
|
-
/**
|
|
573
|
-
|
|
884
|
+
/**
|
|
885
|
+
* Shape the today view into its two `children` bucket records (read-shape v2 R1):
|
|
886
|
+
* `{ today: { items }, evening: { items } }`, each a stage/`when`-pure list (the
|
|
887
|
+
* bucket key states both — TODAY_SECTION_DROP). The whole-view `counts` aggregate
|
|
888
|
+
* is NOT here — it rides `meta.counts` (runRead / the MCP metadata block). Inline
|
|
889
|
+
* per-bucket `total` (present iff capped) is injected downstream by
|
|
890
|
+
* {@link withTodayBucketTotals}, where the pre-cap sizes are known.
|
|
891
|
+
*/
|
|
892
|
+
function shapeTodayView(view, compact, promoter) {
|
|
893
|
+
return {
|
|
894
|
+
today: { items: shapeList(view["today"], TODAY_SECTION_DROP, compact, promoter) },
|
|
895
|
+
evening: { items: shapeList(view["evening"], TODAY_SECTION_DROP, compact, promoter) },
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Inject each today bucket's inline `total` (read-shape v2 R1): present iff the
|
|
900
|
+
* bucket was capped (`items.length < total`), absent otherwise — an untruncated
|
|
901
|
+
* bucket never restates its own length. `totals` are the pre-cap bucket sizes
|
|
902
|
+
* from `truncateToday`. Both the CLI `data.children` wrapper and the MCP data
|
|
903
|
+
* block run the shaped children through this so completeness is answerable
|
|
904
|
+
* locally, with no truncation sidecar. Returns the children object unchanged when
|
|
905
|
+
* it is not the expected shape.
|
|
906
|
+
*/
|
|
907
|
+
export function withTodayBucketTotals(children, totals) {
|
|
908
|
+
if (children === null || typeof children !== "object")
|
|
909
|
+
return children;
|
|
910
|
+
const c = children;
|
|
574
911
|
return {
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
evening: shapeList(view["evening"], TODAY_SECTION_DROP, compact),
|
|
912
|
+
today: withBucketTotal(c["today"], totals.today),
|
|
913
|
+
evening: withBucketTotal(c["evening"], totals.evening),
|
|
578
914
|
};
|
|
579
915
|
}
|
|
916
|
+
/**
|
|
917
|
+
* Stamp a bucket record's inline `total` (read-shape v2 R1) iff it was capped
|
|
918
|
+
* (`items.length < total`) — an untruncated bucket never restates its own length.
|
|
919
|
+
* The pre-cap `total` comes from the bounding layer; returns the bucket unchanged
|
|
920
|
+
* when it is not a `{items}` record. Shared by the today and area inline-total
|
|
921
|
+
* injectors.
|
|
922
|
+
*/
|
|
923
|
+
function withBucketTotal(bucket, total) {
|
|
924
|
+
if (bucket === null || typeof bucket !== "object")
|
|
925
|
+
return bucket;
|
|
926
|
+
const b = bucket;
|
|
927
|
+
const items = b["items"];
|
|
928
|
+
const shown = Array.isArray(items) ? items.length : 0;
|
|
929
|
+
return shown < total ? { ...b, total } : b;
|
|
930
|
+
}
|
|
580
931
|
/** Shape sidebar sections (anytime/someday catalogues) with the section's drop spec. */
|
|
581
|
-
function shapeSections(sections, drop, compact) {
|
|
932
|
+
function shapeSections(sections, drop, compact, promoter) {
|
|
582
933
|
if (!Array.isArray(sections))
|
|
583
934
|
return sections;
|
|
584
|
-
return sections.map((s) => s === null || typeof s !== "object" ? s : withShapedItems(s, drop, compact));
|
|
935
|
+
return sections.map((s) => s === null || typeof s !== "object" ? s : withShapedItems(s, drop, compact, promoter));
|
|
585
936
|
}
|
|
586
937
|
/**
|
|
587
938
|
* The flat, mixed-provenance list kinds mapped to their drop spec. Only the
|
|
588
939
|
* stage-PURE catalogues (inbox/someday/logbook/trash; the section-based `anytime`
|
|
589
940
|
* is pure too, handled via shapeSections below) drop the bucket-implied `stage`.
|
|
590
|
-
*
|
|
591
|
-
*
|
|
592
|
-
* stage
|
|
593
|
-
*
|
|
941
|
+
* The mixed/derived surfaces (search/changes/projects) keep it. The global
|
|
942
|
+
* `upcoming` view is NOT here — it reshapes into `data.sections` day blocks
|
|
943
|
+
* ({@link shapeUpcomingView}), keeping `stage` (R10.2: stage-mixed — future-dated
|
|
944
|
+
* `upcoming` rows beside deadline-forecast `anytime`/`someday` ones).
|
|
594
945
|
*/
|
|
595
946
|
const FLAT_LIST_DROP = new Map([
|
|
596
947
|
["inbox", { stage: true }],
|
|
597
|
-
["upcoming", NO_DROP],
|
|
598
948
|
["logbook", { stage: true }],
|
|
599
949
|
["trash", { stage: true }],
|
|
600
950
|
["changes", NO_DROP],
|
|
@@ -608,28 +958,35 @@ const FLAT_LIST_DROP = new Map([
|
|
|
608
958
|
* mutated (shallow copies throughout), so the human-render path keeps the full
|
|
609
959
|
* entities.
|
|
610
960
|
*/
|
|
611
|
-
export function shapeReadPayload(kind, data, full) {
|
|
961
|
+
export function shapeReadPayload(kind, data, full, promoter) {
|
|
962
|
+
// The ref-promotion oracle drives the round-trip law for flat container refs.
|
|
963
|
+
// Absent (a DB-less unit shaping): assume every title round-trips — bare
|
|
964
|
+
// titles, no uuid siblings. Production always passes the client's promoter.
|
|
965
|
+
const p = promoter ?? ALWAYS_ROUND_TRIPS;
|
|
612
966
|
// `detail` is the FULL record and drops no ancestry / stage / `when`.
|
|
613
967
|
if (kind === "detail")
|
|
614
|
-
return shapeItem(data, NO_DROP, false);
|
|
968
|
+
return shapeItem(data, NO_DROP, false, p);
|
|
615
969
|
const compact = !full;
|
|
616
970
|
const flatDrop = FLAT_LIST_DROP.get(kind);
|
|
617
971
|
if (flatDrop !== undefined)
|
|
618
|
-
return shapeList(data, flatDrop, compact);
|
|
972
|
+
return shapeList(data, flatDrop, compact, p);
|
|
973
|
+
// The global `upcoming` view reshapes into `data.sections` day blocks (PR 4).
|
|
974
|
+
if (kind === "upcoming" && Array.isArray(data))
|
|
975
|
+
return shapeUpcomingView(data, compact, p);
|
|
619
976
|
if (kind === "today" && data !== null && typeof data === "object") {
|
|
620
|
-
return shapeTodayView(data, compact);
|
|
977
|
+
return shapeTodayView(data, compact, p);
|
|
621
978
|
}
|
|
622
979
|
if (kind === "anytime" && Array.isArray(data)) {
|
|
623
|
-
return shapeSections(data, ANYTIME_SECTION_DROP, compact); // stage-pure → drop stage
|
|
980
|
+
return shapeSections(data, ANYTIME_SECTION_DROP, compact, p); // stage-pure → drop stage
|
|
624
981
|
}
|
|
625
982
|
if (kind === "someday" && Array.isArray(data)) {
|
|
626
|
-
return shapeSections(data, SOMEDAY_SECTION_DROP, compact); // stage-pure → drop stage
|
|
983
|
+
return shapeSections(data, SOMEDAY_SECTION_DROP, compact, p); // stage-pure → drop stage
|
|
627
984
|
}
|
|
628
985
|
if (kind === "area-view" && data !== null && typeof data === "object") {
|
|
629
|
-
return shapeAreaView(data, compact);
|
|
986
|
+
return shapeAreaView(data, compact, p);
|
|
630
987
|
}
|
|
631
988
|
if (kind === "project-view" && data !== null && typeof data === "object") {
|
|
632
|
-
return shapeProjectView(data, compact);
|
|
989
|
+
return shapeProjectView(data, compact, p);
|
|
633
990
|
}
|
|
634
991
|
// The `areas` listing carries Area entities whose tags fold to names.
|
|
635
992
|
if (kind === "areas" && Array.isArray(data))
|
|
@@ -663,8 +1020,10 @@ export function candidateRef(type, src) {
|
|
|
663
1020
|
const out = {
|
|
664
1021
|
uuid: typeof s["uuid"] === "string" ? s["uuid"] : "",
|
|
665
1022
|
title: typeof s["title"] === "string" ? s["title"] : "",
|
|
666
|
-
type,
|
|
667
1023
|
};
|
|
1024
|
+
// Absent `type` = to-do — emit it only for the other kinds.
|
|
1025
|
+
if (type !== "to-do")
|
|
1026
|
+
out.type = type;
|
|
668
1027
|
const area = candidateContainerTitle(s["area"]);
|
|
669
1028
|
if (area !== null)
|
|
670
1029
|
out.area = area;
|