things-api 0.9.0 → 0.11.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.
Files changed (261) hide show
  1. package/README.md +57 -12
  2. package/dist/audit/log.js +56 -2
  3. package/dist/audit/log.js.map +1 -1
  4. package/dist/audit/schema.d.ts +26 -8
  5. package/dist/audit/schema.js +42 -1
  6. package/dist/audit/schema.js.map +1 -1
  7. package/dist/cli/clock.d.ts +8 -0
  8. package/dist/cli/clock.js +24 -0
  9. package/dist/cli/clock.js.map +1 -0
  10. package/dist/cli/commands/area.d.ts +26 -3
  11. package/dist/cli/commands/area.js +129 -75
  12. package/dist/cli/commands/area.js.map +1 -1
  13. package/dist/cli/commands/doctor.d.ts +1 -1
  14. package/dist/cli/commands/doctor.js +44 -5
  15. package/dist/cli/commands/doctor.js.map +1 -1
  16. package/dist/cli/commands/install-skill.d.ts +45 -0
  17. package/dist/cli/commands/install-skill.js +207 -0
  18. package/dist/cli/commands/install-skill.js.map +1 -0
  19. package/dist/cli/commands/mcp.js +23 -4
  20. package/dist/cli/commands/mcp.js.map +1 -1
  21. package/dist/cli/commands/project.d.ts +18 -1
  22. package/dist/cli/commands/project.js +74 -35
  23. package/dist/cli/commands/project.js.map +1 -1
  24. package/dist/cli/commands/reads.d.ts +15 -2
  25. package/dist/cli/commands/reads.js +459 -165
  26. package/dist/cli/commands/reads.js.map +1 -1
  27. package/dist/cli/commands/repeat-flags.d.ts +20 -0
  28. package/dist/cli/commands/repeat-flags.js +76 -0
  29. package/dist/cli/commands/repeat-flags.js.map +1 -0
  30. package/dist/cli/commands/setup.js +8 -4
  31. package/dist/cli/commands/setup.js.map +1 -1
  32. package/dist/cli/commands/show.js +53 -30
  33. package/dist/cli/commands/show.js.map +1 -1
  34. package/dist/cli/commands/todo.d.ts +1 -1
  35. package/dist/cli/commands/todo.js +20 -13
  36. package/dist/cli/commands/todo.js.map +1 -1
  37. package/dist/cli/commands/writes.js +388 -161
  38. package/dist/cli/commands/writes.js.map +1 -1
  39. package/dist/cli/did-you-mean.d.ts +1 -1
  40. package/dist/cli/excess-args.d.ts +15 -0
  41. package/dist/cli/excess-args.js +51 -0
  42. package/dist/cli/excess-args.js.map +1 -0
  43. package/dist/cli/glyphs.d.ts +55 -2
  44. package/dist/cli/glyphs.js +114 -21
  45. package/dist/cli/glyphs.js.map +1 -1
  46. package/dist/cli/help.d.ts +54 -0
  47. package/dist/cli/help.js +405 -0
  48. package/dist/cli/help.js.map +1 -0
  49. package/dist/cli/main.js +64 -26
  50. package/dist/cli/main.js.map +1 -1
  51. package/dist/cli/move-hint.d.ts +45 -0
  52. package/dist/cli/move-hint.js +196 -0
  53. package/dist/cli/move-hint.js.map +1 -0
  54. package/dist/cli/period.d.ts +9 -8
  55. package/dist/cli/period.js +56 -30
  56. package/dist/cli/period.js.map +1 -1
  57. package/dist/cli/read-driver.d.ts +26 -19
  58. package/dist/cli/read-driver.js +77 -33
  59. package/dist/cli/read-driver.js.map +1 -1
  60. package/dist/cli/render.d.ts +92 -28
  61. package/dist/cli/render.js +333 -103
  62. package/dist/cli/render.js.map +1 -1
  63. package/dist/cli/resolve-invocation.d.ts +42 -4
  64. package/dist/cli/resolve-invocation.js +97 -14
  65. package/dist/cli/resolve-invocation.js.map +1 -1
  66. package/dist/cli/skill-check.d.ts +21 -0
  67. package/dist/cli/skill-check.js +82 -0
  68. package/dist/cli/skill-check.js.map +1 -0
  69. package/dist/cli/skill.d.ts +62 -0
  70. package/dist/cli/skill.js +142 -0
  71. package/dist/cli/skill.js.map +1 -0
  72. package/dist/cli/tag-filters.d.ts +63 -0
  73. package/dist/cli/tag-filters.js +59 -0
  74. package/dist/cli/tag-filters.js.map +1 -0
  75. package/dist/cli/verb-hint.d.ts +25 -0
  76. package/dist/cli/verb-hint.js +163 -0
  77. package/dist/cli/verb-hint.js.map +1 -0
  78. package/dist/cli/version.d.ts +4 -0
  79. package/dist/cli/version.js +18 -0
  80. package/dist/cli/version.js.map +1 -0
  81. package/dist/cli/width.d.ts +135 -0
  82. package/dist/cli/width.js +313 -0
  83. package/dist/cli/width.js.map +1 -0
  84. package/dist/client.d.ts +160 -24
  85. package/dist/client.js +132 -19
  86. package/dist/client.js.map +1 -1
  87. package/dist/config.d.ts +11 -0
  88. package/dist/config.js +3 -0
  89. package/dist/config.js.map +1 -1
  90. package/dist/contracts.d.ts +97 -17
  91. package/dist/contracts.js +44 -1
  92. package/dist/contracts.js.map +1 -1
  93. package/dist/db/fingerprint.d.ts +12 -0
  94. package/dist/db/fingerprint.js +15 -1
  95. package/dist/db/fingerprint.js.map +1 -1
  96. package/dist/db/locate.js +1 -1
  97. package/dist/db/locate.js.map +1 -1
  98. package/dist/diagnose.d.ts +63 -0
  99. package/dist/diagnose.js +38 -1
  100. package/dist/diagnose.js.map +1 -1
  101. package/dist/index.d.ts +36 -5
  102. package/dist/index.js +38 -2
  103. package/dist/index.js.map +1 -1
  104. package/dist/mcp/server.d.ts +9 -1
  105. package/dist/mcp/server.js +1038 -436
  106. package/dist/mcp/server.js.map +1 -1
  107. package/dist/model/clock.d.ts +61 -0
  108. package/dist/model/clock.js +71 -0
  109. package/dist/model/clock.js.map +1 -0
  110. package/dist/model/dates.d.ts +26 -3
  111. package/dist/model/dates.js +84 -3
  112. package/dist/model/dates.js.map +1 -1
  113. package/dist/model/entities.d.ts +48 -10
  114. package/dist/model/entities.js.map +1 -1
  115. package/dist/model/mappers.d.ts +9 -2
  116. package/dist/model/mappers.js +32 -9
  117. package/dist/model/mappers.js.map +1 -1
  118. package/dist/model/serialize.d.ts +28 -0
  119. package/dist/model/serialize.js +80 -0
  120. package/dist/model/serialize.js.map +1 -0
  121. package/dist/model/when-sugar.d.ts +47 -0
  122. package/dist/model/when-sugar.js +45 -0
  123. package/dist/model/when-sugar.js.map +1 -0
  124. package/dist/read/area-view.d.ts +8 -1
  125. package/dist/read/area-view.js +55 -17
  126. package/dist/read/area-view.js.map +1 -1
  127. package/dist/read/detail.d.ts +1 -1
  128. package/dist/read/detail.js +11 -4
  129. package/dist/read/detail.js.map +1 -1
  130. package/dist/read/filter-contract.d.ts +117 -0
  131. package/dist/read/filter-contract.js +78 -0
  132. package/dist/read/filter-contract.js.map +1 -0
  133. package/dist/read/log-boundary.d.ts +1 -1
  134. package/dist/read/log-boundary.js +13 -3
  135. package/dist/read/log-boundary.js.map +1 -1
  136. package/dist/read/predicates.d.ts +30 -0
  137. package/dist/read/predicates.js +30 -0
  138. package/dist/read/predicates.js.map +1 -1
  139. package/dist/read/project-view.d.ts +12 -1
  140. package/dist/read/project-view.js +45 -12
  141. package/dist/read/project-view.js.map +1 -1
  142. package/dist/read/queries.d.ts +152 -7
  143. package/dist/read/queries.js +277 -36
  144. package/dist/read/queries.js.map +1 -1
  145. package/dist/read/sections.d.ts +51 -0
  146. package/dist/read/sections.js +37 -0
  147. package/dist/read/sections.js.map +1 -0
  148. package/dist/read/sidebar-order.js +2 -1
  149. package/dist/read/sidebar-order.js.map +1 -1
  150. package/dist/read/snapshot.d.ts +1 -1
  151. package/dist/read/snapshot.js +9 -2
  152. package/dist/read/snapshot.js.map +1 -1
  153. package/dist/read/tags.d.ts +27 -3
  154. package/dist/read/tags.js +83 -12
  155. package/dist/read/tags.js.map +1 -1
  156. package/dist/read/truncation.d.ts +71 -0
  157. package/dist/read/{pagination.js → truncation.js} +103 -94
  158. package/dist/read/truncation.js.map +1 -0
  159. package/dist/read/views.d.ts +147 -22
  160. package/dist/read/views.js +282 -80
  161. package/dist/read/views.js.map +1 -1
  162. package/dist/surface-copy.d.ts +19 -0
  163. package/dist/surface-copy.js +32 -0
  164. package/dist/surface-copy.js.map +1 -1
  165. package/dist/sync-health.d.ts +78 -0
  166. package/dist/sync-health.js +312 -0
  167. package/dist/sync-health.js.map +1 -0
  168. package/dist/write/accessibility-probe.d.ts +12 -0
  169. package/dist/write/accessibility-probe.js +71 -0
  170. package/dist/write/accessibility-probe.js.map +1 -0
  171. package/dist/write/automation-probe.d.ts +8 -1
  172. package/dist/write/automation-probe.js +16 -1
  173. package/dist/write/automation-probe.js.map +1 -1
  174. package/dist/write/batch.js +5 -2
  175. package/dist/write/batch.js.map +1 -1
  176. package/dist/write/capabilities.d.ts +8 -0
  177. package/dist/write/capabilities.js +13 -6
  178. package/dist/write/capabilities.js.map +1 -1
  179. package/dist/write/commands.d.ts +3 -1
  180. package/dist/write/commands.js +602 -49
  181. package/dist/write/commands.js.map +1 -1
  182. package/dist/write/edit-checklist.js +3 -2
  183. package/dist/write/edit-checklist.js.map +1 -1
  184. package/dist/write/guards.d.ts +2 -7
  185. package/dist/write/guards.js +87 -6
  186. package/dist/write/guards.js.map +1 -1
  187. package/dist/write/heading.js +2 -0
  188. package/dist/write/heading.js.map +1 -1
  189. package/dist/write/lock.d.ts +40 -2
  190. package/dist/write/lock.js +91 -14
  191. package/dist/write/lock.js.map +1 -1
  192. package/dist/write/make-repeating-project.d.ts +4 -0
  193. package/dist/write/make-repeating-project.js +253 -0
  194. package/dist/write/make-repeating-project.js.map +1 -0
  195. package/dist/write/operations.d.ts +145 -1
  196. package/dist/write/operations.js +48 -0
  197. package/dist/write/operations.js.map +1 -1
  198. package/dist/write/pipeline.d.ts +73 -2
  199. package/dist/write/pipeline.js +218 -43
  200. package/dist/write/pipeline.js.map +1 -1
  201. package/dist/write/planner.js +8 -1
  202. package/dist/write/planner.js.map +1 -1
  203. package/dist/write/pre-state.d.ts +105 -3
  204. package/dist/write/pre-state.js +144 -3
  205. package/dist/write/pre-state.js.map +1 -1
  206. package/dist/write/recurrence-rule-blob.d.ts +65 -0
  207. package/dist/write/recurrence-rule-blob.js +150 -0
  208. package/dist/write/recurrence-rule-blob.js.map +1 -0
  209. package/dist/write/reopen.js +3 -2
  210. package/dist/write/reopen.js.map +1 -1
  211. package/dist/write/reorder.js +27 -17
  212. package/dist/write/reorder.js.map +1 -1
  213. package/dist/write/repeat-rule.d.ts +22 -0
  214. package/dist/write/repeat-rule.js +258 -0
  215. package/dist/write/repeat-rule.js.map +1 -0
  216. package/dist/write/reversibility.js +49 -0
  217. package/dist/write/reversibility.js.map +1 -1
  218. package/dist/write/tag-refs.d.ts +47 -0
  219. package/dist/write/tag-refs.js +126 -0
  220. package/dist/write/tag-refs.js.map +1 -0
  221. package/dist/write/undo.d.ts +74 -5
  222. package/dist/write/undo.js +493 -80
  223. package/dist/write/undo.js.map +1 -1
  224. package/dist/write/vectors/registry.d.ts +15 -1
  225. package/dist/write/vectors/registry.js +65 -2
  226. package/dist/write/vectors/registry.js.map +1 -1
  227. package/dist/write/vectors/simulator.d.ts +44 -0
  228. package/dist/write/vectors/simulator.js +1136 -0
  229. package/dist/write/vectors/simulator.js.map +1 -0
  230. package/dist/write/vectors/types.d.ts +154 -3
  231. package/dist/write/vectors/ui-certification.d.ts +54 -0
  232. package/dist/write/vectors/ui-certification.js +43 -0
  233. package/dist/write/vectors/ui-certification.js.map +1 -0
  234. package/dist/write/vectors/ui-drag.d.ts +188 -0
  235. package/dist/write/vectors/ui-drag.js +1095 -0
  236. package/dist/write/vectors/ui-drag.js.map +1 -0
  237. package/dist/write/vectors/ui-recipes.d.ts +83 -0
  238. package/dist/write/vectors/ui-recipes.js +610 -0
  239. package/dist/write/vectors/ui-recipes.js.map +1 -0
  240. package/dist/write/vectors/ui.d.ts +152 -0
  241. package/dist/write/vectors/ui.js +704 -0
  242. package/dist/write/vectors/ui.js.map +1 -0
  243. package/dist/write/verify/delta.d.ts +141 -4
  244. package/dist/write/verify/delta.js +199 -21
  245. package/dist/write/verify/delta.js.map +1 -1
  246. package/dist/write/verify/poller.d.ts +7 -1
  247. package/dist/write/verify/poller.js +21 -1
  248. package/dist/write/verify/poller.js.map +1 -1
  249. package/package.json +18 -3
  250. package/skills/things-cli/SKILL.md +53 -0
  251. package/skills/things-cli/references/contracts.md +41 -0
  252. package/skills/things-cli/references/gui.md +11 -0
  253. package/skills/things-cli/references/model.md +51 -0
  254. package/dist/cli/exit-codes.d.ts +0 -26
  255. package/dist/cli/exit-codes.js +0 -26
  256. package/dist/cli/exit-codes.js.map +0 -1
  257. package/dist/cli/output.d.ts +0 -42
  258. package/dist/cli/output.js +0 -16
  259. package/dist/cli/output.js.map +0 -1
  260. package/dist/read/pagination.d.ts +0 -104
  261. package/dist/read/pagination.js.map +0 -1
