499 lines
18 KiB
PHP
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 '';
|
|
}
|
|
}
|