Spaces:
Runtime error
Runtime error
| #!/usr/bin/env python3 | |
| """ | |
| OmniRoute i18n Translation Validator | |
| Script for comparing source (en.json) with any translation | |
| Detects missing translations and source changes needing updates | |
| Usage: | |
| python validate_translation.py # Uses TRANSLATION_LANG env or --lang argument | |
| python validate_translation.py --lang cs # Validate Czech (cs.json) | |
| python validate_translation.py -l de # Validate German (de.json) | |
| TRANSLATION_LANG=fr python validate_translation.py # Validate French | |
| Environment variables: | |
| TRANSLATION_LANG Target language code (e.g., cs, de, fr) | |
| """ | |
| import json | |
| import sys | |
| import os | |
| from pathlib import Path | |
| from typing import Dict, List, Set, Tuple, Any | |
| import argparse | |
| # Colors (ANSI) | |
| RED = "\033[0;31m" | |
| GREEN = "\033[0;32m" | |
| YELLOW = "\033[1;33m" | |
| BLUE = "\033[0;34m" | |
| NC = "\033[0m" | |
| # Configuration - find repo root relative to this script | |
| _script_dir = Path(__file__).parent.resolve() | |
| # Walk up out of scripts/<group>/, scripts/, or stay at cwd | |
| if _script_dir.name == "i18n" and _script_dir.parent.name == "scripts": | |
| SCRIPT_DIR = _script_dir.parent.parent | |
| elif _script_dir.name == "scripts": | |
| SCRIPT_DIR = _script_dir.parent | |
| else: | |
| SCRIPT_DIR = _script_dir | |
| MESSAGES_DIR = SCRIPT_DIR / "src" / "i18n" / "messages" | |
| SOURCE_FILE = MESSAGES_DIR / "en.json" | |
| # Get target language from env or argument | |
| def get_target_lang() -> str: | |
| """Get target language from ENV or CLI argument.""" | |
| # First check environment variable | |
| env_lang = os.environ.get("TRANSLATION_LANG") | |
| if env_lang: | |
| return env_lang | |
| # Then check command line argument (will be set in main) | |
| if hasattr(get_target_lang, "cli_lang"): | |
| return get_target_lang.cli_lang | |
| # Default to cs for backwards compatibility | |
| return "cs" | |
| # Keys that should NOT be translated (technical terms, proper names, etc.) | |
| # Loaded from external file for easier maintenance | |
| _UNTRANSLATABLE_KEYS_FILE = _script_dir / "i18n" / "untranslatable-keys.json" | |
| if _UNTRANSLATABLE_KEYS_FILE.exists(): | |
| with open(_UNTRANSLATABLE_KEYS_FILE, "r", encoding="utf-8") as _f: | |
| UNTRANSLATABLE_KEYS = set(json.load(_f).get("keys", [])) | |
| else: | |
| UNTRANSLATABLE_KEYS = set() | |
| def print_header(msg: str) -> None: | |
| print(f"\n{BLUE}{'=' * 50}{NC}") | |
| print(f"{BLUE}{msg}{NC}") | |
| print(f"{BLUE}{'=' * 50}{NC}") | |
| def print_success(msg: str) -> None: | |
| print(f"{GREEN}✓ {msg}{NC}") | |
| def print_warning(msg: str) -> None: | |
| print(f"{YELLOW}⚠ {msg}{NC}") | |
| def print_error(msg: str) -> None: | |
| print(f"{RED}✗ {msg}{NC}") | |
| def load_json(path: Path) -> Dict: | |
| """Load JSON file""" | |
| try: | |
| with open(path, "r", encoding="utf-8") as f: | |
| return json.load(f) | |
| except json.JSONDecodeError as e: | |
| print_error(f"Invalid JSON in {path}: {e}") | |
| sys.exit(1) | |
| def get_all_keys(obj: Any, prefix: str = "") -> Set[str]: | |
| """Recursively get all leaf keys from JSON object""" | |
| keys = set() | |
| if isinstance(obj, dict): | |
| for key, value in obj.items(): | |
| new_prefix = f"{prefix}.{key}" if prefix else key | |
| if isinstance(value, dict): | |
| keys.update(get_all_keys(value, new_prefix)) | |
| elif isinstance(value, list): | |
| # Handle arrays - check first element for structure | |
| if value and isinstance(value[0], dict): | |
| for i, item in enumerate(value): | |
| keys.update(get_all_keys(item, f"{new_prefix}[{i}]")) | |
| else: | |
| keys.add(new_prefix) | |
| else: | |
| keys.add(new_prefix) | |
| return keys | |
| def find_missing_keys(source: Dict, trans: Dict) -> Set[str]: | |
| """Keys in source but not in translation""" | |
| source_keys = get_all_keys(source) | |
| trans_keys = get_all_keys(trans) | |
| return source_keys - trans_keys | |
| def find_extra_keys(source: Dict, trans: Dict) -> Set[str]: | |
| """Keys in translation but not in source""" | |
| source_keys = get_all_keys(source) | |
| trans_keys = get_all_keys(trans) | |
| return trans_keys - source_keys | |
| def get_value_by_path(obj: Dict, path: str) -> Any: | |
| """Get value from nested dict using dot notation""" | |
| keys = path.replace("[", ".").replace("]", "").split(".") | |
| current = obj | |
| for key in keys: | |
| if key.isdigit(): | |
| idx = int(key) | |
| if isinstance(current, list) and idx < len(current): | |
| current = current[idx] | |
| else: | |
| return None | |
| else: | |
| if isinstance(current, dict) and key in current: | |
| current = current[key] | |
| else: | |
| return None | |
| return current | |
| def find_untranslated(source: Dict, trans: Dict) -> Set[str]: | |
| """Keys where source value equals translation (not translated), excluding untranslatable keys""" | |
| source_keys = get_all_keys(source) | |
| untranslated = set() | |
| for key in source_keys: | |
| # Skip keys that are in the untranslatable list | |
| if key in UNTRANSLATABLE_KEYS: | |
| continue | |
| source_val = get_value_by_path(source, key) | |
| trans_val = get_value_by_path(trans, key) | |
| if source_val is not None and source_val == trans_val: | |
| untranslated.add(key) | |
| return untranslated | |
| def find_placeholder_issues(source: Dict, trans: Dict) -> List[Tuple[str, str, str]]: | |
| """ | |
| Find placeholder mismatches between source and translation. | |
| Only checks top-level placeholders like {count}, {day}, NOT ICU inner content. | |
| Returns list of (key, source_placeholder, trans_placeholder) | |
| """ | |
| source_keys = get_all_keys(source) | |
| issues = [] | |
| for key in source_keys: | |
| source_val = get_value_by_path(source, key) | |
| trans_val = get_value_by_path(trans, key) | |
| if source_val is None or trans_val is None: | |
| continue | |
| if not isinstance(source_val, str) or not isinstance(trans_val, str): | |
| continue | |
| # Only extract top-level placeholders: {name}, {count}, {day}, NOT {# X} inside ICU | |
| import re | |
| # Extract variable names from placeholders (e.g., 'name' from '{name}' or 'count' from '{count, plural, ...}') | |
| # This avoids false positives on ICU strings where the internal text is translated. | |
| placeholder_regex = r"\{\s*([a-zA-Z][a-zA-Z0-9_]*)" | |
| source_placeholders = set(re.findall(placeholder_regex, source_val)) | |
| trans_placeholders = set(re.findall(placeholder_regex, trans_val)) | |
| # Check for missing placeholders | |
| missing = source_placeholders - trans_placeholders | |
| if missing: | |
| issues.append((key, str(source_placeholders), str(trans_placeholders))) | |
| return issues | |
| def compare_category( | |
| source: Dict, trans: Dict, category: str | |
| ) -> Tuple[bool, List[str]]: | |
| """Compare a specific category, return (complete, missing_keys)""" | |
| if category not in source: | |
| return False, [f"Category '{category}' not in source"] | |
| if category not in trans: | |
| return False, [f"Category '{category}' missing in translation"] | |
| source_keys = get_all_keys(source[category]) | |
| trans_keys = get_all_keys(trans[category]) | |
| missing = source_keys - trans_keys | |
| return len(missing) == 0, list(missing) | |
| def get_translation_file() -> Path: | |
| """Get the translation file path based on target language.""" | |
| lang = get_target_lang() | |
| return MESSAGES_DIR / f"{lang}.json" | |
| def generate_report(): | |
| """Generate full translation report""" | |
| translation_file = get_translation_file() | |
| print_header("OmniRoute Translation Report") | |
| print(f"Source: {SOURCE_FILE}") | |
| print(f"Translation: {translation_file}\n") | |
| source = load_json(SOURCE_FILE) | |
| trans = load_json(translation_file) | |
| # Count keys | |
| source_count = len(get_all_keys(source)) | |
| trans_count = len(get_all_keys(trans)) | |
| print(f"{BLUE}Key Statistics:{NC}") | |
| print(f" Source keys: {source_count}") | |
| print(f" Translation keys: {trans_count}\n") | |
| # Missing keys | |
| print_header("Missing Translations") | |
| missing = find_missing_keys(source, trans) | |
| if missing: | |
| print(f"{RED}Found {len(missing)} missing keys:{NC}") | |
| for key in sorted(missing)[:50]: # Limit output | |
| print(f" - {key}") | |
| if len(missing) > 50: | |
| print(f" ... and {len(missing) - 50} more") | |
| else: | |
| print_success("No missing translations!") | |
| # Extra keys | |
| print_header("Extra Keys") | |
| extra = find_extra_keys(source, trans) | |
| if extra: | |
| print(f"{YELLOW}Found {len(extra)} extra keys:{NC}") | |
| for key in sorted(extra)[:50]: | |
| print(f" - {key}") | |
| else: | |
| print_success("No extra keys!") | |
| # Untranslated | |
| print_header("Untranslated Keys (same as source)") | |
| untranslated = find_untranslated(source, trans) | |
| if untranslated: | |
| print(f"{YELLOW}Found {len(untranslated)} untranslated keys:{NC}") | |
| for key in sorted(untranslated)[:50]: | |
| print(f" - {key}") | |
| if len(untranslated) > 50: | |
| print(f" ... and {len(untranslated) - 50} more") | |
| else: | |
| print_success("All keys appear to be translated!") | |
| # Placeholder issues | |
| print_header("Placeholder Mismatches") | |
| placeholder_issues = find_placeholder_issues(source, trans) | |
| if placeholder_issues: | |
| print(f"{YELLOW}Found {len(placeholder_issues)} placeholder mismatches:{NC}") | |
| for key, src_ph, trans_ph in placeholder_issues[:20]: | |
| print(f" - {key}") | |
| print(f" Source: {src_ph}") | |
| print(f" Trans: {trans_ph}") | |
| if len(placeholder_issues) > 20: | |
| print(f" ... and {len(placeholder_issues) - 20} more") | |
| else: | |
| print_success("All placeholders match!") | |
| # Per-category status | |
| print_header("Per-Category Status") | |
| for category in sorted(source.keys()): | |
| complete, missing = compare_category(source, trans, category) | |
| if complete: | |
| print_success(f"{category} - complete") | |
| else: | |
| print_error(f"{category} - missing {len(missing)} keys") | |
| # Summary | |
| print_header("Summary") | |
| if not missing and not extra and not untranslated: | |
| print(f"{GREEN}🎉 Translation is fully synchronized!{NC}") | |
| return 0 | |
| else: | |
| print(f"{YELLOW}Translation needs attention:{NC}") | |
| print(f" - Missing: {len(missing)}") | |
| print(f" - Extra: {len(extra)}") | |
| print(f" - Untranslated: {len(untranslated)}") | |
| return 0 | |
| def quick_check() -> int: | |
| """Quick check - just show counts""" | |
| translation_file = get_translation_file() | |
| source = load_json(SOURCE_FILE) | |
| trans = load_json(translation_file) | |
| missing = find_missing_keys(source, trans) | |
| untranslated = find_untranslated(source, trans) | |
| print(f"Missing: {len(missing)}") | |
| print(f"Untranslated: {len(untranslated)}") | |
| print(f"Ignored (UNTRANSLATABLE_KEYS): {len(UNTRANSLATABLE_KEYS)}") | |
| # Exit codes: | |
| # 0 = OK | |
| # 1 = generic error | |
| # 2 = missing string in translation | |
| # 3 = untranslated (soft warning - not a failure) | |
| if missing: | |
| print_warning(f"{len(missing)} missing keys (non-critical)") | |
| return 0 | |
| # untranslated is a soft warning, not a failure - translations exist, just not localized | |
| if untranslated: | |
| print_warning(f"{len(untranslated)} untranslated keys (non-critical)") | |
| return 0 | |
| return 0 | |
| def show_diff(category: str) -> int: | |
| """Show detailed diff for a category""" | |
| translation_file = get_translation_file() | |
| source = load_json(SOURCE_FILE) | |
| trans = load_json(translation_file) | |
| if category not in source: | |
| print_error(f"Category '{category}' not found in source") | |
| print("Available categories:") | |
| for cat in sorted(source.keys()): | |
| print(f" - {cat}") | |
| return 1 | |
| print_header(f"Diff for category: {category}") | |
| print(f"{BLUE}{'Key':<30} | {'Source':<25} | {'Translation':<25}{NC}") | |
| print("-" * 85) | |
| source_keys = get_all_keys(source[category]) | |
| for key in sorted(source_keys): | |
| source_val = get_value_by_path(source[category], key) | |
| trans_val = get_value_by_path(trans.get(category, {}), key) | |
| # Truncate long values | |
| source_str = str(source_val)[:25] if source_val else "(null)" | |
| trans_str = str(trans_val)[:25] if trans_val else "(missing)" | |
| if source_val == trans_val: | |
| status = f"{YELLOW}(same){NC}" | |
| elif trans_val is None: | |
| status = f"{RED}(missing){NC}" | |
| else: | |
| status = f"{GREEN}(ok){NC}" | |
| print(f"{key:<30} | {source_str:<25} | {trans_str:<25} {status}") | |
| return 0 | |
| def export_csv(output_file: str) -> int: | |
| """Export to CSV""" | |
| translation_file = get_translation_file() | |
| source = load_json(SOURCE_FILE) | |
| trans = load_json(translation_file) | |
| print_header(f"Exporting to CSV: {output_file}") | |
| source_keys = get_all_keys(source) | |
| with open(output_file, "w", encoding="utf-8") as f: | |
| f.write("key,source_value,translation_value,status\n") | |
| for key in sorted(source_keys): | |
| source_val = get_value_by_path(source, key) | |
| trans_val = get_value_by_path(trans, key) | |
| # Escape commas | |
| source_str = str(source_val).replace(",", ";") | |
| trans_str = str(trans_val).replace(",", ";") if trans_val else "" | |
| if trans_val is None: | |
| status = "MISSING" | |
| elif source_val == trans_val: | |
| status = "UNTRANSLATED" | |
| else: | |
| status = "OK" | |
| f.write(f'"{key}","{source_str}","{trans_str}",{status}\n') | |
| print_success(f"Exported to {output_file}") | |
| return 0 | |
| def export_markdown(output_file: str) -> int: | |
| """Export all keys to separate Markdown files - translated and untranslated""" | |
| translation_file = get_translation_file() | |
| source = load_json(SOURCE_FILE) | |
| trans = load_json(translation_file) | |
| print_header(f"Exporting to Markdown: {output_file}") | |
| source_keys = get_all_keys(source) | |
| missing = find_missing_keys(source, trans) | |
| untranslated = find_untranslated(source, trans) | |
| # Separate translated and untranslated | |
| translated_keys = [] | |
| untranslated_sorted = sorted(untranslated) | |
| for key in sorted(source_keys): | |
| if key not in missing and key not in untranslated: | |
| translated_keys.append(key) | |
| translated_count = len(translated_keys) | |
| untranslated_count = len(untranslated_sorted) | |
| # Export untranslated (main output file) | |
| with open(output_file, "w", encoding="utf-8") as f: | |
| f.write("# Nepřeložené klíče (Untranslated Keys)\n\n") | |
| f.write(f"Zdroj: `{SOURCE_FILE.name}` | Překlad: `{TRANSLATION_FILE.name}`\n\n") | |
| f.write(f"**Celkem: {untranslated_count} nepreložených klíčů**\n\n") | |
| f.write("| # | Klíč (Key) | Originál | Nepřeloženo |\n") | |
| f.write("|---|------------|----------|------------|\n") | |
| for i, key in enumerate(untranslated_sorted, 1): | |
| source_val = get_value_by_path(source, key) | |
| trans_val = get_value_by_path(trans, key) | |
| source_str = str(source_val).replace("|", "\\|")[:60] | |
| trans_str = str(trans_val).replace("|", "\\|")[:60] | |
| f.write(f"| {i} | `{key}` | {source_str} | {trans_str} |\n") | |
| f.write("\n## Shrnutí (Summary)\n\n") | |
| f.write(f"- Celkem klíčů: {len(source_keys)}\n") | |
| f.write(f"- Chybějících: {len(missing)}\n") | |
| f.write(f"- Nepřeložených: {untranslated_count}\n") | |
| f.write(f"- Přeložených: {translated_count}\n") | |
| # Export translated to separate file | |
| translated_file = output_file.replace(".md", "_translated.md") | |
| translation_filename = translation_file.name | |
| with open(translated_file, "w", encoding="utf-8") as f: | |
| f.write("# Přeložené klíče (Translated Keys)\n\n") | |
| f.write(f"Zdroj: `{SOURCE_FILE.name}` | Překlad: `{translation_filename}`\n\n") | |
| f.write(f"**Celkem: {translated_count} přeložených klíčů**\n\n") | |
| f.write("| # | Klíč (Key) | Originál | Překlad |\n") | |
| f.write("|---|------------|----------|---------|\n") | |
| for i, key in enumerate(translated_keys, 1): | |
| source_val = get_value_by_path(source, key) | |
| trans_val = get_value_by_path(trans, key) | |
| source_str = str(source_val).replace("|", "\\|")[:40] | |
| trans_str = str(trans_val).replace("|", "\\|")[:40] | |
| f.write(f"| {i} | `{key}` | {source_str} | {trans_str} |\n") | |
| print_success(f"Exported: {output_file} ({untranslated_count} keys)") | |
| print_success(f"Exported: {translated_file} ({translated_count} keys)") | |
| return 0 | |
| def usage(): | |
| print(""" | |
| OmniRoute i18n Translation Validator | |
| Usage: validate_translation.py [command] [options] | |
| Options: | |
| -l, --lang <code> Target language code (e.g., cs, de, fr) | |
| Default: cs or TRANSLATION_LANG env variable | |
| Commands: | |
| (default) Generate full report | |
| quick Quick check - just show counts | |
| diff <category> Show detailed diff for a category | |
| csv [file] Export to CSV (default: translation_report.csv) | |
| md [file] Export to Markdown (default: translation_report.md) | |
| Examples: | |
| python validate_translation.py # Full report (default: cs) | |
| python validate_translation.py --lang de # Validate German | |
| python validate_translation.py -l fr # Validate French | |
| TRANSLATION_LANG=es python validate_translation.py # Validate Spanish | |
| python validate_translation.py quick # Quick status check | |
| python validate_translation.py diff common # Diff common category | |
| python validate_translation.py csv # Export to CSV | |
| python validate_translation.py md # Export to Markdown | |
| python validate_translation.py fix # Auto-generate missing keys from en.json | |
| """) | |
| def fix_missing_keys() -> int: | |
| """Auto-generate missing keys by copying from en.json""" | |
| source_file = SOURCE_FILE | |
| translation_file = get_translation_file() | |
| source = load_json(source_file) | |
| trans = load_json(translation_file) | |
| # Get all keys recursively | |
| def get_all_keys(obj, prefix=''): | |
| keys = [] | |
| if isinstance(obj, dict): | |
| for k, v in obj.items(): | |
| full_key = f"{prefix}.{k}" if prefix else k | |
| if isinstance(v, dict): | |
| keys.extend(get_all_keys(v, full_key)) | |
| else: | |
| keys.append(full_key) | |
| return keys | |
| source_keys = set(get_all_keys(source)) | |
| trans_keys = set(get_all_keys(trans)) | |
| missing = source_keys - trans_keys | |
| if not missing: | |
| print_success("No missing keys - translation file is complete!") | |
| return 0 | |
| print(f"Found {len(missing)} missing keys") | |
| # Add missing keys to translation | |
| for key in missing: | |
| parts = key.split('.') | |
| current = trans | |
| for i, part in enumerate(parts[:-1]): | |
| if part not in current: | |
| current[part] = {} | |
| current = current[part] | |
| # Get value from source | |
| src = source | |
| for part in parts: | |
| src = src.get(part, {}) | |
| # Set the value (use English as fallback) | |
| current[parts[-1]] = src if isinstance(src, str) else key | |
| # Write back | |
| with open(translation_file, 'w', encoding='utf-8') as f: | |
| json.dump(trans, f, ensure_ascii=False, indent=2) | |
| f.write('\n') | |
| print_success(f"Added {len(missing)} missing keys to {translation_file.name}") | |
| return 0 | |
| def main(): | |
| # Parse global arguments first | |
| parser = argparse.ArgumentParser(add_help=False) | |
| parser.add_argument("-l", "--lang", dest="lang", default=None) | |
| parser.add_argument("command", nargs="?") | |
| parser.add_argument("arg", nargs="?") | |
| # Parse known args only to allow commands to handle their own args | |
| args, _ = parser.parse_known_args() | |
| # Set language from argument or use default | |
| if args.lang: | |
| get_target_lang.cli_lang = args.lang | |
| elif not os.environ.get("TRANSLATION_LANG"): | |
| # Default to cs for backwards compatibility | |
| get_target_lang.cli_lang = "cs" | |
| # Check if translation file exists | |
| translation_file = get_translation_file() | |
| if not translation_file.exists(): | |
| print_error(f"Translation file not found: {translation_file}") | |
| print(f"Available languages:") | |
| for f in sorted(MESSAGES_DIR.glob("*.json")): | |
| if f.name != "en.json": | |
| print(f" - {f.stem}") | |
| return 1 | |
| # Execute command | |
| if not args.command or args.command in ("help", "--help", "-h"): | |
| return generate_report() | |
| if args.command == "quick": | |
| return quick_check() | |
| elif args.command == "diff": | |
| if not args.arg: | |
| print_error("Please specify category") | |
| usage() | |
| return 1 | |
| return show_diff(args.arg) | |
| elif args.command == "csv": | |
| output = args.arg if args.arg else "translation_report.csv" | |
| return export_csv(output) | |
| elif args.command == "md": | |
| output = args.arg if args.arg else "translation_report.md" | |
| return export_markdown(output) | |
| elif args.command == "fix": | |
| return fix_missing_keys() | |
| else: | |
| print_error(f"Unknown command: {args.command}") | |
| usage() | |
| return 1 | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |