Cómo eliminar el aviso de zona horaria de Modern Events Calendar Lite

Valencia, 17/09/2026, G.B.
Aunque lo he publicado en los foros de Puntocomunica, quiero dejar también aquí constancia, para que no se me olvide si volviera a necesitar solucionarlo. Creo en la redundancia en la Red…

Sitio: (no lo indico por privacidad)
Plugin implicado: Modern Events Calendar Lite (Webnus), v7.36.4
Fecha de resolución: septiembre de 2026

El problema

Tras importar eventos en el calendario, aparece este aviso en el escritorio
de WordPress, en cualquier pantalla del área de administración y no solo en
los ajustes del calendario:

It is advisable to utilize a geographic timezone, such as
«America/Los_Angeles» instead of a UTC timezone offset, like «UTC+0,»
while using The Modern Events Calendar. The latter may cause issues when
importing events or with Daylight Saving Time.

Causa raíz

El propio plugin comprueba, en
wp-content/plugins/modern-events-calendar-lite/app/features/events.php
—líneas 208-218—, si la opción interna de WordPress
timezone_string está vacía:

// Timezone Notice
$timezone_string = get_option('timezone_string');

if (trim($timezone_string) === '')
{
    add_action('admin_notices', function ()
    {
        echo '<div class="notice notice-warning is-dismissible">
            <p>' . esc_html__('It is advisable to utilize a geographic timezone...', 'modern-events-calendar-lite') . '</p>
        </div>';
    });
}

timezone_string queda vacía cuando, en
Ajustes → Generales, se utiliza un desplazamiento numérico
—por ejemplo, UTC+2— en lugar de una zona horaria geográfica,
como Europe/Madrid.

Aunque el aviso incluye la clase is-dismissible, no hay ningún
código que recuerde el descarte. Por eso reaparece en cada carga de página
hasta corregir la causa.

Complicación encontrada en este sitio

En Ajustes → Generales, el desplegable de zona horaria solo
ofrece desplazamientos UTC —UTC+0, UTC+1, etc.—,
sin la lista habitual de ciudades y continentes.

Esto indica que la base de datos de zonas horarias de PHP,
timezone_identifiers_list(), no está completa en el servidor.
Se trata de un problema de configuración de PHP o del hosting, no de
WordPress en sí. Por eso no se pudo resolver simplemente seleccionando una
ciudad en el desplegable.

Solución aplicada: editar directamente wp_options mediante phpMyAdmin

Como el desplegable no permitía elegir una zona geográfica, se editaron
directamente las dos opciones implicadas en la tabla
wp_options:

  • timezone_string: antes estaba vacía; se estableció como
    Europe/Madrid.
  • gmt_offset: antes tenía el valor 2, reflejo de
    UTC+2 en horario de verano; se dejó vacío.

Pasos

  1. Entrar en phpMyAdmin, seleccionar la base de datos del sitio y abrir la
    tabla wp_options.
  2. Buscar la fila cuyo option_name sea
    timezone_string, editar option_value y
    establecerlo como Europe/Madrid.
  3. Buscar la fila cuyo option_name sea
    gmt_offset y vaciar su valor.
  4. Guardar los cambios y recargar el escritorio de WordPress.

El aviso desaparece de inmediato porque
trim($timezone_string) === '' deja de cumplirse.

Nota sobre falsas pistas descartadas

Antes de llegar a la causa real, se investigaron dos vías que no aplicaban
a este caso. Se mantienen como referencia por si el aviso reapareciera con
un texto ligeramente distinto:

  • El texto del aviso es casi idéntico al que muestra
    The Events Calendar (Modern Tribe/StellarWP), un plugin de
    calendario distinto con un nombre parecido. Ese plugin no está instalado
    en la web, pero en wp_options quedan opciones residuales
    con el prefijo tec_, como
    tec_ct1_migration_state y
    tec_timed_events_timezone_update_needed. Esto indica que
    estuvo instalado en algún momento y se desinstaló sin limpiar por
    completo la base de datos. Estas opciones no afectan al problema del
    MEC, pero podrían eliminarse en una limpieza futura.
  • Un mirror desactualizado del código de MEC Lite en GitHub, versión
    5.21.2 de 2021, no contenía el texto del aviso. Fue necesario conseguir
    el ZIP real de la versión 7.36.4 instalada para localizar el código
    exacto.

Un plugin propio de FAQs con lógica condicional para WordPress

Hace poco os contaba cómo monté Comandos Linux, un plugin propio con su tabla, su panel y su shortcode. Esta vez le tocaba a algo que llevaba tiempo queriendo hacer: una sección de preguntas frecuentes con lógica condicional, sin depender de un plugin de formularios de terceros

Valencia, 12/09/2026, G.B.
En un proyecto para una intranet usé Fluent Forms para montar unas FAQs con lógica condicional: el usuario elegía un tema y solo veía las preguntas de ese tema. Funciona muy bien, pero es una herramienta pensada para formularios complejos, y para una sección de FAQs en Indaga.net me sobraba casi todo. Así que hice lo de siempre aquí: quedarme solo con la pieza que necesito y montarla yo mismo.

El planteamiento

La «lógica condicional» que quería no es tan sofisticada como la de un constructor de formularios: no necesito árboles de decisión ni preguntas que dependan de respuestas anteriores. Lo que necesito es más simple y, para una FAQ, más que suficiente: el usuario elige una categoría (por ejemplo «WordPress» o «Facturación») y solo se muestran las preguntas de esa categoría. El resto permanece oculto hasta que cambia de categoría.

Con eso claro, el plugin se reduce a tres piezas: una tabla propia en la base de datos, un panel de administración para gestionar las preguntas, y un shortcode que pinta el selector y las preguntas en el front end.

La tabla

Cada pregunta se guarda con seis campos: categoria, pregunta, respuesta (admite HTML básico, para poder meter enlaces o negritas en la respuesta), orden (para decidir en qué posición aparece dentro de su categoría) y activo (para poder desactivar una pregunta sin borrarla). Nada de post types personalizados ni de meta fields sueltos: una tabla con dbDelta() en la activación, como en el resto de plugins que he ido publicando aquí.

El panel de administración

El formulario de alta y edición es sencillo: categoría (con autocompletado a partir de las categorías ya creadas, para no acabar con «Facturación», «facturacion» y «Facturación » conviviendo en la misma tabla), pregunta, respuesta con el editor visual de WordPress, orden y una casilla de activo. El listado de abajo tiene buscador por texto, filtro por categoría y paginación, para cuando la lista crezca y no quepa cómodamente en una sola pantalla.

Aquí me llevé un buen susto durante las pruebas: el formulario, al guardar, se quedaba la página a medio cargar, como congelada. La causa no tenía nada que ver con la base de datos ni con archivos grandes, sino con el orden de ejecución de WordPress: el guardado del formulario y la redirección posterior se ejecutaban dentro de la misma función que pinta el HTML del panel, y para ese punto WordPress ya ha enviado las cabeceras HTTP. Una redirección que llega tarde, simplemente, no llega. La solución fue mover todo el procesamiento del formulario a un enganche admin_init, que se ejecuta antes de que se imprima nada, y que la función de renderizado se limite a pintar. Detalle poco vistoso, pero de esos que conviene tener en la cabeza si escribís vuestros propios paneles de administración.

La lógica condicional, en el front end

Aquí está el motivo del plugin. Al insertar el shortcode, el plugin agrupa las preguntas por categoría y, si hay más de una, añade automáticamente unos botones de selección arriba del todo, empezando por un botón «Todas». Al pulsar sobre una categoría, un poco de JavaScript sin dependencias oculta el resto de preguntas y muestra solo las de esa categoría, sin recargar la página. Si solo existe una categoría, o si el shortcode se usa fijando una en concreto, el selector directamente no aparece: no tiene sentido mostrar un filtro con una sola opción.

A eso se suma un buscador de texto libre y una paginación, ambos también resueltos en el cliente: no hace falta ir a la base de datos cada vez que alguien escribe una letra o cambia de página, porque una sección de FAQs no suele tener miles de registros. Las preguntas se despliegan en acordeón al hacer clic, así que la página no se llena de texto de golpe.

El shortcode

Para insertarlo en cualquier entrada o página basta con:

indaga_faqs (entre corchetes, [ ])

Y admite varios parámetros para adaptarlo a cada sitio:

indaga_faqs categoria=»Facturación»

Muestra solo esa categoría, sin selector. Útil si queréis una página de FAQs dedicada a un único tema.

indaga_faqs mostrar_selector=»no»

Lista todas las categorías seguidas, sin botones de filtro.

indaga_faqs mostrar_buscador=»no»

Oculta la barra de búsqueda superior.

indaga_faqs por_pagina=»5″

Cambia cuántas preguntas se muestran por página (10 por defecto).

Por qué así

La misma filosofía de siempre: identificar qué parte de una herramienta de terceros es la que de verdad necesito, y quedarme solo con eso, escrito a medida, ligero y sin dependencias que arrastrar en cada actualización. Para una intranet con formularios complejos, Fluent Forms tiene todo el sentido. Para una sección de FAQs en un blog, con esto sobra.

Descargar plugin de mi cuenta de pCloud

Míralo en acción: Indaga FAQs

Plugin para importar CSV o Excel a cualquier tabla de WordPress

Un plugin propio para convertir un CSV o un Excel en filas listas para cualquier tabla de la base de datos, con dos modos: inserción directa o script SQL descargable

Valencia, 10/09/2026, G.B.
Cada vez que necesito poblar una tabla propia —la de comandos Linux, la de frases aleatorias, la de FAQs— acababa haciendo lo mismo: montar el INSERT INTO línea a línea a partir de un listado o insertarlo manualmente en el panel de administración del plugin. Pero si necesito crear muchos registros a la vez, la cosa se hacía algo pesada. Así que he preparado un plugin genérico para no repetir ese trabajo: Importador CSV/Excel a Tabla, que sube un archivo y lo convierte en inserciones para la tabla que le indiques.

Qué hace

El plugin añade una página en Herramientas → Importador CSV/Excel. Desde ahí se sube un archivo .csv o .xlsx y se indican tres cosas:

  • El nombre real de la tabla destino, con su prefijo (por ejemplo, wp_fa_frases)
  • Las columnas destino, separadas por comas y en el mismo orden que las columnas del archivo (sin incluir id si es autoincremental)
  • Si la primera fila es cabecera, para que se ignore

Y después hay que elegir el modo:

  • Insertar directamente en la base de datos, fila a fila, con un resumen de cuántas se han insertado, cuántas han fallado y cuántas se han descartado por no tener suficientes columnas
  • Generar un script SQL descargable, con el INSERT INTO completo, para revisarlo y ejecutarlo yo mismo en phpMyAdmin en vez de dejar que el plugin toque la tabla directamente

Cómo lee los archivos

Con el CSV no hay mucho misterio: se parsea con fgetcsv(), detectando automáticamente si el delimitador es coma, punto y coma o tabulador, y respetando el BOM UTF-8 que añaden Excel o LibreCalc al exportar.

El XLSX es más delicado porque, en el fondo, es un ZIP con varios XML dentro. En vez de tirar de una librería de terceros como PhpSpreadsheet, lo abro con ZipArchive y leo directamente sharedStrings.xml (donde Excel guarda los textos) y sheet1.xml (donde guarda la estructura de celdas) con SimpleXML. Las dos son extensiones nativas de PHP, así que sigo sin añadir ninguna dependencia externa al sitio.

Esto tiene límites que asumo: solo lee la primera hoja del libro, no soporta celdas combinadas ni fórmulas, y las fechas se leen como el valor crudo de la celda, sin formatear. Para el uso que le voy a dar —listados sencillos de una sola hoja— es más que suficiente.

Por qué así

El plugin sigue la misma arquitectura que el resto de los que tengo en marcha: procesamiento del formulario enganchado a admin_init en lugar de dentro del callback del menú (para evitar el típico problema de cabeceras ya enviadas), patrón POST/Redirect/GET para no reenviar el formulario al recargar, y comprobación de nonce y de capacidad manage_options antes de tocar nada. debo comprobar si eso ralentiza el «pintado» del escritorio de WordPress.

La idea de fondo es la misma de siempre: cubrir una necesidad muy concreta —pasar una hoja de cálculo a SQL— con el menor peso posible y sin depender de plugins de terceros que no controlo.

Qué me falta por probar

Lo acabo de instalar y todavía no lo he puesto a prueba con datos reales: antes tengo que revisar la estructura de las tablas con las que voy a trabajar para asegurarme de que las columnas coinciden. Si funciona bien, es probable que añada una vista previa de las primeras filas del archivo antes de confirmar la importación, para pillar errores de columnas antes de que lleguen a la base de datos.

Descargar plugin de mi cuenta de pCloud

Recursos Plugin v2.0: unificando dos plugins en uno

plugin recursos

Valencia, 29/08/2026, G.B.
Uno de esos proyectos que llevaba tiempo rondándome era simplificar el mantenimiento de dos plugins de WordPress que, con el tiempo, habían acabado haciendo cosas muy parecidas. La solución: fusionarlos en uno solo, Recursos Plugin v2.0, y de paso aprovechar para resolver algunos problemas técnicos que arrastraba desde hacía tiempo.

Por qué unificar

Mantener dos plugins con funcionalidades solapadas significa duplicar esfuerzo cada vez que hay que tocar algo: dos bases de código que revisar, dos sitios donde aplicar el mismo parche, dos superficies de error. Unificarlos en un único plugin reduce esa carga de mantenimiento y deja un código más coherente y fácil de razonar.

Como en el resto de mis plugins, la premisa se mantiene: cero dependencias externas. Todo el código es PHP nativo, sin librerías de terceros que puedan quedar desactualizadas o introducir vulnerabilidades.

El reto técnico: búsqueda vía GET

La búsqueda de recursos funciona con parámetros en la URL (GET) en lugar de POST. Esto tiene una ventaja clara para el usuario: los resultados de búsqueda son enlazables y compartibles. Puedes copiar la URL después de buscar y enviarla directamente a alguien, o guardarla en marcadores, y al abrirla se reproduce la misma búsqueda.

Para los formularios que sí requieren envío de datos, apliqué el patrón POST/Redirect/GET: el formulario se envía por POST, el servidor procesa la petición y responde con una redirección a una URL GET. Esto evita el clásico problema de que, al recargar la página después de enviar un formulario, el navegador pregunte si quieres «reenviar los datos» —o peor, los reenvíe sin avisar duplicando la acción.

El arreglo de compatibilidad con MySQL

Este plugin hereda el mismo ajuste que ya había aplicado en mi plugin de Autores: cambiar la longitud de las claves de índice a VARCHAR(191).

¿Por qué 191 y no 255? Según lo que he consultado 191 es un límite práctico impuesto por MySQL 5.7 cuando se usa el juego de caracteres utf8mb4 (necesario para soportar emojis y caracteres Unicode completos). Con utf8mb4, cada carácter puede ocupar hasta 4 bytes, y el límite de InnoDB para un índice es de 767 bytes en total. Eso deja un máximo de 191 caracteres para columnas indexadas antes de que MySQL lance un error.

Es uno de esos detalles que no se nota hasta que un día, sin previo aviso, la creación de una tabla falla con un mensaje críptico. Ya solucionado, y de forma consistente en todos mis plugins.

Instrucciones rápidas (desde el panel de control del plugin)

  1. Rellena el formulario de abajo y pulsa «Añadir Recurso» para crear uno nuevo.
  2. Pulsa «Editar» en la tabla para modificar un recurso existente.
  3. Pulsa «Eliminar» para borrarlo (se pedirá confirmación).
  4. Para mostrar los recursos en una página o entrada, usa el shortcode mostrar_recursos (entre corchetes [ ])
    (con buscador y paginación).
  5. Para mostrar solo los 4 recursos más recientes, usa el shortcode ultimos_recursos (entre corchetes [ ])

    Ejemplo con el shortcode para mostrar los últimos recursos añadidos:

    Mega
    Servicio con sede en Nueva Zelanda que destaca por su cifrado de extremo a extremo por defecto (zero-knowledge): ni siquiera Mega puede acceder al contenido de tus archivos. Ofrece 20 GB gratuitos de entrada.
    Dropbox
    Uno de los pioneros del sector, conocido por su sincronización robusta y fiable entre dispositivos. Ofrece funciones interesantes de recuperación de versiones anteriores de archivos y buena integración con apps de terceros.
    OneDrive
    Servicio de Microsoft integrado de forma nativa en Windows y en la suite Office 365. Cómodo si ya trabajas en el ecosistema Microsoft, con edición colaborativa directa en Word, Excel y PowerPoint online.
    pCloud
    Almacenamiento en la nube con sede en Suiza, con opción de pago único de por vida (en lugar de suscripción mensual) y cifrado adicional opcional (pCloud Crypto). Buena alternativa para quien busca privacidad reforzada.

Resultado

Después de un tiempo probándolo en producción, el plugin unificado funciona correctamente. Un solo punto de mantenimiento, búsquedas enlazables, sin reenvíos de formulario molestos, y compatibilidad garantizada con instalaciones MySQL 5.7. Os dejo el enlace de descarga del plugin, por si álguien quiere probarlo:

Descargar plugin (en formato .zip) de mi cuenta de pCloud

Plugin de WordPress para frases aleatorias

Valencia, 29/08/2026, G.B.
Tal y comenté recientemente en este post, aquí va el plugin de WordPress para frases aleatorias. Os paso también el archivo.php.

Recordad que para subir un plugin a WordPress (que no esté en el repositorio, como es el caso), hay que crear una carpeta con el mismo nombre del archivo .php, comprimirlo en .zip y subirlo por FTP o S FTP (mejor esto último) al apartado del escritorio de WordPress «Subir plugin».

Aquí va e código de la primera versión del plugin. Si no consigues verlo correcatente en este post, descarga el archivo frases-aleatorias.zip de mi cuenta de pCloud e instálalo en WordPRess – > Plugins.

<?php
/**
* Plugin Name: Frases Aleatorias Widget
* Description: Widget, shortcode

y panel de administración para gestionar frases (añadir, editar, eliminar). Cada frase incluye enlace de traducción automática al castellano. Sin dependencias externas.
* Version: 2.0
* Author: Puntocomunica.com
*/

if ( ! defined( ‘ABSPATH’ ) ) {
exit; // Salida directa no permitida.
}

define( ‘FA_TABLA’, ‘fa_frases’ ); // se le añadirá el prefijo de WP al usarla

