Indaga Galerías: un plugin propio de sliders y galerías sin librerías externas

Presento Indaga Galerías 1.0.0, un plugin nativo para mostrar imágenes de la Biblioteca de medios como carrusel o rejilla, con visor y sin dependencias de terceros

Valencia, 11/11/2026, G.B.
Me apetecía poder mostrar sliders y galerías de fotos en el blog sin instalar nada de terceros, así que me he hecho un plugin propio. Se llama Indaga Galerías, va por la versión 1.0.0 y hace justo eso: coge imágenes de la Biblioteca de medios y las muestra como carrusel o como rejilla, con un visor para ampliarlas. Todo en un único fichero PHP y sin librerías externas.

¿Por qué un plugin propio si WordPress ya tiene galerías?

Porque lo que trae el editor se me quedaba corto. Desde WordPress 6.4 el bloque Imagen incluye la opción de ampliar al hacer clic, pero es un visor básico pensado para imágenes sueltas: según WPBeginner, en una galería hay que activarlo imagen por imagen. Y un carrusel, directamente, no lo trae.

Para cubrir ese hueco lo habitual son plugins de terceros con sus librerías, sus hojas de estilo y sus actualizaciones. Yo prefiero lo que ya he hecho con el resto de plugins del sitio: algo pequeño, que controlo yo y que solo carga lo necesario.

¿Cómo se crea un slider o una galería?

Desde el panel Indaga Galerías del menú de administración, en la pestaña Instancias. Una instancia es un conjunto de imágenes con su configuración, y sirve igual para mostrarlo como slider o como galería.

  • Pulsas Añadir instancia y le pones un identificador (por ejemplo capturas) y un nombre.
  • Con Seleccionar imágenes eliges las fotos de la Biblioteca de medios y las ordenas con las flechas de cada miniatura.
  • Ajustas la proporción (16:9, 21:9, 3:2, 4:3 o 1:1), las columnas, el autoplay, las leyendas y el visor.
  • Guardas y copias el shortcode del listado.

El plugin solo guarda los IDs de las imágenes, así que no duplica nada: el texto alternativo y las leyendas salen de la propia Biblioteca de medios.

¿Qué shortcodes y opciones tiene?

Dos shortcodes, uno para el carrusel y otro para la rejilla (nota: pongo dobles corchetes en vez de uno solo para que no se ejecute, ya que ya lo he instalado):

[indaga_slider id="capturas"]
[indaga_galeria id="capturas"]

Y estos atributos opcionales, que sustituyen a lo guardado en la instancia:

  • id: identificador de la instancia. Es obligatorio salvo que uses ids.
  • ids: lista de IDs de imágenes separados por comas, para usarlo sin instancia.
  • leyendas: si o no.
  • lightbox: si o no, para activar o desactivar el visor.
  • autoplay: segundos entre diapositivas, de 0 a 30 (solo slider). Con 0 no avanza solo.
  • columnas: de 1 a 6 (solo galería). En móvil baja a 2 y después a 1.

Por ejemplo, una rejilla de cuatro columnas sin leyendas:

[indaga_galeria id="capturas" columnas="4" leyendas="no"]

¿Se puede usar sin crear una instancia antes?

Sí, con el atributo ids. Es útil para algo rápido, como un par de capturas en una entrada:

[indaga_slider ids="12,15,18"]

El ID de cada imagen aparece en la barra de direcciones cuando la abres en la Biblioteca de medios. En este modo se aplican las opciones por defecto, y si además indicas un id de instancia, usa las opciones de esa instancia pero con tus imágenes.

Un ejemplo

plugin comandos linux
plugin recursos
autores-plugin

¿Carga librerías externas o pesa mucho?

No carga ninguna librería. El carrusel se apoya en CSS scroll-snap y un JavaScript mínimo para las flechas, los puntos y el teclado, y el visor usa el elemento dialog del propio navegador. MDN clasifica el método showModal() como una funcionalidad bien establecida, disponible en los principales navegadores desde marzo de 2022. Si alguno no lo soporta, el clic abre la imagen a tamaño completo y ya está.

El CSS y el JavaScript van embebidos en el plugin y solo se cargan en las páginas que contienen alguno de los shortcodes. Las imágenes usan los tamaños que WordPress ya genera, con srcset y carga diferida, y el visor solo descarga la imagen grande cuando haces clic.

¿Es accesible?

Sí, en lo básico, y es algo que quería cuidar. El autoplay viene desactivado por defecto. Si lo activas aparece un botón de pausa, el avance se detiene al pasar el ratón o al enfocar con el teclado, y no se activa nunca si el sistema del usuario tiene reducido el movimiento. Va en la línea del criterio 2.2.2 de las pautas WCAG del W3C, que pide poder pausar, detener u ocultar el contenido que se mueve o se actualiza solo y se muestra junto a otro contenido (W3C).

Además, el carrusel se maneja con el teclado (flechas, Inicio y Fin), el visor se cierra con Esc y se recorre con las flechas, hay etiquetas ARIA, foco visible y el texto alternativo de cada imagen sale de la Biblioteca de medios.

¿Qué le falta?

Poco de lo vistoso, a propósito. Es una 1.0.0 y la he dejado sencilla: rejilla uniforme, carrusel horizontal y visor. No hay masonry, ni efectos de transición, ni vídeo. Las imágenes se recortan al centro con la proporción elegida para que el conjunto quede uniforme, así que si quieres ver la foto entera conviene escoger la proporción que más se parezca a la original. Y como con el resto de plugins, lo iré ajustando según lo vaya usando.

Como siempre, lo tienes en la página de plugins del blog.

Ficha técnica

  • Fecha: noviembre 2026
  • Nombre: Indaga Galerías
  • Versión: 1.0.0
  • Autor: Guillermo Beltrán Pilato
  • Descripción: Sliders y galerías de imágenes 100% nativos. Instancias gestionadas desde el panel con selector de la Biblioteca de medios, shortcodes indaga_slider e indaga_galeria (también con ids sin crear instancia), visor con dialog nativo, autoplay con pausa y sin dependencias externas.
  • Tags: slider, galeria, lightbox, imagenes
  • Enlace de descarga: pCloud

Formulario de Contacto Indaga v1.1.1: Control total, diagnóstico avanzado y cero dependencias

Valencia, 09/11/2026, G.B.
Siguiendo con mi filosofía de desarrollo en Indaga.net, donde apuesto por la ligereza, la privacidad de los datos y la ausencia de dependencias innecesarias, hoy presento la versión 1.1.1 del plugin Formulario de Contacto Indaga.

Cuando diseño soluciones propias para WordPress, mi objetivo principal es eliminar el código pesado y los plugins sobredimensionados que suelen acompañar a herramientas genéricas. Un formulario de contacto debe ser rápido, seguro por defecto y muy fácil de administrar desde el propio panel de control.

Novedades de la versión 1.1.1

Esta nueva versión pule la experiencia de administración y añade herramientas internas clave para la gestión diaria y la resolución de incidencias:

  • Sistema de logs y diagnóstico integrado: Para averiguar fácilmente por qué un correo puede fallar (por restricciones del servidor o políticas SMTP), he incorporado una pestaña de Logs que registra automáticamente los eventos recientes (intentos de envío, errores de captcha, validaciones o fallos de transmisión), manteniendo un historial limpio de hasta 100 entradas.
  • Herramienta de prueba de correo directo: En el listado de formularios he añadido un botón de prueba (Probar email) que permite simular y comprobar al instante si la pasarela de correo del servidor o plugin SMTP está entregando los mensajes correctamente (yo tengo instalado SMTP Indaga, por lo que el email receptor debe ser el mismo que el configurado en eses plugin).
  • Generación inteligente de identificadores: Al crear una nueva instancia de formulario, si dejo el ID en blanco, el sistema genera automáticamente un slug limpio y seguro a partir del nombre asignado utilizando sanitize_title().
  • Claves de captcha más robustas: He perfeccionado la generación de los transients temporales del captcha matemático combinando el identificador del formulario, la marca de tiempo y wp_rand(), blindando el proceso frente a colisiones de caché.

