'oficina-transicion-justa', 'colegios' => 'coles-transicion', ]; /* ───────────────────────────────────────────── * PASO 1 — Analizar CSV * ───────────────────────────────────────────── */ /** * Analizar el CSV y devolver términos que necesitan mapeo. * * @param array $file $_FILES['csv_file'] * @return array{ success:bool, message:string, csv_path:string, * unmatched_iniciativas:array, unmatched_lineas:array, * existing_iniciativas:array, existing_lineas:array, * stats:array } */ public static function analyze($file) { if (empty($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) { return ['success' => false, 'message' => __('No se ha subido ningún archivo válido.', 'bloques-actuaciones-import')]; } // Guardar el CSV temporalmente en uploads para el paso 2 $upload_dir = wp_upload_dir(); $tmp_path = trailingslashit($upload_dir['basedir']) . 'bloques-import-' . wp_generate_password(12, false) . '.csv'; if (!move_uploaded_file($file['tmp_name'], $tmp_path)) { return ['success' => false, 'message' => __('No se pudo guardar el archivo temporal.', 'bloques-actuaciones-import')]; } $errors = []; $rows = self::parse_csv($tmp_path, $errors); if ($rows === null) { @unlink($tmp_path); return ['success' => false, 'message' => __('Error al leer el CSV.', 'bloques-actuaciones-import') . ' ' . implode(' ', $errors)]; } // Recoger valores únicos del CSV $csv_iniciativas = []; $csv_lineas = []; foreach ($rows as $row) { $ini = trim(self::val($row, ['Iniciativa'])); if ($ini !== '') { $csv_iniciativas[$ini] = true; } $lin = trim(self::val($row, ['Línea de trabajo', 'Líneas de trabajo', 'Linea de trabajo'])); if ($lin !== '') { foreach (preg_split('/\s*;\s*/', $lin) as $l) { $l = trim($l); if ($l !== '') { $csv_lineas[$l] = true; } } } } $csv_iniciativas = array_keys($csv_iniciativas); $csv_lineas = array_keys($csv_lineas); // Términos existentes en WP $existing_iniciativas = self::get_existing_terms('iniciativa'); $existing_lineas = self::get_existing_terms('linea_trabajo'); // Determinar cuáles necesitan mapeo $unmatched_iniciativas = self::find_unmatched($csv_iniciativas, $existing_iniciativas, self::DEFAULT_INICIATIVA_MAP); $unmatched_lineas = self::find_unmatched($csv_lineas, $existing_lineas, []); return [ 'success' => true, 'csv_path' => $tmp_path, 'total_rows' => count($rows), 'unmatched_iniciativas' => $unmatched_iniciativas, 'unmatched_lineas' => $unmatched_lineas, 'existing_iniciativas' => $existing_iniciativas, 'existing_lineas' => $existing_lineas, 'default_map' => self::DEFAULT_INICIATIVA_MAP, ]; } /* ───────────────────────────────────────────── * PASO 2 — Importar con mapeos confirmados * ───────────────────────────────────────────── */ /** * @param string $csv_path Ruta al CSV almacenado en paso 1 * @param array $iniciativa_map csv_value => slug_existente | '__new__' * @param array $linea_map csv_value => slug_existente | '__new__' * @param bool $do_reverse_geocode * @return array{ success:bool, message:string, created:int, updated:int, errors:string[] } */ public static function import($csv_path, array $iniciativa_map, array $linea_map, $do_reverse_geocode = true) { $created = 0; $updated = 0; $errors = []; if (!file_exists($csv_path) || !is_readable($csv_path)) { return self::result(false, __('No se encontró el archivo CSV temporal.', 'bloques-actuaciones-import'), 0, 0, []); } $rows = self::parse_csv($csv_path, $errors); if ($rows === null) { return self::result(false, __('Error al leer el CSV.', 'bloques-actuaciones-import'), 0, 0, $errors); } $total = count($rows); foreach ($rows as $idx => $row) { $title = self::val($row, ['Título/Nombre', 'Título', 'Nombre']); if (trim((string) $title) === '') { $errors[] = sprintf(__('Fila %d: título vacío, omitida.', 'bloques-actuaciones-import'), $idx + 2); continue; } $result = self::get_or_create($title); if (!$result) { $errors[] = sprintf(__('Fila %d: no se pudo crear/obtener "%s".', 'bloques-actuaciones-import'), $idx + 2, esc_html($title)); continue; } $post_id = $result['id']; $result['created'] ? $created++ : $updated++; $desc = self::val($row, ['Descripción', 'Descripcion']); wp_update_post([ 'ID' => $post_id, 'post_title' => $title, 'post_content' => $desc, 'post_excerpt' => wp_trim_words(wp_strip_all_tags($desc), 30), 'post_status' => 'publish', 'post_type' => 'actuacion', ]); // Iniciativa $csv_ini = trim(self::val($row, ['Iniciativa'])); if ($csv_ini !== '') { self::assign_term($post_id, 'iniciativa', $csv_ini, $iniciativa_map); } // Líneas de trabajo $csv_lin = trim(self::val($row, ['Línea de trabajo', 'Líneas de trabajo', 'Linea de trabajo'])); if ($csv_lin !== '') { $lineas_list = array_filter(array_map('trim', preg_split('/\s*;\s*/', $csv_lin))); $term_ids = []; foreach ($lineas_list as $l) { $tid = self::resolve_term('linea_trabajo', $l, $linea_map); if ($tid) { $term_ids[] = $tid; } } if (!empty($term_ids)) { wp_set_object_terms($post_id, $term_ids, 'linea_trabajo'); } } // Coordenadas $lat_long = self::val($row, ['Lat/Long', 'Lat', 'Long']); self::set_coords($post_id, $lat_long); // Reverse geocode → dirección if ($do_reverse_geocode && $lat_long !== '') { $address = self::reverse_geocode($lat_long); if ($address !== '') { update_post_meta($post_id, 'direccion', $address); if (function_exists('update_field')) { update_field('direccion', $address, $post_id); } } if ($total > 1) { usleep(self::NOMINATIM_DELAY_US); } } } // Limpiar archivo temporal @unlink($csv_path); $msg = sprintf( __('Importación completada: %d creadas, %d actualizadas de %d filas.', 'bloques-actuaciones-import'), $created, $updated, $total ); if (!empty($errors)) { $msg .= ' ' . sprintf(__('%d advertencias.', 'bloques-actuaciones-import'), count($errors)); } return self::result(true, $msg, $created, $updated, $errors); } /* ───────────────────────────────────────────── * Helpers privados * ───────────────────────────────────────────── */ private static function result($ok, $msg, $c, $u, $e) { return ['success' => $ok, 'message' => $msg, 'created' => $c, 'updated' => $u, 'errors' => $e]; } /** * Obtener términos existentes de una taxonomía como [ slug => name ]. */ private static function get_existing_terms($taxonomy) { $terms = get_terms(['taxonomy' => $taxonomy, 'hide_empty' => false]); $result = []; if (!is_wp_error($terms)) { foreach ($terms as $t) { $result[$t->slug] = $t->name; } } return $result; } /** * Determinar qué valores del CSV no coinciden con términos existentes. * Devuelve array de [ csv_value => suggested_slug|'' ]. */ private static function find_unmatched(array $csv_values, array $existing, array $default_map) { $unmatched = []; $existing_lower = []; foreach ($existing as $slug => $name) { $existing_lower[mb_strtolower($name)] = $slug; } foreach ($csv_values as $csv_val) { $key = mb_strtolower(trim($csv_val)); // Coincidencia exacta por nombre if (isset($existing_lower[$key])) { continue; } // Coincidencia exacta por slug $slug_candidate = sanitize_title($csv_val); if (isset($existing[$slug_candidate])) { continue; } // Mapeo por defecto if (isset($default_map[$key]) && isset($existing[$default_map[$key]])) { // Hay sugerencia pero se mostrará al usuario para que confirme $unmatched[$csv_val] = $default_map[$key]; continue; } // Sin coincidencia $unmatched[$csv_val] = ''; } return $unmatched; } /** * Asignar un término de taxonomía a un post, usando el mapa de mapeos. */ private static function assign_term($post_id, $taxonomy, $csv_value, array $map) { $tid = self::resolve_term($taxonomy, $csv_value, $map); if ($tid) { wp_set_object_terms($post_id, [$tid], $taxonomy); } } /** * Resolver un valor CSV a un term_id, creando si procede. */ private static function resolve_term($taxonomy, $csv_value, array $map) { $key = trim($csv_value); if ($key === '') { return null; } // ¿Hay mapeo del usuario? if (isset($map[$key])) { $mapped = $map[$key]; if ($mapped === '__new__') { // Crear nuevo término return self::ensure_term($taxonomy, $key); } // Usar slug existente $term = get_term_by('slug', $mapped, $taxonomy); return $term ? (int) $term->term_id : self::ensure_term($taxonomy, $key); } // Intentar match directo por nombre (case-insensitive) $existing = self::get_existing_terms($taxonomy); foreach ($existing as $slug => $name) { if (mb_strtolower($name) === mb_strtolower($key)) { return (int) get_term_by('slug', $slug, $taxonomy)->term_id; } } // Intentar por slug $slug_try = sanitize_title($key); $term = get_term_by('slug', $slug_try, $taxonomy); if ($term) { return (int) $term->term_id; } // Sin mapeo explícito → crear return self::ensure_term($taxonomy, $key); } private static function ensure_term($taxonomy, $name) { $slug = sanitize_title($name); $term = get_term_by('slug', $slug, $taxonomy); if ($term) { return (int) $term->term_id; } $r = wp_insert_term($name, $taxonomy, ['slug' => $slug]); return is_wp_error($r) ? null : (int) $r['term_id']; } /* ── Post idempotente ── */ private static function get_or_create($title) { global $wpdb; $id = $wpdb->get_var($wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'actuacion' AND post_title = %s AND post_status IN ('publish','draft','pending','private') LIMIT 1", $title )); if ($id) { return ['id' => (int) $id, 'created' => false]; } $id = wp_insert_post(['post_title' => $title, 'post_type' => 'actuacion', 'post_status' => 'publish']); if (!$id || is_wp_error($id)) { return false; } return ['id' => (int) $id, 'created' => true]; } /* ── Coordenadas ── */ private static function set_coords($post_id, $lat_long) { if (!preg_match('/^(-?\d+\.?\d*)\s*,\s*(-?\d+\.?\d*)$/', trim($lat_long), $m)) { return; } $lat = (float) $m[1]; $lng = (float) $m[2]; update_post_meta($post_id, 'latitud', $lat); update_post_meta($post_id, 'longitud', $lng); if (function_exists('update_field')) { update_field('latitud', $lat, $post_id); update_field('longitud', $lng, $post_id); } } /* ── Reverse geocode (Nominatim) ── */ private static function reverse_geocode($lat_long) { if (!preg_match('/^(-?\d+\.?\d*)\s*,\s*(-?\d+\.?\d*)$/', trim($lat_long), $m)) { return ''; } $url = add_query_arg([ 'lat' => (float) $m[1], 'lon' => (float) $m[2], 'format' => 'json', 'addressdetails' => 1, ], 'https://nominatim.openstreetmap.org/reverse'); $resp = wp_remote_get($url, [ 'timeout' => 10, 'user-agent' => self::NOMINATIM_USER_AGENT, 'headers' => ['Accept-Language' => 'es'], ]); if (is_wp_error($resp) || wp_remote_retrieve_response_code($resp) !== 200) { return ''; } $data = json_decode(wp_remote_retrieve_body($resp), true); if (empty($data['address'])) { return $data['display_name'] ?? ''; } $a = $data['address']; $parts = array_filter([ $a['road'] ?? '', $a['house_number'] ?? '', $a['suburb'] ?? $a['neighbourhood'] ?? '', $a['city'] ?? $a['town'] ?? $a['village'] ?? $a['municipality'] ?? '', $a['state'] ?? '', ]); return implode(', ', $parts); } /* ── CSV parser ── */ /** * Detectar el delimitador del CSV analizando la primera línea. * Prueba con ';' y ',' y elige el que produce más columnas. */ private static function detect_delimiter($path) { $handle = fopen($path, 'r'); if (!$handle) { return self::CSV_DELIMITER_FALLBACK; } // Saltar BOM $bom = fread($handle, 3); if ($bom !== "\xEF\xBB\xBF") { rewind($handle); } $first_line = fgets($handle); fclose($handle); if ($first_line === false) { return self::CSV_DELIMITER_FALLBACK; } $candidates = [',', ';', "\t"]; $best = self::CSV_DELIMITER_FALLBACK; $best_count = 0; foreach ($candidates as $delim) { $count = count(str_getcsv($first_line, $delim)); if ($count > $best_count) { $best_count = $count; $best = $delim; } } return $best; } private static function parse_csv($path, &$errors = []) { $handle = fopen($path, 'r'); if (!$handle) { $errors[] = __('No se pudo abrir el archivo.', 'bloques-actuaciones-import'); return null; } // Saltar BOM $bom = fread($handle, 3); if ($bom !== "\xEF\xBB\xBF") { rewind($handle); } // Auto-detectar delimitador $delimiter = self::detect_delimiter($path); $header = fgetcsv($handle, 0, $delimiter); if ($header === false || empty($header)) { fclose($handle); $errors[] = __('Cabecera CSV no válida.', 'bloques-actuaciones-import'); return null; } $header = array_map('trim', $header); $rows = []; while (($raw = fgetcsv($handle, 0, $delimiter)) !== false) { $padded = array_pad($raw, count($header), ''); $row = array_combine($header, array_slice($padded, 0, count($header))); if (is_array($row)) { $rows[] = array_map(fn($v) => is_string($v) ? trim($v) : $v, $row); } } fclose($handle); return $rows; } private static function val(array $row, array $keys) { foreach ($keys as $k) { if (isset($row[$k]) && $row[$k] !== '') { return $row[$k]; } } return ''; } }