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.
193 lines
6.3 KiB
Python
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."))
|