shortcutkit 2.0.0__tar.gz → 3.1.0__tar.gz

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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shortcutkit
3
- Version: 2.0.0
4
- Summary: Build, validate, and sign Apple Shortcuts files from Python. All 449 built-in actions and 1,318 Apple app actions, extracted from the Shortcuts engine.
3
+ Version: 3.1.0
4
+ Summary: Build, validate, and sign Apple Shortcuts files from Python. All 449 built-in actions and 1,265 Apple app actions, extracted from the Shortcuts engine.
5
5
  Author: frontboat
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/frontboat/shortcutkit
@@ -51,9 +51,9 @@ are archived ([python-shortcuts](https://github.com/alexander-akhmetov/python-sh
51
51
  [Cherri](https://github.com/electrikmilk/cherri) is a separate language with its own compiler.
52
52
 
53
53
  - All 449 built-in actions as constants, so a typo is an `AttributeError`.
54
- - 1,318 actions from Apple's apps: Reminders, Notes, Messages, Settings, and 70 more. Only
54
+ - 1,265 actions from Apple's apps: Reminders, Notes, Messages, Settings, and 70 more. Only
55
55
  actions the Shortcuts editor offers: the Siri-only intents the app refuses to import are left out.
56
- - Third-party app actions, given their descriptor.
56
+ - Third-party app actions, named by app and intent.
57
57
  - Checked conditions for 150 Find actions, the 14 built-in ones (Filter Files, Find Photos, Find
58
58
  Contacts, ...) and 136 in Apple's apps (Find Messages, Find Notes, ...), from the engine's own
59
59
  rules, and the properties of what they find.
@@ -114,20 +114,38 @@ found = s.action(actions.MESSAGES_FIND_MESSAGE,
114
114
  s.action(actions.SHOWRESULT, Text=text(property_(found, "body")))
115
115
  ```
116
116
 
117
+ ### Dictionaries and types
118
+
119
+ A dictionary parameter takes a plain dict: strings, `text()` and references are text items, and
120
+ numbers, booleans, lists and nested dicts keep their type, as the editor's item type menu sets it.
121
+ `coerce()` reads a value as one content type, as the variable "Type" menu does, and `property_()` on the
122
+ result takes that class's properties, the menu's "Get" list (`CONTENT_ITEM_PROPERTIES`):
123
+ `property_(coerce(file, "WFGenericFileContentItem"), "File Size")`, checked at run time. An output the catalogue types, by
124
+ class or by the object class it wraps (`CONTENT_ITEM_TYPES`), is checked the same way without a coercion:
125
+ `property_(asked_number, "Name")`. Where a choice decides the output, the literal you pass narrows it:
126
+ `s.action(actions.ASK, WFInputType="Number")` outputs a Number, so only Name is accepted.
127
+
128
+ ```python
129
+ from shortcutkit import coerce
130
+
131
+ d = s.action(actions.DICTIONARY, WFItems={"name": ref(name), "greeted": True, "tags": ["a", "b"]})
132
+ gid = s.if_(coerce(d, "WFStringContentItem"), "contains", "a")
133
+ s.end_if(gid)
134
+ ```
135
+
117
136
  ### Other apps
118
137
 
119
- Pass the identifier and descriptor yourself; the parameters are not checked.
138
+ Name the app and the intent: the identifier is the bundle identifier followed by the intent name, as
139
+ the catalogue identifies an app's own intents, and the descriptor is written as the catalogue's are.
140
+ The parameters are not checked: their keys are the intent's parameter names, from
141
+ `Metadata.appintents/extract.actionsdata` in the app bundle.
120
142
 
121
143
  ```python
122
- s.action(
123
- "com.example.app.CreateNoteIntent",
124
- AppIntentDescriptor={
125
- "BundleIdentifier": "com.example.app", "Name": "Example", "TeamIdentifier": "ABCDE12345",
126
- "AppIntentIdentifier": "CreateNoteIntent", "ActionRequiresAppInstallation": True,
127
- },
128
- title=text("Hello"),
129
- body=ref(name),
130
- )
144
+ from shortcutkit import app_intent
145
+
146
+ app = {"BundleIdentifier": "com.example.app", "Name": "Example", "TeamIdentifier": "ABCDE12345"}
147
+ note = s.action(app_intent(app, "CreateNoteIntent", "Note"), title=text("Hello"), body=ref(name))
148
+ s.action(actions.SHOWRESULT, Text=ref(note))
131
149
  ```
132
150
 
133
151
  ## API
@@ -149,8 +167,12 @@ s.action(
149
167
  | `shortcut_input()`, `clipboard()`, `current_date()`, `ask()`, `device_details()`, `current_app()` | Special variables. |
150
168
  | `where(*rows)`, `where_any(*rows)` | Conditions for a Find action's `WFContentItemFilter`. |
151
169
  | `property_(source, name)` | One property of an action's output or a variable. |
152
- | `FILTERS`, `ENTITY_PROPERTIES`, `FILTER_OPERATORS`, `CALENDAR_UNITS` | What each Find action can filter on, and each entity type's properties. |
153
- | `actions`, `ACTIONS`, `get_action(id)` | Action identifiers and their catalog entries. |
170
+ | `coerce(source, class)` | A value read as one content type, the editor's "Type" menu (`CONTENT_ITEM_CLASSES`). |
171
+ | `dictionary(items)` | A dictionary parameter's items; `action()` encodes a plain dict itself. |
172
+ | `app_intent(app, intent, output=None)` | Another app's action, for `s.action()`. |
173
+ | `FILTERS`, `ENTITY_PROPERTIES`, `FILTER_OPERATORS`, `CALENDAR_UNITS`, `CONTENT_ITEM_CLASSES` | What each Find action can filter on, each entity type's properties, and the content types a coercion can name. |
174
+ | `actions`, `ACTIONS`, `get_action(id)` | Action identifiers and their catalog entries: parameters and kinds, output and input types, keywords, description, flags, and the rules under which the editor shows each parameter. |
175
+ | `accepted_by(type)`, `produced_by(type)` | Which actions take a content type as input or as an entity parameter, and which output it. |
154
176
  | `PARAM_KINDS`, `PARAM_CHOICES`, `PARAM_VARIABLE_TYPES` | What each parameter accepts. |
155
177
  | `CONDITION`, `ICON_COLORS`, `PROVENANCE` | If conditions, icon colors, and the build the data came from. |
156
178
 
@@ -30,9 +30,9 @@ are archived ([python-shortcuts](https://github.com/alexander-akhmetov/python-sh
30
30
  [Cherri](https://github.com/electrikmilk/cherri) is a separate language with its own compiler.
31
31
 
32
32
  - All 449 built-in actions as constants, so a typo is an `AttributeError`.
33
- - 1,318 actions from Apple's apps: Reminders, Notes, Messages, Settings, and 70 more. Only
33
+ - 1,265 actions from Apple's apps: Reminders, Notes, Messages, Settings, and 70 more. Only
34
34
  actions the Shortcuts editor offers: the Siri-only intents the app refuses to import are left out.
35
- - Third-party app actions, given their descriptor.
35
+ - Third-party app actions, named by app and intent.
36
36
  - Checked conditions for 150 Find actions, the 14 built-in ones (Filter Files, Find Photos, Find
37
37
  Contacts, ...) and 136 in Apple's apps (Find Messages, Find Notes, ...), from the engine's own
38
38
  rules, and the properties of what they find.
@@ -93,20 +93,38 @@ found = s.action(actions.MESSAGES_FIND_MESSAGE,
93
93
  s.action(actions.SHOWRESULT, Text=text(property_(found, "body")))
94
94
  ```
95
95
 
96
+ ### Dictionaries and types
97
+
98
+ A dictionary parameter takes a plain dict: strings, `text()` and references are text items, and
99
+ numbers, booleans, lists and nested dicts keep their type, as the editor's item type menu sets it.
100
+ `coerce()` reads a value as one content type, as the variable "Type" menu does, and `property_()` on the
101
+ result takes that class's properties, the menu's "Get" list (`CONTENT_ITEM_PROPERTIES`):
102
+ `property_(coerce(file, "WFGenericFileContentItem"), "File Size")`, checked at run time. An output the catalogue types, by
103
+ class or by the object class it wraps (`CONTENT_ITEM_TYPES`), is checked the same way without a coercion:
104
+ `property_(asked_number, "Name")`. Where a choice decides the output, the literal you pass narrows it:
105
+ `s.action(actions.ASK, WFInputType="Number")` outputs a Number, so only Name is accepted.
106
+
107
+ ```python
108
+ from shortcutkit import coerce
109
+
110
+ d = s.action(actions.DICTIONARY, WFItems={"name": ref(name), "greeted": True, "tags": ["a", "b"]})
111
+ gid = s.if_(coerce(d, "WFStringContentItem"), "contains", "a")
112
+ s.end_if(gid)
113
+ ```
114
+
96
115
  ### Other apps
97
116
 
98
- Pass the identifier and descriptor yourself; the parameters are not checked.
117
+ Name the app and the intent: the identifier is the bundle identifier followed by the intent name, as
118
+ the catalogue identifies an app's own intents, and the descriptor is written as the catalogue's are.
119
+ The parameters are not checked: their keys are the intent's parameter names, from
120
+ `Metadata.appintents/extract.actionsdata` in the app bundle.
99
121
 
100
122
  ```python
101
- s.action(
102
- "com.example.app.CreateNoteIntent",
103
- AppIntentDescriptor={
104
- "BundleIdentifier": "com.example.app", "Name": "Example", "TeamIdentifier": "ABCDE12345",
105
- "AppIntentIdentifier": "CreateNoteIntent", "ActionRequiresAppInstallation": True,
106
- },
107
- title=text("Hello"),
108
- body=ref(name),
109
- )
123
+ from shortcutkit import app_intent
124
+
125
+ app = {"BundleIdentifier": "com.example.app", "Name": "Example", "TeamIdentifier": "ABCDE12345"}
126
+ note = s.action(app_intent(app, "CreateNoteIntent", "Note"), title=text("Hello"), body=ref(name))
127
+ s.action(actions.SHOWRESULT, Text=ref(note))
110
128
  ```
111
129
 
112
130
  ## API
@@ -128,8 +146,12 @@ s.action(
128
146
  | `shortcut_input()`, `clipboard()`, `current_date()`, `ask()`, `device_details()`, `current_app()` | Special variables. |
129
147
  | `where(*rows)`, `where_any(*rows)` | Conditions for a Find action's `WFContentItemFilter`. |
130
148
  | `property_(source, name)` | One property of an action's output or a variable. |
131
- | `FILTERS`, `ENTITY_PROPERTIES`, `FILTER_OPERATORS`, `CALENDAR_UNITS` | What each Find action can filter on, and each entity type's properties. |
132
- | `actions`, `ACTIONS`, `get_action(id)` | Action identifiers and their catalog entries. |
149
+ | `coerce(source, class)` | A value read as one content type, the editor's "Type" menu (`CONTENT_ITEM_CLASSES`). |
150
+ | `dictionary(items)` | A dictionary parameter's items; `action()` encodes a plain dict itself. |
151
+ | `app_intent(app, intent, output=None)` | Another app's action, for `s.action()`. |
152
+ | `FILTERS`, `ENTITY_PROPERTIES`, `FILTER_OPERATORS`, `CALENDAR_UNITS`, `CONTENT_ITEM_CLASSES` | What each Find action can filter on, each entity type's properties, and the content types a coercion can name. |
153
+ | `actions`, `ACTIONS`, `get_action(id)` | Action identifiers and their catalog entries: parameters and kinds, output and input types, keywords, description, flags, and the rules under which the editor shows each parameter. |
154
+ | `accepted_by(type)`, `produced_by(type)` | Which actions take a content type as input or as an entity parameter, and which output it. |
133
155
  | `PARAM_KINDS`, `PARAM_CHOICES`, `PARAM_VARIABLE_TYPES` | What each parameter accepts. |
134
156
  | `CONDITION`, `ICON_COLORS`, `PROVENANCE` | If conditions, icon colors, and the build the data came from. |
135
157
 
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "shortcutkit"
7
- version = "2.0.0"
8
- description = "Build, validate, and sign Apple Shortcuts files from Python. All 449 built-in actions and 1,318 Apple app actions, extracted from the Shortcuts engine."
7
+ version = "3.1.0"
8
+ description = "Build, validate, and sign Apple Shortcuts files from Python. All 449 built-in actions and 1,265 Apple app actions, extracted from the Shortcuts engine."
9
9
  requires-python = ">=3.9"
10
10
  readme = "README.md"
11
11
  license = "MIT"
@@ -26,7 +26,10 @@ Value helpers return the exact structures WorkflowKit's state classes serialize:
26
26
  ask("Prompt") WFTextTokenAttachment -> ask each time
27
27
  text("a ", ref(x)) WFTextTokenString -> text with embedded references
28
28
  subject(att) {"Type": "Variable", "Variable": att} (the If condition's subject; pickers take the bare attachment)
29
- property_(x, "body") a reference to one property of x (a WFPropertyVariableAggrandizement)
29
+ coerce(x, "WFStringContentItem") x read as one content type (a WFCoercionVariableAggrandizement; CONTENT_ITEM_CLASSES)
30
+ property_(x, "body") one property of x (a WFPropertyVariableAggrandizement; ENTITY_PROPERTIES, CONTENT_ITEM_PROPERTIES)
31
+ dictionary({...}) WFDictionaryFieldValue -> a dictionary parameter's items, typed as the editor types them
32
+ app_intent(app, "CreateNoteIntent") another app's action, for action(): its identifier and descriptor
30
33
  where(row, ...) Find conditions, encoded by action() for the Find action's entity
31
34
  Plain str / int / float / bool / list / datetime are written as-is.
32
35
  """
@@ -186,18 +189,110 @@ def subject(attachment):
186
189
 
187
190
 
188
191
  def property_(source, name):
189
- """One property of an action's output or of any reference, as the editor's property picker writes
190
- it (a WFPropertyVariableAggrandizement): property_(found, "body"), property_(repeat_item(), "date").
191
- Given an action whose output is one of Apple's entity types, the name must be one of its
192
- properties (ENTITY_PROPERTIES)."""
192
+ """One property of an action's output or of any reference, as the editor's property picker (the "Get" list
193
+ under a variable's "Type") writes it (a WFPropertyVariableAggrandizement): property_(found, "body"),
194
+ property_(repeat_item(), "date"), property_(coerce(file, "WFGenericFileContentItem"), "File Size").
195
+ When the output's type is known, one of Apple's entity types (ENTITY_PROPERTIES) or a content item class
196
+ (CONTENT_ITEM_PROPERTIES: named directly, or as the object class the definition names, NSDecimalNumber being a
197
+ Number, through CONTENT_ITEM_TYPES; also what a coerce()d reference has), the name must be one of its properties."""
198
+ if _is_reference(source):
199
+ cls = _coerced_class(source)
200
+ types = [cls] if cls else []
201
+ else:
202
+ declared = (source["_outputClasses"] + [t for t in source.get("_outputTypes", []) if t in ENTITY_PROPERTIES]) if source.get("_outputClasses") else source.get("_outputTypes", [])
203
+ types = list(dict.fromkeys(t if t in ENTITY_PROPERTIES else _item_class_of(t) for t in declared))
204
+ types = [t for t in types if t]
205
+ known = {p for t in types for p in (ENTITY_PROPERTIES.get(t) or CONTENT_ITEM_PROPERTIES.get(t) or [])}
206
+ if types and name not in known:
207
+ raise ValueError(f"{' / '.join(types)} has no property {name}; it has {', '.join(sorted(known))}")
208
+ return _aggrandize(source, {"Type": "WFPropertyVariableAggrandizement", "PropertyName": name})
209
+
210
+
211
+ def _item_class_of(type_name):
212
+ """An output type name as a content item class: itself when it is one, else the class that owns it (CONTENT_ITEM_TYPES)."""
213
+ return type_name if type_name in CONTENT_ITEM_PROPERTIES else CONTENT_ITEM_TYPES.get(type_name)
214
+
215
+
216
+ def _coerced_class(attachment):
217
+ """The content item class a reference was last coerced to, when it is one the engine lists; else None."""
218
+ ags = attachment["Value"].get("Aggrandizements")
219
+ last = ags[-1] if isinstance(ags, list) and ags else None
220
+ cls = last.get("CoercionItemClass") if isinstance(last, dict) and last.get("Type") == "WFCoercionVariableAggrandizement" else None
221
+ return cls if cls in CONTENT_ITEM_PROPERTIES else None
222
+
223
+
224
+ def _aggrandize(source, aggrandizement):
225
+ """`source` with one more aggrandizement; an action stands for its output."""
193
226
  if not _is_reference(source):
194
- entities = [t for t in source.get("_outputTypes", []) if t in ENTITY_PROPERTIES]
195
- known = {p for e in entities for p in ENTITY_PROPERTIES[e]}
196
- if entities and name not in known:
197
- raise ValueError(f"{' / '.join(entities)} has no property {name}; it has {', '.join(sorted(known))}")
198
227
  source = ref(source)
199
228
  prior = source["Value"].get("Aggrandizements")
200
- return _attachment({**source["Value"], "Aggrandizements": [*(prior if isinstance(prior, list) else []), {"Type": "WFPropertyVariableAggrandizement", "PropertyName": name}]})
229
+ return _attachment({**source["Value"], "Aggrandizements": [*(prior if isinstance(prior, list) else []), aggrandizement]})
230
+
231
+
232
+ def coerce(source, to):
233
+ """`source` read as one content type, as the editor's variable "Type" menu writes it (a
234
+ WFCoercionVariableAggrandizement): coerce(ref(json), "WFDictionaryContentItem"), coerce(repeat_item(), "WFStringContentItem").
235
+ The classes are the engine's own list (CONTENT_ITEM_CLASSES, each with the name the editor shows). An If whose
236
+ subject's type the engine cannot tell (a dictionary value, a repeat item) needs one: without it "is" has nothing
237
+ to compare against and the run fails (a downstream user's observation; docs/extraction.md, run-time observations)."""
238
+ if to not in CONTENT_ITEM_CLASSES:
239
+ raise ValueError(f"no content item class {to}; the engine coerces to {', '.join(CONTENT_ITEM_CLASSES)}")
240
+ return _aggrandize(source, {"Type": "WFCoercionVariableAggrandizement", "CoercionItemClass": to})
241
+
242
+
243
+ def dictionary(items):
244
+ """A dictionary parameter: Dictionary's items, Get Contents of URL's headers, form and JSON bodies. Strings,
245
+ text() and references are text items; numbers, booleans, lists and nested dicts keep their type, as the editor's
246
+ item type menu sets it: dictionary({"apikey": variable("Key"), "count": 5, "nested": {"ok": True}, "tags": ["a", "b"]}).
247
+ action() encodes a plain dict given for a dictionary key itself, so the call is needed only where the key is untyped."""
248
+ return {"WFSerializationType": "WFDictionaryFieldValue",
249
+ "Value": {"WFDictionaryFieldValueItems": [{"WFKey": text(k), **_dictionary_item(v)} for k, v in items.items()]}}
250
+
251
+
252
+ def _is_dictionary_items(v):
253
+ """A plain dict of items for a dictionary parameter (not an encoded value)."""
254
+ return isinstance(v, dict) and "WFSerializationType" not in v and not (v.get("Type") == "Variable" and "Variable" in v)
255
+
256
+
257
+ def _dictionary_item(v):
258
+ """One item's type code and value, as the dictionary state class writes them back (docs/shortcut-file-format.md §3)."""
259
+ if isinstance(v, str):
260
+ return {"WFItemType": 0, "WFValue": text(v)}
261
+ if isinstance(v, dict) and v.get("WFSerializationType") == "WFTextTokenString":
262
+ return {"WFItemType": 0, "WFValue": v}
263
+ if isinstance(v, dict) and v.get("WFSerializationType") == "WFTextTokenAttachment":
264
+ return {"WFItemType": 0, "WFValue": text(v)}
265
+ if isinstance(v, bool):
266
+ return {"WFItemType": 4, "WFValue": {"WFSerializationType": "WFNumberSubstitutableState", "Value": v}}
267
+ if isinstance(v, (int, float)):
268
+ return {"WFItemType": 3, "WFValue": text(_number_text(v))}
269
+ # Array items carry no key; plain text is the bare string, as the engine writes it back.
270
+ if isinstance(v, (list, tuple)):
271
+ return {"WFItemType": 2, "WFValue": {"WFSerializationType": "WFArrayParameterState", "Value": [x if isinstance(x, str) else _dictionary_item(x) for x in v]}}
272
+ # A nested dictionary is wrapped once more than the outer one (the engine serializes it so, and reads the unwrapped form into that).
273
+ if isinstance(v, dict) and v.get("WFSerializationType") == "WFDictionaryFieldValue":
274
+ return {"WFItemType": 1, "WFValue": {"WFSerializationType": "WFDictionaryFieldValue", "Value": v}}
275
+ if _is_dictionary_items(v):
276
+ return {"WFItemType": 1, "WFValue": {"WFSerializationType": "WFDictionaryFieldValue", "Value": dictionary(v)}}
277
+ raise ValueError(f"dictionary(): a value must be a str, text(), a reference, a number, a bool, a list or a dict, got {v!r}")
278
+
279
+
280
+ def _number_text(n):
281
+ """A number as JavaScript's String() writes it, so both packages write the same item (5, not 5.0)."""
282
+ if isinstance(n, float) and n.is_integer():
283
+ return str(int(n))
284
+ return repr(n) if isinstance(n, float) else str(n)
285
+
286
+
287
+ def app_intent(app, intent, output=None):
288
+ """Name an App Intents action of an app the catalogue does not cover, for action():
289
+ s.action(app_intent(app, "CreateNoteIntent", "Note"), title=text("Hi")). `app` holds BundleIdentifier, Name and
290
+ TeamIdentifier (the bundle's Info.plist and `codesign -dv`); the identifier is the bundle identifier followed by the
291
+ intent's name, as the catalogue identifies an app's own intents, and the descriptor is written as the catalogue's are
292
+ (an intent hosted in a framework or extension has another identifier, which action() still takes by hand with an
293
+ AppIntentDescriptor). `output` names the result for ref() ("Output" when left out). The parameters are not checked: their keys are the intent's parameter names, as
294
+ Metadata.appintents/extract.actionsdata in the app bundle lists them."""
295
+ return {"identifier": f"{app['BundleIdentifier']}.{intent}", "descriptor": {**app, "AppIntentIdentifier": intent}, "output": output}
201
296
 
202
297
 
203
298
  def where(*rows):
@@ -322,7 +417,6 @@ def _utc(d):
322
417
 
323
418
  def picker(attachment):
324
419
  """Deprecated, misnamed: this is the If-subject form. Pass a bare reference to picker parameters."""
325
- """Wrap an attachment for WFVariablePickerParameter keys (e.g. WFInput on Repeat with Each)."""
326
420
  return {"Type": "Variable", "Variable": attachment}
327
421
 
328
422
 
@@ -360,14 +454,17 @@ class Shortcut:
360
454
  def action(self, identifier, /, **params):
361
455
  """Append an action and return it; ref() it to use its output. Catalog identifiers are
362
456
  checked: unknown keys, wrong value kinds, and unreadable references raise ValueError. Other
363
- identifiers pass through unchecked. `identifier` is positional-only, so a parameter can
364
- have that name."""
457
+ identifiers pass through unchecked; app_intent() names another app's action with its descriptor.
458
+ `identifier` is positional-only, so a parameter can have that name."""
459
+ intent = identifier if isinstance(identifier, dict) else None
460
+ if intent:
461
+ identifier = intent["identifier"]
365
462
  definition = self.defs.get(identifier)
366
463
  catalog = ACTIONS.get(identifier) or {}
367
- descriptor = catalog.get("descriptor")
464
+ descriptor = catalog.get("descriptor") or (intent or {}).get("descriptor")
368
465
  if self.defs and definition is None and not catalog and identifier.startswith("is.workflow."):
369
466
  raise ValueError(f"unknown built-in action {identifier}")
370
- if definition or descriptor:
467
+ if definition or catalog.get("descriptor"):
371
468
  known = {p.get("Key") for p in (definition or {}).get("Parameters", []) if isinstance(p, dict)}
372
469
  known |= {"UUID", "GroupingIdentifier", "WFControlFlowMode", "CustomOutputName"}
373
470
  known |= LEGACY_KEYS.get(identifier, set())
@@ -383,7 +480,9 @@ class Shortcut:
383
480
  kinds = PARAM_KINDS.get(identifier, {})
384
481
  restricted = PARAM_VARIABLE_TYPES.get(identifier, {})
385
482
  for key, value in params.items():
386
- if _is_where(value) and kinds.get(key) != "filter":
483
+ # where() rows belong to a Find action's filter key; an untyped action (another app's Find) is refused by
484
+ # _encode_filter(), which names the missing FILTERS entry.
485
+ if _is_where(value) and key in kinds and kinds[key] != "filter":
387
486
  raise ValueError(f"{identifier}.{key}: where() rows go only in a Find action's WFContentItemFilter")
388
487
  problem = _check_kind(kinds.get(key, "any"), value)
389
488
  if problem:
@@ -397,6 +496,7 @@ class Shortcut:
397
496
  # token string with one placeholder. Wrap it so ref() works for text keys too.
398
497
  params = {k: (text(v) if kinds.get(k) == "text" and _is_reference(v) and v.get("WFSerializationType") == "WFTextTokenAttachment"
399
498
  else subject(v) if kinds.get(k) == "subject" and _is_reference(v) and v.get("WFSerializationType") == "WFTextTokenAttachment"
499
+ else dictionary(v) if kinds.get(k) == "dictionary" and _is_dictionary_items(v)
400
500
  else _encode_filter(identifier, v) if _is_where(v) else v)
401
501
  for k, v in params.items()}
402
502
  # App Intents actions carry a descriptor naming the app that provides them.
@@ -406,8 +506,17 @@ class Shortcut:
406
506
  entry = {"WFWorkflowActionIdentifier": identifier,
407
507
  "WFWorkflowActionParameters": {**head, **params}}
408
508
  output = (definition or {}).get("Output", {})
409
- entry["_outputName"] = (output.get("OutputName") if isinstance(output, dict) else None) or catalog.get("output")
509
+ entry["_outputName"] = (output.get("OutputName") if isinstance(output, dict) else None) or catalog.get("output") or (intent or {}).get("output")
410
510
  entry["_outputTypes"] = list(catalog.get("outputTypes") or (output.get("Types") if isinstance(output, dict) else []) or [])
511
+ # The engine's answer to what comes out: for a choice that decides it (OUTPUT_CLASSES_BY), the literal given selects
512
+ # its classes and a reference means any of them; otherwise the classes for the default parameters.
513
+ by = OUTPUT_CLASSES_BY.get(identifier) if isinstance(identifier, str) else None
514
+ deciding = next((k for k in by if k in params), None) if by else None
515
+ if deciding:
516
+ v = params[deciding]
517
+ entry["_outputClasses"] = list(by[deciding][v]) if isinstance(v, str) and v in by[deciding] else sorted({c for cs in by[deciding].values() for c in cs})
518
+ elif catalog.get("outputClasses"):
519
+ entry["_outputClasses"] = list(catalog["outputClasses"])
411
520
  if isinstance(entry["_outputName"], dict):
412
521
  entry["_outputName"] = entry["_outputName"].get("format")
413
522
  self.actions.append(entry)
@@ -523,7 +632,11 @@ def get_action(identifier, definitions=DEFINITIONS):
523
632
  if entry is None:
524
633
  return None
525
634
  out = {"identifier": identifier, "name": entry["name"], "params": list(entry["params"]),
526
- "kinds": dict(PARAM_KINDS.get(identifier, {})), "output": entry.get("output"), "outputTypes": list(entry.get("outputTypes", []))}
635
+ "kinds": dict(PARAM_KINDS.get(identifier, {})), "output": entry.get("output"), "outputTypes": list(entry.get("outputTypes", [])),
636
+ "inputTypes": list(entry.get("inputTypes", [])), "keywords": list(entry.get("keywords", []))}
637
+ for k in ("outputClasses", "inputKey", "inputRequired", "description", "descriptionNote", "descriptionResult", "discontinued", "unavailable", "requiresUnlock", "disabledOn", "requires"):
638
+ if entry.get(k):
639
+ out[k] = copy.deepcopy(entry[k])
527
640
  if entry.get("descriptor"):
528
641
  out["descriptor"] = entry["descriptor"]
529
642
  defs = _load_definitions(definitions) if definitions else {}
@@ -532,6 +645,20 @@ def get_action(identifier, definitions=DEFINITIONS):
532
645
  return out
533
646
 
534
647
 
648
+ def accepted_by(type_name):
649
+ """Where a value of one content type can go: every built-in whose input takes it (with the key the input
650
+ lands in) and every App Intents parameter typed as that entity, as (identifier, key) pairs. Declared types
651
+ only; Shortcuts converts between many types at run time, so this is what the editor would list."""
652
+ out = [(i, e.get("inputKey") or "WFInput") for i, e in ACTIONS.items() if type_name in e.get("inputTypes", [])]
653
+ out += [(i, k) for i, keys in PARAM_ENTITY_TYPES.items() for k, t in keys.items() if t == type_name]
654
+ return out
655
+
656
+
657
+ def produced_by(type_name):
658
+ """Every action whose output is declared as this content type (ACTIONS[id]["outputTypes"])."""
659
+ return [i for i, e in ACTIONS.items() if type_name in e.get("outputTypes", []) or type_name in e.get("outputClasses", [])]
660
+
661
+
535
662
  def demo(out):
536
663
  out = pathlib.Path(out)
537
664
  s = Shortcut(out.stem, color="Teal")
@@ -550,13 +677,13 @@ def demo(out):
550
677
 
551
678
 
552
679
  from . import actions # noqa: E402 (generated identifier constants and ACTIONS metadata)
553
- from .actions import ACTIONS, PARAM_KINDS, PARAM_CHOICES, PARAM_VARIABLE_TYPES # noqa: E402
554
- from .entities import FILTERS, FILTER_OPERATORS, CALENDAR_UNITS, BYTE_COUNT_UNITS, BOUNDED_FILTERS, ENTITY_PROPERTIES # noqa: E402 (generated Find filters and entity properties)
680
+ from .actions import ACTIONS, PARAM_KINDS, PARAM_CHOICES, PARAM_VARIABLE_TYPES, PARAM_ENTITY_TYPES, OUTPUT_CLASSES_BY # noqa: E402
681
+ from .entities import FILTERS, FILTER_OPERATORS, CALENDAR_UNITS, BYTE_COUNT_UNITS, BOUNDED_FILTERS, ENTITY_PROPERTIES, CONTENT_ITEM_CLASSES, CONTENT_ITEM_PROPERTIES, CONTENT_ITEM_TYPES # noqa: E402 (generated Find filters, entity properties, content item classes, their properties and owned types)
555
682
 
556
683
  PROVENANCE = json.load(open(HERE / "data" / "provenance.json")) if (HERE / "data" / "provenance.json").exists() else {}
557
684
  """Which macOS and Shortcuts build the bundled data was extracted from."""
558
685
 
559
- __all__ = ["actions", "ACTIONS", "PARAM_KINDS", "PARAM_CHOICES", "PARAM_VARIABLE_TYPES", "PROVENANCE", "get_action", "repeat_item", "repeat_index", "subject", "Shortcut", "ref", "variable", "shortcut_input", "clipboard", "current_date", "ask", "device_details", "current_app", "picker", "text",
560
- "property_", "where", "where_any", "FILTERS", "FILTER_OPERATORS", "CALENDAR_UNITS", "BYTE_COUNT_UNITS", "BOUNDED_FILTERS", "ENTITY_PROPERTIES",
686
+ __all__ = ["actions", "ACTIONS", "PARAM_KINDS", "PARAM_CHOICES", "PARAM_VARIABLE_TYPES", "PROVENANCE", "get_action", "accepted_by", "produced_by", "PARAM_ENTITY_TYPES", "OUTPUT_CLASSES_BY", "repeat_item", "repeat_index", "subject", "Shortcut", "ref", "variable", "shortcut_input", "clipboard", "current_date", "ask", "device_details", "current_app", "picker", "text",
687
+ "property_", "coerce", "dictionary", "app_intent", "where", "where_any", "FILTERS", "FILTER_OPERATORS", "CALENDAR_UNITS", "BYTE_COUNT_UNITS", "BOUNDED_FILTERS", "ENTITY_PROPERTIES", "CONTENT_ITEM_CLASSES", "CONTENT_ITEM_PROPERTIES", "CONTENT_ITEM_TYPES",
561
688
  "ICON_COLORS", "CONDITION", "DEFAULT_GLYPH", "LEGACY_KEYS", "demo"]
562
- __version__ = "2.0.0"
689
+ __version__ = "3.1.0"