959 lines
32 KiB
Python
Executable File
959 lines
32 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
WordPress Plugin .po Translation Script
|
|
Translates WordPress .po files to PT-PT using LibreTranslate with medical/business glossary.
|
|
|
|
Author: Descomplicar® Crescimento Digital
|
|
Date: 2026-02-11
|
|
Version: 1.0.0
|
|
|
|
Usage:
|
|
python3 translate-wordpress-plugin.py input.po [--batch-size 50] [--api-url https://translate.descomplicar.pt]
|
|
|
|
Features:
|
|
- Translates only empty msgstr entries
|
|
- Preserves placeholders (%s, %d, %1$s, etc.)
|
|
- Preserves HTML tags
|
|
- Applies PT-PT glossary post-translation
|
|
- Handles plural forms correctly
|
|
- Validates translations before saving
|
|
- Compiles .mo file automatically
|
|
- Progress tracking with ETA
|
|
"""
|
|
|
|
import re
|
|
import sys
|
|
import json
|
|
import argparse
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Dict, List, Tuple
|
|
from urllib.request import Request, urlopen
|
|
from urllib.error import URLError
|
|
import time
|
|
|
|
# PT-PT Glossary (EN→PT-PT) - Source: Guia-PT-PT.md v2.0
|
|
GLOSSARY = {
|
|
# --- Medical terminology ---
|
|
"appointment": "consulta",
|
|
"appointments": "consultas",
|
|
"encounter": "encontro clínico",
|
|
"encounters": "encontros clínicos",
|
|
"patient": "paciente",
|
|
"patients": "pacientes",
|
|
"doctor": "médico",
|
|
"doctors": "médicos",
|
|
"clinic": "clínica",
|
|
"clinics": "clínicas",
|
|
"prescription": "receita",
|
|
"prescriptions": "receitas",
|
|
"medical record": "registo clínico",
|
|
"medical records": "registos clínicos",
|
|
"health center": "centro de saúde",
|
|
"family doctor": "médico de família",
|
|
"general practitioner": "médico de família",
|
|
"emergency room": "consulta de urgência",
|
|
"ultrasound": "ecografia",
|
|
"CT scan": "TAC",
|
|
"blood tests": "análises",
|
|
"blood test": "análises",
|
|
"medical certificate": "atestado médico",
|
|
"vaccination record": "boletim de vacinas",
|
|
"clinical report": "relatório clínico",
|
|
"orthopedics": "ortopedia",
|
|
"ophthalmology": "oftalmologia",
|
|
"disorder": "perturbação",
|
|
"psychological assessment": "avaliação psicológica",
|
|
"therapy": "psicoterapia",
|
|
|
|
# --- Billing / Finance ---
|
|
"billing": "facturação",
|
|
"invoice": "factura",
|
|
"invoices": "facturas",
|
|
"bill": "factura",
|
|
"bills": "facturas",
|
|
"credit note": "nota de crédito",
|
|
"credit notes": "notas de crédito",
|
|
"receipt": "recibo",
|
|
"receipts": "recibos",
|
|
"purchase order": "nota de encomenda",
|
|
"delivery note": "guia de remessa",
|
|
"installment": "prestação",
|
|
"installments": "prestações",
|
|
"tax": "IVA",
|
|
"VAT": "IVA",
|
|
"bank transfer": "transferência bancária",
|
|
"balance sheet": "balanço",
|
|
"profit and loss": "demonstração de resultados",
|
|
"budget": "orçamento",
|
|
"quote": "orçamento",
|
|
"quotation": "orçamento",
|
|
"interest rate": "taxa de juro",
|
|
"stock exchange": "bolsa de valores",
|
|
"overdraft": "descoberto autorizado",
|
|
|
|
# --- UI Common ---
|
|
"dashboard": "painel",
|
|
"settings": "definições",
|
|
"preferences": "preferências",
|
|
"save": "guardar",
|
|
"delete": "eliminar",
|
|
"remove": "remover",
|
|
"cancel": "cancelar",
|
|
"search": "pesquisar",
|
|
"filter": "filtrar",
|
|
"sort": "ordenar",
|
|
"add new": "adicionar novo",
|
|
"add": "adicionar",
|
|
"edit": "editar",
|
|
"view": "ver",
|
|
"preview": "pré-visualizar",
|
|
"status": "estado",
|
|
"login": "iniciar sessão",
|
|
"log in": "iniciar sessão",
|
|
"sign in": "iniciar sessão",
|
|
"logout": "terminar sessão",
|
|
"log out": "terminar sessão",
|
|
"sign out": "terminar sessão",
|
|
"sign up": "registar-se",
|
|
"register": "registar",
|
|
"password": "palavra-passe",
|
|
"username": "nome de utilizador",
|
|
"display name": "nome de apresentação",
|
|
"permalink": "ligação permanente",
|
|
"upload": "carregar",
|
|
"download": "transferir",
|
|
"file": "ficheiro",
|
|
"files": "ficheiros",
|
|
"folder": "pasta",
|
|
"folders": "pastas",
|
|
"screen": "ecrã",
|
|
"share": "partilhar",
|
|
"shared": "partilhado",
|
|
"link": "hiperligação",
|
|
"hyperlink": "hiperligação",
|
|
"mouse": "rato",
|
|
"keyboard": "teclado",
|
|
"hard drive": "disco rígido",
|
|
"database": "base de dados",
|
|
"backup": "cópia de segurança",
|
|
"restore": "restaurar",
|
|
"update": "actualizar",
|
|
"updates": "actualizações",
|
|
"upgrade": "actualizar",
|
|
"submit": "submeter",
|
|
"confirm": "confirmar",
|
|
"approve": "aprovar",
|
|
"reject": "rejeitar",
|
|
"close": "fechar",
|
|
"open": "abrir",
|
|
"enable": "activar",
|
|
"disable": "desactivar",
|
|
"enabled": "activado",
|
|
"disabled": "desactivado",
|
|
"select": "seleccionar",
|
|
"selected": "seleccionado",
|
|
"collapse": "recolher",
|
|
"expand": "expandir",
|
|
"loading": "a carregar",
|
|
"processing": "a processar",
|
|
"pending": "pendente",
|
|
"completed": "concluído",
|
|
"failed": "falhado",
|
|
"error": "erro",
|
|
"warning": "aviso",
|
|
"success": "sucesso",
|
|
"notification": "notificação",
|
|
"notifications": "notificações",
|
|
"overview": "visão geral",
|
|
"details": "detalhes",
|
|
"summary": "resumo",
|
|
"history": "histórico",
|
|
"report": "relatório",
|
|
"reports": "relatórios",
|
|
"export": "exportar",
|
|
"import": "importar",
|
|
|
|
# --- E-commerce / WooCommerce ---
|
|
"cart": "carrinho",
|
|
"checkout": "finalizar compra",
|
|
"shipping": "envio",
|
|
"delivery": "entrega",
|
|
"coupon": "cupão",
|
|
"coupons": "cupões",
|
|
"discount": "desconto",
|
|
"discounts": "descontos",
|
|
"order": "encomenda",
|
|
"orders": "encomendas",
|
|
"product": "produto",
|
|
"products": "produtos",
|
|
"category": "categoria",
|
|
"categories": "categorias",
|
|
"tag": "etiqueta",
|
|
"tags": "etiquetas",
|
|
"stock": "stock",
|
|
"in stock": "em stock",
|
|
"out of stock": "sem stock",
|
|
"wishlist": "lista de desejos",
|
|
"refund": "reembolso",
|
|
"refunds": "reembolsos",
|
|
"return": "devolução",
|
|
"returns": "devoluções",
|
|
|
|
# --- Professional / Business ---
|
|
"employee": "colaborador",
|
|
"employees": "colaboradores",
|
|
"staff": "pessoal",
|
|
"salary": "ordenado",
|
|
"role": "função",
|
|
"meeting": "reunião",
|
|
"meetings": "reuniões",
|
|
"resume": "curriculum vitae",
|
|
"company": "empresa",
|
|
"team": "equipa",
|
|
"teams": "equipas",
|
|
"customer": "cliente",
|
|
"customers": "clientes",
|
|
"client": "cliente",
|
|
"clients": "clientes",
|
|
"supplier": "fornecedor",
|
|
"suppliers": "fornecedores",
|
|
"lead": "contacto",
|
|
"leads": "contactos",
|
|
"deal": "negócio",
|
|
"deals": "negócios",
|
|
"pipeline": "pipeline",
|
|
"task": "tarefa",
|
|
"tasks": "tarefas",
|
|
"project": "projecto",
|
|
"projects": "projectos",
|
|
"milestone": "marco",
|
|
"milestones": "marcos",
|
|
"deadline": "prazo",
|
|
"deadlines": "prazos",
|
|
"priority": "prioridade",
|
|
"assigned to": "atribuído a",
|
|
"workflow": "fluxo de trabalho",
|
|
|
|
# --- Technology / IT ---
|
|
"machine learning": "aprendizagem automática",
|
|
"big data": "dados massivos",
|
|
"neural network": "rede neuronal",
|
|
"neural networks": "redes neuronais",
|
|
"cloud computing": "computação em nuvem",
|
|
"cybersecurity": "cibersegurança",
|
|
"encryption": "encriptação",
|
|
"firewall": "firewall",
|
|
"authentication": "autenticação",
|
|
"container": "contentor",
|
|
"containers": "contentores",
|
|
"deployment": "implantação",
|
|
"version control": "controlo de versões",
|
|
"unit tests": "testes unitários",
|
|
"continuous integration": "integração contínua",
|
|
"continuous delivery": "entrega contínua",
|
|
"scalability": "escalabilidade",
|
|
"user experience": "experiência do utilizador",
|
|
"accessibility": "acessibilidade",
|
|
"responsive": "responsivo",
|
|
|
|
# --- Marketing / Digital ---
|
|
"target audience": "público-alvo",
|
|
"website": "sítio web",
|
|
"website traffic": "tráfego do sítio web",
|
|
"conversion rate": "taxa de conversão",
|
|
"market share": "quota de mercado",
|
|
"SEO": "SEO",
|
|
"campaign": "campanha",
|
|
"campaigns": "campanhas",
|
|
"newsletter": "boletim informativo",
|
|
"subscriber": "subscritor",
|
|
"subscribers": "subscritores",
|
|
"unsubscribe": "cancelar subscrição",
|
|
|
|
# --- Common phrases ---
|
|
"is required": "é obrigatório",
|
|
"must be": "deve ser",
|
|
"cannot be empty": "não pode estar vazio",
|
|
"at least": "pelo menos",
|
|
"cannot exceed": "não pode exceder",
|
|
"already exists": "já existe",
|
|
"not found": "não encontrado",
|
|
"retrieved successfully": "obtido com sucesso",
|
|
"saved successfully": "guardado com sucesso",
|
|
"updated successfully": "actualizado com sucesso",
|
|
"deleted successfully": "eliminado com sucesso",
|
|
"created successfully": "criado com sucesso",
|
|
"failed to": "falha ao",
|
|
"are you sure": "tem a certeza",
|
|
"this action cannot be undone": "esta acção não pode ser desfeita",
|
|
"no results found": "nenhum resultado encontrado",
|
|
"please try again": "por favor tente novamente",
|
|
"access denied": "acesso negado",
|
|
"permission denied": "permissão negada",
|
|
"you do not have permission": "não tem permissão",
|
|
"invalid": "inválido",
|
|
"optional": "opcional",
|
|
"learn more": "saber mais",
|
|
"read more": "ler mais",
|
|
"show more": "mostrar mais",
|
|
"show less": "mostrar menos",
|
|
"go back": "voltar",
|
|
"next": "seguinte",
|
|
"previous": "anterior",
|
|
"first": "primeiro",
|
|
"last": "último",
|
|
|
|
# --- Date/time ---
|
|
"today": "hoje",
|
|
"yesterday": "ontem",
|
|
"tomorrow": "amanhã",
|
|
"this week": "esta semana",
|
|
"last week": "semana passada",
|
|
"this month": "este mês",
|
|
"last month": "mês passado",
|
|
"this year": "este ano",
|
|
"daily": "diário",
|
|
"weekly": "semanal",
|
|
"monthly": "mensal",
|
|
"yearly": "anual",
|
|
"schedule": "agenda",
|
|
"scheduled": "agendado",
|
|
}
|
|
|
|
# PT-PT orthography fixes (BR→PT-PT) - Source: Guia-PT-PT.md v2.0
|
|
ORTHOGRAPHY_FIXES = {
|
|
# --- Orthographic differences (AO90 BR vs traditional PT-PT) ---
|
|
"atualizar": "actualizar",
|
|
"atualizad": "actualizad",
|
|
"atualização": "actualização",
|
|
"atualizações": "actualizações",
|
|
"fatura": "factura",
|
|
"faturas": "facturas",
|
|
"faturação": "facturação",
|
|
"faturamento": "facturação",
|
|
"selecionar": "seleccionar",
|
|
"selecionad": "seleccionad",
|
|
"seleção": "selecção",
|
|
"seleções": "selecções",
|
|
"ótimo": "óptimo",
|
|
"ação": "acção",
|
|
"ações": "acções",
|
|
"direção": "direcção",
|
|
"direções": "direcções",
|
|
"proteção": "protecção",
|
|
"coleção": "colecção",
|
|
"correção": "correcção",
|
|
"conexão": "conexão",
|
|
"detecção": "detecção",
|
|
"inspecção": "inspecção",
|
|
"recepção": "recepção",
|
|
"exceção": "excepção",
|
|
"infecção": "infecção",
|
|
"objeção": "objecção",
|
|
"projeção": "projecção",
|
|
"projecto": "projecto",
|
|
"contacto": "contacto",
|
|
"facto": "facto",
|
|
"exacto": "exacto",
|
|
"directo": "directo",
|
|
"correcto": "correcto",
|
|
|
|
# --- Vocabulary differences (BR→PT-PT) ---
|
|
# Tech / UI
|
|
"acessar": "aceder",
|
|
"arquivo": "ficheiro",
|
|
"arquivos": "ficheiros",
|
|
"diretório": "pasta",
|
|
"senha": "palavra-passe",
|
|
"senhas": "palavras-passe",
|
|
"tela": "ecrã",
|
|
"telas": "ecrãs",
|
|
"compartilhar": "partilhar",
|
|
"compartilhad": "partilhad",
|
|
"compartilhamento": "partilha",
|
|
"baixar": "transferir",
|
|
"fazer download": "transferir",
|
|
"configuração": "definição",
|
|
"configurações": "definições",
|
|
"configurar": "definir",
|
|
"colapsar": "recolher",
|
|
"banco de dados": "base de dados",
|
|
|
|
# Common verbs
|
|
"salvar": "guardar",
|
|
"deletar": "eliminar",
|
|
"deletad": "eliminad",
|
|
"cancelamento": "cancelamento",
|
|
"clicar": "clicar",
|
|
"apertar": "premir",
|
|
"digitar": "escrever",
|
|
"rodar": "executar",
|
|
"fechar sessão": "terminar sessão",
|
|
"fazer login": "iniciar sessão",
|
|
"fazer logout": "terminar sessão",
|
|
"cadastrar": "registar",
|
|
"cadastro": "registo",
|
|
|
|
# Everyday / general
|
|
"ônibus": "autocarro",
|
|
"trem": "comboio",
|
|
"banheiro": "casa de banho",
|
|
"café da manhã": "pequeno-almoço",
|
|
"geladeira": "frigorífico",
|
|
"celular": "telemóvel",
|
|
"pedágio": "portagem",
|
|
"açougue": "talho",
|
|
"estacionamento": "parque de estacionamento",
|
|
"carteira de motorista": "carta de condução",
|
|
"fila": "fila",
|
|
"nota fiscal": "factura",
|
|
"cupom fiscal": "talão",
|
|
"caixa eletrônico": "Multibanco",
|
|
"parcela": "prestação",
|
|
"parcelas": "prestações",
|
|
|
|
# Professional
|
|
"funcionário": "colaborador",
|
|
"funcionários": "colaboradores",
|
|
"salário": "ordenado",
|
|
"cargo": "função",
|
|
"currículo": "curriculum vitae",
|
|
"time": "equipa",
|
|
"equipe": "equipa",
|
|
|
|
# Education
|
|
"mensalidade": "propina",
|
|
"graduação": "licenciatura",
|
|
"doutorado": "doutoramento",
|
|
"matéria": "disciplina",
|
|
|
|
# Sports / Culture
|
|
"goleiro": "guarda-redes",
|
|
"chute": "pontapé",
|
|
"gol": "golo",
|
|
"pôster": "cartaz",
|
|
"temporada": "época",
|
|
|
|
# Food / Services
|
|
"cardápio": "ementa",
|
|
"garçom": "empregado de mesa",
|
|
"conta": "conta",
|
|
"aluguel": "aluguer",
|
|
|
|
# Legal / Admin
|
|
"réu": "arguido",
|
|
"cartório": "conservatória",
|
|
"prefeitura": "Câmara Municipal",
|
|
"subprefeitura": "Junta de Freguesia",
|
|
|
|
# Weather
|
|
"neblina": "nevoeiro",
|
|
"pancadas de chuva": "aguaceiros",
|
|
|
|
# Health
|
|
"exames de sangue": "análises",
|
|
"pronto-socorro": "consulta de urgência",
|
|
"posto de saúde": "centro de saúde",
|
|
"clínico geral": "médico de família",
|
|
"ultrassom": "ecografia",
|
|
"transtorno": "perturbação",
|
|
"transtornos": "perturbações",
|
|
|
|
# Environment / Science
|
|
"mudanças climáticas": "alterações climáticas",
|
|
"pegada de carbono": "pegada carbónica",
|
|
"rede neural": "rede neuronal",
|
|
"redes neurais": "redes neuronais",
|
|
"impressão 3D": "fabricação aditiva",
|
|
}
|
|
|
|
|
|
class LibreTranslateClient:
|
|
"""LibreTranslate API client."""
|
|
|
|
def __init__(self, api_url: str = "https://translate.descomplicar.pt"):
|
|
self.api_url = api_url.rstrip("/")
|
|
self.translate_endpoint = f"{self.api_url}/translate"
|
|
|
|
def translate(self, text: str, source: str = "en", target: str = "pt") -> str:
|
|
"""Translate text using LibreTranslate API."""
|
|
data = json.dumps({
|
|
"q": text,
|
|
"source": source,
|
|
"target": target,
|
|
"format": "text"
|
|
}).encode('utf-8')
|
|
|
|
req = Request(
|
|
self.translate_endpoint,
|
|
data=data,
|
|
headers={"Content-Type": "application/json"}
|
|
)
|
|
|
|
try:
|
|
with urlopen(req, timeout=30) as response:
|
|
result = json.loads(response.read().decode('utf-8'))
|
|
return result.get("translatedText", "")
|
|
except URLError as e:
|
|
print(f"❌ Translation API error: {e}")
|
|
return ""
|
|
except Exception as e:
|
|
print(f"❌ Unexpected error: {e}")
|
|
return ""
|
|
|
|
|
|
class PoFileTranslator:
|
|
"""WordPress .po file translator with validation."""
|
|
|
|
def __init__(self, po_file: Path, api_url: str, batch_size: int = 50):
|
|
self.po_file = po_file
|
|
self.batch_size = batch_size
|
|
self.translator = LibreTranslateClient(api_url)
|
|
self.stats = {
|
|
"total": 0,
|
|
"translated": 0,
|
|
"skipped": 0,
|
|
"failed": 0
|
|
}
|
|
|
|
def extract_entries(self) -> List[Dict]:
|
|
"""Extract all msgid/msgstr entries from .po file."""
|
|
entries = []
|
|
current_entry = {}
|
|
|
|
with open(self.po_file, 'r', encoding='utf-8') as f:
|
|
lines = f.readlines()
|
|
|
|
for i, line in enumerate(lines):
|
|
line = line.rstrip('\n')
|
|
|
|
# Comment/reference line
|
|
if line.startswith('#'):
|
|
if 'reference' not in current_entry:
|
|
current_entry['reference'] = []
|
|
current_entry['reference'].append(line)
|
|
current_entry['line_start'] = i
|
|
|
|
# msgid
|
|
elif line.startswith('msgid '):
|
|
current_entry['msgid'] = self._extract_string(line)
|
|
current_entry['msgid_line'] = i
|
|
|
|
# msgid_plural
|
|
elif line.startswith('msgid_plural '):
|
|
current_entry['msgid_plural'] = self._extract_string(line)
|
|
|
|
# msgstr
|
|
elif line.startswith('msgstr ') or line.startswith('msgstr['):
|
|
msgstr_value = self._extract_string(line)
|
|
|
|
if 'msgstr' not in current_entry:
|
|
current_entry['msgstr'] = {}
|
|
|
|
# Handle plural forms
|
|
if line.startswith('msgstr['):
|
|
plural_index = int(line[7])
|
|
current_entry['msgstr'][plural_index] = msgstr_value
|
|
else:
|
|
current_entry['msgstr'][0] = msgstr_value
|
|
|
|
current_entry['msgstr_line'] = i
|
|
|
|
# Continuation line
|
|
elif line.startswith('"') and current_entry:
|
|
# Append to last field
|
|
continuation = self._extract_string(line)
|
|
if 'msgstr_line' in current_entry and i > current_entry['msgstr_line']:
|
|
last_index = max(current_entry['msgstr'].keys())
|
|
current_entry['msgstr'][last_index] += continuation
|
|
elif 'msgid_line' in current_entry and i > current_entry['msgid_line']:
|
|
current_entry['msgid'] += continuation
|
|
|
|
# Empty line - entry complete
|
|
elif line == '' and current_entry:
|
|
if 'msgid' in current_entry and current_entry['msgid']:
|
|
entries.append(current_entry)
|
|
current_entry = {}
|
|
|
|
# Add last entry if exists
|
|
if current_entry and 'msgid' in current_entry:
|
|
entries.append(current_entry)
|
|
|
|
return entries
|
|
|
|
def _extract_string(self, line: str) -> str:
|
|
"""Extract string from msgid/msgstr line."""
|
|
match = re.search(r'"(.*?)"', line)
|
|
return match.group(1) if match else ""
|
|
|
|
def needs_translation(self, entry: Dict) -> bool:
|
|
"""Check if entry needs translation."""
|
|
if not entry.get('msgid'):
|
|
return False
|
|
|
|
# Check if msgstr is empty
|
|
msgstr_dict = entry.get('msgstr', {})
|
|
if not msgstr_dict:
|
|
return True
|
|
|
|
# Check if all plural forms are empty
|
|
return all(not v for v in msgstr_dict.values())
|
|
|
|
def extract_placeholders(self, text: str) -> List[str]:
|
|
"""Extract placeholders like %s, %d, %1$s from text."""
|
|
return re.findall(r'%(?:\d+\$)?[sdifuxX]', text)
|
|
|
|
def extract_html_tags(self, text: str) -> List[str]:
|
|
"""Extract HTML tags from text."""
|
|
return re.findall(r'<[^>]+>', text)
|
|
|
|
def apply_glossary(self, text: str) -> str:
|
|
"""Apply PT-PT glossary to translated text."""
|
|
for en, pt in GLOSSARY.items():
|
|
# Case-insensitive replacement
|
|
text = re.sub(r'\b' + re.escape(en) + r'\b', pt, text, flags=re.IGNORECASE)
|
|
|
|
return text
|
|
|
|
def apply_orthography(self, text: str) -> str:
|
|
"""Fix Brazilian Portuguese to European Portuguese."""
|
|
for br, pt in ORTHOGRAPHY_FIXES.items():
|
|
text = re.sub(r'\b' + re.escape(br), pt, text, flags=re.IGNORECASE)
|
|
|
|
return text
|
|
|
|
def validate_translation(self, original: str, translated: str, strict_html: bool = True) -> Tuple[bool, str]:
|
|
"""Validate translated text preserves placeholders and HTML."""
|
|
# Check placeholders (always strict)
|
|
orig_placeholders = self.extract_placeholders(original)
|
|
trans_placeholders = self.extract_placeholders(translated)
|
|
|
|
if sorted(orig_placeholders) != sorted(trans_placeholders):
|
|
return False, f"Placeholder mismatch: {orig_placeholders} vs {trans_placeholders}"
|
|
|
|
# Check HTML tags (optional strict mode)
|
|
if strict_html:
|
|
orig_html = self.extract_html_tags(original)
|
|
trans_html = self.extract_html_tags(translated)
|
|
|
|
if sorted(orig_html) != sorted(trans_html):
|
|
# Warning only - try to auto-fix first
|
|
print(f" ⚠️ HTML tags differ but auto-fixing...")
|
|
|
|
return True, "OK"
|
|
|
|
def translate_text(self, text: str) -> str:
|
|
"""Translate text with glossary and validation."""
|
|
if not text or text.isspace():
|
|
return text
|
|
|
|
# Translate via LibreTranslate
|
|
translated = self.translator.translate(text)
|
|
|
|
if not translated:
|
|
return ""
|
|
|
|
# Apply glossary aggressively (multiple passes)
|
|
for _ in range(2): # Two passes to catch variations
|
|
translated = self.apply_glossary(translated)
|
|
translated = self.apply_orthography(translated)
|
|
|
|
# Fix common LibreTranslate mistakes
|
|
translated = self.fix_common_mistakes(translated)
|
|
|
|
# Fix translated HTML tags
|
|
translated = self.fix_html_tags(translated)
|
|
|
|
# Validate (only placeholders, HTML fixing is automatic)
|
|
valid, error = self.validate_translation(text, translated, strict_html=False)
|
|
|
|
if not valid:
|
|
print(f"⚠️ Validation failed: {error}")
|
|
print(f" Original: {text[:50]}...")
|
|
print(f" Translation: {translated[:50]}...")
|
|
return ""
|
|
|
|
return translated
|
|
|
|
def fix_html_tags(self, text: str) -> str:
|
|
"""Fix common HTML tag translations."""
|
|
html_fixes = {
|
|
# LibreTranslate often translates these
|
|
r'<forte>': '<strong>',
|
|
r'</forte>': '</strong>',
|
|
r'<em>': '<em>', # Keep
|
|
r'</em>': '</em>', # Keep
|
|
r'<quebra>': '<br>',
|
|
r'<parágrafo>': '<p>',
|
|
r'</parágrafo>': '</p>',
|
|
}
|
|
|
|
for wrong, correct in html_fixes.items():
|
|
text = re.sub(wrong, correct, text, flags=re.IGNORECASE)
|
|
|
|
return text
|
|
|
|
|
|
def fix_common_mistakes(self, text: str) -> str:
|
|
"""Fix common translation mistakes from LibreTranslate."""
|
|
fixes = {
|
|
# --- BR vocabulary that LibreTranslate produces ---
|
|
r'\bcompromissos\b': 'consultas', # appointments
|
|
r'\bsenha\b': 'palavra-passe', # password
|
|
r'\bsenhas\b': 'palavras-passe', # passwords
|
|
r'\bconfiguração\b': 'definição', # setting
|
|
r'\bconfigurações\b': 'definições', # settings
|
|
r'\bsalvar\b': 'guardar', # save
|
|
r'\bsalvo\b': 'guardado', # saved
|
|
r'\bsalva\b': 'guardada', # saved (f)
|
|
r'\bdeletar\b': 'eliminar', # delete
|
|
r'\bdeletado\b': 'eliminado', # deleted
|
|
r'\bdeletada\b': 'eliminada', # deleted (f)
|
|
r'\bacessar\b': 'aceder', # access
|
|
r'\bacessado\b': 'acedido', # accessed
|
|
r'\bcompartilhar\b': 'partilhar', # share
|
|
r'\bcompartilhado\b': 'partilhado', # shared
|
|
r'\bbaixar\b': 'transferir', # download
|
|
r'\bcadastrar\b': 'registar', # register
|
|
r'\bcadastro\b': 'registo', # registration
|
|
r'\bcadastrado\b': 'registado', # registered
|
|
r'\bcelular\b': 'telemóvel', # mobile phone
|
|
r'\bgerenciar\b': 'gerir', # manage
|
|
r'\bgerenciamento\b': 'gestão', # management
|
|
r'\bgerenciado\b': 'gerido', # managed
|
|
r'\bimplementar\b': 'implementar', # implement (keep)
|
|
r'\botimizar\b': 'optimizar', # optimize
|
|
|
|
# --- "required" variations ---
|
|
r'\bnecessária\b': 'obrigatória',
|
|
r'\bnecessário\b': 'obrigatório',
|
|
r'\brequerido\b': 'obrigatório',
|
|
r'\brequerida\b': 'obrigatória',
|
|
|
|
# --- Gerund → "estar a + infinitivo" (most common LibreTranslate error) ---
|
|
r'\bestá sendo\b': 'está a ser',
|
|
r'\bestão sendo\b': 'estão a ser',
|
|
r'\bestá fazendo\b': 'está a fazer',
|
|
r'\bestá criando\b': 'está a criar',
|
|
r'\bestá processando\b': 'está a processar',
|
|
r'\bestá carregando\b': 'está a carregar',
|
|
r'\bestá enviando\b': 'está a enviar',
|
|
r'\bestá atualizando\b': 'está a actualizar',
|
|
r'\bestá gerando\b': 'está a gerar',
|
|
r'\bestá excluindo\b': 'está a eliminar',
|
|
r'\bestá salvando\b': 'está a guardar',
|
|
r'\bestá deletando\b': 'está a eliminar',
|
|
r'\bestá baixando\b': 'está a transferir',
|
|
r'\bestá importando\b': 'está a importar',
|
|
r'\bestá exportando\b': 'está a exportar',
|
|
r'\bestá verificando\b': 'está a verificar',
|
|
r'\bestá calculando\b': 'está a calcular',
|
|
r'\bestá sincronizando\b': 'está a sincronizar',
|
|
r'\bestá conectando\b': 'está a ligar',
|
|
|
|
# --- Generic gerund catch-all (careful, only common patterns) ---
|
|
r'\bprocessando\b': 'a processar',
|
|
r'\bcarregando\b': 'a carregar',
|
|
r'\batualizando\b': 'a actualizar',
|
|
r'\bgerando\b': 'a gerar',
|
|
|
|
# --- Pronoun placement (proclisis→enclisis) ---
|
|
r'\bse registrar\b': 'registar-se',
|
|
r'\bse cadastrar\b': 'registar-se',
|
|
r'\bse conectar\b': 'ligar-se',
|
|
r'\bse inscrever\b': 'inscrever-se',
|
|
|
|
# --- Other common LibreTranslate mistakes ---
|
|
r'\bvocê\b': 'o utilizador', # "você" → neutral
|
|
r'\bnenhum resultado\b': 'nenhum resultado', # keep
|
|
r'\bpor favor\b': 'por favor', # keep
|
|
}
|
|
|
|
for wrong, correct in fixes.items():
|
|
text = re.sub(wrong, correct, text, flags=re.IGNORECASE)
|
|
|
|
return text
|
|
|
|
def translate_entries(self) -> List[Dict]:
|
|
"""Translate all entries needing translation."""
|
|
entries = self.extract_entries()
|
|
self.stats['total'] = len(entries)
|
|
|
|
print(f"\n📊 Found {self.stats['total']} entries in {self.po_file.name}")
|
|
|
|
to_translate = [e for e in entries if self.needs_translation(e)]
|
|
print(f"🔄 {len(to_translate)} entries need translation\n")
|
|
|
|
if not to_translate:
|
|
print("✅ All entries already translated!")
|
|
return entries
|
|
|
|
# Translate in batches
|
|
start_time = time.time()
|
|
|
|
for i, entry in enumerate(to_translate, 1):
|
|
msgid = entry['msgid']
|
|
|
|
# Progress
|
|
elapsed = time.time() - start_time
|
|
rate = i / elapsed if elapsed > 0 else 0
|
|
eta = (len(to_translate) - i) / rate if rate > 0 else 0
|
|
|
|
print(f"[{i}/{len(to_translate)}] Translating... (ETA: {eta:.0f}s)")
|
|
print(f" EN: {msgid[:60]}...")
|
|
|
|
# Translate msgid
|
|
translated = self.translate_text(msgid)
|
|
|
|
if translated:
|
|
entry['msgstr'][0] = translated
|
|
print(f" PT: {translated[:60]}...")
|
|
self.stats['translated'] += 1
|
|
else:
|
|
print(f" ❌ Translation failed")
|
|
self.stats['failed'] += 1
|
|
|
|
# Translate plural form if exists
|
|
if 'msgid_plural' in entry:
|
|
msgid_plural = entry['msgid_plural']
|
|
translated_plural = self.translate_text(msgid_plural)
|
|
|
|
if translated_plural:
|
|
entry['msgstr'][1] = translated_plural
|
|
|
|
# Rate limiting
|
|
time.sleep(0.1)
|
|
|
|
print(f"\n✅ Translation complete in {time.time() - start_time:.1f}s")
|
|
|
|
return entries
|
|
|
|
def write_po_file(self, entries: List[Dict], output_file: Path):
|
|
"""Write translated entries back to .po file."""
|
|
with open(self.po_file, 'r', encoding='utf-8') as f:
|
|
lines = f.readlines()
|
|
|
|
# Update msgstr lines
|
|
for entry in entries:
|
|
if 'msgstr_line' in entry and entry.get('msgstr'):
|
|
msgstr_dict = entry['msgstr']
|
|
|
|
# Single msgstr
|
|
if len(msgstr_dict) == 1 and 0 in msgstr_dict:
|
|
msgstr_line = entry['msgstr_line']
|
|
lines[msgstr_line] = f'msgstr "{msgstr_dict[0]}"\n'
|
|
|
|
# Plural msgstr
|
|
else:
|
|
base_line = entry['msgstr_line']
|
|
for idx, value in msgstr_dict.items():
|
|
# Find or create msgstr[N] line
|
|
line_offset = idx
|
|
if base_line + line_offset < len(lines):
|
|
lines[base_line + line_offset] = f'msgstr[{idx}] "{value}"\n'
|
|
|
|
# Write to output
|
|
with open(output_file, 'w', encoding='utf-8') as f:
|
|
f.writelines(lines)
|
|
|
|
print(f"\n💾 Saved to: {output_file}")
|
|
|
|
def compile_mo(self, po_file: Path):
|
|
"""Compile .po to .mo using msgfmt."""
|
|
mo_file = po_file.with_suffix('.mo')
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
['msgfmt', '-cv', '-o', str(mo_file), str(po_file)],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True
|
|
)
|
|
|
|
print(f"\n✅ Compiled .mo file: {mo_file}")
|
|
print(result.stdout)
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
print(f"\n❌ Compilation failed: {e.stderr}")
|
|
except FileNotFoundError:
|
|
print("\n⚠️ msgfmt not found. Install gettext: sudo apt install gettext")
|
|
|
|
def print_stats(self):
|
|
"""Print translation statistics."""
|
|
print("\n" + "="*60)
|
|
print("📊 TRANSLATION STATISTICS")
|
|
print("="*60)
|
|
print(f"Total entries: {self.stats['total']}")
|
|
print(f"Translated: {self.stats['translated']} ✅")
|
|
print(f"Failed: {self.stats['failed']} ❌")
|
|
print(f"Skipped: {self.stats['total'] - self.stats['translated'] - self.stats['failed']}")
|
|
print(f"Success rate: {self.stats['translated'] / self.stats['total'] * 100:.1f}%")
|
|
print("="*60)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Translate WordPress .po files to PT-PT using LibreTranslate"
|
|
)
|
|
parser.add_argument(
|
|
"po_file",
|
|
type=Path,
|
|
help="Path to .po file to translate"
|
|
)
|
|
parser.add_argument(
|
|
"--batch-size",
|
|
type=int,
|
|
default=50,
|
|
help="Number of entries to translate per batch (default: 50)"
|
|
)
|
|
parser.add_argument(
|
|
"--api-url",
|
|
type=str,
|
|
default="https://translate.descomplicar.pt",
|
|
help="LibreTranslate API URL"
|
|
)
|
|
parser.add_argument(
|
|
"--output",
|
|
type=Path,
|
|
help="Output .po file (default: overwrite input)"
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Validate input file
|
|
if not args.po_file.exists():
|
|
print(f"❌ File not found: {args.po_file}")
|
|
sys.exit(1)
|
|
|
|
if not args.po_file.suffix == '.po':
|
|
print(f"❌ Not a .po file: {args.po_file}")
|
|
sys.exit(1)
|
|
|
|
# Output file
|
|
output_file = args.output or args.po_file
|
|
|
|
print("="*60)
|
|
print("🌍 WordPress Plugin Translation Script")
|
|
print("="*60)
|
|
print(f"Input: {args.po_file}")
|
|
print(f"Output: {output_file}")
|
|
print(f"API: {args.api_url}")
|
|
print("="*60)
|
|
|
|
# Translate
|
|
translator = PoFileTranslator(
|
|
po_file=args.po_file,
|
|
api_url=args.api_url,
|
|
batch_size=args.batch_size
|
|
)
|
|
|
|
entries = translator.translate_entries()
|
|
translator.write_po_file(entries, output_file)
|
|
translator.compile_mo(output_file)
|
|
translator.print_stats()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|