Un descanso de unos pocos días…

Valencia, 30/09/2026, G.B.
Llevo bastante «tralla» últimamente, tanto en mi trabajo como con mis webs. Estudiar, aprender, experimentar y publicar está muy bien, pero descansar es fundamental. Despertarse a mitad de noche con la cabeza llena de ideas, aunque sean tontas, no mola nada de nada! Al día siguiente tienes que volver a la rutina, y sobre todo a madrugar: el precio es demasiado alto…

Así pues, me voy a dar el lujo de una pequeña pausa, aunque solo sea de dos o tres días (o lo que haga falta), en ese espacio. Os recomiendo que de vez en cuando, o todas las veces que sean necesarias, lo hagáis;  por vuestro bien, y el de los que comparten su vida con vosotros. Que al final todo se paga…

Hasta pronto!

PD. Os dejo un Bonus Track

 

 

Cuaderno de Ideas: un plugin ligero para tus anotaciones privadas en WordPress

Valencia, 28/09/2026, G.B.
A la hora de gestionar publicaciones y proyectos de contenidos, la dispersión de notas es un dolor de cabeza. Solemos acumular anotaciones en aplicaciones externas, documentos sueltos o notas rápidas que terminan perdiéndose. Para resolver esto, decidí crear un cuaderno de ideas privado integrado dentro del propio panel de administración de WordPress, permitiéndome preparar borradores, visualizarlos de forma privada y exportarlos a distintos formatos (HTML, Markdown o TPL/BBCode para foros como phpBB) de manera 100% nativa.

¿Por qué desarrollar este plugin?

Aunque en el repositorio de WordPress existen bastantes extensiones para tomar notas, la gran mayoría sufren de dos grandes inconvenientes: o cargan librerías pesadas en JavaScript que ralentizan el panel, o guardan la información en servidores de terceros con modelos de suscripción.

Con este plugin conseguimos un entorno de trabajo rápido, privado e integrado en la base de datos de WordPress. Nos permite trabajar sin dependencias externas y transformar cualquier borrador en el código necesario para publicar en blogs, repositorios como GitHub o comunidades en phpBB.

Requisitos y arquitectura técnica

Para mantener el desarrollo ligero y libre de conflictos de compatibilidad, la arquitectura del plugin se apoya en tres pilares básicos:

  • Cero dependencias externas: Funciona única y exclusivamente con la API nativa de WordPress (Custom Post Types, Custom Taxonomies y Meta Boxes). No requiere frameworks ni scripts externos.
  • Seguridad por diseño: Implementa tokens de seguridad (nonces) para validar formularios, comprobación estricta de permisos de usuario (edit_posts), restricción de vista previa pública y sanitización de campos de texto antes de guardarlos en la base de datos.
  • Exportación y vista previa en el servidor: Genera vistas dinámicas para copiar el código transformado al portapapeles y permite la previsualización del post en el frontend de forma estrictamente privada (devolviendo un error 403 de acceso denegado si intenta acceder un usuario no autenticado).

El código completo del plugin

Crea la carpeta /wp-content/plugins/cuaderno-ideas/ y guarda dentro el archivo cuaderno-ideas.php con el siguiente código:

<?php
/**
 * Plugin Name: Cuaderno de Ideas Privado
 * Description: Diario de ideas privado para gestión de publicaciones con vista previa privada y exportación a HTML, Markdown y TPL (BBCode).
 * Version:     1.2.0
 * Author:      INDAGA.net
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit; // Evitar acceso directo
}

/**
 * 1. REGISTRO DEL CUSTOM POST TYPE Y TAXONOMÍAS NATIVAS
 */
function cip_registrar_post_type() {
    $labels = array(
        'name'               => 'Ideas',
        'singular_name'      => 'Idea',
        'menu_name'          => 'Cuaderno Ideas',
        'add_new'            => 'Añadir Idea',
        'add_new_item'       => 'Añadir Nueva Idea',
        'edit_item'          => 'Editar Idea',
        'new_item'           => 'Nueva Idea',
        'view_item'          => 'Ver Idea Privada',
        'search_items'       => 'Buscar Ideas',
        'not_found'          => 'No se encontraron ideas',
    );

    $args = array(
        'labels'             => $labels,
        'public'             => false, // No se indexa en listados públicos
        'publicly_queryable' => true,  // Permite renderizar la vista individual
        'show_ui'            => true,  // Visible en el panel de administración
        'show_in_menu'       => true,
        'query_var'          => true,
        'rewrite'            => array( 'slug' => 'idea-privada' ),
        'capability_type'    => 'post',
        'has_archive'        => false,
        'hierarchical'       => false,
        'menu_position'      => 20,
        'menu_icon'          => 'dashicons-lightbulb',
        'supports'           => array( 'title', 'editor' ),
    );

    register_post_type( 'cip_idea', $args );

    // Categorías de Ideas
    register_taxonomy( 'cip_categoria', 'cip_idea', array(
        'label'        => 'Categorías de Ideas',
        'rewrite'      => false,
        'hierarchical' => true,
        'show_ui'      => true,
    ) );

    // Etiquetas de Ideas
    register_taxonomy( 'cip_etiqueta', 'cip_idea', array(
        'label'        => 'Etiquetas de Ideas',
        'rewrite'      => false,
        'hierarchical' => false,
        'show_ui'      => true,
    ) );
}
add_action( 'init', 'cip_registrar_post_type' );

/**
 * 2. SEGURIDAD: BLOQUEAR VISTA PÚBLICA A USUARIOS NO AUTENTICADOS
 */
function cip_proteger_vista_privada() {
    if ( is_singular( 'cip_idea' ) ) {
        if ( ! is_user_logged_in() || ! current_user_can( 'edit_posts' ) ) {
            wp_die(
                'Acceso restringido: Esta idea pertenece a un cuaderno de notas privado.',
                'Acceso Denegado',
                array( 'response' => 403 )
            );
        }
    }
}
add_action( 'template_redirect', 'cip_proteger_vista_privada' );

/**
 * 3. SUBMENÚ Y PÁGINA DE INSTRUCCIONES / CONFIGURACIÓN EN EL PANEL
 */
function cip_agregar_menu_instrucciones() {
    add_submenu_page(
        'edit.php?post_type=cip_idea',
        'Instrucciones y Uso',
        'Instrucciones',
        'manage_options',
        'cip-instrucciones',
        'cip_render_pagina_instrucciones'
    );
}
add_action( 'admin_menu', 'cip_agregar_menu_instrucciones' );