Seguridad nativa sin fricción

Manteniendo los estándares habituales de mis desarrollos en Indaga.net, el plugin sigue apostando por una protección invisible pero eficaz:

  • Cero JavaScript externo: Sin llamadas a servicios de terceros ni rastreadores de datos.
  • Honeypot y captcha dinámico: Bloquea bots y envíos automatizados sin obligar a la visita a resolver imágenes complejas o rompecabezas molestos.
  • Patrón Post/Redirect/Get (PRG): Evita el reenvío duplicado de formularios cuando se recarga la página tras enviar una consulta.

Conclusión

Construir mis propias herramientas nativas no solo mejora el rendimiento global de la web, sino que me otorga un control absoluto sobre cada línea de código que se ejecuta. El plugin ya está operativo y funcionando a pleno rendimiento en mi entorno de producción.

Como siempre, cualquier sugerencia o comentario para seguir mejorando el código es más que bienvenido.

Ficha Técnica del Plugin: Formulario de Contacto Indaga

  • Nombre del Plugin: Formulario de Contacto Indaga
  • Slug / Archivo Principal: indaga-contacto/indaga-contacto.php
  • Versión Actual: 1.1.1
  • Autor: Guillermo Beltrán Pilato (Indaga.net)
  • Licencia: GPL v2 or later
  • Acceso en wp-admin: Contacto Indaga (wp-admin/admin.php?page=indaga-contacto)
  • Shortcode Principal: (entre corchetes [ ] )
    indaga_contacto
    indaga_contacto id="mi-formulario"

    Admite parámetro de instancia, como en el segundo ejemplo.

Descripción Breve

Formulario de contacto nativo, seguro y ligero para WordPress, diseñado sin dependencias externas. Permite gestionar múltiples instancias de formularios, incluye captcha matemático temporal, campo oculto anti-bots (Honeypot), validación de privacidad, registro de logs de diagnóstico y herramienta integrada para probar el envío directo de correo.

Especificaciones Técnicas y Características

  • Gestión de Instancias: Soporte para múltiples formularios personalizados mediante shortcode. Si al crear una instancia dejo el ID en blanco, el plugin genera un slug limpio automáticamente con sanitize_title().
  • Seguridad Integrada: Verificación de Nonces anti-CSRF (wp_verify_nonce), honeypot antispam, captcha matemático temporal con set_transient (expiración a 5 minutos) usando claves dinámicas compuestas por ID, tiempo y wp_rand(), y checkbox obligatorio con enlace a la política de privacidad.
  • Sanitización y Escapado: Filtros estrictos de entrada (sanitize_text_field, sanitize_email, sanitize_textarea_field) y escapado seguro en salida (esc_html, esc_attr, esc_url, wp_kses_post).
  • Diagnóstico y Logs: Pestaña de logs en el panel de control con registro de hasta 100 eventos recientes y función para probar el envío directo de emails mediante wp_mail.
  • Rendimiento: Carga condicional de estilos mediante wp_add_inline_style, archivo único, cero librerías externas y cero peticiones HTTP adicionales.

Descargar plugin de mi cuenta de pCloud

[Actualización]: Contabilidad Casera Indaga v1.8.0: cero dependencias, máxima privacidad

Valencia, 04/11/2026, G.B.
Llevo algún tiempo dándole vueltas a una idea que tenía clavada como una espina: mi plugin de contabilidad casera seguía cargando Chart.js desde un CDN externo. Y eso, en un proyecto que defiende la privacidad y el control absoluto del rendimiento, no tenía sentido.
Hoy publico la versión 1.8.0 de Contabilidad Casera Indaga, y lo hago con la satisfacción de haber cerrado esa espina. Los tres gráficos del plugin (evolución mensual, gastos por categoría y evolución de pasos) ahora son SVG nativos generados directamente con PHP. Ni una sola librería externa. Ni un script de más. Ni una petición a un CDN que pueda caer o rastrearte.

Qué hay de nuevo en la v1.8.0

  • Adiós a Chart.js, hola al SVG nativo. El plugin ya es 100% autónomo. Si mañana desaparece Internet, tu contabilidad sigue funcionando.
  • Dos pestañas nuevas en el panel de administración: Instrucciones de uso y Ficha técnica, tal como marca mi estándar de desarrollo.
  • Edición inline más segura. He reescrito la forma en que se cargan los datos al pulsar «Editar»: ahora se usan atributos data-* en cada fila, eliminando cualquier riesgo de XSS.
  • Consultas optimizadas. La pestaña de presupuestos, que antes hacía una consulta por cada categoría, ahora resuelve todo con una única GROUP BY. Con 20 categorías, pasas de 20 queries a 1.
  • Índices nuevos en la tabla de contabilidad para que los filtros por fecha, tipo y categoría vuelen.
  • Paginación en el listado de movimientos (20 por página), para que el plugin siga siendo ágil aunque lleves años registrando datos.
  • Autocompletado de categorías con datalist al escribir en el formulario de movimientos.

Lo que no ha cambiado

La cabecera del plugin sigue siendo prácticamente la misma. Los datos de tu base de datos siguen intactos. Las funcionalidades que ya tenías —exportación CSV con UTF-8 y BOM, presupuestos flexibles, edición de pasos, buscador multicriterio— siguen funcionando exactamente igual. Porque actualizar un plugin de Indaga.net nunca significa perder nada de lo que ya tenías.

Si ya lo usabas, actualiza sin miedo. Si no lo conocías, te invito a echarle un vistazo: es ligero, es tuyo, y vive enteramente dentro de tu WordPress.

PD. Ya está esta última versión en Control de plugins.

Menor superficie de ataque y control total: claves de la arquitectura de plugins en un único archivo PHP

Valencia, 03/11/2026, G.B. (una aclaración…).
Fuente: FAQs Puntocomunica

La seguridad en los plugins de WordPress es un pilar fundamental para garantizar la estabilidad, el rendimiento y la integridad de cualquier sitio web. Al tratarse del gestor de contenidos más utilizado del mundo, WordPress es un blanco frecuente para ataques malintencionados. Un plugin vulnerable o desactualizado puede convertirse en la vía de acceso perfecta para que actores maliciosos comprometan la base de datos o ejecuten código de forma remota.Para mitigar estos riesgos de manera drástica, proyectos como Indaga.net aplican una estrategia de desarrollo muy particular que a menudo genera debate en la comunidad técnica: agrupar gran parte de la funcionalidad en un único archivo PHP centralizado, una aproximación que algunos desarrolladores denominan popularmente como la filosofía de los spaghetti plugins.Lejos de ser una decisión tomada al azar, centralizar el código en un único archivo .php responde a razones estratégicas muy meditadas para mantener el control absoluto sobre la seguridad y el mantenimiento:

  • Menor superficie de ataque: Al concentrar la lógica principal en un único archivo controlado, se simplifica enormemente la auditoría de código y la detección rápida de brechas de seguridad, evitando la dispersión de vulnerabilidades a través de múltiples subcarpetas o dependencias externas ocultas.
  • Control de dependencias: Se evita por completo la inclusión de librerías de terceros obsoletas o vulnerables, un problema muy común en plugins complejos que cargan scripts externos sin supervisión directa.
  • Optimización y rendimiento: Reducir la modularidad excesiva disminuye la sobrecarga de peticiones y llamadas innecesarias al  servidor, mejorando de forma notable los tiempos de carga generales.

