mirror of
https://github.com/kyantech/Palmr.git
synced 2025-11-02 13:03:15 +00:00
feat: implement translation management system and enhance localization support
- Added a new translation management system to automate synchronization, validation, and translation of internationalization files. - Introduced scripts for running translation operations, including checking status, synchronizing keys, and auto-translating strings. - Updated package.json with new translation-related commands for easier access. - Enhanced localization files across multiple languages with new keys and improved translations. - Integrated download functionality for share files in the UI, allowing users to download multiple files seamlessly. - Refactored components to support new download features and improved user experience.
This commit is contained in:
231
apps/web/scripts/check_translations.py
Executable file
231
apps/web/scripts/check_translations.py
Executable file
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to check translation status and identify strings that need translation.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Tuple
|
||||
import argparse
|
||||
|
||||
|
||||
def load_json_file(file_path: Path) -> Dict[str, Any]:
|
||||
"""Load a JSON file."""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
print(f"Error loading {file_path}: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def get_all_string_values(data: Dict[str, Any], prefix: str = '') -> List[Tuple[str, str]]:
|
||||
"""Extract all strings from nested JSON with their keys."""
|
||||
strings = []
|
||||
|
||||
for key, value in data.items():
|
||||
current_key = f"{prefix}.{key}" if prefix else key
|
||||
|
||||
if isinstance(value, str):
|
||||
strings.append((current_key, value))
|
||||
elif isinstance(value, dict):
|
||||
strings.extend(get_all_string_values(value, current_key))
|
||||
|
||||
return strings
|
||||
|
||||
|
||||
def check_untranslated_strings(file_path: Path) -> Tuple[int, int, List[str]]:
|
||||
"""Check for untranslated strings in a file."""
|
||||
data = load_json_file(file_path)
|
||||
if not data:
|
||||
return 0, 0, []
|
||||
|
||||
all_strings = get_all_string_values(data)
|
||||
untranslated = []
|
||||
|
||||
for key, value in all_strings:
|
||||
if value.startswith('[TO_TRANSLATE]'):
|
||||
untranslated.append(key)
|
||||
|
||||
return len(all_strings), len(untranslated), untranslated
|
||||
|
||||
|
||||
def compare_languages(reference_file: Path, target_file: Path) -> Dict[str, Any]:
|
||||
"""Compare two language files."""
|
||||
reference_data = load_json_file(reference_file)
|
||||
target_data = load_json_file(target_file)
|
||||
|
||||
if not reference_data or not target_data:
|
||||
return {}
|
||||
|
||||
reference_strings = dict(get_all_string_values(reference_data))
|
||||
target_strings = dict(get_all_string_values(target_data))
|
||||
|
||||
# Find common keys
|
||||
common_keys = set(reference_strings.keys()) & set(target_strings.keys())
|
||||
|
||||
# Check identical strings (possibly untranslated)
|
||||
identical_strings = []
|
||||
for key in common_keys:
|
||||
if reference_strings[key] == target_strings[key] and len(reference_strings[key]) > 3:
|
||||
identical_strings.append(key)
|
||||
|
||||
return {
|
||||
'total_reference': len(reference_strings),
|
||||
'total_target': len(target_strings),
|
||||
'common_keys': len(common_keys),
|
||||
'identical_strings': identical_strings
|
||||
}
|
||||
|
||||
|
||||
def generate_translation_report(messages_dir: Path, reference_file: str = 'en-US.json'):
|
||||
"""Generate complete translation report."""
|
||||
reference_path = messages_dir / reference_file
|
||||
if not reference_path.exists():
|
||||
print(f"Reference file not found: {reference_path}")
|
||||
return
|
||||
|
||||
# Load reference data
|
||||
reference_data = load_json_file(reference_path)
|
||||
reference_strings = dict(get_all_string_values(reference_data))
|
||||
total_reference_strings = len(reference_strings)
|
||||
|
||||
print(f"📊 TRANSLATION REPORT")
|
||||
print(f"Reference: {reference_file} ({total_reference_strings} strings)")
|
||||
print("=" * 80)
|
||||
|
||||
# Find all JSON files
|
||||
json_files = [f for f in messages_dir.glob('*.json') if f.name != reference_file]
|
||||
|
||||
if not json_files:
|
||||
print("No translation files found")
|
||||
return
|
||||
|
||||
reports = []
|
||||
|
||||
for json_file in sorted(json_files):
|
||||
total_strings, untranslated_count, untranslated_keys = check_untranslated_strings(json_file)
|
||||
comparison = compare_languages(reference_path, json_file)
|
||||
|
||||
# Calculate percentages
|
||||
completion_percentage = (total_strings / total_reference_strings) * 100 if total_reference_strings > 0 else 0
|
||||
untranslated_percentage = (untranslated_count / total_strings) * 100 if total_strings > 0 else 0
|
||||
|
||||
reports.append({
|
||||
'file': json_file.name,
|
||||
'total_strings': total_strings,
|
||||
'untranslated_count': untranslated_count,
|
||||
'untranslated_keys': untranslated_keys,
|
||||
'completion_percentage': completion_percentage,
|
||||
'untranslated_percentage': untranslated_percentage,
|
||||
'identical_strings': comparison.get('identical_strings', [])
|
||||
})
|
||||
|
||||
# Sort by completion percentage
|
||||
reports.sort(key=lambda x: x['completion_percentage'], reverse=True)
|
||||
|
||||
print(f"{'LANGUAGE':<15} {'COMPLETENESS':<12} {'STRINGS':<15} {'UNTRANSLATED':<15} {'POSSIBLE MATCHES'}")
|
||||
print("-" * 80)
|
||||
|
||||
for report in reports:
|
||||
language = report['file'].replace('.json', '')
|
||||
completion = f"{report['completion_percentage']:.1f}%"
|
||||
strings_info = f"{report['total_strings']}/{total_reference_strings}"
|
||||
untranslated_info = f"{report['untranslated_count']} ({report['untranslated_percentage']:.1f}%)"
|
||||
identical_count = len(report['identical_strings'])
|
||||
|
||||
# Choose icon based on completeness
|
||||
if report['completion_percentage'] >= 100:
|
||||
icon = "✅" if report['untranslated_count'] == 0 else "⚠️"
|
||||
elif report['completion_percentage'] >= 90:
|
||||
icon = "🟡"
|
||||
else:
|
||||
icon = "🔴"
|
||||
|
||||
print(f"{icon} {language:<13} {completion:<12} {strings_info:<15} {untranslated_info:<15} {identical_count}")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
|
||||
# Show details of problematic files
|
||||
problematic_files = [r for r in reports if r['untranslated_count'] > 0 or r['completion_percentage'] < 100]
|
||||
|
||||
if problematic_files:
|
||||
print("📋 DETAILS OF FILES THAT NEED ATTENTION:")
|
||||
print()
|
||||
|
||||
for report in problematic_files:
|
||||
language = report['file'].replace('.json', '')
|
||||
print(f"🔍 {language.upper()}:")
|
||||
|
||||
if report['completion_percentage'] < 100:
|
||||
missing_count = total_reference_strings - report['total_strings']
|
||||
print(f" • Missing {missing_count} strings ({100 - report['completion_percentage']:.1f}%)")
|
||||
|
||||
if report['untranslated_count'] > 0:
|
||||
print(f" • {report['untranslated_count']} strings marked as [TO_TRANSLATE]")
|
||||
|
||||
if report['untranslated_count'] <= 10:
|
||||
print(" • Untranslated keys:")
|
||||
for key in report['untranslated_keys']:
|
||||
print(f" - {key}")
|
||||
else:
|
||||
print(" • First 10 untranslated keys:")
|
||||
for key in report['untranslated_keys'][:10]:
|
||||
print(f" - {key}")
|
||||
print(f" ... and {report['untranslated_count'] - 10} more")
|
||||
|
||||
if report['identical_strings']:
|
||||
identical_count = len(report['identical_strings'])
|
||||
print(f" • {identical_count} strings identical to English (possibly untranslated)")
|
||||
|
||||
if identical_count <= 5:
|
||||
for key in report['identical_strings']:
|
||||
value = reference_strings.get(key, '')[:50]
|
||||
print(f" - {key}: \"{value}...\"")
|
||||
else:
|
||||
for key in report['identical_strings'][:5]:
|
||||
value = reference_strings.get(key, '')[:50]
|
||||
print(f" - {key}: \"{value}...\"")
|
||||
print(f" ... and {identical_count - 5} more")
|
||||
|
||||
print()
|
||||
|
||||
else:
|
||||
print("🎉 All translations are complete!")
|
||||
|
||||
print("=" * 80)
|
||||
print("💡 TIPS:")
|
||||
print("• Use 'python3 sync_translations.py --dry-run' to see what would be added")
|
||||
print("• Use 'python3 sync_translations.py' to synchronize all translations")
|
||||
print("• Strings marked with [TO_TRANSLATE] need manual translation")
|
||||
print("• Strings identical to English may need translation")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Check translation status and identify strings that need translation'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--messages-dir',
|
||||
type=Path,
|
||||
default=Path(__file__).parent.parent / 'messages',
|
||||
help='Directory containing message files (default: ../messages)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--reference',
|
||||
default='en-US.json',
|
||||
help='Reference file (default: en-US.json)'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.messages_dir.exists():
|
||||
print(f"Directory not found: {args.messages_dir}")
|
||||
return 1
|
||||
|
||||
generate_translation_report(args.messages_dir, args.reference)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(main())
|
||||
144
apps/web/scripts/run_translations.py
Executable file
144
apps/web/scripts/run_translations.py
Executable file
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Main script to run all Palmr translation operations.
|
||||
Makes it easy to run scripts without remembering specific names.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
|
||||
|
||||
def run_command(script_name: str, args: list) -> int:
|
||||
"""Execute a script with the provided arguments."""
|
||||
script_path = Path(__file__).parent / script_name
|
||||
cmd = [sys.executable, str(script_path)] + args
|
||||
return subprocess.run(cmd).returncode
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Main script to manage Palmr translations',
|
||||
epilog='Examples:\n'
|
||||
' python3 run_translations.py check\n'
|
||||
' python3 run_translations.py sync --dry-run\n'
|
||||
' python3 run_translations.py translate --delay 2.0\n'
|
||||
' python3 run_translations.py all # Complete workflow\n',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'command',
|
||||
choices=['check', 'sync', 'translate', 'all', 'help'],
|
||||
help='Command to execute:\n'
|
||||
'check - Check translation status\n'
|
||||
'sync - Synchronize missing keys\n'
|
||||
'translate - Automatically translate strings\n'
|
||||
'all - Run complete workflow\n'
|
||||
'help - Show detailed help'
|
||||
)
|
||||
|
||||
# Capture remaining arguments to pass to scripts
|
||||
args, remaining_args = parser.parse_known_args()
|
||||
|
||||
if args.command == 'help':
|
||||
print("🌍 PALMR TRANSLATION MANAGER")
|
||||
print("=" * 50)
|
||||
print()
|
||||
print("📋 AVAILABLE COMMANDS:")
|
||||
print()
|
||||
print("🔍 check - Check translation status")
|
||||
print(" python3 run_translations.py check")
|
||||
print(" python3 run_translations.py check --reference pt-BR.json")
|
||||
print()
|
||||
print("🔄 sync - Synchronize missing keys")
|
||||
print(" python3 run_translations.py sync")
|
||||
print(" python3 run_translations.py sync --dry-run")
|
||||
print(" python3 run_translations.py sync --no-mark-untranslated")
|
||||
print()
|
||||
print("🌐 translate - Automatically translate")
|
||||
print(" python3 run_translations.py translate")
|
||||
print(" python3 run_translations.py translate --dry-run")
|
||||
print(" python3 run_translations.py translate --delay 2.0")
|
||||
print(" python3 run_translations.py translate --skip-languages pt-BR.json")
|
||||
print()
|
||||
print("⚡ all - Complete workflow (sync + translate)")
|
||||
print(" python3 run_translations.py all")
|
||||
print(" python3 run_translations.py all --dry-run")
|
||||
print()
|
||||
print("📁 STRUCTURE:")
|
||||
print(" apps/web/scripts/ - Management scripts")
|
||||
print(" apps/web/messages/ - Translation files")
|
||||
print()
|
||||
print("💡 TIPS:")
|
||||
print("• Use --dry-run on any command to test")
|
||||
print("• Use --help on any command for specific options")
|
||||
print("• Read https://docs.palmr.dev/docs/3.0-beta/translation-management for complete documentation")
|
||||
return 0
|
||||
|
||||
elif args.command == 'check':
|
||||
print("🔍 Checking translation status...")
|
||||
return run_command('check_translations.py', remaining_args)
|
||||
|
||||
elif args.command == 'sync':
|
||||
print("🔄 Synchronizing translation keys...")
|
||||
return run_command('sync_translations.py', remaining_args)
|
||||
|
||||
elif args.command == 'translate':
|
||||
print("🌐 Automatically translating strings...")
|
||||
return run_command('translate_missing.py', remaining_args)
|
||||
|
||||
elif args.command == 'all':
|
||||
print("⚡ Running complete translation workflow...")
|
||||
print()
|
||||
|
||||
# Determine if it's dry-run based on arguments
|
||||
is_dry_run = '--dry-run' in remaining_args
|
||||
|
||||
# 1. Initial check
|
||||
print("1️⃣ Checking initial status...")
|
||||
result = run_command('check_translations.py', remaining_args)
|
||||
if result != 0:
|
||||
print("❌ Error in initial check")
|
||||
return result
|
||||
|
||||
print("\n" + "="*50)
|
||||
|
||||
# 2. Sync
|
||||
print("2️⃣ Synchronizing missing keys...")
|
||||
result = run_command('sync_translations.py', remaining_args)
|
||||
if result != 0:
|
||||
print("❌ Error in synchronization")
|
||||
return result
|
||||
|
||||
if not is_dry_run:
|
||||
print("\n" + "="*50)
|
||||
|
||||
# 3. Translate
|
||||
print("3️⃣ Automatically translating strings...")
|
||||
result = run_command('translate_missing.py', remaining_args)
|
||||
if result != 0:
|
||||
print("❌ Error in translation")
|
||||
return result
|
||||
|
||||
print("\n" + "="*50)
|
||||
|
||||
# 4. Final check
|
||||
print("4️⃣ Final check...")
|
||||
result = run_command('check_translations.py', remaining_args)
|
||||
if result != 0:
|
||||
print("❌ Error in final check")
|
||||
return result
|
||||
|
||||
print("\n🎉 Complete workflow executed successfully!")
|
||||
if is_dry_run:
|
||||
print("💡 Run without --dry-run to apply changes")
|
||||
|
||||
return 0
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(main())
|
||||
267
apps/web/scripts/sync_translations.py
Executable file
267
apps/web/scripts/sync_translations.py
Executable file
@@ -0,0 +1,267 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to synchronize translations using en-US.json as reference.
|
||||
Adds missing keys to other language files.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Set, List
|
||||
import argparse
|
||||
|
||||
|
||||
def load_json_file(file_path: Path) -> Dict[str, Any]:
|
||||
"""Load a JSON file."""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
print(f"Error loading {file_path}: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def save_json_file(file_path: Path, data: Dict[str, Any], indent: int = 2) -> bool:
|
||||
"""Save a JSON file with consistent formatting."""
|
||||
try:
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=indent, separators=(',', ': '))
|
||||
f.write('\n') # Add newline at the end
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error saving {file_path}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_all_keys(data: Dict[str, Any], prefix: str = '') -> Set[str]:
|
||||
"""Extract all keys from nested JSON recursively."""
|
||||
keys = set()
|
||||
|
||||
for key, value in data.items():
|
||||
current_key = f"{prefix}.{key}" if prefix else key
|
||||
keys.add(current_key)
|
||||
|
||||
if isinstance(value, dict):
|
||||
keys.update(get_all_keys(value, current_key))
|
||||
|
||||
return keys
|
||||
|
||||
|
||||
def get_nested_value(data: Dict[str, Any], key_path: str) -> Any:
|
||||
"""Get a nested value using a key with dots as separator."""
|
||||
keys = key_path.split('.')
|
||||
current = data
|
||||
|
||||
for key in keys:
|
||||
if isinstance(current, dict) and key in current:
|
||||
current = current[key]
|
||||
else:
|
||||
return None
|
||||
|
||||
return current
|
||||
|
||||
|
||||
def set_nested_value(data: Dict[str, Any], key_path: str, value: Any) -> None:
|
||||
"""Set a nested value using a key with dots as separator."""
|
||||
keys = key_path.split('.')
|
||||
current = data
|
||||
|
||||
# Navigate to the second-to-last level, creating dictionaries as needed
|
||||
for key in keys[:-1]:
|
||||
if key not in current:
|
||||
current[key] = {}
|
||||
elif not isinstance(current[key], dict):
|
||||
current[key] = {}
|
||||
current = current[key]
|
||||
|
||||
# Set the value at the last level
|
||||
current[keys[-1]] = value
|
||||
|
||||
|
||||
def find_missing_keys(reference_data: Dict[str, Any], target_data: Dict[str, Any]) -> List[str]:
|
||||
"""Find keys that are in reference but not in target."""
|
||||
reference_keys = get_all_keys(reference_data)
|
||||
target_keys = get_all_keys(target_data)
|
||||
|
||||
missing_keys = reference_keys - target_keys
|
||||
return sorted(list(missing_keys))
|
||||
|
||||
|
||||
def add_missing_keys(reference_data: Dict[str, Any], target_data: Dict[str, Any],
|
||||
missing_keys: List[str], mark_as_untranslated: bool = True) -> Dict[str, Any]:
|
||||
"""Add missing keys to target_data using reference values."""
|
||||
updated_data = target_data.copy()
|
||||
|
||||
for key_path in missing_keys:
|
||||
reference_value = get_nested_value(reference_data, key_path)
|
||||
|
||||
if reference_value is not None:
|
||||
# If marking as untranslated, add prefix
|
||||
if mark_as_untranslated and isinstance(reference_value, str):
|
||||
translated_value = f"[TO_TRANSLATE] {reference_value}"
|
||||
else:
|
||||
translated_value = reference_value
|
||||
|
||||
set_nested_value(updated_data, key_path, translated_value)
|
||||
|
||||
return updated_data
|
||||
|
||||
|
||||
def sync_translations(messages_dir: Path, reference_file: str = 'en-US.json',
|
||||
mark_as_untranslated: bool = True, dry_run: bool = False) -> None:
|
||||
"""Synchronize all translations using a reference file."""
|
||||
|
||||
# Load reference file
|
||||
reference_path = messages_dir / reference_file
|
||||
if not reference_path.exists():
|
||||
print(f"Reference file not found: {reference_path}")
|
||||
return
|
||||
|
||||
print(f"Loading reference file: {reference_file}")
|
||||
reference_data = load_json_file(reference_path)
|
||||
if not reference_data:
|
||||
print("Error loading reference file")
|
||||
return
|
||||
|
||||
# Find all JSON files in the folder
|
||||
json_files = [f for f in messages_dir.glob('*.json') if f.name != reference_file]
|
||||
|
||||
if not json_files:
|
||||
print("No translation files found")
|
||||
return
|
||||
|
||||
total_keys_reference = len(get_all_keys(reference_data))
|
||||
print(f"Reference file contains {total_keys_reference} keys")
|
||||
print(f"Processing {len(json_files)} translation files...\n")
|
||||
|
||||
summary = []
|
||||
|
||||
for json_file in sorted(json_files):
|
||||
print(f"Processing: {json_file.name}")
|
||||
|
||||
# Load translation file
|
||||
translation_data = load_json_file(json_file)
|
||||
if not translation_data:
|
||||
print(f" ❌ Error loading {json_file.name}")
|
||||
continue
|
||||
|
||||
# Find missing keys
|
||||
missing_keys = find_missing_keys(reference_data, translation_data)
|
||||
current_keys = len(get_all_keys(translation_data))
|
||||
|
||||
if not missing_keys:
|
||||
print(f" ✅ Complete ({current_keys}/{total_keys_reference} keys)")
|
||||
summary.append({
|
||||
'file': json_file.name,
|
||||
'status': 'complete',
|
||||
'missing': 0,
|
||||
'total': current_keys
|
||||
})
|
||||
continue
|
||||
|
||||
print(f" 🔍 Found {len(missing_keys)} missing keys")
|
||||
|
||||
if dry_run:
|
||||
print(f" 📝 [DRY RUN] Keys that would be added:")
|
||||
for key in missing_keys[:5]: # Show only first 5
|
||||
print(f" - {key}")
|
||||
if len(missing_keys) > 5:
|
||||
print(f" ... and {len(missing_keys) - 5} more")
|
||||
else:
|
||||
# Add missing keys
|
||||
updated_data = add_missing_keys(reference_data, translation_data,
|
||||
missing_keys, mark_as_untranslated)
|
||||
|
||||
# Save updated file
|
||||
if save_json_file(json_file, updated_data):
|
||||
print(f" ✅ Updated successfully ({current_keys + len(missing_keys)}/{total_keys_reference} keys)")
|
||||
summary.append({
|
||||
'file': json_file.name,
|
||||
'status': 'updated',
|
||||
'missing': len(missing_keys),
|
||||
'total': current_keys + len(missing_keys)
|
||||
})
|
||||
else:
|
||||
print(f" ❌ Error saving {json_file.name}")
|
||||
summary.append({
|
||||
'file': json_file.name,
|
||||
'status': 'error',
|
||||
'missing': len(missing_keys),
|
||||
'total': current_keys
|
||||
})
|
||||
|
||||
print()
|
||||
|
||||
# Show summary
|
||||
print("=" * 60)
|
||||
print("SUMMARY")
|
||||
print("=" * 60)
|
||||
|
||||
if dry_run:
|
||||
print("🔍 DRY RUN MODE - No changes were made\n")
|
||||
|
||||
for item in summary:
|
||||
status_icon = {
|
||||
'complete': '✅',
|
||||
'updated': '🔄',
|
||||
'error': '❌'
|
||||
}.get(item['status'], '❓')
|
||||
|
||||
print(f"{status_icon} {item['file']:<15} - {item['total']}/{total_keys_reference} keys", end='')
|
||||
|
||||
if item['missing'] > 0:
|
||||
print(f" (+{item['missing']} added)" if item['status'] == 'updated' else f" ({item['missing']} missing)")
|
||||
else:
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Synchronize translations using en-US.json as reference'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--messages-dir',
|
||||
type=Path,
|
||||
default=Path(__file__).parent.parent / 'messages',
|
||||
help='Directory containing message files (default: ../messages)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--reference',
|
||||
default='en-US.json',
|
||||
help='Reference file (default: en-US.json)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no-mark-untranslated',
|
||||
action='store_true',
|
||||
help='Don\'t mark added keys as [TO_TRANSLATE]'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Only show what would be changed without making modifications'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.messages_dir.exists():
|
||||
print(f"Directory not found: {args.messages_dir}")
|
||||
return 1
|
||||
|
||||
print(f"Directory: {args.messages_dir}")
|
||||
print(f"Reference: {args.reference}")
|
||||
print(f"Mark untranslated: {not args.no_mark_untranslated}")
|
||||
print(f"Dry run: {args.dry_run}")
|
||||
print("-" * 60)
|
||||
|
||||
sync_translations(
|
||||
messages_dir=args.messages_dir,
|
||||
reference_file=args.reference,
|
||||
mark_as_untranslated=not args.no_mark_untranslated,
|
||||
dry_run=args.dry_run
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(main())
|
||||
342
apps/web/scripts/translate_missing.py
Executable file
342
apps/web/scripts/translate_missing.py
Executable file
@@ -0,0 +1,342 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to automatically translate strings marked with [TO_TRANSLATE]
|
||||
using free Google Translate.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Tuple, Optional
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
# Language code mapping from file names to Google Translate codes
|
||||
LANGUAGE_MAPPING = {
|
||||
'pt-BR.json': 'pt', # Portuguese (Brazil) -> Portuguese
|
||||
'es-ES.json': 'es', # Spanish (Spain) -> Spanish
|
||||
'fr-FR.json': 'fr', # French (France) -> French
|
||||
'de-DE.json': 'de', # German -> German
|
||||
'it-IT.json': 'it', # Italian -> Italian
|
||||
'ru-RU.json': 'ru', # Russian -> Russian
|
||||
'ja-JP.json': 'ja', # Japanese -> Japanese
|
||||
'ko-KR.json': 'ko', # Korean -> Korean
|
||||
'zh-CN.json': 'zh-cn', # Chinese (Simplified) -> Simplified Chinese
|
||||
'ar-SA.json': 'ar', # Arabic -> Arabic
|
||||
'hi-IN.json': 'hi', # Hindi -> Hindi
|
||||
'nl-NL.json': 'nl', # Dutch -> Dutch
|
||||
'tr-TR.json': 'tr', # Turkish -> Turkish
|
||||
'pl-PL.json': 'pl', # Polish -> Polish
|
||||
}
|
||||
|
||||
# Prefix to identify untranslated strings
|
||||
TO_TRANSLATE_PREFIX = '[TO_TRANSLATE] '
|
||||
|
||||
|
||||
def load_json_file(file_path: Path) -> Dict[str, Any]:
|
||||
"""Load a JSON file."""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
print(f"❌ Error loading {file_path}: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def save_json_file(file_path: Path, data: Dict[str, Any], indent: int = 2) -> bool:
|
||||
"""Save a JSON file with consistent formatting."""
|
||||
try:
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=indent, separators=(',', ': '))
|
||||
f.write('\n') # Add newline at the end
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ Error saving {file_path}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_nested_value(data: Dict[str, Any], key_path: str) -> Any:
|
||||
"""Get a nested value using a key with dots as separator."""
|
||||
keys = key_path.split('.')
|
||||
current = data
|
||||
|
||||
for key in keys:
|
||||
if isinstance(current, dict) and key in current:
|
||||
current = current[key]
|
||||
else:
|
||||
return None
|
||||
|
||||
return current
|
||||
|
||||
|
||||
def set_nested_value(data: Dict[str, Any], key_path: str, value: Any) -> None:
|
||||
"""Set a nested value using a key with dots as separator."""
|
||||
keys = key_path.split('.')
|
||||
current = data
|
||||
|
||||
# Navigate to the second-to-last level, creating dictionaries as needed
|
||||
for key in keys[:-1]:
|
||||
if key not in current:
|
||||
current[key] = {}
|
||||
elif not isinstance(current[key], dict):
|
||||
current[key] = {}
|
||||
current = current[key]
|
||||
|
||||
# Set the value at the last level
|
||||
current[keys[-1]] = value
|
||||
|
||||
|
||||
def find_untranslated_strings(data: Dict[str, Any], prefix: str = '') -> List[Tuple[str, str]]:
|
||||
"""Find all strings marked with [TO_TRANSLATE] recursively."""
|
||||
untranslated = []
|
||||
|
||||
for key, value in data.items():
|
||||
current_key = f"{prefix}.{key}" if prefix else key
|
||||
|
||||
if isinstance(value, str) and value.startswith(TO_TRANSLATE_PREFIX):
|
||||
# Remove prefix to get original text
|
||||
original_text = value[len(TO_TRANSLATE_PREFIX):].strip()
|
||||
untranslated.append((current_key, original_text))
|
||||
elif isinstance(value, dict):
|
||||
untranslated.extend(find_untranslated_strings(value, current_key))
|
||||
|
||||
return untranslated
|
||||
|
||||
|
||||
def install_googletrans():
|
||||
"""Install the googletrans library if not available."""
|
||||
try:
|
||||
import googletrans
|
||||
return True
|
||||
except ImportError:
|
||||
print("📦 'googletrans' library not found. Attempting to install...")
|
||||
import subprocess
|
||||
try:
|
||||
subprocess.check_call([sys.executable, "-m", "pip", "install", "googletrans==4.0.0rc1"])
|
||||
print("✅ googletrans installed successfully!")
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
print("❌ Failed to install googletrans. Install manually with:")
|
||||
print("pip install googletrans==4.0.0rc1")
|
||||
return False
|
||||
|
||||
|
||||
def translate_text(text: str, target_language: str, max_retries: int = 3) -> Optional[str]:
|
||||
"""Translate text using free Google Translate."""
|
||||
try:
|
||||
from googletrans import Translator
|
||||
|
||||
translator = Translator()
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Translate from English to target language
|
||||
result = translator.translate(text, src='en', dest=target_language)
|
||||
|
||||
if result and result.text:
|
||||
return result.text.strip()
|
||||
|
||||
except Exception as e:
|
||||
if attempt < max_retries - 1:
|
||||
print(f" ⚠️ Attempt {attempt + 1} failed: {str(e)[:50]}... Retrying in 2s...")
|
||||
time.sleep(2)
|
||||
else:
|
||||
print(f" ❌ Failed after {max_retries} attempts: {str(e)[:50]}...")
|
||||
|
||||
return None
|
||||
|
||||
except ImportError:
|
||||
print("❌ googletrans library not available")
|
||||
return None
|
||||
|
||||
|
||||
def translate_file(file_path: Path, target_language: str, dry_run: bool = False,
|
||||
delay_between_requests: float = 1.0) -> Tuple[int, int, int]:
|
||||
"""
|
||||
Translate all [TO_TRANSLATE] strings in a file.
|
||||
Returns: (total_found, successful_translations, failed_translations)
|
||||
"""
|
||||
print(f"🔍 Processing: {file_path.name}")
|
||||
|
||||
# Load file
|
||||
data = load_json_file(file_path)
|
||||
if not data:
|
||||
return 0, 0, 0
|
||||
|
||||
# Find untranslated strings
|
||||
untranslated_strings = find_untranslated_strings(data)
|
||||
|
||||
if not untranslated_strings:
|
||||
print(f" ✅ No strings to translate")
|
||||
return 0, 0, 0
|
||||
|
||||
print(f" 📝 Found {len(untranslated_strings)} strings to translate")
|
||||
|
||||
if dry_run:
|
||||
print(f" 🔍 [DRY RUN] Strings that would be translated:")
|
||||
for key, text in untranslated_strings[:3]:
|
||||
print(f" - {key}: \"{text[:50]}{'...' if len(text) > 50 else ''}\"")
|
||||
if len(untranslated_strings) > 3:
|
||||
print(f" ... and {len(untranslated_strings) - 3} more")
|
||||
return len(untranslated_strings), 0, 0
|
||||
|
||||
# Translate each string
|
||||
successful = 0
|
||||
failed = 0
|
||||
updated_data = data.copy()
|
||||
|
||||
for i, (key_path, original_text) in enumerate(untranslated_strings, 1):
|
||||
print(f" 📍 ({i}/{len(untranslated_strings)}) Translating: {key_path}")
|
||||
|
||||
# Translate text
|
||||
translated_text = translate_text(original_text, target_language)
|
||||
|
||||
if translated_text and translated_text != original_text:
|
||||
# Update in dictionary
|
||||
set_nested_value(updated_data, key_path, translated_text)
|
||||
successful += 1
|
||||
print(f" ✅ \"{original_text[:30]}...\" → \"{translated_text[:30]}...\"")
|
||||
else:
|
||||
failed += 1
|
||||
print(f" ❌ Translation failed")
|
||||
|
||||
# Delay between requests to avoid rate limiting
|
||||
if i < len(untranslated_strings): # Don't wait after the last one
|
||||
time.sleep(delay_between_requests)
|
||||
|
||||
# Save updated file
|
||||
if successful > 0:
|
||||
if save_json_file(file_path, updated_data):
|
||||
print(f" 💾 File saved with {successful} translations")
|
||||
else:
|
||||
print(f" ❌ Error saving file")
|
||||
failed += successful # Count as failure if couldn't save
|
||||
successful = 0
|
||||
|
||||
return len(untranslated_strings), successful, failed
|
||||
|
||||
|
||||
def translate_all_files(messages_dir: Path, delay_between_requests: float = 1.0,
|
||||
dry_run: bool = False, skip_languages: List[str] = None) -> None:
|
||||
"""Translate all language files that have [TO_TRANSLATE] strings."""
|
||||
|
||||
if not install_googletrans():
|
||||
return
|
||||
|
||||
skip_languages = skip_languages or []
|
||||
|
||||
# Find language JSON files
|
||||
language_files = []
|
||||
for file_name, lang_code in LANGUAGE_MAPPING.items():
|
||||
file_path = messages_dir / file_name
|
||||
if file_path.exists() and file_name not in skip_languages:
|
||||
language_files.append((file_path, lang_code))
|
||||
|
||||
if not language_files:
|
||||
print("❌ No language files found")
|
||||
return
|
||||
|
||||
print(f"🌍 Translating {len(language_files)} languages...")
|
||||
print(f"⏱️ Delay between requests: {delay_between_requests}s")
|
||||
if dry_run:
|
||||
print("🔍 DRY RUN MODE - No changes will be made")
|
||||
print("-" * 60)
|
||||
|
||||
total_found = 0
|
||||
total_successful = 0
|
||||
total_failed = 0
|
||||
|
||||
for i, (file_path, lang_code) in enumerate(language_files, 1):
|
||||
print(f"\n[{i}/{len(language_files)}] 🌐 Language: {lang_code.upper()}")
|
||||
|
||||
found, successful, failed = translate_file(
|
||||
file_path, lang_code, dry_run, delay_between_requests
|
||||
)
|
||||
|
||||
total_found += found
|
||||
total_successful += successful
|
||||
total_failed += failed
|
||||
|
||||
# Pause between files (except the last one)
|
||||
if i < len(language_files) and not dry_run:
|
||||
print(f" ⏸️ Pausing {delay_between_requests * 2}s before next language...")
|
||||
time.sleep(delay_between_requests * 2)
|
||||
|
||||
# Final summary
|
||||
print("\n" + "=" * 60)
|
||||
print("📊 FINAL SUMMARY")
|
||||
print("=" * 60)
|
||||
|
||||
if dry_run:
|
||||
print(f"🔍 DRY RUN MODE:")
|
||||
print(f" • {total_found} strings would be translated")
|
||||
else:
|
||||
print(f"✅ Translations performed:")
|
||||
print(f" • {total_successful} successes")
|
||||
print(f" • {total_failed} failures")
|
||||
print(f" • {total_found} total processed")
|
||||
|
||||
if total_successful > 0:
|
||||
success_rate = (total_successful / total_found) * 100
|
||||
print(f" • Success rate: {success_rate:.1f}%")
|
||||
|
||||
print("\n💡 TIPS:")
|
||||
print("• Run 'python3 check_translations.py' to verify results")
|
||||
print("• Strings that failed translation keep the [TO_TRANSLATE] prefix")
|
||||
print("• Consider reviewing automatic translations to ensure quality")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Automatically translate strings marked with [TO_TRANSLATE]'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--messages-dir',
|
||||
type=Path,
|
||||
default=Path(__file__).parent.parent / 'messages',
|
||||
help='Directory containing message files (default: ../messages)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Only show what would be translated without making changes'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--delay',
|
||||
type=float,
|
||||
default=1.0,
|
||||
help='Delay in seconds between translation requests (default: 1.0)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--skip-languages',
|
||||
nargs='*',
|
||||
default=[],
|
||||
help='List of languages to skip (ex: pt-BR.json fr-FR.json)'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.messages_dir.exists():
|
||||
print(f"❌ Directory not found: {args.messages_dir}")
|
||||
return 1
|
||||
|
||||
print(f"📁 Directory: {args.messages_dir}")
|
||||
print(f"🔍 Dry run: {args.dry_run}")
|
||||
print(f"⏱️ Delay: {args.delay}s")
|
||||
if args.skip_languages:
|
||||
print(f"⏭️ Skipping: {', '.join(args.skip_languages)}")
|
||||
print("-" * 60)
|
||||
|
||||
translate_all_files(
|
||||
messages_dir=args.messages_dir,
|
||||
delay_between_requests=args.delay,
|
||||
dry_run=args.dry_run,
|
||||
skip_languages=args.skip_languages
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(main())
|
||||
Reference in New Issue
Block a user