@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,335 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Build the content-team Excel (CMS import) sheet from a mapping JSON.
|
|
3
|
+
|
|
4
|
+
One row per localization key, in the columns the content team's CMS import expects:
|
|
5
|
+
|
|
6
|
+
Channel | Property Group | Property Module | Key | EN Value | TR Value | AR Value | Anotation EN | Anotation TR
|
|
7
|
+
|
|
8
|
+
- Channel - the delivery channel these strings belong to ("Mobile" by default; --channel).
|
|
9
|
+
- Property Group - deduced from the key namespace; describe your CMS buckets with --taxonomy, or let a
|
|
10
|
+
row override it outright with a "propertyGroup" field.
|
|
11
|
+
- Property Module - deduced too (Fields / Validation / Common / <domain>); overridable with "propertyModule".
|
|
12
|
+
- Key - our NEW (redesign) key, verbatim (row.newKey). "(owned by ...)" component tags are stripped.
|
|
13
|
+
- EN/TR/AR Value - our SUGGESTED values (row.new.en / .tr / .ar - resolve-new-values.py output).
|
|
14
|
+
- Anotation EN/TR - the content team's ACTUAL copy, the Figma CMS annotations (row.cms.en / .tr).
|
|
15
|
+
|
|
16
|
+
Writes a real `.xlsx` (an OOXML zip built with the Python stdlib only - no pip, no network, same trick
|
|
17
|
+
build-artifact.py uses for .docx). `--csv` also emits a UTF-8 CSV alongside it (a terminal-friendly fallback
|
|
18
|
+
when Excel is not needed). `--csv-only` skips the xlsx.
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
python3 build-spreadsheet.py <mapping>.json --out <dir> [--slug <slug>] [--channel Mobile]
|
|
22
|
+
[--taxonomy cms-taxonomy.json] [--csv|--csv-only]
|
|
23
|
+
"""
|
|
24
|
+
import argparse
|
|
25
|
+
import csv
|
|
26
|
+
import html
|
|
27
|
+
import io
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
import zipfile
|
|
32
|
+
|
|
33
|
+
COLUMNS = ["Channel", "Property Group", "Property Module", "Key",
|
|
34
|
+
"EN Value", "TR Value", "AR Value", "Anotation EN", "Anotation TR"]
|
|
35
|
+
|
|
36
|
+
# Approx display widths (chars) per column, for the xlsx.
|
|
37
|
+
COL_WIDTHS = [10, 22, 20, 40, 26, 26, 26, 26, 26]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _text(v):
|
|
41
|
+
return "" if v in (None, "") else str(v)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def slugify(name):
|
|
45
|
+
slug = "".join(ch if ch.isalnum() else "-" for ch in _text(name).lower()).strip("-")
|
|
46
|
+
while "--" in slug:
|
|
47
|
+
slug = slug.replace("--", "-")
|
|
48
|
+
return slug or "screen"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def clean_key(row):
|
|
52
|
+
"""The Key column = our new key, without a trailing '(owned by ...)' component tag."""
|
|
53
|
+
k = _text(row.get("newKey"))
|
|
54
|
+
i = k.find("(owned by")
|
|
55
|
+
return (k[:i].strip() if i >= 0 else k).strip()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# --- Property Group / Module deduction ------------------------------------------------
|
|
59
|
+
# A CMS typically files keys into a group (shared building blocks vs feature areas) and a
|
|
60
|
+
# module inside it. The deduction below is a neutral default; every CMS names its buckets
|
|
61
|
+
# differently, so point --taxonomy at a JSON file to describe yours:
|
|
62
|
+
#
|
|
63
|
+
# {
|
|
64
|
+
# "errors": {"group": "Common-Errors", "module": ""},
|
|
65
|
+
# "lookups": {"group": "Common-Lookup", "module": ""},
|
|
66
|
+
# "validation": {"group": "Core", "module": "Validation"},
|
|
67
|
+
# "fields": {"group": "Core", "module": "Fields"},
|
|
68
|
+
# "fallback": {"group": "Core", "module": "Common"},
|
|
69
|
+
# "domainGroup": "Domains",
|
|
70
|
+
# "domains": {"auth": ["login", "signup", "otp"], "billing": ["invoice", "card"]}
|
|
71
|
+
# }
|
|
72
|
+
#
|
|
73
|
+
# Any subset may be given; unspecified parts keep the defaults. A mapping row always wins
|
|
74
|
+
# over deduction via explicit "propertyGroup" / "propertyModule".
|
|
75
|
+
|
|
76
|
+
DEFAULT_TAXONOMY = {
|
|
77
|
+
"errors": {"group": "Errors", "module": ""},
|
|
78
|
+
"lookups": {"group": "Lookups", "module": ""},
|
|
79
|
+
"validation": {"group": "Core", "module": "Validation"},
|
|
80
|
+
"fields": {"group": "Core", "module": "Fields"},
|
|
81
|
+
"fallback": {"group": "Core", "module": "Common"},
|
|
82
|
+
"domainGroup": "Domains",
|
|
83
|
+
"domains": {
|
|
84
|
+
"auth": ["auth", "login", "signin", "sign-in", "register", "signup", "otp", "password"],
|
|
85
|
+
"account": ["account", "profile", "settings", "preferences"],
|
|
86
|
+
"checkout": ["checkout", "cart", "payment", "billing", "order"],
|
|
87
|
+
"search": ["search", "filter", "results"],
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
_TAXONOMY = DEFAULT_TAXONOMY
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def load_taxonomy(path):
|
|
95
|
+
"""Merge a user taxonomy file over the neutral defaults (one level deep)."""
|
|
96
|
+
if not path:
|
|
97
|
+
return dict(DEFAULT_TAXONOMY)
|
|
98
|
+
with open(path, encoding="utf-8") as fh:
|
|
99
|
+
user = json.load(fh)
|
|
100
|
+
if not isinstance(user, dict):
|
|
101
|
+
raise SystemExit(f"--taxonomy must be a JSON object, got {type(user).__name__}")
|
|
102
|
+
merged = dict(DEFAULT_TAXONOMY)
|
|
103
|
+
for k, v in user.items():
|
|
104
|
+
if isinstance(v, dict) and isinstance(merged.get(k), dict):
|
|
105
|
+
merged[k] = {**merged[k], **v}
|
|
106
|
+
else:
|
|
107
|
+
merged[k] = v
|
|
108
|
+
return merged
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _bucket(name, group, module):
|
|
112
|
+
b = _TAXONOMY.get(name) or {}
|
|
113
|
+
return (group or _text(b.get("group")), module or _text(b.get("module")))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _first_segment(key):
|
|
117
|
+
for sep in (".", "-", "_", "/"):
|
|
118
|
+
if sep in key:
|
|
119
|
+
return key.split(sep, 1)[0]
|
|
120
|
+
return key
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def deduce_group_module(row):
|
|
124
|
+
"""Return (group, module) for a row, honoring explicit overrides first."""
|
|
125
|
+
group = _text(row.get("propertyGroup"))
|
|
126
|
+
module = _text(row.get("propertyModule"))
|
|
127
|
+
if group and module:
|
|
128
|
+
return group, module
|
|
129
|
+
|
|
130
|
+
key = clean_key(row)
|
|
131
|
+
low = key.lower()
|
|
132
|
+
|
|
133
|
+
# Cross-cutting pools first.
|
|
134
|
+
if "error" in low or "errormessage" in low:
|
|
135
|
+
return _bucket("errors", group, module)
|
|
136
|
+
if "lookup" in low:
|
|
137
|
+
return _bucket("lookups", group, module)
|
|
138
|
+
|
|
139
|
+
# Core building blocks.
|
|
140
|
+
if "validation" in low or "invalid" in low or low.endswith(".valid"):
|
|
141
|
+
return _bucket("validation", group, module)
|
|
142
|
+
if any(w in low for w in ("field", "placeholder", "hint", "label")):
|
|
143
|
+
return _bucket("fields", group, module)
|
|
144
|
+
|
|
145
|
+
# Feature domains, matched on the first namespace segment then anywhere in the key.
|
|
146
|
+
first = _first_segment(low)
|
|
147
|
+
for dom, hints in (_TAXONOMY.get("domains") or {}).items():
|
|
148
|
+
if first in hints or any(h in low for h in hints):
|
|
149
|
+
return (group or _text(_TAXONOMY.get("domainGroup")), module or dom)
|
|
150
|
+
|
|
151
|
+
return _bucket("fallback", group, module)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def row_to_record(row, channel):
|
|
155
|
+
group, module = deduce_group_module(row)
|
|
156
|
+
new = row.get("new") or {}
|
|
157
|
+
cms = row.get("cms") or {}
|
|
158
|
+
return [
|
|
159
|
+
channel,
|
|
160
|
+
group,
|
|
161
|
+
module,
|
|
162
|
+
clean_key(row),
|
|
163
|
+
_text(new.get("en")),
|
|
164
|
+
_text(new.get("tr")),
|
|
165
|
+
_text(new.get("ar")),
|
|
166
|
+
_text(cms.get("en")),
|
|
167
|
+
_text(cms.get("tr")),
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def build_records(mapping, channel):
|
|
172
|
+
return [row_to_record(r, channel) for r in mapping.get("rows", [])]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# --- xlsx writer (stdlib only: an .xlsx is a zip of OOXML parts) -----------------------
|
|
176
|
+
|
|
177
|
+
def _xesc(s):
|
|
178
|
+
return html.escape(_text(s), quote=True)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _col_letter(idx): # 0-based -> A, B, ... Z, AA, ...
|
|
182
|
+
idx += 1
|
|
183
|
+
out = ""
|
|
184
|
+
while idx:
|
|
185
|
+
idx, rem = divmod(idx - 1, 26)
|
|
186
|
+
out = chr(65 + rem) + out
|
|
187
|
+
return out
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _sheet_xml(records):
|
|
191
|
+
cols = "".join(
|
|
192
|
+
f'<col min="{i+1}" max="{i+1}" width="{w}" customWidth="1"/>'
|
|
193
|
+
for i, w in enumerate(COL_WIDTHS)
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def cell(col_idx, row_idx, value, style):
|
|
197
|
+
ref = f"{_col_letter(col_idx)}{row_idx}"
|
|
198
|
+
return (f'<c r="{ref}" t="inlineStr" s="{style}">'
|
|
199
|
+
f'<is><t xml:space="preserve">{_xesc(value)}</t></is></c>')
|
|
200
|
+
|
|
201
|
+
rows_xml = []
|
|
202
|
+
header = "".join(cell(i, 1, COLUMNS[i], 1) for i in range(len(COLUMNS)))
|
|
203
|
+
rows_xml.append(f'<row r="1">{header}</row>')
|
|
204
|
+
for ri, rec in enumerate(records, start=2):
|
|
205
|
+
cells = "".join(cell(i, ri, rec[i], 0) for i in range(len(COLUMNS)))
|
|
206
|
+
rows_xml.append(f'<row r="{ri}">{cells}</row>')
|
|
207
|
+
|
|
208
|
+
last = f"{_col_letter(len(COLUMNS)-1)}{max(1, len(records)+1)}"
|
|
209
|
+
return (
|
|
210
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
211
|
+
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
|
212
|
+
f'<dimension ref="A1:{last}"/>'
|
|
213
|
+
'<sheetViews><sheetView workbookViewId="0">'
|
|
214
|
+
'<pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/>'
|
|
215
|
+
'</sheetView></sheetViews>'
|
|
216
|
+
'<sheetFormatPr defaultRowHeight="15"/>'
|
|
217
|
+
f'<cols>{cols}</cols>'
|
|
218
|
+
f'<sheetData>{"".join(rows_xml)}</sheetData>'
|
|
219
|
+
f'<autoFilter ref="A1:{_col_letter(len(COLUMNS)-1)}1"/>'
|
|
220
|
+
'</worksheet>'
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
_STYLES_XML = (
|
|
225
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
226
|
+
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
|
227
|
+
'<fonts count="2">'
|
|
228
|
+
'<font><sz val="11"/><name val="Calibri"/></font>'
|
|
229
|
+
'<font><b/><sz val="11"/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
|
|
230
|
+
'</fonts>'
|
|
231
|
+
'<fills count="3">'
|
|
232
|
+
'<fill><patternFill patternType="none"/></fill>'
|
|
233
|
+
'<fill><patternFill patternType="gray125"/></fill>'
|
|
234
|
+
'<fill><patternFill patternType="solid"><fgColor rgb="FF4472C4"/><bgColor indexed="64"/></patternFill></fill>'
|
|
235
|
+
'</fills>'
|
|
236
|
+
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
|
|
237
|
+
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
|
|
238
|
+
'<cellXfs count="2">'
|
|
239
|
+
'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0" applyAlignment="1">'
|
|
240
|
+
'<alignment vertical="top" wrapText="1"/></xf>'
|
|
241
|
+
'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyAlignment="1">'
|
|
242
|
+
'<alignment vertical="center" wrapText="1"/></xf>'
|
|
243
|
+
'</cellXfs>'
|
|
244
|
+
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
|
|
245
|
+
'</styleSheet>'
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def write_xlsx(records, out_path, sheet_name="Localization"):
|
|
250
|
+
safe = "".join(c for c in sheet_name if c not in r'[]:*?/\\')[:31] or "Sheet1"
|
|
251
|
+
parts = {
|
|
252
|
+
"[Content_Types].xml":
|
|
253
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
254
|
+
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
|
255
|
+
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
|
256
|
+
'<Default Extension="xml" ContentType="application/xml"/>'
|
|
257
|
+
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
|
|
258
|
+
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
|
|
259
|
+
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>'
|
|
260
|
+
'</Types>',
|
|
261
|
+
"_rels/.rels":
|
|
262
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
263
|
+
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
|
264
|
+
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
|
|
265
|
+
'</Relationships>',
|
|
266
|
+
"xl/workbook.xml":
|
|
267
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
268
|
+
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
|
|
269
|
+
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
|
270
|
+
f'<sheets><sheet name="{_xesc(safe)}" sheetId="1" r:id="rId1"/></sheets></workbook>',
|
|
271
|
+
"xl/_rels/workbook.xml.rels":
|
|
272
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
273
|
+
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
|
274
|
+
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
|
|
275
|
+
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'
|
|
276
|
+
'</Relationships>',
|
|
277
|
+
"xl/styles.xml": _STYLES_XML,
|
|
278
|
+
"xl/worksheets/sheet1.xml": _sheet_xml(records),
|
|
279
|
+
}
|
|
280
|
+
with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as z:
|
|
281
|
+
for name, data in parts.items():
|
|
282
|
+
z.writestr(name, data)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def write_csv(records, out_path):
|
|
286
|
+
with open(out_path, "w", newline="", encoding="utf-8-sig") as f:
|
|
287
|
+
w = csv.writer(f)
|
|
288
|
+
w.writerow(COLUMNS)
|
|
289
|
+
w.writerows(records)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def main():
|
|
293
|
+
ap = argparse.ArgumentParser(description=__doc__,
|
|
294
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
295
|
+
ap.add_argument("mapping", help="path to the mapping JSON (- for stdin)")
|
|
296
|
+
ap.add_argument("--out", default=".", help="output directory")
|
|
297
|
+
ap.add_argument("--slug", help="output basename (default: slug of mapping.screen)")
|
|
298
|
+
ap.add_argument("--channel", default="Mobile", help="Channel column value (default: Mobile)")
|
|
299
|
+
ap.add_argument("--taxonomy", help="JSON file describing your CMS group/module buckets "
|
|
300
|
+
"(merged over the neutral defaults; see the module docstring)")
|
|
301
|
+
ap.add_argument("--csv", action="store_true", help="also write a <slug>.localization.csv")
|
|
302
|
+
ap.add_argument("--csv-only", action="store_true", help="write only the CSV, skip the xlsx")
|
|
303
|
+
args = ap.parse_args()
|
|
304
|
+
|
|
305
|
+
global _TAXONOMY
|
|
306
|
+
_TAXONOMY = load_taxonomy(args.taxonomy)
|
|
307
|
+
|
|
308
|
+
raw = sys.stdin.read() if args.mapping == "-" else open(args.mapping, encoding="utf-8").read()
|
|
309
|
+
mapping = json.loads(raw)
|
|
310
|
+
|
|
311
|
+
slug = args.slug or slugify(mapping.get("screen"))
|
|
312
|
+
os.makedirs(args.out, exist_ok=True)
|
|
313
|
+
records = build_records(mapping, args.channel)
|
|
314
|
+
|
|
315
|
+
written = []
|
|
316
|
+
if not args.csv_only:
|
|
317
|
+
xlsx_path = os.path.join(args.out, f"{slug}.localization.xlsx")
|
|
318
|
+
write_xlsx(records, xlsx_path)
|
|
319
|
+
written.append(xlsx_path)
|
|
320
|
+
if args.csv or args.csv_only:
|
|
321
|
+
csv_path = os.path.join(args.out, f"{slug}.localization.csv")
|
|
322
|
+
write_csv(records, csv_path)
|
|
323
|
+
written.append(csv_path)
|
|
324
|
+
|
|
325
|
+
print(json.dumps({
|
|
326
|
+
"slug": slug,
|
|
327
|
+
"rows": len(records),
|
|
328
|
+
"columns": COLUMNS,
|
|
329
|
+
"channel": args.channel,
|
|
330
|
+
"written": [os.path.basename(p) for p in written],
|
|
331
|
+
}, ensure_ascii=False, indent=2))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
if __name__ == "__main__":
|
|
335
|
+
main()
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fetch the content team's Final UX Writing annotations off the screen's Figma nodes.
|
|
3
|
+
|
|
4
|
+
The CMS / content team writes the **final bilingual copy** as Figma Dev Mode
|
|
5
|
+
annotations (category "Final UX Writing") directly on the text nodes, in the form
|
|
6
|
+
`TR: <türkçe>\nEN: <english>`. The design text itself is usually a *placeholder*
|
|
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.
|
|
32
|
+
"""
|
|
33
|
+
import argparse
|
|
34
|
+
import datetime
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import re
|
|
38
|
+
import ssl
|
|
39
|
+
import subprocess
|
|
40
|
+
import sys
|
|
41
|
+
import urllib.error
|
|
42
|
+
import urllib.parse
|
|
43
|
+
import urllib.request
|
|
44
|
+
|
|
45
|
+
FIGMA_API = "https://api.figma.com/v1"
|
|
46
|
+
CHUNK = 4 # Figma /nodes batches; mirrors the web extractor
|
|
47
|
+
|
|
48
|
+
# --- label → {tr, en, mode} - verbatim port of lib/split-lang.mjs ----------
|
|
49
|
+
# Annotation authors separate label from value with a colon, a hyphen, or the
|
|
50
|
+
# en-dash their editor auto-inserts. Built from the codepoint so this file
|
|
51
|
+
# carries no literal dash variant of its own.
|
|
52
|
+
_DASHES = ':\\-' + chr(0x2013)
|
|
53
|
+
RE_TR = re.compile(rf"^(?:tr|tr[kc]e|t[üu]rk[çc]e)\s*[{_DASHES}]\s*(.+)$", re.I)
|
|
54
|
+
RE_EN = re.compile(rf"^(?:en|eng|ing|[iİ]ngilizce)\s*[{_DASHES}]\s*(.+)$", re.I)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def split_lang(raw):
|
|
58
|
+
"""Parse a Dev Mode annotation label into {tr, en, mode}.
|
|
59
|
+
|
|
60
|
+
Prefixed `TR:`/`EN:` lines win over position; otherwise line1=TR, line2=EN.
|
|
61
|
+
"""
|
|
62
|
+
if not raw:
|
|
63
|
+
return {"tr": None, "en": None, "mode": "empty"}
|
|
64
|
+
lines = [ln.strip() for ln in re.split(r"\r?\n", raw)]
|
|
65
|
+
lines = [ln for ln in lines if ln]
|
|
66
|
+
tr = en = None
|
|
67
|
+
bare = []
|
|
68
|
+
for ln in lines:
|
|
69
|
+
m = RE_TR.match(ln)
|
|
70
|
+
if m and tr is None:
|
|
71
|
+
tr = m.group(1).strip()
|
|
72
|
+
continue
|
|
73
|
+
m = RE_EN.match(ln)
|
|
74
|
+
if m and en is None:
|
|
75
|
+
en = m.group(1).strip()
|
|
76
|
+
continue
|
|
77
|
+
bare.append(ln)
|
|
78
|
+
if tr is not None or en is not None:
|
|
79
|
+
bi = 0
|
|
80
|
+
if tr is None and bi < len(bare):
|
|
81
|
+
tr = bare[bi]
|
|
82
|
+
bi += 1
|
|
83
|
+
if en is None and bi < len(bare):
|
|
84
|
+
en = bare[bi]
|
|
85
|
+
bi += 1
|
|
86
|
+
return {"tr": tr, "en": en, "mode": "prefixed"}
|
|
87
|
+
if len(bare) >= 2:
|
|
88
|
+
return {"tr": bare[0], "en": bare[1], "mode": "two-line"}
|
|
89
|
+
if len(bare) == 1:
|
|
90
|
+
return {"tr": bare[0], "en": None, "mode": "single"}
|
|
91
|
+
return {"tr": None, "en": None, "mode": "empty"}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def canon_id(s):
|
|
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
|
|
133
|
+
return {
|
|
134
|
+
"nodeId": node_id,
|
|
135
|
+
"name": name,
|
|
136
|
+
"type": ntype,
|
|
137
|
+
"designText": dtext,
|
|
138
|
+
"raw": raw,
|
|
139
|
+
"tr": best["tr"],
|
|
140
|
+
"en": best["en"],
|
|
141
|
+
"mode": best["mode"],
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def walk_collect(node, out):
|
|
146
|
+
"""Recurse a Figma node tree, collecting every node that carries annotations."""
|
|
147
|
+
labels = labels_of(node)
|
|
148
|
+
if labels:
|
|
149
|
+
out.append(parse_item(node.get("id"), node.get("name"),
|
|
150
|
+
node.get("type"), design_text(node), labels))
|
|
151
|
+
for child in node.get("children", []) or []:
|
|
152
|
+
walk_collect(child, out)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# --- sources ---------------------------------------------------------------
|
|
156
|
+
def figma_token(args):
|
|
157
|
+
if args.token:
|
|
158
|
+
return args.token
|
|
159
|
+
for env in ("FIGMA_ACCESS_TOKEN", "FIGMA_TOKEN"):
|
|
160
|
+
v = os.environ.get(env)
|
|
161
|
+
if v:
|
|
162
|
+
return v.strip()
|
|
163
|
+
try:
|
|
164
|
+
r = subprocess.run(
|
|
165
|
+
["security", "find-generic-password", "-s", "FIGMA_ACCESS_TOKEN", "-w"],
|
|
166
|
+
capture_output=True, text=True)
|
|
167
|
+
if r.returncode == 0 and r.stdout.strip():
|
|
168
|
+
return r.stdout.strip()
|
|
169
|
+
except Exception:
|
|
170
|
+
pass
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def http_get_json(url, headers, ca_file):
|
|
175
|
+
ctx = ssl.create_default_context(cafile=ca_file) if ca_file else ssl.create_default_context()
|
|
176
|
+
req = urllib.request.Request(url, headers=headers)
|
|
177
|
+
try:
|
|
178
|
+
with urllib.request.urlopen(req, context=ctx, timeout=60) as r:
|
|
179
|
+
return json.load(r)
|
|
180
|
+
except urllib.error.HTTPError as e:
|
|
181
|
+
if e.code == 429:
|
|
182
|
+
sys.exit("Figma 429 (rate-limited) - re-run with the MCP dump instead: "
|
|
183
|
+
"--from-mcp <raw.json>.")
|
|
184
|
+
body = e.read()[:200]
|
|
185
|
+
sys.exit(f"Figma HTTP {e.code} for {url}: {body!r}")
|
|
186
|
+
except urllib.error.URLError as e:
|
|
187
|
+
msg = str(getattr(e, "reason", e))
|
|
188
|
+
if re.search(r"CERTIFICATE|ISSUER|SELF.SIGNED|UNABLE_TO|CERT", msg, re.I):
|
|
189
|
+
sys.exit(
|
|
190
|
+
"TLS verification failed. Behind a corporate proxy, export the system "
|
|
191
|
+
"roots and pass --ca-file:\n"
|
|
192
|
+
" security find-certificate -a -p /System/Library/Keychains/"
|
|
193
|
+
"SystemRootCertificates.keychain > /tmp/ca.pem\n"
|
|
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}")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def from_rest(file_key, node_ids, token, ca_file):
|
|
201
|
+
if not token:
|
|
202
|
+
sys.exit("No Figma token - pass --token, set FIGMA_ACCESS_TOKEN, or add the "
|
|
203
|
+
"keychain item FIGMA_ACCESS_TOKEN (or use --from-mcp / --local).")
|
|
204
|
+
headers = {"X-Figma-Token": token}
|
|
205
|
+
collected = []
|
|
206
|
+
ids = [canon_id(n) for n in node_ids]
|
|
207
|
+
for i in range(0, len(ids), CHUNK):
|
|
208
|
+
batch = ids[i:i + CHUNK]
|
|
209
|
+
q = ",".join(urllib.parse.quote(n, safe="") for n in batch)
|
|
210
|
+
data = http_get_json(f"{FIGMA_API}/files/{file_key}/nodes?ids={q}", headers, ca_file)
|
|
211
|
+
for nid in batch:
|
|
212
|
+
entry = (data.get("nodes") or {}).get(nid)
|
|
213
|
+
doc = entry.get("document") if isinstance(entry, dict) else None
|
|
214
|
+
if doc:
|
|
215
|
+
walk_collect(doc, collected)
|
|
216
|
+
return collected, "rest"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def from_mcp(path):
|
|
220
|
+
with open(path, encoding="utf-8") as fh:
|
|
221
|
+
dump = json.load(fh)
|
|
222
|
+
items = dump if isinstance(dump, list) else (dump.get("annotations") or dump.get("items") or [])
|
|
223
|
+
collected = []
|
|
224
|
+
files = []
|
|
225
|
+
for it in items:
|
|
226
|
+
fk = it.get("fileKey")
|
|
227
|
+
if fk and fk not in files:
|
|
228
|
+
files.append(fk)
|
|
229
|
+
labels = it.get("labels")
|
|
230
|
+
if labels is None and it.get("raw") is not None:
|
|
231
|
+
labels = [it["raw"]]
|
|
232
|
+
collected.append(parse_item(it.get("nodeId"), it.get("name"), it.get("type"),
|
|
233
|
+
it.get("designText"), labels or []))
|
|
234
|
+
return collected, "mcp", files
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def from_local(screen_dir):
|
|
238
|
+
"""Best-effort: read annotations from a design-export tree.json if it has any."""
|
|
239
|
+
tree = os.path.join(screen_dir, "tree.json")
|
|
240
|
+
if not os.path.isfile(tree):
|
|
241
|
+
return [], "local"
|
|
242
|
+
with open(tree, encoding="utf-8") as fh:
|
|
243
|
+
data = json.load(fh)
|
|
244
|
+
roots = data if isinstance(data, list) else [data]
|
|
245
|
+
collected = []
|
|
246
|
+
for root in roots:
|
|
247
|
+
if isinstance(root, dict):
|
|
248
|
+
walk_collect(root, collected)
|
|
249
|
+
return collected, "local"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def main():
|
|
253
|
+
ap = argparse.ArgumentParser(description=__doc__,
|
|
254
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
255
|
+
ap.add_argument("--file", help="Figma fileKey (REST)")
|
|
256
|
+
ap.add_argument("--nodes", help="comma-separated node-ids (REST), e.g. '10065:56313,10065:56329'")
|
|
257
|
+
ap.add_argument("--mapping", help="a mapping JSON to read figmaFileKey + figmaNodes from")
|
|
258
|
+
ap.add_argument("--from-mcp", dest="from_mcp", help="captured Dev Mode dump (MCP path)")
|
|
259
|
+
ap.add_argument("--local", help="design-export screen dir (fallback; reads tree.json)")
|
|
260
|
+
ap.add_argument("--source", choices=["auto", "rest", "mcp", "local"], default="auto",
|
|
261
|
+
help="force a source; 'auto' tries REST then --local (default)")
|
|
262
|
+
ap.add_argument("--token", help="Figma token (else env / keychain)")
|
|
263
|
+
ap.add_argument("--ca-file", help="CA bundle for TLS behind a corporate proxy")
|
|
264
|
+
ap.add_argument("--out", help="write _annotations.json here (default: stdout)")
|
|
265
|
+
args = ap.parse_args()
|
|
266
|
+
|
|
267
|
+
file_key = args.file
|
|
268
|
+
node_ids = [n.strip() for n in (args.nodes or "").split(",") if n.strip()]
|
|
269
|
+
if args.mapping:
|
|
270
|
+
with open(args.mapping, encoding="utf-8") as fh:
|
|
271
|
+
m = json.load(fh)
|
|
272
|
+
file_key = file_key or m.get("figmaFileKey")
|
|
273
|
+
if not node_ids:
|
|
274
|
+
node_ids = [str(n) for n in (m.get("figmaNodes") or [])]
|
|
275
|
+
|
|
276
|
+
files = [file_key] if file_key else []
|
|
277
|
+
source = args.source
|
|
278
|
+
|
|
279
|
+
if args.from_mcp or source == "mcp":
|
|
280
|
+
if not args.from_mcp:
|
|
281
|
+
ap.error("--source mcp needs --from-mcp <raw.json>")
|
|
282
|
+
collected, source, mcp_files = from_mcp(args.from_mcp)
|
|
283
|
+
files = mcp_files or files
|
|
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(),
|
|
319
|
+
"source": source,
|
|
320
|
+
"files": files,
|
|
321
|
+
"auditedNodes": [canon_id(n) for n in node_ids],
|
|
322
|
+
"count": len(deduped),
|
|
323
|
+
"withTr": sum(1 for a in deduped if a.get("tr") is not None),
|
|
324
|
+
"withEn": sum(1 for a in deduped if a.get("en") is not None),
|
|
325
|
+
"annotations": deduped,
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
text = json.dumps(out, ensure_ascii=False, indent=2)
|
|
329
|
+
if args.out:
|
|
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")
|
|
334
|
+
else:
|
|
335
|
+
sys.stdout.write(text + "\n")
|
|
336
|
+
|
|
337
|
+
only_tr = [a["nodeId"] for a in deduped if a.get("tr") and not a.get("en")]
|
|
338
|
+
if only_tr:
|
|
339
|
+
sys.stderr.write(f"# {len(only_tr)} annotation(s) have TR but no EN "
|
|
340
|
+
"(flag back to the content team): " + ", ".join(only_tr) + "\n")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
if __name__ == "__main__":
|
|
344
|
+
main()
|