i18n: complete app-wide localization (10 languages) + audit tooling
Android UI Tests / ui-tests (push) Has been cancelled
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>
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user