init: scripts diversos (crawlers, conversores, scrapers)

This commit is contained in:
2026-03-05 20:38:36 +00:00
commit 6ac6f4be2a
925 changed files with 850330 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
# Changelog — wp-translate-ptpt.py
## [1.0.0] - 2026-02-23
### Added
- Script unificado de ~950 linhas combinando funcionalidades de v1.0, v2.0 e v3.0
- 6 classes core: BrandProtector, TranslationEngine, PoFileHandler, CacheManager, QualityValidator, TranslationProcessor
- Base de dados SQLite com 3 tabelas (brands, translations, corrections)
- 98 marcas seed (Fluent Forms, Rank Math, Elementor, WooCommerce, etc.)
- 15+ mapeamentos literal→correcto para traduções de marcas
- ~50 regras PT-BR → PT-PT
- Pipeline de 9 etapas: parse → protect → translate → restore → fix → validate → save → compile
- CLI completo com 6 modos: single, batch, brands-only, dry-run, init-db, export/import
- Backup/rollback automático em caso de erro
- Validação de placeholders (%s, %d, %1$s) e HTML tags
- Rate limiting LibreTranslate: 0.3s entre chamadas
- Retry logic: 3 tentativas com exponential backoff
- Compilação .mo automática (best-effort, não bloqueante)
- Suporte formas plurais (`msgstr[0]`, `msgstr[1]`)
- Log completo de correcções em SQLite
### Fixed
- Bug regex sem verificação None (linha 582): `.group(1)` chamado em `None`
- Bug compilação msgfmt bloqueante: ficheiros com avisos eram revertidos incorrectamente
- Processamento de linhas `msgstr[N]` com `[` mas sem formato plural válido
### Changed
- Compilação .mo agora é tolerante a erros: avisa mas não falha o processamento
- Parser .po mais robusto: detecta e corrige linhas malformadas
### Tested
- Teste individual FluentForm: 53 marcas corrigidas em 1.2s
- Batch 241 ficheiros: 15 marcas corrigidas em 42.8s
- 0 falhas críticas, 100% ficheiros processados com sucesso
### Documentation
- REPORT-FINAL-wp-translate-ptpt.md: Report completo com arquitectura, estatísticas e exemplos
- Comentários inline no código (~10% linhas são comentários)
- Docstrings em todas as classes e métodos principais
---
## [v2.0] - 2026-02 (anterior)
### Added
- LibreTranslate API integration
- SQLite cache básico
- Batch processing
### Issues
- Protecção de marcas parcial
- Sem correcção de traduções literais
- Compilação msgfmt falhava em ficheiros com avisos
---
## [v1.0] - 2025 (anterior)
### Added
- Processamento básico .po
- Conversões PT-BR → PT-PT básicas
### Issues
- Sem tradução automática
- Sem cache
- Sem protecção de marcas
- Sem batch processing

View File

@@ -0,0 +1,145 @@
WordPress Plugin Translation Toolkit
=====================================
Conjunto de scripts reutilizáveis para traduzir e corrigir plugins WordPress
para Português Europeu (PT-PT) usando LibreTranslate.
Author: Descomplicar® Crescimento Digital
Version: 2.0.0
Updated: 20-02-2026
SCRIPTS DISPONÍVEIS
===================
1. translate-wordpress-plugin.py ← Traduzir um único plugin (v1)
2. fix_malformed.py ← Corrigir sintaxe .po mal formada
3. fix_ptbr.py ← Corrigir PT-BR → PT-PT
4. translate_missing.py ← Preencher strings em falta via API
5. batch_process_library.py ← SCRIPT MESTRE: processar biblioteca inteira
USO RÁPIDO — BIBLIOTECA INTEIRA
=================================
# Processar TODOS os plugins (passos 1+2+3+4 automáticos):
python3 /media/ealmeida/Dados/Dev/Scripts/translate-wp-plugin/batch_process_library.py \
/media/ealmeida/Dados/Dev/WordPress/Tradução-Plugins-PT-PT
# Apenas correcções PT-BR (sem chamar API de tradução):
python3 batch_process_library.py /path/to/library --skip-translate
# Simular sem gravar:
python3 batch_process_library.py /path/to/library --dry-run
USO INDIVIDUAL — SCRIPTS ESPECÍFICOS
======================================
fix_malformed.py — Corrige sintaxe .po mal formada
---------------------------------------------------
# Um ficheiro:
python3 fix_malformed.py plugin-pt_PT.po
# Toda uma biblioteca:
find /path/to/library -name "*-pt_PT.po" | xargs python3 fix_malformed.py
fix_ptbr.py — Corrige PT-BR → PT-PT
--------------------------------------
# Um ficheiro:
python3 fix_ptbr.py plugin-pt_PT.po
# Toda uma biblioteca:
find /path/to/library -name "*-pt_PT.po" | xargs python3 fix_ptbr.py
Correcções aplicadas:
salvar → guardar
senha → palavra-passe
usuário → utilizador
arquivo → ficheiro
ativar → activar
atualizar → actualizar
desativar → desactivar
conectar → ligar
dashboard → painel
backup → cópia de segurança
você → o utilizador
(+ 40 outros termos)
translate_missing.py — Traduz strings em falta via LibreTranslate
------------------------------------------------------------------
# Um ficheiro:
python3 translate_missing.py plugin-pt_PT.po
# Simular (sem gravar):
python3 translate_missing.py plugin-pt_PT.po --dry-run
# Toda uma biblioteca:
find /path/to/library -name "*-pt_PT.po" | xargs python3 translate_missing.py
Requisitos:
- LibreTranslate API: https://translate.descomplicar.pt
- Rate limit: 0.3s entre chamadas
- Sem API key (whitelist IP 188.251.199.30)
translate-wordpress-plugin.py — Traduzir plugin único (v1 legacy)
-----------------------------------------------------------------
python3 translate-wordpress-plugin.py input.po
python3 translate-wordpress-plugin.py input.po --output output-pt_PT.po
python3 translate-wordpress-plugin.py input.po --api-url http://localhost:5000
REQUISITOS
==========
- Python 3.8+
- gettext (msgfmt): sudo apt install gettext
- LibreTranslate API: https://translate.descomplicar.pt (self-hosted)
- Acesso de rede ao servidor de tradução
FLUXO RECOMENDADO
=================
Para uma biblioteca nova ou actualização:
1. fix_malformed — garantir que os .po são válidos
2. fix_ptbr — substituir PT-BR por PT-PT
3. translate_missing — preencher strings em falta
4. msgfmt — recompilar .mo
O batch_process_library.py executa este fluxo automaticamente.
BIBLIOTECA PRINCIPAL
====================
Localização: /media/ealmeida/Dados/Dev/WordPress/Tradução-Plugins-PT-PT/
Glossário: /media/ealmeida/Dados/Dev/WordPress/Tradução-Plugins-PT-PT/GLOSSARIO-PT-PT.md
Destino WP: /wp-content/languages/loco/plugins/PLUGIN-SLUG-pt_PT.po
/wp-content/languages/loco/plugins/PLUGIN-SLUG-pt_PT.mo
CHANGELOG
=========
v2.0.0 (20-02-2026)
- Adicionado fix_malformed.py (correcção de sintaxe .po)
- Adicionado fix_ptbr.py (correcções PT-BR → PT-PT expandidas)
- Adicionado translate_missing.py (tradução de strings em falta)
- Adicionado batch_process_library.py (orquestrador para biblioteca inteira)
- Glossário expandido com 50+ termos adicionais
v1.0.0 (11-02-2026)
- Script inicial de tradução de plugin único
CONTACTO
========
Emanuel Almeida <emanuel@descomplicar.pt>
Descomplicar® Crescimento Digital

View File

