@mmerterden/multi-agent-pipeline 20.6.0 → 20.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/docs/facts.json +1 -1
- package/manifest.json +71 -71
- package/package.json +1 -1
- package/pipeline/multi-agent-refs/features/design-conformance.md +62 -64
- package/pipeline/scripts/_notices.mjs +11 -0
- package/pipeline/scripts/gen-skills-index.mjs +1 -1
- package/pipeline/skills/.skill-manifest.json +39 -39
- package/pipeline/skills/shared/README.md +1 -1
- package/pipeline/skills/shared/external/agent-introspection-debugging/SKILL.md +1 -0
- package/pipeline/skills/shared/external/android-architecture/SKILL.md +2 -0
- package/pipeline/skills/shared/external/android-performance/SKILL.md +2 -0
- package/pipeline/skills/shared/external/android-security/SKILL.md +2 -0
- package/pipeline/skills/shared/external/backlog/BACKLOG.md +1 -1
- package/pipeline/skills/shared/external/backlog/SKILL.md +56 -33
- package/pipeline/skills/shared/external/ci-cd-pipelines/SKILL.md +1 -0
- package/pipeline/skills/shared/external/compose-components/SKILL.md +2 -0
- package/pipeline/skills/shared/external/compose-navigation/SKILL.md +3 -2
- package/pipeline/skills/shared/external/compose-testing/SKILL.md +2 -0
- package/pipeline/skills/shared/external/council/SKILL.md +1 -0
- package/pipeline/skills/shared/external/css-modern/SKILL.md +1 -0
- package/pipeline/skills/shared/external/database-patterns/SKILL.md +1 -0
- package/pipeline/skills/shared/external/evidence-github/SKILL.md +2 -0
- package/pipeline/skills/shared/external/evidence-registry/SKILL.md +2 -0
- package/pipeline/skills/shared/external/gradle-kotlin-dsl/SKILL.md +2 -0
- package/pipeline/skills/shared/external/html-semantic/SKILL.md +1 -0
- package/pipeline/skills/shared/external/humanizer/SKILL.md +1 -0
- package/pipeline/skills/shared/external/ios-coding-standard/SKILL.md +1 -0
- package/pipeline/skills/shared/external/ios-module-structure/SKILL.md +1 -0
- package/pipeline/skills/shared/external/ios-security/SKILL.md +2 -0
- package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +91 -283
- package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +119 -151
- package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +60 -90
- package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +119 -156
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +726 -787
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +253 -288
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +243 -304
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +88 -104
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +181 -235
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +198 -263
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +461 -466
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +145 -151
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +123 -141
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +146 -157
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/snapshot-resources.sh +22 -19
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +156 -140
- package/pipeline/skills/shared/external/nextjs-app-router/SKILL.md +1 -0
- package/pipeline/skills/shared/external/play-store-review/SKILL.md +2 -0
- package/pipeline/skills/shared/external/python-patterns/SKILL.md +1 -0
- package/pipeline/skills/shared/external/react-best-practices/SKILL.md +1 -0
- package/pipeline/skills/shared/external/rest-api-design/SKILL.md +1 -0
- package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +2 -0
- package/pipeline/skills/shared/external/room-database/SKILL.md +2 -0
- package/pipeline/skills/shared/external/search-first/SKILL.md +1 -0
- package/pipeline/skills/shared/external/signal-community/SKILL.md +2 -0
- package/pipeline/skills/shared/external/skill-creator/SKILL.md +80 -41
- package/pipeline/skills/shared/external/skill-creator/audit.md +63 -59
- package/pipeline/skills/shared/external/skill-creator/checklist.md +28 -20
- package/pipeline/skills/shared/external/skill-creator/examples.md +40 -40
- package/pipeline/skills/shared/external/skill-creator/label-check.md +48 -36
- package/pipeline/skills/shared/external/skill-creator/scripts/audit-panel.js +91 -100
- package/pipeline/skills/shared/external/skill-creator/template.md +51 -39
- package/pipeline/skills/shared/external/tailwind-css/SKILL.md +1 -0
- package/pipeline/skills/shared/external/testing-backend/SKILL.md +1 -0
- package/pipeline/skills/shared/external/typescript-patterns/SKILL.md +1 -0
- package/pipeline/skills/shared/external/vue-composition/SKILL.md +1 -0
- package/pipeline/skills/shared/external/web-accessibility/SKILL.md +1 -0
- package/pipeline/skills/shared/external/web-performance/SKILL.md +1 -0
- package/pipeline/skills/shared/external/web-testing/SKILL.md +1 -0
package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py
CHANGED
|
@@ -1,186 +1,180 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Resolve
|
|
2
|
+
"""Resolve the legacy translation of each requested key, per language.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
`--live` re-reads the project's label endpoint directly, to refresh or to verify the
|
|
9
|
-
snapshot. That endpoint is project-specific, so nothing about it is baked in - supply
|
|
10
|
-
it the same way `fetch-legacy-labels.py` takes it:
|
|
11
|
-
|
|
12
|
-
resolve-legacy-values.py --keys Continue,Cancel --langs all --live \\
|
|
13
|
-
--endpoint 'https://labels.example.internal/{env}/labels/{lang}' \\
|
|
14
|
-
--headers-file .secrets/label-headers.json
|
|
15
|
-
|
|
16
|
-
Prefer --headers-file (JSON object) over repeated --header for anything secret.
|
|
17
|
-
|
|
18
|
-
`--prefix` covers backends that store keys under a namespace prefix the app strips
|
|
19
|
-
(e.g. a stored `Mobile-Continue` for the app's `Continue`); pass keys WITHOUT it.
|
|
20
|
-
|
|
21
|
-
LAST-RESORT offline fallback (`--plist-root`): a legacy iOS app's shipped
|
|
22
|
-
`<lang>.lproj/language.plist`. It is typically a stale subset - the snapshot and the
|
|
23
|
-
service are authoritative; a plist hit deserves a note in the map.
|
|
24
|
-
|
|
25
|
-
Output: `{key: {lang: value|null}}`. Never invent a value - `null` is a valid answer.
|
|
4
|
+
Sources, in order of preference: the in-repo flat snapshot
|
|
5
|
+
(<root>/<lang>.json), bundled language.plist files, or the live label
|
|
6
|
+
endpoint. Values are never invented: a key the source lacks comes back null.
|
|
7
|
+
Prints {key: {lang: value|null}} as JSON.
|
|
26
8
|
"""
|
|
27
9
|
import argparse
|
|
28
10
|
import json
|
|
29
11
|
import os
|
|
12
|
+
import plistlib
|
|
30
13
|
import sys
|
|
31
14
|
import urllib.request
|
|
32
15
|
|
|
33
|
-
|
|
16
|
+
ALL_LANGS = ["en", "tr", "ar", "de", "es", "fr", "it", "ru"]
|
|
34
17
|
|
|
35
18
|
|
|
36
|
-
def
|
|
37
|
-
|
|
38
|
-
while stack:
|
|
39
|
-
cur = stack.pop()
|
|
40
|
-
if isinstance(cur, dict):
|
|
41
|
-
strs = {k: v for k, v in cur.items() if isinstance(v, str)}
|
|
42
|
-
if len(strs) > len(best):
|
|
43
|
-
best = strs
|
|
44
|
-
stack.extend(cur.values())
|
|
45
|
-
elif isinstance(cur, list):
|
|
46
|
-
stack.extend(cur)
|
|
47
|
-
return best
|
|
19
|
+
def split_csv(value):
|
|
20
|
+
return [part.strip() for part in value.split(",") if part.strip()]
|
|
48
21
|
|
|
49
22
|
|
|
50
|
-
def
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
for pair in
|
|
23
|
+
def parse_langs(value):
|
|
24
|
+
if value.strip() == "all":
|
|
25
|
+
return list(ALL_LANGS)
|
|
26
|
+
return split_csv(value)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_pairs(pairs, flag):
|
|
30
|
+
result = {}
|
|
31
|
+
for pair in pairs or []:
|
|
59
32
|
if "=" not in pair:
|
|
60
|
-
raise SystemExit(f"
|
|
61
|
-
|
|
62
|
-
|
|
33
|
+
raise SystemExit(f"{flag} expects K=V, got {pair!r}")
|
|
34
|
+
key, value = pair.split("=", 1)
|
|
35
|
+
result[key.strip()] = value.strip()
|
|
36
|
+
return result
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_headers(args):
|
|
40
|
+
headers = {"Accept": "application/json"}
|
|
41
|
+
if args.headers_file:
|
|
42
|
+
with open(args.headers_file, encoding="utf-8") as handle:
|
|
43
|
+
extra = json.load(handle)
|
|
44
|
+
if not isinstance(extra, dict):
|
|
45
|
+
raise SystemExit(f"--headers-file must hold a JSON object: {args.headers_file}")
|
|
46
|
+
headers.update({str(k): str(v) for k, v in extra.items()})
|
|
47
|
+
headers.update(parse_pairs(args.header, "--header"))
|
|
63
48
|
return headers
|
|
64
49
|
|
|
65
50
|
|
|
66
|
-
def
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
51
|
+
def largest_string_map(data):
|
|
52
|
+
best = {}
|
|
53
|
+
stack = [data]
|
|
54
|
+
while stack:
|
|
55
|
+
node = stack.pop()
|
|
56
|
+
if isinstance(node, dict):
|
|
57
|
+
if node and all(isinstance(k, str) and isinstance(v, str) for k, v in node.items()):
|
|
58
|
+
if len(node) > len(best):
|
|
59
|
+
best = node
|
|
60
|
+
stack.extend(node.values())
|
|
61
|
+
elif isinstance(node, list):
|
|
62
|
+
stack.extend(node)
|
|
63
|
+
return best
|
|
74
64
|
|
|
75
65
|
|
|
76
|
-
def
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
66
|
+
def fetch_labels(endpoint, lang, env, headers, status_field, fail_status, timeout):
|
|
67
|
+
url = endpoint.format(lang=lang, env=env)
|
|
68
|
+
request_headers = dict(headers)
|
|
69
|
+
request_headers.setdefault("Accept-Language", lang)
|
|
70
|
+
request = urllib.request.Request(url, headers=request_headers)
|
|
71
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
72
|
+
data = json.loads(response.read().decode("utf-8"))
|
|
73
|
+
if isinstance(data, dict) and status_field in data:
|
|
74
|
+
code = str(data[status_field])
|
|
75
|
+
if code in fail_status:
|
|
76
|
+
raise RuntimeError(f"{url}: {fail_status[code]} (status {code})")
|
|
77
|
+
labels = largest_string_map(data)
|
|
78
|
+
if not labels:
|
|
79
|
+
raise RuntimeError(f"the response for '{lang}' carried no label map ({url})")
|
|
80
|
+
return labels
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def lookup(table, key, prefix):
|
|
84
|
+
if prefix and prefix + key in table:
|
|
85
|
+
return table[prefix + key]
|
|
86
|
+
return table.get(key)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def from_snapshot(root, keys, langs, prefix):
|
|
90
|
+
result = {key: {} for key in keys}
|
|
98
91
|
for lang in langs:
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
def resolve_snapshot(keys, langs, root, prefix):
|
|
106
|
-
"""Read the in-repo legacy snapshot (<root>/<lang>.json, flat {key: value}) -
|
|
107
|
-
offline, no network. Refresh it with fetch-legacy-labels.py."""
|
|
108
|
-
maps = {}
|
|
109
|
-
for l in langs:
|
|
110
|
-
p = os.path.join(root, f"{l}.json")
|
|
111
|
-
if os.path.isfile(p):
|
|
112
|
-
with open(p, encoding="utf-8") as fh:
|
|
113
|
-
maps[l] = json.load(fh)
|
|
92
|
+
path = os.path.join(root, f"{lang}.json")
|
|
93
|
+
table = {}
|
|
94
|
+
if os.path.isfile(path):
|
|
95
|
+
with open(path, encoding="utf-8") as handle:
|
|
96
|
+
table = json.load(handle)
|
|
114
97
|
else:
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
for
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
98
|
+
print(f"# no snapshot file for '{lang}': {path}", file=sys.stderr)
|
|
99
|
+
for key in keys:
|
|
100
|
+
result[key][lang] = lookup(table, key, prefix)
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def from_plist(root, keys, langs, prefix):
|
|
105
|
+
result = {key: {} for key in keys}
|
|
106
|
+
for lang in langs:
|
|
107
|
+
table = {}
|
|
108
|
+
candidates = [os.path.join(root, f"{lang}.lproj", "language.plist"), os.path.join(root, f"{lang}.plist")]
|
|
109
|
+
found = next((p for p in candidates if os.path.isfile(p)), None)
|
|
110
|
+
if found:
|
|
111
|
+
with open(found, "rb") as handle:
|
|
112
|
+
table = plistlib.load(handle)
|
|
128
113
|
else:
|
|
129
|
-
|
|
130
|
-
|
|
114
|
+
print(f"# no plist for '{lang}' under {root}", file=sys.stderr)
|
|
115
|
+
for key in keys:
|
|
116
|
+
result[key][lang] = lookup(table, key, prefix)
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def from_live(args, keys, langs):
|
|
121
|
+
if not args.endpoint:
|
|
122
|
+
raise RuntimeError("--live needs --endpoint (no label endpoint is built in)")
|
|
123
|
+
headers = build_headers(args)
|
|
124
|
+
fail_status = parse_pairs(args.fail_status, "--fail-status")
|
|
125
|
+
result = {key: {} for key in keys}
|
|
126
|
+
for lang in langs:
|
|
127
|
+
table = fetch_labels(args.endpoint, lang, args.env, headers, args.status_field, fail_status, args.timeout)
|
|
128
|
+
for key in keys:
|
|
129
|
+
result[key][lang] = lookup(table, key, args.prefix)
|
|
130
|
+
return result
|
|
131
131
|
|
|
132
132
|
|
|
133
133
|
def main():
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
# Preference: in-repo snapshot (offline) → live endpoint → plist.
|
|
160
|
-
if a.snapshot_root and not a.live:
|
|
161
|
-
out = resolve_snapshot(keys, langs, a.snapshot_root, a.prefix)
|
|
162
|
-
elif a.plist_root and not a.live and not a.snapshot_root:
|
|
163
|
-
sys.stderr.write("# OFFLINE plist fallback - the snapshot/service are the source of truth.\n")
|
|
164
|
-
out = resolve_plist(keys, langs, a.plist_root, a.prefix)
|
|
134
|
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
135
|
+
parser.add_argument("--keys", required=True, help="legacy keys separated by commas; leave off the backend prefix")
|
|
136
|
+
parser.add_argument("--langs", default="en,tr", help="comma list or 'all'")
|
|
137
|
+
parser.add_argument("--snapshot-root", help="directory holding <lang>.json flat snapshots")
|
|
138
|
+
parser.add_argument("--live", action="store_true", help="query the label endpoint instead of the snapshot")
|
|
139
|
+
parser.add_argument("--endpoint", help="URL template with {lang} and optional {env}")
|
|
140
|
+
parser.add_argument("--header", action="append", help="extra request header K=V (repeatable)")
|
|
141
|
+
parser.add_argument("--headers-file", help="JSON object of request headers; keeps secrets off argv")
|
|
142
|
+
parser.add_argument("--env", default="", help="value substituted for {env}")
|
|
143
|
+
parser.add_argument("--status-field", default="statusCode", help="response field carrying a status code")
|
|
144
|
+
parser.add_argument("--fail-status", action="append", help="CODE=message treated as a failure (repeatable)")
|
|
145
|
+
parser.add_argument("--timeout", type=int, default=30)
|
|
146
|
+
parser.add_argument("--plist-root", help="last resort: directory of bundled <lang>.lproj/language.plist")
|
|
147
|
+
parser.add_argument("--prefix", default="", help="backend key prefix the app strips, e.g. Mobile-")
|
|
148
|
+
args = parser.parse_args()
|
|
149
|
+
|
|
150
|
+
keys = split_csv(args.keys)
|
|
151
|
+
langs = parse_langs(args.langs)
|
|
152
|
+
|
|
153
|
+
if args.snapshot_root and not args.live:
|
|
154
|
+
result = from_snapshot(args.snapshot_root, keys, langs, args.prefix)
|
|
155
|
+
elif args.plist_root and not args.live:
|
|
156
|
+
print("# reading bundled plists: a stale subset; trust the snapshot and the service over them", file=sys.stderr)
|
|
157
|
+
result = from_plist(args.plist_root, keys, langs, args.prefix)
|
|
165
158
|
else:
|
|
166
159
|
try:
|
|
167
|
-
|
|
160
|
+
result = from_live(args, keys, langs)
|
|
168
161
|
except Exception as exc:
|
|
169
|
-
|
|
170
|
-
if
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
sys.stderr.write("# falling back to --plist-root.\n")
|
|
175
|
-
out = resolve_plist(keys, langs, a.plist_root, a.prefix)
|
|
162
|
+
print(f"# live label fetch failed ({exc})", file=sys.stderr)
|
|
163
|
+
if args.snapshot_root:
|
|
164
|
+
result = from_snapshot(args.snapshot_root, keys, langs, args.prefix)
|
|
165
|
+
elif args.plist_root:
|
|
166
|
+
result = from_plist(args.plist_root, keys, langs, args.prefix)
|
|
176
167
|
else:
|
|
177
168
|
raise
|
|
178
169
|
|
|
179
|
-
json.dump(
|
|
170
|
+
json.dump(result, sys.stdout, indent=2, ensure_ascii=False)
|
|
180
171
|
sys.stdout.write("\n")
|
|
181
|
-
|
|
182
|
-
if
|
|
183
|
-
|
|
172
|
+
|
|
173
|
+
empty = [key for key, values in result.items() if not any(values.values())]
|
|
174
|
+
if empty:
|
|
175
|
+
print(f"# {len(empty)} key(s) with no legacy value in any requested language:", file=sys.stderr)
|
|
176
|
+
for key in empty:
|
|
177
|
+
print(f"# {key}", file=sys.stderr)
|
|
184
178
|
|
|
185
179
|
|
|
186
180
|
if __name__ == "__main__":
|
package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py
CHANGED
|
@@ -1,170 +1,152 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
Why a script: the model otherwise hand-copies 8 languages x N keys out of JSON
|
|
9
|
-
files - slow, token-heavy, and error-prone. This is pure file IO, so it is a
|
|
10
|
-
script. Read-only; touches nothing.
|
|
11
|
-
|
|
12
|
-
Key-form handling: code often references a key as `AddAddress.continueButton`
|
|
13
|
-
(camelCase leaf) while the Suggested filename PascalCases every dotted segment
|
|
14
|
-
(`AddAddress.ContinueButton.json`). We normalise the first character of
|
|
15
|
-
each segment to upper-case before resolving, so either form works as input.
|
|
16
|
-
Accessibility keys live under `AccessibilityStrings/Sources/Suggested/` - pass
|
|
17
|
-
`--domain accessibility` for those.
|
|
18
|
-
|
|
19
|
-
`--catalog <file>`: the SHIPPED string catalog, checked whenever a key has no Suggested
|
|
20
|
-
file. Pass it. A missing Suggested file means "not in this snapshot", NOT "this key is
|
|
21
|
-
unauthored" - the snapshot is a generated mirror and goes stale the moment a key is added
|
|
22
|
-
upstream. Concluding "unauthored" from the snapshot alone has already produced a batch of
|
|
23
|
-
pointless authoring requests for keys that were shipping in all eight languages. With
|
|
24
|
-
`--catalog`, each key reports where its values came from (`suggested` / `catalog`), and
|
|
25
|
-
only keys absent from BOTH are reported as genuinely unauthored.
|
|
26
|
-
|
|
27
|
-
Accepts an Apple `.xcstrings` (`{"strings": {key: {"localizations": {lang: {"stringUnit":
|
|
28
|
-
{"value": ...}}}}}}`) or a flat `{key: {lang: value}}` map.
|
|
2
|
+
"""Look up the redesign copy of each given key, one language map per key.
|
|
3
|
+
|
|
4
|
+
The primary source is the Suggested/<Key>.json file of each key, found in a
|
|
5
|
+
resources checkout or a snapshot of it. A key missing there can still be
|
|
6
|
+
answered by a string catalog the app ships (.xcstrings or a flat JSON map).
|
|
7
|
+
Nothing is modified; the JSON result goes to stdout.
|
|
29
8
|
"""
|
|
30
9
|
import argparse
|
|
31
10
|
import json
|
|
32
11
|
import os
|
|
33
12
|
import sys
|
|
34
13
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
14
|
+
ALL_LANGS = ["en", "tr", "ar"] + sorted(["ru", "it", "fr", "es", "de"])
|
|
15
|
+
|
|
16
|
+
DOMAIN_DIRS = {
|
|
17
|
+
"localization": ("Suggested", "LocalizationStrings"),
|
|
18
|
+
"accessibility": ("AccessibilitySuggested", "AccessibilityStrings"),
|
|
38
19
|
}
|
|
39
20
|
|
|
40
21
|
|
|
41
|
-
def
|
|
42
|
-
|
|
43
|
-
|
|
22
|
+
def parse_langs(value):
|
|
23
|
+
if value.strip() == "all":
|
|
24
|
+
return list(ALL_LANGS)
|
|
25
|
+
return [part.strip() for part in value.split(",") if part.strip()]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def normalize_key(key):
|
|
29
|
+
return ".".join(seg[:1].upper() + seg[1:] for seg in key.split("."))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def read_keys(opts):
|
|
33
|
+
raw = [piece.strip() for piece in opts.keys.split(",")] if opts.keys else []
|
|
34
|
+
if opts.keys_file:
|
|
35
|
+
source = open(opts.keys_file, encoding="utf-8") if opts.keys_file != "-" else sys.stdin
|
|
36
|
+
with source:
|
|
37
|
+
raw += [line.strip() for line in source]
|
|
38
|
+
return list(dict.fromkeys(key for key in raw if key))
|
|
44
39
|
|
|
45
40
|
|
|
46
|
-
|
|
41
|
+
def suggested_dir(opts):
|
|
42
|
+
if opts.suggested_dir:
|
|
43
|
+
return opts.suggested_dir
|
|
44
|
+
snapshot_name, source_name = DOMAIN_DIRS[opts.domain]
|
|
45
|
+
root = opts.resources_root
|
|
46
|
+
snapshot = os.path.join(root, "Localization", snapshot_name)
|
|
47
|
+
if os.path.isdir(snapshot):
|
|
48
|
+
return snapshot
|
|
49
|
+
return os.path.join(root, "Resources", "Localization", source_name, "Sources", "Suggested")
|
|
47
50
|
|
|
48
51
|
|
|
49
|
-
def
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
snap = os.path.join(resources_root, "Localization", SNAPSHOT_SUBDIR[domain])
|
|
53
|
-
if os.path.isdir(snap):
|
|
54
|
-
return snap
|
|
55
|
-
return os.path.join(
|
|
56
|
-
resources_root, "Resources", "Localization",
|
|
57
|
-
DOMAIN_DIR[domain], "Sources", "Suggested",
|
|
58
|
-
)
|
|
52
|
+
def read_json(path):
|
|
53
|
+
with open(path, encoding="utf-8") as stream:
|
|
54
|
+
return json.load(stream)
|
|
59
55
|
|
|
60
56
|
|
|
61
|
-
def
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
for lang in langs:
|
|
69
|
-
entry = data.get(lang)
|
|
70
|
-
out[lang] = entry.get("value") if isinstance(entry, dict) else None
|
|
71
|
-
return out
|
|
57
|
+
def xcstrings_values(entry):
|
|
58
|
+
found = {}
|
|
59
|
+
for lang, localization in ((entry or {}).get("localizations") or {}).items():
|
|
60
|
+
unit = (localization or {}).get("stringUnit") or {}
|
|
61
|
+
if "value" in unit:
|
|
62
|
+
found[lang] = unit["value"]
|
|
63
|
+
return found
|
|
72
64
|
|
|
73
65
|
|
|
74
66
|
def load_catalog(path):
|
|
75
|
-
|
|
76
|
-
if not
|
|
67
|
+
data = read_json(path)
|
|
68
|
+
if not isinstance(data, dict):
|
|
77
69
|
return {}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
for
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
70
|
+
strings = data.get("strings")
|
|
71
|
+
if isinstance(strings, dict):
|
|
72
|
+
return {key: xcstrings_values(entry) for key, entry in strings.items()}
|
|
73
|
+
return data
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def read_suggested(path, langs):
|
|
77
|
+
data = read_json(path)
|
|
78
|
+
per_lang = data if isinstance(data, dict) else {}
|
|
79
|
+
|
|
80
|
+
def value_of(lang):
|
|
81
|
+
block = per_lang.get(lang)
|
|
82
|
+
return block.get("value") if isinstance(block, dict) else None
|
|
83
|
+
|
|
84
|
+
return {lang: value_of(lang) for lang in langs}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
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}
|
|
97
94
|
|
|
98
95
|
|
|
99
96
|
def main():
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
ap.add_argument("--langs", default="en,tr",
|
|
113
|
-
help="comma-separated langs, or 'all' (ar,de,en,es,fr,it,ru,tr)")
|
|
114
|
-
args = ap.parse_args()
|
|
115
|
-
|
|
116
|
-
if args.langs.strip() == "all":
|
|
117
|
-
langs = ["ar", "de", "en", "es", "fr", "it", "ru", "tr"]
|
|
118
|
-
else:
|
|
119
|
-
langs = [l.strip() for l in args.langs.split(",") if l.strip()]
|
|
120
|
-
|
|
121
|
-
keys = []
|
|
122
|
-
if args.keys:
|
|
123
|
-
keys += [k.strip() for k in args.keys.split(",") if k.strip()]
|
|
124
|
-
if args.keys_file:
|
|
125
|
-
src = sys.stdin if args.keys_file == "-" else open(args.keys_file, encoding="utf-8")
|
|
126
|
-
keys += [ln.strip() for ln in src if ln.strip()]
|
|
97
|
+
cli = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
98
|
+
cli.add_argument("--keys", help="keys separated by commas, in code form or file form")
|
|
99
|
+
cli.add_argument("--keys-file", help="one key per line; use - to read stdin")
|
|
100
|
+
cli.add_argument("--resources-root", help="root of the resources checkout or of its snapshot")
|
|
101
|
+
cli.add_argument("--suggested-dir", help="use this Suggested folder instead of looking for one")
|
|
102
|
+
cli.add_argument("--domain", default="localization", choices=sorted(DOMAIN_DIRS))
|
|
103
|
+
cli.add_argument("--catalog", help="string catalog the app ships (.xcstrings or flat JSON), consulted when a key has no Suggested file")
|
|
104
|
+
cli.add_argument("--report-source", action="store_true", help="return {source, values} per key instead of the bare values")
|
|
105
|
+
cli.add_argument("--langs", default="en,tr", help="languages separated by commas, or 'all'")
|
|
106
|
+
opts = cli.parse_args()
|
|
107
|
+
|
|
108
|
+
keys = read_keys(opts)
|
|
127
109
|
if not keys:
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
json.dump(result, sys.stdout, ensure_ascii=False, indent=2)
|
|
110
|
+
cli.error("supply --keys and/or --keys-file")
|
|
111
|
+
if not (opts.resources_root or opts.suggested_dir):
|
|
112
|
+
cli.error("supply --resources-root or --suggested-dir")
|
|
113
|
+
directory = suggested_dir(opts)
|
|
114
|
+
if not os.path.isdir(directory):
|
|
115
|
+
cli.error(f"no Suggested folder at {directory} (check --resources-root / --suggested-dir / --domain)")
|
|
116
|
+
|
|
117
|
+
langs = parse_langs(opts.langs)
|
|
118
|
+
catalog = load_catalog(opts.catalog) if opts.catalog else None
|
|
119
|
+
|
|
120
|
+
result, via_catalog, unresolved = {}, [], []
|
|
121
|
+
for key in keys:
|
|
122
|
+
path = os.path.join(directory, normalize_key(key) + ".json")
|
|
123
|
+
source, values = None, None
|
|
124
|
+
if os.path.isfile(path):
|
|
125
|
+
source, values = "suggested", read_suggested(path, langs)
|
|
126
|
+
elif catalog is not None:
|
|
127
|
+
values = from_catalog(catalog, key, langs)
|
|
128
|
+
if values is not None:
|
|
129
|
+
source = "catalog"
|
|
130
|
+
via_catalog.append(key)
|
|
131
|
+
if values is None:
|
|
132
|
+
unresolved.append(key)
|
|
133
|
+
result[key] = values if not opts.report_source else {"source": source, "values": values}
|
|
134
|
+
|
|
135
|
+
json.dump(result, sys.stdout, indent=2, ensure_ascii=False)
|
|
155
136
|
sys.stdout.write("\n")
|
|
156
137
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
138
|
+
def listing(header, items):
|
|
139
|
+
print(f"# {len(items)} key(s) {header}:", file=sys.stderr)
|
|
140
|
+
for item in items:
|
|
141
|
+
print(f"# {item}", file=sys.stderr)
|
|
142
|
+
|
|
143
|
+
if via_catalog:
|
|
144
|
+
listing("came from the catalog because the snapshot lacks them; run snapshot-resources.sh to refresh it", via_catalog)
|
|
145
|
+
if unresolved:
|
|
146
|
+
if catalog is None:
|
|
147
|
+
listing("are not in the snapshot (without --catalog, calling them 'unauthored' is UNVERIFIED)", unresolved)
|
|
148
|
+
else:
|
|
149
|
+
listing("have no authored source anywhere", unresolved)
|
|
168
150
|
|
|
169
151
|
|
|
170
152
|
if __name__ == "__main__":
|