@mmerterden/multi-agent-pipeline 14.2.1 → 14.2.2
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 +42 -0
- package/README.md +10 -6
- package/README.tr.md +143 -0
- package/docs/architecture.md +23 -8
- package/docs/ecosystem.md +237 -0
- package/install/_plugin-skills.mjs +16 -2
- package/install/codex.mjs +9 -4
- package/install/templates/copilot-instructions.md +12 -9
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/analysis/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/autopilot/SKILL.md +6 -2
- package/pipeline/commands/multi-agent/channels/SKILL.md +15 -4
- package/pipeline/commands/multi-agent/create-jira/SKILL.md +4 -4
- package/pipeline/commands/multi-agent/dev/SKILL.md +10 -23
- package/pipeline/commands/multi-agent/dev-autopilot/SKILL.md +10 -2
- package/pipeline/commands/multi-agent/dev-local/SKILL.md +10 -24
- package/pipeline/commands/multi-agent/dev-local-autopilot/SKILL.md +10 -3
- package/pipeline/commands/multi-agent/jira/SKILL.md +13 -2
- package/pipeline/commands/multi-agent/language/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/local/SKILL.md +6 -2
- package/pipeline/commands/multi-agent/local-autopilot/SKILL.md +6 -2
- package/pipeline/commands/multi-agent/log/SKILL.md +7 -1
- package/pipeline/commands/multi-agent/ship/SKILL.md +5 -1
- package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
- package/pipeline/commands/multi-agent/update/SKILL.md +1 -1
- package/pipeline/lib/extract-conventions.sh +44 -15
- package/pipeline/lib/fetch-figma-annotations.sh +8 -1
- package/pipeline/lib/fetch-fortify.sh +23 -8
- package/pipeline/lib/figma-screenshot.sh +11 -1
- package/pipeline/lib/issue-fetcher.sh +76 -9
- package/pipeline/lib/md2confluence-v3.py +16 -2
- package/pipeline/lib/plan-todos.sh +5 -2
- package/pipeline/lib/post-pr-review.sh +8 -6
- package/pipeline/lib/shadow-git.sh +50 -9
- package/pipeline/lib/submodule-detector.sh +8 -1
- package/pipeline/multi-agent-refs/_input-parser.md +1 -1
- package/pipeline/multi-agent-refs/channels/confluence.md +3 -0
- package/pipeline/multi-agent-refs/channels/issue-comment.md +2 -2
- package/pipeline/multi-agent-refs/channels/jira.md +13 -2
- package/pipeline/multi-agent-refs/channels/pr-review-actions.md +1 -1
- package/pipeline/multi-agent-refs/channels/pr.md +20 -0
- package/pipeline/multi-agent-refs/channels/wiki.md +1 -1
- package/pipeline/multi-agent-refs/cross-cli-contract.md +3 -3
- package/pipeline/multi-agent-refs/features/worktree-finalize.md +1 -1
- package/pipeline/multi-agent-refs/generate-issue.md +2 -2
- package/pipeline/multi-agent-refs/issue-jira-triad.md +3 -3
- package/pipeline/multi-agent-refs/payload-contracts.md +67 -0
- package/pipeline/multi-agent-refs/phases/modes.md +20 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-6-commit.md +8 -40
- package/pipeline/multi-agent-refs/phases/phase-7-report.md +5 -3
- package/pipeline/multi-agent-refs/phases.md +6 -0
- package/pipeline/multi-agent-refs/rules.md +2 -0
- package/pipeline/schemas/prefs.schema.json +1 -1
- package/pipeline/scripts/audit-log-rotate.sh +10 -0
- package/pipeline/scripts/build-stack-plugins.mjs +8 -1
- package/pipeline/scripts/check-derived-drift.mjs +13 -1
- package/pipeline/scripts/diff-explain.mjs +41 -3
- package/pipeline/scripts/diff-risk-score.mjs +72 -8
- package/pipeline/scripts/gen-mode-dispatch.mjs +1 -1
- package/pipeline/scripts/learning-curve.mjs +8 -2
- package/pipeline/scripts/output-quality-check.sh +15 -4
- package/pipeline/scripts/phase-tracker.sh +21 -8
- package/pipeline/scripts/pre-commit-check.sh +69 -22
- package/pipeline/scripts/render-agent-log-cost.sh +8 -3
- package/pipeline/scripts/render-cost-summary.sh +42 -22
- package/pipeline/scripts/render-work-summary.sh +47 -13
- package/pipeline/scripts/review-scope.mjs +1 -1
- package/pipeline/scripts/run-aggregator.mjs +38 -14
- package/pipeline/scripts/smoke-schema-validation.sh +5 -1
- package/pipeline/scripts/test-gap-scan.mjs +45 -6
- package/pipeline/scripts/uninstall.mjs +39 -4
- package/pipeline/scripts/update-issue-progress.sh +12 -16
- package/pipeline/scripts/worktree-finalize.sh +23 -2
- package/pipeline/skills/.skills-index.json +11 -2
- package/pipeline/skills/shared/README.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-autopilot/SKILL.md +4 -0
- package/pipeline/skills/shared/core/multi-agent-create-jira/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-dev/SKILL.md +4 -17
- package/pipeline/skills/shared/core/multi-agent-dev-autopilot/SKILL.md +8 -0
- package/pipeline/skills/shared/core/multi-agent-dev-local/SKILL.md +5 -18
- package/pipeline/skills/shared/core/multi-agent-dev-local-autopilot/SKILL.md +8 -0
- package/pipeline/skills/shared/core/multi-agent-language/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-local/SKILL.md +4 -0
- package/pipeline/skills/shared/core/multi-agent-local-autopilot/SKILL.md +4 -0
- package/pipeline/skills/shared/core/multi-agent-ship/SKILL.md +4 -0
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +2 -2
- package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +302 -0
- package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +144 -0
- package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +156 -0
- package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +108 -0
- package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +175 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +865 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +335 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +344 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +130 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +264 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +298 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +529 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +187 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +171 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +184 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/snapshot-resources.sh +26 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +173 -0
- package/pipeline/skills/skills-index.md +3 -2
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Render a "which key ↔ which UI element" overlay image for one screen.
|
|
3
|
+
|
|
4
|
+
Figma's own image render does NOT include the Dev Mode annotation overlay, so this
|
|
5
|
+
RECONSTRUCTS it deterministically: the screen image (REST render or an MCP-supplied
|
|
6
|
+
data URL) on the right, one card per keyable text element on the left, each card
|
|
7
|
+
tying the **localization key** to the element and showing the content team's
|
|
8
|
+
**CMS TR/EN** copy where annotated. No AI image generation - pure geometry + HTML +
|
|
9
|
+
headless Chrome (with an `.html` fallback when no Chrome is present).
|
|
10
|
+
|
|
11
|
+
Two input modes (mirrors fetch-annotations.py's REST/MCP split):
|
|
12
|
+
* REST (default) - needs a Figma token (--token / env / keychain FIGMA_ACCESS_TOKEN):
|
|
13
|
+
geometry `GET /v1/files/<key>/nodes?ids=<id>` → absoluteBoundingBox
|
|
14
|
+
image `GET /v1/images/<key>?ids=<id>&format=png&scale=<n>` → PNG url → data URL
|
|
15
|
+
* --spec <file> (MCP / no network): { fileKey, pages:[{nodeId,name,image:<dataUrl>,
|
|
16
|
+
frame:{w,h}, nodes:[{characters,tr,en,x,y,w,h}] }] } - the orchestrator builds
|
|
17
|
+
this from get_screenshot + use_figma so no REST calls happen.
|
|
18
|
+
|
|
19
|
+
Cards are keyed off the run's **mapping JSON** (`--mapping`): each text node is matched to
|
|
20
|
+
a mapping row (by `cmsNodeId`, else by normalized value) to label the card with its
|
|
21
|
+
`newKey`. A node carrying a Final UX Writing annotation → pink "İçerik (CMS)" card with
|
|
22
|
+
TR/EN; a keyable node without one → gray "Öneri bekleniyor" card with the on-screen text.
|
|
23
|
+
Non-keyable text (no key, no annotation) is dropped.
|
|
24
|
+
|
|
25
|
+
**No annotations needed.** When value-matching is unreliable (design wording ≠ resolved
|
|
26
|
+
values, or the frame mixes in background content), anchor explicitly: set each row's
|
|
27
|
+
`cmsNodeId` to a synthetic id and feed a `--spec` whose nodes carry the same `id` + the
|
|
28
|
+
box read off the clean screenshot - `cmsNodeId` matching is exact, so the overlay is
|
|
29
|
+
faithful with zero annotations. (`design-export/.../tree.json` is component-instance level
|
|
30
|
+
- use its root `nodeId` for the REST frame, not for per-label boxes.)
|
|
31
|
+
|
|
32
|
+
Output: `<out>/<slug>.overlay.png` (or `<slug>.overlay.html` fallback) + a sibling
|
|
33
|
+
`<slug>.overlay.manifest.json` (`page → file → {fileKey,nodeId}`) so the publish step
|
|
34
|
+
can find the image to attach. Read-only against every source.
|
|
35
|
+
"""
|
|
36
|
+
import argparse
|
|
37
|
+
import base64
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import re
|
|
41
|
+
import ssl
|
|
42
|
+
import subprocess
|
|
43
|
+
import sys
|
|
44
|
+
import tempfile
|
|
45
|
+
import unicodedata
|
|
46
|
+
import urllib.error
|
|
47
|
+
import urllib.parse
|
|
48
|
+
import urllib.request
|
|
49
|
+
|
|
50
|
+
FIGMA_API = "https://api.figma.com/v1"
|
|
51
|
+
OUT_SUFFIX = ".overlay.png"
|
|
52
|
+
|
|
53
|
+
# --- card layout constants (ported from web render-annotations.mjs) --------
|
|
54
|
+
CARD_W = 320
|
|
55
|
+
COL_X = 24
|
|
56
|
+
CHANNEL = 190
|
|
57
|
+
DESIGN_X = COL_X + CARD_W + CHANNEL
|
|
58
|
+
RIGHT_MARGIN = 40
|
|
59
|
+
TOP = 60
|
|
60
|
+
CARD_H = 116
|
|
61
|
+
CARD_GAP = 14
|
|
62
|
+
PINK = "#EC1E91"
|
|
63
|
+
GRAY = "#9aa0a6"
|
|
64
|
+
|
|
65
|
+
# Annotation authors separate label from value with a colon, a hyphen, or the
|
|
66
|
+
# en-dash their editor auto-inserts. Built from the codepoint so this file
|
|
67
|
+
# carries no literal dash variant of its own.
|
|
68
|
+
_DASHES = ':\\-' + chr(0x2013)
|
|
69
|
+
RE_TR = re.compile(rf"^(?:tr|tr[kc]e|t[üu]rk[çc]e)\s*[{_DASHES}]\s*(.+)$", re.I)
|
|
70
|
+
RE_EN = re.compile(rf"^(?:en|eng|ing|[iİ]ngilizce)\s*[{_DASHES}]\s*(.+)$", re.I)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def split_lang(raw):
|
|
74
|
+
if not raw:
|
|
75
|
+
return {"tr": None, "en": None}
|
|
76
|
+
lines = [ln.strip() for ln in re.split(r"\r?\n", raw) if ln.strip()]
|
|
77
|
+
tr = en = None
|
|
78
|
+
bare = []
|
|
79
|
+
for ln in lines:
|
|
80
|
+
m = RE_TR.match(ln)
|
|
81
|
+
if m and tr is None:
|
|
82
|
+
tr = m.group(1).strip()
|
|
83
|
+
continue
|
|
84
|
+
m = RE_EN.match(ln)
|
|
85
|
+
if m and en is None:
|
|
86
|
+
en = m.group(1).strip()
|
|
87
|
+
continue
|
|
88
|
+
bare.append(ln)
|
|
89
|
+
if tr is not None or en is not None:
|
|
90
|
+
bi = 0
|
|
91
|
+
if tr is None and bi < len(bare):
|
|
92
|
+
tr = bare[bi]; bi += 1
|
|
93
|
+
if en is None and bi < len(bare):
|
|
94
|
+
en = bare[bi]; bi += 1
|
|
95
|
+
return {"tr": tr, "en": en}
|
|
96
|
+
if len(bare) >= 2:
|
|
97
|
+
return {"tr": bare[0], "en": bare[1]}
|
|
98
|
+
if len(bare) == 1:
|
|
99
|
+
return {"tr": bare[0], "en": None}
|
|
100
|
+
return {"tr": None, "en": None}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def canon_id(s):
|
|
104
|
+
return re.sub(r"^(\d+)-(\d+)$", r"\1:\2", (s or "").strip())
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def norm(s):
|
|
108
|
+
"""NFC → trim → collapse whitespace → strip trailing .:... → lower (web `norm`)."""
|
|
109
|
+
if not s:
|
|
110
|
+
return ""
|
|
111
|
+
s = unicodedata.normalize("NFC", s).strip()
|
|
112
|
+
s = re.sub(r"\s+", " ", s)
|
|
113
|
+
s = re.sub(r"[.:...]+$", "", s).strip()
|
|
114
|
+
return s.lower()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def slug(s):
|
|
118
|
+
s = (s or "screen").strip()
|
|
119
|
+
s = re.sub(r'[/\\:*?"<>|]+', "-", s)
|
|
120
|
+
s = re.sub(r"\s+", "-", s)
|
|
121
|
+
s = re.sub(r"-{2,}", "-", s)
|
|
122
|
+
return s.strip("-").lower() or "screen"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def esc(s):
|
|
126
|
+
return (str(s) if s is not None else "").replace("&", "&").replace("<", "<").replace(">", ">")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# --- Figma token + HTTP (self-contained, mirrors fetch-annotations.py) ------
|
|
130
|
+
def figma_token(args):
|
|
131
|
+
if args.token:
|
|
132
|
+
return args.token
|
|
133
|
+
for env in ("FIGMA_ACCESS_TOKEN", "FIGMA_TOKEN"):
|
|
134
|
+
v = os.environ.get(env)
|
|
135
|
+
if v:
|
|
136
|
+
return v.strip()
|
|
137
|
+
try:
|
|
138
|
+
r = subprocess.run(
|
|
139
|
+
["security", "find-generic-password", "-s", "FIGMA_ACCESS_TOKEN", "-w"],
|
|
140
|
+
capture_output=True, text=True)
|
|
141
|
+
if r.returncode == 0 and r.stdout.strip():
|
|
142
|
+
return r.stdout.strip()
|
|
143
|
+
except Exception:
|
|
144
|
+
pass
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _ctx(ca_file):
|
|
149
|
+
return ssl.create_default_context(cafile=ca_file) if ca_file else ssl.create_default_context()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def http_get_json(url, headers, ca_file):
|
|
153
|
+
req = urllib.request.Request(url, headers=headers)
|
|
154
|
+
try:
|
|
155
|
+
with urllib.request.urlopen(req, context=_ctx(ca_file), timeout=60) as r:
|
|
156
|
+
return json.load(r)
|
|
157
|
+
except urllib.error.HTTPError as e:
|
|
158
|
+
if e.code == 429:
|
|
159
|
+
sys.exit("Figma 429 (rate-limited) - re-run with --spec (MCP path) instead.")
|
|
160
|
+
sys.exit(f"Figma HTTP {e.code} for {url}: {e.read()[:200]!r}")
|
|
161
|
+
except urllib.error.URLError as e:
|
|
162
|
+
sys.exit(f"Figma request failed: {getattr(e, 'reason', e)}")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def http_get_bytes(url, ca_file):
|
|
166
|
+
try:
|
|
167
|
+
with urllib.request.urlopen(url, context=_ctx(ca_file), timeout=120) as r:
|
|
168
|
+
return r.read()
|
|
169
|
+
except Exception as e:
|
|
170
|
+
sys.exit(f"image fetch failed: {e}")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# --- node collection (REST) ------------------------------------------------
|
|
174
|
+
def node_anno(node):
|
|
175
|
+
anns = node.get("annotations")
|
|
176
|
+
if not isinstance(anns, list):
|
|
177
|
+
return {"tr": None, "en": None}
|
|
178
|
+
for a in anns:
|
|
179
|
+
if not isinstance(a, dict):
|
|
180
|
+
continue
|
|
181
|
+
lbl = a.get("label") or a.get("labelMarkdown") or ""
|
|
182
|
+
if lbl:
|
|
183
|
+
p = split_lang(lbl)
|
|
184
|
+
if p["tr"] is not None or p["en"] is not None:
|
|
185
|
+
return p
|
|
186
|
+
return {"tr": None, "en": None}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def collect_rest_nodes(file_key, node_id, token, ca_file, scale):
|
|
190
|
+
headers = {"X-Figma-Token": token}
|
|
191
|
+
data = http_get_json(f"{FIGMA_API}/files/{file_key}/nodes?ids={urllib.parse.quote(node_id, safe='')}",
|
|
192
|
+
headers, ca_file)
|
|
193
|
+
entry = (data.get("nodes") or {}).get(node_id) or {}
|
|
194
|
+
doc = entry.get("document")
|
|
195
|
+
if not doc:
|
|
196
|
+
sys.exit(f"node {node_id} not found in file {file_key}")
|
|
197
|
+
fb = doc.get("absoluteBoundingBox") or {"x": 0, "y": 0, "width": 0, "height": 0}
|
|
198
|
+
frame = {"w": round(fb.get("width", 0)), "h": round(fb.get("height", 0))}
|
|
199
|
+
nodes = []
|
|
200
|
+
|
|
201
|
+
def walk(n):
|
|
202
|
+
b = n.get("absoluteBoundingBox")
|
|
203
|
+
is_text = n.get("type") == "TEXT" and isinstance(n.get("characters"), str) and n["characters"].strip()
|
|
204
|
+
ann = node_anno(n)
|
|
205
|
+
if b and (is_text or ann["tr"] is not None or ann["en"] is not None):
|
|
206
|
+
nodes.append({
|
|
207
|
+
"id": n.get("id"),
|
|
208
|
+
"characters": n.get("characters") if is_text else "",
|
|
209
|
+
"tr": ann["tr"], "en": ann["en"],
|
|
210
|
+
"x": round(b["x"] - fb["x"]), "y": round(b["y"] - fb["y"]),
|
|
211
|
+
"w": round(b["width"]), "h": round(b["height"]),
|
|
212
|
+
})
|
|
213
|
+
for c in n.get("children", []) or []:
|
|
214
|
+
walk(c)
|
|
215
|
+
|
|
216
|
+
walk(doc)
|
|
217
|
+
|
|
218
|
+
# image render → data URL
|
|
219
|
+
img = http_get_json(
|
|
220
|
+
f"{FIGMA_API}/images/{file_key}?ids={urllib.parse.quote(node_id, safe='')}&format=png&scale={scale}",
|
|
221
|
+
headers, ca_file)
|
|
222
|
+
url = (img.get("images") or {}).get(node_id)
|
|
223
|
+
if not url:
|
|
224
|
+
sys.exit(f"no image render returned for {node_id}")
|
|
225
|
+
raw = http_get_bytes(url, ca_file)
|
|
226
|
+
data_url = "data:image/png;base64," + base64.b64encode(raw).decode("ascii")
|
|
227
|
+
return {"nodeId": node_id, "image": data_url, "frame": frame, "nodes": nodes}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# --- card model ------------------------------------------------------------
|
|
231
|
+
def build_index(rows):
|
|
232
|
+
by_node, by_val = {}, {}
|
|
233
|
+
for r in rows:
|
|
234
|
+
key = r.get("newKey")
|
|
235
|
+
if not key:
|
|
236
|
+
continue
|
|
237
|
+
nid = r.get("cmsNodeId")
|
|
238
|
+
if nid:
|
|
239
|
+
by_node[canon_id(nid)] = r
|
|
240
|
+
# index every known value (tr + en across new / cms / legacy) so a node
|
|
241
|
+
# matches its key regardless of the frame's language or wording source
|
|
242
|
+
for src in (r.get("new") or {}, r.get("cms") or {}, r.get("legacy") or {}):
|
|
243
|
+
if isinstance(src, dict):
|
|
244
|
+
for lang in ("tr", "en"):
|
|
245
|
+
v = src.get(lang)
|
|
246
|
+
if v:
|
|
247
|
+
by_val.setdefault(norm(v), r)
|
|
248
|
+
if r.get("element"):
|
|
249
|
+
by_val.setdefault(norm(r["element"]), r)
|
|
250
|
+
return by_node, by_val
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def match_row(node, by_node, by_val):
|
|
254
|
+
nid = canon_id(node.get("id") or "")
|
|
255
|
+
if nid in by_node:
|
|
256
|
+
return by_node[nid]
|
|
257
|
+
for v in (node.get("tr"), node.get("characters")):
|
|
258
|
+
if v and norm(v) in by_val:
|
|
259
|
+
return by_val[norm(v)]
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def build_cards(page, rows):
|
|
264
|
+
by_node, by_val = build_index(rows)
|
|
265
|
+
# 1-based index of every row in the mapping → the SAME number build-artifact prints
|
|
266
|
+
# in the summary/detail tables (both enumerate rows in order). The overlay card badge
|
|
267
|
+
# uses it so a mark on the screen and its table row always carry the same number.
|
|
268
|
+
row_num = {id(r): i for i, r in enumerate(rows, 1)}
|
|
269
|
+
cards = []
|
|
270
|
+
for n in page["nodes"]:
|
|
271
|
+
row = match_row(n, by_node, by_val)
|
|
272
|
+
key = row.get("newKey") if row else None
|
|
273
|
+
# CMS values: prefer the node's own annotation, else the matched row's cms.*
|
|
274
|
+
tr = n.get("tr")
|
|
275
|
+
en = n.get("en")
|
|
276
|
+
if tr is None and en is None and row and isinstance(row.get("cms"), dict):
|
|
277
|
+
tr, en = row["cms"].get("tr"), row["cms"].get("en")
|
|
278
|
+
if not key and tr is None and en is None:
|
|
279
|
+
continue # non-keyable
|
|
280
|
+
cards.append({
|
|
281
|
+
"key": key or "(eşleşmedi)",
|
|
282
|
+
"tr": tr, "en": en,
|
|
283
|
+
"designText": n.get("characters") or "",
|
|
284
|
+
"x": n["x"], "y": n["y"], "w": n["w"], "h": n["h"],
|
|
285
|
+
"annotated": tr is not None or en is not None,
|
|
286
|
+
"side": (n.get("side") or "left").lower(),
|
|
287
|
+
"num": row_num.get(id(row)) if row else None, # table row number (or None → sequential)
|
|
288
|
+
})
|
|
289
|
+
cards.sort(key=lambda c: c["y"])
|
|
290
|
+
return cards
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# --- HTML composition ------------------------------------------------------
|
|
294
|
+
def build_html(page, cards, title):
|
|
295
|
+
"""Lay out cards on EITHER side of the frame (per-card `side`: left|right). Cards
|
|
296
|
+
that don't read well on the left (e.g. right-column UI like the amenity grid's
|
|
297
|
+
right entries) are placed on the right so their connector stays short and doesn't
|
|
298
|
+
cross the whole frame. Each column is ordered by target y and greedily stacked;
|
|
299
|
+
each link gets its own lane and a numbered dot matching its card. Returns
|
|
300
|
+
(html, canvas_w, canvas_h)."""
|
|
301
|
+
frame_w = page["frame"]["w"] or 800
|
|
302
|
+
frame_h = page["frame"]["h"] or 600
|
|
303
|
+
left_card_right = COL_X + CARD_W
|
|
304
|
+
design_right = DESIGN_X + frame_w
|
|
305
|
+
right_col_x = design_right + CHANNEL
|
|
306
|
+
|
|
307
|
+
items = []
|
|
308
|
+
for c in cards:
|
|
309
|
+
tx = DESIGN_X + c["x"] + c["w"] / 2
|
|
310
|
+
ty = TOP + c["y"] + c["h"] / 2
|
|
311
|
+
items.append({"tx": tx, "ty": ty, "c": c, "side": c.get("side", "left")})
|
|
312
|
+
|
|
313
|
+
left = sorted([it for it in items if it["side"] != "right"], key=lambda it: it["ty"])
|
|
314
|
+
right = sorted([it for it in items if it["side"] == "right"], key=lambda it: it["ty"])
|
|
315
|
+
has_right = bool(right)
|
|
316
|
+
|
|
317
|
+
def place(col):
|
|
318
|
+
cursor = TOP
|
|
319
|
+
for it in col:
|
|
320
|
+
it["y"] = max(cursor, it["ty"] - CARD_H / 2, TOP)
|
|
321
|
+
cursor = it["y"] + CARD_H + CARD_GAP
|
|
322
|
+
return cursor
|
|
323
|
+
cur_l, cur_r = place(left), place(right)
|
|
324
|
+
|
|
325
|
+
canvas_w = (right_col_x + CARD_W + RIGHT_MARGIN) if has_right else (design_right + RIGHT_MARGIN)
|
|
326
|
+
canvas_h = max(TOP + frame_h + TOP, cur_l + TOP, cur_r + TOP)
|
|
327
|
+
|
|
328
|
+
# numbering: use the matched row's table number so the mark on the screen and its
|
|
329
|
+
# table row agree; fall back to sequential only for a card with no matched row.
|
|
330
|
+
seq = 1
|
|
331
|
+
for it in left + right:
|
|
332
|
+
n = it["c"].get("num")
|
|
333
|
+
if n is None:
|
|
334
|
+
n = seq
|
|
335
|
+
it["num"] = n
|
|
336
|
+
seq += 1
|
|
337
|
+
|
|
338
|
+
nL, nR = len(left), len(right)
|
|
339
|
+
lane_step_l = (CHANNEL - 30) / max(1, nL - 1) if nL > 1 else 0
|
|
340
|
+
lane_step_r = (CHANNEL - 30) / max(1, nR - 1) if nR > 1 else 0
|
|
341
|
+
|
|
342
|
+
connectors, card_html = [], []
|
|
343
|
+
|
|
344
|
+
def emit(it, idx, side):
|
|
345
|
+
c, tx, ty, y, num = it["c"], it["tx"], it["ty"], it["y"], it["num"]
|
|
346
|
+
card_mid = y + CARD_H / 2
|
|
347
|
+
colour = PINK if c["annotated"] else GRAY
|
|
348
|
+
if side == "right":
|
|
349
|
+
lane = design_right + 16 + idx * lane_step_r
|
|
350
|
+
path = f'M {right_col_x} {card_mid:.0f} H {lane:.0f} V {ty:.0f} H {tx:.0f}'
|
|
351
|
+
left_px = right_col_x
|
|
352
|
+
else:
|
|
353
|
+
lane = left_card_right + 16 + idx * lane_step_l
|
|
354
|
+
path = f'M {left_card_right} {card_mid:.0f} H {lane:.0f} V {ty:.0f} H {tx:.0f}'
|
|
355
|
+
left_px = COL_X
|
|
356
|
+
connectors.append(
|
|
357
|
+
f'<path d="{path}" '
|
|
358
|
+
f'stroke="{colour}" stroke-width="1.4" stroke-dasharray="5 4" fill="none" opacity="0.85"/>'
|
|
359
|
+
f'<circle cx="{tx:.0f}" cy="{ty:.0f}" r="9" fill="{colour}"/>'
|
|
360
|
+
f'<text x="{tx:.0f}" y="{ty + 3.5:.0f}" text-anchor="middle" font-size="11" '
|
|
361
|
+
f'font-weight="700" fill="#fff">{num}</text>')
|
|
362
|
+
badge = f'<span class="num" style="background:{colour}">{num}</span>'
|
|
363
|
+
if c["annotated"]:
|
|
364
|
+
pill = f'<span class="pill" style="background:{PINK}">İçerik (CMS)</span>'
|
|
365
|
+
rows = f'<div class="row"><span class="lang">TR:</span> {esc(c["tr"]) if c["tr"] else "-"}</div>'
|
|
366
|
+
if c["en"] is not None:
|
|
367
|
+
rows += f'<div class="row"><span class="lang">EN:</span> {esc(c["en"])}</div>'
|
|
368
|
+
klass = ""
|
|
369
|
+
else:
|
|
370
|
+
pill = f'<span class="pill" style="background:{GRAY}">Öneri bekleniyor</span>'
|
|
371
|
+
rows = f'<div class="row muted">metin: {esc(c["designText"]) or "-"}</div>'
|
|
372
|
+
klass = " keyonly"
|
|
373
|
+
card_html.append(
|
|
374
|
+
f'<div class="card{klass}" style="left:{left_px:.0f}px;top:{y:.0f}px">'
|
|
375
|
+
f'<div class="hd">{badge}{pill}</div>'
|
|
376
|
+
f'<div class="key">{esc(c["key"])}</div>{rows}</div>')
|
|
377
|
+
|
|
378
|
+
for i, it in enumerate(left):
|
|
379
|
+
emit(it, i, "left")
|
|
380
|
+
for i, it in enumerate(right):
|
|
381
|
+
emit(it, i, "right")
|
|
382
|
+
|
|
383
|
+
html = f"""<!doctype html><html><head><meta charset="utf-8"><style>
|
|
384
|
+
* {{ box-sizing:border-box; }}
|
|
385
|
+
body {{ margin:0; background:#1e1e1e; font-family:-apple-system,'Segoe UI',Roboto,Arial,sans-serif; }}
|
|
386
|
+
.canvas {{ position:relative; width:{canvas_w}px; height:{canvas_h:.0f}px; }}
|
|
387
|
+
.design {{ position:absolute; left:{DESIGN_X}px; top:{TOP}px; width:{frame_w}px; height:{frame_h}px;
|
|
388
|
+
box-shadow:0 0 0 1px #333; }}
|
|
389
|
+
svg.links {{ position:absolute; left:0; top:0; width:{canvas_w}px; height:{canvas_h:.0f}px; pointer-events:none; }}
|
|
390
|
+
.card {{ position:absolute; width:{CARD_W}px; min-height:{CARD_H}px; background:#2b2b2b; color:#e8e8e8;
|
|
391
|
+
border-radius:10px; padding:10px 14px; box-shadow:0 1px 4px rgba(0,0,0,.4); }}
|
|
392
|
+
.card.keyonly {{ background:#f4f5f6; color:#202124; }}
|
|
393
|
+
.hd {{ display:flex; align-items:center; gap:7px; margin-bottom:6px; }}
|
|
394
|
+
.num {{ display:inline-flex; align-items:center; justify-content:center; width:18px; height:18px;
|
|
395
|
+
border-radius:50%; color:#fff; font-size:11px; font-weight:700; flex:none; }}
|
|
396
|
+
.pill {{ display:inline-block; color:#fff; font-size:11px; font-weight:600; padding:2px 8px;
|
|
397
|
+
border-radius:10px; }}
|
|
398
|
+
.key {{ font-family:'SF Mono',Menlo,Consolas,monospace; font-size:12px; color:#9ad; margin-bottom:6px;
|
|
399
|
+
word-break:break-all; }}
|
|
400
|
+
.card.keyonly .key {{ color:#1666c8; }}
|
|
401
|
+
.row {{ font-size:13px; line-height:1.35; }}
|
|
402
|
+
.row.muted {{ color:#5f6368; }}
|
|
403
|
+
.lang {{ color:{PINK}; font-weight:600; }}
|
|
404
|
+
.title {{ position:absolute; left:{COL_X}px; top:18px; color:#bbb; font-size:13px; }}
|
|
405
|
+
</style></head><body><div class="canvas">
|
|
406
|
+
<div class="title">{esc(title)}</div>
|
|
407
|
+
<img class="design" src="{page['image']}">
|
|
408
|
+
<svg class="links">{''.join(connectors)}</svg>
|
|
409
|
+
{''.join(card_html)}
|
|
410
|
+
</div></body></html>"""
|
|
411
|
+
return html, canvas_w, canvas_h
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# --- rasterize -------------------------------------------------------------
|
|
415
|
+
def find_chrome():
|
|
416
|
+
for p in (
|
|
417
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
418
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
419
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
420
|
+
):
|
|
421
|
+
if os.path.isfile(p) and os.access(p, os.X_OK):
|
|
422
|
+
return p
|
|
423
|
+
from shutil import which
|
|
424
|
+
for c in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"):
|
|
425
|
+
w = which(c)
|
|
426
|
+
if w:
|
|
427
|
+
return w
|
|
428
|
+
return None
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def render_png(html, out_path, canvas_w, canvas_h, scale):
|
|
432
|
+
chrome = find_chrome()
|
|
433
|
+
if not chrome:
|
|
434
|
+
alt = out_path[:-4] + ".html" if out_path.endswith(".png") else out_path + ".html"
|
|
435
|
+
with open(alt, "w", encoding="utf-8") as fh:
|
|
436
|
+
fh.write(html)
|
|
437
|
+
sys.stderr.write(f"# no headless Chrome found - wrote {alt} (open/print to PDF)\n")
|
|
438
|
+
return alt
|
|
439
|
+
with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, encoding="utf-8") as tf:
|
|
440
|
+
tf.write(html)
|
|
441
|
+
tmp = tf.name
|
|
442
|
+
try:
|
|
443
|
+
r = subprocess.run(
|
|
444
|
+
[chrome, "--headless", "--disable-gpu", "--hide-scrollbars",
|
|
445
|
+
f"--force-device-scale-factor={scale}",
|
|
446
|
+
f"--window-size={canvas_w},{int(canvas_h)}",
|
|
447
|
+
f"--screenshot={out_path}", tmp],
|
|
448
|
+
capture_output=True, text=True, timeout=120)
|
|
449
|
+
if not os.path.isfile(out_path):
|
|
450
|
+
sys.exit(f"Chrome screenshot failed: {r.stderr[:300]}")
|
|
451
|
+
finally:
|
|
452
|
+
try:
|
|
453
|
+
os.unlink(tmp)
|
|
454
|
+
except OSError:
|
|
455
|
+
pass
|
|
456
|
+
return out_path
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def main():
|
|
460
|
+
ap = argparse.ArgumentParser(description=__doc__,
|
|
461
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
462
|
+
ap.add_argument("--mapping", required=True, help="mapping JSON (rows + figmaFileKey/figmaNodes + screen)")
|
|
463
|
+
ap.add_argument("--spec", help="MCP spec JSON (no network)")
|
|
464
|
+
ap.add_argument("--file", help="Figma fileKey (REST; else from --mapping figmaFileKey)")
|
|
465
|
+
ap.add_argument("--nodes", help="node-ids (REST; else from --mapping figmaNodes)")
|
|
466
|
+
ap.add_argument("--scale", type=int, default=2)
|
|
467
|
+
ap.add_argument("--token")
|
|
468
|
+
ap.add_argument("--ca-file")
|
|
469
|
+
ap.add_argument("--out", default=".", help="output dir")
|
|
470
|
+
ap.add_argument("--slug", help="override slug (default from screen name)")
|
|
471
|
+
args = ap.parse_args()
|
|
472
|
+
|
|
473
|
+
with open(args.mapping, encoding="utf-8") as fh:
|
|
474
|
+
m = json.load(fh)
|
|
475
|
+
rows = m.get("rows") or []
|
|
476
|
+
screen = m.get("screen") or "screen"
|
|
477
|
+
sl = args.slug or slug(screen)
|
|
478
|
+
os.makedirs(args.out, exist_ok=True)
|
|
479
|
+
|
|
480
|
+
file_key = args.file or m.get("figmaFileKey")
|
|
481
|
+
node_ids = [n.strip() for n in (args.nodes or "").split(",") if n.strip()] or \
|
|
482
|
+
[str(n) for n in (m.get("figmaNodes") or [])]
|
|
483
|
+
|
|
484
|
+
# one overlay for the screen: use the FIRST node as the framing node (the screen frame)
|
|
485
|
+
if args.spec:
|
|
486
|
+
with open(args.spec, encoding="utf-8") as fh:
|
|
487
|
+
spec = json.load(fh)
|
|
488
|
+
pages = spec.get("pages") or []
|
|
489
|
+
if not pages:
|
|
490
|
+
sys.exit("--spec has no pages")
|
|
491
|
+
page = pages[0]
|
|
492
|
+
file_key = file_key or spec.get("fileKey")
|
|
493
|
+
else:
|
|
494
|
+
if not (file_key and node_ids):
|
|
495
|
+
ap.error("REST needs a fileKey + node-ids (via --file/--nodes or the mapping). "
|
|
496
|
+
"Or pass --spec for the MCP path.")
|
|
497
|
+
token = figma_token(args)
|
|
498
|
+
if not token:
|
|
499
|
+
sys.exit("No Figma token - pass --token, set FIGMA_ACCESS_TOKEN, or add the "
|
|
500
|
+
"keychain item FIGMA_ACCESS_TOKEN (or use --spec).")
|
|
501
|
+
page = collect_rest_nodes(file_key, canon_id(node_ids[0]), token, args.ca_file, args.scale)
|
|
502
|
+
page["name"] = screen
|
|
503
|
+
|
|
504
|
+
cards = build_cards(page, rows)
|
|
505
|
+
if not cards:
|
|
506
|
+
sys.stderr.write("# warning: no keyable cards built (no node matched a row / no annotations)\n")
|
|
507
|
+
html, canvas_w, canvas_h = build_html(page, cards, screen)
|
|
508
|
+
out_png = os.path.join(args.out, sl + OUT_SUFFIX)
|
|
509
|
+
written = render_png(html, out_png, canvas_w, canvas_h, args.scale)
|
|
510
|
+
|
|
511
|
+
manifest = {
|
|
512
|
+
"screen": screen,
|
|
513
|
+
"file": os.path.basename(written),
|
|
514
|
+
"fileKey": file_key,
|
|
515
|
+
"nodeId": canon_id(node_ids[0]) if node_ids else page.get("nodeId"),
|
|
516
|
+
"cardCount": len(cards),
|
|
517
|
+
"annotatedCount": sum(1 for c in cards if c["annotated"]),
|
|
518
|
+
"width": canvas_w, "height": int(canvas_h), "scale": args.scale,
|
|
519
|
+
}
|
|
520
|
+
mpath = os.path.join(args.out, sl + ".overlay.manifest.json")
|
|
521
|
+
with open(mpath, "w", encoding="utf-8") as fh:
|
|
522
|
+
json.dump(manifest, fh, ensure_ascii=False, indent=2)
|
|
523
|
+
fh.write("\n")
|
|
524
|
+
sys.stderr.write(f"# overlay: {len(cards)} card(s), {manifest['annotatedCount']} annotated → "
|
|
525
|
+
f"{written}\n")
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
if __name__ == "__main__":
|
|
529
|
+
main()
|