prefix . 'il_pilares') whose names and column lists // are constants under plugin control. Generic WordPress checks // for direct DB queries, caching and prepared-SQL interpolation // don't apply meaningfully here. See docs/LICENSING.md for the // full architecture rationale. class FLINK_Ajax { public function init() { $actions = array( // ── Pilares ── 'flink_save_pilar', 'flink_delete_pilar', 'flink_get_pilar', 'flink_get_pilares_list', 'flink_get_pilar_link_detail', 'flink_count_links_for_pilar', // ── Reglas ── 'flink_save_regla', 'flink_delete_regla', 'flink_toggle_regla', 'flink_get_regla', 'flink_bulk_toggle_reglas', 'flink_bulk_delete_reglas', 'flink_reprocess_regla', 'flink_strip_regla', 'flink_apply_new_regla', 'flink_strip_and_delete_regla', 'flink_get_rule_link_detail', // ── Procesamiento ── 'flink_count_pending', 'flink_process_batch', 'flink_refresh_stats', 'flink_reset_batch', // ── Importar / exportar ── 'flink_import_data', 'flink_import_rules_csv', // ── Éxitos / mapa de enlaces ── 'flink_exitos_list', 'flink_exitos_detail', 'flink_exitos_recalculate', // ── Búsqueda de páginas ── 'flink_search_pages', // ── Misc ── 'flink_clear_settings_transient', ); foreach ( $actions as $action ) { add_action( 'wp_ajax_' . $action, array( $this, str_replace( 'flink_', '', $action ) ) ); } } // ── PILARES ────────────────────────────────────────────────── public function save_pilar() { check_ajax_referer( 'flink_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Sin permisos' ); $pilares = new FLINK_Pilares(); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $id = isset( $_POST['id'] ) ? (int) wp_unslash( $_POST['id'] ) : 0; $data = array( // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. 'nombre' => sanitize_text_field( wp_unslash( $_POST['nombre'] ?? '' ) ), // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. 'url' => esc_url_raw( wp_unslash( $_POST['url'] ?? '' ) ), // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. 'idioma' => sanitize_key( wp_unslash( $_POST['idioma'] ?? '' ) ), 'activo' => 1, ); if ( ! $data['nombre'] ) wp_send_json_error( 'El nombre es obligatorio' ); if ( $id ) { $pilares->update( $id, $data ); $pilar = $pilares->get( $id ); } else { $new_id = $pilares->insert( $data ); $pilar = $pilares->get( $new_id ); } $count = FLINK_Stats::count_real_links_for_pilar( $pilar ); // Pilar URL changes may affect which links are valid update_option( 'flink_needs_processing', 1, false ); delete_option( 'flink_batch_reviewed' ); $admin = new FLINK_Admin(); wp_send_json_success( array( 'html' => $admin->render_pilar_card( $pilar, $count ), 'id' => $pilar->id, 'needs_process' => 1, ) ); } public function delete_pilar() { check_ajax_referer( 'flink_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Sin permisos' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $id = (int) ( isset( $_POST['id'] ) ? wp_unslash( $_POST['id'] ) : 0 ); ( new FLINK_Pilares() )->delete( $id ); update_option( 'flink_needs_processing', 1, false ); delete_option( 'flink_batch_reviewed' ); wp_send_json_success( array( 'needs_process' => 1 ) ); } public function get_pilar() { check_ajax_referer( 'flink_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Sin permisos' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. wp_send_json_success( ( new FLINK_Pilares() )->get( (int) ( isset( $_POST['id'] ) ? wp_unslash( $_POST['id'] ) : 0 ) ) ); } // ── REGLAS ─────────────────────────────────────────────────── public function save_regla() { check_ajax_referer( 'flink_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Sin permisos' ); $reglas = new FLINK_Reglas(); $pilares = new FLINK_Pilares(); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $id = isset( $_POST['id'] ) ? (int) wp_unslash( $_POST['id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $keyword = sanitize_text_field( wp_unslash( $_POST['keyword'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $sinonimos = sanitize_textarea_field( wp_unslash( $_POST['sinonimos'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $url_manual = esc_url_raw( wp_unslash( $_POST['url_manual'] ?? '' ) ); // lang_variantes: JSON string sent from JS. Validated by json_decode // before being persisted; we keep the raw textarea sanitization here. // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended -- Nonce verified in caller. $lang_variantes_raw = isset( $_POST['lang_variantes'] ) ? sanitize_textarea_field( wp_unslash( $_POST['lang_variantes'] ) ) : ''; $data = array( 'nombre' => $keyword, 'descripcion' => $sinonimos, 'origen_tipo' => get_option( 'flink_origen_tipo', 'any' ), 'origen_valor' => '', 'destino_tipo' => 'pilar', // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. 'pilar_id' => empty( $url_manual ) ? ( (int) ( isset( $_POST['pilar_id'] ) ? wp_unslash( $_POST['pilar_id'] ) : 0 ) ?: null ) : null, 'destino_valor' => $url_manual ?: '', 'anchor_text' => $keyword, // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. 'prioridad' => (int) ( isset( $_POST['prioridad'] ) ? wp_unslash( $_POST['prioridad'] ) : 50 ), // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. 'max_enlaces' => (int) ( isset( $_POST['max_enlaces'] ) ? wp_unslash( $_POST['max_enlaces'] ) : 1 ), // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. 'idioma' => sanitize_key( wp_unslash( $_POST['idioma'] ?? '' ) ), 'lang_variantes' => $lang_variantes_raw, 'activo' => 1, ); if ( ! $data['nombre'] ) wp_send_json_error( 'La keyword es obligatoria' ); if ( $id ) { $reglas->update( $id, $data ); $regla = $reglas->get( $id ); } else { $new_id = $reglas->insert( $data ); $regla = $reglas->get( $new_id ); } // Trigger full reprocess of all posts in priority order. // Reset the batch tracker so the next batch run starts from scratch. delete_option( 'flink_batch_reviewed' ); update_option( 'flink_needs_processing', 1, false ); // Immediately process synchronously for small sites (≤ 300 posts) $this->maybe_reprocess_now(); $all_pilares = $pilares->get_all(); $admin = new FLINK_Admin(); wp_send_json_success( array( 'html' => $admin->render_regla_item( $regla, $all_pilares ), 'id' => $regla->id, 'needs_process' => (int) get_option( 'flink_needs_processing', 0 ), ) ); } public function delete_regla() { check_ajax_referer( 'flink_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Sin permisos' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $id = (int) ( isset( $_POST['id'] ) ? wp_unslash( $_POST['id'] ) : 0 ); $reglas = new FLINK_Reglas(); $regla = $reglas->get( $id ); if ( $regla ) { // Strip links created by this rule BEFORE deleting it $linker = new FLINK_Linker(); $linker->init_for_batch(); $linker->strip_regla_from_all_posts( $id ); } $reglas->delete( $id ); // After deletion, reprocess affected posts so other rules still apply delete_option( 'flink_batch_reviewed' ); update_option( 'flink_needs_processing', 1, false ); $this->maybe_reprocess_now(); wp_send_json_success( array( 'needs_process' => (int) get_option( 'flink_needs_processing', 0 ), ) ); } public function toggle_regla() { check_ajax_referer( 'flink_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Sin permisos' ); global $wpdb; // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $id = (int) ( isset( $_POST['id'] ) ? wp_unslash( $_POST['id'] ) : 0 ); $t = $wpdb->prefix . 'il_reglas'; $curr = (int) $wpdb->get_var( $wpdb->prepare( "SELECT activo FROM $t WHERE id = %d", $id ) ); $new = $curr ? 0 : 1; $wpdb->update( $t, array( 'activo' => $new ), array( 'id' => $id ) ); $linker = new FLINK_Linker(); $linker->init_for_batch(); if ( ! $new ) { // ── DESACTIVAR: solo eliminar los enlaces de ESTA regla ────── // No tocar ningún otro enlace de otras reglas activas. // strip_regla_from_all_posts identifica los posts que tienen // enlaces a la URL destino de esta regla y solo elimina esos. $linker->strip_regla_from_all_posts( $id ); // NO llamamos a maybe_reprocess_now() — hacerlo provocaría // un clean-slate completo que destruiría los enlaces de las // otras reglas activas si la reinserción falla por cualquier // motivo (timeout, post vacío, idioma no coincidente, etc.) } else { // ── ACTIVAR: insertar los enlaces de esta regla ────────────── // Usamos un método quirúrgico que añade sin borrar lo existente. $linker->apply_single_regla_to_all_posts( $id ); } delete_option( 'flink_batch_reviewed' ); update_option( 'flink_needs_processing', 0, false ); wp_send_json_success( array( 'activo' => $new, 'needs_process' => 0, ) ); } public function get_pilares_list() { if ( ! check_ajax_referer( 'flink_nonce', 'nonce', false ) ) wp_send_json_error( 'Nonce inválido' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Sin permisos' ); $list = ( new FLINK_Pilares() )->get_all( array( 'activo' => 1 ) ); wp_send_json_success( array_map( function( $p ) { return array( 'id' => (int) $p->id, 'nombre' => $p->nombre ); }, $list ) ); } // ── Pending posts processing ───────────────────────────────── public function count_links_for_pilar() { check_ajax_referer( 'flink_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Sin permisos' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $pilar_id = (int) ( isset( $_POST['pilar_id'] ) ? wp_unslash( $_POST['pilar_id'] ) : 0 ); $pilar = ( new FLINK_Pilares() )->get( $pilar_id ); if ( ! $pilar ) wp_send_json_error( 'Pilar no encontrado' ); $count = FLINK_Stats::count_real_links_for_pilar( $pilar ); wp_send_json_success( array( 'count' => $count, 'pilar_id' => $pilar_id ) ); } public function count_pending() { check_ajax_referer( 'flink_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Sin permisos' ); $needs = (int) get_option( 'flink_needs_processing', 0 ); wp_send_json_success( array( 'pending' => $needs, 'total' => 0, ) ); } /** * Processes a batch of 10 pending posts. * Always fetches the FIRST 10 unprocessed posts (no offset needed). * After processing, those posts will contain pilar links so they * won't appear in the next query. This avoids offset drift bugs. */ public function process_batch() { check_ajax_referer( 'flink_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Sin permisos' ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Long-running batch operations need to extend the execution window; we set a conservative cap, not unlimited. @set_time_limit( 120 ); global $wpdb; $batch_size = 10; $origen_tipo = get_option( 'flink_origen_tipo', 'any' ); // Post-type filter: build a placeholder list + the values array, // so the types are bound through prepare() rather than concatenated. if ( $origen_tipo !== 'any' ) { $types = array_map( 'sanitize_key', explode( ',', $origen_tipo ) ); } else { $types = array( 'post', 'page' ); } $type_placeholders = implode( ', ', array_fill( 0, count( $types ), '%s' ) ); // Load the set of post IDs already reviewed in previous batches. // We store this in an option so it resets naturally if left idle. $reviewed = get_option( 'flink_batch_reviewed', array() ); if ( ! is_array( $reviewed ) ) $reviewed = array(); $reviewed = array_map( 'intval', $reviewed ); // Exclude already-reviewed posts. Build a placeholder list for the // IDs too; the values are bound via prepare() alongside the types. $reviewed_clause = ''; $reviewed_vals = array(); if ( ! empty( $reviewed ) ) { $id_placeholders = implode( ', ', array_fill( 0, count( $reviewed ), '%d' ) ); $reviewed_clause = "AND ID NOT IN ({$id_placeholders})"; $reviewed_vals = $reviewed; } // Count total eligible posts (never changes during the run). // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only %s/%d placeholder tokens are interpolated; values are bound via prepare(). $total_eligible = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ({$type_placeholders})", $types ) ); // Count how many are still unreviewed. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only %s/%d placeholder tokens are interpolated; values are bound via prepare(). $remaining_before = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ({$type_placeholders}) {$reviewed_clause}", array_merge( $types, $reviewed_vals ) ) ); if ( $remaining_before === 0 ) { // All done — clear the tracking option delete_option( 'flink_batch_reviewed' ); wp_send_json_success( array( 'processed' => 0, 'remaining_before' => 0, 'remaining_after' => 0, 'is_done' => true, ) ); } // Fetch next batch. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only %s/%d placeholder tokens are interpolated; values are bound via prepare(). $posts = $wpdb->get_results( $wpdb->prepare( "SELECT ID, post_content, post_type FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ({$type_placeholders}) {$reviewed_clause} ORDER BY ID ASC LIMIT %d", array_merge( $types, $reviewed_vals, array( $batch_size ) ) ) ); if ( empty( $posts ) ) { delete_option( 'flink_batch_reviewed' ); wp_send_json_success( array( 'processed' => 0, 'remaining_before' => 0, 'remaining_after' => 0, 'is_done' => true, ) ); } $linker = new FLINK_Linker(); $linker->init_for_batch(); $processed = 0; $linked = 0; foreach ( $posts as $post ) { // Mark as reviewed regardless of outcome $reviewed[] = (int) $post->ID; if ( FLINK_Exclusiones::is_post_excluded( $post->ID ) ) { $processed++; continue; } $new_content = $linker->apply_links( $post->post_content, $post->ID ); if ( $new_content !== $post->post_content ) { $wpdb->update( $wpdb->posts, array( 'post_content' => $new_content ), array( 'ID' => $post->ID ) ); clean_post_cache( $post->ID ); // Update the link-map cache so the "Mapa de enlaces" page // reflects what we just inserted. Without this, the table // wp_il_link_stats stays empty even after bulk processing // — the user sees zeros despite working links in the HTML. if ( class_exists( 'FLINK_Link_Stats' ) ) { FLINK_Link_Stats::update_post( $post->ID ); } $linked++; } $processed++; } // Persist the reviewed IDs for next batch call // Keep only unique IDs and cap at a reasonable size $reviewed = array_unique( $reviewed ); update_option( 'flink_batch_reviewed', $reviewed, false ); $remaining_after = $remaining_before - $processed; if ( $remaining_after < 0 ) $remaining_after = 0; $is_done = ( $remaining_after === 0 ); if ( $is_done ) { delete_option( 'flink_batch_reviewed' ); update_option( 'flink_needs_processing', 0, false ); } wp_send_json_success( array( 'processed' => $processed, 'linked' => $linked, 'inserted' => $linked, 'remaining_before' => $remaining_before, 'remaining_after' => $remaining_after, 'is_done' => $is_done, ) ); } /** * Reprocessa todos los posts para una regla concreta. * Elimina los enlaces existentes al pilar y los reinserta * con las keywords y sinónimos actuales. Así, sinónimos * eliminados dejan de generar enlaces correctamente. */ public function reprocess_regla() { check_ajax_referer( 'flink_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Sin permisos' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $regla_id = (int) ( isset( $_POST['regla_id'] ) ? wp_unslash( $_POST['regla_id'] ) : 0 ); if ( ! $regla_id ) wp_send_json_error( 'ID de regla no válido' ); $regla = ( new FLINK_Reglas() )->get( $regla_id ); if ( ! $regla ) wp_send_json_error( 'Regla no encontrada' ); // Determinar URL destino de la regla $url = ''; if ( ! empty( $regla->destino_valor ) ) { $url = esc_url_raw( $regla->destino_valor ); } elseif ( ! empty( $regla->pilar_id ) ) { $pilar = ( new FLINK_Pilares() )->get( (int) $regla->pilar_id ); if ( $pilar ) $url = esc_url_raw( $pilar->url ); } if ( ! $url ) { wp_send_json_error( 'No se pudo determinar la URL del pilar para esta regla' ); } $linker = new FLINK_Linker(); $linker->init_for_batch(); $updated = $linker->reprocess_posts_for_url( $url ); wp_send_json_success( array( 'updated' => $updated, 'url' => $url, ) ); } /** * NUEVA REGLA: aplica una regla específica a todos los posts. * Llamado desde JS cuando se crea una regla nueva y el usuario * confirma que quiere procesar los posts. */ public function apply_new_regla() { check_ajax_referer( 'flink_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Sin permisos' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $regla_id = (int) ( isset( $_POST['regla_id'] ) ? wp_unslash( $_POST['regla_id'] ) : 0 ); if ( ! $regla_id ) wp_send_json_error( 'ID de regla no válido' ); $linker = new FLINK_Linker(); $linker->init_for_batch(); $updated = $linker->apply_regla_to_all_posts( $regla_id ); if ( $updated > 0 ) { update_option( 'flink_needs_processing', 0, false ); } wp_send_json_success( array( 'updated' => $updated ) ); } /** * Elimina solo los enlaces de una regla sin reinsertarlos. * Usado al desactivar o eliminar una regla. */ public function strip_regla() { check_ajax_referer( 'flink_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Sin permisos' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $regla_id = (int) ( isset( $_POST['regla_id'] ) ? wp_unslash( $_POST['regla_id'] ) : 0 ); if ( ! $regla_id ) wp_send_json_error( 'ID de regla no válido' ); $reglas_obj = new FLINK_Reglas(); $pilares = new FLINK_Pilares(); $regla = $reglas_obj->get( $regla_id ); if ( ! $regla ) wp_send_json_error( 'Regla no encontrada' ); $linker = new FLINK_Linker(); $linker->init_for_batch(); $updated = 0; // Collect ALL destination URLs: main + all language variants $urls_to_strip = array(); // Main rule URL if ( ! empty( $regla->destino_valor ) ) { $urls_to_strip[] = esc_url_raw( $regla->destino_valor ); } elseif ( ! empty( $regla->pilar_id ) ) { $pilar = $pilares->get( (int) $regla->pilar_id ); if ( $pilar ) $urls_to_strip[] = esc_url_raw( $pilar->url ); } // Variant URLs $variantes = $reglas_obj->get_variantes( $regla ); foreach ( $variantes as $lang => $v ) { if ( ! empty( $v['url'] ) ) { $urls_to_strip[] = esc_url_raw( $v['url'] ); } elseif ( ! empty( $v['pilar_id'] ) ) { $pilar = $pilares->get( (int) $v['pilar_id'] ); if ( $pilar ) $urls_to_strip[] = esc_url_raw( $pilar->url ); } } // Strip each URL foreach ( array_unique( array_filter( $urls_to_strip ) ) as $url ) { $updated += $linker->strip_links_only( $url ); } wp_send_json_success( array( 'updated' => $updated ) ); } /** * Strip all links for a regla by regla_id — works even if regla is being deleted. * Also handles the delete operation if requested. */ public function strip_and_delete_regla() { check_ajax_referer( 'flink_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Sin permisos' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $regla_id = (int) ( isset( $_POST['regla_id'] ) ? wp_unslash( $_POST['regla_id'] ) : 0 ); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $do_delete = (int) ( isset( $_POST['do_delete'] ) ? wp_unslash( $_POST['do_delete'] ) : 0 ); if ( ! $regla_id ) wp_send_json_error( 'ID de regla no válido' ); $linker = new FLINK_Linker(); $linker->init_for_batch(); $updated = $linker->strip_regla_from_all_posts( $regla_id ); if ( $do_delete ) { ( new FLINK_Reglas() )->delete( $regla_id ); } wp_send_json_success( array( 'updated' => $updated, 'deleted' => (bool) $do_delete ) ); } /** * Bulk activate/deactivate a set of rules. * * For each selected rule: * - If activating, insert its links (apply_single_regla_to_all_posts). * - If deactivating, strip its links (strip_regla_from_all_posts). * * Targeted sweep: only posts that contain (or will contain) links of the * affected rules are touched. Links of other active rules are preserved. */ public function bulk_toggle_reglas() { check_ajax_referer( 'flink_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Sin permisos' ); // IDs arrive as either an array or a comma-separated string. // map_deep sanitises every leaf (array or string), satisfying // Plugin Check at the unslash site. We then intval each entry. if ( isset( $_POST['ids'] ) ) { $ids_raw = map_deep( wp_unslash( $_POST['ids'] ), 'sanitize_text_field' ); if ( is_string( $ids_raw ) ) { $ids_raw = explode( ',', $ids_raw ); } } else { $ids_raw = array(); } $ids = array_values( array_filter( array_map( 'intval', (array) $ids_raw ) ) ); if ( empty( $ids ) ) wp_send_json_error( 'No se han seleccionado reglas' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $activo = (int) (bool) ( isset( $_POST['activo'] ) ? wp_unslash( $_POST['activo'] ) : 0 ); global $wpdb; $t = $wpdb->prefix . 'il_reglas'; $linker = new FLINK_Linker(); $linker->init_for_batch(); $processed = 0; $posts_total = 0; $errors = array(); foreach ( $ids as $id ) { $wpdb->update( $t, array( 'activo' => $activo ), array( 'id' => $id ) ); if ( $activo ) { $posts_total += (int) $linker->apply_single_regla_to_all_posts( $id ); } else { $posts_total += (int) $linker->strip_regla_from_all_posts( $id ); } $processed++; } wp_send_json_success( array( 'processed' => $processed, 'posts_total' => $posts_total, 'activo' => $activo, 'errors' => $errors, ) ); } /** * Bulk delete a set of rules. * * Flow (targeted, conservative): * 1. For each rule, strip its links from the posts that contain them. * 2. Delete the rule from the DB. * 3. Do NOT run a global "reprocess everything" — other active rules' * links in posts that weren't affected remain intact. If the user * wants to re-evaluate freed keywords on affected posts, they can * click "Aplicar enlaces" (the existing site-wide button). */ public function bulk_delete_reglas() { check_ajax_referer( 'flink_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Sin permisos' ); // IDs arrive as either an array or a comma-separated string. // map_deep sanitises every leaf (array or string), satisfying // Plugin Check at the unslash site. We then intval each entry. if ( isset( $_POST['ids'] ) ) { $ids_raw = map_deep( wp_unslash( $_POST['ids'] ), 'sanitize_text_field' ); if ( is_string( $ids_raw ) ) { $ids_raw = explode( ',', $ids_raw ); } } else { $ids_raw = array(); } $ids = array_values( array_filter( array_map( 'intval', (array) $ids_raw ) ) ); if ( empty( $ids ) ) wp_send_json_error( 'No se han seleccionado reglas' ); // When keep_links is set, the rules are removed but the links // they already inserted into posts are preserved. Otherwise the // links are stripped first (default behaviour). // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified above. Boolean flag, cast with intval. $keep_links = ! empty( $_POST['keep_links'] ) && (int) wp_unslash( $_POST['keep_links'] ) === 1; $linker = new FLINK_Linker(); $linker->init_for_batch(); $reglas = new FLINK_Reglas(); $deleted = 0; $posts_total = 0; foreach ( $ids as $id ) { if ( ! $keep_links ) { $posts_total += (int) $linker->strip_regla_from_all_posts( $id ); } $reglas->delete( $id ); $deleted++; } wp_send_json_success( array( 'deleted' => $deleted, 'posts_total' => $posts_total, 'kept_links' => $keep_links, ) ); } public function get_pilar_link_detail() { check_ajax_referer( 'flink_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Sin permisos' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $pilar_id = (int) ( isset( $_POST['pilar_id'] ) ? wp_unslash( $_POST['pilar_id'] ) : 0 ); $pilar = ( new FLINK_Pilares() )->get( $pilar_id ); if ( ! $pilar ) wp_send_json_error( 'Pilar no encontrado' ); $detail = FLINK_Stats::get_link_detail_for_pilar( $pilar ); wp_send_json_success( array( 'detail' => $detail, 'pilar_nombre'=> $pilar->nombre, 'pilar_url' => $pilar->url, 'total' => array_sum( array_column( $detail, 'count' ) ), ) ); } public function clear_settings_transient() { check_ajax_referer( 'flink_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Sin permisos' ); delete_transient( 'flink_settings_just_changed' ); wp_send_json_success(); } public function reset_batch() { check_ajax_referer( 'flink_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Sin permisos' ); delete_option( 'flink_batch_reviewed' ); update_option( 'flink_needs_processing', 1, false ); wp_send_json_success(); } public function get_regla() { check_ajax_referer( 'flink_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Sin permisos' ); $reglas = new FLINK_Reglas(); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $regla = $reglas->get( (int) ( isset( $_POST['id'] ) ? wp_unslash( $_POST['id'] ) : 0 ) ); if ( ! $regla ) wp_send_json_error( 'Regla no encontrada' ); // Attach decoded variantes so JS can populate the tabs $regla->variantes_decoded = $reglas->get_variantes( $regla ); wp_send_json_success( $regla ); } // ── ESTADÍSTICAS REALES ─────────────────────────────────────── public function refresh_stats() { check_ajax_referer( 'flink_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Sin permisos' ); $pilares = ( new FLINK_Pilares() )->get_all( array( 'activo' => 1 ) ); $result = array(); foreach ( $pilares as $p ) { $result[ $p->id ] = FLINK_Stats::count_real_links_for_pilar( $p ); } wp_send_json_success( $result ); } /** * Get link detail for a specific rule — shows which posts have links from this rule. */ public function get_rule_link_detail() { check_ajax_referer( 'flink_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Sin permisos' ); global $wpdb; // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $rule_id = (int) ( isset( $_POST['rule_id'] ) ? wp_unslash( $_POST['rule_id'] ) : 0 ); $reglas = new FLINK_Reglas(); $regla = $reglas->get( $rule_id ); if ( ! $regla ) wp_send_json_error( 'Regla no encontrada' ); // Get destination URL $dest_url = ''; if ( ! empty( $regla->destino_valor ) ) { $dest_url = $regla->destino_valor; } elseif ( ! empty( $regla->pilar_id ) ) { $pilar = ( new FLINK_Pilares() )->get( (int) $regla->pilar_id ); if ( $pilar ) $dest_url = $pilar->url; } // Get keywords $keywords = array( $regla->nombre ); if ( ! empty( $regla->descripcion ) ) { $syns = array_filter( array_map( 'trim', explode( "\n", $regla->descripcion ) ) ); $keywords = array_merge( $keywords, $syns ); } $keywords = array_unique( array_filter( $keywords ) ); if ( empty( $dest_url ) || empty( $keywords ) ) { wp_send_json_success( array( 'posts' => array(), 'total' => 0, 'keyword' => $regla->nombre, 'url' => $dest_url ) ); } // Find posts with links $like_il = '%' . $wpdb->esc_like( 'data-il="1"' ) . '%'; $like_url = '%' . $wpdb->esc_like( esc_url_raw( $dest_url ) ) . '%'; $posts = $wpdb->get_results( $wpdb->prepare( "SELECT ID, post_title, post_type, post_content FROM {$wpdb->posts} WHERE post_status IN ('publish','draft','pending') AND post_content LIKE %s AND post_content LIKE %s", $like_il, $like_url ) ); $result = array(); $total = 0; $url_esc = esc_url_raw( $dest_url ); $url_trail = trailingslashit( $url_esc ); $url_alts_re = implode( '|', array_unique( array_map( function($u){ return preg_quote($u, '/'); }, array( $url_esc, $url_trail ) ) ) ); foreach ( $posts as $post ) { $post_count = 0; $anchors = array(); foreach ( $keywords as $kw ) { $kw_esc = preg_quote( trim( $kw ), '/' ); // Single pattern matching both URL forms at once to avoid double-counting $pat = '/]*\bdata-il=["\']1["\'])(?=[^>]*\bhref=["\'](?:' . $url_alts_re . ')["\'])[^>]*>\s*' . $kw_esc . '\s*<\/a>/iu'; $count = preg_match_all( $pat, $post->post_content ); if ( $count > 0 ) { $anchors[] = array( 'anchor' => $kw, 'count' => $count ); $post_count += $count; } } if ( $post_count > 0 ) { $result[] = array( 'post_id' => $post->ID, 'title' => $post->post_title, 'type' => ( $post->post_type === 'page' ? 'page' : 'post' ), 'url' => get_permalink( $post->ID ), 'count' => $post_count, 'anchors' => $anchors, ); $total += $post_count; } } wp_send_json_success( array( 'posts' => $result, 'total' => $total, 'keyword' => $regla->nombre, 'url' => $dest_url, ) ); } /** * Import rules from a CSV file. */ public function import_rules_csv() { if ( ! check_ajax_referer( 'flink_nonce', 'nonce', false ) ) { wp_send_json_error( 'La sesión ha caducado. Recarga la página e inténtalo de nuevo.' ); } if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Sin permisos' ); if ( ! isset( $_FILES['csv_file']['error'], $_FILES['csv_file']['tmp_name'] ) || UPLOAD_ERR_OK !== (int) $_FILES['csv_file']['error'] ) { wp_send_json_error( 'No se recibió ningún archivo CSV.' ); } // Reading the freshly uploaded CSV from its server-controlled tmp path. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Server-controlled tmp_name; WP_Filesystem is unnecessary indirection here. $raw = file_get_contents( sanitize_text_field( wp_unslash( $_FILES['csv_file']['tmp_name'] ) ) ); $mode = isset( $_POST['csv_mode'] ) ? sanitize_key( wp_unslash( $_POST['csv_mode'] ) ) : 'merge'; // Strip UTF-8 BOM $bom = pack( 'H*', 'EFBBBF' ); if ( substr( $raw, 0, 3 ) === $bom ) { $raw = substr( $raw, 3 ); } // Normalize line endings $raw = str_replace( array( "\r\n", "\r" ), "\n", $raw ); $raw = trim( $raw ); if ( $raw === '' ) { wp_send_json_error( 'El archivo CSV está vacío.' ); } // Detect delimiter from first line (comma vs semicolon) $pos = strpos( $raw, "\n" ); $first_line = $pos !== false ? substr( $raw, 0, $pos ) : $raw; $delim = ( substr_count( $first_line, ';' ) >= substr_count( $first_line, ',' ) ) ? ';' : ','; // Parse all lines (str_getcsv handles quoted multi-line cells correctly // when we feed it one line at a time; for simplicity we split first) $lines = array_values( array_filter( explode( "\n", $raw ), function( $l ) { return trim( $l ) !== ''; } ) ); if ( empty( $lines ) ) { wp_send_json_error( 'El archivo CSV está vacío.' ); } // Header $header_line = array_shift( $lines ); $header = array_map( 'trim', array_map( 'strtolower', str_getcsv( $header_line, $delim, '"', '' ) ) ); if ( ! in_array( 'keyword', $header, true ) ) { wp_send_json_error( 'Columna obligatoria faltante: keyword. Cabecera detectada: ' . implode( ', ', $header ) ); } global $wpdb; $table = $wpdb->prefix . 'il_reglas'; $reglas = new FLINK_Reglas(); $pilares = new FLINK_Pilares(); // Build two lookup maps: by normalised URL and by lowercase name $pilar_by_url = array(); $pilar_by_name = array(); foreach ( $pilares->get_all() as $p ) { $pilar_by_url[ untrailingslashit( $p->url ) ] = (int) $p->id; $pilar_by_name[ strtolower( trim( $p->nombre ) ) ] = (int) $p->id; } if ( $mode === 'replace' ) { $wpdb->query( "DELETE FROM {$table}" ); } // Whitelist of supported language codes (empty = all languages) $allowed_langs = array( '', 'es', 'en', 'fr', 'de', 'it', 'pt', 'ca', 'nl', 'pl', 'ru', 'ar', 'zh', 'ja' ); $imported = 0; $updated = 0; $skipped = 0; $errors = array(); foreach ( $lines as $ln => $line ) { $line_num = $ln + 2; // +1 for header, +1 for 1-based $line = trim( $line ); if ( $line === '' ) continue; $cols = str_getcsv( $line, $delim, '"', '' ); $row = array(); foreach ( $header as $i => $col ) { $row[ $col ] = isset( $cols[ $i ] ) ? trim( $cols[ $i ] ) : ''; } $keyword = sanitize_text_field( $row['keyword'] ?? '' ); if ( $keyword === '' ) { $skipped++; $errors[] = "Fila {$line_num}: sin keyword — omitida."; continue; } // Destination resolution: url_destino OR pilar_nombre, at least one $url_destino = esc_url_raw( $row['url_destino'] ?? '' ); $pilar_nombre = trim( $row['pilar_nombre'] ?? '' ); $pilar_id = null; if ( $url_destino !== '' ) { // Direct URL wins — try to also match an existing pilar by URL $lookup = untrailingslashit( $url_destino ); if ( isset( $pilar_by_url[ $lookup ] ) ) { $pilar_id = $pilar_by_url[ $lookup ]; $url_destino = ''; // becomes a pilar-backed rule } } elseif ( $pilar_nombre !== '' ) { $key = strtolower( $pilar_nombre ); if ( isset( $pilar_by_name[ $key ] ) ) { $pilar_id = $pilar_by_name[ $key ]; } else { $skipped++; $errors[] = "Fila {$line_num} (\"{$keyword}\"): pilar \"{$pilar_nombre}\" no encontrado — omitida."; continue; } } else { $skipped++; $errors[] = "Fila {$line_num} (\"{$keyword}\"): sin url_destino ni pilar_nombre — omitida."; continue; } // Synonyms: | → newline $sinonimos = isset( $row['sinonimos'] ) ? str_replace( '|', "\n", $row['sinonimos'] ) : ''; // Language normalisation $idioma = sanitize_key( strtolower( $row['idioma'] ?? '' ) ); if ( ! in_array( $idioma, $allowed_langs, true ) ) { $errors[] = "Fila {$line_num} (\"{$keyword}\"): idioma \"{$idioma}\" no soportado — se usará en todos los idiomas."; $idioma = ''; } // Anchor text (optional override); fall back to keyword $anchor_text = isset( $row['anchor_text'] ) ? sanitize_text_field( $row['anchor_text'] ) : ''; if ( $anchor_text === '' ) $anchor_text = $keyword; // lang_variantes (JSON in a single cell, optional) $lang_variantes = null; if ( ! empty( $row['lang_variantes'] ) ) { $decoded = json_decode( $row['lang_variantes'], true ); if ( is_array( $decoded ) ) { $lang_variantes = wp_json_encode( $decoded ); } else { $errors[] = "Fila {$line_num} (\"{$keyword}\"): lang_variantes no es un JSON válido — ignorado."; } } $data = array( 'nombre' => $keyword, 'descripcion' => sanitize_textarea_field( $sinonimos ), 'origen_tipo' => get_option( 'flink_origen_tipo', 'any' ), 'origen_valor' => '', 'destino_tipo' => $pilar_id ? 'pilar' : 'url', 'pilar_id' => $pilar_id, 'destino_valor' => $pilar_id ? '' : $url_destino, 'anchor_text' => $anchor_text, 'idioma' => $idioma, 'prioridad' => max( 1, min( 100, (int) ( $row['prioridad'] ?? 50 ) ) ), 'max_enlaces' => max( 1, min( 20, (int) ( $row['max_enlaces'] ?? 1 ) ) ), 'activo' => isset( $row['activo'] ) ? (int) (bool) $row['activo'] : 1, 'lang_variantes'=> $lang_variantes, ); if ( $mode === 'merge' ) { $existing = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$table} WHERE nombre = %s LIMIT 1", $keyword ) ); if ( $existing ) { $reglas->update( (int) $existing, $data ); $updated++; continue; } } $reglas->insert( $data ); $imported++; } $total = $imported + $updated; $msg = $total . ' regla' . ( $total !== 1 ? 's' : '' ) . ' procesada' . ( $total !== 1 ? 's' : '' ); $msg .= ' (' . $imported . ' nueva' . ( $imported !== 1 ? 's' : '' ); if ( $updated ) $msg .= ', ' . $updated . ' actualizada' . ( $updated !== 1 ? 's' : '' ); $msg .= ')'; if ( $skipped ) { $msg .= ', ' . $skipped . ' omitida' . ( $skipped !== 1 ? 's' : '' ); } wp_send_json_success( array( 'message' => $msg, 'errors' => $errors, 'imported' => $imported, 'updated' => $updated, 'skipped' => $skipped, ) ); } // FLINK_Export handles the full import/export (pilares, reglas, // exclusiones, image/content templates, KB, pipeline schedules, // settings). It gracefully ignores any section that doesn't exist // in the current edition, so the same handler serves both editions: // a free-edition backup simply has empty premium sections, and a // paid backup restored on the free edition only restores what the // free edition supports. /** * Endpoint principal para subir y procesar un backup JSON. * * Operación en dos fases: * * Fase 1 — preview (?stage=preview): el usuario sube el archivo, * lo validamos y devolvemos los `counts` + metadatos. La UI * los usa para pintar los checkboxes "qué quieres restaurar". * * Fase 2 — import (?stage=import): el usuario ya marcó qué * secciones quiere restaurar. Volvemos a parsear el archivo * (no lo persistimos entre fases por seguridad y simplicidad) * y ejecutamos FLINK_Export::import() con esas secciones. * * Si no se especifica stage, se asume "import" sin secciones * (back-compat con el flujo simple). */ public function import_data() { // Verify the nonce without die()ing on failure, so an expired // nonce returns a readable JSON error ("reload the page") instead // of a bare HTTP 400 that looks like a server crash. if ( ! check_ajax_referer( 'flink_nonce', 'nonce', false ) ) { wp_send_json_error( 'La sesión ha caducado. Recarga la página e inténtalo de nuevo.' ); } if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Sin permisos' ); if ( ! isset( $_FILES['import_file']['error'] ) || UPLOAD_ERR_OK !== (int) $_FILES['import_file']['error'] ) { wp_send_json_error( 'No se recibió ningún archivo o hubo un error al subirlo.' ); } // map_deep sanitises every leaf of the file structure inside the // same expression that unslashes it. We then rebuild a tightly-typed // array with the specific sanitisers each field really needs. $file_raw = isset( $_FILES['import_file'] ) ? map_deep( wp_unslash( $_FILES['import_file'] ), 'sanitize_text_field' ) : array(); $file = array( 'name' => isset( $file_raw['name'] ) ? sanitize_file_name( $file_raw['name'] ) : '', 'type' => isset( $file_raw['type'] ) ? sanitize_mime_type( $file_raw['type'] ) : '', 'tmp_name' => isset( $file_raw['tmp_name'] ) ? $file_raw['tmp_name'] : '', 'error' => isset( $file_raw['error'] ) ? (int) $file_raw['error'] : UPLOAD_ERR_NO_FILE, 'size' => isset( $file_raw['size'] ) ? (int) $file_raw['size'] : 0, ); // Validate file type $ext = strtolower( pathinfo( $file['name'], PATHINFO_EXTENSION ) ); if ( $ext !== 'json' ) { wp_send_json_error( 'El archivo debe ser un .json generado por este plugin.' ); } // Cap raised to 25 MB now that backups can include knowledge // bases with hundreds of fichas. if ( $file['size'] > 25 * 1024 * 1024 ) { wp_send_json_error( 'El archivo es demasiado grande (máximo 25 MB).' ); } $raw = file_get_contents( $file['tmp_name'] ); $data = json_decode( $raw, true ); if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $data ) ) { wp_send_json_error( 'El archivo no es un JSON válido.' ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $stage = isset( $_POST['stage'] ) ? sanitize_key( wp_unslash( $_POST['stage'] ) ) : 'import'; // ── PHASE 1: preview (return what's inside the file) ───── if ( $stage === 'preview' ) { // Use the counts that build() includes, but recompute on // the fly if the file was generated by an older version // that didn't have the 'counts' block. $counts = isset( $data['counts'] ) && is_array( $data['counts'] ) ? $data['counts'] : array( 'pilares' => count( $data['pilares'] ?? array() ), 'reglas' => count( $data['reglas'] ?? array() ), 'exclusiones' => count( $data['exclusiones'] ?? array() ), 'image_templates' => count( $data['image_templates'] ?? array() ), 'content_templates' => count( $data['content_templates'] ?? array() ), 'kb_bases' => count( $data['kb_bases'] ?? array() ), 'kb_entries' => count( $data['kb_entries'] ?? array() ), 'pipeline_schedules' => count( $data['pipeline_schedules'] ?? array() ), 'settings' => count( $data['settings'] ?? array() ), ); wp_send_json_success( array( 'version' => $data['version'] ?? '?', 'exported_at' => $data['exported_at'] ?? '', 'site_url' => $data['site_url'] ?? '', 'counts' => $counts, ) ); } // ── PHASE 2: actual import ─────────────────────────────── // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended -- Nonce verified in caller. $mode = ( isset( $_POST['import_mode'] ) && 'replace' === sanitize_key( wp_unslash( $_POST['import_mode'] ) ) ) ? 'replace' : 'merge'; // Selective sections: comma-separated list from the form. // Empty list = restore everything (back-compat). $sections = array(); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. if ( ! empty( $_POST['sections'] ) ) { $allowed = array( 'pilares', 'reglas', 'exclusiones', 'image_templates', 'content_templates', 'kb_bases', 'kb_entries', 'pipeline_schedules', 'settings', ); // map_deep sanitises every leaf in the same expression that // unslashes it. Sections then go through sanitize_key below. $sections_input = map_deep( wp_unslash( $_POST['sections'] ), 'sanitize_text_field' ); $raw_sections = is_array( $sections_input ) ? $sections_input : explode( ',', (string) $sections_input ); foreach ( $raw_sections as $s ) { $s = sanitize_key( $s ); if ( in_array( $s, $allowed, true ) ) $sections[] = $s; } } $result = FLINK_Export::import( $data, $mode, $sections ); if ( ! empty( $result['errors'] ) ) { wp_send_json_error( implode( ' ', $result['errors'] ) ); } wp_send_json_success( array( 'pilares' => $result['pilares'], 'reglas' => $result['reglas'], 'exclusiones' => $result['exclusiones'], 'image_templates' => $result['image_templates'], 'content_templates' => $result['content_templates'], 'kb_bases' => $result['kb_bases'], 'kb_entries' => $result['kb_entries'], 'pipeline_schedules' => $result['pipeline_schedules'], 'settings' => $result['settings'], 'mode' => $mode, ) ); } // ───────────────────────────────────────────────────────────── // SHARED: Full reprocess for small sites (≤ 300 posts) // ───────────────────────────────────────────────────────────── /** * For sites with ≤ 300 published posts, run the full reprocess synchronously * during the AJAX request so the user sees results immediately. * For larger sites, the batch processor must be used. * * Always runs apply_links() on every post — this does a full clean-slate: * strip all plugin links, then reinsert all active rules in priority order. */ private function maybe_reprocess_now() { global $wpdb; $origen_tipo = get_option( 'flink_origen_tipo', 'any' ); if ( $origen_tipo !== 'any' ) { $types = array_map( 'sanitize_key', explode( ',', $origen_tipo ) ); } else { $types = array( 'post', 'page' ); } $type_placeholders = implode( ', ', array_fill( 0, count( $types ), '%s' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only %s placeholder tokens are interpolated; values are bound via prepare(). $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ({$type_placeholders})", $types ) ); // Only run synchronously for small sites if ( $total > 300 ) return; // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Long-running batch operations need to extend the execution window; we set a conservative cap, not unlimited. @set_time_limit( 120 ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only %s placeholder tokens are interpolated; values are bound via prepare(). $posts = $wpdb->get_results( $wpdb->prepare( "SELECT ID, post_content FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ({$type_placeholders}) ORDER BY ID ASC", $types ) ); $linker = new FLINK_Linker(); $linker->init_for_batch(); foreach ( $posts as $post ) { if ( FLINK_Exclusiones::is_post_excluded( $post->ID ) ) continue; $new_content = $linker->apply_links( $post->post_content, $post->ID ); if ( $new_content !== $post->post_content ) { $wpdb->update( $wpdb->posts, array( 'post_content' => $new_content ), array( 'ID' => $post->ID ) ); clean_post_cache( $post->ID ); } } delete_option( 'flink_batch_reviewed' ); update_option( 'flink_needs_processing', 0, false ); } // ───────────────────────────────────────────────────────────────── // ÉXITOS — Link Stats Dashboard // ───────────────────────────────────────────────────────────────── public function exitos_list() { check_ajax_referer( 'flink_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Sin permisos' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $search = sanitize_text_field( wp_unslash( $_POST['search'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $orderby = sanitize_key( wp_unslash( $_POST['orderby'] ?? 'total' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $order = strtoupper( sanitize_key( wp_unslash( $_POST['order'] ?? 'DESC' ) ) ) === 'ASC' ? 'ASC' : 'DESC'; $rows = FLINK_Link_Stats::get_all( $orderby, $order, $search ); $summary = FLINK_Link_Stats::get_summary(); wp_send_json_success( array( 'rows' => $rows, 'summary' => $summary ) ); } public function exitos_detail() { check_ajax_referer( 'flink_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Sin permisos' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $post_id = (int) ( isset( $_POST['post_id'] ) ? wp_unslash( $_POST['post_id'] ) : 0 ); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $type = sanitize_key( wp_unslash( $_POST['type'] ?? '' ) ); if ( ! $post_id || ! $type ) wp_send_json_error( 'Parámetros inválidos' ); $data = FLINK_Link_Stats::get_detail( $post_id, $type ); wp_send_json_success( array( 'links' => $data, 'post_title' => get_the_title( $post_id ), 'type' => $type, ) ); } public function exitos_recalculate() { check_ajax_referer( 'flink_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Sin permisos' ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Long-running batch operations need to extend the execution window; we set a conservative cap, not unlimited. @set_time_limit( 300 ); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $offset = (int) ( isset( $_POST['offset'] ) ? wp_unslash( $_POST['offset'] ) : 0 ); $result = FLINK_Link_Stats::recalculate_batch( 20, $offset ); wp_send_json_success( $result ); } /** * Autocomplete endpoint for the pillar URL field. * * Returns up to 15 published posts/pages whose title matches the * search term. Each result has the slug-relative URL (path) and * the title, so the JS can show "Title — /path/" suggestions * and insert the path on click. * * Why path-relative and not full URL: the pilar form already * accepts both and normalises later. Path is shorter to display * and easier to read. */ public function search_pages() { check_ajax_referer( 'flink_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Sin permisos' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $term = isset( $_POST['term'] ) ? sanitize_text_field( wp_unslash( $_POST['term'] ) ) : ''; if ( strlen( $term ) < 2 ) { wp_send_json_success( array() ); } // Search both posts and pages. We don't restrict to a // specific post type because pillar pages live anywhere. $query = new WP_Query( array( 'post_type' => array( 'post', 'page' ), 'post_status' => 'publish', 'posts_per_page' => 15, 's' => $term, 'no_found_rows' => true, 'orderby' => 'relevance', ) ); $results = array(); $home = home_url( '/' ); foreach ( $query->posts as $p ) { $full = get_permalink( $p ); // Strip the home base so the field shows "/path/" — this // matches the placeholder the user already sees and keeps // the slug logic in the existing pilares save flow simple. $path = '/' . ltrim( str_replace( $home, '', $full ), '/' ); $results[] = array( 'id' => (int) $p->ID, 'title' => $p->post_title, 'path' => $path, 'type' => $p->post_type, ); } wp_send_json_success( $results ); } }