@mmerterden/multi-agent-pipeline 20.6.0 → 20.7.0
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/CHANGELOG.md +18 -0
- package/docs/facts.json +1 -1
- package/manifest.json +71 -71
- package/package.json +1 -1
- package/pipeline/multi-agent-refs/features/design-conformance.md +62 -64
- package/pipeline/scripts/_notices.mjs +11 -0
- package/pipeline/scripts/gen-skills-index.mjs +1 -1
- package/pipeline/skills/.skill-manifest.json +39 -39
- package/pipeline/skills/shared/README.md +1 -1
- package/pipeline/skills/shared/external/agent-introspection-debugging/SKILL.md +1 -0
- package/pipeline/skills/shared/external/android-architecture/SKILL.md +2 -0
- package/pipeline/skills/shared/external/android-performance/SKILL.md +2 -0
- package/pipeline/skills/shared/external/android-security/SKILL.md +2 -0
- package/pipeline/skills/shared/external/backlog/BACKLOG.md +1 -1
- package/pipeline/skills/shared/external/backlog/SKILL.md +56 -33
- package/pipeline/skills/shared/external/ci-cd-pipelines/SKILL.md +1 -0
- package/pipeline/skills/shared/external/compose-components/SKILL.md +2 -0
- package/pipeline/skills/shared/external/compose-navigation/SKILL.md +3 -2
- package/pipeline/skills/shared/external/compose-testing/SKILL.md +2 -0
- package/pipeline/skills/shared/external/council/SKILL.md +1 -0
- package/pipeline/skills/shared/external/css-modern/SKILL.md +1 -0
- package/pipeline/skills/shared/external/database-patterns/SKILL.md +1 -0
- package/pipeline/skills/shared/external/evidence-github/SKILL.md +2 -0
- package/pipeline/skills/shared/external/evidence-registry/SKILL.md +2 -0
- package/pipeline/skills/shared/external/gradle-kotlin-dsl/SKILL.md +2 -0
- package/pipeline/skills/shared/external/html-semantic/SKILL.md +1 -0
- package/pipeline/skills/shared/external/humanizer/SKILL.md +1 -0
- package/pipeline/skills/shared/external/ios-coding-standard/SKILL.md +1 -0
- package/pipeline/skills/shared/external/ios-module-structure/SKILL.md +1 -0
- package/pipeline/skills/shared/external/ios-security/SKILL.md +2 -0
- package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +91 -283
- package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +119 -151
- package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +60 -90
- package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +119 -156
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +726 -787
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +253 -288
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +243 -304
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +88 -104
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +181 -235
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +198 -263
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +461 -466
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +145 -151
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +123 -141
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +146 -157
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/snapshot-resources.sh +22 -19
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +156 -140
- package/pipeline/skills/shared/external/nextjs-app-router/SKILL.md +1 -0
- package/pipeline/skills/shared/external/play-store-review/SKILL.md +2 -0
- package/pipeline/skills/shared/external/python-patterns/SKILL.md +1 -0
- package/pipeline/skills/shared/external/react-best-practices/SKILL.md +1 -0
- package/pipeline/skills/shared/external/rest-api-design/SKILL.md +1 -0
- package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +2 -0
- package/pipeline/skills/shared/external/room-database/SKILL.md +2 -0
- package/pipeline/skills/shared/external/search-first/SKILL.md +1 -0
- package/pipeline/skills/shared/external/signal-community/SKILL.md +2 -0
- package/pipeline/skills/shared/external/skill-creator/SKILL.md +80 -41
- package/pipeline/skills/shared/external/skill-creator/audit.md +63 -59
- package/pipeline/skills/shared/external/skill-creator/checklist.md +28 -20
- package/pipeline/skills/shared/external/skill-creator/examples.md +40 -40
- package/pipeline/skills/shared/external/skill-creator/label-check.md +48 -36
- package/pipeline/skills/shared/external/skill-creator/scripts/audit-panel.js +91 -100
- package/pipeline/skills/shared/external/skill-creator/template.md +51 -39
- package/pipeline/skills/shared/external/tailwind-css/SKILL.md +1 -0
- package/pipeline/skills/shared/external/testing-backend/SKILL.md +1 -0
- package/pipeline/skills/shared/external/typescript-patterns/SKILL.md +1 -0
- package/pipeline/skills/shared/external/vue-composition/SKILL.md +1 -0
- package/pipeline/skills/shared/external/web-accessibility/SKILL.md +1 -0
- package/pipeline/skills/shared/external/web-performance/SKILL.md +1 -0
- package/pipeline/skills/shared/external/web-testing/SKILL.md +1 -0
|
@@ -1,37 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
|
|
4
|
-
The
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
("Giriniz", "Lorem ipsum", "Ahmet Abdullah"), so the annotation - not the design
|
|
8
|
-
text - is the content team's *actual* value. This script harvests them so the map
|
|
9
|
-
can show an `İçerik (CMS)` column beside the new (resources) and legacy values.
|
|
10
|
-
|
|
11
|
-
Three sources, in priority order (Figma changes constantly → live is primary):
|
|
12
|
-
1. REST - `GET https://api.figma.com/v1/files/<key>/nodes?ids=...`, header
|
|
13
|
-
`X-Figma-Token` (from --token, env FIGMA_ACCESS_TOKEN/FIGMA_TOKEN, or
|
|
14
|
-
the macOS keychain item `FIGMA_ACCESS_TOKEN`). Default.
|
|
15
|
-
2. MCP - `--from-mcp <raw.json>`: an already-captured Dev Mode dump (the host's
|
|
16
|
-
Figma plugin/`use_figma` output). No network, no rate limit - use it
|
|
17
|
-
for big runs or when REST is 429-throttled.
|
|
18
|
-
3. local - `--local <design-export-screen-dir>`: best-effort read of the in-repo
|
|
19
|
-
snapshot's `tree.json` (only if it carries `annotations`). The safety
|
|
20
|
-
net when neither API nor MCP is reachable; usually yields nothing, in
|
|
21
|
-
which case the CMS column is simply left blank (blank beats a guess).
|
|
22
|
-
|
|
23
|
-
Why a script: parsing `TR:/EN:` labels out of a node tree across many nodes is
|
|
24
|
-
pure IO + string work - slow and error-prone by hand. Read-only; touches no repo.
|
|
25
|
-
|
|
26
|
-
Emits the `_annotations.json` contract (to --out or stdout):
|
|
27
|
-
{ generatedAt, source, files[], auditedNodes[], count, withTr, withEn,
|
|
28
|
-
annotations: [ {nodeId, name, type, designText, raw, tr, en, mode} ] }
|
|
29
|
-
|
|
30
|
-
The label→{tr,en} parser (`split_lang`) is a faithful port of the web skill's
|
|
31
|
-
`lib/split-lang.mjs`, so iOS/Android and web stay byte-compatible on the rules.
|
|
2
|
+
"""Gather the "Final UX Writing" Dev Mode annotations of a Figma screen into _annotations.json.
|
|
3
|
+
|
|
4
|
+
The REST API is tried first; a saved MCP dump (--from-mcp) or the tree.json of
|
|
5
|
+
a design export kept in the repo (--local) can stand in for it. The TR and EN
|
|
6
|
+
copy of each annotation is separated. Nothing is written back to Figma.
|
|
32
7
|
"""
|
|
33
8
|
import argparse
|
|
34
|
-
import datetime
|
|
35
9
|
import json
|
|
36
10
|
import os
|
|
37
11
|
import re
|
|
@@ -41,303 +15,268 @@ import sys
|
|
|
41
15
|
import urllib.error
|
|
42
16
|
import urllib.parse
|
|
43
17
|
import urllib.request
|
|
18
|
+
from datetime import datetime, timezone
|
|
44
19
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
"""`1234-56` (URL form) → `1234:56` (node-id form); trims."""
|
|
96
|
-
return re.sub(r"^(\d+)-(\d+)$", r"\1:\2", (s or "").strip())
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
# --- annotation parsing ----------------------------------------------------
|
|
100
|
-
def labels_of(node):
|
|
101
|
-
"""The annotation label strings on a node (each `label` ?? `labelMarkdown`)."""
|
|
102
|
-
anns = node.get("annotations")
|
|
103
|
-
if not isinstance(anns, list):
|
|
104
|
-
return []
|
|
105
|
-
out = []
|
|
106
|
-
for a in anns:
|
|
107
|
-
if not isinstance(a, dict):
|
|
108
|
-
continue
|
|
109
|
-
lbl = a.get("label") or a.get("labelMarkdown") or ""
|
|
110
|
-
if lbl:
|
|
111
|
-
out.append(lbl)
|
|
112
|
-
return out
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def design_text(node):
|
|
116
|
-
if node.get("type") == "TEXT" and isinstance(node.get("characters"), str):
|
|
117
|
-
return node["characters"]
|
|
118
|
-
return node.get("name")
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def parse_item(node_id, name, ntype, dtext, labels):
|
|
122
|
-
"""Run split_lang over each label; keep the first that yields tr or en."""
|
|
123
|
-
best = {"tr": None, "en": None, "mode": "empty"}
|
|
124
|
-
raw = ""
|
|
125
|
-
for lbl in (labels or []):
|
|
126
|
-
parsed = split_lang(lbl)
|
|
127
|
-
if parsed["tr"] is not None or parsed["en"] is not None:
|
|
128
|
-
best = parsed
|
|
129
|
-
raw = lbl
|
|
130
|
-
break
|
|
131
|
-
if not raw:
|
|
132
|
-
raw = lbl
|
|
20
|
+
API = "https://api.figma.com/v1"
|
|
21
|
+
BATCH = 4
|
|
22
|
+
SEP = "[:\\-\u2013]"
|
|
23
|
+
TR_RE = re.compile("^(?:tr|tr[kc]e|t[\u00fcu]rk[\u00e7c]e)\\s*" + SEP + "\\s*(.+)$", re.IGNORECASE)
|
|
24
|
+
EN_RE = re.compile("^(?:en|eng|ing|[i\u0130]ngilizce)\\s*" + SEP + "\\s*(.+)$", re.IGNORECASE)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def canon_id(node_id):
|
|
28
|
+
return str(node_id).strip().replace("-", ":")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def split_lang(text):
|
|
32
|
+
picked = {"tr": None, "en": None}
|
|
33
|
+
loose = []
|
|
34
|
+
for line in filter(None, (piece.strip() for piece in str(text or "").splitlines())):
|
|
35
|
+
tr_hit, en_hit = TR_RE.match(line), EN_RE.match(line)
|
|
36
|
+
if tr_hit and picked["tr"] is None:
|
|
37
|
+
picked["tr"] = tr_hit.group(1).strip()
|
|
38
|
+
elif en_hit and picked["en"] is None:
|
|
39
|
+
picked["en"] = en_hit.group(1).strip()
|
|
40
|
+
else:
|
|
41
|
+
loose.append(line)
|
|
42
|
+
if picked["tr"] is None and picked["en"] is None:
|
|
43
|
+
padded = loose + [None, None]
|
|
44
|
+
style = {0: "empty", 1: "single"}.get(len(loose), "two-line")
|
|
45
|
+
return {"tr": padded[0], "en": padded[1], "mode": style}
|
|
46
|
+
spare = iter(loose)
|
|
47
|
+
for lang in ("tr", "en"):
|
|
48
|
+
if picked[lang] is None:
|
|
49
|
+
picked[lang] = next(spare, None)
|
|
50
|
+
return dict(picked, mode="prefixed")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def label_text(annotation):
|
|
54
|
+
if isinstance(annotation, dict):
|
|
55
|
+
return annotation.get("label") or annotation.get("labelMarkdown") or ""
|
|
56
|
+
return str(annotation or "")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def pick_label(labels):
|
|
60
|
+
for label in labels:
|
|
61
|
+
parsed = split_lang(label)
|
|
62
|
+
if parsed["tr"] or parsed["en"]:
|
|
63
|
+
return label, parsed
|
|
64
|
+
first = labels[0] if labels else ""
|
|
65
|
+
return first, split_lang(first)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def record(node_id, name, node_type, design_text, labels):
|
|
69
|
+
raw, parsed = pick_label(labels)
|
|
133
70
|
return {
|
|
134
|
-
"nodeId": node_id,
|
|
71
|
+
"nodeId": canon_id(node_id),
|
|
135
72
|
"name": name,
|
|
136
|
-
"type":
|
|
137
|
-
"designText":
|
|
73
|
+
"type": node_type,
|
|
74
|
+
"designText": design_text,
|
|
138
75
|
"raw": raw,
|
|
139
|
-
"tr":
|
|
140
|
-
"en":
|
|
141
|
-
"mode":
|
|
76
|
+
"tr": parsed["tr"],
|
|
77
|
+
"en": parsed["en"],
|
|
78
|
+
"mode": parsed["mode"],
|
|
142
79
|
}
|
|
143
80
|
|
|
144
81
|
|
|
145
|
-
def
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
82
|
+
def walk_nodes(root):
|
|
83
|
+
stack = [root]
|
|
84
|
+
while stack:
|
|
85
|
+
node = stack.pop()
|
|
86
|
+
if not isinstance(node, dict):
|
|
87
|
+
continue
|
|
88
|
+
yield node
|
|
89
|
+
stack.extend(reversed(node.get("children") or []))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def annotated(root):
|
|
93
|
+
found = []
|
|
94
|
+
for node in walk_nodes(root):
|
|
95
|
+
notes = node.get("annotations")
|
|
96
|
+
if isinstance(notes, list) and notes:
|
|
97
|
+
node_type = node.get("type")
|
|
98
|
+
design = node.get("characters") if node_type == "TEXT" else node.get("name")
|
|
99
|
+
node_id = node.get("id") or node.get("nodeId")
|
|
100
|
+
found.append(record(node_id, node.get("name"), node_type, design, [label_text(a) for a in notes]))
|
|
101
|
+
return found
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def keychain_secret(service):
|
|
163
105
|
try:
|
|
164
|
-
|
|
165
|
-
["security", "find-generic-password", "-s",
|
|
166
|
-
capture_output=True, text=True
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
return None
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
def
|
|
175
|
-
|
|
176
|
-
|
|
106
|
+
lookup = subprocess.run(
|
|
107
|
+
["security", "find-generic-password", "-s", service, "-w"],
|
|
108
|
+
capture_output=True, text=True, timeout=10,
|
|
109
|
+
)
|
|
110
|
+
except (OSError, subprocess.SubprocessError):
|
|
111
|
+
return None
|
|
112
|
+
secret = lookup.stdout.strip()
|
|
113
|
+
return secret if lookup.returncode == 0 and secret else None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def figma_token(explicit):
|
|
117
|
+
if explicit:
|
|
118
|
+
return explicit
|
|
119
|
+
from_env = next((os.environ[v] for v in ("FIGMA_ACCESS_TOKEN", "FIGMA_TOKEN") if os.environ.get(v)), None)
|
|
120
|
+
return from_env or keychain_secret("FIGMA_ACCESS_TOKEN")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get_json(url, token, ca_file, timeout=60):
|
|
124
|
+
tls = ssl.create_default_context(cafile=ca_file) if ca_file else None
|
|
125
|
+
call = urllib.request.Request(url, headers={"X-Figma-Token": token})
|
|
177
126
|
try:
|
|
178
|
-
with urllib.request.urlopen(
|
|
179
|
-
return json.
|
|
180
|
-
except urllib.error.HTTPError as
|
|
181
|
-
if
|
|
182
|
-
sys.exit("Figma
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if re.search(r"CERTIFICATE|ISSUER|SELF.SIGNED|UNABLE_TO|CERT", msg, re.I):
|
|
127
|
+
with urllib.request.urlopen(call, timeout=timeout, context=tls) as response:
|
|
128
|
+
return json.loads(response.read().decode("utf-8"))
|
|
129
|
+
except urllib.error.HTTPError as exc:
|
|
130
|
+
if exc.code == 429:
|
|
131
|
+
sys.exit("Figma is rate limiting (HTTP 429). Dump the annotations with Figma MCP instead and pass that file via --from-mcp <raw.json>.")
|
|
132
|
+
snippet = exc.read()[:200].decode("utf-8", "replace")
|
|
133
|
+
sys.exit(f"Figma HTTP {exc.code} for {url}: {snippet}")
|
|
134
|
+
except urllib.error.URLError as exc:
|
|
135
|
+
why = str(exc.reason)
|
|
136
|
+
if any(marker in why.upper() for marker in ("CERTIFICATE", "SSL")):
|
|
189
137
|
sys.exit(
|
|
190
|
-
"TLS
|
|
191
|
-
"roots and
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
" security find-certificate -a -p /Library/Keychains/System.keychain "
|
|
195
|
-
">> /tmp/ca.pem\n"
|
|
196
|
-
" ... --ca-file /tmp/ca.pem")
|
|
197
|
-
sys.exit(f"Figma request failed: {msg}")
|
|
138
|
+
"Could not verify TLS, most likely because a proxy signs traffic with its own root. "
|
|
139
|
+
"Write the system roots out with `security find-certificate -a -p > roots.pem` and add --ca-file roots.pem."
|
|
140
|
+
)
|
|
141
|
+
sys.exit(f"Could not reach Figma: {why}")
|
|
198
142
|
|
|
199
143
|
|
|
200
144
|
def from_rest(file_key, node_ids, token, ca_file):
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
145
|
+
wanted = list(map(canon_id, node_ids))
|
|
146
|
+
found = []
|
|
147
|
+
for offset in range(0, len(wanted), BATCH):
|
|
148
|
+
chunk = wanted[offset:offset + BATCH]
|
|
149
|
+
joined = urllib.parse.quote(",".join(chunk), safe="")
|
|
150
|
+
reply = get_json(f"{API}/files/{file_key}/nodes?ids={joined}", token, ca_file)
|
|
151
|
+
by_id = reply.get("nodes") or {}
|
|
152
|
+
for node_id in chunk:
|
|
153
|
+
document = (by_id.get(node_id) or {}).get("document")
|
|
154
|
+
if document:
|
|
155
|
+
found.extend(annotated(document))
|
|
156
|
+
return found, [file_key], wanted
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def read_json(path):
|
|
160
|
+
with open(path, encoding="utf-8") as stream:
|
|
161
|
+
return json.load(stream)
|
|
217
162
|
|
|
218
163
|
|
|
219
164
|
def from_mcp(path):
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if
|
|
228
|
-
|
|
229
|
-
labels =
|
|
230
|
-
if labels
|
|
231
|
-
labels = [
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
165
|
+
data = read_json(path)
|
|
166
|
+
if isinstance(data, dict):
|
|
167
|
+
items = data.get("annotations") or data.get("items") or []
|
|
168
|
+
else:
|
|
169
|
+
items = data
|
|
170
|
+
found, files, audited = [], [], []
|
|
171
|
+
for item in items:
|
|
172
|
+
if not isinstance(item, dict):
|
|
173
|
+
continue
|
|
174
|
+
labels = item.get("labels")
|
|
175
|
+
if not isinstance(labels, list):
|
|
176
|
+
labels = [item.get("raw")] if item.get("raw") else []
|
|
177
|
+
found.append(record(item.get("nodeId"), item.get("name"), item.get("type"), item.get("designText"), [label_text(l) for l in labels]))
|
|
178
|
+
if item.get("fileKey") and item["fileKey"] not in files:
|
|
179
|
+
files.append(item["fileKey"])
|
|
180
|
+
if item.get("nodeId"):
|
|
181
|
+
audited.append(canon_id(item["nodeId"]))
|
|
182
|
+
return found, files, audited
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def from_local(directory):
|
|
186
|
+
tree = os.path.join(directory, "tree.json")
|
|
240
187
|
if not os.path.isfile(tree):
|
|
241
|
-
return [],
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
for
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
188
|
+
return [], [], []
|
|
189
|
+
data = read_json(tree)
|
|
190
|
+
frames = [data] if not isinstance(data, list) else data
|
|
191
|
+
found = [item for frame in frames for item in annotated(frame)]
|
|
192
|
+
ids = [frame.get("nodeId") or frame.get("id") for frame in frames if isinstance(frame, dict)]
|
|
193
|
+
return found, [], [canon_id(i) for i in ids if i]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def pick_source(opts, cli, file_key, nodes):
|
|
197
|
+
if opts.from_mcp or opts.source == "mcp":
|
|
198
|
+
if not opts.from_mcp:
|
|
199
|
+
cli.error("--source mcp needs --from-mcp <raw.json>")
|
|
200
|
+
return ("mcp",) + from_mcp(opts.from_mcp)
|
|
201
|
+
if opts.source == "local":
|
|
202
|
+
if not opts.local:
|
|
203
|
+
cli.error("--source local needs --local <design-export-screen-dir>")
|
|
204
|
+
return ("local",) + from_local(opts.local)
|
|
205
|
+
have_target = bool(file_key and nodes)
|
|
206
|
+
if opts.source == "rest":
|
|
207
|
+
if not have_target:
|
|
208
|
+
cli.error("--source rest needs a file key and node-ids (--file/--nodes or --mapping)")
|
|
209
|
+
token = figma_token(opts.token)
|
|
210
|
+
if not token:
|
|
211
|
+
sys.exit("No Figma token found. Export FIGMA_ACCESS_TOKEN or FIGMA_TOKEN, keep it in the keychain under FIGMA_ACCESS_TOKEN, or use --token.")
|
|
212
|
+
return ("rest",) + from_rest(file_key, nodes, token, opts.ca_file)
|
|
213
|
+
token = figma_token(opts.token) if have_target else None
|
|
214
|
+
if have_target and token:
|
|
215
|
+
try:
|
|
216
|
+
return ("rest",) + from_rest(file_key, nodes, token, opts.ca_file)
|
|
217
|
+
except SystemExit as exc:
|
|
218
|
+
if not opts.local:
|
|
219
|
+
raise
|
|
220
|
+
print(f"# REST fetch did not work ({exc}); using --local instead", file=sys.stderr)
|
|
221
|
+
return ("local",) + from_local(opts.local)
|
|
222
|
+
if opts.local:
|
|
223
|
+
return ("local",) + from_local(opts.local)
|
|
224
|
+
cli.error("nothing to do: give --file/--nodes (or --mapping) with a token, --from-mcp, or --local")
|
|
250
225
|
|
|
251
226
|
|
|
252
227
|
def main():
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
if
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
elif source == "local":
|
|
285
|
-
if not args.local:
|
|
286
|
-
ap.error("--source local needs --local <dir>")
|
|
287
|
-
collected, source = from_local(args.local)
|
|
288
|
-
elif source == "rest":
|
|
289
|
-
if not (file_key and node_ids):
|
|
290
|
-
ap.error("REST needs --file and --nodes (or --mapping)")
|
|
291
|
-
collected, source = from_rest(file_key, node_ids, figma_token(args), args.ca_file)
|
|
292
|
-
else: # auto: REST first, fall back to --local
|
|
293
|
-
if file_key and node_ids and figma_token(args):
|
|
294
|
-
try:
|
|
295
|
-
collected, source = from_rest(file_key, node_ids, figma_token(args), args.ca_file)
|
|
296
|
-
except SystemExit:
|
|
297
|
-
if not args.local:
|
|
298
|
-
raise
|
|
299
|
-
sys.stderr.write("# REST failed - falling back to --local\n")
|
|
300
|
-
collected, source = from_local(args.local)
|
|
301
|
-
elif args.local:
|
|
302
|
-
collected, source = from_local(args.local)
|
|
303
|
-
else:
|
|
304
|
-
ap.error("nothing to do - supply --file/--nodes (or --mapping) for REST, "
|
|
305
|
-
"--from-mcp for MCP, or --local for the snapshot fallback")
|
|
306
|
-
|
|
307
|
-
# de-dup by nodeId, keep first (REST returns each requested node once anyway)
|
|
308
|
-
seen = set()
|
|
309
|
-
deduped = []
|
|
310
|
-
for a in collected:
|
|
311
|
-
nid = a.get("nodeId")
|
|
312
|
-
if nid in seen:
|
|
313
|
-
continue
|
|
314
|
-
seen.add(nid)
|
|
315
|
-
deduped.append(a)
|
|
316
|
-
|
|
317
|
-
out = {
|
|
318
|
-
"generatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
228
|
+
cli = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
229
|
+
cli.add_argument("--file", help="key of the Figma file")
|
|
230
|
+
cli.add_argument("--nodes", help="frame node-ids, separated by commas")
|
|
231
|
+
cli.add_argument("--mapping", help="mapping JSON; its figmaFileKey and figmaNodes fill in for missing flags")
|
|
232
|
+
cli.add_argument("--from-mcp", help="JSON dump of annotations taken through MCP")
|
|
233
|
+
cli.add_argument("--local", help="screen folder of a design export that contains tree.json")
|
|
234
|
+
cli.add_argument("--source", default="auto", choices=["auto", "rest", "mcp", "local"])
|
|
235
|
+
cli.add_argument("--token", help="Figma token; FIGMA_ACCESS_TOKEN or the keychain is safer since other processes can read argv")
|
|
236
|
+
cli.add_argument("--ca-file", help="PEM roots to trust when a proxy intercepts TLS")
|
|
237
|
+
cli.add_argument("--out", help="file to write; stdout when omitted")
|
|
238
|
+
opts = cli.parse_args()
|
|
239
|
+
|
|
240
|
+
file_key = opts.file
|
|
241
|
+
nodes = [piece for piece in (opts.nodes or "").split(",") if piece.strip()]
|
|
242
|
+
if opts.mapping:
|
|
243
|
+
mapping = read_json(opts.mapping)
|
|
244
|
+
file_key = file_key or mapping.get("figmaFileKey")
|
|
245
|
+
nodes = nodes or list(mapping.get("figmaNodes") or [])
|
|
246
|
+
|
|
247
|
+
source, found, files, audited = pick_source(opts, cli, file_key, nodes)
|
|
248
|
+
|
|
249
|
+
first_seen = {}
|
|
250
|
+
for item in found:
|
|
251
|
+
first_seen.setdefault(item["nodeId"], item)
|
|
252
|
+
unique = list(first_seen.values())
|
|
253
|
+
|
|
254
|
+
def having(lang):
|
|
255
|
+
return sum(1 for entry in unique if entry[lang])
|
|
256
|
+
|
|
257
|
+
result = {
|
|
258
|
+
"generatedAt": datetime.now(timezone.utc).isoformat(),
|
|
319
259
|
"source": source,
|
|
320
260
|
"files": files,
|
|
321
|
-
"auditedNodes":
|
|
322
|
-
"count": len(
|
|
323
|
-
"withTr":
|
|
324
|
-
"withEn":
|
|
325
|
-
"annotations":
|
|
261
|
+
"auditedNodes": audited,
|
|
262
|
+
"count": len(unique),
|
|
263
|
+
"withTr": having("tr"),
|
|
264
|
+
"withEn": having("en"),
|
|
265
|
+
"annotations": unique,
|
|
326
266
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
with open(args.out, "w", encoding="utf-8") as fh:
|
|
331
|
-
fh.write(text + "\n")
|
|
332
|
-
sys.stderr.write(f"# {out['count']} annotated node(s) "
|
|
333
|
-
f"(tr={out['withTr']} en={out['withEn']}, source={source}) → {args.out}\n")
|
|
267
|
+
text = json.dumps(result, indent=2, ensure_ascii=False) + "\n"
|
|
268
|
+
if not opts.out:
|
|
269
|
+
sys.stdout.write(text)
|
|
334
270
|
else:
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
271
|
+
with open(opts.out, "w", encoding="utf-8") as sink:
|
|
272
|
+
sink.write(text)
|
|
273
|
+
print(f"# {result['count']} annotations ({result['withTr']} TR, {result['withEn']} EN) from {source} -> {opts.out}", file=sys.stderr)
|
|
274
|
+
|
|
275
|
+
missing_en = [entry["nodeId"] for entry in unique if entry["tr"] and not entry["en"]]
|
|
276
|
+
if missing_en:
|
|
277
|
+
print(f"# {len(missing_en)} annotation(s) carry TR copy without EN; return them to the content team:", file=sys.stderr)
|
|
278
|
+
for node_id in missing_en:
|
|
279
|
+
print(f"# {node_id}", file=sys.stderr)
|
|
341
280
|
|
|
342
281
|
|
|
343
282
|
if __name__ == "__main__":
|