feat(#8): buscador avanzado nativo (FULLTEXT + filtros)

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 18:24:53 -04:00
parent d959331ed0
commit e2f23ace9f
7 changed files with 1095 additions and 0 deletions
+109
View File
@@ -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";
+11
View File
@@ -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();
})();
+19
View File
@@ -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();
})();
+19
View File
@@ -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();
})();
+204
View File
@@ -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}`);
})();
@@ -0,0 +1,632 @@
<?php
/**
* Plugin Name: Fe Adulta — Buscador avanzado (#8)
* Description: Replica el «Buscador avanzado» K2 de Joomla con WordPress nativo.
* Filtros: palabra (FULLTEXT vía fea-search-fulltext.php), autor, tema
* (categoría), cita bíblica (_cita_evangelio), fecha.
* Formulario visible en la página de resultados (search template) y en
* la página dedicada /buscar. Multiidioma (Polylang).
* Version: 1.0
*/
if (!defined('ABSPATH')) exit;
// ─────────────────────────────────────────────────────────────────
// Constantes de configuración
// ─────────────────────────────────────────────────────────────────
/**
* IDs de usuarios a excluir del selector de autores.
* 1,890,1049,1540 = cuentas técnicas/admin.
* 408,409,1563..1570 = pseudo-autores "Nuevo/Antiguo Testamento" (y sus traducciones)
* que NO son personas. Además, abajo se excluye cualquier display_name que contenga
* "Testament" de forma robusta (por si aparecen nuevos IDs).
*/
defined('FEA_AUTORES_EXCLUIR') or define('FEA_AUTORES_EXCLUIR', [
1, 890, 1049, 1540,
408, 409, 1563, 1564, 1565, 1566, 1567, 1568, 1569, 1570,
]);
/**
* Categorías de ESTADO DE CARTA a excluir del selector de categorías:
* 6 (cartasemana), 21 (cartas-de-otras-semanas), 22 (carta-semana-pasada).
*/
defined('FEA_CATS_CARTA_EXCLUIR') or define('FEA_CATS_CARTA_EXCLUIR', [6, 21, 22]);
// ─────────────────────────────────────────────────────────────────
// i18n mínimo (es / en / fr / it / pt)
// ─────────────────────────────────────────────────────────────────
function fea_adv_t(string $key): string {
$lang = function_exists('pll_current_language') ? pll_current_language() : 'es';
$strings = [
'search_advanced' => ['es' => 'Búsqueda avanzada', 'en' => 'Advanced search',
'fr' => 'Recherche avancée', 'it' => 'Ricerca avanzata', 'pt' => 'Pesquisa avançada'],
'word' => ['es' => 'Palabra o frase', 'en' => 'Word or phrase',
'fr' => 'Mot ou phrase', 'it' => 'Parola o frase', 'pt' => 'Palavra ou frase'],
'author' => ['es' => 'Autor', 'en' => 'Author',
'fr' => 'Auteur', 'it' => 'Autore', 'pt' => 'Autor'],
'all_authors' => ['es' => '— Cualquier autor —', 'en' => '— Any author —',
'fr' => '— Tout auteur —', 'it' => '— Qualsiasi autore —', 'pt' => '— Qualquer autor —'],
'topic' => ['es' => 'Categoría', 'en' => 'Category',
'fr' => 'Catégorie', 'it' => 'Categoria', 'pt' => 'Categoria'],
'all_topics' => ['es' => '— Cualquier categoría —', 'en' => '— Any category —',
'fr' => '— Toute catégorie —', 'it' => '— Qualsiasi categoria —', 'pt' => '— Qualquer categoria —'],
'biblical_ref' => ['es' => 'Cita bíblica', 'en' => 'Biblical reference',
'fr' => 'Référence biblique', 'it' => 'Citazione biblica', 'pt' => 'Referência bíblica'],
'biblical_ph' => ['es' => 'Ej: Jn 3', 'en' => 'E.g. Jn 3',
'fr' => 'Ex: Jn 3', 'it' => 'Es: Gv 3', 'pt' => 'Ex: Jo 3'],
'date_from' => ['es' => 'Desde', 'en' => 'From',
'fr' => 'Du', 'it' => 'Dal', 'pt' => 'De'],
'date_to' => ['es' => 'Hasta', 'en' => 'To',
'fr' => "Jusqu'au", 'it' => 'Al', 'pt' => 'Até'],
'search_btn' => ['es' => 'Buscar', 'en' => 'Search',
'fr' => 'Rechercher', 'it' => 'Cerca', 'pt' => 'Pesquisar'],
'reset_btn' => ['es' => 'Limpiar', 'en' => 'Clear',
'fr' => 'Effacer', 'it' => 'Cancella', 'pt' => 'Limpar'],
'results' => ['es' => 'resultado(s)', 'en' => 'result(s)',
'fr' => 'résultat(s)', 'it' => 'risultato/i', 'pt' => 'resultado(s)'],
'no_results' => ['es' => 'Sin resultados. Prueba con otros términos.',
'en' => 'No results. Try other terms.',
'fr' => 'Aucun résultat. Essayez d\'autres termes.',
'it' => 'Nessun risultato. Prova con altri termini.',
'pt' => 'Sem resultados. Tente outros termos.'],
'active_filters' => ['es' => 'Filtros activos:', 'en' => 'Active filters:',
'fr' => 'Filtres actifs:', 'it' => 'Filtri attivi:', 'pt' => 'Filtros ativos:'],
'filter_author' => ['es' => 'Autor', 'en' => 'Author',
'fr' => 'Auteur', 'it' => 'Autore', 'pt' => 'Autor'],
'filter_topic' => ['es' => 'Categoría', 'en' => 'Category',
'fr' => 'Catégorie', 'it' => 'Categoria', 'pt' => 'Categoria'],
'filter_cita' => ['es' => 'Cita', 'en' => 'Ref.',
'fr' => 'Réf.', 'it' => 'Cit.', 'pt' => 'Ref.'],
'filter_date' => ['es' => 'Fecha', 'en' => 'Date',
'fr' => 'Date', 'it' => 'Data', 'pt' => 'Data'],
'by' => ['es' => 'por', 'en' => 'by',
'fr' => 'par', 'it' => 'di', 'pt' => 'por'],
];
$row = $strings[$key] ?? [];
return $row[$lang] ?? $row['es'] ?? $key;
}
// ─────────────────────────────────────────────────────────────────
// Query vars
// ─────────────────────────────────────────────────────────────────
add_filter('query_vars', function (array $vars): array {
$vars[] = 'fea_author';
$vars[] = 'fea_cat';
$vars[] = 'fea_cita';
$vars[] = 'fea_date_from';
$vars[] = 'fea_date_to';
return $vars;
});
// ─────────────────────────────────────────────────────────────────
// pre_get_posts — aplica los filtros avanzados
// ─────────────────────────────────────────────────────────────────
add_action('pre_get_posts', function (WP_Query $q): void {
if (is_admin() || !$q->is_main_query()) return;
// Leer los parámetros avanzados desde $_GET directamente (más fiable en pre_get_posts)
$fea_author = isset($_GET['fea_author']) ? (int)$_GET['fea_author'] : 0;
$fea_cat = isset($_GET['fea_cat']) ? (int)$_GET['fea_cat'] : 0;
$fea_cita = isset($_GET['fea_cita']) ? sanitize_text_field($_GET['fea_cita']) : '';
$fea_dfr = isset($_GET['fea_date_from']) ? sanitize_text_field($_GET['fea_date_from']) : '';
$fea_dto = isset($_GET['fea_date_to']) ? sanitize_text_field($_GET['fea_date_to']) : '';
$has_adv = ($fea_author > 0 || $fea_cat > 0 || $fea_cita !== '' || $fea_dfr !== '' || $fea_dto !== '');
$is_search = $q->is_search();
// Activar si: es búsqueda, o si hay vars avanzadas (con o sin ?s=)
if (!$is_search && !$has_adv) return;
// Si hay filtros avanzados pero no ?s=, convertimos el query en listado de posts
// (evitamos que WP muestre la home o una 404)
if ($has_adv && !$is_search) {
$q->set('post_type', 'post');
$q->set('post_status', 'publish');
// Forzamos is_search para que el template search se active
$q->is_home = false;
$q->is_front_page = false;
$q->is_archive = false;
$q->is_search = true;
}
// Autor
if ($fea_author > 0) $q->set('author', $fea_author);
// Categoría (tema)
if ($fea_cat > 0) $q->set('cat', $fea_cat);
// Cita bíblica: coincidencia por PREFIJO (el valor empieza por el término, ej. "Jn").
// Usamos REGEXP '^<term>' con el término escapado para evitar metacaracteres.
if ($fea_cita !== '') {
$regex = '^' . preg_quote($fea_cita, '/');
$q->set('meta_query', [
[
'key' => '_cita_evangelio',
'value' => $regex,
'compare' => 'REGEXP',
],
]);
}
// Fechas: la UI usa <input type="date"> → formato YYYY-MM-DD (fecha completa).
// Tratamos los límites como fechas exactas, inclusive.
if ($fea_dfr !== '' || $fea_dto !== '') {
$date_query = ['inclusive' => true];
if ($fea_dfr !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $fea_dfr)) {
[$y, $m, $d] = array_map('intval', explode('-', $fea_dfr));
$date_query['after'] = ['year' => $y, 'month' => $m, 'day' => $d];
}
if ($fea_dto !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $fea_dto)) {
[$y, $m, $d] = array_map('intval', explode('-', $fea_dto));
$date_query['before'] = ['year' => $y, 'month' => $m, 'day' => $d];
}
// Sólo aplicamos si quedó al menos un límite válido
if (isset($date_query['after']) || isset($date_query['before'])) {
$q->set('date_query', [$date_query]);
}
}
});
// ─────────────────────────────────────────────────────────────────
// Helpers: obtener datos del formulario
// ─────────────────────────────────────────────────────────────────
/** Devuelve los autores elegibles (≥30 posts, sin excluidos), cacheado por request. */
function fea_adv_get_authors(): array {
static $cache = null;
if ($cache !== null) return $cache;
global $wpdb;
$excl = implode(',', array_map('intval', FEA_AUTORES_EXCLUIR));
// Excluimos por ID y, de forma robusta, cualquier display_name que contenga "Testament"
// (Nuevo/Antiguo Testamento, New/Old Testament, Nouveau/Ancien Testament, etc.).
$cache = $wpdb->get_results("
SELECT u.ID, u.display_name, COUNT(p.ID) as cnt
FROM {$wpdb->users} u
JOIN {$wpdb->posts} p ON p.post_author = u.ID
WHERE p.post_status = 'publish'
AND p.post_type = 'post'
AND u.ID NOT IN ({$excl})
AND u.display_name NOT LIKE '%Testament%'
GROUP BY u.ID
HAVING cnt >= 30
ORDER BY u.display_name
");
return $cache;
}
/**
* Lista CURADA de categorías-sección reales (term_ids ES, idioma canónico), en orden
* editorial. Las 153 categorías reales de la BD incluyen categorías-autor y residuales
* de la migración K2 → desplegable inmanejable; por eso se cura a las secciones del sitio.
* En idiomas ≠ ES se traduce cada term con Polylang.
*/
defined('FEA_CATS_CURADA') or define('FEA_CATS_CURADA', [
1650, // Artículos
1647, // Comentarios al evangelio
1645, // Lecturas bíblicas
1648, // Eucaristía
1646, // Comentario editorial
1649, // Multimedia
63, // EFFA
14, // A la fuente cada día
23, // Cartas que nos llegan
41, // Noticias de alcance
24, // Tablón de anuncios
54, // Canciones religiosas
45, // Canciones-plegarias
]);
/**
* Etiqueta bonita en el desplegable (solo ES) para categorías cuyo nombre en BD quedó
* sin formatear en la migración K2 (no toca el dato del término).
*/
defined('FEA_CATS_LABEL') or define('FEA_CATS_LABEL', [
14 => 'A la fuente cada día', // en BD es "Alafuentecadadia"
]);
/**
* Devuelve las categorías del selector (lista curada FEA_CATS_CURADA), traducidas al
* idioma activo (Polylang) y conservando el orden editorial.
* Devuelve array de objetos {term_id, name} con el term_id DEL IDIOMA ACTUAL.
*/
function fea_adv_get_categories(): array {
static $cache = null;
if ($cache !== null) return $cache;
$lang = function_exists('pll_current_language') ? pll_current_language() : '';
$default = function_exists('pll_default_language') ? pll_default_language() : 'es';
$out = [];
foreach (FEA_CATS_CURADA as $es_id) {
$tid = (int) $es_id;
if ($lang && $lang !== $default && function_exists('pll_get_term')) {
$tr = pll_get_term($es_id, $lang);
if ($tr) $tid = (int) $tr;
}
$term = get_term($tid, 'category');
if (!$term || is_wp_error($term)) continue;
$name = $term->name;
if ((!$lang || $lang === $default) && isset(FEA_CATS_LABEL[$es_id])) {
$name = FEA_CATS_LABEL[$es_id];
}
$out[] = (object) ['term_id' => (int) $term->term_id, 'name' => $name];
}
$cache = $out;
return $cache;
}
/** URL base del idioma actual (para el action del form). */
function fea_adv_lang_base(): string {
$base = home_url('/');
if (function_exists('pll_current_language')) {
$lang = pll_current_language();
$default = function_exists('pll_default_language') ? pll_default_language() : 'es';
if ($lang && $lang !== $default) $base = home_url('/' . $lang . '/');
}
return $base;
}
/** Nombre traducido de una categoría (vía Polylang si está disponible). */
function fea_adv_cat_name(int $cat_id, string $fallback): string {
if (function_exists('pll_current_language')) {
$lang = pll_current_language();
$default = function_exists('pll_default_language') ? pll_default_language() : 'es';
if ($lang !== $default) {
$translated_id = function_exists('pll_get_term') ? pll_get_term($cat_id, $lang) : 0;
if ($translated_id) {
$term = get_term($translated_id);
if ($term && !is_wp_error($term)) return $term->name;
}
}
}
$term = get_term($cat_id, 'category');
return ($term && !is_wp_error($term)) ? $term->name : $fallback;
}
// ─────────────────────────────────────────────────────────────────
// Renderiza el formulario avanzado
// ─────────────────────────────────────────────────────────────────
function fea_adv_form_html(): string {
$action = esc_url(fea_adv_lang_base());
$s = esc_attr(get_search_query());
// Leer de $_GET para fiabilidad (get_query_var puede llegar vacío si la var no estaba registrada aún)
$sel_aut = isset($_GET['fea_author']) ? (int)$_GET['fea_author'] : 0;
$sel_cat = isset($_GET['fea_cat']) ? (int)$_GET['fea_cat'] : 0;
$sel_cit = isset($_GET['fea_cita']) ? esc_attr(sanitize_text_field($_GET['fea_cita'])) : '';
$sel_dfr = isset($_GET['fea_date_from'])? esc_attr(sanitize_text_field($_GET['fea_date_from'])) : '';
$sel_dto = isset($_GET['fea_date_to']) ? esc_attr(sanitize_text_field($_GET['fea_date_to'])) : '';
$authors = fea_adv_get_authors();
$cats = fea_adv_get_categories();
$t_title = esc_html(fea_adv_t('search_advanced'));
$t_word = esc_html(fea_adv_t('word'));
$t_author = esc_html(fea_adv_t('author'));
$t_allaut = esc_html(fea_adv_t('all_authors'));
$t_topic = esc_html(fea_adv_t('topic'));
$t_alltop = esc_html(fea_adv_t('all_topics'));
$t_cita = esc_html(fea_adv_t('biblical_ref'));
$t_citaph = esc_attr(fea_adv_t('biblical_ph'));
$t_dfr = esc_html(fea_adv_t('date_from'));
$t_dto = esc_html(fea_adv_t('date_to'));
$t_btn = esc_html(fea_adv_t('search_btn'));
$t_reset = esc_html(fea_adv_t('reset_btn'));
$html = '<div class="fea-adv-wrap" id="fea-adv-search">';
$html .= '<details class="fea-adv-details" open>';
$html .= '<summary class="fea-adv-summary">' . $t_title . '</summary>';
$html .= '<form class="fea-adv-form" method="get" action="' . $action . '">';
// Fila 1: Palabra
$html .= '<div class="fea-adv-row">';
$html .= '<label class="fea-adv-label" for="fea-s">' . $t_word . '</label>';
$html .= '<input class="fea-adv-input" id="fea-s" type="search" name="s" value="' . $s . '" autocomplete="off">';
$html .= '</div>';
// Fila 2: Autor
$html .= '<div class="fea-adv-row">';
$html .= '<label class="fea-adv-label" for="fea-author">' . $t_author . '</label>';
$html .= '<select class="fea-adv-select" id="fea-author" name="fea_author">';
$html .= '<option value="">' . $t_allaut . '</option>';
foreach ($authors as $a) {
$sel = selected($sel_aut, (int)$a->ID, false);
$name = esc_html($a->display_name);
$html .= "<option value=\"{$a->ID}\"{$sel}>{$name}</option>";
}
$html .= '</select></div>';
// Fila 3: Categoría (real, dinámica)
$html .= '<div class="fea-adv-row">';
$html .= '<label class="fea-adv-label" for="fea-cat">' . $t_topic . '</label>';
$html .= '<select class="fea-adv-select" id="fea-cat" name="fea_cat">';
$html .= '<option value="">' . $t_alltop . '</option>';
foreach ($cats as $c) {
$cat_name = esc_html($c->name);
$sel = selected($sel_cat, $c->term_id, false);
$html .= "<option value=\"{$c->term_id}\"{$sel}>{$cat_name}</option>";
}
$html .= '</select></div>';
// Fila 4: Cita bíblica
$html .= '<div class="fea-adv-row">';
$html .= '<label class="fea-adv-label" for="fea-cita">' . $t_cita . '</label>';
$html .= '<input class="fea-adv-input" id="fea-cita" type="text" name="fea_cita" value="' . $sel_cit . '" placeholder="' . $t_citaph . '">';
$html .= '</div>';
// Fila 5: Fechas
$html .= '<div class="fea-adv-row fea-adv-dates">';
$html .= '<span class="fea-adv-label">' . $t_dfr . '</span>';
$html .= '<input class="fea-adv-input fea-adv-date" type="date" name="fea_date_from" value="' . $sel_dfr . '">';
$html .= '<span class="fea-adv-label fea-adv-to">' . $t_dto . '</span>';
$html .= '<input class="fea-adv-input fea-adv-date" type="date" name="fea_date_to" value="' . $sel_dto . '">';
$html .= '</div>';
// Botones
$html .= '<div class="fea-adv-actions">';
$html .= '<button class="fea-adv-btn fea-adv-btn-primary" type="submit">' . $t_btn . '</button>';
$html .= '<a class="fea-adv-btn fea-adv-btn-secondary" href="' . $action . '">' . $t_reset . '</a>';
$html .= '</div>';
$html .= '</form></details></div>';
return $html;
}
// ─────────────────────────────────────────────────────────────────
// Chips de filtros activos + contador de resultados
// ─────────────────────────────────────────────────────────────────
function fea_adv_chips_html(): string {
$chips = [];
$aut_id = isset($_GET['fea_author']) ? (int)$_GET['fea_author'] : 0;
if ($aut_id > 0) {
$udata = get_userdata($aut_id);
$name = $udata ? esc_html($udata->display_name) : $aut_id;
$chips[] = fea_adv_chip(fea_adv_t('filter_author') . ': ' . $name, 'fea_author');
}
$cat_id = isset($_GET['fea_cat']) ? (int)$_GET['fea_cat'] : 0;
if ($cat_id > 0) {
$term = get_term($cat_id, 'category');
$name = ($term && !is_wp_error($term)) ? esc_html($term->name) : $cat_id;
$chips[] = fea_adv_chip(fea_adv_t('filter_topic') . ': ' . $name, 'fea_cat');
}
$cita = isset($_GET['fea_cita']) ? sanitize_text_field($_GET['fea_cita']) : '';
if ($cita !== '') {
$chips[] = fea_adv_chip(fea_adv_t('filter_cita') . ': ' . esc_html($cita), 'fea_cita');
}
$dfr = isset($_GET['fea_date_from']) ? sanitize_text_field($_GET['fea_date_from']) : '';
$dto = isset($_GET['fea_date_to']) ? sanitize_text_field($_GET['fea_date_to']) : '';
if ($dfr !== '' || $dto !== '') {
$label = fea_adv_t('filter_date') . ': ' . ($dfr ?: '?') . ' ' . ($dto ?: '?');
$chips[] = fea_adv_chip(esc_html($label), 'fea_date_from', 'fea_date_to');
}
if (empty($chips)) return '';
return '<div class="fea-adv-chips"><span class="fea-adv-chips-label">' .
esc_html(fea_adv_t('active_filters')) . '</span>' . implode('', $chips) . '</div>';
}
/** Genera un chip con botón ✕ que elimina el filtro de la URL. */
function fea_adv_chip(string $label, string ...$remove_params): string {
$url = remove_query_arg($remove_params);
return '<span class="fea-adv-chip">' . $label .
' <a href="' . esc_url($url) . '" class="fea-adv-chip-x" aria-label="Eliminar filtro">×</a></span>';
}
// ─────────────────────────────────────────────────────────────────
// Inyección en la página de resultados (template search)
// ─────────────────────────────────────────────────────────────────
/**
* Inyecta el formulario avanzado + chips antes del primer bloque wp:query-title
* del template de búsqueda, modificando el HTML renderizado del bloque principal.
*/
add_filter('render_block', function (string $html, array $block): string {
if (is_admin()) return $html;
// Inyectar en búsquedas y cuando hay filtros avanzados activos
$has_adv_get = !empty($_GET['fea_author']) || !empty($_GET['fea_cat']) ||
!empty($_GET['fea_cita']) || !empty($_GET['fea_date_from']) || !empty($_GET['fea_date_to']);
if (!is_search() && !is_page('buscar') && !$has_adv_get) return $html;
if (($block['blockName'] ?? '') !== 'core/query-title') return $html;
$form = fea_adv_form_html();
$chips = fea_adv_chips_html();
// Contador de resultados (sólo cuando hay consulta activa)
$counter = '';
if (is_search() || $has_adv_get) {
global $wp_query;
if ($wp_query && $wp_query->found_posts !== null) {
$n = (int) $wp_query->found_posts;
$counter = '<div class="fea-adv-count">' . $n . ' ' . esc_html(fea_adv_t('results')) . '</div>';
}
}
return $form . $chips . $counter . $html;
}, 10, 2);
/**
* Añade byline (autor) en cada tarjeta fea-archive-card dentro del template search.
* Lo hacemos inyectando tras wp:post-date en el contexto correcto.
*/
add_filter('render_block', function (string $html, array $block): string {
if (is_admin()) return $html;
$has_adv_get2 = !empty($_GET['fea_author']) || !empty($_GET['fea_cat']) ||
!empty($_GET['fea_cita']) || !empty($_GET['fea_date_from']) || !empty($_GET['fea_date_to']);
if (!is_search() && !is_page('buscar') && !$has_adv_get2) return $html;
if (($block['blockName'] ?? '') !== 'core/post-date') return $html;
$post_id = $block['attrs']['postId'] ?? (in_the_loop() ? get_the_ID() : 0);
if (!$post_id) $post_id = get_the_ID();
if (!$post_id) return $html;
$author_id = (int) get_post_field('post_author', $post_id);
$author_name = get_the_author_meta('display_name', $author_id);
if (!$author_name) return $html;
$author_url = get_author_posts_url($author_id);
$byline = '<div class="fea-adv-byline">' . esc_html(fea_adv_t('by')) . ' ' .
'<a href="' . esc_url($author_url) . '">' . esc_html($author_name) . '</a></div>';
return $html . $byline;
}, 10, 2);
// ─────────────────────────────────────────────────────────────────
// Enlace «Búsqueda avanzada» desde la barra fea-search
// ─────────────────────────────────────────────────────────────────
add_filter('render_block', function (string $html, array $block): string {
if (is_admin()) return $html;
if (($block['blockName'] ?? '') !== 'core/template-part') return $html;
$slug = $block['attrs']['slug'] ?? '';
if (!in_array($slug, ['header', 'cabecera-portada'], true)) return $html;
// Sólo inyectamos el enlace si ya hay una barra de búsqueda (.fea-search-bar)
// Buscamos la barra y le añadimos el enlace de búsqueda avanzada.
if (strpos($html, 'fea-search-bar') === false) return $html;
$adv_url = home_url('/buscar/');
if (function_exists('pll_current_language')) {
$lang = pll_current_language();
$default = function_exists('pll_default_language') ? pll_default_language() : 'es';
if ($lang && $lang !== $default) {
// Intenta obtener la página /buscar traducida
$page = get_page_by_path('buscar');
if ($page) {
$tl = function_exists('pll_get_post') ? pll_get_post($page->ID, $lang) : 0;
if ($tl) $adv_url = get_permalink($tl);
}
}
}
$label = esc_html(fea_adv_t('search_advanced'));
$link = '<div class="fea-adv-link-wrap"><a class="fea-adv-link" href="' . esc_url($adv_url) . '">' . $label . '</a></div>';
// Insertamos el enlace justo al final del bloque .fea-search-bar (cerrando el div externo)
// fea-search.php genera: <div class="fea-search-bar"><form ...>...</form></div>
// Reemplazamos la ÚLTIMA ocurrencia del cierre del div de .fea-search-bar
$marker = '</div>';
$pos = strpos($html, 'fea-search-bar');
if ($pos !== false) {
// Buscamos el </div> que cierra .fea-search-bar (el wrapper externo)
// La estructura es: <div class="fea-search-bar"><form>...</form></div>
// Hay dos </div>: uno cierra el form y otro cierra .fea-search-bar
// Usamos una sustitución segura: buscamos el patrón exacto del cierre
$html = preg_replace('#(</form></div>)#', '$1' . $link, $html, 1);
}
return $html;
}, 25, 2);
// ─────────────────────────────────────────────────────────────────
// Página /buscar — inyectar formulario vía the_content
// ─────────────────────────────────────────────────────────────────
add_filter('the_content', function (string $content): string {
if (!is_page('buscar')) return $content;
// Envoltura: título + formulario + contenido original de la página
$form = fea_adv_form_html();
return $form . '<div class="fea-buscar-intro">' . $content . '</div>';
});
// Nota: la página /buscar se crea con scripts/create_buscar_page.php (ya ejecutado).
// El formulario se inyecta en the_content (hook arriba) y vía render_block en search.
// ─────────────────────────────────────────────────────────────────
// CSS
// ─────────────────────────────────────────────────────────────────
add_action('wp_head', function (): void {
if (!is_search() && !is_page('buscar') &&
empty($_GET['fea_author']) && empty($_GET['fea_cat']) &&
empty($_GET['fea_cita']) && empty($_GET['fea_date_from']) && empty($_GET['fea_date_to'])) return;
?>
<style>
/* ── Formulario buscador avanzado ─────────────────────────────── */
.fea-adv-wrap{max-width:860px;margin:0 auto 1.5rem;padding:0 1rem}
.fea-adv-details{background:#faf6f7;border:1px solid #e8d5da;border-radius:8px;overflow:hidden}
.fea-adv-summary{
padding:.75rem 1.2rem;font-weight:600;font-size:1rem;color:#8b1a2e;
cursor:pointer;list-style:none;display:flex;align-items:center;gap:.5rem;
background:#fff0f2;border-bottom:1px solid #e8d5da;
}
.fea-adv-summary::-webkit-details-marker{display:none}
.fea-adv-summary::before{content:"▸";transition:transform .2s}
details[open] .fea-adv-summary::before{transform:rotate(90deg)}
.fea-adv-form{display:grid;grid-template-columns:1fr 1fr;gap:.75rem 1.2rem;padding:1rem 1.2rem}
@media(max-width:600px){.fea-adv-form{grid-template-columns:1fr}}
/* min-width:0 evita que el contenido fuerce a la celda del grid a desbordar */
.fea-adv-row{display:flex;flex-direction:column;gap:.25rem;min-width:0}
.fea-adv-dates{grid-column:1/-1;flex-direction:row;align-items:center;flex-wrap:wrap;gap:.5rem}
.fea-adv-dates .fea-adv-label{white-space:nowrap}
.fea-adv-label{font-size:.8rem;font-weight:600;color:#5a3a40;text-transform:uppercase;letter-spacing:.04em}
.fea-adv-to{margin-left:.5rem}
.fea-adv-input,.fea-adv-select{
box-sizing:border-box;border:1px solid #d9c4c9;border-radius:6px;padding:.5rem .75rem;
font-size:.95rem;background:#fff;color:#222;width:100%;max-width:100%;
transition:border-color .15s;
}
.fea-adv-input:focus,.fea-adv-select:focus{border-color:#8b1a2e;outline:0;box-shadow:0 0 0 2px #8b1a2e33}
.fea-adv-date{box-sizing:border-box;width:auto;min-width:150px;flex:0 0 auto}
.fea-adv-actions{grid-column:1/-1;display:flex;gap:.75rem;align-items:center;margin-top:.25rem}
.fea-adv-btn{padding:.55rem 1.4rem;border-radius:999px;font-size:.95rem;font-weight:600;cursor:pointer;text-decoration:none;border:2px solid transparent}
.fea-adv-btn-primary{background:#8b1a2e;color:#fff;border-color:#8b1a2e}
.fea-adv-btn-primary:hover{background:#6f1525;border-color:#6f1525}
.fea-adv-btn-secondary{background:transparent;color:#8b1a2e;border-color:#8b1a2e}
.fea-adv-btn-secondary:hover{background:#8b1a2e;color:#fff}
/* ── Contador y chips ─────────────────────────────────────────── */
.fea-adv-count{max-width:860px;margin:.5rem auto .25rem;padding:0 1rem;
font-size:.9rem;color:#666}
.fea-adv-chips{max-width:860px;margin:.5rem auto;padding:0 1rem;
display:flex;flex-wrap:wrap;gap:.4rem;align-items:center}
.fea-adv-chips-label{font-size:.8rem;color:#5a3a40;font-weight:600;margin-right:.25rem}
.fea-adv-chip{display:inline-flex;align-items:center;gap:.35rem;background:#f0e2e5;
border:1px solid #d9c4c9;border-radius:999px;padding:.2rem .75rem;font-size:.82rem;color:#5a3a40}
.fea-adv-chip-x{color:#8b1a2e;text-decoration:none;font-weight:700;font-size:1rem;line-height:1}
.fea-adv-chip-x:hover{color:#6f1525}
/* ── Byline en tarjetas de resultado ──────────────────────────── */
.fea-archive-card .fea-adv-byline{font-size:.78rem;color:#7a5a62;margin-top:.1rem}
.fea-archive-card .fea-adv-byline a{color:#8b1a2e;text-decoration:none}
.fea-archive-card .fea-adv-byline a:hover{text-decoration:underline}
/* ── Enlace «Búsqueda avanzada» en barra header ───────────────── */
.fea-adv-link-wrap{display:none;justify-content:center;padding:.3rem 1rem .4rem;
background:#faf6f7;border-bottom:1px solid #efe2e5}
@media(max-width:600px){.fea-adv-link-wrap{display:flex}}
.fea-adv-link{font-size:.8rem;color:#8b1a2e;text-decoration:none;font-weight:500}
.fea-adv-link:hover{text-decoration:underline}
</style>
<?php
}, 10);
// CSS del enlace «Búsqueda avanzada» en header (se muestra en todas las páginas)
add_action('wp_head', function (): void {
?>
<style>
.fea-adv-link-wrap{display:none;justify-content:center;padding:.3rem 1rem .4rem;
background:#faf6f7;border-bottom:1px solid #efe2e5}
@media(max-width:600px){.fea-adv-link-wrap{display:flex}}
.fea-adv-link{font-size:.8rem;color:#8b1a2e;text-decoration:none;font-weight:500}
.fea-adv-link:hover{text-decoration:underline}
</style>
<?php
}, 15);
@@ -0,0 +1,101 @@
<?php
/**
* Plugin Name: Fe Adulta — Motor FULLTEXT (#8)
* Description: Sustituye el LIKE nativo de WP por MATCH AGAINST (MySQL FULLTEXT, InnoDB,
* Boolean Mode) cuando se hace una búsqueda por texto (/?s=…). Ordena por
* relevancia FULLTEXT si no se pide otro criterio de orden. Degradación elegante:
* si no hay término o el índice FULLTEXT no existe, usa el comportamiento nativo.
* Convive con fea-search-advanced.php (filtros pre_get_posts de autor/cat/cita/fecha).
* Version: 1.1
*/
if (!defined('ABSPATH')) exit;
/**
* Comprueba (cacheado) que existe el índice FULLTEXT 'fea_ft' en wp_posts.
* Si no existe, el motor se degrada al comportamiento nativo para no romper la búsqueda
* con un error SQL (MATCH AGAINST requiere el índice).
*/
function fea_ft_index_exists(): bool {
static $cached = null;
if ($cached !== null) return $cached;
// Cache persistente 12h vía transient para evitar el SHOW INDEX en cada request.
$t = get_transient('fea_ft_index_exists');
if ($t !== false) {
$cached = ($t === '1');
return $cached;
}
global $wpdb;
$found = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND INDEX_NAME = 'fea_ft'",
$wpdb->posts
));
$cached = $found > 0;
set_transient('fea_ft_index_exists', $cached ? '1' : '0', 12 * HOUR_IN_SECONDS);
return $cached;
}
/**
* Calcula el término FULLTEXT en Boolean Mode (cada palabra con prefijo *).
* Devuelve '' si el término sanitizado queda vacío.
*/
function fea_ft_boolean_term(string $raw): string {
$term = trim(substr(preg_replace('/[^\p{L}\p{N}\s\'\-]/u', '', $raw), 0, 200));
if ($term === '') return '';
global $wpdb;
$words = preg_split('/\s+/', $term);
return implode('* ', array_map(fn($w) => $wpdb->esc_like($w), $words)) . '*';
}
/**
* Reemplaza la cláusula WHERE de búsqueda por MATCH AGAINST.
* Hook: posts_search (filtra el WHERE que WP construye para /?s=).
*/
add_filter('posts_search', function (string $search, WP_Query $q): string {
if (is_admin() || !$q->is_main_query() || !$q->is_search()) return $search;
if (!fea_ft_index_exists()) return $search; // degradación elegante
$raw = trim((string) $q->get('s'));
if ($raw === '') return $search;
$ft_term = fea_ft_boolean_term($raw);
if ($ft_term === '') return $search;
global $wpdb;
$ft_esc = esc_sql($ft_term);
// WP construye " AND (...) " para la búsqueda; devolvemos un bloque AND compatible.
return " AND (MATCH({$wpdb->posts}.post_title, {$wpdb->posts}.post_content) AGAINST ('{$ft_esc}' IN BOOLEAN MODE)) ";
}, 10, 2);
/**
* Ordena por relevancia FULLTEXT cuando no se especifica otro orden.
* Hook: posts_clauses (permite modificar SELECT y ORDER BY juntos).
*/
add_filter('posts_clauses', function (array $clauses, WP_Query $q): array {
if (is_admin() || !$q->is_main_query() || !$q->is_search()) return $clauses;
if (!fea_ft_index_exists()) return $clauses; // degradación elegante
$raw = trim((string) $q->get('s'));
if ($raw === '') return $clauses;
// Sólo reordenamos por relevancia si el orderby es el nativo de búsqueda.
$ob = $q->get('orderby');
if (!in_array($ob, ['relevance', 'date', ''], true)) return $clauses;
$ft_term = fea_ft_boolean_term($raw);
if ($ft_term === '') return $clauses;
global $wpdb;
$ft_esc = esc_sql($ft_term);
// Añadimos la columna de relevancia al SELECT y la usamos en ORDER BY.
$score_col = "MATCH({$wpdb->posts}.post_title, {$wpdb->posts}.post_content) AGAINST ('{$ft_esc}' IN BOOLEAN MODE)";
$clauses['fields'] .= ", ({$score_col}) AS fea_ft_score";
$clauses['orderby'] = "fea_ft_score DESC, {$wpdb->posts}.post_date DESC";
return $clauses;
}, 10, 2);