things-api 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cli/canonical-ref.d.ts +21 -0
- package/dist/cli/canonical-ref.js +23 -0
- package/dist/cli/canonical-ref.js.map +1 -0
- package/dist/cli/commands/area.d.ts +5 -5
- package/dist/cli/commands/area.js +39 -18
- package/dist/cli/commands/area.js.map +1 -1
- package/dist/cli/commands/install-skill.d.ts +2 -2
- package/dist/cli/commands/install-skill.js +29 -0
- package/dist/cli/commands/install-skill.js.map +1 -1
- package/dist/cli/commands/mcp.js +12 -1
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/commands/project.js +74 -22
- package/dist/cli/commands/project.js.map +1 -1
- package/dist/cli/commands/reads.d.ts +5 -1
- package/dist/cli/commands/reads.js +31 -15
- package/dist/cli/commands/reads.js.map +1 -1
- package/dist/cli/commands/setup.js +11 -4
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/commands/show.js +51 -32
- package/dist/cli/commands/show.js.map +1 -1
- package/dist/cli/commands/todo.js +2 -2
- package/dist/cli/commands/todo.js.map +1 -1
- package/dist/cli/commands/writes.js +138 -67
- package/dist/cli/commands/writes.js.map +1 -1
- package/dist/cli/did-you-mean.js +19 -5
- package/dist/cli/did-you-mean.js.map +1 -1
- package/dist/cli/dry-run.d.ts +55 -0
- package/dist/cli/dry-run.js +92 -0
- package/dist/cli/dry-run.js.map +1 -0
- package/dist/cli/glyphs.d.ts +9 -2
- package/dist/cli/glyphs.js +11 -5
- package/dist/cli/glyphs.js.map +1 -1
- package/dist/cli/help.js +7 -6
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/main.js +7 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/read-driver.d.ts +47 -7
- package/dist/cli/read-driver.js +43 -21
- package/dist/cli/read-driver.js.map +1 -1
- package/dist/cli/ref-render.d.ts +14 -0
- package/dist/cli/ref-render.js +9 -0
- package/dist/cli/ref-render.js.map +1 -0
- package/dist/cli/render.d.ts +39 -25
- package/dist/cli/render.js +102 -62
- package/dist/cli/render.js.map +1 -1
- package/dist/cli/resolve-invocation.d.ts +5 -3
- package/dist/cli/resolve-invocation.js +13 -4
- package/dist/cli/resolve-invocation.js.map +1 -1
- package/dist/client.d.ts +100 -32
- package/dist/client.js +63 -19
- package/dist/client.js.map +1 -1
- package/dist/contracts.d.ts +34 -33
- package/dist/contracts.js +1 -1
- package/dist/contracts.js.map +1 -1
- package/dist/db/baselines/db-v26.js +4 -1
- package/dist/db/baselines/db-v26.js.map +1 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.js +9 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +192 -139
- package/dist/mcp/server.js.map +1 -1
- package/dist/model/dates.d.ts +26 -0
- package/dist/model/dates.js +46 -4
- package/dist/model/dates.js.map +1 -1
- package/dist/model/entities.d.ts +11 -1
- package/dist/model/entities.js.map +1 -1
- package/dist/model/mappers.js +3 -0
- package/dist/model/mappers.js.map +1 -1
- package/dist/model/serialize.js +15 -3
- package/dist/model/serialize.js.map +1 -1
- package/dist/read/area-filter.d.ts +2 -2
- package/dist/read/area-filter.js +3 -3
- package/dist/read/area-filter.js.map +1 -1
- package/dist/read/project-view.d.ts +76 -1
- package/dist/read/project-view.js +80 -9
- package/dist/read/project-view.js.map +1 -1
- package/dist/read/queries.d.ts +71 -2
- package/dist/read/queries.js +199 -10
- package/dist/read/queries.js.map +1 -1
- package/dist/read/scope.d.ts +2 -2
- package/dist/read/scope.js +3 -3
- package/dist/read/scope.js.map +1 -1
- package/dist/read/shape.d.ts +87 -9
- package/dist/read/shape.js +483 -124
- package/dist/read/shape.js.map +1 -1
- package/dist/read/show-target.js +52 -33
- package/dist/read/show-target.js.map +1 -1
- package/dist/read/truncation.d.ts +73 -7
- package/dist/read/truncation.js +62 -29
- package/dist/read/truncation.js.map +1 -1
- package/dist/read/views.d.ts +20 -7
- package/dist/read/views.js +7 -7
- package/dist/read/views.js.map +1 -1
- package/dist/surface-copy.d.ts +13 -0
- package/dist/surface-copy.js +13 -0
- package/dist/surface-copy.js.map +1 -1
- package/dist/write/batch.js +0 -1
- package/dist/write/batch.js.map +1 -1
- package/dist/write/commands.d.ts +25 -1
- package/dist/write/commands.js +259 -122
- package/dist/write/commands.js.map +1 -1
- package/dist/write/guards.js +27 -11
- package/dist/write/guards.js.map +1 -1
- package/dist/write/move.d.ts +34 -9
- package/dist/write/move.js +730 -78
- package/dist/write/move.js.map +1 -1
- package/dist/write/operations.d.ts +50 -20
- package/dist/write/operations.js +2 -2
- package/dist/write/operations.js.map +1 -1
- package/dist/write/pipeline.js +25 -4
- package/dist/write/pipeline.js.map +1 -1
- package/dist/write/pre-state.d.ts +58 -28
- package/dist/write/pre-state.js +247 -49
- package/dist/write/pre-state.js.map +1 -1
- package/dist/write/reorder.js +266 -32
- package/dist/write/reorder.js.map +1 -1
- package/dist/write/resolution-timestamps.d.ts +34 -0
- package/dist/write/resolution-timestamps.js +286 -0
- package/dist/write/resolution-timestamps.js.map +1 -0
- package/dist/write/reversibility.js +5 -5
- package/dist/write/reversibility.js.map +1 -1
- package/dist/write/scope-guard.js +0 -5
- package/dist/write/scope-guard.js.map +1 -1
- package/dist/write/undo.d.ts +0 -6
- package/dist/write/undo.js +44 -4
- package/dist/write/undo.js.map +1 -1
- package/dist/write/vectors/applescript.js +14 -4
- package/dist/write/vectors/applescript.js.map +1 -1
- package/dist/write/vectors/url-scheme.js +0 -8
- package/dist/write/vectors/url-scheme.js.map +1 -1
- package/dist/write/verify/delta.d.ts +16 -2
- package/dist/write/verify/delta.js +21 -2
- package/dist/write/verify/delta.js.map +1 -1
- package/package.json +1 -1
- package/schema/envelope.schema.json +16 -78
- package/skills/things-cli/SKILL.md +10 -6
- package/skills/things-cli/references/contracts.md +3 -2
- package/skills/things-cli/references/errors.md +9 -1
- package/skills/things-cli/references/model.md +9 -7
- package/skills/things-cli/references/ordering.md +36 -10
package/dist/write/move.js
CHANGED
|
@@ -2,19 +2,21 @@ import { addDaysIso, decodePackedDate, encodePackedDate, localToday } from "../m
|
|
|
2
2
|
import { ReferenceResolutionError, resolveTaskUuidPrefix } from "../read/queries.js";
|
|
3
3
|
import { taskMembershipClause } from "../read/scope.js";
|
|
4
4
|
import { isLooseRef, LOOSE_TO_AREA_REFUSAL } from "../read/pseudo-area.js";
|
|
5
|
-
import { computeReorderPre, resolveArea, resolveHeading, resolveProject } from "./pre-state.js";
|
|
5
|
+
import { computeHeadingMovePre, computeReorderPre, resolveArea, resolveHeading, resolveProject, } from "./pre-state.js";
|
|
6
6
|
import {} from "./pipeline.js";
|
|
7
7
|
import { runMutation } from "./pipeline.js";
|
|
8
8
|
import { runReorder } from "./reorder.js";
|
|
9
9
|
function loadRow(db, uuid) {
|
|
10
10
|
const row = db
|
|
11
|
-
.prepare("SELECT uuid, title, type, project, area, heading, start, startDate, startBucket, " +
|
|
12
|
-
"rt1_recurrenceRule AS rule, repeater
|
|
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 = ?")
|
|
13
14
|
.get(uuid);
|
|
14
15
|
if (row === undefined)
|
|
15
16
|
return undefined;
|
|
16
|
-
const { rule, repeater, ...rest } = row;
|
|
17
|
-
|
|
17
|
+
const { rule, repeater, projectionDay, ...rest } = row;
|
|
18
|
+
const isTemplate = rule !== null || repeater !== null;
|
|
19
|
+
return { ...rest, isTemplate, templateProjectionDay: isTemplate ? projectionDay : null };
|
|
18
20
|
}
|
|
19
21
|
const KIND_LABEL = { 0: "to-do", 1: "project", 2: "heading" };
|
|
20
22
|
/**
|
|
@@ -58,6 +60,33 @@ function scheduleBucket(row, packedToday) {
|
|
|
58
60
|
return "someday";
|
|
59
61
|
return "anytime";
|
|
60
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
|
+
}
|
|
61
90
|
/** An app-default target for a bounce-dependent placement while bounce is off. */
|
|
62
91
|
function bounceDisabledTarget(what) {
|
|
63
92
|
return {
|
|
@@ -90,7 +119,24 @@ function bounceDisabledTarget(what) {
|
|
|
90
119
|
*/
|
|
91
120
|
function reorderTargetOf(row, isTodo, packedToday, bounceEnabled) {
|
|
92
121
|
if (row.isTemplate) {
|
|
93
|
-
|
|
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 };
|
|
94
140
|
}
|
|
95
141
|
const bucket = scheduleBucket(row, packedToday);
|
|
96
142
|
// A same-day (today-proper) or future scheduled day, startBucket=0 — the
|
|
@@ -114,6 +160,17 @@ function reorderTargetOf(row, isTodo, packedToday, bounceEnabled) {
|
|
|
114
160
|
// so they take the dated bounce even on tomorrow.
|
|
115
161
|
const isTomorrow = row.startBucket === 0 && row.startDate === packedTomorrowOf(packedToday);
|
|
116
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 };
|
|
117
174
|
// ARRIVED Today-view members route DATE-FIRST, before any container branch.
|
|
118
175
|
// A dated row whose day has landed (startDate <= today) is a Today member —
|
|
119
176
|
// the GUI renders arrived/today-dated rows in the TODAY view; the Upcoming
|
|
@@ -262,6 +319,20 @@ function indexAxisTargetOf(row, bounceEnabled) {
|
|
|
262
319
|
// shared loose index bucket and spuriously "dual-axis ambiguous".
|
|
263
320
|
return reorderTargetOf(asAnytime, row.type === 0, 0, bounceEnabled);
|
|
264
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
|
+
}
|
|
265
336
|
/**
|
|
266
337
|
* True when a row's INDEX-axis target preserves the Today/Evening flag. TWO
|
|
267
338
|
* flag-safe families:
|
|
@@ -311,11 +382,38 @@ function describeSetLocation(rows, packedToday) {
|
|
|
311
382
|
const buckets = [...new Set(rows.map((r) => scheduleBucket(r, packedToday)))];
|
|
312
383
|
return `in the ${buckets.join(" / ")} bucket`;
|
|
313
384
|
}
|
|
314
|
-
/**
|
|
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. */
|
|
315
400
|
function parseInTarget(deps, raw, rows) {
|
|
316
401
|
const norm = raw.trim().toLowerCase();
|
|
317
402
|
if (IN_AXES.includes(norm))
|
|
318
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
|
+
}
|
|
319
417
|
if (norm === "loose") {
|
|
320
418
|
return {
|
|
321
419
|
error: '`--in loose` is not valid — "loose" is a read view, not a reorder bucket; ' +
|
|
@@ -345,7 +443,7 @@ function parseInTarget(deps, raw, rows) {
|
|
|
345
443
|
}
|
|
346
444
|
return {
|
|
347
445
|
error: `--in "${raw}" did not resolve to a project, area, or heading ` +
|
|
348
|
-
"(or one of today | evening | anytime | someday | inbox)",
|
|
446
|
+
"(or one of today | evening | anytime | someday | inbox | upcoming | a YYYY-MM-DD day)",
|
|
349
447
|
};
|
|
350
448
|
}
|
|
351
449
|
/**
|
|
@@ -375,20 +473,76 @@ function resolveReorderAxis(deps, op, rows, inTarget, packedToday, verb, positio
|
|
|
375
473
|
// single-bucket guard; view=null means "not a clean view set".)
|
|
376
474
|
const views = new Set(coherence.map((r) => viewOf(r, packedToday)));
|
|
377
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;
|
|
378
486
|
// The shared index-axis container target, if the whole coherence set has one
|
|
379
487
|
// (classifying each row as though it sat in its container's anytime bucket).
|
|
380
488
|
const indexTargets = coherence.map((r) => indexAxisTargetOf(r, bounceEnabled));
|
|
381
489
|
const indexKeys = new Set(indexTargets.map(containerKey));
|
|
382
|
-
const indexTarget = indexKeys.size === 1 && indexTargets[0]?.scope != null
|
|
490
|
+
const indexTarget = sameKind && indexKeys.size === 1 && indexTargets[0]?.scope != null
|
|
383
491
|
? indexTargets[0]
|
|
384
492
|
: null;
|
|
385
|
-
// Force the CONTAINER index axis for
|
|
386
|
-
// their natural target (used when --in names a container).
|
|
387
|
-
|
|
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
|
+
};
|
|
388
506
|
if (inTarget !== undefined) {
|
|
389
507
|
const parsed = parseInTarget(deps, inTarget, rows);
|
|
390
508
|
if ("error" in parsed)
|
|
391
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
|
+
}
|
|
392
546
|
if ("axis" in parsed) {
|
|
393
547
|
const axis = parsed.axis;
|
|
394
548
|
if (axis === "today" || axis === "evening") {
|
|
@@ -421,19 +575,34 @@ function resolveReorderAxis(deps, op, rows, inTarget, packedToday, verb, positio
|
|
|
421
575
|
: ""), `reorder them in the view with --in ${view}, or reschedule them off Today first`),
|
|
422
576
|
};
|
|
423
577
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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) {
|
|
429
584
|
return {
|
|
430
|
-
refused: refused(op, "usage", `--in ${axis} but
|
|
431
|
-
(
|
|
432
|
-
|
|
433
|
-
: ""), "omit --in and reorder the items where they are, or move them first"),
|
|
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"),
|
|
434
588
|
};
|
|
435
589
|
}
|
|
436
|
-
|
|
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 };
|
|
437
606
|
}
|
|
438
607
|
// A container ref (project/area/heading).
|
|
439
608
|
const container = parsed.container;
|
|
@@ -472,6 +641,32 @@ function resolveReorderAxis(deps, op, rows, inTarget, packedToday, verb, positio
|
|
|
472
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`),
|
|
473
642
|
};
|
|
474
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
|
+
}
|
|
475
670
|
// A MIXED dual-axis set (some Today/Evening-flagged, some plain, all sharing one
|
|
476
671
|
// flag-safe container index) is NOT ambiguous: the plain members are not view
|
|
477
672
|
// members, so there is no shared view to reorder them in — the container index is
|
|
@@ -529,6 +724,37 @@ function refused(op, refusal, detail, remediation) {
|
|
|
529
724
|
...(remediation !== undefined && { remediation }),
|
|
530
725
|
};
|
|
531
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
|
+
}
|
|
532
758
|
// The ratified teaching errors (spec §4 bare-invocation block, §5, §7).
|
|
533
759
|
const BARE_TODO_MOVE = "`todo move` needs a destination or a position. To change what a to-do belongs to, name " +
|
|
534
760
|
"one of --to-project / --to-heading / --to-area (or --no-heading / --loose to detach). To " +
|
|
@@ -905,6 +1131,36 @@ export async function runProjectMove(deps, request, options = {}) {
|
|
|
905
1131
|
* a contiguous block at the EARLIEST movee's current slot, in argument order
|
|
906
1132
|
* (spec §4 — `--first` is NOT implied). Cross-container operands fail closed.
|
|
907
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
|
+
}
|
|
908
1164
|
export async function runInPlaceReorder(deps, op, request, options = {}) {
|
|
909
1165
|
const now = deps.now?.() ?? new Date();
|
|
910
1166
|
const packedToday = encodePackedDate(localToday(now));
|
|
@@ -933,61 +1189,446 @@ export async function runInPlaceReorder(deps, op, request, options = {}) {
|
|
|
933
1189
|
wrongKind.push({ ref: r.uuid, kind: KIND_LABEL[row.type] ?? "item" });
|
|
934
1190
|
rows.push(row);
|
|
935
1191
|
}
|
|
936
|
-
//
|
|
937
|
-
// reorder: `today` (O12 — `list "Today"` takes projects
|
|
938
|
-
// EVEORD — projects share the evening axis, per-type
|
|
939
|
-
// day-group scopes (`day`/`tomorrow
|
|
940
|
-
//
|
|
941
|
-
//
|
|
942
|
-
//
|
|
943
|
-
//
|
|
944
|
-
//
|
|
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);
|
|
945
1217
|
const globalAxisIntermix = isTodo &&
|
|
946
1218
|
rows.length > 0 &&
|
|
1219
|
+
!inForcesIndex &&
|
|
947
1220
|
rows.every((r) => {
|
|
948
1221
|
const b = scheduleBucket(r, packedToday);
|
|
949
|
-
return b === "today" ||
|
|
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));
|
|
950
1236
|
});
|
|
951
1237
|
if (wrongKind.length > 0 && !globalAxisIntermix) {
|
|
952
|
-
|
|
953
|
-
const allProjects = isTodo && rows.length > 0 && rows.every((r) => r.type === 1);
|
|
954
|
-
return refused(op, "usage", `homogeneous kinds required — \`reorder\` rearranges to-dos ` +
|
|
955
|
-
"(the Today, This Evening, and future day-group lists also accept project rows " +
|
|
956
|
-
`intermixed with to-dos); not: ${list}`, allProjects
|
|
957
|
-
? "to rearrange projects in their sidebar/area/someday order use `things project move " +
|
|
958
|
-
"<refs…> --first/--last/--before/--after`"
|
|
959
|
-
: undefined);
|
|
1238
|
+
return indexKindRefusal(op, rows);
|
|
960
1239
|
}
|
|
961
1240
|
return repositionInPlace(deps, op, rows, request.position, packedToday, options, "reorder", request.in);
|
|
962
1241
|
}
|
|
1242
|
+
/**
|
|
1243
|
+
* The ONE reorder verb (`things reorder <refs…>`) — kind-aware dispatch across
|
|
1244
|
+
* to-dos, projects, headings, and sidebar areas (spec §7). Every operand is
|
|
1245
|
+
* resolved (task first, then area) and classified; a homogeneous set routes to
|
|
1246
|
+
* its engine, and any illegal MIX gets one precise refusal:
|
|
1247
|
+
* - to-dos and/or projects → the in-place index/day/view engine (a to-do+project
|
|
1248
|
+
* set is legal only on the shared Today/Evening/day axes — that engine enforces
|
|
1249
|
+
* it); a pure-project set re-ranks its siblings;
|
|
1250
|
+
* - headings (one project) → the certified heading-block wire (#V11);
|
|
1251
|
+
* - areas → the sidebar-rank drag engine.
|
|
1252
|
+
* A heading or area may not share a call with any other kind.
|
|
1253
|
+
*/
|
|
1254
|
+
export async function runUniversalReorder(deps, request, options = {}) {
|
|
1255
|
+
if (request.uuids.length === 0) {
|
|
1256
|
+
return refused("todo.move", "usage", "no items given — name at least one to reorder");
|
|
1257
|
+
}
|
|
1258
|
+
const classified = [];
|
|
1259
|
+
for (const ref of request.uuids) {
|
|
1260
|
+
const t = resolveMovee(deps, ref);
|
|
1261
|
+
if (!(t instanceof ReferenceResolutionError)) {
|
|
1262
|
+
const row = loadRow(deps.db, t.uuid);
|
|
1263
|
+
if (row !== undefined) {
|
|
1264
|
+
const kind = row.type === 1 ? "project" : row.type === 2 ? "heading" : "todo";
|
|
1265
|
+
classified.push({ kind, uuid: t.uuid, ref, row });
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
// Not a task ref — try a sidebar area (areas live in TMArea, not TMTask).
|
|
1270
|
+
const a = resolveArea(deps.db, { uuid: ref, title: ref });
|
|
1271
|
+
if (a.resolved !== null) {
|
|
1272
|
+
classified.push({ kind: "area", uuid: a.resolved.uuid, ref, title: a.resolved.title });
|
|
1273
|
+
continue;
|
|
1274
|
+
}
|
|
1275
|
+
// Neither a task nor an area — surface an AMBIGUOUS task match (its candidates
|
|
1276
|
+
// are the useful signal); a clean not-found names all four kinds.
|
|
1277
|
+
if (t instanceof ReferenceResolutionError &&
|
|
1278
|
+
t.candidates !== undefined &&
|
|
1279
|
+
t.candidates.length > 0) {
|
|
1280
|
+
return {
|
|
1281
|
+
kind: "move-refused",
|
|
1282
|
+
op: "todo.move",
|
|
1283
|
+
refusal: "usage",
|
|
1284
|
+
detail: t.message,
|
|
1285
|
+
candidates: t.candidates,
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
return refused("todo.move", "usage", `no to-do, project, heading, or area matches "${ref}"`);
|
|
1289
|
+
}
|
|
1290
|
+
const kinds = new Set(classified.map((c) => c.kind));
|
|
1291
|
+
const hasHeading = kinds.has("heading");
|
|
1292
|
+
const hasArea = kinds.has("area");
|
|
1293
|
+
// Mixed-kind refusal (item 2): only a to-do+project set may share a call (and
|
|
1294
|
+
// only on a global axis, enforced downstream). A heading or an area never mixes.
|
|
1295
|
+
if ((hasHeading || hasArea) && kinds.size > 1) {
|
|
1296
|
+
const listed = classified.map((c) => `${c.ref} (${c.kind})`).join(", ");
|
|
1297
|
+
return refused("todo.move", "usage", "one kind at a time — `reorder` rearranges a set of to-dos, a set of projects, a set of " +
|
|
1298
|
+
`headings, OR a set of areas, but this set mixes kinds: ${listed}. Only to-dos and ` +
|
|
1299
|
+
"projects intermix, and only on the shared Today/Evening/day axes.", "reorder each kind in its own call");
|
|
1300
|
+
}
|
|
1301
|
+
if (hasArea) {
|
|
1302
|
+
if (request.in !== undefined) {
|
|
1303
|
+
return refused("area.reorder", "usage", "`--in` names a to-do/project reorder axis — areas have a single sidebar-rank order, so " +
|
|
1304
|
+
"it does not apply here", "drop --in; position the area(s) with --start / --end / --before / --after");
|
|
1305
|
+
}
|
|
1306
|
+
return runAreaReorderUniversal(deps, classified.filter((c) => c.kind === "area"), request.position, options);
|
|
1307
|
+
}
|
|
1308
|
+
if (hasHeading) {
|
|
1309
|
+
if (request.in !== undefined) {
|
|
1310
|
+
return refused("project.move-heading", "usage", "`--in` names a to-do/project reorder axis — a heading's order is the project's heading " +
|
|
1311
|
+
"axis, so it does not apply here", "drop --in; position the heading(s) with --start / --end / --before / --after");
|
|
1312
|
+
}
|
|
1313
|
+
return runHeadingReorder(deps, classified.filter((c) => c.kind === "heading"), request.position, options);
|
|
1314
|
+
}
|
|
1315
|
+
// to-dos and/or projects: the existing in-place engine. A pure-project set routes
|
|
1316
|
+
// through project.move; any to-do present routes through todo.move (which handles
|
|
1317
|
+
// the to-do+project global-axis intermix and refuses a non-global mixed set).
|
|
1318
|
+
const op = kinds.has("todo") ? "todo.move" : "project.move";
|
|
1319
|
+
return runInPlaceReorder(deps, op, request, options);
|
|
1320
|
+
}
|
|
1321
|
+
/** The project's heading uuids in current display (index) order. */
|
|
1322
|
+
function currentHeadingOrder(deps, projectUuid) {
|
|
1323
|
+
return deps.db
|
|
1324
|
+
.prepare(`SELECT uuid FROM TMTask WHERE type = 2 AND trashed = 0 AND project = ? ORDER BY "index"`)
|
|
1325
|
+
.all(projectUuid).map((r) => r.uuid);
|
|
1326
|
+
}
|
|
1327
|
+
/**
|
|
1328
|
+
* Same-project heading re-ranking via the universal verb — dispatches onto the
|
|
1329
|
+
* certified heading-block wire (`project.move-heading`), honoring #V11 (archived
|
|
1330
|
+
* headings reorderable unguarded, with reopens disclosed). `project move-heading`
|
|
1331
|
+
* remains the placement verb (cross-project / demotion); THIS is pure re-rank.
|
|
1332
|
+
*/
|
|
1333
|
+
async function runHeadingReorder(deps, headings, position, options) {
|
|
1334
|
+
const op = "project.move-heading";
|
|
1335
|
+
// One shared project (cross-container refusal, item 2).
|
|
1336
|
+
const projectUuids = new Set(headings.map((h) => h.row.project));
|
|
1337
|
+
if (projectUuids.size !== 1 || projectUuids.has(null)) {
|
|
1338
|
+
const where = headings
|
|
1339
|
+
.map((h) => `${h.uuid} in ${h.row.project === null ? "no project" : `project ${h.row.project}`}`)
|
|
1340
|
+
.join("; ");
|
|
1341
|
+
return refused(op, "blocked", `these headings span projects (${where}) — a heading reorder rearranges the headings of ONE project`, "reorder the headings of one project at a time");
|
|
1342
|
+
}
|
|
1343
|
+
const projectUuid = [...projectUuids][0];
|
|
1344
|
+
const movees = headings.map((h) => h.uuid);
|
|
1345
|
+
// Map the anchor grammar onto a HeadingPlacement. --start/--end → first/last;
|
|
1346
|
+
// --before/--after resolve their ref to a heading of the SAME project (an anchor
|
|
1347
|
+
// positions, never migrates); bare → the block at the earliest movee's slot.
|
|
1348
|
+
let placement;
|
|
1349
|
+
if (position !== undefined && ("before" in position || "after" in position)) {
|
|
1350
|
+
const anchorRef = "before" in position ? position.before : position.after;
|
|
1351
|
+
const ar = resolveMovee(deps, anchorRef);
|
|
1352
|
+
if (ar instanceof ReferenceResolutionError) {
|
|
1353
|
+
return {
|
|
1354
|
+
kind: "move-refused",
|
|
1355
|
+
op,
|
|
1356
|
+
refusal: "usage",
|
|
1357
|
+
detail: ar.message,
|
|
1358
|
+
...(ar.candidates !== undefined && { candidates: ar.candidates }),
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
const arow = loadRow(deps.db, ar.uuid);
|
|
1362
|
+
if (arow === undefined || arow.type !== 2 || arow.project !== projectUuid) {
|
|
1363
|
+
return refused(op, "blocked", `the anchor ${ar.uuid} is not a heading of the movees' project — an anchor positions, it never migrates`, "pick a heading anchor in that project, or use --start / --end");
|
|
1364
|
+
}
|
|
1365
|
+
placement = "before" in position ? { before: ar.uuid } : { after: ar.uuid };
|
|
1366
|
+
}
|
|
1367
|
+
else if (position !== undefined && "at" in position) {
|
|
1368
|
+
placement = { position: position.at };
|
|
1369
|
+
}
|
|
1370
|
+
else {
|
|
1371
|
+
const current = currentHeadingOrder(deps, projectUuid);
|
|
1372
|
+
const moveeSet = new Set(movees);
|
|
1373
|
+
const earliest = current.findIndex((u) => moveeSet.has(u));
|
|
1374
|
+
const before = current.slice(0, Math.max(earliest, 0)).filter((u) => !moveeSet.has(u));
|
|
1375
|
+
placement =
|
|
1376
|
+
before.length > 0 ? { after: before[before.length - 1] } : { position: "first" };
|
|
1377
|
+
}
|
|
1378
|
+
// Compute the wire/reopened disclosure up front (both the dry-run plan and the ok
|
|
1379
|
+
// note surface it) — computeHeadingMovePre also validates membership/anchor.
|
|
1380
|
+
const pre = computeHeadingMovePre(deps.db, resolveProject(deps.db, { uuid: projectUuid }), movees, placement);
|
|
1381
|
+
if (pre.problems.length > 0) {
|
|
1382
|
+
return refused(op, "blocked", `heading reorder rejected: ${pre.problems.join("; ")}`, "reorder the headings of one project (read the project first)");
|
|
1383
|
+
}
|
|
1384
|
+
const touched = pre.wire.filter((u) => !movees.includes(u) && !pre.reopened.includes(u));
|
|
1385
|
+
const reopenNote = pre.reopened.length > 0
|
|
1386
|
+
? `; re-ranking archived heading(s) ${pre.reopened.join(", ")} brought them back to open ` +
|
|
1387
|
+
"(their children stay resolved)"
|
|
1388
|
+
: "";
|
|
1389
|
+
const result = await runMutation(deps, op, { project: { uuid: projectUuid }, headings: movees, placement }, { ...legOptions(options), ...(options.dryRun === true && { dryRun: true }) });
|
|
1390
|
+
if (options.dryRun === true) {
|
|
1391
|
+
return {
|
|
1392
|
+
kind: "move-dry-run",
|
|
1393
|
+
op,
|
|
1394
|
+
plan: {
|
|
1395
|
+
movees,
|
|
1396
|
+
membership: "none (heading re-rank)",
|
|
1397
|
+
placement: `move-heading → ${describePosition(position)}`,
|
|
1398
|
+
placementClass: "guaranteed",
|
|
1399
|
+
note: dryRunNote(result, "heading re-rank via the native heading-block wire") + reopenNote,
|
|
1400
|
+
},
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
if (result.kind === "blocked") {
|
|
1404
|
+
return {
|
|
1405
|
+
kind: "move-refused",
|
|
1406
|
+
op,
|
|
1407
|
+
refusal: "blocked",
|
|
1408
|
+
detail: result.detail,
|
|
1409
|
+
...(result.remediation !== undefined && { remediation: result.remediation }),
|
|
1410
|
+
...(result.hazard !== undefined && { hazard: result.hazard }),
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
if (result.kind !== "ok") {
|
|
1414
|
+
return {
|
|
1415
|
+
kind: "move-leg-failed",
|
|
1416
|
+
op,
|
|
1417
|
+
detail: `the heading re-rank did not complete (${result.kind})`,
|
|
1418
|
+
failed: result,
|
|
1419
|
+
completed: [],
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
return {
|
|
1423
|
+
kind: "move-ok",
|
|
1424
|
+
op,
|
|
1425
|
+
movees: headings.map((h) => ({ uuid: h.uuid, title: h.row.title })),
|
|
1426
|
+
membership: [],
|
|
1427
|
+
placement: result,
|
|
1428
|
+
placementClass: "guaranteed",
|
|
1429
|
+
note: `reordered ${movees.length} heading(s) within project ${projectUuid}` +
|
|
1430
|
+
reopenNote +
|
|
1431
|
+
(touched.length > 0
|
|
1432
|
+
? `; also re-inserted ${touched.length} unnamed heading(s) to honor the order: ${touched.join(", ")}`
|
|
1433
|
+
: ""),
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
/** Build the ordered single-area drag legs that realize a multi-area placement. */
|
|
1437
|
+
function planAreaLegs(areaUuids, position, anchorUuid) {
|
|
1438
|
+
if ("at" in position) {
|
|
1439
|
+
// first: drag last→first so the block ends up top-in-order; last: forward.
|
|
1440
|
+
return position.at === "first"
|
|
1441
|
+
? areaUuids.toReversed().map((target) => ({ target, position: "first" }))
|
|
1442
|
+
: areaUuids.map((target) => ({ target, position: "last" }));
|
|
1443
|
+
}
|
|
1444
|
+
if ("before" in position) {
|
|
1445
|
+
// Each area dragged before the anchor, forward, lands them in order above it.
|
|
1446
|
+
return areaUuids.map((target) => ({ target, before: anchorUuid }));
|
|
1447
|
+
}
|
|
1448
|
+
// after: chain each area after the previous (anchor → a → b → c).
|
|
1449
|
+
const legs = [];
|
|
1450
|
+
let prev = anchorUuid;
|
|
1451
|
+
for (const target of areaUuids) {
|
|
1452
|
+
legs.push({ target, after: prev });
|
|
1453
|
+
prev = target;
|
|
1454
|
+
}
|
|
1455
|
+
return legs;
|
|
1456
|
+
}
|
|
1457
|
+
/**
|
|
1458
|
+
* Sidebar-area re-ranking via the universal verb — dispatches onto `area.reorder`
|
|
1459
|
+
* (the GUI sidebar-drag driver, the ONLY area-order surface, P6/O13). A single
|
|
1460
|
+
* area is one drag; a set composes sequential drags (non-atomic, disclosed). The
|
|
1461
|
+
* discriminating `things area reorder` alias reaches the same path.
|
|
1462
|
+
*/
|
|
1463
|
+
async function runAreaReorderUniversal(deps, areas, position, options) {
|
|
1464
|
+
const op = "area.reorder";
|
|
1465
|
+
if (position === undefined) {
|
|
1466
|
+
return refused(op, "usage", "an area reorder needs a position — areas have no per-item bucket to assemble a block in", "use --start / --end, or --before / --after another area");
|
|
1467
|
+
}
|
|
1468
|
+
const movees = areas.map((a) => a.uuid);
|
|
1469
|
+
let anchorUuid = null;
|
|
1470
|
+
if ("before" in position || "after" in position) {
|
|
1471
|
+
const anchorRef = "before" in position ? position.before : position.after;
|
|
1472
|
+
const ar = resolveArea(deps.db, { uuid: anchorRef, title: anchorRef });
|
|
1473
|
+
if (ar.resolved === null) {
|
|
1474
|
+
return refused(op, "usage", `the anchor "${anchorRef}" did not resolve to an area`, "name an area (uuid or unique title) to anchor against");
|
|
1475
|
+
}
|
|
1476
|
+
anchorUuid = ar.resolved.uuid;
|
|
1477
|
+
if (movees.includes(anchorUuid)) {
|
|
1478
|
+
return refused(op, "blocked", "the anchor area cannot also be one of the moved areas — an anchor positions, it never migrates", "pick a different anchor area, or use --start / --end");
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
const legs = planAreaLegs(movees, position, anchorUuid);
|
|
1482
|
+
const areaTitles = areas.map((a) => ({ uuid: a.uuid, title: a.title }));
|
|
1483
|
+
if (options.dryRun === true) {
|
|
1484
|
+
return {
|
|
1485
|
+
kind: "move-dry-run",
|
|
1486
|
+
op,
|
|
1487
|
+
plan: {
|
|
1488
|
+
movees,
|
|
1489
|
+
membership: "none (sidebar drag)",
|
|
1490
|
+
placement: `area re-rank → ${describePosition(position)}`,
|
|
1491
|
+
placementClass: "guaranteed",
|
|
1492
|
+
note: `${legs.length} sidebar-drag leg(s) — drives the local Things app, one drag per area`,
|
|
1493
|
+
},
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
// Each leg is a full GUI-drive mutation; forward the drive acknowledgement.
|
|
1497
|
+
const legOpts = {
|
|
1498
|
+
...legOptions(options),
|
|
1499
|
+
...(options.dangerouslyDriveGui !== undefined && {
|
|
1500
|
+
dangerouslyDriveGui: options.dangerouslyDriveGui,
|
|
1501
|
+
}),
|
|
1502
|
+
};
|
|
1503
|
+
const placed = [];
|
|
1504
|
+
let last = null;
|
|
1505
|
+
for (const leg of legs) {
|
|
1506
|
+
const res = await runMutation(deps, op, leg, legOpts);
|
|
1507
|
+
last = res;
|
|
1508
|
+
if (res.kind === "blocked") {
|
|
1509
|
+
// A blocked FIRST leg (nothing moved yet) hoists to a clean refusal; a
|
|
1510
|
+
// blocked later leg is a partial-progress leg failure.
|
|
1511
|
+
if (placed.length === 0) {
|
|
1512
|
+
return {
|
|
1513
|
+
kind: "move-refused",
|
|
1514
|
+
op,
|
|
1515
|
+
refusal: "blocked",
|
|
1516
|
+
detail: res.detail,
|
|
1517
|
+
...(res.remediation !== undefined && { remediation: res.remediation }),
|
|
1518
|
+
...(res.hazard !== undefined && { hazard: res.hazard }),
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
return {
|
|
1522
|
+
kind: "move-leg-failed",
|
|
1523
|
+
op,
|
|
1524
|
+
detail: `positioning ${leg.target} was blocked (${res.detail}) — ${placed.length} area(s) already moved`,
|
|
1525
|
+
failed: res,
|
|
1526
|
+
completed: [],
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1529
|
+
if (res.kind !== "ok") {
|
|
1530
|
+
return {
|
|
1531
|
+
kind: "move-leg-failed",
|
|
1532
|
+
op,
|
|
1533
|
+
detail: `positioning ${leg.target} did not complete (${res.kind}) — ${placed.length} area(s) already moved`,
|
|
1534
|
+
failed: res,
|
|
1535
|
+
completed: [],
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
placed.push(leg.target);
|
|
1539
|
+
}
|
|
1540
|
+
return {
|
|
1541
|
+
kind: "move-ok",
|
|
1542
|
+
op,
|
|
1543
|
+
movees: areaTitles,
|
|
1544
|
+
membership: [],
|
|
1545
|
+
placement: last,
|
|
1546
|
+
placementClass: "guaranteed",
|
|
1547
|
+
note: `re-ranked ${areas.length} area(s) via ${legs.length} sidebar drag(s)` +
|
|
1548
|
+
(legs.length > 1 ? " (sequential — non-atomic)" : ""),
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
963
1551
|
// ---------------------------------------------------------------- shared core
|
|
964
1552
|
/**
|
|
965
|
-
* The single
|
|
966
|
-
*
|
|
967
|
-
*
|
|
968
|
-
*
|
|
969
|
-
*
|
|
970
|
-
*
|
|
971
|
-
|
|
972
|
-
|
|
1553
|
+
* The packed day a single row contributes to a shared future day-group, or null.
|
|
1554
|
+
* A SCHEDULED row (to-do or project) on a strictly-future startBucket=0 day
|
|
1555
|
+
* contributes its `startDate`; a DEADLINE-FORECAST to-do (startDate NULL, future
|
|
1556
|
+
* `deadline`, start IN (1,2) — DLBNC/§9o) contributes its `deadline` (they share the
|
|
1557
|
+
* one block todayIndex axis). Any other row (arrived, undated non-forecast, off the
|
|
1558
|
+
* Today axis) contributes null.
|
|
1559
|
+
*/
|
|
1560
|
+
function rowDayKey(row, packedToday) {
|
|
1561
|
+
// A repeating TEMPLATE contributes its PROJECTION day (rt1_nextInstanceStartDate),
|
|
1562
|
+
// the Upcoming day-block its rendered projection row sits in (TMPLSORT/PTMPL). Its
|
|
1563
|
+
// startDate/deadline are NULL (or non-block), so it would otherwise contribute null;
|
|
1564
|
+
// strictly-future gate mirrors the scheduled/forecast branches (an arrived/absent
|
|
1565
|
+
// projection is not a future day-group member).
|
|
1566
|
+
if (row.isTemplate) {
|
|
1567
|
+
return row.templateProjectionDay !== null && row.templateProjectionDay > packedToday
|
|
1568
|
+
? row.templateProjectionDay
|
|
1569
|
+
: null;
|
|
1570
|
+
}
|
|
1571
|
+
if (row.startDate !== null &&
|
|
1572
|
+
row.startBucket === 0 &&
|
|
1573
|
+
row.startDate > packedToday &&
|
|
1574
|
+
(row.type === 0 || row.type === 1)) {
|
|
1575
|
+
return row.startDate;
|
|
1576
|
+
}
|
|
1577
|
+
return forecastDeadlineDay(row, packedToday);
|
|
1578
|
+
}
|
|
1579
|
+
/**
|
|
1580
|
+
* The single STRICTLY-FUTURE day every movee shares, or null — the precondition for
|
|
1581
|
+
* the SIT4 dated `day` bounce (and the one-call `tomorrow` sort). Members are
|
|
1582
|
+
* SCHEDULED to-dos in ANY container AND scheduled PROJECT rows (area-less OR area-
|
|
1583
|
+
* direct — SIT5 AREAPROJDAY proved the update-project when= legs preserve the area
|
|
1584
|
+
* FK) sharing a future startBucket=0 `startDate`, PLUS DEADLINE-FORECAST to-dos
|
|
1585
|
+
* (DLBNC/§9o) whose future `deadline` equals that same day — all on the ONE Upcoming
|
|
1586
|
+
* day-block todayIndex axis, so a scheduled+forecast mix is one group. A row off the
|
|
1587
|
+
* day, a template, or an undated/arrived non-forecast row breaks the group and falls
|
|
1588
|
+
* through to the normal single-container guard. (Templates never reach here: both
|
|
1589
|
+
* `startDate` and `deadline` fail the strictly-future key for a resting template.)
|
|
973
1590
|
*/
|
|
974
1591
|
function sharedFutureDay(rows, packedToday) {
|
|
975
1592
|
const first = rows[0];
|
|
976
1593
|
if (first === undefined)
|
|
977
1594
|
return null;
|
|
978
|
-
const day = first
|
|
979
|
-
if (day === null
|
|
1595
|
+
const day = rowDayKey(first, packedToday);
|
|
1596
|
+
if (day === null)
|
|
980
1597
|
return null;
|
|
981
1598
|
for (const r of rows) {
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
if (r
|
|
1599
|
+
// Templates NO LONGER break the group: a strictly-future template projection is
|
|
1600
|
+
// a first-class day-block member (TMPLSORT-3c-Tomorrow / PTMPL-B5), reached on the
|
|
1601
|
+
// `tomorrow` native wire or the `day` per-class leg family (reorder.ts). rowDayKey
|
|
1602
|
+
// returns its projection day, so an on-day template shares the group.
|
|
1603
|
+
if (rowDayKey(r, packedToday) !== day)
|
|
987
1604
|
return null;
|
|
988
1605
|
}
|
|
989
1606
|
return day;
|
|
990
1607
|
}
|
|
1608
|
+
/** Whether any row in the group is a deadline-forecast member (DLBNC/§9o). */
|
|
1609
|
+
function hasForecastMember(rows, packedToday) {
|
|
1610
|
+
return rows.some((r) => forecastDeadlineDay(r, packedToday) !== null);
|
|
1611
|
+
}
|
|
1612
|
+
/**
|
|
1613
|
+
* The single shared FORECAST deadline day when EVERY row is a deadline-forecast
|
|
1614
|
+
* member (§9o) of that one day, else null. Stricter than {@link sharedFutureDay}:
|
|
1615
|
+
* a scheduled row (or a forecast row on a different day) breaks it — so a
|
|
1616
|
+
* scheduled-only or scheduled+forecast-mixed set returns null (single-axis, day-
|
|
1617
|
+
* only). Gates the forecast dual-axis refusal: only an all-forecast, one-day set
|
|
1618
|
+
* has a coherent container-index alternative to the day-block.
|
|
1619
|
+
*/
|
|
1620
|
+
function sharedForecastDay(rows, packedToday) {
|
|
1621
|
+
const first = rows[0];
|
|
1622
|
+
if (first === undefined)
|
|
1623
|
+
return null;
|
|
1624
|
+
const day = forecastDeadlineDay(first, packedToday);
|
|
1625
|
+
if (day === null)
|
|
1626
|
+
return null;
|
|
1627
|
+
for (const r of rows)
|
|
1628
|
+
if (forecastDeadlineDay(r, packedToday) !== day)
|
|
1629
|
+
return null;
|
|
1630
|
+
return day;
|
|
1631
|
+
}
|
|
991
1632
|
/**
|
|
992
1633
|
* Reposition a shared FUTURE day-group on the global todayIndex axis — the SIT4
|
|
993
1634
|
* dated `day` bounce (loose/direct-area/headed/cross-container to-dos + area-less
|
|
@@ -1040,13 +1681,7 @@ async function runDayGroupReposition(deps, op, rows, position, options, scope, d
|
|
|
1040
1681
|
};
|
|
1041
1682
|
}
|
|
1042
1683
|
if (placement.kind !== "ok") {
|
|
1043
|
-
return
|
|
1044
|
-
kind: "move-leg-failed",
|
|
1045
|
-
op,
|
|
1046
|
-
detail: `the reorder leg did not complete (${placement.kind})`,
|
|
1047
|
-
failed: placement,
|
|
1048
|
-
completed: [],
|
|
1049
|
-
};
|
|
1684
|
+
return repositionFailed(op, placement);
|
|
1050
1685
|
}
|
|
1051
1686
|
return {
|
|
1052
1687
|
kind: "move-ok",
|
|
@@ -1073,6 +1708,18 @@ async function repositionInPlace(deps, op, rows, position, packedToday, options,
|
|
|
1073
1708
|
const axis = resolveReorderAxis(deps, op, rows, inTarget, packedToday, verb, position);
|
|
1074
1709
|
if ("refused" in axis)
|
|
1075
1710
|
return axis.refused;
|
|
1711
|
+
// An explicit day-axis token (`--in <YYYY-MM-DD>` / `--in upcoming`) routes straight
|
|
1712
|
+
// to the cross-container day-group reposition — membership + future-ness were
|
|
1713
|
+
// validated in resolveReorderAxis. Mirror the auto-route's scope pick: the native
|
|
1714
|
+
// one-call `tomorrow` sort only for a non-forecast group whose day is tomorrow (that
|
|
1715
|
+
// surface re-dates a forecast row), else the dated `day` bounce.
|
|
1716
|
+
if ("dayAxis" in axis) {
|
|
1717
|
+
const day = axis.dayAxis;
|
|
1718
|
+
const scope = !hasForecastMember(rows, packedToday) && day === packedTomorrowOf(packedToday)
|
|
1719
|
+
? "tomorrow"
|
|
1720
|
+
: "day";
|
|
1721
|
+
return runDayGroupReposition(deps, op, rows, position, options, scope, day);
|
|
1722
|
+
}
|
|
1076
1723
|
const targetOf = axis.targetOf;
|
|
1077
1724
|
// On a forced/auto INDEX axis (a dual-axis set reordered within its container),
|
|
1078
1725
|
// the display-bucket coherence guards below are the WRONG check: the members
|
|
@@ -1084,18 +1731,27 @@ async function repositionInPlace(deps, op, rows, position, packedToday, options,
|
|
|
1084
1731
|
// A shared FUTURE day-group (single- OR cross-container) rides the global
|
|
1085
1732
|
// todayIndex axis, not the normal single-container reorder: the SIT4 dated `day`
|
|
1086
1733
|
// bounce (loose/direct-area/headed/cross-container to-dos + area-less project
|
|
1087
|
-
// rows, any mix), or the one-call `list "Tomorrow"`
|
|
1088
|
-
//
|
|
1089
|
-
//
|
|
1090
|
-
//
|
|
1091
|
-
//
|
|
1734
|
+
// rows + DEADLINE-FORECAST to-dos, any mix), or the one-call `list "Tomorrow"`
|
|
1735
|
+
// sort when the day is tomorrow. Two exceptions force the `day` scope (never the
|
|
1736
|
+
// native short-cuts): a group CONTAINING a forecast row NEVER rides `list
|
|
1737
|
+
// "Tomorrow"` (that surface re-dates a forecast row, UPCDL-5) NOR the single-
|
|
1738
|
+
// project container-day native re-rank (a forecast row has no startDate, so it is
|
|
1739
|
+
// not a container-day scheduled child) — the deadline-cycle `day` bounce is the
|
|
1740
|
+
// one surface that serves the mixed group. Otherwise a single UNHEADED PROJECT
|
|
1741
|
+
// container's same-day scheduled children ride the cheaper atomic native
|
|
1742
|
+
// container-day re-rank (fall through to reorderTargetOf → container-day).
|
|
1743
|
+
// Templates are excluded by sharedFutureDay. The auto-route NEVER fires on a
|
|
1744
|
+
// forced/auto INDEX axis (indexAxis === true): an explicit `--in someday` /
|
|
1745
|
+
// `--in anytime` / `--in <container>` on a same-day forecast set is the certified
|
|
1746
|
+
// container `index` re-rank, and must not be overridden by the day bounce.
|
|
1092
1747
|
const structKeys = new Set(rows.map((r) => structuralKey(r, packedToday)));
|
|
1093
1748
|
const sharedDay = sharedFutureDay(rows, packedToday);
|
|
1094
|
-
if (sharedDay !== null) {
|
|
1749
|
+
if (sharedDay !== null && !indexAxis) {
|
|
1750
|
+
const forecastInGroup = hasForecastMember(rows, packedToday);
|
|
1095
1751
|
const soleStruct = structKeys.size === 1 ? structKeys.values().next().value : null;
|
|
1096
|
-
const singleProjectContainer = soleStruct?.startsWith("project:") ?? false;
|
|
1752
|
+
const singleProjectContainer = !forecastInGroup && (soleStruct?.startsWith("project:") ?? false);
|
|
1097
1753
|
if (!singleProjectContainer) {
|
|
1098
|
-
const scope = sharedDay === packedTomorrowOf(packedToday) ? "tomorrow" : "day";
|
|
1754
|
+
const scope = !forecastInGroup && sharedDay === packedTomorrowOf(packedToday) ? "tomorrow" : "day";
|
|
1099
1755
|
return runDayGroupReposition(deps, op, rows, position, options, scope, sharedDay);
|
|
1100
1756
|
}
|
|
1101
1757
|
}
|
|
@@ -1204,13 +1860,7 @@ async function repositionInPlace(deps, op, rows, position, packedToday, options,
|
|
|
1204
1860
|
};
|
|
1205
1861
|
}
|
|
1206
1862
|
if (placement.kind !== "ok") {
|
|
1207
|
-
return
|
|
1208
|
-
kind: "move-leg-failed",
|
|
1209
|
-
op,
|
|
1210
|
-
detail: `the reorder leg did not complete (${placement.kind})`,
|
|
1211
|
-
failed: placement,
|
|
1212
|
-
completed: [],
|
|
1213
|
-
};
|
|
1863
|
+
return repositionFailed(op, placement);
|
|
1214
1864
|
}
|
|
1215
1865
|
return {
|
|
1216
1866
|
kind: "move-ok",
|
|
@@ -1484,7 +2134,9 @@ function bucketMembers(deps, target, dayAnchor) {
|
|
|
1484
2134
|
...(target.container !== undefined && { container: { uuid: target.container } }),
|
|
1485
2135
|
};
|
|
1486
2136
|
const containerUuid = target.container ?? null;
|
|
1487
|
-
const pre = computeReorderPre(deps.db, params, containerUuid, deps.now?.() ?? new Date()
|
|
2137
|
+
const pre = computeReorderPre(deps.db, params, containerUuid, deps.now?.() ?? new Date(), {
|
|
2138
|
+
zone: deps.zone,
|
|
2139
|
+
});
|
|
1488
2140
|
return pre.members.map((m) => m.uuid);
|
|
1489
2141
|
}
|
|
1490
2142
|
function legOptions(options) {
|