248 lines
8.4 KiB
Python
Executable File
248 lines
8.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
check-missing-translations.py — Audit locale files against en.json.
|
|
|
|
Treats static/locales/en.json as the canonical key set. For every other
|
|
JSON file in static/locales/, reports keys that are missing (present in
|
|
en.json, absent here) and optionally keys that are extra (present here,
|
|
absent in en.json — usually drift from a removed feature).
|
|
|
|
Exit code:
|
|
0 — every non-English locale has every English key
|
|
1 — at least one locale is missing one or more keys
|
|
|
|
Usage:
|
|
python3 tools/check-missing-translations.py [options]
|
|
|
|
Options:
|
|
--check-only CI-friendly: print per-locale counts only
|
|
(no per-key list). Exit code is unchanged —
|
|
the script always returns 1 on any miss,
|
|
this flag just keeps the CI log terse.
|
|
Mirrors `tools/check-icons.py --check-only`.
|
|
--no-extras Suppress the "extra keys" section.
|
|
--values Show the English source value next to each
|
|
missing key (truncated to 80 chars).
|
|
--locale CODE [CODE…] Audit only the listed locale(s) (e.g. fr de).
|
|
Default: every non-English file in the dir.
|
|
|
|
Examples:
|
|
# Verbose audit of every locale, including extras
|
|
python3 tools/check-missing-translations.py
|
|
|
|
# CI mode — terse output, exit 1 on any miss
|
|
python3 tools/check-missing-translations.py --check-only
|
|
|
|
# Just French and Spanish, with English values to help translators
|
|
python3 tools/check-missing-translations.py --locale fr es --values
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
LOCALES_DIR = REPO_ROOT / "static" / "locales"
|
|
SOURCE_LOCALE = "en"
|
|
|
|
# Truncation length for the English source value shown by --values.
|
|
VALUE_PREVIEW_LEN = 80
|
|
|
|
|
|
def flatten(obj: dict[str, Any], prefix: str = "") -> dict[str, Any]:
|
|
"""Walk a nested JSON object and produce a flat {"dotted.key": value}
|
|
dict. Non-dict leaves (strings, numbers, booleans, arrays) are kept
|
|
as-is; only nested dicts are expanded into the key path."""
|
|
out: dict[str, Any] = {}
|
|
for key, value in obj.items():
|
|
full = f"{prefix}.{key}" if prefix else key
|
|
if isinstance(value, dict):
|
|
out.update(flatten(value, full))
|
|
else:
|
|
out[full] = value
|
|
return out
|
|
|
|
|
|
def truncate(text: str, max_len: int) -> str:
|
|
"""Visual truncation for terminal output. Newlines normalised to
|
|
spaces so a multi-line email body still fits on one row."""
|
|
one_line = text.replace("\n", " ").replace("\r", " ")
|
|
if len(one_line) <= max_len:
|
|
return one_line
|
|
return one_line[: max_len - 1] + "…"
|
|
|
|
|
|
def load_locale(path: Path) -> dict[str, Any]:
|
|
"""Parse one locale file. Returns the flattened key set."""
|
|
try:
|
|
with path.open("r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
print(f"✗ {path.name}: could not parse ({exc})", file=sys.stderr)
|
|
sys.exit(1)
|
|
if not isinstance(data, dict):
|
|
print(f"✗ {path.name}: root must be an object", file=sys.stderr)
|
|
sys.exit(1)
|
|
return flatten(data)
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description=(
|
|
"Audit static/locales/*.json against en.json. Reports keys "
|
|
"missing from each non-English locale and (optionally) keys "
|
|
"that exist in non-English but not in English."
|
|
),
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
parser.add_argument(
|
|
"--check-only",
|
|
action="store_true",
|
|
help=(
|
|
"CI mode: print per-locale counts only, not the full key "
|
|
"list. Exit code is unchanged (1 on any miss). Mirrors "
|
|
"`tools/check-icons.py --check-only`."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--no-extras",
|
|
action="store_true",
|
|
help="Suppress the 'extra keys' section (default: report them).",
|
|
)
|
|
parser.add_argument(
|
|
"--values",
|
|
action="store_true",
|
|
help=(
|
|
"Print the English source value next to each missing key. "
|
|
"Helpful for translators; ignored under --check-only."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--locale",
|
|
nargs="+",
|
|
metavar="CODE",
|
|
help=(
|
|
"Audit only the given locale code(s) (e.g. 'fr', 'zh-TW'). "
|
|
"Default: every *.json in static/locales/ except en.json."
|
|
),
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
|
|
if not LOCALES_DIR.is_dir():
|
|
print(f"✗ Locales directory not found: {LOCALES_DIR}", file=sys.stderr)
|
|
return 1
|
|
|
|
source_path = LOCALES_DIR / f"{SOURCE_LOCALE}.json"
|
|
if not source_path.exists():
|
|
print(f"✗ Source locale not found: {source_path}", file=sys.stderr)
|
|
return 1
|
|
|
|
en = load_locale(source_path)
|
|
en_keys = set(en.keys())
|
|
|
|
# Decide which locales to audit. --locale narrows the set; otherwise
|
|
# we audit everything except en.json.
|
|
if args.locale:
|
|
locale_paths = []
|
|
for code in args.locale:
|
|
p = LOCALES_DIR / f"{code}.json"
|
|
if not p.exists():
|
|
print(f"✗ Locale file not found: {p}", file=sys.stderr)
|
|
return 1
|
|
if code == SOURCE_LOCALE:
|
|
print(
|
|
f"⚠ Skipping --locale {code}: that's the source locale.",
|
|
file=sys.stderr,
|
|
)
|
|
continue
|
|
locale_paths.append(p)
|
|
else:
|
|
locale_paths = sorted(
|
|
p
|
|
for p in LOCALES_DIR.glob("*.json")
|
|
if p.stem != SOURCE_LOCALE
|
|
)
|
|
|
|
if not locale_paths:
|
|
print("Nothing to check.")
|
|
return 0
|
|
|
|
print(f"Source: {source_path.relative_to(REPO_ROOT)} ({len(en_keys)} keys)\n")
|
|
|
|
total_missing = 0
|
|
total_extra = 0
|
|
locale_with_missing: list[str] = []
|
|
|
|
for path in locale_paths:
|
|
loc = load_locale(path)
|
|
loc_keys = set(loc.keys())
|
|
missing = sorted(en_keys - loc_keys)
|
|
extra = sorted(loc_keys - en_keys)
|
|
total_missing += len(missing)
|
|
total_extra += len(extra)
|
|
if missing:
|
|
locale_with_missing.append(path.stem)
|
|
|
|
mark = "✓" if not missing else "✗"
|
|
suffix = ""
|
|
if missing or extra:
|
|
parts = []
|
|
if missing:
|
|
parts.append(f"missing={len(missing)}")
|
|
if extra:
|
|
parts.append(f"extra={len(extra)}")
|
|
suffix = " " + " ".join(parts)
|
|
print(f" {mark} {path.name:14} total={len(loc_keys)}{suffix}")
|
|
|
|
if args.check_only:
|
|
continue
|
|
|
|
# Per-key listing (suppressed under --check-only).
|
|
if missing:
|
|
print(f" missing ({len(missing)}):")
|
|
for key in missing:
|
|
if args.values:
|
|
val = en.get(key, "")
|
|
if not isinstance(val, str):
|
|
val = json.dumps(val, ensure_ascii=False)
|
|
preview = truncate(val, VALUE_PREVIEW_LEN)
|
|
print(f" - {key} :: {preview}")
|
|
else:
|
|
print(f" - {key}")
|
|
if extra and not args.no_extras:
|
|
print(f" extra ({len(extra)}):")
|
|
for key in extra:
|
|
print(f" + {key}")
|
|
|
|
# ── Trailer ────────────────────────────────────────────────────────
|
|
print()
|
|
if total_missing == 0:
|
|
print(f"✓ Every locale is at parity with {SOURCE_LOCALE}.json.")
|
|
if total_extra and not args.no_extras:
|
|
print(
|
|
f" (Note: {total_extra} extra key(s) across locales — "
|
|
f"they don't fail the check but may indicate drift.)"
|
|
)
|
|
return 0
|
|
|
|
print(
|
|
f"✗ {total_missing} missing translation(s) across "
|
|
f"{len(locale_with_missing)} locale(s): "
|
|
f"{', '.join(locale_with_missing)}"
|
|
)
|
|
if total_extra and not args.no_extras:
|
|
print(f" Plus {total_extra} extra key(s); see per-locale output above.")
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|