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:
@@ -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";
|
||||
@@ -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
@@ -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()
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
Executable
+69
@@ -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)."
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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(); ?>
|
||||
@@ -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);
|
||||
@@ -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: & → &)
|
||||
$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";
|
||||
@@ -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, '-');
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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";
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Executable
+88
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user