Como ejemplo práctico de esta estrategia aplicada en Indaga.net, se han desarrollado utilidades internas y específicas para la importación directa de archivos masivos a bases de datos y la optimización de motores de búsqueda que operan de forma autónoma, ligera y segura bajo este esquema compacto.

Guardar datos

 

En conclusión, aunque una arquitectura basada en un único archivo .php pueda chocar con los patrones de modularidad estándar de WordPress, en determinados entornos orientados al rendimiento y al control de riesgos demuestra ser una alternativa sumamente eficaz y segura frente a las vulnerabilidades asociadas a complementos sobrecargados.

Cuaderno Técnico: Plugin «Control y Reserva de Recursos» (CRR v1.12.0), parte II

Valencia, 02/11/2026, G.B.
Continúo con el cuaderno técnico del plugin Control y Reserva de Recursos (CRR). En la primera parte vimos la cabecera, las tablas y los permisos. Hoy toca el motor de las reservas: cómo se comprueba que un recurso está libre, cómo se calcula el precio, cómo se guarda una reserva, cómo se avisa por email y cómo se gestionan el inventario y las cancelaciones.

Bloque 4: Disponibilidad y precio estimado

¿Cómo sabe el plugin si un recurso está libre?

Un recurso está libre si, sumando las reservas que se solapan con la nueva, no se supera su stock. Esto permite tanto una sala (stock 1) como un lote de portátiles (stock 20) con la misma lógica:

function crr_comprobar_disponibilidad( $recurso_id, $fecha_inicio, $fecha_fin, $hora_inicio, $hora_fin, $cantidad, $reserva_id_excluir = 0 ) {
	global $wpdb;
	$tabla_reservas = $wpdb->prefix . 'crr_reservas';
	$tabla_recursos = $wpdb->prefix . 'crr_recursos';

	$recurso = $wpdb->get_row( $wpdb->prepare( "SELECT stock FROM $tabla_recursos WHERE id = %d", $recurso_id ) );
	if ( ! $recurso ) {
		return false;
	}

	$sql = "SELECT SUM(cantidad) FROM $tabla_reservas 
			WHERE recurso_id = %d 
			AND id != %d
			AND (fecha_inicio <= %s AND fecha_fin >= %s)
			AND (hora_inicio < %s AND hora_fin > %s)";

	$ocupacion = $wpdb->get_var( $wpdb->prepare( $sql, $recurso_id, $reserva_id_excluir, $fecha_fin, $fecha_inicio, $hora_fin, $hora_inicio ) );
	$ocupacion_actual = $ocupacion ? intval( $ocupacion ) : 0;

	return ( $ocupacion_actual + $cantidad ) <= intval( $recurso->stock );
}

¿Qué ocurre paso a paso en esta función?

  1. Recurso inexistente: si el id no existe en el inventario, devuelve false y no se reserva nada.
  2. Solape de fechas: una reserva existente choca con la nueva si empieza antes de que la nueva termine y acaba después de que la nueva empiece. Son dos comparaciones, no cuatro.
  3. Solape de horas: misma idea con las horas, pero con desigualdades estrictas (< y >). Así, una reserva que termina a las 12:00 no bloquea otra que empieza a las 12:00.
  4. id != %d: al editar una reserva se excluye a sí misma del cálculo. Sin esto, cambiar la hora de tu propia reserva te daría «no disponible» por chocar contigo mismo.
  5. SUM(cantidad): suma las unidades ya ocupadas. Si más lo que pides no supera el stock, hay hueco.
  6. Consulta preparada: todos los valores van con $wpdb->prepare(), nunca concatenados.

¿Cómo se calcula el precio estimado?

Es una cuenta sencilla: días, por horas de cada día, por el precio por hora, por la cantidad. Se calcula en el servidor, no en el navegador, para que no se pueda manipular desde el formulario:

function crr_calcular_precio_estimado( $recurso_id, $fecha_inicio, $fecha_fin, $hora_inicio, $hora_fin, $cantidad ) {
	global $wpdb;
	$tabla_recursos = $wpdb->prefix . 'crr_recursos';
	
	$recurso = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $tabla_recursos WHERE id = %d", $recurso_id ) );
	if ( ! $recurso ) {
		return 0;
	}

	$f_inicio = new DateTime( $fecha_inicio );
	$f_fin    = new DateTime( $fecha_fin );
	$dias     = $f_inicio->diff( $f_fin )->days + 1;

	$h_inicio  = new DateTime( $hora_inicio );
	$h_fin     = new DateTime( $hora_fin );
	$intervalo = $h_inicio->diff( $h_fin );
	$horas     = $intervalo->h + ( $intervalo->i / 60 );

	if ( $horas <= 0 ) {
		$horas = 1;
	}

	$total = $dias * $horas * floatval( $recurso->precio_hora ) * absint( $cantidad );
	return round( $total, 2 );
}
  1. diff()->days + 1: la diferencia entre el día 10 y el 10 es 0, pero una reserva de un solo día cuenta como 1, de ahí el + 1.
  2. Horas con decimales: 9:00 a 13:30 son 4,5 horas (h más los minutos entre 60).
  3. Mínimo de una hora: si el intervalo sale a cero o negativo, se factura una hora en lugar de devolver un precio de cero.
  4. Cantidad saneada: absint() evita cantidades negativas.

Una limitación que conviene tener presente: el cálculo toma las horas de un mismo día, así que no contempla horarios que crucen la medianoche. Para salas y equipos de oficina no me ha hecho falta, pero queda anotado como posible mejora.

Bloque 5: Guardar una reserva y avisar por email

¿Cómo se crea o se edita una reserva?

Una única función se ocupa de las dos cosas. Si recibe un $reserva_id mayor que cero, edita; si no, inserta. Siempre devuelve un array con un mensaje y un tipo (success, warning o error), que luego se pinta como aviso en pantalla:

function crr_procesar_reserva( $recurso_id, $email_solicitante, $fecha_inicio, $fecha_fin, $hora_inicio, $hora_fin, $cantidad, $observaciones = '', $reserva_id = 0, $enviar_presupuesto = false ) {
	global $wpdb;
	$tabla_reservas = $wpdb->prefix . 'crr_reservas';
	$current_user = wp_get_current_user();

	if ( ! crr_comprobar_disponibilidad( $recurso_id, $fecha_inicio, $fecha_fin, $hora_inicio, $hora_fin, $cantidad, $reserva_id ) ) {
		return array( 'mensaje' => 'El recurso no está disponible en el horario y cantidad seleccionados.', 'tipo' => 'error' );
	}

	$precio_estimado = crr_calcular_precio_estimado( $recurso_id, $fecha_inicio, $fecha_fin, $hora_inicio, $hora_fin, $cantidad );

	$datos = array(
		'recurso_id'        => $recurso_id,
		'user_id'           => $current_user->ID,
		'email_solicitante' => $email_solicitante,
		'fecha_inicio'      => $fecha_inicio,
		'fecha_fin'         => $fecha_fin,
		'hora_inicio'       => $hora_inicio,
		'hora_fin'          => $hora_fin,
		'cantidad'          => $cantidad,
		'observaciones'     => $observaciones,
		'precio_estimado'   => $precio_estimado,
	);

	$formato = array( '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%f' );

	if ( $reserva_id > 0 ) {
		$reserva_existente = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $tabla_reservas WHERE id = %d", $reserva_id ) );
		if ( ! $reserva_existente || ( ! current_user_can( 'manage_options' ) && ! crr_es_usuario_autorizado() && (int) $reserva_existente->user_id !== $current_user->ID ) ) {
			return array( 'mensaje' => 'No tienes permisos para editar esta reserva.', 'tipo' => 'error' );
		}

		$resultado = $wpdb->update( $tabla_reservas, $datos, array( 'id' => $reserva_id ), $formato, array( '%d' ) );
		if ( false !== $resultado ) {
			crr_enviar_notificaciones( $reserva_id, true, $enviar_presupuesto );
			return array( 'mensaje' => 'Reserva #' . $reserva_id . ' actualizada correctamente.', 'tipo' => 'success' );
		}
	} else {
		$resultado = $wpdb->insert( $tabla_reservas, $datos, $formato );
		if ( false !== $resultado ) {
			$nuevo_id = $wpdb->insert_id;
			crr_enviar_notificaciones( $nuevo_id, false, $enviar_presupuesto );
			return array( 'mensaje' => 'Reserva #' . $nuevo_id . ' realizada correctamente.', 'tipo' => 'success' );
		}
	}

	return array( 'mensaje' => 'Error al procesar la reserva en la base de datos.', 'tipo' => 'error' );
}

