diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d90ef81..e81908e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -113,12 +113,16 @@ android { lint { // Community translations are expected to be partial — a missing string // falls back to the English base at runtime — so don't fail the build on - // it. Likewise a translated may not fill every CLDR quantity - // form its locale defines (e.g. Arabic needs "zero"); the missing form - // falls back to "other" at runtime, so MissingQuantity is informational - // too. Stale/extra keys (ExtraTranslation) stay fatal; scripts/ - // check_translations.py guards the same invariants with clearer, - // translator-facing messages. + // it. A translated may likewise skip a CLDR quantity form its + // locale defines (e.g. Arabic "zero"): Android falls back to "other" for + // any form it cannot find, so MissingQuantity is informational too. + // What a translation must NOT skip is "other" itself — nothing falls back + // for that one, not even the base locale, so it throws + // Resources$NotFoundException at runtime (Codeberg #297/#298). + // MissingQuantity doesn't tell the two cases apart, so that invariant is + // enforced by scripts/check_translations.py instead, with a clearer + // translator-facing message. Stale/extra keys (ExtraTranslation) stay + // fatal. informational += listOf("MissingTranslation", "MissingQuantity") } diff --git a/scripts/check_translations.py b/scripts/check_translations.py index fc3699c..c9bcbac 100755 --- a/scripts/check_translations.py +++ b/scripts/check_translations.py @@ -8,7 +8,10 @@ and are produced via Weblate. This guard keeps incoming translation PRs honest: * a translation must not define keys absent from the base — those are stale keys left behind after a rename/removal upstream; * a translation must not translate strings marked ``translatable="false"`` in - the base (URLs, IDs and the like). + the base (URLs, IDs and the like); + * every translated ```` must carry an ``other`` item, because that + is the one quantity form Android cannot fall back for (see + ``plurals_missing_other``). Missing keys are *allowed* and only reported as coverage: a missing string falls back to the English base at runtime, so partial translations are fine @@ -27,10 +30,13 @@ RES_DIR = Path("app/src/main/res") BASE = RES_DIR / "values" / "strings.xml" RESOURCE_TAGS = ("string", "plurals", "string-array") +# The quantity Android itself falls back to, and therefore the one a translated +# may never omit. +PLURAL_FALLBACK = "other" -def entries(path: Path) -> dict[str, bool]: - """Map resource name -> is-translatable for every entry in ``path``.""" - root = ET.parse(path).getroot() + +def entries(root: ET.Element) -> dict[str, bool]: + """Map resource name -> is-translatable for every entry under ``root``.""" return { el.attrib["name"]: el.attrib.get("translatable", "true") != "false" for el in root @@ -38,12 +44,35 @@ def entries(path: Path) -> dict[str, bool]: } +def plurals_missing_other(root: ET.Element) -> list[str]: + """Names of ```` under ``root`` that lack an ``other`` item. + + Android resolves the CLDR quantity for a count and, if that specific form is + absent, falls back to ``other`` — so a translation may legitimately skip + forms its locale rarely uses (Arabic ``zero``, French ``many``). There is no + fallback for ``other`` itself, and none to the base locale either: a + translated ```` without it raises + ``Resources$NotFoundException`` as soon as a count selects the missing form. + + That is Codeberg #297/#298 — ``values-fr`` translated ``agenda_range_days`` + with only ``one`` and ``many``, and since French ``many`` matches only + millions, every custom agenda range from 2 days up crashed the app. + """ + return sorted( + el.attrib["name"] + for el in root + if el.tag == "plurals" + and "name" in el.attrib + and not any(item.attrib.get("quantity") == PLURAL_FALLBACK for item in el.findall("item")) + ) + + def main() -> int: if not BASE.exists(): print(f"::error::base resource file {BASE} not found", file=sys.stderr) return 1 - base = entries(BASE) + base = entries(ET.parse(BASE).getroot()) base_keys = set(base) nontranslatable = {name for name, ok in base.items() if not ok} translatable_total = len(base_keys - nontranslatable) @@ -57,16 +86,18 @@ def main() -> int: for path in files: locale = path.parent.name[len("values-"):] try: - translated = entries(path) + root = ET.parse(path).getroot() except ET.ParseError as exc: print(f"::error file={path}::{locale}: malformed XML: {exc}") errors += 1 continue + translated = entries(root) keys = set(translated) stale = sorted(keys - base_keys) translated_fixed = sorted(keys & nontranslatable) missing = base_keys - nontranslatable - keys + no_other = plurals_missing_other(root) for name in stale: print(f"::error file={path}::{locale}: stale key '{name}' is not in the base strings.xml") @@ -77,10 +108,18 @@ def main() -> int: "in the base and must not be translated" ) errors += 1 + for name in no_other: + print( + f"::error file={path}::{locale}: plurals '{name}' has no " + f" — Android cannot fall back for it " + "and will crash when a count selects it; translate that form or drop the " + "whole plurals to inherit the base" + ) + errors += 1 covered = translatable_total - len(missing) pct = covered * 100 // translatable_total if translatable_total else 100 - verdict = "OK" if not (stale or translated_fixed) else "FAIL" + verdict = "OK" if not (stale or translated_fixed or no_other) else "FAIL" print(f"{locale:<10} {covered}/{translatable_total} keys ({pct}%) — {verdict}") if errors: