buscar.php Motor de búsqueda FULLTEXT 1 líneas
1
<?php
// ============================================================
// SIDE - ARC 1.0
// Archivo: buscar.php
// ============================================================

if (session_status() === PHP_SESSION_NONE) {
    session_start();
}

require_once 'conexion.php';
require_once 'helpers.php';

$titulo_pagina = 'Búsqueda';
$es_admin = isset($_SESSION['admin_id']) && !empty($_SESSION['admin_id']);

$campos_disponibles = [
    'titulo'                 => 'Título',
    'alcance_contenido'      => 'Alcance y contenido',
    'notas'                  => 'Notas',
    'historia_institucional' => 'Historia institucional',
    'pa_nombre'              => 'PA – Nombre',
    'pa_geografico'          => 'PA – Nombre geográfico',
    'pam_materia'            => 'PAM – Materia',
    'codigo_referencia'      => 'Código de referencia',
    'nombre_productor'       => 'Nombre del productor',
];

$campos_fulltext = [
    'titulo', 'alcance_contenido', 'notas',
    'historia_institucional', 'pa_nombre', 'pa_geografico', 'pam_materia',
];

$niveles_descripcion = [
    'Fondo', '1era División de Fondo', '2da División de Fondo',
    'Subfondo', 'Serie', 'Subserie',
    'Unidad Documental Compuesta', 'Unidad Documental Simple',
];

// ============================================================
// RECOGER PARÁMETROS GET
// ============================================================
$q            = trim($_GET['q']            ?? '');
$nivel_filtro = trim($_GET['nivel_filtro'] ?? '');
$anio_ini     = isset($_GET['anio_ini']) && is_numeric($_GET['anio_ini'])
                ? (int) $_GET['anio_ini'] : '';
$anio_fin     = isset($_GET['anio_fin']) && is_numeric($_GET['anio_fin'])
                ? (int) $_GET['anio_fin'] : '';
$estado_filtro  = trim($_GET['estado_filtro']  ?? '');
$digital_filtro = trim($_GET['digital_filtro'] ?? '');

// ---- Paginación ----
$por_pagina = 50;
$pagina     = isset($_GET['pagina']) && is_numeric($_GET['pagina'])
              ? max(1, (int) $_GET['pagina'])
              : 1;

$criterios = [];
if (!empty($_GET['criterios']) && is_array($_GET['criterios'])) {
    foreach ($_GET['criterios'] as $c) {
        $campo    = trim($c['campo']    ?? '');
        $operador = trim($c['operador'] ?? '');
        $termino  = trim($c['termino']  ?? '');
        if (
            array_key_exists($campo, $campos_disponibles) &&
            in_array($operador, ['debe', 'puede', 'no'], true) &&
            $termino !== ''
        ) {
            $criterios[] = [
                'campo'    => $campo,
                'operador' => $operador,
                'termino'  => $termino,
            ];
        }
    }
}

// ============================================================
// MODO DE BÚSQUEDA
// ============================================================
$modo       = '';
$resultados = [];
$total      = 0;   // filas en la página actual
$total_real = 0;   // total de coincidencias en la BD
$error_busq = '';

$hay_general  = ($q !== '');
$hay_avanzada = !empty($criterios);
$hay_filtros = ($nivel_filtro !== '' || $anio_ini !== '' || $anio_fin !== '' || $estado_filtro !== '' || $digital_filtro !== '');

if ($hay_general) {
    $modo = 'general';
} elseif ($hay_avanzada || $hay_filtros) {
    $modo = 'avanzada';
}

