@mmerterden/multi-agent-pipeline 20.8.0 → 20.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/docs/facts.json +1 -1
  3. package/manifest.json +63 -62
  4. package/package.json +2 -2
  5. package/pipeline/lib/redact.mjs +3 -2
  6. package/pipeline/scripts/gen-skills-index.mjs +13 -1
  7. package/pipeline/scripts/pre-commit-check.sh +4 -0
  8. package/pipeline/skills/.skill-manifest.json +21 -21
  9. package/pipeline/skills/shared/README.md +64 -64
  10. package/pipeline/skills/shared/external/alarmkit/SKILL.md +2 -2
  11. package/pipeline/skills/shared/external/alarmkit/evals/evals.json +2 -2
  12. package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +6 -0
  13. package/pipeline/skills/shared/external/app-store-optimization/references/keyword-research-methodology.md +3 -0
  14. package/pipeline/skills/shared/external/app-store-optimization/references/product-page-variants.md +3 -0
  15. package/pipeline/skills/shared/external/app-store-review/SKILL.md +3 -4
  16. package/pipeline/skills/shared/external/apple-on-device-ai/SKILL.md +5 -3
  17. package/pipeline/skills/shared/external/authentication/SKILL.md +29 -17
  18. package/pipeline/skills/shared/external/authentication/references/keychain-biometric.md +3 -1
  19. package/pipeline/skills/shared/external/background-processing/SKILL.md +10 -8
  20. package/pipeline/skills/shared/external/background-processing/references/background-task-patterns.md +7 -7
  21. package/pipeline/skills/shared/external/core-bluetooth/SKILL.md +4 -2
  22. package/pipeline/skills/shared/external/core-data/SKILL.md +12 -2
  23. package/pipeline/skills/shared/external/coreml/SKILL.md +1 -1
  24. package/pipeline/skills/shared/external/cryptokit/SKILL.md +1 -1
  25. package/pipeline/skills/shared/external/device-integrity/SKILL.md +11 -5
  26. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +3 -3
  27. package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +1 -1
  28. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +8 -7
  29. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +2 -2
  30. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +5 -2
  31. package/pipeline/skills/shared/external/localization-reuse-map/scripts/_shared.py +100 -0
  32. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +45 -26
  33. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +14 -16
  34. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +12 -5
  35. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +2 -1
  36. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +6 -5
  37. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +44 -18
  38. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +5 -2
  39. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +10 -11
  40. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +4 -33
  41. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +12 -59
  42. package/pipeline/skills/shared/external/mapkit-location/SKILL.md +4 -2
  43. package/pipeline/skills/shared/external/skill-creator/template.md +7 -1
  44. package/pipeline/skills/shared/external/storekit/references/core-patterns.md +6 -1
  45. package/pipeline/skills/shared/external/swift-concurrency/SKILL.md +3 -2
  46. package/pipeline/skills/shared/external/swift-concurrency/references/concurrency-patterns.md +1 -1
  47. package/pipeline/skills/shared/external/swift-security/SKILL.md +10 -8
  48. package/pipeline/skills/shared/external/swift-security/references/certificate-trust.md +8 -5
  49. package/pipeline/skills/shared/external/swift-security/references/keychain-fundamentals.md +13 -9
  50. package/pipeline/skills/shared/external/swift-security/references/secure-enclave.md +7 -6
  51. package/pipeline/skills/shared/external/swift-testing/SKILL.md +8 -5
  52. package/pipeline/skills/shared/external/swift-testing/evals/evals.json +1 -1
  53. package/pipeline/skills/shared/external/swift-testing/references/testing-advanced.md +3 -2
  54. package/pipeline/skills/shared/external/swiftdata/SKILL.md +5 -3
  55. package/pipeline/skills/shared/external/swiftui-navigation/SKILL.md +17 -9
  56. package/pipeline/skills/shared/external/swiftui-navigation/references/navigationstack.md +4 -2
  57. package/pipeline/skills/shared/external/swiftui-navigation/references/tabview.md +13 -6
  58. package/pipeline/skills/shared/external/weatherkit/SKILL.md +8 -5
  59. package/pipeline/skills/shared/external/widgetkit/SKILL.md +15 -7
  60. package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +8 -6
@@ -11,6 +11,7 @@ import html
11
11
  import json
12
12
  import os
13
13
  import re
14
+ import shlex
14
15
  import shutil
15
16
  import struct
16
17
  import subprocess
@@ -22,7 +23,8 @@ import zipfile
22
23
  from collections import namedtuple
23
24
  from xml.sax.saxutils import escape, quoteattr
24
25
 
25
- LANGS = ["en", "tr", "ar"] + sorted(["ru", "it", "fr", "es", "de"])
26
+ from _shared import LANGS, slug, xml_text
27
+
26
28
  WARN = "\u26a0\ufe0f"
