WME Places Name Normalizer

Normaliza nombres de lugares en Waze Map Editor (WME) por ahora solo con reglas de Colombia

// ==UserScript==
// @name         WME Places Name Normalizer
// @namespace    https://gf.qytechs.cn/en/users/mincho77
// @version      3.1
// @description  Normaliza nombres de lugares en Waze Map Editor (WME) por ahora solo con reglas de Colombia
// @author       mincho77
// @match        https://www.waze.com/*editor*
// @match      https://beta.waze.com/*user/editor*
// @grant        GM_xmlhttpRequest
// @connect      api.languagetool.org
// @connect      *
// @grant        unsafeWindow
// @license      MIT
// @run-at       document-end
// ==/UserScript==
/*global W*/

(() => {
    "use strict";
    try {


   // Insertar estilos globales en el <head>
const styles = `
<style>
    /* Estilos para los botones */
    .apply-suggestion-btn {
        background-color: #4CAF50;
        color: white;
        border: none;
        padding: 8px 12px;
        border-radius: 4px;
        cursor: pointer;
        font-size: 14px;
        font-weight: bold;
        transition: background-color 0.3s ease;
    }
    .apply-suggestion-btn:hover {
        background-color: #45a049;
    }

    #apply-changes-btn {
        background-color: #28a745;
        color: white;
        border: none;
        padding: 10px 15px;
        border-radius: 4px;
        cursor: pointer;
        font-size: 16px;
        font-weight: bold;
        display: flex;
        align-items: center;
        gap: 8px;
    }
    #apply-changes-btn:hover {
        background-color: #218838;
    }

    #cancel-btn {
        background-color: #dc3545;
        color: white;
        border: none;
        padding: 10px 15px;
        border-radius: 4px;
        cursor: pointer;
        font-size: 16px;
        font-weight: bold;
        display: flex;
        align-items: center;
        gap: 8px;
    }
    #cancel-btn:hover {
        background-color: #c82333;
    }
</style>
`;
document.head.insertAdjacentHTML('beforeend', styles);

    // Capturar todos los eventos de drag & drop a nivel de <body>
   // Agregar al inicio del script, justo después del "use strict"
    // Prevenir comportamiento por defecto de drag & drop a nivel global
    document.addEventListener("dragover", function(e) {
        const dropZone = document.getElementById("drop-zone");
        if (e.target === dropZone || dropZone?.contains(e.target)) {
            return; // Permitir el drop en la zona designada
        }
        e.preventDefault();
        e.stopPropagation();
    }, { passive: false }); // Agrega { passive: false }

    document.addEventListener("drop", function(e) {
        const dropZone = document.getElementById("drop-zone");
        if (e.target === dropZone || dropZone?.contains(e.target)) {
            return; // Permitir el drop en la zona designada
        }
        e.preventDefault();
        e.stopPropagation();
    }, { passive: false }); // Agrega { passive: false }

//**************************************************************************
//Nombre: evaluarOrtografiaConTildes
//Fecha modificación: 2025-04-02
//Autor: mincho77
//Entradas: name (string) - Nombre del lugar
//Salidas: objeto con errores detectados
//Descripción:
// Evalúa palabra por palabra si falta una tilde en las palabras que lo requieren,
// según las reglas del español. Primero normaliza el nombre y luego verifica
// si las palabras necesitan una tilde.
//**************************************************************************
function evaluarOrtografiaConTildes(name) {
  // Si el nombre está vacío, retornar inmediatamente una promesa resuelta
  if (!name) {
    return Promise.resolve({
        hasSpellingWarning: false,
        spellingWarnings: []
    });
}


    const palabras = name.trim().split(/\s+/);
    const spellingWarnings = [];

    console.log(`[evaluarOrtografiaConTildes] Verificando ortografía de: ${name}`);

    palabras.forEach((palabra, index) => {
        // Normalizar la palabra antes de cualquier verificación
        let normalizada = normalizePlaceNameOnly(palabra);

        // Ignorar palabras con "&" o que sean emoticonos
        if (/^[A-Za-z]&[A-Za-z]$/.test(normalizada) || /^[\u263a-\u263c\u2764\u1f600-\u1f64f\u1f680-\u1f6ff]+$/.test(normalizada)) {
            return; // No verificar ortografía
        }

        // Excluir palabras específicas como "y" o "Y"
        if (normalizada.toLowerCase() === "y" || /^\d+$/.test(normalizada) || normalizada === "-") {
            return; // Ignorar
        }

        // Verificar si la palabra está en la lista de excluidas
        if (excludeWords.some(w => w.toLowerCase() === normalizada.toLowerCase())) {
            return; // Ignorar palabra excluida
        }

        // Validar que no tenga más de una tilde
        const cantidadTildes = (normalizada.match(/[áéíóú]/g) || []).length;
        if (cantidadTildes > 1) {
            spellingWarnings.push({
                original: palabra,
                sugerida: null, // No hay sugerencia válida
                tipo: "Error de tildes",
                posicion: index
            });
            return;
        }

        // Verificar ortografía usando la API de LanguageTool
        checkSpellingWithAPI(normalizada).then(errores => {
            errores.forEach(error => {
                spellingWarnings.push({
                    original: error.palabra,
                    sugerida: error.sugerencia,
                    tipo: "LanguageTool",
                    posicion: index
                });
            });
        }).catch(err => {
            console.error("Error al verificar ortografía con LanguageTool:", err);
        });
    });

    return {
        hasSpellingWarning: spellingWarnings.length > 0,
        spellingWarnings
    };
}

// Función auxiliar para corregir tildes
function corregirTilde(palabra) {
    // Implementar lógica para corregir tildes según las reglas del español
    // Por ejemplo: "medellin" → "Medellín"
    return palabra; // Retornar la palabra corregida
}

    //**************************************************************************
    //Nombre: toggleSpinner
    //Fecha modificación: 2025-03-31
    //Autor: mincho77
    //Entradas:
    //   show (boolean) - true para mostrar el spinner, false para ocultarlo
    //   message (string, opcional) - mensaje personalizado a mostrar junto al spinner
    //Salidas: ninguna (modifica el DOM)
    //Prerrequisitos: debe existir el estilo CSS del spinner en el documento
    //Descripción:
    //   Muestra u oculta un indicador visual de carga con un mensaje opcional.
    //   El spinner usa un emoji de reloj de arena (⏳) con animación de rotación
    //   para indicar que el proceso está en curso.
    //**************************************************************************
    function toggleSpinner(show, message = 'Revisando ortografía...', progress = null) {
        let existingSpinner = document.querySelector('.spinner-overlay');
        if (existingSpinner) {
            if (show) {
                // Actualizar el mensaje y el progreso si el spinner ya existe
                const spinnerMessage = existingSpinner.querySelector('.spinner-message');
                spinnerMessage.innerHTML = `
                    ${message}
                    ${progress !== null ? `<br><strong>${progress}% completado</strong>` : ''}
                `;
            } else {
                existingSpinner.remove(); // Ocultar el spinner
            }
            return;
        }
    
        if (show) {
            const spinner = document.createElement('div');
            spinner.className = 'spinner-overlay';
            spinner.innerHTML = `
                <div class="spinner-content">
                    <div class="spinner-icon">⏳</div>
                    <div class="spinner-message">
                        ${message}
                        ${progress !== null ? `<br><strong>${progress}% completado</strong>` : ''}
                    </div>
                </div>
            `;
            document.body.appendChild(spinner);
        }
    }

    // Agregar los estilos CSS necesarios
    const spinnerStyles = `
    <style>
    .spinner-overlay {
        position: fixed;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background: rgba(0, 0, 0, 0.5);
        display: flex;
        justify-content: center;
        align-items: center;
        z-index: 10000;
    }

    .spinner-content {
        background: white;
        padding: 20px;
        border-radius: 8px;
        text-align: center;
        box-shadow: 0 2px 10px rgba(0,0,0,0.3);
    }

    .spinner-icon {
        font-size: 24px;
        margin-bottom: 10px;
        animation: spin 1s linear infinite; /* Aseguramos que la animación esté activa */
        display: inline-block;
    }

    .spinner-message {
        color: #333;
        font-size: 14px;
    }

    @keyframes spin {
        0% { transform: rotate(0deg); }
        100% { transform: rotate(360deg); }
    }
    </style>`;

    // Insertar los estilos al inicio del documento
    document.head.insertAdjacentHTML('beforeend', spinnerStyles);

    if (!Array.prototype.flat)
    {
        Array.prototype.flat = function(depth = 1)
        {
            return this.reduce(function (flat, toFlatten)
            {
                return flat.concat(Array.isArray(toFlatten) ? toFlatten.flat(depth - 1) : toFlatten);
            }, []);
        };
    }
    const SCRIPT_NAME = "PlacesNameNormalizer";
    const VERSION = "3.1";
    let placesToNormalize = [];
    let excludeWords = [];
    let maxPlaces = 50;
    let normalizeArticles = true;

    // Expresión regular para detectar siglas (por ejemplo, "S.a.s", "L.T.D.A")
    const siglaRegex = /^[A-Za-z](\.[A-Za-z])+\.?$/;



    
    //**************************************************************************
    //Nombre: waitForSidebar
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas:
    //   retries (número, opcional) – Número máximo de intentos de verificación del sidebar (default: 20).
    //   delay (número, opcional) – Tiempo en milisegundos entre intentos (default: 1000ms).
    //Salidas:
    //   Promesa que se resuelve con el elemento del sidebar si se encuentra, o se rechaza si no se encuentra después de los intentos.
    //Descripción:
    //   Esta función espera a que el DOM cargue completamente el elemento con ID "sidebar".
    //   Realiza múltiples intentos con intervalos definidos, y resuelve la promesa cuando el sidebar esté disponible.
    //   Es útil para asegurarse de que el entorno de WME esté completamente cargado antes de continuar.
    //**************************************************************************
    function waitForSidebar(retries = 20, delay = 1000) {
        return new Promise((resolve, reject) => {
            const check = (attempt = 1) => {
                const sidebar = document.querySelector("#sidebar");
                if (sidebar) {
                    console.log("✅ Sidebar disponible.");
                    resolve(sidebar);
                } else if (attempt <= retries) {
                    console.warn(`⚠️ Sidebar no disponible aún. Reintentando... (${attempt})`);
                    setTimeout(() => check(attempt + 1), delay);
                } else {
                    reject("❌ Sidebar no disponible después de múltiples intentos.");
                }
            };
            check();
        });
    }
    //**************************************************************************
    //Nombre: initializeExcludeWords
    //Fecha modificación: 2025-03-31
    //Autor: mincho77
    //Entradas: Ninguna
    //Salidas: Ninguna
    //Prerrequisitos: excludeWords debe estar declarado globalmente.
    //Descripción: Inicializa la lista de palabras excluidas desde localStorage sin borrar entradas ya existentes.
    //**************************************************************************
    function initializeExcludeWords() {
        const saved = JSON.parse(localStorage.getItem("excludeWords")) || [];

        // Combinar con las actuales sin duplicar
        const merged = [...new Set([...saved, ...excludeWords])].sort((a, b) => a.localeCompare(b));

        // Solo guardar si hay diferencias
        const originalString = JSON.stringify(saved.sort());
        const newString = JSON.stringify(merged);

        if (originalString !== newString) {
            localStorage.setItem("excludeWords", newString);
            console.log(`[initializeExcludeWords] 💾 excludeWords actualizado con ${merged.length} palabras.`);
        } else {
            console.log(`[initializeExcludeWords] 🟢 Sin cambios en excludeWords.`);
        }

        // Actualizar variable global
        excludeWords = merged;
    }

    //Modulo de Ortografía
   



   

    // Insertar los estilos al inicio del documento
    document.head.insertAdjacentHTML('beforeend', spinnerStyles);

    if (!Array.prototype.flat)
    {
        Array.prototype.flat = function(depth = 1)
        {
            return this.reduce(function (flat, toFlatten)
            {
                return flat.concat(Array.isArray(toFlatten) ? toFlatten.flat(depth - 1) : toFlatten);
            }, []);
        };
    }
   

    
    //Modulo de Ortografía
    //**************************************************************************
    //Nombre: getUbicacionAcento
    //Fecha modificación: 2025-03-27
    //Autor: mincho77
    //Entradas: palabra (string) - Palabra en español a evaluar
    //Salidas: Posición del acento (última, penúltima, antepenúltima) o null si no aplica
    //Prerrequisitos si existen: Ninguno
    //Descripción: Determina la posición silábica del acento en una palabra según la ortografía del español.
    //**************************************************************************
    function getUbicacionAcento(palabra) {
        const original = palabra;
        const normalized = palabra.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); // Remove diacritics
        const silabas = normalized
            .replace(/[^aeiou]/gi, "")
            .match(/[aeiou]+/gi);

        if (!silabas || silabas.length === 0) return null;

        const conTilde = silabas.findIndex((s, i) => {
            const originalSyllable = original.slice(
                normalized.indexOf(s),
                normalized.indexOf(s) + s.length
            );
            return /[áéíóú]/.test(originalSyllable);
        });

        if (conTilde !== -1) {
            const posicion = silabas.length - 1 - conTilde;
            if (posicion === 0) return "última";
            if (posicion === 1) return "penúltima";
            if (posicion === 2) return "antepenúltima";
            return "otras";
        }

        return "sin tilde";
    }

    //**************************************************************************
    //Nombre: checkSpellingWithAPI
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: text (string) – Texto a evaluar ortográficamente.
    //Salidas: Promise – Resuelve con lista de errores ortográficos detectados.
    //Prerrequisitos: Requiere permisos GM_xmlhttpRequest y @connect a api.languagetool.org
    //Descripción:
    // Consulta la API de LanguageTool para verificar ortografía del texto.
    //**************************************************************************
    function checkSpellingWithAPI(text) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "POST",
                url: "https://api.languagetool.org/v2/check",
                headers: {
                    "Content-Type": "application/x-www-form-urlencoded"
                },
                data: `language=es&text=${encodeURIComponent(text)}`,
                onload: function(response) {
                    if (response.status === 200) {
                        const result = JSON.parse(response.responseText);
                        const errores = result.matches
                        .filter(match => match.rule.issueType === "misspelling")
                        .map(match => ({
                            palabra: match.context.text.substring(match.context.offset, match.context.offset + match.context.length),
                            sugerencia: match.replacements.length > 0 ? match.replacements[0].value : "(sin sugerencia)"
                        }));
                        resolve(errores);
                    } else {
                        reject("❌ Error en respuesta de LanguageTool");
                    }
                },
                onerror: function(err) {
                    reject("❌ Error de red al contactar LanguageTool");
                }
            });
        });
    }

    window.checkSpellingWithAPI = checkSpellingWithAPI;