// ============================================================
// HELPER: construir token booleano
// ============================================================
function construir_token(string $termino, string $operador, string $campo = ''): string {
    $campos_pipe = ['pam_materia', 'pa_nombre', 'pa_geografico'];
    
    if (in_array($campo, $campos_pipe, true) && str_contains($termino, '|')) {
        // Cada segmento separado por | es un punto de acceso independiente
        $segmentos = array_filter(array_map('trim', explode('|', $termino)));
        $tokens = [];
        foreach ($segmentos as $seg) {
            $limpio   = preg_replace('/[+\-><()\~*\"@]+/', ' ', $seg);
            $palabras = array_filter(explode(' ', trim($limpio)));
            foreach ($palabras as $p) {
                switch ($operador) {
                    case 'debe': $tokens[] = '+' . $p; break;
                    case 'no':   $tokens[] = '-' . $p; break;
                    default:     $tokens[] = $p;
                }
            }
        }
        return implode(' ', $tokens);
    }

    // Comportamiento original para el resto de campos
    $limpio   = preg_replace('/[+\-><()\~*\"@]+/', ' ', $termino);
    $palabras = array_filter(explode(' ', trim($limpio)));
    $tokens   = [];
    foreach ($palabras as $p) {
        switch ($operador) {
            case 'debe': $tokens[] = '+' . $p; break;
            case 'no':   $tokens[] = '-' . $p; break;
            default:     $tokens[] = $p;
        }
    }
    return implode(' ', $tokens);
}