/* ==========================================================
* INSTALACIÓN: crea la tabla y la rellena con las 50 frases
* la primera vez que se activa el plugin.
* ========================================================== */
function fa_activar_plugin() {
global $wpdb;
$tabla = $wpdb->prefix . FA_TABLA;
$charset_collate = $wpdb->get_charset_collate();

$sql = «CREATE TABLE $tabla (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
texto TEXT NOT NULL,
autor VARCHAR(191) NOT NULL,
PRIMARY KEY (id)
) $charset_collate;»;

require_once ABSPATH . ‘wp-admin/includes/upgrade.php’;
dbDelta( $sql );

// Solo se siembran datos si la tabla está vacía (primera instalación)
$total = $wpdb->get_var( «SELECT COUNT(*) FROM $tabla» );
if ( (int) $total === 0 ) {
$frases_iniciales = array(
array( «He who blames others has a long way to go on his journey. He who blames himself is halfway there. He who blames no one has arrived.», «Chinese Proverb» ),
array( «The only way to do great work is to love what you do.», «Steve Jobs» ),
array( «In the middle of difficulty lies opportunity.», «Albert Einstein» ),
array( «It does not matter how slowly you go as long as you do not stop.», «Confucius» ),
array( «Fall seven times, stand up eight.», «Japanese Proverb» ),
array( «The journey of a thousand miles begins with a single step.», «Lao Tzu» ),
array( «Whether you think you can or you think you can’t, you’re right.», «Henry Ford» ),
array( «The best way to predict the future is to create it.», «Abraham Lincoln» ),
array( «Success is not final, failure is not fatal: it is the courage to continue that counts.», «Winston Churchill» ),
array( «Believe you can and you’re halfway there.», «Theodore Roosevelt» ),
array( «Do not go where the path may lead, go instead where there is no path and leave a trail.», «Ralph Waldo Emerson» ),
array( «The mind is everything. What you think you become.», «Buddha» ),
array( «Water which is too pure has no fish.», «Chinese Proverb» ),
array( «A smooth sea never made a skilled sailor.», «African Proverb» ),
array( «He that can have patience can have what he will.», «Benjamin Franklin» ),
array( «You miss 100% of the shots you don’t take.», «Wayne Gretzky» ),
array( «Life is what happens when you’re busy making other plans.», «John Lennon» ),
array( «The only impossible journey is the one you never begin.», «Tony Robbins» ),
array( «Not all those who wander are lost.», «J.R.R. Tolkien» ),
array( «It always seems impossible until it’s done.», «Nelson Mandela» ),
array( «Do what you can, with what you have, where you are.», «Theodore Roosevelt» ),
array( «If you want to go fast, go alone. If you want to go far, go together.», «African Proverb» ),
array( «The best time to plant a tree was 20 years ago. The second best time is now.», «Chinese Proverb» ),
array( «What lies behind us and what lies before us are tiny matters compared to what lies within us.», «Ralph Waldo Emerson» ),
array( «Turn your wounds into wisdom.», «Oprah Winfrey» ),
array( «Knowing yourself is the beginning of all wisdom.», «Aristotle» ),
array( «The unexamined life is not worth living.», «Socrates» ),
array( «He who has a why to live can bear almost any how.», «Friedrich Nietzsche» ),
array( «Out of difficulties grow miracles.», «Jean de La Bruyère» ),
array( «A journey of a thousand miles must begin with a single step.», «Lao Tzu» ),
array( «Even a small star shines in the darkness.», «Finnish Proverb» ),
array( «The bamboo that bends is stronger than the oak that resists.», «Japanese Proverb» ),
array( «Better to light a candle than curse the darkness.», «Chinese Proverb» ),
array( «When the winds of change blow, some build walls, others build windmills.», «Chinese Proverb» ),
array( «Fall down seven times, get up eight.», «Japanese Proverb» ),
array( «One who plants a tree knows they will not sit in its shade.», «Indian Proverb» ),
array( «The nail that sticks out gets hammered down.», «Japanese Proverb» ),
array( «A single arrow is easily broken, but not ten in a bundle.», «Japanese Proverb» ),
array( «He who has never failed has never tried anything new.», «African Proverb» ),
array( «Even the greatest of trees was once a nut who held its ground.», «American Proverb» ),
array( «The river that forgets its source will dry up.», «African Proverb» ),
array( «A bird does not sing because it has an answer, it sings because it has a song.», «Chinese Proverb» ),
array( «Patience is bitter, but its fruit is sweet.», «Aristotle» ),
array( «He who is not courageous enough to take risks will accomplish nothing in life.», «Muhammad Ali» ),
array( «The best preparation for tomorrow is doing your best today.», «H. Jackson Brown Jr.» ),
array( «Success usually comes to those who are too busy to be looking for it.», «Henry David Thoreau» ),
array( «Do not wait for the perfect moment, take the moment and make it perfect.», «Unknown» ),
array( «Great things are done by a series of small things brought together.», «Vincent van Gogh» ),
array( «Perseverance is not a long race; it is many short races one after the other.», «Walter Elliot» ),
array( «You cannot cross the sea merely by standing and staring at the water.», «Rabindranath Tagore» ),
);

foreach ( $frases_iniciales as $f ) {
$wpdb->insert( $tabla, array( ‘texto’ => $f[0], ‘autor’ => $f[1] ) );
}
}
}
register_activation_hook( __FILE__, ‘fa_activar_plugin’ );

/* ==========================================================
* FUNCIONES DE ACCESO A DATOS
* ========================================================== */
function fa_obtener_frases() {
global $wpdb;
$tabla = $wpdb->prefix . FA_TABLA;
return $wpdb->get_results( «SELECT id, texto, autor FROM $tabla ORDER BY id ASC» );
}

function fa_obtener_frases_json() {
$frases = fa_obtener_frases();
$salida = array();
foreach ( $frases as $f ) {
$salida[] = array( ‘texto’ => $f->texto, ‘autor’ => $f->autor );
}
return wp_json_encode( $salida );
}

