| 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';
?>
|