@mmerterden/multi-agent-pipeline 20.8.0 → 20.8.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 +48 -0
- package/docs/facts.json +3 -3
- package/index.js +1 -0
- package/install/_codex-agents.mjs +1 -1
- package/install/_common.mjs +486 -53
- package/install/_mcp-register.mjs +173 -117
- package/install/claude.mjs +281 -220
- package/install/codex.mjs +7 -7
- package/install/copilot.mjs +13 -11
- package/install/index.mjs +92 -27
- package/install/templates/claude-hooks.json +9 -9
- package/manifest.json +112 -114
- package/package.json +5 -2
- package/pipeline/commands/multi-agent/update/SKILL.md +28 -17
- package/pipeline/lib/confusables.json +79 -33
- package/pipeline/lib/extract-conventions.sh +3 -3
- package/pipeline/lib/json-file-lock.mjs +27 -7
- package/pipeline/lib/normalize-text.mjs +86 -17
- package/pipeline/lib/outbound-gate.mjs +13 -4
- package/pipeline/lib/redact.mjs +87 -13
- package/pipeline/multi-agent-refs/analysis/evidence.md +1 -1
- package/pipeline/multi-agent-refs/analysis/synthesis.md +1 -1
- package/pipeline/multi-agent-refs/component-dispatch.md +1 -1
- package/pipeline/multi-agent-refs/conventions-defaults.md +1 -1
- package/pipeline/multi-agent-refs/features/unattended-security.md +2 -2
- package/pipeline/scripts/agent-guard.py +150 -25
- package/pipeline/scripts/audit-log.sh +3 -4
- package/pipeline/scripts/autopilot-runner.mjs +14 -5
- package/pipeline/scripts/doctor.mjs +8 -2
- package/pipeline/scripts/gen-skills-index.mjs +13 -1
- package/pipeline/scripts/log-metric.sh +9 -3
- package/pipeline/scripts/migrate-prefs.mjs +18 -4
- package/pipeline/scripts/pre-commit-check.sh +119 -27
- package/pipeline/scripts/scan-agent-config.sh +9 -9
- package/pipeline/scripts/unattended_policy.py +12 -3
- package/pipeline/scripts/uninstall.mjs +88 -1
- package/pipeline/scripts/usage-identity.mjs +1 -1
- package/pipeline/scripts/usage-register.mjs +1 -1
- package/pipeline/skills/.skill-manifest.json +30 -30
- package/pipeline/skills/shared/README.md +64 -64
- package/pipeline/skills/shared/external/alarmkit/SKILL.md +2 -2
- package/pipeline/skills/shared/external/alarmkit/evals/evals.json +2 -2
- package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +6 -0
- package/pipeline/skills/shared/external/app-store-optimization/references/keyword-research-methodology.md +3 -0
- package/pipeline/skills/shared/external/app-store-optimization/references/product-page-variants.md +3 -0
- package/pipeline/skills/shared/external/app-store-review/SKILL.md +3 -4
- package/pipeline/skills/shared/external/apple-on-device-ai/SKILL.md +5 -3
- package/pipeline/skills/shared/external/authentication/SKILL.md +29 -17
- package/pipeline/skills/shared/external/authentication/references/keychain-biometric.md +3 -1
- package/pipeline/skills/shared/external/background-processing/SKILL.md +10 -8
- package/pipeline/skills/shared/external/background-processing/references/background-task-patterns.md +7 -7
- package/pipeline/skills/shared/external/callkit-voip/SKILL.md +6 -3
- package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +43 -0
- package/pipeline/skills/shared/external/core-bluetooth/SKILL.md +4 -2
- package/pipeline/skills/shared/external/core-data/SKILL.md +12 -2
- package/pipeline/skills/shared/external/core-nfc/SKILL.md +31 -0
- package/pipeline/skills/shared/external/coreml/SKILL.md +1 -1
- package/pipeline/skills/shared/external/cryptokit/SKILL.md +1 -1
- package/pipeline/skills/shared/external/device-integrity/SKILL.md +11 -5
- package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
- package/pipeline/skills/shared/external/ios-module-structure/modules/_TEMPLATE.yml +1 -1
- package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +6 -6
- package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +3 -2
- package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +20 -18
- package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +4 -4
- package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +12 -9
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/_shared.py +201 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +64 -30
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +14 -16
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +20 -12
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +7 -7
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +26 -12
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +4 -3
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +51 -24
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +19 -9
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +26 -19
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +39 -46
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +37 -63
- package/pipeline/skills/shared/external/mapkit-location/SKILL.md +4 -2
- package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +5 -4
- package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +3 -2
- package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +2 -2
- package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +1 -1
- package/pipeline/skills/shared/external/pencilkit-drawing/references/pencilkit-patterns.md +4 -4
- package/pipeline/skills/shared/external/permissionkit/SKILL.md +15 -6
- package/pipeline/skills/shared/external/permissionkit/references/permissionkit-patterns.md +2 -1
- package/pipeline/skills/shared/external/push-notifications/SKILL.md +8 -4
- package/pipeline/skills/shared/external/push-notifications/references/notification-patterns.md +1 -1
- package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +25 -6
- package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +1 -1
- package/pipeline/skills/shared/external/skill-creator/template.md +7 -1
- package/pipeline/skills/shared/external/storekit/references/core-patterns.md +6 -1
- package/pipeline/skills/shared/external/swift-concurrency/SKILL.md +3 -2
- package/pipeline/skills/shared/external/swift-concurrency/references/concurrency-patterns.md +1 -1
- package/pipeline/skills/shared/external/swift-security/SKILL.md +10 -8
- package/pipeline/skills/shared/external/swift-security/references/certificate-trust.md +8 -5
- package/pipeline/skills/shared/external/swift-security/references/keychain-fundamentals.md +13 -9
- package/pipeline/skills/shared/external/swift-security/references/secure-enclave.md +7 -6
- package/pipeline/skills/shared/external/swift-testing/SKILL.md +8 -5
- package/pipeline/skills/shared/external/swift-testing/evals/evals.json +1 -1
- package/pipeline/skills/shared/external/swift-testing/references/testing-advanced.md +3 -2
- package/pipeline/skills/shared/external/swiftdata/SKILL.md +5 -3
- package/pipeline/skills/shared/external/swiftui-navigation/SKILL.md +17 -9
- package/pipeline/skills/shared/external/swiftui-navigation/references/navigationstack.md +4 -2
- package/pipeline/skills/shared/external/swiftui-navigation/references/tabview.md +13 -6
- package/pipeline/skills/shared/external/vision-framework/SKILL.md +3 -1
- package/pipeline/skills/shared/external/weatherkit/SKILL.md +8 -5
- package/pipeline/skills/shared/external/widgetkit/SKILL.md +15 -7
- package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +8 -6
- package/pipeline/scripts/gen-ref-toc.mjs +0 -279
- package/pipeline/scripts/make-manifest.mjs +0 -199
- package/pipeline/scripts/scorecard-snapshot.mjs +0 -178
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Key patterns, mapping-row marker rules and text helpers shared by the scripts.
|
|
2
|
+
|
|
3
|
+
Every script that scans keys, names output files or orders languages imports
|
|
4
|
+
this module, so the scanner, the gate, the CMS sheet and the overlay agree on
|
|
5
|
+
what a wired key is, which rows are component-owned or dynamic, which files are
|
|
6
|
+
source, how a screen name becomes a file slug and which order languages take.
|
|
7
|
+
The key accessor type (`--key-type`, default `L10n`) and the language list
|
|
8
|
+
(`--langs`, default the languages present in the data) are inputs, never a
|
|
9
|
+
built-in list of one app's.
|
|
10
|
+
"""
|
|
11
|
+
import argparse
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
EXTENSIONS = {".swift", ".kt", ".kts", ".xml", ".ts", ".tsx", ".js", ".jsx", ".vue"}
|
|
16
|
+
SKIP_DIRS = {".git", "Generated"}
|
|
17
|
+
FALLBACK_LANG = "en"
|
|
18
|
+
RTL_LANGS = {"ar", "fa", "he", "ur"}
|
|
19
|
+
|
|
20
|
+
WORD = "A-Za-z0-9_"
|
|
21
|
+
IDENT = rf"[A-Za-z_][{WORD}]*"
|
|
22
|
+
DEFAULT_KEY_TYPES = ("L10n",)
|
|
23
|
+
KEY_TYPE_NAME = re.compile(rf"^{IDENT}$")
|
|
24
|
+
LITERAL_KEY = re.compile(rf"\"([{WORD}.]+)\"\.localized\b")
|
|
25
|
+
WEB_T = re.compile(r"(?<![\w$])t\(\s*(['\"])([A-Za-z0-9_.\-]+)\1")
|
|
26
|
+
WEB_DOLLAR_T = re.compile(r"\$t\(\s*(['\"])([A-Za-z0-9_.\-]+)\1")
|
|
27
|
+
INTERPOLATION = re.compile(r"\\\(|\$\{|\$[A-Za-z_]")
|
|
28
|
+
|
|
29
|
+
TRAILING_MARKER = re.compile(r"\s*\([^()]*\)\s*$")
|
|
30
|
+
COMPONENT_MARKER = re.compile(r"\([^)]*(owned by|bile\u015fen|component)[^)]*\)", re.IGNORECASE)
|
|
31
|
+
PLACEHOLDER = re.compile(r"<[^<>]+>")
|
|
32
|
+
DYNAMIC_WORDS = ("dynamic", "dinamik")
|
|
33
|
+
RUNTIME_NOTE = "enumerate at runtime"
|
|
34
|
+
|
|
35
|
+
XML_INVALID = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f]")
|
|
36
|
+
|
|
37
|
+
ACCESSOR_EXTENSIONS = {".swift", ".kt", ".kts"}
|
|
38
|
+
COMMON_ACCESSORS = (
|
|
39
|
+
"L10n", "LocalizationKey", "LocalizationKeys",
|
|
40
|
+
"LocalizedStringKey", "LocalizedStrings", "Localizable", "Localization", "Strings", "Loc",
|
|
41
|
+
)
|
|
42
|
+
ACCESSOR_CHAIN = re.compile(rf"(?<![{WORD}.])([A-Z][{WORD}]*)((?:\.{IDENT}){{2,}})")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def strip_accessor(chain):
|
|
46
|
+
segments = chain.strip(".").split(".")
|
|
47
|
+
while segments and segments[-1].startswith("localized"):
|
|
48
|
+
segments.pop()
|
|
49
|
+
return ".".join(segments)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def key_types(values, fallback=DEFAULT_KEY_TYPES):
|
|
53
|
+
"""The accessor type names from repeated or comma-separated --key-type values.
|
|
54
|
+
|
|
55
|
+
Raises ValueError on a name that is not an identifier.
|
|
56
|
+
"""
|
|
57
|
+
names = [part.strip() for value in values or [] for part in str(value).split(",") if part.strip()]
|
|
58
|
+
for name in names:
|
|
59
|
+
if not KEY_TYPE_NAME.match(name):
|
|
60
|
+
raise ValueError(f"--key-type expects an identifier, got {name!r}")
|
|
61
|
+
return tuple(dict.fromkeys(names)) or tuple(fallback)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def key_type_alternation(types):
|
|
65
|
+
return "|".join(re.escape(name) for name in sorted(types, key=len, reverse=True))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def key_chain_pattern(types):
|
|
69
|
+
return re.compile(rf"(?:{key_type_alternation(types)})((?:\.{IDENT})+)")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def lsk_keys(text, types=DEFAULT_KEY_TYPES):
|
|
73
|
+
for hit in key_chain_pattern(types).finditer(text):
|
|
74
|
+
key = strip_accessor(hit.group(1))
|
|
75
|
+
if key.count(".") >= 1:
|
|
76
|
+
yield key
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def accessor_roots(texts, types=DEFAULT_KEY_TYPES):
|
|
80
|
+
"""Roots of key-like member chains that are not one of `types`, with their counts.
|
|
81
|
+
|
|
82
|
+
A chain counts when its root is a common accessor name or when it ends in
|
|
83
|
+
`.localized`, and it keeps two or more segments once that suffix is dropped.
|
|
84
|
+
"""
|
|
85
|
+
counts = {}
|
|
86
|
+
for text in texts:
|
|
87
|
+
for hit in ACCESSOR_CHAIN.finditer(text):
|
|
88
|
+
root, members = hit.group(1), hit.group(2)
|
|
89
|
+
if root in types:
|
|
90
|
+
continue
|
|
91
|
+
segments = members.strip(".").split(".")
|
|
92
|
+
localized = segments[-1].startswith("localized")
|
|
93
|
+
if strip_accessor(members).count(".") < 1:
|
|
94
|
+
continue
|
|
95
|
+
if root in COMMON_ACCESSORS or localized:
|
|
96
|
+
counts[root] = counts.get(root, 0) + 1
|
|
97
|
+
return dict(sorted(counts.items(), key=lambda item: (-item[1], item[0])))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def key_type_warning(texts, types=DEFAULT_KEY_TYPES, mapping_hint=False):
|
|
101
|
+
"""A warning when no chain of `types` is found but chains of another accessor are.
|
|
102
|
+
|
|
103
|
+
`texts` are the Swift and Kotlin sources read. Returns None when the
|
|
104
|
+
configured type matched at least once or nothing resembles an accessor.
|
|
105
|
+
"""
|
|
106
|
+
texts = list(texts)
|
|
107
|
+
if any(True for text in texts for _ in lsk_keys(text, types)):
|
|
108
|
+
return None
|
|
109
|
+
roots = accessor_roots(texts, types)
|
|
110
|
+
if not roots:
|
|
111
|
+
return None
|
|
112
|
+
seen = ", ".join(f"{root} ({count})" for root, count in list(roots.items())[:5])
|
|
113
|
+
where = "--key-type <Name> (or \"keyType\" in the mapping)" if mapping_hint else "--key-type <Name>"
|
|
114
|
+
return (
|
|
115
|
+
f"WARNING: no key chain of {', '.join(types)} was found, but the code has accessor chains rooted at "
|
|
116
|
+
f"{seen}. Set {where} to the project's key accessor type; until then its keys are "
|
|
117
|
+
f"missed or counted as weak."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def order_langs(langs):
|
|
122
|
+
"""en first when present, then the rest sorted; en alone when nothing is given."""
|
|
123
|
+
unique = list(dict.fromkeys(lang for lang in langs if lang))
|
|
124
|
+
rest = sorted(lang for lang in unique if lang != FALLBACK_LANG)
|
|
125
|
+
ordered = ([FALLBACK_LANG] if FALLBACK_LANG in unique else []) + rest
|
|
126
|
+
return ordered or [FALLBACK_LANG]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def parse_langs(value, present=()):
|
|
130
|
+
"""A comma list in the order given, or for 'all' every language in `present`."""
|
|
131
|
+
text = str(value or "all").strip()
|
|
132
|
+
if text == "all":
|
|
133
|
+
return order_langs(present)
|
|
134
|
+
return [part.strip() for part in text.split(",") if part.strip()]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def reject_token_flag(cli, opts, env_hint):
|
|
138
|
+
"""Exit 2 when a credential was passed on argv, which other processes can read."""
|
|
139
|
+
if getattr(opts, "token", None) is not None:
|
|
140
|
+
cli.error(f"--token is not accepted, because other processes can read argv; {env_hint}")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def add_token_flag(cli):
|
|
144
|
+
cli.add_argument("--token", help=argparse.SUPPRESS)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def is_interpolated(literal):
|
|
148
|
+
return bool(INTERPOLATION.search(literal))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def normalize_key(key):
|
|
152
|
+
return ".".join(seg[:1].upper() + seg[1:] for seg in key.split("."))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def source_files(root):
|
|
156
|
+
if os.path.isfile(root):
|
|
157
|
+
yield root
|
|
158
|
+
return
|
|
159
|
+
for current, dirs, files in os.walk(root):
|
|
160
|
+
relative = os.path.relpath(current, root)
|
|
161
|
+
parts = [] if relative == os.curdir else relative.split(os.sep)
|
|
162
|
+
if SKIP_DIRS.intersection(parts):
|
|
163
|
+
dirs[:] = []
|
|
164
|
+
continue
|
|
165
|
+
for name in sorted(files):
|
|
166
|
+
if os.path.splitext(name)[1] in EXTENSIONS:
|
|
167
|
+
yield os.path.join(current, name)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def raw_key(row):
|
|
171
|
+
return str(row.get("newKey") or "").strip()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def clean_key(new_key):
|
|
175
|
+
text = str(new_key or "").strip()
|
|
176
|
+
if text.startswith("(dynamic"):
|
|
177
|
+
return text
|
|
178
|
+
return TRAILING_MARKER.sub("", text)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def is_component(row):
|
|
182
|
+
return bool(COMPONENT_MARKER.search(raw_key(row)))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def key_is_dynamic(key):
|
|
186
|
+
return key.startswith("(dynamic") or bool(PLACEHOLDER.search(key))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def is_dynamic(row, key):
|
|
190
|
+
element = str(row.get("element") or "").lower()
|
|
191
|
+
note = str(row.get("note") or "").lower()
|
|
192
|
+
return key_is_dynamic(key) or any(word in element for word in DYNAMIC_WORDS) or RUNTIME_NOTE in note
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def xml_text(value):
|
|
196
|
+
return XML_INVALID.sub("", str(value))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def slug(text):
|
|
200
|
+
value = re.sub(r"[\W_]+", "-", str(text or "").strip().lower()).strip("-")
|
|
201
|
+
return value or "screen"
|
|
@@ -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
|
-
|
|
26
|
+
from _shared import RTL_LANGS, order_langs, parse_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(
|
|
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}>"
|
|
@@ -209,14 +210,23 @@ def storage_image(filename, dimension, pixels):
|
|
|
209
210
|
return node("ac:image", [(f"ac:{dimension}", pixels)], target)
|
|
210
211
|
|
|
211
212
|
|
|
213
|
+
def mapping_langs(rows):
|
|
214
|
+
found = []
|
|
215
|
+
for row in rows:
|
|
216
|
+
for side in ("new", "legacy", "cms"):
|
|
217
|
+
found.extend((row.get(side) or {}).keys())
|
|
218
|
+
return order_langs(found)
|
|
219
|
+
|
|
220
|
+
|
|
212
221
|
class Doc:
|
|
213
|
-
def __init__(self, mapping, t, prefix, shots, out_dir):
|
|
222
|
+
def __init__(self, mapping, t, prefix, shots, out_dir, langs=None):
|
|
214
223
|
self.m = mapping
|
|
215
224
|
self.t = t
|
|
216
225
|
self.prefix = prefix
|
|
217
226
|
self.shots = shots
|
|
218
227
|
self.out = out_dir
|
|
219
228
|
self.rows = mapping.get("rows") or []
|
|
229
|
+
self.langs = list(langs) if langs else mapping_langs(self.rows)
|
|
220
230
|
self.counts = {v: sum(1 for r in self.rows if r.get("verdict") == v) for v in VERDICTS}
|
|
221
231
|
self.cms_rows = sum(1 for r in self.rows if (r.get("cms") or {}).get("tr") or (r.get("cms") or {}).get("en"))
|
|
222
232
|
self.drift_rows = sum(1 for r in self.rows if row_drifts(r))
|
|
@@ -245,7 +255,7 @@ class Doc:
|
|
|
245
255
|
]
|
|
246
256
|
|
|
247
257
|
def detail_langs(self, row):
|
|
248
|
-
return
|
|
258
|
+
return self.langs + [lang for lang in (row.get("cms") or {}) if lang not in self.langs]
|
|
249
259
|
|
|
250
260
|
def md(self):
|
|
251
261
|
t, m = self.t, self.m
|
|
@@ -291,12 +301,12 @@ class Doc:
|
|
|
291
301
|
return storage_macro(
|
|
292
302
|
"status",
|
|
293
303
|
node("ac:parameter", [("ac:name", "colour")], look(row).status),
|
|
294
|
-
node("ac:parameter", [("ac:name", "title")],
|
|
304
|
+
node("ac:parameter", [("ac:name", "title")], xml_escape(self.verdict(row))),
|
|
295
305
|
)
|
|
296
306
|
|
|
297
307
|
def xml(self):
|
|
298
308
|
t, m = self.t, self.m
|
|
299
|
-
e =
|
|
309
|
+
e = xml_escape
|
|
300
310
|
|
|
301
311
|
def heading(label):
|
|
302
312
|
return f"<h2>{e(t[label])}</h2>"
|
|
@@ -504,7 +514,7 @@ def para(text, bold=False, size=None, rtl=False):
|
|
|
504
514
|
if rtl:
|
|
505
515
|
styling.append(w("rtl"))
|
|
506
516
|
run = [w("rPr", *styling)] if styling else []
|
|
507
|
-
run.append(node("w:t", [("xml:space", "preserve")],
|
|
517
|
+
run.append(node("w:t", [("xml:space", "preserve")], xml_escape(text)))
|
|
508
518
|
lead = [w("pPr", w("bidi"))] if rtl else []
|
|
509
519
|
return w("p", *lead, w("r", *run))
|
|
510
520
|
|
|
@@ -571,7 +581,7 @@ def detail_blocks(doc, t):
|
|
|
571
581
|
blocks.append(para(f"{t['key']}: {row.get('newKey') or ''}"))
|
|
572
582
|
lines = [table_row(tcell(c, fill="F5F6F8") for c in t["detail_columns"])]
|
|
573
583
|
for lang in doc.detail_langs(row):
|
|
574
|
-
lines.append(table_row(tcell(v, rtl=lang
|
|
584
|
+
lines.append(table_row(tcell(v, rtl=lang in RTL_LANGS) for v in (lang, *lang_values(row, lang))))
|
|
575
585
|
blocks.append(table(lines))
|
|
576
586
|
return blocks
|
|
577
587
|
|
|
@@ -663,17 +673,21 @@ def chrome_candidates():
|
|
|
663
673
|
|
|
664
674
|
def pdf_libreoffice(docx_path, out_dir):
|
|
665
675
|
binaries = [shutil.which("soffice"), shutil.which("libreoffice"), mac_app("LibreOffice", "soffice")]
|
|
666
|
-
|
|
676
|
+
name = os.path.splitext(os.path.basename(docx_path))[0] + ".pdf"
|
|
667
677
|
for binary in binaries:
|
|
668
678
|
if not (binary and os.path.isfile(binary)):
|
|
669
679
|
continue
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
680
|
+
with tempfile.TemporaryDirectory() as scratch:
|
|
681
|
+
try:
|
|
682
|
+
subprocess.run([binary, "--headless", "--convert-to", "pdf", "--outdir", scratch, docx_path],
|
|
683
|
+
capture_output=True, timeout=180)
|
|
684
|
+
except (OSError, subprocess.SubprocessError):
|
|
685
|
+
continue
|
|
686
|
+
rendered = os.path.join(scratch, name)
|
|
687
|
+
if os.path.isfile(rendered) and os.path.getsize(rendered) > 0:
|
|
688
|
+
target = os.path.join(out_dir, name)
|
|
689
|
+
shutil.copyfile(rendered, target)
|
|
690
|
+
return target
|
|
677
691
|
return None
|
|
678
692
|
|
|
679
693
|
|
|
@@ -698,7 +712,7 @@ def pdf_chrome(preview_path, pdf_path):
|
|
|
698
712
|
with tempfile.TemporaryDirectory() as profile:
|
|
699
713
|
if os.path.exists(pdf_path):
|
|
700
714
|
os.remove(pdf_path)
|
|
701
|
-
argv = [chrome, headless, "--disable-gpu", f"--user-data-dir={profile}", "--no-pdf-header-footer",
|
|
715
|
+
argv = [chrome, headless, "--no-sandbox", "--disable-gpu", f"--user-data-dir={profile}", "--no-pdf-header-footer",
|
|
702
716
|
f"--print-to-pdf={pdf_path}", page]
|
|
703
717
|
try:
|
|
704
718
|
proc = subprocess.Popen(argv, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL)
|
|
@@ -721,17 +735,31 @@ def pdf_wkhtml(preview_path, pdf_path):
|
|
|
721
735
|
return pdf_path if os.path.isfile(pdf_path) and os.path.getsize(pdf_path) > 0 else None
|
|
722
736
|
|
|
723
737
|
|
|
724
|
-
def
|
|
738
|
+
def upload_path(name, mapping_dir, out_dir):
|
|
739
|
+
if os.path.isabs(name):
|
|
740
|
+
return name
|
|
741
|
+
for folder in (mapping_dir, out_dir):
|
|
742
|
+
if os.path.exists(os.path.join(folder, name)):
|
|
743
|
+
return os.path.join(folder, name)
|
|
744
|
+
return os.path.join(out_dir, name)
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def print_upload(name, mapping, screen, mapping_dir, out_dir):
|
|
725
748
|
prefix = os.environ.get("LOCALIZATION_PAGE_PREFIX", "Localization")
|
|
726
|
-
|
|
727
|
-
flags
|
|
728
|
-
flags
|
|
749
|
+
quote = shlex.quote
|
|
750
|
+
flags = [f"--xml {quote(os.path.join(out_dir, name + '.confluence.xml'))}", f"--screen {quote(str(screen))}"]
|
|
751
|
+
flags += [
|
|
752
|
+
f"--{field} {quote(upload_path(str(mapping[field]), mapping_dir, out_dir))}"
|
|
753
|
+
for field in ("screenshot", "overlay", "keyshots") if mapping.get(field)
|
|
754
|
+
]
|
|
755
|
+
flags.append(f"--attach {quote(os.path.join(out_dir, name + '.localization.xlsx'))}")
|
|
729
756
|
print(" \\\n ".join(["python3 scripts/publish-confluence.py"] + flags))
|
|
730
757
|
print()
|
|
758
|
+
title = json.dumps(f"{prefix} - {screen}", ensure_ascii=False).replace("'", "'\\''")
|
|
731
759
|
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
|
|
760
|
+
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
761
|
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\"
|
|
762
|
+
print(f"# --data '{{\"type\":\"page\",\"title\":{title},\"space\":{{\"key\":\"'$CONFLUENCE_SPACE'\"}},"
|
|
735
763
|
"\"ancestors\":[{\"id\":\"'$CONFLUENCE_PARENT'\"}],\"body\":{\"storage\":{\"value\":\"...\",\"representation\":\"storage\"}}}'")
|
|
736
764
|
print("# rm auth.curlrc")
|
|
737
765
|
|
|
@@ -766,6 +794,11 @@ def main():
|
|
|
766
794
|
cli.add_argument("--pdf", action="store_true", help="add a PDF to the outputs")
|
|
767
795
|
cli.add_argument("--all", action="store_true", help="shorthand for --docx plus --pdf")
|
|
768
796
|
cli.add_argument("--print-upload", action="store_true", help="show the publish command and a hand-rolled curl version of it")
|
|
797
|
+
cli.add_argument(
|
|
798
|
+
"--langs", default="all",
|
|
799
|
+
help="languages of the details tables, comma list in display order; 'all' (default) takes every language "
|
|
800
|
+
"in the mapping, en first then the rest sorted",
|
|
801
|
+
)
|
|
769
802
|
opts = cli.parse_args()
|
|
770
803
|
|
|
771
804
|
mapping, mapping_dir = read_mapping(opts.mapping)
|
|
@@ -776,7 +809,8 @@ def main():
|
|
|
776
809
|
out_dir = os.path.abspath(opts.target_dir)
|
|
777
810
|
os.makedirs(out_dir, exist_ok=True)
|
|
778
811
|
shots = load_keyshots(mapping, mapping_dir, out_dir)
|
|
779
|
-
|
|
812
|
+
langs = None if opts.langs.strip() == "all" else parse_langs(opts.langs)
|
|
813
|
+
doc = Doc(mapping, T[opts.ui_lang], mapping.get("legacyKeyPrefix") or "", shots, out_dir, langs)
|
|
780
814
|
doc.base = mapping_dir
|
|
781
815
|
|
|
782
816
|
renderers = [("md", doc.md), ("confluence.xml", doc.xml), ("preview.html", doc.preview)]
|
|
@@ -797,7 +831,7 @@ def main():
|
|
|
797
831
|
make_pdf(docx_path, out_dir, stem, keep_docx)
|
|
798
832
|
|
|
799
833
|
if opts.print_upload:
|
|
800
|
-
print_upload(stem, mapping, mapping.get("screen"))
|
|
834
|
+
print_upload(stem, mapping, mapping.get("screen"), mapping_dir, out_dir)
|
|
801
835
|
|
|
802
836
|
|
|
803
837
|
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(
|
|
134
|
+
text = escape(xml_text(value)).replace("\n", " ")
|
|
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__":
|
|
@@ -17,6 +17,8 @@ import urllib.parse
|
|
|
17
17
|
import urllib.request
|
|
18
18
|
from datetime import datetime, timezone
|
|
19
19
|
|
|
20
|
+
from _shared import add_token_flag, reject_token_flag
|
|
21
|
+
|
|
20
22
|
API = "https://api.figma.com/v1"
|
|
21
23
|
BATCH = 4
|
|
22
24
|
SEP = "[:\\-\u2013]"
|
|
@@ -24,8 +26,12 @@ TR_RE = re.compile("^(?:tr|tr[kc]e|t[\u00fcu]rk[\u00e7c]e)\\s*" + SEP + "\\s*(.+
|
|
|
24
26
|
EN_RE = re.compile("^(?:en|eng|ing|[i\u0130]ngilizce)\\s*" + SEP + "\\s*(.+)$", re.IGNORECASE)
|
|
25
27
|
|
|
26
28
|
|
|
29
|
+
URL_NODE_ID = re.compile(r"^\d+-\d+$")
|
|
30
|
+
|
|
31
|
+
|
|
27
32
|
def canon_id(node_id):
|
|
28
|
-
|
|
33
|
+
text = "" if node_id is None else str(node_id).strip()
|
|
34
|
+
return text.replace("-", ":") if URL_NODE_ID.match(text) else text
|
|
29
35
|
|
|
30
36
|
|
|
31
37
|
def split_lang(text):
|
|
@@ -68,7 +74,7 @@ def pick_label(labels):
|
|
|
68
74
|
def record(node_id, name, node_type, design_text, labels):
|
|
69
75
|
raw, parsed = pick_label(labels)
|
|
70
76
|
return {
|
|
71
|
-
"nodeId": canon_id(node_id),
|
|
77
|
+
"nodeId": canon_id(node_id) or None,
|
|
72
78
|
"name": name,
|
|
73
79
|
"type": node_type,
|
|
74
80
|
"designText": design_text,
|
|
@@ -113,9 +119,7 @@ def keychain_secret(service):
|
|
|
113
119
|
return secret if lookup.returncode == 0 and secret else None
|
|
114
120
|
|
|
115
121
|
|
|
116
|
-
def figma_token(
|
|
117
|
-
if explicit:
|
|
118
|
-
return explicit
|
|
122
|
+
def figma_token():
|
|
119
123
|
from_env = next((os.environ[v] for v in ("FIGMA_ACCESS_TOKEN", "FIGMA_TOKEN") if os.environ.get(v)), None)
|
|
120
124
|
return from_env or keychain_secret("FIGMA_ACCESS_TOKEN")
|
|
121
125
|
|
|
@@ -206,11 +210,11 @@ def pick_source(opts, cli, file_key, nodes):
|
|
|
206
210
|
if opts.source == "rest":
|
|
207
211
|
if not have_target:
|
|
208
212
|
cli.error("--source rest needs a file key and node-ids (--file/--nodes or --mapping)")
|
|
209
|
-
token = figma_token(
|
|
213
|
+
token = figma_token()
|
|
210
214
|
if not token:
|
|
211
|
-
sys.exit("No Figma token found. Export FIGMA_ACCESS_TOKEN or FIGMA_TOKEN, keep it in the keychain under FIGMA_ACCESS_TOKEN
|
|
215
|
+
sys.exit("No Figma token found. Export FIGMA_ACCESS_TOKEN or FIGMA_TOKEN, or keep it in the keychain under FIGMA_ACCESS_TOKEN.")
|
|
212
216
|
return ("rest",) + from_rest(file_key, nodes, token, opts.ca_file)
|
|
213
|
-
token = figma_token(
|
|
217
|
+
token = figma_token() if have_target else None
|
|
214
218
|
if have_target and token:
|
|
215
219
|
try:
|
|
216
220
|
return ("rest",) + from_rest(file_key, nodes, token, opts.ca_file)
|
|
@@ -232,10 +236,11 @@ def main():
|
|
|
232
236
|
cli.add_argument("--from-mcp", help="JSON dump of annotations taken through MCP")
|
|
233
237
|
cli.add_argument("--local", help="screen folder of a design export that contains tree.json")
|
|
234
238
|
cli.add_argument("--source", default="auto", choices=["auto", "rest", "mcp", "local"])
|
|
235
|
-
cli
|
|
239
|
+
add_token_flag(cli)
|
|
236
240
|
cli.add_argument("--ca-file", help="PEM roots to trust when a proxy intercepts TLS")
|
|
237
241
|
cli.add_argument("--out", help="file to write; stdout when omitted")
|
|
238
242
|
opts = cli.parse_args()
|
|
243
|
+
reject_token_flag(cli, opts, "export FIGMA_ACCESS_TOKEN (or FIGMA_TOKEN), or keep it in the macOS keychain under FIGMA_ACCESS_TOKEN")
|
|
239
244
|
|
|
240
245
|
file_key = opts.file
|
|
241
246
|
nodes = [piece for piece in (opts.nodes or "").split(",") if piece.strip()]
|
|
@@ -246,10 +251,13 @@ def main():
|
|
|
246
251
|
|
|
247
252
|
source, found, files, audited = pick_source(opts, cli, file_key, nodes)
|
|
248
253
|
|
|
249
|
-
|
|
254
|
+
unique, seen = [], set()
|
|
250
255
|
for item in found:
|
|
251
|
-
|
|
252
|
-
|
|
256
|
+
if item["nodeId"]:
|
|
257
|
+
if item["nodeId"] in seen:
|
|
258
|
+
continue
|
|
259
|
+
seen.add(item["nodeId"])
|
|
260
|
+
unique.append(item)
|
|
253
261
|
|
|
254
262
|
def having(lang):
|
|
255
263
|
return sum(1 for entry in unique if entry[lang])
|
package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py
CHANGED
|
@@ -11,13 +11,13 @@ import os
|
|
|
11
11
|
import sys
|
|
12
12
|
import urllib.request
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
from _shared import parse_langs
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
def
|
|
18
|
-
if
|
|
19
|
-
return
|
|
20
|
-
return [
|
|
17
|
+
def snapshot_langs(snapshot_dir):
|
|
18
|
+
if not os.path.isdir(snapshot_dir):
|
|
19
|
+
return []
|
|
20
|
+
return [name[:-5] for name in os.listdir(snapshot_dir) if name.endswith(".json")]
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
def parse_pairs(pairs, flag):
|
|
@@ -80,7 +80,7 @@ def main():
|
|
|
80
80
|
cli.add_argument("--endpoint", required=True, help="URL template containing {lang} and, if needed, {env}")
|
|
81
81
|
cli.add_argument("--header", action="append", help="additional request header as K=V; may be repeated")
|
|
82
82
|
cli.add_argument("--headers-file", help="request headers as a JSON object, so secrets stay out of argv")
|
|
83
|
-
cli.add_argument("--langs", default="all", help="languages separated by commas, or 'all'")
|
|
83
|
+
cli.add_argument("--langs", default="all", help="languages separated by commas, or 'all' for every language already in the snapshot (en when it is empty)")
|
|
84
84
|
cli.add_argument("--env", default="", help="text put in place of {env}")
|
|
85
85
|
cli.add_argument("--status-field", default="statusCode", help="name of the response field that holds a status code")
|
|
86
86
|
cli.add_argument("--fail-status", action="append", help="CODE=message pair that marks a failed response; may be repeated")
|
|
@@ -92,7 +92,7 @@ def main():
|
|
|
92
92
|
|
|
93
93
|
headers = build_headers(opts)
|
|
94
94
|
fail_status = parse_pairs(opts.fail_status, "--fail-status")
|
|
95
|
-
langs = parse_langs(opts.langs)
|
|
95
|
+
langs = parse_langs(opts.langs, snapshot_langs(opts.snapshot_dir))
|
|
96
96
|
os.makedirs(opts.snapshot_dir, exist_ok=True)
|
|
97
97
|
|
|
98
98
|
total = 0
|