diff --git a/scripts/check_translations.py b/scripts/check_translations.py new file mode 100755 index 0000000000..80d662c0a7 --- /dev/null +++ b/scripts/check_translations.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +""" +Translation check script for OmniRoute. +Checks if all translation keys used in code exist in en.json. + +Usage: + python scripts/check_translations.py + python scripts/check_translations.py --verbose + python scripts/check_translations.py --fix +""" + +import json +import re +import os +import sys +import argparse + + +def get_namespaces_in_code(src_dir='src'): + """Find all namespaces used in code via useTranslations().""" + used_ns = set() + for root, dirs, files in os.walk(src_dir): + for f in files: + if not (f.endswith('.tsx') or f.endswith('.ts')): + continue + try: + content = open(os.path.join(root, f), 'r', encoding='utf-8').read() + matches = re.findall(r'useTranslations\(["\']+([^"\']+)["\']+\)', content) + used_ns.update(matches) + except (IOError, UnicodeDecodeError) as e: + print(f"Warning: could not process file {os.path.join(root, f)}: {e}", file=sys.stderr) + return used_ns + + +def get_keys_in_json(json_path): + """Get all keys (including nested) from a JSON file.""" + with open(json_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + keys = set() + + def traverse(obj, prefix=''): + if isinstance(obj, dict): + for k, v in obj.items(): + key = f"{prefix}.{k}" if prefix else k + keys.add(key) + if isinstance(v, dict): + traverse(v, key) + elif isinstance(obj, list): + for item in obj: + traverse(item, prefix) + + traverse(data) + return keys + + +def check_translations(src_dir='src', en_json_path='src/i18n/messages/en.json', verbose=False): + """Check if all translation keys used in code exist in en.json.""" + # Get namespaces used in code + used_ns = get_namespaces_in_code(src_dir) + + # Get namespaces in en.json + with open(en_json_path, 'r', encoding='utf-8') as f: + en_data = json.load(f) + en_ns = set(en_data.keys()) + + # Find missing namespaces + missing_ns = sorted(used_ns - en_ns) + + # Get all keys from en.json + en_keys = get_keys_in_json(en_json_path) + + # Get all keys used in code + used_keys = set() + for root, dirs, files in os.walk(src_dir): + for f in files: + if not (f.endswith('.tsx') or f.endswith('.ts')): + continue + try: + content = open(os.path.join(root, f), 'r', encoding='utf-8').read() + matches = re.findall(r't\([\'"]+([^\'")]+)[\'"]+\)', content) + used_keys.update(matches) + except (IOError, UnicodeDecodeError) as e: + print(f"Warning: could not process file {os.path.join(root, f)}: {e}", file=sys.stderr) + + # Filter out non-translation keys + # Note: check if key IS a path or ends with extension, not just contains it + # e.g., "invoice.ts.description" (ts = timestamp) should NOT be filtered + # but "components/Button.tsx" or "utils.ts" should be + def is_likely_file_path(key: str) -> bool: + if key.endswith('.ts') or key.endswith('.tsx') or key.endswith('.js') or key.endswith('.json'): + return True + if '/.ts' in key or '/.tsx' in key or '/.js' in key or '/.json' in key: + return True + if '\\.ts' in key or '\\.tsx' in key or '\\.js' in key or '\\.json' in key: + return True + return False + + filtered = {k for k in used_keys if len(k) > 1 + and not any(x in k for x in ['../', '@', '\\', '#', '?']) + and not is_likely_file_path(k) + and k not in ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS', 'PATCH'] + and not k.startswith('#') + and k not in [',', '-', '.', ':', '?', ' ', '']} + + # Find missing keys + real_missing = [] + for k in filtered: + if k in en_keys: + continue + + # Check if key matches any full key in en.json + matched = False + for ek in en_keys: + if k in ek or ek.endswith(k): + matched = True + break + + if matched: + continue + + # Filter out paths and obvious non-translations + if k.startswith('./') or k.startswith('/') or k.startswith('x-') or k.startswith('user-'): + continue + # Use same logic - check if key IS a path or ends with extension + if is_likely_file_path(k): + continue + if k in ['Authorization', 'Content-Disposition', 'IOPlatformUUID', 'REG_SZ']: + continue + + real_missing.append(k) + + return missing_ns, sorted(real_missing) + + +def main(): + parser = argparse.ArgumentParser(description='Check translation keys in en.json') + parser.add_argument('--verbose', '-v', action='store_true', help='Show detailed output') + parser.add_argument('--fix', action='store_true', help='Generate fix suggestions') + parser.add_argument('--src', default='src', help='Source directory') + parser.add_argument('--json', default='src/i18n/messages/en.json', help='Path to en.json') + args = parser.parse_args() + + missing_ns, missing_keys = check_translations(args.src, args.json, args.verbose) + + has_issues = False + + if missing_ns: + has_issues = True + print("=== MISSING NAMESPACES ===") + for ns in missing_ns: + print(f" - {ns}") + if args.fix: + print(f" → Add '{ns}' section to en.json") + + if missing_keys: + has_issues = True + print("\n=== MISSING TRANSLATION KEYS ===") + for k in missing_keys: + print(f" - {k}") + if args.fix: + print(f" → Add to appropriate namespace in en.json") + + if not has_issues: + print("✓ All translation namespaces and keys are present in en.json") + sys.exit(0) + else: + print(f"\nTotal: {len(missing_ns)} namespace(s), {len(missing_keys)} key(s) missing") + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/scripts/validate_translation.py b/scripts/validate_translation.py new file mode 100755 index 0000000000..b87b6b7adb --- /dev/null +++ b/scripts/validate_translation.py @@ -0,0 +1,519 @@ +#!/usr/bin/env python3 +""" +OmniRoute i18n Translation Validator +Script for comparing source (en.json) with Czech translation (cs.json) +Detects missing translations and source changes needing updates +""" + +import json +import sys +import os +from pathlib import Path +from typing import Dict, List, Set, Tuple, Any + +# 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() +# If script is in scripts/ subfolder, go up one level to repo root +if _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" +TRANSLATION_FILE = MESSAGES_DIR / "cs.json" + +# Keys that should NOT be translated (technical terms, proper names, etc.) +UNTRANSLATABLE_KEYS = { + # ICU/Plural formats + "apiManager.modelsCount", + # Technical/Protocol names + "a2aDashboard.metadata", + "a2aDashboard.ok", + "a2aDashboard.url", + "cliTools.baseUrlPlaceholder", + "cliTools.platforms", + "cliTools.toolDescriptions.claude", + "cliTools.toolDescriptions.codex", + "cliTools.toolDescriptions.cursor", + "combos.roundRobin", + "common.model", + "docs.clientCherryStudioTitle", + "docs.clientClaudeTitle", + "docs.clientCursorTitle", + "docs.github", + "docs.protocolA2aTitle", + "docs.protocolMcpTitle", + "endpoint.chat", + "endpoint.chatCompletions", + "endpoint.cloudProxy", + "endpoint.mcpCardTitle", + "endpoint.rerank", + "header.a2a", + "header.mcp", + "health.cpu", + "health.latencyP50", + "health.latencyP95", + "health.latencyP99", + "health.millisecondsShort", + "health.notAvailable", + "health.ok", + "home.aliasLabel", + "home.oauthLabel", + "landing.brandName", + "landing.flowProviderAnthropic", + "landing.flowProviderGemini", + "landing.flowProviderGithubCopilot", + "landing.flowProviderOpenAI", + "landing.flowToolClaudeCode", + "landing.flowToolCline", + "landing.flowToolCursor", + "landing.flowToolOpenAICodex", + "landing.github", + "legal.terms", + "legal.privacy", + "logs.endpoint", + "logs.proxy", + "logs.console", + "logs.request", + "logs.audit", + "media.interpolation", + "media.upscale", + "media.samples", + "search.search", + "search.searchTools", + "search.webSearch", + "search.fileSearch", + "settings.theme", + "settings.language", + "settings.currency", + "settings.timezone", + "stats.requests", + "stats.tokens", + "stats.latency", + "stats.errors", + "themesPage.dark", + "themesPage.light", + "themesPage.system", + "translator.translate", + "translator.translateFrom", + "translator.translateTo", + "translator.detect", + "translator.detectedLanguage", + "usage.totalRequests", + "usage.totalTokens", + "usage.inputTokens", + "usage.outputTokens", + "usage.promptTokens", + "usage.completionTokens", + "usage.cacheReadTokens", + "usage.cacheWriteTokens", +} + + +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 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 generate_report(): + """Generate full translation report""" + 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!") + + # 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"{RED}Translation needs attention:{NC}") + print(f" - Missing: {len(missing)}") + print(f" - Extra: {len(extra)}") + print(f" - Untranslated: {len(untranslated)}") + return 1 + + +def quick_check() -> int: + """Quick check - just show counts""" + 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)}") + + return 0 if not missing and not untranslated else 1 + + +def show_diff(category: str) -> int: + """Show detailed diff for a category""" + 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""" + 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""" + 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') + 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_FILE.name}`\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] + +Commands: + (default) Generate full report + quick Quick check - just show counts + diff 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 + 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 +""") + + +def main(): + if len(sys.argv) < 2: + return generate_report() + + cmd = sys.argv[1] + + if cmd == "quick": + return quick_check() + elif cmd == "diff": + if len(sys.argv) < 3: + print_error("Please specify category") + usage() + return 1 + return show_diff(sys.argv[2]) + elif cmd == "csv": + output = sys.argv[2] if len(sys.argv) > 2 else "translation_report.csv" + return export_csv(output) + elif cmd == "md": + output = sys.argv[2] if len(sys.argv) > 2 else "translation_report.md" + return export_markdown(output) + elif cmd in ("help", "--help", "-h"): + usage() + return 0 + else: + print_error(f"Unknown command: {cmd}") + usage() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/validate-translation.sh b/validate-translation.sh new file mode 100755 index 0000000000..39a3b6be08 --- /dev/null +++ b/validate-translation.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Wrapper for OmniRoute translation validator +# Provides easy CLI access to the Python validation script + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Run the Python script with all arguments +exec python3 "$SCRIPT_DIR/scripts/validate_translation.py" "$@"