@@ -1,8 +1,12 @@
1
1
  import { decodeReminderTime, encodeReminderTime, reminderUrlToken, } from "../model/dates.js";
2
2
  import { byUuid } from "../read/detail.js";
3
- import { childTagTitles, computeReorderPre, emptyPreState, loadTarget, missingTagTitles, projectChildren, projectStatus, resolveArea, resolveHeading, resolveProject, resolveTag, trashedCount, } from "./pre-state.js";
3
+ import { childTagTitles, classifyHeadingConvert, classifyProjectRepeat, computeReorderPre, emptyPreState, loadTarget, projectChildren, projectStatus, projectSubtreeUuids, resolveArea, resolveHeading, resolveProject, resolveTag, sameTitleTaskUuids, trashedCount, } from "./pre-state.js";
4
+ import { resolveTagRefs } from "./tag-refs.js";
4
5
  import { PRIVATE_REORDER_COMMAND } from "./experimental.js";
6
+ import { assertRepeatRule } from "./repeat-rule.js";
5
7
  import { escapeAppleScript } from "./vectors/applescript.js";
8
+ import { areaReorderSidebarRecipe, convertToProjectRecipe, headingConvertToProjectRecipe, makeRepeatingRecipe, pauseRepeatRecipe, projectMakeRepeatingRecipe, projectPauseRepeatRecipe, projectRescheduleRepeatRecipe, projectResumeRepeatRecipe, rescheduleRepeatRecipe, resumeRepeatRecipe, } from "./vectors/ui-recipes.js";
9
+ import { buildRepeatingFingerprint } from "./verify/delta.js";
6
10
  // ------------------------------------------------------------------ helpers
