things-api 0.18.0 → 0.19.1
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 +18 -9
- package/deputy/AppIcon.icns +0 -0
- package/deputy/VERSION +1 -1
- package/deputy/helpers-Info.plist +4 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/CodeResources +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/Info.plist +3 -1
- package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/MacOS/things-reader +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/Resources/AppIcon.icns +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/_CodeSignature/CodeResources +15 -2
- package/deputy/prebuilt/Things API Helper.app/Contents/Info.plist +5 -1
- package/deputy/prebuilt/Things API Helper.app/Contents/MacOS/things-deputy +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/Resources/AppIcon.icns +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/_CodeSignature/CodeResources +14 -2
- package/deputy/reader/Info.plist +2 -0
- package/deputy/reader/entitlements.plist +30 -0
- package/deputy/reader/main.swift +129 -40
- package/deputy/src/server.swift +25 -2
- package/deputy/src/tcc.swift +58 -0
- package/dist/audit/schema.d.ts +30 -1
- package/dist/audit/schema.js.map +1 -1
- package/dist/capability.d.ts +322 -0
- package/dist/capability.js +706 -0
- package/dist/capability.js.map +1 -0
- package/dist/cli/commands/doctor.d.ts +13 -1
- package/dist/cli/commands/doctor.js +141 -5
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/helpers.js +189 -51
- package/dist/cli/commands/helpers.js.map +1 -1
- package/dist/cli/commands/mcp.js +8 -0
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/commands/op-result.js +6 -0
- package/dist/cli/commands/op-result.js.map +1 -1
- package/dist/cli/commands/reads.js +47 -1
- package/dist/cli/commands/reads.js.map +1 -1
- package/dist/cli/commands/repeat-flags.js +34 -2
- package/dist/cli/commands/repeat-flags.js.map +1 -1
- package/dist/cli/commands/setup.d.ts +9 -0
- package/dist/cli/commands/setup.js +76 -83
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/commands/writes.d.ts +1 -1
- package/dist/cli/commands/writes.js +311 -142
- package/dist/cli/commands/writes.js.map +1 -1
- package/dist/cli/dry-run.d.ts +1 -1
- package/dist/cli/dry-run.js +1 -1
- package/dist/cli/glyphs.d.ts +23 -4
- package/dist/cli/glyphs.js +46 -19
- package/dist/cli/glyphs.js.map +1 -1
- package/dist/cli/help.d.ts +1 -1
- package/dist/cli/help.js +49 -27
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/interrupt.d.ts +54 -6
- package/dist/cli/interrupt.js +78 -12
- package/dist/cli/interrupt.js.map +1 -1
- package/dist/cli/main.js +8 -5
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/period.d.ts +3 -1
- package/dist/cli/period.js +1 -1
- package/dist/cli/period.js.map +1 -1
- package/dist/cli/read-driver.js +26 -1
- package/dist/cli/read-driver.js.map +1 -1
- package/dist/cli/render.d.ts +31 -4
- package/dist/cli/render.js +127 -15
- package/dist/cli/render.js.map +1 -1
- package/dist/client.d.ts +23 -3
- package/dist/client.js +36 -13
- package/dist/client.js.map +1 -1
- package/dist/contracts.d.ts +8 -4
- package/dist/contracts.js +1 -1
- package/dist/contracts.js.map +1 -1
- package/dist/deputy/install.d.ts +234 -6
- package/dist/deputy/install.js +843 -31
- package/dist/deputy/install.js.map +1 -1
- package/dist/deputy/notices.js +2 -2
- package/dist/deputy/notices.js.map +1 -1
- package/dist/deputy/protocol.d.ts +59 -5
- package/dist/deputy/protocol.js +48 -11
- package/dist/deputy/protocol.js.map +1 -1
- package/dist/deputy/routing.d.ts +37 -0
- package/dist/deputy/routing.js +181 -27
- package/dist/deputy/routing.js.map +1 -1
- package/dist/deputy/wake.d.ts +61 -0
- package/dist/deputy/wake.js +149 -0
- package/dist/deputy/wake.js.map +1 -0
- package/dist/diagnose.d.ts +19 -4
- package/dist/diagnose.js +64 -8
- package/dist/diagnose.js.map +1 -1
- package/dist/direct-setup.d.ts +104 -0
- package/dist/direct-setup.js +658 -0
- package/dist/direct-setup.js.map +1 -0
- package/dist/host-access.d.ts +75 -0
- package/dist/host-access.js +177 -0
- package/dist/host-access.js.map +1 -0
- package/dist/index.d.ts +20 -5
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.d.ts +27 -1
- package/dist/mcp/server.js +308 -109
- package/dist/mcp/server.js.map +1 -1
- package/dist/op-result.d.ts +9 -0
- package/dist/op-result.js +13 -1
- package/dist/op-result.js.map +1 -1
- package/dist/read/filter-contract.d.ts +1 -1
- package/dist/read/filter-contract.js +4 -0
- package/dist/read/filter-contract.js.map +1 -1
- package/dist/read/shape.js +4 -0
- package/dist/read/shape.js.map +1 -1
- package/dist/read/views.d.ts +29 -0
- package/dist/read/views.js +60 -0
- package/dist/read/views.js.map +1 -1
- package/dist/session-grant.d.ts +48 -0
- package/dist/session-grant.js +185 -0
- package/dist/session-grant.js.map +1 -0
- package/dist/surface-copy.d.ts +19 -3
- package/dist/surface-copy.js +19 -3
- package/dist/surface-copy.js.map +1 -1
- package/dist/wizard.d.ts +58 -0
- package/dist/wizard.js +174 -0
- package/dist/wizard.js.map +1 -0
- package/dist/write/availability.d.ts +1 -16
- package/dist/write/availability.js +10 -65
- package/dist/write/availability.js.map +1 -1
- package/dist/write/batch.d.ts +20 -0
- package/dist/write/batch.js +111 -21
- package/dist/write/batch.js.map +1 -1
- package/dist/write/capabilities.d.ts +9 -0
- package/dist/write/capabilities.js +2 -0
- package/dist/write/capabilities.js.map +1 -1
- package/dist/write/clear-reminder.js +1 -1
- package/dist/write/clear-reminder.js.map +1 -1
- package/dist/write/commands.d.ts +22 -7
- package/dist/write/commands.js +231 -254
- package/dist/write/commands.js.map +1 -1
- package/dist/write/failure-hints.d.ts +32 -7
- package/dist/write/failure-hints.js +9 -14
- package/dist/write/failure-hints.js.map +1 -1
- package/dist/write/guards.js +22 -4
- package/dist/write/guards.js.map +1 -1
- package/dist/write/lock.d.ts +16 -13
- package/dist/write/lock.js +55 -0
- package/dist/write/lock.js.map +1 -1
- package/dist/write/make-repeating-project.js +9 -13
- package/dist/write/make-repeating-project.js.map +1 -1
- package/dist/write/move.js +9 -12
- package/dist/write/move.js.map +1 -1
- package/dist/write/operations.d.ts +51 -29
- package/dist/write/operations.js +16 -0
- package/dist/write/operations.js.map +1 -1
- package/dist/write/opid.d.ts +72 -14
- package/dist/write/opid.js +152 -8
- package/dist/write/opid.js.map +1 -1
- package/dist/write/param-schema.d.ts +80 -0
- package/dist/write/param-schema.js +754 -0
- package/dist/write/param-schema.js.map +1 -0
- package/dist/write/pipeline.d.ts +110 -5
- package/dist/write/pipeline.js +288 -14
- package/dist/write/pipeline.js.map +1 -1
- package/dist/write/pre-state.d.ts +38 -16
- package/dist/write/pre-state.js +117 -22
- package/dist/write/pre-state.js.map +1 -1
- package/dist/write/promote-clone.js +658 -444
- package/dist/write/promote-clone.js.map +1 -1
- package/dist/write/reorder.js +25 -8
- package/dist/write/reorder.js.map +1 -1
- package/dist/write/repeat-collateral.d.ts +31 -0
- package/dist/write/repeat-collateral.js +153 -0
- package/dist/write/repeat-collateral.js.map +1 -0
- package/dist/write/repeat-rule.d.ts +28 -1
- package/dist/write/repeat-rule.js +54 -4
- package/dist/write/repeat-rule.js.map +1 -1
- package/dist/write/resolution-timestamps.d.ts +11 -0
- package/dist/write/resolution-timestamps.js +108 -40
- package/dist/write/resolution-timestamps.js.map +1 -1
- package/dist/write/reversibility.js +4 -0
- package/dist/write/reversibility.js.map +1 -1
- package/dist/write/template-mutation.d.ts +137 -0
- package/dist/write/template-mutation.js +598 -0
- package/dist/write/template-mutation.js.map +1 -0
- package/dist/write/undo.js +10 -20
- package/dist/write/undo.js.map +1 -1
- package/dist/write/update-fields.d.ts +151 -0
- package/dist/write/update-fields.js +427 -0
- package/dist/write/update-fields.js.map +1 -0
- package/dist/write/vectors/applescript.js +6 -10
- package/dist/write/vectors/applescript.js.map +1 -1
- package/dist/write/vectors/registry.d.ts +1 -1
- package/dist/write/vectors/registry.js +10 -2
- package/dist/write/vectors/registry.js.map +1 -1
- package/dist/write/vectors/shortcuts.js +2 -2
- package/dist/write/vectors/shortcuts.js.map +1 -1
- package/dist/write/vectors/simulator.js +15 -4
- package/dist/write/vectors/simulator.js.map +1 -1
- package/dist/write/vectors/types.d.ts +268 -1
- package/dist/write/vectors/ui-certification.js +63 -13
- package/dist/write/vectors/ui-certification.js.map +1 -1
- package/dist/write/vectors/ui-chord.d.ts +144 -0
- package/dist/write/vectors/ui-chord.js +428 -0
- package/dist/write/vectors/ui-chord.js.map +1 -0
- package/dist/write/vectors/ui-drag.d.ts +8 -4
- package/dist/write/vectors/ui-drag.js +2 -0
- package/dist/write/vectors/ui-drag.js.map +1 -1
- package/dist/write/vectors/ui-recipes.d.ts +70 -3
- package/dist/write/vectors/ui-recipes.js +482 -44
- package/dist/write/vectors/ui-recipes.js.map +1 -1
- package/dist/write/vectors/ui.d.ts +280 -10
- package/dist/write/vectors/ui.js +1068 -77
- package/dist/write/vectors/ui.js.map +1 -1
- package/dist/write/vectors/url-scheme.js +3 -0
- package/dist/write/vectors/url-scheme.js.map +1 -1
- package/dist/write/verify/delta.d.ts +62 -0
- package/dist/write/verify/delta.js +36 -2
- package/dist/write/verify/delta.js.map +1 -1
- package/dist/write/verify/poller.d.ts +4 -1
- package/dist/write/verify/poller.js +14 -0
- package/dist/write/verify/poller.js.map +1 -1
- package/package.json +2 -1
- package/schema/envelope.schema.json +6 -4
- package/scripts/build-helpers.sh +5 -0
- package/skills/things-cli/SKILL.md +5 -3
- package/skills/things-cli/references/contracts.md +1 -0
- package/skills/things-cli/references/data-model.md +3 -0
- package/skills/things-cli/references/errors.md +1 -1
- package/skills/things-cli/references/gui.md +1 -0
- package/skills/things-cli/references/repeating.md +6 -0
- package/skills/things-cli/references/tag-cleanup.md +64 -0
package/dist/write/vectors/ui.js
CHANGED
|
@@ -33,6 +33,7 @@ import { UI_DRIVE_OPS } from "../operations.js";
|
|
|
33
33
|
import { escapeAppleScript } from "./applescript.js";
|
|
34
34
|
import { createReachabilityCache, H_UI_SESSION_UNREACHABLE, probeSessionReachability, } from "./session-reachability.js";
|
|
35
35
|
import { certificationOf } from "./ui-certification.js";
|
|
36
|
+
import { chordCommand, driveHeadingChordReorder } from "./ui-chord.js";
|
|
36
37
|
import { driveSidebarAreaReorder, jxaSidebarSnapshotScript } from "./ui-drag.js";
|
|
37
38
|
/** GUI driving can stall on an unanswered sheet; give each step headroom. */
|
|
38
39
|
const STEP_TIMEOUT_MS = 15_000;
|
|
@@ -72,6 +73,197 @@ const SETTLE_AFTER_REVEAL_MS = 1000;
|
|
|
72
73
|
*/
|
|
73
74
|
const SHAPE_UNPROBED = "the Repeat dialog's shape was never measured, so this control's address is unknown (recipe bug)";
|
|
74
75
|
const SE = `tell application "System Events" to tell process "Things3"`;
|
|
76
|
+
/**
|
|
77
|
+
* The cadence group's re-layout settle budget (BEEP1) — how many times
|
|
78
|
+
* `set-group-number` re-reads the group's shape looking for two consecutive
|
|
79
|
+
* identical reads, and how long it waits between reads. ~4s of headroom against
|
|
80
|
+
* a re-layout measured to finish well inside 1.5s; the gate exits on the FIRST
|
|
81
|
+
* agreeing pair, so the common (already-settled) case costs one extra read.
|
|
82
|
+
*/
|
|
83
|
+
const SETTLE_READS = 40;
|
|
84
|
+
const SETTLE_POLL_S = 0.1;
|
|
85
|
+
/**
|
|
86
|
+
* Row tolerance (points) for every LABEL-ANCHORED field address here — how far a
|
|
87
|
+
* control's y may sit from its label's y and still count as the same row.
|
|
88
|
+
* Measured on Things 3.23 (CGRD1 §A census): `Every`@286 / interval@283,
|
|
89
|
+
* `Ends:`@375 / count@372, `days earlier`@413 / start-offset@409 — a 3–4pt
|
|
90
|
+
* baseline offset, so 8 is ~2× the observed worst case and well under the
|
|
91
|
+
* ~45pt row pitch of the cadence group.
|
|
92
|
+
*/
|
|
93
|
+
const ROW_TOLERANCE = 8;
|
|
94
|
+
/**
|
|
95
|
+
* How many of the Next: menu's own item titles a MISS reports back (NEXTPOP1).
|
|
96
|
+
* A refusal that names only the date the dialog lacked cannot distinguish "the
|
|
97
|
+
* rule genuinely does not produce that date" from "the menu was not the rule's"
|
|
98
|
+
* — the two failures read identically, and the second one cost a whole campaign
|
|
99
|
+
* to tell apart. Naming the pop-up's current value plus the first few options it
|
|
100
|
+
* offered makes the refusal self-diagnosing. Bounded so a 100-item level cannot
|
|
101
|
+
* flood the message.
|
|
102
|
+
*/
|
|
103
|
+
const SAMPLE_ITEMS = 5;
|
|
104
|
+
/**
|
|
105
|
+
* The `Next:` occurrence pop-up's asynchronous-recompute budget (NEXTPOP1) — how
|
|
106
|
+
* long {@link axSettleOccurrencesScript} waits for the dialog to absorb a rule
|
|
107
|
+
* change before the drive touches it again, and how often it looks. MEASURED at
|
|
108
|
+
* **0.4s** on golden-v4 / Things 3.23 (DIAG4: the control flipped from the seed's
|
|
109
|
+
* first occurrence to the anchor's between t+0.3s and t+0.4s), so the budget is
|
|
110
|
+
* ~3× the observed latency; the poll exits early the moment the control moves,
|
|
111
|
+
* which is the only case that costs anything.
|
|
112
|
+
*/
|
|
113
|
+
const OCCURRENCE_SETTLE_MS = 1200;
|
|
114
|
+
const OCCURRENCE_POLL_MS = 100;
|
|
115
|
+
/**
|
|
116
|
+
* The shared AppleScript handler prelude for every LABEL-ANCHORED field address
|
|
117
|
+
* in the Repeat dialog — the HXPC1 discrimination law in ONE place, so the
|
|
118
|
+
* pre-commit audit re-reads each field through the SAME address the drive wrote
|
|
119
|
+
* it through (a self-referential read-back is exactly what let #589's wrong-field
|
|
120
|
+
* write report OK).
|
|
121
|
+
*
|
|
122
|
+
* The laws, all measured on Things 3.23 / build 32300036 (CGRD1 §A census, and
|
|
123
|
+
* HXPC1 §A before it — docs/lab/cgrd1-precommit-audit.md, hxpc1-picker-assert.md):
|
|
124
|
+
*
|
|
125
|
+
* - The cadence group's numeric fields are identified by the LABEL ROW they sit
|
|
126
|
+
* on, never by index among the group's text fields. Selecting an "Ends: after"
|
|
127
|
+
* bound INSERTS the count AHEAD of the interval, so index 1 is a different
|
|
128
|
+
* control at different moments.
|
|
129
|
+
* - The interval is matched POSITIVELY, on the `Every` label's row. Every fixed
|
|
130
|
+
* frequency (daily/weekly/monthly/yearly) carries that label at y=286 with the
|
|
131
|
+
* interval at y=283. An AFTER-COMPLETION cadence group carries NEITHER an
|
|
132
|
+
* `Every` nor an `Ends:` label (census: its only static text is "after previous
|
|
133
|
+
* item is checked off.") and offers exactly ONE text field — so that shape falls
|
|
134
|
+
* to the sole-field rule, which is itself a uniqueness check, not an index.
|
|
135
|
+
* - Anything else FAILS CLOSED reporting the whole numeric-field inventory rather
|
|
136
|
+
* than typing a number into a field it cannot vouch for. An AX tree is an
|
|
137
|
+
* undocumented private surface: an unrecognized shape is a refusal, never a
|
|
138
|
+
* best guess.
|
|
139
|
+
*/
|
|
140
|
+
const AX_CADENCE_HANDLERS = `on cgLabelY(g, want)
|
|
141
|
+
tell application "System Events"
|
|
142
|
+
set outY to missing value
|
|
143
|
+
repeat with i from 1 to (count of static texts of g)
|
|
144
|
+
set sv to ""
|
|
145
|
+
try
|
|
146
|
+
set sv to (value of static text i of g) as text
|
|
147
|
+
end try
|
|
148
|
+
if sv is want then
|
|
149
|
+
-- bind the position, THEN index it: System Events refuses indexing a
|
|
150
|
+
-- position read inline from a specifier (-1700).
|
|
151
|
+
set labelPos to position of static text i of g
|
|
152
|
+
set outY to item 2 of labelPos
|
|
153
|
+
end if
|
|
154
|
+
end repeat
|
|
155
|
+
return outY
|
|
156
|
+
end tell
|
|
157
|
+
end cgLabelY
|
|
158
|
+
|
|
159
|
+
on cgInventory(g)
|
|
160
|
+
tell application "System Events"
|
|
161
|
+
set inv to ""
|
|
162
|
+
repeat with i from 1 to (count of text fields of g)
|
|
163
|
+
set fp to position of text field i of g
|
|
164
|
+
set inv to inv & " #" & i & "(y=" & (item 2 of fp) & ",shows=" & ((value of text field i of g) as text) & ")"
|
|
165
|
+
end repeat
|
|
166
|
+
if inv is "" then set inv to " (none)"
|
|
167
|
+
return inv
|
|
168
|
+
end tell
|
|
169
|
+
end cgInventory
|
|
170
|
+
|
|
171
|
+
on cgOnRow(g, y, tol, want)
|
|
172
|
+
tell application "System Events"
|
|
173
|
+
set hits to {}
|
|
174
|
+
repeat with i from 1 to (count of text fields of g)
|
|
175
|
+
set fp to position of text field i of g
|
|
176
|
+
set dy to (item 2 of fp) - y
|
|
177
|
+
if dy < 0 then set dy to -dy
|
|
178
|
+
set onRow to (dy <= tol)
|
|
179
|
+
if onRow is want then set end of hits to text field i of g
|
|
180
|
+
end repeat
|
|
181
|
+
return hits
|
|
182
|
+
end tell
|
|
183
|
+
end cgOnRow
|
|
184
|
+
|
|
185
|
+
on cgSettle(g)
|
|
186
|
+
-- SETTLE ON THE GROUP'S OWN SHAPE, never on a clock (determinism doctrine).
|
|
187
|
+
-- A frequency switch REBUILDS the cadence group. Two things go wrong when a
|
|
188
|
+
-- read starts too early: the row discrimination reads positions from controls
|
|
189
|
+
-- that are still moving, and keystrokes land on a field being torn down —
|
|
190
|
+
-- unhandled, so macOS beeps (BEEP1). Poll until two consecutive reads of the
|
|
191
|
+
-- group's label + field-position signature agree, then proceed.
|
|
192
|
+
tell application "System Events"
|
|
193
|
+
set sig to ""
|
|
194
|
+
set prevSig to "<none>"
|
|
195
|
+
repeat ${SETTLE_READS} times
|
|
196
|
+
set prevSig to sig
|
|
197
|
+
set sig to ""
|
|
198
|
+
repeat with i from 1 to (count of static texts of g)
|
|
199
|
+
set sv to ""
|
|
200
|
+
try
|
|
201
|
+
set sv to (value of static text i of g) as text
|
|
202
|
+
end try
|
|
203
|
+
set sig to sig & "|s:" & sv
|
|
204
|
+
end repeat
|
|
205
|
+
repeat with i from 1 to (count of text fields of g)
|
|
206
|
+
set fp to position of text field i of g
|
|
207
|
+
set sig to sig & "|f:" & (item 2 of fp)
|
|
208
|
+
end repeat
|
|
209
|
+
if sig is prevSig then return true
|
|
210
|
+
delay ${SETTLE_POLL_S}
|
|
211
|
+
end repeat
|
|
212
|
+
error "the Repeat dialog's cadence group is still re-laying out — its shape changed on every read; last seen" & sig
|
|
213
|
+
end tell
|
|
214
|
+
end cgSettle
|
|
215
|
+
|
|
216
|
+
on cgField(g, target, tol)
|
|
217
|
+
tell application "System Events"
|
|
218
|
+
set endsY to my cgLabelY(g, "Ends:")
|
|
219
|
+
set everyY to my cgLabelY(g, "Every")
|
|
220
|
+
set nf to (count of text fields of g)
|
|
221
|
+
if target is "ends-count" then
|
|
222
|
+
if endsY is missing value then error "the Repeat dialog's cadence group carries no \\"Ends:\\" label, so the ends-after count field cannot be identified — numeric fields:" & my cgInventory(g)
|
|
223
|
+
set hits to my cgOnRow(g, endsY, tol, true)
|
|
224
|
+
if (count of hits) is not 1 then error "the Repeat dialog offers " & (count of hits) & " field(s) on the \\"Ends:\\" row, expected exactly 1 — numeric fields:" & my cgInventory(g)
|
|
225
|
+
return item 1 of hits
|
|
226
|
+
end if
|
|
227
|
+
if everyY is not missing value then
|
|
228
|
+
set hits to my cgOnRow(g, everyY, tol, true)
|
|
229
|
+
if (count of hits) is not 1 then error "the Repeat dialog offers " & (count of hits) & " field(s) on the \\"Every\\" row, expected exactly 1 — numeric fields:" & my cgInventory(g)
|
|
230
|
+
return item 1 of hits
|
|
231
|
+
end if
|
|
232
|
+
if endsY is not missing value then
|
|
233
|
+
set hits to my cgOnRow(g, endsY, tol, false)
|
|
234
|
+
if (count of hits) is not 1 then error "the Repeat dialog offers " & (count of hits) & " field(s) off the \\"Ends:\\" row, expected exactly 1 — numeric fields:" & my cgInventory(g)
|
|
235
|
+
return item 1 of hits
|
|
236
|
+
end if
|
|
237
|
+
if nf is not 1 then error "the Repeat dialog's cadence group carries neither an \\"Every\\" nor an \\"Ends:\\" label and offers " & nf & " numeric field(s), so the interval cannot be identified — numeric fields:" & my cgInventory(g)
|
|
238
|
+
-- positional-ok: reached ONLY after the line above proved the group holds
|
|
239
|
+
-- exactly one text field, so this is a uniqueness statement, not an index.
|
|
240
|
+
-- The after-completion cadence group is that shape (MEASURED, CGRD1 §A: its
|
|
241
|
+
-- only static text is "after previous item is checked off.", one field).
|
|
242
|
+
return text field 1 of g
|
|
243
|
+
end tell
|
|
244
|
+
end cgField
|
|
245
|
+
|
|
246
|
+
on rfInventory(c)
|
|
247
|
+
tell application "System Events"
|
|
248
|
+
set inv to ""
|
|
249
|
+
repeat with i from 1 to (count of text fields of c)
|
|
250
|
+
set fp to position of text field i of c
|
|
251
|
+
set inv to inv & " #" & i & "(y=" & (item 2 of fp) & ",shows=" & ((value of text field i of c) as text) & ")"
|
|
252
|
+
end repeat
|
|
253
|
+
if inv is "" then set inv to " (none)"
|
|
254
|
+
return inv
|
|
255
|
+
end tell
|
|
256
|
+
end rfInventory
|
|
257
|
+
|
|
258
|
+
on rfField(c, rowLabel, tol)
|
|
259
|
+
tell application "System Events"
|
|
260
|
+
set labelY to my cgLabelY(c, rowLabel)
|
|
261
|
+
if labelY is missing value then error "the Repeat dialog shows no \\"" & rowLabel & "\\" label, so the field beside it cannot be identified — text fields:" & my rfInventory(c)
|
|
262
|
+
set hits to my cgOnRow(c, labelY, tol, true)
|
|
263
|
+
if (count of hits) is not 1 then error "the Repeat dialog offers " & (count of hits) & " field(s) on the \\"" & rowLabel & "\\" row, expected exactly 1 — text fields:" & my rfInventory(c)
|
|
264
|
+
return item 1 of hits
|
|
265
|
+
end tell
|
|
266
|
+
end rfField`;
|
|
75
267
|
/** resolve-element: does the element exist right now? Returns "true"/"false". */
|
|
76
268
|
export function axResolveScript(path) {
|
|
77
269
|
return `${SE} to return (exists (${path}))`;
|
|
@@ -82,33 +274,49 @@ export function axPressScript(path) {
|
|
|
82
274
|
}
|
|
83
275
|
/**
|
|
84
276
|
* set-field-value: enter a value into the dialog's numeric text field (interval,
|
|
85
|
-
* ends-count, start-days-earlier)
|
|
86
|
-
* value, and Tabs to commit — because
|
|
87
|
-
*
|
|
88
|
-
* number (the field shows "5" but the
|
|
89
|
-
* exactly like `set value` on a pop-up,
|
|
90
|
-
* case used the default interval 1).
|
|
91
|
-
* needs; Tab (not Return, which would
|
|
92
|
-
*
|
|
93
|
-
* reveal/activate preamble puts Things
|
|
277
|
+
* ends-count, start-days-earlier) — and into the Move… picker's filter field. It
|
|
278
|
+
* FOCUSES the field, TYPES the value, and Tabs to commit — because
|
|
279
|
+
* `set value of <field>` writes the field's displayed text WITHOUT firing the
|
|
280
|
+
* edit, so the app's binding keeps the old number (the field shows "5" but the
|
|
281
|
+
* rule stays interval 1 — a silent no-op exactly like `set value` on a pop-up,
|
|
282
|
+
* UIC6; it went unnoticed while every base case used the default interval 1).
|
|
283
|
+
* Real keystrokes fire the change the binding needs; Tab (not Return, which would
|
|
284
|
+
* fire the default OK button) commits and moves focus. Foreground-bound
|
|
285
|
+
* (keystrokes reach the frontmost app) — the reveal/activate preamble puts Things
|
|
286
|
+
* there. One stable command shape.
|
|
287
|
+
*
|
|
288
|
+
* NO SELECT-ALL KEYSTROKE (BEEP1, 2026-08-25, docs/lab/beep1-numeric-field-beep.md).
|
|
289
|
+
* The primitive used to send ⌘A before typing, and that ONE keystroke was the
|
|
290
|
+
* audible macOS alert beep every numeric-field drive fired on the live host:
|
|
291
|
+
* Things' `Edit ▸ Select All` menu item exists and is DISABLED while the Repeat
|
|
292
|
+
* sheet is up, AppKit dispatches ⌘A as a menu key equivalent FIRST, the disabled
|
|
293
|
+
* item swallows it, nothing handles it → NSBeep. It is a menu-dispatch fact, not
|
|
294
|
+
* a focus race: the beep survives a verified first responder and a 1.5 s settle,
|
|
295
|
+
* while Tab and the digits themselves are silent. ⌘A was also REDUNDANT —
|
|
296
|
+
* `set focused of tf to true` installs the field editor with the ENTIRE content
|
|
297
|
+
* selected (measured: `AXSelectedTextRange` length goes 0 → the full value
|
|
298
|
+
* length, on both a 1- and a 2-character value), so typing replaces the old value
|
|
299
|
+
* outright, including the shrinking case (12 → 3) that a stale caret would have
|
|
300
|
+
* corrupted into "123". Dropping the keystroke is therefore silent AND correct on
|
|
301
|
+
* all three fields.
|
|
302
|
+
*
|
|
303
|
+
* CLOSED-LOOP (determinism doctrine): type, Tab-commit, then READ THE FIELD BACK
|
|
304
|
+
* and retry if it did not hold — the interval field, when it is the first numeric
|
|
305
|
+
* field after a frequency/type switch, races the dialog's group re-layout and
|
|
306
|
+
* reverts to 1 (UIC7, oddities §8l). Re-focus + re-type after a settle lands it
|
|
307
|
+
* once the re-layout has finished, and the re-focus re-selects the whole value,
|
|
308
|
+
* so a retry starts from a clean field without ⌘A. Fail-closed (an `error`, i.e.
|
|
309
|
+
* a transport failure the pipeline re-verifies) if it never holds — the
|
|
310
|
+
* create/reschedule delta's rule assertion is the final DB-level authority.
|
|
94
311
|
*/
|
|
95
312
|
export function axSetValueScript(path, value, attempts = 3) {
|
|
96
313
|
const v = escapeAppleScript(value);
|
|
97
314
|
const n = Math.max(1, Math.trunc(attempts));
|
|
98
|
-
// CLOSED-LOOP (determinism doctrine): type, Tab-commit, then READ THE FIELD
|
|
99
|
-
// BACK and retry if it did not hold — the interval field, when it is the first
|
|
100
|
-
// numeric field after a frequency/type switch, races the dialog's group
|
|
101
|
-
// re-layout and reverts to 1 (UIC7, oddities §8l). Re-focus + re-type after a
|
|
102
|
-
// settle lands it once the re-layout has finished. Fail-closed (an `error`,
|
|
103
|
-
// i.e. a transport failure the pipeline re-verifies) if it never holds — the
|
|
104
|
-
// create/reschedule delta's rule assertion is the final DB-level authority.
|
|
105
315
|
return `${SE}
|
|
106
316
|
set tf to (${path})
|
|
107
317
|
repeat ${n} times
|
|
108
318
|
set focused of tf to true
|
|
109
319
|
delay 0.15
|
|
110
|
-
keystroke "a" using command down
|
|
111
|
-
delay 0.1
|
|
112
320
|
keystroke "${v}"
|
|
113
321
|
delay 0.1
|
|
114
322
|
key code 48
|
|
@@ -121,6 +329,309 @@ export function axSetValueScript(path, value, attempts = 3) {
|
|
|
121
329
|
error "field did not hold value \\"${v}\\" after ${n} attempt(s); last shown: " & ((value of tf) as text)
|
|
122
330
|
end tell`;
|
|
123
331
|
}
|
|
332
|
+
/**
|
|
333
|
+
* set-group-number: drive ONE of the Repeat dialog's two numeric fields —
|
|
334
|
+
* the cadence INTERVAL or the ENDS-AFTER COUNT — addressed by the LABEL ROW it
|
|
335
|
+
* sits on (HXPC1, docs/lab/hxpc1-picker-assert.md §A; hardened by CGRD1,
|
|
336
|
+
* docs/lab/cgrd1-precommit-audit.md §A).
|
|
337
|
+
*
|
|
338
|
+
* Both fields used to be spelled `text field 1 of group 1`, which is the same
|
|
339
|
+
* control at different moments. Measured on Things 3.23 (build 32300036):
|
|
340
|
+
*
|
|
341
|
+
* Ends: never → group text fields = 1 · tf1 = interval @[311,283]
|
|
342
|
+
* Ends: after N → group text fields = 2 · tf1 = COUNT @[402,372]
|
|
343
|
+
* tf2 = interval @[311,283]
|
|
344
|
+
*
|
|
345
|
+
* i.e. selecting the "after" bound INSERTS the count ahead of the interval. The
|
|
346
|
+
* create path got away with it by driving the interval while it was still the
|
|
347
|
+
* sole field, but a RESCHEDULE opens the dialog pre-populated: a rule that
|
|
348
|
+
* already ends after N presents both fields from the first step, so the interval
|
|
349
|
+
* drive wrote the requested interval into the count field, the count drive then
|
|
350
|
+
* overwrote it, and the interval silently never changed.
|
|
351
|
+
*
|
|
352
|
+
* The addresses are the {@link AX_CADENCE_HANDLERS} laws — POSITIVE label-row
|
|
353
|
+
* matches wherever the app offers a label to match, a uniqueness check where it
|
|
354
|
+
* does not, and a fail-closed refusal naming the whole numeric-field inventory
|
|
355
|
+
* otherwise. The write itself is the {@link axSetValueScript} closed loop —
|
|
356
|
+
* focus, type, Tab-commit, read back, bounded retries — and, like it, sends NO
|
|
357
|
+
* select-all keystroke: the ⌘A that used to open it is the macOS alert beep
|
|
358
|
+
* (BEEP1, docs/lab/beep1-numeric-field-beep.md), and focusing the field already
|
|
359
|
+
* selects its whole content.
|
|
360
|
+
*
|
|
361
|
+
* The read-back this loop performs is SELF-REFERENTIAL by construction — it
|
|
362
|
+
* re-reads the field it addressed, so it can only prove the keystrokes landed
|
|
363
|
+
* where they were aimed, never that they were aimed at the right control. The
|
|
364
|
+
* PRE-COMMIT AUDIT ({@link axAuditDialogScript}) is what closes that: it re-reads
|
|
365
|
+
* every control through these same handlers just before the OK press.
|
|
366
|
+
*/
|
|
367
|
+
export function axSetGroupNumberScript(groupPath, target, value, attempts = 3, rowTolerance = ROW_TOLERANCE) {
|
|
368
|
+
const v = escapeAppleScript(value);
|
|
369
|
+
const n = Math.max(1, Math.trunc(attempts));
|
|
370
|
+
const tol = Math.max(1, Math.trunc(rowTolerance));
|
|
371
|
+
return `${AX_CADENCE_HANDLERS}
|
|
372
|
+
|
|
373
|
+
${SE}
|
|
374
|
+
set g to (${groupPath})
|
|
375
|
+
my cgSettle(g)
|
|
376
|
+
set tf to my cgField(g, "${target}", ${tol})
|
|
377
|
+
repeat ${n} times
|
|
378
|
+
set focused of tf to true
|
|
379
|
+
delay 0.15
|
|
380
|
+
keystroke "${v}"
|
|
381
|
+
delay 0.1
|
|
382
|
+
key code 48
|
|
383
|
+
delay 0.2
|
|
384
|
+
try
|
|
385
|
+
if ((value of tf) as text) is "${v}" then return "OK"
|
|
386
|
+
end try
|
|
387
|
+
delay 0.3
|
|
388
|
+
end repeat
|
|
389
|
+
error "the ${target} field did not hold value \\"${v}\\" after ${n} attempt(s); last shown: " & ((value of tf) as text)
|
|
390
|
+
end tell`;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* set-row-field: drive a Repeat-dialog text field addressed by the pinned English
|
|
394
|
+
* LABEL sharing its row — the same discrimination law as
|
|
395
|
+
* {@link axSetGroupNumberScript}, applied to a field that lives on the dialog
|
|
396
|
+
* SHELL rather than in the cadence group.
|
|
397
|
+
*
|
|
398
|
+
* Its one caller is the "and start [N] days earlier" offset the "Add deadlines"
|
|
399
|
+
* checkbox reveals, which shipped as `text field 1` of the shell. That address
|
|
400
|
+
* was the HXPC1 error class exactly: a value-bearing text field picked by index
|
|
401
|
+
* out of a STATE-DEPENDENT tree (the field does not exist at all until the
|
|
402
|
+
* checkbox is ticked), verified only by re-reading the same index it wrote. It
|
|
403
|
+
* happened to be right on 3.23 — measured, the shell carries 0 direct text fields
|
|
404
|
+
* with deadlines off and exactly 1 with them on, whether or not reminders are also
|
|
405
|
+
* on (CGRD1 §B census) — but nothing in the address said so, and an AX tree is an
|
|
406
|
+
* undocumented private surface that may add a second field in any release.
|
|
407
|
+
*
|
|
408
|
+
* The label anchor is `days earlier` (y=413 against the field's y=409, CGRD1 §B).
|
|
409
|
+
* A missing label, or anything other than exactly one field on its row, FAILS
|
|
410
|
+
* CLOSED naming the shell's whole text-field inventory. The write is the
|
|
411
|
+
* {@link axSetValueScript} closed loop.
|
|
412
|
+
*/
|
|
413
|
+
export function axSetRowFieldScript(containerPath, rowLabel, value, attempts = 3, rowTolerance = ROW_TOLERANCE) {
|
|
414
|
+
const v = escapeAppleScript(value);
|
|
415
|
+
const label = escapeAppleScript(rowLabel);
|
|
416
|
+
const n = Math.max(1, Math.trunc(attempts));
|
|
417
|
+
const tol = Math.max(1, Math.trunc(rowTolerance));
|
|
418
|
+
return `${AX_CADENCE_HANDLERS}
|
|
419
|
+
|
|
420
|
+
${SE}
|
|
421
|
+
set c to (${containerPath})
|
|
422
|
+
set tf to my rfField(c, "${label}", ${tol})
|
|
423
|
+
repeat ${n} times
|
|
424
|
+
set focused of tf to true
|
|
425
|
+
delay 0.15
|
|
426
|
+
keystroke "${v}"
|
|
427
|
+
delay 0.1
|
|
428
|
+
key code 48
|
|
429
|
+
delay 0.2
|
|
430
|
+
try
|
|
431
|
+
if ((value of tf) as text) is "${v}" then return "OK"
|
|
432
|
+
end try
|
|
433
|
+
delay 0.3
|
|
434
|
+
end repeat
|
|
435
|
+
error "the \\"${label}\\" field did not hold value \\"${v}\\" after ${n} attempt(s); last shown: " & ((value of tf) as text)
|
|
436
|
+
end tell`;
|
|
437
|
+
}
|
|
438
|
+
/** AppleScript list literal of quoted strings. */
|
|
439
|
+
function asList(values) {
|
|
440
|
+
return `{${values.map((v) => `"${escapeAppleScript(v)}"`).join(", ")}}`;
|
|
441
|
+
}
|
|
442
|
+
/** The intended value(s) as the mismatch report should read them. */
|
|
443
|
+
function intendedText(control) {
|
|
444
|
+
if (control.expectedLabel !== undefined)
|
|
445
|
+
return control.expectedLabel;
|
|
446
|
+
return control.expected.map((v) => `\\"${escapeAppleScript(v)}\\"`).join(" or ");
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* audit-dialog: RE-READ EVERY CONTROL THIS DRIVE SET, through each control's own
|
|
450
|
+
* discriminated address, and refuse to commit if any one of them does not hold
|
|
451
|
+
* the value the drive intended.
|
|
452
|
+
*
|
|
453
|
+
* This exists because a per-step read-back is SELF-REFERENTIAL. Every setter here
|
|
454
|
+
* confirms its write by re-reading the element it addressed, so it proves the
|
|
455
|
+
* keystrokes landed where they were aimed — and nothing else. The #589 wrong-field
|
|
456
|
+
* write reported OK for exactly that reason: the interval drive typed into the
|
|
457
|
+
* ends-count field, then read the ends-count field back and found its own number
|
|
458
|
+
* sitting there. The address was wrong; a read-back through the same address
|
|
459
|
+
* cannot see that.
|
|
460
|
+
*
|
|
461
|
+
* The audit is the outside view. It is assembled from the recipe's OWN step list
|
|
462
|
+
* (so no control the recipe drives can be left out of the audit by omission) and
|
|
463
|
+
* runs as the last step before the OK press, comparing the dialog's complete
|
|
464
|
+
* intended state against what the dialog actually shows: frequency, the
|
|
465
|
+
* after-completion cadence unit, interval, ends bound and its count, the
|
|
466
|
+
* deadline/reminder checkboxes, the start-days-earlier offset, the weekday set,
|
|
467
|
+
* the monthly/yearly anchor pop-ups and the 3.23 first-occurrence pop-up. A
|
|
468
|
+
* mismatch is an `error` naming EVERY differing control with both values, which
|
|
469
|
+
* aborts the drive fail-closed BEFORE the commit and runs the standard clean-abort
|
|
470
|
+
* path — nothing reaches the database.
|
|
471
|
+
*
|
|
472
|
+
* Deterministic throughout: the cadence group is settled on its own shape
|
|
473
|
+
* signature (the BEEP1 two-agreeing-reads gate), never on a sleep, and every field
|
|
474
|
+
* is found by its label row rather than by index ({@link AX_CADENCE_HANDLERS}).
|
|
475
|
+
*
|
|
476
|
+
* The dialog's three `AXDateTimeArea` controls are audited separately — their
|
|
477
|
+
* values are NSDates no System Events read can reach, so they ride
|
|
478
|
+
* {@link axAuditDateAreasScript} through the same ObjC bridge that writes them.
|
|
479
|
+
*/
|
|
480
|
+
export function axAuditDialogScript(spec, rowTolerance = ROW_TOLERANCE) {
|
|
481
|
+
const tol = Math.max(1, Math.trunc(rowTolerance));
|
|
482
|
+
const body = spec.controls
|
|
483
|
+
.map((c, i) => {
|
|
484
|
+
const name = escapeAppleScript(c.label);
|
|
485
|
+
const want = asList(c.expected);
|
|
486
|
+
const intended = intendedText(c);
|
|
487
|
+
const miss = `set end of bad to "${name} (intended ${intended}, dialog shows \\"" & v${i} & "\\")"`;
|
|
488
|
+
switch (c.kind) {
|
|
489
|
+
case "popup":
|
|
490
|
+
return ` set v${i} to "(unreadable)"
|
|
491
|
+
try
|
|
492
|
+
set v${i} to (value of (${c.path ?? ""})) as text
|
|
493
|
+
end try
|
|
494
|
+
if not (my aqAny(v${i}, ${want})) then ${miss}`;
|
|
495
|
+
case "occurrence-popup":
|
|
496
|
+
return ` set v${i} to "(unreadable)"
|
|
497
|
+
try
|
|
498
|
+
set v${i} to (value of (${c.path ?? ""})) as text
|
|
499
|
+
end try
|
|
500
|
+
set d${i} to my aqYMD(v${i})
|
|
501
|
+
if d${i} is missing value then
|
|
502
|
+
${miss}
|
|
503
|
+
else if not (my aqAny(d${i}, ${want})) then
|
|
504
|
+
set end of bad to "${name} (intended ${intended}, dialog shows \\"" & v${i} & "\\" = " & d${i} & ")"
|
|
505
|
+
end if`;
|
|
506
|
+
case "checkbox":
|
|
507
|
+
return ` set v${i} to "(unreadable)"
|
|
508
|
+
try
|
|
509
|
+
set v${i} to ((value of (${c.path ?? ""})) as integer) as text
|
|
510
|
+
end try
|
|
511
|
+
if not (my aqAny(v${i}, ${want})) then set end of bad to "${name} (intended ${intended}, dialog shows " & (my aqTick(v${i})) & ")"`;
|
|
512
|
+
case "group-number":
|
|
513
|
+
return ` set v${i} to "(unreadable)"
|
|
514
|
+
try
|
|
515
|
+
set v${i} to ((value of (my cgField(g, "${c.numberTarget ?? "interval"}", ${tol}))) as text)
|
|
516
|
+
end try
|
|
517
|
+
if not (my aqAny(v${i}, ${want})) then ${miss}`;
|
|
518
|
+
case "row-field":
|
|
519
|
+
return ` set v${i} to "(unreadable)"
|
|
520
|
+
try
|
|
521
|
+
set v${i} to ((value of (my rfField(sh, "${escapeAppleScript(c.rowLabel ?? "")}", ${tol}))) as text)
|
|
522
|
+
end try
|
|
523
|
+
if not (my aqAny(v${i}, ${want})) then ${miss}`;
|
|
524
|
+
case "weekdays":
|
|
525
|
+
return ` set got${i} to {}
|
|
526
|
+
repeat with k from ${Math.max(1, Math.trunc(c.weekdayBase ?? 2))} to (count of pop up buttons of g)
|
|
527
|
+
set end of got${i} to ((value of pop up button k of g) as text)
|
|
528
|
+
end repeat
|
|
529
|
+
set v${i} to my aqJoin(got${i}, ",")
|
|
530
|
+
set off${i} to false
|
|
531
|
+
repeat with w in ${want}
|
|
532
|
+
if not (my aqAny(w as text, got${i})) then set off${i} to true
|
|
533
|
+
end repeat
|
|
534
|
+
repeat with w in got${i}
|
|
535
|
+
if not (my aqAny(w as text, ${want})) then set off${i} to true
|
|
536
|
+
end repeat
|
|
537
|
+
if off${i} then ${miss}`;
|
|
538
|
+
}
|
|
539
|
+
})
|
|
540
|
+
.join("\n");
|
|
541
|
+
return `${AX_CADENCE_HANDLERS}
|
|
542
|
+
|
|
543
|
+
on aqAny(v, lst)
|
|
544
|
+
repeat with c in lst
|
|
545
|
+
if (v as text) is (c as text) then return true
|
|
546
|
+
end repeat
|
|
547
|
+
return false
|
|
548
|
+
end aqAny
|
|
549
|
+
|
|
550
|
+
on aqJoin(lst, sep)
|
|
551
|
+
set out to ""
|
|
552
|
+
repeat with x in lst
|
|
553
|
+
if out is not "" then set out to out & sep
|
|
554
|
+
set out to out & (x as text)
|
|
555
|
+
end repeat
|
|
556
|
+
return out
|
|
557
|
+
end aqJoin
|
|
558
|
+
|
|
559
|
+
on aqTick(v)
|
|
560
|
+
if (v as text) is "1" then return "checked"
|
|
561
|
+
if (v as text) is "0" then return "unchecked"
|
|
562
|
+
return "\\"" & (v as text) & "\\""
|
|
563
|
+
end aqTick
|
|
564
|
+
|
|
565
|
+
on aqPad2(n)
|
|
566
|
+
set s to (n as integer) as text
|
|
567
|
+
if (length of s) < 2 then set s to "0" & s
|
|
568
|
+
return s
|
|
569
|
+
end aqPad2
|
|
570
|
+
|
|
571
|
+
on aqYMD(t)
|
|
572
|
+
-- Occurrence-pop-up titles are LOCALIZED ("Sun, Jul 12, 2026"), so the match is
|
|
573
|
+
-- made by PARSING the title to a date and comparing calendar components — never
|
|
574
|
+
-- by rebuilding the app's display string (the axSelectNextOccurrenceScript law).
|
|
575
|
+
set s to t as text
|
|
576
|
+
try
|
|
577
|
+
set d to date s
|
|
578
|
+
return ((year of d) as text) & "-" & my aqPad2((month of d) as integer) & "-" & my aqPad2(day of d)
|
|
579
|
+
end try
|
|
580
|
+
try
|
|
581
|
+
set ofs to offset of ", " in s
|
|
582
|
+
if ofs > 0 then
|
|
583
|
+
set d to date (text (ofs + 2) thru -1 of s)
|
|
584
|
+
return ((year of d) as text) & "-" & my aqPad2((month of d) as integer) & "-" & my aqPad2(day of d)
|
|
585
|
+
end if
|
|
586
|
+
end try
|
|
587
|
+
return missing value
|
|
588
|
+
end aqYMD
|
|
589
|
+
|
|
590
|
+
${SE}
|
|
591
|
+
set sh to (${spec.shell})
|
|
592
|
+
set g to (${spec.group})
|
|
593
|
+
my cgSettle(g)
|
|
594
|
+
set bad to {}
|
|
595
|
+
${body}
|
|
596
|
+
if (count of bad) is 0 then return "OK"
|
|
597
|
+
error "the Repeat dialog does not hold what this drive entered — " & (count of bad) & " control(s) differ: " & my aqJoin(bad, "; ")
|
|
598
|
+
end tell`;
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* The pre-commit audit's DATE-AREA leg. The dialog's first-occurrence, ends-on and
|
|
602
|
+
* reminder controls are `AXDateTimeArea`s whose value is an NSDate — unreachable
|
|
603
|
+
* from System Events — so they are re-read through the SAME ObjC bridge, the same
|
|
604
|
+
* shell-scoped walk and the SAME deterministic `pick` discriminator that
|
|
605
|
+
* {@link axSetDateTimeScript} writes them through. A control the audit cannot find,
|
|
606
|
+
* or one holding a different date/time, throws naming every area the dialog does
|
|
607
|
+
* present (y-position + time-of-day), so the drive aborts before the OK press.
|
|
608
|
+
*/
|
|
609
|
+
export function axAuditDateAreasScript(areas) {
|
|
610
|
+
return `${AX_DATE_AREA_PRELUDE}
|
|
611
|
+
function run(){
|
|
612
|
+
var apps=$.NSRunningApplication.runningApplicationsWithBundleIdentifier('com.culturedcode.ThingsMac');
|
|
613
|
+
if(!apps || apps.count===0) throw new Error('Things not running');
|
|
614
|
+
var app=$.AXUIElementCreateApplication(apps.objectAtIndex(0).processIdentifier);
|
|
615
|
+
var wanted=${JSON.stringify(areas)};
|
|
616
|
+
var cal=$.NSCalendar.currentCalendar;
|
|
617
|
+
var found=[]; try{ var shell=findShell(app); if(shell) collect(shell,'AXDateTimeArea',16,found); }catch(e){ found=[]; }
|
|
618
|
+
var bad=[];
|
|
619
|
+
for(var i=0;i<wanted.length;i++){
|
|
620
|
+
var w=wanted[i];
|
|
621
|
+
var dt=pick(found,w.target);
|
|
622
|
+
if(!dt){ bad.push(w.label+' (intended '+w.spec+', but this dialog state presents no '+w.target+' control among ['+inv(found)+'])'); continue; }
|
|
623
|
+
if(w.spec.indexOf('date:')===0){
|
|
624
|
+
var got=ymdStr(dt,cal), want=w.spec.slice(5);
|
|
625
|
+
if(got!==want) bad.push(w.label+' (intended '+want+', dialog shows '+(got||'(no value)')+')');
|
|
626
|
+
} else {
|
|
627
|
+
var gott=hmStr(dt,cal), p=w.spec.slice(5).split(':'), wantt=(+p[0])+':'+('0'+(+p[1])).slice(-2);
|
|
628
|
+
if(gott!==wantt) bad.push(w.label+' (intended '+wantt+', dialog shows '+(gott||'(no value)')+')');
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if(bad.length) throw new Error('the Repeat dialog does not hold what this drive entered — '+bad.length+' control(s) differ: '+bad.join('; '));
|
|
632
|
+
return 'OK';
|
|
633
|
+
}`;
|
|
634
|
+
}
|
|
124
635
|
/**
|
|
125
636
|
* ensure-checkbox: converge a dialog checkbox to a target state through a
|
|
126
637
|
* DETERMINISTIC CLOSED LOOP (RRD1, determinism doctrine) — never a blind toggle.
|
|
@@ -310,6 +821,9 @@ ${SE}
|
|
|
310
821
|
set theMenu to menu 1 of pu
|
|
311
822
|
set clickedTitle to ""
|
|
312
823
|
set levelsSeen to 0
|
|
824
|
+
set opener to (value of pu) as text
|
|
825
|
+
set sample to ""
|
|
826
|
+
set sampled to 0
|
|
313
827
|
if isToday then
|
|
314
828
|
set nms to name of every menu item of theMenu
|
|
315
829
|
if (count of nms) > 0 then
|
|
@@ -330,6 +844,13 @@ ${SE}
|
|
|
330
844
|
repeat with i from 1 to (count of nms)
|
|
331
845
|
set nm to item i of nms
|
|
332
846
|
if nm is not missing value then
|
|
847
|
+
-- Keep a short sample of what THIS menu actually offered, so a miss can
|
|
848
|
+
-- report the dates the dialog had rather than only the one it lacked.
|
|
849
|
+
if sampled < ${SAMPLE_ITEMS} then
|
|
850
|
+
if sample is not "" then set sample to sample & ", "
|
|
851
|
+
set sample to sample & (nm as text)
|
|
852
|
+
set sampled to sampled + 1
|
|
853
|
+
end if
|
|
333
854
|
set ymd to my parsedYMD(nm)
|
|
334
855
|
if ymd is not missing value then
|
|
335
856
|
if (item 1 of ymd) is wantY and (item 2 of ymd) is wantM and (item 3 of ymd) is wantD then
|
|
@@ -363,7 +884,7 @@ ${SE}
|
|
|
363
884
|
end repeat
|
|
364
885
|
if clickedTitle is "" then
|
|
365
886
|
key code 53
|
|
366
|
-
error "select-next-occurrence: this Repeat dialog offers only the rule's own upcoming occurrences (and today) as the first occurrence, and ${isoDate} is not one of them — searched " & levelsSeen & " level(s) of the Next: menu. Ask for a date the rule actually produces, or change the rule."
|
|
887
|
+
error "select-next-occurrence: this Repeat dialog offers only the rule's own upcoming occurrences (and today) as the first occurrence, and ${isoDate} is not one of them — searched " & levelsSeen & " level(s) of the Next: menu, which opened on \\"" & opener & "\\" and led with: " & sample & ". Ask for a date the rule actually produces, or change the rule."
|
|
367
888
|
end if
|
|
368
889
|
delay 0.4
|
|
369
890
|
set shown to (value of pu) as text
|
|
@@ -373,6 +894,48 @@ ${SE}
|
|
|
373
894
|
return "OK"
|
|
374
895
|
end tell`;
|
|
375
896
|
}
|
|
897
|
+
/**
|
|
898
|
+
* settle-occurrences: let the 3.23 `Next:` pop-up ABSORB the rule change the
|
|
899
|
+
* preceding steps made, before the drive touches the dialog again (NEXTPOP1).
|
|
900
|
+
*
|
|
901
|
+
* MEASURED (golden-v4 / Things 3.23, `research-nextpop1.sh` DIAG3/DIAG4): the
|
|
902
|
+
* dialog recomputes the first-occurrence pop-up — its displayed value AND the
|
|
903
|
+
* menu of occurrences behind it — ASYNCHRONOUSLY. After the yearly anchor was
|
|
904
|
+
* moved from Aug 6 to Aug 20 the control flipped at **t+0.4s** with nothing else
|
|
905
|
+
* driven; when the very next step (the "Add deadlines" checkbox) was pressed
|
|
906
|
+
* inside that window instead, the control NEVER caught up — it still read
|
|
907
|
+
* `Thu, Aug 6, 2026`, and its menu still enumerated the Aug-6 series, six
|
|
908
|
+
* seconds later. A cancelled recompute does not retry.
|
|
909
|
+
*
|
|
910
|
+
* That is what made every deadlined monthly/yearly promote fail closed on 3.23:
|
|
911
|
+
* the anchor drive is followed immediately by the deadline controls, so by the
|
|
912
|
+
* time `select-next-occurrence` opened the menu it was the SEED's series, and
|
|
913
|
+
* the requested date — the rule's own first due date — was genuinely not in it
|
|
914
|
+
* (VMRES1 §4.3, reproduced and explained in NEXTPOP1).
|
|
915
|
+
*
|
|
916
|
+
* The wait is closed-loop in the direction that matters: it exits the moment the
|
|
917
|
+
* control MOVES, which is the case that needs waiting for. When the rule change
|
|
918
|
+
* did not move the first occurrence there is nothing to observe, so the budget
|
|
919
|
+
* bounds it — deliberately over-cautious, since the cost is a fraction of a
|
|
920
|
+
* second and the alternative is a series that starts on the wrong date.
|
|
921
|
+
*/
|
|
922
|
+
export function axSettleOccurrencesScript(popupPath, budgetMs = OCCURRENCE_SETTLE_MS, pollMs = OCCURRENCE_POLL_MS) {
|
|
923
|
+
const poll = Math.max(50, Math.trunc(pollMs)) / 1000;
|
|
924
|
+
const reads = Math.max(1, Math.ceil(Math.max(1, Math.trunc(budgetMs)) / Math.max(50, pollMs)));
|
|
925
|
+
// `before` and `after` are AppleScript's own positional keywords and `now` is
|
|
926
|
+
// taken too — `set before to …` does not even COMPILE (osacompile: "Expected
|
|
927
|
+
// expression but found “to”"), and osascript reports that as a drive failure at
|
|
928
|
+
// run time, mid-dialog. Hence the deliberately dull variable names.
|
|
929
|
+
return `${SE}
|
|
930
|
+
set wasValue to (value of ${popupPath}) as text
|
|
931
|
+
repeat ${reads} times
|
|
932
|
+
delay ${poll}
|
|
933
|
+
set curValue to (value of ${popupPath}) as text
|
|
934
|
+
if curValue is not wasValue then return "moved: " & wasValue & " -> " & curValue
|
|
935
|
+
end repeat
|
|
936
|
+
return "unchanged: " & wasValue
|
|
937
|
+
end tell`;
|
|
938
|
+
}
|
|
376
939
|
/**
|
|
377
940
|
* converge-weekdays: drive the weekly dialog's weekday ROWS onto an exact target
|
|
378
941
|
* set through a deterministic closed loop (RDLG2 — the RRD1 fix).
|
|
@@ -473,6 +1036,19 @@ end tell`;
|
|
|
473
1036
|
* selection-landed verification, so a match guarantees the intended row is
|
|
474
1037
|
* selected. One stable command shape per primitive.
|
|
475
1038
|
*
|
|
1039
|
+
* VMRES1 correction (2026-08-23, golden-v4 / Things 3.23): the readback LAGS the
|
|
1040
|
+
* `select` action, so reading `name of selected to dos` immediately after it can
|
|
1041
|
+
* return the PREVIOUS iteration's selection. A row whose `select` lands nothing
|
|
1042
|
+
* (the blank spacer that follows the project rows) then matched the prior row's
|
|
1043
|
+
* title, the loop returned "OK" one row LATE, and the table was left with NOTHING
|
|
1044
|
+
* selected — `Items ▸ Repeat…` never materialized and the drive died at its wait
|
|
1045
|
+
* (`verify-failed:silent-noop`). It reproduced 3/3 on the second project-repeat
|
|
1046
|
+
* drive of a Things session and 0/2 on the first, which is the signature of a
|
|
1047
|
+
* race, not of app state. Fixed the way the heading sibling below already does
|
|
1048
|
+
* it: settle after `select`, then require `selected of (row i)` — the row THIS
|
|
1049
|
+
* iteration targeted must itself hold the selection — before trusting the title
|
|
1050
|
+
* readback. Evidence: [docs/lab/vmres1-residuals.md](../../../docs/lab/vmres1-residuals.md) §2.
|
|
1051
|
+
*
|
|
476
1052
|
* UIC5 correction: the shipped form set the TABLE's `AXSelectedRows` attribute
|
|
477
1053
|
* to a one-row list, which is a SILENT NO-OP on Things' content table via System
|
|
478
1054
|
* Events (no error, selection never lands). The row `select` action is the
|
|
@@ -488,9 +1064,12 @@ export function axSelectRowScript(tablePath, title) {
|
|
|
488
1064
|
repeat with i from 1 to n
|
|
489
1065
|
try
|
|
490
1066
|
select (row i of theTable)
|
|
491
|
-
|
|
492
|
-
if (
|
|
493
|
-
|
|
1067
|
+
delay 0.25
|
|
1068
|
+
if (selected of (row i of theTable)) then
|
|
1069
|
+
tell application "Things3" to set selNames to (name of selected to dos)
|
|
1070
|
+
if (count of selNames) is 1 and ((item 1 of selNames) as text) is "${t}" then
|
|
1071
|
+
return "OK"
|
|
1072
|
+
end if
|
|
494
1073
|
end if
|
|
495
1074
|
end try
|
|
496
1075
|
end repeat
|
|
@@ -583,6 +1162,141 @@ export function axKeyScript(keys) {
|
|
|
583
1162
|
: `keystroke "${escapeAppleScript(k)}"`);
|
|
584
1163
|
return `tell application "System Events" to tell process "Things3"\n ${lines.join("\n ")}\nend tell`;
|
|
585
1164
|
}
|
|
1165
|
+
/**
|
|
1166
|
+
* type-text: send literal text to whatever control holds focus (HXPC1). The
|
|
1167
|
+
* Move… picker focuses its own filter field the instant it opens, and that field
|
|
1168
|
+
* is NOT addressable as a direct child of the picker window — so there is no
|
|
1169
|
+
* element to hand `set-value`, whose select-all + Tab commit would be wrong for a
|
|
1170
|
+
* search field regardless (a popover filter has no next key view for Tab to move
|
|
1171
|
+
* to). Unlike {@link axKeyScript}, which splits its spec on whitespace and would
|
|
1172
|
+
* drop the spaces out of a multi-word project title, this sends the string as
|
|
1173
|
+
* ONE keystroke. It is deliberately not self-verifying: the `click-picker-row`
|
|
1174
|
+
* step that follows resolves the destination row by name and fails closed when
|
|
1175
|
+
* the filter did not produce it, so a keystroke that landed elsewhere can never
|
|
1176
|
+
* be committed. One stable command shape.
|
|
1177
|
+
*/
|
|
1178
|
+
export function axTypeTextScript(text) {
|
|
1179
|
+
return `${SE}
|
|
1180
|
+
keystroke "${escapeAppleScript(text)}"
|
|
1181
|
+
end tell`;
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* resolve-frame for a control nested inside a CONTENT-TABLE ROW: walk the
|
|
1185
|
+
* table's rows → cells → cell children and return the frame of the one whose
|
|
1186
|
+
* `AXDescription` equals `description` (HXPC1, docs/lab/hxpc1-picker-assert.md
|
|
1187
|
+
* §B0). Same "x y w h" contract as {@link axFrameScript}.
|
|
1188
|
+
*
|
|
1189
|
+
* This exists because the heading row's `…` button — the only content-row
|
|
1190
|
+
* control that carries its own title (`"More. <heading title>"`, the HEADXPROJ
|
|
1191
|
+
* enabler) — sits at `UI element N of cell 1 of row M of the table`, and
|
|
1192
|
+
* `first UI element of <table> whose description is …` searches the table's
|
|
1193
|
+
* DIRECT children only. Those are the rows, which carry no description, so the
|
|
1194
|
+
* shipped one-level spelling matched nothing and the ellipsis drives
|
|
1195
|
+
* (`project.move-heading-to-project`, `project.dissolve-heading`) died at their
|
|
1196
|
+
* own frame resolution before any click — measured on Things 3.23 against a
|
|
1197
|
+
* heading whose button the raw Accessibility API resolves at the same instant.
|
|
1198
|
+
* The row/cell indices are never guessed: every row is walked and the match is
|
|
1199
|
+
* exact, so a heading whose title changed under us fails closed by name.
|
|
1200
|
+
*/
|
|
1201
|
+
export function axRowCellFrameScript(tablePath, description) {
|
|
1202
|
+
const d = escapeAppleScript(description);
|
|
1203
|
+
return `${SE}
|
|
1204
|
+
set t to (${tablePath})
|
|
1205
|
+
repeat with r in rows of t
|
|
1206
|
+
repeat with c in UI elements of r
|
|
1207
|
+
repeat with e in UI elements of c
|
|
1208
|
+
try
|
|
1209
|
+
if ((description of e) as text) is "${d}" then
|
|
1210
|
+
set _p to position of e
|
|
1211
|
+
set _s to size of e
|
|
1212
|
+
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)
|
|
1213
|
+
end if
|
|
1214
|
+
end try
|
|
1215
|
+
end repeat
|
|
1216
|
+
end repeat
|
|
1217
|
+
end repeat
|
|
1218
|
+
error "no row of this project's list exposes \\"${d}\\" — the heading may have been renamed, moved or deleted since it was read"
|
|
1219
|
+
end tell`;
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* resolve-frame for the Move… picker ROW carrying an exact project title — the
|
|
1223
|
+
* step that replaced the recipe's blind Return (HXPC1,
|
|
1224
|
+
* docs/lab/hxpc1-picker-assert.md §B).
|
|
1225
|
+
*
|
|
1226
|
+
* The picker exposes no `AXSelected` / `AXFocused` / `AXHighlighted` on any row
|
|
1227
|
+
* (measured — only its filter field is focused), so there is nothing to read
|
|
1228
|
+
* back from a keyboard commit and no way to assert what Return would take. What
|
|
1229
|
+
* it does expose is one `AXUnknown` per row whose `AXDescription` IS the project
|
|
1230
|
+
* title, and — whenever the filter holds text — a trailing
|
|
1231
|
+
* `New Project "<typed text>"` row that CREATES a project when committed. That
|
|
1232
|
+
* row is what the blind Return took whenever the destination was missing from
|
|
1233
|
+
* the picker, which an ordinary database-resolved destination reaches: a
|
|
1234
|
+
* COMPLETED or CANCELED project appears nowhere in the picker, so the drive
|
|
1235
|
+
* minted a second project of the same title and moved the heading into it
|
|
1236
|
+
* (measured 3.23: projects 14 → 15, heading re-parented to the new row).
|
|
1237
|
+
*
|
|
1238
|
+
* So the commit is addressed instead of guessed. The script requires:
|
|
1239
|
+
* - the picker to be the window it claims (its `AXIdentifier` begins
|
|
1240
|
+
* `MovePopUpDialog-`) — a positive identity check, so a different detached
|
|
1241
|
+
* window can never be clicked into;
|
|
1242
|
+
* - EXACTLY ONE row whose description equals the destination title (the
|
|
1243
|
+
* New-Project row's description is the quoted form, so an exact match cannot
|
|
1244
|
+
* hit it);
|
|
1245
|
+
* - that row's centre to lie inside the picker's own scroll area — the CNCAC1
|
|
1246
|
+
* off-screen hazard, where a row scrolled past the fold still resolves a
|
|
1247
|
+
* frame and a click at it lands on the desktop.
|
|
1248
|
+
* Any miss FAILS CLOSED naming the destination and listing every row the picker
|
|
1249
|
+
* actually offered, so the caller learns what the app was willing to move to.
|
|
1250
|
+
*/
|
|
1251
|
+
export function axPickerRowFrameScript(pickerPath, title) {
|
|
1252
|
+
const t = escapeAppleScript(title);
|
|
1253
|
+
return `${SE}
|
|
1254
|
+
set w to (${pickerPath})
|
|
1255
|
+
set pickerId to ""
|
|
1256
|
+
try
|
|
1257
|
+
set pickerId to (value of attribute "AXIdentifier" of w) as text
|
|
1258
|
+
end try
|
|
1259
|
+
if pickerId does not start with "MovePopUpDialog-" then
|
|
1260
|
+
error "the front dialog is not the Move… project picker (window id \\"" & pickerId & "\\") — nothing was committed"
|
|
1261
|
+
end if
|
|
1262
|
+
-- positional-ok: the picker window holds exactly one scroll area (MEASURED,
|
|
1263
|
+
-- HXPC1 §B2: "direct text fields=0 scroll areas=1"), and the window's own
|
|
1264
|
+
-- AXIdentifier was checked above, so this is a container handle inside an
|
|
1265
|
+
-- already-identified window — the ROW is addressed by exact title below.
|
|
1266
|
+
set sa to scroll area 1 of w
|
|
1267
|
+
set saPos to position of sa
|
|
1268
|
+
set saSize to size of sa
|
|
1269
|
+
set saTop to item 2 of saPos
|
|
1270
|
+
set saBottom to saTop + (item 2 of saSize)
|
|
1271
|
+
set hits to {}
|
|
1272
|
+
set offered to ""
|
|
1273
|
+
repeat with i from 1 to (count of UI elements of sa)
|
|
1274
|
+
set e to UI element i of sa
|
|
1275
|
+
set d to ""
|
|
1276
|
+
try
|
|
1277
|
+
set d to (description of e) as text
|
|
1278
|
+
end try
|
|
1279
|
+
if d is not "" and (role of e) is "AXUnknown" then
|
|
1280
|
+
set offered to offered & " [" & d & "]"
|
|
1281
|
+
if d is "${t}" then set end of hits to e
|
|
1282
|
+
end if
|
|
1283
|
+
end repeat
|
|
1284
|
+
if (count of hits) is 0 then
|
|
1285
|
+
error "the Move… picker offers no project named \\"${t}\\" — it offered:" & offered & ". Committing here would have created a new project with that name instead of moving into the existing one. A completed or canceled project is not offered by this picker."
|
|
1286
|
+
end if
|
|
1287
|
+
if (count of hits) > 1 then
|
|
1288
|
+
error "the Move… picker offers " & (count of hits) & " rows named \\"${t}\\" — it offered:" & offered
|
|
1289
|
+
end if
|
|
1290
|
+
set row1 to item 1 of hits
|
|
1291
|
+
set rp to position of row1
|
|
1292
|
+
set rs to size of row1
|
|
1293
|
+
set cy to (item 2 of rp) + ((item 2 of rs) / 2)
|
|
1294
|
+
if cy < saTop or cy > saBottom then
|
|
1295
|
+
error "the \\"${t}\\" row is scrolled out of the Move… picker's visible list, so clicking it would land outside the picker — narrow the destination or scroll it into view"
|
|
1296
|
+
end if
|
|
1297
|
+
return ((item 1 of rp) as text) & " " & ((item 2 of rp) as text) & " " & ((item 1 of rs) as text) & " " & ((item 2 of rs) as text)
|
|
1298
|
+
end tell`;
|
|
1299
|
+
}
|
|
586
1300
|
/** The abort keystroke sent to dismiss a half-open sheet/popover on failure. */
|
|
587
1301
|
export function axAbortScript() {
|
|
588
1302
|
return `tell application "System Events" to key code 53`; // Escape
|
|
@@ -603,6 +1317,9 @@ export function axAbortScript() {
|
|
|
603
1317
|
export function axCloseReopenActivateScript() {
|
|
604
1318
|
return `tell application "Things3"
|
|
605
1319
|
try
|
|
1320
|
+
-- positional-ok: an APP-LEVEL command to the Things scripting dictionary, not
|
|
1321
|
+
-- an Accessibility element path — "close the front window", whichever it is,
|
|
1322
|
+
-- which is the whole intent of the maneuver (a stuck sheet goes with it).
|
|
606
1323
|
close window 1
|
|
607
1324
|
end try
|
|
608
1325
|
reopen
|
|
@@ -624,6 +1341,8 @@ export function axSheetOpenScript() {
|
|
|
624
1341
|
return `${SE}
|
|
625
1342
|
set sheetOpen to false
|
|
626
1343
|
try
|
|
1344
|
+
-- positional-ok: an EXISTENCE probe over the one attached sheet a window can
|
|
1345
|
+
-- present; no element is read or written through this path.
|
|
627
1346
|
if (exists sheet 1 of (first window whose subrole is "AXStandardWindow")) then set sheetOpen to true
|
|
628
1347
|
end try
|
|
629
1348
|
try
|
|
@@ -791,45 +1510,7 @@ function clickPointCommand(x, y, label) {
|
|
|
791
1510
|
* calendar date at midnight). One stable JXA shape.
|
|
792
1511
|
*/
|
|
793
1512
|
export function axSetDateTimeScript(spec, target) {
|
|
794
|
-
return
|
|
795
|
-
function attr(el,name){ var out=Ref(); if($.AXUIElementCopyAttributeValue(el,$(name),out)!==0) return null; return ObjC.castRefToObject(out[0]); }
|
|
796
|
-
function rolestr(el){ var v=attr(el,'AXRole'); return v? v.js : ''; }
|
|
797
|
-
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; }
|
|
798
|
-
function collect(el,role,depth,out){ if(depth<0) return; if(rolestr(el)===role) out.push(el); var ks=kids(el); for(var i=0;i<ks.length;i++) collect(ks[i],role,depth-1,out); }
|
|
799
|
-
function subrole(el){ var v=attr(el,'AXSubrole'); return v? v.js : ''; }
|
|
800
|
-
function windowsOf(el){ var c=attr(el,'AXWindows'); if(!c) return []; var a=[]; for(var i=0;i<c.count;i++) a.push(c.objectAtIndex(i)); return a; }
|
|
801
|
-
function sizeWH(el){ var s=attr(el,'AXSize'); if(!s) return null; var d=ObjC.castRefToObject($.CFCopyDescription(s)).js; var mw=String(d).match(/w:([-0-9.]+)/); var mh=String(d).match(/h:([-0-9.]+)/); return (mw&&mh)? {w:+mw[1], h:+mh[1]} : null; }
|
|
802
|
-
// Resolve the Repeat-dialog SHELL so the AXDateTimeArea collect walks only its
|
|
803
|
-
// small subtree — never the app-wide tree, whose main-window list content is the
|
|
804
|
-
// 4.4s app-root descent PERF2 removed (docs/lab/perf2-step-latency.md). The dialog
|
|
805
|
-
// presents in TWO shapes (ui-recipes DIALOG_SHELLS, UIC4-a), tried in the SAME
|
|
806
|
-
// priority order the System-Events pathCandidates use: an attached AXSheet on the
|
|
807
|
-
// standard window (Things frontmost), then a detached top-level AXUnknown window
|
|
808
|
-
// that is not the 40x40 utility window (Things backgrounded). null when neither is
|
|
809
|
-
// present — the caller then falls through to the same named "presents 0 date
|
|
810
|
-
// area(s)" error the app-root walk threw when the dialog was absent.
|
|
811
|
-
function findShell(app){
|
|
812
|
-
var wins=windowsOf(app);
|
|
813
|
-
for(var i=0;i<wins.length;i++){ if(subrole(wins[i])==='AXStandardWindow'){ var sh=[]; collect(wins[i],'AXSheet',3,sh); if(sh.length) return sh[0]; } }
|
|
814
|
-
for(var i=0;i<wins.length;i++){ if(subrole(wins[i])==='AXUnknown'){ var wh=sizeWH(wins[i]); if(!wh || !(wh.w===40 && wh.h===40)) return wins[i]; } }
|
|
815
|
-
return null;
|
|
816
|
-
}
|
|
817
|
-
function posY(el){ var p=attr(el,'AXPosition'); if(!p) return 0; var d=ObjC.castRefToObject($.CFCopyDescription(p)).js; var m=String(d).match(/y:([-0-9.]+)/); return m? +m[1] : 0; }
|
|
818
|
-
function timeOfDay(el){ var v=attr(el,'AXValue'); if(!v) return -1; var cal=$.NSCalendar.currentCalendar; return cal.componentFromDate($.NSCalendarUnitHour,v)*60 + cal.componentFromDate($.NSCalendarUnitMinute,v); }
|
|
819
|
-
function pick(areas,target){
|
|
820
|
-
if(areas.length===0) return null;
|
|
821
|
-
var sorted=areas.slice().sort(function(a,b){ return posY(a)-posY(b); });
|
|
822
|
-
if(target==='reminder'){
|
|
823
|
-
var timed=sorted.filter(function(a){ return timeOfDay(a)>0; });
|
|
824
|
-
return timed.length? timed[timed.length-1] : sorted[sorted.length-1];
|
|
825
|
-
}
|
|
826
|
-
var midnight=sorted.filter(function(a){ return timeOfDay(a)===0; });
|
|
827
|
-
if(midnight.length===0) midnight=sorted;
|
|
828
|
-
return target==='ends' ? midnight[midnight.length-1] : midnight[0];
|
|
829
|
-
}
|
|
830
|
-
function inv(areas){ var s=[]; for(var i=0;i<areas.length;i++){ s.push('#'+i+'(y='+Math.round(posY(areas[i]))+',tod='+timeOfDay(areas[i])+')'); } return areas.length? s.join(' ') : '(none)'; }
|
|
831
|
-
function ymdStr(el,cal){ var v=attr(el,'AXValue'); if(!v) return null; var y=cal.componentFromDate($.NSCalendarUnitYear,v), m=cal.componentFromDate($.NSCalendarUnitMonth,v), dd=cal.componentFromDate($.NSCalendarUnitDay,v); return y+'-'+('0'+m).slice(-2)+'-'+('0'+dd).slice(-2); }
|
|
832
|
-
function hmStr(el,cal){ var v=attr(el,'AXValue'); if(!v) return null; var h=cal.componentFromDate($.NSCalendarUnitHour,v), mi=cal.componentFromDate($.NSCalendarUnitMinute,v); return h+':'+('0'+mi).slice(-2); }
|
|
1513
|
+
return `${AX_DATE_AREA_PRELUDE}
|
|
833
1514
|
function run(){
|
|
834
1515
|
var apps=$.NSRunningApplication.runningApplicationsWithBundleIdentifier('com.culturedcode.ThingsMac');
|
|
835
1516
|
if(!apps || apps.count===0) throw new Error('Things not running');
|
|
@@ -867,7 +1548,9 @@ function run(){
|
|
|
867
1548
|
$.NSThread.sleepForTimeInterval(0.2);
|
|
868
1549
|
// READ-BACK: a control can accept the AX write (err 0) yet reject the value —
|
|
869
1550
|
// the macOS error beep — leaving its prior/default value. Fail the step loudly
|
|
870
|
-
// rather than let a garbled commit verify as ok (YANCH1 #493).
|
|
1551
|
+
// rather than let a garbled commit verify as ok (YANCH1 #493). Like every
|
|
1552
|
+
// per-step read-back here it is SELF-REFERENTIAL (it re-reads the area it just
|
|
1553
|
+
// picked), so the pre-commit audit re-checks it from the outside.
|
|
871
1554
|
if(spec.indexOf('date:')===0){
|
|
872
1555
|
var got=ymdStr(dt,cal); var want=spec.slice(5);
|
|
873
1556
|
if(got!==want) throw new Error('set-datetime '+target+' rejected: the control committed '+(got||'(no value)')+', not the requested '+want+' — the write did not take');
|
|
@@ -878,6 +1561,55 @@ function run(){
|
|
|
878
1561
|
return 'OK';
|
|
879
1562
|
}`;
|
|
880
1563
|
}
|
|
1564
|
+
/**
|
|
1565
|
+
* The shared ObjC-bridge prelude for every `AXDateTimeArea` read or write: the
|
|
1566
|
+
* attribute helpers, the DIALOG-SHELL resolver (so the walk stays inside the
|
|
1567
|
+
* dialog's small subtree — the app-root descent PERF2 removed cost ~4.4s on a busy
|
|
1568
|
+
* host), and the deterministic {@link pick} target discriminator. The write
|
|
1569
|
+
* ({@link axSetDateTimeScript}) and the pre-commit read
|
|
1570
|
+
* ({@link axAuditDateAreasScript}) MUST agree on which area is which, so they
|
|
1571
|
+
* share one definition rather than two that can drift apart.
|
|
1572
|
+
*/
|
|
1573
|
+
const AX_DATE_AREA_PRELUDE = `ObjC.import('Foundation'); ObjC.import('AppKit'); ObjC.import('ApplicationServices');
|
|
1574
|
+
function attr(el,name){ var out=Ref(); if($.AXUIElementCopyAttributeValue(el,$(name),out)!==0) return null; return ObjC.castRefToObject(out[0]); }
|
|
1575
|
+
function rolestr(el){ var v=attr(el,'AXRole'); return v? v.js : ''; }
|
|
1576
|
+
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; }
|
|
1577
|
+
function collect(el,role,depth,out){ if(depth<0) return; if(rolestr(el)===role) out.push(el); var ks=kids(el); for(var i=0;i<ks.length;i++) collect(ks[i],role,depth-1,out); }
|
|
1578
|
+
function subrole(el){ var v=attr(el,'AXSubrole'); return v? v.js : ''; }
|
|
1579
|
+
function windowsOf(el){ var c=attr(el,'AXWindows'); if(!c) return []; var a=[]; for(var i=0;i<c.count;i++) a.push(c.objectAtIndex(i)); return a; }
|
|
1580
|
+
function sizeWH(el){ var s=attr(el,'AXSize'); if(!s) return null; var d=ObjC.castRefToObject($.CFCopyDescription(s)).js; var mw=String(d).match(/w:([-0-9.]+)/); var mh=String(d).match(/h:([-0-9.]+)/); return (mw&&mh)? {w:+mw[1], h:+mh[1]} : null; }
|
|
1581
|
+
// Resolve the Repeat-dialog SHELL so the AXDateTimeArea collect walks only its
|
|
1582
|
+
// small subtree — never the app-wide tree, whose main-window list content is the
|
|
1583
|
+
// 4.4s app-root descent PERF2 removed (docs/lab/perf2-step-latency.md). The dialog
|
|
1584
|
+
// presents in TWO shapes (ui-recipes DIALOG_SHELLS, UIC4-a), tried in the SAME
|
|
1585
|
+
// priority order the System-Events pathCandidates use: an attached AXSheet on the
|
|
1586
|
+
// standard window (Things frontmost), then a detached top-level AXUnknown window
|
|
1587
|
+
// that is not the 40x40 utility window (Things backgrounded). null when neither is
|
|
1588
|
+
// present — the caller then falls through to the same named "presents 0 date
|
|
1589
|
+
// area(s)" error the app-root walk threw when the dialog was absent.
|
|
1590
|
+
function findShell(app){
|
|
1591
|
+
var wins=windowsOf(app);
|
|
1592
|
+
for(var i=0;i<wins.length;i++){ if(subrole(wins[i])==='AXStandardWindow'){ var sh=[]; collect(wins[i],'AXSheet',3,sh); if(sh.length) return sh[0]; } }
|
|
1593
|
+
for(var i=0;i<wins.length;i++){ if(subrole(wins[i])==='AXUnknown'){ var wh=sizeWH(wins[i]); if(!wh || !(wh.w===40 && wh.h===40)) return wins[i]; } }
|
|
1594
|
+
return null;
|
|
1595
|
+
}
|
|
1596
|
+
function posY(el){ var p=attr(el,'AXPosition'); if(!p) return 0; var d=ObjC.castRefToObject($.CFCopyDescription(p)).js; var m=String(d).match(/y:([-0-9.]+)/); return m? +m[1] : 0; }
|
|
1597
|
+
function timeOfDay(el){ var v=attr(el,'AXValue'); if(!v) return -1; var cal=$.NSCalendar.currentCalendar; return cal.componentFromDate($.NSCalendarUnitHour,v)*60 + cal.componentFromDate($.NSCalendarUnitMinute,v); }
|
|
1598
|
+
function pick(areas,target){
|
|
1599
|
+
if(areas.length===0) return null;
|
|
1600
|
+
var sorted=areas.slice().sort(function(a,b){ return posY(a)-posY(b); });
|
|
1601
|
+
if(target==='reminder'){
|
|
1602
|
+
var timed=sorted.filter(function(a){ return timeOfDay(a)>0; });
|
|
1603
|
+
return timed.length? timed[timed.length-1] : sorted[sorted.length-1];
|
|
1604
|
+
}
|
|
1605
|
+
var midnight=sorted.filter(function(a){ return timeOfDay(a)===0; });
|
|
1606
|
+
if(midnight.length===0) midnight=sorted;
|
|
1607
|
+
return target==='ends' ? midnight[midnight.length-1] : midnight[0];
|
|
1608
|
+
}
|
|
1609
|
+
function inv(areas){ var s=[]; for(var i=0;i<areas.length;i++){ s.push('#'+i+'(y='+Math.round(posY(areas[i]))+',tod='+timeOfDay(areas[i])+')'); } return areas.length? s.join(' ') : '(none)'; }
|
|
1610
|
+
function ymdStr(el,cal){ var v=attr(el,'AXValue'); if(!v) return null; var y=cal.componentFromDate($.NSCalendarUnitYear,v), m=cal.componentFromDate($.NSCalendarUnitMonth,v), dd=cal.componentFromDate($.NSCalendarUnitDay,v); return y+'-'+('0'+m).slice(-2)+'-'+('0'+dd).slice(-2); }
|
|
1611
|
+
function hmStr(el,cal){ var v=attr(el,'AXValue'); if(!v) return null; var h=cal.componentFromDate($.NSCalendarUnitHour,v), mi=cal.componentFromDate($.NSCalendarUnitMinute,v); return h+':'+('0'+mi).slice(-2); }
|
|
1612
|
+
`;
|
|
881
1613
|
/**
|
|
882
1614
|
* The converge-weekdays step encodes both of its inputs in `value` as
|
|
883
1615
|
* `"<base>|<Weekday>,<Weekday>…"`: the base is the group pop-up index of the
|
|
@@ -1035,6 +1767,33 @@ export function commandForStep(step, targetUuid) {
|
|
|
1035
1767
|
label: step.label,
|
|
1036
1768
|
script: axSetValueScript(step.path ?? "", step.value ?? ""),
|
|
1037
1769
|
};
|
|
1770
|
+
case "set-group-number":
|
|
1771
|
+
return {
|
|
1772
|
+
primitive: "set-group-number",
|
|
1773
|
+
label: step.label,
|
|
1774
|
+
script: axSetGroupNumberScript(step.path ?? "", step.numberTarget ?? "interval", step.value ?? ""),
|
|
1775
|
+
};
|
|
1776
|
+
case "set-row-field":
|
|
1777
|
+
return {
|
|
1778
|
+
primitive: "set-row-field",
|
|
1779
|
+
label: step.label,
|
|
1780
|
+
script: axSetRowFieldScript(step.path ?? "", step.rowLabel ?? "", step.value ?? ""),
|
|
1781
|
+
};
|
|
1782
|
+
case "audit-dialog":
|
|
1783
|
+
// Compiled by driveDialogAudit, which resolves the live dialog shell and the
|
|
1784
|
+
// measured shape first (a control's path and its weekday base both depend on
|
|
1785
|
+
// them). This shape exists only so the step renders/compiles uniformly.
|
|
1786
|
+
return {
|
|
1787
|
+
primitive: "audit-dialog",
|
|
1788
|
+
label: step.label,
|
|
1789
|
+
script: "",
|
|
1790
|
+
};
|
|
1791
|
+
case "type-text":
|
|
1792
|
+
return {
|
|
1793
|
+
primitive: "type-text",
|
|
1794
|
+
label: step.label,
|
|
1795
|
+
script: axTypeTextScript(step.value ?? ""),
|
|
1796
|
+
};
|
|
1038
1797
|
case "select-popup":
|
|
1039
1798
|
return {
|
|
1040
1799
|
primitive: "select-popup",
|
|
@@ -1068,6 +1827,12 @@ export function commandForStep(step, targetUuid) {
|
|
|
1068
1827
|
label: step.label,
|
|
1069
1828
|
script: axSelectNextOccurrenceScript(step.path ?? "", step.value ?? ""),
|
|
1070
1829
|
};
|
|
1830
|
+
case "settle-occurrences":
|
|
1831
|
+
return {
|
|
1832
|
+
primitive: "settle-occurrences",
|
|
1833
|
+
label: step.label,
|
|
1834
|
+
script: axSettleOccurrencesScript(step.path ?? ""),
|
|
1835
|
+
};
|
|
1071
1836
|
case "converge-weekdays":
|
|
1072
1837
|
return {
|
|
1073
1838
|
primitive: "converge-weekdays",
|
|
@@ -1101,11 +1866,26 @@ export function commandForStep(step, targetUuid) {
|
|
|
1101
1866
|
case "click-element":
|
|
1102
1867
|
// Phase 1 of the click: read the target's frame. driveClickElement runs
|
|
1103
1868
|
// this, then posts the click at the resolved center and asserts the outcome.
|
|
1869
|
+
// A `rowCellDescription` step resolves its target by walking the addressed
|
|
1870
|
+
// content table's rows/cells instead (the heading `…` button, which sits
|
|
1871
|
+
// three levels below the table a `whose` clause can reach).
|
|
1104
1872
|
return {
|
|
1105
1873
|
primitive: "resolve-frame",
|
|
1106
1874
|
label: step.label,
|
|
1107
1875
|
lang: "applescript",
|
|
1108
|
-
script:
|
|
1876
|
+
script: step.rowCellDescription !== undefined
|
|
1877
|
+
? axRowCellFrameScript(step.path ?? "", step.rowCellDescription)
|
|
1878
|
+
: axFrameScript(step.path ?? ""),
|
|
1879
|
+
};
|
|
1880
|
+
case "click-picker-row":
|
|
1881
|
+
// Phase 1 of the picker commit: resolve the row carrying the destination's
|
|
1882
|
+
// exact title (identity-checked, uniqueness-checked, on-screen-checked).
|
|
1883
|
+
// driveClickElement then clicks it — the recipe never presses Return.
|
|
1884
|
+
return {
|
|
1885
|
+
primitive: "resolve-frame",
|
|
1886
|
+
label: step.label,
|
|
1887
|
+
lang: "applescript",
|
|
1888
|
+
script: axPickerRowFrameScript(step.path ?? "", step.value ?? ""),
|
|
1109
1889
|
};
|
|
1110
1890
|
case "drag-reorder":
|
|
1111
1891
|
// Composite step: drive() hands it to the sidebar drag driver, which
|
|
@@ -1117,8 +1897,32 @@ export function commandForStep(step, targetUuid) {
|
|
|
1117
1897
|
lang: "javascript",
|
|
1118
1898
|
script: jxaSidebarSnapshotScript(),
|
|
1119
1899
|
};
|
|
1900
|
+
case "chord-reorder":
|
|
1901
|
+
// Composite step: drive() hands it to the heading-chord driver, which
|
|
1902
|
+
// dispatches its own select/chord commands through `run` and asserts the
|
|
1903
|
+
// database between them. This shape only exists so the step renders and
|
|
1904
|
+
// compiles uniformly; the chord it names is the FIRST hop's, and the
|
|
1905
|
+
// driver recomputes every subsequent one from the live order.
|
|
1906
|
+
return chordCommand("up-one");
|
|
1120
1907
|
}
|
|
1121
1908
|
}
|
|
1909
|
+
/**
|
|
1910
|
+
* Recover the message an AppleScript `error "…"` raised from osascript's stderr,
|
|
1911
|
+
* dropping the wrapper osascript adds around it (`<line>:<col>: execution error:
|
|
1912
|
+
* <message> (-1728)`). Returns null when stderr carries no such message, so a
|
|
1913
|
+
* caller can fall back to its own wording. This is what lets a resolver script
|
|
1914
|
+
* REFUSE with a sentence the operator can act on — "the Move… picker offers no
|
|
1915
|
+
* project named X — it offered […]" — instead of the driver's generic guess.
|
|
1916
|
+
*/
|
|
1917
|
+
function scriptErrorText(stderr) {
|
|
1918
|
+
const raw = stderr.trim();
|
|
1919
|
+
if (raw === "")
|
|
1920
|
+
return null;
|
|
1921
|
+
const marker = raw.lastIndexOf("execution error:");
|
|
1922
|
+
const body = marker >= 0 ? raw.slice(marker + "execution error:".length) : raw;
|
|
1923
|
+
const trimmed = body.replace(/\s*\(-?\d+\)\s*$/, "").trim();
|
|
1924
|
+
return trimmed === "" ? null : trimmed;
|
|
1925
|
+
}
|
|
1122
1926
|
/**
|
|
1123
1927
|
* Execute a `click-element` step: resolve the target's AX frame, synthesize a
|
|
1124
1928
|
* mouse click at its center, then verify the declared post-click outcome. Fails
|
|
@@ -1130,10 +1934,17 @@ async function driveClickElement(step, run) {
|
|
|
1130
1934
|
const frameRes = await run(commandForStep(step, ""), STEP_TIMEOUT_MS);
|
|
1131
1935
|
const center = frameRes.ok ? parseFrameCenter(frameRes.stdout) : null;
|
|
1132
1936
|
if (center === null) {
|
|
1937
|
+
// A resolver that REFUSED (rather than merely failing to find an element)
|
|
1938
|
+
// carries the diagnosis — which row it wanted, and what the surface offered
|
|
1939
|
+
// instead. Prefer it over the generic guess: that text is the whole point of
|
|
1940
|
+
// the picker-row and heading-button resolvers (HXPC1).
|
|
1941
|
+
const named = scriptErrorText(frameRes.stderr);
|
|
1133
1942
|
return {
|
|
1134
1943
|
ok: false,
|
|
1135
|
-
why:
|
|
1136
|
-
|
|
1944
|
+
why: named !== null
|
|
1945
|
+
? `${named} — no click was sent`
|
|
1946
|
+
: "its on-screen position did not resolve — a Things update may have moved the control, " +
|
|
1947
|
+
"or the app is not in the expected state; no click was sent",
|
|
1137
1948
|
};
|
|
1138
1949
|
}
|
|
1139
1950
|
const clickRes = await run(clickPointCommand(center.x, center.y, step.label), STEP_TIMEOUT_MS);
|
|
@@ -1162,6 +1973,121 @@ async function driveClickElement(step, run) {
|
|
|
1162
1973
|
}
|
|
1163
1974
|
return { ok: true };
|
|
1164
1975
|
}
|
|
1976
|
+
/**
|
|
1977
|
+
* Execute the PRE-COMMIT FULL-DIALOG AUDIT step (CGRD1): re-read every control the
|
|
1978
|
+
* drive set — through each control's own discriminated address — and refuse the
|
|
1979
|
+
* commit if any of them does not hold the intended value.
|
|
1980
|
+
*
|
|
1981
|
+
* Three resolutions happen here rather than in the recipe, because only the driver
|
|
1982
|
+
* knows them: WHICH dialog shell is live (attached sheet vs detached editor), which
|
|
1983
|
+
* SHAPE the dialog measured (the +1 group-index fork, and which first-occurrence
|
|
1984
|
+
* control class exists), and therefore which candidate path each control has. A
|
|
1985
|
+
* shell that does not resolve, or a shape-dependent control with no shape probed,
|
|
1986
|
+
* fails closed exactly like every other address here — an unaudited commit is not
|
|
1987
|
+
* an option, because the audit is the only non-self-referential check the drive has.
|
|
1988
|
+
*
|
|
1989
|
+
* The System Events sweep and the ObjC date-area sweep are separate commands
|
|
1990
|
+
* because the dialog's date/time controls hold NSDates that System Events cannot
|
|
1991
|
+
* read; both must pass.
|
|
1992
|
+
*/
|
|
1993
|
+
async function driveDialogAudit(step, run, dialogShape) {
|
|
1994
|
+
const plan = step.audit;
|
|
1995
|
+
if (plan === undefined)
|
|
1996
|
+
return { ok: false, why: "no audit plan compiled (recipe bug)" };
|
|
1997
|
+
// Which of the two dialog shells is live, in the SAME priority order the drive's
|
|
1998
|
+
// own candidate resolution used — so the audit reads the dialog the drive wrote.
|
|
1999
|
+
let shellIndex = -1;
|
|
2000
|
+
for (let i = 0; i < plan.shells.length; i += 1) {
|
|
2001
|
+
const res = await run({
|
|
2002
|
+
primitive: "resolve",
|
|
2003
|
+
label: step.label,
|
|
2004
|
+
script: axResolveScript(plan.shells[i]),
|
|
2005
|
+
}, STEP_TIMEOUT_MS);
|
|
2006
|
+
if (res.ok && res.stdout.trim() === "true") {
|
|
2007
|
+
shellIndex = i;
|
|
2008
|
+
break;
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
if (shellIndex < 0) {
|
|
2012
|
+
return {
|
|
2013
|
+
ok: false,
|
|
2014
|
+
why: "the Repeat dialog could not be re-read before committing (neither the attached sheet " +
|
|
2015
|
+
"nor the detached repeat editor window resolved), so what it holds could not be checked",
|
|
2016
|
+
};
|
|
2017
|
+
}
|
|
2018
|
+
const shell = plan.shells[shellIndex];
|
|
2019
|
+
const group = (plan.groups[shellIndex] ?? plan.groups[0]);
|
|
2020
|
+
const scriptControls = [];
|
|
2021
|
+
const dateAreas = [];
|
|
2022
|
+
for (const raw of plan.controls) {
|
|
2023
|
+
if (raw.onlyShape !== undefined || raw.shaped !== undefined) {
|
|
2024
|
+
if (dialogShape === null)
|
|
2025
|
+
return { ok: false, why: SHAPE_UNPROBED };
|
|
2026
|
+
if (raw.onlyShape !== undefined && raw.onlyShape !== dialogShape)
|
|
2027
|
+
continue;
|
|
2028
|
+
}
|
|
2029
|
+
const override = raw.shaped === undefined ? undefined : raw.shaped[dialogShape ?? "next-popup"];
|
|
2030
|
+
if (raw.shaped !== undefined && override === undefined) {
|
|
2031
|
+
return {
|
|
2032
|
+
ok: false,
|
|
2033
|
+
why: `the audit has no check for "${raw.label}" under the "${dialogShape ?? "unmeasured"}" Repeat dialog (recipe bug)`,
|
|
2034
|
+
};
|
|
2035
|
+
}
|
|
2036
|
+
const control = { ...raw, ...override };
|
|
2037
|
+
if (control.kind === "date-area") {
|
|
2038
|
+
dateAreas.push({
|
|
2039
|
+
label: control.label,
|
|
2040
|
+
target: control.dtTarget ?? "next",
|
|
2041
|
+
spec: control.dtSpec ?? "",
|
|
2042
|
+
});
|
|
2043
|
+
continue;
|
|
2044
|
+
}
|
|
2045
|
+
const candidates = control.pathCandidates;
|
|
2046
|
+
scriptControls.push({
|
|
2047
|
+
label: control.label,
|
|
2048
|
+
kind: control.kind,
|
|
2049
|
+
...(candidates !== undefined && {
|
|
2050
|
+
path: (candidates[shellIndex] ?? candidates[0]),
|
|
2051
|
+
}),
|
|
2052
|
+
...(control.numberTarget !== undefined && { numberTarget: control.numberTarget }),
|
|
2053
|
+
...(control.rowLabel !== undefined && { rowLabel: control.rowLabel }),
|
|
2054
|
+
...(control.weekdayBase !== undefined && { weekdayBase: control.weekdayBase }),
|
|
2055
|
+
expected: control.expected ?? [],
|
|
2056
|
+
...(control.expectedLabel !== undefined && { expectedLabel: control.expectedLabel }),
|
|
2057
|
+
});
|
|
2058
|
+
}
|
|
2059
|
+
if (scriptControls.length > 0) {
|
|
2060
|
+
const res = await run({
|
|
2061
|
+
primitive: "audit-dialog",
|
|
2062
|
+
label: step.label,
|
|
2063
|
+
script: axAuditDialogScript({ shell, group, controls: scriptControls }),
|
|
2064
|
+
}, STEP_TIMEOUT_MS);
|
|
2065
|
+
if (!res.ok || res.stdout.trim() !== "OK") {
|
|
2066
|
+
return { ok: false, why: auditFailureText(res) };
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
if (dateAreas.length > 0) {
|
|
2070
|
+
const res = await run({
|
|
2071
|
+
primitive: "audit-dialog",
|
|
2072
|
+
label: step.label,
|
|
2073
|
+
lang: "javascript",
|
|
2074
|
+
script: axAuditDateAreasScript(dateAreas),
|
|
2075
|
+
}, STEP_TIMEOUT_MS);
|
|
2076
|
+
if (!res.ok || res.stdout.trim() !== "OK") {
|
|
2077
|
+
return { ok: false, why: auditFailureText(res) };
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
return { ok: true };
|
|
2081
|
+
}
|
|
2082
|
+
/** The audit's own refusal text, preferred over the driver's generic guess. */
|
|
2083
|
+
function auditFailureText(res) {
|
|
2084
|
+
const named = scriptErrorText(res.stderr);
|
|
2085
|
+
if (named !== null)
|
|
2086
|
+
return `${named} — nothing was committed`;
|
|
2087
|
+
if (res.timedOut === true)
|
|
2088
|
+
return "the pre-commit dialog audit timed out; nothing was committed";
|
|
2089
|
+
return "the Repeat dialog could not be re-read before committing; nothing was committed";
|
|
2090
|
+
}
|
|
1165
2091
|
async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, reachCache = createReachabilityCache()) {
|
|
1166
2092
|
const done = [];
|
|
1167
2093
|
// The overall-drive WATCHDOG (TRACE1 #487). A drive can outlast the caller's
|
|
@@ -1210,7 +2136,14 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
1210
2136
|
// `clear`: how a half-open sheet was cleaned up after a failure (honest — never
|
|
1211
2137
|
// claim a dismissal we could not see, SESSGATE #480); undefined = no sheet was
|
|
1212
2138
|
// opened / no cleanup ran (a benign preamble/canary failure).
|
|
1213
|
-
const partial = (failed, why, clear
|
|
2139
|
+
const partial = (failed, why, clear,
|
|
2140
|
+
/**
|
|
2141
|
+
* The failing step's own transport outcome: `true` when its osascript was
|
|
2142
|
+
* killed by its deadline rather than answering. Together with a blind cleanup
|
|
2143
|
+
* this is what separates "the Things window stopped answering" from "the app
|
|
2144
|
+
* answered and refused/did nothing" (#512) — see {@link ExecuteResult.uiUnreachable}.
|
|
2145
|
+
*/
|
|
2146
|
+
stepTimedOut = false) => {
|
|
1214
2147
|
const base = `ui drive stopped at "${failed}" (${why}). Completed: ${done.join(" → ") || "nothing"}.`;
|
|
1215
2148
|
const cleanup = clear === undefined
|
|
1216
2149
|
? ""
|
|
@@ -1225,7 +2158,28 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
1225
2158
|
: " WARNING: a sheet or popover may still be open in Things — Escape did not dismiss it." +
|
|
1226
2159
|
" Dismiss it manually before retrying (a leftover sheet disables the menu bar and will" +
|
|
1227
2160
|
" make the next drive's preflight fail).";
|
|
1228
|
-
|
|
2161
|
+
// #512: name an environment failure as one. A cleanup that had to run BLIND
|
|
2162
|
+
// is direct evidence the session went AX-blind mid-drive; a step killed by
|
|
2163
|
+
// its own deadline is the window not answering. Either way the app was not
|
|
2164
|
+
// reachable to be driven — which is not the app accepting a command and
|
|
2165
|
+
// changing nothing, and must not be reported as that.
|
|
2166
|
+
const cause = clear?.state === "cleared-blind" ? "unreachable" : stepTimedOut ? "unresponsive" : null;
|
|
2167
|
+
const res = refusal(base + cleanup);
|
|
2168
|
+
if (cause === null)
|
|
2169
|
+
return res;
|
|
2170
|
+
return {
|
|
2171
|
+
...res,
|
|
2172
|
+
uiUnreachable: {
|
|
2173
|
+
step: failed,
|
|
2174
|
+
cause,
|
|
2175
|
+
...(clear !== undefined && { clear: clear.state }),
|
|
2176
|
+
remediation: cause === "unreachable"
|
|
2177
|
+
? "unlock the Mac, or leave the full-screen app so a Things window is visible on the " +
|
|
2178
|
+
"desktop you're viewing, then run the same command again"
|
|
2179
|
+
: "bring Things to the front and check that it is responding, then run the same " +
|
|
2180
|
+
"command again",
|
|
2181
|
+
},
|
|
2182
|
+
};
|
|
1229
2183
|
};
|
|
1230
2184
|
// 0. Run the leading reveal/activate preamble BEFORE the canary. The Items
|
|
1231
2185
|
// menu is context-dependent — its Repeat submenu (and the plain "Repeat…"
|
|
@@ -1239,7 +2193,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
1239
2193
|
// the preamble steps are strictly sequential (select, then foreground) and each must land before the next
|
|
1240
2194
|
const res = await run(commandForStep(step, recipe.targetUuid), STEP_TIMEOUT_MS);
|
|
1241
2195
|
if (!res.ok) {
|
|
1242
|
-
return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed");
|
|
2196
|
+
return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed", undefined, res.timedOut === true);
|
|
1243
2197
|
}
|
|
1244
2198
|
done.push(step.label);
|
|
1245
2199
|
idx += 1;
|
|
@@ -1354,6 +2308,21 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
1354
2308
|
done.push(`${step.label} (${outcome.detail})`);
|
|
1355
2309
|
continue;
|
|
1356
2310
|
}
|
|
2311
|
+
if (step.primitive === "chord-reorder") {
|
|
2312
|
+
// The heading-chord driver runs its own select → chord → DB-assert loop
|
|
2313
|
+
// (ui-chord.ts): every chord is computed from the order it just read, and
|
|
2314
|
+
// a chord that moves nothing (or moves the wrong row) stops the drive
|
|
2315
|
+
// rather than being re-sent. No sheet is involved, so no dismissal clause.
|
|
2316
|
+
if (step.chord === undefined)
|
|
2317
|
+
return partial(step.label, "no chord spec compiled");
|
|
2318
|
+
const spec = step.chord;
|
|
2319
|
+
// the chord ladder depends on the UI state the reveal produced
|
|
2320
|
+
const outcome = await driveHeadingChordReorder(spec, run, aux.headingOrder, (ordinal) => axSelectHeadingRowScript(spec.tablePath, ordinal));
|
|
2321
|
+
if (!outcome.ok)
|
|
2322
|
+
return partial(step.label, outcome.detail);
|
|
2323
|
+
done.push(`${step.label} (${outcome.detail})`);
|
|
2324
|
+
continue;
|
|
2325
|
+
}
|
|
1357
2326
|
// Resolve a candidate-addressed step's effective element before dispatch
|
|
1358
2327
|
// (the sheet-vs-detached-window disjunction). A miss fails closed.
|
|
1359
2328
|
if (step.pathCandidates !== undefined) {
|
|
@@ -1384,7 +2353,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
1384
2353
|
"it again; nothing was entered into the rule"
|
|
1385
2354
|
: res.timedOut === true
|
|
1386
2355
|
? "the dialog-shape probe timed out"
|
|
1387
|
-
: res.stderr.trim() || "the dialog-shape probe failed", clear);
|
|
2356
|
+
: res.stderr.trim() || "the dialog-shape probe failed", clear, res.timedOut === true);
|
|
1388
2357
|
}
|
|
1389
2358
|
dialogShape = verdict;
|
|
1390
2359
|
done.push(`${step.label} (${verdict})`);
|
|
@@ -1408,7 +2377,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
1408
2377
|
? noMatch
|
|
1409
2378
|
: res.timedOut === true
|
|
1410
2379
|
? "the row-selection step timed out"
|
|
1411
|
-
: res.stderr.trim() || "the row-selection step failed", clear);
|
|
2380
|
+
: res.stderr.trim() || "the row-selection step failed", clear, res.timedOut === true);
|
|
1412
2381
|
}
|
|
1413
2382
|
done.push(step.label);
|
|
1414
2383
|
continue;
|
|
@@ -1430,14 +2399,31 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
1430
2399
|
: "the target to-do was not confirmed selected/eligible after the reveal"
|
|
1431
2400
|
: res.timedOut === true
|
|
1432
2401
|
? "the eligibility check timed out"
|
|
1433
|
-
: res.stderr.trim() || "the eligibility check failed", clear);
|
|
2402
|
+
: res.stderr.trim() || "the eligibility check failed", clear, res.timedOut === true);
|
|
2403
|
+
}
|
|
2404
|
+
done.push(step.label);
|
|
2405
|
+
continue;
|
|
2406
|
+
}
|
|
2407
|
+
if (step.primitive === "audit-dialog") {
|
|
2408
|
+
// The last thing before the commit: re-read EVERY control this drive set,
|
|
2409
|
+
// through each control's own discriminated address, and abort fail-closed if
|
|
2410
|
+
// any of them disagrees with what was requested (CGRD1). Every per-step
|
|
2411
|
+
// read-back before this point is self-referential — it re-reads the element it
|
|
2412
|
+
// addressed — so a wrong ADDRESS is invisible until here. A mismatch clears
|
|
2413
|
+
// the dialog through the standard clean-abort path, so nothing is committed.
|
|
2414
|
+
const outcome = await driveDialogAudit(step, run, dialogShape);
|
|
2415
|
+
if (!outcome.ok) {
|
|
2416
|
+
const clear = await clearDialog(run);
|
|
2417
|
+
return partial(step.label, outcome.why ?? "the pre-commit dialog audit failed", clear);
|
|
1434
2418
|
}
|
|
1435
2419
|
done.push(step.label);
|
|
1436
2420
|
continue;
|
|
1437
2421
|
}
|
|
1438
|
-
if (step.primitive === "click-element") {
|
|
2422
|
+
if (step.primitive === "click-element" || step.primitive === "click-picker-row") {
|
|
1439
2423
|
// A mouse click at an AX-resolved frame center (the NATIVE1 primitive),
|
|
1440
|
-
// used only where AXPress is inert (Things' custom `…`/repeat-bar popover)
|
|
2424
|
+
// used only where AXPress is inert (Things' custom `…`/repeat-bar popover)
|
|
2425
|
+
// — and, for `click-picker-row`, where committing by keyboard would take
|
|
2426
|
+
// whatever the app highlighted, including the row that CREATES a project.
|
|
1441
2427
|
// the click depends on the UI state the previous step produced
|
|
1442
2428
|
const outcome = await driveClickElement(step, run);
|
|
1443
2429
|
if (!outcome.ok) {
|
|
@@ -1456,7 +2442,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
1456
2442
|
const clear = step.primitive !== "reveal" && step.primitive !== "activate"
|
|
1457
2443
|
? await clearDialog(run)
|
|
1458
2444
|
: undefined;
|
|
1459
|
-
return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed", clear);
|
|
2445
|
+
return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed", clear, res.timedOut === true);
|
|
1460
2446
|
}
|
|
1461
2447
|
done.push(step.label);
|
|
1462
2448
|
}
|
|
@@ -1527,8 +2513,9 @@ function disabledMatrix() {
|
|
|
1527
2513
|
disruption: 3,
|
|
1528
2514
|
validation: "validated",
|
|
1529
2515
|
notes: "the Accessibility GUI vector is off on this machine — enable it with `things config " +
|
|
1530
|
-
"set ui-enabled true`, then
|
|
1531
|
-
"
|
|
2516
|
+
"set ui-enabled true`, then run `things helpers setup --gui`, which grants GUI-driving " +
|
|
2517
|
+
"to the helper pair (the only identity it is granted to). It drives the local Things " +
|
|
2518
|
+
"GUI and is intended for a dedicated always-on Mac.",
|
|
1532
2519
|
};
|
|
1533
2520
|
}
|
|
1534
2521
|
return matrix;
|
|
@@ -1554,10 +2541,14 @@ export function createUiVector(config, run = defaultRun, aux = {}) {
|
|
|
1554
2541
|
const reachCache = createReachabilityCache();
|
|
1555
2542
|
return {
|
|
1556
2543
|
id: "ui",
|
|
2544
|
+
// Article IV: the pipeline's GUI gate keys on this declaration, never on
|
|
2545
|
+
// the id — see WriteVector.drivesGui.
|
|
2546
|
+
drivesGui: true,
|
|
1557
2547
|
matrix: enabled ? enabledMatrix() : disabledMatrix(),
|
|
1558
2548
|
async execute(invocation) {
|
|
1559
2549
|
if (!enabled) {
|
|
1560
|
-
return refusal("the ui vector is disabled (`things config set ui-enabled true` to enable it
|
|
2550
|
+
return refusal("the ui vector is disabled (`things config set ui-enabled true` to enable it, then " +
|
|
2551
|
+
"`things helpers setup --gui`).");
|
|
1561
2552
|
}
|
|
1562
2553
|
if (invocation.recipe === undefined) {
|
|
1563
2554
|
return refusal("ui invocation carried no recipe (compile bug).");
|