function cip_render_pagina_instrucciones() {
    $active_tab = isset( $_GET['tab'] ) ? sanitize_text_field( $_GET['tab'] ) : 'instrucciones';
    ?>
    <div class="wrap">
        <h1>Cuaderno de Ideas Privado</h1>

        <h2 class="nav-tab-wrapper">
            <a href="?post_type=cip_idea&page=cip-instrucciones&tab=instrucciones" class="nav-tab <?php echo $active_tab === 'instrucciones' ? 'nav-tab-active' : ''; ?>">
                Instrucciones
            </a>
            <a href="?post_type=cip_idea&page=cip-instrucciones&tab=ficha" class="nav-tab <?php echo $active_tab === 'ficha' ? 'nav-tab-active' : ''; ?>">
                Ficha Técnica
            </a>
        </h2>

        <div class="tab-content" style="margin-top:20px; background:#fff; padding:20px; border:1px solid #ccc; max-width:900px;">
            <?php if ( $active_tab === 'instrucciones' ) : ?>
                <h2>Guía de Uso del Cuaderno de Ideas</h2>
                <hr>
                <ol>
                    <li><strong>Crear una Idea:</strong> Dirígete a <code>Cuaderno Ideas &gt; Añadir Idea</code>. Escribe el título y el cuerpo del borrador utilizando el editor nativo.</li>
                    <li><strong>Ver Idea como Post Privado:</strong> Haz clic en el botón «Ver Idea Privada» en el panel lateral para abrir la vista individual de la entrada en el frontend (solo accesible si has iniciado sesión).</li>
                    <li><strong>Categorización:</strong> Asigna categorías y etiquetas en la barra lateral para organizar tus temas.</li>
                    <li><strong>Gestión de Estado:</strong> En el panel lateral «Detalles y Estado de la Idea», define la prioridad (Baja, Media, Alta), marca la casilla si ya ha sido publicada, e indica el lugar y fecha de publicación.</li>
                    <li><strong>Exportar Contenido:</strong> En el panel inferior del editor («Exportar Contenido»), selecciona entre las versiones <strong>HTML</strong>, <strong>Markdown</strong> o <strong>TPL (BBCode para phpBB)</strong> y utiliza los botones para copiar directamente el código al portapapeles.</li>
                </ol>
            <?php else : ?>
                <h2>Ficha del Plugin</h2>
                <hr>
                <p>Diario de ideas privado para gestión interna de publicaciones, vista previa privada e exportación a múltiples formatos de marcado.</p>
                
                <p><strong>Acceso:</strong> Cuaderno Ideas &gt; Instrucciones (<code>wp-admin/edit.php?post_type=cip_idea&amp;page=cip-instrucciones</code>)</p>
                
                <p><strong>Alias / Shortcodes:</strong> No aplica (es un plugin exclusivamente de administración interna privada).</p>
                
                <p><strong>Dependencias:</strong> Ninguna (100% nativo mediante Custom Post Types y API de WordPress).</p>
                
                <p><strong>Seguridad:</strong> Validación mediante nonces (<code>wp_verify_nonce</code>), restricción de vista previa en frontend (<code>template_redirect</code> con respuesta 403 para usuarios no autorizados), verificación de capacidades (<code>edit_posts</code>), sanitización al guardar (<code>sanitize_text_field</code>) y escapado estricto en la salida (<code>esc_textarea</code>, <code>esc_attr</code>, <code>esc_url</code>).</p>
            <?php endif; ?>
        </div>
    </div>
    <?php
}

/**
 * 4. CREACIÓN DE CAMPOS PERSONALIZADOS (META BOXES)
 */
function cip_agregar_meta_boxes() {
    add_meta_box(
        'cip_detalles_idea',
        'Detalles y Estado de la Idea',
        'cip_render_meta_box',
        'cip_idea',
        'side',
        'high'
    );

    add_meta_box(
        'cip_exportar_idea',
        'Exportar Contenido',
        'cip_render_export_box',
        'cip_idea',
        'normal',
        'high'
    );
}
add_action( 'add_meta_boxes', 'cip_agregar_meta_boxes' );

function cip_render_meta_box( $post ) {
    wp_nonce_field( 'cip_guardar_metas', 'cip_nonce' );

    $prioridad   = get_post_meta( $post->ID, '_cip_prioridad', true ) ?: 'media';
    $publicado   = get_post_meta( $post->ID, '_cip_publicado', true );
    $donde       = get_post_meta( $post->ID, '_cip_donde_publicado', true );
    $fecha_pub   = get_post_meta( $post->ID, '_cip_fecha_publicado', true );
    $permalink   = get_permalink( $post->ID );

    ?>
    <?php if ( 'publish' === $post->post_status ) : ?>
        <p>
            <a href="<?php echo esc_url( $permalink ); ?>" target="_blank" class="button button-secondary" style="width:100%; text-align:center;">
                <span class="dashicons dashicons-visibility" style="vertical-align:middle; margin-top:-2px;"></span> Ver Idea Privada
            </a>
        </p>
        <hr>
    <?php endif; ?>

    <p>
        <label for="cip_prioridad"><strong>Prioridad:</strong></label><br>
        <select name="cip_prioridad" id="cip_prioridad" style="width:100%;">
            <option value="baja" <?php selected( $prioridad, 'baja' ); ?>>Baja</option>
            <option value="media" <?php selected( $prioridad, 'media' ); ?>>Media</option>
            <option value="alta" <?php selected( $prioridad, 'alta' ); ?>>Alta</option>
        </select>
    </p>
    <hr>
    <p>
        <label for="cip_publicado">
            <input type="checkbox" name="cip_publicado" id="cip_publicado" value="1" <?php checked( $publicado, '1' ); ?>>
            <strong>¿Publicado fuera?</strong>
        </label>
    </p>
    <p>
        <label for="cip_donde_publicado"><strong>¿Dónde se ha publicado?</strong></label><br>
        <input type="text" name="cip_donde_publicado" id="cip_donde_publicado" value="<?php echo esc_attr( $donde ); ?>" style="width:100%;" placeholder="Ej: Blog principal, Medium...">
    </p>
    <p>
        <label for="cip_fecha_publicado"><strong>Fecha de publicación:</strong></label><br>
        <input type="date" name="cip_fecha_publicado" id="cip_fecha_publicado" value="<?php echo esc_attr( $fecha_pub ); ?>" style="width:100%;">
    </p>
    <?php
}

/**
 * 5. GUARDADO SEGURO DE DATOS (SANITIZACIÓN)
 */
function cip_guardar_metas( $post_id ) {
    if ( ! isset( $_POST['cip_nonce'] ) || ! wp_verify_nonce( $_POST['cip_nonce'], 'cip_guardar_metas' ) ) {
        return;
    }
    if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
        return;
    }
    if ( ! current_user_can( 'edit_post', $post_id ) ) {
        return;
    }

    if ( isset( $_POST['cip_prioridad'] ) ) {
        update_post_meta( $post_id, '_cip_prioridad', sanitize_text_field( $_POST['cip_prioridad'] ) );
    }

    $publicado = isset( $_POST['cip_publicado'] ) ? '1' : '0';
    update_post_meta( $post_id, '_cip_publicado', $publicado );

    if ( isset( $_POST['cip_donde_publicado'] ) ) {
        update_post_meta( $post_id, '_cip_donde_publicado', sanitize_text_field( $_POST['cip_donde_publicado'] ) );
    }

    if ( isset( $_POST['cip_fecha_publicado'] ) ) {
        update_post_meta( $post_id, '_cip_fecha_publicado', sanitize_text_field( $_POST['cip_fecha_publicado'] ) );
    }
}
add_action( 'save_post', 'cip_guardar_metas' );

/**
 * 6. MOTOR DE EXPORTACIÓN (HTML, MARKDOWN, TPL/BBCODE)
 */
function cip_render_export_box( $post ) {
    $content = $post->post_content;
    $title   = $post->post_title;

    $markdown = "# " . $title . "\n\n" . strip_tags( $content );

    $bbcode = "[b]" . $title . "[/b]\n\n";
    $bbcode .= wp_strip_all_tags( $content );

    ?>
    <p>Copia directamente la idea convertida al formato de destino:</p>
    <div style="margin-bottom:15px;">
        <button type="button" class="button" onclick="document.getElementById('cip_exp_html').select(); document.execCommand('copy');">Copiar HTML</button>
        <button type="button" class="button" onclick="document.getElementById('cip_exp_md').select(); document.execCommand('copy');">Copiar Markdown</button>
        <button type="button" class="button" onclick="document.getElementById('cip_exp_tpl').select(); document.execCommand('copy');">Copiar TPL / BBCode</button>
    </div>

    <h4>HTML Limpio:</h4>
    <textarea id="cip_exp_html" style="width:100%; height:80px;" readonly><?php echo esc_textarea( wpautop( $content ) ); ?></textarea>

    <h4>Markdown (.md):</h4>
    <textarea id="cip_exp_md" style="width:100%; height:80px;" readonly><?php echo esc_textarea( $markdown ); ?></textarea>

    <h4>TPL / phpBB (BBCode):</h4>
    <textarea id="cip_exp_tpl" style="width:100%; height:80px;" readonly><?php echo esc_textarea( $bbcode ); ?></textarea>
    <?php
}

