From e2f23ace9f1170233cd798d45abd50e4083cd4df Mon Sep 17 00:00:00 2001 From: rafa Date: Sat, 27 Jun 2026 18:24:53 -0400 Subject: [PATCH] feat(#8): buscador avanzado nativo (FULLTEXT + filtros) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replica el «Buscador avanzado» del Joomla viejo sin servicios externos: - fea-search-fulltext.php: motor MySQL FULLTEXT (MATCH AGAINST, Boolean Mode, orden por relevancia); comprueba que existe el índice fea_ft y degrada al buscador nativo si no, sin romper. - fea-search-advanced.php: formulario con filtros por autor, categoría (lista curada de secciones), cita bíblica (meta _cita_evangelio por prefijo) y rango de fechas; pre_get_posts, chips de filtros, byline en tarjetas, i18n es/en/fr/it/pt. Excluye pseudo-autores «Testamento». - create_buscar_page.php: crea la página /buscar + traducciones (idempotente). - tools/e2e: suite Playwright de verificación. Desplegado y verificado en prod (índice fea_ft + página /buscar + traducciones). Co-Authored-By: Claude Opus 4.8 --- scripts/create_buscar_page.php | 109 +++ tools/e2e/shot_cat.cjs | 11 + tools/e2e/shot_search.cjs | 19 + tools/e2e/shot_search2.cjs | 19 + tools/e2e/shot_search_advanced.cjs | 204 ++++++ .../mu-plugins/fea-search-advanced.php | 632 ++++++++++++++++++ .../mu-plugins/fea-search-fulltext.php | 101 +++ 7 files changed, 1095 insertions(+) create mode 100644 scripts/create_buscar_page.php create mode 100644 tools/e2e/shot_cat.cjs create mode 100644 tools/e2e/shot_search.cjs create mode 100644 tools/e2e/shot_search2.cjs create mode 100644 tools/e2e/shot_search_advanced.cjs create mode 100644 wordpress/wp-content/mu-plugins/fea-search-advanced.php create mode 100644 wordpress/wp-content/mu-plugins/fea-search-fulltext.php 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 @@ + '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 = '

Utiliza el formulario de búsqueda avanzada para encontrar reflexiones, artículos y más.

'; + +$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/tools/e2e/shot_cat.cjs b/tools/e2e/shot_cat.cjs new file mode 100644 index 0000000..71a331e --- /dev/null +++ b/tools/e2e/shot_cat.cjs @@ -0,0 +1,11 @@ +const { chromium } = require('playwright'); +(async () => { + const b = await chromium.launch(); + const p = await b.newPage({ viewport: { width: 1280, height: 900 } }); + await p.goto('http://localhost:8081/buscar/', { waitUntil: 'networkidle' }); + // abrir el select de categoría y listar sus opciones + const opts = await p.$$eval('#fea-cat option', els => els.map(e => e.textContent.trim())); + console.log('opciones categoría ('+opts.length+'):', JSON.stringify(opts)); + await p.screenshot({ path: '/tmp/claude-1000/-mnt-c-Users-Chia/2802dab4-7a28-4ab7-ad16-457debcccc00/scratchpad/cat_curada.png', clip:{x:0,y:120,width:1280,height:520} }); + await b.close(); +})(); diff --git a/tools/e2e/shot_search.cjs b/tools/e2e/shot_search.cjs new file mode 100644 index 0000000..4b66395 --- /dev/null +++ b/tools/e2e/shot_search.cjs @@ -0,0 +1,19 @@ +const { chromium } = require('playwright'); +(async () => { + const b = await chromium.launch(); + // Desktop: cabecera con barra + let p = await b.newPage({ viewport: { width: 1280, height: 900 } }); + await p.goto('http://localhost:8081/', { waitUntil: 'networkidle' }); + const bar = await p.$('.fea-search-bar'); + console.log('barra presente desktop:', !!bar); + await p.screenshot({ path: '/tmp/claude-1000/search_desktop.png', clip: {x:0,y:0,width:1280,height:320} }); + // Móvil + let m = await b.newPage({ viewport: { width: 390, height: 800 } }); + await m.goto('http://localhost:8081/', { waitUntil: 'networkidle' }); + await m.screenshot({ path: '/tmp/claude-1000/search_mobile.png', clip: {x:0,y:0,width:390,height:360} }); + // Resultados + let r = await b.newPage({ viewport: { width: 1280, height: 1000 } }); + await r.goto('http://localhost:8081/?s=oraci%C3%B3n', { waitUntil: 'networkidle' }); + await r.screenshot({ path: '/tmp/claude-1000/search_results.png', clip: {x:0,y:0,width:1280,height:700} }); + await b.close(); +})(); diff --git a/tools/e2e/shot_search2.cjs b/tools/e2e/shot_search2.cjs new file mode 100644 index 0000000..41caca8 --- /dev/null +++ b/tools/e2e/shot_search2.cjs @@ -0,0 +1,19 @@ +const { chromium } = require('playwright'); +(async () => { + const b = await chromium.launch(); + // Resultados (desktop) + let r = await b.newPage({ viewport: { width: 1280, height: 1200 } }); + await r.goto('http://localhost:8081/?s=oraci%C3%B3n', { waitUntil: 'networkidle' }); + await r.screenshot({ path: '/tmp/claude-1000/res_desktop.png', clip:{x:0,y:180,width:1280,height:880} }); + // Desktop 800 ancho: barra NO debe verse (solo el Buscador del menú) + let d = await b.newPage({ viewport: { width: 900, height: 700 } }); + await d.goto('http://localhost:8081/', { waitUntil: 'networkidle' }); + const visD = await d.$eval('.fea-search-bar', el => getComputedStyle(el).display).catch(()=>'noel'); + console.log('barra display @900px:', visD); + // Móvil: barra SÍ + let m = await b.newPage({ viewport: { width: 390, height: 700 } }); + await m.goto('http://localhost:8081/', { waitUntil: 'networkidle' }); + const visM = await m.$eval('.fea-search-bar', el => getComputedStyle(el).display).catch(()=>'noel'); + console.log('barra display @390px:', visM); + await b.close(); +})(); diff --git a/tools/e2e/shot_search_advanced.cjs b/tools/e2e/shot_search_advanced.cjs new file mode 100644 index 0000000..85a48f5 --- /dev/null +++ b/tools/e2e/shot_search_advanced.cjs @@ -0,0 +1,204 @@ +/** + * shot_search_advanced.cjs — Verificación del buscador avanzado (#8) + * Comprueba: FULLTEXT, filtro autor, filtro tema, filtro cita bíblica, + * filtro fecha, combinado palabra+autor, multiidioma, formulario desktop/móvil. + * + * Uso: NODE_PATH=/home/rafa/joomla-migration/tools/e2e/node_modules node tools/e2e/shot_search_advanced.cjs + */ +const { chromium } = require('playwright'); +const path = require('path'); +const fs = require('fs'); + +const BASE = 'http://localhost:8081'; +const OUT = '/tmp/claude-1000/-mnt-c-Users-Chia/2802dab4-7a28-4ab7-ad16-457debcccc00/scratchpad'; + +// Ensure output dir +fs.mkdirSync(OUT, { recursive: true }); + +function shotPath(name) { + return path.join(OUT, name + '.png'); +} + +async function check(page, label, fn) { + try { + const result = await fn(page); + console.log(`✓ ${label}:`, result); + } catch (e) { + console.error(`✗ ${label}: ERROR`, e.message); + } +} + +(async () => { + const b = await chromium.launch({ headless: true }); + + // ── 1. FULLTEXT por relevancia ────────────────────────────────────────── + { + const p = await b.newPage({ viewport: { width: 1280, height: 900 } }); + await p.goto(`${BASE}/?s=oraci%C3%B3n`, { waitUntil: 'networkidle' }); + // Comprueba que hay resultados + const cards = await p.$$('.fea-archive-card'); + console.log(`✓ FULLTEXT /?s=oración: ${cards.length} tarjetas`); + // Comprueba que el formulario avanzado está presente + const form = await p.$('.fea-adv-form'); + console.log(` formulario avanzado en resultados: ${!!form}`); + const counter = await p.$('.fea-adv-count'); + const countText = counter ? await counter.textContent() : 'no counter'; + console.log(` contador: ${countText}`); + await p.screenshot({ path: shotPath('1_fulltext_oración'), fullPage: false }); + await p.close(); + } + + // ── 2. Filtro autor ───────────────────────────────────────────────────── + { + const p = await b.newPage({ viewport: { width: 1280, height: 900 } }); + // Fray Marcos = 382, Florentino Ulibarri = 395 + await p.goto(`${BASE}/?s=&fea_author=382`, { waitUntil: 'networkidle' }); + const cards = await p.$$('.fea-archive-card'); + console.log(`✓ Filtro autor (fea_author=382 Fray Marcos): ${cards.length} tarjetas`); + // Verify selected value in form + const selVal = await p.$eval('#fea-author', el => el.value).catch(() => 'n/a'); + console.log(` select author value: ${selVal}`); + await p.screenshot({ path: shotPath('2_filtro_autor'), fullPage: false }); + await p.close(); + } + + // ── 3. Filtro categoría + verificación del desplegable ─────────────────── + { + const p = await b.newPage({ viewport: { width: 1280, height: 900 } }); + await p.goto(`${BASE}/?fea_cat=1650`, { waitUntil: 'networkidle' }); + const cards = await p.$$('.fea-archive-card'); + console.log(`✓ Filtro categoría (fea_cat=1650 Artículos): ${cards.length} tarjetas`); + const selVal = await p.$eval('#fea-cat', el => el.value).catch(() => 'n/a'); + console.log(` select cat value: ${selVal}`); + // Label debe decir "Categoría" + const catLabel = await p.$eval('label[for="fea-cat"]', el => el.textContent).catch(() => 'n/a'); + console.log(` label categoría: "${catLabel}"`); + // Contar y listar opciones de categoría (idioma ES) + const catOpts = await p.$$eval('#fea-cat option', els => els.map(e => e.textContent)); + console.log(` nº opciones categoría: ${catOpts.length}`); + console.log(` opciones: ${catOpts.join(' | ')}`); + // No debe contener categorías de carta ni "Testament" + const badCat = catOpts.filter(t => /Testament/i.test(t)); + console.log(` opciones con 'Testament': ${badCat.length}`); + await p.screenshot({ path: shotPath('3_filtro_categoria'), fullPage: false }); + await p.close(); + } + + // ── 3b. Desplegable de AUTOR: sin "Testamento" ─────────────────────────── + { + const p = await b.newPage({ viewport: { width: 1280, height: 900 } }); + await p.goto(`${BASE}/?s=fe`, { waitUntil: 'networkidle' }); + const autOpts = await p.$$eval('#fea-author option', els => els.map(e => e.textContent)); + console.log(`✓ Desplegable autor: ${autOpts.length} opciones`); + const badAut = autOpts.filter(t => /Testament/i.test(t)); + console.log(` opciones autor con 'Testament': ${badAut.length} ${badAut.length ? '→ ' + badAut.join(', ') : ''}`); + await p.close(); + } + + // ── 4. Filtro cita bíblica (PREFIJO) ───────────────────────────────────── + { + const p = await b.newPage({ viewport: { width: 1280, height: 900 } }); + await p.goto(`${BASE}/?fea_cita=Jn`, { waitUntil: 'networkidle' }); + const cards = await p.$$('.fea-archive-card'); + console.log(`✓ Filtro cita PREFIJO (fea_cita=Jn): ${cards.length} tarjetas`); + const citaVal = await p.$eval('#fea-cita', el => el.value).catch(() => 'n/a'); + console.log(` input cita value: ${citaVal}`); + const chip = await p.$('.fea-adv-chip'); + const chipText = chip ? await chip.textContent() : 'no chip'; + console.log(` chip activo: ${chipText}`); + // Placeholder corto + foco para ver que NO se corta + const ph = await p.$eval('#fea-cita', el => el.placeholder).catch(() => 'n/a'); + console.log(` placeholder cita: "${ph}"`); + await p.screenshot({ path: shotPath('4_filtro_cita'), fullPage: false }); + await p.close(); + } + + // ── 4b. Cita bíblica con valor largo: comprobar que no desborda ────────── + { + const p = await b.newPage({ viewport: { width: 1280, height: 900 } }); + await p.goto(`${BASE}/?fea_cita=Jn%2020%2C%2019-31`, { waitUntil: 'networkidle' }); + // Medir overflow: scrollWidth vs clientWidth del input y de su fila + const metrics = await p.$eval('#fea-cita', el => ({ + scrollW: el.scrollWidth, clientW: el.clientWidth, + offsetW: el.offsetWidth, parentW: el.parentElement.clientWidth, + })).catch(() => null); + console.log(`✓ Cita valor largo (Jn 20, 19-31): ${JSON.stringify(metrics)}`); + const overflow = metrics ? (metrics.offsetW > metrics.parentW + 1) : 'n/a'; + console.log(` input desborda su celda: ${overflow}`); + await p.screenshot({ path: shotPath('4b_cita_largo_nocorta'), fullPage: false }); + await p.close(); + } + + // ── 5. Filtro fecha ───────────────────────────────────────────────────── + { + const p = await b.newPage({ viewport: { width: 1280, height: 900 } }); + await p.goto(`${BASE}/?fea_date_from=2020-01-01&fea_date_to=2020-12-31`, { waitUntil: 'networkidle' }); + const cards = await p.$$('.fea-archive-card'); + console.log(`✓ Filtro fecha (2020): ${cards.length} tarjetas`); + await p.screenshot({ path: shotPath('5_filtro_fecha'), fullPage: false }); + await p.close(); + } + + // ── 6. Combinado palabra + autor ──────────────────────────────────────── + { + const p = await b.newPage({ viewport: { width: 1280, height: 900 } }); + await p.goto(`${BASE}/?s=amor&fea_author=384`, { waitUntil: 'networkidle' }); // Enrique Martínez Lozano + const cards = await p.$$('.fea-archive-card'); + console.log(`✓ Combinado s=amor + fea_author=384 (Enrique M.L.): ${cards.length} tarjetas`); + await p.screenshot({ path: shotPath('6_combinado_palabra_autor'), fullPage: false }); + await p.close(); + } + + // ── 7. Multiidioma /en/?s=love ────────────────────────────────────────── + { + const p = await b.newPage({ viewport: { width: 1280, height: 900 } }); + await p.goto(`${BASE}/en/?s=love`, { waitUntil: 'networkidle' }); + const cards = await p.$$('.fea-archive-card'); + // Check form action points to /en/ + const formAction = await p.$eval('.fea-adv-form', el => el.action).catch(() => 'n/a'); + console.log(`✓ Multiidioma /en/?s=love: ${cards.length} tarjetas, form action: ${formAction}`); + // Check if labels are in English + const firstLabel = await p.$eval('.fea-adv-label', el => el.textContent).catch(() => 'n/a'); + console.log(` primer label: ${firstLabel}`); + await p.screenshot({ path: shotPath('7_multiidioma_en'), fullPage: false }); + await p.close(); + } + + // ── 8a. Formulario visible en desktop ─────────────────────────────────── + { + const p = await b.newPage({ viewport: { width: 1280, height: 900 } }); + await p.goto(`${BASE}/?s=fe`, { waitUntil: 'networkidle' }); + const formVisible = await p.isVisible('.fea-adv-form'); + console.log(`✓ Formulario desktop (1280px): visible=${formVisible}`); + await p.screenshot({ path: shotPath('8a_form_desktop'), fullPage: false }); + await p.close(); + } + + // ── 8b. Formulario visible en móvil ──────────────────────────────────── + { + const p = await b.newPage({ viewport: { width: 390, height: 844 } }); + await p.goto(`${BASE}/?s=fe`, { waitUntil: 'networkidle' }); + const formVisible = await p.isVisible('.fea-adv-form'); + console.log(`✓ Formulario móvil (390px): visible=${formVisible}`); + // Check advanced link in mobile header + const advLink = await p.$('.fea-adv-link'); + const advLinkVisible = advLink ? await advLink.isVisible() : false; + console.log(` enlace búsqueda avanzada móvil: visible=${advLinkVisible}`); + await p.screenshot({ path: shotPath('8b_form_mobile'), fullPage: false }); + await p.close(); + } + + // ── 9. Página /buscar ──────────────────────────────────────────────────── + { + const p = await b.newPage({ viewport: { width: 1280, height: 900 } }); + await p.goto(`${BASE}/buscar/`, { waitUntil: 'networkidle' }); + const status = p.url(); + const form = await p.$('.fea-adv-form'); + console.log(`✓ Página /buscar: url=${status}, form=${!!form}`); + await p.screenshot({ path: shotPath('9_pagina_buscar'), fullPage: false }); + await p.close(); + } + + await b.close(); + console.log(`\nCapturas guardadas en: ${OUT}`); +})(); diff --git a/wordpress/wp-content/mu-plugins/fea-search-advanced.php b/wordpress/wp-content/mu-plugins/fea-search-advanced.php new file mode 100644 index 0000000..9d6d5fd --- /dev/null +++ b/wordpress/wp-content/mu-plugins/fea-search-advanced.php @@ -0,0 +1,632 @@ + ['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 '^' 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 → 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 = ''; + 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 '
' . + esc_html(fea_adv_t('active_filters')) . '' . implode('', $chips) . '
'; +} + +/** 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 '' . $label . + ' ×'; +} + +// ───────────────────────────────────────────────────────────────── +// 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 = '
' . $n . ' ' . esc_html(fea_adv_t('results')) . '
'; + } + } + + 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 = ''; + + 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 = ''; + + // Insertamos el enlace justo al final del bloque .fea-search-bar (cerrando el div externo) + // fea-search.php genera: + // Reemplazamos la ÚLTIMA ocurrencia del cierre del div de .fea-search-bar + $marker = ''; + $pos = strpos($html, 'fea-search-bar'); + if ($pos !== false) { + // Buscamos el que cierra .fea-search-bar (el wrapper externo) + // La estructura es: + // Hay dos : 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('#()#', '$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 . '
' . $content . '
'; +}); + +// 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; + ?> + + + + 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);