Initial commit — migración Joomla→WordPress feadulta.org

Incluye: mu-plugins custom (fea-homepage, carta-semana), scripts de
migración/traducción/deploy, documentación de auditoría, análisis de
cartas y HTML de evangelios exportados desde Joomla.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-03 08:33:12 -04:00
commit 668d973889
1925 changed files with 415604 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
<?php
/**
* assign_author_photos.php
* Asigna fotos de /uploads/quienes_somos/ a los usuarios de WordPress.
* Guarda la URL en user_meta 'fea_foto_url'.
* Usage: php assign_author_photos.php [--dry-run]
*/
$dry_run = in_array('--dry-run', $argv ?? []);
$pdo = new PDO(
"mysql:host=wordpress-mysql;dbname=wordpress_db;charset=utf8mb4",
'wordpress_user', 'wordpress_pass',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$base_url = 'https://farmer.taild3aaf6.ts.net/fea/wp-content/uploads/quienes_somos/avatars/';
$base_dir = '/var/www/html/wp-content/uploads/quienes_somos/avatars/';
// user_id => foto (preferir col_*.png, fallback a .jpg originales)
$mapping = [
382 => 'col_fraymarcos.png', // Fray Marcos
383 => 'col_pagola.png', // José Antonio Pagola
384 => 'col_enrique.png', // Enrique Martínez Lozano
385 => 'col_galarreta.png', // José Enrique Galarreta
386 => 'col_arregi.png', // José Arregi
387 => 'col_eloy.png', // Eloy Roy
388 => 'col_aleixandre.png', // Dolores Aleixandre
389 => 'col_vicente.png', // Vicente Martínez
390 => 'col_sandra.png', // Sandra Hojman
391 => 'col_mellado.png', // Julián Mellado
392 => 'col_gastalver.png', // Matilde Gastalver
393 => 'col_koldo.png', // Koldo Aldai
394 => 'marta_1.png', // Marta Salazar
395 => 'col_florentino.png', // Florentino Ulibarri
396 => 'col_rafael.png', // Rafael Calvo Beca
405 => 'col_faustino.png', // Faustino Vilabrille
407 => 'col_victor.png', // Víctor Daniel Blanco
423 => 'col_patxi.png', // Mari Patxi Ayerra
468 => 'col_luque.png', // José Sánchez Luque
746 => 'col_viki.png', // Vicky Irigaray
774 => 'col_sicre.png', // José Luis Sicre
842 => 'col_yolanchavez.png', // Yolanda Chávez
948 => 'col_inma_calvo.png', // Inma Calvo Torrejón
1048 => 'col_inma_calvo.png', // Inma Calvo (icalvotorre)
1010 => 'col_inigo-garcia.png', // Íñigo García Blanco
];
echo "=== Asignar fotos de autor ===\n";
echo $dry_run ? "[DRY RUN]\n\n" : "[LIVE RUN]\n\n";
$ok = 0; $skip = 0; $missing = 0;
$upsert = $pdo->prepare("
INSERT INTO wp_usermeta (user_id, meta_key, meta_value)
VALUES (?, 'fea_foto_url', ?)
ON DUPLICATE KEY UPDATE meta_value = VALUES(meta_value)
");
foreach ($mapping as $user_id => $foto) {
$file = $base_dir . $foto;
$url = $base_url . $foto;
// Verificar que el archivo existe
if (!file_exists($file)) {
echo " [MISSING] user $user_id$foto (archivo no encontrado)\n";
$missing++;
continue;
}
// Obtener nombre del usuario
$stmt = $pdo->prepare("SELECT display_name FROM wp_users WHERE ID = ?");
$stmt->execute([$user_id]);
$name = $stmt->fetchColumn();
if (!$name) {
echo " [SKIP] user_id $user_id no existe en la BD\n";
$skip++;
continue;
}
echo " [OK] $name$foto\n";
if (!$dry_run) {
$upsert->execute([$user_id, $url]);
}
$ok++;
}
echo "\n=== Resultado ===\n";
echo "Asignadas: $ok\n";
echo "Archivos no encontrados: $missing\n";
echo "Usuarios no encontrados: $skip\n";
echo "\nDone.\n";
+182
View File
@@ -0,0 +1,182 @@
<?php
/**
* assign_polylang_languages.php
*
* Asigna idioma Polylang a cada post de WordPress basándose en el campo
* "Idioma" (extra_field id=16) de K2 Joomla, cruzando por _fgj2wp_old_k2_id.
*
* Mapa K2 → Polylang:
* 1 = Español → es
* 2 = Inglés → en
* 3 = Francés → fr
* 4 = Italiano → it
* 5 = Portugués → pt
*
* Requisitos:
* - Polylang instalado y activado
* - Los 5 idiomas creados en Polylang (es, en, fr, it, pt)
* - DB Joomla accesible (ajustar credenciales abajo si hace falta)
*
* Uso: wp eval-file assign_polylang_languages.php
* o copiarlo a /wp-content/mu-plugins/ y acceder via navegador con ?run_assign_lang=1
*/
if ( ! defined('ABSPATH') ) {
// Ejecución directa via navegador
define('RUN_VIA_BROWSER', true);
$_SERVER['HTTP_HOST'] = 'localhost';
require_once dirname(__FILE__) . '/../../wp-load.php';
}
if ( defined('RUN_VIA_BROWSER') && ! isset($_GET['run_assign_lang']) ) {
echo 'Añade ?run_assign_lang=1 a la URL para ejecutar.';
exit;
}
if ( ! function_exists('pll_set_post_language') ) {
echo "ERROR: Polylang no está activo.\n";
exit(1);
}
// ── Configuración Joomla DB ───────────────────────────────────────────────────
$joomla_host = defined('RUN_VIA_BROWSER') ? '127.0.0.1' : 'joomla-mysql';
$joomla_db = 'joomla_db';
$joomla_user = 'joomla_user';
$joomla_pass = 'joomla_pass';
$jdb = new mysqli($joomla_host, $joomla_user, $joomla_pass, $joomla_db);
if ( $jdb->connect_error ) {
echo "ERROR conectando a Joomla DB: " . $jdb->connect_error . "\n";
exit(1);
}
$jdb->set_charset('utf8mb4');
// ── Mapa de idiomas K2 → código Polylang ─────────────────────────────────────
$lang_map = [
'1' => 'es',
'2' => 'en',
'3' => 'fr',
'4' => 'it',
'5' => 'pt',
];
// ── Obtener idiomas disponibles en Polylang ───────────────────────────────────
$pll_languages = pll_languages_list(['fields' => 'slug']);
echo "Idiomas disponibles en Polylang: " . implode(', ', $pll_languages) . "\n";
$missing_langs = array_diff(array_values($lang_map), $pll_languages);
if ( ! empty($missing_langs) ) {
echo "AVISO: Faltan idiomas en Polylang: " . implode(', ', $missing_langs) . "\n";
echo "Créalos en Ajustes → Languages antes de continuar.\n";
exit(1);
}
// ── Leer idiomas de K2 ────────────────────────────────────────────────────────
$result = $jdb->query("
SELECT id as k2_id,
CASE
WHEN extra_fields LIKE '%\"id\":\"16\",\"value\":\"1\"%' THEN '1'
WHEN extra_fields LIKE '%\"id\":\"16\",\"value\":\"2\"%' THEN '2'
WHEN extra_fields LIKE '%\"id\":\"16\",\"value\":\"3\"%' THEN '3'
WHEN extra_fields LIKE '%\"id\":\"16\",\"value\":\"4\"%' THEN '4'
WHEN extra_fields LIKE '%\"id\":\"16\",\"value\":\"5\"%' THEN '5'
ELSE '1'
END as lang_value
FROM ew4r_k2_items
WHERE published = 1
");
$k2_langs = [];
while ( $row = $result->fetch_assoc() ) {
$k2_langs[(int)$row['k2_id']] = $lang_map[$row['lang_value']] ?? 'es';
}
$jdb->close();
echo "K2 items con idioma: " . count($k2_langs) . "\n";
// ── Asignar idioma en WordPress ───────────────────────────────────────────────
global $wpdb;
$counts = array_fill_keys(array_values($lang_map), 0);
$counts['sin_k2_id'] = 0;
$counts['ya_asignado'] = 0;
$processed = 0;
// Obtener todos los posts con su k2_id de una vez
$rows = $wpdb->get_results("
SELECT p.ID as wp_id, pm.meta_value as k2_id
FROM {$wpdb->posts} p
JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
WHERE pm.meta_key = '_fgj2wp_old_k2_id'
AND p.post_type = 'post'
AND p.post_status IN ('publish', 'draft', 'private')
");
$total = count($rows);
echo "Posts WP con _fgj2wp_old_k2_id: {$total}\n";
echo "Procesando...\n";
foreach ( $rows as $row ) {
$wp_id = (int) $row->wp_id;
$k2_id = (int) $row->k2_id;
if ( ! isset($k2_langs[$k2_id]) ) {
$counts['sin_k2_id']++;
// Sin datos en K2 → asumir español
pll_set_post_language($wp_id, 'es');
continue;
}
$lang = $k2_langs[$k2_id];
pll_set_post_language($wp_id, $lang);
$counts[$lang]++;
$processed++;
if ( $processed % 500 === 0 ) {
echo " {$processed}/{$total}...\n";
if (ob_get_level()) ob_flush();
flush();
}
}
// ── Asignar español a posts sin k2_id (cartas, EFFA, etc.) ───────────────────
$posts_sin_k2 = $wpdb->get_col("
SELECT p.ID FROM {$wpdb->posts} p
LEFT JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id AND pm.meta_key = '_fgj2wp_old_k2_id'
WHERE pm.post_id IS NULL
AND p.post_type = 'post'
AND p.post_status IN ('publish', 'draft', 'private')
");
echo "Posts sin _fgj2wp_old_k2_id (cartas EFFA etc): " . count($posts_sin_k2) . "\n";
foreach ( $posts_sin_k2 as $wp_id ) {
pll_set_post_language((int)$wp_id, 'es');
}
// ── Eliminar tag "English" falso ──────────────────────────────────────────────
$english_tag = get_term_by('slug', 'english', 'post_tag');
if ( $english_tag ) {
$tag_posts = get_posts(['tag_id' => $english_tag->term_id, 'numberposts' => 1]);
if ( empty($tag_posts) || $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$wpdb->term_relationships} WHERE term_taxonomy_id=%d", $english_tag->term_taxonomy_id)) < 100 ) {
wp_delete_term($english_tag->term_id, 'post_tag');
echo "Tag 'English' falso eliminado.\n";
} else {
// Desasociar masivamente antes de borrar
$wpdb->delete($wpdb->term_relationships, [
'term_taxonomy_id' => $english_tag->term_taxonomy_id
]);
wp_update_term_count($english_tag->term_id, 'post_tag');
wp_delete_term($english_tag->term_id, 'post_tag');
echo "Tag 'English' falso eliminado (12845 asociaciones borradas).\n";
}
}
// ── Resumen ───────────────────────────────────────────────────────────────────
echo "\n=== RESULTADO ===\n";
foreach ( $lang_map as $val => $slug ) {
$names = ['1'=>'Español','2'=>'Inglés','3'=>'Francés','4'=>'Italiano','5'=>'Portugués'];
echo " {$names[$val]} ({$slug}): " . ($counts[$slug] ?? 0) . " posts\n";
}
echo " Sin k2_id (→es): " . $counts['sin_k2_id'] . "\n";
echo " Posts sin k2_id (cartas/EFFA): " . count($posts_sin_k2) . "\n";
echo "\nListo.\n";
File diff suppressed because one or more lines are too long
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""
audit_translations.py
Audits all new translated posts (ID > 42760) to check:
- Assigned Polylang language
- Detected language of the title
- Detected language of the content
Flags mismatches.
"""
import pymysql
import re
import html
from langdetect import detect, LangDetectException
DB = dict(host='172.18.0.2', port=3306, user='wordpress_user',
password='wordpress_pass', database='wordpress_db', charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor)
# Map langdetect codes to our Polylang slugs
LANG_MAP = {'es': 'es', 'pt': 'pt', 'fr': 'fr', 'en': 'en', 'it': 'it',
'ca': 'es', # Catalan often confused with Spanish
}
def strip_html(text):
if not text:
return ''
text = re.sub(r'<[^>]+>', ' ', text)
text = html.unescape(text)
text = re.sub(r'\s+', ' ', text).strip()
return text
def detect_lang(text, min_len=50):
text = text.strip()
if len(text) < min_len:
return None
try:
return detect(text)
except LangDetectException:
return None
def main():
db = pymysql.connect(**DB)
c = db.cursor()
c.execute("""
SELECT p.ID, p.post_title, p.post_content,
t_lang.slug as assigned_lang,
(
SELECT p2.post_title FROM wp_posts p2
JOIN wp_term_relationships trl2 ON p2.ID=trl2.object_id
JOIN wp_term_taxonomy ttl2 ON trl2.term_taxonomy_id=ttl2.term_taxonomy_id AND ttl2.taxonomy='language'
JOIN wp_terms tl2 ON ttl2.term_id=tl2.term_id AND tl2.slug='es'
JOIN wp_term_relationships trg2 ON p2.ID=trg2.object_id
JOIN wp_term_taxonomy ttg2 ON trg2.term_taxonomy_id=ttg2.term_taxonomy_id AND ttg2.taxonomy='post_translations'
WHERE ttg2.term_taxonomy_id = (
SELECT ttg3.term_taxonomy_id FROM wp_term_relationships trg3
JOIN wp_term_taxonomy ttg3 ON trg3.term_taxonomy_id=ttg3.term_taxonomy_id AND ttg3.taxonomy='post_translations'
WHERE trg3.object_id=p.ID LIMIT 1
)
LIMIT 1
) as es_title
FROM wp_posts p
JOIN wp_term_relationships trl ON p.ID=trl.object_id
JOIN wp_term_taxonomy ttl ON trl.term_taxonomy_id=ttl.term_taxonomy_id AND ttl.taxonomy='language'
JOIN wp_terms t_lang ON ttl.term_id=t_lang.term_id
WHERE p.ID > 42760 AND p.post_type='post' AND p.post_status='publish'
AND t_lang.slug != 'es'
ORDER BY t_lang.slug, p.ID
""")
posts = c.fetchall()
db.close()
print(f"Auditing {len(posts)} translated posts...\n")
issues = []
for p in posts:
post_id = p['ID']
assigned = p['assigned_lang']
title = p['post_title'] or ''
es_title = p['es_title'] or ''
content_raw = p['post_content'] or ''
content = strip_html(content_raw)[:600] # first 600 chars for detection
# Detect content language
content_lang = detect_lang(content, min_len=100)
content_lang_norm = LANG_MAP.get(content_lang, content_lang)
# Check title: is it the same as Spanish original?
title_is_spanish = (title.strip().lower() == es_title.strip().lower() and es_title.strip())
# Detect title language (only if long enough)
title_lang = detect_lang(title, min_len=30)
title_lang_norm = LANG_MAP.get(title_lang, title_lang)
problems = []
# Content language mismatch
if content_lang_norm and content_lang_norm != assigned:
# Allow es/pt confusion only if very short
if not (content_lang_norm in ('es', 'pt') and assigned in ('es', 'pt') and len(content) < 200):
problems.append(f"content={content_lang_norm}{assigned}")
# Title still in Spanish
if title_is_spanish:
problems.append(f"title=ES_ORIGINAL")
elif title_lang_norm and title_lang_norm != assigned and len(title) > 20:
# Allow es/pt confusion for titles
if not (title_lang_norm in ('es', 'pt') and assigned in ('es', 'pt')):
problems.append(f"title_lang={title_lang_norm}{assigned}")
if problems:
issues.append({
'id': post_id,
'assigned': assigned,
'problems': problems,
'title': title[:70],
'content_start': content[:80],
})
# Summary by language
print(f"{'='*70}")
print(f"ISSUES FOUND: {len(issues)} out of {len(posts)} posts")
print(f"{'='*70}\n")
by_lang = {}
for issue in issues:
by_lang.setdefault(issue['assigned'], []).append(issue)
for lang in sorted(by_lang.keys()):
lang_issues = by_lang[lang]
print(f"--- {lang.upper()} ({len(lang_issues)} issues) ---")
for i in sorted(lang_issues, key=lambda x: x['problems'][0]):
print(f" [{i['id']}] {', '.join(i['problems'])}")
print(f" Title: {i['title']}")
print(f" Content: {i['content_start']}")
print()
# Write CSV for easier review
with open('/tmp/translation_audit.csv', 'w') as f:
f.write('id,assigned_lang,problems,title,content_start\n')
for i in issues:
title_esc = i['title'].replace('"', '""')
content_esc = i['content_start'].replace('"', '""')
f.write(f'{i["id"]},{i["assigned"]},"{",".join(i["problems"])}","{title_esc}","{content_esc}"\n')
print(f"CSV saved to /tmp/translation_audit.csv")
if __name__ == '__main__':
main()
+18
View File
@@ -0,0 +1,18 @@
<?php
/**
* Plugin Name: Fe Adulta — Carta de la Semana
* Description: Redirige las URLs de carta al archivo de categoría correspondiente.
* Version: 1.4
*/
// Redirigir las páginas custom a las categorías
add_action('template_redirect', function() {
if (is_page('carta-de-la-semana')) {
wp_redirect(home_url('/category/cartasemana/'), 302);
exit;
}
if (is_page('la-semana-pasada')) {
wp_redirect(home_url('/category/carta-semana-pasada/'), 302);
exit;
}
});
+69
View File
@@ -0,0 +1,69 @@
#!/bin/bash
# =============================================================================
# Script de cutover DNS: feadulta.org → feadulta.com
# Ejecutar DESPUÉS de apuntar el DNS de feadulta.com al servidor de producción
# =============================================================================
#
# Este script reemplaza todas las URLs internas de feadulta.org por feadulta.com
# en la base de datos WordPress de producción.
#
# Servidor: 185.42.105.48
# DB: 278025353wordpress20260112013937 / myfeadultaa5 / KjyGU29h
# =============================================================================
set -e
DB_HOST="127.0.0.1"
DB_NAME="278025353wordpress20260112013937"
DB_USER="myfeadultaa5"
DB_PASS="KjyGU29h"
OLD_URL="http://feadulta.org"
NEW_URL="https://feadulta.com"
MYSQL="mysql -h $DB_HOST -u $DB_USER -p$DB_PASS $DB_NAME"
echo "=== Cutover feadulta.org → feadulta.com ==="
echo "OLD: $OLD_URL"
echo "NEW: $NEW_URL"
echo ""
echo "Ejecutando en 5 segundos... (Ctrl+C para cancelar)"
sleep 5
echo "[1/6] Actualizando siteurl y home..."
$MYSQL -e "
UPDATE wp_options SET option_value = '$NEW_URL' WHERE option_name = 'siteurl';
UPDATE wp_options SET option_value = '$NEW_URL' WHERE option_name = 'home';
"
echo "[2/6] Reemplazando en post_content..."
$MYSQL -e "UPDATE wp_posts SET post_content = REPLACE(post_content, '$OLD_URL', '$NEW_URL');"
echo "[3/6] Reemplazando en guid..."
$MYSQL -e "UPDATE wp_posts SET guid = REPLACE(guid, '$OLD_URL', '$NEW_URL');"
echo "[4/6] Reemplazando en postmeta..."
$MYSQL -e "UPDATE wp_postmeta SET meta_value = REPLACE(meta_value, '$OLD_URL', '$NEW_URL');"
echo "[5/6] Reemplazando en wp_options (no serializados)..."
$MYSQL -e "
UPDATE wp_options SET option_value = REPLACE(option_value, '$OLD_URL', '$NEW_URL')
WHERE option_name NOT IN ('wpseo', 'fgj2wp_save_posts', 'bsr_data')
AND option_value LIKE '%feadulta.org%';
"
echo "[6/6] Actualizando wp-config.php..."
ssh feadultada@185.42.105.48 "
sed -i \"s|define('WP_SITEURL','http://feadulta.org')|define('WP_SITEURL','https://feadulta.com')|\" /web/wp-config.php
sed -i \"s|define('WP_HOME','http://feadulta.org')|define('WP_HOME','https://feadulta.com')|\" /web/wp-config.php
"
echo ""
echo "=== Verificación ==="
$MYSQL -e "SELECT option_name, option_value FROM wp_options WHERE option_name IN ('siteurl','home');"
$MYSQL -e "SELECT COUNT(*) as pendientes_feadulta_org FROM wp_posts WHERE post_content LIKE '%feadulta.org%';"
echo ""
echo "=== Cutover completado ==="
echo "IMPORTANTE: Limpiar caché de WordPress y Cloudflare después de este paso."
echo "IMPORTANTE: Activar plugins: AdSense, Wordfence, TTS."
echo "IMPORTANTE: Verificar redirects de feadulta.com/images/Musica/ (ya no hacen falta si los MP3 están en el mismo servidor)."
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""
export_cat_translations.py
Exports Polylang category translation data from local DB and generates SQL
for production. Handles:
1. wp_terms for translated categories
2. wp_term_taxonomy (category + language taxonomy rows)
3. wp_term_taxonomy (term_translations groups)
4. wp_term_relationships (post→translated category assignments)
"""
import pymysql, re
DB = dict(host='172.18.0.2', port=3306, user='wordpress_user',
password='wordpress_pass', database='wordpress_db', charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor)
# Translated category term_ids on local (ES parent → lang: local_term_id)
TRANS_CATS = {
6: {'en': 3077, 'fr': 3083, 'it': 3089, 'pt': 3095},
21: {'en': 3080, 'fr': 3086, 'it': 3092, 'pt': 3098},
1646: {'en': 2982, 'fr': 3032, 'it': 3048, 'pt': 3063},
1647: {'en': 2986, 'fr': 3035, 'it': 3051, 'pt': 3066},
1648: {'en': 2971, 'fr': 3029, 'it': 3045, 'pt': 3060},
1650: {'en': 2964, 'fr': 3023, 'it': 3039, 'pt': 3054},
}
# Collect all translated term_ids
all_trans_ids = []
for mapping in TRANS_CATS.values():
all_trans_ids.extend(mapping.values())
all_trans_ids = sorted(set(all_trans_ids))
all_es_ids = sorted(TRANS_CATS.keys())
db = pymysql.connect(**DB)
c = db.cursor()
sql_lines = [
"-- Polylang category translations export",
"-- Generated by export_cat_translations.py",
"-- Run on production AFTER verifying no term_id conflicts",
"",
"SET NAMES utf8mb4;",
"SET foreign_key_checks = 0;",
"",
]
# ─── 1. wp_terms ──────────────────────────────────────────────────────────────
ids_str = ','.join(str(i) for i in all_trans_ids)
c.execute(f"SELECT term_id, name, slug, term_group FROM wp_terms WHERE term_id IN ({ids_str})")
rows = c.fetchall()
sql_lines.append("-- 1. wp_terms (translated category names/slugs)")
for r in rows:
name = r['name'].replace("'", "''")
slug = r['slug'].replace("'", "''")
sql_lines.append(
f"INSERT IGNORE INTO wp_terms (term_id, name, slug, term_group) "
f"VALUES ({r['term_id']}, '{name}', '{slug}', {r['term_group']});"
)
sql_lines.append("")
# ─── 2. wp_term_taxonomy (category rows for translated terms) ────────────────
c.execute(f"""
SELECT term_taxonomy_id, term_id, taxonomy, description, parent, count
FROM wp_term_taxonomy
WHERE term_id IN ({ids_str}) AND taxonomy='category'
""")
cat_rows = c.fetchall()
sql_lines.append("-- 2. wp_term_taxonomy (taxonomy='category' for translated terms)")
for r in cat_rows:
desc = r['description'].replace("'", "''") if r['description'] else ''
sql_lines.append(
f"INSERT IGNORE INTO wp_term_taxonomy "
f"(term_taxonomy_id, term_id, taxonomy, description, parent, count) "
f"VALUES ({r['term_taxonomy_id']}, {r['term_id']}, 'category', "
f"'{desc}', {r['parent']}, {r['count']});"
)
sql_lines.append("")
# ─── 3. wp_term_taxonomy (language rows for translated terms) ────────────────
c.execute(f"""
SELECT term_taxonomy_id, term_id, taxonomy, description, parent, count
FROM wp_term_taxonomy
WHERE term_id IN ({ids_str}) AND taxonomy='language'
""")
lang_rows = c.fetchall()
sql_lines.append("-- 3. wp_term_taxonomy (taxonomy='language' for translated terms)")
for r in lang_rows:
desc = r['description'].replace("'", "''") if r['description'] else ''
sql_lines.append(
f"INSERT IGNORE INTO wp_term_taxonomy "
f"(term_taxonomy_id, term_id, taxonomy, description, parent, count) "
f"VALUES ({r['term_taxonomy_id']}, {r['term_id']}, 'language', "
f"'{desc}', {r['parent']}, {r['count']});"
)
sql_lines.append("")
# ─── 4. wp_term_taxonomy (term_translations groups for our ES categories) ───
# Get translation groups that contain any of our ES or translated term_ids
all_ids_str = ','.join(str(i) for i in all_es_ids + all_trans_ids)
c.execute("""
SELECT DISTINCT tt.term_taxonomy_id, tt.term_id, tt.taxonomy,
tt.description, tt.parent, tt.count
FROM wp_term_taxonomy tt
WHERE tt.taxonomy = 'term_translations'
""")
all_tt_rows = c.fetchall()
# Filter to only those that reference our category term_ids
relevant_tt = []
for r in all_tt_rows:
desc = r['description'] or ''
# Check if any of our term_ids appear in the description
for tid in all_es_ids + all_trans_ids:
if f'i:{tid};' in desc or f'i:{tid}' == desc.strip():
relevant_tt.append(r)
break
sql_lines.append("-- 4. wp_term_taxonomy (taxonomy='term_translations' groups for our categories)")
for r in relevant_tt:
desc = r['description'].replace("'", "''") if r['description'] else ''
sql_lines.append(
f"INSERT INTO wp_term_taxonomy "
f"(term_taxonomy_id, term_id, taxonomy, description, parent, count) "
f"VALUES ({r['term_taxonomy_id']}, {r['term_id']}, 'term_translations', "
f"'{desc}', {r['parent']}, {r['count']}) "
f"ON DUPLICATE KEY UPDATE description=VALUES(description), count=VALUES(count);"
)
sql_lines.append("")
# ─── 5. wp_terms for term_translations taxonomy entries ─────────────────────
tt_term_ids = [r['term_id'] for r in relevant_tt]
if tt_term_ids:
tt_ids_str = ','.join(str(i) for i in tt_term_ids)
c.execute(f"SELECT term_id, name, slug, term_group FROM wp_terms WHERE term_id IN ({tt_ids_str})")
tt_term_rows = c.fetchall()
# Insert before the term_taxonomy rows (we need to reorder — prepend)
term_inserts = []
for r in tt_term_rows:
name = r['name'].replace("'", "''")
slug = r['slug'].replace("'", "''")
term_inserts.append(
f"INSERT IGNORE INTO wp_terms (term_id, name, slug, term_group) "
f"VALUES ({r['term_id']}, '{name}', '{slug}', {r['term_group']});"
)
# Insert after section 1
idx = sql_lines.index("-- 2. wp_term_taxonomy (taxonomy='category' for translated terms)")
sql_lines[idx:idx] = ["-- 1b. wp_terms for term_translations taxonomy"] + term_inserts + [""]
# ─── 6. wp_term_relationships (post→translated category) ─────────────────────
# Get term_taxonomy_ids for translated categories
cat_tt_ids = [r['term_taxonomy_id'] for r in cat_rows]
if cat_tt_ids:
cat_tt_str = ','.join(str(i) for i in cat_tt_ids)
c.execute(f"""
SELECT object_id, term_taxonomy_id, term_order
FROM wp_term_relationships
WHERE term_taxonomy_id IN ({cat_tt_str})
ORDER BY term_taxonomy_id, object_id
""")
rel_rows = c.fetchall()
sql_lines.append("-- 5. wp_term_relationships (posts → translated categories)")
sql_lines.append(f"-- {len(rel_rows)} relationships")
# Batch INSERT for efficiency
if rel_rows:
batch = []
for r in rel_rows:
batch.append(f"({r['object_id']},{r['term_taxonomy_id']},{r['term_order']})")
if len(batch) >= 500:
sql_lines.append(
"INSERT IGNORE INTO wp_term_relationships (object_id, term_taxonomy_id, term_order) VALUES "
+ ','.join(batch) + ";"
)
batch = []
if batch:
sql_lines.append(
"INSERT IGNORE INTO wp_term_relationships (object_id, term_taxonomy_id, term_order) VALUES "
+ ','.join(batch) + ";"
)
sql_lines.append("")
# ─── 7. wp_term_relationships (translated terms → language taxonomy) ─────────
lang_tt_ids = [r['term_taxonomy_id'] for r in lang_rows]
if lang_tt_ids:
lang_tt_str = ','.join(str(i) for i in lang_tt_ids)
c.execute(f"""
SELECT object_id, term_taxonomy_id, term_order
FROM wp_term_relationships
WHERE term_taxonomy_id IN ({lang_tt_str})
""")
lang_rel_rows = c.fetchall()
sql_lines.append("-- 6. wp_term_relationships (translated category terms → language taxonomy)")
sql_lines.append(f"-- {len(lang_rel_rows)} relationships")
if lang_rel_rows:
batch = []
for r in lang_rel_rows:
batch.append(f"({r['object_id']},{r['term_taxonomy_id']},{r['term_order']})")
if len(batch) >= 500:
sql_lines.append(
"INSERT IGNORE INTO wp_term_relationships (object_id, term_taxonomy_id, term_order) VALUES "
+ ','.join(batch) + ";"
)
batch = []
if batch:
sql_lines.append(
"INSERT IGNORE INTO wp_term_relationships (object_id, term_taxonomy_id, term_order) VALUES "
+ ','.join(batch) + ";"
)
sql_lines.append("")
sql_lines.append("SET foreign_key_checks = 1;")
sql_lines.append("")
sql_lines.append("-- Done.")
db.close()
output = '\n'.join(sql_lines)
with open('/tmp/cat_translations_prod.sql', 'w', encoding='utf-8') as f:
f.write(output)
print(f"Written to /tmp/cat_translations_prod.sql")
print(f" {len(rows)} translated terms (wp_terms)")
print(f" {len(cat_rows)} category taxonomy rows")
print(f" {len(lang_rows)} language taxonomy rows")
print(f" {len(relevant_tt)} term_translations groups")
if cat_tt_ids:
print(f" {len(rel_rows)} post→category relationships")
if lang_tt_ids:
print(f" {len(lang_rel_rows)} term→language relationships")
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""
export_translations.py
Genera SQL para importar todos los posts traducidos (ID > 42760)
de la DB local a producción, con remapeo correcto de language IDs (FR↔PT).
"""
import pymysql
DB_LOCAL = dict(host='172.18.0.2', port=3306, user='wordpress_user',
password='wordpress_pass', database='wordpress_db', charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor)
# Local lang term_taxonomy_id → Production term_taxonomy_id
# Local: en=1407, es=1404, fr=1419, it=1415, pt=1411
# Prod: en=1407, es=1404, fr=1411, it=1415, pt=1419
LANG_MAP = {1407: 1407, 1404: 1404, 1419: 1411, 1415: 1415, 1411: 1419}
def esc(s):
if s is None:
return 'NULL'
return "'" + str(s).replace('\\', '\\\\').replace("'", "\\'") + "'"
db = pymysql.connect(**DB_LOCAL)
c = db.cursor()
lines = []
lines.append("SET NAMES utf8mb4;")
lines.append("SET foreign_key_checks = 0;")
lines.append("")
# ── 1. wp_posts ───────────────────────────────────────────────────────────────
lines.append("-- ============================================================")
lines.append("-- 1. POSTS (ID > 42760)")
lines.append("-- ============================================================")
c.execute("""
SELECT ID, post_author, post_date, post_date_gmt, post_content, post_title,
post_excerpt, post_status, comment_status, ping_status, post_password,
post_name, to_ping, pinged, post_modified, post_modified_gmt,
post_content_filtered, post_parent, guid, menu_order, post_type,
post_mime_type, comment_count
FROM wp_posts
WHERE ID > 42760 AND post_status='publish' AND post_type='post'
ORDER BY ID
""")
posts = c.fetchall()
lines.append(f"-- {len(posts)} posts")
for p in posts:
cols = ['ID','post_author','post_date','post_date_gmt','post_content','post_title',
'post_excerpt','post_status','comment_status','ping_status','post_password',
'post_name','to_ping','pinged','post_modified','post_modified_gmt',
'post_content_filtered','post_parent','guid','menu_order','post_type',
'post_mime_type','comment_count']
vals = ', '.join(esc(p[col]) for col in cols)
lines.append(
f"INSERT IGNORE INTO wp_posts ({', '.join(cols)}) VALUES ({vals});"
)
lines.append("")
# ── 2. wp_term_relationships — language ──────────────────────────────────────
lines.append("-- ============================================================")
lines.append("-- 2. LANGUAGE ASSIGNMENTS (remapped FR↔PT)")
lines.append("-- ============================================================")
post_ids = [p['ID'] for p in posts]
fmt = ','.join(str(i) for i in post_ids)
c.execute(f"""
SELECT object_id, term_taxonomy_id
FROM wp_term_relationships
WHERE object_id IN ({fmt})
AND term_taxonomy_id IN (1404,1407,1411,1415,1419)
""")
lang_rels = c.fetchall()
lines.append(f"-- {len(lang_rels)} language assignments")
for r in lang_rels:
prod_ttid = LANG_MAP[r['term_taxonomy_id']]
lines.append(
f"INSERT IGNORE INTO wp_term_relationships (object_id, term_taxonomy_id, term_order) "
f"VALUES ({r['object_id']}, {prod_ttid}, 0);"
)
lines.append("")
# ── 3. wp_terms + wp_term_taxonomy — post_translations groups ─────────────────
lines.append("-- ============================================================")
lines.append("-- 3. POST_TRANSLATIONS GROUPS (term_taxonomy_id 2705-3043)")
lines.append("-- ============================================================")
c.execute(f"""
SELECT DISTINCT tt.term_taxonomy_id, tt.term_id, tt.taxonomy, tt.description,
tt.parent, tt.count, t.name, t.slug, t.term_group
FROM wp_term_taxonomy tt
JOIN wp_terms t ON tt.term_id=t.term_id
JOIN wp_term_relationships tr ON tt.term_taxonomy_id=tr.term_taxonomy_id
WHERE tt.taxonomy='post_translations'
AND tr.object_id IN ({fmt})
ORDER BY tt.term_taxonomy_id
""")
pt_groups = c.fetchall()
lines.append(f"-- {len(pt_groups)} translation groups")
for g in pt_groups:
# wp_terms
lines.append(
f"INSERT IGNORE INTO wp_terms (term_id, name, slug, term_group) "
f"VALUES ({g['term_id']}, {esc(g['name'])}, {esc(g['slug'])}, {g['term_group']});"
)
# wp_term_taxonomy
lines.append(
f"INSERT IGNORE INTO wp_term_taxonomy (term_taxonomy_id, term_id, taxonomy, description, parent, count) "
f"VALUES ({g['term_taxonomy_id']}, {g['term_id']}, 'post_translations', "
f"{esc(g['description'])}, {g['parent']}, {g['count']});"
)
lines.append("")
# ── 4. wp_term_relationships — post_translations (ALL members of each group) ──
lines.append("-- ============================================================")
lines.append("-- 4. POST_TRANSLATIONS RELATIONSHIPS (all group members)")
lines.append("-- ============================================================")
pt_ttids = [g['term_taxonomy_id'] for g in pt_groups]
fmt_tt = ','.join(str(i) for i in pt_ttids)
c.execute(f"""
SELECT object_id, term_taxonomy_id
FROM wp_term_relationships
WHERE term_taxonomy_id IN ({fmt_tt})
ORDER BY term_taxonomy_id, object_id
""")
pt_rels = c.fetchall()
lines.append(f"-- {len(pt_rels)} translation group relationships")
for r in pt_rels:
lines.append(
f"INSERT IGNORE INTO wp_term_relationships (object_id, term_taxonomy_id, term_order) "
f"VALUES ({r['object_id']}, {r['term_taxonomy_id']}, 0);"
)
lines.append("")
# ── 5. wp_term_relationships — categories ─────────────────────────────────────
lines.append("-- ============================================================")
lines.append("-- 5. CATEGORY ASSIGNMENTS")
lines.append("-- ============================================================")
c.execute(f"""
SELECT tr.object_id, tr.term_taxonomy_id
FROM wp_term_relationships tr
JOIN wp_term_taxonomy tt ON tr.term_taxonomy_id=tt.term_taxonomy_id
WHERE tr.object_id IN ({fmt})
AND tt.taxonomy='category'
ORDER BY tr.object_id
""")
cat_rels = c.fetchall()
lines.append(f"-- {len(cat_rels)} category assignments")
for r in cat_rels:
lines.append(
f"INSERT IGNORE INTO wp_term_relationships (object_id, term_taxonomy_id, term_order) "
f"VALUES ({r['object_id']}, {r['term_taxonomy_id']}, 0);"
)
lines.append("")
lines.append("-- ============================================================")
lines.append("-- 6. UPDATE term_taxonomy counts")
lines.append("-- ============================================================")
lines.append("""
UPDATE wp_term_taxonomy tt
SET count = (
SELECT COUNT(*) FROM wp_term_relationships tr
JOIN wp_posts p ON tr.object_id=p.ID
WHERE tr.term_taxonomy_id=tt.term_taxonomy_id
AND p.post_status='publish'
)
WHERE tt.taxonomy IN ('language','post_translations','category');
""")
lines.append("SET foreign_key_checks = 1;")
lines.append(f"-- Export complete: {len(posts)} posts, {len(pt_groups)} translation groups")
db.close()
output = '\n'.join(lines)
with open('/tmp/translations_export.sql', 'w', encoding='utf-8') as f:
f.write(output)
print(f"SQL written to /tmp/translations_export.sql")
print(f" Posts: {len(posts)}")
print(f" Language rels: {len(lang_rels)}")
print(f" Translation groups: {len(pt_groups)}")
print(f" Group rels: {len(pt_rels)}")
print(f" Category rels: {len(cat_rels)}")
print(f" File size: {len(output)//1024} KB")
+142
View File
@@ -0,0 +1,142 @@
<?php
/**
* Fe Adulta — Homepage template
* Cargado via template_include filter desde fea-homepage.php
*/
if (!defined('ABSPATH')) exit;
get_header();
?>
<style>
/* ── Reset dentro de la homepage ── */
.fea-homepage {
max-width: 960px;
margin: 0 auto;
padding: 2rem 1.25rem 4rem;
font-family: inherit;
}
/* ── Hero: Carta de la semana ── */
.fea-hero {
border-bottom: 2px solid #111;
padding-bottom: 2rem;
margin-bottom: 2.5rem;
}
.fea-hero-link {
display: block;
text-decoration: none;
color: inherit;
}
.fea-hero-link:hover .fea-hero-title {
text-decoration: underline;
text-underline-offset: 4px;
}
.fea-section-label {
display: inline-block;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #888;
margin-bottom: 0.75rem;
}
.fea-hero-title {
font-size: clamp(1.6rem, 4vw, 2.4rem);
font-weight: 700;
line-height: 1.2;
margin: 0 0 1rem;
color: #111;
}
.fea-hero-meta {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.875rem;
color: #666;
}
.fea-hero-meta .fea-avatar {
border-radius: 50%;
flex-shrink: 0;
}
/* ── Secciones ── */
.fea-section {
margin-bottom: 3rem;
}
.fea-section-title {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #888;
margin: 0 0 1.25rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #e5e5e5;
}
/* ── Grid de artículos ── */
.fea-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1.5rem;
}
.fea-grid--4 {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
/* ── Tarjeta ── */
.fea-card {
border-bottom: 1px solid #e5e5e5;
padding-bottom: 1.25rem;
}
.fea-card-meta {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.fea-avatar {
border-radius: 50%;
flex-shrink: 0;
width: 36px;
height: 36px;
}
.fea-card-author {
font-size: 0.8rem;
font-weight: 600;
color: #444;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fea-card-title {
font-size: 0.975rem;
font-weight: 600;
line-height: 1.35;
margin: 0;
}
.fea-card-title a {
text-decoration: none;
color: #111;
}
.fea-card-title a:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
/* ── Mobile ── */
@media (max-width: 600px) {
.fea-grid,
.fea-grid--4 {
grid-template-columns: 1fr;
}
}
</style>
<main id="wp--skip-link--target">
<div class="fea-homepage">
<?php echo fea_homepage_content(); ?>
</div>
</main>
<?php get_footer(); ?>
+425
View File
@@ -0,0 +1,425 @@
<?php
/**
* Plugin Name: Fe Adulta — Homepage
* Description: Portada con selección editorial via ACF.
* Version: 1.3
*/
// ── Foto de perfil de autor via ACF (campo en perfil de usuario) ──────────
add_action('acf/init', function() {
if (!function_exists('acf_add_local_field_group')) return;
acf_add_local_field_group([
'key' => 'group_user_foto_perfil',
'title' => 'Foto de perfil',
'fields' => [[
'key' => 'field_user_foto_perfil',
'label' => 'Foto',
'name' => 'foto_perfil',
'type' => 'image',
'instructions' => 'Sube una foto cuadrada del autor (mínimo 100×100px).',
'return_format' => 'url',
'preview_size' => 'thumbnail',
'upload_folder' => 'autores',
]],
'location' => [[
['param' => 'user_form', 'operator' => '==', 'value' => 'all'],
]],
]);
});
// Usar la foto del autor en lugar del Gravatar cuando existe
// Lee el attachment ID guardado en el meta 'foto_perfil' (campo ACF)
add_filter('get_avatar_url', function($url, $id_or_email, $args) {
$user_id = null;
if (is_numeric($id_or_email)) $user_id = (int) $id_or_email;
elseif ($id_or_email instanceof WP_User) $user_id = $id_or_email->ID;
elseif (is_string($id_or_email)) {
$user = get_user_by('email', $id_or_email);
if ($user) $user_id = $user->ID;
}
if (!$user_id) return $url;
$attach_id = get_user_meta($user_id, 'foto_perfil', true);
if ($attach_id) {
$foto = wp_get_attachment_image_url((int) $attach_id, 'full');
if ($foto) return $foto;
}
return $url;
}, 10, 3);
// ── Ordenar por fecha los resultados del buscador ACF en campos de portada ─
add_filter('acf/fields/relationship/query/key=field_portada_articulos', function($args) {
$args['orderby'] = 'date';
$args['order'] = 'DESC';
return $args;
});
add_filter('acf/fields/relationship/query/key=field_portada_multimedia', function($args) {
$args['orderby'] = 'date';
$args['order'] = 'DESC';
return $args;
});
// ── Campos ACF para la portada ────────────────────────────────────────────
add_action('acf/init', function() {
if (!function_exists('acf_add_local_field_group')) return;
$front_page_id = (int) get_option('page_on_front');
acf_add_local_field_group([
'key' => 'group_portada_fea',
'title' => 'Contenido de la portada',
'fields' => [
[
'key' => 'field_portada_articulos',
'label' => 'Artículos seleccionados',
'name' => 'portada_articulos',
'type' => 'relationship',
'instructions' => 'Elige los artículos que aparecerán en la portada esta semana (máx. 9). Puedes buscar por título.',
'post_type' => ['post'],
'post_status' => ['publish', 'draft'],
'filters' => ['search', 'taxonomy'],
'elements' => ['featured_image'],
'min' => 0,
'max' => 9,
'return_format' => 'object',
'query_args' => ['orderby' => 'date', 'order' => 'DESC'],
],
[
'key' => 'field_portada_multimedia',
'label' => 'Multimedia seleccionado',
'name' => 'portada_multimedia',
'type' => 'relationship',
'instructions' => 'Elige los vídeos o audios para la portada (máx. 4).',
'post_type' => ['post'],
'post_status' => ['publish', 'draft'],
'filters' => ['search'],
'elements' => ['featured_image'],
'min' => 0,
'max' => 4,
'return_format' => 'object',
'query_args' => ['orderby' => 'date', 'order' => 'DESC'],
],
],
'location' => [[
['param' => 'page', 'operator' => '==', 'value' => (string) $front_page_id],
]],
'position' => 'normal',
'style' => 'default',
'label_placement' => 'top',
]);
});
// ── Centrar bloque slider+librería (header template, todas las páginas) ───
add_action('wp_head', function() {
?>
<style>
/* El bloque de columnas con el slider usa márgenes negativos del FSE
que lo desplazan. Lo forzamos a centrar con max-width explícito. */
.wp-block-columns:has(.wp-block-nextend-smartslider3) {
max-width: min(var(--wp--style--global--wide-size, 1340px), 100%);
margin-left: auto !important;
margin-right: auto !important;
box-sizing: border-box;
padding-left: var(--wp--preset--spacing--30);
padding-right: var(--wp--preset--spacing--30);
}
/* Ocultar columna librería en tablet */
@media (max-width: 900px) {
.fea-slider-block .wp-block-column:last-child {
display: none !important;
}
}
/* Ocultar slider en móvil (banner superior sigue visible) */
@media (max-width: 600px) {
.fea-slider-block {
display: none !important;
}
}
/* Buscador del header: reducir altura */
.wp-block-search__input {
padding-top: 0.25rem !important;
padding-bottom: 0.25rem !important;
line-height: 1.3 !important;
}
.wp-block-search__button {
padding-top: 0.25rem !important;
padding-bottom: 0.25rem !important;
}
</style>
<?php
});
// ── Estilos ───────────────────────────────────────────────────────────────
add_action('wp_head', function() {
if (!is_front_page()) return;
?>
<style>
.fea-hero { border-bottom: 2px solid #111; padding-bottom: 2rem; margin-bottom: 2.5rem; }
.fea-hero-link { display: block; text-decoration: none; color: inherit; }
.fea-hero-link:hover .fea-hero-title { text-decoration: underline; text-underline-offset: 4px; }
.fea-section-label { display: inline-block; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: #888; margin-bottom: 0.6rem; }
.fea-hero-title { font-size: clamp(1.5rem, 4vw, 2.2rem); font-weight: 700; line-height: 1.2; margin: 0 0 0.75rem; color: #111; }
.fea-hero-meta { display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; color: #666; }
.fea-section { margin-bottom: 3rem; }
.fea-section-title { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: #888; margin: 0 0 1.25rem; padding-bottom: 0.5rem; border-bottom: 1px solid #e0e0e0; }
.fea-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; }
@media (max-width: 720px) { .fea-grid { grid-template-columns: 1fr 1fr; } }
@media (max-width: 480px) { .fea-grid { grid-template-columns: 1fr; } }
.fea-card { border-bottom: 1px solid #e5e5e5; padding-bottom: 1.1rem; }
.fea-card-meta { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.4rem; }
.fea-avatar { border-radius: 50%; width: 28px !important; height: 28px !important; flex-shrink: 0; display: inline-block !important; }
.fea-card-author { font-size: 0.78rem; font-weight: 600; color: #555; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fea-card-title { font-size: 0.93rem; font-weight: 600; line-height: 1.35; margin: 0; }
.fea-card-title a { text-decoration: none; color: #111; }
.fea-card-title a:hover { text-decoration: underline; text-underline-offset: 3px; }
</style>
<?php
});
// ── Byline personalizado en artículos individuales ────────────────────────
// ── Byline personalizado: se gestiona desde el template FSE (ID 42359) ────
// El template wp_template 'single' ya contiene wp:avatar + wp:post-author-name
// + wp:post-terms. Este hook solo añade los estilos necesarios.
add_action('astra_single_header_bottom', function() {
if (!is_single()) return;
$author_id = (int) get_the_author_meta('ID');
$author_name = get_the_author_meta('display_name');
$avatar_url = get_avatar_url($author_id, ['size' => 48]);
$author_url = get_author_posts_url($author_id);
$cat_str = '';
$cats = get_the_category();
if ($cats) {
$cat_url = get_category_link($cats[0]->term_id);
$cat_str = '<a href="' . esc_url($cat_url) . '" class="fea-byline-cat">'
. esc_html($cats[0]->name) . '</a>';
}
echo '<div class="fea-byline">'
. '<a href="' . esc_url($author_url) . '" class="fea-byline-avatar-link">'
. '<img src="' . esc_url($avatar_url) . '" alt="" width="48" height="48" class="fea-byline-avatar">'
. '</a>'
. '<div class="fea-byline-info">'
. '<a href="' . esc_url($author_url) . '" class="fea-byline-name">' . esc_html($author_name) . '</a>'
. $cat_str
. '</div>'
. '</div>';
});
add_action('wp_head', function() {
if (!is_single()) return;
?>
<style>
.fea-byline { display: flex; align-items: center; gap: 0.75rem; margin-top: 0.75rem; }
.fea-byline-avatar-link { flex-shrink: 0; }
.fea-byline-avatar { border-radius: 50%; display: block; }
.fea-byline-info { display: flex; flex-direction: column; gap: 0.2rem; }
.fea-byline-name { font-size: 0.9rem; font-weight: 600; color: #222; text-decoration: none; }
.fea-byline-name:hover { text-decoration: underline; }
.fea-byline-cat { font-size: 0.78rem; color: #888; text-decoration: none; }
.fea-byline-cat:hover { text-decoration: underline; color: #555; }
</style>
<?php
});
// ── Helpers ───────────────────────────────────────────────────────────────
function fea_title(string $title): string {
$lower = mb_strtolower($title, 'UTF-8');
return mb_strtoupper(mb_substr($lower, 0, 1, 'UTF-8'), 'UTF-8') . mb_substr($lower, 1, null, 'UTF-8');
}
function fea_card(object $post): string {
$author_id = $post->post_author;
$author_name = get_the_author_meta('display_name', $author_id);
$avatar_url = get_avatar_url($author_id, ['size' => 28, 'default' => 'identicon']);
$url = get_permalink($post->ID);
$title = fea_title($post->post_title);
return '<article class="fea-card">'
. '<div class="fea-card-meta">'
. '<img src="' . esc_url($avatar_url) . '" alt="" width="28" height="28" class="fea-avatar" loading="lazy">'
. '<span class="fea-card-author">' . esc_html($author_name) . '</span>'
. '</div>'
. '<h3 class="fea-card-title"><a href="' . esc_url($url) . '">' . esc_html($title) . '</a></h3>'
. '</article>';
}
// ── Shortcode: [fea_carta_semana_hero] ────────────────────────────────────
add_shortcode('fea_carta_semana_hero', function() {
$cartas = get_posts([
'posts_per_page' => 1,
'category__in' => [6],
'post_status' => 'publish',
'orderby' => 'date',
'order' => 'DESC',
]);
if (!$cartas) return '';
$c = $cartas[0];
$url = get_permalink($c->ID);
$fecha = date_i18n('j \d\e F \d\e Y', strtotime($c->post_date));
$author_name = get_the_author_meta('display_name', $c->post_author);
$avatar_url = get_avatar_url($c->post_author, ['size' => 32, 'default' => 'identicon']);
return '<section class="fea-hero">'
. '<a href="' . esc_url($url) . '" class="fea-hero-link">'
. '<span class="fea-section-label">Carta de la semana</span>'
. '<h2 class="fea-hero-title">' . esc_html(fea_title($c->post_title)) . '</h2>'
. '<div class="fea-hero-meta">'
. '<img src="' . esc_url($avatar_url) . '" alt="" width="32" height="32" class="fea-avatar">'
. '<span>' . esc_html($author_name) . ' · ' . $fecha . '</span>'
. '</div>'
. '</a></section>';
});
// ── Shortcode: [fea_articulos_semana] ─────────────────────────────────────
add_shortcode('fea_articulos_semana', function($atts) {
$atts = shortcode_atts(['titulo' => 'Artículos de esta semana'], $atts);
$page = (int) get_option('page_on_front');
// Selección editorial (ACF) — solo posts publicados, ordenados por fecha desc
$posts = [];
if (function_exists('get_field')) {
$seleccion = get_field('portada_articulos', $page) ?: [];
foreach ($seleccion as $p) {
if ($p->post_status === 'publish') $posts[] = $p;
}
}
// Fallback: últimos artículos si no hay selección
if (empty($posts)) {
$posts = get_posts([
'posts_per_page' => 9,
'category__in' => [1650],
'category__not_in' => [6, 21, 22, 23, 26, 58, 40, 1645, 1646, 1647, 1648, 1649, 1651, 1652],
'post_status' => 'publish',
'orderby' => 'date',
'order' => 'DESC',
]);
}
if (!$posts) return '';
$html = '<section class="fea-section">'
. '<h2 class="fea-section-title">' . esc_html($atts['titulo']) . '</h2>'
. '<div class="fea-grid">';
foreach ($posts as $post) $html .= fea_card($post);
return $html . '</div></section>';
});
// ── Shortcode: [fea_evangelio] ────────────────────────────────────────────
// Editorial (cat 1646) primero, luego comentarios (cat 1647). Máx 7 en total.
add_shortcode('fea_evangelio', function($atts) {
$atts = shortcode_atts(['titulo' => 'Comentarios al evangelio'], $atts);
$editorial = get_posts([
'posts_per_page' => 1,
'category__in' => [1646],
'post_status' => 'publish',
'orderby' => 'date',
'order' => 'DESC',
]);
$comentarios = get_posts([
'posts_per_page' => 6,
'category__in' => [1647],
'category__not_in' => [1646],
'post_status' => 'publish',
'orderby' => 'date',
'order' => 'DESC',
]);
$posts = array_merge($editorial, $comentarios);
if (!$posts) return '';
$html = '<section class="fea-section">'
. '<h2 class="fea-section-title">' . esc_html($atts['titulo']) . '</h2>'
. '<div class="fea-grid">';
foreach ($posts as $post) $html .= fea_card($post);
return $html . '</div></section>';
});
// ── Shortcode: [fea_eucaristia] ───────────────────────────────────────────
add_shortcode('fea_eucaristia', function($atts) {
$atts = shortcode_atts(['titulo' => 'Para una eucaristía más participativa'], $atts);
$posts = get_posts([
'posts_per_page' => 6,
'category__in' => [1648],
'post_status' => 'publish',
'orderby' => 'date',
'order' => 'DESC',
]);
if (!$posts) return '';
$html = '<section class="fea-section">'
. '<h2 class="fea-section-title">' . esc_html($atts['titulo']) . '</h2>'
. '<div class="fea-grid">';
foreach ($posts as $post) $html .= fea_card($post);
return $html . '</div></section>';
});
// ── Shortcode: [fea_multimedia] ───────────────────────────────────────────
add_shortcode('fea_multimedia', function($atts) {
$atts = shortcode_atts(['titulo' => 'Multimedia'], $atts);
$page = (int) get_option('page_on_front');
$posts = [];
if (function_exists('get_field')) {
$seleccion = get_field('portada_multimedia', $page) ?: [];
foreach ($seleccion as $p) {
if ($p->post_status === 'publish') $posts[] = $p;
}
}
if (empty($posts)) {
$posts = get_posts([
'posts_per_page' => 4,
'category__in' => [1649, 26, 58],
'post_status' => 'publish',
'orderby' => 'date',
'order' => 'DESC',
]);
}
if (!$posts) return '';
$html = '<section class="fea-section">'
. '<h2 class="fea-section-title">' . esc_html($atts['titulo']) . '</h2>'
. '<div class="fea-grid">';
foreach ($posts as $post) $html .= fea_card($post);
return $html . '</div></section>';
});
// ── Reescribir links internos al idioma activo (Polylang) ─────────────────
add_filter('the_content', function($content) {
if (!function_exists('pll_current_language') || !function_exists('pll_get_post')) return $content;
$lang = pll_current_language();
if (!$lang || $lang === 'es') return $content;
return preg_replace_callback(
'/<a\s([^>]*\s)?href=["\']([^"\']+)["\']([^>]*)>/i',
function($m) use ($lang) {
$href = $m[2];
$home = home_url();
if (strpos($href, $home) === false) return $m[0];
$post_id = url_to_postid($href);
if (!$post_id) return $m[0];
$translated_id = pll_get_post($post_id, $lang);
if (!$translated_id || $translated_id === $post_id) return $m[0];
$new_url = get_permalink($translated_id);
if (!$new_url) return $m[0];
return str_replace($href, $new_url, $m[0]);
},
$content
);
}, 20);
+197
View File
@@ -0,0 +1,197 @@
<?php
/**
* fix_joomla_links.php
*
* Replaces Joomla internal links in WordPress post_content with correct WP URLs.
*
* Handles:
* 1. index.php?option=com_content&view=article&id=NNN → jos_content ID → WP post_name
* 2. es/.../NNN-slug.html (relative) → K2 item ID → WP post_name
* 3. http://feadulta.com/es/.../NNN-slug.html → K2 item ID → WP post_name
* 4. https://farmer.taild3aaf6.ts.net/fea/es/.../NNN-slug.html → K2 ID → WP post_name
*
* Usage: php fix_joomla_links.php [--dry-run]
*/
$dry_run = in_array('--dry-run', $argv ?? []);
// DB config
$db_host = 'wordpress-mysql';
$db_name = 'wordpress_db';
$db_user = 'wordpress_user';
$db_pass = 'wordpress_pass';
$wp_site_url = 'https://farmer.taild3aaf6.ts.net/fea';
$pdo = new PDO("mysql:host=$db_host;dbname=$db_name;charset=utf8mb4", $db_user, $db_pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
echo "=== Fix Joomla Internal Links ===\n";
echo $dry_run ? "[DRY RUN - no changes will be saved]\n\n" : "[LIVE RUN - changes will be saved]\n\n";
// -------------------------------------------------------------------------
// Step 1: Build lookup maps from wp_postmeta
// -------------------------------------------------------------------------
echo "Building lookup maps from wp_postmeta...\n";
// Map: K2 item ID → WP post_name
$k2_map = [];
$stmt = $pdo->query("
SELECT pm.meta_value AS k2_id, p.post_name
FROM wp_postmeta pm
JOIN wp_posts p ON pm.post_id = p.ID
WHERE pm.meta_key = '_fgj2wp_old_k2_id'
AND p.post_status IN ('publish', 'draft')
AND p.post_type = 'post'
AND p.post_name != ''
");
foreach ($stmt as $row) {
$k2_map[(int)$row['k2_id']] = $row['post_name'];
}
echo " K2 map: " . count($k2_map) . " entries\n";
// Map: jos_content ID → WP post_name
$joomla_map = [];
$stmt = $pdo->query("
SELECT pm.meta_value AS joomla_id, p.post_name
FROM wp_postmeta pm
JOIN wp_posts p ON pm.post_id = p.ID
WHERE pm.meta_key = '_fgj2wp_old_id'
AND p.post_status IN ('publish', 'draft')
AND p.post_type = 'post'
AND p.post_name != ''
");
foreach ($stmt as $row) {
$joomla_map[(int)$row['joomla_id']] = $row['post_name'];
}
echo " jos_content map: " . count($joomla_map) . " entries\n\n";
// -------------------------------------------------------------------------
// Step 2: Fetch posts with Joomla links
// -------------------------------------------------------------------------
$stmt = $pdo->query("
SELECT ID, post_title, post_content
FROM wp_posts
WHERE post_type = 'post'
AND post_status IN ('publish', 'draft')
AND (
post_content LIKE '%index.php?option=%'
OR post_content LIKE '%\"es/%'
OR post_content LIKE '%/es/%'
OR post_content LIKE '%feadulta.com%'
OR post_content LIKE '%farmer.taild3aaf6%'
)
");
$posts = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo "Posts to process: " . count($posts) . "\n\n";
// -------------------------------------------------------------------------
// Step 3: Process each post
// -------------------------------------------------------------------------
$stats = [
'posts_changed' => 0,
'posts_skipped' => 0,
'links_replaced' => 0,
'links_not_found'=> 0,
];
$not_found_log = [];
// Regex patterns (note: href values may be HTML-entity encoded: & → &amp;)
$patterns = [
// Pattern A: index.php?option=com_content&[amp;]view=article&[amp;]id=NNN[;alias]
'joomla_content' => '/href="index\.php\?option=com_content(?:&(?:amp;)?)[^"]*?(?:&(?:amp;)?)id=(\d+)[^"]*"/i',
// Pattern B: K2 item links — only on known Joomla-origin domains/paths
// Matches:
// href="es/[path/]NNN-slug.html" (relative)
// href="http://feadulta.com/[path/]NNN-slug.html" (old domain)
// href="https://farmer.taild3aaf6.ts.net/fea/[path/]NNN-slug.html" (staging domain)
'k2_item' => '/href="(?:(?:https?:\/\/feadulta\.com|https?:\/\/farmer\.taild3aaf6\.ts\.net\/fea)\/)?es\/[^"]*?\/(\d+)-[^"\/]+\.html[^"]*"/i',
];
$update_stmt = $pdo->prepare("UPDATE wp_posts SET post_content = ? WHERE ID = ?");
foreach ($posts as $post) {
$original = $post['post_content'];
$content = $original;
$changed = false;
// --- Pattern A: jos_content links ---
$content = preg_replace_callback(
$patterns['joomla_content'],
function ($m) use ($joomla_map, $wp_site_url, &$stats, &$not_found_log, $post) {
$id = (int)$m[1];
if (isset($joomla_map[$id])) {
$stats['links_replaced']++;
$new_url = $wp_site_url . '/' . $joomla_map[$id] . '/';
return 'href="' . $new_url . '"';
}
$stats['links_not_found']++;
$not_found_log[] = "jos_content ID=$id not found (post {$post['ID']}: {$post['post_title']})";
return $m[0]; // keep original
},
$content
);
// --- Pattern B: K2 item links ---
$content = preg_replace_callback(
$patterns['k2_item'],
function ($m) use ($k2_map, $wp_site_url, &$stats, &$not_found_log, $post) {
$id = (int)$m[1];
// Skip if ID 0, or if this looks like a year (4 digits in 1900-2100 range) in a date URL
if ($id === 0) return $m[0];
// Skip pure numbers that are years in date-based URLs (e.g. /2024/01/post.html)
// We check: if the full match contains /YYYY/ before the filename, skip
if ($id >= 1990 && $id <= 2100 && preg_match('/\/\d{4}\//', $m[0])) {
return $m[0];
}
if (isset($k2_map[$id])) {
$stats['links_replaced']++;
$new_url = $wp_site_url . '/' . $k2_map[$id] . '/';
return 'href="' . $new_url . '"';
}
$stats['links_not_found']++;
$not_found_log[] = "K2 ID=$id not found in map (post {$post['ID']}: {$post['post_title']}) | original: " . substr($m[0], 0, 100);
return $m[0]; // keep original
},
$content
);
if ($content !== $original) {
$changed = true;
$stats['posts_changed']++;
if (!$dry_run) {
$update_stmt->execute([$content, $post['ID']]);
} else {
echo " [DRY] Would update post {$post['ID']}: {$post['post_title']}\n";
}
} else {
$stats['posts_skipped']++;
}
}
// -------------------------------------------------------------------------
// Step 4: Summary
// -------------------------------------------------------------------------
echo "\n=== Results ===\n";
echo "Posts changed: {$stats['posts_changed']}\n";
echo "Posts unchanged: {$stats['posts_skipped']}\n";
echo "Links replaced: {$stats['links_replaced']}\n";
echo "Links not resolved: {$stats['links_not_found']}\n";
if (!empty($not_found_log)) {
$log_path = '/tmp/fix_joomla_links_unresolved.log';
file_put_contents($log_path, implode("\n", $not_found_log) . "\n");
echo "\nUnresolved links logged to: $log_path\n";
echo "First 10 unresolved:\n";
foreach (array_slice($not_found_log, 0, 10) as $line) {
echo " $line\n";
}
}
echo "\nDone.\n";
+241
View File
@@ -0,0 +1,241 @@
<?php
/**
* fix_numeric_categories.php
*
* Renames 100 WordPress categories that have numeric names (K2 Autor field IDs)
* to their proper author names from the Joomla K2 extra field mapping.
*
* When a named category already exists for the same author, merges both
* (moves posts from numeric → named category, then deletes numeric).
*
* Usage: php fix_numeric_categories.php [--dry-run]
*/
$dry_run = in_array('--dry-run', $argv ?? []);
$db_host = 'wordpress-mysql';
$db_name = 'wordpress_db';
$db_user = 'wordpress_user';
$db_pass = 'wordpress_pass';
$pdo = new PDO("mysql:host=$db_host;dbname=$db_name;charset=utf8mb4", $db_user, $db_pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
echo "=== Fix Numeric Author Categories ===\n";
echo $dry_run ? "[DRY RUN]\n\n" : "[LIVE RUN]\n\n";
// -------------------------------------------------------------------------
// Mapping: numeric value → author name (from K2 extra field "Autor")
// -------------------------------------------------------------------------
$autor_map = [
1 => "Fray Marcos",
2 => "José Antonio Pagola",
3 => "Enrique Martínez Lozano",
4 => "José Enrique Galarreta",
5 => "José Arregi",
6 => "Eloy Roy",
7 => "Dolores Aleixandre",
9 => "Florentino Ulibarri",
10 => "Rafael Calvo",
11 => "Julián Mellado",
12 => "Vicente Martínez",
13 => "Matilde Gastalver",
14 => "Koldo Aldai",
15 => "Sandra Hojman",
16 => "Leonardo Boff",
17 => "José M. Castillo",
18 => "Luís Alemán",
19 => "Juan José Tamayo",
20 => "José Ignacio González Faus",
22 => "José Manuel Vidal",
23 => "Isabel Gómez-Acebo",
31 => "Faustino Vilabrille",
32 => "Víctor Daniel Blanco",
33 => "Nuevo Testamento",
36 => "Gabriel Mª Otalora",
38 => "Luís García Orso",
40 => "María Teresa Sánchez Carmona",
41 => "Emma Martínez Ocaña",
45 => "Mari Patxi Ayerra",
49 => "Jesús Bastante",
52 => "J. A. Estrada",
53 => "Rafael Díaz Arias",
58 => "Susana Merino",
69 => "Asociación de teólogos y teólogas Juan XXIII",
73 => "José Ignacio Calleja",
75 => "Autor desconocido",
76 => "Gerardo Villar",
79 => "José Sánchez Luque",
83 => "Mari Paz López Santos",
84 => "Patricia Paz",
87 => "Pedro Casaldáliga",
88 => "Foro «Curas de Madrid»",
92 => "Xavier Pikaza",
96 => "Benjamín Forcano",
97 => "Ima Sanchís",
108 => "Pedro M. Lamet",
114 => "Juan G. Bedoya",
115 => "Juan Masiá",
123 => "Frei Betto",
124 => "Juan Cejudo",
125 => "Miguel Ángel Mesa",
126 => "Carlos F. Barberá",
127 => "Mariá Corbí",
129 => "Rafael Fernando Navarro",
149 => "José María Díez Alegría",
174 => "Carmen Soto",
175 => "Hans Küng",
188 => "Fidel Aizpurúa",
194 => "Pepcastelló",
208 => "Juan Yzuel",
234 => "Maite García Romero",
263 => "Gonzalo Haya",
288 => "Redes Cristianas",
303 => "Víctor Codina",
306 => "José María García-Mauriño",
312 => "Patxi Loidi",
321 => "Jesús Gil García",
323 => "John P. Meier",
325 => "Rogelio Cárdenas",
329 => "Pablo Ordaz",
345 => "Papa Francisco",
347 => "Vicky Irigaray",
357 => "Marco Antonio Velásquez Uribe",
362 => "Fernando Bermúdez López",
374 => "Pablo",
375 => "José Luis Sicre",
376 => "Miguel A. Munárriz Casajús",
382 => "Santiago Agrelo",
392 => "Felix Jiménez Tutor",
396 => "José María Alvarez",
399 => "Hechos",
404 => "Bruno Álvarez",
412 => "Luis Miguel Modino",
418 => "Varios autores",
435 => "Voces cristianas de Sevilla",
437 => "Religión Digital",
443 => "Francisco Bautista",
444 => "Yolanda Chávez",
449 => "Atrio",
450 => "Carolina Abarca",
465 => "Magdalena Bennasar",
516 => "Eclesalia",
520 => "Antonio Aradillas",
529 => "Humanismo Sin credos",
540 => "Juan Zapatero",
557 => "Marifé Ramos González",
566 => "Marta García",
570 => "María Dolores López Guzmán",
583 => "Inma Eibe",
615 => "Íñigo García Blanco",
];
// -------------------------------------------------------------------------
// Fetch all numeric categories from WordPress
// -------------------------------------------------------------------------
$stmt = $pdo->query("
SELECT t.term_id, t.name, t.slug, tt.count
FROM wp_terms t
JOIN wp_term_taxonomy tt ON t.term_id = tt.term_id
WHERE tt.taxonomy = 'category' AND t.name REGEXP '^[0-9]+$'
ORDER BY CAST(t.name AS UNSIGNED)
");
$numeric_cats = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo "Numeric categories found: " . count($numeric_cats) . "\n\n";
$stats = ['renamed' => 0, 'merged' => 0, 'skipped' => 0, 'no_map' => 0];
foreach ($numeric_cats as $cat) {
$num_val = (int)$cat['name'];
$term_id = (int)$cat['term_id'];
$post_count = (int)$cat['count'];
if (!isset($autor_map[$num_val])) {
echo " [SKIP] No mapping for value $num_val (term_id=$term_id, $post_count posts)\n";
$stats['no_map']++;
continue;
}
$new_name = $autor_map[$num_val];
// Check if a category with this name already exists
$existing = $pdo->prepare("
SELECT t.term_id, tt.count
FROM wp_terms t
JOIN wp_term_taxonomy tt ON t.term_id = tt.term_id
WHERE tt.taxonomy = 'category' AND t.name = ?
AND t.term_id != ?
");
$existing->execute([$new_name, $term_id]);
$existing_cat = $existing->fetch(PDO::FETCH_ASSOC);
if ($existing_cat) {
// MERGE: move posts from numeric category to the existing named category
$target_term_id = (int)$existing_cat['term_id'];
echo " [MERGE] \"$num_val\" ($post_count posts) \"$new_name\" (term_id=$target_term_id, existing {$existing_cat['count']} posts)\n";
if (!$dry_run) {
// Get term_taxonomy_id for both
$tt_stmt = $pdo->prepare("SELECT term_taxonomy_id FROM wp_term_taxonomy WHERE term_id = ? AND taxonomy = 'category'");
$tt_stmt->execute([$term_id]);
$src_tt_id = (int)$tt_stmt->fetchColumn();
$tt_stmt->execute([$target_term_id]);
$dst_tt_id = (int)$tt_stmt->fetchColumn();
// Move post relationships (avoiding duplicates)
$pdo->prepare("
UPDATE IGNORE wp_term_relationships
SET term_taxonomy_id = ?
WHERE term_taxonomy_id = ?
")->execute([$dst_tt_id, $src_tt_id]);
// Delete remaining relationships for source (duplicates that weren't moved)
$pdo->prepare("DELETE FROM wp_term_relationships WHERE term_taxonomy_id = ?")->execute([$src_tt_id]);
// Update count on target
$pdo->prepare("
UPDATE wp_term_taxonomy SET count = (
SELECT COUNT(*) FROM wp_term_relationships WHERE term_taxonomy_id = ?
) WHERE term_taxonomy_id = ?
")->execute([$dst_tt_id, $dst_tt_id]);
// Delete numeric category
$pdo->prepare("DELETE FROM wp_term_taxonomy WHERE term_id = ? AND taxonomy = 'category'")->execute([$term_id]);
$pdo->prepare("DELETE FROM wp_terms WHERE term_id = ?")->execute([$term_id]);
}
$stats['merged']++;
} else {
// RENAME: update name and slug
$new_slug = sanitize_slug($new_name);
echo " [RENAME] \"$num_val\" \"$new_name\" (term_id=$term_id, $post_count posts)\n";
if (!$dry_run) {
$pdo->prepare("UPDATE wp_terms SET name = ?, slug = ? WHERE term_id = ?")->execute([$new_name, $new_slug, $term_id]);
}
$stats['renamed']++;
}
}
echo "\n=== Results ===\n";
echo "Renamed: {$stats['renamed']}\n";
echo "Merged: {$stats['merged']}\n";
echo "Skipped (no map): {$stats['no_map']}\n";
echo "\nDone.\n";
// -------------------------------------------------------------------------
function sanitize_slug(string $name): string {
$slug = mb_strtolower($name, 'UTF-8');
$slug = str_replace(
['á','é','í','ó','ú','ü','ñ','ã','â','à','ê','ô','ç','ú','ó','ä','ö'],
['a','e','i','o','u','u','n','a','a','a','e','o','c','u','o','a','o'],
$slug
);
$slug = preg_replace('/[^a-z0-9\s-]/', '', $slug);
$slug = preg_replace('/[\s]+/', '-', trim($slug));
$slug = preg_replace('/-+/', '-', $slug);
return trim($slug, '-');
}
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""
fix_remaining_titles.py
Fixes posts where the translated title still equals the Spanish original.
Queries DB dynamically, then translates each title via Jan API.
"""
import pymysql
import json
import urllib.request
import time
JAN_URL = "http://172.19.128.1:1337/v1/chat/completions"
JAN_MODEL = "gemma-3-12b-it-Q4_K_M"
DB = dict(host='172.18.0.2', port=3306, user='wordpress_user',
password='wordpress_pass', database='wordpress_db', charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor)
LANG_NAMES = {"en": "English", "fr": "French", "it": "Italian", "pt": "Portuguese"}
def translate_title(spanish_title, lang_name):
payload = json.dumps({
"model": JAN_MODEL,
"messages": [
{"role": "system", "content": "You are a translator. Respond ONLY with the translated text, nothing else."},
{"role": "user", "content": f"Translate from Spanish to {lang_name}, ALL CAPS:\n\n{spanish_title}"}
],
"temperature": 0.1,
"max_tokens": 120,
}).encode("utf-8")
req = urllib.request.Request(
JAN_URL, data=payload,
headers={"Content-Type": "application/json", "Authorization": "Bearer dummy"},
method="POST"
)
with urllib.request.urlopen(req, timeout=30) as r:
result = json.loads(r.read())
return result["choices"][0]["message"]["content"].strip().strip('"').strip("'")
def main():
db = pymysql.connect(**DB)
c = db.cursor()
# Find all posts where the title = the Spanish original's title (untranslated)
c.execute("""
SELECT p.ID, t.slug as lang, p.post_title as current_title, p2.post_title as sp_title
FROM wp_posts p
JOIN wp_term_relationships trl ON p.ID=trl.object_id
JOIN wp_term_taxonomy ttl ON trl.term_taxonomy_id=ttl.term_taxonomy_id AND ttl.taxonomy='language'
JOIN wp_terms t ON ttl.term_id=t.term_id
JOIN wp_term_relationships trg ON p.ID=trg.object_id
JOIN wp_term_taxonomy ttg ON trg.term_taxonomy_id=ttg.term_taxonomy_id AND ttg.taxonomy='post_translations'
JOIN wp_posts p2 ON (ttg.description LIKE CONCAT('%i:',p2.ID,';%') OR ttg.description LIKE CONCAT('%i:',p2.ID,'}%'))
JOIN wp_term_relationships trl2 ON p2.ID=trl2.object_id
JOIN wp_term_taxonomy ttl2 ON trl2.term_taxonomy_id=ttl2.term_taxonomy_id AND ttl2.taxonomy='language'
JOIN wp_terms t2 ON ttl2.term_id=t2.term_id AND t2.slug='es'
WHERE p.ID > 42760 AND p.post_type='post' AND p.post_status='publish'
AND t.slug != 'es'
AND p.post_title = p2.post_title
ORDER BY t.slug, p.ID
""")
rows = c.fetchall()
print(f"Found {len(rows)} posts with untranslated titles\n")
cache = {} # (sp_title, lang) -> translated
done = 0
errors = 0
for row in rows:
post_id = row['ID']
lang = row['lang']
sp_title = row['sp_title']
lang_name = LANG_NAMES.get(lang, lang)
key = (sp_title, lang)
if key not in cache:
try:
t0 = time.time()
translated = translate_title(sp_title, lang_name)
elapsed = time.time() - t0
# Reject if translation = original (model failed)
if translated.upper() == sp_title.upper():
print(f" [{lang}] FAILED (returned same): {sp_title[:50]}")
errors += 1
cache[key] = None
continue
cache[key] = translated
print(f" [{lang}] {sp_title[:40]!r} -> {translated[:40]!r} ({elapsed:.0f}s)")
except Exception as e:
print(f" [{lang}] ERROR: {e}")
errors += 1
cache[key] = None
continue
new_title = cache.get(key)
if not new_title:
continue
c.execute("UPDATE wp_posts SET post_title=%s WHERE ID=%s", (new_title, post_id))
db.commit()
done += 1
print(f" Updated {post_id} [{lang}]: {new_title[:60]}")
db.close()
print(f"\nDone: {done} fixed, {errors} errors/skipped")
if __name__ == "__main__":
main()
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""
fix_titles.py
Fixes wrong/contaminated/untranslated titles for translated WordPress posts.
Translates only the title via Jan (fast, ~5s each).
"""
import pymysql
import json
import urllib.request
import sys
import time
JAN_URL = "http://172.19.128.1:1337/v1/chat/completions"
JAN_MODEL = "gemma-3-12b-it-Q4_K_M"
DB_HOST = "172.18.0.2"
DB_PORT = 3306
DB_NAME = "wordpress_db"
DB_USER = "wordpress_user"
DB_PASS = "wordpress_pass"
TARGET_LANGS = {"en": "English", "fr": "French", "it": "Italian", "pt": "Portuguese"}
# All posts needing title fix: post_id -> (lang, spanish_id, spanish_title)
FIXES = {
43151: ("en", 42523, "LA TENTACIÓN"),
43281: ("fr", 42523, "LA TENTACIÓN"),
43150: ("en", 42524, "CUANDO NOS LEEMOS EN CLAVE DE CARENCIA"),
43280: ("fr", 42524, "CUANDO NOS LEEMOS EN CLAVE DE CARENCIA"),
43278: ("fr", 42525, "SE TRATA DE BUSCAR LO MEJOR PARA MÍ, AUNQUE ME CUESTE"),
43270: ("it", 42526, "PARA SER TENTADO"),
43269: ("pt", 42526, "PARA SER TENTADO"),
43143: ("en", 42531, "LA MAYOR TENTACIÓN HUMANA"),
43263: ("fr", 42531, "LA MAYOR TENTACIÓN HUMANA"),
43261: ("it", 42531, "LA MAYOR TENTACIÓN HUMANA"),
43256: ("fr", 42532, "MIÉRCOLES DE CENIZA"),
43260: ("it", 42532, "MIÉRCOLES DE CENIZA"),
43141: ("en", 42533, "1º DOMINGO DE CUARESMA"),
43259: ("it", 42533, "1º DOMINGO DE CUARESMA"),
43251: ("pt", 42533, "1º DOMINGO DE CUARESMA"),
43137: ("en", 42538, "ADÁN, EVA Y JESÚS FRENTE A LA TENTACIÓN"),
43240: ("fr", 42538, "ADÁN, EVA Y JESÚS FRENTE A LA TENTACIÓN"),
43236: ("pt", 42538, "ADÁN, EVA Y JESÚS FRENTE A LA TENTACIÓN"),
43135: ("en", 42544, "LO PROVISIONAL Y LO DEFINITIVO"),
43234: ("fr", 42544, "LO PROVISIONAL Y LO DEFINITIVO"),
43228: ("pt", 42544, "LO PROVISIONAL Y LO DEFINITIVO"),
43134: ("en", 42545, "2º DOMINGO DE CUARESMA"),
43232: ("fr", 42545, "2º DOMINGO DE CUARESMA"),
43226: ("pt", 42545, "2º DOMINGO DE CUARESMA"),
43225: ("pt", 42546, "POR LA RENUNCIA AL TRIUNFO"),
43132: ("en", 42547, "LO DIVINO ES NUESTRA ESENCIA"),
43233: ("it", 42547, "LO DIVINO ES NUESTRA ESENCIA"),
43131: ("en", 42548, "¡QUÉ BUENO ES QUE ESTEMOS AQUÍ!"),
43223: ("fr", 42548, "¡QUÉ BUENO ES QUE ESTEMOS AQUÍ!"),
43230: ("it", 42548, "¡QUÉ BUENO ES QUE ESTEMOS AQUÍ!"),
43216: ("pt", 42549, "¿A QUÉ TRANSFIGURACIÓN NOS ESTAMOS REFIRIENDO?"),
43129: ("en", 42555, "CUANDO NOS LEEMOS EN CLAVE DE PLENITUD"),
43211: ("fr", 42555, "CUANDO NOS LEEMOS EN CLAVE DE PLENITUD"),
43221: ("it", 42555, "CUANDO NOS LEEMOS EN CLAVE DE PLENITUD"),
43212: ("pt", 42555, "CUANDO NOS LEEMOS EN CLAVE DE PLENITUD"),
43128: ("en", 42556, "CUARESMA: CREER EN EL EVANGELIO"),
43208: ("fr", 42556, "CUARESMA: CREER EN EL EVANGELIO"),
43127: ("en", 42557, "LA CUARESMA COMO PEDAGOGÍA EN EL TIEMPO"),
43206: ("fr", 42557, "LA CUARESMA COMO PEDAGOGÍA EN EL TIEMPO"),
43217: ("it", 42557, "LA CUARESMA COMO PEDAGOGÍA EN EL TIEMPO"),
43205: ("pt", 42557, "LA CUARESMA COMO PEDAGOGÍA EN EL TIEMPO"),
43126: ("en", 42558, "¡NO TENEMOS UN DIOS VENGATIVO!"),
43124: ("en", 42560, 'CARLOS AGUIAR: "LA SINODALIDAD HA VENIDO A LA IGLESIA PARA QUEDARSE"'),
43123: ("en", 42561, "¿HERENCIA CRISTIANA?"),
43196: ("fr", 42561, "¿HERENCIA CRISTIANA?"),
43194: ("pt", 42561, "¿HERENCIA CRISTIANA?"),
43122: ("en", 42562, 'EL PAPA ADVIERTE A LOS CURAS DE LA "PANDEMIA" DEL CLERICALISMO'),
43120: ("en", 42564, "MOISÉS, LA SAMARITANA Y EL BORRACHO"),
43187: ("pt", 42564, "MOISÉS, LA SAMARITANA Y EL BORRACHO"),
43119: ("en", 42565, "EL FINAL DE LA BÚSQUEDA"),
43182: ("pt", 42565, "EL FINAL DE LA BÚSQUEDA"),
43174: ("fr", 42568, "EN EL POZO DE LA DIGNIDAD LIBERADA"),
43183: ("it", 42568, "EN EL POZO DE LA DIGNIDAD LIBERADA"),
43115: ("en", 42569, "PALABRA Y EUCARISTÍA"),
43171: ("fr", 42569, "PALABRA Y EUCARISTÍA"),
43172: ("pt", 42569, "PALABRA Y EUCARISTÍA"),
43167: ("fr", 42570, 'MABEL RUIZ: "LA TRADICIÓN HA UTILIZADO A LAS MUJERES PARA QUE SEAN SILENCIADAS"'),
43169: ("pt", 42570, 'MABEL RUIZ: "LA TRADICIÓN HA UTILIZADO A LAS MUJERES PARA QUE SEAN SILENCIADAS"'),
43113: ("en", 42571, 'LEÓN XIV, ANTE EL ATAQUE DE EEUU E ISRAEL CONTRA IRÁN: "HAY QUE DETENERLO"'),
43166: ("pt", 42571, 'LEÓN XIV, ANTE EL ATAQUE DE EEUU E ISRAEL CONTRA IRÁN: "HAY QUE DETENERLO"'),
43111: ("en", 42573, 'VICARIO GENERAL DE MOSCÚ: "LA GUERRA EN UCRANIA DEBE TERMINAR"'),
43104: ("pt", 42573, 'VICARIO GENERAL DE MOSCÚ: "LA GUERRA EN UCRANIA DEBE TERMINAR"'),
43163: ("pt", 42574, "SERVIR ES UNA FORMA DE LIDERAR"),
43156: ("pt", 42576, 'DIARMAID MACCULLOCH, HISTORIADOR: "NO EXISTE UNA ENSEÑANZA UNIFORME SOBRE SEXUALIDAD"'),
43155: ("pt", 42577, "3º DOMINGO DE CUARESMA"),
}
# Orphaned posts to delete (no Polylang link to any Spanish original)
ORPHANS_TO_DELETE = [42581, 43130, 43235]
def translate_title(spanish_title, lang_code, lang_name):
payload = json.dumps({
"model": JAN_MODEL,
"messages": [
{"role": "user", "content": f"Translate this title from Spanish to {lang_name}. Return ONLY the translated title in ALL CAPS, nothing else: {spanish_title}"}
],
"temperature": 0.2,
"max_tokens": 100,
}).encode("utf-8")
req = urllib.request.Request(
JAN_URL, data=payload,
headers={"Content-Type": "application/json", "Authorization": "Bearer dummy"},
method="POST"
)
with urllib.request.urlopen(req, timeout=30) as r:
result = json.loads(r.read())
return result["choices"][0]["message"]["content"].strip().strip('"').strip("'")
def get_db():
return pymysql.connect(
host=DB_HOST, port=DB_PORT,
user=DB_USER, password=DB_PASS,
database=DB_NAME, charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor
)
def main():
db = get_db()
c = db.cursor()
# Delete orphans first
print("Deleting orphaned posts...")
for orphan_id in ORPHANS_TO_DELETE:
cmd = f"docker exec wordpress-web wp post delete {orphan_id} --force --allow-root"
import subprocess
result = subprocess.run(cmd.split(), capture_output=True, text=True)
print(f" Deleted {orphan_id}: {result.stdout.strip() or result.stderr.strip()}")
print(f"\nFixing {len(FIXES)} titles...\n")
done = 0
errors = 0
# Group by Spanish title to batch translate same title to multiple langs
by_spanish = {}
for post_id, (lang, sp_id, sp_title) in FIXES.items():
by_spanish.setdefault((sp_id, sp_title), []).append((post_id, lang))
translated_cache = {} # (sp_id, lang) -> translated_title
for (sp_id, sp_title), targets in by_spanish.items():
print(f"ES:{sp_id}{sp_title[:50]}")
for post_id, lang in targets:
lang_name = TARGET_LANGS[lang]
cache_key = (sp_id, lang)
if cache_key not in translated_cache:
try:
t0 = time.time()
new_title = translate_title(sp_title, lang, lang_name)
elapsed = time.time() - t0
translated_cache[cache_key] = new_title
print(f" [{lang}] {new_title[:60]} ({elapsed:.0f}s)")
except Exception as e:
print(f" [{lang}] ERROR translating: {e}")
errors += 1
continue
new_title = translated_cache[cache_key]
# Update the post title
c.execute("UPDATE wp_posts SET post_title=%s WHERE ID=%s", (new_title, post_id))
db.commit()
print(f" [{lang}] Updated {post_id}: {new_title[:60]}")
done += 1
db.close()
print(f"\nDone: {done} fixed, {errors} errors")
if __name__ == "__main__":
main()
+197
View File
@@ -0,0 +1,197 @@
<?php
/**
* generate_k2_redirects.php
*
* Populates wp_fg_redirect table with 301 redirect entries for all K2 items
* migrated to WordPress.
*
* Joomla K2 URL pattern: /es/[menu]/NNN-alias.html
* Stored in wp_fg_redirect as: NNN-alias.html
* FG plugin matches via LIKE '%NNN-alias.html' fallback.
*
* Also adds redirects for K2 categories → WP categories.
*
* Usage: php generate_k2_redirects.php [--dry-run]
*/
$dry_run = in_array('--dry-run', $argv ?? []);
// DB config - WordPress
$wp_host = 'wordpress-mysql';
$wp_db = 'wordpress_db';
$wp_user = 'wordpress_user';
$wp_pass = 'wordpress_pass';
// DB config - Joomla
$jm_host = 'joomla-mysql';
$jm_db = 'joomla_db';
$jm_user = 'joomla_user';
$jm_pass = 'joomla_pass';
$wp_pdo = new PDO("mysql:host=$wp_host;dbname=$wp_db;charset=utf8mb4", $wp_user, $wp_pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$jm_pdo = new PDO("mysql:host=$jm_host;dbname=$jm_db;charset=utf8mb4", $jm_user, $jm_pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
echo "=== Generate K2 Redirects → wp_fg_redirect ===\n";
echo $dry_run ? "[DRY RUN]\n\n" : "[LIVE RUN]\n\n";
// -------------------------------------------------------------------------
// Step 1: Build K2 ID → alias map from Joomla
// -------------------------------------------------------------------------
echo "Loading K2 items from Joomla...\n";
$stmt = $jm_pdo->query("SELECT id, alias FROM ew4r_k2_items WHERE alias != '' AND alias IS NOT NULL");
$k2_aliases = [];
foreach ($stmt as $row) {
$k2_aliases[(int)$row['id']] = $row['alias'];
}
echo " K2 items loaded: " . count($k2_aliases) . "\n";
// -------------------------------------------------------------------------
// Step 2: Load WP postmeta: K2 ID → WP post ID
// -------------------------------------------------------------------------
echo "Loading WP postmeta (_fgj2wp_old_k2_id)...\n";
$stmt = $wp_pdo->query("
SELECT pm.meta_value AS k2_id, pm.post_id AS wp_id
FROM wp_postmeta pm
JOIN wp_posts p ON pm.post_id = p.ID
WHERE pm.meta_key = '_fgj2wp_old_k2_id'
AND p.post_type = 'post'
");
$k2_to_wp = [];
foreach ($stmt as $row) {
$k2_to_wp[(int)$row['k2_id']] = (int)$row['wp_id'];
}
echo " WP posts with K2 ID: " . count($k2_to_wp) . "\n\n";
// -------------------------------------------------------------------------
// Step 3: Check existing redirects to avoid duplicates
// -------------------------------------------------------------------------
echo "Loading existing redirects...\n";
$existing = [];
$stmt = $wp_pdo->query("SELECT old_url FROM wp_fg_redirect");
foreach ($stmt as $row) {
$existing[$row['old_url']] = true;
}
echo " Existing entries: " . count($existing) . "\n\n";
// -------------------------------------------------------------------------
// Step 4: Build and insert K2 item redirects
// -------------------------------------------------------------------------
echo "Building K2 item redirects...\n";
$insert = $wp_pdo->prepare("
INSERT IGNORE INTO wp_fg_redirect (old_url, id, type, activated)
VALUES (?, ?, 'post', 1)
");
$stats = ['inserted' => 0, 'skipped_no_alias' => 0, 'skipped_no_wp' => 0, 'skipped_exists' => 0];
// Process in batches
$batch = [];
foreach ($k2_to_wp as $k2_id => $wp_id) {
if (!isset($k2_aliases[$k2_id])) {
$stats['skipped_no_alias']++;
continue;
}
$alias = $k2_aliases[$k2_id];
$old_url = $k2_id . '-' . $alias . '.html';
if (isset($existing[$old_url])) {
$stats['skipped_exists']++;
continue;
}
$batch[] = [$old_url, $wp_id];
}
echo " Redirects to insert: " . count($batch) . "\n";
if (!$dry_run) {
$wp_pdo->beginTransaction();
try {
foreach ($batch as [$old_url, $wp_id]) {
$insert->execute([$old_url, $wp_id]);
$stats['inserted']++;
if ($stats['inserted'] % 1000 === 0) {
echo " ... {$stats['inserted']} inserted\n";
$wp_pdo->commit();
$wp_pdo->beginTransaction();
}
}
$wp_pdo->commit();
} catch (Exception $e) {
$wp_pdo->rollBack();
echo "ERROR: " . $e->getMessage() . "\n";
exit(1);
}
} else {
$stats['inserted'] = count($batch);
// Show first 5 samples
echo "\n Sample entries:\n";
foreach (array_slice($batch, 0, 5) as [$old_url, $wp_id]) {
echo " $old_url → post ID $wp_id\n";
}
}
// -------------------------------------------------------------------------
// Step 5: K2 category redirects
// -------------------------------------------------------------------------
echo "\nBuilding K2 category redirects...\n";
// Load K2 categories from Joomla
$stmt = $jm_pdo->query("SELECT id, alias FROM ew4r_k2_categories WHERE published=1");
$k2_cats = [];
foreach ($stmt as $row) {
$k2_cats[(int)$row['id']] = $row['alias'];
}
echo " K2 categories: " . count($k2_cats) . "\n";
// Load WP term IDs for K2 categories via postmeta equivalent
// FG plugin stores category mapping in wp_term_meta or wp_termmeta
$stmt = $wp_pdo->query("
SELECT tm.term_id, tm.meta_value AS k2_cat_id
FROM wp_termmeta tm
WHERE tm.meta_key = '_fgj2wp_old_k2_category_id'
");
$k2_cat_to_wp = [];
foreach ($stmt as $row) {
$k2_cat_to_wp[(int)$row['k2_cat_id']] = (int)$row['term_id'];
}
echo " WP categories with K2 ID: " . count($k2_cat_to_wp) . "\n";
$insert_cat = $wp_pdo->prepare("
INSERT IGNORE INTO wp_fg_redirect (old_url, id, type, activated)
VALUES (?, ?, 'category', 1)
");
$cat_inserted = 0;
foreach ($k2_cat_to_wp as $k2_cat_id => $wp_term_id) {
if (!isset($k2_cats[$k2_cat_id])) continue;
$alias = $k2_cats[$k2_cat_id];
// K2 category URL: /es/[alias] or /es/k2-items/[alias]
$old_url = $alias . '.html';
if (isset($existing[$old_url])) continue;
if (!$dry_run) {
$insert_cat->execute([$old_url, $wp_term_id]);
}
$cat_inserted++;
}
echo " Category redirects: $cat_inserted\n";
// -------------------------------------------------------------------------
// Summary
// -------------------------------------------------------------------------
echo "\n=== Results ===\n";
echo "K2 item redirects inserted: {$stats['inserted']}\n";
echo "Skipped (no alias): {$stats['skipped_no_alias']}\n";
echo "Skipped (no WP post): {$stats['skipped_no_wp']}\n";
echo "Skipped (already exists): {$stats['skipped_exists']}\n";
echo "Category redirects: $cat_inserted\n";
echo "\nTotal in wp_fg_redirect now:\n";
if (!$dry_run) {
$count = $wp_pdo->query("SELECT COUNT(*) FROM wp_fg_redirect")->fetchColumn();
echo " $count entries\n";
}
echo "\nDone.\n";
+310
View File
@@ -0,0 +1,310 @@
#!/usr/bin/env python3
"""
retranslate_chunks.py
Re-translates posts where content is in the wrong language.
Splits post_content into chunks of ~800 chars (at </p> boundaries)
and translates each chunk independently to avoid model drift.
"""
import pymysql
import json
import re
import html
import urllib.request
import time
import sys
import csv
from langdetect import detect, LangDetectException, DetectorFactory
DetectorFactory.seed = 0
JAN_URL = "http://172.19.128.1:1337/v1/chat/completions"
JAN_MODEL = "gemma-3-12b-it-Q4_K_M"
DB = dict(host='172.18.0.2', port=3306, user='wordpress_user',
password='wordpress_pass', database='wordpress_db', charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor)
LANG_NAMES = {"en": "English", "fr": "French", "it": "Italian", "pt": "Portuguese"}
LANG_NORM = {'es':'es','pt':'pt','fr':'fr','en':'en','it':'it','ca':'es','gl':'es'}
AI_FOOTER = "\n<p><em>Traducido con IA</em></p>"
CHUNK_SIZE = 800 # max chars per translation chunk
MAX_RETRIES = 2
def strip_html(text):
if not text: return ''
text = re.sub(r'<[^>]+>', ' ', text)
text = html.unescape(text)
return re.sub(r'\s+', ' ', text).strip()
def detect_lang(text, min_len=60):
t = strip_html(text)[:600].strip()
if len(t) < min_len: return None
try: return LANG_NORM.get(detect(t), detect(t))
except: return None
def call_jan(messages, max_tokens=1200, temperature=0.2, timeout=120):
payload = json.dumps({
"model": JAN_MODEL,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}).encode("utf-8")
req = urllib.request.Request(
JAN_URL, data=payload,
headers={"Content-Type": "application/json", "Authorization": "Bearer dummy"},
method="POST"
)
with urllib.request.urlopen(req, timeout=timeout) as r:
result = json.loads(r.read())
return result["choices"][0]["message"]["content"].strip()
def translate_chunk(chunk, lang_name):
"""Translate a single HTML chunk. Returns translated text or None on failure."""
system = (
f"You are a professional translator. Translate the following Spanish text to {lang_name}. "
f"Preserve all HTML tags exactly as they are. "
f"Return ONLY the translated text, nothing else. No preamble, no explanation."
)
plain_len = len(strip_html(chunk).strip())
for attempt in range(MAX_RETRIES):
try:
result = call_jan([
{"role": "system", "content": system},
{"role": "user", "content": chunk}
])
# For short chunks (headings, short phrases) langdetect is unreliable —
# accept the result as long as it changed from the original Spanish
if plain_len < 40:
changed = strip_html(result).strip().lower() != strip_html(chunk).strip().lower()
if changed or attempt > 0:
return result
else:
lang = detect_lang(result, min_len=40)
if lang is None or lang == lang_name[:2].lower():
return result
# Wrong language — retry with more explicit prompt
system = (
f"Translate from Spanish to {lang_name}. "
f"Your response must be entirely in {lang_name}. "
f"Preserve HTML tags. Return ONLY the translation."
)
except Exception as e:
if attempt == MAX_RETRIES - 1:
return None
time.sleep(2)
return None # all retries failed
def translate_title(title, lang_name):
try:
result = call_jan([
{"role": "system", "content": "You are a translator. Respond ONLY with the translated text, nothing else."},
{"role": "user", "content": f"Translate from Spanish to {lang_name}, ALL CAPS:\n\n{title}"}
], max_tokens=120, temperature=0.1, timeout=30)
return result.strip().strip('"').strip("'")
except:
return None
def split_into_chunks(content, max_size=CHUNK_SIZE):
"""Split HTML content at </p> boundaries into chunks <= max_size chars."""
# Split at closing block tags
parts = re.split(r'(</p>|</li>|</h[1-6]>|</blockquote>)', content)
chunks = []
current = ""
for i in range(0, len(parts), 2):
piece = parts[i]
closer = parts[i+1] if i+1 < len(parts) else ""
segment = piece + closer
if len(current) + len(segment) <= max_size:
current += segment
else:
if current:
chunks.append(current)
# If a single segment exceeds max_size, split it roughly
if len(segment) > max_size:
# Split at sentence boundaries
sentences = re.split(r'(?<=[.!?])\s+', segment)
current = ""
for s in sentences:
if len(current) + len(s) <= max_size:
current += s + " "
else:
if current:
chunks.append(current.strip())
current = s + " "
else:
current = segment
if current:
chunks.append(current)
return [c for c in chunks if c.strip()]
def translate_content_chunked(content, lang_name):
"""
Translate full post_content by splitting into chunks.
Returns (translated_content, success_ratio).
"""
if not content or not content.strip():
return content, 1.0
chunks = split_into_chunks(content)
translated_chunks = []
failed = 0
for chunk in chunks:
# Skip chunks that are only HTML tags / whitespace
if not strip_html(chunk).strip():
translated_chunks.append(chunk)
continue
result = translate_chunk(chunk, lang_name)
if result is None:
# Keep original chunk rather than losing it
translated_chunks.append(chunk)
failed += 1
else:
translated_chunks.append(result)
success_ratio = 1.0 - (failed / len(chunks)) if chunks else 1.0
return "\n".join(translated_chunks), success_ratio
def main():
audit_path = '/tmp/audit_clean.csv'
failed_ids = set()
try:
with open(audit_path) as f:
reader = csv.DictReader(f)
for row in reader:
failed_ids.add(int(row['id']))
print(f"Loaded {len(failed_ids)} post IDs with issues from audit")
except FileNotFoundError:
print(f"ERROR: {audit_path} not found. Run audit_translations.py first.")
sys.exit(1)
db = pymysql.connect(**DB)
c = db.cursor()
id_list = ','.join(str(i) for i in sorted(failed_ids))
c.execute(f"""
SELECT DISTINCT p.ID, p.post_title, p.post_content,
t_lang.slug as lang,
ttg.description as group_desc
FROM wp_posts p
JOIN wp_term_relationships trl ON p.ID=trl.object_id
JOIN wp_term_taxonomy ttl ON trl.term_taxonomy_id=ttl.term_taxonomy_id AND ttl.taxonomy='language'
JOIN wp_terms t_lang ON ttl.term_id=t_lang.term_id
JOIN wp_term_relationships trg ON p.ID=trg.object_id
JOIN wp_term_taxonomy ttg ON trg.term_taxonomy_id=ttg.term_taxonomy_id AND ttg.taxonomy='post_translations'
WHERE p.ID IN ({id_list}) AND p.post_type='post' AND p.post_status='publish'
""")
raw_posts = c.fetchall()
# Fetch Spanish originals
posts = []
es_cache = {}
for p in raw_posts:
desc = p['group_desc'] or ''
m = re.search(r's:2:"es";i:(\d+);', desc)
if not m:
continue
es_id = int(m.group(1))
if es_id not in es_cache:
c.execute("SELECT ID, post_title, post_content FROM wp_posts WHERE ID=%s", (es_id,))
row = c.fetchone()
es_cache[es_id] = row
es = es_cache[es_id]
if es:
posts.append({**p, 'es_id': es_id, 'es_title': es['post_title'], 'es_content': es['post_content']})
db.close()
print(f"Fetched {len(posts)} posts to retranslate\n")
by_es = {}
for p in posts:
by_es.setdefault(p['es_id'], []).append(p)
done = errors = skipped = partial = 0
total = len(posts)
n = 0
for es_id, translations in sorted(by_es.items()):
es_title = translations[0]['es_title'] or ''
es_content = translations[0]['es_content'] or ''
content_len = len(strip_html(es_content))
if content_len < 50:
print(f" ES:{es_id} — SKIPPING (too short: {content_len} chars)")
skipped += len(translations)
n += len(translations)
continue
# Show chunk count for visibility
chunks = split_into_chunks(es_content)
print(f"\nES:{es_id}{es_title[:50]} ({content_len} chars, {len(chunks)} chunks)")
for p in translations:
post_id = p['ID']
lang = p['lang']
lang_name = LANG_NAMES.get(lang, lang)
n += 1
try:
t0 = time.time()
# Translate title
t_title = translate_title(es_title, lang_name) if es_title else ''
if not t_title or t_title.upper() == es_title.upper():
t_title = p['post_title'] # keep existing if translation failed
# Translate content chunk by chunk
t_content, ratio = translate_content_chunked(es_content, lang_name)
elapsed = time.time() - t0
# Validate overall content language
content_lang = detect_lang(t_content, min_len=80)
lang_ok = (content_lang == lang) or content_lang is None
# Add AI footer
if AI_FOOTER.strip() not in t_content:
t_content = t_content + AI_FOOTER
# Update DB
db2 = pymysql.connect(**DB)
c2 = db2.cursor()
c2.execute("UPDATE wp_posts SET post_title=%s, post_content=%s WHERE ID=%s",
(t_title, t_content, post_id))
db2.commit()
db2.close()
status = "" if (lang_ok and ratio == 1.0) else ("~" if lang_ok else "")
if ratio < 1.0:
partial += 1
elif lang_ok:
done += 1
else:
errors += 1
print(f" [{lang}] {status} {post_id}: {t_title[:50]} ({elapsed:.0f}s, {ratio:.0%} ok)")
except Exception as e:
print(f" [{lang}] ✗ ERROR on {post_id}: {e}")
errors += 1
print(f"\n{'='*50}")
print(f"Done: {done} ✓ partial: {partial} ~ errors/wrong-lang: {errors} ⚠ skipped: {skipped}")
print(f"Total: {n}/{total}")
if __name__ == "__main__":
main()
+230
View File
@@ -0,0 +1,230 @@
#!/usr/bin/env python3
"""
retranslate_en_all.py
Retranslates ALL English posts (ID > 42760) from their Spanish originals.
Uses chunk-based translation (~800 chars per chunk) to avoid model drift.
Sequential, single process.
"""
import pymysql, json, re, html, urllib.request, time, sys
from langdetect import detect, LangDetectException, DetectorFactory
DetectorFactory.seed = 0
JAN_URL = "http://172.19.128.1:1337/v1/chat/completions"
JAN_MODEL = "gemma-3-12b-it-Q4_K_M"
DB = dict(host='172.18.0.2', port=3306, user='wordpress_user',
password='wordpress_pass', database='wordpress_db', charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor)
CHUNK_SIZE = 800
MAX_RETRIES = 2
AI_FOOTER = "\n<p><em>Traducido con IA</em></p>"
def strip_html(text):
if not text: return ''
text = re.sub(r'<[^>]+>', ' ', text)
text = html.unescape(text)
return re.sub(r'\s+', ' ', text).strip()
def detect_lang(text, min_len=40):
t = strip_html(text)[:400].strip()
if len(t) < min_len: return None
try:
from langdetect import detect as _detect
return _detect(t)
except: return None
def call_jan(messages, max_tokens=1200, temperature=0.2, timeout=120):
payload = json.dumps({
"model": JAN_MODEL, "messages": messages,
"temperature": temperature, "max_tokens": max_tokens,
}).encode("utf-8")
req = urllib.request.Request(
JAN_URL, data=payload,
headers={"Content-Type": "application/json", "Authorization": "Bearer dummy"},
method="POST"
)
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read())["choices"][0]["message"]["content"].strip()
def translate_chunk(chunk, attempt=0):
prompts = [
"You are a professional translator. Translate the following Spanish text to English. Preserve all HTML tags exactly. Return ONLY the translated text, no preamble, no explanation.",
"Translate from Spanish to English. Your entire response must be in English. Preserve HTML tags. Return ONLY the translation, nothing else.",
]
system = prompts[min(attempt, len(prompts)-1)]
result = call_jan([
{"role": "system", "content": system},
{"role": "user", "content": chunk}
])
# Short chunks: retry if output == input (model didn't translate)
plain_in = strip_html(chunk).strip().lower()
plain_out = strip_html(result).strip().lower()
if len(plain_in) < 40 and plain_in == plain_out and attempt == 0:
return translate_chunk(chunk, attempt=1)
return result
def translate_title(es_title):
try:
result = call_jan([
{"role": "system", "content": "You are a translator. Respond ONLY with the translated text, nothing else."},
{"role": "user", "content": f"Translate from Spanish to English, ALL CAPS:\n\n{es_title}"}
], max_tokens=150, temperature=0.1, timeout=30)
result = result.strip().strip('"').strip("'")
# Reject if identical to original
if result.upper() == es_title.upper():
return es_title
return result
except:
return es_title
def split_chunks(content):
parts = re.split(r'(</p>|</li>|</h[1-6]>|</blockquote>)', content)
chunks, current = [], ""
for i in range(0, len(parts), 2):
segment = parts[i] + (parts[i+1] if i+1 < len(parts) else "")
if len(current) + len(segment) <= CHUNK_SIZE:
current += segment
else:
if current: chunks.append(current)
if len(segment) > CHUNK_SIZE:
# Split long segment at sentence boundaries
sentences = re.split(r'(?<=[.!?])\s+', segment)
current = ""
for s in sentences:
if len(current) + len(s) <= CHUNK_SIZE:
current += s + " "
else:
if current: chunks.append(current.strip())
current = s + " "
else:
current = segment
if current: chunks.append(current)
return [c for c in chunks if strip_html(c).strip()]
def main():
db = pymysql.connect(**DB)
c = db.cursor()
# Fetch all EN posts with their Spanish originals
c.execute("""
SELECT DISTINCT p.ID, p.post_title,
ttg.description as group_desc
FROM wp_posts p
JOIN wp_term_relationships trl ON p.ID=trl.object_id
JOIN wp_term_taxonomy ttl ON trl.term_taxonomy_id=ttl.term_taxonomy_id AND ttl.taxonomy='language'
JOIN wp_terms t_lang ON ttl.term_id=t_lang.term_id AND t_lang.slug='en'
JOIN wp_term_relationships trg ON p.ID=trg.object_id
JOIN wp_term_taxonomy ttg ON trg.term_taxonomy_id=ttg.term_taxonomy_id AND ttg.taxonomy='post_translations'
WHERE p.ID > 42760 AND p.post_type='post' AND p.post_status='publish'
ORDER BY p.ID
""")
posts = c.fetchall()
print(f"Found {len(posts)} EN posts to retranslate\n", flush=True)
done = errors = skipped = 0
total = len(posts)
for n, p in enumerate(posts, 1):
post_id = p['ID']
desc = p['group_desc'] or ''
m = re.search(r's:2:"es";i:(\d+);', desc)
if not m:
print(f"[{n}/{total}] {post_id} — SKIP (no ES original in group)", flush=True)
skipped += 1
continue
es_id = int(m.group(1))
c.execute("SELECT post_title, post_content FROM wp_posts WHERE ID=%s", (es_id,))
es = c.fetchone()
if not es or not es['post_content']:
print(f"[{n}/{total}] {post_id} — SKIP (ES:{es_id} empty)", flush=True)
skipped += 1
continue
es_title = es['post_title'] or ''
es_content = es['post_content']
plain_len = len(strip_html(es_content))
chunks = split_chunks(es_content)
print(f"\n[{n}/{total}] WP:{post_id} ← ES:{es_id}{es_title[:50]}", flush=True)
print(f" {plain_len} chars, {len(chunks)} chunks", flush=True)
if plain_len < 50:
print(f" SKIP (too short)", flush=True)
skipped += 1
continue
try:
t0 = time.time()
# Translate title
t_title = translate_title(es_title)
# Translate content chunk by chunk
translated = []
chunk_ok = chunk_bad = 0
for i, chunk in enumerate(chunks):
try:
result = translate_chunk(chunk, attempt=0)
lang = detect_lang(result, min_len=40)
if lang and lang != 'en' and len(strip_html(result)) >= 40:
result2 = translate_chunk(chunk, attempt=1)
lang2 = detect_lang(result2, min_len=40)
if lang2 == 'en' or lang2 is None:
result = result2
chunk_ok += 1
else:
chunk_bad += 1
else:
chunk_ok += 1
translated.append(result)
except Exception as e:
print(f" chunk {i+1} ERROR: {e}", flush=True)
translated.append(chunk)
chunk_bad += 1
t_content = "\n".join(translated)
if AI_FOOTER.strip() not in t_content:
t_content += AI_FOOTER
# Validate overall
content_lang = detect_lang(t_content, min_len=80)
lang_ok = content_lang in ('en', None)
elapsed = time.time() - t0
# Save
db2 = pymysql.connect(**DB)
c2 = db2.cursor()
c2.execute("UPDATE wp_posts SET post_title=%s, post_content=%s WHERE ID=%s",
(t_title, t_content, post_id))
db2.commit()
db2.close()
status = "" if lang_ok else ""
bad_note = f" ({chunk_bad} chunks bad)" if chunk_bad else ""
print(f" {status} {t_title[:60]} ({elapsed:.0f}s){bad_note}", flush=True)
done += 1
except Exception as e:
print(f" ✗ ERROR: {e}", flush=True)
errors += 1
db.close()
print(f"\n{'='*50}")
print(f"Done: {done} ✓ errors: {errors} ✗ skipped: {skipped}")
print(f"Total: {total}")
if __name__ == "__main__":
main()
+233
View File
@@ -0,0 +1,233 @@
#!/usr/bin/env python3
"""
retranslate_failures.py
Re-translates posts where content is in the wrong language.
Reads the audit CSV (/tmp/audit_clean.csv), fetches Spanish originals,
retranslates content (and title if needed), and updates the DB.
Uses a clean prompt WITHOUT few-shot examples to avoid contamination.
"""
import pymysql
import json
import re
import html
import urllib.request
import urllib.error
import time
import sys
import csv
from langdetect import detect, LangDetectException, DetectorFactory
DetectorFactory.seed = 0
JAN_URL = "http://172.19.128.1:1337/v1/chat/completions"
JAN_MODEL = "gemma-3-12b-it-Q4_K_M"
DB = dict(host='172.18.0.2', port=3306, user='wordpress_user',
password='wordpress_pass', database='wordpress_db', charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor)
LANG_NAMES = {"en": "English", "fr": "French", "it": "Italian", "pt": "Portuguese"}
LANG_NORM = {'es':'es','pt':'pt','fr':'fr','en':'en','it':'it','ca':'es','gl':'es'}
AI_FOOTER = "\n<p><em>Traducido con IA</em></p>"
def strip_html(text):
if not text: return ''
text = re.sub(r'<[^>]+>', ' ', text)
text = html.unescape(text)
return re.sub(r'\s+', ' ', text).strip()
def detect_lang(text, min_len=80):
t = strip_html(text)[:600].strip()
if len(t) < min_len: return None
try: return LANG_NORM.get(detect(t), detect(t))
except: return None
def call_jan(messages, max_tokens=4096, temperature=0.3, timeout=300):
payload = json.dumps({
"model": JAN_MODEL,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}).encode("utf-8")
req = urllib.request.Request(
JAN_URL, data=payload,
headers={"Content-Type": "application/json", "Authorization": "Bearer dummy"},
method="POST"
)
with urllib.request.urlopen(req, timeout=timeout) as r:
result = json.loads(r.read())
return result["choices"][0]["message"]["content"].strip()
def translate_content(title, content, lang_code, lang_name):
"""Translate title + content using a clean prompt (no few-shot contamination)."""
system = (
f"You are a professional translator specializing in theological and religious texts. "
f"Translate from Spanish to {lang_name}. "
f"Rules: preserve all HTML tags exactly; translate the title literally in ALL CAPS; "
f"maintain formal theological register; translate standard religious proper nouns (e.g. 'Jesús''Jesus' in English); "
f"keep person/place names as-is; return ONLY the translation starting with 'Title:'"
)
user = f"Title: {title}\n\n{content}"
response = call_jan([
{"role": "system", "content": system},
{"role": "user", "content": user}
])
lines = response.split("\n", 2)
if lines[0].startswith("Title:"):
t_title = lines[0].replace("Title:", "").strip()
t_content = "\n".join(lines[1:]).strip() if len(lines) > 1 else ""
else:
t_title = lines[0].strip()
t_content = "\n".join(lines[1:]).strip() if len(lines) > 1 else response
return t_title, t_content
def translate_title_only(title, lang_name):
response = call_jan([
{"role": "system", "content": "You are a translator. Respond ONLY with the translated text, nothing else."},
{"role": "user", "content": f"Translate from Spanish to {lang_name}, ALL CAPS:\n\n{title}"}
], max_tokens=120, temperature=0.1, timeout=30)
return response.strip().strip('"').strip("'")
def main():
# Load audit results
audit_path = '/tmp/audit_clean.csv'
failed_ids = set()
try:
with open(audit_path) as f:
reader = csv.DictReader(f)
for row in reader:
failed_ids.add(int(row['id']))
print(f"Loaded {len(failed_ids)} post IDs with issues from audit")
except FileNotFoundError:
print(f"ERROR: {audit_path} not found. Run audit_translations.py first.")
sys.exit(1)
db = pymysql.connect(**DB)
c = db.cursor()
# Fetch failed posts - get lang and translation group description
id_list = ','.join(str(i) for i in sorted(failed_ids))
c.execute(f"""
SELECT DISTINCT p.ID, p.post_title, p.post_content,
t_lang.slug as lang,
ttg.description as group_desc
FROM wp_posts p
JOIN wp_term_relationships trl ON p.ID=trl.object_id
JOIN wp_term_taxonomy ttl ON trl.term_taxonomy_id=ttl.term_taxonomy_id AND ttl.taxonomy='language'
JOIN wp_terms t_lang ON ttl.term_id=t_lang.term_id
JOIN wp_term_relationships trg ON p.ID=trg.object_id
JOIN wp_term_taxonomy ttg ON trg.term_taxonomy_id=ttg.term_taxonomy_id AND ttg.taxonomy='post_translations'
WHERE p.ID IN ({id_list}) AND p.post_type='post' AND p.post_status='publish'
""")
raw_posts = c.fetchall()
# Extract Spanish ID from group description and fetch Spanish content
import re as _re
posts = []
es_cache = {}
for p in raw_posts:
desc = p['group_desc'] or ''
m = _re.search(r's:2:"es";i:(\d+);', desc)
if not m:
continue
es_id = int(m.group(1))
if es_id not in es_cache:
c.execute("SELECT ID, post_title, post_content FROM wp_posts WHERE ID=%s", (es_id,))
row = c.fetchone()
es_cache[es_id] = row
es = es_cache[es_id]
if es:
posts.append({**p, 'es_id': es_id, 'es_title': es['post_title'], 'es_content': es['post_content']})
db.close()
print(f"Fetched {len(posts)} posts to retranslate\n")
# Group by Spanish original to avoid redundant API calls
by_es = {}
for p in posts:
by_es.setdefault(p['es_id'], []).append(p)
done = errors = skipped = 0
total = len(posts)
n = 0
for es_id, translations in sorted(by_es.items()):
es_title = translations[0]['es_title']
es_content = translations[0]['es_content'] or ''
content_len = len(strip_html(es_content))
if content_len < 50:
print(f" ES:{es_id} — SKIPPING (content too short: {content_len} chars)")
skipped += len(translations)
n += len(translations)
continue
print(f"\nES:{es_id}{(es_title or '')[:50]} ({content_len} chars)")
for p in translations:
post_id = p['ID']
lang = p['lang']
lang_name = LANG_NAMES.get(lang, lang)
n += 1
try:
t0 = time.time()
t_title, t_content = translate_content(es_title or '', es_content, lang, lang_name)
elapsed = time.time() - t0
# Validate: content should now be in target language
content_lang = detect_lang(t_content, min_len=80)
ok = (content_lang == lang) or content_lang is None
# If still wrong language, retry with simpler prompt
if not ok and content_lang:
print(f" [{lang}] ⚠ Content still {content_lang}, retrying...")
retry_response = call_jan([
{"role": "system", "content": f"You are a professional translator. Translate the following Spanish text to {lang_name}. Preserve all HTML tags. Return ONLY the translated text, no preamble, no explanation."},
{"role": "user", "content": es_content}
])
t_content = retry_response
content_lang2 = detect_lang(t_content, min_len=80)
if content_lang2 == lang or content_lang2 is None:
print(f" [{lang}] ✓ Retry succeeded ({content_lang2})")
ok = True
else:
print(f" [{lang}] ✗ Retry still {content_lang2}, saving anyway")
# Add AI footer if not present
if AI_FOOTER.strip() not in t_content:
t_content = t_content + AI_FOOTER
# Update DB
db2 = pymysql.connect(**DB)
c2 = db2.cursor()
c2.execute("UPDATE wp_posts SET post_title=%s, post_content=%s WHERE ID=%s",
(t_title, t_content, post_id))
db2.commit()
db2.close()
status = "" if ok else ""
print(f" [{lang}] {status} {post_id}: {t_title[:50]} ({elapsed:.0f}s)")
done += 1
except Exception as e:
print(f" [{lang}] ✗ ERROR on {post_id}: {e}")
errors += 1
print(f"\n{'='*50}")
print(f"Done: {done} retranslated, {errors} errors, {skipped} skipped")
print(f"Total processed: {n}/{total}")
if __name__ == "__main__":
main()
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""
retranslate_lang.py
Retranslates ALL posts for a given language (ID > 42760) from their Spanish originals.
Uses chunk-based translation (~800 chars per chunk) to avoid model drift.
Sequential, single process.
Usage: python3 retranslate_lang.py fr
python3 retranslate_lang.py it
python3 retranslate_lang.py pt
"""
import pymysql, json, re, html, urllib.request, time, sys
from langdetect import detect, LangDetectException, DetectorFactory
DetectorFactory.seed = 0
JAN_URL = "http://172.19.128.1:1337/v1/chat/completions"
JAN_MODEL = "gemma-3-12b-it-Q4_K_M"
DB = dict(host='172.18.0.2', port=3306, user='wordpress_user',
password='wordpress_pass', database='wordpress_db', charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor)
LANG_CONFIG = {
"en": {"name": "English", "footer": "<p><em>English version translated with AI</em></p>"},
"fr": {"name": "French", "footer": "<p><em>Version française traduite par IA</em></p>"},
"it": {"name": "Italian", "footer": "<p><em>Versione italiana tradotta con IA</em></p>"},
"pt": {"name": "Portuguese", "footer": "<p><em>Versão portuguesa traduzida com IA</em></p>"},
}
CHUNK_SIZE = 800
MAX_RETRIES = 2
def strip_html(text):
if not text: return ''
text = re.sub(r'<[^>]+>', ' ', text)
text = html.unescape(text)
return re.sub(r'\s+', ' ', text).strip()
def detect_lang(text, min_len=40):
t = strip_html(text)[:400].strip()
if len(t) < min_len: return None
try: return detect(t)
except: return None
def call_jan(messages, max_tokens=1200, temperature=0.2, timeout=150):
payload = json.dumps({
"model": JAN_MODEL, "messages": messages,
"temperature": temperature, "max_tokens": max_tokens,
}).encode("utf-8")
req = urllib.request.Request(
JAN_URL, data=payload,
headers={"Content-Type": "application/json", "Authorization": "Bearer dummy"},
method="POST"
)
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read())["choices"][0]["message"]["content"].strip()
def fix_html_structure(content):
"""Fix common model errors: markdown bold → HTML, orphaned text → <p> wrapped,
unclosed <p> before a new <p>."""
# **text** → <p><strong>text</strong></p>
content = re.sub(r'\*\*(.+?)\*\*',
lambda m: '<p><strong>' + m.group(1).strip() + '</strong></p>',
content)
# Lines of bare text not inside any block tag → wrap in <p>
lines = content.split('\n')
fixed = []
for line in lines:
s = line.strip()
if s and not s.startswith('<') and not s.startswith('<!--'):
fixed.append('<p>' + s + '</p>')
else:
fixed.append(line)
content = '\n'.join(fixed)
# Clean up doubled closing tags
content = re.sub(r'</p>\s*</p>', '</p>', content)
# Fix unclosed <p>: text not ending in block tag followed by \n\n<p>
content = re.sub(r'([^>])\n\n(<p[> ])', r'\1</p>\n\n\2', content)
# Fix nested <em> inside a quote: <em>"..."(n. <em>18).</em> → <em>"..."(n. 18).</em>
content = re.sub(r'\(n\.\s*<em>(\d+\)\.)</em>', r'(n. \1</em>', content)
# Generic: remove extra </em> after </p> if em tags unbalanced
opens = len(re.findall(r'<em[ >]', content))
closes = len(re.findall(r'</em>', content))
if opens < closes:
# Remove extra closing tags
for _ in range(closes - opens):
content = content.replace('</em></p>', '</p>', 1)
elif opens > closes:
# Add missing closing tag before </p> of last unbalanced paragraph
content = re.sub(r'(<em>[^<]*(?:<(?!/em>)[^<]*)*)\n\n<p', r'\1</em>\n\n<p', content)
return content
def translate_chunk(chunk, lang_name, attempt=0):
prompts = [
f"You are a professional translator. Translate the following Spanish text to {lang_name}. Preserve all HTML tags exactly. Return ONLY the translated text, no preamble, no explanation.",
f"Translate from Spanish to {lang_name}. Your entire response must be in {lang_name}. Preserve HTML tags. Return ONLY the translation, nothing else.",
]
result = call_jan([
{"role": "system", "content": prompts[min(attempt, 1)]},
{"role": "user", "content": chunk}
])
# Short chunks: retry if output == input (model didn't translate)
plain_in = strip_html(chunk).strip().lower()
plain_out = strip_html(result).strip().lower()
if len(plain_in) < 40 and plain_in == plain_out and attempt == 0:
return translate_chunk(chunk, lang_name, attempt=1)
return result
def translate_title(es_title, lang_name):
try:
result = call_jan([
{"role": "system", "content": "You are a translator. Respond ONLY with the translated text, nothing else."},
{"role": "user", "content": f"Translate from Spanish to {lang_name}, ALL CAPS:\n\n{es_title}"}
], max_tokens=150, temperature=0.1, timeout=30)
result = result.strip().strip('"').strip("'")
if result.upper() == es_title.upper():
return es_title
return result
except:
return es_title
def split_chunks(content):
parts = re.split(r'(</p>|</li>|</h[1-6]>|</blockquote>)', content)
chunks, current = [], ""
for i in range(0, len(parts), 2):
segment = parts[i] + (parts[i+1] if i+1 < len(parts) else "")
if len(current) + len(segment) <= CHUNK_SIZE:
current += segment
else:
if current: chunks.append(current)
if len(segment) > CHUNK_SIZE:
sentences = re.split(r'(?<=[.!?])\s+', segment)
current = ""
for s in sentences:
if len(current) + len(s) <= CHUNK_SIZE:
current += s + " "
else:
if current: chunks.append(current.strip())
current = s + " "
else:
current = segment
if current: chunks.append(current)
return [c for c in chunks if strip_html(c).strip()]
def main():
if len(sys.argv) < 2 or sys.argv[1] not in LANG_CONFIG:
print(f"Usage: python3 {sys.argv[0]} [fr|it|pt|en]")
sys.exit(1)
lang = sys.argv[1]
lang_name = LANG_CONFIG[lang]["name"]
footer = LANG_CONFIG[lang]["footer"]
db = pymysql.connect(**DB)
c = db.cursor()
c.execute("""
SELECT DISTINCT p.ID, p.post_title,
ttg.description as group_desc
FROM wp_posts p
JOIN wp_term_relationships trl ON p.ID=trl.object_id
JOIN wp_term_taxonomy ttl ON trl.term_taxonomy_id=ttl.term_taxonomy_id AND ttl.taxonomy='language'
JOIN wp_terms t_lang ON ttl.term_id=t_lang.term_id AND t_lang.slug=%s
JOIN wp_term_relationships trg ON p.ID=trg.object_id
JOIN wp_term_taxonomy ttg ON trg.term_taxonomy_id=ttg.term_taxonomy_id AND ttg.taxonomy='post_translations'
WHERE p.ID > 42760 AND p.post_type='post' AND p.post_status='publish'
ORDER BY p.ID
""", (lang,))
posts = c.fetchall()
print(f"Found {len(posts)} {lang_name} posts to retranslate\n", flush=True)
done = errors = skipped = 0
for n, p in enumerate(posts, 1):
post_id = p['ID']
desc = p['group_desc'] or ''
m = re.search(r's:2:"es";i:(\d+);', desc)
if not m:
print(f"[{n}/{len(posts)}] {post_id} — SKIP (no ES original)", flush=True)
skipped += 1
continue
es_id = int(m.group(1))
c.execute("SELECT post_title, post_content FROM wp_posts WHERE ID=%s", (es_id,))
es = c.fetchone()
if not es or not es['post_content']:
print(f"[{n}/{len(posts)}] {post_id} — SKIP (ES:{es_id} empty)", flush=True)
skipped += 1
continue
es_title = es['post_title'] or ''
es_content = es['post_content']
plain_len = len(strip_html(es_content))
chunks = split_chunks(es_content)
print(f"\n[{n}/{len(posts)}] WP:{post_id} ← ES:{es_id}{es_title[:50]}", flush=True)
print(f" {plain_len} chars, {len(chunks)} chunks", flush=True)
if plain_len < 50:
print(f" SKIP (too short)", flush=True)
skipped += 1
continue
try:
t0 = time.time()
t_title = translate_title(es_title, lang_name)
translated = []
chunk_bad = 0
for i, chunk in enumerate(chunks):
try:
result = translate_chunk(chunk, lang_name, attempt=0)
detected = detect_lang(result, min_len=40)
if detected and detected != lang and len(strip_html(result)) >= 40:
result2 = translate_chunk(chunk, lang_name, attempt=1)
detected2 = detect_lang(result2, min_len=40)
if detected2 == lang or detected2 is None:
result = result2
else:
chunk_bad += 1
translated.append(result)
except Exception as e:
print(f" chunk {i+1} ERROR: {e}", flush=True)
translated.append(chunk)
chunk_bad += 1
t_content = fix_html_structure("\n".join(translated))
# Remove any old footer variants before adding the correct one
for old in ["<p><em>Traducido con IA</em></p>",
"<p><em>English version translated with AI</em></p>",
"<p><em>Version française traduite par IA</em></p>",
"<p><em>Versione italiana tradotta con IA</em></p>",
"<p><em>Versão portuguesa traduzida com IA</em></p>"]:
t_content = t_content.replace(old, "")
t_content = t_content.rstrip() + "\n" + footer
elapsed = time.time() - t0
lang_ok = detect_lang(t_content, min_len=80) in (lang, None)
status = "" if lang_ok else ""
bad_note = f" ({chunk_bad} chunks bad)" if chunk_bad else ""
db2 = pymysql.connect(**DB)
c2 = db2.cursor()
c2.execute("UPDATE wp_posts SET post_title=%s, post_content=%s WHERE ID=%s",
(t_title, t_content, post_id))
db2.commit()
db2.close()
print(f" {status} {t_title[:60]} ({elapsed:.0f}s){bad_note}", flush=True)
done += 1
except Exception as e:
print(f" ✗ ERROR: {e}", flush=True)
errors += 1
db.close()
print(f"\n{'='*50}")
print(f"Done: {done} ✓ errors: {errors} ✗ skipped: {skipped}")
print(f"Total: {len(posts)}")
if __name__ == "__main__":
main()
+88
View File
@@ -0,0 +1,88 @@
#!/bin/bash
# Script de configuración automática de WordPress
# Fe Adulta - Migración desde Joomla
set -e
echo "🚀 Instalando WordPress..."
# Instalar WordPress
docker exec wordpress-web wp core install \
--url="http://localhost:8081" \
--title="Fe Adulta - Para poner al día la Fe" \
--admin_user="admin" \
--admin_password="FeAdulta2024!" \
--admin_email="inma@tyve.es" \
--skip-email \
--allow-root
echo "✅ WordPress instalado"
# Configurar idioma español
echo "🌍 Configurando idioma español..."
docker exec wordpress-web wp language core install es_ES --activate --allow-root
# Configurar timezone
docker exec wordpress-web wp option update timezone_string "Europe/Madrid" --allow-root
# Configurar permalink estructura (importante para SEO)
docker exec wordpress-web wp rewrite structure '/%postname%/' --allow-root
echo "📦 Instalando plugins esenciales..."
# Plugins de migración
docker exec wordpress-web wp plugin install fg-joomla-to-wordpress --activate --allow-root
# Plugins de SEO
docker exec wordpress-web wp plugin install wordpress-seo --activate --allow-root
# Plugins de cache y optimización
docker exec wordpress-web wp plugin install wp-super-cache --allow-root
# Plugins de seguridad
docker exec wordpress-web wp plugin install wordfence --allow-root
# Plugins de AdSense
docker exec wordpress-web wp plugin install advanced-ads --allow-root
# Text-to-Speech - varias opciones, instalamos para evaluar
docker exec wordpress-web wp plugin install speech-kit --allow-root
docker exec wordpress-web wp plugin install gspeech --allow-root
# Editor mejorado
docker exec wordpress-web wp plugin install classic-editor --allow-root
# Importador de WordPress
docker exec wordpress-web wp plugin install wordpress-importer --activate --allow-root
echo "🎨 Instalando temas..."
# Tema ligero y optimizado para contenido
docker exec wordpress-web wp theme install astra --activate --allow-root
# Temas alternativos para evaluar
docker exec wordpress-web wp theme install generatepress --allow-root
docker exec wordpress-web wp theme install kadence --allow-root
echo "⚙️ Configuraciones finales..."
# Deshabilitar comentarios por defecto (se pueden habilitar después)
docker exec wordpress-web wp option update default_comment_status "closed" --allow-root
# Configurar posts por página
docker exec wordpress-web wp option update posts_per_page 20 --allow-root
# Eliminar contenido de ejemplo
docker exec wordpress-web wp post delete 1 --force --allow-root || true
docker exec wordpress-web wp post delete 2 --force --allow-root || true
docker exec wordpress-web wp comment delete 1 --force --allow-root || true
echo "✨ WordPress configurado correctamente!"
echo ""
echo "🔑 Credenciales de acceso:"
echo " URL: http://localhost:8081/wp-admin"
echo " Usuario: admin"
echo " Contraseña: FeAdulta2024!"
echo ""
echo "📊 Próximo paso: Accede al panel y revisa la configuración"
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""
test_5articles.py
Translates 5 specific articles ES→EN using chunk approach.
Prints per-chunk results so we can verify quality before full batch.
"""
import pymysql, json, re, html, urllib.request, time
from langdetect import detect, LangDetectException, DetectorFactory
DetectorFactory.seed = 0
JAN_URL = "http://172.19.128.1:1337/v1/chat/completions"
JAN_MODEL = "gemma-3-12b-it-Q4_K_M"
DB = dict(host='172.18.0.2', port=3306, user='wordpress_user',
password='wordpress_pass', database='wordpress_db', charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor)
# (wp_id_EN, es_id)
TEST_POSTS = [
(43127, 42557), # ~3k chars
(43132, 42547), # ~4k chars
(43114, 42570), # ~4k chars
(43139, 42536), # ~5k chars
(42987, 42535), # ~15k chars
]
CHUNK_SIZE = 800
AI_FOOTER = "\n<p><em>Traducido con IA</em></p>"
def strip_html(text):
if not text: return ''
text = re.sub(r'<[^>]+>', ' ', text)
text = html.unescape(text)
return re.sub(r'\s+', ' ', text).strip()
def detect_lang(text, min_len=40):
t = strip_html(text)[:400].strip()
if len(t) < min_len: return None
try: return detect(t)
except: return None
def call_jan(messages, max_tokens=1200, temperature=0.2, timeout=120):
payload = json.dumps({
"model": JAN_MODEL, "messages": messages,
"temperature": temperature, "max_tokens": max_tokens,
}).encode("utf-8")
req = urllib.request.Request(
JAN_URL, data=payload,
headers={"Content-Type": "application/json", "Authorization": "Bearer dummy"},
method="POST"
)
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read())["choices"][0]["message"]["content"].strip()
def translate_chunk(chunk, attempt=0):
prompts = [
"You are a professional translator. Translate the following Spanish text to English. Preserve all HTML tags exactly. Return ONLY the translated text, no preamble.",
"Translate from Spanish to English. Your response must be entirely in English. Preserve HTML tags. Return ONLY the translation.",
]
system = prompts[min(attempt, len(prompts)-1)]
result = call_jan([
{"role": "system", "content": system},
{"role": "user", "content": chunk}
])
# For very short chunks, retry if result == original (model didn't translate)
plain_in = strip_html(chunk).strip().lower()
plain_out = strip_html(result).strip().lower()
if len(plain_in) < 40 and plain_in == plain_out and attempt == 0:
return translate_chunk(chunk, attempt=1)
return result
def split_chunks(content):
parts = re.split(r'(</p>|</li>|</h[1-6]>|</blockquote>)', content)
chunks, current = [], ""
for i in range(0, len(parts), 2):
segment = parts[i] + (parts[i+1] if i+1 < len(parts) else "")
if len(current) + len(segment) <= CHUNK_SIZE:
current += segment
else:
if current: chunks.append(current)
current = segment
if current: chunks.append(current)
return [c for c in chunks if strip_html(c).strip()]
def main():
db = pymysql.connect(**DB)
c = db.cursor()
for wp_en_id, es_id in TEST_POSTS:
c.execute("SELECT post_title, post_content FROM wp_posts WHERE ID=%s", (es_id,))
es = c.fetchone()
if not es:
print(f"\n[SKIP] ES:{es_id} not found"); continue
es_title = es['post_title'] or ''
es_content = es['post_content'] or ''
chunks = split_chunks(es_content)
plain_len = len(strip_html(es_content))
print(f"\n{'='*60}")
print(f"WP:{wp_en_id} ← ES:{es_id}")
print(f"Title: {es_title[:60]}")
print(f"Content: {plain_len} chars, {len(chunks)} chunks")
print(f"{'='*60}")
# Translate title
try:
t0 = time.time()
t_title = call_jan([
{"role": "system", "content": "You are a translator. Respond ONLY with the translated text."},
{"role": "user", "content": f"Translate from Spanish to English, ALL CAPS:\n\n{es_title}"}
], max_tokens=120, temperature=0.1, timeout=30)
t_title = t_title.strip().strip('"').strip("'")
print(f"Title [{detect_lang(t_title) or '?'}]: {t_title[:70]} ({time.time()-t0:.0f}s)")
except Exception as e:
t_title = es_title
print(f"Title ERROR: {e}")
# Translate chunks
translated = []
ok = bad = 0
for i, chunk in enumerate(chunks):
try:
t0 = time.time()
result = translate_chunk(chunk, attempt=0)
lang = detect_lang(result) or '?'
if lang not in ('en', None, '?') and len(strip_html(result)) > 40:
# Retry
result2 = translate_chunk(chunk, attempt=1)
lang2 = detect_lang(result2) or '?'
if lang2 == 'en' or lang2 in ('?', None):
result, lang = result2, lang2
print(f" chunk {i+1}/{len(chunks)} [retry→{lang}] {time.time()-t0:.0f}s ✓")
else:
print(f" chunk {i+1}/{len(chunks)} [STILL {lang2}] {time.time()-t0:.0f}s ⚠ — keeping anyway")
bad += 1
else:
print(f" chunk {i+1}/{len(chunks)} [{lang}] {time.time()-t0:.0f}s ✓")
ok += 1
translated.append(result)
except Exception as e:
print(f" chunk {i+1}/{len(chunks)} ERROR: {e}")
translated.append(chunk) # keep original
bad += 1
t_content = "\n".join(translated)
if AI_FOOTER.strip() not in t_content:
t_content += AI_FOOTER
# Save to DB
c.execute("UPDATE wp_posts SET post_title=%s, post_content=%s WHERE ID=%s",
(t_title, t_content, wp_en_id))
db.commit()
ratio = ok / len(chunks) if chunks else 1.0
print(f" → Saved. {ok}/{len(chunks)} chunks ok ({ratio:.0%})")
print(f" → Check: http://localhost:8081/?p={wp_en_id}")
db.close()
print(f"\n{'='*60}")
print("Done. Review the 5 posts in WP admin before running full batch.")
print("URLs to check:")
for wp_en_id, _ in TEST_POSTS:
print(f" http://localhost:8081/?p={wp_en_id}")
if __name__ == "__main__":
main()
+401
View File
@@ -0,0 +1,401 @@
#!/usr/bin/env python3
"""
translate_cartas.py
Traduce artículos españoles de las últimas 2 cartas semanales usando Jan (Gemma 12B).
Crea los posts traducidos en WordPress local (Docker) y los vincula con Polylang.
Uso:
1. Arranca Jan con Gemma 12B
2. python3 translate_cartas.py --check-api # verifica conexión a Jan
3. python3 translate_cartas.py --dry-run # muestra qué se traduciría
4. python3 translate_cartas.py # traduce todo
5. python3 translate_cartas.py --lang en # solo un idioma
6. python3 translate_cartas.py --id 42579 # solo un artículo
"""
import subprocess
import json
import re
import sys
import time
import argparse
import pymysql
# ── Configuración ─────────────────────────────────────────────────────────────
JAN_URL = "http://172.19.128.1:1337/v1/chat/completions"
JAN_MODEL = "gemma-3-12b-it-Q4_K_M"
DB_HOST = "172.18.0.2"
DB_PORT = 3306
DB_NAME = "wordpress_db"
DB_USER = "wordpress_user"
DB_PASS = "wordpress_pass"
WP_CONTAINER = "wordpress-web"
TARGET_LANGS = {
"en": "English",
"fr": "French",
"it": "Italian",
"pt": "Portuguese",
}
# IDs de artículos en español de todas las cartas de 2026
# (excluye 26899 = 42k chars, demasiado largo para Jan)
SPANISH_IDS = [
# Carta 2026-03-05 (Agua Viva) — las 2 últimas ya traducidas, se saltarán automáticamente
42732, 42731, 42730, 42729, 42728, 42727, 42726, 42590,
42579, 42578, 42577, 42576, 42575, 42574, 42573, 42572, 42571,
42570, 42569, 42568, 42567, 42566, 42565, 42564, 42563, 42562,
42561, 42560, 42559, 42558, 42557, 42556,
# Carta 2026-02-26 (¿Creemos en el evangelio?)
42594, 42555, 42554, 42553, 42552, 42551, 42550, 42549, 42548, 42547,
42546, 42545, 42544, 42543, 42542, 42541, 42540, 42539, 42538,
42537, 42536, 42535, 42534, 42533, 42532, 42531, 42530, 42529,
42528, 42527, 42526, 42525, 42524, 42523,
# Carta 2026-02-19 (Seres limitados)
42589, 42517, 42516, 42515, 42514, 42513, 42512, 42511,
42510, 42509, 42508, 42507, 42506, 42518, 42505, 42504, 42503,
42502, 42501,
# Carta 2026-02-12 (Más allá de la ley)
42588, 42500, 42499, 42498, 42497, 42496, 42495, 42490,
42489, 42488, 42487, 42486, 42485, 42484, 42587, 42478,
# Carta 2026-02-05 (Ser sal, ser luz)
42477, 42476, 42475, 42474, 42473, 42472, 42471, 42470,
42469, 42468, 42467, 42466, 42465, 42464, 42586, 42479,
# Carta 2026-01-29 (Bienaventurados)
42459, 42458, 42457, 42456, 42455, 42454, 42453, 42452,
42451, 42585, 42450, 42463, 42462, 42461, 42460, 42445, 42444,
# Carta 2026-01-22 (Nuevos caminos)
42584, 42443, 42442, 42441, 42440, 42439, 42438, 42437,
42436, 42431, 42430, 42429, 42428, 42427, 42426, 42425, 42424,
# Carta 2026-01-15 (La ley del Oeste)
26899, # 42k chars — se saltará por tamaño
26898, 26897, 26896, 26895, 26894, 26893, 26892,
26714, 26713, 26712, 26711, 26710, 26717, 26887, 26716, 26886, 26715,
# Carta 2026-01-08 (Hakuna / Avivando ilusiones)
26885, 26884, 26883, 26882, 26881, 26880, 26875, 26708,
26707, 26706, 26705, 26704, 26703, 26702, 26874, 26873,
26872, 26871, 26870, 26869, 26868, 26867, 26866, 26865,
# Carta 2026-01-01
26864, 26863, 26862, 26861, 26860, 26859, 26858, 26857,
26856, 26855, 26709,
]
# Tamaño máximo de contenido para traducción automática (chars)
MAX_CONTENT_LEN = 35000
AI_FOOTER = "\n<p><em>Traducido con IA</em></p>"
# ── Detectar modelo Jan ───────────────────────────────────────────────────────
def get_jan_model():
import urllib.request
try:
req_m = urllib.request.Request(JAN_URL.replace("/chat/completions", "/models"), headers={"Authorization": "Bearer dummy"})
with urllib.request.urlopen(req_m, timeout=5) as r:
data = json.loads(r.read())
models = data.get("data", [])
if models:
return models[0]["id"]
except Exception as e:
print(f"ERROR: No se puede conectar a Jan en {JAN_URL}")
print(f" {e}")
print(" Asegúrate de que Jan está corriendo con Gemma 12B cargado.")
sys.exit(1)
return "gemma"
# ── Traducción via Jan ────────────────────────────────────────────────────────
def translate(title, content, lang_code, lang_name):
import urllib.request, urllib.error
# Few-shot examples from existing human translations (Pagola) to guide style
few_shot = {
"en": [
("NO SABEMOS SABOREAR LA FE", "WE DON'T KNOW HOW TO SAVOR FAITH"),
("ESCUCHAR A JESÚS EN LA SOCIEDAD ACTUAL", "LISTENING TO JESUS IN TODAY'S SOCIETY"),
("FIELES A JESÚS EN MEDIO DE LAS TENTACIONES", "FAITHFUL TO JESUS IN TEMPTATIONS"),
],
"fr": [
("NO SABEMOS SABOREAR LA FE", "NOUS NE SAVONS PAS APPRÉCIER LA FOI"),
("ESCUCHAR A JESÚS EN LA SOCIEDAD ACTUAL", "ÉCOUTER JÉSUS DANS LA SOCIÉTÉ ACTUELLE"),
("FIELES A JESÚS EN MEDIO DE LAS TENTACIONES", "FIDÈLES À JÉSUS AU MILIEU DES TENTATIONS"),
],
"it": [
("NO SABEMOS SABOREAR LA FE", "NON SAPPIAMO ASSAPORARE LA FEDE"),
("ESCUCHAR A JESÚS EN LA SOCIEDAD ACTUAL", "ASCOLTARE GESÙ NELLA SOCIETÀ ATTUALE"),
("FIELES A JESÚS EN MEDIO DE LAS TENTACIONES", "FEDELI A GESÙ NELLE TENTAZIONI"),
],
"pt": [
("NO SABEMOS SABOREAR LA FE", "NÃO SABEMOS SABOREAR A FÉ"),
("ESCUCHAR A JESÚS EN LA SOCIEDAD ACTUAL", "OUVIR JESUS NA SOCIEDADE ATUAL"),
("FIELES A JESÚS EN MEDIO DE LAS TENTACIONES", "FIÉIS A JESUS NO MEIO DAS TENTAÇÕES"),
],
}
example_lines = "\n".join(
f" ES: {e}\n {lang_code.upper()}: {t}"
for e, t in few_shot.get(lang_code, [])
)
example_block = f"\n\nTitle translation examples (be exactly this literal):\n{example_lines}" if example_lines else ""
system_prompt = f"""You are a professional translator specializing in theological and religious texts.
Translate from Spanish to {lang_name}.
Rules:
- Preserve all HTML tags exactly as they appear
- Translate the title LITERALLY — never paraphrase or summarize it
- Keep the full title including everything after colons and quoted subtitles
- Titles must be in ALL CAPS
- Maintain formal theological register
- Standard religious proper nouns: translate them (e.g. "Jesús""Jesus" in English)
- Other proper nouns (person names, place names): keep as-is
- Return ONLY the translation, starting with 'Title:'{example_block}"""
payload = json.dumps({
"model": JAN_MODEL,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"Title: {title}\n\n{content}"}
],
"temperature": 0.3,
"max_tokens": 4096,
}).encode("utf-8")
req = urllib.request.Request(
JAN_URL,
data=payload,
headers={"Content-Type": "application/json", "Authorization": "Bearer dummy"},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=300) as r:
result = json.loads(r.read())
full = result["choices"][0]["message"]["content"].strip()
# Separar título traducido del contenido
lines = full.split("\n", 2)
if lines[0].startswith("Title:"):
translated_title = lines[0].replace("Title:", "").strip()
translated_content = "\n".join(lines[1:]).strip() if len(lines) > 1 else ""
else:
translated_title = lines[0].strip()
translated_content = "\n".join(lines[1:]).strip() if len(lines) > 1 else full
# Si el título volvió igual al original (sin traducir), reintentamos solo el título
if translated_title.strip().upper() == title.strip().upper():
title_payload = json.dumps({
"model": JAN_MODEL,
"messages": [
{"role": "user", "content": f"Translate this title from Spanish to {lang_name}. Return ONLY the translated title in ALL CAPS, nothing else: {title}"}
],
"temperature": 0.2,
"max_tokens": 50,
}).encode("utf-8")
title_req = urllib.request.Request(JAN_URL, data=title_payload,
headers={"Content-Type": "application/json", "Authorization": "Bearer dummy"}, method="POST")
with urllib.request.urlopen(title_req, timeout=30) as tr:
title_result = json.loads(tr.read())
translated_title = title_result["choices"][0]["message"]["content"].strip().strip('"')
# Si el contenido traducido está vacío o es muy corto, reintentamos con prompt más directo
if len(translated_content.strip()) < 50 and len(content.strip()) > 50:
retry_payload = json.dumps({
"model": JAN_MODEL,
"messages": [
{"role": "system", "content": f"You are a professional translator. Translate the following text from Spanish to {lang_name}. Preserve all HTML tags. Return ONLY the translated text, no preamble."},
{"role": "user", "content": content}
],
"temperature": 0.3,
"max_tokens": 4096,
}).encode("utf-8")
retry_req = urllib.request.Request(JAN_URL, data=retry_payload,
headers={"Content-Type": "application/json", "Authorization": "Bearer dummy"}, method="POST")
with urllib.request.urlopen(retry_req, timeout=300) as rr:
retry_result = json.loads(rr.read())
translated_content = retry_result["choices"][0]["message"]["content"].strip()
return translated_title, translated_content
except urllib.error.URLError as e:
raise RuntimeError(f"Error llamando a Jan: {e}")
# ── Base de datos WordPress ───────────────────────────────────────────────────
def get_db():
return pymysql.connect(
host=DB_HOST, port=DB_PORT,
user=DB_USER, password=DB_PASS,
database=DB_NAME, charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor
)
def get_article(db, wp_id):
with db.cursor() as c:
c.execute("""
SELECT p.ID, p.post_title, p.post_content, p.post_author,
p.post_date, p.post_name,
GROUP_CONCAT(t.term_id) as term_ids
FROM wp_posts p
LEFT JOIN wp_term_relationships tr ON p.ID=tr.object_id
LEFT JOIN wp_term_taxonomy tt ON tr.term_taxonomy_id=tt.term_taxonomy_id
AND tt.taxonomy='category'
LEFT JOIN wp_terms t ON tt.term_id=t.term_id
WHERE p.ID=%s
GROUP BY p.ID
""", (wp_id,))
return c.fetchone()
def get_existing_translation(db, original_id, lang_code):
"""Devuelve el WP ID de la traducción si ya existe."""
with db.cursor() as c:
# Polylang guarda las traducciones en wp_term_relationships con taxonomy 'post_translations'
c.execute("""
SELECT tr2.object_id as translated_id
FROM wp_term_relationships tr1
JOIN wp_term_relationships tr2 ON tr1.term_taxonomy_id=tr2.term_taxonomy_id
JOIN wp_term_taxonomy tt1 ON tr1.term_taxonomy_id=tt1.term_taxonomy_id
WHERE tt1.taxonomy='post_translations'
AND tr1.object_id=%s AND tr2.object_id!=%s
""", (original_id, original_id))
candidates = [r['translated_id'] for r in c.fetchall()]
for cid in candidates:
c.execute("""
SELECT t.slug FROM wp_terms t
JOIN wp_term_taxonomy tt ON t.term_id=tt.term_id
JOIN wp_term_relationships tr ON tt.term_taxonomy_id=tr.term_taxonomy_id
WHERE tt.taxonomy='language' AND tr.object_id=%s
""", (cid,))
row = c.fetchone()
if row and row['slug'] == lang_code:
return cid
return None
# ── Crear post vía WP-CLI en Docker ──────────────────────────────────────────
def create_wp_post(article, translated_title, translated_content, lang_code, original_id, dry_run=False):
content_with_footer = translated_content + AI_FOOTER
php = f"""
global $wpdb;
$post_id = wp_insert_post([
'post_title' => {json.dumps(translated_title, ensure_ascii=False)},
'post_content' => {json.dumps(content_with_footer, ensure_ascii=False)},
'post_author' => {article['post_author']},
'post_status' => 'publish',
'post_type' => 'post',
'post_date' => {json.dumps(article['post_date'].strftime('%Y-%m-%d %H:%M:%S') if hasattr(article['post_date'], 'strftime') else str(article['post_date']), ensure_ascii=False)},
]);
if (is_wp_error($post_id)) {{ echo 'ERROR: ' . $post_id->get_error_message(); exit; }}
// Asignar idioma Polylang
if (function_exists('pll_set_post_language')) {{
pll_set_post_language($post_id, {json.dumps(lang_code)});
}}
// Vincular traducciones
if (function_exists('pll_save_post_translations')) {{
$translations = pll_get_post_translations({original_id});
$translations[{json.dumps(lang_code)}] = $post_id;
$translations['es'] = {original_id};
pll_save_post_translations($translations);
}}
// Copiar categorías del original (excepto las de idioma)
$cats = wp_get_post_categories({original_id}, ['fields' => 'ids']);
if (!empty($cats)) wp_set_post_categories($post_id, $cats);
echo 'CREATED:' . $post_id;
"""
if dry_run:
print(f" [DRY] Crearía post '{translated_title[:60]}' en {lang_code}")
return 0
cmd = ["docker", "exec", WP_CONTAINER, "wp", "eval", php, "--allow-root"]
result = subprocess.run(cmd, capture_output=True, text=True)
output = result.stdout.strip()
if "CREATED:" in output:
new_id = int(output.split("CREATED:")[1].strip())
return new_id
else:
raise RuntimeError(f"Error creando post: {result.stdout} {result.stderr}")
# ── Main ──────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--check-api", action="store_true", help="Verificar conexión a Jan")
parser.add_argument("--dry-run", action="store_true", help="Simular sin crear posts")
parser.add_argument("--lang", help="Solo traducir a este idioma (en/fr/it/pt)")
parser.add_argument("--id", type=int, help="Solo traducir este WP ID")
args = parser.parse_args()
global JAN_MODEL
JAN_MODEL = get_jan_model()
print(f"Jan API OK — modelo: {JAN_MODEL}")
if args.check_api:
print("Probando traducción...")
t, c = translate("Prueba", "<p>Hola mundo</p>", "en", "English")
print(f" Título: {t}")
print(f" Contenido: {c}")
return
langs = {args.lang: TARGET_LANGS[args.lang]} if args.lang else TARGET_LANGS
ids = [args.id] if args.id else SPANISH_IDS
db = get_db()
total = len(ids) * len(langs)
done = 0
skipped = 0
errors = 0
print(f"\nArtículos: {len(ids)} | Idiomas: {list(langs.keys())} | Total: {total} traducciones\n")
for wp_id in ids:
article = get_article(db, wp_id)
if not article:
print(f" ⚠ ID {wp_id} no encontrado, saltando")
continue
title = article['post_title']
content = article['post_content']
print(f"\n[{wp_id}] {title[:70]}")
if len(content) > MAX_CONTENT_LEN:
print(f" ⚠ Contenido demasiado largo ({len(content)} chars), saltando")
skipped += 1
continue
for lang_code, lang_name in langs.items():
existing = get_existing_translation(db, wp_id, lang_code)
if existing:
print(f"{lang_code.upper()}: ya existe (ID {existing}), saltando")
skipped += 1
continue
try:
if args.dry_run:
print(f"{lang_code.upper()}: [DRY] se traduciría y crearía post")
done += 1
continue
print(f"{lang_code.upper()}: traduciendo... ", end="", flush=True)
t0 = time.time()
trans_title, trans_content = translate(title, content, lang_code, lang_name)
elapsed = time.time() - t0
print(f"{elapsed:.0f}s")
print(f" Título: {trans_title[:60]}")
new_id = create_wp_post(article, trans_title, trans_content, lang_code, wp_id, False)
print(f" Post creado: ID {new_id}")
done += 1
except Exception as e:
print(f" ERROR: {e}")
errors += 1
time.sleep(2)
db.close()
print(f"\n{'='*50}")
print(f"Completado: {done} creados, {skipped} saltados, {errors} errores")
if errors:
print("Puedes volver a ejecutar — los ya creados se saltarán automáticamente.")
if __name__ == "__main__":
main()