taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

commit 4d8315d12e1871a900c03905ac6177f24cc99a98
parent 47f685caba454b71bf6bd6902e85bc615244498c
Author: Hernâni Marques <hernani@vecirex.net>
Date:   Thu, 16 Jul 2026 11:08:12 +0200

feat/check-translations (11424): add format/array lint checks

Diffstat:
Mcheck-translations.py | 220++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
1 file changed, 191 insertions(+), 29 deletions(-)

diff --git a/check-translations.py b/check-translations.py @@ -2,18 +2,26 @@ """ check-translations.py -Finds all Android string resources by first locating the git root (.git folder), allowing to -run it reliably from sub folders. +Finds all Android string resources by first locating the git root (.git folder), +allowing to run it reliably from sub folders. Usage: - python3 check-translations.py # Overview of all languages, across all repo modules - python3 check-translations.py de # Show concrete missing German strings per-module + python3 check-translations.py # Overview of all languages + python3 check-translations.py de # Missing DE strings per module + python3 check-translations.py de --lint # + format-arg / array lint checks """ +from __future__ import annotations + import argparse +import re +import sys import xml.etree.ElementTree as ET -from pathlib import Path from collections import defaultdict +from pathlib import Path + + +PLACEHOLDER_RE = re.compile(r"%(?:(\d+)\$)?([%sdf])") def find_git_root(start: Path) -> Path | None: @@ -34,44 +42,177 @@ def get_string_names(xml_path: Path) -> set[str]: return { elem.get("name") for elem in tree.findall(".//string") - # Only propose string names which are thought to be translated. if elem.get("name") and elem.get("translatable") != "false" } except ET.ParseError: return set() +def get_string_map(xml_path: Path) -> dict[str, str]: + """name -> text content (translatable strings only).""" + if not xml_path.exists(): + return {} + try: + root = ET.parse(xml_path).getroot() + except ET.ParseError: + return {} + out: dict[str, str] = {} + for elem in root.findall(".//string"): + name = elem.get("name") + if not name or elem.get("translatable") == "false": + continue + out[name] = "".join(elem.itertext()) + return out + + +def get_string_array_items(xml_path: Path) -> dict[str, list[str]]: + if not xml_path.exists(): + return {} + try: + root = ET.parse(xml_path).getroot() + except ET.ParseError: + return {} + out: dict[str, list[str]] = {} + for arr in root.findall(".//string-array"): + name = arr.get("name") + if not name: + continue + out[name] = ["".join(item.itertext()) for item in arr.findall("item")] + return out + + +def normalize_placeholders(text: str) -> list[tuple[int, str]]: + """Return sorted (index, type) list for Android format placeholders.""" + result: list[tuple[int, str]] = [] + auto = 1 + for idx, typ in PLACEHOLDER_RE.findall(text): + if typ == "%": + continue + if idx: + result.append((int(idx), typ)) + else: + result.append((auto, typ)) + auto += 1 + return sorted(result) + + def find_all_res_directories(git_root: Path) -> list[Path]: """Find all res/ folders inside the git repository.""" res_dirs = [] for values_dir in git_root.rglob("values"): if (values_dir / "strings.xml").exists(): + # skip build outputs + if "build" in values_dir.parts: + continue res_dirs.append(values_dir.parent) return sorted(set(res_dirs)) -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("lang", nargs="?", help="Language code (e.g., de or it)") +def module_name(git_root: Path, res_dir: Path) -> str: + try: + return res_dir.relative_to(git_root).parts[0] + except Exception: + return str(res_dir) + + +def lint_module( + git_root: Path, + res_dir: Path, + lang: str, +) -> list[str]: + """Lint-like checks for one language vs English reference.""" + ref_file = res_dir / "values" / "strings.xml" + loc_file = res_dir / f"values-{lang}" / "strings.xml" + if not ref_file.exists() or not loc_file.exists(): + return [] + + issues: list[str] = [] + mod = module_name(git_root, res_dir) + ref_map = get_string_map(ref_file) + loc_map = get_string_map(loc_file) + ref_names = set(ref_map) + loc_names = set(loc_map) + + for name in sorted(ref_names - loc_names): + issues.append(f"{mod}: MissingTranslation: {name}") + + for name in sorted(loc_names - ref_names): + issues.append(f"{mod}: ExtraTranslation: {name}") + + for name in sorted(ref_names & loc_names): + en_ph = normalize_placeholders(ref_map[name]) + tr_ph = normalize_placeholders(loc_map[name]) + if en_ph != tr_ph: + issues.append( + f"{mod}: StringFormatCount: {name} " + f"(en={en_ph} {lang}={tr_ph})" + ) + + ref_arrays = get_string_array_items(ref_file) + loc_arrays = get_string_array_items(loc_file) + for aname, ref_items in sorted(ref_arrays.items()): + if aname not in loc_arrays: + # not all locales redefine arrays; only check when present + continue + loc_items = loc_arrays[aname] + if len(ref_items) != len(loc_items): + issues.append( + f"{mod}: StringArrayLength: {aname} " + f"(en={len(ref_items)} {lang}={len(loc_items)})" + ) + # values/labels pairing when both exist in locale + if ( + "settings_language_values" in loc_arrays + and "settings_language_labels" in loc_arrays + ): + nv = len(loc_arrays["settings_language_values"]) + nl = len(loc_arrays["settings_language_labels"]) + if nv != nl: + issues.append( + f"{mod}: StringArrayPair: settings_language_values/" + f"labels length {nv}!={nl}" + ) + + return issues + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Check Android translation coverage and optional lint." + ) + parser.add_argument( + "lang", + nargs="?", + help="Language code (e.g. de or fr)", + ) + parser.add_argument( + "--lint", + action="store_true", + help=( + "Extra checks: format placeholders, extra keys, " + "string-array lengths (for LANG, or all langs if LANG omitted)" + ), + ) args = parser.parse_args() git_root = find_git_root(Path.cwd()) - if not git_root: - print("Could not find a .git directory. Please run from inside a git repository.") - return + print( + "Could not find a .git directory. " + "Please run from inside a git repository." + ) + return 1 print(f"Git root: {git_root}\n") res_dirs = find_all_res_directories(git_root) - if not res_dirs: print("No Android string resources found in this repository.") - return + return 1 print(f"Found {len(res_dirs)} resource folder(s)\n") - lang_data = defaultdict(list) + lang_data: dict[str, list[dict]] = defaultdict(list) for res_dir in res_dirs: ref_file = res_dir / "values" / "strings.xml" @@ -91,13 +232,14 @@ def main(): present = get_string_names(strings_file) missing = ref_names - present - lang_data[lang_code].append({ - "res_dir": res_dir, - "missing_count": len(missing), - "missing_names": sorted(missing) - }) + lang_data[lang_code].append( + { + "res_dir": res_dir, + "missing_count": len(missing), + "missing_names": sorted(missing), + } + ) - # Overview print("Language Folders Strings Missing") print("-" * 35) for lang in sorted(lang_data.keys()): @@ -107,33 +249,53 @@ def main(): print() + exit_code = 0 + if args.lang: lang = args.lang if lang not in lang_data: print(f"No '{lang}' translations found.") - return + return 1 print(f"=== Missing strings in '{lang}' ===\n") for entry in lang_data[lang]: res_dir = entry["res_dir"] missing_names = entry["missing_names"] - - # Show actual module names (merchant-terminal, cashier, wallet, ...) - try: - module = res_dir.relative_to(git_root).parts[0] - except Exception: - module = str(res_dir) + module = module_name(git_root, res_dir) print(f"→ {module} ({entry['missing_count']} missing)") if missing_names: + exit_code = 1 for name in missing_names: print(f" {name}") else: print(" (complete)") print() + if args.lint: + langs = [args.lang] if args.lang else sorted(lang_data.keys()) + print(f"=== Lint checks ({', '.join(langs)}) ===\n") + any_issue = False + for lang in langs: + lint_issues: list[str] = [] + for res_dir in res_dirs: + lint_issues.extend(lint_module(git_root, res_dir, lang)) + if not lint_issues: + print(f"→ {lang}: clean") + else: + any_issue = True + exit_code = 1 + print(f"→ {lang}: {len(lint_issues)} issue(s)") + for line in lint_issues: + print(f" {line}") + print() + if not any_issue: + print("All lint checks passed.") + + return exit_code + if __name__ == "__main__": - main() + sys.exit(main())