Solucionado el bug, ya nos queda "congelado" en webs con Gutenberg o constructores tipo WBakery. La soculión ha sido cambiar el archivo admin-page.php (ubicado en la subcarpeta includes del plugin).
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Despacha las acciones del panel (subida, ajustes, borrados) enganchado a
* admin_init, en lugar de dentro del callback del menú (ietv_admin_page).
*
* WordPress ya ha enviado admin-header.php (y por tanto cabeceras HTTP y
* HTML) cuando se ejecuta el callback de add_menu_page(). Si el
* procesamiento del POST vive ahí y acaba llamando a wp_safe_redirect(),
* la redirección llega tarde: "headers already sent" y la página se queda
* a medio renderizar ("congelada"). Al engancharlo a admin_init se procesa
* antes de que se envíe ninguna salida, así que la redirección funciona
* con normalidad.
*/
add_action( 'admin_init', 'ietv_handle_admin_actions' );
function ietv_handle_admin_actions() {
// Solo actuar en la página de este plugin, para no interceptar el
// admin_init de cualquier otra pantalla del admin.
if ( ! isset( $_GET['page'] ) || 'import-excel-to-table' !== $_GET['page'] ) {
return;
}
if ( ! isset( $_POST['ietv_action'] ) ) {
return;
}
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'No tienes permiso para realizar esta acción.', 'import-excel-to-table' ) );
}
check_admin_referer( 'ietv_admin_action', 'ietv_nonce' );
if ( 'upload' === $_POST['ietv_action'] ) {
ietv_handle_upload();
} elseif ( 'save_settings' === $_POST['ietv_action'] ) {
ietv_handle_save_settings();
} elseif ( 'delete_import' === $_POST['ietv_action'] ) {
$import_id = isset( $_POST['ietv_import_id'] ) ? sanitize_text_field( wp_unslash( $_POST['ietv_import_id'] ) ) : '';
ietv_delete_import( $import_id );
ietv_redirect_with_message(
/* translators: %s: identificador de la importación borrada */
sprintf( __( 'Importación "%s" eliminada.', 'import-excel-to-table' ), ietv_sanitize_import_id( $import_id ) ),
'success'
);
} elseif ( 'delete_all' === $_POST['ietv_action'] ) {
ietv_delete_all_data();
ietv_redirect_with_message( __( 'Se han borrado todas las importaciones.', 'import-excel-to-table' ), 'success' );
}
}
/**
* Renderiza la página de administración del plugin.
*
* Este callback ya no procesa ningún $_POST: eso ocurre antes, en
* ietv_handle_admin_actions() enganchado a admin_init. Aquí solo se pinta
* el HTML a partir del estado actual (importaciones guardadas, mensaje de
* aviso tras la redirección, vista previa si se ha pedido).
*/
function ietv_admin_page() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'No tienes permiso para acceder a esta página.', 'import-excel-to-table' ) );
}
$imports = ietv_get_import_list();
$require_login = (bool) get_option( 'ietv_require_login', false );
$preview_id = isset( $_GET['ietv_preview'] ) ? ietv_sanitize_import_id( wp_unslash( $_GET['ietv_preview'] ) ) : '';
$preview_data = $preview_id ? ietv_get_import_data( $preview_id ) : array();
?>
<div class="wrap ietv-container">
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
<?php ietv_render_admin_notice(); ?>
<div class="ietv-upload-section">
<h2><?php esc_html_e( 'Subir archivo', 'import-excel-to-table' ); ?></h2>
<form method="post" enctype="multipart/form-data" novalidate>
<?php wp_nonce_field( 'ietv_admin_action', 'ietv_nonce' ); ?>
<input type="hidden" name="ietv_action" value="upload" />
<table class="form-table" role="presentation">
<tr>
<th scope="row"><label for="ietv_file"><?php esc_html_e( 'Archivo', 'import-excel-to-table' ); ?></label></th>
<td>
<input type="file" id="ietv_file" name="ietv_file" accept=".xlsx,.ods" required aria-describedby="ietv_file_help" />
<p id="ietv_file_help" class="description">
<?php esc_html_e( 'Formatos admitidos: .xlsx y .ods. Tamaño máximo: 5 MB. La primera fila debe contener los encabezados de columna.', 'import-excel-to-table' ); ?>
</p>
</td>
</tr>
<tr>
<th scope="row"><label for="ietv_import_id"><?php esc_html_e( 'Identificador', 'import-excel-to-table' ); ?></label></th>
<td>
<input type="text" id="ietv_import_id" name="ietv_import_id" class="regular-text" placeholder="<?php esc_attr_e( 'p. ej. contactos', 'import-excel-to-table' ); ?>" aria-describedby="ietv_import_id_help" />
<p id="ietv_import_id_help" class="description">
<?php esc_html_e( 'Identifica esta importación y su shortcode: [excel_table id="tu-identificador"]. Si lo dejas en blanco, se genera a partir del nombre del archivo. Cada identificador guarda sus datos por separado; importar de nuevo con el mismo identificador solo sustituye esos datos, no los de otras importaciones.', 'import-excel-to-table' ); ?>
</p>
</td>
</tr>
</table>
<p>
<button type="submit" class="button button-primary">
<?php esc_html_e( 'Importar', 'import-excel-to-table' ); ?>
</button>
</p>
</form>
</div>
<div class="ietv-settings-section">
<h2><?php esc_html_e( 'Ajustes de visualización', 'import-excel-to-table' ); ?></h2>
<form method="post">
<?php wp_nonce_field( 'ietv_admin_action', 'ietv_nonce' ); ?>
<input type="hidden" name="ietv_action" value="save_settings" />
<label>
<input type="checkbox" name="ietv_require_login" value="1" <?php checked( $require_login ); ?> />
<?php esc_html_e( 'Mostrar las tablas de shortcode solo a usuarios con sesión iniciada (recomendado si los datos son sensibles / RGPD). Aplica a todas las importaciones.', 'import-excel-to-table' ); ?>
</label>
<p><button type="submit" class="button"><?php esc_html_e( 'Guardar ajustes', 'import-excel-to-table' ); ?></button></p>
</form>
</div>
<div class="ietv-preview-section">
<h2><?php esc_html_e( 'Importaciones', 'import-excel-to-table' ); ?></h2>
<?php if ( empty( $imports ) ) : ?>
<p><?php esc_html_e( 'No hay ninguna importación todavía.', 'import-excel-to-table' ); ?></p>
<?php else : ?>
<table class="widefat striped">
<thead>
<tr>
<th scope="col"><?php esc_html_e( 'Identificador', 'import-excel-to-table' ); ?></th>
<th scope="col"><?php esc_html_e( 'Registros', 'import-excel-to-table' ); ?></th>
<th scope="col"><?php esc_html_e( 'Última importación', 'import-excel-to-table' ); ?></th>
<th scope="col"><?php esc_html_e( 'Shortcode', 'import-excel-to-table' ); ?></th>
<th scope="col"><?php esc_html_e( 'Acciones', 'import-excel-to-table' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $imports as $import ) : ?>
<tr>
<td><?php echo esc_html( $import->import_id ); ?></td>
<td><?php echo intval( $import->total ); ?></td>
<td><?php echo esc_html( mysql2date( 'd/m/Y H:i', $import->last_import ) ); ?></td>
<td><code>[excel_table id="<?php echo esc_html( $import->import_id ); ?>"]</code></td>
<td>
<a class="button button-small" href="<?php echo esc_url( add_query_arg( 'ietv_preview', $import->import_id ) ); ?>">
<?php esc_html_e( 'Ver datos', 'import-excel-to-table' ); ?>
</a>
<form method="post" style="display:inline" onsubmit="return confirm('<?php echo esc_js( __( '¿Seguro que quieres borrar esta importación? Esta acción no se puede deshacer.', 'import-excel-to-table' ) ); ?>');">
<?php wp_nonce_field( 'ietv_admin_action', 'ietv_nonce' ); ?>
<input type="hidden" name="ietv_action" value="delete_import" />
<input type="hidden" name="ietv_import_id" value="<?php echo esc_attr( $import->import_id ); ?>" />
<button type="submit" class="button button-small button-link-delete"><?php esc_html_e( 'Borrar', 'import-excel-to-table' ); ?></button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php if ( $preview_id && ! empty( $preview_data ) ) : ?>
<h3>
<?php
printf(
/* translators: %s: identificador de la importación */
esc_html__( 'Vista previa: %s', 'import-excel-to-table' ),
esc_html( $preview_id )
);
?>
</h3>
<?php $preview_columns = array_keys( $preview_data[0] ); ?>
<table class="widefat striped">
<thead>
<tr>
<?php foreach ( $preview_columns as $col ) : ?>
<th scope="col"><?php echo esc_html( $col ); ?></th>
<?php endforeach; ?>
</tr>
</thead>
<tbody>
<?php foreach ( array_slice( $preview_data, 0, 10 ) as $row ) : ?>
<tr>
<?php foreach ( $preview_columns as $col ) : ?>
<td><?php echo esc_html( ietv_cell_text( $row[ $col ] ?? '' ) ); ?></td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<p class="description">
<?php
printf(
/* translators: %d: número de registros mostrados como vista previa */
esc_html__( 'Mostrando los primeros %d registros de esta importación.', 'import-excel-to-table' ),
10
);
?>
</p>
<?php endif; ?>
<p style="margin-top: 20px;">
<form method="post" onsubmit="return confirm('<?php echo esc_js( __( '¿Seguro que quieres borrar TODAS las importaciones? Esta acción no se puede deshacer.', 'import-excel-to-table' ) ); ?>');">
<?php wp_nonce_field( 'ietv_admin_action', 'ietv_nonce' ); ?>
<input type="hidden" name="ietv_action" value="delete_all" />
<button type="submit" class="button button-secondary">
<?php esc_html_e( 'Borrar TODAS las importaciones', 'import-excel-to-table' ); ?>
</button>
</form>
</p>
<?php endif; ?>
</div>
</div>
<?php
}
/**
* Procesa la subida del archivo Excel/ODS.
*/
function ietv_handle_upload() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'No tienes permiso para realizar esta acción.', 'import-excel-to-table' ) );
}
// Con archivos grandes, procesar y guardar cientos de filas en una sola
// petición puede acercarse al límite de ejecución por defecto de PHP
// (a menudo 30s). Se pide más margen como salvaguarda; si el corte viene
// de un proxy inverso delante del servidor (no de PHP), esto no basta y
// haría falta ajustar el timeout del proxy.
if ( function_exists( 'set_time_limit' ) ) {
@set_time_limit( 180 ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_set_time_limit, WordPress.PHP.NoSilencedErrors.Discouraged
}
if ( empty( $_FILES['ietv_file'] ) || UPLOAD_ERR_OK !== $_FILES['ietv_file']['error'] ) {
ietv_redirect_with_message( __( 'Error al subir el archivo.', 'import-excel-to-table' ), 'error' );
}
$file = $_FILES['ietv_file'];
$max_size = apply_filters( 'ietv_max_file_size', IETV_MAX_FILE_SIZE );
if ( $file['size'] > $max_size ) {
ietv_redirect_with_message( __( 'El archivo supera el tamaño máximo permitido (5 MB).', 'import-excel-to-table' ), 'error' );
}
if ( ! is_uploaded_file( $file['tmp_name'] ) ) {
ietv_redirect_with_message( __( 'Archivo no válido.', 'import-excel-to-table' ), 'error' );
}
$file_name = sanitize_file_name( $file['name'] );
$ext = strtolower( pathinfo( $file_name, PATHINFO_EXTENSION ) );
$allowed_types = array(
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'ods' => 'application/vnd.oasis.opendocument.spreadsheet',
);
if ( ! isset( $allowed_types[ $ext ] ) ) {
ietv_redirect_with_message( __( 'Formato no permitido. Usa .xlsx o .ods.', 'import-excel-to-table' ), 'error' );
}
// Validación real del tipo de archivo, no solo por extensión.
$finfo = finfo_open( FILEINFO_MIME_TYPE );
$mime_type = finfo_file( $finfo, $file['tmp_name'] );
finfo_close( $finfo );
// Los .xlsx/.ods son en realidad archivos ZIP; algunos servidores
// devuelven application/zip en lugar del MIME específico de Office,
// así que se acepta también ese caso.
$valid_mime = ( $mime_type === $allowed_types[ $ext ] || 'application/zip' === $mime_type );
if ( ! $valid_mime ) {
ietv_redirect_with_message(
/* translators: %s: tipo MIME detectado */
sprintf( __( 'El contenido del archivo no coincide con su extensión (detectado: %s).', 'import-excel-to-table' ), $mime_type ),
'error'
);
}
if ( ! class_exists( 'ZipArchive' ) ) {
ietv_redirect_with_message( __( 'La extensión PHP "zip" no está activa en este hosting.', 'import-excel-to-table' ), 'error' );
}
// Cronometraje de diagnóstico: si con WP_DEBUG_LOG activo el "TOTAL"
// que aparece en debug.log es bajo (décimas o pocos segundos) pero el
// navegador se queda esperando mucho más tiempo, la causa del bloqueo
// no está en este procesamiento sino en algo posterior (típicamente un
// proxy inverso cortando la conexión antes de que llegue la respuesta).
$ietv_t0 = microtime( true );
try {
$rows = ( 'xlsx' === $ext ) ? ietv_read_xlsx( $file['tmp_name'] ) : ietv_read_ods( $file['tmp_name'] );
} catch ( Exception $e ) {
ietv_log_error( 'Error leyendo archivo: ' . $e->getMessage() );
ietv_redirect_with_message( __( 'No se pudo leer el archivo. Comprueba que no esté dañado.', 'import-excel-to-table' ), 'error' );
return;
}
ietv_log_error( sprintf( 'Lectura de %s: %.3fs (%d filas)', $ext, microtime( true ) - $ietv_t0, count( $rows ) ) );
if ( empty( $rows ) ) {
ietv_redirect_with_message( __( 'El archivo no contiene filas de datos.', 'import-excel-to-table' ), 'error' );
}
$ietv_t1 = microtime( true );
$clean_rows = ietv_sanitize_rows( $rows );
ietv_log_error( sprintf( 'Saneado de datos: %.3fs', microtime( true ) - $ietv_t1 ) );
$import_id = isset( $_POST['ietv_import_id'] ) ? sanitize_text_field( wp_unslash( $_POST['ietv_import_id'] ) ) : '';
if ( '' === trim( $import_id ) ) {
$import_id = pathinfo( $file_name, PATHINFO_FILENAME ); // sin extensión, a partir del nombre subido
}
$import_id = ietv_sanitize_import_id( $import_id );
$ietv_t2 = microtime( true );
$result = ietv_replace_import_data( $import_id, $clean_rows );
ietv_log_error( sprintf( 'Guardado en BD: %.3fs', microtime( true ) - $ietv_t2 ) );
ietv_log_error( sprintf( 'TOTAL importación "%s": %.3fs', $import_id, microtime( true ) - $ietv_t0 ) );
if ( is_wp_error( $result ) ) {
ietv_redirect_with_message( $result->get_error_message(), 'error' );
}
ietv_redirect_with_message(
/* translators: 1: número de registros importados, 2: identificador de la importación */
sprintf( __( 'Importación completada: %1$d registros guardados con el identificador "%2$s". Shortcode: [excel_table id="%2$s"]', 'import-excel-to-table' ), $result, $import_id ),
'success'
);
}
/**
* Guarda los ajustes de visualización.
*/
function ietv_handle_save_settings() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'No tienes permiso para realizar esta acción.', 'import-excel-to-table' ) );
}
$require_login = isset( $_POST['ietv_require_login'] ) ? '1' === $_POST['ietv_require_login'] : false;
update_option( 'ietv_require_login', $require_login );
ietv_redirect_with_message( __( 'Ajustes guardados.', 'import-excel-to-table' ), 'success' );
}
/**
* Redirige a la página del plugin con un mensaje de estado.
* (Sin llamadas HTTP espurias: solo construye la URL y redirige.)
*
* @param string $message
* @param string $type 'success' o 'error'
*/
function ietv_redirect_with_message( $message, $type = 'success' ) {
$url = add_query_arg(
array(
'page' => 'import-excel-to-table',
'ietv_message' => rawurlencode( $message ),
'ietv_type' => $type,
),
admin_url( 'admin.php' )
);
wp_safe_redirect( $url );
exit;
}
/**
* Muestra el aviso de éxito/error tras una redirección.
*/
function ietv_render_admin_notice() {
if ( empty( $_GET['ietv_message'] ) ) {
return;
}
$message = sanitize_text_field( wp_unslash( $_GET['ietv_message'] ) );
$type = isset( $_GET['ietv_type'] ) && 'error' === $_GET['ietv_type'] ? 'error' : 'success';
$class = 'error' === $type ? 'notice-error' : 'notice-success';
printf(
'<div class="notice %s is-dismissible"><p>%s</p></div>',
esc_attr( $class ),
esc_html( $message )
);
}