fix: French agenda-range plural crash (#297, #298) (#301)

This commit is contained in:
Jean-Luc Makiola
2026-09-12 16:27:30 +02:00
parent 20c20b71ea
commit 08f7228a69
5 changed files with 67 additions and 16 deletions
+46 -7
View File
@@ -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 ``<plurals>`` 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
# <plurals> 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 ``<plurals>`` 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 ``<plurals>`` 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"<item quantity=\"{PLURAL_FALLBACK}\"> — 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: