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,597 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Resolve Shortcuts icon glyph + color from natural language.
|
|
3
|
+
|
|
4
|
+
Examples:
|
|
5
|
+
python3 scripts/select_shortcut_icon_color.py --prompt "Build a weather shortcut"
|
|
6
|
+
python3 scripts/select_shortcut_icon_color.py --prompt "Expense tracker" --icon "calculator" --color gold
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import re
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
DEFAULT_GLYPH = 61440 # shortcuts
|
|
19
|
+
DEFAULT_COLOR_VALUE = 431817727 # Teal
|
|
20
|
+
|
|
21
|
+
STOP_WORDS = {
|
|
22
|
+
"a",
|
|
23
|
+
"an",
|
|
24
|
+
"and",
|
|
25
|
+
"app",
|
|
26
|
+
"as",
|
|
27
|
+
"be",
|
|
28
|
+
"build",
|
|
29
|
+
"by",
|
|
30
|
+
"create",
|
|
31
|
+
"for",
|
|
32
|
+
"from",
|
|
33
|
+
"i",
|
|
34
|
+
"icon",
|
|
35
|
+
"in",
|
|
36
|
+
"is",
|
|
37
|
+
"it",
|
|
38
|
+
"make",
|
|
39
|
+
"my",
|
|
40
|
+
"of",
|
|
41
|
+
"on",
|
|
42
|
+
"or",
|
|
43
|
+
"please",
|
|
44
|
+
"set",
|
|
45
|
+
"shortcut",
|
|
46
|
+
"symbol",
|
|
47
|
+
"that",
|
|
48
|
+
"the",
|
|
49
|
+
"this",
|
|
50
|
+
"to",
|
|
51
|
+
"use",
|
|
52
|
+
"with",
|
|
53
|
+
"workflow",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
ICON_QUERY_PATTERNS = [
|
|
57
|
+
re.compile(
|
|
58
|
+
r"\b(?:use|set|choose|pick|with|make)\s+(?:a|an|the)?\s*([a-z0-9][a-z0-9 '&+./_-]{1,60}?)\s+(?:icon|glyph|symbol)\b",
|
|
59
|
+
re.I,
|
|
60
|
+
),
|
|
61
|
+
re.compile(
|
|
62
|
+
r"\b(?:icon|glyph|symbol)\s*(?:to|as|is|named|name)?\s*([a-z0-9][a-z0-9 '&+./_-]{1,60})\b",
|
|
63
|
+
re.I,
|
|
64
|
+
),
|
|
65
|
+
re.compile(r"\b(?:sf\s*symbol|sfsymbol|sf-symbol)\s*(?:to|as|is|named)?\s*([a-z0-9._+-]{1,60})\b", re.I),
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
GLYPH_NUMBER_PATTERNS = [
|
|
69
|
+
re.compile(r"\b(?:glyph|icon|symbol)\s*(?:number|id|code)?\s*#?\s*(\d{5})\b", re.I),
|
|
70
|
+
re.compile(r"\b#(\d{5})\b"),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
HEX_COLOR_RE = re.compile(r"#([0-9a-fA-F]{6})\b")
|
|
74
|
+
|
|
75
|
+
COLOR_HINTS = {
|
|
76
|
+
"Red": {"alert", "urgent", "danger", "critical", "alarm", "warning", "error", "security"},
|
|
77
|
+
"Orange": {"warning", "caution", "attention"},
|
|
78
|
+
"Yellow": {"sun", "bright", "sunny", "daylight"},
|
|
79
|
+
"Gold": {"money", "finance", "budget", "expense", "invoice", "payment", "bank", "billing", "crypto"},
|
|
80
|
+
"Green": {"health", "fitness", "run", "workout", "exercise", "wellness", "eco", "habit"},
|
|
81
|
+
"Teal": {"automation", "shortcut", "workflow", "routine"},
|
|
82
|
+
"Cyan": {"weather", "cloud", "sky", "water"},
|
|
83
|
+
"Blue": {"travel", "map", "navigation", "flight", "internet", "network", "weather"},
|
|
84
|
+
"Navy": {"business", "work", "professional", "productivity", "office"},
|
|
85
|
+
"Purple": {"ai", "assistant", "creative", "music", "podcast", "magic", "design"},
|
|
86
|
+
"Lavender": {"journal", "mindfulness", "dream", "calm"},
|
|
87
|
+
"Pink": {"social", "chat", "message", "family", "love", "photo"},
|
|
88
|
+
"Gray": {"system", "settings", "utility", "tools", "developer", "terminal", "debug"},
|
|
89
|
+
"Sage": {"notes", "organize", "documents", "files", "archive"},
|
|
90
|
+
"Tan": {"book", "read", "writing", "manual"},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
ICON_HINTS: dict[int, set[str]] = {
|
|
94
|
+
59714: {"weather", "forecast", "cloud", "sky"},
|
|
95
|
+
59715: {"rain", "storm", "precipitation", "weather"},
|
|
96
|
+
59395: {"expense", "expenses", "finance", "budget", "money", "invoice", "billing", "payment"},
|
|
97
|
+
59680: {"calculator", "calculate", "sum", "totals", "math"},
|
|
98
|
+
59412: {"translate", "translation", "language", "languages", "dictionary", "global", "international"},
|
|
99
|
+
61529: {"terminal", "shell", "bash", "zsh", "cli", "developer", "code", "script", "command"},
|
|
100
|
+
59414: {"message", "messages", "chat", "conversation", "text", "sms"},
|
|
101
|
+
59773: {"mail", "email", "inbox", "envelope"},
|
|
102
|
+
59682: {"camera", "photo", "picture", "snapshot", "image"},
|
|
103
|
+
61459: {"photos", "gallery", "album", "images"},
|
|
104
|
+
59790: {"music", "song", "audio", "playlist"},
|
|
105
|
+
59816: {"podcast", "podcasts", "episodes"},
|
|
106
|
+
59681: {"calendar", "date", "schedule", "event", "events"},
|
|
107
|
+
61587: {"task", "tasks", "todo", "reminder", "checklist"},
|
|
108
|
+
61444: {"map", "maps", "navigation", "route", "directions", "location"},
|
|
109
|
+
59648: {"flight", "airplane", "travel", "airport"},
|
|
110
|
+
59743: {"settings", "preferences", "config", "configuration"},
|
|
111
|
+
59749: {"tools", "tool", "utility", "maintenance"},
|
|
112
|
+
59870: {"fix", "repair", "debug", "troubleshoot"},
|
|
113
|
+
61566: {"ai", "assistant", "chatgpt", "llm", "robot", "agent"},
|
|
114
|
+
61581: {"magic", "sparkle", "generate", "rewrite", "enhance"},
|
|
115
|
+
59711: {"clipboard", "copy", "paste", "snippet"},
|
|
116
|
+
59819: {"qr", "qrcode", "scan", "scanner", "otp"},
|
|
117
|
+
59661: {"barcode", "sku", "inventory"},
|
|
118
|
+
59754: {"health", "heart", "wellness"},
|
|
119
|
+
59808: {"run", "running", "fitness", "workout", "exercise"},
|
|
120
|
+
59867: {"wifi", "network", "internet", "wireless"},
|
|
121
|
+
59489: {"battery", "power", "charge"},
|
|
122
|
+
59770: {"lock", "secure", "password", "security"},
|
|
123
|
+
62501: {"shield", "protection", "defense", "security"},
|
|
124
|
+
59725: {"document", "documents", "file", "files", "pdf"},
|
|
125
|
+
59737: {"folder", "folders", "files"},
|
|
126
|
+
61464: {"note", "notes", "memo"},
|
|
127
|
+
59791: {"news", "article", "articles", "rss"},
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
LANGUAGE_HINT_TOKENS = {
|
|
131
|
+
"translate",
|
|
132
|
+
"translation",
|
|
133
|
+
"language",
|
|
134
|
+
"languages",
|
|
135
|
+
"multilingual",
|
|
136
|
+
"dictionary",
|
|
137
|
+
"english",
|
|
138
|
+
"spanish",
|
|
139
|
+
"french",
|
|
140
|
+
"german",
|
|
141
|
+
"italian",
|
|
142
|
+
"japanese",
|
|
143
|
+
"chinese",
|
|
144
|
+
"portuguese",
|
|
145
|
+
"korean",
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _stem_token(token: str) -> str:
|
|
150
|
+
if len(token) <= 3:
|
|
151
|
+
return token
|
|
152
|
+
if token.endswith("ies") and len(token) > 4:
|
|
153
|
+
return token[:-3] + "y"
|
|
154
|
+
if token.endswith("ing") and len(token) > 5:
|
|
155
|
+
return token[:-3]
|
|
156
|
+
if token.endswith("ed") and len(token) > 4:
|
|
157
|
+
return token[:-2]
|
|
158
|
+
if token.endswith("es") and len(token) > 4:
|
|
159
|
+
if token[:-1].endswith("e"):
|
|
160
|
+
return token[:-1]
|
|
161
|
+
return token[:-2]
|
|
162
|
+
if token.endswith("s") and len(token) > 4:
|
|
163
|
+
return token[:-1]
|
|
164
|
+
return token
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _normalize(text: str) -> str:
|
|
168
|
+
text = text.strip()
|
|
169
|
+
text = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", text)
|
|
170
|
+
text = text.lower()
|
|
171
|
+
text = text.replace("_", " ").replace("-", " ")
|
|
172
|
+
text = re.sub(r"[^a-z0-9#+ ]+", " ", text)
|
|
173
|
+
return re.sub(r"\s+", " ", text).strip()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _tokenize(text: str) -> list[str]:
|
|
177
|
+
norm = _normalize(text)
|
|
178
|
+
base_tokens = [t for t in norm.split() if t and t not in STOP_WORDS]
|
|
179
|
+
tokens: list[str] = []
|
|
180
|
+
seen: set[str] = set()
|
|
181
|
+
for token in base_tokens:
|
|
182
|
+
for candidate in (token, _stem_token(token)):
|
|
183
|
+
if candidate and candidate not in STOP_WORDS and candidate not in seen:
|
|
184
|
+
tokens.append(candidate)
|
|
185
|
+
seen.add(candidate)
|
|
186
|
+
return tokens
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _rgb_from_hex(hex_value: str) -> tuple[int, int, int]:
|
|
190
|
+
value = hex_value.lstrip("#")
|
|
191
|
+
return int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _distance_rgb(a: tuple[int, int, int], b: tuple[int, int, int]) -> float:
|
|
195
|
+
return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b)))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _load_json(path: Path) -> Any:
|
|
199
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
200
|
+
return json.load(handle)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _load_data(skill_root: Path) -> tuple[dict[str, str], dict[str, dict[str, Any]], list[dict[str, Any]]]:
|
|
204
|
+
data_dir = skill_root / "data"
|
|
205
|
+
glyph_map = _load_json(data_dir / "shortcuts-official-glyph-mapping.json")
|
|
206
|
+
glyph_synonyms = _load_json(data_dir / "shortcuts-glyph-synonyms.json")
|
|
207
|
+
colors = _load_json(data_dir / "shortcuts-icon-colors.json")
|
|
208
|
+
return glyph_map, glyph_synonyms, colors
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _build_index(glyph_map: dict[str, str], glyph_synonyms: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
|
|
212
|
+
index: list[dict[str, Any]] = []
|
|
213
|
+
|
|
214
|
+
for glyph_str, official_name in glyph_map.items():
|
|
215
|
+
entry = glyph_synonyms.get(glyph_str, {})
|
|
216
|
+
display_name = entry.get("name") or official_name
|
|
217
|
+
synonyms = entry.get("synonyms") or []
|
|
218
|
+
|
|
219
|
+
terms = [display_name, official_name, *synonyms]
|
|
220
|
+
norm_terms: list[str] = []
|
|
221
|
+
seen_terms: set[str] = set()
|
|
222
|
+
for term in terms:
|
|
223
|
+
if not isinstance(term, str):
|
|
224
|
+
continue
|
|
225
|
+
norm = _normalize(term)
|
|
226
|
+
if norm and norm not in seen_terms:
|
|
227
|
+
norm_terms.append(norm)
|
|
228
|
+
seen_terms.add(norm)
|
|
229
|
+
|
|
230
|
+
name_tokens = set(_tokenize(display_name)) | set(_tokenize(official_name))
|
|
231
|
+
syn_tokens: set[str] = set()
|
|
232
|
+
for syn in synonyms:
|
|
233
|
+
if isinstance(syn, str):
|
|
234
|
+
syn_tokens.update(_tokenize(syn))
|
|
235
|
+
|
|
236
|
+
index.append(
|
|
237
|
+
{
|
|
238
|
+
"glyph": int(glyph_str),
|
|
239
|
+
"name": display_name,
|
|
240
|
+
"official_name": official_name,
|
|
241
|
+
"norm_name": _normalize(display_name),
|
|
242
|
+
"terms": norm_terms,
|
|
243
|
+
"name_tokens": name_tokens,
|
|
244
|
+
"syn_tokens": syn_tokens,
|
|
245
|
+
"all_tokens": name_tokens | syn_tokens,
|
|
246
|
+
}
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
return index
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _extract_explicit_glyph(prompt: str, glyph_map: dict[str, str]) -> tuple[int | None, str | None]:
|
|
253
|
+
for pattern in GLYPH_NUMBER_PATTERNS:
|
|
254
|
+
match = pattern.search(prompt)
|
|
255
|
+
if not match:
|
|
256
|
+
continue
|
|
257
|
+
glyph = int(match.group(1))
|
|
258
|
+
if str(glyph) in glyph_map:
|
|
259
|
+
return glyph, f"explicit glyph number {glyph}"
|
|
260
|
+
|
|
261
|
+
return None, None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _extract_icon_query(prompt: str) -> str | None:
|
|
265
|
+
for pattern in ICON_QUERY_PATTERNS:
|
|
266
|
+
match = pattern.search(prompt)
|
|
267
|
+
if not match:
|
|
268
|
+
continue
|
|
269
|
+
query = match.group(1).strip(" .,!?:;\"'()[]{}")
|
|
270
|
+
if query:
|
|
271
|
+
return query
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _score_icon(entry: dict[str, Any], query: str, explicit: bool) -> int:
|
|
276
|
+
query_norm = _normalize(query)
|
|
277
|
+
query_tokens = set(_tokenize(query))
|
|
278
|
+
if not query_norm and not query_tokens:
|
|
279
|
+
return 0
|
|
280
|
+
|
|
281
|
+
score = 0
|
|
282
|
+
boost = 1.35 if explicit else 1.0
|
|
283
|
+
|
|
284
|
+
if query_norm:
|
|
285
|
+
if query_norm == entry["norm_name"]:
|
|
286
|
+
score += int(260 * boost)
|
|
287
|
+
if query_norm in entry["terms"]:
|
|
288
|
+
score += int(230 * boost)
|
|
289
|
+
for term in entry["terms"]:
|
|
290
|
+
if term.startswith(query_norm) and query_norm != term:
|
|
291
|
+
score += int(150 * boost)
|
|
292
|
+
elif query_norm in term and query_norm != term:
|
|
293
|
+
score += int(100 * boost)
|
|
294
|
+
|
|
295
|
+
overlap_name = len(query_tokens & entry["name_tokens"])
|
|
296
|
+
overlap_syn = len(query_tokens & entry["syn_tokens"])
|
|
297
|
+
score += int(overlap_name * 34 * boost)
|
|
298
|
+
score += int(overlap_syn * 20 * boost)
|
|
299
|
+
|
|
300
|
+
if query_tokens:
|
|
301
|
+
overlap_all = len(query_tokens & entry["all_tokens"])
|
|
302
|
+
if overlap_all == len(query_tokens):
|
|
303
|
+
score += int(45 * boost)
|
|
304
|
+
elif overlap_all >= 2:
|
|
305
|
+
score += int(20 * boost)
|
|
306
|
+
|
|
307
|
+
icon_hint_terms = ICON_HINTS.get(entry["glyph"], set())
|
|
308
|
+
if icon_hint_terms and query_tokens:
|
|
309
|
+
overlap_hint = len(query_tokens & icon_hint_terms)
|
|
310
|
+
if overlap_hint:
|
|
311
|
+
score += int(overlap_hint * 32 * boost)
|
|
312
|
+
if overlap_hint >= 2:
|
|
313
|
+
score += int(22 * boost)
|
|
314
|
+
|
|
315
|
+
if entry["glyph"] in {59412, 62305} and query_tokens:
|
|
316
|
+
lang_overlap = len(query_tokens & LANGUAGE_HINT_TOKENS)
|
|
317
|
+
if lang_overlap:
|
|
318
|
+
score += int(lang_overlap * 30 * boost)
|
|
319
|
+
|
|
320
|
+
if explicit and query_tokens:
|
|
321
|
+
missing = len(query_tokens - entry["all_tokens"])
|
|
322
|
+
score -= missing * 6
|
|
323
|
+
|
|
324
|
+
return score
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _resolve_icon(
|
|
328
|
+
prompt: str,
|
|
329
|
+
icon_override: str | None,
|
|
330
|
+
glyph_map: dict[str, str],
|
|
331
|
+
index: list[dict[str, Any]],
|
|
332
|
+
top: int,
|
|
333
|
+
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
334
|
+
explicit_glyph, explicit_glyph_reason = (None, None)
|
|
335
|
+
if icon_override is None:
|
|
336
|
+
explicit_glyph, explicit_glyph_reason = _extract_explicit_glyph(prompt, glyph_map)
|
|
337
|
+
|
|
338
|
+
if explicit_glyph is not None:
|
|
339
|
+
name = glyph_map[str(explicit_glyph)]
|
|
340
|
+
icon = {
|
|
341
|
+
"glyph_number": explicit_glyph,
|
|
342
|
+
"symbol_name": name,
|
|
343
|
+
"selection_mode": "explicit",
|
|
344
|
+
"reason": explicit_glyph_reason,
|
|
345
|
+
"confidence": 1.0,
|
|
346
|
+
}
|
|
347
|
+
candidates = [{"glyph_number": explicit_glyph, "symbol_name": name, "score": 999}]
|
|
348
|
+
return icon, candidates
|
|
349
|
+
|
|
350
|
+
explicit_query = _normalize(icon_override or "") if icon_override else None
|
|
351
|
+
if not explicit_query:
|
|
352
|
+
explicit_query = _extract_icon_query(prompt)
|
|
353
|
+
|
|
354
|
+
query = explicit_query or prompt
|
|
355
|
+
explicit = bool(explicit_query)
|
|
356
|
+
|
|
357
|
+
scored = []
|
|
358
|
+
for entry in index:
|
|
359
|
+
score = _score_icon(entry, query, explicit)
|
|
360
|
+
if score > 0:
|
|
361
|
+
scored.append((score, entry))
|
|
362
|
+
scored.sort(key=lambda item: item[0], reverse=True)
|
|
363
|
+
|
|
364
|
+
if not scored:
|
|
365
|
+
fallback_name = glyph_map.get(str(DEFAULT_GLYPH), "shortcuts")
|
|
366
|
+
icon = {
|
|
367
|
+
"glyph_number": DEFAULT_GLYPH,
|
|
368
|
+
"symbol_name": fallback_name,
|
|
369
|
+
"selection_mode": "fallback",
|
|
370
|
+
"reason": "no relevant icon match in prompt",
|
|
371
|
+
"confidence": 0.0,
|
|
372
|
+
}
|
|
373
|
+
return icon, [{"glyph_number": DEFAULT_GLYPH, "symbol_name": fallback_name, "score": 0}]
|
|
374
|
+
|
|
375
|
+
top_score, best = scored[0]
|
|
376
|
+
second_score = scored[1][0] if len(scored) > 1 else 0
|
|
377
|
+
|
|
378
|
+
low_confidence = top_score < (120 if explicit else 20)
|
|
379
|
+
tieish = second_score > 0 and top_score / max(second_score, 1) < 1.08
|
|
380
|
+
|
|
381
|
+
if not explicit and low_confidence and tieish:
|
|
382
|
+
fallback_name = glyph_map.get(str(DEFAULT_GLYPH), "shortcuts")
|
|
383
|
+
icon = {
|
|
384
|
+
"glyph_number": DEFAULT_GLYPH,
|
|
385
|
+
"symbol_name": fallback_name,
|
|
386
|
+
"selection_mode": "fallback",
|
|
387
|
+
"reason": "prompt is too generic; defaulting to shortcuts icon",
|
|
388
|
+
"confidence": 0.1,
|
|
389
|
+
}
|
|
390
|
+
else:
|
|
391
|
+
mode = "explicit" if explicit else "inferred"
|
|
392
|
+
reason_query = explicit_query or prompt
|
|
393
|
+
confidence = min(0.99, max(0.2, top_score / 260.0))
|
|
394
|
+
icon = {
|
|
395
|
+
"glyph_number": best["glyph"],
|
|
396
|
+
"symbol_name": best["name"],
|
|
397
|
+
"selection_mode": mode,
|
|
398
|
+
"reason": f"matched query '{reason_query}'",
|
|
399
|
+
"confidence": round(confidence, 3),
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
candidates: list[dict[str, Any]] = []
|
|
403
|
+
for score, entry in scored[: max(1, top)]:
|
|
404
|
+
candidates.append(
|
|
405
|
+
{
|
|
406
|
+
"glyph_number": entry["glyph"],
|
|
407
|
+
"symbol_name": entry["name"],
|
|
408
|
+
"score": score,
|
|
409
|
+
}
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
return icon, candidates
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _extract_color_query(prompt: str, colors: list[dict[str, Any]]) -> str | None:
|
|
416
|
+
if match := HEX_COLOR_RE.search(prompt):
|
|
417
|
+
return f"#{match.group(1)}"
|
|
418
|
+
|
|
419
|
+
color_names = [c["name"].lower() for c in colors]
|
|
420
|
+
name_pattern = "|".join(re.escape(name) for name in color_names)
|
|
421
|
+
patterns = [
|
|
422
|
+
re.compile(rf"\b(?:icon|shortcut)\s*(?:color|colour)\s*(?:to|as|is|=)?\s*({name_pattern})\b", re.I),
|
|
423
|
+
re.compile(rf"\b({name_pattern})\b(?=[^.!?]{{0,24}}\b(?:icon|glyph|symbol|color|colour)\b)", re.I),
|
|
424
|
+
re.compile(rf"\b(?:color|colour)\s+(?:it\s+)?({name_pattern})\b", re.I),
|
|
425
|
+
]
|
|
426
|
+
for pattern in patterns:
|
|
427
|
+
match = pattern.search(prompt)
|
|
428
|
+
if match:
|
|
429
|
+
return match.group(1)
|
|
430
|
+
|
|
431
|
+
return None
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _resolve_color_by_alias(query: str, colors: list[dict[str, Any]]) -> tuple[dict[str, Any] | None, str | None]:
|
|
435
|
+
normalized = _normalize(query)
|
|
436
|
+
if not normalized:
|
|
437
|
+
return None, None
|
|
438
|
+
|
|
439
|
+
for color in colors:
|
|
440
|
+
if normalized == _normalize(color["name"]):
|
|
441
|
+
return color, f"explicit color name '{query}'"
|
|
442
|
+
|
|
443
|
+
for color in colors:
|
|
444
|
+
for synonym in color.get("synonyms", []):
|
|
445
|
+
if normalized == _normalize(synonym):
|
|
446
|
+
return color, f"explicit color synonym '{query}'"
|
|
447
|
+
|
|
448
|
+
try:
|
|
449
|
+
numeric = int(normalized)
|
|
450
|
+
except ValueError:
|
|
451
|
+
numeric = None
|
|
452
|
+
|
|
453
|
+
if numeric is not None:
|
|
454
|
+
for color in colors:
|
|
455
|
+
if numeric == int(color["value"]) or numeric in [int(v) for v in color.get("aliases", [])]:
|
|
456
|
+
return color, f"explicit color value {numeric}"
|
|
457
|
+
|
|
458
|
+
return None, None
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _resolve_color(
|
|
462
|
+
prompt: str,
|
|
463
|
+
color_override: str | None,
|
|
464
|
+
colors: list[dict[str, Any]],
|
|
465
|
+
icon: dict[str, Any],
|
|
466
|
+
) -> dict[str, Any]:
|
|
467
|
+
explicit_query = color_override or _extract_color_query(prompt, colors)
|
|
468
|
+
|
|
469
|
+
if explicit_query:
|
|
470
|
+
if explicit_query.startswith("#") and len(explicit_query) == 7:
|
|
471
|
+
target_rgb = _rgb_from_hex(explicit_query)
|
|
472
|
+
closest = min(colors, key=lambda color: _distance_rgb(target_rgb, _rgb_from_hex(color["hex"])))
|
|
473
|
+
return {
|
|
474
|
+
"value": int(closest["value"]),
|
|
475
|
+
"name": closest["name"],
|
|
476
|
+
"hex": closest["hex"],
|
|
477
|
+
"selection_mode": "explicit",
|
|
478
|
+
"reason": f"closest palette color to {explicit_query}",
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
color_match, reason = _resolve_color_by_alias(explicit_query, colors)
|
|
482
|
+
if color_match:
|
|
483
|
+
return {
|
|
484
|
+
"value": int(color_match["value"]),
|
|
485
|
+
"name": color_match["name"],
|
|
486
|
+
"hex": color_match["hex"],
|
|
487
|
+
"selection_mode": "explicit",
|
|
488
|
+
"reason": reason,
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
prompt_tokens = set(_tokenize(prompt))
|
|
492
|
+
icon_tokens = set(_tokenize(icon.get("symbol_name", "")))
|
|
493
|
+
|
|
494
|
+
best_color = None
|
|
495
|
+
best_score = 0
|
|
496
|
+
for color in colors:
|
|
497
|
+
score = 0
|
|
498
|
+
color_name = color["name"]
|
|
499
|
+
|
|
500
|
+
score += len(prompt_tokens & { _normalize(color_name) }) * 20
|
|
501
|
+
|
|
502
|
+
for synonym in color.get("synonyms", []):
|
|
503
|
+
syn_token_set = set(_tokenize(synonym))
|
|
504
|
+
if syn_token_set:
|
|
505
|
+
overlap = len(prompt_tokens & syn_token_set)
|
|
506
|
+
score += overlap * 6
|
|
507
|
+
|
|
508
|
+
for hint in COLOR_HINTS.get(color_name, set()):
|
|
509
|
+
hint_tokens = set(_tokenize(hint))
|
|
510
|
+
if hint_tokens and hint_tokens <= prompt_tokens:
|
|
511
|
+
score += 14
|
|
512
|
+
elif hint_tokens and prompt_tokens & hint_tokens:
|
|
513
|
+
score += 6
|
|
514
|
+
|
|
515
|
+
# A small bias to keep icon + color visually coherent for known themes.
|
|
516
|
+
if color_name == "Purple" and {"robot", "brain", "sparkles"} & icon_tokens:
|
|
517
|
+
score += 8
|
|
518
|
+
if color_name == "Gold" and {"dollar", "dollar sign", "credit card"} & icon_tokens:
|
|
519
|
+
score += 8
|
|
520
|
+
if color_name == "Blue" and {"cloud", "airplane", "map"} & icon_tokens:
|
|
521
|
+
score += 8
|
|
522
|
+
if color_name == "Gray" and {"gear", "tools", "wrench", "command"} & icon_tokens:
|
|
523
|
+
score += 8
|
|
524
|
+
|
|
525
|
+
if score > best_score:
|
|
526
|
+
best_score = score
|
|
527
|
+
best_color = color
|
|
528
|
+
|
|
529
|
+
if best_color is None or best_score == 0:
|
|
530
|
+
default = next((c for c in colors if int(c["value"]) == DEFAULT_COLOR_VALUE), colors[0])
|
|
531
|
+
return {
|
|
532
|
+
"value": int(default["value"]),
|
|
533
|
+
"name": default["name"],
|
|
534
|
+
"hex": default["hex"],
|
|
535
|
+
"selection_mode": "fallback",
|
|
536
|
+
"reason": "no explicit or thematic color match; using default",
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return {
|
|
540
|
+
"value": int(best_color["value"]),
|
|
541
|
+
"name": best_color["name"],
|
|
542
|
+
"hex": best_color["hex"],
|
|
543
|
+
"selection_mode": "inferred",
|
|
544
|
+
"reason": "matched prompt theme keywords",
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def resolve_icon_color(prompt: str, icon_override: str | None, color_override: str | None, top: int = 5) -> dict[str, Any]:
|
|
549
|
+
script_path = Path(__file__).resolve()
|
|
550
|
+
skill_root = script_path.parents[1]
|
|
551
|
+
|
|
552
|
+
glyph_map, glyph_synonyms, colors = _load_data(skill_root)
|
|
553
|
+
index = _build_index(glyph_map, glyph_synonyms)
|
|
554
|
+
|
|
555
|
+
icon, icon_candidates = _resolve_icon(prompt, icon_override, glyph_map, index, top)
|
|
556
|
+
color = _resolve_color(prompt, color_override, colors, icon)
|
|
557
|
+
|
|
558
|
+
return {
|
|
559
|
+
"prompt": prompt,
|
|
560
|
+
"icon": icon,
|
|
561
|
+
"color": color,
|
|
562
|
+
"wf_workflow_icon": {
|
|
563
|
+
"WFWorkflowIconGlyphNumber": int(icon["glyph_number"]),
|
|
564
|
+
"WFWorkflowIconStartColor": int(color["value"]),
|
|
565
|
+
},
|
|
566
|
+
"icon_candidates": icon_candidates,
|
|
567
|
+
"color_palette": [
|
|
568
|
+
{
|
|
569
|
+
"name": c["name"],
|
|
570
|
+
"hex": c["hex"],
|
|
571
|
+
"value": int(c["value"]),
|
|
572
|
+
}
|
|
573
|
+
for c in colors
|
|
574
|
+
],
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
579
|
+
parser = argparse.ArgumentParser(description="Resolve Shortcuts icon/color from natural language")
|
|
580
|
+
parser.add_argument("--prompt", required=True, help="Original user prompt used to build the shortcut")
|
|
581
|
+
parser.add_argument("--icon", default=None, help="Optional explicit icon query (name/synonym/glyph number)")
|
|
582
|
+
parser.add_argument("--color", default=None, help="Optional explicit color query (name/synonym/value/#RRGGBB)")
|
|
583
|
+
parser.add_argument("--top", type=int, default=5, help="How many icon candidates to include")
|
|
584
|
+
return parser
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def main() -> int:
|
|
588
|
+
parser = _build_parser()
|
|
589
|
+
args = parser.parse_args()
|
|
590
|
+
|
|
591
|
+
payload = resolve_icon_color(args.prompt, args.icon, args.color, top=max(1, args.top))
|
|
592
|
+
print(json.dumps(payload, indent=2, ensure_ascii=True))
|
|
593
|
+
return 0
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
if __name__ == "__main__":
|
|
597
|
+
raise SystemExit(main())
|