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