things-api 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/README.md +30 -11
  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/commands/area.d.ts +26 -3
  8. package/dist/cli/commands/area.js +125 -70
  9. package/dist/cli/commands/area.js.map +1 -1
  10. package/dist/cli/commands/doctor.d.ts +1 -1
  11. package/dist/cli/commands/doctor.js +44 -5
  12. package/dist/cli/commands/doctor.js.map +1 -1
  13. package/dist/cli/commands/mcp.js +23 -4
  14. package/dist/cli/commands/mcp.js.map +1 -1
  15. package/dist/cli/commands/project.d.ts +18 -1
  16. package/dist/cli/commands/project.js +69 -31
  17. package/dist/cli/commands/project.js.map +1 -1
  18. package/dist/cli/commands/reads.js +423 -157
  19. package/dist/cli/commands/reads.js.map +1 -1
  20. package/dist/cli/commands/repeat-flags.d.ts +20 -0
  21. package/dist/cli/commands/repeat-flags.js +76 -0
  22. package/dist/cli/commands/repeat-flags.js.map +1 -0
  23. package/dist/cli/commands/setup.js +2 -3
  24. package/dist/cli/commands/setup.js.map +1 -1
  25. package/dist/cli/commands/show.js +47 -24
  26. package/dist/cli/commands/show.js.map +1 -1
  27. package/dist/cli/commands/todo.d.ts +1 -1
  28. package/dist/cli/commands/todo.js +7 -6
  29. package/dist/cli/commands/todo.js.map +1 -1
  30. package/dist/cli/commands/writes.js +378 -148
  31. package/dist/cli/commands/writes.js.map +1 -1
  32. package/dist/cli/did-you-mean.d.ts +1 -1
  33. package/dist/cli/excess-args.d.ts +15 -0
  34. package/dist/cli/excess-args.js +51 -0
  35. package/dist/cli/excess-args.js.map +1 -0
  36. package/dist/cli/glyphs.d.ts +39 -2
  37. package/dist/cli/glyphs.js +91 -21
  38. package/dist/cli/glyphs.js.map +1 -1
  39. package/dist/cli/help.d.ts +54 -0
  40. package/dist/cli/help.js +355 -0
  41. package/dist/cli/help.js.map +1 -0
  42. package/dist/cli/main.js +32 -25
  43. package/dist/cli/main.js.map +1 -1
  44. package/dist/cli/period.d.ts +7 -0
  45. package/dist/cli/period.js +12 -0
  46. package/dist/cli/period.js.map +1 -1
  47. package/dist/cli/read-driver.d.ts +26 -19
  48. package/dist/cli/read-driver.js +67 -33
  49. package/dist/cli/read-driver.js.map +1 -1
  50. package/dist/cli/render.d.ts +92 -28
  51. package/dist/cli/render.js +318 -97
  52. package/dist/cli/render.js.map +1 -1
  53. package/dist/cli/resolve-invocation.d.ts +42 -4
  54. package/dist/cli/resolve-invocation.js +97 -14
  55. package/dist/cli/resolve-invocation.js.map +1 -1
  56. package/dist/cli/tag-filters.d.ts +59 -0
  57. package/dist/cli/tag-filters.js +57 -0
  58. package/dist/cli/tag-filters.js.map +1 -0
  59. package/dist/cli/verb-hint.d.ts +25 -0
  60. package/dist/cli/verb-hint.js +138 -0
  61. package/dist/cli/verb-hint.js.map +1 -0
  62. package/dist/cli/width.d.ts +135 -0
  63. package/dist/cli/width.js +313 -0
  64. package/dist/cli/width.js.map +1 -0
  65. package/dist/client.d.ts +137 -24
  66. package/dist/client.js +105 -16
  67. package/dist/client.js.map +1 -1
  68. package/dist/config.d.ts +11 -0
  69. package/dist/config.js +3 -0
  70. package/dist/config.js.map +1 -1
  71. package/dist/contracts.d.ts +87 -17
  72. package/dist/contracts.js +44 -1
  73. package/dist/contracts.js.map +1 -1
  74. package/dist/db/fingerprint.d.ts +12 -0
  75. package/dist/db/fingerprint.js +15 -1
  76. package/dist/db/fingerprint.js.map +1 -1
  77. package/dist/db/locate.js +1 -1
  78. package/dist/db/locate.js.map +1 -1
  79. package/dist/diagnose.d.ts +63 -0
  80. package/dist/diagnose.js +38 -1
  81. package/dist/diagnose.js.map +1 -1
  82. package/dist/index.d.ts +30 -4
  83. package/dist/index.js +35 -2
  84. package/dist/index.js.map +1 -1
  85. package/dist/mcp/server.d.ts +9 -1
  86. package/dist/mcp/server.js +691 -133
  87. package/dist/mcp/server.js.map +1 -1
  88. package/dist/model/entities.d.ts +31 -7
  89. package/dist/model/entities.js.map +1 -1
  90. package/dist/model/mappers.d.ts +7 -0
  91. package/dist/model/mappers.js +7 -2
  92. package/dist/model/mappers.js.map +1 -1
  93. package/dist/model/serialize.d.ts +28 -0
  94. package/dist/model/serialize.js +80 -0
  95. package/dist/model/serialize.js.map +1 -0
  96. package/dist/model/when-sugar.d.ts +47 -0
  97. package/dist/model/when-sugar.js +45 -0
  98. package/dist/model/when-sugar.js.map +1 -0
  99. package/dist/read/area-view.d.ts +8 -1
  100. package/dist/read/area-view.js +49 -13
  101. package/dist/read/area-view.js.map +1 -1
  102. package/dist/read/filter-contract.d.ts +117 -0
  103. package/dist/read/filter-contract.js +78 -0
  104. package/dist/read/filter-contract.js.map +1 -0
  105. package/dist/read/predicates.d.ts +30 -0
  106. package/dist/read/predicates.js +30 -0
  107. package/dist/read/predicates.js.map +1 -1
  108. package/dist/read/project-view.d.ts +12 -1
  109. package/dist/read/project-view.js +39 -8
  110. package/dist/read/project-view.js.map +1 -1
  111. package/dist/read/queries.d.ts +149 -6
  112. package/dist/read/queries.js +250 -31
  113. package/dist/read/queries.js.map +1 -1
  114. package/dist/read/sections.d.ts +51 -0
  115. package/dist/read/sections.js +37 -0
  116. package/dist/read/sections.js.map +1 -0
  117. package/dist/read/sidebar-order.js +2 -1
  118. package/dist/read/sidebar-order.js.map +1 -1
  119. package/dist/read/tags.d.ts +27 -3
  120. package/dist/read/tags.js +83 -12
  121. package/dist/read/tags.js.map +1 -1
  122. package/dist/read/truncation.d.ts +71 -0
  123. package/dist/read/{pagination.js → truncation.js} +103 -94
  124. package/dist/read/truncation.js.map +1 -0
  125. package/dist/read/views.d.ts +140 -16
  126. package/dist/read/views.js +264 -67
  127. package/dist/read/views.js.map +1 -1
  128. package/dist/surface-copy.d.ts +19 -0
  129. package/dist/surface-copy.js +32 -0
  130. package/dist/surface-copy.js.map +1 -1
  131. package/dist/sync-health.d.ts +78 -0
  132. package/dist/sync-health.js +312 -0
  133. package/dist/sync-health.js.map +1 -0
  134. package/dist/write/accessibility-probe.d.ts +12 -0
  135. package/dist/write/accessibility-probe.js +63 -0
  136. package/dist/write/accessibility-probe.js.map +1 -0
  137. package/dist/write/automation-probe.d.ts +7 -0
  138. package/dist/write/automation-probe.js +8 -1
  139. package/dist/write/automation-probe.js.map +1 -1
  140. package/dist/write/batch.js +4 -2
  141. package/dist/write/batch.js.map +1 -1
  142. package/dist/write/capabilities.d.ts +8 -0
  143. package/dist/write/capabilities.js +13 -6
  144. package/dist/write/capabilities.js.map +1 -1
  145. package/dist/write/commands.d.ts +2 -0
  146. package/dist/write/commands.js +562 -48
  147. package/dist/write/commands.js.map +1 -1
  148. package/dist/write/edit-checklist.js +3 -2
  149. package/dist/write/edit-checklist.js.map +1 -1
  150. package/dist/write/guards.d.ts +2 -7
  151. package/dist/write/guards.js +64 -6
  152. package/dist/write/guards.js.map +1 -1
  153. package/dist/write/heading.js +2 -0
  154. package/dist/write/heading.js.map +1 -1
  155. package/dist/write/lock.d.ts +40 -2
  156. package/dist/write/lock.js +91 -14
  157. package/dist/write/lock.js.map +1 -1
  158. package/dist/write/make-repeating-project.d.ts +4 -0
  159. package/dist/write/make-repeating-project.js +253 -0
  160. package/dist/write/make-repeating-project.js.map +1 -0
  161. package/dist/write/operations.d.ts +145 -1
  162. package/dist/write/operations.js +48 -0
  163. package/dist/write/operations.js.map +1 -1
  164. package/dist/write/pipeline.d.ts +19 -0
  165. package/dist/write/pipeline.js +109 -26
  166. package/dist/write/pipeline.js.map +1 -1
  167. package/dist/write/pre-state.d.ts +68 -3
  168. package/dist/write/pre-state.js +102 -3
  169. package/dist/write/pre-state.js.map +1 -1
  170. package/dist/write/reopen.js +3 -2
  171. package/dist/write/reopen.js.map +1 -1
  172. package/dist/write/reorder.js +27 -17
  173. package/dist/write/reorder.js.map +1 -1
  174. package/dist/write/repeat-rule.d.ts +22 -0
  175. package/dist/write/repeat-rule.js +258 -0
  176. package/dist/write/repeat-rule.js.map +1 -0
  177. package/dist/write/reversibility.js +49 -0
  178. package/dist/write/reversibility.js.map +1 -1
  179. package/dist/write/tag-refs.d.ts +47 -0
  180. package/dist/write/tag-refs.js +126 -0
  181. package/dist/write/tag-refs.js.map +1 -0
  182. package/dist/write/undo.d.ts +74 -5
  183. package/dist/write/undo.js +493 -80
  184. package/dist/write/undo.js.map +1 -1
  185. package/dist/write/vectors/registry.d.ts +17 -1
  186. package/dist/write/vectors/registry.js +18 -2
  187. package/dist/write/vectors/registry.js.map +1 -1
  188. package/dist/write/vectors/types.d.ts +119 -3
  189. package/dist/write/vectors/ui-certification.d.ts +48 -0
  190. package/dist/write/vectors/ui-certification.js +46 -0
  191. package/dist/write/vectors/ui-certification.js.map +1 -0
  192. package/dist/write/vectors/ui-drag.d.ts +188 -0
  193. package/dist/write/vectors/ui-drag.js +1095 -0
  194. package/dist/write/vectors/ui-drag.js.map +1 -0
  195. package/dist/write/vectors/ui-recipes.d.ts +70 -0
  196. package/dist/write/vectors/ui-recipes.js +558 -0
  197. package/dist/write/vectors/ui-recipes.js.map +1 -0
  198. package/dist/write/vectors/ui.d.ts +137 -0
  199. package/dist/write/vectors/ui.js +656 -0
  200. package/dist/write/vectors/ui.js.map +1 -0
  201. package/dist/write/verify/delta.d.ts +31 -3
  202. package/dist/write/verify/delta.js +22 -6
  203. package/dist/write/verify/delta.js.map +1 -1
  204. package/dist/write/verify/poller.js +1 -0
  205. package/dist/write/verify/poller.js.map +1 -1
  206. package/package.json +1 -1
  207. package/dist/cli/exit-codes.d.ts +0 -26
  208. package/dist/cli/exit-codes.js +0 -26
  209. package/dist/cli/exit-codes.js.map +0 -1
  210. package/dist/cli/output.d.ts +0 -42
  211. package/dist/cli/output.js +0 -16
  212. package/dist/cli/output.js.map +0 -1
  213. package/dist/read/pagination.d.ts +0 -104
  214. package/dist/read/pagination.js.map +0 -1
