init: scripts diversos (crawlers, conversores, scrapers)
This commit is contained in:
67
translate-wp-plugin/CHANGELOG.md
Normal file
67
translate-wp-plugin/CHANGELOG.md
Normal 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
|
||||
145
translate-wp-plugin/README-translate-wordpress-plugin.txt
Normal file
145
translate-wp-plugin/README-translate-wordpress-plugin.txt
Normal 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
|
||||
223
translate-wp-plugin/README.md
Normal file
223
translate-wp-plugin/README.md
Normal 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+
|
||||
236
translate-wp-plugin/batch_process_library.py
Executable file
236
translate-wp-plugin/batch_process_library.py
Executable 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()
|
||||
165
translate-wp-plugin/fix_malformed.py
Executable file
165
translate-wp-plugin/fix_malformed.py
Executable 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
348
translate-wp-plugin/fix_ptbr.py
Executable 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()
|
||||
347
translate-wp-plugin/setup_glossary.py
Normal file
347
translate-wp-plugin/setup_glossary.py
Normal 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()
|
||||
958
translate-wp-plugin/translate-wordpress-plugin.py
Executable file
958
translate-wp-plugin/translate-wordpress-plugin.py
Executable 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()
|
||||
285
translate-wp-plugin/translate_missing.py
Executable file
285
translate-wp-plugin/translate_missing.py
Executable 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()
|
||||
967
translate-wp-plugin/wp-translate-ptpt.py
Executable file
967
translate-wp-plugin/wp-translate-ptpt.py
Executable 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())
|
||||
Reference in New Issue
Block a user