// ============================================================
// CONSTRUCCIÓN Y EJECUCIÓN DE LA CONSULTA
// ============================================================
if ($modo !== '') {

    $where_parts       = [];
    $score_expr        = '1';   // expresión de relevancia por defecto

    // bind_types/values para la consulta de DATOS (score SELECT + WHERE)
    $bind_types        = '';
    $bind_values       = [];

    // bind_types/values para la consulta de CONTEO (solo WHERE, sin score)
    $bind_types_count  = '';
    $bind_values_count = [];

    // Columnas FULLTEXT con prefijo de alias d.
    $ft_cols = 'd.titulo, d.alcance_contenido, d.notas,
                d.historia_institucional, d.pa_nombre,
                d.pa_geografico, d.pam_materia';

    // ---- MODO GENERAL ----------------------------------------
    if ($modo === 'general') {
        // NATURAL LANGUAGE MODE: TF-IDF real, ranking más preciso.
        // El SELECT necesita un ? para el score y el WHERE otro ? para filtrar.
        // El COUNT solo necesita el ? del WHERE.
        $score_expr    = "MATCH($ft_cols) AGAINST(? IN NATURAL LANGUAGE MODE)";
        $where_parts[] = "MATCH($ft_cols) AGAINST(? IN NATURAL LANGUAGE MODE)";

        // Para datos: ? del score (SELECT) + ? del WHERE
        $bind_types   .= 'ss';
        $bind_values[] = $q;   // score
        $bind_values[] = $q;   // WHERE

        // Para conteo: solo el ? del WHERE
        $bind_types_count  .= 's';
        $bind_values_count[] = $q;
    }

    // ---- MODO AVANZADO ----------------------------------------
    if ($modo === 'avanzada' && $hay_avanzada) {

        $score_parts = [];

        // Acumuladores SEPARADOS: primero se volcarán los del score (SELECT),
        // luego los del WHERE, para que el orden de ? coincida con el SQL.
        $bind_types_score   = '';
        $bind_values_score  = [];

        // Agrupar tokens por campo
        $por_campo = [];
        foreach ($criterios as $c) {
            $token = construir_token($c['termino'], $c['operador'], $c['campo']);
            if ($token !== '') {
                $por_campo[$c['campo']][] = $token;
            }
        }

        foreach ($por_campo as $campo => $tokens) {
            $cadena = implode(' ', $tokens);
            if ($cadena === '') continue;

            if (in_array($campo, $campos_fulltext, true)) {
                // FULLTEXT individual — usa alias d.
                $expr          = "MATCH(d.`$campo`) AGAINST(? IN BOOLEAN MODE)";
                $score_parts[] = $expr;
                $where_parts[] = $expr;
                // Acumular ? del score por separado
                $bind_types_score   .= 's';
                $bind_values_score[] = $cadena;
                // ? del WHERE
                $bind_types         .= 's';
                $bind_values[]       = $cadena;
                // Conteo: solo ? del WHERE
                $bind_types_count   .= 's';
                $bind_values_count[] = $cadena;
            } else {
                // LIKE para campos no-FULLTEXT
                $palabras = array_filter(explode(' ', $cadena));
                foreach ($palabras as $p) {
                    $p = ltrim($p, '+-');
                    if ($p === '') continue;
                    $where_parts[]       = "d.`$campo` LIKE ?";
                    $bind_types         .= 's';
                    $bind_values[]       = '%' . $p . '%';
                    // Conteo: mismo valor, mismo ?
                    $bind_types_count   .= 's';
                    $bind_values_count[] = '%' . $p . '%';
                }
                $score_parts[] = '1';
            }
        }

        $score_expr = !empty($score_parts)
            ? '(' . implode(' + ', $score_parts) . ')'
            : '1';

        // Anteponer los bind del score a los del WHERE, respetando el orden
        // en que MySQL encuentra los ? en el SQL: primero SELECT, luego WHERE.
        $bind_types  = $bind_types_score  . $bind_types;
        $bind_values = array_merge($bind_values_score, $bind_values);
    }

    // ---- FILTROS ESTRICTOS ------------------------------------
    if ($nivel_filtro !== '') {
        $where_parts[]       = 'd.nivel_descripcion = ?';
        $bind_types         .= 's';
        $bind_values[]       = $nivel_filtro;
        $bind_types_count   .= 's';
        $bind_values_count[] = $nivel_filtro;
    }

    if ($anio_ini !== '') {
        $where_parts[]       = 'd.fecha_inicial_filtro >= ?';
        $bind_types         .= 'i';
        $bind_values[]       = $anio_ini;
        $bind_types_count   .= 'i';
        $bind_values_count[] = $anio_ini;
    }

    if ($anio_fin !== '') {
        $where_parts[]       = 'd.fecha_final_filtro <= ?';
        $bind_types         .= 'i';
        $bind_values[]       = $anio_fin;
        $bind_types_count   .= 'i';
        $bind_values_count[] = $anio_fin;
    }

    if ($estado_filtro !== '') {
        $where_parts[]       = 'd.estado = ?';
        $bind_types         .= 's';
        $bind_values[]       = $estado_filtro;
        $bind_types_count   .= 's';
        $bind_values_count[] = $estado_filtro;
    }

    if ($digital_filtro === 'si') {
        $where_parts[] = "d.objeto_digital IS NOT NULL AND d.objeto_digital <> ''";
    } elseif ($digital_filtro === 'no') {
        $where_parts[] = "(d.objeto_digital IS NULL OR d.objeto_digital = '')";
    }

    // ---- SQL FINAL --------------------------------------------
    $where_sql = !empty($where_parts)
        ? 'WHERE ' . implode(' AND ', $where_parts)
        : '';

    $offset = ($pagina - 1) * $por_pagina;

    // --- Consulta de conteo (total real de coincidencias) ------
    $sql_count = "SELECT COUNT(*)
                  FROM descripcion_isadg d
                  $where_sql";

    // --- Consulta de datos (solo la página actual) -------------
    $sql = "SELECT
                d.id,
                d.titulo,
                d.nivel_descripcion,
                d.codigo_referencia,
                d.alcance_contenido,
                d.objeto_digital,
                d.parent_id,
                p.titulo        AS titulo_padre,
                ($score_expr)   AS relevancia
            FROM descripcion_isadg d
            LEFT JOIN descripcion_isadg p ON d.parent_id = p.id
            $where_sql
            ORDER BY relevancia DESC
            LIMIT $por_pagina OFFSET $offset";

    // ---- EJECUTAR --------------------------------------------
    $bind_stmt = function(mysqli_stmt $s, string $types, array $values): void {
        mysqli_stmt_bind_param($s, $types, ...$values);
    };

    if (!empty($bind_values)) {

        // -- Conteo (usa solo los ? del WHERE) --
        $stmt_c = mysqli_prepare($conexion, $sql_count);
        if ($stmt_c) {
            if (!empty($bind_values_count)) {
                $bind_stmt($stmt_c, $bind_types_count, $bind_values_count);
            }
            mysqli_stmt_execute($stmt_c);
            $res_c = mysqli_stmt_get_result($stmt_c);
            $total_real = (int) mysqli_fetch_row($res_c)[0];
            mysqli_free_result($res_c);
            mysqli_stmt_close($stmt_c);
        }

        // -- Datos (usa ? del score SELECT + ? del WHERE) --
        $stmt = mysqli_prepare($conexion, $sql);
        if (!$stmt) {
            $error_busq = 'Error al preparar: ' . mysqli_error($conexion);
        } else {
            $bind_stmt($stmt, $bind_types, $bind_values);
            mysqli_stmt_execute($stmt);
            $res = mysqli_stmt_get_result($stmt);
            while ($fila = mysqli_fetch_assoc($res)) {
                $resultados[] = $fila;
            }
            mysqli_free_result($res);
            mysqli_stmt_close($stmt);
            $total = count($resultados);
        }

    } else {
        // Solo filtros sin texto: sin bind

        // -- Conteo --
        $res_c = mysqli_query($conexion, $sql_count);
        if ($res_c) {
            $total_real = (int) mysqli_fetch_row($res_c)[0];
            mysqli_free_result($res_c);
        }

        // -- Datos --
        $res = mysqli_query($conexion, $sql);
        if ($res) {
            while ($fila = mysqli_fetch_assoc($res)) {
                $resultados[] = $fila;
            }
            mysqli_free_result($res);
            $total = count($resultados);
        }
    }

    $total_paginas = $total_real > 0
        ? (int) ceil($total_real / $por_pagina)
        : 0;
}

