#!/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 [...] [--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()