Add check_translations and validate_translations scripts

This commit is contained in:
zenobit
2026-03-26 22:16:49 +01:00
parent 9ff6353b88
commit 7224e03590
3 changed files with 700 additions and 0 deletions

173
scripts/check_translations.py Executable file
View File

@@ -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()

519
scripts/validate_translation.py Executable file
View File

@@ -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 <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
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())