shortcutkit 0.1.0__py3-none-any.whl
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.
- shortcutkit/__init__.py +275 -0
- shortcutkit/__main__.py +7 -0
- shortcutkit/actions.py +1107 -0
- shortcutkit/data/builtin-actions.json +18974 -0
- shortcutkit/data/provenance.json +12 -0
- shortcutkit-0.1.0.dist-info/METADATA +145 -0
- shortcutkit-0.1.0.dist-info/RECORD +10 -0
- shortcutkit-0.1.0.dist-info/WHEEL +5 -0
- shortcutkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- shortcutkit-0.1.0.dist-info/top_level.txt +1 -0
shortcutkit/__init__.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""shortcutkit: build, validate and sign Apple Shortcuts files from Python.
|
|
3
|
+
|
|
4
|
+
The file format is the one Shortcuts.app uses (verified against Apple's gallery
|
|
5
|
+
workflows in WorkflowKit and against this Mac's library). Actions and parameter keys
|
|
6
|
+
are validated against _builtin-actions.json produced by extract-builtin-actions.sh.
|
|
7
|
+
|
|
8
|
+
from shortcutkit import Shortcut, ref, text, ask, shortcut_input, clipboard, variable
|
|
9
|
+
|
|
10
|
+
s = Shortcut("Demo", color="Teal", glyph=0xF000)
|
|
11
|
+
stored = s.action("is.workflow.actions.setstoredcontent",
|
|
12
|
+
WFInput="hello", WFStoredContentKey="greeting", WFStoredContentGlobalValue=False)
|
|
13
|
+
got = s.action("is.workflow.actions.getstoredcontent", WFStoredContentKey="greeting")
|
|
14
|
+
s.action("is.workflow.actions.showresult", Text=text("Stored: ", ref(got)))
|
|
15
|
+
s.write("Demo.shortcut") # unsigned plist
|
|
16
|
+
s.sign("Demo.shortcut", "Demo-signed.shortcut") # runs `shortcuts sign --mode anyone`
|
|
17
|
+
|
|
18
|
+
Command line: python -m shortcutkit demo OUT.shortcut (writes and signs the demo above)
|
|
19
|
+
|
|
20
|
+
Value helpers return the exact structures WorkflowKit's state classes serialize:
|
|
21
|
+
ref(action) WFTextTokenAttachment -> that action's output
|
|
22
|
+
variable("Name") WFTextTokenAttachment -> a named variable
|
|
23
|
+
shortcut_input() WFTextTokenAttachment -> the shortcut's input
|
|
24
|
+
clipboard() WFTextTokenAttachment -> clipboard
|
|
25
|
+
current_date() WFTextTokenAttachment -> current date
|
|
26
|
+
ask("Prompt") WFTextTokenAttachment -> ask each time
|
|
27
|
+
text("a ", ref(x)) WFTextTokenString -> text with embedded references
|
|
28
|
+
picker(att) {"Type": "Variable", "Variable": att} (variable-picker parameters)
|
|
29
|
+
Plain str / int / float / bool / list are written as-is.
|
|
30
|
+
"""
|
|
31
|
+
import json
|
|
32
|
+
import pathlib
|
|
33
|
+
import plistlib
|
|
34
|
+
import subprocess
|
|
35
|
+
import sys
|
|
36
|
+
import uuid
|
|
37
|
+
|
|
38
|
+
HERE = pathlib.Path(__file__).resolve().parent
|
|
39
|
+
DEFINITIONS = HERE / "data" / "builtin-actions.json"
|
|
40
|
+
|
|
41
|
+
# WFWorkflowIcon.backgroundColorValue for palette colors 0-14, as the unsigned 32-bit
|
|
42
|
+
# value the plist stores. Names follow Shortcuts' picker order.
|
|
43
|
+
ICON_COLORS = {
|
|
44
|
+
"Red": 4282601983, "DarkOrange": 4251333119, "Orange": 4271458815, "Yellow": 4274264319,
|
|
45
|
+
"Green": 4292093695, "Teal": 431817727, "LightBlue": 1440408063, "Blue": 463140863,
|
|
46
|
+
"DarkBlue": 946986751, "Violet": 2071128575, "Purple": 3679049983, "Pink": 3980825855,
|
|
47
|
+
"Taupe": 255, "Gray": 3031607807, "DarkGray": 2846468607,
|
|
48
|
+
}
|
|
49
|
+
DEFAULT_GLYPH = 61440 # +[WFWorkflowIcon defaultGlyphCharacter]
|
|
50
|
+
|
|
51
|
+
# Legacy WFCondition codes for is.workflow.actions.conditional. Shortcuts still reads these
|
|
52
|
+
# and migrates them to the modern WFConditions template on load. Codes marked (v) were
|
|
53
|
+
# verified against shortcuts on this Mac or Apple's gallery; the rest follow the same
|
|
54
|
+
# enumeration as documented by the community (shortcuts-js, Cherri).
|
|
55
|
+
CONDITION = {
|
|
56
|
+
"less_than": 0, # (v) numbers
|
|
57
|
+
"less_or_equal": 1,
|
|
58
|
+
"greater_than": 2, # (v) numbers
|
|
59
|
+
"greater_or_equal": 3,
|
|
60
|
+
"is": 4,
|
|
61
|
+
"is_not": 5,
|
|
62
|
+
"begins_with": 8,
|
|
63
|
+
"ends_with": 9,
|
|
64
|
+
"contains": 99, # (v) as filter Operator in the gallery
|
|
65
|
+
"has_any_value": 100, # (v) supportedComparisonOperators of every subject state
|
|
66
|
+
"has_no_value": 101, # (v)
|
|
67
|
+
"does_not_contain": 999,
|
|
68
|
+
"is_between": 1003,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Keys Shortcuts still accepts on load even though the current definition no longer lists
|
|
73
|
+
# them. Legacy If: single condition on WFInput, migrated to WFConditions by the app.
|
|
74
|
+
LEGACY_KEYS = {
|
|
75
|
+
"is.workflow.actions.conditional": {"WFInput", "WFCondition", "WFConditionalActionString", "WFNumberValue",
|
|
76
|
+
"WFConditionalLegacyComparisonBehavior", "WFEnumerationValue", "WFBooleanValue",
|
|
77
|
+
"WFDate", "WFAnotherDate", "WFDuration", "WFConditions"},
|
|
78
|
+
"is.workflow.actions.choosefrommenu": {"WFMenuPrompt", "WFMenuItems", "WFMenuItemTitle", "WFMenuItemAttributedTitle"},
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _is_reference(value):
|
|
83
|
+
"""Attachments, token strings and picker wrappers are accepted wherever a plain value is."""
|
|
84
|
+
return isinstance(value, dict) and ("WFSerializationType" in value or value.get("Type") == "Variable")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _check_kind(kind, value):
|
|
88
|
+
"""Run-time counterpart of the TypeScript ParamTypes: returns a problem string or None."""
|
|
89
|
+
if _is_reference(value):
|
|
90
|
+
return None
|
|
91
|
+
if kind == "bool" and not isinstance(value, bool):
|
|
92
|
+
return f"expected a bool or a reference, got {type(value).__name__}"
|
|
93
|
+
if kind in ("number", "plainNumber") and (isinstance(value, bool) or not isinstance(value, (int, float))):
|
|
94
|
+
return f"expected a number{'' if kind == 'plainNumber' else ' or a reference'}, got {type(value).__name__}"
|
|
95
|
+
if kind in ("string", "text", "plainString") and not isinstance(value, str):
|
|
96
|
+
return f"expected a string{'' if kind == 'plainString' else ' or a reference'}, got {type(value).__name__}"
|
|
97
|
+
if kind == "picker":
|
|
98
|
+
return "expected picker(<attachment>) or an attachment"
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _attachment(value):
|
|
103
|
+
return {"WFSerializationType": "WFTextTokenAttachment", "Value": value}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def ref(action, output_name=None):
|
|
107
|
+
"""Reference another action's output. `action` is the dict returned by Shortcut.action()."""
|
|
108
|
+
return _attachment({"Type": "ActionOutput", "OutputUUID": action["WFWorkflowActionParameters"]["UUID"],
|
|
109
|
+
"OutputName": output_name or action.get("_outputName", "Output")})
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def variable(name):
|
|
113
|
+
return _attachment({"Type": "Variable", "VariableName": name})
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def shortcut_input():
|
|
117
|
+
return _attachment({"Type": "ExtensionInput"})
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def clipboard():
|
|
121
|
+
return _attachment({"Type": "Clipboard"})
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def current_date():
|
|
125
|
+
return _attachment({"Type": "CurrentDate"})
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def ask(prompt=None):
|
|
129
|
+
return _attachment({"Type": "Ask", **({"Prompt": prompt} if prompt else {})})
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def picker(attachment):
|
|
133
|
+
"""Wrap an attachment for WFVariablePickerParameter keys (e.g. WFInput on Repeat with Each)."""
|
|
134
|
+
return {"Type": "Variable", "Variable": attachment}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def text(*parts):
|
|
138
|
+
"""A WFTextTokenString: strings and attachments interleaved. Each attachment becomes U+FFFC."""
|
|
139
|
+
string = ""
|
|
140
|
+
attachments = {}
|
|
141
|
+
for part in parts:
|
|
142
|
+
if isinstance(part, dict) and part.get("WFSerializationType") == "WFTextTokenAttachment":
|
|
143
|
+
# NSRange offsets are UTF-16 code units, not code points.
|
|
144
|
+
attachments[f"{{{len(string.encode('utf-16-le')) // 2}, 1}}"] = part["Value"]
|
|
145
|
+
string += ""
|
|
146
|
+
else:
|
|
147
|
+
string += str(part)
|
|
148
|
+
return {"WFSerializationType": "WFTextTokenString", "Value": {"string": string, "attachmentsByRange": attachments}}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class Shortcut:
|
|
152
|
+
def __init__(self, name, color="Blue", glyph=DEFAULT_GLYPH, input_classes=None, definitions=DEFINITIONS):
|
|
153
|
+
self.name = name
|
|
154
|
+
self.color = ICON_COLORS[color] if isinstance(color, str) else color
|
|
155
|
+
self.glyph = glyph
|
|
156
|
+
self.input_classes = input_classes or []
|
|
157
|
+
self.actions = []
|
|
158
|
+
self.defs = json.load(open(definitions)) if pathlib.Path(definitions).exists() else {}
|
|
159
|
+
|
|
160
|
+
def action(self, identifier, **params):
|
|
161
|
+
"""Append an action. Unknown identifiers or parameter keys raise ValueError."""
|
|
162
|
+
definition = self.defs.get(identifier)
|
|
163
|
+
if self.defs and definition is None and identifier.startswith("is.workflow."):
|
|
164
|
+
raise ValueError(f"unknown built-in action {identifier}")
|
|
165
|
+
if definition:
|
|
166
|
+
known = {p.get("Key") for p in definition.get("Parameters", []) if isinstance(p, dict)}
|
|
167
|
+
known |= {"UUID", "GroupingIdentifier", "WFControlFlowMode", "CustomOutputName"}
|
|
168
|
+
known |= LEGACY_KEYS.get(identifier, set())
|
|
169
|
+
inp = definition.get("Input")
|
|
170
|
+
if isinstance(inp, dict) and inp.get("ParameterKey"):
|
|
171
|
+
known.add(inp["ParameterKey"])
|
|
172
|
+
unknown = set(params) - known
|
|
173
|
+
if unknown and known - {"UUID", "GroupingIdentifier", "WFControlFlowMode", "CustomOutputName"}:
|
|
174
|
+
raise ValueError(f"{identifier}: unknown parameter(s) {sorted(unknown)}; known: {sorted(known)}")
|
|
175
|
+
for key, value in params.items():
|
|
176
|
+
problem = _check_kind(PARAM_KINDS.get(identifier, {}).get(key, "any"), value)
|
|
177
|
+
if problem:
|
|
178
|
+
raise ValueError(f"{identifier}.{key}: {problem}")
|
|
179
|
+
entry = {"WFWorkflowActionIdentifier": identifier,
|
|
180
|
+
"WFWorkflowActionParameters": {"UUID": str(uuid.uuid4()).upper(), **params}}
|
|
181
|
+
output = (definition or {}).get("Output", {})
|
|
182
|
+
entry["_outputName"] = output.get("OutputName") if isinstance(output, dict) else None
|
|
183
|
+
if isinstance(entry["_outputName"], dict):
|
|
184
|
+
entry["_outputName"] = entry["_outputName"].get("format")
|
|
185
|
+
self.actions.append(entry)
|
|
186
|
+
return entry
|
|
187
|
+
|
|
188
|
+
# Control flow: each block is a group sharing a GroupingIdentifier, with
|
|
189
|
+
# WFControlFlowMode 0 = start, 1 = middle (Otherwise / menu item), 2 = end.
|
|
190
|
+
def if_(self, subject, condition, value=None):
|
|
191
|
+
gid = str(uuid.uuid4()).upper()
|
|
192
|
+
params = {"GroupingIdentifier": gid, "WFControlFlowMode": 0, "WFInput": picker(subject),
|
|
193
|
+
"WFCondition": CONDITION[condition] if isinstance(condition, str) else condition}
|
|
194
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
195
|
+
params["WFNumberValue"] = value
|
|
196
|
+
elif value is not None:
|
|
197
|
+
params["WFConditionalActionString"] = value
|
|
198
|
+
self.action("is.workflow.actions.conditional", **params)
|
|
199
|
+
return gid
|
|
200
|
+
|
|
201
|
+
def otherwise(self, gid):
|
|
202
|
+
self.action("is.workflow.actions.conditional", GroupingIdentifier=gid, WFControlFlowMode=1)
|
|
203
|
+
|
|
204
|
+
def end_if(self, gid):
|
|
205
|
+
self.action("is.workflow.actions.conditional", GroupingIdentifier=gid, WFControlFlowMode=2)
|
|
206
|
+
|
|
207
|
+
def repeat_each(self, items):
|
|
208
|
+
"""Open a Repeat with Each block; returns the grouping identifier to pass to end_repeat_each()."""
|
|
209
|
+
gid = str(uuid.uuid4()).upper()
|
|
210
|
+
self.action("is.workflow.actions.repeat.each", GroupingIdentifier=gid, WFControlFlowMode=0, WFInput=picker(items))
|
|
211
|
+
return gid
|
|
212
|
+
|
|
213
|
+
def end_repeat_each(self, gid):
|
|
214
|
+
self.action("is.workflow.actions.repeat.each", GroupingIdentifier=gid, WFControlFlowMode=2)
|
|
215
|
+
|
|
216
|
+
def to_plist(self):
|
|
217
|
+
actions = []
|
|
218
|
+
for a in self.actions:
|
|
219
|
+
actions.append({k: v for k, v in a.items() if not k.startswith("_")})
|
|
220
|
+
return {
|
|
221
|
+
"WFWorkflowClientVersion": "4018.0.4",
|
|
222
|
+
"WFWorkflowMinimumClientVersion": 900,
|
|
223
|
+
"WFWorkflowMinimumClientVersionString": "900",
|
|
224
|
+
"WFWorkflowIcon": {"WFWorkflowIconStartColor": self.color, "WFWorkflowIconGlyphNumber": self.glyph},
|
|
225
|
+
"WFWorkflowTypes": [],
|
|
226
|
+
"WFQuickActionSurfaces": [],
|
|
227
|
+
"WFWorkflowInputContentItemClasses": self.input_classes,
|
|
228
|
+
"WFWorkflowOutputContentItemClasses": [],
|
|
229
|
+
"WFWorkflowImportQuestions": [],
|
|
230
|
+
"WFWorkflowHasShortcutInputVariables": any(
|
|
231
|
+
json.dumps(a).find('"ExtensionInput"') >= 0 for a in actions),
|
|
232
|
+
"WFWorkflowHasOutputFallback": False,
|
|
233
|
+
"WFWorkflowActions": actions,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
def write(self, path=None):
|
|
237
|
+
"""Write the unsigned plist. Shortcuts names an imported shortcut after its file, so the
|
|
238
|
+
default file name is the shortcut's name."""
|
|
239
|
+
path = pathlib.Path(path) if path else pathlib.Path(f"{self.name}.shortcut")
|
|
240
|
+
with open(path, "wb") as f:
|
|
241
|
+
plistlib.dump(self.to_plist(), f, fmt=plistlib.FMT_BINARY)
|
|
242
|
+
return path
|
|
243
|
+
|
|
244
|
+
@staticmethod
|
|
245
|
+
def sign(src, dst, mode="anyone"):
|
|
246
|
+
subprocess.run(["shortcuts", "sign", "--mode", mode, "--input", str(src), "--output", str(dst)], check=True)
|
|
247
|
+
return dst
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def demo(out):
|
|
251
|
+
out = pathlib.Path(out)
|
|
252
|
+
s = Shortcut(out.stem, color="Teal")
|
|
253
|
+
stored = s.action("is.workflow.actions.setstoredcontent", WFInput="hello from python",
|
|
254
|
+
WFStoredContentKey="demo-greeting", WFStoredContentGlobalValue=False)
|
|
255
|
+
got = s.action("is.workflow.actions.getstoredcontent", WFStoredContentKey="demo-greeting")
|
|
256
|
+
gid = s.if_(ref(got), "has_any_value")
|
|
257
|
+
s.action("is.workflow.actions.showresult", Text=text("Stored value: ", ref(got)))
|
|
258
|
+
s.otherwise(gid)
|
|
259
|
+
s.action("is.workflow.actions.showresult", Text=text("Nothing stored"))
|
|
260
|
+
s.end_if(gid)
|
|
261
|
+
unsigned = pathlib.Path(out).with_suffix(".unsigned.shortcut")
|
|
262
|
+
s.write(unsigned)
|
|
263
|
+
s.sign(unsigned, out)
|
|
264
|
+
print(f"wrote {out} ({len(s.actions)} actions)")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
from . import actions # noqa: E402 (generated identifier constants and ACTIONS metadata)
|
|
268
|
+
from .actions import ACTIONS, PARAM_KINDS # noqa: E402
|
|
269
|
+
|
|
270
|
+
PROVENANCE = json.load(open(HERE / "data" / "provenance.json")) if (HERE / "data" / "provenance.json").exists() else {}
|
|
271
|
+
"""Which macOS and Shortcuts build the bundled data was extracted from."""
|
|
272
|
+
|
|
273
|
+
__all__ = ["actions", "ACTIONS", "PARAM_KINDS", "PROVENANCE", "Shortcut", "ref", "variable", "shortcut_input", "clipboard", "current_date", "ask", "picker", "text",
|
|
274
|
+
"ICON_COLORS", "CONDITION", "DEFAULT_GLYPH", "LEGACY_KEYS", "demo"]
|
|
275
|
+
__version__ = "0.1.0"
|