// Combinar la lógica de checkSpelling
function checkSpelling(text) {
    return new Promise((resolve, reject) => {
        GM_xmlhttpRequest({
            method: "POST",
            url: "https://api.languagetool.org/v2/check",
            data: `text=${encodeURIComponent(text)}&language=es`,
            headers: {
                "Content-Type": "application/x-www-form-urlencoded"
            },
            onload: function (response) {
                if (response.status === 200) {
                    try {
                        const data = JSON.parse(response.responseText);
                        const errores = data.matches
                            .filter(match => match.rule.issueType === "misspelling")
                            .map(match => ({
                                palabra: match.context.text.substring(match.context.offset, match.context.offset + match.context.length),
                                sugerencia: match.replacements.length > 0 ? match.replacements[0].value : "(sin sugerencia)"
                            }));
                        resolve(errores);
                    } catch (err) {
                        reject(err);
                    }
                } else {
                    reject(`Error HTTP: ${response.status}`);
                }
            },
            onerror: function (err) {
                reject(err);
            }
        });
    });
}   

    //**************************************************************************
    //Nombre: validarWordSpelling
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: palabra (string) - Palabra en español a validar ortográficamente
    //Salidas: true si cumple reglas ortográficas básicas, false si no
    //Descripción:
    // Evalúa si una palabra tiene el uso correcto de tilde o si le falta una tilde
    // según las reglas del español: esdrújulas siempre con tilde, agudas con tilde
    // si terminan en n, s o vocal, y llanas con tildse si NO terminan en n, s o vocal.
    // Se asegura que solo haya una tilde por palabra.
    //**************************************************************************
    function validarWordSpelling(palabra) {
        if (!palabra) return false;

        // Ignorar siglas con formato X&X
        if (/^[A-Za-z]&[A-Za-z]$/.test(palabra)) return true;

        // Si la palabra es un número, no necesita validación
        if (/^\d+$/.test(palabra)) return true;

        const tieneTilde = /[áéíóú]/.test(palabra);
        const cantidadTildes = (palabra.match(/[áéíóú]/g) || []).length;
        if (cantidadTildes > 1) return false; // Solo se permite una tilde

        const silabas = palabra.normalize("NFD").replace(/[^aeiouAEIOU\u0300-\u036f]/g, "").match(/[aeiouáéíóú]+/gi);
        if (!silabas || silabas.length === 0) return false;

        const totalSilabas = silabas.length;
        const ultimaLetra = palabra.slice(-1).toLowerCase();

        let tipo = "";
        if (totalSilabas >= 3 && /[áéíóú]/.test(palabra)) {
            tipo = "esdrújula";
        } else if (totalSilabas >= 2) {
            const penultimaSilaba = silabas[totalSilabas - 2];
            if (/[áéíóú]/.test(penultimaSilaba)) tipo = "grave";
        }

        if (!tipo) tipo = /[áéíóú]/.test(silabas[totalSilabas - 1]) ? "aguda" : "sin tilde";

        if (tipo === "esdrújula") return tieneTilde;
        if (tipo === "aguda") {
            return (/[nsáéíóúaeiou]$/.test(ultimaLetra) && tieneTilde) ||
                (!/[nsáéíóúaeiou]$/.test(ultimaLetra) && !tieneTilde);
        }
        if (tipo === "grave") {
            return (!/[nsáéíóúaeiou]$/.test(ultimaLetra) && tieneTilde) ||
                (/[nsáéíóúaeiou]$/.test(ultimaLetra) && !tieneTilde);
        }

        return true;
    }



    ///**************************************************************************
    //Nombre: separarSilabas
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: palabra (string) – Palabra en español.
    //Salidas: array de sílabas (aproximado).
    //Descripción:
    //   Separa una palabra en sílabas usando reglas heurísticas.
    //   Esta versión simplificada considera diptongos y combinaciones comunes.
    //**************************************************************************
    function separarSilabas(palabra) {
        // Normaliza y quita acentos para facilitar la segmentación
        const limpia = palabra.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();

        // Divide por vocales agrupadas como aproximación de sílabas
        const silabas = limpia.match(/[bcdfghjklmnñpqrstvwxyz]*[aeiou]{1,2}[bcdfghjklmnñpqrstvwxyz]*/g);

        return silabas || [palabra]; // fallback si no separa nada
    }

    //**************************************************************************
    //Nombre: clasificarPalabra
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: silabas (array) – Arreglo con las sílabas de la palabra.
    //Salidas: string – 'aguda', 'grave' (llana), 'esdrújula'.
    //Descripción:
    //   Determina el tipo de palabra según el número de sílabas y la posición
    //   de la tilde (si existe).
    //**************************************************************************
    function clasificarPalabra(silabas) {
        const palabra = silabas.join("");
        const tieneTilde = /[áéíóúÁÉÍÓÚ]/.test(palabra);

        if (tieneTilde) {
            const posicionTilde = silabas.findIndex(s => /[áéíóúÁÉÍÓÚ]/.test(s));
            const posicionDesdeFinal = silabas.length - 1 - posicionTilde;

            if (posicionDesdeFinal === 0) return "aguda";
            if (posicionDesdeFinal === 1) return "grave";
            if (posicionDesdeFinal >= 2) return "esdrújula";
        }

        // Si no tiene tilde, asumimos que es:
        if (silabas.length === 1) return "aguda";
        if (silabas.length === 2) return "grave";
        return "grave"; // por convención para 3+ sin tilde
    }

    //**************************************************************************
    //Nombre: evaluarOrtografiaNombre
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: name (string) - Nombre del lugar
    //Salidas: objeto con errores detectados
    //Descripción: Evalúa palabra por palabra si hay errores ortográficos o falta de tildes.
    // Ya no utiliza sugerencias automáticas para correcciones.
    //**************************************************************************
    function evaluarOrtografiaNombre(name) {
        if (!name) return {
            hasSpellingWarning: false,
            spellingWarnings: []
        };

        const checkOnlyTildes = document.getElementById("checkOnlyTildes")?.checked;
        const palabras = name.trim().split(/\s+/);
        const spellingWarnings = [];

        console.log(`[evaluarOrtografiaNombre] Verificando ortografía de: ${name}`);

        if (checkOnlyTildes) {
            palabras.forEach((palabra, index) => {
                // Verificar si la palabra está en la lista de exclusiones
                if (excludeWords.some(w => w.toLowerCase() === palabra.toLowerCase()) || /^\d+$/.test(palabra)) {
                    return; // Ignorar palabra excluida
                }

                if (!validarWordSpelling(palabra)) {
                    spellingWarnings.push({
                        original: palabra,
                        sugerida: null,
                        tipo: "Tilde",
                        posicion: index // Guardar la posición en la frase
                    });
                   
                }
            });

            return Promise.resolve({
                hasSpellingWarning: spellingWarnings.length > 0,
                spellingWarnings
            });
        } else {
            return checkSpellingWithAPI(name)
                .then(errores => {
                    errores.forEach(error => {
                        // Verificar si la palabra está en exclusiones
                        if (!excludeWords.some(w => w.toLowerCase() === error.palabra.toLowerCase())) {
                            spellingWarnings.push({
                                original: error.palabra,
                                sugerida: error.sugerencia,
                                tipo: "LanguageTool",
                                posicion: name.indexOf(error.palabra)
                            });
                        }
                    });
                    return {
                        hasSpellingWarning: spellingWarnings.length > 0,
                        spellingWarnings
                    };
                });
        }
    }
    
    //**************************************************************************
    //Nombre: handleImportList
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: ninguna directa, depende del input file y checkbox en el DOM
    //Salidas: actualiza excludeWords en localStorage y visualmente
    //Prerrequisitos: existir un input file con id="importListInput" y checkbox con id="replaceExcludeListCheckbox"
    //Descripción: Importa una lista de palabras para excluir, opcionalmente reemplazando la lista actual
    //**************************************************************************
    function handleImportList() {
        const fileInput = document.getElementById("importListInput");
        const replaceCheckbox = document.getElementById("replaceExcludeListCheckbox");

        if (!fileInput || !fileInput.files || fileInput.files.length === 0) {
            alert("No se seleccionó ningún archivo.");
            return;
        }

        const reader = new FileReader();
        reader.onload = function (event)
        {
            const rawLines = event.target.result.split(/\r?\n/);

            const lines = rawLines
            .map(line => line.replace(/[^\p{L}\p{N}().\s-]/gu, "").trim())
            .filter(line => line.length > 0);

            const eliminadas = rawLines.length - lines.length;
            if (eliminadas > 0) {
                console.warn(`[Importar Lista] ⚠️ ${eliminadas} líneas inválidas fueron ignoradas (vacías, caracteres no permitidos o basura).`);
            }

            if (replaceCheckbox && replaceCheckbox.checked) {
                // Si se marcó reemplazar, limpiar todo
                excludeWords = [];
            } else {
                // Si no, recuperar la actual del localStorage
                excludeWords = JSON.parse(localStorage.getItem("excludeWords")) || excludeWords || [];
            }

            // Unificar, eliminar duplicados y ordenar
            excludeWords = [...new Set([...excludeWords, ...lines])]
                .filter(w => w.trim().length > 0)
                .sort((a, b) => a.localeCompare(b));

            localStorage.setItem("excludeWords", JSON.stringify(excludeWords));

            console.log("[handleImportList] Palabras actuales excluidas:", excludeWords);

            renderExcludedWordsPanel(); // O renderExcludeWordList() si usas otro nombre
            setupDragAndDropImport(); // Activa drag and drop

            alert(`✅ Palabras excluidas importadas correctamente: ${excludeWords.length}`);

            //Limpia el input para permitir recarga posterior
            fileInput.value = "";
        };

        reader.readAsText(fileInput.files[0]);
    }

    //**************************************************************************
    //Nombre: setupDragAndDropImport
    //Fecha modificación: 2025-03-31
    //Autor: mincho77
    //Entradas: ninguna
    //Salidas: habilita funcionalidad de arrastrar y soltar archivos al panel
    //Descripción:
    // Permite arrastrar y soltar un archivo .txt sobre el panel lateral (#normalizer-sidebar).
    // Extrae las palabras del archivo y las agrega a excludeWords sin duplicados.
    //**************************************************************************
    function setupDragAndDropImport() {
        const dropArea = document.getElementById("drop-zone");
        if (!dropArea) {
            console.warn("[setupDragAndDropImport] No se encontró la zona de drop");
            return;
        }

        // Highlight drop zone when dragging over it
        const highlight = (e) => {
            e.preventDefault();
            e.stopPropagation();
            dropArea.classList.add("drag-over");
            dropArea.style.backgroundColor = "#e8f5e9";
            dropArea.style.borderColor = "#4CAF50";
        };

        // Remove highlighting
        const unhighlight = (e) => {
            e.preventDefault();
            e.stopPropagation();
            dropArea.classList.remove("drag-over");
            dropArea.style.backgroundColor = "";
            dropArea.style.borderColor = "#ccc";
        };

        // Handle the dropped files
        const handleDrop = (e) => {
            console.log("[handleDrop] Evento drop detectado");
            e.preventDefault();
            e.stopPropagation();
            unhighlight(e);

            const file = e.dataTransfer.files[0];
            if (!file) {
                alert("❌ No se detectó ningún archivo");
                return;
            }

            // Validar extensión del archivo
            if (!file.name.match(/\.(txt|xml)$/i)) {
                alert("❌ Por favor, arrastra un archivo .txt o .xml");
                return;
            }

            console.log(`[handleDrop] Procesando archivo: ${file.name}`);

            const reader = new FileReader();
            reader.onload = (event) => {
                try {
                    const content = event.target.result;
                    const isXML = file.name.toLowerCase().endsWith('.xml');
                    let words = [];

                    if (isXML) {
                        const parser = new DOMParser();
                        const xmlDoc = parser.parseFromString(content, "text/xml");
                        if (xmlDoc.getElementsByTagName("parsererror").length > 0) {
                            throw new Error("XML inválido");
                        }
                        words = Array.from(xmlDoc.getElementsByTagName("word"))
                            .map(node => node.textContent.trim())
                            .filter(w => w.length > 0);
                    } else {
                        words = content.split(/\r?\n/)
                            .map(line => line.trim())
                            .filter(line => line.length > 0);
                    }

                    if (words.length === 0) {
                        alert("⚠️ No se encontraron palabras válidas en el archivo");
                        return;
                    }

                    // Actualizar lista de palabras excluidas
                    excludeWords = [...new Set([...excludeWords, ...words])].sort();
                    localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
                    renderExcludedWordsPanel();

                    alert(`✅ Se importaron ${words.length} palabras exitosamente`);
                    console.log(`[handleDrop] Importadas ${words.length} palabras`);

                } catch (error) {
                    console.error("[handleDrop] Error procesando archivo:", error);
                    alert("❌ Error procesando el archivo");
                }
            };

            reader.onerror = () => {
                console.error("[handleDrop] Error leyendo archivo");
                alert("❌ Error leyendo el archivo");
            };

            reader.readAsText(file);
        };

        // Attach the event listeners
        dropArea.addEventListener("dragenter", highlight, false);
        dropArea.addEventListener("dragover", highlight, false);
        dropArea.addEventListener("dragleave", unhighlight, false);
        dropArea.addEventListener("drop", handleDrop, false);

        console.log("[setupDragAndDropImport] Eventos de drag & drop configurados");
    }

    // Función auxiliar para procesar el archivo
    function handleImportedFile(content, isXML) {
        let words = [];

        if (isXML) {
            const parser = new DOMParser();
            const xmlDoc = parser.parseFromString(content, "text/xml");
            const nodes = xmlDoc.getElementsByTagName("word");
            words = Array.from(nodes).map(node => node.textContent.trim());
        } else {
            words = content.split(/\r?\n/).map(line => line.trim()).filter(line => line);
        }

        if (words.length === 0) {
            alert("No se encontraron palabras válidas en el archivo");
            return;
        }

        // Actualizar lista de palabras excluidas
        excludeWords = [...new Set([...excludeWords, ...words])].sort();
        localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
        renderExcludedWordsPanel();

        alert(`✅ Se importaron ${words.length} palabras exitosamente`);
    }

    //**************************************************************************
    //Nombre: renderExcludedWordsPanel
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: ninguna (usa la variable global excludeWords)
    //Salidas: ninguna (actualiza el DOM y el localStorage)
    //Prerrequisitos si existen: Debe existir un contenedor con id="normalizer-sidebar"
    //Descripción:
    // Esta función limpia y vuelve a renderizar la lista de palabras excluidas
    // dentro del panel lateral del normalizador. Muestra una lista ordenada
    // alfabéticamente, evitando palabras vacías. Además, asegura que el localStorage
    // se actualice correctamente con la lista limpia y depurada.
    //**************************************************************************
    function renderExcludedWordsPanel() {
        const container = document.getElementById("normalizer-sidebar");
        if (!container) {
            console.warn("[renderExcludedWordsPanel] ❌ No se encontró el contenedor 'normalizer-sidebar'");
            return;
        }

        //Limpiar el contenedor visual
        container.innerHTML = "";
        console.log("[renderExcludedWordsPanel] ✅ Contenedor limpiado.");

        //Limpiar palabras vacías y ordenar
        const sortedWords = excludeWords.filter(w => !!w).sort((a, b) => a.localeCompare(b));
        console.log(`[renderExcludedWordsPanel] 📋 Lista excluida depurada: ${sortedWords.length} palabras`, sortedWords);

        const excludeListSection = document.createElement("div");
        excludeListSection.style.marginTop = "20px";
        excludeListSection.innerHTML = `
        <h4 style="margin-bottom: 5px;">Palabras Excluidas</h4>
        <div style="max-height: 150px; overflow-y: auto; border: 1px solid #ccc; padding: 8px; font-size: 13px; border-radius: 4px;">
            <ul style="margin: 0; padding-left: 18px;" id="excludeWordsList">
                ${sortedWords.map(w => `<li>${w}</li>`).join("")}
            </ul>
        </div>
    `;
        container.appendChild(excludeListSection);
        console.log("[renderExcludedWordsPanel] ✅ Lista renderizada en el DOM.");

        //Guardar en localStorage
        localStorage.setItem("excludeWords", JSON.stringify(sortedWords));
        console.log("[renderExcludedWordsPanel] 💾 excludeWords actualizado en localStorage.");
    }


    //**************************************************************************
    //Nombre: normalizePlaceName (unsafeWindow)
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas:
    //  - name (string): el nombre original del lugar.
    //Salidas:
    //  - string: nombre normalizado, respetando exclusiones y opciones del usuario.
    //Prerrequisitos si existen:
    //  - Debe estar cargada la lista global excludeWords.
    //  - Debe existir un checkbox con id “normalizeArticles” para definir si se normalizan artículos.
    //Descripción:
    //  Esta versión expuesta globalmente permite acceder a la normalización básica del nombre de un lugar
    //  desde otros contextos como el navegador o Tampermonkey. Capitaliza cada palabra, respeta las excluidas
    //  y no aplica normalización a artículos si el checkbox lo indica.
    //  Realiza limpieza básica: reemplazo de pipes, eliminación de espacios dobles y trim final.
    //**************************************************************************
    unsafeWindow.normalizePlaceName = function(name)
    {
        if (!name) return "";

        const normalizeArticles = !document.getElementById("normalizeArticles")?.checked;
        const articles = ["el", "la", "los", "las", "de", "del", "al", "y", "o"];

        const words = name.trim().split(/\s+/);
        const normalizedWords = words.map((word, index) => {
            const lowerWord = word.toLowerCase();

            // Saltar palabras excluidas
            if (excludeWords.includes(word)) return word;

            // Saltar artículos si el checkbox está activo y no es la primera palabra
            if (!normalizeArticles && articles.includes(lowerWord) && index !== 0) {
                return lowerWord;
            }
            //Mayúsculas
            return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
        });

        name = normalizedWords.join(" ");
        name = name.replace(/\s*\|\s*/g, " - ");
        name = name.replace(/\s{2,}/g, " ").trim();

        return name;
    };

    //**************************************************************************
    //Nombre: normalizePlaceNameOnly
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: name (string) – Nombre del lugar a normalizar.
    //Salidas: texto normalizado sin validación ortográfica.
    //Descripción:
    //   Realiza normalización visual del nombre: capitaliza, ajusta espacios,
    //   formatea guiones, paréntesis, y símbolos. No evalúa ortografía ni acentos.
    //**************************************************************************
    function normalizePlaceNameOnly(name) {
        if (!name) return "";
    
        const normalizeArticles = !document.getElementById("normalizeArticles")?.checked;
        const articles = ["el", "la", "los", "las", "de", "del", "al", "y"];
        const words = name.trim().split(/\s+/);
        const isRoman = word => /^(i{1,3}|iv|v|vi{0,3}|ix|x|xi{0,3}|xiv|xv|xvi{0,3}|xix|xx|xxi{0,3}|xxiv|xxv)$/i.test(word);
    
        const normalizedWords = words.map((word, index) => {
            const lowerWord = word.toLowerCase();
    
            // Si contiene "&", convertir a mayúsculas
            if (/^[A-Za-z]&[A-Za-z]$/.test(word)) return word.toUpperCase();
    
            // Verificar si está en la lista de excluidas
            const matchExcluded = excludeWords.find(w => w.toLowerCase() === lowerWord);
            if (matchExcluded) return matchExcluded;
    
            // Si es un número romano, convertir a mayúsculas
            if (isRoman(word)) return word.toUpperCase();
    
            // Si no se deben normalizar artículos y es un artículo, mantener en minúsculas
            if (!normalizeArticles && articles.includes(lowerWord) && index !== 0) return lowerWord;
    
            // Si es un número o un símbolo como "-", no modificar
            if (/^\d+$/.test(word) || word === "-") return word;
    
            // Verificar ortografía usando la API de LanguageTool
            return checkSpellingWithAPI(word)
                .then(errors => {
                    if (errors.length > 0) {
                        const suggestion = errors[0].sugerencia || word;
                        return suggestion.charAt(0).toUpperCase() + suggestion.slice(1).toLowerCase();
                    }
                    return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
                })
                .catch(() => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
        });
    
        let newName = normalizedWords.join(" ")
            .replace(/\s*\|\s*/g, " - ")
            .replace(/([(["'])\s*([\p{L}])/gu, (match, p1, p2) => p1 + p2.toUpperCase())
            .replace(/\s*-\s*/g, " - ")
            .replace(/\b(\d+)([A-Z])\b/g, (match, num, letter) => num + letter.toUpperCase())
            .replace(/\.$/, "")
            .replace(/&(\s*)([A-Z])/g, (match, space, letter) => "&" + space + letter.toUpperCase());
    
        return newName.replace(/\s{2,}/g, " ").trim();
    }

    //**************************************************************************
    //Nombre: validarOrtografiaConAPI
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: texto (string) - palabra o frase a evaluar
    //Salidas: Promesa con resultado de corrección ortográfica
    //Descripción: Consulta la API pública de LanguageTool para identificar errores ortográficos
    //**************************************************************************
    function validarOrtografiaConAPI2(texto) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "POST",
                url: "https://api.languagetool.org/v2/check",
                headers: {
                    "Content-Type": "application/x-www-form-urlencoded"
                },
                data: `text=${encodeURIComponent(texto)}&language=es`,
                onload: function (response) {
                    if (response.status === 200) {
                        const result = JSON.parse(response.responseText);
                        resolve(result.matches || []);
                    } else {
                        reject("Error en la API de LanguageTool");
                    }
                },
                onerror: function () {
                    reject("Fallo la solicitud a la API de LanguageTool");
                }
            });
        });
    }

//**************************************************************************
//Nombre: importExcludeList
//Fecha modificación: 2025-03-31
//Autor: mincho77
//Entradas:
//   file (File) – Archivo cargado por el usuario, que contiene una lista de palabras excluidas.
//Salidas:
//   Ninguna (actualiza el array global excludeWords y el localStorage).
//Prerrequisitos:
//   Debe existir un panel visual para mostrar la lista y una función renderExcludeList().
//Descripción:
//   Lee un archivo .txt línea por línea, limpia espacios, elimina duplicados y vacíos,
//   ordena alfabéticamente la lista resultante y actualiza la lista global de palabras
//   excluidas (excludeWords). Guarda la lista en localStorage y actualiza el panel visual.
//**************************************************************************
    function importExcludeList(file) {
        const reader = new FileReader();
        reader.onload = function (e) {
            const newWords = e.target.result
            .split(/\r?\n/)
            .map(w => w.trim())
            .filter(w => w.length > 0); // eliminar vacíos

            excludeWords = [...new Set(newWords)].sort((a, b) => a.localeCompare(b));
            localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
            renderExcludeList(); // actualiza el panel visual
        };
        reader.readAsText(file);
    }

//**************************************************************************
//Nombre: applySpellCorrection
//Fecha modificación: 2025-03-31
//Autor: mincho77
//Entradas:
//   text (string) – Texto que se desea corregir automáticamente con sugerencias ortográficas.
//Salidas:
//   Promise<string> – Texto corregido con las mejores sugerencias aplicadas.
//Prerrequisitos:
//   Debe existir la función checkSpelling que retorna los errores detectados por LanguageTool.
//Descripción:
//   Llama a checkSpelling y aplica la primera sugerencia de reemplazo para cada error ortográfico,
//   procesando los reemplazos de atrás hacia adelante (para evitar desajustes de índice).
//   Devuelve el texto corregido como resultado final.
//**************************************************************************

    function applySpellCorrection(text) {
        return checkSpelling(text).then(data => {
            let corrected = text;
            // Ordenar los matches de mayor a menor offset
            const matches = data.matches.sort((a, b) => b.offset - a.offset);
            matches.forEach(match => {
                if (match.replacements && match.replacements.length > 0) {
                    const replacement = match.replacements[0].value;
                    corrected = corrected.substring(0, match.offset) + replacement + corrected.substring(match.offset + match.length);
                }
            });
            return corrected;
        });
    }



//**************************************************************************
//Nombre: createSidebarTab
//Fecha modificación: 2025-03-31
//Autor: mincho77
//Entradas: Ninguna directa. Usa `W.userscripts` para registrar el tab.
//Salidas: Renderiza un nuevo tab lateral personalizado con interfaz del normalizador.
//Prerrequisitos:
//  - `W.userscripts.registerSidebarTab` debe estar disponible.
//  - `getSidebarHTML()` debe retornar el HTML necesario.
//Descripción:
//  Crea y registra una nueva pestaña en el sidebar de WME con el título
//  "Places Name Normalizer". Al cargar correctamente, inserta el HTML
//  generado por `getSidebarHTML()` y espera a que se renderice completamente
//  para ejecutar los eventos mediante `waitForDOM`.
//**************************************************************************
function createSidebarTab() {
    try {
        // Check if the sidebar system is ready
        if (!W || !W.userscripts) {
            console.error(`[${SCRIPT_NAME}] WME not ready for sidebar creation`);
            return;
        }

        // Check for existing tab and clean up if needed
        const existingTab = document.getElementById("normalizer-tab");
        if (existingTab) {
            console.log(`[${SCRIPT_NAME}] Removing existing tab...`);
            existingTab.remove();
        }

        // Register new tab with error handling
        let registration;
        try {
            registration = W.userscripts.registerSidebarTab("PlacesNormalizer");
        } catch (e) {
            if (e.message.includes("already been registered")) {
                console.warn(`[${SCRIPT_NAME}] Tab registration conflict, attempting cleanup...`);
                // Additional cleanup could go here
                return;
            }
            throw e;
        }

        const { tabLabel, tabPane } = registration;
        
        if (!tabLabel || !tabPane) {
            throw new Error("Tab registration failed to return required elements");
        }

        // Configure tab
        tabLabel.innerHTML = `
        <img src="data:image/jpeg;base64,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"
        style="height: 16px; vertical-align: middle; margin-right: 5px;">
        NrmliZer
        `;
        tabLabel.title = "Places Name Normalizer";
        
        // Set content and attach events
        tabPane.innerHTML = getSidebarHTML();
        waitForDOM("#normalizer-tab", (element) => {
            attachEvents();
            console.log(`[${SCRIPT_NAME}] Tab created and events attached`);
        }, 500, 10);

    } catch (error) {
        console.error(`[${SCRIPT_NAME}] Error in createSidebarTab:`, error);
    }
}

   

//**************************************************************************
//Nombre: attachEvents
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: Ninguna
//Salidas: Ninguna
//Prerrequisitos si existen:
// - Deben existir en el DOM los elementos con los siguientes IDs:
//   "normalizeArticles", "maxPlacesInput", "addExcludeWord", "scanPlaces",
//   "hiddenImportInput", "importExcludeWordsUnifiedBtn" y "exportExcludeWords".
// - Debe existir la función handleImportList y la función scanPlaces.
// - Debe estar definida la variable global excludeWords y la función renderExcludedWordsPanel.
//Descripción:
// Esta función adjunta los event listeners necesarios para gestionar la interacción del usuario
// con el panel del normalizador de nombres. Se encargan de:
//   - Actualizar la opción de normalizar artículos al cambiar el estado del checkbox.
//   - Modificar el número máximo de lugares a procesar a través de un input.
//   - Exportar la lista de palabras excluidas a un archivo XML.
//   - Añadir nuevas palabras a la lista de palabras excluidas, evitando duplicados, y actualizar el panel.
//   - Activar el botón unificado para la importación de palabras excluidas mediante un input oculto.
//   - Ejecutar la función de escaneo de lugares al hacer clic en el botón correspondiente.
//**************************************************************************
    function attachEvents()
    {
        console.log(`[${SCRIPT_NAME}] Adjuntando eventos...`);

        const normalizeArticlesCheckbox = document.getElementById("normalizeArticles");
        const maxPlacesInput = document.getElementById("maxPlacesInput");
        const addExcludeWordButton = document.getElementById("addExcludeWord");
        const scanPlacesButton = document.getElementById("scanPlaces");
        const hiddenInput = document.getElementById("hiddenImportInput");
        const importButtonUnified = document.getElementById("importExcludeWordsUnifiedBtn");

        // Validación de elementos necesarios
        if (!normalizeArticlesCheckbox || !maxPlacesInput || !addExcludeWordButton || !scanPlacesButton) {
            console.error(`[${SCRIPT_NAME}] Error: No se encontraron elementos en el DOM.`);
            return;
        }

        // ✅ Evento: cambiar estado de "no normalizar artículos"
        normalizeArticlesCheckbox.addEventListener("change", (e) => {
            normalizeArticles = e.target.checked;
        });

        // ✅ Evento: cambiar número máximo de places
        maxPlacesInput.addEventListener("input", (e) => {
            maxPlaces = parseInt(e.target.value, 10);
        });

        // ✅ Evento: exportar palabras excluidas a XML
        document.getElementById("exportExcludeWords").addEventListener("click", () => {
            const savedWords = JSON.parse(localStorage.getItem("excludeWords")) || [];
            if (savedWords.length === 0) {
                alert("No hay palabras excluidas para exportar.");
                return;
            }
            const sortedWords = [...savedWords].sort((a, b) => a.localeCompare(b));

            const xmlContent = `<?xml version="1.0" encoding="UTF-8"?>
<ExcludedWords>
  ${sortedWords.map(word => `  <word>${word}</word>`).join("\n  ")}
</ExcludedWords>`;

            const blob = new Blob([xmlContent], { type: "application/xml" });
            const url = URL.createObjectURL(blob);
            const link = document.createElement("a");

            link.href = url;
            link.download = "excluded_words.xml";
            document.body.appendChild(link); // Correctly appends the link
            link.click();
            document.body.removeChild(link); // Correctly removes the link
        });

        // ✅ Evento: añadir palabra excluida sin duplicados
        addExcludeWordButton.addEventListener("click", () => {
            const wordInput = document.getElementById("excludeWord") || document.getElementById("excludedWord");
            const word = wordInput?.value.trim();

            if (!word) return;

            const lowerWord = word.toLowerCase();
            const alreadyExists = excludeWords.some(w => w.toLowerCase() === lowerWord);
            if (!alreadyExists) {
                excludeWords.push(word);
                localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
                renderExcludedWordsPanel(); // Usar renderExcludedWordsPanel en lugar de updateExcludeList
            }

            wordInput.value = "";
        });

        // ✅ Evento: nuevo botón unificado de importación
        importButtonUnified.addEventListener("click", () => {
            hiddenInput.click(); // abre el file input oculto
        });

        hiddenInput.addEventListener("change", () => {
            handleImportList(); // ✅ Llama a la función handleImportList al importar
        });

        // ✅ Evento: escanear lugares
        scanPlacesButton.addEventListener("click", scanPlaces);
    }


    function updateExcludeList() {
        const list = document.getElementById("excludedWordsList");
        if (!list) return;

        // Ordena una copia del array para no alterar el original
        const sortedWords = [...excludeWords].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));

        list.innerHTML = sortedWords.map(word => `<li>${word}</li>`).join("");
    }

//**************************************************************************
//Nombre: scanPlaces
//Fecha modificación: 2025-04-02
//Autor: mincho77
//Entradas: ninguna directamente (usa datos del modelo WME y del input #maxPlacesInput)
//Salidas: abre panel flotante con lugares que deben ser normalizados
//Descripción:
// Escanea los lugares visibles en el WME, normaliza los nombres y verifica
// si les falta una tilde en las palabras que lo requieren. Si se selecciona
// "Revisar solo tildes", utiliza la función evaluarOrtografiaConTildes.
//**************************************************************************
// ==UserScript==
// @name         WME Places Name Normalizer
// @namespace    https://gf.qytechs.cn/en/users/mincho77
// @version      2.1
// @description  Normaliza nombres de lugares en Waze Map Editor (WME) por ahora solo con reglas de Colombia
// @author       mincho77
// @match        https://www.waze.com/*editor*
// @match      https://beta.waze.com/*user/editor*
// @grant        GM_xmlhttpRequest
// @connect      api.languagetool.org
// @connect      *
// @grant        unsafeWindow
// @license      MIT
// @run-at       document-end
// @downloadURL https://update.gf.qytechs.cn/scripts/530268/WME%20Places%20Name%20Normalizer.user.js
// @updateURL https://update.gf.qytechs.cn/scripts/530268/WME%20Places%20Name%20Normalizer.meta.js
// ==/UserScript==
/*global W*/