¿Qué ocurre paso a paso en esta función?

  1. Primero disponibilidad: si no hay hueco, se corta aquí con un error y no se toca la base de datos.
  2. Precio en servidor: se recalcula siempre, aunque el formulario muestre una estimación.
  3. Array de formatos: $formato tiene un marcador por cada campo de $datos, en el mismo orden (%d enteros, %s textos, %f decimal). $wpdb los usa para escapar cada valor.
  4. Quién puede editar: un administrador, un usuario autorizado (la función de la parte I) o el propio autor de la reserva. Nadie más.
  5. false !== $resultado: update() devuelve 0 si no cambió ninguna fila, y eso no es un error. Por eso se compara con false estricto.
  6. Aviso por email: solo se envía cuando el guardado ha ido bien.

¿Cómo se envían los emails de la reserva?

Cada reserva genera un correo en HTML para el solicitante y otro para el responsable del recurso. Se recupera la reserva con un LEFT JOIN para traer en una sola consulta el nombre del recurso y el email del responsable:

function crr_enviar_notificaciones( $reserva_id, $es_edicion = false, $incluir_precio = true ) {
	global $wpdb;
	$tabla_reservas = $wpdb->prefix . 'crr_reservas';
	$tabla_recursos = $wpdb->prefix . 'crr_recursos';

	$reserva = $wpdb->get_row( $wpdb->prepare( "SELECT r.*, rec.nombre as recurso_nombre, rec.email_propietario FROM $tabla_reservas r LEFT JOIN $tabla_recursos rec ON r.recurso_id = rec.id WHERE r.id = %d", $reserva_id ) );
	if ( ! $reserva ) {
		return;
	}

	$asunto  = ( $es_edicion ? '[Actualización] ' : '[Nueva Reserva] ' ) . 'Reserva #' . $reserva->id . ' - ' . $reserva->recurso_nombre;
	$headers = array('Content-Type: text/html; charset=UTF-8');

	$mensaje = "<h2>Detalles de la Reserva #" . esc_html( $reserva->id ) . "</h2>";
	$mensaje .= "<p><strong>Recurso:</strong> " . esc_html( $reserva->recurso_nombre ) . "</p>";
	$mensaje .= "<p><strong>Solicitante:</strong> " . esc_html( $reserva->email_solicitante ) . "</p>";
	$mensaje .= "<p><strong>Fechas:</strong> Del " . esc_html( $reserva->fecha_inicio ) . " al " . esc_html( $reserva->fecha_fin ) . "</p>";
	$mensaje .= "<p><strong>Horario:</strong> De " . esc_html( $reserva->hora_inicio ) . " a " . esc_html( $reserva->hora_fin ) . "</p>";
	$mensaje .= "<p><strong>Cantidad:</strong> " . esc_html( $reserva->cantidad ) . "</p>";
	if ( $incluir_precio ) {
		$mensaje .= "<p><strong>Precio Estimado:</strong> " . esc_html( $reserva->precio_estimado ) . " €</p>";
	}
	if ( ! empty( $reserva->observaciones ) ) {
		$mensaje .= "<p><strong>Observaciones:</strong> " . esc_html( $reserva->observaciones ) . "</p>";
	}

	wp_mail( $reserva->email_solicitante, $asunto, $mensaje, $headers );
	if ( ! empty( $reserva->email_propietario ) && $reserva->email_propietario !== $reserva->email_solicitante ) {
		wp_mail( $reserva->email_propietario, $asunto, $mensaje, $headers );
	}
}
  1. Asunto según el caso: [Nueva Reserva] o [Actualización], seguido del número y el nombre del recurso, para localizarlos rápido en la bandeja de entrada.
  2. Cabecera HTML: Content-Type: text/html hace que wp_mail() envíe el cuerpo con formato en lugar de texto plano.
  3. Todo escapado: cada dato que viene de la base de datos pasa por esc_html() antes de entrar en el correo.
  4. Presupuesto opcional: la línea del precio solo aparece si $incluir_precio es verdadero, que es lo que decide la casilla «Enviar desglose de presupuesto» del formulario.
  5. Sin duplicados: si el solicitante es también el responsable del recurso, solo recibe un correo.

Bloque 6: Inventario y cancelaciones

¿Cómo se modifican y se eliminan los recursos del inventario?

Solo los administradores (manage_options) tocan el inventario. La edición valida los campos obligatorios y comprueba que el recurso existe. El borrado, además, se niega si el recurso tiene reservas asociadas, para no dejar reservas huérfanas apuntando a algo que ya no existe:

function crr_actualizar_recurso( $recurso_id, $datos ) {
	global $wpdb;

	if ( ! current_user_can( 'manage_options' ) ) {
		return array( 'mensaje' => 'No tienes permisos para modificar el inventario.', 'tipo' => 'error' );
	}

	if ( empty( $datos['nombre'] ) || empty( $datos['email_propietario'] ) ) {
		return array( 'mensaje' => 'Por favor, rellena todos los campos obligatorios.', 'tipo' => 'error' );
	}

	$tabla_recursos = $wpdb->prefix . 'crr_recursos';
	$existe         = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $tabla_recursos WHERE id = %d", $recurso_id ) );
	if ( ! $existe ) {
		return array( 'mensaje' => 'El recurso especificado no existe.', 'tipo' => 'error' );
	}

	$resultado = $wpdb->update(
		$tabla_recursos,
		$datos,
		array( 'id' => $recurso_id ),
		array( '%s', '%s', '%d', '%f', '%s' ),
		array( '%d' )
	);

	if ( false === $resultado ) {
		return array( 'mensaje' => 'Error al actualizar el recurso. ' . esc_html( $wpdb->last_error ), 'tipo' => 'error' );
	}

	return array( 'mensaje' => 'Recurso #' . $recurso_id . ' actualizado correctamente.', 'tipo' => 'success' );
}