@@ -0,0 +1,223 @@
# WordPress PT-PT Translation System v1.0.0
Sistema eficiente de traduções WordPress para Português Europeu com correcção automática de marcas, conversão PT-BR→PT-PT e validação de qualidade.
## Quick Start
```bash
# 1. Inicializar base de dados (primeira vez)
python3 wp-translate-ptpt.py --init-db
# 2. Processar ficheiro individual
python3 wp-translate-ptpt.py /path/to/plugin-pt_PT.po
# 3. Processar biblioteca inteira (só marcas)
python3 wp-translate-ptpt.py --brands-only --batch /biblioteca/
# 4. Tradução completa de novo plugin
python3 wp-translate-ptpt.py /novo-plugin-pt_PT.po
```
## Features
**Correcção automática de marcas** — Detecta "Formulários Fluentes" → "Fluent Forms"
**98 marcas seed** — Fluent Forms, Rank Math, Elementor, WooCommerce, etc.
**50+ regras PT-BR→PT-PT** — gerenciar→gerir, deletar→eliminar, senha→palavra-passe
**Cache SQLite** — Traduções reutilizadas, processamento rápido
**Validação qualidade** — Preserva placeholders (%s, %d) e HTML tags
**Batch processing** — Centenas de ficheiros em segundos
**Backup/rollback** — Reversão automática em caso de erro
**Compilação .mo** — Automática e tolerante a erros
## Arquitectura
### 6 Classes Core
```
BrandProtector → Detecta e corrige marcas traduzidas literalmente
TranslationEngine → LibreTranslate API wrapper com retry
PoFileHandler → Parser/writer ficheiros .po
CacheManager → SQLite cache + auditoria
QualityValidator → Validação placeholders/HTML/PT-BR
TranslationProcessor → Orquestrador pipeline 9 etapas
```
### Pipeline de 9 Etapas
```
1. Parse .po → PoEntry objects
2. Brand protection (pre-translation)
3. Filter (cache check)
4. Translate (LibreTranslate)
5. Restore brands
6. PT-BR fixes
7. Brand correction (post-translation)
8. Quality validation
9. Save + compile .mo
```
## Modos de Uso
### Single File
```bash
python3 wp-translate-ptpt.py /path/to/plugin-pt_PT.po
```
### Batch Processing
```bash
python3 wp-translate-ptpt.py --batch /biblioteca/
```
### Brands Only (sem traduzir, só corrigir marcas)
```bash
python3 wp-translate-ptpt.py --brands-only ficheiro.po
python3 wp-translate-ptpt.py --brands-only --batch /biblioteca/
```
### Dry Run (simular sem alterar)
```bash
python3 wp-translate-ptpt.py --dry-run ficheiro.po
```
### Database Management
```bash
# Inicializar
python3 wp-translate-ptpt.py --init-db
# Exportar marcas
python3 wp-translate-ptpt.py --export-brands brands.json
# Importar marcas
python3 wp-translate-ptpt.py --import-brands brands.json
```
## Base de Dados SQLite
**Localização:** `wp-translations.db`
### 3 Tabelas
```sql
brands 98 marcas seed + auto-detectadas
translations Cache traduções (MD5 hash lookup)
corrections Log auditoria de todas as correcções
```
## Marcas Seed (98 total)
```
Fluent Forms, FluentCRM, Fluent SMTP, Rank Math, Elementor,
Elementor Pro, WooCommerce, WPForms, Wordfence, UpdraftPlus,
WP Rocket, Loco Translate, Bit Integrations, Element Pack,
ElementsKit, Happy Addons, Real Cookie Banner, Google,
Facebook, Instagram, PayPal, Stripe, WordPress, ...
```
## Conversões PT-BR → PT-PT (50+ regras)
```python
salvar guardar
deletar eliminar
gerenciar gerir
senha palavra-passe
arquivo ficheiro
atualiz* actualiz*
voce o utilizador
habilitar activar
desabilitar desactivar
cadastro registo
```
## Traduções Literais Detectadas (15+)
```python
"Fluent Forms" ["Formulários Fluentes", "Formas Fluentes"]
"Rank Math" ["Matemática de Classificação"]
"Happy Addons" ["Complementos Felizes"]
"Wordfence" ["Cerca de Palavras"]
"Element Pack" ["Pacote de Elementos"]
```
## Performance
**Teste individual (FluentForm):**
- 53 marcas corrigidas
- Tempo: 1.2s
**Batch (241 ficheiros):**
- 15 marcas corrigidas
- Tempo: 42.8s
- Velocidade: ~5.6 ficheiros/segundo
## Dependências
```bash
pip install requests # LibreTranslate API
# gettext tools (msgfmt) - já incluído na maioria das distros Linux
```
## Configuração LibreTranslate
**API URL (default):** `https://translate.descomplicar.pt/translate`
**Auth:** Whitelist IP (sem API key)
**Rate limit:** 0.3s entre chamadas
## Troubleshooting
### Erros msgfmt
Alguns ficheiros têm erros msgfmt pré-existentes (placeholders perdidos, headers inválidos). O script continua processamento e avisa, mas não falha.
### Ficheiros duplicados
A biblioteca pode ter duplicados em diferentes localizações. O script processa todos.
### Cache
Para limpar cache: `rm wp-translations.db` e executar `--init-db` novamente.
## Ficheiros
```
wp-translate-ptpt.py → Script principal (~950 linhas)
wp-translations.db → Base de dados SQLite
CHANGELOG.md → Histórico de versões
REPORT-FINAL-*.md → Reports de execução
README.md → Esta documentação
```
## Scripts Anteriores
Este script substitui:
- `translate-wordpress-plugin.py` (v1.0)
- `translate_missing.py` (v2.0 LibreTranslate)
- `fix_ptbr.py` (conversões PT-BR)
- `fix_malformed.py` (correcções sintaxe)
## Próximos Passos
1. **Manutenção mensal:**
```bash
python3 wp-translate-ptpt.py --brands-only --batch /biblioteca/
```
2. **Novos plugins:**
```bash
python3 wp-translate-ptpt.py /novo-plugin-pt_PT.po
```
3. **Auditoria PT-BR:**
```bash
grep -r "gerenciar\|deletar\|voce" *.po
```
## Contribuir
1. Adicionar novas marcas: editar `SEED_BRANDS` (linha ~800)
2. Adicionar regras PT-BR: editar `PTBR_TO_PTPT` (linha ~400)
3. Adicionar mapeamentos literais: editar `LITERAL_TRANSLATIONS` (linha ~200)
## Versão
**v1.0.0** — 2026-02-23
**Autor:** Descomplicar® Crescimento Digital
**Licença:** Proprietário
**Python:** 3.8+

View File

