things-api 0.10.0 → 0.12.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 +50 -13
- package/dist/audit/schema.d.ts +7 -0
- package/dist/audit/schema.js.map +1 -1
- package/dist/cli/clock.d.ts +8 -0
- package/dist/cli/clock.js +24 -0
- package/dist/cli/clock.js.map +1 -0
- package/dist/cli/commands/area.d.ts +18 -11
- package/dist/cli/commands/area.js +61 -74
- 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 +60 -0
- package/dist/cli/commands/install-skill.js +256 -0
- package/dist/cli/commands/install-skill.js.map +1 -0
- package/dist/cli/commands/mcp.js +9 -0
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/commands/project.d.ts +1 -0
- package/dist/cli/commands/project.js +27 -34
- package/dist/cli/commands/project.js.map +1 -1
- package/dist/cli/commands/reads.d.ts +15 -2
- package/dist/cli/commands/reads.js +144 -44
- package/dist/cli/commands/reads.js.map +1 -1
- package/dist/cli/commands/setup.js +7 -2
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/commands/show.js +38 -14
- package/dist/cli/commands/show.js.map +1 -1
- package/dist/cli/commands/todo.js +15 -9
- package/dist/cli/commands/todo.js.map +1 -1
- package/dist/cli/commands/writes.js +797 -174
- 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 +26 -3
- package/dist/cli/did-you-mean.js.map +1 -1
- package/dist/cli/glyphs.d.ts +32 -2
- package/dist/cli/glyphs.js +83 -12
- package/dist/cli/glyphs.js.map +1 -1
- package/dist/cli/help.js +121 -16
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/main.js +33 -2
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/move-hint.d.ts +46 -0
- package/dist/cli/move-hint.js +196 -0
- package/dist/cli/move-hint.js.map +1 -0
- package/dist/cli/period.d.ts +2 -8
- package/dist/cli/period.js +44 -30
- package/dist/cli/period.js.map +1 -1
- package/dist/cli/read-driver.d.ts +43 -5
- package/dist/cli/read-driver.js +91 -15
- package/dist/cli/read-driver.js.map +1 -1
- package/dist/cli/render.d.ts +19 -8
- package/dist/cli/render.js +70 -27
- package/dist/cli/render.js.map +1 -1
- package/dist/cli/resolve-invocation.d.ts +2 -2
- package/dist/cli/resolve-invocation.js +3 -3
- package/dist/cli/resolve-invocation.js.map +1 -1
- package/dist/cli/skill-check.d.ts +27 -0
- package/dist/cli/skill-check.js +99 -0
- package/dist/cli/skill-check.js.map +1 -0
- package/dist/cli/skill.d.ts +70 -0
- package/dist/cli/skill.js +152 -0
- package/dist/cli/skill.js.map +1 -0
- package/dist/cli/tag-filters.d.ts +6 -2
- package/dist/cli/tag-filters.js +8 -6
- package/dist/cli/tag-filters.js.map +1 -1
- package/dist/cli/verb-hint.js +28 -3
- package/dist/cli/verb-hint.js.map +1 -1
- package/dist/cli/version.d.ts +4 -0
- package/dist/cli/version.js +18 -0
- package/dist/cli/version.js.map +1 -0
- package/dist/client.d.ts +171 -27
- package/dist/client.js +279 -40
- 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 +186 -28
- package/dist/contracts.js +22 -1
- package/dist/contracts.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 +25 -9
- package/dist/index.js +22 -3
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.d.ts +9 -0
- package/dist/mcp/server.js +1157 -684
- package/dist/mcp/server.js.map +1 -1
- package/dist/model/clock.d.ts +61 -0
- package/dist/model/clock.js +71 -0
- package/dist/model/clock.js.map +1 -0
- package/dist/model/dates.d.ts +26 -3
- package/dist/model/dates.js +84 -3
- package/dist/model/dates.js.map +1 -1
- package/dist/model/entities.d.ts +60 -3
- package/dist/model/entities.js.map +1 -1
- package/dist/model/mappers.d.ts +4 -2
- package/dist/model/mappers.js +66 -9
- 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/read/area-filter.d.ts +76 -0
- package/dist/read/area-filter.js +59 -0
- package/dist/read/area-filter.js.map +1 -0
- package/dist/read/area-view.d.ts +12 -12
- package/dist/read/area-view.js +58 -34
- package/dist/read/area-view.js.map +1 -1
- package/dist/read/detail.d.ts +1 -1
- package/dist/read/detail.js +26 -12
- package/dist/read/detail.js.map +1 -1
- package/dist/read/log-boundary.d.ts +1 -1
- package/dist/read/log-boundary.js +13 -3
- package/dist/read/log-boundary.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 +32 -16
- package/dist/read/project-view.js +80 -45
- 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 +108 -19
- package/dist/read/queries.js +212 -35
- package/dist/read/queries.js.map +1 -1
- package/dist/read/scope.d.ts +126 -0
- package/dist/read/scope.js +162 -0
- package/dist/read/scope.js.map +1 -0
- package/dist/read/search-rank.d.ts +2 -5
- package/dist/read/shape.d.ts +162 -0
- package/dist/read/shape.js +686 -0
- package/dist/read/shape.js.map +1 -0
- package/dist/read/show-target.d.ts +9 -1
- package/dist/read/show-target.js +45 -4
- package/dist/read/show-target.js.map +1 -1
- package/dist/read/snapshot.d.ts +1 -1
- package/dist/read/snapshot.js +9 -2
- package/dist/read/snapshot.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 +8 -6
- package/dist/read/truncation.js +35 -10
- package/dist/read/truncation.js.map +1 -1
- package/dist/read/views.d.ts +61 -20
- package/dist/read/views.js +240 -82
- package/dist/read/views.js.map +1 -1
- package/dist/surface-copy.d.ts +9 -0
- package/dist/surface-copy.js +9 -0
- package/dist/surface-copy.js.map +1 -1
- package/dist/write/accessibility-probe.d.ts +1 -1
- package/dist/write/accessibility-probe.js +8 -0
- package/dist/write/accessibility-probe.js.map +1 -1
- package/dist/write/automation-probe.d.ts +1 -1
- package/dist/write/automation-probe.js +8 -0
- package/dist/write/automation-probe.js.map +1 -1
- package/dist/write/batch.d.ts +49 -10
- package/dist/write/batch.js +423 -70
- package/dist/write/batch.js.map +1 -1
- package/dist/write/commands.d.ts +1 -1
- package/dist/write/commands.js +316 -66
- package/dist/write/commands.js.map +1 -1
- package/dist/write/guards.d.ts +1 -1
- package/dist/write/guards.js +94 -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 +130 -0
- package/dist/write/move.js +1533 -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 +122 -3
- package/dist/write/pipeline.js +309 -52
- package/dist/write/pipeline.js.map +1 -1
- package/dist/write/planner.js +8 -1
- package/dist/write/planner.js.map +1 -1
- package/dist/write/pre-state.d.ts +197 -6
- package/dist/write/pre-state.js +427 -20
- package/dist/write/pre-state.js.map +1 -1
- package/dist/write/recurrence-rule-blob.d.ts +65 -0
- package/dist/write/recurrence-rule-blob.js +150 -0
- package/dist/write/recurrence-rule-blob.js.map +1 -0
- package/dist/write/reorder.d.ts +23 -3
- package/dist/write/reorder.js +1871 -85
- 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/registry.d.ts +5 -7
- package/dist/write/vectors/registry.js +49 -2
- package/dist/write/vectors/registry.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.d.ts +44 -0
- package/dist/write/vectors/simulator.js +1192 -0
- package/dist/write/vectors/simulator.js.map +1 -0
- package/dist/write/vectors/types.d.ts +46 -0
- package/dist/write/vectors/ui-certification.d.ts +10 -4
- package/dist/write/vectors/ui-certification.js +43 -10
- 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 +34 -1
- package/dist/write/vectors/ui-recipes.js +217 -6
- package/dist/write/vectors/ui-recipes.js.map +1 -1
- package/dist/write/vectors/ui.d.ts +40 -1
- package/dist/write/vectors/ui.js +196 -39
- package/dist/write/vectors/ui.js.map +1 -1
- package/dist/write/verify/delta.d.ts +127 -2
- package/dist/write/verify/delta.js +255 -16
- package/dist/write/verify/delta.js.map +1 -1
- package/dist/write/verify/poller.d.ts +7 -1
- package/dist/write/verify/poller.js +20 -1
- package/dist/write/verify/poller.js.map +1 -1
- package/package.json +24 -3
- package/schema/envelope.schema.json +383 -0
- package/skills/things-cli/SKILL.md +70 -0
- package/skills/things-cli/references/banner.md +35 -0
- package/skills/things-cli/references/contracts.md +62 -0
- package/skills/things-cli/references/errors.md +49 -0
- package/skills/things-cli/references/gui.md +11 -0
- package/skills/things-cli/references/model.md +60 -0
- package/skills/things-cli/references/ordering.md +71 -0
|
@@ -0,0 +1,1533 @@
|
|
|
1
|
+
import { addDaysIso, decodePackedDate, encodePackedDate, localToday } from "../model/dates.js";
|
|
2
|
+
import { ReferenceResolutionError, resolveTaskUuidPrefix } from "../read/queries.js";
|
|
3
|
+
import { taskMembershipClause } from "../read/scope.js";
|
|
4
|
+
import { isLooseRef, LOOSE_TO_AREA_REFUSAL } from "../read/pseudo-area.js";
|
|
5
|
+
import { computeReorderPre, resolveArea, resolveHeading, resolveProject } from "./pre-state.js";
|
|
6
|
+
import {} from "./pipeline.js";
|
|
7
|
+
import { runMutation } from "./pipeline.js";
|
|
8
|
+
import { runReorder } from "./reorder.js";
|
|
9
|
+
function loadRow(db, uuid) {
|
|
10
|
+
const row = db
|
|
11
|
+
.prepare("SELECT uuid, title, type, project, area, heading, start, startDate, startBucket, " +
|
|
12
|
+
"rt1_recurrenceRule AS rule, repeater FROM TMTask WHERE uuid = ?")
|
|
13
|
+
.get(uuid);
|
|
14
|
+
if (row === undefined)
|
|
15
|
+
return undefined;
|
|
16
|
+
const { rule, repeater, ...rest } = row;
|
|
17
|
+
return { ...rest, isTemplate: rule !== null || repeater !== null };
|
|
18
|
+
}
|
|
19
|
+
const KIND_LABEL = { 0: "to-do", 1: "project", 2: "heading" };
|
|
20
|
+
/**
|
|
21
|
+
* The display bucket a row sits in — the anchor single-bucket rule keys on it.
|
|
22
|
+
*
|
|
23
|
+
* DATE-FIRST precedence, mirroring the read layer's settled semantics
|
|
24
|
+
* (src/read/stage.ts deriveStage, post-R10.2/R12): a `startDate` is classified
|
|
25
|
+
* by its date BEFORE `start` is consulted, because the app's ONLY representation
|
|
26
|
+
* of a future-scheduled item is `start=2` + a future `startDate` (UPC1 upcoming
|
|
27
|
+
* cohort, BANNER1 scheduled arrivals; live prod scan 2026-07-30: 4/4
|
|
28
|
+
* future-scheduled to-dos are start=2, zero are start=1+future). Classifying
|
|
29
|
+
* `start===2 → someday` BEFORE the date check would mislabel every real
|
|
30
|
+
* future-scheduled row as `someday` and route it to the someday protocols —
|
|
31
|
+
* whose someday↔anytime bounce legs would CLEAR the item's date (a de-schedule).
|
|
32
|
+
* So: inbox → dated (arrived → today/evening; future → scheduled:<date>) → then
|
|
33
|
+
* `start===2` correctly means an UNDATED someday only → else anytime. An arrived
|
|
34
|
+
* `start=2` (someday-scheduled, `startDate <= today`) is a Today+Anytime member,
|
|
35
|
+
* so it buckets today/evening here, exactly as deriveStage derives `anytime`
|
|
36
|
+
* with the Today marker.
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* The packed startDate of TOMORROW (the day after `packedToday`). Packed dates
|
|
40
|
+
* are bit-fields, not integers, so tomorrow is NOT packedToday+1 across a month/
|
|
41
|
+
* year boundary — decode → +1 calendar day → re-encode. Feeds the ORDFIN2
|
|
42
|
+
* TOMORROWLIST fast path (a future day-group whose day == tomorrow rides the
|
|
43
|
+
* native `list "Tomorrow"` one-call sort, not the scratch-park compound).
|
|
44
|
+
*/
|
|
45
|
+
function packedTomorrowOf(packedToday) {
|
|
46
|
+
const iso = decodePackedDate(packedToday);
|
|
47
|
+
return iso === null ? packedToday : encodePackedDate(addDaysIso(iso, 1));
|
|
48
|
+
}
|
|
49
|
+
function scheduleBucket(row, packedToday) {
|
|
50
|
+
if (row.start === 0)
|
|
51
|
+
return "inbox";
|
|
52
|
+
if (row.startDate !== null) {
|
|
53
|
+
if (row.startDate <= packedToday)
|
|
54
|
+
return row.startBucket === 1 ? "evening" : "today";
|
|
55
|
+
return `scheduled:${row.startDate}`;
|
|
56
|
+
}
|
|
57
|
+
if (row.start === 2)
|
|
58
|
+
return "someday";
|
|
59
|
+
return "anytime";
|
|
60
|
+
}
|
|
61
|
+
/** An app-default target for a bounce-dependent placement while bounce is off. */
|
|
62
|
+
function bounceDisabledTarget(what) {
|
|
63
|
+
return {
|
|
64
|
+
scope: null,
|
|
65
|
+
reason: `${what} needs the when= bounce, which is disabled (bounce-enabled=false) — ` +
|
|
66
|
+
"re-enable it with `things config set bounce-enabled true`",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The reorder protocol for a row's container × bucket, per the REORDGAPS +
|
|
71
|
+
* BOUNCE2 verdicts (docs/lab/reordgaps-results.md, spec §4 rule 5). GUARANTEED:
|
|
72
|
+
* loose inbox/today/evening/someday/anytime (ANYBNC bounce); a project's
|
|
73
|
+
* unheaded anytime OR someday children (SOMEORD-b, native `index`); an area's
|
|
74
|
+
* anytime members; an area's someday members (SOMEBNC-area bounce — was §9f-
|
|
75
|
+
* prohibited); a heading's anytime children (BOUNCE2-h forward-order bounce); a
|
|
76
|
+
* container's UNHEADED same-day scheduled children (DAYORD-b native todayIndex re-
|
|
77
|
+
* rank — the single-project degenerate case); ANY OTHER future day-group — loose,
|
|
78
|
+
* direct-area, headed, cross-container, or any mix incl. AREA-LESS scheduled
|
|
79
|
+
* PROJECT rows — via the SIT4 dated `day` bounce (cross-date re-when round-trip,
|
|
80
|
+
* per-type legs, reminder/deadline/heading-FK preserving; the one-call `tomorrow`
|
|
81
|
+
* sort when the day is tomorrow); a heading's SOMEDAY children (HEADSUB1 heading-
|
|
82
|
+
* someday re-head-in-order back-insert); a container child's EVENING sub-bucket,
|
|
83
|
+
* to-do OR project (HEADSUB1 Arm D + ORDFIN1 Arm 2b + SIT4 EVEORD — the shipped
|
|
84
|
+
* `evening` bounce accepts project/area/HEADED movees, heading FK preserved,
|
|
85
|
+
* projects share the evening axis); area-less someday projects; top-level anytime
|
|
86
|
+
* projects. APP-DEFAULT: an AREA project's future-day cell (only area-less project
|
|
87
|
+
* rows are proven — SIT4 DAYBNC); repeating TEMPLATE rows (§9e). When bounce is
|
|
88
|
+
* DISABLED the bounce-dependent classes degrade to app-default naming the flag —
|
|
89
|
+
* never a destructive or unverified fallback.
|
|
90
|
+
*/
|
|
91
|
+
function reorderTargetOf(row, isTodo, packedToday, bounceEnabled) {
|
|
92
|
+
if (row.isTemplate) {
|
|
93
|
+
return { scope: null, reason: "a repeating template (unreorderable — oddity §9e)" };
|
|
94
|
+
}
|
|
95
|
+
const bucket = scheduleBucket(row, packedToday);
|
|
96
|
+
// A same-day (today-proper) or future scheduled day, startBucket=0 — the
|
|
97
|
+
// DAYORD-b container todayIndex surface. The evening sub-bucket is distinct.
|
|
98
|
+
// NB: for a to-do an ARRIVED day (today-proper) is intercepted at the top of
|
|
99
|
+
// the isTodo block and routed to the today scope (it is a Today-view member,
|
|
100
|
+
// not a day-group member); the today-proper disjunct here only bears on the
|
|
101
|
+
// projects path below (where an arrived scheduled project stays app-default).
|
|
102
|
+
const containerDay = bucket === "today" || bucket.startsWith("scheduled:");
|
|
103
|
+
// The packed scheduled day, threaded into the future day-group targets so their
|
|
104
|
+
// refusal / disclosure copy names the DATE — a day-group's identity IS its day
|
|
105
|
+
// (two rows on different days are different groups). Reached only for strictly-
|
|
106
|
+
// future scheduled rows below, so startDate is non-null there.
|
|
107
|
+
const dayField = row.startDate !== null ? { day: row.startDate } : {};
|
|
108
|
+
// ORDFIN2 TOMORROWLIST: a row scheduled for TOMORROW (startBucket=0) rides the
|
|
109
|
+
// native one-call `list "Tomorrow"` day-sort instead of the SIT4 dated `day`
|
|
110
|
+
// bounce. It re-ranks the whole cross-container tomorrow group on todayIndex,
|
|
111
|
+
// projects included, preserving startDate/FKs (no §9g re-date) — cheaper than
|
|
112
|
+
// the 2N-leg bounce. The single-project container-day path keeps its own native
|
|
113
|
+
// re-rank; HEADED children never ride the native sort (it RIPS a heading, §9k),
|
|
114
|
+
// so they take the dated bounce even on tomorrow.
|
|
115
|
+
const isTomorrow = row.startBucket === 0 && row.startDate === packedTomorrowOf(packedToday);
|
|
116
|
+
if (isTodo) {
|
|
117
|
+
// ARRIVED Today-view members route DATE-FIRST, before any container branch.
|
|
118
|
+
// A dated row whose day has landed (startDate <= today) is a Today member —
|
|
119
|
+
// the GUI renders arrived/today-dated rows in the TODAY view; the Upcoming
|
|
120
|
+
// day-groups hold STRICTLY FUTURE dates only. So it reorders via the shipped
|
|
121
|
+
// cross-container today/evening scopes exactly like an undated Today member,
|
|
122
|
+
// regardless of its container (loose, project-, heading-, or area-child).
|
|
123
|
+
// `scheduleBucket` already classifies arrived rows date-first (#325 — arrived
|
|
124
|
+
// → today/evening, future → scheduled:<date>), so route on its verdict
|
|
125
|
+
// (single-source, no re-derive). Only strictly-future dates fall through to
|
|
126
|
+
// the per-container day-groups below. This is the same class of bug #325 fixed
|
|
127
|
+
// in `scheduleBucket`: classifying by date-group before checking arrived-ness
|
|
128
|
+
// misrouted an arrived member into a future day-group compound.
|
|
129
|
+
if (bucket === "today")
|
|
130
|
+
return { scope: "today" };
|
|
131
|
+
if (bucket === "evening") {
|
|
132
|
+
// Evening flag is live (startBucket=1) only while startDate == today (§9n);
|
|
133
|
+
// scheduleBucket already gates this — an arrived evening member front-inserts
|
|
134
|
+
// via the shipped `evening` bounce (container FK + startBucket=1 preserved,
|
|
135
|
+
// R07 reminder-loss caveat inherited). Same scope for loose and every child.
|
|
136
|
+
return bounceEnabled ? { scope: "evening" } : bounceDisabledTarget("evening-section order");
|
|
137
|
+
}
|
|
138
|
+
if (row.heading !== null) {
|
|
139
|
+
// Within-heading order (HEADSUB1). anytime → the forward-order bounce
|
|
140
|
+
// (BOUNCE2-h). someday → the re-head-in-order back-insert (heading-someday,
|
|
141
|
+
// Arm B/C — pure URL move legs, no gate). FUTURE same-day scheduled → the
|
|
142
|
+
// SIT4 dated `day` bounce (it preserves the heading FK, §2e/R21 — no unhead/
|
|
143
|
+
// re-head round-trip needed). --before/--after against an unmoved sibling
|
|
144
|
+
// rides these scopes' co-touch (handled by the anchor path), not here.
|
|
145
|
+
if (bucket === "anytime") {
|
|
146
|
+
return bounceEnabled
|
|
147
|
+
? { scope: "heading", container: row.heading }
|
|
148
|
+
: bounceDisabledTarget("within-heading order");
|
|
149
|
+
}
|
|
150
|
+
if (bucket === "someday")
|
|
151
|
+
return { scope: "heading-someday", container: row.heading };
|
|
152
|
+
// A heading's same-day SCHEDULED children ride the `day` dated bounce — SIT4
|
|
153
|
+
// DAYBNC proved the cross-date round-trip preserves the heading FK byte-
|
|
154
|
+
// identical and lands a headed child exactly, so the former heading-day
|
|
155
|
+
// unhead→container-day→re-head round-trip is gone. Container-less: `day` is a
|
|
156
|
+
// GLOBAL cross-container axis. Headed rows NEVER ride the native tomorrow
|
|
157
|
+
// sort (the private reorder RIPS a headed child, §9k/O06), so even on tomorrow
|
|
158
|
+
// a headed child bounces.
|
|
159
|
+
if (containerDay)
|
|
160
|
+
return { scope: "day", ...dayField };
|
|
161
|
+
return {
|
|
162
|
+
scope: null,
|
|
163
|
+
reason: `a heading's ${bucket} sub-bucket (no wired order surface for it)`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
if (row.project !== null) {
|
|
167
|
+
// Project unheaded: a FUTURE same-day scheduled bucket re-ranks todayIndex via
|
|
168
|
+
// the container specifier, date-preserving (DAYORD-b); everything else
|
|
169
|
+
// (anytime / someday) re-ranks cleanly by index through the native project
|
|
170
|
+
// reorder (O04, SOMEORD-b).
|
|
171
|
+
if (containerDay)
|
|
172
|
+
return { scope: "container-day", container: row.project, ...dayField };
|
|
173
|
+
return { scope: "project", container: row.project };
|
|
174
|
+
}
|
|
175
|
+
if (row.area !== null) {
|
|
176
|
+
// Area someday members: the SOMEBNC-area bounce (was §9f-prohibited via
|
|
177
|
+
// the destructive area reorder command — the planner NEVER uses that).
|
|
178
|
+
if (bucket === "someday") {
|
|
179
|
+
return bounceEnabled
|
|
180
|
+
? { scope: "area-someday", container: row.area }
|
|
181
|
+
: bounceDisabledTarget("an area's someday order");
|
|
182
|
+
}
|
|
183
|
+
if (bucket === "anytime")
|
|
184
|
+
return { scope: "area", container: row.area };
|
|
185
|
+
// A direct-area to-do's FUTURE scheduled DAY rides the `day` dated bounce
|
|
186
|
+
// (SIT4 DAYBNC — the when= round-trip preserves the area FK and needs no
|
|
187
|
+
// scratch project; container-less GLOBAL axis), or the one-call `list
|
|
188
|
+
// "Tomorrow"` sort when the day is tomorrow.
|
|
189
|
+
if (containerDay)
|
|
190
|
+
return isTomorrow ? { scope: "tomorrow", ...dayField } : { scope: "day", ...dayField };
|
|
191
|
+
return { scope: null, reason: "a direct-area to-do's scheduled bucket (app-default)" };
|
|
192
|
+
}
|
|
193
|
+
// loose:
|
|
194
|
+
if (bucket === "inbox")
|
|
195
|
+
return { scope: "inbox" };
|
|
196
|
+
if (bucket === "someday")
|
|
197
|
+
return { scope: "someday" };
|
|
198
|
+
if (bucket === "anytime") {
|
|
199
|
+
// ANYBNC reverse-order bounce for area-less loose anytime to-dos.
|
|
200
|
+
return bounceEnabled
|
|
201
|
+
? { scope: "anytime" }
|
|
202
|
+
: bounceDisabledTarget("area-less loose anytime order");
|
|
203
|
+
}
|
|
204
|
+
// A loose FUTURE Upcoming day rides the `day` dated bounce (SIT4 DAYBNC — the
|
|
205
|
+
// reverse-target when= round-trip front-inserts on the global todayIndex axis,
|
|
206
|
+
// no scratch project, no experimental gate), or the one-call `list "Tomorrow"`
|
|
207
|
+
// sort when the day is tomorrow.
|
|
208
|
+
return isTomorrow ? { scope: "tomorrow", ...dayField } : { scope: "day", ...dayField };
|
|
209
|
+
}
|
|
210
|
+
// projects:
|
|
211
|
+
// ARRIVED Today-view members route DATE-FIRST (mirrors the to-do path, #341). An
|
|
212
|
+
// arrived today-dated project is a Today member — the native Today reorder
|
|
213
|
+
// accepts projects intermixed (O12); an arrived This-Evening project rides the
|
|
214
|
+
// shipped `evening` bounce (SIT4 EVEORD — projects share the evening todayIndex
|
|
215
|
+
// axis, R07 reminder-loss caveat inherited). Only STRICTLY-FUTURE project rows
|
|
216
|
+
// fall through to the day-group / app-default routing below.
|
|
217
|
+
if (bucket === "today")
|
|
218
|
+
return { scope: "today" };
|
|
219
|
+
if (bucket === "evening") {
|
|
220
|
+
return bounceEnabled ? { scope: "evening" } : bounceDisabledTarget("evening-section order");
|
|
221
|
+
}
|
|
222
|
+
if (row.area !== null) {
|
|
223
|
+
// A project INSIDE an area on a strictly-future day now rides the dated `day`
|
|
224
|
+
// bounce (SIT5 AREAPROJDAY — the update-project when= legs preserve the area FK
|
|
225
|
+
// and re-enter at the day's global todayIndex min), or the one-call Tomorrow
|
|
226
|
+
// sort when the day is tomorrow. Someday stays app-default; anytime rides the
|
|
227
|
+
// area's native index order.
|
|
228
|
+
if (containerDay)
|
|
229
|
+
return isTomorrow ? { scope: "tomorrow", ...dayField } : { scope: "day", ...dayField };
|
|
230
|
+
return bucket === "someday"
|
|
231
|
+
? { scope: null, reason: "a someday project inside an area (app-default)" }
|
|
232
|
+
: { scope: "area", container: row.area };
|
|
233
|
+
}
|
|
234
|
+
if (bucket === "someday")
|
|
235
|
+
return { scope: "someday" };
|
|
236
|
+
if (bucket === "anytime") {
|
|
237
|
+
// Top-level sidebar order is bounce-only (P8e).
|
|
238
|
+
return bounceEnabled ? { scope: "projects" } : bounceDisabledTarget("top-level projects order");
|
|
239
|
+
}
|
|
240
|
+
// An AREA-LESS scheduled PROJECT row on a strictly-future day: the dated `day`
|
|
241
|
+
// bounce (SIT4 DAYBNC — DP rows front-insert on the shared todayIndex axis via
|
|
242
|
+
// update-project), or the one-call `list "Tomorrow"` sort when the day is
|
|
243
|
+
// tomorrow. This is the loose-scheduled-PROJECT-row cell, formerly app-default.
|
|
244
|
+
if (containerDay)
|
|
245
|
+
return isTomorrow ? { scope: "tomorrow", ...dayField } : { scope: "day", ...dayField };
|
|
246
|
+
return { scope: null, reason: "a scheduled day bucket (app-default)" };
|
|
247
|
+
}
|
|
248
|
+
const IN_AXES = ["today", "evening", "anytime", "someday", "inbox"];
|
|
249
|
+
/**
|
|
250
|
+
* The INDEX-axis reorder target of a row — the target it has through its CONTAINER
|
|
251
|
+
* (a project/area/heading `index`, or the loose Anytime bucket), IGNORING any
|
|
252
|
+
* Today/Evening membership. For a dual-axis row this is the alternative to its
|
|
253
|
+
* view-axis (today/evening) target; computed by classifying the row as if it sat
|
|
254
|
+
* in its container's ANYTIME bucket (start=1, no startDate — date-independent, so
|
|
255
|
+
* the packedToday argument is irrelevant here).
|
|
256
|
+
*/
|
|
257
|
+
function indexAxisTargetOf(row, bounceEnabled) {
|
|
258
|
+
const asAnytime = { ...row, start: 1, startDate: null, startBucket: 0 };
|
|
259
|
+
// Classify by the row's REAL kind — a project's index axis is its area / the
|
|
260
|
+
// sidebar (`projects`), NOT the loose Anytime to-do list. Conflating them (an
|
|
261
|
+
// isTodo=true hardcode) makes a mixed to-do+project Today block look like ONE
|
|
262
|
+
// shared loose index bucket and spuriously "dual-axis ambiguous".
|
|
263
|
+
return reorderTargetOf(asAnytime, row.type === 0, 0, bounceEnabled);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* True when a row's INDEX-axis target preserves the Today/Evening flag. TWO
|
|
267
|
+
* flag-safe families:
|
|
268
|
+
* - the NATIVE project/area `index` re-rank writes only `index` (startBucket/
|
|
269
|
+
* startDate kept — sit3 EVEPROJ / DAYORD-b);
|
|
270
|
+
* - the SIT6 flag-safe MOVE protocols route a FLAGGED touched set off the
|
|
271
|
+
* de-Today bounce onto the URL move family (heading→HEADMOVE, loose anytime→
|
|
272
|
+
* LOOSEPARK, area-less sidebar projects→PROJPARK), which preserve the flag +
|
|
273
|
+
* reminder + deadline. So the heading / anytime / projects index axes are now
|
|
274
|
+
* honest alternatives for a Today/Evening member too.
|
|
275
|
+
* This is consulted only where the index target's scope is non-null; when bounce
|
|
276
|
+
* is disabled those scopes degrade to `{ scope: null }` upstream, so no unsafe
|
|
277
|
+
* bounce is ever offered.
|
|
278
|
+
*/
|
|
279
|
+
function indexAxisTodaySafe(target) {
|
|
280
|
+
return (target.scope === "project" ||
|
|
281
|
+
target.scope === "area" ||
|
|
282
|
+
target.scope === "heading" ||
|
|
283
|
+
target.scope === "anytime" ||
|
|
284
|
+
target.scope === "projects");
|
|
285
|
+
}
|
|
286
|
+
/** A row's Today/Evening view, or null when it is not an arrived view member. */
|
|
287
|
+
function viewOf(row, packedToday) {
|
|
288
|
+
const b = scheduleBucket(row, packedToday);
|
|
289
|
+
return b === "today" || b === "evening" ? b : null;
|
|
290
|
+
}
|
|
291
|
+
/** The container uuid a row's INDEX axis lives in (most-specific), or null (loose). */
|
|
292
|
+
function indexAxisContainerOf(row) {
|
|
293
|
+
return row.heading ?? row.project ?? row.area ?? null;
|
|
294
|
+
}
|
|
295
|
+
/** A container's display title (for the `--in <title>` spelling), else its uuid. */
|
|
296
|
+
function containerLabel(deps, uuid) {
|
|
297
|
+
const r = deps.db.prepare("SELECT title FROM TMTask WHERE uuid = ?").get(uuid);
|
|
298
|
+
return r?.title ?? uuid;
|
|
299
|
+
}
|
|
300
|
+
/** The kind of a resolved `--in` container uuid. */
|
|
301
|
+
function containerKindOf(deps, uuid) {
|
|
302
|
+
const t = deps.db.prepare("SELECT type FROM TMTask WHERE uuid = ?").get(uuid);
|
|
303
|
+
if (t?.type === 1)
|
|
304
|
+
return "project";
|
|
305
|
+
if (t?.type === 2)
|
|
306
|
+
return "heading";
|
|
307
|
+
return "area";
|
|
308
|
+
}
|
|
309
|
+
/** A short phrase naming the display bucket(s) the set currently sits in. */
|
|
310
|
+
function describeSetLocation(rows, packedToday) {
|
|
311
|
+
const buckets = [...new Set(rows.map((r) => scheduleBucket(r, packedToday)))];
|
|
312
|
+
return `in the ${buckets.join(" / ")} bucket`;
|
|
313
|
+
}
|
|
314
|
+
/** Parse a raw `--in <target>` into a list axis or a resolved container ref. */
|
|
315
|
+
function parseInTarget(deps, raw, rows) {
|
|
316
|
+
const norm = raw.trim().toLowerCase();
|
|
317
|
+
if (IN_AXES.includes(norm))
|
|
318
|
+
return { axis: norm };
|
|
319
|
+
if (norm === "loose") {
|
|
320
|
+
return {
|
|
321
|
+
error: '`--in loose` is not valid — "loose" is a read view, not a reorder bucket; ' +
|
|
322
|
+
"use --in anytime / --in someday, or a project/area/heading ref",
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
// A raw uuid that IS a movee's own container (project/area/heading).
|
|
326
|
+
const ownContainers = new Set(rows.map(indexAxisContainerOf).filter((u) => u !== null));
|
|
327
|
+
if (ownContainers.has(raw)) {
|
|
328
|
+
return { container: { uuid: raw, kind: containerKindOf(deps, raw) } };
|
|
329
|
+
}
|
|
330
|
+
const p = resolveProject(deps.db, { title: raw });
|
|
331
|
+
if (p.resolved?.uuid !== undefined) {
|
|
332
|
+
return { container: { uuid: p.resolved.uuid, kind: "project" } };
|
|
333
|
+
}
|
|
334
|
+
const a = resolveArea(deps.db, { title: raw });
|
|
335
|
+
if (a.resolved?.uuid !== undefined) {
|
|
336
|
+
return { container: { uuid: a.resolved.uuid, kind: "area" } };
|
|
337
|
+
}
|
|
338
|
+
// A heading, resolved within the movees' shared project.
|
|
339
|
+
const proj = rows[0]?.project ?? headingProjectOf(deps.db, rows[0]?.heading ?? null);
|
|
340
|
+
if (proj !== null && proj !== undefined) {
|
|
341
|
+
const h = resolveHeading(deps.db, proj, raw);
|
|
342
|
+
if (h.resolved?.uuid !== undefined) {
|
|
343
|
+
return { container: { uuid: h.resolved.uuid, kind: "heading" } };
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
error: `--in "${raw}" did not resolve to a project, area, or heading ` +
|
|
348
|
+
"(or one of today | evening | anytime | someday | inbox)",
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Resolve the reorder AXIS for a `things reorder` set per `--in` (the ratified
|
|
353
|
+
* axis-disambiguation contract). Returns a per-row target classifier, or a
|
|
354
|
+
* refusal. The `todo move` anchor-implied reposition (verb !== "reorder") keeps
|
|
355
|
+
* its prior single-axis behavior — no `--in`, no ambiguity gate.
|
|
356
|
+
*/
|
|
357
|
+
function resolveReorderAxis(deps, op, rows, inTarget, packedToday, verb, position) {
|
|
358
|
+
const isTodo = op === "todo.move";
|
|
359
|
+
const bounceEnabled = deps.config.bounceEnabled;
|
|
360
|
+
const base = (r) => reorderTargetOf(r, isTodo, packedToday, bounceEnabled);
|
|
361
|
+
if (verb !== "reorder")
|
|
362
|
+
return { targetOf: base };
|
|
363
|
+
// The coherence set is the movees PLUS the anchor (spec: "the movee set AND
|
|
364
|
+
// anchor is coherent on both axes"). A cross-container anchor breaks the index
|
|
365
|
+
// axis (so the reorder is unambiguously the view axis); an unresolved anchor is
|
|
366
|
+
// left to the downstream anchor validation.
|
|
367
|
+
const anchorRow = position !== undefined && ("before" in position || "after" in position)
|
|
368
|
+
? (() => {
|
|
369
|
+
const ar = resolveMovee(deps, "before" in position ? position.before : position.after);
|
|
370
|
+
return ar instanceof ReferenceResolutionError ? undefined : loadRow(deps.db, ar.uuid);
|
|
371
|
+
})()
|
|
372
|
+
: undefined;
|
|
373
|
+
const coherence = anchorRow !== undefined ? [...rows, anchorRow] : rows;
|
|
374
|
+
// One shared Today/Evening view? (Mixed today+evening is caught downstream by the
|
|
375
|
+
// single-bucket guard; view=null means "not a clean view set".)
|
|
376
|
+
const views = new Set(coherence.map((r) => viewOf(r, packedToday)));
|
|
377
|
+
const view = views.size === 1 ? [...views][0] : null;
|
|
378
|
+
// The shared index-axis container target, if the whole coherence set has one
|
|
379
|
+
// (classifying each row as though it sat in its container's anytime bucket).
|
|
380
|
+
const indexTargets = coherence.map((r) => indexAxisTargetOf(r, bounceEnabled));
|
|
381
|
+
const indexKeys = new Set(indexTargets.map(containerKey));
|
|
382
|
+
const indexTarget = indexKeys.size === 1 && indexTargets[0]?.scope != null
|
|
383
|
+
? indexTargets[0]
|
|
384
|
+
: null;
|
|
385
|
+
// Force the CONTAINER index axis for view members, leaving non-view members on
|
|
386
|
+
// their natural target (used when --in names a container).
|
|
387
|
+
const indexClassifier = (r) => viewOf(r, packedToday) !== null ? indexAxisTargetOf(r, bounceEnabled) : base(r);
|
|
388
|
+
if (inTarget !== undefined) {
|
|
389
|
+
const parsed = parseInTarget(deps, inTarget, rows);
|
|
390
|
+
if ("error" in parsed)
|
|
391
|
+
return { refused: refused(op, "usage", parsed.error) };
|
|
392
|
+
if ("axis" in parsed) {
|
|
393
|
+
const axis = parsed.axis;
|
|
394
|
+
if (axis === "today" || axis === "evening") {
|
|
395
|
+
if (view === axis)
|
|
396
|
+
return { targetOf: base };
|
|
397
|
+
return {
|
|
398
|
+
refused: refused(op, "blocked", `--in ${axis} but the items are ${describeSetLocation(rows, packedToday)} — they are ` +
|
|
399
|
+
`not ${axis === "today" ? "Today" : "This Evening"} members`, "name the axis the items actually share, or omit --in if it is unambiguous"),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
// anytime | someday | inbox — a loose list axis.
|
|
403
|
+
// `--in anytime` on genuinely-LOOSE Today/Evening members is now HONEST: the
|
|
404
|
+
// SIT6 LOOSEPARK protocol reorders the loose Anytime `index` axis via URL
|
|
405
|
+
// move legs, preserving the flag (no de-Today). Route the whole (possibly
|
|
406
|
+
// mixed flagged+plain) loose set to the anytime index scope. someday/inbox
|
|
407
|
+
// have NO flag-safe twin, so a view member on those still de-Todays → refused.
|
|
408
|
+
if (axis === "anytime" &&
|
|
409
|
+
bounceEnabled &&
|
|
410
|
+
rows.every((r) => indexAxisContainerOf(r) === null) &&
|
|
411
|
+
rows.some((r) => viewOf(r, packedToday) !== null)) {
|
|
412
|
+
return { targetOf: indexClassifier, indexAxis: true };
|
|
413
|
+
}
|
|
414
|
+
if (view !== null) {
|
|
415
|
+
return {
|
|
416
|
+
refused: refused(op, "blocked", `--in ${axis} would order ${view === "today" ? "Today" : "This Evening"} members on ` +
|
|
417
|
+
`the loose ${axis} axis, whose when= legs OVERWRITE the Today/Evening flag ` +
|
|
418
|
+
"(de-Today hazard) — refused rather than silently stripping it" +
|
|
419
|
+
(axis === "anytime"
|
|
420
|
+
? " (the flag-safe LOOSEPARK path needs every item genuinely loose and bounce-enabled)"
|
|
421
|
+
: ""), `reorder them in the view with --in ${view}, or reschedule them off Today first`),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
const wrong = rows.filter((r) => scheduleBucket(r, packedToday) !== axis ||
|
|
425
|
+
r.project !== null ||
|
|
426
|
+
r.area !== null ||
|
|
427
|
+
r.heading !== null);
|
|
428
|
+
if (wrong.length > 0) {
|
|
429
|
+
return {
|
|
430
|
+
refused: refused(op, "usage", `--in ${axis} but the items are ${describeSetLocation(rows, packedToday)}` +
|
|
431
|
+
(rows.some((r) => r.project !== null || r.area !== null || r.heading !== null)
|
|
432
|
+
? " (and some are in a container, not loose)"
|
|
433
|
+
: ""), "omit --in and reorder the items where they are, or move them first"),
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
return { targetOf: base };
|
|
437
|
+
}
|
|
438
|
+
// A container ref (project/area/heading).
|
|
439
|
+
const container = parsed.container;
|
|
440
|
+
const notIn = rows.filter((r) => indexAxisContainerOf(r) !== container.uuid);
|
|
441
|
+
if (notIn.length > 0) {
|
|
442
|
+
return {
|
|
443
|
+
refused: refused(op, "usage", `--in "${inTarget}" (${container.kind} ${container.uuid}) but these items are not in it: ` +
|
|
444
|
+
notIn.map((r) => r.uuid).join(", "), "name the container the items actually share, or omit --in"),
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
if (view !== null) {
|
|
448
|
+
const forced = indexAxisTargetOf(rows[0], bounceEnabled);
|
|
449
|
+
if (!indexAxisTodaySafe(forced)) {
|
|
450
|
+
return {
|
|
451
|
+
refused: refused(op, "blocked", `--in "${inTarget}" would order these ${view === "today" ? "Today" : "This Evening"} ` +
|
|
452
|
+
`members on ${describeScope(forced)}, a when= bounce whose legs OVERWRITE the ` +
|
|
453
|
+
"Today/Evening flag (de-Today hazard); no flag-safe protocol reaches that bucket, so it " +
|
|
454
|
+
"is refused rather than silently stripping the flag", `reorder them in the view with --in ${view}, or reschedule them off Today first`),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return { targetOf: indexClassifier, indexAxis: true };
|
|
459
|
+
}
|
|
460
|
+
// No --in: refuse only when BOTH axes are honest — a shared view AND a flag-safe
|
|
461
|
+
// container index (native project/area re-rank OR a SIT6 flag-safe MOVE protocol,
|
|
462
|
+
// heading/anytime/projects). The two readings are genuinely ambiguous, so name
|
|
463
|
+
// one with --in. This replaces the old silent always-Today resolution.
|
|
464
|
+
if (rows.length >= 2 &&
|
|
465
|
+
view !== null &&
|
|
466
|
+
indexTarget !== null &&
|
|
467
|
+
indexTarget.scope !== null &&
|
|
468
|
+
indexAxisTodaySafe(indexTarget)) {
|
|
469
|
+
return {
|
|
470
|
+
refused: refused(op, "blocked", `these items are ${view === "today" ? "Today" : "This Evening"} members that also share ` +
|
|
471
|
+
`${describeScope(indexTarget)} — the reorder is ambiguous between the ${view} view ` +
|
|
472
|
+
"(todayIndex slots) and the container (index slots); say which with --in", `--in ${view} to reorder the ${view} view, or ${inSpellingFor(deps, indexTarget)} to reorder within the container`),
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
// A MIXED dual-axis set (some Today/Evening-flagged, some plain, all sharing one
|
|
476
|
+
// flag-safe container index) is NOT ambiguous: the plain members are not view
|
|
477
|
+
// members, so there is no shared view to reorder them in — the container index is
|
|
478
|
+
// the only coherent reading. Auto-route it there (a flag-safe MOVE protocol keeps
|
|
479
|
+
// the flagged members' flag). Without this the flagged members classify to the
|
|
480
|
+
// today view while the plain ones classify to the container, and the set would
|
|
481
|
+
// spuriously "span containers".
|
|
482
|
+
if (rows.length >= 2 &&
|
|
483
|
+
view === null &&
|
|
484
|
+
indexTarget !== null &&
|
|
485
|
+
indexTarget.scope !== null &&
|
|
486
|
+
indexAxisTodaySafe(indexTarget) &&
|
|
487
|
+
coherence.some((r) => viewOf(r, packedToday) !== null)) {
|
|
488
|
+
return { targetOf: indexClassifier, indexAxis: true };
|
|
489
|
+
}
|
|
490
|
+
return { targetOf: base };
|
|
491
|
+
}
|
|
492
|
+
/** The `--in` spelling that names an index target (a loose axis, or a container). */
|
|
493
|
+
function inSpellingFor(deps, target) {
|
|
494
|
+
if (target.scope === null)
|
|
495
|
+
return "--in <container>"; // unreachable: only real index targets reach here
|
|
496
|
+
if (target.scope === "anytime")
|
|
497
|
+
return "--in anytime";
|
|
498
|
+
if (target.container !== undefined)
|
|
499
|
+
return `--in "${containerLabel(deps, target.container)}"`;
|
|
500
|
+
return `--in ${target.scope}`;
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* The STRUCTURAL container a row shares with its siblings — the rule-2 span key.
|
|
504
|
+
* Distinct from the reorder target: two in-project to-dos with different
|
|
505
|
+
* schedules share ONE structural container (their project) but different display
|
|
506
|
+
* buckets (a rule-4 concern, not a rule-2 span).
|
|
507
|
+
*/
|
|
508
|
+
function structuralKey(row, packedToday) {
|
|
509
|
+
if (row.heading !== null)
|
|
510
|
+
return `heading:${row.heading}`;
|
|
511
|
+
if (row.project !== null)
|
|
512
|
+
return `project:${row.project}`;
|
|
513
|
+
if (row.area !== null)
|
|
514
|
+
return `area:${row.area}`;
|
|
515
|
+
return `list:${scheduleBucket(row, packedToday)}`;
|
|
516
|
+
}
|
|
517
|
+
/** A stable key for a placement target (guaranteed scope, or its app/prohibited reason). */
|
|
518
|
+
function containerKey(target) {
|
|
519
|
+
if (target.scope === null)
|
|
520
|
+
return `app:${target.reason}`;
|
|
521
|
+
return target.container !== undefined ? `${target.scope}:${target.container}` : target.scope;
|
|
522
|
+
}
|
|
523
|
+
function refused(op, refusal, detail, remediation) {
|
|
524
|
+
return {
|
|
525
|
+
kind: "move-refused",
|
|
526
|
+
op,
|
|
527
|
+
refusal,
|
|
528
|
+
detail,
|
|
529
|
+
...(remediation !== undefined && { remediation }),
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
// The ratified teaching errors (spec §4 bare-invocation block, §5, §7).
|
|
533
|
+
const BARE_TODO_MOVE = "`todo move` needs a destination or a position. To change what a to-do belongs to, name " +
|
|
534
|
+
"one of --to-project / --to-heading / --to-area (or --no-heading / --loose to detach). To " +
|
|
535
|
+
"rearrange to-dos that already share a container, use `todo reorder` (or pass --first / " +
|
|
536
|
+
"--last / --before / --after here). A bare `todo move` has no meaning — it would move " +
|
|
537
|
+
"nothing.";
|
|
538
|
+
const BARE_PROJECT_MOVE = "`project move` needs a destination or a position. Name --to-area (or --no-area to leave " +
|
|
539
|
+
"the area) to change the project's area, or pass --first / --last / --before / --after to " +
|
|
540
|
+
"reorder it among its siblings. A bare `project move` has no meaning.";
|
|
541
|
+
// ------------------------------------------------------------ scope-aware resolve
|
|
542
|
+
function resolveMovee(deps, ref) {
|
|
543
|
+
try {
|
|
544
|
+
// Scope-aware (the no-oracle guarantee): an out-of-scope ref resolves to
|
|
545
|
+
// not-found through the identical path a nonexistent one does (#276).
|
|
546
|
+
const taskScope = deps.scope !== undefined ? taskMembershipClause(deps.scope) : undefined;
|
|
547
|
+
return { uuid: resolveTaskUuidPrefix(deps.db, ref, "item", taskScope) };
|
|
548
|
+
}
|
|
549
|
+
catch (err) {
|
|
550
|
+
if (err instanceof ReferenceResolutionError)
|
|
551
|
+
return err;
|
|
552
|
+
// A short/invalid prefix is a plain RangeError — wrap as not-found.
|
|
553
|
+
return new ReferenceResolutionError(err instanceof Error ? err.message : String(err), {
|
|
554
|
+
code: "not-found",
|
|
555
|
+
ref,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
// --------------------------------------------------------------- todo move
|
|
560
|
+
export async function runTodoMove(deps, request, options = {}) {
|
|
561
|
+
const op = "todo.move";
|
|
562
|
+
const now = deps.now?.() ?? new Date();
|
|
563
|
+
const packedToday = encodePackedDate(localToday(now));
|
|
564
|
+
if (request.uuids.length === 0) {
|
|
565
|
+
return refused(op, "usage", "no to-dos given — name at least one to-do to move");
|
|
566
|
+
}
|
|
567
|
+
// Resolve movees + homogeneity (rule 3).
|
|
568
|
+
const rows = [];
|
|
569
|
+
const wrongKind = [];
|
|
570
|
+
for (const ref of request.uuids) {
|
|
571
|
+
const r = resolveMovee(deps, ref);
|
|
572
|
+
if (r instanceof ReferenceResolutionError) {
|
|
573
|
+
return {
|
|
574
|
+
kind: "move-refused",
|
|
575
|
+
op,
|
|
576
|
+
refusal: "usage",
|
|
577
|
+
detail: r.message,
|
|
578
|
+
candidates: r.candidates,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
const row = loadRow(deps.db, r.uuid);
|
|
582
|
+
if (row === undefined) {
|
|
583
|
+
return refused(op, "usage", `no to-do matches "${ref}"`);
|
|
584
|
+
}
|
|
585
|
+
if (row.type !== 0)
|
|
586
|
+
wrongKind.push({ ref: r.uuid, kind: KIND_LABEL[row.type] ?? "item" });
|
|
587
|
+
rows.push(row);
|
|
588
|
+
}
|
|
589
|
+
if (wrongKind.length > 0) {
|
|
590
|
+
const list = wrongKind.map((w) => `${w.ref} (${w.kind})`).join(", ");
|
|
591
|
+
return refused(op, "usage", `homogeneous movee kinds required — \`todo move\` moves to-dos only, but these are not to-dos: ${list}`, "move projects with `project move` and headings with `project move-heading`");
|
|
592
|
+
}
|
|
593
|
+
const dest = request.destination;
|
|
594
|
+
const position = request.position;
|
|
595
|
+
// Detach-family teaching errors (§5/§7) before any wire leg.
|
|
596
|
+
if (dest?.kind === "detach") {
|
|
597
|
+
return refused(op, "usage", "`--detach` was removed. To leave the heading but stay in the project use --no-heading; " +
|
|
598
|
+
"to leave the project, area, AND heading use --loose.");
|
|
599
|
+
}
|
|
600
|
+
if (dest?.kind === "no-area") {
|
|
601
|
+
// A to-do's area is inherited from its project (project child) or direct
|
|
602
|
+
// (loose direct-area to-do). Neither takes --no-area (§5).
|
|
603
|
+
const child = rows.some((r) => r.project !== null || r.heading !== null);
|
|
604
|
+
return refused(op, "usage", child
|
|
605
|
+
? "`--no-area` does not apply to a to-do: its area comes from its project. Use --loose to " +
|
|
606
|
+
"leave the project (and its area), or move the project with `project move --no-area`."
|
|
607
|
+
: "`--no-area` does not apply to a to-do — for a to-do this is `--loose` (leaving a " +
|
|
608
|
+
"direct-area to-do's area severs its only container). No duplicate spellings.");
|
|
609
|
+
}
|
|
610
|
+
// Bare invocation (§4): the ratified teaching error.
|
|
611
|
+
if (dest === undefined && position === undefined) {
|
|
612
|
+
return refused(op, "usage", BARE_TODO_MOVE);
|
|
613
|
+
}
|
|
614
|
+
// No destination + a position → an anchor-implied reposition (rule 2): pure
|
|
615
|
+
// positioning within the movees' current shared container. Delegate to the
|
|
616
|
+
// reorder machinery, but with move's own cross-container teaching copy.
|
|
617
|
+
if (dest === undefined) {
|
|
618
|
+
return repositionInPlace(deps, op, rows, position, packedToday, options, "move");
|
|
619
|
+
}
|
|
620
|
+
// --no-heading / --loose validation against the movees' current containment.
|
|
621
|
+
if (dest.kind === "no-heading") {
|
|
622
|
+
const noProject = rows.filter((r) => r.project === null && r.heading === null);
|
|
623
|
+
if (noProject.length > 0) {
|
|
624
|
+
return refused(op, "usage", "--no-heading needs the to-do to be in a project — these are not in any project: " +
|
|
625
|
+
noProject.map((r) => r.uuid).join(", "), "use --loose to detach a loose to-do's schedule-only state, or --to-project to place it");
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
// Where the movees will LAND (computed pre-membership from the destination +
|
|
629
|
+
// each movee's kept schedule) — drives the placement class and the pre-flight
|
|
630
|
+
// anchor check.
|
|
631
|
+
const landing = uniformLanding(rows.map((r) => todoLandedRow(deps, dest, r)), true, packedToday, deps.config.bounceEnabled);
|
|
632
|
+
// Fail-closed BEFORE any membership leg: an explicit --before/--after into a
|
|
633
|
+
// destination bucket with no guaranteed protocol cannot be honored, so refuse
|
|
634
|
+
// the whole move rather than move-then-drop-the-position (rule 5 honesty).
|
|
635
|
+
const anchorRefusal = preflightAnchor(op, position, landing);
|
|
636
|
+
if (anchorRefusal !== null)
|
|
637
|
+
return anchorRefusal;
|
|
638
|
+
// Membership legs (rule 1: selection order). Each movee gets one todo.move.
|
|
639
|
+
const legParams = membershipLeg(deps, dest, rows, options);
|
|
640
|
+
if ("refused" in legParams)
|
|
641
|
+
return legParams.refused;
|
|
642
|
+
const membership = [];
|
|
643
|
+
for (const leg of legParams.legs) {
|
|
644
|
+
const res = await runMutation(deps, "todo.move", leg.params, {
|
|
645
|
+
// A leg-pinned vector is the fallback transport (inbox → applescript); an
|
|
646
|
+
// explicit caller --vector still wins because legOptions spreads after it.
|
|
647
|
+
...(leg.vector !== undefined && { vector: leg.vector }),
|
|
648
|
+
...legOptions(options),
|
|
649
|
+
...(options.dryRun === true && { dryRun: true }),
|
|
650
|
+
});
|
|
651
|
+
membership.push(res);
|
|
652
|
+
if (res.kind !== "ok" && res.kind !== "dry-run") {
|
|
653
|
+
return {
|
|
654
|
+
kind: "move-leg-failed",
|
|
655
|
+
op,
|
|
656
|
+
detail: `moving ${leg.uuid} into the destination failed (${res.kind})`,
|
|
657
|
+
failed: res,
|
|
658
|
+
completed: membership.slice(0, -1),
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return finishPlacement(deps, op, rows, landing, position, packedToday, options, membership);
|
|
663
|
+
}
|
|
664
|
+
/** Pre-flight refusal for an explicit anchor into a no-protocol destination bucket. */
|
|
665
|
+
function preflightAnchor(op, position, landing) {
|
|
666
|
+
if (position === undefined || !("before" in position || "after" in position))
|
|
667
|
+
return null;
|
|
668
|
+
// The ONLY remaining anchor refusal is a bucket with no reorder protocol at
|
|
669
|
+
// all. Within-heading --before/--after IS supported now — the extended bounce
|
|
670
|
+
// co-bounces the members between the block and the anchor (disclosed).
|
|
671
|
+
if (landing.scope !== null)
|
|
672
|
+
return null;
|
|
673
|
+
// A mixed-stage selection (movees span the destination's stage sub-buckets) is
|
|
674
|
+
// the per-bucket anchor refusal (§4 rule 4): an anchor has no honest cross-bucket
|
|
675
|
+
// meaning. Distinguished from a single bucket that simply has no protocol.
|
|
676
|
+
const spansBuckets = landing.reason.includes("span display buckets");
|
|
677
|
+
return refused(op, landing.prohibited === true ? "blocked" : "unsupported", spansBuckets
|
|
678
|
+
? "--before/--after cannot anchor a selection that spans stage sub-buckets in the " +
|
|
679
|
+
"destination — every movee must share the anchor's sub-bucket"
|
|
680
|
+
: `--before/--after cannot be honored in the destination (${describeScope(landing)}) — ` +
|
|
681
|
+
"no reorder protocol positions within that bucket", spansBuckets
|
|
682
|
+
? "split the move by bucket, or drop the anchor (use --first/--last — they apply per sub-bucket)"
|
|
683
|
+
: "use --first/--last, or omit the position (membership still lands)");
|
|
684
|
+
}
|
|
685
|
+
/** Build one todo.move leg per movee for the requested destination. */
|
|
686
|
+
function membershipLeg(deps, dest, rows, _options) {
|
|
687
|
+
const op = "todo.move";
|
|
688
|
+
const legs = [];
|
|
689
|
+
// Resolve a shared project for a heading destination (rule: within
|
|
690
|
+
// --to-project's project, else the movees' shared project).
|
|
691
|
+
if (dest.kind === "heading") {
|
|
692
|
+
let projectUuid = null;
|
|
693
|
+
if (dest.project !== undefined) {
|
|
694
|
+
const p = resolveProject(deps.db, dest.project);
|
|
695
|
+
if (p.resolved === null) {
|
|
696
|
+
return {
|
|
697
|
+
refused: refused(op, "usage", "the --to-project for this heading did not resolve"),
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
projectUuid = p.resolved.uuid;
|
|
701
|
+
}
|
|
702
|
+
else {
|
|
703
|
+
// The movees' shared project (direct, or the heading's project).
|
|
704
|
+
const projSet = new Set(rows.map((r) => r.project ?? headingProjectOf(deps.db, r.heading)));
|
|
705
|
+
projSet.delete(null);
|
|
706
|
+
if (projSet.size !== 1) {
|
|
707
|
+
return {
|
|
708
|
+
refused: refused(op, "usage", "the movees span projects (or none), so a --to-heading selector is ambiguous — " +
|
|
709
|
+
"name --to-project <ref> to say which project's heading you mean"),
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
projectUuid = [...projSet][0];
|
|
713
|
+
}
|
|
714
|
+
const h = resolveHeading(deps.db, projectUuid, dest.sel);
|
|
715
|
+
if (h.resolved === null) {
|
|
716
|
+
return {
|
|
717
|
+
refused: {
|
|
718
|
+
kind: "move-refused",
|
|
719
|
+
op,
|
|
720
|
+
refusal: "usage",
|
|
721
|
+
detail: h.matches > 1
|
|
722
|
+
? `the heading selector "${dest.sel}" matches ${h.matches} headings — disambiguate with a uuid`
|
|
723
|
+
: `no heading matching "${dest.sel}" in the destination project`,
|
|
724
|
+
},
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
for (const r of rows) {
|
|
728
|
+
legs.push({
|
|
729
|
+
uuid: r.uuid,
|
|
730
|
+
params: { uuid: r.uuid, project: { uuid: projectUuid }, heading: h.resolved.uuid },
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
return { legs };
|
|
734
|
+
}
|
|
735
|
+
// `loose` is the reserved READ-only pseudo-area — never a move destination.
|
|
736
|
+
// (Detaching a to-do to area-less is `--loose`, not `--to-area loose`.)
|
|
737
|
+
if (dest.kind === "area" && isLooseRef(dest.ref.uuid ?? dest.ref.title ?? "")) {
|
|
738
|
+
return { refused: refused(op, "usage", LOOSE_TO_AREA_REFUSAL) };
|
|
739
|
+
}
|
|
740
|
+
for (const r of rows) {
|
|
741
|
+
switch (dest.kind) {
|
|
742
|
+
case "project":
|
|
743
|
+
legs.push({
|
|
744
|
+
uuid: r.uuid,
|
|
745
|
+
params: {
|
|
746
|
+
uuid: r.uuid,
|
|
747
|
+
project: dest.ref,
|
|
748
|
+
...(dest.heading !== undefined && { heading: dest.heading }),
|
|
749
|
+
},
|
|
750
|
+
});
|
|
751
|
+
break;
|
|
752
|
+
case "area":
|
|
753
|
+
legs.push({ uuid: r.uuid, params: { uuid: r.uuid, area: dest.ref } });
|
|
754
|
+
break;
|
|
755
|
+
case "inbox":
|
|
756
|
+
// Inbox return compiles only to AppleScript — pin the transport so the
|
|
757
|
+
// planner does not route it to url-scheme (the tier-0 registry-tie winner).
|
|
758
|
+
legs.push({ uuid: r.uuid, params: { uuid: r.uuid, inbox: true }, vector: "applescript" });
|
|
759
|
+
break;
|
|
760
|
+
case "no-heading":
|
|
761
|
+
legs.push({ uuid: r.uuid, params: { uuid: r.uuid, noHeading: true } });
|
|
762
|
+
break;
|
|
763
|
+
case "loose":
|
|
764
|
+
legs.push({ uuid: r.uuid, params: { uuid: r.uuid, loose: true } });
|
|
765
|
+
break;
|
|
766
|
+
default:
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
return { legs };
|
|
771
|
+
}
|
|
772
|
+
/** The row a to-do BECOMES after a membership move (schedule kept unless it resets). */
|
|
773
|
+
function todoLandedRow(deps, dest, row) {
|
|
774
|
+
switch (dest.kind) {
|
|
775
|
+
case "project": {
|
|
776
|
+
const p = resolveProject(deps.db, dest.ref);
|
|
777
|
+
return {
|
|
778
|
+
...row,
|
|
779
|
+
project: p.resolved?.uuid ?? null,
|
|
780
|
+
area: null,
|
|
781
|
+
heading: dest.heading !== undefined ? "landed-heading" : null,
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
case "heading":
|
|
785
|
+
// Under a heading — the exact heading uuid does not matter to the target
|
|
786
|
+
// classifier (a heading bucket is app-default regardless); mark it headed.
|
|
787
|
+
return { ...row, heading: "landed-heading", area: null };
|
|
788
|
+
case "area": {
|
|
789
|
+
const a = resolveArea(deps.db, dest.ref);
|
|
790
|
+
return { ...row, area: a.resolved?.uuid ?? null, project: null, heading: null };
|
|
791
|
+
}
|
|
792
|
+
case "inbox":
|
|
793
|
+
return { ...row, start: 0, startDate: null, project: null, area: null, heading: null };
|
|
794
|
+
case "no-heading": {
|
|
795
|
+
const container = row.project ?? headingProjectOf(deps.db, row.heading);
|
|
796
|
+
return { ...row, project: container, area: null, heading: null };
|
|
797
|
+
}
|
|
798
|
+
case "loose":
|
|
799
|
+
return { ...row, project: null, area: null, heading: null };
|
|
800
|
+
default:
|
|
801
|
+
return row;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
/** The uniform landing target across the movees (app-default when they diverge). */
|
|
805
|
+
function uniformLanding(landed, isTodo, packedToday, bounceEnabled) {
|
|
806
|
+
const targets = landed.map((r) => reorderTargetOf(r, isTodo, packedToday, bounceEnabled));
|
|
807
|
+
const keys = new Set(targets.map(containerKey));
|
|
808
|
+
return keys.size === 1
|
|
809
|
+
? targets[0]
|
|
810
|
+
: {
|
|
811
|
+
scope: null,
|
|
812
|
+
reason: "the movees span display buckets in the destination (per-bucket app-default)",
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
function headingProjectOf(db, headingUuid) {
|
|
816
|
+
if (headingUuid === null)
|
|
817
|
+
return null;
|
|
818
|
+
const r = db.prepare("SELECT project FROM TMTask WHERE uuid = ?").get(headingUuid);
|
|
819
|
+
return r?.project ?? null;
|
|
820
|
+
}
|
|
821
|
+
// --------------------------------------------------------------- project move
|
|
822
|
+
export async function runProjectMove(deps, request, options = {}) {
|
|
823
|
+
const op = "project.move";
|
|
824
|
+
const now = deps.now?.() ?? new Date();
|
|
825
|
+
const packedToday = encodePackedDate(localToday(now));
|
|
826
|
+
if (request.uuids.length === 0) {
|
|
827
|
+
return refused(op, "usage", "no projects given — name at least one project to move");
|
|
828
|
+
}
|
|
829
|
+
const rows = [];
|
|
830
|
+
const wrongKind = [];
|
|
831
|
+
for (const ref of request.uuids) {
|
|
832
|
+
const r = resolveMovee(deps, ref);
|
|
833
|
+
if (r instanceof ReferenceResolutionError) {
|
|
834
|
+
return {
|
|
835
|
+
kind: "move-refused",
|
|
836
|
+
op,
|
|
837
|
+
refusal: "usage",
|
|
838
|
+
detail: r.message,
|
|
839
|
+
candidates: r.candidates,
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
const row = loadRow(deps.db, r.uuid);
|
|
843
|
+
if (row === undefined)
|
|
844
|
+
return refused(op, "usage", `no project matches "${ref}"`);
|
|
845
|
+
if (row.type !== 1)
|
|
846
|
+
wrongKind.push({ ref: r.uuid, kind: KIND_LABEL[row.type] ?? "item" });
|
|
847
|
+
rows.push(row);
|
|
848
|
+
}
|
|
849
|
+
if (wrongKind.length > 0) {
|
|
850
|
+
const list = wrongKind.map((w) => `${w.ref} (${w.kind})`).join(", ");
|
|
851
|
+
return refused(op, "usage", `homogeneous movee kinds required — \`project move\` moves projects only, but these are not projects: ${list}`, "move to-dos with `todo move`");
|
|
852
|
+
}
|
|
853
|
+
const dest = request.destination;
|
|
854
|
+
const position = request.position;
|
|
855
|
+
if (dest?.kind === "detach") {
|
|
856
|
+
return refused(op, "usage", "`--detach` was removed. A project's detach is `--no-area` (a project has one containment level).");
|
|
857
|
+
}
|
|
858
|
+
if (dest?.kind === "loose") {
|
|
859
|
+
return refused(op, "usage", "`--loose` does not apply to a project — a project has a single containment level, so its " +
|
|
860
|
+
"detach is `--no-area`. (`--loose` is a to-do's total sever.)");
|
|
861
|
+
}
|
|
862
|
+
if (dest === undefined && position === undefined) {
|
|
863
|
+
return refused(op, "usage", BARE_PROJECT_MOVE);
|
|
864
|
+
}
|
|
865
|
+
if (dest === undefined) {
|
|
866
|
+
return repositionInPlace(deps, op, rows, position, packedToday, options, "move");
|
|
867
|
+
}
|
|
868
|
+
// `loose` is the reserved READ-only pseudo-area — never a move destination.
|
|
869
|
+
// (A project leaves its area with `--no-area`, not `--to-area loose`.)
|
|
870
|
+
if (dest.kind === "area" && isLooseRef(dest.ref.uuid ?? dest.ref.title ?? "")) {
|
|
871
|
+
return refused(op, "usage", LOOSE_TO_AREA_REFUSAL);
|
|
872
|
+
}
|
|
873
|
+
const landedArea = dest.kind === "area" ? (resolveArea(deps.db, dest.ref).resolved?.uuid ?? null) : null;
|
|
874
|
+
const landing = uniformLanding(rows.map((r) => ({ ...r, area: landedArea })), false, packedToday, deps.config.bounceEnabled);
|
|
875
|
+
const anchorRefusal = preflightAnchor(op, position, landing);
|
|
876
|
+
if (anchorRefusal !== null)
|
|
877
|
+
return anchorRefusal;
|
|
878
|
+
// Membership legs.
|
|
879
|
+
const membership = [];
|
|
880
|
+
for (const r of rows) {
|
|
881
|
+
const params = dest.kind === "area"
|
|
882
|
+
? { uuid: r.uuid, area: dest.ref }
|
|
883
|
+
: { uuid: r.uuid, noArea: true };
|
|
884
|
+
const res = await runMutation(deps, "project.move", params, {
|
|
885
|
+
...legOptions(options),
|
|
886
|
+
...(options.dryRun === true && { dryRun: true }),
|
|
887
|
+
});
|
|
888
|
+
membership.push(res);
|
|
889
|
+
if (res.kind !== "ok" && res.kind !== "dry-run") {
|
|
890
|
+
return {
|
|
891
|
+
kind: "move-leg-failed",
|
|
892
|
+
op,
|
|
893
|
+
detail: `moving ${r.uuid} failed (${res.kind})`,
|
|
894
|
+
failed: res,
|
|
895
|
+
completed: membership.slice(0, -1),
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
return finishPlacement(deps, op, rows, landing, position, packedToday, options, membership);
|
|
900
|
+
}
|
|
901
|
+
// ----------------------------------------------------------------- reorder verb
|
|
902
|
+
/**
|
|
903
|
+
* `todo reorder` / `project reorder` — pure positioning within the movees'
|
|
904
|
+
* CURRENT shared container+bucket. Bare (no position) assembles the movees as
|
|
905
|
+
* a contiguous block at the EARLIEST movee's current slot, in argument order
|
|
906
|
+
* (spec §4 — `--first` is NOT implied). Cross-container operands fail closed.
|
|
907
|
+
*/
|
|
908
|
+
export async function runInPlaceReorder(deps, op, request, options = {}) {
|
|
909
|
+
const now = deps.now?.() ?? new Date();
|
|
910
|
+
const packedToday = encodePackedDate(localToday(now));
|
|
911
|
+
const isTodo = op === "todo.move";
|
|
912
|
+
if (request.uuids.length === 0) {
|
|
913
|
+
return refused(op, "usage", "no items given — name at least one to reorder");
|
|
914
|
+
}
|
|
915
|
+
const rows = [];
|
|
916
|
+
const wrongKind = [];
|
|
917
|
+
for (const ref of request.uuids) {
|
|
918
|
+
const r = resolveMovee(deps, ref);
|
|
919
|
+
if (r instanceof ReferenceResolutionError) {
|
|
920
|
+
return {
|
|
921
|
+
kind: "move-refused",
|
|
922
|
+
op,
|
|
923
|
+
refusal: "usage",
|
|
924
|
+
detail: r.message,
|
|
925
|
+
candidates: r.candidates,
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
const row = loadRow(deps.db, r.uuid);
|
|
929
|
+
if (row === undefined)
|
|
930
|
+
return refused(op, "usage", `no item matches "${ref}"`);
|
|
931
|
+
const want = isTodo ? 0 : 1;
|
|
932
|
+
if (row.type !== want)
|
|
933
|
+
wrongKind.push({ ref: r.uuid, kind: KIND_LABEL[row.type] ?? "item" });
|
|
934
|
+
rows.push(row);
|
|
935
|
+
}
|
|
936
|
+
// The GLOBAL todayIndex buckets accept PROJECT rows INTERMIXED with to-dos in one
|
|
937
|
+
// reorder: `today` (O12 — `list "Today"` takes projects inline), `evening` (SIT4
|
|
938
|
+
// EVEORD — projects share the evening axis, per-type bounce legs), and the future
|
|
939
|
+
// day-group scopes (`day`/`tomorrow` — SIT4 DAYBNC + TOMORROWLIST accept area-
|
|
940
|
+
// less project rows). So on the `todo reorder` path a project movee is legal WHEN
|
|
941
|
+
// every movee sits in a today/evening/scheduled bucket; the mixed set routes to
|
|
942
|
+
// one of those scopes (whose wire list is type IN (0,1)). Every OTHER bucket
|
|
943
|
+
// keeps the homogeneous-kinds refusal, and an ineligible member still fails the
|
|
944
|
+
// downstream scope guard, so the relaxation never reaches an unvalidated scope.
|
|
945
|
+
const globalAxisIntermix = isTodo &&
|
|
946
|
+
rows.length > 0 &&
|
|
947
|
+
rows.every((r) => {
|
|
948
|
+
const b = scheduleBucket(r, packedToday);
|
|
949
|
+
return b === "today" || b === "evening" || b.startsWith("scheduled:");
|
|
950
|
+
});
|
|
951
|
+
if (wrongKind.length > 0 && !globalAxisIntermix) {
|
|
952
|
+
const list = wrongKind.map((w) => `${w.ref} (${w.kind})`).join(", ");
|
|
953
|
+
const allProjects = isTodo && rows.length > 0 && rows.every((r) => r.type === 1);
|
|
954
|
+
return refused(op, "usage", `homogeneous kinds required — \`reorder\` rearranges to-dos ` +
|
|
955
|
+
"(the Today, This Evening, and future day-group lists also accept project rows " +
|
|
956
|
+
`intermixed with to-dos); not: ${list}`, allProjects
|
|
957
|
+
? "to rearrange projects in their sidebar/area/someday order use `things project move " +
|
|
958
|
+
"<refs…> --first/--last/--before/--after`"
|
|
959
|
+
: undefined);
|
|
960
|
+
}
|
|
961
|
+
return repositionInPlace(deps, op, rows, request.position, packedToday, options, "reorder", request.in);
|
|
962
|
+
}
|
|
963
|
+
// ---------------------------------------------------------------- shared core
|
|
964
|
+
/**
|
|
965
|
+
* The single STRICTLY-FUTURE day every movee shares (packed startDate), or null —
|
|
966
|
+
* the precondition for the SIT4 dated `day` bounce (and the one-call `tomorrow`
|
|
967
|
+
* sort). Members are to-dos in ANY container AND scheduled PROJECT rows (area-less
|
|
968
|
+
* OR area-direct — SIT5 AREAPROJDAY proved the update-project when= legs preserve
|
|
969
|
+
* the area FK), all on the SAME future day in the Today-bucket axis (startBucket=0).
|
|
970
|
+
* A row off the day, a template, or an undated/arrived row breaks the group and
|
|
971
|
+
* falls through to the normal single-container guard. (Templates never reach here:
|
|
972
|
+
* startDate is NULL on a resting template, so `startDate === day` fails for them.)
|
|
973
|
+
*/
|
|
974
|
+
function sharedFutureDay(rows, packedToday) {
|
|
975
|
+
const first = rows[0];
|
|
976
|
+
if (first === undefined)
|
|
977
|
+
return null;
|
|
978
|
+
const day = first.startDate;
|
|
979
|
+
if (day === null || day <= packedToday || first.startBucket !== 0)
|
|
980
|
+
return null;
|
|
981
|
+
for (const r of rows) {
|
|
982
|
+
if (r.startBucket !== 0 || r.startDate !== day)
|
|
983
|
+
return null;
|
|
984
|
+
if (r.type !== 0 && r.type !== 1)
|
|
985
|
+
return null;
|
|
986
|
+
if (r.isTemplate)
|
|
987
|
+
return null;
|
|
988
|
+
}
|
|
989
|
+
return day;
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* Reposition a shared FUTURE day-group on the global todayIndex axis — the SIT4
|
|
993
|
+
* dated `day` bounce (loose/direct-area/headed/cross-container to-dos + area-less
|
|
994
|
+
* project rows, any mix), or the one-call native `list "Tomorrow"` sort when the
|
|
995
|
+
* day is tomorrow. Both order the WHOLE cross-container day-group on ONE shared
|
|
996
|
+
* axis, so the anchor (if any) is validated by DAY-GROUP membership (not the
|
|
997
|
+
* structural container) — an anchor positions within the group, it never migrates.
|
|
998
|
+
*/
|
|
999
|
+
async function runDayGroupReposition(deps, op, rows, position, options, scope, day) {
|
|
1000
|
+
const target = { scope, day };
|
|
1001
|
+
const movees = rows.map((r) => r.uuid);
|
|
1002
|
+
const members = bucketMembers(deps, target, movees[0]);
|
|
1003
|
+
// Anchor must share the movees' day-group (positions, never migrates) — an
|
|
1004
|
+
// AXIS-aware check (the day-group is one cross-container todayIndex axis, so a
|
|
1005
|
+
// different structural container is NOT a migration).
|
|
1006
|
+
if (position !== undefined && ("before" in position || "after" in position)) {
|
|
1007
|
+
const anchorRef = "before" in position ? position.before : position.after;
|
|
1008
|
+
const ar = resolveMovee(deps, anchorRef);
|
|
1009
|
+
if (ar instanceof ReferenceResolutionError) {
|
|
1010
|
+
return {
|
|
1011
|
+
kind: "move-refused",
|
|
1012
|
+
op,
|
|
1013
|
+
refusal: "usage",
|
|
1014
|
+
detail: ar.message,
|
|
1015
|
+
candidates: ar.candidates,
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
if (!members.includes(ar.uuid)) {
|
|
1019
|
+
return refused(op, "blocked", `the anchor ${ar.uuid} is not in ${describeScope(target)} — an anchor positions, it never migrates`, "pick an anchor that shares the movees' day, or use --first/--last");
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
const reorderUuids = position === undefined
|
|
1023
|
+
? earliestSlotOrder(deps, target, movees)
|
|
1024
|
+
: buildReorderOrder(deps, target, movees, position);
|
|
1025
|
+
const placement = await runReorder(deps, { scope, uuids: reorderUuids, named: movees }, { ...legOptions(options), ...(options.dryRun === true && { dryRun: true }) });
|
|
1026
|
+
const mechanism = scope === "tomorrow"
|
|
1027
|
+
? 'cross-container Tomorrow day-group reorder (one-call `list "Tomorrow"` sort)'
|
|
1028
|
+
: "cross-container future day-group reorder (SIT4 dated when= bounce)";
|
|
1029
|
+
if (options.dryRun === true) {
|
|
1030
|
+
return {
|
|
1031
|
+
kind: "move-dry-run",
|
|
1032
|
+
op,
|
|
1033
|
+
plan: {
|
|
1034
|
+
movees,
|
|
1035
|
+
membership: "none (in-place reposition)",
|
|
1036
|
+
placement: `reorder scope=${scope} → ${describePosition(position)}`,
|
|
1037
|
+
placementClass: "guaranteed",
|
|
1038
|
+
note: dryRunNote(placement, mechanism),
|
|
1039
|
+
},
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
if (placement.kind !== "ok") {
|
|
1043
|
+
return {
|
|
1044
|
+
kind: "move-leg-failed",
|
|
1045
|
+
op,
|
|
1046
|
+
detail: `the reorder leg did not complete (${placement.kind})`,
|
|
1047
|
+
failed: placement,
|
|
1048
|
+
completed: [],
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
return {
|
|
1052
|
+
kind: "move-ok",
|
|
1053
|
+
op,
|
|
1054
|
+
movees: rows.map((r) => ({ uuid: r.uuid, title: r.title })),
|
|
1055
|
+
membership: [],
|
|
1056
|
+
placement,
|
|
1057
|
+
placementClass: "guaranteed",
|
|
1058
|
+
note: `reordered within ${describeScope(target)} (${scope} scope — placement guaranteed)` +
|
|
1059
|
+
touchedSuffix(placement),
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* Reorder the given rows within their CURRENT shared container. Fails closed
|
|
1064
|
+
* when the rows span containers or (for --before/--after) the anchor lives
|
|
1065
|
+
* elsewhere or in another bucket.
|
|
1066
|
+
*/
|
|
1067
|
+
async function repositionInPlace(deps, op, rows, position, packedToday, options, verb, inTarget) {
|
|
1068
|
+
// Resolve the reorder AXIS (`reorder --in` disambiguation). The classifier maps
|
|
1069
|
+
// each row to its effective reorder target for the chosen axis: the view axis
|
|
1070
|
+
// (today/evening) leaves reorderTargetOf unchanged; a container index axis
|
|
1071
|
+
// reclassifies Today/Evening members by their container. A dual-axis set with no
|
|
1072
|
+
// --in is refused here (naming both --in spellings).
|
|
1073
|
+
const axis = resolveReorderAxis(deps, op, rows, inTarget, packedToday, verb, position);
|
|
1074
|
+
if ("refused" in axis)
|
|
1075
|
+
return axis.refused;
|
|
1076
|
+
const targetOf = axis.targetOf;
|
|
1077
|
+
// On a forced/auto INDEX axis (a dual-axis set reordered within its container),
|
|
1078
|
+
// the display-bucket coherence guards below are the WRONG check: the members
|
|
1079
|
+
// share ONE container `index` order even though a flagged member renders in the
|
|
1080
|
+
// Today/Evening view. The target-span guard still enforces the single shared
|
|
1081
|
+
// container; only the per-display-bucket splits are relaxed (a SIT6 flag-safe
|
|
1082
|
+
// protocol reorders the whole run, flag preserved).
|
|
1083
|
+
const indexAxis = axis.indexAxis === true;
|
|
1084
|
+
// A shared FUTURE day-group (single- OR cross-container) rides the global
|
|
1085
|
+
// todayIndex axis, not the normal single-container reorder: the SIT4 dated `day`
|
|
1086
|
+
// bounce (loose/direct-area/headed/cross-container to-dos + area-less project
|
|
1087
|
+
// rows, any mix), or the one-call `list "Tomorrow"` sort when the day is
|
|
1088
|
+
// tomorrow. The ONLY exception is a single UNHEADED PROJECT container — its same-
|
|
1089
|
+
// day children ride the cheaper atomic native container-day re-rank, so it falls
|
|
1090
|
+
// through to the normal path below (reorderTargetOf → container-day). Templates
|
|
1091
|
+
// and area-direct project rows are excluded by sharedFutureDay.
|
|
1092
|
+
const structKeys = new Set(rows.map((r) => structuralKey(r, packedToday)));
|
|
1093
|
+
const sharedDay = sharedFutureDay(rows, packedToday);
|
|
1094
|
+
if (sharedDay !== null) {
|
|
1095
|
+
const soleStruct = structKeys.size === 1 ? structKeys.values().next().value : null;
|
|
1096
|
+
const singleProjectContainer = soleStruct?.startsWith("project:") ?? false;
|
|
1097
|
+
if (!singleProjectContainer) {
|
|
1098
|
+
const scope = sharedDay === packedTomorrowOf(packedToday) ? "tomorrow" : "day";
|
|
1099
|
+
return runDayGroupReposition(deps, op, rows, position, options, scope, sharedDay);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
// One shared reorder TARGET (rule 2 span). Keyed by the reorder target, NOT the
|
|
1103
|
+
// structural container: a GLOBAL todayIndex bucket (today / evening) collapses
|
|
1104
|
+
// cross-container rows to ONE target, so they reorder together on that shared
|
|
1105
|
+
// axis (the GUI permits exactly that drag — nothing migrates); an INDEX-axis
|
|
1106
|
+
// bucket keeps its per-container key, so a genuine cross-container span still
|
|
1107
|
+
// refuses. The comparator and the describer read the SAME key, so an identical-
|
|
1108
|
+
// label refusal is structurally impossible.
|
|
1109
|
+
const targetKeys = new Set(rows.map((r) => containerKey(targetOf(r))));
|
|
1110
|
+
if (targetKeys.size !== 1) {
|
|
1111
|
+
const where = rows.map((r) => `${r.uuid} in ${describeScope(targetOf(r))}`).join("; ");
|
|
1112
|
+
return refused(op, "blocked", `the items span different containers (${where}), so they cannot be repositioned together`, verb === "move"
|
|
1113
|
+
? "name an explicit destination (--to-project / --to-area / --no-heading / --loose) if you mean to MOVE them"
|
|
1114
|
+
: "reorder items that already share one container and bucket, or move them together first");
|
|
1115
|
+
}
|
|
1116
|
+
const target = targetOf(rows[0]);
|
|
1117
|
+
if (target.scope === null) {
|
|
1118
|
+
// A prohibited bucket has a destructive protocol we NEVER attempt (§9f); an
|
|
1119
|
+
// app-default bucket simply has no wired protocol. Both refuse a reorder
|
|
1120
|
+
// (an explicit rearrange we cannot honor honestly), but with distinct copy.
|
|
1121
|
+
return refused(op, target.prohibited === true ? "blocked" : "unsupported", target.prohibited === true
|
|
1122
|
+
? `these items are ${describeScope(target)} — reordering them there is destructive, so it is refused`
|
|
1123
|
+
: `these items are in ${describeScope(target)} — no reorder protocol addresses that bucket yet`, "see the placement-honesty note in `things help move`");
|
|
1124
|
+
}
|
|
1125
|
+
// All movees must share ONE bucket (rule 4 single-bucket-strict) — EXCEPT on a
|
|
1126
|
+
// forced/auto index axis, where a dual-axis set legitimately spans display
|
|
1127
|
+
// buckets (a flagged member in Today + plain members in Anytime) yet shares one
|
|
1128
|
+
// container index order.
|
|
1129
|
+
const buckets = new Map();
|
|
1130
|
+
for (const r of rows) {
|
|
1131
|
+
const b = scheduleBucket(r, packedToday);
|
|
1132
|
+
buckets.set(b, [...(buckets.get(b) ?? []), r.uuid]);
|
|
1133
|
+
}
|
|
1134
|
+
if (buckets.size > 1 && !indexAxis) {
|
|
1135
|
+
const listed = [...buckets.entries()].map(([b, u]) => `${b}: ${u.join(", ")}`).join("; ");
|
|
1136
|
+
return refused(op, "blocked", `the items are in different display buckets (${listed}) — a single reorder cannot span buckets`, "reorder within one bucket at a time, or use per-bucket --first/--last");
|
|
1137
|
+
}
|
|
1138
|
+
// Anchor validation for --before/--after (rule 2/4).
|
|
1139
|
+
let anchorUuid = null;
|
|
1140
|
+
if (position !== undefined && ("before" in position || "after" in position)) {
|
|
1141
|
+
const anchorRef = "before" in position ? position.before : position.after;
|
|
1142
|
+
const ar = resolveMovee(deps, anchorRef);
|
|
1143
|
+
if (ar instanceof ReferenceResolutionError) {
|
|
1144
|
+
return {
|
|
1145
|
+
kind: "move-refused",
|
|
1146
|
+
op,
|
|
1147
|
+
refusal: "usage",
|
|
1148
|
+
detail: ar.message,
|
|
1149
|
+
candidates: ar.candidates,
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
anchorUuid = ar.uuid;
|
|
1153
|
+
const anchorRow = loadRow(deps.db, anchorUuid);
|
|
1154
|
+
if (anchorRow === undefined)
|
|
1155
|
+
return refused(op, "usage", `the anchor "${anchorRef}" was not found`);
|
|
1156
|
+
// AXIS-aware anchor validity: the anchor must share the movees' reorder TARGET,
|
|
1157
|
+
// not its structural container. For a GLOBAL todayIndex bucket (today/evening)
|
|
1158
|
+
// a different structural container is NOT a migration — both collapse to the
|
|
1159
|
+
// same target — so the anchor is valid; for an INDEX-axis bucket a cross-
|
|
1160
|
+
// container anchor genuinely implies migration and still refuses. Comparator
|
|
1161
|
+
// and describer read the SAME key (containerKey ⇔ describeScope), so the two
|
|
1162
|
+
// rendered labels can never be identical when the comparator refuses.
|
|
1163
|
+
const anchorTarget = targetOf(anchorRow);
|
|
1164
|
+
if (containerKey(anchorTarget) !== containerKey(target)) {
|
|
1165
|
+
return refused(op, "blocked", `the anchor ${anchorUuid} is in ${describeScope(anchorTarget)}, not ${describeScope(target)} — an anchor positions, it never migrates`, "move the items into the anchor's container explicitly, or pick an anchor that shares it");
|
|
1166
|
+
}
|
|
1167
|
+
// On the index axis the anchor need only share the container (checked above via
|
|
1168
|
+
// containerKey) — display buckets legitimately differ for a dual-axis set.
|
|
1169
|
+
const anchorBucket = scheduleBucket(anchorRow, packedToday);
|
|
1170
|
+
const movBucket = [...buckets.keys()][0];
|
|
1171
|
+
if (!indexAxis && anchorBucket !== movBucket) {
|
|
1172
|
+
return refused(op, "blocked", `the anchor is in the "${anchorBucket}" bucket but the movees are in "${movBucket}" — ` +
|
|
1173
|
+
"--before/--after require every movee to share the anchor's bucket", "use --first/--last (bucket-relative), or reorder within one bucket");
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
const movees = rows.map((r) => r.uuid);
|
|
1177
|
+
// Bare form (no position): assemble the block at the EARLIEST movee's current
|
|
1178
|
+
// slot in argument order (`--first` is NOT implied) — spec §4. A position
|
|
1179
|
+
// realizes first/last/before/after instead.
|
|
1180
|
+
const reorderUuids = position === undefined
|
|
1181
|
+
? earliestSlotOrder(deps, target, movees)
|
|
1182
|
+
: buildReorderOrder(deps, target, movees, position);
|
|
1183
|
+
const reorderParams = {
|
|
1184
|
+
scope: target.scope,
|
|
1185
|
+
uuids: reorderUuids,
|
|
1186
|
+
named: movees,
|
|
1187
|
+
...(target.container !== undefined && { container: { uuid: target.container } }),
|
|
1188
|
+
};
|
|
1189
|
+
const placement = await runReorder(deps, reorderParams, {
|
|
1190
|
+
...legOptions(options),
|
|
1191
|
+
...(options.dryRun === true && { dryRun: true }),
|
|
1192
|
+
});
|
|
1193
|
+
if (options.dryRun === true) {
|
|
1194
|
+
return {
|
|
1195
|
+
kind: "move-dry-run",
|
|
1196
|
+
op,
|
|
1197
|
+
plan: {
|
|
1198
|
+
movees,
|
|
1199
|
+
membership: "none (in-place reposition)",
|
|
1200
|
+
placement: `reorder scope=${target.scope}${target.container !== undefined ? ` container=${target.container}` : ""} → ${describePosition(position)}`,
|
|
1201
|
+
placementClass: "guaranteed",
|
|
1202
|
+
note: dryRunNote(placement, "in-place reorder within the shared container/bucket"),
|
|
1203
|
+
},
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
if (placement.kind !== "ok") {
|
|
1207
|
+
return {
|
|
1208
|
+
kind: "move-leg-failed",
|
|
1209
|
+
op,
|
|
1210
|
+
detail: `the reorder leg did not complete (${placement.kind})`,
|
|
1211
|
+
failed: placement,
|
|
1212
|
+
completed: [],
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
return {
|
|
1216
|
+
kind: "move-ok",
|
|
1217
|
+
op,
|
|
1218
|
+
movees: rows.map((r) => ({ uuid: r.uuid, title: r.title })),
|
|
1219
|
+
membership: [],
|
|
1220
|
+
placement,
|
|
1221
|
+
placementClass: "guaranteed",
|
|
1222
|
+
note: `reordered within ${describeScope(target)} (${target.scope} scope — placement guaranteed)` +
|
|
1223
|
+
touchedSuffix(placement),
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
/** Honest disclosure suffix naming co-bounced siblings an anchor placement touched. */
|
|
1227
|
+
function touchedSuffix(placement) {
|
|
1228
|
+
if (placement.kind !== "ok" ||
|
|
1229
|
+
placement.touched === undefined ||
|
|
1230
|
+
placement.touched.length === 0) {
|
|
1231
|
+
return "";
|
|
1232
|
+
}
|
|
1233
|
+
return `; also re-inserted ${placement.touched.length} unnamed sibling(s) to honor the anchor: ${placement.touched.join(", ")}`;
|
|
1234
|
+
}
|
|
1235
|
+
/** Dry-run note that surfaces the planned co-bounce touch count. */
|
|
1236
|
+
function dryRunNote(placement, base) {
|
|
1237
|
+
if (placement.kind === "dry-run")
|
|
1238
|
+
return `${base} — ${placement.plan.invocation}`;
|
|
1239
|
+
return base;
|
|
1240
|
+
}
|
|
1241
|
+
/**
|
|
1242
|
+
* The final placement step for a MEMBERSHIP move: reorder the movees within the
|
|
1243
|
+
* destination bucket when a protocol exists, else report app-default.
|
|
1244
|
+
*/
|
|
1245
|
+
async function finishPlacement(deps, op, rows, landing, position, packedToday, options, membership) {
|
|
1246
|
+
const movees = rows.map((r) => r.uuid);
|
|
1247
|
+
const moveeTitles = rows.map((r) => ({ uuid: r.uuid, title: r.title }));
|
|
1248
|
+
// Anchor with a membership move: the anchor must be in the destination
|
|
1249
|
+
// (rule 2) and share the movees' bucket (rule 4). Validated against post-move
|
|
1250
|
+
// truth. When the destination bucket has no guaranteed protocol we now REFUSE
|
|
1251
|
+
// an explicit --before/--after (we KNOW no honest spelling exists there —
|
|
1252
|
+
// HEADORD-b/§9f) rather than silently app-default it.
|
|
1253
|
+
if (position !== undefined && ("before" in position || "after" in position)) {
|
|
1254
|
+
if (landing.scope === null) {
|
|
1255
|
+
return refused(op, landing.prohibited === true ? "blocked" : "unsupported", `moved into ${describeScope(landing)} — --before/--after cannot be honored there; ` +
|
|
1256
|
+
"no reorder protocol positions within that bucket", "use --first/--last, or omit the position (membership still lands)");
|
|
1257
|
+
}
|
|
1258
|
+
const destStructural = landing.container !== undefined
|
|
1259
|
+
? `${landing.scope}:${landing.container}`
|
|
1260
|
+
: `list:${landing.scope}`;
|
|
1261
|
+
const anchorRef = "before" in position ? position.before : position.after;
|
|
1262
|
+
const ar = resolveMovee(deps, anchorRef);
|
|
1263
|
+
if (ar instanceof ReferenceResolutionError) {
|
|
1264
|
+
return {
|
|
1265
|
+
kind: "move-refused",
|
|
1266
|
+
op,
|
|
1267
|
+
refusal: "usage",
|
|
1268
|
+
detail: `after moving, the anchor "${anchorRef}" did not resolve`,
|
|
1269
|
+
candidates: ar.candidates,
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
const anchorRow = loadRow(deps.db, ar.uuid);
|
|
1273
|
+
if (anchorRow === undefined || structuralKey(anchorRow, packedToday) !== destStructural) {
|
|
1274
|
+
return refused(op, "blocked", `the anchor ${ar.uuid} is not in the destination container — an anchor positions, it never migrates`, "pick an anchor that lives in the destination, or use --first/--last");
|
|
1275
|
+
}
|
|
1276
|
+
const anchorBucket = scheduleBucket(anchorRow, packedToday);
|
|
1277
|
+
const badBucket = rows.filter((r) => scheduleBucket(r, packedToday) !== anchorBucket);
|
|
1278
|
+
if (badBucket.length > 0) {
|
|
1279
|
+
const listed = rows.map((r) => `${r.uuid}: ${scheduleBucket(r, packedToday)}`).join("; ");
|
|
1280
|
+
return refused(op, "blocked", `--before/--after require every movee in the anchor's "${anchorBucket}" bucket, but: ${listed}`, "use --first/--last (per-bucket), or split the move by bucket");
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
// Mixed-stage --first/--last (spec §4 rule 4): a selection that lands across
|
|
1284
|
+
// stage sub-buckets (anytime + scheduled + someday) places each stage-group at
|
|
1285
|
+
// the top/bottom of ITS bucket in the destination. Grouped off the RELOADED
|
|
1286
|
+
// post-move rows (real container uuids + true buckets), so it needs no dest
|
|
1287
|
+
// context and is correct for a heading landing too. Dry-run keeps the generic
|
|
1288
|
+
// path (no DB truth to reload).
|
|
1289
|
+
if (options.dryRun !== true && position !== undefined && "at" in position) {
|
|
1290
|
+
const reloaded = rows.map((r) => loadRow(deps.db, r.uuid) ?? r);
|
|
1291
|
+
const groups = groupByReorderTarget(deps, reloaded, packedToday);
|
|
1292
|
+
if (groups.length > 1) {
|
|
1293
|
+
return placePerBucket(deps, op, groups, position, moveeTitles, membership, options);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
if (landing.scope === null) {
|
|
1297
|
+
// Membership landed; the destination bucket has no guaranteed protocol (or a
|
|
1298
|
+
// prohibited/destructive one we never attempt) — app-default placement, honest.
|
|
1299
|
+
return {
|
|
1300
|
+
kind: "move-ok",
|
|
1301
|
+
op,
|
|
1302
|
+
movees: moveeTitles,
|
|
1303
|
+
membership,
|
|
1304
|
+
placement: null,
|
|
1305
|
+
placementClass: "app-default",
|
|
1306
|
+
note: `membership moved; landed in ${describeScope(landing)} — ` +
|
|
1307
|
+
(landing.prohibited === true
|
|
1308
|
+
? "ordering there is destructive so it was NOT attempted (the app placed it); "
|
|
1309
|
+
: "no reorder protocol addresses that bucket yet; ") +
|
|
1310
|
+
"app-default placement (spec rule 5)",
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
if (options.dryRun === true) {
|
|
1314
|
+
return {
|
|
1315
|
+
kind: "move-dry-run",
|
|
1316
|
+
op,
|
|
1317
|
+
plan: {
|
|
1318
|
+
movees,
|
|
1319
|
+
membership: `${membership.length} membership leg(s)`,
|
|
1320
|
+
placement: `reorder scope=${landing.scope}${landing.container !== undefined ? ` container=${landing.container}` : ""} → ${describePosition(position)}`,
|
|
1321
|
+
placementClass: "guaranteed",
|
|
1322
|
+
note: "membership + top-of-bucket placement",
|
|
1323
|
+
},
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
const reorderUuids = buildReorderOrder(deps, landing, movees, position);
|
|
1327
|
+
const placement = await runReorder(deps, {
|
|
1328
|
+
scope: landing.scope,
|
|
1329
|
+
uuids: reorderUuids,
|
|
1330
|
+
named: movees,
|
|
1331
|
+
...(landing.container !== undefined && { container: { uuid: landing.container } }),
|
|
1332
|
+
}, legOptions(options));
|
|
1333
|
+
if (placement.kind === "ok") {
|
|
1334
|
+
return {
|
|
1335
|
+
kind: "move-ok",
|
|
1336
|
+
op,
|
|
1337
|
+
movees: moveeTitles,
|
|
1338
|
+
membership,
|
|
1339
|
+
placement,
|
|
1340
|
+
placementClass: "guaranteed",
|
|
1341
|
+
note: `membership moved and placed top-of-bucket in ${describeScope(landing)} (guaranteed via the ${landing.scope} reorder protocol)` +
|
|
1342
|
+
touchedSuffix(placement),
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
// The membership already landed; the placement protocol was unavailable
|
|
1346
|
+
// (experimental off, or an app-side refusal). Honest degrade to app-default.
|
|
1347
|
+
return {
|
|
1348
|
+
kind: "move-ok",
|
|
1349
|
+
op,
|
|
1350
|
+
movees: moveeTitles,
|
|
1351
|
+
membership,
|
|
1352
|
+
placement,
|
|
1353
|
+
placementClass: "app-default",
|
|
1354
|
+
note: `membership moved into ${describeScope(landing)}, but the ${landing.scope} reorder protocol ` +
|
|
1355
|
+
`was unavailable (${placement.kind}${placement.kind === "blocked" ? `: ${placement.detail}` : ""}) — ` +
|
|
1356
|
+
"placement fell back to app-default; enable it with `things config set allow-experimental true`",
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Partition rows by their reorder TARGET (the distinct placement protocol each
|
|
1361
|
+
* stage sub-bucket needs), preserving selection order within each group. Used for
|
|
1362
|
+
* per-sub-bucket --first/--last on a mixed-stage membership move.
|
|
1363
|
+
*/
|
|
1364
|
+
function groupByReorderTarget(deps, rows, packedToday) {
|
|
1365
|
+
const groups = new Map();
|
|
1366
|
+
for (const r of rows) {
|
|
1367
|
+
const target = reorderTargetOf(r, r.type === 0, packedToday, deps.config.bounceEnabled);
|
|
1368
|
+
const key = containerKey(target);
|
|
1369
|
+
const g = groups.get(key);
|
|
1370
|
+
if (g !== undefined)
|
|
1371
|
+
g.rows.push(r);
|
|
1372
|
+
else
|
|
1373
|
+
groups.set(key, { target, rows: [r] });
|
|
1374
|
+
}
|
|
1375
|
+
return [...groups.values()];
|
|
1376
|
+
}
|
|
1377
|
+
/**
|
|
1378
|
+
* Place each stage-group of a mixed-stage membership move at the top/bottom of ITS
|
|
1379
|
+
* sub-bucket (spec §4 rule 4). Each group with a wired protocol runs its own
|
|
1380
|
+
* --first/--last reorder; a protocol-less group is honest app-default. The note
|
|
1381
|
+
* states every group's outcome (the rule-5 honesty surface).
|
|
1382
|
+
*/
|
|
1383
|
+
async function placePerBucket(deps, op, groups, position, moveeTitles, membership, options) {
|
|
1384
|
+
const notes = [];
|
|
1385
|
+
const placements = [];
|
|
1386
|
+
let anyGuaranteed = false;
|
|
1387
|
+
let anyDefault = false;
|
|
1388
|
+
for (const g of groups) {
|
|
1389
|
+
const count = g.rows.length;
|
|
1390
|
+
if (g.target.scope === null) {
|
|
1391
|
+
anyDefault = true;
|
|
1392
|
+
notes.push(`${describeScope(g.target)}: app-default (${count})`);
|
|
1393
|
+
continue;
|
|
1394
|
+
}
|
|
1395
|
+
const movees = g.rows.map((r) => r.uuid);
|
|
1396
|
+
const reorderUuids = buildReorderOrder(deps, g.target, movees, position);
|
|
1397
|
+
const placement = await runReorder(deps, {
|
|
1398
|
+
scope: g.target.scope,
|
|
1399
|
+
uuids: reorderUuids,
|
|
1400
|
+
named: movees,
|
|
1401
|
+
...(g.target.container !== undefined && { container: { uuid: g.target.container } }),
|
|
1402
|
+
}, legOptions(options));
|
|
1403
|
+
placements.push(placement);
|
|
1404
|
+
if (placement.kind === "ok") {
|
|
1405
|
+
anyGuaranteed = true;
|
|
1406
|
+
notes.push(`${describeScope(g.target)}: ${position.at} (${count})${touchedSuffix(placement)}`);
|
|
1407
|
+
}
|
|
1408
|
+
else {
|
|
1409
|
+
anyDefault = true;
|
|
1410
|
+
notes.push(`${describeScope(g.target)}: placement unavailable (${placement.kind}) — app-default`);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
return {
|
|
1414
|
+
kind: "move-ok",
|
|
1415
|
+
op,
|
|
1416
|
+
movees: moveeTitles,
|
|
1417
|
+
membership,
|
|
1418
|
+
placement: placements[0] ?? null,
|
|
1419
|
+
placementClass: anyGuaranteed && !anyDefault ? "guaranteed" : "app-default",
|
|
1420
|
+
note: `membership moved; mixed-stage placement applied PER sub-bucket (--${position.at}): ` +
|
|
1421
|
+
notes.join("; "),
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
/**
|
|
1425
|
+
* The full bucket order with the movees assembled as a contiguous block at the
|
|
1426
|
+
* EARLIEST movee's current slot (bare-reorder semantics, spec §4).
|
|
1427
|
+
*/
|
|
1428
|
+
function earliestSlotOrder(deps, target, movees) {
|
|
1429
|
+
const members = bucketMembers(deps, target, movees[0]);
|
|
1430
|
+
const moveeSet = new Set(movees);
|
|
1431
|
+
const earliest = members.findIndex((u) => moveeSet.has(u));
|
|
1432
|
+
if (earliest < 0)
|
|
1433
|
+
return movees;
|
|
1434
|
+
const before = members.slice(0, earliest).filter((u) => !moveeSet.has(u));
|
|
1435
|
+
const after = members.slice(earliest + 1).filter((u) => !moveeSet.has(u));
|
|
1436
|
+
return [...before, ...movees, ...after];
|
|
1437
|
+
}
|
|
1438
|
+
/** Build the reorder uuid list realizing the requested position. */
|
|
1439
|
+
function buildReorderOrder(deps, target, movees, position) {
|
|
1440
|
+
if (target.scope === null)
|
|
1441
|
+
return movees;
|
|
1442
|
+
// first / default → partial top-placement (reorder puts these at the top).
|
|
1443
|
+
if (position === undefined || ("at" in position && position.at === "first")) {
|
|
1444
|
+
return movees;
|
|
1445
|
+
}
|
|
1446
|
+
// last / before / after → a FULL re-rank: read the bucket order and splice.
|
|
1447
|
+
const members = bucketMembers(deps, target, movees[0]);
|
|
1448
|
+
const others = members.filter((u) => !movees.includes(u));
|
|
1449
|
+
if ("at" in position && position.at === "last")
|
|
1450
|
+
return [...others, ...movees];
|
|
1451
|
+
if ("before" in position || "after" in position) {
|
|
1452
|
+
const anchor = resolveMoveeUuid(deps, "before" in position ? position.before : position.after);
|
|
1453
|
+
const idx = others.indexOf(anchor);
|
|
1454
|
+
if (idx < 0)
|
|
1455
|
+
return [...others, ...movees];
|
|
1456
|
+
const insertAt = "before" in position ? idx : idx + 1;
|
|
1457
|
+
return [...others.slice(0, insertAt), ...movees, ...others.slice(insertAt)];
|
|
1458
|
+
}
|
|
1459
|
+
return movees;
|
|
1460
|
+
}
|
|
1461
|
+
function resolveMoveeUuid(deps, ref) {
|
|
1462
|
+
try {
|
|
1463
|
+
return resolveTaskUuidPrefix(deps.db, ref, "item");
|
|
1464
|
+
}
|
|
1465
|
+
catch {
|
|
1466
|
+
return ref;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
/**
|
|
1470
|
+
* The current member order of a reorder bucket (for full re-rank builds). The
|
|
1471
|
+
* day-group scopes (container-day, `day`, tomorrow) read their day off the FIRST
|
|
1472
|
+
* requested uuid (a day-group is keyed by a movee's startDate, not by a
|
|
1473
|
+
* container), so `dayAnchor` (a movee) seeds the enumeration for them — without it
|
|
1474
|
+
* those scopes enumerate nothing and an anchored (--last/--before/--after)
|
|
1475
|
+
* placement cannot splice against the real day order.
|
|
1476
|
+
*/
|
|
1477
|
+
function bucketMembers(deps, target, dayAnchor) {
|
|
1478
|
+
if (target.scope === null)
|
|
1479
|
+
return [];
|
|
1480
|
+
const seedsDay = target.scope === "container-day" || target.scope === "day" || target.scope === "tomorrow";
|
|
1481
|
+
const params = {
|
|
1482
|
+
scope: target.scope,
|
|
1483
|
+
uuids: seedsDay && dayAnchor !== undefined ? [dayAnchor] : [],
|
|
1484
|
+
...(target.container !== undefined && { container: { uuid: target.container } }),
|
|
1485
|
+
};
|
|
1486
|
+
const containerUuid = target.container ?? null;
|
|
1487
|
+
const pre = computeReorderPre(deps.db, params, containerUuid, deps.now?.() ?? new Date());
|
|
1488
|
+
return pre.members.map((m) => m.uuid);
|
|
1489
|
+
}
|
|
1490
|
+
function legOptions(options) {
|
|
1491
|
+
const legs = {};
|
|
1492
|
+
if (options.maxDisruption !== undefined)
|
|
1493
|
+
legs.maxDisruption = options.maxDisruption;
|
|
1494
|
+
if (options.verifyTimeoutMs !== undefined)
|
|
1495
|
+
legs.verifyTimeoutMs = options.verifyTimeoutMs;
|
|
1496
|
+
if (options.actor !== undefined)
|
|
1497
|
+
legs.actor = options.actor;
|
|
1498
|
+
if (options.vector !== undefined)
|
|
1499
|
+
legs.vector = options.vector;
|
|
1500
|
+
if (options.acknowledgeProjectReopen !== undefined) {
|
|
1501
|
+
legs.acknowledgeProjectReopen = options.acknowledgeProjectReopen;
|
|
1502
|
+
}
|
|
1503
|
+
return legs;
|
|
1504
|
+
}
|
|
1505
|
+
function describeScope(target) {
|
|
1506
|
+
if (target.scope === null)
|
|
1507
|
+
return target.reason;
|
|
1508
|
+
// A day-group's identity IS its date — name it so cross-day refusals read
|
|
1509
|
+
// coherently (e.g. "the loose 2026-08-05 day-group" vs "…2026-08-07…"), never
|
|
1510
|
+
// the self-contradictory date-less "the loose future-day group" on both sides.
|
|
1511
|
+
const day = target.day !== undefined ? decodePackedDate(target.day) : null;
|
|
1512
|
+
if (target.scope === "day") {
|
|
1513
|
+
return day !== null ? `the ${day} day-group` : "the future day-group";
|
|
1514
|
+
}
|
|
1515
|
+
if (target.scope === "tomorrow")
|
|
1516
|
+
return "the Tomorrow day-group";
|
|
1517
|
+
if (target.scope === "container-day" && day !== null) {
|
|
1518
|
+
return `the container-day ${target.container} day-group (${day})`;
|
|
1519
|
+
}
|
|
1520
|
+
return target.container !== undefined
|
|
1521
|
+
? `the ${target.scope} ${target.container}`
|
|
1522
|
+
: `the ${target.scope} list`;
|
|
1523
|
+
}
|
|
1524
|
+
function describePosition(position) {
|
|
1525
|
+
if (position === undefined)
|
|
1526
|
+
return "top of bucket (default)";
|
|
1527
|
+
if ("at" in position)
|
|
1528
|
+
return position.at;
|
|
1529
|
+
if ("before" in position)
|
|
1530
|
+
return `before ${position.before}`;
|
|
1531
|
+
return `after ${position.after}`;
|
|
1532
|
+
}
|
|
1533
|
+
//# sourceMappingURL=move.js.map
|