/* ==========================================================
* RENDERIZADO EN PORTADA (widget y shortcode)
* Incluye enlace de traducción automática al castellano
* (Google Translate), generado por JS según la frase mostrada.
* ========================================================== */
function fa_render_markup_y_script( $instancia_id ) {
ob_start();
?>
<div id=»fa-widget-<?php echo esc_attr( $instancia_id ); ?>» class=»fa-widget» style=»font-family: Georgia, serif; padding: 16px; border-left: 4px solid #444; background: #f9f9f9;»>
<p class=»fa-texto» style=»font-style: italic; font-size: 16px; line-height: 1.5; margin: 0 0 8px 0;»></p>
<p class=»fa-autor» style=»text-align: right; font-weight: bold; margin: 0 0 8px 0;»></p>
<p style=»text-align: right; margin: 0 0 12px 0;»>
<a class=»fa-traducir» href=»#» target=»_blank» rel=»noopener noreferrer» style=»font-size: 13px; text-decoration: underline;»>Traducir al castellano</a>
</p>
<button class=»fa-btn» type=»button» style=»cursor: pointer; padding: 6px 14px; border: none; border-radius: 4px; background: #444; color: #fff; font-size: 14px;»>Otra frase</button>
</div>
<script>
(function() {
var frases = <?php echo fa_obtener_frases_json(); ?>;
var contenedor = document.getElementById(‘fa-widget-<?php echo esc_js( $instancia_id ); ?>’);
if (!contenedor || !frases.length) return;
var elTexto = contenedor.querySelector(‘.fa-texto’);
var elAutor = contenedor.querySelector(‘.fa-autor’);
var elTraducir = contenedor.querySelector(‘.fa-traducir’);
var elBtn = contenedor.querySelector(‘.fa-btn’);

function mostrarFraseAleatoria() {
var indice = Math.floor(Math.random() * frases.length);
var frase = frases[indice];
elTexto.textContent = ‘\u201C’ + frase.texto + ‘\u201D’;
elAutor.textContent = ‘\u2014 ‘ + frase.autor;
elTraducir.href = ‘https://translate.google.com/?sl=en&tl=es&text=’ + encodeURIComponent(frase.texto) + ‘&op=translate’;
}

elBtn.addEventListener(‘click’, mostrarFraseAleatoria);
mostrarFraseAleatoria();
})();
</script>
<?php
return ob_get_clean();
}

function fa_shortcode_frases_aleatorias() {
static $contador = 0;
$contador++;
return fa_render_markup_y_script( ‘sc-‘ . $contador );
}
add_shortcode( ‘frases_aleatorias’, ‘fa_shortcode_frases_aleatorias’ );

class FA_Widget_Frases extends WP_Widget {
public function __construct() {
parent::__construct(
‘fa_widget_frases’,
__( ‘Frase Aleatoria’, ‘frases-aleatorias’ ),
array( ‘description’ => __( ‘Muestra una frase célebre al azar, con enlace de traducción y botón para cambiarla.’, ‘frases-aleatorias’ ) )
);
}

public function widget( $args, $instance ) {
echo $args[‘before_widget’];
if ( ! empty( $instance[‘titulo’] ) ) {
echo $args[‘before_title’] . apply_filters( ‘widget_title’, $instance[‘titulo’] ) . $args[‘after_title’];
}
echo fa_render_markup_y_script( ‘widget-‘ . $this->id );
echo $args[‘after_widget’];
}

public function form( $instance ) {
$titulo = ! empty( $instance[‘titulo’] ) ? $instance[‘titulo’] : __( ‘Frase del día’, ‘frases-aleatorias’ );
?>
<p>
<label for=»<?php echo esc_attr( $this->get_field_id( ‘titulo’ ) ); ?>»><?php _e( ‘Título:’, ‘frases-aleatorias’ ); ?></label>
<input class=»widefat» id=»<?php echo esc_attr( $this->get_field_id( ‘titulo’ ) ); ?>» name=»<?php echo esc_attr( $this->get_field_name( ‘titulo’ ) ); ?>» type=»text» value=»<?php echo esc_attr( $titulo ); ?>»>
</p>
<?php
}

public function update( $new_instance, $old_instance ) {
$instance = array();
$instance[‘titulo’] = ( ! empty( $new_instance[‘titulo’] ) ) ? sanitize_text_field( $new_instance[‘titulo’] ) : »;
return $instance;
}
}
add_action( ‘widgets_init’, function () {
register_widget( ‘FA_Widget_Frases’ );
} );

/* ==========================================================
* PANEL DE ADMINISTRACIÓN: listar, añadir, editar, eliminar
* ========================================================== */
add_action( ‘admin_menu’, function () {
add_menu_page(
‘Frases Aleatorias’,
‘Frases Aleatorias’,
‘manage_options’,
‘fa-frases’,
‘fa_pagina_admin’,
‘dashicons-format-quote’,
58
);
} );