Explicación del código de forma sencilla

Para entender la lógica tras el plugin, podemos resumir su funcionamiento en cinco partes clave:

Custom Post Type privado pero consultable

Utilizamos register_post_type configurando 'public' => false pero activando 'publicly_queryable' => true. Esto nos permite generar un enlace individual para visualizar la entrada como si fuera un post publicado estándar dentro de nuestro tema gráfico, pero sin aparecer en buscadores ni en los listados generales del sitio.

Protección de la vista privada en el servidor

Para asegurar que nadie ajeno lea nuestras anotaciones, la función cip_proteger_vista_privada() se ejecuta en el hook template_redirect. Si un visitante no autenticado (o sin permisos de edición) intenta abrir la URL directa de una idea, el servidor interrumpe la carga y devuelve una pantalla de error 403 (Acceso Denegado).

Panel de Instrucciones integrado

Siguiendo la estructura limpia que usamos en nuestros desarrollos, se añade una página dentro del menú del plugin (Cuaderno Ideas > Instrucciones) con pestañas dinámicas que recogen la guía de uso y la ficha técnica con los detalles de seguridad y accesos.

Metadatos y control de estado

Mediante add_meta_box creamos un panel lateral en el editor para fijar la prioridad de la idea, indicar si ya ha sido publicada fuera, la fecha y el medio de destino. Al guardar, el proceso valida un código nonce anti-CSRF y limpia los datos recibidos mediante sanitize_text_field.

Motor de exportación al vuelo

El panel inferior convierte el título y el cuerpo del borrador a tres formatos limpios. Para el HTML aplica la función wpautop() para estructurar los párrafos; para Markdown añade la sintaxis básica de encabezados; y para TPL/phpBB traduce el formato a etiquetas BBCode tipo [b]. Incluye además botones en JavaScript nativo para copiar el resultado con un solo clic.

Si quieres probarlo, puedes descargarlo desde Control de plugins.

Cómo instalar Paper2Agent: convierte cualquier paper científico en un agente de IA consultable

Valencia, 25/09/2026, G.B.
Recientemente he leído un artículo sobre un proyecto que conecta modelos de lenguaje con herramientas externas vía MCP (Model Context Protocol). Se llama Paper2Agent: toma un paper científico con su repositorio de código y genera automáticamente un agente conversacional capaz de ejecutar los métodos del estudio. Nada de leerte el código y reconstruir el pipeline a mano, simplemente le preguntas directamente al agente.

El proyecto es de un equipo de Stanford (Jiacheng Miao, Joe R. Davis, Jonathan K. Pritchard y James Zou) y está publicado en GitHub bajo licencia MIT: github.com/jmiao24/Paper2Agent. Puedes probar Paper2Agent en modo live o instalarlo. Este es el proceso de instalación:

Lo que necesitas antes de empezar

  • Python 3.10 o superior
  • Node.js/npm, porque Claude Code (el agente de codificación de Anthropic sobre el que corre todo esto) se instala vía npm
  • Cuenta y acceso a Claude Code, la pieza que orquesta la generación del agente

Si no tienes Claude Code todavía, se instala en el mismo paso 3 de abajo, así que no hace falta que lo prepares antes por separado.

Instalación paso a paso

1. Clona el repositorio

git clone https://github.com/jmiao24/Paper2Agent.git
cd Paper2Agent

2. Instala la dependencia de Python

Solo necesitas fastmcp, la librería que monta el servidor MCP:

pip install fastmcp

Si trabajas con varios proyectos Python en la misma máquina (como hago yo), te recomiendo meterlo en un entorno virtual para no ensuciar el sistema global.

3. Instala y arranca Claude Code

npm install -g @anthropic-ai/claude-code
claude

Con esto ya tienes todo el terreno preparado.

Generar tu primer agente

El comando base es este:

bash Paper2Agent.sh \
  --project_dir <NOMBRE_PROYECTO> \
  --github_url <URL_DEL_REPOSITORIO>

Por ejemplo, para convertir el repositorio de Scanpy (muy usado en análisis de datos de célula única) en un agente:

bash Paper2Agent.sh \
  --project_dir Scanpy_Agent \
  --github_url https://github.com/scverse/scanpy \
  --tutorials "Preprocessing and clustering"

El flag --tutorials te deja acotar el proceso a un tutorial concreto del repositorio en lugar de procesarlo entero, algo que agradecerás si el proyecto original es grande. También existen --api (para repos que piden clave de autenticación) y --benchmark (para que genere y evalúe preguntas de referencia sobre el agente resultante).

Un aviso sobre tiempos y coste

Esto no es instantáneo. Según la documentación oficial, el proceso completo puede tardar entre 30 minutos y más de 3 horas, dependiendo de lo complejo que sea el código fuente del paper. Y no es gratis: usando Claude Sonnet 4, el coste estimado para repositorios grandes como el de AlphaGenome ronda los 15 dólares, como pago único por la generación del agente (no por cada consulta posterior).

Vale la pena tenerlo en cuenta antes de lanzarlo sobre un repositorio enorme solo por curiosidad.

Verificar que el agente está activo

Al terminar, Claude Code se abre automáticamente con el servidor MCP recién generado ya cargado. Para comprobarlo en cualquier momento:

claude mcp list

o, ya dentro de la sesión de Claude Code, el comando \mcp.

Atajo: MCP ya generados

Si solo quieres probar el concepto sin montar nada tú mismo, el repositorio ya ofrece tres servidores MCP remotos listos para usar, alojados en Hugging Face: AlphaGenome, Scanpy y TISSUE. Se conectan con:

bash launch_remote_mcp.sh \
  --working_dir <directorio_de_trabajo> \
  --mcp_name <nombre_mcp> \
  --mcp_url <url_del_servidor>

Por ejemplo, para el de AlphaGenome:

bash launch_remote_mcp.sh \
  --working_dir analysis_dir \
  --mcp_name alphagenome \
  --mcp_url https://Paper2Agent-alphagenome-mcp.hf.space

Es la forma más rápida de hacerse una idea de qué se puede hacer con esto antes de meterte a generar un agente propio desde un paper que te interese.

«Steam» de Peter Gabriel, momentos musicales

Valencia, 24/09/2026, G.B.
Tanto código, tanta historia… Un poquito de por favor, de relajación. Aquí va un vídeo de la canción «Steam» de Peter Gabriel (creo que con su hija, qué bonito!). Además es en directo, como debe ser… Es de la gira Secret World Tour.

Disfrutadla.

Bonus track

Y aquí el concierto entero o casi, no lo sé. Eso sí, mala calidad de sonido:

Y solo la música (disco 1, buena calidad de sonido):

 

Nextcloud: porqué no puedes borrar tus carpetas sincronizadas después de desinstalarlo

Valencia, 24/09/2026, G.B.
Si alguna vez has desinstalado el cliente de escritorio de Nextcloud y luego te has encontrado con que las carpetas que sincronizabas no se dejan borrar (ni siquiera como administrador, ni cambiando permisos NTFS) no es un fallo aislado. Es un problema conocido que tiene que ver con cómo funcionan los archivos virtuales en Windows.

