things-api 0.8.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 +31 -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 +34 -2
- package/dist/cli/commands/area.js +140 -28
- 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 +23 -1
- package/dist/cli/commands/project.js +82 -15
- package/dist/cli/commands/project.js.map +1 -1
- package/dist/cli/commands/reads.d.ts +6 -125
- package/dist/cli/commands/reads.js +649 -657
- 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.d.ts +1 -1
- package/dist/cli/commands/show.js +169 -18
- package/dist/cli/commands/show.js.map +1 -1
- package/dist/cli/commands/snapshot.js +1 -1
- package/dist/cli/commands/snapshot.js.map +1 -1
- package/dist/cli/commands/todo.d.ts +1 -1
- package/dist/cli/commands/todo.js +21 -9
- 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 +29 -0
- package/dist/cli/did-you-mean.js +53 -0
- package/dist/cli/did-you-mean.js.map +1 -0
- 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 +67 -7
- package/dist/cli/glyphs.js +202 -19
- 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 +36 -21
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/period.d.ts +39 -0
- package/dist/cli/period.js +141 -0
- package/dist/cli/period.js.map +1 -0
- package/dist/cli/read-driver.d.ts +83 -0
- package/dist/cli/read-driver.js +215 -0
- package/dist/cli/read-driver.js.map +1 -0
- package/dist/cli/render.d.ts +246 -0
- package/dist/cli/render.js +806 -0
- package/dist/cli/render.js.map +1 -0
- package/dist/cli/resolve-invocation.d.ts +116 -0
- package/dist/cli/resolve-invocation.js +261 -0
- package/dist/cli/resolve-invocation.js.map +1 -0
- package/dist/cli/shell-quote.d.ts +9 -0
- package/dist/cli/shell-quote.js +12 -0
- package/dist/cli/shell-quote.js.map +1 -0
- 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 +146 -24
- package/dist/client.js +106 -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 +136 -1
- 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 +850 -128
- package/dist/mcp/server.js.map +1 -1
- package/dist/model/entities.d.ts +40 -7
- package/dist/model/entities.js.map +1 -1
- package/dist/model/mappers.d.ts +7 -0
- package/dist/model/mappers.js +20 -3
- package/dist/model/mappers.js.map +1 -1
- package/dist/model/occurrences.d.ts +6 -1
- package/dist/model/occurrences.js +9 -5
- package/dist/model/occurrences.js.map +1 -1
- package/dist/model/recurrence.d.ts +17 -8
- package/dist/model/recurrence.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/caps.d.ts +14 -0
- package/dist/read/caps.js +21 -0
- package/dist/read/caps.js.map +1 -0
- 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/log-boundary.d.ts +9 -4
- package/dist/read/log-boundary.js.map +1 -1
- package/dist/read/predicates.d.ts +61 -0
- package/dist/read/predicates.js +78 -0
- package/dist/read/predicates.js.map +1 -0
- 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 +156 -8
- package/dist/read/queries.js +261 -38
- package/dist/read/queries.js.map +1 -1
- package/dist/read/search-rank.d.ts +36 -0
- package/dist/read/search-rank.js +31 -0
- package/dist/read/search-rank.js.map +1 -0
- 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/show-target.d.ts +13 -4
- package/dist/read/show-target.js +11 -2
- package/dist/read/show-target.js.map +1 -1
- package/dist/read/sidebar-order.d.ts +19 -0
- package/dist/read/sidebar-order.js +93 -0
- package/dist/read/sidebar-order.js.map +1 -0
- 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/truncation.js +217 -0
- package/dist/read/truncation.js.map +1 -0
- package/dist/read/views.d.ts +177 -19
- package/dist/read/views.js +390 -213
- package/dist/read/views.js.map +1 -1
- package/dist/surface-copy.d.ts +42 -0
- package/dist/surface-copy.js +55 -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 -5
- 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 +494 -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/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).
|
|
@@ -291,6 +401,7 @@ function planChecklistItemInverse(record, current) {
|
|
|
291
401
|
return conflict(`the moved item "${title}" is no longer in the checklist`);
|
|
292
402
|
if (i === -2)
|
|
293
403
|
return conflict(`"${title}" is now a duplicate title — the moved item is ambiguous`);
|
|
404
|
+
// oxlint-disable-next-line no-map-spread -- cloning specs before splice, not mutating in place
|
|
294
405
|
const next = items.map((c) => ({ ...c }));
|
|
295
406
|
const [moved] = next.splice(i, 1);
|
|
296
407
|
next.splice(Math.max(0, Math.min(next.length, oldPos - 1)), 0, moved);
|
|
@@ -333,7 +444,13 @@ function wholesaleChecklistConflict(record, current) {
|
|
|
333
444
|
* it was cleared from), so it must read the live schedule.
|
|
334
445
|
*/
|
|
335
446
|
export function planUndo(record, now, allRecords = [], current) {
|
|
336
|
-
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
|
+
};
|
|
337
454
|
const todayIso = localToday(now);
|
|
338
455
|
const notes = [];
|
|
339
456
|
const irreversible = (reason) => ({
|
|
@@ -411,7 +528,7 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
411
528
|
return {
|
|
412
529
|
target,
|
|
413
530
|
kind: "invertible",
|
|
414
|
-
steps: [{ op: "todo.reopen", params: { uuid } }],
|
|
531
|
+
steps: [{ op: "todo.reopen", params: { uuid }, options: { guardFields: ["status"] } }],
|
|
415
532
|
notes,
|
|
416
533
|
};
|
|
417
534
|
}
|
|
@@ -425,7 +542,13 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
425
542
|
return {
|
|
426
543
|
target,
|
|
427
544
|
kind: "invertible",
|
|
428
|
-
steps: [
|
|
545
|
+
steps: [
|
|
546
|
+
{
|
|
547
|
+
op: was === "completed" ? "todo.complete" : "todo.cancel",
|
|
548
|
+
params: { uuid },
|
|
549
|
+
options: { guardFields: ["status"] },
|
|
550
|
+
},
|
|
551
|
+
],
|
|
429
552
|
notes,
|
|
430
553
|
};
|
|
431
554
|
}
|
|
@@ -437,7 +560,7 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
437
560
|
return {
|
|
438
561
|
target,
|
|
439
562
|
kind: "invertible",
|
|
440
|
-
steps: [{ op: "todo.restore", params: { uuid } }],
|
|
563
|
+
steps: [{ op: "todo.restore", params: { uuid }, options: { guardFields: ["trashed"] } }],
|
|
441
564
|
notes,
|
|
442
565
|
};
|
|
443
566
|
}
|
|
@@ -447,7 +570,7 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
447
570
|
return {
|
|
448
571
|
target,
|
|
449
572
|
kind: "invertible",
|
|
450
|
-
steps: [{ op: "todo.delete", params: { uuid } }],
|
|
573
|
+
steps: [{ op: "todo.delete", params: { uuid }, options: { guardFields: ["trashed"] } }],
|
|
451
574
|
notes,
|
|
452
575
|
};
|
|
453
576
|
}
|
|
@@ -458,7 +581,7 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
458
581
|
return {
|
|
459
582
|
target,
|
|
460
583
|
kind: "invertible",
|
|
461
|
-
steps: [{ op: "project.restore", params: { uuid } }],
|
|
584
|
+
steps: [{ op: "project.restore", params: { uuid }, options: { guardFields: ["trashed"] } }],
|
|
462
585
|
notes,
|
|
463
586
|
};
|
|
464
587
|
}
|
|
@@ -469,7 +592,9 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
469
592
|
case "project.cancel": {
|
|
470
593
|
if (uuid === null)
|
|
471
594
|
return irreversible("no target uuid recorded");
|
|
472
|
-
const steps = [
|
|
595
|
+
const steps = [
|
|
596
|
+
{ op: "project.reopen", params: { uuid }, options: { guardFields: ["status"] } },
|
|
597
|
+
];
|
|
473
598
|
const pre = record.pre ?? {};
|
|
474
599
|
if (!("status" in pre)) {
|
|
475
600
|
for (const [childUuid, fields] of Object.entries(pre)) {
|
|
@@ -501,8 +626,16 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
501
626
|
kind: "invertible",
|
|
502
627
|
steps: [
|
|
503
628
|
was === "completed"
|
|
504
|
-
? {
|
|
505
|
-
|
|
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
|
+
},
|
|
506
639
|
],
|
|
507
640
|
notes,
|
|
508
641
|
};
|
|
@@ -513,7 +646,7 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
513
646
|
return {
|
|
514
647
|
target,
|
|
515
648
|
kind: "invertible",
|
|
516
|
-
steps: [{ op: "project.delete", params: { uuid } }],
|
|
649
|
+
steps: [{ op: "project.delete", params: { uuid }, options: { guardFields: ["trashed"] } }],
|
|
517
650
|
notes,
|
|
518
651
|
};
|
|
519
652
|
}
|
|
@@ -532,19 +665,30 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
532
665
|
if (deadline !== undefined)
|
|
533
666
|
params["deadline"] = deadline;
|
|
534
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;
|
|
535
672
|
if (requestedWhen) {
|
|
536
673
|
const schedule = scheduleSteps(uuid, record, todayIso);
|
|
537
674
|
notes.push(...schedule.notes);
|
|
538
675
|
const scheduleStep = schedule.steps[0];
|
|
539
676
|
if (scheduleStep !== undefined && scheduleStep.op === "todo.update") {
|
|
540
677
|
Object.assign(params, scheduleStep.params);
|
|
678
|
+
scheduleMerged = true;
|
|
541
679
|
}
|
|
542
680
|
else if (scheduleStep !== undefined) {
|
|
543
|
-
|
|
681
|
+
// inbox restore is a separate move op — carry the schedule guard on it
|
|
682
|
+
steps.push({ ...scheduleStep, options: { guardFields: scheduleGuard } });
|
|
544
683
|
}
|
|
545
684
|
}
|
|
546
|
-
if (Object.keys(params).length > 1)
|
|
547
|
-
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
|
+
}
|
|
548
692
|
if (steps.length === 0) {
|
|
549
693
|
return irreversible("no pre-values were captured for the changed fields");
|
|
550
694
|
}
|
|
@@ -566,12 +710,15 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
566
710
|
// when/reminder restore reuses the schedule reconstructor (emitting a
|
|
567
711
|
// project.update); projects never live in the Inbox so that branch is
|
|
568
712
|
// unreachable here.
|
|
713
|
+
let projectScheduleMerged = false;
|
|
569
714
|
if ((record.requested["when"] ?? record.requested["reminder"]) !== undefined) {
|
|
570
715
|
const schedule = scheduleSteps(uuid, record, todayIso, "project.update");
|
|
571
716
|
notes.push(...schedule.notes);
|
|
572
717
|
const scheduleStep = schedule.steps[0];
|
|
573
|
-
if (scheduleStep !== undefined)
|
|
718
|
+
if (scheduleStep !== undefined) {
|
|
574
719
|
Object.assign(params, scheduleStep.params);
|
|
720
|
+
projectScheduleMerged = true;
|
|
721
|
+
}
|
|
575
722
|
}
|
|
576
723
|
if (Object.keys(params).length === 1) {
|
|
577
724
|
return irreversible("no pre-values were captured for the changed fields");
|
|
@@ -579,7 +726,15 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
579
726
|
return {
|
|
580
727
|
target,
|
|
581
728
|
kind: "invertible",
|
|
582
|
-
steps: [
|
|
729
|
+
steps: [
|
|
730
|
+
{
|
|
731
|
+
op: "project.update",
|
|
732
|
+
params,
|
|
733
|
+
...(projectScheduleMerged && {
|
|
734
|
+
options: { guardFields: ["start", "startDate", "todaySection"] },
|
|
735
|
+
}),
|
|
736
|
+
},
|
|
737
|
+
],
|
|
583
738
|
notes,
|
|
584
739
|
};
|
|
585
740
|
}
|
|
@@ -612,11 +767,19 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
612
767
|
if (preField(record, "heading") !== null && preField(record, "heading") !== undefined) {
|
|
613
768
|
notes.push("heading placement cannot be restored — the to-do returns to the project root");
|
|
614
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.
|
|
615
772
|
if (typeof oldProj === "string") {
|
|
616
773
|
return {
|
|
617
774
|
target,
|
|
618
775
|
kind: "invertible",
|
|
619
|
-
steps: [
|
|
776
|
+
steps: [
|
|
777
|
+
{
|
|
778
|
+
op: "todo.move",
|
|
779
|
+
params: { uuid, project: { uuid: oldProj } },
|
|
780
|
+
options: { guardFields: ["project", "area"] },
|
|
781
|
+
},
|
|
782
|
+
],
|
|
620
783
|
notes,
|
|
621
784
|
};
|
|
622
785
|
}
|
|
@@ -624,7 +787,13 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
624
787
|
return {
|
|
625
788
|
target,
|
|
626
789
|
kind: "invertible",
|
|
627
|
-
steps: [
|
|
790
|
+
steps: [
|
|
791
|
+
{
|
|
792
|
+
op: "todo.move",
|
|
793
|
+
params: { uuid, area: { uuid: oldArea } },
|
|
794
|
+
options: { guardFields: ["project", "area"] },
|
|
795
|
+
},
|
|
796
|
+
],
|
|
628
797
|
notes,
|
|
629
798
|
};
|
|
630
799
|
}
|
|
@@ -638,6 +807,11 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
638
807
|
if (schedule.steps.length === 0) {
|
|
639
808
|
return irreversible("pre-op scheduling state was not captured — cannot leave the Inbox");
|
|
640
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
|
+
}
|
|
641
815
|
return { target, kind: "invertible", steps: schedule.steps, notes };
|
|
642
816
|
}
|
|
643
817
|
// The audit captured the OLD value of whatever destination-kind fields
|
|
@@ -657,7 +831,13 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
657
831
|
return {
|
|
658
832
|
target,
|
|
659
833
|
kind: "invertible",
|
|
660
|
-
steps: [
|
|
834
|
+
steps: [
|
|
835
|
+
{
|
|
836
|
+
op: "todo.move",
|
|
837
|
+
params: { uuid, project: { uuid: oldProject } },
|
|
838
|
+
options: { guardFields: ["project.uuid", "heading.uuid"] },
|
|
839
|
+
},
|
|
840
|
+
],
|
|
661
841
|
notes,
|
|
662
842
|
};
|
|
663
843
|
}
|
|
@@ -665,7 +845,13 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
665
845
|
return {
|
|
666
846
|
target,
|
|
667
847
|
kind: "invertible",
|
|
668
|
-
steps: [
|
|
848
|
+
steps: [
|
|
849
|
+
{
|
|
850
|
+
op: "todo.move",
|
|
851
|
+
params: { uuid, area: { uuid: oldArea } },
|
|
852
|
+
options: { guardFields: ["area.uuid"] },
|
|
853
|
+
},
|
|
854
|
+
],
|
|
669
855
|
notes,
|
|
670
856
|
};
|
|
671
857
|
}
|
|
@@ -683,10 +869,17 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
683
869
|
? areaRef.uuid
|
|
684
870
|
: undefined;
|
|
685
871
|
if (typeof oldArea === "string") {
|
|
872
|
+
// The op detached (area now null); guard it is still area-less.
|
|
686
873
|
return {
|
|
687
874
|
target,
|
|
688
875
|
kind: "invertible",
|
|
689
|
-
steps: [
|
|
876
|
+
steps: [
|
|
877
|
+
{
|
|
878
|
+
op: "project.move",
|
|
879
|
+
params: { uuid, area: { uuid: oldArea } },
|
|
880
|
+
options: { guardFields: ["area"] },
|
|
881
|
+
},
|
|
882
|
+
],
|
|
690
883
|
notes,
|
|
691
884
|
};
|
|
692
885
|
}
|
|
@@ -697,7 +890,13 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
697
890
|
return {
|
|
698
891
|
target,
|
|
699
892
|
kind: "invertible",
|
|
700
|
-
steps: [
|
|
893
|
+
steps: [
|
|
894
|
+
{
|
|
895
|
+
op: "project.move",
|
|
896
|
+
params: { uuid, area: { uuid: areaPre } },
|
|
897
|
+
options: { guardFields: ["area.uuid"] },
|
|
898
|
+
},
|
|
899
|
+
],
|
|
701
900
|
notes,
|
|
702
901
|
};
|
|
703
902
|
}
|
|
@@ -706,7 +905,13 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
706
905
|
return {
|
|
707
906
|
target,
|
|
708
907
|
kind: "invertible",
|
|
709
|
-
steps: [
|
|
908
|
+
steps: [
|
|
909
|
+
{
|
|
910
|
+
op: "project.move",
|
|
911
|
+
params: { uuid, detach: true },
|
|
912
|
+
options: { guardFields: ["area.uuid"] },
|
|
913
|
+
},
|
|
914
|
+
],
|
|
710
915
|
notes,
|
|
711
916
|
};
|
|
712
917
|
}
|
|
@@ -870,6 +1075,35 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
870
1075
|
"their current positions");
|
|
871
1076
|
return { target, kind: "invertible", steps: [{ op: "reorder", params }], notes };
|
|
872
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
|
+
}
|
|
873
1107
|
case "heading.rename": {
|
|
874
1108
|
if (uuid === null)
|
|
875
1109
|
return irreversible("no target uuid recorded");
|
|
@@ -886,7 +1120,9 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
886
1120
|
case "heading.archive": {
|
|
887
1121
|
if (uuid === null)
|
|
888
1122
|
return irreversible("no target uuid recorded");
|
|
889
|
-
const steps = [
|
|
1123
|
+
const steps = [
|
|
1124
|
+
{ op: "heading.unarchive", params: { uuid }, options: { guardFields: ["status"] } },
|
|
1125
|
+
];
|
|
890
1126
|
// Reopen exactly the children the cascade resolved (nested pre map —
|
|
891
1127
|
// the project.complete pattern). Reparented children live in leg
|
|
892
1128
|
// records; replay their inverses too when this summary heads a txn.
|
|
@@ -941,7 +1177,13 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
941
1177
|
return {
|
|
942
1178
|
target,
|
|
943
1179
|
kind: "invertible",
|
|
944
|
-
steps: [
|
|
1180
|
+
steps: [
|
|
1181
|
+
{
|
|
1182
|
+
op: "heading.archive",
|
|
1183
|
+
params: { uuid, children: "complete" },
|
|
1184
|
+
options: { guardFields: ["status"] },
|
|
1185
|
+
},
|
|
1186
|
+
],
|
|
945
1187
|
notes,
|
|
946
1188
|
};
|
|
947
1189
|
}
|
|
@@ -982,6 +1224,78 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
982
1224
|
notes,
|
|
983
1225
|
};
|
|
984
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
|
+
}
|
|
985
1299
|
default:
|
|
986
1300
|
return irreversible(`no inverse is defined for operation "${record.op}"`);
|
|
987
1301
|
}
|
|
@@ -989,6 +1303,44 @@ export function planUndo(record, now, allRecords = [], current) {
|
|
|
989
1303
|
// ------------------------------------------------------ precondition guard
|
|
990
1304
|
/** Content fields an inverse can silently CLOBBER; keyed to observed 1:1. */
|
|
991
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
|
+
*/
|
|
992
1344
|
function fieldsEqual(a, b) {
|
|
993
1345
|
if (Array.isArray(a) && Array.isArray(b))
|
|
994
1346
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
@@ -997,14 +1349,31 @@ function fieldsEqual(a, b) {
|
|
|
997
1349
|
function formatValue(value) {
|
|
998
1350
|
return value === null || value === undefined ? "none" : JSON.stringify(value);
|
|
999
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
|
+
}
|
|
1000
1365
|
/**
|
|
1001
|
-
* Refuse an inverse step that would overwrite
|
|
1002
|
-
* already moved.
|
|
1003
|
-
*
|
|
1004
|
-
*
|
|
1005
|
-
*
|
|
1006
|
-
*
|
|
1007
|
-
*
|
|
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.
|
|
1008
1377
|
*/
|
|
1009
1378
|
function checkStepPrecondition(deps, step, observed) {
|
|
1010
1379
|
if (observed === null)
|
|
@@ -1019,25 +1388,62 @@ function checkStepPrecondition(deps, step, observed) {
|
|
|
1019
1388
|
if (!(field in step.params) || !(field in observed))
|
|
1020
1389
|
continue;
|
|
1021
1390
|
const cur = getField(current, field) ?? null;
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
"re-run undo once the field is back to its post-change value",
|
|
1032
|
-
};
|
|
1033
|
-
}
|
|
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);
|
|
1034
1400
|
}
|
|
1035
1401
|
return null;
|
|
1036
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
|
+
}
|
|
1037
1425
|
// ----------------------------------------------------------------- executor
|
|
1038
1426
|
export async function runUndo(deps, auditDirPath, options = {}, onItem) {
|
|
1039
1427
|
const records = readAuditRecords(auditDirPath);
|
|
1040
|
-
|
|
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
|
+
});
|
|
1041
1447
|
const now = deps.now?.() ?? new Date();
|
|
1042
1448
|
const items = [];
|
|
1043
1449
|
for (const record of targets) {
|
|
@@ -1050,30 +1456,29 @@ export async function runUndo(deps, auditDirPath, options = {}, onItem) {
|
|
|
1050
1456
|
item = { plan, results: [], outcome: "irreversible" };
|
|
1051
1457
|
}
|
|
1052
1458
|
else if (options.dryRun === true) {
|
|
1053
|
-
|
|
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" };
|
|
1054
1471
|
}
|
|
1055
1472
|
else {
|
|
1056
1473
|
const results = [];
|
|
1057
1474
|
let failed = false;
|
|
1058
1475
|
for (const step of plan.steps) {
|
|
1059
|
-
//
|
|
1060
|
-
//
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
op: step.op,
|
|
1066
|
-
reason: "environment",
|
|
1067
|
-
detail: planBlock.detail,
|
|
1068
|
-
remediation: planBlock.remediation,
|
|
1069
|
-
});
|
|
1070
|
-
failed = true;
|
|
1071
|
-
break;
|
|
1072
|
-
}
|
|
1073
|
-
// Precondition guard: never clobber a field an out-of-band edit moved.
|
|
1074
|
-
const precondition = checkStepPrecondition(deps, step, record.observed);
|
|
1075
|
-
if (precondition !== null) {
|
|
1076
|
-
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);
|
|
1077
1482
|
failed = true;
|
|
1078
1483
|
break;
|
|
1079
1484
|
}
|
|
@@ -1092,6 +1497,9 @@ export async function runUndo(deps, auditDirPath, options = {}, onItem) {
|
|
|
1092
1497
|
}
|
|
1093
1498
|
const writeOptions = {
|
|
1094
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,
|
|
1095
1503
|
...(options.verifyTimeoutMs !== undefined && {
|
|
1096
1504
|
verifyTimeoutMs: options.verifyTimeoutMs,
|
|
1097
1505
|
}),
|
|
@@ -1099,10 +1507,16 @@ export async function runUndo(deps, auditDirPath, options = {}, onItem) {
|
|
|
1099
1507
|
acknowledgeChecklistReset: true,
|
|
1100
1508
|
}),
|
|
1101
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 }),
|
|
1102
1514
|
};
|
|
1103
1515
|
const result = step.op === "reorder"
|
|
1104
|
-
?
|
|
1105
|
-
|
|
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);
|
|
1106
1520
|
results.push(result);
|
|
1107
1521
|
if (result.kind !== "ok") {
|
|
1108
1522
|
failed = true;
|