@mmerterden/multi-agent-pipeline 20.5.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 +36 -0
- package/LICENSE +10 -0
- package/docs/facts.json +5 -4
- package/install/_dev-only-files.mjs +1 -0
- package/manifest.json +84 -82
- package/package.json +4 -3
- package/pipeline/lib/confusables.json +100 -0
- package/pipeline/lib/normalize-text.mjs +48 -0
- package/pipeline/lib/outbound-gate.mjs +10 -1
- package/pipeline/lib/redact.mjs +4 -1
- package/pipeline/lib/vercel-deploy.sh +5 -7
- package/pipeline/multi-agent-refs/features/design-conformance.md +62 -64
- package/pipeline/scripts/_notices.mjs +11 -0
- package/pipeline/scripts/agent-guard.py +30 -1
- package/pipeline/scripts/gen-skills-index.mjs +1 -1
- package/pipeline/scripts/pre-commit-check.sh +59 -16
- package/pipeline/scripts/pre-push-check.sh +3 -3
- package/pipeline/scripts/run-ui-tests.sh +5 -5
- package/pipeline/scripts/website-deploy-commit.sh +3 -2
- 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
|
@@ -1,334 +1,299 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
2
|
+
"""Write the CMS import sheet for a reuse map as <slug>.localization.xlsx and/or .csv.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
- Channel - the delivery channel these strings belong to ("Mobile" by default; --channel).
|
|
9
|
-
- Property Group - deduced from the key namespace; describe your CMS buckets with --taxonomy, or let a
|
|
10
|
-
row override it outright with a "propertyGroup" field.
|
|
11
|
-
- Property Module - deduced too (Fields / Validation / Common / <domain>); overridable with "propertyModule".
|
|
12
|
-
- Key - our NEW (redesign) key, verbatim (row.newKey). "(owned by ...)" component tags are stripped.
|
|
13
|
-
- EN/TR/AR Value - our SUGGESTED values (row.new.en / .tr / .ar - resolve-new-values.py output).
|
|
14
|
-
- Anotation EN/TR - the content team's ACTUAL copy, the Figma CMS annotations (row.cms.en / .tr).
|
|
15
|
-
|
|
16
|
-
Writes a real `.xlsx` (an OOXML zip built with the Python stdlib only - no pip, no network, same trick
|
|
17
|
-
build-artifact.py uses for .docx). `--csv` also emits a UTF-8 CSV alongside it (a terminal-friendly fallback
|
|
18
|
-
when Excel is not needed). `--csv-only` skips the xlsx.
|
|
19
|
-
|
|
20
|
-
Usage:
|
|
21
|
-
python3 build-spreadsheet.py <mapping>.json --out <dir> [--slug <slug>] [--channel Mobile]
|
|
22
|
-
[--taxonomy cms-taxonomy.json] [--csv|--csv-only]
|
|
4
|
+
Each mapping row turns into a single sheet row with the nine columns below.
|
|
5
|
+
Text is copied from the row's `new` block and the annotation columns from its
|
|
6
|
+
`cms` block; a column stays empty when the mapping has nothing for it. The
|
|
7
|
+
workbook is assembled from its XML parts with nothing beyond the stdlib.
|
|
23
8
|
"""
|
|
24
9
|
import argparse
|
|
25
10
|
import csv
|
|
26
|
-
import html
|
|
27
|
-
import io
|
|
28
11
|
import json
|
|
29
12
|
import os
|
|
13
|
+
import re
|
|
30
14
|
import sys
|
|
31
15
|
import zipfile
|
|
16
|
+
from xml.sax.saxutils import escape
|
|
32
17
|
|
|
33
|
-
COLUMNS = [
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def _text(v):
|
|
41
|
-
return "" if v in (None, "") else str(v)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def slugify(name):
|
|
45
|
-
slug = "".join(ch if ch.isalnum() else "-" for ch in _text(name).lower()).strip("-")
|
|
46
|
-
while "--" in slug:
|
|
47
|
-
slug = slug.replace("--", "-")
|
|
48
|
-
return slug or "screen"
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def clean_key(row):
|
|
52
|
-
"""The Key column = our new key, without a trailing '(owned by ...)' component tag."""
|
|
53
|
-
k = _text(row.get("newKey"))
|
|
54
|
-
i = k.find("(owned by")
|
|
55
|
-
return (k[:i].strip() if i >= 0 else k).strip()
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
# --- Property Group / Module deduction ------------------------------------------------
|
|
59
|
-
# A CMS typically files keys into a group (shared building blocks vs feature areas) and a
|
|
60
|
-
# module inside it. The deduction below is a neutral default; every CMS names its buckets
|
|
61
|
-
# differently, so point --taxonomy at a JSON file to describe yours:
|
|
62
|
-
#
|
|
63
|
-
# {
|
|
64
|
-
# "errors": {"group": "Common-Errors", "module": ""},
|
|
65
|
-
# "lookups": {"group": "Common-Lookup", "module": ""},
|
|
66
|
-
# "validation": {"group": "Core", "module": "Validation"},
|
|
67
|
-
# "fields": {"group": "Core", "module": "Fields"},
|
|
68
|
-
# "fallback": {"group": "Core", "module": "Common"},
|
|
69
|
-
# "domainGroup": "Domains",
|
|
70
|
-
# "domains": {"auth": ["login", "signup", "otp"], "billing": ["invoice", "card"]}
|
|
71
|
-
# }
|
|
72
|
-
#
|
|
73
|
-
# Any subset may be given; unspecified parts keep the defaults. A mapping row always wins
|
|
74
|
-
# over deduction via explicit "propertyGroup" / "propertyModule".
|
|
18
|
+
COLUMNS = [
|
|
19
|
+
"Channel", "Property Group", "Property Module", "Key",
|
|
20
|
+
"EN Value", "TR Value", "AR Value", "Anotation EN", "Anotation TR",
|
|
21
|
+
]
|
|
22
|
+
WIDTHS = [12, 18, 20, 48, 40, 40, 40, 40, 40]
|
|
75
23
|
|
|
76
24
|
DEFAULT_TAXONOMY = {
|
|
77
|
-
"errors": {"group": "Errors"
|
|
78
|
-
"lookups": {"group": "Lookups"
|
|
79
|
-
"validation": {"
|
|
80
|
-
"fields": {"
|
|
81
|
-
"fallback": {"
|
|
25
|
+
"errors": {"group": "Errors"},
|
|
26
|
+
"lookups": {"group": "Lookups"},
|
|
27
|
+
"validation": {"module": "Validation", "group": "Core"},
|
|
28
|
+
"fields": {"module": "Fields", "group": "Core"},
|
|
29
|
+
"fallback": {"module": "Common", "group": "Core"},
|
|
82
30
|
"domainGroup": "Domains",
|
|
83
31
|
"domains": {
|
|
84
|
-
"auth": ["
|
|
85
|
-
"account": ["
|
|
86
|
-
"checkout": ["
|
|
87
|
-
"search": ["
|
|
32
|
+
"auth": ["login", "sign-in", "signin", "signup", "register", "password", "otp", "auth"],
|
|
33
|
+
"account": ["profile", "account", "preferences", "settings"],
|
|
34
|
+
"checkout": ["cart", "checkout", "billing", "payment", "order"],
|
|
35
|
+
"search": ["results", "filter", "search"],
|
|
88
36
|
},
|
|
89
37
|
}
|
|
90
38
|
|
|
91
|
-
|
|
39
|
+
|
|
40
|
+
def slug(text):
|
|
41
|
+
value = re.sub(r"[\W_]+", "-", str(text or "").strip().lower()).strip("-")
|
|
42
|
+
return value or "screen"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def read_json(source):
|
|
46
|
+
with open(source, encoding="utf-8") as stream:
|
|
47
|
+
return json.load(stream)
|
|
92
48
|
|
|
93
49
|
|
|
94
50
|
def load_taxonomy(path):
|
|
95
|
-
|
|
51
|
+
taxonomy = json.loads(json.dumps(DEFAULT_TAXONOMY))
|
|
96
52
|
if not path:
|
|
97
|
-
return
|
|
98
|
-
|
|
99
|
-
user = json.load(fh)
|
|
53
|
+
return taxonomy
|
|
54
|
+
user = read_json(path)
|
|
100
55
|
if not isinstance(user, dict):
|
|
101
|
-
raise SystemExit(f"--taxonomy must
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
merged[k] = {**merged[k], **v}
|
|
56
|
+
raise SystemExit(f"--taxonomy must hold a JSON object: {path}")
|
|
57
|
+
for key, value in user.items():
|
|
58
|
+
if isinstance(value, dict) and isinstance(taxonomy.get(key), dict):
|
|
59
|
+
taxonomy[key].update(value)
|
|
106
60
|
else:
|
|
107
|
-
|
|
108
|
-
return
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
def row_to_record(row, channel):
|
|
155
|
-
group, module = deduce_group_module(row)
|
|
156
|
-
new = row.get("new") or {}
|
|
157
|
-
cms = row.get("cms") or {}
|
|
158
|
-
return [
|
|
159
|
-
channel,
|
|
160
|
-
group,
|
|
161
|
-
module,
|
|
162
|
-
clean_key(row),
|
|
163
|
-
_text(new.get("en")),
|
|
164
|
-
_text(new.get("tr")),
|
|
165
|
-
_text(new.get("ar")),
|
|
166
|
-
_text(cms.get("en")),
|
|
167
|
-
_text(cms.get("tr")),
|
|
168
|
-
]
|
|
61
|
+
taxonomy[key] = value
|
|
62
|
+
return taxonomy
|
|
63
|
+
|
|
64
|
+
|
|
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
|
+
def bucket(key, taxonomy):
|
|
72
|
+
lowered = key.lower()
|
|
73
|
+
if "error" in lowered:
|
|
74
|
+
entry = taxonomy["errors"]
|
|
75
|
+
elif "lookup" in lowered:
|
|
76
|
+
entry = taxonomy["lookups"]
|
|
77
|
+
elif "validation" in lowered or "invalid" in lowered or lowered.endswith(".valid"):
|
|
78
|
+
entry = taxonomy["validation"]
|
|
79
|
+
elif any(word in lowered for word in ("field", "placeholder", "hint", "label")):
|
|
80
|
+
entry = taxonomy["fields"]
|
|
81
|
+
else:
|
|
82
|
+
first = re.split(r"[.\-_/]", lowered, maxsplit=1)[0]
|
|
83
|
+
for domain, words in (taxonomy.get("domains") or {}).items():
|
|
84
|
+
if first in words or any(word in lowered for word in words):
|
|
85
|
+
return taxonomy.get("domainGroup", "Domains"), domain
|
|
86
|
+
entry = taxonomy["fallback"]
|
|
87
|
+
return entry.get("group", ""), entry.get("module", "")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def table_rows(mapping, channel, taxonomy):
|
|
91
|
+
out = []
|
|
92
|
+
for row in mapping.get("rows") or []:
|
|
93
|
+
key = clean_key(row.get("newKey"))
|
|
94
|
+
group, module = bucket(key, taxonomy)
|
|
95
|
+
if row.get("propertyGroup"):
|
|
96
|
+
group = row["propertyGroup"]
|
|
97
|
+
if row.get("propertyModule"):
|
|
98
|
+
module = row["propertyModule"]
|
|
99
|
+
new = row.get("new") or {}
|
|
100
|
+
cms = row.get("cms") or {}
|
|
101
|
+
out.append([
|
|
102
|
+
channel, group, module, key,
|
|
103
|
+
new.get("en") or "", new.get("tr") or "", new.get("ar") or "",
|
|
104
|
+
cms.get("en") or "", cms.get("tr") or "",
|
|
105
|
+
])
|
|
106
|
+
return out
|
|
169
107
|
|
|
170
108
|
|
|
171
|
-
def
|
|
172
|
-
|
|
109
|
+
def column_letter(index):
|
|
110
|
+
letters = ""
|
|
111
|
+
index += 1
|
|
112
|
+
while index:
|
|
113
|
+
index, rem = divmod(index - 1, 26)
|
|
114
|
+
letters = chr(65 + rem) + letters
|
|
115
|
+
return letters
|
|
173
116
|
|
|
174
117
|
|
|
175
|
-
|
|
118
|
+
XML_DECL = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
119
|
+
OOXML = "http://schemas.openxmlformats.org/"
|
|
120
|
+
NS_SHEET = OOXML + "spreadsheetml/2006/main"
|
|
121
|
+
NS_PKG_REL = OOXML + "package/2006/relationships"
|
|
122
|
+
NS_TYPES = OOXML + "package/2006/content-types"
|
|
123
|
+
NS_DOC_REL = OOXML + "officeDocument/2006/relationships"
|
|
124
|
+
MIME_OOXML = "application/vnd.openxmlformats-"
|
|
125
|
+
SHEET_NAME = "Localization"
|
|
176
126
|
|
|
177
|
-
def _xesc(s):
|
|
178
|
-
return html.escape(_text(s), quote=True)
|
|
179
127
|
|
|
128
|
+
def el(name, attrs=(), *children):
|
|
129
|
+
opening = name + "".join(f' {k}="{v}"' for k, v in attrs)
|
|
130
|
+
if not children:
|
|
131
|
+
return f"<{opening}/>"
|
|
132
|
+
return f"<{opening}>{''.join(children)}</{name}>"
|
|
180
133
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
134
|
+
|
|
135
|
+
def part(root, attrs, *children):
|
|
136
|
+
return XML_DECL + el(root, attrs, *children)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def cell(ref, value, style):
|
|
140
|
+
text = escape(str(value)).replace("\n", " ")
|
|
141
|
+
attrs = [("r", ref), ("t", "inlineStr")]
|
|
142
|
+
if style:
|
|
143
|
+
attrs.append(("s", style))
|
|
144
|
+
return el("c", attrs, el("is", (), el("t", [("xml:space", "preserve")], text)))
|
|
188
145
|
|
|
189
146
|
|
|
190
|
-
def
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
147
|
+
def sheet_xml(rows):
|
|
148
|
+
span = f"A1:{column_letter(len(COLUMNS) - 1)}{len(rows) + 1}"
|
|
149
|
+
widths = [
|
|
150
|
+
el("col", [("min", n), ("max", n), ("width", width), ("customWidth", 1)])
|
|
151
|
+
for n, width in enumerate(WIDTHS, 1)
|
|
152
|
+
]
|
|
153
|
+
lines = []
|
|
154
|
+
for number, values in enumerate([COLUMNS] + rows, 1):
|
|
155
|
+
header = 1 if number == 1 else 0
|
|
156
|
+
cells = [cell(f"{column_letter(c)}{number}", v, header) for c, v in enumerate(values)]
|
|
157
|
+
lines.append(el("row", [("r", number)], *cells))
|
|
158
|
+
frozen = el("pane", [("ySplit", 1), ("topLeftCell", "A2"), ("activePane", "bottomLeft"), ("state", "frozen")])
|
|
159
|
+
return part(
|
|
160
|
+
"worksheet", [("xmlns", NS_SHEET)],
|
|
161
|
+
el("dimension", [("ref", span)]),
|
|
162
|
+
el("sheetViews", (), el("sheetView", [("workbookViewId", 0)], frozen)),
|
|
163
|
+
el("cols", (), *widths),
|
|
164
|
+
el("sheetData", (), *lines),
|
|
165
|
+
el("autoFilter", [("ref", span)]),
|
|
194
166
|
)
|
|
195
167
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
168
|
+
|
|
169
|
+
def font(bold=False, rgb=None):
|
|
170
|
+
pieces = [el("b")] if bold else []
|
|
171
|
+
pieces.append(el("sz", [("val", 11)]))
|
|
172
|
+
if rgb:
|
|
173
|
+
pieces.append(el("color", [("rgb", rgb)]))
|
|
174
|
+
pieces.append(el("name", [("val", "Calibri")]))
|
|
175
|
+
return el("font", (), *pieces)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def fill(kind, *colors):
|
|
179
|
+
return el("fill", (), el("patternFill", [("patternType", kind)], *colors))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def xf(font_id, fill_id, *extra):
|
|
183
|
+
ids = [("numFmtId", 0), ("fontId", font_id), ("fillId", fill_id), ("borderId", 0)]
|
|
184
|
+
return el("xf", ids + list(extra))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def counted(name, *items):
|
|
188
|
+
return el(name, [("count", len(items))], *items)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def styles_xml():
|
|
192
|
+
edges = [el(side) for side in ("left", "right", "top", "bottom", "diagonal")]
|
|
193
|
+
solid = fill("solid", el("fgColor", [("rgb", "FF4472C4")]), el("bgColor", [("indexed", 64)]))
|
|
194
|
+
return part(
|
|
195
|
+
"styleSheet", [("xmlns", NS_SHEET)],
|
|
196
|
+
counted("fonts", font(), font(bold=True, rgb="FFFFFFFF")),
|
|
197
|
+
counted("fills", fill("none"), fill("gray125"), solid),
|
|
198
|
+
counted("borders", el("border", (), *edges)),
|
|
199
|
+
counted("cellStyleXfs", xf(0, 0)),
|
|
200
|
+
counted("cellXfs", xf(0, 0, ("xfId", 0)), xf(1, 2, ("xfId", 0), ("applyFont", 1), ("applyFill", 1))),
|
|
221
201
|
)
|
|
222
202
|
|
|
223
203
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
'<font><b/><sz val="11"/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
|
|
230
|
-
'</fonts>'
|
|
231
|
-
'<fills count="3">'
|
|
232
|
-
'<fill><patternFill patternType="none"/></fill>'
|
|
233
|
-
'<fill><patternFill patternType="gray125"/></fill>'
|
|
234
|
-
'<fill><patternFill patternType="solid"><fgColor rgb="FF4472C4"/><bgColor indexed="64"/></patternFill></fill>'
|
|
235
|
-
'</fills>'
|
|
236
|
-
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
|
|
237
|
-
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
|
|
238
|
-
'<cellXfs count="2">'
|
|
239
|
-
'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0" applyAlignment="1">'
|
|
240
|
-
'<alignment vertical="top" wrapText="1"/></xf>'
|
|
241
|
-
'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyAlignment="1">'
|
|
242
|
-
'<alignment vertical="center" wrapText="1"/></xf>'
|
|
243
|
-
'</cellXfs>'
|
|
244
|
-
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
|
|
245
|
-
'</styleSheet>'
|
|
246
|
-
)
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
def write_xlsx(records, out_path, sheet_name="Localization"):
|
|
250
|
-
safe = "".join(c for c in sheet_name if c not in r'[]:*?/\\')[:31] or "Sheet1"
|
|
251
|
-
parts = {
|
|
252
|
-
"[Content_Types].xml":
|
|
253
|
-
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
254
|
-
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
|
255
|
-
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
|
256
|
-
'<Default Extension="xml" ContentType="application/xml"/>'
|
|
257
|
-
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
|
|
258
|
-
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
|
|
259
|
-
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>'
|
|
260
|
-
'</Types>',
|
|
261
|
-
"_rels/.rels":
|
|
262
|
-
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
263
|
-
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
|
264
|
-
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
|
|
265
|
-
'</Relationships>',
|
|
266
|
-
"xl/workbook.xml":
|
|
267
|
-
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
268
|
-
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
|
|
269
|
-
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
|
270
|
-
f'<sheets><sheet name="{_xesc(safe)}" sheetId="1" r:id="rId1"/></sheets></workbook>',
|
|
271
|
-
"xl/_rels/workbook.xml.rels":
|
|
272
|
-
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
273
|
-
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
|
274
|
-
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
|
|
275
|
-
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'
|
|
276
|
-
'</Relationships>',
|
|
277
|
-
"xl/styles.xml": _STYLES_XML,
|
|
278
|
-
"xl/worksheets/sheet1.xml": _sheet_xml(records),
|
|
204
|
+
def content_types_xml():
|
|
205
|
+
kinds = {
|
|
206
|
+
"/xl/workbook.xml": "spreadsheetml.sheet.main",
|
|
207
|
+
"/xl/worksheets/sheet1.xml": "spreadsheetml.worksheet",
|
|
208
|
+
"/xl/styles.xml": "spreadsheetml.styles",
|
|
279
209
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
210
|
+
overrides = [
|
|
211
|
+
el("Override", [("PartName", name), ("ContentType", f"{MIME_OOXML}officedocument.{kind}+xml")])
|
|
212
|
+
for name, kind in kinds.items()
|
|
213
|
+
]
|
|
214
|
+
return part(
|
|
215
|
+
"Types", [("xmlns", NS_TYPES)],
|
|
216
|
+
el("Default", [("Extension", "rels"), ("ContentType", MIME_OOXML + "package.relationships+xml")]),
|
|
217
|
+
el("Default", [("Extension", "xml"), ("ContentType", "application/xml")]),
|
|
218
|
+
*overrides,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def relationships_xml(*links):
|
|
223
|
+
items = [
|
|
224
|
+
el("Relationship", [("Id", f"rId{n}"), ("Type", f"{NS_DOC_REL}/{kind}"), ("Target", target)])
|
|
225
|
+
for n, (kind, target) in enumerate(links, 1)
|
|
226
|
+
]
|
|
227
|
+
return part("Relationships", [("xmlns", NS_PKG_REL)], *items)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def workbook_xml():
|
|
231
|
+
last = column_letter(len(COLUMNS) - 1)
|
|
232
|
+
filter_name = el(
|
|
233
|
+
"definedName", [("name", "_xlnm._FilterDatabase"), ("localSheetId", 0), ("hidden", 1)],
|
|
234
|
+
f"{SHEET_NAME}!$A$1:${last}$1",
|
|
235
|
+
)
|
|
236
|
+
return part(
|
|
237
|
+
"workbook", [("xmlns", NS_SHEET), ("xmlns:r", NS_DOC_REL)],
|
|
238
|
+
el("sheets", (), el("sheet", [("name", SHEET_NAME), ("sheetId", 1), ("r:id", "rId1")])),
|
|
239
|
+
el("definedNames", (), filter_name),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def write_xlsx(path, rows):
|
|
244
|
+
members = [
|
|
245
|
+
("[Content_Types].xml", content_types_xml()),
|
|
246
|
+
("_rels/.rels", relationships_xml(("officeDocument", "xl/workbook.xml"))),
|
|
247
|
+
("xl/workbook.xml", workbook_xml()),
|
|
248
|
+
("xl/_rels/workbook.xml.rels", relationships_xml(("worksheet", "worksheets/sheet1.xml"), ("styles", "styles.xml"))),
|
|
249
|
+
("xl/styles.xml", styles_xml()),
|
|
250
|
+
("xl/worksheets/sheet1.xml", sheet_xml(rows)),
|
|
251
|
+
]
|
|
252
|
+
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive:
|
|
253
|
+
for member, xml in members:
|
|
254
|
+
archive.writestr(member, xml)
|
|
283
255
|
|
|
284
256
|
|
|
285
|
-
def write_csv(
|
|
286
|
-
with open(
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
257
|
+
def write_csv(path, rows):
|
|
258
|
+
with open(path, "w", newline="", encoding="utf-8-sig") as sink:
|
|
259
|
+
out = csv.writer(sink)
|
|
260
|
+
out.writerow(COLUMNS)
|
|
261
|
+
out.writerows(rows)
|
|
290
262
|
|
|
291
263
|
|
|
292
264
|
def main():
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
print(json.dumps({
|
|
326
|
-
"slug": slug,
|
|
327
|
-
"rows": len(records),
|
|
328
|
-
"columns": COLUMNS,
|
|
329
|
-
"channel": args.channel,
|
|
330
|
-
"written": [os.path.basename(p) for p in written],
|
|
331
|
-
}, ensure_ascii=False, indent=2))
|
|
265
|
+
cli = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
266
|
+
cli.add_argument("mapping", help="reuse-map JSON file; pass - to read it from stdin")
|
|
267
|
+
cli.add_argument("--out", dest="target_dir", default=".", help="folder that receives the generated files")
|
|
268
|
+
cli.add_argument("--slug", help="stem for the file names; derived from the screen name when omitted")
|
|
269
|
+
cli.add_argument("--channel", default="Mobile", help="text placed in every Channel cell")
|
|
270
|
+
cli.add_argument("--taxonomy", help="JSON object laid over the built-in Property Group / Module rules")
|
|
271
|
+
cli.add_argument("--csv", action="store_true", help="emit a CSV next to the workbook")
|
|
272
|
+
cli.add_argument("--csv-only", action="store_true", help="emit only the CSV, no workbook")
|
|
273
|
+
opts = cli.parse_args()
|
|
274
|
+
|
|
275
|
+
mapping = json.load(sys.stdin) if opts.mapping == "-" else read_json(opts.mapping)
|
|
276
|
+
if not isinstance(mapping, dict) or "rows" not in mapping:
|
|
277
|
+
cli.error("mapping must be a JSON object with rows")
|
|
278
|
+
|
|
279
|
+
rows = table_rows(mapping, opts.channel, load_taxonomy(opts.taxonomy))
|
|
280
|
+
stem = opts.slug or slug(mapping.get("screen"))
|
|
281
|
+
os.makedirs(opts.target_dir, exist_ok=True)
|
|
282
|
+
|
|
283
|
+
def target(ext):
|
|
284
|
+
return os.path.join(opts.target_dir, f"{stem}.localization.{ext}")
|
|
285
|
+
|
|
286
|
+
produced = []
|
|
287
|
+
if not opts.csv_only:
|
|
288
|
+
produced.append(target("xlsx"))
|
|
289
|
+
write_xlsx(produced[-1], rows)
|
|
290
|
+
if opts.csv_only or opts.csv:
|
|
291
|
+
produced.append(target("csv"))
|
|
292
|
+
write_csv(produced[-1], rows)
|
|
293
|
+
|
|
294
|
+
summary = {"slug": stem, "rows": len(rows), "columns": COLUMNS, "channel": opts.channel, "written": produced}
|
|
295
|
+
json.dump(summary, sys.stdout, indent=2, ensure_ascii=False)
|
|
296
|
+
sys.stdout.write("\n")
|
|
332
297
|
|
|
333
298
|
|
|
334
299
|
if __name__ == "__main__":
|