Files
tangente-bloques-en-transicion/bloques-actuaciones-import/includes/class-bloques-actuaciones-importer.php
2026-02-14 18:21:10 +01:00

499 lines
18 KiB
PHP

<?php
/**
* Importador de Actuaciones desde CSV — flujo en dos pasos.
*
* Paso 1 (analyze): lee el CSV, extrae valores únicos de Iniciativa y Línea de trabajo,
* los compara con los términos existentes y devuelve los que necesitan mapeo.
* Paso 2 (import): recibe el CSV guardado + los mapeos confirmados por el usuario
* e importa/actualiza las actuaciones de forma idempotente.
*
* @package Bloques_Actuaciones_Import
*/
if (!defined('ABSPATH')) {
exit;
}
class Bloques_Actuaciones_Importer {
private const CSV_DELIMITER_FALLBACK = ';';
private const NOMINATIM_DELAY_US = 1100000; // 1.1 s
private const NOMINATIM_USER_AGENT = 'BloquesTransicion/1.0 (WordPress; tangente.coop)';
/**
* Mapeos por defecto del CSV a los slugs de WP.
* clave = nombre tal como aparece en el CSV (mb_strtolower).
*/
private const DEFAULT_INICIATIVA_MAP = [
'bloques' => '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 '';
}
}