(() => {
    "use strict";
    try {


   // Insertar estilos globales en el <head>
const styles = `
<style>
    /* Estilos para los botones */
    .apply-suggestion-btn {
        background-color: #4CAF50;
        color: white;
        border: none;
        padding: 8px 12px;
        border-radius: 4px;
        cursor: pointer;
        font-size: 14px;
        font-weight: bold;
        transition: background-color 0.3s ease;
    }
    .apply-suggestion-btn:hover {
        background-color: #45a049;
    }

    #apply-changes-btn {
        background-color: #28a745;
        color: white;
        border: none;
        padding: 10px 15px;
        border-radius: 4px;
        cursor: pointer;
        font-size: 16px;
        font-weight: bold;
        display: flex;
        align-items: center;
        gap: 8px;
    }
    #apply-changes-btn:hover {
        background-color: #218838;
    }

    #cancel-btn {
        background-color: #dc3545;
        color: white;
        border: none;
        padding: 10px 15px;
        border-radius: 4px;
        cursor: pointer;
        font-size: 16px;
        font-weight: bold;
        display: flex;
        align-items: center;
        gap: 8px;
    }
    #cancel-btn:hover {
        background-color: #c82333;
    }
</style>
`;
document.head.insertAdjacentHTML('beforeend', styles);

    // Capturar todos los eventos de drag & drop a nivel de <body>
   // Agregar al inicio del script, justo después del "use strict"
    // Prevenir comportamiento por defecto de drag & drop a nivel global
    document.addEventListener("dragover", function(e) {
        const dropZone = document.getElementById("drop-zone");
        if (e.target === dropZone || dropZone?.contains(e.target)) {
            return; // Permitir el drop en la zona designada
        }
        e.preventDefault();
        e.stopPropagation();
    }, { passive: false }); // Agrega { passive: false }

    document.addEventListener("drop", function(e) {
        const dropZone = document.getElementById("drop-zone");
        if (e.target === dropZone || dropZone?.contains(e.target)) {
            return; // Permitir el drop en la zona designada
        }
        e.preventDefault();
        e.stopPropagation();
    }, { passive: false }); // Agrega { passive: false }

//**************************************************************************
//Nombre: evaluarOrtografiaConTildes
//Fecha modificación: 2025-04-02
//Autor: mincho77
//Entradas: name (string) - Nombre del lugar
//Salidas: objeto con errores detectados
//Descripción:
// Evalúa palabra por palabra si falta una tilde en las palabras que lo requieren,
// según las reglas del español. Primero normaliza el nombre y luego verifica
// si las palabras necesitan una tilde.
//**************************************************************************
function evaluarOrtografiaConTildes(name) {
    return new Promise((resolve) => {
        if (!name) {
            return resolve({
                hasSpellingWarning: false,
                spellingWarnings: []
            });
        }

        const palabras = name.trim().split(/\s+/);
        const spellingWarnings = [];
        const totalPalabras = palabras.length;
        let procesadas = 0;

        // Función para procesar cada palabra secuencialmente
        function procesarPalabra(index) {
            if (index >= palabras.length) {
                // Todas las palabras procesadas
                toggleSpinner(false);
                return resolve({
                    hasSpellingWarning: spellingWarnings.length > 0,
                    spellingWarnings
                });
            }

            const palabra = palabras[index];
            let normalizada = normalizePlaceNameOnly(palabra);

            // Actualizar progreso
            procesadas++;
            const progress = Math.round((procesadas / totalPalabras) * 100);
            toggleSpinner(true, 'Revisando tildes...', progress);

            // Lógica de verificación existente
            if (/^[A-Za-z]&[A-Za-z]$/.test(normalizada) || 
                /^[\u263a-\u263c\u2764\u1f600-\u1f64f\u1f680-\u1f6ff]+$/.test(normalizada)) {
                return procesarPalabra(index + 1);
            }

            if (normalizada.toLowerCase() === "y" || /^\d+$/.test(normalizada) || normalizada === "-") {
                return procesarPalabra(index + 1);
            }

            if (excludeWords.some(w => w.toLowerCase() === normalizada.toLowerCase())) {
                return procesarPalabra(index + 1);
            }

            const cantidadTildes = (normalizada.match(/[áéíóú]/g) || []).length;
            if (cantidadTildes > 1) {
                spellingWarnings.push({
                    original: palabra,
                    sugerida: null,
                    tipo: "Error de tildes",
                    posicion: index
                });
            }

            // Procesar siguiente palabra
            procesarPalabra(index + 1);
        }

        // Iniciar el procesamiento
        toggleSpinner(true, 'Revisando tildes...', 0);
        procesarPalabra(0);
    });
}

// Función auxiliar para corregir tildes
function corregirTilde(palabra) {
    // Implementar lógica para corregir tildes según las reglas del español
    // Por ejemplo: "medellin" → "Medellín"
    return palabra; // Retornar la palabra corregida
}

    //**************************************************************************
    //Nombre: toggleSpinner
    //Fecha modificación: 2025-03-31
    //Autor: mincho77
    //Entradas:
    //   show (boolean) - true para mostrar el spinner, false para ocultarlo
    //   message (string, opcional) - mensaje personalizado a mostrar junto al spinner
    //Salidas: ninguna (modifica el DOM)
    //Prerrequisitos: debe existir el estilo CSS del spinner en el documento
    //Descripción:
    //   Muestra u oculta un indicador visual de carga con un mensaje opcional.
    //   El spinner usa un emoji de reloj de arena (⏳) con animación de rotación
    //   para indicar que el proceso está en curso.
    //**************************************************************************
    function toggleSpinner(show, message = 'Revisando ortografía...', progress = null) {
        let existingSpinner = document.querySelector('.spinner-overlay');
        if (existingSpinner) {
            if (show) {
                // Actualizar el mensaje y el progreso si el spinner ya existe
                const spinnerMessage = existingSpinner.querySelector('.spinner-message');
                spinnerMessage.innerHTML = `
                    ${message}
                    ${progress !== null ? `<br><strong>${progress}% completado</strong>` : ''}
                `;
            } else {
                existingSpinner.remove(); // Ocultar el spinner
            }
            return;
        }
    
        if (show) {
            const spinner = document.createElement('div');
            spinner.className = 'spinner-overlay';
            spinner.innerHTML = `
                <div class="spinner-content">
                    <div class="spinner-icon">⏳</div>
                    <div class="spinner-message">
                        ${message}
                        ${progress !== null ? `<br><strong>${progress}% completado</strong>` : ''}
                    </div>
                </div>
            `;
            document.body.appendChild(spinner);
        }
    }

    // Agregar los estilos CSS necesarios
    const spinnerStyles = `
    <style>
    .spinner-overlay {
        position: fixed;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background: rgba(0, 0, 0, 0.5);
        display: flex;
        justify-content: center;
        align-items: center;
        z-index: 10000;
    }

    .spinner-content {
        background: white;
        padding: 20px;
        border-radius: 8px;
        text-align: center;
        box-shadow: 0 2px 10px rgba(0,0,0,0.3);
    }

    .spinner-icon {
        font-size: 24px;
        margin-bottom: 10px;
        animation: spin 1s linear infinite; /* Aseguramos que la animación esté activa */
        display: inline-block;
    }

    .spinner-message {
        color: #333;
        font-size: 14px;
    }

    @keyframes spin {
        0% { transform: rotate(0deg); }
        100% { transform: rotate(360deg); }
    }
    </style>`;

    // Insertar los estilos al inicio del documento
    document.head.insertAdjacentHTML('beforeend', spinnerStyles);

    if (!Array.prototype.flat)
    {
        Array.prototype.flat = function(depth = 1)
        {
            return this.reduce(function (flat, toFlatten)
            {
                return flat.concat(Array.isArray(toFlatten) ? toFlatten.flat(depth - 1) : toFlatten);
            }, []);
        };
    }
    const SCRIPT_NAME = "PlacesNameNormalizer";
    const VERSION = "2.1";
    let placesToNormalize = [];
    let excludeWords = [];
    let maxPlaces = 100;
    let normalizeArticles = true;

    // Expresión regular para detectar siglas (por ejemplo, "S.a.s", "L.T.D.A")
    const siglaRegex = /^[A-Za-z](\.[A-Za-z])+\.?$/;



    
    //**************************************************************************
    //Nombre: waitForSidebar
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas:
    //   retries (número, opcional) – Número máximo de intentos de verificación del sidebar (default: 20).
    //   delay (número, opcional) – Tiempo en milisegundos entre intentos (default: 1000ms).
    //Salidas:
    //   Promesa que se resuelve con el elemento del sidebar si se encuentra, o se rechaza si no se encuentra después de los intentos.
    //Descripción:
    //   Esta función espera a que el DOM cargue completamente el elemento con ID "sidebar".
    //   Realiza múltiples intentos con intervalos definidos, y resuelve la promesa cuando el sidebar esté disponible.
    //   Es útil para asegurarse de que el entorno de WME esté completamente cargado antes de continuar.
    //**************************************************************************
    function waitForSidebar(retries = 20, delay = 1000) {
        return new Promise((resolve, reject) => {
            const check = (attempt = 1) => {
                const sidebar = document.querySelector("#sidebar");
                if (sidebar) {
                    console.log("✅ Sidebar disponible.");
                    resolve(sidebar);
                } else if (attempt <= retries) {
                    console.warn(`⚠️ Sidebar no disponible aún. Reintentando... (${attempt})`);
                    setTimeout(() => check(attempt + 1), delay);
                } else {
                    reject("❌ Sidebar no disponible después de múltiples intentos.");
                }
            };
            check();
        });
    }
    //**************************************************************************
    //Nombre: initializeExcludeWords
    //Fecha modificación: 2025-03-31
    //Autor: mincho77
    //Entradas: Ninguna
    //Salidas: Ninguna
    //Prerrequisitos: excludeWords debe estar declarado globalmente.
    //Descripción: Inicializa la lista de palabras excluidas desde localStorage sin borrar entradas ya existentes.
    //**************************************************************************
    function initializeExcludeWords() {
        const saved = JSON.parse(localStorage.getItem("excludeWords")) || [];

        // Combinar con las actuales sin duplicar
        const merged = [...new Set([...saved, ...excludeWords])].sort((a, b) => a.localeCompare(b));

        // Solo guardar si hay diferencias
        const originalString = JSON.stringify(saved.sort());
        const newString = JSON.stringify(merged);

        if (originalString !== newString) {
            localStorage.setItem("excludeWords", newString);
            console.log(`[initializeExcludeWords] 💾 excludeWords actualizado con ${merged.length} palabras.`);
        } else {
            console.log(`[initializeExcludeWords] 🟢 Sin cambios en excludeWords.`);
        }

        // Actualizar variable global
        excludeWords = merged;
    }

    //Modulo de Ortografía
   



   

    // Insertar los estilos al inicio del documento
    document.head.insertAdjacentHTML('beforeend', spinnerStyles);

    if (!Array.prototype.flat)
    {
        Array.prototype.flat = function(depth = 1)
        {
            return this.reduce(function (flat, toFlatten)
            {
                return flat.concat(Array.isArray(toFlatten) ? toFlatten.flat(depth - 1) : toFlatten);
            }, []);
        };
    }
   

    
    //Modulo de Ortografía
    //**************************************************************************
    //Nombre: getUbicacionAcento
    //Fecha modificación: 2025-03-27
    //Autor: mincho77
    //Entradas: palabra (string) - Palabra en español a evaluar
    //Salidas: Posición del acento (última, penúltima, antepenúltima) o null si no aplica
    //Prerrequisitos si existen: Ninguno
    //Descripción: Determina la posición silábica del acento en una palabra según la ortografía del español.
    //**************************************************************************
    function getUbicacionAcento(palabra) {
        const original = palabra;
        const normalized = palabra.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); // Remove diacritics
        const silabas = normalized
            .replace(/[^aeiou]/gi, "")
            .match(/[aeiou]+/gi);

        if (!silabas || silabas.length === 0) return null;

        const conTilde = silabas.findIndex((s, i) => {
            const originalSyllable = original.slice(
                normalized.indexOf(s),
                normalized.indexOf(s) + s.length
            );
            return /[áéíóú]/.test(originalSyllable);
        });

        if (conTilde !== -1) {
            const posicion = silabas.length - 1 - conTilde;
            if (posicion === 0) return "última";
            if (posicion === 1) return "penúltima";
            if (posicion === 2) return "antepenúltima";
            return "otras";
        }

        return "sin tilde";
    }

    //**************************************************************************
    //Nombre: checkSpellingWithAPI
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: text (string) – Texto a evaluar ortográficamente.
    //Salidas: Promise – Resuelve con lista de errores ortográficos detectados.
    //Prerrequisitos: Requiere permisos GM_xmlhttpRequest y @connect a api.languagetool.org
    //Descripción:
    // Consulta la API de LanguageTool para verificar ortografía del texto.
    //**************************************************************************
    function checkSpellingWithAPI(text) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "POST",
                url: "https://api.languagetool.org/v2/check",
                headers: {
                    "Content-Type": "application/x-www-form-urlencoded"
                },
                data: `language=es&text=${encodeURIComponent(text)}`,
                onload: function(response) {
                    if (response.status === 200) {
                        const result = JSON.parse(response.responseText);
                        const errores = result.matches
                        .filter(match => match.rule.issueType === "misspelling")
                        .map(match => ({
                            palabra: match.context.text.substring(match.context.offset, match.context.offset + match.context.length),
                            sugerencia: match.replacements.length > 0 ? match.replacements[0].value : "(sin sugerencia)"
                        }));
                        resolve(errores);
                    } else {
                        reject("❌ Error en respuesta de LanguageTool");
                    }
                },
                onerror: function(err) {
                    reject("❌ Error de red al contactar LanguageTool");
                }
            });
        });
    }

    window.checkSpellingWithAPI = checkSpellingWithAPI;