27
29
  DASH = "-"
28
30
  VERDICTS = ("reuse", "review", "new")
@@ -120,11 +122,6 @@ def look(row):
120
122
  return LOOKS.get(row.get("verdict"), UNKNOWN_LOOK)
121
123
 
122
124
 
123
- def slug(text):
124
- value = re.sub(r"[\W_]+", "-", str(text or "").strip().lower()).strip("-")
125
- return value or "screen"
126
-
127
-
128
125
  def norm(value):
129
126
  text = unicodedata.normalize("NFC", str(value or "")).strip()
130
127
  text = re.sub(r"\s+", " ", text)
@@ -189,8 +186,12 @@ def load_keyshots(mapping, mapping_dir, out_dir):
189
186
  return shots
190
187
 
191
188
 
189
+ def xml_escape(value):
190
+ return escape(xml_text(value))
191
+
192
+
192
193
  def node(name, attrs=(), *children):
193
- opening = name + "".join(f" {k}={quoteattr(str(v))}" for k, v in attrs)
194
+ opening = name + "".join(f" {k}={quoteattr(xml_text(v))}" for k, v in attrs)
194
195
  if not children:
195
196
  return f"<{opening}/>"
196
197
  return f"<{opening}>{''.join(children)}</{name}>"
@@ -291,12 +292,12 @@ class Doc:
291
292
  return storage_macro(
292
293
  "status",
293
294
  node("ac:parameter", [("ac:name", "colour")], look(row).status),
294
- node("ac:parameter", [("ac:name", "title")], escape(self.verdict(row))),
295
+ node("ac:parameter", [("ac:name", "title")], xml_escape(self.verdict(row))),
295
296
  )
296
297
 
297
298
  def xml(self):
298
299
  t, m = self.t, self.m
299
- e = escape
300
+ e = xml_escape
300
301
 
301
302
  def heading(label):
302
303
  return f"<h2>{e(t[label])}</h2>"
@@ -504,7 +505,7 @@ def para(text, bold=False, size=None, rtl=False):
504
505
  if rtl:
505
506
  styling.append(w("rtl"))
506
507
  run = [w("rPr", *styling)] if styling else []
507
- run.append(node("w:t", [("xml:space", "preserve")], escape(str(text))))
508
+ run.append(node("w:t", [("xml:space", "preserve")], xml_escape(text)))
508
509
  lead = [w("pPr", w("bidi"))] if rtl else []
509
510
  return w("p", *lead, w("r", *run))
510
511
 
@@ -663,17 +664,21 @@ def chrome_candidates():
663
664
 
664
665
  def pdf_libreoffice(docx_path, out_dir):
665
666
  binaries = [shutil.which("soffice"), shutil.which("libreoffice"), mac_app("LibreOffice", "soffice")]
666
- expected = os.path.splitext(docx_path)[0] + ".pdf"
667
+ name = os.path.splitext(os.path.basename(docx_path))[0] + ".pdf"
667
668
  for binary in binaries:
668
669
  if not (binary and os.path.isfile(binary)):
669
670
  continue
670
- try:
671
- subprocess.run([binary, "--headless", "--convert-to", "pdf", "--outdir", out_dir, docx_path],
672
- capture_output=True, timeout=180)
673
- except (OSError, subprocess.SubprocessError):
674
- continue
675
- if os.path.isfile(expected) and os.path.getsize(expected) > 0:
676
- return expected
671
+ with tempfile.TemporaryDirectory() as scratch:
672
+ try:
673
+ subprocess.run([binary, "--headless", "--convert-to", "pdf", "--outdir", scratch, docx_path],
674
+ capture_output=True, timeout=180)
675
+ except (OSError, subprocess.SubprocessError):
676
+ continue
677
+ rendered = os.path.join(scratch, name)
678
+ if os.path.isfile(rendered) and os.path.getsize(rendered) > 0:
679
+ target = os.path.join(out_dir, name)
680
+ shutil.copyfile(rendered, target)
681
+ return target
677
682
  return None
678
683
 
679
684
 
@@ -698,7 +703,7 @@ def pdf_chrome(preview_path, pdf_path):
698
703
  with tempfile.TemporaryDirectory() as profile:
699
704
  if os.path.exists(pdf_path):
700
705
  os.remove(pdf_path)
