db65db6232
Android UI Tests / ui-tests (push) Has been cancelled
Localize all user-facing strings across iOS (SwiftUI), shared Kotlin, and Android Compose into en/es/fr/de/pt/it/ja/ko/nl/zh: - iOS String Catalogs: main + widget Localizable.xcstrings, InfoPlist.xcstrings (permissions), plural variations, ~200 new keys translated - Shared Kotlin ClientStrings table + Android composeResources/values-* (884 keys ×10), routed Api/ViewModel/util error & UI strings through localization - Backend-localized lookups/suggestions consumed via display names - Widget extension catalog; theme names, home-profile fallbacks, validation, network errors, accessibility labels all localized Add re-runnable verification gates: - scripts/i18n_audit.py — enumerate every literal, partition to GAP=0 - scripts/i18n_coverage.py — all 10 locales translated, format-specifier parity Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
192 lines
8.1 KiB
Python
192 lines
8.1 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
i18n_coverage.py — translation-coverage + format-specifier gate for honeyDue.
|
|
|
|
For each localization store, verify every source key is present and translated in
|
|
ALL target locales, and that format specifiers (%@ %lld %d %s %1$s {0} ...) match
|
|
the source count in each translation.
|
|
|
|
Stores:
|
|
iOS catalogs : iosApp/iosApp/Localizable.xcstrings, InfoPlist.xcstrings,
|
|
iosApp/HoneyDue/Localizable.xcstrings (locales: en es fr de pt-BR it ja ko nl zh-Hans)
|
|
Android : composeApp/.../composeResources/values*/strings.xml
|
|
Kotlin : composeApp/.../i18n/ClientStringsData.kt (STRINGS map)
|
|
|
|
Exit 0 if fully covered + specifier-clean, else 1. --json OUT to dump details.
|
|
"""
|
|
import os, re, sys, json, argparse, xml.etree.ElementTree as ET
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
IOS_LOCALES = ["en","es","fr","de","pt","it","ja","ko","nl","zh"]
|
|
AND_LOCALES = ["", "es","fr","de","pt","it","ja","ko","nl","zh"] # "" == values/ (en)
|
|
KT_LOCALES = ["en","es","fr","de","pt","it","ja","ko","nl","zh"]
|
|
|
|
SPEC_RE = re.compile(r"%(?:\d+\$)?[@a-zA-Z]|%%|\{\d+\}")
|
|
def specs(s):
|
|
# normalize positional %1$@ -> %@ ; count multiset
|
|
out = {}
|
|
for m in SPEC_RE.findall(s or ""):
|
|
key = re.sub(r"^%\d+\$", "%", m)
|
|
out[key] = out.get(key, 0) + 1
|
|
return out
|
|
|
|
def check_specs(src, tr):
|
|
a, b = specs(src), specs(tr)
|
|
# ignore %% and plain '%'; compare placeholder multisets
|
|
a = {k:v for k,v in a.items() if k not in ("%%",)}
|
|
b = {k:v for k,v in b.items() if k not in ("%%",)}
|
|
return a == b
|
|
|
|
# ---------------- iOS xcstrings ----------------
|
|
def unit_value(node):
|
|
"""Return concatenated translated text for a localization node (stringUnit or
|
|
plural variations), or None if not translated/empty."""
|
|
if not isinstance(node, dict):
|
|
return None
|
|
if "stringUnit" in node:
|
|
su = node["stringUnit"]
|
|
if su.get("state") in ("translated", "needs_review") and su.get("value", "") != "":
|
|
return su["value"]
|
|
return None
|
|
if "variations" in node:
|
|
var = node["variations"]
|
|
cat = var.get("plural") or var.get("device") or {}
|
|
vals = []
|
|
for k, v in cat.items():
|
|
uv = unit_value(v)
|
|
if uv is None:
|
|
return None
|
|
vals.append(uv)
|
|
return " ".join(vals) if vals else None
|
|
return None
|
|
|
|
def check_ios(path, locales, problems):
|
|
if not os.path.exists(path):
|
|
return
|
|
data = json.load(open(path))
|
|
src_lang = data.get("sourceLanguage", "en")
|
|
rel = os.path.relpath(path, ROOT)
|
|
for key, entry in data.get("strings", {}).items():
|
|
locs = entry.get("localizations", {})
|
|
# keys explicitly excluded from translation
|
|
if entry.get("shouldTranslate") is False:
|
|
continue
|
|
# non-prose symbol/format-only keys ("•", "%@", "%lld / 5", "+%lld", "%lld%%")
|
|
# never need translation — strip format specifiers first, then require real letters.
|
|
bare = re.sub(r"%(?:\d+\$)?[a-zA-Z@]+|%%", "", key)
|
|
if sum(c.isalpha() for c in bare) < 2:
|
|
continue
|
|
src = unit_value(locs.get(src_lang)) or key
|
|
for loc in locales:
|
|
if loc == src_lang:
|
|
continue
|
|
uv = unit_value(locs.get(loc))
|
|
if uv is None:
|
|
problems.append({"store": rel, "key": key[:60], "locale": loc, "issue": "missing"})
|
|
elif not check_specs(src, uv):
|
|
problems.append({"store": rel, "key": key[:60], "locale": loc, "issue": f"spec {specs(src)} vs {specs(uv)}"})
|
|
|
|
# ---------------- Android strings.xml ----------------
|
|
def load_xml(path):
|
|
d = {}
|
|
if not os.path.exists(path):
|
|
return None
|
|
try:
|
|
root = ET.parse(path).getroot()
|
|
except Exception as e:
|
|
return {"__error__": str(e)}
|
|
for el in root:
|
|
if el.tag == "string" and el.get("name"):
|
|
d[el.get("name")] = "".join(el.itertext())
|
|
elif el.tag == "plurals" and el.get("name"):
|
|
d[el.get("name")] = "".join(el.itertext())
|
|
return d
|
|
|
|
def check_android(problems):
|
|
base = os.path.join(ROOT, "composeApp/src/commonMain/composeResources")
|
|
en = load_xml(os.path.join(base, "values/strings.xml"))
|
|
if en is None:
|
|
problems.append({"store": "android", "key": "-", "locale": "values", "issue": "missing strings.xml"})
|
|
return
|
|
for loc in AND_LOCALES:
|
|
if loc == "":
|
|
continue
|
|
p = os.path.join(base, f"values-{loc}/strings.xml")
|
|
d = load_xml(p)
|
|
if d is None:
|
|
problems.append({"store": "android", "key": "-", "locale": loc, "issue": "locale file missing"})
|
|
continue
|
|
if "__error__" in d:
|
|
problems.append({"store": "android", "key": "-", "locale": loc, "issue": "xml parse: " + d["__error__"]})
|
|
continue
|
|
for k, sv in en.items():
|
|
if k not in d:
|
|
problems.append({"store": "android", "key": k, "locale": loc, "issue": "missing"})
|
|
elif not check_specs(sv, d[k]):
|
|
problems.append({"store": "android", "key": k, "locale": loc, "issue": f"spec {specs(sv)} vs {specs(d[k])}"})
|
|
|
|
# ---------------- Kotlin ClientStringsData ----------------
|
|
def check_kotlin(problems):
|
|
path = os.path.join(ROOT, "composeApp/src/commonMain/kotlin/com/tt/honeyDue/i18n/ClientStringsData.kt")
|
|
if not os.path.exists(path):
|
|
return
|
|
src = open(path, encoding="utf-8").read()
|
|
# entries look like: "key" to mapOf("en" to "..", "es" to "..", ...)
|
|
# find each top-level "key" to mapOf( ... ) block
|
|
for m in re.finditer(r'"((?:[^"\\]|\\.)+)"\s*to\s*mapOf\s*\(', src):
|
|
key = m.group(1)
|
|
# capture the mapOf(...) body by brace matching from the '(' after mapOf
|
|
i = m.end() - 1
|
|
depth = 0
|
|
j = i
|
|
while j < len(src):
|
|
if src[j] == "(":
|
|
depth += 1
|
|
elif src[j] == ")":
|
|
depth -= 1
|
|
if depth == 0:
|
|
break
|
|
j += 1
|
|
body = src[i:j]
|
|
langs = dict(re.findall(r'"([a-zA-Z\-]+)"\s*to\s*"((?:[^"\\]|\\.)*)"', body))
|
|
srcval = langs.get("en", key)
|
|
for loc in KT_LOCALES:
|
|
if loc == "en":
|
|
continue
|
|
# empty string is a deliberate choice for some keys (e.g. date.at has no
|
|
# connector word in CJK); treat key-present-but-empty as covered.
|
|
if loc not in langs:
|
|
problems.append({"store": "kotlin", "key": key[:60], "locale": loc, "issue": "missing"})
|
|
elif not check_specs(srcval, langs[loc]):
|
|
problems.append({"store": "kotlin", "key": key[:60], "locale": loc, "issue": f"spec {specs(srcval)} vs {specs(langs[loc])}"})
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--json")
|
|
args = ap.parse_args()
|
|
problems = []
|
|
check_ios(os.path.join(ROOT, "iosApp/iosApp/Localizable.xcstrings"), IOS_LOCALES, problems)
|
|
check_ios(os.path.join(ROOT, "iosApp/iosApp/InfoPlist.xcstrings"), IOS_LOCALES, problems)
|
|
check_ios(os.path.join(ROOT, "iosApp/HoneyDue/Localizable.xcstrings"), IOS_LOCALES, problems)
|
|
check_android(problems)
|
|
check_kotlin(problems)
|
|
import collections
|
|
by_store = collections.Counter(p["store"] for p in problems)
|
|
miss = collections.Counter(p["store"] for p in problems if p["issue"] == "missing")
|
|
spec = collections.Counter(p["store"] for p in problems if p["issue"].startswith("spec"))
|
|
print("\n=== i18n coverage ===")
|
|
stores = set([p["store"] for p in problems]) | {"iosApp/iosApp/Localizable.xcstrings","android","kotlin"}
|
|
for s in sorted(stores):
|
|
print(f" {s:42} problems={by_store.get(s,0):>4} (missing={miss.get(s,0)}, spec={spec.get(s,0)})")
|
|
print(f"TOTAL problems: {len(problems)}")
|
|
if args.json:
|
|
json.dump(problems, open(args.json, "w"), ensure_ascii=False, indent=2)
|
|
print("wrote", args.json)
|
|
# sample
|
|
for p in problems[:25]:
|
|
print(f" {p['store']} [{p['locale']}] {p['key']} :: {p['issue']}")
|
|
sys.exit(0 if not problems else 1)
|
|
|
|
if __name__ == "__main__":
|
|
main()
|