Plugins bloques

This commit is contained in:
Jose Ibáñez
2026-02-13 13:13:06 +01:00
commit 26d7f11378
24 changed files with 6324 additions and 0 deletions
@@ -0,0 +1,82 @@
<?php
/**
* Plugin Name: Importar Actuaciones desde CSV
* Description: Importador idempotente de Actuaciones (Bloques en Transición) desde CSV con mapeo interactivo de categorías.
* Version: 1.1.0
* Author: Freepress Coop
* License: GPL-2.0+
* Text Domain: bloques-actuaciones-import
* Requires Plugins: bloques-transicion
* Requires at least: 6.0
* Requires PHP: 8.0
*/
if (!defined('ABSPATH')) {
exit;
}
define('BLOQUES_IMPORT_VERSION', '1.1.0');
define('BLOQUES_IMPORT_DIR', plugin_dir_path(__FILE__));
define('BLOQUES_IMPORT_URL', plugin_dir_url(__FILE__));
require_once BLOQUES_IMPORT_DIR . 'includes/class-bloques-actuaciones-importer.php';
add_action('admin_menu', 'bloques_import_register_menu', 20);
function bloques_import_register_menu() {
if (!defined('BLOQUES_PLUGIN_DIR')) {
return;
}
add_submenu_page(
'bloques-dashboard',
__('Importar Actuaciones CSV', 'bloques-actuaciones-import'),
__('📥 Importar CSV', 'bloques-actuaciones-import'),
'manage_options',
'bloques-import-actuaciones',
'bloques_import_render_page'
);
}
function bloques_import_render_page() {
$step = 'upload'; // upload | mapping | done
$message = '';
$message_type = '';
$import_errors = [];
$analysis = null;
// ── PASO 2: el usuario ha confirmado mapeos → importar ──
if (
isset($_POST['bloques_import_step']) &&
$_POST['bloques_import_step'] === 'import' &&
wp_verify_nonce($_POST['bloques_import_nonce'] ?? '', 'bloques_import_actuaciones')
) {
$csv_path = sanitize_text_field($_POST['csv_path'] ?? '');
$geocode = !empty($_POST['reverse_geocode']);
$ini_map = array_map('sanitize_text_field', (array) ($_POST['ini_map'] ?? []));
$linea_map = array_map('sanitize_text_field', (array) ($_POST['linea_map'] ?? []));
$result = Bloques_Actuaciones_Importer::import($csv_path, $ini_map, $linea_map, $geocode);
$message = $result['message'];
$message_type = $result['success'] ? 'success' : 'error';
$import_errors = $result['errors'] ?? [];
$step = 'done';
}
// ── PASO 1: subir CSV → analizar y mostrar mapeos ──
elseif (
isset($_POST['bloques_import_step']) &&
$_POST['bloques_import_step'] === 'analyze' &&
wp_verify_nonce($_POST['bloques_import_nonce'] ?? '', 'bloques_import_actuaciones')
) {
$analysis = Bloques_Actuaciones_Importer::analyze($_FILES['csv_file'] ?? null);
if (!$analysis['success']) {
$message = $analysis['message'];
$message_type = 'error';
$step = 'upload';
} else {
$step = 'mapping';
}
}
include BLOQUES_IMPORT_DIR . 'templates/admin/import-page.php';
}
@@ -0,0 +1,457 @@
<?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 = ';';
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 ── */
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);
}
$header = fgetcsv($handle, 0, self::CSV_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, self::CSV_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 '';
}
}
@@ -0,0 +1,172 @@
<?php
if (!defined('ABSPATH')) {
exit;
}
$step = $step ?? 'upload';
$message = $message ?? '';
$message_type = $message_type ?? '';
$import_errors = $import_errors ?? [];
$analysis = $analysis ?? null;
?>
<div class="wrap">
<h1><?php esc_html_e('Importar Actuaciones desde CSV', 'bloques-actuaciones-import'); ?></h1>
<?php if ($message) : ?>
<div class="notice notice-<?php echo esc_attr($message_type === 'success' ? 'success' : 'error'); ?> is-dismissible">
<p><?php echo esc_html($message); ?></p>
</div>
<?php endif; ?>
<?php if (!empty($import_errors)) : ?>
<div class="notice notice-warning">
<p><strong><?php esc_html_e('Detalle:', 'bloques-actuaciones-import'); ?></strong></p>
<ul style="margin-left:1.5em;list-style:disc;">
<?php foreach (array_slice($import_errors, 0, 30) as $err) : ?>
<li><?php echo esc_html($err); ?></li>
<?php endforeach; ?>
</ul>
<?php if (count($import_errors) > 30) : ?>
<p><?php echo esc_html(sprintf(__('… y %d más.', 'bloques-actuaciones-import'), count($import_errors) - 30)); ?></p>
<?php endif; ?>
</div>
<?php endif; ?>
<?php /* ═══════════════════════════════════════════
* PASO 1 — Subir CSV
* ═══════════════════════════════════════════ */ ?>
<?php if ($step === 'upload' || $step === 'done') : ?>
<div class="card" style="max-width:660px;padding:20px;">
<h2 style="margin-top:0;"><?php esc_html_e('1. Subir archivo CSV', 'bloques-actuaciones-import'); ?></h2>
<p><?php esc_html_e('CSV con delimitador punto y coma (;). Columnas: Título/Nombre, Descripción, Lat/Long, Iniciativa, Línea de trabajo.', 'bloques-actuaciones-import'); ?></p>
<p><?php esc_html_e('La importación es idempotente: si la actuación ya existe (mismo título), se actualiza.', 'bloques-actuaciones-import'); ?></p>
<form method="post" enctype="multipart/form-data">
<?php wp_nonce_field('bloques_import_actuaciones', 'bloques_import_nonce'); ?>
<input type="hidden" name="bloques_import_step" value="analyze">
<p>
<label for="csv_file"><strong><?php esc_html_e('Archivo CSV', 'bloques-actuaciones-import'); ?></strong></label><br>
<input type="file" name="csv_file" id="csv_file" accept=".csv,.txt" required>
</p>
<p>
<label>
<input type="checkbox" name="reverse_geocode" value="1" checked>
<?php esc_html_e('Obtener dirección desde coordenadas (Nominatim/OpenStreetMap, ~1 petición/s).', 'bloques-actuaciones-import'); ?>
</label>
</p>
<p>
<button type="submit" class="button button-primary"><?php esc_html_e('Analizar CSV', 'bloques-actuaciones-import'); ?></button>
</p>
</form>
</div>
<?php endif; ?>
<?php /* ═══════════════════════════════════════════
* PASO 2 — Mapeo de categorías
* ═══════════════════════════════════════════ */ ?>
<?php if ($step === 'mapping' && $analysis) :
$unmatched_ini = $analysis['unmatched_iniciativas'];
$unmatched_lin = $analysis['unmatched_lineas'];
$existing_ini = $analysis['existing_iniciativas'];
$existing_lin = $analysis['existing_lineas'];
$default_map = $analysis['default_map'];
$has_unmatched = !empty($unmatched_ini) || !empty($unmatched_lin);
?>
<div class="card" style="max-width:780px;padding:20px;">
<h2 style="margin-top:0;">
<?php esc_html_e('2. Revisar mapeo de categorías', 'bloques-actuaciones-import'); ?>
</h2>
<p>
<?php echo esc_html(sprintf(
__('Se han encontrado %d filas en el CSV.', 'bloques-actuaciones-import'),
$analysis['total_rows']
)); ?>
</p>
<form method="post">
<?php wp_nonce_field('bloques_import_actuaciones', 'bloques_import_nonce'); ?>
<input type="hidden" name="bloques_import_step" value="import">
<input type="hidden" name="csv_path" value="<?php echo esc_attr($analysis['csv_path']); ?>">
<input type="hidden" name="reverse_geocode" value="<?php echo !empty($_POST['reverse_geocode']) ? '1' : '0'; ?>">
<?php if (!$has_unmatched) : ?>
<div class="notice notice-success inline" style="margin:12px 0;">
<p><?php esc_html_e('Todas las categorías del CSV coinciden con términos existentes en WordPress. Puedes importar directamente.', 'bloques-actuaciones-import'); ?></p>
</div>
<?php endif; ?>
<?php /* ── Iniciativas ── */ ?>
<?php if (!empty($unmatched_ini)) : ?>
<h3><?php esc_html_e('Iniciativas', 'bloques-actuaciones-import'); ?></h3>
<p class="description"><?php esc_html_e('Estos valores de la columna "Iniciativa" no coinciden exactamente con ningún término existente. Elige a qué iniciativa asignarlos o crea uno nuevo.', 'bloques-actuaciones-import'); ?></p>
<table class="widefat striped" style="max-width:720px;">
<thead>
<tr>
<th><?php esc_html_e('Valor en CSV', 'bloques-actuaciones-import'); ?></th>
<th><?php esc_html_e('Asignar a…', 'bloques-actuaciones-import'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($unmatched_ini as $csv_val => $suggested_slug) : ?>
<tr>
<td><strong><?php echo esc_html($csv_val); ?></strong></td>
<td>
<select name="ini_map[<?php echo esc_attr($csv_val); ?>]" style="width:100%;">
<option value="__new__"><?php echo esc_html(sprintf(__('Crear nueva: "%s"', 'bloques-actuaciones-import'), $csv_val)); ?></option>
<?php foreach ($existing_ini as $slug => $name) : ?>
<option value="<?php echo esc_attr($slug); ?>"<?php selected($suggested_slug, $slug); ?>>
<?php echo esc_html($name); ?>
</option>
<?php endforeach; ?>
</select>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php /* ── Líneas de trabajo ── */ ?>
<?php if (!empty($unmatched_lin)) : ?>
<h3 style="margin-top:24px;"><?php esc_html_e('Líneas de trabajo', 'bloques-actuaciones-import'); ?></h3>
<p class="description"><?php esc_html_e('Estos valores de "Línea de trabajo" no coinciden con ningún término existente.', 'bloques-actuaciones-import'); ?></p>
<table class="widefat striped" style="max-width:720px;">
<thead>
<tr>
<th><?php esc_html_e('Valor en CSV', 'bloques-actuaciones-import'); ?></th>
<th><?php esc_html_e('Asignar a…', 'bloques-actuaciones-import'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($unmatched_lin as $csv_val => $suggested_slug) : ?>
<tr>
<td><strong><?php echo esc_html($csv_val); ?></strong></td>
<td>
<select name="linea_map[<?php echo esc_attr($csv_val); ?>]" style="width:100%;">
<option value="__new__"><?php echo esc_html(sprintf(__('Crear nueva: "%s"', 'bloques-actuaciones-import'), $csv_val)); ?></option>
<?php foreach ($existing_lin as $slug => $name) : ?>
<option value="<?php echo esc_attr($slug); ?>"<?php selected($suggested_slug, $slug); ?>>
<?php echo esc_html($name); ?>
</option>
<?php endforeach; ?>
</select>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<p style="margin-top:20px;">
<button type="submit" class="button button-primary button-hero">
<?php esc_html_e('Confirmar e importar', 'bloques-actuaciones-import'); ?>
</button>
<a href="<?php echo esc_url(admin_url('admin.php?page=bloques-import-actuaciones')); ?>" class="button" style="margin-left:8px;">
<?php esc_html_e('Cancelar', 'bloques-actuaciones-import'); ?>
</a>
</p>
</form>
</div>
<?php endif; ?>
</div>