From b6116b066d7cc272f9233ebad7c07e26dfa26894 Mon Sep 17 00:00:00 2001 From: Rafa / Claude Code Date: Sun, 28 Jun 2026 15:10:46 -0400 Subject: [PATCH] =?UTF-8?q?A=C3=B1adir=20mu-plugins=20y=20scripts=20de=20f?= =?UTF-8?q?eadulta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 42 +- mu-plugins/carta-semana-plugin.php | 75 + mu-plugins/fa-custom-css.php.disabled | 14 + mu-plugins/fea-analytics.php | 40 + mu-plugins/fea-audio-player.php | 62 + mu-plugins/fea-avatar-cachebust.php | 17 + mu-plugins/fea-beta-feedback.php | 291 +++ mu-plugins/fea-carta-portada.php | 216 ++ mu-plugins/fea-compact-entry-spacing.php | 67 + mu-plugins/fea-cookie-consent.php | 112 + mu-plugins/fea-disable-comments.php | 29 + mu-plugins/fea-hide-bad-tag.php | 66 + mu-plugins/fea-homepage.php | 2130 ++++++++++++++++++ mu-plugins/fea-menu-i18n.php | 159 ++ mu-plugins/fea-pensamientos.php | 453 ++++ mu-plugins/fea-recopilatorios.php | 277 +++ mu-plugins/fea-search-advanced.php | 632 ++++++ mu-plugins/fea-search-fulltext.php | 101 + mu-plugins/fea-search.php | 61 + mu-plugins/fea-share.php | 240 ++ mu-plugins/fea-slider-sync.php | 185 ++ mu-plugins/fea-support-campaign.php | 336 +++ mu-plugins/fea-support-campaign/template.php | 244 ++ mu-plugins/fea-ui.php | 101 + mu-plugins/stop-redirects.php | 1 + scripts/aplicar_clasificacion_a_bd.py | 180 ++ scripts/apply_lecturas_wp.php | 41 + scripts/assign_author_photos.php | 93 + scripts/assign_polylang_languages.php | 182 ++ scripts/assign_polylang_prod.php | 63 + scripts/audit_translations.py | 151 ++ scripts/autores_biblicos.php | 50 + scripts/build_lectionary_index.py | 126 ++ scripts/carta-semana-plugin.php | 75 + scripts/create_buscar_page.php | 109 + scripts/create_lecturas.php | 43 + scripts/cutover_feadulta_com.sh | 69 + scripts/demote_old_cartasemana.php | 69 + scripts/deploy_php83_compat_step1.sh | 282 +++ scripts/detect_untranslated.php | 77 + scripts/download_lecturas.py | 88 + scripts/export_cat_translations.py | 229 ++ scripts/export_translations.py | 200 ++ scripts/face_crop_avatar.py | 142 ++ scripts/fea-homepage-template.php | 142 ++ scripts/fea-homepage.php | 425 ++++ scripts/fea_post_io.php | 62 + scripts/fea_translate_helper.php | 289 +++ scripts/fetch_lectura_bolls.py | 21 + scripts/fix_carta_content_links.php | 65 + scripts/fix_carta_joomla_links.php | 42 + scripts/fix_carta_links.php | 104 + scripts/fix_catnames.php | 26 + scripts/fix_image_paths.php | 137 ++ scripts/fix_imported_k2_metas.py | 225 ++ scripts/fix_joomla_links.php | 197 ++ scripts/fix_k2_authors.php | 94 + scripts/fix_numeric_categories.php | 241 ++ scripts/fix_remaining_titles.py | 113 + scripts/fix_titles.py | 180 ++ scripts/gen_avatars_81b.py | 33 + scripts/gen_avatars_initials.py | 77 + scripts/generate_k2_redirects.php | 197 ++ scripts/import_avatars.php | 82 + scripts/import_avatars_143.php | 56 + scripts/import_avatars_75.php | 59 + scripts/import_avatars_90.php | 55 + scripts/import_new_cartas.py | 320 +++ scripts/import_new_content.py | 254 +++ scripts/import_new_k2_items.py | 403 ++++ scripts/import_public_joomla_delta.py | 517 +++++ scripts/lecturas_apply.py | 84 + scripts/minimax_tts.py | 430 ++++ scripts/pretranslate_en_haiku.py | 125 + scripts/prettify_carta_links.php | 44 + scripts/publish_carta.php | 26 + scripts/quitar_multimedia.php | 14 + scripts/reasign_cats.php | 70 + scripts/regen_avatars.php | 40 + scripts/regenerar_clasificacion_csv.py | 298 +++ scripts/remap_carta_tr_links.php | 40 + scripts/remap_translation_cats.php | 42 + scripts/repoint_carta_links.php | 41 + scripts/reprocess_en_haiku.py | 128 ++ scripts/retranslate_chunks.py | 310 +++ scripts/retranslate_en_all.py | 230 ++ scripts/retranslate_failures.py | 233 ++ scripts/retranslate_lang.py | 275 +++ scripts/rotate_cartas.php | 83 + scripts/set_search_template.php | 86 + scripts/setup-wordpress.sh | 88 + scripts/sync_translations_to_prod.py | 325 +++ scripts/test_5articles.py | 178 ++ scripts/trad_cats.php | 38 + scripts/translate_cartas.py | 401 ++++ scripts/translate_gap.sh | 76 + scripts/translate_haiku.py | 87 + scripts/translate_lectura_titles.php | 218 ++ scripts/translate_post.py | 340 +++ scripts/tts_carta.py | 99 + scripts/tts_carta_edge.py | 51 + scripts/tts_eval.py | 82 + scripts/tts_kokoro.py | 41 + scripts/tts_produce.py | 118 + scripts/tts_xtts.py | 72 + scripts/unpublish_date_slug_posts.php | 153 ++ 106 files changed, 17600 insertions(+), 2 deletions(-) create mode 100755 mu-plugins/carta-semana-plugin.php create mode 100644 mu-plugins/fa-custom-css.php.disabled create mode 100644 mu-plugins/fea-analytics.php create mode 100644 mu-plugins/fea-audio-player.php create mode 100644 mu-plugins/fea-avatar-cachebust.php create mode 100644 mu-plugins/fea-beta-feedback.php create mode 100644 mu-plugins/fea-carta-portada.php create mode 100644 mu-plugins/fea-compact-entry-spacing.php create mode 100644 mu-plugins/fea-cookie-consent.php create mode 100644 mu-plugins/fea-disable-comments.php create mode 100644 mu-plugins/fea-hide-bad-tag.php create mode 100755 mu-plugins/fea-homepage.php create mode 100644 mu-plugins/fea-menu-i18n.php create mode 100644 mu-plugins/fea-pensamientos.php create mode 100644 mu-plugins/fea-recopilatorios.php create mode 100644 mu-plugins/fea-search-advanced.php create mode 100644 mu-plugins/fea-search-fulltext.php create mode 100644 mu-plugins/fea-search.php create mode 100644 mu-plugins/fea-share.php create mode 100644 mu-plugins/fea-slider-sync.php create mode 100755 mu-plugins/fea-support-campaign.php create mode 100755 mu-plugins/fea-support-campaign/template.php create mode 100644 mu-plugins/fea-ui.php create mode 100644 mu-plugins/stop-redirects.php create mode 100644 scripts/aplicar_clasificacion_a_bd.py create mode 100644 scripts/apply_lecturas_wp.php create mode 100644 scripts/assign_author_photos.php create mode 100644 scripts/assign_polylang_languages.php create mode 100644 scripts/assign_polylang_prod.php create mode 100644 scripts/audit_translations.py create mode 100644 scripts/autores_biblicos.php create mode 100644 scripts/build_lectionary_index.py create mode 100755 scripts/carta-semana-plugin.php create mode 100644 scripts/create_buscar_page.php create mode 100644 scripts/create_lecturas.php create mode 100755 scripts/cutover_feadulta_com.sh create mode 100755 scripts/demote_old_cartasemana.php create mode 100755 scripts/deploy_php83_compat_step1.sh create mode 100644 scripts/detect_untranslated.php create mode 100644 scripts/download_lecturas.py create mode 100644 scripts/export_cat_translations.py create mode 100644 scripts/export_translations.py create mode 100644 scripts/face_crop_avatar.py create mode 100644 scripts/fea-homepage-template.php create mode 100644 scripts/fea-homepage.php create mode 100644 scripts/fea_post_io.php create mode 100644 scripts/fea_translate_helper.php create mode 100644 scripts/fetch_lectura_bolls.py create mode 100644 scripts/fix_carta_content_links.php create mode 100644 scripts/fix_carta_joomla_links.php create mode 100644 scripts/fix_carta_links.php create mode 100644 scripts/fix_catnames.php create mode 100644 scripts/fix_image_paths.php create mode 100644 scripts/fix_imported_k2_metas.py create mode 100644 scripts/fix_joomla_links.php create mode 100644 scripts/fix_k2_authors.php create mode 100644 scripts/fix_numeric_categories.php create mode 100644 scripts/fix_remaining_titles.py create mode 100644 scripts/fix_titles.py create mode 100644 scripts/gen_avatars_81b.py create mode 100644 scripts/gen_avatars_initials.py create mode 100644 scripts/generate_k2_redirects.php create mode 100644 scripts/import_avatars.php create mode 100644 scripts/import_avatars_143.php create mode 100644 scripts/import_avatars_75.php create mode 100644 scripts/import_avatars_90.php create mode 100644 scripts/import_new_cartas.py create mode 100644 scripts/import_new_content.py create mode 100644 scripts/import_new_k2_items.py create mode 100644 scripts/import_public_joomla_delta.py create mode 100644 scripts/lecturas_apply.py create mode 100644 scripts/minimax_tts.py create mode 100644 scripts/pretranslate_en_haiku.py create mode 100644 scripts/prettify_carta_links.php create mode 100644 scripts/publish_carta.php create mode 100644 scripts/quitar_multimedia.php create mode 100644 scripts/reasign_cats.php create mode 100644 scripts/regen_avatars.php create mode 100644 scripts/regenerar_clasificacion_csv.py create mode 100644 scripts/remap_carta_tr_links.php create mode 100644 scripts/remap_translation_cats.php create mode 100644 scripts/repoint_carta_links.php create mode 100644 scripts/reprocess_en_haiku.py create mode 100644 scripts/retranslate_chunks.py create mode 100644 scripts/retranslate_en_all.py create mode 100644 scripts/retranslate_failures.py create mode 100644 scripts/retranslate_lang.py create mode 100644 scripts/rotate_cartas.php create mode 100644 scripts/set_search_template.php create mode 100755 scripts/setup-wordpress.sh create mode 100644 scripts/sync_translations_to_prod.py create mode 100644 scripts/test_5articles.py create mode 100644 scripts/trad_cats.php create mode 100644 scripts/translate_cartas.py create mode 100755 scripts/translate_gap.sh create mode 100644 scripts/translate_haiku.py create mode 100644 scripts/translate_lectura_titles.php create mode 100644 scripts/translate_post.py create mode 100644 scripts/tts_carta.py create mode 100644 scripts/tts_carta_edge.py create mode 100644 scripts/tts_eval.py create mode 100644 scripts/tts_kokoro.py create mode 100644 scripts/tts_produce.py create mode 100644 scripts/tts_xtts.py create mode 100644 scripts/unpublish_date_slug_posts.php diff --git a/README.md b/README.md index e16ba4a..52823a9 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,41 @@ -# feadulta +# feadulta — WordPress -WordPress feadulta.com — plugins, scripts y documentación \ No newline at end of file +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!` diff --git a/mu-plugins/carta-semana-plugin.php b/mu-plugins/carta-semana-plugin.php new file mode 100755 index 0000000..98a4dba --- /dev/null +++ b/mu-plugins/carta-semana-plugin.php @@ -0,0 +1,75 @@ +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); + } +}); diff --git a/mu-plugins/fa-custom-css.php.disabled b/mu-plugins/fa-custom-css.php.disabled new file mode 100644 index 0000000..0bca06d --- /dev/null +++ b/mu-plugins/fa-custom-css.php.disabled @@ -0,0 +1,14 @@ + 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; + ?> + + + +' + . '' + . ' Escucha' + . '' + . ''; +} + +// 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, ''); + 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; + ?> + + a las URLs de avatar servidas desde + * uploads/avatares/autores/autor-.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); diff --git a/mu-plugins/fea-beta-feedback.php b/mu-plugins/fea-beta-feedback.php new file mode 100644 index 0000000..1c29560 --- /dev/null +++ b/mu-plugins/fea-beta-feedback.php @@ -0,0 +1,291 @@ + [ + '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\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 '' . esc_html(wp_parse_url($u, PHP_URL_PATH) ?: $u) . ''; + } 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(); + ?> + + + + + + + + 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/-...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); diff --git a/mu-plugins/fea-compact-entry-spacing.php b/mu-plugins/fea-compact-entry-spacing.php new file mode 100644 index 0000000..4179b4b --- /dev/null +++ b/mu-plugins/fea-compact-entry-spacing.php @@ -0,0 +1,67 @@ + + + (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 : '/'); + ?> + + + +remove_menu('comments'); + } +}); diff --git a/mu-plugins/fea-hide-bad-tag.php b/mu-plugins/fea-hide-bad-tag.php new file mode 100644 index 0000000..fbbde0d --- /dev/null +++ b/mu-plugins/fea-hide-bad-tag.php @@ -0,0 +1,66 @@ +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); diff --git a/mu-plugins/fea-homepage.php b/mu-plugins/fea-homepage.php new file mode 100755 index 0000000..d24a20e --- /dev/null +++ b/mu-plugins/fea-homepage.php @@ -0,0 +1,2130 @@ +is_main_query()) return; + if (!function_exists('pll_current_language') || !function_exists('pll_get_post')) return; + + // Solo actuar cuando WP cree que está en el home/front page + if (!$query->is_home() && !$query->is_front_page()) return; + + $lang = pll_current_language(); + if (!$lang || $lang === 'es') return; + + $translated_id = pll_get_post(26542, $lang); + if (!$translated_id) return; + + // Forzar carga de la página traducida como si fuera la portada estática + $query->set('page_id', $translated_id); + $query->set('post_type', 'page'); + $query->is_home = false; + $query->is_front_page = true; + $query->is_singular = true; + $query->is_page = true; +}, 1); + +// ── Multimedia: 30 entradas por página en su archivo (#63) ── +add_action('pre_get_posts', function(WP_Query $query) { + if (is_admin() || !$query->is_main_query()) return; + if ($query->is_category('multimedia')) { + $query->set('posts_per_page', 30); + } +}); + +// ── Tablón de anuncios (#97): ocultar del listado los anuncios de más de 12 meses ── +// Ventana móvil, no destructiva: los posts siguen publicados/accesibles por URL; solo +// se excluyen del archivo de la categoría para que el Tablón no parezca desactualizado. +// Cubre las 5 categorías Polylang (ES + en/fr/it/pt). +add_action('pre_get_posts', function(WP_Query $query) { + if (is_admin() || !$query->is_main_query()) return; + if ($query->is_category(['tablon-de-anuncios', 'tablon-de-anuncios-en', + 'tablon-de-anuncios-fr', 'tablon-de-anuncios-it', 'tablon-de-anuncios-pt'])) { + $query->set('date_query', [[ + 'after' => date('Y-m-d', strtotime('-12 months')), + 'inclusive' => true, + ]]); + } +}); + +// ── Normalizar títulos (TODO CAPS legacy → frase) en todo el front #63 #73 ── +// Mismo criterio que la portada (fea_title). Cubre listados/búsqueda/home, el artículo +// (single), el /SEO y los feeds. El wp-admin se deja SIN tocar para que el editor +// vea el dato real (en mayúsculas) al editar. +add_filter('the_title', function($title, $post_id = 0) { + if (is_admin() || !function_exists('fea_title')) return $title; + // Artículo individual: solo el título del propio post mostrado (no widgets/relacionados). + if (is_singular()) { + if (in_the_loop() || (int) $post_id === (int) get_queried_object_id()) { + return fea_title($title); + } + return $title; + } + // Listados (incluida portada) dentro del loop. + if ((is_archive() || is_search() || is_home() || is_front_page()) && in_the_loop()) { + return fea_title($title); + } + return $title; +}, 20, 2); + +// <title> del documento — núcleo WP (cuando Yoast no lo sobrescribe). +add_filter('document_title_parts', function($parts) { + if (is_admin() || !function_exists('fea_title')) return $parts; + if (!empty($parts['title'])) $parts['title'] = fea_title($parts['title']); + return $parts; +}, 20); + +// <title>/OG/Twitter vía Yoast: normaliza solo la porción del título del post. +$fea_seo_title = function($title) { + if (is_admin() || !is_singular() || !function_exists('fea_title')) return $title; + $raw = get_post_field('post_title', get_queried_object_id()); + if ($raw && mb_strpos($title, $raw) !== false) { + $title = str_replace($raw, fea_title($raw), $title); + } + return $title; +}; +add_filter('wpseo_title', $fea_seo_title, 20); +add_filter('wpseo_opengraph_title', $fea_seo_title, 20); +add_filter('wpseo_twitter_title', $fea_seo_title, 20); + +// Título del item en feeds RSS. +add_filter('the_title_rss', function($title) { + return function_exists('fea_title') ? fea_title($title) : $title; +}, 20); + +// Asegura que option_page_on_front devuelve la página traducida al idioma actual +// (necesario para is_front_page() y para que el template FSE lo reconozca) +add_filter('option_page_on_front', function($value) { + if (!function_exists('pll_current_language') || !function_exists('pll_get_post')) return $value; + static $running = false; + if ($running) return $value; // evitar recursión + $running = true; + $lang = pll_current_language(); + $running = false; + if (!$lang || $lang === 'es') return $value; + $translated = pll_get_post((int) $value, $lang); + return $translated ?: $value; +}); + +// ── Traducir links de categoría "Esta semana" / "Semana pasada" al idioma actual ─ +// Las categorías traducidas EN/FR/IT/PT dan 404 (bug WP_Tax_Query AND 0=1 con Polylang). +// Solución: redirigir ES cat URL → post traducido directamente (no a la categoría). +// Para "Otras semanas" (cartas-de-otras-semanas, count=729): se deja como está. +add_action('wp_footer', function() { + if (!function_exists('pll_current_language') || !function_exists('pll_get_post')) return; + $lang = pll_current_language(); + if (!$lang || $lang === 'es') return; + + global $wpdb; + $map = []; + + // Categorías de carta única (count=1) → redirigir al post traducido + $single_carta_cats = [ + 'cartasemana' => 6, + 'carta-semana-pasada' => 22, + ]; + foreach ($single_carta_cats as $slug => $es_cat_id) { + // term_taxonomy_id de la categoría ES (bypassa Polylang) + $tt_id = (int) $wpdb->get_var($wpdb->prepare( + "SELECT term_taxonomy_id FROM {$wpdb->term_taxonomy} + WHERE term_id=%d AND taxonomy='category'", $es_cat_id + )); + if (!$tt_id) continue; + // Post ES más reciente en esa categoría + $es_post_id = (int) $wpdb->get_var($wpdb->prepare( + "SELECT p.ID FROM {$wpdb->posts} p + JOIN {$wpdb->term_relationships} tr ON p.ID=tr.object_id + WHERE tr.term_taxonomy_id=%d AND p.post_status='publish' + ORDER BY p.post_date DESC LIMIT 1", $tt_id + )); + if (!$es_post_id) continue; + // Post traducido + $trans_id = pll_get_post($es_post_id, $lang); + if (!$trans_id) continue; + $trans_url = get_permalink($trans_id); + if ($trans_url) { + $map[home_url('/category/' . $slug . '/')] = $trans_url; + } + } + if (empty($map)) return; + ?> + <script> + (function(){ + var map = <?php echo json_encode($map); ?>; + document.querySelectorAll('a[href]').forEach(function(a){ + var clean = a.href.split('?')[0]; + if (map[clean]) a.href = map[clean]; + }); + })(); + </script> + <?php +}, 25); + +// ── 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', + ]); +}); + +// ── Selector de idioma (dropdown con bandera + 2 letras) ───────────────── +add_action('wp_head', function() { + if (!function_exists('pll_the_languages')) return; + ?> + <style> + #fea-lang-switcher { + position: relative; + display: inline-flex; + align-items: center; + font-family: inherit; + z-index: 9999; + } + #fea-lang-btn { + display: inline-flex; + align-items: center; + gap: 5px; + background: none; + border: 1px solid rgba(0,0,0,0.25); + border-radius: 5px; + padding: 4px 9px; + cursor: pointer; + font-size: 0.82rem; + font-weight: 700; + color: inherit; + line-height: 1.4; + white-space: nowrap; + } + #fea-lang-btn:hover { background: rgba(0,0,0,0.06); } + #fea-lang-btn .arrow { font-size: 0.6rem; opacity: 0.6; } + #fea-lang-dropdown { + display: none; + position: absolute; + top: calc(100% + 4px); + right: 0; + background: #fff; + border: 1px solid #ddd; + border-radius: 7px; + box-shadow: 0 6px 16px rgba(0,0,0,0.13); + min-width: 90px; + padding: 4px 0; + list-style: none; + margin: 0; + } + #fea-lang-switcher.open #fea-lang-dropdown { display: block; } + #fea-lang-dropdown li { margin: 0; padding: 0; } + #fea-lang-dropdown a { + display: flex; + align-items: center; + gap: 7px; + padding: 7px 14px; + font-size: 0.82rem; + font-weight: 600; + text-decoration: none; + color: #222; + white-space: nowrap; + } + #fea-lang-dropdown a:hover { background: #f5f5f5; } + #fea-lang-dropdown a[aria-current] { font-weight: 700; color: #000; background: #efefef; } + #fea-lang-dropdown a.fea-lang-untrans { opacity: 0.45; } + #fea-lang-dropdown a.fea-lang-untrans::after { content: "·"; margin-left: 4px; opacity: 0.7; } + </style> + <?php +}); + +add_action('wp_footer', function() { + if (!function_exists('pll_the_languages')) return; + + $flags = ['es' => '🇪🇸', 'en' => '🇬🇧', 'fr' => '🇫🇷', 'it' => '🇮🇹', 'pt' => '🇵🇹']; + + $langs = pll_the_languages(['raw' => 1, 'hide_if_no_translation' => 0]); + if (!$langs) return; + + $current = array_filter($langs, fn($l) => $l['current_lang']); + $current = $current ? array_values($current)[0] : null; + $cur_slug = $current ? $current['slug'] : 'es'; + $cur_flag = $flags[$cur_slug] ?? '🌐'; + $cur_code = strtoupper($cur_slug); + + $items = ''; + foreach ($langs as $l) { + $flag = $flags[$l['slug']] ?? '🌐'; + $code = strtoupper($l['slug']); + $cur = $l['current_lang'] ? ' aria-current="true"' : ''; + // Marcar los idiomas SIN traducción de esta página (Polylang enlaza al inicio del + // idioma): se atenúan y avisan, para no hacer creer que traducen el contenido actual. + $notr = !empty($l['no_translation']) && empty($l['current_lang']); + $cls = $notr ? ' class="fea-lang-untrans"' : ''; + $ttl = $notr ? ' title="Esta página no está traducida — irás al inicio en ' . esc_attr($code) . '"' : ''; + $items .= '<li><a href="' . esc_url($l['url']) . '"' . $cur . $cls . $ttl . '>' . $flag . ' ' . $code . '</a></li>'; + } + ?> + <div id="fea-lang-switcher" role="navigation" aria-label="Idioma / Language"> + <button id="fea-lang-btn" aria-haspopup="listbox" aria-expanded="false" + onclick="var s=this.closest('#fea-lang-switcher'),o=s.classList.toggle('open');this.setAttribute('aria-expanded',o)"> + <?= $cur_flag ?> <?= $cur_code ?> <span class="arrow">▼</span> + </button> + <ul id="fea-lang-dropdown" role="listbox"><?= $items ?></ul> + </div> + <script> + (function() { + var sw = document.getElementById('fea-lang-switcher'); + if (!sw) return; + // Cerrar al clicar fuera + document.addEventListener('click', function(e) { + if (!sw.contains(e.target)) { + sw.classList.remove('open'); + document.getElementById('fea-lang-btn').setAttribute('aria-expanded', 'false'); + } + }); + // Inyectar en el nav principal del header + function inject() { + // Buscar la barra de navegación principal (primer nivel dentro del header) + var header = document.querySelector('header.wp-block-template-part'); + if (!header) header = document.querySelector('header'); + if (!header) return; + // Buscar el div/group que contiene el menú de navegación + var navGroup = header.querySelector('.wp-block-navigation__container'); + if (!navGroup) navGroup = header.querySelector('nav'); + if (!navGroup) { + // Fallback: poner en el header con posición absoluta + header.style.position = 'relative'; + sw.style.cssText = 'position:absolute;top:50%;right:1.5rem;transform:translateY(-50%);'; + header.appendChild(sw); + return; + } + // Crear li wrapper para el nav + var li = document.createElement('li'); + li.className = 'wp-block-navigation-item'; + li.style.cssText = 'display:flex;align-items:center;margin-left:0.5rem;'; + li.appendChild(sw); + // Insertar DESPUÉS del buscador (si existe) + var searchItem = navGroup.querySelector('.wp-block-search'); + if (searchItem) { + // Subir hasta el li/div hermano directo del navGroup + var anchor = searchItem; + while (anchor.parentNode && anchor.parentNode !== navGroup) { + anchor = anchor.parentNode; + } + anchor.parentNode.insertBefore(li, anchor.nextSibling); + } else { + navGroup.appendChild(li); + } + } + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', inject); + } else { + inject(); + } + })(); + </script> + <?php +}, 20); + +// ── 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 (!fea_is_front_page()) return; + ?> + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> + <link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,600;9..144,700&display=swap" rel="stylesheet"> + <style> + /* ── Hero: Carta (izq) + Carrusel (der) en banda beige full-width ── */ + .fea-hero-band { max-width: none !important; width: 100vw; margin-left: calc(50% - 50vw) !important; margin-right: calc(50% - 50vw) !important; background: linear-gradient(180deg, #efe9e1, #f4f0ea); border-bottom: 1px solid #e4ddd1; margin-bottom: 3rem; } + .fea-hero-inner { max-width: 1180px; margin: 0 auto; padding: 2.75rem 28px; display: grid; grid-template-columns: minmax(260px, 400px) minmax(0, 1fr); gap: 2.5rem; align-items: center; } + .fea-hero-text { min-width: 0; } + .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.18em; text-transform: uppercase; color: #8b1a2e; margin-bottom: 0.7rem; } + .fea-hero-title { font-family: 'Fraunces', Georgia, serif; font-size: clamp(2rem, 3.4vw, 3rem); font-weight: 600; line-height: 1.08; letter-spacing: -0.01em; margin: 0 0 1rem; color: #2a2320; } + .fea-hero-meta { display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; color: #6f655c; } + .fea-hero-cta { display: inline-block; margin-top: 1.25rem; background: #8b1a2e; color: #fff; font-weight: 600; font-size: 0.92rem; padding: 0.7rem 1.4rem; border-radius: 8px; } + .fea-hero-slider { min-width: 0; overflow: hidden; } + .fea-hero-slider .n2-ss-align { max-width: 100% !important; width: 100% !important; } + @media (max-width: 860px) { .fea-hero-inner { grid-template-columns: 1fr; gap: 1.75rem; } } + + /* Ancho de las secciones de portada: contenedor centrado (aire a los lados) */ + body.home .fea-section, .fea-front .fea-section { max-width: 1180px !important; margin-left: auto !important; margin-right: auto !important; } + + /* ── Secciones — dirección "Cálido editorial" (Mockup A, #57) ── */ + .fea-section { margin-bottom: 3.75rem; padding-top: 0.5rem; } + .fea-section-title { display: flex; align-items: center; gap: 1rem; font-family: 'Fraunces', Georgia, serif; font-size: 1.65rem; font-weight: 600; letter-spacing: -0.01em; text-transform: none; color: #2a2320; margin: 0 0 1.6rem; padding: 0; border: 0; } + .fea-section-title::after { content: ''; display: inline-block; flex: 0 0 46px; height: 3px; background: #8b1a2e; border-radius: 2px; } + .fea-section-head { display: flex; align-items: center; gap: 1rem; margin-bottom: 1.6rem; } + .fea-section-head .fea-section-title { flex: 1 1 auto; min-width: 0; margin-bottom: 0; } + .fea-section-more { flex: 0 0 auto; color: #8b1a2e; font-size: 0.86rem; font-weight: 700; text-decoration: none; white-space: nowrap; } + .fea-section-more:hover { text-decoration: underline; text-underline-offset: 3px; } + @media (max-width: 480px) { + .fea-section-head { gap: 0.65rem; } + .fea-section-head .fea-section-title { font-size: 1.45rem; gap: 0.65rem; } + .fea-section-head .fea-section-title::after { flex-basis: 28px; } + .fea-section-more { font-size: 0.8rem; } + } + + .fea-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1.6rem; } + @media (max-width: 980px) { .fea-grid { grid-template-columns: repeat(3, 1fr); } } + @media (max-width: 680px) { .fea-grid { grid-template-columns: 1fr 1fr; } } + @media (max-width: 420px) { .fea-grid { grid-template-columns: 1fr; } } + + .fea-card { background: #fff; border: 1px solid #efe7d8; border-radius: 14px; padding: 1.5rem 1rem; text-align: center; transition: transform .18s ease, box-shadow .18s ease; } + .fea-card:hover { transform: translateY(-5px); box-shadow: 0 18px 36px -20px rgba(42,35,32,.45); } + /* Ocultar visualmente el texto de accesibilidad del Smart Slider (nombres de fichero con guiones bajos) */ + .n2-ss-slide--focus { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0,0,0,0) !important; white-space: nowrap !important; border: 0 !important; } + .fea-card-avatar-link { display: block; } + .fea-card-avatar { box-shadow: 0 0 0 3px #f7ecd9, 0 0 0 4px #e7d7bb; transition: transform .2s ease; } + .fea-card:hover .fea-card-avatar { transform: scale(1.05); } + .fea-card-author { color: #8b1a2e; font-weight: 600; font-size: 0.9rem; margin: 0.95rem 0 0.5rem; line-height: 1.25; } + .fea-card-title { font-size: 0.98rem; font-weight: 600; line-height: 1.32; margin: 0; } + .fea-card-title a { text-decoration: none; color: #2a2320; } + .fea-card-title a:hover { text-decoration: underline; text-underline-offset: 3px; } + + /* ── Footer portada: contener anchos, aire a los lados (no entre medias) ── */ + /* Reduce el espaciado preset 80 (enorme) solo dentro del footer, sin tocar listas */ + .fea-footer-portada { max-width: 1180px; margin-left: auto; margin-right: auto; --wp--preset--spacing--80: 3rem; } + /* Las 3 imágenes (Librería / Noticias / Portal-EFFA): columnas iguales, aires iguales */ + .fea-footer-portada .wp-block-columns { max-width: 900px; margin-left: auto; margin-right: auto; gap: 2.5rem !important; align-items: start; justify-content: center; } + .fea-footer-portada .wp-block-columns .wp-block-column { flex: 1 1 0 !important; width: auto !important; min-width: 0; display: flex; flex-direction: column; align-items: center; text-align: center; } + .fea-footer-portada .wp-block-columns .wp-block-column img { max-width: 100%; width: auto; height: auto; margin-left: auto; margin-right: auto; } + /* Las columnas de enlaces: centrarlas (aire a los lados, no entre medias) */ + .fea-footer-portada .wp-block-group.is-content-justification-space-between { justify-content: center !important; gap: 3.5rem !important; } + </style> + <?php +}); + +// ── Slider del hero a prueba de fuego: llena la columna y, si es más estrecha +// que el ancho base del slider (Smart Slider no baja de él), lo escala con transform ── +add_action('wp_footer', function() { + if (!fea_is_front_page()) return; + ?> + <script> + (function () { + var els = function () { + return { + c: document.querySelector('.fea-hero-slider'), + s: document.querySelector('.fea-hero-slider .n2-ss-slider') + }; + }; + var lastW = -1, busy = false, raf = 0; + function apply() { + var e = els(); + if (!e.c || !e.s) return; + // reset para medir el tamaño natural que decide Smart Slider + e.s.style.transform = ''; + e.s.style.transformOrigin = ''; + e.c.style.height = ''; + // que Smart Slider recalcule (crece hasta llenar la columna si cabe) + busy = true; window.dispatchEvent(new Event('resize')); busy = false; + requestAnimationFrame(function () { + var avail = e.c.clientWidth; + var rect = e.s.getBoundingClientRect(); + if (rect.width > avail + 1) { // columna más estrecha que el slider → escalar + var k = avail / rect.width; + e.s.style.transformOrigin = 'top left'; + e.s.style.transform = 'scale(' + k + ')'; + e.c.style.height = Math.round(rect.height * k) + 'px'; + } + }); + } + function schedule() { cancelAnimationFrame(raf); raf = requestAnimationFrame(apply); } + function onChange(force) { + if (busy) return; + var c = els().c; if (!c) return; + var w = c.clientWidth; + if (!force && w === lastW) return; // solo reaccionar a cambios de ANCHO (evita bucle por la altura) + lastW = w; schedule(); + } + window.addEventListener('load', function () { onChange(true); setTimeout(function(){onChange(true);}, 300); setTimeout(function(){onChange(true);}, 800); }); + window.addEventListener('resize', function () { onChange(false); }); + if ('ResizeObserver' in window) { + var c = els().c; + if (c) new ResizeObserver(function () { onChange(false); }).observe(c); + } + })(); + </script> + <?php +}, 99); + +// ── Estilos para listados/archivos (categoría, autor, fecha, búsqueda) #63 ── +add_action('wp_head', function() { + if (is_admin() || fea_is_front_page()) return; + if (!(is_archive() || is_search() || is_home())) return; + ?> + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,600;9..144,700&display=swap" rel="stylesheet"> + <style> + /* Título del archivo: serif con barra granate (coherente con la portada) */ + .wp-block-query-title { font-family: 'Fraunces', Georgia, serif !important; font-weight: 600 !important; color: #2a2320 !important; letter-spacing: -0.01em; display: flex; align-items: center; gap: 1rem; } + .wp-block-query-title::after { content: ''; flex: 0 0 46px; height: 3px; background: #8b1a2e; border-radius: 2px; } + /* Anchura coherente con la portada */ + .wp-block-query.alignwide { max-width: 1180px !important; margin-left: auto !important; margin-right: auto !important; } + /* Grid de tarjetas */ + .fea-archive-grid { display: grid !important; grid-template-columns: repeat(3, 1fr) !important; gap: 1.5rem !important; } + @media (max-width: 900px) { .fea-archive-grid { grid-template-columns: repeat(2, 1fr) !important; } } + @media (max-width: 560px) { .fea-archive-grid { grid-template-columns: 1fr !important; } } + .fea-archive-card { background: #fff; border: 1px solid #efe7d8; border-radius: 14px; padding: 1.4rem 1.3rem; height: 100%; display: flex; flex-direction: column; transition: transform .18s ease, box-shadow .18s ease; } + .fea-archive-card:hover { transform: translateY(-4px); box-shadow: 0 18px 36px -20px rgba(42,35,32,.45); } + .fea-archive-title { margin: 0 !important; } + .fea-archive-title a { font-family: 'Fraunces', Georgia, serif; font-weight: 600; color: #2a2320; text-decoration: none; line-height: 1.25; } + .fea-archive-card:hover .fea-archive-title a { color: #8b1a2e; } + .fea-archive-date { margin: 0.45rem 0 0 !important; } + .fea-archive-date a, .fea-archive-date { color: #8b1a2e !important; font-weight: 600; text-decoration: none; } + .fea-archive-excerpt { margin: 0.6rem 0 0 !important; color: #6f655c; font-size: 0.92rem; line-height: 1.45; } + .fea-archive-excerpt a { display: none; } + </style> + <?php +}); + + +// ── Ocultar título de página en todas las portadas ──────────────────────── +add_action('wp_head', function() { + if (!fea_is_front_page()) return; + echo '<style>.wp-block-post-title { display:none !important; }</style>'; +}, 20); + +// ── H1 semántico oculto para páginas sin título visible ─────────────────── +add_action('wp_head', function() { + ?> + <style> + .fea-sr-only { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + clip-path: inset(50%) !important; + white-space: nowrap !important; + border: 0 !important; + } + </style> + <?php +}, 20); + +add_filter('the_content', function($content) { + if (is_admin() || !is_main_query() || !in_the_loop()) return $content; + if (preg_match('/<h1\b/i', $content)) return $content; + + $title = ''; + if (fea_is_front_page()) { + $title = get_bloginfo('name') ?: 'Fe Adulta'; + } elseif (fea_is_escuela_page()) { + $title = 'Escuela de Formación en Fe Adulta'; + } + + if (!$title) return $content; + + return '<h1 class="fea-sr-only fea-page-h1">' . esc_html($title) . '</h1>' . $content; +}, 5); + +// ── Título <title> para páginas sin post_title propio (Escuela) ─────────── +add_filter('document_title_parts', function($parts) { + if (fea_is_escuela_page()) { + $parts['title'] = 'Escuela de Formación en Fe Adulta'; + } + return $parts; +}); + +// ── Ocultar metadatos de post en páginas estáticas ──────────────────────── +add_action('wp_head', function() { + if (!fea_hide_static_meta()) return; + ?> + <style> + .wp-block-group:has(> .wp-block-avatar):has(.wp-block-post-author-name), + .wp-block-post-author-name, + .wp-block-post-terms.taxonomy-category { + display: none !important; + } + </style> + <?php +}, 20); + +// ── 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="' . esc_attr($author_name) . '" 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; } + .wp-block-group:has(> .wp-block-avatar):has(.wp-block-post-author-name) { + align-items: center; + gap: 0.7rem; + } + .wp-block-group:has(> .wp-block-avatar):has(.wp-block-post-author-name) > .wp-block-avatar { + flex: 0 0 54px; + } + .wp-block-group:has(> .wp-block-avatar):has(.wp-block-post-author-name) > .wp-block-avatar img { + width: 54px !important; + height: 54px !important; + } + .fea-post-date-inline { + font-size: 0.74rem; + line-height: 1.2; + color: #888; + margin-top: 0.08rem; + } + .fea-post-date-inline time { white-space: nowrap; } + @media (max-width: 720px) { + .wp-block-group:has(> .wp-block-avatar):has(.wp-block-post-author-name) > .wp-block-avatar { + flex-basis: 50px; + } + .wp-block-group:has(> .wp-block-avatar):has(.wp-block-post-author-name) > .wp-block-avatar img { + width: 50px !important; + height: 50px !important; + } + } + </style> + <?php +}); + +add_filter('render_block_core/post-terms', function($block_content, $block) { + if (!is_single() || get_post_type() !== 'post') return $block_content; + + $taxonomy = $block['attrs']['term'] ?? ''; + if ($taxonomy !== 'category') return $block_content; + if (strpos($block_content, 'fea-post-date-inline') !== false) return $block_content; + + $date_attr = esc_attr(get_the_date('c')); + $date_text = esc_html(ucfirst(wp_date(get_option('date_format'), get_post_timestamp()))); + if ($date_text === '') return $block_content; + + return $block_content + . '<div class="fea-post-date-inline"><time datetime="' . $date_attr . '">' . $date_text . '</time></div>'; +}, 10, 2); + +// ── Helpers ─────────────────────────────────────────────────────────────── +function fea_title(string $title): string { + $lower = mb_strtolower($title, 'UTF-8'); + $out = mb_strtoupper(mb_substr($lower, 0, 1, 'UTF-8'), 'UTF-8') . mb_substr($lower, 1, null, 'UTF-8'); + // Capitalizar también la primera letra tras separadores de cláusula (citas bíblicas + // dobles "Isaías 5,1 / Filipenses 4,6", subtítulos "Título: Subtítulo", ¿…?, ¡…!). + $out = preg_replace_callback('/([\/:¿¡] *)(\p{Ll})/u', function ($m) { + return $m[1] . mb_strtoupper($m[2], 'UTF-8'); + }, $out); + return $out; +} + +/** Lista de libros bíblicos (para avatar genérico de lecturas/eucaristías). #61 */ +function fea_libros_biblicos(): array { + return [ + 'Nuevo Testamento','Antiguo Testamento', + 'Génesis','Éxodo','Levítico','Números','Deuteronomio','Josué','Jueces','Rut', + 'Samuel','Reyes','Crónicas','Esdras','Nehemías','Tobías','Judit','Ester','Macabeos', + 'Job','Salmos','Salmo','Proverbios','Eclesiastés','Cantar','Sabiduría','Eclesiástico','Sirácide', + 'Isaías','Jeremías','Lamentaciones','Baruc','Ezequiel','Daniel', + 'Oseas','Joel','Amós','Abdías','Jonás','Miqueas','Nahúm','Habacuc','Sofonías','Ageo','Zacarías','Malaquías', + 'Hechos','Romanos','Corintios','Gálatas','Efesios','Filipenses','Colosenses','Tesalonicenses', + 'Timoteo','Tito','Filemón','Hebreos','Santiago','Pedro','Judas','Apocalipsis', + ]; +} + +/** Devuelve la abreviatura del evangelista si el texto es una cita de evangelio, o ''. #61 */ +function fea_evangelista_de_texto(string $txt): string { + if (preg_match('/^\s*(Mateo|Mt|Marcos|Mc|Lucas|Lc|Juan|Jn)\b\.?\s*\d/iu', $txt, $m)) { + $k = mb_strtolower($m[1], 'UTF-8'); + $map = ['mateo'=>'mateo-angel','mt'=>'mateo-angel','marcos'=>'marcos-leon','mc'=>'marcos-leon', + 'lucas'=>'lucas-toro','lc'=>'lucas-toro','juan'=>'juan-aguila','jn'=>'juan-aguila']; + return $map[$k] ?? ''; + } + return ''; +} + +/** Devuelve 'antiguo-testamento' / 'nuevo-testamento' si el texto es esa firma, o ''. #66 */ +function fea_testamento_de_texto(string $txt): string { + $t = mb_strtolower(trim($txt), 'UTF-8'); + if ($t === 'antiguo testamento') return 'antiguo-testamento'; + if ($t === 'nuevo testamento') return 'nuevo-testamento'; + return ''; +} + +/** True si el texto empieza por un libro bíblico (cita) o es el nombre de un libro. #61 */ +function fea_es_libro_biblico(string $txt): bool { + $txt = trim($txt); + foreach (fea_libros_biblicos() as $libro) { + if (mb_strtolower($txt, 'UTF-8') === mb_strtolower($libro, 'UTF-8')) return true; + if (preg_match('/^' . preg_quote($libro, '/') . '\b/iu', $txt)) return true; + } + return false; +} + +/** + * URL del avatar para un post de portada/listado. #61 + * - Citas de evangelio (Mt/Mc/Lc/Jn + número) → símbolo del evangelista. + * - Lecturas/eucaristías firmadas por un libro bíblico → Biblia. + * - Resto → avatar real del autor. + */ +function fea_avatar_url(object $post, int $size, int $author_id, string $author_name): string { + $base = content_url('uploads/avatares/evangelistas/'); + // Solo la LECTURA en sí (cuyo título es la cita, p.ej. "Mateo 9, 36") lleva símbolo de + // evangelista; los comentaristas humanos conservan su avatar aunque comenten ese evangelio. + $title = (string) $post->post_title; + // Las traducciones tienen el título en su idioma (Matteo, Geremia, Romani…) que no + // reconocemos. Usamos el título del ORIGINAL ES, donde sí detectamos el libro → la + // traducción hereda el mismo símbolo que el ES (#135). + if (function_exists('pll_get_post_language') && pll_get_post_language($post->ID) !== 'es') { + $es = function_exists('pll_get_post') ? pll_get_post($post->ID, 'es') : 0; + if ($es) { + $esp = get_post($es); + if ($esp) $title = (string) $esp->post_title; + } + } + if ($ev = fea_evangelista_de_texto($title)) return $base . $ev . '.svg'; + // "Antiguo Testamento" / "Nuevo Testamento" llevan símbolo propio (no el genérico). #66 + if ($t = fea_testamento_de_texto($title) ?: fea_testamento_de_texto($author_name)) return $base . $t . '.svg'; + if (fea_es_libro_biblico($title) || fea_es_libro_biblico($author_name)) return $base . 'biblia.svg'; + return get_avatar_url($author_id, ['size' => $size, 'default' => 'identicon']); +} + +function fea_card(object $post): string { + $author_id = $post->post_author; + $author_name = get_the_author_meta('display_name', $author_id); + $avatar_url = fea_avatar_url($post, 96, (int) $author_id, (string) $author_name); + $url = get_permalink($post->ID); + $title = fea_title($post->post_title); + return '<article class="fea-card">' + . '<a href="' . esc_url($url) . '" class="fea-card-avatar-link" aria-label="' . esc_attr($author_name) . '">' + . '<span class="fea-card-avatar" style="display:block;width:84px;height:84px;border-radius:50%;overflow:hidden;margin:0 auto;">' + . '<img src="' . esc_url($avatar_url) . '" alt="' . esc_attr($author_name) . '" width="84" height="84" class="fea-avatar" style="width:100%;height:100%;object-fit:cover;display:block;" loading="lazy">' + . '</span>' + . '</a>' + . '<div class="fea-card-author">' . esc_html($author_name) . '</div>' + . '<h3 class="fea-card-title"><a href="' . esc_url($url) . '">' . esc_html($title) . '</a></h3>' + . '</article>'; +} + +/** IDs de las páginas de portada en todos los idiomas. */ +function fea_front_page_ids(): array { + return [26542, 42756, 42757, 42758, 43889]; // ES=26542, PT=42756, IT=42757, FR=42758, EN=43889 (FR/PT corregidos #75) +} + +/** True si la página actual es alguna de las portadas (multilingüe). */ +function fea_is_front_page(): bool { + if (is_front_page()) return true; + $id = get_queried_object_id(); + return $id && in_array($id, fea_front_page_ids(), true); +} + +/** True para la landing de Escuela, que no muestra título visible. */ +function fea_is_escuela_page(): bool { + if (!is_page()) return false; + $page = get_queried_object(); + return $page instanceof WP_Post && $page->post_name === 'escuela'; +} + +/** True para contenidos importados que funcionan como páginas institucionales. */ +function fea_hide_static_meta(): bool { + if (is_admin() || fea_is_front_page()) return false; + + $post = get_queried_object(); + if (!$post instanceof WP_Post) return false; + + if ($post->post_type === 'page') return true; + + $slugs = [ + 'colaboradores', + 'contactar', + 'multimedia', + 'ayuda', + 'video-tutorial', + 'como-usar-el-buscador-avanzado', + 'portal', + 'paraponeraldialafe', + 'alta', + 'alta-en-effa', + 'regala', + 'catalogo-de-publicaciones-2018', + 'nueva-politica-de-privacidad', + ]; + + return in_array($post->post_name, $slugs, true); +} + +/** Devuelve el idioma actual de Polylang, o 'es' si no está activo. */ +function fea_current_lang(): string { + return (function_exists('pll_current_language') ? pll_current_language() : null) ?: 'es'; +} + +/** Etiquetas de sección traducidas por idioma. */ +function fea_labels(): array { + $all = [ + 'es' => [ + 'carta' => 'Carta de la semana', + 'evangelio' => 'Comentarios al evangelio', + 'articulos' => 'Artículos de esta semana', + 'eucaristia' => 'Para una eucaristía más participativa', + 'multimedia' => 'Multimedia', + ], + 'en' => [ + 'carta' => 'Letter of the week', + 'evangelio' => 'Gospel commentary', + 'articulos' => 'Articles of the week', + 'eucaristia' => 'For a more participatory Eucharist', + 'multimedia' => 'Multimedia', + ], + 'fr' => [ + 'carta' => 'Lettre de la semaine', + 'evangelio' => 'Commentaires de l\'évangile', + 'articulos' => 'Articles de la semaine', + 'eucaristia' => 'Pour une eucharistie plus participative', + 'multimedia' => 'Multimédia', + ], + 'it' => [ + 'carta' => 'Lettera della settimana', + 'evangelio' => 'Commenti al vangelo', + 'articulos' => 'Articoli della settimana', + 'eucaristia' => 'Per una eucaristia più partecipativa', + 'multimedia' => 'Multimedia', + ], + 'pt' => [ + 'carta' => 'Carta da semana', + 'evangelio' => 'Comentários ao evangelho', + 'articulos' => 'Artigos da semana', + 'eucaristia' => 'Para uma eucaristia mais participativa', + 'multimedia' => 'Multimédia', + ], + ]; + return $all[fea_current_lang()] ?? $all['es']; +} + +/** + * ID de la página de portada para el idioma actual. + */ +function fea_front_page_id(): int { + $es_front = 26542; + $lang = fea_current_lang(); + if ($lang === 'es') return $es_front; + if (function_exists('pll_get_post')) { + $translated = pll_get_post($es_front, $lang); + if ($translated) return (int) $translated; + } + return $es_front; +} + +/** + * Traduce un term_id de categoría ES al idioma actual. + * Si no hay traducción disponible, devuelve el ID original. + */ +function fea_cat(int $es_cat_id): int { + $lang = fea_current_lang(); + if ($lang === 'es') return $es_cat_id; + if (function_exists('pll_get_term')) { + $translated = pll_get_term($es_cat_id, $lang); + if ($translated) return (int) $translated; + } + return $es_cat_id; +} + +// ── Enlace "Evangelio del día · fecha" ──────────────────────────────────── +// Píldora discreta, estilo "fecha con contenido", a la página del Evangelio de +// cada día. Se inserta dentro del hero de la carta (portada). Multiidioma. +function fea_eed_link_html() { + $tz = new DateTimeZone('Europe/Madrid'); + $now = new DateTimeImmutable('now', $tz); + $d = (int) $now->format('j'); + $m = (int) $now->format('n'); + $lang = function_exists('fea_current_lang') ? fea_current_lang() : 'es'; + + $LBL = ['es'=>'Evangelio del día','en'=>'Gospel of the day','fr'=>'Évangile du jour','it'=>'Vangelo del giorno','pt'=>'Evangelho do dia']; + $MES = [ + 'es'=>[1=>'enero','febrero','marzo','abril','mayo','junio','julio','agosto','septiembre','octubre','noviembre','diciembre'], + 'en'=>[1=>'January','February','March','April','May','June','July','August','September','October','November','December'], + 'fr'=>[1=>'janvier','février','mars','avril','mai','juin','juillet','août','septembre','octobre','novembre','décembre'], + 'it'=>[1=>'gennaio','febbraio','marzo','aprile','maggio','giugno','luglio','agosto','settembre','ottobre','novembre','dicembre'], + 'pt'=>[1=>'janeiro','fevereiro','março','abril','maio','junho','julho','agosto','setembro','outubro','novembro','dezembro'], + ]; + $L = isset($MES[$lang]) ? $lang : 'es'; + $mes = $MES[$L][$m]; + if ($L === 'es' || $L === 'pt') $fecha = $d . ' de ' . $mes; + elseif ($L === 'en') $fecha = $mes . ' ' . $d; + else $fecha = $d . ' ' . $mes; // fr, it + + // get_posts usa suppress_filters=true → Polylang no filtra y siempre + // devuelve la página ES. Resolvemos la traducción del idioma actual. + $pg = get_posts(['name'=>'evangelio-de-cada-dia','post_type'=>'post','post_status'=>'publish','numberposts'=>1]); + $pid = $pg ? (int) $pg[0]->ID : 0; + if ($pid && $lang !== 'es' && function_exists('pll_get_post')) { + $tr = pll_get_post($pid, $lang); + if ($tr) $pid = (int) $tr; + } + $url = $pid ? get_permalink($pid) : home_url('/evangelio-de-cada-dia/'); + + $css = '<style>' + . '.fea-eed-link{margin:1.6rem 0 0}' + . '.fea-eed-link a{display:inline-flex;align-items:center;gap:.5rem;color:#8b1a2e;text-decoration:none;font-size:.92rem;line-height:1}' + . '.fea-eed-link a:hover{text-decoration:underline;text-underline-offset:3px}' + . '.fea-eed-link .lbl{font-weight:700}' + . '.fea-eed-link .sep{color:#caa9b0}' + . '.fea-eed-link .fecha{color:#6f655c}' + . '.fea-eed-link .arrow{color:#8b1a2e;font-weight:700}' + . '</style>'; + + return $css . '<div class="fea-eed-link"><a href="' . esc_url($url) . '">' + . '<span class="lbl">' . esc_html($LBL[$L]) . '</span>' + . '<span class="sep">·</span>' + . '<span class="fecha">' . esc_html($fecha) . '</span>' + . '<span class="arrow">›</span></a></div>'; +} + +// ── Shortcode: [fea_carta_semana_hero] ──────────────────────────────────── +add_shortcode('fea_carta_semana_hero', function() { + $lang = fea_current_lang(); + $labels = fea_labels(); + + $cartas = get_posts([ + 'posts_per_page' => 1, + 'category__in' => [fea_cat(6)], + 'post_status' => 'publish', + 'orderby' => 'date', + 'order' => 'DESC', + ]); + if (!$cartas) return ''; + + $c = $cartas[0]; + $url = get_permalink($c->ID); + $fecha = date_i18n('j F 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']); + $slider = do_shortcode('[smartslider3 slider="2"]'); + + return '<section class="fea-hero-band"><div class="fea-hero-inner">' + . '<div class="fea-hero-text">' + . '<a href="' . esc_url($url) . '" class="fea-hero-link">' + . '<span class="fea-section-label">' . esc_html($labels['carta']) . '</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="' . esc_attr($author_name) . '" width="32" height="32" class="fea-avatar" style="width:32px;height:32px;border-radius:50%;object-fit:cover;">' + . '<span>' . esc_html($author_name) . ' · ' . $fecha . '</span>' + . '</div>' + . '<span class="fea-hero-cta">' . esc_html($labels['carta'] ? 'Leer la carta' : 'Leer la carta') . ' →</span>' + . '</a>' + . fea_eed_link_html() + . '</div>' + . '<div class="fea-hero-slider">' . $slider . '</div>' + . '</div></section>'; +}); + +// ── Shortcode: [fea_articulos_semana] ───────────────────────────────────── +// Fuente principal: links de la sección "Artículos seleccionados" de la carta vigente. +// Fallbacks: ACF portada_articulos → últimos por categoría. +add_shortcode('fea_articulos_semana', function($atts) { + $lang = fea_current_lang(); + $labels = fea_labels(); + $atts = shortcode_atts(['titulo' => $labels['articulos']], $atts); + $page = fea_front_page_id(); + + // 1) Fuente principal: sección "Artículos seleccionados" de la carta vigente + $posts = function_exists('fea_carta_section_posts') ? fea_carta_section_posts('articulos', $lang) : []; + + // 2) Fallback ACF (solo ES) + if (empty($posts) && $lang === 'es' && function_exists('get_field')) { + $seleccion = get_field('portada_articulos', $page) ?: []; + foreach ($seleccion as $p) { + if ($p->post_status === 'publish') $posts[] = $p; + } + } + + // 3) Fallback: últimos artículos en el idioma actual + if (empty($posts)) { + $posts = get_posts([ + 'posts_per_page' => 9, + 'category__in' => [fea_cat(1650)], + 'category__not_in' => array_map('fea_cat', [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] ──────────────────────────────────────────── +// Fuente principal: sección "Evangelio y comentarios" de la carta vigente. +// Fallback: editorial cat 1646 + comentarios cat 1647 por fecha. +add_shortcode('fea_evangelio', function($atts) { + $lang = fea_current_lang(); + $labels = fea_labels(); + $atts = shortcode_atts(['titulo' => $labels['evangelio']], $atts); + + $posts = function_exists('fea_carta_section_posts') ? fea_carta_section_posts('evangelio', $lang) : []; + + if (empty($posts)) { + $editorial = get_posts([ + 'posts_per_page' => 1, + 'category__in' => [fea_cat(1646)], + 'post_status' => 'publish', + 'orderby' => 'date', + 'order' => 'DESC', + ]); + $comentarios = get_posts([ + 'posts_per_page' => 6, + 'category__in' => [fea_cat(1647)], + 'category__not_in' => [fea_cat(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_evangelio_diario] ───────────────────────────────────── +// "El Evangelio de cada día": dos devocionales diarios indexados por día del año. +// · A la fuente cada día (texto, Fray Marcos) → categoría term_id 14 +// · Otro evangelio es posible (vídeo YouTube) → categoría term_id 15 +// Los posts están titulados "D mes" (ej. "21 junio"). Se muestra el de HOY +// (zona horaria del sitio) o el día indicado por ?fed=M-D, con pestañas para +// que el usuario elija formato y navegación día anterior / siguiente. +// Contenido solo en ES (devocional sin traducción) → categorías 14/15 fijas. +add_shortcode('fea_evangelio_diario', function($atts) { + $MESES = [1=>'enero',2=>'febrero',3=>'marzo',4=>'abril',5=>'mayo',6=>'junio', + 7=>'julio',8=>'agosto',9=>'septiembre',10=>'octubre',11=>'noviembre',12=>'diciembre']; + // Día litúrgico de referencia: España (la web es ES); el servidor va en UTC. + $tz = new DateTimeZone('Europe/Madrid'); + $now = new DateTimeImmutable('now', $tz); + $m = (int) $now->format('n'); + $d = (int) $now->format('j'); + if (!empty($_GET['fed']) && preg_match('/^(\d{1,2})-(\d{1,2})$/', $_GET['fed'], $mm)) { + $gm = (int) $mm[1]; $gd = (int) $mm[2]; + if ($gm >= 1 && $gm <= 12 && $gd >= 1 && $gd <= 31) { $m = $gm; $d = $gd; } + } + $titulo_dia = $d . ' ' . $MESES[$m]; // "21 junio" + + $find = function($cat) use ($titulo_dia) { + global $wpdb; + $id = $wpdb->get_var($wpdb->prepare( + "SELECT p.ID 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 + WHERE tt.taxonomy = 'category' AND tt.term_id = %d + AND p.post_type = 'post' AND p.post_status = 'publish' + AND LOWER(TRIM(p.post_title)) = %s + ORDER BY p.ID ASC LIMIT 1", + $cat, $titulo_dia)); + return $id ? get_post((int) $id) : null; + }; + $texto = $find(14); + $video = $find(15); + + // navegación por día del calendario (año irrelevante; usamos un año bisiesto fijo) + $base = get_permalink(); + $cur = DateTimeImmutable::createFromFormat('!Y-n-j', '2024-' . $m . '-' . $d, $tz); + $prev = $cur->modify('-1 day'); + $next = $cur->modify('+1 day'); + $lbl = function($dt) use ($MESES) { return ((int)$dt->format('j')) . ' ' . substr($MESES[(int)$dt->format('n')], 0, 3); }; + $href = function($dt) use ($base) { return esc_url(add_query_arg('fed', $dt->format('n') . '-' . $dt->format('j'), $base)); }; + + $render = function($post, $tipo) { + if (!$post) { + return '<p class="fea-eed-empty">No hay ' . ($tipo === 'video' ? 'vídeo' : 'texto') . ' disponible para este día.</p>'; + } + $c = apply_filters('the_content', $post->post_content); + return '<div class="fea-eed-art">' . $c . '</div>'; + }; + + $css = '<style>' + . '.fea-eed{max-width:760px;margin:0 auto}' + . '.fea-eed-head{display:flex;align-items:center;justify-content:space-between;gap:.5rem;margin:.2rem 0 1.1rem}' + . '.fea-eed-date{font-size:1.15rem;color:#8b1a2e;margin:0;text-align:center;flex:1}' + . '.fea-eed-nav{white-space:nowrap;color:#8b1a2e;text-decoration:none;font-weight:600;font-size:.9rem;padding:.3rem .6rem;border:1px solid #e2cdd2;border-radius:8px}' + . '.fea-eed-nav:hover{background:#faf3f4}' + . '.fea-eed-tabs input{position:absolute;opacity:0;pointer-events:none}' + . '.fea-eed-labels{display:flex;gap:.5rem;border-bottom:2px solid #e2cdd2;margin-bottom:1.2rem}' + . '.fea-eed-labels label{flex:1;text-align:center;cursor:pointer;padding:.6rem 1rem;font-weight:700;color:#888;border:2px solid transparent;border-bottom:none;border-radius:8px 8px 0 0;margin-bottom:-2px}' + . '#fed-texto:checked~.fea-eed-labels label[for=fed-texto],' + . '#fed-video:checked~.fea-eed-labels label[for=fed-video]{color:#8b1a2e;border-color:#e2cdd2;background:#faf3f4}' + . '.fea-eed-panel{display:none}' + . '#fed-texto:checked~.fea-eed-panel-texto{display:block}' + . '#fed-video:checked~.fea-eed-panel-video{display:block}' + . '.fea-eed-art iframe{max-width:100%}' + . '.fea-eed-panel-video .fea-eed-art iframe{width:100%;max-width:600px;height:auto;aspect-ratio:16/9;display:block;margin:0 auto}' + . '.fea-eed-art h1{font-size:1.4rem;line-height:1.3;text-align:center}' + . '.fea-eed-empty{color:#888;font-style:italic;text-align:center;padding:1.5rem}' + . '.wp-block-post-title{text-align:center}' + . '</style>'; + + $h = $css . '<div class="fea-eed">'; + $h .= '<div class="fea-eed-head">' + . '<a class="fea-eed-nav" href="' . $href($prev) . '">‹ ' . esc_html($lbl($prev)) . '</a>' + . '<h2 class="fea-eed-date">' . esc_html($d . ' de ' . $MESES[$m]) . '</h2>' + . '<a class="fea-eed-nav" href="' . $href($next) . '">' . esc_html($lbl($next)) . ' ›</a>' + . '</div>'; + $h .= '<div class="fea-eed-tabs">' + . '<input type="radio" name="fed-tab" id="fed-texto" checked>' + . '<input type="radio" name="fed-tab" id="fed-video">' + . '<div class="fea-eed-labels">' + . '<label for="fed-texto">A la fuente cada día</label>' + . '<label for="fed-video">Otro evangelio es posible (vídeo)</label>' + . '</div>' + . '<div class="fea-eed-panel fea-eed-panel-texto">' . $render($texto, 'texto') . '</div>' + . '<div class="fea-eed-panel fea-eed-panel-video">' . $render($video, 'video') . '</div>' + . '</div></div>'; + return $h; +}); + +// ── Shortcode: [fea_eucaristia] ─────────────────────────────────────────── +// Fuente principal: sección "Para unas eucaristías más participativas" de la carta. +// Fallback: cat 1648 por fecha. +add_shortcode('fea_eucaristia', function($atts) { + $lang = fea_current_lang(); + $labels = fea_labels(); + $atts = shortcode_atts(['titulo' => $labels['eucaristia']], $atts); + + $posts = function_exists('fea_carta_section_posts') ? fea_carta_section_posts('eucaristia', $lang) : []; + + if (empty($posts)) { + $posts = get_posts([ + 'posts_per_page' => 6, + 'category__in' => [fea_cat(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] ─────────────────────────────────────────── +// Fuente principal: sección "Material multimedia" de la carta vigente. +// Fallbacks: ACF portada_multimedia → últimos por categoría. +add_shortcode('fea_multimedia', function($atts) { + $lang = fea_current_lang(); + // Multimedia es contenido ES y externo (sin traducir) → solo se muestra en + // español de momento; en otros idiomas la sección se oculta (#135). + if ($lang !== 'es') return ''; + $labels = fea_labels(); + $atts = shortcode_atts(['titulo' => $labels['multimedia']], $atts); + $page = fea_front_page_id(); + $more_labels = [ + 'es' => 'Ver más', + 'en' => 'View all', + 'fr' => 'Voir plus', + 'it' => 'Vedi tutto', + 'pt' => 'Ver mais', + ]; + $more_label = $more_labels[$lang] ?? $more_labels['es']; + $index_id = 18977; + if ($lang !== 'es' && function_exists('pll_get_post')) { + $translated_index = (int) pll_get_post($index_id, $lang); + if ($translated_index) $index_id = $translated_index; + } + $index_url = get_permalink($index_id); + + $posts = function_exists('fea_carta_section_posts') ? fea_carta_section_posts('multimedia', $lang) : []; + + if (empty($posts) && $lang === 'es' && 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' => array_map('fea_cat', [1649, 26, 58]), + 'post_status' => 'publish', + 'orderby' => 'date', + 'order' => 'DESC', + ]); + } + if (!$posts) return ''; + + $html = '<section class="fea-section">' + . '<div class="fea-section-head">' + . '<h2 class="fea-section-title">' . esc_html($atts['titulo']) . '</h2>' + . ($index_url ? '<a class="fea-section-more" href="' . esc_url($index_url) . '">' + . esc_html($more_label) . ' <span aria-hidden="true">→</span></a>' : '') + . '</div>' + . '<div class="fea-grid">'; + foreach ($posts as $post) $html .= fea_card($post); + return $html . '</div></section>'; +}); + +// ── Shortcode: [fea_noticia_centro] — bloque central del footer (Noticias) ─── +add_shortcode('fea_noticia_centro', function() { + $uploads = wp_upload_dir()['baseurl']; + $cat_url = get_category_link(fea_cat(41)); + $latest = get_posts([ + 'posts_per_page' => 1, + 'category__in' => [fea_cat(41)], + 'post_status' => 'publish', + 'orderby' => 'date', + 'order' => 'DESC', + ]); + $title = !empty($latest) ? fea_title($latest[0]->post_title) : ''; + $url = !empty($latest) ? get_permalink($latest[0]->ID) : $cat_url; + + $html = '<a href="' . esc_url($cat_url) . '">'; + $html .= '<img src="' . esc_url($uploads . '/recursos/noticias_2025.jpg') . '" ' + . 'alt="Noticias de alcance" width="300" height="340" ' + . 'style="display:block;margin:0 auto;" />'; + $html .= '</a>'; + if ($title) { + $html .= '<p style="text-align:center;margin-top:0.5rem;font-size:0.85rem;font-weight:600;line-height:1.3;">' + . '<a href="' . esc_url($url) . '" style="color:#0000cc;">' + . esc_html($title) . '</a></p>'; + } + return $html; +}); + +// ── 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; + // Los archivos de cartas pueden contener cientos de enlaces. Resolver cada uno + // con url_to_postid() en un listado agota memoria; la reescritura solo aporta + // valor al mostrar el contenido completo de una entrada o página. + if (!is_singular()) 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); + +// ── Ordenar categoría Evangelios y Comentarios por título ASC ───────────── +add_action('pre_get_posts', function($query) { + if ($query->is_main_query() && $query->is_category('evangelios-y-comentarios')) { + $query->set('orderby', 'title'); + $query->set('order', 'ASC'); + } +}); + + +// ── Acordeón de versículos en posts de Evangelios y Comentarios ─────────── +add_action('wp_footer', function() { + if (!is_single()) return; + global $post; + if (!has_category('evangelios-y-comentarios', $post)) return; + ?> + <style> + .versiculo-group { margin: 0.5em 0; } + .versiculo-toggle { + display: flex; align-items: center; gap: 10px; + cursor: default; margin: 0.8em 0 0; + } + .versiculo-toggle .v-icon { + display: inline-flex; align-items: center; justify-content: center; + width: 22px; height: 22px; min-width: 22px; border-radius: 50%; + background: #c8860a; color: white; font-size: 18px; line-height: 1; + cursor: pointer; user-select: none; font-weight: bold; + transition: background 0.15s; + } + .versiculo-group.open .v-icon { background: #6b3080; } + .v-icon::after { content: '+'; } + .versiculo-group.open .v-icon::after { content: '\2212'; } + .versiculo-body { display: none; padding-left: 4px; } + .versiculo-group.open .versiculo-body { display: block; } + </style> + <script> + document.addEventListener('DOMContentLoaded', function() { + var content = document.querySelector('.wp-block-post-content, .entry-content'); + if (!content) return; + + var verseRe = /^(JUAN|LUCAS|MARCOS|MATEO)\s+\d/i; + + function isVerse(el) { + if (el.tagName !== 'P') return false; + var links = el.querySelectorAll('a'); + if (links.length !== 1) return false; + return verseRe.test(el.textContent.trim()); + } + + var children = Array.from(content.children); + var i = 0; + while (i < children.length) { + var child = children[i]; + if (isVerse(child)) { + var group = document.createElement('div'); + group.className = 'versiculo-group'; + + var header = document.createElement('div'); + header.className = 'versiculo-toggle'; + + var icon = document.createElement('span'); + icon.className = 'v-icon'; + + var link = child.querySelector('a').cloneNode(true); + header.appendChild(icon); + header.appendChild(link); + group.appendChild(header); + + var body = document.createElement('div'); + body.className = 'versiculo-body'; + + child.parentNode.insertBefore(group, child); + child.remove(); + i++; + + while (i < children.length && !isVerse(children[i])) { + body.appendChild(children[i]); + i++; + } + + group.appendChild(body); + + icon.addEventListener('click', function() { + this.closest('.versiculo-group').classList.toggle('open'); + }); + } else { + i++; + } + } + }); + </script> + <?php +}, 30); + +// ── Avatares en artículos de versículos (Evangelios y Comentarios) ───────── +add_action('wp_footer', function() { + if (!is_single()) return; + global $post, $wpdb; + if (!has_category('evangelios-y-comentarios', $post)) return; + + // Extraer todos los slugs de links internos del contenido + preg_match_all('/<a\s[^>]*href=["\']([^"\']+)["\']/', $post->post_content, $m); + $slugs = []; + foreach ($m[1] as $url) { + $slug = basename(rtrim(parse_url($url, PHP_URL_PATH), '/')); + if ($slug) { + $slugs[] = $slug; + } + } + $slugs = array_values(array_unique(array_filter($slugs))); + if (empty($slugs)) return; + + // Una sola query: slug → author_id + $ph = implode(',', array_fill(0, count($slugs), '%s')); + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT post_name, post_author FROM {$wpdb->posts} + WHERE post_name IN ($ph) AND post_status IN ('publish','draft') AND post_type='post'", + $slugs + ) + ); + + // Construir mapa slug → avatar_url (reutilizando cache de autor) + $author_cache = []; + $avatar_map = []; + foreach ($rows as $row) { + $aid = (int) $row->post_author; + if (!isset($author_cache[$aid])) { + $user = get_userdata($aid); + $author_cache[$aid] = ['src' => get_avatar_url($aid, ['size' => 40, 'default' => 'identicon']), 'name' => $user ? $user->display_name : '']; + } + if ($author_cache[$aid]) { + $avatar_map[$row->post_name] = $author_cache[$aid]; + } + } + if (empty($avatar_map)) return; + ?> + <style> + .versiculo-body ul { padding-left: 0; list-style: none; } + .versiculo-body li { display: flex; align-items: center; gap: 7px; margin-bottom: 4px; } + .fea-li-avatar { width: 24px; height: 24px; border-radius: 50%; object-fit: cover; flex-shrink: 0; } + .transl-toggle { font-size: 11px; color: #888; cursor: pointer; margin-left: 6px; user-select: none; white-space: nowrap; flex-shrink: 0; } + .transl-toggle:hover { color: #777; } + .transl-block { display: none; margin: 2px 0 6px 32px; font-size: 11px; line-height: 1.8; } + .transl-block.open { display: block; } + .transl-block a { color: #aaa; display: block; } + .transl-block a:hover { color: #555; } + </style> + <script> + (function() { + var map = <?php echo json_encode($avatar_map); ?>; + function slug(url) { + return url.replace(/\/$/, '').split('/').pop().split('?')[0].split('#')[0]; + } + document.addEventListener('DOMContentLoaded', function() { + document.querySelectorAll('.versiculo-body li').forEach(function(li) { + var a = li.querySelector('a'); + if (!a) return; + var entry = map[slug(a.href)]; + if (!entry) return; + var img = document.createElement('img'); + img.src = entry.src; + img.title = entry.name; + img.alt = ''; + img.className = 'fea-li-avatar'; + li.insertBefore(img, li.firstChild); + }); + + // Colapsar traducciones (p margin-left:90px) bajo "(…)" + document.querySelectorAll('.versiculo-body').forEach(function(body) { + Array.from(body.children).forEach(function(el) { + if (el.tagName !== 'P') return; + var ml = parseInt(el.style.marginLeft || '0'); + if (ml < 60) return; + var prev = el.previousElementSibling; + while (prev && prev.tagName !== 'UL') prev = prev.previousElementSibling; + if (!prev) return; + var lis = prev.querySelectorAll('li'); + if (!lis.length) return; + var lastLi = lis[lis.length - 1]; + el.classList.add('transl-block'); + var btn = document.createElement('span'); + btn.className = 'transl-toggle'; + btn.textContent = 'Otros idiomas (\u2026)'; + lastLi.appendChild(btn); + btn.addEventListener('click', function(e) { + e.stopPropagation(); + el.classList.toggle('open'); + }); + }); + }); + }); + })(); + </script> + <?php +}, 31); + + +// ── Shortcodes lista de autores ──────────────────────────────────────────── + +// IDs excluidos: Fe Adulta (1,890), Ediciones Feadulta (1540), José Chicharro (1049) +if (!defined('FEA_AUTORES_EXCLUIR')) define('FEA_AUTORES_EXCLUIR', [1, 890, 1049, 1540]); +if (!defined('FEA_LANG_ES_TTID')) define('FEA_LANG_ES_TTID', 1404); + +function fea_autores_query($min_count = 0, $extra_exclude = []) { + global $wpdb; + $excl = implode(',', array_merge(FEA_AUTORES_EXCLUIR, $extra_exclude)); + $ttid = FEA_LANG_ES_TTID; + $having = $min_count > 0 ? "HAVING cnt >= $min_count" : ''; + $order = $min_count > 0 ? 'cnt DESC' : 'u.display_name ASC'; + return $wpdb->get_results(" + SELECT u.ID, u.display_name, COUNT(*) as cnt + FROM {$wpdb->posts} p + JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID + JOIN {$wpdb->users} u ON u.ID = p.post_author + WHERE p.post_type = 'post' + AND p.post_status = 'publish' + AND tr.term_taxonomy_id = $ttid + AND u.ID NOT IN ($excl) + GROUP BY p.post_author + $having + ORDER BY $order + "); +} + +function fea_autores_html($rows, $show_count = false, $extra_class = "") { + $extra = $extra_class ? ' ' . $extra_class : ''; + $out = '<ul class="fea-autores-lista' . $extra . '" style="list-style:none;padding-left:0;">'; + foreach ($rows as $r) { + $url = esc_url(get_author_posts_url($r->ID)); + $name = esc_html($r->display_name); + $avatar = esc_url(get_avatar_url($r->ID, ['size' => 40, 'default' => 'identicon'])); + $cnt = $show_count ? ' <span class="fea-autor-cnt">(' . $r->cnt . ')</span>' : ''; + $out .= '<li><span style="display:inline-block;width:40px;height:40px;min-width:40px;border-radius:50%;overflow:hidden;flex-shrink:0;"><img src="' . $avatar . '" width="40" height="40" alt="" loading="lazy" style="width:40px;height:40px;object-fit:cover;display:block;"></span>  <a href="' . $url . '">' . $name . '</a>' . $cnt . '</li>'; + } + $out .= '</ul>'; + return $out; +} + +add_shortcode('fea_autores_habituales', function() { + $rows = fea_autores_query(30, [948, 1048]); + if (empty($rows)) return '<p>No hay datos disponibles.</p>'; + $n = count($rows); + $out = '<details class="fea-autores-section"><summary class="fea-autores-summary">Autores habituales <span class="fea-autor-cnt">(' . $n . ' autores)</span></summary>'; + $out .= fea_autores_html($rows, true); + $out .= '</details>'; + return $out; +}); + +add_shortcode('fea_autores_completo', function() { + $rows = fea_autores_query(0); + if (empty($rows)) return '<p>No hay datos disponibles.</p>'; + $n = count($rows); + $out = '<details class="fea-autores-section"><summary class="fea-autores-summary">Lista completa por orden alfabético <span class="fea-autor-cnt">(' . $n . ' autores)</span></summary>'; + $out .= fea_autores_html($rows, false, 'fea-autores-completo'); + $out .= '</details>'; + return $out; +}); + +add_action('wp_head', function() { + if (!is_page('autores-lista')) return; + ?> + <style> + .fea-autores-section { margin-bottom: 1.5em; } + .fea-autores-summary { + font-size: 1.3rem; font-weight: 600; cursor: pointer; + padding: 0.5em 0; list-style: none; + display: flex; align-items: center; gap: 0.5em; + border-bottom: 2px solid #046bd2; margin-bottom: 0.8em; + user-select: none; + } + .fea-autores-summary::-webkit-details-marker { display: none; } + .fea-autores-summary::before { + content: "\25B6"; font-size: 0.75em; color: #046bd2; + transition: transform 0.2s; display: inline-block; + } + details[open] > .fea-autores-summary::before { transform: rotate(90deg); } + .fea-autores-lista { list-style: none; padding: 0; margin: 0.5em 0 1em; } + .fea-autores-lista li { + display: flex; align-items: center; gap: 12px; + padding: 3px 0; border-bottom: 1px solid #f5f5f5; + } + .fea-autores-lista li img.fea-autor-avatar { + width: 40px; height: 40px; min-width: 40px; + border-radius: 50%; clip-path: circle(50%); + object-fit: cover; display: block; flex-shrink: 0; + } + .fea-autor-cnt { color: #888; font-size: 0.85em; } + .fea-autores-completo { column-count: 3; column-gap: 2em; } + .fea-autores-completo li { break-inside: avoid; } + @media (max-width: 700px) { .fea-autores-completo { column-count: 2; } } + @media (max-width: 480px) { .fea-autores-completo { column-count: 1; } } + </style> + <?php +}, 20); + +// ── Aumentar posts por página en archivos de autor ───────────────────────── +add_action('pre_get_posts', function($query) { + if ($query->is_main_query() && $query->is_author()) { + $query->set('posts_per_page', 30); + } +}); + +// ── Shortcodes EFFA (Escuela de Formación en Fe Adulta) ──────────────────── + +// Sección de vídeos (subpáginas): thumbnail YT o primera imagen del contenido +add_shortcode('effa_seccion', function($atts) { + $atts = shortcode_atts(['cat' => '', 'num' => -1], $atts); + if (!$atts['cat']) return ''; + + $posts = get_posts([ + 'numberposts' => (int) $atts['num'], + 'category_name' => $atts['cat'], + 'post_status' => 'publish', + 'orderby' => 'meta_value', + 'meta_key' => '_effa_joomla_alias', + 'order' => 'ASC', + ]); + + if (!$posts) return '<p>No hay contenido en esta sección todavía.</p>'; + + $items = []; + foreach ($posts as $p) { + $url = get_permalink($p->ID); + $thumb = get_the_post_thumbnail_url($p->ID, 'medium'); + if (!$thumb && preg_match('~youtube\.com/watch\?v=([a-zA-Z0-9_-]+)~', $p->post_content, $m)) { + $thumb = 'https://img.youtube.com/vi/' . $m[1] . '/mqdefault.jpg'; + } + if (!$thumb && preg_match('~<img[^>]+src="([^"]+)"~', $p->post_content, $mi)) { + $thumb = $mi[1]; + } + $cell = '<td style="width:25%;padding:6px;vertical-align:top;">'; + $cell .= '<a href="' . esc_url($url) . '" style="text-decoration:none;color:inherit;display:block;">'; + if ($thumb) $cell .= '<img src="' . esc_url($thumb) . '" style="width:100%;height:auto;display:block;border-radius:4px;">'; + $cell .= '<strong style="display:block;font-size:0.85rem;margin-top:5px;color:#222;">' . esc_html(fea_title($p->post_title)) . '</strong>'; + $cell .= '</a>'; + $cell .= '</td>'; + $items[] = $cell; + } + + $html = '<table class="effa-proyecto-table" style="width:100%;border-collapse:collapse;table-layout:fixed;">'; + foreach (array_chunk($items, 4) as $row) { + while (count($row) < 4) $row[] = '<td></td>'; + $html .= '<tr>' . implode('', $row) . '</tr>'; + } + $html .= '</table>'; + return $html; +}); + +// Hub del proyecto (8 artículos de presentación): primera imagen + extracto +add_shortcode('effa_proyecto', function() { + $posts = get_posts([ + 'numberposts' => -1, + 'category_name' => 'proyecat', + 'post_status' => 'publish', + 'orderby' => 'name', + 'order' => 'ASC', + ]); + if (!$posts) return ''; + + $items = []; + foreach ($posts as $p) { + $url = get_permalink($p->ID); + preg_match('~<img[^>]+src="([^"]+)"~', $p->post_content, $mi); + $thumb = $mi[1] ?? ''; + $text = trim(preg_replace('/\s+/', ' ', wp_strip_all_tags($p->post_content))); + $excerpt = wp_trim_words($text, 12, '…'); + + $cell = '<td style="width:25%;padding:6px;vertical-align:top;">'; + $cell .= '<a href="' . esc_url($url) . '" style="text-decoration:none;color:inherit;display:block;">'; + if ($thumb) $cell .= '<img src="' . esc_url($thumb) . '" style="width:100%;height:auto;display:block;border-radius:4px;">'; + $cell .= '<strong style="display:block;font-size:0.85rem;margin-top:5px;color:#222;">' . esc_html(fea_title($p->post_title)) . '</strong>'; + $cell .= '</a>'; + $cell .= '<span style="display:block;font-size:0.75rem;color:#666;margin-top:3px;line-height:1.3;">' . esc_html($excerpt) . '</span>'; + $cell .= '</td>'; + $items[] = $cell; + } + + $html = '<table class="effa-proyecto-table" style="width:100%;border-collapse:collapse;table-layout:fixed;">'; + foreach (array_chunk($items, 4) as $row) { + while (count($row) < 4) $row[] = '<td></td>'; + $html .= '<tr>' . implode('', $row) . '</tr>'; + } + $html .= '</table>'; + return $html; +}); + +// CSS EFFA (tabs nav, logo, CTA, cards) +add_action('wp_head', function() { + global $post; + if (!$post || (strpos($post->post_content, 'effa_') === false && strpos($post->post_name ?? '', 'effa') === false)) return; + ?> + <style> + .effa-logo { display: block; margin: 0 auto 1rem; max-width: 200px; } + .effa-intro { text-align: center; margin-bottom: 1.5rem; } + .effa-nav { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 1.5rem 0 2rem; padding: 0 0 1rem; list-style: none; border-bottom: 2px solid #e5e5e5; } + .effa-nav li { list-style: none; } + .effa-nav a { display: inline-block; padding: 0.35em 1em; border: 1px solid #ccc; border-radius: 999px; font-size: 0.85rem; text-decoration: none; color: #444; transition: background 0.15s, color 0.15s, border-color 0.15s; } + .effa-nav a:hover, .effa-nav a.active { background: #E89A1A; color: #fff; border-color: #E89A1A; } + .effa-cta-wrap { text-align: center; margin: 2rem 0 1rem; } + .effa-cta { display: inline-block; padding: 0.6em 1.8em; background: #E89A1A; color: #fff; border-radius: 999px; text-decoration: none; font-weight: 700; font-size: 1rem; } + .effa-cta:hover { background: #c97d10; color: #fff; } + /* Tarjetas EFFA: reflota de 4 columnas a 2 en movil */ + @media (max-width: 600px) { + .effa-proyecto-table tr { display: flex; flex-wrap: wrap; } + .effa-proyecto-table td { width: 50% !important; box-sizing: border-box; } + .effa-proyecto-table td:empty { display: none; } + } + </style> + <?php +}, 20); + +// ── Índice dinámico de evangelio por libro ───────────────────────────────── + +// "Jn 4, 5-42" → "jn-4-5-42" (anchor ID format) +function fea_cita_to_anchor(string $cita): string { + return strtolower(preg_replace('/[,\.\s]+/', '-', trim($cita))); +} + +// Parse "Jn 4, 5-42" → [4, 5] for numeric sorting +function fea_cita_sort_key(string $cita): array { + preg_match('/(\d+),\s*(\d+)/', $cita, $m); + return [(int)($m[1] ?? 0), (int)($m[2] ?? 0)]; +} + +add_shortcode('fea_citas_evangelio', function($atts) { + $atts = shortcode_atts(['libro' => 'Jn'], $atts); + $libro = preg_replace('/[^A-Za-z]/', '', $atts['libro']); // sanitize + global $wpdb; + $cat_com = 1647; + + // One query: all posts + author + avatar email in this book + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT p.ID, p.post_title, p.post_author, + pm.meta_value AS cita, + u.display_name AS autor_name, + u.user_email AS autor_email + FROM {$wpdb->posts} p + JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = '_cita_evangelio' + 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.term_id = %d + JOIN {$wpdb->users} u ON u.ID = p.post_author + WHERE p.post_status = 'publish' + AND pm.meta_value LIKE %s + ORDER BY pm.meta_value, p.post_date ASC", + $cat_com, $libro . ' %' + )); + + if (empty($rows)) return '<p>No hay comentarios para este evangelio.</p>'; + + // Group by cita + $groups = []; + foreach ($rows as $r) $groups[$r->cita][] = $r; + + // Sort groups numerically by chapter, then verse + uksort($groups, function($a, $b) { + [$ca, $va] = fea_cita_sort_key($a); + [$cb, $vb] = fea_cita_sort_key($b); + return $ca !== $cb ? $ca - $cb : $va - $vb; + }); + + // Avatar URL by email (Gravatar, with fallback) + $avatar_cache = []; + $avatar_url = function(string $email, int $id) use (&$avatar_cache): string { + if (!isset($avatar_cache[$id])) { + $url = get_avatar_url($id, ['size' => 40, 'default' => 'mystery']); + $avatar_cache[$id] = $url ?: 'https://www.gravatar.com/avatar/' . md5(strtolower(trim($email))) . '?s=40&d=mystery'; + } + return $avatar_cache[$id]; + }; + + $html = '<div class="fea-indice-evangelio">'; + + foreach ($groups as $cita => $posts) { + $n = count($posts); + $anchor = fea_cita_to_anchor($cita); + $html .= '<details class="fea-cita-group" id="' . esc_attr($anchor) . '">'; + $html .= '<summary class="fea-cita-summary">' + . '<span class="fea-cita-ref">' . esc_html($cita) . '</span>' + . ' <span class="fea-cita-count">' . $n . ' comentario' . ($n !== 1 ? 's' : '') . '</span>' + . '</summary>'; + $html .= '<ul class="fea-cita-lista">'; + foreach ($posts as $p) { + $av = $avatar_url($p->autor_email, $p->post_author); + $html .= '<li class="fea-cita-item">' + . '<span class="fea-cita-avatar">' + . '<img src="' . esc_url($av) . '" width="40" height="40" alt="" loading="lazy">' + . '</span>' + . '<span class="fea-cita-meta">' + . '<a href="' . esc_url(get_permalink($p->ID)) . '">' . esc_html(fea_title($p->post_title)) . '</a>' + . '<span class="fea-cita-autor">' . esc_html($p->autor_name) . '</span>' + . '</span>' + . '</li>'; + } + $html .= '</ul></details>'; + } + + $html .= '</div>'; + return $html; +}); + +add_action('wp_head', function() { + global $post; + if (!$post || !has_shortcode($post->post_content, 'fea_citas_evangelio')) return; + ?> + <style> + .fea-indice-evangelio { margin: 1.5rem 0; } + + .fea-cita-group { + border-bottom: 1px solid #e5e5e5; + padding: 0; + } + .fea-cita-summary { + display: flex; + align-items: baseline; + gap: 0.6rem; + padding: 0.75rem 0.5rem; + cursor: pointer; + list-style: none; + user-select: none; + } + .fea-cita-summary::-webkit-details-marker { display: none; } + .fea-cita-summary::before { + content: '›'; + font-size: 1.1rem; + color: #046bd2; + transition: transform 0.15s; + display: inline-block; + flex-shrink: 0; + } + .fea-cita-group[open] > .fea-cita-summary::before { transform: rotate(90deg); } + .fea-cita-ref { + font-weight: 700; + font-size: 0.95rem; + } + .fea-cita-count { + font-size: 0.8rem; + color: #888; + } + + .fea-cita-lista { + list-style: none; + padding: 0 0 0.75rem 1.5rem; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; + } + .fea-cita-item { + display: flex; + align-items: center; + gap: 0.75rem; + } + .fea-cita-avatar { + display: inline-block; + width: 40px; height: 40px; min-width: 40px; + border-radius: 50%; + overflow: hidden; + flex-shrink: 0; + } + .fea-cita-avatar img { + width: 40px; height: 40px; + object-fit: cover; display: block; + } + .fea-cita-meta { + display: flex; + flex-direction: column; + gap: 0.1rem; + line-height: 1.3; + } + .fea-cita-meta a { font-size: 0.9rem; } + .fea-cita-autor { + font-size: 0.78rem; + color: #888; + } + </style> + <?php +}, 20); + +// ── Pie de comentario: carta, cita evangelio, otros comentarios ──────────── + +// Add id anchors to verse headings in the 4 gospel index pages +add_filter('the_content', function($content) { + global $post; + $index_ids = [17874, 17881, 17882, 17883]; + if (!isset($post->ID) || !in_array($post->ID, $index_ids)) return $content; + + $bookmap = ['JUAN'=>'Jn','LUCAS'=>'Lc','MARCOS'=>'Mc','MATEO'=>'Mt']; + + return preg_replace_callback( + '~<p([^>]*)>\s*(<a[^>]*>)\s*((JUAN|LUCAS|MARCOS|MATEO)\s+(\d+),\s*([\d\-–\s]+))\s*(</a>)\s*</p>~i', + function($m) use ($bookmap) { + $abbr = $bookmap[strtoupper($m[4])] ?? ''; + if (!$abbr) return $m[0]; + $anchor = strtolower($abbr . '-' . $m[5] . '-' . preg_replace('/[\s–\-]+/', '-', trim($m[6]))); + $anchor = rtrim($anchor, '-'); + return '<p' . $m[1] . ' id="' . $anchor . '">' . $m[2] . $m[3] . $m[7] . '</p>'; + }, + $content + ); +}, 9); // priority 9 so it runs before the_content shortcode processing + +add_filter('the_content', function($content) { + if (!is_single()) return $content; + global $post; + if (!in_category('comentarios-al-evangelio', $post)) return $content; + + $carta_id = get_post_meta($post->ID, '_carta_id', true); + $cita = get_post_meta($post->ID, '_cita_evangelio', true); + + if (!$carta_id && !$cita) return $content; + + $html = '<div class="fea-com-footer">'; + + // --- Carta de la semana --- + if ($carta_id) { + $carta = get_post($carta_id); + if ($carta && $carta->post_status === 'publish') { + $fecha = date_i18n('j F Y', strtotime($carta->post_date)); + $html .= '<div class="fea-com-row fea-com-carta">' + . '<span class="fea-com-label">Carta de la semana</span>' + . '<a href="' . esc_url(get_permalink($carta_id)) . '">' + . esc_html(fea_title($carta->post_title)) + . ' <span class="fea-com-fecha">(' . $fecha . ')</span>' + . '</a>' + . '</div>'; + } + } + + // --- Cita del evangelio — link directo al anchor en el índice --- + if ($cita) { + $book_post_id = ['Mt' => 43908, 'Mc' => 43909, 'Lc' => 43907, 'Jn' => 43906]; + $abbr = substr($cita, 0, 2); + $anchor = fea_cita_to_anchor($cita); + $index_url = isset($book_post_id[$abbr]) + ? esc_url(get_permalink($book_post_id[$abbr]) . '#' . $anchor) + : ''; + + $html .= '<div class="fea-com-row fea-com-cita">' + . '<span class="fea-com-label">Evangelio</span>' + . ($index_url + ? '<a href="' . $index_url . '">' . esc_html($cita) . '</a>' + : '<span>' . esc_html($cita) . '</span>') + . '</div>'; + } + + // --- Otros comentarios de esta misma cita --- + if ($cita) { + $others = get_posts([ + 'post_type' => 'post', + 'post_status' => 'publish', + 'posts_per_page' => 30, + 'post__not_in' => [$post->ID], + 'category_name' => 'comentarios-al-evangelio', + 'meta_key' => '_cita_evangelio', + 'meta_value' => $cita, + 'orderby' => 'date', + 'order' => 'ASC', + ]); + + if (!empty($others)) { + $html .= '<div class="fea-com-row fea-com-otros">' + . '<span class="fea-com-label">Otros comentarios sobre ' . esc_html($cita) . '</span>' + . '<ul class="fea-com-lista">'; + foreach ($others as $o) { + $html .= '<li><a href="' . esc_url(get_permalink($o->ID)) . '">' + . esc_html(fea_title($o->post_title)) . '</a></li>'; + } + $html .= '</ul></div>'; + } + } + + $html .= '</div>'; // .fea-com-footer + return $content . $html; +}); + +add_action('wp_head', function() { + if (!is_single()) return; + global $post; + if (!$post || !in_category('comentarios-al-evangelio', $post)) return; + ?> + <style> + .fea-com-footer { + margin-top: 2.5rem; + padding-top: 1.5rem; + border-top: 2px solid #e5e5e5; + font-size: 0.9rem; + } + .fea-com-row { + display: flex; + flex-wrap: wrap; + gap: 0.4rem 0.8rem; + align-items: baseline; + margin-bottom: 1rem; + } + .fea-com-label { + font-weight: 700; + color: #555; + white-space: nowrap; + flex-shrink: 0; + } + .fea-com-label::after { content: ':'; margin-right: 0.2em; } + .fea-com-fecha { color: #888; font-size: 0.85em; } + .fea-com-lista { + list-style: none; + padding: 0; + margin: 0.3rem 0 0; + display: flex; + flex-wrap: wrap; + gap: 0.3rem 0; + flex-direction: column; + } + .fea-com-lista li::before { + content: '›'; + margin-right: 0.4em; + color: #046bd2; + } + .fea-com-otros .fea-com-label { display: block; margin-bottom: 0.4rem; } + .fea-com-otros { flex-direction: column; align-items: flex-start; } + </style> + <?php +}, 20); diff --git a/mu-plugins/fea-menu-i18n.php b/mu-plugins/fea-menu-i18n.php new file mode 100644 index 0000000..53239c9 --- /dev/null +++ b/mu-plugins/fea-menu-i18n.php @@ -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); diff --git a/mu-plugins/fea-pensamientos.php b/mu-plugins/fea-pensamientos.php new file mode 100644 index 0000000..d7f627c --- /dev/null +++ b/mu-plugins/fea-pensamientos.php @@ -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); diff --git a/mu-plugins/fea-recopilatorios.php b/mu-plugins/fea-recopilatorios.php new file mode 100644 index 0000000..123b42d --- /dev/null +++ b/mu-plugins/fea-recopilatorios.php @@ -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); diff --git a/mu-plugins/fea-search-advanced.php b/mu-plugins/fea-search-advanced.php new file mode 100644 index 0000000..9d6d5fd --- /dev/null +++ b/mu-plugins/fea-search-advanced.php @@ -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); diff --git a/mu-plugins/fea-search-fulltext.php b/mu-plugins/fea-search-fulltext.php new file mode 100644 index 0000000..7fb43e7 --- /dev/null +++ b/mu-plugins/fea-search-fulltext.php @@ -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); diff --git a/mu-plugins/fea-search.php b/mu-plugins/fea-search.php new file mode 100644 index 0000000..55d4c88 --- /dev/null +++ b/mu-plugins/fea-search.php @@ -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 +}); diff --git a/mu-plugins/fea-share.php b/mu-plugins/fea-share.php new file mode 100644 index 0000000..dd523f1 --- /dev/null +++ b/mu-plugins/fea-share.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); diff --git a/mu-plugins/fea-slider-sync.php b/mu-plugins/fea-slider-sync.php new file mode 100644 index 0000000..837f364 --- /dev/null +++ b/mu-plugins/fea-slider-sync.php @@ -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. diff --git a/mu-plugins/fea-support-campaign.php b/mu-plugins/fea-support-campaign.php new file mode 100755 index 0000000..333df42 --- /dev/null +++ b/mu-plugins/fea-support-campaign.php @@ -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(); +}); diff --git a/mu-plugins/fea-support-campaign/template.php b/mu-plugins/fea-support-campaign/template.php new file mode 100755 index 0000000..24bb94f --- /dev/null +++ b/mu-plugins/fea-support-campaign/template.php @@ -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(); diff --git a/mu-plugins/fea-ui.php b/mu-plugins/fea-ui.php new file mode 100644 index 0000000..04056a4 --- /dev/null +++ b/mu-plugins/fea-ui.php @@ -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); diff --git a/mu-plugins/stop-redirects.php b/mu-plugins/stop-redirects.php new file mode 100644 index 0000000..a500be6 --- /dev/null +++ b/mu-plugins/stop-redirects.php @@ -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); diff --git a/scripts/aplicar_clasificacion_a_bd.py b/scripts/aplicar_clasificacion_a_bd.py new file mode 100644 index 0000000..b07e10b --- /dev/null +++ b/scripts/aplicar_clasificacion_a_bd.py @@ -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() diff --git a/scripts/apply_lecturas_wp.php b/scripts/apply_lecturas_wp.php new file mode 100644 index 0000000..a16e8f1 --- /dev/null +++ b/scripts/apply_lecturas_wp.php @@ -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"; diff --git a/scripts/assign_author_photos.php b/scripts/assign_author_photos.php new file mode 100644 index 0000000..c5d7e9e --- /dev/null +++ b/scripts/assign_author_photos.php @@ -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"; diff --git a/scripts/assign_polylang_languages.php b/scripts/assign_polylang_languages.php new file mode 100644 index 0000000..a2c780a --- /dev/null +++ b/scripts/assign_polylang_languages.php @@ -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"; diff --git a/scripts/assign_polylang_prod.php b/scripts/assign_polylang_prod.php new file mode 100644 index 0000000..3aeb9b9 --- /dev/null +++ b/scripts/assign_polylang_prod.php @@ -0,0 +1,63 @@ +<?php +require_once dirname(__FILE__) . '/wp-load.php'; + +if ( ! isset($_GET['run']) || $_GET['secret'] !== 'fea2026' ) die('Acceso denegado. ?run=1&secret=fea2026'); +if ( ! function_exists('pll_set_post_language') ) die("Polylang no activo."); + +@ini_set('memory_limit', '256M'); +@ini_set('max_execution_time', '300'); +set_time_limit(300); +header('Content-Type: text/plain; charset=utf-8'); + +$offset = isset($_GET['offset']) ? (int)$_GET['offset'] : 0; +$batch = 500; + +$k2_langs = [104=>'it',105=>'fr',108=>'en',112=>'it',125=>'it',127=>'fr',129=>'en',135=>'it',136=>'it',137=>'fr',138=>'en',139=>'fr',140=>'en',142=>'fr',143=>'en',157=>'it',158=>'fr',163=>'fr',164=>'en',172=>'it',173=>'fr',174=>'fr',175=>'en',178=>'it',179=>'fr',192=>'fr',193=>'en',208=>'fr',210=>'en',222=>'fr',223=>'en',271=>'fr',272=>'it',276=>'fr',277=>'en',278=>'fr',281=>'fr',300=>'fr',301=>'it',303=>'fr',310=>'fr',442=>'fr',461=>'it',462=>'fr',463=>'fr',464=>'fr',465=>'pt',466=>'fr',467=>'en',482=>'fr',487=>'it',488=>'fr',489=>'fr',490=>'fr',491=>'fr',492=>'en',493=>'pt',498=>'pt',500=>'it',501=>'fr',502=>'fr',503=>'en',506=>'fr',507=>'fr',719=>'en',720=>'fr',721=>'pt',722=>'fr',726=>'fr',727=>'it',755=>'fr',886=>'fr',891=>'it',892=>'fr',893=>'fr',894=>'fr',895=>'en',896=>'pt',998=>'fr',1003=>'it',1004=>'fr',1005=>'fr',1006=>'pt',1007=>'pt',1081=>'pt',1082=>'fr',1083=>'en',1084=>'fr',1089=>'it',1090=>'fr',1091=>'fr',1107=>'pt',1108=>'fr',1109=>'fr',1110=>'pt',1114=>'pt',1115=>'it',1131=>'pt',1132=>'en',1133=>'en',1134=>'en',1135=>'pt',1136=>'pt',1137=>'fr',1138=>'en',1142=>'fr',1316=>'pt',1420=>'pt',1421=>'en',1422=>'pt',1423=>'en',1424=>'pt',1425=>'pt',1426=>'en',1427=>'it',1447=>'it',1518=>'pt',1519=>'en',1520=>'fr',1521=>'fr',1522=>'en',1523=>'en',1524=>'pt',1525=>'pt',1530=>'fr',1531=>'it',1547=>'pt',1727=>'pt',1728=>'en',1729=>'pt',1730=>'en',1731=>'en',1732=>'pt',1733=>'pt',1734=>'fr',1735=>'en',1739=>'fr',1740=>'it',1741=>'fr',1742=>'fr',1806=>'pt',1807=>'en',1808=>'fr',1809=>'fr',1810=>'fr',1811=>'en',1812=>'en',1813=>'pt',1814=>'pt',1815=>'it',2031=>'pt',2032=>'pt',2033=>'en',2034=>'en',2035=>'pt',2036=>'en',2041=>'fr',2042=>'it',2043=>'fr',2044=>'fr',2045=>'en',2046=>'fr',2112=>'pt',2113=>'pt',2432=>'en',2433=>'en',2434=>'en',2435=>'pt',2436=>'pt',2437=>'pt',2442=>'fr',2443=>'it',2444=>'fr',2445=>'en',2446=>'fr',2447=>'pt',2448=>'fr',2718=>'pt',2719=>'pt',2720=>'en',2721=>'en',2723=>'fr',2724=>'it',2725=>'fr',2726=>'en',2727=>'en',2728=>'pt',2729=>'fr',2730=>'pt',2731=>'fr',2732=>'fr',2755=>'pt',2756=>'en',2758=>'pt',2759=>'en',2761=>'pt',2762=>'en',2764=>'fr',2771=>'fr',2772=>'it',2775=>'fr',2781=>'pt',2782=>'pt',2783=>'fr',2791=>'pt',2792=>'pt',2794=>'fr',2795=>'pt',2797=>'fr',2803=>'fr',2804=>'it',2807=>'fr',2808=>'en',2811=>'fr',2815=>'en',2816=>'en',2817=>'en',2819=>'en',2820=>'pt',2822=>'en',2823=>'pt',2825=>'en',2826=>'pt',2831=>'fr',2844=>'it',2845=>'fr',2846=>'en',2847=>'fr',2848=>'fr',2864=>'pt',2865=>'en',2866=>'fr',2867=>'pt',2868=>'en',2869=>'pt',2870=>'en',2871=>'pt',2872=>'en',2877=>'it',2878=>'fr',2879=>'en',2880=>'fr',2881=>'pt',2882=>'fr',2888=>'fr',2889=>'en',2890=>'pt',2894=>'en',2895=>'pt',2897=>'en',2898=>'pt',2900=>'en',2901=>'pt',2911=>'fr',2912=>'en',2917=>'fr',2918=>'it',2919=>'fr',2920=>'fr',2922=>'fr',2923=>'en',2924=>'pt',2928=>'pt',2930=>'pt',2932=>'pt',2939=>'fr',2940=>'it',2944=>'fr',2948=>'en',2949=>'en',2950=>'en',2951=>'fr',2952=>'en',2953=>'fr',2956=>'en',2957=>'pt',2959=>'en',2960=>'pt',2962=>'en',2963=>'pt',2965=>'en',2966=>'fr',2967=>'pt',2975=>'fr',2976=>'it',2982=>'fr',2983=>'en',2986=>'fr',2987=>'pt',2989=>'fr',2990=>'en',2991=>'pt',2994=>'fr',2996=>'en',2997=>'pt',2999=>'en',3000=>'pt',3002=>'en',3003=>'pt',3011=>'fr',3012=>'it',3017=>'fr',3020=>'en',3022=>'fr',3023=>'fr',3024=>'en',3025=>'pt',3028=>'en',3029=>'pt',3031=>'en',3032=>'pt',3033=>'en',3034=>'pt',3042=>'it',3051=>'fr',3052=>'fr',3053=>'fr',3054=>'en',3055=>'pt',3057=>'pt',3059=>'pt',3060=>'pt',3068=>'it',3074=>'en',3075=>'pt',3078=>'fr',3079=>'en',3080=>'en',3081=>'en',3082=>'pt',3083=>'fr',3086=>'fr',3087=>'en',3088=>'pt',3090=>'pt',3092=>'pt',3093=>'pt',3109=>'en',3110=>'pt',3111=>'pt',3112=>'en',3113=>'en',3114=>'en',3115=>'fr',3116=>'it',3117=>'fr',3119=>'fr',3120=>'en',3121=>'pt',3123=>'pt',3125=>'pt',3127=>'pt',3138=>'en',3139=>'en',3140=>'en',3141=>'fr',3145=>'pt',3146=>'fr',3156=>'it',3175=>'fr',3176=>'en',3177=>'pt',3178=>'en',3179=>'en',3180=>'en',3190=>'it',3191=>'fr',3192=>'en',3193=>'fr',3194=>'pt',3197=>'en',3198=>'pt',3200=>'en',3201=>'pt',3203=>'pt',3210=>'fr',3211=>'it',3217=>'fr',3221=>'en',3222=>'fr',3224=>'fr',3225=>'en',3226=>'pt',3238=>'it',3242=>'fr',3243=>'en',3247=>'pt',3250=>'fr',3251=>'fr',3252=>'fr',3253=>'en',3254=>'pt',3265=>'fr',3266=>'it',3274=>'fr',3275=>'fr',3276=>'fr',3277=>'en',3278=>'pt',3280=>'en',3282=>'en',3284=>'en',3294=>'fr',3295=>'en',3299=>'pt',3301=>'fr',3303=>'fr',3304=>'fr',3305=>'en',3306=>'pt',3308=>'pt',3310=>'pt',3312=>'pt',3327=>'en',3328=>'pt',3329=>'en',3330=>'en',3331=>'en',3332=>'fr',3333=>'it',3334=>'it',3335=>'fr',3336=>'fr',3337=>'en',3338=>'pt',3348=>'it',3356=>'en',3358=>'en',3359=>'fr',3360=>'pt',3361=>'fr',3362=>'fr',3363=>'en',3364=>'pt',3366=>'pt',3367=>'pt',3369=>'pt',3377=>'fr',3383=>'it',3384=>'fr',3385=>'en',3386=>'fr',3388=>'en',3389=>'en',3390=>'en',3391=>'fr',3392=>'pt',3393=>'fr',3394=>'en',3395=>'pt',3405=>'it',3413=>'fr',3415=>'en',3416=>'en',3417=>'fr',3418=>'pt',3419=>'pt',3420=>'pt',3421=>'fr',3422=>'en',3423=>'pt',3427=>'pt',3435=>'fr',3436=>'it',3444=>'fr',3445=>'fr',3446=>'fr',3447=>'en',3448=>'pt',3457=>'it',3465=>'fr',3466=>'en',3468=>'fr',3469=>'pt',3470=>'fr',3471=>'fr',3485=>'en',3486=>'en',3487=>'en',3492=>'it',3493=>'it',3494=>'fr',3495=>'fr',3496=>'fr',3508=>'fr',3514=>'it',3522=>'fr',3523=>'fr',3524=>'fr',3525=>'en',3526=>'pt',3535=>'fr',3536=>'it',3546=>'fr',3547=>'pt',3548=>'fr',3551=>'pt',3556=>'fr',3557=>'en',3558=>'pt',3566=>'fr',3567=>'it',3570=>'fr',3571=>'fr',3572=>'fr',3573=>'fr',3574=>'en',3575=>'pt',3578=>'pt',3585=>'fr',3586=>'it',3589=>'pt',3591=>'fr',3592=>'en',3598=>'fr',3599=>'fr',3600=>'en',3601=>'pt',3609=>'fr',3615=>'en',3619=>'it',3620=>'fr',3621=>'fr',3624=>'fr',3625=>'en',3626=>'pt',3633=>'it',3636=>'pt',3638=>'fr',3639=>'en',3644=>'fr',3645=>'fr',3646=>'en',3647=>'pt',3655=>'fr',3656=>'it',3659=>'en',3666=>'fr',3667=>'en',3668=>'pt',3670=>'fr',3677=>'it',3685=>'fr',3687=>'fr',3688=>'fr',3689=>'pt',3690=>'fr',3691=>'en',3692=>'pt',3700=>'fr',3701=>'it',3706=>'en',3711=>'fr',3712=>'fr',3713=>'en',3714=>'pt',3717=>'fr',3718=>'it',3730=>'pt',3734=>'fr',3736=>'fr',3737=>'en',3738=>'pt',3747=>'fr',3753=>'it',3754=>'fr',3758=>'fr',3759=>'fr',3760=>'en',3761=>'pt',3774=>'pt',3779=>'it',3780=>'en',3781=>'en',3782=>'fr',3783=>'fr',3784=>'fr',3785=>'en',3786=>'pt',3794=>'fr',3795=>'it',3803=>'fr',3812=>'fr',3814=>'pt',3817=>'fr',3821=>'en',3822=>'fr',3823=>'pt',3825=>'fr',3826=>'it',3828=>'fr',3829=>'fr',3830=>'en',3831=>'en',3832=>'fr',3833=>'en',3834=>'pt',3837=>'fr',3845=>'fr',3846=>'it',3849=>'pt',3856=>'en',3857=>'en',3858=>'en',3859=>'fr',3862=>'fr',3863=>'fr',3864=>'en',3865=>'pt',3874=>'fr',3875=>'it',3882=>'fr',3885=>'en',3886=>'en',3887=>'en',3888=>'fr',3889=>'fr',3890=>'en',3891=>'pt',3896=>'fr',3904=>'fr',3905=>'it',3911=>'en',3913=>'en',3914=>'fr',3923=>'fr',3924=>'it',3925=>'fr',3928=>'fr',3929=>'en',3930=>'pt',3937=>'en',3938=>'en',3939=>'en',3941=>'fr',3942=>'en',3943=>'pt',3946=>'fr',3953=>'fr',3954=>'it',3962=>'fr',3964=>'en',3965=>'en',3968=>'fr',3970=>'fr',3971=>'it',3979=>'fr',3980=>'en',3981=>'pt',3984=>'en',3985=>'en',3987=>'fr',3988=>'en',3989=>'pt',3995=>'fr',3999=>'fr',4000=>'it',4008=>'pt',4009=>'pt',4010=>'pt',4013=>'en',4014=>'en',4015=>'en',4017=>'fr',4018=>'en',4019=>'pt',4022=>'fr',4027=>'fr',4028=>'it',4032=>'pt',4035=>'fr',4036=>'en',4040=>'en',4041=>'en',4043=>'fr',4044=>'en',4045=>'pt',4054=>'fr',4055=>'it',4063=>'pt',4064=>'pt',4067=>'fr',4068=>'en',4069=>'pt',4077=>'fr',4079=>'fr',4080=>'it',4085=>'en',4086=>'en',4087=>'en',4088=>'pt',4089=>'pt',4091=>'fr',4092=>'en',4093=>'pt',4099=>'fr',4104=>'fr',4105=>'it',4113=>'fr',4114=>'en',4115=>'pt',4121=>'fr',4122=>'it',4133=>'fr',4134=>'en',4135=>'pt',4143=>'fr',4144=>'it',4152=>'en',4153=>'en',4154=>'fr',4160=>'it',4165=>'fr',4166=>'en',4167=>'pt',4173=>'fr',4174=>'it',4179=>'fr',4180=>'fr',4181=>'fr',4182=>'en',4183=>'en',4185=>'fr',4186=>'en',4187=>'pt',4195=>'fr',4204=>'it',4205=>'fr',4206=>'en',4207=>'en',4208=>'fr',4209=>'fr',4210=>'en',4211=>'pt',4215=>'fr',4216=>'it',4226=>'fr',4227=>'en',4231=>'en',4232=>'en',4233=>'fr',4234=>'fr',4235=>'fr',4236=>'en',4237=>'pt',4245=>'fr',4246=>'it',4255=>'en',4256=>'en',4257=>'fr',4258=>'fr',4259=>'en',4260=>'pt',4268=>'fr',4269=>'it',4274=>'fr',4280=>'fr',4281=>'fr',4282=>'en',4283=>'en',4284=>'fr',4285=>'en',4286=>'pt',4295=>'fr',4296=>'it',4307=>'pt',4308=>'fr',4309=>'fr',4310=>'en',4311=>'pt',4316=>'fr',4317=>'it',4330=>'fr',4331=>'en',4332=>'en',4333=>'en',4334=>'fr',4336=>'pt',4337=>'fr',4338=>'en',4339=>'pt',4346=>'fr',4347=>'it',4356=>'fr',4358=>'en',4359=>'fr',4360=>'pt',4367=>'it',4377=>'fr',4378=>'fr',4379=>'fr',4380=>'en',4381=>'pt',4389=>'fr',4390=>'it',4401=>'fr',4402=>'en',4403=>'pt',4411=>'it',4418=>'fr',4422=>'en',4423=>'en',4424=>'en',4425=>'fr',4426=>'en',4427=>'pt',4435=>'fr',4436=>'it',4445=>'fr',4461=>'fr',4462=>'it',4469=>'en',4470=>'en',4471=>'en',4472=>'en',4473=>'en',4485=>'it',4489=>'en',4490=>'pt',4499=>'fr',4500=>'fr',4501=>'fr',4504=>'fr',4505=>'en',4506=>'pt',4515=>'fr',4525=>'en',4526=>'en',4527=>'fr',4529=>'fr',4530=>'en',4531=>'pt',4539=>'fr',4542=>'fr',4543=>'en',4549=>'it',4552=>'fr',4553=>'en',4554=>'pt',4560=>'fr',4561=>'it',4572=>'fr',4573=>'fr',4575=>'fr',4576=>'en',4577=>'pt',4585=>'it',4597=>'fr',4599=>'fr',4600=>'en',4601=>'pt',4613=>'fr',4614=>'it',4615=>'fr',4622=>'fr',4623=>'fr',4624=>'en',4625=>'pt',4636=>'fr',4637=>'it',4647=>'fr',4648=>'en',4649=>'pt',4660=>'fr',4661=>'it',4670=>'fr',4671=>'en',4672=>'pt',4684=>'fr',4685=>'it',4694=>'fr',4695=>'fr',4696=>'fr',4697=>'en',4698=>'pt',4709=>'fr',4710=>'it',4720=>'fr',4721=>'fr',4722=>'en',4723=>'pt',4732=>'it',4745=>'fr',4747=>'fr',4748=>'en',4749=>'pt',4757=>'fr',4758=>'it',4767=>'fr',4769=>'fr',4770=>'en',4771=>'pt',4782=>'fr',4783=>'it',4791=>'fr',4792=>'fr',4793=>'en',4794=>'pt',4806=>'fr',4807=>'it',4817=>'fr',4819=>'fr',4820=>'en',4821=>'pt',4832=>'it',4838=>'fr',4863=>'fr',4864=>'it',4869=>'fr',4870=>'en',4876=>'fr',4878=>'fr',4879=>'fr',4890=>'fr',4891=>'it',4903=>'fr',4904=>'en',4905=>'pt',4912=>'fr',4913=>'it',4925=>'fr',4928=>'fr',4929=>'en',4930=>'pt',4941=>'fr',4942=>'it',4952=>'fr',4953=>'en',4954=>'pt',4965=>'fr',4966=>'it',4975=>'fr',4976=>'fr',4978=>'fr',4979=>'en',4980=>'pt',4991=>'fr',4992=>'it',5000=>'fr',5002=>'fr',5003=>'en',5004=>'pt',5014=>'fr',5015=>'it',5024=>'fr',5025=>'en',5026=>'pt',5030=>'fr',5031=>'it',5047=>'fr',5048=>'en',5049=>'pt',5053=>'fr',5054=>'it',5070=>'fr',5071=>'en',5072=>'pt',5079=>'fr',5080=>'it',5093=>'fr',5095=>'fr',5096=>'en',5097=>'pt',5110=>'fr',5111=>'it',5120=>'fr',5121=>'fr',5138=>'it',5139=>'fr',5140=>'en',5141=>'pt',5144=>'fr',5145=>'fr',5146=>'fr',5147=>'en',5148=>'pt',5159=>'it',5172=>'fr',5173=>'en',5174=>'pt',5180=>'it',5190=>'fr',5191=>'en',5192=>'pt',5204=>'fr',5205=>'it',5214=>'fr',5215=>'en',5216=>'pt',5227=>'it',5236=>'fr',5238=>'fr',5239=>'en',5240=>'pt',5252=>'it',5260=>'fr',5261=>'en',5262=>'pt',5272=>'it',5283=>'fr',5284=>'en',5285=>'pt',5297=>'it',5305=>'fr',5306=>'fr',5307=>'en',5308=>'pt',5320=>'it',5330=>'fr',5331=>'en',5332=>'pt',5350=>'it',5351=>'fr',5353=>'fr',5354=>'en',5355=>'pt',5364=>'it',5373=>'fr',5374=>'en',5375=>'pt',5385=>'fr',5386=>'it',5392=>'fr',5396=>'fr',5397=>'fr',5398=>'en',5399=>'pt',5409=>'fr',5410=>'it',5420=>'fr',5421=>'en',5422=>'pt',5433=>'fr',5434=>'it',5452=>'fr',5453=>'it',5471=>'fr',5472=>'it',5478=>'fr',5482=>'fr',5484=>'fr',5485=>'en',5486=>'pt',5497=>'fr',5498=>'it',5509=>'fr',5515=>'fr',5516=>'it',5519=>'en',5520=>'fr',5521=>'pt',5522=>'fr',5523=>'en',5524=>'pt',5546=>'fr',5547=>'it',5561=>'fr',5563=>'fr',5564=>'en',5565=>'pt',5577=>'fr',5578=>'it',5592=>'fr',5593=>'en',5594=>'pt',5603=>'fr',5604=>'it',5620=>'fr',5622=>'fr',5623=>'en',5624=>'pt',5637=>'fr',5638=>'it',5649=>'fr',5650=>'en',5651=>'pt',5660=>'fr',5661=>'it',5682=>'fr',5683=>'en',5684=>'pt',5695=>'fr',5696=>'it',5722=>'fr',5723=>'it',5755=>'fr',5756=>'it',5768=>'fr',5802=>'it',5805=>'fr',5806=>'en',5807=>'pt',5810=>'fr',5811=>'it',5824=>'pt',5839=>'fr',5844=>'fr',5845=>'en',5846=>'pt',5860=>'fr',5861=>'fr',5862=>'en',5863=>'pt',5877=>'fr',5878=>'it',5889=>'fr',5890=>'fr',5891=>'fr',5892=>'pt',5905=>'fr',5906=>'it',5931=>'it',5932=>'fr',5934=>'fr',5935=>'en',5936=>'pt',5951=>'fr',5952=>'en',5953=>'pt',5964=>'fr',5975=>'it',5977=>'fr',5978=>'en',5979=>'pt',5988=>'fr',5989=>'it',6008=>'fr',6009=>'en',6010=>'pt',6022=>'fr',6023=>'it',6035=>'fr',6036=>'en',6037=>'pt',6046=>'fr',6047=>'it',6063=>'fr',6064=>'en',6065=>'pt',6074=>'fr',6075=>'it',6097=>'fr',6098=>'it',6099=>'fr',6100=>'en',6101=>'pt',6128=>'fr',6129=>'it',6146=>'fr',6147=>'en',6148=>'pt',6149=>'fr',6150=>'en',6151=>'pt',6152=>'fr',6153=>'en',6154=>'pt',6166=>'fr',6167=>'it',6174=>'fr',6193=>'fr',6194=>'en',6195=>'pt',6207=>'fr',6208=>'it',6219=>'fr',6220=>'en',6221=>'pt',6230=>'fr',6231=>'it',6246=>'fr',6247=>'en',6248=>'pt',6257=>'fr',6258=>'it',6273=>'fr',6274=>'en',6275=>'pt',6286=>'fr',6287=>'it',6299=>'fr',6300=>'en',6301=>'pt',6311=>'fr',6312=>'it',6322=>'fr',6327=>'fr',6328=>'en',6329=>'pt',6338=>'fr',6339=>'it',6361=>'fr',6362=>'it',6377=>'fr',6378=>'fr',6379=>'en',6380=>'pt',6389=>'fr',6390=>'it',6403=>'fr',6404=>'en',6405=>'pt',6407=>'fr',6408=>'it',6429=>'fr',6430=>'en',6431=>'pt',6441=>'it',6460=>'fr',6461=>'it',6462=>'fr',6463=>'en',6464=>'pt',6482=>'fr',6483=>'fr',6484=>'en',6485=>'pt',6495=>'fr',6496=>'it',6507=>'fr',6508=>'fr',6509=>'en',6510=>'pt',6519=>'fr',6520=>'it',6535=>'fr',6536=>'fr',6537=>'en',6538=>'pt',6550=>'fr',6551=>'it',6566=>'fr',6567=>'it',6585=>'fr',6586=>'en',6587=>'pt',6588=>'fr',6589=>'en',6590=>'pt',6602=>'fr',6603=>'it',6614=>'fr',6615=>'en',6616=>'pt',6626=>'fr',6627=>'it',6641=>'fr',6642=>'en',6643=>'pt',6654=>'fr',6655=>'it',6675=>'fr',6676=>'it',6687=>'fr',6688=>'en',6689=>'pt',6699=>'fr',6700=>'it',6712=>'fr',6713=>'en',6714=>'pt',6725=>'fr',6726=>'it',6737=>'fr',6738=>'en',6739=>'pt',6753=>'fr',6762=>'it',6763=>'fr',6764=>'en',6765=>'pt',6779=>'fr',6780=>'it',6789=>'fr',6790=>'en',6791=>'pt',6805=>'fr',6806=>'it',6815=>'fr',6816=>'en',6817=>'pt',6826=>'fr',6827=>'it',6841=>'fr',6842=>'en',6843=>'pt',6852=>'fr',6853=>'it',6867=>'fr',6868=>'en',6869=>'pt',6878=>'fr',6879=>'it',6893=>'fr',6894=>'en',6895=>'pt',6909=>'fr',6910=>'it',6933=>'fr',6934=>'it',6951=>'fr',6952=>'it',6980=>'fr',6981=>'it',7001=>'fr',7002=>'it',7016=>'fr',7017=>'fr',7018=>'fr',7019=>'fr',7020=>'en',7021=>'pt',7032=>'fr',7033=>'it',7047=>'fr',7113=>'fr',7114=>'en',7115=>'pt',7135=>'en',7136=>'fr',7137=>'pt',7138=>'en',7139=>'fr',7140=>'pt',7141=>'en',7142=>'fr',7143=>'pt',7169=>'fr',7170=>'en',7171=>'pt',7239=>'fr',7240=>'fr',7241=>'fr',7242=>'fr',7243=>'fr',7308=>'fr',7309=>'fr',7310=>'fr',7311=>'fr',7420=>'en',7421=>'fr',7422=>'pt',7485=>'fr',7524=>'fr',7577=>'fr',7597=>'fr',7638=>'fr',7639=>'fr',7682=>'fr',7978=>'fr',8020=>'fr',8151=>'fr',8215=>'fr',8216=>'fr',8217=>'en',8218=>'pt',8240=>'fr',8446=>'fr',8447=>'fr',8448=>'fr',8449=>'fr',8451=>'fr',8452=>'fr',8489=>'fr',8526=>'fr',8563=>'fr',8582=>'fr',8583=>'en',8584=>'pt',8776=>'fr',8777=>'fr',8778=>'fr',8779=>'fr',8780=>'fr',8781=>'fr',8782=>'fr',8783=>'fr',8784=>'fr',8809=>'fr',8810=>'en',8811=>'pt',8832=>'fr',8833=>'en',8834=>'pt',8872=>'fr',9041=>'fr',9042=>'en',9043=>'pt',9117=>'fr',9118=>'en',9119=>'pt',9138=>'fr',9139=>'en',9140=>'pt',9159=>'fr',9178=>'fr',9179=>'en',9180=>'pt',9188=>'en',9189=>'en',9203=>'fr',9204=>'en',9205=>'pt',9298=>'fr',9338=>'fr',9465=>'fr',9509=>'fr',9510=>'fr',9511=>'fr',9512=>'fr',9547=>'fr',9567=>'fr',9568=>'en',9569=>'pt',9585=>'fr',9633=>'fr',9666=>'fr',9716=>'fr',9772=>'fr',9776=>'fr',9777=>'en',9778=>'pt',9811=>'fr',9840=>'fr',9841=>'en',9842=>'pt',9914=>'fr',10062=>'fr',10081=>'fr',10152=>'fr',10194=>'pt',10195=>'fr',10232=>'fr',10267=>'fr',10306=>'fr',10345=>'fr',10413=>'fr',10433=>'fr',10472=>'fr',10475=>'fr',10476=>'en',10477=>'pt',10481=>'fr',10482=>'en',10483=>'it',10484=>'pt',10501=>'fr',10502=>'en',10503=>'pt',10506=>'fr',10507=>'en',10508=>'it',10509=>'pt',10524=>'fr',10525=>'en',10526=>'pt',10535=>'fr',10536=>'en',10537=>'it',10538=>'pt',10551=>'fr',10552=>'en',10553=>'it',10554=>'pt',10571=>'fr',10572=>'en',10573=>'it',10574=>'pt',10592=>'fr',10593=>'en',10594=>'it',10595=>'pt',10607=>'fr',10611=>'fr',10612=>'en',10613=>'it',10614=>'pt',10631=>'fr',10632=>'en',10633=>'it',10634=>'pt',10647=>'fr',10651=>'fr',10652=>'en',10653=>'it',10654=>'pt',10674=>'fr',10675=>'en',10676=>'it',10677=>'pt',10696=>'fr',10712=>'fr',10713=>'en',10714=>'it',10715=>'pt',10719=>'fr',10720=>'en',10721=>'it',10722=>'pt',10735=>'fr',10738=>'fr',10739=>'en',10740=>'it',10741=>'pt',10758=>'fr',10759=>'en',10760=>'it',10761=>'pt',10768=>'fr',10778=>'fr',10779=>'en',10780=>'it',10781=>'pt',10796=>'fr',10797=>'en',10798=>'it',10799=>'pt',10813=>'fr',10817=>'fr',10818=>'en',10819=>'it',10820=>'pt',10836=>'fr',10837=>'en',10838=>'it',10839=>'pt',10845=>'fr',10855=>'fr',10856=>'en',10857=>'it',10858=>'pt',10875=>'fr',10876=>'en',10877=>'it',10878=>'pt',10891=>'fr',10895=>'fr',10896=>'en',10897=>'it',10898=>'pt',10914=>'fr',10915=>'en',10916=>'it',10917=>'pt',10932=>'fr',10933=>'en',10934=>'it',10935=>'pt',10952=>'fr',10953=>'en',10954=>'it',10955=>'pt',10971=>'fr',10972=>'en',10973=>'it',10974=>'pt',10990=>'fr',10991=>'en',10992=>'it',10993=>'pt',11010=>'fr',11011=>'en',11012=>'it',11013=>'pt',11033=>'fr',11034=>'en',11035=>'it',11036=>'pt',11049=>'fr',11050=>'en',11051=>'it',11052=>'pt',11066=>'fr',11067=>'en',11068=>'it',11069=>'pt',11087=>'fr',11088=>'en',11089=>'it',11090=>'pt',11103=>'fr',11106=>'fr',11107=>'en',11108=>'it',11109=>'pt',11120=>'fr',11127=>'fr',11128=>'en',11129=>'en',11130=>'pt',11145=>'fr',11146=>'en',11147=>'it',11148=>'pt',11165=>'fr',11166=>'en',11167=>'it',11168=>'pt',11183=>'fr',11184=>'en',11185=>'it',11186=>'pt',11200=>'fr',11203=>'fr',11204=>'en',11205=>'it',11206=>'pt',11220=>'fr',11221=>'en',11222=>'it',11223=>'pt',11240=>'fr',11244=>'fr',11245=>'en',11246=>'it',11247=>'pt',11264=>'fr',11265=>'en',11266=>'it',11267=>'pt',11280=>'fr',11284=>'fr',11285=>'en',11286=>'it',11287=>'pt',11304=>'fr',11305=>'en',11306=>'it',11307=>'pt',11315=>'fr',11324=>'fr',11325=>'en',11326=>'it',11327=>'pt',11345=>'fr',11346=>'en',11347=>'it',11348=>'pt',11354=>'fr',11364=>'fr',11365=>'en',11366=>'it',11367=>'pt',11386=>'fr',11387=>'en',11388=>'pt',11389=>'it',11406=>'fr',11408=>'fr',11409=>'en',11410=>'it',11411=>'pt',11436=>'fr',11437=>'en',11438=>'it',11439=>'pt',11452=>'fr',11456=>'fr',11457=>'en',11458=>'it',11459=>'pt',11474=>'fr',11475=>'en',11476=>'it',11477=>'pt',11491=>'fr',11495=>'fr',11496=>'en',11497=>'it',11498=>'pt',11516=>'fr',11517=>'en',11518=>'it',11519=>'pt',11524=>'fr',11534=>'fr',11535=>'en',11536=>'it',11537=>'pt',11554=>'fr',11555=>'en',11556=>'it',11557=>'pt',11571=>'fr',11575=>'fr',11577=>'en',11576=>'en',11578=>'it',11579=>'pt',11594=>'fr',11595=>'en',11596=>'it',11597=>'pt',11607=>'fr',11614=>'fr',11615=>'en',11616=>'it',11617=>'pt',11632=>'fr',11633=>'en',11634=>'it',11635=>'pt',11650=>'fr',11652=>'fr',11653=>'en',11654=>'it',11655=>'pt',11673=>'fr',11674=>'en',11675=>'it',11676=>'pt',11682=>'fr',11693=>'fr',11694=>'en',11695=>'it',11696=>'pt',11723=>'fr',11724=>'en',11725=>'it',11726=>'pt',11739=>'fr',11745=>'fr',11746=>'en',11747=>'it',11748=>'pt',11764=>'fr',11765=>'en',11766=>'it',11767=>'pt',11782=>'fr',11783=>'en',11784=>'it',11785=>'pt',11803=>'fr',11804=>'en',11805=>'it',11806=>'pt',11819=>'fr',11822=>'fr',11823=>'en',11824=>'it',11825=>'pt',11841=>'fr',11842=>'en',11843=>'it',11844=>'pt',11858=>'fr',11865=>'fr',11866=>'en',11867=>'it',11868=>'pt',11879=>'fr',11880=>'en',11881=>'it',11882=>'pt',11891=>'fr',11901=>'fr',11902=>'en',11903=>'it',11904=>'pt',11920=>'fr',11921=>'en',11922=>'it',11923=>'pt',11939=>'fr',11940=>'en',11941=>'it',11942=>'pt',11957=>'fr',11958=>'en',11959=>'it',11960=>'pt',11974=>'fr',11978=>'fr',11979=>'en',11980=>'it',11981=>'pt',11997=>'fr',11998=>'en',11999=>'it',12000=>'pt',12016=>'fr',12017=>'en',12018=>'it',12019=>'pt',12038=>'fr',12039=>'en',12040=>'it',12041=>'pt',12054=>'fr',12055=>'en',12056=>'it',12057=>'pt',12075=>'fr',12076=>'en',12077=>'it',12078=>'pt',12094=>'fr',12095=>'en',12096=>'it',12097=>'pt',12112=>'fr',12113=>'en',12114=>'it',12115=>'pt',12131=>'fr',12132=>'en',12133=>'it',12134=>'pt',12150=>'fr',12151=>'en',12152=>'it',12153=>'pt',12170=>'fr',12171=>'en',12172=>'it',12173=>'pt',12192=>'fr',12193=>'en',12194=>'it',12195=>'pt',12211=>'fr',12212=>'en',12213=>'it',12214=>'pt',12230=>'fr',12231=>'en',12232=>'it',12233=>'pt',12249=>'fr',12250=>'en',12251=>'it',12252=>'pt',12272=>'fr',12273=>'en',12274=>'it',12275=>'pt',12292=>'fr',12293=>'en',12294=>'it',12295=>'pt',12309=>'fr',12310=>'en',12311=>'it',12312=>'pt',12324=>'fr',12325=>'en',12326=>'it',12327=>'pt',12344=>'fr',12345=>'en',12346=>'it',12347=>'pt',12364=>'fr',12365=>'en',12366=>'it',12367=>'pt',12384=>'fr',12385=>'en',12386=>'it',12387=>'pt',12405=>'fr',12406=>'en',12407=>'it',12408=>'pt',12425=>'fr',12426=>'en',12427=>'it',12428=>'pt',12444=>'fr',12445=>'en',12446=>'it',12447=>'pt',12468=>'fr',12469=>'it',12470=>'en',12471=>'pt',12489=>'fr',12490=>'en',12491=>'it',12492=>'pt',12508=>'fr',12509=>'en',12510=>'it',12511=>'pt',12523=>'fr',12524=>'en',12525=>'it',12526=>'pt',12545=>'fr',12546=>'en',12547=>'it',12548=>'pt',12564=>'fr',12565=>'en',12566=>'it',12567=>'pt',12582=>'fr',12583=>'en',12584=>'it',12585=>'pt',12601=>'fr',12602=>'en',12603=>'it',12604=>'pt',12620=>'fr',12621=>'en',12622=>'it',12623=>'pt',12639=>'fr',12640=>'en',12641=>'it',12642=>'pt',12660=>'fr',12661=>'en',12662=>'it',12663=>'pt',12676=>'fr',12677=>'en',12678=>'it',12679=>'pt',12705=>'fr',12706=>'en',12707=>'it',12708=>'pt',12719=>'fr',12720=>'en',12721=>'it',12722=>'pt',12739=>'fr',12740=>'en',12741=>'it',12742=>'pt',12757=>'fr',12758=>'en',12759=>'it',12760=>'pt',12779=>'fr',12780=>'en',12781=>'it',12782=>'pt',12797=>'fr',12798=>'en',12799=>'it',12800=>'pt',12817=>'fr',12818=>'en',12819=>'it',12820=>'pt',12834=>'fr',12835=>'en',12836=>'it',12837=>'pt',12849=>'fr',12850=>'en',12851=>'it',12852=>'pt',12875=>'fr',12876=>'en',12877=>'it',12878=>'pt',12894=>'fr',12895=>'en',12896=>'it',12897=>'pt',12914=>'fr',12915=>'en',12916=>'it',12917=>'pt',12933=>'fr',12934=>'fr',12935=>'it',12936=>'pt',12953=>'fr',12954=>'en',12955=>'it',12956=>'pt',12971=>'fr',12972=>'en',12973=>'it',12974=>'pt',12990=>'fr',12991=>'en',12992=>'it',12993=>'pt',13010=>'fr',13011=>'en',13012=>'it',13013=>'pt',13035=>'fr',13036=>'en',13037=>'it',13038=>'pt',13054=>'fr',13055=>'en',13056=>'it',13057=>'pt',13071=>'fr',13072=>'en',13073=>'it',13074=>'pt',13095=>'fr',13096=>'en',13097=>'it',13098=>'pt',13114=>'fr',13115=>'en',13117=>'pt',13134=>'fr',13135=>'en',13136=>'it',13137=>'pt',13158=>'fr',13159=>'en',13160=>'it',13161=>'pt',13177=>'fr',13178=>'en',13179=>'it',13180=>'pt',13198=>'fr',13199=>'en',13200=>'it',13201=>'pt',13218=>'fr',13219=>'en',13220=>'it',13221=>'pt',13237=>'fr',13238=>'en',13239=>'it',13240=>'pt',13256=>'fr',13257=>'en',13258=>'it',13259=>'pt',13275=>'fr',13276=>'en',13277=>'it',13278=>'pt',13297=>'fr',13298=>'en',13299=>'it',13300=>'pt',13319=>'fr',13320=>'en',13321=>'it',13322=>'pt',13339=>'fr',13340=>'en',13341=>'it',13342=>'pt',13357=>'fr',13358=>'en',13359=>'it',13360=>'pt',13376=>'fr',13377=>'en',13378=>'it',13379=>'pt',13390=>'fr',13391=>'en',13392=>'it',13393=>'pt',13411=>'fr',13412=>'en',13413=>'it',13414=>'pt',13434=>'fr',13435=>'en',13436=>'it',13437=>'pt',13455=>'fr',13456=>'en',13457=>'it',13458=>'pt',13475=>'fr',13476=>'en',13477=>'it',13478=>'pt',13500=>'fr',13501=>'en',13502=>'it',13503=>'pt',13523=>'fr',13524=>'en',13525=>'it',13526=>'pt',13544=>'fr',13545=>'en',13546=>'it',13547=>'pt',13563=>'fr',13564=>'en',13565=>'it',13566=>'pt',13590=>'fr',13591=>'en',13592=>'it',13593=>'pt',13599=>'fr',13600=>'en',13601=>'it',13602=>'pt',13621=>'fr',13622=>'en',13623=>'it',13624=>'pt',13641=>'fr',13642=>'en',13643=>'it',13644=>'pt',13667=>'fr',13668=>'en',13669=>'it',13670=>'pt',13677=>'fr',13678=>'en',13679=>'it',13680=>'pt',13706=>'fr',13707=>'en',13708=>'it',13709=>'pt',13717=>'fr',13718=>'en',13719=>'it',13720=>'pt',13736=>'fr',13737=>'en',13738=>'it',13739=>'pt',13754=>'fr',13755=>'en',13756=>'it',13757=>'pt',13789=>'fr',13790=>'en',13791=>'it',13792=>'pt',13797=>'fr',13798=>'en',13799=>'it',13800=>'pt',13819=>'fr',13820=>'en',13821=>'it',13822=>'pt',13837=>'fr',13838=>'en',13839=>'it',13840=>'pt',13865=>'fr',13866=>'en',13867=>'it',13868=>'pt',13874=>'fr',13875=>'en',13876=>'it',13877=>'pt',13893=>'fr',13894=>'en',13895=>'it',13896=>'pt',13911=>'fr',13912=>'en',13913=>'it',13914=>'pt',13931=>'fr',13932=>'en',13933=>'it',13934=>'pt',13950=>'fr',13951=>'en',13952=>'it',13953=>'pt',13970=>'fr',13971=>'en',13972=>'it',13973=>'pt',13989=>'fr',13990=>'en',13991=>'it',13992=>'pt',14002=>'fr',14003=>'en',14004=>'it',14005=>'pt',14026=>'fr',14027=>'en',14028=>'it',14029=>'pt',14044=>'fr',14045=>'en',14046=>'it',14047=>'pt',14063=>'fr',14064=>'en',14065=>'it',14066=>'pt',14082=>'fr',14083=>'en',14084=>'it',14085=>'pt',14100=>'fr',14101=>'en',14102=>'it',14103=>'pt',14121=>'fr',14122=>'en',14123=>'it',14124=>'pt',14140=>'fr',14141=>'en',14142=>'it',14143=>'pt',14159=>'fr',14160=>'en',14161=>'it',14162=>'pt',14176=>'fr',14177=>'en',14178=>'it',14179=>'pt',14196=>'fr',14197=>'en',14198=>'it',14199=>'pt',14215=>'fr',14216=>'en',14217=>'it',14218=>'pt',14228=>'fr',14229=>'en',14230=>'it',14231=>'pt',14253=>'fr',14254=>'en',14255=>'it',14256=>'pt',14272=>'fr',14273=>'en',14274=>'it',14275=>'pt',14286=>'fr',14287=>'en',14288=>'pt',14289=>'it',14312=>'fr',14313=>'en',14314=>'it',14315=>'pt',14332=>'fr',14333=>'en',14334=>'it',14335=>'pt',14351=>'fr',14352=>'en',14353=>'it',14354=>'pt',14369=>'fr',14370=>'en',14371=>'it',14372=>'pt',14386=>'fr',14387=>'en',14388=>'it',14389=>'pt',14408=>'fr',14409=>'en',14410=>'it',14411=>'pt',14429=>'fr',14430=>'en',14431=>'it',14432=>'pt',14447=>'fr',14448=>'en',14449=>'it',14450=>'pt',14470=>'fr',14471=>'en',14472=>'it',14473=>'pt',14488=>'fr',14489=>'en',14490=>'it',14491=>'pt',14518=>'fr',14519=>'en',14520=>'it',14521=>'pt',14530=>'fr',14531=>'en',14532=>'it',14533=>'pt',14549=>'fr',14550=>'en',14551=>'it',14552=>'pt',14567=>'fr',14568=>'en',14569=>'it',14570=>'pt',14586=>'fr',14587=>'en',14588=>'it',14589=>'pt',14606=>'fr',14607=>'en',14608=>'it',14609=>'pt',14624=>'fr',14625=>'en',14626=>'it',14627=>'pt',14645=>'fr',14646=>'en',14647=>'it',14648=>'pt',14663=>'fr',14664=>'en',14665=>'it',14666=>'pt',14690=>'fr',14691=>'en',14692=>'it',14693=>'pt',14701=>'fr',14702=>'en',14703=>'it',14704=>'pt',14719=>'fr',14720=>'en',14721=>'it',14722=>'pt',14739=>'fr',14740=>'en',14741=>'it',14742=>'pt',14768=>'fr',14769=>'en',14770=>'it',14771=>'pt',14779=>'fr',14780=>'en',14781=>'it',14782=>'pt',14803=>'fr',14804=>'en',14805=>'it',14806=>'pt',14819=>'fr',14820=>'en',14821=>'it',14822=>'pt',14844=>'fr',14845=>'en',14846=>'it',14847=>'pt',14855=>'fr',14856=>'en',14857=>'it',14858=>'pt',14888=>'fr',14889=>'en',14890=>'it',14891=>'pt',14899=>'fr',14900=>'en',14901=>'it',14902=>'pt',14918=>'fr',14919=>'en',14920=>'it',14921=>'pt',14934=>'fr',14935=>'en',14936=>'it',14937=>'pt',14965=>'fr',14966=>'en',14967=>'it',14968=>'pt',14977=>'fr',14978=>'en',14979=>'it',14980=>'pt',15004=>'fr',15005=>'en',15006=>'it',15007=>'pt',15015=>'fr',15016=>'en',15017=>'it',15018=>'pt',15037=>'fr',15038=>'en',15039=>'it',15040=>'pt',15052=>'fr',15053=>'en',15054=>'it',15055=>'pt',15080=>'fr',15081=>'en',15082=>'it',15083=>'pt',15092=>'fr',15093=>'en',15094=>'it',15095=>'pt',15118=>'fr',15119=>'en',15120=>'it',15121=>'pt',15129=>'fr',15130=>'en',15131=>'it',15132=>'pt',15148=>'fr',15149=>'en',15150=>'it',15151=>'pt',15175=>'fr',15176=>'en',15177=>'it',15178=>'pt',15185=>'fr',15186=>'en',15187=>'it',15188=>'pt',15201=>'fr',15202=>'en',15203=>'it',15204=>'pt',15232=>'fr',15233=>'en',15234=>'it',15235=>'pt',15242=>'fr',15243=>'en',15244=>'it',15245=>'pt',15261=>'fr',15262=>'en',15263=>'it',15264=>'pt',15287=>'fr',15288=>'en',15289=>'it',15290=>'pt',15296=>'fr',15297=>'en',15298=>'it',15299=>'pt',15314=>'fr',15315=>'en',15316=>'it',15317=>'pt',15334=>'fr',15335=>'en',15336=>'it',15337=>'pt',15354=>'fr',15355=>'en',15356=>'it',15357=>'pt',15375=>'fr',15376=>'en',15377=>'it',15378=>'pt',15396=>'fr',15397=>'en',15398=>'it',15399=>'pt',15413=>'fr',15414=>'en',15415=>'it',15416=>'pt',15436=>'fr',15437=>'en',15438=>'it',15439=>'pt',15450=>'fr',15451=>'en',15452=>'it',15453=>'pt',15468=>'fr',15469=>'en',15470=>'it',15471=>'pt',15501=>'fr',15502=>'en',15503=>'it',15504=>'pt',15520=>'fr',15521=>'en',15522=>'it',15523=>'pt',15545=>'fr',15546=>'en',15547=>'it',15548=>'pt',15562=>'fr',15563=>'en',15564=>'it',15565=>'pt',15580=>'fr',15581=>'en',15582=>'it',15583=>'pt',15613=>'fr',15614=>'en',15615=>'it',15616=>'pt',15623=>'fr',15624=>'en',15625=>'it',15626=>'pt',15639=>'fr',15640=>'en',15641=>'it',15642=>'pt',15660=>'fr',15661=>'en',15662=>'it',15663=>'pt',15683=>'fr',15684=>'en',15685=>'it',15686=>'pt',15703=>'fr',15704=>'en',15705=>'it',15706=>'pt',15722=>'fr',15723=>'en',15724=>'it',15725=>'pt',15745=>'fr',15746=>'en',15747=>'it',15748=>'pt',15771=>'fr',15772=>'en',15773=>'it',15774=>'pt',15788=>'fr',15789=>'en',15790=>'it',15791=>'pt',15815=>'fr',15816=>'en',15817=>'it',15818=>'pt',15834=>'fr',15835=>'en',15836=>'it',15837=>'pt',15854=>'fr',15855=>'en',15856=>'it',15857=>'pt',15875=>'fr',15876=>'en',15877=>'it',15878=>'pt',15908=>'fr',15909=>'en',15910=>'it',15911=>'pt',15917=>'fr',15918=>'en',15919=>'it',15920=>'pt',15936=>'fr',15937=>'en',15938=>'it',15939=>'pt',15961=>'fr',15962=>'en',15963=>'it',15964=>'pt',15981=>'fr',15982=>'en',15983=>'it',15984=>'pt',15994=>'fr',15995=>'en',15996=>'it',15997=>'pt',16029=>'fr',16030=>'en',16031=>'it',16032=>'pt',16041=>'fr',16042=>'en',16043=>'it',16044=>'pt',16058=>'fr',16059=>'en',16060=>'it',16061=>'pt',16089=>'fr',16090=>'en',16091=>'it',16092=>'pt',16109=>'fr',16110=>'en',16111=>'it',16112=>'pt',16121=>'fr',16122=>'en',16123=>'pt',16124=>'it',16138=>'fr',16139=>'en',16140=>'it',16141=>'pt',16161=>'fr',16162=>'en',16163=>'it',16164=>'pt',16189=>'fr',16190=>'en',16191=>'it',16192=>'pt',16201=>'fr',16202=>'en',16203=>'it',16204=>'pt',16222=>'fr',16223=>'en',16224=>'it',16225=>'pt',16236=>'fr',16237=>'en',16238=>'it',16239=>'pt',16259=>'fr',16260=>'en',16261=>'it',16262=>'pt',16289=>'fr',16290=>'en',16291=>'it',16292=>'pt',16302=>'fr',16303=>'en',16304=>'it',16305=>'pt',16320=>'fr',16321=>'en',16322=>'it',16323=>'pt',16342=>'fr',16343=>'en',16344=>'it',16345=>'pt',16357=>'fr',16358=>'en',16359=>'it',16360=>'pt',16381=>'fr',16382=>'en',16383=>'it',16384=>'pt',16413=>'fr',16414=>'en',16415=>'it',16416=>'pt',16423=>'fr',16424=>'en',16425=>'it',16426=>'pt',16451=>'fr',16452=>'en',16453=>'it',16454=>'pt',16465=>'fr',16466=>'en',16467=>'it',16468=>'pt',16488=>'fr',16489=>'en',16490=>'it',16491=>'pt',16515=>'fr',16516=>'en',16517=>'it',16518=>'pt',16535=>'fr',16536=>'en',16537=>'it',16538=>'pt',16543=>'fr',16544=>'en',16545=>'it',16546=>'pt',16582=>'fr',16583=>'en',16584=>'it',16585=>'pt',16600=>'fr',16601=>'en',16602=>'it',16603=>'pt',16623=>'fr',16624=>'en',16625=>'it',16626=>'pt',16651=>'fr',16652=>'en',16653=>'it',16654=>'pt',16671=>'fr',16672=>'en',16673=>'it',16674=>'pt',16684=>'fr',16685=>'en',16686=>'it',16687=>'pt',16713=>'fr',16714=>'en',16715=>'it',16716=>'pt',16725=>'fr',16726=>'en',16727=>'it',16728=>'pt',16748=>'fr',16749=>'en',16750=>'it',16751=>'pt',16765=>'fr',16766=>'en',16767=>'it',16768=>'pt',16788=>'fr',16789=>'en',16790=>'it',16791=>'pt',16808=>'fr',16809=>'en',16810=>'it',16811=>'pt',16836=>'fr',16837=>'en',16838=>'it',16839=>'pt',16852=>'fr',16853=>'en',16854=>'it',16855=>'pt',16868=>'fr',16869=>'en',16870=>'it',16871=>'pt',16892=>'fr',16893=>'en',16894=>'it',16895=>'pt',16923=>'fr',16924=>'en',16925=>'it',16926=>'pt',16946=>'fr',16960=>'fr',16947=>'en',16959=>'en',16948=>'it',16958=>'it',16949=>'pt',16957=>'pt',16973=>'fr',16974=>'en',16975=>'it',16976=>'pt',17001=>'fr',17002=>'en',17003=>'it',17004=>'pt',17021=>'fr',17022=>'en',17023=>'it',17024=>'pt',17047=>'fr',17048=>'en',17049=>'it',17050=>'pt',17066=>'fr',17067=>'en',17068=>'it',17069=>'pt',17080=>'fr',17081=>'en',17082=>'it',17083=>'pt',17106=>'fr',17107=>'en',17108=>'it',17109=>'pt',17117=>'fr',17118=>'en',17119=>'it',17120=>'pt',17133=>'fr',17134=>'en',17135=>'it',17136=>'pt',17158=>'fr',17159=>'en',17160=>'it',17161=>'pt',17178=>'fr',17179=>'en',17180=>'it',17181=>'pt',17199=>'fr',17200=>'en',17201=>'it',17202=>'pt',17218=>'fr',17219=>'en',17220=>'it',17221=>'pt',17246=>'fr',17247=>'en',17248=>'it',17249=>'pt',17267=>'fr',17268=>'en',17269=>'it',17270=>'pt',17288=>'fr',17289=>'it',17290=>'pt',17291=>'en',17308=>'fr',17309=>'en',17310=>'it',17311=>'pt',17328=>'fr',17329=>'en',17330=>'it',17331=>'pt',17337=>'fr',17338=>'en',17339=>'it',17340=>'pt',17368=>'fr',17369=>'en',17370=>'it',17371=>'pt',17388=>'fr',17389=>'en',17390=>'it',17391=>'pt',17406=>'fr',17407=>'en',17408=>'it',17409=>'pt',17428=>'fr',17429=>'en',17430=>'it',17431=>'pt',17440=>'fr',17441=>'en',17442=>'it',17443=>'pt',17460=>'fr',17461=>'en',17462=>'it',17463=>'pt',17491=>'fr',17492=>'en',17493=>'it',17494=>'pt',17502=>'fr',17503=>'en',17504=>'it',17505=>'pt',17531=>'fr',17532=>'en',17533=>'it',17534=>'pt',17551=>'fr',17552=>'en',17553=>'it',17554=>'pt',17566=>'fr',17567=>'en',17568=>'it',17569=>'pt',17588=>'fr',17589=>'en',17590=>'it',17591=>'pt',17614=>'fr',17615=>'en',17616=>'it',17617=>'pt',17633=>'fr',17634=>'en',17635=>'it',17636=>'pt',17656=>'fr',17657=>'en',17658=>'it',17659=>'pt',17661=>'fr',17662=>'en',17663=>'it',17664=>'pt',17690=>'fr',17691=>'en',17692=>'it',17693=>'pt',17702=>'fr',17703=>'en',17704=>'it',17705=>'pt',17722=>'fr',17723=>'en',17724=>'it',17725=>'pt',17736=>'fr',17737=>'en',17738=>'it',17739=>'pt',17770=>'fr',17771=>'en',17772=>'it',17773=>'pt',17781=>'fr',17782=>'en',17783=>'it',17784=>'pt',17809=>'fr',17810=>'en',17811=>'it',17812=>'pt',17830=>'fr',17831=>'en',17832=>'it',17833=>'pt',17841=>'fr',17842=>'en',17843=>'it',17844=>'pt',17870=>'fr',17871=>'en',17872=>'it',17873=>'pt']; + +global $wpdb; + +if ( $offset === 0 ) { + // Primera pasada: limpiar tag English falso + $english_tag = get_term_by('slug', 'english', 'post_tag'); + if ( $english_tag ) { + $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 eliminado.\n"; + } +} + +$rows = $wpdb->get_results($wpdb->prepare(" + 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') + ORDER BY p.ID + LIMIT %d OFFSET %d +", $batch, $offset)); + +if ( empty($rows) ) { + // Última pasada: posts sin k2_id → español + $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') + "); + foreach ($posts_sin_k2 as $wp_id) pll_set_post_language((int)$wp_id, 'es'); + echo "DONE. Posts sin k2_id (→es): " . count($posts_sin_k2) . "\n"; + echo "BORRA /web/tmp_assign_lang.php del servidor.\n"; + exit; +} + +foreach ( $rows as $row ) { + $lang = $k2_langs[(int)$row->k2_id] ?? 'es'; + pll_set_post_language((int)$row->wp_id, $lang); +} + +$next = $offset + $batch; +echo "OK offset={$offset} procesados=" . count($rows) . "\n"; +echo "Siguiente: <a href='?run=1&secret=fea2026&offset={$next}'>offset {$next}</a>\n"; +echo "<meta http-equiv='refresh' content='1;url=?run=1&secret=fea2026&offset={$next}'>\n"; diff --git a/scripts/audit_translations.py b/scripts/audit_translations.py new file mode 100644 index 0000000..4007cfa --- /dev/null +++ b/scripts/audit_translations.py @@ -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() diff --git a/scripts/autores_biblicos.php b/scripts/autores_biblicos.php new file mode 100644 index 0000000..1b780e1 --- /dev/null +++ b/scripts/autores_biblicos.php @@ -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"; diff --git a/scripts/build_lectionary_index.py b/scripts/build_lectionary_index.py new file mode 100644 index 0000000..5f16bd6 --- /dev/null +++ b/scripts/build_lectionary_index.py @@ -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() diff --git a/scripts/carta-semana-plugin.php b/scripts/carta-semana-plugin.php new file mode 100755 index 0000000..98a4dba --- /dev/null +++ b/scripts/carta-semana-plugin.php @@ -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); + } +}); diff --git a/scripts/create_buscar_page.php b/scripts/create_buscar_page.php new file mode 100644 index 0000000..44eae16 --- /dev/null +++ b/scripts/create_buscar_page.php @@ -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"; diff --git a/scripts/create_lecturas.php b/scripts/create_lecturas.php new file mode 100644 index 0000000..96b8d64 --- /dev/null +++ b/scripts/create_lecturas.php @@ -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"; diff --git a/scripts/cutover_feadulta_com.sh b/scripts/cutover_feadulta_com.sh new file mode 100755 index 0000000..a9a10c5 --- /dev/null +++ b/scripts/cutover_feadulta_com.sh @@ -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)." diff --git a/scripts/demote_old_cartasemana.php b/scripts/demote_old_cartasemana.php new file mode 100755 index 0000000..506656b --- /dev/null +++ b/scripts/demote_old_cartasemana.php @@ -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"; diff --git a/scripts/deploy_php83_compat_step1.sh b/scripts/deploy_php83_compat_step1.sh new file mode 100755 index 0000000..461d3fa --- /dev/null +++ b/scripts/deploy_php83_compat_step1.sh @@ -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 diff --git a/scripts/detect_untranslated.php b/scripts/detect_untranslated.php new file mode 100644 index 0000000..8f6cd8c --- /dev/null +++ b/scripts/detect_untranslated.php @@ -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))); diff --git a/scripts/download_lecturas.py b/scripts/download_lecturas.py new file mode 100644 index 0000000..298ef13 --- /dev/null +++ b/scripts/download_lecturas.py @@ -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() diff --git a/scripts/export_cat_translations.py b/scripts/export_cat_translations.py new file mode 100644 index 0000000..d57a154 --- /dev/null +++ b/scripts/export_cat_translations.py @@ -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") diff --git a/scripts/export_translations.py b/scripts/export_translations.py new file mode 100644 index 0000000..ade8a29 --- /dev/null +++ b/scripts/export_translations.py @@ -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") diff --git a/scripts/face_crop_avatar.py b/scripts/face_crop_avatar.py new file mode 100644 index 0000000..456ebd2 --- /dev/null +++ b/scripts/face_crop_avatar.py @@ -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() diff --git a/scripts/fea-homepage-template.php b/scripts/fea-homepage-template.php new file mode 100644 index 0000000..45bd822 --- /dev/null +++ b/scripts/fea-homepage-template.php @@ -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(); ?> diff --git a/scripts/fea-homepage.php b/scripts/fea-homepage.php new file mode 100644 index 0000000..773501f --- /dev/null +++ b/scripts/fea-homepage.php @@ -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); diff --git a/scripts/fea_post_io.php b/scripts/fea_post_io.php new file mode 100644 index 0000000..b2b6da9 --- /dev/null +++ b/scripts/fea_post_io.php @@ -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); diff --git a/scripts/fea_translate_helper.php b/scripts/fea_translate_helper.php new file mode 100644 index 0000000..74c6b97 --- /dev/null +++ b/scripts/fea_translate_helper.php @@ -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); +} diff --git a/scripts/fetch_lectura_bolls.py b/scripts/fetch_lectura_bolls.py new file mode 100644 index 0000000..9e3f00d --- /dev/null +++ b/scripts/fetch_lectura_bolls.py @@ -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()}) diff --git a/scripts/fix_carta_content_links.php b/scripts/fix_carta_content_links.php new file mode 100644 index 0000000..895f148 --- /dev/null +++ b/scripts/fix_carta_content_links.php @@ -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"; diff --git a/scripts/fix_carta_joomla_links.php b/scripts/fix_carta_joomla_links.php new file mode 100644 index 0000000..37e1098 --- /dev/null +++ b/scripts/fix_carta_joomla_links.php @@ -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"; diff --git a/scripts/fix_carta_links.php b/scripts/fix_carta_links.php new file mode 100644 index 0000000..e6e9d48 --- /dev/null +++ b/scripts/fix_carta_links.php @@ -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"; diff --git a/scripts/fix_catnames.php b/scripts/fix_catnames.php new file mode 100644 index 0000000..0bca84f --- /dev/null +++ b/scripts/fix_catnames.php @@ -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"; diff --git a/scripts/fix_image_paths.php b/scripts/fix_image_paths.php new file mode 100644 index 0000000..5dc03ac --- /dev/null +++ b/scripts/fix_image_paths.php @@ -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"; diff --git a/scripts/fix_imported_k2_metas.py b/scripts/fix_imported_k2_metas.py new file mode 100644 index 0000000..04c4036 --- /dev/null +++ b/scripts/fix_imported_k2_metas.py @@ -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() diff --git a/scripts/fix_joomla_links.php b/scripts/fix_joomla_links.php new file mode 100644 index 0000000..ffbdf43 --- /dev/null +++ b/scripts/fix_joomla_links.php @@ -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"; diff --git a/scripts/fix_k2_authors.php b/scripts/fix_k2_authors.php new file mode 100644 index 0000000..ade6f55 --- /dev/null +++ b/scripts/fix_k2_authors.php @@ -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"; diff --git a/scripts/fix_numeric_categories.php b/scripts/fix_numeric_categories.php new file mode 100644 index 0000000..2d55365 --- /dev/null +++ b/scripts/fix_numeric_categories.php @@ -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, '-'); +} diff --git a/scripts/fix_remaining_titles.py b/scripts/fix_remaining_titles.py new file mode 100644 index 0000000..4eae786 --- /dev/null +++ b/scripts/fix_remaining_titles.py @@ -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() diff --git a/scripts/fix_titles.py b/scripts/fix_titles.py new file mode 100644 index 0000000..43122d2 --- /dev/null +++ b/scripts/fix_titles.py @@ -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() diff --git a/scripts/gen_avatars_81b.py b/scripts/gen_avatars_81b.py new file mode 100644 index 0000000..08e3bbe --- /dev/null +++ b/scripts/gen_avatars_81b.py @@ -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})") diff --git a/scripts/gen_avatars_initials.py b/scripts/gen_avatars_initials.py new file mode 100644 index 0000000..2637f26 --- /dev/null +++ b/scripts/gen_avatars_initials.py @@ -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}") diff --git a/scripts/generate_k2_redirects.php b/scripts/generate_k2_redirects.php new file mode 100644 index 0000000..48d915f --- /dev/null +++ b/scripts/generate_k2_redirects.php @@ -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"; diff --git a/scripts/import_avatars.php b/scripts/import_avatars.php new file mode 100644 index 0000000..62e9f6e --- /dev/null +++ b/scripts/import_avatars.php @@ -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"; diff --git a/scripts/import_avatars_143.php b/scripts/import_avatars_143.php new file mode 100644 index 0000000..cedda96 --- /dev/null +++ b/scripts/import_avatars_143.php @@ -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"; diff --git a/scripts/import_avatars_75.php b/scripts/import_avatars_75.php new file mode 100644 index 0000000..33b5182 --- /dev/null +++ b/scripts/import_avatars_75.php @@ -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"; diff --git a/scripts/import_avatars_90.php b/scripts/import_avatars_90.php new file mode 100644 index 0000000..ac9c1ce --- /dev/null +++ b/scripts/import_avatars_90.php @@ -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"; diff --git a/scripts/import_new_cartas.py b/scripts/import_new_cartas.py new file mode 100644 index 0000000..78bbd7e --- /dev/null +++ b/scripts/import_new_cartas.py @@ -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() diff --git a/scripts/import_new_content.py b/scripts/import_new_content.py new file mode 100644 index 0000000..564001e --- /dev/null +++ b/scripts/import_new_content.py @@ -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() diff --git a/scripts/import_new_k2_items.py b/scripts/import_new_k2_items.py new file mode 100644 index 0000000..79db559 --- /dev/null +++ b/scripts/import_new_k2_items.py @@ -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() diff --git a/scripts/import_public_joomla_delta.py b/scripts/import_public_joomla_delta.py new file mode 100644 index 0000000..bf3a760 --- /dev/null +++ b/scripts/import_public_joomla_delta.py @@ -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*", doc, re.I | re.S) + if m: + title = html.unescape(re.sub(r"<.*?>", "", m.group(1))).strip() + m = re.search(r'
\s*(.*?)\s*
\s*\s*
\s*(.*?)\s*
', doc, re.I | re.S) + if not m: + m = re.search(r'
\s*(.*?)\s*
', 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']*>\s*(.*?)\s*', 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"]*>(.*?)