El origen: archivos que no son archivos

Desde hace varias versiones, el cliente de Nextcloud para Windows ofrece dos modos de sincronización:

  • Archivos locales: Todo el contenido se descarga físicamente a tu disco duro.
  • Archivos virtuales (bajo demanda): Solo ves un marcador (placeholder) de cada archivo. El contenido real no se descarga hasta que lo abres.

Este segundo modo se apoya en una tecnología de Windows llamada Cloud Filter API, la misma que usa OneDrive para sus «archivos bajo demanda». Cada placeholder no es un archivo normal: es un reparse point, una entrada especial del sistema de archivos NTFS que le dice a Windows: «Esto no está aquí de verdad, pregúntale al driver de Nextcloud».

El problema aparece cuando desinstalas el cliente sin haber convertido antes esas carpetas a archivos locales. El controlador (driver) que sabía interpretar esos reparse points desaparece, pero las entradas se quedan en el disco, huérfanas. Windows ve algo que dice que es un reparse point gestionado por un proceso externo inexistente y bloquea la operación al no tener forma de resolverlo con normalidad.

Por qué cambiar permisos no sirve

Aquí está la trampa: como usuario, lo primero que se prueba es tomar posesión de la carpeta y ajustar las listas de control de acceso (ACL) de NTFS. Pero no es un problema de permisos.

El sistema operativo no te está diciendo que no tienes acceso, te está diciendo que no sabe qué es ese objeto. Los permisos estándar de Windows no tocan ni eliminan el atributo de reparse point.

Cómo solucionarlo

Opción rápida: desbloqueo de procesos

Cierra cualquier proceso de Nextcloud que pueda seguir corriendo en segundo plano y reinicia explorer.exe (o reinicia el equipo directamente). Muchas veces el bloqueo es simplemente un identificador (handle) que quedó abierto tras un cierre de sesión incompleto.

Opción limpia: reinstalación previa

Si tienes margen, vuelve a instalar el cliente, conecta de nuevo esa carpeta y cambia su configuración de «archivos virtuales» a «archivos locales». Esto fuerza la descarga completa y limpia los reparse points. Después ya puedes desconectar la cuenta y desinstalar la aplicación sin dejar rastro.

Opción quirúrgica: desde PowerShell con fsutil

Desde una ventana de PowerShell como administrador, puedes comprobar si un archivo o carpeta conserva dicho atributo ejecutando:

Get-Item 'C:\ruta\a\la\carpeta' | Select-Object Attributes

Si en los atributos devueltos aparece ReparsePoint, puedes eliminar el enlace directamente con la herramienta nativa fsutil:

fsutil reparsepoint delete "C:\ruta\a\la\carpeta"

Esto convierte el marcador en un archivo o directorio normal (vacío, ya que su contenido residía en la nube) que sí se puede borrar sin errores desde el explorador.

Último recurso

Arrancar Windows en Modo Seguro (donde no se cargan los controladores de integración con la nube) o emplear la herramienta robocopy con la opción /MIR contra una carpeta vacía para forzar el vaciado del directorio bloqueado.

La lección para la próxima vez

Si vas a desinstalar el cliente de Nextcloud, conviene poner primero las carpetas sincronizadas en modo «solo archivos locales» desde los ajustes de la aplicación antes de desconectar la cuenta. Es un paso de treinta segundos que te ahorra este dolor de cabeza por completo.

Buenos presagios, Good Omens…

Valencia, 23/09/2026, G.B.
Acabo de terminar el libro «Buenos presagios» («Good Omens«) de Terry Pratchett y Neil Gaiman. Debo decir que me ha encantado. Tanto es así que me he comprado la versión original, en inglés y voy a leerlo ya!.

«Buenos presagios (Good Omens) es un libro que os recomiendo encarecidamente, si os gusta la fantasía, claro… Os dejo un fragmento del mismo (en .pdf) en castellano.

Y no, no dejéis que se os caiga a la bañera, que Terry ya no os lo podrá firmar, por desgracia. Neil Gaiman podría hacerlo (aunque ya sin sombreo…) , pero tal vez con una sonrisa apagada por haber perdido a su «compadre»…

Una última cosa más: Adam Young, ¿se nos acaba ya realmente el mundo, o sigue la tregua?…

Más información del libro (Wikipedia)

De Ivory Search al motor nativo: cómo crear tu propio buscador de WordPress seguro, ligero y sin plugins externos

Valencia, 23/09/2026, G.B.
La búsqueda interna es una de las piezas clave en cualquier sitio web con una buena cantidad de contenidos, categorías y enlaces. Sin embargo, mantener un buscador funcional en WordPress sin cargar el sitio de dependencias o scripts superfluos puede llegar a ser complicado.

Durante mucho tiempo estuve utilizando en el sitio el conocido plugin Ivory Search. Cumplía su función, pero tras las últimas actualizaciones comenzó a fallar hasta que dejó de funcionar del todo. Como solución provisional, opté por sustituirlo por un buscador integrado directamente en la barra de navegación del menú (proceso que ya os comenté en un post anterior aquí en Indaga.net). Aunque funcionaba (y sigue funcionando) correctamente para búsquedas rápidas, faltaba la flexibilidad de disponer de una caja de búsqueda limpia, personalizable y reutilizable mediante shortcode en cualquier página, entrada o widget.

La solución por la que me he decidido ha sido desarrollar un  plugin propio en un único archivo .php: 100% nativo, ligero, sin librerías de terceros y  seguro.

¿Por qué prescindir de plugins comerciales de búsqueda?

La mayoría de los plugins de búsqueda del repositorio introducen tablas adicionales en la base de datos, hojas de estilo CSS pesadas, scripts JavaScript que ralentizan el renderizado («pintado») y paneles llenos de características comerciales inútiles.

El núcleo de WordPress ya cuenta con un motor de búsqueda integrado extremadamente eficiente que se invoca mediante el parámetro estándar ?s=. Lo único que necesitamos es una interfaz limpia para capturar la búsqueda.