// ============================================================
// FILAS DE CRITERIO PARA REPOBLAR EL FORMULARIO
// ============================================================
$filas_criterio = !empty($criterios) ? $criterios : [
    ['campo' => 'titulo', 'operador' => 'debe', 'termino' => '']
];

require_once 'header.php';
?>

<div class="buscar-pagina">

    <div class="buscar-pagina__encabezado">
        <h1 class="buscar-pagina__titulo">Búsqueda en el Fondo Documental</h1>
    </div>

    <form action="/apps/side-arc/buscar.php" method="GET" class="buscar-form">

        <!-- BÚSQUEDA GENERAL -->
        <div class="buscar-bloque">
            <label class="buscar-bloque__titulo">Búsqueda general</label>
            <p class="buscar-bloque__desc">
                Barre simultáneamente título, alcance, notas, historia
                e índices mediante FULLTEXT.
            </p>
            <div class="buscador-general__fila">
                <input type="text" name="q" id="q"
                       class="form-input buscar-input-general"
                       value="<?= h($q) ?>"
                       placeholder="Palabras clave en lenguaje natural…"
                       autocomplete="off">
                <button type="submit" class="btn btn--buscar">Buscar</button>
            </div>
        </div>

        <div class="buscar-divisor"><span>— o combina criterios avanzados —</span></div>

        <!-- BÚSQUEDA AVANZADA -->
        <div class="buscar-bloque">
            <label class="buscar-bloque__titulo">Búsqueda avanzada por campos</label>
            <p class="buscar-bloque__desc">
                "Debe contener" (+), "Puede contener" (relevancia),
                "No debe contener" (−).
            </p>

            <div id="criterios-contenedor">
                <?php foreach ($filas_criterio as $idx => $c): ?>
                <div class="criterio-fila">

                    <select name="criterios[<?= $idx ?>][operador]"
                            class="form-input form-select criterio-operador">
                        <option value="debe"  <?= $c['operador']==='debe'  ? 'selected':'' ?>>Debe contener</option>
                        <option value="puede" <?= $c['operador']==='puede' ? 'selected':'' ?>>Puede contener</option>
                        <option value="no"    <?= $c['operador']==='no'    ? 'selected':'' ?>>No debe contener</option>
                    </select>

                    <select name="criterios[<?= $idx ?>][campo]"
                            class="form-input form-select criterio-campo">
                        <?php foreach ($campos_disponibles as $clave => $etiq): ?>
                            <option value="<?= $clave ?>"
                                <?= $c['campo']===$clave ? 'selected':'' ?>>
                                <?= $etiq ?>
                            </option>
                        <?php endforeach; ?>
                    </select>

                    <input type="text"
                           name="criterios[<?= $idx ?>][termino]"
                           class="form-input criterio-termino"
                           value="<?= h($c['termino']) ?>"
                           placeholder="Término de búsqueda…">

                    <button type="button" class="btn btn--quitar-criterio"
                            onclick="quitarCriterio(this)">✕</button>
                </div>
                <?php endforeach; ?>
            </div>

            <button type="button" class="btn btn--agregar-criterio"
                    onclick="agregarCriterio()">+ Agregar criterio</button>
        </div>

        <!-- FILTROS ESTRICTOS -->
        <div class="buscar-bloque buscar-bloque--filtros">
            <label class="buscar-bloque__titulo">Filtros estrictos</label>
            <div class="filtros-grid">

                <div class="form-grupo">
                    <label for="nivel_filtro" class="form-label">Nivel de descripción</label>
                    <select name="nivel_filtro" id="nivel_filtro"
                            class="form-input form-select">
                        <option value="">— Todos los niveles —</option>
                        <?php foreach ($niveles_descripcion as $nv): ?>
                            <option value="<?= $nv ?>"
                                <?= $nivel_filtro===$nv ? 'selected':'' ?>>
                                <?= $nv ?>
                            </option>
                        <?php endforeach; ?>
                    </select>
                </div>

          <div class="form-grupo">
                    <label for="anio_ini" class="form-label">Año inicial</label>
                    <input type="number" name="anio_ini" id="anio_ini"
                           class="form-input"
                           value="<?= $anio_ini !== '' ? $anio_ini : '' ?>"
                           min="1000" max="2999" placeholder="Ej: 1990">
                </div>

                <div class="form-grupo">
                    <label for="anio_fin" class="form-label">Año final</label>
                    <input type="number" name="anio_fin" id="anio_fin"
                           class="form-input"
                           value="<?= $anio_fin !== '' ? $anio_fin : '' ?>"
                           min="1000" max="2999" placeholder="Ej: 2005">
                </div>

            <div class="form-grupo">
                    <label for="estado_filtro" class="form-label">Estado</label>
                    <select name="estado_filtro" id="estado_filtro"
                    class="form-input form-select">
                    <option value="">— Todos los estados —</option>
                    <?php foreach (['Borrador', 'Revisión', 'Publicado'] as $est): ?>
                    <option value="<?= $est ?>"
                        <?= $estado_filtro === $est ? 'selected' : '' ?>>
                        <?= $est ?>
                    </option>
                    <?php endforeach; ?>
                </select>
            </div>

            <div class="form-grupo">
                <label for="digital_filtro" class="form-label">Objeto digitalizado</label>
                <select name="digital_filtro" id="digital_filtro"
                class="form-input form-select">
                <option value="">— Todos —</option>
                <option value="si" <?= $digital_filtro === 'si' ? 'selected' : '' ?>>Con objeto digital</option>
                <option value="no" <?= $digital_filtro === 'no' ? 'selected' : '' ?>>Sin objeto digital</option>
            </select>
            </div>
        </div>
                
            <div class="filtros-acciones">
                <button type="submit" class="btn btn--buscar">Aplicar búsqueda</button>
                <a href="/apps/side-arc/buscar.php" class="btn btn--cancelar">Limpiar todo</a>
            </div>
        </div>

    </form>

    <!-- RESULTADOS -->
    <?php if ($modo !== ''): ?>
    <div class="buscar-resultados">

        <div class="buscar-resultados__cabecera">
            <h2 class="buscar-resultados__titulo">
                <?php if (!empty($error_busq)): ?>
                    Error en la búsqueda
                <?php elseif ($total_real === 0): ?>
                    Sin resultados
                <?php else:
                    $desde = ($pagina - 1) * $por_pagina + 1;
                    $hasta = min($pagina * $por_pagina, $total_real);
                ?>
                    <?= $total_real ?> resultado(s)
                    <span class="buscar-resultados__modo">
                        — mostrando <?= $desde ?><?= $hasta ?>
                        <?php if ($modo === 'general'): ?>, ordenados por relevancia<?php endif; ?>
                    </span>
                <?php endif; ?>
            </h2>
        </div>

        <?php if (!empty($error_busq)): ?>
            <div class="alerta alerta--error"><?= h($error_busq) ?></div>

        <?php elseif ($total_real === 0): ?>
            <div class="alerta alerta--info">
                No se encontraron registros. Intente con otros términos
                o amplíe los filtros.
            </div>

        <?php else: ?>
            <ol class="buscar-lista">
                <?php foreach ($resultados as $r):
                    $prefijo       = prefijo_nivel($r['nivel_descripcion']);
                    $alcance_raw   = $r['alcance_contenido'] ?? '';
                    $alcance_corto = mb_strlen($alcance_raw) > 220
                        ? mb_substr($alcance_raw, 0, 220) . '…'
                        : $alcance_raw;
                    $tiene_digital = !empty($r['objeto_digital']);
                ?>
                <li class="buscar-resultado">

                    <div class="buscar-resultado__cabecera">
                        <span class="arbol-nodo__prefijo nivel-<?= strtolower($prefijo) ?>">
                            [<?= $prefijo ?>]
                        </span>
                        <a href="/apps/side-arc/ficha.php?id=<?= $r['id'] ?>"
                           class="buscar-resultado__titulo">
                            <?= h($r['titulo']) ?>
                        </a>
                        <?php if ($tiene_digital): ?>
                            <span class="sub-digital-badge">(Digitalizado)</span>
                        <?php endif; ?>
                    </div>

                    <?php if (!empty($r['codigo_referencia'])): ?>
                        <div class="buscar-resultado__codigo">
                            <span class="ficha-codigo">
                                <?= h($r['codigo_referencia']) ?>
                            </span>
                        </div>
                    <?php endif; ?>

                    <?php if (!empty($r['titulo_padre'])): ?>
                        <div class="buscar-resultado__padre">
                            Parte de:
                            <a href="/apps/side-arc/ficha.php?id=<?= $r['parent_id'] ?>">
                                <?= h($r['titulo_padre']) ?>
                            </a>
                        </div>
                    <?php endif; ?>

                    <?php if (!empty($alcance_corto)): ?>
                        <div class="buscar-resultado__alcance">
                            <?= h($alcance_corto) ?>
                        </div>
                    <?php endif; ?>

                </li>
                <?php endforeach; ?>
            </ol>

            <?php if ($total_paginas > 1):
                // Construir la URL base conservando todos los parámetros menos 'pagina'
                $params_base = $_GET;
                unset($params_base['pagina']);
                $url_base = '/apps/side-arc/buscar.php?' . http_build_query($params_base) . '&pagina=';
            ?>
            <nav class="paginacion" aria-label="Páginas de resultados">
                <?php if ($pagina > 1): ?>
                    <a href="<?= $url_base . ($pagina - 1) ?>" class="paginacion__btn">← Anterior</a>
                <?php endif; ?>

                <span class="paginacion__info">
                    Página <?= $pagina ?> de <?= $total_paginas ?>
                </span>

                <?php if ($pagina < $total_paginas): ?>
                    <a href="<?= $url_base . ($pagina + 1) ?>" class="paginacion__btn">Siguiente →</a>
                <?php endif; ?>
            </nav>
            <?php endif; ?>
        <?php endif; ?>

    </div>
    <?php endif; ?>

