Translate 200 strings to German, Spanish, French, Japanese, Korean, and Brazilian Portuguese, bringing localization coverage from 35% to 95%. Also adds translation helper script for future localization work. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
213 lines
6.6 KiB
Python
Executable File
213 lines
6.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Translate missing strings in Localizable.xcstrings to all target languages.
|
|
Uses Google Translate (free, no API key needed).
|
|
|
|
Usage:
|
|
pip install deep-translator
|
|
python3 scripts/translate_xcstrings.py
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
try:
|
|
from deep_translator import GoogleTranslator
|
|
except ImportError:
|
|
print("Please install deep-translator: pip install deep-translator")
|
|
sys.exit(1)
|
|
|
|
XCSTRINGS_PATH = Path(__file__).parent.parent / "Feels" / "Localizable.xcstrings"
|
|
TARGET_LANGUAGES = {
|
|
"de": "german",
|
|
"es": "spanish",
|
|
"fr": "french",
|
|
"ja": "japanese",
|
|
"ko": "korean",
|
|
"pt-BR": "portuguese"
|
|
}
|
|
|
|
LANG_DISPLAY = {
|
|
"de": "German",
|
|
"es": "Spanish",
|
|
"fr": "French",
|
|
"ja": "Japanese",
|
|
"ko": "Korean",
|
|
"pt-BR": "Brazilian Portuguese"
|
|
}
|
|
|
|
# Strings that should not be translated
|
|
SKIP_PATTERNS = [
|
|
"", " ", " ", " ", "-", "—", "·", "•", ">", "§",
|
|
"12", "17", "20",
|
|
]
|
|
|
|
|
|
def get_english_value(string_data: dict, key: str) -> str | None:
|
|
"""Extract the English value for a string."""
|
|
localizations = string_data.get("localizations", {})
|
|
if "en" in localizations:
|
|
en_loc = localizations["en"]
|
|
if "stringUnit" in en_loc:
|
|
return en_loc["stringUnit"].get("value")
|
|
return key
|
|
|
|
|
|
def needs_translation(string_data: dict, lang: str) -> bool:
|
|
"""Check if a string needs translation for a given language."""
|
|
localizations = string_data.get("localizations", {})
|
|
if lang not in localizations:
|
|
return True
|
|
lang_data = localizations[lang]
|
|
if "stringUnit" not in lang_data:
|
|
return True
|
|
state = lang_data["stringUnit"].get("state", "")
|
|
return state not in ["translated"]
|
|
|
|
|
|
def should_skip(key: str, english_value: str) -> bool:
|
|
"""Check if a string should be skipped."""
|
|
if not key.strip() or not english_value.strip():
|
|
return True
|
|
if key in SKIP_PATTERNS or english_value in SKIP_PATTERNS:
|
|
return True
|
|
stripped = english_value.strip()
|
|
if stripped.startswith("%") and len(stripped) <= 10 and " " not in stripped:
|
|
return True
|
|
if "http://" in english_value or "https://" in english_value:
|
|
return True
|
|
return False
|
|
|
|
|
|
def protect_format_specifiers(text: str) -> tuple[str, dict]:
|
|
"""Replace format specifiers with placeholders to protect them during translation."""
|
|
placeholders = {}
|
|
counter = [0]
|
|
|
|
def replace(match):
|
|
placeholder = f"__FMT{counter[0]}__"
|
|
placeholders[placeholder] = match.group(0)
|
|
counter[0] += 1
|
|
return placeholder
|
|
|
|
# Match format specifiers: %@, %lld, %1$@, %2$lld, %.0f%%, etc.
|
|
pattern = r'%(\d+\$)?[-+0 #]*(\d+)?(\.\d+)?(lld|ld|d|@|f|s|%%)'
|
|
protected = re.sub(pattern, replace, text)
|
|
return protected, placeholders
|
|
|
|
|
|
def restore_format_specifiers(text: str, placeholders: dict) -> str:
|
|
"""Restore format specifiers from placeholders."""
|
|
for placeholder, original in placeholders.items():
|
|
text = text.replace(placeholder, original)
|
|
return text
|
|
|
|
|
|
def translate_string(text: str, target_lang: str) -> str | None:
|
|
"""Translate a single string, preserving format specifiers."""
|
|
if not text.strip():
|
|
return text
|
|
|
|
# Protect format specifiers
|
|
protected, placeholders = protect_format_specifiers(text)
|
|
|
|
try:
|
|
translator = GoogleTranslator(source='en', target=target_lang)
|
|
translated = translator.translate(protected)
|
|
|
|
if translated:
|
|
# Restore format specifiers
|
|
result = restore_format_specifiers(translated, placeholders)
|
|
return result
|
|
return None
|
|
except Exception as e:
|
|
print(f" Translation error: {e}")
|
|
return None
|
|
|
|
|
|
def main():
|
|
print(f"Loading {XCSTRINGS_PATH}...")
|
|
with open(XCSTRINGS_PATH, "r") as f:
|
|
data = json.load(f)
|
|
|
|
strings = data.get("strings", {})
|
|
print(f"Found {len(strings)} total strings")
|
|
|
|
# Find strings needing translation
|
|
needs_work = []
|
|
for key, string_data in strings.items():
|
|
english_value = get_english_value(string_data, key)
|
|
if not english_value or should_skip(key, english_value):
|
|
continue
|
|
missing_langs = [
|
|
lang for lang in TARGET_LANGUAGES.keys()
|
|
if needs_translation(string_data, lang)
|
|
]
|
|
if missing_langs:
|
|
needs_work.append((key, english_value, missing_langs))
|
|
|
|
print(f"Found {len(needs_work)} strings needing translation")
|
|
total_translations = sum(len(ml) for _, _, ml in needs_work)
|
|
print(f"Total translations needed: {total_translations}")
|
|
|
|
if not needs_work:
|
|
print("All strings are already translated!")
|
|
return
|
|
|
|
total_translated = 0
|
|
|
|
for i, (key, english, missing_langs) in enumerate(needs_work):
|
|
print(f"\n[{i+1}/{len(needs_work)}] \"{english[:50]}{'...' if len(english) > 50 else ''}\"")
|
|
|
|
if "localizations" not in strings[key]:
|
|
strings[key]["localizations"] = {}
|
|
|
|
for lang in missing_langs:
|
|
google_lang = TARGET_LANGUAGES[lang]
|
|
translated = translate_string(english, google_lang)
|
|
|
|
if translated:
|
|
strings[key]["localizations"][lang] = {
|
|
"stringUnit": {
|
|
"state": "translated",
|
|
"value": translated
|
|
}
|
|
}
|
|
total_translated += 1
|
|
print(f" {lang}: {translated[:60]}{'...' if len(translated) > 60 else ''}")
|
|
else:
|
|
print(f" {lang}: FAILED")
|
|
|
|
# Rate limiting to avoid getting blocked
|
|
time.sleep(0.3)
|
|
|
|
# Save progress every 20 strings
|
|
if (i + 1) % 20 == 0:
|
|
print(f"\n Saving progress ({total_translated} translations)...")
|
|
with open(XCSTRINGS_PATH, "w") as f:
|
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
|
|
# Final save
|
|
print(f"\nSaving {total_translated} translations...")
|
|
with open(XCSTRINGS_PATH, "w") as f:
|
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
|
|
print("\n=== Summary ===")
|
|
for lang, name in LANG_DISPLAY.items():
|
|
count = sum(
|
|
1 for s in strings.values()
|
|
if s.get("localizations", {}).get(lang, {}).get("stringUnit", {}).get("state") == "translated"
|
|
)
|
|
total = len([k for k, v in strings.items() if not should_skip(k, get_english_value(v, k) or "")])
|
|
pct = (count / total * 100) if total > 0 else 0
|
|
print(f" {name}: {count}/{total} ({pct:.0f}%)")
|
|
|
|
print("\nDone!")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|