function fa_pagina_admin() {
if ( ! current_user_can( ‘manage_options’ ) ) {
return;
}

global $wpdb;
$tabla = $wpdb->prefix . FA_TABLA;

/* ———- Procesar acciones enviadas por formulario ———- */
if ( isset( $_POST[‘fa_accion’] ) && check_admin_referer( ‘fa_guardar_frase’, ‘fa_nonce’ ) ) {
$texto = isset( $_POST[‘fa_texto’] ) ? sanitize_textarea_field( wp_unslash( $_POST[‘fa_texto’] ) ) : »;
$autor = isset( $_POST[‘fa_autor’] ) ? sanitize_text_field( wp_unslash( $_POST[‘fa_autor’] ) ) : »;

if ( $texto !== » && $autor !== » ) {
if ( $_POST[‘fa_accion’] === ‘crear’ ) {
$wpdb->insert( $tabla, array( ‘texto’ => $texto, ‘autor’ => $autor ) );
echo ‘<div class=»notice notice-success is-dismissible»><p>Frase añadida.</p></div>’;
} elseif ( $_POST[‘fa_accion’] === ‘actualizar’ && ! empty( $_POST[‘fa_id’] ) ) {
$wpdb->update( $tabla, array( ‘texto’ => $texto, ‘autor’ => $autor ), array( ‘id’ => (int) $_POST[‘fa_id’] ) );
echo ‘<div class=»notice notice-success is-dismissible»><p>Frase actualizada.</p></div>’;
}
} else {
echo ‘<div class=»notice notice-error is-dismissible»><p>El texto y el autor no pueden estar vacíos.</p></div>’;
}
}

if ( isset( $_GET[‘fa_eliminar’] ) && check_admin_referer( ‘fa_eliminar_frase’ ) ) {
$wpdb->delete( $tabla, array( ‘id’ => (int) $_GET[‘fa_eliminar’] ) );
echo ‘<div class=»notice notice-success is-dismissible»><p>Frase eliminada.</p></div>’;
}

/* ———- ¿Estamos editando una frase? ———- */
$editando = null;
if ( isset( $_GET[‘fa_editar’] ) ) {
$editando = $wpdb->get_row( $wpdb->prepare( «SELECT * FROM $tabla WHERE id = %d», (int) $_GET[‘fa_editar’] ) );
}

$frases = fa_obtener_frases();
?>
<div class=»wrap»>
<h1>Frases Aleatorias</h1>
<p>Shortcode: <code>

</code> · También disponible como widget en Apariencia → Widgets.</p>

<h2><?php echo $editando ? ‘Editar frase’ : ‘Añadir nueva frase’; ?></h2>
<form method=»post» style=»max-width: 600px;»>
<?php wp_nonce_field( ‘fa_guardar_frase’, ‘fa_nonce’ ); ?>
<input type=»hidden» name=»fa_accion» value=»<?php echo $editando ? ‘actualizar’ : ‘crear’; ?>»>
<?php if ( $editando ) : ?>
<input type=»hidden» name=»fa_id» value=»<?php echo esc_attr( $editando->id ); ?>»>
<?php endif; ?>

<table class=»form-table»>
<tr>
<th><label for=»fa_texto»>Texto (inglés)</label></th>
<td><textarea name=»fa_texto» id=»fa_texto» rows=»3″ class=»large-text» required><?php echo $editando ? esc_textarea( $editando->texto ) : »; ?></textarea></td>
</tr>
<tr>
<th><label for=»fa_autor»>Autor</label></th>
<td><input type=»text» name=»fa_autor» id=»fa_autor» class=»regular-text» required value=»<?php echo $editando ? esc_attr( $editando->autor ) : »; ?>»></td>
</tr>
</table>

<?php submit_button( $editando ? ‘Actualizar frase’ : ‘Añadir frase’ ); ?>
<?php if ( $editando ) : ?>
<a href=»<?php echo esc_url( admin_url( ‘admin.php?page=fa-frases’ ) ); ?>» class=»button»>Cancelar edición</a>
<?php endif; ?>
</form>

<h2>Frases existentes (<?php echo count( $frases ); ?>)</h2>
<table class=»widefat striped»>
<thead>
<tr>
<th style=»width: 55%;»>Texto</th>
<th>Autor</th>
<th style=»width: 160px;»>Acciones</th>
</tr>
</thead>
<tbody>
<?php foreach ( $frases as $f ) : ?>
<tr>
<td><?php echo esc_html( $f->texto ); ?></td>
<td><?php echo esc_html( $f->autor ); ?></td>
<td>
<a href=»<?php echo esc_url( admin_url( ‘admin.php?page=fa-frases&fa_editar=’ . $f->id ) ); ?>»>Editar</a>
|
<a href=»<?php echo esc_url( wp_nonce_url( admin_url( ‘admin.php?page=fa-frases&fa_eliminar=’ . $f->id ), ‘fa_eliminar_frase’ ) ); ?>»
onclick=»return confirm(‘¿Eliminar esta frase?’);» style=»color: #b32d2e;»>Eliminar</a>
|
<a href=»https://translate.google.com/?sl=en&tl=es&text=<?php echo rawurlencode( $f->texto ); ?>&op=translate» target=»_blank» rel=»noopener noreferrer»>Traducir</a>
</td>
</tr>
<?php endforeach; ?>
<?php if ( empty( $frases ) ) : ?>
<tr><td colspan=»3″>Todavía no hay frases.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php
}

Plugin Autores revisado y actualizado (versión 3.9)

autores-plugin

Valencia, 18/08/2026, G.B.
El pequeño plugin que hice para insertar publicaciones, Autores, me fallaba en Puntocomunica.com y en este sitio Indaga.net. Tenía que crear la tabla «a mano» desde phpMyAdmin.

En la web para la que hice este plugin funcionaba perfectamente. La diferencia es que esa web tiene la última versión de mariaDB y las otras dos, las mías, una versión más antigua de MySQL. Además, la seguridad no estaba del todo «afinada«.  Ahora creo que sí. Para mostrarlo hay que insertar el shortcode (entre corchetes[ ], como todos los shortcodes): autores_form

Una muestra:

Añadir nueva publicación

No tiene permiso para agregar autores.

Publicaciones listadas

Terry Pratchett
A Stroke of the Pen. The lost Stories
Libros - 2023
Manuel Vicent
Verás el cielo abierto
Libros - 2005
Rosa Montero
Historia del rey transparente
Libros - 2005
Terry Pratchett
Pyramids
Libros - 1992
Terry Pratchett
Equal Rites
Libros - 1987

Este es el enlace de descarga de mi cuenta pCloud: descargar autores-plugin.zip

Eliminar hipervínculos de títulos en WordPress: Cómo crear un plugin sencillo

eliminar_enlaces_en_titulo

Valencia, 17/08/2026, G.B.
Los títulos con enlaces en WordPress pueden ser un problema cuando necesitas mantener la consistencia visual o evitar navegación innecesaria. Si administras un directorio o catálogo de productos y tus títulos contienen HTML con hipervínculos, te voy a mostrar cómo crear un plugin personalizado para eliminarlos automáticamente.

El problema

Durante un tiempo, estuve poniendo el enlace en el título a las entradas de la parte del directorio de Indaga.net. No sé, en ese momento me pareció una buena idea… Con el tiempo comprobé que era un error, por lo que he decidido quitar el código HTML en el título de las entradas afectadas., las cuales, además, deben tener una etiqueta (tag) específica: en mi caso «directorio».

Un ejemplo de título de una entrada con el enlace incluido, el que quiero borrar, para que veas a qué me refiero:

<a href="https://www.ejemplo.com" target="_blank" rel="nofollow">Título de la entrada</a>

Esto no solo afecta la apariencia, sino que también puede generar problemas de SEO y navegación indeseada. ¿La solución? Un plugin personalizado que elimine estos enlaces automáticamente.

La expresión regular

La clave está en esta expresión regular: /<a[^>]*>(.*?)<\/a>/i

Desglosándola:

  • <a[^>]*> – Captura la etiqueta de apertura con cualquier atributo
  • (.*?) – Extrae el texto dentro (no greedy)
  • <\/a> – Captura el cierre de la etiqueta
  • i – Ignora mayúsculas/minúsculas

Crear el plugin en 3 pasos

Paso 1: Crear el archivo del plugin

Accede a tu servidor mediante FTP,  SFTP o el administrador de archivos de tu hosting, y crea un nuevo archivo:

/wp-content/plugins/eliminar_enlaces_en_titulo.php

Más rápido:
También puedes crear una carpeta con el mismo nombre del archivo .php e incluirlo ahí. Luego la comprimes como .zip y navegas en tu escritorio de WordPress a  Plugins: Añadir plugin. Subes el archivo .zip, lo instalas y lo activas.

Paso 2: Agregar el código

Copia y pega el siguiente código:

<?php
/**
 * Plugin Name: Eliminar Enlaces en Título
 * Description: Elimina hipervínculos del título solo en entradas con tag "directorio"
 * Version: 1.0
 * Author: Guillermo Beltrán Pilato
 * Text Domain: eliminar-enlaces-directorio
 */

function eliminar_enlaces_de_titulos_directorio($titulo, $id = 0) {
    // Solo aplicar si el post tiene el tag "directorio"
    if ($id && has_tag('directorio', $id)) {
        return preg_replace('/<a[^>]*>(.*?)<\/a>/i', '$1', $titulo);
    }
    return $titulo;
}

add_filter('the_title', 'eliminar_enlaces_de_titulos_directorio', 10, 2);
?>

Paso 3: Activar el plugin

  1. Ve a Plugins → Plugins instalados en tu panel de WordPress
  2. Busca «Eliminar Enlaces en Tag Directorio»
  3. Haz clic en Activar

¿Cómo funciona?

El plugin actúa como un filtro invisible que:

  • Detecta todas las entradas con el tag «directorio»
  • Elimina el código HTML de los enlaces en los títulos
  • Conserva el texto original sin el hipervínculo
  • No modifica la base de datos (solo afecta la visualización)

Ejemplo:

AntesDespués
<a href=»…»>Traductor online Deepl</a>Traductor online Deepl

Personalización

Usar un tag diferente

Si tu tag no se llama «directorio», simplemente cambia el nombre en la función:

if ($id && has_tag('mi-tag', $id)) {

Aplicar a múltiples tags

if ($id && (has_tag('directorio', $id) || has_tag('catalogo', $id))) {

Filtrar por tipo de entrada

Si solo quieres que funcione en páginas en lugar de entradas:

if ($id && has_tag('directorio', $id) && get_post_type($id) === 'page') {

Ventajas de este enfoque

  • Automático: No necesitas hacer nada manualmente
  • Seguro: No modifica la base de datos
  • Flexible: Solo afecta las entradas que especifiques
  • Reversible: Desactiva el plugin y vuelve al estado anterior
  • Lightweight: Código mínimo, sin impacto en rendimiento

Actualizar títulos existentes en la base de datos

Si prefieres que los cambios se guarden permanentemente en la base de datos, puedes usar esta consulta SQL (con copia de seguridad previa):

UPDATE wp_posts 
SET post_title = REGEXP_REPLACE(post_title, '<a[^>]*>(.*?)</a>', '$1')
WHERE post_type = 'post' AND post_title LIKE '%<a%';

Conclusión

Con este plugin personalizado, tienes control total sobre cómo se muestran los títulos en tu sitio. Es especialmente útil para directorios, catálogos y sitios donde importas contenido regularmente. Fácil de instalar, sin configuración necesaria, y totalmente gratuito.

Bonus Track

Enlaces a páginas para aprender sobre expresiones regulares:

RegexOne: Tutorial interactivo donde practicas directamente en el navegador con ejercicios progresivos. Muy práctico para aprender haciendo

Regular Expressions Info: Aunque el sitio principal está en inglés, tiene una sección traducida en español y es muy exhaustivo. Excelente como referencia

MDN Web Docs de Mozilla – Expresiones regulares es probablemente tu mejor opción. Es completo, autorizado, tiene ejemplos claros y está bien traducido al español. Cubre desde lo básico hasta conceptos avanzados.

Cómo evitar el spam en los formularios de Contact Form 7 en WordPress

Valencia, 13/08/2026, G.B.
Cualquiera que administre un sitio en WordPress con un formulario de contacto conoce el problema: tarde o temprano, la bandeja de entrada empieza a llenarse de mensajes automáticos, ofertas de SEO milagroso o enlaces sospechosos. Contact Form 7, uno de los plugins de formularios más usados del ecosistema WordPress, no incorpora protección antispam por defecto, así que esa tarea recae en quien lo instala. La buena noticia es que evitarlo no requiere grandes conocimientos técnicos, sino combinar bien un puñado de técnicas.

Por qué llega el spam a un formulario de contacto

El spam en formularios no suele venir de personas, sino de bots que rastrean la web en busca de campos <form> y <input> para rellenarlos automáticamente. Cuantos más elementos «adivinables» tenga el formulario (nombre, email, mensaje), más fácil es para un bot completarlo sin intervención humana. Por eso las soluciones eficaces no se basan en dificultar la vida al usuario real, sino en tender trampas o barreras que solo detectan a las máquinas.

El método más eficaz y menos invasivo: el honeypot

La técnica del honeypot consiste en añadir al formulario un campo invisible para las personas pero visible para los bots, que recorren el HTML sin ejecutar el CSS que lo oculta. Si ese campo llega relleno al servidor, se descarta el envío sin más. Contact Form 7 permite implementarlo fácilmente combinando un campo de texto oculto con CSS y una validación en functions.php que rechace el envío si ese campo no está vacío. Es la opción que menos fricción añade, porque no exige nada al usuario legítimo.

Un honeypot («tarro de miel» en castellano) existe en el HTML, así que cualquier bot que analice el código lo detecta como un campo más a rellenar. Está oculto para las personas, normalmente con CSS (display: none o posicionándolo fuera de la pantalla), así que un usuario real jamás lo ve ni lo rellena, por lo que debería llegar siempre vacío cuando el envío lo hace una persona.

Un bot que rastrea formularios no «ve» el CSS, solo el HTML crudo, así que interpreta ese campo como uno legítimo y lo completa automáticamente (a menudo con texto basura, o incluso su propia URL). En el servidor, la lógica es simple: si ese campo llega con algo escrito, el envío se descarta sin más, porque solo una máquina pudo haberlo rellenado.

Es una trampa en el sentido literal: no impide que el bot rellene el formulario, sino que aprovecha su propio comportamiento automático para delatarlo. Por eso es tan eficaz y tan poco invasivo a la vez —no le pide nada extra al usuario real, solo tiende una trampa que solo un bot puede pisar.

reCAPTCHA: la barrera más conocida

Google reCAPTCHA (en su versión v3, invisible) es la integración nativa de Contact Form 7 y sigue siendo una de las defensas más sólidas frente a bots más sofisticados. Se configura desde el propio panel de Contact Form 7, introduciendo la clave del sitio y la clave secreta que genera Google. Su ventaja es que analiza el comportamiento del visitante en segundo plano, sin mostrar los típicos rompecabezas de «selecciona las imágenes con semáforos», lo que evita perjudicar la conversión.

Validación estricta de campos

Otra capa de protección, complementaria a las anteriores, es endurecer la validación de los campos del formulario: exigir que el campo de email tenga un formato válido, limitar la longitud del mensaje, o bloquear la presencia de etiquetas HTML o enlaces en campos que no deberían contenerlos (el nombre, por ejemplo). Contact Form 7 admite expresiones regulares personalizadas en sus campos, lo que permite filtrar patrones típicos del spam antes de que el mensaje llegue siquiera a enviarse.

Filtrado adicional con Akismet

Si el sitio ya usa Akismet para proteger los comentarios de WordPress, se puede aprovechar el mismo servicio para los formularios: existen extensiones de Contact Form 7 que envían cada envío a Akismet para su análisis antes de reenviarlo por correo. Es un paso extra útil cuando el volumen de spam sigue siendo alto pese al honeypot y el reCAPTCHA.

Qué evitar

Conviene ser prudente con los bloqueos por IP y las limitaciones agresivas de frecuencia de envío: son medidas que, mal calibradas, terminan bloqueando a usuarios legítimos con más facilidad que a los propios bots, que suelen rotar de IP constantemente. Tampoco es necesario desactivar el formulario ni añadir capas de fricción visibles si con un honeypot y reCAPTCHA v3 ya se resuelve la mayor parte del problema.

En resumen

La combinación más razonable para un formulario de Contact Form 7 en WordPress es: honeypot como primera barrera invisible, reCAPTCHA v3 como segunda capa de análisis de comportamiento, y validación de campos para cerrar el paso a los patrones más evidentes de spam. Con estos tres elementos bien configurados, la gran mayoría de los envíos automáticos quedan filtrados sin que el visitante real note ninguna diferencia al escribir su mensaje.

Cómo avisar a tus lectores de que un artículo es antiguo (sin plugins abandonados)

plugin aviso contenido antiguo

Valencia, 13/08/2026, G.B.
Necesitaba un plugin para Indaga.net que avisara a los posibles lectores en el caso de acceder a un artículo con más de 5 años de antigüedad. Busqué en el repositorio de WordPress y encontré alguna opción, pero o llevaba tiempo sin actualizarse o no se ajustaba del todo a lo que quería (control por artículo, umbral configurable, sin dependencias). Así que decidí crear mi propio plugin: Aviso de Contenido Antiguo.

Se trata de un plugin independiente, sin dependencias externas y con estándares de seguridad (nonces, current_user_can, sanitización).

Continuar leyendo «Cómo avisar a tus lectores de que un artículo es antiguo (sin plugins abandonados)»

Una actualización de «Record of Consent Extension for Complianz» provoca un error crítico en WordPress

Valencia, 11/08/2026, G.B.
Una actualización reciente del plugin Record of Consent Extension for Complianz, el complemento gratuito que añade registro de consentimientos al plugin Complianz, ha dejado inaccesibles varios paneles de administración de WordPress tras instalarse. El fallo se ha detectado en varios sitios, incluidos algunos que gestiono, donde el escritorio de WordPress quedó bloqueado por un error crítico inmediatamente después de la actualización automática del plugin, mientras que la parte pública de las webs siguió funcionando con normalidad.

Cómo se detectó el problema

El síntoma es el clásico «error crítico» de WordPress, pero limitado al escritorio: el acceso a wp-admin se interrumpía por completo, sin más detalle visible para el usuario salvo que ha ocurrido un problema técnico, mientras que la web pública seguía cargando sin incidencias. Al revisar por SFTP la carpeta wp-content/plugins, el plugin con la fecha de modificación más reciente —coincidiendo con el día anterior a la incidencia— resultó ser precisamente esta extensión de Complianz. Renombrar su carpeta (lo que equivale a desactivarlo a la fuerza, ya que WordPress no puede cargar un plugin cuya ruta ha cambiado) restableció el acceso al instante, confirmando que el origen del fallo estaba ahí. Tras verificarlo, se optó por eliminar el plugin directamente en lugar de reactivarlo.

Qué dice el registro de cambios oficial

El historial de versiones publicado en el repositorio de WordPress.org confirma que este no es el primer episodio de errores fatales en el plugin: versiones anteriores tuvieron que corregir expresamente una llamada a un método inexistente que provocaba un error fatal en el almacenamiento de consentimientos, así como un fallo por el que la tabla de base de datos no se creaba durante el asistente de configuración inicial, lo que impedía guardar los registros de consentimiento. Es decir, se trata de un complemento con antecedentes de regresiones críticas entre versiones, algo a tener en cuenta por quienes lo usan en producción.

En el momento de escribir estas líneas, el repositorio oficial de WordPress.org señala la versión 2.1 como la más reciente publicada, sin que se haya localizado en foros o canales públicos un reporte específico que documente esta incidencia concreta con fecha de ayer; es posible que se trate de un problema de compatibilidad puntual con la configuración de PHP, caché o el propio Complianz en los servidores afectados, y no de un fallo generalizado reportado aún por la comunidad.

Qué hacer si os ha pasado lo mismo

Si una web deja de responder justo después de que WordPress actualice automáticamente un plugin, el primer paso es siempre identificar por SFTP o FTP qué carpeta de wp-content/plugins tiene la fecha de modificación más reciente y renombrarla temporalmente (añadiendo, por ejemplo, _OLD al nombre de la carpeta); esto basta para que WordPress lo desactive sin poder acceder al panel, y suele restaurar el acceso en segundos si el plugin es efectivamente el causante. Conviene además revisar los logs de error de PHP del hosting (error_log o el panel de errores que ofrezca el proveedor) para confirmar la causa exacta antes de decidir si se reinstala una versión anterior, se contacta con el soporte del desarrollador o, como en este caso, se opta por eliminarlo y buscar una alternativa.