@@ -0,0 +1,656 @@
1
+ /**
2
+ * The Accessibility GUI ("ui") vector — the FOURTH write vector, for the
3
+ * GUI-only transforms that have no headless spelling (make/reschedule/pause/
4
+ * resume/stop a repeat, convert a to-do or heading to a project). It drives
5
+ * the LOCAL Things app through the Accessibility API (the AXUIElement tree via
6
+ * `osascript` + System Events), addressing SEMANTIC elements (`menu item
7
+ * "Pause" …`, `button "Convert" of sheet 1 …`) — NEVER coordinates, never
8
+ * screenshots. That semantic addressing is what makes it fail-closed:
9
+ *
10
+ * - Recipe canary preflight: before ANY press, every statically-reachable
11
+ * element the recipe will touch is resolved; a single miss refuses the
12
+ * whole drive, naming the element (a Things update moved/renamed the menu,
13
+ * Accessibility is not granted, the app is not running, or the app is not
14
+ * in English). Nothing is pressed on a partial resolution.
15
+ * - Wait-for-element with timeout for async UI (sheets/popovers): the driver
16
+ * polls for the expected element and, on timeout, aborts (Escape) and
17
+ * reports partial state honestly — which steps ran, which did not.
18
+ *
19
+ * Two-key gated: the `ui.enabled` config (below — an unset config makes the
20
+ * matrix report the op unsupported) AND a per-call `dangerouslyDriveGui`
21
+ * acknowledgement (H-UI-DRIVE, enforced by the pipeline's guards). Every op
22
+ * ships UNCERTIFIED (ui-certification.ts): the element paths are derived from
23
+ * the known menu structure but not yet exercised on real hardware.
24
+ *
25
+ * A vendored native AXUIElement client is an explicitly-deferred follow-up;
26
+ * v1 shells out to `osascript` with ONE stable command shape per primitive.
27
+ */
28
+ import { execFile } from "node:child_process";
29
+ import { UI_DRIVE_OPS } from "../operations.js";
30
+ import { escapeAppleScript } from "./applescript.js";
31
+ import { certificationOf } from "./ui-certification.js";
32
+ import { driveSidebarAreaReorder, jxaSidebarSnapshotScript } from "./ui-drag.js";
33
+ /** GUI driving can stall on an unanswered sheet; give each step headroom. */
34
+ const STEP_TIMEOUT_MS = 15_000;
35
+ /** Poll interval while waiting for a dynamic element (sheet/popover). */
36
+ const WAIT_POLL_MS = 300;
37
+ /**
38
+ * How long `resolveStepPath` polls a candidate-addressed control before failing
39
+ * closed. The full-vocabulary dialog reveals a pop-up/field a beat AFTER the
40
+ * frequency/Ends switch that precedes it (UIC6: ~250 ms), so the effective-form
41
+ * resolution must poll, not snap once.
42
+ */
43
+ const RESOLVE_CANDIDATE_TIMEOUT_MS = 5_000;
44
+ /**
45
+ * Settle after the reveal/activate preamble so the menu bar repopulates for the
46
+ * newly-selected target before the canary reads it (UIC1: the Items ▸ Repeat
47
+ * submenu appears only once a repeating item is selected, and the update is not
48
+ * instantaneous).
49
+ */
50
+ const SETTLE_AFTER_REVEAL_MS = 1500;
51
+ const SE = `tell application "System Events" to tell process "Things3"`;
52
+ /** resolve-element: does the element exist right now? Returns "true"/"false". */
53
+ export function axResolveScript(path) {
54
+ return `${SE} to return (exists (${path}))`;
55
+ }
56
+ /** press: AXPress the element. */
57
+ export function axPressScript(path) {
58
+ return `${SE} to click (${path})`;
59
+ }
60
+ /**
61
+ * set-field-value: enter a value into the dialog's numeric text field (interval,
62
+ * ends-count, start-days-earlier). It FOCUSES the field, selects all, TYPES the
63
+ * value, and Tabs to commit — because `set value of <field>` writes the field's
64
+ * displayed text WITHOUT firing the edit, so the app's binding keeps the old
65
+ * number (the field shows "5" but the rule stays interval 1 — a silent no-op
66
+ * exactly like `set value` on a pop-up, UIC6; it went unnoticed while every base
67
+ * case used the default interval 1). Real keystrokes fire the change the binding
68
+ * needs; Tab (not Return, which would fire the default OK button) commits and
69
+ * moves focus. Foreground-bound (keystrokes reach the frontmost app) — the
70
+ * reveal/activate preamble puts Things there. One stable command shape.
71
+ */
72
+ export function axSetValueScript(path, value) {
73
+ return `${SE}
74
+ set tf to (${path})
75
+ set focused of tf to true
76
+ delay 0.15
77
+ keystroke "a" using command down
78
+ delay 0.1
79
+ keystroke "${escapeAppleScript(value)}"
80
+ delay 0.1
81
+ key code 48
82
+ delay 0.2
83
+ end tell`;
84
+ }
85
+ /**
86
+ * select-popup: choose an item in a pop-up button by NAME. Setting `value` on a
87
+ * Things pop-up button is a silent no-op (UIC1 / UI2-i) — the control must be
88
+ * opened and the menu item clicked. The open-click is POLLED until the menu
89
+ * actually renders: in the full-vocabulary dialog a preceding pop-up's menu is
90
+ * still animating closed when the next select fires, and that first open-click
91
+ * is ABSORBED (the pop-up stays closed, so `menu 1` is an invalid index and the
92
+ * item click errors -1719, UIC6). Re-clicking only while the menu is absent
93
+ * (never once it is open) opens it reliably without toggling it back shut. One
94
+ * stable command shape per primitive.
95
+ */
96
+ export function axSelectPopupScript(path, value) {
97
+ return `${SE}
98
+ set pu to (${path})
99
+ repeat 20 times
100
+ if (exists menu 1 of pu) then exit repeat
101
+ click pu
102
+ delay 0.3
103
+ end repeat
104
+ click menu item "${escapeAppleScript(value)}" of menu 1 of pu
105
+ end tell`;
106
+ }
107
+ /**
108
+ * select-row: select a PROJECT row by title, purely via AX (UIC4-a). Walks the
109
+ * content table's rows, issues the row `select` action on each (which REPLACES
110
+ * the table selection — single-select, UIC5), and reads back Things' `name of
111
+ * selected to dos`; the first row whose readback equals the target title is LEFT
112
+ * selected and the script returns "OK". Non-selectable rows (the area/Someday
113
+ * header, the blank spacer) select nothing (readback count 0) and are skipped.
114
+ * Returns "NOMATCH" if no row selects to the title — the readback is the
115
+ * selection-landed verification, so a match guarantees the intended row is
116
+ * selected. One stable command shape per primitive.
117
+ *
118
+ * UIC5 correction: the shipped form set the TABLE's `AXSelectedRows` attribute
119
+ * to a one-row list, which is a SILENT NO-OP on Things' content table via System
120
+ * Events (no error, selection never lands). The row `select` action is the
121
+ * working pure-System-Events route and stays background-capable with no focus
122
+ * steal (UIC5-e). (UIC4-a proved settability with the ObjC-bridge NSArray set —
123
+ * a different API than the System Events attribute set the driver shells out to.)
124
+ */
125
+ export function axSelectRowScript(tablePath, title) {
126
+ const t = escapeAppleScript(title);
127
+ return `tell application "System Events" to tell process "Things3"
128
+ set theTable to (${tablePath})
129
+ set n to (count rows of theTable)
130
+ repeat with i from 1 to n
131
+ try
132
+ select (row i of theTable)
133
+ tell application "Things3" to set selNames to (name of selected to dos)
134
+ if (count of selNames) is 1 and ((item 1 of selNames) as text) is "${t}" then
135
+ return "OK"
136
+ end if
137
+ end try
138
+ end repeat
139
+ end tell
140
+ return "NOMATCH"`;
141
+ }
142
+ /** activate: foreground Things (the fallback preamble step). */
143
+ export function axActivateScript() {
144
+ return `tell application "Things3" to activate`;
145
+ }
146
+ /** key: a space-separated keystroke spec (e.g. "down down return"). */
147
+ export function axKeyScript(keys) {
148
+ const KEY_CODES = { return: 36, escape: 53, down: 125, up: 126, tab: 48 };
149
+ const lines = keys
150
+ .split(/\s+/)
151
+ .filter((k) => k !== "")
152
+ .map((k) => KEY_CODES[k] !== undefined
153
+ ? `key code ${KEY_CODES[k]}`
154
+ : `keystroke "${escapeAppleScript(k)}"`);
155
+ return `tell application "System Events" to tell process "Things3"\n ${lines.join("\n ")}\nend tell`;
156
+ }
157
+ /** The abort keystroke sent to dismiss a half-open sheet/popover on failure. */
158
+ export function axAbortScript() {
159
+ return `tell application "System Events" to key code 53`; // Escape
160
+ }
161
+ /**
162
+ * resolve-frame: read the element's on-screen frame (top-left origin, points)
163
+ * from the live AX tree and print "x y w h". Used by `click-element` to target
164
+ * the frame CENTER — the position comes from AX (`position`/`size`), never a
165
+ * guessed pixel, so a missing element errors (fail-closed) instead of clicking
166
+ * a stale coordinate. Points map 1:1 to CGEvent coordinates (NATIVE1-b).
167
+ */
168
+ export function axFrameScript(path) {
169
+ return `${SE}
170
+ set _p to position of (${path})
171
+ set _s to size of (${path})
172
+ return ((item 1 of _p) as text) & " " & ((item 2 of _p) as text) & " " & ((item 1 of _s) as text) & " " & ((item 2 of _s) as text)
173
+ end tell`;
174
+ }
175
+ /**
176
+ * click-point: synthesize a single left mouse click at (x, y) via the global
177
+ * HID event tap (the NATIVE1 JXA ObjC-bridge path — `CGEventPostToPid` is inert
178
+ * for Things' hit-testing; only `CGEventPost(kCGHIDEventTap)` lands). The HID
179
+ * tap posts to the FOREGROUND surface, so the recipe must have activated Things
180
+ * first. Event types are the stable CGEventType values (5 = mouse-moved,
181
+ * 1 = left-down, 2 = left-up).
182
+ */
183
+ export function jxaClickScript(x, y) {
184
+ const xi = Math.round(x);
185
+ const yi = Math.round(y);
186
+ return `ObjC.import('Foundation');
187
+ ObjC.import('CoreGraphics');
188
+ function sleep(ms){ $.NSThread.sleepForTimeInterval(ms/1000); }
189
+ function mev(t){ return $.CGEventCreateMouseEvent($(), t, $.CGPointMake(${xi}, ${yi}), 0); }
190
+ $.CGEventPost($.kCGHIDEventTap, mev(5)); sleep(20);
191
+ $.CGEventPost($.kCGHIDEventTap, mev(1)); sleep(15);
192
+ $.CGEventPost($.kCGHIDEventTap, mev(2));`;
193
+ }
194
+ /** The command that posts an AX-resolved mouse click (one stable JXA shape). */
195
+ function clickPointCommand(x, y, label) {
196
+ return { primitive: "click-point", label, lang: "javascript", script: jxaClickScript(x, y) };
197
+ }
198
+ /**
199
+ * set-datetime: set the Repeat dialog's `AXDateTimeArea` (reminder time / "ends
200
+ * on date" bound) via the ObjC AX bridge. Things' date/time control holds an
201
+ * NSDate, and System Events cannot write it (`set value … to <date>` → -10000,
202
+ * UIC6), so — like the mouse-synthesis primitive — this runs in JXA and calls
203
+ * `AXUIElementSetAttributeValue(…, AXValue, <NSDate>)` directly. The control is
204
+ * found by ROLE within Things' front dialog (there is exactly one during a
205
+ * reminder/end-date step; the matrix never sets both at once), polled briefly
206
+ * so it is caught right after the checkbox/pop-up that reveals it, and the
207
+ * script THROWS when absent so the driver fails closed. `spec` is
208
+ * `time:HH:mm` (keep the control's date, overwrite the time-of-day) or
209
+ * `date:YYYY-MM-DD` (overwrite the date at midnight). One stable JXA shape.
210
+ */
211
+ export function axSetDateTimeScript(spec) {
212
+ return `ObjC.import('Foundation'); ObjC.import('AppKit'); ObjC.import('ApplicationServices');
213
+ function attr(el,name){ var out=Ref(); if($.AXUIElementCopyAttributeValue(el,$(name),out)!==0) return null; return ObjC.castRefToObject(out[0]); }
214
+ function rolestr(el){ var v=attr(el,'AXRole'); return v? v.js : ''; }
215
+ function kids(el){ var c=attr(el,'AXChildren'); if(!c) return []; var a=[]; for(var i=0;i<c.count;i++) a.push(c.objectAtIndex(i)); return a; }
216
+ function find(el,role,depth){ if(depth<0) return null; if(rolestr(el)===role) return el; var ks=kids(el); for(var i=0;i<ks.length;i++){ var r=find(ks[i],role,depth-1); if(r) return r;} return null; }
217
+ function run(){
218
+ var apps=$.NSRunningApplication.runningApplicationsWithBundleIdentifier('com.culturedcode.ThingsMac');
219
+ if(!apps || apps.count===0) throw new Error('Things not running');
220
+ var pid=apps.objectAtIndex(0).processIdentifier;
221
+ var app=$.AXUIElementCreateApplication(pid);
222
+ var dt=null;
223
+ for(var t=0;t<20 && !dt;t++){ dt=find(app,'AXDateTimeArea',16); if(!dt) $.NSThread.sleepForTimeInterval(0.1); }
224
+ if(!dt) throw new Error('no AXDateTimeArea in the Repeat dialog');
225
+ var spec=${JSON.stringify(spec)};
226
+ var cal=$.NSCalendar.currentCalendar;
227
+ var d;
228
+ if(spec.indexOf('time:')===0){
229
+ // Set the time-of-day on the control's own (today's) date via the purpose-
230
+ // built calendar API — component-bag mutation via JXA silently drops the
231
+ // hour, leaking the current wall-clock hour into the reminder (UIC6).
232
+ var cur=attr(dt,'AXValue'); if(!cur) throw new Error('date/time control has no value');
233
+ var hm=spec.slice(5).split(':');
234
+ d=cal.dateBySettingHourMinuteSecondOfDateOptions(+hm[0], +hm[1], 0, cur, 0);
235
+ } else if(spec.indexOf('date:')===0){
236
+ var ymd=spec.slice(5).split('-');
237
+ var comps=$.NSDateComponents.alloc.init;
238
+ comps.year=+ymd[0]; comps.month=+ymd[1]; comps.day=+ymd[2]; comps.hour=0; comps.minute=0; comps.second=0;
239
+ d=cal.dateFromComponents(comps);
240
+ } else { throw new Error('bad datetime spec: '+spec); }
241
+ if(!d) throw new Error('could not build date from '+spec);
242
+ var err=$.AXUIElementSetAttributeValue(dt,$('AXValue'),d);
243
+ if(err!==0) throw new Error('AXValue set failed err='+err);
244
+ $.NSThread.sleepForTimeInterval(0.2);
245
+ return 'OK';
246
+ }`;
247
+ }
248
+ /** Parse a resolve-frame "x y w h" line into the frame's center point. */
249
+ export function parseFrameCenter(stdout) {
250
+ const nums = stdout.trim().split(/\s+/).map(Number);
251
+ if (nums.length !== 4 || nums.some((n) => !Number.isFinite(n)))
252
+ return null;
253
+ const [x, y, w, h] = nums;
254
+ return { x: x + w / 2, y: y + h / 2 };
255
+ }
256
+ function revealUrl(uuid) {
257
+ return `things:///show?id=${encodeURIComponent(uuid)}`;
258
+ }
259
+ function defaultRun(command, timeoutMs) {
260
+ return new Promise((resolve) => {
261
+ let bin;
262
+ let args;
263
+ if (command.primitive === "reveal") {
264
+ [bin, args] = ["open", [command.url ?? ""]];
265
+ }
266
+ else if (command.lang === "javascript") {
267
+ // JXA (ObjC bridge) for the mouse-synthesis primitive; one stable shape.
268
+ [bin, args] = ["osascript", ["-l", "JavaScript", "-e", command.script ?? ""]];
269
+ }
270
+ else {
271
+ [bin, args] = ["osascript", ["-e", command.script ?? ""]];
272
+ }
273
+ execFile(bin, [...args], { timeout: timeoutMs }, (err, stdout, stderr) => {
274
+ const timedOut = err !== null && err.killed === true;
275
+ resolve({
276
+ ok: err === null,
277
+ stdout: String(stdout),
278
+ stderr: String(stderr),
279
+ ...(timedOut && { timedOut: true }),
280
+ });
281
+ });
282
+ });
283
+ }
284
+ /** The element paths the preflight canary resolves (static steps only). */
285
+ function canaryPaths(recipe) {
286
+ const out = [];
287
+ for (const step of recipe.steps) {
288
+ if (step.dynamic === true)
289
+ continue;
290
+ if (step.primitive !== "press" &&
291
+ step.primitive !== "set-value" &&
292
+ step.primitive !== "resolve" &&
293
+ step.primitive !== "click-element" &&
294
+ step.primitive !== "select-row") {
295
+ continue;
296
+ }
297
+ // A candidate-addressed step is resolved at run time (its element is
298
+ // dynamic by construction), so it is never canaried here.
299
+ if (step.pathCandidates !== undefined)
300
+ continue;
301
+ const path = step.canaryPath ?? step.path;
302
+ if (path !== undefined)
303
+ out.push({ path, label: step.label });
304
+ }
305
+ return out;
306
+ }
307
+ /**
308
+ * Resolve a step's effective element path. A `pathCandidates` step dispatches
309
+ * against the FIRST candidate that exists (the dialog-form disjunction — attached
310
+ * sheet vs detached AXUnknown window, UIC4-a). The candidates are POLLED over a
311
+ * bounded window because the full-vocabulary controls are REVEALED by the
312
+ * preceding step: switching the frequency pop-up to weekly/monthly/yearly (or
313
+ * ticking Ends=after) re-lays-out the cadence group, and the new pop-up/field
314
+ * lands ~250 ms later (UIC6). A single immediate exists-check races that render
315
+ * and would spuriously fail closed; polling matches the `dynamic` nature these
316
+ * steps already declare. Returns null when none resolve within the window.
317
+ */
318
+ async function resolveStepPath(step, run) {
319
+ if (step.pathCandidates === undefined)
320
+ return step.path ?? null;
321
+ const candidates = step.pathCandidates;
322
+ const deadline = Date.now() + (step.timeoutMs ?? RESOLVE_CANDIDATE_TIMEOUT_MS);
323
+ for (;;) {
324
+ for (const candidate of candidates) {
325
+ // candidates are tried in priority order; the first hit wins, so a race would blur which form matched
326
+ const res = await run({ primitive: "resolve", label: step.label, script: axResolveScript(candidate) }, STEP_TIMEOUT_MS);
327
+ if (res.ok && res.stdout.trim() === "true")
328
+ return candidate;
329
+ }
330
+ if (Date.now() >= deadline)
331
+ return null;
332
+ // the revealed control lands a beat after the mode switch; poll until it does
333
+ await new Promise((r) => setTimeout(r, WAIT_POLL_MS));
334
+ }
335
+ }
336
+ function refusal(detail) {
337
+ return { exitCode: 1, stdout: "", stderr: detail };
338
+ }
339
+ /** Compile one recipe step into its primitive command (no dispatch). */
340
+ export function commandForStep(step, targetUuid) {
341
+ switch (step.primitive) {
342
+ case "reveal":
343
+ return { primitive: "reveal", label: step.label, url: revealUrl(step.value ?? targetUuid) };
344
+ case "activate":
345
+ return { primitive: "activate", label: step.label, script: axActivateScript() };
346
+ case "press":
347
+ return { primitive: "press", label: step.label, script: axPressScript(step.path ?? "") };
348
+ case "resolve":
349
+ return { primitive: "resolve", label: step.label, script: axResolveScript(step.path ?? "") };
350
+ case "set-value":
351
+ return {
352
+ primitive: "set-value",
353
+ label: step.label,
354
+ script: axSetValueScript(step.path ?? "", step.value ?? ""),
355
+ };
356
+ case "select-popup":
357
+ return {
358
+ primitive: "select-popup",
359
+ label: step.label,
360
+ script: axSelectPopupScript(step.path ?? "", step.value ?? ""),
361
+ };
362
+ case "set-datetime":
363
+ return {
364
+ primitive: "set-datetime",
365
+ label: step.label,
366
+ lang: "javascript",
367
+ script: axSetDateTimeScript(step.value ?? ""),
368
+ };
369
+ case "wait":
370
+ return { primitive: "wait", label: step.label, script: axResolveScript(step.path ?? "") };
371
+ case "select-row":
372
+ return {
373
+ primitive: "select-row",
374
+ label: step.label,
375
+ script: axSelectRowScript(step.path ?? "", step.value ?? ""),
376
+ };
377
+ case "key":
378
+ return { primitive: "key", label: step.label, script: axKeyScript(step.keys ?? "") };
379
+ case "click-element":
380
+ // Phase 1 of the click: read the target's frame. driveClickElement runs
381
+ // this, then posts the click at the resolved center and asserts the outcome.
382
+ return {
383
+ primitive: "resolve-frame",
384
+ label: step.label,
385
+ lang: "applescript",
386
+ script: axFrameScript(step.path ?? ""),
387
+ };
388
+ case "drag-reorder":
389
+ // Composite step: drive() hands it to the sidebar drag driver, which
390
+ // dispatches its own snapshot/scroll/drag commands through `run`. This
391
+ // shape only exists so the step renders/compiles uniformly.
392
+ return {
393
+ primitive: "sidebar-snapshot",
394
+ label: step.label,
395
+ lang: "javascript",
396
+ script: jxaSidebarSnapshotScript(),
397
+ };
398
+ }
399
+ }
400
+ /**
401
+ * Execute a `click-element` step: resolve the target's AX frame, synthesize a
402
+ * mouse click at its center, then verify the declared post-click outcome. Fails
403
+ * closed at every stage — a missing frame aborts BEFORE any click (no guessed
404
+ * pixel is ever clicked); a missing post-click element dismisses whatever opened
405
+ * (Escape) and aborts.
406
+ */
407
+ async function driveClickElement(step, run) {
408
+ const frameRes = await run(commandForStep(step, ""), STEP_TIMEOUT_MS);
409
+ const center = frameRes.ok ? parseFrameCenter(frameRes.stdout) : null;
410
+ if (center === null) {
411
+ return {
412
+ ok: false,
413
+ why: "its on-screen position did not resolve — a Things update may have moved the control, " +
414
+ "or the app is not in the expected state; no click was sent",
415
+ };
416
+ }
417
+ const clickRes = await run(clickPointCommand(center.x, center.y, step.label), STEP_TIMEOUT_MS);
418
+ if (!clickRes.ok) {
419
+ return {
420
+ ok: false,
421
+ why: clickRes.timedOut === true
422
+ ? "the click timed out"
423
+ : clickRes.stderr.trim() || "the click failed",
424
+ needsAbort: true,
425
+ };
426
+ }
427
+ if (step.assertPath !== undefined) {
428
+ const ok = await waitForElement({
429
+ primitive: "wait",
430
+ label: step.assertLabel ?? step.label,
431
+ script: axResolveScript(step.assertPath),
432
+ }, step.assertTimeoutMs ?? STEP_TIMEOUT_MS, run);
433
+ if (!ok) {
434
+ return {
435
+ ok: false,
436
+ why: `${step.assertLabel ?? "the expected element"} did not appear after the click`,
437
+ needsAbort: true,
438
+ };
439
+ }
440
+ }
441
+ return { ok: true };
442
+ }
443
+ async function drive(recipe, run, aux) {
444
+ const done = [];
445
+ const abort = () => run({ primitive: "key", label: "abort (Escape)", script: axAbortScript() }, STEP_TIMEOUT_MS);
446
+ const partial = (failed, why, dismissed = true) => refusal(`ui drive stopped at "${failed}" (${why}). Completed: ${done.join(" → ") || "nothing"}.` +
447
+ (dismissed ? " The open sheet/popover was dismissed (Escape)." : ""));
448
+ // 0. Run the leading reveal/activate preamble BEFORE the canary. The Items
449
+ // menu is context-dependent — its Repeat submenu (and the plain "Repeat…"
450
+ // item) only materialize once a matching item is SELECTED (UIC1). Resolving
451
+ // those menu paths in the canary is only meaningful after the reveal has
452
+ // selected the target, so the preamble must run first.
453
+ let idx = 0;
454
+ while (idx < recipe.steps.length &&
455
+ (recipe.steps[idx]?.primitive === "reveal" || recipe.steps[idx]?.primitive === "activate")) {
456
+ const step = recipe.steps[idx];
457
+ // the preamble steps are strictly sequential (select, then foreground) and each must land before the next
458
+ const res = await run(commandForStep(step, recipe.targetUuid), STEP_TIMEOUT_MS);
459
+ if (!res.ok) {
460
+ return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed");
461
+ }
462
+ done.push(step.label);
463
+ idx += 1;
464
+ }
465
+ // Let the selection settle so the menu bar repopulates before the canary reads it.
466
+ if (idx > 0)
467
+ await new Promise((r) => setTimeout(r, SETTLE_AFTER_REVEAL_MS));
468
+ // 1. Recipe canary: resolve every statically-reachable element (now that the
469
+ // target is selected). A miss refuses the whole drive before anything is
470
+ // pressed. (This is also the localization check: English titles must resolve.)
471
+ for (const { path, label } of canaryPaths(recipe)) {
472
+ // the canary resolves elements one at a time; a single miss aborts before anything is pressed, so parallelizing would waste work and blur which element failed
473
+ const res = await run({ primitive: "resolve", label, script: axResolveScript(path) }, STEP_TIMEOUT_MS);
474
+ if (!res.ok || res.stdout.trim() !== "true") {
475
+ return refusal(`ui preflight refused: element for "${label}" did not resolve (${path}) — a Things ` +
476
+ "update may have changed the menu, Accessibility may not be granted, Things may not " +
477
+ "be running, or the app may not be in English. Nothing was pressed.");
478
+ }
479
+ }
480
+ // 2. Execute the remaining steps in order; a dynamic element is waited-for.
481
+ for (let i = idx; i < recipe.steps.length; i += 1) {
482
+ let step = recipe.steps[i];
483
+ if (step.primitive === "wait") {
484
+ // A candidate-addressed wait polls for ANY of its shapes to appear (the
485
+ // dialog opening as an attached sheet OR a detached AXUnknown window).
486
+ // steps are strictly sequential: this wait must resolve before the step that acts on the awaited element runs
487
+ const ok = await waitForAnyElement(step.pathCandidates ?? [step.path ?? ""], step.label, step.timeoutMs ?? STEP_TIMEOUT_MS, run);
488
+ if (!ok) {
489
+ // the abort keystroke must land before returning the partial-state report
490
+ await abort();
491
+ return partial(step.label, "the expected element never appeared within the timeout");
492
+ }
493
+ done.push(step.label);
494
+ continue;
495
+ }
496
+ if (step.primitive === "drag-reorder") {
497
+ // The sidebar drag driver runs its own snapshot → scroll → drag →
498
+ // DB-assert ladder (ui-drag.ts); every gesture anchors on frames it
499
+ // resolves live, and a failed assert triggers a verified recovery drag.
500
+ if (step.drag === undefined)
501
+ return partial(step.label, "no drag spec compiled", false);
502
+ // the drag ladder depends on the UI state the preamble produced
503
+ const outcome = await driveSidebarAreaReorder(step.drag, run, aux);
504
+ if (!outcome.ok)
505
+ return partial(step.label, outcome.detail, false);
506
+ done.push(`${step.label} (${outcome.detail})`);
507
+ continue;
508
+ }
509
+ // Resolve a candidate-addressed step's effective element before dispatch
510
+ // (the sheet-vs-detached-window disjunction). A miss fails closed.
511
+ if (step.pathCandidates !== undefined) {
512
+ // the effective form must be resolved before this step can act on it
513
+ const effective = await resolveStepPath(step, run);
514
+ if (effective === null) {
515
+ // dismiss whatever opened before reporting
516
+ await abort();
517
+ return partial(step.label, "none of its expected element shapes resolved (neither the attached sheet nor the " +
518
+ "detached repeat editor window)");
519
+ }
520
+ step = { ...step, path: effective };
521
+ }
522
+ const command = commandForStep(step, recipe.targetUuid);
523
+ if (step.primitive === "select-row") {
524
+ // Pure-AX row selection with readback verification (UIC4-a): "OK" only
525
+ // when a row selected to the target title.
526
+ // the selection must land before the menu that acts on it is pressed
527
+ const res = await run(command, STEP_TIMEOUT_MS);
528
+ if (!res.ok || res.stdout.trim() !== "OK") {
529
+ // clear any transient state before reporting
530
+ await abort();
531
+ return partial(step.label, res.ok
532
+ ? "no content-table row selected to the target project's title — it may not be a " +
533
+ "selectable row in this view, or its title changed"
534
+ : res.timedOut === true
535
+ ? "the row-selection step timed out"
536
+ : res.stderr.trim() || "the row-selection step failed");
537
+ }
538
+ done.push(step.label);
539
+ continue;
540
+ }
541
+ if (step.primitive === "click-element") {
542
+ // A mouse click at an AX-resolved frame center (the NATIVE1 primitive),
543
+ // used only where AXPress is inert (Things' custom `…`/repeat-bar popover).
544
+ // the click depends on the UI state the previous step produced
545
+ const outcome = await driveClickElement(step, run);
546
+ if (!outcome.ok) {
547
+ // dismiss whatever the click opened before reporting
548
+ if (outcome.needsAbort === true)
549
+ await abort();
550
+ return partial(step.label, outcome.why ?? "the click failed");
551
+ }
552
+ done.push(step.label);
553
+ continue;
554
+ }
555
+ // each recipe step depends on the UI state the previous step produced; they cannot be parallelized
556
+ const res = await run(command, STEP_TIMEOUT_MS);
557
+ if (!res.ok) {
558
+ // dismiss the half-open sheet/popover before reporting partial state
559
+ if (step.primitive !== "reveal" && step.primitive !== "activate")
560
+ await abort();
561
+ return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed");
562
+ }
563
+ done.push(step.label);
564
+ }
565
+ return { exitCode: 0, stdout: `drove ${done.length} step(s): ${done.join(" → ")}`, stderr: "" };
566
+ }
567
+ async function waitForElement(command, timeoutMs, run) {
568
+ const deadline = Date.now() + timeoutMs;
569
+ for (;;) {
570
+ // polling the same element until it appears is inherently sequential
571
+ const res = await run(command, STEP_TIMEOUT_MS);
572
+ if (res.ok && res.stdout.trim() === "true")
573
+ return true;
574
+ if (Date.now() >= deadline)
575
+ return false;
576
+ // inter-poll delay between sequential existence checks
577
+ await new Promise((r) => setTimeout(r, WAIT_POLL_MS));
578
+ }
579
+ }
580
+ /** Poll until ANY of the candidate element shapes exists (the sheet-vs-detached-window disjunction). */
581
+ async function waitForAnyElement(paths, label, timeoutMs, run) {
582
+ const deadline = Date.now() + timeoutMs;
583
+ for (;;) {
584
+ for (const path of paths) {
585
+ // Emitted as the `wait` primitive (not `resolve`) so the command stream a
586
+ // caller observes is unchanged from the single-path waitForElement.
587
+ // candidates checked in priority order; the first present shape ends the wait
588
+ const res = await run({ primitive: "wait", label, script: axResolveScript(path) }, STEP_TIMEOUT_MS);
589
+ if (res.ok && res.stdout.trim() === "true")
590
+ return true;
591
+ }
592
+ if (Date.now() >= deadline)
593
+ return false;
594
+ // inter-poll delay between sequential existence checks
595
+ await new Promise((r) => setTimeout(r, WAIT_POLL_MS));
596
+ }
597
+ }
598
+ function enabledMatrix() {
599
+ const matrix = {};
600
+ for (const op of UI_DRIVE_OPS) {
601
+ const cert = certificationOf(op);
602
+ matrix[op] = {
603
+ support: "yes",
604
+ // The most-disruptive tier: the drive foregrounds Things and takes over
605
+ // UI focus. The `dangerouslyDriveGui` ack lifts the disruption ceiling.
606
+ disruption: 3,
607
+ // The RECIPE is wired and lab-derived (validated for planning); on-device
608
+ // CERTIFICATION is a separate axis surfaced by `things capabilities`.
609
+ validation: "validated",
610
+ ...(cert !== undefined && { evidence: cert.evidence }),
611
+ notes: `drives the Things app through the Accessibility API (${cert?.status ?? "uncertified"}` +
612
+ " — recipe element paths pending on-device confirmation); menu-path element presses do not " +
613
+ "steal focus and work under a locked session (AXVM1), while ops that open Things' custom " +
614
+ "repeat menus additionally move the pointer, bring the app to the foreground, and need an " +
615
+ "unlocked session with the display awake (NATIVE1)",
616
+ };
617
+ }
618
+ return matrix;
619
+ }
620
+ function disabledMatrix() {
621
+ const matrix = {};
622
+ for (const op of UI_DRIVE_OPS) {
623
+ matrix[op] = {
624
+ support: "no",
625
+ disruption: 3,
626
+ validation: "validated",
627
+ notes: "the Accessibility GUI vector is off on this machine — enable it with `things config " +
628
+ "set ui-enabled true`, then grant Accessibility to this process (see docs/setup.md). " +
629
+ "It drives the local Things GUI and is intended for a dedicated always-on Mac.",
630
+ };
631
+ }
632
+ return matrix;
633
+ }
634
+ /**
635
+ * The ui vector. Config-gated: when `ui.enabled` is false the matrix reports
636
+ * every op unsupported (with a remediation naming the config key + setup doc),
637
+ * so the operation is never dispatched. When enabled, `execute` runs the
638
+ * compiled recipe fail-closed.
639
+ */
640
+ export function createUiVector(config, run = defaultRun, aux = {}) {
641
+ const enabled = config.ui.enabled;
642
+ return {
643
+ id: "ui",
644
+ matrix: enabled ? enabledMatrix() : disabledMatrix(),
645
+ async execute(invocation) {
646
+ if (!enabled) {
647
+ return refusal("the ui vector is disabled (`things config set ui-enabled true` to enable it).");
648
+ }
649
+ if (invocation.recipe === undefined) {
650
+ return refusal("ui invocation carried no recipe (compile bug).");
651
+ }
652
+ return drive(invocation.recipe, run, aux);
653
+ },
654
+ };
655
+ }
656
+ //# sourceMappingURL=ui.js.map