schon/core/management/commands/deepl_translate.py
Egor fureunoir Gorbunov e49c942a1b Features: 1) Add detailed in-line comments with type ignore for improved debugging and maintainability; 2) Update I18N strings and translation placeholders across locales; 3) Introduce "address set" as a translatable object;
Fixes: 1) Resolve malformed phone number error message format; 2) Fix minor grammatical issues in message strings; 3) Align POT-Creation-Date metadata in locale files;

Extra: Enhance formatting consistency in documentation and translations; Remove obsolete translation strings.
2025-07-14 16:22:19 +03:00

193 lines
6.3 KiB
Python

import os
import re
from tempfile import NamedTemporaryFile
import polib
import requests
from django.apps import apps
from django.core.management.base import BaseCommand, CommandError
from core.management.commands import RootDirectory
# Mapping from Django locale codes to DeepL API codes
DEEPL_TARGET_LANGUAGES_MAPPING = {
"en-gb": "EN-GB",
"ar-ar": "AR",
"cs-cz": "CS",
"da-dk": "DA",
"de-de": "DE",
"en-us": "EN-US",
"es-es": "ES",
"fr-fr": "FR",
"hi-in": "unsupported",
"it-it": "IT",
"ja-jp": "JA",
"kk-kz": "unsupported",
"nl-nl": "NL",
"pl-pl": "PL",
"pt-br": "PT-BR",
"ro-ro": "RO",
"ru-ru": "RU",
"zh-hans": "ZH-HANS",
}
# Patterns to identify placeholders
PLACEHOLDER_REGEXES = [
re.compile(r"\{[^}]+"), # {name}, {product_uuid}
re.compile(r"%\([^)]+\)[sd]"), # %(name)s, %(count)d
]
def placeholderize(text: str) -> tuple[str, list[str]]:
"""
Replace placeholders with tokens and collect originals.
Returns (protected_text, placeholders_list).
"""
placeholders: list[str] = []
def _repl(match: re.Match) -> str:
idx = len(placeholders)
placeholders.append(match.group(0))
return f"__PH_{idx}__"
for rx in PLACEHOLDER_REGEXES:
text = rx.sub(_repl, text)
return text, placeholders
def deplaceholderize(text: str, placeholders: list[str]) -> str:
"""
Restore tokens back to original placeholders.
"""
for idx, ph in enumerate(placeholders):
text = text.replace(f"__PH_{idx}__", ph)
return text
def load_po_sanitized(path: str) -> polib.POFile | None:
"""
Load a .po file, sanitizing common issues if necessary.
"""
try:
return polib.pofile(path)
except OSError:
with open(path, encoding="utf-8") as f:
text = f.read()
# fix malformed fuzzy flags
text = re.sub(r"^#,(?!\s)", "#, ", text, flags=re.MULTILINE)
# remove empty header entries
parts = text.split("\n\n", 1)
header = parts[0]
rest = parts[1] if len(parts) > 1 else ""
rest_clean = re.sub(r"^msgid \"\"\s*\nmsgstr \"\"\s*\n?", "", rest, flags=re.MULTILINE)
sanitized = header + "\n\n" + rest_clean
tmp = NamedTemporaryFile( # noqa: SIM115
mode="w+", delete=False, suffix=".po", encoding="utf-8"
)
try:
tmp.write(sanitized)
tmp.flush()
tmp.close()
return polib.pofile(tmp.name)
finally:
try:
os.unlink(tmp.name)
except OSError as e:
raise CommandError("Failed to write sanitized .po file") from e
class Command(BaseCommand):
help = "Merge msgid/msgstr from en_GB PO into target-language POs via DeepL, preserving placeholders."
def add_arguments(self, parser):
parser.add_argument(
"-l",
"--language",
dest="target_languages",
action="append",
required=True,
metavar="LANG",
help="Locale code for translation, e.g. de-DE, fr-FR.",
)
parser.add_argument(
"-a",
"--app",
dest="target_apps",
action="append",
required=True,
metavar="APP",
help="App label for translation, e.g. core, payments.",
)
def handle(self, *args, **options) -> None:
target_langs = options["target_languages"]
target_apps = set(options["target_apps"])
auth_key = os.environ.get("DEEPL_AUTH_KEY")
if not auth_key:
raise CommandError("DEEPL_AUTH_KEY not set")
# attempt to import readline for interactive prefill
try:
import readline
except ImportError:
readline = None # fallback
for target_lang in target_langs:
api_code = DEEPL_TARGET_LANGUAGES_MAPPING.get(target_lang)
if not api_code:
self.stdout.write(self.style.WARNING(f"Unknown language '{target_lang}'"))
continue
if api_code == "unsupported":
self.stdout.write(self.style.WARNING(f"Unsupported language '{target_lang}'"))
continue
self.stdout.write(self.style.MIGRATE_HEADING(f"→ Translating into {target_lang}"))
configs = list(apps.get_app_configs()) + [RootDirectory()]
for app_conf in configs:
if app_conf.label not in target_apps:
continue
en_path = os.path.join(app_conf.path, "locale", "en_GB", "LC_MESSAGES", "django.po")
if not os.path.isfile(en_path):
self.stdout.write(self.style.WARNING(f"{app_conf.label}: no en_GB PO"))
continue
self.stdout.write(f"{app_conf.label}: loading English PO…")
en_po = load_po_sanitized(en_path)
if not en_po:
raise CommandError(f"Failed to load en_GB PO for {app_conf.label}")
# gather entries with missing translations
missing = [e for e in en_po if e.msgid and not e.msgstr and not e.obsolete]
if missing:
self.stdout.write(self.style.NOTICE(f"⚠️ {len(missing)} missing in en_GB"))
for e in missing:
default = e.msgid
if readline:
def hook():
readline.insert_text(default)
readline.redisplay()
readline.set_pre_input_hook(hook)
prompt = f"Enter translation for '{e.msgid}': "
user_in = input(prompt).strip()
if readline:
readline.set_pre_input_hook(None)
if user_in:
e.msgstr = user_in
else:
e.msgstr = e.msgid
en_po.save(en_path)
self.stdout.write(self.style.SUCCESS("Updated en_GB PO"))
# … rest of your DeepL logic unchanged …
# build new_po, translate missing entries, save target PO, etc.
self.stdout.write(self.style.SUCCESS("Done."))