things-api 0.11.0 → 0.13.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 +16 -5
- package/dist/audit/schema.d.ts +7 -0
- package/dist/audit/schema.js.map +1 -1
- 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 +18 -11
- package/dist/cli/commands/area.js +81 -77
- package/dist/cli/commands/area.js.map +1 -1
- package/dist/cli/commands/doctor.d.ts +1 -2
- package/dist/cli/commands/doctor.js +11 -1
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/install-skill.d.ts +19 -4
- package/dist/cli/commands/install-skill.js +130 -52
- package/dist/cli/commands/install-skill.js.map +1 -1
- package/dist/cli/commands/mcp.js +21 -1
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/commands/project.d.ts +1 -0
- package/dist/cli/commands/project.js +92 -48
- package/dist/cli/commands/project.js.map +1 -1
- package/dist/cli/commands/reads.d.ts +5 -1
- package/dist/cli/commands/reads.js +110 -31
- 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 +75 -36
- 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 +795 -144
- package/dist/cli/commands/writes.js.map +1 -1
- package/dist/cli/did-you-mean.d.ts +14 -3
- package/dist/cli/did-you-mean.js +44 -7
- 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 +16 -2
- package/dist/cli/glyphs.js +60 -12
- package/dist/cli/glyphs.js.map +1 -1
- package/dist/cli/help.js +69 -13
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/main.js +8 -1
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/move-hint.d.ts +3 -2
- package/dist/cli/move-hint.js +2 -2
- package/dist/cli/move-hint.js.map +1 -1
- package/dist/cli/read-driver.d.ts +53 -5
- package/dist/cli/read-driver.js +82 -15
- 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 +40 -14
- package/dist/cli/render.js +128 -55
- package/dist/cli/render.js.map +1 -1
- package/dist/cli/resolve-invocation.d.ts +7 -5
- package/dist/cli/resolve-invocation.js +16 -7
- package/dist/cli/resolve-invocation.js.map +1 -1
- package/dist/cli/skill-check.d.ts +12 -6
- package/dist/cli/skill-check.js +31 -14
- package/dist/cli/skill-check.js.map +1 -1
- package/dist/cli/skill.d.ts +8 -0
- package/dist/cli/skill.js +10 -0
- package/dist/cli/skill.js.map +1 -1
- package/dist/cli/verb-hint.js +1 -1
- package/dist/cli/verb-hint.js.map +1 -1
- package/dist/client.d.ts +158 -26
- package/dist/client.js +254 -38
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +66 -2
- package/dist/config.js +120 -12
- package/dist/config.js.map +1 -1
- package/dist/contracts.d.ts +187 -28
- package/dist/contracts.js +22 -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/diagnose.d.ts +34 -2
- package/dist/diagnose.js +36 -1
- package/dist/diagnose.js.map +1 -1
- package/dist/index.d.ts +21 -8
- package/dist/index.js +26 -3
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.d.ts +9 -0
- package/dist/mcp/server.js +790 -322
- package/dist/mcp/server.js.map +1 -1
- package/dist/model/entities.d.ts +57 -4
- package/dist/model/entities.js.map +1 -1
- package/dist/model/mappers.d.ts +2 -0
- package/dist/model/mappers.js +44 -2
- package/dist/model/mappers.js.map +1 -1
- package/dist/model/recurrence.d.ts +8 -1
- package/dist/model/recurrence.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 +76 -0
- package/dist/read/area-filter.js +58 -0
- package/dist/read/area-filter.js.map +1 -0
- package/dist/read/area-view.d.ts +22 -11
- package/dist/read/area-view.js +54 -28
- package/dist/read/area-view.js.map +1 -1
- package/dist/read/detail.js +15 -8
- package/dist/read/detail.js.map +1 -1
- package/dist/read/predicates.d.ts +18 -0
- package/dist/read/predicates.js +19 -0
- package/dist/read/predicates.js.map +1 -1
- package/dist/read/project-view.d.ts +91 -15
- package/dist/read/project-view.js +135 -37
- package/dist/read/project-view.js.map +1 -1
- package/dist/read/pseudo-area.d.ts +29 -0
- package/dist/read/pseudo-area.js +27 -0
- package/dist/read/pseudo-area.js.map +1 -0
- package/dist/read/queries.d.ts +175 -19
- package/dist/read/queries.js +376 -32
- package/dist/read/queries.js.map +1 -1
- package/dist/read/scope.d.ts +126 -0
- package/dist/read/scope.js +161 -0
- package/dist/read/scope.js.map +1 -0
- package/dist/read/search-rank.d.ts +2 -5
- package/dist/read/shape.d.ts +181 -0
- package/dist/read/shape.js +737 -0
- package/dist/read/shape.js.map +1 -0
- package/dist/read/show-target.d.ts +9 -1
- package/dist/read/show-target.js +77 -17
- package/dist/read/show-target.js.map +1 -1
- package/dist/read/stage.d.ts +199 -0
- package/dist/read/stage.js +125 -0
- package/dist/read/stage.js.map +1 -0
- package/dist/read/truncation.d.ts +7 -12
- package/dist/read/truncation.js +56 -21
- package/dist/read/truncation.js.map +1 -1
- package/dist/read/views.d.ts +68 -16
- package/dist/read/views.js +206 -52
- package/dist/read/views.js.map +1 -1
- package/dist/surface-copy.d.ts +16 -0
- package/dist/surface-copy.js +16 -0
- package/dist/surface-copy.js.map +1 -1
- package/dist/write/batch.d.ts +49 -10
- package/dist/write/batch.js +423 -71
- package/dist/write/batch.js.map +1 -1
- package/dist/write/commands.js +268 -55
- package/dist/write/commands.js.map +1 -1
- package/dist/write/guards.d.ts +1 -1
- package/dist/write/guards.js +71 -10
- package/dist/write/guards.js.map +1 -1
- package/dist/write/heading.d.ts +10 -1
- package/dist/write/heading.js +35 -5
- package/dist/write/heading.js.map +1 -1
- package/dist/write/make-repeating-project.d.ts +2 -2
- package/dist/write/make-repeating-project.js +9 -9
- package/dist/write/make-repeating-project.js.map +1 -1
- package/dist/write/move.d.ts +134 -0
- package/dist/write/move.js +1874 -0
- package/dist/write/move.js.map +1 -0
- package/dist/write/operations.d.ts +105 -19
- package/dist/write/operations.js +56 -7
- package/dist/write/operations.js.map +1 -1
- package/dist/write/opid.d.ts +31 -0
- package/dist/write/opid.js +30 -0
- package/dist/write/opid.js.map +1 -0
- package/dist/write/pipeline.d.ts +70 -3
- package/dist/write/pipeline.js +198 -33
- package/dist/write/pipeline.js.map +1 -1
- package/dist/write/pre-state.d.ts +172 -8
- package/dist/write/pre-state.js +467 -25
- package/dist/write/pre-state.js.map +1 -1
- package/dist/write/reorder.d.ts +23 -3
- package/dist/write/reorder.js +2106 -86
- package/dist/write/reorder.js.map +1 -1
- package/dist/write/reversibility.js +19 -7
- package/dist/write/reversibility.js.map +1 -1
- package/dist/write/scope-guard.d.ts +31 -0
- package/dist/write/scope-guard.js +162 -0
- package/dist/write/scope-guard.js.map +1 -0
- package/dist/write/undo.js +120 -13
- package/dist/write/undo.js.map +1 -1
- package/dist/write/vectors/applescript.js +16 -7
- package/dist/write/vectors/applescript.js.map +1 -1
- package/dist/write/vectors/shortcuts.js +1 -1
- package/dist/write/vectors/shortcuts.js.map +1 -1
- package/dist/write/vectors/simulator.js +83 -27
- package/dist/write/vectors/simulator.js.map +1 -1
- package/dist/write/vectors/types.d.ts +11 -0
- package/dist/write/vectors/ui-certification.d.ts +3 -3
- package/dist/write/vectors/ui-certification.js +41 -5
- package/dist/write/vectors/ui-certification.js.map +1 -1
- package/dist/write/vectors/ui-drag.d.ts +21 -1
- package/dist/write/vectors/ui-drag.js +138 -27
- package/dist/write/vectors/ui-drag.js.map +1 -1
- package/dist/write/vectors/ui-recipes.d.ts +21 -1
- package/dist/write/vectors/ui-recipes.js +166 -7
- package/dist/write/vectors/ui-recipes.js.map +1 -1
- package/dist/write/vectors/ui.d.ts +25 -1
- package/dist/write/vectors/ui.js +142 -33
- package/dist/write/vectors/ui.js.map +1 -1
- package/dist/write/verify/delta.d.ts +17 -1
- package/dist/write/verify/delta.js +106 -29
- package/dist/write/verify/delta.js.map +1 -1
- package/package.json +7 -1
- package/schema/envelope.schema.json +397 -0
- package/skills/things-cli/SKILL.md +36 -17
- package/skills/things-cli/references/banner.md +35 -0
- package/skills/things-cli/references/contracts.md +32 -9
- package/skills/things-cli/references/errors.md +57 -0
- package/skills/things-cli/references/gui.md +1 -1
- package/skills/things-cli/references/model.md +18 -7
- package/skills/things-cli/references/ordering.md +88 -0
package/dist/write/reorder.js
CHANGED
|
@@ -1,15 +1,195 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* write.reorder orchestrator — two strategies, both lab-derived:
|
|
3
|
+
*
|
|
4
|
+
* native — one `_private_experimental_ reorder` AppleScript call through
|
|
5
|
+
* the standard pipeline (drift gate → guards → canary → execute →
|
|
6
|
+
* ordering verification). Scopes: today (bucket-0 members, O01/
|
|
7
|
+
* O03/O12), project/area (un-headed children, O04/O05/O09–O11),
|
|
8
|
+
* container-day (a container's same-day scheduled children — a
|
|
9
|
+
* date-preserving todayIndex re-rank, DAYORD-b/O17). Gated by
|
|
10
|
+
* config.allowExperimental AND the sdef canary.
|
|
11
|
+
*
|
|
12
|
+
* bounce — verified `when=` round-trips (REORDGAPS + BOUNCE2): re-scheduling
|
|
13
|
+
* an item away from and back into its resting bucket RE-INSERTS it,
|
|
14
|
+
* and the DIRECTION follows the containment context (the BOUNCE2
|
|
15
|
+
* re-entry law, oddities §9h):
|
|
16
|
+
* - loose / area-direct items FRONT-insert below the group min, so
|
|
17
|
+
* reverse-order legs land the target order (today, evening,
|
|
18
|
+
* projects, area-less loose anytime, an area's someday members);
|
|
19
|
+
* - strict-container children (heading / project children)
|
|
20
|
+
* BACK-insert at the bucket end, so forward-order legs land the
|
|
21
|
+
* target order (a heading's anytime children; a project's someday
|
|
22
|
+
* children — the SOMEBNC-project fallback when native is off).
|
|
23
|
+
* Each leg is a full verified todo.update/project.update mutation;
|
|
24
|
+
* between items the live state is re-checked so a user editing Things
|
|
25
|
+
* concurrently causes a clean abort with partial-progress detail,
|
|
26
|
+
* never a fight. Several bounce scopes are the ONLY surface that
|
|
27
|
+
* reaches their bucket (evening O03; top-level projects P8e/scf2 P6;
|
|
28
|
+
* within-heading order HEADORD-b; an area's someday order §9f).
|
|
29
|
+
*
|
|
30
|
+
* The requested uuid list may be a subset of the scope: for native, the wire
|
|
31
|
+
* list is extended with every remaining member in current order (placement
|
|
32
|
+
* stays deterministic); for bounce, unrequested members simply stay put below
|
|
33
|
+
* the bounced block (neighbors untouched), except an anchored (`--before`/
|
|
34
|
+
* `--after`) placement co-bounces the minimal contiguous run between the block
|
|
35
|
+
* and the bucket edge (disclosed as `touched`).
|
|
36
|
+
*
|
|
37
|
+
* JSON collapse (BOUNCEJSON, oddities §9i): for the bounce classes whose
|
|
38
|
+
* PLACEMENT (`back`) leg is `when=anytime` landing into a loose or heading-
|
|
39
|
+
* container bucket, the whole N-item round-trip can collapse to ONE pre-
|
|
40
|
+
* validated `things:///json` update array (2N ops, 1 dispatch, exact array
|
|
41
|
+
* order, ~7×, validate-first FULL-ABORT). Eligibility is a per-BounceSpec flag
|
|
42
|
+
* ({@link BounceSpec.jsonCollapsible}) keyed off that mechanism, NOT the front/
|
|
43
|
+
* back direction — see the flag's doc for why a someday-placement or area-
|
|
44
|
+
* direct class must NEVER collapse (json is index-inert there — a collapse
|
|
45
|
+
* would silently fail to reorder).
|
|
46
|
+
*/
|
|
47
|
+
import { randomBytes } from "node:crypto";
|
|
48
|
+
import { addDaysIso, decodePackedDate, localToday, encodePackedDate } from "../model/dates.js";
|
|
2
49
|
import { resolveTaskUuidPrefix } from "../read/queries.js";
|
|
3
|
-
import { computeReorderPre, resolveArea, resolveProject } from "./pre-state.js";
|
|
50
|
+
import { computeReorderPre, resolveArea, resolveProject, todayEveningFlagOf } from "./pre-state.js";
|
|
4
51
|
import { sdefDeclaresPrivateReorder } from "./experimental.js";
|
|
5
|
-
import { fingerprintLabel, runMutation, } from "./pipeline.js";
|
|
52
|
+
import { fingerprintLabel, readAuthToken, runMutation, } from "./pipeline.js";
|
|
6
53
|
import { createDbReader, evaluateDelta } from "./verify/delta.js";
|
|
7
54
|
import { pollUntilVerified } from "./verify/poller.js";
|
|
8
55
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
56
|
+
* DEFAULT bounce cap — 2 verified mutations per item (~110 ms/item guest-local,
|
|
57
|
+
* BOUNCE2-t). The effective cap is `config.bounceMaxItems` (default 30); this
|
|
58
|
+
* constant is the built-in fallback used when a caller supplies no config value,
|
|
59
|
+
* and the figure surface copy cites.
|
|
60
|
+
*/
|
|
61
|
+
export const BOUNCE_MAX_ITEMS = 30;
|
|
62
|
+
/**
|
|
63
|
+
* The bounce kinds whose N-item round-trip is eligible for the `things:///json`
|
|
64
|
+
* one-array collapse (BOUNCEJSON §9i) — exactly the classes whose placement
|
|
65
|
+
* (`back`) leg is `when=anytime` into a loose/heading-container bucket. Exported
|
|
66
|
+
* for the classification regression test (guards against "optimizing" a someday-
|
|
67
|
+
* placement or area-direct class into json, which §9i proves is a silent no-op).
|
|
11
68
|
*/
|
|
12
|
-
export
|
|
69
|
+
export function bounceJsonCollapsible(kind) {
|
|
70
|
+
return bounceSpecOf(kind).jsonCollapsible;
|
|
71
|
+
}
|
|
72
|
+
function bounceSpecOf(kind) {
|
|
73
|
+
switch (kind) {
|
|
74
|
+
case "today":
|
|
75
|
+
return {
|
|
76
|
+
away: "evening",
|
|
77
|
+
back: "today",
|
|
78
|
+
dated: false,
|
|
79
|
+
direction: "front",
|
|
80
|
+
rankKey: "todayIndex",
|
|
81
|
+
legOp: "todo.update",
|
|
82
|
+
// todayIndex leg, not an anytime placement — json reindex unproven here.
|
|
83
|
+
jsonCollapsible: false,
|
|
84
|
+
};
|
|
85
|
+
case "evening":
|
|
86
|
+
// EVEORD: to-dos AND projects share ONE evening todayIndex axis; a project's
|
|
87
|
+
// when=evening re-entry front-inserts at the group min just like a to-do
|
|
88
|
+
// (×3). So the evening bounce accepts MIXED movees via a per-type leg op.
|
|
89
|
+
// Caveat: when=evening CLEARS reminderTime for BOTH kinds (§9n / R07).
|
|
90
|
+
return {
|
|
91
|
+
away: "today",
|
|
92
|
+
back: "evening",
|
|
93
|
+
dated: false,
|
|
94
|
+
direction: "front",
|
|
95
|
+
rankKey: "todayIndex",
|
|
96
|
+
legOp: "per-type",
|
|
97
|
+
jsonCollapsible: false,
|
|
98
|
+
};
|
|
99
|
+
// SIT4 DAYBNC: the DATED BOUNCE for an arbitrary future day-group across all
|
|
100
|
+
// containers — loose/project-child/headed/area-direct to-dos AND area-less
|
|
101
|
+
// project rows front-insert at the day-D GLOBAL todayIndex min on the back
|
|
102
|
+
// leg. away/back are the cross-date round-trip (D+1 → D), per-type legs,
|
|
103
|
+
// reminder/deadline/heading-FK preserving (§2e/R21). Sequential URL only —
|
|
104
|
+
// the json when= reindex is unproven for dated placement (§9i tested anytime).
|
|
105
|
+
case "day":
|
|
106
|
+
return {
|
|
107
|
+
away: null,
|
|
108
|
+
back: null,
|
|
109
|
+
dated: true,
|
|
110
|
+
direction: "front",
|
|
111
|
+
rankKey: "todayIndex",
|
|
112
|
+
legOp: "per-type",
|
|
113
|
+
jsonCollapsible: false,
|
|
114
|
+
};
|
|
115
|
+
case "projects":
|
|
116
|
+
return {
|
|
117
|
+
away: "someday",
|
|
118
|
+
back: "anytime",
|
|
119
|
+
dated: false,
|
|
120
|
+
direction: "front",
|
|
121
|
+
rankKey: "index",
|
|
122
|
+
legOp: "project.update",
|
|
123
|
+
// project.update (type=1): the json when= reindex is unproven for a
|
|
124
|
+
// project row — stays on the URL loop (§9i tested to-dos only).
|
|
125
|
+
jsonCollapsible: false,
|
|
126
|
+
};
|
|
127
|
+
// Headed anytime children BACK-insert (BOUNCE2-h): forward-order bounce.
|
|
128
|
+
// Placement leg = anytime into a heading container -> json-collapsible (BJ-a).
|
|
129
|
+
case "heading":
|
|
130
|
+
return {
|
|
131
|
+
away: "someday",
|
|
132
|
+
back: "anytime",
|
|
133
|
+
dated: false,
|
|
134
|
+
direction: "back",
|
|
135
|
+
rankKey: "index",
|
|
136
|
+
legOp: "todo.update",
|
|
137
|
+
jsonCollapsible: true,
|
|
138
|
+
};
|
|
139
|
+
// Area someday members FRONT-insert (SOMEBNC-area): reverse-order bounce.
|
|
140
|
+
// Someday placement leg AND area-direct -> json index-INERT (§9i b+c): URL only.
|
|
141
|
+
case "area-someday":
|
|
142
|
+
return {
|
|
143
|
+
away: "anytime",
|
|
144
|
+
back: "someday",
|
|
145
|
+
dated: false,
|
|
146
|
+
direction: "front",
|
|
147
|
+
rankKey: "index",
|
|
148
|
+
legOp: "todo.update",
|
|
149
|
+
jsonCollapsible: false,
|
|
150
|
+
};
|
|
151
|
+
// Area-less loose anytime FRONT-insert (ANYBNC): reverse-order bounce.
|
|
152
|
+
// Placement leg = anytime into a loose bucket -> json-collapsible (BJ-0).
|
|
153
|
+
case "anytime":
|
|
154
|
+
return {
|
|
155
|
+
away: "someday",
|
|
156
|
+
back: "anytime",
|
|
157
|
+
dated: false,
|
|
158
|
+
direction: "front",
|
|
159
|
+
rankKey: "index",
|
|
160
|
+
legOp: "todo.update",
|
|
161
|
+
jsonCollapsible: true,
|
|
162
|
+
};
|
|
163
|
+
// Project someday children BACK-insert (SOMEBNC-project): forward-order bounce.
|
|
164
|
+
// Someday placement leg -> json index-INERT (§9i b): URL only, despite being
|
|
165
|
+
// a back-insert (eligibility is the placement-leg value, not the direction).
|
|
166
|
+
case "project-someday":
|
|
167
|
+
return {
|
|
168
|
+
away: "anytime",
|
|
169
|
+
back: "someday",
|
|
170
|
+
dated: false,
|
|
171
|
+
direction: "back",
|
|
172
|
+
rankKey: "index",
|
|
173
|
+
legOp: "todo.update",
|
|
174
|
+
jsonCollapsible: false,
|
|
175
|
+
};
|
|
176
|
+
// SIT7 SOMEBACK: loose someday to-dos AND area-less someday projects FRONT-insert
|
|
177
|
+
// at the loose someday index min via the anytime↔someday round-trip (reverse-order
|
|
178
|
+
// dispatch). Per-type leg op (todo.update / update-project) — the first MIXED-kind
|
|
179
|
+
// fixed-bucket bounce, mirroring the day/evening per-type legs. Someday placement
|
|
180
|
+
// leg -> json index-INERT (§9i b): URL loop only (never collapsible).
|
|
181
|
+
case "someday":
|
|
182
|
+
return {
|
|
183
|
+
away: "anytime",
|
|
184
|
+
back: "someday",
|
|
185
|
+
dated: false,
|
|
186
|
+
direction: "front",
|
|
187
|
+
rankKey: "index",
|
|
188
|
+
legOp: "per-type",
|
|
189
|
+
jsonCollapsible: false,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
13
193
|
function blocked(detail, remediation) {
|
|
14
194
|
return {
|
|
15
195
|
kind: "blocked",
|
|
@@ -25,89 +205,597 @@ function blocked(detail, remediation) {
|
|
|
25
205
|
}
|
|
26
206
|
export async function runReorder(deps, params, options = {}) {
|
|
27
207
|
params = { ...params, uuids: params.uuids.map((u) => resolveTaskUuidPrefix(deps.db, u)) };
|
|
208
|
+
// HEADSUB1 within-heading sub-bucket compound (headsub1-heading-subbuckets.md):
|
|
209
|
+
// heading-someday re-heads the block in forward order (move-to-heading back-
|
|
210
|
+
// insert). (The former heading-day unhead→container-day→re-head round-trip is
|
|
211
|
+
// SUPERSEDED by the `day` dated bounce — SIT4 DAYBNC preserves the heading FK,
|
|
212
|
+
// so a headed same-day child just bounces.)
|
|
213
|
+
if (params.scope === "heading-someday")
|
|
214
|
+
return runHeadingSomeday(deps, params, options);
|
|
28
215
|
const strategy = resolveStrategy(deps, params);
|
|
29
216
|
if (strategy.kind === "blocked")
|
|
30
217
|
return strategy.result;
|
|
31
218
|
if (strategy.strategy === "native") {
|
|
32
219
|
return runMutation(deps, "reorder", params, { ...options, vector: "applescript" });
|
|
33
220
|
}
|
|
34
|
-
|
|
221
|
+
const result = strategy.strategy === "bounce"
|
|
222
|
+
? await runBounce(deps, params, strategy.bounceKind, options)
|
|
223
|
+
: await runMoveFallback(deps, params, strategy.fallback, options);
|
|
224
|
+
// SIT7 disclosure: when the private reorder surface is unavailable the scope
|
|
225
|
+
// ran a degraded-but-guaranteed non-experimental fallback — say so, so the
|
|
226
|
+
// caller never silently mistakes a fallback for the native placement.
|
|
227
|
+
if (strategy.fallbackNote !== undefined && result.kind === "ok") {
|
|
228
|
+
return { ...result, warnings: [...(result.warnings ?? []), strategy.fallbackNote] };
|
|
229
|
+
}
|
|
230
|
+
return result;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Why the native `_private_experimental_ reorder` command is unavailable, for the
|
|
234
|
+
* fallback disclosure note. Either the config gate is off or the sdef canary
|
|
235
|
+
* failed — the two triggers {@link resolveStrategy} routes a fallback for.
|
|
236
|
+
*/
|
|
237
|
+
function nativeUnavailableReason(deps) {
|
|
238
|
+
return deps.config.allowExperimental
|
|
239
|
+
? "the app no longer declares the private reorder command (sdef canary failed)"
|
|
240
|
+
: "allow-experimental is off";
|
|
241
|
+
}
|
|
242
|
+
/** The fallback disclosure warning — which protocol ran, and why native could not. */
|
|
243
|
+
function fallbackNoteFor(deps, protocol) {
|
|
244
|
+
return (`reordered via the non-experimental ${protocol} fallback because the native reorder ` +
|
|
245
|
+
`is unavailable (${nativeUnavailableReason(deps)})`);
|
|
246
|
+
}
|
|
247
|
+
/** A blocked result for a move-based SIT7 fallback while the shared move gate is off. */
|
|
248
|
+
function moveFallbackDisabled(what) {
|
|
249
|
+
return {
|
|
250
|
+
kind: "blocked",
|
|
251
|
+
result: {
|
|
252
|
+
kind: "blocked",
|
|
253
|
+
op: "reorder",
|
|
254
|
+
reason: "environment",
|
|
255
|
+
detail: `${what} falls back to a park + re-enter MOVE protocol (the native reorder is ` +
|
|
256
|
+
"unavailable), which shares the bounce gate and is disabled (bounce-enabled=false) — " +
|
|
257
|
+
"it was NOT attempted (no destructive or unverified fallback exists)",
|
|
258
|
+
remediation: "re-enable it with `things config set bounce-enabled true`" +
|
|
259
|
+
" (each moved item costs a park + re-enter leg), or turn allow-experimental back on",
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/** Gate a move-based SIT7 fallback on bounce-enabled (the shared multi-leg move gate). */
|
|
264
|
+
function fallbackOk(deps, what, kind, protocol) {
|
|
265
|
+
if (!deps.config.bounceEnabled)
|
|
266
|
+
return moveFallbackDisabled(what);
|
|
267
|
+
return {
|
|
268
|
+
kind: "ok",
|
|
269
|
+
strategy: "fallback",
|
|
270
|
+
fallback: kind,
|
|
271
|
+
fallbackNote: fallbackNoteFor(deps, protocol),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
/** A blocked result for a bounce-dependent placement while bounce is disabled. */
|
|
275
|
+
function bounceDisabled(what) {
|
|
276
|
+
return {
|
|
277
|
+
kind: "blocked",
|
|
278
|
+
result: {
|
|
279
|
+
kind: "blocked",
|
|
280
|
+
op: "reorder",
|
|
281
|
+
reason: "environment",
|
|
282
|
+
detail: `${what} requires the when= bounce, which is disabled (bounce-enabled=false) — ` +
|
|
283
|
+
"it was NOT attempted (no destructive or unverified fallback exists)",
|
|
284
|
+
remediation: "re-enable it with `things config set bounce-enabled true`" +
|
|
285
|
+
" (each bounced item costs two verified mutations)",
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
function bounceOk(deps, what, kind) {
|
|
290
|
+
if (!deps.config.bounceEnabled)
|
|
291
|
+
return bounceDisabled(what);
|
|
292
|
+
return { kind: "ok", strategy: "bounce", bounceKind: kind };
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* A when=-bounce SIT7 fallback (SOMEBACK someday, or a dated `day` bounce standing
|
|
296
|
+
* in for container-day/tomorrow): a bounce that ALSO carries the fallback disclosure
|
|
297
|
+
* note, because it ran only because the native surface was unavailable.
|
|
298
|
+
*/
|
|
299
|
+
function bounceFallbackOk(deps, what, kind, protocol) {
|
|
300
|
+
const d = bounceOk(deps, what, kind);
|
|
301
|
+
if (d.kind !== "ok" || d.strategy !== "bounce")
|
|
302
|
+
return d;
|
|
303
|
+
return { ...d, fallbackNote: fallbackNoteFor(deps, protocol) };
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Whether the native `_private_experimental_ reorder` command is available — the
|
|
307
|
+
* config gate is on AND the app still declares it in its sdef (the canary). The
|
|
308
|
+
* `day`-scope template leg family gates on this too: a repeating TO-DO template's
|
|
309
|
+
* only safe day-block placement is the native single-id `list "Upcoming"` front-
|
|
310
|
+
* insert (a dated when= leg CRASHES a template, §1), so a day-group carrying any
|
|
311
|
+
* template requires the native surface (else it refuses honestly, naming them).
|
|
312
|
+
*/
|
|
313
|
+
function nativeReorderAvailable(deps) {
|
|
314
|
+
return deps.config.allowExperimental && (deps.sdefProbe ?? sdefDeclaresPrivateReorder)();
|
|
35
315
|
}
|
|
36
316
|
function resolveStrategy(deps, params) {
|
|
37
|
-
const nativeAvailable =
|
|
317
|
+
const nativeAvailable = nativeReorderAvailable(deps);
|
|
318
|
+
// Scopes whose ONLY surface is the bounce (no native command reaches them).
|
|
319
|
+
const bounceOnly = {
|
|
320
|
+
evening: { kind: "evening", what: "evening-section order" },
|
|
321
|
+
day: { kind: "day", what: "an arbitrary future-day order" },
|
|
322
|
+
projects: { kind: "projects", what: "top-level projects order" },
|
|
323
|
+
heading: { kind: "heading", what: "within-heading order" },
|
|
324
|
+
"area-someday": { kind: "area-someday", what: "an area's someday order" },
|
|
325
|
+
anytime: { kind: "anytime", what: "area-less loose anytime order" },
|
|
326
|
+
};
|
|
327
|
+
const bounceEntry = bounceOnly[params.scope];
|
|
38
328
|
if (params.strategy === "native") {
|
|
39
|
-
if (
|
|
40
|
-
return blocked(
|
|
41
|
-
"every listed item (O03)", "omit --strategy (evening defaults to bounce)");
|
|
42
|
-
}
|
|
43
|
-
if (params.scope === "projects") {
|
|
44
|
-
return blocked("top-level sidebar order has NO native surface (every AppleScript spelling errors " +
|
|
45
|
-
"and the private command no-ops — scf2 P6); only the when= bounce works (P8e)", "omit --strategy (projects defaults to bounce)");
|
|
329
|
+
if (bounceEntry !== undefined) {
|
|
330
|
+
return blocked(`${bounceEntry.what} has NO native surface — only the when= bounce reaches it`, "omit --strategy (it defaults to the bounce)");
|
|
46
331
|
}
|
|
47
332
|
return { kind: "ok", strategy: "native" };
|
|
48
333
|
}
|
|
49
334
|
if (params.strategy === "bounce") {
|
|
50
|
-
if (params.scope === "
|
|
51
|
-
|
|
52
|
-
params.scope === "inbox" ||
|
|
53
|
-
params.scope === "headings" ||
|
|
54
|
-
params.scope === "someday") {
|
|
55
|
-
return blocked("bounce can only reorder the Today/Evening sections and top-level projects — its " +
|
|
56
|
-
"primitive is a when= round-trip, which does not move this scope's order", "use the native strategy (requires `things config set allow-experimental true`)");
|
|
335
|
+
if (params.scope === "container-day") {
|
|
336
|
+
return blocked("a container's scheduled-day order is a native todayIndex re-rank (DAYORD-b), not a bounce", "omit --strategy (container-day defaults to native)");
|
|
57
337
|
}
|
|
58
|
-
|
|
338
|
+
// `someday` DOES have a when= bounce twin (SIT7 SOMEBACK) — route explicit
|
|
339
|
+
// --strategy bounce to it. project/area/inbox have no when= surface (their
|
|
340
|
+
// SIT7 fallbacks are MOVE protocols, planner-selected only).
|
|
341
|
+
if (params.scope === "someday") {
|
|
342
|
+
return bounceOk(deps, "loose someday order", "someday");
|
|
343
|
+
}
|
|
344
|
+
if (params.scope === "project" || params.scope === "area" || params.scope === "inbox") {
|
|
345
|
+
return blocked("bounce can only reorder the Today/Evening sections, top-level projects, within-heading, " +
|
|
346
|
+
"area-someday, area-less anytime, and loose someday — its primitive is a when= round-trip, " +
|
|
347
|
+
"which does not move this scope's order (use the native strategy, or omit --strategy to let " +
|
|
348
|
+
"the non-experimental MOVE fallback run when native is unavailable)", "use the native strategy (requires `things config set allow-experimental true`)");
|
|
349
|
+
}
|
|
350
|
+
if (bounceEntry !== undefined)
|
|
351
|
+
return bounceOk(deps, bounceEntry.what, bounceEntry.kind);
|
|
352
|
+
// today with explicit bounce.
|
|
353
|
+
return bounceOk(deps, "Today-section order", "today");
|
|
59
354
|
}
|
|
60
355
|
// Default per scope.
|
|
356
|
+
if (bounceEntry !== undefined)
|
|
357
|
+
return bounceOk(deps, bounceEntry.what, bounceEntry.kind);
|
|
61
358
|
switch (params.scope) {
|
|
62
|
-
case "evening":
|
|
63
|
-
case "projects":
|
|
64
|
-
return { kind: "ok", strategy: "bounce" };
|
|
65
359
|
case "today":
|
|
66
|
-
return
|
|
360
|
+
return nativeAvailable
|
|
361
|
+
? { kind: "ok", strategy: "native" }
|
|
362
|
+
: bounceOk(deps, "Today-section order", "today");
|
|
363
|
+
// SIT7 AUTOMATIC FALLBACKS — every native-only reorder scope degrades to a
|
|
364
|
+
// proven non-experimental protocol when the private surface is unavailable
|
|
365
|
+
// (allow-experimental off OR the sdef canary fails), rather than fail-explain.
|
|
67
366
|
case "project":
|
|
367
|
+
// PROJROOT (fallback 3): park the unheaded children to a scratch project, then
|
|
368
|
+
// re-home `list-id=<P>` in FORWARD target order (back-insert). One protocol for
|
|
369
|
+
// ALL rows (flagged or not) — the move round-trip is proven flag-safe (SIT7).
|
|
370
|
+
return nativeAvailable
|
|
371
|
+
? { kind: "ok", strategy: "native" }
|
|
372
|
+
: fallbackOk(deps, "within-project child order", "proj-root", "PROJROOT");
|
|
68
373
|
case "area":
|
|
374
|
+
// AREABACK (fallback 4): park members out, re-home `list-id=`/`area-id=<area>`
|
|
375
|
+
// in REVERSE target order (front-insert), area FK preserved. Flag-safe move.
|
|
376
|
+
return nativeAvailable
|
|
377
|
+
? { kind: "ok", strategy: "native" }
|
|
378
|
+
: fallbackOk(deps, "an area's member order", "area-back", "AREABACK");
|
|
69
379
|
case "inbox":
|
|
70
|
-
|
|
380
|
+
// INBOXBACK (fallback 1): park each row into a scratch project, then re-enter
|
|
381
|
+
// via `move … to list "Inbox"` in REVERSE target order (front-insert, restores
|
|
382
|
+
// start=0). A same-list `move … to "Inbox"` is a no-op, so park-first is required.
|
|
383
|
+
return nativeAvailable
|
|
384
|
+
? { kind: "ok", strategy: "native" }
|
|
385
|
+
: fallbackOk(deps, "Inbox order", "inbox-park", "INBOXBACK");
|
|
71
386
|
case "someday":
|
|
72
|
-
//
|
|
73
|
-
//
|
|
387
|
+
// SOMEBACK (fallback 2): the anytime↔someday when= bounce front-inserts loose
|
|
388
|
+
// someday to-dos AND area-less someday projects (reverse-target, per-type leg).
|
|
389
|
+
return nativeAvailable
|
|
390
|
+
? { kind: "ok", strategy: "native" }
|
|
391
|
+
: bounceFallbackOk(deps, "loose someday order", "someday", "SOMEBACK");
|
|
392
|
+
case "container-day":
|
|
393
|
+
case "tomorrow":
|
|
394
|
+
// Fallback 5: the dated `day` bounce (ORD-6 DAYBNC) reaches every same-day child
|
|
395
|
+
// via a pure-URL cross-date round-trip — a non-experimental stand-in for both
|
|
396
|
+
// the container-day native re-rank and the one-call `list "Tomorrow"` sort.
|
|
397
|
+
return nativeAvailable
|
|
398
|
+
? { kind: "ok", strategy: "native" }
|
|
399
|
+
: bounceFallbackOk(deps, params.scope === "tomorrow" ? "Tomorrow order" : "a container's scheduled-day order", "day", "dated-day-bounce");
|
|
400
|
+
// bounce-only scopes handled by bounceEntry above; unreachable here.
|
|
401
|
+
case "evening":
|
|
402
|
+
case "day":
|
|
403
|
+
case "projects":
|
|
404
|
+
case "heading":
|
|
405
|
+
case "area-someday":
|
|
406
|
+
case "anytime":
|
|
74
407
|
return { kind: "ok", strategy: "native" };
|
|
408
|
+
// heading-someday is intercepted in runReorder before resolveStrategy runs;
|
|
409
|
+
// this case exists only for switch exhaustiveness.
|
|
410
|
+
case "heading-someday":
|
|
411
|
+
return { kind: "ok", strategy: "native" };
|
|
412
|
+
// `upcoming` is an INTERNAL per-template front-insert leg (the `day` bounce
|
|
413
|
+
// dispatches it via runMutation directly — TMPLSORT-1); it never reaches the
|
|
414
|
+
// strategy resolver. Blocked here for exhaustiveness / defence in depth.
|
|
415
|
+
case "upcoming":
|
|
416
|
+
return blocked("the `upcoming` scope is an internal per-template front-insert leg, not a user reorder scope", "reorder a template's day-block via `things todo reorder` on its day (the planner routes it)");
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
// (The SOMEBNC-project someday-only bounce fallback for the `project` scope is
|
|
420
|
+
// SUPERSEDED by PROJROOT — the SIT7 park + re-home MOVE round-trip that reorders
|
|
421
|
+
// ALL of a project's unheaded children flag-safely, one protocol for every row.
|
|
422
|
+
// The `project-someday` BounceKind survives as the §9i json-collapse classification
|
|
423
|
+
// pin, but it is no longer selected by resolveStrategy.)
|
|
424
|
+
// ----------------------------------------------------- compound leg options
|
|
425
|
+
//
|
|
426
|
+
// (The former park-sort-restore day compounds — loose-day / area-day / upcoming-
|
|
427
|
+
// day — and the heading-day unhead→re-head round-trip are DELETED: SIT4 DAYBNC
|
|
428
|
+
// proved the dated `day` bounce serves their whole population plus area-less
|
|
429
|
+
// project rows, reminder/deadline/heading-FK preserving, with NO scratch project
|
|
430
|
+
// and NO experimental gate. compoundLegOptions survives — heading-someday still
|
|
431
|
+
// uses it for its per-leg move sub-mutations.)
|
|
432
|
+
/** Leg options for a compound sub-mutation — each leg fully verified. */
|
|
433
|
+
function compoundLegOptions(options, txnId) {
|
|
434
|
+
const legs = { txn: { id: txnId, role: "leg" } };
|
|
435
|
+
if (options.maxDisruption !== undefined)
|
|
436
|
+
legs.maxDisruption = options.maxDisruption;
|
|
437
|
+
if (options.verifyTimeoutMs !== undefined)
|
|
438
|
+
legs.verifyTimeoutMs = options.verifyTimeoutMs;
|
|
439
|
+
if (options.actor !== undefined)
|
|
440
|
+
legs.actor = options.actor;
|
|
441
|
+
return legs;
|
|
442
|
+
}
|
|
443
|
+
// ----------------------------------------------------- heading sub-buckets
|
|
444
|
+
//
|
|
445
|
+
// HEADSUB1 (docs/lab/headsub1-heading-subbuckets.md) settled the per-class order
|
|
446
|
+
// of a heading's sub-buckets. heading-someday is wired here; the anytime sub-
|
|
447
|
+
// bucket rides the `heading` bounce, the EVENING sub-bucket rides the shipped
|
|
448
|
+
// `evening` bounce (Arm 2b — the heading FK survives the today↔evening round-
|
|
449
|
+
// trip), and a heading's same-day SCHEDULED children ride the `day` dated bounce
|
|
450
|
+
// (SIT4 DAYBNC — the cross-date round-trip preserves the heading FK, so the former
|
|
451
|
+
// unhead→container-day→re-head heading-day round-trip is no longer needed).
|
|
452
|
+
//
|
|
453
|
+
// heading-someday: UNHEAD the block, then RE-HEAD it in FORWARD target order
|
|
454
|
+
// (Arm B-someday / Arm C + HEADSUB2 Q1). A re-head of a row ALREADY under the
|
|
455
|
+
// target heading is a same-heading NO-OP (HEADSUB2 Q1(b) — index untouched),
|
|
456
|
+
// so the block MUST be unheaded FIRST (clean — heading→NULL, index/start=2
|
|
457
|
+
// preserved, Arm C); the re-head then BACK-INSERTS each now-loose row at the
|
|
458
|
+
// heading someday-bucket end (§9h renumber, Arm B), so forward-order re-heads
|
|
459
|
+
// land the exact target order (HEADSUB2 q1fix), `start=2` preserved. Two URL
|
|
460
|
+
// move legs per item (unhead + re-head), no json collapse (re-head is a list-
|
|
461
|
+
// id move, not a when= reindex); needs neither the experimental surface nor
|
|
462
|
+
// the bounce. (The earlier direct-re-head-only compile shipped in #327 was a
|
|
463
|
+
// silent no-op — HEADSUB2 §Q1 — corrected here.)
|
|
464
|
+
/** The project a heading belongs to (its re-head destination), or null. */
|
|
465
|
+
function headingProjectUuid(deps, headingUuid) {
|
|
466
|
+
if (headingUuid === null)
|
|
467
|
+
return null;
|
|
468
|
+
const row = deps.db
|
|
469
|
+
.prepare("SELECT project FROM TMTask WHERE uuid = ? AND type = 2 AND trashed = 0")
|
|
470
|
+
.get(headingUuid);
|
|
471
|
+
return row?.project ?? null;
|
|
472
|
+
}
|
|
473
|
+
/** Abort payload for heading-someday: items are UNHEADED in the project root. */
|
|
474
|
+
function headingSomedayAborted(detail, placed, remaining, cause) {
|
|
475
|
+
return { kind: "bounce-aborted", op: "reorder", detail, placed, remaining, cause };
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* heading-someday: the unhead → re-head round-trip (HEADSUB1 Arm B-someday / Arm
|
|
479
|
+
* C + HEADSUB2 Q1). A re-head of a row ALREADY under the target heading is a
|
|
480
|
+
* same-heading NO-OP (HEADSUB2 Q1(b) — the app leaves `index` untouched), so the
|
|
481
|
+
* block cannot be sorted by re-heading in place. The block is UNHEADED first
|
|
482
|
+
* (clean — heading→NULL, `index`/`start=2` preserved, Arm C), then RE-HEADED in
|
|
483
|
+
* forward target order — each now-loose row BACK-INSERTS at the heading someday-
|
|
484
|
+
* bucket end (§9h renumber, Arm B), so forward-order re-heads land the exact
|
|
485
|
+
* target order (HEADSUB2 q1fix). The block is the SUFFIX from the first named
|
|
486
|
+
* movee's target slot to the bucket end; the untouched prefix stays headed above
|
|
487
|
+
* (its `index` is below the re-inserted block), and unnamed siblings inside the
|
|
488
|
+
* suffix are co-unheaded + co-re-headed (disclosed as `touched`). Two `todo.move`
|
|
489
|
+
* legs per item (unhead + re-head), `start=2` preserved throughout; needs neither
|
|
490
|
+
* the experimental surface nor the bounce, and there is no json collapse (re-head
|
|
491
|
+
* is a list-id move). Non-atomic: a mid-protocol failure leaves items UNHEADED in
|
|
492
|
+
* the project root and fails loudly with placed/remaining detail.
|
|
493
|
+
*/
|
|
494
|
+
async function runHeadingSomeday(deps, params, options) {
|
|
495
|
+
const startedAt = deps.now?.() ?? new Date();
|
|
496
|
+
const now = deps.now ?? (() => new Date());
|
|
497
|
+
const actor = options.actor ?? deps.config.actor;
|
|
498
|
+
const cap = deps.config.bounceMaxItems ?? BOUNCE_MAX_ITEMS;
|
|
499
|
+
const headingUuid = params.container?.uuid ?? null;
|
|
500
|
+
const projectUuid = headingProjectUuid(deps, headingUuid);
|
|
501
|
+
const txnId = `txn-${startedAt.getTime().toString(36)}-${process.pid.toString(36)}`;
|
|
502
|
+
const pre = computeReorderPre(deps.db, params, headingUuid, now());
|
|
503
|
+
const preRanks = {};
|
|
504
|
+
for (const m of pre.members)
|
|
505
|
+
preRanks[m.uuid] = m.rank;
|
|
506
|
+
// Back-insert (like the heading bounce): the SUFFIX from the first named movee's
|
|
507
|
+
// target slot to the bucket end is unheaded then re-headed forward (each appends).
|
|
508
|
+
const targetOrder = pre.wireList;
|
|
509
|
+
const named = new Set(params.named ?? params.uuids);
|
|
510
|
+
const movedPositions = targetOrder.map((u, i) => (named.has(u) ? i : -1)).filter((i) => i >= 0);
|
|
511
|
+
const firstMoved = movedPositions.length > 0 ? movedPositions[0] : 0;
|
|
512
|
+
const block = targetOrder.slice(firstMoved);
|
|
513
|
+
const touchedUnnamed = block.filter((u) => !named.has(u));
|
|
514
|
+
const problems = [];
|
|
515
|
+
if (headingUuid === null || projectUuid === null) {
|
|
516
|
+
problems.push("the heading did not resolve to a project (re-head needs the heading's project)");
|
|
517
|
+
}
|
|
518
|
+
if (params.uuids.length === 0)
|
|
519
|
+
problems.push("no uuids given");
|
|
520
|
+
if (pre.duplicates.length > 0)
|
|
521
|
+
problems.push(`duplicated uuid(s): ${pre.duplicates.join(", ")}`);
|
|
522
|
+
for (const r of pre.rejected)
|
|
523
|
+
problems.push(`${r.uuid} ${r.reason}`);
|
|
524
|
+
if (block.length > cap) {
|
|
525
|
+
problems.push(`${block.length} touched items exceed the cap of ${cap} (each costs an unhead + re-head leg` +
|
|
526
|
+
(touchedUnnamed.length > 0
|
|
527
|
+
? `; ${touchedUnnamed.length} unnamed heading sibling(s) are co-re-headed to honor the order`
|
|
528
|
+
: "") +
|
|
529
|
+
")");
|
|
530
|
+
}
|
|
531
|
+
if (problems.length > 0) {
|
|
532
|
+
const result = {
|
|
533
|
+
kind: "blocked",
|
|
534
|
+
op: "reorder",
|
|
535
|
+
reason: "hazard",
|
|
536
|
+
hazard: "H-REORDER-SCOPE",
|
|
537
|
+
detail: `within-heading someday reorder rejected: ${problems.join("; ")}`,
|
|
538
|
+
remediation: "reorder the Someday children of ONE heading (read the project first), " +
|
|
539
|
+
`at most ${cap} touched (set with \`things config set bounce-max-items\`)`,
|
|
540
|
+
};
|
|
541
|
+
auditSummary(deps, params, startedAt, "blocked:H-REORDER-SCOPE", null, {
|
|
542
|
+
pre: preRanks,
|
|
543
|
+
txnId,
|
|
544
|
+
actor,
|
|
545
|
+
});
|
|
546
|
+
return result;
|
|
547
|
+
}
|
|
548
|
+
if (options.dryRun === true) {
|
|
549
|
+
return {
|
|
550
|
+
kind: "dry-run",
|
|
551
|
+
op: "reorder",
|
|
552
|
+
plan: {
|
|
553
|
+
op: "reorder",
|
|
554
|
+
vector: "url-scheme",
|
|
555
|
+
tier: 0,
|
|
556
|
+
invocation: `unhead ×${block.length} → re-head ×${block.length} (back-insert, forward order` +
|
|
557
|
+
(touchedUnnamed.length > 0
|
|
558
|
+
? `, touches ${touchedUnnamed.length} unnamed sibling(s)`
|
|
559
|
+
: "") +
|
|
560
|
+
"): a same-heading re-head is a no-op (HEADSUB2 Q1), so the block is unheaded first; " +
|
|
561
|
+
"each `list-id=<project>&heading=<heading>` then appends at the someday-bucket end; " +
|
|
562
|
+
"one terminal order verify",
|
|
563
|
+
expectedDelta: { mode: "ordering", key: "index", sequence: block },
|
|
564
|
+
hazardsChecked: ["H-REORDER-SCOPE"],
|
|
565
|
+
},
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
const legOpts = compoundLegOptions(options, txnId);
|
|
569
|
+
// 1. UNHEAD each block member (clean — heading→NULL, index/start=2 preserved).
|
|
570
|
+
// A same-heading re-head is a NO-OP (HEADSUB2 Q1(b)), so the block MUST be
|
|
571
|
+
// unheaded before it can be re-inserted in order.
|
|
572
|
+
const unheaded = [];
|
|
573
|
+
for (const uuid of block) {
|
|
574
|
+
const res = await runMutation(deps, "todo.move", { uuid, noHeading: true }, legOpts);
|
|
575
|
+
if (res.kind !== "ok") {
|
|
576
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...unheaded] }, { pre: preRanks, txnId, actor });
|
|
577
|
+
return headingSomedayAborted(`unheading ${uuid} failed — ${unheaded.length} item(s) are UNHEADED in project ` +
|
|
578
|
+
`${projectUuid} and must be moved back under the heading manually`, unheaded, block.slice(unheaded.length), res);
|
|
579
|
+
}
|
|
580
|
+
unheaded.push(uuid);
|
|
581
|
+
}
|
|
582
|
+
// 2. RE-HEAD each in forward target order — each now-loose row BACK-INSERTS at
|
|
583
|
+
// the someday-bucket end (Arm B), so forward-order re-heads land the target.
|
|
584
|
+
const placed = [];
|
|
585
|
+
for (const uuid of block) {
|
|
586
|
+
const res = await runMutation(deps, "todo.move", { uuid, project: { uuid: projectUuid }, heading: headingUuid }, legOpts);
|
|
587
|
+
if (res.kind !== "ok") {
|
|
588
|
+
const stillUnheaded = block.filter((u) => !placed.includes(u));
|
|
589
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...placed] }, { pre: preRanks, txnId, actor });
|
|
590
|
+
return headingSomedayAborted(`re-heading ${uuid} failed — ${stillUnheaded.length} item(s) remain UNHEADED in project ` +
|
|
591
|
+
`${projectUuid} (ordered) and must be moved back under the heading manually`, placed, stillUnheaded, res);
|
|
592
|
+
}
|
|
593
|
+
placed.push(uuid);
|
|
594
|
+
}
|
|
595
|
+
// 3. Terminal verify: the block's index order matches the target.
|
|
596
|
+
const verify = await pollUntilVerified(() => evaluateDelta({ mode: "ordering", key: "index", sequence: block }, createDbReader(deps.db), {
|
|
597
|
+
modDates: {},
|
|
598
|
+
fields: {},
|
|
599
|
+
}), options.verifyTimeoutMs ?? 4000, deps.poller ?? {});
|
|
600
|
+
if (verify.kind !== "ok") {
|
|
601
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...placed] }, { pre: preRanks, txnId, actor });
|
|
602
|
+
return headingSomedayAborted("the heading someday sub-bucket did not land the requested order after re-heading; " +
|
|
603
|
+
"re-run once Things is idle", placed, [], null);
|
|
75
604
|
}
|
|
605
|
+
const reader = createDbReader(deps.db);
|
|
606
|
+
const observed = {};
|
|
607
|
+
for (const uuid of block)
|
|
608
|
+
observed[uuid] = reader.rankOf(uuid, "index");
|
|
609
|
+
auditSummary(deps, params, startedAt, "ok", observed, { pre: preRanks, txnId, actor });
|
|
610
|
+
return {
|
|
611
|
+
kind: "ok",
|
|
612
|
+
op: "reorder",
|
|
613
|
+
uuid: null,
|
|
614
|
+
observed,
|
|
615
|
+
vector: "url-scheme",
|
|
616
|
+
tier: 0,
|
|
617
|
+
undoToken: txnId,
|
|
618
|
+
...(touchedUnnamed.length > 0 && { touched: touchedUnnamed }),
|
|
619
|
+
};
|
|
76
620
|
}
|
|
77
621
|
// ------------------------------------------------------------------- bounce
|
|
78
|
-
async function runBounce(deps, params, options) {
|
|
622
|
+
async function runBounce(deps, params, bounceKind, options) {
|
|
79
623
|
const startedAt = deps.now?.() ?? new Date();
|
|
80
624
|
const now = deps.now ?? (() => new Date());
|
|
81
625
|
const actor = options.actor ?? deps.config.actor;
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
const
|
|
626
|
+
const spec = bounceSpecOf(bounceKind);
|
|
627
|
+
const { direction, rankKey, legOp } = spec;
|
|
628
|
+
const cap = deps.config.bounceMaxItems ?? BOUNCE_MAX_ITEMS;
|
|
629
|
+
const containerUuid = resolveContainerUuid(deps, params);
|
|
630
|
+
const wantsContainer = bounceKind === "heading" ||
|
|
631
|
+
bounceKind === "area-someday" ||
|
|
632
|
+
bounceKind === "project-someday" ||
|
|
633
|
+
// SIT7 fallback routing: `container-day` degrades to the dated `day` bounce
|
|
634
|
+
// (fallback 5) and legitimately carries its project/area container.
|
|
635
|
+
params.scope === "container-day";
|
|
636
|
+
// The two `when=` leg values. A fixed-bucket bounce uses the spec's keywords; the
|
|
637
|
+
// DATED `day` bounce (SIT4 DAYBNC) derives them from the movees' shared day —
|
|
638
|
+
// back = the day D, away = the neighbour day D+1 (a strictly-future staging day
|
|
639
|
+
// ≠ D that keeps the transient visit out of Today). The day D is read off the
|
|
640
|
+
// first requested uuid — its `startDate` when scheduled, else its `deadline` when
|
|
641
|
+
// it is a deadline-forecast row (startDate NULL, start IN (1,2) — DLBNC/§9o). The
|
|
642
|
+
// planner guarantees every movee shares D; an absent/malformed day yields an empty
|
|
643
|
+
// member set and is rejected below before any dispatch, so the today fallback here
|
|
644
|
+
// only keeps the WhenValue well-typed. (The `when=` values drive the SCHEDULED
|
|
645
|
+
// rows' bounce legs; a forecast row ignores them — it rides the deadline-cycle.)
|
|
646
|
+
let dayPacked = null;
|
|
647
|
+
let awayValue;
|
|
648
|
+
let backValue;
|
|
649
|
+
if (spec.dated) {
|
|
650
|
+
const firstUuid = params.uuids[0];
|
|
651
|
+
const firstRow = firstUuid !== undefined
|
|
652
|
+
? deps.db
|
|
653
|
+
.prepare("SELECT startDate, startBucket, deadline, start, rt1_nextInstanceStartDate AS proj, " +
|
|
654
|
+
"(rt1_recurrenceRule IS NOT NULL OR repeater IS NOT NULL) AS isTemplate " +
|
|
655
|
+
"FROM TMTask WHERE uuid = ?")
|
|
656
|
+
.get(firstUuid)
|
|
657
|
+
: undefined;
|
|
658
|
+
// The day D: a template's PROJECTION day (rt1_nextInstanceStartDate), else a
|
|
659
|
+
// scheduled row's startDate, else a forecast row's deadline (mirrors
|
|
660
|
+
// computeReorderPre so the when= legs and the member set agree on D).
|
|
661
|
+
dayPacked =
|
|
662
|
+
firstRow === undefined
|
|
663
|
+
? null
|
|
664
|
+
: firstRow.isTemplate === 1
|
|
665
|
+
? firstRow.proj
|
|
666
|
+
: firstRow.startDate !== null && firstRow.startBucket === 0
|
|
667
|
+
? firstRow.startDate
|
|
668
|
+
: firstRow.startDate === null && (firstRow.start === 1 || firstRow.start === 2)
|
|
669
|
+
? firstRow.deadline
|
|
670
|
+
: null;
|
|
671
|
+
const iso = decodePackedDate(dayPacked);
|
|
672
|
+
backValue = iso ?? localToday(now());
|
|
673
|
+
awayValue = iso !== null ? addDaysIso(iso, 1) : localToday(now());
|
|
674
|
+
}
|
|
675
|
+
else {
|
|
676
|
+
awayValue = spec.away;
|
|
677
|
+
backValue = spec.back;
|
|
678
|
+
}
|
|
679
|
+
// A day-scope DEADLINE-FORECAST row (startDate NULL, deadline set) reorders by the
|
|
680
|
+
// DLBNC deadline-cycle (URL `deadline=` clear + re-set of the SAME deadline —
|
|
681
|
+
// byte-identical, front-inserts on the block's todayIndex axis) instead of the
|
|
682
|
+
// scheduled `when=` bounce. Returns the decoded ISO to re-set, or null for a
|
|
683
|
+
// scheduled row (which takes the `when=` legs). AppleScript `due date` is
|
|
684
|
+
// certified-wrong here (lazy todayIndex, cannot clear) — URL only (DLBNC §9q).
|
|
685
|
+
const forecastLegOf = (uuid) => {
|
|
686
|
+
if (bounceKind !== "day")
|
|
687
|
+
return null;
|
|
688
|
+
const r = deps.db.prepare("SELECT startDate, deadline FROM TMTask WHERE uuid = ?").get(uuid);
|
|
689
|
+
if (r === undefined || r.startDate !== null || r.deadline === null)
|
|
690
|
+
return null;
|
|
691
|
+
const iso = decodePackedDate(r.deadline);
|
|
692
|
+
return iso === null ? null : { iso };
|
|
693
|
+
};
|
|
694
|
+
// Per-item leg op: the mixed-kind `day`/`evening` bounces pick update-project for
|
|
695
|
+
// a project row (type=1) and todo.update for a to-do (type=0); every other bounce
|
|
696
|
+
// uses one fixed op. (NEVER send a dated when= leg to a template — §1 crash — but
|
|
697
|
+
// templates are already excluded from the member set, and a requested template
|
|
698
|
+
// ref is refused by computeReorderPre, so no leg ever targets one.)
|
|
699
|
+
const opForRow = (uuid) => {
|
|
700
|
+
if (legOp !== "per-type")
|
|
701
|
+
return legOp;
|
|
702
|
+
const t = deps.db.prepare("SELECT type FROM TMTask WHERE uuid = ?").get(uuid)?.type;
|
|
703
|
+
return t === 1 ? "project.update" : "todo.update";
|
|
704
|
+
};
|
|
85
705
|
const txnId = `txn-${startedAt.getTime().toString(36)}-${process.pid.toString(36)}`;
|
|
86
706
|
// Scope/membership guard — same data the native path's guard uses.
|
|
87
|
-
const pre = computeReorderPre(deps.db, params,
|
|
707
|
+
const pre = computeReorderPre(deps.db, params, containerUuid, now());
|
|
88
708
|
// Pre-ranks make the SUMMARY record the undoable unit (a single inverse
|
|
89
709
|
// reorder restores the old relative order); legs are excluded from undo.
|
|
90
710
|
const preRanks = {};
|
|
91
711
|
for (const m of pre.members)
|
|
92
712
|
preRanks[m.uuid] = m.rank;
|
|
713
|
+
// The bounce re-inserts the MINIMAL contiguous run of `pre.wireList` (the full
|
|
714
|
+
// target order the planner spliced) needed to realize the request, per the
|
|
715
|
+
// BOUNCE2 laws:
|
|
716
|
+
// - back-insert (heading / project children): the SUFFIX from the first named
|
|
717
|
+
// movee's target slot to the bucket end, bounced FORWARD (each appends,
|
|
718
|
+
// ending at the bottom in order; the untouched prefix stays above);
|
|
719
|
+
// - front-insert (loose / area-direct): the PREFIX from the top to the last
|
|
720
|
+
// named movee's target slot, bounced REVERSE (each front-inserts, ending at
|
|
721
|
+
// the top in order; the untouched suffix stays below).
|
|
722
|
+
// Everything ELSE in the run beyond the named block is a CO-BOUNCED sibling —
|
|
723
|
+
// touched (mod bump, changes-feed entry, audit leg) and disclosed.
|
|
724
|
+
const targetOrder = pre.wireList;
|
|
725
|
+
const named = new Set(params.named ?? params.uuids);
|
|
726
|
+
const movedPositions = targetOrder.map((u, i) => (named.has(u) ? i : -1)).filter((i) => i >= 0);
|
|
727
|
+
const firstMoved = movedPositions.length > 0 ? movedPositions[0] : 0;
|
|
728
|
+
const lastMoved = movedPositions.length > 0 ? movedPositions[movedPositions.length - 1] : 0;
|
|
729
|
+
const coBounce = direction === "back" ? targetOrder.slice(firstMoved) : targetOrder.slice(0, lastMoved + 1);
|
|
730
|
+
const touchedUnnamed = coBounce.filter((u) => !named.has(u));
|
|
731
|
+
// FLAG-AWARE protocol routing (SIT6). The three json-collapsible index bounces
|
|
732
|
+
// (`heading` BOUNCE2-h, `anytime` ANYBNC, `projects` P8e) are when=someday →
|
|
733
|
+
// when=anytime round-trips whose `when=` legs OVERWRITE the Today/Evening flag:
|
|
734
|
+
// the someday leg nulls startDate (start 1→2), the anytime leg only flips start
|
|
735
|
+
// back to 1, so a flagged movee is silently DE-Todayed (PROJSTAR de-star). Each
|
|
736
|
+
// has a lab-proven flag-safe MOVE twin on the same `index` axis (SIT6): route
|
|
737
|
+
// the WHOLE touched set through it whenever ANY touched row carries the flag —
|
|
738
|
+
// heading → HEADMOVE (unhead → re-head in forward target order; back-insert),
|
|
739
|
+
// anytime → LOOSEPARK (park into a scratch PROJECT → unpark in reverse target;
|
|
740
|
+
// front-insert),
|
|
741
|
+
// projects → PROJPARK (park into a scratch AREA → detach in reverse target;
|
|
742
|
+
// front-insert).
|
|
743
|
+
// Every leg is a URL move (`list-id=`/`area-id=`), NO when= leg and NO private
|
|
744
|
+
// reorder surface, so the flag / reminder / deadline / FKs all survive. An
|
|
745
|
+
// all-UNFLAGGED touched set keeps the cheaper bounce below. Detection is
|
|
746
|
+
// `todayEveningFlagOf` over the full touched set (the coBounce run), the same
|
|
747
|
+
// single-source marker #351 used. This SUPERSEDES #351's `projects` de-Today
|
|
748
|
+
// refusal for flagged movees; a refusal remains only when the protocol itself is
|
|
749
|
+
// unavailable (cap exceeded), raised inside the protocol.
|
|
750
|
+
if (bounceKind === "heading" || bounceKind === "anytime" || bounceKind === "projects") {
|
|
751
|
+
const flagged = coBounce.some((uuid) => todayEveningFlagOf(deps.db, uuid, now()) !== null);
|
|
752
|
+
if (flagged) {
|
|
753
|
+
const ctx = {
|
|
754
|
+
coBounce,
|
|
755
|
+
containerUuid,
|
|
756
|
+
txnId,
|
|
757
|
+
actor,
|
|
758
|
+
touchedUnnamed,
|
|
759
|
+
startedAt,
|
|
760
|
+
options,
|
|
761
|
+
cap,
|
|
762
|
+
};
|
|
763
|
+
if (bounceKind === "heading")
|
|
764
|
+
return runHeadMove(deps, params, ctx);
|
|
765
|
+
if (bounceKind === "anytime")
|
|
766
|
+
return runLoosePark(deps, params, ctx);
|
|
767
|
+
return runProjPark(deps, params, ctx);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
93
770
|
const problems = [];
|
|
94
|
-
if (params.container !== undefined)
|
|
95
|
-
problems.push("container is only valid for
|
|
771
|
+
if (params.container !== undefined && !wantsContainer)
|
|
772
|
+
problems.push("container is only valid for the heading / area-someday / project scopes");
|
|
96
773
|
if (params.uuids.length === 0)
|
|
97
774
|
problems.push("no uuids given");
|
|
98
775
|
if (pre.duplicates.length > 0)
|
|
99
776
|
problems.push(`duplicated uuid(s): ${pre.duplicates.join(", ")}`);
|
|
777
|
+
// SIT7 SOMEBACK: the someday bounce is same-type only (loose to-dos front-insert on a
|
|
778
|
+
// different axis than area-less projects — a mixed wire list is unprobed), mirroring
|
|
779
|
+
// the native someday anchor-stack's same-type rule.
|
|
780
|
+
if (pre.mixedTypes) {
|
|
781
|
+
problems.push("a someday reorder must be all to-dos OR all projects (same-type only) — a mixed member set " +
|
|
782
|
+
"is unprobed");
|
|
783
|
+
}
|
|
100
784
|
for (const r of pre.rejected)
|
|
101
785
|
problems.push(`${r.uuid} ${r.reason}`);
|
|
102
|
-
if (
|
|
786
|
+
if (legOp === "todo.update") {
|
|
103
787
|
for (const uuid of pre.projectMembers) {
|
|
104
788
|
problems.push(`${uuid} is a project — bounce re-schedules via todo.update, which is only validated ` +
|
|
105
789
|
"for to-dos; use the native strategy for Today lists containing projects");
|
|
106
790
|
}
|
|
107
791
|
}
|
|
108
|
-
if (
|
|
109
|
-
problems.push(`${
|
|
110
|
-
"(each
|
|
792
|
+
if (coBounce.length > cap) {
|
|
793
|
+
problems.push(`${coBounce.length} touched items exceed the bounce cap of ${cap} ` +
|
|
794
|
+
"(each costs two verified mutations" +
|
|
795
|
+
(touchedUnnamed.length > 0
|
|
796
|
+
? `; the anchor placement re-inserts ${touchedUnnamed.length} unnamed sibling(s) too`
|
|
797
|
+
: "") +
|
|
798
|
+
")");
|
|
111
799
|
}
|
|
112
800
|
if (problems.length > 0) {
|
|
113
801
|
const result = {
|
|
@@ -117,7 +805,7 @@ async function runBounce(deps, params, options) {
|
|
|
117
805
|
hazard: "H-REORDER-SCOPE",
|
|
118
806
|
detail: `reorder request rejected: ${problems.join("; ")}`,
|
|
119
807
|
remediation: "read the scope first (things today) and pass only its eligible members, " +
|
|
120
|
-
`at most ${
|
|
808
|
+
`at most ${cap} for the bounce strategy (set with \`things config set bounce-max-items\`)`,
|
|
121
809
|
};
|
|
122
810
|
auditSummary(deps, params, startedAt, "blocked:H-REORDER-SCOPE", null, {
|
|
123
811
|
pre: preRanks,
|
|
@@ -126,9 +814,126 @@ async function runBounce(deps, params, options) {
|
|
|
126
814
|
});
|
|
127
815
|
return result;
|
|
128
816
|
}
|
|
129
|
-
|
|
130
|
-
|
|
817
|
+
// TMPLSORT/PTMPL template leg families (the `day` scope only — the native
|
|
818
|
+
// `tomorrow` one-call wire carries templates as ordinary members). A repeating
|
|
819
|
+
// template's projection is a first-class day-block member, but a dated when=/
|
|
820
|
+
// deadline leg CRASHES it (§1), so it NEVER receives one. Split per class:
|
|
821
|
+
// - TO-DO template: a single-id `list "Upcoming"` NATIVE front-insert leg
|
|
822
|
+
// (TMPLSORT-1), interleaved into the reverse-target dispatch on the shared
|
|
823
|
+
// block min-space (TMPLSORT-2). Needs the native surface.
|
|
824
|
+
// - PROJECT template: byte-UNTOUCHED under the SUFFIX RULE — it has no headless
|
|
825
|
+
// reach on an arbitrary future day (PTMPL-B: only `list "Tomorrow"` / a GUI
|
|
826
|
+
// drag place it), so every movable front-inserts ABOVE it. Accept only a wire
|
|
827
|
+
// where the project templates form a suffix in their CURRENT relative order;
|
|
828
|
+
// refuse otherwise, naming the one achievable arrangement.
|
|
829
|
+
// The whole day dispatch is experimental-gated when ANY template is present.
|
|
830
|
+
const memberInfo = new Map(pre.members.map((m) => [m.uuid, m]));
|
|
831
|
+
const isTmpl = (u) => memberInfo.get(u)?.isTemplate === true;
|
|
832
|
+
const isProjectTemplate = (u) => isTmpl(u) && memberInfo.get(u)?.type === 1;
|
|
833
|
+
const templatesPresent = bounceKind === "day" && coBounce.some(isTmpl);
|
|
834
|
+
let dispatchRun = coBounce;
|
|
835
|
+
if (templatesPresent) {
|
|
836
|
+
const dayIso = decodePackedDate(dayPacked) ?? "the target day";
|
|
837
|
+
const templateRefusal = (detail, remediation) => {
|
|
838
|
+
const result = {
|
|
839
|
+
kind: "blocked",
|
|
840
|
+
op: "reorder",
|
|
841
|
+
reason: "hazard",
|
|
842
|
+
hazard: "H-REORDER-SCOPE",
|
|
843
|
+
detail,
|
|
844
|
+
remediation,
|
|
845
|
+
};
|
|
846
|
+
auditSummary(deps, params, startedAt, "blocked:H-REORDER-SCOPE", null, {
|
|
847
|
+
pre: preRanks,
|
|
848
|
+
txnId,
|
|
849
|
+
actor,
|
|
850
|
+
});
|
|
851
|
+
return result;
|
|
852
|
+
};
|
|
853
|
+
if (!nativeReorderAvailable(deps)) {
|
|
854
|
+
// C(iii): refuse honestly, NAMING the templates — never a dated leg (crash),
|
|
855
|
+
// never a silent skip, never a partial sort.
|
|
856
|
+
const tmpls = coBounce.filter(isTmpl);
|
|
857
|
+
return templateRefusal(`the ${dayIso} day-group contains repeating template(s) [${tmpls.join(", ")}] whose ` +
|
|
858
|
+
"day-block placement needs the native private reorder surface (a dated when= leg CRASHES " +
|
|
859
|
+
`a template — §1/§9e), but it is unavailable (${nativeUnavailableReason(deps)})`, "enable it with `things config set allow-experimental true` (and keep Things updated so the " +
|
|
860
|
+
"sdef canary passes), or reorder the day-group without the template(s)");
|
|
861
|
+
}
|
|
862
|
+
// PROJECT-template SUFFIX RULE.
|
|
863
|
+
const projectTemplates = coBounce.filter(isProjectTemplate);
|
|
864
|
+
if (projectTemplates.length > 0) {
|
|
865
|
+
const suffix = coBounce.slice(coBounce.length - projectTemplates.length);
|
|
866
|
+
const suffixContiguous = suffix.every(isProjectTemplate);
|
|
867
|
+
// Their achievable order is their CURRENT todayIndex (render) order — untouched.
|
|
868
|
+
const currentOrder = projectTemplates.toSorted((a, b) => (memberInfo.get(a)?.rank ?? 0) - (memberInfo.get(b)?.rank ?? 0));
|
|
869
|
+
const orderPreserved = suffixContiguous && suffix.every((u, i) => u === currentOrder[i]);
|
|
870
|
+
if (!orderPreserved) {
|
|
871
|
+
const movables = coBounce.filter((u) => !isProjectTemplate(u));
|
|
872
|
+
const achievable = [...movables, ...currentOrder];
|
|
873
|
+
return templateRefusal(`a repeating PROJECT template cannot be placed above a movable item on the ${dayIso} ` +
|
|
874
|
+
"day-block, and project templates cannot change their relative order there — an arbitrary " +
|
|
875
|
+
"future day gives a project template no headless reach (PTMPL-B: only the Tomorrow one-call " +
|
|
876
|
+
"sort, when the day is tomorrow, or a GUI drag place it). The only arrangement this " +
|
|
877
|
+
`day-group can reach headlessly is: ${achievable.join(", ")} (every movable above the ` +
|
|
878
|
+
"project template(s), which keep their current relative order)", "request that arrangement (place the project template(s) last, in their current order), or " +
|
|
879
|
+
"drag the project template in the app; if the day is tomorrow, reorder on Tomorrow instead");
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
// Movables + to-do templates dispatch; project templates are the untouched suffix.
|
|
883
|
+
dispatchRun = coBounce.filter((u) => !isProjectTemplate(u));
|
|
884
|
+
}
|
|
885
|
+
// Front-insert contexts (loose/area-direct) place last-first (reverse iterate,
|
|
886
|
+
// unshift); back-insert contexts (heading/project children) place first-first
|
|
887
|
+
// (forward iterate, push). Either way `placed` holds the current top-to-bottom
|
|
888
|
+
// order of the bounced block. The SAME per-item order drives the json-array
|
|
889
|
+
// collapse (array order == result index order for both directions).
|
|
890
|
+
const order = direction === "front"
|
|
891
|
+
? dispatchRun.map((_, i) => dispatchRun.length - 1 - i)
|
|
892
|
+
: dispatchRun.map((_, i) => i);
|
|
893
|
+
// BOUNCEJSON collapse (§9i): when the placement (`back`) leg is when=anytime
|
|
894
|
+
// into a loose/heading-container bucket, the whole N-item round-trip collapses
|
|
895
|
+
// to ONE pre-validated `things:///json` array (2N ops, 1 dispatch, ~7×,
|
|
896
|
+
// validate-first FULL-ABORT). Only against a REAL dispatch surface — under the
|
|
897
|
+
// simulator (simulates=true, per-op appliers, no json-array applier) we keep
|
|
898
|
+
// the proven sequential legs.
|
|
899
|
+
// The json collapse is only ever eligible for a fixed-op, jsonCollapsible spec;
|
|
900
|
+
// per-type (day/evening) and every dated bounce stay on the sequential URL loop.
|
|
901
|
+
const dispatchVector = spec.jsonCollapsible && legOp !== "per-type" ? pickDispatchVector(deps, legOp) : undefined;
|
|
902
|
+
const useJson = spec.jsonCollapsible && dispatchVector !== undefined && dispatchVector.simulates !== true;
|
|
131
903
|
if (options.dryRun === true) {
|
|
904
|
+
// Name the per-row-class legs. A `day` set may mix SCHEDULED rows (when= bounce)
|
|
905
|
+
// and DEADLINE-FORECAST rows (the DLBNC deadline-cycle: deadline= clear + re-set),
|
|
906
|
+
// so the plan discloses the leg family per class + count; every other bounce is a
|
|
907
|
+
// single when= round-trip.
|
|
908
|
+
let legNaming = `when=${awayValue} → when=${backValue}`;
|
|
909
|
+
if (bounceKind === "day") {
|
|
910
|
+
const ttN = coBounce.filter((u) => isTmpl(u) && !isProjectTemplate(u)).length;
|
|
911
|
+
const ptN = coBounce.filter(isProjectTemplate).length;
|
|
912
|
+
const fN = coBounce.filter((u) => !isTmpl(u) && forecastLegOf(u) !== null).length;
|
|
913
|
+
const sN = coBounce.length - fN - ttN - ptN;
|
|
914
|
+
const bits = [];
|
|
915
|
+
if (sN > 0)
|
|
916
|
+
bits.push(`${sN} scheduled via when=${awayValue} → when=${backValue}`);
|
|
917
|
+
if (fN > 0)
|
|
918
|
+
bits.push(`${fN} deadline-forecast via deadline-cycle (deadline= clear + re-set)`);
|
|
919
|
+
if (ttN > 0)
|
|
920
|
+
bits.push(`${ttN} to-do template(s) via single-id \`list "Upcoming"\` front-insert (umd-silent)`);
|
|
921
|
+
if (ptN > 0)
|
|
922
|
+
bits.push(`${ptN} project template(s) left byte-untouched (suffix rule — no headless reach)`);
|
|
923
|
+
legNaming = bits.join("; ");
|
|
924
|
+
}
|
|
925
|
+
const invocation = useJson
|
|
926
|
+
? `json-collapse ×${coBounce.length} (${direction}-insert, ` +
|
|
927
|
+
`${direction === "front" ? "reverse" : "forward"} array order, ` +
|
|
928
|
+
(touchedUnnamed.length > 0 ? `touches ${touchedUnnamed.length} unnamed sibling(s), ` : "") +
|
|
929
|
+
`1 dispatch / ${coBounce.length * 2} ops): ` +
|
|
930
|
+
`${legNaming} interleaved per item; validate-first full-abort, ` +
|
|
931
|
+
`one terminal order verify`
|
|
932
|
+
: `bounce ×${coBounce.length} (${direction}-insert, ` +
|
|
933
|
+
`${direction === "front" ? "reverse" : "forward"} order, ` +
|
|
934
|
+
(touchedUnnamed.length > 0 ? `touches ${touchedUnnamed.length} unnamed sibling(s), ` : "") +
|
|
935
|
+
`${coBounce.length * 2} legs): ` +
|
|
936
|
+
`${legNaming}; one verify per item round-trip`;
|
|
132
937
|
return {
|
|
133
938
|
kind: "dry-run",
|
|
134
939
|
op: "reorder",
|
|
@@ -136,21 +941,34 @@ async function runBounce(deps, params, options) {
|
|
|
136
941
|
op: "reorder",
|
|
137
942
|
vector: "url-scheme",
|
|
138
943
|
tier: 0,
|
|
139
|
-
invocation
|
|
140
|
-
|
|
141
|
-
"state re-checked between items",
|
|
142
|
-
expectedDelta: { mode: "ordering", key: rankKey, sequence: params.uuids },
|
|
944
|
+
invocation,
|
|
945
|
+
expectedDelta: { mode: "ordering", key: rankKey, sequence: coBounce },
|
|
143
946
|
hazardsChecked: ["H-REORDER-SCOPE"],
|
|
144
947
|
},
|
|
145
948
|
};
|
|
146
949
|
}
|
|
147
|
-
|
|
148
|
-
|
|
950
|
+
if (useJson) {
|
|
951
|
+
return runBounceJsonCollapse(deps, params, spec, {
|
|
952
|
+
coBounce,
|
|
953
|
+
order,
|
|
954
|
+
bounceKind,
|
|
955
|
+
containerUuid,
|
|
956
|
+
preRanks,
|
|
957
|
+
txnId,
|
|
958
|
+
actor,
|
|
959
|
+
touchedUnnamed,
|
|
960
|
+
startedAt,
|
|
961
|
+
options,
|
|
962
|
+
vector: dispatchVector,
|
|
963
|
+
});
|
|
964
|
+
}
|
|
149
965
|
const placed = [];
|
|
150
|
-
for (let
|
|
151
|
-
const
|
|
966
|
+
for (let step = 0; step < order.length; step++) {
|
|
967
|
+
const i = order[step];
|
|
968
|
+
const uuid = dispatchRun[i];
|
|
969
|
+
const remainingBefore = () => direction === "front" ? dispatchRun.slice(0, i + 1) : dispatchRun.slice(i);
|
|
152
970
|
// Concurrent-edit re-check: the item must still be an eligible member.
|
|
153
|
-
const memberProblem = checkStillMember(deps, uuid,
|
|
971
|
+
const memberProblem = checkStillMember(deps, uuid, bounceKind, containerUuid, now(), dayPacked);
|
|
154
972
|
if (memberProblem !== null) {
|
|
155
973
|
const detail = `aborted before bouncing ${uuid}: ${memberProblem} (Things was likely edited ` +
|
|
156
974
|
"concurrently); already-placed items keep their new positions";
|
|
@@ -160,38 +978,104 @@ async function runBounce(deps, params, options) {
|
|
|
160
978
|
op: "reorder",
|
|
161
979
|
detail,
|
|
162
980
|
placed: [...placed],
|
|
163
|
-
remaining:
|
|
981
|
+
remaining: remainingBefore(),
|
|
164
982
|
cause: null,
|
|
165
983
|
};
|
|
166
984
|
}
|
|
167
|
-
//
|
|
168
|
-
|
|
985
|
+
// A repeating TO-DO template rides ONE native single-id `list "Upcoming"` front-
|
|
986
|
+
// insert leg (TMPLSORT-1) — NEVER a dated when=/deadline leg (§1 crash). It front-
|
|
987
|
+
// inserts on the SAME shared block min-space as the when=/deadline families, so the
|
|
988
|
+
// reverse-target dispatch interleaves it exactly (TMPLSORT-2). Project templates are
|
|
989
|
+
// excluded from `dispatchRun` (the untouched suffix), so only to-do templates reach
|
|
990
|
+
// this branch; the leg is umd-silent (§9r — disclosed in the result note).
|
|
991
|
+
if (isTmpl(uuid)) {
|
|
992
|
+
const tmplLeg = await runMutation(deps, "reorder", { scope: "upcoming", uuids: [uuid], named: [uuid] }, legOptions(options, txnId));
|
|
993
|
+
if (tmplLeg.kind !== "ok") {
|
|
994
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...placed] }, { pre: preRanks, txnId, actor });
|
|
995
|
+
return {
|
|
996
|
+
kind: "bounce-aborted",
|
|
997
|
+
op: "reorder",
|
|
998
|
+
detail: `the native \`list "Upcoming"\` front-insert leg failed for repeating template ${uuid} ` +
|
|
999
|
+
"— it was NOT moved (its position is unchanged)",
|
|
1000
|
+
placed: [...placed],
|
|
1001
|
+
remaining: remainingBefore(),
|
|
1002
|
+
cause: tmplLeg,
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
if (direction === "front")
|
|
1006
|
+
placed.unshift(uuid);
|
|
1007
|
+
else
|
|
1008
|
+
placed.push(uuid);
|
|
1009
|
+
const tmplPrefix = await pollUntilVerified(() => evaluateDelta({ mode: "ordering", key: rankKey, sequence: [...placed] }, createDbReader(deps.db), { modDates: {}, fields: {} }), options.verifyTimeoutMs ?? 4000, deps.poller ?? {});
|
|
1010
|
+
if (tmplPrefix.kind !== "ok") {
|
|
1011
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...placed] }, { pre: preRanks, txnId, actor });
|
|
1012
|
+
return {
|
|
1013
|
+
kind: "bounce-aborted",
|
|
1014
|
+
op: "reorder",
|
|
1015
|
+
detail: `placed items fell out of order after front-inserting template ${uuid} (concurrent ` +
|
|
1016
|
+
"edit?); re-run the reorder once Things is idle",
|
|
1017
|
+
placed: [...placed],
|
|
1018
|
+
remaining: direction === "front" ? dispatchRun.slice(0, i) : dispatchRun.slice(i + 1),
|
|
1019
|
+
cause: null,
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
// leg 1 sends the item AWAY from its resting bucket; leg 2 returns it — the
|
|
1025
|
+
// return leg is what front-inserts (BOUNCE2 re-entry law). SCHEDULED rows use
|
|
1026
|
+
// the `when=` round-trip; a day-scope DEADLINE-FORECAST row (startDate NULL,
|
|
1027
|
+
// deadline set) uses the DLBNC deadline-cycle instead: leg 1 CLEARS the deadline
|
|
1028
|
+
// (transiently off the block), leg 2 RE-SETS the same deadline byte-identical
|
|
1029
|
+
// (front-inserts on the block todayIndex axis). BOTH classes pick the leg op PER
|
|
1030
|
+
// ROW TYPE — `todo.update` (→ `update?…`) for a to-do, `project.update` (→
|
|
1031
|
+
// `update-project?…`) for a project — so a forecast PROJECT rides the #385-
|
|
1032
|
+
// certified `update-project?deadline=` cycle (PROJDL-2b, PROJSTAR-safe). Both
|
|
1033
|
+
// classes front-insert at the day's global min, so one reverse-target pass
|
|
1034
|
+
// interleaves them exactly (o-suite forecast-cycle + mixed-interleave locks).
|
|
1035
|
+
const forecast = forecastLegOf(uuid);
|
|
1036
|
+
const rowLegOp = opForRow(uuid);
|
|
1037
|
+
const awayLabel = forecast !== null ? "deadline= clear" : `when=${awayValue}`;
|
|
1038
|
+
const backLabel = forecast !== null ? "deadline= re-set" : `when=${backValue}`;
|
|
1039
|
+
const leg1 = forecast !== null
|
|
1040
|
+
? await runMutation(deps, rowLegOp, { uuid, deadline: null }, legOptions(options, txnId))
|
|
1041
|
+
: await runMutation(deps, rowLegOp, { uuid, when: awayValue }, legOptions(options, txnId));
|
|
169
1042
|
if (leg1.kind !== "ok") {
|
|
170
1043
|
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...placed] }, { pre: preRanks, txnId, actor });
|
|
171
1044
|
return {
|
|
172
1045
|
kind: "bounce-aborted",
|
|
173
1046
|
op: "reorder",
|
|
174
|
-
detail: `bounce leg 1 (
|
|
1047
|
+
detail: `bounce leg 1 (${awayLabel}) failed for ${uuid} — the item was NOT moved`,
|
|
175
1048
|
placed: [...placed],
|
|
176
|
-
remaining:
|
|
1049
|
+
remaining: remainingBefore(),
|
|
177
1050
|
cause: leg1,
|
|
178
1051
|
};
|
|
179
1052
|
}
|
|
180
1053
|
// leg 2 must follow leg 1's committed state for the same item before the next item's bounce begins
|
|
181
|
-
const leg2 =
|
|
1054
|
+
const leg2 = forecast !== null
|
|
1055
|
+
? await runMutation(deps, rowLegOp, { uuid, deadline: forecast.iso }, legOptions(options, txnId))
|
|
1056
|
+
: await runMutation(deps, rowLegOp, { uuid, when: backValue }, legOptions(options, txnId));
|
|
182
1057
|
if (leg2.kind !== "ok") {
|
|
183
1058
|
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...placed] }, { pre: preRanks, txnId, actor });
|
|
1059
|
+
const strandedState = forecast !== null
|
|
1060
|
+
? "left DEADLINE-LESS (transiently off the day-block)"
|
|
1061
|
+
: `STRANDED IN ${String(awayValue).toUpperCase()}`;
|
|
1062
|
+
const recover = forecast !== null
|
|
1063
|
+
? `re-set its deadline (${forecast.iso})`
|
|
1064
|
+
: `re-schedule it (when=${backValue})`;
|
|
184
1065
|
return {
|
|
185
1066
|
kind: "bounce-aborted",
|
|
186
1067
|
op: "reorder",
|
|
187
|
-
detail: `bounce leg 2 (
|
|
188
|
-
`${
|
|
1068
|
+
detail: `bounce leg 2 (${backLabel}) failed for ${uuid} — THE ITEM IS ${strandedState}; ` +
|
|
1069
|
+
`${recover} or fix in the app`,
|
|
189
1070
|
placed: [...placed],
|
|
190
|
-
remaining:
|
|
1071
|
+
remaining: remainingBefore(),
|
|
191
1072
|
cause: leg2,
|
|
192
1073
|
};
|
|
193
1074
|
}
|
|
194
|
-
|
|
1075
|
+
if (direction === "front")
|
|
1076
|
+
placed.unshift(uuid);
|
|
1077
|
+
else
|
|
1078
|
+
placed.push(uuid);
|
|
195
1079
|
// Placed-prefix invariant: everything bounced so far must read back in
|
|
196
1080
|
// requested relative order — anything else means a concurrent reshuffle.
|
|
197
1081
|
// the placed-prefix must be verified after each item before bouncing the next, so a concurrent reshuffle is caught immediately rather than compounded
|
|
@@ -204,16 +1088,35 @@ async function runBounce(deps, params, options) {
|
|
|
204
1088
|
detail: `placed items fell out of order after bouncing ${uuid} (concurrent edit?); ` +
|
|
205
1089
|
"re-run the reorder once Things is idle",
|
|
206
1090
|
placed: [...placed],
|
|
207
|
-
remaining:
|
|
1091
|
+
remaining: direction === "front" ? dispatchRun.slice(0, i) : dispatchRun.slice(i + 1),
|
|
208
1092
|
cause: null,
|
|
209
1093
|
};
|
|
210
1094
|
}
|
|
211
1095
|
}
|
|
212
1096
|
const reader = createDbReader(deps.db);
|
|
213
1097
|
const observed = {};
|
|
214
|
-
for (const uuid of
|
|
1098
|
+
for (const uuid of coBounce)
|
|
215
1099
|
observed[uuid] = reader.rankOf(uuid, rankKey);
|
|
216
1100
|
auditSummary(deps, params, startedAt, "ok", observed, { pre: preRanks, txnId, actor });
|
|
1101
|
+
// §9r disclosure: a template's `list "Upcoming"` front-insert leg is
|
|
1102
|
+
// userModificationDate-SILENT (a umd-diffing sync/watcher misses it), and a project
|
|
1103
|
+
// template left as the untouched suffix moved not at all — surface both so the caller
|
|
1104
|
+
// never mistakes a umd-silent placement for a no-op.
|
|
1105
|
+
const warnings = [];
|
|
1106
|
+
if (templatesPresent) {
|
|
1107
|
+
const tt = coBounce.filter((u) => isTmpl(u) && !isProjectTemplate(u));
|
|
1108
|
+
const pt = coBounce.filter(isProjectTemplate);
|
|
1109
|
+
if (tt.length > 0) {
|
|
1110
|
+
warnings.push(`${tt.length} repeating to-do template(s) were front-inserted via \`list "Upcoming"\` and ` +
|
|
1111
|
+
"are userModificationDate-SILENT (a umd-diffing sync/watcher will not see the move): " +
|
|
1112
|
+
tt.join(", "));
|
|
1113
|
+
}
|
|
1114
|
+
if (pt.length > 0) {
|
|
1115
|
+
warnings.push(`${pt.length} repeating project template(s) were left byte-untouched as the day-block suffix ` +
|
|
1116
|
+
"(no headless reach on this day — every movable was placed above them): " +
|
|
1117
|
+
pt.join(", "));
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
217
1120
|
return {
|
|
218
1121
|
kind: "ok",
|
|
219
1122
|
op: "reorder",
|
|
@@ -224,6 +1127,1027 @@ async function runBounce(deps, params, options) {
|
|
|
224
1127
|
// A bounce reorder is a summary txn, so its token is the txn id (matches
|
|
225
1128
|
// the audit record's undoToken); pass it to `things undo --txn <token>`.
|
|
226
1129
|
undoToken: txnId,
|
|
1130
|
+
...(warnings.length > 0 && { warnings }),
|
|
1131
|
+
// Co-bounced siblings the anchor placement re-inserted (honest disclosure).
|
|
1132
|
+
...(touchedUnnamed.length > 0 && { touched: touchedUnnamed }),
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
/** A unique-enough scratch-container title suffix (opId-ish). */
|
|
1136
|
+
function scratchSuffix(startedAt) {
|
|
1137
|
+
return `${startedAt.getTime().toString(36)}-${randomBytes(3).toString("hex")}`;
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Pre-ranks over the FULL touched set on the `index` axis — the flag-carrying
|
|
1141
|
+
* movees are NOT scope members (a Today flag makes startDate non-null, so the
|
|
1142
|
+
* bounce's `pre.members` excludes them), so the undoable summary must capture
|
|
1143
|
+
* every coBounce row's prior `index` directly, not just `pre.members`.
|
|
1144
|
+
*/
|
|
1145
|
+
function captureIndexRanks(deps, uuids) {
|
|
1146
|
+
const reader = createDbReader(deps.db);
|
|
1147
|
+
const pre = {};
|
|
1148
|
+
for (const uuid of uuids)
|
|
1149
|
+
pre[uuid] = reader.rankOf(uuid, "index");
|
|
1150
|
+
return pre;
|
|
1151
|
+
}
|
|
1152
|
+
/** Abort payload for a flag-safe protocol (placed/remaining + recovery detail). */
|
|
1153
|
+
function swapAborted(detail, placed, remaining, cause) {
|
|
1154
|
+
return { kind: "bounce-aborted", op: "reorder", detail, placed, remaining, cause };
|
|
1155
|
+
}
|
|
1156
|
+
/** A H-REORDER-SCOPE block from a flag-safe protocol (records the summary). */
|
|
1157
|
+
function swapBlocked(deps, params, ctx, preRanks, detail, remediation) {
|
|
1158
|
+
const result = {
|
|
1159
|
+
kind: "blocked",
|
|
1160
|
+
op: "reorder",
|
|
1161
|
+
reason: "hazard",
|
|
1162
|
+
hazard: "H-REORDER-SCOPE",
|
|
1163
|
+
detail,
|
|
1164
|
+
remediation,
|
|
1165
|
+
};
|
|
1166
|
+
auditSummary(deps, params, ctx.startedAt, "blocked:H-REORDER-SCOPE", null, {
|
|
1167
|
+
pre: preRanks,
|
|
1168
|
+
txnId: ctx.txnId,
|
|
1169
|
+
actor: ctx.actor,
|
|
1170
|
+
});
|
|
1171
|
+
return result;
|
|
1172
|
+
}
|
|
1173
|
+
/** Terminal ordering verify over the touched run on the `index` axis. */
|
|
1174
|
+
async function verifyIndexOrder(deps, coBounce, options) {
|
|
1175
|
+
return pollUntilVerified(() => evaluateDelta({ mode: "ordering", key: "index", sequence: coBounce }, createDbReader(deps.db), {
|
|
1176
|
+
modDates: {},
|
|
1177
|
+
fields: {},
|
|
1178
|
+
}), options.verifyTimeoutMs ?? 4000, deps.poller ?? {});
|
|
1179
|
+
}
|
|
1180
|
+
/** The OK result shape shared by the three protocols. */
|
|
1181
|
+
function swapOk(deps, coBounce, ctx, warnings) {
|
|
1182
|
+
const reader = createDbReader(deps.db);
|
|
1183
|
+
const observed = {};
|
|
1184
|
+
for (const uuid of coBounce)
|
|
1185
|
+
observed[uuid] = reader.rankOf(uuid, "index");
|
|
1186
|
+
return {
|
|
1187
|
+
kind: "ok",
|
|
1188
|
+
op: "reorder",
|
|
1189
|
+
uuid: null,
|
|
1190
|
+
observed,
|
|
1191
|
+
vector: "url-scheme",
|
|
1192
|
+
tier: 0,
|
|
1193
|
+
undoToken: ctx.txnId,
|
|
1194
|
+
...(warnings !== undefined && warnings.length > 0 && { warnings }),
|
|
1195
|
+
...(ctx.touchedUnnamed.length > 0 && { touched: ctx.touchedUnnamed }),
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* HEADMOVE (SIT6) — a heading's flag-carrying anytime children: UNHEAD the whole
|
|
1200
|
+
* touched run (clean — heading→NULL, `index` + flag preserved), then RE-HEAD it in
|
|
1201
|
+
* FORWARD target order — each now-loose row BACK-INSERTS past the heading-bucket
|
|
1202
|
+
* `index` max, so forward-order re-heads land the exact order (the shipped
|
|
1203
|
+
* `heading-someday` mechanism, now for the ANYTIME class + proven flag-safe). Two
|
|
1204
|
+
* `todo.move` URL legs per item (`list-id=<p>` then `list-id=<p>&heading=<h>`), no
|
|
1205
|
+
* when= leg, no json collapse. Non-atomic: a mid-fail leaves rows UNHEADED in the
|
|
1206
|
+
* project root, disclosed (same discipline as heading-someday).
|
|
1207
|
+
*/
|
|
1208
|
+
async function runHeadMove(deps, params, ctx) {
|
|
1209
|
+
const { coBounce, containerUuid, txnId, actor, touchedUnnamed, startedAt, options, cap } = ctx;
|
|
1210
|
+
const headingUuid = containerUuid;
|
|
1211
|
+
const projectUuid = headingProjectUuid(deps, headingUuid);
|
|
1212
|
+
const preRanks = captureIndexRanks(deps, coBounce);
|
|
1213
|
+
const problems = [];
|
|
1214
|
+
if (headingUuid === null || projectUuid === null) {
|
|
1215
|
+
problems.push("the heading did not resolve to a project (re-head needs the heading's project)");
|
|
1216
|
+
}
|
|
1217
|
+
if (coBounce.length > cap) {
|
|
1218
|
+
problems.push(`${coBounce.length} touched items exceed the cap of ${cap} (each costs an unhead + re-head leg` +
|
|
1219
|
+
(touchedUnnamed.length > 0
|
|
1220
|
+
? `; ${touchedUnnamed.length} unnamed heading sibling(s) are co-moved to honor the order`
|
|
1221
|
+
: "") +
|
|
1222
|
+
")");
|
|
1223
|
+
}
|
|
1224
|
+
if (problems.length > 0) {
|
|
1225
|
+
return swapBlocked(deps, params, ctx, preRanks, `within-heading flag-safe reorder rejected: ${problems.join("; ")}`, "reorder the anytime children of ONE heading (read the project first), " +
|
|
1226
|
+
`at most ${cap} touched (set with \`things config set bounce-max-items\`)`);
|
|
1227
|
+
}
|
|
1228
|
+
if (options.dryRun === true) {
|
|
1229
|
+
return {
|
|
1230
|
+
kind: "dry-run",
|
|
1231
|
+
op: "reorder",
|
|
1232
|
+
plan: {
|
|
1233
|
+
op: "reorder",
|
|
1234
|
+
vector: "url-scheme",
|
|
1235
|
+
tier: 0,
|
|
1236
|
+
invocation: `HEADMOVE unhead ×${coBounce.length} → re-head ×${coBounce.length} (flag-safe back-insert, ` +
|
|
1237
|
+
`forward order${touchedUnnamed.length > 0 ? `, touches ${touchedUnnamed.length} unnamed sibling(s)` : ""}): ` +
|
|
1238
|
+
"a flagged movee's when= bounce would de-Today it, so each child is unheaded (index + flag " +
|
|
1239
|
+
"preserved) then re-headed to append at the heading-bucket end; one terminal order verify",
|
|
1240
|
+
expectedDelta: { mode: "ordering", key: "index", sequence: coBounce },
|
|
1241
|
+
hazardsChecked: ["H-REORDER-SCOPE"],
|
|
1242
|
+
},
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
const legOpts = compoundLegOptions(options, txnId);
|
|
1246
|
+
// 1. UNHEAD each touched member (clean — heading→NULL, index + flag preserved).
|
|
1247
|
+
const unheaded = [];
|
|
1248
|
+
for (const uuid of coBounce) {
|
|
1249
|
+
const res = await runMutation(deps, "todo.move", { uuid, noHeading: true }, legOpts);
|
|
1250
|
+
if (res.kind !== "ok") {
|
|
1251
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...unheaded] }, {
|
|
1252
|
+
pre: preRanks,
|
|
1253
|
+
txnId,
|
|
1254
|
+
actor,
|
|
1255
|
+
});
|
|
1256
|
+
return swapAborted(`unheading ${uuid} failed — ${unheaded.length} item(s) are UNHEADED in project ` +
|
|
1257
|
+
`${projectUuid} and must be moved back under the heading manually`, unheaded, coBounce.slice(unheaded.length), res);
|
|
1258
|
+
}
|
|
1259
|
+
unheaded.push(uuid);
|
|
1260
|
+
}
|
|
1261
|
+
// 2. RE-HEAD in forward target order (each now-loose row back-inserts at the end).
|
|
1262
|
+
const placed = [];
|
|
1263
|
+
for (const uuid of coBounce) {
|
|
1264
|
+
const res = await runMutation(deps, "todo.move", { uuid, project: { uuid: projectUuid }, heading: headingUuid }, legOpts);
|
|
1265
|
+
if (res.kind !== "ok") {
|
|
1266
|
+
const stillUnheaded = coBounce.filter((u) => !placed.includes(u));
|
|
1267
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...placed] }, {
|
|
1268
|
+
pre: preRanks,
|
|
1269
|
+
txnId,
|
|
1270
|
+
actor,
|
|
1271
|
+
});
|
|
1272
|
+
return swapAborted(`re-heading ${uuid} failed — ${stillUnheaded.length} item(s) remain UNHEADED in project ` +
|
|
1273
|
+
`${projectUuid} (ordered) and must be moved back under the heading manually`, placed, stillUnheaded, res);
|
|
1274
|
+
}
|
|
1275
|
+
placed.push(uuid);
|
|
1276
|
+
}
|
|
1277
|
+
const verify = await verifyIndexOrder(deps, coBounce, options);
|
|
1278
|
+
if (verify.kind !== "ok") {
|
|
1279
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...placed] }, {
|
|
1280
|
+
pre: preRanks,
|
|
1281
|
+
txnId,
|
|
1282
|
+
actor,
|
|
1283
|
+
});
|
|
1284
|
+
return swapAborted("the heading did not land the requested order after re-heading; re-run once Things is idle", placed, [], null);
|
|
1285
|
+
}
|
|
1286
|
+
auditSummary(deps, params, startedAt, "ok", swapObserved(deps, coBounce), {
|
|
1287
|
+
pre: preRanks,
|
|
1288
|
+
txnId,
|
|
1289
|
+
actor,
|
|
1290
|
+
});
|
|
1291
|
+
return swapOk(deps, coBounce, ctx);
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* LOOSEPARK (SIT6) — flag-carrying area-less loose anytime to-dos: create a scratch
|
|
1295
|
+
* PROJECT, PARK every touched row into it (any order), then UNPARK in REVERSE target
|
|
1296
|
+
* order — each unpark (`list-id=` empty) FRONT-INSERTS at the loose Anytime `index`
|
|
1297
|
+
* min in dispatch order, so a reverse-target dispatch lands the exact order (the
|
|
1298
|
+
* central SIT6 law; NO in-scratch reorder needed). Verify the scratch is EMPTY, then
|
|
1299
|
+
* TRASH it (shallow project trash). Non-atomic: a mid-fail leaves rows PARKED in the
|
|
1300
|
+
* NAMED scratch project (recovery text); the scratch is NEVER trashed while non-empty
|
|
1301
|
+
* (AREADEL — that would Trash the parked rows).
|
|
1302
|
+
*/
|
|
1303
|
+
async function runLoosePark(deps, params, ctx) {
|
|
1304
|
+
const { coBounce, txnId, actor, touchedUnnamed, startedAt, options, cap } = ctx;
|
|
1305
|
+
const preRanks = captureIndexRanks(deps, coBounce);
|
|
1306
|
+
if (coBounce.length > cap) {
|
|
1307
|
+
return swapBlocked(deps, params, ctx, preRanks, `area-less loose anytime flag-safe reorder rejected: ${coBounce.length} touched items exceed ` +
|
|
1308
|
+
`the cap of ${cap} (each costs a park + unpark leg` +
|
|
1309
|
+
(touchedUnnamed.length > 0
|
|
1310
|
+
? `; ${touchedUnnamed.length} co-touched loose sibling(s)`
|
|
1311
|
+
: "") +
|
|
1312
|
+
")", `reorder at most ${cap} loose Anytime to-dos (set with \`things config set bounce-max-items\`)`);
|
|
1313
|
+
}
|
|
1314
|
+
if (options.dryRun === true) {
|
|
1315
|
+
return {
|
|
1316
|
+
kind: "dry-run",
|
|
1317
|
+
op: "reorder",
|
|
1318
|
+
plan: {
|
|
1319
|
+
op: "reorder",
|
|
1320
|
+
vector: "url-scheme",
|
|
1321
|
+
tier: 0,
|
|
1322
|
+
invocation: `LOOSEPARK scratch project + park ×${coBounce.length} + unpark ×${coBounce.length} ` +
|
|
1323
|
+
`(flag-safe front-insert, reverse target order` +
|
|
1324
|
+
(touchedUnnamed.length > 0
|
|
1325
|
+
? `, touches ${touchedUnnamed.length} unnamed sibling(s)`
|
|
1326
|
+
: "") +
|
|
1327
|
+
"; trash the empty scratch): a flagged movee's when= bounce would de-Today it, so each row " +
|
|
1328
|
+
"is parked into a scratch project then unparked to front-insert at the loose min; one terminal " +
|
|
1329
|
+
"order verify",
|
|
1330
|
+
expectedDelta: { mode: "ordering", key: "index", sequence: coBounce },
|
|
1331
|
+
hazardsChecked: ["H-REORDER-SCOPE"],
|
|
1332
|
+
},
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
const legOpts = compoundLegOptions(options, txnId);
|
|
1336
|
+
const scratchTitle = `things-api reorder-anytime ${scratchSuffix(startedAt)}`;
|
|
1337
|
+
// 1. Create the scratch PROJECT.
|
|
1338
|
+
const add = await runMutation(deps, "project.add", { title: scratchTitle }, legOpts);
|
|
1339
|
+
if (add.kind !== "ok" || add.uuid === null) {
|
|
1340
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [] }, {
|
|
1341
|
+
pre: preRanks,
|
|
1342
|
+
txnId,
|
|
1343
|
+
actor,
|
|
1344
|
+
});
|
|
1345
|
+
return swapAborted(`could not create the scratch project "${scratchTitle}" — nothing was parked; no changes were made`, [], coBounce, add.kind === "ok" ? null : add);
|
|
1346
|
+
}
|
|
1347
|
+
const scratch = add.uuid;
|
|
1348
|
+
// 2. PARK each touched row into the scratch project (any order).
|
|
1349
|
+
const parked = [];
|
|
1350
|
+
for (const uuid of coBounce) {
|
|
1351
|
+
const res = await runMutation(deps, "todo.move", { uuid, project: { uuid: scratch } }, legOpts);
|
|
1352
|
+
if (res.kind !== "ok") {
|
|
1353
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...parked] }, {
|
|
1354
|
+
pre: preRanks,
|
|
1355
|
+
txnId,
|
|
1356
|
+
actor,
|
|
1357
|
+
});
|
|
1358
|
+
return swapAborted(`parking ${uuid} into scratch project ${scratch} failed — ${parked.length} item(s) are PARKED ` +
|
|
1359
|
+
`there (${scratch}) and must be moved back to the loose Anytime list manually; the scratch ` +
|
|
1360
|
+
"project was NOT trashed", parked, coBounce.slice(parked.length), res);
|
|
1361
|
+
}
|
|
1362
|
+
parked.push(uuid);
|
|
1363
|
+
}
|
|
1364
|
+
// 3. UNPARK in REVERSE target order — front-insert lands the target order.
|
|
1365
|
+
const dispatch = coBounce.toReversed();
|
|
1366
|
+
const unparked = [];
|
|
1367
|
+
for (const uuid of dispatch) {
|
|
1368
|
+
const res = await runMutation(deps, "todo.move", { uuid, loose: true }, legOpts);
|
|
1369
|
+
if (res.kind !== "ok") {
|
|
1370
|
+
const stillParked = dispatch.filter((u) => !unparked.includes(u));
|
|
1371
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...unparked] }, {
|
|
1372
|
+
pre: preRanks,
|
|
1373
|
+
txnId,
|
|
1374
|
+
actor,
|
|
1375
|
+
});
|
|
1376
|
+
return swapAborted(`unparking ${uuid} from scratch project ${scratch} failed — ${stillParked.length} item(s) ` +
|
|
1377
|
+
`remain PARKED in ${scratch} and must be moved back to the loose Anytime list manually; the ` +
|
|
1378
|
+
"scratch project was NOT trashed", unparked, stillParked, res);
|
|
1379
|
+
}
|
|
1380
|
+
unparked.push(uuid);
|
|
1381
|
+
}
|
|
1382
|
+
// 4. Verify the scratch is EMPTY, then trash it (NEVER trash a non-empty scratch).
|
|
1383
|
+
const remaining = countProjectChildren(deps, scratch);
|
|
1384
|
+
if (remaining > 0) {
|
|
1385
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...unparked] }, {
|
|
1386
|
+
pre: preRanks,
|
|
1387
|
+
txnId,
|
|
1388
|
+
actor,
|
|
1389
|
+
});
|
|
1390
|
+
return swapAborted(`the scratch project ${scratch} still holds ${remaining} parked item(s) after unparking — ` +
|
|
1391
|
+
"refusing to trash it (trashing a non-empty scratch would send them to the Trash, AREADEL); " +
|
|
1392
|
+
`move them back to the loose Anytime list and delete ${scratch} manually`, unparked, [], null);
|
|
1393
|
+
}
|
|
1394
|
+
const del = await runMutation(deps, "project.delete", { uuid: scratch }, legOpts);
|
|
1395
|
+
const scratchTrashed = del.kind === "ok";
|
|
1396
|
+
// 5. Terminal verify: the loose Anytime order matches the target.
|
|
1397
|
+
const verify = await verifyIndexOrder(deps, coBounce, options);
|
|
1398
|
+
if (verify.kind !== "ok") {
|
|
1399
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...unparked] }, {
|
|
1400
|
+
pre: preRanks,
|
|
1401
|
+
txnId,
|
|
1402
|
+
actor,
|
|
1403
|
+
});
|
|
1404
|
+
return swapAborted("the loose Anytime list did not land the requested order after unparking (scratch project " +
|
|
1405
|
+
`${scratch} was ${scratchTrashed ? "trashed" : "left in place"}); re-run once Things is idle`, unparked, [], null);
|
|
1406
|
+
}
|
|
1407
|
+
auditSummary(deps, params, startedAt, "ok", swapObserved(deps, coBounce), {
|
|
1408
|
+
pre: preRanks,
|
|
1409
|
+
txnId,
|
|
1410
|
+
actor,
|
|
1411
|
+
});
|
|
1412
|
+
return swapOk(deps, coBounce, ctx, [
|
|
1413
|
+
`scratch project ${scratch} was created for the reorder and ` +
|
|
1414
|
+
(scratchTrashed
|
|
1415
|
+
? "moved to the Trash (verified empty first — the protocol never trashes a non-empty scratch)"
|
|
1416
|
+
: `could NOT be trashed (${del.kind}) — it remains in your project list empty; delete it manually`),
|
|
1417
|
+
]);
|
|
1418
|
+
}
|
|
1419
|
+
/**
|
|
1420
|
+
* PROJPARK (SIT6) — flag-carrying area-less sidebar projects: create a scratch AREA,
|
|
1421
|
+
* PARK each project into it (`area-id=` leg), then DETACH in REVERSE target order —
|
|
1422
|
+
* each detach (`area-id=` empty) FRONT-INSERTS at the area-less project `index` min in
|
|
1423
|
+
* dispatch order, so a reverse-target dispatch lands the exact order (stars intact).
|
|
1424
|
+
* Verify the scratch area is EMPTY, then DELETE it — the delete supplies its own
|
|
1425
|
+
* H-PERMANENT-DELETE acknowledgement INTERNALLY (this transaction created the area and
|
|
1426
|
+
* has just verified it empty); it NEVER deletes a non-empty area (AREADEL would Trash
|
|
1427
|
+
* the parked projects + shallow-trash their children). Replaces the #351 de-Today
|
|
1428
|
+
* refusal for flagged movees. Non-atomic: a mid-fail leaves projects PARKED in the
|
|
1429
|
+
* NAMED scratch area (recovery text).
|
|
1430
|
+
*/
|
|
1431
|
+
async function runProjPark(deps, params, ctx) {
|
|
1432
|
+
const { coBounce, txnId, actor, touchedUnnamed, startedAt, options, cap } = ctx;
|
|
1433
|
+
const preRanks = captureIndexRanks(deps, coBounce);
|
|
1434
|
+
if (coBounce.length > cap) {
|
|
1435
|
+
return swapBlocked(deps, params, ctx, preRanks, `top-level projects flag-safe reorder rejected: ${coBounce.length} touched items exceed the cap ` +
|
|
1436
|
+
`of ${cap} (each costs a park + detach leg` +
|
|
1437
|
+
(touchedUnnamed.length > 0
|
|
1438
|
+
? `; ${touchedUnnamed.length} co-touched sidebar sibling(s)`
|
|
1439
|
+
: "") +
|
|
1440
|
+
")", `reorder at most ${cap} top-level projects (set with \`things config set bounce-max-items\`)`);
|
|
1441
|
+
}
|
|
1442
|
+
if (options.dryRun === true) {
|
|
1443
|
+
return {
|
|
1444
|
+
kind: "dry-run",
|
|
1445
|
+
op: "reorder",
|
|
1446
|
+
plan: {
|
|
1447
|
+
op: "reorder",
|
|
1448
|
+
vector: "url-scheme",
|
|
1449
|
+
tier: 0,
|
|
1450
|
+
invocation: `PROJPARK scratch area + park ×${coBounce.length} + detach ×${coBounce.length} ` +
|
|
1451
|
+
`(flag-safe front-insert, reverse target order` +
|
|
1452
|
+
(touchedUnnamed.length > 0
|
|
1453
|
+
? `, touches ${touchedUnnamed.length} unnamed sibling(s)`
|
|
1454
|
+
: "") +
|
|
1455
|
+
"; delete the empty scratch area): a flagged project's when= bounce would de-star it, so each " +
|
|
1456
|
+
"project is parked into a scratch area then detached to front-insert at the sidebar min; one " +
|
|
1457
|
+
"terminal order verify",
|
|
1458
|
+
expectedDelta: { mode: "ordering", key: "index", sequence: coBounce },
|
|
1459
|
+
hazardsChecked: ["H-REORDER-SCOPE"],
|
|
1460
|
+
},
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
const legOpts = compoundLegOptions(options, txnId);
|
|
1464
|
+
const scratchTitle = `things-api reorder-projects ${scratchSuffix(startedAt)}`;
|
|
1465
|
+
// 1. Create the scratch AREA.
|
|
1466
|
+
const add = await runMutation(deps, "area.add", { title: scratchTitle }, legOpts);
|
|
1467
|
+
if (add.kind !== "ok" || add.uuid === null) {
|
|
1468
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [] }, {
|
|
1469
|
+
pre: preRanks,
|
|
1470
|
+
txnId,
|
|
1471
|
+
actor,
|
|
1472
|
+
});
|
|
1473
|
+
return swapAborted(`could not create the scratch area "${scratchTitle}" — nothing was parked; no changes were made`, [], coBounce, add.kind === "ok" ? null : add);
|
|
1474
|
+
}
|
|
1475
|
+
const scratch = add.uuid;
|
|
1476
|
+
// 2. PARK each project into the scratch area (any order).
|
|
1477
|
+
const parked = [];
|
|
1478
|
+
for (const uuid of coBounce) {
|
|
1479
|
+
const res = await runMutation(deps, "project.move", { uuid, area: { uuid: scratch } }, legOpts);
|
|
1480
|
+
if (res.kind !== "ok") {
|
|
1481
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...parked] }, {
|
|
1482
|
+
pre: preRanks,
|
|
1483
|
+
txnId,
|
|
1484
|
+
actor,
|
|
1485
|
+
});
|
|
1486
|
+
return swapAborted(`parking project ${uuid} into scratch area ${scratch} failed — ${parked.length} project(s) are ` +
|
|
1487
|
+
`PARKED there (${scratch}) and must be moved back to the sidebar manually; the scratch area was ` +
|
|
1488
|
+
"NOT deleted", parked, coBounce.slice(parked.length), res);
|
|
1489
|
+
}
|
|
1490
|
+
parked.push(uuid);
|
|
1491
|
+
}
|
|
1492
|
+
// 3. DETACH in REVERSE target order — front-insert lands the target order.
|
|
1493
|
+
const dispatch = coBounce.toReversed();
|
|
1494
|
+
const detached = [];
|
|
1495
|
+
for (const uuid of dispatch) {
|
|
1496
|
+
const res = await runMutation(deps, "project.move", { uuid, noArea: true }, legOpts);
|
|
1497
|
+
if (res.kind !== "ok") {
|
|
1498
|
+
const stillParked = dispatch.filter((u) => !detached.includes(u));
|
|
1499
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...detached] }, {
|
|
1500
|
+
pre: preRanks,
|
|
1501
|
+
txnId,
|
|
1502
|
+
actor,
|
|
1503
|
+
});
|
|
1504
|
+
return swapAborted(`detaching project ${uuid} from scratch area ${scratch} failed — ${stillParked.length} ` +
|
|
1505
|
+
`project(s) remain PARKED in ${scratch} and must be moved back to the sidebar manually; the ` +
|
|
1506
|
+
"scratch area was NOT deleted", detached, stillParked, res);
|
|
1507
|
+
}
|
|
1508
|
+
detached.push(uuid);
|
|
1509
|
+
}
|
|
1510
|
+
// 4. Verify the scratch area is EMPTY, then delete it (NEVER delete a non-empty area).
|
|
1511
|
+
const remaining = countAreaMembers(deps, scratch);
|
|
1512
|
+
if (remaining > 0) {
|
|
1513
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...detached] }, {
|
|
1514
|
+
pre: preRanks,
|
|
1515
|
+
txnId,
|
|
1516
|
+
actor,
|
|
1517
|
+
});
|
|
1518
|
+
return swapAborted(`the scratch area ${scratch} still holds ${remaining} parked project(s) after detaching — ` +
|
|
1519
|
+
"refusing to delete it (deleting a non-empty area TRASHES its projects and shallow-trashes their " +
|
|
1520
|
+
`children, AREADEL); move them back to the sidebar and delete ${scratch} manually`, detached, [], null);
|
|
1521
|
+
}
|
|
1522
|
+
// The delete supplies H-PERMANENT-DELETE internally (created + verified-empty this
|
|
1523
|
+
// txn); H-AREA-NOT-EMPTY does not trip on the verified-empty area. Never
|
|
1524
|
+
// acknowledge H-AREA-NOT-EMPTY — a non-empty area aborts above, never deletes.
|
|
1525
|
+
const del = await runMutation(deps, "area.delete", { target: scratch }, { ...legOpts, dangerouslyPermanent: true });
|
|
1526
|
+
const scratchDeleted = del.kind === "ok";
|
|
1527
|
+
// 5. Terminal verify: the sidebar order matches the target.
|
|
1528
|
+
const verify = await verifyIndexOrder(deps, coBounce, options);
|
|
1529
|
+
if (verify.kind !== "ok") {
|
|
1530
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...detached] }, {
|
|
1531
|
+
pre: preRanks,
|
|
1532
|
+
txnId,
|
|
1533
|
+
actor,
|
|
1534
|
+
});
|
|
1535
|
+
return swapAborted("the top-level projects did not land the requested order after detaching (scratch area " +
|
|
1536
|
+
`${scratch} was ${scratchDeleted ? "deleted" : "left in place"}); re-run once Things is idle`, detached, [], null);
|
|
1537
|
+
}
|
|
1538
|
+
auditSummary(deps, params, startedAt, "ok", swapObserved(deps, coBounce), {
|
|
1539
|
+
pre: preRanks,
|
|
1540
|
+
txnId,
|
|
1541
|
+
actor,
|
|
1542
|
+
});
|
|
1543
|
+
return swapOk(deps, coBounce, ctx, [
|
|
1544
|
+
`scratch area ${scratch} was created for the reorder and ` +
|
|
1545
|
+
(scratchDeleted
|
|
1546
|
+
? "deleted (verified empty first — the protocol never deletes a non-empty area)"
|
|
1547
|
+
: `could NOT be deleted (${del.kind}) — it remains in your sidebar empty; delete it manually`),
|
|
1548
|
+
]);
|
|
1549
|
+
}
|
|
1550
|
+
/** Observed `index` ranks over the touched run (for the audit summary). */
|
|
1551
|
+
function swapObserved(deps, coBounce) {
|
|
1552
|
+
const reader = createDbReader(deps.db);
|
|
1553
|
+
const observed = {};
|
|
1554
|
+
for (const uuid of coBounce)
|
|
1555
|
+
observed[uuid] = reader.rankOf(uuid, "index");
|
|
1556
|
+
return observed;
|
|
1557
|
+
}
|
|
1558
|
+
/** Live (open, non-trashed) direct child count of a project (scratch emptiness). */
|
|
1559
|
+
function countProjectChildren(deps, projectUuid) {
|
|
1560
|
+
const row = deps.db
|
|
1561
|
+
.prepare("SELECT COUNT(*) AS n FROM TMTask WHERE trashed = 0 AND status = 0 AND " +
|
|
1562
|
+
"(project = ? OR heading IN (SELECT uuid FROM TMTask WHERE type = 2 AND project = ?))")
|
|
1563
|
+
.get(projectUuid, projectUuid);
|
|
1564
|
+
return row.n;
|
|
1565
|
+
}
|
|
1566
|
+
/** Live member count of an area (direct to-dos + projects) — scratch emptiness. */
|
|
1567
|
+
function countAreaMembers(deps, areaUuid) {
|
|
1568
|
+
const row = deps.db
|
|
1569
|
+
.prepare("SELECT COUNT(*) AS n FROM TMTask WHERE trashed = 0 AND status = 0 AND area = ?")
|
|
1570
|
+
.get(areaUuid);
|
|
1571
|
+
return row.n;
|
|
1572
|
+
}
|
|
1573
|
+
/** A row's type (0 = to-do, 1 = project, 2 = heading), or null when it is gone. */
|
|
1574
|
+
function rowTypeOf(deps, uuid) {
|
|
1575
|
+
const row = deps.db.prepare("SELECT type FROM TMTask WHERE uuid = ?").get(uuid);
|
|
1576
|
+
return row?.type ?? null;
|
|
1577
|
+
}
|
|
1578
|
+
// -------------------------------------------- SIT7 automatic MOVE fallbacks
|
|
1579
|
+
//
|
|
1580
|
+
// The non-experimental park + re-enter protocols the native-only reorder scopes
|
|
1581
|
+
// degrade to when the private `_private_experimental_ reorder` command is
|
|
1582
|
+
// unavailable (allow-experimental off OR the sdef canary fails) — the LIVE
|
|
1583
|
+
// automatic backups SIT7 proved (docs/lab/sit7-backup-laws.md). Every leg is a
|
|
1584
|
+
// URL/AppleScript MOVE (`list-id=`/`area-id=`/`move … to list "Inbox"`) — NO
|
|
1585
|
+
// when= leg, NO private reorder surface — so the Today/Evening flag + reminder +
|
|
1586
|
+
// deadline + FKs all survive the sort. Re-entry geometry follows the destination's
|
|
1587
|
+
// CONTAINMENT class (SIT7's general law): LOOSE-like buckets FRONT-insert (reverse
|
|
1588
|
+
// target dispatch), CONTAINERS BACK-insert (forward target dispatch):
|
|
1589
|
+
// - INBOXBACK (inbox): park each row into a scratch PROJECT, then re-enter
|
|
1590
|
+
// `move … to list "Inbox"` in REVERSE target order — FRONT-inserts and
|
|
1591
|
+
// RESTORES start=0 (a same-list `move … to "Inbox"` is a no-op, so park-first
|
|
1592
|
+
// is mandatory — the inbox cousin of §9l).
|
|
1593
|
+
// - PROJROOT (project): park each unheaded child into a scratch PROJECT, then
|
|
1594
|
+
// re-home `list-id=<P>` in FORWARD target order — BACK-inserts at the project-
|
|
1595
|
+
// root max (a project root behaves like a heading container). ONE protocol for
|
|
1596
|
+
// ALL rows (flagged or not) — the move round-trip is proven flag-safe.
|
|
1597
|
+
// - AREABACK (area): park each member OUT (a to-do into a scratch PROJECT, a project
|
|
1598
|
+
// into a scratch AREA), then re-home to the area in REVERSE target order —
|
|
1599
|
+
// FRONT-inserts at the area's member min (an area behaves like a loose bucket),
|
|
1600
|
+
// the area FK preserved.
|
|
1601
|
+
// They share the SIT6 move family's gate (bounce-enabled) and cap (bounce-max-items),
|
|
1602
|
+
// pre-ranks (undo via a single inverse reorder), named-scratch abort disclosure,
|
|
1603
|
+
// verify-empty teardown (AREADEL — never trash/delete a non-empty scratch), and
|
|
1604
|
+
// placement-honesty `touched` disclosure. Non-atomic: a mid-protocol failure leaves
|
|
1605
|
+
// rows in a disclosed transient state and fails loudly with placed/remaining detail.
|
|
1606
|
+
/**
|
|
1607
|
+
* Compute the touched run + build the {@link SwapCtx} for a SIT7 move fallback,
|
|
1608
|
+
* validate scope membership, and dispatch to the matching protocol. Unlike the
|
|
1609
|
+
* SIT6 flag-swap (whose movees are flag-EXCLUDED from `pre.members`), the SIT7
|
|
1610
|
+
* fallbacks operate on TRUE scope members, so duplicates/rejected/mixed-type are
|
|
1611
|
+
* validated here exactly as the bounce path validates them.
|
|
1612
|
+
*/
|
|
1613
|
+
async function runMoveFallback(deps, params, fallback, options) {
|
|
1614
|
+
const startedAt = deps.now?.() ?? new Date();
|
|
1615
|
+
const now = deps.now ?? (() => new Date());
|
|
1616
|
+
const actor = options.actor ?? deps.config.actor;
|
|
1617
|
+
const cap = deps.config.bounceMaxItems ?? BOUNCE_MAX_ITEMS;
|
|
1618
|
+
const containerUuid = resolveContainerUuid(deps, params);
|
|
1619
|
+
const txnId = `txn-${startedAt.getTime().toString(36)}-${process.pid.toString(36)}`;
|
|
1620
|
+
const pre = computeReorderPre(deps.db, params, containerUuid, now());
|
|
1621
|
+
// proj-root BACK-inserts (forward dispatch → suffix from the first named slot);
|
|
1622
|
+
// inbox/area FRONT-insert (reverse dispatch → prefix to the last named slot).
|
|
1623
|
+
const direction = fallback === "proj-root" ? "back" : "front";
|
|
1624
|
+
const targetOrder = pre.wireList;
|
|
1625
|
+
const named = new Set(params.named ?? params.uuids);
|
|
1626
|
+
const movedPositions = targetOrder.map((u, i) => (named.has(u) ? i : -1)).filter((i) => i >= 0);
|
|
1627
|
+
const firstMoved = movedPositions.length > 0 ? movedPositions[0] : 0;
|
|
1628
|
+
const lastMoved = movedPositions.length > 0 ? movedPositions[movedPositions.length - 1] : 0;
|
|
1629
|
+
const coBounce = direction === "back" ? targetOrder.slice(firstMoved) : targetOrder.slice(0, lastMoved + 1);
|
|
1630
|
+
const touchedUnnamed = coBounce.filter((u) => !named.has(u));
|
|
1631
|
+
const ctx = {
|
|
1632
|
+
coBounce,
|
|
1633
|
+
containerUuid,
|
|
1634
|
+
txnId,
|
|
1635
|
+
actor,
|
|
1636
|
+
touchedUnnamed,
|
|
1637
|
+
startedAt,
|
|
1638
|
+
options,
|
|
1639
|
+
cap,
|
|
1640
|
+
};
|
|
1641
|
+
const preRanks = captureIndexRanks(deps, coBounce);
|
|
1642
|
+
const problems = [];
|
|
1643
|
+
if (params.uuids.length === 0)
|
|
1644
|
+
problems.push("no uuids given");
|
|
1645
|
+
if (pre.duplicates.length > 0)
|
|
1646
|
+
problems.push(`duplicated uuid(s): ${pre.duplicates.join(", ")}`);
|
|
1647
|
+
for (const r of pre.rejected)
|
|
1648
|
+
problems.push(`${r.uuid} ${r.reason}`);
|
|
1649
|
+
if (pre.mixedTypes) {
|
|
1650
|
+
problems.push("an area reorder must be all to-dos OR all projects — a mixed member set is unprobed");
|
|
1651
|
+
}
|
|
1652
|
+
if (problems.length > 0) {
|
|
1653
|
+
return swapBlocked(deps, params, ctx, preRanks, `reorder request rejected: ${problems.join("; ")}`, "read the scope first and pass only its eligible members, at most " +
|
|
1654
|
+
`${cap} for the fallback protocol (set with \`things config set bounce-max-items\`)`);
|
|
1655
|
+
}
|
|
1656
|
+
switch (fallback) {
|
|
1657
|
+
case "inbox-park":
|
|
1658
|
+
return runInboxPark(deps, params, ctx);
|
|
1659
|
+
case "proj-root":
|
|
1660
|
+
return runProjectRoot(deps, params, ctx);
|
|
1661
|
+
case "area-back":
|
|
1662
|
+
return runAreaBack(deps, params, ctx);
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
/**
|
|
1666
|
+
* INBOXBACK (SIT7) — Inbox order without the private surface: create a scratch
|
|
1667
|
+
* PROJECT, PARK every touched row into it (`list-id=<scratch>` flips start 0→1
|
|
1668
|
+
* transiently), then re-enter `move to do id X to list "Inbox"` in REVERSE target
|
|
1669
|
+
* order — each re-entry FRONT-INSERTS at the inbox `index` min AND RESTORES start=0
|
|
1670
|
+
* (project→NULL), so a reverse-target dispatch lands the exact order. The inbox
|
|
1671
|
+
* return leg is AppleScript-only (`move … to list "Inbox"`; #356) — pinned here.
|
|
1672
|
+
* Verify the scratch is EMPTY, then TRASH it. Non-atomic: a mid-fail leaves rows
|
|
1673
|
+
* PARKED in the NAMED scratch project (recovery text); never trashes a non-empty
|
|
1674
|
+
* scratch (AREADEL).
|
|
1675
|
+
*/
|
|
1676
|
+
async function runInboxPark(deps, params, ctx) {
|
|
1677
|
+
const { coBounce, txnId, actor, touchedUnnamed, startedAt, options, cap } = ctx;
|
|
1678
|
+
const preRanks = captureIndexRanks(deps, coBounce);
|
|
1679
|
+
if (coBounce.length > cap) {
|
|
1680
|
+
return swapBlocked(deps, params, ctx, preRanks, `Inbox order fallback rejected: ${coBounce.length} touched items exceed the cap of ${cap} ` +
|
|
1681
|
+
`(each costs a park + Inbox-return leg${touchedUnnamed.length > 0 ? `; ${touchedUnnamed.length} co-touched inbox sibling(s)` : ""})`, `reorder at most ${cap} inbox to-dos (set with \`things config set bounce-max-items\`)`);
|
|
1682
|
+
}
|
|
1683
|
+
if (options.dryRun === true) {
|
|
1684
|
+
return {
|
|
1685
|
+
kind: "dry-run",
|
|
1686
|
+
op: "reorder",
|
|
1687
|
+
plan: {
|
|
1688
|
+
op: "reorder",
|
|
1689
|
+
vector: "url-scheme",
|
|
1690
|
+
tier: 0,
|
|
1691
|
+
invocation: `INBOXBACK scratch project + park ×${coBounce.length} + Inbox-return ×${coBounce.length} ` +
|
|
1692
|
+
`(front-insert, reverse target order${touchedUnnamed.length > 0 ? `, touches ${touchedUnnamed.length} unnamed sibling(s)` : ""}; ` +
|
|
1693
|
+
'trash the empty scratch): a same-list `move … to "Inbox"` is a no-op, so each row is ' +
|
|
1694
|
+
"parked into a scratch project then re-entered to the Inbox to front-insert (restoring " +
|
|
1695
|
+
"start=0); one terminal order verify",
|
|
1696
|
+
expectedDelta: { mode: "ordering", key: "index", sequence: coBounce },
|
|
1697
|
+
hazardsChecked: ["H-REORDER-SCOPE"],
|
|
1698
|
+
},
|
|
1699
|
+
};
|
|
1700
|
+
}
|
|
1701
|
+
const legOpts = compoundLegOptions(options, txnId);
|
|
1702
|
+
const scratchTitle = `things-api reorder-inbox ${scratchSuffix(startedAt)}`;
|
|
1703
|
+
// 1. Create the scratch PROJECT.
|
|
1704
|
+
const add = await runMutation(deps, "project.add", { title: scratchTitle }, legOpts);
|
|
1705
|
+
if (add.kind !== "ok" || add.uuid === null) {
|
|
1706
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [] }, {
|
|
1707
|
+
pre: preRanks,
|
|
1708
|
+
txnId,
|
|
1709
|
+
actor,
|
|
1710
|
+
});
|
|
1711
|
+
return swapAborted(`could not create the scratch project "${scratchTitle}" — nothing was parked; no changes were made`, [], coBounce, add.kind === "ok" ? null : add);
|
|
1712
|
+
}
|
|
1713
|
+
const scratch = add.uuid;
|
|
1714
|
+
// 2. PARK each touched row into the scratch project (any order).
|
|
1715
|
+
const parked = [];
|
|
1716
|
+
for (const uuid of coBounce) {
|
|
1717
|
+
const res = await runMutation(deps, "todo.move", { uuid, project: { uuid: scratch } }, legOpts);
|
|
1718
|
+
if (res.kind !== "ok") {
|
|
1719
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...parked] }, {
|
|
1720
|
+
pre: preRanks,
|
|
1721
|
+
txnId,
|
|
1722
|
+
actor,
|
|
1723
|
+
});
|
|
1724
|
+
return swapAborted(`parking ${uuid} into scratch project ${scratch} failed — ${parked.length} item(s) are PARKED ` +
|
|
1725
|
+
`there (${scratch}) and must be moved back to the Inbox manually; the scratch project was ` +
|
|
1726
|
+
"NOT trashed", parked, coBounce.slice(parked.length), res);
|
|
1727
|
+
}
|
|
1728
|
+
parked.push(uuid);
|
|
1729
|
+
}
|
|
1730
|
+
// 3. RE-ENTER `move … to list "Inbox"` (AppleScript-only, #356) in REVERSE target
|
|
1731
|
+
// order — front-insert restores start=0 and lands the target order.
|
|
1732
|
+
const dispatch = coBounce.toReversed();
|
|
1733
|
+
const returned = [];
|
|
1734
|
+
for (const uuid of dispatch) {
|
|
1735
|
+
const res = await runMutation(deps, "todo.move", { uuid, inbox: true }, { ...legOpts, vector: "applescript" });
|
|
1736
|
+
if (res.kind !== "ok") {
|
|
1737
|
+
const stillParked = dispatch.filter((u) => !returned.includes(u));
|
|
1738
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...returned] }, {
|
|
1739
|
+
pre: preRanks,
|
|
1740
|
+
txnId,
|
|
1741
|
+
actor,
|
|
1742
|
+
});
|
|
1743
|
+
return swapAborted(`returning ${uuid} to the Inbox from scratch project ${scratch} failed — ${stillParked.length} ` +
|
|
1744
|
+
`item(s) remain PARKED in ${scratch} and must be moved back to the Inbox manually; the ` +
|
|
1745
|
+
"scratch project was NOT trashed", returned, stillParked, res);
|
|
1746
|
+
}
|
|
1747
|
+
returned.push(uuid);
|
|
1748
|
+
}
|
|
1749
|
+
// 4. Verify the scratch is EMPTY, then trash it (NEVER trash a non-empty scratch).
|
|
1750
|
+
const remaining = countProjectChildren(deps, scratch);
|
|
1751
|
+
if (remaining > 0) {
|
|
1752
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...returned] }, {
|
|
1753
|
+
pre: preRanks,
|
|
1754
|
+
txnId,
|
|
1755
|
+
actor,
|
|
1756
|
+
});
|
|
1757
|
+
return swapAborted(`the scratch project ${scratch} still holds ${remaining} parked item(s) after the Inbox return — ` +
|
|
1758
|
+
"refusing to trash it (trashing a non-empty scratch would send them to the Trash, AREADEL); " +
|
|
1759
|
+
`move them back to the Inbox and delete ${scratch} manually`, returned, [], null);
|
|
1760
|
+
}
|
|
1761
|
+
const del = await runMutation(deps, "project.delete", { uuid: scratch }, legOpts);
|
|
1762
|
+
const scratchTrashed = del.kind === "ok";
|
|
1763
|
+
// 5. Terminal verify: the inbox order matches the target.
|
|
1764
|
+
const verify = await verifyIndexOrder(deps, coBounce, options);
|
|
1765
|
+
if (verify.kind !== "ok") {
|
|
1766
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...returned] }, {
|
|
1767
|
+
pre: preRanks,
|
|
1768
|
+
txnId,
|
|
1769
|
+
actor,
|
|
1770
|
+
});
|
|
1771
|
+
return swapAborted("the Inbox did not land the requested order after the return (scratch project " +
|
|
1772
|
+
`${scratch} was ${scratchTrashed ? "trashed" : "left in place"}); re-run once Things is idle`, returned, [], null);
|
|
1773
|
+
}
|
|
1774
|
+
auditSummary(deps, params, startedAt, "ok", swapObserved(deps, coBounce), {
|
|
1775
|
+
pre: preRanks,
|
|
1776
|
+
txnId,
|
|
1777
|
+
actor,
|
|
1778
|
+
});
|
|
1779
|
+
return swapOk(deps, coBounce, ctx, [
|
|
1780
|
+
`scratch project ${scratch} was created for the reorder and ` +
|
|
1781
|
+
(scratchTrashed
|
|
1782
|
+
? "moved to the Trash (verified empty first — the protocol never trashes a non-empty scratch)"
|
|
1783
|
+
: `could NOT be trashed (${del.kind}) — it remains in your project list empty; delete it manually`),
|
|
1784
|
+
]);
|
|
1785
|
+
}
|
|
1786
|
+
/**
|
|
1787
|
+
* PROJROOT (SIT7) — a project's unheaded children without the private surface: create
|
|
1788
|
+
* a scratch PROJECT, PARK every touched child into it (`list-id=<scratch>`), then
|
|
1789
|
+
* re-home to the original project (`list-id=<P>`, no heading) in FORWARD target order
|
|
1790
|
+
* — each re-home BACK-INSERTS at the project-root `index` max (a project root behaves
|
|
1791
|
+
* like a heading container), so a forward-target dispatch lands the exact order. ONE
|
|
1792
|
+
* protocol for ALL rows (flagged or not) — the move round-trip is proven flag-safe.
|
|
1793
|
+
* Verify the scratch is EMPTY, then TRASH it. Non-atomic: a mid-fail leaves children
|
|
1794
|
+
* PARKED in the NAMED scratch project (recovery text).
|
|
1795
|
+
*/
|
|
1796
|
+
async function runProjectRoot(deps, params, ctx) {
|
|
1797
|
+
const { coBounce, containerUuid, txnId, actor, touchedUnnamed, startedAt, options, cap } = ctx;
|
|
1798
|
+
const preRanks = captureIndexRanks(deps, coBounce);
|
|
1799
|
+
if (containerUuid === null) {
|
|
1800
|
+
return swapBlocked(deps, params, ctx, preRanks, "the project did not resolve (the re-home leg needs the project container)", "pass the project by uuid or a unique title (`--project`)");
|
|
1801
|
+
}
|
|
1802
|
+
if (coBounce.length > cap) {
|
|
1803
|
+
return swapBlocked(deps, params, ctx, preRanks, `within-project order fallback rejected: ${coBounce.length} touched items exceed the cap of ${cap} ` +
|
|
1804
|
+
`(each costs a park + re-home leg${touchedUnnamed.length > 0 ? `; ${touchedUnnamed.length} co-touched child sibling(s)` : ""})`, `reorder at most ${cap} of a project's unheaded children (set with \`things config set bounce-max-items\`)`);
|
|
1805
|
+
}
|
|
1806
|
+
if (options.dryRun === true) {
|
|
1807
|
+
return {
|
|
1808
|
+
kind: "dry-run",
|
|
1809
|
+
op: "reorder",
|
|
1810
|
+
plan: {
|
|
1811
|
+
op: "reorder",
|
|
1812
|
+
vector: "url-scheme",
|
|
1813
|
+
tier: 0,
|
|
1814
|
+
invocation: `PROJROOT scratch project + park ×${coBounce.length} + re-home ×${coBounce.length} ` +
|
|
1815
|
+
`(flag-safe back-insert, forward target order${touchedUnnamed.length > 0 ? `, touches ${touchedUnnamed.length} unnamed sibling(s)` : ""}; ` +
|
|
1816
|
+
"trash the empty scratch): the private reorder is unavailable, so each child is parked into a " +
|
|
1817
|
+
"scratch project then re-homed to the project root to back-insert at its end; one terminal " +
|
|
1818
|
+
"order verify",
|
|
1819
|
+
expectedDelta: { mode: "ordering", key: "index", sequence: coBounce },
|
|
1820
|
+
hazardsChecked: ["H-REORDER-SCOPE"],
|
|
1821
|
+
},
|
|
1822
|
+
};
|
|
1823
|
+
}
|
|
1824
|
+
const legOpts = compoundLegOptions(options, txnId);
|
|
1825
|
+
const scratchTitle = `things-api reorder-project ${scratchSuffix(startedAt)}`;
|
|
1826
|
+
// 1. Create the scratch PROJECT.
|
|
1827
|
+
const add = await runMutation(deps, "project.add", { title: scratchTitle }, legOpts);
|
|
1828
|
+
if (add.kind !== "ok" || add.uuid === null) {
|
|
1829
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [] }, {
|
|
1830
|
+
pre: preRanks,
|
|
1831
|
+
txnId,
|
|
1832
|
+
actor,
|
|
1833
|
+
});
|
|
1834
|
+
return swapAborted(`could not create the scratch project "${scratchTitle}" — nothing was parked; no changes were made`, [], coBounce, add.kind === "ok" ? null : add);
|
|
1835
|
+
}
|
|
1836
|
+
const scratch = add.uuid;
|
|
1837
|
+
// 2. PARK each child into the scratch project (any order).
|
|
1838
|
+
const parked = [];
|
|
1839
|
+
for (const uuid of coBounce) {
|
|
1840
|
+
const res = await runMutation(deps, "todo.move", { uuid, project: { uuid: scratch } }, legOpts);
|
|
1841
|
+
if (res.kind !== "ok") {
|
|
1842
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...parked] }, {
|
|
1843
|
+
pre: preRanks,
|
|
1844
|
+
txnId,
|
|
1845
|
+
actor,
|
|
1846
|
+
});
|
|
1847
|
+
return swapAborted(`parking ${uuid} into scratch project ${scratch} failed — ${parked.length} child(ren) are PARKED ` +
|
|
1848
|
+
`there (${scratch}) and must be moved back to project ${containerUuid} manually; the scratch ` +
|
|
1849
|
+
"project was NOT trashed", parked, coBounce.slice(parked.length), res);
|
|
1850
|
+
}
|
|
1851
|
+
parked.push(uuid);
|
|
1852
|
+
}
|
|
1853
|
+
// 3. RE-HOME to the project root in FORWARD target order — back-insert lands the target.
|
|
1854
|
+
const rehomed = [];
|
|
1855
|
+
for (const uuid of coBounce) {
|
|
1856
|
+
const res = await runMutation(deps, "todo.move", { uuid, project: { uuid: containerUuid } }, legOpts);
|
|
1857
|
+
if (res.kind !== "ok") {
|
|
1858
|
+
const stillParked = coBounce.filter((u) => !rehomed.includes(u));
|
|
1859
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...rehomed] }, {
|
|
1860
|
+
pre: preRanks,
|
|
1861
|
+
txnId,
|
|
1862
|
+
actor,
|
|
1863
|
+
});
|
|
1864
|
+
return swapAborted(`re-homing ${uuid} to project ${containerUuid} from scratch project ${scratch} failed — ` +
|
|
1865
|
+
`${stillParked.length} child(ren) remain PARKED in ${scratch} and must be moved back to ` +
|
|
1866
|
+
`project ${containerUuid} manually; the scratch project was NOT trashed`, rehomed, stillParked, res);
|
|
1867
|
+
}
|
|
1868
|
+
rehomed.push(uuid);
|
|
1869
|
+
}
|
|
1870
|
+
// 4. Verify the scratch is EMPTY, then trash it (NEVER trash a non-empty scratch).
|
|
1871
|
+
const remaining = countProjectChildren(deps, scratch);
|
|
1872
|
+
if (remaining > 0) {
|
|
1873
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...rehomed] }, {
|
|
1874
|
+
pre: preRanks,
|
|
1875
|
+
txnId,
|
|
1876
|
+
actor,
|
|
1877
|
+
});
|
|
1878
|
+
return swapAborted(`the scratch project ${scratch} still holds ${remaining} parked child(ren) after re-homing — ` +
|
|
1879
|
+
"refusing to trash it (trashing a non-empty scratch would send them to the Trash, AREADEL); " +
|
|
1880
|
+
`move them back to project ${containerUuid} and delete ${scratch} manually`, rehomed, [], null);
|
|
1881
|
+
}
|
|
1882
|
+
const del = await runMutation(deps, "project.delete", { uuid: scratch }, legOpts);
|
|
1883
|
+
const scratchTrashed = del.kind === "ok";
|
|
1884
|
+
// 5. Terminal verify: the project-root order matches the target.
|
|
1885
|
+
const verify = await verifyIndexOrder(deps, coBounce, options);
|
|
1886
|
+
if (verify.kind !== "ok") {
|
|
1887
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...rehomed] }, {
|
|
1888
|
+
pre: preRanks,
|
|
1889
|
+
txnId,
|
|
1890
|
+
actor,
|
|
1891
|
+
});
|
|
1892
|
+
return swapAborted("the project's children did not land the requested order after re-homing (scratch project " +
|
|
1893
|
+
`${scratch} was ${scratchTrashed ? "trashed" : "left in place"}); re-run once Things is idle`, rehomed, [], null);
|
|
1894
|
+
}
|
|
1895
|
+
auditSummary(deps, params, startedAt, "ok", swapObserved(deps, coBounce), {
|
|
1896
|
+
pre: preRanks,
|
|
1897
|
+
txnId,
|
|
1898
|
+
actor,
|
|
1899
|
+
});
|
|
1900
|
+
return swapOk(deps, coBounce, ctx, [
|
|
1901
|
+
`scratch project ${scratch} was created for the reorder and ` +
|
|
1902
|
+
(scratchTrashed
|
|
1903
|
+
? "moved to the Trash (verified empty first — the protocol never trashes a non-empty scratch)"
|
|
1904
|
+
: `could NOT be trashed (${del.kind}) — it remains in your project list empty; delete it manually`),
|
|
1905
|
+
]);
|
|
1906
|
+
}
|
|
1907
|
+
/**
|
|
1908
|
+
* AREABACK (SIT7) — an area's members without the private surface. Park each member
|
|
1909
|
+
* OUT of the area (a to-do into a scratch PROJECT, a project into a scratch AREA),
|
|
1910
|
+
* then re-home to the area (`list-id=<area>` for a to-do, `area-id=<area>` for a
|
|
1911
|
+
* project) in REVERSE target order — each re-home FRONT-INSERTS at the area's member
|
|
1912
|
+
* `index` min (an area behaves like a loose bucket) with the area FK preserved, so a
|
|
1913
|
+
* reverse-target dispatch lands the exact order, flag-safe. The area scope is uniform-
|
|
1914
|
+
* type (mixed sets are rejected upstream), so the whole run is one kind. Verify the
|
|
1915
|
+
* scratch is EMPTY, then trash/delete it (the area delete supplies its own
|
|
1916
|
+
* H-PERMANENT-DELETE acknowledgement internally — created + verified-empty this txn).
|
|
1917
|
+
* Non-atomic: a mid-fail leaves members PARKED in the NAMED scratch container.
|
|
1918
|
+
*/
|
|
1919
|
+
async function runAreaBack(deps, params, ctx) {
|
|
1920
|
+
const { coBounce, containerUuid, txnId, actor, touchedUnnamed, startedAt, options, cap } = ctx;
|
|
1921
|
+
const preRanks = captureIndexRanks(deps, coBounce);
|
|
1922
|
+
if (containerUuid === null) {
|
|
1923
|
+
return swapBlocked(deps, params, ctx, preRanks, "the area did not resolve (the re-home leg needs the area container)", "pass the area by uuid or a unique title (`--area`)");
|
|
1924
|
+
}
|
|
1925
|
+
const isProjects = rowTypeOf(deps, coBounce[0]) === 1;
|
|
1926
|
+
const noun = isProjects ? "project" : "to-do";
|
|
1927
|
+
if (coBounce.length > cap) {
|
|
1928
|
+
return swapBlocked(deps, params, ctx, preRanks, `an area's member order fallback rejected: ${coBounce.length} touched items exceed the cap of ${cap} ` +
|
|
1929
|
+
`(each costs a park + re-home leg${touchedUnnamed.length > 0 ? `; ${touchedUnnamed.length} co-touched area sibling(s)` : ""})`, `reorder at most ${cap} of an area's members (set with \`things config set bounce-max-items\`)`);
|
|
1930
|
+
}
|
|
1931
|
+
if (options.dryRun === true) {
|
|
1932
|
+
return {
|
|
1933
|
+
kind: "dry-run",
|
|
1934
|
+
op: "reorder",
|
|
1935
|
+
plan: {
|
|
1936
|
+
op: "reorder",
|
|
1937
|
+
vector: "url-scheme",
|
|
1938
|
+
tier: 0,
|
|
1939
|
+
invocation: `AREABACK scratch ${isProjects ? "area" : "project"} + park ×${coBounce.length} + re-home ×${coBounce.length} ` +
|
|
1940
|
+
`(flag-safe front-insert, reverse target order${touchedUnnamed.length > 0 ? `, touches ${touchedUnnamed.length} unnamed sibling(s)` : ""}; ` +
|
|
1941
|
+
`${isProjects ? "delete" : "trash"} the empty scratch): the private reorder is unavailable, so each ` +
|
|
1942
|
+
`${noun} is parked out then re-homed to the area to front-insert at its member min (area FK ` +
|
|
1943
|
+
"preserved); one terminal order verify",
|
|
1944
|
+
expectedDelta: { mode: "ordering", key: "index", sequence: coBounce },
|
|
1945
|
+
hazardsChecked: ["H-REORDER-SCOPE"],
|
|
1946
|
+
},
|
|
1947
|
+
};
|
|
1948
|
+
}
|
|
1949
|
+
const legOpts = compoundLegOptions(options, txnId);
|
|
1950
|
+
const scratchTitle = `things-api reorder-area ${scratchSuffix(startedAt)}`;
|
|
1951
|
+
// 1. Create the scratch container (a PROJECT to hold to-dos / an AREA to hold projects).
|
|
1952
|
+
const add = isProjects
|
|
1953
|
+
? await runMutation(deps, "area.add", { title: scratchTitle }, legOpts)
|
|
1954
|
+
: await runMutation(deps, "project.add", { title: scratchTitle }, legOpts);
|
|
1955
|
+
if (add.kind !== "ok" || add.uuid === null) {
|
|
1956
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [] }, {
|
|
1957
|
+
pre: preRanks,
|
|
1958
|
+
txnId,
|
|
1959
|
+
actor,
|
|
1960
|
+
});
|
|
1961
|
+
return swapAborted(`could not create the scratch ${isProjects ? "area" : "project"} "${scratchTitle}" — nothing was ` +
|
|
1962
|
+
"parked; no changes were made", [], coBounce, add.kind === "ok" ? null : add);
|
|
1963
|
+
}
|
|
1964
|
+
const scratch = add.uuid;
|
|
1965
|
+
// 2. PARK each member OUT of the area into the scratch container (any order).
|
|
1966
|
+
const parked = [];
|
|
1967
|
+
for (const uuid of coBounce) {
|
|
1968
|
+
const res = isProjects
|
|
1969
|
+
? await runMutation(deps, "project.move", { uuid, area: { uuid: scratch } }, legOpts)
|
|
1970
|
+
: await runMutation(deps, "todo.move", { uuid, project: { uuid: scratch } }, legOpts);
|
|
1971
|
+
if (res.kind !== "ok") {
|
|
1972
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...parked] }, {
|
|
1973
|
+
pre: preRanks,
|
|
1974
|
+
txnId,
|
|
1975
|
+
actor,
|
|
1976
|
+
});
|
|
1977
|
+
return swapAborted(`parking ${noun} ${uuid} into scratch ${isProjects ? "area" : "project"} ${scratch} failed — ` +
|
|
1978
|
+
`${parked.length} ${noun}(s) are PARKED there (${scratch}) and must be moved back to area ` +
|
|
1979
|
+
`${containerUuid} manually; the scratch was NOT ${isProjects ? "deleted" : "trashed"}`, parked, coBounce.slice(parked.length), res);
|
|
1980
|
+
}
|
|
1981
|
+
parked.push(uuid);
|
|
1982
|
+
}
|
|
1983
|
+
// 3. RE-HOME to the area in REVERSE target order — front-insert lands the target.
|
|
1984
|
+
const dispatch = coBounce.toReversed();
|
|
1985
|
+
const rehomed = [];
|
|
1986
|
+
for (const uuid of dispatch) {
|
|
1987
|
+
const res = isProjects
|
|
1988
|
+
? await runMutation(deps, "project.move", { uuid, area: { uuid: containerUuid } }, legOpts)
|
|
1989
|
+
: await runMutation(deps, "todo.move", { uuid, area: { uuid: containerUuid } }, legOpts);
|
|
1990
|
+
if (res.kind !== "ok") {
|
|
1991
|
+
const stillParked = dispatch.filter((u) => !rehomed.includes(u));
|
|
1992
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...rehomed] }, {
|
|
1993
|
+
pre: preRanks,
|
|
1994
|
+
txnId,
|
|
1995
|
+
actor,
|
|
1996
|
+
});
|
|
1997
|
+
return swapAborted(`re-homing ${noun} ${uuid} to area ${containerUuid} from scratch ${scratch} failed — ` +
|
|
1998
|
+
`${stillParked.length} ${noun}(s) remain PARKED in ${scratch} and must be moved back to ` +
|
|
1999
|
+
`area ${containerUuid} manually; the scratch was NOT ${isProjects ? "deleted" : "trashed"}`, rehomed, stillParked, res);
|
|
2000
|
+
}
|
|
2001
|
+
rehomed.push(uuid);
|
|
2002
|
+
}
|
|
2003
|
+
// 4. Verify the scratch is EMPTY, then trash/delete it (NEVER teardown a non-empty scratch).
|
|
2004
|
+
const remaining = isProjects
|
|
2005
|
+
? countAreaMembers(deps, scratch)
|
|
2006
|
+
: countProjectChildren(deps, scratch);
|
|
2007
|
+
if (remaining > 0) {
|
|
2008
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...rehomed] }, {
|
|
2009
|
+
pre: preRanks,
|
|
2010
|
+
txnId,
|
|
2011
|
+
actor,
|
|
2012
|
+
});
|
|
2013
|
+
return swapAborted(`the scratch ${isProjects ? "area" : "project"} ${scratch} still holds ${remaining} parked ` +
|
|
2014
|
+
`${noun}(s) after re-homing — refusing to ${isProjects ? "delete" : "trash"} it (that would ` +
|
|
2015
|
+
`${isProjects ? "trash its members and shallow-trash their children" : "send them to the Trash"}, ` +
|
|
2016
|
+
`AREADEL); move them back to area ${containerUuid} and remove ${scratch} manually`, rehomed, [], null);
|
|
2017
|
+
}
|
|
2018
|
+
// The area delete supplies H-PERMANENT-DELETE internally (created + verified-empty
|
|
2019
|
+
// this txn); H-AREA-NOT-EMPTY cannot trip (a non-empty scratch aborts above).
|
|
2020
|
+
const del = isProjects
|
|
2021
|
+
? await runMutation(deps, "area.delete", { target: scratch }, { ...legOpts, dangerouslyPermanent: true })
|
|
2022
|
+
: await runMutation(deps, "project.delete", { uuid: scratch }, legOpts);
|
|
2023
|
+
const scratchRemoved = del.kind === "ok";
|
|
2024
|
+
// 5. Terminal verify: the area's member order matches the target.
|
|
2025
|
+
const verify = await verifyIndexOrder(deps, coBounce, options);
|
|
2026
|
+
if (verify.kind !== "ok") {
|
|
2027
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...rehomed] }, {
|
|
2028
|
+
pre: preRanks,
|
|
2029
|
+
txnId,
|
|
2030
|
+
actor,
|
|
2031
|
+
});
|
|
2032
|
+
return swapAborted("the area's members did not land the requested order after re-homing (scratch " +
|
|
2033
|
+
`${scratch} was ${scratchRemoved ? (isProjects ? "deleted" : "trashed") : "left in place"}); ` +
|
|
2034
|
+
"re-run once Things is idle", rehomed, [], null);
|
|
2035
|
+
}
|
|
2036
|
+
auditSummary(deps, params, startedAt, "ok", swapObserved(deps, coBounce), {
|
|
2037
|
+
pre: preRanks,
|
|
2038
|
+
txnId,
|
|
2039
|
+
actor,
|
|
2040
|
+
});
|
|
2041
|
+
return swapOk(deps, coBounce, ctx, [
|
|
2042
|
+
`scratch ${isProjects ? "area" : "project"} ${scratch} was created for the reorder and ` +
|
|
2043
|
+
(scratchRemoved
|
|
2044
|
+
? `${isProjects ? "deleted" : "moved to the Trash"} (verified empty first — the protocol never ` +
|
|
2045
|
+
`${isProjects ? "deletes a non-empty area" : "trashes a non-empty scratch"})`
|
|
2046
|
+
: `could NOT be ${isProjects ? "deleted" : "trashed"} (${del.kind}) — it remains empty; remove it manually`),
|
|
2047
|
+
]);
|
|
2048
|
+
}
|
|
2049
|
+
/**
|
|
2050
|
+
* The url-scheme dispatch surface for a leg op — the vector the json collapse
|
|
2051
|
+
* opens its `things:///json` array through. In tests this is the injected fake
|
|
2052
|
+
* (id "url-scheme"); in production `createUrlSchemeVector`; under the simulator
|
|
2053
|
+
* fence the simulator (id "url-scheme", simulates=true) — which the caller then
|
|
2054
|
+
* rejects for the collapse (it has no json-array applier) and falls back to the
|
|
2055
|
+
* sequential per-leg bounce the simulator DOES model.
|
|
2056
|
+
*/
|
|
2057
|
+
function pickDispatchVector(deps, legOp) {
|
|
2058
|
+
return deps.vectors.find((v) => v.id === "url-scheme" && v.matrix[legOp]?.support === "yes");
|
|
2059
|
+
}
|
|
2060
|
+
/**
|
|
2061
|
+
* The BOUNCEJSON collapse (§9i / BJ-a / BJ-c): dispatch the whole bounce as ONE
|
|
2062
|
+
* pre-validated `things:///json` update array carrying `[{when:away},{when:back}]`
|
|
2063
|
+
* per item, iterated in the SAME per-item order the sequential loop uses (so
|
|
2064
|
+
* array order == the resulting index order for both insert directions), then run
|
|
2065
|
+
* ONE terminal ordering verify. The app validates the entire array first and
|
|
2066
|
+
* applies it all-or-nothing, so a failed dispatch/verify means NOTHING landed —
|
|
2067
|
+
* there is no partial-progress state to reconcile (contrast the sequential
|
|
2068
|
+
* path's placed/remaining bookkeeping). Every id was already resolved in
|
|
2069
|
+
* {@link runReorder} (a single unresolvable ref would full-abort the batch).
|
|
2070
|
+
*/
|
|
2071
|
+
async function runBounceJsonCollapse(deps, params, spec, ctx) {
|
|
2072
|
+
const { coBounce, order, bounceKind, containerUuid, preRanks, txnId, actor, touchedUnnamed, startedAt, options, vector, } = ctx;
|
|
2073
|
+
const { rankKey } = spec;
|
|
2074
|
+
// The json collapse is only ever reached for a fixed-bucket, jsonCollapsible spec
|
|
2075
|
+
// (heading / anytime) — never a dated or per-type bounce — so away/back are the
|
|
2076
|
+
// concrete keywords the spec pins.
|
|
2077
|
+
const away = spec.away;
|
|
2078
|
+
const back = spec.back;
|
|
2079
|
+
const now = deps.now ?? (() => new Date());
|
|
2080
|
+
const abort = (detail) => {
|
|
2081
|
+
auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [] }, {
|
|
2082
|
+
pre: preRanks,
|
|
2083
|
+
txnId,
|
|
2084
|
+
actor,
|
|
2085
|
+
});
|
|
2086
|
+
return {
|
|
2087
|
+
kind: "bounce-aborted",
|
|
2088
|
+
op: "reorder",
|
|
2089
|
+
detail,
|
|
2090
|
+
placed: [],
|
|
2091
|
+
remaining: coBounce,
|
|
2092
|
+
cause: null,
|
|
2093
|
+
};
|
|
2094
|
+
};
|
|
2095
|
+
// Validate-first full-abort means nothing lands, so the concurrent-edit guard
|
|
2096
|
+
// checks EVERY member up front (not per item as the sequential loop does).
|
|
2097
|
+
for (const uuid of coBounce) {
|
|
2098
|
+
const problem = checkStillMember(deps, uuid, bounceKind, containerUuid, now(), null);
|
|
2099
|
+
if (problem !== null) {
|
|
2100
|
+
return abort(`aborted before the json-collapsed bounce: ${uuid} ${problem} (Things was likely ` +
|
|
2101
|
+
"edited concurrently); NOTHING was applied");
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
// ONE json array, both legs interleaved per item, in placement order.
|
|
2105
|
+
const ops = order.flatMap((i) => {
|
|
2106
|
+
const uuid = coBounce[i];
|
|
2107
|
+
return [
|
|
2108
|
+
{ type: "to-do", operation: "update", id: uuid, attributes: { when: away } },
|
|
2109
|
+
{ type: "to-do", operation: "update", id: uuid, attributes: { when: back } },
|
|
2110
|
+
];
|
|
2111
|
+
});
|
|
2112
|
+
const token = readAuthToken(deps.db);
|
|
2113
|
+
const data = encodeURIComponent(JSON.stringify(ops));
|
|
2114
|
+
const payload = `things:///json?data=${data}${token !== null ? `&auth-token=${encodeURIComponent(token)}` : ""}`;
|
|
2115
|
+
const redactedPayload = `things:///json?data=${data}${token !== null ? "&auth-token=REDACTED" : ""}`;
|
|
2116
|
+
const exec = await vector.execute({
|
|
2117
|
+
vector: "url-scheme",
|
|
2118
|
+
kind: "open-url",
|
|
2119
|
+
payload,
|
|
2120
|
+
redactedPayload,
|
|
2121
|
+
});
|
|
2122
|
+
if (exec.exitCode !== 0 || exec.timedOut === true) {
|
|
2123
|
+
return abort(`the json-collapsed bounce dispatch failed (exit ${exec.exitCode ?? "?"}` +
|
|
2124
|
+
`${exec.timedOut === true ? ", timed out" : ""}) — the array is validate-first ` +
|
|
2125
|
+
"all-or-nothing, so NOTHING was applied");
|
|
2126
|
+
}
|
|
2127
|
+
// ONE terminal ordering verify over the whole run.
|
|
2128
|
+
const verify = await pollUntilVerified(() => evaluateDelta({ mode: "ordering", key: rankKey, sequence: coBounce }, createDbReader(deps.db), {
|
|
2129
|
+
modDates: {},
|
|
2130
|
+
fields: {},
|
|
2131
|
+
}), options.verifyTimeoutMs ?? 4000, deps.poller ?? {});
|
|
2132
|
+
if (verify.kind !== "ok") {
|
|
2133
|
+
return abort("the json-collapsed bounce did not land the requested order — the app validates the " +
|
|
2134
|
+
"whole array first and applies it all-or-nothing (§9i / BJ-c), so NOTHING was applied " +
|
|
2135
|
+
"(no partial-progress repair needed); re-run once Things is idle");
|
|
2136
|
+
}
|
|
2137
|
+
const reader = createDbReader(deps.db);
|
|
2138
|
+
const observed = {};
|
|
2139
|
+
for (const uuid of coBounce)
|
|
2140
|
+
observed[uuid] = reader.rankOf(uuid, rankKey);
|
|
2141
|
+
auditSummary(deps, params, startedAt, "ok", observed, { pre: preRanks, txnId, actor });
|
|
2142
|
+
return {
|
|
2143
|
+
kind: "ok",
|
|
2144
|
+
op: "reorder",
|
|
2145
|
+
uuid: null,
|
|
2146
|
+
observed,
|
|
2147
|
+
vector: "url-scheme",
|
|
2148
|
+
tier: 0,
|
|
2149
|
+
undoToken: txnId,
|
|
2150
|
+
...(touchedUnnamed.length > 0 && { touched: touchedUnnamed }),
|
|
227
2151
|
};
|
|
228
2152
|
}
|
|
229
2153
|
function legOptions(options, txnId) {
|
|
@@ -236,22 +2160,37 @@ function legOptions(options, txnId) {
|
|
|
236
2160
|
legs.verifyTimeoutMs = options.verifyTimeoutMs;
|
|
237
2161
|
if (options.actor !== undefined)
|
|
238
2162
|
legs.actor = options.actor;
|
|
2163
|
+
// One verify per item round-trip (BOUNCE2): the transient AWAY/BACK legs skip
|
|
2164
|
+
// their own state verify; the orchestrator's placed-position check after each
|
|
2165
|
+
// item is the single per-round-trip assertion. Transport fail-loud is retained.
|
|
2166
|
+
legs.skipVerify = true;
|
|
239
2167
|
return legs;
|
|
240
2168
|
}
|
|
241
2169
|
function resolveContainerUuid(deps, params) {
|
|
242
|
-
if (params.scope === "project"
|
|
2170
|
+
if (params.scope === "project") {
|
|
243
2171
|
return resolveProject(deps.db, params.container ?? {}).resolved?.uuid ?? null;
|
|
244
2172
|
}
|
|
245
2173
|
if (params.scope === "area") {
|
|
246
2174
|
return resolveArea(deps.db, params.container ?? {}).resolved?.uuid ?? null;
|
|
247
2175
|
}
|
|
2176
|
+
// heading / area-someday / container-day / heading-someday: the planner passes a
|
|
2177
|
+
// resolved uuid container directly (a heading uuid, an area uuid, or a project/
|
|
2178
|
+
// area uuid). The `day` bounce is container-less (a global cross-container axis).
|
|
2179
|
+
if (params.scope === "heading" ||
|
|
2180
|
+
params.scope === "area-someday" ||
|
|
2181
|
+
params.scope === "container-day" ||
|
|
2182
|
+
params.scope === "heading-someday") {
|
|
2183
|
+
return params.container?.uuid ?? null;
|
|
2184
|
+
}
|
|
248
2185
|
return null;
|
|
249
2186
|
}
|
|
250
2187
|
/** null = still eligible; otherwise a human-readable problem. */
|
|
251
|
-
function checkStillMember(deps, uuid,
|
|
2188
|
+
function checkStillMember(deps, uuid, bounceKind, containerUuid, now, dayPacked) {
|
|
252
2189
|
const packedToday = encodePackedDate(localToday(now));
|
|
253
2190
|
const row = deps.db
|
|
254
|
-
.prepare("SELECT status, trashed, startBucket, startDate, start, type, area
|
|
2191
|
+
.prepare("SELECT status, trashed, startBucket, startDate, start, type, area, project, heading, deadline, " +
|
|
2192
|
+
"rt1_recurrenceRule AS rule, repeater, rt1_nextInstanceStartDate AS proj " +
|
|
2193
|
+
"FROM TMTask WHERE uuid = ?")
|
|
255
2194
|
.get(uuid);
|
|
256
2195
|
if (row === undefined)
|
|
257
2196
|
return "the item no longer exists";
|
|
@@ -259,25 +2198,106 @@ function checkStillMember(deps, uuid, scope, now) {
|
|
|
259
2198
|
return "the item was trashed";
|
|
260
2199
|
if (row.status !== 0)
|
|
261
2200
|
return "the item is no longer open";
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
2201
|
+
switch (bounceKind) {
|
|
2202
|
+
case "projects":
|
|
2203
|
+
if (row.type !== 1)
|
|
2204
|
+
return "the item is not a project";
|
|
2205
|
+
if (row.area !== null)
|
|
2206
|
+
return "the project moved into an area";
|
|
2207
|
+
if (row.start !== 1 || row.startDate !== null) {
|
|
2208
|
+
return "the project is no longer a plain Anytime project";
|
|
2209
|
+
}
|
|
2210
|
+
return null;
|
|
2211
|
+
case "heading":
|
|
2212
|
+
if (row.type !== 0)
|
|
2213
|
+
return "the item is not a to-do";
|
|
2214
|
+
if (row.heading !== containerUuid)
|
|
2215
|
+
return "the to-do left the heading";
|
|
2216
|
+
if (row.start !== 1 || row.startDate !== null) {
|
|
2217
|
+
return "the to-do is no longer a plain Anytime child of the heading";
|
|
2218
|
+
}
|
|
2219
|
+
return null;
|
|
2220
|
+
case "area-someday":
|
|
2221
|
+
if (row.type !== 0)
|
|
2222
|
+
return "the item is not a to-do";
|
|
2223
|
+
if (row.area !== containerUuid || row.heading !== null)
|
|
2224
|
+
return "the to-do left the area";
|
|
2225
|
+
if (row.start !== 2 || row.startDate !== null)
|
|
2226
|
+
return "the to-do is no longer a Someday item";
|
|
2227
|
+
return null;
|
|
2228
|
+
case "project-someday":
|
|
2229
|
+
if (row.type !== 0)
|
|
2230
|
+
return "the item is not a to-do";
|
|
2231
|
+
if (row.project !== containerUuid || row.heading !== null) {
|
|
2232
|
+
return "the to-do left the project";
|
|
2233
|
+
}
|
|
2234
|
+
if (row.start !== 2 || row.startDate !== null)
|
|
2235
|
+
return "the to-do is no longer a Someday item";
|
|
2236
|
+
return null;
|
|
2237
|
+
case "anytime":
|
|
2238
|
+
if (row.type !== 0)
|
|
2239
|
+
return "the item is not a to-do";
|
|
2240
|
+
if (row.project !== null || row.area !== null || row.heading !== null) {
|
|
2241
|
+
return "the to-do is no longer loose (it gained a container)";
|
|
2242
|
+
}
|
|
2243
|
+
if (row.start !== 1 || row.startDate !== null) {
|
|
2244
|
+
return "the to-do is no longer a plain Anytime item";
|
|
2245
|
+
}
|
|
2246
|
+
return null;
|
|
2247
|
+
// SIT7 SOMEBACK: a loose someday to-do OR an area-less someday project. Both
|
|
2248
|
+
// front-insert on the shared loose someday index axis via the per-type bounce.
|
|
2249
|
+
case "someday":
|
|
2250
|
+
if (row.type !== 0 && row.type !== 1)
|
|
2251
|
+
return "the item is not a to-do or project";
|
|
2252
|
+
if (row.start !== 2 || row.startDate !== null)
|
|
2253
|
+
return "the item is no longer a Someday item";
|
|
2254
|
+
if (row.type === 0 && (row.project !== null || row.area !== null || row.heading !== null)) {
|
|
2255
|
+
return "the to-do is no longer a loose Someday item (it gained a container)";
|
|
2256
|
+
}
|
|
2257
|
+
if (row.type === 1 && row.area !== null)
|
|
2258
|
+
return "the project moved into an area";
|
|
2259
|
+
return null;
|
|
2260
|
+
case "today":
|
|
2261
|
+
case "evening": {
|
|
2262
|
+
const inToday = row.startDate !== null &&
|
|
2263
|
+
row.startDate <= packedToday &&
|
|
2264
|
+
(row.start === 1 || row.start === 2);
|
|
2265
|
+
if (!inToday)
|
|
2266
|
+
return "the item left the Today list";
|
|
2267
|
+
if (bounceKind === "today" && row.startBucket !== 0)
|
|
2268
|
+
return "the item moved to This Evening";
|
|
2269
|
+
if (bounceKind === "evening" && (row.startBucket !== 1 || row.startDate !== packedToday)) {
|
|
2270
|
+
return "the item left This Evening";
|
|
2271
|
+
}
|
|
2272
|
+
return null;
|
|
2273
|
+
}
|
|
2274
|
+
case "day": {
|
|
2275
|
+
// The dated bounce's members are SCHEDULED to-dos (any container) and scheduled
|
|
2276
|
+
// project rows (area-less OR area-direct — SIT5 AREAPROJDAY) sharing the day D
|
|
2277
|
+
// on the startBucket=0 axis, PLUS DEADLINE-FORECAST to-dos (startDate NULL,
|
|
2278
|
+
// start IN (1,2), deadline == D — DLBNC/§9o) on the SAME block todayIndex axis.
|
|
2279
|
+
// A concurrent edit that re-dates, de-schedules, evenings, clears the deadline,
|
|
2280
|
+
// or drops the row to the Inbox (start=0) ejects it from the group.
|
|
2281
|
+
if (row.type !== 0 && row.type !== 1)
|
|
2282
|
+
return "the item is not a to-do or project";
|
|
2283
|
+
// A repeating TEMPLATE stays a day-block member via its PROJECTION day
|
|
2284
|
+
// (rt1_nextInstanceStartDate — TMPLSORT/PTMPL), not startDate/deadline.
|
|
2285
|
+
if (row.rule !== null || row.repeater !== null) {
|
|
2286
|
+
return row.proj === dayPacked
|
|
2287
|
+
? null
|
|
2288
|
+
: "the template's projection day changed (its recurrence rule was edited)";
|
|
2289
|
+
}
|
|
2290
|
+
const scheduled = row.startBucket === 0 && row.startDate !== null && row.startDate === dayPacked;
|
|
2291
|
+
const forecast = row.startDate === null &&
|
|
2292
|
+
(row.start === 1 || row.start === 2) &&
|
|
2293
|
+
row.deadline !== null &&
|
|
2294
|
+
row.deadline === dayPacked;
|
|
2295
|
+
if (!scheduled && !forecast) {
|
|
2296
|
+
return "the item left the day-group (re-dated, evening-ed, de-scheduled, or its deadline changed)";
|
|
2297
|
+
}
|
|
2298
|
+
return null;
|
|
269
2299
|
}
|
|
270
|
-
return null;
|
|
271
|
-
}
|
|
272
|
-
const inToday = row.startDate !== null && row.startDate <= packedToday && (row.start === 1 || row.start === 2);
|
|
273
|
-
if (!inToday)
|
|
274
|
-
return "the item left the Today list";
|
|
275
|
-
if (scope === "today" && row.startBucket !== 0)
|
|
276
|
-
return "the item moved to This Evening";
|
|
277
|
-
if (scope === "evening" && (row.startBucket !== 1 || row.startDate !== packedToday)) {
|
|
278
|
-
return "the item left This Evening";
|
|
279
2300
|
}
|
|
280
|
-
return null;
|
|
281
2301
|
}
|
|
282
2302
|
function auditSummary(deps, params, startedAt, result, observed, extras) {
|
|
283
2303
|
const fp = deps.fingerprint();
|
|
@@ -290,7 +2310,7 @@ function auditSummary(deps, params, startedAt, result, observed, extras) {
|
|
|
290
2310
|
uuid: null,
|
|
291
2311
|
vector: "url-scheme",
|
|
292
2312
|
disruption: 0,
|
|
293
|
-
invocation: `
|
|
2313
|
+
invocation: `reorder(${params.scope}) ×${params.uuids.length}`,
|
|
294
2314
|
requested: params,
|
|
295
2315
|
pre: extras?.pre ?? null,
|
|
296
2316
|
...(extras?.txnId !== undefined && { txn: { id: extras.txnId, role: "summary" } }),
|