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