", 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']*href="([^"]+)"[^>]*>(.*?)', 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() diff --git a/scripts/lecturas_apply.py b/scripts/lecturas_apply.py new file mode 100644 index 0000000..4f40660 --- /dev/null +++ b/scripts/lecturas_apply.py @@ -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() diff --git a/scripts/minimax_tts.py b/scripts/minimax_tts.py new file mode 100644 index 0000000..97ad713 --- /dev/null +++ b/scripts/minimax_tts.py @@ -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 sube y clona (voice_id: >=8 chars, letras+números) + carta [model] [nombre] locuta una carta entera + text "" [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)

||", "\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 + (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() diff --git a/scripts/pretranslate_en_haiku.py b/scripts/pretranslate_en_haiku.py new file mode 100644 index 0000000..8c1c0a7 --- /dev/null +++ b/scripts/pretranslate_en_haiku.py @@ -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() diff --git a/scripts/prettify_carta_links.php b/scripts/prettify_carta_links.php new file mode 100644 index 0000000..f35a2e2 --- /dev/null +++ b/scripts/prettify_carta_links.php @@ -0,0 +1,44 @@ +` 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=`, 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= php prettify_carta_links.php (dry-run) + * APPLY=1 CARTA= 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=\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"; diff --git a/scripts/publish_carta.php b/scripts/publish_carta.php new file mode 100644 index 0000000..9931fc7 --- /dev/null +++ b/scripts/publish_carta.php @@ -0,0 +1,26 @@ +'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"; diff --git a/scripts/quitar_multimedia.php b/scripts/quitar_multimedia.php new file mode 100644 index 0000000..7727cbf --- /dev/null +++ b/scripts/quitar_multimedia.php @@ -0,0 +1,14 @@ +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"; diff --git a/scripts/reasign_cats.php b/scripts/reasign_cats.php new file mode 100644 index 0000000..a5f0e41 --- /dev/null +++ b/scripts/reasign_cats.php @@ -0,0 +1,70 @@ + 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"; diff --git a/scripts/regen_avatars.php b/scripts/regen_avatars.php new file mode 100644 index 0000000..32b9827 --- /dev/null +++ b/scripts/regen_avatars.php @@ -0,0 +1,40 @@ +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; diff --git a/scripts/regenerar_clasificacion_csv.py b/scripts/regenerar_clasificacion_csv.py new file mode 100644 index 0000000..3426a61 --- /dev/null +++ b/scripts/regenerar_clasificacion_csv.py @@ -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() diff --git a/scripts/remap_carta_tr_links.php b/scripts/remap_carta_tr_links.php new file mode 100644 index 0000000..abdafa2 --- /dev/null +++ b/scripts/remap_carta_tr_links.php @@ -0,0 +1,40 @@ +$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"; diff --git a/scripts/remap_translation_cats.php b/scripts/remap_translation_cats.php new file mode 100644 index 0000000..cc6f544 --- /dev/null +++ b/scripts/remap_translation_cats.php @@ -0,0 +1,42 @@ +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"; diff --git a/scripts/repoint_carta_links.php b/scripts/repoint_carta_links.php new file mode 100644 index 0000000..0b542ab --- /dev/null +++ b/scripts/repoint_carta_links.php @@ -0,0 +1,41 @@ +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"; diff --git a/scripts/reprocess_en_haiku.py b/scripts/reprocess_en_haiku.py new file mode 100644 index 0000000..fca1a11 --- /dev/null +++ b/scripts/reprocess_en_haiku.py @@ -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() diff --git a/scripts/retranslate_chunks.py b/scripts/retranslate_chunks.py new file mode 100644 index 0000000..6654daa --- /dev/null +++ b/scripts/retranslate_chunks.py @@ -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

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

Traducido con IA

" +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

boundaries into chunks <= max_size chars.""" + # Split at closing block tags + parts = re.split(r'(

|||)', 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() diff --git a/scripts/retranslate_en_all.py b/scripts/retranslate_en_all.py new file mode 100644 index 0000000..7bb398d --- /dev/null +++ b/scripts/retranslate_en_all.py @@ -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

Traducido con IA

" + + +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'(

|||)', 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() diff --git a/scripts/retranslate_failures.py b/scripts/retranslate_failures.py new file mode 100644 index 0000000..af2a41e --- /dev/null +++ b/scripts/retranslate_failures.py @@ -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

Traducido con IA

" + + +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() diff --git a/scripts/retranslate_lang.py b/scripts/retranslate_lang.py new file mode 100644 index 0000000..610249c --- /dev/null +++ b/scripts/retranslate_lang.py @@ -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": "

English version translated with AI

"}, + "fr": {"name": "French", "footer": "

Version française traduite par IA

"}, + "it": {"name": "Italian", "footer": "

Versione italiana tradotta con IA

"}, + "pt": {"name": "Portuguese", "footer": "

Versão portuguesa traduzida com IA

"}, +} + +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 →

wrapped, + unclosed

before a new

.""" + # **text** →

text

+ content = re.sub(r'\*\*(.+?)\*\*', + lambda m: '

' + m.group(1).strip() + '

', + content) + # Lines of bare text not inside any block tag → wrap in

+ lines = content.split('\n') + fixed = [] + for line in lines: + s = line.strip() + if s and not s.startswith('<') and not s.startswith(' + + +

+ + + + + + +
+
+ +

Lo siento, no se ha encontrado nada. Por favor, prueba a buscar con otras palabras clave.

+ +
+ + + + +
+ + + +
+ + + + + + + + +
+ + + + + +
+
+
+ + + +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"; diff --git a/scripts/setup-wordpress.sh b/scripts/setup-wordpress.sh new file mode 100755 index 0000000..7903d4a --- /dev/null +++ b/scripts/setup-wordpress.sh @@ -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" diff --git a/scripts/sync_translations_to_prod.py b/scripts/sync_translations_to_prod.py new file mode 100644 index 0000000..ad71e63 --- /dev/null +++ b/scripts/sync_translations_to_prod.py @@ -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()) diff --git a/scripts/test_5articles.py b/scripts/test_5articles.py new file mode 100644 index 0000000..d61b294 --- /dev/null +++ b/scripts/test_5articles.py @@ -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

Traducido con IA

" + + +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'(

|||)', 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() diff --git a/scripts/trad_cats.php b/scripts/trad_cats.php new file mode 100644 index 0000000..cb353a7 --- /dev/null +++ b/scripts/trad_cats.php @@ -0,0 +1,38 @@ + [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"; diff --git a/scripts/translate_cartas.py b/scripts/translate_cartas.py new file mode 100644 index 0000000..5be0da8 --- /dev/null +++ b/scripts/translate_cartas.py @@ -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

Traducido con IA

" + +# ── 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", "

Hola mundo

", "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() diff --git a/scripts/translate_gap.sh b/scripts/translate_gap.sh new file mode 100755 index 0000000..bbd12c0 --- /dev/null +++ b/scripts/translate_gap.sh @@ -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" diff --git a/scripts/translate_haiku.py b/scripts/translate_haiku.py new file mode 100644 index 0000000..ead8055 --- /dev/null +++ b/scripts/translate_haiku.py @@ -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 <<>> y <<>>, sin nada más." + ) + + +def extract(text: str) -> str: + # Coge el bloque <<>>...<<>> de contenido MÁS LARGO (robusto al + # bug del runner local, donde el modelo a veces re-menciona las marcas). + blocks = re.findall(r"<<>>(.*?)<<>>", 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"<<>>{text}<<>>" + ) + 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]) diff --git a/scripts/translate_lectura_titles.php b/scripts/translate_lectura_titles.php new file mode 100644 index 0000000..1316f24 --- /dev/null +++ b/scripts/translate_lectura_titles.php @@ -0,0 +1,218 @@ + , ...». + * 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 «» (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 = '/(?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"; diff --git a/scripts/translate_post.py b/scripts/translate_post.py new file mode 100644 index 0000000..4fe17b6 --- /dev/null +++ b/scripts/translate_post.py @@ -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

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 <<>>…<<>>. + + 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 = "<<>>", "<<>>" + 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 <<>> y <<>>, 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"<<>>{text}<<>>" + 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"(?<=

)", 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()) diff --git a/scripts/tts_carta.py b/scripts/tts_carta.py new file mode 100644 index 0000000..bcbbf43 --- /dev/null +++ b/scripts/tts_carta.py @@ -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 [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)

||", "\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 [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() diff --git a/scripts/tts_carta_edge.py b/scripts/tts_carta_edge.py new file mode 100644 index 0000000..9a2d1ed --- /dev/null +++ b/scripts/tts_carta_edge.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Locuta una carta entera con edge-tts (online, gratis). Para comparar con XTTS. + +Uso: tts_carta_edge.py [voz] [nombre_salida] +voz por defecto: es-ES-XimenaNeural +""" +import html +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +EDGE = os.path.expanduser("~/.hermes/hermes-agent/venv/bin/edge-tts") +OUT = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts-samples" +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"] + raw = re.sub(r"(?i)

||", "\n", raw) + 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")] + return d["title"], [p for p in paras if len(p) > 1] + + +def main(): + pid = int(sys.argv[1]) + voice = sys.argv[2] if len(sys.argv) > 2 else "es-ES-XimenaNeural" + name = sys.argv[3] if len(sys.argv) > 3 else f"carta-edge-{pid}" + title, paras = get_post_text(pid) + text = "\n\n".join(paras) + txt_path = "/tmp/carta_text.txt" + open(txt_path, "w").write(text) + print(f"Post #{pid}: «{title}» ({len(text)} car) → {voice}") + OUT.mkdir(parents=True, exist_ok=True) + mp3 = OUT / f"{name}.mp3" + subprocess.run([EDGE, "--voice", voice, "--file", txt_path, + "--write-media", str(mp3)], check=True) + print(f"OK -> {mp3}") + + +if __name__ == "__main__": + main() diff --git a/scripts/tts_eval.py b/scripts/tts_eval.py new file mode 100644 index 0000000..8feea01 --- /dev/null +++ b/scripts/tts_eval.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +""" +tts_eval.py — Genera la MISMA frase de feadulta con varias voces/modelos TTS para +compararlas (evaluación de voz, issue #76). Incluye: + - edge-tts Ximena (referencia, gratis, ya la usamos) — siempre. + - Modelos premium vía Hugging Face Inference Providers (consume crédito HF) — opcional. + +Objetivo: ELEGIR voz. Para producción en masa NO se usa HF (sale caro); el modelo +abierto ganador se corre en LOCAL (RTX 5060 Ti) gratis. Ver análisis en #76. + +Uso: + # Solo la referencia local (gratis): + python3 scripts/tts_eval.py + # Con modelos HF (necesita token; gasta unos céntimos del crédito): + HF_TOKEN=hf_xxx python3 scripts/tts_eval.py --hf + +Salida: ./tts-eval/.mp3 (escúchalos y elige). +""" +from __future__ import annotations +import argparse, os, subprocess, sys +from pathlib import Path + +SAMPLE = ( + "Bienvenido a Fe Adulta. La humanidad abriga una esperanza: verse liberada de la " + "esclavitud y alcanzar la libertad de los hijos de Dios. Una fe adulta es una fe " + "personal, valiente, sin miedos infantiles. Detente un instante y respira." +) +OUT = Path(__file__).resolve().parent.parent / "tts-eval" +EDGE = os.path.expanduser("~/.hermes/hermes-agent/venv/bin/edge-tts") + +# Candidatos vía HF Inference Providers (provider, model). Verifica disponibilidad en la +# pestaña "Inference Providers" de cada modelo en huggingface.co — el routing cambia. +HF_CANDIDATES = [ + ("fal-ai", "fal-ai/f5-tts"), + ("fal-ai", "fal-ai/chatterbox/text-to-speech"), + ("hf-inference", "myshell-ai/MeloTTS-Spanish"), +] + + +def edge_samples(): + OUT.mkdir(exist_ok=True) + for voz in ("es-ES-XimenaNeural", "es-ES-ElviraNeural", "es-MX-JorgeNeural"): + dst = OUT / f"edge-{voz}.mp3" + print(f"edge-tts {voz} ...", flush=True) + subprocess.run([EDGE, "--voice", voz, "--text", SAMPLE, "--write-media", str(dst)], + capture_output=True) + print(f" -> {OUT}") + + +def hf_samples(): + try: + from huggingface_hub import InferenceClient + except ImportError: + sys.exit("Falta huggingface_hub: pip install huggingface_hub") + token = os.environ.get("HF_TOKEN") + if not token: + sys.exit("Define HF_TOKEN para usar --hf") + OUT.mkdir(exist_ok=True) + for provider, model in HF_CANDIDATES: + name = model.replace("/", "_") + try: + client = InferenceClient(provider=provider, api_key=token) + audio = client.text_to_speech(SAMPLE, model=model) + dst = OUT / f"hf-{name}.mp3" + dst.write_bytes(audio) + print(f"OK {provider}:{model} -> {dst.name}") + except Exception as exc: # noqa: BLE001 + print(f"FALLO {provider}:{model} -> {exc}") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--hf", action="store_true", help="También generar con modelos HF (gasta crédito).") + args = ap.parse_args() + edge_samples() + if args.hf: + hf_samples() + print("\nEscucha los .mp3 en", OUT, "y elige. Para producción: correr el modelo abierto ganador en local.") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tts_kokoro.py b/scripts/tts_kokoro.py new file mode 100644 index 0000000..9242f3b --- /dev/null +++ b/scripts/tts_kokoro.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Genera la muestra de feadulta con Kokoro (TTS local, gratis). Issue #76. + +Voces español: ef_dora (fem), em_alex / em_santa (masc). lang_code 'e' = español. +Salida en uploads/tts-samples/ como kokoro-.wav (+ mp3 si hay ffmpeg). +""" +import subprocess +import sys +from pathlib import Path + +import numpy as np +import soundfile as sf +from kokoro import KPipeline + +SAMPLE = ( + "Bienvenido a Fe Adulta. La humanidad abriga una esperanza: verse liberada de la " + "esclavitud y alcanzar la libertad de los hijos de Dios. Una fe adulta es una fe " + "personal, valiente, sin miedos infantiles. Detente un instante y respira." +) +OUT = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts-samples" +SR = 24000 +VOICES = sys.argv[1:] or ["ef_dora", "em_alex"] + + +def main(): + OUT.mkdir(parents=True, exist_ok=True) + pipe = KPipeline(lang_code="e") # español + for voice in VOICES: + chunks = [audio for _, _, audio in pipe(SAMPLE, voice=voice)] + audio = np.concatenate(chunks) if len(chunks) > 1 else chunks[0] + wav = OUT / f"kokoro-{voice}.wav" + sf.write(wav, audio, SR) + mp3 = OUT / f"kokoro-{voice}.mp3" + subprocess.run(["ffmpeg", "-y", "-i", str(wav), "-b:a", "96k", str(mp3)], + capture_output=True) + dur = len(audio) / SR + print(f"OK {voice}: {dur:.1f}s -> {mp3.name}") + + +if __name__ == "__main__": + main() diff --git a/scripts/tts_produce.py b/scripts/tts_produce.py new file mode 100644 index 0000000..7d3c201 --- /dev/null +++ b/scripts/tts_produce.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Orquestador nocturno: locuta cartas ES del gap con MiniMax (voz Nico), una a +una, repartido en el tiempo. Reanudable (meta fea_audio_done) y con freno ante +la cuota (para tras N fallos seguidos). NO toca el front; solo genera el mp3 y +asocia la URL al post (meta fea_audio_url). + +Lanzar: nohup ~/tts-local/xtts-venv/bin/python scripts/tts_produce.py > /tmp/feadulta-tts-prod.out 2>&1 & +Log: /tmp/feadulta-tts-prod.log +""" +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import minimax_tts as mm # get_post_text, add_pauses, t2a, OUT +import translate_post as tp # carta_article_ids + +VOICE = "NicoFeadulta2026" +MODEL = "speech-2.8-hd" +CONTAINER = "wordpress-web" +PROD = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts" +LOG = Path("/tmp/feadulta-tts-prod.log") +INTERVAL = 180 # s entre cartas exitosas (reparte el ritmo) +BACKOFF = 1800 # s de espera ante fallo de cuota antes de reintentar +MAX_CONSEC_FAIL = 3 # fallos seguidos → parar (cuota probablemente agotada) +MIN_CHARS = 200 # por debajo, se considera sin contenido locutable + +# Cola de cartas a locutar. Override por entorno (FEA_TTS_CARTAS) para priorizar +# la carta nueva de la semana; si no, cae al orden del gap histórico. +_DEFAULT_CARTAS = "45018 44997 44975 44230 44229 44228 44090 44089 44088 44087 44086 44085 44084 44083 42590" +CARTAS = os.environ.get("FEA_TTS_CARTAS", _DEFAULT_CARTAS).replace(",", " ").split() + + +def log(msg): + line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}" + print(line, flush=True) + with LOG.open("a") as f: + f.write(line + "\n") + + +def php(*args): + return subprocess.run(["docker", "exec", CONTAINER, "php", "/tmp/fea_post_io.php", *args], + capture_output=True, text=True) + + +def meta(pid, key): + return php("getmeta", str(pid), key).stdout.strip() + + +def build_queue(): + # Cola literal de IDs (ya filtrada/ordenada) para priorizar la carta nueva. + ids_override = os.environ.get("FEA_TTS_IDS", "").replace(",", " ").split() + if ids_override: + return [int(x) for x in ids_override if x.strip().isdigit()] + q = [] + for c in CARTAS: + cid = int(c) + for pid in tp.carta_article_ids(cid): + if pid not in q: + q.append(pid) + return q + + +def main(): + PROD.mkdir(parents=True, exist_ok=True) + subprocess.run(["docker", "cp", "scripts/fea_post_io.php", f"{CONTAINER}:/tmp/fea_post_io.php"], + capture_output=True) + queue = build_queue() + log(f"=== INICIO orquestador TTS. Cola: {len(queue)} posts ES del gap ===") + + i = consec = ok = 0 + while i < len(queue): + pid = queue[i] + if meta(pid, "fea_audio_done") == "1" or meta(pid, "fea_audio_skip") == "1": + i += 1 + continue + try: + title, text = mm.get_post_text(pid) + except Exception as e: # noqa: BLE001 + log(f"#{pid}: error leyendo ({e}); skip") + php("setflag", str(pid), "fea_audio_skip", "1") + i += 1 + continue + if len(text) < MIN_CHARS: + log(f"#{pid}: sin contenido ({len(text)} car); skip") + php("setflag", str(pid), "fea_audio_skip", "1") + i += 1 + continue + + rc = mm.t2a(mm.add_pauses(text), VOICE, MODEL, f"prod-{pid}") + if rc == 0: + src = mm.OUT / f"prod-{pid}.mp3" + dst = PROD / f"{pid}.mp3" + shutil.move(str(src), str(dst)) + php("setaudio", str(pid), f"/wp-content/uploads/tts/{pid}.mp3") + ok += 1 + consec = 0 + log(f"#{pid} OK «{title[:45]}» → tts/{pid}.mp3 (total {ok})") + i += 1 + time.sleep(INTERVAL) + else: + consec += 1 + log(f"#{pid} FALLO rc={rc} (fallo seguido {consec}/{MAX_CONSEC_FAIL})") + php("setflag", str(pid), "fea_audio_error", str(rc)) + if consec >= MAX_CONSEC_FAIL: + log("Demasiados fallos seguidos → cuota agotada probablemente. PARO. " + "Reanudable: relanzar el script más tarde (salta lo ya hecho).") + break + time.sleep(BACKOFF) # reintenta el mismo post tras esperar + + log(f"=== FIN tanda. {ok} audios generados esta ejecución. ===") + + +if __name__ == "__main__": + main() diff --git a/scripts/tts_xtts.py b/scripts/tts_xtts.py new file mode 100644 index 0000000..34ab09f --- /dev/null +++ b/scripts/tts_xtts.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Clona una voz con XTTS-v2 (local) y locuta la muestra de feadulta. Issue #76. + +Uso: + tts_xtts.py [nombre_salida] +La muestra: 6-20s de voz limpia en español. Salida en uploads/tts-samples/. + +NOTA: XTTS-v2 tiene licencia no comercial (CPML). En CPU tarda ~1-2 min por +muestra; con GPU sería casi instantáneo. +""" +import os +import subprocess +import sys +from pathlib import Path + +os.environ.setdefault("COQUI_TOS_AGREED", "1") # acepta licencia CPML no-interactivo + +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" + +SAMPLE = ( + "Bienvenido a Fe Adulta. La humanidad abriga una esperanza: verse liberada de la " + "esclavitud y alcanzar la libertad de los hijos de Dios. Una fe adulta es una fe " + "personal, valiente, sin miedos infantiles. Detente un instante y respira." +) +OUT = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts-samples" + + +def main(): + if len(sys.argv) < 2: + sys.exit("uso: tts_xtts.py [nombre_salida]") + spk = sys.argv[1] + name = sys.argv[2] if len(sys.argv) > 2 else "xtts-clon" + OUT.mkdir(parents=True, exist_ok=True) + + print(f"Cargando XTTS-v2 en {DEVICE}…", flush=True) + tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(DEVICE) + raw = OUT / f"{name}.raw.wav" + print(f"Clonando voz de {spk} y locutando…", flush=True) + tts.tts_to_file( + text=SAMPLE, speaker_wav=spk, language="es", file_path=str(raw), + temperature=0.65, # menos aleatoriedad → más estable + length_penalty=1.0, + repetition_penalty=5.0, # reduce artefactos/balbuceos en español + top_k=50, + top_p=0.85, + enable_text_splitting=True, # parte por frases → mejor prosodia + ) + # Comfort noise: ruido marrón suave y constante que rellena los silencios de + # comas/puntos para que no contrasten con el suelo de ruido del habla clonada. + wav = OUT / f"{name}.wav" + if os.environ.get("FEA_NO_COMFORT"): + subprocess.run(["ffmpeg", "-y", "-i", str(raw), str(wav)], capture_output=True) + else: + 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}") + + +if __name__ == "__main__": + main() diff --git a/scripts/unpublish_date_slug_posts.php b/scripts/unpublish_date_slug_posts.php new file mode 100644 index 0000000..d13d5b7 --- /dev/null +++ b/scripts/unpublish_date_slug_posts.php @@ -0,0 +1,153 @@ +posts; + $backup_table = $wpdb->prefix . 'fea_date_slug_posts_backup'; + + $count = (int)$wpdb->get_var("SELECT COUNT(*) FROM $posts_table WHERE $where"); + echo "Matching published posts: $count\n"; + + $sample = $wpdb->get_results(" + SELECT ID, post_date, post_title, post_name + FROM $posts_table + WHERE $where + ORDER BY post_date DESC + LIMIT 20 + ", ARRAY_A); + + foreach ($sample as $row) { + echo sprintf( + " #%d %s %s (%s)\n", + $row['ID'], + $row['post_date'], + $row['post_title'], + $row['post_name'] + ); + } + + if ($dry_run) { + echo "\nNo changes made. Re-run with APPLY=1 to set these posts to draft.\n"; + return; + } + + $wpdb->query(" + CREATE TABLE IF NOT EXISTS $backup_table ( + post_id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + old_status VARCHAR(20) NOT NULL, + old_modified DATETIME NOT NULL, + backed_up_at DATETIME NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + "); + + $backed_up = $wpdb->query(" + INSERT IGNORE INTO $backup_table (post_id, old_status, old_modified, backed_up_at) + SELECT ID, post_status, post_modified, NOW() + FROM $posts_table + WHERE $where + "); + + $updated = $wpdb->query(" + UPDATE $posts_table + SET post_status = 'draft', + post_modified = NOW(), + post_modified_gmt = UTC_TIMESTAMP() + WHERE $where + "); + + echo "\nBacked up rows in $backup_table: $backed_up\n"; + echo "Posts set to draft: $updated\n"; + return; +} + +$db_host = getenv('WORDPRESS_DB_HOST') ?: 'wordpress-db'; +$db_name = getenv('WORDPRESS_DB_NAME') ?: 'wordpress_db'; +$db_user = getenv('WORDPRESS_DB_USER') ?: 'wordpress_user'; +$db_pass = getenv('WORDPRESS_DB_PASSWORD') ?: 'wordpress_pass'; + +$pdo = new PDO("mysql:host=$db_host;dbname=$db_name;charset=utf8mb4", $db_user, $db_pass, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, +]); + +$count = (int)$pdo->query("SELECT COUNT(*) FROM wp_posts WHERE $where")->fetchColumn(); +echo "Matching published posts: $count\n"; + +$sample = $pdo->query(" + SELECT ID, post_date, post_title, post_name + FROM wp_posts + WHERE $where + ORDER BY post_date DESC + LIMIT 20 +")->fetchAll(PDO::FETCH_ASSOC); + +foreach ($sample as $row) { + echo sprintf( + " #%d %s %s (%s)\n", + $row['ID'], + $row['post_date'], + $row['post_title'], + $row['post_name'] + ); +} + +if ($dry_run) { + echo "\nNo changes made. Re-run with --apply to set these posts to draft.\n"; + exit(0); +} + +$pdo->exec(" + CREATE TABLE IF NOT EXISTS $backup_table ( + post_id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + old_status VARCHAR(20) NOT NULL, + old_modified DATETIME NOT NULL, + backed_up_at DATETIME NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 +"); + +$pdo->beginTransaction(); + +$backed_up = $pdo->exec(" + INSERT IGNORE INTO $backup_table (post_id, old_status, old_modified, backed_up_at) + SELECT ID, post_status, post_modified, NOW() + FROM wp_posts + WHERE $where +"); + +$updated = $pdo->exec(" + UPDATE wp_posts + SET post_status = 'draft', + post_modified = NOW(), + post_modified_gmt = UTC_TIMESTAMP() + WHERE $where +"); + +$pdo->commit(); + +echo "\nBacked up rows in $backup_table: $backed_up\n"; +echo "Posts set to draft: $updated\n";