Añadir mu-plugins y scripts de feadulta
This commit is contained in:
@@ -1,3 +1,41 @@
|
|||||||
# feadulta
|
# feadulta — WordPress
|
||||||
|
|
||||||
WordPress feadulta.com — plugins, scripts y documentación
|
Código del sitio WordPress de feadulta.com. Plugins personalizados (mu-plugins) y scripts de migración y mantenimiento.
|
||||||
|
|
||||||
|
## Estructura
|
||||||
|
|
||||||
|
```
|
||||||
|
mu-plugins/ — plugins WordPress cargados automáticamente (sin activar)
|
||||||
|
scripts/ — scripts Python/PHP de migración, traducción, TTS y mantenimiento
|
||||||
|
```
|
||||||
|
|
||||||
|
## mu-plugins principales
|
||||||
|
|
||||||
|
| Plugin | Función |
|
||||||
|
|---|---|
|
||||||
|
| `fea-carta-portada.php` | Parser de la carta semanal → portada |
|
||||||
|
| `fea-homepage.php` | Tarjetas de portada |
|
||||||
|
| `fea-share.php` | Sección "Comparte FeAdulta" (Facebook, Instagram, Imprimir) |
|
||||||
|
| `fea-ui.php` | Estilos globales, menú, fondo cálido en artículos |
|
||||||
|
| `fea-beta-feedback.php` | Barra de feedback beta + endpoint `/fea/v1/lang/{id}` |
|
||||||
|
| `fea-search.php` | Buscador móvil nativo |
|
||||||
|
| `fea-pensamientos.php` | Widget de pensamientos |
|
||||||
|
| `carta-semana-plugin.php` | Redirects carta vigente/semana pasada |
|
||||||
|
|
||||||
|
## Scripts principales
|
||||||
|
|
||||||
|
| Script | Función |
|
||||||
|
|---|---|
|
||||||
|
| `import_new_k2_items.py` | Importar artículos nuevos desde Joomla/K2 |
|
||||||
|
| `import_new_cartas.py` | Importar cartas semanales nuevas |
|
||||||
|
| `translate_haiku.py` | Traducción automática vía Claude Haiku |
|
||||||
|
| `tts_produce.py` | Generación de audio TTS (MiniMax) |
|
||||||
|
| `sync_translations_to_prod.py` | Sincronizar traducciones local → producción |
|
||||||
|
| `detect_untranslated.php` | Detectar posts sin traducir |
|
||||||
|
|
||||||
|
## Servidor local de desarrollo
|
||||||
|
|
||||||
|
WordPress corre en Docker local, accesible vía Tailscale:
|
||||||
|
`https://farmer.taild3aaf6.ts.net/fea/`
|
||||||
|
|
||||||
|
Admin: `eqpyk` / `NuevaFeAdulta2024!`
|
||||||
|
|||||||
Executable
+75
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fe Adulta — Carta de la Semana
|
||||||
|
* Description: Redirige las URLs de carta al archivo de categoría correspondiente.
|
||||||
|
* Version: 1.8
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Las categorías de carta actual/anterior siempre llevan al post traducido que
|
||||||
|
// corresponde a la categoría española canónica. No dependemos del count ni de
|
||||||
|
// las relaciones traducidas, que pueden quedar desfasadas durante una importación.
|
||||||
|
add_action("template_redirect", function() {
|
||||||
|
if (!is_category()) return;
|
||||||
|
$cat = get_queried_object();
|
||||||
|
if (!$cat || empty($cat->term_id)) return;
|
||||||
|
|
||||||
|
$source_cat_id = (int) $cat->term_id;
|
||||||
|
if (function_exists('pll_get_term')) {
|
||||||
|
$spanish_cat_id = (int) pll_get_term($source_cat_id, 'es');
|
||||||
|
if ($spanish_cat_id) $source_cat_id = $spanish_cat_id;
|
||||||
|
}
|
||||||
|
if (!in_array($source_cat_id, [6, 22], true)) return;
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$source_post_id = (int) $wpdb->get_var($wpdb->prepare(
|
||||||
|
"SELECT p.ID
|
||||||
|
FROM {$wpdb->posts} p
|
||||||
|
INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
|
||||||
|
INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
|
||||||
|
WHERE tt.taxonomy = 'category' AND tt.term_id = %d
|
||||||
|
AND p.post_type = 'post' AND p.post_status = 'publish'
|
||||||
|
ORDER BY p.post_date DESC, p.ID DESC
|
||||||
|
LIMIT 1",
|
||||||
|
$source_cat_id
|
||||||
|
));
|
||||||
|
if (!$source_post_id) return;
|
||||||
|
|
||||||
|
$post_id = $source_post_id;
|
||||||
|
if (function_exists('pll_current_language') && function_exists('pll_get_post')) {
|
||||||
|
$lang = pll_current_language();
|
||||||
|
$translated = $lang ? (int) pll_get_post($source_post_id, $lang) : 0;
|
||||||
|
if ($translated) $post_id = $translated;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = get_permalink($post_id);
|
||||||
|
if (!$url) return;
|
||||||
|
wp_safe_redirect($url, 302);
|
||||||
|
exit;
|
||||||
|
}, 9);
|
||||||
|
|
||||||
|
// Mostrar 50 artículos por página en los archivos de cartas
|
||||||
|
add_action("pre_get_posts", function($query) {
|
||||||
|
if (!$query->is_main_query() || is_admin()) return;
|
||||||
|
if ($query->is_category([
|
||||||
|
"cartasemana", "carta-semana-pasada", "cartas-de-otras-semanas",
|
||||||
|
"letter-of-the-week", "lettre-de-la-semaine", "lettera-della-settimana", "carta-da-semana",
|
||||||
|
"carta-semana-pasada-en", "carta-semana-pasada-fr",
|
||||||
|
"carta-semana-pasada-it", "carta-semana-pasada-pt",
|
||||||
|
"letters-from-other-weeks", "lettres-des-autres-semaines",
|
||||||
|
"lettere-delle-altre-settimane", "cartas-de-outras-semanas",
|
||||||
|
])) {
|
||||||
|
$query->set("posts_per_page", 50);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fe Adulta Custom CSS
|
||||||
|
* Description: Carga el CSS personalizado para el faldón
|
||||||
|
*/
|
||||||
|
|
||||||
|
add_action("wp_enqueue_scripts", function() {
|
||||||
|
wp_enqueue_style(
|
||||||
|
"fa-custom-css",
|
||||||
|
content_url("/uploads/astra-custom.css"),
|
||||||
|
array(),
|
||||||
|
time()
|
||||||
|
);
|
||||||
|
}, 999);
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fea Analytics — GA4 (#93)
|
||||||
|
* Description: Inserta Google Analytics 4 (gtag.js) en el <head> del front-end.
|
||||||
|
* Measurement ID portable: el cutover wp-nuevo.feadulta.com -> www.feadulta.com
|
||||||
|
* no requiere tocar nada (el ID no cambia; la Stream URL es informativa).
|
||||||
|
* NO se filtra tráfico interno (decisión Rafa: las IPs propias cuentan).
|
||||||
|
* Nota RGPD: GA4 setea cookies; si se añade banner de consentimiento habrá
|
||||||
|
* que condicionar este tag (Consent Mode) — pendiente, no implementado aquí.
|
||||||
|
*/
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
define('FEA_GA4_MEASUREMENT_ID', 'G-6RT9ZRS4LW');
|
||||||
|
|
||||||
|
add_action('wp_head', function () {
|
||||||
|
// wp_head no corre en wp-admin; guard extra por seguridad.
|
||||||
|
if (is_admin()) return;
|
||||||
|
$gid = FEA_GA4_MEASUREMENT_ID;
|
||||||
|
?>
|
||||||
|
<!-- Google tag (gtag.js) - GA4 #93 -->
|
||||||
|
<script>
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
// Consent Mode v2: por defecto DENEGADO hasta que el usuario acepte en el
|
||||||
|
// banner (fea-cookie-consent.php hace el consent 'update'). HTML idéntico
|
||||||
|
// para todos (cacheable por Cloudflare); el estado por usuario lo aplica el
|
||||||
|
// banner en JS leyendo la cookie.
|
||||||
|
gtag('consent', 'default', {
|
||||||
|
'ad_storage': 'denied',
|
||||||
|
'ad_user_data': 'denied',
|
||||||
|
'ad_personalization': 'denied',
|
||||||
|
'analytics_storage': 'denied',
|
||||||
|
'wait_for_update': 500
|
||||||
|
});
|
||||||
|
gtag('js', new Date());
|
||||||
|
gtag('config', '<?php echo esc_js($gid); ?>');
|
||||||
|
</script>
|
||||||
|
<script async src="https://www.googletagmanager.com/gtag/js?id=<?php echo esc_attr($gid); ?>"></script>
|
||||||
|
<?php
|
||||||
|
}, 1);
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fea Audio Player (#76)
|
||||||
|
* Description: Reproductor TTS compacto en la fila del autor (a la derecha) cuando
|
||||||
|
* existe el meta fea_audio_url (voz Nico, MiniMax HD). Se inyecta como
|
||||||
|
* último hijo del grupo flex del autor (template FSE) vía render_block.
|
||||||
|
*/
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
/** Devuelve el HTML del reproductor para el post actual, o '' si no hay audio. */
|
||||||
|
function fea_audio_player_html(): string {
|
||||||
|
$url = get_post_meta(get_the_ID(), 'fea_audio_url', true);
|
||||||
|
if (!$url) return '';
|
||||||
|
return '<div class="fea-audio">'
|
||||||
|
. '<span class="fea-audio-label">'
|
||||||
|
. '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">'
|
||||||
|
. '<path fill="currentColor" d="M3 10v4h4l5 5V5L7 10H3zm13.5 2a4.5 4.5 0 0 0-2.5-4.03v8.06A4.5 4.5 0 0 0 16.5 12zM14 3.23v2.06a7 7 0 0 1 0 13.42v2.06a9 9 0 0 0 0-17.54z"/>'
|
||||||
|
. '</svg> Escucha</span>'
|
||||||
|
. '<audio controls preload="none" src="' . esc_url($url) . '"></audio>'
|
||||||
|
. '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inyecta el player como último hijo de la fila flex del autor (el grupo que
|
||||||
|
// contiene el core/avatar en la cabecera FSE del single).
|
||||||
|
add_filter('render_block', function ($html, $block) {
|
||||||
|
if (!is_singular('post')) return $html;
|
||||||
|
if (($block['blockName'] ?? '') !== 'core/group') return $html;
|
||||||
|
$has_avatar = false;
|
||||||
|
foreach ($block['innerBlocks'] ?? [] as $ib) {
|
||||||
|
if (($ib['blockName'] ?? '') === 'core/avatar') { $has_avatar = true; break; }
|
||||||
|
}
|
||||||
|
if (!$has_avatar) return $html;
|
||||||
|
$player = fea_audio_player_html();
|
||||||
|
if (!$player) return $html;
|
||||||
|
$pos = strrpos($html, '</div>');
|
||||||
|
return $pos === false ? $html . $player : substr($html, 0, $pos) . $player . substr($html, $pos);
|
||||||
|
}, 10, 2);
|
||||||
|
|
||||||
|
add_action('wp_head', function () {
|
||||||
|
if (!is_singular('post')) return;
|
||||||
|
if (!get_post_meta(get_queried_object_id(), 'fea_audio_url', true)) return;
|
||||||
|
?>
|
||||||
|
<style>
|
||||||
|
/* El player se inyecta en el grupo flex del autor. En el single FSE ese
|
||||||
|
contenedor es el wp-block-group (no .fea-byline, que solo existe en Astra),
|
||||||
|
así que el wrap debe apuntar también a él o en móvil vertical el <audio>
|
||||||
|
no encuentra ancho y no muestra controles (#142). */
|
||||||
|
.fea-byline,
|
||||||
|
.wp-block-group:has(> .wp-block-avatar):has(.wp-block-post-author-name){flex-wrap:wrap}
|
||||||
|
.fea-audio{display:flex;align-items:center;gap:.5rem;margin-left:auto;
|
||||||
|
padding:.3rem .55rem;background:#faf6f7;border:1px solid #e7d6da;
|
||||||
|
border-left:3px solid #8b1a2e;border-radius:8px}
|
||||||
|
.fea-audio-label{display:inline-flex;align-items:center;gap:.35rem;font-size:.78rem;
|
||||||
|
font-weight:600;color:#8b1a2e;white-space:nowrap;line-height:1}
|
||||||
|
.fea-audio audio{height:32px;width:230px;max-width:44vw}
|
||||||
|
@media(max-width:600px){
|
||||||
|
.fea-audio{margin-left:0;width:100%;margin-top:.5rem}
|
||||||
|
.fea-audio audio{flex:1 1 auto;width:auto;max-width:none}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<?php
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fea Avatar Cache-bust (#81)
|
||||||
|
* Description: Añade ?v=<mtime> a las URLs de avatar servidas desde
|
||||||
|
* uploads/avatares/autores/autor-<uid>.png. Como al actualizar la foto se
|
||||||
|
* reescribe el MISMO fichero, sin esto el navegador/Cloudflare siguen sirviendo
|
||||||
|
* la versión cacheada. Corre tras el filtro de fea-homepage (prioridad 20).
|
||||||
|
*/
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
add_filter('get_avatar_url', function ($url, $id_or_email, $args) {
|
||||||
|
if (!is_string($url) || strpos($url, '/avatares/autores/autor-') === false) return $url;
|
||||||
|
$rel = preg_replace('~\?.*$~', '', substr($url, strpos($url, '/avatares/')));
|
||||||
|
$path = wp_get_upload_dir()['basedir'] . $rel;
|
||||||
|
if (file_exists($path)) $url = add_query_arg('v', filemtime($path), $url);
|
||||||
|
return $url;
|
||||||
|
}, 20, 3);
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fe Adulta — Feedback Beta
|
||||||
|
* Description: Barra sutil de aviso "Beta" en todo el sitio + mini formulario (👍/👎 +
|
||||||
|
* comentario opcional) que se abre a demanda, para que el público ayude a
|
||||||
|
* encontrar errores. Guarda cada voto como "Beta Feedback" (CPT propio),
|
||||||
|
* legible en wp-admin en una sola lista. No usa el sistema de comentarios.
|
||||||
|
* Version: 1.1
|
||||||
|
*
|
||||||
|
* Ver issue rafa/feadulta#78.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
const FEA_FB_CPT = 'fea_feedback';
|
||||||
|
const FEA_FB_RATE_MAX = 12; // máximo de envíos por IP por hora
|
||||||
|
const FEA_FB_COMMENT_MAX = 2000;
|
||||||
|
|
||||||
|
/* ── 1) CPT donde se guardan los votos (solo backend) ─────────────────────── */
|
||||||
|
add_action('init', function () {
|
||||||
|
register_post_type(FEA_FB_CPT, [
|
||||||
|
'labels' => [
|
||||||
|
'name' => 'Beta Feedback',
|
||||||
|
'singular_name' => 'Feedback',
|
||||||
|
'menu_name' => 'Beta Feedback',
|
||||||
|
],
|
||||||
|
'public' => false,
|
||||||
|
'show_ui' => true,
|
||||||
|
'show_in_menu' => true,
|
||||||
|
'menu_icon' => 'dashicons-feedback',
|
||||||
|
'menu_position' => 26,
|
||||||
|
'capability_type' => 'post',
|
||||||
|
'capabilities' => ['create_posts' => 'do_not_allow'], // solo se crean por API
|
||||||
|
'map_meta_cap' => true,
|
||||||
|
'supports' => ['title', 'editor'],
|
||||||
|
'exclude_from_search' => true,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── 2a) Endpoint de consulta de idioma Polylang (para publicabot / integración externa) ── */
|
||||||
|
add_action('rest_api_init', function () {
|
||||||
|
register_rest_route('fea/v1', '/lang/(?P<id>\d+)', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
'callback' => function (WP_REST_Request $req) {
|
||||||
|
$id = (int) $req->get_param('id');
|
||||||
|
$post = get_post($id);
|
||||||
|
if (!$post || $post->post_type !== 'post') {
|
||||||
|
return new WP_REST_Response(['error' => 'post not found'], 404);
|
||||||
|
}
|
||||||
|
$langs = wp_get_object_terms($id, 'language', ['fields' => 'slugs']);
|
||||||
|
$lang = (!is_wp_error($langs) && !empty($langs)) ? $langs[0] : null;
|
||||||
|
return new WP_REST_Response(['id' => $id, 'lang' => $lang], 200);
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── 2) Endpoint REST para recibir el voto ────────────────────────────────── */
|
||||||
|
add_action('rest_api_init', function () {
|
||||||
|
register_rest_route('fea/v1', '/feedback', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'permission_callback' => '__return_true', // público (Beta); protegido con honeypot + rate-limit
|
||||||
|
'callback' => 'fea_feedback_submit',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function fea_feedback_submit(WP_REST_Request $req) {
|
||||||
|
// Honeypot: si el campo oculto viene relleno, es un bot.
|
||||||
|
if (trim((string) $req->get_param('website')) !== '') {
|
||||||
|
return new WP_REST_Response(['ok' => true], 200); // fingir éxito
|
||||||
|
}
|
||||||
|
|
||||||
|
$vote = $req->get_param('vote') === 'up' ? 'up' : ($req->get_param('vote') === 'down' ? 'down' : '');
|
||||||
|
if ($vote === '') {
|
||||||
|
return new WP_REST_Response(['ok' => false, 'error' => 'voto inválido'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate-limit por IP.
|
||||||
|
$ip = fea_feedback_client_ip();
|
||||||
|
$key = 'fea_fb_rl_' . md5($ip);
|
||||||
|
$n = (int) get_transient($key);
|
||||||
|
if ($n >= FEA_FB_RATE_MAX) {
|
||||||
|
return new WP_REST_Response(['ok' => false, 'error' => 'demasiados envíos'], 429);
|
||||||
|
}
|
||||||
|
set_transient($key, $n + 1, HOUR_IN_SECONDS);
|
||||||
|
|
||||||
|
$comment = trim((string) $req->get_param('comment'));
|
||||||
|
$comment = mb_substr(wp_strip_all_tags($comment), 0, FEA_FB_COMMENT_MAX);
|
||||||
|
$url = esc_url_raw((string) $req->get_param('url'));
|
||||||
|
$src_id = (int) $req->get_param('post_id');
|
||||||
|
$lang = preg_replace('/[^a-z]/', '', (string) $req->get_param('lang'));
|
||||||
|
$title = (string) $req->get_param('title');
|
||||||
|
|
||||||
|
$emoji = $vote === 'up' ? '👍' : '👎';
|
||||||
|
$post_id = wp_insert_post([
|
||||||
|
'post_type' => FEA_FB_CPT,
|
||||||
|
'post_status' => 'private',
|
||||||
|
'post_title' => sprintf('%s %s', $emoji, $title ?: $url),
|
||||||
|
'post_content' => $comment,
|
||||||
|
], true);
|
||||||
|
|
||||||
|
if (is_wp_error($post_id)) {
|
||||||
|
return new WP_REST_Response(['ok' => false, 'error' => 'no se pudo guardar'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
update_post_meta($post_id, '_fea_fb_vote', $vote);
|
||||||
|
update_post_meta($post_id, '_fea_fb_url', $url);
|
||||||
|
update_post_meta($post_id, '_fea_fb_source_id', $src_id);
|
||||||
|
update_post_meta($post_id, '_fea_fb_lang', $lang);
|
||||||
|
update_post_meta($post_id, '_fea_fb_ua', mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255));
|
||||||
|
update_post_meta($post_id, '_fea_fb_ip', md5($ip)); // hash, no IP en claro
|
||||||
|
|
||||||
|
return new WP_REST_Response(['ok' => true], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_feedback_client_ip(): string {
|
||||||
|
foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR'] as $k) {
|
||||||
|
if (!empty($_SERVER[$k])) return trim(explode(',', $_SERVER[$k])[0]);
|
||||||
|
}
|
||||||
|
return '0.0.0.0';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 3) Columnas en el listado del wp-admin ───────────────────────────────── */
|
||||||
|
add_filter('manage_' . FEA_FB_CPT . '_posts_columns', function ($cols) {
|
||||||
|
return [
|
||||||
|
'cb' => $cols['cb'] ?? '',
|
||||||
|
'fb_vote' => 'Voto',
|
||||||
|
'fb_url' => 'Página',
|
||||||
|
'fb_lang' => 'Idioma',
|
||||||
|
'fb_comment'=> 'Comentario',
|
||||||
|
'date' => 'Fecha',
|
||||||
|
];
|
||||||
|
});
|
||||||
|
add_action('manage_' . FEA_FB_CPT . '_posts_custom_column', function ($col, $post_id) {
|
||||||
|
if ($col === 'fb_vote') {
|
||||||
|
echo get_post_meta($post_id, '_fea_fb_vote', true) === 'up' ? '👍' : '👎';
|
||||||
|
} elseif ($col === 'fb_url') {
|
||||||
|
$u = get_post_meta($post_id, '_fea_fb_url', true);
|
||||||
|
if ($u) echo '<a href="' . esc_url($u) . '" target="_blank" rel="noopener">' . esc_html(wp_parse_url($u, PHP_URL_PATH) ?: $u) . '</a>';
|
||||||
|
} elseif ($col === 'fb_lang') {
|
||||||
|
echo esc_html(strtoupper(get_post_meta($post_id, '_fea_fb_lang', true) ?: '—'));
|
||||||
|
} elseif ($col === 'fb_comment') {
|
||||||
|
echo esc_html(wp_trim_words(get_post_field('post_content', $post_id), 24));
|
||||||
|
}
|
||||||
|
}, 10, 2);
|
||||||
|
|
||||||
|
/* ── 4) Barra Beta sutil (persistente) + tarjeta de feedback (a demanda) ──── */
|
||||||
|
/** Etiquetas del widget Beta por idioma (Polylang). */
|
||||||
|
function fea_beta_labels(): array {
|
||||||
|
$all = [
|
||||||
|
'es' => ['region'=>'Aviso Beta','intro'=>'Estamos en','help'=>'¿Nos ayudas a mejorar FeAdulta?',
|
||||||
|
'opinion'=>'Dar mi opinión','collab'=>'Colaborar','dismiss'=>'Cerrar aviso','fbregion'=>'Feedback de la página',
|
||||||
|
'close'=>'Cerrar','q'=>'¿Se ve bien esta página?','up'=>'Sí, se ve bien','down'=>'No, hay algo mal',
|
||||||
|
'ph'=>'¿Algo falla o se ve mal? Cuéntanoslo (opcional)','send'=>'Enviar','thanks'=>'¡Gracias por ayudar! 🙏'],
|
||||||
|
'en' => ['region'=>'Beta notice','intro'=>'We are in','help'=>'Will you help us improve FeAdulta?',
|
||||||
|
'opinion'=>'Give feedback','collab'=>'Collaborate','dismiss'=>'Close notice','fbregion'=>'Page feedback',
|
||||||
|
'close'=>'Close','q'=>'Does this page look right?','up'=>'Yes, looks good','down'=>'No, something is wrong',
|
||||||
|
'ph'=>'Something broken or off? Tell us (optional)','send'=>'Send','thanks'=>'Thanks for helping! 🙏'],
|
||||||
|
'fr' => ['region'=>'Avis Bêta','intro'=>'Nous sommes en','help'=>'Voulez-vous nous aider à améliorer FeAdulta ?',
|
||||||
|
'opinion'=>'Donner mon avis','collab'=>'Collaborer','dismiss'=>'Fermer l’avis','fbregion'=>'Retour sur la page',
|
||||||
|
'close'=>'Fermer','q'=>'Cette page s’affiche-t-elle bien ?','up'=>'Oui, c’est bien','down'=>'Non, il y a un problème',
|
||||||
|
'ph'=>'Un souci ou un affichage incorrect ? Dites-le-nous (facultatif)','send'=>'Envoyer','thanks'=>'Merci de votre aide ! 🙏'],
|
||||||
|
'it' => ['region'=>'Avviso Beta','intro'=>'Siamo in','help'=>'Ci aiuti a migliorare FeAdulta?',
|
||||||
|
'opinion'=>'Dai la tua opinione','collab'=>'Collabora','dismiss'=>'Chiudi avviso','fbregion'=>'Feedback della pagina',
|
||||||
|
'close'=>'Chiudi','q'=>'Questa pagina si vede bene?','up'=>'Sì, si vede bene','down'=>'No, c’è qualcosa che non va',
|
||||||
|
'ph'=>'Qualcosa non va o si vede male? Faccelo sapere (facoltativo)','send'=>'Invia','thanks'=>'Grazie per l’aiuto! 🙏'],
|
||||||
|
'pt' => ['region'=>'Aviso Beta','intro'=>'Estamos em','help'=>'Ajuda-nos a melhorar a FeAdulta?',
|
||||||
|
'opinion'=>'Dar a minha opinião','collab'=>'Colaborar','dismiss'=>'Fechar aviso','fbregion'=>'Feedback da página',
|
||||||
|
'close'=>'Fechar','q'=>'Esta página vê-se bem?','up'=>'Sim, vê-se bem','down'=>'Não, há algo errado',
|
||||||
|
'ph'=>'Algo falha ou vê-se mal? Conta-nos (opcional)','send'=>'Enviar','thanks'=>'Obrigado por ajudar! 🙏'],
|
||||||
|
];
|
||||||
|
$lang = function_exists('pll_current_language') ? (string) pll_current_language() : 'es';
|
||||||
|
return $all[$lang] ?? $all['es'];
|
||||||
|
}
|
||||||
|
|
||||||
|
add_action('wp_footer', function () {
|
||||||
|
if (is_admin()) return;
|
||||||
|
$rest = esc_url_raw(rest_url('fea/v1/feedback'));
|
||||||
|
$t = fea_beta_labels();
|
||||||
|
?>
|
||||||
|
<style>
|
||||||
|
/* Barra sutil de aviso Beta, abajo, full-width */
|
||||||
|
#fea-beta-bar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 99997;
|
||||||
|
background: #faf6f2; border-top: 1px solid #e6ddd5; color: #4a3b34;
|
||||||
|
font-family: inherit; font-size: .86rem; line-height: 1.3;
|
||||||
|
padding: 8px 44px 8px 16px; text-align: center; }
|
||||||
|
#fea-beta-bar strong { color: #8b1a2e; }
|
||||||
|
#fea-beta-bar .fea-beta-open { margin-left: 10px; cursor: pointer; border: 1px solid #8b1a2e;
|
||||||
|
background: #8b1a2e; color: #fff; border-radius: 6px; padding: 4px 12px; font-size: .82rem; font-weight: 600; }
|
||||||
|
#fea-beta-bar .fea-beta-open:hover { background: #761526; }
|
||||||
|
#fea-beta-bar .fea-beta-collab { margin-left: 8px; cursor: pointer; display: inline-block;
|
||||||
|
border: 1px solid #1b7a34; background: #1b7a34; color: #fff; border-radius: 6px;
|
||||||
|
padding: 4px 12px; font-size: .82rem; font-weight: 600; text-decoration: none; }
|
||||||
|
#fea-beta-bar .fea-beta-collab:hover { background: #15642a; }
|
||||||
|
#fea-beta-bar .fea-beta-dismiss { position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
|
||||||
|
border: 0; background: none; font-size: 1.15rem; cursor: pointer; color: #8a7a72; padding: 2px 6px; line-height: 1; }
|
||||||
|
#fea-beta-bar.hidden { display: none; }
|
||||||
|
|
||||||
|
/* Tarjeta de feedback: oculta hasta que el usuario la abre desde la barra */
|
||||||
|
#fea-fb { position: fixed; right: 16px; bottom: 56px; z-index: 99998; font-family: inherit; max-width: 300px; }
|
||||||
|
#fea-fb[hidden] { display: none; }
|
||||||
|
#fea-fb .fea-fb-card { background:#fff; border:1px solid #e2e2e2; border-radius:12px;
|
||||||
|
box-shadow:0 8px 28px rgba(0,0,0,.16); padding:12px 14px; font-size:.9rem; color:#222; position:relative; }
|
||||||
|
#fea-fb .fea-fb-q { margin:0 0 8px; line-height:1.3; padding-right:16px; }
|
||||||
|
#fea-fb .fea-fb-btns { display:flex; gap:8px; }
|
||||||
|
#fea-fb button.fea-fb-vote { cursor:pointer; border:1px solid #ccc; background:#fafafa; border-radius:8px;
|
||||||
|
padding:6px 12px; font-size:1.05rem; line-height:1; }
|
||||||
|
#fea-fb button.fea-fb-vote:hover { background:#f0f0f0; }
|
||||||
|
#fea-fb button.fea-fb-vote.sel { border-color:#8b1a2e; background:#f7e9ec; }
|
||||||
|
#fea-fb textarea { width:100%; margin:9px 0 8px; border:1px solid #ccc; border-radius:8px; padding:7px;
|
||||||
|
font:inherit; font-size:.85rem; resize:vertical; min-height:58px; box-sizing:border-box; }
|
||||||
|
#fea-fb .fea-fb-send { background:#8b1a2e; color:#fff; border:1px solid #8b1a2e; border-radius:8px;
|
||||||
|
padding:6px 12px; font-size:.85rem; width:100%; cursor:pointer; }
|
||||||
|
#fea-fb .fea-fb-hp { position:absolute; left:-9999px; }
|
||||||
|
#fea-fb .fea-fb-close { position:absolute; top:4px; right:8px; border:0; background:none; font-size:1rem; cursor:pointer; padding:2px 4px; line-height:1; }
|
||||||
|
@media (max-width:600px){ #fea-fb{ right:10px; left:10px; max-width:none; } #fea-beta-bar{ font-size:.8rem; } }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div id="fea-beta-bar" class="hidden" role="region" aria-label="<?php echo esc_attr($t['region']); ?>">
|
||||||
|
🌱 <?php echo esc_html($t['intro']); ?> <strong>Beta</strong>. <?php echo esc_html($t['help']); ?>
|
||||||
|
<button type="button" class="fea-beta-open"><?php echo esc_html($t['opinion']); ?></button>
|
||||||
|
<a class="fea-beta-collab" href="https://edicionesfeadulta.com/colabora/" target="_blank" rel="noopener"><?php echo esc_html($t['collab']); ?></a>
|
||||||
|
<button type="button" class="fea-beta-dismiss" aria-label="<?php echo esc_attr($t['dismiss']); ?>">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="fea-fb" hidden role="complementary" aria-label="<?php echo esc_attr($t['fbregion']); ?>">
|
||||||
|
<div class="fea-fb-card">
|
||||||
|
<button type="button" class="fea-fb-close" aria-label="<?php echo esc_attr($t['close']); ?>">×</button>
|
||||||
|
<p class="fea-fb-q"><?php echo esc_html($t['q']); ?></p>
|
||||||
|
<div class="fea-fb-btns">
|
||||||
|
<button type="button" class="fea-fb-vote" data-vote="up" aria-label="<?php echo esc_attr($t['up']); ?>">👍</button>
|
||||||
|
<button type="button" class="fea-fb-vote" data-vote="down" aria-label="<?php echo esc_attr($t['down']); ?>">👎</button>
|
||||||
|
</div>
|
||||||
|
<div class="fea-fb-more" hidden>
|
||||||
|
<input type="text" class="fea-fb-hp" tabindex="-1" autocomplete="off" aria-hidden="true" placeholder="No rellenar">
|
||||||
|
<textarea placeholder="<?php echo esc_attr($t['ph']); ?>"></textarea>
|
||||||
|
<button type="button" class="fea-fb-send"><?php echo esc_html($t['send']); ?></button>
|
||||||
|
</div>
|
||||||
|
<div class="fea-fb-thanks" hidden><?php echo esc_html($t['thanks']); ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var bar = document.getElementById('fea-beta-bar');
|
||||||
|
var box = document.getElementById('fea-fb');
|
||||||
|
if(!bar || !box) return;
|
||||||
|
var REST = <?php echo json_encode($rest); ?>;
|
||||||
|
var pid = <?php echo (int) (is_singular() ? get_queried_object_id() : 0); ?>;
|
||||||
|
var lang = <?php echo json_encode(function_exists('pll_current_language') ? (string) pll_current_language() : ''); ?>;
|
||||||
|
var chosen = null;
|
||||||
|
var moreEl = box.querySelector('.fea-fb-more');
|
||||||
|
var votes = box.querySelectorAll('.fea-fb-vote');
|
||||||
|
var thanks = box.querySelector('.fea-fb-thanks');
|
||||||
|
|
||||||
|
// Mostrar la barra salvo que el usuario la haya descartado antes.
|
||||||
|
try { if (!localStorage.getItem('fea_beta_bar_off')) bar.classList.remove('hidden'); }
|
||||||
|
catch(e){ bar.classList.remove('hidden'); }
|
||||||
|
|
||||||
|
function openCard(){ box.hidden = false; }
|
||||||
|
function closeCard(){ box.hidden = true; }
|
||||||
|
|
||||||
|
bar.querySelector('.fea-beta-open').addEventListener('click', openCard);
|
||||||
|
bar.querySelector('.fea-beta-dismiss').addEventListener('click', function(){
|
||||||
|
bar.classList.add('hidden');
|
||||||
|
try { localStorage.setItem('fea_beta_bar_off','1'); } catch(e){}
|
||||||
|
});
|
||||||
|
box.querySelector('.fea-fb-close').addEventListener('click', closeCard);
|
||||||
|
|
||||||
|
votes.forEach(function(b){ b.addEventListener('click', function(){
|
||||||
|
chosen = b.getAttribute('data-vote');
|
||||||
|
votes.forEach(function(x){ x.classList.toggle('sel', x===b); });
|
||||||
|
moreEl.hidden = false;
|
||||||
|
});});
|
||||||
|
|
||||||
|
box.querySelector('.fea-fb-send').addEventListener('click', function(){
|
||||||
|
if(!chosen) return;
|
||||||
|
var hp = box.querySelector('.fea-fb-hp').value;
|
||||||
|
var comment = box.querySelector('textarea').value;
|
||||||
|
fetch(REST, { method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ vote:chosen, comment:comment, url:location.href, post_id:pid,
|
||||||
|
lang:lang, title:document.title, website:hp }) }).catch(function(){});
|
||||||
|
box.querySelector('.fea-fb-btns').hidden = true;
|
||||||
|
box.querySelector('.fea-fb-q').hidden = true;
|
||||||
|
moreEl.hidden = true; thanks.hidden = false;
|
||||||
|
setTimeout(closeCard, 2200);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?php
|
||||||
|
}, 40);
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fe Adulta — Carta → Portada
|
||||||
|
* Description: Parser de la carta semanal. Extrae los links de cada sección de la
|
||||||
|
* carta y los expone para que los shortcodes de portada los rendericen.
|
||||||
|
* Version: 1.0
|
||||||
|
*
|
||||||
|
* Modelo: cada carta semanal es un post HTML con secciones encabezadas
|
||||||
|
* (Evangelio, Artículos, Eucaristía, Multimedia, EFFA). Los links DENTRO de
|
||||||
|
* cada sección son lo que la portada debe mostrar en su shortcode equivalente.
|
||||||
|
*
|
||||||
|
* Ver issue rafa/feadulta#38.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devuelve el post-carta vigente para un idioma (más reciente en cat 6).
|
||||||
|
* Si Polylang está activo y hay traducción del idioma, devuelve la traducida.
|
||||||
|
*/
|
||||||
|
function fea_get_current_carta_id($lang = null) {
|
||||||
|
static $cache = [];
|
||||||
|
$lang = $lang ?: (function_exists('pll_current_language') ? pll_current_language() : 'es');
|
||||||
|
if ($lang === false || $lang === null) $lang = 'es';
|
||||||
|
if (isset($cache[$lang])) return $cache[$lang];
|
||||||
|
|
||||||
|
$cat_es = 6;
|
||||||
|
$cat = function_exists('fea_cat') ? fea_cat($cat_es) : $cat_es;
|
||||||
|
|
||||||
|
$cartas = get_posts([
|
||||||
|
'posts_per_page' => 1,
|
||||||
|
'category__in' => [$cat],
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'orderby' => 'date',
|
||||||
|
'order' => 'DESC',
|
||||||
|
'suppress_filters' => false,
|
||||||
|
]);
|
||||||
|
if (!$cartas) return $cache[$lang] = 0;
|
||||||
|
|
||||||
|
$carta_id = (int) $cartas[0]->ID;
|
||||||
|
|
||||||
|
if ($lang !== 'es' && function_exists('pll_get_post')) {
|
||||||
|
$trans = pll_get_post($carta_id, $lang);
|
||||||
|
if ($trans) $carta_id = (int) $trans;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cache[$lang] = $carta_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsea el HTML de la carta y devuelve los post_ids agrupados por sección.
|
||||||
|
*/
|
||||||
|
function fea_parse_carta_sections($carta_id) {
|
||||||
|
static $mem = [];
|
||||||
|
$carta_id = (int) $carta_id;
|
||||||
|
if (!$carta_id) return [];
|
||||||
|
if (isset($mem[$carta_id])) return $mem[$carta_id];
|
||||||
|
|
||||||
|
$tk = 'fea_carta_sections_' . $carta_id;
|
||||||
|
$cached = get_transient($tk);
|
||||||
|
if (is_array($cached)) return $mem[$carta_id] = $cached;
|
||||||
|
|
||||||
|
$post = get_post($carta_id);
|
||||||
|
if (!$post) return $mem[$carta_id] = [];
|
||||||
|
|
||||||
|
$sections = fea_extract_sections_from_html($post->post_content);
|
||||||
|
|
||||||
|
set_transient($tk, $sections, 15 * MINUTE_IN_SECONDS);
|
||||||
|
return $mem[$carta_id] = $sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrae secciones del HTML. Pública para tests/CLI.
|
||||||
|
*/
|
||||||
|
function fea_extract_sections_from_html($html) {
|
||||||
|
$section_patterns = [
|
||||||
|
'evangelio' => '/Evangelio\s+y\s+comentarios\s+al\s+Evangelio/iu',
|
||||||
|
'articulos' => '/Art[ií]culos\s+seleccionados\s+para\s+la\s+semana/iu',
|
||||||
|
'eucaristia' => '/Para\s+unas\s+eucarist[ií]as\s+m[áa]s\s+participativas/iu',
|
||||||
|
'multimedia' => '/Material\s+multimedia/iu',
|
||||||
|
'effa' => '/Escuela\s+EFFA/iu',
|
||||||
|
];
|
||||||
|
|
||||||
|
$positions = [];
|
||||||
|
foreach ($section_patterns as $slug => $regex) {
|
||||||
|
if (preg_match($regex, $html, $m, PREG_OFFSET_CAPTURE)) {
|
||||||
|
$positions[$slug] = $m[0][1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (empty($positions)) return [];
|
||||||
|
asort($positions);
|
||||||
|
|
||||||
|
$slugs = array_keys($positions);
|
||||||
|
$offsets = array_values($positions);
|
||||||
|
$offsets[] = strlen($html);
|
||||||
|
|
||||||
|
$sections = [];
|
||||||
|
for ($i = 0; $i < count($slugs); $i++) {
|
||||||
|
$segment = substr($html, $offsets[$i], $offsets[$i+1] - $offsets[$i]);
|
||||||
|
$ids = fea_resolve_links_in_html($segment);
|
||||||
|
if ($ids) $sections[$slugs[$i]] = $ids;
|
||||||
|
}
|
||||||
|
return $sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrae los href de un fragmento HTML y los resuelve a wp_posts.ID.
|
||||||
|
*/
|
||||||
|
function fea_resolve_links_in_html($html) {
|
||||||
|
if (!preg_match_all('/href=["\']([^"\']+)["\']/i', $html, $m)) return [];
|
||||||
|
$ids = [];
|
||||||
|
$seen = [];
|
||||||
|
foreach ($m[1] as $url) {
|
||||||
|
$pid = fea_url_to_post_id($url);
|
||||||
|
if ($pid && !isset($seen[$pid])) {
|
||||||
|
$seen[$pid] = true;
|
||||||
|
$ids[] = $pid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resuelve una URL (WP o Joomla legacy) a wp_posts.ID o null.
|
||||||
|
*/
|
||||||
|
function fea_url_to_post_id($url) {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
// Joomla legacy: /item/<k2id>-...html
|
||||||
|
if (preg_match('~/item/(\d+)-[^/"]+\.html~i', $url, $m)) {
|
||||||
|
$k2 = (int) $m[1];
|
||||||
|
if ($k2 > 0) {
|
||||||
|
$pid = $wpdb->get_var($wpdb->prepare(
|
||||||
|
"SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key='_fgj2wp_old_k2_id' AND meta_value=%s LIMIT 1",
|
||||||
|
(string) $k2
|
||||||
|
));
|
||||||
|
if ($pid) return (int) $pid;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enlace interno WP: deriva el slug del path, relativo al home. Agnóstico al
|
||||||
|
// entorno → funciona en local (home en .../fea) y en prod (home en la raíz).
|
||||||
|
// No depende de un prefijo /fea/ hardcodeado (issue #91).
|
||||||
|
$host = wp_parse_url($url, PHP_URL_HOST);
|
||||||
|
$home_host = wp_parse_url(home_url(), PHP_URL_HOST);
|
||||||
|
if ($host && $home_host && strcasecmp($host, $home_host) !== 0) {
|
||||||
|
return null; // host externo → no es un artículo nuestro
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = (string) wp_parse_url($url, PHP_URL_PATH);
|
||||||
|
if ($path === '') return null;
|
||||||
|
$home_path = rtrim((string) wp_parse_url(home_url('/'), PHP_URL_PATH), '/');
|
||||||
|
if ($home_path !== '' && strpos($path, $home_path . '/') === 0) {
|
||||||
|
$path = substr($path, strlen($home_path));
|
||||||
|
}
|
||||||
|
$seg = explode('/', trim($path, '/'));
|
||||||
|
$slug = $seg[0] ?? '';
|
||||||
|
if ($slug === '' || in_array($slug, ['wp-admin','wp-content','category','tag','author','page','en','fr','it','pt'], true)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$pid = $wpdb->get_var($wpdb->prepare(
|
||||||
|
"SELECT ID FROM {$wpdb->posts}
|
||||||
|
WHERE post_name=%s AND post_status='publish' AND post_type='post'
|
||||||
|
ORDER BY post_date DESC LIMIT 1",
|
||||||
|
$slug
|
||||||
|
));
|
||||||
|
return $pid ? (int) $pid : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devuelve los WP_Post objects de una sección de la carta vigente,
|
||||||
|
* o array vacío si no hay carta o no hay links resueltos en esa sección.
|
||||||
|
*/
|
||||||
|
function fea_carta_section_posts($section_slug, $lang = null) {
|
||||||
|
$lang = $lang ?: (function_exists('fea_current_lang') ? fea_current_lang() : 'es');
|
||||||
|
|
||||||
|
// El parser de secciones reconoce las cabeceras SOLO en español
|
||||||
|
// (fea_extract_sections_from_html). Las cartas traducidas tienen las
|
||||||
|
// cabeceras en su idioma → 0 secciones. Por eso parseamos SIEMPRE la
|
||||||
|
// carta ES y luego mapeamos cada link a su traducción del idioma destino.
|
||||||
|
$carta_id = fea_get_current_carta_id($lang);
|
||||||
|
if (!$carta_id) return [];
|
||||||
|
$carta_es = $carta_id;
|
||||||
|
if ($lang !== 'es' && function_exists('pll_get_post')) {
|
||||||
|
$es = pll_get_post($carta_id, 'es');
|
||||||
|
if ($es) $carta_es = (int) $es;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sections = fea_parse_carta_sections($carta_es);
|
||||||
|
$ids = $sections[$section_slug] ?? [];
|
||||||
|
if (!$ids) return [];
|
||||||
|
$posts = [];
|
||||||
|
foreach ($ids as $pid) {
|
||||||
|
// Mapear el artículo ES a su traducción del idioma de la portada.
|
||||||
|
// Si no hay traducción, se mantiene el ES (degradación elegante).
|
||||||
|
if ($lang !== 'es' && function_exists('pll_get_post')) {
|
||||||
|
$tr = pll_get_post((int) $pid, $lang);
|
||||||
|
if ($tr) $pid = (int) $tr;
|
||||||
|
}
|
||||||
|
$p = get_post($pid);
|
||||||
|
if ($p && $p->post_status === 'publish') $posts[] = $p;
|
||||||
|
}
|
||||||
|
return $posts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalida transients de secciones al guardar/editar un post.
|
||||||
|
*/
|
||||||
|
add_action('save_post_post', function($post_id, $post, $update) {
|
||||||
|
$cats = wp_get_post_categories($post_id);
|
||||||
|
$watch = [6, 21, 22];
|
||||||
|
if (array_intersect($cats, $watch)) {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_fea_carta_sections_%'");
|
||||||
|
$wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_fea_carta_sections_%'");
|
||||||
|
}
|
||||||
|
}, 10, 3);
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fe Adulta - compact entry spacing
|
||||||
|
* Description: Ajusta el aire vertical de la navegacion de entradas y la paginacion de archivos.
|
||||||
|
*/
|
||||||
|
|
||||||
|
add_action('wp_head', function() {
|
||||||
|
if (is_admin()) return;
|
||||||
|
if (!(is_single() || is_archive() || is_search() || is_home())) return;
|
||||||
|
?>
|
||||||
|
<style>
|
||||||
|
/* Issue #67: compactar el cierre del single post sin tocar el template FSE. */
|
||||||
|
body.single-post .wp-block-group.alignwide:has(> nav[aria-label="Navegación de entradas"]) {
|
||||||
|
margin-top: 1rem !important;
|
||||||
|
margin-bottom: 0.75rem !important;
|
||||||
|
}
|
||||||
|
body.single-post nav[aria-label="Navegación de entradas"] {
|
||||||
|
padding-top: 0.75rem !important;
|
||||||
|
padding-bottom: 0.75rem !important;
|
||||||
|
gap: 1rem !important;
|
||||||
|
}
|
||||||
|
body.single-post .wp-block-post-navigation-link {
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
body.single-post .wp-block-post-navigation-link a {
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
body.single-post .wp-block-group.alignwide:has(> .wp-block-heading + .wp-block-query) {
|
||||||
|
padding-top: 1rem !important;
|
||||||
|
padding-bottom: 1.25rem !important;
|
||||||
|
}
|
||||||
|
body.single-post .wp-block-group.alignwide:has(> .wp-block-heading + .wp-block-query) > .wp-block-heading {
|
||||||
|
margin-bottom: 0.65rem !important;
|
||||||
|
}
|
||||||
|
body.single-post .wp-block-group.alignwide:has(> .wp-block-heading + .wp-block-query) .wp-block-post-template > .wp-block-post {
|
||||||
|
margin-block-start: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Issue #67: paginacion de archivos/categorias ("Mas entradas" / siguiente pagina). */
|
||||||
|
body.archive .wp-block-query.alignwide > .wp-block-spacer,
|
||||||
|
body.search .wp-block-query.alignwide > .wp-block-spacer,
|
||||||
|
body.blog .wp-block-query.alignwide > .wp-block-spacer {
|
||||||
|
height: 0.5rem !important;
|
||||||
|
}
|
||||||
|
body.archive .wp-block-query.alignwide > .wp-block-group.alignfull:has(> .wp-block-query-pagination),
|
||||||
|
body.search .wp-block-query.alignwide > .wp-block-group.alignfull:has(> .wp-block-query-pagination),
|
||||||
|
body.blog .wp-block-query.alignwide > .wp-block-group.alignfull:has(> .wp-block-query-pagination) {
|
||||||
|
margin-top: 0.5rem !important;
|
||||||
|
margin-bottom: 0.75rem !important;
|
||||||
|
}
|
||||||
|
body.archive .wp-block-query-pagination,
|
||||||
|
body.search .wp-block-query-pagination,
|
||||||
|
body.blog .wp-block-query-pagination {
|
||||||
|
gap: 0.75rem 1.25rem !important;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
body.archive .wp-block-query-pagination-numbers,
|
||||||
|
body.search .wp-block-query-pagination-numbers,
|
||||||
|
body.blog .wp-block-query-pagination-numbers {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.45rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<?php
|
||||||
|
}, 30);
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fea Cookie Consent — banner RGPD (#93)
|
||||||
|
* Description: Banner de consentimiento de cookies (Consent Mode v2). Mientras no
|
||||||
|
* haya consentimiento, GA4 (fea-analytics.php) arranca con analytics_storage
|
||||||
|
* 'denied'. Al Aceptar, el banner hace gtag consent 'update' a 'granted' y guarda
|
||||||
|
* cookie. Multiidioma por <html lang> (ES/EN/FR/IT/PT). HTML idéntico para todos
|
||||||
|
* (cacheable); el estado por usuario se aplica en JS desde la cookie.
|
||||||
|
* Cambiar preferencias: window.feaOpenCookiePrefs() (engánchalo a un enlace del pie).
|
||||||
|
*/
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
// Página de política de privacidad/cookies. ID del post madre (ES); el enlace
|
||||||
|
// se resuelve a la traducción del idioma actual vía Polylang.
|
||||||
|
define('FEA_PRIVACY_PAGE_ID', 21946);
|
||||||
|
|
||||||
|
add_action('wp_footer', function () {
|
||||||
|
if (is_admin()) return;
|
||||||
|
// Resolver la política al idioma actual (Polylang); fallback al ID madre.
|
||||||
|
$priv_id = FEA_PRIVACY_PAGE_ID;
|
||||||
|
if (function_exists('pll_get_post') && function_exists('pll_current_language')) {
|
||||||
|
$tr = pll_get_post($priv_id, pll_current_language());
|
||||||
|
if ($tr) $priv_id = $tr;
|
||||||
|
}
|
||||||
|
$privacy = get_permalink($priv_id);
|
||||||
|
$privacy = esc_url($privacy ? $privacy : '/');
|
||||||
|
?>
|
||||||
|
<style>
|
||||||
|
#fea-cc{position:fixed;left:0;right:0;bottom:0;z-index:99999;display:none;
|
||||||
|
background:#fafafa;color:#555;border-top:1px solid #e5e7eb;padding:12px 18px;
|
||||||
|
box-shadow:0 -1px 6px rgba(0,0,0,.08);font-size:13px;line-height:1.4}
|
||||||
|
#fea-cc .fea-cc-inner{max-width:1100px;margin:0 auto;display:flex;gap:14px;
|
||||||
|
align-items:center;flex-wrap:wrap;justify-content:space-between}
|
||||||
|
#fea-cc .fea-cc-text{flex:1 1 380px;min-width:240px}
|
||||||
|
#fea-cc a{color:#555;text-decoration:underline}
|
||||||
|
#fea-cc .fea-cc-btns{display:flex;gap:8px;flex-shrink:0}
|
||||||
|
#fea-cc button{cursor:pointer;border:0;border-radius:5px;padding:8px 16px;
|
||||||
|
font-size:13px;font-weight:500}
|
||||||
|
#fea-cc .fea-cc-reject{background:transparent;color:#6b7280;border:1px solid #d1d5db}
|
||||||
|
#fea-cc .fea-cc-accept{background:#6b7280;color:#fff;border:1px solid #6b7280}
|
||||||
|
</style>
|
||||||
|
<div id="fea-cc" role="dialog" aria-live="polite" aria-label="cookie consent">
|
||||||
|
<div class="fea-cc-inner">
|
||||||
|
<div class="fea-cc-text" id="fea-cc-text"></div>
|
||||||
|
<div class="fea-cc-btns">
|
||||||
|
<button type="button" class="fea-cc-reject" id="fea-cc-reject"></button>
|
||||||
|
<button type="button" class="fea-cc-accept" id="fea-cc-accept"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var PRIVACY = <?php echo json_encode($privacy); ?>;
|
||||||
|
var I18N = {
|
||||||
|
es:{t:"Usamos cookies de analítica (Google Analytics) para entender cómo se usa la web y mejorarla. ¿Nos das tu consentimiento?",a:"Aceptar",r:"Rechazar",m:"Más información"},
|
||||||
|
en:{t:"We use analytics cookies (Google Analytics) to understand how the site is used and improve it. Do you consent?",a:"Accept",r:"Reject",m:"Learn more"},
|
||||||
|
fr:{t:"Nous utilisons des cookies d'analyse (Google Analytics) pour comprendre l'usage du site et l'améliorer. Acceptez-vous ?",a:"Accepter",r:"Refuser",m:"En savoir plus"},
|
||||||
|
it:{t:"Usiamo cookie di analisi (Google Analytics) per capire come viene usato il sito e migliorarlo. Acconsenti?",a:"Accetta",r:"Rifiuta",m:"Maggiori informazioni"},
|
||||||
|
pt:{t:"Usamos cookies de análise (Google Analytics) para perceber como o site é usado e melhorá-lo. Dás o teu consentimento?",a:"Aceitar",r:"Rejeitar",m:"Saber mais"}
|
||||||
|
};
|
||||||
|
function lang(){
|
||||||
|
var l=(document.documentElement.lang||"es").slice(0,2).toLowerCase();
|
||||||
|
return I18N[l]?l:"es";
|
||||||
|
}
|
||||||
|
function getCookie(n){
|
||||||
|
var m=document.cookie.match('(?:^|; )'+n+'=([^;]*)');
|
||||||
|
return m?decodeURIComponent(m[1]):null;
|
||||||
|
}
|
||||||
|
function setCookie(n,v){
|
||||||
|
var d=";domain=.feadulta.com";
|
||||||
|
if(!/(^|\.)feadulta\.com$/.test(location.hostname)) d=""; // local: host-only
|
||||||
|
var exp=new Date(Date.now()+180*864e5).toUTCString();
|
||||||
|
document.cookie=n+"="+encodeURIComponent(v)+";path=/;expires="+exp+";SameSite=Lax"+d;
|
||||||
|
}
|
||||||
|
function grant(){
|
||||||
|
if(typeof gtag==='function'){
|
||||||
|
gtag('consent','update',{'analytics_storage':'granted'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function render(){
|
||||||
|
var L=I18N[lang()];
|
||||||
|
var box=document.getElementById('fea-cc');
|
||||||
|
document.getElementById('fea-cc-text').innerHTML=
|
||||||
|
L.t+' <a href="'+PRIVACY+'">'+L.m+'</a>';
|
||||||
|
document.getElementById('fea-cc-accept').textContent=L.a;
|
||||||
|
document.getElementById('fea-cc-reject').textContent=L.r;
|
||||||
|
box.style.display='block';
|
||||||
|
}
|
||||||
|
function hide(){var b=document.getElementById('fea-cc');if(b)b.style.display='none';}
|
||||||
|
function init(){
|
||||||
|
var v=getCookie('fea_consent');
|
||||||
|
if(v==='granted'){grant();return;}
|
||||||
|
if(v==='denied'){return;}
|
||||||
|
render();
|
||||||
|
document.getElementById('fea-cc-accept').addEventListener('click',function(){
|
||||||
|
setCookie('fea_consent','granted');grant();hide();
|
||||||
|
});
|
||||||
|
document.getElementById('fea-cc-reject').addEventListener('click',function(){
|
||||||
|
setCookie('fea_consent','denied');hide();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Reabrir el banner para cambiar preferencias (enlace del pie, etc.)
|
||||||
|
window.feaOpenCookiePrefs=function(){render();
|
||||||
|
document.getElementById('fea-cc-accept').onclick=function(){setCookie('fea_consent','granted');grant();hide();};
|
||||||
|
document.getElementById('fea-cc-reject').onclick=function(){setCookie('fea_consent','denied');hide();};
|
||||||
|
};
|
||||||
|
if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',init);}
|
||||||
|
else{init();}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?php
|
||||||
|
}, 99);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* fea-disable-comments — feadulta no usa comentarios.
|
||||||
|
* Defensivo: aunque un post quede con comment_status=open por accidente,
|
||||||
|
* el render trata comentarios y pings como cerrados y no muestra UI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Comentarios y pings siempre cerrados en el frontend.
|
||||||
|
add_filter('comments_open', '__return_false', 20, 2);
|
||||||
|
add_filter('pings_open', '__return_false', 20, 2);
|
||||||
|
|
||||||
|
// No devolver comentarios existentes al render.
|
||||||
|
add_filter('comments_array', '__return_empty_array', 10, 2);
|
||||||
|
|
||||||
|
// Quitar el soporte de comentarios de los tipos de contenido.
|
||||||
|
add_action('init', function () {
|
||||||
|
remove_post_type_support('post', 'comments');
|
||||||
|
remove_post_type_support('post', 'trackbacks');
|
||||||
|
remove_post_type_support('page', 'comments');
|
||||||
|
remove_post_type_support('page', 'trackbacks');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Quitar "Comentarios" de la barra de admin.
|
||||||
|
add_action('wp_before_admin_bar_render', function () {
|
||||||
|
if (is_admin_bar_showing()) {
|
||||||
|
global $wp_admin_bar;
|
||||||
|
$wp_admin_bar->remove_menu('comments');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fe Adulta - hide imported artifacts
|
||||||
|
* Description: Oculta en frontend artefactos importados hasta decidir una limpieza definitiva.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function fea_current_request_path() {
|
||||||
|
$path = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH);
|
||||||
|
return is_string($path) ? trim($path, '/') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_is_bad_imported_request_path() {
|
||||||
|
$path = fea_current_request_path();
|
||||||
|
return (bool)preg_match('~(^|/)tag/1/?$~', $path)
|
||||||
|
|| (bool)preg_match('~(^|/)[0-9]{2}-[0-9]{2}-[0-9]{4}/?$~', $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_is_bad_imported_tag($term) {
|
||||||
|
return $term
|
||||||
|
&& isset($term->taxonomy, $term->slug, $term->name)
|
||||||
|
&& $term->taxonomy === 'post_tag'
|
||||||
|
&& ($term->slug === '1' || $term->name === '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
add_filter('get_the_terms', function($terms, $post_id, $taxonomy) {
|
||||||
|
if (is_admin() || $taxonomy !== 'post_tag' || empty($terms) || is_wp_error($terms)) {
|
||||||
|
return $terms;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter($terms, function($term) {
|
||||||
|
return !fea_is_bad_imported_tag($term);
|
||||||
|
}));
|
||||||
|
}, 10, 3);
|
||||||
|
|
||||||
|
add_filter('get_terms', function($terms, $taxonomies) {
|
||||||
|
if (is_admin() || is_wp_error($terms) || empty($terms) || !in_array('post_tag', (array)$taxonomies, true)) {
|
||||||
|
return $terms;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter($terms, function($term) {
|
||||||
|
return !fea_is_bad_imported_tag($term);
|
||||||
|
}));
|
||||||
|
}, 10, 2);
|
||||||
|
|
||||||
|
add_filter('redirect_canonical', function($redirect_url) {
|
||||||
|
return fea_is_bad_imported_request_path() ? false : $redirect_url;
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
add_filter('do_redirect_guess_404_permalink', function($do_redirect) {
|
||||||
|
return fea_is_bad_imported_request_path() ? false : $do_redirect;
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
add_filter('wp_redirect', function($location) {
|
||||||
|
return fea_is_bad_imported_request_path() ? false : $location;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
add_action('template_redirect', function() {
|
||||||
|
if (!is_tag('1') && !fea_is_bad_imported_request_path()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $wp_query;
|
||||||
|
$wp_query->set_404();
|
||||||
|
status_header(404);
|
||||||
|
nocache_headers();
|
||||||
|
}, 0);
|
||||||
Executable
+2130
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* fea-menu-i18n — Traducción de los menús FSE (issue #120).
|
||||||
|
*
|
||||||
|
* Polylang FREE no traduce los bloques `wp_navigation` (menús del header y pie).
|
||||||
|
* Este plugin engancha `render_block` sobre cada `core/navigation-link` /
|
||||||
|
* `core/navigation-submenu` y, cuando el idioma actual ≠ es:
|
||||||
|
* 1) sustituye la ETIQUETA por su traducción (mapa de abajo, hecho a mano),
|
||||||
|
* 2) remapea la URL al destino traducido si existe (post/página/categoría via
|
||||||
|
* Polylang); si no hay traducción del destino, deja la URL ES (fallback).
|
||||||
|
*
|
||||||
|
* Las etiquetas son cortas y de contexto religioso → traducidas a mano para
|
||||||
|
* máxima calidad (el contenido largo de #120 va por MiniMax + glosario).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// NOTA: los mu-plugins cargan antes que Polylang → NO comprobar pll_* a nivel de
|
||||||
|
// fichero (abortaría). Se comprueba dentro del filtro, en render (ya cargado).
|
||||||
|
|
||||||
|
/** Mapa etiqueta ES => [en, fr, it, pt]. Claves normalizadas con trim. */
|
||||||
|
function fea_menu_map(): array {
|
||||||
|
static $m = null;
|
||||||
|
if ($m !== null) return $m;
|
||||||
|
$m = [
|
||||||
|
// ── Menú principal (header) ──
|
||||||
|
'PORTADA' => ['Home', 'Accueil', 'Home', 'Início'],
|
||||||
|
'Quiénes somos' => ['About us', 'À propos', 'Chi siamo', 'Quem somos'],
|
||||||
|
'Colaboradores' => ['Contributors', 'Collaborateurs', 'Collaboratori', 'Colaboradores'],
|
||||||
|
'Este portal' => ['This portal', 'Ce portail', 'Questo portale', 'Este portal'],
|
||||||
|
'Para poner al día la Fe' => ['Bringing faith up to date', 'Mettre la foi à jour', 'Aggiornare la fede', 'Atualizar a fé'],
|
||||||
|
'Cartas' => ['Letters', 'Lettres', 'Lettere', 'Cartas'],
|
||||||
|
'Esta semana' => ['This week', 'Cette semaine', 'Questa settimana', 'Esta semana'],
|
||||||
|
'Semana pasada' => ['Last week', 'Semaine dernière', 'Settimana scorsa', 'Semana passada'],
|
||||||
|
'Otras semanas' => ['Other weeks', 'Autres semaines', 'Altre settimane', 'Outras semanas'],
|
||||||
|
'Acceso a webs anteriores:' => ['Previous websites:', 'Anciens sites :', 'Siti precedenti:', 'Sites anteriores:'],
|
||||||
|
'Web V1 — FrontPage (2006-2012)' => ['Site V1 — FrontPage (2006-2012)', 'Site V1 — FrontPage (2006-2012)', 'Sito V1 — FrontPage (2006-2012)', 'Site V1 — FrontPage (2006-2012)'],
|
||||||
|
'Web V2 — Joomla (2012-2026)' => ['Site V2 — Joomla (2012-2026)', 'Site V2 — Joomla (2012-2026)', 'Sito V2 — Joomla (2012-2026)', 'Site V2 — Joomla (2012-2026)'],
|
||||||
|
'Nueva política de datos' => ['New data policy', 'Nouvelle politique de données', 'Nuova politica sui dati', 'Nova política de dados'],
|
||||||
|
'Contactar' => ['Contact', 'Contact', 'Contatti', 'Contactar'],
|
||||||
|
'Para contactar con nosotros' => ['Contact us', 'Nous contacter', 'Per contattarci', 'Para contactar-nos'],
|
||||||
|
'Para recibir la carta de novedades' => ['Subscribe to the newsletter', 'Recevoir la newsletter', 'Ricevere la newsletter', 'Receber a newsletter'],
|
||||||
|
'Para inscribirse en la Escuela' => ['Enrol in the School', 'S\'inscrire à l\'École', 'Iscriversi alla Scuola', 'Inscrever-se na Escola'],
|
||||||
|
'🎓 Escuela' => ['🎓 School', '🎓 École', '🎓 Scuola', '🎓 Escola'],
|
||||||
|
'📚 Librería 🛒' => ['📚 Bookshop 🛒', '📚 Librairie 🛒', '📚 Libreria 🛒', '📚 Livraria 🛒'],
|
||||||
|
'Buscar' => ['Search', 'Rechercher', 'Cerca', 'Pesquisar'],
|
||||||
|
// ── Menús del pie ──
|
||||||
|
'Cartas que nos llegan' => ['Letters we receive', 'Lettres que nous recevons', 'Lettere che riceviamo', 'Cartas que recebemos'],
|
||||||
|
'Tablón de anuncios' => ['Notice board', 'Tableau d\'annonces', 'Bacheca', 'Mural de avisos'],
|
||||||
|
'Asociación FeAdulta' => ['FeAdulta Association', 'Association FeAdulta', 'Associazione FeAdulta', 'Associação FeAdulta'],
|
||||||
|
'La suma de todos' => ['The sum of all', 'La somme de tous', 'La somma di tutti', 'A soma de todos'],
|
||||||
|
'Comunidades cristianas' => ['Christian communities', 'Communautés chrétiennes', 'Comunità cristiane', 'Comunidades cristãs'],
|
||||||
|
'El Evangelio de cada día' => ['The daily Gospel', 'L\'Évangile de chaque jour', 'Il Vangelo di ogni giorno', 'O Evangelho de cada dia'],
|
||||||
|
'Índice cronológico' => ['Chronological index', 'Index chronologique', 'Indice cronologico', 'Índice cronológico'],
|
||||||
|
'Índice cronológico' => ['Chronological index', 'Index chronologique', 'Indice cronologico', 'Índice cronológico'],
|
||||||
|
'Evangelios y comentarios' => ['Gospels and commentaries', 'Évangiles et commentaires', 'Vangeli e commenti', 'Evangelhos e comentários'],
|
||||||
|
'Oraciones eucarísticas' => ['Eucharistic prayers', 'Prières eucharistiques', 'Preghiere eucaristiche', 'Orações eucarísticas'],
|
||||||
|
'A modo de salmos' => ['In the manner of psalms', 'À la manière de psaumes', 'A mo\' di salmi', 'À maneira de salmos'],
|
||||||
|
'Preces y oraciones varias' => ['Prayers and various orations', 'Prières et oraisons diverses', 'Preci e orazioni varie', 'Preces e orações várias'],
|
||||||
|
'Primeras lecturas' => ['First readings', 'Premières lectures', 'Prime letture', 'Primeiras leituras'],
|
||||||
|
'Autores' => ['Authors', 'Auteurs', 'Autori', 'Autores'],
|
||||||
|
'Temas' => ['Topics', 'Thèmes', 'Temi', 'Temas'],
|
||||||
|
'Multimedia' => ['Multimedia', 'Multimédia', 'Multimedia', 'Multimédia'],
|
||||||
|
'Índice de pensamientos' => ['Index of reflections', 'Index des pensées', 'Indice dei pensieri', 'Índice de pensamentos'],
|
||||||
|
'Cantoral' => ['Hymnal', 'Recueil de chants', 'Canzoniere', 'Cancioneiro'],
|
||||||
|
'Películas' => ['Films', 'Films', 'Film', 'Filmes'],
|
||||||
|
'Reseñas de libros' => ['Book reviews', 'Critiques de livres', 'Recensioni di libri', 'Resenhas de livros'],
|
||||||
|
'In memoriam' => ['In memoriam', 'In memoriam', 'In memoriam', 'In memoriam'],
|
||||||
|
];
|
||||||
|
return $m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Índice de columna por idioma. */
|
||||||
|
function fea_menu_lang_col(string $lang): int {
|
||||||
|
return ['en' => 0, 'fr' => 1, 'it' => 2, 'pt' => 3][$lang] ?? -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_menu_tr(string $label, int $col): ?string {
|
||||||
|
$map = fea_menu_map();
|
||||||
|
$key = trim($label);
|
||||||
|
if (isset($map[$key][$col]) && $map[$key][$col] !== '') return $map[$key][$col];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remapea una URL ES al destino traducido si existe (post/página/categoría).
|
||||||
|
* Devuelve la URL original si no hay traducción o no se resuelve.
|
||||||
|
*/
|
||||||
|
function fea_menu_localize_url(string $url, string $lang): string {
|
||||||
|
if ($url === '' || preg_match('~^(mailto:|tel:|javascript:|#)~i', $url)) return $url;
|
||||||
|
// Solo enlaces internos de ESTE sitio (no Librería, no webs anteriores externas).
|
||||||
|
$host = parse_url($url, PHP_URL_HOST);
|
||||||
|
if ($host) {
|
||||||
|
$site_host = parse_url(home_url('/'), PHP_URL_HOST);
|
||||||
|
if ($host !== $site_host) return $url; // externo
|
||||||
|
}
|
||||||
|
$path = trim((string) parse_url($url, PHP_URL_PATH), '/');
|
||||||
|
// quitar subcarpeta local (fea) y prefijo de idioma (es/en/…), con o sin barra final
|
||||||
|
$path = preg_replace('#^fea(/|$)#', '', $path);
|
||||||
|
$path = preg_replace('#^(es|en|fr|it|pt)(/|$)#', '', $path);
|
||||||
|
if ($path === '') {
|
||||||
|
// raíz del sitio → portada del idioma
|
||||||
|
return function_exists('pll_home_url') ? pll_home_url($lang) : $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// categoría: category/<slug>. El slug del menú es ES; buscamos SIN filtro de
|
||||||
|
// idioma de Polylang (lang => '') porque el render va en otro idioma.
|
||||||
|
if (preg_match('#^category/([^/]+)/?$#', $path, $mm)) {
|
||||||
|
$terms = get_terms(['taxonomy' => 'category', 'slug' => $mm[1], 'hide_empty' => false, 'number' => 1, 'lang' => '']);
|
||||||
|
$term = (!is_wp_error($terms) && $terms) ? $terms[0] : null;
|
||||||
|
if ($term && function_exists('pll_get_term')) {
|
||||||
|
$tr = pll_get_term($term->term_id, $lang);
|
||||||
|
if ($tr) {
|
||||||
|
$link = get_category_link($tr);
|
||||||
|
if ($link && !is_wp_error($link)) return $link;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// página/post por último segmento (lang => '' para no filtrar por idioma actual)
|
||||||
|
$slug = basename($path);
|
||||||
|
$q = get_posts(['name' => $slug, 'post_type' => ['post', 'page'], 'numberposts' => 1, 'post_status' => 'publish', 'lang' => '']);
|
||||||
|
$page = $q ? $q[0] : null;
|
||||||
|
if ($page && function_exists('pll_get_post')) {
|
||||||
|
$tr = pll_get_post($page->ID, $lang);
|
||||||
|
if ($tr) {
|
||||||
|
$link = get_permalink($tr);
|
||||||
|
if ($link) return $link;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
add_filter('render_block', function ($content, $block) {
|
||||||
|
if (empty($block['blockName'])) return $content;
|
||||||
|
if ($block['blockName'] !== 'core/navigation-link' && $block['blockName'] !== 'core/navigation-submenu') return $content;
|
||||||
|
if (!function_exists('pll_current_language')) return $content;
|
||||||
|
|
||||||
|
$lang = pll_current_language();
|
||||||
|
$col = fea_menu_lang_col((string) $lang);
|
||||||
|
if ($col < 0) return $content; // es o desconocido → sin tocar
|
||||||
|
|
||||||
|
$label = isset($block['attrs']['label']) ? (string) $block['attrs']['label'] : '';
|
||||||
|
$url = isset($block['attrs']['url']) ? (string) $block['attrs']['url'] : '';
|
||||||
|
|
||||||
|
// 1) etiqueta
|
||||||
|
if ($label !== '') {
|
||||||
|
$tr = fea_menu_tr($label, $col);
|
||||||
|
if ($tr !== null && $tr !== $label) {
|
||||||
|
$content = str_replace('>' . esc_html($label) . '<', '>' . esc_html($tr) . '<', $content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2) URL — localizar el href realmente renderizado (robusto frente a discrepancias attr/HTML)
|
||||||
|
$content = preg_replace_callback('/href=("|\')([^"\']*)\1/i', function ($m) use ($lang) {
|
||||||
|
$href = html_entity_decode($m[2], ENT_QUOTES);
|
||||||
|
$loc = fea_menu_localize_url($href, (string) $lang);
|
||||||
|
return 'href=' . $m[1] . esc_url($loc) . $m[1];
|
||||||
|
}, $content, 1);
|
||||||
|
return $content;
|
||||||
|
}, 10, 2);
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* fea-pensamientos — Galerías Joomla y pausa aleatoria en artículos.
|
||||||
|
*
|
||||||
|
* Reutiliza /images de Joomla sin duplicar ficheros en WordPress.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('FEA_JOOMLA_IMAGES_DIR')) {
|
||||||
|
define('FEA_JOOMLA_IMAGES_DIR', file_exists('/web/images') ? '/web/images' : '/var/www/joomla-images');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined('FEA_JOOMLA_IMAGES_URL')) {
|
||||||
|
$fea_is_prod = (defined('ABSPATH') && strpos((string) ABSPATH, '/web/wp-nuevo/') === 0)
|
||||||
|
|| (isset($_SERVER['HTTP_HOST']) && preg_match('/(^|\.)feadulta\.com$/', (string) $_SERVER['HTTP_HOST']))
|
||||||
|
|| file_exists('/web/images');
|
||||||
|
|
||||||
|
define(
|
||||||
|
'FEA_JOOMLA_IMAGES_URL',
|
||||||
|
$fea_is_prod ? 'https://www.feadulta.com/images' : 'https://farmer.taild3aaf6.ts.net/joomla/images'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined('FEA_PENS_DIR')) {
|
||||||
|
define('FEA_PENS_DIR', rtrim(FEA_JOOMLA_IMAGES_DIR, '/') . '/Pensamientos');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined('FEA_PENS_URL')) {
|
||||||
|
define('FEA_PENS_URL', rtrim(FEA_JOOMLA_IMAGES_URL, '/') . '/Pensamientos');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined('FEA_GALLERY_PER_PAGE')) {
|
||||||
|
define('FEA_GALLERY_PER_PAGE', 72);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined('FEA_RANDOM_THOUGHT_EXCLUDED_CATS')) {
|
||||||
|
// Categorías que NO muestran pensamiento aleatorio:
|
||||||
|
// 1645 Lecturas bíblicas · 28 Evangelios y comentarios (textos del evangelio)
|
||||||
|
// 20 Presentación colaboradores (fichas de colaboradores)
|
||||||
|
// 1647 Comentarios al evangelio SÍ muestra pensamiento (decisión Rafa 2026-06-19).
|
||||||
|
define('FEA_RANDOM_THOUGHT_EXCLUDED_CATS', '1645,28,20');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined('FEA_RANDOM_THOUGHT_EXCLUDED_IDS')) {
|
||||||
|
// Posts estructurales (páginas disfrazadas de post) que no deben llevar pensamiento.
|
||||||
|
// 17563 = índice /colaboradores/ (está en «Sin categoría», no lo cubre la cat 20).
|
||||||
|
define('FEA_RANDOM_THOUGHT_EXCLUDED_IDS', '17563');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined('FEA_GALLERY_MANIFEST')) {
|
||||||
|
define('FEA_GALLERY_MANIFEST', WP_CONTENT_DIR . '/uploads/fea-gallery-manifest.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_gallery_safe_dir(string $dir): string {
|
||||||
|
$dir = trim($dir);
|
||||||
|
$dir = trim($dir, "/\\ \t\n\r\0\x0B");
|
||||||
|
return preg_replace('/[^A-Za-z0-9._-]/', '', $dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_gallery_base_dir(): string {
|
||||||
|
return rtrim(FEA_JOOMLA_IMAGES_DIR, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_gallery_base_url(): string {
|
||||||
|
return rtrim(FEA_JOOMLA_IMAGES_URL, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_gallery_manifest_files(string $dir, string $order = 'desc'): array {
|
||||||
|
static $manifest = null;
|
||||||
|
|
||||||
|
if ($manifest === null) {
|
||||||
|
$manifest = [];
|
||||||
|
$path = (string) FEA_GALLERY_MANIFEST;
|
||||||
|
if (is_readable($path)) {
|
||||||
|
$decoded = json_decode((string) file_get_contents($path), true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
$manifest = $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($manifest[$dir]) || !is_array($manifest[$dir])) return [];
|
||||||
|
|
||||||
|
$files = array_values(array_filter(array_map('strval', $manifest[$dir]), function ($file) {
|
||||||
|
return preg_match('/\.(jpe?g|png|gif|webp)$/i', $file);
|
||||||
|
}));
|
||||||
|
natsort($files);
|
||||||
|
$files = array_values($files);
|
||||||
|
if ($order === 'desc') {
|
||||||
|
$files = array_reverse($files);
|
||||||
|
}
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_gallery_files(string $dir, string $order = 'desc'): array {
|
||||||
|
$dir = fea_gallery_safe_dir($dir);
|
||||||
|
if ($dir === '') return [];
|
||||||
|
|
||||||
|
$path = fea_gallery_base_dir() . '/' . $dir;
|
||||||
|
if (!is_dir($path) || !is_readable($path)) {
|
||||||
|
return fea_gallery_manifest_files($dir, $order);
|
||||||
|
}
|
||||||
|
|
||||||
|
$mtime = (int) @filemtime($path);
|
||||||
|
$cache_key = 'fea_gallery_' . md5($path . '|' . $mtime . '|' . $order);
|
||||||
|
$cached = get_transient($cache_key);
|
||||||
|
if (is_array($cached)) return $cached;
|
||||||
|
|
||||||
|
$files = [];
|
||||||
|
$entries = @scandir($path);
|
||||||
|
if (!is_array($entries)) return [];
|
||||||
|
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
|
if (!preg_match('/\.(jpe?g|png|gif|webp)$/i', $entry)) continue;
|
||||||
|
if (!is_file($path . '/' . $entry)) continue;
|
||||||
|
$files[] = $entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
natsort($files);
|
||||||
|
$files = array_values($files);
|
||||||
|
if ($order === 'desc') {
|
||||||
|
$files = array_reverse($files);
|
||||||
|
}
|
||||||
|
|
||||||
|
set_transient($cache_key, $files, 10 * MINUTE_IN_SECONDS);
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_gallery_url(string $dir, string $file): string {
|
||||||
|
return fea_gallery_base_url() . '/' . rawurlencode(fea_gallery_safe_dir($dir)) . '/' . rawurlencode($file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_gallery_page_param(string $dir): string {
|
||||||
|
return 'fea_gallery_' . substr(md5($dir), 0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_gallery_render(array $atts = []): string {
|
||||||
|
$atts = shortcode_atts([
|
||||||
|
'dir' => 'Pensamientos',
|
||||||
|
'per_page' => (string) FEA_GALLERY_PER_PAGE,
|
||||||
|
'order' => 'desc',
|
||||||
|
], $atts, 'fea_galeria');
|
||||||
|
|
||||||
|
$dir = fea_gallery_safe_dir((string) $atts['dir']);
|
||||||
|
if ($dir === '') return '';
|
||||||
|
|
||||||
|
$order = strtolower((string) $atts['order']) === 'asc' ? 'asc' : 'desc';
|
||||||
|
$files = fea_gallery_files($dir, $order);
|
||||||
|
if (!$files) {
|
||||||
|
return '<p class="fea-gallery-empty">Galería no disponible.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$per_page = max(12, min(144, (int) $atts['per_page']));
|
||||||
|
$total = count($files);
|
||||||
|
$pages = max(1, (int) ceil($total / $per_page));
|
||||||
|
$param = fea_gallery_page_param($dir);
|
||||||
|
$page = isset($_GET[$param]) ? max(1, (int) $_GET[$param]) : 1;
|
||||||
|
$page = min($page, $pages);
|
||||||
|
$offset = ($page - 1) * $per_page;
|
||||||
|
$visible = array_slice($files, $offset, $per_page);
|
||||||
|
|
||||||
|
$html = '<div class="fea-gallery" data-fea-gallery="' . esc_attr($dir) . '">';
|
||||||
|
$html .= '<div class="fea-gallery-grid">';
|
||||||
|
foreach ($visible as $file) {
|
||||||
|
$url = fea_gallery_url($dir, $file);
|
||||||
|
$alt = preg_replace('/\.[^.]+$/', '', $file);
|
||||||
|
$html .= '<a class="fea-gallery-item" href="' . esc_url($url) . '" data-fea-lightbox="1">';
|
||||||
|
$html .= '<img src="' . esc_url($url) . '" alt="' . esc_attr($alt) . '" loading="lazy" decoding="async">';
|
||||||
|
$html .= '</a>';
|
||||||
|
}
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
if ($pages > 1) {
|
||||||
|
$html .= '<nav class="fea-gallery-pages" aria-label="Paginación de galería">';
|
||||||
|
if ($page > 1) {
|
||||||
|
$html .= '<a href="' . esc_url(add_query_arg($param, $page - 1)) . '">Anterior</a>';
|
||||||
|
}
|
||||||
|
$html .= '<span>Página ' . esc_html((string) $page) . ' de ' . esc_html((string) $pages) . '</span>';
|
||||||
|
if ($page < $pages) {
|
||||||
|
$html .= '<a href="' . esc_url(add_query_arg($param, $page + 1)) . '">Siguiente</a>';
|
||||||
|
}
|
||||||
|
$html .= '</nav>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '</div>';
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
add_shortcode('fea_galeria', 'fea_gallery_render');
|
||||||
|
|
||||||
|
add_filter('the_content', function ($content) {
|
||||||
|
if (is_admin() || stripos($content, '{gallery}') === false) return $content;
|
||||||
|
|
||||||
|
return preg_replace_callback(
|
||||||
|
'/\{gallery\}\s*([^{}]+?)\s*\{\/gallery\}/i',
|
||||||
|
function ($m) {
|
||||||
|
return fea_gallery_render(['dir' => trim($m[1])]);
|
||||||
|
},
|
||||||
|
$content
|
||||||
|
);
|
||||||
|
}, 8);
|
||||||
|
|
||||||
|
function fea_random_thought_html(): string {
|
||||||
|
$files = fea_gallery_files('Pensamientos', 'desc');
|
||||||
|
if (!$files) return '';
|
||||||
|
|
||||||
|
$file = $files[array_rand($files)];
|
||||||
|
$url = fea_gallery_url('Pensamientos', $file);
|
||||||
|
|
||||||
|
return '<aside class="fea-random-thought" aria-label="Una pausa">'
|
||||||
|
. '<div class="fea-random-thought-title"><span></span><strong>Una pausa para el alma</strong><span></span></div>'
|
||||||
|
. '<a href="' . esc_url($url) . '" data-fea-lightbox="1">'
|
||||||
|
. '<img src="' . esc_url($url) . '" alt="Pensamiento aleatorio" loading="lazy" decoding="async">'
|
||||||
|
. '</a></aside>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_random_thought_excluded(): bool {
|
||||||
|
if (!is_singular('post')) return true;
|
||||||
|
|
||||||
|
$post_id = get_the_ID();
|
||||||
|
|
||||||
|
$excluded_ids = array_filter(array_map('intval', explode(',', (string) FEA_RANDOM_THOUGHT_EXCLUDED_IDS)));
|
||||||
|
if ($post_id && in_array((int) $post_id, $excluded_ids, true)) return true;
|
||||||
|
|
||||||
|
if ($post_id) {
|
||||||
|
$raw = (string) get_post_field('post_content', $post_id);
|
||||||
|
if (stripos($raw, '{gallery}') !== false || stripos($raw, '[fea_galeria') !== false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = array_filter(array_map('intval', explode(',', (string) FEA_RANDOM_THOUGHT_EXCLUDED_CATS)));
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
if ($id > 0 && has_category($id)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
add_shortcode('fea_reflexion_aleatoria', function () {
|
||||||
|
return fea_random_thought_html();
|
||||||
|
});
|
||||||
|
|
||||||
|
add_filter('the_content', function ($content) {
|
||||||
|
if (is_admin() || !is_main_query() || !in_the_loop() || fea_random_thought_excluded()) {
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
if (strpos($content, 'fea-random-thought') !== false) return $content;
|
||||||
|
|
||||||
|
$thought = fea_random_thought_html();
|
||||||
|
return $thought ? $content . $thought : $content;
|
||||||
|
}, 18);
|
||||||
|
|
||||||
|
function fea_pensamientos_assets_needed(): bool {
|
||||||
|
if (!is_singular()) return false;
|
||||||
|
|
||||||
|
$post_id = get_queried_object_id();
|
||||||
|
$raw = $post_id ? (string) get_post_field('post_content', $post_id) : '';
|
||||||
|
if (stripos($raw, '{gallery}') !== false || stripos($raw, '[fea_galeria') !== false || stripos($raw, '[fea_reflexion_aleatoria') !== false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !fea_random_thought_excluded();
|
||||||
|
}
|
||||||
|
|
||||||
|
add_action('wp_head', function () {
|
||||||
|
if (!fea_pensamientos_assets_needed()) return;
|
||||||
|
|
||||||
|
?>
|
||||||
|
<style>
|
||||||
|
.fea-gallery {
|
||||||
|
width: min(1180px, 100%);
|
||||||
|
max-width: none;
|
||||||
|
margin: 1.5rem auto 2rem;
|
||||||
|
}
|
||||||
|
.fea-gallery .fea-gallery-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
|
||||||
|
gap: 0.65rem !important;
|
||||||
|
}
|
||||||
|
.fea-gallery-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
aspect-ratio: 275 / 160;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(0,0,0,0.08);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.07);
|
||||||
|
}
|
||||||
|
.fea-gallery-item img {
|
||||||
|
width: 98.5%;
|
||||||
|
height: 96.5%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
transition: transform 0.16s ease;
|
||||||
|
}
|
||||||
|
.fea-gallery-item:hover img { transform: scale(1.02); }
|
||||||
|
.fea-gallery-pages {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.fea-gallery-pages a {
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.fea-random-thought {
|
||||||
|
margin: 2.4rem auto 1.4rem;
|
||||||
|
max-width: 720px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.fea-random-thought-title {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #7f1d1d;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.fea-random-thought-title span {
|
||||||
|
height: 1px;
|
||||||
|
background: currentColor;
|
||||||
|
opacity: 0.28;
|
||||||
|
}
|
||||||
|
.fea-random-thought a { display: inline-block; max-width: min(100%, 560px); }
|
||||||
|
.fea-random-thought img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.14);
|
||||||
|
}
|
||||||
|
.fea-lightbox {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 99999;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4rem 5rem;
|
||||||
|
background: rgba(0,0,0,0.86);
|
||||||
|
}
|
||||||
|
.fea-lightbox.is-open { display: flex; }
|
||||||
|
.fea-lightbox-frame {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.fea-lightbox-frame img {
|
||||||
|
max-width: min(100%, 1100px);
|
||||||
|
max-height: calc(100vh - 8rem);
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 18px 60px rgba(0,0,0,0.45);
|
||||||
|
}
|
||||||
|
.fea-lightbox button {
|
||||||
|
position: absolute;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255,255,255,0.92);
|
||||||
|
color: #111;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.fea-lightbox-close {
|
||||||
|
top: 0.75rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
.fea-lightbox-prev,
|
||||||
|
.fea-lightbox-next {
|
||||||
|
top: 50%;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
font-size: 2rem;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
.fea-lightbox-prev { left: 1rem; }
|
||||||
|
.fea-lightbox-next { right: 1rem; }
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.fea-gallery { width: min(100%, calc(100vw - 1rem)); }
|
||||||
|
.fea-gallery .fea-gallery-grid { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; }
|
||||||
|
.fea-lightbox { padding: 3.5rem 1rem; }
|
||||||
|
.fea-lightbox-prev,
|
||||||
|
.fea-lightbox-next {
|
||||||
|
top: auto;
|
||||||
|
bottom: 0.75rem;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var links = Array.prototype.slice.call(document.querySelectorAll('[data-fea-lightbox="1"]'));
|
||||||
|
if (!links.length) return;
|
||||||
|
|
||||||
|
var box = document.createElement('div');
|
||||||
|
box.className = 'fea-lightbox';
|
||||||
|
box.innerHTML = '<button type="button" class="fea-lightbox-close" aria-label="Cerrar">×</button><button type="button" class="fea-lightbox-prev" aria-label="Anterior">‹</button><div class="fea-lightbox-frame"><img alt=""></div><button type="button" class="fea-lightbox-next" aria-label="Siguiente">›</button>';
|
||||||
|
document.body.appendChild(box);
|
||||||
|
|
||||||
|
var img = box.querySelector('img');
|
||||||
|
var closeButton = box.querySelector('.fea-lightbox-close');
|
||||||
|
var prevButton = box.querySelector('.fea-lightbox-prev');
|
||||||
|
var nextButton = box.querySelector('.fea-lightbox-next');
|
||||||
|
var index = 0;
|
||||||
|
|
||||||
|
var show = function (nextIndex) {
|
||||||
|
index = (nextIndex + links.length) % links.length;
|
||||||
|
img.src = links[index].href;
|
||||||
|
img.alt = links[index].querySelector('img') ? links[index].querySelector('img').alt : '';
|
||||||
|
box.classList.add('is-open');
|
||||||
|
};
|
||||||
|
var close = function () {
|
||||||
|
box.classList.remove('is-open');
|
||||||
|
img.removeAttribute('src');
|
||||||
|
};
|
||||||
|
|
||||||
|
box.addEventListener('click', function (event) {
|
||||||
|
if (event.target === box) close();
|
||||||
|
});
|
||||||
|
closeButton.addEventListener('click', close);
|
||||||
|
prevButton.addEventListener('click', function () { show(index - 1); });
|
||||||
|
nextButton.addEventListener('click', function () { show(index + 1); });
|
||||||
|
document.addEventListener('keydown', function (event) {
|
||||||
|
if (!box.classList.contains('is-open')) return;
|
||||||
|
if (event.key === 'Escape') close();
|
||||||
|
if (event.key === 'ArrowLeft') show(index - 1);
|
||||||
|
if (event.key === 'ArrowRight') show(index + 1);
|
||||||
|
});
|
||||||
|
links.forEach(function (link, linkIndex) {
|
||||||
|
link.addEventListener('click', function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
show(linkIndex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<?php
|
||||||
|
}, 30);
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* fea-recopilatorios — Listados dinámicos auto-actualizables (issues #96-#118).
|
||||||
|
*
|
||||||
|
* Sustituye las páginas-recopilatorio MANUALES de Joomla (tablas/listas de
|
||||||
|
* enlaces mantenidas a mano) por un listado generado desde una categoría.
|
||||||
|
* Así, cada carta/post nuevo que entre en la categoría aparece solo, sin
|
||||||
|
* copiar y pegar.
|
||||||
|
*
|
||||||
|
* Uso:
|
||||||
|
* [fea_recopilatorio cat="1648"] (por term_id)
|
||||||
|
* [fea_recopilatorio cat="eucaristia"] (por slug)
|
||||||
|
* [fea_recopilatorio cat="1648" per_page="150" group="year" order="desc"]
|
||||||
|
*
|
||||||
|
* - group="year" (def.): separadores por año. group="none": lista plana.
|
||||||
|
* - Paginación propia (?recop=N) para no chocar con la paginación del tema.
|
||||||
|
* - Títulos normalizados con fea_title() si existe (legacy en MAYÚSCULAS).
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('FEA_RECOP_DEFAULT_PER_PAGE')) {
|
||||||
|
define('FEA_RECOP_DEFAULT_PER_PAGE', 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_recop_resolve_term($cat): int {
|
||||||
|
$cat = trim((string) $cat);
|
||||||
|
if ($cat === '') return 0;
|
||||||
|
if (ctype_digit($cat)) return (int) $cat;
|
||||||
|
$t = get_term_by('slug', $cat, 'category');
|
||||||
|
return $t ? (int) $t->term_id : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_recop_title(string $raw): string {
|
||||||
|
return function_exists('fea_title') ? fea_title($raw) : $raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_recop_render(array $atts = []): string {
|
||||||
|
$atts = shortcode_atts([
|
||||||
|
'cat' => '',
|
||||||
|
'per_page' => (string) FEA_RECOP_DEFAULT_PER_PAGE,
|
||||||
|
'group' => 'year',
|
||||||
|
'order' => 'desc',
|
||||||
|
], $atts, 'fea_recopilatorio');
|
||||||
|
|
||||||
|
$term_id = fea_recop_resolve_term($atts['cat']);
|
||||||
|
if (!$term_id) return '<p class="fea-recop-empty">Recopilatorio no disponible.</p>';
|
||||||
|
|
||||||
|
$per_page = max(20, min(500, (int) $atts['per_page']));
|
||||||
|
$order = strtolower($atts['order']) === 'asc' ? 'ASC' : 'DESC';
|
||||||
|
$paged = isset($_GET['recop']) ? max(1, (int) $_GET['recop']) : 1;
|
||||||
|
|
||||||
|
$q = new WP_Query([
|
||||||
|
'post_type' => 'post',
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'cat' => $term_id,
|
||||||
|
'posts_per_page' => $per_page,
|
||||||
|
'paged' => $paged,
|
||||||
|
'orderby' => 'date',
|
||||||
|
'order' => $order,
|
||||||
|
'ignore_sticky_posts' => true,
|
||||||
|
'no_found_rows' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$q->have_posts()) {
|
||||||
|
wp_reset_postdata();
|
||||||
|
return '<p class="fea-recop-empty">Todavía no hay entradas en esta sección.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$by_year = ($atts['group'] === 'year');
|
||||||
|
$html = '<div class="fea-recop">';
|
||||||
|
$cur_year = null;
|
||||||
|
$open = false;
|
||||||
|
|
||||||
|
while ($q->have_posts()) {
|
||||||
|
$q->the_post();
|
||||||
|
if ($by_year) {
|
||||||
|
$y = get_the_date('Y');
|
||||||
|
if ($y !== $cur_year) {
|
||||||
|
if ($open) $html .= '</ul>';
|
||||||
|
$html .= '<h3 class="fea-recop-year">' . esc_html($y) . '</h3><ul class="fea-recop-list">';
|
||||||
|
$cur_year = $y; $open = true;
|
||||||
|
}
|
||||||
|
} elseif (!$open) {
|
||||||
|
$html .= '<ul class="fea-recop-list">'; $open = true;
|
||||||
|
}
|
||||||
|
$title = fea_recop_title(get_the_title());
|
||||||
|
$html .= '<li><a href="' . esc_url(get_permalink()) . '">' . esc_html($title) . '</a>'
|
||||||
|
. ' <span class="fea-recop-date">' . esc_html(get_the_date('j M Y')) . '</span></li>';
|
||||||
|
}
|
||||||
|
if ($open) $html .= '</ul>';
|
||||||
|
wp_reset_postdata();
|
||||||
|
|
||||||
|
// Paginación propia
|
||||||
|
$total_pages = (int) $q->max_num_pages;
|
||||||
|
if ($total_pages > 1) {
|
||||||
|
$html .= '<nav class="fea-recop-pages" aria-label="Paginación del recopilatorio">';
|
||||||
|
if ($paged > 1) {
|
||||||
|
$html .= '<a href="' . esc_url(add_query_arg('recop', $paged - 1)) . '">Anterior</a>';
|
||||||
|
}
|
||||||
|
$html .= '<span>Página ' . $paged . ' de ' . $total_pages . '</span>';
|
||||||
|
if ($paged < $total_pages) {
|
||||||
|
$html .= '<a href="' . esc_url(add_query_arg('recop', $paged + 1)) . '">Siguiente</a>';
|
||||||
|
}
|
||||||
|
$html .= '</nav>';
|
||||||
|
}
|
||||||
|
$html .= '</div>';
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
add_shortcode('fea_recopilatorio', 'fea_recop_render');
|
||||||
|
|
||||||
|
// ── [fea_multimedia_indice] — galería visual de multimedia (issue #110) ──────
|
||||||
|
// Sustituye la página intermedia /multimedia/ (4 enlaces) por la lista directa
|
||||||
|
// de los artículos de las categorías multimedia, con preview visual (miniatura
|
||||||
|
// de YouTube o primera imagen del contenido) + extracto, para invitar al clic.
|
||||||
|
|
||||||
|
/** Extrae una preview del contenido: ['type'=>'video'|'image'|'none','src'=>url]. */
|
||||||
|
function fea_mm_preview(string $content): array {
|
||||||
|
// 1) Vídeo de YouTube embebido → miniatura hqdefault
|
||||||
|
if (preg_match('~(?:youtube(?:-nocookie)?\.com/(?:embed/|watch\?v=)|youtu\.be/)([A-Za-z0-9_-]{6,})~', $content, $m)) {
|
||||||
|
return ['type' => 'video', 'src' => 'https://img.youtube.com/vi/' . $m[1] . '/hqdefault.jpg'];
|
||||||
|
}
|
||||||
|
// 2) Vimeo → sin thumbnail server-side fiable; marcar como vídeo sin src
|
||||||
|
if (preg_match('~vimeo\.com/(?:video/)?(\d+)~', $content)) {
|
||||||
|
return ['type' => 'video', 'src' => ''];
|
||||||
|
}
|
||||||
|
// 3) Primera imagen del contenido
|
||||||
|
if (preg_match('~<img[^>]+src=["\']([^"\']+)["\']~i', $content, $m)) {
|
||||||
|
return ['type' => 'image', 'src' => $m[1]];
|
||||||
|
}
|
||||||
|
return ['type' => 'none', 'src' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_mm_indice_render(array $atts = []): string {
|
||||||
|
$atts = shortcode_atts([
|
||||||
|
'cats' => '1649,26', // Multimedia + Índice multimedia
|
||||||
|
'per_page' => '24',
|
||||||
|
], $atts, 'fea_multimedia_indice');
|
||||||
|
|
||||||
|
$cats = array_filter(array_map('intval', explode(',', $atts['cats'])));
|
||||||
|
if (!$cats) return '';
|
||||||
|
$per_page = max(6, min(60, (int) $atts['per_page']));
|
||||||
|
$paged = isset($_GET['mmpag']) ? max(1, (int) $_GET['mmpag']) : 1;
|
||||||
|
|
||||||
|
$q = new WP_Query([
|
||||||
|
'post_type' => 'post',
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'category__in' => $cats,
|
||||||
|
'posts_per_page' => $per_page,
|
||||||
|
'paged' => $paged,
|
||||||
|
'orderby' => 'date',
|
||||||
|
'order' => 'DESC',
|
||||||
|
'ignore_sticky_posts' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$q->have_posts()) {
|
||||||
|
wp_reset_postdata();
|
||||||
|
return '<p class="fea-recop-empty">Todavía no hay multimedia disponible.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$html = '<div class="fea-mm-wrap"><div class="fea-mm-grid">';
|
||||||
|
while ($q->have_posts()) {
|
||||||
|
$q->the_post();
|
||||||
|
$content = get_the_content();
|
||||||
|
$prev = fea_mm_preview($content);
|
||||||
|
$title = fea_recop_title(get_the_title());
|
||||||
|
$url = get_permalink();
|
||||||
|
$excerpt = wp_trim_words(trim(preg_replace('/\s+/', ' ', wp_strip_all_tags($content))), 22, '…');
|
||||||
|
|
||||||
|
$thumb = '';
|
||||||
|
if ($prev['src'] !== '') {
|
||||||
|
$thumb = '<img class="fea-mm-img" src="' . esc_url($prev['src']) . '" alt="" loading="lazy" '
|
||||||
|
. 'onerror="this.style.display=\'none\';this.parentNode.classList.add(\'fea-mm-noimg\');">';
|
||||||
|
}
|
||||||
|
$cls = 'fea-mm-thumb' . ($prev['src'] === '' ? ' fea-mm-noimg' : '');
|
||||||
|
$play = $prev['type'] === 'video'
|
||||||
|
? '<span class="fea-mm-play" aria-hidden="true"></span>' : '';
|
||||||
|
|
||||||
|
$html .= '<a class="fea-mm-card" href="' . esc_url($url) . '">'
|
||||||
|
. '<span class="' . $cls . '">' . $thumb . $play . '</span>'
|
||||||
|
. '<span class="fea-mm-body">'
|
||||||
|
. '<span class="fea-mm-title">' . esc_html($title) . '</span>'
|
||||||
|
. '<span class="fea-mm-date">' . esc_html(get_the_date('j M Y')) . '</span>'
|
||||||
|
. '<span class="fea-mm-excerpt">' . esc_html($excerpt) . '</span>'
|
||||||
|
. '</span></a>';
|
||||||
|
}
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
$total_pages = (int) $q->max_num_pages;
|
||||||
|
wp_reset_postdata();
|
||||||
|
if ($total_pages > 1) {
|
||||||
|
$html .= '<nav class="fea-recop-pages" aria-label="Paginación de multimedia">';
|
||||||
|
if ($paged > 1) $html .= '<a href="' . esc_url(add_query_arg('mmpag', $paged - 1)) . '">Anterior</a>';
|
||||||
|
$html .= '<span>Página ' . $paged . ' de ' . $total_pages . '</span>';
|
||||||
|
if ($paged < $total_pages) $html .= '<a href="' . esc_url(add_query_arg('mmpag', $paged + 1)) . '">Siguiente</a>';
|
||||||
|
$html .= '</nav>';
|
||||||
|
}
|
||||||
|
$html .= '</div>'; // .fea-mm-wrap
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
add_shortcode('fea_multimedia_indice', 'fea_mm_indice_render');
|
||||||
|
|
||||||
|
add_action('wp_head', function () {
|
||||||
|
if (is_admin()) return;
|
||||||
|
?>
|
||||||
|
<style id="fea-recop-css">
|
||||||
|
.fea-recop { margin: 1.5rem 0; }
|
||||||
|
.fea-recop-year {
|
||||||
|
font-family: 'Fraunces', Georgia, serif; font-weight: 600;
|
||||||
|
color: #8b1a2e; margin: 1.6rem 0 0.6rem; font-size: 1.3rem;
|
||||||
|
border-bottom: 1px solid #efe7d8; padding-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.fea-recop-list { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.fea-recop-list li {
|
||||||
|
padding: 0.35rem 0; border-bottom: 1px solid #f4eee2;
|
||||||
|
display: flex; justify-content: space-between; gap: 1rem; align-items: baseline;
|
||||||
|
}
|
||||||
|
.fea-recop-list a { text-decoration: none; color: #2a2320; }
|
||||||
|
.fea-recop-list a:hover { color: #8b1a2e; text-decoration: underline; }
|
||||||
|
.fea-recop-date { color: #998; font-size: 0.82rem; white-space: nowrap; }
|
||||||
|
.fea-recop-pages {
|
||||||
|
display: flex; gap: 1rem; align-items: center; justify-content: center;
|
||||||
|
margin-top: 1.4rem; font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.fea-recop-pages a {
|
||||||
|
padding: 0.4rem 0.8rem; border: 1px solid #8b1a2e; border-radius: 6px;
|
||||||
|
text-decoration: none; color: #8b1a2e;
|
||||||
|
}
|
||||||
|
/* Galería multimedia (#110) */
|
||||||
|
/* wrapper: el padre es entry-content alignfull (ancho completo), así que basta
|
||||||
|
centrar con margin:auto; max-width:none vence el cap de is-layout-constrained */
|
||||||
|
.fea-mm-wrap {
|
||||||
|
width: min(1180px, 100%);
|
||||||
|
max-width: none;
|
||||||
|
margin: 1.5rem auto 2rem;
|
||||||
|
}
|
||||||
|
.fea-mm-grid {
|
||||||
|
display: grid; gap: 1.3rem;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
@media (min-width: 520px) { .fea-mm-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||||
|
@media (min-width: 760px) { .fea-mm-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||||||
|
@media (max-width: 700px) { .fea-mm-wrap { width: min(100%, calc(100vw - 1rem)); } }
|
||||||
|
.fea-mm-card {
|
||||||
|
display: flex; flex-direction: column; text-decoration: none;
|
||||||
|
background: #fff; border: 1px solid #efe7d8; border-radius: 10px;
|
||||||
|
overflow: hidden; transition: box-shadow .15s, transform .15s;
|
||||||
|
}
|
||||||
|
.fea-mm-card:hover { box-shadow: 0 6px 18px rgba(139,26,46,.13); transform: translateY(-2px); }
|
||||||
|
.fea-mm-thumb {
|
||||||
|
position: relative; display: block; aspect-ratio: 16/9; background: #f4eee2;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.fea-mm-img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.fea-mm-noimg {
|
||||||
|
background: linear-gradient(135deg, #8b1a2e 0%, #b34255 100%);
|
||||||
|
}
|
||||||
|
.fea-mm-play {
|
||||||
|
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||||
|
width: 54px; height: 54px; border-radius: 50%;
|
||||||
|
background: rgba(0,0,0,.55); pointer-events: none;
|
||||||
|
}
|
||||||
|
.fea-mm-play::after {
|
||||||
|
content: ''; position: absolute; top: 50%; left: 54%; transform: translate(-50%, -50%);
|
||||||
|
border-style: solid; border-width: 11px 0 11px 18px;
|
||||||
|
border-color: transparent transparent transparent #fff;
|
||||||
|
}
|
||||||
|
.fea-mm-card:hover .fea-mm-play { background: rgba(139,26,46,.85); }
|
||||||
|
.fea-mm-body { padding: 0.8rem 0.9rem 1rem; display: flex; flex-direction: column; gap: 0.3rem; }
|
||||||
|
.fea-mm-title {
|
||||||
|
font-family: 'Fraunces', Georgia, serif; font-weight: 600; color: #2a2320;
|
||||||
|
font-size: 1.02rem; line-height: 1.25;
|
||||||
|
}
|
||||||
|
.fea-mm-card:hover .fea-mm-title { color: #8b1a2e; }
|
||||||
|
.fea-mm-date { color: #998; font-size: 0.78rem; }
|
||||||
|
.fea-mm-excerpt { color: #5a534e; font-size: 0.85rem; line-height: 1.4; margin-top: 0.15rem; }
|
||||||
|
</style>
|
||||||
|
<?php
|
||||||
|
}, 26);
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fe Adulta — Buscador avanzado (#8)
|
||||||
|
* Description: Replica el «Buscador avanzado» K2 de Joomla con WordPress nativo.
|
||||||
|
* Filtros: palabra (FULLTEXT vía fea-search-fulltext.php), autor, tema
|
||||||
|
* (categoría), cita bíblica (_cita_evangelio), fecha.
|
||||||
|
* Formulario visible en la página de resultados (search template) y en
|
||||||
|
* la página dedicada /buscar. Multiidioma (Polylang).
|
||||||
|
* Version: 1.0
|
||||||
|
*/
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
// Constantes de configuración
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IDs de usuarios a excluir del selector de autores.
|
||||||
|
* 1,890,1049,1540 = cuentas técnicas/admin.
|
||||||
|
* 408,409,1563..1570 = pseudo-autores "Nuevo/Antiguo Testamento" (y sus traducciones)
|
||||||
|
* que NO son personas. Además, abajo se excluye cualquier display_name que contenga
|
||||||
|
* "Testament" de forma robusta (por si aparecen nuevos IDs).
|
||||||
|
*/
|
||||||
|
defined('FEA_AUTORES_EXCLUIR') or define('FEA_AUTORES_EXCLUIR', [
|
||||||
|
1, 890, 1049, 1540,
|
||||||
|
408, 409, 1563, 1564, 1565, 1566, 1567, 1568, 1569, 1570,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categorías de ESTADO DE CARTA a excluir del selector de categorías:
|
||||||
|
* 6 (cartasemana), 21 (cartas-de-otras-semanas), 22 (carta-semana-pasada).
|
||||||
|
*/
|
||||||
|
defined('FEA_CATS_CARTA_EXCLUIR') or define('FEA_CATS_CARTA_EXCLUIR', [6, 21, 22]);
|
||||||
|
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
// i18n mínimo (es / en / fr / it / pt)
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function fea_adv_t(string $key): string {
|
||||||
|
$lang = function_exists('pll_current_language') ? pll_current_language() : 'es';
|
||||||
|
$strings = [
|
||||||
|
'search_advanced' => ['es' => 'Búsqueda avanzada', 'en' => 'Advanced search',
|
||||||
|
'fr' => 'Recherche avancée', 'it' => 'Ricerca avanzata', 'pt' => 'Pesquisa avançada'],
|
||||||
|
'word' => ['es' => 'Palabra o frase', 'en' => 'Word or phrase',
|
||||||
|
'fr' => 'Mot ou phrase', 'it' => 'Parola o frase', 'pt' => 'Palavra ou frase'],
|
||||||
|
'author' => ['es' => 'Autor', 'en' => 'Author',
|
||||||
|
'fr' => 'Auteur', 'it' => 'Autore', 'pt' => 'Autor'],
|
||||||
|
'all_authors' => ['es' => '— Cualquier autor —', 'en' => '— Any author —',
|
||||||
|
'fr' => '— Tout auteur —', 'it' => '— Qualsiasi autore —', 'pt' => '— Qualquer autor —'],
|
||||||
|
'topic' => ['es' => 'Categoría', 'en' => 'Category',
|
||||||
|
'fr' => 'Catégorie', 'it' => 'Categoria', 'pt' => 'Categoria'],
|
||||||
|
'all_topics' => ['es' => '— Cualquier categoría —', 'en' => '— Any category —',
|
||||||
|
'fr' => '— Toute catégorie —', 'it' => '— Qualsiasi categoria —', 'pt' => '— Qualquer categoria —'],
|
||||||
|
'biblical_ref' => ['es' => 'Cita bíblica', 'en' => 'Biblical reference',
|
||||||
|
'fr' => 'Référence biblique', 'it' => 'Citazione biblica', 'pt' => 'Referência bíblica'],
|
||||||
|
'biblical_ph' => ['es' => 'Ej: Jn 3', 'en' => 'E.g. Jn 3',
|
||||||
|
'fr' => 'Ex: Jn 3', 'it' => 'Es: Gv 3', 'pt' => 'Ex: Jo 3'],
|
||||||
|
'date_from' => ['es' => 'Desde', 'en' => 'From',
|
||||||
|
'fr' => 'Du', 'it' => 'Dal', 'pt' => 'De'],
|
||||||
|
'date_to' => ['es' => 'Hasta', 'en' => 'To',
|
||||||
|
'fr' => "Jusqu'au", 'it' => 'Al', 'pt' => 'Até'],
|
||||||
|
'search_btn' => ['es' => 'Buscar', 'en' => 'Search',
|
||||||
|
'fr' => 'Rechercher', 'it' => 'Cerca', 'pt' => 'Pesquisar'],
|
||||||
|
'reset_btn' => ['es' => 'Limpiar', 'en' => 'Clear',
|
||||||
|
'fr' => 'Effacer', 'it' => 'Cancella', 'pt' => 'Limpar'],
|
||||||
|
'results' => ['es' => 'resultado(s)', 'en' => 'result(s)',
|
||||||
|
'fr' => 'résultat(s)', 'it' => 'risultato/i', 'pt' => 'resultado(s)'],
|
||||||
|
'no_results' => ['es' => 'Sin resultados. Prueba con otros términos.',
|
||||||
|
'en' => 'No results. Try other terms.',
|
||||||
|
'fr' => 'Aucun résultat. Essayez d\'autres termes.',
|
||||||
|
'it' => 'Nessun risultato. Prova con altri termini.',
|
||||||
|
'pt' => 'Sem resultados. Tente outros termos.'],
|
||||||
|
'active_filters' => ['es' => 'Filtros activos:', 'en' => 'Active filters:',
|
||||||
|
'fr' => 'Filtres actifs:', 'it' => 'Filtri attivi:', 'pt' => 'Filtros ativos:'],
|
||||||
|
'filter_author' => ['es' => 'Autor', 'en' => 'Author',
|
||||||
|
'fr' => 'Auteur', 'it' => 'Autore', 'pt' => 'Autor'],
|
||||||
|
'filter_topic' => ['es' => 'Categoría', 'en' => 'Category',
|
||||||
|
'fr' => 'Catégorie', 'it' => 'Categoria', 'pt' => 'Categoria'],
|
||||||
|
'filter_cita' => ['es' => 'Cita', 'en' => 'Ref.',
|
||||||
|
'fr' => 'Réf.', 'it' => 'Cit.', 'pt' => 'Ref.'],
|
||||||
|
'filter_date' => ['es' => 'Fecha', 'en' => 'Date',
|
||||||
|
'fr' => 'Date', 'it' => 'Data', 'pt' => 'Data'],
|
||||||
|
'by' => ['es' => 'por', 'en' => 'by',
|
||||||
|
'fr' => 'par', 'it' => 'di', 'pt' => 'por'],
|
||||||
|
];
|
||||||
|
$row = $strings[$key] ?? [];
|
||||||
|
return $row[$lang] ?? $row['es'] ?? $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
// Query vars
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
add_filter('query_vars', function (array $vars): array {
|
||||||
|
$vars[] = 'fea_author';
|
||||||
|
$vars[] = 'fea_cat';
|
||||||
|
$vars[] = 'fea_cita';
|
||||||
|
$vars[] = 'fea_date_from';
|
||||||
|
$vars[] = 'fea_date_to';
|
||||||
|
return $vars;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
// pre_get_posts — aplica los filtros avanzados
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
add_action('pre_get_posts', function (WP_Query $q): void {
|
||||||
|
if (is_admin() || !$q->is_main_query()) return;
|
||||||
|
|
||||||
|
// Leer los parámetros avanzados desde $_GET directamente (más fiable en pre_get_posts)
|
||||||
|
$fea_author = isset($_GET['fea_author']) ? (int)$_GET['fea_author'] : 0;
|
||||||
|
$fea_cat = isset($_GET['fea_cat']) ? (int)$_GET['fea_cat'] : 0;
|
||||||
|
$fea_cita = isset($_GET['fea_cita']) ? sanitize_text_field($_GET['fea_cita']) : '';
|
||||||
|
$fea_dfr = isset($_GET['fea_date_from']) ? sanitize_text_field($_GET['fea_date_from']) : '';
|
||||||
|
$fea_dto = isset($_GET['fea_date_to']) ? sanitize_text_field($_GET['fea_date_to']) : '';
|
||||||
|
|
||||||
|
$has_adv = ($fea_author > 0 || $fea_cat > 0 || $fea_cita !== '' || $fea_dfr !== '' || $fea_dto !== '');
|
||||||
|
$is_search = $q->is_search();
|
||||||
|
|
||||||
|
// Activar si: es búsqueda, o si hay vars avanzadas (con o sin ?s=)
|
||||||
|
if (!$is_search && !$has_adv) return;
|
||||||
|
|
||||||
|
// Si hay filtros avanzados pero no ?s=, convertimos el query en listado de posts
|
||||||
|
// (evitamos que WP muestre la home o una 404)
|
||||||
|
if ($has_adv && !$is_search) {
|
||||||
|
$q->set('post_type', 'post');
|
||||||
|
$q->set('post_status', 'publish');
|
||||||
|
// Forzamos is_search para que el template search se active
|
||||||
|
$q->is_home = false;
|
||||||
|
$q->is_front_page = false;
|
||||||
|
$q->is_archive = false;
|
||||||
|
$q->is_search = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autor
|
||||||
|
if ($fea_author > 0) $q->set('author', $fea_author);
|
||||||
|
|
||||||
|
// Categoría (tema)
|
||||||
|
if ($fea_cat > 0) $q->set('cat', $fea_cat);
|
||||||
|
|
||||||
|
// Cita bíblica: coincidencia por PREFIJO (el valor empieza por el término, ej. "Jn").
|
||||||
|
// Usamos REGEXP '^<term>' con el término escapado para evitar metacaracteres.
|
||||||
|
if ($fea_cita !== '') {
|
||||||
|
$regex = '^' . preg_quote($fea_cita, '/');
|
||||||
|
$q->set('meta_query', [
|
||||||
|
[
|
||||||
|
'key' => '_cita_evangelio',
|
||||||
|
'value' => $regex,
|
||||||
|
'compare' => 'REGEXP',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fechas: la UI usa <input type="date"> → formato YYYY-MM-DD (fecha completa).
|
||||||
|
// Tratamos los límites como fechas exactas, inclusive.
|
||||||
|
if ($fea_dfr !== '' || $fea_dto !== '') {
|
||||||
|
$date_query = ['inclusive' => true];
|
||||||
|
if ($fea_dfr !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $fea_dfr)) {
|
||||||
|
[$y, $m, $d] = array_map('intval', explode('-', $fea_dfr));
|
||||||
|
$date_query['after'] = ['year' => $y, 'month' => $m, 'day' => $d];
|
||||||
|
}
|
||||||
|
if ($fea_dto !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $fea_dto)) {
|
||||||
|
[$y, $m, $d] = array_map('intval', explode('-', $fea_dto));
|
||||||
|
$date_query['before'] = ['year' => $y, 'month' => $m, 'day' => $d];
|
||||||
|
}
|
||||||
|
// Sólo aplicamos si quedó al menos un límite válido
|
||||||
|
if (isset($date_query['after']) || isset($date_query['before'])) {
|
||||||
|
$q->set('date_query', [$date_query]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
// Helpers: obtener datos del formulario
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Devuelve los autores elegibles (≥30 posts, sin excluidos), cacheado por request. */
|
||||||
|
function fea_adv_get_authors(): array {
|
||||||
|
static $cache = null;
|
||||||
|
if ($cache !== null) return $cache;
|
||||||
|
global $wpdb;
|
||||||
|
$excl = implode(',', array_map('intval', FEA_AUTORES_EXCLUIR));
|
||||||
|
// Excluimos por ID y, de forma robusta, cualquier display_name que contenga "Testament"
|
||||||
|
// (Nuevo/Antiguo Testamento, New/Old Testament, Nouveau/Ancien Testament, etc.).
|
||||||
|
$cache = $wpdb->get_results("
|
||||||
|
SELECT u.ID, u.display_name, COUNT(p.ID) as cnt
|
||||||
|
FROM {$wpdb->users} u
|
||||||
|
JOIN {$wpdb->posts} p ON p.post_author = u.ID
|
||||||
|
WHERE p.post_status = 'publish'
|
||||||
|
AND p.post_type = 'post'
|
||||||
|
AND u.ID NOT IN ({$excl})
|
||||||
|
AND u.display_name NOT LIKE '%Testament%'
|
||||||
|
GROUP BY u.ID
|
||||||
|
HAVING cnt >= 30
|
||||||
|
ORDER BY u.display_name
|
||||||
|
");
|
||||||
|
return $cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lista CURADA de categorías-sección reales (term_ids ES, idioma canónico), en orden
|
||||||
|
* editorial. Las 153 categorías reales de la BD incluyen categorías-autor y residuales
|
||||||
|
* de la migración K2 → desplegable inmanejable; por eso se cura a las secciones del sitio.
|
||||||
|
* En idiomas ≠ ES se traduce cada term con Polylang.
|
||||||
|
*/
|
||||||
|
defined('FEA_CATS_CURADA') or define('FEA_CATS_CURADA', [
|
||||||
|
1650, // Artículos
|
||||||
|
1647, // Comentarios al evangelio
|
||||||
|
1645, // Lecturas bíblicas
|
||||||
|
1648, // Eucaristía
|
||||||
|
1646, // Comentario editorial
|
||||||
|
1649, // Multimedia
|
||||||
|
63, // EFFA
|
||||||
|
14, // A la fuente cada día
|
||||||
|
23, // Cartas que nos llegan
|
||||||
|
41, // Noticias de alcance
|
||||||
|
24, // Tablón de anuncios
|
||||||
|
54, // Canciones religiosas
|
||||||
|
45, // Canciones-plegarias
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Etiqueta bonita en el desplegable (solo ES) para categorías cuyo nombre en BD quedó
|
||||||
|
* sin formatear en la migración K2 (no toca el dato del término).
|
||||||
|
*/
|
||||||
|
defined('FEA_CATS_LABEL') or define('FEA_CATS_LABEL', [
|
||||||
|
14 => 'A la fuente cada día', // en BD es "Alafuentecadadia"
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devuelve las categorías del selector (lista curada FEA_CATS_CURADA), traducidas al
|
||||||
|
* idioma activo (Polylang) y conservando el orden editorial.
|
||||||
|
* Devuelve array de objetos {term_id, name} con el term_id DEL IDIOMA ACTUAL.
|
||||||
|
*/
|
||||||
|
function fea_adv_get_categories(): array {
|
||||||
|
static $cache = null;
|
||||||
|
if ($cache !== null) return $cache;
|
||||||
|
|
||||||
|
$lang = function_exists('pll_current_language') ? pll_current_language() : '';
|
||||||
|
$default = function_exists('pll_default_language') ? pll_default_language() : 'es';
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach (FEA_CATS_CURADA as $es_id) {
|
||||||
|
$tid = (int) $es_id;
|
||||||
|
if ($lang && $lang !== $default && function_exists('pll_get_term')) {
|
||||||
|
$tr = pll_get_term($es_id, $lang);
|
||||||
|
if ($tr) $tid = (int) $tr;
|
||||||
|
}
|
||||||
|
$term = get_term($tid, 'category');
|
||||||
|
if (!$term || is_wp_error($term)) continue;
|
||||||
|
$name = $term->name;
|
||||||
|
if ((!$lang || $lang === $default) && isset(FEA_CATS_LABEL[$es_id])) {
|
||||||
|
$name = FEA_CATS_LABEL[$es_id];
|
||||||
|
}
|
||||||
|
$out[] = (object) ['term_id' => (int) $term->term_id, 'name' => $name];
|
||||||
|
}
|
||||||
|
$cache = $out;
|
||||||
|
return $cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL base del idioma actual (para el action del form). */
|
||||||
|
function fea_adv_lang_base(): string {
|
||||||
|
$base = home_url('/');
|
||||||
|
if (function_exists('pll_current_language')) {
|
||||||
|
$lang = pll_current_language();
|
||||||
|
$default = function_exists('pll_default_language') ? pll_default_language() : 'es';
|
||||||
|
if ($lang && $lang !== $default) $base = home_url('/' . $lang . '/');
|
||||||
|
}
|
||||||
|
return $base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nombre traducido de una categoría (vía Polylang si está disponible). */
|
||||||
|
function fea_adv_cat_name(int $cat_id, string $fallback): string {
|
||||||
|
if (function_exists('pll_current_language')) {
|
||||||
|
$lang = pll_current_language();
|
||||||
|
$default = function_exists('pll_default_language') ? pll_default_language() : 'es';
|
||||||
|
if ($lang !== $default) {
|
||||||
|
$translated_id = function_exists('pll_get_term') ? pll_get_term($cat_id, $lang) : 0;
|
||||||
|
if ($translated_id) {
|
||||||
|
$term = get_term($translated_id);
|
||||||
|
if ($term && !is_wp_error($term)) return $term->name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$term = get_term($cat_id, 'category');
|
||||||
|
return ($term && !is_wp_error($term)) ? $term->name : $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
// Renderiza el formulario avanzado
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function fea_adv_form_html(): string {
|
||||||
|
$action = esc_url(fea_adv_lang_base());
|
||||||
|
$s = esc_attr(get_search_query());
|
||||||
|
// Leer de $_GET para fiabilidad (get_query_var puede llegar vacío si la var no estaba registrada aún)
|
||||||
|
$sel_aut = isset($_GET['fea_author']) ? (int)$_GET['fea_author'] : 0;
|
||||||
|
$sel_cat = isset($_GET['fea_cat']) ? (int)$_GET['fea_cat'] : 0;
|
||||||
|
$sel_cit = isset($_GET['fea_cita']) ? esc_attr(sanitize_text_field($_GET['fea_cita'])) : '';
|
||||||
|
$sel_dfr = isset($_GET['fea_date_from'])? esc_attr(sanitize_text_field($_GET['fea_date_from'])) : '';
|
||||||
|
$sel_dto = isset($_GET['fea_date_to']) ? esc_attr(sanitize_text_field($_GET['fea_date_to'])) : '';
|
||||||
|
|
||||||
|
$authors = fea_adv_get_authors();
|
||||||
|
$cats = fea_adv_get_categories();
|
||||||
|
|
||||||
|
$t_title = esc_html(fea_adv_t('search_advanced'));
|
||||||
|
$t_word = esc_html(fea_adv_t('word'));
|
||||||
|
$t_author = esc_html(fea_adv_t('author'));
|
||||||
|
$t_allaut = esc_html(fea_adv_t('all_authors'));
|
||||||
|
$t_topic = esc_html(fea_adv_t('topic'));
|
||||||
|
$t_alltop = esc_html(fea_adv_t('all_topics'));
|
||||||
|
$t_cita = esc_html(fea_adv_t('biblical_ref'));
|
||||||
|
$t_citaph = esc_attr(fea_adv_t('biblical_ph'));
|
||||||
|
$t_dfr = esc_html(fea_adv_t('date_from'));
|
||||||
|
$t_dto = esc_html(fea_adv_t('date_to'));
|
||||||
|
$t_btn = esc_html(fea_adv_t('search_btn'));
|
||||||
|
$t_reset = esc_html(fea_adv_t('reset_btn'));
|
||||||
|
|
||||||
|
$html = '<div class="fea-adv-wrap" id="fea-adv-search">';
|
||||||
|
$html .= '<details class="fea-adv-details" open>';
|
||||||
|
$html .= '<summary class="fea-adv-summary">' . $t_title . '</summary>';
|
||||||
|
$html .= '<form class="fea-adv-form" method="get" action="' . $action . '">';
|
||||||
|
|
||||||
|
// Fila 1: Palabra
|
||||||
|
$html .= '<div class="fea-adv-row">';
|
||||||
|
$html .= '<label class="fea-adv-label" for="fea-s">' . $t_word . '</label>';
|
||||||
|
$html .= '<input class="fea-adv-input" id="fea-s" type="search" name="s" value="' . $s . '" autocomplete="off">';
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
// Fila 2: Autor
|
||||||
|
$html .= '<div class="fea-adv-row">';
|
||||||
|
$html .= '<label class="fea-adv-label" for="fea-author">' . $t_author . '</label>';
|
||||||
|
$html .= '<select class="fea-adv-select" id="fea-author" name="fea_author">';
|
||||||
|
$html .= '<option value="">' . $t_allaut . '</option>';
|
||||||
|
foreach ($authors as $a) {
|
||||||
|
$sel = selected($sel_aut, (int)$a->ID, false);
|
||||||
|
$name = esc_html($a->display_name);
|
||||||
|
$html .= "<option value=\"{$a->ID}\"{$sel}>{$name}</option>";
|
||||||
|
}
|
||||||
|
$html .= '</select></div>';
|
||||||
|
|
||||||
|
// Fila 3: Categoría (real, dinámica)
|
||||||
|
$html .= '<div class="fea-adv-row">';
|
||||||
|
$html .= '<label class="fea-adv-label" for="fea-cat">' . $t_topic . '</label>';
|
||||||
|
$html .= '<select class="fea-adv-select" id="fea-cat" name="fea_cat">';
|
||||||
|
$html .= '<option value="">' . $t_alltop . '</option>';
|
||||||
|
foreach ($cats as $c) {
|
||||||
|
$cat_name = esc_html($c->name);
|
||||||
|
$sel = selected($sel_cat, $c->term_id, false);
|
||||||
|
$html .= "<option value=\"{$c->term_id}\"{$sel}>{$cat_name}</option>";
|
||||||
|
}
|
||||||
|
$html .= '</select></div>';
|
||||||
|
|
||||||
|
// Fila 4: Cita bíblica
|
||||||
|
$html .= '<div class="fea-adv-row">';
|
||||||
|
$html .= '<label class="fea-adv-label" for="fea-cita">' . $t_cita . '</label>';
|
||||||
|
$html .= '<input class="fea-adv-input" id="fea-cita" type="text" name="fea_cita" value="' . $sel_cit . '" placeholder="' . $t_citaph . '">';
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
// Fila 5: Fechas
|
||||||
|
$html .= '<div class="fea-adv-row fea-adv-dates">';
|
||||||
|
$html .= '<span class="fea-adv-label">' . $t_dfr . '</span>';
|
||||||
|
$html .= '<input class="fea-adv-input fea-adv-date" type="date" name="fea_date_from" value="' . $sel_dfr . '">';
|
||||||
|
$html .= '<span class="fea-adv-label fea-adv-to">' . $t_dto . '</span>';
|
||||||
|
$html .= '<input class="fea-adv-input fea-adv-date" type="date" name="fea_date_to" value="' . $sel_dto . '">';
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
// Botones
|
||||||
|
$html .= '<div class="fea-adv-actions">';
|
||||||
|
$html .= '<button class="fea-adv-btn fea-adv-btn-primary" type="submit">' . $t_btn . '</button>';
|
||||||
|
$html .= '<a class="fea-adv-btn fea-adv-btn-secondary" href="' . $action . '">' . $t_reset . '</a>';
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
$html .= '</form></details></div>';
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
// Chips de filtros activos + contador de resultados
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function fea_adv_chips_html(): string {
|
||||||
|
$chips = [];
|
||||||
|
|
||||||
|
$aut_id = isset($_GET['fea_author']) ? (int)$_GET['fea_author'] : 0;
|
||||||
|
if ($aut_id > 0) {
|
||||||
|
$udata = get_userdata($aut_id);
|
||||||
|
$name = $udata ? esc_html($udata->display_name) : $aut_id;
|
||||||
|
$chips[] = fea_adv_chip(fea_adv_t('filter_author') . ': ' . $name, 'fea_author');
|
||||||
|
}
|
||||||
|
|
||||||
|
$cat_id = isset($_GET['fea_cat']) ? (int)$_GET['fea_cat'] : 0;
|
||||||
|
if ($cat_id > 0) {
|
||||||
|
$term = get_term($cat_id, 'category');
|
||||||
|
$name = ($term && !is_wp_error($term)) ? esc_html($term->name) : $cat_id;
|
||||||
|
$chips[] = fea_adv_chip(fea_adv_t('filter_topic') . ': ' . $name, 'fea_cat');
|
||||||
|
}
|
||||||
|
|
||||||
|
$cita = isset($_GET['fea_cita']) ? sanitize_text_field($_GET['fea_cita']) : '';
|
||||||
|
if ($cita !== '') {
|
||||||
|
$chips[] = fea_adv_chip(fea_adv_t('filter_cita') . ': ' . esc_html($cita), 'fea_cita');
|
||||||
|
}
|
||||||
|
|
||||||
|
$dfr = isset($_GET['fea_date_from']) ? sanitize_text_field($_GET['fea_date_from']) : '';
|
||||||
|
$dto = isset($_GET['fea_date_to']) ? sanitize_text_field($_GET['fea_date_to']) : '';
|
||||||
|
if ($dfr !== '' || $dto !== '') {
|
||||||
|
$label = fea_adv_t('filter_date') . ': ' . ($dfr ?: '?') . ' – ' . ($dto ?: '?');
|
||||||
|
$chips[] = fea_adv_chip(esc_html($label), 'fea_date_from', 'fea_date_to');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($chips)) return '';
|
||||||
|
return '<div class="fea-adv-chips"><span class="fea-adv-chips-label">' .
|
||||||
|
esc_html(fea_adv_t('active_filters')) . '</span>' . implode('', $chips) . '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Genera un chip con botón ✕ que elimina el filtro de la URL. */
|
||||||
|
function fea_adv_chip(string $label, string ...$remove_params): string {
|
||||||
|
$url = remove_query_arg($remove_params);
|
||||||
|
return '<span class="fea-adv-chip">' . $label .
|
||||||
|
' <a href="' . esc_url($url) . '" class="fea-adv-chip-x" aria-label="Eliminar filtro">×</a></span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
// Inyección en la página de resultados (template search)
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inyecta el formulario avanzado + chips antes del primer bloque wp:query-title
|
||||||
|
* del template de búsqueda, modificando el HTML renderizado del bloque principal.
|
||||||
|
*/
|
||||||
|
add_filter('render_block', function (string $html, array $block): string {
|
||||||
|
if (is_admin()) return $html;
|
||||||
|
|
||||||
|
// Inyectar en búsquedas y cuando hay filtros avanzados activos
|
||||||
|
$has_adv_get = !empty($_GET['fea_author']) || !empty($_GET['fea_cat']) ||
|
||||||
|
!empty($_GET['fea_cita']) || !empty($_GET['fea_date_from']) || !empty($_GET['fea_date_to']);
|
||||||
|
|
||||||
|
if (!is_search() && !is_page('buscar') && !$has_adv_get) return $html;
|
||||||
|
|
||||||
|
if (($block['blockName'] ?? '') !== 'core/query-title') return $html;
|
||||||
|
|
||||||
|
$form = fea_adv_form_html();
|
||||||
|
$chips = fea_adv_chips_html();
|
||||||
|
|
||||||
|
// Contador de resultados (sólo cuando hay consulta activa)
|
||||||
|
$counter = '';
|
||||||
|
if (is_search() || $has_adv_get) {
|
||||||
|
global $wp_query;
|
||||||
|
if ($wp_query && $wp_query->found_posts !== null) {
|
||||||
|
$n = (int) $wp_query->found_posts;
|
||||||
|
$counter = '<div class="fea-adv-count">' . $n . ' ' . esc_html(fea_adv_t('results')) . '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $form . $chips . $counter . $html;
|
||||||
|
}, 10, 2);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Añade byline (autor) en cada tarjeta fea-archive-card dentro del template search.
|
||||||
|
* Lo hacemos inyectando tras wp:post-date en el contexto correcto.
|
||||||
|
*/
|
||||||
|
add_filter('render_block', function (string $html, array $block): string {
|
||||||
|
if (is_admin()) return $html;
|
||||||
|
$has_adv_get2 = !empty($_GET['fea_author']) || !empty($_GET['fea_cat']) ||
|
||||||
|
!empty($_GET['fea_cita']) || !empty($_GET['fea_date_from']) || !empty($_GET['fea_date_to']);
|
||||||
|
if (!is_search() && !is_page('buscar') && !$has_adv_get2) return $html;
|
||||||
|
if (($block['blockName'] ?? '') !== 'core/post-date') return $html;
|
||||||
|
|
||||||
|
$post_id = $block['attrs']['postId'] ?? (in_the_loop() ? get_the_ID() : 0);
|
||||||
|
if (!$post_id) $post_id = get_the_ID();
|
||||||
|
if (!$post_id) return $html;
|
||||||
|
|
||||||
|
$author_id = (int) get_post_field('post_author', $post_id);
|
||||||
|
$author_name = get_the_author_meta('display_name', $author_id);
|
||||||
|
if (!$author_name) return $html;
|
||||||
|
|
||||||
|
$author_url = get_author_posts_url($author_id);
|
||||||
|
$byline = '<div class="fea-adv-byline">' . esc_html(fea_adv_t('by')) . ' ' .
|
||||||
|
'<a href="' . esc_url($author_url) . '">' . esc_html($author_name) . '</a></div>';
|
||||||
|
|
||||||
|
return $html . $byline;
|
||||||
|
}, 10, 2);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
// Enlace «Búsqueda avanzada» desde la barra fea-search
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
add_filter('render_block', function (string $html, array $block): string {
|
||||||
|
if (is_admin()) return $html;
|
||||||
|
if (($block['blockName'] ?? '') !== 'core/template-part') return $html;
|
||||||
|
$slug = $block['attrs']['slug'] ?? '';
|
||||||
|
if (!in_array($slug, ['header', 'cabecera-portada'], true)) return $html;
|
||||||
|
|
||||||
|
// Sólo inyectamos el enlace si ya hay una barra de búsqueda (.fea-search-bar)
|
||||||
|
// Buscamos la barra y le añadimos el enlace de búsqueda avanzada.
|
||||||
|
if (strpos($html, 'fea-search-bar') === false) return $html;
|
||||||
|
|
||||||
|
$adv_url = home_url('/buscar/');
|
||||||
|
if (function_exists('pll_current_language')) {
|
||||||
|
$lang = pll_current_language();
|
||||||
|
$default = function_exists('pll_default_language') ? pll_default_language() : 'es';
|
||||||
|
if ($lang && $lang !== $default) {
|
||||||
|
// Intenta obtener la página /buscar traducida
|
||||||
|
$page = get_page_by_path('buscar');
|
||||||
|
if ($page) {
|
||||||
|
$tl = function_exists('pll_get_post') ? pll_get_post($page->ID, $lang) : 0;
|
||||||
|
if ($tl) $adv_url = get_permalink($tl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = esc_html(fea_adv_t('search_advanced'));
|
||||||
|
$link = '<div class="fea-adv-link-wrap"><a class="fea-adv-link" href="' . esc_url($adv_url) . '">' . $label . '</a></div>';
|
||||||
|
|
||||||
|
// Insertamos el enlace justo al final del bloque .fea-search-bar (cerrando el div externo)
|
||||||
|
// fea-search.php genera: <div class="fea-search-bar"><form ...>...</form></div>
|
||||||
|
// Reemplazamos la ÚLTIMA ocurrencia del cierre del div de .fea-search-bar
|
||||||
|
$marker = '</div>';
|
||||||
|
$pos = strpos($html, 'fea-search-bar');
|
||||||
|
if ($pos !== false) {
|
||||||
|
// Buscamos el </div> que cierra .fea-search-bar (el wrapper externo)
|
||||||
|
// La estructura es: <div class="fea-search-bar"><form>...</form></div>
|
||||||
|
// Hay dos </div>: uno cierra el form y otro cierra .fea-search-bar
|
||||||
|
// Usamos una sustitución segura: buscamos el patrón exacto del cierre
|
||||||
|
$html = preg_replace('#(</form></div>)#', '$1' . $link, $html, 1);
|
||||||
|
}
|
||||||
|
return $html;
|
||||||
|
}, 25, 2);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
// Página /buscar — inyectar formulario vía the_content
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
add_filter('the_content', function (string $content): string {
|
||||||
|
if (!is_page('buscar')) return $content;
|
||||||
|
// Envoltura: título + formulario + contenido original de la página
|
||||||
|
$form = fea_adv_form_html();
|
||||||
|
return $form . '<div class="fea-buscar-intro">' . $content . '</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nota: la página /buscar se crea con scripts/create_buscar_page.php (ya ejecutado).
|
||||||
|
// El formulario se inyecta en the_content (hook arriba) y vía render_block en search.
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
// CSS
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
add_action('wp_head', function (): void {
|
||||||
|
if (!is_search() && !is_page('buscar') &&
|
||||||
|
empty($_GET['fea_author']) && empty($_GET['fea_cat']) &&
|
||||||
|
empty($_GET['fea_cita']) && empty($_GET['fea_date_from']) && empty($_GET['fea_date_to'])) return;
|
||||||
|
?>
|
||||||
|
<style>
|
||||||
|
/* ── Formulario buscador avanzado ─────────────────────────────── */
|
||||||
|
.fea-adv-wrap{max-width:860px;margin:0 auto 1.5rem;padding:0 1rem}
|
||||||
|
|
||||||
|
.fea-adv-details{background:#faf6f7;border:1px solid #e8d5da;border-radius:8px;overflow:hidden}
|
||||||
|
.fea-adv-summary{
|
||||||
|
padding:.75rem 1.2rem;font-weight:600;font-size:1rem;color:#8b1a2e;
|
||||||
|
cursor:pointer;list-style:none;display:flex;align-items:center;gap:.5rem;
|
||||||
|
background:#fff0f2;border-bottom:1px solid #e8d5da;
|
||||||
|
}
|
||||||
|
.fea-adv-summary::-webkit-details-marker{display:none}
|
||||||
|
.fea-adv-summary::before{content:"▸";transition:transform .2s}
|
||||||
|
details[open] .fea-adv-summary::before{transform:rotate(90deg)}
|
||||||
|
|
||||||
|
.fea-adv-form{display:grid;grid-template-columns:1fr 1fr;gap:.75rem 1.2rem;padding:1rem 1.2rem}
|
||||||
|
@media(max-width:600px){.fea-adv-form{grid-template-columns:1fr}}
|
||||||
|
|
||||||
|
/* min-width:0 evita que el contenido fuerce a la celda del grid a desbordar */
|
||||||
|
.fea-adv-row{display:flex;flex-direction:column;gap:.25rem;min-width:0}
|
||||||
|
.fea-adv-dates{grid-column:1/-1;flex-direction:row;align-items:center;flex-wrap:wrap;gap:.5rem}
|
||||||
|
.fea-adv-dates .fea-adv-label{white-space:nowrap}
|
||||||
|
|
||||||
|
.fea-adv-label{font-size:.8rem;font-weight:600;color:#5a3a40;text-transform:uppercase;letter-spacing:.04em}
|
||||||
|
.fea-adv-to{margin-left:.5rem}
|
||||||
|
|
||||||
|
.fea-adv-input,.fea-adv-select{
|
||||||
|
box-sizing:border-box;border:1px solid #d9c4c9;border-radius:6px;padding:.5rem .75rem;
|
||||||
|
font-size:.95rem;background:#fff;color:#222;width:100%;max-width:100%;
|
||||||
|
transition:border-color .15s;
|
||||||
|
}
|
||||||
|
.fea-adv-input:focus,.fea-adv-select:focus{border-color:#8b1a2e;outline:0;box-shadow:0 0 0 2px #8b1a2e33}
|
||||||
|
.fea-adv-date{box-sizing:border-box;width:auto;min-width:150px;flex:0 0 auto}
|
||||||
|
|
||||||
|
.fea-adv-actions{grid-column:1/-1;display:flex;gap:.75rem;align-items:center;margin-top:.25rem}
|
||||||
|
.fea-adv-btn{padding:.55rem 1.4rem;border-radius:999px;font-size:.95rem;font-weight:600;cursor:pointer;text-decoration:none;border:2px solid transparent}
|
||||||
|
.fea-adv-btn-primary{background:#8b1a2e;color:#fff;border-color:#8b1a2e}
|
||||||
|
.fea-adv-btn-primary:hover{background:#6f1525;border-color:#6f1525}
|
||||||
|
.fea-adv-btn-secondary{background:transparent;color:#8b1a2e;border-color:#8b1a2e}
|
||||||
|
.fea-adv-btn-secondary:hover{background:#8b1a2e;color:#fff}
|
||||||
|
|
||||||
|
/* ── Contador y chips ─────────────────────────────────────────── */
|
||||||
|
.fea-adv-count{max-width:860px;margin:.5rem auto .25rem;padding:0 1rem;
|
||||||
|
font-size:.9rem;color:#666}
|
||||||
|
|
||||||
|
.fea-adv-chips{max-width:860px;margin:.5rem auto;padding:0 1rem;
|
||||||
|
display:flex;flex-wrap:wrap;gap:.4rem;align-items:center}
|
||||||
|
.fea-adv-chips-label{font-size:.8rem;color:#5a3a40;font-weight:600;margin-right:.25rem}
|
||||||
|
.fea-adv-chip{display:inline-flex;align-items:center;gap:.35rem;background:#f0e2e5;
|
||||||
|
border:1px solid #d9c4c9;border-radius:999px;padding:.2rem .75rem;font-size:.82rem;color:#5a3a40}
|
||||||
|
.fea-adv-chip-x{color:#8b1a2e;text-decoration:none;font-weight:700;font-size:1rem;line-height:1}
|
||||||
|
.fea-adv-chip-x:hover{color:#6f1525}
|
||||||
|
|
||||||
|
/* ── Byline en tarjetas de resultado ──────────────────────────── */
|
||||||
|
.fea-archive-card .fea-adv-byline{font-size:.78rem;color:#7a5a62;margin-top:.1rem}
|
||||||
|
.fea-archive-card .fea-adv-byline a{color:#8b1a2e;text-decoration:none}
|
||||||
|
.fea-archive-card .fea-adv-byline a:hover{text-decoration:underline}
|
||||||
|
|
||||||
|
/* ── Enlace «Búsqueda avanzada» en barra header ───────────────── */
|
||||||
|
.fea-adv-link-wrap{display:none;justify-content:center;padding:.3rem 1rem .4rem;
|
||||||
|
background:#faf6f7;border-bottom:1px solid #efe2e5}
|
||||||
|
@media(max-width:600px){.fea-adv-link-wrap{display:flex}}
|
||||||
|
.fea-adv-link{font-size:.8rem;color:#8b1a2e;text-decoration:none;font-weight:500}
|
||||||
|
.fea-adv-link:hover{text-decoration:underline}
|
||||||
|
</style>
|
||||||
|
<?php
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
// CSS del enlace «Búsqueda avanzada» en header (se muestra en todas las páginas)
|
||||||
|
add_action('wp_head', function (): void {
|
||||||
|
?>
|
||||||
|
<style>
|
||||||
|
.fea-adv-link-wrap{display:none;justify-content:center;padding:.3rem 1rem .4rem;
|
||||||
|
background:#faf6f7;border-bottom:1px solid #efe2e5}
|
||||||
|
@media(max-width:600px){.fea-adv-link-wrap{display:flex}}
|
||||||
|
.fea-adv-link{font-size:.8rem;color:#8b1a2e;text-decoration:none;font-weight:500}
|
||||||
|
.fea-adv-link:hover{text-decoration:underline}
|
||||||
|
</style>
|
||||||
|
<?php
|
||||||
|
}, 15);
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fe Adulta — Motor FULLTEXT (#8)
|
||||||
|
* Description: Sustituye el LIKE nativo de WP por MATCH AGAINST (MySQL FULLTEXT, InnoDB,
|
||||||
|
* Boolean Mode) cuando se hace una búsqueda por texto (/?s=…). Ordena por
|
||||||
|
* relevancia FULLTEXT si no se pide otro criterio de orden. Degradación elegante:
|
||||||
|
* si no hay término o el índice FULLTEXT no existe, usa el comportamiento nativo.
|
||||||
|
* Convive con fea-search-advanced.php (filtros pre_get_posts de autor/cat/cita/fecha).
|
||||||
|
* Version: 1.1
|
||||||
|
*/
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comprueba (cacheado) que existe el índice FULLTEXT 'fea_ft' en wp_posts.
|
||||||
|
* Si no existe, el motor se degrada al comportamiento nativo para no romper la búsqueda
|
||||||
|
* con un error SQL (MATCH AGAINST requiere el índice).
|
||||||
|
*/
|
||||||
|
function fea_ft_index_exists(): bool {
|
||||||
|
static $cached = null;
|
||||||
|
if ($cached !== null) return $cached;
|
||||||
|
|
||||||
|
// Cache persistente 12h vía transient para evitar el SHOW INDEX en cada request.
|
||||||
|
$t = get_transient('fea_ft_index_exists');
|
||||||
|
if ($t !== false) {
|
||||||
|
$cached = ($t === '1');
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$found = (int) $wpdb->get_var($wpdb->prepare(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND INDEX_NAME = 'fea_ft'",
|
||||||
|
$wpdb->posts
|
||||||
|
));
|
||||||
|
$cached = $found > 0;
|
||||||
|
set_transient('fea_ft_index_exists', $cached ? '1' : '0', 12 * HOUR_IN_SECONDS);
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula el término FULLTEXT en Boolean Mode (cada palabra con prefijo *).
|
||||||
|
* Devuelve '' si el término sanitizado queda vacío.
|
||||||
|
*/
|
||||||
|
function fea_ft_boolean_term(string $raw): string {
|
||||||
|
$term = trim(substr(preg_replace('/[^\p{L}\p{N}\s\'\-]/u', '', $raw), 0, 200));
|
||||||
|
if ($term === '') return '';
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$words = preg_split('/\s+/', $term);
|
||||||
|
return implode('* ', array_map(fn($w) => $wpdb->esc_like($w), $words)) . '*';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reemplaza la cláusula WHERE de búsqueda por MATCH AGAINST.
|
||||||
|
* Hook: posts_search (filtra el WHERE que WP construye para /?s=).
|
||||||
|
*/
|
||||||
|
add_filter('posts_search', function (string $search, WP_Query $q): string {
|
||||||
|
if (is_admin() || !$q->is_main_query() || !$q->is_search()) return $search;
|
||||||
|
if (!fea_ft_index_exists()) return $search; // degradación elegante
|
||||||
|
|
||||||
|
$raw = trim((string) $q->get('s'));
|
||||||
|
if ($raw === '') return $search;
|
||||||
|
|
||||||
|
$ft_term = fea_ft_boolean_term($raw);
|
||||||
|
if ($ft_term === '') return $search;
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$ft_esc = esc_sql($ft_term);
|
||||||
|
|
||||||
|
// WP construye " AND (...) " para la búsqueda; devolvemos un bloque AND compatible.
|
||||||
|
return " AND (MATCH({$wpdb->posts}.post_title, {$wpdb->posts}.post_content) AGAINST ('{$ft_esc}' IN BOOLEAN MODE)) ";
|
||||||
|
}, 10, 2);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordena por relevancia FULLTEXT cuando no se especifica otro orden.
|
||||||
|
* Hook: posts_clauses (permite modificar SELECT y ORDER BY juntos).
|
||||||
|
*/
|
||||||
|
add_filter('posts_clauses', function (array $clauses, WP_Query $q): array {
|
||||||
|
if (is_admin() || !$q->is_main_query() || !$q->is_search()) return $clauses;
|
||||||
|
if (!fea_ft_index_exists()) return $clauses; // degradación elegante
|
||||||
|
|
||||||
|
$raw = trim((string) $q->get('s'));
|
||||||
|
if ($raw === '') return $clauses;
|
||||||
|
|
||||||
|
// Sólo reordenamos por relevancia si el orderby es el nativo de búsqueda.
|
||||||
|
$ob = $q->get('orderby');
|
||||||
|
if (!in_array($ob, ['relevance', 'date', ''], true)) return $clauses;
|
||||||
|
|
||||||
|
$ft_term = fea_ft_boolean_term($raw);
|
||||||
|
if ($ft_term === '') return $clauses;
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$ft_esc = esc_sql($ft_term);
|
||||||
|
|
||||||
|
// Añadimos la columna de relevancia al SELECT y la usamos en ORDER BY.
|
||||||
|
$score_col = "MATCH({$wpdb->posts}.post_title, {$wpdb->posts}.post_content) AGAINST ('{$ft_esc}' IN BOOLEAN MODE)";
|
||||||
|
$clauses['fields'] .= ", ({$score_col}) AS fea_ft_score";
|
||||||
|
$clauses['orderby'] = "fea_ft_score DESC, {$wpdb->posts}.post_date DESC";
|
||||||
|
|
||||||
|
return $clauses;
|
||||||
|
}, 10, 2);
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fe Adulta — Buscador visible (#8, MVP nativo)
|
||||||
|
* Description: Inyecta una barra de búsqueda visible bajo la cabecera (template part
|
||||||
|
* FSE 'header'), usando el buscador nativo de WordPress (/?s=). Multiidioma:
|
||||||
|
* el form apunta a la home del idioma actual (Polylang filtra por idioma).
|
||||||
|
* Fase 2: sustituir el motor por Typesense (self-host) manteniendo esta UI.
|
||||||
|
* Version: 1.0
|
||||||
|
*/
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
/** HTML del formulario de búsqueda (home del idioma actual como action). */
|
||||||
|
function fea_search_form_html(): string {
|
||||||
|
// Raíz del idioma actual (Polylang) para que /?s= busque en ese idioma:
|
||||||
|
// ES (idioma por defecto) → /; EN/FR/IT/PT → /<lang>/.
|
||||||
|
$base = home_url('/');
|
||||||
|
if (function_exists('pll_current_language')) {
|
||||||
|
$lang = pll_current_language();
|
||||||
|
$default = function_exists('pll_default_language') ? pll_default_language() : 'es';
|
||||||
|
if ($lang && $lang !== $default) $base = home_url('/' . $lang . '/');
|
||||||
|
}
|
||||||
|
$action = esc_url($base);
|
||||||
|
$q = esc_attr(get_search_query());
|
||||||
|
$ph = esc_attr__('Buscar reflexiones, artículos, autores…', 'default');
|
||||||
|
$svg = '<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false">'
|
||||||
|
. '<path fill="currentColor" d="M15.5 14h-.79l-.28-.27a6.5 6.5 0 1 0-.7.7l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0A4.5 4.5 0 1 1 14 9.5 4.5 4.5 0 0 1 9.5 14z"/></svg>';
|
||||||
|
return '<div class="fea-search-bar"><form role="search" method="get" class="fea-search" action="' . $action . '">'
|
||||||
|
. '<input type="search" name="s" value="' . $q . '" placeholder="' . $ph . '" aria-label="Buscar">'
|
||||||
|
. '<button type="submit" aria-label="Buscar">' . $svg . '</button>'
|
||||||
|
. '</form></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inyecta la barra al final del template part 'header'. */
|
||||||
|
add_filter('render_block', function ($html, $block) {
|
||||||
|
if (is_admin()) return $html;
|
||||||
|
if (($block['blockName'] ?? '') !== 'core/template-part') return $html;
|
||||||
|
// El home usa el part 'cabecera-portada'; el resto del sitio usa 'header'.
|
||||||
|
if (!in_array($block['attrs']['slug'] ?? '', ['header', 'cabecera-portada'], true)) return $html;
|
||||||
|
return $html . fea_search_form_html();
|
||||||
|
}, 20, 2);
|
||||||
|
|
||||||
|
add_action('wp_head', function () {
|
||||||
|
?>
|
||||||
|
<style>
|
||||||
|
/* En desktop se usa el buscador del menú; esta barra es para móvil, donde el
|
||||||
|
menú colapsa en hamburguesa y el buscador del menú queda oculto (#8). */
|
||||||
|
.fea-search-bar{display:none;justify-content:center;padding:.5rem 1rem;
|
||||||
|
background:#faf6f7;border-top:1px solid #efe2e5;border-bottom:1px solid #efe2e5}
|
||||||
|
@media(max-width:600px){.fea-search-bar{display:flex}}
|
||||||
|
.fea-search{display:flex;align-items:center;width:100%;max-width:560px;
|
||||||
|
background:#fff;border:1px solid #d9c4c9;border-radius:999px;overflow:hidden}
|
||||||
|
.fea-search input[type=search]{flex:1 1 auto;border:0;outline:0;background:transparent;
|
||||||
|
font-size:.95rem;padding:.55rem .9rem;color:#222}
|
||||||
|
.fea-search input[type=search]::placeholder{color:#9a8a8e}
|
||||||
|
.fea-search button{flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;
|
||||||
|
border:0;cursor:pointer;background:#8b1a2e;color:#fff;width:42px;align-self:stretch}
|
||||||
|
.fea-search button:hover{background:#6f1525}
|
||||||
|
@media(max-width:600px){.fea-search-bar{padding:.45rem .6rem}}
|
||||||
|
</style>
|
||||||
|
<?php
|
||||||
|
});
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* fea-share — Sección "Comparte Fe Adulta" en single posts + Open Graph tags.
|
||||||
|
* Botones: Facebook, Instagram (Web Share API + fallback copiar), Imprimir.
|
||||||
|
* Sin plugins externos, sin JS de terceros, sin tracking.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Solo artículos reales: single post_type=post, excluyendo institucionales. */
|
||||||
|
function fea_share_eligible(): bool {
|
||||||
|
if (!is_singular('post')) return false;
|
||||||
|
if (function_exists('fea_hide_static_meta') && fea_hide_static_meta()) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Textos por idioma. */
|
||||||
|
function fea_share_labels(): array {
|
||||||
|
$lang = function_exists('fea_current_lang') ? fea_current_lang() : 'es';
|
||||||
|
$all = [
|
||||||
|
'es' => ['section' => 'Comparte FeAdulta', 'fb' => 'Facebook',
|
||||||
|
'ig' => 'Instagram', 'print' => 'Imprimir',
|
||||||
|
'copied' => '¡Enlace copiado! Compártelo en Instagram'],
|
||||||
|
'en' => ['section' => 'Share FeAdulta', 'fb' => 'Facebook',
|
||||||
|
'ig' => 'Instagram', 'print' => 'Print',
|
||||||
|
'copied' => 'Link copied! Share it on Instagram'],
|
||||||
|
'fr' => ['section' => 'Partager FeAdulta', 'fb' => 'Facebook',
|
||||||
|
'ig' => 'Instagram', 'print' => 'Imprimer',
|
||||||
|
'copied' => 'Lien copié ! Partagez-le sur Instagram'],
|
||||||
|
'it' => ['section' => 'Condividi FeAdulta', 'fb' => 'Facebook',
|
||||||
|
'ig' => 'Instagram', 'print' => 'Stampa',
|
||||||
|
'copied' => 'Link copiato! Condividilo su Instagram'],
|
||||||
|
'pt' => ['section' => 'Partilha FeAdulta', 'fb' => 'Facebook',
|
||||||
|
'ig' => 'Instagram', 'print' => 'Imprimir',
|
||||||
|
'copied' => 'Link copiado! Partilha-o no Instagram'],
|
||||||
|
];
|
||||||
|
return $all[$lang] ?? $all['es'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_share_block_html(): string {
|
||||||
|
$t = fea_share_labels();
|
||||||
|
$url = rawurlencode(get_permalink());
|
||||||
|
|
||||||
|
$svg_fb = '<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true" focusable="false" fill="currentColor"><path d="M22 12.06C22 6.5 17.52 2 12 2 6.48 2 2 6.5 2 12.06c0 5.02 3.66 9.18 8.44 9.94v-7.03H7.9v-2.9h2.54V9.85c0-2.51 1.49-3.9 3.78-3.9 1.09 0 2.24.2 2.24.2v2.46h-1.26c-1.24 0-1.63.78-1.63 1.57v1.88h2.78l-.44 2.9h-2.34V22c4.78-.76 8.44-4.92 8.44-9.94z"/></svg>';
|
||||||
|
$svg_ig = '<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true" focusable="false" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 1.366.062 2.633.334 3.608 1.308.974.975 1.245 2.242 1.307 3.608.058 1.265.07 1.645.07 4.85s-.012 3.584-.07 4.85c-.062 1.366-.334 2.633-1.308 3.608-.975.974-2.242 1.245-3.608 1.307-1.265.058-1.645.07-4.849.07s-3.584-.012-4.85-.07c-1.366-.062-2.633-.334-3.608-1.308-.974-.975-1.245-2.242-1.307-3.608C2.175 15.584 2.163 15.204 2.163 12s.012-3.584.07-4.85c.062-1.366.334-2.633 1.308-3.608C4.516 2.497 5.783 2.226 7.149 2.164 8.414 2.106 8.794 2.094 12 2.094zm0-2.163c-3.259 0-3.667.014-4.947.072-1.613.074-3.067.39-4.213 1.535C1.695 2.803 1.38 4.257 1.305 5.87.014 8.194 0 8.602 0 12c0 3.259.014 3.667.072 4.947.074 1.613.39 3.067 1.535 4.213 1.146 1.145 2.6 1.46 4.213 1.535C8.333 23.986 8.741 24 12 24c3.259 0 3.667-.014 4.947-.072 1.613-.074 3.067-.39 4.213-1.535 1.145-1.146 1.46-2.6 1.535-4.213C23.986 15.667 24 15.259 24 12c0-3.259-.014-3.667-.072-4.947-.074-1.613-.39-3.067-1.535-4.213C21.247 1.695 19.793 1.38 18.18 1.305 15.807.014 15.399 0 12 0zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324zm0 10.162a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881z"/></svg>';
|
||||||
|
$svg_pr = '<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true" focusable="false" fill="currentColor"><path d="M19 8H5c-1.66 0-3 1.34-3 3v6h4v4h12v-4h4v-6c0-1.66-1.34-3-3-3zm-3 11H8v-5h8v5zm3-7a1 1 0 0 1 0-2 1 1 0 0 1 0 2zm-1-9H6v4h12V3z"/></svg>';
|
||||||
|
|
||||||
|
$copied_msg = esc_js($t['copied']);
|
||||||
|
|
||||||
|
return '
|
||||||
|
<div class="fea-share">
|
||||||
|
<div class="fea-share-rule" aria-hidden="true"></div>
|
||||||
|
<div class="fea-share-row">
|
||||||
|
<span class="fea-share-label">' . esc_html($t['section']) . '</span>
|
||||||
|
<a href="https://www.facebook.com/sharer/sharer.php?u=' . $url . '"
|
||||||
|
target="_blank" rel="noopener noreferrer nofollow"
|
||||||
|
class="fea-share-item fea-share-fb">
|
||||||
|
' . $svg_fb . '<span>' . esc_html($t['fb']) . '</span>
|
||||||
|
</a>
|
||||||
|
<span class="fea-share-sep" aria-hidden="true">·</span>
|
||||||
|
<button type="button" class="fea-share-item fea-share-ig"
|
||||||
|
data-copied="' . esc_attr($t['copied']) . '">
|
||||||
|
' . $svg_ig . '<span>' . esc_html($t['ig']) . '</span>
|
||||||
|
</button>
|
||||||
|
<span class="fea-share-sep" aria-hidden="true">·</span>
|
||||||
|
<button type="button" class="fea-share-item fea-share-print"
|
||||||
|
onclick="window.print()">
|
||||||
|
' . $svg_pr . '<span>' . esc_html($t['print']) . '</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="fea-share-toast" aria-live="polite"></div>
|
||||||
|
</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_share_already_rendered(string $content): bool {
|
||||||
|
return strpos($content, 'class="fea-share"') !== false
|
||||||
|
|| strpos($content, "class='fea-share'") !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bloque al final del contenido ────────────────────────────────────────────
|
||||||
|
add_filter('the_content', function ($content) {
|
||||||
|
if (is_admin() || !fea_share_eligible() || !is_main_query() || !in_the_loop()) {
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
if (fea_share_already_rendered($content)) return $content;
|
||||||
|
return $content . fea_share_block_html();
|
||||||
|
}, 20);
|
||||||
|
|
||||||
|
add_filter('render_block', function ($block_content, $block) {
|
||||||
|
if (is_admin() || !fea_share_eligible()) return $block_content;
|
||||||
|
if (($block['blockName'] ?? '') !== 'core/post-content') return $block_content;
|
||||||
|
if (fea_share_already_rendered($block_content)) return $block_content;
|
||||||
|
return $block_content . fea_share_block_html();
|
||||||
|
}, 20, 2);
|
||||||
|
|
||||||
|
// ── CSS (screen + print) ─────────────────────────────────────────────────────
|
||||||
|
add_action('wp_head', function () {
|
||||||
|
if (!fea_share_eligible()) return;
|
||||||
|
?>
|
||||||
|
<style>
|
||||||
|
/* ── Sección compartir ── */
|
||||||
|
.fea-share { margin: 2.5rem 0 1rem; }
|
||||||
|
|
||||||
|
.fea-share-rule {
|
||||||
|
border: none; border-top: 1px solid #e2d9d0; margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fea-share-row {
|
||||||
|
display: flex; align-items: center; flex-wrap: wrap;
|
||||||
|
gap: .15rem .4rem; color: #5a4a42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fea-share-label {
|
||||||
|
font-size: .78rem; font-weight: 700; letter-spacing: .06em;
|
||||||
|
text-transform: uppercase; color: #8a7a72; margin-right: .4rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset total de <button> + alineación compartida con <a> */
|
||||||
|
.fea-share-item {
|
||||||
|
display: inline-flex; align-items: center; gap: .32rem;
|
||||||
|
background: none; border: none; padding: 0; margin: 0; cursor: pointer;
|
||||||
|
font-family: inherit; font-size: .84rem; font-weight: 600; line-height: 1;
|
||||||
|
color: #8b1a2e; text-decoration: none;
|
||||||
|
transition: color .15s;
|
||||||
|
}
|
||||||
|
.fea-share-item:hover { color: #5a1220; }
|
||||||
|
.fea-share-item svg { display: block; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.fea-share-sep { color: #c9b8ae; font-size: .84rem; line-height: 1; user-select: none; }
|
||||||
|
|
||||||
|
/* Toast */
|
||||||
|
.fea-share-toast {
|
||||||
|
font-size: .8rem; color: #1b7a34; margin-top: .5rem;
|
||||||
|
min-height: 1.1em; transition: opacity .3s;
|
||||||
|
}
|
||||||
|
.fea-share-toast.hidden { opacity: 0; }
|
||||||
|
|
||||||
|
/* ── CSS de impresión ── */
|
||||||
|
@media print {
|
||||||
|
/* Ocultar todo lo que no es contenido editorial */
|
||||||
|
#fea-beta-bar, #fea-fb,
|
||||||
|
.fea-share, .fea-share-btns,
|
||||||
|
header, nav, footer,
|
||||||
|
.wp-block-navigation, .wp-site-logo,
|
||||||
|
.fea-pensamientos-wrap, .fea-cookie-consent,
|
||||||
|
#wpadminbar { display: none !important; }
|
||||||
|
|
||||||
|
body, .entry-content, .wp-block-post-content {
|
||||||
|
font-size: 12pt; line-height: 1.6; color: #000;
|
||||||
|
background: #fff; margin: 0; padding: 0;
|
||||||
|
}
|
||||||
|
a { color: #000; text-decoration: underline; }
|
||||||
|
a[href]::after { content: none; } /* no imprimir URLs */
|
||||||
|
img { max-width: 100%; }
|
||||||
|
h1, h2, h3 { page-break-after: avoid; }
|
||||||
|
p { orphans: 3; widows: 3; }
|
||||||
|
|
||||||
|
/* Cabecera de impresión con nombre del sitio */
|
||||||
|
body::before {
|
||||||
|
content: "FeAdulta — feadulta.com";
|
||||||
|
display: block; font-size: 9pt; color: #666;
|
||||||
|
border-bottom: 1px solid #ccc; padding-bottom: 4pt;
|
||||||
|
margin-bottom: 12pt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<?php
|
||||||
|
}, 20);
|
||||||
|
|
||||||
|
// ── JS: Instagram (Web Share API + fallback copiar) ──────────────────────────
|
||||||
|
add_action('wp_footer', function () {
|
||||||
|
if (!fea_share_eligible()) return;
|
||||||
|
?>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var btn = document.querySelector('.fea-share-ig');
|
||||||
|
if (!btn) return;
|
||||||
|
var toast = document.querySelector('.fea-share-toast');
|
||||||
|
var copied = btn.getAttribute('data-copied');
|
||||||
|
var url = <?php echo json_encode(get_permalink()); ?>;
|
||||||
|
var title = <?php
|
||||||
|
$post = get_queried_object();
|
||||||
|
$raw = $post instanceof WP_Post ? get_the_title($post) : '';
|
||||||
|
$t = function_exists('fea_title') ? fea_title($raw) : $raw;
|
||||||
|
echo json_encode($t);
|
||||||
|
?>;
|
||||||
|
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
if (navigator.share) {
|
||||||
|
navigator.share({ title: title, url: url }).catch(function(){});
|
||||||
|
} else {
|
||||||
|
navigator.clipboard.writeText(url).then(function(){
|
||||||
|
if (toast) {
|
||||||
|
toast.textContent = copied;
|
||||||
|
toast.classList.remove('hidden');
|
||||||
|
setTimeout(function(){ toast.classList.add('hidden'); }, 3000);
|
||||||
|
}
|
||||||
|
}).catch(function(){});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?php
|
||||||
|
}, 20);
|
||||||
|
|
||||||
|
// ── Open Graph tags ───────────────────────────────────────────────────────────
|
||||||
|
add_action('wp_head', function () {
|
||||||
|
if (!fea_share_eligible()) return;
|
||||||
|
$post = get_queried_object();
|
||||||
|
if (!$post instanceof WP_Post) return;
|
||||||
|
|
||||||
|
$raw = get_the_title($post);
|
||||||
|
$title = function_exists('fea_title') ? fea_title($raw) : $raw;
|
||||||
|
$desc = has_excerpt($post)
|
||||||
|
? get_the_excerpt($post)
|
||||||
|
: wp_trim_words(wp_strip_all_tags($post->post_content), 30, '…');
|
||||||
|
$url = get_permalink($post);
|
||||||
|
|
||||||
|
$img = get_the_post_thumbnail_url($post, 'large');
|
||||||
|
if (!$img && preg_match('~<img[^>]+src="([^"]+)"~', $post->post_content, $m)) {
|
||||||
|
$img = $m[1];
|
||||||
|
}
|
||||||
|
if ($img && strpos($img, 'http') !== 0) {
|
||||||
|
if (strpos($img, '//') === 0) {
|
||||||
|
$img = (is_ssl() ? 'https:' : 'http:') . $img;
|
||||||
|
} elseif ($img[0] === '/') {
|
||||||
|
$origin = preg_replace('~^(https?://[^/]+).*~', '$1', home_url('/'));
|
||||||
|
$img = $origin . $img;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n";
|
||||||
|
echo '<meta property="og:type" content="article" />' . "\n";
|
||||||
|
echo '<meta property="og:site_name" content="Fe Adulta" />' . "\n";
|
||||||
|
echo '<meta property="og:title" content="' . esc_attr($title) . '" />' . "\n";
|
||||||
|
echo '<meta property="og:description" content="' . esc_attr($desc) . '" />' . "\n";
|
||||||
|
echo '<meta property="og:url" content="' . esc_url($url) . '" />' . "\n";
|
||||||
|
if ($img) {
|
||||||
|
echo '<meta property="og:image" content="' . esc_url($img) . '" />' . "\n";
|
||||||
|
}
|
||||||
|
}, 5);
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fe Adulta — Slider home sync (filesystem → Smart Slider 3)
|
||||||
|
* Description: Sincroniza los slides del Smart Slider 3 id 2 ("Slider_home")
|
||||||
|
* con el contenido del directorio `wp-content/uploads/home/`.
|
||||||
|
* Imita el modelo Joomla mod_ariimageslider que leía
|
||||||
|
* `images/home/` automáticamente.
|
||||||
|
* Version: 1.0
|
||||||
|
*
|
||||||
|
* Operativa para el editor: subir/borrar ficheros en `uploads/home/`.
|
||||||
|
* El slider de la portada se actualiza solo al primer pageview.
|
||||||
|
*
|
||||||
|
* Ver issue rafa/feadulta#43.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FEA_SLIDER_ID = 2;
|
||||||
|
const FEA_SLIDER_DIRNAME = 'home';
|
||||||
|
const FEA_SLIDER_EXTS = ['jpg', 'jpeg', 'png', 'webp'];
|
||||||
|
const FEA_SLIDER_OPT_KEY = 'fea_slider_home_mtime';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lista las imágenes del directorio uploads/home/, ordenadas por nombre.
|
||||||
|
*/
|
||||||
|
function fea_slider_home_files() {
|
||||||
|
$uploads = wp_upload_dir();
|
||||||
|
$dir = trailingslashit($uploads['basedir']) . FEA_SLIDER_DIRNAME;
|
||||||
|
if (!is_dir($dir)) return [];
|
||||||
|
$files = [];
|
||||||
|
foreach (scandir($dir) as $name) {
|
||||||
|
if ($name === '.' || $name === '..') continue;
|
||||||
|
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||||
|
if (!in_array($ext, FEA_SLIDER_EXTS, true)) continue;
|
||||||
|
$files[] = $name;
|
||||||
|
}
|
||||||
|
sort($files, SORT_NATURAL);
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* mtime del directorio (cambia al añadir/borrar ficheros).
|
||||||
|
*/
|
||||||
|
function fea_slider_home_dir_mtime() {
|
||||||
|
$uploads = wp_upload_dir();
|
||||||
|
$dir = trailingslashit($uploads['basedir']) . FEA_SLIDER_DIRNAME;
|
||||||
|
if (!is_dir($dir)) return 0;
|
||||||
|
$m = (int) @filemtime($dir);
|
||||||
|
// Sumar mtime de cada fichero para detectar reemplazos del mismo nombre
|
||||||
|
foreach (fea_slider_home_files() as $f) {
|
||||||
|
$m = max($m, (int) @filemtime($dir . '/' . $f));
|
||||||
|
}
|
||||||
|
return $m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sincroniza la tabla wp_nextend2_smartslider3_slides para el slider 2
|
||||||
|
* con los ficheros del directorio. Idempotente.
|
||||||
|
*
|
||||||
|
* - Reusa slides existentes (manteniendo IDs) cuando coincide la imagen
|
||||||
|
* - Crea slides nuevos para imágenes nuevas
|
||||||
|
* - Borra slides cuya imagen ya no está
|
||||||
|
*/
|
||||||
|
function fea_slider_home_sync_now($force = false) {
|
||||||
|
global $wpdb;
|
||||||
|
$mtime = fea_slider_home_dir_mtime();
|
||||||
|
if (!$force) {
|
||||||
|
$last = (int) get_option(FEA_SLIDER_OPT_KEY, 0);
|
||||||
|
if ($last === $mtime && $mtime > 0) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = fea_slider_home_files();
|
||||||
|
|
||||||
|
$uploads = wp_upload_dir();
|
||||||
|
$reldir = '$upload$/' . FEA_SLIDER_DIRNAME; // SS3 variable placeholder
|
||||||
|
|
||||||
|
// Leer slides actuales del slider 2 → mapeo imagen → row
|
||||||
|
$existing = $wpdb->get_results($wpdb->prepare(
|
||||||
|
"SELECT id, title, params, ordering FROM {$wpdb->prefix}nextend2_smartslider3_slides WHERE slider=%d ORDER BY ordering",
|
||||||
|
FEA_SLIDER_ID
|
||||||
|
), ARRAY_A);
|
||||||
|
|
||||||
|
$by_image = [];
|
||||||
|
foreach ($existing as $row) {
|
||||||
|
$p = json_decode($row['params'], true) ?: [];
|
||||||
|
$img = isset($p['backgroundImage']) ? basename($p['backgroundImage']) : null;
|
||||||
|
if ($img) $by_image[$img] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$keep_ids = [];
|
||||||
|
$ordering = 0;
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$ordering++;
|
||||||
|
if (isset($by_image[$file])) {
|
||||||
|
// Reutilizar slide existente — actualizar params si fuera necesario
|
||||||
|
$row = $by_image[$file];
|
||||||
|
$p = json_decode($row['params'], true) ?: [];
|
||||||
|
$expected = $reldir . '/' . $file;
|
||||||
|
$needs_update = false;
|
||||||
|
if (($p['backgroundImage'] ?? '') !== $expected) { $p['backgroundImage'] = $expected; $needs_update = true; }
|
||||||
|
if (($p['background-type'] ?? '') !== 'image') { $p['background-type'] = 'image'; $needs_update = true; }
|
||||||
|
if ($needs_update) {
|
||||||
|
$wpdb->update(
|
||||||
|
$wpdb->prefix . 'nextend2_smartslider3_slides',
|
||||||
|
['params' => wp_json_encode($p), 'ordering' => $ordering],
|
||||||
|
['id' => $row['id']],
|
||||||
|
['%s','%d'], ['%d']
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$wpdb->update(
|
||||||
|
$wpdb->prefix . 'nextend2_smartslider3_slides',
|
||||||
|
['ordering' => $ordering],
|
||||||
|
['id' => $row['id']],
|
||||||
|
['%d'], ['%d']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$keep_ids[] = (int) $row['id'];
|
||||||
|
} else {
|
||||||
|
// Crear slide nuevo
|
||||||
|
$title = pathinfo($file, PATHINFO_FILENAME);
|
||||||
|
$params = wp_json_encode([
|
||||||
|
'background-type' => 'image',
|
||||||
|
'backgroundImage' => $reldir . '/' . $file,
|
||||||
|
'version' => '3.5.1.32',
|
||||||
|
]);
|
||||||
|
$img_url = $reldir . '/' . $file;
|
||||||
|
$wpdb->insert(
|
||||||
|
$wpdb->prefix . 'nextend2_smartslider3_slides',
|
||||||
|
[
|
||||||
|
'slider' => FEA_SLIDER_ID,
|
||||||
|
'title' => $title,
|
||||||
|
'description' => '',
|
||||||
|
'params' => $params,
|
||||||
|
'slide' => '[]',
|
||||||
|
'thumbnail' => $img_url,
|
||||||
|
'publish_up' => '1970-01-01 00:00:00',
|
||||||
|
'publish_down' => '1970-01-01 00:00:00',
|
||||||
|
'published' => 1,
|
||||||
|
'first' => 0,
|
||||||
|
'generator_id' => 0,
|
||||||
|
'ordering' => $ordering,
|
||||||
|
],
|
||||||
|
['%d','%s','%s','%s','%s','%s','%s','%s','%d','%d','%d','%d']
|
||||||
|
);
|
||||||
|
$keep_ids[] = (int) $wpdb->insert_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Borrar slides cuya imagen ya no está en el directorio
|
||||||
|
if ($keep_ids) {
|
||||||
|
$in = implode(',', array_map('intval', $keep_ids));
|
||||||
|
$wpdb->query($wpdb->prepare(
|
||||||
|
"DELETE FROM {$wpdb->prefix}nextend2_smartslider3_slides WHERE slider=%d AND id NOT IN ($in)",
|
||||||
|
FEA_SLIDER_ID
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
$wpdb->query($wpdb->prepare(
|
||||||
|
"DELETE FROM {$wpdb->prefix}nextend2_smartslider3_slides WHERE slider=%d",
|
||||||
|
FEA_SLIDER_ID
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
update_option(FEA_SLIDER_OPT_KEY, $mtime);
|
||||||
|
|
||||||
|
// Marcar slider como cambiado para que SS3 regenere su manifest
|
||||||
|
$wpdb->update(
|
||||||
|
$wpdb->prefix . 'nextend2_section_storage',
|
||||||
|
['value' => (string) $mtime],
|
||||||
|
['application' => 'smartslider', 'section' => 'sliderChanged', 'referenceKey' => (string) FEA_SLIDER_ID],
|
||||||
|
['%s'], ['%s','%s','%s']
|
||||||
|
);
|
||||||
|
|
||||||
|
return count($files);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ejecutar sync en cada carga de portada (la comprobación de mtime evita trabajo si nada cambió).
|
||||||
|
add_action('template_redirect', function() {
|
||||||
|
if (!is_front_page()) return;
|
||||||
|
fea_slider_home_sync_now();
|
||||||
|
}, 5);
|
||||||
|
|
||||||
|
// Sync también al entrar al admin (por si el editor sube ficheros desde wp-admin).
|
||||||
|
add_action('admin_init', function() {
|
||||||
|
fea_slider_home_sync_now();
|
||||||
|
});
|
||||||
|
|
||||||
|
// WP-CLI helper: `wp eval "fea_slider_home_sync_now(true);"` fuerza resync ignorando mtime.
|
||||||
Executable
+336
@@ -0,0 +1,336 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fe Adulta — Support Campaign
|
||||||
|
* Description: Landing y banner discreto de apoyo económico para la migración de Fe Adulta.
|
||||||
|
* Version: 1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEA_SUPPORT_TEMPLATE = 'fea-support-campaign.php';
|
||||||
|
const FEA_SUPPORT_TEMPLATE_LABEL = 'Fe Adulta — Campaña de apoyo';
|
||||||
|
|
||||||
|
function fea_support_template_path(): string {
|
||||||
|
return __DIR__ . '/fea-support-campaign/template.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_support_is_spanish_context(): bool {
|
||||||
|
if (!function_exists('pll_current_language')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pll_current_language() === 'es';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_support_campaign_page(): ?WP_Post {
|
||||||
|
static $page = 'unset';
|
||||||
|
|
||||||
|
if ($page !== 'unset') {
|
||||||
|
return $page instanceof WP_Post ? $page : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pages = get_posts([
|
||||||
|
'post_type' => 'page',
|
||||||
|
'post_status' => ['publish', 'draft', 'private'],
|
||||||
|
'posts_per_page' => 1,
|
||||||
|
'meta_key' => '_wp_page_template',
|
||||||
|
'meta_value' => FEA_SUPPORT_TEMPLATE,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$pages) {
|
||||||
|
$slug_page = get_page_by_path('apoya-feadulta', OBJECT, 'page');
|
||||||
|
$page = $slug_page instanceof WP_Post ? $slug_page : null;
|
||||||
|
return $page;
|
||||||
|
}
|
||||||
|
|
||||||
|
$page = $pages[0];
|
||||||
|
return $page;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_support_campaign_url(): string {
|
||||||
|
$page = fea_support_campaign_page();
|
||||||
|
if (!$page || $page->post_status !== 'publish') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) get_permalink($page);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_support_meta(int $page_id, string $key, $default = '') {
|
||||||
|
$value = get_post_meta($page_id, $key, true);
|
||||||
|
return ($value === '' || $value === null) ? $default : $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_support_campaign_data(?int $page_id = null): array {
|
||||||
|
$page_id = $page_id ?: (fea_support_campaign_page()?->ID ?? 0);
|
||||||
|
if (!$page_id) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$goal = (float) fea_support_meta($page_id, 'fea_support_goal', 2000);
|
||||||
|
$raised = (float) fea_support_meta($page_id, 'fea_support_raised', 0);
|
||||||
|
$goal = $goal > 0 ? $goal : 2000;
|
||||||
|
$raised = max(0, $raised);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'page_id' => $page_id,
|
||||||
|
'eyebrow' => (string) fea_support_meta($page_id, 'fea_support_eyebrow', 'Apoya Fe Adulta'),
|
||||||
|
'hero_title' => (string) fea_support_meta($page_id, 'fea_support_hero_title', 'Ayúdanos a sostener la nueva web de Fe Adulta'),
|
||||||
|
'hero_intro' => (string) fea_support_meta($page_id, 'fea_support_hero_intro', 'La migración de Fe Adulta ha requerido meses de trabajo y un coste aproximado de 2000€. Si puedes colaborar, por pequeña que sea la aportación, nos ayudas a sostener este esfuerzo compartido.'),
|
||||||
|
'progress_note' => (string) fea_support_meta($page_id, 'fea_support_progress_note', 'Objetivo aproximado para cubrir el trabajo técnico y la migración.'),
|
||||||
|
'banner_title' => (string) fea_support_meta($page_id, 'fea_support_banner_title', 'Estamos sosteniendo la nueva web entre todos'),
|
||||||
|
'banner_text' => (string) fea_support_meta($page_id, 'fea_support_banner_text', 'La migración ha supuesto meses de trabajo y unos 2000€ de coste. Si puedes colaborar, nos ayudas a cuidar Fe Adulta.'),
|
||||||
|
'goal' => $goal,
|
||||||
|
'raised' => $raised,
|
||||||
|
'stripe_url' => (string) fea_support_meta($page_id, 'fea_support_stripe_url', ''),
|
||||||
|
'cajamar_url' => (string) fea_support_meta($page_id, 'fea_support_cajamar_url', ''),
|
||||||
|
'paypal_url' => (string) fea_support_meta($page_id, 'fea_support_paypal_url', ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_support_amount(float $amount): string {
|
||||||
|
if (floor($amount) === $amount) {
|
||||||
|
return number_format_i18n($amount, 0) . '€';
|
||||||
|
}
|
||||||
|
|
||||||
|
return number_format_i18n($amount, 2) . '€';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_support_progress_percent(array $data): float {
|
||||||
|
$goal = (float) ($data['goal'] ?? 0);
|
||||||
|
$raised = (float) ($data['raised'] ?? 0);
|
||||||
|
|
||||||
|
if ($goal <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return max(0, min(100, ($raised / $goal) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_support_buttons_html(array $data, string $context = 'page'): string {
|
||||||
|
$buttons = [
|
||||||
|
'stripe_url' => ['label' => 'Donar con Stripe', 'class' => 'is-primary'],
|
||||||
|
'cajamar_url' => ['label' => 'Donar con Cajamar', 'class' => 'is-secondary'],
|
||||||
|
'paypal_url' => ['label' => 'Donar con PayPal', 'class' => 'is-secondary'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$html = '<div class="fea-support-actions fea-support-actions--' . esc_attr($context) . '">';
|
||||||
|
foreach ($buttons as $key => $config) {
|
||||||
|
if (empty($data[$key])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '<a class="fea-support-button ' . esc_attr($config['class']) . '" href="'
|
||||||
|
. esc_url($data[$key]) . '" target="_blank" rel="noopener">'
|
||||||
|
. esc_html($config['label']) . '</a>';
|
||||||
|
}
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_support_progress_html(array $data, string $context = 'page'): string {
|
||||||
|
$percent = fea_support_progress_percent($data);
|
||||||
|
$summary = fea_support_amount((float) $data['raised']) . ' de ' . fea_support_amount((float) $data['goal']);
|
||||||
|
|
||||||
|
$html = '<div class="fea-support-progress fea-support-progress--' . esc_attr($context) . '">';
|
||||||
|
$html .= '<div class="fea-support-progress__numbers">';
|
||||||
|
$html .= '<strong>' . esc_html($summary) . '</strong>';
|
||||||
|
$html .= '<span>' . esc_html(number_format_i18n($percent, 0)) . '%</span>';
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '<div class="fea-support-progress__track" aria-hidden="true">';
|
||||||
|
$html .= '<span class="fea-support-progress__fill" style="width:' . esc_attr(number_format($percent, 2, '.', '')) . '%"></span>';
|
||||||
|
$html .= '</div>';
|
||||||
|
if (!empty($data['progress_note'])) {
|
||||||
|
$html .= '<p class="fea-support-progress__note">' . esc_html($data['progress_note']) . '</p>';
|
||||||
|
}
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_support_banner_html(): string {
|
||||||
|
$page = fea_support_campaign_page();
|
||||||
|
if (!$page || $page->post_status !== 'publish') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = fea_support_campaign_data($page->ID);
|
||||||
|
$url = get_permalink($page);
|
||||||
|
|
||||||
|
$html = '<section class="fea-support-banner" aria-label="Campaña de apoyo económico">';
|
||||||
|
$html .= '<div class="fea-support-banner__copy">';
|
||||||
|
$html .= '<span class="fea-support-banner__eyebrow">' . esc_html($data['eyebrow']) . '</span>';
|
||||||
|
$html .= '<h2 class="fea-support-banner__title">' . esc_html($data['banner_title']) . '</h2>';
|
||||||
|
$html .= '<p class="fea-support-banner__text">' . esc_html($data['banner_text']) . '</p>';
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '<div class="fea-support-banner__side">';
|
||||||
|
$html .= fea_support_progress_html($data, 'banner');
|
||||||
|
$html .= '<div class="fea-support-banner__links">';
|
||||||
|
$html .= '<a class="fea-support-button is-primary" href="' . esc_url($url) . '">Ver la campaña</a>';
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '</section>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
add_filter('theme_page_templates', function(array $templates, WP_Theme $theme, ?WP_Post $post, string $post_type): array {
|
||||||
|
if ($post_type === 'page') {
|
||||||
|
$templates[FEA_SUPPORT_TEMPLATE] = FEA_SUPPORT_TEMPLATE_LABEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $templates;
|
||||||
|
}, 10, 4);
|
||||||
|
|
||||||
|
add_filter('template_include', function(string $template): string {
|
||||||
|
if (!is_page()) {
|
||||||
|
return $template;
|
||||||
|
}
|
||||||
|
|
||||||
|
$page = get_queried_object();
|
||||||
|
if (!$page instanceof WP_Post) {
|
||||||
|
return $template;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (get_page_template_slug($page) !== FEA_SUPPORT_TEMPLATE) {
|
||||||
|
return $template;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fea_support_template_path();
|
||||||
|
});
|
||||||
|
|
||||||
|
add_action('acf/init', function() {
|
||||||
|
if (!function_exists('acf_add_local_field_group')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
acf_add_local_field_group([
|
||||||
|
'key' => 'group_fea_support_campaign',
|
||||||
|
'title' => 'Campaña de apoyo económico',
|
||||||
|
'fields' => [
|
||||||
|
[
|
||||||
|
'key' => 'field_fea_support_goal',
|
||||||
|
'label' => 'Objetivo económico',
|
||||||
|
'name' => 'fea_support_goal',
|
||||||
|
'type' => 'number',
|
||||||
|
'instructions' => 'Importe objetivo de la campaña.',
|
||||||
|
'default_value' => 2000,
|
||||||
|
'min' => 1,
|
||||||
|
'step' => 1,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'field_fea_support_raised',
|
||||||
|
'label' => 'Importe recaudado',
|
||||||
|
'name' => 'fea_support_raised',
|
||||||
|
'type' => 'number',
|
||||||
|
'instructions' => 'Cantidad actual recaudada.',
|
||||||
|
'default_value' => 0,
|
||||||
|
'min' => 0,
|
||||||
|
'step' => 0.01,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'field_fea_support_eyebrow',
|
||||||
|
'label' => 'Antetítulo',
|
||||||
|
'name' => 'fea_support_eyebrow',
|
||||||
|
'type' => 'text',
|
||||||
|
'default_value' => 'Apoya Fe Adulta',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'field_fea_support_hero_title',
|
||||||
|
'label' => 'Título principal',
|
||||||
|
'name' => 'fea_support_hero_title',
|
||||||
|
'type' => 'text',
|
||||||
|
'default_value' => 'Ayúdanos a sostener la nueva web de Fe Adulta',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'field_fea_support_hero_intro',
|
||||||
|
'label' => 'Texto principal',
|
||||||
|
'name' => 'fea_support_hero_intro',
|
||||||
|
'type' => 'textarea',
|
||||||
|
'rows' => 4,
|
||||||
|
'new_lines' => 'br',
|
||||||
|
'default_value' => 'La migración de Fe Adulta ha requerido meses de trabajo y un coste aproximado de 2000€. Si puedes colaborar, por pequeña que sea la aportación, nos ayudas a sostener este esfuerzo compartido.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'field_fea_support_progress_note',
|
||||||
|
'label' => 'Nota bajo la barra',
|
||||||
|
'name' => 'fea_support_progress_note',
|
||||||
|
'type' => 'text',
|
||||||
|
'default_value' => 'Objetivo aproximado para cubrir el trabajo técnico y la migración.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'field_fea_support_banner_title',
|
||||||
|
'label' => 'Título del banner de portada',
|
||||||
|
'name' => 'fea_support_banner_title',
|
||||||
|
'type' => 'text',
|
||||||
|
'default_value' => 'Estamos sosteniendo la nueva web entre todos',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'field_fea_support_banner_text',
|
||||||
|
'label' => 'Texto del banner de portada',
|
||||||
|
'name' => 'fea_support_banner_text',
|
||||||
|
'type' => 'textarea',
|
||||||
|
'rows' => 3,
|
||||||
|
'new_lines' => 'br',
|
||||||
|
'default_value' => 'La migración ha supuesto meses de trabajo y unos 2000€ de coste. Si puedes colaborar, nos ayudas a cuidar Fe Adulta.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'field_fea_support_stripe_url',
|
||||||
|
'label' => 'URL Stripe',
|
||||||
|
'name' => 'fea_support_stripe_url',
|
||||||
|
'type' => 'url',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'field_fea_support_cajamar_url',
|
||||||
|
'label' => 'URL TPV Cajamar',
|
||||||
|
'name' => 'fea_support_cajamar_url',
|
||||||
|
'type' => 'url',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'field_fea_support_paypal_url',
|
||||||
|
'label' => 'URL PayPal',
|
||||||
|
'name' => 'fea_support_paypal_url',
|
||||||
|
'type' => 'url',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'location' => [[
|
||||||
|
['param' => 'page_template', 'operator' => '==', 'value' => FEA_SUPPORT_TEMPLATE],
|
||||||
|
]],
|
||||||
|
'position' => 'normal',
|
||||||
|
'style' => 'default',
|
||||||
|
'label_placement' => 'top',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
add_filter('the_content', function(string $content): string {
|
||||||
|
// DESACTIVADO temporalmente: la campaña de apoyo (Codex) aún no está lista
|
||||||
|
// (barra 0€/2.000€, sin enlaces de donación). No mostrar el banner en portada.
|
||||||
|
// Reactivar quitando este return cuando la campaña esté terminada.
|
||||||
|
return $content;
|
||||||
|
|
||||||
|
if (is_admin() || !is_main_query() || !in_the_loop()) {
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('fea_is_front_page') || !fea_is_front_page()) {
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fea_support_is_spanish_context()) {
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
$banner = fea_support_banner_html();
|
||||||
|
if (!$banner) {
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $content . $banner;
|
||||||
|
}, 40);
|
||||||
|
|
||||||
|
add_shortcode('fea_support_campaign_banner', function() {
|
||||||
|
return fea_support_banner_html();
|
||||||
|
});
|
||||||
Executable
+244
@@ -0,0 +1,244 @@
|
|||||||
|
<?php
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
get_header();
|
||||||
|
the_post();
|
||||||
|
|
||||||
|
$data = fea_support_campaign_data(get_the_ID());
|
||||||
|
?>
|
||||||
|
<style>
|
||||||
|
.fea-support-page {
|
||||||
|
--fea-support-burgundy: #8b1a2e;
|
||||||
|
--fea-support-ink: #2a2320;
|
||||||
|
--fea-support-warm: #f5efe7;
|
||||||
|
--fea-support-line: #e6d8c5;
|
||||||
|
--fea-support-card: #fffdf9;
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2.5rem 1.25rem 4.5rem;
|
||||||
|
color: var(--fea-support-ink);
|
||||||
|
}
|
||||||
|
.fea-support-hero {
|
||||||
|
background: linear-gradient(180deg, #f3ece3 0%, #fbf7f1 100%);
|
||||||
|
border: 1px solid var(--fea-support-line);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 2rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.8fr);
|
||||||
|
gap: 2rem;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
.fea-support-hero__eyebrow,
|
||||||
|
.fea-support-banner__eyebrow {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
color: var(--fea-support-burgundy);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.fea-support-hero__title {
|
||||||
|
font-family: "Fraunces", Georgia, serif;
|
||||||
|
font-size: clamp(2rem, 4vw, 3.35rem);
|
||||||
|
line-height: 1.05;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
.fea-support-hero__intro {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.03rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
max-width: 58ch;
|
||||||
|
}
|
||||||
|
.fea-support-card {
|
||||||
|
background: var(--fea-support-card);
|
||||||
|
border: 1px solid var(--fea-support-line);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
box-shadow: 0 18px 40px -34px rgba(42, 35, 32, 0.55);
|
||||||
|
}
|
||||||
|
.fea-support-card__title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
color: #6f655c;
|
||||||
|
}
|
||||||
|
.fea-support-progress {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.fea-support-progress__numbers {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.55rem;
|
||||||
|
}
|
||||||
|
.fea-support-progress__numbers strong {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
.fea-support-progress__numbers span {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: #6f655c;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.fea-support-progress__track {
|
||||||
|
width: 100%;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eadfce;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.fea-support-progress__fill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, #8b1a2e, #c4884b);
|
||||||
|
}
|
||||||
|
.fea-support-progress__note {
|
||||||
|
margin: 0.65rem 0 0;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #6f655c;
|
||||||
|
}
|
||||||
|
.fea-support-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
.fea-support-button {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.94rem;
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
.fea-support-button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 14px 24px -20px rgba(42, 35, 32, 0.55);
|
||||||
|
}
|
||||||
|
.fea-support-button.is-primary {
|
||||||
|
background: var(--fea-support-burgundy);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.fea-support-button.is-secondary {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--fea-support-ink);
|
||||||
|
border: 1px solid var(--fea-support-line);
|
||||||
|
}
|
||||||
|
.fea-support-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 300px;
|
||||||
|
gap: 2rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.fea-support-content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.fea-support-content > *:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.fea-support-content h2,
|
||||||
|
.fea-support-content h3 {
|
||||||
|
font-family: "Fraunces", Georgia, serif;
|
||||||
|
color: var(--fea-support-ink);
|
||||||
|
}
|
||||||
|
.fea-support-content h2 {
|
||||||
|
margin-top: 2rem;
|
||||||
|
font-size: clamp(1.5rem, 2.4vw, 2rem);
|
||||||
|
}
|
||||||
|
.fea-support-content h3 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
.fea-support-content p,
|
||||||
|
.fea-support-content li {
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
.fea-support-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 2rem;
|
||||||
|
}
|
||||||
|
.fea-support-sidebar__small {
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: #6f655c;
|
||||||
|
}
|
||||||
|
.fea-support-banner {
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 2rem auto 0;
|
||||||
|
padding: 1.4rem 1.5rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--fea-support-line);
|
||||||
|
background: linear-gradient(180deg, #f8f3eb 0%, #fffdf9 100%);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) minmax(260px, 0.85fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.fea-support-banner__title {
|
||||||
|
font-family: "Fraunces", Georgia, serif;
|
||||||
|
font-size: clamp(1.4rem, 2.6vw, 2rem);
|
||||||
|
margin: 0 0 0.45rem;
|
||||||
|
}
|
||||||
|
.fea-support-banner__text {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
.fea-support-banner__links {
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.fea-support-hero,
|
||||||
|
.fea-support-layout,
|
||||||
|
.fea-support-banner {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fea-support-sidebar {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<main id="wp--skip-link--target" class="fea-support-page">
|
||||||
|
<section class="fea-support-hero">
|
||||||
|
<div class="fea-support-hero__copy">
|
||||||
|
<span class="fea-support-hero__eyebrow"><?php echo esc_html($data['eyebrow'] ?? 'Apoya Fe Adulta'); ?></span>
|
||||||
|
<h1 class="fea-support-hero__title"><?php echo esc_html($data['hero_title'] ?? get_the_title()); ?></h1>
|
||||||
|
<p class="fea-support-hero__intro"><?php echo esc_html($data['hero_intro'] ?? ''); ?></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="fea-support-card" aria-label="Estado de la campaña">
|
||||||
|
<p class="fea-support-card__title">Objetivo de la campaña</p>
|
||||||
|
<?php echo fea_support_progress_html($data, 'page'); ?>
|
||||||
|
<?php echo fea_support_buttons_html($data, 'page'); ?>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="fea-support-layout">
|
||||||
|
<div class="fea-support-content">
|
||||||
|
<?php the_content(); ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="fea-support-sidebar">
|
||||||
|
<div class="fea-support-card">
|
||||||
|
<p class="fea-support-card__title">Colaborar ahora</p>
|
||||||
|
<?php echo fea_support_progress_html($data, 'sidebar'); ?>
|
||||||
|
<?php echo fea_support_buttons_html($data, 'sidebar'); ?>
|
||||||
|
<p class="fea-support-sidebar__small">
|
||||||
|
Si compartes esta página con otras personas de la comunidad, también nos ayudas a acercarnos al objetivo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
get_footer();
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* fea-ui — Ajustes estéticos globales del front (issue #95).
|
||||||
|
*
|
||||||
|
* 1. Foco/click: elimina el recuadro gris/negro por defecto al hacer click,
|
||||||
|
* mantiene foco visible y de marca SOLO con teclado (:focus-visible).
|
||||||
|
* 2. Menú: tipografía editorial Fraunces (coherente con los títulos),
|
||||||
|
* línea carmesí en hover/ítem activo, sin subrayado.
|
||||||
|
*
|
||||||
|
* CSS global (el menú y los enlaces están en todo el sitio), no solo en single.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('FEA_UI_CRIMSON')) {
|
||||||
|
define('FEA_UI_CRIMSON', '#8b1a2e'); // carmesí de marca
|
||||||
|
}
|
||||||
|
if (!defined('FEA_UI_INK')) {
|
||||||
|
define('FEA_UI_INK', '#2a2320'); // texto de marca
|
||||||
|
}
|
||||||
|
|
||||||
|
add_action('wp_head', function () {
|
||||||
|
if (is_admin()) return;
|
||||||
|
$crimson = FEA_UI_CRIMSON;
|
||||||
|
$ink = FEA_UI_INK;
|
||||||
|
?>
|
||||||
|
<style id="fea-ui">
|
||||||
|
/* ── 1. Foco / click ───────────────────────────────────────────── */
|
||||||
|
a, button, summary, [role="button"] { -webkit-tap-highlight-color: transparent; }
|
||||||
|
|
||||||
|
/* Sin outline al hacer click con ratón/dedo… */
|
||||||
|
a:focus:not(:focus-visible),
|
||||||
|
button:focus:not(:focus-visible) { outline: none; }
|
||||||
|
|
||||||
|
/* …pero foco visible y de marca al navegar con teclado (accesibilidad) */
|
||||||
|
a:focus-visible,
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 2px solid <?php echo $crimson; ?>;
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* El core agranda el outline del menú (outline-offset:4px) → lo reducimos */
|
||||||
|
.wp-block-navigation .wp-block-navigation-item .wp-block-navigation-item__content {
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 2. Menú — Opción C · Sans limpia minimalista (issue #95) ──── */
|
||||||
|
.wp-block-navigation .wp-block-navigation-item__content {
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
color: <?php echo $ink; ?>;
|
||||||
|
text-decoration: none;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nunca subrayado (el core lo pone en hover) */
|
||||||
|
.wp-block-navigation a.wp-block-navigation-item__content:hover,
|
||||||
|
.wp-block-navigation a.wp-block-navigation-item__content:focus {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Línea carmesí inferior, animada, en hover y en el ítem activo */
|
||||||
|
.wp-block-navigation > .wp-block-navigation__container > .wp-block-navigation-item
|
||||||
|
> .wp-block-navigation-item__content::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0; right: 0; bottom: -3px;
|
||||||
|
height: 2px;
|
||||||
|
background: <?php echo $crimson; ?>;
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left center;
|
||||||
|
transition: transform 0.18s ease;
|
||||||
|
}
|
||||||
|
.wp-block-navigation > .wp-block-navigation__container > .wp-block-navigation-item
|
||||||
|
> .wp-block-navigation-item__content:hover::after,
|
||||||
|
.wp-block-navigation > .wp-block-navigation__container > .wp-block-navigation-item
|
||||||
|
> .wp-block-navigation-item__content[aria-current]::after {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
.wp-block-navigation > .wp-block-navigation__container > .wp-block-navigation-item
|
||||||
|
> .wp-block-navigation-item__content[aria-current] {
|
||||||
|
color: <?php echo $crimson; ?>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Submenús: sin línea inferior; hover marca el texto en carmesí */
|
||||||
|
.wp-block-navigation__submenu-container .wp-block-navigation-item__content::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.wp-block-navigation__submenu-container .wp-block-navigation-item__content:hover,
|
||||||
|
.wp-block-navigation__submenu-container .wp-block-navigation-item__content[aria-current] {
|
||||||
|
color: <?php echo $crimson; ?>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 3. Fondo cálido en artículos (issue #78 feedback "demasiado blanca") ── */
|
||||||
|
body.single-post {
|
||||||
|
background-color: #f5f0eb !important;
|
||||||
|
--wp--preset--color--base: #f5f0eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
<?php
|
||||||
|
}, 25);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<?php remove_filter("template_redirect", "redirect_canonical"); add_filter("redirect_canonical", "__return_false"); remove_action("template_redirect", "wp_redirect_admin_locations", 1000);
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Aplica `clasificacion_articulos_regen.csv` a wp_term_relationships.
|
||||||
|
|
||||||
|
MODO CONSERVADOR (--mode=add): solo AÑADE las cats nuevas que el CSV indique
|
||||||
|
y que no estén ya. NO borra cats existentes. Maximiza seguridad — no perdemos
|
||||||
|
atribuciones legítimas que el CSV viejo o asignaciones manuales pusieran.
|
||||||
|
|
||||||
|
MODO ESTRICTO (--mode=replace): para los posts presentes en el CSV, sustituye
|
||||||
|
el conjunto de cats {1645,1646,1647,1648,1649,1650} por exactamente las que
|
||||||
|
el CSV indique. Borra las que sobren. Posts NO presentes en CSV no se tocan.
|
||||||
|
|
||||||
|
Recalcula `wp_term_taxonomy.count` al final.
|
||||||
|
|
||||||
|
Issue: rafa/feadulta#42
|
||||||
|
Uso: python3 aplicar_clasificacion_a_bd.py [--csv FILE] [--mode add|replace] [--dry-run]
|
||||||
|
"""
|
||||||
|
import argparse, csv, subprocess, sys
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pymysql
|
||||||
|
except ImportError:
|
||||||
|
sys.exit('requiere pymysql')
|
||||||
|
|
||||||
|
CAT_NAME_TO_TERM = {
|
||||||
|
'lectura': 1645,
|
||||||
|
'comentario_editorial':1646,
|
||||||
|
'comentario': 1647,
|
||||||
|
'eucaristia': 1648,
|
||||||
|
'multimedia': 1649,
|
||||||
|
'articulo': 1650,
|
||||||
|
# 'noticia': 1651, # no implementado
|
||||||
|
# 'otro': 1652, # no implementado
|
||||||
|
# 'effa': ?, # no implementado
|
||||||
|
}
|
||||||
|
MANAGED_TERMS = set(CAT_NAME_TO_TERM.values())
|
||||||
|
|
||||||
|
|
||||||
|
def get_conn():
|
||||||
|
ip = subprocess.run(['docker','inspect','wordpress-mysql','--format',
|
||||||
|
'{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'],
|
||||||
|
capture_output=True, text=True, check=True).stdout.strip()
|
||||||
|
return pymysql.connect(host=ip, user='wordpress_user', password='wordpress_pass',
|
||||||
|
database='wordpress_db', charset='utf8mb4', autocommit=False)
|
||||||
|
|
||||||
|
|
||||||
|
def get_term_taxonomy_ids(conn, term_ids):
|
||||||
|
"""Devuelve dict term_id → term_taxonomy_id para taxonomy='category'."""
|
||||||
|
with conn.cursor() as c:
|
||||||
|
c.execute(f"""
|
||||||
|
SELECT term_id, term_taxonomy_id FROM wp_term_taxonomy
|
||||||
|
WHERE taxonomy='category' AND term_id IN ({','.join(str(t) for t in term_ids)})
|
||||||
|
""")
|
||||||
|
return dict(c.fetchall())
|
||||||
|
|
||||||
|
|
||||||
|
def load_csv(path):
|
||||||
|
"""Devuelve dict post_id → set(cat_name)."""
|
||||||
|
out = defaultdict(set)
|
||||||
|
with open(path, encoding='utf-8') as f:
|
||||||
|
r = csv.DictReader(f)
|
||||||
|
for row in r:
|
||||||
|
pid = row.get('post_id')
|
||||||
|
cat = row.get('categoria_propuesta')
|
||||||
|
if pid and cat in CAT_NAME_TO_TERM:
|
||||||
|
out[int(pid)].add(cat)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def current_cats(conn, post_ids, tt_ids):
|
||||||
|
"""Para cada post devuelve set de term_ids de MANAGED_TERMS que tiene actualmente."""
|
||||||
|
if not post_ids: return {}
|
||||||
|
in_ttids = ','.join(str(t) for t in tt_ids)
|
||||||
|
in_pids = ','.join(str(p) for p in post_ids)
|
||||||
|
out = defaultdict(set)
|
||||||
|
with conn.cursor() as c:
|
||||||
|
c.execute(f"""
|
||||||
|
SELECT tr.object_id, tt.term_id
|
||||||
|
FROM wp_term_relationships tr
|
||||||
|
JOIN wp_term_taxonomy tt ON tt.term_taxonomy_id=tr.term_taxonomy_id
|
||||||
|
WHERE tr.object_id IN ({in_pids}) AND tt.term_taxonomy_id IN ({in_ttids})
|
||||||
|
""")
|
||||||
|
for pid, tid in c.fetchall():
|
||||||
|
out[pid].add(tid)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument('--csv', default='/tmp/clasif_new.csv')
|
||||||
|
ap.add_argument('--mode', choices=['add', 'replace'], default='add')
|
||||||
|
ap.add_argument('--dry-run', action='store_true')
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
print(f'CSV: {args.csv}', file=sys.stderr)
|
||||||
|
print(f'Mode: {args.mode}{" (DRY)" if args.dry_run else ""}', file=sys.stderr)
|
||||||
|
|
||||||
|
desired_by_pid = load_csv(args.csv)
|
||||||
|
print(f'Posts en CSV: {len(desired_by_pid)}', file=sys.stderr)
|
||||||
|
|
||||||
|
conn = get_conn()
|
||||||
|
term_to_tt = get_term_taxonomy_ids(conn, MANAGED_TERMS)
|
||||||
|
print(f'Term taxonomy ids: {term_to_tt}', file=sys.stderr)
|
||||||
|
if len(term_to_tt) != len(CAT_NAME_TO_TERM):
|
||||||
|
sys.exit(f'No encuentro todos los term_ids: {set(MANAGED_TERMS) - set(term_to_tt)}')
|
||||||
|
|
||||||
|
cat_to_tt = {name: term_to_tt[tid] for name, tid in CAT_NAME_TO_TERM.items()}
|
||||||
|
|
||||||
|
# Cats actuales para los posts del CSV
|
||||||
|
pids = list(desired_by_pid.keys())
|
||||||
|
BATCH = 5000
|
||||||
|
current_by_pid = {}
|
||||||
|
for i in range(0, len(pids), BATCH):
|
||||||
|
chunk = pids[i:i+BATCH]
|
||||||
|
current_by_pid.update(current_cats(conn, chunk, term_to_tt.values()))
|
||||||
|
|
||||||
|
# Computar añadir / quitar
|
||||||
|
to_add = [] # (object_id, term_taxonomy_id)
|
||||||
|
to_del = [] # (object_id, term_taxonomy_id)
|
||||||
|
for pid, desired_names in desired_by_pid.items():
|
||||||
|
desired_tids = {CAT_NAME_TO_TERM[n] for n in desired_names}
|
||||||
|
current_tids = current_by_pid.get(pid, set())
|
||||||
|
# Añadir las que estén en desired y no en current
|
||||||
|
for tid in desired_tids - current_tids:
|
||||||
|
to_add.append((pid, term_to_tt[tid]))
|
||||||
|
# En modo replace: quitar las MANAGED que estén en current y no en desired
|
||||||
|
if args.mode == 'replace':
|
||||||
|
for tid in current_tids - desired_tids:
|
||||||
|
to_del.append((pid, term_to_tt[tid]))
|
||||||
|
|
||||||
|
print(f'A añadir: {len(to_add)}', file=sys.stderr)
|
||||||
|
print(f'A quitar: {len(to_del)}', file=sys.stderr)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
# Muestra
|
||||||
|
print('\n--- 5 ejemplos añadir ---', file=sys.stderr)
|
||||||
|
for x in to_add[:5]: print(' ', x, file=sys.stderr)
|
||||||
|
print('\n--- 5 ejemplos quitar ---', file=sys.stderr)
|
||||||
|
for x in to_del[:5]: print(' ', x, file=sys.stderr)
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
with conn.cursor() as c:
|
||||||
|
# Bulk insert (INSERT IGNORE)
|
||||||
|
if to_add:
|
||||||
|
for i in range(0, len(to_add), 1000):
|
||||||
|
chunk = to_add[i:i+1000]
|
||||||
|
vals = ','.join(f'({p},{t})' for p, t in chunk)
|
||||||
|
c.execute(f'INSERT IGNORE INTO wp_term_relationships (object_id, term_taxonomy_id) VALUES {vals}')
|
||||||
|
if to_del:
|
||||||
|
for i in range(0, len(to_del), 1000):
|
||||||
|
chunk = to_del[i:i+1000]
|
||||||
|
conds = ' OR '.join(f'(object_id={p} AND term_taxonomy_id={t})' for p, t in chunk)
|
||||||
|
c.execute(f'DELETE FROM wp_term_relationships WHERE {conds}')
|
||||||
|
# Recalcular counts
|
||||||
|
in_ttids = ','.join(str(t) for t in term_to_tt.values())
|
||||||
|
c.execute(f"""
|
||||||
|
UPDATE wp_term_taxonomy tt
|
||||||
|
SET tt.count = (SELECT COUNT(*) FROM wp_term_relationships tr WHERE tr.term_taxonomy_id=tt.term_taxonomy_id)
|
||||||
|
WHERE tt.term_taxonomy_id IN ({in_ttids})
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
print('Commit OK.', file=sys.stderr)
|
||||||
|
|
||||||
|
# Conteos finales
|
||||||
|
with conn.cursor() as c:
|
||||||
|
c.execute(f"""
|
||||||
|
SELECT t.term_id, t.slug, tt.count FROM wp_term_taxonomy tt
|
||||||
|
JOIN wp_terms t USING(term_id)
|
||||||
|
WHERE tt.term_taxonomy_id IN ({in_ttids}) ORDER BY t.term_id
|
||||||
|
""")
|
||||||
|
print('\nCats finales:', file=sys.stderr)
|
||||||
|
for row in c.fetchall():
|
||||||
|
print(f' {row[0]:5d} {row[1]:30s} {row[2]}', file=sys.stderr)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* apply_lecturas_wp.php — Crea las traducciones de lecturas bíblicas casadas por
|
||||||
|
* referencia (lecturas_apply.py) y las asocia en Polylang. Idempotente.
|
||||||
|
*
|
||||||
|
* Ejecutar dentro del contenedor:
|
||||||
|
* docker cp /tmp/lecturas_creadas.json wordpress-web:/tmp/
|
||||||
|
* docker exec wordpress-web wp eval-file /tmp/apply_lecturas_wp.php [publish]
|
||||||
|
*/
|
||||||
|
$status = (isset($argv[1]) && $argv[1] === 'publish') ? 'publish' : 'draft';
|
||||||
|
$data = json_decode(file_get_contents('/tmp/lecturas_creadas.json'), true);
|
||||||
|
$created = 0; $posts_done = 0; $skipped = 0;
|
||||||
|
foreach ($data as $row) {
|
||||||
|
$es_id = (int) $row['es_id'];
|
||||||
|
$es = get_post($es_id);
|
||||||
|
if (!$es) { $skipped++; continue; }
|
||||||
|
$existing = function_exists('pll_get_post_translations') ? pll_get_post_translations($es_id) : ['es' => $es_id];
|
||||||
|
$group = $existing;
|
||||||
|
$es_cats = wp_get_post_categories($es_id);
|
||||||
|
foreach (['en', 'fr', 'it', 'pt'] as $L) {
|
||||||
|
if (!empty($existing[$L])) { $group[$L] = $existing[$L]; continue; }
|
||||||
|
if (empty($row['langs'][$L])) continue;
|
||||||
|
$id = wp_insert_post([
|
||||||
|
'post_title' => $es->post_title, // referencia bíblica (igual en todos)
|
||||||
|
'post_content' => $row['langs'][$L],
|
||||||
|
'post_status' => $status,
|
||||||
|
'post_type' => 'post',
|
||||||
|
'comment_status' => 'closed',
|
||||||
|
], true);
|
||||||
|
if (is_wp_error($id)) continue;
|
||||||
|
pll_set_post_language($id, $L);
|
||||||
|
$cats = [];
|
||||||
|
foreach ($es_cats as $c) { $t = pll_get_term($c, $L); if ($t) $cats[] = $t; }
|
||||||
|
if ($cats) wp_set_post_categories($id, $cats);
|
||||||
|
$group[$L] = $id;
|
||||||
|
$created++;
|
||||||
|
}
|
||||||
|
if (function_exists('pll_save_post_translations')) pll_save_post_translations($group);
|
||||||
|
$posts_done++;
|
||||||
|
}
|
||||||
|
echo "posts ES procesados: $posts_done | traducciones creadas: $created | status=$status | skip=$skipped\n";
|
||||||
@@ -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,50 @@
|
|||||||
|
<?php
|
||||||
|
$DEF = [
|
||||||
|
408 => ['en'=>'New Testament','fr'=>'Nouveau Testament','it'=>'Nuovo Testamento','pt'=>'Novo Testamento'],
|
||||||
|
409 => ['en'=>'Old Testament','fr'=>'Ancien Testament','it'=>'Antico Testamento','pt'=>'Antigo Testamento'],
|
||||||
|
];
|
||||||
|
$LOGIN = [408=>'nt', 409=>'at'];
|
||||||
|
// 1) crear/asegurar usuarios por idioma
|
||||||
|
$uid_map = []; // [es_author][lang] => uid
|
||||||
|
foreach ($DEF as $es_author => $names) {
|
||||||
|
foreach ($names as $L => $dn) {
|
||||||
|
$login = $LOGIN[$es_author].'-'.$L;
|
||||||
|
$u = get_user_by('login', $login);
|
||||||
|
if (!$u) {
|
||||||
|
$id = wp_insert_user(['user_login'=>$login,'user_pass'=>wp_generate_password(20),
|
||||||
|
'user_email'=>$login.'@feadulta.local','display_name'=>$dn,'role'=>'author','first_name'=>$dn]);
|
||||||
|
if (is_wp_error($id)) { echo "ERROR user $login: ".$id->get_error_message()."\n"; continue; }
|
||||||
|
echo "creado usuario $login (#$id) = $dn\n";
|
||||||
|
} else { $id = $u->ID; wp_update_user(['ID'=>$id,'display_name'=>$dn]); echo "existe $login (#$id)\n"; }
|
||||||
|
$uid_map[$es_author][$L] = $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2) reasignar autor de las traducciones según el autor del ES original
|
||||||
|
$reasig = 0;
|
||||||
|
foreach ([408,409] as $es_author) {
|
||||||
|
$posts = get_posts(['author'=>$es_author,'post_type'=>'post','post_status'=>['publish','draft'],
|
||||||
|
'posts_per_page'=>-1,'fields'=>'ids','lang'=>'es','no_found_rows'=>true]);
|
||||||
|
foreach ($posts as $es_id) {
|
||||||
|
$tr = pll_get_post_translations($es_id);
|
||||||
|
foreach (['en','fr','it','pt'] as $L) {
|
||||||
|
if (empty($tr[$L]) || empty($uid_map[$es_author][$L])) continue;
|
||||||
|
$p = get_post($tr[$L]);
|
||||||
|
if ($p && (int)$p->post_author !== (int)$uid_map[$es_author][$L]) {
|
||||||
|
wp_update_post(['ID'=>$tr[$L],'post_author'=>$uid_map[$es_author][$L]]);
|
||||||
|
$reasig++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo "ES autor $es_author: ".count($posts)." posts ES procesados\n";
|
||||||
|
}
|
||||||
|
// 2ª pasada: cualquier post no-ES que aún tenga el autor bíblico ES → autor del idioma
|
||||||
|
$reasig2 = 0;
|
||||||
|
foreach ([408,409] as $es_author) {
|
||||||
|
foreach (['en','fr','it','pt'] as $L) {
|
||||||
|
if (empty($uid_map[$es_author][$L])) continue;
|
||||||
|
$ids = get_posts(['author'=>$es_author,'lang'=>$L,'post_type'=>'post',
|
||||||
|
'post_status'=>['publish','draft'],'fields'=>'ids','posts_per_page'=>-1,'no_found_rows'=>true]);
|
||||||
|
foreach ($ids as $id) { wp_update_post(['ID'=>$id,'post_author'=>$uid_map[$es_author][$L]]); $reasig2++; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo "traducciones reasignadas de autor: $reasig (1ª) + $reasig2 (2ª directa)\n";
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
build_lectionary_index.py — Descarga el leccionario de evangelizo.ws para un rango de
|
||||||
|
fechas (un ciclo litúrgico completo cubre todas las lecturas) en es/en/fr/it/pt y
|
||||||
|
construye un índice POR REFERENCIA bíblica, para casar lecturas sin depender de fechas.
|
||||||
|
|
||||||
|
Salida: /tmp/lectionary_index.json { "LIBRO|cap|vers": {es,en,fr,it,pt: html} }
|
||||||
|
Cache por día/idioma en /tmp/evangelizo_cache (resumible).
|
||||||
|
|
||||||
|
Uso: python3 build_lectionary_index.py 2023-01-01 2025-12-31
|
||||||
|
"""
|
||||||
|
import sys, os, re, json, time, html, unicodedata, urllib.request
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
LANGS = {"SP": "es", "AM": "en", "FR": "fr", "IT": "it", "PT": "pt"}
|
||||||
|
CACHE = "/tmp/evangelizo_cache"
|
||||||
|
os.makedirs(CACHE, exist_ok=True)
|
||||||
|
INDEX = "/tmp/lectionary_index.json"
|
||||||
|
|
||||||
|
|
||||||
|
def norm_book(full_title):
|
||||||
|
# "Libro de Jeremías" / "Carta de san Pablo a los Romanos" -> "JEREMIAS"/"ROMANOS"
|
||||||
|
s = unicodedata.normalize("NFKD", full_title).encode("ascii", "ignore").decode().upper()
|
||||||
|
s = re.sub(r"[^A-Z0-9 ]", " ", s)
|
||||||
|
toks = [t for t in s.split() if t]
|
||||||
|
return toks[-1] if toks else ""
|
||||||
|
|
||||||
|
|
||||||
|
def clean(raw):
|
||||||
|
raw = html.unescape(raw or "")
|
||||||
|
raw = re.sub(r"\[\[[^\]]+\]\]", "", raw)
|
||||||
|
paras = [p.strip() for p in raw.split("\n") if p.strip()]
|
||||||
|
return "".join(f"<p>{p}</p>\n" for p in paras)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(date_s, lang_code):
|
||||||
|
fp = os.path.join(CACHE, f"{date_s}_{lang_code}.json")
|
||||||
|
if os.path.exists(fp):
|
||||||
|
try:
|
||||||
|
return json.load(open(fp))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
url = f"https://publication.evangelizo.ws/{lang_code}/days/{date_s}"
|
||||||
|
for a in range(3):
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "fea-lect/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
data = json.load(r)
|
||||||
|
out = []
|
||||||
|
for rd in data.get("data", {}).get("readings", []) or []:
|
||||||
|
out.append({
|
||||||
|
"code": rd.get("reading_code", ""),
|
||||||
|
"ref": (rd.get("reference_displayed") or "").strip().rstrip("."),
|
||||||
|
"book": (rd.get("book") or {}).get("full_title", ""),
|
||||||
|
"text": clean(rd.get("text", "")),
|
||||||
|
})
|
||||||
|
json.dump(out, open(fp, "w"), ensure_ascii=False)
|
||||||
|
time.sleep(0.15)
|
||||||
|
return out
|
||||||
|
except Exception:
|
||||||
|
if a == 2:
|
||||||
|
json.dump([], open(fp, "w"))
|
||||||
|
return []
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def key_from(book_full, ref):
|
||||||
|
m = re.match(r"(\d{1,3})\s*,\s*(\d{1,3})", ref)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
return f"{norm_book(book_full)}|{int(m.group(1))}|{int(m.group(2))}"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
d0 = date.fromisoformat(sys.argv[1])
|
||||||
|
d1 = date.fromisoformat(sys.argv[2])
|
||||||
|
days = (d1 - d0).days + 1
|
||||||
|
|
||||||
|
# Las fiestas trasladadas caen en fechas distintas por país/idioma → NO se puede
|
||||||
|
# casar dentro del mismo día. Indexamos por reading_code (estable entre idiomas)
|
||||||
|
# acumulando el texto de cada idioma desde CUALQUIER día donde aparezca.
|
||||||
|
code_text = {wl: {} for wl in LANGS.values()} # lang -> {code: text}
|
||||||
|
code_book = {} # code -> norm_book (del SP)
|
||||||
|
cur, n = d0, 0
|
||||||
|
while cur <= d1:
|
||||||
|
ds = cur.isoformat()
|
||||||
|
for lc, wl in LANGS.items():
|
||||||
|
for rd in fetch(ds, lc):
|
||||||
|
code = rd["code"]
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
if rd["text"] and code not in code_text[wl]:
|
||||||
|
code_text[wl][code] = rd["text"]
|
||||||
|
if wl == "es" and code not in code_book:
|
||||||
|
nb = norm_book(rd["book"])
|
||||||
|
m = re.search(r"(\d{1,3})\s*,\s*(\d{1,3})", code)
|
||||||
|
if nb and m:
|
||||||
|
code_book[code] = f"{nb}|{int(m.group(1))}|{int(m.group(2))}"
|
||||||
|
n += 1
|
||||||
|
if n % 60 == 0:
|
||||||
|
print(f" {n}/{days} días codes_es={len(code_text['es'])}", flush=True)
|
||||||
|
cur += timedelta(days=1)
|
||||||
|
|
||||||
|
# combinar: para cada code con clave-ES y texto en los 4 idiomas
|
||||||
|
index = {}
|
||||||
|
for code, key in code_book.items():
|
||||||
|
if key in index:
|
||||||
|
continue
|
||||||
|
entry = {}
|
||||||
|
ok = True
|
||||||
|
for wl in ("es", "en", "fr", "it", "pt"):
|
||||||
|
t = code_text[wl].get(code)
|
||||||
|
if not t:
|
||||||
|
ok = (wl == "es") and ok # es siempre presente; faltar otro descarta
|
||||||
|
if wl != "es":
|
||||||
|
ok = False
|
||||||
|
break
|
||||||
|
entry[wl] = t
|
||||||
|
if ok and all(l in entry for l in ("en", "fr", "it", "pt")):
|
||||||
|
index[key] = entry
|
||||||
|
json.dump(index, open(INDEX, "w"), ensure_ascii=False)
|
||||||
|
print(f"FIN. {n} días. codes_es={len(code_book)} → índice {len(index)} referencias en {INDEX}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+75
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fe Adulta — Carta de la Semana
|
||||||
|
* Description: Redirige las URLs de carta al archivo de categoría correspondiente.
|
||||||
|
* Version: 1.8
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Las categorías de carta actual/anterior siempre llevan al post traducido que
|
||||||
|
// corresponde a la categoría española canónica. No dependemos del count ni de
|
||||||
|
// las relaciones traducidas, que pueden quedar desfasadas durante una importación.
|
||||||
|
add_action("template_redirect", function() {
|
||||||
|
if (!is_category()) return;
|
||||||
|
$cat = get_queried_object();
|
||||||
|
if (!$cat || empty($cat->term_id)) return;
|
||||||
|
|
||||||
|
$source_cat_id = (int) $cat->term_id;
|
||||||
|
if (function_exists('pll_get_term')) {
|
||||||
|
$spanish_cat_id = (int) pll_get_term($source_cat_id, 'es');
|
||||||
|
if ($spanish_cat_id) $source_cat_id = $spanish_cat_id;
|
||||||
|
}
|
||||||
|
if (!in_array($source_cat_id, [6, 22], true)) return;
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$source_post_id = (int) $wpdb->get_var($wpdb->prepare(
|
||||||
|
"SELECT p.ID
|
||||||
|
FROM {$wpdb->posts} p
|
||||||
|
INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
|
||||||
|
INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
|
||||||
|
WHERE tt.taxonomy = 'category' AND tt.term_id = %d
|
||||||
|
AND p.post_type = 'post' AND p.post_status = 'publish'
|
||||||
|
ORDER BY p.post_date DESC, p.ID DESC
|
||||||
|
LIMIT 1",
|
||||||
|
$source_cat_id
|
||||||
|
));
|
||||||
|
if (!$source_post_id) return;
|
||||||
|
|
||||||
|
$post_id = $source_post_id;
|
||||||
|
if (function_exists('pll_current_language') && function_exists('pll_get_post')) {
|
||||||
|
$lang = pll_current_language();
|
||||||
|
$translated = $lang ? (int) pll_get_post($source_post_id, $lang) : 0;
|
||||||
|
if ($translated) $post_id = $translated;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = get_permalink($post_id);
|
||||||
|
if (!$url) return;
|
||||||
|
wp_safe_redirect($url, 302);
|
||||||
|
exit;
|
||||||
|
}, 9);
|
||||||
|
|
||||||
|
// Mostrar 50 artículos por página en los archivos de cartas
|
||||||
|
add_action("pre_get_posts", function($query) {
|
||||||
|
if (!$query->is_main_query() || is_admin()) return;
|
||||||
|
if ($query->is_category([
|
||||||
|
"cartasemana", "carta-semana-pasada", "cartas-de-otras-semanas",
|
||||||
|
"letter-of-the-week", "lettre-de-la-semaine", "lettera-della-settimana", "carta-da-semana",
|
||||||
|
"carta-semana-pasada-en", "carta-semana-pasada-fr",
|
||||||
|
"carta-semana-pasada-it", "carta-semana-pasada-pt",
|
||||||
|
"letters-from-other-weeks", "lettres-des-autres-semaines",
|
||||||
|
"lettere-delle-altre-settimane", "cartas-de-outras-semanas",
|
||||||
|
])) {
|
||||||
|
$query->set("posts_per_page", 50);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* create_buscar_page.php (#8) — Crea/repara la página dedicada /buscar.
|
||||||
|
* La página sirve como destino del enlace «Búsqueda avanzada» y muestra el formulario
|
||||||
|
* avanzado aunque no haya consulta activa.
|
||||||
|
*
|
||||||
|
* Idempotente de verdad:
|
||||||
|
* - Si /buscar NO existe → la crea (es) + traducciones (en/fr/it/pt) y las vincula.
|
||||||
|
* - Si /buscar YA existe → la deja, pero REPARA traducciones Polylang faltantes o
|
||||||
|
* no publicadas (crea las que falten, publica las que estén en borrador, revincula).
|
||||||
|
*
|
||||||
|
* Uso:
|
||||||
|
* wp eval-file scripts/create_buscar_page.php # DRY-RUN
|
||||||
|
* APPLY=1 wp eval-file scripts/create_buscar_page.php # aplica
|
||||||
|
*/
|
||||||
|
$apply = getenv('APPLY') === '1';
|
||||||
|
|
||||||
|
$titles = [
|
||||||
|
'es' => 'Búsqueda avanzada',
|
||||||
|
'en' => 'Advanced Search',
|
||||||
|
'fr' => 'Recherche avancée',
|
||||||
|
'it' => 'Ricerca avanzata',
|
||||||
|
'pt' => 'Pesquisa avançada',
|
||||||
|
];
|
||||||
|
// Contenido mínimo (sin bloques Gutenberg). El formulario se inyecta vía the_content.
|
||||||
|
$content = '<p>Utiliza el formulario de búsqueda avanzada para encontrar reflexiones, artículos y más.</p>';
|
||||||
|
|
||||||
|
$has_pll = function_exists('pll_set_post_language') && function_exists('pll_save_post_translations');
|
||||||
|
$pll_langs = (function_exists('pll_languages_list'))
|
||||||
|
? pll_languages_list(['fields' => 'slug'])
|
||||||
|
: ['es'];
|
||||||
|
|
||||||
|
/** Crea (o devuelve si existe) una página por slug, con idioma Polylang. */
|
||||||
|
function fea_buscar_ensure_page(string $slug, string $title, string $content, string $lang, bool $apply, bool $has_pll) {
|
||||||
|
$existing = get_page_by_path($slug, OBJECT, 'page');
|
||||||
|
if ($existing) {
|
||||||
|
// Asegurar que está publicada
|
||||||
|
if ($apply && $existing->post_status !== 'publish') {
|
||||||
|
wp_update_post(['ID' => $existing->ID, 'post_status' => 'publish']);
|
||||||
|
echo " · ({$lang}) página '{$slug}' existía en estado {$existing->post_status} → publicada (ID {$existing->ID})\n";
|
||||||
|
} else {
|
||||||
|
echo " · ({$lang}) página '{$slug}' ya existe y publicada (ID {$existing->ID})\n";
|
||||||
|
}
|
||||||
|
// Asegurar idioma asignado
|
||||||
|
if ($apply && $has_pll && function_exists('pll_get_post_language')) {
|
||||||
|
$cur = pll_get_post_language($existing->ID);
|
||||||
|
if ($cur !== $lang) { pll_set_post_language($existing->ID, $lang); echo " idioma → {$lang}\n"; }
|
||||||
|
}
|
||||||
|
return (int) $existing->ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo ($apply ? " · ({$lang}) creando" : " · ({$lang}) [dry] crearía") . " página '{$slug}'\n";
|
||||||
|
if (!$apply) return 0;
|
||||||
|
|
||||||
|
$id = wp_insert_post([
|
||||||
|
'post_type' => 'page',
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'post_name' => $slug,
|
||||||
|
'post_title' => $title,
|
||||||
|
'post_content' => $content,
|
||||||
|
'post_author' => 1,
|
||||||
|
], true);
|
||||||
|
if (is_wp_error($id)) { echo " ERROR: " . $id->get_error_message() . "\n"; return 0; }
|
||||||
|
if ($has_pll) pll_set_post_language($id, $lang);
|
||||||
|
echo " creada (ID {$id})\n";
|
||||||
|
return (int) $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo ($apply ? "APLICANDO" : "DRY-RUN") . " — crear/reparar /buscar\n";
|
||||||
|
|
||||||
|
// 1) Página ES (slug 'buscar')
|
||||||
|
$translations = [];
|
||||||
|
$es_id = fea_buscar_ensure_page('buscar', $titles['es'], $content, 'es', $apply, $has_pll);
|
||||||
|
if ($es_id) $translations['es'] = $es_id;
|
||||||
|
|
||||||
|
// 2) Traducciones (en/fr/it/pt) sólo si Polylang activo y el idioma existe
|
||||||
|
if ($has_pll) {
|
||||||
|
foreach (['en', 'fr', 'it', 'pt'] as $lang) {
|
||||||
|
if (!in_array($lang, $pll_langs, true)) { echo " · ({$lang}) idioma no configurado en Polylang, omitido\n"; continue; }
|
||||||
|
|
||||||
|
// Si ya hay traducción vinculada a la ES, reusarla
|
||||||
|
$linked = ($es_id && function_exists('pll_get_post')) ? (int) pll_get_post($es_id, $lang) : 0;
|
||||||
|
if ($linked) {
|
||||||
|
$p = get_post($linked);
|
||||||
|
if ($p && $p->post_status !== 'publish' && $apply) {
|
||||||
|
wp_update_post(['ID' => $linked, 'post_status' => 'publish']);
|
||||||
|
echo " · ({$lang}) traducción vinculada (ID {$linked}) estaba {$p->post_status} → publicada\n";
|
||||||
|
} else {
|
||||||
|
echo " · ({$lang}) traducción ya vinculada (ID {$linked})\n";
|
||||||
|
}
|
||||||
|
$translations[$lang] = $linked;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear/reparar por slug
|
||||||
|
$tl_id = fea_buscar_ensure_page('buscar-' . $lang, $titles[$lang], $content, $lang, $apply, $has_pll);
|
||||||
|
if ($tl_id) $translations[$lang] = $tl_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Revincular todas las traducciones
|
||||||
|
if ($apply && count($translations) > 1) {
|
||||||
|
pll_save_post_translations($translations);
|
||||||
|
echo " · traducciones revinculadas: " . implode(', ', array_map(
|
||||||
|
fn($l, $id) => "{$l}={$id}", array_keys($translations), $translations)) . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (function_exists('wp_cache_flush')) wp_cache_flush();
|
||||||
|
echo ($apply ? "APLICADO" : "DRY-RUN") . "\n";
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
|
||||||
|
$ES = (int)(getenv('ES_ID') ?: 2682); // MATEO 10, 26-33 (es)
|
||||||
|
$src = get_post($ES);
|
||||||
|
$tr_html = json_decode(file_get_contents(getenv('LECTURAS_JSON') ?: '/tmp/lecturas_mateo.json'), true);
|
||||||
|
$BOOK = ['en'=>'MATTHEW','fr'=>'MATTHIEU','it'=>'MATTEO','pt'=>'MATEUS'];
|
||||||
|
$VER = ['en'=>'Douay-Rheims Bible','fr'=>'Bible du Semeur 2015','it'=>'Nuova Riveduta 2006','pt'=>'Bíblia CNBB 2002'];
|
||||||
|
$REST = getenv('LECTURA_TITULO_ES') ?: 'MATEO 10, 26-33'; // título es
|
||||||
|
$tail = preg_replace('~^MATEO~','',$REST); // " 10, 26-33"
|
||||||
|
$es_cats = wp_get_post_categories($ES);
|
||||||
|
$target_ids = array_values(array_filter(array_map('intval', explode(',', getenv('TARGET_IDS') ?: ''))));
|
||||||
|
$target_map = [];
|
||||||
|
foreach (['en','fr','it','pt'] as $idx => $lang0) {
|
||||||
|
if (!empty($target_ids[$idx])) $target_map[$lang0] = (int)$target_ids[$idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
$grp = pll_get_post_translations($ES); if(!$grp) $grp=['es'=>$ES];
|
||||||
|
foreach (['en','fr','it','pt'] as $lang) {
|
||||||
|
$exist = (int)pll_get_post($ES,$lang);
|
||||||
|
if ($exist && get_post($exist)) { echo "$lang ya existe #$exist — saltado\n"; $grp[$lang]=$exist; continue; }
|
||||||
|
$title = $BOOK[$lang].$tail;
|
||||||
|
$postarr = [
|
||||||
|
'post_title'=>$title,'post_content'=>$tr_html[$lang],'post_status'=>'publish',
|
||||||
|
'post_type'=>'post','post_author'=>(int)$src->post_author,'post_date'=>$src->post_date,
|
||||||
|
];
|
||||||
|
$target = (int)($target_map[$lang] ?? 0);
|
||||||
|
if ($target && get_post($target)) {
|
||||||
|
$postarr['ID'] = $target;
|
||||||
|
} elseif ($target) {
|
||||||
|
$postarr['import_id'] = $target;
|
||||||
|
}
|
||||||
|
$id = wp_insert_post($postarr, true);
|
||||||
|
if (is_wp_error($id)) { echo "$lang ERROR ".$id->get_error_message()."\n"; continue; }
|
||||||
|
if ($target && (int)$id !== $target) { echo "$lang ERROR id esperado $target creado $id\n"; continue; }
|
||||||
|
pll_set_post_language($id,$lang);
|
||||||
|
$mapped=[]; foreach($es_cats as $c){ $tc=(int)pll_get_term($c,$lang); $mapped[]=$tc?:$c; }
|
||||||
|
wp_set_post_categories($id, array_values(array_unique($mapped)));
|
||||||
|
$grp[$lang]=$id; pll_save_post_translations($grp);
|
||||||
|
update_post_meta($id,'lectura_fuente',$VER[$lang]);
|
||||||
|
update_post_meta($id,'traduccion_origen',$ES);
|
||||||
|
echo "$lang creado #$id «$title» [".$VER[$lang]."]\n";
|
||||||
|
}
|
||||||
|
echo "Grupo final: ".json_encode(pll_get_post_translations($ES))."\n";
|
||||||
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)."
|
||||||
Executable
+69
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Ciclo carta nueva — sincroniza "esta semana", "semana pasada" y "otras semanas"
|
||||||
|
* en TODOS los idiomas (ES + EN/FR/IT/PT).
|
||||||
|
*
|
||||||
|
* Deriva los términos por Polylang desde los términos ES base:
|
||||||
|
* cartasemana = term 6 | otras semanas = term 21 | semana pasada = term 22
|
||||||
|
*
|
||||||
|
* Uso: CARTA=<es_id> php demote_old_cartasemana.php (dry-run)
|
||||||
|
* APPLY=1 CARTA=<es_id> php demote_old_cartasemana.php
|
||||||
|
*/
|
||||||
|
require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
|
||||||
|
$APPLY = getenv('APPLY') === '1';
|
||||||
|
$CARTA = (int)(getenv('CARTA') ?: 0);
|
||||||
|
if (!$CARTA) { fwrite(STDERR,"Falta CARTA=<es_id>\n"); exit(1); }
|
||||||
|
|
||||||
|
$cs_terms = pll_get_term_translations(6); // cartasemana por idioma
|
||||||
|
$otras_terms = pll_get_term_translations(21); // cartas de otras semanas por idioma
|
||||||
|
$last_terms = pll_get_term_translations(22); // carta semana pasada por idioma
|
||||||
|
$carta_tr = pll_get_post_translations($CARTA);
|
||||||
|
$last_es_posts = get_posts([
|
||||||
|
'post_type' => 'post',
|
||||||
|
'numberposts' => 1,
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'fields' => 'ids',
|
||||||
|
'cat' => 22,
|
||||||
|
'orderby' => 'date',
|
||||||
|
'order' => 'DESC',
|
||||||
|
'suppress_filters' => true,
|
||||||
|
]);
|
||||||
|
$last_es = (int) ($last_es_posts[0] ?? 0);
|
||||||
|
$last_tr = $last_es ? pll_get_post_translations($last_es) : [];
|
||||||
|
|
||||||
|
foreach ($cs_terms as $lang=>$cs) {
|
||||||
|
$keep = $carta_tr[$lang] ?? 0;
|
||||||
|
$otras = $otras_terms[$lang] ?? 0;
|
||||||
|
$last = $last_terms[$lang] ?? 0;
|
||||||
|
$keep_last = $last_tr[$lang] ?? 0;
|
||||||
|
$posts = get_posts(['post_type'=>'post','numberposts'=>-1,'post_status'=>'any','fields'=>'ids',
|
||||||
|
'tax_query'=>[['taxonomy'=>'category','field'=>'term_id','terms'=>$cs]]]);
|
||||||
|
$moved=0;
|
||||||
|
foreach ($posts as $pid) {
|
||||||
|
if ($pid == $keep) continue;
|
||||||
|
if ($APPLY) {
|
||||||
|
if ($otras) wp_set_object_terms($pid,[(int)$otras],'category',true); // append a otras
|
||||||
|
wp_remove_object_terms($pid,[(int)$cs],'category');
|
||||||
|
}
|
||||||
|
$moved++;
|
||||||
|
}
|
||||||
|
$last_posts = $last ? get_posts([
|
||||||
|
'post_type'=>'post','numberposts'=>-1,'post_status'=>'any','fields'=>'ids',
|
||||||
|
'tax_query'=>[['taxonomy'=>'category','field'=>'term_id','terms'=>$last]],
|
||||||
|
]) : [];
|
||||||
|
$last_removed = 0;
|
||||||
|
foreach ($last_posts as $pid) {
|
||||||
|
if ($pid == $keep_last) continue;
|
||||||
|
if ($APPLY) wp_remove_object_terms($pid, [(int)$last], 'category');
|
||||||
|
$last_removed++;
|
||||||
|
}
|
||||||
|
if ($APPLY && $last && $keep_last) {
|
||||||
|
wp_set_object_terms($keep_last, [(int)$last], 'category', true);
|
||||||
|
}
|
||||||
|
if ($APPLY) clean_term_cache(array_filter([$cs,$otras,$last]),'category');
|
||||||
|
$t=get_term($cs);
|
||||||
|
echo sprintf("%s: %s %d de actual | anterior=#%d (limpia %d) | '%s' keep=#%d\n",
|
||||||
|
strtoupper($lang), $APPLY?"movidas":"se moverían", $moved,
|
||||||
|
$keep_last, $last_removed, $t->slug, $keep);
|
||||||
|
}
|
||||||
|
echo $APPLY ? "APLICADO\n" : "DRY-RUN (APPLY=1 para aplicar)\n";
|
||||||
Executable
+282
@@ -0,0 +1,282 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Paso 1 del upgrade PHP 8.3 en feadulta.com (issue #46):
|
||||||
|
# subir 5 ficheros parcheados + limpieza malware, manteniendo PHP 7.4.
|
||||||
|
#
|
||||||
|
# Modos:
|
||||||
|
# --dry-run imprime lo que haría sin tocar nada (default)
|
||||||
|
# --apply ejecuta el despliegue real
|
||||||
|
# --rollback BACKUP_DIR restaura desde un backup pre-step1 concreto
|
||||||
|
#
|
||||||
|
# Pre-flight estricto: aborta si los 5 ficheros remotos no coinciden con el
|
||||||
|
# hash esperado (el que verificamos hoy 2026-05-25). Si en prod cambió algo
|
||||||
|
# entre planning y ejecución, hay que rehacer la planificación.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ─── Config ────────────────────────────────────────────────────────────────
|
||||||
|
SSH_USER="feadulta"
|
||||||
|
SSH_HOST="134.0.10.170"
|
||||||
|
SSH_PASS='6Rm2qOF@eundwpda'
|
||||||
|
|
||||||
|
REMOTE_WEB_ROOT="/web"
|
||||||
|
LOCAL_SRC_ROOT="/home/rafa/joomla-migration/joomla-php83"
|
||||||
|
BACKUP_ROOT="/home/rafa/joomla-migration/backup/prod-20260525-php83-compat"
|
||||||
|
|
||||||
|
# Mapa: ruta_relativa | hash_local_esperado | hash_remoto_esperado_actual
|
||||||
|
# (verificado 2026-05-25 con md5sum local + ssh + tar -xzO del backup)
|
||||||
|
declare -a FILES=(
|
||||||
|
"modules/mod_featcats/helper.php|01ae5ad40e13abdcd5852897786d3733|744922888ae533b090eb34effe3bb469"
|
||||||
|
"templates/fe_adulta_1/functions.php|06b2c26a618dbceefa5d8d5f0293c2cf|6318edec84cdf7a6b84a1d07f063c6c0"
|
||||||
|
"templates/fe_adulta_1/index.php|dc318909b9ae5976e5c4f01c06c35f66|9793dfa3c880eba37ad5ad35e6988705"
|
||||||
|
"modules/mod_k2_filter/helper.php|0d9767a0d8d67aa420baefe38382a87c|8530a4e70043973fd2d625c6f8de6ce9"
|
||||||
|
"modules/mod_k2_filter/tmpl/Default/template.php|a62a39dacafa0748528268b9f04aa844|008465147acdeb442eca6f64311fb23d"
|
||||||
|
)
|
||||||
|
|
||||||
|
# URLs de smoke test (deben devolver HTTP 200 antes y después del cambio)
|
||||||
|
declare -a SMOKE_URLS=(
|
||||||
|
"https://www.feadulta.com/"
|
||||||
|
"https://www.feadulta.com/es/"
|
||||||
|
"https://www.feadulta.com/es/ayuda.html"
|
||||||
|
"https://www.feadulta.com/es/quienessomos/colaboradores.html"
|
||||||
|
"https://www.feadulta.com/es/buscadoravanzado/itemlist/"
|
||||||
|
"https://www.feadulta.com/es/buscadoravanzado/itemlist/user/43-fraymarcos.html"
|
||||||
|
"https://www.feadulta.com/es/comentcol2.html"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Patrones de spam que deben dar 0 hits en las respuestas tras el paso 1
|
||||||
|
SPAM_REGEX="(apuestadeportiva|vavada\.mobi|inkabet|betsafe|betcris|botbotbot)"
|
||||||
|
|
||||||
|
# IP del origen para bypass de Cloudflare (la web está detrás de CF managed challenge,
|
||||||
|
# las peticiones curl normales reciben 403). --resolve nos lleva directo al origen.
|
||||||
|
ORIGIN_IP="134.0.10.170"
|
||||||
|
SMOKE_UA="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"
|
||||||
|
|
||||||
|
# ─── Utilidades ────────────────────────────────────────────────────────────
|
||||||
|
# Logs van a stderr; stdout queda libre para "datos" (p.ej. ruta del backup
|
||||||
|
# para capturar con $(...) sin contaminación). Bug detectado en revisión #46.
|
||||||
|
log() { printf '%s [%s] %s\n' "$(date +'%H:%M:%S')" "$1" "$2" >&2; }
|
||||||
|
info() { log "INFO" "$1"; }
|
||||||
|
warn() { log "WARN" "$1"; }
|
||||||
|
err() { log "ERR " "$1"; }
|
||||||
|
ok() { log "OK " "$1"; }
|
||||||
|
|
||||||
|
ssh_run() {
|
||||||
|
SSHPASS="$SSH_PASS" sshpass -e ssh -o StrictHostKeyChecking=no -o ConnectTimeout=15 \
|
||||||
|
"$SSH_USER@$SSH_HOST" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# El cPanel jail rechaza scp y sftp ("Connection closed"). Usamos cat-over-ssh,
|
||||||
|
# que sí funciona (cat está en /usr/bin/cat). Verificación 2026-05-25: download
|
||||||
|
# de un fichero conocido reproduce el mismo MD5; upload de bytes y verificación
|
||||||
|
# por re-lectura sin pérdidas.
|
||||||
|
scp_get() {
|
||||||
|
local remote="$1" local_target="$2"
|
||||||
|
SSHPASS="$SSH_PASS" sshpass -e ssh -o StrictHostKeyChecking=no -o ConnectTimeout=15 \
|
||||||
|
"$SSH_USER@$SSH_HOST" "cat '$remote'" > "$local_target"
|
||||||
|
}
|
||||||
|
|
||||||
|
scp_put() {
|
||||||
|
local local_src="$1" remote_target="$2"
|
||||||
|
SSHPASS="$SSH_PASS" sshpass -e ssh -o StrictHostKeyChecking=no -o ConnectTimeout=15 \
|
||||||
|
"$SSH_USER@$SSH_HOST" "cat > '$remote_target'" < "$local_src"
|
||||||
|
}
|
||||||
|
|
||||||
|
remote_md5() {
|
||||||
|
ssh_run "md5sum '$1' 2>/dev/null | cut -d' ' -f1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Pre-flight checks ─────────────────────────────────────────────────────
|
||||||
|
preflight() {
|
||||||
|
info "Pre-flight: verificar hashes locales y remotos"
|
||||||
|
local fail=0
|
||||||
|
|
||||||
|
for entry in "${FILES[@]}"; do
|
||||||
|
IFS='|' read -r rel hl hr_expected <<<"$entry"
|
||||||
|
local local_path="$LOCAL_SRC_ROOT/$rel"
|
||||||
|
local remote_path="$REMOTE_WEB_ROOT/$rel"
|
||||||
|
|
||||||
|
if [[ ! -f "$local_path" ]]; then
|
||||||
|
err "Local no existe: $local_path"; fail=1; continue
|
||||||
|
fi
|
||||||
|
local hl_actual
|
||||||
|
hl_actual=$(md5sum "$local_path" | cut -d' ' -f1)
|
||||||
|
if [[ "$hl_actual" != "$hl" ]]; then
|
||||||
|
err "Local $rel: hash $hl_actual ≠ esperado $hl"; fail=1
|
||||||
|
else
|
||||||
|
ok "Local $rel coincide ($hl)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local hr_actual
|
||||||
|
hr_actual=$(remote_md5 "$remote_path")
|
||||||
|
if [[ -z "$hr_actual" ]]; then
|
||||||
|
err "Remoto no existe o vacío: $remote_path"; fail=1
|
||||||
|
elif [[ "$hr_actual" != "$hr_expected" ]]; then
|
||||||
|
err "Remoto $rel: hash $hr_actual ≠ esperado $hr_expected (algo cambió en prod, replanificar)"; fail=1
|
||||||
|
else
|
||||||
|
ok "Remoto $rel coincide ($hr_actual)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ $fail -eq 1 ]]; then
|
||||||
|
err "Pre-flight FAIL → abortando"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
ok "Pre-flight OK"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Backup pre-cambio (los 5 ficheros remotos) ────────────────────────────
|
||||||
|
backup_pre_step1() {
|
||||||
|
local ts; ts=$(date +'%Y%m%d-%H%M%S')
|
||||||
|
local dir="$BACKUP_ROOT/pre-step1-$ts"
|
||||||
|
|
||||||
|
if [[ "$MODE" == "dry-run" ]]; then
|
||||||
|
info "[dry-run] Crearía directorio: $dir"
|
||||||
|
info "[dry-run] Descargaría ${#FILES[@]} ficheros remotos a ese directorio + md5sums.txt + tar.gz"
|
||||||
|
echo "$dir" # imprime para usar después
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Creando backup pre-step1 en $dir"
|
||||||
|
mkdir -p "$dir"
|
||||||
|
|
||||||
|
for entry in "${FILES[@]}"; do
|
||||||
|
IFS='|' read -r rel _ hr_expected <<<"$entry"
|
||||||
|
local remote_path="$REMOTE_WEB_ROOT/$rel"
|
||||||
|
local local_target="$dir/$rel"
|
||||||
|
mkdir -p "$(dirname "$local_target")"
|
||||||
|
info " Descargando $remote_path"
|
||||||
|
scp_get "$remote_path" "$local_target"
|
||||||
|
if [[ ! -s "$local_target" ]]; then
|
||||||
|
err "Descarga vacía: $local_target — ABORT (revisar acceso SSH)"
|
||||||
|
exit 6
|
||||||
|
fi
|
||||||
|
local h_after
|
||||||
|
h_after=$(md5sum "$local_target" | cut -d' ' -f1)
|
||||||
|
if [[ "$h_after" != "$hr_expected" ]]; then
|
||||||
|
err "Backup $rel: md5 $h_after ≠ esperado $hr_expected — ABORT"
|
||||||
|
exit 6
|
||||||
|
fi
|
||||||
|
ok " Backup $rel verificado ($h_after)"
|
||||||
|
done
|
||||||
|
|
||||||
|
(cd "$dir" && find . -type f -name '*.php' -exec md5sum {} \; > md5sums.txt)
|
||||||
|
tar -czf "$dir.tar.gz" -C "$BACKUP_ROOT" "pre-step1-$ts"
|
||||||
|
ok "Backup pre-step1 creado: $dir"
|
||||||
|
ok "Backup tar.gz: $dir.tar.gz"
|
||||||
|
echo "$dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Subida de los 5 ficheros ──────────────────────────────────────────────
|
||||||
|
upload_files() {
|
||||||
|
for entry in "${FILES[@]}"; do
|
||||||
|
IFS='|' read -r rel hl _ <<<"$entry"
|
||||||
|
local local_path="$LOCAL_SRC_ROOT/$rel"
|
||||||
|
local remote_path="$REMOTE_WEB_ROOT/$rel"
|
||||||
|
|
||||||
|
if [[ "$MODE" == "dry-run" ]]; then
|
||||||
|
info "[dry-run] scp $local_path → $remote_path"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Subiendo $rel"
|
||||||
|
scp_put "$local_path" "$remote_path"
|
||||||
|
local hr_after; hr_after=$(remote_md5 "$remote_path")
|
||||||
|
if [[ "$hr_after" != "$hl" ]]; then
|
||||||
|
err "Subida de $rel: hash remoto post-subida $hr_after ≠ local $hl"
|
||||||
|
err "ABORT — el fichero remoto no coincide con el local. Considerar rollback."
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
ok "Subido + verificado $rel ($hr_after)"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Smoke test HTTP ───────────────────────────────────────────────────────
|
||||||
|
# allow_spam=1 → solo validar HTTP code (uso post-rollback, donde los ficheros
|
||||||
|
# restaurados llevan las inyecciones spam originales y un spam>0 es esperado).
|
||||||
|
smoke_test() {
|
||||||
|
local allow_spam="${1:-0}"
|
||||||
|
if [[ "$allow_spam" -eq 1 ]]; then
|
||||||
|
info "Smoke test HTTP (post-rollback: solo validar HTTP, spam>0 esperado)"
|
||||||
|
else
|
||||||
|
info "Smoke test HTTP (HTTP 200/3xx + spam=0)"
|
||||||
|
fi
|
||||||
|
local fail=0
|
||||||
|
for url in "${SMOKE_URLS[@]}"; do
|
||||||
|
if [[ "$MODE" == "dry-run" ]]; then
|
||||||
|
info "[dry-run] curl $url + grep spam"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
local code body_spam tmp
|
||||||
|
tmp=$(mktemp)
|
||||||
|
code=$(curl -ksL -A "$SMOKE_UA" --resolve "www.feadulta.com:443:$ORIGIN_IP" \
|
||||||
|
-o "$tmp" -w "%{http_code}" "$url" || echo "ERR")
|
||||||
|
body_spam=$(grep -cE "$SPAM_REGEX" "$tmp" || true)
|
||||||
|
rm -f "$tmp"
|
||||||
|
if [[ "$code" != "200" && "$code" != "301" && "$code" != "302" ]]; then
|
||||||
|
err " $url → HTTP $code"; fail=1
|
||||||
|
elif [[ "$allow_spam" -eq 0 && "$body_spam" -gt 0 ]]; then
|
||||||
|
err " $url → HTTP $code, $body_spam strings spam"; fail=1
|
||||||
|
else
|
||||||
|
ok " $url → HTTP $code, $body_spam spam"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [[ $fail -eq 1 ]]; then
|
||||||
|
err "Smoke test FAIL → considerar rollback manual con --rollback <backup-dir>"
|
||||||
|
exit 4
|
||||||
|
fi
|
||||||
|
ok "Smoke test OK"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Rollback desde backup ─────────────────────────────────────────────────
|
||||||
|
rollback() {
|
||||||
|
local dir="$1"
|
||||||
|
if [[ ! -d "$dir" ]]; then
|
||||||
|
err "Backup dir no existe: $dir"; exit 5
|
||||||
|
fi
|
||||||
|
info "Rollback desde $dir"
|
||||||
|
for entry in "${FILES[@]}"; do
|
||||||
|
IFS='|' read -r rel _ _ <<<"$entry"
|
||||||
|
local local_src="$dir/$rel"
|
||||||
|
local remote_path="$REMOTE_WEB_ROOT/$rel"
|
||||||
|
if [[ ! -f "$local_src" ]]; then
|
||||||
|
err " No hay backup para $rel — saltando"; continue
|
||||||
|
fi
|
||||||
|
info " Restaurando $rel"
|
||||||
|
scp_put "$local_src" "$remote_path"
|
||||||
|
ok " Restaurado $rel"
|
||||||
|
done
|
||||||
|
ok "Rollback completado"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Main ──────────────────────────────────────────────────────────────────
|
||||||
|
MODE="dry-run"
|
||||||
|
ROLLBACK_DIR=""
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--dry-run) MODE="dry-run"; shift ;;
|
||||||
|
--apply) MODE="apply"; shift ;;
|
||||||
|
--rollback) MODE="rollback"; ROLLBACK_DIR="${2:-}"; shift 2 ;;
|
||||||
|
-h|--help) sed -n '1,12p' "$0"; exit 0 ;;
|
||||||
|
*) err "Flag desconocido: $1"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
info "Modo: $MODE"
|
||||||
|
|
||||||
|
if [[ "$MODE" == "rollback" ]]; then
|
||||||
|
if [[ -z "$ROLLBACK_DIR" ]]; then err "--rollback requiere ruta del backup"; exit 1; fi
|
||||||
|
rollback "$ROLLBACK_DIR"
|
||||||
|
smoke_test 1 # allow_spam=1: ficheros restaurados aún tienen las inyecciones
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
preflight
|
||||||
|
backup_dir=$(backup_pre_step1)
|
||||||
|
upload_files
|
||||||
|
smoke_test
|
||||||
|
|
||||||
|
ok "Paso 1 finalizado en modo: $MODE"
|
||||||
|
if [[ "$MODE" == "apply" ]]; then
|
||||||
|
ok "Backup pre-step1: $backup_dir"
|
||||||
|
ok "Para rollback: $0 --rollback $backup_dir"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Issue #80 — detecta traducciones con fragmentos en español sin traducir.
|
||||||
|
* Señal: una frase sin traducir queda IDÉNTICA a la del ES original.
|
||||||
|
* Para cada traducción (en/fr/it/pt) compara sus frases contra el set de frases
|
||||||
|
* del ES enlazado (Polylang) y calcula el % de caracteres que coinciden literal.
|
||||||
|
*
|
||||||
|
* Uso (en contenedor): php detect_untranslated.php [umbral] [status]
|
||||||
|
* umbral: ratio mínimo para marcar (def 0.12)
|
||||||
|
* status: draft (def) | publish | any
|
||||||
|
*/
|
||||||
|
require "/var/www/html/wp-load.php";
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$THRESH = isset($argv[1]) ? (float)$argv[1] : 0.12;
|
||||||
|
$STATUS = $argv[2] ?? 'draft';
|
||||||
|
|
||||||
|
function norm_text($html) {
|
||||||
|
$t = preg_replace('~(?i)</p>|<br\s*/?>|</h[1-6]>~', "\n", $html);
|
||||||
|
$t = preg_replace('~<[^>]+>~', ' ', $t);
|
||||||
|
$t = preg_replace('~\[[^\]]+\]~', ' ', $t);
|
||||||
|
$t = html_entity_decode($t, ENT_QUOTES);
|
||||||
|
return $t;
|
||||||
|
}
|
||||||
|
/** Frases normalizadas de longitud >= 40 (las cortas dan falsos positivos). */
|
||||||
|
function sentences($html) {
|
||||||
|
$t = norm_text($html);
|
||||||
|
$parts = preg_split('~(?<=[.!?…])\s+|\n+~u', $t);
|
||||||
|
$out = [];
|
||||||
|
foreach ($parts as $s) {
|
||||||
|
$s = trim(preg_replace('~\s+~u', ' ', $s));
|
||||||
|
$s = mb_strtolower($s);
|
||||||
|
if (mb_strlen($s) >= 40) $out[$s] = mb_strlen($s);
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
$statuses = $STATUS === 'any' ? ['draft','publish'] : [$STATUS];
|
||||||
|
$in = "'" . implode("','", $statuses) . "'";
|
||||||
|
$ids = $wpdb->get_col(
|
||||||
|
"SELECT p.ID FROM wp_posts p
|
||||||
|
JOIN wp_term_relationships tr ON tr.object_id=p.ID
|
||||||
|
JOIN wp_term_taxonomy tt ON tt.term_taxonomy_id=tr.term_taxonomy_id AND tt.taxonomy='language'
|
||||||
|
JOIN wp_terms t ON t.term_id=tt.term_id AND t.slug IN ('en','fr','it','pt')
|
||||||
|
WHERE p.post_type='post' AND p.post_status IN ($in)
|
||||||
|
GROUP BY p.ID"
|
||||||
|
);
|
||||||
|
|
||||||
|
$by_lang = []; $offenders = [];
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
$lang = pll_get_post_language($id);
|
||||||
|
$es = pll_get_post((int)$id, 'es');
|
||||||
|
if (!$es) continue;
|
||||||
|
$tr_s = sentences(get_post($id)->post_content);
|
||||||
|
if (!$tr_s) continue;
|
||||||
|
$es_s = sentences(get_post($es)->post_content);
|
||||||
|
if (!$es_s) continue;
|
||||||
|
$total = array_sum($tr_s); $match = 0;
|
||||||
|
foreach ($tr_s as $s => $len) if (isset($es_s[$s])) $match += $len;
|
||||||
|
$ratio = $total ? $match / $total : 0;
|
||||||
|
$by_lang[$lang]['n'] = ($by_lang[$lang]['n'] ?? 0) + 1;
|
||||||
|
if ($ratio >= $THRESH) {
|
||||||
|
$by_lang[$lang]['bad'] = ($by_lang[$lang]['bad'] ?? 0) + 1;
|
||||||
|
$offenders[] = [$id, $lang, $es, round($ratio, 2), get_post($id)->post_title];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($offenders, fn($a, $b) => $b[3] <=> $a[3]);
|
||||||
|
echo "=== Traducciones con fragmentos ES (ratio >= $THRESH, status=$STATUS) ===\n";
|
||||||
|
foreach ($offenders as $o)
|
||||||
|
echo sprintf("#%d [%s] ratio=%.2f es=%d %s\n", $o[0], $o[1], $o[3], $o[2], mb_substr($o[4], 0, 45));
|
||||||
|
echo "\n--- resumen por idioma ---\n";
|
||||||
|
foreach ($by_lang as $l => $d)
|
||||||
|
echo sprintf("%s: %d/%d con fragmentos ES\n", $l, $d['bad'] ?? 0, $d['n']);
|
||||||
|
echo "TOTAL ofensores: " . count($offenders) . "\n";
|
||||||
|
// Volcar IDs para el reprocesado
|
||||||
|
file_put_contents('/tmp/untranslated_ids.txt', implode("\n", array_map(fn($o) => $o[0], $offenders)));
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
download_lecturas.py — Descarga lecturas bíblicas litúrgicas (texto católico oficial)
|
||||||
|
desde evangelizo.org en es/en/fr/it/pt, para una fecha litúrgica dada.
|
||||||
|
|
||||||
|
Fuente: feed.evangelizo.org/v2/reader.php (lecturas del día, leccionario católico).
|
||||||
|
Códigos de idioma evangelizo: SP=es, AM=en, FR=fr, IT=it, PT=pt.
|
||||||
|
|
||||||
|
Uso: python3 download_lecturas.py 2026-06-21 [--books Jeremías,Romanos]
|
||||||
|
Salida: JSON a stdout con {libro: {lang: {title, html}}}.
|
||||||
|
"""
|
||||||
|
import sys, re, html, json, urllib.request
|
||||||
|
|
||||||
|
LANGS = {"SP": "es", "AM": "en", "FR": "fr", "IT": "it", "PT": "pt"}
|
||||||
|
# nombre del libro por idioma (para casar el bloque correcto)
|
||||||
|
BOOK_ALIASES = {
|
||||||
|
"Jeremías": ["Jeremías", "Jeremiah", "Jérémie", "Geremia", "Jeremias"],
|
||||||
|
"Romanos": ["Romanos", "Romans", "Romains", "Romani"],
|
||||||
|
"Mateo": ["Mateo", "Matthew", "Matthieu", "Matteo", "Mateus"],
|
||||||
|
"Marcos": ["Marcos", "Mark", "Marc", "Marco", "Marcos"],
|
||||||
|
"Lucas": ["Lucas", "Luke", "Luc", "Luca", "Lucas"],
|
||||||
|
"Juan": ["Juan", "John", "Jean", "Giovanni", "João"],
|
||||||
|
}
|
||||||
|
|
||||||
|
REF_RE = re.compile(r"(\d{1,3}\s*,[\d.\-\s]+)\.?$")
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(date, lang_code):
|
||||||
|
url = f"https://feed.evangelizo.org/v2/reader.php?date={date}&lang={lang_code}&type=all"
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "fea-lecturas/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
raw = r.read().decode("utf-8", "replace")
|
||||||
|
raw = re.sub(r"<br\s*/?>", "\n", raw)
|
||||||
|
raw = re.sub(r"<[^>]+>", "", raw)
|
||||||
|
txt = html.unescape(raw)
|
||||||
|
return [l.strip() for l in txt.split("\n") if l.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def is_header(line):
|
||||||
|
return len(line) < 110 and bool(REF_RE.search(line))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_blocks(lines):
|
||||||
|
"""Devuelve [(header, [parrafos])] saltando la 1ª línea (título del día)."""
|
||||||
|
blocks = []
|
||||||
|
cur_h, cur_t = None, []
|
||||||
|
for ln in lines[1:]:
|
||||||
|
if is_header(ln):
|
||||||
|
if cur_h:
|
||||||
|
blocks.append((cur_h, cur_t))
|
||||||
|
cur_h, cur_t = ln, []
|
||||||
|
else:
|
||||||
|
if cur_h:
|
||||||
|
cur_t.append(ln)
|
||||||
|
if cur_h:
|
||||||
|
blocks.append((cur_h, cur_t))
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def short_title(header):
|
||||||
|
"""'Carta de San Pablo a los Romanos 5,12-15.' -> 'ROMANOS 5,12-15'."""
|
||||||
|
m = re.search(r"([A-Za-zÀ-ÿ]+)\s+(\d{1,3}\s*,[\d.\-\s]+)\.?$", header)
|
||||||
|
if not m:
|
||||||
|
return header.rstrip(".")
|
||||||
|
return m.group(1).upper() + " " + re.sub(r"\s+", "", m.group(2)).rstrip(".")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
date = sys.argv[1]
|
||||||
|
books = ["Jeremías", "Romanos"]
|
||||||
|
if "--books" in sys.argv:
|
||||||
|
books = sys.argv[sys.argv.index("--books") + 1].split(",")
|
||||||
|
|
||||||
|
result = {b: {} for b in books}
|
||||||
|
for code, wl in LANGS.items():
|
||||||
|
blocks = parse_blocks(fetch(date, code))
|
||||||
|
for b in books:
|
||||||
|
aliases = BOOK_ALIASES.get(b, [b])
|
||||||
|
for header, paras in blocks:
|
||||||
|
if any(a.lower() in header.lower() for a in aliases):
|
||||||
|
htmlc = "".join(f"<p>{p}</p>\n" for p in paras)
|
||||||
|
result[b][wl] = {"title": short_title(header), "html": htmlc, "header": header}
|
||||||
|
break
|
||||||
|
json.dump(result, sys.stdout, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Recorta avatares cuadrados centrados en la cara, usando Haar cascade de OpenCV.
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
python3 face_crop_avatar.py <src> <dst> [--size 256] [--padding 0.6]
|
||||||
|
python3 face_crop_avatar.py --batch <src_dir> <dst_dir> [--size 256]
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
- Detecta caras frontales (haarcascade_frontalface_default).
|
||||||
|
- Si encuentra >=1: coge la mayor, expande con padding (factor del lado de la cara)
|
||||||
|
y recorta cuadrado.
|
||||||
|
- Si encuentra 0: fallback a crop cuadrado centrado en el TERCIO SUPERIOR
|
||||||
|
de la imagen (donde suele estar la cabeza en fotos verticales).
|
||||||
|
- Redimensiona el cuadrado a `size x size`.
|
||||||
|
|
||||||
|
Mantiene aspecto natural — NO estira.
|
||||||
|
"""
|
||||||
|
import argparse, os, sys
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
DEFAULT_SIZE = 256
|
||||||
|
DEFAULT_PADDING = 0.6 # factor del lado de la cara para añadir alrededor
|
||||||
|
|
||||||
|
cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
|
||||||
|
profile_cascade_path = cv2.data.haarcascades + 'haarcascade_profileface.xml'
|
||||||
|
_face_cascade = cv2.CascadeClassifier(cascade_path)
|
||||||
|
_profile_cascade = cv2.CascadeClassifier(profile_cascade_path)
|
||||||
|
|
||||||
|
|
||||||
|
def detect_face(gray):
|
||||||
|
"""Devuelve (x, y, w, h) de la cara más grande, o None."""
|
||||||
|
for cascade in (_face_cascade, _profile_cascade):
|
||||||
|
faces = cascade.detectMultiScale(
|
||||||
|
gray, scaleFactor=1.1, minNeighbors=5, minSize=(30, 30)
|
||||||
|
)
|
||||||
|
if len(faces):
|
||||||
|
# más grande
|
||||||
|
return max(faces, key=lambda r: r[2] * r[3])
|
||||||
|
# también lateral en flip
|
||||||
|
if cascade is _profile_cascade:
|
||||||
|
flipped = cv2.flip(gray, 1)
|
||||||
|
faces2 = cascade.detectMultiScale(flipped, 1.1, 5, minSize=(30, 30))
|
||||||
|
if len(faces2):
|
||||||
|
x, y, w, h = max(faces2, key=lambda r: r[2] * r[3])
|
||||||
|
return (gray.shape[1] - x - w, y, w, h)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def square_crop_box(face, img_w, img_h, padding):
|
||||||
|
"""Caja cuadrada centrada en la cara. Si el padding no cabe sin invadir
|
||||||
|
lados opuestos (típicamente texto), se REDUCE el side antes que extender.
|
||||||
|
"""
|
||||||
|
x, y, w, h = face
|
||||||
|
cx, cy = x + w / 2, y + h / 2
|
||||||
|
ideal = max(w, h) * (1 + 2 * padding)
|
||||||
|
# side máximo manteniendo cara centrada y dentro de la imagen
|
||||||
|
max_x = 2 * min(cx, img_w - cx)
|
||||||
|
max_y = 2 * min(cy, img_h - cy)
|
||||||
|
side = min(ideal, max_x, max_y)
|
||||||
|
half = side / 2
|
||||||
|
x1, y1 = int(cx - half), int(cy - half)
|
||||||
|
x2, y2 = int(cx + half), int(cy + half)
|
||||||
|
return x1, y1, x2, y2
|
||||||
|
|
||||||
|
|
||||||
|
def fallback_box(img_w, img_h):
|
||||||
|
"""Sin cara detectada. Heurística por aspect ratio:
|
||||||
|
- Horizontal (w > h*1.3): cuadrado a la IZQUIERDA (col_* suelen tener foto
|
||||||
|
a la izquierda y texto a la derecha).
|
||||||
|
- Vertical o cuadrado: cuadrado anclado al tercio superior, centrado en x.
|
||||||
|
"""
|
||||||
|
if img_w > img_h * 1.3:
|
||||||
|
side = img_h
|
||||||
|
return 0, 0, side, side
|
||||||
|
side = min(img_w, img_h)
|
||||||
|
cx = img_w / 2
|
||||||
|
x1 = max(0, int(cx - side / 2))
|
||||||
|
return x1, 0, x1 + side, side
|
||||||
|
|
||||||
|
|
||||||
|
def process(src_path, dst_path, size=DEFAULT_SIZE, padding=DEFAULT_PADDING):
|
||||||
|
img = cv2.imread(src_path, cv2.IMREAD_UNCHANGED)
|
||||||
|
if img is None:
|
||||||
|
return False, 'imread fail'
|
||||||
|
# convertir alpha → blanco si hace falta
|
||||||
|
if img.ndim == 3 and img.shape[2] == 4:
|
||||||
|
# composite sobre blanco
|
||||||
|
bgr = img[:, :, :3].copy()
|
||||||
|
alpha = img[:, :, 3] / 255.0
|
||||||
|
white = (1 - alpha[:, :, None]) * 255
|
||||||
|
img = (bgr * alpha[:, :, None] + white).astype('uint8')
|
||||||
|
elif img.ndim == 2:
|
||||||
|
img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
|
||||||
|
|
||||||
|
h, w = img.shape[:2]
|
||||||
|
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
||||||
|
face = detect_face(gray)
|
||||||
|
if face is not None:
|
||||||
|
x1, y1, x2, y2 = square_crop_box(face, w, h, padding)
|
||||||
|
used = 'face'
|
||||||
|
else:
|
||||||
|
x1, y1, x2, y2 = fallback_box(w, h)
|
||||||
|
used = 'fallback'
|
||||||
|
|
||||||
|
crop = img[y1:y2, x1:x2]
|
||||||
|
resized = cv2.resize(crop, (size, size), interpolation=cv2.INTER_AREA)
|
||||||
|
# asegurar JPEG-safe (sin alpha)
|
||||||
|
if resized.ndim == 3 and resized.shape[2] == 4:
|
||||||
|
resized = cv2.cvtColor(resized, cv2.COLOR_BGRA2BGR)
|
||||||
|
os.makedirs(os.path.dirname(dst_path), exist_ok=True)
|
||||||
|
cv2.imwrite(dst_path, resized, [cv2.IMWRITE_JPEG_QUALITY, 88])
|
||||||
|
return True, used
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument('src')
|
||||||
|
ap.add_argument('dst')
|
||||||
|
ap.add_argument('--size', type=int, default=DEFAULT_SIZE)
|
||||||
|
ap.add_argument('--padding', type=float, default=DEFAULT_PADDING)
|
||||||
|
ap.add_argument('--batch', action='store_true', help='src y dst son directorios')
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if args.batch:
|
||||||
|
files = [f for f in os.listdir(args.src) if f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp'))]
|
||||||
|
stats = {'face': 0, 'fallback': 0, 'fail': 0}
|
||||||
|
for fn in files:
|
||||||
|
ok, info = process(
|
||||||
|
os.path.join(args.src, fn),
|
||||||
|
os.path.join(args.dst, os.path.splitext(fn)[0] + '.jpg'),
|
||||||
|
size=args.size, padding=args.padding,
|
||||||
|
)
|
||||||
|
stats['fail' if not ok else info] += 1
|
||||||
|
print(f'face: {stats["face"]}, fallback: {stats["fallback"]}, fail: {stats["fail"]}')
|
||||||
|
else:
|
||||||
|
ok, info = process(args.src, args.dst, size=args.size, padding=args.padding)
|
||||||
|
print(f'{"OK" if ok else "FAIL"} {info} → {args.dst}')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -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,62 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* IO mínimo de posts WP para el reprocesador EN.
|
||||||
|
* get <id> -> escribe /tmp/fea_es.json {title, content, status}
|
||||||
|
* update <id> <titlef> <bodyf> -> actualiza post_title/post_content desde ficheros
|
||||||
|
* Carga wp-load; portable (local docker o prod via FEA_WP_LOAD).
|
||||||
|
*/
|
||||||
|
$WP = getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
|
||||||
|
require $WP;
|
||||||
|
|
||||||
|
$action = $argv[1] ?? '';
|
||||||
|
|
||||||
|
if ($action === 'get') {
|
||||||
|
$id = (int)$argv[2];
|
||||||
|
$p = get_post($id);
|
||||||
|
if (!$p) { fwrite(STDERR, "no existe $id\n"); exit(1); }
|
||||||
|
file_put_contents('/tmp/fea_es.json', json_encode([
|
||||||
|
'id' => $id,
|
||||||
|
'title' => $p->post_title,
|
||||||
|
'content' => $p->post_content,
|
||||||
|
'status' => $p->post_status,
|
||||||
|
], JSON_UNESCAPED_UNICODE));
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'update') {
|
||||||
|
$id = (int)$argv[2];
|
||||||
|
$title = rtrim(file_get_contents($argv[3]), "\r\n");
|
||||||
|
$body = file_get_contents($argv[4]);
|
||||||
|
if (!get_post($id)) { fwrite(STDERR, "no existe $id\n"); exit(1); }
|
||||||
|
$r = wp_update_post([
|
||||||
|
'ID' => $id,
|
||||||
|
'post_title' => $title,
|
||||||
|
'post_content' => $body,
|
||||||
|
], true);
|
||||||
|
if (is_wp_error($r)) { fwrite(STDERR, "error: " . $r->get_error_message() . "\n"); exit(1); }
|
||||||
|
fwrite(STDOUT, "ok actualizado $id\n");
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'getmeta') {
|
||||||
|
echo get_post_meta((int)$argv[2], $argv[3], true);
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'setaudio') { // setaudio <id> <relpath>
|
||||||
|
$id = (int)$argv[2];
|
||||||
|
update_post_meta($id, 'fea_audio_url', home_url($argv[3]));
|
||||||
|
update_post_meta($id, 'fea_audio_voice', 'NicoFeadulta2026');
|
||||||
|
update_post_meta($id, 'fea_audio_done', '1');
|
||||||
|
delete_post_meta($id, 'fea_audio_error');
|
||||||
|
fwrite(STDOUT, "ok " . home_url($argv[3]) . "\n");
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'setflag') { // setflag <id> <key> <value>
|
||||||
|
update_post_meta((int)$argv[2], $argv[3], $argv[4]);
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDERR, "uso: get|update|getmeta|setaudio|setflag\n");
|
||||||
|
exit(2);
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Helper PHP para translate_post.py — corre DENTRO del contenedor WP cargando wp-load.php
|
||||||
|
* (no necesita wp-cli ni proc_open). Centraliza la lógica de WordPress/Polylang.
|
||||||
|
*
|
||||||
|
* Uso (vía `docker exec wordpress-web php /tmp/fea_translate_helper.php <subcomando> ...`):
|
||||||
|
* read <id> → JSON {id,title,content,excerpt,lang,status,author,date,cats}
|
||||||
|
* read_full <id> → JSON con slug, metas, categorías y grupo Polylang
|
||||||
|
* exists <es_id> <lang> → imprime el ID de la traducción en <lang> (0 si no hay)
|
||||||
|
* create <es_id> <lang> <status> (lee {title,content} por stdin)
|
||||||
|
* → crea el post traducido, lo enlaza con Polylang y mete metas;
|
||||||
|
* imprime el nuevo ID.
|
||||||
|
* clone <target_id> <lang> <status> (lee payload JSON por stdin)
|
||||||
|
* → inserta/actualiza un post con ID explícito, categorías y metas.
|
||||||
|
* save_translations → guarda un grupo Polylang exacto leído por stdin.
|
||||||
|
*
|
||||||
|
* Ver issue rafa/feadulta#75.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Bootstrap portable. Si WP no está cargado (modo standalone), cargar wp-load.
|
||||||
|
// Local (docker): /var/www/html/wp-load.php (por defecto).
|
||||||
|
// Prod: export FEA_WP_LOAD=/web/wp-nuevo/wp-load.php
|
||||||
|
// (Si se ejecuta vía `wp eval-file`, ABSPATH ya está definido y no se recarga.)
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
$_SERVER['REQUEST_URI'] = $_SERVER['REQUEST_URI'] ?? '/';
|
||||||
|
$_SERVER['HTTP_HOST'] = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||||
|
require_once (getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('pll_set_post_language')) {
|
||||||
|
fwrite(STDERR, "Polylang no disponible\n");
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cmd = $argv[1] ?? '';
|
||||||
|
|
||||||
|
function out_json($data): void { echo wp_json_encode($data); }
|
||||||
|
|
||||||
|
function meta_payload(int $id): array {
|
||||||
|
$raw = get_post_meta($id);
|
||||||
|
$out = [];
|
||||||
|
foreach ($raw as $key => $values) {
|
||||||
|
if (in_array($key, ['_edit_lock', '_edit_last'], true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[$key] = array_map('maybe_unserialize', (array) $values);
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize_meta_input(array $payload): array {
|
||||||
|
$meta = $payload['meta'] ?? [];
|
||||||
|
if (!is_array($meta)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$out = [];
|
||||||
|
foreach ($meta as $key => $values) {
|
||||||
|
if (!is_string($key) || $key === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!is_array($values)) {
|
||||||
|
$values = [$values];
|
||||||
|
}
|
||||||
|
$out[$key] = $values;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function set_meta_payload(int $id, array $meta): void {
|
||||||
|
foreach ($meta as $key => $values) {
|
||||||
|
delete_post_meta($id, $key);
|
||||||
|
foreach ($values as $value) {
|
||||||
|
add_post_meta($id, $key, maybe_serialize($value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($cmd) {
|
||||||
|
case 'read': {
|
||||||
|
$id = (int) ($argv[2] ?? 0);
|
||||||
|
$p = get_post($id);
|
||||||
|
if (!$p) { fwrite(STDERR, "post $id no existe\n"); exit(3); }
|
||||||
|
out_json([
|
||||||
|
'id' => $p->ID,
|
||||||
|
'title' => $p->post_title,
|
||||||
|
'content' => $p->post_content,
|
||||||
|
'excerpt' => $p->post_excerpt,
|
||||||
|
'lang' => function_exists('pll_get_post_language') ? pll_get_post_language($id) : '',
|
||||||
|
'status' => $p->post_status,
|
||||||
|
'author' => (int) $p->post_author,
|
||||||
|
'date' => $p->post_date,
|
||||||
|
'cats' => wp_get_post_categories($id),
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'read_full': {
|
||||||
|
$id = (int) ($argv[2] ?? 0);
|
||||||
|
$p = get_post($id);
|
||||||
|
if (!$p) { fwrite(STDERR, "post $id no existe\n"); exit(3); }
|
||||||
|
out_json([
|
||||||
|
'id' => $p->ID,
|
||||||
|
'title' => $p->post_title,
|
||||||
|
'content' => $p->post_content,
|
||||||
|
'excerpt' => $p->post_excerpt,
|
||||||
|
'slug' => $p->post_name,
|
||||||
|
'lang' => function_exists('pll_get_post_language') ? pll_get_post_language($id) : '',
|
||||||
|
'status' => $p->post_status,
|
||||||
|
'author' => (int) $p->post_author,
|
||||||
|
'date' => $p->post_date,
|
||||||
|
'date_gmt' => $p->post_date_gmt,
|
||||||
|
'type' => $p->post_type,
|
||||||
|
'cats' => wp_get_post_categories($id),
|
||||||
|
'cat_slugs' => array_values(array_map(static fn($t) => $t->slug, get_the_terms($id, 'category') ?: [])),
|
||||||
|
'meta' => meta_payload($id),
|
||||||
|
'translations' => function_exists('pll_get_post_translations') ? pll_get_post_translations($id) : [],
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'exists': {
|
||||||
|
$es = (int) ($argv[2] ?? 0);
|
||||||
|
$lang = (string) ($argv[3] ?? '');
|
||||||
|
$t = (int) pll_get_post($es, $lang);
|
||||||
|
if ($t && !get_post($t)) $t = 0; // enlace colgado a un post borrado
|
||||||
|
echo $t;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'unlink': {
|
||||||
|
// Borra la traducción en <lang> y la saca del grupo (para --force / limpieza).
|
||||||
|
$es = (int) ($argv[2] ?? 0);
|
||||||
|
$lang = (string) ($argv[3] ?? '');
|
||||||
|
$t = (int) pll_get_post($es, $lang);
|
||||||
|
if ($t && get_post($t)) wp_delete_post($t, true);
|
||||||
|
$tr = function_exists('pll_get_post_translations') ? pll_get_post_translations($es) : ['es' => $es];
|
||||||
|
unset($tr[$lang]);
|
||||||
|
if ($tr) pll_save_post_translations($tr);
|
||||||
|
echo $t;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'create': {
|
||||||
|
$es = (int) ($argv[2] ?? 0);
|
||||||
|
$lang = (string) ($argv[3] ?? '');
|
||||||
|
$status = (string) ($argv[4] ?? 'draft');
|
||||||
|
$src = get_post($es);
|
||||||
|
if (!$src) { fwrite(STDERR, "post fuente $es no existe\n"); exit(3); }
|
||||||
|
|
||||||
|
$payload = json_decode(file_get_contents('php://stdin'), true);
|
||||||
|
if (!is_array($payload) || empty($payload['title'])) {
|
||||||
|
fwrite(STDERR, "payload inválido por stdin\n"); exit(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ¿ya existe (y vivo)? idempotencia dura.
|
||||||
|
$existing = (int) pll_get_post($es, $lang);
|
||||||
|
if ($existing && !get_post($existing)) $existing = 0;
|
||||||
|
if ($existing) { echo $existing; break; }
|
||||||
|
|
||||||
|
$new_id = wp_insert_post([
|
||||||
|
'post_title' => wp_slash($payload['title']),
|
||||||
|
'post_content' => wp_slash($payload['content'] ?? ''),
|
||||||
|
'post_excerpt' => wp_slash($payload['excerpt'] ?? ''),
|
||||||
|
'post_name' => sanitize_title($payload['title']),
|
||||||
|
'post_status' => $status,
|
||||||
|
'post_type' => 'post',
|
||||||
|
'post_author' => (int) $src->post_author,
|
||||||
|
'post_date' => $src->post_date,
|
||||||
|
'to_ping' => '',
|
||||||
|
'pinged' => '',
|
||||||
|
], true);
|
||||||
|
|
||||||
|
if (is_wp_error($new_id)) { fwrite(STDERR, $new_id->get_error_message() . "\n"); exit(5); }
|
||||||
|
|
||||||
|
// Idioma primero, para que las categorías traducidas casen con el idioma del post.
|
||||||
|
pll_set_post_language($new_id, $lang);
|
||||||
|
|
||||||
|
// Categorías: mapea cada categoría ES a su traducción en el idioma destino
|
||||||
|
// (las categorías de carta ya están traducidas: cartasemana 6→en 3077, fr 3083…).
|
||||||
|
$cats = wp_get_post_categories($es);
|
||||||
|
$mapped = [];
|
||||||
|
foreach ($cats as $c) {
|
||||||
|
$tc = function_exists('pll_get_term') ? (int) pll_get_term($c, $lang) : 0;
|
||||||
|
$mapped[] = $tc ?: $c; // traducida si existe; si no, la ES (fallback)
|
||||||
|
}
|
||||||
|
if ($mapped) wp_set_post_categories($new_id, array_values(array_unique($mapped)));
|
||||||
|
|
||||||
|
// Enlace de traducción (preservando el grupo existente).
|
||||||
|
$tr = function_exists('pll_get_post_translations') ? pll_get_post_translations($es) : ['es' => $es];
|
||||||
|
if (!$tr) $tr = ['es' => $es];
|
||||||
|
$tr[$lang] = $new_id;
|
||||||
|
pll_save_post_translations($tr);
|
||||||
|
|
||||||
|
// Metas de trazabilidad.
|
||||||
|
update_post_meta($new_id, 'traduccion_automatica', '1');
|
||||||
|
update_post_meta($new_id, 'traduccion_origen', $es);
|
||||||
|
update_post_meta($new_id, 'traduccion_modelo', $payload['model'] ?? '');
|
||||||
|
update_post_meta($new_id, 'traduccion_fecha', gmdate('c'));
|
||||||
|
|
||||||
|
echo $new_id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'clone': {
|
||||||
|
$target = (int) ($argv[2] ?? 0);
|
||||||
|
$lang = (string) ($argv[3] ?? '');
|
||||||
|
$status = (string) ($argv[4] ?? 'draft');
|
||||||
|
if ($target <= 0 || $lang === '') {
|
||||||
|
fwrite(STDERR, "uso: clone <target_id> <lang> <status>\n"); exit(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = json_decode(file_get_contents('php://stdin'), true);
|
||||||
|
if (!is_array($payload) || empty($payload['title'])) {
|
||||||
|
fwrite(STDERR, "payload inválido por stdin\n"); exit(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
$postarr = [
|
||||||
|
'post_title' => wp_slash($payload['title']),
|
||||||
|
'post_content' => wp_slash($payload['content'] ?? ''),
|
||||||
|
'post_excerpt' => wp_slash($payload['excerpt'] ?? ''),
|
||||||
|
'post_status' => $status ?: ($payload['status'] ?? 'draft'),
|
||||||
|
'post_type' => $payload['type'] ?? 'post',
|
||||||
|
'post_author' => (int) ($payload['author'] ?? 1),
|
||||||
|
'post_date' => $payload['date'] ?? current_time('mysql'),
|
||||||
|
'post_date_gmt'=> $payload['date_gmt'] ?? current_time('mysql', true),
|
||||||
|
'post_name' => $payload['slug'] ?? '',
|
||||||
|
'to_ping' => '',
|
||||||
|
'pinged' => '',
|
||||||
|
];
|
||||||
|
|
||||||
|
$existing = get_post($target);
|
||||||
|
if ($existing) {
|
||||||
|
$postarr['ID'] = $target;
|
||||||
|
$new_id = wp_update_post($postarr, true);
|
||||||
|
} else {
|
||||||
|
$postarr['import_id'] = $target;
|
||||||
|
$new_id = wp_insert_post($postarr, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_wp_error($new_id)) { fwrite(STDERR, $new_id->get_error_message() . "\n"); exit(5); }
|
||||||
|
if ((int) $new_id !== $target) {
|
||||||
|
fwrite(STDERR, "ID preservado falló: esperado $target, creado $new_id\n"); exit(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
pll_set_post_language($new_id, $lang);
|
||||||
|
|
||||||
|
$cats = [];
|
||||||
|
foreach ((array) ($payload['cat_slugs'] ?? []) as $slug) {
|
||||||
|
$term = get_term_by('slug', (string) $slug, 'category');
|
||||||
|
if ($term && !is_wp_error($term)) {
|
||||||
|
$cats[] = (int) $term->term_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$cats) {
|
||||||
|
$cats = array_values(array_unique(array_map('intval', (array) ($payload['cats'] ?? []))));
|
||||||
|
}
|
||||||
|
wp_set_post_categories($new_id, $cats);
|
||||||
|
|
||||||
|
set_meta_payload($new_id, normalize_meta_input($payload));
|
||||||
|
clean_post_cache($new_id);
|
||||||
|
echo $new_id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'save_translations': {
|
||||||
|
$payload = json_decode(file_get_contents('php://stdin'), true);
|
||||||
|
if (!is_array($payload) || empty($payload['translations']) || !is_array($payload['translations'])) {
|
||||||
|
fwrite(STDERR, "payload inválido por stdin\n"); exit(4);
|
||||||
|
}
|
||||||
|
$tr = [];
|
||||||
|
foreach ($payload['translations'] as $lang => $id) {
|
||||||
|
$id = (int) $id;
|
||||||
|
if (!is_string($lang) || $lang === '' || $id <= 0 || !get_post($id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$tr[$lang] = $id;
|
||||||
|
}
|
||||||
|
if (count($tr) < 2) {
|
||||||
|
fwrite(STDERR, "grupo insuficiente\n"); exit(8);
|
||||||
|
}
|
||||||
|
pll_save_post_translations($tr);
|
||||||
|
out_json($tr);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
fwrite(STDERR, "subcomando desconocido: '$cmd'\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Descarga un pasaje bíblico en EN/FR/IT/PT desde bolls.life (issue #88).
|
||||||
|
NO traducir la Biblia con LLM: descargar versiones oficiales.
|
||||||
|
Edita BOOK/CH/V0/V1 y TR según el pasaje. Salida JSON {lang: html} a /tmp/lecturas_<x>.json
|
||||||
|
Traducciones usadas (carta 46956, Mateo 10,26-33): EN=DRB (católica), PT=CNBB (católica),
|
||||||
|
IT=NR06 (única moderna IT), FR=BDS. Libros bolls: Mateo=40. Ver languages.json del sitio.
|
||||||
|
"""
|
||||||
|
import json, urllib.request, re, sys
|
||||||
|
TR={"en":"DRB","fr":"BDS","it":"NR06","pt":"CNBB"}
|
||||||
|
BOOK=int(sys.argv[1]) if len(sys.argv)>1 else 40
|
||||||
|
CH=int(sys.argv[2]) if len(sys.argv)>2 else 10
|
||||||
|
V0=int(sys.argv[3]) if len(sys.argv)>3 else 26
|
||||||
|
V1=int(sys.argv[4]) if len(sys.argv)>4 else 33
|
||||||
|
def clean(t): return re.sub(r"\s+"," ",re.sub(r"<[^>]+>"," ",t)).strip()
|
||||||
|
out={}
|
||||||
|
for lang,code in TR.items():
|
||||||
|
data=json.load(urllib.request.urlopen(f"https://bolls.life/get-text/{code}/{BOOK}/{CH}/",timeout=20))
|
||||||
|
v={x["verse"]:clean(x["text"]) for x in data if V0<=x["verse"]<=V1}
|
||||||
|
out[lang]="\n".join(f'<p><sup><strong>{n}</strong></sup> {v[n]}</p>' for n in range(V0,V1+1) if n in v)
|
||||||
|
json.dump(out,open("/tmp/lecturas_fetched.json","w"),ensure_ascii=False)
|
||||||
|
print("OK ->/tmp/lecturas_fetched.json", {k:len(x) for k,x in out.items()})
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Arregla los enlaces internos de la carta que apuntan a contenido com_content
|
||||||
|
* de Joomla (multimedia, vídeos, cantoral...) con forma legacy
|
||||||
|
* es/<seccion>/<joomla_content_id>-<slug>.html
|
||||||
|
* que fix_carta_joomla_links.php NO mapea (solo trata /item/<k2>-...). Resuelve
|
||||||
|
* el número (id de contenido Joomla) por meta `_fgj2wp_old_content_id` y, en su
|
||||||
|
* defecto, `_fgj2wp_old_id` (contenido migrado en el bulk original) → permalink
|
||||||
|
* WP en el idioma de cada carta (degrada a ES si no hay traducción).
|
||||||
|
*
|
||||||
|
* Deja intactos los enlaces absolutos a feadulta.com (navegación externa) y los
|
||||||
|
* índices de sección (tablon-de-anuncios.html, noticias-de-alcance.html, etc.).
|
||||||
|
*
|
||||||
|
* Uso: CARTA=<es_id> php fix_carta_content_links.php (dry-run)
|
||||||
|
* APPLY=1 CARTA=<es_id> php fix_carta_content_links.php
|
||||||
|
*/
|
||||||
|
require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
|
||||||
|
global $wpdb;
|
||||||
|
$APPLY = getenv('APPLY') === '1';
|
||||||
|
$CARTA = (int)(getenv('CARTA') ?: 0);
|
||||||
|
if (!$CARTA) { fwrite(STDERR, "Falta CARTA=<es_id>\n"); exit(1); }
|
||||||
|
$BAK = "/tmp/fix_carta_content_bak"; if ($APPLY) @mkdir($BAK, 0777, true);
|
||||||
|
|
||||||
|
function content_es_post($jid) {
|
||||||
|
global $wpdb;
|
||||||
|
foreach (['_fgj2wp_old_content_id', '_fgj2wp_old_id'] as $mk) {
|
||||||
|
$pid = $wpdb->get_var($wpdb->prepare(
|
||||||
|
"SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key=%s AND meta_value=%s LIMIT 1", $mk, (string)$jid));
|
||||||
|
if ($pid) return (int)$pid;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tot = 0;
|
||||||
|
foreach (pll_get_post_translations($CARTA) as $lang => $pid) {
|
||||||
|
$post = get_post($pid); if (!$post) continue;
|
||||||
|
$chg = 0; $miss = [];
|
||||||
|
$new = preg_replace_callback('~href="([^"]+)"~i', function($m) use ($lang, &$chg, &$miss) {
|
||||||
|
$href = html_entity_decode(trim($m[1]));
|
||||||
|
if (stripos($href, '.html') === false) return $m[0]; // solo legacy .html
|
||||||
|
if (stripos($href, 'feadulta.com') !== false) return $m[0]; // absoluto externo → dejar
|
||||||
|
if (stripos($href, '/item/') !== false) return $m[0]; // K2 lo trata otro script
|
||||||
|
if (!preg_match('~/(\d+)-[^/"]+\.html$~i', $href, $mm)) return $m[0]; // necesita <id>-slug.html
|
||||||
|
$es = content_es_post((int)$mm[1]);
|
||||||
|
if (!$es) { $miss[] = $href; return $m[0]; }
|
||||||
|
$t = function_exists('pll_get_post') ? (pll_get_post($es, $lang) ?: $es) : $es;
|
||||||
|
$url = get_permalink($t);
|
||||||
|
if (!$url || strpos($url, '?p=') !== false) return $m[0];
|
||||||
|
$chg++;
|
||||||
|
return 'href="' . esc_url($url) . '"';
|
||||||
|
}, $post->post_content);
|
||||||
|
printf("#%d [%s] «%s» — %d enlaces de contenido%s\n", $pid, $lang, mb_substr($post->post_title,0,26), $chg,
|
||||||
|
$miss ? (" | sin mapear: " . implode(", ", array_slice($miss,0,3))) : "");
|
||||||
|
$tot += $chg;
|
||||||
|
if ($APPLY && $chg) {
|
||||||
|
file_put_contents("$BAK/$pid.html", $post->post_content);
|
||||||
|
wp_update_post(['ID'=>$pid, 'post_content'=>$new]);
|
||||||
|
clean_post_cache($pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($APPLY) {
|
||||||
|
$wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_fea_carta_sections_%'");
|
||||||
|
$wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_fea_carta_sections_%'");
|
||||||
|
}
|
||||||
|
echo ($APPLY ? "APLICADO" : "DRY-RUN") . ": $tot enlaces.\n";
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
|
||||||
|
global $wpdb;
|
||||||
|
$APPLY = getenv("APPLY") === "1";
|
||||||
|
$BAK = "/tmp/fix_carta_links_bak"; if ($APPLY) @mkdir($BAK,0777,true);
|
||||||
|
|
||||||
|
$CARTA = (int)(getenv("CARTA") ?: 46956);
|
||||||
|
$ids = array_values(pll_get_post_translations($CARTA)) ?: [$CARTA];
|
||||||
|
|
||||||
|
// k2_id -> ES post
|
||||||
|
function es_post_by_k2($k2){ global $wpdb; return (int)$wpdb->get_var($wpdb->prepare(
|
||||||
|
"SELECT post_id FROM wp_postmeta WHERE meta_key='_fgj2wp_old_k2_id' AND meta_value=%s LIMIT 1",$k2)); }
|
||||||
|
// slug -> ES post
|
||||||
|
function es_post_by_slug($slug){ global $wpdb; return (int)$wpdb->get_var($wpdb->prepare(
|
||||||
|
"SELECT ID FROM wp_posts WHERE post_name=%s AND post_type='post' AND post_status IN('publish','draft','future') LIMIT 1",$slug)); }
|
||||||
|
|
||||||
|
$tot=0;
|
||||||
|
foreach($ids as $pid){
|
||||||
|
$post=get_post($pid); if(!$post) continue;
|
||||||
|
$lang=pll_get_post_language($pid) ?: 'es';
|
||||||
|
$chg=0; $miss=[];
|
||||||
|
$new=preg_replace_callback('~href="([^"]+)"~i', function($m) use($lang,&$chg,&$miss){
|
||||||
|
$href=html_entity_decode(trim($m[1]));
|
||||||
|
if(stripos($href,'.html')===false) return $m[0]; // solo legacy joomla .html
|
||||||
|
if(stripos($href,'feadulta.com')!==false) return $m[0]; // dominio viejo absoluto -> dejar
|
||||||
|
$es=0;
|
||||||
|
if(preg_match('~/item/(\d+)-~',$href,$mm)) $es=es_post_by_k2($mm[1]);
|
||||||
|
if(!$es && preg_match('~/?([a-z0-9-]+)\.html$~i',$href,$mm)) $es=es_post_by_slug($mm[1]);
|
||||||
|
if(!$es){ $miss[]=$href; return $m[0]; }
|
||||||
|
$t=pll_get_post($es,$lang) ?: $es;
|
||||||
|
$url=get_permalink($t);
|
||||||
|
if(!$url) return $m[0];
|
||||||
|
$chg++;
|
||||||
|
return 'href="'.esc_url($url).'"';
|
||||||
|
}, $post->post_content);
|
||||||
|
echo sprintf("#%d [%s] «%s» — %d reescritos%s\n",$pid,$lang,mb_substr($post->post_title,0,30),$chg,
|
||||||
|
$miss?(" | sin mapear: ".implode(", ",array_slice($miss,0,4))):"");
|
||||||
|
$tot+=$chg;
|
||||||
|
if($APPLY && $chg){ file_put_contents("$BAK/$pid.html",$post->post_content);
|
||||||
|
$wpdb->update($wpdb->posts,['post_content'=>$new],['ID'=>$pid]); clean_post_cache($pid); }
|
||||||
|
}
|
||||||
|
echo "\n".($APPLY?"APLICADO":"DRY-RUN").": $tot enlaces.\n";
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Issue #75 — normaliza los enlaces internos de las cartas (ES + traducciones).
|
||||||
|
*
|
||||||
|
* Bugs que arregla:
|
||||||
|
* - Cartas ES con enlaces a http://localhost:8081/<slug>/ (rompen en prod).
|
||||||
|
* - Traducciones con enlaces relativos /<slug>/ (rompen en local, falta /fea).
|
||||||
|
* - Enlaces que apuntan a un artículo en otro idioma (los re-apunta a la
|
||||||
|
* traducción del MISMO idioma de la carta si existe).
|
||||||
|
*
|
||||||
|
* Para CADA <a href>: si el slug resuelve a un post del sitio, se reescribe al
|
||||||
|
* permalink absoluto del post en el idioma de la página (fallback: el que haya).
|
||||||
|
* Si el slug NO resuelve a ningún post (legacy .html, externos), se deja intacto.
|
||||||
|
*
|
||||||
|
* Uso (dentro del contenedor, con WP cargado):
|
||||||
|
* php fix_carta_links.php -> DRY-RUN (no escribe nada)
|
||||||
|
* APPLY=1 php fix_carta_links.php -> aplica y guarda backup en /tmp/fix_links_bak/
|
||||||
|
*/
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
|
||||||
|
}
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$APPLY = getenv("APPLY") === "1";
|
||||||
|
$BAKDIR = "/tmp/fix_links_bak";
|
||||||
|
if ($APPLY) @mkdir($BAKDIR, 0777, true);
|
||||||
|
|
||||||
|
// Conjunto de trabajo: posts ES con localhost:8081 + todas sus traducciones.
|
||||||
|
$es_ids = $wpdb->get_col(
|
||||||
|
"SELECT ID FROM wp_posts WHERE post_type='post'
|
||||||
|
AND post_status IN ('publish','draft')
|
||||||
|
AND post_content LIKE '%localhost:8081%'"
|
||||||
|
);
|
||||||
|
$targets = [];
|
||||||
|
foreach ($es_ids as $id) {
|
||||||
|
$targets[$id] = true;
|
||||||
|
foreach (pll_get_post_translations($id) as $tid) $targets[$tid] = true;
|
||||||
|
}
|
||||||
|
$targets = array_keys($targets);
|
||||||
|
|
||||||
|
/** Extrae el slug candidato de un href, o null si no parece interno. */
|
||||||
|
function slug_from_href($href) {
|
||||||
|
$href = html_entity_decode(trim($href));
|
||||||
|
if ($href === '' || $href[0] === '#') return null;
|
||||||
|
if (preg_match('~^(mailto:|tel:|javascript:)~i', $href)) return null;
|
||||||
|
if (stripos($href, '.html') !== false) return null; // legacy Joomla
|
||||||
|
if (stripos($href, 'feadulta.com') !== false) return null; // dominio viejo
|
||||||
|
if (strpos($href, '%') !== false) return null; // placeholders [unsubscribe]
|
||||||
|
// Quitar querystring / fragment
|
||||||
|
$href = preg_replace('~[?#].*$~', '', $href);
|
||||||
|
// Quitar esquema+host si los hay
|
||||||
|
$path = preg_replace('~^https?://[^/]+~i', '', $href);
|
||||||
|
if ($path === '') return null;
|
||||||
|
if ($path[0] !== '/') return null; // relativo raro -> no tocar
|
||||||
|
if (stripos($path, '/category/') !== false) return null; // categorías, no posts
|
||||||
|
if (stripos($path, '/wp-') === 0) return null;
|
||||||
|
// Quitar /fea y prefijo de idioma
|
||||||
|
$path = preg_replace('~^/fea~', '', $path);
|
||||||
|
$path = preg_replace('~^/(en|fr|it|pt|es)(/|$)~', '/', $path);
|
||||||
|
$segs = array_values(array_filter(explode('/', $path), 'strlen'));
|
||||||
|
if (count($segs) !== 1) return null; // solo /<slug>/ de un nivel
|
||||||
|
return $segs[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$total_posts = 0; $total_links = 0; $samples = 0;
|
||||||
|
foreach ($targets as $pid) {
|
||||||
|
$post = get_post($pid);
|
||||||
|
if (!$post) continue;
|
||||||
|
$lang = pll_get_post_language($pid) ?: 'es';
|
||||||
|
$content = $post->post_content;
|
||||||
|
$changes = 0;
|
||||||
|
|
||||||
|
$new = preg_replace_callback('~href="([^"]*)"~i', function($m) use ($lang, &$changes, $wpdb) {
|
||||||
|
$href = $m[1];
|
||||||
|
$slug = slug_from_href($href);
|
||||||
|
if ($slug === null) return $m[0];
|
||||||
|
$found = $wpdb->get_var($wpdb->prepare(
|
||||||
|
"SELECT ID FROM wp_posts WHERE post_name=%s AND post_type='post'
|
||||||
|
AND post_status='publish' LIMIT 1", $slug));
|
||||||
|
if (!$found) return $m[0]; // no es un post -> intacto
|
||||||
|
// Resolver a la traducción del idioma de la página
|
||||||
|
$target = pll_get_post((int)$found, $lang);
|
||||||
|
if (!$target) $target = (int)$found;
|
||||||
|
$url = get_permalink($target);
|
||||||
|
if (!$url || $url === $href) return $m[0];
|
||||||
|
$changes++;
|
||||||
|
return 'href="' . esc_url($url) . '"';
|
||||||
|
}, $content);
|
||||||
|
|
||||||
|
if ($changes > 0) {
|
||||||
|
$total_posts++; $total_links += $changes;
|
||||||
|
echo sprintf("#%d [%s] «%s» — %d enlace(s) reescrito(s)\n",
|
||||||
|
$pid, $lang, mb_substr($post->post_title, 0, 40), $changes);
|
||||||
|
if ($APPLY) {
|
||||||
|
file_put_contents("$BAKDIR/$pid.html", $content);
|
||||||
|
$wpdb->update($wpdb->posts, ['post_content' => $new], ['ID' => $pid]);
|
||||||
|
clean_post_cache($pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n";
|
||||||
|
echo ($APPLY ? "APLICADO" : "DRY-RUN") . ": $total_links enlaces en $total_posts posts.\n";
|
||||||
|
if (!$APPLY) echo "Para aplicar: APPLY=1 php fix_carta_links.php (backup en $BAKDIR)\n";
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
$D = [
|
||||||
|
410 => ['en'=>'New Testament','fr'=>'Nouveau Testament','it'=>'Nuovo Testamento','pt'=>'Novo Testamento'],
|
||||||
|
411 => ['en'=>'Old Testament','fr'=>'Ancien Testament','it'=>'Antico Testamento','pt'=>'Antigo Testamento'],
|
||||||
|
49 => ['en'=>'Advent and Christmas','fr'=>'Avent et Noël','it'=>'Avvento e Natale','pt'=>'Advento e Natal'],
|
||||||
|
12 => ['en'=>'In Memoriam','fr'=>'In Memoriam','it'=>'In Memoriam','pt'=>'In Memoriam'],
|
||||||
|
1651 => ['en'=>'News','fr'=>'Actualités','it'=>'Notizie','pt'=>'Notícias'],
|
||||||
|
61 => ['en'=>'Christian Communities','fr'=>'Communautés chrétiennes','it'=>'Comunità cristiane','pt'=>'Comunidades cristãs'],
|
||||||
|
23 => ['en'=>'Letters We Receive','fr'=>'Lettres reçues','it'=>'Lettere che riceviamo','pt'=>'Cartas que recebemos'],
|
||||||
|
39 => ['en'=>'Topics','fr'=>'Thèmes','it'=>'Temi','pt'=>'Temas'],
|
||||||
|
27 => ['en'=>'Chronological Index','fr'=>'Index chronologique','it'=>'Indice cronologico','pt'=>'Índice cronológico'],
|
||||||
|
63 => ['en'=>'EFFA','fr'=>'EFFA','it'=>'EFFA','pt'=>'EFFA'],
|
||||||
|
];
|
||||||
|
$fixed=0;
|
||||||
|
foreach ($D as $es=>$names) {
|
||||||
|
foreach ($names as $L=>$correct) {
|
||||||
|
$t = pll_get_term($es, $L);
|
||||||
|
if (!$t) { echo " $es/$L sin término\n"; continue; }
|
||||||
|
$cur = get_term($t)->name;
|
||||||
|
if ($cur !== $correct) {
|
||||||
|
wp_update_term($t, 'category', ['name'=>$correct]);
|
||||||
|
echo " #$t [$L] \"$cur\" → \"$correct\"\n"; $fixed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo "nombres corregidos: $fixed\n";
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* fix_image_paths.php
|
||||||
|
*
|
||||||
|
* Reescribe rutas relativas Joomla `images/...` en wp_posts.post_content
|
||||||
|
* a rutas absolutas del site `/fea/wp-content/uploads/...`, pero solo cuando
|
||||||
|
* el fichero correspondiente existe en /var/www/html/wp-content/uploads/.
|
||||||
|
*
|
||||||
|
* Cubre src= y href= con comillas dobles o simples.
|
||||||
|
* URL-decodifica antes de comprobar el filesystem (mp3 con espacios/tildes).
|
||||||
|
*
|
||||||
|
* Issue: rafa/feadulta#34
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* docker exec wordpress-web php /tmp/fix_image_paths.php --dry-run
|
||||||
|
* docker exec wordpress-web php /tmp/fix_image_paths.php # live
|
||||||
|
*/
|
||||||
|
|
||||||
|
$dry_run = in_array('--dry-run', $argv ?? []);
|
||||||
|
|
||||||
|
$db_host = 'wordpress-mysql';
|
||||||
|
$db_name = 'wordpress_db';
|
||||||
|
$db_user = 'wordpress_user';
|
||||||
|
$db_pass = 'wordpress_pass';
|
||||||
|
|
||||||
|
$uploads_fs = '/var/www/html/wp-content/uploads';
|
||||||
|
$uploads_url = '/fea/wp-content/uploads';
|
||||||
|
|
||||||
|
$pdo = new PDO("mysql:host=$db_host;dbname=$db_name;charset=utf8mb4", $db_user, $db_pass, [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
]);
|
||||||
|
|
||||||
|
echo "=== Fix image paths (Joomla `images/...` → WP uploads) ===\n";
|
||||||
|
echo $dry_run ? "[DRY RUN]\n\n" : "[LIVE RUN]\n\n";
|
||||||
|
|
||||||
|
$stmt = $pdo->query("
|
||||||
|
SELECT ID, post_title, post_content
|
||||||
|
FROM wp_posts
|
||||||
|
WHERE post_status IN ('publish','draft')
|
||||||
|
AND post_type IN ('post','page')
|
||||||
|
AND (
|
||||||
|
post_content LIKE '%src=\"images/%'
|
||||||
|
OR post_content LIKE \"%src='images/%\"
|
||||||
|
OR post_content LIKE '%href=\"images/%'
|
||||||
|
OR post_content LIKE \"%href='images/%\"
|
||||||
|
)
|
||||||
|
");
|
||||||
|
$posts = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
echo "Posts candidatos: " . count($posts) . "\n\n";
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'posts_changed' => 0,
|
||||||
|
'posts_unchanged' => 0,
|
||||||
|
'refs_rewritten' => 0,
|
||||||
|
'refs_missing_file' => 0,
|
||||||
|
];
|
||||||
|
$missing = []; // path => count
|
||||||
|
$missing_per_post = []; // ID => [path,...]
|
||||||
|
|
||||||
|
// (src|href)= ( " | ' ) images/... ( " | ' )
|
||||||
|
$pattern = '/\b(src|href)=("|\')images\/([^"\']+)\2/i';
|
||||||
|
|
||||||
|
$update = $pdo->prepare("UPDATE wp_posts SET post_content = ? WHERE ID = ?");
|
||||||
|
|
||||||
|
foreach ($posts as $post) {
|
||||||
|
$original = $post['post_content'];
|
||||||
|
$pid = (int)$post['ID'];
|
||||||
|
|
||||||
|
$content = preg_replace_callback(
|
||||||
|
$pattern,
|
||||||
|
function ($m) use ($uploads_fs, $uploads_url, &$stats, &$missing, &$missing_per_post, $pid) {
|
||||||
|
$attr = $m[1];
|
||||||
|
$quote = $m[2];
|
||||||
|
$rel_enc = $m[3]; // tal como aparece en HTML (puede ir URL-encoded)
|
||||||
|
$rel_dec = urldecode($rel_enc); // para mirar el filesystem
|
||||||
|
$fs_path = $uploads_fs . '/' . $rel_dec;
|
||||||
|
|
||||||
|
if (is_file($fs_path)) {
|
||||||
|
$stats['refs_rewritten']++;
|
||||||
|
return $attr . '=' . $quote . $uploads_url . '/' . $rel_enc . $quote;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats['refs_missing_file']++;
|
||||||
|
$missing[$rel_dec] = ($missing[$rel_dec] ?? 0) + 1;
|
||||||
|
$missing_per_post[$pid][] = $rel_dec;
|
||||||
|
return $m[0]; // dejar sin tocar
|
||||||
|
},
|
||||||
|
$original
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($content !== $original) {
|
||||||
|
$stats['posts_changed']++;
|
||||||
|
if (!$dry_run) {
|
||||||
|
$update->execute([$content, $pid]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$stats['posts_unchanged']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== Resumen ===\n";
|
||||||
|
echo "Posts modificados: {$stats['posts_changed']}\n";
|
||||||
|
echo "Posts sin cambios: {$stats['posts_unchanged']}\n";
|
||||||
|
echo "Referencias reescritas: {$stats['refs_rewritten']}\n";
|
||||||
|
echo "Referencias sin fichero: {$stats['refs_missing_file']}\n";
|
||||||
|
echo "Rutas faltantes únicas: " . count($missing) . "\n";
|
||||||
|
|
||||||
|
if (!empty($missing)) {
|
||||||
|
arsort($missing);
|
||||||
|
$log_path = '/tmp/fix_image_paths_missing.log';
|
||||||
|
$lines = [];
|
||||||
|
foreach ($missing as $path => $n) {
|
||||||
|
$lines[] = sprintf("%4d %s", $n, $path);
|
||||||
|
}
|
||||||
|
file_put_contents($log_path, implode("\n", $lines) . "\n");
|
||||||
|
echo "\nLog rutas faltantes (orden por #ocurrencias): $log_path\n";
|
||||||
|
echo "Top 15:\n";
|
||||||
|
$i = 0;
|
||||||
|
foreach ($missing as $path => $n) {
|
||||||
|
echo sprintf(" %4d %s\n", $n, $path);
|
||||||
|
if (++$i >= 15) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// breakdown por carpeta raíz (segmento tras `images/`)
|
||||||
|
$by_root = [];
|
||||||
|
foreach ($missing as $path => $n) {
|
||||||
|
$root = explode('/', $path)[0] ?? '?';
|
||||||
|
$by_root[$root] = ($by_root[$root] ?? 0) + $n;
|
||||||
|
}
|
||||||
|
arsort($by_root);
|
||||||
|
echo "\nFaltantes por carpeta raíz:\n";
|
||||||
|
foreach ($by_root as $root => $n) {
|
||||||
|
echo sprintf(" %4d images/%s/\n", $n, $root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\nHecho.\n";
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
fix_imported_k2_metas.py
|
||||||
|
|
||||||
|
Asigna metas, categorías y Polylang a los posts importados por import_new_k2_items.py.
|
||||||
|
Los posts WP ya existen (IDs 43914-44082); este script solo añade los metadatos.
|
||||||
|
|
||||||
|
Mapping: wp_id = k2_id + 26040
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
|
||||||
|
# ── Config ─────────────────────────────────────────────────────────────────────
|
||||||
|
JOOMLA_SSH_HOST = "134.0.10.170"
|
||||||
|
JOOMLA_SSH_USER = "feadulta"
|
||||||
|
JOOMLA_SSH_PASS = "6Rm2qOF@eundwpda"
|
||||||
|
JOOMLA_DB_HOST = "127.0.0.1"
|
||||||
|
JOOMLA_DB_USER = "fejoomla3"
|
||||||
|
JOOMLA_DB_PASS = "5FF-}5^[>7^pK4W9"
|
||||||
|
JOOMLA_DB_NAME = "fejoomla3"
|
||||||
|
|
||||||
|
WP_DOCKER = "wordpress-mysql"
|
||||||
|
WP_DB_USER = "wordpress_user"
|
||||||
|
WP_DB_PASS = "wordpress_pass"
|
||||||
|
WP_DB_NAME = "wordpress_db"
|
||||||
|
|
||||||
|
LAST_K2_ID = 17873
|
||||||
|
WP_ID_OFFSET = 26040 # wp_id = k2_id + WP_ID_OFFSET
|
||||||
|
|
||||||
|
CAT_FEADULTA = 71
|
||||||
|
CAT_ARTICULOS = 1650
|
||||||
|
CAT_EVANGELIO = 1647
|
||||||
|
CAT_EUCARISTIA = 1648
|
||||||
|
LANG_MAP = {1: 'es', 2: 'en', 3: 'fr', 4: 'it', 5: 'pt'}
|
||||||
|
DOMINGO_RE = r'DOMINGO|SEMANA SANTA|SEMANA DE PASCUA|PENTECOST|NAVIDAD|EPIFAN'
|
||||||
|
|
||||||
|
DRY_RUN = '--dry-run' in sys.argv
|
||||||
|
|
||||||
|
# ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def wp_execute(sql: str):
|
||||||
|
if DRY_RUN:
|
||||||
|
print(f" [DRY] {sql[:100]}")
|
||||||
|
return
|
||||||
|
cmd = ['docker', 'exec', WP_DOCKER,
|
||||||
|
'mysql', '-u', WP_DB_USER, f'-p{WP_DB_PASS}', WP_DB_NAME,
|
||||||
|
'--default-character-set=utf8mb4', '-e', sql]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
err = result.stderr.replace('mysql: [Warning] Using a password on the command line interface can be insecure.\n', '')
|
||||||
|
if err.strip():
|
||||||
|
print(f" [ERR] {err.strip()[:200]}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def wp_mysql(query: str) -> list[dict]:
|
||||||
|
cmd = ['docker', 'exec', WP_DOCKER,
|
||||||
|
'mysql', '-u', WP_DB_USER, f'-p{WP_DB_PASS}', WP_DB_NAME,
|
||||||
|
'--default-character-set=utf8mb4', '-B', '-e', query]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8')
|
||||||
|
if result.returncode != 0:
|
||||||
|
return []
|
||||||
|
lines = result.stdout.strip().split('\n')
|
||||||
|
if len(lines) < 2:
|
||||||
|
return []
|
||||||
|
headers = lines[0].split('\t')
|
||||||
|
return [dict(zip(headers, line.split('\t'))) for line in lines[1:] if line]
|
||||||
|
|
||||||
|
|
||||||
|
def esc(s: str) -> str:
|
||||||
|
return s.replace('\\', '\\\\').replace("'", "\\'")
|
||||||
|
|
||||||
|
|
||||||
|
def unhex(val: str) -> str:
|
||||||
|
if not val or val == 'NULL':
|
||||||
|
return ''
|
||||||
|
try:
|
||||||
|
return bytes.fromhex(val).decode('utf-8', errors='replace')
|
||||||
|
except Exception:
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def parse_extra_fields(ef_json: str) -> dict:
|
||||||
|
result = {'lang_val': None, 'has_libro': False}
|
||||||
|
if not ef_json:
|
||||||
|
return result
|
||||||
|
try:
|
||||||
|
fields = json.loads(ef_json)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return result
|
||||||
|
for f in fields:
|
||||||
|
fid = str(f.get('id', ''))
|
||||||
|
val = f.get('value')
|
||||||
|
if fid == '16' and val is not None:
|
||||||
|
try:
|
||||||
|
result['lang_val'] = int(val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
elif fid == '9':
|
||||||
|
result['has_libro'] = True
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def determine_categories(ef: dict, title: str) -> list[int]:
|
||||||
|
lang = ef.get('lang_val')
|
||||||
|
es = (lang == 1 or lang is None)
|
||||||
|
cats = [CAT_FEADULTA]
|
||||||
|
if es and ef.get('has_libro'):
|
||||||
|
cats.append(CAT_EVANGELIO)
|
||||||
|
elif es and re.search(DOMINGO_RE, title, re.IGNORECASE):
|
||||||
|
cats.append(CAT_EUCARISTIA)
|
||||||
|
else:
|
||||||
|
cats.append(CAT_ARTICULOS)
|
||||||
|
return cats
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"=== Fix metas/cats K2 items > {LAST_K2_ID} {'[DRY RUN]' if DRY_RUN else '[LIVE]'} ===\n")
|
||||||
|
|
||||||
|
# Cargar term_taxonomy_ids
|
||||||
|
term_ids = [CAT_FEADULTA, CAT_ARTICULOS, CAT_EVANGELIO, CAT_EUCARISTIA]
|
||||||
|
tt_ids = {}
|
||||||
|
rows = wp_mysql(f"SELECT term_id, term_taxonomy_id FROM wp_term_taxonomy WHERE term_id IN ({','.join(map(str,term_ids))}) AND taxonomy='category'")
|
||||||
|
for r in rows:
|
||||||
|
tt_ids[int(r['term_id'])] = int(r['term_taxonomy_id'])
|
||||||
|
print(f"TT IDs categorías: {tt_ids}")
|
||||||
|
|
||||||
|
pl_ids = {}
|
||||||
|
rows = wp_mysql("SELECT t.slug, tt.term_taxonomy_id FROM wp_terms t JOIN wp_term_taxonomy tt ON tt.term_id=t.term_id WHERE tt.taxonomy='language' AND t.slug IN ('es','en','fr','it','pt')")
|
||||||
|
for r in rows:
|
||||||
|
pl_ids[r['slug']] = int(r['term_taxonomy_id'])
|
||||||
|
print(f"Polylang TT IDs: {pl_ids}")
|
||||||
|
|
||||||
|
# Verificar que los WP posts existen
|
||||||
|
rows = wp_mysql(f"SELECT COUNT(*) n FROM wp_posts WHERE ID BETWEEN {LAST_K2_ID+WP_ID_OFFSET+1} AND (SELECT MAX(ID) FROM wp_posts)")
|
||||||
|
print(f"Posts WP a procesar (aprox): {rows[0]['n'] if rows else '?'}")
|
||||||
|
|
||||||
|
# Obtener K2 items desde Joomla
|
||||||
|
print("\nObteniendo K2 items de Joomla prod...")
|
||||||
|
query = (
|
||||||
|
f"SELECT id, HEX(title) title, HEX(extra_fields) extra_fields "
|
||||||
|
f"FROM ew4r_k2_items WHERE published=1 AND id > {LAST_K2_ID} ORDER BY id;"
|
||||||
|
)
|
||||||
|
mysql_cmd = (
|
||||||
|
f"mysql -h {JOOMLA_DB_HOST} -u {JOOMLA_DB_USER} "
|
||||||
|
f"-p'{JOOMLA_DB_PASS}' {JOOMLA_DB_NAME} "
|
||||||
|
f"--default-character-set=utf8mb4 -B"
|
||||||
|
)
|
||||||
|
cmd = ['sshpass', '-p', JOOMLA_SSH_PASS, 'ssh', f'{JOOMLA_SSH_USER}@{JOOMLA_SSH_HOST}', mysql_cmd]
|
||||||
|
result = subprocess.run(cmd, input=query, capture_output=True, text=True, encoding='utf-8')
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"ERROR: {result.stderr[:300]}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
lines = result.stdout.strip().split('\n')
|
||||||
|
headers = lines[0].split('\t')
|
||||||
|
items = [dict(zip(headers, line.split('\t'))) for line in lines[1:] if line]
|
||||||
|
print(f"Items obtenidos: {len(items)}")
|
||||||
|
|
||||||
|
stats = {'ok': 0, 'skip': 0}
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
k2_id = int(item['id'])
|
||||||
|
wp_id = k2_id + WP_ID_OFFSET
|
||||||
|
title = unhex(item.get('title', ''))
|
||||||
|
ef_raw = unhex(item.get('extra_fields', ''))
|
||||||
|
ef = parse_extra_fields(ef_raw)
|
||||||
|
lang = LANG_MAP.get(ef.get('lang_val'), 'es')
|
||||||
|
cats = determine_categories(ef, title)
|
||||||
|
|
||||||
|
# Verificar que el WP post existe
|
||||||
|
existing = wp_mysql(f"SELECT ID FROM wp_posts WHERE ID={wp_id} LIMIT 1")
|
||||||
|
if not existing:
|
||||||
|
print(f" [SKIP] WP post ID={wp_id} no encontrado (k2={k2_id})")
|
||||||
|
stats['skip'] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f" [{k2_id}→{wp_id}] {title[:45]} | lang={lang} | cats={cats}")
|
||||||
|
|
||||||
|
# Metas
|
||||||
|
for meta_key, meta_val in [('_fgj2wp_old_k2_id', str(k2_id)), ('Idioma', str(ef.get('lang_val') or 1))]:
|
||||||
|
wp_execute(
|
||||||
|
f"INSERT IGNORE INTO wp_postmeta (post_id, meta_key, meta_value) "
|
||||||
|
f"VALUES ({wp_id}, '{esc(meta_key)}', '{esc(meta_val)}')"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Categorías
|
||||||
|
for term_id in cats:
|
||||||
|
tt_id = tt_ids.get(term_id)
|
||||||
|
if tt_id:
|
||||||
|
wp_execute(
|
||||||
|
f"INSERT IGNORE INTO wp_term_relationships (object_id, term_taxonomy_id) "
|
||||||
|
f"VALUES ({wp_id}, {tt_id})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Polylang
|
||||||
|
pl_tt = pl_ids.get(lang)
|
||||||
|
if pl_tt:
|
||||||
|
wp_execute(
|
||||||
|
f"INSERT IGNORE INTO wp_term_relationships (object_id, term_taxonomy_id) "
|
||||||
|
f"VALUES ({wp_id}, {pl_tt})"
|
||||||
|
)
|
||||||
|
|
||||||
|
stats['ok'] += 1
|
||||||
|
|
||||||
|
# Actualizar counts
|
||||||
|
if not DRY_RUN and stats['ok'] > 0:
|
||||||
|
print("\nActualizando counts de categorías y Polylang...")
|
||||||
|
all_tt = list(tt_ids.values()) + list(pl_ids.values())
|
||||||
|
tt_str = ','.join(str(x) for x in all_tt)
|
||||||
|
wp_execute(
|
||||||
|
f"UPDATE wp_term_taxonomy tt SET count = ("
|
||||||
|
f"SELECT COUNT(*) FROM wp_term_relationships tr WHERE tr.term_taxonomy_id=tt.term_taxonomy_id"
|
||||||
|
f") WHERE tt.term_taxonomy_id IN ({tt_str})"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"\n=== Resultado: {stats['ok']} ok, {stats['skip']} skip ===")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -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,94 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* fix_k2_authors.php (#143) — Corrige la autoría de artículos de origen K2 que
|
||||||
|
* quedaron atribuidos al usuario genérico «Fe Adulta» (post_author 1 / 890)
|
||||||
|
* porque el importador de delta (import_new_k2_items.py) no encontró un usuario
|
||||||
|
* WP para su `created_by` de Joomla y cayó al fallback admin.
|
||||||
|
*
|
||||||
|
* Qué hace, por cada artículo del TSV de entrada:
|
||||||
|
* 1. Crea (idempotente) un usuario WP rol 'subscriber' con el nombre real del
|
||||||
|
* autor (display_name), login = slug del nombre, email = slug@feadulta.com.
|
||||||
|
* 2. Reasigna post_author del/los post WP de ese K2 (los que sigan en 1/890).
|
||||||
|
*
|
||||||
|
* Entrada: un TSV «k2_id<TAB>created_by<TAB>nombre», generado desde Joomla:
|
||||||
|
* IDS=<lista de k2_id atribuidos a 1/890> # de wp: meta _fgj2wp_old_k2_id
|
||||||
|
* mysql --skip-ssl ... fejoomla3 -N -e \
|
||||||
|
* "SELECT i.id, i.created_by, COALESCE(u.name,'') \
|
||||||
|
* FROM ew4r_k2_items i LEFT JOIN ew4r_users u ON u.id=i.created_by \
|
||||||
|
* WHERE i.id IN ($IDS);" > /tmp/autores143.tsv
|
||||||
|
*
|
||||||
|
* Uso (en el servidor, dentro de /web/wp-nuevo):
|
||||||
|
* FEA_TSV=/tmp/autores143.tsv wp eval-file scripts/fix_k2_authors.php # dry-run
|
||||||
|
* APPLY=1 FEA_TSV=/tmp/autores143.tsv wp eval-file scripts/fix_k2_authors.php # aplica
|
||||||
|
*
|
||||||
|
* Notas:
|
||||||
|
* - Los autores con created_by cuyo usuario Joomla ya no existe llegan con
|
||||||
|
* nombre vacío en el TSV → se SALTAN (no recuperable; firma en el cuerpo).
|
||||||
|
* - El nombre literal «Fe Adulta» se salta (es legítimo).
|
||||||
|
* - Los nuevos usuarios quedan sin foto_perfil (avatar genérico). Si se quiere
|
||||||
|
* avatar propio, generarlo aparte (ver flujo de avatares #62).
|
||||||
|
*/
|
||||||
|
|
||||||
|
$APPLY = getenv('APPLY') === '1';
|
||||||
|
$TSV = getenv('FEA_TSV') ?: '/tmp/autores143.tsv';
|
||||||
|
if (!is_readable($TSV)) { fwrite(STDERR, "No puedo leer TSV: $TSV\n"); exit(1); }
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$GENERIC = [1, 890];
|
||||||
|
$SKIP_NAMES = ['Fe Adulta'];
|
||||||
|
|
||||||
|
$byname = [];
|
||||||
|
foreach (file($TSV) as $line) {
|
||||||
|
$r = explode("\t", rtrim($line, "\n"));
|
||||||
|
if (count($r) < 3) continue;
|
||||||
|
$name = trim($r[2]);
|
||||||
|
if ($name === '' || in_array($name, $SKIP_NAMES, true)) continue;
|
||||||
|
$byname[$name][] = (int) $r[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$created = 0; $reassigned = 0; $log = [];
|
||||||
|
foreach ($byname as $name => $k2ids) {
|
||||||
|
$login = sanitize_user(sanitize_title($name), true);
|
||||||
|
$u = get_user_by('login', $login);
|
||||||
|
if (!$u) {
|
||||||
|
$email = $login . '@feadulta.com'; $i = 2;
|
||||||
|
while (email_exists($email)) { $email = $login . $i . '@feadulta.com'; $i++; }
|
||||||
|
if ($APPLY) {
|
||||||
|
$uid = wp_insert_user([
|
||||||
|
'user_login' => $login,
|
||||||
|
'user_pass' => wp_generate_password(20),
|
||||||
|
'user_email' => $email,
|
||||||
|
'display_name' => $name,
|
||||||
|
'nickname' => $name,
|
||||||
|
'role' => 'subscriber',
|
||||||
|
]);
|
||||||
|
if (is_wp_error($uid)) { $log[] = "ERROR crear '$name': " . $uid->get_error_message(); continue; }
|
||||||
|
$u = get_userdata($uid); $created++;
|
||||||
|
$log[] = "USER creado: '$name' -> id $uid ($login / $email)";
|
||||||
|
} else {
|
||||||
|
$log[] = "[dry] crearia USER '$name' ($login / $email)"; $created++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$log[] = "USER ya existe: '$name' -> id {$u->ID} ($login)";
|
||||||
|
}
|
||||||
|
$uid = $u ? $u->ID : 0;
|
||||||
|
foreach ($k2ids as $k2) {
|
||||||
|
$pids = $wpdb->get_col($wpdb->prepare(
|
||||||
|
"SELECT DISTINCT post_id FROM {$wpdb->postmeta}
|
||||||
|
WHERE meta_key='_fgj2wp_old_k2_id' AND meta_value=%s", (string) $k2));
|
||||||
|
foreach ($pids as $pid) {
|
||||||
|
$a = (int) get_post_field('post_author', $pid);
|
||||||
|
if (!in_array($a, $GENERIC, true)) continue;
|
||||||
|
if ($APPLY && $uid) {
|
||||||
|
wp_update_post(['ID' => (int) $pid, 'post_author' => $uid]);
|
||||||
|
$reassigned++; $log[] = " post $pid (k2 $k2) author $a -> $uid";
|
||||||
|
} else {
|
||||||
|
$reassigned++; $log[] = " [dry] post $pid (k2 $k2) author $a -> '$name'";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo implode("\n", $log) . "\n";
|
||||||
|
echo "\nRESUMEN: usuarios " . ($APPLY ? 'creados' : 'a crear') . ": $created ; "
|
||||||
|
. "posts " . ($APPLY ? 'reasignados' : 'a reasignar') . ": $reassigned "
|
||||||
|
. "(modo " . ($APPLY ? 'APPLY' : 'DRY-RUN') . ")\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,33 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Issue #90 — círculo 200px RGBA (esquinas transparentes) para 2 autores nuevos.
|
||||||
|
Recorte cuadrado centrado en la cara + máscara circular supersampleada.
|
||||||
|
"""
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
SRC = "/home/rafa/Feadulta"
|
||||||
|
OUT = "/home/rafa/joomla-migration/wordpress/wp-content/uploads/avatares/autores"
|
||||||
|
SIZE = 200
|
||||||
|
SS = 4 # supersampling para borde suave
|
||||||
|
|
||||||
|
# foto, uid, (left, top, side) recorte cuadrado en coords del original
|
||||||
|
JOBS = [
|
||||||
|
("MP_Lopez.jpeg", 474, (120, 0, 880)),
|
||||||
|
("A_delaCruz.jpeg", 993, (128, 0, 785)),
|
||||||
|
]
|
||||||
|
|
||||||
|
mask = Image.new("L", (SIZE * SS, SIZE * SS), 0)
|
||||||
|
ImageDraw.Draw(mask).ellipse((0, 0, SIZE * SS - 1, SIZE * SS - 1), fill=255)
|
||||||
|
mask = mask.resize((SIZE, SIZE), Image.LANCZOS)
|
||||||
|
|
||||||
|
for fname, uid, (l, t, side) in JOBS:
|
||||||
|
im = Image.open(f"{SRC}/{fname}").convert("RGB")
|
||||||
|
W, H = im.size
|
||||||
|
# clamp dentro de la imagen
|
||||||
|
l = max(0, min(l, W - side))
|
||||||
|
t = max(0, min(t, H - side))
|
||||||
|
crop = im.crop((l, t, l + side, t + side)).resize((SIZE, SIZE), Image.LANCZOS)
|
||||||
|
out = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
|
||||||
|
out.paste(crop, (0, 0))
|
||||||
|
out.putalpha(mask)
|
||||||
|
out.save(f"{OUT}/autor-{uid}.png")
|
||||||
|
print(f"OK autor-{uid}.png <- {fname} crop=({l},{t},{side})")
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Genera avatares de INICIALES (mismo formato que #62) para autores sin foto.
|
||||||
|
|
||||||
|
Formato replicado del #62: PNG 200x200 RGBA, círculo de color sólido (paleta
|
||||||
|
determinista de 10 tonos elegida por hash del nombre) con las iniciales en blanco,
|
||||||
|
DejaVuSans-Bold, esquinas transparentes (borde circular supersampleado).
|
||||||
|
|
||||||
|
Entrada: TSV «uid<TAB>display_name» (env FEA_TSV, por defecto /tmp/users29.tsv).
|
||||||
|
Salida: uploads/avatares/autores/autor-<uid>.png
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
FEA_TSV=/tmp/users29.tsv python3 scripts/gen_avatars_initials.py
|
||||||
|
"""
|
||||||
|
import hashlib, os, unicodedata
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
OUT = "/home/rafa/joomla-migration/wordpress/wp-content/uploads/avatares/autores"
|
||||||
|
TSV = os.environ.get("FEA_TSV", "/tmp/users29.tsv")
|
||||||
|
FONT = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
|
||||||
|
SIZE, SS = 200, 4
|
||||||
|
|
||||||
|
# Paleta del #62 (extraída de los avatares existentes; 10 tonos apagados)
|
||||||
|
PALETTE = [
|
||||||
|
(91, 110, 80), (100, 110, 60), (160, 110, 70), (176, 122, 57), (150, 90, 110),
|
||||||
|
(80, 80, 110), (74, 95, 120), (60, 90, 90), (139, 26, 46), (120, 82, 72),
|
||||||
|
]
|
||||||
|
STOP = {"la", "las", "el", "los", "un", "una", "de", "del", "y", "e", "da", "do", "the"}
|
||||||
|
|
||||||
|
|
||||||
|
def strip_accents(s: str) -> str:
|
||||||
|
return "".join(c for c in unicodedata.normalize("NFD", s)
|
||||||
|
if unicodedata.category(c) != "Mn")
|
||||||
|
|
||||||
|
|
||||||
|
def initials(name: str) -> str:
|
||||||
|
raw = name.replace("/", " ").replace('"', " ").replace("'", " ")
|
||||||
|
toks = [t for t in raw.split() if t and t.lower() not in STOP]
|
||||||
|
if not toks:
|
||||||
|
return "?"
|
||||||
|
picks = toks[:2] if len(toks) >= 2 else toks[:1]
|
||||||
|
return strip_accents("".join(t[0] for t in picks)).upper()
|
||||||
|
|
||||||
|
|
||||||
|
def color_for(name: str):
|
||||||
|
h = int(hashlib.md5(name.encode("utf-8")).hexdigest(), 16)
|
||||||
|
return PALETTE[h % len(PALETTE)]
|
||||||
|
|
||||||
|
|
||||||
|
def make(uid: str, name: str):
|
||||||
|
big = SIZE * SS
|
||||||
|
im = Image.new("RGBA", (big, big), (0, 0, 0, 0))
|
||||||
|
d = ImageDraw.Draw(im)
|
||||||
|
d.ellipse((0, 0, big - 1, big - 1), fill=color_for(name) + (255,))
|
||||||
|
txt = initials(name)
|
||||||
|
# ajusta tamaño hasta cap-height ~77px (en escala SS)
|
||||||
|
font = ImageFont.truetype(FONT, int(104 * SS))
|
||||||
|
bbox = d.textbbox((0, 0), txt, font=font)
|
||||||
|
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||||
|
x = (big - tw) / 2 - bbox[0]
|
||||||
|
y = (big - th) / 2 - bbox[1]
|
||||||
|
d.text((x, y), txt, font=font, fill=(255, 255, 255, 255))
|
||||||
|
im = im.resize((SIZE, SIZE), Image.LANCZOS)
|
||||||
|
im.save(f"{OUT}/autor-{uid}.png")
|
||||||
|
return txt
|
||||||
|
|
||||||
|
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
n = 0
|
||||||
|
for line in open(TSV, encoding="utf-8"):
|
||||||
|
parts = line.rstrip("\n").split("\t")
|
||||||
|
if len(parts) < 2:
|
||||||
|
continue
|
||||||
|
uid, name = parts[0].strip(), parts[1].strip()
|
||||||
|
ini = make(uid, name)
|
||||||
|
print(f"autor-{uid}.png {ini:3} {name}")
|
||||||
|
n += 1
|
||||||
|
print(f"\n{n} avatares generados en {OUT}")
|
||||||
@@ -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,82 @@
|
|||||||
|
<?php
|
||||||
|
// Importa avatares col_*.png de uploads/autores/joomla/ como attachments WP
|
||||||
|
// y asigna foto_perfil al user_id correspondiente.
|
||||||
|
//
|
||||||
|
// Uso: docker exec wordpress-web php /tmp/import_avatars.php [--dry-run]
|
||||||
|
|
||||||
|
require '/var/www/html/wp-load.php';
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/image.php';
|
||||||
|
|
||||||
|
$dry = in_array('--dry-run', $argv ?? [], true);
|
||||||
|
$tsv = '/tmp/avatar_assignments.tsv';
|
||||||
|
|
||||||
|
$uploads = wp_upload_dir();
|
||||||
|
$avatars_dir = $uploads['basedir'] . '/autores/joomla';
|
||||||
|
$avatars_url = $uploads['baseurl'] . '/autores/joomla';
|
||||||
|
|
||||||
|
$lines = file($tsv, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
|
array_shift($lines); // header
|
||||||
|
|
||||||
|
$stats = ['ok' => 0, 'reused' => 0, 'missing_file' => 0, 'missing_user' => 0, 'assigned' => 0, 'already' => 0];
|
||||||
|
$samples_ok = [];
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$attach_by_file = [];
|
||||||
|
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
[$aid, $login, $posts, $source, $filename] = explode("\t", $line);
|
||||||
|
$aid = (int) $aid;
|
||||||
|
$abs = $avatars_dir . '/' . $filename;
|
||||||
|
if (!is_file($abs)) { $stats['missing_file']++; continue; }
|
||||||
|
if (!get_userdata($aid)) { $stats['missing_user']++; continue; }
|
||||||
|
|
||||||
|
// Si ya tiene foto_perfil, no tocar (preservar manual assignments)
|
||||||
|
if (get_user_meta($aid, 'foto_perfil', true)) { $stats['already']++; continue; }
|
||||||
|
|
||||||
|
// ¿Ya existe attachment para este fichero? (reutilizar)
|
||||||
|
if (!isset($attach_by_file[$filename])) {
|
||||||
|
$url = $avatars_url . '/' . $filename;
|
||||||
|
$existing = $wpdb->get_var($wpdb->prepare(
|
||||||
|
"SELECT ID FROM {$wpdb->posts} WHERE post_type='attachment' AND guid=%s LIMIT 1",
|
||||||
|
$url
|
||||||
|
));
|
||||||
|
if ($existing) {
|
||||||
|
$attach_by_file[$filename] = (int) $existing;
|
||||||
|
$stats['reused']++;
|
||||||
|
} else {
|
||||||
|
if ($dry) {
|
||||||
|
$attach_by_file[$filename] = -1; // marcador dry
|
||||||
|
} else {
|
||||||
|
$attach_id = wp_insert_attachment([
|
||||||
|
'guid' => $url,
|
||||||
|
'post_mime_type' => wp_check_filetype($abs)['type'] ?: 'image/png',
|
||||||
|
'post_title' => pathinfo($filename, PATHINFO_FILENAME),
|
||||||
|
'post_content' => '',
|
||||||
|
'post_status' => 'inherit',
|
||||||
|
], $abs);
|
||||||
|
if (is_wp_error($attach_id) || !$attach_id) {
|
||||||
|
error_log("[import_avatars] insert FAIL para $filename: " . (is_wp_error($attach_id) ? $attach_id->get_error_message() : 'unknown'));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$meta = wp_generate_attachment_metadata($attach_id, $abs);
|
||||||
|
wp_update_attachment_metadata($attach_id, $meta);
|
||||||
|
$attach_by_file[$filename] = $attach_id;
|
||||||
|
$stats['ok']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$attach_id = $attach_by_file[$filename];
|
||||||
|
if ($attach_id !== 0) {
|
||||||
|
if (!$dry && $attach_id > 0) update_user_meta($aid, 'foto_perfil', (string) $attach_id);
|
||||||
|
$stats['assigned']++;
|
||||||
|
if (count($samples_ok) < 5) {
|
||||||
|
$samples_ok[] = "user $aid ($login) → attach " . ($attach_id > 0 ? $attach_id : 'NEW') . " ($filename)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo ($dry ? '[DRY] ' : '') . "Stats:\n";
|
||||||
|
foreach ($stats as $k => $v) echo " $k: $v\n";
|
||||||
|
echo "\nSamples:\n";
|
||||||
|
foreach ($samples_ok as $s) echo " $s\n";
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* import_avatars_143.php (#143) — da de alta los avatares de INICIALES de los
|
||||||
|
* usuarios nuevos creados al corregir la autoría K2 (ver fix_k2_authors.php).
|
||||||
|
*
|
||||||
|
* Crea/actualiza el attachment del PNG uploads/avatares/autores/autor-<uid>.png
|
||||||
|
* y reapunta foto_perfil (ACF user_meta). Backup del valor previo en
|
||||||
|
* user_meta _foto_perfil_pre143. Idempotente (si ya apunta, solo regenera metadata).
|
||||||
|
*
|
||||||
|
* Entrada: TSV «uid<TAB>display_name» (env FEA_TSV, por defecto /tmp/users29.tsv).
|
||||||
|
* Uso (en el servidor, dentro de /web/wp-nuevo):
|
||||||
|
* FEA_TSV=/tmp/users29.tsv wp eval-file scripts/import_avatars_143.php # dry-run
|
||||||
|
* APPLY=1 FEA_TSV=/tmp/users29.tsv wp eval-file scripts/import_avatars_143.php # aplica
|
||||||
|
*/
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/image.php';
|
||||||
|
|
||||||
|
$apply = getenv('APPLY') === '1';
|
||||||
|
$tsv = getenv('FEA_TSV') ?: '/tmp/users29.tsv';
|
||||||
|
$updir = wp_get_upload_dir();
|
||||||
|
if (!is_readable($tsv)) { echo "No puedo leer TSV: $tsv\n"; return; }
|
||||||
|
|
||||||
|
$done = $regen = $err = 0;
|
||||||
|
foreach (file($tsv) as $line) {
|
||||||
|
$p = explode("\t", rtrim($line, "\n"));
|
||||||
|
if (count($p) < 2) continue;
|
||||||
|
$uid = (int) $p[0];
|
||||||
|
$name = trim($p[1]);
|
||||||
|
$rel = "avatares/autores/autor-{$uid}.png";
|
||||||
|
$abs = $updir['basedir'] . '/' . $rel;
|
||||||
|
if (!file_exists($abs)) { echo "MISSING uid=$uid ($name)\n"; $err++; continue; }
|
||||||
|
|
||||||
|
$cur = (int) get_user_meta($uid, 'foto_perfil', true);
|
||||||
|
if ($cur && get_post_meta($cur, '_wp_attached_file', true) === $rel) {
|
||||||
|
echo "REGEN #$uid $name (attachment $cur ya apunta al PNG)\n";
|
||||||
|
if ($apply) wp_update_attachment_metadata($cur, wp_generate_attachment_metadata($cur, $abs));
|
||||||
|
$regen++; continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "NUEVO #$uid $name (foto_perfil actual: " . ($cur ?: 'ninguna') . ")\n";
|
||||||
|
if (!$apply) { $done++; continue; }
|
||||||
|
|
||||||
|
if (get_user_meta($uid, '_foto_perfil_pre143', true) === '') {
|
||||||
|
update_user_meta($uid, '_foto_perfil_pre143', $cur);
|
||||||
|
}
|
||||||
|
$aid = wp_insert_attachment([
|
||||||
|
'post_mime_type' => 'image/png',
|
||||||
|
'post_title' => "Avatar {$name}",
|
||||||
|
'post_status' => 'inherit',
|
||||||
|
'guid' => $updir['baseurl'] . '/' . $rel,
|
||||||
|
], $abs, 0, true);
|
||||||
|
if (is_wp_error($aid)) { echo " ERR: " . $aid->get_error_message() . "\n"; $err++; continue; }
|
||||||
|
wp_update_attachment_metadata($aid, wp_generate_attachment_metadata($aid, $abs));
|
||||||
|
update_user_meta($uid, 'foto_perfil', $aid);
|
||||||
|
$done++;
|
||||||
|
}
|
||||||
|
echo "\n" . ($apply ? "APLICADO" : "DRY-RUN") . ": nuevos=$done regen=$regen errores=$err\n";
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Issue #81 — avatares nuevos de 6 colaboradores habituales.
|
||||||
|
* Reapunta foto_perfil (user_meta) al PNG uploads/avatares/autores/autor-<uid>.png.
|
||||||
|
* Si foto_perfil ya apuntaba a ese fichero (caso #62), solo regenera metadata.
|
||||||
|
* Backup del foto_perfil anterior en user_meta _foto_perfil_pre75 (revertible).
|
||||||
|
*
|
||||||
|
* Uso (dentro del contenedor):
|
||||||
|
* php import_avatars_75.php -> DRY-RUN
|
||||||
|
* APPLY=1 php import_avatars_75.php -> aplica
|
||||||
|
*/
|
||||||
|
require "/var/www/html/wp-load.php";
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/image.php';
|
||||||
|
|
||||||
|
$apply = getenv('APPLY') === '1';
|
||||||
|
$updir = wp_get_upload_dir();
|
||||||
|
|
||||||
|
$authors = [
|
||||||
|
[384, "Enrique Martínez Lozano"],
|
||||||
|
[583, "Fidel Aizpurúa"],
|
||||||
|
[1138, "Guadalupe Labrador"],
|
||||||
|
[383, "José Antonio Pagola"],
|
||||||
|
[774, "José Luis Sicre"],
|
||||||
|
[775, "Miguel A. Munárriz"],
|
||||||
|
];
|
||||||
|
|
||||||
|
$done = $regen = $err = 0;
|
||||||
|
foreach ($authors as [$uid, $name]) {
|
||||||
|
$rel = "avatares/autores/autor-{$uid}.png";
|
||||||
|
$abs = $updir['basedir'] . '/' . $rel;
|
||||||
|
if (!file_exists($abs)) { echo "MISSING uid=$uid ($name)\n"; $err++; continue; }
|
||||||
|
|
||||||
|
$cur = (int) get_user_meta($uid, 'foto_perfil', true);
|
||||||
|
|
||||||
|
// foto_perfil ya apunta a este fichero -> solo se sobrescribió el PNG
|
||||||
|
if ($cur && get_post_meta($cur, '_wp_attached_file', true) === $rel) {
|
||||||
|
echo "REGEN #$uid $name (attachment $cur ya apunta al PNG)\n";
|
||||||
|
if ($apply) wp_update_attachment_metadata($cur, wp_generate_attachment_metadata($cur, $abs));
|
||||||
|
$regen++; continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "NUEVO #$uid $name (foto_perfil actual: " . ($cur ?: 'ninguna') . ")\n";
|
||||||
|
if (!$apply) { $done++; continue; }
|
||||||
|
|
||||||
|
if (get_user_meta($uid, '_foto_perfil_pre75', true) === '') {
|
||||||
|
update_user_meta($uid, '_foto_perfil_pre75', $cur);
|
||||||
|
}
|
||||||
|
$aid = wp_insert_attachment([
|
||||||
|
'post_mime_type' => 'image/png',
|
||||||
|
'post_title' => "Avatar {$name}",
|
||||||
|
'post_status' => 'inherit',
|
||||||
|
'guid' => $updir['baseurl'] . '/' . $rel,
|
||||||
|
], $abs, 0, true);
|
||||||
|
if (is_wp_error($aid)) { echo " ERR: " . $aid->get_error_message() . "\n"; $err++; continue; }
|
||||||
|
wp_update_attachment_metadata($aid, wp_generate_attachment_metadata($aid, $abs));
|
||||||
|
update_user_meta($uid, 'foto_perfil', $aid);
|
||||||
|
$done++;
|
||||||
|
}
|
||||||
|
echo "\n" . ($apply ? "APLICADO" : "DRY-RUN") . ": nuevos=$done regen=$regen errores=$err\n";
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Issue #90 — avatares nuevos de 2 colaboradores nuevos (Mari Paz Lopez, Africa de la Cruz).
|
||||||
|
* Reapunta foto_perfil (user_meta) al PNG uploads/avatares/autores/autor-<uid>.png.
|
||||||
|
* Si foto_perfil ya apuntaba a ese fichero (caso #62), solo regenera metadata.
|
||||||
|
* Backup del foto_perfil anterior en user_meta _foto_perfil_pre81 (revertible).
|
||||||
|
*
|
||||||
|
* Uso (dentro del contenedor):
|
||||||
|
* php import_avatars_75.php -> DRY-RUN
|
||||||
|
* APPLY=1 php import_avatars_75.php -> aplica
|
||||||
|
*/
|
||||||
|
require "/var/www/html/wp-load.php";
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/image.php';
|
||||||
|
|
||||||
|
$apply = getenv('APPLY') === '1';
|
||||||
|
$updir = wp_get_upload_dir();
|
||||||
|
|
||||||
|
$authors = [
|
||||||
|
[474, "Mari Paz López Santos"],
|
||||||
|
[993, "África de la Cruz Tomé"],
|
||||||
|
];
|
||||||
|
|
||||||
|
$done = $regen = $err = 0;
|
||||||
|
foreach ($authors as [$uid, $name]) {
|
||||||
|
$rel = "avatares/autores/autor-{$uid}.png";
|
||||||
|
$abs = $updir['basedir'] . '/' . $rel;
|
||||||
|
if (!file_exists($abs)) { echo "MISSING uid=$uid ($name)\n"; $err++; continue; }
|
||||||
|
|
||||||
|
$cur = (int) get_user_meta($uid, 'foto_perfil', true);
|
||||||
|
|
||||||
|
// foto_perfil ya apunta a este fichero -> solo se sobrescribió el PNG
|
||||||
|
if ($cur && get_post_meta($cur, '_wp_attached_file', true) === $rel) {
|
||||||
|
echo "REGEN #$uid $name (attachment $cur ya apunta al PNG)\n";
|
||||||
|
if ($apply) wp_update_attachment_metadata($cur, wp_generate_attachment_metadata($cur, $abs));
|
||||||
|
$regen++; continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "NUEVO #$uid $name (foto_perfil actual: " . ($cur ?: 'ninguna') . ")\n";
|
||||||
|
if (!$apply) { $done++; continue; }
|
||||||
|
|
||||||
|
if (get_user_meta($uid, '_foto_perfil_pre81', true) === '') {
|
||||||
|
update_user_meta($uid, '_foto_perfil_pre81', $cur);
|
||||||
|
}
|
||||||
|
$aid = wp_insert_attachment([
|
||||||
|
'post_mime_type' => 'image/png',
|
||||||
|
'post_title' => "Avatar {$name}",
|
||||||
|
'post_status' => 'inherit',
|
||||||
|
'guid' => $updir['baseurl'] . '/' . $rel,
|
||||||
|
], $abs, 0, true);
|
||||||
|
if (is_wp_error($aid)) { echo " ERR: " . $aid->get_error_message() . "\n"; $err++; continue; }
|
||||||
|
wp_update_attachment_metadata($aid, wp_generate_attachment_metadata($aid, $abs));
|
||||||
|
update_user_meta($uid, 'foto_perfil', $aid);
|
||||||
|
$done++;
|
||||||
|
}
|
||||||
|
echo "\n" . ($apply ? "APLICADO" : "DRY-RUN") . ": nuevos=$done regen=$regen errores=$err\n";
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
import_new_cartas.py
|
||||||
|
|
||||||
|
Importa las cartas de la semana nuevas de ew4r_content (Joomla prod, id > 9043)
|
||||||
|
al WordPress local (Docker), y luego asigna _carta_id a los artículos K2
|
||||||
|
correspondientes según la fecha (extra_field id 15).
|
||||||
|
|
||||||
|
Categorías WP según catid Joomla:
|
||||||
|
catid 27 (Carta de la semana) → WP: 6 + 21 + 71
|
||||||
|
catid 40 (Cartas de otras sem) → WP: 21 + 71
|
||||||
|
catid 41 (Carta semana pasada) → WP: 21 + 22 + 71
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
JOOMLA_SSH_HOST = "134.0.10.170"
|
||||||
|
JOOMLA_SSH_USER = "feadulta"
|
||||||
|
JOOMLA_SSH_PASS = "C6c2A!mAl3Wj.BQF"
|
||||||
|
JOOMLA_DB_HOST = "127.0.0.1"
|
||||||
|
JOOMLA_DB_USER = "fejoomla3"
|
||||||
|
JOOMLA_DB_PASS = "5FF-}5^[>7^pK4W9"
|
||||||
|
JOOMLA_DB_NAME = "fejoomla3"
|
||||||
|
|
||||||
|
WP_DOCKER = "wordpress-mysql"
|
||||||
|
WP_DB_USER = "wordpress_user"
|
||||||
|
WP_DB_PASS = "wordpress_pass"
|
||||||
|
WP_DB_NAME = "wordpress_db"
|
||||||
|
|
||||||
|
LAST_CONTENT_ID = None # se calcula dinámicamente en main(): MAX(_fgj2wp_old_content_id) en WP
|
||||||
|
|
||||||
|
# WP term_ids y sus term_taxonomy_ids (se cargan dinámicamente)
|
||||||
|
CAT_FEADULTA = 71
|
||||||
|
CAT_CARTA_SEMANA = 6
|
||||||
|
CAT_CARTAS_OTRAS = 21
|
||||||
|
CAT_CARTA_PASADA = 22
|
||||||
|
|
||||||
|
CATID_TO_WP = {
|
||||||
|
27: [CAT_CARTA_SEMANA, CAT_CARTAS_OTRAS, CAT_FEADULTA],
|
||||||
|
40: [CAT_CARTAS_OTRAS, CAT_FEADULTA],
|
||||||
|
41: [CAT_CARTAS_OTRAS, CAT_CARTA_PASADA, CAT_FEADULTA],
|
||||||
|
}
|
||||||
|
|
||||||
|
DRY_RUN = '--dry-run' in sys.argv
|
||||||
|
|
||||||
|
# ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def joomla_query(query: str) -> list[dict]:
|
||||||
|
mysql_cmd = (f"mysql --skip-ssl -h {JOOMLA_DB_HOST} -u {JOOMLA_DB_USER} "
|
||||||
|
f"-p'{JOOMLA_DB_PASS}' {JOOMLA_DB_NAME} "
|
||||||
|
f"--default-character-set=utf8mb4 -B")
|
||||||
|
cmd = ['sshpass', '-p', JOOMLA_SSH_PASS,
|
||||||
|
'ssh', f'{JOOMLA_SSH_USER}@{JOOMLA_SSH_HOST}', mysql_cmd]
|
||||||
|
result = subprocess.run(cmd, input=query, capture_output=True,
|
||||||
|
text=True, encoding='utf-8')
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"[ERR SSH] {result.stderr[:300]}", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
lines = result.stdout.strip().split('\n')
|
||||||
|
if len(lines) < 2:
|
||||||
|
return []
|
||||||
|
headers = lines[0].split('\t')
|
||||||
|
return [dict(zip(headers, line.split('\t'))) for line in lines[1:] if line]
|
||||||
|
|
||||||
|
|
||||||
|
def wp_mysql(query: str) -> list[dict]:
|
||||||
|
cmd = ['docker', 'exec', WP_DOCKER,
|
||||||
|
'mysql', '-u', WP_DB_USER, f'-p{WP_DB_PASS}', WP_DB_NAME,
|
||||||
|
'--default-character-set=utf8mb4', '-B', '-e', query]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8')
|
||||||
|
if result.returncode != 0:
|
||||||
|
return []
|
||||||
|
lines = result.stdout.strip().split('\n')
|
||||||
|
if len(lines) < 2:
|
||||||
|
return []
|
||||||
|
headers = lines[0].split('\t')
|
||||||
|
return [dict(zip(headers, line.split('\t'))) for line in lines[1:] if line]
|
||||||
|
|
||||||
|
|
||||||
|
def wp_execute(sql: str):
|
||||||
|
if DRY_RUN:
|
||||||
|
print(f" [DRY] {sql[:110]}")
|
||||||
|
return None
|
||||||
|
cmd = ['docker', 'exec', WP_DOCKER,
|
||||||
|
'mysql', '-u', WP_DB_USER, f'-p{WP_DB_PASS}', WP_DB_NAME,
|
||||||
|
'--default-character-set=utf8mb4', '-e', sql]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
err = result.stderr.replace('mysql: [Warning] Using a password on the command line interface can be insecure.\n', '')
|
||||||
|
if err.strip():
|
||||||
|
print(f" [ERR] {err.strip()[:200]}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def esc(s: str) -> str:
|
||||||
|
return s.replace('\\', '\\\\').replace("'", "\\'")
|
||||||
|
|
||||||
|
|
||||||
|
def unhex(val: str) -> str:
|
||||||
|
if not val or val == 'NULL':
|
||||||
|
return ''
|
||||||
|
try:
|
||||||
|
return bytes.fromhex(val).decode('utf-8', errors='replace')
|
||||||
|
except Exception:
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
global LAST_CONTENT_ID
|
||||||
|
# Detección dinámica del último ew4r_content (carta) ya importado
|
||||||
|
r = wp_mysql("SELECT MAX(CAST(meta_value AS UNSIGNED)) m FROM wp_postmeta "
|
||||||
|
"WHERE meta_key='_fgj2wp_old_content_id'")
|
||||||
|
LAST_CONTENT_ID = int(r[0]['m']) if r and r[0].get('m') and r[0]['m'] != 'NULL' else 9043
|
||||||
|
print(f"=== Import nuevas cartas (ew4r_content id > {LAST_CONTENT_ID}) "
|
||||||
|
f"{'[DRY RUN]' if DRY_RUN else '[LIVE]'} ===\n")
|
||||||
|
|
||||||
|
# Cargar term_taxonomy_ids
|
||||||
|
all_term_ids = [CAT_FEADULTA, CAT_CARTA_SEMANA, CAT_CARTAS_OTRAS, CAT_CARTA_PASADA]
|
||||||
|
rows = wp_mysql(
|
||||||
|
f"SELECT term_id, term_taxonomy_id FROM wp_term_taxonomy "
|
||||||
|
f"WHERE term_id IN ({','.join(map(str,all_term_ids))}) AND taxonomy='category'"
|
||||||
|
)
|
||||||
|
tt_ids = {int(r['term_id']): int(r['term_taxonomy_id']) for r in rows}
|
||||||
|
print(f"TT IDs: {tt_ids}")
|
||||||
|
|
||||||
|
# Cargar Polylang ES
|
||||||
|
pl_rows = wp_mysql(
|
||||||
|
"SELECT tt.term_taxonomy_id FROM wp_terms t "
|
||||||
|
"JOIN wp_term_taxonomy tt ON tt.term_id=t.term_id "
|
||||||
|
"WHERE tt.taxonomy='language' AND t.slug='es' LIMIT 1"
|
||||||
|
)
|
||||||
|
pl_es_tt = int(pl_rows[0]['term_taxonomy_id']) if pl_rows else None
|
||||||
|
print(f"Polylang ES tt_id: {pl_es_tt}")
|
||||||
|
|
||||||
|
# Cargar user map
|
||||||
|
user_rows = wp_mysql(
|
||||||
|
"SELECT um.meta_value jid, u.ID wid FROM wp_users u "
|
||||||
|
"JOIN wp_usermeta um ON um.user_id=u.ID "
|
||||||
|
"WHERE um.meta_key='_fgj2wp_old_user_id'"
|
||||||
|
)
|
||||||
|
user_map = {}
|
||||||
|
for r in user_rows:
|
||||||
|
try:
|
||||||
|
user_map[int(r['jid'])] = int(r['wid'])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Obtener cartas nuevas de Joomla (con HEX para texto)
|
||||||
|
print("\nObteniendo cartas nuevas de Joomla...")
|
||||||
|
query = (
|
||||||
|
f"SELECT id, HEX(title) title, HEX(alias) alias, "
|
||||||
|
f"HEX(introtext) introtext, HEX(`fulltext`) fulltext_col, "
|
||||||
|
f"catid, created, created_by "
|
||||||
|
f"FROM ew4r_content "
|
||||||
|
f"WHERE state=1 AND id > {LAST_CONTENT_ID} AND catid IN (27,40,41) "
|
||||||
|
f"ORDER BY id;"
|
||||||
|
)
|
||||||
|
items = joomla_query(query)
|
||||||
|
print(f"Cartas a importar: {len(items)}")
|
||||||
|
|
||||||
|
# Mapa fecha_carta → wp_id (para asignar _carta_id a artículos K2)
|
||||||
|
fecha_a_wp_carta = {}
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
joomla_id = int(item['id'])
|
||||||
|
catid = int(item['catid'])
|
||||||
|
title = unhex(item.get('title',''))
|
||||||
|
alias = unhex(item.get('alias',''))
|
||||||
|
intro = unhex(item.get('introtext',''))
|
||||||
|
full = unhex(item.get('fulltext_col',''))
|
||||||
|
created = item.get('created','') or datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
created_by = int(item.get('created_by', 0) or 0)
|
||||||
|
|
||||||
|
content = intro + ('\n<!--more-->\n' + full if full.strip() else '')
|
||||||
|
# La carta semanal SIEMPRE la firma Inma Calvo (WP user 1048 icalvotorre),
|
||||||
|
# aunque en Joomla la cree el webmaster (José Chicharro / josek 1049).
|
||||||
|
CARTA_AUTHOR = 1048
|
||||||
|
wp_author = CARTA_AUTHOR
|
||||||
|
wp_cats = CATID_TO_WP.get(catid, [CAT_CARTAS_OTRAS, CAT_FEADULTA])
|
||||||
|
fecha_carta = created[:10] # YYYY-MM-DD
|
||||||
|
|
||||||
|
print(f"\n [{joomla_id}] {title[:55]} | catid={catid} | fecha={fecha_carta}")
|
||||||
|
print(f" → WP cats: {wp_cats}")
|
||||||
|
|
||||||
|
# INSERT post
|
||||||
|
post_slug = esc(alias[:200])
|
||||||
|
post_title = esc(title)
|
||||||
|
post_content = esc(content)
|
||||||
|
|
||||||
|
wp_execute(
|
||||||
|
f"INSERT INTO wp_posts "
|
||||||
|
f"(post_author, post_date, post_date_gmt, post_content, post_title, "
|
||||||
|
f"post_excerpt, post_status, comment_status, ping_status, post_name, "
|
||||||
|
f"post_type, post_modified, post_modified_gmt, comment_count, "
|
||||||
|
f"to_ping, pinged, post_content_filtered) VALUES ("
|
||||||
|
f"{wp_author}, '{created}', '{created}', '{post_content}', "
|
||||||
|
f"'{post_title}', '', 'publish', 'open', 'open', '{post_slug}', "
|
||||||
|
f"'post', '{created}', '{created}', 0, '', '', '')"
|
||||||
|
)
|
||||||
|
|
||||||
|
if DRY_RUN:
|
||||||
|
fecha_a_wp_carta[fecha_carta] = f"DRY_WP_ID_for_{joomla_id}"
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_id_rows = wp_mysql("SELECT MAX(ID) new_id FROM wp_posts")
|
||||||
|
if not new_id_rows:
|
||||||
|
print(f" [ERR] No se pudo obtener ID del post", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
new_wp_id = int(new_id_rows[0]['new_id'])
|
||||||
|
print(f" → WP post ID={new_wp_id}")
|
||||||
|
|
||||||
|
fecha_a_wp_carta[fecha_carta] = new_wp_id
|
||||||
|
|
||||||
|
# Metas
|
||||||
|
wp_execute(f"INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES ({new_wp_id}, '_fgj2wp_old_content_id', '{joomla_id}')")
|
||||||
|
wp_execute(f"INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES ({new_wp_id}, 'Idioma', '1')")
|
||||||
|
|
||||||
|
# Categorías
|
||||||
|
for term_id in wp_cats:
|
||||||
|
tt_id = tt_ids.get(term_id)
|
||||||
|
if tt_id:
|
||||||
|
wp_execute(
|
||||||
|
f"INSERT IGNORE INTO wp_term_relationships (object_id, term_taxonomy_id) "
|
||||||
|
f"VALUES ({new_wp_id}, {tt_id})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Polylang ES
|
||||||
|
if pl_es_tt:
|
||||||
|
wp_execute(
|
||||||
|
f"INSERT IGNORE INTO wp_term_relationships (object_id, term_taxonomy_id) "
|
||||||
|
f"VALUES ({new_wp_id}, {pl_es_tt})"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"\nFecha→WP carta map: {fecha_a_wp_carta}")
|
||||||
|
|
||||||
|
# ── Asignar _carta_id a los artículos K2 importados ──────────────────────
|
||||||
|
if DRY_RUN or not fecha_a_wp_carta:
|
||||||
|
print("\n[SKIP] Asignación _carta_id (dry-run o sin cartas importadas)")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("\n=== Asignando _carta_id a artículos K2 ===")
|
||||||
|
|
||||||
|
# Obtener los artículos K2 con su fecha (id 15), acotando por la fecha más
|
||||||
|
# antigua de las cartas importadas en esta ejecución (evita recorrer todo).
|
||||||
|
min_fecha = min(fecha_a_wp_carta.keys())
|
||||||
|
k2_query = (
|
||||||
|
f"SELECT id, HEX(extra_fields) ef "
|
||||||
|
f"FROM ew4r_k2_items WHERE published=1 AND created >= '{min_fecha} 00:00:00' "
|
||||||
|
f"ORDER BY id;"
|
||||||
|
)
|
||||||
|
k2_items = joomla_query(k2_query)
|
||||||
|
print(f"Artículos K2 a procesar (desde {min_fecha}): {len(k2_items)}")
|
||||||
|
|
||||||
|
assigned = 0
|
||||||
|
for k2item in k2_items:
|
||||||
|
k2_id = int(k2item['id'])
|
||||||
|
# wp_id REAL por meta (NO offset fijo, que pisaba metas en deltas sucesivos)
|
||||||
|
wp_rows = wp_mysql(
|
||||||
|
f"SELECT post_id FROM wp_postmeta WHERE meta_key='_fgj2wp_old_k2_id' "
|
||||||
|
f"AND meta_value='{k2_id}' LIMIT 1"
|
||||||
|
)
|
||||||
|
if not wp_rows:
|
||||||
|
continue
|
||||||
|
wp_id = int(wp_rows[0]['post_id'])
|
||||||
|
ef_raw = unhex(k2item.get('ef',''))
|
||||||
|
|
||||||
|
# Parsear fecha (id 15)
|
||||||
|
fecha_art = None
|
||||||
|
try:
|
||||||
|
fields = json.loads(ef_raw)
|
||||||
|
for f in fields:
|
||||||
|
if str(f.get('id','')) == '15':
|
||||||
|
fecha_art = str(f.get('value',''))[:10]
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not fecha_art:
|
||||||
|
continue
|
||||||
|
|
||||||
|
carta_wp_id = fecha_a_wp_carta.get(fecha_art)
|
||||||
|
if not carta_wp_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Verificar que el meta no existe ya
|
||||||
|
existing = wp_mysql(
|
||||||
|
f"SELECT meta_id FROM wp_postmeta WHERE post_id={wp_id} AND meta_key='_carta_id' LIMIT 1"
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
continue
|
||||||
|
|
||||||
|
wp_execute(
|
||||||
|
f"INSERT INTO wp_postmeta (post_id, meta_key, meta_value) "
|
||||||
|
f"VALUES ({wp_id}, '_carta_id', '{carta_wp_id}')"
|
||||||
|
)
|
||||||
|
print(f" K2 {k2_id} (WP {wp_id}) → _carta_id={carta_wp_id} [{fecha_art}]")
|
||||||
|
assigned += 1
|
||||||
|
|
||||||
|
print(f"\n_carta_id asignado a {assigned} artículos.")
|
||||||
|
|
||||||
|
# Actualizar counts de categorías
|
||||||
|
print("\nActualizando counts de categorías...")
|
||||||
|
tt_str = ','.join(str(v) for v in tt_ids.values())
|
||||||
|
wp_execute(
|
||||||
|
f"UPDATE wp_term_taxonomy tt SET count = ("
|
||||||
|
f"SELECT COUNT(*) FROM wp_term_relationships tr "
|
||||||
|
f"WHERE tr.term_taxonomy_id=tt.term_taxonomy_id"
|
||||||
|
f") WHERE tt.term_taxonomy_id IN ({tt_str})"
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\nListo.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
import_new_content.py
|
||||||
|
|
||||||
|
Importa los ew4r_content items no-carta nuevos (id > 9043, catid NOT IN 27,40,41)
|
||||||
|
al WordPress local (Docker).
|
||||||
|
|
||||||
|
Mapping catid → WP term_ids:
|
||||||
|
54 (Índice multimedia) → 26
|
||||||
|
77 (Videos) → 58
|
||||||
|
64 (Noticias de alcance) → 41
|
||||||
|
52 (Tablón de anuncios) → 1 (uncategorized / sin categoría)
|
||||||
|
63 (Fechas) → 40
|
||||||
|
61 (Lista completa de autores) → 38
|
||||||
|
65 (Cantoral Salomé Arricibita) → 31
|
||||||
|
otro → 1 (uncategorized)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
JOOMLA_SSH_HOST = "134.0.10.170"
|
||||||
|
JOOMLA_SSH_USER = "feadulta"
|
||||||
|
JOOMLA_SSH_PASS = "6Rm2qOF@eundwpda"
|
||||||
|
JOOMLA_DB_HOST = "127.0.0.1"
|
||||||
|
JOOMLA_DB_USER = "fejoomla3"
|
||||||
|
JOOMLA_DB_PASS = "5FF-}5^[>7^pK4W9"
|
||||||
|
JOOMLA_DB_NAME = "fejoomla3"
|
||||||
|
|
||||||
|
WP_DOCKER = "wordpress-mysql"
|
||||||
|
WP_DB_USER = "wordpress_user"
|
||||||
|
WP_DB_PASS = "wordpress_pass"
|
||||||
|
WP_DB_NAME = "wordpress_db"
|
||||||
|
|
||||||
|
LAST_CONTENT_ID = 9043
|
||||||
|
CARTA_CATIDS = {27, 40, 41}
|
||||||
|
|
||||||
|
CATID_TO_WP = {
|
||||||
|
54: [26],
|
||||||
|
77: [58],
|
||||||
|
64: [41],
|
||||||
|
52: [1],
|
||||||
|
63: [40],
|
||||||
|
61: [38],
|
||||||
|
65: [31],
|
||||||
|
}
|
||||||
|
|
||||||
|
DRY_RUN = '--dry-run' in sys.argv
|
||||||
|
|
||||||
|
# ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def joomla_query(query: str) -> list[dict]:
|
||||||
|
mysql_cmd = (f"mysql --skip-ssl -h {JOOMLA_DB_HOST} -u {JOOMLA_DB_USER} "
|
||||||
|
f"-p'{JOOMLA_DB_PASS}' {JOOMLA_DB_NAME} "
|
||||||
|
f"--default-character-set=utf8mb4 -B")
|
||||||
|
cmd = ['sshpass', '-p', JOOMLA_SSH_PASS,
|
||||||
|
'ssh', f'{JOOMLA_SSH_USER}@{JOOMLA_SSH_HOST}', mysql_cmd]
|
||||||
|
result = subprocess.run(cmd, input=query, capture_output=True,
|
||||||
|
text=True, encoding='utf-8')
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"[ERR SSH] {result.stderr[:300]}", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
lines = result.stdout.strip().split('\n')
|
||||||
|
if len(lines) < 2:
|
||||||
|
return []
|
||||||
|
headers = lines[0].split('\t')
|
||||||
|
return [dict(zip(headers, line.split('\t'))) for line in lines[1:] if line]
|
||||||
|
|
||||||
|
|
||||||
|
def wp_mysql(query: str) -> list[dict]:
|
||||||
|
cmd = ['docker', 'exec', WP_DOCKER,
|
||||||
|
'mysql', '-u', WP_DB_USER, f'-p{WP_DB_PASS}', WP_DB_NAME,
|
||||||
|
'--default-character-set=utf8mb4', '-B', '-e', query]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8')
|
||||||
|
if result.returncode != 0:
|
||||||
|
return []
|
||||||
|
lines = result.stdout.strip().split('\n')
|
||||||
|
if len(lines) < 2:
|
||||||
|
return []
|
||||||
|
headers = lines[0].split('\t')
|
||||||
|
return [dict(zip(headers, line.split('\t'))) for line in lines[1:] if line]
|
||||||
|
|
||||||
|
|
||||||
|
def wp_execute(sql: str):
|
||||||
|
if DRY_RUN:
|
||||||
|
print(f" [DRY] {sql[:110]}")
|
||||||
|
return
|
||||||
|
cmd = ['docker', 'exec', WP_DOCKER,
|
||||||
|
'mysql', '-u', WP_DB_USER, f'-p{WP_DB_PASS}', WP_DB_NAME,
|
||||||
|
'--default-character-set=utf8mb4', '-e', sql]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
err = result.stderr.replace('mysql: [Warning] Using a password on the command line interface can be insecure.\n', '')
|
||||||
|
if err.strip():
|
||||||
|
print(f" [ERR] {err.strip()[:200]}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def esc(s: str) -> str:
|
||||||
|
return s.replace('\\', '\\\\').replace("'", "\\'")
|
||||||
|
|
||||||
|
|
||||||
|
def unhex(val: str) -> str:
|
||||||
|
if not val or val == 'NULL':
|
||||||
|
return ''
|
||||||
|
try:
|
||||||
|
return bytes.fromhex(val).decode('utf-8', errors='replace')
|
||||||
|
except Exception:
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"=== Import ew4r_content no-cartas (id > {LAST_CONTENT_ID}) "
|
||||||
|
f"{'[DRY RUN]' if DRY_RUN else '[LIVE]'} ===\n")
|
||||||
|
|
||||||
|
# Cargar user map
|
||||||
|
user_rows = wp_mysql(
|
||||||
|
"SELECT um.meta_value jid, u.ID wid FROM wp_users u "
|
||||||
|
"JOIN wp_usermeta um ON um.user_id=u.ID "
|
||||||
|
"WHERE um.meta_key='_fgj2wp_old_user_id'"
|
||||||
|
)
|
||||||
|
user_map = {}
|
||||||
|
for r in user_rows:
|
||||||
|
try:
|
||||||
|
user_map[int(r['jid'])] = int(r['wid'])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
print(f"Usuarios mapeados: {len(user_map)}")
|
||||||
|
|
||||||
|
# Cargar term_taxonomy_ids
|
||||||
|
all_term_ids = sorted({t for cats in CATID_TO_WP.values() for t in cats} | {1})
|
||||||
|
rows = wp_mysql(
|
||||||
|
f"SELECT term_id, term_taxonomy_id FROM wp_term_taxonomy "
|
||||||
|
f"WHERE term_id IN ({','.join(map(str,all_term_ids))}) AND taxonomy='category'"
|
||||||
|
)
|
||||||
|
tt_ids = {int(r['term_id']): int(r['term_taxonomy_id']) for r in rows}
|
||||||
|
print(f"TT IDs: {tt_ids}")
|
||||||
|
|
||||||
|
# Polylang ES
|
||||||
|
pl_rows = wp_mysql(
|
||||||
|
"SELECT tt.term_taxonomy_id FROM wp_terms t "
|
||||||
|
"JOIN wp_term_taxonomy tt ON tt.term_id=t.term_id "
|
||||||
|
"WHERE tt.taxonomy='language' AND t.slug='es' LIMIT 1"
|
||||||
|
)
|
||||||
|
pl_es_tt = int(pl_rows[0]['term_taxonomy_id']) if pl_rows else None
|
||||||
|
|
||||||
|
# IDs ya en WP
|
||||||
|
existing_rows = wp_mysql(
|
||||||
|
f"SELECT meta_value FROM wp_postmeta "
|
||||||
|
f"WHERE meta_key='_fgj2wp_old_content_id' AND meta_value+0 > {LAST_CONTENT_ID}"
|
||||||
|
)
|
||||||
|
existing_ids = {int(r['meta_value']) for r in existing_rows}
|
||||||
|
print(f"IDs ya importados con id > {LAST_CONTENT_ID}: {len(existing_ids)}")
|
||||||
|
|
||||||
|
# Obtener items de Joomla
|
||||||
|
print("\nObteniendo items de Joomla...")
|
||||||
|
catids_excl = ','.join(str(c) for c in CARTA_CATIDS)
|
||||||
|
query = (
|
||||||
|
f"SELECT id, HEX(title) title, HEX(alias) alias, "
|
||||||
|
f"HEX(introtext) introtext, HEX(`fulltext`) fulltext_col, "
|
||||||
|
f"catid, created, created_by "
|
||||||
|
f"FROM ew4r_content "
|
||||||
|
f"WHERE state=1 AND id > {LAST_CONTENT_ID} AND catid NOT IN ({catids_excl}) "
|
||||||
|
f"ORDER BY id;"
|
||||||
|
)
|
||||||
|
items = joomla_query(query)
|
||||||
|
print(f"Items a importar: {len(items)}")
|
||||||
|
|
||||||
|
stats = {'ok': 0, 'skip': 0, 'err': 0}
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
joomla_id = int(item['id'])
|
||||||
|
catid = int(item['catid'])
|
||||||
|
title = unhex(item.get('title', ''))
|
||||||
|
alias = unhex(item.get('alias', ''))
|
||||||
|
intro = unhex(item.get('introtext', ''))
|
||||||
|
full = unhex(item.get('fulltext_col', ''))
|
||||||
|
created = item.get('created', '') or datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
created_by = int(item.get('created_by', 0) or 0)
|
||||||
|
|
||||||
|
if joomla_id in existing_ids:
|
||||||
|
print(f" [SKIP] id={joomla_id} ya existe")
|
||||||
|
stats['skip'] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
content = intro + ('\n<!--more-->\n' + full if full.strip() else '')
|
||||||
|
# Multimedia/pensamientos/vídeos/cantoral (catid 54/77/65) son contenido
|
||||||
|
# propio de FeAdulta → autor "Fe Adulta" (WP user 890), no el webmaster
|
||||||
|
# que los sube en Joomla. El resto conserva su autor real (noticias, etc.).
|
||||||
|
FEADULTA_AUTHOR = 890
|
||||||
|
FEADULTA_CATIDS = {54, 77, 65}
|
||||||
|
wp_author = FEADULTA_AUTHOR if catid in FEADULTA_CATIDS else user_map.get(created_by, 1)
|
||||||
|
wp_cats = CATID_TO_WP.get(catid, [1])
|
||||||
|
|
||||||
|
print(f" [{joomla_id}] catid={catid} | {title[:50]}")
|
||||||
|
|
||||||
|
wp_execute(
|
||||||
|
f"INSERT INTO wp_posts "
|
||||||
|
f"(post_author, post_date, post_date_gmt, post_content, post_title, "
|
||||||
|
f"post_excerpt, post_status, comment_status, ping_status, post_name, "
|
||||||
|
f"post_type, post_modified, post_modified_gmt, comment_count, "
|
||||||
|
f"to_ping, pinged, post_content_filtered) VALUES ("
|
||||||
|
f"{wp_author}, '{created}', '{created}', '{esc(content)}', "
|
||||||
|
f"'{esc(title)}', '', 'publish', 'open', 'open', '{esc(alias[:200])}', "
|
||||||
|
f"'post', '{created}', '{created}', 0, '', '', '')"
|
||||||
|
)
|
||||||
|
|
||||||
|
if DRY_RUN:
|
||||||
|
stats['ok'] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_id_rows = wp_mysql("SELECT MAX(ID) new_id FROM wp_posts")
|
||||||
|
if not new_id_rows:
|
||||||
|
stats['err'] += 1
|
||||||
|
continue
|
||||||
|
new_wp_id = int(new_id_rows[0]['new_id'])
|
||||||
|
print(f" → WP ID={new_wp_id}")
|
||||||
|
|
||||||
|
# Metas
|
||||||
|
wp_execute(f"INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES ({new_wp_id}, '_fgj2wp_old_content_id', '{joomla_id}')")
|
||||||
|
|
||||||
|
# Categorías
|
||||||
|
for term_id in wp_cats:
|
||||||
|
tt_id = tt_ids.get(term_id)
|
||||||
|
if tt_id:
|
||||||
|
wp_execute(
|
||||||
|
f"INSERT IGNORE INTO wp_term_relationships (object_id, term_taxonomy_id) "
|
||||||
|
f"VALUES ({new_wp_id}, {tt_id})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Polylang ES
|
||||||
|
if pl_es_tt:
|
||||||
|
wp_execute(
|
||||||
|
f"INSERT IGNORE INTO wp_term_relationships (object_id, term_taxonomy_id) "
|
||||||
|
f"VALUES ({new_wp_id}, {pl_es_tt})"
|
||||||
|
)
|
||||||
|
|
||||||
|
stats['ok'] += 1
|
||||||
|
|
||||||
|
if not DRY_RUN and stats['ok'] > 0:
|
||||||
|
print("\nActualizando counts de categorías...")
|
||||||
|
tt_str = ','.join(str(v) for v in tt_ids.values())
|
||||||
|
wp_execute(
|
||||||
|
f"UPDATE wp_term_taxonomy tt SET count = ("
|
||||||
|
f"SELECT COUNT(*) FROM wp_term_relationships tr "
|
||||||
|
f"WHERE tr.term_taxonomy_id=tt.term_taxonomy_id"
|
||||||
|
f") WHERE tt.term_taxonomy_id IN ({tt_str})"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"\n=== Resultado: {stats['ok']} ok, {stats['skip']} skip, {stats['err']} err ===")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
import_new_k2_items.py
|
||||||
|
|
||||||
|
Importa los K2 items nuevos de Joomla prod (id > 17873) al WordPress local (Docker).
|
||||||
|
Conexión a Joomla: SSH + MySQL en feadulta@134.0.10.170
|
||||||
|
Conexión a WP: Docker exec wordpress-mysql
|
||||||
|
|
||||||
|
Categorías WP asignadas según extra_fields:
|
||||||
|
- ES + tiene "libro de la biblia" (id 9) → Comentarios al evangelio (1647) + Feadulta (71)
|
||||||
|
- ES + no id9 + título "DOMINGO/SEMANA SANTA/etc." → Eucaristía (1648) + Feadulta (71)
|
||||||
|
- ES + no id9 + otro → Artículos (1650) + Feadulta (71)
|
||||||
|
- No ES → Artículos (1650) + Feadulta (71)
|
||||||
|
|
||||||
|
Idioma Polylang asignado según extra_field id 16:
|
||||||
|
1=es, 2=en, 3=fr, 4=it, 5=pt
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# ── Configuración ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
JOOMLA_SSH_HOST = "134.0.10.170"
|
||||||
|
JOOMLA_SSH_USER = "feadulta"
|
||||||
|
JOOMLA_SSH_PASS = "C6c2A!mAl3Wj.BQF"
|
||||||
|
JOOMLA_DB_HOST = "127.0.0.1"
|
||||||
|
JOOMLA_DB_USER = "fejoomla3"
|
||||||
|
JOOMLA_DB_PASS = "5FF-}5^[>7^pK4W9"
|
||||||
|
JOOMLA_DB_NAME = "fejoomla3"
|
||||||
|
|
||||||
|
WP_DOCKER = "wordpress-mysql"
|
||||||
|
WP_DB_USER = "wordpress_user"
|
||||||
|
WP_DB_PASS = "wordpress_pass"
|
||||||
|
WP_DB_NAME = "wordpress_db"
|
||||||
|
WP_DB_HOST = "wordpress-mysql" # dentro del container
|
||||||
|
|
||||||
|
LAST_K2_ID = None # se calcula dinámicamente en main(): MAX(_fgj2wp_old_k2_id) en WP
|
||||||
|
|
||||||
|
# WP term_taxonomy_ids (obtenidos con SELECT tt.term_taxonomy_id FROM wp_term_taxonomy tt WHERE tt.term_id=N)
|
||||||
|
# Precalculados:
|
||||||
|
CAT_FEADULTA = 71 # term_id (se convertirá a term_taxonomy_id abajo)
|
||||||
|
CAT_ARTICULOS = 1650
|
||||||
|
CAT_EVANGELIO = 1647
|
||||||
|
CAT_EUCARISTIA = 1648
|
||||||
|
|
||||||
|
LANG_MAP = {1: 'es', 2: 'en', 3: 'fr', 4: 'it', 5: 'pt'}
|
||||||
|
DOMINGO_RE = r'DOMINGO|SEMANA SANTA|SEMANA DE PASCUA|PENTECOST|NAVIDAD|EPIFAN'
|
||||||
|
|
||||||
|
DRY_RUN = '--dry-run' in sys.argv
|
||||||
|
|
||||||
|
# ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def ssh_mysql(query: str) -> list[dict]:
|
||||||
|
"""Ejecuta una query en el MySQL de Joomla prod vía sshpass."""
|
||||||
|
cmd = [
|
||||||
|
'sshpass', '-p', JOOMLA_SSH_PASS,
|
||||||
|
'ssh', f'{JOOMLA_SSH_USER}@{JOOMLA_SSH_HOST}',
|
||||||
|
f'mysql --skip-ssl -h {JOOMLA_DB_HOST} -u {JOOMLA_DB_USER} '
|
||||||
|
f'-p{repr(JOOMLA_DB_PASS)} {JOOMLA_DB_NAME} '
|
||||||
|
f'--default-character-set=utf8mb4 -B -e "{query}"'
|
||||||
|
]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8')
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"[ERROR SSH] {result.stderr[:300]}", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
lines = result.stdout.strip().split('\n')
|
||||||
|
if len(lines) < 2:
|
||||||
|
return []
|
||||||
|
headers = lines[0].split('\t')
|
||||||
|
rows = []
|
||||||
|
for line in lines[1:]:
|
||||||
|
if line:
|
||||||
|
vals = line.split('\t')
|
||||||
|
rows.append(dict(zip(headers, vals)))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def wp_mysql(query: str) -> list[dict]:
|
||||||
|
"""Ejecuta una query en el MySQL del WP local vía Docker exec."""
|
||||||
|
cmd = [
|
||||||
|
'docker', 'exec', WP_DOCKER,
|
||||||
|
'mysql', '-u', WP_DB_USER, f'-p{WP_DB_PASS}', WP_DB_NAME,
|
||||||
|
'--default-character-set=utf8mb4', '-B', '-e', query
|
||||||
|
]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8')
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"[ERROR WP] {result.stderr[:300]}", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
lines = result.stdout.strip().split('\n')
|
||||||
|
if len(lines) < 2:
|
||||||
|
return []
|
||||||
|
headers = lines[0].split('\t')
|
||||||
|
rows = []
|
||||||
|
for line in lines[1:]:
|
||||||
|
if line:
|
||||||
|
vals = line.split('\t')
|
||||||
|
rows.append(dict(zip(headers, vals)))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def wp_execute(sql: str):
|
||||||
|
"""Ejecuta un INSERT/UPDATE en WP MySQL."""
|
||||||
|
if DRY_RUN:
|
||||||
|
print(f" [DRY] {sql[:120]}")
|
||||||
|
return
|
||||||
|
cmd = [
|
||||||
|
'docker', 'exec', WP_DOCKER,
|
||||||
|
'mysql', '-u', WP_DB_USER, f'-p{WP_DB_PASS}', WP_DB_NAME,
|
||||||
|
'--default-character-set=utf8mb4', '-e', sql
|
||||||
|
]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"[ERROR INSERT] {result.stderr[:300]}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def esc(s: str) -> str:
|
||||||
|
"""Escapa una string para SQL."""
|
||||||
|
return s.replace('\\', '\\\\').replace("'", "\\'")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Cargar datos auxiliares ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def load_user_map() -> dict:
|
||||||
|
"""Devuelve {joomla_user_id: wp_user_id}."""
|
||||||
|
rows = wp_mysql(
|
||||||
|
"SELECT um.meta_value jid, u.ID wid FROM wp_users u "
|
||||||
|
"JOIN wp_usermeta um ON um.user_id=u.ID "
|
||||||
|
"WHERE um.meta_key='_fgj2wp_old_user_id'"
|
||||||
|
)
|
||||||
|
m = {}
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
m[int(r['jid'])] = int(r['wid'])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def load_term_taxonomy_ids() -> dict:
|
||||||
|
"""Devuelve {term_id: term_taxonomy_id} para las categorías relevantes."""
|
||||||
|
term_ids = [CAT_FEADULTA, CAT_ARTICULOS, CAT_EVANGELIO, CAT_EUCARISTIA]
|
||||||
|
ids_str = ','.join(str(x) for x in term_ids)
|
||||||
|
rows = wp_mysql(
|
||||||
|
f"SELECT term_id, term_taxonomy_id FROM wp_term_taxonomy "
|
||||||
|
f"WHERE term_id IN ({ids_str}) AND taxonomy='category'"
|
||||||
|
)
|
||||||
|
return {int(r['term_id']): int(r['term_taxonomy_id']) for r in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def load_polylang_term_ids() -> dict:
|
||||||
|
"""Devuelve {'es': tt_id, 'en': tt_id, ...} para los términos de idioma de Polylang."""
|
||||||
|
rows = wp_mysql(
|
||||||
|
"SELECT t.slug, tt.term_taxonomy_id FROM wp_terms t "
|
||||||
|
"JOIN wp_term_taxonomy tt ON tt.term_id=t.term_id "
|
||||||
|
"WHERE tt.taxonomy='language' AND t.slug IN ('es','en','fr','it','pt')"
|
||||||
|
)
|
||||||
|
return {r['slug']: int(r['term_taxonomy_id']) for r in rows}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Parsear extra_fields ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def parse_extra_fields(ef_json: str) -> dict:
|
||||||
|
"""Devuelve dict con claves: lang_val, has_libro, cita_biblica."""
|
||||||
|
result = {'lang_val': None, 'has_libro': False, 'cita_biblica': None}
|
||||||
|
if not ef_json or ef_json == 'NULL':
|
||||||
|
return result
|
||||||
|
try:
|
||||||
|
fields = json.loads(ef_json)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return result
|
||||||
|
for f in fields:
|
||||||
|
fid = str(f.get('id', ''))
|
||||||
|
val = f.get('value')
|
||||||
|
if fid == '16' and val is not None:
|
||||||
|
try:
|
||||||
|
result['lang_val'] = int(val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
elif fid == '9':
|
||||||
|
result['has_libro'] = True
|
||||||
|
elif fid == '14':
|
||||||
|
if isinstance(val, list):
|
||||||
|
result['cita_biblica'] = ','.join(str(v) for v in val)
|
||||||
|
else:
|
||||||
|
result['cita_biblica'] = str(val) if val else None
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def determine_categories(ef: dict, title: str) -> list[int]:
|
||||||
|
"""Devuelve lista de term_ids de categoría para el post."""
|
||||||
|
import re
|
||||||
|
lang = ef.get('lang_val')
|
||||||
|
es = (lang == 1 or lang is None)
|
||||||
|
cats = [CAT_FEADULTA]
|
||||||
|
if es and ef.get('has_libro'):
|
||||||
|
cats.append(CAT_EVANGELIO)
|
||||||
|
elif es and re.search(DOMINGO_RE, title, re.IGNORECASE):
|
||||||
|
cats.append(CAT_EUCARISTIA)
|
||||||
|
else:
|
||||||
|
cats.append(CAT_ARTICULOS)
|
||||||
|
return cats
|
||||||
|
|
||||||
|
|
||||||
|
# ── Import principal ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
global LAST_K2_ID
|
||||||
|
# Detección dinámica del último K2 importado (evita hardcodear y re-importar deltas previos)
|
||||||
|
r = wp_mysql("SELECT MAX(CAST(meta_value AS UNSIGNED)) m FROM wp_postmeta "
|
||||||
|
"WHERE meta_key='_fgj2wp_old_k2_id'")
|
||||||
|
LAST_K2_ID = int(r[0]['m']) if r and r[0].get('m') and r[0]['m'] != 'NULL' else 17873
|
||||||
|
print(f"=== Import K2 items > {LAST_K2_ID} → WP local {'[DRY RUN]' if DRY_RUN else '[LIVE]'} ===\n")
|
||||||
|
|
||||||
|
user_map = load_user_map()
|
||||||
|
print(f"Usuarios mapeados: {len(user_map)}")
|
||||||
|
|
||||||
|
tt_ids = load_term_taxonomy_ids()
|
||||||
|
print(f"Categorías TT IDs: {tt_ids}")
|
||||||
|
|
||||||
|
pl_ids = load_polylang_term_ids()
|
||||||
|
print(f"Polylang idiomas: {pl_ids}")
|
||||||
|
|
||||||
|
# Verificar que los K2 IDs ya en WP no se reimportan
|
||||||
|
existing = wp_mysql(
|
||||||
|
f"SELECT meta_value FROM wp_postmeta WHERE meta_key='_fgj2wp_old_k2_id' "
|
||||||
|
f"AND meta_value+0 > {LAST_K2_ID}"
|
||||||
|
)
|
||||||
|
existing_ids = {int(r['meta_value']) for r in existing}
|
||||||
|
print(f"K2 IDs > {LAST_K2_ID} ya en WP: {len(existing_ids)}")
|
||||||
|
|
||||||
|
# Obtener items de Joomla vía SSH+MySQL (query por stdin para evitar escape de shell)
|
||||||
|
print("\nObteniendo K2 items de Joomla prod...")
|
||||||
|
# HEX encoding para campos de texto (evita que el HTML con saltos de línea
|
||||||
|
# rompa el parsing TSV)
|
||||||
|
query = (
|
||||||
|
f"SELECT id, HEX(title) title, HEX(alias) alias, "
|
||||||
|
f"HEX(introtext) introtext, HEX(`fulltext`) fulltext_col, "
|
||||||
|
f"created, created_by, HEX(extra_fields) extra_fields, publish_up "
|
||||||
|
f"FROM ew4r_k2_items "
|
||||||
|
f"WHERE published=1 AND id > {LAST_K2_ID} ORDER BY id;"
|
||||||
|
)
|
||||||
|
mysql_cmd = (
|
||||||
|
f"mysql --skip-ssl -h {JOOMLA_DB_HOST} -u {JOOMLA_DB_USER} "
|
||||||
|
f"-p'{JOOMLA_DB_PASS}' {JOOMLA_DB_NAME} "
|
||||||
|
f"--default-character-set=utf8mb4 -B"
|
||||||
|
)
|
||||||
|
cmd = [
|
||||||
|
'sshpass', '-p', JOOMLA_SSH_PASS,
|
||||||
|
'ssh', f'{JOOMLA_SSH_USER}@{JOOMLA_SSH_HOST}',
|
||||||
|
mysql_cmd
|
||||||
|
]
|
||||||
|
result = subprocess.run(cmd, input=query, capture_output=True, text=True, encoding='utf-8')
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"ERROR: {result.stderr[:500]}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
lines = result.stdout.strip().split('\n')
|
||||||
|
if len(lines) < 2:
|
||||||
|
print("No se encontraron items nuevos.")
|
||||||
|
return
|
||||||
|
|
||||||
|
headers = lines[0].split('\t')
|
||||||
|
items = []
|
||||||
|
for line in lines[1:]:
|
||||||
|
if line:
|
||||||
|
vals = line.split('\t')
|
||||||
|
items.append(dict(zip(headers, vals)))
|
||||||
|
|
||||||
|
print(f"Items a importar: {len(items)}")
|
||||||
|
|
||||||
|
stats = {'ok': 0, 'skip': 0, 'err': 0}
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
k2_id = int(item['id'])
|
||||||
|
|
||||||
|
if k2_id in existing_ids:
|
||||||
|
print(f" [SKIP] K2 id={k2_id} ya existe en WP")
|
||||||
|
stats['skip'] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
def unhex(val: str) -> str:
|
||||||
|
if not val or val == 'NULL':
|
||||||
|
return ''
|
||||||
|
try:
|
||||||
|
return bytes.fromhex(val).decode('utf-8', errors='replace')
|
||||||
|
except Exception:
|
||||||
|
return val
|
||||||
|
|
||||||
|
title = unhex(item.get('title', ''))
|
||||||
|
alias = unhex(item.get('alias', ''))
|
||||||
|
intro = unhex(item.get('introtext', ''))
|
||||||
|
full = unhex(item.get('fulltext_col', ''))
|
||||||
|
ef_json = unhex(item.get('extra_fields', '')) or '[]'
|
||||||
|
created = item.get('created', '') or datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
if not created or created == 'NULL':
|
||||||
|
created = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
created_by_raw = item.get('created_by', '0')
|
||||||
|
created_by = int(created_by_raw) if created_by_raw and created_by_raw != 'NULL' else 0
|
||||||
|
|
||||||
|
# Contenido combinado
|
||||||
|
if full and full.strip():
|
||||||
|
content = intro + '\n<!--more-->\n' + full
|
||||||
|
else:
|
||||||
|
content = intro
|
||||||
|
|
||||||
|
# Autor WP
|
||||||
|
wp_author = user_map.get(created_by, 1) # fallback: admin
|
||||||
|
if created_by and created_by not in user_map:
|
||||||
|
# Autor Joomla sin usuario WP equivalente: queda atribuido a «Fe Adulta».
|
||||||
|
# NO se pierde el dato: corregir tras el delta con scripts/fix_k2_authors.php,
|
||||||
|
# que crea el usuario (nombre real de ew4r_users) y reasigna post_author (#143).
|
||||||
|
print(f" ⚠ autor K2 {created_by} sin user WP → queda en 'Fe Adulta' "
|
||||||
|
f"(corregir con fix_k2_authors.php)")
|
||||||
|
|
||||||
|
# Extra fields
|
||||||
|
ef = parse_extra_fields(ef_json)
|
||||||
|
lang_code = LANG_MAP.get(ef.get('lang_val'), 'es')
|
||||||
|
cats = determine_categories(ef, title)
|
||||||
|
|
||||||
|
print(f" [{k2_id}] {title[:50]} | lang={lang_code} | cats={cats}")
|
||||||
|
|
||||||
|
# INSERT post
|
||||||
|
post_slug = esc(alias[:200]) if alias else ''
|
||||||
|
post_title = esc(title)
|
||||||
|
post_content = esc(content)
|
||||||
|
post_date = created
|
||||||
|
post_date_gmt = created # simplificado (no ajuste TZ)
|
||||||
|
|
||||||
|
insert_post = (
|
||||||
|
f"INSERT INTO wp_posts "
|
||||||
|
f"(post_author, post_date, post_date_gmt, post_content, post_title, "
|
||||||
|
f"post_excerpt, post_status, comment_status, ping_status, post_name, "
|
||||||
|
f"post_type, post_modified, post_modified_gmt, comment_count, "
|
||||||
|
f"to_ping, pinged, post_content_filtered) VALUES ("
|
||||||
|
f"{wp_author}, '{post_date}', '{post_date_gmt}', '{post_content}', "
|
||||||
|
f"'{post_title}', '', 'publish', 'open', 'open', '{post_slug}', "
|
||||||
|
f"'post', '{post_date}', '{post_date_gmt}', 0, '', '', '')"
|
||||||
|
)
|
||||||
|
wp_execute(insert_post)
|
||||||
|
|
||||||
|
if DRY_RUN:
|
||||||
|
stats['ok'] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Obtener el ID del post recién insertado. NO usar LAST_INSERT_ID(): cada
|
||||||
|
# docker exec abre una conexión nueva y devolvería 0. MAX(ID) es fiable
|
||||||
|
# en uso secuencial (sin inserciones concurrentes).
|
||||||
|
new_id_rows = wp_mysql("SELECT MAX(ID) as new_id FROM wp_posts")
|
||||||
|
if not new_id_rows:
|
||||||
|
print(f" [ERROR] No se pudo obtener el ID del post para k2_id={k2_id}")
|
||||||
|
stats['err'] += 1
|
||||||
|
continue
|
||||||
|
new_wp_id = int(new_id_rows[0]['new_id'])
|
||||||
|
print(f" → WP post ID={new_wp_id}")
|
||||||
|
|
||||||
|
# INSERT metas
|
||||||
|
metas = [
|
||||||
|
('_fgj2wp_old_k2_id', str(k2_id)),
|
||||||
|
('Idioma', str(ef.get('lang_val') or 1)),
|
||||||
|
]
|
||||||
|
for meta_key, meta_val in metas:
|
||||||
|
wp_execute(
|
||||||
|
f"INSERT INTO wp_postmeta (post_id, meta_key, meta_value) "
|
||||||
|
f"VALUES ({new_wp_id}, '{esc(meta_key)}', '{esc(meta_val)}')"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Categorías
|
||||||
|
for term_id in cats:
|
||||||
|
tt_id = tt_ids.get(term_id)
|
||||||
|
if tt_id:
|
||||||
|
wp_execute(
|
||||||
|
f"INSERT IGNORE INTO wp_term_relationships (object_id, term_taxonomy_id) "
|
||||||
|
f"VALUES ({new_wp_id}, {tt_id})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Polylang language
|
||||||
|
pl_tt = pl_ids.get(lang_code)
|
||||||
|
if pl_tt:
|
||||||
|
wp_execute(
|
||||||
|
f"INSERT IGNORE INTO wp_term_relationships (object_id, term_taxonomy_id) "
|
||||||
|
f"VALUES ({new_wp_id}, {pl_tt})"
|
||||||
|
)
|
||||||
|
|
||||||
|
stats['ok'] += 1
|
||||||
|
|
||||||
|
# Actualizar counts de categorías
|
||||||
|
if not DRY_RUN and stats['ok'] > 0:
|
||||||
|
print("\nActualizando counts de categorías...")
|
||||||
|
tt_ids_list = ','.join(str(v) for v in tt_ids.values())
|
||||||
|
wp_execute(
|
||||||
|
f"UPDATE wp_term_taxonomy tt SET count = ("
|
||||||
|
f"SELECT COUNT(*) FROM wp_term_relationships tr WHERE tr.term_taxonomy_id=tt.term_taxonomy_id"
|
||||||
|
f") WHERE tt.term_taxonomy_id IN ({tt_ids_list})"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"\n=== Resultado: {stats['ok']} ok, {stats['skip']} skip, {stats['err']} err ===")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Importa a WordPress local el delta visible en Joomla produccion usando HTML publico.
|
||||||
|
|
||||||
|
Ruta de contingencia para cuando no hay SSH/DB a produccion. Conserva los IDs
|
||||||
|
Joomla en `_fgj2wp_old_k2_id` y `_fgj2wp_old_content_id` extraidos de las URLs.
|
||||||
|
|
||||||
|
Por defecto es dry-run. Usar `--apply` para escribir en la BD local.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import html
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unicodedata
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import pymysql
|
||||||
|
|
||||||
|
|
||||||
|
ORIGIN_IP = "134.0.10.170"
|
||||||
|
HOST = "www.feadulta.com"
|
||||||
|
BASE = f"https://{HOST}"
|
||||||
|
WP_DB_USER = "wordpress_user"
|
||||||
|
WP_DB_PASS = "wordpress_pass"
|
||||||
|
WP_DB_NAME = "wordpress_db"
|
||||||
|
|
||||||
|
TERM_FEADULTA = 71
|
||||||
|
TERM_CARTA_SEMANA = 6
|
||||||
|
TERM_CARTAS_OTRAS = 21
|
||||||
|
TERM_CARTA_PASADA = 22
|
||||||
|
TERM_INDICE_MULTIMEDIA = 26
|
||||||
|
TERM_VIDEOS = 58
|
||||||
|
TERM_LECTURA = 1645
|
||||||
|
TERM_COMENTARIO_EDITORIAL = 1646
|
||||||
|
TERM_COMENTARIO = 1647
|
||||||
|
TERM_EUCARISTIA = 1648
|
||||||
|
TERM_MULTIMEDIA = 1649
|
||||||
|
TERM_ARTICULOS = 1650
|
||||||
|
|
||||||
|
SECTION_TO_TERM = {
|
||||||
|
"lectura": TERM_LECTURA,
|
||||||
|
"comentario_editorial": TERM_COMENTARIO_EDITORIAL,
|
||||||
|
"comentario": TERM_COMENTARIO,
|
||||||
|
"articulo": TERM_ARTICULOS,
|
||||||
|
"eucaristia": TERM_EUCARISTIA,
|
||||||
|
"multimedia": TERM_MULTIMEDIA,
|
||||||
|
}
|
||||||
|
|
||||||
|
CARTAS = [
|
||||||
|
{
|
||||||
|
"content_id": 9136,
|
||||||
|
"url": "/es/ayuda/otras-semanas/9136-uno-y-trino.html",
|
||||||
|
"date": "2026-05-28 00:00:00",
|
||||||
|
"cats": [TERM_CARTAS_OTRAS, TERM_FEADULTA],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content_id": 9143,
|
||||||
|
"url": "/es/ayuda/semana-pasada/9143-20-anos-de-fe-adulta.html",
|
||||||
|
"date": "2026-06-06 00:00:00",
|
||||||
|
"cats": [TERM_CARTAS_OTRAS, TERM_CARTA_PASADA, TERM_FEADULTA],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content_id": 9150,
|
||||||
|
"url": "/es/ayuda/esta-semana/9150-la-puerta-pequena.html",
|
||||||
|
"date": "2026-06-13 00:00:00",
|
||||||
|
"cats": [TERM_CARTA_SEMANA, TERM_CARTAS_OTRAS, TERM_FEADULTA],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Item:
|
||||||
|
kind: str
|
||||||
|
source_id: int
|
||||||
|
url: str
|
||||||
|
title: str = ""
|
||||||
|
content: str = ""
|
||||||
|
slug: str = ""
|
||||||
|
date: str = "2026-06-13 00:00:00"
|
||||||
|
author_name: Optional[str] = None
|
||||||
|
term_ids: set[int] = field(default_factory=set)
|
||||||
|
carta_source_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
def wp_ip() -> str:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"inspect",
|
||||||
|
"wordpress-mysql",
|
||||||
|
"--format",
|
||||||
|
"{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def conn():
|
||||||
|
return pymysql.connect(
|
||||||
|
host=wp_ip(),
|
||||||
|
user=WP_DB_USER,
|
||||||
|
password=WP_DB_PASS,
|
||||||
|
database=WP_DB_NAME,
|
||||||
|
charset="utf8mb4",
|
||||||
|
autocommit=False,
|
||||||
|
cursorclass=pymysql.cursors.DictCursor,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(text: str) -> str:
|
||||||
|
text = unicodedata.normalize("NFKD", text)
|
||||||
|
text = "".join(c for c in text if not unicodedata.combining(c))
|
||||||
|
return re.sub(r"\s+", " ", text).strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def slug_from_url(path: str) -> str:
|
||||||
|
name = path.rstrip("/").rsplit("/", 1)[-1]
|
||||||
|
name = name.split("?", 1)[0]
|
||||||
|
name = re.sub(r"^\d+-", "", name)
|
||||||
|
return re.sub(r"\.html$", "", name)
|
||||||
|
|
||||||
|
|
||||||
|
def id_from_url(path: str) -> Optional[int]:
|
||||||
|
m = re.search(r"/(\d+)-[^/?#]+(?:\.html)?", path)
|
||||||
|
return int(m.group(1)) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(path: str) -> str:
|
||||||
|
url = urljoin(BASE, path)
|
||||||
|
print(f"FETCH {path}", file=sys.stderr, flush=True)
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"curl",
|
||||||
|
"--resolve",
|
||||||
|
f"{HOST}:443:{ORIGIN_IP}",
|
||||||
|
"-k",
|
||||||
|
"-L",
|
||||||
|
"--max-time",
|
||||||
|
"12",
|
||||||
|
"-A",
|
||||||
|
"Mozilla/5.0 Codex Feadulta delta importer",
|
||||||
|
"-sS",
|
||||||
|
url,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def clean_fragment(fragment: str) -> str:
|
||||||
|
fragment = re.sub(r"<script\b.*?</script>", "", fragment, flags=re.I | re.S)
|
||||||
|
fragment = re.sub(r"<form\b.*?</form>", "", fragment, flags=re.I | re.S)
|
||||||
|
fragment = re.sub(r"\s+href=\"([^\"]*)\?tmpl=component[^\"]*\"", r' href="\1"', fragment)
|
||||||
|
fragment = fragment.replace("\r\n", "\n")
|
||||||
|
# Rutas de imagen Joomla -> uploads WP cuando el fichero existe localmente.
|
||||||
|
def repl(m):
|
||||||
|
attr, path = m.group(1), m.group(2)
|
||||||
|
local = f"/home/rafa/joomla-migration/wordpress/wp-content/uploads/{path}"
|
||||||
|
try:
|
||||||
|
exists = subprocess.run(["test", "-f", local]).returncode == 0
|
||||||
|
except Exception:
|
||||||
|
exists = False
|
||||||
|
if exists:
|
||||||
|
return f'{attr}="/fea/wp-content/uploads/{path}"'
|
||||||
|
return f'{attr}="/images/{path}"'
|
||||||
|
|
||||||
|
fragment = re.sub(r'(src|href)="/images/([^"]+)"', repl, fragment)
|
||||||
|
return fragment.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def extract_title_and_content(doc: str) -> tuple[str, str]:
|
||||||
|
title = ""
|
||||||
|
m = re.search(r'<h2 class="fa-postheader">\s*(.*?)\s*</h2>', doc, re.I | re.S)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'<h2 class="itemTitle">\s*(.*?)\s*</h2>', doc, re.I | re.S)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'<meta property="og:title" content="([^"]+)"', doc, re.I | re.S)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r"<title>\s*(.*?)\s*</title>", doc, re.I | re.S)
|
||||||
|
if m:
|
||||||
|
title = html.unescape(re.sub(r"<.*?>", "", m.group(1))).strip()
|
||||||
|
m = re.search(r'<div class="fa-article">\s*(.*?)\s*</div>\s*</div>\s*<div class="cleared"', doc, re.I | re.S)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'<div class="itemFullText">\s*(.*?)\s*</div>', doc, re.I | re.S)
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'<div class="fa-article">\s*(.*?)\s*</div>', doc, re.I | re.S)
|
||||||
|
content = clean_fragment(m.group(1)) if m else ""
|
||||||
|
return title, content
|
||||||
|
|
||||||
|
|
||||||
|
def extract_author(doc: str) -> Optional[str]:
|
||||||
|
m = re.search(r'<meta name="author" content="([^"]+)"', doc, re.I)
|
||||||
|
if m:
|
||||||
|
return html.unescape(m.group(1)).strip()
|
||||||
|
m = re.search(r'<a rel="author"[^>]*>\s*(.*?)\s*</a>', doc, re.I | re.S)
|
||||||
|
if m:
|
||||||
|
return html.unescape(re.sub(r"<.*?>", "", m.group(1))).strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def iter_paragraphs(content: str):
|
||||||
|
for m in re.finditer(r"<p\b[^>]*>(.*?)</p>", content, flags=re.I | re.S):
|
||||||
|
yield m.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def links_by_section(carta: Item) -> list[tuple[str, str, str, Optional[str]]]:
|
||||||
|
section = None
|
||||||
|
evangelio_pos = 0
|
||||||
|
out = []
|
||||||
|
for p in iter_paragraphs(carta.content):
|
||||||
|
plain = normalize(re.sub(r"<.*?>", " ", html.unescape(p)))
|
||||||
|
if "evangelio y comentarios al evangelio" in plain:
|
||||||
|
section = "evangelio"
|
||||||
|
evangelio_pos = 0
|
||||||
|
continue
|
||||||
|
if "articulos seleccionados para la semana" in plain:
|
||||||
|
section = "articulo"
|
||||||
|
continue
|
||||||
|
if "eucaristias mas participativas" in plain:
|
||||||
|
section = "eucaristia"
|
||||||
|
continue
|
||||||
|
if "material multimedia" in plain:
|
||||||
|
section = "multimedia"
|
||||||
|
continue
|
||||||
|
if not section:
|
||||||
|
continue
|
||||||
|
for href, text in re.findall(r'<a\b[^>]*href="([^"]+)"[^>]*>(.*?)</a>', p, flags=re.I | re.S):
|
||||||
|
href = html.unescape(href)
|
||||||
|
text_plain = html.unescape(re.sub(r"<.*?>", " ", text))
|
||||||
|
text_plain = re.sub(r"\s+", " ", text_plain).strip()
|
||||||
|
if section == "evangelio":
|
||||||
|
if evangelio_pos == 0:
|
||||||
|
cat = "lectura"
|
||||||
|
elif evangelio_pos == 1:
|
||||||
|
cat = "comentario_editorial"
|
||||||
|
else:
|
||||||
|
cat = "comentario"
|
||||||
|
evangelio_pos += 1
|
||||||
|
else:
|
||||||
|
cat = section
|
||||||
|
author = text_plain.split(":", 1)[0].strip() if ":" in text_plain else None
|
||||||
|
out.append((href, cat, text_plain, author))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def load_existing(c, meta_key: str) -> set[int]:
|
||||||
|
with c.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT CAST(meta_value AS UNSIGNED) id FROM wp_postmeta WHERE meta_key=%s",
|
||||||
|
(meta_key,),
|
||||||
|
)
|
||||||
|
return {int(r["id"]) for r in cur.fetchall() if r["id"] is not None}
|
||||||
|
|
||||||
|
|
||||||
|
def max_existing(ids: set[int]) -> int:
|
||||||
|
return max(ids) if ids else 0
|
||||||
|
|
||||||
|
|
||||||
|
def load_terms(c) -> dict[int, int]:
|
||||||
|
term_ids = [
|
||||||
|
TERM_FEADULTA,
|
||||||
|
TERM_CARTA_SEMANA,
|
||||||
|
TERM_CARTAS_OTRAS,
|
||||||
|
TERM_CARTA_PASADA,
|
||||||
|
TERM_INDICE_MULTIMEDIA,
|
||||||
|
TERM_VIDEOS,
|
||||||
|
TERM_LECTURA,
|
||||||
|
TERM_COMENTARIO_EDITORIAL,
|
||||||
|
TERM_COMENTARIO,
|
||||||
|
TERM_EUCARISTIA,
|
||||||
|
TERM_MULTIMEDIA,
|
||||||
|
TERM_ARTICULOS,
|
||||||
|
]
|
||||||
|
with c.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT term_id, term_taxonomy_id FROM wp_term_taxonomy "
|
||||||
|
"WHERE taxonomy='category' AND term_id IN (%s)" % ",".join(["%s"] * len(term_ids)),
|
||||||
|
term_ids,
|
||||||
|
)
|
||||||
|
return {int(r["term_id"]): int(r["term_taxonomy_id"]) for r in cur.fetchall()}
|
||||||
|
|
||||||
|
|
||||||
|
def load_lang_es(c) -> Optional[int]:
|
||||||
|
with c.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT tt.term_taxonomy_id FROM wp_terms t "
|
||||||
|
"JOIN wp_term_taxonomy tt ON tt.term_id=t.term_id "
|
||||||
|
"WHERE tt.taxonomy='language' AND t.slug='es' LIMIT 1"
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return int(row["term_taxonomy_id"]) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def load_authors(c) -> dict[str, int]:
|
||||||
|
with c.cursor() as cur:
|
||||||
|
cur.execute("SELECT ID, display_name, user_login FROM wp_users")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
authors = {}
|
||||||
|
for r in rows:
|
||||||
|
authors[normalize(r["display_name"])] = int(r["ID"])
|
||||||
|
authors[normalize(r["user_login"])] = int(r["ID"])
|
||||||
|
return authors
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_author(author_map: dict[str, int], name: Optional[str]) -> int:
|
||||||
|
if not name:
|
||||||
|
return 1
|
||||||
|
n = normalize(name)
|
||||||
|
if n in author_map:
|
||||||
|
return author_map[n]
|
||||||
|
for key, uid in author_map.items():
|
||||||
|
if n == key or n in key or key in n:
|
||||||
|
return uid
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def build_items(c) -> list[Item]:
|
||||||
|
existing_k2 = load_existing(c, "_fgj2wp_old_k2_id")
|
||||||
|
existing_content = load_existing(c, "_fgj2wp_old_content_id")
|
||||||
|
max_k2 = max_existing(existing_k2)
|
||||||
|
max_content = max_existing(existing_content)
|
||||||
|
print(
|
||||||
|
f"WP existentes: K2={len(existing_k2)} max={max_k2} "
|
||||||
|
f"content={len(existing_content)} max={max_content}"
|
||||||
|
)
|
||||||
|
|
||||||
|
items: dict[tuple[str, int], Item] = {}
|
||||||
|
for carta_def in CARTAS:
|
||||||
|
doc = fetch(carta_def["url"])
|
||||||
|
title, content = extract_title_and_content(doc)
|
||||||
|
carta = Item(
|
||||||
|
kind="content",
|
||||||
|
source_id=carta_def["content_id"],
|
||||||
|
url=carta_def["url"],
|
||||||
|
title=title,
|
||||||
|
content=content,
|
||||||
|
slug=slug_from_url(carta_def["url"]),
|
||||||
|
date=carta_def["date"],
|
||||||
|
term_ids=set(carta_def["cats"]),
|
||||||
|
)
|
||||||
|
if carta.source_id > max_content and carta.source_id not in existing_content:
|
||||||
|
items[(carta.kind, carta.source_id)] = carta
|
||||||
|
|
||||||
|
for href, cat_name, _text, author in links_by_section(carta):
|
||||||
|
if "/buscadoravanzado/item/" in href:
|
||||||
|
sid = id_from_url(href)
|
||||||
|
if not sid or sid <= max_k2 or sid in existing_k2:
|
||||||
|
continue
|
||||||
|
key = ("k2", sid)
|
||||||
|
item = items.get(key)
|
||||||
|
if not item:
|
||||||
|
item = Item(
|
||||||
|
kind="k2",
|
||||||
|
source_id=sid,
|
||||||
|
url=href,
|
||||||
|
slug=slug_from_url(href),
|
||||||
|
date=carta.date,
|
||||||
|
author_name=author,
|
||||||
|
term_ids={TERM_FEADULTA},
|
||||||
|
carta_source_id=carta.source_id,
|
||||||
|
)
|
||||||
|
items[key] = item
|
||||||
|
item.term_ids.add(SECTION_TO_TERM[cat_name])
|
||||||
|
elif "/indice-multimedia/" in href or "/videos/" in href:
|
||||||
|
sid = id_from_url(href)
|
||||||
|
if not sid or sid <= max_content or sid in existing_content:
|
||||||
|
continue
|
||||||
|
is_video = "/videos/" in href
|
||||||
|
key = ("content", sid)
|
||||||
|
item = items.get(key)
|
||||||
|
if not item:
|
||||||
|
item = Item(
|
||||||
|
kind="content",
|
||||||
|
source_id=sid,
|
||||||
|
url=href,
|
||||||
|
slug=slug_from_url(href),
|
||||||
|
date=carta.date,
|
||||||
|
term_ids={TERM_MULTIMEDIA, TERM_VIDEOS if is_video else TERM_INDICE_MULTIMEDIA},
|
||||||
|
)
|
||||||
|
items[key] = item
|
||||||
|
|
||||||
|
# Fetch item pages after discovery.
|
||||||
|
for item in items.values():
|
||||||
|
if item.title and item.content:
|
||||||
|
continue
|
||||||
|
doc = fetch(item.url)
|
||||||
|
title, content = extract_title_and_content(doc)
|
||||||
|
author = extract_author(doc)
|
||||||
|
item.title = title or item.slug.replace("-", " ").title()
|
||||||
|
item.content = content
|
||||||
|
if author and not item.author_name:
|
||||||
|
item.author_name = author
|
||||||
|
return sorted(items.values(), key=lambda x: (x.date, x.kind, x.source_id))
|
||||||
|
|
||||||
|
|
||||||
|
def insert_item(c, item: Item, term_to_tt: dict[int, int], lang_es_tt: Optional[int], author_map: dict[str, int], dry_run: bool) -> Optional[int]:
|
||||||
|
author_id = resolve_author(author_map, item.author_name)
|
||||||
|
if dry_run:
|
||||||
|
print(
|
||||||
|
f"[DRY] {item.kind:7s} {item.source_id:5d} "
|
||||||
|
f"terms={sorted(item.term_ids)} author={author_id} {item.title[:70]}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
with c.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO wp_posts
|
||||||
|
(post_author, post_date, post_date_gmt, post_content, post_title,
|
||||||
|
post_excerpt, post_status, comment_status, ping_status, post_name,
|
||||||
|
post_type, post_modified, post_modified_gmt, comment_count,
|
||||||
|
to_ping, pinged, post_content_filtered)
|
||||||
|
VALUES
|
||||||
|
(%s,%s,%s,%s,%s,'','publish','open','open',%s,
|
||||||
|
'post',%s,%s,0,'','','')
|
||||||
|
""",
|
||||||
|
(author_id, item.date, item.date, item.content, item.title, item.slug, item.date, item.date),
|
||||||
|
)
|
||||||
|
post_id = cur.lastrowid
|
||||||
|
meta_key = "_fgj2wp_old_k2_id" if item.kind == "k2" else "_fgj2wp_old_content_id"
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (%s,%s,%s)",
|
||||||
|
(post_id, meta_key, str(item.source_id)),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (%s,'Idioma','1')",
|
||||||
|
(post_id,),
|
||||||
|
)
|
||||||
|
for term_id in sorted(item.term_ids):
|
||||||
|
tt = term_to_tt.get(term_id)
|
||||||
|
if tt:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT IGNORE INTO wp_term_relationships (object_id, term_taxonomy_id) VALUES (%s,%s)",
|
||||||
|
(post_id, tt),
|
||||||
|
)
|
||||||
|
if lang_es_tt:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT IGNORE INTO wp_term_relationships (object_id, term_taxonomy_id) VALUES (%s,%s)",
|
||||||
|
(post_id, lang_es_tt),
|
||||||
|
)
|
||||||
|
return post_id
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_counts(c, term_to_tt: dict[int, int], lang_es_tt: Optional[int]):
|
||||||
|
ttids = list(term_to_tt.values())
|
||||||
|
if lang_es_tt:
|
||||||
|
ttids.append(lang_es_tt)
|
||||||
|
with c.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE wp_term_taxonomy tt SET count = ("
|
||||||
|
"SELECT COUNT(*) FROM wp_term_relationships tr "
|
||||||
|
"WHERE tr.term_taxonomy_id=tt.term_taxonomy_id"
|
||||||
|
") WHERE tt.term_taxonomy_id IN (%s)" % ",".join(["%s"] * len(ttids)),
|
||||||
|
ttids,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--apply", action="store_true", help="escribe en WordPress local")
|
||||||
|
args = ap.parse_args()
|
||||||
|
dry_run = not args.apply
|
||||||
|
|
||||||
|
c = conn()
|
||||||
|
try:
|
||||||
|
term_to_tt = load_terms(c)
|
||||||
|
lang_es_tt = load_lang_es(c)
|
||||||
|
author_map = load_authors(c)
|
||||||
|
items = build_items(c)
|
||||||
|
print(f"Items nuevos detectados: {len(items)}")
|
||||||
|
print(
|
||||||
|
" K2:",
|
||||||
|
len([i for i in items if i.kind == "k2"]),
|
||||||
|
"content:",
|
||||||
|
len([i for i in items if i.kind == "content"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
source_to_wp = {}
|
||||||
|
for item in items:
|
||||||
|
wp_id = insert_item(c, item, term_to_tt, lang_es_tt, author_map, dry_run)
|
||||||
|
if wp_id:
|
||||||
|
source_to_wp[(item.kind, item.source_id)] = wp_id
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
with c.cursor() as cur:
|
||||||
|
for item in items:
|
||||||
|
if item.kind != "k2" or not item.carta_source_id:
|
||||||
|
continue
|
||||||
|
wp_id = source_to_wp.get(("k2", item.source_id))
|
||||||
|
carta_wp_id = source_to_wp.get(("content", item.carta_source_id))
|
||||||
|
if wp_id and carta_wp_id:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT IGNORE INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (%s,'_carta_id',%s)",
|
||||||
|
(wp_id, str(carta_wp_id)),
|
||||||
|
)
|
||||||
|
refresh_counts(c, term_to_tt, lang_es_tt)
|
||||||
|
c.commit()
|
||||||
|
print("Import commit OK.")
|
||||||
|
else:
|
||||||
|
c.rollback()
|
||||||
|
print("Dry-run: sin cambios.")
|
||||||
|
except Exception:
|
||||||
|
c.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
lecturas_apply.py — Casa las lecturas ES sin traducir contra el índice del leccionario
|
||||||
|
(build_lectionary_index.py) POR REFERENCIA bíblica y vuelca las traducciones a crear.
|
||||||
|
|
||||||
|
Entrada: /tmp/lectionary_index.json , /tmp/lecturas_todo.json
|
||||||
|
Salida: /tmp/lecturas_creadas.json (para que un wp eval cree+asocie+publique)
|
||||||
|
/tmp/lecturas_skip.json
|
||||||
|
|
||||||
|
Uso: python3 lecturas_apply.py [--limit N]
|
||||||
|
"""
|
||||||
|
import sys, re, json, unicodedata
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
# Alias de nombre de libro: feadulta -> token usado por evangelizo (último token full_title ES)
|
||||||
|
ALIAS = {
|
||||||
|
"HECHOS": "APOSTOLES", "HCH": "APOSTOLES",
|
||||||
|
"CANTAR": "CANTARES",
|
||||||
|
"APOC": "APOCALIPSIS", "AP": "APOCALIPSIS",
|
||||||
|
"QOHELET": "ECLESIASTES",
|
||||||
|
# abreviaturas litúrgicas
|
||||||
|
"MT": "MATEO", "MC": "MARCOS", "LC": "LUCAS", "JN": "JUAN",
|
||||||
|
"RM": "ROMANOS", "GA": "GALATAS", "EF": "EFESIOS", "FLP": "FILIPENSES",
|
||||||
|
"COL": "COLOSENSES", "HB": "HEBREOS", "ST": "SANTIAGO",
|
||||||
|
"IS": "ISAIAS", "JR": "JEREMIAS", "EZ": "EZEQUIEL", "GN": "GENESIS",
|
||||||
|
"EX": "EXODO", "DT": "DEUTERONOMIO", "SAL": "SALMOS", "PR": "PROVERBIOS",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def norm(s):
|
||||||
|
s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode().upper()
|
||||||
|
return re.sub(r"[^A-Z]", "", s) # solo letras → descarta el número del libro
|
||||||
|
|
||||||
|
|
||||||
|
def title_keys(title):
|
||||||
|
keys = []
|
||||||
|
for part in re.split(r"\s*/\s*", title):
|
||||||
|
m = re.search(r"([0-9]?\s*[A-Za-zÀ-ÿ][A-Za-zÀ-ÿ.\s]+?)\s+(\d{1,3})\s*,\s*(\d{1,3})", part)
|
||||||
|
if not m:
|
||||||
|
return None # parte no parseable → no casar el post entero
|
||||||
|
book = norm(m.group(1))
|
||||||
|
book = ALIAS.get(book, book)
|
||||||
|
keys.append(f"{book}|{int(m.group(2))}|{int(m.group(3))}")
|
||||||
|
return keys or None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
limit = 0
|
||||||
|
if "--limit" in sys.argv:
|
||||||
|
limit = int(sys.argv[sys.argv.index("--limit") + 1])
|
||||||
|
idx = json.load(open("/tmp/lectionary_index.json"))
|
||||||
|
todo = json.load(open("/tmp/lecturas_todo.json"))
|
||||||
|
if limit:
|
||||||
|
todo = todo[:limit]
|
||||||
|
|
||||||
|
creadas, skip = [], []
|
||||||
|
for t in todo:
|
||||||
|
keys = title_keys(t["title"])
|
||||||
|
if not keys:
|
||||||
|
skip.append({**t, "why": "título no parseable"})
|
||||||
|
continue
|
||||||
|
if not all(k in idx for k in keys):
|
||||||
|
missing = [k for k in keys if k not in idx]
|
||||||
|
skip.append({**t, "why": "ref no en índice", "missing": missing})
|
||||||
|
continue
|
||||||
|
langs = {}
|
||||||
|
for wl in ("en", "fr", "it", "pt"):
|
||||||
|
langs[wl] = "".join(idx[k][wl] for k in keys)
|
||||||
|
creadas.append({"es_id": t["id"], "title": t["title"], "langs": langs})
|
||||||
|
|
||||||
|
json.dump(creadas, open("/tmp/lecturas_creadas.json", "w"), ensure_ascii=False)
|
||||||
|
json.dump(skip, open("/tmp/lecturas_skip.json", "w"), ensure_ascii=False)
|
||||||
|
print(f"CASADAS: {len(creadas)} / {len(todo)} SKIP: {len(skip)}")
|
||||||
|
print("motivos skip:", dict(Counter(s["why"] for s in skip)))
|
||||||
|
# muestra de refs que faltan (para ampliar alias/rango)
|
||||||
|
missing = Counter()
|
||||||
|
for s in skip:
|
||||||
|
for k in s.get("missing", []):
|
||||||
|
missing[k.split("|")[0]] += 1
|
||||||
|
print("libros con más misses:", dict(missing.most_common(12)))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""TTS con MiniMax (clonación de voz + síntesis de calidad). Issue #76.
|
||||||
|
|
||||||
|
Credenciales en /home/rafa/Feadulta/minimax.txt:
|
||||||
|
- la API key (línea que empieza por 'sk-api-')
|
||||||
|
- el GroupId (línea 'GroupId=...' o 'group_id ...' o un número suelto)
|
||||||
|
|
||||||
|
Subcomandos:
|
||||||
|
clone <audio.wav> <voice_id> sube y clona (voice_id: >=8 chars, letras+números)
|
||||||
|
carta <post_id> <voice_id> [model] [nombre] locuta una carta entera
|
||||||
|
text "<texto>" <voice_id> [model] [nombre] locuta texto suelto
|
||||||
|
models: speech-2.8-turbo (barato) | speech-2.8-hd (calidad)
|
||||||
|
"""
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
CRED = "/home/rafa/Feadulta/minimax.txt"
|
||||||
|
BASE = "https://api.minimax.io/v1"
|
||||||
|
OUT = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts-samples"
|
||||||
|
CONTAINER = "wordpress-web"
|
||||||
|
|
||||||
|
|
||||||
|
def creds():
|
||||||
|
key = gid = None
|
||||||
|
for ln in open(CRED):
|
||||||
|
ln = ln.strip()
|
||||||
|
if not ln:
|
||||||
|
continue
|
||||||
|
if ln.startswith("sk-"):
|
||||||
|
key = ln # coge la última key del fichero (la más reciente)
|
||||||
|
elif "groupid" in ln.lower() or "group_id" in ln.lower():
|
||||||
|
gid = re.split(r"[=:\s]+", ln, 1)[1].strip()
|
||||||
|
elif ln.isdigit():
|
||||||
|
gid = ln
|
||||||
|
return key, gid
|
||||||
|
|
||||||
|
|
||||||
|
KEY, GID = creds()
|
||||||
|
H_JSON = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def _q(url):
|
||||||
|
return f"{url}?GroupId={GID}" if GID else url
|
||||||
|
|
||||||
|
|
||||||
|
def upload(path, purpose="voice_clone"):
|
||||||
|
r = requests.post(_q(f"{BASE}/files/upload"),
|
||||||
|
headers={"Authorization": f"Bearer {KEY}"},
|
||||||
|
data={"purpose": purpose},
|
||||||
|
files={"file": open(path, "rb")})
|
||||||
|
j = r.json()
|
||||||
|
fid = (j.get("file") or {}).get("file_id")
|
||||||
|
if not fid:
|
||||||
|
sys.exit(f"upload falló: {json.dumps(j)[:400]}")
|
||||||
|
print(f" file_id={fid}")
|
||||||
|
return fid
|
||||||
|
|
||||||
|
|
||||||
|
def clone(audio, voice_id):
|
||||||
|
print(f"Subiendo {audio}…", flush=True)
|
||||||
|
fid = upload(audio, "voice_clone")
|
||||||
|
print(f"Clonando como voice_id={voice_id}…", flush=True)
|
||||||
|
r = requests.post(_q(f"{BASE}/voice_clone"), headers=H_JSON,
|
||||||
|
json={"file_id": fid, "voice_id": voice_id, "model": "speech-2.8-hd"})
|
||||||
|
print(json.dumps(r.json(), ensure_ascii=False)[:500])
|
||||||
|
|
||||||
|
|
||||||
|
def get_post_text(pid):
|
||||||
|
subprocess.run(["docker", "exec", CONTAINER, "php", "/tmp/fea_post_io.php", "get", str(pid)],
|
||||||
|
check=True, capture_output=True)
|
||||||
|
subprocess.run(["docker", "cp", f"{CONTAINER}:/tmp/fea_es.json", "/tmp/fea_es.json"], check=True)
|
||||||
|
d = json.load(open("/tmp/fea_es.json"))
|
||||||
|
raw = re.sub(r"(?i)</p>|<br\s*/?>|</h[1-6]>", "\n", d["content"])
|
||||||
|
raw = re.sub(r"<[^>]+>", "", raw)
|
||||||
|
raw = re.sub(r"\[[^\]]+\]", "", raw)
|
||||||
|
raw = html.unescape(raw)
|
||||||
|
paras = [re.sub(r"\s+", " ", p).strip() for p in raw.split("\n") if len(p.strip()) > 1]
|
||||||
|
paras = trim_after_author_signature(paras)
|
||||||
|
return d["title"], "\n\n".join(paras)
|
||||||
|
|
||||||
|
|
||||||
|
def is_author_signature(text):
|
||||||
|
"""Heurística simple para detectar la firma final del autor.
|
||||||
|
|
||||||
|
Queremos conservar la línea del nombre y cortar todo lo que venga detrás
|
||||||
|
(URLs, notas, anexos o bloques extra), pero sin confundirla con títulos
|
||||||
|
internos del artículo.
|
||||||
|
"""
|
||||||
|
text = text.strip()
|
||||||
|
if not text or len(text) > 80 or any(ch.isdigit() for ch in text):
|
||||||
|
return False
|
||||||
|
if any(mark in text for mark in [":", ";", "http", "www.", "@"]):
|
||||||
|
return False
|
||||||
|
words = text.split()
|
||||||
|
if len(words) < 2 or len(words) > 6:
|
||||||
|
return False
|
||||||
|
allowed_lower = {"de", "del", "la", "las", "los", "y", "e"}
|
||||||
|
for word in words:
|
||||||
|
clean = re.sub(r"[^\wÁÉÍÓÚÜÑáéíóúüñ-]", "", word)
|
||||||
|
if not clean:
|
||||||
|
return False
|
||||||
|
if clean.lower() in allowed_lower:
|
||||||
|
continue
|
||||||
|
if not clean[0].isupper():
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def trim_after_author_signature(paras):
|
||||||
|
out = []
|
||||||
|
for p in paras:
|
||||||
|
out.append(p)
|
||||||
|
if is_author_signature(p):
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _sent_pause(n_words, short, long_):
|
||||||
|
"""Pausa (s) tras un punto, proporcional a la longitud de la frase que cierra:
|
||||||
|
frase corta → pausa corta; frase larga → el narrador 'respira' más."""
|
||||||
|
if n_words < short:
|
||||||
|
return os.environ.get("FEA_PAUSE_SHORT", "0.1")
|
||||||
|
if n_words <= long_:
|
||||||
|
return os.environ.get("FEA_PAUSE_MID", "0.2")
|
||||||
|
return os.environ.get("FEA_PAUSE_LONG", "0.3")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_terminal_punctuation(block):
|
||||||
|
"""Cierra con punto los bloques sin puntuación final.
|
||||||
|
|
||||||
|
MiniMax deja la entonación abierta cuando un título/párrafo termina "en seco".
|
||||||
|
Si el bloque ya acaba en . ! ? … : ;, se respeta.
|
||||||
|
"""
|
||||||
|
block = block.strip()
|
||||||
|
if not block:
|
||||||
|
return ""
|
||||||
|
if block[-1] not in ".!?…:;":
|
||||||
|
return block + "."
|
||||||
|
return block
|
||||||
|
|
||||||
|
|
||||||
|
def expand_bible_abbreviations(text):
|
||||||
|
"""Expande abreviaturas bíblicas cuando aparecen con forma de cita.
|
||||||
|
|
||||||
|
Ejemplos:
|
||||||
|
- Mt 5, 1-12 -> Mateo 5, 1-12
|
||||||
|
- Lc 2, 10 -> Lucas 2, 10
|
||||||
|
- Jn 3, 16 -> Juan 3, 16
|
||||||
|
- Mc 1, 14 -> Marcos 1, 14
|
||||||
|
|
||||||
|
Se limita a abreviaturas seguidas de capítulo/versículo para no tocar usos
|
||||||
|
no bíblicos de esas siglas dentro del texto.
|
||||||
|
"""
|
||||||
|
books = [
|
||||||
|
("1Cor", "Primera carta a los Corintios"),
|
||||||
|
("2Cor", "Segunda carta a los Corintios"),
|
||||||
|
("1Tes", "Primera carta a los Tesalonicenses"),
|
||||||
|
("2Tes", "Segunda carta a los Tesalonicenses"),
|
||||||
|
("1Tim", "Primera carta a Timoteo"),
|
||||||
|
("2Tim", "Segunda carta a Timoteo"),
|
||||||
|
("1Pe", "Primera carta de Pedro"),
|
||||||
|
("2Pe", "Segunda carta de Pedro"),
|
||||||
|
("1Jn", "Primera carta de Juan"),
|
||||||
|
("2Jn", "Segunda carta de Juan"),
|
||||||
|
("3Jn", "Tercera carta de Juan"),
|
||||||
|
("1Mac", "Primer libro de los Macabeos"),
|
||||||
|
("2Mac", "Segundo libro de los Macabeos"),
|
||||||
|
("1Sam", "Primer libro de Samuel"),
|
||||||
|
("2Sam", "Segundo libro de Samuel"),
|
||||||
|
("1Sm", "Primer libro de Samuel"),
|
||||||
|
("2Sm", "Segundo libro de Samuel"),
|
||||||
|
("1Re", "Primer libro de los Reyes"),
|
||||||
|
("2Re", "Segundo libro de los Reyes"),
|
||||||
|
("1Cr", "Primer libro de las Crónicas"),
|
||||||
|
("2Cr", "Segundo libro de las Crónicas"),
|
||||||
|
("Hch", "Hechos de los Apóstoles"),
|
||||||
|
("Rom", "Romanos"),
|
||||||
|
("Rm", "Romanos"),
|
||||||
|
("Gal", "Gálatas"),
|
||||||
|
("Gál", "Gálatas"),
|
||||||
|
("Ef", "Efesios"),
|
||||||
|
("Flp", "Filipenses"),
|
||||||
|
("Fil", "Filipenses"),
|
||||||
|
("Col", "Colosenses"),
|
||||||
|
("Tit", "Tito"),
|
||||||
|
("Flm", "Filemón"),
|
||||||
|
("Heb", "Hebreos"),
|
||||||
|
("Sant", "Santiago"),
|
||||||
|
("St", "Santiago"),
|
||||||
|
("Sto", "Santiago"),
|
||||||
|
("Jud", "Judas"),
|
||||||
|
("Ap", "Apocalipsis"),
|
||||||
|
("Mt", "Mateo"),
|
||||||
|
("Mc", "Marcos"),
|
||||||
|
("Lc", "Lucas"),
|
||||||
|
("Jn", "Juan"),
|
||||||
|
("Gn", "Génesis"),
|
||||||
|
("Gen", "Génesis"),
|
||||||
|
("Ex", "Éxodo"),
|
||||||
|
("Lv", "Levítico"),
|
||||||
|
("Lev", "Levítico"),
|
||||||
|
("Nm", "Números"),
|
||||||
|
("Num", "Números"),
|
||||||
|
("Dt", "Deuteronomio"),
|
||||||
|
("Jos", "Josué"),
|
||||||
|
("Jue", "Jueces"),
|
||||||
|
("Rut", "Rut"),
|
||||||
|
("Esd", "Esdras"),
|
||||||
|
("Neh", "Nehemías"),
|
||||||
|
("Tob", "Tobías"),
|
||||||
|
("Jdt", "Judit"),
|
||||||
|
("Est", "Ester"),
|
||||||
|
("Job", "Job"),
|
||||||
|
("Sal", "Salmos"),
|
||||||
|
("Prov", "Proverbios"),
|
||||||
|
("Cant", "Cantar de los Cantares"),
|
||||||
|
("Sab", "Sabiduría"),
|
||||||
|
("Eclo", "Eclesiástico"),
|
||||||
|
("Sir", "Eclesiástico"),
|
||||||
|
("Ecl", "Eclesiástico"),
|
||||||
|
("Isa", "Isaías"),
|
||||||
|
("Is", "Isaías"),
|
||||||
|
("Jer", "Jeremías"),
|
||||||
|
("Jr", "Jeremías"),
|
||||||
|
("Lam", "Lamentaciones"),
|
||||||
|
("Bar", "Baruc"),
|
||||||
|
("Eze", "Ezequiel"),
|
||||||
|
("Ez", "Ezequiel"),
|
||||||
|
("Dan", "Daniel"),
|
||||||
|
("Dn", "Daniel"),
|
||||||
|
("Os", "Oseas"),
|
||||||
|
("Joel", "Joel"),
|
||||||
|
("Am", "Amós"),
|
||||||
|
("Abd", "Abdías"),
|
||||||
|
("Jon", "Jonás"),
|
||||||
|
("Miq", "Miqueas"),
|
||||||
|
("Nah", "Nahúm"),
|
||||||
|
("Hab", "Habacuc"),
|
||||||
|
("Sof", "Sofonías"),
|
||||||
|
("Ag", "Ageo"),
|
||||||
|
("Zac", "Zacarías"),
|
||||||
|
("Mal", "Malaquías"),
|
||||||
|
]
|
||||||
|
for short, full in books:
|
||||||
|
text = re.sub(
|
||||||
|
rf"\b{short}\.?(?=\s+\d)",
|
||||||
|
full,
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
text = re.sub(r"\b1\s+Co\.?(?=\s+\d)", "Primera carta a los Corintios", text)
|
||||||
|
text = re.sub(r"\b2\s+Co\.?(?=\s+\d)", "Segunda carta a los Corintios", text)
|
||||||
|
text = re.sub(r"\b1\s+Ts\.?(?=\s+\d)", "Primera carta a los Tesalonicenses", text)
|
||||||
|
text = re.sub(r"\b2\s+Ts\.?(?=\s+\d)", "Segunda carta a los Tesalonicenses", text)
|
||||||
|
text = re.sub(r"\b1\s+P\.?(?=\s+\d)", "Primera carta de Pedro", text)
|
||||||
|
text = re.sub(r"\b2\s+P\.?(?=\s+\d)", "Segunda carta de Pedro", text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def add_pauses(text, para=None):
|
||||||
|
"""Pausas MiniMax <#seg#> DINÁMICAS por longitud de frase + cierre de párrafos.
|
||||||
|
- Tras cada fin de frase (.!?…): pausa según nº de palabras de esa frase
|
||||||
|
(<short=0.1s, <=long=0.2s, >long=0.3s; umbrales por palabras).
|
||||||
|
- A los párrafos/títulos sin puntuación final se les añade un punto, para que
|
||||||
|
MiniMax cierre bien la entonación (si no, deja el tono abierto)."""
|
||||||
|
para = para if para is not None else os.environ.get("FEA_PARA_PAUSE", "0.7")
|
||||||
|
short = int(os.environ.get("FEA_SHORT_WORDS", "6"))
|
||||||
|
long_ = int(os.environ.get("FEA_LONG_WORDS", "12"))
|
||||||
|
text = expand_bible_abbreviations(text)
|
||||||
|
out = []
|
||||||
|
for p in text.split("\n\n"):
|
||||||
|
p = ensure_terminal_punctuation(p)
|
||||||
|
if not p:
|
||||||
|
continue
|
||||||
|
# Reconstruir insertando pausa proporcional tras cada signo de fin de frase.
|
||||||
|
parts = re.split(r"([.!?…]+)", p)
|
||||||
|
rebuilt = ""
|
||||||
|
for i in range(0, len(parts), 2):
|
||||||
|
frase = parts[i]
|
||||||
|
sign = parts[i + 1] if i + 1 < len(parts) else ""
|
||||||
|
rebuilt += frase + sign
|
||||||
|
if sign and frase.strip():
|
||||||
|
rebuilt += f" <#{_sent_pause(len(frase.split()), short, long_)}#> "
|
||||||
|
# Quitar la pausa de frase final: el separador de párrafo ya aporta la suya.
|
||||||
|
rebuilt = re.sub(r"\s*<#[\d.]+#>\s*$", "", rebuilt)
|
||||||
|
out.append(rebuilt.strip())
|
||||||
|
return f" <#{para}#> ".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
# MiniMax limita a 10.000 car por petición; dejamos margen porque las pausas
|
||||||
|
# <#seg#> y el language_boost también cuentan.
|
||||||
|
CHAR_LIMIT = 8000
|
||||||
|
|
||||||
|
|
||||||
|
def _split_for_tts(text, limit=CHAR_LIMIT):
|
||||||
|
"""Trocea respetando las pausas <#..#> (frase/párrafo). Fallback por palabras
|
||||||
|
si una frase suelta supera el límite."""
|
||||||
|
if len(text) <= limit:
|
||||||
|
return [text]
|
||||||
|
parts = re.split(r"(\s*<#[\d.]+#>\s*)", text)
|
||||||
|
chunks, cur = [], ""
|
||||||
|
for seg in parts:
|
||||||
|
if not seg:
|
||||||
|
continue
|
||||||
|
if len(cur) + len(seg) <= limit:
|
||||||
|
cur += seg
|
||||||
|
continue
|
||||||
|
if cur.strip():
|
||||||
|
chunks.append(cur.strip())
|
||||||
|
if len(seg) > limit: # frase gigantesca: parte por palabras
|
||||||
|
cur = ""
|
||||||
|
for w in seg.split(" "):
|
||||||
|
if len(cur) + len(w) + 1 <= limit:
|
||||||
|
cur += (" " if cur else "") + w
|
||||||
|
else:
|
||||||
|
if cur:
|
||||||
|
chunks.append(cur)
|
||||||
|
cur = w
|
||||||
|
else:
|
||||||
|
cur = seg
|
||||||
|
if cur.strip():
|
||||||
|
chunks.append(cur.strip())
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def _synth_chunk(text, voice_id, model):
|
||||||
|
"""Una petición t2a. Devuelve (audio_bytes|None, rc, usage_chars)."""
|
||||||
|
body = {
|
||||||
|
"model": model,
|
||||||
|
"text": text,
|
||||||
|
"voice_setting": {"voice_id": voice_id, "speed": 1.0, "vol": 1.0, "pitch": 0},
|
||||||
|
"audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "mp3", "channel": 1},
|
||||||
|
"language_boost": "Spanish",
|
||||||
|
}
|
||||||
|
r = requests.post(f"{BASE}/t2a_v2", headers=H_JSON, json=body)
|
||||||
|
j = r.json()
|
||||||
|
audio_hex = (j.get("data") or {}).get("audio")
|
||||||
|
if not audio_hex:
|
||||||
|
rc = (j.get("base_resp") or {}).get("status_code")
|
||||||
|
print(f"t2a falló: {json.dumps(j, ensure_ascii=False)[:300]}")
|
||||||
|
return None, rc, 0
|
||||||
|
usage = (j.get("extra_info") or {}).get("usage_characters", 0)
|
||||||
|
return bytes.fromhex(audio_hex), 0, usage
|
||||||
|
|
||||||
|
|
||||||
|
def t2a(text, voice_id, model, name):
|
||||||
|
chunks = _split_for_tts(text)
|
||||||
|
print(f"Sintetizando {len(text)} car con {model} / {voice_id} "
|
||||||
|
f"({len(chunks)} petición/es)…", flush=True)
|
||||||
|
raw = OUT / f"{name}.raw.mp3"
|
||||||
|
if len(chunks) == 1:
|
||||||
|
audio, rc, _ = _synth_chunk(chunks[0], voice_id, model)
|
||||||
|
if audio is None:
|
||||||
|
return rc
|
||||||
|
raw.write_bytes(audio)
|
||||||
|
else:
|
||||||
|
parts = []
|
||||||
|
for k, ch in enumerate(chunks):
|
||||||
|
if k > 0:
|
||||||
|
import os as _os, time as _t
|
||||||
|
_t.sleep(int(_os.environ.get("FEA_CHUNK_PAUSE", "35"))) # respetar TPM de MiniMax
|
||||||
|
print(f" trozo {k + 1}/{len(chunks)} ({len(ch)} car)…", flush=True)
|
||||||
|
audio, rc, _ = _synth_chunk(ch, voice_id, model)
|
||||||
|
if audio is None:
|
||||||
|
for p in parts:
|
||||||
|
p.unlink(missing_ok=True)
|
||||||
|
return rc
|
||||||
|
p = OUT / f"{name}.part{k}.mp3"
|
||||||
|
p.write_bytes(audio)
|
||||||
|
parts.append(p)
|
||||||
|
import subprocess as sp0
|
||||||
|
args = ["ffmpeg", "-y"]
|
||||||
|
for p in parts:
|
||||||
|
args += ["-i", str(p)]
|
||||||
|
n = len(parts)
|
||||||
|
filt = "".join(f"[{k}:a]" for k in range(n)) + f"concat=n={n}:v=0:a=1[a]"
|
||||||
|
args += ["-filter_complex", filt, "-map", "[a]", "-b:a", "128k", str(raw)]
|
||||||
|
sp0.run(args, capture_output=True)
|
||||||
|
for p in parts:
|
||||||
|
p.unlink(missing_ok=True)
|
||||||
|
# Acabado: comfort noise marrón + fade in/out (quita el "bump" final).
|
||||||
|
import subprocess as sp
|
||||||
|
dur = float(sp.run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
||||||
|
"-of", "default=noprint_wrappers=1:nokey=1", str(raw)],
|
||||||
|
capture_output=True, text=True).stdout.strip() or "0")
|
||||||
|
st = max(0.0, dur - 0.5)
|
||||||
|
mp3 = OUT / f"{name}.mp3"
|
||||||
|
sp.run(["ffmpeg", "-y", "-i", str(raw), "-filter_complex",
|
||||||
|
"anoisesrc=color=brown:amplitude=0.004:sample_rate=32000[n];"
|
||||||
|
"[n]highpass=f=120,lowpass=f=3800[nf];"
|
||||||
|
"[0:a][nf]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[m];"
|
||||||
|
f"[m]afade=t=in:st=0:d=0.08,afade=t=out:st={st:.2f}:d=0.5[a]",
|
||||||
|
"-map", "[a]", "-b:a", "128k", str(mp3)], capture_output=True)
|
||||||
|
raw.unlink(missing_ok=True)
|
||||||
|
print(f"OK -> {mp3} ({dur:.0f}s)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
sys.exit(__doc__)
|
||||||
|
cmd = sys.argv[1]
|
||||||
|
if not KEY:
|
||||||
|
sys.exit("No encuentro la API key en " + CRED)
|
||||||
|
if cmd == "clone":
|
||||||
|
clone(sys.argv[2], sys.argv[3])
|
||||||
|
elif cmd == "carta":
|
||||||
|
pid, voice_id = sys.argv[2], sys.argv[3]
|
||||||
|
model = sys.argv[4] if len(sys.argv) > 4 else "speech-2.8-turbo"
|
||||||
|
title, text = get_post_text(int(pid))
|
||||||
|
name = sys.argv[5] if len(sys.argv) > 5 else f"carta-minimax-{pid}-{model.split('-')[-1]}"
|
||||||
|
text = add_pauses(text)
|
||||||
|
print(f"Post #{pid}: «{title}» ({len(text)} car con pausas)")
|
||||||
|
t2a(text, voice_id, model, name)
|
||||||
|
elif cmd == "text":
|
||||||
|
model = sys.argv[4] if len(sys.argv) > 4 else "speech-2.8-turbo"
|
||||||
|
name = sys.argv[5] if len(sys.argv) > 5 else "minimax-text"
|
||||||
|
t2a(sys.argv[2], sys.argv[3], model, name)
|
||||||
|
else:
|
||||||
|
sys.exit(__doc__)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Pre-traduce a EN con Haiku los posts del gap que Gemma AÚN no ha alcanzado.
|
||||||
|
|
||||||
|
Crea el post EN + enlace Polylang (reutiliza fea_translate_helper.php, igual que
|
||||||
|
Gemma) ANTES de que Gemma llegue. Cuando Gemma llega, ve la traducción EN ya
|
||||||
|
enlazada en Polylang y la salta (translate_post.py:233), haciendo solo FR/IT/PT.
|
||||||
|
Así el EN se hace UNA vez y bien, sin el reprocesado posterior.
|
||||||
|
|
||||||
|
Coordinación: recorre los posts en el MISMO orden que Gemma, localiza por dónde
|
||||||
|
va (último :en en el state) y arranca `--margin` posts por delante para no
|
||||||
|
colisionar con el que Gemma está procesando ahora. Haiku (API) es mucho más
|
||||||
|
rápido que Gemma local, así que se aleja y nunca la alcanza.
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
pretranslate_en_haiku.py # PLAN: muestra arranque y pendientes
|
||||||
|
pretranslate_en_haiku.py --apply # crea los EN
|
||||||
|
opciones: --margin N (def 2), --limit N
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, HERE)
|
||||||
|
|
||||||
|
import translate_post as tp # read_post, translation_exists, create_translation, carta_article_ids
|
||||||
|
from translate_haiku import translate # Haiku
|
||||||
|
|
||||||
|
# Mismo orden que translate_gap.sh
|
||||||
|
CARTAS = "45018 44997 44975 44230 44229 44228 44090 44089 44088 44087 44086 44085 44084 44083 42590".split()
|
||||||
|
|
||||||
|
|
||||||
|
def read_state():
|
||||||
|
"""Lee el state de Gemma con reintentos (lo reescribe en vivo)."""
|
||||||
|
for _ in range(6):
|
||||||
|
try:
|
||||||
|
d = json.loads(open(tp.STATE_FILE).read())
|
||||||
|
if d.get("done"):
|
||||||
|
return d
|
||||||
|
except (json.JSONDecodeError, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
time.sleep(0.5)
|
||||||
|
sys.exit("No pude leer el state de Gemma con contenido; aborto por seguridad.")
|
||||||
|
|
||||||
|
|
||||||
|
def build_order():
|
||||||
|
"""Lista global de post_ids en el orden exacto en que Gemma los procesa."""
|
||||||
|
g = []
|
||||||
|
for c in CARTAS:
|
||||||
|
g.append(int(c))
|
||||||
|
g.extend(tp.carta_article_ids(int(c)))
|
||||||
|
return g
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--apply", action="store_true")
|
||||||
|
ap.add_argument("--margin", type=int, default=2)
|
||||||
|
ap.add_argument("--limit", type=int, default=0)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
state = read_state()
|
||||||
|
done = state["done"]
|
||||||
|
order = build_order()
|
||||||
|
|
||||||
|
# Frente de Gemma = último índice con su :en ya hecho.
|
||||||
|
front = -1
|
||||||
|
for i, pid in enumerate(order):
|
||||||
|
if f"{pid}:en" in done:
|
||||||
|
front = i
|
||||||
|
if front < 0:
|
||||||
|
sys.exit("No encuentro el frente de Gemma en la lista; aborto.")
|
||||||
|
|
||||||
|
start = front + 1 + args.margin
|
||||||
|
work = order[start:]
|
||||||
|
if args.limit:
|
||||||
|
work = work[:args.limit]
|
||||||
|
|
||||||
|
cur = order[front]
|
||||||
|
print(f"Gemma va por #{cur} (índice {front}/{len(order)-1}).")
|
||||||
|
print(f"Margen {args.margin} → arranco en índice {start} (#{order[start] if start < len(order) else '—'}).")
|
||||||
|
print(f"Posts pendientes a pre-traducir: {len(work)}")
|
||||||
|
if work:
|
||||||
|
print(f" primeros: {work[:5]}")
|
||||||
|
print(f" últimos: {work[-5:]}")
|
||||||
|
if not args.apply:
|
||||||
|
print("\nMODO PLAN (no se crea nada). Añade --apply para ejecutar.")
|
||||||
|
return
|
||||||
|
|
||||||
|
tot_in = tot_out = 0.0
|
||||||
|
created = skipped = 0
|
||||||
|
for pid in work:
|
||||||
|
if tp.translation_exists(pid, "en"):
|
||||||
|
print(f"#{pid}: EN ya existe (Gemma se adelantó) — salto")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
src = tp.read_post(pid)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f"#{pid}: no pude leer ({e}) — salto")
|
||||||
|
continue
|
||||||
|
if src.get("lang") and src["lang"] != "es":
|
||||||
|
continue
|
||||||
|
body, u1 = translate(src["content"], "en")
|
||||||
|
title, u2 = translate(src["title"], "en", is_title=True)
|
||||||
|
tot_in += u1.input_tokens + u2.input_tokens
|
||||||
|
tot_out += u1.output_tokens + u2.output_tokens
|
||||||
|
# Re-chequeo justo antes de crear (ventana de carrera con Gemma).
|
||||||
|
if tp.translation_exists(pid, "en"):
|
||||||
|
print(f"#{pid}: EN apareció mientras traducía — salto")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
new_id = tp.create_translation(pid, "en", title, body, "draft")
|
||||||
|
created += 1
|
||||||
|
print(f"#{pid} → EN #{new_id} «{title[:45]}»")
|
||||||
|
|
||||||
|
cost = tot_in / 1e6 * 1.0 + tot_out / 1e6 * 5.0
|
||||||
|
print(f"\nCreados: {created} Saltados: {skipped}")
|
||||||
|
print(f"Tokens in={int(tot_in)} out={int(tot_out)} coste=${cost:.4f}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Convierte los enlaces internos `...?p=<id>` de las cartas (grupo Polylang) a
|
||||||
|
* su permalink "bonito" por slug. Necesario cuando se arreglaron los enlaces de
|
||||||
|
* la carta con los artículos en DRAFT: get_permalink() devolvía `?p=<id>`, que
|
||||||
|
* el parser de portada (fea_url_to_post_id, resuelve por slug) NO reconoce → los
|
||||||
|
* artículos no salían en sus secciones. Con los posts ya en publish, get_permalink
|
||||||
|
* da el slug. Solo datos, sin tocar mu-plugins. Dry-run por defecto.
|
||||||
|
*
|
||||||
|
* Uso: CARTA=<es_id> php prettify_carta_links.php (dry-run)
|
||||||
|
* APPLY=1 CARTA=<es_id> php prettify_carta_links.php
|
||||||
|
*/
|
||||||
|
require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
|
||||||
|
$APPLY = getenv('APPLY') === '1';
|
||||||
|
$CARTA = (int)(getenv('CARTA') ?: 0);
|
||||||
|
if (!$CARTA) { fwrite(STDERR, "Falta CARTA=<es_id>\n"); exit(1); }
|
||||||
|
$BAK = "/tmp/prettify_carta_bak"; if ($APPLY) @mkdir($BAK, 0777, true);
|
||||||
|
|
||||||
|
$tot = 0;
|
||||||
|
foreach (pll_get_post_translations($CARTA) as $lang => $pid) {
|
||||||
|
$post = get_post($pid); if (!$post) continue;
|
||||||
|
$chg = 0;
|
||||||
|
$new = preg_replace_callback('~href="([^"]*[?&]p=(\d+)[^"]*)"~i', function($m) use (&$chg) {
|
||||||
|
$id = (int) $m[2];
|
||||||
|
$url = get_permalink($id);
|
||||||
|
if (!$url || strpos($url, '?p=') !== false) return $m[0]; // sigue feo → dejar
|
||||||
|
$chg++;
|
||||||
|
return 'href="' . esc_url($url) . '"';
|
||||||
|
}, $post->post_content);
|
||||||
|
echo sprintf("#%d [%s] «%s» — %d enlaces ?p= → slug\n", $pid, $lang, mb_substr($post->post_title,0,28), $chg);
|
||||||
|
$tot += $chg;
|
||||||
|
if ($APPLY && $chg) {
|
||||||
|
file_put_contents("$BAK/$pid.html", $post->post_content);
|
||||||
|
wp_update_post(['ID'=>$pid, 'post_content'=>$new]);
|
||||||
|
clean_post_cache($pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Invalida los transients de secciones de la portada para que recoja los cambios.
|
||||||
|
if ($APPLY) {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_fea_carta_sections_%'");
|
||||||
|
$wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_fea_carta_sections_%'");
|
||||||
|
}
|
||||||
|
echo ($APPLY ? "APLICADO" : "DRY-RUN") . ": $tot enlaces.\n";
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
|
||||||
|
$CARTA = (int)(getenv('CARTA') ?: 46956);
|
||||||
|
// IDs objetivo: carta + sus artículos ES, y TODAS sus traducciones Polylang.
|
||||||
|
$es_ids = [$CARTA];
|
||||||
|
$arts = get_posts(['post_type'=>'post','numberposts'=>-1,'post_status'=>'any',
|
||||||
|
'fields'=>'ids','meta_key'=>'_carta_id','meta_value'=>$CARTA]);
|
||||||
|
$es_ids = array_merge($es_ids, $arts);
|
||||||
|
$all = [];
|
||||||
|
foreach ($es_ids as $id) foreach (pll_get_post_translations($id) as $tid) $all[$tid]=true;
|
||||||
|
$all = array_keys($all);
|
||||||
|
|
||||||
|
$now = current_time('mysql'); $now_gmt = current_time('mysql', true);
|
||||||
|
$pub=0; $skip=0; $fixed=0;
|
||||||
|
foreach ($all as $id) {
|
||||||
|
$p = get_post($id); if(!$p) continue;
|
||||||
|
if ($p->post_status === 'publish') { $skip++; continue; }
|
||||||
|
$data = ['ID'=>$id, 'post_status'=>'publish'];
|
||||||
|
if (strtotime($p->post_date_gmt) > strtotime($now_gmt)) { // fecha futura -> a ahora
|
||||||
|
$data['post_date']=$now; $data['post_date_gmt']=$now_gmt; $fixed++;
|
||||||
|
}
|
||||||
|
$r = wp_update_post($data, true);
|
||||||
|
if (is_wp_error($r)) { echo "ERR #$id: ".$r->get_error_message()."\n"; continue; }
|
||||||
|
clean_post_cache($id); $pub++;
|
||||||
|
}
|
||||||
|
echo "Publicados: $pub | ya estaban: $skip | fechas futuras corregidas: $fixed\n";
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
// #4: quita la categoría Multimedia (1649 + traducciones) de los posts-lectura.
|
||||||
|
$multi=[1649];
|
||||||
|
foreach(["en","fr","it","pt"] as $L){ $t=pll_get_term(1649,$L); if($t)$multi[]=(int)$t; }
|
||||||
|
global $wpdb;
|
||||||
|
$rows=$wpdb->get_results("SELECT ID FROM {$wpdb->posts} WHERE post_type='post' AND post_status='publish' AND post_title REGEXP '[0-9]+, *[0-9]+(-[0-9]+)?\\.?$'");
|
||||||
|
$feadulta=(int)$wpdb->get_var("SELECT term_id FROM {$wpdb->terms} WHERE name='Feadulta' LIMIT 1");
|
||||||
|
$changed=0;
|
||||||
|
foreach($rows as $r){
|
||||||
|
$cats=wp_get_post_categories($r->ID);
|
||||||
|
$new=array_values(array_diff($cats,$multi));
|
||||||
|
if(count($new)!==count($cats)){ if(!$new)$new=[$feadulta]; wp_set_post_categories($r->ID,$new); $changed++; }
|
||||||
|
}
|
||||||
|
echo "lecturas con Multimedia eliminada: $changed\n";
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* reasign_cats.php — #136: reasigna las categorías de los posts traducidos (en/fr/it/pt)
|
||||||
|
* a la versión Polylang de su idioma. Si la categoría no tiene traducción, crea un
|
||||||
|
* término espejo (mismo nombre) en ese idioma y lo asocia. Idempotente y resumible
|
||||||
|
* (marca _cats_reasignadas=1).
|
||||||
|
*
|
||||||
|
* Ejecutar: docker exec wordpress-web wp eval-file /tmp/reasign_cats.php [LANG] [LIMIT] --allow-root
|
||||||
|
* sin args: procesa los 4 idiomas. Con LANG (en/fr/it/pt) y LIMIT acota.
|
||||||
|
*/
|
||||||
|
$only_lang = (isset($argv[1]) && in_array($argv[1], ['en','fr','it','pt'], true)) ? $argv[1] : null;
|
||||||
|
$limit = isset($argv[2]) ? (int) $argv[2] : 0;
|
||||||
|
|
||||||
|
$mirror_cache = []; // "cat_es|lang" => term_id
|
||||||
|
|
||||||
|
function translate_or_mirror($c, $lang, &$cache) {
|
||||||
|
$key = $c . '|' . $lang;
|
||||||
|
if (isset($cache[$key])) return $cache[$key];
|
||||||
|
$t = pll_get_term($c, $lang);
|
||||||
|
if (!$t) {
|
||||||
|
$term = get_term($c, 'category');
|
||||||
|
if (!$term || is_wp_error($term)) { return $cache[$key] = $c; }
|
||||||
|
$slug = sanitize_title($term->name) . '-' . $lang . '-' . $c;
|
||||||
|
$res = wp_insert_term($term->name, 'category', ['slug' => $slug]);
|
||||||
|
if (is_wp_error($res)) {
|
||||||
|
$ex = $res->get_error_data();
|
||||||
|
if (!$ex) { return $cache[$key] = $c; }
|
||||||
|
$t = is_array($ex) ? ($ex['term_id'] ?? 0) : (int) $ex;
|
||||||
|
} else {
|
||||||
|
$t = $res['term_id'];
|
||||||
|
}
|
||||||
|
if ($t) {
|
||||||
|
pll_set_term_language($t, $lang);
|
||||||
|
$tr = pll_get_term_translations($c);
|
||||||
|
$tr[$lang] = $t;
|
||||||
|
pll_save_term_translations($tr);
|
||||||
|
} else {
|
||||||
|
$t = $c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $cache[$key] = (int) $t;
|
||||||
|
}
|
||||||
|
|
||||||
|
$langs = $only_lang ? [$only_lang] : ['en','fr','it','pt'];
|
||||||
|
$done = 0; $changed = 0; $mirrors = 0;
|
||||||
|
$start_mirror_terms = count($mirror_cache);
|
||||||
|
foreach ($langs as $lang) {
|
||||||
|
$args = ['lang'=>$lang,'post_type'=>'post','post_status'=>['publish','draft'],
|
||||||
|
'fields'=>'ids','posts_per_page'=> $limit ?: -1,'no_found_rows'=>true,
|
||||||
|
'meta_query'=>[['key'=>'_cats_reasignadas','compare'=>'NOT EXISTS']]];
|
||||||
|
$ids = get_posts($args);
|
||||||
|
foreach ($ids as $pid) {
|
||||||
|
// Fuente de verdad = categorías del ES original (las del post traducido
|
||||||
|
// pueden estar incompletas). Se traducen al idioma del post.
|
||||||
|
$es = pll_get_post($pid, 'es');
|
||||||
|
$source = ($es && $es != $pid) ? wp_get_post_categories($es) : wp_get_post_categories($pid);
|
||||||
|
$new = [];
|
||||||
|
foreach ($source as $c) {
|
||||||
|
$clang = pll_get_term_language($c);
|
||||||
|
$new[] = ($clang === $lang) ? $c : translate_or_mirror($c, $lang, $mirror_cache);
|
||||||
|
}
|
||||||
|
$new = array_values(array_unique($new));
|
||||||
|
$cur = wp_get_post_categories($pid);
|
||||||
|
sort($cur); $cmp = $new; sort($cmp);
|
||||||
|
if ($cur !== $cmp && $new) { wp_set_post_categories($pid, $new); $changed++; }
|
||||||
|
update_post_meta($pid, '_cats_reasignadas', 1);
|
||||||
|
$done++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo "procesados: $done | con cambios: $changed | términos espejo en caché: " . count($mirror_cache) . "\n";
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
// Regenera thumbnails de los attachments creados en /uploads/autores/joomla/
|
||||||
|
// Borra los thumbnails viejos y los recrea con las versiones face-cropped.
|
||||||
|
require '/var/www/html/wp-load.php';
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/image.php';
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$rows = $wpdb->get_results(
|
||||||
|
"SELECT ID FROM {$wpdb->posts}
|
||||||
|
WHERE post_type='attachment'
|
||||||
|
AND guid LIKE '%/autores/joomla/%'"
|
||||||
|
);
|
||||||
|
echo 'Attachments: ' . count($rows) . PHP_EOL;
|
||||||
|
|
||||||
|
$uploads_basedir = wp_upload_dir()['basedir'];
|
||||||
|
$ok = 0; $fail = 0;
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$aid = (int) $r->ID;
|
||||||
|
$file = get_attached_file($aid);
|
||||||
|
if (!$file || !file_exists($file)) { $fail++; continue; }
|
||||||
|
|
||||||
|
// Borrar thumbnails viejos del attachment (todas las variantes -WxH)
|
||||||
|
$old_meta = wp_get_attachment_metadata($aid);
|
||||||
|
if (!empty($old_meta['sizes'])) {
|
||||||
|
$dir = dirname($file);
|
||||||
|
foreach ($old_meta['sizes'] as $s) {
|
||||||
|
$thumb = $dir . '/' . $s['file'];
|
||||||
|
if (file_exists($thumb)) @unlink($thumb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Regenerar
|
||||||
|
$meta = wp_generate_attachment_metadata($aid, $file);
|
||||||
|
if ($meta) {
|
||||||
|
wp_update_attachment_metadata($aid, $meta);
|
||||||
|
$ok++;
|
||||||
|
} else {
|
||||||
|
$fail++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo "OK: $ok, FAIL: $fail" . PHP_EOL;
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Regenera `clasificacion_articulos.csv` recorriendo las cartas semanales y
|
||||||
|
extrayendo los links que cada una agrupa por encabezado (Artículos, Evangelio,
|
||||||
|
Eucaristía, Multimedia, EFFA). Paridad con `wp-content/mu-plugins/fea-carta-portada.php`.
|
||||||
|
|
||||||
|
Output: post_id, post_title, categoria_propuesta, seccion_original, carta_id, carta_titulo, carta_fecha
|
||||||
|
|
||||||
|
ALCANCE (vs. CSV histórico de marzo 2026):
|
||||||
|
- Cubre las 5 secciones estándar: comentario, articulo, eucaristia, multimedia, effa.
|
||||||
|
- Sub-clasifica por posición dentro de "Evangelio y comentarios al Evangelio":
|
||||||
|
· pos 1 → `lectura` (cita del evangelio)
|
||||||
|
· pos 2 → `comentario_editorial`
|
||||||
|
· pos 3+ → `comentario`
|
||||||
|
(regla del editor, confirmada contra el CSV histórico)
|
||||||
|
- NO cubre todavía:
|
||||||
|
· `lectura` dentro de eucaristía (lecturas bíblicas — el viejo las separa, aquí van todas a `eucaristia`)
|
||||||
|
· `otro` (catch-all del catch-all)
|
||||||
|
· `noticia` (subgrupo poco usado, 12 filas en el viejo)
|
||||||
|
· Encabezados de fiestas especiales ("Domingo de Resurrección", "Navidad", "Vigilia Pascual", etc.)
|
||||||
|
|
||||||
|
Para regenerar el CSV con cobertura completa habría que ampliar el mapping en
|
||||||
|
SECTION_PATTERNS y SECTION_LABELS con reglas adicionales. El CSV histórico
|
||||||
|
existente (raíz del repo) sirve como baseline para esa cobertura granular.
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
python3 regenerar_clasificacion_csv.py [--out /path/clasificacion_articulos.csv] [--diff /path/csv_marzo.csv]
|
||||||
|
|
||||||
|
Issue: rafa/feadulta#42
|
||||||
|
"""
|
||||||
|
import argparse, csv, os, re, subprocess, sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pymysql
|
||||||
|
except ImportError:
|
||||||
|
sys.exit('pymysql requerido: pip install --user pymysql')
|
||||||
|
|
||||||
|
# Mapping sección encabezado → cat slug (debe espejar fea-carta-portada.php)
|
||||||
|
SECTION_PATTERNS = [
|
||||||
|
('comentario', re.compile(r'Evangelio\s+y\s+comentarios\s+al\s+Evangelio', re.I)),
|
||||||
|
('articulo', re.compile(r'Art[ií]culos\s+seleccionados\s+para\s+la\s+semana', re.I)),
|
||||||
|
('eucaristia', re.compile(r'Para\s+unas\s+eucarist[ií]as\s+m[áa]s\s+participativas', re.I)),
|
||||||
|
('multimedia', re.compile(r'Material\s+multimedia', re.I)),
|
||||||
|
('effa', re.compile(r'Escuela\s+EFFA', re.I)),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Nombres “bonitos” usados en seccion_original (verbatim del CSV histórico)
|
||||||
|
SECTION_LABELS = {
|
||||||
|
'comentario': 'Evangelio y comentarios al Evangelio',
|
||||||
|
'articulo': 'Artículos seleccionados para la semana',
|
||||||
|
'eucaristia': 'Para unas eucaristías más participativas y actuales',
|
||||||
|
'multimedia': 'Material multimedia',
|
||||||
|
'effa': 'Escuela EFFA',
|
||||||
|
}
|
||||||
|
|
||||||
|
CAT_PROPUESTA = {
|
||||||
|
'comentario': 'comentario',
|
||||||
|
'articulo': 'articulo',
|
||||||
|
'eucaristia': 'eucaristia',
|
||||||
|
'multimedia': 'multimedia',
|
||||||
|
'effa': 'effa',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sub-clasificación posicional dentro de la sección "Evangelio y comentarios al Evangelio".
|
||||||
|
# El editor SIEMPRE coloca: 1º lectura del evangelio, 2º comentario editorial, 3º+ comentarios.
|
||||||
|
SUBCAT_EVANGELIO_BY_POS = ['lectura', 'comentario_editorial'] # resto = 'comentario'
|
||||||
|
|
||||||
|
HREF_RX = re.compile(r'href=["\']([^"\']+)["\']', re.I)
|
||||||
|
WP_SLUG_RX = re.compile(r'(?:^|/)fea/([a-z0-9\-]+)/?(?:[?#]|$)', re.I)
|
||||||
|
K2_ITEM_RX = re.compile(r'/item/(\d+)-[^/"]+\.html', re.I)
|
||||||
|
RESERVED_SLUGS = {'wp-admin','wp-content','category','tag','author','page','en','fr','it','pt'}
|
||||||
|
|
||||||
|
|
||||||
|
def get_conn():
|
||||||
|
ip = subprocess.run(
|
||||||
|
['docker', 'inspect', 'wordpress-mysql', '--format',
|
||||||
|
'{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'],
|
||||||
|
capture_output=True, text=True, check=True,
|
||||||
|
).stdout.strip()
|
||||||
|
return pymysql.connect(
|
||||||
|
host=ip, user='wordpress_user', password='wordpress_pass',
|
||||||
|
database='wordpress_db', charset='utf8mb4', autocommit=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mysql(query, conn=None):
|
||||||
|
"""Ejecuta query y devuelve filas (lista de tuplas) — pymysql, sin parsing CLI."""
|
||||||
|
own = False
|
||||||
|
if conn is None:
|
||||||
|
conn = get_conn(); own = True
|
||||||
|
try:
|
||||||
|
with conn.cursor() as c:
|
||||||
|
c.execute(query)
|
||||||
|
return list(c.fetchall())
|
||||||
|
finally:
|
||||||
|
if own: conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_cartas(conn):
|
||||||
|
"""Todas las cartas (cat 6 actual, 22 semana pasada, 21 otras) + sus contenidos."""
|
||||||
|
q = """
|
||||||
|
SELECT p.ID, p.post_title, DATE(p.post_date), p.post_content
|
||||||
|
FROM wp_posts p
|
||||||
|
WHERE p.post_status='publish' AND p.post_type='post'
|
||||||
|
AND p.ID IN (
|
||||||
|
SELECT DISTINCT tr.object_id FROM wp_term_relationships tr
|
||||||
|
JOIN wp_term_taxonomy tt ON tt.term_taxonomy_id=tr.term_taxonomy_id
|
||||||
|
WHERE tt.term_id IN (6, 21, 22) AND tt.taxonomy='category'
|
||||||
|
)
|
||||||
|
ORDER BY p.post_date DESC;
|
||||||
|
"""
|
||||||
|
return mysql(q, conn)
|
||||||
|
|
||||||
|
|
||||||
|
def build_lookups(conn):
|
||||||
|
"""Construye dicts slug→post_id y k2_id→post_id para no machacar la BD por cada link.
|
||||||
|
Para slugs duplicados (varios posts con mismo slug), se usa el MÁS RECIENTE
|
||||||
|
(criterio espejo del mu-plugin fea-carta-portada.php tras el bug detectado en #38).
|
||||||
|
"""
|
||||||
|
print('Cargando lookups (slug y k2_id) ...', file=sys.stderr, flush=True)
|
||||||
|
slug_to_id = {}
|
||||||
|
rows = mysql("""
|
||||||
|
SELECT p1.post_name, p1.ID
|
||||||
|
FROM wp_posts p1
|
||||||
|
WHERE p1.post_status='publish' AND p1.post_type='post' AND p1.post_name<>''
|
||||||
|
ORDER BY p1.post_date DESC;
|
||||||
|
""", conn)
|
||||||
|
for r in rows:
|
||||||
|
slug = r[0]
|
||||||
|
if slug not in slug_to_id: # primero (más reciente) gana
|
||||||
|
slug_to_id[slug] = int(r[1])
|
||||||
|
|
||||||
|
k2_to_id = {}
|
||||||
|
rows = mysql("""
|
||||||
|
SELECT meta_value, MAX(post_id) FROM wp_postmeta
|
||||||
|
WHERE meta_key='_fgj2wp_old_k2_id' AND meta_value<>''
|
||||||
|
GROUP BY meta_value;
|
||||||
|
""", conn)
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
k2_to_id[int(r[0])] = int(r[1])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f' slugs: {len(slug_to_id)} k2_ids: {len(k2_to_id)}', file=sys.stderr)
|
||||||
|
return slug_to_id, k2_to_id
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_titles(ids, conn):
|
||||||
|
if not ids: return {}
|
||||||
|
ids_str = ','.join(str(i) for i in ids)
|
||||||
|
rows = mysql(f"SELECT ID, post_title FROM wp_posts WHERE ID IN ({ids_str});", conn)
|
||||||
|
return {int(r[0]): r[1] for r in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def url_to_post_id(url, slug_to_id, k2_to_id):
|
||||||
|
m = WP_SLUG_RX.search(url)
|
||||||
|
if m:
|
||||||
|
slug = m.group(1).lower()
|
||||||
|
if slug not in RESERVED_SLUGS and slug in slug_to_id:
|
||||||
|
return slug_to_id[slug]
|
||||||
|
m = K2_ITEM_RX.search(url)
|
||||||
|
if m:
|
||||||
|
k2 = int(m.group(1))
|
||||||
|
if k2 in k2_to_id:
|
||||||
|
return k2_to_id[k2]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_sections(html_content):
|
||||||
|
"""Devuelve dict {section_slug: [post_id, ...]} basándose en encabezados.
|
||||||
|
NOTA: los post_ids aún no están resueltos aquí — devuelve hrefs en su lugar.
|
||||||
|
"""
|
||||||
|
positions = []
|
||||||
|
for slug, rx in SECTION_PATTERNS:
|
||||||
|
m = rx.search(html_content)
|
||||||
|
if m:
|
||||||
|
positions.append((m.start(), slug))
|
||||||
|
if not positions:
|
||||||
|
return {}
|
||||||
|
positions.sort()
|
||||||
|
positions.append((len(html_content), None))
|
||||||
|
|
||||||
|
out = {}
|
||||||
|
for i in range(len(positions) - 1):
|
||||||
|
start, slug = positions[i]
|
||||||
|
end = positions[i+1][0]
|
||||||
|
segment = html_content[start:end]
|
||||||
|
hrefs = HREF_RX.findall(segment)
|
||||||
|
# Dedup preservando orden
|
||||||
|
seen, urls = set(), []
|
||||||
|
for h in hrefs:
|
||||||
|
if h not in seen:
|
||||||
|
seen.add(h); urls.append(h)
|
||||||
|
out[slug] = urls
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument('--out', default='/tmp/clasificacion_articulos_regen.csv')
|
||||||
|
ap.add_argument('--diff', help='CSV de referencia para mostrar diff')
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
conn = get_conn()
|
||||||
|
slug_to_id, k2_to_id = build_lookups(conn)
|
||||||
|
|
||||||
|
print('Leyendo cartas ...', file=sys.stderr, flush=True)
|
||||||
|
cartas = fetch_cartas(conn)
|
||||||
|
print(f' cartas: {len(cartas)}', file=sys.stderr)
|
||||||
|
|
||||||
|
rows_out = []
|
||||||
|
needed_titles = set()
|
||||||
|
n_unresolved = 0
|
||||||
|
n_resolved = 0
|
||||||
|
|
||||||
|
for c in cartas:
|
||||||
|
carta_id, carta_title, carta_fecha, content = c
|
||||||
|
carta_id = int(carta_id)
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
|
|
||||||
|
sections = extract_sections(content)
|
||||||
|
for slug, urls in sections.items():
|
||||||
|
label = SECTION_LABELS[slug]
|
||||||
|
default_cat = CAT_PROPUESTA[slug]
|
||||||
|
# Filtrar a posts resueltos manteniendo orden
|
||||||
|
resolved = []
|
||||||
|
for url in urls:
|
||||||
|
pid = url_to_post_id(url, slug_to_id, k2_to_id)
|
||||||
|
if pid is None:
|
||||||
|
n_unresolved += 1
|
||||||
|
continue
|
||||||
|
resolved.append(pid)
|
||||||
|
for pos, pid in enumerate(resolved):
|
||||||
|
# Sub-clasificación posicional para evangelio
|
||||||
|
if slug == 'comentario' and pos < len(SUBCAT_EVANGELIO_BY_POS):
|
||||||
|
cat = SUBCAT_EVANGELIO_BY_POS[pos]
|
||||||
|
else:
|
||||||
|
cat = default_cat
|
||||||
|
n_resolved += 1
|
||||||
|
needed_titles.add(pid)
|
||||||
|
rows_out.append({
|
||||||
|
'post_id': pid,
|
||||||
|
'categoria_propuesta': cat,
|
||||||
|
'seccion_original': label,
|
||||||
|
'carta_id': carta_id,
|
||||||
|
'carta_titulo': carta_title,
|
||||||
|
'carta_fecha': carta_fecha,
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f'Resueltos: {n_resolved} Sin resolver: {n_unresolved}', file=sys.stderr)
|
||||||
|
print('Cargando títulos ...', file=sys.stderr, flush=True)
|
||||||
|
titles = fetch_titles(list(needed_titles), conn)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Escribir CSV
|
||||||
|
cols = ['post_id', 'post_title', 'categoria_propuesta', 'seccion_original', 'carta_id', 'carta_titulo', 'carta_fecha']
|
||||||
|
with open(args.out, 'w', newline='', encoding='utf-8') as f:
|
||||||
|
w = csv.DictWriter(f, fieldnames=cols, quoting=csv.QUOTE_MINIMAL)
|
||||||
|
w.writeheader()
|
||||||
|
for r in rows_out:
|
||||||
|
r['post_title'] = titles.get(r['post_id'], '')
|
||||||
|
w.writerow(r)
|
||||||
|
print(f'Escrito: {args.out} ({len(rows_out)} filas)')
|
||||||
|
|
||||||
|
# Diff opcional
|
||||||
|
if args.diff and os.path.exists(args.diff):
|
||||||
|
from collections import defaultdict, Counter
|
||||||
|
def load(path):
|
||||||
|
d = defaultdict(set) # (post_id, cat) → set((carta_id, seccion))
|
||||||
|
cats_by_post = defaultdict(set)
|
||||||
|
with open(path, encoding='utf-8-sig') as fh:
|
||||||
|
r = csv.DictReader(fh)
|
||||||
|
for row in r:
|
||||||
|
pid = row.get('post_id','')
|
||||||
|
cat = row.get('categoria_propuesta','')
|
||||||
|
if not pid: continue
|
||||||
|
cats_by_post[pid].add(cat)
|
||||||
|
return cats_by_post
|
||||||
|
old = load(args.diff)
|
||||||
|
new = load(args.out)
|
||||||
|
old_keys = set(old.keys())
|
||||||
|
new_keys = set(new.keys())
|
||||||
|
print('\n=== DIFF ===')
|
||||||
|
print(f'posts en CSV viejo: {len(old_keys)}')
|
||||||
|
print(f'posts en CSV nuevo: {len(new_keys)}')
|
||||||
|
print(f'solo en viejo: {len(old_keys - new_keys)}')
|
||||||
|
print(f'solo en nuevo: {len(new_keys - old_keys)}')
|
||||||
|
common = old_keys & new_keys
|
||||||
|
same_cats = sum(1 for k in common if old[k] == new[k])
|
||||||
|
diff_cats = len(common) - same_cats
|
||||||
|
print(f'común con mismas cats: {same_cats}')
|
||||||
|
print(f'común con cats distintas: {diff_cats}')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
// Remapea los enlaces internos de las traducciones de una carta: convierte
|
||||||
|
// permalinks/IDs que apuntan al artículo ES → permalink del artículo en el
|
||||||
|
// idioma de CADA carta (vía Polylang). Necesario cuando la carta ES se tradujo
|
||||||
|
// DESPUÉS de haberle fijado los enlaces a permalink (las traducciones heredan
|
||||||
|
// los permalinks ES). Solo toca traducciones (no la ES). Dry-run por defecto.
|
||||||
|
require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
|
||||||
|
global $wpdb;
|
||||||
|
$APPLY = getenv("APPLY") === "1";
|
||||||
|
$BAK = "/tmp/remap_carta_tr_bak"; if ($APPLY) @mkdir($BAK,0777,true);
|
||||||
|
$CARTA = (int)(getenv("CARTA") ?: 53644);
|
||||||
|
|
||||||
|
$tr = pll_get_post_translations($CARTA);
|
||||||
|
$tot = 0;
|
||||||
|
foreach($tr as $lang=>$pid){
|
||||||
|
if($lang === 'es') continue; // la ES ya está bien
|
||||||
|
$post = get_post($pid); if(!$post) continue;
|
||||||
|
$chg=0; $miss=[];
|
||||||
|
$new = preg_replace_callback('~href="([^"]+)"~i', function($m) use($lang,&$chg,&$miss){
|
||||||
|
$href = html_entity_decode(trim($m[1]));
|
||||||
|
if(stripos($href,'farmer.taild3aaf6.ts.net')===false && stripos($href,'/fea/')===false) return $m[0];
|
||||||
|
$es = 0;
|
||||||
|
if(preg_match('~[?&]p=(\d+)~',$href,$mm)) $es=(int)$mm[1]; // forma ?p=ID
|
||||||
|
if(!$es) $es = (int)url_to_postid($href); // forma /slug/
|
||||||
|
if(!$es){ return $m[0]; }
|
||||||
|
if(pll_get_post_language($es) !== 'es'){ return $m[0]; } // solo si apunta a ES
|
||||||
|
$t = pll_get_post($es,$lang);
|
||||||
|
if(!$t || $t==$es){ $miss[]=$href; return $m[0]; } // sin traducción → dejar
|
||||||
|
$url = get_permalink($t);
|
||||||
|
if(!$url) return $m[0];
|
||||||
|
$chg++;
|
||||||
|
return 'href="'.esc_url($url).'"';
|
||||||
|
}, $post->post_content);
|
||||||
|
echo sprintf("#%d [%s] «%s» — %d remapeados%s\n",$pid,$lang,mb_substr($post->post_title,0,30),$chg,
|
||||||
|
$miss?(" | sin traducción: ".count($miss)):"");
|
||||||
|
$tot+=$chg;
|
||||||
|
if($APPLY && $chg){ file_put_contents("$BAK/$pid.html",$post->post_content);
|
||||||
|
$wpdb->update($wpdb->posts,['post_content'=>$new],['ID'=>$pid]); clean_post_cache($pid); }
|
||||||
|
}
|
||||||
|
echo ($APPLY?"APLICADO":"DRY-RUN").": $tot enlaces.\n";
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Remapea las categorías de las traducciones automáticas (meta traduccion_origen)
|
||||||
|
* a los términos traducidos de su propio idioma. Idempotente y sin llamar a Gemma.
|
||||||
|
*
|
||||||
|
* Arregla las traducciones creadas antes de que fea_translate_helper.php mapeara
|
||||||
|
* categorías (issue #75): p.ej. una carta EN que quedó en la categoría ES `cartasemana`
|
||||||
|
* pasa a la categoría EN `letter-of-the-week`, poblando el archivo de carta por idioma.
|
||||||
|
*
|
||||||
|
* Uso: docker exec wordpress-web php /tmp/remap_translation_cats.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
$_SERVER['REQUEST_URI'] = $_SERVER['REQUEST_URI'] ?? '/';
|
||||||
|
$_SERVER['HTTP_HOST'] = $_SERVER['HTTP_HOST'] ?? 'farmer.taild3aaf6.ts.net';
|
||||||
|
require_once '/var/www/html/wp-load.php';
|
||||||
|
|
||||||
|
if (!function_exists('pll_get_term')) { fwrite(STDERR, "Polylang no disponible\n"); exit(2); }
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$ids = $wpdb->get_col("SELECT DISTINCT post_id FROM {$wpdb->postmeta} WHERE meta_key='traduccion_origen'");
|
||||||
|
$fixed = 0;
|
||||||
|
|
||||||
|
foreach ($ids as $pid) {
|
||||||
|
$pid = (int) $pid;
|
||||||
|
$lang = pll_get_post_language($pid);
|
||||||
|
if (!$lang || $lang === 'es') continue;
|
||||||
|
|
||||||
|
$cats = wp_get_post_categories($pid);
|
||||||
|
$mapped = [];
|
||||||
|
$changed = false;
|
||||||
|
foreach ($cats as $c) {
|
||||||
|
$tc = (int) pll_get_term($c, $lang);
|
||||||
|
if ($tc && $tc !== $c) { $mapped[] = $tc; $changed = true; }
|
||||||
|
else { $mapped[] = $c; }
|
||||||
|
}
|
||||||
|
if ($changed) {
|
||||||
|
wp_set_post_categories($pid, array_values(array_unique($mapped)));
|
||||||
|
$fixed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Remapeadas categorías en $fixed traducciones (de " . count($ids) . " revisadas)\n";
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
|
||||||
|
global $wpdb;
|
||||||
|
$APPLY = getenv("APPLY")==="1";
|
||||||
|
$CARTA = (int)(getenv("CARTA") ?: 46956);
|
||||||
|
$BAK="/tmp/repoint_bak"; if($APPLY) @mkdir($BAK,0777,true);
|
||||||
|
|
||||||
|
function resolve_post($href){
|
||||||
|
// ?p=ID
|
||||||
|
if (preg_match('~[?&]p=(\d+)~',$href,$m)) return (int)$m[1];
|
||||||
|
$path = preg_replace('~^https?://[^/]+~i','',$href);
|
||||||
|
$path = preg_replace('~[?#].*$~','',$path);
|
||||||
|
$path = preg_replace('~^/fea~','',$path);
|
||||||
|
$path = preg_replace('~^/(en|fr|it|pt|es)(/|$)~','/',$path);
|
||||||
|
$segs = array_values(array_filter(explode('/',$path),'strlen'));
|
||||||
|
if (count($segs)!==1) return 0;
|
||||||
|
$p = get_page_by_path($segs[0], OBJECT, 'post');
|
||||||
|
return $p ? $p->ID : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_values(pll_get_post_translations($CARTA)) as $pid) {
|
||||||
|
$post=get_post($pid); if(!$post) continue;
|
||||||
|
$lang=pll_get_post_language($pid) ?: 'es';
|
||||||
|
$chg=0;
|
||||||
|
$new=preg_replace_callback('~href="([^"]+)"~i', function($m) use($lang,&$chg){
|
||||||
|
$href=$m[1];
|
||||||
|
if (stripos($href,'.html')!==false) return $m[0]; // legacy lo maneja el otro script
|
||||||
|
$tid=resolve_post($href);
|
||||||
|
if(!$tid) return $m[0];
|
||||||
|
$plang=pll_get_post_language($tid);
|
||||||
|
if(!$plang || $plang===$lang) return $m[0]; // ya está en el idioma correcto
|
||||||
|
$t=pll_get_post($tid,$lang);
|
||||||
|
if(!$t || $t==$tid) return $m[0]; // no hay traducción -> dejar
|
||||||
|
$url=get_permalink($t); if(!$url) return $m[0];
|
||||||
|
$chg++; return 'href="'.esc_url($url).'"';
|
||||||
|
}, $post->post_content);
|
||||||
|
echo "#$pid [$lang] — $chg repuntado(s)\n";
|
||||||
|
if($APPLY && $chg){ file_put_contents("$BAK/$pid.html",$post->post_content);
|
||||||
|
$wpdb->update($wpdb->posts,['post_content'=>$new],['ID'=>$pid]); clean_post_cache($pid); }
|
||||||
|
}
|
||||||
|
echo $APPLY?"APLICADO\n":"DRY-RUN\n";
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Reprocesa traducciones ROTAS con Claude Haiku 4.5 (API directa).
|
||||||
|
|
||||||
|
Coge el ES original, lo traduce con Haiku y SOBRESCRIBE la traducción ya
|
||||||
|
existente (in-place, sin duplicar). Detecta los rotos por ratio de longitud.
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
reprocess_en_haiku.py --auto --langs en --limit 100 # EN rotos
|
||||||
|
reprocess_en_haiku.py --auto --langs fr,it,pt --limit 50 # otros idiomas
|
||||||
|
reprocess_en_haiku.py --ids 44205 --langs en # src concretos
|
||||||
|
añade --apply para ESCRIBIR en la BD (sin él = dry-run).
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from translate_haiku import translate # noqa: E402
|
||||||
|
|
||||||
|
STATE = "/tmp/feadulta-translate-state.json"
|
||||||
|
CONTAINER = "wordpress-web"
|
||||||
|
RATIO_BROKEN = 0.45
|
||||||
|
|
||||||
|
|
||||||
|
def dexec(args):
|
||||||
|
return subprocess.run(["docker", "exec", CONTAINER, *args],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def dcp(remote, local):
|
||||||
|
subprocess.run(["docker", "cp", f"{CONTAINER}:{remote}", local], check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def dcp_to(local, remote):
|
||||||
|
subprocess.run(["docker", "cp", local, f"{CONTAINER}:{remote}"], check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_post(pid):
|
||||||
|
r = dexec(["php", "/tmp/fea_post_io.php", "get", str(pid)])
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError(f"get {pid}: {r.stderr.strip()}")
|
||||||
|
dcp("/tmp/fea_es.json", "/tmp/fea_es.json")
|
||||||
|
return json.load(open("/tmp/fea_es.json"))
|
||||||
|
|
||||||
|
|
||||||
|
def strip_len(html):
|
||||||
|
return len(re.sub(r"<[^>]*>", "", html))
|
||||||
|
|
||||||
|
|
||||||
|
def find_broken(state, langs, limit):
|
||||||
|
"""Devuelve [(src, lang), ...] de traducciones rotas."""
|
||||||
|
out = []
|
||||||
|
for key, tid in state["done"].items():
|
||||||
|
src, lang = key.split(":")
|
||||||
|
if lang not in langs:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
es = get_post(int(src))
|
||||||
|
tr = get_post(int(tid))
|
||||||
|
except RuntimeError:
|
||||||
|
continue
|
||||||
|
olen = strip_len(es["content"])
|
||||||
|
if olen < 40:
|
||||||
|
continue
|
||||||
|
if strip_len(tr["content"]) / olen < RATIO_BROKEN:
|
||||||
|
out.append((int(src), lang))
|
||||||
|
if len(out) >= limit:
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--ids", nargs="*", type=int, default=[])
|
||||||
|
ap.add_argument("--langs", default="en")
|
||||||
|
ap.add_argument("--auto", action="store_true")
|
||||||
|
ap.add_argument("--limit", type=int, default=100)
|
||||||
|
ap.add_argument("--apply", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
langs = [l.strip() for l in args.langs.split(",") if l.strip()]
|
||||||
|
state = json.load(open(STATE))
|
||||||
|
|
||||||
|
if args.auto:
|
||||||
|
print(f"Autodetectando rotos (ratio<{RATIO_BROKEN}) en {langs}…")
|
||||||
|
pairs = find_broken(state, langs, args.limit)
|
||||||
|
print(f"Encontrados: {pairs}")
|
||||||
|
else:
|
||||||
|
pairs = [(src, lang) for src in args.ids for lang in langs]
|
||||||
|
|
||||||
|
tot_in = tot_out = 0.0
|
||||||
|
for src, lang in pairs:
|
||||||
|
tid = state["done"].get(f"{src}:{lang}")
|
||||||
|
if not tid:
|
||||||
|
print(f"[{src}:{lang}] sin traducción en state; salto")
|
||||||
|
continue
|
||||||
|
es = get_post(src)
|
||||||
|
body, u1 = translate(es["content"], lang)
|
||||||
|
title, u2 = translate(es["title"], lang, is_title=True)
|
||||||
|
tot_in += u1.input_tokens + u2.input_tokens
|
||||||
|
tot_out += u1.output_tokens + u2.output_tokens
|
||||||
|
|
||||||
|
print(f"\n===== src #{src} [{lang}] -> #{tid} =====")
|
||||||
|
print(f"TÍTULO {lang}: {title}")
|
||||||
|
print(f"cuerpo ES={strip_len(es['content'])} -> {lang}={strip_len(body)}")
|
||||||
|
|
||||||
|
if args.apply:
|
||||||
|
open("/tmp/fea_title.txt", "w").write(title)
|
||||||
|
open("/tmp/fea_body.txt", "w").write(body)
|
||||||
|
dcp_to("/tmp/fea_title.txt", "/tmp/fea_title.txt")
|
||||||
|
dcp_to("/tmp/fea_body.txt", "/tmp/fea_body.txt")
|
||||||
|
r = dexec(["php", "/tmp/fea_post_io.php", "update", str(tid),
|
||||||
|
"/tmp/fea_title.txt", "/tmp/fea_body.txt"])
|
||||||
|
print(("APLICADO: " + r.stdout.strip()) if r.returncode == 0
|
||||||
|
else ("FALLO: " + r.stderr.strip()))
|
||||||
|
else:
|
||||||
|
print("(dry-run)")
|
||||||
|
|
||||||
|
cost = tot_in / 1e6 * 1.0 + tot_out / 1e6 * 5.0
|
||||||
|
print(f"\nTOTAL tokens: in={int(tot_in)} out={int(tot_out)} coste=${cost:.4f}")
|
||||||
|
print("MODO: " + ("APLICADO a BD" if args.apply else "DRY-RUN"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Ciclo carta nueva — ROTACIÓN de la "carta de la semana" en TODOS los idiomas.
|
||||||
|
*
|
||||||
|
* Rotación (en este orden, para no perder ninguna):
|
||||||
|
* 1) la que estaba en "semana pasada" -> queda solo en "otras semanas"
|
||||||
|
* 2) la que estaba en "semana actual" -> pasa a "semana pasada"
|
||||||
|
* 3) la carta NUEVA -> pasa a "semana actual"
|
||||||
|
*
|
||||||
|
* Robusto/autocorrige: la NUEVA es el parámetro CARTA; la "semana pasada" se
|
||||||
|
* deriva como la carta publicada más reciente que NO es la nueva (por fecha),
|
||||||
|
* no por quién estuviera en el término (que puede estar roto). Garantiza
|
||||||
|
* count=1 en "actual" y count=1 en "pasada" por idioma. "Otras semanas" (21)
|
||||||
|
* es el cajón base que conservan TODAS las cartas.
|
||||||
|
*
|
||||||
|
* Términos ES base (se derivan por Polylang a cada idioma):
|
||||||
|
* actual = 6 (cartasemana) | pasada = 22 (carta-semana-pasada) | otras = 21
|
||||||
|
*
|
||||||
|
* Uso: CARTA=<es_id> php rotate_cartas.php (dry-run)
|
||||||
|
* APPLY=1 CARTA=<es_id> php rotate_cartas.php
|
||||||
|
*/
|
||||||
|
require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
|
||||||
|
$APPLY = getenv('APPLY') === '1';
|
||||||
|
$CARTA = (int)(getenv('CARTA') ?: 0);
|
||||||
|
if (!$CARTA) { fwrite(STDERR, "Falta CARTA=<es_id>\n"); exit(1); }
|
||||||
|
|
||||||
|
$actual_terms = pll_get_term_translations(6);
|
||||||
|
$pasada_terms = pll_get_term_translations(22);
|
||||||
|
$otras_terms = pll_get_term_translations(21);
|
||||||
|
$carta_tr = pll_get_post_translations($CARTA);
|
||||||
|
|
||||||
|
function cartas_en($terms) { // posts publish en cualquiera de esos términos (mismo idioma), por fecha desc
|
||||||
|
return get_posts(['post_type'=>'post','post_status'=>'publish','numberposts'=>-1,'fields'=>'ids',
|
||||||
|
'orderby'=>'date','order'=>'DESC','suppress_filters'=>true,
|
||||||
|
'tax_query'=>[['taxonomy'=>'category','field'=>'term_id','terms'=>array_values(array_filter($terms))]]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($actual_terms as $lang => $t_actual) {
|
||||||
|
$t_pasada = (int)($pasada_terms[$lang] ?? 0);
|
||||||
|
$t_otras = (int)($otras_terms[$lang] ?? 0);
|
||||||
|
$t_actual = (int)$t_actual;
|
||||||
|
$new = (int)($carta_tr[$lang] ?? 0);
|
||||||
|
|
||||||
|
// Conjunto de cartas de ESTE idioma (los términos ya son por idioma) por fecha desc.
|
||||||
|
$all = cartas_en([$t_actual, $t_pasada, $t_otras]);
|
||||||
|
// "semana pasada" = la más reciente que no es la nueva.
|
||||||
|
$prev = 0; foreach ($all as $pid) { if ($pid != $new) { $prev = $pid; break; } }
|
||||||
|
|
||||||
|
// Posts actualmente marcados como actual/pasada (conjunto pequeño a limpiar).
|
||||||
|
$flagged = get_posts(['post_type'=>'post','post_status'=>'any','numberposts'=>-1,'fields'=>'ids',
|
||||||
|
'suppress_filters'=>true,
|
||||||
|
'tax_query'=>[['taxonomy'=>'category','field'=>'term_id','terms'=>array_values(array_filter([$t_actual,$t_pasada]))]]]);
|
||||||
|
|
||||||
|
if ($APPLY) {
|
||||||
|
// 1) limpiar: quitar actual+pasada de cualquiera salvo los dos destinos.
|
||||||
|
foreach ($flagged as $pid) {
|
||||||
|
if ($pid == $new || $pid == $prev) continue;
|
||||||
|
wp_remove_object_terms($pid, array_values(array_filter([$t_actual,$t_pasada])), 'category');
|
||||||
|
}
|
||||||
|
// 2) NUEVA -> semana actual (y fuera de pasada). Mantener otras.
|
||||||
|
if ($new) {
|
||||||
|
wp_set_object_terms($new, [$t_actual], 'category', true);
|
||||||
|
if ($t_pasada) wp_remove_object_terms($new, [$t_pasada], 'category');
|
||||||
|
if ($t_otras) wp_set_object_terms($new, [$t_otras], 'category', true);
|
||||||
|
}
|
||||||
|
// 3) ANTERIOR -> semana pasada (y fuera de actual). Mantener otras.
|
||||||
|
if ($prev && $t_pasada) {
|
||||||
|
wp_set_object_terms($prev, [$t_pasada], 'category', true);
|
||||||
|
wp_remove_object_terms($prev, [$t_actual], 'category');
|
||||||
|
if ($t_otras) wp_set_object_terms($prev, [$t_otras], 'category', true);
|
||||||
|
}
|
||||||
|
clean_term_cache(array_filter([$t_actual,$t_pasada,$t_otras]), 'category');
|
||||||
|
}
|
||||||
|
|
||||||
|
$cleaned = count(array_diff($flagged, [$new, $prev]));
|
||||||
|
$tn = $new ? get_post($new) : null;
|
||||||
|
$tp = $prev ? get_post($prev) : null;
|
||||||
|
printf("%s: actual=#%d «%s» | pasada=#%d «%s» | degradadas a 'otras' %d post(s)%s\n",
|
||||||
|
strtoupper($lang), $new, $tn?mb_substr($tn->post_title,0,26):'-',
|
||||||
|
$prev, $tp?mb_substr($tp->post_title,0,26):'-',
|
||||||
|
$cleaned, $APPLY?'':' [DRY-RUN]');
|
||||||
|
}
|
||||||
|
echo $APPLY ? "APLICADO\n" : "DRY-RUN (APPLY=1 para aplicar)\n";
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* set_search_template.php (#8) — instala un template FSE 'search' con resultados
|
||||||
|
* COMPACTOS (rejilla de tarjetas título+fecha+extracto), igual que el 'archive'
|
||||||
|
* (#63), en vez del patrón genérico del tema que muestra los posts «todos seguidos».
|
||||||
|
*
|
||||||
|
* Reutiliza las clases fea-archive-grid / fea-archive-card (mismo CSS ya presente).
|
||||||
|
* Idempotente: crea el wp_template 'search' si no existe, o actualiza su contenido.
|
||||||
|
*
|
||||||
|
* Uso (dentro del contenedor / wp-cli):
|
||||||
|
* wp eval-file scripts/set_search_template.php # DRY-RUN
|
||||||
|
* APPLY=1 wp eval-file scripts/set_search_template.php # aplica
|
||||||
|
*/
|
||||||
|
$apply = getenv('APPLY') === '1';
|
||||||
|
$theme = get_stylesheet(); // twentytwentyfive
|
||||||
|
|
||||||
|
$content = <<<HTML
|
||||||
|
<!-- wp:template-part {"slug":"header","theme":"{$theme}"} /-->
|
||||||
|
|
||||||
|
<!-- wp:group {"tagName":"main","style":{"spacing":{"margin":{"top":"var:preset|spacing|60"}}},"layout":{"type":"constrained"}} -->
|
||||||
|
<main class="wp-block-group" style="margin-top:var(--wp--preset--spacing--60)"><!-- wp:query-title {"type":"search","align":"wide","fontSize":"x-large"} /-->
|
||||||
|
|
||||||
|
<!-- wp:spacer {"height":"var:preset|spacing|40"} -->
|
||||||
|
<div style="height:var(--wp--preset--spacing--40)" aria-hidden="true" class="wp-block-spacer"></div>
|
||||||
|
<!-- /wp:spacer -->
|
||||||
|
|
||||||
|
<!-- wp:query {"queryId":74,"query":{"perPage":12,"pages":0,"offset":0,"postType":"post","order":"desc","orderBy":"date","author":"","search":"","exclude":[],"sticky":"","inherit":true,"taxQuery":null,"parents":[]},"align":"wide","layout":{"type":"default"}} -->
|
||||||
|
<div class="wp-block-query alignwide"><!-- wp:group {"layout":{"type":"constrained"}} -->
|
||||||
|
<div class="wp-block-group"><!-- wp:query-no-results {"align":"wide","fontSize":"medium"} -->
|
||||||
|
<!-- wp:paragraph -->
|
||||||
|
<p>Lo siento, no se ha encontrado nada. Por favor, prueba a buscar con otras palabras clave.</p>
|
||||||
|
<!-- /wp:paragraph -->
|
||||||
|
<!-- /wp:query-no-results --></div>
|
||||||
|
<!-- /wp:group -->
|
||||||
|
|
||||||
|
<!-- wp:post-template {"align":"wide","className":"fea-archive-grid","style":{"spacing":{"blockGap":"1.5rem"}},"layout":{"type":"grid","columnCount":3}} -->
|
||||||
|
<!-- wp:group {"className":"fea-archive-card","layout":{"type":"constrained"}} -->
|
||||||
|
<div class="wp-block-group fea-archive-card"><!-- wp:post-title {"isLink":true,"className":"fea-archive-title","fontSize":"medium"} /-->
|
||||||
|
|
||||||
|
<!-- wp:post-date {"isLink":true,"className":"fea-archive-date","fontSize":"small"} /-->
|
||||||
|
|
||||||
|
<!-- wp:post-excerpt {"showMoreOnNewLine":false,"excerptLength":22,"className":"fea-archive-excerpt"} /--></div>
|
||||||
|
<!-- /wp:group -->
|
||||||
|
<!-- /wp:post-template -->
|
||||||
|
|
||||||
|
<!-- wp:spacer {"height":"var:preset|spacing|30"} -->
|
||||||
|
<div style="height:var(--wp--preset--spacing--30)" aria-hidden="true" class="wp-block-spacer"></div>
|
||||||
|
<!-- /wp:spacer -->
|
||||||
|
|
||||||
|
<!-- wp:group {"align":"full","style":{"spacing":{"margin":{"top":"var:preset|spacing|40","bottom":"var:preset|spacing|40"}}},"layout":{"type":"constrained"}} -->
|
||||||
|
<div class="wp-block-group alignfull" style="margin-top:var(--wp--preset--spacing--40);margin-bottom:var(--wp--preset--spacing--40)"><!-- wp:query-pagination {"align":"full","style":{"typography":{"fontStyle":"normal","fontWeight":"400"}},"layout":{"type":"flex","justifyContent":"space-between","flexWrap":"wrap"}} -->
|
||||||
|
<!-- wp:query-pagination-previous /-->
|
||||||
|
|
||||||
|
<!-- wp:query-pagination-numbers /-->
|
||||||
|
|
||||||
|
<!-- wp:query-pagination-next /-->
|
||||||
|
<!-- /wp:query-pagination --></div>
|
||||||
|
<!-- /wp:group --></div>
|
||||||
|
<!-- /wp:query --></main>
|
||||||
|
<!-- /wp:group -->
|
||||||
|
|
||||||
|
<!-- wp:template-part {"slug":"footer","theme":"{$theme}"} /-->
|
||||||
|
HTML;
|
||||||
|
|
||||||
|
$existing = get_posts(['post_type' => 'wp_template', 'name' => 'search', 'post_status' => 'any', 'posts_per_page' => 1]);
|
||||||
|
if ($existing) {
|
||||||
|
$id = $existing[0]->ID;
|
||||||
|
echo "Template 'search' existe (post $id) → " . ($apply ? "actualizando" : "[dry] actualizaría") . "\n";
|
||||||
|
if ($apply) wp_update_post(['ID' => $id, 'post_content' => $content]);
|
||||||
|
} else {
|
||||||
|
echo ($apply ? "Creando" : "[dry] crearía") . " wp_template 'search' (theme $theme)\n";
|
||||||
|
if ($apply) {
|
||||||
|
$id = wp_insert_post([
|
||||||
|
'post_type' => 'wp_template',
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'post_name' => 'search',
|
||||||
|
'post_title' => 'Search Results',
|
||||||
|
'post_content' => $content,
|
||||||
|
], true);
|
||||||
|
if (is_wp_error($id)) { echo "ERROR: " . $id->get_error_message() . "\n"; return; }
|
||||||
|
wp_set_object_terms($id, $theme, 'wp_theme');
|
||||||
|
echo " creado post $id\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (function_exists('wp_cache_flush')) wp_cache_flush();
|
||||||
|
echo ($apply ? "APLICADO" : "DRY-RUN") . "\n";
|
||||||
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,325 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
sync_translations_to_prod.py — Sincroniza contenido local a PROD reutilizando el
|
||||||
|
texto ya verificado en local.
|
||||||
|
|
||||||
|
Tiene dos modos:
|
||||||
|
1. Legado: sincroniza traducciones automáticas (`traduccion_origen`) suponiendo que
|
||||||
|
el post ES origen ya existe en prod con el mismo ID.
|
||||||
|
2. IDs preservados: clona posts locales a prod con ID explícito, copiando contenido,
|
||||||
|
slug, metas y categorías, y después reconstruye los grupos Polylang exactos.
|
||||||
|
|
||||||
|
El modo 2 es el que usa el handoff de la carta 46956 para evitar romper la
|
||||||
|
coincidencia local↔prod cuando prod va por detrás.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ── Config ───────────────────────────────────────────────────────────────────
|
||||||
|
WP_CONTAINER = os.environ.get("FEA_WP_CONTAINER", "wordpress-web")
|
||||||
|
DB_CONTAINER = os.environ.get("FEA_DB_CONTAINER", "wordpress-mysql")
|
||||||
|
DB_NAME = os.environ.get("FEA_DB_NAME", "wordpress_db")
|
||||||
|
DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
|
||||||
|
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
|
||||||
|
|
||||||
|
PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170")
|
||||||
|
PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF")
|
||||||
|
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-nuevo/wp-load.php")
|
||||||
|
PROD_HELPER = "/tmp/fea_translate_helper.php"
|
||||||
|
|
||||||
|
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
|
||||||
|
LOCAL_HELPER_DST = "/tmp/fea_translate_helper.php"
|
||||||
|
STATE_FILE = Path(os.environ.get("FEA_SYNC_STATE", "/tmp/feadulta-sync-state.json"))
|
||||||
|
LOG_FILE = Path(os.environ.get("FEA_SYNC_LOG", "/tmp/feadulta-sync.log"))
|
||||||
|
STATUS = os.environ.get("FEA_SYNC_STATUS", "draft")
|
||||||
|
|
||||||
|
# URLs absolutas del entorno local que NO deben llegar a prod (issue #91): el
|
||||||
|
# post_content local arrastra el host de Tailscale con prefijo /fea; en prod la
|
||||||
|
# instalación cuelga de la raíz. Se reescriben al desplegar para no dejar enlaces
|
||||||
|
# rotos (Tailscale es inaccesible para los visitantes).
|
||||||
|
LOCAL_BASE = os.environ.get("FEA_LOCAL_BASE", "https://farmer.taild3aaf6.ts.net/fea")
|
||||||
|
PROD_BASE = os.environ.get("FEA_PROD_BASE", "https://wp-nuevo.feadulta.com")
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg: str) -> None:
|
||||||
|
line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}"
|
||||||
|
print(line, flush=True)
|
||||||
|
try:
|
||||||
|
LOG_FILE.open("a", encoding="utf-8").write(line + "\n")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def sh(cmd: list[str], *, stdin: str | None = None, timeout: int = 120) -> str:
|
||||||
|
r = subprocess.run(cmd, input=stdin, capture_output=True, text=True, timeout=timeout)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError(f"cmd falló ({r.returncode}): {' '.join(cmd[:3])}…\n{r.stderr.strip()[:400]}")
|
||||||
|
return r.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def parse_csv_ints(raw: str) -> list[int]:
|
||||||
|
out: list[int] = []
|
||||||
|
for part in raw.split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if part.isdigit():
|
||||||
|
out.append(int(part))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def localize_urls(text: str | None) -> tuple[str, int]:
|
||||||
|
"""Reescribe URLs absolutas local→prod en el contenido antes de subirlo.
|
||||||
|
|
||||||
|
Equivale al search-replace `farmer.taild3aaf6.ts.net/fea` → `wp-nuevo.feadulta.com`
|
||||||
|
pero aplicado en origen, así el contenido llega ya correcto a prod (issue #91).
|
||||||
|
Devuelve (texto, nº de reemplazos).
|
||||||
|
"""
|
||||||
|
if not text or not LOCAL_BASE:
|
||||||
|
return text or "", 0
|
||||||
|
n = text.count(LOCAL_BASE)
|
||||||
|
return (text.replace(LOCAL_BASE, PROD_BASE), n) if n else (text, 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Local ────────────────────────────────────────────────────────────────────
|
||||||
|
_local_ready = False
|
||||||
|
|
||||||
|
|
||||||
|
def local_helper(subcmd: str, *args: str) -> str:
|
||||||
|
global _local_ready
|
||||||
|
if not _local_ready:
|
||||||
|
sh(["docker", "cp", str(HELPER_SRC), f"{WP_CONTAINER}:{LOCAL_HELPER_DST}"])
|
||||||
|
_local_ready = True
|
||||||
|
return sh(["docker", "exec", "-i", WP_CONTAINER, "php", LOCAL_HELPER_DST, subcmd, *args], timeout=180)
|
||||||
|
|
||||||
|
|
||||||
|
def local_read(post_id: int) -> dict:
|
||||||
|
return json.loads(local_helper("read", str(post_id)))
|
||||||
|
|
||||||
|
|
||||||
|
def local_read_full(post_id: int) -> dict:
|
||||||
|
return json.loads(local_helper("read_full", str(post_id)))
|
||||||
|
|
||||||
|
|
||||||
|
def local_translation_pairs() -> list[tuple[int, int]]:
|
||||||
|
q = ("SELECT post_id, meta_value FROM wp_postmeta "
|
||||||
|
"WHERE meta_key='traduccion_origen' ORDER BY CAST(meta_value AS UNSIGNED), post_id;")
|
||||||
|
out = sh(["docker", "exec", DB_CONTAINER, "mysql", f"-u{DB_USER}", f"-p{DB_PASS}",
|
||||||
|
DB_NAME, "-N", "-e", q])
|
||||||
|
pairs = []
|
||||||
|
for line in out.splitlines():
|
||||||
|
parts = line.split("\t")
|
||||||
|
if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
|
||||||
|
pairs.append((int(parts[0]), int(parts[1])))
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
|
def carta_article_ids(carta_id: int) -> list[int]:
|
||||||
|
q = ("SELECT post_id FROM wp_postmeta "
|
||||||
|
f"WHERE meta_key='_carta_id' AND meta_value='{carta_id}' ORDER BY post_id;")
|
||||||
|
out = sh(["docker", "exec", DB_CONTAINER, "mysql", f"-u{DB_USER}", f"-p{DB_PASS}",
|
||||||
|
DB_NAME, "-N", "-e", q])
|
||||||
|
return [int(x) for x in out.split() if x.isdigit()]
|
||||||
|
|
||||||
|
|
||||||
|
def collect_related_posts(seed_ids: list[int]) -> tuple[dict[int, dict], list[dict[str, int]]]:
|
||||||
|
posts: dict[int, dict] = {}
|
||||||
|
groups: dict[tuple[tuple[str, int], ...], dict[str, int]] = {}
|
||||||
|
|
||||||
|
for seed in seed_ids:
|
||||||
|
info = local_read_full(seed)
|
||||||
|
posts[seed] = info
|
||||||
|
raw_group = info.get("translations") or {}
|
||||||
|
group = {
|
||||||
|
lang: int(pid)
|
||||||
|
for lang, pid in raw_group.items()
|
||||||
|
if str(pid).isdigit()
|
||||||
|
}
|
||||||
|
if not group:
|
||||||
|
lang = info.get("lang") or "es"
|
||||||
|
group = {lang: seed}
|
||||||
|
sig = tuple(sorted(group.items()))
|
||||||
|
groups[sig] = group
|
||||||
|
|
||||||
|
all_ids = sorted({pid for group in groups.values() for pid in group.values()})
|
||||||
|
for pid in all_ids:
|
||||||
|
if pid not in posts:
|
||||||
|
posts[pid] = local_read_full(pid)
|
||||||
|
|
||||||
|
return posts, list(groups.values())
|
||||||
|
|
||||||
|
|
||||||
|
# ── Prod ─────────────────────────────────────────────────────────────────────
|
||||||
|
_prod_ready = False
|
||||||
|
|
||||||
|
|
||||||
|
def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
|
||||||
|
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||||
|
return sh(cmd, stdin=stdin, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str:
|
||||||
|
global _prod_ready
|
||||||
|
if not _prod_ready:
|
||||||
|
_ssh(f"cat > {PROD_HELPER}", stdin=HELPER_SRC.read_text(encoding="utf-8"))
|
||||||
|
_prod_ready = True
|
||||||
|
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
|
||||||
|
return _ssh(inner, stdin=stdin, timeout=180)
|
||||||
|
|
||||||
|
|
||||||
|
def prod_create(origin: int, lang: str, title: str, content: str) -> int:
|
||||||
|
content, n = localize_urls(content)
|
||||||
|
if n:
|
||||||
|
log(f" localize origin={origin} [{lang}]: {n} URL(s) Tailscale→prod")
|
||||||
|
payload = json.dumps({"title": title, "content": content, "model": "google/gemma-4-e4b (sync)"})
|
||||||
|
out = prod_helper("create", str(origin), lang, STATUS, stdin=payload).strip()
|
||||||
|
return int(out)
|
||||||
|
|
||||||
|
|
||||||
|
def prod_clone(post: dict) -> int:
|
||||||
|
content, n1 = localize_urls(post.get("content", ""))
|
||||||
|
excerpt, n2 = localize_urls(post.get("excerpt", ""))
|
||||||
|
if n1 or n2:
|
||||||
|
log(f" localize #{post['id']} [{post.get('lang','?')}]: {n1 + n2} URL(s) Tailscale→prod")
|
||||||
|
payload = {
|
||||||
|
"title": post["title"],
|
||||||
|
"content": content,
|
||||||
|
"excerpt": excerpt,
|
||||||
|
"slug": post.get("slug", ""),
|
||||||
|
"type": post.get("type", "post"),
|
||||||
|
"author": post.get("author", 1),
|
||||||
|
"date": post.get("date"),
|
||||||
|
"date_gmt": post.get("date_gmt"),
|
||||||
|
"status": post.get("status"),
|
||||||
|
"cats": post.get("cats", []),
|
||||||
|
"cat_slugs": post.get("cat_slugs", []),
|
||||||
|
"meta": post.get("meta", {}),
|
||||||
|
}
|
||||||
|
out = prod_helper("clone", str(post["id"]), post["lang"], STATUS, stdin=json.dumps(payload)).strip()
|
||||||
|
return int(out)
|
||||||
|
|
||||||
|
|
||||||
|
def prod_save_group(group: dict[str, int]) -> dict[str, int]:
|
||||||
|
out = prod_helper("save_translations", stdin=json.dumps({"translations": group})).strip()
|
||||||
|
return json.loads(out)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Estado ───────────────────────────────────────────────────────────────────
|
||||||
|
def load_state() -> dict:
|
||||||
|
if STATE_FILE.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(STATE_FILE.read_text())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
return {"done": {}, "errors": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(state: dict) -> None:
|
||||||
|
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Modo IDs preservados ─────────────────────────────────────────────────────
|
||||||
|
def deploy_fixed_ids(seed_ids: list[int], *, keep_existing: set[int], dry_run: bool) -> int:
|
||||||
|
posts, groups = collect_related_posts(seed_ids)
|
||||||
|
clone_ids = [pid for pid in posts if pid not in keep_existing]
|
||||||
|
clone_ids.sort(key=lambda pid: (0 if posts[pid].get("lang") == "es" else 1, pid))
|
||||||
|
|
||||||
|
log(f"Plan IDs preservados: seeds={seed_ids} clone={len(clone_ids)} grupos={len(groups)} status={STATUS}")
|
||||||
|
if keep_existing:
|
||||||
|
log(f"IDs marcados como ya existentes en prod: {sorted(keep_existing)}")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
for pid in clone_ids:
|
||||||
|
p = posts[pid]
|
||||||
|
log(f" CLONE #{pid} [{p.get('lang','?')}] slug={p.get('slug','')} cats={len(p.get('cat_slugs', []))}")
|
||||||
|
for group in groups:
|
||||||
|
log(f" GROUP {group}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
for pid in clone_ids:
|
||||||
|
p = posts[pid]
|
||||||
|
new_id = prod_clone(p)
|
||||||
|
log(f" clone #{pid} [{p.get('lang','?')}] → prod #{new_id} «{p['title'][:45]}»")
|
||||||
|
|
||||||
|
for group in groups:
|
||||||
|
saved = prod_save_group(group)
|
||||||
|
log(f" group enlazado {saved}")
|
||||||
|
|
||||||
|
log("FIN sync IDs preservados.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main legado ──────────────────────────────────────────────────────────────
|
||||||
|
def legacy_sync(limit: int, origin: int) -> int:
|
||||||
|
state = load_state()
|
||||||
|
pairs = local_translation_pairs()
|
||||||
|
if origin:
|
||||||
|
pairs = [p for p in pairs if p[1] == origin]
|
||||||
|
log(f"Traducciones locales a sincronizar: {len(pairs)} (status={STATUS})")
|
||||||
|
|
||||||
|
n_ok = n_skip = n_err = 0
|
||||||
|
for tid, src_origin in pairs:
|
||||||
|
if limit and (n_ok + n_err) >= limit:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
t = local_read(tid)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log(f" local read #{tid} ERROR: {exc}")
|
||||||
|
n_err += 1
|
||||||
|
continue
|
||||||
|
lang = t.get("lang", "")
|
||||||
|
if lang in ("", "es"):
|
||||||
|
continue
|
||||||
|
key = f"{src_origin}:{lang}"
|
||||||
|
if key in state["done"]:
|
||||||
|
n_skip += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
new_id = prod_create(src_origin, lang, t["title"], t["content"])
|
||||||
|
state["done"][key] = new_id
|
||||||
|
save_state(state)
|
||||||
|
n_ok += 1
|
||||||
|
log(f" {key} → prod #{new_id} «{t['title'][:45]}»")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
state["errors"][key] = str(exc)[:300]
|
||||||
|
save_state(state)
|
||||||
|
n_err += 1
|
||||||
|
log(f" {key} ERROR: {exc}")
|
||||||
|
|
||||||
|
save_state(state)
|
||||||
|
log(f"FIN sync legado. nuevos={n_ok} saltados={n_skip} errores={n_err}. Estado: {STATE_FILE}")
|
||||||
|
log("Recuerda en prod: ejecutar remap_translation_cats.php si alguna quedó sin categoría traducida.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="Sincroniza contenido local→prod reutilizando el texto local.")
|
||||||
|
ap.add_argument("--limit", type=int, default=0, help="Modo legado: máximo de traducciones a sincronizar.")
|
||||||
|
ap.add_argument("--origin", type=int, default=0, help="Modo legado: solo traducciones de este ES.")
|
||||||
|
ap.add_argument("--carta", type=int, default=0, help="Modo IDs preservados: carta ES y todo su cluster.")
|
||||||
|
ap.add_argument("--ids", default="", help="Modo IDs preservados: lista CSV de posts semilla a clonar/enlazar.")
|
||||||
|
ap.add_argument("--keep-existing", default="", help="IDs que ya existen en prod y no deben clonarse.")
|
||||||
|
ap.add_argument("--dry-run", action="store_true", help="Solo muestra el plan; no toca prod.")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
seed_ids: list[int] = []
|
||||||
|
if args.carta:
|
||||||
|
seed_ids = [args.carta, *carta_article_ids(args.carta)]
|
||||||
|
elif args.ids:
|
||||||
|
seed_ids = parse_csv_ints(args.ids)
|
||||||
|
|
||||||
|
if seed_ids:
|
||||||
|
keep_existing = set(parse_csv_ints(args.keep_existing))
|
||||||
|
return deploy_fixed_ids(seed_ids, keep_existing=keep_existing, dry_run=args.dry_run)
|
||||||
|
|
||||||
|
return legacy_sync(args.limit, args.origin)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -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,38 @@
|
|||||||
|
<?php
|
||||||
|
// Categorías estructurales de contenido → traducciones (es_term_id => [en,fr,it,pt])
|
||||||
|
$D = [
|
||||||
|
410 => ['Nuevo Testamento', 'New Testament','Nouveau Testament','Nuovo Testamento','Novo Testamento'],
|
||||||
|
411 => ['Antiguo Testamento','Old Testament','Ancien Testament','Antico Testamento','Antigo Testamento'],
|
||||||
|
49 => ['Adviento y Navidad','Advent and Christmas','Avent et Noël','Avvento e Natale','Advento e Natal'],
|
||||||
|
12 => ['In memoriam','In Memoriam','In Memoriam','In Memoriam','In Memoriam'],
|
||||||
|
1651 => ['Noticias','News','Actualités','Notizie','Notícias'],
|
||||||
|
61 => ['Comunidades cristianas','Christian Communities','Communautés chrétiennes','Comunità cristiane','Comunidades cristãs'],
|
||||||
|
23 => ['Cartas que nos llegan','Letters We Receive','Lettres reçues','Lettere che riceviamo','Cartas que recebemos'],
|
||||||
|
39 => ['Temas','Topics','Thèmes','Temi','Temas'],
|
||||||
|
27 => ['Índice cronológico','Chronological Index','Index chronologique','Indice cronologico','Índice cronológico'],
|
||||||
|
63 => ['EFFA','EFFA','EFFA','EFFA','EFFA'],
|
||||||
|
];
|
||||||
|
$LangSlug = ['en'=>'en','fr'=>'fr','it'=>'it','pt'=>'pt'];
|
||||||
|
$created=0;
|
||||||
|
foreach ($D as $es_id => $names) {
|
||||||
|
$es_term = get_term($es_id, 'category');
|
||||||
|
if (!$es_term || is_wp_error($es_term)) { echo "skip $es_id (no existe)\n"; continue; }
|
||||||
|
$existing = pll_get_term_translations($es_id);
|
||||||
|
$group = $existing;
|
||||||
|
$i = 1;
|
||||||
|
foreach (['en','fr','it','pt'] as $L) {
|
||||||
|
$i++;
|
||||||
|
if (!empty($existing[$L])) { $group[$L]=$existing[$L]; continue; }
|
||||||
|
$name = $names[$i-1];
|
||||||
|
$slug = sanitize_title($name).'-'.$L;
|
||||||
|
$res = wp_insert_term($name, 'category', ['slug'=>$slug]);
|
||||||
|
if (is_wp_error($res)) { echo " $es_id $L ERROR: ".$res->get_error_message()."\n"; continue; }
|
||||||
|
$tid = $res['term_id'];
|
||||||
|
pll_set_term_language($tid, $L);
|
||||||
|
$group[$L] = $tid;
|
||||||
|
$created++;
|
||||||
|
}
|
||||||
|
pll_save_term_translations($group);
|
||||||
|
echo "#$es_id ".$names[0]." → ".implode(",", array_map(function($k,$v){return $k.":".$v;}, array_keys($group),$group))."\n";
|
||||||
|
}
|
||||||
|
echo "términos traducidos creados: $created\n";
|
||||||
@@ -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()
|
||||||
Executable
+76
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ============================================================================
|
||||||
|
# translate_gap.sh — Traduce el "gap" marzo→ahora de feadulta a EN/FR/IT/PT.
|
||||||
|
#
|
||||||
|
# Hace TODO de una vez: comprobaciones previas, traduce las 15 cartas semanales
|
||||||
|
# (marzo a junio) + sus artículos con Gemma local, remapea categorías y enseña
|
||||||
|
# el recuento final. Idempotente y REANUDABLE: re-ejecutar es seguro (salta lo
|
||||||
|
# ya traducido). NO publica nada (todo queda en borrador / draft).
|
||||||
|
#
|
||||||
|
# USO (un solo comando, en segundo plano):
|
||||||
|
# nohup bash scripts/translate_gap.sh > /tmp/feadulta-gap.out 2>&1 &
|
||||||
|
# Y para ver el progreso:
|
||||||
|
# tail -f /tmp/feadulta-gap.log
|
||||||
|
#
|
||||||
|
# Ver issue rafa/feadulta#75.
|
||||||
|
# ============================================================================
|
||||||
|
set -u
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.." || { echo "No puedo entrar en el repo"; exit 1; }
|
||||||
|
|
||||||
|
LOG=/tmp/feadulta-gap.log
|
||||||
|
LANGS="en,fr,it,pt"
|
||||||
|
# Cartas del gap (de más reciente a más antigua). Override opcional: CARTAS="45018" bash ...
|
||||||
|
CARTAS="${CARTAS:-45018 44997 44975 44230 44229 44228 44090 44089 44088 44087 44086 44085 44084 44083 42590}"
|
||||||
|
|
||||||
|
ts() { date '+%Y-%m-%d %H:%M:%S'; }
|
||||||
|
say() { echo "[$(ts)] $*" | tee -a "$LOG"; }
|
||||||
|
|
||||||
|
say "================ INICIO batch del gap (draft) ================"
|
||||||
|
|
||||||
|
# 1) LM Studio + Gemma cargado
|
||||||
|
say "Preflight 1/2: LM Studio / Gemma..."
|
||||||
|
if ! curl -s --max-time 10 http://172.19.128.1:1234/v1/models 2>/dev/null | grep -q 'gemma-4-e4b'; then
|
||||||
|
say "ERROR: LM Studio no responde o 'google/gemma-4-e4b' no está cargado."
|
||||||
|
say " -> En Windows: abre LM Studio, carga 'google/gemma-4-e4b', server en 0.0.0.0:1234."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
say " OK: Gemma disponible."
|
||||||
|
|
||||||
|
# 2) Contenedores docker arriba
|
||||||
|
say "Preflight 2/2: contenedores docker..."
|
||||||
|
for cnt in wordpress-web wordpress-mysql; do
|
||||||
|
if ! docker ps --format '{{.Names}}' | grep -qx "$cnt"; then
|
||||||
|
say "ERROR: el contenedor '$cnt' no está arriba. Arranca el stack (docker compose up -d) y reintenta."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
say " OK: wordpress-web y wordpress-mysql arriba."
|
||||||
|
|
||||||
|
# 3) Traducir cada carta + sus artículos
|
||||||
|
N=$(echo $CARTAS | wc -w)
|
||||||
|
i=0
|
||||||
|
for c in $CARTAS; do
|
||||||
|
i=$((i+1))
|
||||||
|
say "=== Carta $c ($i/$N) -> $LANGS (draft) ==="
|
||||||
|
python3 scripts/translate_post.py --carta "$c" --langs "$LANGS" --status draft 2>&1 | tee -a "$LOG"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 4) Remap de categorías (idempotente, sin Gemma): mete cada traducción en la
|
||||||
|
# categoría de su idioma (arregla el archivo de carta por idioma).
|
||||||
|
say "Remapeando categorías de todas las traducciones..."
|
||||||
|
docker cp scripts/remap_translation_cats.php wordpress-web:/tmp/remap_translation_cats.php >/dev/null 2>&1
|
||||||
|
docker exec wordpress-web php /tmp/remap_translation_cats.php 2>&1 | tee -a "$LOG"
|
||||||
|
|
||||||
|
# 5) Recuento final por idioma
|
||||||
|
say "Recuento final de traducciones por idioma (meta traduccion_origen):"
|
||||||
|
docker exec wordpress-mysql mysql -uwordpress_user -pwordpress_pass wordpress_db -N -e "
|
||||||
|
SELECT t.slug, COUNT(*) FROM wp_postmeta m
|
||||||
|
JOIN wp_term_relationships tr ON m.post_id=tr.object_id
|
||||||
|
JOIN wp_term_taxonomy tt ON tr.term_taxonomy_id=tt.term_taxonomy_id AND tt.taxonomy='language'
|
||||||
|
JOIN wp_terms t ON tt.term_id=t.term_id
|
||||||
|
WHERE m.meta_key='traduccion_origen' GROUP BY t.slug;" 2>/dev/null | tee -a "$LOG"
|
||||||
|
|
||||||
|
say "================ FIN batch del gap ================"
|
||||||
|
say "Todo en DRAFT. No se ha publicado nada. Avisa a Rafa para revisar antes de publicar."
|
||||||
|
say "Log completo: $LOG"
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Traduce ES->EN con Claude Haiku 4.5 vía API directa. Prueba de coste/calidad.
|
||||||
|
|
||||||
|
Lee la ANTHROPIC_API_KEY de portfolio-tracker/.env (la misma que usa el
|
||||||
|
portfolio tracker para trade setups). Reporta tokens reales de la API.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Cargar API key del .env de portfolio-tracker sin pisar el entorno existente.
|
||||||
|
ENV_PATH = "/home/rafa/portfolio-tracker/.env"
|
||||||
|
if "ANTHROPIC_API_KEY" not in os.environ:
|
||||||
|
for line in open(ENV_PATH):
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith("ANTHROPIC_API_KEY="):
|
||||||
|
os.environ["ANTHROPIC_API_KEY"] = line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||||
|
break
|
||||||
|
|
||||||
|
import anthropic
|
||||||
|
|
||||||
|
MODEL = "claude-haiku-4-5"
|
||||||
|
|
||||||
|
LANG_NAMES = {"en": "English", "fr": "French (français)",
|
||||||
|
"it": "Italian (italiano)", "pt": "Portuguese (português)"}
|
||||||
|
|
||||||
|
|
||||||
|
def system_prompt(lang: str) -> str:
|
||||||
|
target = LANG_NAMES[lang]
|
||||||
|
return (
|
||||||
|
f"Eres un traductor profesional de textos religiosos cristianos "
|
||||||
|
f"(espiritualidad y teología católica). Traduce del español al {target}. "
|
||||||
|
f"REGLAS ESTRICTAS:\n"
|
||||||
|
f"1. Conserva EXACTAMENTE el marcado HTML (etiquetas y atributos) y los "
|
||||||
|
f"shortcodes entre [ ] y {{ }}. No los traduzcas ni los reordenes.\n"
|
||||||
|
f"2. NO traduzcas las referencias bíblicas ni sus abreviaturas "
|
||||||
|
f"(p.ej. 'Jn 3, 16', 'Mt 5'). Déjalas idénticas.\n"
|
||||||
|
f"3. Conserva los nombres propios de persona y lugar (salvo exónimos establecidos).\n"
|
||||||
|
f"4. Términos litúrgicos correctos (p.ej. 'Cuaresma' = Lent/Carême/Quaresima/Quaresma; "
|
||||||
|
f"NO inventes palabras).\n"
|
||||||
|
f"5. Traducción FIEL: no resumas, no añadas, no comentes.\n"
|
||||||
|
f"6. Devuelve SOLO la traducción entre las marcas <<<INI>>> y <<<FIN>>>, sin nada más."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract(text: str) -> str:
|
||||||
|
# Coge el bloque <<<INI>>>...<<<FIN>>> de contenido MÁS LARGO (robusto al
|
||||||
|
# bug del runner local, donde el modelo a veces re-menciona las marcas).
|
||||||
|
blocks = re.findall(r"<<<INI>>>(.*?)<<<FIN>>>", text, re.S)
|
||||||
|
out = max(blocks, key=len).strip() if blocks else text.strip()
|
||||||
|
out = re.sub(r"^```[a-z]*\n?", "", out)
|
||||||
|
out = re.sub(r"\n?```$", "", out)
|
||||||
|
return out.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def translate(text: str, lang: str, *, is_title: bool = False) -> tuple[str, object]:
|
||||||
|
client = anthropic.Anthropic()
|
||||||
|
kind = "el TÍTULO" if is_title else "el texto"
|
||||||
|
user = (
|
||||||
|
f"Traduce {kind} que va entre las marcas. "
|
||||||
|
f"Debe quedar en {LANG_NAMES[lang]} de forma natural.\n"
|
||||||
|
f"<<<INI>>>{text}<<<FIN>>>"
|
||||||
|
)
|
||||||
|
max_tokens = max(1024, int(len(text) * 0.9))
|
||||||
|
resp = client.messages.create(
|
||||||
|
model=MODEL,
|
||||||
|
max_tokens=min(max_tokens, 16000),
|
||||||
|
system=system_prompt(lang),
|
||||||
|
messages=[{"role": "user", "content": user}],
|
||||||
|
)
|
||||||
|
body = "".join(b.text for b in resp.content if b.type == "text")
|
||||||
|
return extract(body), resp.usage
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
path = sys.argv[1] if len(sys.argv) > 1 else "/tmp/orig_es.html"
|
||||||
|
lang = sys.argv[2] if len(sys.argv) > 2 else "en"
|
||||||
|
src = open(path).read()
|
||||||
|
out, usage = translate(src, lang)
|
||||||
|
open("/tmp/trad_haiku.html", "w").write(out)
|
||||||
|
cost = usage.input_tokens / 1e6 * 1.0 + usage.output_tokens / 1e6 * 5.0
|
||||||
|
print(f"MODEL={MODEL} lang={lang}")
|
||||||
|
print(f"input_tokens={usage.input_tokens} output_tokens={usage.output_tokens}")
|
||||||
|
print(f"coste_articulo=${cost:.5f}")
|
||||||
|
print(f"chars_in={len(src)} chars_out={len(out)}")
|
||||||
|
print("--- primeras 500 car ---")
|
||||||
|
print(out[:500])
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* translate_lectura_titles.php (issue Gitea #140)
|
||||||
|
*
|
||||||
|
* Traduce SOLO el nombre del libro bíblico en el INICIO del post_title de los
|
||||||
|
* posts no-ES (EN/FR/IT/PT) cuyo título es una cita bíblica «<LIBRO> <num>, ...».
|
||||||
|
* El cuerpo ya está traducido; esto es title-only.
|
||||||
|
*
|
||||||
|
* - Determinista: mapa fijo de libros ES -> {en,fr,it,pt}.
|
||||||
|
* - Idempotente: si el token inicial ya está en el idioma destino, no toca nada.
|
||||||
|
* - Seguro: exige número tras el libro (excluye «Juan Pablo II», «Domingo 30...»,
|
||||||
|
* etc. — DOMINGO/SEMANA no son libros, no están en el mapa).
|
||||||
|
* - Cotejo insensible a acentos (fold a ASCII-mayúsculas) para casar variantes;
|
||||||
|
* el valor canónico por idioma garantiza que ES==destino sea un no-op.
|
||||||
|
*
|
||||||
|
* Uso (local): docker exec wordpress-web php /var/www/html/scripts/... (o vía cwd)
|
||||||
|
* php scripts/translate_lectura_titles.php # dry-run + reporte
|
||||||
|
* APPLY=1 php scripts/translate_lectura_titles.php # aplica
|
||||||
|
* Prod: FEA_WP_LOAD=/web/wp-nuevo/wp-load.php php translate_lectura_titles.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
error_reporting(E_ALL & ~E_DEPRECATED & ~E_NOTICE);
|
||||||
|
|
||||||
|
$WP_LOAD = getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
|
||||||
|
if (!file_exists($WP_LOAD)) {
|
||||||
|
fwrite(STDERR, "No encuentro wp-load.php en $WP_LOAD\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
define('WP_USE_THEMES', false);
|
||||||
|
require $WP_LOAD;
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$APPLY = getenv('APPLY') === '1';
|
||||||
|
$LANGS = ['en', 'fr', 'it', 'pt'];
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mapa de libros: clave en español (display) => [en, fr, it, pt] (Title case canónico).
|
||||||
|
* El cotejo es accent-insensitive; los valores destino son los litúrgicos católicos.
|
||||||
|
*/
|
||||||
|
$BOOKS = [
|
||||||
|
// --- Antiguo Testamento ---
|
||||||
|
'Génesis' => ['Genesis', 'Genèse', 'Genesi', 'Gênesis'],
|
||||||
|
'Éxodo' => ['Exodus', 'Exode', 'Esodo', 'Êxodo'],
|
||||||
|
'Levítico' => ['Leviticus', 'Lévitique', 'Levitico', 'Levítico'],
|
||||||
|
'Números' => ['Numbers', 'Nombres', 'Numeri', 'Números'],
|
||||||
|
'Deuteronomio' => ['Deuteronomy', 'Deutéronome', 'Deuteronomio', 'Deuteronômio'],
|
||||||
|
'Josué' => ['Joshua', 'Josué', 'Giosuè', 'Josué'],
|
||||||
|
'Jueces' => ['Judges', 'Juges', 'Giudici', 'Juízes'],
|
||||||
|
'Rut' => ['Ruth', 'Ruth', 'Rut', 'Rute'],
|
||||||
|
'Samuel' => ['Samuel', 'Samuel', 'Samuele', 'Samuel'],
|
||||||
|
'Reyes' => ['Kings', 'Rois', 'Re', 'Reis'],
|
||||||
|
'Crónicas' => ['Chronicles', 'Chroniques', 'Cronache', 'Crônicas'],
|
||||||
|
'Esdras' => ['Ezra', 'Esdras', 'Esdra', 'Esdras'],
|
||||||
|
'Nehemías' => ['Nehemiah', 'Néhémie', 'Neemia', 'Neemias'],
|
||||||
|
'Tobías' => ['Tobit', 'Tobie', 'Tobia', 'Tobias'],
|
||||||
|
'Judit' => ['Judith', 'Judith', 'Giuditta', 'Judite'],
|
||||||
|
'Ester' => ['Esther', 'Esther', 'Ester', 'Ester'],
|
||||||
|
'Macabeos' => ['Maccabees', 'Maccabées', 'Maccabei', 'Macabeus'],
|
||||||
|
'Job' => ['Job', 'Job', 'Giobbe', 'Jó'],
|
||||||
|
'Salmos' => ['Psalms', 'Psaumes', 'Salmi', 'Salmos'],
|
||||||
|
'Salmo' => ['Psalm', 'Psaume', 'Salmo', 'Salmo'],
|
||||||
|
'Proverbios' => ['Proverbs', 'Proverbes', 'Proverbi', 'Provérbios'],
|
||||||
|
'Eclesiastés' => ['Ecclesiastes', 'Ecclésiaste', 'Ecclesiaste', 'Eclesiastes'],
|
||||||
|
'Eclesiástico' => ['Ecclesiasticus', 'Siracide', 'Siracide', 'Eclesiástico'],
|
||||||
|
'Sabiduría' => ['Wisdom', 'Sagesse', 'Sapienza', 'Sabedoria'],
|
||||||
|
'Isaías' => ['Isaiah', 'Isaïe', 'Isaia', 'Isaías'],
|
||||||
|
'Jeremías' => ['Jeremiah', 'Jérémie', 'Geremia', 'Jeremias'],
|
||||||
|
'Lamentaciones' => ['Lamentations', 'Lamentations', 'Lamentazioni', 'Lamentações'],
|
||||||
|
'Baruc' => ['Baruch', 'Baruch', 'Baruc', 'Baruc'],
|
||||||
|
'Ezequiel' => ['Ezekiel', 'Ézéchiel', 'Ezechiele', 'Ezequiel'],
|
||||||
|
'Daniel' => ['Daniel', 'Daniel', 'Daniele', 'Daniel'],
|
||||||
|
'Oseas' => ['Hosea', 'Osée', 'Osea', 'Oseias'],
|
||||||
|
'Joel' => ['Joel', 'Joël', 'Gioele', 'Joel'],
|
||||||
|
'Amós' => ['Amos', 'Amos', 'Amos', 'Amós'],
|
||||||
|
'Abdías' => ['Obadiah', 'Abdias', 'Abdia', 'Abdias'],
|
||||||
|
'Jonás' => ['Jonah', 'Jonas', 'Giona', 'Jonas'],
|
||||||
|
'Miqueas' => ['Micah', 'Michée', 'Michea', 'Miqueias'],
|
||||||
|
'Nahúm' => ['Nahum', 'Nahum', 'Naum', 'Naum'],
|
||||||
|
'Habacuc' => ['Habakkuk', 'Habacuc', 'Abacuc', 'Habacuc'],
|
||||||
|
'Sofonías' => ['Zephaniah', 'Sophonie', 'Sofonia', 'Sofonias'],
|
||||||
|
'Ageo' => ['Haggai', 'Aggée', 'Aggeo', 'Ageu'],
|
||||||
|
'Zacarías' => ['Zechariah', 'Zacharie', 'Zaccaria', 'Zacarias'],
|
||||||
|
'Malaquías' => ['Malachi', 'Malachie', 'Malachia', 'Malaquias'],
|
||||||
|
// --- Nuevo Testamento ---
|
||||||
|
'Mateo' => ['Matthew', 'Matthieu', 'Matteo', 'Mateus'],
|
||||||
|
'Marcos' => ['Mark', 'Marc', 'Marco', 'Marcos'],
|
||||||
|
'Lucas' => ['Luke', 'Luc', 'Luca', 'Lucas'],
|
||||||
|
'Juan' => ['John', 'Jean', 'Giovanni', 'João'],
|
||||||
|
'Hechos' => ['Acts', 'Actes', 'Atti', 'Atos'],
|
||||||
|
'Romanos' => ['Romans', 'Romains', 'Romani', 'Romanos'],
|
||||||
|
'Corintios' => ['Corinthians', 'Corinthiens', 'Corinzi', 'Coríntios'],
|
||||||
|
'Gálatas' => ['Galatians', 'Galates', 'Galati', 'Gálatas'],
|
||||||
|
'Efesios' => ['Ephesians', 'Éphésiens', 'Efesini', 'Efésios'],
|
||||||
|
'Filipenses' => ['Philippians', 'Philippiens', 'Filippesi', 'Filipenses'],
|
||||||
|
'Colosenses' => ['Colossians', 'Colossiens', 'Colossesi', 'Colossenses'],
|
||||||
|
'Tesalonicenses' => ['Thessalonians', 'Thessaloniciens', 'Tessalonicesi', 'Tessalonicenses'],
|
||||||
|
'Timoteo' => ['Timothy', 'Timothée', 'Timoteo', 'Timóteo'],
|
||||||
|
'Tito' => ['Titus', 'Tite', 'Tito', 'Tito'],
|
||||||
|
'Filemón' => ['Philemon', 'Philémon', 'Filemone', 'Filêmon'],
|
||||||
|
'Hebreos' => ['Hebrews', 'Hébreux', 'Ebrei', 'Hebreus'],
|
||||||
|
'Santiago' => ['James', 'Jacques', 'Giacomo', 'Tiago'],
|
||||||
|
'Pedro' => ['Peter', 'Pierre', 'Pietro', 'Pedro'],
|
||||||
|
'Judas' => ['Jude', 'Jude', 'Giuda', 'Judas'],
|
||||||
|
'Apocalipsis' => ['Revelation', 'Apocalypse', 'Apocalisse', 'Apocalipse'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// fold a ASCII-mayúsculas (quita acentos) para cotejo robusto
|
||||||
|
function fold($s) {
|
||||||
|
$s = trim($s);
|
||||||
|
$map = ['Á'=>'A','À'=>'A','Ä'=>'A','Â'=>'A','Ã'=>'A','É'=>'E','È'=>'E','Ë'=>'E','Ê'=>'E',
|
||||||
|
'Í'=>'I','Ì'=>'I','Ï'=>'I','Î'=>'I','Ó'=>'O','Ò'=>'O','Ö'=>'O','Ô'=>'O','Õ'=>'O',
|
||||||
|
'Ú'=>'U','Ù'=>'U','Ü'=>'U','Û'=>'U','Ñ'=>'N','Ç'=>'C'];
|
||||||
|
$s = mb_strtoupper($s, 'UTF-8');
|
||||||
|
return strtr($s, $map);
|
||||||
|
}
|
||||||
|
|
||||||
|
$langIdx = array_flip($LANGS); // en=>0,...
|
||||||
|
// índice de búsqueda: foldedSpanish => [en,fr,it,pt]
|
||||||
|
$LOOKUP = [];
|
||||||
|
foreach ($BOOKS as $es => $tr) {
|
||||||
|
$LOOKUP[fold($es)] = $tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Todos los posts no-ES (filtramos/transformamos en PHP, regex /u fiable).
|
||||||
|
$placeholders = implode(',', array_fill(0, count($LANGS), '%s'));
|
||||||
|
$sql = $wpdb->prepare(
|
||||||
|
"SELECT p.ID, t.slug AS lang, p.post_title
|
||||||
|
FROM {$wpdb->posts} p
|
||||||
|
JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
|
||||||
|
JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy='language'
|
||||||
|
JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
|
||||||
|
WHERE p.post_type='post'
|
||||||
|
AND p.post_status IN ('publish','draft','future','pending','private')
|
||||||
|
AND t.slug IN ($placeholders)",
|
||||||
|
$LANGS
|
||||||
|
);
|
||||||
|
$rows = $wpdb->get_results($sql);
|
||||||
|
|
||||||
|
$changes = []; // [ID => [lang, old, new]]
|
||||||
|
$per_lang = array_fill_keys($LANGS, 0);
|
||||||
|
$skipped_already = 0;
|
||||||
|
$candidates = 0; // títulos con al menos una cita bíblica detectada
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Traduce CADA token de libro de una cita bíblica dentro del título:
|
||||||
|
* - una palabra (letras) NO precedida por letra/número
|
||||||
|
* - seguida de «<espacios><dígito>» (el capítulo de la cita).
|
||||||
|
* Cubre el inicio, los compuestos «1ª lectura / 2ª lectura / evangelio» (tras «/»),
|
||||||
|
* el ordinal previo («2 Timoteo 4») y prefijos de fiesta («Epifanía - Isaías 60»).
|
||||||
|
* Como SOLO casa ortografías españolas (las del mapa), en posts no-ES únicamente
|
||||||
|
* toca citas heredadas del ES; las descripciones van en el idioma destino.
|
||||||
|
*/
|
||||||
|
$BOOK_RE = '/(?<![\p{L}\p{N}])(\p{L}[\p{L}.]*)(?=\s+\d)/u';
|
||||||
|
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$lang = $r->lang;
|
||||||
|
$li = $langIdx[$lang];
|
||||||
|
$hit = false;
|
||||||
|
$new_title = preg_replace_callback($BOOK_RE, function ($m) use ($LOOKUP, $li, &$hit) {
|
||||||
|
$book = $m[1];
|
||||||
|
$key = fold($book);
|
||||||
|
if (!isset($LOOKUP[$key])) {
|
||||||
|
return $m[0]; // no es libro conocido -> intacto
|
||||||
|
}
|
||||||
|
$hit = true;
|
||||||
|
$canon = $LOOKUP[$key][$li];
|
||||||
|
$isUpper = (mb_strtoupper($book, 'UTF-8') === $book);
|
||||||
|
return $isUpper ? mb_strtoupper($canon, 'UTF-8') : $canon;
|
||||||
|
}, $r->post_title);
|
||||||
|
|
||||||
|
if (!$hit) { continue; }
|
||||||
|
$candidates++;
|
||||||
|
if ($new_title === $r->post_title) { $skipped_already++; continue; } // idempotente
|
||||||
|
|
||||||
|
$changes[$r->ID] = [$lang, $r->post_title, $new_title];
|
||||||
|
$per_lang[$lang]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reporte ---
|
||||||
|
echo "== translate_lectura_titles.php (issue #140) ==\n";
|
||||||
|
echo "WP_LOAD: $WP_LOAD\n";
|
||||||
|
echo "Posts no-ES escaneados: " . count($rows) . "\n";
|
||||||
|
echo " - con cita bíblica detectada: $candidates\n";
|
||||||
|
echo " - ya en idioma destino (idempotente): $skipped_already\n";
|
||||||
|
echo " - A CAMBIAR: " . count($changes) . "\n";
|
||||||
|
echo " por idioma: ";
|
||||||
|
foreach ($LANGS as $l) echo strtoupper($l) . "=" . $per_lang[$l] . " ";
|
||||||
|
echo "\n\n";
|
||||||
|
|
||||||
|
$SAMPLE = (int)(getenv('SAMPLE') ?: 8);
|
||||||
|
foreach ($LANGS as $l) {
|
||||||
|
$shown = 0;
|
||||||
|
echo "--- muestra $l ---\n";
|
||||||
|
foreach ($changes as $id => $c) {
|
||||||
|
if ($c[0] !== $l) continue;
|
||||||
|
echo sprintf(" %d «%s» -> «%s»\n", $id, $c[1], $c[2]);
|
||||||
|
if ($SAMPLE > 0 && ++$shown >= $SAMPLE) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
if (!$APPLY) {
|
||||||
|
echo "DRY-RUN (no se ha tocado nada). Ejecuta con APPLY=1 para aplicar.\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Aplica (title-only, sin tocar slug/cuerpo) ---
|
||||||
|
$done = 0;
|
||||||
|
foreach ($changes as $id => $c) {
|
||||||
|
$ok = $wpdb->update($wpdb->posts, ['post_title' => $c[2]], ['ID' => $id], ['%s'], ['%d']);
|
||||||
|
if ($ok !== false) {
|
||||||
|
clean_post_cache($id);
|
||||||
|
$done++;
|
||||||
|
} else {
|
||||||
|
fwrite(STDERR, "ERROR al actualizar ID $id\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo "APLICADOS: $done de " . count($changes) . "\n";
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
translate_post.py — Traduce posts de feadulta (ES → EN/FR/IT/PT) con Gemma 4B local
|
||||||
|
y los enlaza como traducciones de Polylang, SIN servicios de pago.
|
||||||
|
|
||||||
|
Diseño (issue rafa/feadulta#75, fase 1):
|
||||||
|
- Gemma (LM Studio, http://172.19.128.1:1234/v1) traduce título + contenido HTML.
|
||||||
|
- Reglas estrictas: preserva HTML/shortcodes, NO traduce referencias bíblicas, respeta
|
||||||
|
nombres propios y un glosario fijo del proyecto. Traducción fiel (sin resumir).
|
||||||
|
- La lógica WordPress/Polylang vive en fea_translate_helper.php (corre dentro del
|
||||||
|
contenedor cargando wp-load.php; no necesita wp-cli ni proc_open).
|
||||||
|
- Idempotente y reanudable: si ya existe la traducción en ese idioma, se salta.
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
python3 scripts/translate_post.py --post-id 45018 --langs en,fr,it,pt
|
||||||
|
python3 scripts/translate_post.py --carta 45018 # carta + sus _carta_id
|
||||||
|
python3 scripts/translate_post.py --post-id 45018 --langs en --status publish --force
|
||||||
|
|
||||||
|
Pensado para que Codex lo lance en lote sobre la cola priorizada (cartas/destacados).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ── Configuración ────────────────────────────────────────────────────────────
|
||||||
|
WP_CONTAINER = os.environ.get("FEA_WP_CONTAINER", "wordpress-web")
|
||||||
|
DB_CONTAINER = os.environ.get("FEA_DB_CONTAINER", "wordpress-mysql")
|
||||||
|
DB_NAME = os.environ.get("FEA_DB_NAME", "wordpress_db")
|
||||||
|
DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
|
||||||
|
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
|
||||||
|
|
||||||
|
LM_BASE_URL = os.environ.get("OPENAI_BASE_URL", "http://172.19.128.1:1234/v1")
|
||||||
|
MODEL = os.environ.get("LOCAL_MODEL", "google/gemma-4-e4b")
|
||||||
|
|
||||||
|
# Motor de traducción: "gemma" (local, por defecto) o "haiku" (Claude Haiku 4.5 vía API).
|
||||||
|
# Haiku da más calidad y no necesita trocear (contexto 200k). Reutiliza translate_haiku.py.
|
||||||
|
ENGINE = os.environ.get("FEA_ENGINE", "gemma").lower()
|
||||||
|
if ENGINE == "haiku":
|
||||||
|
MODEL = "claude-haiku-4-5"
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
import translate_haiku # carga la API key de portfolio-tracker/.env
|
||||||
|
elif ENGINE == "minimax":
|
||||||
|
MODEL = os.environ.get("LOCAL_MODEL", "MiniMax-Text-01")
|
||||||
|
MINIMAX_URL = os.environ.get("MINIMAX_URL", "https://api.minimax.io/v1/text/chatcompletion_v2")
|
||||||
|
_kf = Path(os.environ.get("MINIMAX_KEY_FILE", "/home/rafa/Feadulta/minimax.txt"))
|
||||||
|
_keys = [l.strip() for l in _kf.read_text().splitlines() if l.strip().startswith("sk-")]
|
||||||
|
MINIMAX_KEY = _keys[-1] if _keys else ""
|
||||||
|
|
||||||
|
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
|
||||||
|
HELPER_DST = "/tmp/fea_translate_helper.php"
|
||||||
|
STATE_FILE = Path(os.environ.get("FEA_TR_STATE", "/tmp/feadulta-translate-state.json"))
|
||||||
|
LOG_FILE = Path(os.environ.get("FEA_TR_LOG", "/tmp/feadulta-translate.log"))
|
||||||
|
|
||||||
|
LANG_NAMES = {"en": "English", "fr": "French (français)", "it": "Italian (italiano)", "pt": "Portuguese (português)"}
|
||||||
|
|
||||||
|
# Glosario: términos que NO se traducen o se fijan.
|
||||||
|
GLOSSARY = {
|
||||||
|
"Fe Adulta": {"en": "Fe Adulta", "fr": "Fe Adulta", "it": "Fe Adulta", "pt": "Fe Adulta"},
|
||||||
|
"EFFA": {"en": "EFFA", "fr": "EFFA", "it": "EFFA", "pt": "EFFA"},
|
||||||
|
}
|
||||||
|
|
||||||
|
CHUNK_LIMIT = 5000 # caracteres por llamada a Gemma (parte por </p> si se supera)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Utilidades de proceso ────────────────────────────────────────────────────
|
||||||
|
def log(msg: str) -> None:
|
||||||
|
line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}"
|
||||||
|
print(line, flush=True)
|
||||||
|
try:
|
||||||
|
LOG_FILE.open("a", encoding="utf-8").write(line + "\n")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def sh(cmd: list[str], *, stdin: str | None = None, timeout: int = 120) -> str:
|
||||||
|
r = subprocess.run(cmd, input=stdin, capture_output=True, text=True, timeout=timeout)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError(f"cmd falló ({r.returncode}): {' '.join(cmd)}\n{r.stderr.strip()}")
|
||||||
|
return r.stdout
|
||||||
|
|
||||||
|
|
||||||
|
_helper_ready = False
|
||||||
|
|
||||||
|
|
||||||
|
def php_helper(subcmd: str, *args: str, stdin: str | None = None) -> str:
|
||||||
|
"""Copia el helper al contenedor (una vez) y lo ejecuta cargando wp-load.php."""
|
||||||
|
global _helper_ready
|
||||||
|
if not _helper_ready:
|
||||||
|
sh(["docker", "cp", str(HELPER_SRC), f"{WP_CONTAINER}:{HELPER_DST}"])
|
||||||
|
_helper_ready = True
|
||||||
|
cmd = ["docker", "exec", "-i", WP_CONTAINER, "php", HELPER_DST, subcmd, *args]
|
||||||
|
return sh(cmd, stdin=stdin, timeout=180)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Gemma (LM Studio) ────────────────────────────────────────────────────────
|
||||||
|
def gemma(messages: list[dict], *, max_tokens: int) -> str:
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
body = json.dumps({
|
||||||
|
"model": MODEL,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": 0.2,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"reasoning_effort": "none",
|
||||||
|
}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{LM_BASE_URL}/chat/completions", data=body,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=300) as resp:
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
return data["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
def minimax(messages: list[dict], *, max_tokens: int) -> str:
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
body = json.dumps({
|
||||||
|
"model": MODEL,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": 0.2,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
MINIMAX_URL, data=body,
|
||||||
|
headers={"Content-Type": "application/json", "Authorization": f"Bearer {MINIMAX_KEY}"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=300) as resp:
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
return data["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
def _extract(text: str) -> str:
|
||||||
|
"""Extrae la traducción del ÚLTIMO bloque <<<INI>>>…<<<FIN>>>.
|
||||||
|
|
||||||
|
Gemma (modo reasoning) escribe un preámbulo que MENCIONA las propias marcas,
|
||||||
|
así que hay que quedarse con la última ocurrencia, no la primera.
|
||||||
|
"""
|
||||||
|
start_tok, end_tok = "<<<INI>>>", "<<<FIN>>>"
|
||||||
|
i = text.rfind(start_tok)
|
||||||
|
if i != -1:
|
||||||
|
rest = text[i + len(start_tok):]
|
||||||
|
j = rest.find(end_tok)
|
||||||
|
out = (rest[:j] if j != -1 else rest).strip()
|
||||||
|
else:
|
||||||
|
out = text.strip()
|
||||||
|
# Quita vallas de código markdown si Gemma las añade.
|
||||||
|
out = re.sub(r"^```[a-z]*\n?", "", out)
|
||||||
|
out = re.sub(r"\n?```$", "", out)
|
||||||
|
return out.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _system_prompt(lang: str) -> str:
|
||||||
|
target = LANG_NAMES[lang]
|
||||||
|
glos = "; ".join(f'"{k}" → "{v[lang]}"' for k, v in GLOSSARY.items())
|
||||||
|
return (
|
||||||
|
f"Eres un traductor profesional de textos religiosos cristianos (espiritualidad y "
|
||||||
|
f"teología católica). Traduce del español al {target}. REGLAS ESTRICTAS:\n"
|
||||||
|
f"1. Conserva EXACTAMENTE el marcado HTML (etiquetas y atributos) y los shortcodes "
|
||||||
|
f"entre [ ] y { '{' } { '}' }. No los traduzcas ni los reordenes.\n"
|
||||||
|
f"2. NO traduzcas las referencias bíblicas ni sus abreviaturas (p.ej. 'Jn 3, 16', "
|
||||||
|
f"'Isaías 5, 1-7', 'Mt 5'). Déjalas idénticas.\n"
|
||||||
|
f"3. Conserva los nombres propios de persona y lugar (salvo exónimos establecidos).\n"
|
||||||
|
f"4. Glosario fijo: {glos}.\n"
|
||||||
|
f"5. Traducción FIEL: no resumas, no añadas, no comentes.\n"
|
||||||
|
f"6. Devuelve SOLO la traducción entre las marcas <<<INI>>> y <<<FIN>>>, sin nada más."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def translate_text(text: str, lang: str, *, is_title: bool = False) -> str:
|
||||||
|
text = text.strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
if ENGINE == "haiku":
|
||||||
|
out, _usage = translate_haiku.translate(text, lang, is_title=is_title)
|
||||||
|
return out
|
||||||
|
user = f"<<<INI>>>{text}<<<FIN>>>"
|
||||||
|
if is_title:
|
||||||
|
kind = "el TÍTULO"
|
||||||
|
task = (
|
||||||
|
f"Traduce {kind} que va entre las marcas.\n"
|
||||||
|
f"Debe quedar en {LANG_NAMES[lang]} de forma natural. "
|
||||||
|
f"No lo dejes en inglés salvo que el original ya sea un nombre propio o una marca.\n"
|
||||||
|
f"Responde solo con el título traducido entre las marcas:\n{user}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
kind = "el texto"
|
||||||
|
task = f"Traduce {kind} que va entre las marcas:\n{user}"
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": _system_prompt(lang)},
|
||||||
|
{"role": "user", "content": task},
|
||||||
|
]
|
||||||
|
max_tokens = max(800, int(len(text) * 1.6))
|
||||||
|
engine_fn = minimax if ENGINE == "minimax" else gemma
|
||||||
|
raw = engine_fn(messages, max_tokens=max_tokens)
|
||||||
|
return _extract(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def translate_html(html: str, lang: str) -> str:
|
||||||
|
"""Trocea por párrafos si el contenido es largo, para no saturar el contexto de Gemma."""
|
||||||
|
if ENGINE == "haiku":
|
||||||
|
# Haiku tiene 200k de contexto: el artículo entero de una vez (mejor coherencia).
|
||||||
|
return translate_text(html, lang)
|
||||||
|
if len(html) <= CHUNK_LIMIT:
|
||||||
|
return translate_text(html, lang)
|
||||||
|
parts = re.split(r"(?<=</p>)", html)
|
||||||
|
chunks, buf = [], ""
|
||||||
|
for p in parts:
|
||||||
|
if len(buf) + len(p) > CHUNK_LIMIT and buf:
|
||||||
|
chunks.append(buf)
|
||||||
|
buf = ""
|
||||||
|
buf += p
|
||||||
|
if buf:
|
||||||
|
chunks.append(buf)
|
||||||
|
log(f" contenido largo ({len(html)} car) → {len(chunks)} trozos")
|
||||||
|
return "".join(translate_text(c, lang) for c in chunks)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Datos / estado ───────────────────────────────────────────────────────────
|
||||||
|
def read_post(post_id: int) -> dict:
|
||||||
|
return json.loads(php_helper("read", str(post_id)))
|
||||||
|
|
||||||
|
|
||||||
|
def translation_exists(es_id: int, lang: str) -> int:
|
||||||
|
return int(php_helper("exists", str(es_id), lang).strip() or "0")
|
||||||
|
|
||||||
|
|
||||||
|
WP_LOCK_FILE = Path(os.environ.get("FEA_TR_LOCK", "/tmp/feadulta-translate.lock"))
|
||||||
|
|
||||||
|
|
||||||
|
def create_translation(es_id: int, lang: str, title: str, content: str, status: str) -> int:
|
||||||
|
payload = json.dumps({"title": title, "content": content, "model": MODEL})
|
||||||
|
# Lock entre procesos: serializa SOLO la escritura/enlace Polylang (rápido), no la
|
||||||
|
# traducción LLM (lenta), para que 4 streams por idioma no pisen el grupo de traducciones.
|
||||||
|
with WP_LOCK_FILE.open("w") as lk:
|
||||||
|
fcntl.flock(lk, fcntl.LOCK_EX)
|
||||||
|
try:
|
||||||
|
return int(php_helper("create", str(es_id), lang, status, stdin=payload).strip())
|
||||||
|
finally:
|
||||||
|
fcntl.flock(lk, fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
|
||||||
|
def carta_article_ids(carta_id: int) -> list[int]:
|
||||||
|
q = (f"SELECT post_id FROM wp_postmeta WHERE meta_key='_carta_id' "
|
||||||
|
f"AND meta_value='{carta_id}' ORDER BY post_id;")
|
||||||
|
out = sh(["docker", "exec", DB_CONTAINER, "mysql", f"-u{DB_USER}", f"-p{DB_PASS}",
|
||||||
|
DB_NAME, "-N", "-e", q])
|
||||||
|
return [int(x) for x in out.split() if x.strip().isdigit()]
|
||||||
|
|
||||||
|
|
||||||
|
def load_state() -> dict:
|
||||||
|
if STATE_FILE.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(STATE_FILE.read_text())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
return {"done": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(state: dict) -> None:
|
||||||
|
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Orquestación ─────────────────────────────────────────────────────────────
|
||||||
|
def process_post(post_id: int, langs: list[str], status: str, force: bool, state: dict) -> None:
|
||||||
|
src = read_post(post_id)
|
||||||
|
if src.get("lang") and src["lang"] != "es":
|
||||||
|
log(f"#{post_id} no es ES (lang={src['lang']}) — saltado")
|
||||||
|
return
|
||||||
|
log(f"#{post_id} «{src['title'][:60]}»")
|
||||||
|
|
||||||
|
for lang in langs:
|
||||||
|
key = f"{post_id}:{lang}"
|
||||||
|
existing = translation_exists(post_id, lang)
|
||||||
|
if existing and not force:
|
||||||
|
log(f" {lang}: ya existe (#{existing}) — saltado")
|
||||||
|
state["done"][key] = existing
|
||||||
|
continue
|
||||||
|
if existing and force:
|
||||||
|
php_helper("unlink", str(post_id), lang)
|
||||||
|
log(f" {lang}: --force, eliminada traducción previa #{existing}")
|
||||||
|
try:
|
||||||
|
t0 = time.time()
|
||||||
|
title = translate_text(src["title"], lang, is_title=True)
|
||||||
|
content = translate_html(src["content"], lang)
|
||||||
|
new_id = create_translation(post_id, lang, title, content, status)
|
||||||
|
dt = time.time() - t0
|
||||||
|
log(f" {lang}: creado #{new_id} ({dt:.0f}s) → «{title[:50]}»")
|
||||||
|
state["done"][key] = new_id
|
||||||
|
save_state(state)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log(f" {lang}: ERROR {exc}")
|
||||||
|
state.setdefault("errors", {})[key] = str(exc)
|
||||||
|
save_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="Traduce posts de feadulta con Gemma local + Polylang.")
|
||||||
|
g = ap.add_mutually_exclusive_group(required=True)
|
||||||
|
g.add_argument("--post-id", type=int, help="ID de un post ES a traducir.")
|
||||||
|
g.add_argument("--carta", type=int, help="ID de carta: traduce la carta y todos sus artículos (_carta_id).")
|
||||||
|
g.add_argument("--ids-file", help="Fichero con un ID de post ES por línea.")
|
||||||
|
ap.add_argument("--langs", default="en,fr,it,pt", help="Idiomas destino separados por coma.")
|
||||||
|
ap.add_argument("--status", default="draft", choices=["draft", "publish"], help="Estado de la traducción.")
|
||||||
|
ap.add_argument("--force", action="store_true", help="Regenera aunque ya exista la traducción.")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
langs = [l.strip() for l in args.langs.split(",") if l.strip() in LANG_NAMES]
|
||||||
|
if not langs:
|
||||||
|
log("Sin idiomas válidos."); return 1
|
||||||
|
|
||||||
|
if args.post_id:
|
||||||
|
ids = [args.post_id]
|
||||||
|
elif args.ids_file:
|
||||||
|
ids = [int(x) for x in Path(args.ids_file).read_text().split() if x.strip().isdigit()]
|
||||||
|
log(f"ids-file {args.ids_file}: {len(ids)} posts")
|
||||||
|
else:
|
||||||
|
ids = [args.carta] + carta_article_ids(args.carta)
|
||||||
|
log(f"Carta {args.carta}: {len(ids)} posts (carta + {len(ids)-1} artículos)")
|
||||||
|
|
||||||
|
state = load_state()
|
||||||
|
for pid in ids:
|
||||||
|
process_post(pid, langs, args.status, args.force, state)
|
||||||
|
save_state(state)
|
||||||
|
log(f"FIN. {len(state['done'])} traducciones registradas, "
|
||||||
|
f"{len(state.get('errors', {}))} errores. Estado: {STATE_FILE}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Locuta una carta/artículo entero de feadulta con la voz clonada (XTTS-v2 + GPU).
|
||||||
|
|
||||||
|
Saca el texto del post ES, lo trocea por párrafos, lo locuta con la voz de
|
||||||
|
referencia (calculando los latents del hablante UNA sola vez), concatena con
|
||||||
|
pausas y añade comfort noise. Issue #76.
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
tts_carta.py <post_id> <muestra_voz.wav> [nombre_salida]
|
||||||
|
"""
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
os.environ.setdefault("COQUI_TOS_AGREED", "1")
|
||||||
|
|
||||||
|
import numpy as np # noqa: E402
|
||||||
|
import soundfile as sf # noqa: E402
|
||||||
|
import torch # noqa: E402
|
||||||
|
from TTS.api import TTS # noqa: E402
|
||||||
|
|
||||||
|
DEVICE = "cuda" if torch.cuda.is_available() and not os.environ.get("FEA_CPU") else "cpu"
|
||||||
|
OUT = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts-samples"
|
||||||
|
SR = 24000
|
||||||
|
CONTAINER = "wordpress-web"
|
||||||
|
|
||||||
|
|
||||||
|
def get_post_text(pid):
|
||||||
|
subprocess.run(["docker", "exec", CONTAINER, "php", "/tmp/fea_post_io.php", "get", str(pid)],
|
||||||
|
check=True, capture_output=True)
|
||||||
|
subprocess.run(["docker", "cp", f"{CONTAINER}:/tmp/fea_es.json", "/tmp/fea_es.json"], check=True)
|
||||||
|
d = json.load(open("/tmp/fea_es.json"))
|
||||||
|
raw = d["content"]
|
||||||
|
# Conserva límites de párrafo antes de quitar tags.
|
||||||
|
raw = re.sub(r"(?i)</p>|<br\s*/?>|</h[1-6]>", "\n", raw)
|
||||||
|
raw = re.sub(r"<[^>]+>", "", raw) # quita tags
|
||||||
|
raw = re.sub(r"\[[^\]]+\]", "", raw) # quita shortcodes
|
||||||
|
raw = html.unescape(raw)
|
||||||
|
paras = [re.sub(r"\s+", " ", p).strip() for p in raw.split("\n")]
|
||||||
|
paras = [p for p in paras if len(p) > 1]
|
||||||
|
return d["title"], paras
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
sys.exit("uso: tts_carta.py <post_id> <muestra_voz.wav> [nombre_salida]")
|
||||||
|
pid = int(sys.argv[1])
|
||||||
|
spk = sys.argv[2]
|
||||||
|
name = sys.argv[3] if len(sys.argv) > 3 else f"carta-{pid}"
|
||||||
|
|
||||||
|
title, paras = get_post_text(pid)
|
||||||
|
print(f"Post #{pid}: «{title}» ({len(paras)} párrafos, {sum(len(p) for p in paras)} car)")
|
||||||
|
|
||||||
|
print(f"Cargando XTTS-v2 en {DEVICE}…", flush=True)
|
||||||
|
tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(DEVICE)
|
||||||
|
model = tts.synthesizer.tts_model
|
||||||
|
print("Calculando timbre del hablante (1 vez)…", flush=True)
|
||||||
|
gpt_cond, spk_emb = model.get_conditioning_latents(audio_path=[spk])
|
||||||
|
|
||||||
|
pause = np.zeros(int(SR * 0.35), dtype=np.float32)
|
||||||
|
pieces = []
|
||||||
|
import time
|
||||||
|
t0 = time.time()
|
||||||
|
for i, para in enumerate(paras, 1):
|
||||||
|
out = model.inference(
|
||||||
|
para, "es", gpt_cond, spk_emb,
|
||||||
|
temperature=0.65, repetition_penalty=5.0, top_k=50, top_p=0.85,
|
||||||
|
enable_text_splitting=True,
|
||||||
|
)
|
||||||
|
pieces.append(np.asarray(out["wav"], dtype=np.float32))
|
||||||
|
pieces.append(pause)
|
||||||
|
print(f" párrafo {i}/{len(paras)} ({len(para)} car) ok", flush=True)
|
||||||
|
audio = np.concatenate(pieces)
|
||||||
|
dt = time.time() - t0
|
||||||
|
dur = len(audio) / SR
|
||||||
|
print(f"Síntesis: {dt:.1f}s para {dur:.1f}s de audio (x{dur/dt:.1f} tiempo real) en {DEVICE}")
|
||||||
|
|
||||||
|
raw = OUT / f"{name}.raw.wav"
|
||||||
|
sf.write(raw, audio, SR)
|
||||||
|
wav = OUT / f"{name}.wav"
|
||||||
|
subprocess.run([
|
||||||
|
"ffmpeg", "-y", "-i", str(raw), "-filter_complex",
|
||||||
|
"anoisesrc=color=brown:amplitude=0.004:sample_rate=24000[n];"
|
||||||
|
"[n]highpass=f=120,lowpass=f=3800[nf];"
|
||||||
|
"[0:a][nf]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[a]",
|
||||||
|
"-map", "[a]", "-ar", "24000", str(wav),
|
||||||
|
], capture_output=True)
|
||||||
|
raw.unlink(missing_ok=True)
|
||||||
|
mp3 = OUT / f"{name}.mp3"
|
||||||
|
subprocess.run(["ffmpeg", "-y", "-i", str(wav), "-b:a", "96k", str(mp3)], capture_output=True)
|
||||||
|
print(f"OK -> {mp3} ({dur:.0f}s de audio)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user