function crr_eliminar_recurso( $recurso_id ) {
	global $wpdb;

	if ( ! current_user_can( 'manage_options' ) ) {
		return array( 'mensaje' => 'No tienes permisos para modificar el inventario.', 'tipo' => 'error' );
	}

	$tabla_recursos = $wpdb->prefix . 'crr_recursos';
	$tabla_reservas = $wpdb->prefix . 'crr_reservas';

	$existe = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $tabla_recursos WHERE id = %d", $recurso_id ) );
	if ( ! $existe ) {
		return array( 'mensaje' => 'El recurso especificado no existe.', 'tipo' => 'error' );
	}

	$con_reservas = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $tabla_reservas WHERE recurso_id = %d", $recurso_id ) );
	if ( $con_reservas > 0 ) {
		return array(
			'mensaje' => 'No se puede eliminar el recurso #' . $recurso_id . ': tiene ' . $con_reservas . ' reserva(s) asociada(s). Cancélalas primero.',
			'tipo'    => 'error',
		);
	}

	$borrado = $wpdb->delete( $tabla_recursos, array( 'id' => $recurso_id ), array( '%d' ) );
	if ( $borrado ) {
		return array( 'mensaje' => 'Recurso #' . $recurso_id . ' eliminado del inventario.', 'tipo' => 'warning' );
	}

	return array( 'mensaje' => 'Error al intentar eliminar el recurso.', 'tipo' => 'error' );
}
  1. Permisos dentro de la función: aunque el formulario solo se muestre a administradores, la comprobación está también aquí, por si alguien llama a la función por otra vía.
  2. Formatos en el update(): cinco marcadores (%s %s %d %f %s) para nombre, tipo, stock, precio por hora y email del responsable, en el mismo orden en que el formulario construye el array.
  3. Error legible: si la actualización falla, se muestra $wpdb->last_error escapado, lo que ayuda mucho a depurar.
  4. Borrado protegido: si hay reservas asociadas, el mensaje dice cuántas son y qué hacer (cancelarlas primero).

¿Quién puede cancelar una reserva?

La misma regla que para editar: administrador, usuario autorizado o el autor de la reserva. La cancelación borra la fila, y devuelve un aviso de tipo warning en vez de success, porque es una acción destructiva:

function crr_cancelar_reserva( $reserva_id ) {
	global $wpdb;
	$tabla_reservas = $wpdb->prefix . 'crr_reservas';
	$current_user = wp_get_current_user();

	$reserva = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $tabla_reservas WHERE id = %d", $reserva_id ) );
	if ( ! $reserva ) {
		return array( 'mensaje' => 'La reserva especificada no existe.', 'tipo' => 'error' );
	}

	if ( ! current_user_can( 'manage_options' ) && ! crr_es_usuario_autorizado() && (int) $reserva->user_id !== $current_user->ID ) {
		return array( 'mensaje' => 'No tienes permiso para cancelar esta reserva.', 'tipo' => 'error' );
	}

	$borrado = $wpdb->delete( $reserva_id ? $tabla_reservas : $tabla_reservas, array( 'id' => $reserva_id ), array( '%d' ) );
	if ( $borrado ) {
		return array( 'mensaje' => 'Reserva #' . $reserva_id . ' cancelada con éxito.', 'tipo' => 'warning' );
	}

	return array( 'mensaje' => 'Error al intentar cancelar la reserva.', 'tipo' => 'error' );
}

Fíjate en el patrón que se repite en todo el bloque: cada función comprueba permisos, valida, actúa y devuelve el mismo formato de array. Esa uniformidad es lo que permite que el formulario muestre cualquier resultado con un único bloque de aviso, algo que veremos en la tercera parte.

En la próxima entrega cierro la serie con la exportación a Excel y CSV con filtros, el calendario mensual, el formulario con selector de recursos, los shortcodes y el panel de administración.

Continuará en la parte III.

Control y Reserva de Recursos (CRR v1.12.0): cimientos, lógica de negocio y paneles de Control (cuaderno técnico, parte I)

Valencia, 31/10/2026, G.B.
El plugin Control y Reserva de Recursos (CRR v1.12.0) es una herramienta propia para gestionar salas, equipos y servicios con reservas, presupuesto automático, calendario y paneles de control, sin dependencias externas. Continuando con la serie de cuadernos técnicos, hoy hago un primer recorrido general por el plugin. Mi filosofía es siempre la misma: herramientas 100 % nativas, sin librerías pesadas de terceros y con los datos en mi propia base de datos. En las siguientes partes iré profundizando en cada pieza.

Bloque 1: Cabecera y seguridad del archivo

Lo primero es la cabecera que lee WordPress para mostrar el plugin en el listado:

<?php
/**
 * Plugin Name: Control y Reserva de Recursos (CRR)
 * Plugin URI:  https://indaga.net
 * Description: Sistema completo e independiente para la gestión y reserva de recursos, salas y servicios con presupuestos automáticos, vista de calendario, ficha desplegable de detalles, selector por tipo con buscador, gestión de inventario y panel frontal de filtrado por responsable y exportación avanzada.
 * Version:     1.12.0
 * Author:      Guillermo Beltrán Pilato
 * Author URI:  https://indaga.net
 * Text Domain: crr
 */

WordPress identifica un plugin por su ruta (carpeta y archivo principal), no por su nombre. Por eso mantengo siempre la misma carpeta y el mismo archivo entre versiones: si la ruta cambia, WordPress lo trata como un plugin distinto y no como una actualización. Entre versiones lo único que cambia es Version. El Text Domain crr está declarado, aunque ahora mismo el plugin no usa funciones de traducción.

Para evitar que alguien abra el archivo directamente, aplico la comprobación de siempre: si la constante ABSPATH no existe, el archivo se ha pedido por fuera de WordPress y corto la ejecución. A continuación defino una constante con la versión, que uso para controlar la base de datos:

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Seguridad: Salir si se accede directamente
}

define( 'CRR_VERSION', '1.12.0' );

A diferencia de otros desarrollos, como Agenda Indaga, en el CRR no uso una clase con patrón Singleton. Es un conjunto de funciones con el prefijo crr_, lo que reduce muchísimo el riesgo de choques de nombres con otros plugins y mantiene todo directo. Expliqué mis motivos en este apunte sobre el patrón Singleton.

Bloque 2: Base de datos propia y actualización controlada

El sistema crea dos tablas propias, sin depender de nada externo: crr_recursos (el inventario de salas, equipos y servicios) y crr_reservas (cada reserva, enlazada a un recurso por recurso_id). Se despliegan al activar el plugin:

function crr_instalar() {
	global $wpdb;
	$charset_collate = $wpdb->get_charset_collate();

	$tabla_recursos = $wpdb->prefix . 'crr_recursos';
	$sql_recursos   = "CREATE TABLE $tabla_recursos (
		id bigint(20) NOT NULL AUTO_INCREMENT,
		nombre varchar(255) NOT NULL,
		tipo varchar(100) DEFAULT 'General' NOT NULL,
		stock int(11) DEFAULT 1 NOT NULL,
		precio_hora decimal(10,2) DEFAULT '0.00' NOT NULL,
		email_propietario varchar(100) NOT NULL,
		PRIMARY KEY  (id)
	) $charset_collate;";

	$tabla_reservas = $wpdb->prefix . 'crr_reservas';
	$sql_reservas   = "CREATE TABLE $tabla_reservas (
		id bigint(20) NOT NULL AUTO_INCREMENT,
		recurso_id bigint(20) NOT NULL,
		user_id bigint(20) NOT NULL,
		email_solicitante varchar(100) NOT NULL,
		fecha_inicio date NOT NULL,
		fecha_fin date NOT NULL,
		hora_inicio time NOT NULL,
		hora_fin time NOT NULL,
		cantidad int(11) DEFAULT 1 NOT NULL,
		observaciones text NULL,
		precio_estimado decimal(10,2) DEFAULT '0.00' NOT NULL,
		fecha_creacion datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
		PRIMARY KEY  (id)
	) $charset_collate;";

	require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
	dbDelta( $sql_recursos );
	dbDelta( $sql_reservas );

	update_option( 'crr_db_version', CRR_VERSION );
}
register_activation_hook( __FILE__, 'crr_instalar' );

