286 lines
9.7 KiB
Python
Executable File
286 lines
9.7 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
translate_missing.py — Preenche strings em falta em ficheiros .po via DeepL (PT-PT).
|
|
|
|
Traduz apenas msgstr vazios (msgstr ""). Não sobrescreve traduções existentes.
|
|
Preserva placeholders WordPress (%s, %d, %1$s, {var}, ##var##) e HTML.
|
|
Recompila .mo automaticamente após tradução via polib.
|
|
Suporta batching (50 strings/pedido) — muito mais rápido que LibreTranslate.
|
|
|
|
Uso:
|
|
python3 translate_missing.py plugin-pt_PT.po [plugin2-pt_PT.po ...]
|
|
python3 translate_missing.py plugin-pt_PT.po --dry-run
|
|
find /path/to/library -name "*-pt_PT.po" | xargs python3 translate_missing.py
|
|
|
|
Author: Descomplicar®
|
|
Version: 2.1.0 — DeepL PT-PT + suporte plural (msgstr[0]/msgstr[1])
|
|
"""
|
|
import sys
|
|
import re
|
|
import time
|
|
import shutil
|
|
import os
|
|
from pathlib import Path
|
|
|
|
# Carregar .env do mesmo directório
|
|
_env_file = Path(__file__).parent / '.env'
|
|
if _env_file.exists():
|
|
for _line in _env_file.read_text().splitlines():
|
|
if '=' in _line and not _line.startswith('#'):
|
|
_k, _v = _line.split('=', 1)
|
|
os.environ.setdefault(_k.strip(), _v.strip())
|
|
|
|
try:
|
|
import deepl
|
|
except ImportError:
|
|
print("ERRO: biblioteca 'deepl' não instalada. Correr: pip3 install deepl", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
import polib
|
|
except ImportError:
|
|
print("ERRO: biblioteca 'polib' não instalada. Correr: pip3 install polib", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Configuração
|
|
DEEPL_AUTH_KEY = os.environ.get('DEEPL_API_KEY', '')
|
|
DEEPL_GLOSSARY_ID = os.environ.get('DEEPL_GLOSSARY_ID', '')
|
|
TARGET_LANG = 'PT-PT'
|
|
BATCH_SIZE = 50 # DeepL suporta até 50 textos por pedido
|
|
RATE_LIMIT = 0.1 # segundos entre pedidos (muito mais rápido que LibreTranslate)
|
|
MAX_RETRIES = 3
|
|
RETRY_DELAY = 3
|
|
|
|
# Padrão para placeholder WordPress — proteger durante tradução
|
|
PH_PATTERN = re.compile(
|
|
r'(%(?:\d+\$)?[sdfu]|%\.\d+[fF]|\{[^}]+\}|##[^#]+##|<[^>]+>|&[a-z]+;|&#\d+;)',
|
|
re.UNICODE
|
|
)
|
|
|
|
|
|
def protect_placeholders(text: str) -> tuple[str, list[str]]:
|
|
"""
|
|
Substitui placeholders por tokens ⟦0⟧, ⟦1⟧, etc. (Unicode, não XML).
|
|
DeepL preserva estes tokens sem necessitar de tag_handling.
|
|
Devolve (texto_protegido, lista_de_placeholders).
|
|
"""
|
|
placeholders = []
|
|
def replace(m):
|
|
token = f'⟦{len(placeholders)}⟧'
|
|
placeholders.append(m.group(0))
|
|
return token
|
|
protected = PH_PATTERN.sub(replace, text)
|
|
return protected, placeholders
|
|
|
|
|
|
def restore_placeholders(text: str, placeholders: list[str]) -> str:
|
|
"""Restaura placeholders após tradução."""
|
|
for i, ph in enumerate(placeholders):
|
|
text = text.replace(f'⟦{i}⟧', ph)
|
|
return text
|
|
|
|
|
|
def escape_po(text: str) -> str:
|
|
"""Escapa caracteres especiais para formato .po."""
|
|
text = text.replace('\\', '\\\\')
|
|
text = text.replace('"', '\\"')
|
|
text = text.replace('\n', '\\n')
|
|
text = text.replace('\t', '\\t')
|
|
return text
|
|
|
|
|
|
def translate_batch(translator: 'deepl.Translator', texts: list[str]) -> list[str | None]:
|
|
"""
|
|
Traduz uma lista de textos via DeepL com protecção de placeholders.
|
|
Devolve lista de traduções (None em caso de falha individual).
|
|
"""
|
|
if not texts:
|
|
return []
|
|
|
|
# Proteger placeholders em todos os textos
|
|
protected_texts = []
|
|
all_placeholders = []
|
|
for text in texts:
|
|
protected, phs = protect_placeholders(text)
|
|
protected_texts.append(protected)
|
|
all_placeholders.append(phs)
|
|
|
|
for attempt in range(MAX_RETRIES):
|
|
try:
|
|
kwargs = dict(
|
|
source_lang='EN',
|
|
target_lang=TARGET_LANG,
|
|
preserve_formatting=True,
|
|
)
|
|
if DEEPL_GLOSSARY_ID:
|
|
kwargs['glossary'] = DEEPL_GLOSSARY_ID
|
|
results = translator.translate_text(protected_texts, **kwargs)
|
|
translations = []
|
|
for i, result in enumerate(results):
|
|
restored = restore_placeholders(result.text, all_placeholders[i])
|
|
translations.append(restored)
|
|
return translations
|
|
except deepl.exceptions.QuotaExceededException:
|
|
print("\n ATENÇÃO: Quota DeepL esgotada! Limite mensal atingido.", file=sys.stderr)
|
|
return [None] * len(texts)
|
|
except Exception as e:
|
|
if attempt < MAX_RETRIES - 1:
|
|
time.sleep(RETRY_DELAY)
|
|
else:
|
|
print(f" ERRO API DeepL: {e}", file=sys.stderr)
|
|
return [None] * len(texts)
|
|
|
|
return [None] * len(texts)
|
|
|
|
|
|
def process_file(filepath: str, translator: 'deepl.Translator', dry_run: bool = False) -> tuple[int, int]:
|
|
"""
|
|
Processa um ficheiro .po e traduz strings em falta via DeepL.
|
|
Suporta entradas simples (msgstr) e plurais (msgstr[0]/msgstr[1]).
|
|
Devolve (strings traduzidas, erros).
|
|
"""
|
|
path = Path(filepath)
|
|
if not path.exists():
|
|
print(f" ERRO: ficheiro não encontrado: {filepath}", file=sys.stderr)
|
|
return 0, 1
|
|
|
|
print(f"\n{'='*60}")
|
|
print(f" {path.parent.name}/{path.name}")
|
|
|
|
po = polib.pofile(filepath, encoding='utf-8')
|
|
|
|
# Recolher entradas simples vazias
|
|
# item: ('simple', entry, None)
|
|
# item: ('plural_s', entry, None) — singular do plural
|
|
# item: ('plural_p', entry, None) — plural do plural
|
|
to_translate = [] # (tipo, entry, texto_a_traduzir)
|
|
|
|
for entry in po:
|
|
if entry.msgid_plural:
|
|
# Entrada plural — verificar cada forma
|
|
s_empty = not entry.msgstr_plural.get(0, '').strip()
|
|
p_empty = not entry.msgstr_plural.get(1, '').strip()
|
|
if s_empty and entry.msgid.strip():
|
|
to_translate.append(('plural_s', entry, entry.msgid))
|
|
if p_empty and entry.msgid_plural.strip():
|
|
to_translate.append(('plural_p', entry, entry.msgid_plural))
|
|
else:
|
|
# Entrada simples
|
|
if not entry.msgstr.strip() and entry.msgid.strip():
|
|
to_translate.append(('simple', entry, entry.msgid))
|
|
|
|
if not to_translate:
|
|
print(f" Sem strings em falta — a saltar")
|
|
return 0, 0
|
|
|
|
n_simple = sum(1 for t, _, _ in to_translate if t == 'simple')
|
|
n_plural = sum(1 for t, _, _ in to_translate if t != 'simple')
|
|
print(f" {len(to_translate)} strings em falta ({n_simple} simples, {n_plural} plurais) — DeepL PT-PT...")
|
|
|
|
if dry_run:
|
|
print(f" [DRY-RUN] sem alterações gravadas")
|
|
return len(to_translate), 0
|
|
|
|
# Traduzir em batches
|
|
translated_count = 0
|
|
errors = 0
|
|
texts = [texto for _, _, texto in to_translate]
|
|
|
|
for batch_start in range(0, len(texts), BATCH_SIZE):
|
|
batch_items = to_translate[batch_start:batch_start + BATCH_SIZE]
|
|
batch_texts = [texto for _, _, texto in batch_items]
|
|
|
|
results = translate_batch(translator, batch_texts)
|
|
time.sleep(RATE_LIMIT)
|
|
|
|
for (tipo, entry, _), translation in zip(batch_items, results):
|
|
if translation:
|
|
if tipo == 'simple':
|
|
entry.msgstr = translation
|
|
elif tipo == 'plural_s':
|
|
entry.msgstr_plural[0] = translation
|
|
elif tipo == 'plural_p':
|
|
entry.msgstr_plural[1] = translation
|
|
translated_count += 1
|
|
else:
|
|
errors += 1
|
|
|
|
done = min(batch_start + BATCH_SIZE, len(texts))
|
|
print(f" [{done}/{len(texts)}] batch OK")
|
|
|
|
if translated_count == 0:
|
|
print(f" Nenhuma string traduzida")
|
|
return 0, errors
|
|
|
|
shutil.copy2(filepath, filepath + '.bak_translate')
|
|
po.save(filepath)
|
|
|
|
# Compilar .mo
|
|
mo_path = filepath.replace('.po', '.mo')
|
|
try:
|
|
po.save_as_mofile(mo_path)
|
|
print(f" .mo compilado: OK ({len(po.translated_entries())} strings traduzidas)")
|
|
except Exception as e:
|
|
print(f" ERRO .mo: {e}", file=sys.stderr)
|
|
errors += 1
|
|
|
|
print(f" Traduzidas: {translated_count} | Erros: {errors}")
|
|
return translated_count, errors
|
|
|
|
|
|
def main():
|
|
if not DEEPL_AUTH_KEY:
|
|
print("ERRO: DEEPL_API_KEY não definida. Criar .env com DEEPL_API_KEY=...", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
args = sys.argv[1:]
|
|
dry_run = '--dry-run' in args
|
|
files = [a for a in args if not a.startswith('--')]
|
|
|
|
if not files:
|
|
print("Uso: python3 translate_missing.py <ficheiro.po> [...] [--dry-run]")
|
|
sys.exit(1)
|
|
|
|
# Inicializar DeepL e verificar quota
|
|
try:
|
|
translator = deepl.Translator(DEEPL_AUTH_KEY)
|
|
usage = translator.get_usage()
|
|
if usage.character.valid:
|
|
used = usage.character.count
|
|
limit = usage.character.limit
|
|
remaining = limit - used
|
|
print(f"\nDeepL API — Quota: {used:,}/{limit:,} chars usados | {remaining:,} restantes")
|
|
if remaining < 10000:
|
|
print(" ATENÇÃO: quota quase esgotada!", file=sys.stderr)
|
|
if DEEPL_GLOSSARY_ID:
|
|
print(f"Glossário activo: {DEEPL_GLOSSARY_ID}")
|
|
else:
|
|
print("Glossário: não configurado (definir DEEPL_GLOSSARY_ID em .env)")
|
|
except Exception as e:
|
|
print(f"ERRO ao ligar à API DeepL: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
total_translated = 0
|
|
total_errors = 0
|
|
|
|
for f in files:
|
|
t, e = process_file(f, translator, dry_run)
|
|
total_translated += t
|
|
total_errors += e
|
|
|
|
# Quota final
|
|
try:
|
|
usage = translator.get_usage()
|
|
if usage.character.valid:
|
|
print(f"\nQuota usada nesta sessão: {usage.character.count:,}/{usage.character.limit:,} chars")
|
|
except Exception:
|
|
pass
|
|
|
|
print(f"\n{'='*60}")
|
|
print(f"TOTAL: {total_translated} traduzidas | {total_errors} erros | {len(files)} ficheiro(s)")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|