Los requisitos para este plugin han sido:

  • Cero dependencias externas: Sin librerías JS, sin llamadas a CDNs y con icono de lupa insertado en SVG nativo
  • Seguridad estricta: Validación y sanitización de todas las entradas del usuario y escape en el renderizado HTML (prevención de vulnerabilidades XSS
  • Aislamiento de estilos: Generación de selectores dinámicos para evitar conflictos con el tema activo de WordPress
  • Panel de administración e instrucciones: Un panel sencillo dentro de Ajustes > Buscador Indaga para cambiar colores, texto del placeholder y elegir varios diseños de maquetación
  • Atributos en Shortcode: Posibilidad de anular la configuración global directamente desde la etiqueta indaga_search (entre corchetes, []). Aquí podéis verlo insertado funcionamiento.

Estructura y código completo del plugin

Para instalarlo, basta con crear la carpeta /wp-content/plugins/indaga-search-plugin/ y guardar dentro el archivo indaga-search-plugin.php con el siguiente código PHP:

<?php
/**
 * Plugin Name: Indaga Search Form
 * Plugin URI:  https://indaga.net
 * Description: Buscador nativo de WordPress ligero, seguro y sin dependencias. Permite varios diseños y configuraciones personalizables desde el panel o mediante atributos de shortcode.
 * Version:     1.0.0
 * Author:      INDAGA.net
 * Text Domain: indaga-search
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Evitar acceso directo por URL
}

/**
 * 1. REGISTRO DE OPCIONES Y PÁGINA DE CONFIGURACIÓN EN EL PANEL
 */
function isf_register_settings() {
	register_setting( 'isf_options_group', 'isf_placeholder', array(
		'type'              => 'string',
		'default'           => 'Buscar en el sitio...',
		'sanitize_callback' => 'sanitize_text_field',
	) );

	register_setting( 'isf_options_group', 'isf_button_text', array(
		'type'              => 'string',
		'default'           => 'Buscar',
		'sanitize_callback' => 'sanitize_text_field',
	) );

	register_setting( 'isf_options_group', 'isf_layout', array(
		'type'              => 'string',
		'default'           => 'inline',
		'sanitize_callback' => 'isf_sanitize_layout',
	) );

	register_setting( 'isf_options_group', 'isf_primary_color', array(
		'type'              => 'string',
		'default'           => '#2563eb',
		'sanitize_callback' => 'sanitize_hex_color',
	) );

	register_setting( 'isf_options_group', 'isf_bg_color', array(
		'type'              => 'string',
		'default'           => '#ffffff',
		'sanitize_callback' => 'sanitize_hex_color',
	) );

	register_setting( 'isf_options_group', 'isf_border_color', array(
		'type'              => 'string',
		'default'           => '#cbd5e1',
		'sanitize_callback' => 'sanitize_hex_color',
	) );
}
add_action( 'admin_init', 'isf_register_settings' );

function isf_sanitize_layout( $input ) {
	$valid_layouts = array( 'inline', 'stacked', 'minimal', 'icon_only' );
	return in_array( $input, $valid_layouts, true ) ? $input : 'inline';
}

function isf_add_admin_menu() {
	add_options_page(
		'Configuración de Buscador Indaga',
		'Buscador Indaga',
		'manage_options',
		'indaga-search',
		'isf_render_admin_page'
	);
}
add_action( 'admin_menu', 'isf_add_admin_menu' );

function isf_render_admin_page() {
	if ( ! current_user_can( 'manage_options' ) ) {
		return;
	}

	$placeholder   = get_option( 'isf_placeholder', 'Buscar en el sitio...' );
	$button_text   = get_option( 'isf_button_text', 'Buscar' );
	$layout        = get_option( 'isf_layout', 'inline' );
	$primary_color = get_option( 'isf_primary_color', '#2563eb' );
	$bg_color      = get_option( 'isf_bg_color', '#ffffff' );
	$border_color  = get_option( 'isf_border_color', '#cbd5e1' );
	?>
	<div class="wrap">
		<h1>Ajustes del Buscador Indaga.net</h1>
		<p>Personaliza el aspecto del buscador nativo o consulta las instrucciones para incrustarlo mediante shortcode.</p>

		<div style="display: flex; gap: 30px; flex-wrap: wrap; margin-top: 20px;">
			<div style="flex: 1; min-width: 320px; max-width: 600px;">
				<form method="post" action="options.php">
					<?php
					settings_fields( 'isf_options_group' );
					do_settings_sections( 'isf_options_group' );
					?>
					<table class="form-table" role="presentation">
						<tr>
							<th scope="row"><label for="isf_placeholder">Texto del placeholder</label></th>
							<td>
								<input type="text" id="isf_placeholder" name="isf_placeholder" value="<?php echo esc_attr( $placeholder ); ?>" class="regular-text" />
							</td>
						</tr>
						<tr>
							<th scope="row"><label for="isf_button_text">Texto del botón</label></th>
							<td>
								<input type="text" id="isf_button_text" name="isf_button_text" value="<?php echo esc_attr( $button_text ); ?>" class="regular-text" />
							</td>
						</tr>
						<tr>
							<th scope="row"><label for="isf_layout">Diseño de la estructura</label></th>
							<td>
								<select id="isf_layout" name="isf_layout">
									<option value="inline" <?php selected( $layout, 'inline' ); ?>>En línea (Clásico - Botón a la derecha)</option>
									<option value="stacked" <?php selected( $layout, 'stacked' ); ?>>Apilado (Campo de texto arriba, botón abajo)</option>
									<option value="minimal" <?php selected( $layout, 'minimal' ); ?>>Minimalista (Caja integrada con lupa)</option>
									<option value="icon_only" <?php selected( $layout, 'icon_only' ); ?>>Solo icono (Botón cuadrado compacto)</option>
								</select>
							</td>
						</tr>
						<tr>
							<th scope="row"><label for="isf_primary_color">Color principal</label></th>
							<td>
								<input type="color" id="isf_primary_color" name="isf_primary_color" value="<?php echo esc_attr( $primary_color ); ?>" />
							</td>
						</tr>
						<tr>
							<th scope="row"><label for="isf_bg_color">Color de fondo del campo</label></th>
							<td>
								<input type="color" id="isf_bg_color" name="isf_bg_color" value="<?php echo esc_attr( $bg_color ); ?>" />
							</td>
						</tr>
						<tr>
							<th scope="row"><label for="isf_border_color">Color del borde</label></th>
							<td>
								<input type="color" id="isf_border_color" name="isf_border_color" value="<?php echo esc_attr( $border_color ); ?>" />
							</td>
						</tr>
					</table>
					<?php submit_button( 'Guardar cambios' ); ?>
				</form>
			</div>

			<div style="flex: 1; min-width: 300px; max-width: 450px; background: #fff; padding: 20px; border: 1px solid #c3c4c7; border-radius: 4px; height: fit-content;">
				<h2>Instrucciones de Uso</h2>
				<p>Inserta el buscador en cualquier lugar utilizando:</p>
				<p><code>
</code></p> <hr style="margin: 15px 0; border: 0; border-top: 1px solid #eee;" /> <h3>Parámetros del Shortcode</h3> <ul> <li><code>layout="inline|stacked|minimal|icon_only"</code></li> <li><code>placeholder="Buscar..."</code></li> <li><code>color="#2563eb"</code></li> </ul> </div> </div> </div> <?php } /** * 2. LÓGICA DEL SHORTCODE
*/ function isf_search_shortcode( $atts ) { $db_placeholder = get_option( 'isf_placeholder', 'Buscar en el sitio...' ); $db_button_text = get_option( 'isf_button_text', 'Buscar' ); $db_layout = get_option( 'isf_layout', 'inline' ); $db_primary_color = get_option( 'isf_primary_color', '#2563eb' ); $db_bg_color = get_option( 'isf_bg_color', '#ffffff' ); $db_border_color = get_option( 'isf_border_color', '#cbd5e1' ); $atts = shortcode_atts( array( 'placeholder' => $db_placeholder, 'button_text' => $db_button_text, 'layout' => $db_layout, 'color' => $db_primary_color, 'bg' => $db_bg_color, 'border' => $db_border_color, ), $atts, 'indaga_search' ); $layout = isf_sanitize_layout( $atts['layout'] ); $placeholder = sanitize_text_field( $atts['placeholder'] ); $button_text = sanitize_text_field( $atts['button_text'] ); $primary_color = sanitize_hex_color( $atts['color'] ); $bg_color = sanitize_hex_color( $atts['bg'] ); $border_color = sanitize_hex_color( $atts['border'] ); $unique_id = 'indaga-search-' . wp_rand( 1000, 9999 ); $search_svg = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle;"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>'; $output = '<style>'; $output .= '.' . $unique_id . ' { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; width: 100%; margin: 10px 0; }'; $output .= '.' . $unique_id . ' form { display: flex; gap: 8px; margin: 0; width: 100%; box-sizing: border-box; }'; $output .= '.' . $unique_id . ' input[type="search"] { flex: 1; background-color: ' . esc_attr( $bg_color ) . '; border: 1px solid ' . esc_attr( $border_color ) . '; border-radius: 4px; padding: 8px 12px; font-size: 14px; color: #1e293b; outline: none; transition: border-color 0.2s ease; box-sizing: border-box; }'; $output .= '.' . $unique_id . ' input[type="search"]:focus { border-color: ' . esc_attr( $primary_color ) . '; }'; $output .= '.' . $unique_id . ' button[type="submit"] { background-color: ' . esc_attr( $primary_color ) . '; color: #ffffff; border: none; border-radius: 4px; padding: 8px 16px; font-size: 14px; font-weight: 500; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 6px; }'; if ( 'stacked' === $layout ) { $output .= '.' . $unique_id . ' form { flex-direction: column; }'; $output .= '.' . $unique_id . ' button[type="submit"] { width: 100%; }'; } elseif ( 'minimal' === $layout ) { $output .= '.' . $unique_id . ' form { position: relative; }'; $output .= '.' . $unique_id . ' input[type="search"] { padding-right: 40px; }'; $output .= '.' . $unique_id . ' button[type="submit"] { position: absolute; right: 4px; top: 4px; bottom: 4px; background: transparent; color: ' . esc_attr( $primary_color ) . '; }'; } elseif ( 'icon_only' === $layout ) { $output .= '.' . $unique_id . ' button[type="submit"] { padding: 8px 12px; }'; } $output .= '</style>'; $home_url = esc_url( home_url( '/' ) ); $current_query = get_search_query(); $output .= '<div class="indaga-search-container ' . esc_attr( $unique_id ) . '">'; $output .= '<form role="search" method="get" action="' . $home_url . '">'; $output .= '<input type="search" value="' . esc_attr( $current_query ) . '" name="s" placeholder="' . esc_attr( $placeholder ) . '" aria-label="Buscar" required />'; $output .= '<button type="submit">'; if ( 'minimal' === $layout || 'icon_only' === $layout ) { $output .= $search_svg; } else { $output .= $search_svg . ' ' . esc_html( $button_text ); } $output .= '</button>'; $output .= '</form>'; $output .= '</div>'; return $output; } add_shortcode( 'indaga_search', 'isf_search_shortcode' );

Formas de invocación en tus páginas

Una vez activado el plugin en WordPress, puedes invocar el buscador en cualquier parte de varias formas distintas )lo encontrarás en Ajustes -> Buscador Indaga):

  • Buscador clásico en línea:

    indaga_search

    (recordad, siempre los shortcodes siempre entre corchetes):

  • Formato minimalista con la lupa integrada:
    indaga_search layout="minimal" placeholder="Explorar en Indaga.net..."

  • Formato apilado para barras laterales o pie de página:

    indaga_search layout="stacked" button_text="Buscar en el archivo"