Un detalle clave: dbDelta es muy estricto y espera exactamente dos espacios entre PRIMARY KEY y el paréntesis. Además, para no depender únicamente del gancho de activación (que no salta cuando actualizo el plugin por FTP), compruebo la versión en cada carga con la acción plugins_loaded:

function crr_comprobar_actualizacion_db() {
	if ( get_option( 'crr_db_version' ) !== CRR_VERSION ) {
		crr_instalar();
	}
}
add_action( 'plugins_loaded', 'crr_comprobar_actualizacion_db' );

Bloque 3: Lógica de reservas, validaciones y presupuesto automático

El punto de entrada del formulario valida el nonce, limpia cada dato de entrada y delega todo el trabajo en crr_procesar_reserva():

if ( isset( $_POST['crr_nueva_reserva'] ) && check_admin_referer( 'crr_reserva_nonce', 'crr_reserva_nonce' ) ) {
	$res_id      = isset( $_POST['reserva_id'] ) ? absint( $_POST['reserva_id'] ) : 0;
	$enviar_pres = isset( $_POST['enviar_presupuesto'] ) ? true : false;
	$alerta      = crr_procesar_reserva(
		absint( $_POST['recurso_id'] ),
		sanitize_email( $_POST['email_solicitante'] ),
		sanitize_text_field( $_POST['fecha_inicio'] ),
		sanitize_text_field( $_POST['fecha_fin'] ),
		sanitize_text_field( $_POST['hora_inicio'] ),
		sanitize_text_field( $_POST['hora_fin'] ),
		absint( $_POST['cantidad'] ),
		sanitize_textarea_field( $_POST['observaciones'] ),
		$res_id,
		$enviar_pres
	);
}

Antes de guardar nada, esa función comprueba la disponibilidad con crr_comprobar_disponibilidad(). Es la pieza que más me importa del plugin:

function crr_comprobar_disponibilidad( $recurso_id, $fecha_inicio, $fecha_fin, $hora_inicio, $hora_fin, $cantidad, $reserva_id_excluir = 0 ) {
	global $wpdb;
	$tabla_reservas = $wpdb->prefix . 'crr_reservas';
	$tabla_recursos = $wpdb->prefix . 'crr_recursos';

	$recurso = $wpdb->get_row( $wpdb->prepare( "SELECT stock FROM $tabla_recursos WHERE id = %d", $recurso_id ) );
	if ( ! $recurso ) {
		return false;
	}

	$sql = "SELECT SUM(cantidad) FROM $tabla_reservas
			WHERE recurso_id = %d
			AND id != %d
			AND (fecha_inicio <= %s AND fecha_fin >= %s)
			AND (hora_inicio < %s AND hora_fin > %s)";

	$ocupacion = $wpdb->get_var( $wpdb->prepare( $sql, $recurso_id, $reserva_id_excluir, $fecha_fin, $fecha_inicio, $hora_fin, $hora_inicio ) );
	$ocupacion_actual = $ocupacion ? intval( $ocupacion ) : 0;

	return ( $ocupacion_actual + $cantidad ) <= intval( $recurso->stock );
}

La consulta suma la cantidad de todas las reservas del mismo recurso que se solapan con la nueva, tanto en fechas como en horas, y comprueba que esa suma más lo solicitado no supere el stock. El parámetro $reserva_id_excluir permite editar una reserva sin que choque consigo misma.

Si hay disponibilidad, se calcula el presupuesto estimado con crr_calcular_precio_estimado(). Este es el extracto con la fórmula:

$dias  = $f_inicio->diff( $f_fin )->days + 1;
$horas = $intervalo->h + ( $intervalo->i / 60 );

if ( $horas <= 0 ) {
	$horas = 1;
}

$total = $dias * $horas * floatval( $recurso->precio_hora ) * absint( $cantidad );
return round( $total, 2 );

Es decir: días × horas diarias × tarifa por hora × cantidad. El resultado se guarda en el campo precio_estimado. Tengo pendiente revisar este cálculo: ahora mismo no valido que la fecha de fin sea posterior a la de inicio ni que la hora de fin sea posterior a la de inicio, y con las horas invertidas DateTime::diff devuelve igualmente un valor positivo, así que el precio saldría incorrecto.

  • Seguridad y sanitización: nonces con check_admin_referer, limpieza de variables con funciones nativas de WordPress (absint, sanitize_email, sanitize_text_field) y consultas preparadas con $wpdb->prepare
  • Alta y edición: la misma función sirve para ambas, según venga o no el ID de la reserva
  • Permisos: solo puede editar o cancelar una reserva quien la creó, un administrador o un usuario autorizado
  • Control de stock: se contrasta lo solicitado con las reservas solapadas y el stock del recurso antes de insertar o actualizar

Bloque 4: Visualización en calendario interactivo y paneles avanzados

Con el shortcode [crr_calendario] cualquier página se convierte en una cuadrícula mensual con las reservas del mes. Al hacer clic en una de ellas se abre una ficha flotante (modal) con los detalles clave, sin recargar la página, porque los datos viajan en un atributo data-reserva que lee JavaScript. El cambio de mes sí recarga la página, ya que navega mediante parámetros por URL (crr_mes y crr_anio).

Por otro lado, el panel frontal de gestión lo ven los administradores y los usuarios autorizados (una lista de emails que configuro en la pestaña «Usuarios Autorizados» del panel de administración). Permite filtrar por fechas, categoría, responsable del recurso y texto libre, con soporte de comodín *.

La exportación admite dos formatos: un CSV estándar y una variante «Excel», que en realidad es también un CSV, pero con codificación UTF-8 con BOM y punto y coma como delimitador, para que Excel muestre bien los acentos. Y la descarga respeta los filtros que tenga aplicados en ese momento.

Para mostrar el calendario en cualquier página basta con insertar el shortcode, sin parámetros:

[crr_calendario]

Y para el formulario de reservas con su panel de gestión, el shortcode es [crr_reservas].

Con esto cerramos este primer recorrido técnico por el CRR v1.12.0. Continuaré…

Configuración SMTP nativa y sin dependencias en WordPress con SMTP Indaga

Valencia, 30/10/2026, G.B.
Hoy quiero hablaros de un nuevo añadido que ya forma parte de casa en Indaga.net: el plugin SMTP Indaga. Si habéis seguido otros desarrollos por aquí, ya sabéis cómo me gusta plantear las cosas: herramientas 100% nativas, sin dependencias externas pesadas y con los datos guardados de forma segura en nuestra propia base de datos.

Gestionar el correo electrónico en WordPress suele ser un quebradero de cabeza en muchos hostings compartidos, donde las funciones por defecto fallan o los mensajes acaban directamente en la carpeta de SPAM. Para solucionar esto y mantener un control absoluto, he desarrollado este plugin que intercepta wp_mail() mediante filtros nativos para enrutar los correos a través de cualquier servidor SMTP de forma limpia y transparente.

¿Por qué desarrollar un plugin SMTP propio?

Podría haber recurrido a opciones masivas del repositorio oficial, pero la experiencia nos dice que suelen venir sobrecargadas de opciones comerciales o llamadas innecesarias. Con SMTP Indaga busco mantener la filosofía de la casa:

  • Cero dependencias externas: Todo se apoya exclusivamente en el motor PHPMailer que WordPress incluye de serie
  • Máximo rendimiento: El plugin solo registra los filtros de envío si realmente está habilitado en los ajustes. Si se desactiva, WordPress opera con total normalidad sin consumo extra
  • Seguridad e independencia: Almacenamiento seguro de credenciales mediante la API de opciones y validación estricta de cada parámetro

¿Cómo funciona por dentro?