701
- argv = [chrome, headless, "--disable-gpu", f"--user-data-dir={profile}", "--no-pdf-header-footer",
706
+ argv = [chrome, headless, "--no-sandbox", "--disable-gpu", f"--user-data-dir={profile}", "--no-pdf-header-footer",
702
707
  f"--print-to-pdf={pdf_path}", page]
703
708
  try:
704
709
  proc = subprocess.Popen(argv, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL)
@@ -721,17 +726,31 @@ def pdf_wkhtml(preview_path, pdf_path):
721
726
  return pdf_path if os.path.isfile(pdf_path) and os.path.getsize(pdf_path) > 0 else None
722
727
 
723
728
 
724
- def print_upload(name, mapping, screen):
729
+ def upload_path(name, mapping_dir, out_dir):
730
+ if os.path.isabs(name):
731
+ return name
732
+ for folder in (mapping_dir, out_dir):
733
+ if os.path.exists(os.path.join(folder, name)):
734
+ return os.path.join(folder, name)
735
+ return os.path.join(out_dir, name)
736
+
737
+
738
+ def print_upload(name, mapping, screen, mapping_dir, out_dir):
725
739
  prefix = os.environ.get("LOCALIZATION_PAGE_PREFIX", "Localization")
726
- flags = [f"--xml {name}.confluence.xml", f'--screen "{screen}"']
727
- flags += [f"--{field} {mapping[field]}" for field in ("screenshot", "overlay", "keyshots") if mapping.get(field)]
728
- flags.append(f"--attach {name}.localization.xlsx")
740
+ quote = shlex.quote
741
+ flags = [f"--xml {quote(os.path.join(out_dir, name + '.confluence.xml'))}", f"--screen {quote(str(screen))}"]
742
+ flags += [
743
+ f"--{field} {quote(upload_path(str(mapping[field]), mapping_dir, out_dir))}"
744
+ for field in ("screenshot", "overlay", "keyshots") if mapping.get(field)
745
+ ]
746
+ flags.append(f"--attach {quote(os.path.join(out_dir, name + '.localization.xlsx'))}")
729
747
  print(" \\\n ".join(["python3 scripts/publish-confluence.py"] + flags))
730
748
  print()
749
+ title = json.dumps(f"{prefix} - {screen}", ensure_ascii=False).replace("'", "'\\''")
731
750
  print("# The same call by hand, for Server/DC with a Bearer PAT read from the keychain (argv never sees the token):")
732
- print("# printf 'header = \"Authorization: Bearer %s\"\\n' \"$(security find-generic-password -s CONFLUENCE_API_TOKEN -w)\" > auth.curlrc; chmod 600 auth.curlrc")
751
+ print("# (umask 077 && rm -f auth.curlrc && printf 'header = \"Authorization: Bearer %s\"\\n' \"$(security find-generic-password -s CONFLUENCE_API_TOKEN -w)\" > auth.curlrc)")
733
752
  print("# curl -sS -K auth.curlrc -X POST -H 'Content-Type: application/json' \"$CONFLUENCE_BASE_URL/rest/api/content\" \\")
734
- print(f"# --data '{{\"type\":\"page\",\"title\":\"{prefix} - {screen}\",\"space\":{{\"key\":\"'$CONFLUENCE_SPACE'\"}},"
753
+ print(f"# --data '{{\"type\":\"page\",\"title\":{title},\"space\":{{\"key\":\"'$CONFLUENCE_SPACE'\"}},"
735
754
  "\"ancestors\":[{\"id\":\"'$CONFLUENCE_PARENT'\"}],\"body\":{\"storage\":{\"value\":\"...\",\"representation\":\"storage\"}}}'")
736
755
  print("# rm auth.curlrc")
737
756
 
@@ -797,7 +816,7 @@ def main():
797
816
  make_pdf(docx_path, out_dir, stem, keep_docx)
798
817
 
799
818
  if opts.print_upload:
800
- print_upload(stem, mapping, mapping.get("screen"))
819
+ print_upload(stem, mapping, mapping.get("screen"), mapping_dir, out_dir)
801
820
 
802
821
 
803
822
  if __name__ == "__main__":
@@ -15,6 +15,8 @@ import sys
15
15
  import zipfile
16
16
  from xml.sax.saxutils import escape
17
17
 
18
+ from _shared import clean_key, key_is_dynamic, slug, xml_text
19
+
18
20
  COLUMNS = [
19
21
  "Channel", "Property Group", "Property Module", "Key",
20
22
  "EN Value", "TR Value", "AR Value", "Anotation EN", "Anotation TR",
@@ -37,11 +39,6 @@ DEFAULT_TAXONOMY = {
37
39
  }
38
40
 
39
41
 
40
- def slug(text):
41
- value = re.sub(r"[\W_]+", "-", str(text or "").strip().lower()).strip("-")
42
- return value or "screen"
43
-
44
-
45
42
  def read_json(source):
46
43
  with open(source, encoding="utf-8") as stream:
47
44
  return json.load(stream)
@@ -62,12 +59,6 @@ def load_taxonomy(path):
62
59
  return taxonomy
63
60
 
64
61
 
65
- def clean_key(new_key):
66
- text = str(new_key or "")
67
- cut = text.find("(owned by")
68
- return (text[:cut] if cut >= 0 else text).strip()
69
-
70
-
71
62
  def bucket(key, taxonomy):
72
63
  lowered = key.lower()
73
64
  if "error" in lowered:
@@ -88,9 +79,12 @@ def bucket(key, taxonomy):
88
79
 
89
80
 
90
81
  def table_rows(mapping, channel, taxonomy):
91
- out = []
92
- for row in mapping.get("rows") or []:
82
+ out, skipped = [], []
83
+ for number, row in enumerate(mapping.get("rows") or [], 1):
93
84
  key = clean_key(row.get("newKey"))
85
+ if not key or key_is_dynamic(key):
86
+ skipped.append((number, key))
87
+ continue
94
88
  group, module = bucket(key, taxonomy)
95
89
  if row.get("propertyGroup"):
96
90
  group = row["propertyGroup"]
@@ -103,7 +97,7 @@ def table_rows(mapping, channel, taxonomy):
103
97
  new.get("en") or "", new.get("tr") or "", new.get("ar") or "",
104
98
  cms.get("en") or "", cms.get("tr") or "",
105
99
  ])
106
- return out
100
+ return out, skipped
107
101
 
108
102
 
109
103
  def column_letter(index):
@@ -137,7 +131,7 @@ def part(root, attrs, *children):
137
131
 
138
132
 
139
133
  def cell(ref, value, style):
140
- text = escape(str(value)).replace("\n", "&#10;")
134
+ text = escape(xml_text(value)).replace("\n", "&#10;")
141
135
  attrs = [("r", ref), ("t", "inlineStr")]
142
136
  if style:
143
137
  attrs.append(("s", style))
@@ -276,7 +270,7 @@ def main():
276
270
  if not isinstance(mapping, dict) or "rows" not in mapping:
277
271
  cli.error("mapping must be a JSON object with rows")
278
272
 
279
- rows = table_rows(mapping, opts.channel, load_taxonomy(opts.taxonomy))
273
+ rows, skipped = table_rows(mapping, opts.channel, load_taxonomy(opts.taxonomy))
280
274
  stem = opts.slug or slug(mapping.get("screen"))
281
275
  os.makedirs(opts.target_dir, exist_ok=True)
282
276
 
@@ -294,6 +288,10 @@ def main():
294
288
  summary = {"slug": stem, "rows": len(rows), "columns": COLUMNS, "channel": opts.channel, "written": produced}
295
289
  json.dump(summary, sys.stdout, indent=2, ensure_ascii=False)
296
290
  sys.stdout.write("\n")
291
+ if skipped:
292
+ print(f"# {len(skipped)} row(s) left out of the sheet: no literal key (empty, dynamic or <placeholder>)", file=sys.stderr)
293
+ for number, key in skipped:
294
+ print(f"# row {number}: {key or '(empty)'}", file=sys.stderr)
297
295
 
298
296
 
299
297
  if __name__ == "__main__":
@@ -24,8 +24,12 @@ TR_RE = re.compile("^(?:tr|tr[kc]e|t[\u00fcu]rk[\u00e7c]e)\\s*" + SEP + "\\s*(.+
24
24
  EN_RE = re.compile("^(?:en|eng|ing|[i\u0130]ngilizce)\\s*" + SEP + "\\s*(.+)$", re.IGNORECASE)
25
25
 
26
26
 
27
+ URL_NODE_ID = re.compile(r"^\d+-\d+$")
28
+
29
+
27
30
  def canon_id(node_id):
28
- return str(node_id).strip().replace("-", ":")
31
+ text = "" if node_id is None else str(node_id).strip()
32
+ return text.replace("-", ":") if URL_NODE_ID.match(text) else text
29
33
 
30
34
 
31
35
  def split_lang(text):
@@ -68,7 +72,7 @@ def pick_label(labels):
68
72
  def record(node_id, name, node_type, design_text, labels):
69
73
  raw, parsed = pick_label(labels)
70
74
  return {
71
- "nodeId": canon_id(node_id),
75
+ "nodeId": canon_id(node_id) or None,
72
76
  "name": name,
73
77
  "type": node_type,
74
78
  "designText": design_text,
@@ -246,10 +250,13 @@ def main():
246
250
 
247
251
  source, found, files, audited = pick_source(opts, cli, file_key, nodes)
248
252
 
249
- first_seen = {}
253
+ unique, seen = [], set()
250
254
  for item in found:
251
- first_seen.setdefault(item["nodeId"], item)
252
- unique = list(first_seen.values())
255
+ if item["nodeId"]:
256
+ if item["nodeId"] in seen:
257
+ continue
258
+ seen.add(item["nodeId"])
259
+ unique.append(item)
253
260
 
254
261
  def having(lang):
255
262
  return sum(1 for entry in unique if entry[lang])
@@ -11,7 +11,8 @@ import os
11
11
  import sys
12
12
  import urllib.request
13
13
 
14
- ALL_LANGS = ["en", "tr", "ar"] + sorted(["ru", "it", "fr", "es", "de"])
14
+ from _shared import LANGS as ALL_LANGS
15
+
15
16
 
16
17
 
17
18
  def parse_langs(value):
@@ -16,10 +16,10 @@ import subprocess
16
16
  import sys
17
17
  import tempfile
18
18
  import urllib.parse
19
- from xml.sax.saxutils import quoteattr
19
+ from xml.sax.saxutils import escape, quoteattr
20
20
 
21
21
  EMBED_EXTENSIONS = {".xlsx", ".xls", ".csv", ".docx", ".pdf"}
22
- EMBED_HEADING = "CMS Aktar\u0131m Dosyas\u0131"
22
+ EMBED_HEADING = "CMS Import File"
23
23
  CONTENT = "/rest/api/content"
24
24
 
25
25
 
@@ -82,8 +82,8 @@ def view_file_macro(path):
82
82
  return f'<p><ac:structured-macro ac:name="view-file">{parameter}</ac:structured-macro></p>'
83
83
 
84
84
 
85
- def embed_block(files):
86
- return f"<h2>{EMBED_HEADING}</h2>" + "".join(map(view_file_macro, files))
85
+ def embed_block(files, heading):
86
+ return f"<h2>{escape(heading)}</h2>" + "".join(map(view_file_macro, files))
87
87
 
88
88
 
89
89
  def keyshot_files(manifest_path):
@@ -113,6 +113,7 @@ def build_cli():
113
113
  cli.add_argument("--parent", default=os.environ.get("CONFLUENCE_PARENT"), help="id of the parent page")
114
114
  cli.add_argument("--token", help="Bearer PAT; CONFLUENCE_API_TOKEN or the keychain is safer since other processes can read argv")
115
115
  cli.add_argument("--keychain-name", default="CONFLUENCE_API_TOKEN", help="keychain service that stores the PAT")
116
+ cli.add_argument("--embed-heading", default=EMBED_HEADING, help="heading above the embedded spreadsheet / document previews")
116
117
  cli.add_argument("--dry-run", action="store_true", help="report the planned actions without touching Confluence")
117
118
  return cli
118
119
 
@@ -174,7 +175,7 @@ def main():
174
175
  body = stream.read()
175
176
  embedded = [p for p in opts.attach if os.path.splitext(p)[1].lower() in EMBED_EXTENSIONS]
176
177
  if embedded:
177
- body += embed_block(embedded)
178
+ body += embed_block(embedded, opts.embed_heading)
178
179
 
179
180
  if opts.dry_run:
180
181
  plan = {
@@ -25,6 +25,8 @@ import urllib.error
25
25
  import urllib.parse
26
26
  import urllib.request
27
27
 
28
+ from _shared import slug
29
+
28
30
  API = "https://api.figma.com/v1"
29
31
  SEP = "[:\\-\u2013]"
30
32
  TR_RE = re.compile("^(?:tr|tr[kc]e|t[\u00fcu]rk[\u00e7c]e)\\s*" + SEP + "\\s*(.+)$", re.IGNORECASE)
@@ -40,9 +42,10 @@ RIGHT_MARGIN = 40
40
42
  PINK = "#EC1E91"
41
43
  GRAY = "#9aa0a6"
42
44
  BACKGROUND = "#1e1e1e"
43
- LABEL_CMS = "\u0130\u00e7erik (CMS)"
44
- LABEL_PENDING = "\u00d6neri bekleniyor"
45
- LABEL_UNMATCHED = "(e\u015fle\u015fmedi)"
45
+ LABELS = {
46
+ "en": {"cms": "CMS copy", "pending": "Awaiting copy", "unmatched": "(unmatched)"},
47
+ "tr": {"cms": "\u0130\u00e7erik (CMS)", "pending": "\u00d6neri bekleniyor", "unmatched": "(e\u015fle\u015fmedi)"},
48
+ }
46
49
 
47
50
  PAGE_OPEN = "<!doctype html><html><head>"
48
51
  META_UTF8 = '<meta charset="utf-8">'
@@ -60,13 +63,12 @@ PATH_CHROMES = (
60
63
  )
61
64
 
62
65
 
63
- def canon_id(node_id):
64
- return str(node_id or "").strip().replace("-", ":")
66
+ URL_NODE_ID = re.compile(r"^\d+-\d+$")
65
67
 
66
68
 
67
- def slug(text):
68
- value = re.sub(r"[\W_]+", "-", str(text or "").strip().lower()).strip("-")
69
- return value or "screen"
69
+ def canon_id(node_id):
70
+ text = str(node_id or "").strip()
71
+ return text.replace("-", ":") if URL_NODE_ID.match(text) else text
70
72
 
71
73
 
72
74
  def split_lang(text):
@@ -248,7 +250,7 @@ def page_from_rest(file_key, node_id, scale, token, ca_file):
248
250
  }
249
251
 
250
252
 
251
- def build_cards(page, rows):
253
+ def build_cards(page, rows, labels):
252
254
  index = build_index(rows)
253
255
  cards = []
254
256
  taken = set()
@@ -273,7 +275,7 @@ def build_cards(page, rows):
273
275
  box = {axis: float(node.get(axis, 0)) for axis in ("x", "y", "w", "h")}
274
276
  cards.append({
275
277
  "num": number,
276
- "key": new_key or LABEL_UNMATCHED,
278
+ "key": new_key or labels["unmatched"],
277
279
  "tr": tr,
278
280
  "en": en,
279
281
  "text": node.get("characters") or "",
@@ -302,9 +304,9 @@ def chip(kind, colour, content):
302
304
  return f'<span class="{kind}" style="background:{colour}">{content}</span>'
303
305
 
304
306
 
305
- def card_html(card, left):
307
+ def card_html(card, left, labels):
306
308
  colour = PINK if card["annotated"] else GRAY
307
- pill = LABEL_CMS if card["annotated"] else LABEL_PENDING
309
+ pill = labels["cms"] if card["annotated"] else labels["pending"]
308
310
  if card["annotated"]:
309
311
  body = "".join(
310
312
  f'<div class="line"><b>{label}</b> {html.escape(card[lang] or "-")}</div>'
@@ -336,7 +338,7 @@ def connector(card, lane, lane_count, start_x, target_x, from_left):
336
338
  )
337
339
 
338
340
 
339
- def build_html(page, cards):
341
+ def build_html(page, cards, labels):
340
342
  fw, fh = page["frame"]["w"], page["frame"]["h"]
341
343
  sides = {"left": [], "right": []}
342
344
  for card in cards:
@@ -350,7 +352,7 @@ def build_html(page, cards):
350
352
  for card_x, group in ((COLUMN_X, sides["left"]), (right_x, sides["right"])):
351
353
  from_left = card_x == COLUMN_X
352
354
  for lane, card in enumerate(group):
353
- boxes.append(card_html(card, card_x))
355
+ boxes.append(card_html(card, card_x, labels))
354
356
  if from_left:
355
357
  wire = connector(card, lane, len(group), COLUMN_X + CARD_W, screen_x + card["x"], True)
356
358
  else:
@@ -430,12 +432,26 @@ def chrome_screenshot(chrome, html_text, out_png, width, height, scale):
430
432
  raise RuntimeError((log.read() or "no output").strip()[-300:])
431
433
 
432
434
 
435
+ def is_number(value):
436
+ try:
437
+ float(value)
438
+ except (TypeError, ValueError):
439
+ return False
440
+ return True
441
+
442
+
433
443
  def load_spec_pages(path):
434
444
  with open(path, encoding="utf-8") as stream:
435
445
  spec = json.load(stream)
436
446
  folder = os.path.dirname(os.path.abspath(path))
437
447
  pages = spec.get("pages") or []
438
- for page in pages:
448
+ for number, page in enumerate(pages, 1):
449
+ frame = page.get("frame") if isinstance(page, dict) else None
450
+ if not (isinstance(frame, dict) and all(is_number(frame.get(side)) for side in ("w", "h"))):
451
+ sys.exit(f"{path}: page {number} needs \"frame\": {{\"w\": <width>, \"h\": <height>}} in px")
452
+ for side in ("w", "h"):
453
+ if not isinstance(frame[side], (int, float)):
454
+ frame[side] = float(frame[side])
439
455
  page["image"] = image_source(page.get("image"), folder)
440
456
  return spec, pages
441
457
 
@@ -445,7 +461,7 @@ def split_nodes(flag, mapping):
445
461
  return given or list(mapping.get("figmaNodes") or [])
446
462
 
447
463
 
448
- def main():
464
+ def build_cli():
449
465
  cli = argparse.ArgumentParser(description=__doc__.splitlines()[0])
450
466
  cli.add_argument("--mapping", required=True, help="the reuse-map JSON")
451
467
  cli.add_argument("--spec", help="geometry from MCP or measured by hand: {fileKey, pages:[{nodeId, image, frame, nodes}]}")
@@ -456,7 +472,14 @@ def main():
456
472
  cli.add_argument("--ca-file", help="PEM roots to trust when a proxy intercepts TLS")
457
473
  cli.add_argument("--out", dest="target_dir", default=".", help="folder that receives the generated files")
458
474
  cli.add_argument("--slug", help="stem for the file names; derived from the screen name when omitted")
475
+ cli.add_argument("--ui-lang", choices=sorted(LABELS), default="en", help="language of the card labels")
476
+ return cli
477
+
478
+
479
+ def main():
480
+ cli = build_cli()
459
481
  opts = cli.parse_args()
482
+ labels = LABELS[opts.ui_lang]
460
483
 
461
484
  with open(opts.mapping, encoding="utf-8") as stream:
462
485
  mapping = json.load(stream)
@@ -467,6 +490,9 @@ def main():
467
490
  if not pages:
468
491
  sys.exit("--spec lists no pages")
469
492
  page = pages[0]
493
+ if len(pages) > 1:
494
+ print(f"# warning: {len(pages) - 1} extra page(s) in --spec ignored; the overlay draws the first page only "
495
+ "(render-key-shots.py reads every page)", file=sys.stderr)
470
496
  file_key = spec.get("fileKey") or mapping.get("figmaFileKey")
471
497
  else:
472
498
  file_key = opts.file or mapping.get("figmaFileKey")
@@ -479,8 +505,8 @@ def main():
479
505
  "Without one, measure the boxes on the screenshot and hand them over with --spec.")
480
506
  page = page_from_rest(file_key, canon_id(nodes[0]), opts.scale, token, opts.ca_file)
481
507
 
482
- cards = build_cards(page, mapping.get("rows") or [])
483
- document, width, height = build_html(page, cards)
508
+ cards = build_cards(page, mapping.get("rows") or [], labels)
509
+ document, width, height = build_html(page, cards, labels)
484
510
  os.makedirs(opts.target_dir, exist_ok=True)
485
511
 
486
512
  def output(suffix):
@@ -13,7 +13,8 @@ import plistlib
13
13
  import sys
14
14
  import urllib.request
15
15
 
16
- ALL_LANGS = ["en", "tr", "ar", "de", "es", "fr", "it", "ru"]
16
+ from _shared import LANGS as ALL_LANGS
17
+
17
18
 
18
19
 
19
20
  def split_csv(value):
@@ -149,6 +150,8 @@ def main():
149
150
 
150
151
  keys = split_csv(args.keys)
151
152
  langs = parse_langs(args.langs)
153
+ if not (args.snapshot_root or args.plist_root or args.endpoint):
154
+ sys.exit("no source given: pass --snapshot-root, --plist-root, or --live with --endpoint")
152
155
 
153
156
  if args.snapshot_root and not args.live:
154
157
  result = from_snapshot(args.snapshot_root, keys, langs, args.prefix)
@@ -165,7 +168,7 @@ def main():
165
168
  elif args.plist_root:
166
169
  result = from_plist(args.plist_root, keys, langs, args.prefix)
167
170
  else:
168
- raise
171
+ sys.exit("no --snapshot-root or --plist-root to fall back on")
169
172
 
170
173
  json.dump(result, sys.stdout, indent=2, ensure_ascii=False)
171
174
  sys.stdout.write("\n")
@@ -11,7 +11,8 @@ import json
11
11
  import os
12
12
  import sys
13
13
 
14
- ALL_LANGS = ["en", "tr", "ar"] + sorted(["ru", "it", "fr", "es", "de"])
14
+ from _shared import LANGS as ALL_LANGS, normalize_key
15
+
15
16
 
16
17
  DOMAIN_DIRS = {
17
18
  "localization": ("Suggested", "LocalizationStrings"),
@@ -25,10 +26,6 @@ def parse_langs(value):
25
26
  return [part.strip() for part in value.split(",") if part.strip()]
26
27
 
27
28
 
28
- def normalize_key(key):
29
- return ".".join(seg[:1].upper() + seg[1:] for seg in key.split("."))
30
-
31
-
32
29
  def read_keys(opts):
33
30
  raw = [piece.strip() for piece in opts.keys.split(",")] if opts.keys else []
34
31
  if opts.keys_file:
@@ -85,12 +82,14 @@ def read_suggested(path, langs):
85
82
 
86
83
 
87
84
  def from_catalog(catalog, key, langs):
88
- entry = catalog.get(key)
89
- if entry is None:
90
- entry = catalog.get(normalize_key(key))
91
- if not isinstance(entry, dict):
92
- return None
93
- return {lang: entry.get(lang) for lang in langs}
85
+ for candidate in dict.fromkeys((key, normalize_key(key))):
86
+ entry = catalog.get(candidate)
87
+ if not isinstance(entry, dict):
88
+ continue
89
+ values = {lang: entry.get(lang) for lang in langs}
90
+ if any(value is not None for value in values.values()):
91
+ return values
92
+ return None
94
93
 
95
94
 
96
95
  def main():
@@ -13,22 +13,17 @@ import os
13
13
  import re
14
14
  import sys
15
15
 
16
- EXTENSIONS = {".swift", ".kt", ".kts", ".xml", ".ts", ".tsx", ".js", ".jsx", ".vue"}
16
+ from _shared import IDENT, WEB_DOLLAR_T, WEB_T, WORD, is_interpolated, lsk_keys, source_files
17
+
17
18
  ANDROID = {".kt", ".kts", ".xml"}
18
19
  WEB = {".ts", ".tsx", ".js", ".jsx", ".vue"}
19
- SKIP_PARTS = ("/.git", "/Generated/")
20
20
 
21
21
  ERROR_WORDS = ("toast", "snackbar", "alert", "warning", "failure", "exception", "showerror", "errormessage", "error", "validat")
22
22
  ERROR_CONTEXT = re.compile("|".join(ERROR_WORDS), re.IGNORECASE)
23
23
 
24
- WORD = "A-Za-z0-9_"
25
- IDENT = rf"[A-Za-z_][{WORD}]*"
26
- LSK = re.compile(rf"LocalizationStringKey((?:\.{IDENT}){{2,}})")
27
24
  LITERAL_LOCALIZED = re.compile(r"\"([^\"\\\n]+)\"\.localized\b")
28
25
  R_STRING = re.compile(rf"\bR\.string\.([{WORD}]+)")
29
26
  XML_STRING = re.compile(rf"@string/([{WORD}.]+)")
30
- WEB_T = re.compile(r"(?<![\w$])t\(\s*(['\"])([A-Za-z0-9_.\-]+)\1")
31
- WEB_DOLLAR_T = re.compile(r"\$t\(\s*(['\"])([A-Za-z0-9_.\-]+)\1")
32
27
  WEB_T_DYNAMIC = re.compile(r"(?<![\w$])t\(\s*[`$A-Za-z_]")
33
28
  WEB_DOLLAR_T_DYNAMIC = re.compile(r"\$t\(\s*[`$A-Za-z_]")
34
29
  LSK_DYNAMIC = re.compile(r"LocalizationStringKey\(")
@@ -38,13 +33,6 @@ INTERPOLATED_LOCALIZED = re.compile(r"\"([^\"\n]*(?:\\\(|\$\{|\$[A-Za-z_])[^\"\n
38
33
  RECEIVER_LOCALIZED = re.compile(rf"(?<![\w.\"])({IDENT}(?:\.{IDENT})*)\.localized\b")
39
34
 
40
35
 
41
- def strip_accessor(chain):
42
- segments = chain.strip(".").split(".")
43
- while segments and segments[-1].startswith("localized"):
44
- segments.pop()
45
- return ".".join(segments)
46
-
47
-
48
36
  def platform_of(path):
49
37
  ext = os.path.splitext(path)[1]
50
38
  if ext in ANDROID:
@@ -54,20 +42,6 @@ def platform_of(path):
54
42
  return "ios"
55
43
 
56
44
 
57
- def source_files(root):
58
- if os.path.isfile(root):
59
- yield root
60
- return
61
- for current, dirs, files in os.walk(root):
62
- probe = current.replace(os.sep, "/") + "/"
63
- if any(part in probe for part in SKIP_PARTS):
64
- dirs[:] = []
65
- continue
66
- for name in sorted(files):
67
- if os.path.splitext(name)[1] in EXTENSIONS:
68
- yield os.path.join(current, name)
69
-
70
-
71
45
  def hits_in_line(line, ext):
72
46
  static, dynamic = [], []
73
47
  if ext == ".xml":
@@ -81,11 +55,8 @@ def hits_in_line(line, ext):
81
55
  if WEB_DOLLAR_T_DYNAMIC.search(line):
82
56
  dynamic.append("(dynamic: $t(...))")
83
57
  return static, dynamic
84
- for match in LSK.finditer(line):
85
- key = strip_accessor(match.group(1))
86
- if key.count(".") >= 1:
87
- static.append(key)
88
- static.extend(m.group(1) for m in LITERAL_LOCALIZED.finditer(line))
58
+ static.extend(lsk_keys(line))
59
+ static.extend(m.group(1) for m in LITERAL_LOCALIZED.finditer(line) if not is_interpolated(m.group(1)))
89
60
  static.extend(m.group(1) for m in R_STRING.finditer(line))
90
61
  if LSK_DYNAMIC.search(line):
91
62
  dynamic.append("(dynamic: LocalizationStringKey(...))")