shortcuts-playground 1.2.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 +54 -0
- package/agents/shortcut-builder.md +172 -0
- package/agents/shortcut-remixer.md +239 -0
- package/commands/build-shortcut.md +32 -0
- package/commands/remix-shortcut.md +34 -0
- package/opencode.json +30 -0
- package/package.json +47 -0
- package/plugin/validator.ts +179 -0
- package/skills/shortcuts-playground/ACTIONS.md +713 -0
- package/skills/shortcuts-playground/APPINTENTS.md +2760 -0
- package/skills/shortcuts-playground/AUTOMATION_TRIGGERS.md +133 -0
- package/skills/shortcuts-playground/BEST_PRACTICES.md +357 -0
- package/skills/shortcuts-playground/CHANGELOG.md +585 -0
- package/skills/shortcuts-playground/CONTROL_FLOW.md +777 -0
- package/skills/shortcuts-playground/DATE_TIME.md +75 -0
- package/skills/shortcuts-playground/EXAMPLES.md +738 -0
- package/skills/shortcuts-playground/FILTERS.md +697 -0
- package/skills/shortcuts-playground/HEALTHKIT.md +317 -0
- package/skills/shortcuts-playground/ICONS_AND_COLORS.md +89 -0
- package/skills/shortcuts-playground/JAVASCRIPT_WEBPAGE.md +51 -0
- package/skills/shortcuts-playground/PARAMETER_TYPES.md +1117 -0
- package/skills/shortcuts-playground/PLIST_FORMAT.md +296 -0
- package/skills/shortcuts-playground/README.md +77 -0
- package/skills/shortcuts-playground/SKILL.md +459 -0
- package/skills/shortcuts-playground/THIRD_PARTY_ACTIONS.md +77 -0
- package/skills/shortcuts-playground/TOOLKIT_SNAPSHOT.md +62 -0
- package/skills/shortcuts-playground/URL_SCHEMES.md +59 -0
- package/skills/shortcuts-playground/VARIABLES.md +569 -0
- package/skills/shortcuts-playground/assets/shortcuts-small.svg +14 -0
- package/skills/shortcuts-playground/assets/shortcuts.png +0 -0
- package/skills/shortcuts-playground/data/healthkit-ios26.2-reference.json +2603 -0
- package/skills/shortcuts-playground/data/macos27-shortpy-grounding.json +4085 -0
- package/skills/shortcuts-playground/data/macos27-workflow-trigger-samples.json +1 -0
- package/skills/shortcuts-playground/data/shortcuts-glyph-synonyms.json +5102 -0
- package/skills/shortcuts-playground/data/shortcuts-icon-colors.json +107 -0
- package/skills/shortcuts-playground/data/shortcuts-official-glyph-mapping.json +509 -0
- package/skills/shortcuts-playground/data/toolkit-v63-tool-ids.json +1806 -0
- package/skills/shortcuts-playground/data/toolkit-v78-first-party-enum-cases.json +1 -0
- package/skills/shortcuts-playground/data/toolkit-v78-first-party-parameter-keys.json +1 -0
- package/skills/shortcuts-playground/data/toolkit-v78-ios27-tool-ids.json +1222 -0
- package/skills/shortcuts-playground/data/toolkit-v78-tool-ids.json +2745 -0
- package/skills/shortcuts-playground/data/toolkit-v78-trigger-parameter-keys.json +1051 -0
- package/skills/shortcuts-playground/golden-shortcuts/index.jsonl +19 -0
- package/skills/shortcuts-playground/golden-shortcuts/xml/1be4dde95b794253bf82438e201b33e7.xml +174 -0
- package/skills/shortcuts-playground/golden-shortcuts/xml/2e0fb675e45948aaacee7e534f910492.xml +1016 -0
- package/skills/shortcuts-playground/golden-shortcuts/xml/332c12a0060043b388b22b806be7ab58.xml +1924 -0
- package/skills/shortcuts-playground/golden-shortcuts/xml/3dd4ee24e43f464f92adaa70a0311eaa.xml +1177 -0
- package/skills/shortcuts-playground/golden-shortcuts/xml/4d102301c6e646faa7a8a221f4f4ec98.xml +321 -0
- package/skills/shortcuts-playground/golden-shortcuts/xml/51cc4e26d1044893a0c3f2f3630cf2d2.xml +1933 -0
- package/skills/shortcuts-playground/golden-shortcuts/xml/623e7f1ca5f948e2bd53811fec63e544.xml +136 -0
- package/skills/shortcuts-playground/golden-shortcuts/xml/6a18b76843ac45c384ac3400f3740997.xml +1791 -0
- package/skills/shortcuts-playground/golden-shortcuts/xml/71f0cacb0f604b399b76c5dcb7286e7c.xml +2228 -0
- package/skills/shortcuts-playground/golden-shortcuts/xml/807525ed9f974829bc8494defac923a8.xml +259 -0
- package/skills/shortcuts-playground/golden-shortcuts/xml/8ab0d39826ab4249be58672763caa3ba.xml +420 -0
- package/skills/shortcuts-playground/golden-shortcuts/xml/91c45fd3fd3b427897d9ba485efb1227.xml +800 -0
- package/skills/shortcuts-playground/golden-shortcuts/xml/97be626bb25c41709d175646a7f6d8f2.xml +424 -0
- package/skills/shortcuts-playground/golden-shortcuts/xml/ae59e10d409348f9bd33894f03f9beb4.xml +568 -0
- package/skills/shortcuts-playground/golden-shortcuts/xml/afa83b6be811483b9c32189c41eb9312.xml +391 -0
- package/skills/shortcuts-playground/golden-shortcuts/xml/cfdb083b857e4ac189629fa386d27cdc.xml +193 -0
- package/skills/shortcuts-playground/golden-shortcuts/xml/e01cebe192d64b2fbca80204d03d92ab.xml +111 -0
- package/skills/shortcuts-playground/golden-shortcuts/xml/ef669bcf8bad489d9ef4b88bfaf5772f.xml +780 -0
- package/skills/shortcuts-playground/golden-shortcuts/xml/f44f5caf5e3e48d4817e73af450c4404.xml +570 -0
- package/skills/shortcuts-playground/scripts/generate_healthkit_reference.py +394 -0
- package/skills/shortcuts-playground/scripts/lookup_action_grounding.py +1164 -0
- package/skills/shortcuts-playground/scripts/select_shortcut_icon_color.py +597 -0
- package/skills/shortcuts-playground/scripts/test_random_mixed_shortcuts.py +1067 -0
- package/skills/shortcuts-playground/scripts/test_wiring_regressions.py +2247 -0
- package/skills/shortcuts-playground/scripts/validate_shortcut.py +4526 -0
|
@@ -0,0 +1,4526 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate Shortcuts XML/.shortcut outputs for common structural errors.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python3 scripts/validate_shortcut.py /path/to/Shortcut.xml
|
|
6
|
+
python3 scripts/validate_shortcut.py /path/to/Shortcut.shortcut
|
|
7
|
+
|
|
8
|
+
Exit code:
|
|
9
|
+
0 = pass
|
|
10
|
+
1 = fail
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import plistlib
|
|
17
|
+
import re
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import urllib.parse
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Optional, Tuple
|
|
23
|
+
|
|
24
|
+
ALLOWED_EMPTY_KEYS = {
|
|
25
|
+
# User-configurable or device-specific fields that may be empty at build time
|
|
26
|
+
"WFHomeAccessory",
|
|
27
|
+
"WFHomeAction",
|
|
28
|
+
"WFHomeScene",
|
|
29
|
+
"WFHomeService",
|
|
30
|
+
"WFHomeRoom",
|
|
31
|
+
"WFHomeZone",
|
|
32
|
+
"WFHome",
|
|
33
|
+
"WFContact",
|
|
34
|
+
"WFContacts",
|
|
35
|
+
"WFPerson",
|
|
36
|
+
"WFPeople",
|
|
37
|
+
"WFRecipient",
|
|
38
|
+
"WFRecipients",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Keys where whitespace is meaningful and should not be treated as empty.
|
|
42
|
+
WHITESPACE_SIGNIFICANT_KEYS = {
|
|
43
|
+
"WFTextCustomSeparator",
|
|
44
|
+
"WFReplaceTextFind",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# Keys where an empty string can be intentional and valid.
|
|
48
|
+
ALLOW_EMPTY_STRING_KEYS = {
|
|
49
|
+
"WFReplaceTextReplace",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# Matches a UUID where every hex character is the same — e.g.
|
|
53
|
+
# 11111111-1111-1111-1111-111111111111 or AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA.
|
|
54
|
+
# These are placeholder sequences that agents used to produce instead of real
|
|
55
|
+
# random UUIDs. They're formally valid (36 chars, right shape, uppercase) but
|
|
56
|
+
# break cross-shortcut uniqueness and are a tell that the generator cheated.
|
|
57
|
+
# The agent prompt now requires uuidgen; this regex is the backstop.
|
|
58
|
+
#
|
|
59
|
+
# Segment lengths: 8-4-4-4-12 hex characters. Backreference counts after the
|
|
60
|
+
# initial capture: 7 + 4 + 4 + 4 + 12 = 31 more chars matching the captured
|
|
61
|
+
# hex char, 32 total, plus 4 dashes = 36-char UUID.
|
|
62
|
+
REPEATING_UUID_RE = re.compile(
|
|
63
|
+
r"\b([0-9A-F])\1{7}-\1{4}-\1{4}-\1{4}-\1{12}\b"
|
|
64
|
+
)
|
|
65
|
+
UUID_RE = re.compile(
|
|
66
|
+
r"^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Verified against an Apple-built sample shortcut covering all condition codes.
|
|
70
|
+
# All conditional codes use explicit WFInput as a Type=Variable wrapper. There
|
|
71
|
+
# is no "implicit input" mode; the previously documented numeric-implicit pattern
|
|
72
|
+
# was incorrect.
|
|
73
|
+
|
|
74
|
+
STRING_CONDITIONS = {
|
|
75
|
+
"Equals",
|
|
76
|
+
"Does Not Equal",
|
|
77
|
+
"Contains",
|
|
78
|
+
"Does Not Contain",
|
|
79
|
+
"Begins With",
|
|
80
|
+
"Ends With",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# Codes whose WFCondition value requires a non-empty WFConditionalActionString
|
|
84
|
+
# literal alongside the WFInput variable reference.
|
|
85
|
+
STRING_CONDITION_CODES = {
|
|
86
|
+
4, # is (string equals)
|
|
87
|
+
5, # is not (string inequality)
|
|
88
|
+
8, # begins with
|
|
89
|
+
9, # ends with
|
|
90
|
+
99, # contains
|
|
91
|
+
999, # does not contain
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
NUMBER_CONDITIONS = {
|
|
95
|
+
"Is",
|
|
96
|
+
"Is Not",
|
|
97
|
+
"Is Greater Than",
|
|
98
|
+
"Is Greater Than Or Equal To",
|
|
99
|
+
"Is Less Than",
|
|
100
|
+
"Is Less Than Or Equal To",
|
|
101
|
+
"Is Between",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# Codes whose WFCondition value requires WFNumberValue. Code 1003 also requires
|
|
105
|
+
# WFAnotherNumber for the upper bound of a "between" check.
|
|
106
|
+
NUMBER_CONDITION_CODES = {
|
|
107
|
+
0, # is less than
|
|
108
|
+
1, # is less than or equal to
|
|
109
|
+
2, # is greater than
|
|
110
|
+
3, # is greater than or equal to
|
|
111
|
+
1003, # is between (also requires WFAnotherNumber)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# Codes that test for variable existence and need NEITHER WFConditionalActionString
|
|
115
|
+
# nor WFNumberValue. They still require WFInput.
|
|
116
|
+
EXISTENCE_CONDITION_CODES = {
|
|
117
|
+
100, # has any value
|
|
118
|
+
101, # does not have any value
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# All codes the validator recognizes. Anything outside this set is rejected.
|
|
122
|
+
ALL_CONDITION_CODES = STRING_CONDITION_CODES | NUMBER_CONDITION_CODES | EXISTENCE_CONDITION_CODES
|
|
123
|
+
|
|
124
|
+
LIST_PRODUCING_ACTIONS = {
|
|
125
|
+
"is.workflow.actions.list",
|
|
126
|
+
"is.workflow.actions.additemtolist",
|
|
127
|
+
}
|
|
128
|
+
ADD_ITEM_TO_LIST_POSITIONS = {
|
|
129
|
+
"Beginning",
|
|
130
|
+
"End",
|
|
131
|
+
"Index",
|
|
132
|
+
}
|
|
133
|
+
CHOOSE_FROM_LIST_BOOLEAN_KEYS = {
|
|
134
|
+
"WFChooseFromListActionSelectAll",
|
|
135
|
+
"WFChooseFromListActionSelectMultiple",
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
STORED_CONTENT_ACTIONS = {
|
|
139
|
+
"is.workflow.actions.deletestoredcontent",
|
|
140
|
+
"is.workflow.actions.getstoredcontent",
|
|
141
|
+
"is.workflow.actions.setstoredcontent",
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
ON_SCREEN_CONTEXT_SCOPES = {
|
|
145
|
+
"All Visible",
|
|
146
|
+
"Focused App Only",
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
VPN_OPERATIONS = {
|
|
150
|
+
"Connect",
|
|
151
|
+
"Disconnect",
|
|
152
|
+
"Set On Demand",
|
|
153
|
+
"Toggle",
|
|
154
|
+
"Toggle On Demand",
|
|
155
|
+
}
|
|
156
|
+
VPN_SORT_PROPERTIES = {
|
|
157
|
+
"Name",
|
|
158
|
+
"Random",
|
|
159
|
+
"Server Address",
|
|
160
|
+
}
|
|
161
|
+
VPN_COMPOUND_TYPES = {
|
|
162
|
+
0,
|
|
163
|
+
1,
|
|
164
|
+
"0",
|
|
165
|
+
"1",
|
|
166
|
+
"Any",
|
|
167
|
+
"All",
|
|
168
|
+
}
|
|
169
|
+
FIND_PLACES_SORT_ORDERS = {
|
|
170
|
+
"Distance",
|
|
171
|
+
"Relevance",
|
|
172
|
+
}
|
|
173
|
+
APPEND_NOTE_OPERATIONS = {
|
|
174
|
+
"append",
|
|
175
|
+
"prepend",
|
|
176
|
+
}
|
|
177
|
+
MULTITASKING_MODES = {
|
|
178
|
+
"fullScreenApps",
|
|
179
|
+
"stageManager",
|
|
180
|
+
"windowedApps",
|
|
181
|
+
}
|
|
182
|
+
MATH_OPERATIONS = {
|
|
183
|
+
"-",
|
|
184
|
+
"×",
|
|
185
|
+
"÷",
|
|
186
|
+
"…",
|
|
187
|
+
}
|
|
188
|
+
OPEN_APP_WINDOWING_FORMATS = {
|
|
189
|
+
"Full Screen",
|
|
190
|
+
"Left",
|
|
191
|
+
"Right",
|
|
192
|
+
"Top",
|
|
193
|
+
"Bottom",
|
|
194
|
+
"Top Leading",
|
|
195
|
+
"Top Trailing",
|
|
196
|
+
"Bottom Leading",
|
|
197
|
+
"Bottom Trailing",
|
|
198
|
+
"Left Third",
|
|
199
|
+
"Middle Third",
|
|
200
|
+
"Right Third",
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
TOOLKIT_SNAPSHOT_MIN_MACOS_MAJOR = {
|
|
204
|
+
# ToolKit v78 was captured from macOS 27 Golden Gate. Keep this gated so
|
|
205
|
+
# older macOS hosts do not accidentally validate shortcuts they cannot run.
|
|
206
|
+
"toolkit-v78": 27,
|
|
207
|
+
# iOS Simulator v78 metadata is also OS 27-era. It remains target-gated so
|
|
208
|
+
# iOS-only identifiers do not validate for older target versions.
|
|
209
|
+
"toolkit-v78-ios27": 27,
|
|
210
|
+
}
|
|
211
|
+
TARGET_MACOS_ENV_VARS = (
|
|
212
|
+
"SHORTCUTS_PLAYGROUND_TARGET_MACOS",
|
|
213
|
+
"CLAUDE_PLUGIN_OPTION_TARGET_MACOS",
|
|
214
|
+
)
|
|
215
|
+
TARGET_PLATFORM_ENV_VARS = (
|
|
216
|
+
"SHORTCUTS_PLAYGROUND_TARGET_PLATFORM",
|
|
217
|
+
"CLAUDE_PLUGIN_OPTION_TARGET_PLATFORM",
|
|
218
|
+
)
|
|
219
|
+
OS27_PARAMETER_KEYS_BY_ACTION = {
|
|
220
|
+
# Cross-checked against the Automators OS 26 -> 27 action delta and local
|
|
221
|
+
# ToolKit v78 metadata. These action identifiers may exist on older OSes,
|
|
222
|
+
# but the listed top-level parameter keys are OS 27-era metadata.
|
|
223
|
+
"com.apple.Safari.CreateNewTabGroup": {"contents"},
|
|
224
|
+
"com.apple.mobilesafari.CreateNewTabGroup": {"contents"},
|
|
225
|
+
"com.apple.mobilenotes.SharingExtension": {"interpretAsMarkdown"},
|
|
226
|
+
"is.workflow.actions.appendnote": {
|
|
227
|
+
"ignoreWhitespace",
|
|
228
|
+
"interpretAsMarkdown",
|
|
229
|
+
"section",
|
|
230
|
+
},
|
|
231
|
+
"is.workflow.actions.askllm": {"FollowUp", "WFAllowWebSearch"},
|
|
232
|
+
"is.workflow.actions.extracttextfromimage": {"imageFile"},
|
|
233
|
+
"is.workflow.actions.getdistance": {"WFAvoidHighways", "WFAvoidTolls"},
|
|
234
|
+
"is.workflow.actions.gettraveltime": {"WFAvoidHighways", "WFAvoidTolls"},
|
|
235
|
+
"is.workflow.actions.hide.app": {"WFAppsExcept"},
|
|
236
|
+
"is.workflow.actions.quit.app": {"WFAppsExcept"},
|
|
237
|
+
"is.workflow.actions.scanbarcode": {"imageFile"},
|
|
238
|
+
}
|
|
239
|
+
OS27_PARAMETER_REASON = "macOS 27+ (toolkit-v78 parameter)"
|
|
240
|
+
OS27_BOOLEAN_PARAMETER_KEYS_BY_ACTION = {
|
|
241
|
+
"com.apple.ShortcutsActions.SetBatteryChargeLimitAction": {"setUntilTomorrow"},
|
|
242
|
+
"com.apple.ShortcutsActions.SetMultitaskingModeAction": {
|
|
243
|
+
"automaticallyShowAndHideDock",
|
|
244
|
+
"showRecentApps",
|
|
245
|
+
},
|
|
246
|
+
"com.apple.mobilenotes.SharingExtension": {"interpretAsMarkdown"},
|
|
247
|
+
"is.workflow.actions.addnewreminder": {"WFUrgent"},
|
|
248
|
+
"is.workflow.actions.appendnote": {"ignoreWhitespace", "interpretAsMarkdown"},
|
|
249
|
+
"is.workflow.actions.askllm": {"FollowUp", "WFAllowWebSearch"},
|
|
250
|
+
"is.workflow.actions.getdistance": {"WFAvoidHighways", "WFAvoidTolls"},
|
|
251
|
+
"is.workflow.actions.gettraveltime": {"WFAvoidHighways", "WFAvoidTolls"},
|
|
252
|
+
}
|
|
253
|
+
GET_DISTANCE_ROUTE_MODES = {"Driving", "Walking", "Biking", "Direct"}
|
|
254
|
+
GET_TRAVEL_TIME_ROUTE_MODES = {"Driving", "Walking", "Biking", "Transit"}
|
|
255
|
+
GET_DISTANCE_UNITS = {"Miles", "Kilometers"}
|
|
256
|
+
GET_DISTANCE_ACCURACY_VALUES = {
|
|
257
|
+
"Best",
|
|
258
|
+
"NearestTenMeters",
|
|
259
|
+
"HundredMeters",
|
|
260
|
+
"Kilometer",
|
|
261
|
+
"ThreeKilometers",
|
|
262
|
+
}
|
|
263
|
+
APP_ACTION_MODES = {"App", "All Apps"}
|
|
264
|
+
CONTENT_ITEM_INPUT_PARAMETERS = {"Library"}
|
|
265
|
+
TOOLKIT_PARAMETER_CATALOG_MIN_MACOS_MAJOR = 27
|
|
266
|
+
WORKFLOW_TRIGGER_CATALOG_MIN_MACOS_MAJOR = 27
|
|
267
|
+
TOOLKIT_PARAMETER_STRUCTURAL_KEYS = {
|
|
268
|
+
# Shortcuts plist metadata keys, not action parameters in ToolKit.
|
|
269
|
+
"AppIntentDescriptor",
|
|
270
|
+
"CustomOutputName",
|
|
271
|
+
"UUID",
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
REQUIRED_INPUT_ACTIONS = {
|
|
275
|
+
# Actions that should always have explicit input wired
|
|
276
|
+
"is.workflow.actions.openurl",
|
|
277
|
+
"is.workflow.actions.detect.link",
|
|
278
|
+
"is.workflow.actions.choosefromlist",
|
|
279
|
+
"is.workflow.actions.base64encode",
|
|
280
|
+
"is.workflow.actions.text.replace",
|
|
281
|
+
"is.workflow.actions.setclipboard",
|
|
282
|
+
"is.workflow.actions.count",
|
|
283
|
+
"is.workflow.actions.math",
|
|
284
|
+
"is.workflow.actions.round",
|
|
285
|
+
"is.workflow.actions.detect.dictionary",
|
|
286
|
+
"is.workflow.actions.image.convert",
|
|
287
|
+
"is.workflow.actions.properties.weather.conditions",
|
|
288
|
+
"is.workflow.actions.gettimebetweendates",
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
EDITOR_VISIBLE_INPUT_ACTIONS = {
|
|
292
|
+
# Inputs that commonly render blank unless represented as a tokenized string
|
|
293
|
+
"is.workflow.actions.detect.link",
|
|
294
|
+
"is.workflow.actions.text.replace",
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
TEXT_INPUT_KEY_ACTIONS = {
|
|
298
|
+
# Actions whose primary input parameter key is "text", not WFInput
|
|
299
|
+
"is.workflow.actions.text.changecase",
|
|
300
|
+
"is.workflow.actions.text.split",
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
VCARD_MARKER = "ALLOW_VCARD"
|
|
304
|
+
TOKEN_FILE_MARKER = "ALLOW_TOKEN_FILE"
|
|
305
|
+
ALLOW_DATETIME_FORMAT_MARKER = "ALLOW_DATETIME_FORMAT"
|
|
306
|
+
ALLOW_MANUAL_UNIT_CONVERSION_MARKER = "ALLOW_MANUAL_UNIT_CONVERSION"
|
|
307
|
+
TODOIST_TASKS_PREFIX = "https://api.todoist.com/rest/v2/tasks/"
|
|
308
|
+
COMMENT_INTERNAL_WF_KEY_RE = re.compile(r"\bWF[A-Z][A-Za-z0-9]*\b")
|
|
309
|
+
COMMENT_ALLOWED_MARKERS = {
|
|
310
|
+
VCARD_MARKER,
|
|
311
|
+
TOKEN_FILE_MARKER,
|
|
312
|
+
ALLOW_DATETIME_FORMAT_MARKER,
|
|
313
|
+
ALLOW_MANUAL_UNIT_CONVERSION_MARKER,
|
|
314
|
+
}
|
|
315
|
+
COMMENT_WF_FRIENDLY_LABELS = {
|
|
316
|
+
"WFInput": "Input",
|
|
317
|
+
"WFDate": "Date",
|
|
318
|
+
"WFImage": "Image",
|
|
319
|
+
"WFURL": "URL",
|
|
320
|
+
"WFDuration": "Duration",
|
|
321
|
+
"WFAskActionPrompt": "Prompt",
|
|
322
|
+
"WFVariableName": "Variable Name",
|
|
323
|
+
"WFCondition": "Condition",
|
|
324
|
+
"WFContentItemPropertyName": "Detail",
|
|
325
|
+
"WFRequestVariable": "Request Body",
|
|
326
|
+
"WFJSONValues": "JSON",
|
|
327
|
+
"WFTextActionText": "Text",
|
|
328
|
+
"WFCommentActionText": "Comment",
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
FILE_ACTION_PREFIXES = (
|
|
332
|
+
"is.workflow.actions.file.",
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
FILE_ACTION_IDS = {
|
|
336
|
+
"is.workflow.actions.getfile",
|
|
337
|
+
"is.workflow.actions.documentpicker.open",
|
|
338
|
+
"is.workflow.actions.documentpicker.save",
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
TOKEN_HINT_RE = re.compile(
|
|
342
|
+
r"\b(api[\s_-]?(?:key|token)|bearer[\s_-]?token|access[\s_-]?token|secret[\s_-]?(?:key|token)|auth[\s_-]?token)\b",
|
|
343
|
+
re.IGNORECASE,
|
|
344
|
+
)
|
|
345
|
+
LANG_CODE_RE = re.compile(r"^[a-z]{2,3}(-[A-Za-z0-9]+)?$")
|
|
346
|
+
IDENTIFIER_TOKEN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
347
|
+
THIRD_PARTY_IDENTIFIER_RE = re.compile(r"^[a-z0-9]+(\.[A-Za-z0-9_-]+){2,}$")
|
|
348
|
+
UNSAFE_ERROR_DOT_PATH_RE = re.compile(r"^error\.", re.IGNORECASE)
|
|
349
|
+
|
|
350
|
+
DATE_DELTA_UNITS = {
|
|
351
|
+
"seconds",
|
|
352
|
+
"minutes",
|
|
353
|
+
"hours",
|
|
354
|
+
"days",
|
|
355
|
+
"weeks",
|
|
356
|
+
"months",
|
|
357
|
+
"years",
|
|
358
|
+
"second",
|
|
359
|
+
"minute",
|
|
360
|
+
"hour",
|
|
361
|
+
"day",
|
|
362
|
+
"week",
|
|
363
|
+
"month",
|
|
364
|
+
"year",
|
|
365
|
+
# Common Shortcuts aliases seen in exported payloads.
|
|
366
|
+
"sec",
|
|
367
|
+
"secs",
|
|
368
|
+
"min",
|
|
369
|
+
"mins",
|
|
370
|
+
"hr",
|
|
371
|
+
"hrs",
|
|
372
|
+
"wk",
|
|
373
|
+
"wks",
|
|
374
|
+
"mo",
|
|
375
|
+
"mos",
|
|
376
|
+
"yr",
|
|
377
|
+
"yrs",
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
CONTROL_FLOW_TOOLKIT_EXCEPTIONS = {
|
|
381
|
+
"is.workflow.actions.conditional",
|
|
382
|
+
"is.workflow.actions.repeat.count",
|
|
383
|
+
"is.workflow.actions.repeat.each",
|
|
384
|
+
"is.workflow.actions.choosefrommenu",
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
KNOWN_AGGRANDIZEMENT_TYPES = {
|
|
388
|
+
"WFCoercionVariableAggrandizement",
|
|
389
|
+
"WFPropertyVariableAggrandizement",
|
|
390
|
+
"WFDictionaryValueVariableAggrandizement",
|
|
391
|
+
"WFDateFormatVariableAggrandizement",
|
|
392
|
+
"WFUnitVariableAggrandizement",
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
NOTES_CREATE_ACTIONS = {
|
|
396
|
+
"com.apple.mobilenotes.SharingExtension",
|
|
397
|
+
"com.apple.mobilenotes.CreateNoteLinkAction",
|
|
398
|
+
"com.apple.Notes.CreateNoteFromMarkdownLinkAction",
|
|
399
|
+
"com.apple.Notes.CreateNoteLinkAction",
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
NOTES_TITLE_KEYS = {
|
|
403
|
+
"wfnotetitle",
|
|
404
|
+
"title",
|
|
405
|
+
"name",
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
NOTES_CONTENT_KEYS = {
|
|
409
|
+
"wfnotecontentitem",
|
|
410
|
+
"wfcreatenoteinput",
|
|
411
|
+
"markdown",
|
|
412
|
+
"markdowncontents",
|
|
413
|
+
"content",
|
|
414
|
+
"text",
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
REQUIRED_URL_ACTIONS = {
|
|
418
|
+
"is.workflow.actions.url",
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
REQUIRED_URL_INPUT_ACTIONS = {
|
|
422
|
+
"is.workflow.actions.downloadurl",
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
SHORTCUTS_URL_ACTIONS = {
|
|
426
|
+
"is.workflow.actions.url",
|
|
427
|
+
"is.workflow.actions.openurl",
|
|
428
|
+
"is.workflow.actions.openxcallbackurl",
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
WEATHER_SOURCE_ACTIONS = {
|
|
432
|
+
"is.workflow.actions.weather.currentconditions",
|
|
433
|
+
"is.workflow.actions.weather.forecast",
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
LOCATION_SOURCE_ACTIONS = {
|
|
437
|
+
"is.workflow.actions.getcurrentlocation",
|
|
438
|
+
"is.workflow.actions.location",
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
WEATHER_DETAIL_PLACEHOLDER_VALUES = {
|
|
442
|
+
"detail",
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
WEATHER_DETAIL_SUPPORTED_VALUES = {
|
|
446
|
+
"Date",
|
|
447
|
+
"Location",
|
|
448
|
+
"Temperature",
|
|
449
|
+
"Low",
|
|
450
|
+
"High",
|
|
451
|
+
"Feels Like",
|
|
452
|
+
"Condition",
|
|
453
|
+
"Visibility",
|
|
454
|
+
"Dewpoint",
|
|
455
|
+
"Humidity",
|
|
456
|
+
"Pressure",
|
|
457
|
+
"Precipitation Amount",
|
|
458
|
+
"Precipitation Chance",
|
|
459
|
+
"Wind Speed",
|
|
460
|
+
"Wind Direction",
|
|
461
|
+
"UV Index",
|
|
462
|
+
"Sunrise Time",
|
|
463
|
+
"Sunset Time",
|
|
464
|
+
"Air Quality Index",
|
|
465
|
+
"Air Quality Category",
|
|
466
|
+
"Air Pollutants",
|
|
467
|
+
"Name",
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
WEATHER_DETAIL_LIST_VALUES = {
|
|
471
|
+
"Sunrise Time",
|
|
472
|
+
"Sunset Time",
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
LOCATION_PARAMETER_KEYS = {
|
|
476
|
+
"WFLocation",
|
|
477
|
+
"WFWeatherCustomLocation",
|
|
478
|
+
# Legacy export key still seen in some installed shortcuts.
|
|
479
|
+
"WFWeatherLocation",
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
DESTRUCTIVE_FILE_ACTIONS = {
|
|
483
|
+
"is.workflow.actions.file.delete": "Delete File",
|
|
484
|
+
"is.workflow.actions.file.move": "Move File",
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
RENAMED_COPY_OUTPUT_ACTIONS = {
|
|
488
|
+
"is.workflow.actions.documentpicker.save": "Save File",
|
|
489
|
+
"is.workflow.actions.share": "Share",
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
UNUSED_OUTPUT_ACTIONS = {
|
|
493
|
+
"is.workflow.actions.gettext",
|
|
494
|
+
"is.workflow.actions.number",
|
|
495
|
+
"is.workflow.actions.url",
|
|
496
|
+
"is.workflow.actions.getvalueforkey",
|
|
497
|
+
"is.workflow.actions.format.date",
|
|
498
|
+
"is.workflow.actions.ask",
|
|
499
|
+
"is.workflow.actions.count",
|
|
500
|
+
"is.workflow.actions.math",
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
HEALTH_FIND_SAMPLES_ACTION = "is.workflow.actions.filter.health.quantity"
|
|
504
|
+
HEALTH_LOG_SAMPLE_ACTION = "is.workflow.actions.health.quantity.log"
|
|
505
|
+
HEALTH_LOG_WORKOUT_ACTION = "is.workflow.actions.health.workout.log"
|
|
506
|
+
HEALTH_SAMPLE_DETAIL_ACTION = "is.workflow.actions.properties.health.quantity"
|
|
507
|
+
|
|
508
|
+
HEALTH_IOS_ONLY_ACTIONS = {
|
|
509
|
+
HEALTH_FIND_SAMPLES_ACTION,
|
|
510
|
+
HEALTH_LOG_SAMPLE_ACTION,
|
|
511
|
+
HEALTH_LOG_WORKOUT_ACTION,
|
|
512
|
+
HEALTH_SAMPLE_DETAIL_ACTION,
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
HEALTH_SAMPLE_SOURCE_ACTIONS = {
|
|
516
|
+
HEALTH_FIND_SAMPLES_ACTION,
|
|
517
|
+
HEALTH_LOG_SAMPLE_ACTION,
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
HEALTH_SAMPLE_DETAIL_PROPERTIES = {
|
|
521
|
+
"Type",
|
|
522
|
+
"Value",
|
|
523
|
+
"Unit",
|
|
524
|
+
"Start Date",
|
|
525
|
+
"End Date",
|
|
526
|
+
"Duration",
|
|
527
|
+
"Source",
|
|
528
|
+
"Name",
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
HEALTH_QUANTITY_FIELD_KEYS = {
|
|
532
|
+
"WFQuantitySampleQuantity",
|
|
533
|
+
"WFQuantitySampleAdditionalQuantity",
|
|
534
|
+
"WFWorkoutDuration",
|
|
535
|
+
"WFWorkoutCaloriesQuantity",
|
|
536
|
+
"WFWorkoutDistanceQuantity",
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
HEALTH_REQUIRED_MAIN_QUANTITY_KEYS = {
|
|
540
|
+
"WFQuantitySampleQuantity",
|
|
541
|
+
"WFWorkoutDuration",
|
|
542
|
+
"WFWorkoutCaloriesQuantity",
|
|
543
|
+
"WFWorkoutDistanceQuantity",
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _load_healthkit_reference_sets() -> dict[str, set[str]]:
|
|
548
|
+
out = {
|
|
549
|
+
"quantity_types": set(),
|
|
550
|
+
"find_sample_types": set(),
|
|
551
|
+
"sample_types": set(),
|
|
552
|
+
"category_values": set(),
|
|
553
|
+
"workouts": set(),
|
|
554
|
+
"units": set(),
|
|
555
|
+
}
|
|
556
|
+
ref_path = Path(__file__).resolve().parents[1] / "data/healthkit-ios26.2-reference.json"
|
|
557
|
+
if not ref_path.exists():
|
|
558
|
+
return out
|
|
559
|
+
try:
|
|
560
|
+
payload = json.loads(ref_path.read_text(encoding="utf-8"))
|
|
561
|
+
except (json.JSONDecodeError, OSError):
|
|
562
|
+
return out
|
|
563
|
+
|
|
564
|
+
def labels_from_rows(rows) -> set[str]:
|
|
565
|
+
labels: set[str] = set()
|
|
566
|
+
if not isinstance(rows, list):
|
|
567
|
+
return labels
|
|
568
|
+
for row in rows:
|
|
569
|
+
if not isinstance(row, dict):
|
|
570
|
+
continue
|
|
571
|
+
for key in ("shortcut_label_guess", "sdk_suffix"):
|
|
572
|
+
value = row.get(key)
|
|
573
|
+
if isinstance(value, str) and value:
|
|
574
|
+
labels.add(value)
|
|
575
|
+
observed = row.get("observed_shortcuts_labels")
|
|
576
|
+
if isinstance(observed, list):
|
|
577
|
+
labels.update(item for item in observed if isinstance(item, str) and item)
|
|
578
|
+
return labels
|
|
579
|
+
|
|
580
|
+
def find_sample_labels_from_rows(rows) -> set[str]:
|
|
581
|
+
labels: set[str] = set()
|
|
582
|
+
if not isinstance(rows, list):
|
|
583
|
+
return labels
|
|
584
|
+
for row in rows:
|
|
585
|
+
if not isinstance(row, dict):
|
|
586
|
+
continue
|
|
587
|
+
observed_find = row.get("observed_find_samples_labels")
|
|
588
|
+
if isinstance(observed_find, list) and observed_find:
|
|
589
|
+
labels.update(item for item in observed_find if isinstance(item, str) and item)
|
|
590
|
+
continue
|
|
591
|
+
for key in ("shortcut_label_guess", "sdk_suffix"):
|
|
592
|
+
value = row.get(key)
|
|
593
|
+
if isinstance(value, str) and value:
|
|
594
|
+
labels.add(value)
|
|
595
|
+
observed = row.get("observed_shortcuts_labels")
|
|
596
|
+
if isinstance(observed, list):
|
|
597
|
+
labels.update(item for item in observed if isinstance(item, str) and item)
|
|
598
|
+
return labels
|
|
599
|
+
|
|
600
|
+
quantity_labels = labels_from_rows(payload.get("quantity_types"))
|
|
601
|
+
category_labels = labels_from_rows(payload.get("category_types"))
|
|
602
|
+
out["find_sample_types"].update(find_sample_labels_from_rows(payload.get("quantity_types")))
|
|
603
|
+
out["find_sample_types"].update(find_sample_labels_from_rows(payload.get("category_types")))
|
|
604
|
+
out["quantity_types"].update(quantity_labels)
|
|
605
|
+
out["sample_types"].update(quantity_labels | category_labels)
|
|
606
|
+
out["workouts"].update(labels_from_rows(payload.get("workout_activity_types")))
|
|
607
|
+
|
|
608
|
+
category_values = payload.get("category_values")
|
|
609
|
+
if isinstance(category_values, dict):
|
|
610
|
+
for rows in category_values.values():
|
|
611
|
+
if not isinstance(rows, list):
|
|
612
|
+
continue
|
|
613
|
+
for row in rows:
|
|
614
|
+
if not isinstance(row, dict):
|
|
615
|
+
continue
|
|
616
|
+
for key in ("shortcut_label_guess", "symbol"):
|
|
617
|
+
value = row.get(key)
|
|
618
|
+
if isinstance(value, str) and value:
|
|
619
|
+
out["category_values"].add(value)
|
|
620
|
+
|
|
621
|
+
for row in payload.get("quantity_units", []):
|
|
622
|
+
if isinstance(row, dict):
|
|
623
|
+
unit = row.get("unit")
|
|
624
|
+
if isinstance(unit, str) and unit:
|
|
625
|
+
out["units"].add(unit)
|
|
626
|
+
for unit in payload.get("quantity_unit_hints", []):
|
|
627
|
+
if isinstance(unit, str) and unit:
|
|
628
|
+
out["units"].add(unit)
|
|
629
|
+
|
|
630
|
+
return out
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
HEALTH_REFERENCE_SETS = _load_healthkit_reference_sets()
|
|
634
|
+
|
|
635
|
+
BUILTIN_VARIABLES = {
|
|
636
|
+
"Repeat Item",
|
|
637
|
+
"Repeat Item 2",
|
|
638
|
+
"Repeat Item 3",
|
|
639
|
+
"Repeat Item 4",
|
|
640
|
+
"Repeat Results",
|
|
641
|
+
"Repeat Results 2",
|
|
642
|
+
"Repeat Results 3",
|
|
643
|
+
"Repeat Index",
|
|
644
|
+
"Repeat Index 2",
|
|
645
|
+
"Repeat Index 3",
|
|
646
|
+
"Shortcut Input",
|
|
647
|
+
"Clipboard",
|
|
648
|
+
"Current Date",
|
|
649
|
+
"Current Location",
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
ACTION_ALIAS_HINTS: dict[str, str] = {
|
|
653
|
+
"is.workflow.actions.text": "is.workflow.actions.gettext",
|
|
654
|
+
"is.workflow.actions.translate": "is.workflow.actions.text.translate",
|
|
655
|
+
"is.workflow.actions.getshortcutinput": "is.workflow.actions.input",
|
|
656
|
+
"is.workflow.actions.sendmail": "is.workflow.actions.sendemail",
|
|
657
|
+
"is.workflow.actions.runscript": "is.workflow.actions.runshellscript (or runapplescript / runjavascriptforautomation)",
|
|
658
|
+
"is.workflow.actions.properties.podcastepisode": "is.workflow.actions.properties.podcast or is.workflow.actions.properties.podcastshow",
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
UNIT_KEYWORD_PATTERNS: list[re.Pattern] = [
|
|
662
|
+
re.compile(r"\bmiles?\b", re.I),
|
|
663
|
+
re.compile(r"\bkilometers?\b", re.I),
|
|
664
|
+
re.compile(r"\bkilometres?\b", re.I),
|
|
665
|
+
re.compile(r"\bkm\b", re.I),
|
|
666
|
+
re.compile(r"\bmeters?\b", re.I),
|
|
667
|
+
re.compile(r"\bmetres?\b", re.I),
|
|
668
|
+
re.compile(r"\bcm\b", re.I),
|
|
669
|
+
re.compile(r"\bmm\b", re.I),
|
|
670
|
+
re.compile(r"\bfeet\b", re.I),
|
|
671
|
+
re.compile(r"\bfoot\b", re.I),
|
|
672
|
+
re.compile(r"\bft\b", re.I),
|
|
673
|
+
re.compile(r"\byards?\b", re.I),
|
|
674
|
+
re.compile(r"\binches?\b", re.I),
|
|
675
|
+
re.compile(r"\bpounds?\b", re.I),
|
|
676
|
+
re.compile(r"\blbs?\b", re.I),
|
|
677
|
+
re.compile(r"\blb\b", re.I),
|
|
678
|
+
re.compile(r"\bkilograms?\b", re.I),
|
|
679
|
+
re.compile(r"\bkg\b", re.I),
|
|
680
|
+
re.compile(r"\boz\b", re.I),
|
|
681
|
+
re.compile(r"\bounces?\b", re.I),
|
|
682
|
+
re.compile(r"\bgallons?\b", re.I),
|
|
683
|
+
re.compile(r"\bliters?\b", re.I),
|
|
684
|
+
re.compile(r"\blitres?\b", re.I),
|
|
685
|
+
re.compile(r"\bmilliliters?\b", re.I),
|
|
686
|
+
re.compile(r"\bmillilitres?\b", re.I),
|
|
687
|
+
re.compile(r"\bml\b", re.I),
|
|
688
|
+
re.compile(r"°f", re.I),
|
|
689
|
+
re.compile(r"°c", re.I),
|
|
690
|
+
re.compile(r"\bfahrenheit\b", re.I),
|
|
691
|
+
re.compile(r"\bcelsius\b", re.I),
|
|
692
|
+
]
|
|
693
|
+
|
|
694
|
+
UNIT_KEYWORD_IGNORED_KEYS = {
|
|
695
|
+
"WFDateFormat",
|
|
696
|
+
"WFDateFormatString",
|
|
697
|
+
"WFTimeFormatStyle",
|
|
698
|
+
"WFRelativeDateFormatStyle",
|
|
699
|
+
"WFMatchTextPattern",
|
|
700
|
+
# Key used inside WFDateFormatVariableAggrandizement payloads — matches
|
|
701
|
+
# the literal plist key, not the WF-prefixed parameter. Date format strings
|
|
702
|
+
# like "yyyy-MM-dd" contain "MM" which otherwise trips \bmm\b.
|
|
703
|
+
"DateFormat",
|
|
704
|
+
# Unit values inside WFQuantityFieldValue are structured metadata, not
|
|
705
|
+
# evidence of a manual unit conversion.
|
|
706
|
+
"Unit",
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _coerce_control_flow_mode(params: dict) -> int | None:
|
|
711
|
+
"""Extract WFControlFlowMode as int, coercing string digits."""
|
|
712
|
+
mode = params.get("WFControlFlowMode")
|
|
713
|
+
if isinstance(mode, str) and mode.isdigit():
|
|
714
|
+
return int(mode)
|
|
715
|
+
return mode
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _collect_variable_append_counts(actions: list[dict]) -> dict[str, int]:
|
|
719
|
+
counts: dict[str, int] = {}
|
|
720
|
+
for act in actions:
|
|
721
|
+
if act.get("WFWorkflowActionIdentifier") != "is.workflow.actions.appendvariable":
|
|
722
|
+
continue
|
|
723
|
+
params = act.get("WFWorkflowActionParameters") or {}
|
|
724
|
+
name = params.get("WFVariableName")
|
|
725
|
+
if isinstance(name, str) and name.strip():
|
|
726
|
+
counts[name] = counts.get(name, 0) + 1
|
|
727
|
+
return counts
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def read_text(path: Path) -> str:
|
|
731
|
+
return path.read_text(encoding="utf-8", errors="ignore")
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def parse_actions_md(text: str) -> set[str]:
|
|
735
|
+
"""Parse ACTIONS.md and return the set of valid action identifiers.
|
|
736
|
+
|
|
737
|
+
Only rows inside tables whose first column header is "Identifier" are
|
|
738
|
+
treated as action rows. Parameter/property tables (first column
|
|
739
|
+
"Parameter", "Property", "Class Name", etc.) are skipped so their
|
|
740
|
+
backticked cells do not pollute the allowlist — a prior version wrongly
|
|
741
|
+
injected entries like `is.workflow.actions.UUID` / `is.workflow.actions.Album`.
|
|
742
|
+
"""
|
|
743
|
+
|
|
744
|
+
actions: set[str] = set()
|
|
745
|
+
in_identifier_table = False
|
|
746
|
+
|
|
747
|
+
def add_action_id(token: str):
|
|
748
|
+
if not IDENTIFIER_TOKEN_RE.match(token):
|
|
749
|
+
return
|
|
750
|
+
if token.startswith("is.workflow.actions."):
|
|
751
|
+
actions.add(token)
|
|
752
|
+
return
|
|
753
|
+
if token.startswith(("WF", "com.apple.", "io.")):
|
|
754
|
+
return
|
|
755
|
+
actions.add(f"is.workflow.actions.{token}")
|
|
756
|
+
|
|
757
|
+
header_re = re.compile(r"^\|\s*([^|`]+?)\s*\|")
|
|
758
|
+
separator_re = re.compile(r"^\|\s*[-:]+")
|
|
759
|
+
row_re = re.compile(r"\|\s*`([^`]+)`\s*\|")
|
|
760
|
+
|
|
761
|
+
for line in text.splitlines():
|
|
762
|
+
stripped = line.strip()
|
|
763
|
+
if stripped.startswith("#"):
|
|
764
|
+
in_identifier_table = False
|
|
765
|
+
continue
|
|
766
|
+
if separator_re.match(line):
|
|
767
|
+
continue
|
|
768
|
+
if stripped.startswith("|"):
|
|
769
|
+
# Heuristic: if the first column is plain text (no backticks) it's
|
|
770
|
+
# a header row. Switch mode based on whether header == "Identifier".
|
|
771
|
+
header_m = header_re.match(line)
|
|
772
|
+
backtick_m = row_re.match(line)
|
|
773
|
+
if header_m and not backtick_m:
|
|
774
|
+
col = header_m.group(1).strip().lower()
|
|
775
|
+
in_identifier_table = col == "identifier"
|
|
776
|
+
continue
|
|
777
|
+
if in_identifier_table and backtick_m:
|
|
778
|
+
add_action_id(backtick_m.group(1).strip())
|
|
779
|
+
return actions
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def parse_appintents_md(text: str) -> set[str]:
|
|
783
|
+
intents: set[str] = set()
|
|
784
|
+
|
|
785
|
+
def add_intent_id(token: str):
|
|
786
|
+
if "." not in token:
|
|
787
|
+
return
|
|
788
|
+
if not IDENTIFIER_TOKEN_RE.match(token):
|
|
789
|
+
return
|
|
790
|
+
if token.startswith(("com.apple.", "is.workflow.actions.")):
|
|
791
|
+
intents.add(token)
|
|
792
|
+
|
|
793
|
+
for line in text.splitlines():
|
|
794
|
+
m = re.match(r"\|\s*`([^`]+)`\s*\|", line)
|
|
795
|
+
if m:
|
|
796
|
+
add_intent_id(m.group(1).strip())
|
|
797
|
+
return intents
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def parse_third_party_md(text: str) -> set[str]:
|
|
801
|
+
ids: set[str] = set()
|
|
802
|
+
for match in re.finditer(r"`([^`]+)`", text):
|
|
803
|
+
ident = match.group(1).strip()
|
|
804
|
+
if "." in ident and IDENTIFIER_TOKEN_RE.match(ident):
|
|
805
|
+
ids.add(ident)
|
|
806
|
+
return ids
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def detect_host_macos_major() -> int | None:
|
|
810
|
+
"""Return the running macOS major version, or None outside macOS."""
|
|
811
|
+
|
|
812
|
+
try:
|
|
813
|
+
proc = subprocess.run(
|
|
814
|
+
["sw_vers", "-productVersion"],
|
|
815
|
+
text=True,
|
|
816
|
+
capture_output=True,
|
|
817
|
+
check=False,
|
|
818
|
+
)
|
|
819
|
+
except (OSError, ValueError):
|
|
820
|
+
return None
|
|
821
|
+
if proc.returncode != 0:
|
|
822
|
+
return None
|
|
823
|
+
version = proc.stdout.strip()
|
|
824
|
+
if not version:
|
|
825
|
+
return None
|
|
826
|
+
first = version.split(".", 1)[0]
|
|
827
|
+
try:
|
|
828
|
+
return int(first)
|
|
829
|
+
except ValueError:
|
|
830
|
+
return None
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def resolve_target_macos_major(raw: str | None = None) -> int | None:
|
|
834
|
+
"""Resolve CLI/env target macOS.
|
|
835
|
+
|
|
836
|
+
Returns None for "latest"/"all" because that intentionally includes every
|
|
837
|
+
packaged snapshot. "auto" uses the host macOS version and falls back to the
|
|
838
|
+
conservative macOS 26 target when the host version cannot be detected.
|
|
839
|
+
"""
|
|
840
|
+
|
|
841
|
+
value = raw
|
|
842
|
+
if value is None:
|
|
843
|
+
for env_name in TARGET_MACOS_ENV_VARS:
|
|
844
|
+
env_value = os.environ.get(env_name)
|
|
845
|
+
if env_value:
|
|
846
|
+
value = env_value
|
|
847
|
+
break
|
|
848
|
+
if value is None:
|
|
849
|
+
value = "auto"
|
|
850
|
+
normalized = value.strip().lower()
|
|
851
|
+
if normalized in {"", "auto", "host"}:
|
|
852
|
+
detected = detect_host_macos_major()
|
|
853
|
+
return detected if detected is not None else 26
|
|
854
|
+
if normalized in {"latest", "all", "any"}:
|
|
855
|
+
return None
|
|
856
|
+
if normalized.startswith("macos"):
|
|
857
|
+
normalized = normalized.removeprefix("macos").strip()
|
|
858
|
+
try:
|
|
859
|
+
return int(normalized.split(".", 1)[0])
|
|
860
|
+
except ValueError:
|
|
861
|
+
return None
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def resolve_target_platform(raw: str | None = None) -> str | None:
|
|
865
|
+
"""Resolve target platform.
|
|
866
|
+
|
|
867
|
+
Returns "macos" or "ios" for platform-specific validation, and None for
|
|
868
|
+
"all"/"any" when the caller intentionally wants every packaged platform.
|
|
869
|
+
The default is macOS because this plugin signs on macOS and most generated
|
|
870
|
+
shortcuts are Mac-imported unless the user explicitly targets iOS/iPadOS.
|
|
871
|
+
"""
|
|
872
|
+
|
|
873
|
+
value = raw
|
|
874
|
+
if value is None:
|
|
875
|
+
for env_name in TARGET_PLATFORM_ENV_VARS:
|
|
876
|
+
env_value = os.environ.get(env_name)
|
|
877
|
+
if env_value:
|
|
878
|
+
value = env_value
|
|
879
|
+
break
|
|
880
|
+
if value is None:
|
|
881
|
+
value = "macos"
|
|
882
|
+
normalized = value.strip().lower().replace("_", "-")
|
|
883
|
+
if normalized in {"", "auto", "host", "mac", "macos", "mac-os"}:
|
|
884
|
+
return "macos"
|
|
885
|
+
if normalized in {"ios", "ipados", "iphone", "ipad"}:
|
|
886
|
+
return "ios"
|
|
887
|
+
if normalized in {"latest", "all", "any"}:
|
|
888
|
+
return None
|
|
889
|
+
return "macos"
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _toolkit_snapshot_min_macos_major(payload: dict, path: Path) -> int:
|
|
893
|
+
version = payload.get("version")
|
|
894
|
+
if isinstance(version, str) and version in TOOLKIT_SNAPSHOT_MIN_MACOS_MAJOR:
|
|
895
|
+
return TOOLKIT_SNAPSHOT_MIN_MACOS_MAJOR[version]
|
|
896
|
+
if isinstance(version, str):
|
|
897
|
+
match = re.search(r"v(\d+)", version)
|
|
898
|
+
if match and int(match.group(1)) >= 78:
|
|
899
|
+
return 27
|
|
900
|
+
return 0
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def _toolkit_snapshot_platform_label(payload: dict, version: str) -> str:
|
|
904
|
+
platform_value = payload.get("platform")
|
|
905
|
+
platform_text = platform_value if isinstance(platform_value, str) else version
|
|
906
|
+
if "ios" in platform_text.lower():
|
|
907
|
+
return "iOS"
|
|
908
|
+
return "macOS"
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def _target_platform_label(target_platform: str | None) -> str:
|
|
912
|
+
if target_platform == "ios":
|
|
913
|
+
return "iOS/iPadOS"
|
|
914
|
+
if target_platform == "macos":
|
|
915
|
+
return "macOS"
|
|
916
|
+
return "all platforms"
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def _snapshot_matches_target_platform(platform_label: str, target_platform: str | None) -> bool:
|
|
920
|
+
if target_platform is None:
|
|
921
|
+
return True
|
|
922
|
+
if platform_label == "iOS":
|
|
923
|
+
return target_platform == "ios"
|
|
924
|
+
return target_platform == "macos"
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def _load_packaged_toolkit_snapshots(skill_dir: Path) -> list[tuple[str, int, str, set[str]]]:
|
|
928
|
+
"""Load bundled ToolKit snapshots as (version, minimum OS, platform, ids)."""
|
|
929
|
+
|
|
930
|
+
data_dir = skill_dir / "data"
|
|
931
|
+
candidates = sorted(data_dir.glob("toolkit-v*-tool-ids.json"))
|
|
932
|
+
snapshots: list[tuple[str, int, str, set[str]]] = []
|
|
933
|
+
for path in candidates:
|
|
934
|
+
if not path.exists():
|
|
935
|
+
continue
|
|
936
|
+
try:
|
|
937
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
938
|
+
except (json.JSONDecodeError, OSError):
|
|
939
|
+
continue
|
|
940
|
+
if not isinstance(payload, dict):
|
|
941
|
+
continue
|
|
942
|
+
version = payload.get("version")
|
|
943
|
+
if not isinstance(version, str) or not version:
|
|
944
|
+
version = path.stem
|
|
945
|
+
ids_for_snapshot: set[str] = set()
|
|
946
|
+
ids = payload.get("ids") if isinstance(payload, dict) else None
|
|
947
|
+
if isinstance(ids, list):
|
|
948
|
+
for item in ids:
|
|
949
|
+
if isinstance(item, str) and item:
|
|
950
|
+
ids_for_snapshot.add(item)
|
|
951
|
+
exceptions = (
|
|
952
|
+
payload.get("control_flow_exceptions_missing_from_tools_table")
|
|
953
|
+
if isinstance(payload, dict)
|
|
954
|
+
else None
|
|
955
|
+
)
|
|
956
|
+
if isinstance(exceptions, list):
|
|
957
|
+
for item in exceptions:
|
|
958
|
+
if isinstance(item, str) and item:
|
|
959
|
+
ids_for_snapshot.add(item)
|
|
960
|
+
snapshots.append(
|
|
961
|
+
(
|
|
962
|
+
version,
|
|
963
|
+
_toolkit_snapshot_min_macos_major(payload, path),
|
|
964
|
+
_toolkit_snapshot_platform_label(payload, version),
|
|
965
|
+
ids_for_snapshot,
|
|
966
|
+
)
|
|
967
|
+
)
|
|
968
|
+
return snapshots
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def load_packaged_toolkit_ids(
|
|
972
|
+
skill_dir: Path,
|
|
973
|
+
target_macos_major: int | None = None,
|
|
974
|
+
target_platform: str | None = "macos",
|
|
975
|
+
) -> set[str]:
|
|
976
|
+
"""Load bundled ToolKit ID snapshots for the requested target macOS.
|
|
977
|
+
|
|
978
|
+
Each snapshot is a portable allowlist extracted from Apple's ToolKit SQLite
|
|
979
|
+
metadata. The validator intentionally reads packaged JSON only; it should
|
|
980
|
+
not depend on the user's live Shortcuts database during normal validation.
|
|
981
|
+
"""
|
|
982
|
+
|
|
983
|
+
allowed: set[str] = set()
|
|
984
|
+
for _, min_macos_major, platform_label, ids in _load_packaged_toolkit_snapshots(skill_dir):
|
|
985
|
+
if target_macos_major is not None and min_macos_major > target_macos_major:
|
|
986
|
+
continue
|
|
987
|
+
if not _snapshot_matches_target_platform(platform_label, target_platform):
|
|
988
|
+
continue
|
|
989
|
+
allowed |= ids
|
|
990
|
+
allowed |= CONTROL_FLOW_TOOLKIT_EXCEPTIONS
|
|
991
|
+
allowed |= HEALTH_IOS_ONLY_ACTIONS
|
|
992
|
+
return allowed
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def load_future_toolkit_id_reasons(
|
|
996
|
+
skill_dir: Path,
|
|
997
|
+
target_macos_major: int | None,
|
|
998
|
+
target_platform: str | None = "macos",
|
|
999
|
+
) -> dict[str, str]:
|
|
1000
|
+
"""Return IDs excluded by target OS/platform, with a human-readable reason."""
|
|
1001
|
+
|
|
1002
|
+
if target_macos_major is None and target_platform is None:
|
|
1003
|
+
return {}
|
|
1004
|
+
snapshots = _load_packaged_toolkit_snapshots(skill_dir)
|
|
1005
|
+
included: set[str] = set()
|
|
1006
|
+
for _, min_macos_major, platform_label, ids in snapshots:
|
|
1007
|
+
if target_macos_major is not None and min_macos_major > target_macos_major:
|
|
1008
|
+
continue
|
|
1009
|
+
if not _snapshot_matches_target_platform(platform_label, target_platform):
|
|
1010
|
+
continue
|
|
1011
|
+
included |= ids
|
|
1012
|
+
future: dict[str, str] = {}
|
|
1013
|
+
for version, min_macos_major, platform_label, ids in snapshots:
|
|
1014
|
+
if target_macos_major is not None and min_macos_major > target_macos_major:
|
|
1015
|
+
reason = f"{platform_label} {min_macos_major}+ ({version})"
|
|
1016
|
+
elif not _snapshot_matches_target_platform(platform_label, target_platform):
|
|
1017
|
+
reason = (
|
|
1018
|
+
f"{platform_label}-only ({version}); "
|
|
1019
|
+
f"target platform is {_target_platform_label(target_platform)}"
|
|
1020
|
+
)
|
|
1021
|
+
else:
|
|
1022
|
+
continue
|
|
1023
|
+
for ident in ids - included:
|
|
1024
|
+
future[ident] = reason
|
|
1025
|
+
return future
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def load_future_parameter_key_reasons(target_macos_major: int | None) -> dict[str, dict[str, str]]:
|
|
1029
|
+
"""Return OS 27-only parameter keys excluded by the target macOS."""
|
|
1030
|
+
|
|
1031
|
+
if target_macos_major is None or target_macos_major >= 27:
|
|
1032
|
+
return {}
|
|
1033
|
+
return {
|
|
1034
|
+
identifier: {key: OS27_PARAMETER_REASON for key in keys}
|
|
1035
|
+
for identifier, keys in OS27_PARAMETER_KEYS_BY_ACTION.items()
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _catalog_platforms_match_target(platforms: list[str], target_platform: str | None) -> bool:
|
|
1040
|
+
if target_platform is None:
|
|
1041
|
+
return True
|
|
1042
|
+
for platform_name in platforms:
|
|
1043
|
+
if _catalog_platform_name_matches_target(platform_name, target_platform):
|
|
1044
|
+
return True
|
|
1045
|
+
return False
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def _catalog_platform_name_matches_target(platform_name: str, target_platform: str | None) -> bool:
|
|
1049
|
+
if target_platform is None:
|
|
1050
|
+
return True
|
|
1051
|
+
if target_platform == "ios" and platform_name.startswith("iOS"):
|
|
1052
|
+
return True
|
|
1053
|
+
if target_platform == "macos" and platform_name.startswith("macOS"):
|
|
1054
|
+
return True
|
|
1055
|
+
return False
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def _catalog_parameter_matches_target(
|
|
1059
|
+
parameter: dict,
|
|
1060
|
+
target_platform: str | None,
|
|
1061
|
+
) -> bool:
|
|
1062
|
+
platforms = parameter.get("platforms")
|
|
1063
|
+
if not isinstance(platforms, list) or not platforms:
|
|
1064
|
+
return True
|
|
1065
|
+
return _catalog_platforms_match_target(
|
|
1066
|
+
[platform for platform in platforms if isinstance(platform, str)],
|
|
1067
|
+
target_platform,
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
def load_toolkit_parameter_schemas(
|
|
1072
|
+
skill_dir: Path,
|
|
1073
|
+
target_macos_major: int | None = None,
|
|
1074
|
+
target_platform: str | None = "macos",
|
|
1075
|
+
) -> dict[str, set[str]]:
|
|
1076
|
+
"""Load target-gated v78 parameter schemas for first-party AppIntent IDs.
|
|
1077
|
+
|
|
1078
|
+
The v78 ToolKit parameter catalog is useful for com.apple AppIntent-style
|
|
1079
|
+
actions, where top-level keys map cleanly to AppIntent parameters. It is not
|
|
1080
|
+
complete enough for every legacy WF action because those can include UI
|
|
1081
|
+
state keys that are valid in plists but absent from ToolKit metadata.
|
|
1082
|
+
"""
|
|
1083
|
+
|
|
1084
|
+
if (
|
|
1085
|
+
target_macos_major is not None
|
|
1086
|
+
and target_macos_major < TOOLKIT_PARAMETER_CATALOG_MIN_MACOS_MAJOR
|
|
1087
|
+
):
|
|
1088
|
+
return {}
|
|
1089
|
+
path = skill_dir / "data/toolkit-v78-first-party-parameter-keys.json"
|
|
1090
|
+
if not path.exists():
|
|
1091
|
+
return {}
|
|
1092
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
1093
|
+
payload = json.load(handle)
|
|
1094
|
+
schemas: dict[str, set[str]] = {}
|
|
1095
|
+
for identifier, entry in (payload.get("tools") or {}).items():
|
|
1096
|
+
if not isinstance(identifier, str) or not identifier.startswith("com.apple."):
|
|
1097
|
+
continue
|
|
1098
|
+
if not isinstance(entry, dict):
|
|
1099
|
+
continue
|
|
1100
|
+
platforms = entry.get("platforms") or []
|
|
1101
|
+
if not _catalog_platforms_match_target(platforms, target_platform):
|
|
1102
|
+
continue
|
|
1103
|
+
keys = {
|
|
1104
|
+
parameter.get("key")
|
|
1105
|
+
for parameter in (entry.get("parameters") or [])
|
|
1106
|
+
if isinstance(parameter, dict)
|
|
1107
|
+
and isinstance(parameter.get("key"), str)
|
|
1108
|
+
and _catalog_parameter_matches_target(parameter, target_platform)
|
|
1109
|
+
}
|
|
1110
|
+
schemas[identifier] = keys
|
|
1111
|
+
return schemas
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def _parameter_type_python_names(
|
|
1115
|
+
parameter: dict,
|
|
1116
|
+
target_platform: str | None = None,
|
|
1117
|
+
) -> list[str]:
|
|
1118
|
+
if target_platform is not None:
|
|
1119
|
+
by_platform = parameter.get("typePythonNamesByPlatform")
|
|
1120
|
+
if isinstance(by_platform, dict):
|
|
1121
|
+
names_by_target: list[str] = []
|
|
1122
|
+
for platform_name, type_names in by_platform.items():
|
|
1123
|
+
if not isinstance(platform_name, str) or not _catalog_platform_name_matches_target(
|
|
1124
|
+
platform_name,
|
|
1125
|
+
target_platform,
|
|
1126
|
+
):
|
|
1127
|
+
continue
|
|
1128
|
+
if isinstance(type_names, list):
|
|
1129
|
+
names_by_target.extend(
|
|
1130
|
+
name for name in type_names if isinstance(name, str) and name
|
|
1131
|
+
)
|
|
1132
|
+
if names_by_target:
|
|
1133
|
+
return sorted(set(names_by_target))
|
|
1134
|
+
names: list[str] = []
|
|
1135
|
+
single = parameter.get("typePythonName")
|
|
1136
|
+
if isinstance(single, str) and single:
|
|
1137
|
+
names.append(single)
|
|
1138
|
+
multi = parameter.get("typePythonNames")
|
|
1139
|
+
if isinstance(multi, list):
|
|
1140
|
+
names.extend(name for name in multi if isinstance(name, str) and name)
|
|
1141
|
+
return sorted(set(names))
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
def load_toolkit_parameter_enum_cases(
|
|
1145
|
+
skill_dir: Path,
|
|
1146
|
+
target_macos_major: int | None = None,
|
|
1147
|
+
target_platform: str | None = "macos",
|
|
1148
|
+
) -> dict[str, dict[str, set[str]]]:
|
|
1149
|
+
"""Load target-gated literal enum cases for simple first-party params.
|
|
1150
|
+
|
|
1151
|
+
This intentionally validates only parameters with exactly one enum type. If
|
|
1152
|
+
ToolKit reports a union of enum and non-enum types, or several possible enum
|
|
1153
|
+
types, the validator skips that parameter to avoid rejecting valid picker or
|
|
1154
|
+
entity states that cannot be represented by a simple literal string. The
|
|
1155
|
+
enum checks are safe for both AppIntents and classic WF actions because they
|
|
1156
|
+
only run when a known enum key is present; unlike AppIntent schemas, they do
|
|
1157
|
+
not reject extra top-level keys on classic WF actions.
|
|
1158
|
+
"""
|
|
1159
|
+
|
|
1160
|
+
if (
|
|
1161
|
+
target_macos_major is not None
|
|
1162
|
+
and target_macos_major < TOOLKIT_PARAMETER_CATALOG_MIN_MACOS_MAJOR
|
|
1163
|
+
):
|
|
1164
|
+
return {}
|
|
1165
|
+
parameter_path = skill_dir / "data/toolkit-v78-first-party-parameter-keys.json"
|
|
1166
|
+
enum_path = skill_dir / "data/toolkit-v78-first-party-enum-cases.json"
|
|
1167
|
+
if not parameter_path.exists() or not enum_path.exists():
|
|
1168
|
+
return {}
|
|
1169
|
+
with parameter_path.open("r", encoding="utf-8") as handle:
|
|
1170
|
+
parameter_payload = json.load(handle)
|
|
1171
|
+
with enum_path.open("r", encoding="utf-8") as handle:
|
|
1172
|
+
enum_payload = json.load(handle)
|
|
1173
|
+
|
|
1174
|
+
enum_types = enum_payload.get("types") or {}
|
|
1175
|
+
schemas: dict[str, dict[str, set[str]]] = {}
|
|
1176
|
+
for identifier, entry in (parameter_payload.get("tools") or {}).items():
|
|
1177
|
+
if not isinstance(identifier, str) or not (
|
|
1178
|
+
identifier.startswith("com.apple.")
|
|
1179
|
+
or identifier.startswith("is.workflow.actions.")
|
|
1180
|
+
):
|
|
1181
|
+
continue
|
|
1182
|
+
if not isinstance(entry, dict):
|
|
1183
|
+
continue
|
|
1184
|
+
platforms = entry.get("platforms") or []
|
|
1185
|
+
if not _catalog_platforms_match_target(platforms, target_platform):
|
|
1186
|
+
continue
|
|
1187
|
+
parameter_cases: dict[str, set[str]] = {}
|
|
1188
|
+
for parameter in entry.get("parameters") or []:
|
|
1189
|
+
if not isinstance(parameter, dict):
|
|
1190
|
+
continue
|
|
1191
|
+
if not _catalog_parameter_matches_target(parameter, target_platform):
|
|
1192
|
+
continue
|
|
1193
|
+
key = parameter.get("key")
|
|
1194
|
+
if not isinstance(key, str):
|
|
1195
|
+
continue
|
|
1196
|
+
type_names = _parameter_type_python_names(parameter, target_platform)
|
|
1197
|
+
if len(type_names) != 1:
|
|
1198
|
+
continue
|
|
1199
|
+
enum_entry = enum_types.get(type_names[0])
|
|
1200
|
+
if not isinstance(enum_entry, dict):
|
|
1201
|
+
continue
|
|
1202
|
+
case_ids = {
|
|
1203
|
+
case.get("id")
|
|
1204
|
+
for case in (enum_entry.get("cases") or [])
|
|
1205
|
+
if isinstance(case, dict) and isinstance(case.get("id"), str)
|
|
1206
|
+
}
|
|
1207
|
+
if case_ids:
|
|
1208
|
+
parameter_cases[key] = case_ids
|
|
1209
|
+
if parameter_cases:
|
|
1210
|
+
schemas[identifier] = parameter_cases
|
|
1211
|
+
return schemas
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def load_toolkit_parameter_boolean_keys(
|
|
1215
|
+
skill_dir: Path,
|
|
1216
|
+
target_macos_major: int | None = None,
|
|
1217
|
+
target_platform: str | None = "macos",
|
|
1218
|
+
) -> dict[str, set[str]]:
|
|
1219
|
+
"""Load target-gated boolean parameter keys for first-party actions."""
|
|
1220
|
+
|
|
1221
|
+
if (
|
|
1222
|
+
target_macos_major is not None
|
|
1223
|
+
and target_macos_major < TOOLKIT_PARAMETER_CATALOG_MIN_MACOS_MAJOR
|
|
1224
|
+
):
|
|
1225
|
+
return {}
|
|
1226
|
+
path = skill_dir / "data/toolkit-v78-first-party-parameter-keys.json"
|
|
1227
|
+
if not path.exists():
|
|
1228
|
+
return {}
|
|
1229
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
1230
|
+
payload = json.load(handle)
|
|
1231
|
+
schemas: dict[str, set[str]] = {}
|
|
1232
|
+
for identifier, entry in (payload.get("tools") or {}).items():
|
|
1233
|
+
if not isinstance(identifier, str) or not (
|
|
1234
|
+
identifier.startswith("com.apple.")
|
|
1235
|
+
or identifier.startswith("is.workflow.actions.")
|
|
1236
|
+
):
|
|
1237
|
+
continue
|
|
1238
|
+
if not isinstance(entry, dict):
|
|
1239
|
+
continue
|
|
1240
|
+
platforms = entry.get("platforms") or []
|
|
1241
|
+
if not _catalog_platforms_match_target(platforms, target_platform):
|
|
1242
|
+
continue
|
|
1243
|
+
keys: set[str] = set()
|
|
1244
|
+
for parameter in entry.get("parameters") or []:
|
|
1245
|
+
if not isinstance(parameter, dict):
|
|
1246
|
+
continue
|
|
1247
|
+
if not _catalog_parameter_matches_target(parameter, target_platform):
|
|
1248
|
+
continue
|
|
1249
|
+
key = parameter.get("key")
|
|
1250
|
+
if not isinstance(key, str):
|
|
1251
|
+
continue
|
|
1252
|
+
if _parameter_type_python_names(parameter, target_platform) == ["bool"]:
|
|
1253
|
+
keys.add(key)
|
|
1254
|
+
if keys:
|
|
1255
|
+
schemas[identifier] = keys
|
|
1256
|
+
return schemas
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
def load_workflow_trigger_catalog(
|
|
1260
|
+
skill_dir: Path,
|
|
1261
|
+
target_macos_major: int | None = None,
|
|
1262
|
+
target_platform: str | None = "macos",
|
|
1263
|
+
) -> dict[str, object]:
|
|
1264
|
+
"""Load exported OS 27 WFWorkflowTriggers samples for structural validation."""
|
|
1265
|
+
|
|
1266
|
+
if (
|
|
1267
|
+
target_macos_major is not None
|
|
1268
|
+
and target_macos_major < WORKFLOW_TRIGGER_CATALOG_MIN_MACOS_MAJOR
|
|
1269
|
+
):
|
|
1270
|
+
return {
|
|
1271
|
+
"workflow_identifiers": set(),
|
|
1272
|
+
"serialized_keys_by_identifier": {},
|
|
1273
|
+
}
|
|
1274
|
+
path = skill_dir / "data/macos27-workflow-trigger-samples.json"
|
|
1275
|
+
if not path.exists():
|
|
1276
|
+
return {
|
|
1277
|
+
"workflow_identifiers": set(),
|
|
1278
|
+
"serialized_keys_by_identifier": {},
|
|
1279
|
+
}
|
|
1280
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
1281
|
+
payload = json.load(handle)
|
|
1282
|
+
workflow_identifiers: set[str] = set()
|
|
1283
|
+
serialized_keys_by_identifier: dict[str, set[str]] = {}
|
|
1284
|
+
for entry in (payload.get("triggers") or {}).values():
|
|
1285
|
+
if not isinstance(entry, dict):
|
|
1286
|
+
continue
|
|
1287
|
+
platforms = entry.get("platforms") or []
|
|
1288
|
+
if not _catalog_platforms_match_target(platforms, target_platform):
|
|
1289
|
+
continue
|
|
1290
|
+
workflow_trigger = entry.get("workflowTrigger")
|
|
1291
|
+
if not isinstance(workflow_trigger, dict):
|
|
1292
|
+
continue
|
|
1293
|
+
wf_identifier = workflow_trigger.get("WFTriggerIdentifier")
|
|
1294
|
+
if not isinstance(wf_identifier, str) or not wf_identifier:
|
|
1295
|
+
continue
|
|
1296
|
+
workflow_identifiers.add(wf_identifier)
|
|
1297
|
+
keys = serialized_keys_by_identifier.setdefault(wf_identifier, set())
|
|
1298
|
+
for key in entry.get("serializedParameterKeys") or []:
|
|
1299
|
+
if isinstance(key, str) and key:
|
|
1300
|
+
keys.add(key)
|
|
1301
|
+
for key in entry.get("toolkitParameterKeys") or []:
|
|
1302
|
+
if isinstance(key, str) and key:
|
|
1303
|
+
keys.add(key)
|
|
1304
|
+
return {
|
|
1305
|
+
"workflow_identifiers": workflow_identifiers,
|
|
1306
|
+
"serialized_keys_by_identifier": serialized_keys_by_identifier,
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
def load_allowed_ids(
|
|
1311
|
+
skill_dir: Path,
|
|
1312
|
+
target_macos_major: int | None = None,
|
|
1313
|
+
target_platform: str | None = "macos",
|
|
1314
|
+
) -> set[str]:
|
|
1315
|
+
allowed = set()
|
|
1316
|
+
allowed |= load_packaged_toolkit_ids(skill_dir, target_macos_major, target_platform)
|
|
1317
|
+
|
|
1318
|
+
# Keep markdown references as additive fallback, especially for backup-only third-party IDs.
|
|
1319
|
+
actions_md = skill_dir / "ACTIONS.md"
|
|
1320
|
+
appintents_md = skill_dir / "APPINTENTS.md"
|
|
1321
|
+
third_party_md = skill_dir / "THIRD_PARTY_ACTIONS.md"
|
|
1322
|
+
|
|
1323
|
+
if actions_md.exists():
|
|
1324
|
+
allowed |= parse_actions_md(read_text(actions_md))
|
|
1325
|
+
if appintents_md.exists():
|
|
1326
|
+
allowed |= parse_appintents_md(read_text(appintents_md))
|
|
1327
|
+
if third_party_md.exists():
|
|
1328
|
+
allowed |= parse_third_party_md(read_text(third_party_md))
|
|
1329
|
+
|
|
1330
|
+
allowed |= CONTROL_FLOW_TOOLKIT_EXCEPTIONS
|
|
1331
|
+
allowed |= HEALTH_IOS_ONLY_ACTIONS
|
|
1332
|
+
future_ids = load_future_toolkit_id_reasons(skill_dir, target_macos_major, target_platform)
|
|
1333
|
+
allowed -= set(future_ids)
|
|
1334
|
+
return allowed
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
def load_icon_metadata(skill_dir: Path) -> tuple[set[int], set[int]]:
|
|
1338
|
+
glyph_ids: set[int] = set()
|
|
1339
|
+
color_values: set[int] = set()
|
|
1340
|
+
|
|
1341
|
+
glyph_path = skill_dir / "data/shortcuts-official-glyph-mapping.json"
|
|
1342
|
+
color_path = skill_dir / "data/shortcuts-icon-colors.json"
|
|
1343
|
+
|
|
1344
|
+
try:
|
|
1345
|
+
if glyph_path.exists():
|
|
1346
|
+
with glyph_path.open("r", encoding="utf-8") as handle:
|
|
1347
|
+
payload = json.load(handle)
|
|
1348
|
+
if isinstance(payload, dict):
|
|
1349
|
+
for key in payload.keys():
|
|
1350
|
+
try:
|
|
1351
|
+
glyph_ids.add(int(key))
|
|
1352
|
+
except (TypeError, ValueError):
|
|
1353
|
+
continue
|
|
1354
|
+
except json.JSONDecodeError:
|
|
1355
|
+
pass
|
|
1356
|
+
|
|
1357
|
+
try:
|
|
1358
|
+
if color_path.exists():
|
|
1359
|
+
with color_path.open("r", encoding="utf-8") as handle:
|
|
1360
|
+
payload = json.load(handle)
|
|
1361
|
+
if isinstance(payload, list):
|
|
1362
|
+
for row in payload:
|
|
1363
|
+
if not isinstance(row, dict):
|
|
1364
|
+
continue
|
|
1365
|
+
value = row.get("value")
|
|
1366
|
+
if isinstance(value, (int, float)):
|
|
1367
|
+
color_values.add(int(value))
|
|
1368
|
+
for alias in row.get("aliases", []):
|
|
1369
|
+
if isinstance(alias, (int, float)):
|
|
1370
|
+
color_values.add(int(alias))
|
|
1371
|
+
except json.JSONDecodeError:
|
|
1372
|
+
pass
|
|
1373
|
+
|
|
1374
|
+
return glyph_ids, color_values
|
|
1375
|
+
|
|
1376
|
+
|
|
1377
|
+
def load_plist(path: Path):
|
|
1378
|
+
with path.open("rb") as f:
|
|
1379
|
+
return plistlib.load(f)
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
def iter_empty_strings(obj, path=()):
|
|
1383
|
+
if isinstance(obj, dict):
|
|
1384
|
+
for k, v in obj.items():
|
|
1385
|
+
new_path = path + (k,)
|
|
1386
|
+
if isinstance(v, str):
|
|
1387
|
+
# Some text fields legitimately preserve whitespace and should only
|
|
1388
|
+
# fail when truly empty.
|
|
1389
|
+
if k in WHITESPACE_SIGNIFICANT_KEYS:
|
|
1390
|
+
if v == "":
|
|
1391
|
+
yield new_path
|
|
1392
|
+
elif k in ALLOW_EMPTY_STRING_KEYS:
|
|
1393
|
+
continue
|
|
1394
|
+
elif v.strip() == "" and k not in ALLOWED_EMPTY_KEYS:
|
|
1395
|
+
yield new_path
|
|
1396
|
+
else:
|
|
1397
|
+
yield from iter_empty_strings(v, new_path)
|
|
1398
|
+
elif isinstance(obj, list):
|
|
1399
|
+
for i, v in enumerate(obj):
|
|
1400
|
+
yield from iter_empty_strings(v, path + (f"[{i}]",))
|
|
1401
|
+
|
|
1402
|
+
|
|
1403
|
+
def iter_strings(obj, path=()):
|
|
1404
|
+
if isinstance(obj, dict):
|
|
1405
|
+
for k, v in obj.items():
|
|
1406
|
+
new_path = path + (k,)
|
|
1407
|
+
if isinstance(v, str):
|
|
1408
|
+
yield new_path, v
|
|
1409
|
+
else:
|
|
1410
|
+
yield from iter_strings(v, new_path)
|
|
1411
|
+
elif isinstance(obj, list):
|
|
1412
|
+
for i, v in enumerate(obj):
|
|
1413
|
+
yield from iter_strings(v, path + (f"[{i}]",))
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
def iter_text_token_strings(obj):
|
|
1417
|
+
if isinstance(obj, dict):
|
|
1418
|
+
if obj.get("WFSerializationType") == "WFTextTokenString":
|
|
1419
|
+
yield obj
|
|
1420
|
+
for v in obj.values():
|
|
1421
|
+
yield from iter_text_token_strings(v)
|
|
1422
|
+
elif isinstance(obj, list):
|
|
1423
|
+
for v in obj:
|
|
1424
|
+
yield from iter_text_token_strings(v)
|
|
1425
|
+
|
|
1426
|
+
|
|
1427
|
+
def _validate_shortcuts_url_literal(url: str, idx: int) -> list[str]:
|
|
1428
|
+
errors: list[str] = []
|
|
1429
|
+
parsed = urllib.parse.urlparse(url)
|
|
1430
|
+
if parsed.scheme != "shortcuts":
|
|
1431
|
+
return errors
|
|
1432
|
+
|
|
1433
|
+
query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
|
|
1434
|
+
|
|
1435
|
+
def has_nonempty(name: str) -> bool:
|
|
1436
|
+
return bool(query.get(name) and query[name][0])
|
|
1437
|
+
|
|
1438
|
+
def validate_run_params(context: str) -> None:
|
|
1439
|
+
if not has_nonempty("name"):
|
|
1440
|
+
errors.append(f"{context} missing required name parameter at index {idx}")
|
|
1441
|
+
input_values = query.get("input", [])
|
|
1442
|
+
if input_values:
|
|
1443
|
+
input_value = input_values[0]
|
|
1444
|
+
if input_value not in {"text", "clipboard"}:
|
|
1445
|
+
errors.append(
|
|
1446
|
+
f"{context} input must be 'text' or 'clipboard', got '{input_value}' at index {idx}"
|
|
1447
|
+
)
|
|
1448
|
+
if input_value == "text" and not has_nonempty("text"):
|
|
1449
|
+
errors.append(f"{context} uses input=text but has no text parameter at index {idx}")
|
|
1450
|
+
|
|
1451
|
+
host = parsed.netloc
|
|
1452
|
+
path = parsed.path
|
|
1453
|
+
|
|
1454
|
+
if host == "" and path in {"", "/"} and not parsed.query:
|
|
1455
|
+
return errors
|
|
1456
|
+
if host == "create-shortcut" and path in {"", "/"}:
|
|
1457
|
+
if parsed.query:
|
|
1458
|
+
errors.append(f"shortcuts://create-shortcut does not take query parameters at index {idx}")
|
|
1459
|
+
return errors
|
|
1460
|
+
if host == "open-shortcut" and path in {"", "/"}:
|
|
1461
|
+
if not has_nonempty("name"):
|
|
1462
|
+
errors.append(f"shortcuts://open-shortcut missing required name parameter at index {idx}")
|
|
1463
|
+
return errors
|
|
1464
|
+
if host == "run-shortcut" and path in {"", "/"}:
|
|
1465
|
+
validate_run_params("shortcuts://run-shortcut")
|
|
1466
|
+
return errors
|
|
1467
|
+
if host == "gallery":
|
|
1468
|
+
if path in {"", "/"}:
|
|
1469
|
+
if parsed.query:
|
|
1470
|
+
errors.append(f"shortcuts://gallery does not take query parameters at index {idx}")
|
|
1471
|
+
return errors
|
|
1472
|
+
if path == "/search":
|
|
1473
|
+
if not has_nonempty("query"):
|
|
1474
|
+
errors.append(f"shortcuts://gallery/search missing required query parameter at index {idx}")
|
|
1475
|
+
return errors
|
|
1476
|
+
if host == "x-callback-url" and path == "/run-shortcut":
|
|
1477
|
+
validate_run_params("shortcuts://x-callback-url/run-shortcut")
|
|
1478
|
+
callbacks = ["x-success", "x-cancel", "x-error"]
|
|
1479
|
+
if not any(has_nonempty(name) for name in callbacks):
|
|
1480
|
+
errors.append(
|
|
1481
|
+
f"shortcuts://x-callback-url/run-shortcut should include at least one callback URL at index {idx}"
|
|
1482
|
+
)
|
|
1483
|
+
for name in callbacks:
|
|
1484
|
+
if has_nonempty(name):
|
|
1485
|
+
callback = query[name][0]
|
|
1486
|
+
callback_parsed = urllib.parse.urlparse(callback)
|
|
1487
|
+
if not callback_parsed.scheme:
|
|
1488
|
+
errors.append(f"{name} callback is not an absolute URL at index {idx}")
|
|
1489
|
+
return errors
|
|
1490
|
+
|
|
1491
|
+
errors.append(f"Unsupported Apple-documented Shortcuts URL route '{host}{path}' at index {idx}")
|
|
1492
|
+
return errors
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
def _utf16_units(s: str) -> list[int]:
|
|
1496
|
+
data = s.encode("utf-16-le")
|
|
1497
|
+
return [data[i] + (data[i + 1] << 8) for i in range(0, len(data), 2)]
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
def _placeholder_position_hint(string: str) -> str:
|
|
1501
|
+
positions = [f"{{{idx}, 1}}" for idx, unit in enumerate(_utf16_units(string)) if unit == 0xFFFC]
|
|
1502
|
+
if not positions:
|
|
1503
|
+
return "expected no placeholder positions"
|
|
1504
|
+
return "expected placeholder position(s): " + ", ".join(positions)
|
|
1505
|
+
|
|
1506
|
+
|
|
1507
|
+
def iter_action_output_refs(obj):
|
|
1508
|
+
if isinstance(obj, dict):
|
|
1509
|
+
if obj.get("Type") == "ActionOutput":
|
|
1510
|
+
uuid = obj.get("OutputUUID")
|
|
1511
|
+
if uuid:
|
|
1512
|
+
yield uuid
|
|
1513
|
+
for v in obj.values():
|
|
1514
|
+
yield from iter_action_output_refs(v)
|
|
1515
|
+
elif isinstance(obj, list):
|
|
1516
|
+
for v in obj:
|
|
1517
|
+
yield from iter_action_output_refs(v)
|
|
1518
|
+
|
|
1519
|
+
|
|
1520
|
+
def iter_action_output_refs_with_name(obj):
|
|
1521
|
+
if isinstance(obj, dict):
|
|
1522
|
+
if obj.get("Type") == "ActionOutput":
|
|
1523
|
+
uuid = obj.get("OutputUUID")
|
|
1524
|
+
name = obj.get("OutputName")
|
|
1525
|
+
if uuid or name:
|
|
1526
|
+
yield uuid, name
|
|
1527
|
+
for v in obj.values():
|
|
1528
|
+
yield from iter_action_output_refs_with_name(v)
|
|
1529
|
+
elif isinstance(obj, list):
|
|
1530
|
+
for v in obj:
|
|
1531
|
+
yield from iter_action_output_refs_with_name(v)
|
|
1532
|
+
|
|
1533
|
+
|
|
1534
|
+
def iter_variable_names(obj):
|
|
1535
|
+
if isinstance(obj, dict):
|
|
1536
|
+
if obj.get("Type") == "Variable":
|
|
1537
|
+
name = obj.get("VariableName")
|
|
1538
|
+
if isinstance(name, str):
|
|
1539
|
+
yield name
|
|
1540
|
+
for v in obj.values():
|
|
1541
|
+
yield from iter_variable_names(v)
|
|
1542
|
+
elif isinstance(obj, list):
|
|
1543
|
+
for v in obj:
|
|
1544
|
+
yield from iter_variable_names(v)
|
|
1545
|
+
|
|
1546
|
+
|
|
1547
|
+
def _comment_has_bullets(text: str) -> bool:
|
|
1548
|
+
for line in text.splitlines():
|
|
1549
|
+
stripped = line.lstrip()
|
|
1550
|
+
if stripped.startswith(("-", "•", "–")):
|
|
1551
|
+
return True
|
|
1552
|
+
return False
|
|
1553
|
+
|
|
1554
|
+
|
|
1555
|
+
def _friendly_comment_field_name(token: str) -> str:
|
|
1556
|
+
friendly = COMMENT_WF_FRIENDLY_LABELS.get(token)
|
|
1557
|
+
if friendly:
|
|
1558
|
+
return friendly
|
|
1559
|
+
if token.startswith("WF") and len(token) > 2:
|
|
1560
|
+
return token[2:]
|
|
1561
|
+
return token
|
|
1562
|
+
|
|
1563
|
+
|
|
1564
|
+
def _comment_internal_wf_terms(text: str) -> list[str]:
|
|
1565
|
+
found: set[str] = set()
|
|
1566
|
+
for raw_line in text.splitlines():
|
|
1567
|
+
line = raw_line.strip()
|
|
1568
|
+
if not line:
|
|
1569
|
+
continue
|
|
1570
|
+
# Skip quoted user prompt lines in the required prompt Comment block.
|
|
1571
|
+
if line.startswith(">"):
|
|
1572
|
+
continue
|
|
1573
|
+
upper = line.upper()
|
|
1574
|
+
if any(marker in upper for marker in COMMENT_ALLOWED_MARKERS):
|
|
1575
|
+
continue
|
|
1576
|
+
for token in COMMENT_INTERNAL_WF_KEY_RE.findall(line):
|
|
1577
|
+
found.add(token)
|
|
1578
|
+
return sorted(found)
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
def _text_has_unit_keywords(text: str) -> bool:
|
|
1582
|
+
for pat in UNIT_KEYWORD_PATTERNS:
|
|
1583
|
+
if pat.search(text):
|
|
1584
|
+
return True
|
|
1585
|
+
return False
|
|
1586
|
+
|
|
1587
|
+
|
|
1588
|
+
def iter_extension_input_refs(obj):
|
|
1589
|
+
if isinstance(obj, dict):
|
|
1590
|
+
if obj.get("Type") == "ExtensionInput":
|
|
1591
|
+
yield True
|
|
1592
|
+
for v in obj.values():
|
|
1593
|
+
yield from iter_extension_input_refs(v)
|
|
1594
|
+
elif isinstance(obj, list):
|
|
1595
|
+
for v in obj:
|
|
1596
|
+
yield from iter_extension_input_refs(v)
|
|
1597
|
+
|
|
1598
|
+
|
|
1599
|
+
def _snippet(params: dict) -> str:
|
|
1600
|
+
simple = {}
|
|
1601
|
+
for k, v in params.items():
|
|
1602
|
+
if isinstance(v, (str, int, float, bool)) and v != "":
|
|
1603
|
+
simple[k] = v
|
|
1604
|
+
if not simple:
|
|
1605
|
+
simple = {"keys": sorted(params.keys())}
|
|
1606
|
+
s = repr(simple)
|
|
1607
|
+
if len(s) > 240:
|
|
1608
|
+
s = s[:237] + "..."
|
|
1609
|
+
return s
|
|
1610
|
+
|
|
1611
|
+
|
|
1612
|
+
def _literal_enum_values(value) -> list[str]:
|
|
1613
|
+
if isinstance(value, bool):
|
|
1614
|
+
return []
|
|
1615
|
+
if isinstance(value, str):
|
|
1616
|
+
stripped = value.strip()
|
|
1617
|
+
return [stripped] if stripped else []
|
|
1618
|
+
if isinstance(value, int):
|
|
1619
|
+
return [str(value)]
|
|
1620
|
+
if isinstance(value, dict):
|
|
1621
|
+
values: list[str] = []
|
|
1622
|
+
for key in ("value", "identifier"):
|
|
1623
|
+
item = value.get(key)
|
|
1624
|
+
if isinstance(item, str) and item.strip():
|
|
1625
|
+
values.append(item.strip())
|
|
1626
|
+
return sorted(set(values))
|
|
1627
|
+
if isinstance(value, list):
|
|
1628
|
+
values: list[str] = []
|
|
1629
|
+
for item in value:
|
|
1630
|
+
values.extend(_literal_enum_values(item))
|
|
1631
|
+
return values
|
|
1632
|
+
return []
|
|
1633
|
+
|
|
1634
|
+
|
|
1635
|
+
def _format_allowed_values(values: set[str], limit: int = 20) -> str:
|
|
1636
|
+
ordered = sorted(values)
|
|
1637
|
+
preview = ", ".join(repr(value) for value in ordered[:limit])
|
|
1638
|
+
if len(ordered) > limit:
|
|
1639
|
+
preview += f", ... (+{len(ordered) - limit} more)"
|
|
1640
|
+
return preview
|
|
1641
|
+
|
|
1642
|
+
|
|
1643
|
+
def _is_allowed_toolkit_enum_alias(ident: str, key: str, value: str, allowed_values: set[str]) -> bool:
|
|
1644
|
+
if (
|
|
1645
|
+
ident == "is.workflow.actions.filter.vpns"
|
|
1646
|
+
and key == "WFCompoundType"
|
|
1647
|
+
and {"0", "1"}.issubset(allowed_values)
|
|
1648
|
+
):
|
|
1649
|
+
return value in {"Any", "All"}
|
|
1650
|
+
return False
|
|
1651
|
+
|
|
1652
|
+
|
|
1653
|
+
def _input_is_attached(value) -> bool:
|
|
1654
|
+
if not isinstance(value, dict):
|
|
1655
|
+
return False
|
|
1656
|
+
if value.get("Type") == "Variable" and isinstance(value.get("Variable"), dict):
|
|
1657
|
+
return _input_is_attached(value.get("Variable"))
|
|
1658
|
+
if value.get("WFSerializationType") in {"WFTextTokenAttachment", "WFTextTokenString"}:
|
|
1659
|
+
return True
|
|
1660
|
+
return False
|
|
1661
|
+
|
|
1662
|
+
|
|
1663
|
+
def _parameter_is_dynamic_attachment(value) -> bool:
|
|
1664
|
+
if not isinstance(value, dict):
|
|
1665
|
+
return False
|
|
1666
|
+
if value.get("Type") == "Variable" and isinstance(value.get("Variable"), dict):
|
|
1667
|
+
return _parameter_is_dynamic_attachment(value.get("Variable"))
|
|
1668
|
+
if value.get("WFSerializationType") == "WFTextTokenAttachment":
|
|
1669
|
+
token_value = value.get("Value")
|
|
1670
|
+
return isinstance(token_value, dict) and bool(token_value.get("Type"))
|
|
1671
|
+
if value.get("WFSerializationType") == "WFTextTokenString":
|
|
1672
|
+
token_value = value.get("Value")
|
|
1673
|
+
if not isinstance(token_value, dict):
|
|
1674
|
+
return False
|
|
1675
|
+
attachments = token_value.get("attachmentsByRange")
|
|
1676
|
+
return isinstance(attachments, dict) and bool(attachments)
|
|
1677
|
+
return False
|
|
1678
|
+
|
|
1679
|
+
|
|
1680
|
+
def _conditional_input_is_wrapped(value) -> bool:
|
|
1681
|
+
return isinstance(value, dict) and value.get("Type") == "Variable" and isinstance(value.get("Variable"), dict)
|
|
1682
|
+
|
|
1683
|
+
|
|
1684
|
+
def _wrapped_variable_contains_action_output(value) -> bool:
|
|
1685
|
+
if not isinstance(value, dict):
|
|
1686
|
+
return False
|
|
1687
|
+
if value.get("Type") != "Variable":
|
|
1688
|
+
return False
|
|
1689
|
+
inner = value.get("Variable")
|
|
1690
|
+
if not isinstance(inner, dict):
|
|
1691
|
+
return False
|
|
1692
|
+
ser = inner.get("WFSerializationType")
|
|
1693
|
+
val = inner.get("Value")
|
|
1694
|
+
if ser == "WFTextTokenAttachment":
|
|
1695
|
+
return isinstance(val, dict) and val.get("Type") == "ActionOutput"
|
|
1696
|
+
if ser == "WFTextTokenString" and isinstance(val, dict):
|
|
1697
|
+
attachments = val.get("attachmentsByRange")
|
|
1698
|
+
if isinstance(attachments, dict):
|
|
1699
|
+
for att in attachments.values():
|
|
1700
|
+
if isinstance(att, dict) and att.get("Type") == "ActionOutput":
|
|
1701
|
+
return True
|
|
1702
|
+
return False
|
|
1703
|
+
|
|
1704
|
+
|
|
1705
|
+
def _input_is_token_string(value) -> bool:
|
|
1706
|
+
if not isinstance(value, dict):
|
|
1707
|
+
return False
|
|
1708
|
+
if value.get("WFSerializationType") != "WFTextTokenString":
|
|
1709
|
+
return False
|
|
1710
|
+
inner = value.get("Value")
|
|
1711
|
+
if not isinstance(inner, dict):
|
|
1712
|
+
return False
|
|
1713
|
+
attachments = inner.get("attachmentsByRange")
|
|
1714
|
+
return isinstance(attachments, dict) and bool(attachments)
|
|
1715
|
+
|
|
1716
|
+
|
|
1717
|
+
def _input_is_single_placeholder_token_string(value) -> bool:
|
|
1718
|
+
if not _input_is_token_string(value):
|
|
1719
|
+
return False
|
|
1720
|
+
inner = value.get("Value")
|
|
1721
|
+
if not isinstance(inner, dict):
|
|
1722
|
+
return False
|
|
1723
|
+
if inner.get("string") != "\ufffc":
|
|
1724
|
+
return False
|
|
1725
|
+
attachments = inner.get("attachmentsByRange")
|
|
1726
|
+
return isinstance(attachments, dict) and list(attachments.keys()) == ["{0, 1}"]
|
|
1727
|
+
|
|
1728
|
+
|
|
1729
|
+
def _input_is_editor_visible(value) -> bool:
|
|
1730
|
+
return _conditional_input_is_wrapped(value) or _input_is_token_string(value)
|
|
1731
|
+
|
|
1732
|
+
|
|
1733
|
+
def _token_param_is_empty(value) -> bool:
|
|
1734
|
+
if value is None:
|
|
1735
|
+
return True
|
|
1736
|
+
if isinstance(value, str):
|
|
1737
|
+
return value.strip() == ""
|
|
1738
|
+
if isinstance(value, (int, float, bool)):
|
|
1739
|
+
return False
|
|
1740
|
+
if not isinstance(value, dict):
|
|
1741
|
+
return False
|
|
1742
|
+
ser = value.get("WFSerializationType")
|
|
1743
|
+
if ser == "WFTextTokenString":
|
|
1744
|
+
inner = value.get("Value")
|
|
1745
|
+
if not isinstance(inner, dict):
|
|
1746
|
+
return True
|
|
1747
|
+
string = inner.get("string")
|
|
1748
|
+
attachments = inner.get("attachmentsByRange")
|
|
1749
|
+
if isinstance(attachments, dict) and attachments:
|
|
1750
|
+
return False
|
|
1751
|
+
return not isinstance(string, str) or string.strip() == ""
|
|
1752
|
+
if ser == "WFTextTokenAttachment":
|
|
1753
|
+
inner = value.get("Value")
|
|
1754
|
+
if not isinstance(inner, dict):
|
|
1755
|
+
return True
|
|
1756
|
+
token_type = inner.get("Type")
|
|
1757
|
+
if isinstance(token_type, str) and token_type.strip():
|
|
1758
|
+
return False
|
|
1759
|
+
return True
|
|
1760
|
+
return False
|
|
1761
|
+
|
|
1762
|
+
|
|
1763
|
+
def _validate_health_quantity_field(
|
|
1764
|
+
value,
|
|
1765
|
+
*,
|
|
1766
|
+
key: str,
|
|
1767
|
+
idx: int,
|
|
1768
|
+
errors: list[str],
|
|
1769
|
+
require_magnitude: bool = True,
|
|
1770
|
+
) -> None:
|
|
1771
|
+
if not isinstance(value, dict):
|
|
1772
|
+
errors.append(f"Health quantity field {key} must be a dict at index {idx}")
|
|
1773
|
+
return
|
|
1774
|
+
if value.get("WFSerializationType") != "WFQuantityFieldValue":
|
|
1775
|
+
errors.append(
|
|
1776
|
+
f"Health quantity field {key} must use WFQuantityFieldValue serialization at index {idx}"
|
|
1777
|
+
)
|
|
1778
|
+
return
|
|
1779
|
+
inner = value.get("Value")
|
|
1780
|
+
if not isinstance(inner, dict):
|
|
1781
|
+
errors.append(f"Health quantity field {key} missing Value dict at index {idx}")
|
|
1782
|
+
return
|
|
1783
|
+
|
|
1784
|
+
magnitude = inner.get("Magnitude")
|
|
1785
|
+
unit = inner.get("Unit")
|
|
1786
|
+
if require_magnitude and _token_param_is_empty(magnitude):
|
|
1787
|
+
errors.append(f"Health quantity field {key} missing Magnitude at index {idx}")
|
|
1788
|
+
if not isinstance(unit, str) or unit.strip() == "":
|
|
1789
|
+
errors.append(f"Health quantity field {key} missing Unit at index {idx}")
|
|
1790
|
+
elif HEALTH_REFERENCE_SETS["units"] and unit not in HEALTH_REFERENCE_SETS["units"]:
|
|
1791
|
+
errors.append(f"Health quantity field {key} uses unknown Unit '{unit}' at index {idx}")
|
|
1792
|
+
|
|
1793
|
+
|
|
1794
|
+
def _validate_health_date_like(value, *, key: str, idx: int, errors: list[str]) -> None:
|
|
1795
|
+
if _token_param_is_empty(value):
|
|
1796
|
+
errors.append(f"Health date field {key} is empty at index {idx}")
|
|
1797
|
+
|
|
1798
|
+
|
|
1799
|
+
def _validate_health_filter_template(
|
|
1800
|
+
filter_value,
|
|
1801
|
+
*,
|
|
1802
|
+
action_label: str,
|
|
1803
|
+
idx: int,
|
|
1804
|
+
errors: list[str],
|
|
1805
|
+
) -> None:
|
|
1806
|
+
if not isinstance(filter_value, dict):
|
|
1807
|
+
errors.append(f"{action_label} missing WFContentItemFilter at index {idx}")
|
|
1808
|
+
return
|
|
1809
|
+
if filter_value.get("WFSerializationType") != "WFContentPredicateTableTemplate":
|
|
1810
|
+
errors.append(
|
|
1811
|
+
f"{action_label} WFContentItemFilter must use WFContentPredicateTableTemplate at index {idx}"
|
|
1812
|
+
)
|
|
1813
|
+
return
|
|
1814
|
+
inner = filter_value.get("Value")
|
|
1815
|
+
if not isinstance(inner, dict):
|
|
1816
|
+
errors.append(f"{action_label} WFContentItemFilter missing Value dict at index {idx}")
|
|
1817
|
+
return
|
|
1818
|
+
prefix = inner.get("WFActionParameterFilterPrefix")
|
|
1819
|
+
if prefix not in (0, 1):
|
|
1820
|
+
errors.append(
|
|
1821
|
+
f"{action_label} WFContentItemFilter prefix must be 0 (Any) or 1 (All) at index {idx}"
|
|
1822
|
+
)
|
|
1823
|
+
templates = inner.get("WFActionParameterFilterTemplates")
|
|
1824
|
+
if not isinstance(templates, list) or not templates:
|
|
1825
|
+
errors.append(f"{action_label} WFContentItemFilter has no templates at index {idx}")
|
|
1826
|
+
return
|
|
1827
|
+
for tidx, template in enumerate(templates):
|
|
1828
|
+
if not isinstance(template, dict):
|
|
1829
|
+
errors.append(f"{action_label} filter template {tidx} is not a dict at index {idx}")
|
|
1830
|
+
continue
|
|
1831
|
+
if _token_param_is_empty(template.get("Property")):
|
|
1832
|
+
errors.append(f"{action_label} filter template {tidx} missing Property at index {idx}")
|
|
1833
|
+
if "Operator" not in template:
|
|
1834
|
+
errors.append(f"{action_label} filter template {tidx} missing Operator at index {idx}")
|
|
1835
|
+
if "Values" in template and not isinstance(template.get("Values"), dict):
|
|
1836
|
+
errors.append(f"{action_label} filter template {tidx} Values must be a dict at index {idx}")
|
|
1837
|
+
|
|
1838
|
+
|
|
1839
|
+
def _health_filter_type_value(filter_value) -> tuple[Optional[str], Optional[int], bool]:
|
|
1840
|
+
if not isinstance(filter_value, dict):
|
|
1841
|
+
return None, None, False
|
|
1842
|
+
inner = filter_value.get("Value")
|
|
1843
|
+
if not isinstance(inner, dict):
|
|
1844
|
+
return None, None, False
|
|
1845
|
+
templates = inner.get("WFActionParameterFilterTemplates")
|
|
1846
|
+
if not isinstance(templates, list):
|
|
1847
|
+
return None, None, False
|
|
1848
|
+
|
|
1849
|
+
malformed_legacy_value_row = False
|
|
1850
|
+
type_value: Optional[str] = None
|
|
1851
|
+
type_operator: Optional[int] = None
|
|
1852
|
+
for template in templates:
|
|
1853
|
+
if not isinstance(template, dict):
|
|
1854
|
+
continue
|
|
1855
|
+
prop = template.get("Property")
|
|
1856
|
+
if prop == "Value":
|
|
1857
|
+
malformed_legacy_value_row = True
|
|
1858
|
+
continue
|
|
1859
|
+
if prop != "Type":
|
|
1860
|
+
continue
|
|
1861
|
+
type_operator = template.get("Operator")
|
|
1862
|
+
if template.get("Bounded") is not True or template.get("Removable") is not False:
|
|
1863
|
+
return None, type_operator, True
|
|
1864
|
+
values = template.get("Values")
|
|
1865
|
+
if not isinstance(values, dict):
|
|
1866
|
+
return None, type_operator, True
|
|
1867
|
+
enum_state = values.get("Enumeration")
|
|
1868
|
+
if not isinstance(enum_state, dict):
|
|
1869
|
+
return None, type_operator, True
|
|
1870
|
+
if enum_state.get("WFSerializationType") != "WFStringSubstitutableState":
|
|
1871
|
+
return None, type_operator, True
|
|
1872
|
+
enum_value = enum_state.get("Value")
|
|
1873
|
+
if isinstance(enum_value, str) and enum_value:
|
|
1874
|
+
type_value = enum_value
|
|
1875
|
+
continue
|
|
1876
|
+
return None, type_operator, True
|
|
1877
|
+
return type_value, type_operator, malformed_legacy_value_row
|
|
1878
|
+
|
|
1879
|
+
|
|
1880
|
+
def _lang_value_is_code(value) -> bool:
|
|
1881
|
+
if isinstance(value, str):
|
|
1882
|
+
return bool(LANG_CODE_RE.match(value)) and " " not in value
|
|
1883
|
+
if isinstance(value, dict) and value.get("WFSerializationType") == "WFTextTokenString":
|
|
1884
|
+
inner = value.get("Value")
|
|
1885
|
+
if isinstance(inner, dict):
|
|
1886
|
+
s = inner.get("string")
|
|
1887
|
+
if isinstance(s, str) and "" not in s:
|
|
1888
|
+
return bool(LANG_CODE_RE.match(s)) and " " not in s
|
|
1889
|
+
return False
|
|
1890
|
+
|
|
1891
|
+
|
|
1892
|
+
def _extract_input_variable_name(value) -> Optional[str]:
|
|
1893
|
+
if not isinstance(value, dict):
|
|
1894
|
+
return None
|
|
1895
|
+
if value.get("Type") == "Variable" and isinstance(value.get("Variable"), dict):
|
|
1896
|
+
value = value.get("Variable")
|
|
1897
|
+
if not isinstance(value, dict):
|
|
1898
|
+
return None
|
|
1899
|
+
# WFTextTokenAttachment with direct variable
|
|
1900
|
+
val = value.get("Value")
|
|
1901
|
+
if isinstance(val, dict) and val.get("Type") == "Variable":
|
|
1902
|
+
name = val.get("VariableName")
|
|
1903
|
+
if isinstance(name, str) and name.strip():
|
|
1904
|
+
return name
|
|
1905
|
+
# WFTextTokenString with variable attachment
|
|
1906
|
+
if value.get("WFSerializationType") == "WFTextTokenString":
|
|
1907
|
+
val = value.get("Value")
|
|
1908
|
+
if isinstance(val, dict):
|
|
1909
|
+
attachments = val.get("attachmentsByRange")
|
|
1910
|
+
if isinstance(attachments, dict):
|
|
1911
|
+
for att in attachments.values():
|
|
1912
|
+
if isinstance(att, dict) and att.get("Type") == "Variable":
|
|
1913
|
+
name = att.get("VariableName")
|
|
1914
|
+
if isinstance(name, str) and name.strip():
|
|
1915
|
+
return name
|
|
1916
|
+
return None
|
|
1917
|
+
|
|
1918
|
+
|
|
1919
|
+
def _extract_input_variable_names(value) -> set[str]:
|
|
1920
|
+
return {name for name in iter_variable_names(value) if isinstance(name, str) and name.strip()}
|
|
1921
|
+
|
|
1922
|
+
|
|
1923
|
+
def _reassigned_list_variable_name(value, set_counts: dict[str, int], list_set_counts: dict[str, int]) -> str | None:
|
|
1924
|
+
name = _extract_input_variable_name(value)
|
|
1925
|
+
if (
|
|
1926
|
+
name
|
|
1927
|
+
and set_counts.get(name, 0) > 1
|
|
1928
|
+
and list_set_counts.get(name, 0) > 1
|
|
1929
|
+
):
|
|
1930
|
+
return name
|
|
1931
|
+
return None
|
|
1932
|
+
|
|
1933
|
+
|
|
1934
|
+
def _input_action_output_uuids(value) -> list[str]:
|
|
1935
|
+
if not isinstance(value, dict):
|
|
1936
|
+
return []
|
|
1937
|
+
if value.get("Type") == "Variable" and isinstance(value.get("Variable"), dict):
|
|
1938
|
+
value = value.get("Variable")
|
|
1939
|
+
ser = value.get("WFSerializationType")
|
|
1940
|
+
if ser == "WFTextTokenAttachment":
|
|
1941
|
+
val = value.get("Value")
|
|
1942
|
+
if isinstance(val, dict) and val.get("Type") == "ActionOutput":
|
|
1943
|
+
uuid = val.get("OutputUUID")
|
|
1944
|
+
return [uuid] if uuid else []
|
|
1945
|
+
if ser == "WFTextTokenString":
|
|
1946
|
+
val = value.get("Value") if isinstance(value.get("Value"), dict) else {}
|
|
1947
|
+
attachments = val.get("attachmentsByRange")
|
|
1948
|
+
if isinstance(attachments, dict):
|
|
1949
|
+
out = []
|
|
1950
|
+
for att in attachments.values():
|
|
1951
|
+
if isinstance(att, dict) and att.get("Type") == "ActionOutput":
|
|
1952
|
+
uuid = att.get("OutputUUID")
|
|
1953
|
+
if uuid:
|
|
1954
|
+
out.append(uuid)
|
|
1955
|
+
return out
|
|
1956
|
+
return []
|
|
1957
|
+
|
|
1958
|
+
|
|
1959
|
+
def _input_has_current_date_token(value) -> bool:
|
|
1960
|
+
if not isinstance(value, dict):
|
|
1961
|
+
return False
|
|
1962
|
+
if value.get("Type") == "Variable" and isinstance(value.get("Variable"), dict):
|
|
1963
|
+
value = value.get("Variable")
|
|
1964
|
+
ser = value.get("WFSerializationType")
|
|
1965
|
+
if ser == "WFTextTokenAttachment":
|
|
1966
|
+
val = value.get("Value")
|
|
1967
|
+
return isinstance(val, dict) and val.get("Type") == "CurrentDate"
|
|
1968
|
+
if ser == "WFTextTokenString":
|
|
1969
|
+
val = value.get("Value") if isinstance(value.get("Value"), dict) else {}
|
|
1970
|
+
attachments = val.get("attachmentsByRange")
|
|
1971
|
+
if isinstance(attachments, dict):
|
|
1972
|
+
return any(
|
|
1973
|
+
isinstance(att, dict) and att.get("Type") == "CurrentDate"
|
|
1974
|
+
for att in attachments.values()
|
|
1975
|
+
)
|
|
1976
|
+
return False
|
|
1977
|
+
|
|
1978
|
+
|
|
1979
|
+
def _collect_send_message_empty_text_spacer_uuids(actions: list[dict]) -> set[str]:
|
|
1980
|
+
"""Empty Text outputs are allowed only as Send Message payload spacers."""
|
|
1981
|
+
send_message_vars: set[str] = set()
|
|
1982
|
+
empty_text_uuids: set[str] = set()
|
|
1983
|
+
|
|
1984
|
+
for act in actions:
|
|
1985
|
+
ident = act.get("WFWorkflowActionIdentifier")
|
|
1986
|
+
params = act.get("WFWorkflowActionParameters") or {}
|
|
1987
|
+
if ident == "is.workflow.actions.sendmessage":
|
|
1988
|
+
name = _extract_input_variable_name(params.get("WFSendMessageContent"))
|
|
1989
|
+
if name:
|
|
1990
|
+
send_message_vars.add(name)
|
|
1991
|
+
elif ident == "is.workflow.actions.gettext":
|
|
1992
|
+
uuid = params.get("UUID")
|
|
1993
|
+
if isinstance(uuid, str) and params.get("WFTextActionText") == "":
|
|
1994
|
+
empty_text_uuids.add(uuid)
|
|
1995
|
+
|
|
1996
|
+
if not send_message_vars or not empty_text_uuids:
|
|
1997
|
+
return set()
|
|
1998
|
+
|
|
1999
|
+
spacer_uuids: set[str] = set()
|
|
2000
|
+
for act in actions:
|
|
2001
|
+
if act.get("WFWorkflowActionIdentifier") != "is.workflow.actions.appendvariable":
|
|
2002
|
+
continue
|
|
2003
|
+
params = act.get("WFWorkflowActionParameters") or {}
|
|
2004
|
+
if params.get("WFVariableName") not in send_message_vars:
|
|
2005
|
+
continue
|
|
2006
|
+
for uuid in _input_action_output_uuids(params.get("WFInput")):
|
|
2007
|
+
if uuid in empty_text_uuids:
|
|
2008
|
+
spacer_uuids.add(uuid)
|
|
2009
|
+
return spacer_uuids
|
|
2010
|
+
|
|
2011
|
+
|
|
2012
|
+
def _input_has_reference(value) -> bool:
|
|
2013
|
+
if _extract_input_variable_name(value) or _input_action_output_uuids(value):
|
|
2014
|
+
return True
|
|
2015
|
+
if not isinstance(value, dict):
|
|
2016
|
+
return False
|
|
2017
|
+
if value.get("Type") == "Variable" and isinstance(value.get("Variable"), dict):
|
|
2018
|
+
value = value.get("Variable")
|
|
2019
|
+
if not isinstance(value, dict):
|
|
2020
|
+
return False
|
|
2021
|
+
if value.get("WFSerializationType") == "WFTextTokenAttachment":
|
|
2022
|
+
val = value.get("Value")
|
|
2023
|
+
return isinstance(val, dict) and val.get("Type") == "ExtensionInput"
|
|
2024
|
+
if value.get("WFSerializationType") == "WFTextTokenString":
|
|
2025
|
+
val = value.get("Value")
|
|
2026
|
+
if isinstance(val, dict):
|
|
2027
|
+
attachments = val.get("attachmentsByRange")
|
|
2028
|
+
if isinstance(attachments, dict):
|
|
2029
|
+
for att in attachments.values():
|
|
2030
|
+
if isinstance(att, dict) and att.get("Type") == "ExtensionInput":
|
|
2031
|
+
return True
|
|
2032
|
+
return False
|
|
2033
|
+
|
|
2034
|
+
|
|
2035
|
+
def _math_operand_has_reference(value) -> bool:
|
|
2036
|
+
if value is None:
|
|
2037
|
+
return False
|
|
2038
|
+
if isinstance(value, (int, float)):
|
|
2039
|
+
return True
|
|
2040
|
+
if isinstance(value, str) and value.strip() != "":
|
|
2041
|
+
return True
|
|
2042
|
+
if isinstance(value, dict):
|
|
2043
|
+
if value.get("WFSerializationType") in {"WFTextTokenAttachment", "WFTextTokenString"}:
|
|
2044
|
+
val = value.get("Value")
|
|
2045
|
+
if isinstance(val, dict):
|
|
2046
|
+
if val.get("Type") == "Variable" and val.get("VariableName"):
|
|
2047
|
+
return True
|
|
2048
|
+
if val.get("Type") == "ActionOutput" and val.get("OutputUUID"):
|
|
2049
|
+
return True
|
|
2050
|
+
if isinstance(val.get("string"), str) and val.get("string").strip() != "":
|
|
2051
|
+
return True
|
|
2052
|
+
return False
|
|
2053
|
+
|
|
2054
|
+
|
|
2055
|
+
def _math_operand_literal_string(value) -> Optional[str]:
|
|
2056
|
+
if isinstance(value, (int, float)):
|
|
2057
|
+
return str(value)
|
|
2058
|
+
if isinstance(value, str):
|
|
2059
|
+
stripped = value.strip()
|
|
2060
|
+
return stripped or None
|
|
2061
|
+
if isinstance(value, dict):
|
|
2062
|
+
val = value.get("Value")
|
|
2063
|
+
if isinstance(val, dict):
|
|
2064
|
+
string_value = val.get("string")
|
|
2065
|
+
if isinstance(string_value, str):
|
|
2066
|
+
stripped = string_value.strip()
|
|
2067
|
+
return stripped or None
|
|
2068
|
+
return None
|
|
2069
|
+
|
|
2070
|
+
|
|
2071
|
+
def iter_aggrandizements(obj):
|
|
2072
|
+
"""Yield all Aggrandizement dicts found recursively in a parameter tree."""
|
|
2073
|
+
if isinstance(obj, dict):
|
|
2074
|
+
aggs = obj.get("Aggrandizements")
|
|
2075
|
+
if isinstance(aggs, list):
|
|
2076
|
+
for item in aggs:
|
|
2077
|
+
if isinstance(item, dict):
|
|
2078
|
+
yield item
|
|
2079
|
+
for v in obj.values():
|
|
2080
|
+
yield from iter_aggrandizements(v)
|
|
2081
|
+
elif isinstance(obj, list):
|
|
2082
|
+
for v in obj:
|
|
2083
|
+
yield from iter_aggrandizements(v)
|
|
2084
|
+
|
|
2085
|
+
|
|
2086
|
+
WORKFLOW_TRIGGER_KEYS = {
|
|
2087
|
+
"WFTriggerIdentifier",
|
|
2088
|
+
"WFTriggerSerializedParameters",
|
|
2089
|
+
"WFTriggerUUID",
|
|
2090
|
+
}
|
|
2091
|
+
WORKFLOW_TRIGGER_IDENTIFIERS_REQUIRING_EDITOR_CONFIGURATION = {
|
|
2092
|
+
# OS 27 exports these as bare WFWorkflowTriggers dictionaries, but the
|
|
2093
|
+
# Shortcuts editor marks those imports invalid until picker state is created
|
|
2094
|
+
# inside the app. The file/folder/drive selection is stored outside the
|
|
2095
|
+
# portable trigger dictionary, so XML generation cannot safely synthesize it.
|
|
2096
|
+
"WFDiskMountTrigger",
|
|
2097
|
+
"WFFileTrigger",
|
|
2098
|
+
"WFFolderTrigger",
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
|
|
2102
|
+
def _contains_catalog_placeholder(value) -> bool:
|
|
2103
|
+
if isinstance(value, dict):
|
|
2104
|
+
if "$placeholder" in value:
|
|
2105
|
+
return True
|
|
2106
|
+
return any(_contains_catalog_placeholder(item) for item in value.values())
|
|
2107
|
+
if isinstance(value, list):
|
|
2108
|
+
return any(_contains_catalog_placeholder(item) for item in value)
|
|
2109
|
+
return False
|
|
2110
|
+
|
|
2111
|
+
|
|
2112
|
+
def _validate_workflow_triggers(
|
|
2113
|
+
plist,
|
|
2114
|
+
errors: list[str],
|
|
2115
|
+
workflow_trigger_catalog: dict[str, object],
|
|
2116
|
+
target_macos_major: int | None,
|
|
2117
|
+
) -> None:
|
|
2118
|
+
if "WFWorkflowTriggers" not in plist:
|
|
2119
|
+
return
|
|
2120
|
+
if target_macos_major is not None and target_macos_major < WORKFLOW_TRIGGER_CATALOG_MIN_MACOS_MAJOR:
|
|
2121
|
+
errors.append(
|
|
2122
|
+
"WFWorkflowTriggers requires macOS/iOS 27+. "
|
|
2123
|
+
f"Target macOS is {target_macos_major}; remove the trigger header or validate with --target-macos 27."
|
|
2124
|
+
)
|
|
2125
|
+
triggers = plist.get("WFWorkflowTriggers")
|
|
2126
|
+
if not isinstance(triggers, list):
|
|
2127
|
+
errors.append("WFWorkflowTriggers must be an array")
|
|
2128
|
+
return
|
|
2129
|
+
workflow_identifiers = workflow_trigger_catalog.get("workflow_identifiers")
|
|
2130
|
+
if not isinstance(workflow_identifiers, set):
|
|
2131
|
+
workflow_identifiers = set()
|
|
2132
|
+
serialized_keys_by_identifier = workflow_trigger_catalog.get(
|
|
2133
|
+
"serialized_keys_by_identifier"
|
|
2134
|
+
)
|
|
2135
|
+
if not isinstance(serialized_keys_by_identifier, dict):
|
|
2136
|
+
serialized_keys_by_identifier = {}
|
|
2137
|
+
|
|
2138
|
+
for index, trigger in enumerate(triggers):
|
|
2139
|
+
prefix = f"WFWorkflowTriggers[{index}]"
|
|
2140
|
+
if not isinstance(trigger, dict):
|
|
2141
|
+
errors.append(f"{prefix} must be a dictionary")
|
|
2142
|
+
continue
|
|
2143
|
+
missing = sorted(WORKFLOW_TRIGGER_KEYS - set(trigger))
|
|
2144
|
+
if missing:
|
|
2145
|
+
errors.append(f"{prefix} missing required key(s): {', '.join(missing)}")
|
|
2146
|
+
extra = sorted(set(trigger) - WORKFLOW_TRIGGER_KEYS)
|
|
2147
|
+
if extra:
|
|
2148
|
+
errors.append(f"{prefix} has unknown key(s): {', '.join(extra)}")
|
|
2149
|
+
|
|
2150
|
+
wf_identifier = trigger.get("WFTriggerIdentifier")
|
|
2151
|
+
if not isinstance(wf_identifier, str) or not wf_identifier.strip():
|
|
2152
|
+
errors.append(f"{prefix}.WFTriggerIdentifier must be a non-empty string")
|
|
2153
|
+
elif workflow_identifiers and wf_identifier not in workflow_identifiers:
|
|
2154
|
+
errors.append(
|
|
2155
|
+
f"{prefix}.WFTriggerIdentifier is not in the exported OS 27 trigger catalog: "
|
|
2156
|
+
f"{wf_identifier}"
|
|
2157
|
+
)
|
|
2158
|
+
|
|
2159
|
+
trigger_uuid = trigger.get("WFTriggerUUID")
|
|
2160
|
+
if not isinstance(trigger_uuid, str) or not trigger_uuid.strip():
|
|
2161
|
+
errors.append(f"{prefix}.WFTriggerUUID must be a non-empty UUID string")
|
|
2162
|
+
elif not UUID_RE.match(trigger_uuid):
|
|
2163
|
+
errors.append(
|
|
2164
|
+
f"{prefix}.WFTriggerUUID must be an uppercase UUID generated with uuidgen: "
|
|
2165
|
+
f"{trigger_uuid!r}"
|
|
2166
|
+
)
|
|
2167
|
+
elif REPEATING_UUID_RE.search(trigger_uuid):
|
|
2168
|
+
errors.append(
|
|
2169
|
+
f"{prefix}.WFTriggerUUID is a repeating placeholder UUID: {trigger_uuid}"
|
|
2170
|
+
)
|
|
2171
|
+
|
|
2172
|
+
serialized = trigger.get("WFTriggerSerializedParameters")
|
|
2173
|
+
if not isinstance(serialized, dict):
|
|
2174
|
+
errors.append(f"{prefix}.WFTriggerSerializedParameters must be a dictionary")
|
|
2175
|
+
continue
|
|
2176
|
+
if _contains_catalog_placeholder(serialized):
|
|
2177
|
+
errors.append(
|
|
2178
|
+
f"{prefix}.WFTriggerSerializedParameters still contains catalog placeholder values; "
|
|
2179
|
+
"replace local picker payloads with user-provided/exported values before signing."
|
|
2180
|
+
)
|
|
2181
|
+
if isinstance(wf_identifier, str):
|
|
2182
|
+
if wf_identifier in WORKFLOW_TRIGGER_IDENTIFIERS_REQUIRING_EDITOR_CONFIGURATION:
|
|
2183
|
+
errors.append(
|
|
2184
|
+
f"{prefix}.{wf_identifier} cannot currently be generated as portable "
|
|
2185
|
+
"WFWorkflowTriggers XML. macOS 27 exports a lossy empty trigger dictionary, "
|
|
2186
|
+
"but Shortcuts stores the required picker state outside that dictionary and "
|
|
2187
|
+
"imports the bare header as an invalid automation."
|
|
2188
|
+
)
|
|
2189
|
+
expected_keys = serialized_keys_by_identifier.get(wf_identifier, set())
|
|
2190
|
+
if isinstance(expected_keys, set) and expected_keys:
|
|
2191
|
+
unknown = sorted(key for key in serialized if key not in expected_keys)
|
|
2192
|
+
if unknown:
|
|
2193
|
+
expected = ", ".join(sorted(expected_keys))
|
|
2194
|
+
errors.append(
|
|
2195
|
+
f"{prefix}.WFTriggerSerializedParameters has unknown key(s) for "
|
|
2196
|
+
f"{wf_identifier}: {', '.join(unknown)}. Exported/ToolKit keys: {expected}."
|
|
2197
|
+
)
|
|
2198
|
+
|
|
2199
|
+
|
|
2200
|
+
def validate(
|
|
2201
|
+
plist,
|
|
2202
|
+
allowed_ids: set[str],
|
|
2203
|
+
allowed_glyph_ids: Optional[set[int]] = None,
|
|
2204
|
+
allowed_icon_colors: Optional[set[int]] = None,
|
|
2205
|
+
unavailable_ids: Optional[dict[str, str]] = None,
|
|
2206
|
+
unavailable_parameter_keys: Optional[dict[str, dict[str, str]]] = None,
|
|
2207
|
+
toolkit_parameter_schemas: Optional[dict[str, set[str]]] = None,
|
|
2208
|
+
toolkit_parameter_enum_cases: Optional[dict[str, dict[str, set[str]]]] = None,
|
|
2209
|
+
toolkit_parameter_boolean_keys: Optional[dict[str, set[str]]] = None,
|
|
2210
|
+
workflow_trigger_catalog: Optional[dict[str, object]] = None,
|
|
2211
|
+
target_macos_major: int | None = None,
|
|
2212
|
+
) -> Tuple[list[str], Optional[Tuple[int, str, str]]]:
|
|
2213
|
+
errors: list[str] = []
|
|
2214
|
+
first_error: Optional[Tuple[int, str, str]] = None
|
|
2215
|
+
unavailable_ids = unavailable_ids or {}
|
|
2216
|
+
unavailable_parameter_keys = unavailable_parameter_keys or {}
|
|
2217
|
+
toolkit_parameter_schemas = toolkit_parameter_schemas or {}
|
|
2218
|
+
toolkit_parameter_enum_cases = toolkit_parameter_enum_cases or {}
|
|
2219
|
+
toolkit_parameter_boolean_keys = toolkit_parameter_boolean_keys or {}
|
|
2220
|
+
workflow_trigger_catalog = workflow_trigger_catalog or {}
|
|
2221
|
+
actions = plist.get("WFWorkflowActions", [])
|
|
2222
|
+
comments: list[str] = []
|
|
2223
|
+
uuid_to_ident: dict[str, str] = {}
|
|
2224
|
+
uuid_to_params: dict[str, dict] = {}
|
|
2225
|
+
append_counts = _collect_variable_append_counts(actions)
|
|
2226
|
+
send_message_empty_text_spacer_uuids = _collect_send_message_empty_text_spacer_uuids(actions)
|
|
2227
|
+
used_output_uuids: set[str] = set()
|
|
2228
|
+
repeat_stack: list[dict] = []
|
|
2229
|
+
repeat_end_uuids: dict[str, str] = {}
|
|
2230
|
+
defined_vars: set[str] = set()
|
|
2231
|
+
setvariable_counts: dict[str, int] = {}
|
|
2232
|
+
list_variable_set_counts: dict[str, int] = {}
|
|
2233
|
+
uuid_to_var_name: dict[str, str] = {}
|
|
2234
|
+
var_format_dates: dict[str, dict] = {}
|
|
2235
|
+
cents_vars: set[str] = set()
|
|
2236
|
+
divide_by_100_vars: set[str] = set()
|
|
2237
|
+
divide_by_100_uuids: set[str] = set()
|
|
2238
|
+
sleep_duration_divide_by_60_uuids: set[str] = set()
|
|
2239
|
+
sleep_duration_divide_by_60_round_uuids: set[str] = set()
|
|
2240
|
+
cents_context_steps = 0
|
|
2241
|
+
unit_text_found = False
|
|
2242
|
+
has_measurement_convert = False
|
|
2243
|
+
weather_source_vars: set[str] = set()
|
|
2244
|
+
location_source_vars: set[str] = set()
|
|
2245
|
+
health_sample_source_vars: set[str] = set()
|
|
2246
|
+
rename_file_source_vars: dict[str, int] = {}
|
|
2247
|
+
rename_file_source_uuids: dict[str, int] = {}
|
|
2248
|
+
rename_file_output_uuids: dict[str, int] = {}
|
|
2249
|
+
|
|
2250
|
+
_validate_workflow_triggers(
|
|
2251
|
+
plist,
|
|
2252
|
+
errors,
|
|
2253
|
+
workflow_trigger_catalog,
|
|
2254
|
+
target_macos_major,
|
|
2255
|
+
)
|
|
2256
|
+
|
|
2257
|
+
for idx, act in enumerate(actions):
|
|
2258
|
+
ident = act.get("WFWorkflowActionIdentifier")
|
|
2259
|
+
if ident == "is.workflow.actions.comment":
|
|
2260
|
+
params = act.get("WFWorkflowActionParameters") or {}
|
|
2261
|
+
text = params.get("WFCommentActionText")
|
|
2262
|
+
if isinstance(text, str):
|
|
2263
|
+
comments.append(text)
|
|
2264
|
+
wf_terms = _comment_internal_wf_terms(text)
|
|
2265
|
+
if wf_terms:
|
|
2266
|
+
hints = ", ".join(
|
|
2267
|
+
f"{token}->{_friendly_comment_field_name(token)}" for token in wf_terms
|
|
2268
|
+
)
|
|
2269
|
+
errors.append(
|
|
2270
|
+
f"Comment uses internal WF parameter names at index {idx}: {hints}. "
|
|
2271
|
+
"Use Shortcuts UI wording (for example: Input, Date, Provided Input, Repeat Item)."
|
|
2272
|
+
)
|
|
2273
|
+
if not unit_text_found and _text_has_unit_keywords(text):
|
|
2274
|
+
unit_text_found = True
|
|
2275
|
+
if ident == "is.workflow.actions.measurement.convert":
|
|
2276
|
+
has_measurement_convert = True
|
|
2277
|
+
params = act.get("WFWorkflowActionParameters") or {}
|
|
2278
|
+
parameter_reasons = unavailable_parameter_keys.get(ident) if ident else None
|
|
2279
|
+
if parameter_reasons:
|
|
2280
|
+
for key in sorted(parameter_reasons):
|
|
2281
|
+
if key in params:
|
|
2282
|
+
errors.append(
|
|
2283
|
+
f"Parameter '{key}' on {ident} requires {parameter_reasons[key]} "
|
|
2284
|
+
f"at index {idx}. Set --target-macos 27 or "
|
|
2285
|
+
"SHORTCUTS_PLAYGROUND_TARGET_MACOS=27 only when building for Golden Gate."
|
|
2286
|
+
)
|
|
2287
|
+
if isinstance(ident, str) and ident.startswith("com.apple."):
|
|
2288
|
+
descriptor = params.get("AppIntentDescriptor")
|
|
2289
|
+
if not isinstance(descriptor, dict):
|
|
2290
|
+
errors.append(
|
|
2291
|
+
f"AppIntent action missing AppIntentDescriptor at index {idx}: {ident}"
|
|
2292
|
+
)
|
|
2293
|
+
elif not descriptor.get("AppIntentIdentifier"):
|
|
2294
|
+
errors.append(
|
|
2295
|
+
f"AppIntentDescriptor missing AppIntentIdentifier at index {idx}: {ident}"
|
|
2296
|
+
)
|
|
2297
|
+
expected_toolkit_keys = toolkit_parameter_schemas.get(ident) if ident else None
|
|
2298
|
+
if expected_toolkit_keys is not None:
|
|
2299
|
+
unknown_keys = sorted(
|
|
2300
|
+
key
|
|
2301
|
+
for key in params
|
|
2302
|
+
if key not in expected_toolkit_keys
|
|
2303
|
+
and key not in TOOLKIT_PARAMETER_STRUCTURAL_KEYS
|
|
2304
|
+
)
|
|
2305
|
+
if unknown_keys:
|
|
2306
|
+
expected = ", ".join(sorted(expected_toolkit_keys)) or "no action parameters"
|
|
2307
|
+
errors.append(
|
|
2308
|
+
f"Unknown AppIntent parameter key(s) for {ident} at index {idx}: "
|
|
2309
|
+
f"{', '.join(unknown_keys)}. ToolKit v78 expects: {expected}."
|
|
2310
|
+
)
|
|
2311
|
+
expected_enum_cases = toolkit_parameter_enum_cases.get(ident) if ident else None
|
|
2312
|
+
if expected_enum_cases:
|
|
2313
|
+
enum_error_kind = "AppIntent" if str(ident).startswith("com.apple.") else "ToolKit"
|
|
2314
|
+
for key, allowed_values in sorted(expected_enum_cases.items()):
|
|
2315
|
+
if key not in params:
|
|
2316
|
+
continue
|
|
2317
|
+
literal_values = _literal_enum_values(params.get(key))
|
|
2318
|
+
for literal_value in literal_values:
|
|
2319
|
+
if literal_value not in allowed_values and not _is_allowed_toolkit_enum_alias(
|
|
2320
|
+
ident,
|
|
2321
|
+
key,
|
|
2322
|
+
literal_value,
|
|
2323
|
+
allowed_values,
|
|
2324
|
+
):
|
|
2325
|
+
errors.append(
|
|
2326
|
+
f"Invalid {enum_error_kind} enum value for {ident}.{key} at index {idx}: "
|
|
2327
|
+
f"{literal_value!r}. ToolKit v78 allows: "
|
|
2328
|
+
f"{_format_allowed_values(allowed_values)}."
|
|
2329
|
+
)
|
|
2330
|
+
expected_boolean_keys = toolkit_parameter_boolean_keys.get(ident) if ident else None
|
|
2331
|
+
if expected_boolean_keys:
|
|
2332
|
+
boolean_error_kind = "AppIntent" if str(ident).startswith("com.apple.") else "ToolKit"
|
|
2333
|
+
for key in sorted(expected_boolean_keys):
|
|
2334
|
+
value = params.get(key)
|
|
2335
|
+
if (
|
|
2336
|
+
key in params
|
|
2337
|
+
and not isinstance(value, bool)
|
|
2338
|
+
and not _parameter_is_dynamic_attachment(value)
|
|
2339
|
+
):
|
|
2340
|
+
errors.append(
|
|
2341
|
+
f"Invalid {boolean_error_kind} boolean value for {ident}.{key} at index {idx}: "
|
|
2342
|
+
"expected a plist boolean."
|
|
2343
|
+
)
|
|
2344
|
+
used_output_uuids.update(iter_action_output_refs(params))
|
|
2345
|
+
if not unit_text_found:
|
|
2346
|
+
for path, s in iter_strings(params):
|
|
2347
|
+
if path and path[-1] in UNIT_KEYWORD_IGNORED_KEYS:
|
|
2348
|
+
continue
|
|
2349
|
+
if isinstance(s, str) and s and _text_has_unit_keywords(s):
|
|
2350
|
+
unit_text_found = True
|
|
2351
|
+
break
|
|
2352
|
+
uuid = params.get("UUID")
|
|
2353
|
+
if uuid and ident:
|
|
2354
|
+
uuid_to_ident[uuid] = ident
|
|
2355
|
+
uuid_to_params[uuid] = params
|
|
2356
|
+
|
|
2357
|
+
allow_vcard = any(VCARD_MARKER in text.upper() for text in comments)
|
|
2358
|
+
allow_token_file = any(TOKEN_FILE_MARKER in text.upper() for text in comments)
|
|
2359
|
+
allow_datetime_format = any(ALLOW_DATETIME_FORMAT_MARKER in text.upper() for text in comments)
|
|
2360
|
+
allow_manual_unit_conversion = any(
|
|
2361
|
+
ALLOW_MANUAL_UNIT_CONVERSION_MARKER in text.upper() for text in comments
|
|
2362
|
+
)
|
|
2363
|
+
|
|
2364
|
+
# --- GroupingIdentifier consistency check ---
|
|
2365
|
+
grouping_start: dict[str, tuple[int, str]] = {} # grouping_id -> (idx, ident)
|
|
2366
|
+
grouping_seen_end: set[str] = set()
|
|
2367
|
+
|
|
2368
|
+
for idx, act in enumerate(actions):
|
|
2369
|
+
ident = act.get("WFWorkflowActionIdentifier")
|
|
2370
|
+
if ident not in CONTROL_FLOW_TOOLKIT_EXCEPTIONS:
|
|
2371
|
+
continue
|
|
2372
|
+
params = act.get("WFWorkflowActionParameters") or {}
|
|
2373
|
+
mode = _coerce_control_flow_mode(params)
|
|
2374
|
+
if mode is None:
|
|
2375
|
+
mode = 0
|
|
2376
|
+
grouping = params.get("GroupingIdentifier")
|
|
2377
|
+
if not grouping:
|
|
2378
|
+
continue
|
|
2379
|
+
if mode == 0:
|
|
2380
|
+
if grouping in grouping_start:
|
|
2381
|
+
errors.append(
|
|
2382
|
+
f"Duplicate GroupingIdentifier '{grouping[:12]}...' for start action at index {idx}; "
|
|
2383
|
+
f"first seen at index {grouping_start[grouping][0]}"
|
|
2384
|
+
)
|
|
2385
|
+
else:
|
|
2386
|
+
grouping_start[grouping] = (idx, ident or "")
|
|
2387
|
+
elif mode in (1, 2):
|
|
2388
|
+
if grouping not in grouping_start:
|
|
2389
|
+
errors.append(
|
|
2390
|
+
f"Control-flow middle/end at index {idx} has GroupingIdentifier with no matching start (mode 0)"
|
|
2391
|
+
)
|
|
2392
|
+
else:
|
|
2393
|
+
start_ident = grouping_start[grouping][1]
|
|
2394
|
+
if ident != start_ident:
|
|
2395
|
+
errors.append(
|
|
2396
|
+
f"GroupingIdentifier mismatch at index {idx}: action '{ident}' "
|
|
2397
|
+
f"does not match start action '{start_ident}'"
|
|
2398
|
+
)
|
|
2399
|
+
if mode == 2:
|
|
2400
|
+
grouping_seen_end.add(grouping)
|
|
2401
|
+
|
|
2402
|
+
for grouping, (start_idx, start_ident) in grouping_start.items():
|
|
2403
|
+
if grouping not in grouping_seen_end:
|
|
2404
|
+
errors.append(
|
|
2405
|
+
f"Control-flow start '{start_ident}' at index {start_idx} has no matching end (mode 2)"
|
|
2406
|
+
)
|
|
2407
|
+
|
|
2408
|
+
# --- Menu case count and title matching check ---
|
|
2409
|
+
menu_items_by_grouping: dict[str, list[str]] = {}
|
|
2410
|
+
menu_cases_by_grouping: dict[str, list[tuple[int, str]]] = {}
|
|
2411
|
+
|
|
2412
|
+
for idx, act in enumerate(actions):
|
|
2413
|
+
if act.get("WFWorkflowActionIdentifier") != "is.workflow.actions.choosefrommenu":
|
|
2414
|
+
continue
|
|
2415
|
+
params = act.get("WFWorkflowActionParameters") or {}
|
|
2416
|
+
mode = _coerce_control_flow_mode(params)
|
|
2417
|
+
if mode is None:
|
|
2418
|
+
mode = 0
|
|
2419
|
+
grouping = params.get("GroupingIdentifier")
|
|
2420
|
+
if not grouping:
|
|
2421
|
+
continue
|
|
2422
|
+
if mode == 0:
|
|
2423
|
+
items = params.get("WFMenuItems")
|
|
2424
|
+
if isinstance(items, list):
|
|
2425
|
+
# Menu items can be plain strings or rich dicts with WFValue
|
|
2426
|
+
extracted = []
|
|
2427
|
+
for i in items:
|
|
2428
|
+
if isinstance(i, str):
|
|
2429
|
+
extracted.append(i)
|
|
2430
|
+
elif isinstance(i, dict):
|
|
2431
|
+
extracted.append(str(i)) # count it even if rich format
|
|
2432
|
+
menu_items_by_grouping[grouping] = extracted
|
|
2433
|
+
elif mode == 1:
|
|
2434
|
+
title = params.get("WFMenuItemTitle", "")
|
|
2435
|
+
menu_cases_by_grouping.setdefault(grouping, []).append((idx, title))
|
|
2436
|
+
|
|
2437
|
+
for grouping, items in menu_items_by_grouping.items():
|
|
2438
|
+
cases = menu_cases_by_grouping.get(grouping, [])
|
|
2439
|
+
if len(cases) != len(items):
|
|
2440
|
+
errors.append(
|
|
2441
|
+
f"Choose from Menu: {len(items)} item(s) in WFMenuItems but {len(cases)} case action(s) found"
|
|
2442
|
+
)
|
|
2443
|
+
else:
|
|
2444
|
+
# Only validate title matching when items are plain strings
|
|
2445
|
+
raw_items = menu_items_by_grouping.get(grouping, [])
|
|
2446
|
+
original_list = None
|
|
2447
|
+
for idx2, act2 in enumerate(actions):
|
|
2448
|
+
if act2.get("WFWorkflowActionIdentifier") == "is.workflow.actions.choosefrommenu":
|
|
2449
|
+
p2 = act2.get("WFWorkflowActionParameters") or {}
|
|
2450
|
+
if p2.get("GroupingIdentifier") == grouping:
|
|
2451
|
+
m2 = p2.get("WFControlFlowMode")
|
|
2452
|
+
if isinstance(m2, str) and m2.isdigit():
|
|
2453
|
+
m2 = int(m2)
|
|
2454
|
+
if m2 is None or m2 == 0:
|
|
2455
|
+
original_list = p2.get("WFMenuItems", [])
|
|
2456
|
+
break
|
|
2457
|
+
all_strings = original_list and all(isinstance(i, str) for i in original_list)
|
|
2458
|
+
if all_strings:
|
|
2459
|
+
for order_idx, ((case_act_idx, case_title), expected) in enumerate(zip(cases, items)):
|
|
2460
|
+
if case_title != expected:
|
|
2461
|
+
errors.append(
|
|
2462
|
+
f"Choose from Menu case {order_idx} at index {case_act_idx}: "
|
|
2463
|
+
f"WFMenuItemTitle '{case_title}' does not match WFMenuItems[{order_idx}] '{expected}'"
|
|
2464
|
+
)
|
|
2465
|
+
|
|
2466
|
+
input_classes = plist.get("WFWorkflowInputContentItemClasses") or []
|
|
2467
|
+
uses_javascript_webpage = any(
|
|
2468
|
+
act.get("WFWorkflowActionIdentifier") == "is.workflow.actions.runjavascriptonwebpage"
|
|
2469
|
+
for act in actions
|
|
2470
|
+
)
|
|
2471
|
+
uses_input = False
|
|
2472
|
+
for act in actions:
|
|
2473
|
+
ident = act.get("WFWorkflowActionIdentifier")
|
|
2474
|
+
params = act.get("WFWorkflowActionParameters") or {}
|
|
2475
|
+
if ident == "is.workflow.actions.input":
|
|
2476
|
+
uses_input = True
|
|
2477
|
+
break
|
|
2478
|
+
for name in iter_variable_names(params):
|
|
2479
|
+
if name == "Shortcut Input":
|
|
2480
|
+
uses_input = True
|
|
2481
|
+
break
|
|
2482
|
+
if uses_input:
|
|
2483
|
+
break
|
|
2484
|
+
for _, out_name in iter_action_output_refs_with_name(params):
|
|
2485
|
+
if out_name == "Shortcut Input":
|
|
2486
|
+
uses_input = True
|
|
2487
|
+
break
|
|
2488
|
+
if uses_input:
|
|
2489
|
+
break
|
|
2490
|
+
if any(iter_extension_input_refs(params)):
|
|
2491
|
+
uses_input = True
|
|
2492
|
+
break
|
|
2493
|
+
if not input_classes and uses_input:
|
|
2494
|
+
errors.append(
|
|
2495
|
+
"Shortcut uses Shortcut Input but WFWorkflowInputContentItemClasses is empty (causes Stop and Respond)."
|
|
2496
|
+
)
|
|
2497
|
+
if input_classes and not uses_input and not uses_javascript_webpage:
|
|
2498
|
+
errors.append(
|
|
2499
|
+
"WFWorkflowInputContentItemClasses is set but Shortcut Input is unused; remove input types or use Shortcut Input."
|
|
2500
|
+
)
|
|
2501
|
+
|
|
2502
|
+
workflow_types = plist.get("WFWorkflowTypes") or []
|
|
2503
|
+
if uses_javascript_webpage:
|
|
2504
|
+
if "ActionExtension" not in workflow_types:
|
|
2505
|
+
errors.append(
|
|
2506
|
+
"Run JavaScript on Webpage requires ActionExtension in WFWorkflowTypes so it can run from Safari share sheet."
|
|
2507
|
+
)
|
|
2508
|
+
if "WFSafariWebPageContentItem" not in input_classes:
|
|
2509
|
+
errors.append(
|
|
2510
|
+
"Run JavaScript on Webpage requires WFSafariWebPageContentItem in WFWorkflowInputContentItemClasses."
|
|
2511
|
+
)
|
|
2512
|
+
extra_classes = [cls for cls in input_classes if cls != "WFSafariWebPageContentItem"]
|
|
2513
|
+
if extra_classes:
|
|
2514
|
+
errors.append(
|
|
2515
|
+
"Run JavaScript on Webpage shortcuts should scope share-sheet input to Safari webpages only; "
|
|
2516
|
+
f"remove extra input classes: {', '.join(extra_classes)}."
|
|
2517
|
+
)
|
|
2518
|
+
|
|
2519
|
+
icon_dict = plist.get("WFWorkflowIcon")
|
|
2520
|
+
if not isinstance(icon_dict, dict):
|
|
2521
|
+
errors.append("Missing WFWorkflowIcon root dictionary")
|
|
2522
|
+
else:
|
|
2523
|
+
glyph_number = icon_dict.get("WFWorkflowIconGlyphNumber")
|
|
2524
|
+
start_color = icon_dict.get("WFWorkflowIconStartColor")
|
|
2525
|
+
|
|
2526
|
+
if glyph_number is None:
|
|
2527
|
+
errors.append("WFWorkflowIcon missing WFWorkflowIconGlyphNumber")
|
|
2528
|
+
else:
|
|
2529
|
+
try:
|
|
2530
|
+
glyph_int = int(glyph_number)
|
|
2531
|
+
except (TypeError, ValueError):
|
|
2532
|
+
errors.append("WFWorkflowIconGlyphNumber must be an integer")
|
|
2533
|
+
else:
|
|
2534
|
+
if allowed_glyph_ids and glyph_int not in allowed_glyph_ids:
|
|
2535
|
+
errors.append(
|
|
2536
|
+
f"WFWorkflowIconGlyphNumber {glyph_int} is not in the official 507 glyph mapping"
|
|
2537
|
+
)
|
|
2538
|
+
|
|
2539
|
+
if start_color is None:
|
|
2540
|
+
errors.append("WFWorkflowIcon missing WFWorkflowIconStartColor")
|
|
2541
|
+
else:
|
|
2542
|
+
try:
|
|
2543
|
+
color_int = int(start_color)
|
|
2544
|
+
except (TypeError, ValueError):
|
|
2545
|
+
errors.append("WFWorkflowIconStartColor must be an integer")
|
|
2546
|
+
else:
|
|
2547
|
+
if allowed_icon_colors and color_int not in allowed_icon_colors:
|
|
2548
|
+
errors.append(
|
|
2549
|
+
f"WFWorkflowIconStartColor {color_int} is not in the known Shortcuts color palette"
|
|
2550
|
+
)
|
|
2551
|
+
|
|
2552
|
+
if actions:
|
|
2553
|
+
if len(actions) < 2:
|
|
2554
|
+
errors.append("Missing required two leading Comment actions")
|
|
2555
|
+
else:
|
|
2556
|
+
first = actions[0]
|
|
2557
|
+
second = actions[1]
|
|
2558
|
+
if first.get("WFWorkflowActionIdentifier") != "is.workflow.actions.comment":
|
|
2559
|
+
errors.append("First action must be a Comment describing the shortcut")
|
|
2560
|
+
else:
|
|
2561
|
+
first_text = (first.get("WFWorkflowActionParameters") or {}).get("WFCommentActionText")
|
|
2562
|
+
if not first_text:
|
|
2563
|
+
errors.append("First Comment is empty")
|
|
2564
|
+
if second.get("WFWorkflowActionIdentifier") != "is.workflow.actions.comment":
|
|
2565
|
+
errors.append("Second action must be the prompt Comment block")
|
|
2566
|
+
else:
|
|
2567
|
+
second_text = (second.get("WFWorkflowActionParameters") or {}).get("WFCommentActionText", "")
|
|
2568
|
+
if "Shortcuts generated by Shortcuts Playground." not in second_text:
|
|
2569
|
+
errors.append("Second Comment missing required Shortcuts Playground prompt text")
|
|
2570
|
+
|
|
2571
|
+
# Comment density requirements
|
|
2572
|
+
comment_count = len(comments)
|
|
2573
|
+
min_comments = 2
|
|
2574
|
+
if len(actions) >= 24:
|
|
2575
|
+
min_comments = 5
|
|
2576
|
+
elif len(actions) >= 16:
|
|
2577
|
+
min_comments = 4
|
|
2578
|
+
elif len(actions) >= 8:
|
|
2579
|
+
min_comments = 3
|
|
2580
|
+
if comment_count < min_comments:
|
|
2581
|
+
errors.append(
|
|
2582
|
+
f"Insufficient Comment blocks: {comment_count} found, require {min_comments} for {len(actions)} actions"
|
|
2583
|
+
)
|
|
2584
|
+
|
|
2585
|
+
if unit_text_found and not has_measurement_convert and not allow_manual_unit_conversion:
|
|
2586
|
+
errors.append(
|
|
2587
|
+
"Unit conversion detected but no measurement.convert action; use measurement.create + measurement.convert or add ALLOW_MANUAL_UNIT_CONVERSION comment."
|
|
2588
|
+
)
|
|
2589
|
+
|
|
2590
|
+
if errors and actions and first_error is None:
|
|
2591
|
+
first_action = actions[0]
|
|
2592
|
+
first_params = first_action.get("WFWorkflowActionParameters") or {}
|
|
2593
|
+
first_error = (0, first_action.get("WFWorkflowActionIdentifier") or "UNKNOWN", _snippet(first_params))
|
|
2594
|
+
|
|
2595
|
+
for idx, act in enumerate(actions):
|
|
2596
|
+
ident = act.get("WFWorkflowActionIdentifier")
|
|
2597
|
+
params = act.get("WFWorkflowActionParameters") or {}
|
|
2598
|
+
uuid = params.get("UUID")
|
|
2599
|
+
|
|
2600
|
+
# WFControlFlowMode must be <integer>, not <string>
|
|
2601
|
+
if ident in CONTROL_FLOW_TOOLKIT_EXCEPTIONS:
|
|
2602
|
+
raw_mode = params.get("WFControlFlowMode")
|
|
2603
|
+
if isinstance(raw_mode, str):
|
|
2604
|
+
errors.append(
|
|
2605
|
+
f"WFControlFlowMode must be <integer> not <string> at index {idx}: "
|
|
2606
|
+
f"{ident} (found: {raw_mode!r})"
|
|
2607
|
+
)
|
|
2608
|
+
|
|
2609
|
+
# Aggrandizement type validation
|
|
2610
|
+
for agg in iter_aggrandizements(params):
|
|
2611
|
+
agg_type = agg.get("Type")
|
|
2612
|
+
if agg_type is None:
|
|
2613
|
+
errors.append(f"Aggrandizement missing Type field at index {idx}: {ident}")
|
|
2614
|
+
elif agg_type not in KNOWN_AGGRANDIZEMENT_TYPES:
|
|
2615
|
+
errors.append(
|
|
2616
|
+
f"Unknown aggrandizement Type '{agg_type}' at index {idx}: {ident}"
|
|
2617
|
+
)
|
|
2618
|
+
|
|
2619
|
+
# Enforce descriptive Comment immediately before control-flow starts
|
|
2620
|
+
if ident in {
|
|
2621
|
+
"is.workflow.actions.repeat.each",
|
|
2622
|
+
"is.workflow.actions.repeat.count",
|
|
2623
|
+
"is.workflow.actions.conditional",
|
|
2624
|
+
"is.workflow.actions.choosefrommenu",
|
|
2625
|
+
}:
|
|
2626
|
+
mode = _coerce_control_flow_mode(params)
|
|
2627
|
+
if mode == 0:
|
|
2628
|
+
if idx == 0 or actions[idx - 1].get("WFWorkflowActionIdentifier") != "is.workflow.actions.comment":
|
|
2629
|
+
errors.append(
|
|
2630
|
+
f"Missing descriptive Comment immediately before control-flow start at index {idx}"
|
|
2631
|
+
)
|
|
2632
|
+
else:
|
|
2633
|
+
prev_params = actions[idx - 1].get("WFWorkflowActionParameters") or {}
|
|
2634
|
+
prev_text = prev_params.get("WFCommentActionText", "")
|
|
2635
|
+
if not _comment_has_bullets(prev_text):
|
|
2636
|
+
errors.append(
|
|
2637
|
+
f"Control-flow Comment must include a bulleted wiring list at index {idx - 1}"
|
|
2638
|
+
)
|
|
2639
|
+
|
|
2640
|
+
if ident == "is.workflow.actions.comment":
|
|
2641
|
+
text = params.get("WFCommentActionText")
|
|
2642
|
+
if isinstance(text, str) and "cents" in text.lower():
|
|
2643
|
+
cents_context_steps = 10
|
|
2644
|
+
|
|
2645
|
+
# Track repeat nesting (repeat.each and repeat.count)
|
|
2646
|
+
if ident in {"is.workflow.actions.repeat.each", "is.workflow.actions.repeat.count"}:
|
|
2647
|
+
mode = _coerce_control_flow_mode(params)
|
|
2648
|
+
if mode is None:
|
|
2649
|
+
mode = 0
|
|
2650
|
+
grouping = params.get("GroupingIdentifier")
|
|
2651
|
+
if mode == 0:
|
|
2652
|
+
repeat_stack.append({"group": grouping, "uuid": uuid, "kind": ident})
|
|
2653
|
+
elif mode == 2 and repeat_stack:
|
|
2654
|
+
if grouping:
|
|
2655
|
+
repeat_end_uuids[grouping] = uuid
|
|
2656
|
+
repeat_stack.pop()
|
|
2657
|
+
|
|
2658
|
+
# Validate repeat variables inside nested loops
|
|
2659
|
+
if repeat_stack:
|
|
2660
|
+
innermost = repeat_stack[-1]
|
|
2661
|
+
innermost_uuid = innermost.get("uuid")
|
|
2662
|
+
loop_label = "Repeat with Each" if innermost.get("kind") == "is.workflow.actions.repeat.each" else "Repeat (count)"
|
|
2663
|
+
repeat_uuids = {item.get("uuid") for item in repeat_stack if item.get("uuid")}
|
|
2664
|
+
active_groups = {item.get("group") for item in repeat_stack if item.get("group")}
|
|
2665
|
+
active_end_uuids = {repeat_end_uuids.get(g) for g in active_groups if repeat_end_uuids.get(g)}
|
|
2666
|
+
for out_uuid, out_name in iter_action_output_refs_with_name(params):
|
|
2667
|
+
if out_name and out_name.startswith("Repeat Results"):
|
|
2668
|
+
errors.append(
|
|
2669
|
+
f"Repeat Results referenced inside {loop_label} at index {idx}"
|
|
2670
|
+
)
|
|
2671
|
+
if out_uuid in repeat_uuids:
|
|
2672
|
+
if out_name == "Repeat Results":
|
|
2673
|
+
errors.append(
|
|
2674
|
+
f"Repeat Results referenced inside {loop_label} at index {idx}"
|
|
2675
|
+
)
|
|
2676
|
+
elif out_name in {"Repeat Item", "Repeat Index"} and out_uuid != innermost_uuid:
|
|
2677
|
+
errors.append(
|
|
2678
|
+
f"Nested repeat uses outer {out_name} at index {idx}; use innermost Repeat Item/Index"
|
|
2679
|
+
)
|
|
2680
|
+
# Even for innermost, prefer Variable references (not ActionOutput) to avoid UI showing Repeat Results
|
|
2681
|
+
if out_name in {"Repeat Item", "Repeat Index"}:
|
|
2682
|
+
errors.append(
|
|
2683
|
+
f"Repeat Item/Index should be referenced as a Variable (not ActionOutput) inside {loop_label} at index {idx}"
|
|
2684
|
+
)
|
|
2685
|
+
if out_uuid in active_end_uuids:
|
|
2686
|
+
if out_name in {"Repeat Item", "Repeat Index"}:
|
|
2687
|
+
errors.append(
|
|
2688
|
+
f"{out_name} should be referenced as a Variable (not ActionOutput) inside {loop_label} at index {idx}"
|
|
2689
|
+
)
|
|
2690
|
+
else:
|
|
2691
|
+
errors.append(
|
|
2692
|
+
f"Repeat Results (end output) used inside {loop_label} at index {idx}"
|
|
2693
|
+
)
|
|
2694
|
+
for name in iter_variable_names(params):
|
|
2695
|
+
if name.startswith("Repeat Results"):
|
|
2696
|
+
errors.append(
|
|
2697
|
+
f"Repeat Results variable used inside {loop_label} at index {idx}"
|
|
2698
|
+
)
|
|
2699
|
+
|
|
2700
|
+
# Track variable definitions
|
|
2701
|
+
if ident == "is.workflow.actions.setvariable":
|
|
2702
|
+
name = params.get("WFVariableName")
|
|
2703
|
+
if isinstance(name, str) and name.strip():
|
|
2704
|
+
defined_vars.add(name)
|
|
2705
|
+
setvariable_counts[name] = setvariable_counts.get(name, 0) + 1
|
|
2706
|
+
if "cents" in name.lower():
|
|
2707
|
+
cents_vars.add(name)
|
|
2708
|
+
wfinput = params.get("WFInput")
|
|
2709
|
+
out_uuids = _input_action_output_uuids(wfinput)
|
|
2710
|
+
source_var_name = _extract_input_variable_name(wfinput)
|
|
2711
|
+
list_source = False
|
|
2712
|
+
weather_source = bool(source_var_name and source_var_name in weather_source_vars)
|
|
2713
|
+
location_source = bool(source_var_name and source_var_name in location_source_vars)
|
|
2714
|
+
health_sample_source = bool(
|
|
2715
|
+
source_var_name and source_var_name in health_sample_source_vars
|
|
2716
|
+
)
|
|
2717
|
+
for source_uuid in out_uuids:
|
|
2718
|
+
source_ident = uuid_to_ident.get(source_uuid)
|
|
2719
|
+
if source_ident in LIST_PRODUCING_ACTIONS:
|
|
2720
|
+
list_source = True
|
|
2721
|
+
if source_ident == "is.workflow.actions.format.date":
|
|
2722
|
+
var_format_dates[name] = uuid_to_params.get(source_uuid, {})
|
|
2723
|
+
if source_ident in WEATHER_SOURCE_ACTIONS:
|
|
2724
|
+
weather_source = True
|
|
2725
|
+
if source_ident in LOCATION_SOURCE_ACTIONS:
|
|
2726
|
+
location_source = True
|
|
2727
|
+
if source_ident in HEALTH_SAMPLE_SOURCE_ACTIONS:
|
|
2728
|
+
health_sample_source = True
|
|
2729
|
+
if weather_source:
|
|
2730
|
+
weather_source_vars.add(name)
|
|
2731
|
+
if location_source:
|
|
2732
|
+
location_source_vars.add(name)
|
|
2733
|
+
if health_sample_source:
|
|
2734
|
+
health_sample_source_vars.add(name)
|
|
2735
|
+
if list_source:
|
|
2736
|
+
list_variable_set_counts[name] = list_variable_set_counts.get(name, 0) + 1
|
|
2737
|
+
if (
|
|
2738
|
+
"hour" in name.lower()
|
|
2739
|
+
and any(
|
|
2740
|
+
source_uuid in sleep_duration_divide_by_60_uuids
|
|
2741
|
+
or source_uuid in sleep_duration_divide_by_60_round_uuids
|
|
2742
|
+
for source_uuid in out_uuids
|
|
2743
|
+
)
|
|
2744
|
+
):
|
|
2745
|
+
errors.append(
|
|
2746
|
+
f"Sleep duration math divides by 60 but stores hours in '{name}' at index {idx}; "
|
|
2747
|
+
"Health Duration math is seconds, so divide by 3600 for decimal hours or label the result as minutes"
|
|
2748
|
+
)
|
|
2749
|
+
|
|
2750
|
+
if ident == "is.workflow.actions.getvariable":
|
|
2751
|
+
wfvar = params.get("WFVariable")
|
|
2752
|
+
if isinstance(wfvar, dict):
|
|
2753
|
+
val = wfvar.get("Value")
|
|
2754
|
+
if isinstance(val, dict):
|
|
2755
|
+
name = val.get("VariableName")
|
|
2756
|
+
if isinstance(name, str) and name.strip() and uuid:
|
|
2757
|
+
uuid_to_var_name[uuid] = name
|
|
2758
|
+
|
|
2759
|
+
# Unknown actions
|
|
2760
|
+
if ident:
|
|
2761
|
+
if ident.startswith("is.workflow.actions."):
|
|
2762
|
+
if ident not in allowed_ids:
|
|
2763
|
+
unavailable_reason = unavailable_ids.get(ident)
|
|
2764
|
+
if unavailable_reason:
|
|
2765
|
+
errors.append(
|
|
2766
|
+
f"Action identifier requires {unavailable_reason} at index {idx}: {ident}. "
|
|
2767
|
+
"Set --target-macos 27 only for Golden Gate-only IDs, or "
|
|
2768
|
+
"--target-platform ios/all only when intentionally targeting iOS/iPadOS metadata."
|
|
2769
|
+
)
|
|
2770
|
+
else:
|
|
2771
|
+
hint = ACTION_ALIAS_HINTS.get(ident)
|
|
2772
|
+
if hint:
|
|
2773
|
+
errors.append(
|
|
2774
|
+
f"Unknown action identifier at index {idx}: {ident} (use {hint})"
|
|
2775
|
+
)
|
|
2776
|
+
else:
|
|
2777
|
+
errors.append(f"Unknown action identifier at index {idx}: {ident}")
|
|
2778
|
+
elif ident.startswith("com.apple."):
|
|
2779
|
+
if ident not in allowed_ids:
|
|
2780
|
+
unavailable_reason = unavailable_ids.get(ident)
|
|
2781
|
+
if unavailable_reason:
|
|
2782
|
+
errors.append(
|
|
2783
|
+
f"AppIntent identifier requires {unavailable_reason} at index {idx}: {ident}. "
|
|
2784
|
+
"Set --target-macos 27 only for Golden Gate-only IDs, or "
|
|
2785
|
+
"--target-platform ios/all only when intentionally targeting iOS/iPadOS metadata."
|
|
2786
|
+
)
|
|
2787
|
+
else:
|
|
2788
|
+
errors.append(f"Unknown AppIntent identifier at index {idx}: {ident}")
|
|
2789
|
+
else:
|
|
2790
|
+
# Accept unknown vendor-prefixed third-party identifiers so shared
|
|
2791
|
+
# skills remain usable without local ToolKit extraction.
|
|
2792
|
+
if ident not in allowed_ids and not THIRD_PARTY_IDENTIFIER_RE.match(ident):
|
|
2793
|
+
errors.append(f"Unknown third-party identifier at index {idx}: {ident}")
|
|
2794
|
+
|
|
2795
|
+
if ident in NOTES_CREATE_ACTIONS:
|
|
2796
|
+
title_val = None
|
|
2797
|
+
content_val = None
|
|
2798
|
+
for key, value in (params or {}).items():
|
|
2799
|
+
if key.lower() in NOTES_TITLE_KEYS and title_val is None:
|
|
2800
|
+
title_val = value
|
|
2801
|
+
if key.lower() in NOTES_CONTENT_KEYS and content_val is None:
|
|
2802
|
+
content_val = value
|
|
2803
|
+
if title_val is None:
|
|
2804
|
+
errors.append(f"Notes create action missing title/name parameter at index {idx}")
|
|
2805
|
+
elif _token_param_is_empty(title_val):
|
|
2806
|
+
errors.append(f"Notes create action title/name is empty at index {idx}")
|
|
2807
|
+
if content_val is None:
|
|
2808
|
+
errors.append(f"Notes create action missing content/markdown parameter at index {idx}")
|
|
2809
|
+
elif _token_param_is_empty(content_val):
|
|
2810
|
+
errors.append(f"Notes create action content/markdown is empty at index {idx}")
|
|
2811
|
+
|
|
2812
|
+
# Empty strings in parameters
|
|
2813
|
+
for path in iter_empty_strings(params):
|
|
2814
|
+
if (
|
|
2815
|
+
ident == "is.workflow.actions.gettext"
|
|
2816
|
+
and tuple(path) == ("WFTextActionText",)
|
|
2817
|
+
and params.get("UUID") in send_message_empty_text_spacer_uuids
|
|
2818
|
+
):
|
|
2819
|
+
continue
|
|
2820
|
+
errors.append(f"Empty parameter at index {idx}: {ident} -> {'/'.join(path)}")
|
|
2821
|
+
|
|
2822
|
+
# Validate WFTextTokenString placeholders (Shortcuts uses UTF-16 indices)
|
|
2823
|
+
for token in iter_text_token_strings(params):
|
|
2824
|
+
value = token.get("Value") if isinstance(token, dict) else None
|
|
2825
|
+
if not isinstance(value, dict):
|
|
2826
|
+
errors.append(f"WFTextTokenString missing Value dict at index {idx}")
|
|
2827
|
+
continue
|
|
2828
|
+
string = value.get("string")
|
|
2829
|
+
attachments = value.get("attachmentsByRange")
|
|
2830
|
+
if not isinstance(string, str):
|
|
2831
|
+
errors.append(f"WFTextTokenString missing string at index {idx}")
|
|
2832
|
+
continue
|
|
2833
|
+
if attachments is None:
|
|
2834
|
+
if "" in string:
|
|
2835
|
+
errors.append(f"WFTextTokenString missing attachmentsByRange at index {idx}")
|
|
2836
|
+
continue
|
|
2837
|
+
if not isinstance(attachments, dict):
|
|
2838
|
+
errors.append(f"WFTextTokenString attachmentsByRange not a dict at index {idx}")
|
|
2839
|
+
continue
|
|
2840
|
+
units = _utf16_units(string)
|
|
2841
|
+
total_len = 0
|
|
2842
|
+
for key in attachments.keys():
|
|
2843
|
+
m = re.match(r"^\{(\d+),\s*(\d+)\}$", str(key))
|
|
2844
|
+
if not m:
|
|
2845
|
+
errors.append(f"Invalid attachmentsByRange key at index {idx}: {key}")
|
|
2846
|
+
continue
|
|
2847
|
+
start = int(m.group(1))
|
|
2848
|
+
length = int(m.group(2))
|
|
2849
|
+
total_len += length
|
|
2850
|
+
if start < 0 or start + length > len(units):
|
|
2851
|
+
errors.append(
|
|
2852
|
+
f"attachmentsByRange out of bounds at index {idx}: got {key}; "
|
|
2853
|
+
f"UTF-16 length is {len(units)}; {_placeholder_position_hint(string)}"
|
|
2854
|
+
)
|
|
2855
|
+
continue
|
|
2856
|
+
if any(units[start + j] != 0xFFFC for j in range(length)):
|
|
2857
|
+
errors.append(
|
|
2858
|
+
f"attachmentsByRange does not match placeholder at index {idx}: got {key}; "
|
|
2859
|
+
f"{_placeholder_position_hint(string)}"
|
|
2860
|
+
)
|
|
2861
|
+
if string.count("") != total_len:
|
|
2862
|
+
errors.append(
|
|
2863
|
+
f"Placeholder count mismatch at index {idx}: string has {string.count('')} "
|
|
2864
|
+
f"placeholder(s), attachments cover {total_len}; {_placeholder_position_hint(string)}"
|
|
2865
|
+
)
|
|
2866
|
+
|
|
2867
|
+
# vCard/VCF usage should be explicit
|
|
2868
|
+
if ident != "is.workflow.actions.comment" and not allow_vcard:
|
|
2869
|
+
for _, value in iter_strings(params):
|
|
2870
|
+
upper = value.upper()
|
|
2871
|
+
if "BEGIN:VCARD" in upper or "VCARD" in upper:
|
|
2872
|
+
errors.append(f"vCard/VCF content detected at index {idx}: {ident}")
|
|
2873
|
+
break
|
|
2874
|
+
|
|
2875
|
+
# Token file usage should be explicit
|
|
2876
|
+
if not allow_token_file and ident:
|
|
2877
|
+
if ident in FILE_ACTION_IDS or any(ident.startswith(p) for p in FILE_ACTION_PREFIXES):
|
|
2878
|
+
for path, value in iter_strings(params):
|
|
2879
|
+
if "WFSerializationType" in path:
|
|
2880
|
+
continue
|
|
2881
|
+
if value in {
|
|
2882
|
+
"WFTextTokenString",
|
|
2883
|
+
"WFTextTokenAttachment",
|
|
2884
|
+
"WFTokenAttachmentParameterState",
|
|
2885
|
+
}:
|
|
2886
|
+
continue
|
|
2887
|
+
if TOKEN_HINT_RE.search(value or ""):
|
|
2888
|
+
errors.append(f"API token loaded from file at index {idx}: {ident}")
|
|
2889
|
+
break
|
|
2890
|
+
|
|
2891
|
+
# TODOIST update URL should include task ID placeholder
|
|
2892
|
+
if ident != "is.workflow.actions.comment":
|
|
2893
|
+
for _, value in iter_strings(params):
|
|
2894
|
+
if TODOIST_TASKS_PREFIX in value:
|
|
2895
|
+
suffix = value.split(TODOIST_TASKS_PREFIX, 1)[1]
|
|
2896
|
+
if "" not in suffix:
|
|
2897
|
+
errors.append(f"Todoist task update URL missing task ID placeholder at index {idx}")
|
|
2898
|
+
break
|
|
2899
|
+
|
|
2900
|
+
# Conditional tests must be fully specified for If starts and macOS 27's
|
|
2901
|
+
# Otherwise If middles. Plain Otherwise is also mode 1, but has no
|
|
2902
|
+
# WFCondition/WFInput fields.
|
|
2903
|
+
if ident == "is.workflow.actions.conditional":
|
|
2904
|
+
mode = _coerce_control_flow_mode(params)
|
|
2905
|
+
if mode is None:
|
|
2906
|
+
mode = 0
|
|
2907
|
+
if mode == 2:
|
|
2908
|
+
continue
|
|
2909
|
+
condition_keys = {
|
|
2910
|
+
"WFCondition",
|
|
2911
|
+
"WFInput",
|
|
2912
|
+
"WFConditions",
|
|
2913
|
+
"WFConditionalActionString",
|
|
2914
|
+
"WFNumberValue",
|
|
2915
|
+
"WFAnotherNumber",
|
|
2916
|
+
}
|
|
2917
|
+
has_condition_fields = any(key in params for key in condition_keys)
|
|
2918
|
+
if mode == 1 and not has_condition_fields:
|
|
2919
|
+
continue
|
|
2920
|
+
if mode not in (0, 1):
|
|
2921
|
+
continue
|
|
2922
|
+
|
|
2923
|
+
cond = params.get("WFCondition")
|
|
2924
|
+
inp = params.get("WFInput")
|
|
2925
|
+
wfconds = params.get("WFConditions")
|
|
2926
|
+
if mode == 1 and cond is None and wfconds is None:
|
|
2927
|
+
# Apple may preserve WFInput on imported plain Otherwise blocks,
|
|
2928
|
+
# but they intentionally have no condition or comparison literal.
|
|
2929
|
+
literal_keys = {
|
|
2930
|
+
"WFConditionalActionString",
|
|
2931
|
+
"WFNumberValue",
|
|
2932
|
+
"WFAnotherNumber",
|
|
2933
|
+
}
|
|
2934
|
+
if not any(key in params for key in literal_keys):
|
|
2935
|
+
continue
|
|
2936
|
+
|
|
2937
|
+
# Multi-condition pattern: WFConditions holds an array of templates,
|
|
2938
|
+
# each template is its own (WFCondition + WFInput + literal). When
|
|
2939
|
+
# this is set, the action does NOT have top-level WFCondition/WFInput.
|
|
2940
|
+
if wfconds is not None:
|
|
2941
|
+
if not isinstance(wfconds, dict):
|
|
2942
|
+
errors.append(f"Conditional WFConditions must be a dict at index {idx}")
|
|
2943
|
+
else:
|
|
2944
|
+
serialization = wfconds.get("WFSerializationType")
|
|
2945
|
+
if serialization != "WFContentPredicateTableTemplate":
|
|
2946
|
+
errors.append(
|
|
2947
|
+
f"Conditional WFConditions WFSerializationType must be "
|
|
2948
|
+
f"WFContentPredicateTableTemplate at index {idx}"
|
|
2949
|
+
)
|
|
2950
|
+
value = wfconds.get("Value")
|
|
2951
|
+
templates = None
|
|
2952
|
+
prefix = None
|
|
2953
|
+
if isinstance(value, dict):
|
|
2954
|
+
templates = value.get("WFActionParameterFilterTemplates")
|
|
2955
|
+
prefix = value.get("WFActionParameterFilterPrefix")
|
|
2956
|
+
if not templates:
|
|
2957
|
+
errors.append(
|
|
2958
|
+
f"Conditional WFConditions has empty filter templates at index {idx}"
|
|
2959
|
+
)
|
|
2960
|
+
elif not isinstance(templates, list):
|
|
2961
|
+
errors.append(
|
|
2962
|
+
f"Conditional WFConditions templates must be a list at index {idx}"
|
|
2963
|
+
)
|
|
2964
|
+
else:
|
|
2965
|
+
if prefix not in (0, 1):
|
|
2966
|
+
errors.append(
|
|
2967
|
+
f"Conditional WFConditions WFActionParameterFilterPrefix must be "
|
|
2968
|
+
f"0 (Any are true) or 1 (All are true) at index {idx}"
|
|
2969
|
+
)
|
|
2970
|
+
for tidx, template in enumerate(templates):
|
|
2971
|
+
if not isinstance(template, dict):
|
|
2972
|
+
errors.append(
|
|
2973
|
+
f"Conditional template {tidx} is not a dict at index {idx}"
|
|
2974
|
+
)
|
|
2975
|
+
continue
|
|
2976
|
+
tcond = template.get("WFCondition")
|
|
2977
|
+
tinp = template.get("WFInput")
|
|
2978
|
+
if tcond is None or tinp is None:
|
|
2979
|
+
errors.append(
|
|
2980
|
+
f"Conditional template {tidx} missing WFCondition or WFInput at index {idx}"
|
|
2981
|
+
)
|
|
2982
|
+
continue
|
|
2983
|
+
if not isinstance(tcond, int):
|
|
2984
|
+
errors.append(
|
|
2985
|
+
f"Conditional template {tidx} WFCondition must be integer at index {idx}"
|
|
2986
|
+
)
|
|
2987
|
+
continue
|
|
2988
|
+
if tcond not in ALL_CONDITION_CODES:
|
|
2989
|
+
errors.append(
|
|
2990
|
+
f"Conditional template {tidx} uses unknown WFCondition {tcond} at index {idx}"
|
|
2991
|
+
)
|
|
2992
|
+
continue
|
|
2993
|
+
if not _input_is_attached(tinp) or not _conditional_input_is_wrapped(tinp):
|
|
2994
|
+
errors.append(
|
|
2995
|
+
f"Conditional template {tidx} WFInput must be a Type=Variable "
|
|
2996
|
+
f"wrapper at index {idx}"
|
|
2997
|
+
)
|
|
2998
|
+
if tcond in STRING_CONDITION_CODES and not template.get("WFConditionalActionString"):
|
|
2999
|
+
errors.append(
|
|
3000
|
+
f"Conditional template {tidx} (string code {tcond}) missing "
|
|
3001
|
+
f"WFConditionalActionString at index {idx}"
|
|
3002
|
+
)
|
|
3003
|
+
if tcond == 99:
|
|
3004
|
+
reassigned_name = _reassigned_list_variable_name(
|
|
3005
|
+
tinp,
|
|
3006
|
+
setvariable_counts,
|
|
3007
|
+
list_variable_set_counts,
|
|
3008
|
+
)
|
|
3009
|
+
if reassigned_name:
|
|
3010
|
+
errors.append(
|
|
3011
|
+
f"Conditional template {tidx} checks list variable "
|
|
3012
|
+
f"'{reassigned_name}' after it was set multiple times at index {idx}; "
|
|
3013
|
+
"macOS 27 imports this list-contains pattern with a blank comparison value. "
|
|
3014
|
+
"Reference the final List/Add to List action output directly, or assign it "
|
|
3015
|
+
"once to a fresh final variable name before the If."
|
|
3016
|
+
)
|
|
3017
|
+
if tcond in NUMBER_CONDITION_CODES and template.get("WFNumberValue") is None:
|
|
3018
|
+
errors.append(
|
|
3019
|
+
f"Conditional template {tidx} (numeric code {tcond}) missing "
|
|
3020
|
+
f"WFNumberValue at index {idx}"
|
|
3021
|
+
)
|
|
3022
|
+
if tcond == 1003 and template.get("WFAnotherNumber") is None:
|
|
3023
|
+
errors.append(
|
|
3024
|
+
f"Conditional template {tidx} (between, code 1003) missing "
|
|
3025
|
+
f"WFAnotherNumber upper bound at index {idx}"
|
|
3026
|
+
)
|
|
3027
|
+
# Multi-condition is mutually exclusive with top-level WFCondition/WFInput.
|
|
3028
|
+
if cond is not None or inp is not None:
|
|
3029
|
+
errors.append(
|
|
3030
|
+
f"Conditional has both WFConditions multi-condition block and "
|
|
3031
|
+
f"top-level WFCondition/WFInput at index {idx}; remove the top-level fields"
|
|
3032
|
+
)
|
|
3033
|
+
continue
|
|
3034
|
+
|
|
3035
|
+
# Single-condition pattern: top-level WFCondition + WFInput + literal field.
|
|
3036
|
+
# Use `is None` (not truthiness) — code 0 (less than) is a valid integer.
|
|
3037
|
+
if cond is None or inp is None:
|
|
3038
|
+
errors.append(f"Conditional missing WFInput/WFCondition at index {idx}")
|
|
3039
|
+
continue
|
|
3040
|
+
if not isinstance(cond, int):
|
|
3041
|
+
errors.append(
|
|
3042
|
+
f"Conditional WFCondition should use integer code for runtime stability at index {idx}"
|
|
3043
|
+
)
|
|
3044
|
+
continue
|
|
3045
|
+
if cond not in ALL_CONDITION_CODES:
|
|
3046
|
+
errors.append(
|
|
3047
|
+
f"Conditional uses unknown WFCondition code {cond} at index {idx}; see CONTROL_FLOW.md"
|
|
3048
|
+
)
|
|
3049
|
+
continue
|
|
3050
|
+
if not _input_is_attached(inp):
|
|
3051
|
+
errors.append(f"Conditional WFInput is not a token attachment at index {idx}")
|
|
3052
|
+
continue
|
|
3053
|
+
is_wrapped = _conditional_input_is_wrapped(inp)
|
|
3054
|
+
if not is_wrapped:
|
|
3055
|
+
errors.append(
|
|
3056
|
+
f"Conditional WFInput must use Type=Variable wrapper for editor visibility at index {idx}"
|
|
3057
|
+
)
|
|
3058
|
+
# Per-code field requirements.
|
|
3059
|
+
if cond in STRING_CONDITION_CODES:
|
|
3060
|
+
if not params.get("WFConditionalActionString"):
|
|
3061
|
+
errors.append(
|
|
3062
|
+
f"Conditional (string code {cond}) missing WFConditionalActionString at index {idx}"
|
|
3063
|
+
)
|
|
3064
|
+
else:
|
|
3065
|
+
for out_uuid in _input_action_output_uuids(inp):
|
|
3066
|
+
ident_inp = uuid_to_ident.get(out_uuid)
|
|
3067
|
+
if ident_inp == "is.workflow.actions.getvalueforkey":
|
|
3068
|
+
errors.append(
|
|
3069
|
+
f"Conditional compares Dictionary Value directly at index {idx}; wrap in Text first"
|
|
3070
|
+
)
|
|
3071
|
+
if cond == 99:
|
|
3072
|
+
reassigned_name = _reassigned_list_variable_name(
|
|
3073
|
+
inp,
|
|
3074
|
+
setvariable_counts,
|
|
3075
|
+
list_variable_set_counts,
|
|
3076
|
+
)
|
|
3077
|
+
if reassigned_name:
|
|
3078
|
+
errors.append(
|
|
3079
|
+
f"Conditional checks list variable '{reassigned_name}' after it was set "
|
|
3080
|
+
f"{setvariable_counts.get(reassigned_name, 0)} times at index {idx}; "
|
|
3081
|
+
"macOS 27 imports this list-contains pattern with a blank comparison value. "
|
|
3082
|
+
"Reference the final List/Add to List action output directly, or assign it "
|
|
3083
|
+
"once to a fresh final variable name before the If."
|
|
3084
|
+
)
|
|
3085
|
+
elif cond in NUMBER_CONDITION_CODES:
|
|
3086
|
+
if params.get("WFNumberValue") is None:
|
|
3087
|
+
errors.append(
|
|
3088
|
+
f"Conditional (numeric code {cond}) missing WFNumberValue at index {idx}"
|
|
3089
|
+
)
|
|
3090
|
+
if cond == 1003 and params.get("WFAnotherNumber") is None:
|
|
3091
|
+
errors.append(
|
|
3092
|
+
f"Conditional (between, code 1003) missing WFAnotherNumber upper bound at index {idx}"
|
|
3093
|
+
)
|
|
3094
|
+
elif cond in EXISTENCE_CONDITION_CODES:
|
|
3095
|
+
# Existence checks (100/101) take no literal — only WFInput.
|
|
3096
|
+
if params.get("WFConditionalActionString") is not None:
|
|
3097
|
+
errors.append(
|
|
3098
|
+
f"Conditional (existence code {cond}) must not set WFConditionalActionString at index {idx}"
|
|
3099
|
+
)
|
|
3100
|
+
if params.get("WFNumberValue") is not None:
|
|
3101
|
+
errors.append(
|
|
3102
|
+
f"Conditional (existence code {cond}) must not set WFNumberValue at index {idx}"
|
|
3103
|
+
)
|
|
3104
|
+
|
|
3105
|
+
# Choose from Menu start requires items
|
|
3106
|
+
if ident == "is.workflow.actions.choosefrommenu":
|
|
3107
|
+
mode = _coerce_control_flow_mode(params)
|
|
3108
|
+
if mode is None:
|
|
3109
|
+
mode = 0
|
|
3110
|
+
if mode == 0:
|
|
3111
|
+
items = params.get("WFMenuItems")
|
|
3112
|
+
if not items:
|
|
3113
|
+
errors.append(f"Choose from Menu has no items at index {idx}")
|
|
3114
|
+
prompt = params.get("WFMenuPrompt")
|
|
3115
|
+
if isinstance(prompt, str) and prompt == "":
|
|
3116
|
+
errors.append(f"Choose from Menu has empty prompt at index {idx}")
|
|
3117
|
+
|
|
3118
|
+
# Ask for Input must have a prompt
|
|
3119
|
+
if ident == "is.workflow.actions.ask":
|
|
3120
|
+
if "WFAskActionPrompt" not in params or not params.get("WFAskActionPrompt"):
|
|
3121
|
+
errors.append(f"Ask for Input missing prompt at index {idx}")
|
|
3122
|
+
if params.get("WFAskActionDefaultAnswer") == "":
|
|
3123
|
+
errors.append(f"Ask for Input has empty default answer at index {idx}")
|
|
3124
|
+
|
|
3125
|
+
if ident == "is.workflow.actions.input":
|
|
3126
|
+
errors.append(
|
|
3127
|
+
f"Shortcut Input action is not runtime-safe on iOS at index {idx}; use ExtensionInput attachment instead"
|
|
3128
|
+
)
|
|
3129
|
+
|
|
3130
|
+
if ident == "is.workflow.actions.additemtolist":
|
|
3131
|
+
if _token_param_is_empty(params.get("WFListItem")):
|
|
3132
|
+
errors.append(f"Add Item to List missing WFListItem at index {idx}")
|
|
3133
|
+
if _token_param_is_empty(params.get("WFListVariable")):
|
|
3134
|
+
errors.append(f"Add Item to List missing WFListVariable at index {idx}")
|
|
3135
|
+
insert_position = params.get("WFInsertPosition")
|
|
3136
|
+
if not _token_param_is_empty(insert_position) and insert_position not in ADD_ITEM_TO_LIST_POSITIONS:
|
|
3137
|
+
errors.append(
|
|
3138
|
+
f"Add Item to List has unknown WFInsertPosition at index {idx}: {insert_position!r}"
|
|
3139
|
+
)
|
|
3140
|
+
if insert_position == "Index":
|
|
3141
|
+
item_index = params.get("WFItemIndex")
|
|
3142
|
+
if _token_param_is_empty(item_index):
|
|
3143
|
+
errors.append(f"Add Item to List position Index missing WFItemIndex at index {idx}")
|
|
3144
|
+
else:
|
|
3145
|
+
try:
|
|
3146
|
+
numeric_index = int(item_index)
|
|
3147
|
+
except (TypeError, ValueError):
|
|
3148
|
+
errors.append(
|
|
3149
|
+
f"Add Item to List WFItemIndex must be an integer at index {idx}: {item_index!r}"
|
|
3150
|
+
)
|
|
3151
|
+
else:
|
|
3152
|
+
if numeric_index < 1:
|
|
3153
|
+
errors.append(f"Add Item to List WFItemIndex must be 1 or greater at index {idx}")
|
|
3154
|
+
elif "WFItemIndex" in params and not _token_param_is_empty(params.get("WFItemIndex")):
|
|
3155
|
+
errors.append(
|
|
3156
|
+
f"Add Item to List WFItemIndex is only valid when WFInsertPosition is Index at index {idx}"
|
|
3157
|
+
)
|
|
3158
|
+
|
|
3159
|
+
if ident == "is.workflow.actions.choosefromlist":
|
|
3160
|
+
for key in CHOOSE_FROM_LIST_BOOLEAN_KEYS:
|
|
3161
|
+
value = params.get(key)
|
|
3162
|
+
if value is not None and not isinstance(value, bool):
|
|
3163
|
+
errors.append(f"{key} must be boolean at index {idx}: {ident}")
|
|
3164
|
+
if params.get("WFChooseFromListActionSelectAll") is True and params.get(
|
|
3165
|
+
"WFChooseFromListActionSelectMultiple"
|
|
3166
|
+
) is not True:
|
|
3167
|
+
errors.append(
|
|
3168
|
+
f"WFChooseFromListActionSelectAll requires WFChooseFromListActionSelectMultiple at index {idx}"
|
|
3169
|
+
)
|
|
3170
|
+
|
|
3171
|
+
if ident in SHORTCUTS_URL_ACTIONS:
|
|
3172
|
+
for _, value in iter_strings(params):
|
|
3173
|
+
if value.startswith("shortcuts://"):
|
|
3174
|
+
errors.extend(_validate_shortcuts_url_literal(value, idx))
|
|
3175
|
+
|
|
3176
|
+
if ident == "is.workflow.actions.runjavascriptonwebpage":
|
|
3177
|
+
script_values = [
|
|
3178
|
+
value
|
|
3179
|
+
for path, value in iter_strings(params)
|
|
3180
|
+
if path and path[-1] != "UUID" and value.strip()
|
|
3181
|
+
]
|
|
3182
|
+
combined_script = "\n".join(script_values)
|
|
3183
|
+
if "completion(" not in combined_script:
|
|
3184
|
+
errors.append(
|
|
3185
|
+
f"Run JavaScript on Webpage script must call completion(...) or completion() at index {idx}"
|
|
3186
|
+
)
|
|
3187
|
+
if re.search(r"\b(?:window\.)?(?:alert|prompt|confirm)\s*\(", combined_script):
|
|
3188
|
+
errors.append(
|
|
3189
|
+
f"Run JavaScript on Webpage script uses blocking dialog APIs at index {idx}; avoid alert/prompt/confirm"
|
|
3190
|
+
)
|
|
3191
|
+
if re.search(r"setTimeout\s*\([^,]+,\s*(?:[5-9]\d{3,}|\d{5,})", combined_script):
|
|
3192
|
+
errors.append(
|
|
3193
|
+
f"Run JavaScript on Webpage script uses a long timeout at index {idx}; keep execution short"
|
|
3194
|
+
)
|
|
3195
|
+
|
|
3196
|
+
# Text action should not be blank
|
|
3197
|
+
if ident == "is.workflow.actions.gettext":
|
|
3198
|
+
if "WFTextActionText" not in params:
|
|
3199
|
+
errors.append(f"Text action missing text at index {idx}")
|
|
3200
|
+
else:
|
|
3201
|
+
text = params.get("WFTextActionText")
|
|
3202
|
+
if text == "" and params.get("UUID") not in send_message_empty_text_spacer_uuids:
|
|
3203
|
+
errors.append(f"Text action has empty text at index {idx}")
|
|
3204
|
+
|
|
3205
|
+
if ident == "is.workflow.actions.number":
|
|
3206
|
+
number = params.get("WFNumberActionNumber")
|
|
3207
|
+
if number is None or number == "" or number == {}:
|
|
3208
|
+
errors.append(f"Number action missing WFNumberActionNumber at index {idx}")
|
|
3209
|
+
|
|
3210
|
+
# URL action should have URL
|
|
3211
|
+
if ident in REQUIRED_URL_ACTIONS:
|
|
3212
|
+
if not params.get("WFURLActionURL"):
|
|
3213
|
+
errors.append(f"URL action missing WFURLActionURL at index {idx}")
|
|
3214
|
+
|
|
3215
|
+
# Require explicit input for selected actions
|
|
3216
|
+
if ident in REQUIRED_INPUT_ACTIONS:
|
|
3217
|
+
wfinput = params.get("WFInput")
|
|
3218
|
+
if not wfinput:
|
|
3219
|
+
errors.append(f"Missing WFInput at index {idx}: {ident}")
|
|
3220
|
+
elif not _input_is_attached(wfinput):
|
|
3221
|
+
errors.append(f"WFInput is not a token attachment at index {idx}: {ident}")
|
|
3222
|
+
elif not _input_has_reference(wfinput):
|
|
3223
|
+
errors.append(f"WFInput has no variable/output reference at index {idx}: {ident}")
|
|
3224
|
+
else:
|
|
3225
|
+
for out_uuid in _input_action_output_uuids(wfinput):
|
|
3226
|
+
if out_uuid not in uuid_to_ident:
|
|
3227
|
+
errors.append(f"WFInput references unknown OutputUUID at index {idx}: {ident}")
|
|
3228
|
+
if ident in EDITOR_VISIBLE_INPUT_ACTIONS and not _input_is_editor_visible(wfinput):
|
|
3229
|
+
errors.append(
|
|
3230
|
+
f"WFInput should use WFTextTokenString (placeholder) or wrapped Variable for editor visibility at index {idx}: {ident}"
|
|
3231
|
+
)
|
|
3232
|
+
|
|
3233
|
+
if ident in STORED_CONTENT_ACTIONS:
|
|
3234
|
+
if _token_param_is_empty(params.get("WFStoredContentKey")):
|
|
3235
|
+
errors.append(f"Stored Content action missing WFStoredContentKey at index {idx}: {ident}")
|
|
3236
|
+
global_value = params.get("WFStoredContentGlobalValue")
|
|
3237
|
+
if global_value is not None and not isinstance(global_value, bool):
|
|
3238
|
+
errors.append(
|
|
3239
|
+
f"WFStoredContentGlobalValue must be boolean at index {idx}: {ident}"
|
|
3240
|
+
)
|
|
3241
|
+
if ident == "is.workflow.actions.setstoredcontent":
|
|
3242
|
+
wfinput = params.get("WFInput")
|
|
3243
|
+
if not wfinput:
|
|
3244
|
+
errors.append(f"Missing WFInput at index {idx}: {ident}")
|
|
3245
|
+
elif not _input_is_single_placeholder_token_string(wfinput):
|
|
3246
|
+
errors.append(
|
|
3247
|
+
f"Store Content WFInput must be a WFTextTokenString with exactly one object placeholder "
|
|
3248
|
+
f"at index {idx}; bare or wrapped token attachments import as an empty Content parameter"
|
|
3249
|
+
)
|
|
3250
|
+
elif not _input_has_reference(wfinput):
|
|
3251
|
+
errors.append(f"Store Content WFInput has no variable/output reference at index {idx}")
|
|
3252
|
+
else:
|
|
3253
|
+
for out_uuid in _input_action_output_uuids(wfinput):
|
|
3254
|
+
if out_uuid not in uuid_to_ident:
|
|
3255
|
+
errors.append(f"Store Content WFInput references unknown OutputUUID at index {idx}")
|
|
3256
|
+
|
|
3257
|
+
if ident == "is.workflow.actions.getonscreencontext":
|
|
3258
|
+
scope = params.get("WFOnScreenContextScope")
|
|
3259
|
+
if not _token_param_is_empty(scope) and scope not in ON_SCREEN_CONTEXT_SCOPES:
|
|
3260
|
+
errors.append(
|
|
3261
|
+
f"Get What's On Screen has unknown WFOnScreenContextScope at index {idx}: {scope!r}"
|
|
3262
|
+
)
|
|
3263
|
+
limit_enabled = params.get("WFOnScreenContextLimitEnabled")
|
|
3264
|
+
if limit_enabled is not None and not isinstance(limit_enabled, bool):
|
|
3265
|
+
errors.append(
|
|
3266
|
+
f"WFOnScreenContextLimitEnabled must be boolean at index {idx}"
|
|
3267
|
+
)
|
|
3268
|
+
limit_value = params.get("WFOnScreenContextLimit")
|
|
3269
|
+
if limit_enabled is True:
|
|
3270
|
+
if _token_param_is_empty(limit_value):
|
|
3271
|
+
errors.append(f"Get What's On Screen limit is enabled but missing WFOnScreenContextLimit at index {idx}")
|
|
3272
|
+
else:
|
|
3273
|
+
try:
|
|
3274
|
+
numeric_limit = float(limit_value)
|
|
3275
|
+
except (TypeError, ValueError):
|
|
3276
|
+
errors.append(
|
|
3277
|
+
f"WFOnScreenContextLimit must be numeric at index {idx}: {limit_value!r}"
|
|
3278
|
+
)
|
|
3279
|
+
else:
|
|
3280
|
+
if numeric_limit <= 0:
|
|
3281
|
+
errors.append(
|
|
3282
|
+
f"WFOnScreenContextLimit must be greater than 0 at index {idx}"
|
|
3283
|
+
)
|
|
3284
|
+
|
|
3285
|
+
for key in OS27_BOOLEAN_PARAMETER_KEYS_BY_ACTION.get(ident, set()):
|
|
3286
|
+
value = params.get(key)
|
|
3287
|
+
if value is not None and not isinstance(value, bool):
|
|
3288
|
+
errors.append(f"{key} must be boolean at index {idx}: {ident}")
|
|
3289
|
+
|
|
3290
|
+
if ident == "is.workflow.actions.appendnote":
|
|
3291
|
+
operation = params.get("operation")
|
|
3292
|
+
if not _token_param_is_empty(operation) and operation not in APPEND_NOTE_OPERATIONS:
|
|
3293
|
+
errors.append(
|
|
3294
|
+
f"Append to Note has unknown operation at index {idx}: {operation!r}"
|
|
3295
|
+
)
|
|
3296
|
+
|
|
3297
|
+
if ident in {"is.workflow.actions.getdistance", "is.workflow.actions.gettraveltime"}:
|
|
3298
|
+
route_mode = params.get("WFGetDirectionsActionMode")
|
|
3299
|
+
valid_modes = (
|
|
3300
|
+
GET_DISTANCE_ROUTE_MODES
|
|
3301
|
+
if ident == "is.workflow.actions.getdistance"
|
|
3302
|
+
else GET_TRAVEL_TIME_ROUTE_MODES
|
|
3303
|
+
)
|
|
3304
|
+
if not _token_param_is_empty(route_mode) and route_mode not in valid_modes:
|
|
3305
|
+
errors.append(
|
|
3306
|
+
f"{ident} has unknown WFGetDirectionsActionMode at index {idx}: {route_mode!r}"
|
|
3307
|
+
)
|
|
3308
|
+
if ident == "is.workflow.actions.getdistance":
|
|
3309
|
+
distance_unit = params.get("WFDistanceUnit")
|
|
3310
|
+
if not _token_param_is_empty(distance_unit) and distance_unit not in GET_DISTANCE_UNITS:
|
|
3311
|
+
errors.append(
|
|
3312
|
+
f"Get Distance has unknown WFDistanceUnit at index {idx}: {distance_unit!r}"
|
|
3313
|
+
)
|
|
3314
|
+
accuracy = params.get("Accuracy")
|
|
3315
|
+
if not _token_param_is_empty(accuracy) and accuracy not in GET_DISTANCE_ACCURACY_VALUES:
|
|
3316
|
+
errors.append(
|
|
3317
|
+
f"Get Distance has unknown Accuracy at index {idx}: {accuracy!r}"
|
|
3318
|
+
)
|
|
3319
|
+
|
|
3320
|
+
if ident == "is.workflow.actions.searchlocalbusinesses":
|
|
3321
|
+
sort_order = params.get("WFSearchSortOrder")
|
|
3322
|
+
if not _token_param_is_empty(sort_order) and sort_order not in FIND_PLACES_SORT_ORDERS:
|
|
3323
|
+
errors.append(
|
|
3324
|
+
f"Find Places has unknown WFSearchSortOrder at index {idx}: {sort_order!r}"
|
|
3325
|
+
)
|
|
3326
|
+
|
|
3327
|
+
if ident == "com.apple.ShortcutsActions.SetMultitaskingModeAction":
|
|
3328
|
+
mode = params.get("mode")
|
|
3329
|
+
if not _token_param_is_empty(mode) and not _parameter_is_dynamic_attachment(mode):
|
|
3330
|
+
mode_values = _literal_enum_values(mode)
|
|
3331
|
+
if not mode_values:
|
|
3332
|
+
errors.append(
|
|
3333
|
+
f"Set Multitasking Mode has unsupported mode shape at index {idx}: {mode!r}"
|
|
3334
|
+
)
|
|
3335
|
+
else:
|
|
3336
|
+
for mode_value in mode_values:
|
|
3337
|
+
if mode_value not in MULTITASKING_MODES:
|
|
3338
|
+
errors.append(
|
|
3339
|
+
f"Set Multitasking Mode has unknown mode at index {idx}: {mode_value!r}"
|
|
3340
|
+
)
|
|
3341
|
+
|
|
3342
|
+
if ident == "is.workflow.actions.openapp":
|
|
3343
|
+
windowing_format = params.get("WFWindowingFormat")
|
|
3344
|
+
if (
|
|
3345
|
+
not _token_param_is_empty(windowing_format)
|
|
3346
|
+
and windowing_format not in OPEN_APP_WINDOWING_FORMATS
|
|
3347
|
+
):
|
|
3348
|
+
errors.append(
|
|
3349
|
+
f"Open App has unknown WFWindowingFormat at index {idx}: {windowing_format!r}"
|
|
3350
|
+
)
|
|
3351
|
+
|
|
3352
|
+
if ident == "is.workflow.actions.scanbarcode":
|
|
3353
|
+
image_file = params.get("imageFile")
|
|
3354
|
+
if _token_param_is_empty(image_file):
|
|
3355
|
+
errors.append(f"Scan QR or Barcode missing imageFile at index {idx}")
|
|
3356
|
+
elif not _input_is_attached(image_file):
|
|
3357
|
+
errors.append(f"Scan QR or Barcode imageFile is not a token attachment at index {idx}")
|
|
3358
|
+
elif not _input_has_reference(image_file):
|
|
3359
|
+
errors.append(f"Scan QR or Barcode imageFile has no variable/output reference at index {idx}")
|
|
3360
|
+
else:
|
|
3361
|
+
for out_uuid in _input_action_output_uuids(image_file):
|
|
3362
|
+
if out_uuid not in uuid_to_ident:
|
|
3363
|
+
errors.append(
|
|
3364
|
+
f"Scan QR or Barcode imageFile references unknown OutputUUID at index {idx}"
|
|
3365
|
+
)
|
|
3366
|
+
|
|
3367
|
+
if ident in {
|
|
3368
|
+
"com.apple.Safari.CreateNewTabGroup",
|
|
3369
|
+
"com.apple.mobilesafari.CreateNewTabGroup",
|
|
3370
|
+
} and "contents" in params:
|
|
3371
|
+
if _token_param_is_empty(params.get("contents")):
|
|
3372
|
+
errors.append(f"Create Tab Group has empty contents at index {idx}; omit contents or provide URLs/tabs")
|
|
3373
|
+
|
|
3374
|
+
if ident in {"is.workflow.actions.hide.app", "is.workflow.actions.quit.app"}:
|
|
3375
|
+
mode_key = "WFHideAppMode" if ident == "is.workflow.actions.hide.app" else "WFQuitAppMode"
|
|
3376
|
+
mode = params.get(mode_key)
|
|
3377
|
+
if not _token_param_is_empty(mode) and mode not in APP_ACTION_MODES:
|
|
3378
|
+
errors.append(f"{ident} has unknown {mode_key} at index {idx}: {mode!r}")
|
|
3379
|
+
if mode == "App" and _token_param_is_empty(params.get("WFApp")):
|
|
3380
|
+
errors.append(f"{ident} mode App missing WFApp at index {idx}")
|
|
3381
|
+
apps_except = params.get("WFAppsExcept")
|
|
3382
|
+
if "WFAppsExcept" in params and _token_param_is_empty(apps_except):
|
|
3383
|
+
errors.append(f"{ident} has empty WFAppsExcept at index {idx}; omit it or provide apps to keep open")
|
|
3384
|
+
ask_to_save = params.get("WFAskToSaveChanges")
|
|
3385
|
+
if ask_to_save is not None and not isinstance(ask_to_save, bool):
|
|
3386
|
+
errors.append(f"WFAskToSaveChanges must be boolean at index {idx}: {ident}")
|
|
3387
|
+
|
|
3388
|
+
if ident == "is.workflow.actions.filter.vpns":
|
|
3389
|
+
input_parameter = params.get("WFContentItemInputParameter")
|
|
3390
|
+
if not _token_param_is_empty(input_parameter) and input_parameter not in CONTENT_ITEM_INPUT_PARAMETERS:
|
|
3391
|
+
errors.append(
|
|
3392
|
+
f"Find VPNs has unknown WFContentItemInputParameter at index {idx}: {input_parameter!r}"
|
|
3393
|
+
)
|
|
3394
|
+
sort_property = params.get("WFContentItemSortProperty")
|
|
3395
|
+
if not _token_param_is_empty(sort_property) and sort_property not in VPN_SORT_PROPERTIES:
|
|
3396
|
+
errors.append(
|
|
3397
|
+
f"Find VPNs has unknown WFContentItemSortProperty at index {idx}: {sort_property!r}"
|
|
3398
|
+
)
|
|
3399
|
+
compound_type = params.get("WFCompoundType")
|
|
3400
|
+
if compound_type is not None and compound_type not in VPN_COMPOUND_TYPES:
|
|
3401
|
+
errors.append(
|
|
3402
|
+
f"Find VPNs has unknown WFCompoundType at index {idx}: {compound_type!r}"
|
|
3403
|
+
)
|
|
3404
|
+
limit_enabled = params.get("WFContentItemLimitEnabled")
|
|
3405
|
+
if limit_enabled is not None and not isinstance(limit_enabled, bool):
|
|
3406
|
+
errors.append(
|
|
3407
|
+
f"Find VPNs WFContentItemLimitEnabled must be boolean at index {idx}"
|
|
3408
|
+
)
|
|
3409
|
+
limit_number = params.get("WFContentItemLimitNumber")
|
|
3410
|
+
if limit_enabled is True:
|
|
3411
|
+
if _token_param_is_empty(limit_number):
|
|
3412
|
+
errors.append(f"Find VPNs limit is enabled but missing WFContentItemLimitNumber at index {idx}")
|
|
3413
|
+
else:
|
|
3414
|
+
try:
|
|
3415
|
+
numeric_limit = float(limit_number)
|
|
3416
|
+
except (TypeError, ValueError):
|
|
3417
|
+
errors.append(
|
|
3418
|
+
f"Find VPNs WFContentItemLimitNumber must be numeric at index {idx}: {limit_number!r}"
|
|
3419
|
+
)
|
|
3420
|
+
else:
|
|
3421
|
+
if numeric_limit <= 0:
|
|
3422
|
+
errors.append(
|
|
3423
|
+
f"Find VPNs WFContentItemLimitNumber must be greater than 0 at index {idx}"
|
|
3424
|
+
)
|
|
3425
|
+
|
|
3426
|
+
if ident == "is.workflow.actions.vpn.set":
|
|
3427
|
+
operation = params.get("WFVPNOperation")
|
|
3428
|
+
if _token_param_is_empty(operation):
|
|
3429
|
+
errors.append(f"Set VPN missing WFVPNOperation at index {idx}")
|
|
3430
|
+
elif operation not in VPN_OPERATIONS:
|
|
3431
|
+
errors.append(f"Set VPN has unknown WFVPNOperation at index {idx}: {operation!r}")
|
|
3432
|
+
if _token_param_is_empty(params.get("WFVPN")):
|
|
3433
|
+
errors.append(f"Set VPN missing WFVPN at index {idx}")
|
|
3434
|
+
on_demand_value = params.get("WFOnDemandValue")
|
|
3435
|
+
if on_demand_value is not None and not isinstance(on_demand_value, bool):
|
|
3436
|
+
errors.append(f"WFOnDemandValue must be boolean at index {idx}: {ident}")
|
|
3437
|
+
if operation == "Set On Demand" and on_demand_value is None:
|
|
3438
|
+
errors.append(f"Set VPN operation Set On Demand missing WFOnDemandValue at index {idx}")
|
|
3439
|
+
|
|
3440
|
+
if ident == "is.workflow.actions.location" and "WFLocation" not in params:
|
|
3441
|
+
errors.append(f"Location action missing WFLocation at index {idx}")
|
|
3442
|
+
|
|
3443
|
+
for location_key in LOCATION_PARAMETER_KEYS:
|
|
3444
|
+
if location_key not in params:
|
|
3445
|
+
continue
|
|
3446
|
+
location_value = params.get(location_key)
|
|
3447
|
+
if _token_param_is_empty(location_value):
|
|
3448
|
+
errors.append(
|
|
3449
|
+
f"Location parameter {location_key} is empty at index {idx}: {ident}"
|
|
3450
|
+
)
|
|
3451
|
+
continue
|
|
3452
|
+
if not _input_is_attached(location_value):
|
|
3453
|
+
errors.append(
|
|
3454
|
+
f"Location parameter {location_key} is not a token attachment at index {idx}: {ident}"
|
|
3455
|
+
)
|
|
3456
|
+
continue
|
|
3457
|
+
if (
|
|
3458
|
+
isinstance(location_value, dict)
|
|
3459
|
+
and location_value.get("WFSerializationType") != "WFTextTokenAttachment"
|
|
3460
|
+
):
|
|
3461
|
+
errors.append(
|
|
3462
|
+
f"Location parameter {location_key} should use WFTextTokenAttachment (token strings can import as empty) at index {idx}: {ident}"
|
|
3463
|
+
)
|
|
3464
|
+
continue
|
|
3465
|
+
if not _input_has_reference(location_value):
|
|
3466
|
+
errors.append(
|
|
3467
|
+
f"Location parameter {location_key} has no variable/output reference at index {idx}: {ident}"
|
|
3468
|
+
)
|
|
3469
|
+
continue
|
|
3470
|
+
input_var_name = _extract_input_variable_name(location_value)
|
|
3471
|
+
if input_var_name:
|
|
3472
|
+
if input_var_name not in location_source_vars:
|
|
3473
|
+
errors.append(
|
|
3474
|
+
f"Location parameter {location_key} variable '{input_var_name}' does not resolve to a location source variable at index {idx}: {ident}"
|
|
3475
|
+
)
|
|
3476
|
+
continue
|
|
3477
|
+
for out_uuid in _input_action_output_uuids(location_value):
|
|
3478
|
+
source_ident = uuid_to_ident.get(out_uuid)
|
|
3479
|
+
if source_ident is None:
|
|
3480
|
+
errors.append(
|
|
3481
|
+
f"Location parameter {location_key} references unknown OutputUUID at index {idx}: {ident}"
|
|
3482
|
+
)
|
|
3483
|
+
elif source_ident not in LOCATION_SOURCE_ACTIONS:
|
|
3484
|
+
errors.append(
|
|
3485
|
+
f"Location parameter {location_key} should reference output from Get Current Location/Location action at index {idx}: {ident}"
|
|
3486
|
+
)
|
|
3487
|
+
|
|
3488
|
+
wfinput = params.get("WFInput")
|
|
3489
|
+
if (
|
|
3490
|
+
wfinput
|
|
3491
|
+
and ident != "is.workflow.actions.conditional"
|
|
3492
|
+
and _wrapped_variable_contains_action_output(wfinput)
|
|
3493
|
+
):
|
|
3494
|
+
errors.append(
|
|
3495
|
+
f"WFInput wraps ActionOutput in Type=Variable wrapper at index {idx}: {ident}"
|
|
3496
|
+
)
|
|
3497
|
+
|
|
3498
|
+
# Data-producing actions should have their outputs referenced later
|
|
3499
|
+
if ident in UNUSED_OUTPUT_ACTIONS and uuid and uuid not in used_output_uuids:
|
|
3500
|
+
if idx != len(actions) - 1:
|
|
3501
|
+
errors.append(
|
|
3502
|
+
f"Output from {ident} at index {idx} is unused; likely missing variable wiring"
|
|
3503
|
+
)
|
|
3504
|
+
|
|
3505
|
+
# Download URL should have WFURL or WFInput
|
|
3506
|
+
if ident in REQUIRED_URL_INPUT_ACTIONS:
|
|
3507
|
+
if not params.get("WFURL") and not params.get("WFInput"):
|
|
3508
|
+
errors.append(f"Get Contents of URL missing WFURL/WFInput at index {idx}")
|
|
3509
|
+
|
|
3510
|
+
if ident == "is.workflow.actions.count":
|
|
3511
|
+
if "Input" not in params:
|
|
3512
|
+
errors.append(f"Count action should include Input mirror of WFInput at index {idx}")
|
|
3513
|
+
elif params.get("WFInput") and params.get("Input") != params.get("WFInput"):
|
|
3514
|
+
errors.append(f"Count action Input must match WFInput reference at index {idx}")
|
|
3515
|
+
|
|
3516
|
+
# Action-specific required fields
|
|
3517
|
+
if ident == "is.workflow.actions.getvalueforkey":
|
|
3518
|
+
wfinput = params.get("WFInput")
|
|
3519
|
+
if not wfinput:
|
|
3520
|
+
errors.append(f"Get Dictionary Value missing input at index {idx}")
|
|
3521
|
+
elif not _input_is_attached(wfinput):
|
|
3522
|
+
errors.append(f"Get Dictionary Value WFInput is not a token attachment at index {idx}")
|
|
3523
|
+
elif not _input_has_reference(wfinput):
|
|
3524
|
+
errors.append(f"Get Dictionary Value WFInput has no variable/output reference at index {idx}")
|
|
3525
|
+
else:
|
|
3526
|
+
for out_uuid in _input_action_output_uuids(wfinput):
|
|
3527
|
+
if out_uuid not in uuid_to_ident:
|
|
3528
|
+
errors.append(f"Get Dictionary Value references unknown OutputUUID at index {idx}")
|
|
3529
|
+
|
|
3530
|
+
value_type = params.get("WFGetDictionaryValueType")
|
|
3531
|
+
if value_type in (None, "", "Value"):
|
|
3532
|
+
key_param = params.get("WFDictionaryKey")
|
|
3533
|
+
if _token_param_is_empty(key_param):
|
|
3534
|
+
errors.append(f"Get Dictionary Value missing key at index {idx}")
|
|
3535
|
+
elif isinstance(key_param, str):
|
|
3536
|
+
if UNSAFE_ERROR_DOT_PATH_RE.match(key_param.strip()):
|
|
3537
|
+
errors.append(
|
|
3538
|
+
f"Get Dictionary Value uses unsafe direct key path '{key_param}' at index {idx}; extract 'error' first, then read nested keys inside a guarded If block"
|
|
3539
|
+
)
|
|
3540
|
+
|
|
3541
|
+
if ident == "is.workflow.actions.image.convert":
|
|
3542
|
+
if _token_param_is_empty(params.get("WFImageFormat")):
|
|
3543
|
+
errors.append(f"Convert Image missing WFImageFormat at index {idx}")
|
|
3544
|
+
|
|
3545
|
+
if ident == "is.workflow.actions.properties.weather.conditions":
|
|
3546
|
+
prop_name = params.get("WFContentItemPropertyName")
|
|
3547
|
+
if _token_param_is_empty(prop_name):
|
|
3548
|
+
errors.append(
|
|
3549
|
+
f"Get Detail of Weather Conditions missing WFContentItemPropertyName at index {idx}"
|
|
3550
|
+
)
|
|
3551
|
+
elif (
|
|
3552
|
+
isinstance(prop_name, str)
|
|
3553
|
+
and prop_name.strip().lower() in WEATHER_DETAIL_PLACEHOLDER_VALUES
|
|
3554
|
+
):
|
|
3555
|
+
errors.append(
|
|
3556
|
+
f"Get Detail of Weather Conditions uses placeholder detail name at index {idx}: {prop_name!r}"
|
|
3557
|
+
)
|
|
3558
|
+
elif isinstance(prop_name, str) and prop_name not in WEATHER_DETAIL_SUPPORTED_VALUES:
|
|
3559
|
+
errors.append(
|
|
3560
|
+
f"Get Detail of Weather Conditions uses unsupported detail name at index {idx}: {prop_name!r}; use one of {', '.join(sorted(WEATHER_DETAIL_SUPPORTED_VALUES))}"
|
|
3561
|
+
)
|
|
3562
|
+
weather_input = params.get("WFInput")
|
|
3563
|
+
if _token_param_is_empty(weather_input):
|
|
3564
|
+
errors.append(f"Get Detail of Weather Conditions missing WFInput at index {idx}")
|
|
3565
|
+
elif not _input_is_attached(weather_input):
|
|
3566
|
+
errors.append(
|
|
3567
|
+
f"Get Detail of Weather Conditions WFInput is not a token attachment at index {idx}"
|
|
3568
|
+
)
|
|
3569
|
+
elif not _input_has_reference(weather_input):
|
|
3570
|
+
errors.append(
|
|
3571
|
+
f"Get Detail of Weather Conditions WFInput has no variable/output reference at index {idx}"
|
|
3572
|
+
)
|
|
3573
|
+
else:
|
|
3574
|
+
input_var_name = _extract_input_variable_name(weather_input)
|
|
3575
|
+
if input_var_name:
|
|
3576
|
+
errors.append(
|
|
3577
|
+
f"Get Detail of Weather Conditions WFInput should reference weather action output directly (variable '{input_var_name}' can import as generic Detail) at index {idx}"
|
|
3578
|
+
)
|
|
3579
|
+
|
|
3580
|
+
out_uuids = _input_action_output_uuids(weather_input)
|
|
3581
|
+
if not out_uuids:
|
|
3582
|
+
errors.append(
|
|
3583
|
+
f"Get Detail of Weather Conditions WFInput should reference output from Get Weather/Forecast directly at index {idx}"
|
|
3584
|
+
)
|
|
3585
|
+
else:
|
|
3586
|
+
has_weather_source = False
|
|
3587
|
+
for out_uuid in out_uuids:
|
|
3588
|
+
source_ident = uuid_to_ident.get(out_uuid)
|
|
3589
|
+
if source_ident is None:
|
|
3590
|
+
errors.append(
|
|
3591
|
+
f"Get Detail of Weather Conditions references unknown OutputUUID at index {idx}"
|
|
3592
|
+
)
|
|
3593
|
+
elif source_ident in WEATHER_SOURCE_ACTIONS:
|
|
3594
|
+
has_weather_source = True
|
|
3595
|
+
if not has_weather_source:
|
|
3596
|
+
errors.append(
|
|
3597
|
+
f"Get Detail of Weather Conditions WFInput should reference output from Get Weather/Forecast at index {idx}"
|
|
3598
|
+
)
|
|
3599
|
+
|
|
3600
|
+
if ident == "is.workflow.actions.getitemfromlist":
|
|
3601
|
+
wfinput = params.get("WFInput")
|
|
3602
|
+
for out_uuid in _input_action_output_uuids(wfinput):
|
|
3603
|
+
source_ident = uuid_to_ident.get(out_uuid)
|
|
3604
|
+
source_params = uuid_to_params.get(out_uuid, {})
|
|
3605
|
+
source_prop = source_params.get("WFContentItemPropertyName")
|
|
3606
|
+
if (
|
|
3607
|
+
source_ident == "is.workflow.actions.properties.weather.conditions"
|
|
3608
|
+
and source_prop in WEATHER_DETAIL_LIST_VALUES
|
|
3609
|
+
):
|
|
3610
|
+
specifier = params.get("WFItemSpecifier")
|
|
3611
|
+
if source_prop == "Sunrise Time" and specifier not in (None, "", "First Item"):
|
|
3612
|
+
errors.append(
|
|
3613
|
+
f"Sunrise Time returns a list; Get Item from List should use First Item at index {idx}"
|
|
3614
|
+
)
|
|
3615
|
+
if source_prop == "Sunset Time" and specifier != "Last Item":
|
|
3616
|
+
errors.append(
|
|
3617
|
+
f"Sunset Time returns a list; Get Item from List should use Last Item at index {idx}"
|
|
3618
|
+
)
|
|
3619
|
+
|
|
3620
|
+
if ident == "is.workflow.actions.gettimebetweendates":
|
|
3621
|
+
date_keys = ("WFDate", "WFTimeUntilCustomDate", "WFTimeUntilFromDate")
|
|
3622
|
+
non_empty_date_refs: list[tuple[str, object]] = []
|
|
3623
|
+
for key in date_keys:
|
|
3624
|
+
if key not in params:
|
|
3625
|
+
continue
|
|
3626
|
+
value = params.get(key)
|
|
3627
|
+
if _token_param_is_empty(value):
|
|
3628
|
+
errors.append(
|
|
3629
|
+
f"Get Time Between Dates has empty {key} at index {idx}; omit unused date keys"
|
|
3630
|
+
)
|
|
3631
|
+
else:
|
|
3632
|
+
non_empty_date_refs.append((key, value))
|
|
3633
|
+
|
|
3634
|
+
if not non_empty_date_refs:
|
|
3635
|
+
errors.append(
|
|
3636
|
+
f"Get Time Between Dates missing WFDate/WFTimeUntilCustomDate/WFTimeUntilFromDate at index {idx}"
|
|
3637
|
+
)
|
|
3638
|
+
elif len(non_empty_date_refs) > 1:
|
|
3639
|
+
used = ", ".join(key for key, _ in non_empty_date_refs)
|
|
3640
|
+
errors.append(
|
|
3641
|
+
f"Get Time Between Dates should set exactly one non-empty date operand (found: {used}) at index {idx}"
|
|
3642
|
+
)
|
|
3643
|
+
|
|
3644
|
+
for key, reference in non_empty_date_refs:
|
|
3645
|
+
if not _input_is_attached(reference):
|
|
3646
|
+
errors.append(
|
|
3647
|
+
f"Get Time Between Dates {key} is not a token attachment at index {idx}"
|
|
3648
|
+
)
|
|
3649
|
+
continue
|
|
3650
|
+
if _input_has_current_date_token(reference):
|
|
3651
|
+
errors.append(
|
|
3652
|
+
f"Get Time Between Dates {key} cannot use CurrentDate magic token directly at index {idx}; "
|
|
3653
|
+
"insert a Date action set to Current Date and reference that action output"
|
|
3654
|
+
)
|
|
3655
|
+
continue
|
|
3656
|
+
if not _input_is_token_string(reference):
|
|
3657
|
+
errors.append(
|
|
3658
|
+
f"Get Time Between Dates {key} should use WFTextTokenString with a placeholder at index {idx}"
|
|
3659
|
+
)
|
|
3660
|
+
if not _input_has_reference(reference):
|
|
3661
|
+
errors.append(
|
|
3662
|
+
f"Get Time Between Dates {key} has no variable/output reference at index {idx}"
|
|
3663
|
+
)
|
|
3664
|
+
continue
|
|
3665
|
+
for out_uuid in _input_action_output_uuids(reference):
|
|
3666
|
+
if out_uuid not in uuid_to_ident:
|
|
3667
|
+
errors.append(
|
|
3668
|
+
f"Get Time Between Dates {key} references unknown OutputUUID at index {idx}"
|
|
3669
|
+
)
|
|
3670
|
+
|
|
3671
|
+
wfinput = params.get("WFInput")
|
|
3672
|
+
if _input_has_current_date_token(wfinput):
|
|
3673
|
+
errors.append(
|
|
3674
|
+
f"Get Time Between Dates WFInput cannot use CurrentDate magic token directly at index {idx}; "
|
|
3675
|
+
"insert a Date action set to Current Date and reference that action output"
|
|
3676
|
+
)
|
|
3677
|
+
elif wfinput and not _input_is_token_string(wfinput):
|
|
3678
|
+
errors.append(
|
|
3679
|
+
f"Get Time Between Dates WFInput should use WFTextTokenString with a placeholder at index {idx}"
|
|
3680
|
+
)
|
|
3681
|
+
|
|
3682
|
+
unit = params.get("WFTimeUntilUnit")
|
|
3683
|
+
if not _token_param_is_empty(unit) and isinstance(unit, str) and unit.strip().lower() not in DATE_DELTA_UNITS:
|
|
3684
|
+
errors.append(f"Get Time Between Dates has unsupported WFTimeUntilUnit '{unit}' at index {idx}")
|
|
3685
|
+
|
|
3686
|
+
if ident == "is.workflow.actions.extracttextfromimage":
|
|
3687
|
+
image_keys = ("imageFile", "WFImage", "WFInput")
|
|
3688
|
+
non_empty_image_inputs: list[tuple[str, object]] = []
|
|
3689
|
+
for key in image_keys:
|
|
3690
|
+
if key not in params:
|
|
3691
|
+
continue
|
|
3692
|
+
value = params.get(key)
|
|
3693
|
+
if _token_param_is_empty(value):
|
|
3694
|
+
errors.append(
|
|
3695
|
+
f"Extract Text from Image has empty {key} at index {idx}; omit unused image-input keys"
|
|
3696
|
+
)
|
|
3697
|
+
else:
|
|
3698
|
+
non_empty_image_inputs.append((key, value))
|
|
3699
|
+
|
|
3700
|
+
if not non_empty_image_inputs:
|
|
3701
|
+
errors.append(
|
|
3702
|
+
f"Extract Text from Image missing imageFile/WFImage/WFInput at index {idx}"
|
|
3703
|
+
)
|
|
3704
|
+
elif len(non_empty_image_inputs) > 1:
|
|
3705
|
+
used = ", ".join(key for key, _ in non_empty_image_inputs)
|
|
3706
|
+
errors.append(
|
|
3707
|
+
f"Extract Text from Image should set exactly one non-empty image input key (found: {used}) at index {idx}"
|
|
3708
|
+
)
|
|
3709
|
+
|
|
3710
|
+
for key, value in non_empty_image_inputs:
|
|
3711
|
+
if not _input_is_attached(value):
|
|
3712
|
+
errors.append(
|
|
3713
|
+
f"Extract Text from Image {key} is not a token attachment at index {idx}"
|
|
3714
|
+
)
|
|
3715
|
+
continue
|
|
3716
|
+
if not _input_has_reference(value):
|
|
3717
|
+
errors.append(
|
|
3718
|
+
f"Extract Text from Image {key} has no variable/output reference at index {idx}"
|
|
3719
|
+
)
|
|
3720
|
+
continue
|
|
3721
|
+
for out_uuid in _input_action_output_uuids(value):
|
|
3722
|
+
if out_uuid not in uuid_to_ident:
|
|
3723
|
+
errors.append(
|
|
3724
|
+
f"Extract Text from Image {key} references unknown OutputUUID at index {idx}"
|
|
3725
|
+
)
|
|
3726
|
+
|
|
3727
|
+
if ident == "is.workflow.actions.setvalueforkey":
|
|
3728
|
+
if not params.get("WFDictionary"):
|
|
3729
|
+
errors.append(f"Set Dictionary Value missing WFDictionary at index {idx}")
|
|
3730
|
+
|
|
3731
|
+
if ident == "is.workflow.actions.text.match":
|
|
3732
|
+
if "WFInput" in params:
|
|
3733
|
+
errors.append(
|
|
3734
|
+
f"Match Text should use 'text' parameter (not WFInput) at index {idx}"
|
|
3735
|
+
)
|
|
3736
|
+
text_param = params.get("text")
|
|
3737
|
+
if text_param is None:
|
|
3738
|
+
errors.append(f"Match Text missing 'text' parameter at index {idx}")
|
|
3739
|
+
elif _token_param_is_empty(text_param):
|
|
3740
|
+
errors.append(f"Match Text has empty 'text' parameter at index {idx}")
|
|
3741
|
+
|
|
3742
|
+
if ident in TEXT_INPUT_KEY_ACTIONS:
|
|
3743
|
+
action_name = "Change Case" if ident == "is.workflow.actions.text.changecase" else "Split Text"
|
|
3744
|
+
text_param = params.get("text")
|
|
3745
|
+
if text_param is None:
|
|
3746
|
+
if "WFInput" in params:
|
|
3747
|
+
errors.append(
|
|
3748
|
+
f"{action_name} should use 'text' parameter (not WFInput) at index {idx}"
|
|
3749
|
+
)
|
|
3750
|
+
else:
|
|
3751
|
+
errors.append(f"{action_name} missing 'text' parameter at index {idx}")
|
|
3752
|
+
else:
|
|
3753
|
+
if _token_param_is_empty(text_param):
|
|
3754
|
+
errors.append(f"{action_name} has empty 'text' parameter at index {idx}")
|
|
3755
|
+
elif isinstance(text_param, dict) and not _input_is_attached(text_param):
|
|
3756
|
+
errors.append(f"{action_name} 'text' parameter is not a token attachment at index {idx}")
|
|
3757
|
+
if "WFInput" in params:
|
|
3758
|
+
errors.append(
|
|
3759
|
+
f"{action_name} should not include WFInput; wire input via 'text' parameter at index {idx}"
|
|
3760
|
+
)
|
|
3761
|
+
if ident == "is.workflow.actions.text.split":
|
|
3762
|
+
sep = params.get("WFTextSeparator")
|
|
3763
|
+
if sep == "Custom":
|
|
3764
|
+
custom_sep = params.get("WFTextCustomSeparator")
|
|
3765
|
+
# A single space is a valid custom separator; only reject missing/empty-string.
|
|
3766
|
+
if custom_sep is None:
|
|
3767
|
+
errors.append(f"Split Text missing WFTextCustomSeparator for Custom separator at index {idx}")
|
|
3768
|
+
elif isinstance(custom_sep, str) and custom_sep == "":
|
|
3769
|
+
errors.append(f"Split Text missing WFTextCustomSeparator for Custom separator at index {idx}")
|
|
3770
|
+
elif isinstance(custom_sep, dict) and _token_param_is_empty(custom_sep):
|
|
3771
|
+
errors.append(f"Split Text missing WFTextCustomSeparator for Custom separator at index {idx}")
|
|
3772
|
+
|
|
3773
|
+
if ident == "is.workflow.actions.text.replace":
|
|
3774
|
+
if "text" in params and "WFInput" not in params:
|
|
3775
|
+
errors.append(
|
|
3776
|
+
f"Replace Text should use WFInput (not 'text') at index {idx}"
|
|
3777
|
+
)
|
|
3778
|
+
|
|
3779
|
+
if ident == "is.workflow.actions.filter.notes":
|
|
3780
|
+
filt = params.get("WFContentItemFilter")
|
|
3781
|
+
if not isinstance(filt, dict):
|
|
3782
|
+
errors.append(f"Find Notes missing WFContentItemFilter at index {idx}")
|
|
3783
|
+
else:
|
|
3784
|
+
if filt.get("WFSerializationType") != "WFContentPredicateTableTemplate":
|
|
3785
|
+
errors.append(
|
|
3786
|
+
f"Find Notes WFContentItemFilter must use WFContentPredicateTableTemplate at index {idx}"
|
|
3787
|
+
)
|
|
3788
|
+
fval = filt.get("Value")
|
|
3789
|
+
if not isinstance(fval, dict):
|
|
3790
|
+
errors.append(f"Find Notes WFContentItemFilter missing Value dict at index {idx}")
|
|
3791
|
+
else:
|
|
3792
|
+
templates = fval.get("WFActionParameterFilterTemplates")
|
|
3793
|
+
if not isinstance(templates, list) or not templates:
|
|
3794
|
+
errors.append(f"Find Notes filter has no templates at index {idx}")
|
|
3795
|
+
else:
|
|
3796
|
+
for tidx, template in enumerate(templates):
|
|
3797
|
+
if not isinstance(template, dict):
|
|
3798
|
+
errors.append(
|
|
3799
|
+
f"Find Notes filter template {tidx} is not a dict at index {idx}"
|
|
3800
|
+
)
|
|
3801
|
+
continue
|
|
3802
|
+
prop = template.get("Property")
|
|
3803
|
+
values = template.get("Values")
|
|
3804
|
+
if not isinstance(values, dict):
|
|
3805
|
+
errors.append(
|
|
3806
|
+
f"Find Notes filter template '{prop}' missing Values dict at index {idx}"
|
|
3807
|
+
)
|
|
3808
|
+
continue
|
|
3809
|
+
if prop == "Name":
|
|
3810
|
+
string_state = values.get("String")
|
|
3811
|
+
if _token_param_is_empty(string_state):
|
|
3812
|
+
errors.append(
|
|
3813
|
+
f"Find Notes Name filter has empty String value at index {idx}"
|
|
3814
|
+
)
|
|
3815
|
+
elif isinstance(string_state, dict) and string_state.get(
|
|
3816
|
+
"WFSerializationType"
|
|
3817
|
+
) != "WFTextTokenString":
|
|
3818
|
+
errors.append(
|
|
3819
|
+
f"Find Notes Name filter should use WFTextTokenString for String value at index {idx}"
|
|
3820
|
+
)
|
|
3821
|
+
if prop == "Folder":
|
|
3822
|
+
enum_state = values.get("Enumeration")
|
|
3823
|
+
if not isinstance(enum_state, dict):
|
|
3824
|
+
errors.append(
|
|
3825
|
+
f"Find Notes Folder filter missing Enumeration value at index {idx}"
|
|
3826
|
+
)
|
|
3827
|
+
else:
|
|
3828
|
+
if (
|
|
3829
|
+
enum_state.get("WFSerializationType")
|
|
3830
|
+
!= "WFLinkDynamicOptionSubstitutableState"
|
|
3831
|
+
):
|
|
3832
|
+
errors.append(
|
|
3833
|
+
f"Find Notes Folder filter should use WFLinkDynamicOptionSubstitutableState at index {idx}"
|
|
3834
|
+
)
|
|
3835
|
+
enum_value = enum_state.get("Value")
|
|
3836
|
+
if _token_param_is_empty(enum_value):
|
|
3837
|
+
errors.append(
|
|
3838
|
+
f"Find Notes Folder filter Enumeration value is empty at index {idx}"
|
|
3839
|
+
)
|
|
3840
|
+
|
|
3841
|
+
if ident == "is.workflow.actions.filter.calendarevents":
|
|
3842
|
+
filt = params.get("WFContentItemFilter")
|
|
3843
|
+
if not isinstance(filt, dict):
|
|
3844
|
+
errors.append(f"Find Calendar Events missing WFContentItemFilter at index {idx}")
|
|
3845
|
+
elif filt.get("WFSerializationType") != "WFContentPredicateTableTemplate":
|
|
3846
|
+
errors.append(
|
|
3847
|
+
f"Find Calendar Events WFContentItemFilter must use WFContentPredicateTableTemplate at index {idx}"
|
|
3848
|
+
)
|
|
3849
|
+
else:
|
|
3850
|
+
inner = filt.get("Value")
|
|
3851
|
+
templates = inner.get("WFActionParameterFilterTemplates") if isinstance(inner, dict) else None
|
|
3852
|
+
if not isinstance(templates, list) or not templates:
|
|
3853
|
+
errors.append(f"Find Calendar Events WFContentItemFilter has no templates at index {idx}")
|
|
3854
|
+
else:
|
|
3855
|
+
for tidx, template in enumerate(templates):
|
|
3856
|
+
if not isinstance(template, dict):
|
|
3857
|
+
errors.append(
|
|
3858
|
+
f"Find Calendar Events filter template {tidx} is not a dict at index {idx}"
|
|
3859
|
+
)
|
|
3860
|
+
continue
|
|
3861
|
+
prop = template.get("Property")
|
|
3862
|
+
op = template.get("Operator")
|
|
3863
|
+
if prop in {"Start Date", "End Date"} and op == 3:
|
|
3864
|
+
errors.append(
|
|
3865
|
+
f"Find Calendar Events {prop} filter uses numeric operator 3 at index {idx}; "
|
|
3866
|
+
"use date operator 2 for 'is after', 1002 for 'is today', or 1003 for 'is between'"
|
|
3867
|
+
)
|
|
3868
|
+
if prop in {"Start Date", "End Date"} and op in {2, 1003}:
|
|
3869
|
+
values = template.get("Values")
|
|
3870
|
+
if not isinstance(values, dict):
|
|
3871
|
+
errors.append(
|
|
3872
|
+
f"Find Calendar Events {prop} date filter missing Values dict at index {idx}"
|
|
3873
|
+
)
|
|
3874
|
+
elif op == 2 and "Date" not in values:
|
|
3875
|
+
errors.append(
|
|
3876
|
+
f"Find Calendar Events {prop} 'is after' filter missing Values.Date at index {idx}"
|
|
3877
|
+
)
|
|
3878
|
+
elif op == 1003 and ("Date" not in values or "AnotherDate" not in values):
|
|
3879
|
+
errors.append(
|
|
3880
|
+
f"Find Calendar Events {prop} 'is between' filter needs Values.Date and Values.AnotherDate at index {idx}"
|
|
3881
|
+
)
|
|
3882
|
+
|
|
3883
|
+
if ident == HEALTH_FIND_SAMPLES_ACTION:
|
|
3884
|
+
if "WFHealthQuantityType" in params:
|
|
3885
|
+
errors.append(
|
|
3886
|
+
f"Find Health Samples uses obsolete WFHealthQuantityType at index {idx}; put the sample kind in a non-removable Type filter row"
|
|
3887
|
+
)
|
|
3888
|
+
_validate_health_filter_template(
|
|
3889
|
+
params.get("WFContentItemFilter"),
|
|
3890
|
+
action_label="Find Health Samples",
|
|
3891
|
+
idx=idx,
|
|
3892
|
+
errors=errors,
|
|
3893
|
+
)
|
|
3894
|
+
health_type, type_operator, malformed_type = _health_filter_type_value(
|
|
3895
|
+
params.get("WFContentItemFilter")
|
|
3896
|
+
)
|
|
3897
|
+
if malformed_type or _token_param_is_empty(health_type):
|
|
3898
|
+
errors.append(f"Find Health Samples missing or malformed Type filter at index {idx}")
|
|
3899
|
+
elif type_operator != 4:
|
|
3900
|
+
errors.append(
|
|
3901
|
+
f"Find Health Samples Type filter must use operator 4 at index {idx}"
|
|
3902
|
+
)
|
|
3903
|
+
elif (
|
|
3904
|
+
isinstance(health_type, str)
|
|
3905
|
+
and HEALTH_REFERENCE_SETS["find_sample_types"]
|
|
3906
|
+
and health_type not in HEALTH_REFERENCE_SETS["find_sample_types"]
|
|
3907
|
+
):
|
|
3908
|
+
errors.append(
|
|
3909
|
+
f"Find Health Samples uses unknown Type filter value '{health_type}' at index {idx}"
|
|
3910
|
+
)
|
|
3911
|
+
if "WFContentItemLimitEnabled" in params and not isinstance(
|
|
3912
|
+
params.get("WFContentItemLimitEnabled"), bool
|
|
3913
|
+
):
|
|
3914
|
+
errors.append(
|
|
3915
|
+
f"Find Health Samples WFContentItemLimitEnabled must be boolean at index {idx}"
|
|
3916
|
+
)
|
|
3917
|
+
if params.get("WFContentItemLimitEnabled") is True:
|
|
3918
|
+
limit_number = params.get("WFContentItemLimitNumber")
|
|
3919
|
+
if limit_number is None or limit_number == "":
|
|
3920
|
+
errors.append(
|
|
3921
|
+
f"Find Health Samples limit is enabled but WFContentItemLimitNumber is missing at index {idx}"
|
|
3922
|
+
)
|
|
3923
|
+
|
|
3924
|
+
if ident == HEALTH_SAMPLE_DETAIL_ACTION:
|
|
3925
|
+
prop_name = params.get("WFContentItemPropertyName")
|
|
3926
|
+
if _token_param_is_empty(prop_name):
|
|
3927
|
+
errors.append(
|
|
3928
|
+
f"Get Details of Health Sample missing WFContentItemPropertyName at index {idx}"
|
|
3929
|
+
)
|
|
3930
|
+
elif isinstance(prop_name, str) and prop_name not in HEALTH_SAMPLE_DETAIL_PROPERTIES:
|
|
3931
|
+
errors.append(
|
|
3932
|
+
f"Get Details of Health Sample uses unknown detail '{prop_name}' at index {idx}"
|
|
3933
|
+
)
|
|
3934
|
+
|
|
3935
|
+
health_input = params.get("WFInput")
|
|
3936
|
+
if _token_param_is_empty(health_input):
|
|
3937
|
+
errors.append(f"Get Details of Health Sample missing WFInput at index {idx}")
|
|
3938
|
+
elif not _input_is_attached(health_input):
|
|
3939
|
+
errors.append(
|
|
3940
|
+
f"Get Details of Health Sample WFInput is not a token attachment at index {idx}"
|
|
3941
|
+
)
|
|
3942
|
+
elif not _input_has_reference(health_input):
|
|
3943
|
+
errors.append(
|
|
3944
|
+
f"Get Details of Health Sample WFInput has no variable/output reference at index {idx}"
|
|
3945
|
+
)
|
|
3946
|
+
else:
|
|
3947
|
+
input_var_name = _extract_input_variable_name(health_input)
|
|
3948
|
+
if input_var_name:
|
|
3949
|
+
if input_var_name not in health_sample_source_vars:
|
|
3950
|
+
errors.append(
|
|
3951
|
+
f"Get Details of Health Sample variable '{input_var_name}' does not resolve to Health Samples at index {idx}"
|
|
3952
|
+
)
|
|
3953
|
+
for out_uuid in _input_action_output_uuids(health_input):
|
|
3954
|
+
source_ident = uuid_to_ident.get(out_uuid)
|
|
3955
|
+
if source_ident is None:
|
|
3956
|
+
errors.append(
|
|
3957
|
+
f"Get Details of Health Sample references unknown OutputUUID at index {idx}"
|
|
3958
|
+
)
|
|
3959
|
+
elif source_ident not in HEALTH_SAMPLE_SOURCE_ACTIONS:
|
|
3960
|
+
errors.append(
|
|
3961
|
+
f"Get Details of Health Sample should reference Find Health Samples/Log Health Sample output at index {idx}"
|
|
3962
|
+
)
|
|
3963
|
+
|
|
3964
|
+
if ident == HEALTH_LOG_SAMPLE_ACTION:
|
|
3965
|
+
sample_type = params.get("WFQuantitySampleType")
|
|
3966
|
+
if _token_param_is_empty(sample_type):
|
|
3967
|
+
errors.append(f"Log Health Sample missing WFQuantitySampleType at index {idx}")
|
|
3968
|
+
elif (
|
|
3969
|
+
isinstance(sample_type, str)
|
|
3970
|
+
and HEALTH_REFERENCE_SETS["sample_types"]
|
|
3971
|
+
and sample_type not in HEALTH_REFERENCE_SETS["sample_types"]
|
|
3972
|
+
):
|
|
3973
|
+
errors.append(
|
|
3974
|
+
f"Log Health Sample uses unknown WFQuantitySampleType '{sample_type}' at index {idx}"
|
|
3975
|
+
)
|
|
3976
|
+
|
|
3977
|
+
has_quantity = "WFQuantitySampleQuantity" in params
|
|
3978
|
+
has_category = (
|
|
3979
|
+
"WFCategorySampleEnumeration" in params
|
|
3980
|
+
or "WFCategorySampleAdditionalEnumerationKey" in params
|
|
3981
|
+
)
|
|
3982
|
+
if not has_quantity and not has_category:
|
|
3983
|
+
errors.append(
|
|
3984
|
+
f"Log Health Sample missing sample value: use WFQuantitySampleQuantity or WFCategorySampleEnumeration at index {idx}"
|
|
3985
|
+
)
|
|
3986
|
+
|
|
3987
|
+
for key in HEALTH_QUANTITY_FIELD_KEYS:
|
|
3988
|
+
if key not in params:
|
|
3989
|
+
continue
|
|
3990
|
+
require_magnitude = key in HEALTH_REQUIRED_MAIN_QUANTITY_KEYS
|
|
3991
|
+
if key == "WFQuantitySampleAdditionalQuantity":
|
|
3992
|
+
# The bundled Caffeine XML example includes only Unit for
|
|
3993
|
+
# the secondary quantity field, so Magnitude stays optional.
|
|
3994
|
+
require_magnitude = False
|
|
3995
|
+
_validate_health_quantity_field(
|
|
3996
|
+
params.get(key),
|
|
3997
|
+
key=key,
|
|
3998
|
+
idx=idx,
|
|
3999
|
+
errors=errors,
|
|
4000
|
+
require_magnitude=require_magnitude,
|
|
4001
|
+
)
|
|
4002
|
+
|
|
4003
|
+
for key in (
|
|
4004
|
+
"WFCategorySampleEnumeration",
|
|
4005
|
+
"WFCategorySampleAdditionalEnumerationKey",
|
|
4006
|
+
"WFQuantitySampleAdditionalEnumeration",
|
|
4007
|
+
):
|
|
4008
|
+
if key in params and _token_param_is_empty(params.get(key)):
|
|
4009
|
+
errors.append(f"Log Health Sample {key} is empty at index {idx}")
|
|
4010
|
+
elif (
|
|
4011
|
+
key in params
|
|
4012
|
+
and isinstance(params.get(key), str)
|
|
4013
|
+
and HEALTH_REFERENCE_SETS["category_values"]
|
|
4014
|
+
and params.get(key) not in HEALTH_REFERENCE_SETS["category_values"]
|
|
4015
|
+
):
|
|
4016
|
+
errors.append(
|
|
4017
|
+
f"Log Health Sample {key} uses unknown category value '{params.get(key)}' at index {idx}"
|
|
4018
|
+
)
|
|
4019
|
+
|
|
4020
|
+
for key in ("WFQuantitySampleDate", "WFSampleEndDate"):
|
|
4021
|
+
if key in params:
|
|
4022
|
+
_validate_health_date_like(params.get(key), key=key, idx=idx, errors=errors)
|
|
4023
|
+
|
|
4024
|
+
if ident == HEALTH_LOG_WORKOUT_ACTION:
|
|
4025
|
+
workout_type = params.get("WFWorkoutReadableActivityType")
|
|
4026
|
+
if _token_param_is_empty(workout_type):
|
|
4027
|
+
errors.append(f"Log Workout missing WFWorkoutReadableActivityType at index {idx}")
|
|
4028
|
+
elif (
|
|
4029
|
+
isinstance(workout_type, str)
|
|
4030
|
+
and HEALTH_REFERENCE_SETS["workouts"]
|
|
4031
|
+
and workout_type not in HEALTH_REFERENCE_SETS["workouts"]
|
|
4032
|
+
):
|
|
4033
|
+
errors.append(
|
|
4034
|
+
f"Log Workout uses unknown WFWorkoutReadableActivityType '{workout_type}' at index {idx}"
|
|
4035
|
+
)
|
|
4036
|
+
for key in ("WFWorkoutDate",):
|
|
4037
|
+
if key in params:
|
|
4038
|
+
_validate_health_date_like(params.get(key), key=key, idx=idx, errors=errors)
|
|
4039
|
+
for key in ("WFWorkoutDuration", "WFWorkoutCaloriesQuantity", "WFWorkoutDistanceQuantity"):
|
|
4040
|
+
if key in params:
|
|
4041
|
+
_validate_health_quantity_field(
|
|
4042
|
+
params.get(key),
|
|
4043
|
+
key=key,
|
|
4044
|
+
idx=idx,
|
|
4045
|
+
errors=errors,
|
|
4046
|
+
require_magnitude=True,
|
|
4047
|
+
)
|
|
4048
|
+
|
|
4049
|
+
if ident == "is.workflow.actions.text.translate":
|
|
4050
|
+
if "WFInputText" not in params:
|
|
4051
|
+
if "WFInput" in params:
|
|
4052
|
+
errors.append(
|
|
4053
|
+
f"Translate Text should use WFInputText (not WFInput) at index {idx}"
|
|
4054
|
+
)
|
|
4055
|
+
else:
|
|
4056
|
+
errors.append(f"Translate Text missing WFInputText at index {idx}")
|
|
4057
|
+
lang = params.get("WFSelectedLanguage")
|
|
4058
|
+
if lang is None or lang == "":
|
|
4059
|
+
errors.append(f"Translate Text missing WFSelectedLanguage at index {idx}")
|
|
4060
|
+
elif _lang_value_is_code(lang):
|
|
4061
|
+
errors.append(
|
|
4062
|
+
f"Translate Text WFSelectedLanguage should use display name (not code) at index {idx}"
|
|
4063
|
+
)
|
|
4064
|
+
|
|
4065
|
+
if ident == "is.workflow.actions.ask":
|
|
4066
|
+
if "WFAskActionDefaultAnswer" in params:
|
|
4067
|
+
default = params.get("WFAskActionDefaultAnswer")
|
|
4068
|
+
if default is None or (isinstance(default, str) and default.strip() == ""):
|
|
4069
|
+
errors.append(f"Ask for Input has empty default answer at index {idx}; omit WFAskActionDefaultAnswer")
|
|
4070
|
+
|
|
4071
|
+
if ident == "is.workflow.actions.setvariable":
|
|
4072
|
+
wfinput = params.get("WFInput")
|
|
4073
|
+
if not wfinput:
|
|
4074
|
+
errors.append(f"Set Variable missing WFInput at index {idx}")
|
|
4075
|
+
elif not _input_is_attached(wfinput):
|
|
4076
|
+
errors.append(f"Set Variable WFInput is not a token attachment at index {idx}")
|
|
4077
|
+
|
|
4078
|
+
if ident == "is.workflow.actions.sendmessage":
|
|
4079
|
+
# Send Message content should be sent via an appended variable list,
|
|
4080
|
+
# even for single-type payloads, to preserve the importable list shape.
|
|
4081
|
+
content = params.get("WFSendMessageContent")
|
|
4082
|
+
attachments = params.get("WFSendMessageAttachments")
|
|
4083
|
+
if not content and not attachments and not params.get("WFInput"):
|
|
4084
|
+
errors.append(f"Send Message missing content/attachments at index {idx}")
|
|
4085
|
+
if content and attachments:
|
|
4086
|
+
errors.append(
|
|
4087
|
+
f"Send Message mixes content + attachments; use Append Variable list and pass single variable at index {idx}"
|
|
4088
|
+
)
|
|
4089
|
+
if isinstance(content, dict):
|
|
4090
|
+
name = _extract_input_variable_name(content)
|
|
4091
|
+
if name and append_counts.get(name, 0) < 2:
|
|
4092
|
+
errors.append(
|
|
4093
|
+
f"Send Message content variable '{name}' should be built with Append Variable (2+ appends, even for single-type content) at index {idx}"
|
|
4094
|
+
)
|
|
4095
|
+
# Disallow ActionOutput attachments for Send Message content
|
|
4096
|
+
out_uuids = _input_action_output_uuids(content)
|
|
4097
|
+
if out_uuids:
|
|
4098
|
+
errors.append(
|
|
4099
|
+
f"Send Message content should reference a named variable (not ActionOutput) at index {idx}"
|
|
4100
|
+
)
|
|
4101
|
+
# Require WFTextTokenString for variable content to avoid blank UI
|
|
4102
|
+
if content.get("WFSerializationType") == "WFTextTokenAttachment":
|
|
4103
|
+
errors.append(
|
|
4104
|
+
f"Send Message content should use WFTextTokenString with variable placeholder at index {idx}"
|
|
4105
|
+
)
|
|
4106
|
+
|
|
4107
|
+
if ident == "is.workflow.actions.sendemail":
|
|
4108
|
+
for key in (params or {}).keys():
|
|
4109
|
+
if key.startswith("WFSendMailAction"):
|
|
4110
|
+
errors.append(
|
|
4111
|
+
f"Send Email uses legacy SendMail parameter keys; use WFSendEmailAction* at index {idx}"
|
|
4112
|
+
)
|
|
4113
|
+
|
|
4114
|
+
if ident == "is.workflow.actions.setitemname":
|
|
4115
|
+
wf_input = params.get("WFInput")
|
|
4116
|
+
if _token_param_is_empty(wf_input):
|
|
4117
|
+
errors.append(f"Set Name missing WFInput at index {idx}")
|
|
4118
|
+
elif not _input_is_attached(wf_input):
|
|
4119
|
+
errors.append(f"Set Name WFInput is not a token attachment at index {idx}")
|
|
4120
|
+
elif not _input_has_reference(wf_input):
|
|
4121
|
+
errors.append(f"Set Name WFInput has no variable/output reference at index {idx}")
|
|
4122
|
+
else:
|
|
4123
|
+
for out_uuid in _input_action_output_uuids(wf_input):
|
|
4124
|
+
if out_uuid not in uuid_to_ident:
|
|
4125
|
+
errors.append(f"Set Name WFInput references unknown OutputUUID at index {idx}")
|
|
4126
|
+
|
|
4127
|
+
if _token_param_is_empty(params.get("WFName")):
|
|
4128
|
+
errors.append(f"Set Name missing WFName at index {idx}")
|
|
4129
|
+
|
|
4130
|
+
if ident == "is.workflow.actions.file.rename":
|
|
4131
|
+
wf_file = params.get("WFFile")
|
|
4132
|
+
if _token_param_is_empty(wf_file):
|
|
4133
|
+
errors.append(f"Rename File missing WFFile at index {idx}")
|
|
4134
|
+
elif not _input_is_attached(wf_file):
|
|
4135
|
+
errors.append(f"Rename File WFFile is not a token attachment at index {idx}")
|
|
4136
|
+
elif not _input_has_reference(wf_file):
|
|
4137
|
+
errors.append(f"Rename File WFFile has no variable/output reference at index {idx}")
|
|
4138
|
+
else:
|
|
4139
|
+
for out_uuid in _input_action_output_uuids(wf_file):
|
|
4140
|
+
if out_uuid not in uuid_to_ident:
|
|
4141
|
+
errors.append(f"Rename File WFFile references unknown OutputUUID at index {idx}")
|
|
4142
|
+
else:
|
|
4143
|
+
rename_file_source_uuids[out_uuid] = idx
|
|
4144
|
+
for var_name in _extract_input_variable_names(wf_file):
|
|
4145
|
+
rename_file_source_vars[var_name] = idx
|
|
4146
|
+
|
|
4147
|
+
if _token_param_is_empty(params.get("WFNewFilename")):
|
|
4148
|
+
errors.append(f"Rename File missing WFNewFilename at index {idx}")
|
|
4149
|
+
|
|
4150
|
+
rename_uuid = params.get("UUID")
|
|
4151
|
+
if isinstance(rename_uuid, str) and rename_uuid.strip():
|
|
4152
|
+
rename_file_output_uuids[rename_uuid] = idx
|
|
4153
|
+
|
|
4154
|
+
if ident == "is.workflow.actions.format.date":
|
|
4155
|
+
if not params.get("WFDate"):
|
|
4156
|
+
errors.append(f"Format Date missing WFDate at index {idx}")
|
|
4157
|
+
else:
|
|
4158
|
+
wfdate = params.get("WFDate")
|
|
4159
|
+
if not _input_is_editor_visible(wfdate):
|
|
4160
|
+
errors.append(f"Format Date WFDate should use WFTextTokenString with placeholder at index {idx}")
|
|
4161
|
+
for out_uuid in _input_action_output_uuids(wfdate):
|
|
4162
|
+
source_ident = uuid_to_ident.get(out_uuid)
|
|
4163
|
+
source_params = uuid_to_params.get(out_uuid, {})
|
|
4164
|
+
source_prop = source_params.get("WFContentItemPropertyName")
|
|
4165
|
+
if (
|
|
4166
|
+
source_ident == "is.workflow.actions.properties.weather.conditions"
|
|
4167
|
+
and source_prop in WEATHER_DETAIL_LIST_VALUES
|
|
4168
|
+
):
|
|
4169
|
+
expected = "First Item" if source_prop == "Sunrise Time" else "Last Item"
|
|
4170
|
+
errors.append(
|
|
4171
|
+
f"{source_prop} returns a list; use Get Item from List ({expected}) before Format Date at index {idx}"
|
|
4172
|
+
)
|
|
4173
|
+
if params.get("WFDateFormat") == "Custom" and not params.get("WFDateFormatString"):
|
|
4174
|
+
errors.append(f"Format Date custom format is empty at index {idx}")
|
|
4175
|
+
if params.get("WFDateFormat") == "Custom" and params.get("WFDateFormatString"):
|
|
4176
|
+
if params.get("WFDateFormatStyle") != "Custom":
|
|
4177
|
+
errors.append(f"Format Date custom style must be set to Custom at index {idx}")
|
|
4178
|
+
custom_name = params.get("CustomOutputName")
|
|
4179
|
+
enforce_custom_style = custom_name in {"Start Date", "End Date"}
|
|
4180
|
+
if params.get("WFDateFormat") == "Custom":
|
|
4181
|
+
fmt = params.get("WFDateFormatString", "")
|
|
4182
|
+
if enforce_custom_style:
|
|
4183
|
+
if fmt and not allow_datetime_format and re.search(r"[HhmsZ]|'T'", fmt):
|
|
4184
|
+
errors.append(
|
|
4185
|
+
f"Custom date format includes time; use date-only or add ALLOW_DATETIME_FORMAT at index {idx}"
|
|
4186
|
+
)
|
|
4187
|
+
if "00:00:00" in fmt or "23:59:59" in fmt:
|
|
4188
|
+
errors.append(
|
|
4189
|
+
f"Custom date format hardcodes start/end of day; use date-only yyyy-MM-dd at index {idx}"
|
|
4190
|
+
)
|
|
4191
|
+
|
|
4192
|
+
# Heuristic: Start/End Date variables should use Custom format (API-friendly)
|
|
4193
|
+
if ident == "is.workflow.actions.setvariable":
|
|
4194
|
+
name = params.get("WFVariableName")
|
|
4195
|
+
if isinstance(name, str) and name.strip() and name in {"Start Date", "End Date"}:
|
|
4196
|
+
fmt_params = var_format_dates.get(name)
|
|
4197
|
+
if fmt_params is not None:
|
|
4198
|
+
fmt_key = fmt_params.get("WFDateFormat")
|
|
4199
|
+
fmt_str = fmt_params.get("WFDateFormatString", "")
|
|
4200
|
+
# Accept Custom or a direct date-only format (e.g., yyyy-MM-dd)
|
|
4201
|
+
effective = fmt_str if fmt_key == "Custom" else (fmt_key or fmt_str)
|
|
4202
|
+
if not allow_datetime_format and isinstance(effective, str) and re.search(r"[HhmsZ]|'T'", effective):
|
|
4203
|
+
errors.append(f"{name} should use date-only format (yyyy-MM-dd)")
|
|
4204
|
+
|
|
4205
|
+
if ident in DESTRUCTIVE_FILE_ACTIONS:
|
|
4206
|
+
wfinput = params.get("WFInput")
|
|
4207
|
+
destructive_name = DESTRUCTIVE_FILE_ACTIONS[ident]
|
|
4208
|
+
for var_name in _extract_input_variable_names(wfinput):
|
|
4209
|
+
rename_idx = rename_file_source_vars.get(var_name)
|
|
4210
|
+
if rename_idx is not None:
|
|
4211
|
+
errors.append(
|
|
4212
|
+
f"{destructive_name} reuses variable '{var_name}' after Rename File at index {rename_idx}; Rename File changes the original file in place, so use Set Name (is.workflow.actions.setitemname) for renamed-copy workflows at index {idx}"
|
|
4213
|
+
)
|
|
4214
|
+
for out_uuid in _input_action_output_uuids(wfinput):
|
|
4215
|
+
rename_idx = rename_file_source_uuids.get(out_uuid)
|
|
4216
|
+
if rename_idx is not None:
|
|
4217
|
+
errors.append(
|
|
4218
|
+
f"{destructive_name} reuses the same file output after Rename File at index {rename_idx}; Rename File changes the original file in place, so use Set Name (is.workflow.actions.setitemname) for renamed-copy workflows at index {idx}"
|
|
4219
|
+
)
|
|
4220
|
+
|
|
4221
|
+
if ident in RENAMED_COPY_OUTPUT_ACTIONS:
|
|
4222
|
+
wfinput = params.get("WFInput")
|
|
4223
|
+
consumer_name = RENAMED_COPY_OUTPUT_ACTIONS[ident]
|
|
4224
|
+
for out_uuid in _input_action_output_uuids(wfinput):
|
|
4225
|
+
rename_idx = rename_file_output_uuids.get(out_uuid)
|
|
4226
|
+
if rename_idx is not None:
|
|
4227
|
+
errors.append(
|
|
4228
|
+
f"{consumer_name} consumes Rename File output from index {rename_idx}; Rename File mutates the original file in place. Use Set Name (is.workflow.actions.setitemname with WFInput and WFName) to create a renamed file object for saving or sharing at index {idx}"
|
|
4229
|
+
)
|
|
4230
|
+
|
|
4231
|
+
if ident == "is.workflow.actions.math":
|
|
4232
|
+
if not params.get("WFMathOperand"):
|
|
4233
|
+
errors.append(f"Math action missing WFMathOperand at index {idx}")
|
|
4234
|
+
elif not _math_operand_has_reference(params.get("WFMathOperand")):
|
|
4235
|
+
errors.append(f"Math operand is empty or unreferenced at index {idx}")
|
|
4236
|
+
# If the math input is a variable, ensure it exists
|
|
4237
|
+
wfinput = params.get("WFInput")
|
|
4238
|
+
var_name = _extract_input_variable_name(wfinput)
|
|
4239
|
+
if var_name and var_name not in defined_vars and var_name not in BUILTIN_VARIABLES:
|
|
4240
|
+
errors.append(f"Math input references undefined variable '{var_name}' at index {idx}")
|
|
4241
|
+
input_name = var_name
|
|
4242
|
+
if input_name is None and isinstance(wfinput, dict):
|
|
4243
|
+
val = wfinput.get("Value")
|
|
4244
|
+
if isinstance(val, dict) and val.get("Type") == "ActionOutput":
|
|
4245
|
+
out_uuid = val.get("OutputUUID")
|
|
4246
|
+
if out_uuid in uuid_to_var_name:
|
|
4247
|
+
input_name = uuid_to_var_name[out_uuid]
|
|
4248
|
+
else:
|
|
4249
|
+
output_name = val.get("OutputName")
|
|
4250
|
+
if isinstance(output_name, str):
|
|
4251
|
+
input_name = output_name
|
|
4252
|
+
op = params.get("WFMathOperation")
|
|
4253
|
+
if op == "+":
|
|
4254
|
+
errors.append(
|
|
4255
|
+
f"Math action uses WFMathOperation '+' at index {idx}; omit WFMathOperation for addition"
|
|
4256
|
+
)
|
|
4257
|
+
elif op == "*":
|
|
4258
|
+
errors.append(
|
|
4259
|
+
f"Math action uses ASCII '*' at index {idx}; use '×' (U+00D7) for multiplication"
|
|
4260
|
+
)
|
|
4261
|
+
elif op == "/":
|
|
4262
|
+
errors.append(
|
|
4263
|
+
f"Math action uses ASCII '/' at index {idx}; use '÷' (U+00F7) for division"
|
|
4264
|
+
)
|
|
4265
|
+
elif op is not None and op not in MATH_OPERATIONS:
|
|
4266
|
+
errors.append(f"Math action has unknown WFMathOperation at index {idx}: {op!r}")
|
|
4267
|
+
operand = params.get("WFMathOperand")
|
|
4268
|
+
literal_operand = _math_operand_literal_string(operand)
|
|
4269
|
+
if (
|
|
4270
|
+
op in {"/", "÷"}
|
|
4271
|
+
and literal_operand == "60"
|
|
4272
|
+
and input_name
|
|
4273
|
+
and "sleep" in input_name.lower()
|
|
4274
|
+
and uuid
|
|
4275
|
+
):
|
|
4276
|
+
sleep_duration_divide_by_60_uuids.add(uuid)
|
|
4277
|
+
if (
|
|
4278
|
+
op in {"/", "÷"}
|
|
4279
|
+
and literal_operand == "1000"
|
|
4280
|
+
and input_name
|
|
4281
|
+
and "distance" in input_name.lower()
|
|
4282
|
+
):
|
|
4283
|
+
errors.append(
|
|
4284
|
+
f"Health distance math divides '{input_name}' by 1000 at index {idx}; "
|
|
4285
|
+
"do not assume Walking + Running Distance values are meters. Sum the Health Value directly or use Convert Measurement with an explicit source unit"
|
|
4286
|
+
)
|
|
4287
|
+
is_100 = False
|
|
4288
|
+
if isinstance(operand, int) and operand == 100:
|
|
4289
|
+
is_100 = True
|
|
4290
|
+
elif isinstance(operand, dict):
|
|
4291
|
+
val = operand.get("Value")
|
|
4292
|
+
if isinstance(val, dict):
|
|
4293
|
+
if isinstance(val.get("string"), str) and val.get("string").strip() == "100":
|
|
4294
|
+
is_100 = True
|
|
4295
|
+
if op == "/" and is_100:
|
|
4296
|
+
divide_by_100_uuids.add(uuid)
|
|
4297
|
+
if input_name:
|
|
4298
|
+
divide_by_100_vars.add(input_name)
|
|
4299
|
+
# If operating on cents with literal 100, must use divide
|
|
4300
|
+
if is_100 and input_name and "cents" in input_name.lower() and op not in {"/", "÷"}:
|
|
4301
|
+
errors.append(f"Math uses {op} with 100 on cents; must divide by 100 at index {idx}")
|
|
4302
|
+
if is_100 and cents_context_steps > 0 and op not in {"/", "÷"}:
|
|
4303
|
+
errors.append(f"Math uses {op} with 100 in cents context; must divide by 100 at index {idx}")
|
|
4304
|
+
|
|
4305
|
+
if ident == "is.workflow.actions.round":
|
|
4306
|
+
wfinput = params.get("WFInput")
|
|
4307
|
+
if not wfinput:
|
|
4308
|
+
errors.append(f"Round Number missing WFInput at index {idx}")
|
|
4309
|
+
else:
|
|
4310
|
+
for out_uuid in _input_action_output_uuids(wfinput):
|
|
4311
|
+
if out_uuid in sleep_duration_divide_by_60_uuids and uuid:
|
|
4312
|
+
sleep_duration_divide_by_60_round_uuids.add(uuid)
|
|
4313
|
+
used_divide = False
|
|
4314
|
+
for out_uuid in _input_action_output_uuids(wfinput):
|
|
4315
|
+
if out_uuid in divide_by_100_uuids:
|
|
4316
|
+
used_divide = True
|
|
4317
|
+
break
|
|
4318
|
+
if not used_divide:
|
|
4319
|
+
var_name = _extract_input_variable_name(wfinput)
|
|
4320
|
+
if var_name and "cents" in var_name.lower():
|
|
4321
|
+
errors.append(f"Round used on cents without divide-by-100 at index {idx}")
|
|
4322
|
+
|
|
4323
|
+
if ident == "is.workflow.actions.adjustdate":
|
|
4324
|
+
date_keys = ("WFDate", "WFInput")
|
|
4325
|
+
non_empty_date_refs: list[tuple[str, object]] = []
|
|
4326
|
+
for key in date_keys:
|
|
4327
|
+
if key not in params:
|
|
4328
|
+
continue
|
|
4329
|
+
value = params.get(key)
|
|
4330
|
+
if _token_param_is_empty(value):
|
|
4331
|
+
errors.append(f"Adjust Date has empty {key} at index {idx}")
|
|
4332
|
+
else:
|
|
4333
|
+
non_empty_date_refs.append((key, value))
|
|
4334
|
+
|
|
4335
|
+
if not non_empty_date_refs:
|
|
4336
|
+
errors.append(f"Adjust Date missing WFDate/WFInput at index {idx}")
|
|
4337
|
+
else:
|
|
4338
|
+
for key, reference in non_empty_date_refs:
|
|
4339
|
+
if not _input_is_attached(reference):
|
|
4340
|
+
errors.append(f"Adjust Date {key} is not a token attachment at index {idx}")
|
|
4341
|
+
continue
|
|
4342
|
+
if not _input_has_reference(reference):
|
|
4343
|
+
errors.append(f"Adjust Date {key} has no variable/output reference at index {idx}")
|
|
4344
|
+
continue
|
|
4345
|
+
for out_uuid in _input_action_output_uuids(reference):
|
|
4346
|
+
if out_uuid not in uuid_to_ident:
|
|
4347
|
+
errors.append(f"Adjust Date {key} references unknown OutputUUID at index {idx}")
|
|
4348
|
+
|
|
4349
|
+
has_offset_picker = "WFAdjustOffsetPicker" in params
|
|
4350
|
+
has_legacy_op = "WFAdjustOperation" in params
|
|
4351
|
+
has_legacy_duration = "WFDuration" in params
|
|
4352
|
+
if not has_legacy_duration:
|
|
4353
|
+
if has_offset_picker:
|
|
4354
|
+
errors.append(
|
|
4355
|
+
f"Adjust Date offset-picker-only payload is unreliable on iOS import; include WFDuration at index {idx}"
|
|
4356
|
+
)
|
|
4357
|
+
else:
|
|
4358
|
+
errors.append(f"Adjust Date missing WFDuration at index {idx}")
|
|
4359
|
+
if has_offset_picker:
|
|
4360
|
+
offset = params.get("WFAdjustOffsetPicker")
|
|
4361
|
+
if not isinstance(offset, dict) or offset.get("WFSerializationType") != "WFTimeOffsetValue":
|
|
4362
|
+
errors.append(
|
|
4363
|
+
f"Adjust Date WFAdjustOffsetPicker must be WFTimeOffsetValue at index {idx}"
|
|
4364
|
+
)
|
|
4365
|
+
if has_legacy_op:
|
|
4366
|
+
op = params.get("WFAdjustOperation")
|
|
4367
|
+
if _token_param_is_empty(op):
|
|
4368
|
+
errors.append(f"Adjust Date has empty WFAdjustOperation at index {idx}")
|
|
4369
|
+
elif isinstance(op, str) and op not in {"Add", "Subtract"}:
|
|
4370
|
+
errors.append(f"Adjust Date WFAdjustOperation should be Add or Subtract at index {idx}")
|
|
4371
|
+
if has_legacy_duration:
|
|
4372
|
+
duration = params.get("WFDuration")
|
|
4373
|
+
if duration is None:
|
|
4374
|
+
errors.append(f"Adjust Date missing WFDuration at index {idx}")
|
|
4375
|
+
elif not isinstance(duration, dict):
|
|
4376
|
+
errors.append(f"Adjust Date WFDuration must be a dict at index {idx}")
|
|
4377
|
+
else:
|
|
4378
|
+
if duration.get("WFSerializationType") != "WFQuantityFieldValue":
|
|
4379
|
+
errors.append(
|
|
4380
|
+
f"Adjust Date WFDuration must use WFQuantityFieldValue serialization at index {idx}"
|
|
4381
|
+
)
|
|
4382
|
+
dur_val = duration.get("Value")
|
|
4383
|
+
if not isinstance(dur_val, dict):
|
|
4384
|
+
errors.append(f"Adjust Date WFDuration missing Value dict at index {idx}")
|
|
4385
|
+
else:
|
|
4386
|
+
magnitude = dur_val.get("Magnitude")
|
|
4387
|
+
unit = dur_val.get("Unit")
|
|
4388
|
+
if magnitude is None or (isinstance(magnitude, str) and magnitude.strip() == ""):
|
|
4389
|
+
errors.append(f"Adjust Date WFDuration missing Magnitude at index {idx}")
|
|
4390
|
+
if not isinstance(unit, str) or unit.strip() == "":
|
|
4391
|
+
errors.append(f"Adjust Date WFDuration missing Unit at index {idx}")
|
|
4392
|
+
elif unit.strip().lower() not in DATE_DELTA_UNITS:
|
|
4393
|
+
errors.append(f"Adjust Date WFDuration has unsupported Unit '{unit}' at index {idx}")
|
|
4394
|
+
|
|
4395
|
+
if errors and first_error is None:
|
|
4396
|
+
first_error = (idx, ident or "UNKNOWN", _snippet(params))
|
|
4397
|
+
|
|
4398
|
+
if cents_context_steps > 0:
|
|
4399
|
+
cents_context_steps -= 1
|
|
4400
|
+
|
|
4401
|
+
# If cents variables exist but no divide-by-100 conversion is present, flag it
|
|
4402
|
+
for name in sorted(cents_vars):
|
|
4403
|
+
if name not in divide_by_100_vars:
|
|
4404
|
+
errors.append(f"Cents variable '{name}' is never divided by 100 (missing cents→dollars conversion)")
|
|
4405
|
+
if first_error is None:
|
|
4406
|
+
first_error = (0, "VALIDATION", "Missing cents→dollars conversion")
|
|
4407
|
+
|
|
4408
|
+
return errors, first_error
|
|
4409
|
+
|
|
4410
|
+
|
|
4411
|
+
def main() -> int:
|
|
4412
|
+
parser = argparse.ArgumentParser(description="Validate Shortcuts output for empty params and unknown actions")
|
|
4413
|
+
parser.add_argument("shortcut", help="Path to .xml or .shortcut file")
|
|
4414
|
+
parser.add_argument(
|
|
4415
|
+
"--target-macos",
|
|
4416
|
+
default=None,
|
|
4417
|
+
help=(
|
|
4418
|
+
"Target macOS major version for bundled ToolKit availability. "
|
|
4419
|
+
"Use 26, 27, auto (default), or latest/all to include every packaged snapshot."
|
|
4420
|
+
),
|
|
4421
|
+
)
|
|
4422
|
+
parser.add_argument(
|
|
4423
|
+
"--target-platform",
|
|
4424
|
+
default=None,
|
|
4425
|
+
help=(
|
|
4426
|
+
"Target platform for bundled ToolKit availability. "
|
|
4427
|
+
"Use macos (default), ios/ipados, or all to include every packaged platform."
|
|
4428
|
+
),
|
|
4429
|
+
)
|
|
4430
|
+
args = parser.parse_args()
|
|
4431
|
+
|
|
4432
|
+
shortcut_path = Path(args.shortcut).expanduser().resolve()
|
|
4433
|
+
if not shortcut_path.exists():
|
|
4434
|
+
print(f"File not found: {shortcut_path}", file=sys.stderr)
|
|
4435
|
+
return 1
|
|
4436
|
+
|
|
4437
|
+
skill_dir = Path(__file__).resolve().parents[1]
|
|
4438
|
+
target_macos_major = resolve_target_macos_major(args.target_macos)
|
|
4439
|
+
target_platform = resolve_target_platform(args.target_platform)
|
|
4440
|
+
allowed_ids = load_allowed_ids(skill_dir, target_macos_major, target_platform)
|
|
4441
|
+
unavailable_ids = load_future_toolkit_id_reasons(
|
|
4442
|
+
skill_dir,
|
|
4443
|
+
target_macos_major,
|
|
4444
|
+
target_platform,
|
|
4445
|
+
)
|
|
4446
|
+
unavailable_parameter_keys = load_future_parameter_key_reasons(target_macos_major)
|
|
4447
|
+
toolkit_parameter_schemas = load_toolkit_parameter_schemas(
|
|
4448
|
+
skill_dir,
|
|
4449
|
+
target_macos_major,
|
|
4450
|
+
target_platform,
|
|
4451
|
+
)
|
|
4452
|
+
toolkit_parameter_enum_cases = load_toolkit_parameter_enum_cases(
|
|
4453
|
+
skill_dir,
|
|
4454
|
+
target_macos_major,
|
|
4455
|
+
target_platform,
|
|
4456
|
+
)
|
|
4457
|
+
toolkit_parameter_boolean_keys = load_toolkit_parameter_boolean_keys(
|
|
4458
|
+
skill_dir,
|
|
4459
|
+
target_macos_major,
|
|
4460
|
+
target_platform,
|
|
4461
|
+
)
|
|
4462
|
+
workflow_trigger_catalog = load_workflow_trigger_catalog(
|
|
4463
|
+
skill_dir,
|
|
4464
|
+
target_macos_major,
|
|
4465
|
+
target_platform,
|
|
4466
|
+
)
|
|
4467
|
+
allowed_glyph_ids, allowed_icon_colors = load_icon_metadata(skill_dir)
|
|
4468
|
+
|
|
4469
|
+
try:
|
|
4470
|
+
plist = load_plist(shortcut_path)
|
|
4471
|
+
except Exception as e:
|
|
4472
|
+
print(f"Failed to read plist: {e}", file=sys.stderr)
|
|
4473
|
+
return 1
|
|
4474
|
+
|
|
4475
|
+
errors, first_error = validate(
|
|
4476
|
+
plist,
|
|
4477
|
+
allowed_ids,
|
|
4478
|
+
allowed_glyph_ids,
|
|
4479
|
+
allowed_icon_colors,
|
|
4480
|
+
unavailable_ids,
|
|
4481
|
+
unavailable_parameter_keys,
|
|
4482
|
+
toolkit_parameter_schemas,
|
|
4483
|
+
toolkit_parameter_enum_cases,
|
|
4484
|
+
toolkit_parameter_boolean_keys,
|
|
4485
|
+
workflow_trigger_catalog,
|
|
4486
|
+
target_macos_major,
|
|
4487
|
+
)
|
|
4488
|
+
|
|
4489
|
+
# Repeating-hex UUID check (agent placeholder detection). Runs on the raw
|
|
4490
|
+
# file text so we catch UUIDs in WFWorkflowActionParameters.UUID,
|
|
4491
|
+
# OutputUUID references, GroupingIdentifier, and anywhere else a repeating
|
|
4492
|
+
# placeholder could hide. Deduplicated so we surface each offending UUID
|
|
4493
|
+
# once with a single remediation hint.
|
|
4494
|
+
try:
|
|
4495
|
+
raw_text = shortcut_path.read_text(encoding="utf-8")
|
|
4496
|
+
except (OSError, UnicodeDecodeError):
|
|
4497
|
+
raw_text = ""
|
|
4498
|
+
repeating_uuids = sorted({
|
|
4499
|
+
match.group(0) for match in REPEATING_UUID_RE.finditer(raw_text)
|
|
4500
|
+
})
|
|
4501
|
+
if repeating_uuids:
|
|
4502
|
+
examples = ", ".join(repeating_uuids[:3])
|
|
4503
|
+
more = f" (+{len(repeating_uuids) - 3} more)" if len(repeating_uuids) > 3 else ""
|
|
4504
|
+
errors.append(
|
|
4505
|
+
f"Found {len(repeating_uuids)} repeating-hex placeholder UUID(s): {examples}{more}. "
|
|
4506
|
+
f"Every action UUID must be generated via `uuidgen | tr '[:lower:]' '[:upper:]'`, "
|
|
4507
|
+
f"not hand-picked sequences. Re-mint ALL offending UUIDs and update every "
|
|
4508
|
+
f"OutputUUID / GroupingIdentifier reference to match."
|
|
4509
|
+
)
|
|
4510
|
+
|
|
4511
|
+
if errors:
|
|
4512
|
+
print("Validation failed:\n")
|
|
4513
|
+
if first_error:
|
|
4514
|
+
idx, ident, snippet = first_error
|
|
4515
|
+
print(f"First failing action: index {idx} ({ident})")
|
|
4516
|
+
print(f"Snippet: {snippet}\n")
|
|
4517
|
+
for err in errors:
|
|
4518
|
+
print(f"- {err}")
|
|
4519
|
+
return 1
|
|
4520
|
+
|
|
4521
|
+
print("Validation passed.")
|
|
4522
|
+
return 0
|
|
4523
|
+
|
|
4524
|
+
|
|
4525
|
+
if __name__ == "__main__":
|
|
4526
|
+
raise SystemExit(main())
|