// Combinar la lógica de checkSpelling
function checkSpelling(text) {
    return new Promise((resolve, reject) => {
        GM_xmlhttpRequest({
            method: "POST",
            url: "https://api.languagetool.org/v2/check",
            data: `text=${encodeURIComponent(text)}&language=es`,
            headers: {
                "Content-Type": "application/x-www-form-urlencoded"
            },
            onload: function (response) {
                if (response.status === 200) {
                    try {
                        const data = JSON.parse(response.responseText);
                        const errores = data.matches
                            .filter(match => match.rule.issueType === "misspelling")
                            .map(match => ({
                                palabra: match.context.text.substring(match.context.offset, match.context.offset + match.context.length),
                                sugerencia: match.replacements.length > 0 ? match.replacements[0].value : "(sin sugerencia)"
                            }));
                        resolve(errores);
                    } catch (err) {
                        reject(err);
                    }
                } else {
                    reject(`Error HTTP: ${response.status}`);
                }
            },
            onerror: function (err) {
                reject(err);
            }
        });
    });
}   

    //**************************************************************************
    //Nombre: validarWordSpelling
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: palabra (string) - Palabra en español a validar ortográficamente
    //Salidas: true si cumple reglas ortográficas básicas, false si no
    //Descripción:
    // Evalúa si una palabra tiene el uso correcto de tilde o si le falta una tilde
    // según las reglas del español: esdrújulas siempre con tilde, agudas con tilde
    // si terminan en n, s o vocal, y llanas con tildse si NO terminan en n, s o vocal.
    // Se asegura que solo haya una tilde por palabra.
    //**************************************************************************
    function validarWordSpelling(palabra) {
        if (!palabra) return false;

        // Ignorar siglas con formato X&X
        if (/^[A-Za-z]&[A-Za-z]$/.test(palabra)) return true;

        // Si la palabra es un número, no necesita validación
        if (/^\d+$/.test(palabra)) return true;

        const tieneTilde = /[áéíóú]/.test(palabra);
        const cantidadTildes = (palabra.match(/[áéíóú]/g) || []).length;
        if (cantidadTildes > 1) return false; // Solo se permite una tilde

        const silabas = palabra.normalize("NFD").replace(/[^aeiouAEIOU\u0300-\u036f]/g, "").match(/[aeiouáéíóú]+/gi);
        if (!silabas || silabas.length === 0) return false;

        const totalSilabas = silabas.length;
        const ultimaLetra = palabra.slice(-1).toLowerCase();

        let tipo = "";
        if (totalSilabas >= 3 && /[áéíóú]/.test(palabra)) {
            tipo = "esdrújula";
        } else if (totalSilabas >= 2) {
            const penultimaSilaba = silabas[totalSilabas - 2];
            if (/[áéíóú]/.test(penultimaSilaba)) tipo = "grave";
        }

        if (!tipo) tipo = /[áéíóú]/.test(silabas[totalSilabas - 1]) ? "aguda" : "sin tilde";

        if (tipo === "esdrújula") return tieneTilde;
        if (tipo === "aguda") {
            return (/[nsáéíóúaeiou]$/.test(ultimaLetra) && tieneTilde) ||
                (!/[nsáéíóúaeiou]$/.test(ultimaLetra) && !tieneTilde);
        }
        if (tipo === "grave") {
            return (!/[nsáéíóúaeiou]$/.test(ultimaLetra) && tieneTilde) ||
                (/[nsáéíóúaeiou]$/.test(ultimaLetra) && !tieneTilde);
        }

        return true;
    }



    ///**************************************************************************
    //Nombre: separarSilabas
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: palabra (string) – Palabra en español.
    //Salidas: array de sílabas (aproximado).
    //Descripción:
    //   Separa una palabra en sílabas usando reglas heurísticas.
    //   Esta versión simplificada considera diptongos y combinaciones comunes.
    //**************************************************************************
    function separarSilabas(palabra) {
        // Normaliza y quita acentos para facilitar la segmentación
        const limpia = palabra.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();

        // Divide por vocales agrupadas como aproximación de sílabas
        const silabas = limpia.match(/[bcdfghjklmnñpqrstvwxyz]*[aeiou]{1,2}[bcdfghjklmnñpqrstvwxyz]*/g);

        return silabas || [palabra]; // fallback si no separa nada
    }

    //**************************************************************************
    //Nombre: clasificarPalabra
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: silabas (array) – Arreglo con las sílabas de la palabra.
    //Salidas: string – 'aguda', 'grave' (llana), 'esdrújula'.
    //Descripción:
    //   Determina el tipo de palabra según el número de sílabas y la posición
    //   de la tilde (si existe).
    //**************************************************************************
    function clasificarPalabra(silabas) {
        const palabra = silabas.join("");
        const tieneTilde = /[áéíóúÁÉÍÓÚ]/.test(palabra);

        if (tieneTilde) {
            const posicionTilde = silabas.findIndex(s => /[áéíóúÁÉÍÓÚ]/.test(s));
            const posicionDesdeFinal = silabas.length - 1 - posicionTilde;

            if (posicionDesdeFinal === 0) return "aguda";
            if (posicionDesdeFinal === 1) return "grave";
            if (posicionDesdeFinal >= 2) return "esdrújula";
        }

        // Si no tiene tilde, asumimos que es:
        if (silabas.length === 1) return "aguda";
        if (silabas.length === 2) return "grave";
        return "grave"; // por convención para 3+ sin tilde
    }

    //**************************************************************************
    //Nombre: evaluarOrtografiaNombre
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: name (string) - Nombre del lugar
    //Salidas: objeto con errores detectados
    //Descripción: Evalúa palabra por palabra si hay errores ortográficos o falta de tildes.
    // Ya no utiliza sugerencias automáticas para correcciones.
    //**************************************************************************
    function evaluarOrtografiaNombre(name) {
        if (!name) return {
            hasSpellingWarning: false,
            spellingWarnings: []
        };

        const checkOnlyTildes = document.getElementById("checkOnlyTildes")?.checked;
        const palabras = name.trim().split(/\s+/);
        const spellingWarnings = [];

        console.log(`[evaluarOrtografiaNombre] Verificando ortografía de: ${name}`);

        if (checkOnlyTildes) {
            palabras.forEach((palabra, index) => {
                // Verificar si la palabra está en la lista de exclusiones
                if (excludeWords.some(w => w.toLowerCase() === palabra.toLowerCase()) || /^\d+$/.test(palabra)) {
                    return; // Ignorar palabra excluida
                }

                if (!validarWordSpelling(palabra)) {
                    spellingWarnings.push({
                        original: palabra,
                        sugerida: null,
                        tipo: "Tilde",
                        posicion: index // Guardar la posición en la frase
                    });
                   
                }
            });

            return Promise.resolve({
                hasSpellingWarning: spellingWarnings.length > 0,
                spellingWarnings
            });
        } else {
            return checkSpellingWithAPI(name)
                .then(errores => {
                    errores.forEach(error => {
                        // Verificar si la palabra está en exclusiones
                        if (!excludeWords.some(w => w.toLowerCase() === error.palabra.toLowerCase())) {
                            spellingWarnings.push({
                                original: error.palabra,
                                sugerida: error.sugerencia,
                                tipo: "LanguageTool",
                                posicion: name.indexOf(error.palabra)
                            });
                        }
                    });
                    return {
                        hasSpellingWarning: spellingWarnings.length > 0,
                        spellingWarnings
                    };
                });
        }
    }
    
    //**************************************************************************
    //Nombre: handleImportList
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: ninguna directa, depende del input file y checkbox en el DOM
    //Salidas: actualiza excludeWords en localStorage y visualmente
    //Prerrequisitos: existir un input file con id="importListInput" y checkbox con id="replaceExcludeListCheckbox"
    //Descripción: Importa una lista de palabras para excluir, opcionalmente reemplazando la lista actual
    //**************************************************************************
    function handleImportList() {
        const fileInput = document.getElementById("importListInput");
        const replaceCheckbox = document.getElementById("replaceExcludeListCheckbox");

        if (!fileInput || !fileInput.files || fileInput.files.length === 0) {
            alert("No se seleccionó ningún archivo.");
            return;
        }

        const reader = new FileReader();
        reader.onload = function (event)
        {
            const rawLines = event.target.result.split(/\r?\n/);

            const lines = rawLines
            .map(line => line.replace(/[^\p{L}\p{N}().\s-]/gu, "").trim())
            .filter(line => line.length > 0);

            const eliminadas = rawLines.length - lines.length;
            if (eliminadas > 0) {
                console.warn(`[Importar Lista] ⚠️ ${eliminadas} líneas inválidas fueron ignoradas (vacías, caracteres no permitidos o basura).`);
            }

            if (replaceCheckbox && replaceCheckbox.checked) {
                // Si se marcó reemplazar, limpiar todo
                excludeWords = [];
            } else {
                // Si no, recuperar la actual del localStorage
                excludeWords = JSON.parse(localStorage.getItem("excludeWords")) || excludeWords || [];
            }

            // Unificar, eliminar duplicados y ordenar
            excludeWords = [...new Set([...excludeWords, ...lines])]
                .filter(w => w.trim().length > 0)
                .sort((a, b) => a.localeCompare(b));

            localStorage.setItem("excludeWords", JSON.stringify(excludeWords));

            console.log("[handleImportList] Palabras actuales excluidas:", excludeWords);

            renderExcludedWordsPanel(); // O renderExcludeWordList() si usas otro nombre
            setupDragAndDropImport(); // Activa drag and drop

            alert(`✅ Palabras excluidas importadas correctamente: ${excludeWords.length}`);

            //Limpia el input para permitir recarga posterior
            fileInput.value = "";
        };

        reader.readAsText(fileInput.files[0]);
    }

    //**************************************************************************
    //Nombre: setupDragAndDropImport
    //Fecha modificación: 2025-03-31
    //Autor: mincho77
    //Entradas: ninguna
    //Salidas: habilita funcionalidad de arrastrar y soltar archivos al panel
    //Descripción:
    // Permite arrastrar y soltar un archivo .txt sobre el panel lateral (#normalizer-sidebar).
    // Extrae las palabras del archivo y las agrega a excludeWords sin duplicados.
    //**************************************************************************
    function setupDragAndDropImport() {
        const dropArea = document.getElementById("drop-zone");
        if (!dropArea) {
            console.warn("[setupDragAndDropImport] No se encontró la zona de drop");
            return;
        }

        // Highlight drop zone when dragging over it
        const highlight = (e) => {
            e.preventDefault();
            e.stopPropagation();
            dropArea.classList.add("drag-over");
            dropArea.style.backgroundColor = "#e8f5e9";
            dropArea.style.borderColor = "#4CAF50";
        };

        // Remove highlighting
        const unhighlight = (e) => {
            e.preventDefault();
            e.stopPropagation();
            dropArea.classList.remove("drag-over");
            dropArea.style.backgroundColor = "";
            dropArea.style.borderColor = "#ccc";
        };

        // Handle the dropped files
        const handleDrop = (e) => {
            console.log("[handleDrop] Evento drop detectado");
            e.preventDefault();
            e.stopPropagation();
            unhighlight(e);

            const file = e.dataTransfer.files[0];
            if (!file) {
                alert("❌ No se detectó ningún archivo");
                return;
            }

            // Validar extensión del archivo
            if (!file.name.match(/\.(txt|xml)$/i)) {
                alert("❌ Por favor, arrastra un archivo .txt o .xml");
                return;
            }

            console.log(`[handleDrop] Procesando archivo: ${file.name}`);

            const reader = new FileReader();
            reader.onload = (event) => {
                try {
                    const content = event.target.result;
                    const isXML = file.name.toLowerCase().endsWith('.xml');
                    let words = [];

                    if (isXML) {
                        const parser = new DOMParser();
                        const xmlDoc = parser.parseFromString(content, "text/xml");
                        if (xmlDoc.getElementsByTagName("parsererror").length > 0) {
                            throw new Error("XML inválido");
                        }
                        words = Array.from(xmlDoc.getElementsByTagName("word"))
                            .map(node => node.textContent.trim())
                            .filter(w => w.length > 0);
                    } else {
                        words = content.split(/\r?\n/)
                            .map(line => line.trim())
                            .filter(line => line.length > 0);
                    }

                    if (words.length === 0) {
                        alert("⚠️ No se encontraron palabras válidas en el archivo");
                        return;
                    }

                    // Actualizar lista de palabras excluidas
                    excludeWords = [...new Set([...excludeWords, ...words])].sort();
                    localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
                    renderExcludedWordsPanel();

                    alert(`✅ Se importaron ${words.length} palabras exitosamente`);
                    console.log(`[handleDrop] Importadas ${words.length} palabras`);

                } catch (error) {
                    console.error("[handleDrop] Error procesando archivo:", error);
                    alert("❌ Error procesando el archivo");
                }
            };

            reader.onerror = () => {
                console.error("[handleDrop] Error leyendo archivo");
                alert("❌ Error leyendo el archivo");
            };

            reader.readAsText(file);
        };

        // Attach the event listeners
        dropArea.addEventListener("dragenter", highlight, false);
        dropArea.addEventListener("dragover", highlight, false);
        dropArea.addEventListener("dragleave", unhighlight, false);
        dropArea.addEventListener("drop", handleDrop, false);

        console.log("[setupDragAndDropImport] Eventos de drag & drop configurados");
    }

    // Función auxiliar para procesar el archivo
    function handleImportedFile(content, isXML) {
        let words = [];

        if (isXML) {
            const parser = new DOMParser();
            const xmlDoc = parser.parseFromString(content, "text/xml");
            const nodes = xmlDoc.getElementsByTagName("word");
            words = Array.from(nodes).map(node => node.textContent.trim());
        } else {
            words = content.split(/\r?\n/).map(line => line.trim()).filter(line => line);
        }

        if (words.length === 0) {
            alert("No se encontraron palabras válidas en el archivo");
            return;
        }

        // Actualizar lista de palabras excluidas
        excludeWords = [...new Set([...excludeWords, ...words])].sort();
        localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
        renderExcludedWordsPanel();

        alert(`✅ Se importaron ${words.length} palabras exitosamente`);
    }

    //**************************************************************************
    //Nombre: renderExcludedWordsPanel
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: ninguna (usa la variable global excludeWords)
    //Salidas: ninguna (actualiza el DOM y el localStorage)
    //Prerrequisitos si existen: Debe existir un contenedor con id="normalizer-sidebar"
    //Descripción:
    // Esta función limpia y vuelve a renderizar la lista de palabras excluidas
    // dentro del panel lateral del normalizador. Muestra una lista ordenada
    // alfabéticamente, evitando palabras vacías. Además, asegura que el localStorage
    // se actualice correctamente con la lista limpia y depurada.
    //**************************************************************************
    function renderExcludedWordsPanel() {
        const container = document.getElementById("normalizer-sidebar");
        if (!container) {
            console.warn("[renderExcludedWordsPanel] ❌ No se encontró el contenedor 'normalizer-sidebar'");
            return;
        }

        //Limpiar el contenedor visual
        container.innerHTML = "";
        console.log("[renderExcludedWordsPanel] ✅ Contenedor limpiado.");

        //Limpiar palabras vacías y ordenar
        const sortedWords = excludeWords.filter(w => !!w).sort((a, b) => a.localeCompare(b));
        console.log(`[renderExcludedWordsPanel] 📋 Lista excluida depurada: ${sortedWords.length} palabras`, sortedWords);

        const excludeListSection = document.createElement("div");
        excludeListSection.style.marginTop = "20px";
        excludeListSection.innerHTML = `
        <h4 style="margin-bottom: 5px;">Palabras Excluidas</h4>
        <div style="max-height: 150px; overflow-y: auto; border: 1px solid #ccc; padding: 8px; font-size: 13px; border-radius: 4px;">
            <ul style="margin: 0; padding-left: 18px;" id="excludeWordsList">
                ${sortedWords.map(w => `<li>${w}</li>`).join("")}
            </ul>
        </div>
    `;
        container.appendChild(excludeListSection);
        console.log("[renderExcludedWordsPanel] ✅ Lista renderizada en el DOM.");

        //Guardar en localStorage
        localStorage.setItem("excludeWords", JSON.stringify(sortedWords));
        console.log("[renderExcludedWordsPanel] 💾 excludeWords actualizado en localStorage.");
    }


    //**************************************************************************
    //Nombre: normalizePlaceName (unsafeWindow)
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas:
    //  - name (string): el nombre original del lugar.
    //Salidas:
    //  - string: nombre normalizado, respetando exclusiones y opciones del usuario.
    //Prerrequisitos si existen:
    //  - Debe estar cargada la lista global excludeWords.
    //  - Debe existir un checkbox con id “normalizeArticles” para definir si se normalizan artículos.
    //Descripción:
    //  Esta versión expuesta globalmente permite acceder a la normalización básica del nombre de un lugar
    //  desde otros contextos como el navegador o Tampermonkey. Capitaliza cada palabra, respeta las excluidas
    //  y no aplica normalización a artículos si el checkbox lo indica.
    //  Realiza limpieza básica: reemplazo de pipes, eliminación de espacios dobles y trim final.
    //**************************************************************************
    unsafeWindow.normalizePlaceName = function(name)
    {
        if (!name) return "";

        const normalizeArticles = !document.getElementById("normalizeArticles")?.checked;
        const articles = ["el", "la", "los", "las", "de", "del", "al", "y", "o"];

        const words = name.trim().split(/\s+/);
        const normalizedWords = words.map((word, index) => {
            const lowerWord = word.toLowerCase();

            // Saltar palabras excluidas
            if (excludeWords.includes(word)) return word;

            // Saltar artículos si el checkbox está activo y no es la primera palabra
            if (!normalizeArticles && articles.includes(lowerWord) && index !== 0) {
                return lowerWord;
            }
            //Mayúsculas
            return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
        });

        name = normalizedWords.join(" ");
        name = name.replace(/\s*\|\s*/g, " - ");
        name = name.replace(/\s{2,}/g, " ").trim();

        return name;
    };

    //**************************************************************************
    //Nombre: normalizePlaceNameOnly
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: name (string) – Nombre del lugar a normalizar.
    //Salidas: texto normalizado sin validación ortográfica.
    //Descripción:
    //   Realiza normalización visual del nombre: capitaliza, ajusta espacios,
    //   formatea guiones, paréntesis, y símbolos. No evalúa ortografía ni acentos.
    //**************************************************************************
    function normalizePlaceNameOnly(name) {
        if (!name) return "";
    
        const normalizeArticles = !document.getElementById("normalizeArticles")?.checked;
        const articles = ["el", "la", "los", "las", "de", "del", "al", "y"];
        const words = name.trim().split(/\s+/);
        const isRoman = word => /^(i{1,3}|iv|v|vi{0,3}|ix|x|xi{0,3}|xiv|xv|xvi{0,3}|xix|xx|xxi{0,3}|xxiv|xxv)$/i.test(word);
    
        const normalizedWords = words.map((word, index) => {
            const lowerWord = word.toLowerCase();
    
            // Si contiene "&", convertir a mayúsculas
            if (/^[A-Za-z]&[A-Za-z]$/.test(word)) return word.toUpperCase();
    
            // Verificar si está en la lista de excluidas
            const matchExcluded = excludeWords.find(w => w.toLowerCase() === lowerWord);
            if (matchExcluded) return matchExcluded;
    
            // Si es un número romano, convertir a mayúsculas
            if (isRoman(word)) return word.toUpperCase();
    
            // Si no se deben normalizar artículos y es un artículo, mantener en minúsculas
            if (!normalizeArticles && articles.includes(lowerWord) && index !== 0) return lowerWord;
    
            // Si es un número o un símbolo como "-", no modificar
            if (/^\d+$/.test(word) || word === "-") return word;
    
            // Verificar ortografía usando la API de LanguageTool
            return checkSpellingWithAPI(word)
                .then(errors => {
                    if (errors.length > 0) {
                        const suggestion = errors[0].sugerencia || word;
                        return suggestion.charAt(0).toUpperCase() + suggestion.slice(1).toLowerCase();
                    }
                    return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
                })
                .catch(() => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
        });
    
        let newName = normalizedWords.join(" ")
            .replace(/\s*\|\s*/g, " - ")
            .replace(/([(["'])\s*([\p{L}])/gu, (match, p1, p2) => p1 + p2.toUpperCase())
            .replace(/\s*-\s*/g, " - ")
            .replace(/\b(\d+)([A-Z])\b/g, (match, num, letter) => num + letter.toUpperCase())
            .replace(/\.$/, "")
            .replace(/&(\s*)([A-Z])/g, (match, space, letter) => "&" + space + letter.toUpperCase());
    
        return newName.replace(/\s{2,}/g, " ").trim();
    }

    //**************************************************************************
    //Nombre: validarOrtografiaConAPI
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: texto (string) - palabra o frase a evaluar
    //Salidas: Promesa con resultado de corrección ortográfica
    //Descripción: Consulta la API pública de LanguageTool para identificar errores ortográficos
    //**************************************************************************
    function validarOrtografiaConAPI2(texto) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "POST",
                url: "https://api.languagetool.org/v2/check",
                headers: {
                    "Content-Type": "application/x-www-form-urlencoded"
                },
                data: `text=${encodeURIComponent(texto)}&language=es`,
                onload: function (response) {
                    if (response.status === 200) {
                        const result = JSON.parse(response.responseText);
                        resolve(result.matches || []);
                    } else {
                        reject("Error en la API de LanguageTool");
                    }
                },
                onerror: function () {
                    reject("Fallo la solicitud a la API de LanguageTool");
                }
            });
        });
    }

//**************************************************************************
//Nombre: importExcludeList
//Fecha modificación: 2025-03-31
//Autor: mincho77
//Entradas:
//   file (File) – Archivo cargado por el usuario, que contiene una lista de palabras excluidas.
//Salidas:
//   Ninguna (actualiza el array global excludeWords y el localStorage).
//Prerrequisitos:
//   Debe existir un panel visual para mostrar la lista y una función renderExcludeList().
//Descripción:
//   Lee un archivo .txt línea por línea, limpia espacios, elimina duplicados y vacíos,
//   ordena alfabéticamente la lista resultante y actualiza la lista global de palabras
//   excluidas (excludeWords). Guarda la lista en localStorage y actualiza el panel visual.
//**************************************************************************
    function importExcludeList(file) {
        const reader = new FileReader();
        reader.onload = function (e) {
            const newWords = e.target.result
            .split(/\r?\n/)
            .map(w => w.trim())
            .filter(w => w.length > 0); // eliminar vacíos

            excludeWords = [...new Set(newWords)].sort((a, b) => a.localeCompare(b));
            localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
            renderExcludeList(); // actualiza el panel visual
        };
        reader.readAsText(file);
    }

//**************************************************************************
//Nombre: applySpellCorrection
//Fecha modificación: 2025-03-31
//Autor: mincho77
//Entradas:
//   text (string) – Texto que se desea corregir automáticamente con sugerencias ortográficas.
//Salidas:
//   Promise<string> – Texto corregido con las mejores sugerencias aplicadas.
//Prerrequisitos:
//   Debe existir la función checkSpelling que retorna los errores detectados por LanguageTool.
//Descripción:
//   Llama a checkSpelling y aplica la primera sugerencia de reemplazo para cada error ortográfico,
//   procesando los reemplazos de atrás hacia adelante (para evitar desajustes de índice).
//   Devuelve el texto corregido como resultado final.
//**************************************************************************

    function applySpellCorrection(text) {
        return checkSpelling(text).then(data => {
            let corrected = text;
            // Ordenar los matches de mayor a menor offset
            const matches = data.matches.sort((a, b) => b.offset - a.offset);
            matches.forEach(match => {
                if (match.replacements && match.replacements.length > 0) {
                    const replacement = match.replacements[0].value;
                    corrected = corrected.substring(0, match.offset) + replacement + corrected.substring(match.offset + match.length);
                }
            });
            return corrected;
        });
    }



//**************************************************************************
//Nombre: createSidebarTab
//Fecha modificación: 2025-03-31
//Autor: mincho77
//Entradas: Ninguna directa. Usa `W.userscripts` para registrar el tab.
//Salidas: Renderiza un nuevo tab lateral personalizado con interfaz del normalizador.
//Prerrequisitos:
//  - `W.userscripts.registerSidebarTab` debe estar disponible.
//  - `getSidebarHTML()` debe retornar el HTML necesario.
//Descripción:
//  Crea y registra una nueva pestaña en el sidebar de WME con el título
//  "Places Name Normalizer". Al cargar correctamente, inserta el HTML
//  generado por `getSidebarHTML()` y espera a que se renderice completamente
//  para ejecutar los eventos mediante `waitForDOM`.
//**************************************************************************
function createSidebarTab() {
    try {
        // Check if the sidebar system is ready
        if (!W || !W.userscripts) {
            console.error(`[${SCRIPT_NAME}] WME not ready for sidebar creation`);
            return;
        }

        // Check for existing tab and clean up if needed
        const existingTab = document.getElementById("normalizer-tab");
        if (existingTab) {
            console.log(`[${SCRIPT_NAME}] Removing existing tab...`);
            existingTab.remove();
        }

        // Register new tab with error handling
        let registration;
        try {
            registration = W.userscripts.registerSidebarTab("PlacesNormalizer");
        } catch (e) {
            if (e.message.includes("already been registered")) {
                console.warn(`[${SCRIPT_NAME}] Tab registration conflict, attempting cleanup...`);
                // Additional cleanup could go here
                return;
            }
            throw e;
        }

        const { tabLabel, tabPane } = registration;
        
        if (!tabLabel || !tabPane) {
            throw new Error("Tab registration failed to return required elements");
        }

        // Configure tab
        tabLabel.innerHTML = `
        <img src="data:image/jpeg;base64,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"
        style="height: 16px; vertical-align: middle; margin-right: 5px;">
        NrmliZer
        `;
        tabLabel.title = "Places Name Normalizer";
        
        // Set content and attach events
        tabPane.innerHTML = getSidebarHTML();
        waitForDOM("#normalizer-tab", (element) => {
            attachEvents();
            console.log(`[${SCRIPT_NAME}] Tab created and events attached`);
        }, 500, 10);

    } catch (error) {
        console.error(`[${SCRIPT_NAME}] Error in createSidebarTab:`, error);
    }
}

   

//**************************************************************************
//Nombre: attachEvents
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: Ninguna
//Salidas: Ninguna
//Prerrequisitos si existen:
// - Deben existir en el DOM los elementos con los siguientes IDs:
//   "normalizeArticles", "maxPlacesInput", "addExcludeWord", "scanPlaces",
//   "hiddenImportInput", "importExcludeWordsUnifiedBtn" y "exportExcludeWords".
// - Debe existir la función handleImportList y la función scanPlaces.
// - Debe estar definida la variable global excludeWords y la función renderExcludedWordsPanel.
//Descripción:
// Esta función adjunta los event listeners necesarios para gestionar la interacción del usuario
// con el panel del normalizador de nombres. Se encargan de:
//   - Actualizar la opción de normalizar artículos al cambiar el estado del checkbox.
//   - Modificar el número máximo de lugares a procesar a través de un input.
//   - Exportar la lista de palabras excluidas a un archivo XML.
//   - Añadir nuevas palabras a la lista de palabras excluidas, evitando duplicados, y actualizar el panel.
//   - Activar el botón unificado para la importación de palabras excluidas mediante un input oculto.
//   - Ejecutar la función de escaneo de lugares al hacer clic en el botón correspondiente.
//**************************************************************************
    function attachEvents()
    {
        console.log(`[${SCRIPT_NAME}] Adjuntando eventos...`);

        const normalizeArticlesCheckbox = document.getElementById("normalizeArticles");
        const maxPlacesInput = document.getElementById("maxPlacesInput");
        const addExcludeWordButton = document.getElementById("addExcludeWord");
        const scanPlacesButton = document.getElementById("scanPlaces");
        const hiddenInput = document.getElementById("hiddenImportInput");
        const importButtonUnified = document.getElementById("importExcludeWordsUnifiedBtn");

        // Validación de elementos necesarios
        if (!normalizeArticlesCheckbox || !maxPlacesInput || !addExcludeWordButton || !scanPlacesButton) {
            console.error(`[${SCRIPT_NAME}] Error: No se encontraron elementos en el DOM.`);
            return;
        }

        // ✅ Evento: cambiar estado de "no normalizar artículos"
        normalizeArticlesCheckbox.addEventListener("change", (e) => {
            normalizeArticles = e.target.checked;
        });

        // ✅ Evento: cambiar número máximo de places
        maxPlacesInput.addEventListener("input", (e) => {
            maxPlaces = parseInt(e.target.value, 10);
        });

        // ✅ Evento: exportar palabras excluidas a XML
        document.getElementById("exportExcludeWords").addEventListener("click", () => {
            const savedWords = JSON.parse(localStorage.getItem("excludeWords")) || [];
            if (savedWords.length === 0) {
                alert("No hay palabras excluidas para exportar.");
                return;
            }
            const sortedWords = [...savedWords].sort((a, b) => a.localeCompare(b));

            const xmlContent = `<?xml version="1.0" encoding="UTF-8"?>
<ExcludedWords>
  ${sortedWords.map(word => `  <word>${word}</word>`).join("\n  ")}
</ExcludedWords>`;

            const blob = new Blob([xmlContent], { type: "application/xml" });
            const url = URL.createObjectURL(blob);
            const link = document.createElement("a");

            link.href = url;
            link.download = "excluded_words.xml";
            document.body.appendChild(link); // Correctly appends the link
            link.click();
            document.body.removeChild(link); // Correctly removes the link
        });

        // ✅ Evento: añadir palabra excluida sin duplicados
        addExcludeWordButton.addEventListener("click", () => {
            const wordInput = document.getElementById("excludeWord") || document.getElementById("excludedWord");
            const word = wordInput?.value.trim();

            if (!word) return;

            const lowerWord = word.toLowerCase();
            const alreadyExists = excludeWords.some(w => w.toLowerCase() === lowerWord);
            if (!alreadyExists) {
                excludeWords.push(word);
                localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
                renderExcludedWordsPanel(); // Usar renderExcludedWordsPanel en lugar de updateExcludeList
            }

            wordInput.value = "";
        });

        // ✅ Evento: nuevo botón unificado de importación
        importButtonUnified.addEventListener("click", () => {
            hiddenInput.click(); // abre el file input oculto
        });

        hiddenInput.addEventListener("change", () => {
            handleImportList(); // ✅ Llama a la función handleImportList al importar
        });

        // ✅ Evento: escanear lugares
        scanPlacesButton.addEventListener("click", scanPlaces);
    }


    function updateExcludeList() {
        const list = document.getElementById("excludedWordsList");
        if (!list) return;

        // Ordena una copia del array para no alterar el original
        const sortedWords = [...excludeWords].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));

        list.innerHTML = sortedWords.map(word => `<li>${word}</li>`).join("");
    }