A nivel técnico, la arquitectura es muy directa y aprovecha los ganchos nativos que nos ofrece WordPress:

  • El filtro phpmailer_init: Modifica el objeto PHPMailer antes de cada envío para configurar el servidor, el puerto, la seguridad (SSL o TLS), el usuario y la contraseña.
  • Forzado de remitente: Mediante wp_mail_from y wp_mail_from_name aseguramos que el correo visible coincida con la cuenta autenticada, evitando rechazos por políticas anti-spoofing.
  • Flujo seguro PRG (Post/Redirect/Get): El panel de administración protege cada guardado y prueba de envío mediante nonces de seguridad (wp_nonce_field) y control de capacidades (manage_options).

Guía de Uso Rápido

  1. Accede a tu panel de administración de WordPress y busca SMTP Indaga dentro del menú de Ajustes
  2. Introduce los datos facilitados por tu proveedor de correo (servidor host, puerto, usuario y contraseña)
  3. Marca la casilla para habilitar el SMTP personalizado y guarda los cambios.
  4. Utiliza la pestaña de prueba de envío para verificar que la conexión funciona correctamente

Ficha Técnica

  • Nombre del plugin: SMTP Indaga
  • Versión: 1.0.0
  • Autor: Guillermo Beltrán Pilato (Indaga.net)
  • Dependencias: 100% nativo (PHP puro, API de WordPress, PHPMailer integrado)
  • Seguridad: Sanitización estricta de variables, nonces de verificación contra ataques CSRF y control de capacidades manage_options
  • Acceso en administración: Ajustes > SMTP Indaga (options-general.php?page=indaga-smtp)


Descargar de mi cuenta de pCloud

Qué es el patrón Singleton y porqué se usa en el plugin CRR

Valencia, 30/10/2026, G.B.
El patrón Singleton garantiza que una clase tenga una única instancia en toda la aplicación y ofrece un punto de acceso global a ella. A raíz de la preparación del cuaderno técnico del plugin Control y Reserva de Recursos (CRR), que publicaré en breve, cuento que, a diferencia de otros desarrollos anteriores como Agenda Indaga, en el CRR no utilizo una clase con patrón Singleton, sino un conjunto de funciones planas con el prefijo crr_ en un único archivo. Vamos a ver qué es este patrón y por qué decidí prescindir de él en este plugin.

Pero, ¿qué es eso del patrón Singleton?

El patrón Singleton (o patrón de instancia única) es un patrón de diseño de software pensado para que una clase tenga una sola instancia y para dar acceso global a ella.

Normalmente, en PHP puedes crear tantos objetos de una clase como quieras con la palabra clave new. El Singleton restringe esto con tres reglas clave:

  1. Constructor privado (private function __construct): impide que cualquier código externo cree nuevos objetos con new MiClase().
  2. Propiedad estática (private static $instance): almacena dentro de la propia clase la única instancia permitida.
  3. Método de acceso público estático (public static function get_instance()): es el único punto de entrada. Si la instancia no existe, la crea; si ya existe, devuelve la que estaba guardada.
class Mi_Plugin_Singleton {
    private static $instance = null;

    // 1. Constructor privado para evitar instanciación externa
    private function __construct() {
        // Inicialización del plugin (ganchos, acciones, etc.)
    }

    // 2. Método global para obtener la única instancia
    public static function get_instance() {
        if ( self::$instance === null ) {
            self::$instance = new self();
        }
        return self::$instance;
    }
}

// Arranque del plugin
Mi_Plugin_Singleton::get_instance();

Por qué se usa habitualmente en WordPress

  • Organización orientada a objetos: permite agrupar la lógica, las propiedades y los métodos del plugin en una estructura limpia y encapsulada
  • Ayuda a evitar duplicidades: reduce el riesgo de que los ganchos de WordPress (add_action, add_filter) se registren más de una vez si la clase se carga en distintos puntos del ciclo de vida
  • Punto de acceso único: cualquier parte del plugin puede obtener el mismo objeto con get_instance(), sin pasarlo de un sitio a otro

Por qué decidí no usarlo en el CRR

En el plugin Control y Reserva de Recursos (CRR) opté por un enfoque más directo: un conjunto de funciones con el prefijo único crr_ dentro de un único archivo. Así se ve el arranque en el código real del plugin:

function crr_instalar() {
    // Creación de las tablas con dbDelta, versión de la base de datos, etc.
}
register_activation_hook( __FILE__, 'crr_instalar' );

function crr_comprobar_actualizacion_db() {
    if ( get_option( 'crr_db_version' ) !== CRR_VERSION ) {
        crr_instalar();
    }
}
add_action( 'plugins_loaded', 'crr_comprobar_actualizacion_db' );
  • Simplicidad y legibilidad: se trata de un sistema autocontenido, centrado en la gestión de recursos y reservas mediante formularios e inventario. Un único archivo de funciones se lee de arriba abajo, sin saltar entre clases, y es fácil de mantener
  • Colisiones de nombres prácticamente eliminadas: al usar el prefijo crr_ en todas las funciones y constantes, el riesgo de choque con WordPress o con otros plugins activos es mínimo. No es una garantía absoluta, pero en la práctica basta

Los inconvenientes del Singleton

Conviene ser honestos: el Singleton tampoco está libre de críticas, y muchos desarrolladores lo consideran un antipatrón. Introduce estado global, dificulta las pruebas automatizadas y aumenta el acoplamiento entre componentes. Que sea habitual en WordPress no significa que sea siempre la mejor opción.

Cuándo sí y cuándo no

  • Singleton (o clases en general): si el plugin crece, tiene estado compartido o se compone de varios componentes que deben colaborar
  • Funciones planas con prefijo: si el plugin es pequeño, autocontenido y no necesita compartir estado entre piezas

Este post es, en sí, un apunte técnico; la primera parte del cuaderno técnico del CRR llegará en breve.

Singleton explicado en Refactoring Guru

Indaga Native Icons 1.2.0: Control de tamaño nativo y más iconos para nuestra biblioteca SVG

Valencia, 29/10/2026, G.B.
En mi intento hacia un desarrollo web completamente independiente y optimizado, los iconos son una pieza fundamental. Tras el lanzamiento inicial de una biblioteca de iconos vectoriales, echaba en falta una característica muy concreta para el día a día en la maquetación: un control de tamaño más versátil y directo sin necesidad de sobrecargar las hojas de estilo con clases auxiliares para cada medida. Con esa idea nace la versión 1.2.0 de Indaga Native Icons. Manteniendo el compromiso de cero dependencias externas y cero peticiones HTTP adicionales, esta nueva actualización introduce mejoras estructurales importantes tanto en el backend como en el motor de renderizado de los SVG.

Qué hay de nuevo en la versión 1.2.0

  • Control de tamaño nativo por atributo: Ahora el shortcode y la función PHP incorporan el parámetro size. Podemos definir tamaños preestablecidos (sm para 16px, md para 24px, lg para 32px y xl para 48px) o pasar directamente valores numéricos en píxeles (por ejemplo, size="32") para un control absoluto del diseño.
  • Ampliación de la biblioteca de iconos: Se han incorporado nuevos iconos clave para interfaces modernas de administración y frontend, abarcando elementos de estado, acciones de edición, seguridad (candados, visibilidad), utilidades de nube y el logotipo oficial de WordPress en SVG puro.
  • Optimización del panel de administración: La cuadrícula visual de la biblioteca en wp-admin se ha actualizado para reflejar de forma más intuitiva las opciones de copia rápida de shortcodes y las nuevas directrices de uso técnico.

Ejemplos prácticos de uso

La flexibilidad que aporta el nuevo atributo size simplifica la inserción de iconos tanto en el editor de bloques como en las plantillas PHP personalizadas de nuestros temas (siempre entre corchetes):