Con este pequeño plugin vuelvo a tener más control sobre el rendimiento del sitio, asegurándome de que el buscador responda al instante, es seguro y no añade sobrecarga al servidor.

Descargar plugin de mi cuenta de pCloud

PD. Actualizaré la página Control de plugins. En breve…

Cómo filtrar direcciones IP y rangos en logs Web usando solo Bash

Valencia, 22/09/2026, G.B.
Cuando administramos servidores web como Apache, Nginx o LiteSpeed, los archivos de acceso (access.log) se convierten rápidamente en la fuente principal para identificar tráfico no deseado, bots malintencionados o intentos de fuerza bruta.

Aunque existen herramientas de análisis y cortafuegos dinámicos, en muchas ocasiones necesitamos realizar una limpieza rápida de un archivo de log directamente desde la consola, sin instalar dependencias adicionales ni recurrir a lenguajes externos como Python o Perl.

En este artículo veremos cómo construir un script en Bash puro que nos permita eliminar líneas enteras de un log basándonos en direcciones IP individuales o rangos mediante expresiones regulares (regex).

La herramienta clave: grep en modo extendido

Para filtrar contenido en GNU/Linux descartando líneas completas, el comando por excelencia es grep. En lugar de buscar lo que queremos conservar, le indicamos lo que queremos excluir mediante el parámetro -v.

Para manejar rangos o múltiples patrones en una sola pasada, utilizaremos dos modificadores esenciales:

  • -v (--revert-match): Invierte el resultado de la búsqueda, conservando únicamente las líneas que no coinciden con el patrón.
  • -E (--extended-regexp): Activa las expresiones regulares extendidas, permitiéndonos usar operadores lógicos como la barra vertical (|) para el condicional «O» (OR) y corchetes [] para rangos numéricos.

Filtrado de rangos IP mediante Expresiones Regulares

A diferencia de las herramientas orientadas a red que interpretan notación CIDR (como /24), las utilidades del Shell procesan el archivo de log como texto plano. Por ello, traduciremos las subredes a expresiones regulares fijas.

A continuación, algunas equivalencias útiles para definir rangos dentro del patrón de filtrado:

Rango deseadoExpresión RegularExplicación
Subred /24 completa192\.168\.1\.Coincide con cualquier IP que comience por 192.168.1.
Subred /16 completa10\.0\.Coincide con cualquier IP que empiece por 10.0.
Rango .0 a .9172\.16\.0\.[0-9]\bFiltra del 172.16.0.0 al 172.16.0.9
Rango .10 a .19172\.16\.0\.1[0-9]\bFiltra del 172.16.0.10 al 172.16.0.19
IP única203\.0\.113\.45Filtra exclusivamente esa dirección

Nota: Es fundamental escapar los puntos (\.) con una barra invertida. De lo contrario, en expresiones regulares el punto actuaría como un comodín que representa cualquier carácter. Igualmente importante: los patrones que terminan en un dígito suelto (como [0-9] o 1[0-9]) deben cerrarse con \b (límite de palabra), o de lo contrario también harán match con números más largos que empiecen por esos mismos dígitos.

El Script Bash: filtrar_log.sh

A continuación se muestra la estructura completa del script. Este toma como entrada el archivo de log original y genera un nuevo archivo limpio sin las líneas asociadas a las IP definidas.

#!/bin/bash

# ==============================================================================
# Script de filtrado de IPs y rangos en logs de acceso
# Publicado en Indaga.net
# ==============================================================================

# Validar que se introduzcan los argumentos necesarios
if [ "$#" -ne 2 ]; then
    echo "Error: Argumentos insuficientes."
    echo "Uso: $0 <log_origen> <log_destino>"
    exit 1
fi

LOG_ORIGEN="$1"
LOG_DESTINO="$2"

# Verificar la existencia del archivo de origen
if [ ! -f "$LOG_ORIGEN" ]; then
    echo "Error: El archivo '$LOG_ORIGEN' no existe."
    exit 1
fi

# Definición de reglas y rangos a excluir (formato Regex)
PATRONES=(
    "192\.168\.1\."           # Subred 192.168.1.0/24
    "10\.0\."                  # Subred 10.0.0.0/16
    "172\.16\.0\.[0-9]\b"      # IPs del 172.16.0.0 al 172.16.0.9
    "172\.16\.0\.1[0-9]\b"     # IPs del 172.16.0.10 al 172.16.0.19
    "203\.0\.113\.45"          # Dirección IP única estática
)

# Unir el array de patrones utilizando la barra vertical (|) como separador OR
PATRON_REGEX=$(IFS='|'; echo "${PATRONES[*]}")

# Ejecución del filtrado con grep
grep -v -E "$PATRON_REGEX" "$LOG_ORIGEN" > "$LOG_DESTINO"
RESULTADO=$?