7
11
  function thingsUrl(command, params, token) {
8
12
  const parts = [];
@@ -99,6 +103,18 @@ function whenAssertions(when, todayIso) {
99
103
  function sortedTags(tags) {
100
104
  return [...tags].toSorted();
101
105
  }
106
+ /**
107
+ * Resolve tag refs (title or `parent/child` path — names only) into pre-state:
108
+ * the leaf titles to apply, plus the unknown (`missingTags`) refs the
109
+ * H-UNKNOWN-TAG guard refuses on. Tags apply BY NAME through the app's own
110
+ * vector, so the app resolves the name (duplicate names resolve app-side, as
111
+ * in the GUI) — the resolver is a pure existence check.
112
+ */
113
+ function applyTagRefs(db, pre, tags) {
114
+ const res = resolveTagRefs(db, tags);
115
+ pre.missingTags = res.missing;
116
+ pre.resolvedTagTitles = res.titles;
117
+ }
102
118
  /** Round-trip normalization: "6:5"-style inputs → canonical "06:05". */
103
119
  function normalizeReminder(time) {
104
120
  return decodeReminderTime(encodeReminderTime(time)) ?? time;
@@ -115,6 +131,17 @@ function whenWithReminder(when, reminder) {
115
131
  function containerGiven(ref) {
116
132
  return ref !== undefined && (ref.uuid !== undefined || ref.title !== undefined);
117
133
  }
134
+ /**
135
+ * The create-probe title for the duplicate / identity-replacement ops, whose
136
+ * new row inherits the pre-read target's title. Non-heading targets only (the
137
+ * heading-convert op uses the raw title, headings included); "" when the target
138
+ * did not resolve. Shared by the pre-read same-title capture and the delta so
139
+ * the excluded set and the probe title cannot drift apart.
140
+ */
141
+ function nonHeadingTitle(pre) {
142
+ const target = pre.target;
143
+ return target !== null && target.type !== "heading" ? target.title : "";
144
+ }
118
145
  // ----------------------------------------------------------------- commands
119
146
  const todoAdd = {
120
147
  op: "todo.add",
@@ -126,6 +153,12 @@ const todoAdd = {
126
153
  "H-REMINDER-SCOPE",
127
154
  ],
128
155
  preRead(db, params) {
156
+ if (containerGiven(params.project) && containerGiven(params.area)) {
157
+ throw new RangeError("project and area are exclusive destinations");
158
+ }
159
+ if (params.heading !== undefined && !containerGiven(params.project)) {
160
+ throw new RangeError("heading requires a project destination");
161
+ }
129
162
  const pre = emptyPreState();
130
163
  if (containerGiven(params.project)) {
131
164
  pre.destProject = resolveProject(db, params.project);
@@ -139,7 +172,8 @@ const todoAdd = {
139
172
  if (containerGiven(params.area))
140
173
  pre.destArea = resolveArea(db, params.area);
141
174
  if (params.tags !== undefined)
142
- pre.missingTags = missingTagTitles(db, params.tags);
175
+ applyTagRefs(db, pre, params.tags);
176
+ pre.sameTitleUuids = sameTitleTaskUuids(db, params.title, "to-do");
143
177
  return pre;
144
178
  },
145
179
  expectedDelta(pre, params, ctx) {
@@ -153,8 +187,9 @@ const todoAdd = {
153
187
  }
154
188
  if (params.deadline !== undefined)
155
189
  assert.push({ field: "deadline", equals: params.deadline });
156
- if (params.tags !== undefined)
157
- assert.push({ field: "tags", equals: sortedTags(params.tags) });
190
+ if (params.tags !== undefined) {
191
+ assert.push({ field: "tags", equals: sortedTags(pre.resolvedTagTitles) });
192
+ }
158
193
  if (params.checklistItems !== undefined) {
159
194
  assert.push({ field: "checklistTitles", equals: params.checklistItems });
160
195
  }
@@ -171,7 +206,12 @@ const todoAdd = {
171
206
  assert.push({ field: "area.uuid", equals: area.uuid });
172
207
  return {
173
208
  mode: "create",
174
- probe: { title: params.title, type: "to-do", sinceEpoch: ctx.nowEpoch - 2 },
209
+ probe: {
210
+ title: params.title,
211
+ type: "to-do",
212
+ sinceEpoch: ctx.nowEpoch - 2,
213
+ excludeUuids: pre.sameTitleUuids,
214
+ },
175
215
  assert,
176
216
  };
177
217
  },
@@ -184,7 +224,7 @@ const todoAdd = {
184
224
  notes: params.notes,
185
225
  when: params.when === undefined ? undefined : whenWithReminder(params.when, params.reminder),
186
226
  deadline: params.deadline,
187
- tags: params.tags?.join(","),
227
+ tags: params.tags === undefined ? undefined : pre.resolvedTagTitles.join(","),
188
228
  "checklist-items": params.checklistItems?.join("\n"),
189
229
  "list-id": container?.uuid,
190
230
  heading: pre.destHeading?.resolved?.title,
@@ -317,6 +357,12 @@ const todoMove = {
317
357
  if (params.detach === true && container) {
318
358
  throw new RangeError("detach is exclusive with project/area/heading destinations");
319
359
  }
360
+ if (containerGiven(params.project) && containerGiven(params.area)) {
361
+ throw new RangeError("project and area are exclusive destinations");
362
+ }
363
+ if (params.heading !== undefined && !containerGiven(params.project)) {
364
+ throw new RangeError("heading requires a project destination");
365
+ }
320
366
  const pre = emptyPreState();
321
367
  pre.target = loadTarget(db, params.uuid);
322
368
  if (containerGiven(params.project)) {
@@ -400,21 +446,22 @@ const todoSetTags = {
400
446
  preRead(db, params) {
401
447
  const pre = emptyPreState();
402
448
  pre.target = loadTarget(db, params.uuid);
403
- pre.missingTags = missingTagTitles(db, params.tags);
449
+ applyTagRefs(db, pre, params.tags);
404
450
  return pre;
405
451
  },
406
- expectedDelta(_pre, params) {
452
+ expectedDelta(pre, params) {
407
453
  return {
408
454
  mode: "update",
409
455
  uuid: params.uuid,
410
- assert: [{ field: "tags", equals: sortedTags(params.tags) }],
456
+ assert: [{ field: "tags", equals: sortedTags(pre.resolvedTagTitles) }],
411
457
  };
412
458
  },
413
- compile(params, vector, _pre, ctx) {
459
+ compile(params, vector, pre, ctx) {
460
+ const applied = pre.resolvedTagTitles;
414
461
  if (vector === "url-scheme") {
415
- return thingsUrl("update", { id: params.uuid, tags: params.tags.join(",") }, ctx.token);
462
+ return thingsUrl("update", { id: params.uuid, tags: applied.join(",") }, ctx.token);
416
463
  }
417
- return osa(`set tag names of to do id ${q(params.uuid)} to ${q(params.tags.join(", "))}`);
464
+ return osa(`set tag names of to do id ${q(params.uuid)} to ${q(applied.join(", "))}`);
418
465
  },
419
466
  };
420
467
  /** Normalize the string | spec union; decide whether states force the json form. */
@@ -516,6 +563,7 @@ const projectAdd = {
516
563
  const pre = emptyPreState();
517
564
  if (containerGiven(params.area))
518
565
  pre.destArea = resolveArea(db, params.area);
566
+ pre.sameTitleUuids = sameTitleTaskUuids(db, params.title, "project");
519
567
  return pre;
520
568
  },
521
569
  expectedDelta(pre, params, ctx) {
@@ -531,7 +579,12 @@ const projectAdd = {
531
579
  assert.push({ field: "area.uuid", equals: area.uuid });
532
580
  return {
533
581
  mode: "create",
534
- probe: { title: params.title, type: "project", sinceEpoch: ctx.nowEpoch - 2 },
582
+ probe: {
583
+ title: params.title,
584
+ type: "project",
585
+ sinceEpoch: ctx.nowEpoch - 2,
586
+ excludeUuids: pre.sameTitleUuids,
587
+ },
535
588
  assert,
536
589
  };
537
590
  },
@@ -602,24 +655,25 @@ const projectSetTags = {
602
655
  preRead(db, params) {
603
656
  const pre = emptyPreState();
604
657
  pre.target = loadTarget(db, params.uuid);
605
- pre.missingTags = missingTagTitles(db, params.tags);
658
+ applyTagRefs(db, pre, params.tags);
606
659
  return pre;
607
660
  },
608
- expectedDelta(_pre, params) {
661
+ expectedDelta(pre, params) {
609
662
  return {
610
663
  mode: "update",
611
664
  uuid: params.uuid,
612
- assert: [{ field: "tags", equals: sortedTags(params.tags) }],
665
+ assert: [{ field: "tags", equals: sortedTags(pre.resolvedTagTitles) }],
613
666
  };
614
667
  },
615
- compile(params, vector, _pre, ctx) {
668
+ compile(params, vector, pre, ctx) {
616
669
  // Both vectors validated on projects (A1 URL, A2 AppleScript). Full
617
670
  // replacement semantics mirror todo.set-tags; unknown tags are guarded
618
671
  // pre-write (the app silently drops them).
672
+ const applied = pre.resolvedTagTitles;
619
673
  if (vector === "url-scheme") {
620
- return thingsUrl("update-project", { id: params.uuid, tags: params.tags.join(",") }, ctx.token);
674
+ return thingsUrl("update-project", { id: params.uuid, tags: applied.join(",") }, ctx.token);
621
675
  }
622
- return osa(`set tag names of project id ${q(params.uuid)} to ${q(params.tags.join(", "))}`);
676
+ return osa(`set tag names of project id ${q(params.uuid)} to ${q(applied.join(", "))}`);
623
677
  },
624
678
  };
625
679
  const projectMove = {
@@ -665,7 +719,7 @@ const projectMove = {
665
719
  };
666
720
  const todoRestore = {
667
721
  op: "todo.restore",
668
- hazards: ["H-UNKNOWN-DESTINATION", "H-REPEAT-SCHEDULE"],
722
+ hazards: ["H-UNKNOWN-DESTINATION", "H-TEMPLATE-CHILD-RESTORE", "H-REPEAT-SCHEDULE"],
669
723
  preRead(db, params) {
670
724
  const pre = emptyPreState();
671
725
  pre.target = loadTarget(db, params.uuid);
@@ -695,17 +749,24 @@ const projectDuplicate = {
695
749
  preRead(db, params) {
696
750
  const pre = emptyPreState();
697
751
  pre.target = loadTarget(db, params.uuid);
752
+ pre.sameTitleUuids = sameTitleTaskUuids(db, nonHeadingTitle(pre), "project");
698
753
  return pre;
699
754
  },
700
755
  expectedDelta(pre, _params, ctx) {
701
756
  // E17: the copy carries the title, notes AND children; discover it with
702
- // the create probe (fresh creationDate, same as the to-do path E07).
757
+ // the create probe (fresh creationDate, same as the to-do path E07). The
758
+ // ORIGINAL shares the copy's title, so it is among the excluded pre-existing
759
+ // rows — discovery lands on the copy, never the source.
703
760
  const target = pre.target;
704
- const title = target !== null && target.type !== "heading" ? target.title : "";
705
761
  const notes = target !== null && target.type !== "heading" ? target.notes : "";
706
762
  return {
707
763
  mode: "create",
708
- probe: { title, type: "project", sinceEpoch: ctx.nowEpoch - 2 },
764
+ probe: {
765
+ title: nonHeadingTitle(pre),
766
+ type: "project",
767
+ sinceEpoch: ctx.nowEpoch - 2,
768
+ excludeUuids: pre.sameTitleUuids,
769
+ },
709
770
  assert: [{ field: "notes", equals: notes }],
710
771
  };
711
772
  },
@@ -861,7 +922,7 @@ const areaAdd = {
861
922
  preRead(db, params) {
862
923
  const pre = emptyPreState();
863
924
  if (params.tags !== undefined)
864
- pre.missingTags = missingTagTitles(db, params.tags);
925
+ applyTagRefs(db, pre, params.tags);
865
926
  pre.existingEntityUuids = db.prepare("SELECT uuid FROM TMArea WHERE title = ? COLLATE NOCASE").all(params.title).map((r) => r.uuid);
866
927
  return pre;
867
928
  },
@@ -871,9 +932,13 @@ const areaAdd = {
871
932
  entity: "area",
872
933
  title: params.title,
873
934
  excludeUuids: pre.existingEntityUuids,
935
+ // Assert the landed tag set on the created area — the app silently
936
+ // drops unknowns (guarded pre-write), so the created row must carry
937
+ // exactly the resolved titles (parity with the other tag-set ops).
938
+ ...(params.tags !== undefined && { assertTags: sortedTags(pre.resolvedTagTitles) }),
874
939
  };
875
940
  },
876
- compile(params, vector) {
941
+ compile(params, vector, pre) {
877
942
  if (vector !== "applescript")
878
943
  unsupportedVector(this.op, vector);
879
944
  const make = `make new area with properties {name:${q(params.title)}}`;
@@ -881,7 +946,7 @@ const areaAdd = {
881
946
  return osa(make);
882
947
  const payload = `tell application "Things3"\n` +
883
948
  ` ${make}\n` +
884
- ` set tag names of area ${q(params.title)} to ${q(params.tags.join(", "))}\n` +
949
+ ` set tag names of area ${q(params.title)} to ${q(pre.resolvedTagTitles.join(", "))}\n` +
885
950
  `end tell`;
886
951
  return { vector: "applescript", kind: "osascript", payload, redactedPayload: payload };
887
952
  },
@@ -966,17 +1031,23 @@ const todoDuplicate = {
966
1031
  preRead(db, params) {
967
1032
  const pre = emptyPreState();
968
1033
  pre.target = loadTarget(db, params.uuid);
1034
+ pre.sameTitleUuids = sameTitleTaskUuids(db, nonHeadingTitle(pre), "to-do");
969
1035
  return pre;
970
1036
  },
971
1037
  expectedDelta(pre, _params, ctx) {
972
1038
  // The copy carries the same title/notes with a fresh uuid + creationDate
973
- // (E07) — discover it with the create probe, assert copy fidelity.
1039
+ // (E07) — discover it with the create probe, assert copy fidelity. The
1040
+ // ORIGINAL shares the title, so it is excluded as a pre-existing row.
974
1041
  const target = pre.target;
975
- const title = target !== null && target.type !== "heading" ? target.title : "";
976
1042
  const notes = target !== null && target.type !== "heading" ? target.notes : "";
977
1043
  return {
978
1044
  mode: "create",
979
- probe: { title, type: "to-do", sinceEpoch: ctx.nowEpoch - 2 },
1045
+ probe: {
1046
+ title: nonHeadingTitle(pre),
1047
+ type: "to-do",
1048
+ sinceEpoch: ctx.nowEpoch - 2,
1049
+ excludeUuids: pre.sameTitleUuids,
1050
+ },
980
1051
  assert: [{ field: "notes", equals: notes }],
981
1052
  };
982
1053
  },
@@ -997,15 +1068,16 @@ const areaUpdate = {
997
1068
  const pre = emptyPreState();
998
1069
  pre.entityTarget = resolveArea(db, { title: params.target, uuid: params.target });
999
1070
  if (params.tags !== undefined)
1000
- pre.missingTags = missingTagTitles(db, params.tags);
1071
+ applyTagRefs(db, pre, params.tags);
1001
1072
  return pre;
1002
1073
  },
1003
1074
  expectedDelta(pre, params) {
1004
1075
  const assert = [];
1005
1076
  if (params.title !== undefined)
1006
1077
  assert.push({ field: "title", equals: params.title });
1007
- if (params.tags !== undefined)
1008
- assert.push({ field: "tags", equals: sortedTags(params.tags) });
1078
+ if (params.tags !== undefined) {
1079
+ assert.push({ field: "tags", equals: sortedTags(pre.resolvedTagTitles) });
1080
+ }
1009
1081
  return {
1010
1082
  mode: "entity-updated",
1011
1083
  entity: "area",
@@ -1021,7 +1093,7 @@ const areaUpdate = {
1021
1093
  if (params.title !== undefined)
1022
1094
  lines.push(`set name of area id ${id} to ${q(params.title)}`);
1023
1095
  if (params.tags !== undefined) {
1024
- lines.push(`set tag names of area id ${id} to ${q(params.tags.join(", "))}`);
1096
+ lines.push(`set tag names of area id ${id} to ${q(pre.resolvedTagTitles.join(", "))}`);
1025
1097
  }
1026
1098
  if (lines.length === 1)
1027
1099
  return osa(lines[0]);
@@ -1180,6 +1252,18 @@ function asDateBlock(varName, iso) {
1180
1252
  `set day of ${varName} to ${d}`,
1181
1253
  ];
1182
1254
  }
1255
+ /**
1256
+ * Local noon -> UTC instant, so the stored timestamp decodes back to the
1257
+ * requested local DATE in every timezone (P4d: json attrs honored exactly).
1258
+ * WITHOUT milliseconds: the app's json date parser rejects fractional
1259
+ * seconds — a `.000Z` timestamp fails the whole command (error modal, no
1260
+ * write; caught live by the e2e 2026-07-09 — P4d's validated shape was
1261
+ * second-precision).
1262
+ */
1263
+ function utcNoon(iso) {
1264
+ const [y, m, d] = iso.split("-").map(Number);
1265
+ return new Date(y ?? 0, (m ?? 1) - 1, d ?? 1, 12, 0, 0).toISOString().replace(/\.\d{3}Z$/, "Z");
1266
+ }
1183
1267
  const todoBackdate = {
1184
1268
  op: "todo.backdate",
1185
1269
  hazards: ["H-UNKNOWN-DESTINATION", "H-BACKDATE-OPEN"],
@@ -1223,7 +1307,7 @@ const todoAddLogged = {
1223
1307
  throw new RangeError("creationDate must not be after completionDate");
1224
1308
  }
1225
1309
  const pre = emptyPreState();
1226
- pre.sameTitleUuids = db.prepare("SELECT uuid FROM TMTask WHERE title = ? AND type = 0").all(params.title).map((r) => r.uuid);
1310
+ pre.sameTitleUuids = sameTitleTaskUuids(db, params.title, "to-do");
1227
1311
  return pre;
1228
1312
  },
1229
1313
  expectedDelta(pre, params) {
@@ -1250,18 +1334,6 @@ const todoAddLogged = {
1250
1334
  compile(params, vector, _pre, ctx) {
1251
1335
  if (vector !== "url-scheme")
1252
1336
  unsupportedVector(this.op, vector);
1253
- // Local noon -> UTC instant, so the stored timestamp decodes back to the
1254
- // requested local DATE in every timezone (P4d: json attrs honored exactly).
1255
- // WITHOUT milliseconds: the app's json date parser rejects fractional
1256
- // seconds — a `.000Z` timestamp fails the whole command (error modal, no
1257
- // write; caught live by the e2e 2026-07-09 — P4d's validated shape was
1258
- // second-precision).
1259
- const utcNoon = (iso) => {
1260
- const [y, m, d] = iso.split("-").map(Number);
1261
- return new Date(y ?? 0, (m ?? 1) - 1, d ?? 1, 12, 0, 0)
1262
- .toISOString()
1263
- .replace(/\.\d{3}Z$/, "Z");
1264
- };
1265
1337
  const payload = JSON.stringify([
1266
1338
  {
1267
1339
  type: "to-do",
@@ -1395,15 +1467,21 @@ const headingCreate = {
1395
1467
  preRead(db, params) {
1396
1468
  const pre = emptyPreState();
1397
1469
  pre.destProject = resolveProject(db, params.project);
1470
+ pre.sameTitleUuids = sameTitleTaskUuids(db, params.title, "heading");
1398
1471
  return pre;
1399
1472
  },
1400
1473
  expectedDelta(pre, params, ctx) {
1401
1474
  const project = pre.destProject?.resolved;
1402
- // A new type=2 row with this title under the project; disambiguate
1403
- // duplicate titles by newest creationDate (findCreated orders DESC).
1475
+ // A new type=2 row with this title under the project; a pre-existing
1476
+ // same-title heading is excluded so discovery cannot bind to it.
1404
1477
  return {
1405
1478
  mode: "create",
1406
- probe: { title: params.title, type: "heading", sinceEpoch: ctx.nowEpoch - 2 },
1479
+ probe: {
1480
+ title: params.title,
1481
+ type: "heading",
1482
+ sinceEpoch: ctx.nowEpoch - 2,
1483
+ excludeUuids: pre.sameTitleUuids,
1484
+ },
1407
1485
  assert: project !== undefined && project !== null
1408
1486
  ? [{ field: "project.uuid", equals: project.uuid }]
1409
1487
  : [],
@@ -1453,6 +1531,469 @@ const todoClearDatedReminder = {
1453
1531
  });
1454
1532
  },
1455
1533
  };
1534
+ // ------------------------------------------------------- ui (GUI) vector
1535
+ /** A compiled Accessibility recipe as a CompiledInvocation (no secrets). */
1536
+ function uiDrive(recipe) {
1537
+ const rendered = `ui-drive ${recipe.op} on ${recipe.targetUuid}: ${recipe.steps
1538
+ .map((s) => s.label)
1539
+ .join(" → ")}`;
1540
+ return { vector: "ui", kind: "ui-drive", payload: rendered, redactedPayload: rendered, recipe };
1541
+ }
1542
+ // Re-exported so make-repeating-project.ts and existing callers keep importing
1543
+ // the validator from commands.ts; the implementation + the full combination
1544
+ // matrix live in repeat-rule.ts.
1545
+ export { assertRepeatRule };
1546
+ /**
1547
+ * The extended-vocabulary fields of a rule as a recipe `extras` bag, including
1548
+ * ONLY the keys that are present (exactOptionalPropertyTypes: never set an
1549
+ * optional field to undefined). A bare `{ uuid, frequency, interval }` yields
1550
+ * `{}`, so the recipe drives exactly the certified two-control path.
1551
+ */
1552
+ function ruleExtras(params) {
1553
+ return {
1554
+ ...(params.afterCompletion !== undefined && { afterCompletion: params.afterCompletion }),
1555
+ ...(params.weekdays !== undefined && { weekdays: params.weekdays }),
1556
+ ...(params.monthly !== undefined && { monthly: params.monthly }),
1557
+ ...(params.yearly !== undefined && { yearly: params.yearly }),
1558
+ ...(params.ends !== undefined && { ends: params.ends }),
1559
+ ...(params.reminder !== undefined && { reminder: params.reminder }),
1560
+ ...(params.deadline !== undefined && { deadline: params.deadline }),
1561
+ ...(params.startDaysEarlier !== undefined && { startDaysEarlier: params.startDaysEarlier }),
1562
+ };
1563
+ }
1564
+ /** ui ops all guard existence/type + the H-UI-DRIVE acknowledgement. */
1565
+ const UI_HAZARDS = ["H-UNKNOWN-DESTINATION", "H-UI-DRIVE"];
1566
+ const todoMakeRepeating = {
1567
+ op: "todo.make-repeating",
1568
+ hazards: UI_HAZARDS,
1569
+ preRead(db, params) {
1570
+ assertRepeatRule(params);
1571
+ const pre = emptyPreState();
1572
+ pre.target = loadTarget(db, params.uuid);
1573
+ pre.sameTitleUuids = sameTitleTaskUuids(db, nonHeadingTitle(pre), "to-do");
1574
+ return pre;
1575
+ },
1576
+ expectedDelta(pre, _params, ctx) {
1577
+ // Identity REPLACEMENT or preserve-as-instance (UI2-a / RSIM-R): a NEW
1578
+ // template row (type=0 with a recurrence rule) is born; the original uuid is
1579
+ // EITHER destroyed OR relinked as the current-occurrence instance. Discover
1580
+ // the template with the create probe (excluding the pre-existing same-title
1581
+ // rows), pick it by asserting it IS a template, then the `repeating` context
1582
+ // hardens discovery (restored time-bound, source-fingerprint tiebreak) and
1583
+ // derives instance + source fate for the enriched result.
1584
+ return {
1585
+ mode: "create",
1586
+ probe: {
1587
+ title: nonHeadingTitle(pre),
1588
+ type: "to-do",
1589
+ sinceEpoch: ctx.nowEpoch - 2,
1590
+ excludeUuids: pre.sameTitleUuids,
1591
+ ...(pre.target !== null && {
1592
+ repeating: {
1593
+ sourceUuid: pre.target.uuid,
1594
+ fingerprint: buildRepeatingFingerprint(pre.target),
1595
+ },
1596
+ }),
1597
+ },
1598
+ assert: [{ field: "repeating.isTemplate", equals: true }],
1599
+ };
1600
+ },
1601
+ compile(params, vector) {
1602
+ if (vector !== "ui")
1603
+ unsupportedVector(this.op, vector);
1604
+ return uiDrive(makeRepeatingRecipe(params.uuid, params.frequency, params.interval, ruleExtras(params)));
1605
+ },
1606
+ };
1607
+ const todoRescheduleRepeat = {
1608
+ op: "todo.reschedule-repeat",
1609
+ hazards: UI_HAZARDS,
1610
+ preRead(db, params) {
1611
+ assertRepeatRule(params);
1612
+ const pre = emptyPreState();
1613
+ pre.target = loadTarget(db, params.uuid);
1614
+ return pre;
1615
+ },
1616
+ expectedDelta(_pre, params) {
1617
+ // Identity PRESERVED (UI2-b): the same template uuid, rule mutated in
1618
+ // place. Assert the decoded rule's frequency + interval; ALSO capture the
1619
+ // whole prior rule (+ deadline flag) so the undo can re-drive it faithfully.
1620
+ return {
1621
+ mode: "update",
1622
+ uuid: params.uuid,
1623
+ assert: [
1624
+ { field: "repeating.rule.unit", equals: params.frequency },
1625
+ { field: "repeating.rule.interval", equals: params.interval },
1626
+ ],
1627
+ capture: [{ field: "repeating.rule" }, { field: "repeating.deadlined" }],
1628
+ };
1629
+ },
1630
+ compile(params, vector) {
1631
+ if (vector !== "ui")
1632
+ unsupportedVector(this.op, vector);
1633
+ return uiDrive(rescheduleRepeatRecipe(params.uuid, params.frequency, params.interval, ruleExtras(params)));
1634
+ },
1635
+ };
1636
+ const todoPauseRepeat = {
1637
+ op: "todo.pause-repeat",
1638
+ hazards: UI_HAZARDS,
1639
+ preRead(db, params) {
1640
+ const pre = emptyPreState();
1641
+ pre.target = loadTarget(db, params.uuid);
1642
+ return pre;
1643
+ },
1644
+ expectedDelta(_pre, params) {
1645
+ // Pause (UI2-c): rt1_instanceCreationPaused → 1; identity preserved.
1646
+ return {
1647
+ mode: "update",
1648
+ uuid: params.uuid,
1649
+ assert: [{ field: "repeating.paused", equals: true }],
1650
+ };
1651
+ },
1652
+ compile(params, vector) {
1653
+ if (vector !== "ui")
1654
+ unsupportedVector(this.op, vector);
1655
+ return uiDrive(pauseRepeatRecipe(params.uuid));
1656
+ },
1657
+ };
1658
+ const todoResumeRepeat = {
1659
+ op: "todo.resume-repeat",
1660
+ hazards: UI_HAZARDS,
1661
+ preRead(db, params) {
1662
+ const pre = emptyPreState();
1663
+ pre.target = loadTarget(db, params.uuid);
1664
+ return pre;
1665
+ },
1666
+ expectedDelta(_pre, params) {
1667
+ // Resume (UI2-c): rt1_instanceCreationPaused → 0; identity preserved.
1668
+ return {
1669
+ mode: "update",
1670
+ uuid: params.uuid,
1671
+ assert: [{ field: "repeating.paused", equals: false }],
1672
+ };
1673
+ },
1674
+ compile(params, vector) {
1675
+ if (vector !== "ui")
1676
+ unsupportedVector(this.op, vector);
1677
+ return uiDrive(resumeRepeatRecipe(params.uuid));
1678
+ },
1679
+ };
1680
+ // ------------------------------------------------- repeating-PROJECT ops
1681
+ // The project analogs of reschedule/pause/resume. Same recurrence codec and
1682
+ // Repeat dialog as the to-do ops (identical DB deltas), reached through the
1683
+ // project view's always-visible repeat bar (UIC2). No project.stop-repeat is
1684
+ // built — the project Stop then selecting the demoted project crashes Things
1685
+ // (CRASH1 / oddities §7 C5).
1686
+ const projectRescheduleRepeat = {
1687
+ op: "project.reschedule-repeat",
1688
+ hazards: UI_HAZARDS,
1689
+ preRead(db, params) {
1690
+ assertRepeatRule(params);
1691
+ const pre = emptyPreState();
1692
+ pre.target = loadTarget(db, params.uuid);
1693
+ return pre;
1694
+ },
1695
+ expectedDelta(_pre, params) {
1696
+ // Identity PRESERVED (UIC2-a): same project uuid, rule mutated in place;
1697
+ // capture the prior rule (+ deadline flag) for the faithful undo.
1698
+ return {
1699
+ mode: "update",
1700
+ uuid: params.uuid,
1701
+ assert: [
1702
+ { field: "repeating.rule.unit", equals: params.frequency },
1703
+ { field: "repeating.rule.interval", equals: params.interval },
1704
+ ],
1705
+ capture: [{ field: "repeating.rule" }, { field: "repeating.deadlined" }],
1706
+ };
1707
+ },
1708
+ compile(params, vector) {
1709
+ if (vector !== "ui")
1710
+ unsupportedVector(this.op, vector);
1711
+ return uiDrive(projectRescheduleRepeatRecipe(params.uuid, params.frequency, params.interval, ruleExtras(params)));
1712
+ },
1713
+ };
1714
+ const projectPauseRepeat = {
1715
+ op: "project.pause-repeat",
1716
+ hazards: UI_HAZARDS,
1717
+ preRead(db, params) {
1718
+ const pre = emptyPreState();
1719
+ pre.target = loadTarget(db, params.uuid);
1720
+ return pre;
1721
+ },
1722
+ expectedDelta(_pre, params) {
1723
+ // Pause (UIC2-a): rt1_instanceCreationPaused → 1; identity preserved.
1724
+ return {
1725
+ mode: "update",
1726
+ uuid: params.uuid,
1727
+ assert: [{ field: "repeating.paused", equals: true }],
1728
+ };
1729
+ },
1730
+ compile(params, vector) {
1731
+ if (vector !== "ui")
1732
+ unsupportedVector(this.op, vector);
1733
+ return uiDrive(projectPauseRepeatRecipe(params.uuid));
1734
+ },
1735
+ };
1736
+ const projectResumeRepeat = {
1737
+ op: "project.resume-repeat",
1738
+ hazards: UI_HAZARDS,
1739
+ preRead(db, params) {
1740
+ const pre = emptyPreState();
1741
+ pre.target = loadTarget(db, params.uuid);
1742
+ return pre;
1743
+ },
1744
+ expectedDelta(_pre, params) {
1745
+ // Resume (UIC2-a): rt1_instanceCreationPaused → 0; identity preserved.
1746
+ return {
1747
+ mode: "update",
1748
+ uuid: params.uuid,
1749
+ assert: [{ field: "repeating.paused", equals: false }],
1750
+ };
1751
+ },
1752
+ compile(params, vector) {
1753
+ if (vector !== "ui")
1754
+ unsupportedVector(this.op, vector);
1755
+ return uiDrive(projectResumeRepeatRecipe(params.uuid));
1756
+ },
1757
+ };
1758
+ // project.make-repeating: the pure-AX row-selection op (UIC4). The command
1759
+ // spec drives the area / someday cases; the area-less-anytime coercion is done
1760
+ // by the runMakeRepeatingProject orchestrator BEFORE this spec sees the target,
1761
+ // so by drive time the taxonomy is always area or someday. A DIRECT dispatch on
1762
+ // an anytime project is refused by H-PROJECT-REPEAT (with the orchestrator as
1763
+ // the remediation).
1764
+ const projectMakeRepeating = {
1765
+ op: "project.make-repeating",
1766
+ hazards: ["H-UNKNOWN-DESTINATION", "H-PROJECT-REPEAT", "H-UI-DRIVE"],
1767
+ preRead(db, params) {
1768
+ assertRepeatRule(params);
1769
+ const pre = emptyPreState();
1770
+ pre.target = loadTarget(db, params.uuid);
1771
+ pre.projectRepeat = classifyProjectRepeat(db, pre.target);
1772
+ pre.sameTitleUuids = sameTitleTaskUuids(db, nonHeadingTitle(pre), "project");
1773
+ if (pre.target !== null)
1774
+ pre.repeatSubtreeUuids = projectSubtreeUuids(db, pre.target.uuid);
1775
+ return pre;
1776
+ },
1777
+ expectedDelta(pre, _params, ctx) {
1778
+ // Identity REPLACEMENT or preserve-as-instance (UIC4-b / RSIM-R): a NEW
1779
+ // template project (with a recurrence rule) is born; the source project is
1780
+ // EITHER destroyed OR (when its subtree holds a nested repeater) relinked as
1781
+ // the current-occurrence instance. Pick the TEMPLATE by asserting it IS one,
1782
+ // excluding pre-existing same-title rows; the `repeating` context hardens
1783
+ // discovery and derives instance + source fate + childrenReplaced.
1784
+ return {
1785
+ mode: "create",
1786
+ probe: {
1787
+ title: nonHeadingTitle(pre),
1788
+ type: "project",
1789
+ sinceEpoch: ctx.nowEpoch - 2,
1790
+ excludeUuids: pre.sameTitleUuids,
1791
+ ...(pre.target !== null && {
1792
+ repeating: {
1793
+ sourceUuid: pre.target.uuid,
1794
+ fingerprint: buildRepeatingFingerprint(pre.target),
1795
+ subtreeUuids: pre.repeatSubtreeUuids ?? [],
1796
+ },
1797
+ }),
1798
+ },
1799
+ assert: [{ field: "repeating.isTemplate", equals: true }],
1800
+ };
1801
+ },
1802
+ compile(params, vector, pre) {
1803
+ if (vector !== "ui")
1804
+ unsupportedVector(this.op, vector);
1805
+ const tax = pre.projectRepeat;
1806
+ if (tax === null || tax.kind === "refuse" || tax.kind === "anytime") {
1807
+ // Unreachable in practice: the guards block refuse/anytime before compile.
1808
+ throw new Error("project.make-repeating: no selectable-row taxonomy resolved (guard bypassed?)");
1809
+ }
1810
+ return uiDrive(projectMakeRepeatingRecipe(tax.containerReveal, params.uuid, tax.title, params.frequency, params.interval, ruleExtras(params)));
1811
+ },
1812
+ };
1813
+ // project.create-repeating is delivered by the runCreateRepeatingProject
1814
+ // orchestrator (project.add THEN project.make-repeating); it has no single
1815
+ // atomic surface and is never dispatched directly through the pipeline.
1816
+ const CREATE_REPEATING_ORCHESTRATED = "project.create-repeating is delivered by the runCreateRepeatingProject orchestrator (create " +
1817
+ "the project, then promote it to a repeating series); it has no atomic surface and is never " +
1818
+ "dispatched directly through the pipeline";
1819
+ const projectCreateRepeating = {
1820
+ op: "project.create-repeating",
1821
+ hazards: [],
1822
+ preRead() {
1823
+ return emptyPreState();
1824
+ },
1825
+ expectedDelta() {
1826
+ throw new Error(CREATE_REPEATING_ORCHESTRATED);
1827
+ },
1828
+ compile() {
1829
+ throw new Error(CREATE_REPEATING_ORCHESTRATED);
1830
+ },
1831
+ };
1832
+ const todoConvertToProject = {
1833
+ op: "todo.convert-to-project",
1834
+ hazards: UI_HAZARDS,
1835
+ preRead(db, params) {
1836
+ const pre = emptyPreState();
1837
+ pre.target = loadTarget(db, params.uuid);
1838
+ // The new row is a PROJECT (type=1) — exclude pre-existing same-title
1839
+ // projects, not the source to-do (a different type the probe never sees).
1840
+ pre.sameTitleUuids = sameTitleTaskUuids(db, nonHeadingTitle(pre), "project");
1841
+ return pre;
1842
+ },
1843
+ expectedDelta(pre, _params, ctx) {
1844
+ // Identity REPLACEMENT (UI2-d): the to-do uuid dies; a NEW type=1 project
1845
+ // is born, notes preserved. Discover it (its uuid is returned).
1846
+ const target = pre.target;
1847
+ const notes = target !== null && target.type !== "heading" ? target.notes : "";
1848
+ return {
1849
+ mode: "create",
1850
+ probe: {
1851
+ title: nonHeadingTitle(pre),
1852
+ type: "project",
1853
+ sinceEpoch: ctx.nowEpoch - 2,
1854
+ excludeUuids: pre.sameTitleUuids,
1855
+ },
1856
+ assert: [{ field: "notes", equals: notes }],
1857
+ };
1858
+ },
1859
+ compile(params, vector) {
1860
+ if (vector !== "ui")
1861
+ unsupportedVector(this.op, vector);
1862
+ return uiDrive(convertToProjectRecipe("todo.convert-to-project", params.uuid));
1863
+ },
1864
+ };
1865
+ const headingConvertToProject = {
1866
+ op: "heading.convert-to-project",
1867
+ hazards: UI_HAZARDS,
1868
+ preRead(db, params) {
1869
+ const pre = emptyPreState();
1870
+ pre.target = loadTarget(db, params.uuid);
1871
+ // The new row is a PROJECT (type=1) carrying the former heading's title;
1872
+ // exclude pre-existing same-title projects. The heading uses its raw title
1873
+ // (headings are not covered by nonHeadingTitle).
1874
+ const title = pre.target !== null ? pre.target.title : "";
1875
+ pre.sameTitleUuids = sameTitleTaskUuids(db, title, "project");
1876
+ pre.headingConvert = classifyHeadingConvert(db, pre.target);
1877
+ return pre;
1878
+ },
1879
+ expectedDelta(pre, _params, ctx) {
1880
+ // Identity REPLACEMENT (UI2-d): the heading uuid dies; a NEW type=1
1881
+ // project is born (promoted into the parent project's area, children
1882
+ // reparented). Discover the new project by its (former heading) title.
1883
+ const target = pre.target;
1884
+ const title = target !== null ? target.title : "";
1885
+ return {
1886
+ mode: "create",
1887
+ probe: {
1888
+ title,
1889
+ type: "project",
1890
+ sinceEpoch: ctx.nowEpoch - 2,
1891
+ excludeUuids: pre.sameTitleUuids,
1892
+ },
1893
+ assert: [],
1894
+ };
1895
+ },
1896
+ compile(_params, vector, pre) {
1897
+ if (vector !== "ui")
1898
+ unsupportedVector(this.op, vector);
1899
+ const tax = pre.headingConvert;
1900
+ if (tax === null || tax.kind === "refuse") {
1901
+ // H-UNKNOWN-DESTINATION blocks a non-heading target before compile; a
1902
+ // refuse here means a parentless heading (or one absent from its project's
1903
+ // heading set) slipped past — fail loud rather than drive the wrong row.
1904
+ throw new Error(`heading.convert-to-project: ${tax?.kind === "refuse" ? tax.detail : "no heading taxonomy resolved (guard bypassed?)"}`);
1905
+ }
1906
+ return uiDrive(headingConvertToProjectRecipe(tax.projectReveal, tax.ordinal));
1907
+ },
1908
+ };
1909
+ // -------------------------------------------------- sidebar AREA reorder
1910
+ /** The compile-time placement for area.reorder (resolved refs). */
1911
+ function sidebarPlacementOf(params, pre) {
1912
+ const ref = pre.destArea?.resolved;
1913
+ if (params.before !== undefined && ref != null) {
1914
+ return { kind: "before", uuid: ref.uuid, title: ref.title };
1915
+ }
1916
+ if (params.after !== undefined && ref != null) {
1917
+ return { kind: "after", uuid: ref.uuid, title: ref.title };
1918
+ }
1919
+ return { kind: params.position === "first" ? "first" : "last" };
1920
+ }
1921
+ const areaReorderSidebar = {
1922
+ op: "area.reorder",
1923
+ hazards: ["H-UNKNOWN-DESTINATION", "H-UI-DRIVE"],
1924
+ preRead(db, params) {
1925
+ const destinations = [params.before, params.after, params.position].filter((d) => d !== undefined);
1926
+ if (destinations.length !== 1) {
1927
+ throw new RangeError("pass exactly one destination: before, after, or position (first | last)");
1928
+ }
1929
+ if (params.position !== undefined &&
1930
+ params.position !== "first" &&
1931
+ params.position !== "last") {
1932
+ throw new RangeError(`invalid position "${params.position}" — expected first | last`);
1933
+ }
1934
+ const pre = emptyPreState();
1935
+ pre.entityTarget = resolveArea(db, { uuid: params.target, title: params.target });
1936
+ const ref = params.before ?? params.after;
1937
+ if (ref !== undefined)
1938
+ pre.destArea = resolveArea(db, { uuid: ref, title: ref });
1939
+ const target = pre.entityTarget.resolved;
1940
+ const dest = pre.destArea?.resolved;
1941
+ if (target != null && dest != null && target.uuid === dest.uuid) {
1942
+ throw new RangeError("the destination area is the area being moved");
1943
+ }
1944
+ // The sidebar drag addresses rows by their VISIBLE NAME — a duplicated
1945
+ // area title makes the row ambiguous, so refuse up front.
1946
+ for (const area of [target, dest]) {
1947
+ if (area == null)
1948
+ continue;
1949
+ const dup = db
1950
+ .prepare("SELECT COUNT(*) AS n FROM TMArea WHERE title = ? COLLATE NOCASE")
1951
+ .get(area.title);
1952
+ if (dup.n > 1) {
1953
+ throw new RangeError(`area title "${area.title}" is shared by ${dup.n} areas — the sidebar move addresses ` +
1954
+ "areas by their visible name; rename one first");
1955
+ }
1956
+ }
1957
+ pre.areaOrder = db.prepare(`SELECT uuid FROM TMArea ORDER BY "index", uuid`).all().map((r) => r.uuid);
1958
+ return pre;
1959
+ },
1960
+ expectedDelta(pre, params) {
1961
+ const target = pre.entityTarget?.resolved?.uuid ?? "";
1962
+ const order = (pre.areaOrder ?? []).filter((u) => u !== target);
1963
+ const refUuid = pre.destArea?.resolved?.uuid;
1964
+ // Assert the implicated RELATIVE pair (index VALUES are never asserted —
1965
+ // a drag may renumber a neighbor, AXDRAG1-a); capture the FULL pre-order
1966
+ // for undo.
1967
+ let sequence;
1968
+ if (params.before !== undefined && refUuid !== undefined) {
1969
+ sequence = [target, refUuid];
1970
+ }
1971
+ else if (params.after !== undefined && refUuid !== undefined) {
1972
+ sequence = [refUuid, target];
1973
+ }
1974
+ else if (params.position === "first") {
1975
+ const first = order[0];
1976
+ sequence = first === undefined ? [target] : [target, first];
1977
+ }
1978
+ else {
1979
+ const last = order.at(-1);
1980
+ sequence = last === undefined ? [target] : [last, target];
1981
+ }
1982
+ return {
1983
+ mode: "ordering",
1984
+ key: "area-index",
1985
+ sequence,
1986
+ capture: pre.areaOrder ?? sequence,
1987
+ subject: target,
1988
+ };
1989
+ },
1990
+ compile(params, vector, pre) {
1991
+ if (vector !== "ui")
1992
+ unsupportedVector(this.op, vector);
1993
+ const target = pre.entityTarget?.resolved;
1994
+ return uiDrive(areaReorderSidebarRecipe({ uuid: target?.uuid ?? "", title: target?.title ?? "" }, sidebarPlacementOf(params, pre)));
1995
+ },
1996
+ };
1456
1997
  const trashEmpty = {
1457
1998
  op: "trash.empty",
1458
1999
  hazards: ["H-PERMANENT-DELETE"],
@@ -1508,5 +2049,17 @@ export const COMMANDS = {
1508
2049
  "heading.unarchive": headingUnarchive,
1509
2050
  "heading.create": headingCreate,
1510
2051
  "todo.clear-dated-reminder": todoClearDatedReminder,
2052
+ "todo.make-repeating": todoMakeRepeating,
2053
+ "todo.reschedule-repeat": todoRescheduleRepeat,
2054
+ "todo.pause-repeat": todoPauseRepeat,
2055
+ "todo.resume-repeat": todoResumeRepeat,
2056
+ "todo.convert-to-project": todoConvertToProject,
2057
+ "heading.convert-to-project": headingConvertToProject,
2058
+ "project.reschedule-repeat": projectRescheduleRepeat,
2059
+ "project.pause-repeat": projectPauseRepeat,
2060
+ "project.resume-repeat": projectResumeRepeat,
2061
+ "area.reorder": areaReorderSidebar,
2062
+ "project.make-repeating": projectMakeRepeating,
2063
+ "project.create-repeating": projectCreateRepeating,
1511
2064
  };
1512
2065
  //# sourceMappingURL=commands.js.map