Y si estamos picando código directamente en un template del theme:

<?php echo indaga_icon( 'close', 'indaga-icon-danger', 'Cerrar ventana', '32' ); ?>

Privacidad, rendimiento y control absoluto

Depender de librerías de iconos externas como Font Awesome implica abrir la puerta a peticiones de red hacia servidores de terceros, scripts pesados y posibles problemas de privacidad o rendimiento en la carga inicial. Con Indaga Native Icons, todo el código SVG vive de forma interna, inyectándose de manera limpia y segura.

Seguimos sumando herramientas propias a Indaga.net y para quien las necesite. Hasta pronto!

Ficha técnica del plugin

  • Nombre del Plugin: Indaga Native Icons
  • Versión: 1.2.0 (Con control de tamaño nativo)
  • Autor: Guillermo Beltrán Pilato (Indaga.net)
  • Descripción: Biblioteca ampliada de iconos SVG nativos con control de tamaño dinámico mediante atributos. Diseñada para reemplazar por completo a Font Awesome y soluciones de terceros en WordPress con rendimiento absoluto y control total.
  • Acceso Backend: Menú lateral Iconos Indaga (wp-admin/admin.php?page=indaga-icons).
  • Alias / Shortcodes: (entre corchetes) indaga_icon name="nombre-icono" size="md" (admite parámetros adicionales como size [sm, md, lg, xl o numérico], class y label).
  • Dependencias: 0 (Cero) – Sin CDNs, sin llamadas a servidores externos, sin librerías pesadas.
  • Seguridad: Sanitización completa mediante sanitize_key(), esc_attr() y esc_html(). Bloqueo estricto de accesos directos (ABSPATH) y validación de permisos de usuario (manage_options).

Descargar plugin

Cuaderno Técnico: Plugin «Agenda Indaga» (v1.3.0), parte 3

Entrega 3: Los Shortcodes de Frontend (Formulario y Calendario Interactivo)

Valencia, 25/10/2026, G.B.
En las entregas anteriores vimos cómo levantar los cimientos del plugin en la base de datos y cómo estructurar el panel de administración en el backend. En este tercer bloque me centro en la parte visible para el usuario: los shortcodes que dan vida a la agenda en el frontend de la web.

Para conseguirlo, divido la funcionalidad en dos shortcodes independientes pero complementarios: indaga_agenda (entre corchetes) para gestionar el alta, edición y listado de citas, y calendario_agenda (entre corchetes) para renderizar el calendario mensual interactivo.

1. El Shortcode de Gestión y Formulario (indaga_agenda) (entre corchetes)

Este shortcode permite desplegar un formulario de alta y edición de eventos, acompañado de una tabla con los registros actuales de la instancia. Todo ello protegido mediante comprobaciones de sesión de usuario, validación de nonces y funciones de saneamiento de datos.

public function renderizar_shortcode_agenda( $atts ) {
    global $wpdb;
    $tabla = $wpdb->prefix . 'indaga_agenda';
    $atts = shortcode_atts( array( 'id' => 'default' ), $atts, 'indaga_agenda' );
    $grupo_id = sanitize_text_field( $atts['id'] );
    $user_id = get_current_user_id();
    $mensaje = '';

    // Procesamiento del formulario de guardado / edición
    if ( isset( $_POST['indaga_agenda_frontend_nonce'] ) && wp_verify_nonce( $_POST['indaga_agenda_frontend_nonce'], 'guardar_evento_frontend' ) ) {
        if ( !is_user_logged_in() ) {
            return '<p style="color:red;">Debes iniciar sesión para gestionar la agenda.</p>';
        }

        $evento_id = isset( $_POST['evento_id'] ) ? absint( $_POST['evento_id'] ) : 0;
        $titulo = sanitize_text_field( $_POST['titulo'] );
        $descripcion = sanitize_textarea_field( $_POST['descripcion'] );
        $fecha_inicio = sanitize_text_field( $_POST['fecha_inicio'] );
        $fecha_fin = sanitize_text_field( $_POST['fecha_fin'] );
        $categoria = sanitize_text_field( $_POST['categoria'] );

        if ( !empty( $titulo ) && !empty( $fecha_inicio ) ) {
            $datos = array(
                'user_id' => $user_id,
                'grupo_id' => $grupo_id,
                'titulo' => $titulo,
                'descripcion' => $descripcion,
                'fecha_inicio' => $fecha_inicio,
                'fecha_fin' => !empty( $fecha_fin ) ? $fecha_fin : $fecha_inicio,
                'categoria' => $categoria
            );

            if ( $evento_id > 0 ) {
                $wpdb->update( $tabla, $datos, array( 'id' => $evento_id ), array( '%d', '%s', '%s', '%s', '%s', '%s', '%s' ), array( '%d' ) );
                $mensaje = '<div style="background:#d4edda; color:#155724; padding:10px; margin-bottom:15px; border-radius:4px;">¡Evento actualizado con éxito!</div>';
            } else {
                $wpdb->insert( $tabla, $datos, array( '%d', '%s', '%s', '%s', '%s', '%s', '%s' ) );
                $mensaje = '<div style="background:#d4edda; color:#155724; padding:10px; margin-bottom:15px; border-radius:4px;">¡Evento creado con éxito!</div>';
            }
        }
    }
    // ... (código de renderizado de la vista y formulario)
}

Puntos clave del formulario:

  • Soporte multinstancia por atributo: A través del parámetro id="nombre_instancia" (por defecto default), delimito los registros que pertenecen a esa agenda concreta.
  • Doble funcionalidad (Alta / Edición): Si el sistema detecta un parámetro editar_id en la URL, carga automáticamente los datos del evento en los inputs para modificarlos sin duplicar registros.

2. El calendario interactivo (calendario_agenda) (entre corchetes)

Para mostrar los eventos en una vista de calendario mensual limpia y visual, transformo los registros de la base de datos a un array de JavaScript y construyo una cuadrícula dinámica.

public function renderizar_shortcode_calendario( $atts ) {
    global $wpdb;
    $tabla = $wpdb->prefix . 'indaga_agenda';
    $atts = shortcode_atts( array( 'id' => 'default' ), $atts, 'calendario_agenda' );
    $grupo_id = sanitize_text_field( $atts['id'] );

    $eventos = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM $tabla WHERE grupo_id = %s ORDER BY fecha_inicio ASC", $grupo_id ) );
    
    $eventos_js = array();
    foreach ( $eventos as $ev ) {
        $eventos_js[] = array(
            'id' => $ev->id,
            'titulo' => $ev->titulo,
            'descripcion' => $ev->descripcion,
            'categoria' => $ev->categoria,
            'fecha_inicio' => $ev->fecha_inicio,
            'fecha_fin' => $ev->fecha_fin
        );
    }
    // ... (retorno del HTML y script de renderizado)
}

Características del motor visual en JavaScript:

  • Navegación fluida por meses: Botones de avance y retroceso (cambiarMes()) que recalculan los días de la semana y sitúan correctamente el primer día del mes.
  • Fichas modales emergentes: Al hacer clic sobre cualquier evento listado dentro de una casilla del calendario, se despliega una ventana flotante con los detalles completos (horarios, categoría y descripción).
  • Conexión directa con el alta: El botón superior «+ Añadir Evento» o hacer clic directamente sobre cualquier día del calendario desplaza la vista automáticamente hacia el formulario y precarga la fecha seleccionada.

Con esta tercera entrega queda completamente documentada la arquitectura del plugin Agenda Indaga (v1.3.0): desde los cimientos en la base de datos, pasando por el panel de administración multinstancia, hasta la interacción visual en el frontend.