# Confirmación de salida
# grep devuelve: 0 = hubo líneas tras filtrar, 1 = ninguna línea coincidió
# (log resultante vacío, no es un error), 2 = error real (lectura, etc.)
if [ "$RESULTADO" -le 1 ]; then
    echo "Filtrado completado con éxito."
    echo "Archivo limpio guardado en: $LOG_DESTINO"
else
    echo "Ocurrió un error al procesar el archivo."
fi

Modo de empleo

  1. Guardar el archivo: Crea el script en tu servidor, por ejemplo con nano filtrar_log.sh.
  2. Asignar permisos de ejecución:
    chmod +x filtrar_log.sh
  3. Ejecutar el proceso: Pasa como primer argumento el log actual y como segundo el nombre del archivo resultante:
    ./filtrar_log.sh /var/log/nginx/access.log /var/log/nginx/access_limpio.log

Ejecución rápida en una sola línea (One-Liner)

Si no necesitas la estructura del script y buscas solucionar una tarea puntual en la terminal, puedes ejecutar el filtro directamente concatenando los patrones deseados:

grep -v -E "192\.168\.1\.|10\.0\.|172\.16\.0\.[0-9]\b" access.log > access_filtrado.log

Esta alternativa utiliza únicamente las utilidades nativas de cualquier sistema POSIX, garantizando un rendimiento óptimo al procesar grandes volúmenes de texto sin sobrecargar los recursos del sistema.

Hooks y actions de WordPress explicados con un plugin real: Mis Plugins

Valencia, 21/09/2026, G.B.
Ya he hablado varias veces por aquí de hooks, actions y filters en WordPress, pero siempre viene bien un ejemplo real y completo en vez de fragmentos sueltos. Así que voy a usar mi propio plugin Mis Plugins como caso práctico: es pequeño, no tiene dependencias externas, y toca casi todos los hooks que se usan en el día a día de un plugin.

Qué es un hook

Un hook es un punto del código de WordPress donde puedes «engancharte» para ejecutar tu propia función, sin tocar el núcleo. Hay dos tipos:

  • Actions: ejecutan tu función en un momento concreto (por ejemplo, cuando se activa un plugin, o cuando se carga el panel de administración). No devuelven nada, solo hacen algo.
  • Filters: reciben un valor, tu función lo modifica y lo devuelve. Se usan para transformar datos antes de que WordPress los use.

Mis Plugins usa sobre todo actions, así que empiezo por ahí.

register_activation_hook: al activar el plugin

Este hook se dispara una sola vez, justo cuando activas el plugin desde el panel. Lo uso para crear la tabla propia en la base de datos:

register_activation_hook( __FILE__, 'indaga_mp_activar' );

function indaga_mp_activar() {
    global $wpdb;
    $table_name = indaga_mp_table_name();
    $charset_collate = $wpdb->get_charset_collate();

    require_once ABSPATH . 'wp-admin/includes/upgrade.php';

    $sql = "CREATE TABLE {$table_name} (
        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        fecha DATE NOT NULL,
        nombre_plugin VARCHAR(191) NOT NULL,
        PRIMARY KEY (id)
    ) {$charset_collate};";

    dbDelta( $sql );
}

La función dbDelta es la forma segura de crear o actualizar tablas propias: compara la estructura que le pasas con la que ya existe y hace los cambios necesarios sin borrar datos.

plugins_loaded: comprobar la versión en cada carga

register_activation_hook solo se dispara al activar, así que si subo una versión nueva del plugin con columnas añadidas, necesito otro punto de enganche que se ejecute siempre. Para eso uso plugins_loaded, comparando un número de versión guardado en las opciones:

add_action( 'plugins_loaded', 'indaga_mp_comprobar_version_bd' );

function indaga_mp_comprobar_version_bd() {
    if ( get_option( 'indaga_mp_db_version' ) !== INDAGA_MP_DB_VERSION ) {
        indaga_mp_activar();
    }
}

Así, cuando subí la versión 2.0.0 con los campos de autor y tags, la tabla se actualizó sola en cuanto WordPress cargó los plugins, sin que el usuario tuviera que desactivar y reactivar nada.

admin_menu: añadir la página al panel

Este hook se dispara mientras WordPress construye el menú de administración. Es el sitio correcto para registrar una nueva pantalla:

add_action( 'admin_menu', 'indaga_mp_menu_admin' );

function indaga_mp_menu_admin() {
    add_menu_page(
        'Mis Plugins',
        'Mis Plugins',
        'manage_options',
        INDAGA_MP_SLUG,
        'indaga_mp_render_pagina_admin',
        'dashicons-admin-plugins',
        80
    );
}

admin_init: procesar el formulario, nunca en el callback del menú

Este es el hook con el que más cuidado tengo, porque un error aquí ya me ha dado algún quebradero de cabeza en otros plugins: procesar un formulario POST dentro de la función que pinta la página de administración puede dejar la pantalla congelada. La solución es mover ese procesamiento a admin_init, comprobando primero en qué pantalla estamos:

add_action( 'admin_init', 'indaga_mp_procesar_admin_init' );

function indaga_mp_procesar_admin_init() {
    if ( ! isset( $_GET['page'] ) || INDAGA_MP_SLUG !== $_GET['page'] ) {
        return;
    }
    if ( ! current_user_can( 'manage_options' ) ) {
        return;
    }
    // Aquí se valida el nonce y se guarda o borra el registro.
}

admin_enqueue_scripts: cargar estilos solo donde tocan

Para no meter CSS en todas las pantallas del panel, engancho los estilos a este hook y compruebo en qué página estoy antes de cargarlos:

add_action( 'admin_enqueue_scripts', 'indaga_mp_admin_estilos' );

function indaga_mp_admin_estilos( $hook ) {
    if ( strpos( (string) $hook, INDAGA_MP_SLUG ) === false ) {
        return;
    }
    wp_register_style( 'indaga-mp-admin', false );
    wp_enqueue_style( 'indaga-mp-admin' );
    wp_add_inline_style( 'indaga-mp-admin', '/* CSS aquí */' );
}

add_shortcode: mostrarlo en el front end

No es técnicamente un action ni un filter, pero funciona con la misma lógica de enganche: le dices a WordPress que, cuando encuentre el shortcode mis_plugins (entre brackets, que si no se ejecuta aquí)  en el contenido, ejecute tu función y sustituya el shortcode por lo que devuelva:

add_shortcode( 'mis_plugins', 'indaga_mp_shortcode' );

function indaga_mp_shortcode( $atts ) {
    $atts = shortcode_atts( array(
        'por_pagina' => 10,
    ), $atts, 'mis_plugins' );

    // Consulta a la base de datos y devuelve el HTML con ob_start()/ob_get_clean().
}

Un detalle interesante para quien quiera profundizar en filters: shortcode_atts, por dentro, aplica el filter shortcode_atts_mis_plugins, que permite a otro plugin modificar los atributos por defecto sin tocar mi código. Es un buen ejemplo de cómo WordPress deja puertas abiertas incluso en funciones tan pequeñas como esta.

Resumen

Con seis hooks (uno de activación, uno de carga, tres de administración y un shortcode) queda montado un plugin completo, con su propia tabla, su formulario y su vista en el front end. Si quieres verlo funcionando, tienes el listado en Control de plugins, con el catálogo de todos los que he ido publicando aquí.

Nextcloud: sincronización local vs. archivos virtuales, y cómo cambiar sin reinstalar

nextcloud