//**************************************************************************
//Nombre: scanPlaces
//Fecha modificación: 2025-04-02
//Autor: mincho77
//Entradas: ninguna directamente (usa datos del modelo WME y del input #maxPlacesInput)
//Salidas: abre panel flotante con lugares que deben ser normalizados
//Descripción:
// Escanea los lugares visibles en el WME, normaliza los nombres y verifica
// si les falta una tilde en las palabras que lo requieren. Si se selecciona
// "Revisar solo tildes", utiliza la función evaluarOrtografiaConTildes.
//**************************************************************************
function scanPlaces() {
    console.log("scanPlaces ejecutado");
    if (!W || !W.model || !W.model.venues || !W.model.venues.objects) {
        console.error(`[${SCRIPT_NAME}] WME no está listo.`);
        return;
    }

    const allPlaces = Object.values(W.model.venues.objects);
    console.log(`[${SCRIPT_NAME}] Total de lugares encontrados: ${allPlaces.length}`);

    const maxPlaces = parseInt(document.getElementById("maxPlacesInput").value, 10) || 20;
    const checkOnlyTildes = document.getElementById("checkOnlyTildes")?.checked; // Verificar el estado del checkbox
    const placesToNormalize = allPlaces
        .filter(p => {
            let isValid = true;

            if (!p) {
                console.log("Lugar descartado: p es null o undefined", p);
                isValid = false;
            } else if (typeof p.getID !== "function") {
                console.log("Lugar descartado: p.getID no es una función", p);
                isValid = false;
            } else if (!p.attributes) {
                console.log("Lugar descartado: p.attributes es null o undefined", p);
                isValid = false;
            } else if (typeof p.attributes.name !== "string") {
                console.log("Lugar descartado: p.attributes.name no es una cadena", p);
                isValid = false;
            } else if (!p.attributes.name.trim()) {
                console.log("Lugar descartado: p.attributes.name está vacío después de trim", p);
                isValid = false;
            }

            return isValid;
        })
        .slice(0, maxPlaces)
        .map(place => ({
            id: place.getID(),
            name: place.attributes.name,
            attributes: place.attributes,
            place
        }));

    console.log("placesToNormalize:", placesToNormalize);

    toggleSpinner(true, 'Revisando ortografía...', 0); // Mostrar spinner al inicio con 0%

    const totalPlaces = placesToNormalize.length;
    let processedPlaces = 0;
    let results = []; // Almacenar resultados parciales

    function processPlace(index) {
        if (index >= totalPlaces) {
            toggleSpinner(false); // Ocultar spinner al terminar
            if (results.length === 0) {
                alert("No se encontraron Places que requieran cambio.");
            } else {
                openFloatingPanel(results); // Mostrar resultados parciales
            }
            return;
        }

        const place = placesToNormalize[index];
        const originalName = place.name;
        const normalized = normalizePlaceName(originalName);

        const ortografia = checkOnlyTildes
            ? evaluarOrtografiaConTildes(normalized) // Usar la función para revisar solo tildes
            : evaluarOrtografiaNombre(normalized);

        Promise.resolve(ortografia)
            .then(ortografiaResult => {
                processedPlaces++;
                const progress = Math.round((processedPlaces / totalPlaces) * 100);
                toggleSpinner(true, 'Revisando ortografía...', progress); // Actualizar progreso

                results.push({
                    id: place.id,
                    originalName,
                    newName: normalized,
                    hasSpellingWarning: ortografiaResult.hasSpellingWarning,
                    spellingWarnings: ortografiaResult.spellingWarnings
                });

                processPlace(index + 1); // Procesar el siguiente lugar
            })
            .catch(error => {
                console.error("Error durante el escaneo de lugares:", error);
                alert(`Error durante el escaneo de lugares: ${error}`);
                toggleSpinner(false); // Ocultar spinner en caso de error
                openFloatingPanel(results); // Mostrar resultados parciales
            });
    }

    processPlace(0); // Iniciar el procesamiento desde el primer lugar
}
    //**************************************************************************
    //Nombre: applyNormalization
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: Ninguna
    //Salidas: Ninguna
    //Prerrequisitos si existen:
    // - Debe existir un elemento en el DOM con las clases .normalize-checkbox, .delete-checkbox y .new-name-input.
    // - El objeto global W debe estar disponible, incluyendo W.model.venues, W.model.actionManager y W.controller.
    // - Deben estar definidos los módulos "Waze/Action/UpdateObject" y "Waze/Action/DeleteObject" (accesibles mediante require()).
    // - Debe existir la variable placesToNormalize, que contiene datos de los lugares a normalizar, incluyendo sugerencias ortográficas.
    //Descripción:
    // Esta función aplica la normalización y/o eliminación de nombres de lugares en el Waze Map Editor
    // según las selecciones realizadas en el panel flotante. Primero, obtiene los checkboxes seleccionados
    // para normalización y eliminación. Si no hay ningún elemento seleccionado, se informa y se cancela la operación.
    // Si se han seleccionado TODOS los checkboxes de eliminación, se solicita una confirmación adicional.
    // Para cada checkbox de normalización seleccionado, se verifica si se debe aplicar la sugerencia ortográfica
    // (cuando se ha hecho clic en el botón correspondiente) o el nombre completo modificado, y se actualiza el lugar
    // mediante la acción de actualización. Posteriormente, se procesan los checkboxes de eliminación aplicando la
    // acción de eliminación a los lugares correspondientes. Si se realizaron cambios, se marca el modelo como modificado.
    // Finalmente, se cierra el panel flotante.
    //**************************************************************************
    function applyNormalization() {
        const normalizeCheckboxes = document.querySelectorAll(".normalize-checkbox:checked");
        const deleteCheckboxes = document.querySelectorAll(".delete-checkbox:checked");
        let changesMade = false;
    
        if (normalizeCheckboxes.length === 0 && deleteCheckboxes.length === 0) {
            alert("No hay lugares seleccionados para normalizar o eliminar.");
            return;
        }
    
        // Procesar normalización
        normalizeCheckboxes.forEach(cb => {
            const index = cb.dataset.index;
            const input = document.querySelector(`.new-name-input[data-index="${index}"]`);
            const newName = input?.value?.trim();
            const placeId = input?.getAttribute("data-place-id");
            const place = W.model.venues.getObjectById(placeId);
    
            if (!place || !place.attributes?.name) {
                console.warn(`No se encontró el lugar con ID: ${placeId}`);
                return;
            }
    
            const currentName = place.attributes.name.trim();
            if (currentName !== newName) {
                try {
                    const UpdateObject = require("Waze/Action/UpdateObject");
                    const action = new UpdateObject(place, { name: newName });
                    W.model.actionManager.add(action);
                    console.log(`Nombre actualizado: "${currentName}" → "${newName}"`);
                    changesMade = true;
                } catch (error) {
                    console.error("Error aplicando la acción de actualización:", error);
                }
            }
        });
    
        // Procesar eliminación
        deleteCheckboxes.forEach(cb => {
            const index = cb.dataset.index;
            const placeId = document.querySelector(`.new-name-input[data-index="${index}"]`)?.getAttribute("data-place-id");
            const place = W.model.venues.getObjectById(placeId);
    
            if (!place) {
                console.warn(`No se encontró el lugar con ID para eliminar: ${placeId}`);
                return;
            }
    
            try {
                const DeleteObject = require("Waze/Action/DeleteObject");
                const deleteAction = new DeleteObject(place);
                W.model.actionManager.add(deleteAction);
                console.log(`Lugar eliminado: ${placeId}`);
                changesMade = true;
            } catch (error) {
                console.error("Error eliminando el lugar:", error);
            }
        });
    
        if (changesMade) {
            alert("Cambios aplicados correctamente.");
        } else {
            alert("No se realizaron cambios.");
        }
    
        // Cerrar el panel flotante
        const panel = document.getElementById("normalizer-floating-panel");
        if (panel) panel.remove();
    }

    //**************************************************************************
    //Nombre: isSimilar
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas:
    //  - a (string): Primera palabra a comparar.
    //  - b (string): Segunda palabra a comparar.
    //Salidas:
    //  - boolean: Retorna true si las palabras son consideradas similares de forma leve; de lo contrario, retorna false.
    //Prerrequisitos si existen: Ninguno.
    //Descripción:
    // Esta función evalúa la similitud leve entre dos palabras. Primero, verifica si ambas palabras son
    // idénticas, en cuyo caso retorna true. Luego, comprueba si la diferencia en la cantidad de caracteres
    // entre ambas es mayor a 2; si es así, retorna false. Posteriormente, compara carácter por carácter
    // hasta el largo mínimo de las palabras, contando las diferencias. Si el número de discrepancias
    // excede 2, se considera que las palabras no son similares y retorna false; en caso contrario, retorna true.
    //**************************************************************************
    function isSimilar(a, b)
    {
        if (a === b) return true;
        if (Math.abs(a.length - b.length) > 2) return false;

        let mismatches = 0;
        for (let i = 0; i < Math.min(a.length, b.length); i++) {
            if (a[i] !== b[i]) mismatches++;
            if (mismatches > 2) return false;
        }

        return true;
    }


    //**************************************************************************
    //Nombre: normalizePlaceName
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: name (string) - Nombre del lugar
    //Salidas: texto normalizado (string)
    //Descripción: Normaliza un nombre aplicando capitalización, manejo de artículos, números y paréntesis.
    //**************************************************************************
    function normalizePlaceName(name) {
        if (!name) return "";

        const normalizeArticles = !document.getElementById("normalizeArticles")?.checked;
        const articles = ["el", "la", "los", "las", "de", "del", "al", "y"];
        const words = name.trim().split(/\s+/);
        const isRoman = word => /^(i{1,3}|iv|v|vi{0,3}|ix|x|xi{0,3}|xiv|xv|xvi{0,3}|xix|xx|xxi{0,3}|xxiv|xxv)$/i.test(word);

        const normalizedWords = words.map((word, index) => {
            const lowerWord = word.toLowerCase();

            // Si la palabra es un número, no la analizamos
            if (/^\d+$/.test(word)) return word;

            // Si la palabra está en la lista de exclusión, no la modificamos
            if (excludeWords.some(w => w.toLowerCase() === lowerWord)) return word;

            // Si es un número romano, lo dejamos en mayúsculas
            if (isRoman(word)) return word.toUpperCase();

            // Si es una sigla con estructura T&T o a&A, convertirla a mayúsculas
            if (/^[A-Za-z]&[A-Za-z]$/.test(word)) return word.toUpperCase();

            // Si es una sigla con apóstrofe como "E's", también la dejamos igual
            if (/^[A-Z]'[A-Z][a-z]+$/.test(word)) return word;

            // Si no se deben normalizar artículos y es un artículo, lo dejamos en minúsculas (excepto la primera palabra)
            if (!normalizeArticles && articles.includes(lowerWord) && index !== 0) return lowerWord;

            // Si es un número seguido de letras, lo dejamos igual
            if (/^\d+[A-Z][a-zA-Z]*$/.test(word)) return word;

            // Si está entre paréntesis y es todo mayúsculas o minúsculas, lo dejamos igual
            if (/^\(.*\)$/.test(word)) {
                const inner = word.slice(1, -1);
                if (inner === inner.toUpperCase() || inner === inner.toLowerCase()) return word;
            }

            // Capitalizamos la palabra (primera letra en mayúscula, el resto en minúscula)
            return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
        });

        let newName = normalizedWords.join(" ")
            .replace(/\s*\|\s*/g, " - ")
            .replace(/([(["'])\s*([\p{L}])/gu, (match, p1, p2) => p1 + p2.toUpperCase())
            .replace(/\s*-\s*/g, " - ")
            .replace(/\b(\d+)([A-Z])\b/g, (match, num, letter) => num + letter.toUpperCase())
            .replace(/\.$/, "")
            .replace(/&(\s*)([A-Z])/g, (match, space, letter) => "&" + space + letter.toUpperCase());

        return newName.replace(/\s{2,}/g, " ").trim();
    }

    // Para exponer al contexto global real desde Tampermonkey
    unsafeWindow.normalizePlaceName = normalizePlaceName;



    //**************************************************************************
    //Nombre: openFloatingPanel
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: placesToNormalize (array de lugares con nombre original y sugerencias)
    //Salidas: Panel flotante con opciones de normalización y eliminación
    //Prerrequisitos si existen: Debe haberse definido normalizePlaceName y cargado excludeWords correctamente
    //Descripción: Crea un panel interactivo donde se presentan los lugares que requieren cambios,
    //             permitiendo su corrección o eliminación. Solo muestra lugares que requieren cambio
    //             y errores ortográficos verdaderos.
    //**************************************************************************
    function openFloatingPanel(placesToNormalize) {
        const panel = document.createElement("div");
        panel.id = "normalizer-floating-panel";
        panel.style.cssText = `
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background: white;
            padding: 20px;
            border: 1px solid #ccc;
            border-radius: 8px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
            z-index: 1000;
            max-height: 80vh;
            overflow-y: auto;
            min-width: 800px;
        `;

        let panelContent = `
            <style>
                #normalizer-table { width: 100%; border-collapse: collapse; }
                #normalizer-table th, #normalizer-table td { padding: 8px; border: 1px solid #ddd; }
                #normalizer-table tr:nth-child(even) { background-color: #f9f9f9; }
                .warning-row { background-color: #fff3cd !important; }
                .close-btn {
                    position: absolute;
                    top: 10px;
                    right: 10px;
                    background: #ccc;
                    color: black;
                    border: none;
                    border-radius: 4px;
                    width: 25px;
                    height: 25px;
                    font-size: 16px;
                    font-weight: bold;
                    cursor: pointer;
                    text-align: center;
                    line-height: 25px;
                    transition: background-color 0.3s ease;
                }
                .close-btn:hover {
                    background: #bbb;
                }
                .footer-buttons {
                    display: flex;
                    justify-content: center;
                    gap: 10px;
                    margin-top: 20px;
                }
            </style>
            <button class="close-btn" id="close-panel-btn">X</button>
            <h3>Places to Normalize</h3>
            <table id="normalizer-table">
                <thead>
                    <tr>
                        <th>Normalizar</th>
                        <th>Eliminar</th>
                        <th>Estado</th>
                        <th>Nombre Original</th>
                        <th>Nombre Sugerido</th>
                        <th>Corrección</th>
                        <th>Acción</th>
                    </tr>
                </thead>
                <tbody>
        `;

        // Procesar cada lugar y sus advertencias
        placesToNormalize.forEach((place, index) => {
            const { originalName, newName, spellingWarnings = [] } = place;

            // Crear una fila por cada advertencia ortográfica
            spellingWarnings.forEach((warning, warningIndex) => {
                const suggestionId = `suggestion-${index}-${warningIndex}`;

                panelContent += `
                <tr class="warning-row">
                    <td style="text-align: center;">
                        <input type="checkbox" class="normalize-checkbox"
                            data-index="${index}"
                            data-warning-index="${warningIndex}"
                            data-suggestion-id="${suggestionId}">
                    </td>
                    <td style="text-align: center;">
                        <input type="checkbox" class="delete-checkbox"
                            data-index="${index}"
                            data-warning-index="${warningIndex}">
                    </td>
                    <td style="text-align: center;">⚠️</td>
                    <td id="name-cell-${index}-${warningIndex}">${originalName}</td>
                    <td>
                        <input type="text" class="new-name-input"
                            data-index="${index}"
                            data-warning-index="${warningIndex}"
                            data-place-id="${place.id}"
                            data-suggestion-id="${suggestionId}"
                            value="${newName}">
                    </td>
                    <td>${warning.original} → ${warning.sugerida} (${warning.tipo})</td>
                    <td style="text-align: center;">
                        <button class="apply-suggestion-btn"
                                data-index="${index}"
                                data-warning-index="${warningIndex}"
                                title="Corregir ortografía de la palabra"
                                style="background-color: #4CAF50; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">
                            Fix
                        </button>
                        <button class="add-exclude-btn"
                                data-word="${warning.original}"
                                title="Adicionar palabra excluida nueva"
                                style="background-color: #007bff; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">
                            Add
                        </button>
                    </td>
                </tr>`;
            });
        });

        panelContent += `
                    </tbody>
                </table>
                <div class="footer-buttons">
                    <button class="apply-changes-btn" id="apply-changes-btn">✔️ Apply Changes</button>
                    <button class="cancel-btn" id="cancel-btn">❌ Cancel</button>
                </div>
            `;

        panel.innerHTML = panelContent;
        document.body.appendChild(panel);

        // Eventos para los botones
        document.getElementById('close-panel-btn').addEventListener('click', () => {
            panel.remove(); // Cerrar el panel flotante
        });

        document.getElementById('apply-changes-btn').addEventListener('click', () => {
            window.applyNormalization(); // Llamar a la función global para aplicar cambios
        });

        document.getElementById('cancel-btn').addEventListener('click', () => {
            panel.remove(); // Cerrar el panel flotante
        });

        // Asignar eventos a los botones "Fix"
        document.querySelectorAll('.apply-suggestion-btn').forEach(button => {
            button.addEventListener('click', function () {
                const index = this.dataset.index;
                const warningIndex = this.dataset.warningIndex;
                const input = document.querySelector(
                    `.new-name-input[data-index="${index}"][data-warning-index="${warningIndex}"]`
                );
                const checkbox = document.querySelector(
                    `.normalize-checkbox[data-index="${index}"][data-warning-index="${warningIndex}"]`
                );

                if (input && checkbox) {
                    // Aplicar la corrección ortográfica al campo de entrada
                    const warning = placesToNormalize[index].spellingWarnings[warningIndex];
                    input.value = input.value.replace(warning.original, warning.sugerida);

                    checkbox.checked = true; // Marcar el checkbox
                }
            });
        });

        // Asignar eventos a los botones "Add Excld Word"
        document.querySelectorAll('.add-exclude-btn').forEach(button => {
            button.addEventListener('click', function () {
                const word = this.dataset.word;

                // Verificar si es una sigla con formato X&X
                if (/^[A-Za-z]&[A-Za-z]$/.test(word)) {
                    alert("⚠️ No es necesario adicionar palabras excluidas que tengan '&'.");
                    return;
                }

                if (!excludeWords.includes(word)) {
                    excludeWords.push(word);
                    excludeWords.sort((a, b) => a.localeCompare(b)); // Ordenar alfabéticamente
                    localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
                    renderExcludedWordsPanel(); // Actualizar el panel lateral

                    // Mostrar popup en el centro del panel flotante
                    const panel = document.getElementById("normalizer-floating-panel");
                    const popup = document.createElement('div');
                    popup.textContent = `✅ The Word "${word}" has been added to the exclusion list.`;
                    popup.style.position = 'absolute';
                    popup.style.top = '50%';
                    popup.style.left = '50%';
                    popup.style.transform = 'translate(-50%, -50%)';
                    popup.style.backgroundColor = '#4CAF50';
                    popup.style.color = 'white';
                    popup.style.padding = '10px 20px';
                    popup.style.borderRadius = '5px';
                    popup.style.zIndex = '10000';
                    popup.style.opacity = '1';
                    popup.style.transition = 'opacity 1s ease-in-out';

                    panel.appendChild(popup);

                    setTimeout(() => {
                        popup.style.opacity = '0';
                        setTimeout(() => panel.removeChild(popup), 1000);
                    }, 2000);

                    // Bloquear el botón y cambiar su texto
                    this.textContent = 'Added';
                    this.disabled = true;
                    this.style.backgroundColor = '#6c757d'; // Cambiar color a gris
                    this.style.cursor = 'not-allowed';
                } else {
                    alert(`⚠️ The word "${word}" is already on the exclusion list.`);
                }
            });
        });
    }
    //**************************************************************************
    //Nombre: loadExcludeWordsFromXML
    //Fecha modificación:
    //Autor: mincho77
    //Entradas:
    //  - callback: función opcional que se ejecuta una vez se cargan y procesan las palabras excluidas.
    //Salidas: ninguna directa. Actualiza la variable global `excludeWords`.
    //Prerrequisitos:
    //  - Debe existir un archivo llamado 'excludeWords.xml' accesible por fetch.
    //  - Debe estar definida la variable global `excludeWords`.
    //Descripción:
    //  Carga un archivo XML que contiene una lista de palabras excluidas.
    //  Combina las palabras nuevas con las que ya están guardadas en localStorage
    //  y actualiza la lista global `excludeWords`. Si el XML no puede ser cargado,
    //  se usa únicamente el contenido almacenado localmente como respaldo.
    //**************************************************************************
    function loadExcludeWordsFromXML(callback) {
        fetch("excludeWords.xml")
            .then(response => response.text()) // Corrected the syntax here
            .then(xmlText => {
            const parser = new DOMParser();
            const xmlDoc = parser.parseFromString(xmlText, "text/xml");
            const wordNodes = xmlDoc.getElementsByTagName("word");
            const wordsFromXML = Array.from(wordNodes).map(node => node.textContent.trim());

            const existing = JSON.parse(localStorage.getItem("excludeWords")) || [];
            excludeWords = [...new Set([...existing, ...wordsFromXML])].sort((a, b) => a.localeCompare(b));
            localStorage.setItem("excludeWords", JSON.stringify(excludeWords));

            if (callback) callback();
        })
            .catch(() => {
            console.warn("⚠️ No se pudo cargar excludeWords.xml. Solo se usará localStorage.");
            excludeWords = JSON.parse(localStorage.getItem("excludeWords")) || ["EDS", "IPS", "McDonald's", "EPS"];
            localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
            if (callback) callback();
        });
    }

    function exportExcludeWordsToXML() {
        const xmlContent = `<?xml version="1.0" encoding="UTF-8"?>
<ExcludedWords>
${excludeWords.map(word => `  <word>${word}</word>`).join("\n")}
</ExcludedWords>`;

        const blob = new Blob([xmlContent], { type: "application/xml" });
        const url = URL.createObjectURL(blob); // ✅ ESTA LÍNEA ESTABA FALTANDO
        const link = document.createElement("a");
        link.href = url;
        link.download = "excludeWords.xml";
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);
}


    function showFloatingMessage(message) {
        const msg = document.createElement("div");
        msg.textContent = message;
        msg.style.position = "fixed";
        msg.style.bottom = "30px";
        msg.style.left = "50%";
        msg.style.transform = "translateX(-50%)";
        msg.style.backgroundColor = "#333";
        msg.style.color = "#fff";
        msg.style.padding = "10px 20px";
        msg.style.borderRadius = "5px";
        msg.style.zIndex = 9999;
        msg.style.opacity = "0.95";
        msg.style.transition = "opacity 1s ease-in-out";

        document.body.appendChild(msg);

        setTimeout(() => {
            msg.style.opacity = "0";
            setTimeout(() => document.body.removeChild(msg), 1000);
        }, 3000);
    }
    //**************************************************************************
    //Nombre: waitForWME
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: Ninguna
    //Salidas: Ninguna
    //Prerrequisitos si existen:
    // - El objeto global W debe estar disponible.
    // - Deben estar definidas las funciones: initializeExcludeWords, createSidebarTab y renderExcludedWordsPanel.
    //Descripción:
    // Esta función espera a que el entorno de edición de Waze (WME) esté completamente cargado,
    // verificando que existan los objetos necesarios para iniciar el script.
    // Una vez disponible, inicializa la lista de palabras excluidas desde localStorage,
    // crea el tab lateral personalizado y renderiza la lista visual de palabras excluidas.
    // Si WME aún no está listo, vuelve a intentar cada 1000 ms.
    //**************************************************************************
    function waitForWME() {
        if (W && W.userscripts && W.model && W.model.venues) {
            console.log(`[${SCRIPT_NAME}] Inicializando v${VERSION}`);

            initializeExcludeWords(); // ⚠️ Usa solo localStorage
            createSidebarTab();

            waitForDOM("#normalizer-tab", () => {
                console.log("[waitForWME] 🧩 Sidebar listo, renderizando palabras excluidas");

                renderExcludedWordsPanel();       // Muestra las palabras
                setupDragAndDropImport();         // Activa drag & drop

                //Evita que se abra el archivo si cae fuera del área
              //  window.addEventListener("dragover", e => e.preventDefault(), false);
             //   window.addEventListener("drop", e => e.preventDefault(), false);
            });

        } else {
            console.log(`[${SCRIPT_NAME}] Esperando que WME esté listo...`);
            setTimeout(waitForWME, 1000);
        }
    }


    //**************************************************************************
    //Nombre: cleanupEventListeners
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: Ninguna
    //Salidas: Ninguna
    //Prerrequisitos si existen:
    // - Debe existir un elemento en el DOM con el id "normalizer-floating-panel".
    //Descripción:
    // Esta función limpia los event listeners asociados al panel flotante de normalización.
    // Lo hace clonando el nodo del panel y reemplazándolo en el DOM, lo cual elimina todos
    // los listeners previamente asignados a ese nodo, evitando posibles fugas de memoria
    // o comportamientos inesperados.
    //**************************************************************************
    function cleanupEventListeners() {
        const panel = document.getElementById("normalizer-floating-panel");
        if (panel) {
            const clone = panel.cloneNode(true);
            panel.parentNode.replaceChild(clone, panel);
        }
    }  

    //**************************************************************************
    //Nombre: normalizePlaceName (unsafeWindow)
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas:
    //  - name (string): el nombre original del lugar.
    //Salidas:
    //  - string: nombre normalizado, respetando exclusiones y opciones del usuario.
    //Prerrequisitos si existen:
    //  - Debe estar cargada la lista global excludeWords.
    //  - Debe existir un checkbox con id “normalizeArticles” para definir si se normalizan artículos.
    //Descripción:
    //  Esta versión expuesta globalmente permite acceder a la normalización básica del nombre de un lugar
    //  desde otros contextos como el navegador o Tampermonkey. Capitaliza cada palabra, respeta las excluidas
    //  y no aplica normalización a artículos si el checkbox lo indica.
    //  Realiza limpieza básica: reemplazo de pipes, eliminación de espacios dobles y trim final.
    //**************************************************************************
    unsafeWindow.normalizePlaceName = function(name)
    {
        if (!name) return "";

        const normalizeArticles = !document.getElementById("normalizeArticles")?.checked;
        const articles = ["el", "la", "los", "las", "de", "del", "al", "y", "o"];

        const words = name.trim().split(/\s+/);
        const normalizedWords = words.map((word, index) => {
            const lowerWord = word.toLowerCase();

            // Saltar palabras excluidas
            if (excludeWords.includes(word)) return word;

            // Saltar artículos si el checkbox está activo y no es la primera palabra
            if (!normalizeArticles && articles.includes(lowerWord) && index !== 0) {
                return lowerWord;
            }
            //Mayúsculas
            return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
        });

        name = normalizedWords.join(" ");
        name = name.replace(/\s*\|\s*/g, " - ");
        name = name.replace(/\s{2,}/g, " ").trim();

        return name;
    };



//**************************************************************************
//Nombre: waitForDOM
//Fecha modificación: 2025-03-31
//Autor: mincho77
//Entradas:
//   selector (string): Selector CSS del nodo a esperar.
//   callback (function): Función a ejecutar cuando el nodo esté disponible.
//   interval (int, opcional): Tiempo entre intentos en milisegundos. Default: 500ms.
//   maxAttempts (int, opcional): Máximo de intentos antes de abortar. Default: 10.
//Salidas: Ejecuta el callback si encuentra el selector dentro del tiempo.
//Descripción:
//   Esta función monitorea el DOM en intervalos constantes hasta que
//   encuentra un nodo que coincida con el selector. Si lo encuentra,
//   ejecuta el callback con ese nodo. Si no, muestra un error por consola.
//**************************************************************************
    function waitForDOM(selector, callback, interval = 500, maxAttempts = 10)
    {
        let attempts = 0;
        const checkExist = setInterval(() => {
            const element = document.querySelector(selector);
            if (element) {
                clearInterval(checkExist);
                callback(element);
            } else if (attempts >= maxAttempts) {
                clearInterval(checkExist);
                console.error(`[${SCRIPT_NAME}] Error: No se encontraron elementos en el DOM después de ${maxAttempts} intentos.`);
            }
            attempts++;
        }, interval);
    }

    //**************************************************************************
    //Nombre: getSidebarHTML
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: Ninguna.
    //Salidas: Retorna un string con HTML que define el contenido del panel lateral del script.
    //Prerrequisitos si existen: Debe estar disponible el valor de las variables globales:
    // - normalizeArticles (boolean): Define si los artículos deben ser normalizados o no.
    // - maxPlaces (number): Número máximo de lugares a escanear.
    //Descripción:
    // Esta función construye el HTML que se inyecta en el panel lateral (sidebar) de WME.
    // Incluye controles para:
    // - Activar o desactivar normalización de artículos ("el", "la", etc).
    // - Definir la cantidad máxima de lugares a procesar.
    // - Agregar palabras excluidas manualmente.
    // - Exportar palabras excluidas a un archivo XML.
    // - Importar una lista de palabras excluidas desde archivo XML.
    // - Disparar el escaneo de lugares para normalización.
    // La lista de palabras excluidas **no se renderiza aquí directamente**, sino en el div
    // con id "normalizer-sidebar" para permitir que sea manejada dinámicamente.
    //**************************************************************************
    function getSidebarHTML() {
        return `
        <div id="normalizer-tab">
          <h4>Places Name Normalizer <span style="font-size:11px;">v${VERSION}</span></h4>
    
          <div>
            <input type="checkbox" id="normalizeArticles" ${normalizeArticles ? "checked" : ""}>
            <label for="normalizeArticles">No Normalizar artículos (el, la, los, ...)</label>
          </div>
    
          <div>
            <label>Máximo de Places a buscar: </label>
            <input type='number' id='maxPlacesInput' value='${maxPlaces}' min='1' max='800' style='width: 60px;'>
          </div>
    
          <div>
            <label>Palabras Excluidas:</label>
            <input type='text' id='excludeWord' style='width: 120px;'>
            <button id='addExcludeWord'>Add Word</button>
            <div id="normalizer-sidebar" style="margin-top: 20px;"></div>
            <button id="exportExcludeWords" style="margin-top: 5px;">Export Words</button>
            <br>
            <button id="importExcludeWordsUnifiedBtn" style="margin-top: 5px;">Import List...</button>
            <input type="file" id="hiddenImportInput" accept=".xml" style="display: none;">
          </div>
    
          <div>
            <input type="checkbox" id="checkOnlyTildes" checked>
            <label for="checkOnlyTildes">Revisar solo tildes</label>
          </div>
    
          <div id="drop-zone" style="border: 2px dashed #ccc; border-radius: 6px; padding: 15px; margin: 15px 0; text-align: center; font-style: italic; color: #555; background-color: #f8f9fa; transition: all 0.3s ease;">
            📂 Arrastra aquí tu archivo .txt o .xml para importar palabras excluidas
          </div>
    
          <hr>
          <button id="scanPlaces">Scan...</button>
        </div>
      `;
    }

//**************************************************************************
//Nombre: attachEvents
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: Ninguna
//Salidas: Ninguna
//Prerrequisitos si existen:
// - Deben existir en el DOM los elementos con los siguientes IDs:
//   "normalizeArticles", "maxPlacesInput", "addExcludeWord", "scanPlaces",
//   "hiddenImportInput", "importExcludeWordsUnifiedBtn" y "exportExcludeWords".
// - Debe existir la función handleImportList y la función scanPlaces.
// - Debe estar definida la variable global excludeWords y la función renderExcludedWordsPanel.
//Descripción:
// Esta función adjunta los event listeners necesarios para gestionar la interacción del usuario
// con el panel del normalizador de nombres. Se encargan de:
//   - Actualizar la opción de normalizar artículos al cambiar el estado del checkbox.
//   - Modificar el número máximo de lugares a procesar a través de un input.
//   - Exportar la lista de palabras excluidas a un archivo XML.
//   - Añadir nuevas palabras a la lista de palabras excluidas, evitando duplicados, y actualizar el panel.
//   - Activar el botón unificado para la importación de palabras excluidas mediante un input oculto.
//   - Ejecutar la función de escaneo de lugares al hacer clic en el botón correspondiente.
//**************************************************************************
    function attachEvents2()
    {
        console.log(`[${SCRIPT_NAME}] Adjuntando eventos...`);

        const normalizeArticlesCheckbox = document.getElementById("normalizeArticles");
        const maxPlacesInput = document.getElementById("maxPlacesInput");
        const addExcludeWordButton = document.getElementById("addExcludeWord");
        const scanPlacesButton = document.getElementById("scanPlaces");
        const hiddenInput = document.getElementById("hiddenImportInput");
        const importButtonUnified = document.getElementById("importExcludeWordsUnifiedBtn");

        // Validación de elementos necesarios
        if (!normalizeArticlesCheckbox || !maxPlacesInput || !addExcludeWordButton || !scanPlacesButton) {
            console.error(`[${SCRIPT_NAME}] Error: No se encontraron elementos en el DOM.`);
            return;
        }

        // ✅ Evento: cambiar estado de "no normalizar artículos"
        normalizeArticlesCheckbox.addEventListener("change", (e) => {
            normalizeArticles = e.target.checked;
        });

        // ✅ Evento: cambiar número máximo de places
        maxPlacesInput.addEventListener("input", (e) => {
            maxPlaces = parseInt(e.target.value, 10);
        });

        // ✅ Evento: exportar palabras excluidas a XML
        document.getElementById("exportExcludeWords").addEventListener("click", () => {
            const savedWords = JSON.parse(localStorage.getItem("excludeWords")) || [];
            if (savedWords.length === 0) {
                alert("No hay palabras excluidas para exportar.");
                return;
            }
            const sortedWords = [...savedWords].sort((a, b) => a.localeCompare(b));

            const xmlContent = `<?xml version="1.0" encoding="UTF-8"?>
<ExcludedWords>
  ${sortedWords.map(word => `  <word>${word}</word>`).join("\n  ")}
</ExcludedWords>`;

            const blob = new Blob([xmlContent], { type: "application/xml" });
            const url = URL.createObjectURL(blob);
            const link = document.createElement("a");

            link.href = url;
            link.download = "excluded_words.xml";
            document.body.appendChild(link); // Correctly appends the link
            link.click();
            document.body.removeChild(link); // Correctly removes the link
        });

        // ✅ Evento: añadir palabra excluida sin duplicados
        addExcludeWordButton.addEventListener("click", () => {
            const wordInput = document.getElementById("excludeWord") || document.getElementById("excludedWord");
            const word = wordInput?.value.trim();

            if (!word) return;

            const lowerWord = word.toLowerCase();
            const alreadyExists = excludeWords.some(w => w.toLowerCase() === lowerWord);
            if (!alreadyExists) {
                excludeWords.push(word);
                localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
                renderExcludedWordsPanel(); // Usar renderExcludedWordsPanel en lugar de updateExcludeList
            }

            wordInput.value = "";
        });

        // ✅ Evento: nuevo botón unificado de importación
        importButtonUnified.addEventListener("click", () => {
            hiddenInput.click(); // abre el file input oculto
        });

        hiddenInput.addEventListener("change", () => {
            handleImportList(); // ✅ Llama a la función handleImportList al importar
        });

        // ✅ Evento: escanear lugares
        scanPlacesButton.addEventListener("click", scanPlaces);
    }

  

    //**************************************************************************
    //Nombre: NameChangeAction
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas:
    //   - venue (object): Objeto Place que contiene la información del lugar a modificar.
    //   - oldName (string): Nombre actual del lugar.
    //   - newName (string): Nuevo nombre sugerido para el lugar.
    //Salidas: Ninguna (función constructora que crea un objeto de acción).
    //Prerrequisitos si existen:
    //   - El objeto venue debe contar con la propiedad attributes, y dentro de ésta, con el campo id.
    //Descripción:
    // Esta función constructora crea un objeto que representa la acción de cambio de nombre
    // de un Place en el Waze Map Editor (WME). Asigna las propiedades correspondientes:
    //   - Guarda el objeto venue, el nombre original (oldName) y el nuevo nombre (newName).
    //   - Extrae y guarda el ID único del lugar desde venue.attributes.id.
    //   - Establece metadatos para identificar la acción, asignando el tipo "NameChangeAction"
    //     y marcando isGeometryEdit como false para indicar que no se trata de una edición de geometría.
    // Estos metadatos pueden ser utilizados por WME y otros plugins para gestionar y mostrar la acción.
    //**************************************************************************
    function NameChangeAction(venue, oldName, newName)
    {
        // Referencia al Place y los nombres
        this.venue = venue;
        this.oldName = oldName;
        this.newName = newName;

        // ID único del Place
        this.venueId = venue.attributes.id;

        // Metadatos que WME/Plugins pueden usar
        this.type = "NameChangeAction";
        this.isGeometryEdit = false; // no es una edición de geometría
    }

    /**
 * 1) getActionName: nombre de la acción en el historial.
 */
    NameChangeAction.prototype.getActionName = function() {
        return "Update place name";
    };

    /** 2) getActionText: texto corto que WME a veces muestra. */
    NameChangeAction.prototype.getActionText = function() {
        return "Update place name";
    };

    /** 3) getName: algunas versiones llaman a getName(). */
    NameChangeAction.prototype.getName = function() {
        return "Update place name";
    };

    /** 4) getDescription: descripción detallada de la acción. */
    NameChangeAction.prototype.getDescription = function() {
        return `Place name changed from "${this.oldName}" to "${this.newName}".`;
    };

    /** 5) getT: título (a veces requerido por plugins). */
    NameChangeAction.prototype.getT = function() {
        return "Update place name";
    };

    /** 6) getID: si un plugin llama a e.getID(). */
    NameChangeAction.prototype.getID = function() {
        return `NameChangeAction-${this.venueId}`;
    };

    /** 7) doAction: asigna el nuevo nombre (WME llama a esto al crear la acción). */
    NameChangeAction.prototype.doAction = function() {
        this.venue.attributes.name = this.newName;
        this.venue.isDirty = true;
        if (typeof W.model.venues.markObjectEdited === "function") {
            W.model.venues.markObjectEdited(this.venue);
        }
    };

    /** 8) undoAction: revertir al nombre anterior (Ctrl+Z). */
    NameChangeAction.prototype.undoAction = function() {
        this.venue.attributes.name = this.oldName;
        this.venue.isDirty = true;
        if (typeof W.model.venues.markObjectEdited === "function") {
            W.model.venues.markObjectEdited(this.venue);
        }
    };

    /** 9) redoAction: rehacer (Ctrl+Shift+Z), vuelve a doAction. */
    NameChangeAction.prototype.redoAction = function() {
        return this.doAction();
    };

    /** 10) undoSupported / redoSupported: indica si se puede des/rehacer. */
    NameChangeAction.prototype.undoSupported = function() {
        return true;
    };
    NameChangeAction.prototype.redoSupported = function() {
        return true;
    };

    /** 11) accept / supersede: evita fusionar con otras acciones. */
    NameChangeAction.prototype.accept = function() {
        return false;
    };
    NameChangeAction.prototype.supersede = function() {
        return false;
    };

    /** 12) isEditAction: true => habilita "Guardar". */
    NameChangeAction.prototype.isEditAction = function() {
        return true;
    };

    /** 13) getAffectedUniqueIds: objetos que se alteran. */
    NameChangeAction.prototype.getAffectedUniqueIds = function() {
        return [this.venueId];
    };

    /** 14) isSerializable: si no implementas serialize(), pon false. */
    NameChangeAction.prototype.isSerializable = function() {
        return false;
    };

    /** 15) isActionStackable: false => no combina con otras ediciones. */
    NameChangeAction.prototype.isActionStackable = function() {
        return false;
    };

    /** 16) getFocusFeatures: WME/Plugins pueden usarlo para "enfocar" el objeto. */
    NameChangeAction.prototype.getFocusFeatures = function() {
        // Devolvemos el venue para indicar que ese es el foco (o un array vacío si prefieres).
        return [this.venue];
    };

    /** 17) Métodos vacíos para evitar futuros "no es una función" si WME pide estos. */
    NameChangeAction.prototype.getFocusSegments = function() {
        return [];
    };
    NameChangeAction.prototype.getFocusNodes = function() {
        return [];
    };
    NameChangeAction.prototype.getFocusClosures = function() {
        return [];

    };

    /** 18) getTimestamp: método nuevo que WME/Plugins están llamando. */
    NameChangeAction.prototype.getTimestamp = function() {
        // Devolvemos un timestamp numérico (ms desde época UNIX).
        return Date.now();
    };

 
    //**************************************************************************
    //Nombre: openFloatingPanel
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: placesToNormalize (array de lugares con nombre original y sugerencias)
    //Salidas: Panel flotante con opciones de normalización y eliminación
    //Prerrequisitos si existen: Debe haberse definido normalizePlaceName y cargado excludeWords correctamente
    //Descripción: Crea un panel interactivo donde se presentan los lugares que requieren cambios,
    //             permitiendo su corrección o eliminación. Solo muestra lugares que requieren cambio
    //             y errores ortográficos verdaderos.
    //**************************************************************************
    function openFloatingPanel2(placesToNormalize) {
        const panel = document.createElement("div");
        panel.id = "normalizer-floating-panel";
        panel.style.cssText = `
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background: white;
            padding: 20px;
            border: 1px solid #ccc;
            border-radius: 8px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
            z-index: 1000;
            max-height: 80vh;
            overflow-y: auto;
            min-width: 800px;
        `;

        let panelContent = `
            <style>
                #normalizer-table { width: 100%; border-collapse: collapse; }
                #normalizer-table th, #normalizer-table td { padding: 8px; border: 1px solid #ddd; }
                #normalizer-table tr:nth-child(even) { background-color: #f9f9f9; }
                .warning-row { background-color: #fff3cd !important; }
                .close-btn {
                    position: absolute;
                    top: 10px;
                    right: 10px;
                    background: #ccc;
                    color: black;
                    border: none;
                    border-radius: 4px;
                    width: 25px;
                    height: 25px;
                    font-size: 16px;
                    font-weight: bold;
                    cursor: pointer;
                    text-align: center;
                    line-height: 25px;
                    transition: background-color 0.3s ease;
                }
                .close-btn:hover {
                    background: #bbb;
                }
                .footer-buttons {
                    display: flex;
                    justify-content: center;
                    gap: 10px;
                    margin-top: 20px;
                }
            </style>
            <button class="close-btn" id="close-panel-btn">X</button>
            <h3>Places to Normalize</h3>
            <table id="normalizer-table">
                <thead>
                    <tr>
                        <th>Normalizar</th>
                        <th>Eliminar</th>
                        <th>Estado</th>
                        <th>Nombre Original</th>
                        <th>Nombre Sugerido</th>
                        <th>Corrección</th>
                        <th>Acción</th>
                    </tr>
                </thead>
                <tbody>
        `;

        // Procesar cada lugar y sus advertencias
        placesToNormalize.forEach((place, index) => {
            const { originalName, newName, spellingWarnings = [] } = place;

            // Crear una fila por cada advertencia ortográfica
            spellingWarnings.forEach((warning, warningIndex) => {
                const suggestionId = `suggestion-${index}-${warningIndex}`;

                panelContent += `
                <tr class="warning-row">
                    <td style="text-align: center;">
                        <input type="checkbox" class="normalize-checkbox"
                            data-index="${index}"
                            data-warning-index="${warningIndex}"
                            data-suggestion-id="${suggestionId}">
                    </td>
                    <td style="text-align: center;">
                        <input type="checkbox" class="delete-checkbox"
                            data-index="${index}"
                            data-warning-index="${warningIndex}">
                    </td>
                    <td style="text-align: center;">⚠️</td>
                    <td id="name-cell-${index}-${warningIndex}">${originalName}</td>
                    <td>
                        <input type="text" class="new-name-input"
                            data-index="${index}"
                            data-warning-index="${warningIndex}"
                            data-place-id="${place.id}"
                            data-suggestion-id="${suggestionId}"
                            value="${newName}">
                    </td>
                    <td>${warning.original} → ${warning.sugerida} (${warning.tipo})</td>
                    <td style="text-align: center;">
                        <button class="apply-suggestion-btn"
                                data-index="${index}"
                                data-warning-index="${warningIndex}"
                                title="Corregir ortografía de la palabra"
                                style="background-color: #4CAF50; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">
                            Fix
                        </button>
                        <button class="add-exclude-btn"
                                data-word="${warning.original}"
                                title="Adicionar palabra excluida nueva"
                                style="background-color: #007bff; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">
                            Add Excld
                        </button>
                    </td>
                </tr>`;
            });
        });

        panelContent += `
                    </tbody>
                </table>
                <div class="footer-buttons">
                    <button class="apply-changes-btn" id="apply-changes-btn">✔️ Apply Changes</button>
                    <button class="cancel-btn" id="cancel-btn">❌ Cancel</button>
                </div>
            `;

        panel.innerHTML = panelContent;
        document.body.appendChild(panel);

        // Eventos para los botones
        document.getElementById('close-panel-btn').addEventListener('click', () => {
            panel.remove(); // Cerrar el panel flotante
        });

        document.getElementById('apply-changes-btn').addEventListener('click', () => {
            window.applyNormalization(); // Llamar a la función global para aplicar cambios
        });

        document.getElementById('cancel-btn').addEventListener('click', () => {
            panel.remove(); // Cerrar el panel flotante
        });

        // Asignar eventos a los botones "Fix"
        document.querySelectorAll('.apply-suggestion-btn').forEach(button => {
            button.addEventListener('click', function () {
                const index = this.dataset.index;
                const warningIndex = this.dataset.warningIndex;
                const input = document.querySelector(
                    `.new-name-input[data-index="${index}"][data-warning-index="${warningIndex}"]`
                );
                const checkbox = document.querySelector(
                    `.normalize-checkbox[data-index="${index}"][data-warning-index="${warningIndex}"]`
                );
        
                if (input && checkbox) {
                    const warning = placesToNormalize[index].spellingWarnings[warningIndex];
                    input.value = input.value.replace(warning.original, warning.sugerida || warning.original);
        
                    checkbox.checked = true; // Marcar el checkbox
                }
            });
        });

        // Asignar eventos a los botones "Add Excld Word"
        document.querySelectorAll('.add-exclude-btn').forEach(button => {
            button.addEventListener('click', function () {
                const word = this.dataset.word;

                if (!excludeWords.includes(word)) {
                    excludeWords.push(word);
                    excludeWords.sort((a, b) => a.localeCompare(b)); // Ordenar alfabéticamente
                    localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
                    renderExcludedWordsPanel(); // Actualizar el panel lateral

                    // Mostrar popup en el centro del panel flotante
                    const panel = document.getElementById("normalizer-floating-panel");
                    const popup = document.createElement('div');
                    popup.textContent = `✅ The Word "${word}" has been added to the exclusion list.`;
                    popup.style.position = 'absolute';
                    popup.style.top = '50%';
                    popup.style.left = '50%';
                    popup.style.transform = 'translate(-50%, -50%)';
                    popup.style.backgroundColor = '#4CAF50';
                    popup.style.color = 'white';
                    popup.style.padding = '10px 20px';
                    popup.style.borderRadius = '5px';
                    popup.style.zIndex = '10000';
                    popup.style.opacity = '1';
                    popup.style.transition = 'opacity 1s ease-in-out';

                    panel.appendChild(popup);

                    setTimeout(() => {
                        popup.style.opacity = '0';
                        setTimeout(() => panel.removeChild(popup), 1000);
                    }, 2000);

                    // Bloquear el botón y cambiar su texto
                    this.textContent = 'Excluded Word Added';
                    this.disabled = true;
                    this.style.backgroundColor = '#6c757d'; // Cambiar color a gris
                    this.style.cursor = 'not-allowed';
                } else {
                    alert(`⚠️ The word "${word}" is already on the exclusion list.`);
                }
            });
        });
    }
    
    //**************************************************************************
    //Nombre: waitForWME
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: Ninguna
    //Salidas: Ninguna
    //Prerrequisitos si existen:
    // - El objeto global W debe estar disponible.
    // - Deben estar definidas las funciones: initializeExcludeWords, createSidebarTab y renderExcludedWordsPanel.
    //Descripción:
    // Esta función espera a que el entorno de edición de Waze (WME) esté completamente cargado,
    // verificando que existan los objetos necesarios para iniciar el script.
    // Una vez disponible, inicializa la lista de palabras excluidas desde localStorage,
    // crea el tab lateral personalizado y renderiza la lista visual de palabras excluidas.
    // Si WME aún no está listo, vuelve a intentar cada 1000 ms.
    //**************************************************************************
    function waitForWME2() {
        if (W && W.userscripts && W.model && W.model.venues) {
            console.log(`[${SCRIPT_NAME}] Inicializando v${VERSION}`);

            initializeExcludeWords(); // ⚠️ Usa solo localStorage
            createSidebarTab();

            waitForDOM("#normalizer-tab", () => {
                console.log("[waitForWME] 🧩 Sidebar listo, renderizando palabras excluidas");

                renderExcludedWordsPanel();       // Muestra las palabras
                setupDragAndDropImport();         // Activa drag & drop

                //Evita que se abra el archivo si cae fuera del área
              //  window.addEventListener("dragover", e => e.preventDefault(), false);
             //   window.addEventListener("drop", e => e.preventDefault(), false);
            });

        } else {
            console.log(`[${SCRIPT_NAME}] Esperando que WME esté listo...`);
            setTimeout(waitForWME, 1000);
        }
    }
//**************************************************************************
//Nombre: init
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: Ninguna
//Salidas: Ninguna
//Prerrequisitos si existen:
// - El objeto global W debe estar disponible.
// - Deben estar definidas las funciones: initializeExcludeWords, createSidebarTab, waitForDOM, renderExcludedWordsPanel y setupDragAndDropImport.
//Descripción:
// Esta función espera a que el entorno de edición de Waze (WME) esté completamente cargado,
// verificando que existan los objetos necesarios para iniciar el script. Si WME aún no está listo,
// reintenta la inicialización cada 1000 ms. Una vez disponible, inicializa la lista de palabras
// excluidas, crea el tab lateral personalizado, espera a que el DOM del tab esté listo para
// renderizar el contenido (palabras excluidas y funcionalidad drag & drop), y expone globalmente
// las funciones applyNormalization y normalizePlaceName para uso externo.
//**************************************************************************
function init() {
    if (!W || !W.userscripts || !W.model || !W.model.venues) {
        console.log(`[${SCRIPT_NAME}] Esperando que WME esté listo...`);
        setTimeout(init, 1000);
        return;
    }

    console.log(`[${SCRIPT_NAME}] Inicializando v${VERSION}`);
    initializeExcludeWords();
    createSidebarTab();
    waitForDOM("#normalizer-tab", () => {
        console.log("[init] 🧩 Sidebar listo, renderizando palabras excluidas");
        renderExcludedWordsPanel();
        setupDragAndDropImport();
    });

    window.applyNormalization = applyNormalization;
    window.normalizePlaceName = normalizePlaceName;
}
init();
    
    } catch (e) {
        console.error('[PlacesNameNormalizer] Fatal initialization error:', e);
    }
})();

    //**************************************************************************
    //Nombre: applyNormalization
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: Ninguna
    //Salidas: Ninguna
    //Prerrequisitos si existen:
    // - Debe existir un elemento en el DOM con las clases .normalize-checkbox, .delete-checkbox y .new-name-input.
    // - El objeto global W debe estar disponible, incluyendo W.model.venues, W.model.actionManager y W.controller.
    // - Deben estar definidos los módulos "Waze/Action/UpdateObject" y "Waze/Action/DeleteObject" (accesibles mediante require()).
    // - Debe existir la variable placesToNormalize, que contiene datos de los lugares a normalizar, incluyendo sugerencias ortográficas.
    //Descripción:
    // Esta función aplica la normalización y/o eliminación de nombres de lugares en el Waze Map Editor
    // según las selecciones realizadas en el panel flotante. Primero, obtiene los checkboxes seleccionados
    // para normalización y eliminación. Si no hay ningún elemento seleccionado, se informa y se cancela la operación.
    // Si se han seleccionado TODOS los checkboxes de eliminación, se solicita una confirmación adicional.
    // Para cada checkbox de normalización seleccionado, se verifica si se debe aplicar la sugerencia ortográfica
    // (cuando se ha hecho clic en el botón correspondiente) o el nombre completo modificado, y se actualiza el lugar
    // mediante la acción de actualización. Posteriormente, se procesan los checkboxes de eliminación aplicando la
    // acción de eliminación a los lugares correspondientes. Si se realizaron cambios, se marca el modelo como modificado.
    // Finalmente, se cierra el panel flotante.
    //**************************************************************************
    function applyNormalization() {
        const normalizeCheckboxes = document.querySelectorAll(".normalize-checkbox:checked");
        const deleteCheckboxes = document.querySelectorAll(".delete-checkbox:checked");
        let changesMade = false;
    
        if (normalizeCheckboxes.length === 0 && deleteCheckboxes.length === 0) {
            alert("No hay lugares seleccionados para normalizar o eliminar.");
            return;
        }
    
        // Procesar normalización
        normalizeCheckboxes.forEach(cb => {
            const index = cb.dataset.index;
            const input = document.querySelector(`.new-name-input[data-index="${index}"]`);
            const newName = input?.value?.trim();
            const placeId = input?.getAttribute("data-place-id");
            const place = W.model.venues.getObjectById(placeId);
    
            if (!place || !place.attributes?.name) {
                console.warn(`No se encontró el lugar con ID: ${placeId}`);
                return;
            }
    
            const currentName = place.attributes.name.trim();
            if (currentName !== newName) {
                try {
                    const UpdateObject = require("Waze/Action/UpdateObject");
                    const action = new UpdateObject(place, { name: newName });
                    W.model.actionManager.add(action);
                    console.log(`Nombre actualizado: "${currentName}" → "${newName}"`);
                    changesMade = true;
                } catch (error) {
                    console.error("Error aplicando la acción de actualización:", error);
                }
            }
        });
    
        // Procesar eliminación
        deleteCheckboxes.forEach(cb => {
            const index = cb.dataset.index;
            const placeId = document.querySelector(`.new-name-input[data-index="${index}"]`)?.getAttribute("data-place-id");
            const place = W.model.venues.getObjectById(placeId);
    
            if (!place) {
                console.warn(`No se encontró el lugar con ID para eliminar: ${placeId}`);
                return;
            }
    
            try {
                const DeleteObject = require("Waze/Action/DeleteObject");
                const deleteAction = new DeleteObject(place);
                W.model.actionManager.add(deleteAction);
                console.log(`Lugar eliminado: ${placeId}`);
                changesMade = true;
            } catch (error) {
                console.error("Error eliminando el lugar:", error);
            }
        });
    
        if (changesMade) {
            alert("Cambios aplicados correctamente.");
        } else {
            alert("No se realizaron cambios.");
        }
    
        // Cerrar el panel flotante
        const panel = document.getElementById("normalizer-floating-panel");
        if (panel) panel.remove();
    }

    //**************************************************************************
    //Nombre: isSimilar
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas:
    //  - a (string): Primera palabra a comparar.
    //  - b (string): Segunda palabra a comparar.
    //Salidas:
    //  - boolean: Retorna true si las palabras son consideradas similares de forma leve; de lo contrario, retorna false.
    //Prerrequisitos si existen: Ninguno.
    //Descripción:
    // Esta función evalúa la similitud leve entre dos palabras. Primero, verifica si ambas palabras son
    // idénticas, en cuyo caso retorna true. Luego, comprueba si la diferencia en la cantidad de caracteres
    // entre ambas es mayor a 2; si es así, retorna false. Posteriormente, compara carácter por carácter
    // hasta el largo mínimo de las palabras, contando las diferencias. Si el número de discrepancias
    // excede 2, se considera que las palabras no son similares y retorna false; en caso contrario, retorna true.
    //**************************************************************************
    function isSimilar(a, b)
    {
        if (a === b) return true;
        if (Math.abs(a.length - b.length) > 2) return false;

        let mismatches = 0;
        for (let i = 0; i < Math.min(a.length, b.length); i++) {
            if (a[i] !== b[i]) mismatches++;
            if (mismatches > 2) return false;
        }

        return true;
    }


    //**************************************************************************
    //Nombre: normalizePlaceName
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: name (string) - Nombre del lugar
    //Salidas: texto normalizado (string)
    //Descripción: Normaliza un nombre aplicando capitalización, manejo de artículos, números y paréntesis.
    //**************************************************************************
    function normalizePlaceName(name) {
        if (!name) return "";

        const normalizeArticles = !document.getElementById("normalizeArticles")?.checked;
        const articles = ["el", "la", "los", "las", "de", "del", "al", "y"];
        const words = name.trim().split(/\s+/);
        const isRoman = word => /^(i{1,3}|iv|v|vi{0,3}|ix|x|xi{0,3}|xiv|xv|xvi{0,3}|xix|xx|xxi{0,3}|xxiv|xxv)$/i.test(word);

        const normalizedWords = words.map((word, index) => {
            const lowerWord = word.toLowerCase();

            // Si la palabra es un número, no la analizamos
            if (/^\d+$/.test(word)) return word;

            // Si la palabra está en la lista de exclusión, no la modificamos
            if (excludeWords.some(w => w.toLowerCase() === lowerWord)) return word;

            // Si es un número romano, lo dejamos en mayúsculas
            if (isRoman(word)) return word.toUpperCase();

            // Si es una sigla con estructura T&T o a&A, convertirla a mayúsculas
            if (/^[A-Za-z]&[A-Za-z]$/.test(word)) return word.toUpperCase();

            // Si es una sigla con apóstrofe como "E's", también la dejamos igual
            if (/^[A-Z]'[A-Z][a-z]+$/.test(word)) return word;

            // Si no se deben normalizar artículos y es un artículo, lo dejamos en minúsculas (excepto la primera palabra)
            if (!normalizeArticles && articles.includes(lowerWord) && index !== 0) return lowerWord;

            // Si es un número seguido de letras, lo dejamos igual
            if (/^\d+[A-Z][a-zA-Z]*$/.test(word)) return word;

            // Si está entre paréntesis y es todo mayúsculas o minúsculas, lo dejamos igual
            if (/^\(.*\)$/.test(word)) {
                const inner = word.slice(1, -1);
                if (inner === inner.toUpperCase() || inner === inner.toLowerCase()) return word;
            }

            // Capitalizamos la palabra (primera letra en mayúscula, el resto en minúscula)
            return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
        });

        let newName = normalizedWords.join(" ")
            .replace(/\s*\|\s*/g, " - ")
            .replace(/([(["'])\s*([\p{L}])/gu, (match, p1, p2) => p1 + p2.toUpperCase())
            .replace(/\s*-\s*/g, " - ")
            .replace(/\b(\d+)([A-Z])\b/g, (match, num, letter) => num + letter.toUpperCase())
            .replace(/\.$/, "")
            .replace(/&(\s*)([A-Z])/g, (match, space, letter) => "&" + space + letter.toUpperCase());

        return newName.replace(/\s{2,}/g, " ").trim();
    }

    // Para exponer al contexto global real desde Tampermonkey
    unsafeWindow.normalizePlaceName = normalizePlaceName;



    //**************************************************************************
    //Nombre: openFloatingPanel
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: placesToNormalize (array de lugares con nombre original y sugerencias)
    //Salidas: Panel flotante con opciones de normalización y eliminación
    //Prerrequisitos si existen: Debe haberse definido normalizePlaceName y cargado excludeWords correctamente
    //Descripción: Crea un panel interactivo donde se presentan los lugares que requieren cambios,
    //             permitiendo su corrección o eliminación. Solo muestra lugares que requieren cambio
    //             y errores ortográficos verdaderos.
    //**************************************************************************
    function openFloatingPanel(placesToNormalize) {
        const panel = document.createElement("div");
        panel.id = "normalizer-floating-panel";
        panel.style.cssText = `
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background: white;
            padding: 20px;
            border: 1px solid #ccc;
            border-radius: 8px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
            z-index: 1000;
            max-height: 80vh;
            overflow-y: auto;
            min-width: 800px;
        `;

        let panelContent = `
            <style>
                #normalizer-table { width: 100%; border-collapse: collapse; }
                #normalizer-table th, #normalizer-table td { padding: 8px; border: 1px solid #ddd; }
                #normalizer-table tr:nth-child(even) { background-color: #f9f9f9; }
                .warning-row { background-color: #fff3cd !important; }
                .close-btn {
                    position: absolute;
                    top: 10px;
                    right: 10px;
                    background: #ccc;
                    color: black;
                    border: none;
                    border-radius: 4px;
                    width: 25px;
                    height: 25px;
                    font-size: 16px;
                    font-weight: bold;
                    cursor: pointer;
                    text-align: center;
                    line-height: 25px;
                    transition: background-color 0.3s ease;
                }
                .close-btn:hover {
                    background: #bbb;
                }
                .footer-buttons {
                    display: flex;
                    justify-content: center;
                    gap: 10px;
                    margin-top: 20px;
                }
            </style>
            <button class="close-btn" id="close-panel-btn">X</button>
            <h3>Places to Normalize</h3>
            <table id="normalizer-table">
                <thead>
                    <tr>
                        <th>Normalizar</th>
                        <th>Eliminar</th>
                        <th>Estado</th>
                        <th>Nombre Original</th>
                        <th>Nombre Sugerido</th>
                        <th>Corrección</th>
                        <th>Acción</th>
                    </tr>
                </thead>
                <tbody>
        `;

        // Procesar cada lugar y sus advertencias
        placesToNormalize.forEach((place, index) => {
            const { originalName, newName, spellingWarnings = [] } = place;

            // Crear una fila por cada advertencia ortográfica
            spellingWarnings.forEach((warning, warningIndex) => {
                const suggestionId = `suggestion-${index}-${warningIndex}`;

                panelContent += `
                <tr class="warning-row">
                    <td style="text-align: center;">
                        <input type="checkbox" class="normalize-checkbox"
                            data-index="${index}"
                            data-warning-index="${warningIndex}"
                            data-suggestion-id="${suggestionId}">
                    </td>
                    <td style="text-align: center;">
                        <input type="checkbox" class="delete-checkbox"
                            data-index="${index}"
                            data-warning-index="${warningIndex}">
                    </td>
                    <td style="text-align: center;">⚠️</td>
                    <td id="name-cell-${index}-${warningIndex}">${originalName}</td>
                    <td>
                        <input type="text" class="new-name-input"
                            data-index="${index}"
                            data-warning-index="${warningIndex}"
                            data-place-id="${place.id}"
                            data-suggestion-id="${suggestionId}"
                            value="${newName}">
                    </td>
                    <td>${warning.original} → ${warning.sugerida} (${warning.tipo})</td>
                    <td style="text-align: center;">
                        <button class="apply-suggestion-btn"
                                data-index="${index}"
                                data-warning-index="${warningIndex}"
                                title="Corregir ortografía de la palabra"
                                style="background-color: #4CAF50; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">
                            Fix
                        </button>
                        <button class="add-exclude-btn"
                                data-word="${warning.original}"
                                title="Adicionar palabra excluida nueva"
                                style="background-color: #007bff; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">
                            Add
                        </button>
                    </td>
                </tr>`;
            });
        });

        panelContent += `
                    </tbody>
                </table>
                <div class="footer-buttons">
                    <button class="apply-changes-btn" id="apply-changes-btn">✔️ Apply Changes</button>
                    <button class="cancel-btn" id="cancel-btn">❌ Cancel</button>
                </div>
            `;

        panel.innerHTML = panelContent;
        document.body.appendChild(panel);

        // Eventos para los botones
        document.getElementById('close-panel-btn').addEventListener('click', () => {
            panel.remove(); // Cerrar el panel flotante
        });

        document.getElementById('apply-changes-btn').addEventListener('click', () => {
            window.applyNormalization(); // Llamar a la función global para aplicar cambios
        });

        document.getElementById('cancel-btn').addEventListener('click', () => {
            panel.remove(); // Cerrar el panel flotante
        });

        // Asignar eventos a los botones "Fix"
        document.querySelectorAll('.apply-suggestion-btn').forEach(button => {
            button.addEventListener('click', function () {
                const index = this.dataset.index;
                const warningIndex = this.dataset.warningIndex;
                const input = document.querySelector(
                    `.new-name-input[data-index="${index}"][data-warning-index="${warningIndex}"]`
                );
                const checkbox = document.querySelector(
                    `.normalize-checkbox[data-index="${index}"][data-warning-index="${warningIndex}"]`
                );

                if (input && checkbox) {
                    // Aplicar la corrección ortográfica al campo de entrada
                    const warning = placesToNormalize[index].spellingWarnings[warningIndex];
                    input.value = input.value.replace(warning.original, warning.sugerida);

                    checkbox.checked = true; // Marcar el checkbox
                }
            });
        });

        // Asignar eventos a los botones "Add Excld Word"
        document.querySelectorAll('.add-exclude-btn').forEach(button => {
            button.addEventListener('click', function () {
                const word = this.dataset.word;

                // Verificar si es una sigla con formato X&X
                if (/^[A-Za-z]&[A-Za-z]$/.test(word)) {
                    alert("⚠️ No es necesario adicionar palabras excluidas que tengan '&'.");
                    return;
                }

                if (!excludeWords.includes(word)) {
                    excludeWords.push(word);
                    excludeWords.sort((a, b) => a.localeCompare(b)); // Ordenar alfabéticamente
                    localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
                    renderExcludedWordsPanel(); // Actualizar el panel lateral

                    // Mostrar popup en el centro del panel flotante
                    const panel = document.getElementById("normalizer-floating-panel");
                    const popup = document.createElement('div');
                    popup.textContent = `✅ The Word "${word}" has been added to the exclusion list.`;
                    popup.style.position = 'absolute';
                    popup.style.top = '50%';
                    popup.style.left = '50%';
                    popup.style.transform = 'translate(-50%, -50%)';
                    popup.style.backgroundColor = '#4CAF50';
                    popup.style.color = 'white';
                    popup.style.padding = '10px 20px';
                    popup.style.borderRadius = '5px';
                    popup.style.zIndex = '10000';
                    popup.style.opacity = '1';
                    popup.style.transition = 'opacity 1s ease-in-out';

                    panel.appendChild(popup);

                    setTimeout(() => {
                        popup.style.opacity = '0';
                        setTimeout(() => panel.removeChild(popup), 1000);
                    }, 2000);

                    // Bloquear el botón y cambiar su texto
                    this.textContent = 'Added';
                    this.disabled = true;
                    this.style.backgroundColor = '#6c757d'; // Cambiar color a gris
                    this.style.cursor = 'not-allowed';
                } else {
                    alert(`⚠️ The word "${word}" is already on the exclusion list.`);
                }
            });
        });
    }
    //**************************************************************************
    //Nombre: loadExcludeWordsFromXML
    //Fecha modificación:
    //Autor: mincho77
    //Entradas:
    //  - callback: función opcional que se ejecuta una vez se cargan y procesan las palabras excluidas.
    //Salidas: ninguna directa. Actualiza la variable global `excludeWords`.
    //Prerrequisitos:
    //  - Debe existir un archivo llamado 'excludeWords.xml' accesible por fetch.
    //  - Debe estar definida la variable global `excludeWords`.
    //Descripción:
    //  Carga un archivo XML que contiene una lista de palabras excluidas.
    //  Combina las palabras nuevas con las que ya están guardadas en localStorage
    //  y actualiza la lista global `excludeWords`. Si el XML no puede ser cargado,
    //  se usa únicamente el contenido almacenado localmente como respaldo.
    //**************************************************************************
    function loadExcludeWordsFromXML(callback) {
        fetch("excludeWords.xml")
            .then(response => response.text()) // Corrected the syntax here
            .then(xmlText => {
            const parser = new DOMParser();
            const xmlDoc = parser.parseFromString(xmlText, "text/xml");
            const wordNodes = xmlDoc.getElementsByTagName("word");
            const wordsFromXML = Array.from(wordNodes).map(node => node.textContent.trim());

            const existing = JSON.parse(localStorage.getItem("excludeWords")) || [];
            excludeWords = [...new Set([...existing, ...wordsFromXML])].sort((a, b) => a.localeCompare(b));
            localStorage.setItem("excludeWords", JSON.stringify(excludeWords));

            if (callback) callback();
        })
            .catch(() => {
            console.warn("⚠️ No se pudo cargar excludeWords.xml. Solo se usará localStorage.");
            excludeWords = JSON.parse(localStorage.getItem("excludeWords")) || ["EDS", "IPS", "McDonald's", "EPS"];
            localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
            if (callback) callback();
        });
    }

    function exportExcludeWordsToXML() {
        const xmlContent = `<?xml version="1.0" encoding="UTF-8"?>
<ExcludedWords>
${excludeWords.map(word => `  <word>${word}</word>`).join("\n")}
</ExcludedWords>`;

        const blob = new Blob([xmlContent], { type: "application/xml" });
        const url = URL.createObjectURL(blob); // ✅ ESTA LÍNEA ESTABA FALTANDO
        const link = document.createElement("a");
        link.href = url;
        link.download = "excludeWords.xml";
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);
}


    function showFloatingMessage(message) {
        const msg = document.createElement("div");
        msg.textContent = message;
        msg.style.position = "fixed";
        msg.style.bottom = "30px";
        msg.style.left = "50%";
        msg.style.transform = "translateX(-50%)";
        msg.style.backgroundColor = "#333";
        msg.style.color = "#fff";
        msg.style.padding = "10px 20px";
        msg.style.borderRadius = "5px";
        msg.style.zIndex = 9999;
        msg.style.opacity = "0.95";
        msg.style.transition = "opacity 1s ease-in-out";

        document.body.appendChild(msg);

        setTimeout(() => {
            msg.style.opacity = "0";
            setTimeout(() => document.body.removeChild(msg), 1000);
        }, 3000);
    }
    //**************************************************************************
    //Nombre: waitForWME
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: Ninguna
    //Salidas: Ninguna
    //Prerrequisitos si existen:
    // - El objeto global W debe estar disponible.
    // - Deben estar definidas las funciones: initializeExcludeWords, createSidebarTab y renderExcludedWordsPanel.
    //Descripción:
    // Esta función espera a que el entorno de edición de Waze (WME) esté completamente cargado,
    // verificando que existan los objetos necesarios para iniciar el script.
    // Una vez disponible, inicializa la lista de palabras excluidas desde localStorage,
    // crea el tab lateral personalizado y renderiza la lista visual de palabras excluidas.
    // Si WME aún no está listo, vuelve a intentar cada 1000 ms.
    //**************************************************************************
    function waitForWME() {
        if (W && W.userscripts && W.model && W.model.venues) {
            console.log(`[${SCRIPT_NAME}] Inicializando v${VERSION}`);

            initializeExcludeWords(); // ⚠️ Usa solo localStorage
            createSidebarTab();

            waitForDOM("#normalizer-tab", () => {
                console.log("[waitForWME] 🧩 Sidebar listo, renderizando palabras excluidas");

                renderExcludedWordsPanel();       // Muestra las palabras
                setupDragAndDropImport();         // Activa drag & drop

                //Evita que se abra el archivo si cae fuera del área
              //  window.addEventListener("dragover", e => e.preventDefault(), false);
             //   window.addEventListener("drop", e => e.preventDefault(), false);
            });

        } else {
            console.log(`[${SCRIPT_NAME}] Esperando que WME esté listo...`);
            setTimeout(waitForWME, 1000);
        }
    }


    //**************************************************************************
    //Nombre: cleanupEventListeners
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: Ninguna
    //Salidas: Ninguna
    //Prerrequisitos si existen:
    // - Debe existir un elemento en el DOM con el id "normalizer-floating-panel".
    //Descripción:
    // Esta función limpia los event listeners asociados al panel flotante de normalización.
    // Lo hace clonando el nodo del panel y reemplazándolo en el DOM, lo cual elimina todos
    // los listeners previamente asignados a ese nodo, evitando posibles fugas de memoria
    // o comportamientos inesperados.
    //**************************************************************************
    function cleanupEventListeners() {
        const panel = document.getElementById("normalizer-floating-panel");
        if (panel) {
            const clone = panel.cloneNode(true);
            panel.parentNode.replaceChild(clone, panel);
        }
    }  

    //**************************************************************************
    //Nombre: normalizePlaceName (unsafeWindow)
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas:
    //  - name (string): el nombre original del lugar.
    //Salidas:
    //  - string: nombre normalizado, respetando exclusiones y opciones del usuario.
    //Prerrequisitos si existen:
    //  - Debe estar cargada la lista global excludeWords.
    //  - Debe existir un checkbox con id “normalizeArticles” para definir si se normalizan artículos.
    //Descripción:
    //  Esta versión expuesta globalmente permite acceder a la normalización básica del nombre de un lugar
    //  desde otros contextos como el navegador o Tampermonkey. Capitaliza cada palabra, respeta las excluidas
    //  y no aplica normalización a artículos si el checkbox lo indica.
    //  Realiza limpieza básica: reemplazo de pipes, eliminación de espacios dobles y trim final.
    //**************************************************************************
    unsafeWindow.normalizePlaceName = function(name)
    {
        if (!name) return "";

        const normalizeArticles = !document.getElementById("normalizeArticles")?.checked;
        const articles = ["el", "la", "los", "las", "de", "del", "al", "y", "o"];

        const words = name.trim().split(/\s+/);
        const normalizedWords = words.map((word, index) => {
            const lowerWord = word.toLowerCase();

            // Saltar palabras excluidas
            if (excludeWords.includes(word)) return word;

            // Saltar artículos si el checkbox está activo y no es la primera palabra
            if (!normalizeArticles && articles.includes(lowerWord) && index !== 0) {
                return lowerWord;
            }
            //Mayúsculas
            return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
        });

        name = normalizedWords.join(" ");
        name = name.replace(/\s*\|\s*/g, " - ");
        name = name.replace(/\s{2,}/g, " ").trim();

        return name;
    };



//**************************************************************************
//Nombre: waitForDOM
//Fecha modificación: 2025-03-31
//Autor: mincho77
//Entradas:
//   selector (string): Selector CSS del nodo a esperar.
//   callback (function): Función a ejecutar cuando el nodo esté disponible.
//   interval (int, opcional): Tiempo entre intentos en milisegundos. Default: 500ms.
//   maxAttempts (int, opcional): Máximo de intentos antes de abortar. Default: 10.
//Salidas: Ejecuta el callback si encuentra el selector dentro del tiempo.
//Descripción:
//   Esta función monitorea el DOM en intervalos constantes hasta que
//   encuentra un nodo que coincida con el selector. Si lo encuentra,
//   ejecuta el callback con ese nodo. Si no, muestra un error por consola.
//**************************************************************************
    function waitForDOM(selector, callback, interval = 500, maxAttempts = 10)
    {
        let attempts = 0;
        const checkExist = setInterval(() => {
            const element = document.querySelector(selector);
            if (element) {
                clearInterval(checkExist);
                callback(element);
            } else if (attempts >= maxAttempts) {
                clearInterval(checkExist);
                console.error(`[${SCRIPT_NAME}] Error: No se encontraron elementos en el DOM después de ${maxAttempts} intentos.`);
            }
            attempts++;
        }, interval);
    }

    //**************************************************************************
    //Nombre: getSidebarHTML
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: Ninguna.
    //Salidas: Retorna un string con HTML que define el contenido del panel lateral del script.
    //Prerrequisitos si existen: Debe estar disponible el valor de las variables globales:
    // - normalizeArticles (boolean): Define si los artículos deben ser normalizados o no.
    // - maxPlaces (number): Número máximo de lugares a escanear.
    //Descripción:
    // Esta función construye el HTML que se inyecta en el panel lateral (sidebar) de WME.
    // Incluye controles para:
    // - Activar o desactivar normalización de artículos ("el", "la", etc).
    // - Definir la cantidad máxima de lugares a procesar.
    // - Agregar palabras excluidas manualmente.
    // - Exportar palabras excluidas a un archivo XML.
    // - Importar una lista de palabras excluidas desde archivo XML.
    // - Disparar el escaneo de lugares para normalización.
    // La lista de palabras excluidas **no se renderiza aquí directamente**, sino en el div
    // con id "normalizer-sidebar" para permitir que sea manejada dinámicamente.
    //**************************************************************************
    function getSidebarHTML() {
        return `
        <div id="normalizer-tab">
          <h4>Places Name Normalizer <span style="font-size:11px;">v${VERSION}</span></h4>
    
          <div>
            <input type="checkbox" id="normalizeArticles" ${normalizeArticles ? "checked" : ""}>
            <label for="normalizeArticles">No Normalizar artículos (el, la, los, ...)</label>
          </div>
    
          <div>
            <label>Máximo de Places a buscar: </label>
            <input type='number' id='maxPlacesInput' value='${maxPlaces}' min='1' max='800' style='width: 60px;'>
          </div>
    
          <div>
            <label>Palabras Excluidas:</label>
            <input type='text' id='excludeWord' style='width: 120px;'>
            <button id='addExcludeWord'>Add Word</button>
            <div id="normalizer-sidebar" style="margin-top: 20px;"></div>
            <button id="exportExcludeWords" style="margin-top: 5px;">Export Words</button>
            <br>
            <button id="importExcludeWordsUnifiedBtn" style="margin-top: 5px;">Import List...</button>
            <input type="file" id="hiddenImportInput" accept=".xml" style="display: none;">
          </div>
    
          <div>
            <input type="checkbox" id="checkOnlyTildes" checked>
            <label for="checkOnlyTildes">Revisar solo tildes</label>
          </div>
    
          <div id="drop-zone" style="border: 2px dashed #ccc; border-radius: 6px; padding: 15px; margin: 15px 0; text-align: center; font-style: italic; color: #555; background-color: #f8f9fa; transition: all 0.3s ease;">
            📂 Arrastra aquí tu archivo .txt o .xml para importar palabras excluidas
          </div>
    
          <hr>
          <button id="scanPlaces">Scan...</button>
        </div>
      `;
    }

//**************************************************************************
//Nombre: attachEvents
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: Ninguna
//Salidas: Ninguna
//Prerrequisitos si existen:
// - Deben existir en el DOM los elementos con los siguientes IDs:
//   "normalizeArticles", "maxPlacesInput", "addExcludeWord", "scanPlaces",
//   "hiddenImportInput", "importExcludeWordsUnifiedBtn" y "exportExcludeWords".
// - Debe existir la función handleImportList y la función scanPlaces.
// - Debe estar definida la variable global excludeWords y la función renderExcludedWordsPanel.
//Descripción:
// Esta función adjunta los event listeners necesarios para gestionar la interacción del usuario
// con el panel del normalizador de nombres. Se encargan de:
//   - Actualizar la opción de normalizar artículos al cambiar el estado del checkbox.
//   - Modificar el número máximo de lugares a procesar a través de un input.
//   - Exportar la lista de palabras excluidas a un archivo XML.
//   - Añadir nuevas palabras a la lista de palabras excluidas, evitando duplicados, y actualizar el panel.
//   - Activar el botón unificado para la importación de palabras excluidas mediante un input oculto.
//   - Ejecutar la función de escaneo de lugares al hacer clic en el botón correspondiente.
//**************************************************************************
    function attachEvents2()
    {
        console.log(`[${SCRIPT_NAME}] Adjuntando eventos...`);

        const normalizeArticlesCheckbox = document.getElementById("normalizeArticles");
        const maxPlacesInput = document.getElementById("maxPlacesInput");
        const addExcludeWordButton = document.getElementById("addExcludeWord");
        const scanPlacesButton = document.getElementById("scanPlaces");
        const hiddenInput = document.getElementById("hiddenImportInput");
        const importButtonUnified = document.getElementById("importExcludeWordsUnifiedBtn");

        // Validación de elementos necesarios
        if (!normalizeArticlesCheckbox || !maxPlacesInput || !addExcludeWordButton || !scanPlacesButton) {
            console.error(`[${SCRIPT_NAME}] Error: No se encontraron elementos en el DOM.`);
            return;
        }

        // ✅ Evento: cambiar estado de "no normalizar artículos"
        normalizeArticlesCheckbox.addEventListener("change", (e) => {
            normalizeArticles = e.target.checked;
        });

        // ✅ Evento: cambiar número máximo de places
        maxPlacesInput.addEventListener("input", (e) => {
            maxPlaces = parseInt(e.target.value, 10);
        });

        // ✅ Evento: exportar palabras excluidas a XML
        document.getElementById("exportExcludeWords").addEventListener("click", () => {
            const savedWords = JSON.parse(localStorage.getItem("excludeWords")) || [];
            if (savedWords.length === 0) {
                alert("No hay palabras excluidas para exportar.");
                return;
            }
            const sortedWords = [...savedWords].sort((a, b) => a.localeCompare(b));

            const xmlContent = `<?xml version="1.0" encoding="UTF-8"?>
<ExcludedWords>
  ${sortedWords.map(word => `  <word>${word}</word>`).join("\n  ")}
</ExcludedWords>`;

            const blob = new Blob([xmlContent], { type: "application/xml" });
            const url = URL.createObjectURL(blob);
            const link = document.createElement("a");

            link.href = url;
            link.download = "excluded_words.xml";
            document.body.appendChild(link); // Correctly appends the link
            link.click();
            document.body.removeChild(link); // Correctly removes the link
        });

        // ✅ Evento: añadir palabra excluida sin duplicados
        addExcludeWordButton.addEventListener("click", () => {
            const wordInput = document.getElementById("excludeWord") || document.getElementById("excludedWord");
            const word = wordInput?.value.trim();

            if (!word) return;

            const lowerWord = word.toLowerCase();
            const alreadyExists = excludeWords.some(w => w.toLowerCase() === lowerWord);
            if (!alreadyExists) {
                excludeWords.push(word);
                localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
                renderExcludedWordsPanel(); // Usar renderExcludedWordsPanel en lugar de updateExcludeList
            }

            wordInput.value = "";
        });

        // ✅ Evento: nuevo botón unificado de importación
        importButtonUnified.addEventListener("click", () => {
            hiddenInput.click(); // abre el file input oculto
        });

        hiddenInput.addEventListener("change", () => {
            handleImportList(); // ✅ Llama a la función handleImportList al importar
        });

        // ✅ Evento: escanear lugares
        scanPlacesButton.addEventListener("click", scanPlaces);
    }

  

    //**************************************************************************
    //Nombre: NameChangeAction
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas:
    //   - venue (object): Objeto Place que contiene la información del lugar a modificar.
    //   - oldName (string): Nombre actual del lugar.
    //   - newName (string): Nuevo nombre sugerido para el lugar.
    //Salidas: Ninguna (función constructora que crea un objeto de acción).
    //Prerrequisitos si existen:
    //   - El objeto venue debe contar con la propiedad attributes, y dentro de ésta, con el campo id.
    //Descripción:
    // Esta función constructora crea un objeto que representa la acción de cambio de nombre
    // de un Place en el Waze Map Editor (WME). Asigna las propiedades correspondientes:
    //   - Guarda el objeto venue, el nombre original (oldName) y el nuevo nombre (newName).
    //   - Extrae y guarda el ID único del lugar desde venue.attributes.id.
    //   - Establece metadatos para identificar la acción, asignando el tipo "NameChangeAction"
    //     y marcando isGeometryEdit como false para indicar que no se trata de una edición de geometría.
    // Estos metadatos pueden ser utilizados por WME y otros plugins para gestionar y mostrar la acción.
    //**************************************************************************
    function NameChangeAction(venue, oldName, newName)
    {
        // Referencia al Place y los nombres
        this.venue = venue;
        this.oldName = oldName;
        this.newName = newName;

        // ID único del Place
        this.venueId = venue.attributes.id;

        // Metadatos que WME/Plugins pueden usar
        this.type = "NameChangeAction";
        this.isGeometryEdit = false; // no es una edición de geometría
    }

    /**
 * 1) getActionName: nombre de la acción en el historial.
 */
    NameChangeAction.prototype.getActionName = function() {
        return "Update place name";
    };

    /** 2) getActionText: texto corto que WME a veces muestra. */
    NameChangeAction.prototype.getActionText = function() {
        return "Update place name";
    };

    /** 3) getName: algunas versiones llaman a getName(). */
    NameChangeAction.prototype.getName = function() {
        return "Update place name";
    };

    /** 4) getDescription: descripción detallada de la acción. */
    NameChangeAction.prototype.getDescription = function() {
        return `Place name changed from "${this.oldName}" to "${this.newName}".`;
    };

    /** 5) getT: título (a veces requerido por plugins). */
    NameChangeAction.prototype.getT = function() {
        return "Update place name";
    };

    /** 6) getID: si un plugin llama a e.getID(). */
    NameChangeAction.prototype.getID = function() {
        return `NameChangeAction-${this.venueId}`;
    };

    /** 7) doAction: asigna el nuevo nombre (WME llama a esto al crear la acción). */
    NameChangeAction.prototype.doAction = function() {
        this.venue.attributes.name = this.newName;
        this.venue.isDirty = true;
        if (typeof W.model.venues.markObjectEdited === "function") {
            W.model.venues.markObjectEdited(this.venue);
        }
    };

    /** 8) undoAction: revertir al nombre anterior (Ctrl+Z). */
    NameChangeAction.prototype.undoAction = function() {
        this.venue.attributes.name = this.oldName;
        this.venue.isDirty = true;
        if (typeof W.model.venues.markObjectEdited === "function") {
            W.model.venues.markObjectEdited(this.venue);
        }
    };

    /** 9) redoAction: rehacer (Ctrl+Shift+Z), vuelve a doAction. */
    NameChangeAction.prototype.redoAction = function() {
        return this.doAction();
    };

    /** 10) undoSupported / redoSupported: indica si se puede des/rehacer. */
    NameChangeAction.prototype.undoSupported = function() {
        return true;
    };
    NameChangeAction.prototype.redoSupported = function() {
        return true;
    };

    /** 11) accept / supersede: evita fusionar con otras acciones. */
    NameChangeAction.prototype.accept = function() {
        return false;
    };
    NameChangeAction.prototype.supersede = function() {
        return false;
    };

    /** 12) isEditAction: true => habilita "Guardar". */
    NameChangeAction.prototype.isEditAction = function() {
        return true;
    };

    /** 13) getAffectedUniqueIds: objetos que se alteran. */
    NameChangeAction.prototype.getAffectedUniqueIds = function() {
        return [this.venueId];
    };

    /** 14) isSerializable: si no implementas serialize(), pon false. */
    NameChangeAction.prototype.isSerializable = function() {
        return false;
    };

    /** 15) isActionStackable: false => no combina con otras ediciones. */
    NameChangeAction.prototype.isActionStackable = function() {
        return false;
    };

    /** 16) getFocusFeatures: WME/Plugins pueden usarlo para "enfocar" el objeto. */
    NameChangeAction.prototype.getFocusFeatures = function() {
        // Devolvemos el venue para indicar que ese es el foco (o un array vacío si prefieres).
        return [this.venue];
    };

    /** 17) Métodos vacíos para evitar futuros "no es una función" si WME pide estos. */
    NameChangeAction.prototype.getFocusSegments = function() {
        return [];
    };
    NameChangeAction.prototype.getFocusNodes = function() {
        return [];
    };
    NameChangeAction.prototype.getFocusClosures = function() {
        return [];

    };

    /** 18) getTimestamp: método nuevo que WME/Plugins están llamando. */
    NameChangeAction.prototype.getTimestamp = function() {
        // Devolvemos un timestamp numérico (ms desde época UNIX).
        return Date.now();
    };

 
    //**************************************************************************
    //Nombre: openFloatingPanel
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: placesToNormalize (array de lugares con nombre original y sugerencias)
    //Salidas: Panel flotante con opciones de normalización y eliminación
    //Prerrequisitos si existen: Debe haberse definido normalizePlaceName y cargado excludeWords correctamente
    //Descripción: Crea un panel interactivo donde se presentan los lugares que requieren cambios,
    //             permitiendo su corrección o eliminación. Solo muestra lugares que requieren cambio
    //             y errores ortográficos verdaderos.
    //**************************************************************************
    function openFloatingPanel2(placesToNormalize) {
        const panel = document.createElement("div");
        panel.id = "normalizer-floating-panel";
        panel.style.cssText = `
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background: white;
            padding: 20px;
            border: 1px solid #ccc;
            border-radius: 8px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
            z-index: 1000;
            max-height: 80vh;
            overflow-y: auto;
            min-width: 800px;
        `;

        let panelContent = `
            <style>
                #normalizer-table { width: 100%; border-collapse: collapse; }
                #normalizer-table th, #normalizer-table td { padding: 8px; border: 1px solid #ddd; }
                #normalizer-table tr:nth-child(even) { background-color: #f9f9f9; }
                .warning-row { background-color: #fff3cd !important; }
                .close-btn {
                    position: absolute;
                    top: 10px;
                    right: 10px;
                    background: #ccc;
                    color: black;
                    border: none;
                    border-radius: 4px;
                    width: 25px;
                    height: 25px;
                    font-size: 16px;
                    font-weight: bold;
                    cursor: pointer;
                    text-align: center;
                    line-height: 25px;
                    transition: background-color 0.3s ease;
                }
                .close-btn:hover {
                    background: #bbb;
                }
                .footer-buttons {
                    display: flex;
                    justify-content: center;
                    gap: 10px;
                    margin-top: 20px;
                }
            </style>
            <button class="close-btn" id="close-panel-btn">X</button>
            <h3>Places to Normalize</h3>
            <table id="normalizer-table">
                <thead>
                    <tr>
                        <th>Normalizar</th>
                        <th>Eliminar</th>
                        <th>Estado</th>
                        <th>Nombre Original</th>
                        <th>Nombre Sugerido</th>
                        <th>Corrección</th>
                        <th>Acción</th>
                    </tr>
                </thead>
                <tbody>
        `;

        // Procesar cada lugar y sus advertencias
        placesToNormalize.forEach((place, index) => {
            const { originalName, newName, spellingWarnings = [] } = place;

            // Crear una fila por cada advertencia ortográfica
            spellingWarnings.forEach((warning, warningIndex) => {
                const suggestionId = `suggestion-${index}-${warningIndex}`;

                panelContent += `
                <tr class="warning-row">
                    <td style="text-align: center;">
                        <input type="checkbox" class="normalize-checkbox"
                            data-index="${index}"
                            data-warning-index="${warningIndex}"
                            data-suggestion-id="${suggestionId}">
                    </td>
                    <td style="text-align: center;">
                        <input type="checkbox" class="delete-checkbox"
                            data-index="${index}"
                            data-warning-index="${warningIndex}">
                    </td>
                    <td style="text-align: center;">⚠️</td>
                    <td id="name-cell-${index}-${warningIndex}">${originalName}</td>
                    <td>
                        <input type="text" class="new-name-input"
                            data-index="${index}"
                            data-warning-index="${warningIndex}"
                            data-place-id="${place.id}"
                            data-suggestion-id="${suggestionId}"
                            value="${newName}">
                    </td>
                    <td>${warning.original} → ${warning.sugerida} (${warning.tipo})</td>
                    <td style="text-align: center;">
                        <button class="apply-suggestion-btn"
                                data-index="${index}"
                                data-warning-index="${warningIndex}"
                                title="Corregir ortografía de la palabra"
                                style="background-color: #4CAF50; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">
                            Fix
                        </button>
                        <button class="add-exclude-btn"
                                data-word="${warning.original}"
                                title="Adicionar palabra excluida nueva"
                                style="background-color: #007bff; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">
                            Add Excld
                        </button>
                    </td>
                </tr>`;
            });
        });

        panelContent += `
                    </tbody>
                </table>
                <div class="footer-buttons">
                    <button class="apply-changes-btn" id="apply-changes-btn">✔️ Apply Changes</button>
                    <button class="cancel-btn" id="cancel-btn">❌ Cancel</button>
                </div>
            `;

        panel.innerHTML = panelContent;
        document.body.appendChild(panel);

        // Eventos para los botones
        document.getElementById('close-panel-btn').addEventListener('click', () => {
            panel.remove(); // Cerrar el panel flotante
        });

        document.getElementById('apply-changes-btn').addEventListener('click', () => {
            window.applyNormalization(); // Llamar a la función global para aplicar cambios
        });

        document.getElementById('cancel-btn').addEventListener('click', () => {
            panel.remove(); // Cerrar el panel flotante
        });

        // Asignar eventos a los botones "Fix"
        document.querySelectorAll('.apply-suggestion-btn').forEach(button => {
            button.addEventListener('click', function () {
                const index = this.dataset.index;
                const warningIndex = this.dataset.warningIndex;
                const input = document.querySelector(
                    `.new-name-input[data-index="${index}"][data-warning-index="${warningIndex}"]`
                );
                const checkbox = document.querySelector(
                    `.normalize-checkbox[data-index="${index}"][data-warning-index="${warningIndex}"]`
                );
        
                if (input && checkbox) {
                    const warning = placesToNormalize[index].spellingWarnings[warningIndex];
                    input.value = input.value.replace(warning.original, warning.sugerida || warning.original);
        
                    checkbox.checked = true; // Marcar el checkbox
                }
            });
        });

        // Asignar eventos a los botones "Add Excld Word"
        document.querySelectorAll('.add-exclude-btn').forEach(button => {
            button.addEventListener('click', function () {
                const word = this.dataset.word;

                if (!excludeWords.includes(word)) {
                    excludeWords.push(word);
                    excludeWords.sort((a, b) => a.localeCompare(b)); // Ordenar alfabéticamente
                    localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
                    renderExcludedWordsPanel(); // Actualizar el panel lateral

                    // Mostrar popup en el centro del panel flotante
                    const panel = document.getElementById("normalizer-floating-panel");
                    const popup = document.createElement('div');
                    popup.textContent = `✅ The Word "${word}" has been added to the exclusion list.`;
                    popup.style.position = 'absolute';
                    popup.style.top = '50%';
                    popup.style.left = '50%';
                    popup.style.transform = 'translate(-50%, -50%)';
                    popup.style.backgroundColor = '#4CAF50';
                    popup.style.color = 'white';
                    popup.style.padding = '10px 20px';
                    popup.style.borderRadius = '5px';
                    popup.style.zIndex = '10000';
                    popup.style.opacity = '1';
                    popup.style.transition = 'opacity 1s ease-in-out';

                    panel.appendChild(popup);

                    setTimeout(() => {
                        popup.style.opacity = '0';
                        setTimeout(() => panel.removeChild(popup), 1000);
                    }, 2000);

                    // Bloquear el botón y cambiar su texto
                    this.textContent = 'Excluded Word Added';
                    this.disabled = true;
                    this.style.backgroundColor = '#6c757d'; // Cambiar color a gris
                    this.style.cursor = 'not-allowed';
                } else {
                    alert(`⚠️ The word "${word}" is already on the exclusion list.`);
                }
            });
        });
    }
    
    //**************************************************************************
    //Nombre: waitForWME
    //Fecha modificación: 2025-03-30
    //Autor: mincho77
    //Entradas: Ninguna
    //Salidas: Ninguna
    //Prerrequisitos si existen:
    // - El objeto global W debe estar disponible.
    // - Deben estar definidas las funciones: initializeExcludeWords, createSidebarTab y renderExcludedWordsPanel.
    //Descripción:
    // Esta función espera a que el entorno de edición de Waze (WME) esté completamente cargado,
    // verificando que existan los objetos necesarios para iniciar el script.
    // Una vez disponible, inicializa la lista de palabras excluidas desde localStorage,
    // crea el tab lateral personalizado y renderiza la lista visual de palabras excluidas.
    // Si WME aún no está listo, vuelve a intentar cada 1000 ms.
    //**************************************************************************
    function waitForWME2() {
        if (W && W.userscripts && W.model && W.model.venues) {
            console.log(`[${SCRIPT_NAME}] Inicializando v${VERSION}`);

            initializeExcludeWords(); // ⚠️ Usa solo localStorage
            createSidebarTab();

            waitForDOM("#normalizer-tab", () => {
                console.log("[waitForWME] 🧩 Sidebar listo, renderizando palabras excluidas");

                renderExcludedWordsPanel();       // Muestra las palabras
                setupDragAndDropImport();         // Activa drag & drop

                //Evita que se abra el archivo si cae fuera del área
              //  window.addEventListener("dragover", e => e.preventDefault(), false);
             //   window.addEventListener("drop", e => e.preventDefault(), false);
            });

        } else {
            console.log(`[${SCRIPT_NAME}] Esperando que WME esté listo...`);
            setTimeout(waitForWME, 1000);
        }
    }
//**************************************************************************
//Nombre: init
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: Ninguna
//Salidas: Ninguna
//Prerrequisitos si existen:
// - El objeto global W debe estar disponible.
// - Deben estar definidas las funciones: initializeExcludeWords, createSidebarTab, waitForDOM, renderExcludedWordsPanel y setupDragAndDropImport.
//Descripción:
// Esta función espera a que el entorno de edición de Waze (WME) esté completamente cargado,
// verificando que existan los objetos necesarios para iniciar el script. Si WME aún no está listo,
// reintenta la inicialización cada 1000 ms. Una vez disponible, inicializa la lista de palabras
// excluidas, crea el tab lateral personalizado, espera a que el DOM del tab esté listo para
// renderizar el contenido (palabras excluidas y funcionalidad drag & drop), y expone globalmente
// las funciones applyNormalization y normalizePlaceName para uso externo.
//**************************************************************************
function init() {
    if (!W || !W.userscripts || !W.model || !W.model.venues) {
        console.log(`[${SCRIPT_NAME}] Esperando que WME esté listo...`);
        setTimeout(init, 1000);
        return;
    }

    console.log(`[${SCRIPT_NAME}] Inicializando v${VERSION}`);
    initializeExcludeWords();
    createSidebarTab();
    waitForDOM("#normalizer-tab", () => {
        console.log("[init] 🧩 Sidebar listo, renderizando palabras excluidas");
        renderExcludedWordsPanel();
        setupDragAndDropImport();
    });

    window.applyNormalization = applyNormalization;
    window.normalizePlaceName = normalizePlaceName;
}
init();
    
    } catch (e) {
        console.error('[PlacesNameNormalizer] Fatal initialization error:', e);
    }
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址