{languageOptions.map((option, index) => {
- const isEnglishGB = option.value === 'en-GB'; // Currently only English GB has enough translations to use
- const isDisabled = !isEnglishGB;
+ // Enable languages with >90% translation completion
+ const enabledLanguages = ['en-GB', 'ar-AR', 'de-DE', 'es-ES', 'fr-FR', 'it-IT', 'pt-BR', 'ru-RU', 'zh-CN'];
+ const isDisabled = !enabledLanguages.includes(option.value);
return (
+ python3 json_validator.py ar_AR_batch_*.json
+ python3 json_validator.py ar_AR_batch_1_of_3.json
+ python3 json_validator.py --all-batches ar_AR
+"""
+
+import json
+import sys
+import argparse
+import glob
+from pathlib import Path
+
+
+def get_line_context(file_path, line_num, context_lines=3):
+ """Get lines around the error for context"""
+ try:
+ with open(file_path, 'r', encoding='utf-8') as f:
+ lines = f.readlines()
+
+ start = max(0, line_num - context_lines - 1)
+ end = min(len(lines), line_num + context_lines)
+
+ context = []
+ for i in range(start, end):
+ marker = ">>> " if i == line_num - 1 else " "
+ context.append(f"{marker}{i+1:4d}: {lines[i].rstrip()}")
+
+ return "\n".join(context)
+ except Exception as e:
+ return f"Could not read context: {e}"
+
+
+def get_character_context(file_path, char_pos, context_chars=100):
+ """Get characters around the error position"""
+ try:
+ with open(file_path, 'r', encoding='utf-8') as f:
+ content = f.read()
+
+ start = max(0, char_pos - context_chars)
+ end = min(len(content), char_pos + context_chars)
+
+ before = content[start:char_pos]
+ error_char = content[char_pos] if char_pos < len(content) else "EOF"
+ after = content[char_pos+1:end]
+
+ return {
+ 'before': before,
+ 'error_char': error_char,
+ 'after': after,
+ 'display': f"{before}[{error_char}]{after}"
+ }
+ except Exception as e:
+ return None
+
+
+def validate_json_file(file_path):
+ """Validate a single JSON file and return detailed error info"""
+ result = {
+ 'file': str(file_path),
+ 'valid': False,
+ 'error': None,
+ 'line': None,
+ 'column': None,
+ 'position': None,
+ 'context': None,
+ 'char_context': None,
+ 'entry_count': 0
+ }
+
+ try:
+ with open(file_path, 'r', encoding='utf-8') as f:
+ data = json.load(f)
+
+ result['valid'] = True
+ result['entry_count'] = len(data) if isinstance(data, dict) else 0
+
+ except json.JSONDecodeError as e:
+ result['error'] = e.msg
+ result['line'] = e.lineno
+ result['column'] = e.colno
+ result['position'] = e.pos
+ result['context'] = get_line_context(file_path, e.lineno)
+ result['char_context'] = get_character_context(file_path, e.pos)
+
+ except FileNotFoundError:
+ result['error'] = "File not found"
+
+ except Exception as e:
+ result['error'] = str(e)
+
+ return result
+
+
+def print_validation_result(result, verbose=True):
+ """Print validation result in a formatted way"""
+ file_name = Path(result['file']).name
+
+ if result['valid']:
+ print(f"✓ {file_name}: Valid JSON ({result['entry_count']} entries)")
+ else:
+ print(f"✗ {file_name}: Invalid JSON")
+ print(f" Error: {result['error']}")
+
+ if result['line']:
+ print(f" Location: Line {result['line']}, Column {result['column']} (character {result['position']})")
+
+ if verbose and result['context']:
+ print(f"\n Context:")
+ for line in result['context'].split('\n'):
+ print(f" {line}")
+
+ if verbose and result['char_context']:
+ print(f"\n Character context:")
+ print(f" ...{result['char_context']['display'][-150:]}...")
+ print(f" Error character: {repr(result['char_context']['error_char'])}")
+
+ print()
+
+
+def get_common_fixes(error_msg):
+ """Suggest common fixes based on error message"""
+ fixes = []
+
+ if "Expecting ',' delimiter" in error_msg:
+ fixes.append("Missing comma between JSON entries")
+ fixes.append("Check for unescaped quotes inside string values")
+
+ if "Invalid \\escape" in error_msg or "Invalid escape" in error_msg:
+ fixes.append("Unescaped backslash in string (use \\\\ for literal backslash)")
+ fixes.append("Common in regex patterns: \\d should be \\\\d")
+
+ if "Expecting property name" in error_msg:
+ fixes.append("Missing or extra comma")
+ fixes.append("Trailing comma before closing brace")
+
+ if "Expecting value" in error_msg:
+ fixes.append("Missing value after colon")
+ fixes.append("Extra comma")
+
+ return fixes
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description='Validate JSON syntax in translation files',
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog="""
+Examples:
+ Validate single file:
+ python3 json_validator.py ar_AR_batch_1_of_3.json
+
+ Validate all batches for a language:
+ python3 json_validator.py --all-batches ar_AR
+
+ Validate pattern:
+ python3 json_validator.py "ar_AR_batch_*.json"
+
+ Validate multiple files:
+ python3 json_validator.py file1.json file2.json file3.json
+ """
+ )
+
+ parser.add_argument(
+ 'files',
+ nargs='*',
+ help='JSON file(s) to validate (supports wildcards)'
+ )
+
+ parser.add_argument(
+ '--all-batches',
+ metavar='LANGUAGE',
+ help='Validate all batch files for a language (e.g., ar_AR)'
+ )
+
+ parser.add_argument(
+ '--quiet',
+ action='store_true',
+ help='Only show files with errors'
+ )
+
+ parser.add_argument(
+ '--brief',
+ action='store_true',
+ help='Brief output without context'
+ )
+
+ args = parser.parse_args()
+
+ # Determine which files to validate
+ files_to_validate = []
+
+ if args.all_batches:
+ pattern = f"{args.all_batches}_batch_*.json"
+ files_to_validate = glob.glob(pattern)
+ if not files_to_validate:
+ print(f"No batch files found matching: {pattern}")
+ return 1
+ elif args.files:
+ for file_pattern in args.files:
+ if '*' in file_pattern or '?' in file_pattern:
+ files_to_validate.extend(glob.glob(file_pattern))
+ else:
+ files_to_validate.append(file_pattern)
+ else:
+ parser.print_help()
+ return 1
+
+ if not files_to_validate:
+ print("No files to validate")
+ return 1
+
+ # Sort files for consistent output
+ files_to_validate.sort()
+
+ print(f"Validating {len(files_to_validate)} file(s)...\n")
+
+ # Validate each file
+ results = []
+ for file_path in files_to_validate:
+ result = validate_json_file(file_path)
+ results.append(result)
+
+ if not args.quiet or not result['valid']:
+ print_validation_result(result, verbose=not args.brief)
+
+ # Summary
+ valid_count = sum(1 for r in results if r['valid'])
+ invalid_count = len(results) - valid_count
+
+ print("=" * 60)
+ print(f"Summary: {valid_count} valid, {invalid_count} invalid")
+
+ # Show common fixes for errors
+ if invalid_count > 0:
+ all_errors = [r['error'] for r in results if r['error']]
+ unique_error_types = set(all_errors)
+
+ print("\nCommon fixes:")
+ fixes_shown = set()
+ for error in unique_error_types:
+ fixes = get_common_fixes(error)
+ for fix in fixes:
+ if fix not in fixes_shown:
+ print(f" • {fix}")
+ fixes_shown.add(fix)
+
+ return 0 if invalid_count == 0 else 1
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/scripts/translations/validate_json_structure.py b/scripts/translations/validate_json_structure.py
new file mode 100644
index 000000000..3f071c5be
--- /dev/null
+++ b/scripts/translations/validate_json_structure.py
@@ -0,0 +1,229 @@
+#!/usr/bin/env python3
+"""
+Validate JSON structure and formatting of translation files.
+
+Checks for:
+- Valid JSON syntax
+- Consistent key structure with en-GB
+- Missing keys
+- Extra keys not in en-GB
+- Malformed entries
+
+Usage:
+ python scripts/translations/validate_json_structure.py [--language LANG]
+"""
+
+import json
+import sys
+from pathlib import Path
+from typing import Dict, List, Set
+import argparse
+
+
+def get_all_keys(d: dict, parent_key: str = '', sep: str = '.') -> Set[str]:
+ """Get all keys from nested dict as dot-notation paths."""
+ keys = set()
+ for k, v in d.items():
+ new_key = f"{parent_key}{sep}{k}" if parent_key else k
+ keys.add(new_key)
+ if isinstance(v, dict):
+ keys.update(get_all_keys(v, new_key, sep=sep))
+ return keys
+
+
+def validate_json_file(file_path: Path) -> tuple[bool, str]:
+ """Validate that a file contains valid JSON."""
+ try:
+ with open(file_path, 'r', encoding='utf-8') as f:
+ json.load(f)
+ return True, "Valid JSON"
+ except json.JSONDecodeError as e:
+ return False, f"Invalid JSON at line {e.lineno}, column {e.colno}: {e.msg}"
+ except Exception as e:
+ return False, f"Error reading file: {str(e)}"
+
+
+def validate_structure(
+ en_gb_keys: Set[str],
+ lang_keys: Set[str],
+ lang_code: str
+) -> Dict:
+ """Compare structure between en-GB and target language."""
+ missing_keys = en_gb_keys - lang_keys
+ extra_keys = lang_keys - en_gb_keys
+
+ return {
+ 'language': lang_code,
+ 'missing_keys': sorted(missing_keys),
+ 'extra_keys': sorted(extra_keys),
+ 'total_keys': len(lang_keys),
+ 'expected_keys': len(en_gb_keys),
+ 'missing_count': len(missing_keys),
+ 'extra_count': len(extra_keys)
+ }
+
+
+def print_validation_result(result: Dict, verbose: bool = False):
+ """Print validation results in readable format."""
+ lang = result['language']
+
+ print(f"\n{'='*100}")
+ print(f"Language: {lang}")
+ print(f"{'='*100}")
+ print(f" Total keys: {result['total_keys']}")
+ print(f" Expected keys (en-GB): {result['expected_keys']}")
+ print(f" Missing keys: {result['missing_count']}")
+ print(f" Extra keys: {result['extra_count']}")
+
+ if result['missing_count'] == 0 and result['extra_count'] == 0:
+ print(f" ✅ Structure matches en-GB perfectly!")
+ else:
+ if result['missing_count'] > 0:
+ print(f"\n ⚠️ Missing {result['missing_count']} key(s):")
+ if verbose or result['missing_count'] <= 20:
+ for key in result['missing_keys'][:50]:
+ print(f" - {key}")
+ if result['missing_count'] > 50:
+ print(f" ... and {result['missing_count'] - 50} more")
+ else:
+ print(f" (use --verbose to see all)")
+
+ if result['extra_count'] > 0:
+ print(f"\n ⚠️ Extra {result['extra_count']} key(s) not in en-GB:")
+ if verbose or result['extra_count'] <= 20:
+ for key in result['extra_keys'][:50]:
+ print(f" - {key}")
+ if result['extra_count'] > 50:
+ print(f" ... and {result['extra_count'] - 50} more")
+ else:
+ print(f" (use --verbose to see all)")
+
+ print("-" * 100)
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description='Validate translation JSON structure'
+ )
+ parser.add_argument(
+ '--language',
+ help='Specific language code to validate (e.g., es-ES)',
+ default=None
+ )
+ parser.add_argument(
+ '--verbose', '-v',
+ action='store_true',
+ help='Show all missing/extra keys'
+ )
+ parser.add_argument(
+ '--json',
+ action='store_true',
+ help='Output results as JSON'
+ )
+
+ args = parser.parse_args()
+
+ # Define paths
+ locales_dir = Path('frontend/public/locales')
+ en_gb_path = locales_dir / 'en-GB' / 'translation.json'
+
+ if not en_gb_path.exists():
+ print(f"❌ Error: en-GB translation file not found at {en_gb_path}")
+ sys.exit(1)
+
+ # Validate en-GB itself
+ is_valid, message = validate_json_file(en_gb_path)
+ if not is_valid:
+ print(f"❌ Error in en-GB file: {message}")
+ sys.exit(1)
+
+ # Load en-GB structure
+ with open(en_gb_path, 'r', encoding='utf-8') as f:
+ en_gb = json.load(f)
+
+ en_gb_keys = get_all_keys(en_gb)
+
+ # Get list of languages to validate
+ if args.language:
+ languages = [args.language]
+ else:
+ languages = [
+ d.name for d in locales_dir.iterdir()
+ if d.is_dir() and d.name != 'en-GB' and (d / 'translation.json').exists()
+ ]
+
+ results = []
+ json_errors = []
+
+ # Validate each language
+ for lang_code in sorted(languages):
+ lang_path = locales_dir / lang_code / 'translation.json'
+
+ if not lang_path.exists():
+ print(f"⚠️ Warning: {lang_code}/translation.json not found, skipping")
+ continue
+
+ # First check if JSON is valid
+ is_valid, message = validate_json_file(lang_path)
+ if not is_valid:
+ json_errors.append({
+ 'language': lang_code,
+ 'file': str(lang_path),
+ 'error': message
+ })
+ continue
+
+ # Load and compare structure
+ with open(lang_path, 'r', encoding='utf-8') as f:
+ lang_data = json.load(f)
+
+ lang_keys = get_all_keys(lang_data)
+ result = validate_structure(en_gb_keys, lang_keys, lang_code)
+ results.append(result)
+
+ # Output results
+ if args.json:
+ output = {
+ 'json_errors': json_errors,
+ 'structure_validation': results
+ }
+ print(json.dumps(output, indent=2, ensure_ascii=False))
+ else:
+ # Print JSON errors first
+ if json_errors:
+ print("\n❌ JSON Syntax Errors:")
+ print("=" * 100)
+ for error in json_errors:
+ print(f"\nLanguage: {error['language']}")
+ print(f"File: {error['file']}")
+ print(f"Error: {error['error']}")
+ print("\n")
+
+ # Print structure validation results
+ if results:
+ print("\n📊 Structure Validation Summary:")
+ print(f" Languages validated: {len(results)}")
+
+ perfect = sum(1 for r in results if r['missing_count'] == 0 and r['extra_count'] == 0)
+ print(f" Perfect matches: {perfect}/{len(results)}")
+
+ total_missing = sum(r['missing_count'] for r in results)
+ total_extra = sum(r['extra_count'] for r in results)
+ print(f" Total missing keys: {total_missing}")
+ print(f" Total extra keys: {total_extra}")
+
+ for result in results:
+ print_validation_result(result, verbose=args.verbose)
+
+ if not json_errors and perfect == len(results):
+ print("\n✅ All translations have perfect structure!")
+
+ # Exit with error code if issues found
+ has_issues = len(json_errors) > 0 or any(
+ r['missing_count'] > 0 or r['extra_count'] > 0 for r in results
+ )
+ sys.exit(1 if has_issues else 0)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/scripts/translations/validate_placeholders.py b/scripts/translations/validate_placeholders.py
new file mode 100644
index 000000000..ff73755ce
--- /dev/null
+++ b/scripts/translations/validate_placeholders.py
@@ -0,0 +1,189 @@
+#!/usr/bin/env python3
+"""
+Validate that translation files have the same placeholders as en-GB (source of truth).
+
+Usage:
+ python scripts/translations/validate_placeholders.py [--language LANG] [--fix]
+
+ --language: Validate specific language (e.g., es-ES, de-DE)
+ --fix: Automatically remove extra placeholders (use with caution)
+"""
+
+import json
+import re
+import sys
+from pathlib import Path
+from typing import Dict, List, Set, Tuple
+import argparse
+
+
+def find_placeholders(text: str) -> Set[str]:
+ """Find all placeholders in text like {n}, {{var}}, {0}, etc."""
+ if not isinstance(text, str):
+ return set()
+ return set(re.findall(r'\{\{?[^}]+\}\}?', text))
+
+
+def flatten_dict(d: dict, parent_key: str = '', sep: str = '.') -> Dict[str, str]:
+ """Flatten nested dict to dot-notation keys."""
+ items = []
+ for k, v in d.items():
+ new_key = f"{parent_key}{sep}{k}" if parent_key else k
+ if isinstance(v, dict):
+ items.extend(flatten_dict(v, new_key, sep=sep).items())
+ else:
+ items.append((new_key, v))
+ return dict(items)
+
+
+def validate_language(
+ en_gb_flat: Dict[str, str],
+ lang_flat: Dict[str, str],
+ lang_code: str
+) -> List[Dict]:
+ """Validate placeholders for a language against en-GB."""
+ issues = []
+
+ for key in en_gb_flat:
+ if key not in lang_flat:
+ continue
+
+ en_placeholders = find_placeholders(en_gb_flat[key])
+ lang_placeholders = find_placeholders(lang_flat[key])
+
+ if en_placeholders != lang_placeholders:
+ missing = en_placeholders - lang_placeholders
+ extra = lang_placeholders - en_placeholders
+
+ issue = {
+ 'language': lang_code,
+ 'key': key,
+ 'missing': missing,
+ 'extra': extra,
+ 'en_text': en_gb_flat[key],
+ 'lang_text': lang_flat[key]
+ }
+ issues.append(issue)
+
+ return issues
+
+
+def print_issues(issues: List[Dict], verbose: bool = False):
+ """Print validation issues in a readable format."""
+ if not issues:
+ print("✅ No placeholder validation issues found!")
+ return
+
+ print(f"❌ Found {len(issues)} placeholder validation issue(s):\n")
+ print("=" * 100)
+
+ for i, issue in enumerate(issues, 1):
+ print(f"\n{i}. Language: {issue['language']}")
+ print(f" Key: {issue['key']}")
+
+ if issue['missing']:
+ print(f" ⚠️ MISSING placeholders: {issue['missing']}")
+ if issue['extra']:
+ print(f" ⚠️ EXTRA placeholders: {issue['extra']}")
+
+ if verbose:
+ print(f" EN-GB: {issue['en_text'][:150]}")
+ print(f" {issue['language']}: {issue['lang_text'][:150]}")
+
+ print("-" * 100)
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description='Validate translation placeholder consistency'
+ )
+ parser.add_argument(
+ '--language',
+ help='Specific language code to validate (e.g., es-ES)',
+ default=None
+ )
+ parser.add_argument(
+ '--verbose', '-v',
+ action='store_true',
+ help='Show full text samples for each issue'
+ )
+ parser.add_argument(
+ '--json',
+ action='store_true',
+ help='Output results as JSON'
+ )
+
+ args = parser.parse_args()
+
+ # Define paths
+ locales_dir = Path('frontend/public/locales')
+ en_gb_path = locales_dir / 'en-GB' / 'translation.json'
+
+ if not en_gb_path.exists():
+ print(f"❌ Error: en-GB translation file not found at {en_gb_path}")
+ sys.exit(1)
+
+ # Load en-GB (source of truth)
+ with open(en_gb_path, 'r', encoding='utf-8') as f:
+ en_gb = json.load(f)
+
+ en_gb_flat = flatten_dict(en_gb)
+
+ # Get list of languages to validate
+ if args.language:
+ languages = [args.language]
+ else:
+ # Validate all languages except en-GB
+ languages = [
+ d.name for d in locales_dir.iterdir()
+ if d.is_dir() and d.name != 'en-GB' and (d / 'translation.json').exists()
+ ]
+
+ all_issues = []
+
+ # Validate each language
+ for lang_code in sorted(languages):
+ lang_path = locales_dir / lang_code / 'translation.json'
+
+ if not lang_path.exists():
+ print(f"⚠️ Warning: {lang_code}/translation.json not found, skipping")
+ continue
+
+ with open(lang_path, 'r', encoding='utf-8') as f:
+ lang_data = json.load(f)
+
+ lang_flat = flatten_dict(lang_data)
+ issues = validate_language(en_gb_flat, lang_flat, lang_code)
+ all_issues.extend(issues)
+
+ # Output results
+ if args.json:
+ print(json.dumps(all_issues, indent=2, ensure_ascii=False))
+ else:
+ if all_issues:
+ # Group by language
+ by_language = {}
+ for issue in all_issues:
+ lang = issue['language']
+ if lang not in by_language:
+ by_language[lang] = []
+ by_language[lang].append(issue)
+
+ print(f"📊 Validation Summary:")
+ print(f" Total issues: {len(all_issues)}")
+ print(f" Languages with issues: {len(by_language)}\n")
+
+ for lang in sorted(by_language.keys()):
+ print(f"\n{'='*100}")
+ print(f"Language: {lang} ({len(by_language[lang])} issue(s))")
+ print(f"{'='*100}")
+ print_issues(by_language[lang], verbose=args.verbose)
+ else:
+ print("✅ All translations have correct placeholders!")
+
+ # Exit with error code if issues found
+ sys.exit(1 if all_issues else 0)
+
+
+if __name__ == '__main__':
+ main()