@@ -0,0 +1,236 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
batch_process_library.py — Processa uma biblioteca inteira de traduções .po WordPress.
Executa em sequência os passos obrigatórios em TODOS os ficheiros *-pt_PT.po:
1. fix_malformed — Corrige sintaxe .po mal formada
2. fix_ptbr — Corrige PT-BR → PT-PT (saltado com --deepl, DeepL gera PT-PT nativo)
3. translate_missing — Traduz strings em falta via DeepL PT-PT (padrão) ou LibreTranslate
No final compila todos os .mo via polib e gera relatório.
Uso:
python3 batch_process_library.py /path/to/library
python3 batch_process_library.py /path/to/library --skip-translate
python3 batch_process_library.py /path/to/library --only-ptbr
python3 batch_process_library.py /path/to/library --dry-run
python3 batch_process_library.py /path/to/library --no-deepl (usar LibreTranslate)
Exemplos:
python3 batch_process_library.py /media/ealmeida/Dados/Dev/WordPress/Tradução-Plugins-PT-PT
python3 batch_process_library.py /media/ealmeida/Dados/Dev/WordPress/Tradução-Plugins-PT-PT --skip-translate
Author: Descomplicar®
Version: 2.0.0 — DeepL PT-PT
"""
import sys
import os
import subprocess
import time
import argparse
from pathlib import Path
from datetime import datetime
SCRIPT_DIR = Path(__file__).parent
FIX_MALFORMED = SCRIPT_DIR / 'fix_malformed.py'
FIX_PTBR = SCRIPT_DIR / 'fix_ptbr.py'
TRANSLATE_MISSING = SCRIPT_DIR / 'translate_missing.py'
def find_po_files(library_path: Path) -> list[Path]:
"""Encontra todos os ficheiros *-pt_PT.po na biblioteca (maxdepth 2)."""
po_files = []
for po in sorted(library_path.rglob('*-pt_PT.po')):
# Ignorar backups
if '.bak' in po.name:
continue
# Máximo 2 níveis de profundidade
rel = po.relative_to(library_path)
if len(rel.parts) <= 2:
po_files.append(po)
return po_files
def run_script(script: Path, files: list[Path], extra_args: list[str] = None) -> int:
"""Executa um script Python com lista de ficheiros."""
if not files:
return 0
cmd = ['python3', str(script)] + [str(f) for f in files]
if extra_args:
cmd += extra_args
result = subprocess.run(cmd, capture_output=False)
return result.returncode
def compile_mo(po_file: Path) -> bool:
"""Compila um .po para .mo via polib (mais permissivo que msgfmt)."""
mo_file = po_file.with_suffix('.mo')
try:
import polib
po = polib.pofile(str(po_file), encoding='utf-8')
po.save_as_mofile(str(mo_file))
return True
except Exception:
# Fallback: msgfmt
r = subprocess.run(
['msgfmt', str(po_file), '-o', str(mo_file)],
stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
return r.returncode == 0
def count_empty_strings(po_file: Path) -> tuple[int, int]:
"""Conta strings totais e vazias num .po. Devolve (total, vazias)."""
try:
with open(po_file, 'r', encoding='utf-8', errors='replace') as f:
content = f.read()
total = content.count('\nmsgid "') + (1 if content.startswith('msgid "') else 0)
# Não contar o header (msgid "")
empty = content.count('\nmsgstr ""\n')
return max(0, total - 1), empty
except Exception:
return 0, 0
def main():
parser = argparse.ArgumentParser(
description='Processa biblioteca de traduções .po WordPress para PT-PT'
)
parser.add_argument('library', help='Caminho para a pasta com os plugins')
parser.add_argument('--skip-translate', action='store_true',
help='Saltar tradução de strings em falta (só correcções)')
parser.add_argument('--only-ptbr', action='store_true',
help='Executar apenas correcções PT-BR (sem tradução)')
parser.add_argument('--dry-run', action='store_true',
help='Simular sem gravar alterações')
parser.add_argument('--no-deepl', action='store_true',
help='Usar LibreTranslate em vez de DeepL (legado)')
args = parser.parse_args()
library = Path(args.library)
if not library.exists():
print(f"ERRO: pasta não encontrada: {library}", file=sys.stderr)
sys.exit(1)
start_time = time.time()
timestamp = datetime.now().strftime('%d-%m-%Y %H:%M')
print(f"\n{'='*65}")
print(f" Batch Process Library — PT-PT WordPress Translations")
print(f" {timestamp}")
print(f"{'='*65}")
print(f" Biblioteca: {library}")
# Encontrar todos os .po
po_files = find_po_files(library)
if not po_files:
print(f"\nNenhum ficheiro *-pt_PT.po encontrado em {library}")
sys.exit(0)
print(f" Ficheiros .po encontrados: {len(po_files)}")
# --- PASSO 1: Corrigir malformações ---
if not args.only_ptbr:
print(f"\n{''*65}")
print(f" PASSO 1/3 — Corrigir malformações de sintaxe .po")
print(f"{''*65}")
run_script(FIX_MALFORMED, po_files)
# --- PASSO 2: Corrigir PT-BR → PT-PT ---
print(f"\n{''*65}")
print(f" PASSO 2/3 — Corrigir PT-BR → PT-PT")
print(f"{''*65}")
run_script(FIX_PTBR, po_files)
# --- PASSO 3: Traduzir strings em falta ---
if not args.skip_translate and not args.only_ptbr:
engine = 'LibreTranslate' if args.no_deepl else 'DeepL PT-PT'
print(f"\n{''*65}")
print(f" PASSO 3/4 — Traduzir strings em falta via {engine}")
print(f"{''*65}")
extra = ['--dry-run'] if args.dry_run else []
run_script(TRANSLATE_MISSING, po_files, extra)
# 2ª passagem fix_ptbr — DeepL pode introduzir padrões PT-BR no output
if not args.dry_run:
print(f"\n{''*65}")
print(f" PASSO 3.5/4 — Fix PT-BR pós-tradução (2ª passagem)")
print(f"{''*65}")
run_script(FIX_PTBR, po_files)
else:
print(f"\n PASSO 3/4 — Tradução de strings: SALTADO")
# --- PASSO 4: Recompilar todos os .mo ---
if not args.dry_run:
print(f"\n{''*65}")
print(f" PASSO 4/4 — Recompilar .mo")
print(f"{''*65}")
mo_ok = 0
mo_err = 0
mo_errors = []
for po in po_files:
ok = compile_mo(po)
if ok:
mo_ok += 1
else:
mo_err += 1
mo_errors.append(po.name)
print(f" .mo compilados: {mo_ok} OK | {mo_err} com erro")
if mo_errors:
print(f" Erros: {', '.join(mo_errors[:5])}")
# --- RELATÓRIO FINAL ---
elapsed = time.time() - start_time
print(f"\n{'='*65}")
print(f" RELATÓRIO FINAL")
print(f"{'='*65}")
total_strings = 0
total_empty = 0
plugins_100 = 0
plugins_partial = 0
for po in po_files:
total, empty = count_empty_strings(po)
total_strings += total
total_empty += empty
if empty == 0:
plugins_100 += 1
else:
plugins_partial += 1
coverage = ((total_strings - total_empty) / total_strings * 100) if total_strings > 0 else 0
print(f" Plugins processados: {len(po_files)}")
print(f" 100% traduzidos: {plugins_100}")
print(f" Com strings em falta: {plugins_partial}")
print(f" Strings totais: {total_strings}")
print(f" Strings em falta: {total_empty}")
print(f" Cobertura: {coverage:.1f}%")
print(f" Tempo total: {elapsed:.0f}s")
print(f"{'='*65}\n")
# Limpar backups temporários
if not args.dry_run:
bak_count = 0
for bak in library.rglob('*.bak_*'):
bak.unlink()
bak_count += 1
if bak_count:
print(f" {bak_count} ficheiros de backup temporário removidos")
if plugins_partial > 0:
print(f"\n Plugins com strings ainda em falta:")
for po in po_files:
_, empty = count_empty_strings(po)
if empty > 0:
print(f" - {po.parent.name}: {empty} string(s) em falta")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,165 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
fix_malformed.py — Corrige ficheiros .po com msgstr mal formados.
Problemas detectados e corrigidos:
1. Aspa de fecho escapada (linha termina com \")
2. Sequência de escape inválida (backslash + espaço)
3. Aspas internas não escapadas
Se existirem linhas de continuação, usa-as como tradução correcta.
Se não houver continuação, fecha a aspa em falta.
Uso:
python3 fix_malformed.py plugin-pt_PT.po [plugin2-pt_PT.po ...]
find /path/to/library -name "*-pt_PT.po" | xargs python3 fix_malformed.py
Author: Descomplicar®
Version: 1.1.0
"""
import sys
import re
import shutil
import subprocess
from pathlib import Path
def last_quote_is_escaped(s: str) -> bool:
"""Verifica se a última aspas está escapada com backslash."""
if not s.endswith('"'):
return False
count = 0
i = len(s) - 2
while i >= 0 and s[i] == '\\':
count += 1
i -= 1
return count % 2 == 1
def has_invalid_escape(s: str) -> bool:
"""Detecta sequências de escape inválidas (backslash + espaço/tab)."""
return bool(re.search(r'\\[ \t]', s))
def has_unescaped_internal_quote(s: str) -> bool:
"""Detecta aspas não escapadas no interior da string."""
i = 0
while i < len(s):
if s[i] == '\\':
i += 2
continue
if s[i] == '"':
return True
i += 1
return False
def is_malformed(line: str) -> bool:
"""Determina se uma linha msgstr está mal formada."""
if not line.startswith('msgstr "'):
return False
content = line[7:]
# Vazio é válido
if content == '""':
return False
# Não termina em aspas, ou a aspas final está escapada
if not content.endswith('"') or last_quote_is_escaped(content):
return True
# Escape inválido no interior
if has_invalid_escape(content):
return True
# Aspas internas não escapadas
inner = content[1:-1]
if has_unescaped_internal_quote(inner):
return True
return False
def process_file(filepath: str) -> tuple[int, bool]:
"""
Processa um ficheiro .po e corrige malformações.
Devolve (número de correcções, sucesso do .mo).
"""
path = Path(filepath)
if not path.exists():
print(f" ERRO: ficheiro não encontrado: {filepath}", file=sys.stderr)
return 0, False
with open(filepath, 'r', encoding='utf-8') as f:
lines = f.readlines()
new_lines = []
i = 0
fixes = 0
while i < len(lines):
line = lines[i].rstrip('\n')
if is_malformed(line):
# Verificar se há linhas de continuação
continuation = []
j = i + 1
while j < len(lines) and lines[j].rstrip('\n').startswith('"'):
continuation.append(lines[j])
j += 1
if continuation:
# Usar continuação como tradução correcta
new_lines.append('msgstr ""\n')
for c in continuation:
new_lines.append(c)
fixes += 1
i = j
continue
elif not line.endswith('"') or last_quote_is_escaped(line):
# Fechar aspa em falta
new_lines.append(line + '"\n')
fixes += 1
i += 1
continue
new_lines.append(lines[i])
i += 1
if fixes > 0:
shutil.copy2(filepath, filepath + '.bak_malformed')
with open(filepath, 'w', encoding='utf-8') as f:
f.writelines(new_lines)
mo_path = filepath.replace('.po', '.mo')
r = subprocess.run(
['msgfmt', filepath, '-o', mo_path],
stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
mo_ok = r.returncode == 0
mo_status = "OK" if mo_ok else f"ERRO: {r.stderr.decode()[:80]}"
print(f" {path.name}: {fixes} correcção(ões) | .mo: {mo_status}")
return fixes, mo_ok
else:
print(f" {path.name}: sem malformações")
return 0, True
def main():
if len(sys.argv) < 2:
print("Uso: python3 fix_malformed.py <ficheiro.po> [...]")
print(" find /path -name '*-pt_PT.po' | xargs python3 fix_malformed.py")
sys.exit(1)
total_fixes = 0
total_files = 0
errors = 0
for filepath in sys.argv[1:]:
total_files += 1
fixes, ok = process_file(filepath)
total_fixes += fixes
if not ok:
errors += 1
print(f"\nTotal: {total_fixes} correcções em {total_files} ficheiro(s) | Erros .mo: {errors}")
if __name__ == '__main__':
main()

348
translate-wp-plugin/fix_ptbr.py Executable file
View File

@@ -0,0 +1,348 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
fix_ptbr.py — Corrige termos PT-BR para PT-PT em ficheiros .po.
Aplica substituições apenas em linhas msgstr (nunca em msgid ou comentários).
Preserva placeholders e marcas registadas.
Uso:
python3 fix_ptbr.py plugin-pt_PT.po [plugin2-pt_PT.po ...]
find /path/to/library -name "*-pt_PT.po" | xargs python3 fix_ptbr.py
Author: Descomplicar®
Version: 1.1.0
"""
import sys
import re
import shutil
import subprocess
from pathlib import Path
# Substituições PT-BR → PT-PT (padrão: word boundary para evitar falsos positivos)
FIXES = [
# --- Verbos ---
(r'\bsalvar\b', 'guardar'),
(r'\bSalvar\b', 'Guardar'),
(r'\bsalve\b', 'guarde'),
(r'\bSalve\b', 'Guarde'),
(r'\bativar\b', 'activar'),
(r'\bAtivar\b', 'Activar'),
(r'\bativado\b', 'activado'),
(r'\bAtivado\b', 'Activado'),
(r'\bativados\b', 'activados'),
(r'\bAtivados\b', 'Activados'),
(r'\bativada\b', 'activada'),
(r'\bAtivada\b', 'Activada'),
(r'\batualizar\b', 'actualizar'),
(r'\bAtualizar\b', 'Actualizar'),
(r'\batualização\b', 'actualização'),
(r'\bAtualização\b', 'Actualização'),
(r'\batualizações\b', 'actualizações'),
(r'\bAtualizações\b', 'Actualizações'),
(r'\bdesativar\b', 'desactivar'),
(r'\bDesativar\b', 'Desactivar'),
(r'\bdesativado\b', 'desactivado'),
(r'\bDesativado\b', 'Desactivado'),
(r'\bdesativada\b', 'desactivada'),
(r'\bDesativada\b', 'Desactivada'),
(r'\bconectar\b', 'ligar'),
(r'\bConectar\b', 'Ligar'),
(r'\bconectado\b', 'ligado'),
(r'\bConectado\b', 'Ligado'),
(r'\bdesconectar\b', 'desligar'),
(r'\bDesconectar\b', 'Desligar'),
(r'\bdesconectado\b', 'desligado'),
(r'\bDesconectado\b', 'Desligado'),
(r'\breconectar\b', 'religar'),
(r'\bReconectar\b', 'Religar'),
(r'\bselecionar\b', 'seleccionar'),
(r'\bSelecionar\b', 'Seleccionar'),
(r'\bexcluir\b', 'eliminar'),
(r'\bExcluir\b', 'Eliminar'),
(r'\bexcluído\b', 'eliminado'),
(r'\bExcluído\b', 'Eliminado'),
(r'\bexcluída\b', 'eliminada'),
(r'\bExcluída\b', 'Eliminada'),
(r'\bexcluídos\b', 'eliminados'),
(r'\bExcluídos\b', 'Eliminados'),
(r'\bexcluídas\b', 'eliminadas'),
(r'\bExcluídas\b', 'Eliminadas'),
(r'\bacessar\b', 'aceder'),
(r'\bAcessar\b', 'Aceder'),
(r'\bcompartilhar\b', 'partilhar'),
(r'\bCompartilhar\b', 'Partilhar'),
(r'\bcompartilhado\b', 'partilhado'),
(r'\bCompartilhado\b', 'Partilhado'),
(r'\bcompartilhada\b', 'partilhada'),
(r'\bCompartilhada\b', 'Partilhada'),
(r'\bcompartilhados\b', 'partilhados'),
(r'\bCompartilhados\b', 'Partilhados'),
(r'\bcompartilhadas\b', 'partilhadas'),
(r'\bCompartilhadas\b', 'Partilhadas'),
(r'\bdigite\b', 'introduza'),
(r'\bDigite\b', 'Introduza'),
# --- Substantivos ---
(r'\bsenha\b', 'palavra-passe'),
(r'\bSenha\b', 'Palavra-passe'),
(r'\bsenhas\b', 'palavras-passe'),
(r'\bSenhas\b', 'Palavras-passe'),
(r'\busuário\b', 'utilizador'),
(r'\bUsuário\b', 'Utilizador'),
(r'\busuários\b', 'utilizadores'),
(r'\bUsuários\b', 'Utilizadores'),
(r'\busuario\b', 'utilizador'),
(r'\bUsuario\b', 'Utilizador'),
(r'\barquivo\b', 'ficheiro'),
(r'\bArquivo\b', 'Ficheiro'),
(r'\barquivos\b', 'ficheiros'),
(r'\bArquivos\b', 'Ficheiros'),
(r'\bconexão\b', 'ligação'),
(r'\bConexão\b', 'Ligação'),
(r'\bseleção\b', 'selecção'),
(r'\bSeleção\b', 'Selecção'),
(r'\bselecionado\b', 'seleccionado'),
(r'\bSelecionado\b', 'Seleccionado'),
(r'\baplicativo\b', 'aplicação'),
(r'\bAplicativo\b', 'Aplicação'),
(r'\baplicativos\b', 'aplicações'),
(r'\bAplicativos\b', 'Aplicações'),
(r'\bdashboard\b', 'painel'),
(r'\bDashboard\b', 'Painel'),
(r'\bbackup\b', 'cópia de segurança'),
(r'\bBackup\b', 'Cópia de segurança'),
(r'\bbackups\b', 'cópias de segurança'),
(r'\bBackups\b', 'Cópias de segurança'),
(r'\bpostagem\b', 'publicação'),
(r'\bPostagem\b', 'Publicação'),
(r'\bpostagens\b', 'publicações'),
(r'\bPostagens\b', 'Publicações'),
(r'\bativo\b', 'activo'),
(r'\bAtivo\b', 'Activo'),
(r'\bativos\b', 'activos'),
(r'\bAtivos\b', 'Activos'),
(r'\bativa\b', 'activa'),
(r'\bAtiva\b', 'Activa'),
(r'\bativas\b', 'activas'),
(r'\bAtivas\b', 'Activas'),
(r'\binativo\b', 'inactivo'),
(r'\bInativo\b', 'Inactivo'),
(r'\binativos\b', 'inactivos'),
(r'\bInativos\b', 'Inactivos'),
(r'\binativa\b', 'inactiva'),
(r'\bInativa\b', 'Inactiva'),
(r'\binativas\b', 'inactivas'),
(r'\bInativas\b', 'Inactivas'),
(r'\bativação\b', 'activação'),
(r'\bAtivação\b', 'Activação'),
(r'\bdesativação\b', 'desactivação'),
(r'\bDesativação\b', 'Desactivação'),
# --- Ortografia PT-PT ---
# Formas garbled LibreTranslate (Updated→Atualizard, Updates→Atualizars)
(r'\bAtualizard\b', 'Actualizado'),
(r'\batualizard\b', 'actualizado'),
(r'\bAtualizars\b', 'Actualizações'),
(r'\batualizars\b', 'actualizações'),
# Formas maiúsculas (all-caps)
(r'\bATUALIZAR\b', 'ACTUALIZAR'),
(r'\bATUALIZAÇÃO\b', 'ACTUALIZAÇÃO'),
(r'\bATIVAR\b', 'ACTIVAR'),
(r'\bDESATIVAR\b', 'DESACTIVAR'),
(r'\bATIVADO\b', 'ACTIVADO'),
(r'\bDESATIVADO\b', 'DESACTIVADO'),
(r'\bARQUIVO\b', 'FICHEIRO'),
(r'\bARQUIVOS\b', 'FICHEIROS'),
# Todas as formas de atualiz* → actualiz*
(r'\batualizada\b', 'actualizada'),
(r'\bAtualizada\b', 'Actualizada'),
(r'\batualizadas\b', 'actualizadas'),
(r'\bAtualizadas\b', 'Actualizadas'),
(r'\batualizado\b', 'actualizado'),
(r'\bAtualizado\b', 'Actualizado'),
(r'\batualizados\b', 'actualizados'),
(r'\bAtualizados\b', 'Actualizados'),
(r'\batualize\b', 'actualize'),
(r'\bAtualize\b', 'Actualize'),
(r'\batualiza\b', 'actualiza'),
(r'\bAtualiza\b', 'Actualiza'),
(r'\batualizando\b', 'actualizando'),
(r'\bAtualizando\b', 'Actualizando'),
(r'\batualizou\b', 'actualizou'),
(r'\bAtualizou\b', 'Actualizou'),
(r'\batualizem\b', 'actualizem'),
(r'\bAtualizem\b', 'Actualizem'),
(r'\batualizam\b', 'actualizam'),
(r'\bAtualizam\b', 'Actualizam'),
(r'\batualizamos\b', 'actualizamos'),
(r'\bAtualizamos\b', 'Actualizamos'),
(r'\batualizei\b', 'actualizei'),
(r'\bAtualizei\b', 'Actualizei'),
(r'\batualizará\b', 'actualizará'),
(r'\bAtualizará\b', 'Actualizará'),
(r'\batualizarão\b', 'actualizarão'),
(r'\bAtualizarão\b', 'Actualizarão'),
(r'\batualizarei\b', 'actualizarei'),
(r'\bAtualizarei\b', 'Actualizarei'),
(r'\batualizaremos\b', 'actualizaremos'),
(r'\bAtualizaremos\b', 'Actualizaremos'),
(r'\batualizarás\b', 'actualizarás'),
(r'\batualizassem\b', 'actualizassem'),
# Formas com acento (atualizá-los, atualizá-la)
(r'\batualizá\b', 'actualizá'),
(r'\bAtualizá\b', 'Actualizá'),
# Habilitar/desabilitar → activar/desactivar
(r'\bhabilitado\b', 'activado'),
(r'\bHabilitado\b', 'Activado'),
(r'\bhabilitada\b', 'activada'),
(r'\bHabilitada\b', 'Activada'),
(r'\bhabilitados\b', 'activados'),
(r'\bHabilitados\b', 'Activados'),
(r'\bhabilitadas\b', 'activadas'),
(r'\bHabilitadas\b', 'Activadas'),
(r'\bhabilitar\b', 'activar'),
(r'\bHabilitar\b', 'Activar'),
(r'\bdesabilitado\b', 'desactivado'),
(r'\bDesabilitado\b', 'Desactivado'),
(r'\bdesabilitada\b', 'desactivada'),
(r'\bDesabilitada\b', 'Desactivada'),
(r'\bdesabilitados\b', 'desactivados'),
(r'\bDesabilitados\b', 'Desactivados'),
(r'\bdesabilitar\b', 'desactivar'),
(r'\bDesabilitar\b', 'Desactivar'),
(r'\bdesabilitadas\b', 'desactivadas'),
(r'\bDesabilitadas\b', 'Desactivadas'),
# Formas plurais de desativar
(r'\bdesativados\b', 'desactivados'),
(r'\bDesativados\b', 'Desactivados'),
(r'\bdesativadas\b', 'desactivadas'),
(r'\bDesativadas\b', 'Desactivadas'),
(r'\bdesativando\b', 'desactivando'),
(r'\bDesativando\b', 'Desactivando'),
# Formas plurais de ativar
(r'\bativados\b', 'activados'),
(r'\bAtivados\b', 'Activados'),
(r'\bativadas\b', 'activadas'),
(r'\bAtivadas\b', 'Activadas'),
(r'\bativando\b', 'activando'),
(r'\bAtivando\b', 'Activando'),
(r'\bfatura\b', 'factura'),
(r'\bFatura\b', 'Factura'),
(r'\bfaturas\b', 'facturas'),
(r'\bFaturas\b', 'Facturas'),
(r'\bfacturar\b', 'facturar'),
(r'\bóptico\b', 'óptico'),
(r'\bótimo\b', 'óptimo'),
(r'\bÓtimo\b', 'Óptimo'),
(r'\bação\b', 'acção'),
(r'\bAção\b', 'Acção'),
(r'\bações\b', 'acções'),
(r'\bAções\b', 'Acções'),
(r'\bdireito\b', 'direito'), # igual — sem alteração necessária
(r'\bdiretamente\b', 'directamente'),
(r'\bDiretamente\b', 'Directamente'),
(r'\bdireto\b', 'directo'),
(r'\bDireto\b', 'Directo'),
(r'\bcontacto\b', 'contacto'), # já correcto
# --- Pronomes PT-BR ---
(r'\bVOCÊ\b', 'SI'),
(r'\bVOCÊS\b', 'VÓS'),
(r'\bvocê\b', 'o utilizador'),
(r'\bVocê\b', 'O utilizador'),
(r'\bvocês\b', 'os utilizadores'),
(r'\bVocês\b', 'Os utilizadores'),
# --- Expressões compostas ---
(r'\bsua conta\b', 'a sua conta'),
(r'\bSua conta\b', 'A sua conta'),
(r'\bnome de usuário\b', 'nome de utilizador'),
(r'\bNome de usuário\b', 'Nome de utilizador'),
(r'\bnome de usuario\b', 'nome de utilizador'),
]
def fix_line(line: str) -> str:
"""Aplica todas as substituições PT-BR → PT-PT numa linha."""
result = line
for pattern, replacement in FIXES:
result = re.sub(pattern, replacement, result)
return result
def process_file(filepath: str) -> tuple[int, bool]:
"""
Processa um ficheiro .po e corrige PT-BR → PT-PT.
Devolve (número de correcções, sucesso do .mo).
"""
path = Path(filepath)
if not path.exists():
print(f" ERRO: ficheiro não encontrado: {filepath}", file=sys.stderr)
return 0, False
with open(filepath, 'r', encoding='utf-8') as f:
lines = f.readlines()
new_lines = []
in_msgstr = False
fixes = 0
for line in lines:
stripped = line.rstrip('\n')
if stripped.startswith('msgstr ') or re.match(r'^msgstr\[\d+\]', stripped):
in_msgstr = True
fixed = fix_line(stripped)
if fixed != stripped:
fixes += 1
new_lines.append(fixed + '\n')
elif stripped.startswith('"') and in_msgstr:
fixed = fix_line(stripped)
if fixed != stripped:
fixes += 1
new_lines.append(fixed + '\n')
else:
if not stripped or stripped.startswith('#') or \
stripped.startswith('msgid') or stripped.startswith('msgctxt'):
in_msgstr = False
new_lines.append(line)
if fixes > 0:
shutil.copy2(filepath, filepath + '.bak_ptbr')
with open(filepath, 'w', encoding='utf-8') as f:
f.writelines(new_lines)
mo_path = filepath.replace('.po', '.mo')
r = subprocess.run(
['msgfmt', filepath, '-o', mo_path],
stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
mo_ok = r.returncode == 0
mo_status = "OK" if mo_ok else f"ERRO: {r.stderr.decode()[:80]}"
print(f" {path.name}: {fixes} correcção(ões) PT-BR | .mo: {mo_status}")
return fixes, mo_ok
else:
print(f" {path.name}: sem PT-BR detectado")
return 0, True
def main():
if len(sys.argv) < 2:
print("Uso: python3 fix_ptbr.py <ficheiro.po> [...]")
print(" find /path -name '*-pt_PT.po' | xargs python3 fix_ptbr.py")
sys.exit(1)
total_fixes = 0
total_files = 0
errors = 0
for filepath in sys.argv[1:]:
total_files += 1
fixes, ok = process_file(filepath)
total_fixes += fixes
if not ok:
errors += 1
print(f"\nTotal: {total_fixes} correcção(ões) em {total_files} ficheiro(s) | Erros .mo: {errors}")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,347 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
setup_glossary.py — Cria/actualiza glossário DeepL para WordPress PT-PT.
Combina:
1. Brand names dos plugins (mantidos sem tradução)
2. Termos WordPress PT-PT preferidos (Settings → Definições, etc.)
3. Plataformas sociais que DeepL traduz incorrectamente (Discord → Discórdia)
Uso:
python3 setup_glossary.py # cria/actualiza e guarda ID em .env
python3 setup_glossary.py --list # lista glossários existentes
python3 setup_glossary.py --test # testa tradução com glossário
Author: Descomplicar®
Version: 1.0.0
"""
import sys
import os
from pathlib import Path
# Carregar .env
_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: pip3 install deepl", file=sys.stderr)
sys.exit(1)
DEEPL_AUTH_KEY = os.environ.get('DEEPL_API_KEY', '')
GLOSSARY_NAME = 'WordPress PT-PT'
# ---------------------------------------------------------------------------
# 1. BRAND NAMES — manter exactamente como em inglês
# ---------------------------------------------------------------------------
BRAND_NAMES = {
# Plugins desta biblioteca
'AI Engine': 'AI Engine',
'Astra': 'Astra',
'BDThemes Element Pack': 'BDThemes Element Pack',
'BetterDocs': 'BetterDocs',
'Bit Integrations': 'Bit Integrations',
'Bit Social': 'Bit Social',
'Branda': 'Branda',
'Docket Cache': 'Docket Cache',
'Elementor': 'Elementor',
'Elementor Pro': 'Elementor Pro',
'ElementsKit': 'ElementsKit',
'Email Candy': 'Email Candy',
'Envato Elements': 'Envato Elements',
'Eventin': 'Eventin',
'FileBird': 'FileBird',
'Fluent Booking': 'Fluent Booking',
'FluentCRM': 'FluentCRM',
'FluentForms': 'FluentForms',
'Fluent Forms': 'Fluent Forms',
'Fluent SMTP': 'Fluent SMTP',
'Fluent Support': 'Fluent Support',
'Forminator': 'Forminator',
'GUM Elementor Addon': 'GUM Elementor Addon',
'Happy Elementor Addons': 'Happy Elementor Addons',
'HappyFiles': 'HappyFiles',
'Insert Headers and Footers': 'Insert Headers and Footers',
'JetEngine': 'JetEngine',
'KiviCare': 'KiviCare',
'Loco Translate': 'Loco Translate',
'LoginPress': 'LoginPress',
'MainWP': 'MainWP',
'BackWPup': 'BackWPup',
'MetForm': 'MetForm',
'PowerPack Elements': 'PowerPack Elements',
'Rank Math': 'Rank Math',
'Real Cookie Banner': 'Real Cookie Banner',
'Shipper': 'Shipper',
'TablePress': 'TablePress',
'Ultimate Branding': 'Ultimate Branding',
'Uncanny Automator': 'Uncanny Automator',
'UpdraftPlus': 'UpdraftPlus',
'WebP Express': 'WebP Express',
'WholesaleX': 'WholesaleX',
'WooCommerce': 'WooCommerce',
'WooCommerce Dashboard Stats': 'WooCommerce Dashboard Stats',
'Wordfence': 'Wordfence',
'WPCode': 'WPCode',
'WP Defender': 'WP Defender',
'WPForms': 'WPForms',
'WPFunnels': 'WPFunnels',
'WP Hummingbird': 'WP Hummingbird',
'WP Mail SMTP': 'WP Mail SMTP',
'WP Rocket': 'WP Rocket',
'WP Smush': 'WP Smush',
'WP Security Audit Log': 'WP Security Audit Log',
'WPMU DEV': 'WPMU DEV',
'WP-Optimize': 'WP-Optimize',
'WP Fastest Cache': 'WP Fastest Cache',
# Core WordPress
'WordPress': 'WordPress',
'Gutenberg': 'Gutenberg',
'WP-CLI': 'WP-CLI',
'phpMyAdmin': 'phpMyAdmin',
# Plugins adicionais starter.descomplicar.pt
'Complianz': 'Complianz',
'WP Activity Log': 'WP Activity Log',
'Meow Apps': 'Meow Apps',
'Happy Addons': 'Happy Addons',
# Termos AI/LLM que DeepL traduz incorrectamente
'Playground': 'Playground',
'Chatbot': 'Chatbot',
'Prompt': 'Prompt',
'Prompts': 'Prompts',
'Token': 'Token',
'Tokens': 'Tokens',
'Embedding': 'Embedding',
'Embeddings': 'Embeddings',
'Fine-tuning': 'Ajuste fino',
'Finetune': 'Ajuste fino',
'MCP': 'MCP',
# Plataformas que DeepL traduz (Discord → Discórdia, etc.)
'Discord': 'Discord',
'Slack': 'Slack',
'GitHub': 'GitHub',
'GitLab': 'GitLab',
'Bitbucket': 'Bitbucket',
'Zapier': 'Zapier',
'HubSpot': 'HubSpot',
'Mailchimp': 'Mailchimp',
'SendGrid': 'SendGrid',
'Twilio': 'Twilio',
'Stripe': 'Stripe',
'PayPal': 'PayPal',
'Moloni': 'Moloni',
'Multibanco': 'Multibanco',
'Google Analytics': 'Google Analytics',
'Google Tag Manager': 'Google Tag Manager',
'Google Search Console': 'Google Search Console',
'Google reCAPTCHA': 'Google reCAPTCHA',
'Facebook Pixel': 'Facebook Pixel',
'WhatsApp': 'WhatsApp',
'Telegram': 'Telegram',
'TikTok': 'TikTok',
'LinkedIn': 'LinkedIn',
'YouTube': 'YouTube',
'Instagram': 'Instagram',
'Twitter': 'Twitter',
'Twitch': 'Twitch',
'Pinterest': 'Pinterest',
'Dropbox': 'Dropbox',
'Amazon S3': 'Amazon S3',
'Google Drive': 'Google Drive',
'OneDrive': 'OneDrive',
# Termos técnicos a manter em inglês
'API': 'API',
'JSON': 'JSON',
'REST API': 'REST API',
'CSS': 'CSS',
'HTML': 'HTML',
'PHP': 'PHP',
'JavaScript': 'JavaScript',
'URL': 'URL',
'SSL': 'SSL',
'HTTPS': 'HTTPS',
'CDN': 'CDN',
'DNS': 'DNS',
'SMTP': 'SMTP',
'IMAP': 'IMAP',
'FTP': 'SFTP',
'SFTP': 'SFTP',
'OAuth': 'OAuth',
'JWT': 'JWT',
'SEO': 'SEO',
'UI': 'UI',
'UX': 'UX',
'CRM': 'CRM',
'SaaS': 'SaaS',
'GDPR': 'RGPD',
}
# ---------------------------------------------------------------------------
# 2. TERMOS WORDPRESS PT-PT
# Apenas termos críticos onde DeepL pode usar PT-BR
# ---------------------------------------------------------------------------
WORDPRESS_TERMS = {
'Settings': 'Definições',
'Password': 'Palavra-passe',
'Dashboard': 'Painel de controlo',
'Update': 'Actualização',
'Updates': 'Actualizações',
'Backup': 'Cópia de segurança',
'Backups': 'Cópias de segurança',
'Plugin': 'Plugin',
'Plugins': 'Plugins',
'Widget': 'Widget',
'Widgets': 'Widgets',
'Theme': 'Tema',
'Themes': 'Temas',
'Shortcode': 'Código curto',
'Webhook': 'Webhook',
'Log': 'Registo',
'Logs': 'Registos',
'Cache': 'Cache',
'Firewall': 'Firewall',
'Malware': 'Malware',
# E-commerce
'Cart': 'Carrinho',
'Checkout': 'Finalização de compra',
'Order': 'Encomenda',
'Orders': 'Encomendas',
'Coupon': 'Cupão',
'Coupons': 'Cupões',
'Stock': 'Stock',
'Inventory': 'Inventário',
'SKU': 'Referência',
'VAT': 'IVA',
# Segurança
'Login': 'Início de sessão',
'Logout': 'Terminar sessão',
'Whitelist': 'Lista de permissões',
'Blacklist': 'Lista de bloqueios',
# Formulários
'Checkbox': 'Caixa de seleção',
'Dropdown': 'Lista pendente',
'Submit': 'Submeter',
# Acções comuns
'Save': 'Guardar',
'Delete': 'Eliminar',
'Reset': 'Repor',
'Upload': 'Carregar',
'Download': 'Transferir',
}
# ---------------------------------------------------------------------------
def get_all_entries() -> dict:
entries = {}
entries.update(BRAND_NAMES)
entries.update(WORDPRESS_TERMS)
return entries
def list_glossaries(translator):
glossaries = translator.list_glossaries()
if not glossaries:
print("Sem glossários criados.")
return
for g in glossaries:
print(f" {g.name!r} — ID: {g.glossary_id} | {g.entry_count} entradas | {g.source_lang}{g.target_lang}")
def create_or_update_glossary(translator) -> str:
entries = get_all_entries()
print(f" Entradas a carregar: {len(entries)}")
# Verificar se já existe glossário com este nome e apagar
existing = translator.list_glossaries()
for g in existing:
if g.name == GLOSSARY_NAME:
print(f" Glossário existente encontrado (ID: {g.glossary_id}) — a substituir...")
translator.delete_glossary(g.glossary_id)
break
glossary = translator.create_glossary(
GLOSSARY_NAME,
source_lang='EN',
target_lang='PT',
entries=entries,
)
print(f" Glossário criado: {glossary.glossary_id}")
print(f" Entradas confirmadas: {glossary.entry_count}")
return glossary.glossary_id
def save_glossary_id(glossary_id: str):
env_path = Path(__file__).parent / '.env'
content = env_path.read_text()
if 'DEEPL_GLOSSARY_ID=' in content:
lines = []
for line in content.splitlines():
if line.startswith('DEEPL_GLOSSARY_ID='):
lines.append(f'DEEPL_GLOSSARY_ID={glossary_id}')
else:
lines.append(line)
env_path.write_text('\n'.join(lines) + '\n')
else:
with open(env_path, 'a') as f:
f.write(f'\nDEEPL_GLOSSARY_ID={glossary_id}\n')
print(f" ID guardado em .env: DEEPL_GLOSSARY_ID={glossary_id}")
def test_glossary(translator, glossary_id: str):
test_strings = [
'Discord notifications',
'Go to Settings',
'UpdraftPlus Backup',
'Password reset',
'WooCommerce Cart',
'Wordfence Firewall',
'Update available',
]
print(f"\n Teste de tradução com glossário:")
results = translator.translate_text(
test_strings,
source_lang='EN',
target_lang='PT-PT',
glossary=glossary_id,
)
for src, res in zip(test_strings, results):
print(f" EN: {src!r}")
print(f" PT: {res.text!r}")
print()
def main():
if not DEEPL_AUTH_KEY:
print("ERRO: DEEPL_API_KEY não definida.", file=sys.stderr)
sys.exit(1)
args = sys.argv[1:]
translator = deepl.Translator(DEEPL_AUTH_KEY)
if '--list' in args:
print("\nGlossários DeepL:")
list_glossaries(translator)
return
print(f"\n{'='*60}")
print(f" Setup Glossário DeepL — {GLOSSARY_NAME}")
print(f"{'='*60}")
glossary_id = create_or_update_glossary(translator)
save_glossary_id(glossary_id)
if '--test' in args or True: # sempre testar
test_glossary(translator, glossary_id)
print(f"\n Concluído. Usar DEEPL_GLOSSARY_ID={glossary_id}")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,958 @@
#!/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()

View File

@@ -0,0 +1,285 @@
#!/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()

View File

@@ -0,0 +1,967 @@
#!/usr/bin/env python3
"""
wp-translate-ptpt.py
Sistema eficiente de traduções WordPress PT-PT.
Author: Descomplicar® Crescimento Digital
Date: 2026-02-23
Version: 1.0.0
"""
import os
import sys
import re
import json
import time
import sqlite3
import hashlib
import argparse
import subprocess
import shutil
from pathlib import Path
from typing import Dict, List, Tuple, Optional
from dataclasses import dataclass, field
from urllib.request import Request, urlopen
from urllib.error import URLError
# Version
__version__ = "1.0.0"
# =============================================================================
# Data Classes
# =============================================================================
@dataclass
class PoEntry:
"""Represents a .po file entry."""
msgid: str = ""
msgid_plural: str = ""
msgstr: str = ""
msgstr_plural: Dict[int, str] = field(default_factory=dict)
comments: List[str] = field(default_factory=list)
line_start: int = 0
msgid_line: int = 0
msgstr_line: int = 0
@dataclass
class ProcessResult:
"""Result of processing a .po file."""
success: bool
total: int = 0
translated: int = 0
cached: int = 0
brands_fixed: int = 0
errors: List[Dict] = field(default_factory=list)
error: str = ""
# =============================================================================
# PT-BR to PT-PT Conversion Rules
# =============================================================================
PTBR_TO_PTPT = {
# Verbs
r'\bsalvar\b': 'guardar',
r'\bsalvo\b': 'guardado',
r'\bsalva\b': 'guardada',
r'\bdeletar\b': 'eliminar',
r'\bdeletado\b': 'eliminado',
r'\bdeletada\b': 'eliminada',
r'\bgerenciar\b': 'gerir',
r'\bgerenciamento\b': 'gestão',
r'\bgerenciado\b': 'gerido',
r'\bhabilitar\b': 'activar',
r'\bhabilitado\b': 'activado',
r'\bhabilitada\b': 'activada',
r'\bdesabilitar\b': 'desactivar',
r'\bdesabilitado\b': 'desactivado',
r'\bdesabilitada\b': 'desactivada',
r'\bacessar\b': 'aceder',
r'\bacessado\b': 'acedido',
r'\bbaixar\b': 'transferir',
r'\bcadastrar\b': 'registar',
r'\bcadastro\b': 'registo',
r'\bcadastrado\b': 'registado',
r'\bcompartilhar\b': 'partilhar',
r'\bcompartilhado\b': 'partilhado',
r'\bvisualizar\b': 'pré-visualizar',
# Nouns
r'\bsenha\b': 'palavra-passe',
r'\bsenhas\b': 'palavras-passe',
r'\barquivo\b': 'ficheiro',
r'\barquivos\b': 'ficheiros',
r'\btela\b': 'ecrã',
r'\btelas\b': 'ecrãs',
r'\bcelular\b': 'telemóvel',
r'\busuário\b': 'utilizador',
r'\busuários\b': 'utilizadores',
r'\bconfiguração\b': 'definição',
r'\bconfigurações\b': 'definições',
r'\blixeira\b': 'lixo',
r'\bequipe\b': 'equipa',
# Orthography (consoantes mudas)
r'\batualiz': 'actualiz',
r'\bfatura': 'factura',
r'\bselecion': 'seleccion',
r'\bação\b': 'acção',
r'\bações\b': 'acções',
r'\bprojeto\b': 'projecto',
r'\bprojetos\b': 'projectos',
r'\bdireção\b': 'direcção',
r'\bproteção\b': 'protecção',
r'\bcoleção\b': 'colecção',
r'\bcorreção\b': 'correcção',
r'\bótimo\b': 'óptimo',
# Gerund to infinitive
r'\bprocessando\b': 'a processar',
r'\bcarregando\b': 'a carregar',
r'\batualizando\b': 'a actualizar',
r'\bgerando\b': 'a gerar',
r'\bsalvando\b': 'a guardar',
r'\bdeletando\b': 'a eliminar',
}
def apply_ptbr_fixes(text: str) -> Tuple[str, int]:
"""Apply PT-BR to PT-PT conversions."""
fixed = text
count = 0
for pattern, replacement in PTBR_TO_PTPT.items():
before = fixed
fixed = re.sub(pattern, replacement, fixed, flags=re.IGNORECASE)
if fixed != before:
count += 1
return fixed, count
# =============================================================================
# Seed Brands
# =============================================================================
SEED_BRANDS = [
# 115 plugins from current library
"Fluent Forms", "FluentCRM", "Fluent SMTP", "Fluent Booking", "FluentCampaign Pro",
"Fluent Support",
"Rank Math", "Rank Math Pro",
"Element Pack", "Element Pack Lite",
"Elementor", "Elementor Pro",
"ElementsKit", "ElementsKit Lite",
"Happy Addons", "Happy Elementor Addons",
"WooCommerce", "WPForms", "WPForms Lite", "Wordfence",
"UpdraftPlus", "Real Cookie Banner", "Loco Translate",
"WP Fastest Cache", "Forminator", "Bit Integrations", "Bit Social", "Bit Pi",
"KiviCare", "KiviCare Pro", "Astra", "Branda", "TablePress",
"AI Engine", "BetterDocs", "Cookie Notice",
"Docket Cache", "Envato Elements", "Email Candy Pro",
"Eventin Pro", "Fast Indexing API",
"FileBird", "FileBird Document Library",
"GUM Elementor Addon", "HappyFiles Pro",
"Insert Headers and Footers",
"Iqonic Extensions", "Iqonic Layouts",
"JEG Elementor Kit", "Jet Engine",
"JWT Authentication",
"LoginPress", "MainWP BackWPup Extension",
"MetForm", "PowerPack Elements",
"Print My Blog", "Product Import Export for WooCommerce",
"Shipper", "SkyBoot Custom Icons",
"Testimonial Pro", "Ultimate Branding",
"Uncanny Automator",
"WebP Express", "WholesaleX",
"WooCommerce Dashboard Stats", "Woo Save Abandoned Carts",
"WPConsent", "WP Defender", "WP Event Solution",
"WP Hummingbird", "WP Mail SMTP", "WPMU DEV SEO",
"WPMU DEV Updates", "WP Optimize", "WP Rocket",
"WP Security Audit Log", "WP Smush Pro",
"WPFunnels", "WPFunnels Pro",
# Common services
"Google", "Facebook", "Instagram", "Twitter", "LinkedIn",
"PayPal", "Stripe", "Mailchimp", "Zapier", "HubSpot",
"OpenAI", "ChatGPT", "YouTube", "TikTok",
"Gmail", "Outlook",
# WordPress core
"WordPress", "Gutenberg", "Jetpack",
]
# =============================================================================
# CacheManager
# =============================================================================
class CacheManager:
"""Manages SQLite cache for translations and brands."""
def __init__(self, db_path: str):
"""Initialize database connection and create tables."""
self.conn = sqlite3.connect(db_path)
self._init_db()
def _init_db(self):
"""Create database schema."""
# Brands table
self.conn.execute("""
CREATE TABLE IF NOT EXISTS brands (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
variations TEXT,
auto_detected BOOLEAN DEFAULT 0,
confidence_score REAL DEFAULT 1.0,
last_seen TIMESTAMP,
plugin_slug TEXT
)
""")
# Translations cache
self.conn.execute("""
CREATE TABLE IF NOT EXISTS translations (
msgid_hash TEXT PRIMARY KEY,
msgid TEXT,
msgstr TEXT,
plugin_name TEXT,
validated BOOLEAN DEFAULT 0,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Corrections history
self.conn.execute("""
CREATE TABLE IF NOT EXISTS corrections (
id INTEGER PRIMARY KEY,
original TEXT,
corrected TEXT,
rule_applied TEXT,
plugin_name TEXT,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
self.conn.commit()
def get_cached_translation(self, msgid: str) -> Optional[str]:
"""Retrieve cached translation for msgid."""
msgid_hash = hashlib.md5(msgid.encode()).hexdigest()
cursor = self.conn.execute(
"SELECT msgstr FROM translations WHERE msgid_hash = ? AND validated = 1",
(msgid_hash,)
)
result = cursor.fetchone()
return result[0] if result else None
def save_translation(self, msgid: str, msgstr: str, plugin_name: str, validated: bool = False):
"""Save translation to cache."""
msgid_hash = hashlib.md5(msgid.encode()).hexdigest()
self.conn.execute(
"""INSERT OR REPLACE INTO translations
(msgid_hash, msgid, msgstr, plugin_name, validated, timestamp)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)""",
(msgid_hash, msgid, msgstr, plugin_name, validated)
)
self.conn.commit()
def log_correction(self, original: str, corrected: str, rule: str, plugin_name: str):
"""Log a correction to history."""
self.conn.execute(
"""INSERT INTO corrections (original, corrected, rule_applied, plugin_name, timestamp)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)""",
(original, corrected, rule, plugin_name)
)
self.conn.commit()
def close(self):
"""Close database connection."""
self.conn.close()
# =============================================================================
# BrandProtector
# =============================================================================
class BrandProtector:
"""Detects and protects brand names from literal translation."""
# Known literal translations
LITERAL_TRANSLATIONS = {
"Fluent Forms": ["Formulários Fluentes", "Formas Fluentes"],
"FluentCRM": ["CRM Fluente"],
"Fluent SMTP": ["SMTP Fluente"],
"Fluent Booking": ["Reserva Fluente"],
"Rank Math": ["Matemática de Classificação", "SEO Matemática"],
"Element Pack": ["Pacote de Elementos"],
"ElementsKit": ["Kit de Elementos"],
"Happy Addons": ["Complementos Felizes"],
"Happy Elementor Addons": ["Complementos Elementor Felizes"],
"Real Cookie Banner": ["Banner de Biscoito Real", "Bandeira de Biscoito Real"],
"Cookie Banner": ["Banner de Biscoito"],
"Loco Translate": ["Loco Traduzir"],
"WP Fastest Cache": ["Cache Mais Rápido WP"],
"Bit Integrations": ["Integrações Bit"],
"Bit Social": ["Social Bit"],
"Wordfence": ["Cerca de Palavras"],
}
def __init__(self, db_path: str):
"""Initialize with database path."""
self.db_path = db_path
self.cache = CacheManager(db_path) if db_path != ":memory:" else None
self.known_brands = self._load_brands()
def _load_brands(self) -> List[str]:
"""Load known brands from database."""
if not self.cache:
return list(self.LITERAL_TRANSLATIONS.keys())
cursor = self.cache.conn.execute("SELECT name FROM brands")
brands = [row[0] for row in cursor.fetchall()]
return brands if brands else list(self.LITERAL_TRANSLATIONS.keys())
def detect_brand_patterns(self, text: str) -> List[str]:
"""Detect possible brand names using heuristics."""
candidates = []
# Pattern 1: CamelCase
camel_case = re.findall(r'\b[A-Z][a-z]+(?:[A-Z][a-z]+)+\b', text)
candidates.extend(camel_case)
# Pattern 2: Acronyms
acronyms = re.findall(r'\b[A-Z]{2,}\b', text)
candidates.extend(acronyms)
# Pattern 3: Trademarks
trademarks = re.findall(r'(\w+(?:\s+\w+)?)\s*[®™]', text)
candidates.extend(trademarks)
# Pattern 4: Mid-sentence capitals
mid_sentence = re.findall(r'(?<=\s)[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*(?=\s)', text)
candidates.extend(mid_sentence)
return list(set(candidates))
def calculate_confidence(self, brand: str, occurrences: int = 1) -> float:
"""Calculate confidence score for detected brand."""
score = 0.0
# +0.4 if CamelCase
if re.match(r'^[A-Z][a-z]+(?:[A-Z][a-z]+)+$', brand):
score += 0.4
# +0.3 if has trademark
if any(char in brand for char in ['®', '']):
score += 0.3
# +0.1 per 5 occurrences (max 0.3)
score += min(occurrences / 5 * 0.1, 0.3)
return min(score, 1.0)
def protect_brands(self, text: str) -> Tuple[str, Dict[str, str]]:
"""Replace brand names with placeholders before translation."""
placeholders = {}
protected_text = text
for i, brand in enumerate(self.known_brands):
if brand in text:
placeholder = f"__BRAND_{i}__"
placeholders[placeholder] = brand
protected_text = protected_text.replace(brand, placeholder)
return protected_text, placeholders
def restore_brands(self, text: str, placeholders: Dict[str, str]) -> str:
"""Restore brand names after translation."""
restored_text = text
for placeholder, brand in placeholders.items():
restored_text = restored_text.replace(placeholder, brand)
return restored_text
def fix_translated_brands(self, msgid: str, msgstr: str) -> Tuple[str, List[str]]:
"""Fix brands that were literally translated."""
corrections = []
fixed_msgstr = msgstr
# Fix known literal translations
for correct_name, wrong_variations in self.LITERAL_TRANSLATIONS.items():
for wrong in wrong_variations:
if wrong in fixed_msgstr:
fixed_msgstr = fixed_msgstr.replace(wrong, correct_name)
corrections.append(f"{wrong}{correct_name}")
if self.cache:
self.cache.log_correction(
original=wrong,
corrected=correct_name,
rule="literal_translation",
plugin_name="unknown"
)
return fixed_msgstr, corrections
# =============================================================================
# QualityValidator
# =============================================================================
class QualityValidator:
"""Validates translation quality."""
PTBR_TERMS = [
'você', 'vocês', 'gerenciar', 'habilitar', 'desabilitar',
'deletar', 'salvar', 'arquivo', 'tela', 'senha', 'celular',
'usuário', 'configuração', 'cadastro', 'lixeira', 'gerenciamento',
'visualizar', 'acessar', 'baixar', 'compartilhar'
]
def validate_entry(self, entry: PoEntry) -> Tuple[bool, List[str]]:
"""Validate a complete entry."""
errors = []
# 1. Check placeholders
if not self._check_placeholders(entry.msgid, entry.msgstr):
errors.append("PLACEHOLDER_MISMATCH")
# 2. Check HTML tags
if not self._check_html_tags(entry.msgid, entry.msgstr):
errors.append("HTML_TAG_MISMATCH")
# 3. Check for empty translations
if entry.msgid and not entry.msgstr and not entry.msgstr_plural:
errors.append("EMPTY_TRANSLATION")
# 4. Check for PT-BR terms
ptbr_terms = self._detect_ptbr(entry.msgstr)
if ptbr_terms:
errors.append(f"PTBR_TERMS: {', '.join(ptbr_terms)}")
return len(errors) == 0, errors
def _check_placeholders(self, msgid: str, msgstr: str) -> bool:
"""Check if placeholders are preserved."""
if not msgstr:
return True
pattern = r'%(?:\d+\$)?[sdifuxX]|\{\{?\w+\}?\}|\[\w+\]'
msgid_placeholders = sorted(re.findall(pattern, msgid))
msgstr_placeholders = sorted(re.findall(pattern, msgstr))
return msgid_placeholders == msgstr_placeholders
def _check_html_tags(self, msgid: str, msgstr: str) -> bool:
"""Check if HTML tags are preserved."""
if not msgstr:
return True
msgid_tags = sorted(re.findall(r'<[^>]+>', msgid))
msgstr_tags = sorted(re.findall(r'<[^>]+>', msgstr))
# Auto-fix common issues
msgstr_fixed = msgstr.replace('<forte>', '<strong>').replace('</forte>', '</strong>')
msgstr_tags_fixed = sorted(re.findall(r'<[^>]+>', msgstr_fixed))
return msgid_tags == msgstr_tags or msgid_tags == msgstr_tags_fixed
def _detect_ptbr(self, text: str) -> List[str]:
"""Detect PT-BR terms in text."""
found = []
for term in self.PTBR_TERMS:
if re.search(r'\b' + re.escape(term) + r'\b', text, re.IGNORECASE):
found.append(term)
return found
# =============================================================================
# TranslationEngine
# =============================================================================
class TranslationEngine:
"""Wrapper for LibreTranslate API with retry and rate limiting."""
def __init__(self, api_url: str = "https://translate.descomplicar.pt"):
"""Initialize translation engine."""
self.api_url = api_url.rstrip("/")
self.translate_endpoint = f"{self.api_url}/translate"
self.rate_limit = 0.3
self.last_call = 0
self.stats = {"success": 0, "failed": 0, "cached": 0}
def translate(self, text: str, source: str = "en", target: str = "pt") -> str:
"""Translate text with retry logic."""
if not text or text.isspace():
return text
# Rate limiting
elapsed = time.time() - self.last_call
if elapsed < self.rate_limit:
time.sleep(self.rate_limit - elapsed)
# Retry 3 times
for attempt in range(3):
try:
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"}
)
with urlopen(req, timeout=30) as response:
result = json.loads(response.read().decode('utf-8'))
translated = result.get("translatedText", "")
self.last_call = time.time()
self.stats["success"] += 1
return translated
except (URLError, Exception) as e:
if attempt < 2:
wait = 2 ** attempt
time.sleep(wait)
continue
else:
self.stats["failed"] += 1
return ""
return ""
# =============================================================================
# PoFileHandler
# =============================================================================
class PoFileHandler:
"""Parse and write .po files."""
def parse(self, po_file: Path) -> List[PoEntry]:
"""Parse .po file into list of entries."""
entries = []
current = PoEntry()
with open(po_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
for i, line in enumerate(lines):
line = line.rstrip('\n')
if line.startswith('#'):
current.comments.append(line)
current.line_start = i
elif line.startswith('msgid '):
if current.msgid:
entries.append(current)
current = PoEntry()
current.msgid = self._extract_string(line)
current.msgid_line = i
elif line.startswith('msgid_plural '):
current.msgid_plural = self._extract_string(line)
elif line.startswith('msgstr'):
value = self._extract_string(line)
if '[' in line:
match = re.search(r'\[(\d+)\]', line)
if match:
idx = int(match.group(1))
current.msgstr_plural[idx] = value
else:
current.msgstr = value
else:
current.msgstr = value
current.msgstr_line = i
elif line.startswith('"'):
continuation = self._extract_string(line)
if current.msgstr_line and i > current.msgstr_line:
if current.msgstr_plural:
last_idx = max(current.msgstr_plural.keys())
current.msgstr_plural[last_idx] += continuation
else:
current.msgstr += continuation
elif current.msgid_line and i > current.msgid_line:
if current.msgid_plural:
current.msgid_plural += continuation
else:
current.msgid += continuation
elif not line.strip():
if current.msgid:
entries.append(current)
current = PoEntry()
if current.msgid:
entries.append(current)
return entries
def save(self, entries: List[PoEntry], output_file: Path):
"""Save entries to .po file."""
lines = []
for entry in entries:
lines.extend(entry.comments)
lines.append(f'msgid "{entry.msgid}"')
if entry.msgid_plural:
lines.append(f'msgid_plural "{entry.msgid_plural}"')
if entry.msgstr_plural:
for idx, value in sorted(entry.msgstr_plural.items()):
lines.append(f'msgstr[{idx}] "{value}"')
else:
lines.append(f'msgstr "{entry.msgstr}"')
lines.append("")
with open(output_file, 'w', encoding='utf-8') as f:
f.write('\n'.join(lines))
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 ""
# =============================================================================
# TranslationProcessor
# =============================================================================
class TranslationProcessor:
"""Main orchestrator for translation pipeline."""
def __init__(self, db_path: str, api_url: str):
"""Initialize processor with all components."""
self.cache = CacheManager(db_path) if db_path != ":memory:" else None
self.brand_protector = BrandProtector(db_path)
self.translator = TranslationEngine(api_url)
self.po_handler = PoFileHandler()
self.validator = QualityValidator()
def process_file(self, po_file: Path, mode: str = "full") -> ProcessResult:
"""Process .po file through full pipeline."""
# Backup original
backup_path = po_file.with_suffix('.po.backup')
if backup_path.exists():
backup_path.unlink()
shutil.copy2(po_file, backup_path)
try:
# Parse
entries = self.po_handler.parse(po_file)
# Process entries
processed = []
errors = []
stats = {"translated": 0, "cached": 0, "brands_fixed": 0}
for entry in entries:
try:
result, brands_fixed = self._process_entry(entry, mode, po_file.stem)
# Validate
valid, validation_errors = self.validator.validate_entry(result)
if valid or not result.msgstr:
processed.append(result)
if result.msgstr and not entry.msgstr:
stats["translated"] += 1
stats["brands_fixed"] += brands_fixed
else:
errors.append({
'msgid': entry.msgid[:50],
'errors': validation_errors
})
processed.append(entry)
except Exception as e:
errors.append({'msgid': entry.msgid[:50], 'exception': str(e)})
processed.append(entry)
# Save
self.po_handler.save(processed, po_file)
# Compile .mo (best effort - don't fail if compilation has errors)
compile_success = self._compile_mo(po_file)
if not compile_success:
errors.append({'warning': 'msgfmt compilation had warnings or errors'})
# Success - remove backup
backup_path.unlink()
return ProcessResult(
success=True,
total=len(entries),
translated=stats["translated"],
cached=stats["cached"],
brands_fixed=stats["brands_fixed"],
errors=errors
)
except Exception as e:
# Rollback
shutil.copy2(backup_path, po_file)
return ProcessResult(success=False, error=str(e))
def _process_entry(self, entry: PoEntry, mode: str, plugin_name: str) -> Tuple[PoEntry, int]:
"""Process single entry through pipeline."""
brands_fixed = 0
# Skip header entries
if not entry.msgid:
return entry, 0
# Mode: brands-only
if mode == "brands-only" or entry.msgstr:
# Process msgstr (singular)
if entry.msgstr:
fixed, corrections = self.brand_protector.fix_translated_brands(
entry.msgid, entry.msgstr
)
if corrections:
brands_fixed = len(corrections)
fixed, _ = apply_ptbr_fixes(fixed)
entry.msgstr = fixed
# Process msgstr_plural (plural forms)
if entry.msgstr_plural:
for idx, value in entry.msgstr_plural.items():
fixed, corrections = self.brand_protector.fix_translated_brands(
entry.msgid, value
)
if corrections:
brands_fixed += len(corrections)
fixed, _ = apply_ptbr_fixes(fixed)
entry.msgstr_plural[idx] = fixed
return entry, brands_fixed
# Mode: full translation
if entry.msgid and not entry.msgstr:
# Check cache
if self.cache:
cached = self.cache.get_cached_translation(entry.msgid)
if cached:
entry.msgstr = cached
return entry, 0
# Translate
protected, placeholders = self.brand_protector.protect_brands(entry.msgid)
translated = self.translator.translate(protected)
if translated:
translated = self.brand_protector.restore_brands(translated, placeholders)
translated, _ = apply_ptbr_fixes(translated)
translated, corrections = self.brand_protector.fix_translated_brands(
entry.msgid, translated
)
brands_fixed = len(corrections)
entry.msgstr = translated
if self.cache:
self.cache.save_translation(
entry.msgid, translated, plugin_name, validated=False
)
return entry, brands_fixed
def _compile_mo(self, po_file: Path) -> bool:
"""Compile .mo file using msgfmt."""
mo_file = po_file.with_suffix('.mo')
try:
subprocess.run(
['msgfmt', '-cv', '-o', str(mo_file), str(po_file)],
capture_output=True,
text=True,
check=True
)
return True
except (subprocess.CalledProcessError, FileNotFoundError):
return False
# =============================================================================
# Seed Database
# =============================================================================
def seed_brands_db(cache: CacheManager):
"""Populate database with seed brands."""
print("🌱 Seeding brands database...")
for brand in SEED_BRANDS:
try:
cache.conn.execute(
"""INSERT OR IGNORE INTO brands (name, auto_detected, confidence_score)
VALUES (?, 0, 1.0)""",
(brand,)
)
except Exception:
pass
cache.conn.commit()
print(f"✅ Seeded {len(SEED_BRANDS)} brands")
# =============================================================================
# Main CLI
# =============================================================================
def main():
"""Main CLI entry point."""
parser = argparse.ArgumentParser(
description="Sistema eficiente de traduções WordPress PT-PT",
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("files", nargs="*", help="Po files to process")
parser.add_argument("--batch", type=Path, help="Process all .po files in directory")
parser.add_argument("--brands-only", action="store_true", help="Only fix brands")
parser.add_argument("--dry-run", action="store_true", help="Show what would be done")
parser.add_argument("--init-db", action="store_true", help="Initialize database")
parser.add_argument("--export-brands", type=Path, help="Export brands to JSON")
parser.add_argument("--import-brands", type=Path, help="Import brands from JSON")
parser.add_argument("--db-path", type=str,
default=str(Path.home() / ".wp-translate-ptpt" / "cache.db"))
parser.add_argument("--api-url", type=str,
default="https://translate.descomplicar.pt")
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
args = parser.parse_args()
# Ensure db directory exists
db_dir = Path(args.db_path).parent
db_dir.mkdir(parents=True, exist_ok=True)
# Initialize database
if args.init_db:
cache = CacheManager(args.db_path)
seed_brands_db(cache)
cache.close()
return 0
# Export brands
if args.export_brands:
cache = CacheManager(args.db_path)
cursor = cache.conn.execute("SELECT name FROM brands ORDER BY name")
brands = [row[0] for row in cursor.fetchall()]
with open(args.export_brands, 'w') as f:
json.dump(brands, f, indent=2, ensure_ascii=False)
print(f"✅ Exported {len(brands)} brands to {args.export_brands}")
cache.close()
return 0
# Import brands
if args.import_brands:
with open(args.import_brands, 'r') as f:
brands = json.load(f)
cache = CacheManager(args.db_path)
for brand in brands:
cache.conn.execute(
"""INSERT OR IGNORE INTO brands (name, auto_detected, confidence_score)
VALUES (?, 0, 1.0)""",
(brand,)
)
cache.conn.commit()
print(f"✅ Imported {len(brands)} brands")
cache.close()
return 0
# Collect files
files_to_process = []
if args.batch:
files_to_process = list(args.batch.rglob("*-pt_PT.po"))
elif args.files:
files_to_process = [Path(f) for f in args.files]
else:
parser.print_help()
return 1
if not files_to_process:
print("❌ No .po files found")
return 1
# Process files
processor = TranslationProcessor(args.db_path, args.api_url)
mode = "brands-only" if args.brands_only else "full"
print("="*60)
print(f"🌍 WP Translate PT-PT v{__version__}")
print("="*60)
print(f"Mode: {mode}")
print(f"Files: {len(files_to_process)}")
print(f"Dry run: {args.dry_run}")
print("="*60)
print()
results = []
start_time = time.time()
for i, po_file in enumerate(files_to_process, 1):
print(f"[{i}/{len(files_to_process)}] {po_file.name}...", end=" ", flush=True)
if args.dry_run:
print("(skipped)")
continue
result = processor.process_file(po_file, mode=mode)
results.append(result)
if result.success:
print(f"{result.brands_fixed} brands fixed")
else:
print(f"{result.error}")
# Summary
if results and not args.dry_run:
elapsed = time.time() - start_time
print("\n" + "="*60)
print("📊 SUMMARY")
print("="*60)
total_success = sum(1 for r in results if r.success)
total_brands_fixed = sum(r.brands_fixed for r in results)
total_errors = sum(len(r.errors) for r in results)
print(f"Files processed: {total_success}/{len(results)}")
print(f"Brands fixed: {total_brands_fixed}")
print(f"Errors: {total_errors}")
print(f"Time: {elapsed:.1f}s")
print("="*60)
if processor.cache:
processor.cache.close()
return 0
if __name__ == "__main__":
sys.exit(main())