fix(i18n): fail CI when a translated plurals has no "other" form
Codeberg #297 and #298 are the same crash, reported twice from fr-FR: Resources$NotFoundException: Plural resource ID #0x7f0f0000 quantity=14 item=other values-fr translated agenda_range_days with only "one" and "many". French "many" matches millions only, so every count from 2 upwards resolves to "other" — which the translation doesn't define. Android falls back to "other" for any quantity form it can't find, but nothing falls back for "other" itself and there is no fall back to the base locale either, so getQuantityString throws. Any French user with a custom agenda range of 2+ days crashed the moment that label composed: the settings summary, the range picker, and the live preview while typing a day count. Nothing caught it. The lint config downgrades MissingQuantity on the grounds that a missing form "falls back to other at runtime" — true of every form except "other", which is the one that was missing here. And check_translations.py treated <plurals> as an opaque key via RESOURCE_TAGS, never looking inside at quantities. So enforce the one invariant Android actually requires: a translated <plurals> must carry an "other" item. Locales may still skip forms their language rarely uses (Arabic "zero", the missing Italian "many" in search_delete_title) because those do fall back. Lint's MissingQuantity can't tell the two cases apart, which is why this lives in the script rather than in a lint severity, and the stale lint comment is corrected to say so. The broken French string itself is owned by Weblate and is fixed there.
This commit is contained in:
+10
-6
@@ -113,12 +113,16 @@ android {
|
|||||||
lint {
|
lint {
|
||||||
// Community translations are expected to be partial — a missing string
|
// 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
|
// falls back to the English base at runtime — so don't fail the build on
|
||||||
// it. Likewise a translated <plurals> may not fill every CLDR quantity
|
// it. A translated <plurals> may likewise skip a CLDR quantity form its
|
||||||
// form its locale defines (e.g. Arabic needs "zero"); the missing form
|
// locale defines (e.g. Arabic "zero"): Android falls back to "other" for
|
||||||
// falls back to "other" at runtime, so MissingQuantity is informational
|
// any form it cannot find, so MissingQuantity is informational too.
|
||||||
// too. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
|
// What a translation must NOT skip is "other" itself — nothing falls back
|
||||||
// check_translations.py guards the same invariants with clearer,
|
// for that one, not even the base locale, so it throws
|
||||||
// translator-facing messages.
|
// 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")
|
informational += listOf("MissingTranslation", "MissingQuantity")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
* a translation must not define keys absent from the base — those are stale
|
||||||
keys left behind after a rename/removal upstream;
|
keys left behind after a rename/removal upstream;
|
||||||
* a translation must not translate strings marked ``translatable="false"`` in
|
* 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
|
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
|
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"
|
BASE = RES_DIR / "values" / "strings.xml"
|
||||||
RESOURCE_TAGS = ("string", "plurals", "string-array")
|
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``."""
|
def entries(root: ET.Element) -> dict[str, bool]:
|
||||||
root = ET.parse(path).getroot()
|
"""Map resource name -> is-translatable for every entry under ``root``."""
|
||||||
return {
|
return {
|
||||||
el.attrib["name"]: el.attrib.get("translatable", "true") != "false"
|
el.attrib["name"]: el.attrib.get("translatable", "true") != "false"
|
||||||
for el in root
|
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:
|
def main() -> int:
|
||||||
if not BASE.exists():
|
if not BASE.exists():
|
||||||
print(f"::error::base resource file {BASE} not found", file=sys.stderr)
|
print(f"::error::base resource file {BASE} not found", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
base = entries(BASE)
|
base = entries(ET.parse(BASE).getroot())
|
||||||
base_keys = set(base)
|
base_keys = set(base)
|
||||||
nontranslatable = {name for name, ok in base.items() if not ok}
|
nontranslatable = {name for name, ok in base.items() if not ok}
|
||||||
translatable_total = len(base_keys - nontranslatable)
|
translatable_total = len(base_keys - nontranslatable)
|
||||||
@@ -57,16 +86,18 @@ def main() -> int:
|
|||||||
for path in files:
|
for path in files:
|
||||||
locale = path.parent.name[len("values-"):]
|
locale = path.parent.name[len("values-"):]
|
||||||
try:
|
try:
|
||||||
translated = entries(path)
|
root = ET.parse(path).getroot()
|
||||||
except ET.ParseError as exc:
|
except ET.ParseError as exc:
|
||||||
print(f"::error file={path}::{locale}: malformed XML: {exc}")
|
print(f"::error file={path}::{locale}: malformed XML: {exc}")
|
||||||
errors += 1
|
errors += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
translated = entries(root)
|
||||||
keys = set(translated)
|
keys = set(translated)
|
||||||
stale = sorted(keys - base_keys)
|
stale = sorted(keys - base_keys)
|
||||||
translated_fixed = sorted(keys & nontranslatable)
|
translated_fixed = sorted(keys & nontranslatable)
|
||||||
missing = base_keys - nontranslatable - keys
|
missing = base_keys - nontranslatable - keys
|
||||||
|
no_other = plurals_missing_other(root)
|
||||||
|
|
||||||
for name in stale:
|
for name in stale:
|
||||||
print(f"::error file={path}::{locale}: stale key '{name}' is not in the base strings.xml")
|
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"
|
"in the base and must not be translated"
|
||||||
)
|
)
|
||||||
errors += 1
|
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)
|
covered = translatable_total - len(missing)
|
||||||
pct = covered * 100 // translatable_total if translatable_total else 100
|
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}")
|
print(f"{locale:<10} {covered}/{translatable_total} keys ({pct}%) — {verdict}")
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
|
|||||||
Reference in New Issue
Block a user