things-api 0.9.0 → 0.10.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 +30 -11
- package/dist/audit/log.js +56 -2
- package/dist/audit/log.js.map +1 -1
- package/dist/audit/schema.d.ts +26 -8
- package/dist/audit/schema.js +42 -1
- package/dist/audit/schema.js.map +1 -1
- package/dist/cli/commands/area.d.ts +26 -3
- package/dist/cli/commands/area.js +125 -70
- package/dist/cli/commands/area.js.map +1 -1
- package/dist/cli/commands/doctor.d.ts +1 -1
- package/dist/cli/commands/doctor.js +44 -5
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/mcp.js +23 -4
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/commands/project.d.ts +18 -1
- package/dist/cli/commands/project.js +69 -31
- package/dist/cli/commands/project.js.map +1 -1
- package/dist/cli/commands/reads.js +423 -157
- package/dist/cli/commands/reads.js.map +1 -1
- package/dist/cli/commands/repeat-flags.d.ts +20 -0
- package/dist/cli/commands/repeat-flags.js +76 -0
- package/dist/cli/commands/repeat-flags.js.map +1 -0
- package/dist/cli/commands/setup.js +2 -3
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/commands/show.js +47 -24
- package/dist/cli/commands/show.js.map +1 -1
- package/dist/cli/commands/todo.d.ts +1 -1
- package/dist/cli/commands/todo.js +7 -6
- package/dist/cli/commands/todo.js.map +1 -1
- package/dist/cli/commands/writes.js +378 -148
- package/dist/cli/commands/writes.js.map +1 -1
- package/dist/cli/did-you-mean.d.ts +1 -1
- package/dist/cli/excess-args.d.ts +15 -0
- package/dist/cli/excess-args.js +51 -0
- package/dist/cli/excess-args.js.map +1 -0
- package/dist/cli/glyphs.d.ts +39 -2
- package/dist/cli/glyphs.js +91 -21
- package/dist/cli/glyphs.js.map +1 -1
- package/dist/cli/help.d.ts +54 -0
- package/dist/cli/help.js +355 -0
- package/dist/cli/help.js.map +1 -0
- package/dist/cli/main.js +32 -25
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/period.d.ts +7 -0
- package/dist/cli/period.js +12 -0
- package/dist/cli/period.js.map +1 -1
- package/dist/cli/read-driver.d.ts +26 -19
- package/dist/cli/read-driver.js +67 -33
- package/dist/cli/read-driver.js.map +1 -1
- package/dist/cli/render.d.ts +92 -28
- package/dist/cli/render.js +318 -97
- package/dist/cli/render.js.map +1 -1
- package/dist/cli/resolve-invocation.d.ts +42 -4
- package/dist/cli/resolve-invocation.js +97 -14
- package/dist/cli/resolve-invocation.js.map +1 -1
- package/dist/cli/tag-filters.d.ts +59 -0
- package/dist/cli/tag-filters.js +57 -0
- package/dist/cli/tag-filters.js.map +1 -0
- package/dist/cli/verb-hint.d.ts +25 -0
- package/dist/cli/verb-hint.js +138 -0
- package/dist/cli/verb-hint.js.map +1 -0
- package/dist/cli/width.d.ts +135 -0
- package/dist/cli/width.js +313 -0
- package/dist/cli/width.js.map +1 -0
- package/dist/client.d.ts +137 -24
- package/dist/client.js +105 -16
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +11 -0
- package/dist/config.js +3 -0
- package/dist/config.js.map +1 -1
- package/dist/contracts.d.ts +87 -17
- package/dist/contracts.js +44 -1
- package/dist/contracts.js.map +1 -1
- package/dist/db/fingerprint.d.ts +12 -0
- package/dist/db/fingerprint.js +15 -1
- package/dist/db/fingerprint.js.map +1 -1
- package/dist/db/locate.js +1 -1
- package/dist/db/locate.js.map +1 -1
- package/dist/diagnose.d.ts +63 -0
- package/dist/diagnose.js +38 -1
- package/dist/diagnose.js.map +1 -1
- package/dist/index.d.ts +30 -4
- package/dist/index.js +35 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.d.ts +9 -1
- package/dist/mcp/server.js +691 -133
- package/dist/mcp/server.js.map +1 -1
- package/dist/model/entities.d.ts +31 -7
- package/dist/model/entities.js.map +1 -1
- package/dist/model/mappers.d.ts +7 -0
- package/dist/model/mappers.js +7 -2
- package/dist/model/mappers.js.map +1 -1
- package/dist/model/serialize.d.ts +28 -0
- package/dist/model/serialize.js +80 -0
- package/dist/model/serialize.js.map +1 -0
- package/dist/model/when-sugar.d.ts +47 -0
- package/dist/model/when-sugar.js +45 -0
- package/dist/model/when-sugar.js.map +1 -0
- package/dist/read/area-view.d.ts +8 -1
- package/dist/read/area-view.js +49 -13
- package/dist/read/area-view.js.map +1 -1
- package/dist/read/filter-contract.d.ts +117 -0
- package/dist/read/filter-contract.js +78 -0
- package/dist/read/filter-contract.js.map +1 -0
- package/dist/read/predicates.d.ts +30 -0
- package/dist/read/predicates.js +30 -0
- package/dist/read/predicates.js.map +1 -1
- package/dist/read/project-view.d.ts +12 -1
- package/dist/read/project-view.js +39 -8
- package/dist/read/project-view.js.map +1 -1
- package/dist/read/queries.d.ts +149 -6
- package/dist/read/queries.js +250 -31
- package/dist/read/queries.js.map +1 -1
- package/dist/read/sections.d.ts +51 -0
- package/dist/read/sections.js +37 -0
- package/dist/read/sections.js.map +1 -0
- package/dist/read/sidebar-order.js +2 -1
- package/dist/read/sidebar-order.js.map +1 -1
- package/dist/read/tags.d.ts +27 -3
- package/dist/read/tags.js +83 -12
- package/dist/read/tags.js.map +1 -1
- package/dist/read/truncation.d.ts +71 -0
- package/dist/read/{pagination.js → truncation.js} +103 -94
- package/dist/read/truncation.js.map +1 -0
- package/dist/read/views.d.ts +140 -16
- package/dist/read/views.js +264 -67
- package/dist/read/views.js.map +1 -1
- package/dist/surface-copy.d.ts +19 -0
- package/dist/surface-copy.js +32 -0
- package/dist/surface-copy.js.map +1 -1
- package/dist/sync-health.d.ts +78 -0
- package/dist/sync-health.js +312 -0
- package/dist/sync-health.js.map +1 -0
- package/dist/write/accessibility-probe.d.ts +12 -0
- package/dist/write/accessibility-probe.js +63 -0
- package/dist/write/accessibility-probe.js.map +1 -0
- package/dist/write/automation-probe.d.ts +7 -0
- package/dist/write/automation-probe.js +8 -1
- package/dist/write/automation-probe.js.map +1 -1
- package/dist/write/batch.js +4 -2
- package/dist/write/batch.js.map +1 -1
- package/dist/write/capabilities.d.ts +8 -0
- package/dist/write/capabilities.js +13 -6
- package/dist/write/capabilities.js.map +1 -1
- package/dist/write/commands.d.ts +2 -0
- package/dist/write/commands.js +562 -48
- package/dist/write/commands.js.map +1 -1
- package/dist/write/edit-checklist.js +3 -2
- package/dist/write/edit-checklist.js.map +1 -1
- package/dist/write/guards.d.ts +2 -7
- package/dist/write/guards.js +64 -6
- package/dist/write/guards.js.map +1 -1
- package/dist/write/heading.js +2 -0
- package/dist/write/heading.js.map +1 -1
- package/dist/write/lock.d.ts +40 -2
- package/dist/write/lock.js +91 -14
- package/dist/write/lock.js.map +1 -1
- package/dist/write/make-repeating-project.d.ts +4 -0
- package/dist/write/make-repeating-project.js +253 -0
- package/dist/write/make-repeating-project.js.map +1 -0
- package/dist/write/operations.d.ts +145 -1
- package/dist/write/operations.js +48 -0
- package/dist/write/operations.js.map +1 -1
- package/dist/write/pipeline.d.ts +19 -0
- package/dist/write/pipeline.js +109 -26
- package/dist/write/pipeline.js.map +1 -1
- package/dist/write/pre-state.d.ts +68 -3
- package/dist/write/pre-state.js +102 -3
- package/dist/write/pre-state.js.map +1 -1
- package/dist/write/reopen.js +3 -2
- package/dist/write/reopen.js.map +1 -1
- package/dist/write/reorder.js +27 -17
- package/dist/write/reorder.js.map +1 -1
- package/dist/write/repeat-rule.d.ts +22 -0
- package/dist/write/repeat-rule.js +258 -0
- package/dist/write/repeat-rule.js.map +1 -0
- package/dist/write/reversibility.js +49 -0
- package/dist/write/reversibility.js.map +1 -1
- package/dist/write/tag-refs.d.ts +47 -0
- package/dist/write/tag-refs.js +126 -0
- package/dist/write/tag-refs.js.map +1 -0
- package/dist/write/undo.d.ts +74 -5
- package/dist/write/undo.js +493 -80
- package/dist/write/undo.js.map +1 -1
- package/dist/write/vectors/registry.d.ts +17 -1
- package/dist/write/vectors/registry.js +18 -2
- package/dist/write/vectors/registry.js.map +1 -1
- package/dist/write/vectors/types.d.ts +119 -3
- package/dist/write/vectors/ui-certification.d.ts +48 -0
- package/dist/write/vectors/ui-certification.js +46 -0
- package/dist/write/vectors/ui-certification.js.map +1 -0
- package/dist/write/vectors/ui-drag.d.ts +188 -0
- package/dist/write/vectors/ui-drag.js +1095 -0
- package/dist/write/vectors/ui-drag.js.map +1 -0
- package/dist/write/vectors/ui-recipes.d.ts +70 -0
- package/dist/write/vectors/ui-recipes.js +558 -0
- package/dist/write/vectors/ui-recipes.js.map +1 -0
- package/dist/write/vectors/ui.d.ts +137 -0
- package/dist/write/vectors/ui.js +656 -0
- package/dist/write/vectors/ui.js.map +1 -0
- package/dist/write/verify/delta.d.ts +31 -3
- package/dist/write/verify/delta.js +22 -6
- package/dist/write/verify/delta.js.map +1 -1
- package/dist/write/verify/poller.js +1 -0
- package/dist/write/verify/poller.js.map +1 -1
- package/package.json +1 -1
- package/dist/cli/exit-codes.d.ts +0 -26
- package/dist/cli/exit-codes.js +0 -26
- package/dist/cli/exit-codes.js.map +0 -1
- package/dist/cli/output.d.ts +0 -42
- package/dist/cli/output.js +0 -16
- package/dist/cli/output.js.map +0 -1
- package/dist/read/pagination.d.ts +0 -104
- package/dist/read/pagination.js.map +0 -1
package/dist/write/undo.js
CHANGED
|
@@ -22,16 +22,30 @@
|
|
|
22
22
|
* - Inverse mutations are audited under an `undo:`-prefixed actor and are
|
|
23
23
|
* themselves EXCLUDED from later undo target selection (no undo-the-undo).
|
|
24
24
|
* - PRECONDITION guard: before executing each inverse step, runUndo confirms
|
|
25
|
-
* the
|
|
26
|
-
* (`observed`).
|
|
27
|
-
*
|
|
28
|
-
* the
|
|
25
|
+
* the state the step would OVERWRITE still holds its recorded after-state
|
|
26
|
+
* (`observed`). Two axes are checked, both against the SAME recorded
|
|
27
|
+
* `observed` after-values: CONTENT fields (title/notes/deadline/reminder/
|
|
28
|
+
* tags, keyed 1:1 to the inverse step's params) and the STRUCTURAL axes each
|
|
29
|
+
* step names in its `guardFields` — status, container (project/area/
|
|
30
|
+
* heading), schedule (start/startDate/todaySection), and trashed state. A
|
|
31
|
+
* field an out-of-band edit already moved is NOT clobbered — the step is
|
|
32
|
+
* refused (blocked, naming what moved) and unwinding stops.
|
|
33
|
+
* `--acknowledge-out-of-band-changes` (MCP `acknowledge_out_of_band_changes`)
|
|
34
|
+
* bypasses this whole class uniformly (content + structural) — a single
|
|
35
|
+
* "the world moved, overwrite anyway". Axes with no captured after-state are
|
|
36
|
+
* left unguarded (see the NOT-GUARDED note on `checkStepPrecondition`); the
|
|
37
|
+
* checklist item-level refusal is a SEPARATE plan-time mechanism (it cannot
|
|
38
|
+
* compute a faithful forced inverse), so the flag does not bypass it. This is
|
|
39
|
+
* in addition to the pipeline's own verified read-after-write.
|
|
29
40
|
*/
|
|
30
41
|
import { readFileSync, readdirSync } from "node:fs";
|
|
31
42
|
import { join } from "node:path";
|
|
43
|
+
import { undoToken } from "../audit/schema.js";
|
|
32
44
|
import { localToday } from "../model/dates.js";
|
|
33
45
|
import { getField } from "./verify/delta.js";
|
|
34
46
|
import { isRepeatingTemplate, loadTarget } from "./pre-state.js";
|
|
47
|
+
import { isUiDriveOp } from "./operations.js";
|
|
48
|
+
import { ruleToInverseParams } from "./repeat-rule.js";
|
|
35
49
|
import { runMutation } from "./pipeline.js";
|
|
36
50
|
import { runReorder } from "./reorder.js";
|
|
37
51
|
// -------------------------------------------------------------- audit reads
|
|
@@ -47,6 +61,7 @@ export function readAuditRecords(dir) {
|
|
|
47
61
|
return [];
|
|
48
62
|
}
|
|
49
63
|
const records = [];
|
|
64
|
+
let torn = 0;
|
|
50
65
|
for (const file of files) {
|
|
51
66
|
let raw;
|
|
52
67
|
try {
|
|
@@ -64,24 +79,85 @@ export function readAuditRecords(dir) {
|
|
|
64
79
|
records.push(parsed);
|
|
65
80
|
}
|
|
66
81
|
catch {
|
|
67
|
-
// tolerate a torn/corrupt line —
|
|
82
|
+
// tolerate a torn/corrupt line — append-only files can hold a partial
|
|
83
|
+
// trailing write — but make it VISIBLE rather than silently dropping it.
|
|
84
|
+
torn += 1;
|
|
68
85
|
}
|
|
69
86
|
}
|
|
70
87
|
}
|
|
88
|
+
// One note per read (M5): silent line-dropping could hide a lost record.
|
|
89
|
+
if (torn > 0) {
|
|
90
|
+
process.stderr.write(`things: skipped ${torn} unreadable line(s) in the local change history (${dir}) — ` +
|
|
91
|
+
"a change record may be incomplete; recent history is otherwise intact\n");
|
|
92
|
+
}
|
|
71
93
|
return records.toSorted((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
|
|
72
94
|
}
|
|
73
95
|
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
96
|
+
* Which audit records are undoable at all: only SUCCESSFUL mutations, never an
|
|
97
|
+
* inverse mutation (actor `undo:…` — no undo-the-undo), and never a compound
|
|
98
|
+
* leg (its summary record is the single undoable unit for the whole sequence).
|
|
99
|
+
*
|
|
100
|
+
* `result === "ok"` also excludes INTENT records (result `"intent"`, the
|
|
101
|
+
* pre-execute marker — M3): an intent is not a completed mutation, so it is
|
|
102
|
+
* never an undo target. This is the single choke point every selector flows
|
|
103
|
+
* through (selectUndoTargets, its `--txn` and `by` branches), so intent records
|
|
104
|
+
* are skipped there uniformly. The two OTHER audit readers that scan by result
|
|
105
|
+
* — runUndo's already-undone check and planUndo's compound-leg lookup — also
|
|
106
|
+
* filter `result === "ok"`, so they skip intent records too.
|
|
76
107
|
*/
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
108
|
+
function undoableRecords(records) {
|
|
109
|
+
return records.filter((r) => r.result === "ok" && !r.actor.startsWith("undo:") && r.txn?.role !== "leg");
|
|
110
|
+
}
|
|
111
|
+
/** Pairing key for an intent and its final record (both derive from startedAt). */
|
|
112
|
+
function intentKey(r) {
|
|
113
|
+
// uuid is DELIBERATELY excluded: a create discovers its uuid only in the
|
|
114
|
+
// final record, so the intent (uuid null) and final (uuid set) must still pair.
|
|
115
|
+
return JSON.stringify([r.ts, r.op, r.actor, r.host]);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Detect crashed writes: INTENT records left without a matching final record.
|
|
119
|
+
* A mutation writes an intent immediately before touching the app and a final
|
|
120
|
+
* record after read-after-write; the two share ts+op+actor+host. An intent
|
|
121
|
+
* with no non-intent sibling on that key means the process died mid-write — the
|
|
122
|
+
* app may have applied the change, but no result was recorded, so the change is
|
|
123
|
+
* invisible to undo. Surfaced by `things doctor` so the user can reconcile.
|
|
124
|
+
*/
|
|
125
|
+
export function scanAuditIntegrity(records) {
|
|
126
|
+
const finalKeys = new Set();
|
|
127
|
+
for (const r of records) {
|
|
128
|
+
if (r.result !== "intent")
|
|
129
|
+
finalKeys.add(intentKey(r));
|
|
130
|
+
}
|
|
131
|
+
let orphanedIntents = 0;
|
|
132
|
+
let newestOrphanIntent = null;
|
|
133
|
+
for (const r of records) {
|
|
134
|
+
if (r.result !== "intent" || finalKeys.has(intentKey(r)))
|
|
135
|
+
continue;
|
|
136
|
+
orphanedIntents += 1;
|
|
137
|
+
if (newestOrphanIntent === null || r.ts > newestOrphanIntent)
|
|
138
|
+
newestOrphanIntent = r.ts;
|
|
139
|
+
}
|
|
140
|
+
return { orphanedIntents, newestOrphanIntent };
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The undoable targets to unwind, NEWEST FIRST (undo unwinds a stack).
|
|
144
|
+
*
|
|
145
|
+
* - `txn` wins when present: the ONE record whose undo token matches (or an
|
|
146
|
+
* empty array — the caller distinguishes not-found from already-undone).
|
|
147
|
+
* - otherwise the last `last` records, optionally narrowed to actor `by`
|
|
148
|
+
* (exact match; "*"/undefined = every actor). `by` NEVER matches an
|
|
149
|
+
* `undo:<actor>` record — those are excluded from undoability entirely.
|
|
150
|
+
*/
|
|
151
|
+
export function selectUndoTargets(records, selector = {}) {
|
|
152
|
+
const undoable = undoableRecords(records);
|
|
153
|
+
if (selector.txn !== undefined) {
|
|
154
|
+
const match = undoable.find((r) => undoToken(r) === selector.txn);
|
|
155
|
+
return match === undefined ? [] : [match];
|
|
156
|
+
}
|
|
157
|
+
const byActor = selector.by === undefined || selector.by === "*"
|
|
158
|
+
? undoable
|
|
159
|
+
: undoable.filter((r) => r.actor === selector.by);
|
|
160
|
+
return byActor.slice(-Math.max(1, selector.last ?? 1)).toReversed();
|
|
85
161
|
}
|
|
86
162
|
// -------------------------------------------------------------- plan builder
|
|
87
163
|
/**
|
|
@@ -96,6 +172,22 @@ export const IRREVERSIBLE = {
|
|
|
96
172
|
"trash.empty": "emptying the Trash hard-deletes every row — nothing to restore (A27)",
|
|
97
173
|
"heading.create": "a created heading can only be removed by deleting it, which has no headless surface " +
|
|
98
174
|
"(heading delete is interactive-only) — archive it in the app instead",
|
|
175
|
+
"todo.make-repeating": "making a to-do repeat is an identity replacement (UI2-a): the original uuid is destroyed " +
|
|
176
|
+
"and a new template row is born — there is no un-repeat that restores the original",
|
|
177
|
+
"todo.convert-to-project": "converting a to-do to a project is an identity replacement (UI2-d): the to-do uuid is " +
|
|
178
|
+
"destroyed and a new project is born — the app offers no convert-back",
|
|
179
|
+
"project.make-repeating": "making a project repeat is an identity replacement (UIC4-b): the original project uuid is " +
|
|
180
|
+
"destroyed and a new template project is born — there is no un-repeat that restores the original",
|
|
181
|
+
"project.create-repeating": "the composite creates a project then promotes it (identity replacement, UIC4-b): the " +
|
|
182
|
+
"created uuid is destroyed by the promote and a new repeating template is born — delete the " +
|
|
183
|
+
"resulting repeating project in the app",
|
|
184
|
+
"heading.convert-to-project": "converting a heading to a project is an identity replacement (UI2-d): the heading uuid is " +
|
|
185
|
+
"destroyed and a new project is born — no convert-back",
|
|
186
|
+
// NB: todo.reschedule-repeat / project.reschedule-repeat are NOT here — with
|
|
187
|
+
// the full rule vocabulary they are CONDITIONAL (planUndo re-drives reschedule
|
|
188
|
+
// with the CAPTURED prior rule when it is decodable and expressible; only a
|
|
189
|
+
// rule the Repeat dialog itself cannot produce falls to the irreversible
|
|
190
|
+
// branch). See the reschedule cases below and reversibility.ts.
|
|
99
191
|
// NB: todo.clear-dated-reminder is NOT here — it IS reversible. The URL
|
|
100
192
|
// scheme re-SETS a dated reminder (update?id=X&when=<date>@<time>, R17/R18),
|
|
101
193
|
// so its inverse re-attaches the captured reminder to the item's current
|
|
@@ -106,6 +198,24 @@ export const IRREVERSIBLE = {
|
|
|
106
198
|
function preField(record, field) {
|
|
107
199
|
return record.pre === null ? undefined : record.pre[field];
|
|
108
200
|
}
|
|
201
|
+
const RULE_UNITS = new Set(["daily", "weekly", "monthly", "yearly"]);
|
|
202
|
+
/**
|
|
203
|
+
* The decoded PRIOR recurrence rule captured by a reschedule pre-read
|
|
204
|
+
* (`repeating.rule`), or null when it was not captured / not a decodable rule.
|
|
205
|
+
* The value survived JSON in the audit trail, so it is shape-checked rather than
|
|
206
|
+
* re-decoded.
|
|
207
|
+
*/
|
|
208
|
+
function decodedRuleOf(record) {
|
|
209
|
+
const raw = preField(record, "repeating.rule");
|
|
210
|
+
if (raw === null || typeof raw !== "object")
|
|
211
|
+
return null;
|
|
212
|
+
const r = raw;
|
|
213
|
+
if (typeof r["unit"] !== "string" || !RULE_UNITS.has(r["unit"]))
|
|
214
|
+
return null;
|
|
215
|
+
if (typeof r["interval"] !== "number" || !Array.isArray(r["offsets"]))
|
|
216
|
+
return null;
|
|
217
|
+
return raw;
|
|
218
|
+
}
|
|
109
219
|
/**
|
|
110
220
|
* Reconstruct the scheduling step that restores a to-do's pre-op placement
|
|
111
221
|
* from the captured pre-values (start / startDate / todaySection / reminder).
|
|
@@ -334,7 +444,13 @@ function wholesaleChecklistConflict(record, current) {
|
|
|
334
444
|
* it was cleared from), so it must read the live schedule.
|
|
335
445
|
*/
|
|
336
446
|
export function planUndo(record, now, allRecords = [], current) {
|
|
337
|
-
const target = {
|
|
447
|
+
const target = {
|
|
448
|
+
ts: record.ts,
|
|
449
|
+
op: record.op,
|
|
450
|
+
uuid: record.uuid,
|
|
451
|
+
actor: record.actor,
|
|
452
|
+
token: undoToken(record),
|
|
453
|
+
};
|
|
338
454
|
const todayIso = localToday(now);
|
|
339
455
|
const notes = [];
|
|
340
456
|
const irreversible = (reason) => ({
|
|
@@ -412,7 +528,7 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
412
528
|
return {
|
|
413
529
|
target,
|
|
414
530
|
kind: "invertible",
|
|
415
|
-
steps: [{ op: "todo.reopen", params: { uuid } }],
|
|
531
|
+
steps: [{ op: "todo.reopen", params: { uuid }, options: { guardFields: ["status"] } }],
|
|
416
532
|
notes,
|
|
417
533
|
};
|
|
418
534
|
}
|
|
@@ -426,7 +542,13 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
426
542
|
return {
|
|
427
543
|
target,
|
|
428
544
|
kind: "invertible",
|
|
429
|
-
steps: [
|
|
545
|
+
steps: [
|
|
546
|
+
{
|
|
547
|
+
op: was === "completed" ? "todo.complete" : "todo.cancel",
|
|
548
|
+
params: { uuid },
|
|
549
|
+
options: { guardFields: ["status"] },
|
|
550
|
+
},
|
|
551
|
+
],
|
|
430
552
|
notes,
|
|
431
553
|
};
|
|
432
554
|
}
|
|
@@ -438,7 +560,7 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
438
560
|
return {
|
|
439
561
|
target,
|
|
440
562
|
kind: "invertible",
|
|
441
|
-
steps: [{ op: "todo.restore", params: { uuid } }],
|
|
563
|
+
steps: [{ op: "todo.restore", params: { uuid }, options: { guardFields: ["trashed"] } }],
|
|
442
564
|
notes,
|
|
443
565
|
};
|
|
444
566
|
}
|
|
@@ -448,7 +570,7 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
448
570
|
return {
|
|
449
571
|
target,
|
|
450
572
|
kind: "invertible",
|
|
451
|
-
steps: [{ op: "todo.delete", params: { uuid } }],
|
|
573
|
+
steps: [{ op: "todo.delete", params: { uuid }, options: { guardFields: ["trashed"] } }],
|
|
452
574
|
notes,
|
|
453
575
|
};
|
|
454
576
|
}
|
|
@@ -459,7 +581,7 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
459
581
|
return {
|
|
460
582
|
target,
|
|
461
583
|
kind: "invertible",
|
|
462
|
-
steps: [{ op: "project.restore", params: { uuid } }],
|
|
584
|
+
steps: [{ op: "project.restore", params: { uuid }, options: { guardFields: ["trashed"] } }],
|
|
463
585
|
notes,
|
|
464
586
|
};
|
|
465
587
|
}
|
|
@@ -470,7 +592,9 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
470
592
|
case "project.cancel": {
|
|
471
593
|
if (uuid === null)
|
|
472
594
|
return irreversible("no target uuid recorded");
|
|
473
|
-
const steps = [
|
|
595
|
+
const steps = [
|
|
596
|
+
{ op: "project.reopen", params: { uuid }, options: { guardFields: ["status"] } },
|
|
597
|
+
];
|
|
474
598
|
const pre = record.pre ?? {};
|
|
475
599
|
if (!("status" in pre)) {
|
|
476
600
|
for (const [childUuid, fields] of Object.entries(pre)) {
|
|
@@ -502,8 +626,16 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
502
626
|
kind: "invertible",
|
|
503
627
|
steps: [
|
|
504
628
|
was === "completed"
|
|
505
|
-
? {
|
|
506
|
-
|
|
629
|
+
? {
|
|
630
|
+
op: "project.complete",
|
|
631
|
+
params: { uuid, children: "require-resolved" },
|
|
632
|
+
options: { guardFields: ["status"] },
|
|
633
|
+
}
|
|
634
|
+
: {
|
|
635
|
+
op: "project.cancel",
|
|
636
|
+
params: { uuid, children: "require-resolved" },
|
|
637
|
+
options: { guardFields: ["status"] },
|
|
638
|
+
},
|
|
507
639
|
],
|
|
508
640
|
notes,
|
|
509
641
|
};
|
|
@@ -514,7 +646,7 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
514
646
|
return {
|
|
515
647
|
target,
|
|
516
648
|
kind: "invertible",
|
|
517
|
-
steps: [{ op: "project.delete", params: { uuid } }],
|
|
649
|
+
steps: [{ op: "project.delete", params: { uuid }, options: { guardFields: ["trashed"] } }],
|
|
518
650
|
notes,
|
|
519
651
|
};
|
|
520
652
|
}
|
|
@@ -533,19 +665,30 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
533
665
|
if (deadline !== undefined)
|
|
534
666
|
params["deadline"] = deadline;
|
|
535
667
|
const requestedWhen = (record.requested["when"] ?? record.requested["reminder"]) !== undefined;
|
|
668
|
+
// Schedule axes the restore overwrites — guarded against the recorded
|
|
669
|
+
// after-state so an out-of-band re-schedule blocks rather than clobbers.
|
|
670
|
+
const scheduleGuard = ["start", "startDate", "todaySection"];
|
|
671
|
+
let scheduleMerged = false;
|
|
536
672
|
if (requestedWhen) {
|
|
537
673
|
const schedule = scheduleSteps(uuid, record, todayIso);
|
|
538
674
|
notes.push(...schedule.notes);
|
|
539
675
|
const scheduleStep = schedule.steps[0];
|
|
540
676
|
if (scheduleStep !== undefined && scheduleStep.op === "todo.update") {
|
|
541
677
|
Object.assign(params, scheduleStep.params);
|
|
678
|
+
scheduleMerged = true;
|
|
542
679
|
}
|
|
543
680
|
else if (scheduleStep !== undefined) {
|
|
544
|
-
|
|
681
|
+
// inbox restore is a separate move op — carry the schedule guard on it
|
|
682
|
+
steps.push({ ...scheduleStep, options: { guardFields: scheduleGuard } });
|
|
545
683
|
}
|
|
546
684
|
}
|
|
547
|
-
if (Object.keys(params).length > 1)
|
|
548
|
-
steps.unshift({
|
|
685
|
+
if (Object.keys(params).length > 1) {
|
|
686
|
+
steps.unshift({
|
|
687
|
+
op: "todo.update",
|
|
688
|
+
params,
|
|
689
|
+
...(scheduleMerged && { options: { guardFields: scheduleGuard } }),
|
|
690
|
+
});
|
|
691
|
+
}
|
|
549
692
|
if (steps.length === 0) {
|
|
550
693
|
return irreversible("no pre-values were captured for the changed fields");
|
|
551
694
|
}
|
|
@@ -567,12 +710,15 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
567
710
|
// when/reminder restore reuses the schedule reconstructor (emitting a
|
|
568
711
|
// project.update); projects never live in the Inbox so that branch is
|
|
569
712
|
// unreachable here.
|
|
713
|
+
let projectScheduleMerged = false;
|
|
570
714
|
if ((record.requested["when"] ?? record.requested["reminder"]) !== undefined) {
|
|
571
715
|
const schedule = scheduleSteps(uuid, record, todayIso, "project.update");
|
|
572
716
|
notes.push(...schedule.notes);
|
|
573
717
|
const scheduleStep = schedule.steps[0];
|
|
574
|
-
if (scheduleStep !== undefined)
|
|
718
|
+
if (scheduleStep !== undefined) {
|
|
575
719
|
Object.assign(params, scheduleStep.params);
|
|
720
|
+
projectScheduleMerged = true;
|
|
721
|
+
}
|
|
576
722
|
}
|
|
577
723
|
if (Object.keys(params).length === 1) {
|
|
578
724
|
return irreversible("no pre-values were captured for the changed fields");
|
|
@@ -580,7 +726,15 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
580
726
|
return {
|
|
581
727
|
target,
|
|
582
728
|
kind: "invertible",
|
|
583
|
-
steps: [
|
|
729
|
+
steps: [
|
|
730
|
+
{
|
|
731
|
+
op: "project.update",
|
|
732
|
+
params,
|
|
733
|
+
...(projectScheduleMerged && {
|
|
734
|
+
options: { guardFields: ["start", "startDate", "todaySection"] },
|
|
735
|
+
}),
|
|
736
|
+
},
|
|
737
|
+
],
|
|
584
738
|
notes,
|
|
585
739
|
};
|
|
586
740
|
}
|
|
@@ -613,11 +767,19 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
613
767
|
if (preField(record, "heading") !== null && preField(record, "heading") !== undefined) {
|
|
614
768
|
notes.push("heading placement cannot be restored — the to-do returns to the project root");
|
|
615
769
|
}
|
|
770
|
+
// The detach removed the container; guard that it is STILL detached
|
|
771
|
+
// (current project/area both null) so a re-attach out of band blocks.
|
|
616
772
|
if (typeof oldProj === "string") {
|
|
617
773
|
return {
|
|
618
774
|
target,
|
|
619
775
|
kind: "invertible",
|
|
620
|
-
steps: [
|
|
776
|
+
steps: [
|
|
777
|
+
{
|
|
778
|
+
op: "todo.move",
|
|
779
|
+
params: { uuid, project: { uuid: oldProj } },
|
|
780
|
+
options: { guardFields: ["project", "area"] },
|
|
781
|
+
},
|
|
782
|
+
],
|
|
621
783
|
notes,
|
|
622
784
|
};
|
|
623
785
|
}
|
|
@@ -625,7 +787,13 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
625
787
|
return {
|
|
626
788
|
target,
|
|
627
789
|
kind: "invertible",
|
|
628
|
-
steps: [
|
|
790
|
+
steps: [
|
|
791
|
+
{
|
|
792
|
+
op: "todo.move",
|
|
793
|
+
params: { uuid, area: { uuid: oldArea } },
|
|
794
|
+
options: { guardFields: ["project", "area"] },
|
|
795
|
+
},
|
|
796
|
+
],
|
|
629
797
|
notes,
|
|
630
798
|
};
|
|
631
799
|
}
|
|
@@ -639,6 +807,11 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
639
807
|
if (schedule.steps.length === 0) {
|
|
640
808
|
return irreversible("pre-op scheduling state was not captured — cannot leave the Inbox");
|
|
641
809
|
}
|
|
810
|
+
// The op left the item in the Inbox (start="inbox"); guard that placement
|
|
811
|
+
// so an out-of-band re-schedule blocks the leave-Inbox restore.
|
|
812
|
+
for (const s of schedule.steps) {
|
|
813
|
+
s.options = { ...s.options, guardFields: ["start", "startDate"] };
|
|
814
|
+
}
|
|
642
815
|
return { target, kind: "invertible", steps: schedule.steps, notes };
|
|
643
816
|
}
|
|
644
817
|
// The audit captured the OLD value of whatever destination-kind fields
|
|
@@ -658,7 +831,13 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
658
831
|
return {
|
|
659
832
|
target,
|
|
660
833
|
kind: "invertible",
|
|
661
|
-
steps: [
|
|
834
|
+
steps: [
|
|
835
|
+
{
|
|
836
|
+
op: "todo.move",
|
|
837
|
+
params: { uuid, project: { uuid: oldProject } },
|
|
838
|
+
options: { guardFields: ["project.uuid", "heading.uuid"] },
|
|
839
|
+
},
|
|
840
|
+
],
|
|
662
841
|
notes,
|
|
663
842
|
};
|
|
664
843
|
}
|
|
@@ -666,7 +845,13 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
666
845
|
return {
|
|
667
846
|
target,
|
|
668
847
|
kind: "invertible",
|
|
669
|
-
steps: [
|
|
848
|
+
steps: [
|
|
849
|
+
{
|
|
850
|
+
op: "todo.move",
|
|
851
|
+
params: { uuid, area: { uuid: oldArea } },
|
|
852
|
+
options: { guardFields: ["area.uuid"] },
|
|
853
|
+
},
|
|
854
|
+
],
|
|
670
855
|
notes,
|
|
671
856
|
};
|
|
672
857
|
}
|
|
@@ -684,10 +869,17 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
684
869
|
? areaRef.uuid
|
|
685
870
|
: undefined;
|
|
686
871
|
if (typeof oldArea === "string") {
|
|
872
|
+
// The op detached (area now null); guard it is still area-less.
|
|
687
873
|
return {
|
|
688
874
|
target,
|
|
689
875
|
kind: "invertible",
|
|
690
|
-
steps: [
|
|
876
|
+
steps: [
|
|
877
|
+
{
|
|
878
|
+
op: "project.move",
|
|
879
|
+
params: { uuid, area: { uuid: oldArea } },
|
|
880
|
+
options: { guardFields: ["area"] },
|
|
881
|
+
},
|
|
882
|
+
],
|
|
691
883
|
notes,
|
|
692
884
|
};
|
|
693
885
|
}
|
|
@@ -698,7 +890,13 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
698
890
|
return {
|
|
699
891
|
target,
|
|
700
892
|
kind: "invertible",
|
|
701
|
-
steps: [
|
|
893
|
+
steps: [
|
|
894
|
+
{
|
|
895
|
+
op: "project.move",
|
|
896
|
+
params: { uuid, area: { uuid: areaPre } },
|
|
897
|
+
options: { guardFields: ["area.uuid"] },
|
|
898
|
+
},
|
|
899
|
+
],
|
|
702
900
|
notes,
|
|
703
901
|
};
|
|
704
902
|
}
|
|
@@ -707,7 +905,13 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
707
905
|
return {
|
|
708
906
|
target,
|
|
709
907
|
kind: "invertible",
|
|
710
|
-
steps: [
|
|
908
|
+
steps: [
|
|
909
|
+
{
|
|
910
|
+
op: "project.move",
|
|
911
|
+
params: { uuid, detach: true },
|
|
912
|
+
options: { guardFields: ["area.uuid"] },
|
|
913
|
+
},
|
|
914
|
+
],
|
|
711
915
|
notes,
|
|
712
916
|
};
|
|
713
917
|
}
|
|
@@ -871,6 +1075,35 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
871
1075
|
"their current positions");
|
|
872
1076
|
return { target, kind: "invertible", steps: [{ op: "reorder", params }], notes };
|
|
873
1077
|
}
|
|
1078
|
+
case "area.reorder": {
|
|
1079
|
+
if (uuid === null)
|
|
1080
|
+
return irreversible("no target area uuid recorded");
|
|
1081
|
+
const pre = record.pre;
|
|
1082
|
+
if (pre === null)
|
|
1083
|
+
return irreversible("the pre-move area order was not captured");
|
|
1084
|
+
const ranked = Object.entries(pre).filter(([, rank]) => typeof rank === "number");
|
|
1085
|
+
if (ranked.length === 0)
|
|
1086
|
+
return irreversible("the pre-move area order was not captured");
|
|
1087
|
+
const ranks = ranked.map(([, r]) => r);
|
|
1088
|
+
if (new Set(ranks).size !== ranks.length) {
|
|
1089
|
+
return irreversible("the area order before this move was not fully determined (areas were still " +
|
|
1090
|
+
"unranked), so the previous position cannot be reconstructed");
|
|
1091
|
+
}
|
|
1092
|
+
const ordered = ranked.toSorted((a, b) => a[1] - b[1]).map(([id]) => id);
|
|
1093
|
+
const idx = ordered.indexOf(uuid);
|
|
1094
|
+
if (idx < 0)
|
|
1095
|
+
return irreversible("the moved area is not in the captured order");
|
|
1096
|
+
const pred = ordered[idx - 1];
|
|
1097
|
+
const params = pred === undefined ? { target: uuid, position: "first" } : { target: uuid, after: pred };
|
|
1098
|
+
notes.push("the area returns to its previous position by driving the app again " +
|
|
1099
|
+
"(relative order restores; the app may assign fresh rank numbers)");
|
|
1100
|
+
return {
|
|
1101
|
+
target,
|
|
1102
|
+
kind: "invertible",
|
|
1103
|
+
steps: [{ op: "area.reorder", params }],
|
|
1104
|
+
notes,
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
874
1107
|
case "heading.rename": {
|
|
875
1108
|
if (uuid === null)
|
|
876
1109
|
return irreversible("no target uuid recorded");
|
|
@@ -887,7 +1120,9 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
887
1120
|
case "heading.archive": {
|
|
888
1121
|
if (uuid === null)
|
|
889
1122
|
return irreversible("no target uuid recorded");
|
|
890
|
-
const steps = [
|
|
1123
|
+
const steps = [
|
|
1124
|
+
{ op: "heading.unarchive", params: { uuid }, options: { guardFields: ["status"] } },
|
|
1125
|
+
];
|
|
891
1126
|
// Reopen exactly the children the cascade resolved (nested pre map —
|
|
892
1127
|
// the project.complete pattern). Reparented children live in leg
|
|
893
1128
|
// records; replay their inverses too when this summary heads a txn.
|
|
@@ -942,7 +1177,13 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
942
1177
|
return {
|
|
943
1178
|
target,
|
|
944
1179
|
kind: "invertible",
|
|
945
|
-
steps: [
|
|
1180
|
+
steps: [
|
|
1181
|
+
{
|
|
1182
|
+
op: "heading.archive",
|
|
1183
|
+
params: { uuid, children: "complete" },
|
|
1184
|
+
options: { guardFields: ["status"] },
|
|
1185
|
+
},
|
|
1186
|
+
],
|
|
946
1187
|
notes,
|
|
947
1188
|
};
|
|
948
1189
|
}
|
|
@@ -983,6 +1224,78 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
983
1224
|
notes,
|
|
984
1225
|
};
|
|
985
1226
|
}
|
|
1227
|
+
// The ui-vector reversible pair: pause ↔ resume. Both drive the GUI, so
|
|
1228
|
+
// the inverse carries the drive acknowledgement (added in runUndo).
|
|
1229
|
+
case "todo.pause-repeat": {
|
|
1230
|
+
if (uuid === null)
|
|
1231
|
+
return irreversible("no target uuid recorded");
|
|
1232
|
+
return {
|
|
1233
|
+
target,
|
|
1234
|
+
kind: "invertible",
|
|
1235
|
+
steps: [{ op: "todo.resume-repeat", params: { uuid } }],
|
|
1236
|
+
notes,
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
case "todo.resume-repeat": {
|
|
1240
|
+
if (uuid === null)
|
|
1241
|
+
return irreversible("no target uuid recorded");
|
|
1242
|
+
return {
|
|
1243
|
+
target,
|
|
1244
|
+
kind: "invertible",
|
|
1245
|
+
steps: [{ op: "todo.pause-repeat", params: { uuid } }],
|
|
1246
|
+
notes,
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
case "project.pause-repeat": {
|
|
1250
|
+
if (uuid === null)
|
|
1251
|
+
return irreversible("no target uuid recorded");
|
|
1252
|
+
return {
|
|
1253
|
+
target,
|
|
1254
|
+
kind: "invertible",
|
|
1255
|
+
steps: [{ op: "project.resume-repeat", params: { uuid } }],
|
|
1256
|
+
notes,
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
case "project.resume-repeat": {
|
|
1260
|
+
if (uuid === null)
|
|
1261
|
+
return irreversible("no target uuid recorded");
|
|
1262
|
+
return {
|
|
1263
|
+
target,
|
|
1264
|
+
kind: "invertible",
|
|
1265
|
+
steps: [{ op: "project.pause-repeat", params: { uuid } }],
|
|
1266
|
+
notes,
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
// Reschedule: identity preserved (UI2-b / UIC2-a), the rule mutated in
|
|
1270
|
+
// place. The captured-rule inverse re-drives reschedule with the PRIOR rule
|
|
1271
|
+
// (recorded pre-op as the decoded rule + deadline flag). Reversible when the
|
|
1272
|
+
// prior rule was captured, decodable, AND expressible in the Repeat dialog's
|
|
1273
|
+
// vocabulary; irreversible for a rule the dialog itself cannot produce.
|
|
1274
|
+
case "todo.reschedule-repeat":
|
|
1275
|
+
case "project.reschedule-repeat": {
|
|
1276
|
+
if (uuid === null)
|
|
1277
|
+
return irreversible("no target uuid recorded");
|
|
1278
|
+
const priorRule = decodedRuleOf(record);
|
|
1279
|
+
if (priorRule === null) {
|
|
1280
|
+
return irreversible("the prior recurrence rule was not captured (or could not be decoded) — reschedule again by hand");
|
|
1281
|
+
}
|
|
1282
|
+
const inverse = ruleToInverseParams(priorRule, preField(record, "repeating.deadlined") === true);
|
|
1283
|
+
if (inverse === null) {
|
|
1284
|
+
return irreversible("the prior rule is outside the Repeat dialog's vocabulary (a rule with two end bounds, or a " +
|
|
1285
|
+
"multi-anchor month/year rule) — reschedule again by hand");
|
|
1286
|
+
}
|
|
1287
|
+
if (inverse.reminder === undefined) {
|
|
1288
|
+
// The reminder time is not part of the recurrence rule, so a reminder
|
|
1289
|
+
// the reschedule may have set/changed is not restored by the inverse.
|
|
1290
|
+
notes.push("the recurrence structure is restored; a reminder time on the spawned instances is not part of the captured rule and is not restored");
|
|
1291
|
+
}
|
|
1292
|
+
return {
|
|
1293
|
+
target,
|
|
1294
|
+
kind: "invertible",
|
|
1295
|
+
steps: [{ op: record.op, params: { uuid, ...inverse } }],
|
|
1296
|
+
notes,
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
986
1299
|
default:
|
|
987
1300
|
return irreversible(`no inverse is defined for operation "${record.op}"`);
|
|
988
1301
|
}
|
|
@@ -990,6 +1303,44 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
990
1303
|
// ------------------------------------------------------ precondition guard
|
|
991
1304
|
/** Content fields an inverse can silently CLOBBER; keyed to observed 1:1. */
|
|
992
1305
|
const CLOBBER_FIELDS = ["title", "notes", "deadline", "reminder", "tags"];
|
|
1306
|
+
/**
|
|
1307
|
+
* Human labels for the guarded axes, used in the block detail. Both the raw
|
|
1308
|
+
* content fields and the structural getField paths a step may name in
|
|
1309
|
+
* `guardFields` are mapped here; anything unmapped falls back to the raw path.
|
|
1310
|
+
*/
|
|
1311
|
+
const AXIS_LABEL = {
|
|
1312
|
+
title: "title",
|
|
1313
|
+
notes: "notes",
|
|
1314
|
+
deadline: "deadline",
|
|
1315
|
+
reminder: "reminder",
|
|
1316
|
+
tags: "tags",
|
|
1317
|
+
status: "status",
|
|
1318
|
+
trashed: "trashed state",
|
|
1319
|
+
project: "project",
|
|
1320
|
+
"project.uuid": "project",
|
|
1321
|
+
area: "area",
|
|
1322
|
+
"area.uuid": "area",
|
|
1323
|
+
"heading.uuid": "heading",
|
|
1324
|
+
start: "schedule",
|
|
1325
|
+
startDate: "schedule",
|
|
1326
|
+
todaySection: "schedule",
|
|
1327
|
+
};
|
|
1328
|
+
/**
|
|
1329
|
+
* NOT GUARDED (no captured after-state to compare against, so left unguarded —
|
|
1330
|
+
* these axes rely on the inverse's own verified read-after-write instead):
|
|
1331
|
+
* - REPEAT rule / paused state (reschedule-repeat, pause/resume-repeat): the
|
|
1332
|
+
* repeat axis is out of this guard's scope.
|
|
1333
|
+
* - todo.backdate timestamps (creation/completion date).
|
|
1334
|
+
* - reorder / area.reorder RANKS: the inverse is a 3-way restore that already
|
|
1335
|
+
* leaves non-targeted members in place; a rank precondition is not modeled.
|
|
1336
|
+
* - area.update / tag.update fields: those steps address by `target`, not
|
|
1337
|
+
* `uuid`, so this guard returns early for them (entity ops are unguarded, as
|
|
1338
|
+
* before this change).
|
|
1339
|
+
* - CASCADE CHILDREN of a project/heading resolve (the extra todo.reopen steps
|
|
1340
|
+
* carry no guardFields): the recorded `observed` after-values are the PARENT
|
|
1341
|
+
* target's, not each child's, so a per-child compare cannot be made from
|
|
1342
|
+
* them; the parent's status axis IS guarded.
|
|
1343
|
+
*/
|
|
993
1344
|
function fieldsEqual(a, b) {
|
|
994
1345
|
if (Array.isArray(a) && Array.isArray(b))
|
|
995
1346
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
@@ -998,14 +1349,31 @@ function fieldsEqual(a, b) {
|
|
|
998
1349
|
function formatValue(value) {
|
|
999
1350
|
return value === null || value === undefined ? "none" : JSON.stringify(value);
|
|
1000
1351
|
}
|
|
1352
|
+
/** The blocked result for an axis an out-of-band change already moved. */
|
|
1353
|
+
function clobberBlock(op, field, after, cur) {
|
|
1354
|
+
const label = AXIS_LABEL[field] ?? field;
|
|
1355
|
+
return {
|
|
1356
|
+
kind: "blocked",
|
|
1357
|
+
op,
|
|
1358
|
+
reason: "environment",
|
|
1359
|
+
detail: `${label} changed since the recorded mutation (expected ${formatValue(after)}, found ` +
|
|
1360
|
+
`${formatValue(cur)}) — refusing to overwrite a change made outside things-api`,
|
|
1361
|
+
remediation: "review the item's current state and redo the change by hand if it's still wanted; or " +
|
|
1362
|
+
"re-run undo with --acknowledge-out-of-band-changes to overwrite the change made since",
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1001
1365
|
/**
|
|
1002
|
-
* Refuse an inverse step that would overwrite
|
|
1003
|
-
* already moved.
|
|
1004
|
-
*
|
|
1005
|
-
*
|
|
1006
|
-
*
|
|
1007
|
-
*
|
|
1008
|
-
*
|
|
1366
|
+
* Refuse an inverse step that would overwrite state an out-of-band change has
|
|
1367
|
+
* already moved. Two axes are checked against the SAME recorded `observed`
|
|
1368
|
+
* after-state:
|
|
1369
|
+
* - CONTENT (CLOBBER_FIELDS): every content field the step WRITES (present in
|
|
1370
|
+
* its params) whose after-value `observed` recorded.
|
|
1371
|
+
* - STRUCTURAL (`step.options.guardFields`): the container / status / schedule
|
|
1372
|
+
* / trashed getField paths the step overwrites, set by planUndo on the
|
|
1373
|
+
* primary-target step. Paths absent from `observed` are skipped.
|
|
1374
|
+
* For each, the target's CURRENT value must still equal the recorded after-value;
|
|
1375
|
+
* a divergence means the world moved underneath us, so we block rather than
|
|
1376
|
+
* clobber. Returns a blocked result on the first divergence, else null.
|
|
1009
1377
|
*/
|
|
1010
1378
|
function checkStepPrecondition(deps, step, observed) {
|
|
1011
1379
|
if (observed === null)
|
|
@@ -1020,25 +1388,62 @@ function checkStepPrecondition(deps, step, observed) {
|
|
|
1020
1388
|
if (!(field in step.params) || !(field in observed))
|
|
1021
1389
|
continue;
|
|
1022
1390
|
const cur = getField(current, field) ?? null;
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
"re-run undo once the field is back to its post-change value",
|
|
1033
|
-
};
|
|
1034
|
-
}
|
|
1391
|
+
if (!fieldsEqual(cur, observed[field]))
|
|
1392
|
+
return clobberBlock(step.op, field, observed[field], cur);
|
|
1393
|
+
}
|
|
1394
|
+
for (const field of step.options?.guardFields ?? []) {
|
|
1395
|
+
if (!(field in observed))
|
|
1396
|
+
continue; // no captured after-state for this axis
|
|
1397
|
+
const cur = getField(current, field) ?? null;
|
|
1398
|
+
if (!fieldsEqual(cur, observed[field]))
|
|
1399
|
+
return clobberBlock(step.op, field, observed[field], cur);
|
|
1035
1400
|
}
|
|
1036
1401
|
return null;
|
|
1037
1402
|
}
|
|
1403
|
+
/**
|
|
1404
|
+
* The block that would refuse a step before execution, or null. Two sources:
|
|
1405
|
+
* the plan-time checklist refusal (item-level, never bypassed — there is no
|
|
1406
|
+
* faithful forced inverse) and the runtime precondition guard (content +
|
|
1407
|
+
* structural), which `acknowledgeOutOfBandChanges` suppresses uniformly. Shared
|
|
1408
|
+
* by the executor and the dry-run preview so both report the same outcome.
|
|
1409
|
+
*/
|
|
1410
|
+
function stepWouldBlock(deps, step, observed, acknowledgeOutOfBandChanges) {
|
|
1411
|
+
const planBlock = step.options?.blocked;
|
|
1412
|
+
if (planBlock !== undefined) {
|
|
1413
|
+
return {
|
|
1414
|
+
kind: "blocked",
|
|
1415
|
+
op: step.op,
|
|
1416
|
+
reason: "environment",
|
|
1417
|
+
detail: planBlock.detail,
|
|
1418
|
+
remediation: planBlock.remediation,
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
if (acknowledgeOutOfBandChanges)
|
|
1422
|
+
return null;
|
|
1423
|
+
return checkStepPrecondition(deps, step, observed);
|
|
1424
|
+
}
|
|
1038
1425
|
// ----------------------------------------------------------------- executor
|
|
1039
1426
|
export async function runUndo(deps, auditDirPath, options = {}, onItem) {
|
|
1040
1427
|
const records = readAuditRecords(auditDirPath);
|
|
1041
|
-
|
|
1428
|
+
// Exact-token selection is loud and specific: distinguish a token that was
|
|
1429
|
+
// already undone (an inverse for it is on the trail) from one that never
|
|
1430
|
+
// named an undoable mutation. Both are usage errors (RangeError → exit 2).
|
|
1431
|
+
if (options.txn !== undefined) {
|
|
1432
|
+
const alreadyUndone = records.some((r) => r.undoOf === options.txn && r.result === "ok" && r.actor.startsWith("undo:"));
|
|
1433
|
+
if (alreadyUndone) {
|
|
1434
|
+
throw new RangeError(`mutation "${options.txn}" has already been undone (an inverse for it is in the audit ` +
|
|
1435
|
+
"trail); there is nothing left to undo");
|
|
1436
|
+
}
|
|
1437
|
+
if (selectUndoTargets(records, { txn: options.txn }).length === 0) {
|
|
1438
|
+
throw new RangeError(`no undoable mutation has undo token "${options.txn}" — check the token from the ` +
|
|
1439
|
+
"mutation result, or run `things undo --dry-run` to list recent targets");
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
const targets = selectUndoTargets(records, {
|
|
1443
|
+
...(options.last !== undefined && { last: options.last }),
|
|
1444
|
+
...(options.by !== undefined && { by: options.by }),
|
|
1445
|
+
...(options.txn !== undefined && { txn: options.txn }),
|
|
1446
|
+
});
|
|
1042
1447
|
const now = deps.now?.() ?? new Date();
|
|
1043
1448
|
const items = [];
|
|
1044
1449
|
for (const record of targets) {
|
|
@@ -1051,30 +1456,29 @@ export async function runUndo(deps, auditDirPath, options = {}, onItem) {
|
|
|
1051
1456
|
item = { plan, results: [], outcome: "irreversible" };
|
|
1052
1457
|
}
|
|
1053
1458
|
else if (options.dryRun === true) {
|
|
1054
|
-
|
|
1459
|
+
// Preview the would-block outcome so --dry-run surfaces a refusal (an
|
|
1460
|
+
// out-of-band move/status/schedule change, or the checklist item-level
|
|
1461
|
+
// conflict) before any change is attempted — respecting the ack flag.
|
|
1462
|
+
const preview = [];
|
|
1463
|
+
for (const step of plan.steps) {
|
|
1464
|
+
const block = stepWouldBlock(deps, step, record.observed, options.acknowledgeOutOfBandChanges === true);
|
|
1465
|
+
if (block !== null) {
|
|
1466
|
+
preview.push(block);
|
|
1467
|
+
break;
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
item = { plan, results: preview, outcome: "dry-run" };
|
|
1055
1471
|
}
|
|
1056
1472
|
else {
|
|
1057
1473
|
const results = [];
|
|
1058
1474
|
let failed = false;
|
|
1059
1475
|
for (const step of plan.steps) {
|
|
1060
|
-
//
|
|
1061
|
-
//
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
op: step.op,
|
|
1067
|
-
reason: "environment",
|
|
1068
|
-
detail: planBlock.detail,
|
|
1069
|
-
remediation: planBlock.remediation,
|
|
1070
|
-
});
|
|
1071
|
-
failed = true;
|
|
1072
|
-
break;
|
|
1073
|
-
}
|
|
1074
|
-
// Precondition guard: never clobber a field an out-of-band edit moved.
|
|
1075
|
-
const precondition = checkStepPrecondition(deps, step, record.observed);
|
|
1076
|
-
if (precondition !== null) {
|
|
1077
|
-
results.push(precondition);
|
|
1476
|
+
// Refuse rather than clobber: the plan-time checklist conflict, or the
|
|
1477
|
+
// runtime precondition guard (content + structural) unless the caller
|
|
1478
|
+
// acknowledged out-of-band changes.
|
|
1479
|
+
const block = stepWouldBlock(deps, step, record.observed, options.acknowledgeOutOfBandChanges === true);
|
|
1480
|
+
if (block !== null) {
|
|
1481
|
+
results.push(block);
|
|
1078
1482
|
failed = true;
|
|
1079
1483
|
break;
|
|
1080
1484
|
}
|
|
@@ -1093,6 +1497,9 @@ export async function runUndo(deps, auditDirPath, options = {}, onItem) {
|
|
|
1093
1497
|
}
|
|
1094
1498
|
const writeOptions = {
|
|
1095
1499
|
actor: `undo:${options.actor ?? deps.config.actor}`,
|
|
1500
|
+
// Back-reference the mutation being reversed so a later
|
|
1501
|
+
// `undo --txn <token>` can report it as already undone.
|
|
1502
|
+
undoOf: plan.target.token,
|
|
1096
1503
|
...(options.verifyTimeoutMs !== undefined && {
|
|
1097
1504
|
verifyTimeoutMs: options.verifyTimeoutMs,
|
|
1098
1505
|
}),
|
|
@@ -1100,10 +1507,16 @@ export async function runUndo(deps, auditDirPath, options = {}, onItem) {
|
|
|
1100
1507
|
acknowledgeChecklistReset: true,
|
|
1101
1508
|
}),
|
|
1102
1509
|
...(needsPermanent && { dangerouslyPermanent: true }),
|
|
1510
|
+
// A GUI-driven inverse (pause ↔ resume) replays a change the user
|
|
1511
|
+
// already acknowledged when they made it — carry the drive ack so it
|
|
1512
|
+
// is not re-gated by H-UI-DRIVE.
|
|
1513
|
+
...(isUiDriveOp(step.op) && { dangerouslyDriveGui: true }),
|
|
1103
1514
|
};
|
|
1104
1515
|
const result = step.op === "reorder"
|
|
1105
|
-
?
|
|
1106
|
-
|
|
1516
|
+
? // undo steps must be inverted in order: each step's precondition check depends on the DB state left by the prior step
|
|
1517
|
+
await runReorder(deps, step.params, writeOptions)
|
|
1518
|
+
: // same sequencing requirement as the reorder branch above
|
|
1519
|
+
await runMutation(deps, step.op, step.params, writeOptions);
|
|
1107
1520
|
results.push(result);
|
|
1108
1521
|
if (result.kind !== "ok") {
|
|
1109
1522
|
failed = true;
|