#!/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()