</div><!-- /.buscar-pagina -->

<script>
let criterioIdx = <?= count($filas_criterio) ?>;
const campos = <?= json_encode($campos_disponibles) ?>;

function agregarCriterio() {
    const contenedor = document.getElementById('criterios-contenedor');
    const idx = criterioIdx++;

    let opcionesCampos = '';
    for (const [val, etiq] of Object.entries(campos)) {
        opcionesCampos += `<option value="${val}">${etiq}</option>`;
    }

    const fila = document.createElement('div');
    fila.className = 'criterio-fila';
    fila.innerHTML = `
        <select name="criterios[${idx}][operador]"
                class="form-input form-select criterio-operador">
            <option value="debe">Debe contener</option>
            <option value="puede">Puede contener</option>
            <option value="no">No debe contener</option>
        </select>
        <select name="criterios[${idx}][campo]"
                class="form-input form-select criterio-campo">
            ${opcionesCampos}
        </select>
        <input type="text"
               name="criterios[${idx}][termino]"
               class="form-input criterio-termino"
               placeholder="Término de búsqueda…">
        <button type="button"
                class="btn btn--quitar-criterio"
                onclick="quitarCriterio(this)">✕</button>
    `;
    contenedor.appendChild(fila);
}

function quitarCriterio(btn) {
    const contenedor = document.getElementById('criterios-contenedor');
    if (contenedor.querySelectorAll('.criterio-fila').length > 1) {
        btn.closest('.criterio-fila').remove();
    }
}
</script>

<?php
mysqli_close($conexion);
require_once 'footer.php';
?>