Valencia, 20/09/2026, G.B.
Si usas el cliente de escritorio de Nextcloud desde hace tiempo, es probable que en algún momento hayas notado que la carpeta sincronizada empieza a comportarse mal: tarda una eternidad en actualizarse, consume una cantidad de espacio en disco que ya no te cuadra, o directamente deja de sincronizar algunos archivos sin motivo aparente. La reacción típica  (desinstalar completamente el cliente, reinstalarlo y volver a configurar las conexiones desde cero) suele funcionar, pero es un martillazo para lo que a veces es un problema mucho más pequeño, es como cazar moscas a cañonazos. En otros casos, es necesario. Antes de llegar ahí merece la pena entender qué diferencia hay entre los dos modos de sincronización que ofrece Nextcloud, porque cambiar de uno a otro puede resolver el problema sin perder nada por el camino.

Sincronización local total: la de toda la vida

Es el modo por defecto y el que casi todo el mundo usa sin plantearse alternativas. Funciona de forma muy simple:

  • Todo el contenido de tu cuenta Nextcloud se descarga físicamente al disco duro.
  • Cada archivo ocupa espacio en disco de manera permanente, exactamente igual que si lo hubieras copiado tú a mano.
  • Puedes acceder a cualquier archivo en cualquier momento, incluso sin conexión a internet o con el cliente de Nextcloud completamente cerrado, porque son ficheros normales del sistema operativo.
  • El cliente solo entra en juego para detectar cambios y sincronizarlos; el acceso al archivo en sí no depende de que esté en ejecución.

El problema aparece cuando la cuenta crece: si gestionas varios gigas —o varios cientos— de documentos, fotos o archivos de proyecto, tener una copia local completa de todo empieza a pasar factura, tanto en espacio como en el tiempo que tarda el cliente en comprobar el estado de cada archivo.

Archivos virtuales (VFS): descarga solo lo que usas

Aquí es donde entra el modo de archivos virtuales, también conocido como VFS (Virtual File System). La idea es distinta:

  • El cliente crea una especie de «estructura fantasma»: ves todos los nombres de archivos y carpetas exactamente igual que antes, pero el contenido no se descarga hasta que lo abres.
  • En el momento en que abres un archivo, se descarga (con el pequeño retraso lógico según tamaño y velocidad de red) y queda cacheado en local.
  • Puedes marcar archivos o carpetas concretas como «siempre disponible sin conexión» para que se comporten exactamente como en el modo de sincronización total.
  • En Windows, esto se apoya en la API nativa CfAPI (Cloud Files API), la misma tecnología que usa OneDrive para su modo «bajo demanda». Requiere Windows 10 build 1809 o superior. En macOS también está soportado en las versiones recientes del cliente; en Linux el comportamiento es distinto, ya que se gestiona directamente desde el propio cliente sin depender de una API del sistema.

La pregunta que casi todo el mundo se hace: ¿y sin el cliente abierto?

Es la duda más razonable, y la respuesta tiene matices: los archivos que ya se han abierto y descargado (o que has marcado como «siempre disponible») siguen ahí, en disco, accesibles sin conexión ni cliente activo. Pero los que aún no se han descargado son solo placeholders: el nombre y el icono existen, pero el contenido real no se puede recuperar hasta que el cliente de Nextcloud vuelva a estar en ejecución y haya conexión al servidor. Si trabajas mucho en un portátil que cierra procesos en segundo plano para ahorrar batería, o necesitas acceso offline a archivos que no has abierto antes, conviene tenerlo en cuenta y marcar de antemano como disponibles sin conexión las carpetas que sabes que vas a necesitar.

Cómo cambiar de local a virtual sin reinstalar nada

La buena noticia es que el cambio se hace desde la propia cuenta ya configurada, sin tocar la instalación del cliente:

  1. Abre la configuración de la cuenta desde el icono del cliente en la bandeja del sistema.
  2. Accede a las opciones de la carpeta sincronizada, normalmente a través de 3 puntos, una flecha o menú desplegable junto al nombre de la carpeta local.
  3. Selecciona «Activar soporte para archivos virtuales …» o un texto equivalente, según tu versión de Nextcloud Files
  4. Confirma la conversión cuando el cliente avise de que va a cambiar el modo de la carpeta.
  5. Espera a que se reindexe la carpeta —puede tardar varios minutos según el volumen de archivos—.
  6. Comprueba el resultado: en el explorador de Windows o en Finder verás iconos de nube (solo en línea) o de check verde (ya descargado) junto a cada archivo.

Antes de tocar nada: diagnostica primero

Si el motivo por el que te planteas este cambio es un problema de sincronización que no acaba de funcionar, no des el salto directamente. Revisa antes lo siguiente, porque a veces la causa es otra y cambiar el modo de sincronización solo la enmascara temporalmente:

  • Registro de actividad del cliente (Actividad reciente > filtrar por errores): suele señalar la causa real —conflictos, permisos, archivos bloqueados—.
  • Rutas y nombres problemáticos: en Windows, uno de los fallos más habituales es superar el límite de longitud de ruta (MAX_PATH) o usar caracteres no permitidos como : * ? " < > |.
  • Espacio en disco y estado de la carpeta local: a veces el problema es simplemente falta de espacio, no la sincronización en sí.

Si tras revisar todo esto el error persiste igual después de pasar a VFS, entonces sí, probablemente el problema no era el modo de sincronización y tiene sentido plantearse una reinstalación limpia.

Un truco útil si al final hay que reinstalar

Si después de todo el cambio a VFS no resuelve el problema y toca reinstalar, un método sencillo para no perder nada: tras desconectar la cuenta, cierra la sesión de Windows —así se liberan los procesos que puedan tener la carpeta bloqueada (Explorer, indexador de búsqueda, el propio cliente)— y renombra la carpeta local afectada en lugar de borrarla. De esta forma conservas una copia de seguridad intacta mientras configuras la conexión desde cero, y puedes rescatar cualquier archivo que se hubiera quedado a medio sincronizar.

En resumen: pasar de sincronización local a archivos virtuales es un cambio reversible, rápido y mucho menos drástico que una reinstalación completa. Merece la pena probarlo antes de tirar por el camino más largo.

Nota final: en Linux, de momento, no es una opción fiable

Todo lo anterior aplica sin problemas a Windows y macOS, pero conviene ser claro respecto a Linux: el cliente de escritorio de Nextcloud no ofrece soporte oficial de archivos virtuales en esta plataforma. Ni el paquete de nextcloud-desktop ni el AppImage muestran la opción en la configuración habitual de la cuenta.

Existe un workaround no oficial —añadir showExperimentalOptions=true en la sección [General] del archivo ~/.config/Nextcloud/nextcloud.cfg, lo que hace aparecer la opción como función experimental—, pero varios usuarios reportan que no funciona de forma fiable: en lugar de comportarse como archivos bajo demanda, el cliente intenta descargar o sincronizar toda la cuenta igualmente, anulando la ventaja que se busca.

De hecho, el propio anuncio de Nextcloud sobre su nueva «Virtual Drive» (la evolución de esta función) reconoce que la vista previa técnica funciona en Windows, que macOS llegará poco después, y que Linux «se abordará más adelante» — es decir, sigue sin ser una prioridad resuelta ni en la próxima generación de la función.

Conclusión para equipos Linux: si el problema de sincronización aparece en un equipo con este sistema, el cambio a archivos virtuales no es una alternativa segura a día de hoy. Lo más sensato es agotar primero el diagnóstico (registro de errores del cliente, rutas y nombres de archivo problemáticos, espacio en disco disponible) y, si el problema persiste, optar directamente por una reinstalación limpia — renombrando antes la carpeta local afectada tras cerrar sesión, para conservar una copia de seguridad por si acaso.