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_Export { /** * Genera el array completo de exportación. * * Esta versión es un backup REAL de todo lo que el usuario ha * personalizado en el plugin. Lo que se incluye y por qué: * * - pilares, reglas, exclusiones: configuración esencial. * - image_templates: plantillas de prompt de imagen del usuario. * - kb_bases + kb_entries: bases de conocimiento y sus fichas. * Los embeddings (BLOB) NO se exportan: ocupan mucho y se * regeneran solos al re-vectorizar. * - content_templates: plantillas de generación de contenido. * - pipeline_schedules: automatizaciones programadas. * - settings: TODOS los ajustes del plugin que empiezan por * "il_*" o "ilc_*", excepto la API key (seguridad) y unas * cuantas opciones que son cache/transient sin valor para backup. * * Lo que NO se incluye: * - API key de OpenAI (motivo: seguridad). * - link_stats, semantic_links, semantic_embeddings: datos * derivados que se regeneran del propio contenido del sitio. * - content_jobs, translation_jobs, pipeline_runs: colas * transitorias, no aporta backup. */ public static function build() { global $wpdb; $px = $wpdb->prefix; // ── Configuración esencial ─────────────────────────────── $pilares = $wpdb->get_results( "SELECT * FROM {$px}il_pilares ORDER BY id ASC", ARRAY_A ); $reglas = $wpdb->get_results( "SELECT * FROM {$px}il_reglas ORDER BY id ASC", ARRAY_A ); $exclusiones = FLINK_Exclusiones::get_all(); // ── Plantillas de imagen (sólo las del usuario, no las predefinidas) ─ $image_templates = array(); if ( $wpdb->get_var( "SHOW TABLES LIKE '{$px}il_image_templates'" ) === $px . 'il_image_templates' ) { $image_templates = $wpdb->get_results( "SELECT * FROM {$px}il_image_templates WHERE is_predefined = 0 ORDER BY sort_order ASC, id ASC", ARRAY_A ); } // ── Plantillas de contenido ────────────────────────────── $content_templates = array(); if ( $wpdb->get_var( "SHOW TABLES LIKE '{$px}il_content_templates'" ) === $px . 'il_content_templates' ) { $content_templates = $wpdb->get_results( "SELECT * FROM {$px}il_content_templates ORDER BY id ASC", ARRAY_A ); } // ── Bases de conocimiento + fichas ─────────────────────── // Excluimos los embeddings (MEDIUMBLOB) porque pesan mucho // y son regenerables. Marcamos las fichas como pending para // que al importarlas en otro sitio se vuelvan a vectorizar. $kb_bases = array(); $kb_entries = array(); if ( $wpdb->get_var( "SHOW TABLES LIKE '{$px}il_kb_bases'" ) === $px . 'il_kb_bases' ) { $kb_bases = $wpdb->get_results( "SELECT * FROM {$px}il_kb_bases ORDER BY id ASC", ARRAY_A ); } if ( $wpdb->get_var( "SHOW TABLES LIKE '{$px}il_kb_entries'" ) === $px . 'il_kb_entries' ) { $kb_entries = $wpdb->get_results( "SELECT id, base_id, title, content, tags, created_at, updated_at FROM {$px}il_kb_entries ORDER BY id ASC", ARRAY_A ); } // ── Automatizaciones (programaciones) ──────────────────── $pipeline_schedules = array(); if ( $wpdb->get_var( "SHOW TABLES LIKE '{$px}il_pipeline_schedules'" ) === $px . 'il_pipeline_schedules' ) { $pipeline_schedules = $wpdb->get_results( "SELECT * FROM {$px}il_pipeline_schedules ORDER BY id ASC", ARRAY_A ); } // ── Todos los settings il_* y ilc_* ────────────────────── // Lista de bloqueo (settings que NO exportamos): // - il_openai_key: razones obvias de seguridad. // - il_db_version, il_kb_db_version: versiones de schema, // se gestionan al instalar/actualizar el plugin. // - il_*_migrated, il_*_removed: sentinelas de migración // internas; recrearlas en el destino sería peligroso. // - il_cron_token: el destino tendrá su propio token y // copiarlo entre sitios rompe el flujo del cron. // - il_onboarding_hidden: preferencia local del admin. $blocked_settings = array( 'flink_openai_key', 'flink_db_version', 'flink_kb_db_version', 'flink_onboarding_v2_migrated', 'flink_destino_tipo_migrated', 'flink_modo_frontend_removed', 'flink_log_removed', 'flink_cron_token', 'flink_onboarding_hidden', ); // Settings: las opciones del plugin viven hoy con prefijo // `flink_`. Versiones ≤ 4.5.1 las guardaban con `il_` y `ilc_`; // las dos primeras quedan migradas por FLINK_Prefix_Migration, // pero seguimos cubriendo `ilc_` por si alguna instalación // muy antigua aún tuviera options con ese prefijo cuando // ejecuta el export. $opt_rows = $wpdb->get_results( "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE 'flink\_%' OR option_name LIKE 'ilc\_%'", ARRAY_A ); $settings = array(); foreach ( $opt_rows as $row ) { $name = $row['option_name']; if ( in_array( $name, $blocked_settings, true ) ) continue; // Conservamos el valor tal cual; al importar lo pasaremos // por update_option que se encarga de la serialización. $settings[ $name ] = maybe_unserialize( $row['option_value'] ); } return array( 'version' => FLINK_VERSION, 'exported_at' => current_time( 'c' ), 'site_url' => get_site_url(), // Counts up front so the importer can show "this file contains // X pilares, Y reglas..." before the user commits to import. 'counts' => array( 'pilares' => count( $pilares ?: array() ), 'reglas' => count( $reglas ?: array() ), 'exclusiones' => count( $exclusiones ?: array() ), 'image_templates' => count( $image_templates ?: array() ), 'content_templates' => count( $content_templates ?: array() ), 'kb_bases' => count( $kb_bases ?: array() ), 'kb_entries' => count( $kb_entries ?: array() ), 'pipeline_schedules' => count( $pipeline_schedules ?: array() ), 'settings' => count( $settings ), ), 'pilares' => $pilares ?: array(), 'reglas' => $reglas ?: array(), 'exclusiones' => $exclusiones ?: array(), 'image_templates' => $image_templates ?: array(), 'content_templates' => $content_templates ?: array(), 'kb_bases' => $kb_bases ?: array(), 'kb_entries' => $kb_entries ?: array(), 'pipeline_schedules' => $pipeline_schedules ?: array(), 'settings' => $settings, ); } /** * Envía el JSON como descarga al navegador. */ public static function download() { if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Sin permisos' ); check_admin_referer( 'flink_export' ); $data = self::build(); $json = wp_json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE ); $filename = '04-internal-linking-export-' . gmdate( 'Y-m-d' ) . '.json'; header( 'Content-Type: application/json; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="' . $filename . '"' ); header( 'Content-Length: ' . strlen( $json ) ); header( 'Cache-Control: no-cache, no-store, must-revalidate' ); // This is a file-download response (Content-Disposition: attachment), // not HTML printed into a page. $json is produced by wp_json_encode() // and is sent verbatim as the downloaded .json file; HTML-escaping it // would corrupt the file. Output is terminated immediately with exit. // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON file-download payload from wp_json_encode(); must not be HTML-escaped. echo $json; exit; } /** * Importa desde un array decodificado de JSON. * * @param array $data Datos del JSON importado. * @param string $mode 'merge' | 'replace'. Replace borra * las tablas implicadas antes de * importar. Solo afecta a las * secciones realmente seleccionadas. * @param array $sections Lista de claves a restaurar. Si está * vacía, se restaura TODO lo que haya * en el archivo (back-compat). Claves: * - pilares, reglas, exclusiones * - image_templates, content_templates * - kb_bases, kb_entries * - pipeline_schedules * - settings * @return array Estadísticas + errores. */ public static function import( array $data, $mode = 'merge', array $sections = array() ) { global $wpdb; $px = $wpdb->prefix; $result = array( 'pilares' => 0, 'reglas' => 0, 'exclusiones' => 0, 'image_templates' => 0, 'content_templates' => 0, 'kb_bases' => 0, 'kb_entries' => 0, 'pipeline_schedules' => 0, 'settings' => 0, 'errors' => array(), ); // Validación básica: debe haber al menos una sección con datos $any_data = ! empty( $data['pilares'] ) || ! empty( $data['reglas'] ) || ! empty( $data['exclusiones'] ) || ! empty( $data['image_templates'] ) || ! empty( $data['content_templates'] ) || ! empty( $data['kb_bases'] ) || ! empty( $data['pipeline_schedules'] ) || ! empty( $data['settings'] ); if ( ! $any_data ) { $result['errors'][] = 'El archivo no contiene datos reconocibles.'; return $result; } // Si no se pasa lista de secciones, hacemos un import completo // (compatibilidad con cualquier código existente que llame a // import() solo con $data y $mode). if ( empty( $sections ) ) { $sections = array( 'pilares', 'reglas', 'exclusiones', 'image_templates', 'content_templates', 'kb_bases', 'kb_entries', 'pipeline_schedules', 'settings', ); } $do = array_flip( $sections ); // O(1) lookup $t_pilares = $px . 'il_pilares'; $t_reglas = $px . 'il_reglas'; // ── REPLACE MODE: TRUNCATE solo las tablas seleccionadas ── if ( $mode === 'replace' ) { // Reglas dependen de pilares (FK lógica): si vamos a // borrar pilares, también truncamos reglas para evitar // dejar reglas huérfanas apuntando a IDs que ya no existen. if ( isset( $do['pilares'] ) ) { $wpdb->query( "TRUNCATE TABLE {$t_reglas}" ); $wpdb->query( "TRUNCATE TABLE {$t_pilares}" ); } elseif ( isset( $do['reglas'] ) ) { $wpdb->query( "TRUNCATE TABLE {$t_reglas}" ); } if ( isset( $do['exclusiones'] ) ) { update_option( FLINK_Exclusiones::OPTION_KEY, array(), false ); } if ( isset( $do['image_templates'] ) ) { $t = $px . 'il_image_templates'; if ( $wpdb->get_var( "SHOW TABLES LIKE '{$t}'" ) === $t ) { // Solo borramos las del usuario; las predefinidas se conservan. $wpdb->query( "DELETE FROM {$t} WHERE is_predefined = 0" ); } } if ( isset( $do['content_templates'] ) ) { $t = $px . 'il_content_templates'; if ( $wpdb->get_var( "SHOW TABLES LIKE '{$t}'" ) === $t ) { $wpdb->query( "TRUNCATE TABLE {$t}" ); } } // KB bases truncate también truncates entries if ( isset( $do['kb_bases'] ) || isset( $do['kb_entries'] ) ) { $t_kbe = $px . 'il_kb_entries'; $t_kbb = $px . 'il_kb_bases'; if ( isset( $do['kb_bases'] ) ) { if ( $wpdb->get_var( "SHOW TABLES LIKE '{$t_kbe}'" ) === $t_kbe ) { $wpdb->query( "TRUNCATE TABLE {$t_kbe}" ); } if ( $wpdb->get_var( "SHOW TABLES LIKE '{$t_kbb}'" ) === $t_kbb ) { $wpdb->query( "TRUNCATE TABLE {$t_kbb}" ); } } elseif ( isset( $do['kb_entries'] ) ) { if ( $wpdb->get_var( "SHOW TABLES LIKE '{$t_kbe}'" ) === $t_kbe ) { $wpdb->query( "TRUNCATE TABLE {$t_kbe}" ); } } } if ( isset( $do['pipeline_schedules'] ) ) { $t = $px . 'il_pipeline_schedules'; if ( $wpdb->get_var( "SHOW TABLES LIKE '{$t}'" ) === $t ) { $wpdb->query( "TRUNCATE TABLE {$t}" ); } } // Settings replace: borramos todas las opciones del plugin // (prefijo actual flink_ + legacy il_/ilc_) excepto las del // bloqueo (igual que en build()). if ( isset( $do['settings'] ) ) { $blocked = array( 'flink_openai_key', 'flink_db_version', 'flink_kb_db_version', 'flink_onboarding_v2_migrated', 'flink_destino_tipo_migrated', 'flink_modo_frontend_removed', 'flink_log_removed', 'flink_cron_token', 'flink_onboarding_hidden', ); $names = $wpdb->get_col( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE 'flink\_%' OR option_name LIKE 'il\_%' OR option_name LIKE 'ilc\_%'" ); foreach ( $names as $name ) { if ( in_array( $name, $blocked, true ) ) continue; delete_option( $name ); } } } // Map de IDs viejos → nuevos para mantener relaciones FK. $pilar_id_map = array(); $kb_base_id_map = array(); // ── PILARES ────────────────────────────────────────────── if ( isset( $do['pilares'] ) && ! empty( $data['pilares'] ) && is_array( $data['pilares'] ) ) { foreach ( $data['pilares'] as $p ) { if ( empty( $p['nombre'] ) ) continue; $old_id = isset( $p['id'] ) ? (int) $p['id'] : 0; if ( $mode === 'merge' && ! empty( $p['url'] ) ) { $existing = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$t_pilares} WHERE url = %s LIMIT 1", $p['url'] ) ); if ( $existing ) { $pilar_id_map[ $old_id ] = (int) $existing; continue; } } $insert = array( 'nombre' => sanitize_text_field( $p['nombre'] ), 'url' => esc_url_raw( $p['url'] ?? '' ), 'tematica' => sanitize_text_field( $p['tematica'] ?? '' ), 'idioma' => sanitize_key( strtolower( $p['idioma'] ?? '' ) ), 'prioridad'=> max( 1, min( 100, (int) ( $p['prioridad'] ?? 50 ) ) ), 'activo' => (int) ( $p['activo'] ?? 1 ), ); if ( $wpdb->insert( $t_pilares, $insert ) ) { $new_id = (int) $wpdb->insert_id; if ( $old_id ) $pilar_id_map[ $old_id ] = $new_id; $result['pilares']++; } } } // ── REGLAS ─────────────────────────────────────────────── if ( isset( $do['reglas'] ) && ! empty( $data['reglas'] ) && is_array( $data['reglas'] ) ) { foreach ( $data['reglas'] as $r ) { if ( empty( $r['nombre'] ) ) continue; $old_pilar_id = isset( $r['pilar_id'] ) ? (int) $r['pilar_id'] : null; $new_pilar_id = $old_pilar_id && isset( $pilar_id_map[ $old_pilar_id ] ) ? $pilar_id_map[ $old_pilar_id ] : $old_pilar_id; if ( $mode === 'merge' ) { $idioma_for_check = sanitize_key( strtolower( $r['idioma'] ?? '' ) ); $existing = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$t_reglas} WHERE nombre = %s AND pilar_id = %d AND IFNULL(idioma,'') = %s LIMIT 1", $r['nombre'], $new_pilar_id, $idioma_for_check ) ); if ( $existing ) continue; } $insert = array( 'nombre' => sanitize_text_field( $r['nombre'] ), 'descripcion' => sanitize_textarea_field( $r['descripcion'] ?? '' ), 'origen_tipo' => sanitize_key( $r['origen_tipo'] ?? 'any' ), 'origen_valor' => sanitize_text_field( $r['origen_valor'] ?? '' ), 'destino_tipo' => sanitize_key( $r['destino_tipo'] ?? 'pilar' ), 'destino_valor' => esc_url_raw( $r['destino_valor'] ?? '' ), 'pilar_id' => $new_pilar_id ?: null, 'prioridad' => max( 1, min( 100, (int) ( $r['prioridad'] ?? 50 ) ) ), 'max_enlaces' => max( 1, min( 20, (int) ( $r['max_enlaces'] ?? 1 ) ) ), 'anchor_text' => sanitize_text_field( $r['anchor_text'] ?? '' ), 'activo' => (int) ( $r['activo'] ?? 1 ), 'idioma' => sanitize_key( strtolower( $r['idioma'] ?? '' ) ), 'lang_variantes' => ! empty( $r['lang_variantes'] ) ? ( is_string( $r['lang_variantes'] ) ? $r['lang_variantes'] : wp_json_encode( $r['lang_variantes'] ) ) : null, ); if ( $wpdb->insert( $t_reglas, $insert ) ) { $result['reglas']++; } } } // ── EXCLUSIONES ────────────────────────────────────────── if ( isset( $do['exclusiones'] ) && ! empty( $data['exclusiones'] ) && is_array( $data['exclusiones'] ) ) { foreach ( $data['exclusiones'] as $url ) { $added = FLINK_Exclusiones::add( sanitize_text_field( $url ) ); if ( $added ) $result['exclusiones']++; } } // ── PLANTILLAS DE IMAGEN ───────────────────────────────── if ( isset( $do['image_templates'] ) && ! empty( $data['image_templates'] ) && is_array( $data['image_templates'] ) ) { $t = $px . 'il_image_templates'; if ( $wpdb->get_var( "SHOW TABLES LIKE '{$t}'" ) === $t ) { foreach ( $data['image_templates'] as $tpl ) { if ( empty( $tpl['name'] ) ) continue; if ( $mode === 'merge' ) { $existing = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$t} WHERE name = %s LIMIT 1", $tpl['name'] ) ); if ( $existing ) continue; } $insert = array( 'name' => sanitize_text_field( $tpl['name'] ), 'description' => sanitize_textarea_field( $tpl['description'] ?? '' ), 'instructions' => wp_kses_post( $tpl['instructions'] ?? '' ), 'image_filename' => sanitize_file_name( $tpl['image_filename'] ?? '' ), 'is_predefined' => 0, 'sort_order' => (int) ( $tpl['sort_order'] ?? 0 ), ); if ( $wpdb->insert( $t, $insert ) ) { $result['image_templates']++; } } } } // ── PLANTILLAS DE CONTENIDO ────────────────────────────── if ( isset( $do['content_templates'] ) && ! empty( $data['content_templates'] ) && is_array( $data['content_templates'] ) ) { $t = $px . 'il_content_templates'; if ( $wpdb->get_var( "SHOW TABLES LIKE '{$t}'" ) === $t ) { foreach ( $data['content_templates'] as $tpl ) { // Para no asumir el schema exacto (que puede variar // entre versiones), insertamos solo columnas que // sabemos que existen y descartamos id/created_at. $clean = array(); foreach ( $tpl as $k => $v ) { if ( in_array( $k, array( 'id', 'created_at', 'updated_at' ), true ) ) continue; $clean[ $k ] = $v; } if ( ! empty( $clean ) && $wpdb->insert( $t, $clean ) ) { $result['content_templates']++; } } } } // ── BASES DE CONOCIMIENTO ──────────────────────────────── if ( isset( $do['kb_bases'] ) && ! empty( $data['kb_bases'] ) && is_array( $data['kb_bases'] ) ) { $t = $px . 'il_kb_bases'; if ( $wpdb->get_var( "SHOW TABLES LIKE '{$t}'" ) === $t ) { foreach ( $data['kb_bases'] as $b ) { if ( empty( $b['name'] ) ) continue; $old_id = isset( $b['id'] ) ? (int) $b['id'] : 0; if ( $mode === 'merge' ) { $existing = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$t} WHERE name = %s LIMIT 1", $b['name'] ) ); if ( $existing ) { $kb_base_id_map[ $old_id ] = (int) $existing; continue; } } $insert = array( 'name' => sanitize_text_field( $b['name'] ), 'description' => sanitize_textarea_field( $b['description'] ?? '' ), ); if ( $wpdb->insert( $t, $insert ) ) { $new_id = (int) $wpdb->insert_id; if ( $old_id ) $kb_base_id_map[ $old_id ] = $new_id; $result['kb_bases']++; } } } } // ── FICHAS (kb_entries) ────────────────────────────────── // Las fichas necesitan re-vectorizarse en el destino, así que // las insertamos con embedding_status='pending'. El usuario // pulsa "Reprocesar pendientes" en cada base y se vectorizan. if ( isset( $do['kb_entries'] ) && ! empty( $data['kb_entries'] ) && is_array( $data['kb_entries'] ) ) { $t = $px . 'il_kb_entries'; if ( $wpdb->get_var( "SHOW TABLES LIKE '{$t}'" ) === $t ) { foreach ( $data['kb_entries'] as $e ) { if ( empty( $e['title'] ) ) continue; $old_base_id = isset( $e['base_id'] ) ? (int) $e['base_id'] : 0; $new_base_id = isset( $kb_base_id_map[ $old_base_id ] ) ? $kb_base_id_map[ $old_base_id ] : $old_base_id; if ( ! $new_base_id ) continue; // sin base destino válida $insert = array( 'base_id' => $new_base_id, 'title' => sanitize_text_field( $e['title'] ), 'content' => wp_kses_post( $e['content'] ?? '' ), 'tags' => sanitize_text_field( $e['tags'] ?? '' ), 'embedding_status' => 'pending', // forzar reprocesado ); if ( $wpdb->insert( $t, $insert ) ) { $result['kb_entries']++; } } } } // ── AUTOMATIZACIONES (pipeline_schedules) ──────────────── if ( isset( $do['pipeline_schedules'] ) && ! empty( $data['pipeline_schedules'] ) && is_array( $data['pipeline_schedules'] ) ) { $t = $px . 'il_pipeline_schedules'; if ( $wpdb->get_var( "SHOW TABLES LIKE '{$t}'" ) === $t ) { foreach ( $data['pipeline_schedules'] as $s ) { $clean = array(); foreach ( $s as $k => $v ) { if ( in_array( $k, array( 'id', 'created_at', 'updated_at', 'last_run_at', 'next_run_at' ), true ) ) continue; $clean[ $k ] = $v; } if ( ! empty( $clean ) && $wpdb->insert( $t, $clean ) ) { $result['pipeline_schedules']++; } } } } // ── SETTINGS ───────────────────────────────────────────── if ( isset( $do['settings'] ) && ! empty( $data['settings'] ) && is_array( $data['settings'] ) ) { // Misma lista de bloqueo que en build(): nunca importamos // estas opciones aunque vinieran en el archivo. $blocked = array( 'flink_openai_key', 'flink_db_version', 'flink_kb_db_version', 'flink_onboarding_v2_migrated', 'flink_destino_tipo_migrated', 'flink_modo_frontend_removed', 'flink_log_removed', 'flink_cron_token', 'flink_onboarding_hidden', ); foreach ( $data['settings'] as $key => $value ) { $key = sanitize_key( $key ); if ( in_array( $key, $blocked, true ) ) continue; // Accept the current `flink_` prefix plus the legacy // `il_`/`ilc_` prefixes, so backups taken on older // versions still restore their settings. if ( strpos( $key, 'flink_' ) !== 0 && strpos( $key, 'il_' ) !== 0 && strpos( $key, 'ilc_' ) !== 0 ) continue; update_option( $key, $value ); $result['settings']++; } } return $result; } /** * Export all rules as CSV (UTF-8 with BOM for Excel). * * Columns: * keyword — rule name (required) * sinonimos — synonyms separated by | * url_destino — destination URL (takes precedence over pilar_nombre) * pilar_nombre — name of the pilar (used if url_destino is empty) * idioma — language code (es, en, fr, …) or empty for all * anchor_text — optional anchor override * prioridad — 1-100 (default 50) * max_enlaces — 1-20 (default 1) * activo — 1 or 0 * lang_variantes — JSON string with per-language variants (optional) */ public static function download_rules_csv() { check_admin_referer( 'flink_export_rules_csv' ); if ( ! current_user_can( 'manage_options' ) ) wp_die( 'No autorizado' ); global $wpdb; $reglas = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}il_reglas ORDER BY prioridad DESC" ); $pilares = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}il_pilares" ); $pilar_map_url = array(); $pilar_map_name = array(); foreach ( $pilares as $p ) { $pilar_map_url[ (int) $p->id ] = $p->url; $pilar_map_name[ (int) $p->id ] = $p->nombre; } $filename = '04-il-reglas-' . gmdate( 'Y-m-d' ) . '.csv'; header( 'Content-Type: text/csv; charset=UTF-8' ); header( 'Content-Disposition: attachment; filename="' . $filename . '"' ); header( 'Pragma: no-cache' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- 'php://output' is the response output stream for a file download, not a WP_Filesystem file. $out = fopen( 'php://output', 'w' ); // UTF-8 BOM for Excel compatibility (written directly to the output stream). echo "\xEF\xBB\xBF"; fputcsv( $out, array( 'keyword', 'sinonimos', 'url_destino', 'pilar_nombre', 'idioma', 'anchor_text', 'prioridad', 'max_enlaces', 'activo', 'lang_variantes', ), ',', '"', '' ); foreach ( $reglas as $r ) { // Synonyms: join with | so it stays in a single CSV cell $sinonimos = ''; if ( ! empty( $r->descripcion ) ) { $sinonimos = str_replace( array( "\r\n", "\r", "\n" ), '|', trim( $r->descripcion ) ); } // Destination: prefer direct URL, else look up pilar $url_destino = ! empty( $r->destino_valor ) ? $r->destino_valor : ''; $pilar_nombre = ''; if ( empty( $url_destino ) && ! empty( $r->pilar_id ) ) { $pilar_nombre = $pilar_map_name[ (int) $r->pilar_id ] ?? ''; } // lang_variantes — already JSON in DB, keep it raw (one CSV cell) $lang_variantes = ''; if ( ! empty( $r->lang_variantes ) ) { $lang_variantes = is_string( $r->lang_variantes ) ? $r->lang_variantes : wp_json_encode( $r->lang_variantes ); } fputcsv( $out, array( $r->nombre, $sinonimos, $url_destino, $pilar_nombre, $r->idioma ?? '', $r->anchor_text ?? '', (int) $r->prioridad, (int) $r->max_enlaces, (int) $r->activo, $lang_variantes, ), ',', '"', '' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing the 'php://output' stream handle, not a WP_Filesystem file. fclose( $out ); exit; } }