i18n: complete app-wide localization (10 languages) + audit tooling
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:
Trey T
2026-06-04 20:52:28 -05:00
parent 6058013951
commit db65db6232
211 changed files with 81756 additions and 22467 deletions
+191
View File
@@ -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()