taler-android

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

check-translations.py (9069B)


      1 #!/usr/bin/env python3
      2 """
      3 check-translations.py
      4 
      5 Finds all Android string resources by first locating the git root (.git folder),
      6 allowing to run it reliably from sub folders.
      7 
      8 Usage:
      9     python3 check-translations.py           # Overview of all languages
     10     python3 check-translations.py de        # Missing DE strings per module
     11     python3 check-translations.py de --lint # + format-arg / array lint checks
     12 """
     13 
     14 from __future__ import annotations
     15 
     16 import argparse
     17 import re
     18 import sys
     19 import xml.etree.ElementTree as ET
     20 from collections import defaultdict
     21 from pathlib import Path
     22 
     23 
     24 PLACEHOLDER_RE = re.compile(r"%(?:(\d+)\$)?([%sdf])")
     25 
     26 
     27 def find_git_root(start: Path) -> Path | None:
     28     """Walk upwards until we find a .git directory."""
     29     current = start.resolve()
     30     while current != current.parent:
     31         if (current / ".git").exists():
     32             return current
     33         current = current.parent
     34     return None
     35 
     36 
     37 def get_string_names(xml_path: Path) -> set[str]:
     38     if not xml_path.exists():
     39         return set()
     40     try:
     41         tree = ET.parse(xml_path)
     42         return {
     43             elem.get("name")
     44             for elem in tree.findall(".//string")
     45             if elem.get("name") and elem.get("translatable") != "false"
     46         }
     47     except ET.ParseError:
     48         return set()
     49 
     50 
     51 def get_string_map(xml_path: Path) -> dict[str, str]:
     52     """name -> text content (translatable strings only)."""
     53     if not xml_path.exists():
     54         return {}
     55     try:
     56         root = ET.parse(xml_path).getroot()
     57     except ET.ParseError:
     58         return {}
     59     out: dict[str, str] = {}
     60     for elem in root.findall(".//string"):
     61         name = elem.get("name")
     62         if not name or elem.get("translatable") == "false":
     63             continue
     64         out[name] = "".join(elem.itertext())
     65     return out
     66 
     67 
     68 def get_string_array_items(xml_path: Path) -> dict[str, list[str]]:
     69     if not xml_path.exists():
     70         return {}
     71     try:
     72         root = ET.parse(xml_path).getroot()
     73     except ET.ParseError:
     74         return {}
     75     out: dict[str, list[str]] = {}
     76     for arr in root.findall(".//string-array"):
     77         name = arr.get("name")
     78         if not name:
     79             continue
     80         out[name] = ["".join(item.itertext()) for item in arr.findall("item")]
     81     return out
     82 
     83 
     84 def normalize_placeholders(text: str) -> list[tuple[int, str]]:
     85     """Return sorted (index, type) list for Android format placeholders."""
     86     result: list[tuple[int, str]] = []
     87     auto = 1
     88     for idx, typ in PLACEHOLDER_RE.findall(text):
     89         if typ == "%":
     90             continue
     91         if idx:
     92             result.append((int(idx), typ))
     93         else:
     94             result.append((auto, typ))
     95             auto += 1
     96     return sorted(result)
     97 
     98 
     99 def find_all_res_directories(git_root: Path) -> list[Path]:
    100     """Find all res/ folders inside the git repository."""
    101     res_dirs = []
    102     for values_dir in git_root.rglob("values"):
    103         if (values_dir / "strings.xml").exists():
    104             # skip build outputs
    105             if "build" in values_dir.parts:
    106                 continue
    107             res_dirs.append(values_dir.parent)
    108     return sorted(set(res_dirs))
    109 
    110 
    111 def module_name(git_root: Path, res_dir: Path) -> str:
    112     try:
    113         return res_dir.relative_to(git_root).parts[0]
    114     except Exception:
    115         return str(res_dir)
    116 
    117 
    118 def lint_module(
    119     git_root: Path,
    120     res_dir: Path,
    121     lang: str,
    122 ) -> list[str]:
    123     """Lint-like checks for one language vs English reference."""
    124     ref_file = res_dir / "values" / "strings.xml"
    125     loc_file = res_dir / f"values-{lang}" / "strings.xml"
    126     if not ref_file.exists() or not loc_file.exists():
    127         return []
    128 
    129     issues: list[str] = []
    130     mod = module_name(git_root, res_dir)
    131     ref_map = get_string_map(ref_file)
    132     loc_map = get_string_map(loc_file)
    133     ref_names = set(ref_map)
    134     loc_names = set(loc_map)
    135 
    136     for name in sorted(ref_names - loc_names):
    137         issues.append(f"{mod}: MissingTranslation: {name}")
    138 
    139     for name in sorted(loc_names - ref_names):
    140         issues.append(f"{mod}: ExtraTranslation: {name}")
    141 
    142     for name in sorted(ref_names & loc_names):
    143         en_ph = normalize_placeholders(ref_map[name])
    144         tr_ph = normalize_placeholders(loc_map[name])
    145         if en_ph != tr_ph:
    146             issues.append(
    147                 f"{mod}: StringFormatCount: {name} "
    148                 f"(en={en_ph} {lang}={tr_ph})"
    149             )
    150 
    151     ref_arrays = get_string_array_items(ref_file)
    152     loc_arrays = get_string_array_items(loc_file)
    153     for aname, ref_items in sorted(ref_arrays.items()):
    154         if aname not in loc_arrays:
    155             # not all locales redefine arrays; only check when present
    156             continue
    157         loc_items = loc_arrays[aname]
    158         if len(ref_items) != len(loc_items):
    159             issues.append(
    160                 f"{mod}: StringArrayLength: {aname} "
    161                 f"(en={len(ref_items)} {lang}={len(loc_items)})"
    162             )
    163         # values/labels pairing when both exist in locale
    164     if (
    165         "settings_language_values" in loc_arrays
    166         and "settings_language_labels" in loc_arrays
    167     ):
    168         nv = len(loc_arrays["settings_language_values"])
    169         nl = len(loc_arrays["settings_language_labels"])
    170         if nv != nl:
    171             issues.append(
    172                 f"{mod}: StringArrayPair: settings_language_values/"
    173                 f"labels length {nv}!={nl}"
    174             )
    175 
    176     return issues
    177 
    178 
    179 def main() -> int:
    180     parser = argparse.ArgumentParser(
    181         description="Check Android translation coverage and optional lint."
    182     )
    183     parser.add_argument(
    184         "lang",
    185         nargs="?",
    186         help="Language code (e.g. de or fr)",
    187     )
    188     parser.add_argument(
    189         "--lint",
    190         action="store_true",
    191         help=(
    192             "Extra checks: format placeholders, extra keys, "
    193             "string-array lengths (for LANG, or all langs if LANG omitted)"
    194         ),
    195     )
    196     args = parser.parse_args()
    197 
    198     git_root = find_git_root(Path.cwd())
    199     if not git_root:
    200         print(
    201             "Could not find a .git directory. "
    202             "Please run from inside a git repository."
    203         )
    204         return 1
    205 
    206     print(f"Git root: {git_root}\n")
    207 
    208     res_dirs = find_all_res_directories(git_root)
    209     if not res_dirs:
    210         print("No Android string resources found in this repository.")
    211         return 1
    212 
    213     print(f"Found {len(res_dirs)} resource folder(s)\n")
    214 
    215     lang_data: dict[str, list[dict]] = defaultdict(list)
    216 
    217     for res_dir in res_dirs:
    218         ref_file = res_dir / "values" / "strings.xml"
    219         if not ref_file.exists():
    220             continue
    221 
    222         ref_names = get_string_names(ref_file)
    223         if not ref_names:
    224             continue
    225 
    226         for values_dir in res_dir.glob("values-*"):
    227             lang_code = values_dir.name.removeprefix("values-")
    228             strings_file = values_dir / "strings.xml"
    229             if not strings_file.exists():
    230                 continue
    231 
    232             present = get_string_names(strings_file)
    233             missing = ref_names - present
    234 
    235             lang_data[lang_code].append(
    236                 {
    237                     "res_dir": res_dir,
    238                     "missing_count": len(missing),
    239                     "missing_names": sorted(missing),
    240                 }
    241             )
    242 
    243     print("Language   Folders   Strings Missing")
    244     print("-" * 35)
    245     for lang in sorted(lang_data.keys()):
    246         entries = lang_data[lang]
    247         total_missing = sum(e["missing_count"] for e in entries)
    248         print(f"{lang:<10} {len(entries):>7}   {total_missing:>6}")
    249 
    250     print()
    251 
    252     exit_code = 0
    253 
    254     if args.lang:
    255         lang = args.lang
    256         if lang not in lang_data:
    257             print(f"No '{lang}' translations found.")
    258             return 1
    259 
    260         print(f"=== Missing strings in '{lang}' ===\n")
    261 
    262         for entry in lang_data[lang]:
    263             res_dir = entry["res_dir"]
    264             missing_names = entry["missing_names"]
    265             module = module_name(git_root, res_dir)
    266 
    267             print(f"→ {module}  ({entry['missing_count']} missing)")
    268 
    269             if missing_names:
    270                 exit_code = 1
    271                 for name in missing_names:
    272                     print(f"   {name}")
    273             else:
    274                 print("   (complete)")
    275             print()
    276 
    277     if args.lint:
    278         langs = [args.lang] if args.lang else sorted(lang_data.keys())
    279         print(f"=== Lint checks ({', '.join(langs)}) ===\n")
    280         any_issue = False
    281         for lang in langs:
    282             lint_issues: list[str] = []
    283             for res_dir in res_dirs:
    284                 lint_issues.extend(lint_module(git_root, res_dir, lang))
    285             if not lint_issues:
    286                 print(f"→ {lang}: clean")
    287             else:
    288                 any_issue = True
    289                 exit_code = 1
    290                 print(f"→ {lang}: {len(lint_issues)} issue(s)")
    291                 for line in lint_issues:
    292                     print(f"   {line}")
    293             print()
    294         if not any_issue:
    295             print("All lint checks passed.")
    296 
    297     return exit_code
    298 
    299 
    300 if __name__ == "__main__":
    301     sys.exit(main())