WME Places Name Normalizer

Normaliza nombres de lugares en Waze Map Editor (WME)

// ==UserScript==
// @name         WME Places Name Normalizer
// @namespace    https://greasyfork.org/en/users/mincho77
// @version      5.0.2
// @description  Normaliza nombres de lugares en Waze Map Editor (WME)
// @author       mincho77
// @match        https://www.waze.com/*editor*
// @match        https://beta.waze.com/*user/editor*
// @grant        GM_xmlhttpRequest
// @connect      api.languagetool.org
// @grant        unsafeWindow
// @license      MIT
// @run-at       document-end
// ==/UserScript==
/*global W*/
(() => {
    "use strict";
    // Variables globales básicas
    const SCRIPT_NAME = GM_info.script.name;
    const VERSION = GM_info.script.version.toString();
    // Inicializar la lista de palabras especiales
    let specialWords = JSON.parse(localStorage.getItem("specialWords")) || [];

    let maxPlaces = 50;
    let normalizeArticles = true;
    let placesToNormalize = [];
    let wordLists = {
        excludeWords : JSON.parse(localStorage.getItem("excludeWords")) || [],
        dictionaryWords :
          JSON.parse(localStorage.getItem("dictionaryWords")) || []
    };
    // ==============================================
    // Inicialización de idioma y diccionarios
    // ==============================================

    // Idioma activo: cargado desde memoria o por defecto "SP"
    let activeDictionaryLang =
      localStorage.getItem("activeDictionaryLang") || "SP";

    // Diccionarios por defecto (solo si no hay nada guardado)
    const defaultDictionaries = {
        SP : { a : [ "árbol" ], b : [ "barco" ] },
        EN : { a : [ "apple" ], b : [ "boat" ] }
    };

    // Diccionario principal, comenzamos con los valores por defecto
    const spellDictionaries = {
        SP : {...defaultDictionaries.SP },
        EN : {...defaultDictionaries.EN }
    };

    // Si hay datos guardados en localStorage, los sobreescribimos
    const savedSP = localStorage.getItem("spellDictionaries_SP");
    const savedEN = localStorage.getItem("spellDictionaries_EN");

    if (savedSP)
    {
        try
        {
            spellDictionaries.SP = JSON.parse(savedSP);
        }
        catch (e)
        {
            console.warn(
              "❌ Diccionario SP corrupto en memoria, se usará el de ejemplo.");
        }
    }
    if (savedEN)
    {
        try
        {
            spellDictionaries.EN = JSON.parse(savedEN);
        }
        catch (e)
        {
            console.warn(
              "❌ Diccionario EN corrupto en memoria, se usará el de ejemplo.");
        }
    }
    // Crear la lista visible a partir del idioma actual
    let dictionaryWords =
      Object.values(spellDictionaries[activeDictionaryLang]).flat().sort();

    unsafeWindow.debugLang = activeDictionaryLang;
    unsafeWindow.debugDict = spellDictionaries;

    let excludeWords = wordLists.excludeWords || [];

    // ********************************************************************************************************************************
    // Declaración global de placesToNormalize
    // --------------------------------------------------------------------------------------------------------------------------------
    // Prevención global del comportamiento por defecto en drag & drop
    // (Evita que se abra el archivo en otra ventana)
    // Se aplican los eventos de arrastre y suelta a todo el documento.
    // Se previene el comportamiento por defecto para todos los eventos
    // de arrastre y suelta, excepto en el drop-zone.
    // Se establece el efecto de arrastre como "none" para evitar
    // cualquier efecto visual no deseado.
    // --------------------------------------------------------------------------------------------------------------------------------
    ["dragenter", "dragover", "dragleave", "drop"].forEach((evt) => {
        document.addEventListener(evt, (e) => {
            // Si el evento ocurre dentro del área de drop-zone, no lo
            // bloquea
            if (e.target && e.target.closest && e.target.closest("#drop-zone"))
            {
                return; // Permitir que el drop-zone maneje el evento
            }
            if (e.target && e.target.closest &&
                e.target.closest("#dictionary-drop-zone"))
            {
                return; // Permitir que el dictionary-drop-zone maneje el evento
            }
            e.preventDefault();  // Prevenir el comportamiento predeterminado
            e.stopPropagation(); // Detener la propagación del evento
        }, { capture : true });
    });

    // ********************************************************************************************************************************
    // Nombre: debugDictionaries
    // Fecha modificación: 2025-04-15 12:17
    // Autor: mincho77
    // Entradas: Ninguna
    // Salidas: Ninguna. Muestra en la consola el idioma activo y el diccionario actual.
    // Descripción:
    // Esta función muestra en la consola el idioma activo y el diccionario actual. Se utiliza para depurar 
    // y verificar el estado de los diccionarios. Permite al verificar que el idioma y el
    // diccionario se han configurado correctamente. La función se puede invocar manualmente desde la 
    // consola del navegador para obtener información sobre el estado actual de los diccionarios. 
    // *********************************************************************************************************************************
    unsafeWindow.debugDictionaries = function ()
    {
        console.log("Idioma activo:", activeDictionaryLang);
        console.log("Diccionario actual:", spellDictionaries[activeDictionaryLang]);
    };

    // ********************************************************************************************************************************
    // Nombre: renderExcludedWordsList
    // Fecha modificación: 2025-04-15 12:17
    // Autor: mincho77
    // Entradas: Ninguna
    // Salidas: Ninguna. Renderiza el panel de palabras excluidas.
    // Descripción:
    // Esta función renderiza el panel de palabras excluidas en la interfaz
    // de usuario. Se encarga de crear la estructura HTML necesaria para
    // mostrar la lista de palabras excluidas, así como los botones para
    // agregar, eliminar y editar palabras. La función también maneja la
    // lógica de búsqueda y filtrado de palabras en la lista. Se utiliza para
    // permitir al usuario gestionar su lista de palabras excluidas de manera
    // eficiente. La función se activa al cargar la página y cada vez que se
    // actualiza la lista de palabras excluidas. Se utiliza para mejorar la
    // experiencia del usuario al permitirle ver y gestionar fácilmente las
    // palabras excluidas. La función también se encarga de renderizar la
    // lista de palabras del diccionario después de agregar una nueva palabra.
    // *********************************************************************************************************************************
    function renderDictionaryWordsList()
    {
        const container = document.getElementById("dictionary-words-list");
        if (!container)
        {
            console.warn(`[${SCRIPT_NAME}] No se encontró el contenedor 'dictionary-words-list'.`);
            return;
        }

        container.innerHTML = ""; // Limpia el contenedor

        const selectedLang = activeDictionaryLang || "SP";
        const dictByLetter = spellDictionaries[selectedLang];

        if (!dictByLetter || Object.keys(dictByLetter).length === 0)
        {
            console.warn(`[${SCRIPT_NAME}] No hay datos para el idioma activo: ${selectedLang}`);
            container.innerHTML = "<p>No hay palabras en el diccionario para este idioma.</p>";
            return;
        }

        const words = Object.values(dictByLetter).flat().sort((a, b) => a.localeCompare(b));

        const ul = document.createElement("ul");
        ul.style.listStyle = "none";

        words.forEach((word) => {
            const li = document.createElement("li");
            li.textContent = word;
            ul.appendChild(li);
        });
        container.appendChild(ul);
    }

    // ********************************************************************************************************************************
    // Nombre: showNoPlacesFoundMessage
    // Fecha modificación: 2025-04-10
    // Autor: mincho77
    // Entradas: Ninguna
    // Salidas: Ninguna. Crea un modal que informa al usuario que no se encontraron lugares que cumplan con los criterios actuales. 
    // Descripción:
    // Muestra un mensaje modal cuando no se encuentran lugares. Este mensaje incluye un botón para cerrar el modal. Se utiliza para 
    // mostrar información al usuario sobre la falta de lugares encontrados.
    // ********************************************************************************************************************************
    function showNoPlacesFoundMessage()
    { // Crear el modal
        const modal = document.createElement("div");
        modal.className = "no-places-modal-overlay";
        modal.innerHTML = `
          <div class="no-places-modal">
              <div class="no-places-header">
                  <h3>⚠️ No se encontraron lugares</h3>
              </div>
              <div class="no-places-body">
                  <p>No se encontraron lugares que cumplan con los criterios actuales.</p>
                  <p>Intenta ajustar los filtros o ampliar el área de búsqueda.</p>
              </div>
              <div class="no-places-footer">
                  <button id="close-no-places-btn" class="no-places-btn">Aceptar</button>
              </div>
          </div>
      `;
        // Agregar el modal al documento
        document.body.appendChild(modal);
        // Manejar el evento de cierre
        document.getElementById("close-no-places-btn")
          .addEventListener("click", () => { modal.remove(); });
    }
    // Estilos CSS para el mensaje
    const noPlacesStyles = `
    <style>
    .no-places-modal-overlay
    {
        position: fixed;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background: rgba(0, 0, 0, 0.6);
        display: flex;
        justify-content: center;
        align-items: center;
        z-index: 10000;
        animation: fadeIn 0.3s ease-in-out;
    }

    .no-places-modal {
        background: #fff;
        border-radius: 10px;
        box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
        width: 90%;
        max-width: 400px;
        overflow: hidden;
        animation: slideIn 0.3s ease-in-out;
        text-align: center;
        padding: 20px;
    }

    .no-places-header {
        background: #f39c12;
        color: white;
        padding: 15px;
        font-size: 18px;
        font-weight: bold;
        border-radius: 10px 10px 0 0;
    }

    .no-places-body {
        padding: 20px;
        font-size: 14px;
        color: #333;
    }

    .no-places-footer {
        padding: 15px;
        background: #f4f4f4;
        text-align: center;
    }

    .no-places-btn {
        padding: 10px 20px;
        border: none;
        border-radius: 5px;
        cursor: pointer;
        font-size: 14px;
        font-weight: bold;
        background: #3498db;
        color: white;
        transition: background 0.3s, transform 0.2s;
    }

    .no-places-btn:hover {
        background: #2980b9;
        transform: scale(1.05);
    }

    /* Animaciones */
    @keyframes fadeIn {
        from {
            opacity: 0;
        }
        to {
            opacity: 1;
        }
    }
    #dictionary-drop-zone
    {
        border: 2px dashed #ccc;
        padding: 10px;
        margin: 10px;
        text-align: center;
        font-style: italic;
        color: #555;
        background-color: #f8f9fa;
    }
    @keyframes slideIn {
        from {
            transform: translateY(-20px);
        }
        to {
            transform: translateY(0);
        }
    }
    </style>
    `;
    // Insertar los estilos en el documento
    document.head.insertAdjacentHTML("beforeend", noPlacesStyles);
  
    // ********************************************************************************************************************************
    // Nombre: showModal
    // Fecha modificación: 2025-04-10
    // Autor: mincho77
    // Entradas: title (string): Título del modal.
    //          message (string): Mensaje a mostrar en el modal.
    //          confirmText (string): Texto del botón de confirmación.
    //          cancelText (string): Texto del botón de cancelación.
    //          onConfirm (function): Función a ejecutar
    //          al hacer clic en el botón de confirmación.
    //          onCancel (function): Función a ejecutar
    //          al hacer clic en el botón de cancelación.
    //          type (string): Tipo de modal (info, error, warning, question,
    //          success).
    //          autoClose (number): Tiempo en milisegundos para cerrar
    //          automáticamente el modal.
    //          prependText (string): Texto a mostrar antes del mensaje.
    // Salidas: Ninguna. Crea un modal personalizado con título, mensaje y botones de confirmación y cancelación. Permite al usuario
    //  interactuar
    // con el modal y ejecutar funciones específicas al hacer clic en los botones. Se utiliza para mostrar mensajes de advertencia,
    //  información o error al usuario. El modal se cierra automáticamente después de un tiempo especificado si se indica.
    // Descripción:
    // Esta función crea un modal personalizado que se muestra en la pantalla con un título, un mensaje y botones de confirmación y 
    // cancelación. El modal se puede personalizar con diferentes tipos (info, error, warning, question, success) y se puede cerrar 
    // automáticamente después de un tiempo especificado. Permite al usuario interactuar con el modal y ejecutar funciones específicas 
    // al hacer clic en los botones. Se utiliza para mostrar mensajes de advertencia, información o error al usuario.
    // El modal se cierra automáticamente después de un tiempo especificado si se indica. 
    // ********************************************************************************************************************************
    function showModal({
        title,
        message,
        confirmText,
        cancelText,
        onConfirm,
        onCancel,
        type = "info",
        autoClose = null,
        prependText = "",
    })
    {
        // Determinar el ícono según el tipo
        let icon;
        switch (type)
        {
            case "error":
                icon = "⛔";
                break;
            case "warning":
                icon = "⚠️";
                break;
            case "info":
                icon = "ℹ️";
                break;
            case "question":
                icon = "❓";
                break;
            case "success":
                icon = "✅";
                break;
            default:
                icon = "ℹ️";
                break;
        }

        const fullMessage = message.replace("{prependText}", prependText);

        // Crear el modal
        const modal = document.createElement("div");
        modal.className = "custom-modal-overlay";
        modal.innerHTML = `
            <div class="custom-modal">
                <div class="custom-modal-header">
                    <h3>${icon} ${title}</h3>
                    <button class="close-modal-btn" title="Cerrar">×</button>
                </div>
                <div class="custom-modal-body">
                    <p>${fullMessage}</p>
                </div>
                <div class="custom-modal-footer">
                    ${
          cancelText
            ? `<button id="modal-cancel-btn" class="modal-btn cancel-btn">${
                cancelText}</button>`
            : ""}
                    ${
          confirmText
            ? `<button id="modal-confirm-btn" class="modal-btn confirm-btn">${
                confirmText}</button>`
            : ""}
                </div>
            </div>
        `;

        // Agregar el modal al documento
        document.body.appendChild(modal);

        // Manejar eventos de los botones
        if (confirmText)
        {
            document.getElementById("modal-confirm-btn")
              .addEventListener("click", () => {
                  if (onConfirm)
                      onConfirm(); // Ejecutar la función de confirmación
                  modal.remove();  // Cerrar el modal
              });
        }

        if (cancelText)
        {
            document.getElementById("modal-cancel-btn")
              .addEventListener("click", () => {
                  if (onCancel)
                      onCancel(); // Ejecutar la función de cancelación
                  modal.remove(); // Cerrar el modal
              });
        }

        // Cerrar modal al hacer clic en el botón de cerrar
        modal.querySelector(".close-modal-btn")
          .addEventListener("click", () => { modal.remove(); });

        // Cerrar automáticamente si se especifica autoClose
        if (autoClose)
        {
            setTimeout(() => { modal.remove(); }, autoClose);
        }
    }
    // Estilos CSS para el modal
    const modalStyles = `
        <style>
        .custom-modal-overlay {
        position: fixed;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background: rgba(0, 0, 0, 0.6);
        display: flex;
        justify-content: center;
        align-items: center;
        z-index: 10000;
        animation: fadeIn 0.3s ease-in-out;
        }

        .custom-modal {
        background: #fff;
        border-radius: 10px;
        box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
        width: 90%;
        max-width: 400px;
        overflow: hidden;
        animation: slideIn 0.3s ease-in-out;
        }

        .custom-modal-header {
        background: #3498db;
        color: white;
        padding: 15px;
        display: flex;
        justify-content: space-between;
        align-items: center;
        }

        .custom-modal-header h3 {
        margin: 0;
        font-size: 18px;
        }

        .close-modal-btn {
        background: none;
        border: none;
        color: white;
        font-size: 20px;
        cursor: pointer;
        transition: color 0.3s;
        }

        .close-modal-btn:hover {
        color: #e74c3c;
        }

        .custom-modal-body {
        padding: 20px;
        font-size: 14px;
        color: #333;
        text-align: center;
        }

        .custom-modal-footer {
        display: flex;
        justify-content: space-between;
        padding: 15px;
        background: #f4f4f4;
        }

        .modal-btn {
        padding: 10px 20px;
        border: none;
        border-radius: 5px;
        cursor: pointer;
        font-size: 14px;
        font-weight: bold;
        transition: background 0.3s, transform 0.2s;
        }

        .confirm-btn {
        background: #27ae60;
        color: white;
        }

        .confirm-btn:hover {
        background: #2ecc71;
        transform: scale(1.05);
        }

        .cancel-btn {
        background: #e74c3c;
        color: white;
        }

        .cancel-btn:hover {
        background: #c0392b;
        transform: scale(1.05);
        }

        /* Animaciones */
        @keyframes fadeIn {
        from {
            opacity: 0;
        }
        to {
            opacity: 1;
        }
        }

        @keyframes slideIn {
        from {
            transform: translateY(-20px);
        }
        to {
            transform: translateY(0);
        }
        }
        </style>
        `;
    
    // Insertar los estilos en el documento
    document.head.insertAdjacentHTML("beforeend", modalStyles);
    
    // ********************************************************************************************************************************
    // Nombre: openEditPopup
    // Fecha modificación: 2025-04-22 06:00
    // Autor: mincho77
    // Entradas: index (number): Índice de la palabra a editar.
    //          listType (string): Tipo de lista (excludeWords o
    //          dictionaryWords).
    // Salidas: Ninguna. Abre un popup para editar una palabra en la lista
    // Descripción:
    // Esta función abre un popup para editar una palabra en la lista especificada (excludeWords o dictionaryWords). Permite al usuario
    // modificar la palabra y actualizar la lista correspondiente. Si la palabra ya existe en la lista, se muestra un mensaje de
    // advertencia. La función también valida que la palabra no esté vacía y que no sea duplicada. Se utiliza para permitir al usuario 
    // gestionar su lista de palabras excluidas o su diccionario ortográfico personalizado.
    // ********************************************************************************************************************************
    function openEditPopup(index, listType = "excludeWords")
    {
        const wordList = listType === "dictionaryWords" ? dictionaryWords : excludeWords;
        const wordToEdit = wordList[index];
        if (!wordToEdit)
        {
            console.error(`No se encontró la palabra en el índice ${index}`);
            return;
        }

        showModal(
        {
            title : "Editar palabra",
            message : `<input type="text" id="editWordInput" value="${
              wordToEdit}" style="width: 95%; padding: 5px; border-radius: 4px; border: 1px solid #ccc;">`,
            confirmText : "Guardar",
            cancelText : "Cancelar",
            type : "question",
            onConfirm : () => {
                const newWord =
                  document.getElementById("editWordInput").value.trim();
                if (!newWord)
                {
                    showModal({
                        title : "Error",
                        message : "La palabra no puede estar vacía.",
                        confirmText : "Aceptar",
                        type : "error"
                    });
                    return;
                }

                if (wordList.includes(newWord) && wordList[index] !== newWord)
                {
                    showModal({
                        title : "Duplicada",
                        message : "Esa palabra ya está en la lista.",
                        confirmText : "Aceptar",
                        type : "warning"
                    });
                    return;
                }

                wordList[index] = newWord;
                if (listType === "dictionaryWords")
                {
                    wordLists.dictionaryWords = dictionaryWords;
                    localStorage.setItem("dictionaryWords",
                                         JSON.stringify(dictionaryWords));
                    renderDictionaryWordsPanel();
                }
                else
                {
                    wordLists.excludeWords = excludeWords;
                    localStorage.setItem("excludeWords",
                                         JSON.stringify(excludeWords));
                    renderExcludedWordsPanel();
                }

                showModal({
                    title : "Actualizada",
                    message : "La palabra fue modificada correctamente.",
                    confirmText : "Aceptar",
                    type : "success",
                    autoClose : 2000
                });
            }
        });
    }

    // ***********************************************************************************************************************************************************
    // Nombre: waitForElement
    // Fecha modificación: 2025-04-10
    // Autor: mincho77
    // Entradas:
    // - selector (string): El selector CSS del elemento que se desea esperar en el DOM.
    // - callback (function): Función que se ejecutará una vez que el elemento se encuentre en el DOM.
    // - interval (number, opcional): Tiempo en milisegundos entre cada intento de búsqueda (por defecto: 300ms).
    // - maxAttempts (number, opcional): Número máximo de intentos antes de abandonar la búsqueda (por defecto: 20). 
    // Salidas: Ninguna. Ejecuta el callback pasando el elemento encontrado o muestra una advertencia en la
    // consola si no se encuentra. 
    // Prerrequisitos: - El DOM debe estar cargado.
    // Descripción: Esta función espera a que un elemento definido por un selector CSS aparezca en el DOM. Utiliza un intervalo de 
    // tiempo (interval) para realizar múltiples comprobaciones, hasta un máximo definido(maxAttempts). Si el elemento se encuentra
    //  dentro de esos intentos, se ejecuta la función callback con el elemento como argumento. Si no se encuentra después de los 
    // intentos máximos, se detiene y se muestra una advertencia en la consola. Esto es útil para asegurarse de que elementos
    // dinámicos estén disponibles antes de asignarles event listeners o manipularlos.
    // ***********************************************************************************************************************************************************
    function waitForElement(selector, callback, interval = 300, maxAttempts = 20)
    {
        let attempts = 0;
        const checkExist = setInterval(() => {
            const element = document.querySelector(selector);
            attempts++;
            if (element)
            {
                clearInterval(checkExist);
                callback(element);
            }
            else if (attempts >= maxAttempts)
            {
                clearInterval(checkExist);
                console.warn(`No se encontró el elemento ${
                  selector} después de ${maxAttempts} intentos.`);
            }
        }, interval);
    }

     // ********************************************************************************************************************************
    // Nombre: configurarCambioIdiomaDiccionario
    // Fecha modificación: 2025-04-22
    // Hora: 07:45
    // Autor: mincho77
    // Entradas: Ninguna directa (se usa el selector del DOM)
    // Salidas: Cambia el idioma del diccionario y conserva el contenido anterior 
    // Descripción: Esta función configura el evento de  cambio de idioma del diccionario. Guarda el idioma anterior, carga el nuevo
    // desde localStorage y actualiza el panel.
     // ********************************************************************************************************************************
    function configurarCambioIdiomaDiccionario()
    {
        const selector = document.getElementById("dictionaryLanguageSelect");

        if (!selector)
        {
            setTimeout(configurarCambioIdiomaDiccionario, 200);
            return;
        }

        selector.addEventListener("change", () => {
            const previousLang = activeDictionaryLang;
            const newLang = selector.value;

            if (previousLang && spellDictionaries[previousLang])
            {
                localStorage.setItem(
                  `spellDictionaries_${previousLang}`,
                  JSON.stringify(spellDictionaries[previousLang]));
            }

            activeDictionaryLang = newLang;
            localStorage.setItem("activeDictionaryLang", activeDictionaryLang);

            const storedDictionary = JSON.parse(localStorage.getItem(
              `spellDictionaries_${activeDictionaryLang}`));

            if (storedDictionary)
            {
                spellDictionaries[activeDictionaryLang] = storedDictionary;
            }
            else if (!spellDictionaries[activeDictionaryLang] ||
                     Object.keys(spellDictionaries[activeDictionaryLang])
                         .length === 0)
            {
                if (activeDictionaryLang === "SP")
                {
                    spellDictionaries.SP = { a : [ "árbol" ], b : [ "barco" ] };
                }
                else if (activeDictionaryLang === "EN")
                {
                    spellDictionaries.EN = { a : [ "apple" ], b : [ "boat" ] };
                }

                localStorage.setItem(
                  `spellDictionaries_${activeDictionaryLang}`,
                  JSON.stringify(spellDictionaries[activeDictionaryLang]));
            }

            dictionaryWords =
              Object.values(spellDictionaries[activeDictionaryLang])
                .flat()
                .sort();

            renderDictionaryWordsPanel();
        });

        console.log("Idioma activo:", activeDictionaryLang);
        console.log("Datos del diccionario:",
                    spellDictionaries[activeDictionaryLang]);
    }

    // ***********************************************************************************************************************************************************
    // Nombre: waitForDictionaryLangSelectAndConfigure
    // Fecha modificación: 2025-04-22 06:17
    // Autor: mincho77
    // Entradas: Ninguna
    // Salidas: Ninguna. Espera a que el selector de idioma del diccionario esté disponible en el DOM y luego configura el evento de 
    // cambio de idioma. 
    // Descripción:
    // Esta función espera a que el selector de idioma del diccionario esté disponible en el DOM. Una vez que se encuentra, llama a 
    // la función configurarCambioIdiomaDiccionario para configurar el evento de cambio de idioma. Se utiliza para asegurarse de que 
    // el selector esté listo antes de intentar agregarle un event listener. Esto es útil en situaciones donde el DOM se carga de 
    // manera asíncrona o el elementopuede no estar presente en el momento de la ejecución del script.
    // ***********************************************************************************************************************************************************
    function waitForDictionaryLangSelectAndConfigure()
    {
        const selector = document.getElementById("dictionaryLanguageSelect");
        if (selector)
        {
            // Asignar el idioma activo al selector
            selector.value = activeDictionaryLang;

            // Configurar el evento de cambio de idioma
            dictionaryWords =
              Object.values(spellDictionaries[activeDictionaryLang])
                .flat()
                .sort();

            // Renderizar el panel del diccionario ortográfico
            renderDictionaryWordsPanel();

            // Configurar el evento de cambio de idioma
            configurarCambioIdiomaDiccionario();
        }
        else
        {
            setTimeout(waitForDictionaryLangSelectAndConfigure, 200);
        }
    }

    // ***********************************************************************************************************************************************************
    // Nombre: renderSpellDictionaryPanel
    // Fecha modificación: 2025-04-15 12:17
    // Autor: mincho77
    // Entradas: Ninguna
    // Salidas: string: HTML para el panel del diccionario ortográfico.
    // Descripción: Esta función genera el HTML para el panel del diccionario ortográfico. Incluye un selector para elegir el idioma 
    // del diccionario, un campo de texto para agregar nuevas palabras, un botón para agregar palabras, un campo de búsqueda para 
    // filtrar palabras en la lista, y botones para importar y exportar el diccionario. El panel se puede mostrar u ocultar al hacer 
    // clic en el encabezado. Se utiliza para permitir al usuario gestionar un diccionario ortográfico personalizado
    // para el normalizador de nombres de lugares. Se incluye un icono representativo para cada idioma (España e Inglaterra) junto a 
    // la opción correspondiente en el selector. El campo de búsqueda permite filtrar las palabras en la lista del diccionario, 
    // facilitando la búsqueda de palabras específicas. Los botones de importar y exportar permiten al usuario gestionar su diccionario 
    // ortográfico, facilitando la importación de palabras desde un archivo XML y la exportación de palabras a un archivo XML. 
    // Se utiliza para mejorar la experiencia del usuario al permitirle personalizar su diccionario ortográfico según sus necesidades.
    // ***********************************************************************************************************************************************************
    function renderSpellDictionaryPanel()
    {
        return `
            <details id="details-dictionary-words" style="margin-top: 15px;">
                <summary style="cursor: pointer; font-weight: bold; list-style: none;">
                    <span id="arrow-dic" style="display: inline-block; transition: transform 0.2s;">▶</span> Diccionario Ortográfico
                </summary>

                <!-- Selector de idioma -->
                <div style="margin-top: 10px;">
                    <label for="dictionaryLanguageSelect"><b>Idioma activo:</b></label>
                    <select id="dictionaryLanguageSelect" style="width: 100%; margin-top: 5px; padding: 4px;">
                        <option value="SP">Español 🇪🇸</option>
                        <option value="EN">Inglés 🇬🇧</option>
                    </select>
                </div>

                <!-- Buscar palabra -->
                <div style="margin-top: 10px;">
                    <input type="text" id="searchDictionaryWord" placeholder="Buscar palabra..." style="width: 100%; padding: 5px; border: 1px solid #ccc; border-radius: 4px;">
                </div>


                <div id="dictionary-words-list" style="margin-top: 10px; max-height: 200px; overflow-y: auto;">

                </div>

                <!-- Botones de archivo -->
                <div style="margin-top: 10px;">
                    <button id="exportDictionaryBtn">📤 Exportar Diccionario</button>
                    <button id="importDictionaryBtn">📥 Importar Diccionario</button>
                    <button id="clear-dictionary-btn" style="margin-left: 10px;">🧹 Limpiar Diccionario</button>
                    <input type="file" id="hiddenImportDictionaryInput" accept=".xml" style="display: none;">
                </div>

                <!-- Drag & Drop -->
                <div id="dictionary-drop-zone" style="border: 2px dashed #ccc; padding: 10px; margin: 10px;">
                    📂 Arrastra aquí tu archivo de palabras del diccionario (.xml o .txt)
                </div>
            </details>
        `;
    }

    // ***********************************************************************************************************************************************************
    // Nombre: initializeExcludeWords
    // Fecha modificación: 2025-03-30
    // Autor: mincho77
    // Entradas: Ninguna
    // Salidas: Ninguna
    // Prerrequisitos si existen:
    // - localStorage debe estar disponible.
    // Descripción: Inicializa la lista de palabras excluidas a partir del localStorage, combinando con las palabras ya cargadas en la 
    // variable global excludeWords y actualizando el almacenamiento local.
    // ***********************************************************************************************************************************************************
    function initializeExcludeWords()
    {
        const saved = JSON.parse(localStorage.getItem("excludeWords")) || [];
        wordLists.excludeWords =
          [...new Set([...saved, ...wordLists.excludeWords ]) ].sort();
        excludeWords = wordLists.excludeWords; // Sincronizar
        localStorage.setItem("excludeWords",
                             JSON.stringify(wordLists.excludeWords));
    }

    // ***********************************************************************************************************************************************************
    // Nombre: initSearchSpecialWords
    // Fecha modificación: 2025-04-10
    // Autor: mincho77
    // Entradas: Ninguna
    // Salidas: Ninguna
    // Descripción: Esta función inicializa la búsqueda de palabras especiales en el panel lateral del normalizador. Agrega un evento de 
    // entrada al campo de búsqueda que filtra los elementos de la lista de palabras especiales según el texto ingresado. Si el campo de 
    // búsqueda no está disponible, espera 200 ms y vuelve a intentar. Esto es útil para permitir al usuario buscar y filtrar palabras 
    // especiales en la lista de manera eficiente.
    // ***********************************************************************************************************************************************************
    function initSearchSpecialWords()
    {
        const searchInput = document.getElementById("searchWord");
        const normalizerSidebar = document.getElementById("normalizer-sidebar");
        if (searchInput && normalizerSidebar)
        {
            searchInput.addEventListener("input", function() {
                const query = searchInput.value.toLowerCase().trim();
                const items = normalizerSidebar.querySelectorAll("li");
                items.forEach(item => {
                    const text =
                      item.querySelector("span")?.textContent.toLowerCase() ||
                      "";
                    item.style.display = text.includes(query) ? "flex" : "none";
                });
            });
        }
        else
        {
            setTimeout(initSearchSpecialWords, 200);
        }
    }

    // ***********************************************************************************************************************************************************
    // Nombre: getSidebarHTML
    // Fecha modificación: 2025-04-09
    // Autor: mincho77
    // Entradas: Ninguna
    // Salidas: string: HTML para el panel lateral del normalizador.
    // Descripción: Esta función genera el HTML para el panel lateral del normalizador de nombres de lugares. Incluye opciones para
    // normalizar artículos, un campo para ingresar el máximo de lugares a buscar, una sección para palabras especiales
    // con un botón para agregar palabras, un campo de búsqueda, y botones para importar y exportar la lista de palabras especiales.
    // También incluye un botón para limpiar la lista de palabras especiales. El panel se puede mostrar u ocultar al hacer clic en el
    // encabezado. Se utiliza para permitir al usuario gestionar su lista de palabras especiales y personalizar el comportamiento del
    // normalizador de nombres de lugares. El HTML incluye estilos en línea para mejorar la apariencia y la usabilidad del panel.
    // ***********************************************************************************************************************************************************
    function getSidebarHTML()
    {
        return `
        <div id="normalizer-tab">
            <h4>Places Name Normalizer <span style="font-size:11px;">${
          VERSION}</span></h4>
            <!-- No Normalizar artículos -->
            <div style="margin-top: 15px;">
                <input type="checkbox" id="normalizeArticles" ${
          normalizeArticles ? "checked" : ""}>
                <label for="normalizeArticles">No Normalizar artículos (el, la, los, ...)</label>
            </div>
            <div style="margin-top: 15px;">
                <input type="checkbox" id="useSpellingAPI">
                <label for="useSpellingAPI">Usar API de ortografía</label>
            </div>
            <!-- Máximo de Places a buscar -->
            <div style="margin-top: 15px;">
                <label>Máximo de Places a buscar: </label>
                <input type="number" id="maxPlacesInput" value="${
          maxPlaces}" min="1" max="800" style="width: 60px;">
            </div>
            <!-- Sección de Palabras Especiales -->
            <details id="details-special-words" style="margin-top: 15px;">
                <summary style="cursor: pointer; font-weight: bold; list-style: none;">
                    <span id="arrow" style="display: inline-block; transition: transform 0.2s;">▶</span> Palabras Especiales
                </summary>
                <div style="margin-top: 10px; display: flex; gap: 5px;">
                    <input type="text" id="excludeWord" placeholder="Agregar palabra..." style="flex: 1; padding: 5px; border: 1px solid #ccc; border-radius: 4px;">
                    <button id="addExcludeWord" style="background: #3498db; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">Agregar</button>
                </div>
                <div style="margin-top: 10px; display: flex; gap: 5px;">
                    <input type="text" id="searchWord" placeholder="Buscar palabra..." style="flex: 1; padding: 5px; border: 1px solid #ccc; border-radius: 4px;">
                </div>
                <div id="normalizer-sidebar" style="margin-top: 10px; max-height: 200px; overflow-y: auto;"></div>
                <button id="exportExcludeWords" style="margin-top: 10px;">Exportar Palabras</button>
                <button id="importExcludeWordsUnifiedBtn" style="margin-top: 5px;">Importar Lista</button>
                <input type="file" id="hiddenImportInput" accept=".xml,.txt" style="display: none;">
                <div style="margin-top: 5px;">
                    <input type="checkbox" id="replaceExcludeListCheckbox">
                    <label for="replaceExcludeListCheckbox">Reemplazar lista actual</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;">
                    📂 Arrastra aquí tu archivo .txt o .xml para importar palabras especiales
                </div>
            </details>
            <hr>
            <!-- Sección de Diccionario Ortográfico -->
            ${renderSpellDictionaryPanel()}
            <hr>
            <!-- Botón Scan -->
            <button id="scanPlaces">Scan...</button>
        </div>
        <hr>
        <!-- Botón de limpieza -->
        <button id="customButton" style="background:rgb(219, 96, 52); color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; font-weight: bold; margin-top: 10px;">
            Eliminar Palabras Especiales
        </button>
    `;
    }

    // ***********************************************************************************************************************************************************
    // Nombre: clearExcludeWordsList
    // Fecha modificación: 2025-04-10
    // Autor: mincho77
    // Entradas: Ninguna
    // Salidas: Ninguna
    // Prerrequisitos si existen:
    // - La variable global excludeWords debe estar definida.
    // - La función renderExcludedWordsPanel debe estar definida.
    // Descripción: Esta función limpia la lista de palabras excluidas almacenadas en localStorage y actualiza la variable global 
    // excludeWords.
    // ***********************************************************************************************************************************************************
    function clearExcludeWordsList()
    {
        excludeWords = []; // Limpia la lista de palabras excluidas
        wordLists.excludeWords = excludeWords; // Sincronizar
        localStorage.removeItem(
          "excludeWords"); // Elimina las palabras del almacenamiento local

        // Limpia manualmente el contenedor antes de renderizar
        const container = document.getElementById("normalizer-sidebar");
        if (container)
        {
            container.innerHTML = ""; // Limpia el contenido del contenedor
        }

        renderExcludedWordsPanel(); // Refresca la lista en la interfaz

        showModal({
            title : "Lista Limpiada",
            message : "La lista de palabras excluidas ha sido limpiada.",
            type : "success",
            autoClose : 1500
        });
    }
    // ***********************************************************************************************************************************************************
    // Nombre: clearActiveDictionary
    // Fecha modificación: 2025-04-22
    // Autor: mincho77
    // Entradas: Ninguna
    // Salidas: Ninguna
    // Prerrequisitos si existen:
    // - La variable global activeDictionaryLang debe estar definida.
    // - La variable global spellDictionaries debe estar definida.
    // - La función renderDictionaryWordsPanel debe estar definida.
    // Descripción: Esta función limpia el diccionario ortográfico activo, eliminando todas las palabras y actualizando el almacenamiento
    // local. También muestra un modal de confirmación al usuario.
    // ***********************************************************************************************************************************************************
    function clearActiveDictionary()
    {
        if (!spellDictionaries[activeDictionaryLang])
        {
            console.warn("⚠️ No se encontró el diccionario del idioma activo.");
            return;
        }

        // Limpiar las letras
        spellDictionaries[activeDictionaryLang] = {};

        // Actualizar localStorage
        localStorage.setItem(
          `spellDictionaries_${activeDictionaryLang}`,
          JSON.stringify(spellDictionaries[activeDictionaryLang]));

        // Limpiar visualmente
        dictionaryWords = [];
        renderDictionaryWordsPanel();

        showModal({
            title : "Diccionario borrado",
            message : `Se eliminó todo el contenido del diccionario en idioma ${
              activeDictionaryLang}.`,
            confirmText : "Aceptar",
            type : "info",
        });
    }
    // ***********************************************************************************************************************************************************
    // 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ónrenderExcludedWordsPanel. 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 para el botón personalizado
        const customButton = document.getElementById("customButton");
        if (customButton)
        {
            customButton.addEventListener("click", () => {
                showModal({
                    title : "Confirmación",
                    message :
                      "¿Estás seguro de que deseas limpiar la lista de palabras excluidas?",
                    confirmText : "Sí, limpiar",
                    cancelText : "Cancelar",
                    type : "question",
                    onConfirm: () => { clearExcludeWordsList(); }, // Llama a la función para limpiar la lista},
                    onCancel : () => { console.log("El usuario canceló la limpieza de la lista.");
                    }
                });
            });
        }

        // Evento: exportar palabras excluidas a XML
        document.getElementById("exportExcludeWords")
          .addEventListener("click", () => {
              const savedWords =
                JSON.parse(localStorage.getItem("excludeWords")) || [];
              if (savedWords.length === 0)
              {
                  showModal({
                      title : "Error",
                      message : "No hay palabras excluidas para exportar.",
                      confirmText : "Aceptar",
                      onConfirm :
                        () => { console.log("El usuario cerró el modal."); }
                  });
                  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);
              link.click();
              document.body.removeChild(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)
            {
                wordLists.excludeWords.push(word);
                localStorage.setItem("excludeWords",
                                     JSON.stringify(wordLists.excludeWords));
                renderExcludedWordsPanel(); // Refresca la lista después de agregar la palabra
            }
            wordInput.value = ""; // Limpia el campo de entrada
        });

        // Evento: nuevo botón unificado de importación
        importButtonUnified.addEventListener("click",
                                             () => { hiddenInput.click(); });
        hiddenInput.addEventListener("change", () => { handleImportList(); });

        // limpiardiccionario
        waitForElement("#clear-dictionary-btn", (btn) => {
            btn.addEventListener("click", () => {
                const confirmClear = confirm(
                  "¿Seguro que deseas borrar TODO el diccionario activo?");
                if (confirmClear)
                    clearActiveDictionary();
            });
        });
        // Evento: escanear lugares
        scanPlacesButton.addEventListener("click", scanPlaces);
    }
  
    // ***********************************************************************************************************************************************************
    // Nombre: attachDictionarySearch
    // Fecha modificación: 2025-04-10
    // Autor: mincho77
    // Entradas: Ninguna
    // Salidas: Ninguna
    // Prerrequisitos si existen:
    // - Debe existir en el DOM el campo de búsqueda con id "searchDictionaryWord" y el contenedor de palabras del diccionario con id
    // "dictionary-words-list". Descripción: Esta función adjunta un evento de búsqueda al campo de búsqueda del diccionario ortográfico. 
    // Filtra la lista de palabras mostradas en el contenedor "dictionary-words-list" según la entrada del usuario. Se utiliza para 
    // mejorar la experiencia del usuario al permitirle buscar rápidamente palabras específicas en el diccionario ortográfico.
    // ***********************************************************************************************************************************************************
    function attachDictionarySearch()
    {
        const dictionarySearchInput =
          document.getElementById("searchDictionaryWord");
        const dictionaryWordsContainer =
          document.getElementById("dictionary-words-list");

        if (!dictionarySearchInput || !dictionaryWordsContainer)
        {
            console.error(
              "[PlacesNameNormalizer] No se encontró el campo 'searchDictionaryWord' o 'dictionary-words-list'.");
            return;
        }

        // Solo modifica .style.display para ocultar/mostrar
        dictionarySearchInput.addEventListener("input", () => {
            const query = dictionarySearchInput.value.toLowerCase().trim();
            const items = dictionaryWordsContainer.querySelectorAll("li");
            items.forEach(item => {
                const text =
                  item.querySelector("span")?.textContent.toLowerCase() || "";
                item.style.display = text.includes(query) ? "flex" : "none";
            });
        });
    }
    
    // ***********************************************************************************************************************************************************
    // Nombre: createSidebarTab
    // Fecha modificación: 2025-04-22
    // Hora: 06:50
    // Autor: mincho77
    // Entradas: Ninguna
    // Salidas: Ninguna
    // Prerrequisitos si existen:
    // - Debe existir la función W.userscripts.registerSidebarTab.
    // Descripción: Esta función crea una pestaña en la barra lateral de WME para el normalizador de nombres de lugares.
    // Primero, verifica si la pestaña ya existe y la elimina si es necesario. Luego, registra una nueva pestaña utilizando la función
    // W.userscripts.registerSidebarTab. Si la pestaña se registra correctamente, se configura su contenido y se añaden los eventos necesarios.
    // Se utiliza para proporcionar una interfaz de usuario para el normalizador de nombres de lugares dentro de WME, permitiendo al usuario
    // acceder a las funciones del script de manera fácil y rápida. La pestaña incluye opciones para normalizar artículos, un campo
    // para ingresar el máximo de lugares a buscar, una sección para palabras especiales con un botón para agregar palabras, un campo de búsqueda,
    // y botones para importar y exportar la lista de palabras especiales. También incluye un botón para limpiar la lista de palabras especiales.
    // ***********************************************************************************************************************************************************
    function createSidebarTab()
    {
        try
        {
            if (!W || !W.userscripts)
            {
                console.error(
                  `[${SCRIPT_NAME}] WME not ready for sidebar creation`);
                return;
            }

            const existingTab = document.getElementById("normalizer-tab");
            if (existingTab)
            {
                console.log(`[${SCRIPT_NAME}] Removing existing tab...`);
                existingTab.remove();
            }

            let registration;
            try
            {
                registration =
                  W.userscripts.registerSidebarTab("PlacesNormalizer");
            }
            catch (e)
            {
                if (e.message.includes("already been registered"))
                {
                    console.warn(`[${
                      SCRIPT_NAME}] Tab registration conflict, skipping...`);
                    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,/9j/4QDKRXhpZgAATU0AKgAAAAgABgESAAMAAAABAAEAAAEaAAUAAAABAAAAVgEbAAUAAAABAAAAXgEoAAMAAAABAAIAAAITAAMAAAABAAEAAIdpAAQAAAABAAAAZgAAAAAAAABIAAAAAQAAAEgAAAABAAeQAAAHAAAABDAyMjGRAQAHAAAABAECAwCgAAAHAAAABDAxMDCgAQADAAAAAQABAACgAgAEAAAAAQAAAoCgAwAEAAAAAQAAAqmkBgADAAAAAQAAAAAAAAAAAAD/2wCEAAEBAQEBAQIBAQIDAgICAwQDAwMDBAUEBAQEBAUGBQUFBQUFBgYGBgYGBgYHBwcHBwcICAgICAkJCQkJCQkJCQkBAQEBAgICBAICBAkGBQYJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCf/dAAQABv/AABEIAGUAXwMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AP7+KKKKACiivxO/4Kff8FePDv7Gt23wK+B9jbeKvird26zNBcMf7O0SGUfu7nUTGVd3Ycw2kbK8g5ZokIevQyvKq+MrLD4eN2/6+47MBl9XE1VRoq7P2rlnht4zNOwRFGSxOAB7noK5i18e+CL67+wWWsWMs/Ty0uImb/vkNmv86P42fHz9o/8Aaf1eTW/2lPiBrXitpWZvsAuXsdIiD/8ALOLTbQx2/lr0XzVlkx952PNfNsHwh+FdlOlzY+HNOtpoyGSWG3jjkUjoVdAGBHYg1+uYXwbk4fvq9n5RuvzX5H6Rh/C+q4/vKqT8l/wx/qNZFLX+ej+zZ+3h+2f+x/qMNx8G/Heoato0LBpPDnii4m1bTJlG3KI9w7XVqSowrQTKik7jG/Q/2Mf8E7v+Cjvwo/4KAfD25vdDtz4c8a6AI08QeGriUST2bSZCTwSAL9pspip8mdVXoUkWORWRfiuJ+A8Xlkfav3qfddPVdPyPlc/4QxOAXPLWHdf5dD9FqKK5bxp4v0bwH4Zu/FWvPstrOPccfeY9FRR3ZjgKPWvgMXi6WHpSr1pKMYq7b2SX+R81QoTqTVOmrt6JHU0V8kfAr9pTUfix4uufC+q6SlliB7iF4ZDIAqMqlZMgc/MMEcdsdK+t68DhHjHL88wax+WT5qd2tmtV5NJno51keJy6v9WxUbSt5fof/9D+/iiiigD5K/bq/acsP2Ov2TfG/wC0Tcwpd3Ph7TmbT7R2CC61Gdlt7G2z2865kjT8a/z2ftfifWtUv/GPj7UJNZ8Sa9cyajrGpTf6y7vZzullb0GeEQfKiBUXCqoH9d3/AAcVXl2n7C+haPGStrqHjnQo7nHdYGluYwfbzYkr+RYnnmv6H8I8vpwwM8T9qTt8klY/bPDPBQWGnX6t2+Ssdp8K/hT8Yf2gviVZ/Bn9n7w7N4p8U3qGYW0brDBbW6kK1zeXD/Jb26EjLHLH7qK74U/qF4j/AOCDH/BRrw74N/4SvTr7wTr+oLGHfQrS8vIJxgfMkN5cQLDK/ZQ6QqT1Ze36Wf8ABuH8PPBVn+zz8Rvi9DGj+J9c8YT6bfSHaZYbPTLeFbK3B6rFiV7hV6bpmPev6NK8Xi3xIxmGx0sNhUlGGmq3/wCB6Hj8R8dYuji5UcPZKOm39fgf5lFzba3o+t6l4R8XabdaHruiXL2Wp6Xfx+VdWdzHjdFKnY4IKkZV1KshKMpPoXwU/aD8ZfsgfGzw7+1R8PyxvPCU2/ULZP8Al/0aQqNRsmAxu8yFd8Q6CeOJv4cV+t3/AAcK+BPCXhT9tz4eeOvDsaQar4w8JajHrAT/AJbDR7u1Sxmcf3lW7mj3dWVVByEXH4i3j20dpLJesqQqjGRmIChAPmJJ4AA6+1fqmT42GZ5dCtUhpNar8H+Wnkfo2V4uGY4CNSpHSS2/A/0wfCnifRfGnhfTvGPhyYXOnaraw3lrKvSSGdBJGw9ipFfmh+1J8XX8feKx4K8PuZNM0qXZ+75+0XX3SQB1CfcT1OfavHv2Ovil43+FH/BKf4HeCfFENxp3i+/8GabbtDcqUuLa2SEIsrq3zK5i2BAQCCeR8pFfRv7I/wAGf7Y1Bfih4gi/0SyYpp8bdHlXgy/7sfRf9rn+EV/k/wCO+bYnOc5jwDkstW/30ltGC6fdaT7+7Hq0fHcG5PQymjVzzG6qF4013e11+S7avoj6Z/Zy+DqfC3wiLrVox/bOpBZLo9fLA+5CD6J/Fjq2e2K+iqTGOBS1/QXDHDmFyjAUsuwUbU6asv8AN+b3fmfk+bZpWxuIniq7vKX9W9Fsj//R/v4ooooA/K7/AILRfATWv2gf+CdfjzRfCls95rXhpLXxTp1vEPnmm0OdL0wr/tSxRvGOP4q/hs07UbPV9Pg1XTpFmt7mNZYnXlWRxlSMdiOlf6b80MVxC0E6h0cbWVhkEHggj0r/AD7f+Cgv7Hd3+wd+1dqvwhsIDF4K8Sm413wbLgBFsZJAbnThjgNp00gjVQABbvBjJ3Y/cPCPPIpTy+e/xR+7X8l+J+s+GmbxjzYKXXVfr+SOi/4J7/8ABQLx/wD8E7vinq/iHTtHl8V+BvFvlNr+hWzxxXaXMC+XFqFg0pSIziMCKWKRlWWNUw6GMbv6DPEP/BxT+wTZ+F5NR8JWHjLW9a8smLSE8P3VnK0gHCNc3Yis09NxmK+meK/kNZgME9K+ifgT+yJ+07+0zqMVh8EvBOp6tbybc6jNC1lpcasMiR764VIWTjnyfNf0Q19txFwVlWKqfXMX7vd3SXzv+lj6zO+EMvxFT6zXfL31SXz/AKRX/aH+Pnxo/bq/aTu/jT47sS2ua59m0fQ9A09muVsrQORbWNvkIZZZJZC0km1TJI38KKoT9f8A4F/8EdvDfw28a+H/ABj+2J460eWPS9uq6l4KsbeWWWby0EkNlNfecI5A0oHnRrABKmYwShZm/Qf9gn/gmd8PP2MJ4vif49vLfxj8SyjiK7jjI07SFkG1ksUf5pJinyPcvhiMhFiRih774r/BzxpoOq3nivSXn12xupHnm3fPdws5ydw/5aoOxHIHGMDNfyN9IH6SOOyPBrB8F0ozUdJSteytb3Vvp3WqOzJq2GxFT6lRqeypJWTSV5el17qt10b6W63/AAtoXib9pT4uST3uYYZSJLlk+7a2ifKkadgcfIn+1luxr9ddH0jTtA0u30XSIVgtbWNYoo0GAqKMACvgb9hPXri9i8QaTbQxNaRGCUzhcSea25fLY9wFXIH8PPrX6F1+DfRs4foRyZ55NudfEuTnJrXSTVl5XTfm35JL4DxVzKo8csviuWnSSUUttl+mnoFFFFf0Yflp/9L+/iiiigAr4P8A+CiX7Dfg79vb9na9+FGsTJpfiCwkGpeG9ZKb207VIVIikIGGaCRSYbiMEb4XYZBwR94UV04PF1MPVjWou0o7G2GxE6M1UpuzWx/mfXOleM/hR8ULrwL8V9DWy8U+B9Zt49Z0S5bMTy2U0VwYGcKd1reRBdsgXD28oYLztr+8H4G/tO/DX9rD4NWvxm+EF+8mkKFgv9LbC3Gk3SKN1rcQx8KUBBVhlHQq6EoytXyF/wAFf/8Agl8v7X3hJPj58B7WG2+Lnhe18uFeI49e0+MlzplyxwokUlms5m/1UhKE+XI9fy1/sJ/Gn9qzwH+09oOkfsXWdxcfETWp30u68OXqSQ2s8NpIVvIddhYBre3sWLebMyia1f5Y8ySeRN+t8U5dheL8l/ieyq00/Radf7rtofskcyoZphFiW1GdPo9v+Be2j6H9yWn6hJqLebBHstxwGbqx9h0AFaAuYt7qD/qsbvb2/KvSNc+H2qz6RHc6MILa+Ma+bChJhD4+byiQCAD93IHHYV51b+FNUluYvCsMMiPKf30jKRtT+JyenPav4PzDh3HYSqqMoXvs1s+yR5WGzPD1oc8Xa3TsepfCDQLLTdBn1uG3SGbVZjPIyqFLgfIhbAGeBXrVVrO1gsbSOytl2xxKEUegUYFWa/ecmy2OEwtPDR+yvx6/ifm2PxTr1pVX1/pfgFFFFemcZ//T/v4oopOlAH5M/wDBZL/gpvaf8Esf2Urf43aZo9t4k8Ta5rVpomiaTdSywQzyyB57l5ZII5HjjgtIZpSwU5YKnVgK+Q/+CHX/AAXNuP8AgrD4j8d/Dvx/4X0vwj4j8KWljqllBpV9Lew3mn3Mk1vK4aeOFw8E0ShwE27ZY8HOQPxT/wCC4WvXf/BUb/guB8IP+CXnhWc3PhzwhPbafraLJIiibUlj1PXZMrxuttGt44UdRlZLopuQk1i/tR29p/wRy/4OU/CHx60GIaR8Nfiy1m9zHBHHFbJY655Oi6pFnhQlnfwWF8wG0hWON3SgD+rb/grf+3b4u/4Jx/sX6r+1F4I8PWfii/0/VdJ05NPv55LaBxqV5HaFjLCkjrs8zdwh6YxX8vPwi/4Lm/t1XUWtft1/Bv8AYL0u7sPGEKjV/HGgx6pM2pQac5gPnXVrpUk8q27IUZmQqmz5uF4/Zj/g6IYH/gkb4jYc/wDFT+FP/Txb1/P7/wAEvv8Agu78V/2Iv+CYvhX9nr4ffsy+N/HcvhmHV3t/FUVtdDw5MbnULq7LvNa2dy/lweb5cgQH50YZXqKjNpNJjUmtj+on/gj/AP8ABZn4Pf8ABWXwPrn9g6DceDfGvhKO1l1jRJ51vIDbXm9YLyxvEVBcW0jxSJ80cUqMvzxqGQt82/8ABW3/AIOFvg1/wTn8eH9nX4T+HD8TPikiQte2CXX2bT9JNwEa3ivZoo553upkdWitLeF5MNGZDEJYt/5c/wDBo58KPh/s+MP7Wknjjw/qvjLxHDb20/hLR2K3ekWbXV1qBuLyI4VBdzylbZIPMijhiUedI7MqfEP/AAbR+ENH/bp/4K1fEr9sT47QjU9b0Gz1HxbZW96DJJFq2u6rNAk7q5I8ywtka3i4/dbtq42JiRH163/BzH/wVV+Bk1p8QP2v/wBk/wDsLwLfXEaQ3bW2u6K0iSNgLFdahaPb+a3SKO48jzGwBgHNf1nfsJ/tyfAj/god+znpP7Sn7Pt5LLpN+8lrd2d0qpeadf2523Fldxozqs0TY5RmjdSskbNGysfefjH8H/h58ffhV4g+CnxY0yHWPDfiiwn03UbO4UPHLBOhRhhgQCM5U9VYAjkV8r/sC/8ABN79l7/gm18PtU+Hf7MllqUFvr1zFeapc6rqV1qNxeXUMK26zOZ3McbeWiqRCka4AG3AGAD70ooooA//1P7+K8j+Pnxn8F/s6fBLxZ8efiLcraaF4O0m71i+lY4xBZwtKwHudu0DuSAK9crG8QeHfD/izR5/Dvimxt9S0+5AWa2uokmhkAIIDxuCrDIBwR2oA/zJ/wDgl3/wSl+Lv/Bd/wCI/wAYv2wPGvxOvfh0P7eaebVtJiW+lu9X1gvqF3ZxzC5jKxWNtJbQ5U8rsTaqpivYP+Cr3/Btd40/YP8A2SNS/at0/wCNGtfFC28P3VpZ6rZarZeW1ppmpzLaTXUM5uJ/LETyRtLldmwFmxsBH+jR4S8D+C/AOnPo/gXSLLRbSSQytBYW8dtG0hABcpEqqWIAGcZwB6Vp67oGheKNIuPD/iWyg1Cwu08ue2uY1lhkQ/wvG4KsPYjFAH8SP7a/7a1t+3D/AMGsPh742a/qMNxr+l614X8P+JJfNi2jVNG1m3tbiZmRigS4VFukJI/dSq2BXjv/AASH/wCDkL/gnv8A8E//APgm54H/AGUvi1H4j1Xxt4VGrNNb6Va2z2crXmpXV7Akd5NdRQDMcyBixVVbIPSv7lovgj8GIPDs3hCDwjoqaTczLcy2S6fbC3kmQALI0Qj2M6gABiMjAx0rGh/Zu/Z4t5Vnt/Afh2N0OVZdLswQR6ERcUAfxH/8GyvwY+M3x1/4KPfE3/gpFpnhR/CXwy1i38UJbj5vsj3HiLWIL+HTrGTYqXMVmkDGeSImJHKKh5KR+AfG3wT+0b/wbZ/8FXtV/a28M+F5PEPwZ8bXmpJBKhMFldaRq9wL2bTJ7zaYbTUbC6H+ieftjkjChM+bMYf9E6ysrPTrWOysIkghiAVI41CqoHQBRgAewqlrmgaH4m0ubQ/EdnBf2VwuyW3uY1lidfRkcFSPYigD+Mv9pj/g8H/Zo8Q/BDU/DX7GXhHxBN8StVs3tbJ9dWyhstNuph5ayEW13PLfyRkkxQ2qssrhVMkYYGv2Z/4IT/8ADyjWv2R5viH/AMFJfEF9qeq+ILxJ/DVhrFrbW2q2ejrCio+ofZ7a1YTXMu+RY5k82OHy/M2yM6J+lngf9kf9lb4ZeIT4t+HHw18LaBqpbd9s07R7K2nz6+ZFErfrX0KBigBaKKKAP//V/v4ooooAKKKKACiiigAooooAKKKKACiiigD/2Q=="
          style="height: 16px; vertical-align: middle; margin-right: 5px;">
          NrmliZer
          `;
            // Inyectar HTML del panel
            tabPane.innerHTML = getSidebarHTML();

            // Esperar que el DOM esté listo antes de adjuntar eventos
            waitForElement("#normalizeArticles", () => {
                console.log(`[${
                  SCRIPT_NAME}] ✅ Elementos del DOM listos, adjuntando eventos`);
                attachEvents();
            });

            // Activar búsqueda para palabras especiales
            initSearchSpecialWords();

            // Esperar que el selector de idioma esté en el DOM antes de configurar
            function waitForDictionaryLangSelectAndConfigure()
            {
                const selector =
                  document.getElementById("dictionaryLanguageSelect");
                if (selector)
                {
                    configurarCambioIdiomaDiccionario();
                }
                else
                {
                    setTimeout(waitForDictionaryLangSelectAndConfigure, 200);
                }
            }
            waitForDictionaryLangSelectAndConfigure();

            // Exponer depuración por consola
            unsafeWindow.debugDictionaries = function() {
                console.log("Idioma activo:", activeDictionaryLang);
                console.log("Diccionario actual:",
                            spellDictionaries[activeDictionaryLang]);
            };
        }
        catch (error)
        {
            console.error(`[${SCRIPT_NAME}] Error creating sidebar tab:`,
                          error);
        }
    }

    // ********************************************************************************************************************************
    // 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.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
                                  : match.context
                                      .text // Mantener la palabra original si
                                            // no hay sugerencias
                          }));
                        resolve(errores);
                    }
                    else
                    {
                        reject("❌ Error en respuesta de LanguageTool");
                    }
                },
                onerror : function(
                  err) { reject("❌ Error de red al contactar LanguageTool"); }
            });
        });
    }
    window.checkSpellingWithAPI = checkSpellingWithAPI;
    // ********************************************************************************************************************************
    // Nombre: evaluarOrtografiaCompleta
    // Fecha modificación: 2025-04-10 22:15 GMT-5
    // Autor: mincho77
    // Entradas:
    // - texto (string): Texto a evaluar
    // - config (opcional): {
    //       usarAPI: true,       // Usar LanguageTool
    //       reglasLocales: true, // Aplicar reglas de tildes
    //       timeout: 5000        // Tiempo máximo para API
    //     }
    // Salidas:
    // Promise<{
    //     original: string,
    //     normalizado: string,
    //     errores: Array<{
    //       palabra: string,
    //       sugerencia: string,
    //       tipo: 'ortografia'|'tilde'|'gramatica',
    //       severidad: 'alta'|'media'|'baja'
    //     }>,
    //     metadata: {
    //       totalErrores: number,
    //       apiUsada: boolean,
    //       tiempoProcesamiento: number
    //     }
    // }>
    // Descripción:
    // Sistema completo que combina normalización y revisión ortográfica real
    // ********************************************************************************************************************************
    async function evaluarOrtografiaCompleta(texto, config = {})
    {
        const inicio = Date.now();
        const resultadoBase = {
            original : texto,
            normalizado : "",
            errores : [],
            metadata :
              { totalErrores : 0, apiUsada : false, tiempoProcesamiento : 0 }
        };
        // 1. Normalización básica inicial
        const normalizado = await normalizePlaceName(texto, true);
        resultadoBase.normalizado = normalizado;
        // 2. Detección de errores locales (síncrono)
        if (config.reglasLocales !== false)
        {
            const erroresLocales = detectarErroresLocales(texto, normalizado);
            resultadoBase.errores.push(...erroresLocales);
        }
        // 3. Revisión con API LanguageTool (asíncrono)
        if (config.usarAPI !== false && texto.length > 1)
        {
            try
            {
                const resultadoAPI =
                  await revisarConLanguageTool(texto, config.timeout);
                resultadoBase.errores.push(...resultadoAPI.errores);
                resultadoBase.metadata.apiUsada = true;
            }
            catch (error)
            {
                console.error("Error en API LanguageTool:", error);
            }
        }
        // 4. Filtrar y clasificar errores
        resultadoBase.errores = filtrarErrores(resultadoBase.errores);
        resultadoBase.metadata.totalErrores = resultadoBase.errores.length;
        resultadoBase.metadata.tiempoProcesamiento = Date.now() - inicio;
        return resultadoBase;
    }
    // ==================== FUNCIONES DE SOPORTE ====================
    // ********************************************************************************************************************************
    // Nombre: detectarErroresLocales
    // Descripción: Detecta errores de tildes y mayúsculas
    // ********************************************************************************************************************************
    function detectarErroresLocales(original, normalizado)
    {
        const errores = [];
        const palabrasOriginal = original.split(/\s+/);
        const palabrasNormalizado = normalizado.split(/\s+/);
        palabrasOriginal.forEach((palabra, i) => {
            const palabraNormalizada = palabrasNormalizado[i] || palabra;
            // 1. Comparación directa para detectar cambios
            if (palabra !== palabraNormalizada)
            {
                errores.push({
                    palabra,
                    sugerencia : palabraNormalizada,
                    tipo : "ortografia",
                    severidad : "media"
                });
            }
            // 2. Detección específica de tildes
            if (tieneTildesIncorrectas(palabra))
            {
                errores.push({
                    palabra,
                    sugerencia : corregirTildeLocal(palabra),
                    tipo : "tilde",
                    severidad : "alta"
                });
            }
        });
        return errores;
    }
    // ********************************************************************************************************************************
    // Nombre: revisarConLanguageTool
    // Fecha modificación: 2025-04-10 22:15 GMT-5
    // Autor: mincho77
    // Entradas:
    // - texto (string): Texto a evaluar
    // - timeout (opcional): Tiempo máximo para la API (en milisegundos)
    // Salidas:
    // Promise<{
    //     errores: Array<{
    //       palabra: string,
    //       sugerencia: string,
    //       tipo: 'ortografia'|'gramatica',
    //       severidad: 'alta'|'media'
    //     }>,
    //     apiStatus:
    //     'success'|'timeout'|'parse_error'|'api_error'|'network_error'
    // }>
    // Prerrequisitos: Requiere permisos GM_xmlhttpRequest y @connect a
    // api.languagetool.org Descripción: Consulta la API para errores
    // ortográficos y gramaticales
    // ********************************************************************************************************************************
    // Descripción: Consulta la API para errores avanzados
    // ********************************************************************************************************************************
    function revisarConLanguageTool(texto, timeout = 5000)
    {
        return new Promise((resolve) => {
            const timer = setTimeout(
              () => { resolve({ errores : [], apiStatus : "timeout" }); },
              timeout);
            GM_xmlhttpRequest({
                method : "POST",
                url : "https://api.languagetool.org/v2/check",
                headers :
                  { "Content-Type" : "application/x-www-form-urlencoded" },
                data : `language=es&text=${encodeURIComponent(texto)}`,
                onload : function(response) {
                    clearTimeout(timer);
                    if (response.status === 200)
                    {
                        try
                        {
                            const data = JSON.parse(response.responseText);
                            const errores = data.matches.map((match) => {
                                // Validar que match y sus propiedades existan
                                const palabra =
                                  match?.context?.text?.substring(
                                    match?.context?.offset || 0,
                                    (match?.context?.offset || 0) +
                                      (match?.context?.length || 0)) ||
                                  "(sin contexto)";

                                const sugerencia =
                                  match?.replacements?.[0]?.value ||
                                  match?.context?.text || "(sin sugerencia)";

                                const tipo =
                                  "ortografia"; // Valor predeterminado ya que
                                                // se eliminó la categoría

                                const severidad =
                                  match?.rule?.issueType === "misspelling"
                                    ? "alta"
                                    : "media";

                                return { palabra, sugerencia, tipo, severidad };
                            });
                            resolve({ errores, apiStatus : "success" });
                        }
                        catch (e)
                        {
                            resolve(
                              { errores : [], apiStatus : "parse_error" });
                        }
                    }
                    else
                    {
                        resolve({ errores : [], apiStatus : "api_error" });
                    }
                },
                onerror : function() {
                    clearTimeout(timer);
                    resolve({ errores : [], apiStatus : "network_error" });
                }
            });
        });
    }
    // ********************************************************************************************************************************
    // Nombre: filtrarErrores
    // Descripción: Elimina duplicados y errores menores
    // ********************************************************************************************************************************
    function filtrarErrores(errores)
    {
        const unicos = [];
        const vistas = new Set();
        errores.forEach((error) => {
            const clave = `${error.palabra}-${error.sugerencia}-${error.tipo}`;
            if (!vistas.has(clave))
            {
                vistas.add(clave);
                unicos.push(error);
            }
        });
        return unicos.sort((a, b) => {
            if (a.severidad === b.severidad)
                return 0;

            return a.severidad === "alta" ? -1 : 1;
        });
    }
    // ********************************************************************************************************************************
    // Nombre: tieneTildesIncorrectas
    // Fecha modificación: 2025-04-10 21:30 GMT-5
    // Autor: mincho77
    // Entradas:
    // - palabra (string): Palabra a evaluar
    // - config (opcional): {
    //       ignorarMayusculas: true,
    //       considerarAdverbios: true,
    //       considerarMonosílabos: false
    //     }
    // Salidas: boolean - true si la palabra requiere corrección de tilde
    // Descripción:
    // Evalúa si una palabra en español tiene tildes incorrectas según las
    // reglas RAE. Incluye casos especiales para adverbios, hiatos, diptongos y
    // monosílabos.
    // ********************************************************************************************************************************
    function tieneTildesIncorrectas(palabra, config = {})
    {
        if (typeof palabra !== "string" || palabra.length === 0)
            return false;

        const settings = {
            ignorarMayusculas : config.ignorarMayusculas !==
                                  false, // No marcar errores en MAYÚSCULAS
            considerarAdverbios :
              config.considerarAdverbios !==
                false, // Evaluar adverbios terminados en -mente
            considerarMonosílabos :
              config.considerarMonosílabos || false, // Seguir reglas pre-2010
        };
        // Normalizar palabra (quitar tildes existentes para evaluación)
        const palabraNormalizada = palabra.normalize("NFD")
                                     .replace(/[\u0300-\u036f]/g, "")
                                     .toLowerCase();
        const tieneTildeActual = /[áéíóú]/.test(palabra);
        // 1. Reglas para palabras específicas (excepciones)
        const reglasEspecificas = {
            // Adverbios terminados en -mente
            mente :
              settings.considerarAdverbios && /mente$/i.test(palabra)
                ? tieneTildesIncorrectas(palabra.replace(/mente$/i, ""), config)
                : false,
            // Monosílabos
            monosilabos : settings.considerarMonosílabos &&
                            [
                                "fe",
                                "fue",
                                "fui",
                                "vio",
                                "dio",
                                "lia",
                                "lie",
                                "lio",
                                "rion",
                                "ries",
                                "se",
                                "te",
                                "de",
                                "si",
                                "ti"
                            ].includes(palabraNormalizada),
            // Casos especiales
            solo : palabraNormalizada === "solo" && !tieneTildeActual,
            este : /^este(s)?$/i.test(palabraNormalizada) && !tieneTildeActual,
            aun : palabraNormalizada === "aun" && !tieneTildeActual,
            guion : palabraNormalizada === "guion" && !tieneTildeActual,
            hui : palabraNormalizada === "hui" && !tieneTildeActual
        };
        if (Object.values(reglasEspecificas).some((v) => v))
            return true;

        // 2. Reglas generales de acentuación
        const silabas = separarSilabas(palabraNormalizada);
        const numSilabas = silabas.length;
        const ultimaLetra = palabraNormalizada.slice(-1);
        // Palabras agudas (tildan en última sílaba)
        if (numSilabas === 1)
            return false;

        // Monosílabos ya evaluados
        const esAguda = numSilabas === 1 ||
                        (numSilabas > 1 && silabas[numSilabas - 1].acento);
        const debeTildarAguda =
          esAguda && /[nsaeiouáéíóú]$/i.test(palabraNormalizada);
        const palabraLower = palabra.toLowerCase();
        if (correccionesEspecificas[palabraLower])
        {
            return aplicarCapitalizacion(palabra,
                                         correccionesEspecificas[palabraLower]);
        }
        // Determinar sílaba a tildar
        if (numSilabas > 2 && esEsdrujula(palabra))
        {
            silabaTildada = numSilabas - 3;
        }
        else if (numSilabas > 1 && esGrave(palabra))
        {
            silabaTildada = numSilabas - 2;
        }
        else if (esAguda(palabra))
        {
            silabaTildada = numSilabas - 1;
        }
        if (silabaTildada >= 0)
        {
            return aplicarTildeSilaba(palabra, silabas, silabaTildada);
        }
        return palabra;
    }
    // ==================== FUNCIONES AUXILIARES ====================
    // ********************************************************************************************************************************
    // Nombre: separarSilabas
    // Fecha modificación: 2025-04-10 22:15 GMT-5
    // Autor: mincho77
    // Entradas: palabra (string) – Palabra a separar en sílabas.
    // Salidas: Array<{ texto: string, acento: boolean }> – Lista de sílabas
    // Descripción: Separa la palabra en sílabas y determina si cada sílaba
    // tiene acento. Implementación simplificada para propósitos de
    // normalización visual.
    // ********************************************************************************************************************************
    function separarSilabas(palabra)
    { // Implementación simplificada (usar librería completa en producción)
        const vocalesFuertes = /[aeoáéó]/;
        const vocalesDebiles = /[iuü]/;
        const silabas = [];
        let silabaActual = "";
        let tieneVocalFuerte = false;
        for (let i = 0; i < palabra.length; i++)
        {
            const c = palabra[i];
            silabaActual += c;
            if (vocalesFuertes.test(c))
            {
                tieneVocalFuerte = true;
            }
            // Lógica simplificada de separación
            if (i < palabra.length - 1 &&
                ((vocalesFuertes.test(c) &&
                  vocalesFuertes.test(palabra[i + 1])) ||
                 (vocalesDebiles.test(c) &&
                  vocalesFuertes.test(palabra[i + 1]) && !tieneVocalFuerte)))
            {
                silabas.push(
                  { texto : silabaActual, acento : tieneVocalFuerte });
                silabaActual = "";
                tieneVocalFuerte = false;
            }
        }
        if (silabaActual)
        {
            silabas.push({ texto : silabaActual, acento : tieneVocalFuerte });
        }
        return silabas;
    }
    // ********************************************************************************************************************************
    // Nombre: aplicarCapitalizacion
    // Fecha modificación: 2025-04-10 22:15 GMT-5
    // Autor: mincho77
    // Entradas: original (string) – Palabra original
    //           corregida (string) – Palabra corregida
    // Salidas: string – Palabra corregida con mayúsculas/minúsculas
    // Descripción: Aplica mayúsculas/minúsculas a la palabra corregida
    // según la original. Mantiene mayúsculas y minúsculas en la primera letra
    // y el resto de la palabra.
    // ********************************************************************************************************************************
    function aplicarCapitalizacion(original, corregida)
    {
        if (original === original.toUpperCase())
        {
            return corregida.toUpperCase();
        }
        else if (original[0] === original[0].toUpperCase())
        {
            return corregida[0].toUpperCase() + corregida.slice(1);
        }
        return corregida;
    }
    // ********************************************************************************************************************************
    // Nombre: aplicarTildeSilaba
    // Fecha modificación: 2025-04-10 22:15 GMT-5
    // Autor: mincho77
    // Entradas: palabra (string) – Palabra original
    //           silabas (Array<{ texto: string, acento: boolean }>) – Lista de
    //           sílabas
    //           indiceSilaba (number) – Índice de la sílaba a tildar
    // Salidas: string – Palabra con tilde aplicada
    // Descripción: Aplica tilde a la sílaba especificada
    // según las reglas de acentuación. La sílaba se identifica por su índice
    // en la lista de sílabas. La función asume que la palabra ya ha sido
    // separada en sílabas y que el índice es válido.
    // ********************************************************************************************************************************
    function aplicarTildeSilaba(palabra, silabas, indiceSilaba)
    {
        let resultado = "";
        let posActual = 0;
        silabas.forEach((silaba, i) => {
            if (i === indiceSilaba)
            {
                const conTilde = silaba.texto.replace(
                  /([aeiou])([^aeiou]*)$/, (match, vocal, resto) => {
                      return (
                        vocal.normalize("NFD").replace(/[\u0300-\u036f]/g, "") +
                        "́" + resto);
                  });
                resultado += conTilde;
            }
            else
            {
                resultado += silaba.texto;
            }
        });
        return resultado;
    }

     // ********************************************************************************************************************************
    // Nombre: applyNormalization
    // Fecha modificación: 2025-04-15
    // Hora: 13:30:00
    // Autor: mincho77
    // Entradas: Ninguna directamente (usa el arreglo `changes` ya cargado)
    // Salidas: Aplica acciones en WME y muestra resultados
    // Prerrequisitos: `changes` debe contener objetos válidos con `place`,
    // `newName`, y opcionalmente `delete`
     // ********************************************************************************************************************************
    function applyNormalization(changes)
    {
        if (!Array.isArray(changes) || changes.length === 0)
        {
            showModal({
                title : "Información",
                message : "No hay cambios seleccionados para aplicar",
                confirmText : "Aceptar",
                type : "info"
            });
            return;
        }

        let lastAttemptedPlace = null;
        let cambiosRechazados = 0;

        try
        {
            changes.forEach((change) => {
                lastAttemptedPlace = {
                    name : change.originalName ||
                             change.place.attributes?.name || "Sin nombre",
                    id : change.place.getID?.() || "ID no disponible"
                };

                if (change.delete)
                {
                    const DeleteObject = require("Waze/Action/DeleteObject");
                    const action = new DeleteObject(change.place);
                    W.model.actionManager.add(action);
                }
                else
                {
                    const UpdateObject = require("Waze/Action/UpdateObject");
                    const action =
                      new UpdateObject(change.place, { name : change.newName });
                    W.model.actionManager.add(action);
                }
            });

            observarErroresDeWME(changes.length, lastAttemptedPlace);

            W.controller?.setModified?.(true);
            showModal({
                title : "Éxito",
                message : `${
                  changes
                    .length} cambio(s) enviados. Clic en Guardar para aplicar en WME.`,
                type : "success",
                autoClose : 2000
            });
        }
        catch (error)
        {
            console.error("Error aplicando cambios:", error);
            showModal({
                title : "Error",
                message :
                  "Error al aplicar cambios. Ver consola para detalles.",
                confirmText : "Aceptar",
                type : "error"
            });
        }
    }
    // ********************************************************************************************************************************
    // 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(
          async (
            palabra,
            index) => { // Normalizar la palabra antes de cualquier verificación
              let normalizada = await normalizePlaceName(palabra, true);
              // 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
              }
              // Excluir palabras específicas como "e" o "E"
              if (normalizada.toLowerCase() === "e" ||
                  /^\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
        };
    }
    // ********************************************************************************************************************************
    // 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);
            }, []);
        };
    }
    // ********************************************************************************************************************************
    // 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: escapeHtml
    // Fecha modificación: 2025-06-20 18:30 GMT-5
    // Autor: mincho77
    // Entradas:
    // - unsafe (string|any): Valor a escapar
    // Salidas:
    // - string: Texto escapado seguro para usar en HTML
    // Prerrequisitos:
    // - Ninguno
    // Descripción:
    // Convierte caracteres especiales en entidades HTML para prevenir XSS.
    // Escapa los siguientes caracteres:
    // & → &amp;
    // < → &lt;
    // > → &gt;
    // " → &quot;
    // ' → &#039;
    // Si el input no es string, lo convierte a string.
    // Devuelve string vacío si el input es null/undefined.
    // ********************************************************************************************************************************
    function escapeHtml(unsafe)
    {
        if (unsafe === null || unsafe === undefined)
            return "";

        return String(unsafe)
          .replace(/&/g, "&amp;")
          .replace(/</g, "&lt;")
          .replace(/>/g, "&gt;")
          .replace(/"/g, "&quot;")
          .replace(/'/g, "&#039;");
    }

    let cambiosRechazados = 0;
    //**********************************************************************
    // Nombre: observarErroresDeWME
    // Fecha modificación: 2025-04-15
    // Hora: 13:01:25
    // Autor: mincho77
    // Entradas: Ninguna
    // Salidas: Ninguna
    // Descripción: Observa errores de WME y muestra un modal si se detecta
    // un mensaje de error relacionado con restricciones de edición.
    // Prerrequisitos: Ninguno
    //**********************************************************************
    function observarErroresDeWME(totalEsperado, lastAttemptedPlace)
    {
        const observer = new MutationObserver((mutationsList) => {
            for (const mutation of mutationsList)
            {
                for (const node of mutation.addedNodes)
                {
                    if (node.nodeType === 1 &&
                        node.innerText?.includes(
                          "That change isn't allowed at this time"))
                    {
                        observer.disconnect();

                        const ahora = new Date().toLocaleString("es-CO");
                        const historico = JSON.parse(
                          localStorage.getItem("rechazosWME") || "[]");

                        historico.push({
                            timestamp : ahora,
                            motivo : "Cambio no permitido por WME",
                            lugar : lastAttemptedPlace?.name || "Desconocido",
                            id : lastAttemptedPlace?.id || "N/A"
                        });

                        localStorage.setItem("rechazosWME",
                                             JSON.stringify(historico));

                        showModal({
                            title : "Resultado parcial",
                            message :
                              `⚠️ Algunos lugares no pudieron ser modificados por restricciones de WME.\n` +
                                `Verifica el historial o vuelve a intentarlo.`,
                            confirmText : "Aceptar",
                            type : "warning"
                        });

                        break;
                    }
                }
            }
        });

        observer.observe(document.body, { childList : true, subtree : true });
    }

    //**********************************************************************
    // Nombre: waitForDOM
    // Fecha modificación: 2025-04-15
    // Hora: 13:01:25
    // Autor: mincho77
    // Entradas: selector (string) - Selector CSS del elemento a esperar
    //          callback (function) - Función a ejecutar cuando se encuentra el
    //          elemento
    //          interval (number) - Intervalo de tiempo entre intentos  en ms
    //          maxAttempts (number) - Número máximo de intentos
    // Salidas: Ninguna
    // Descripción: Espera a que un elemento del DOM esté disponible y ejecuta
    // la función de callback. Si no se encuentra el elemento después de un
    // número máximo de intentos, se muestra un mensaje de advertencia en la
    // consola.
    //**********************************************************************
    function waitForDOM(selector, callback, interval = 300, maxAttempts = 20)
    {
        let attempts = 0;
        const checkExist = setInterval(() => {
            const element = document.querySelector(selector);
            attempts++;
            if (element)
            {
                clearInterval(checkExist);
                callback(element);
            }
            else if (attempts >= maxAttempts)
            {
                clearInterval(checkExist);
                console.warn(
                  `[PlacesNameNormalizer] No se encontró el elemento ${
                    selector} después de ${maxAttempts} intentos.`);
            }
        }, interval);
    }
    
     // ******************************************************************************************************************************************************************
    // Nombre: openFloatingPanel
    // Fecha modificación: 2025-04-15
    // Hora: 13:01:25
    // Autor: mincho77
    // Entradas: placesToNormalize (array) - Arreglo de lugares a normalizar
    // Salidas: Ninguna
    // Descripción: Abre un panel flotante con una tabla para normalizar nombres
    // de lugares. Permite aplicar cambios, excluir palabras y agregar
    // palabras especiales. Incluye un botón para cerrar el panel.
    // Prerrequisitos: Ninguno
     // ******************************************************************************************************************************************************************
    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%);
        width: 90%; max-width: 1200px; max-height: 80vh; background: white;
        padding: 20px; border-radius: 8px; box-shadow: 0 0 25px rgba(0,0,0,0.4);
        z-index: 10000; overflow-y: auto; font-family: Arial, sans-serif;
    `;

        let html = `
       <style>
            #normalizer-table { width: 100%; border-collapse: collapse; margin: 15px 0; }
            #normalizer-table th { background: #2c3e50; color: white; padding: 10px; text-align: left; }
            #normalizer-table td { padding: 8px 10px; border-bottom: 1px solid #eee; }
            .warning-row { background: #fff8e1; }
            .normalize-btn, .apply-btn, .add-exclude-btn, .add-special-btn {
                padding: 8px 16px; /* Aumentar el tamaño del botón */
                margin: 2px;
                border: none;
                border-radius: 4px;
                cursor: pointer;
                font-weight: bold;
                transition: all 0.3s;
            }
            .normalize-btn { background: #3498db; color: white; }
            .apply-btn { background: #2ecc71; color: white; }
            .add-exclude-btn { background: #e67e22; color: white; }
            .add-special-btn { background: #9b59b6; color: white; }
            .close-btn {
                position: absolute; top: 10px; right: 10px;
                background: #e74c3c; color: white; border: none;
                width: 30px; height: 30px; border-radius: 50%; font-weight: bold;
            }
            input[type="checkbox"] { transform: scale(1.3); margin: 0 5px; }
            input[type="text"] { width: 100%; padding: 5px; box-sizing: border-box; }
        </style>
        <button class="close-btn" id="close-panel-btn">×</button>
        <h2 style="color: #2c3e50; margin-top: 5px;">Normalizador de Nombres</h2>
        <div style="margin: 10px 0; color: #7f8c8d;">
            <span id="places-count">${
          placesToNormalize.length} lugares para revisar</span>
        </div>

        <table id="normalizer-table">
            <thead>
                <tr>
                    <th width="5%">Aplicar</th>
                    <th width="5%">Eliminar</th>
       
                    <th width="25%">Nombre Actual</th>
                    <th width="25%">Nombre Normalizado</th>
                    <th width="15%">Problema Detectado</th>
                    <th width="10%">Acciones</th>
                </tr>
            </thead>
        <tbody>`;

        placesToNormalize.forEach((place, index) => {
            const {
                originalName,
                newName,
                hasSpellingWarning,
                spellingWarnings,
                place : venue
            } = place;

            const placeId = venue.getID();

            html += `
        <tr>
            <td>
                <input type="checkbox" class="normalize-checkbox" data-index="${
              index}" data-type="full">
            </td>   
            <td>
                <input type="checkbox" class="delete-checkbox" data-index="${
              index}">
            </td>
            
            
            <td>${escapeHtml(originalName)}</td>
            <td>
                <input type="text" class="new-name-input" value="${
              escapeHtml(newName)}"
                    data-index="${index}" data-place-id="${
              placeId}" data-type="full"
                    data-original="${escapeHtml(originalName)}">
            </td>
            <td>${
              originalName !== newName ? "Normalización necesaria" : "-"}</td>
            <td>
                <button class="normalize-btn" data-index="${
              index}">NrmliZer</button>
                <button class="add-special-btn" data-word="${
              escapeHtml(originalName)}">AddWrdDic</button>
                <button class="add-exclude-btn" data-word="${
              escapeHtml(
                originalName)}" data-index="${index}">ExcludeWrd</button>
            </td>
        </tr>`;

            spellingWarnings.forEach((warning, warningIndex) => {
                html += `
        <tr class="warning-row">
            <td><input type="checkbox" class="normalize-checkbox" data-index="${index}" data-warning-index="${warningIndex}" data-type="warning"></td>
            <td></td>
            
            <td>${escapeHtml(warning.original)}</td>
            <td><input type="text" class="new-name-input" value="${escapeHtml(warning.sugerida || newName)}"
                data-index="${index}" data-place-id="${placeId}" data-warning-index="${warningIndex}" data-type="warning"></td>
            <td>${escapeHtml(warning.tipo || "Error ortográfico")}
                <div class="tool-source">${warning.origen || "Reglas locales"}</div>
            </td>
            <td>
                <button class="apply-btn" data-index="${index}" data-warning-index="${warningIndex}">Aplicar</button>
                <button class="add-exclude-btn" data-word="${escapeHtml(warning.original)}" data-index="${index}">Excluir</button>
            </td>
        </tr>`;
            });
        });
        html += `</tbody></table>
        <div style="margin-top: 20px; text-align: right;">
            <button id="apply-all-btn" style="background: #27ae60; color: white; padding: 10px 20px;
                border: none; border-radius: 4px; font-weight: bold;">Aplicar Cambios Seleccionados</button>
            <button id="cancel-btn" style="background: #e74c3c; color: white; padding: 10px 20px;
                border: none; border-radius: 4px; margin-left: 10px; font-weight: bold;">Cancelar</button>
        </div>`;

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

        document.getElementById("close-panel-btn")
          .addEventListener("click", () => panel.remove());
        document.getElementById("cancel-btn")
          .addEventListener("click", () => panel.remove());
        document.getElementById("apply-all-btn")
          .addEventListener("click", () => {
              const selectedPlaces =
                placesToNormalize.filter((place, index) => {
                    const checkbox = panel.querySelector(
                      `.normalize-checkbox[data-index="${index}"]`);
                    return checkbox && checkbox.checked;
                });

              if (selectedPlaces.length === 0)
              {
                  showModal({
                      title : "Advertencia",
                      message :
                        "No se seleccionaron lugares para aplicar cambios.",
                      confirmText : "Aceptar",
                      type : "warning"
                  });
                  return;
              }

              applyNormalization(selectedPlaces);
              panel.remove();
          });

        // Evento para marcar el checkbox de "Aplicar" al modificar un texto,
        // y lógica de exclusión para "Eliminar"
        panel.querySelectorAll(".new-name-input").forEach((input) => {
            input.addEventListener("input", function () {
                const row = this.closest("tr");
                const applyCheckbox = row?.querySelector(".normalize-checkbox");
                const deleteCheckbox = row?.querySelector(".delete-checkbox");
                const original = this.dataset.original || "";
                const current = this.value.trim();

                if (applyCheckbox && deleteCheckbox) {
                    if (current !== original) {
                        applyCheckbox.checked = true;
                        deleteCheckbox.checked = false;
                    } else {
                        applyCheckbox.checked = false;
                    }
                }
            });
        });

        // Evento para marcar "Aplicar" si se selecciona "Eliminar" (sólo una vez)
        panel.querySelectorAll(".delete-checkbox").forEach((checkbox) => {
            checkbox.addEventListener("change", function () {
                const row = this.closest("tr");
                const applyCheckbox = row?.querySelector(".normalize-checkbox");
                if (this.checked && applyCheckbox) {
                    applyCheckbox.checked = true;
                }
            });
        });

        // Evento para normalizar el nombre al hacer clic en "NrmliZer"
        panel.querySelectorAll(".normalize-btn").forEach((btn) => {
            btn.addEventListener("click", async function() {
                const row = this.closest("tr");
                const input =
                  row.querySelector(".new-name-input[data-type='full']");
                const applyCheckbox =
                  row.querySelector("input.normalize-checkbox");
                const deleteCheckbox =
                  row.querySelector("input.delete-checkbox");

                if (!input)
                    return;

                // Animación
                let dots = 0;
                const originalText = "NrmliZer";
                const interval = setInterval(() => {
                    dots = (dots + 1) % 4;
                    this.textContent = originalText + ".".repeat(dots);
                }, 500);

                try
                {
                    input.value = await normalizePlaceName(input.value, true);

                    if (applyCheckbox)
                        applyCheckbox.checked = true;
                    if (deleteCheckbox)
                        deleteCheckbox.checked = false;

                    clearInterval(interval);
                    this.textContent = "✓ Ready";
                    this.style.backgroundColor = "#95a5a6";
                    this.disabled = true;
                }
                catch (error)
                {
                    console.error("Error al normalizar:", error);
                    clearInterval(interval);
                    this.textContent = originalText;
                }
            });
        });

        panel.querySelectorAll(".apply-btn").forEach((btn) => {
            btn.addEventListener("click", function() {
                const index = this.dataset.index;
                const warningIndex = this.dataset.warningIndex;
                const checkbox =
                  panel.querySelector(`.normalize-checkbox[data-index="${
                    index}"][data-warning-index="${warningIndex}"]`);
                if (checkbox)
                {
                    checkbox.checked = true;
                    this.textContent = "✓ Aplicado";
                    this.style.backgroundColor = "#95a5a6";
                    this.disabled = true;
                }
            });
        });

        panel.querySelectorAll(".add-special-btn").forEach((btn) => {
            btn.addEventListener("click", function() {
                const name = this.dataset.word;
                openAddSpecialWordPopup(
                  name); // Llamar al modal para seleccionar palabras
            });
        });
      

        panel.querySelectorAll(".add-exclude-btn").forEach((btn) => {
            btn.addEventListener("click", function() {
                const word = this.dataset.word;
                if (word)
                {
                    openAddSpecialWordPopup(
                      word, "excludeWords"); // Llama al popup para agregar a palabras excluidas
                }
            });
        });

      
    }
    

    // ********************************************************************************************************************************
    // Nombre: checkOnlyTildes (4)
    // Fecha modificación: 2025-06-21
    // Autor: mincho77
    // Entradas:
    // - original (string): Palabra original a comparar.
    // - sugerida (string): Palabra sugerida a comparar.
    // Salidas:
    // - boolean:
    //     - true si las palabras son iguales excepto por tildes.
    //     - false si difieren en otros caracteres o si alguna es
    //     undefined/null.
    // Descripción:
    // Compara dos palabras ignorando tildes/diacríticos para determinar si la
    // única diferencia entre ellas es la acentuación. Utiliza normalización
    // Unicode para una comparación precisa. Optimizada para reducir operaciones
    // innecesarias.
    // ********************************************************************************************************************************
    function checkOnlyTildes(original, sugerida)
    {
        if (typeof original !== "string" || typeof sugerida !== "string")
        {
            return false;
        }
        if (original === sugerida)
        {
            return false;
        }
        const normalize = (str) =>
          str.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
        return normalize(original) === normalize(sugerida);
    }
    // ********************************************************************************************************************************
    // Nombre: deleteWord
    // Fecha modificación: 2025-04-14
    // Autor: mincho77
    // Entradas:
    // - index (number): Índice de la palabra a eliminar.
    // Salidas: Ninguna. Muestra un modal de confirmación.
    // Descripción:
    // Muestra un modal de confirmación para eliminar una palabra de la lista de
    // exclusiones. Si el usuario confirma, elimina la palabra de la lista y
    // actualiza el almacenamiento local.
    // ********************************************************************************************************************************
    function deleteWord(index)
    {
        const wordToDelete = excludeWords[index];
        if (!wordToDelete)
            return;

        showModal({
            title : "Eliminar palabra",
            message :
              `¿Estás seguro de que deseas eliminar la palabra <strong>${
                wordToDelete}</strong>?`,
            confirmText : "Eliminar",
            cancelText : "Cancelar",
            type : "warning",
            onConfirm : () => {
                // Eliminar la palabra de la lista
                excludeWords.splice(index, 1);

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

                // Actualizar la interfaz
                renderExcludedWordsPanel();

                showModal({
                    title : "Éxito",
                    message : "La palabra fue eliminada correctamente.",
                    confirmText : "Aceptar",
                    type : "success",
                    autoClose : 2000,
                });
            },
        });
    }

    // ********************************************************************************************************************************
    // Nombre: openDeletePopup
    // Fecha modificación: 2025-04-14
    // Autor: mincho77
    // Entradas:
    // - index (number): Índice de la palabra a eliminar.
    // Salidas: Ninguna. Muestra un modal de confirmación.
    // Descripción:
    // Muestra un modal de confirmación para eliminar una palabra de la lista de exclusiones. Si el usuario confirma, elimina la 
    // palabra de la lista y actualiza el almacenamiento local.
    // ********************************************************************************************************************************
    function openDeletePopup(index)
    {
        const wordToDelete = excludeWords[index];
        if (!wordToDelete)
        {
            console.error(`No se encontró la palabra en el índice ${index}`);
            return;
        }

        showModal({
            title : "Eliminar palabra",
            message :
              `¿Estás seguro de que deseas eliminar la palabra <strong>${
                wordToDelete}</strong>?`,
            confirmText : "Eliminar",
            cancelText : "Cancelar",
            type : "warning",
            onConfirm : () => {
                // Eliminar la palabra de la lista
                excludeWords.splice(index, 1);

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

                // Actualizar la interfaz
                renderExcludedWordsPanel();

                showModal({
                    title : "Éxito",
                    message : "La palabra fue eliminada correctamente.",
                    confirmText : "Aceptar",
                    type : "success",
                    autoClose : 2000,
                });
            },
        });
    }
    // ********************************************************************************************************************************
    // Nombre: evaluarOrtografiaNombre
    // Fecha modificación: 2025-04-10 20:45 GMT-5
    // Autor: mincho77
    // Entradas:
    // - name (string): Nombre a evaluar.
    // - opciones (object): Opciones de configuración.
    //     - timeout (number): Tiempo máximo de espera para la API (ms).
    //     - usarCache (boolean): Si se debe usar caché para resultados
    //     - modoEstricto (boolean): Si se debe aplicar modo estricto.
    // Salidas:
    // - Promise: Objeto que contiene el resultado de la evaluación.
    // Descripción: Evalúa la ortografía de un nombre utilizando reglas locales y la API de LanguageTool. Devuelve un objeto con
    // advertencias de ortografía y metadatos sobre la evaluación. Incluye un sistema de caché para evitar llamadas duplicadas durante 
    // la sesión.
    // Prerrequisitos: Funciones auxiliares como tieneTildesIncorrectas y corregirTildeLocal.
    // ********************************************************************************************************************************
    function evaluarOrtografiaNombre(name, opciones = {})
    {
        const config = {
            timeout : opciones.timeout || 5000,
            usarCache : opciones.usarCache !== false,
            modoEstricto : opciones.modoEstricto || false
        };
        // Cache simple (evita llamadas duplicadas durante la sesión)
        const cache = evaluarOrtografiaNombre.cache ||
                      (evaluarOrtografiaNombre.cache = new Map());
        const cacheKey = `${config.modoEstricto}-${name}`;
        if (config.usarCache && cache.has(cacheKey))
        {
            return Promise.resolve(cache.get(cacheKey));
        }
        return new Promise((resolve) => { // 1. Validación de entrada
            if (typeof name !== "string" || name.trim().length === 0)
            {
                const resultado = {
                    hasSpellingWarning : false,
                    spellingWarnings : [],
                    metadata : { apiStatus : "invalid_input" }
                };
                cache.set(cacheKey, resultado);
                return resolve(resultado);
            }
            const inicio = Date.now();
            let timeoutExcedido = false;
            // 2. Timeout de seguridad
            const timeoutId = setTimeout(() => {
                timeoutExcedido = true;
                const resultado = {
                    hasSpellingWarning : false,
                    spellingWarnings : [],
                    metadata : {
                        apiStatus : "timeout",
                        tiempoRespuesta : Date.now() - inicio
                    }
                };
                cache.set(cacheKey, resultado);
                resolve(resultado);
            }, config.timeout);
            // 3. Primero verificar reglas locales (sincrónicas)
            const problemasLocales = [];
            const palabras = name.split(/\s+/);
            palabras.forEach((palabra) => {
                if (tieneTildesIncorrectas(palabra))
                {
                    problemasLocales.push({
                        original : palabra,
                        sugerida : corregirTildeLocal(palabra),
                        tipo : "Tilde incorrecta",
                        origen : "Reglas locales"
                    });
                }
            });
            // 4. Si hay problemas locales y no es modo estricto, devolver
            // inmediato
            if (problemasLocales.length > 0 && !config.modoEstricto)
            {
                clearTimeout(timeoutId);
                const resultado = {
                    hasSpellingWarning : true,
                    spellingWarnings : problemasLocales,
                    metadata : { apiStatus : "local_rules_applied" }
                };
                cache.set(cacheKey, resultado);
                return resolve(resultado);
            }
            // 5. Consultar API LanguageTool
            GM_xmlhttpRequest({
                method : "POST",
                url : "https://api.languagetool.org/v2/check",
                headers : {
                    "Content-Type" : "application/x-www-form-urlencoded",
                    Accept : "application/json"
                },
                data : `language=es&text=${encodeURIComponent(name)}`,
                onload : (response) => {
                    if (timeoutExcedido)
                        return;

                    clearTimeout(timeoutId);
                    const tiempoRespuesta = Date.now() - inicio;
                    let resultado;
                    try
                    {
                        if (response.status === 200)
                        {
                            const data = JSON.parse(response.responseText);
                            const problemasAPI = data.matches.map(
                              (match) => ({
                                  original : match.context.text.substring(
                                    match.context.offset,
                                    match.context.offset +
                                      match.context.length),
                                  sugerida : match.replacements[0]?.value ||
                                               match.context.text,
                                  tipo :
                                    "Ortografía", // Cambiar a "Ortografía" 
                                  origen : "API",
                                  regla : match.rule.id,
                                  contexto : match.context.text
                              }));
                            // Combinar resultados locales y de API
                            const todosProblemas =
                              [...problemasLocales, ...problemasAPI ];
                            resultado = {
                                hasSpellingWarning : todosProblemas.length > 0,
                                spellingWarnings : todosProblemas,
                                metadata : {
                                    apiStatus : "success",
                                    tiempoRespuesta,
                                    totalErrores : todosProblemas.length
                                }
                            };
                        }
                        else
                        {
                            resultado = {
                                hasSpellingWarning :
                                  problemasLocales.length > 0,
                                spellingWarnings : problemasLocales,
                                metadata : {
                                    apiStatus : `api_error_${response.status}`,
                                    tiempoRespuesta
                                }
                            };
                        }
                    }
                    catch (error)
                    {
                        resultado = {
                            hasSpellingWarning : problemasLocales.length > 0,
                            spellingWarnings : problemasLocales,
                            metadata :
                              { apiStatus : "parse_error", tiempoRespuesta }
                        };
                    }
                    cache.set(cacheKey, resultado);
                    resolve(resultado);
                },
                onerror : () => {
                    if (timeoutExcedido)
                        return;

                    clearTimeout(timeoutId);
                    const resultado = {
                        hasSpellingWarning : problemasLocales.length > 0,
                        spellingWarnings : problemasLocales,
                        metadata : {
                            apiStatus : "network_error",
                            tiempoRespuesta : Date.now() - inicio
                        }
                    };
                    cache.set(cacheKey, resultado);
                    resolve(resultado);
                }
            });
        });
    }

    // ********************************************************************************************************************************
    // Nombre: corregirTildeLocal
    // Fecha modificación: 2025-04-25 05:45 GMT-5
    // Autor: mincho77
    // Entradas:
    // - palabra (string): Palabra a corregir
    // Salidas:  (string): Palabra corregida o la original si no hay corrección.
    // Descripción: Esta función corrige las tildes de palabras específicas en español. Se basa en un objeto de correcciones 
    // predefinido. Si la palabra no está en el objeto, se devuelve la palabra original.
    // ********************************************************************************************************************************
    function corregirTildeLocal(palabra)
    {
        const correcciones = {
            aun : "aún",   // Adverbio de tiempo           
            tu : "tú",          // Pronombre personal
            mi : "mí",          // Pronombre personal
            el : "él",          // Pronombre personal
            si : "sí",          // Afirmación o pronombre reflexivo
            de : "dé",          // Verbo dar
            se : "sé",          // Verbo saber o ser
            mas : "más",        // Adverbio de cantidad
            te : "té",          // Sustantivo (bebida)
            que : "qué",        // Interrogativo o exclamativo
            quien : "quién",    // Interrogativo o exclamativo
            como : "cómo",      // Interrogativo o exclamativo
            cuando : "cuándo",  // Interrogativo o exclamativo
            donde : "dónde",    // Interrogativo o exclamativo
            cual : "cuál",      // Interrogativo o exclamativo
            cuanto : "cuánto",  // Interrogativo o exclamativo
            porque : "porqué",  // Sustantivo (la razón)
            porqué : "por qué", // Interrogativo o exclamativo
        };
        return correcciones[palabra.toLowerCase()] || palabra;
    }

    // ********************************************************************************************************************************
    // Nombre: scanPlaces
    // Fecha modificación: 2025-04-10 18:30 GMT-5
    // Autor: mincho77
    // Entradas: Ninguna
    // Salidas: Ninguna
    // Descripción: Escanea los lugares en el mapa y normaliza sus nombres. Filtra los lugares que no tienen nombre y procesa aquellos
    // que requieren normalización. Muestra un panel flotante con los lugares a normalizar y
    // permite aplicar cambios, excluir palabras y agregar palabras especiales. Incluye un botón para cerrar el panel.
    // Prerrequisitos: Funciones auxiliares como normalizePlaceName y evaluarOrtografiaNombre.
    // ********************************************************************************************************************************
    function scanPlaces()
    {       
        const maxPlaces =
          parseInt(document.getElementById("maxPlacesInput")?.value || 100, 10);
        if (!W?.model?.venues?.objects)
        {
            console.error("Modelo WME no disponible");
            return;
        }

        const allPlaces = Object.values(W.model.venues.objects)
                            .filter((place) => {
                                // Filtrar lugares que no tienen nombre
                                if (!place?.attributes?.name)
                                {
                                    return false;
                                }
                                return true;
                            })
                            .slice(0, maxPlaces);

        if (allPlaces.length === 0)
        {
            toggleSpinner(false);
            showNoPlacesFoundMessage(); // Mostrar el mensaje mejorado
            return;
        }
        // 6. Procesamiento asíncrono con progreso
        let processedCount = 0;
        const placesToNormalize = [];
        const processBatch = async (index) => {
            if (index >= allPlaces.length)
            {
                toggleSpinner(false);
                if (placesToNormalize.length > 0)
                {
                    openFloatingPanel(placesToNormalize);
                }
                else
                {
                    showModal({
                        title : "Advertencia",
                        message :
                          "No se encontraron lugares que requieran normalización.",
                        confirmText : "Entendido",
                        type : "warning"
                    });
                }
                return;
            }
            const place = allPlaces[index];
            try
            {
                const originalName = place.attributes.name;
                const normalizedName =
                  await normalizePlaceName(originalName, true);
                // Actualizar progreso
                processedCount++;
                toggleSpinner(
                  true,
                  `Procesando lugares... (${processedCount}/${
                    allPlaces.length})`,
                  Math.round((processedCount / allPlaces.length) * 100));
                // Evaluar ortografía (usando el modo seleccionado)
                const ortografia =
                  checkOnlyTildes
                    ? await evaluarOrtografiaConTildes(normalizedName)
                    : await evaluarOrtografiaNombre(normalizedName);
                if (ortografia.hasSpellingWarning ||
                    originalName !== normalizedName)
                {
                    placesToNormalize.push({
                        id : place.getID(),
                        originalName,
                        newName : normalizedName,                       
                        hasSpellingWarning : ortografia.hasSpellingWarning,
                        spellingWarnings : ortografia.spellingWarnings,
                        place
                    });
                }
                // Procesar siguiente lugar con pequeño retardo para no bloquear UI
                setTimeout(() => processBatch(index + 1), 50);
            }
            catch (error)
            {
                console.error(`Error procesando lugar ${place.getID()}:`, error);
                // Continuar con el siguiente lugar a pesar del error
                setTimeout(() => processBatch(index + 1), 50);
            }
        };
        // Iniciar procesamiento por lotes
        processBatch(0);
    }

    // ********************************************************************************************************************************
    // Nombre: renderDictionaryWordsPanel
    // Fecha modificación: 2025-04-14
    // Autor: mincho77
    // Entradas: Ninguna (usa la variable global dictionaryWords).
    // Salidas: Ninguna.
    // Descripción:
    // Limpia y renderiza la lista de palabras del diccionario en el panel
    // lateral. Ordena las palabras alfabéticamente y actualiza el localStorage.
    // ********************************************************************************************************************************
    function renderDictionaryWordsPanel()
    {
        const container = document.getElementById("dictionary-words-list");
        if (!container)
        {
            console.warn(
              "[PlacesNameNormalizer] No se encontró el contenedor 'dictionary-words-list'.");
            return;
        }

        container.innerHTML = "";

        const dict = spellDictionaries[activeDictionaryLang] || {};
        const words =
          Object.values(dict).flat().sort((a, b) => a.localeCompare(b));

        dictionaryWords = words; // Actualiza global para búsquedas

        const ul = document.createElement("ul");
        ul.style.listStyle = "none";

        words.forEach((word) => {
            const li = document.createElement("li");
            li.style.display = "flex";
            li.style.justifyContent = "space-between";
            li.style.alignItems = "center";
            li.style.padding = "5px 0";

            const wordSpan = document.createElement("span");
            wordSpan.textContent = word;
            li.appendChild(wordSpan);

            const btnContainer = document.createElement("div");
            btnContainer.style.display = "flex";
            btnContainer.style.gap = "10px";

            const editBtn = document.createElement("button");
            editBtn.textContent = "✏️";
            editBtn.title = "Editar";
            editBtn.style.cursor = "pointer";
            editBtn.addEventListener("click", () => {
                const index =
                  dictionaryWords.indexOf(wordSpan.textContent.trim());
                if (index !== -1)
                {
                    openEditPopup(index, "dictionaryWords");
                }
            });

            const deleteBtn = document.createElement("button");
            deleteBtn.textContent = "🗑️";
            deleteBtn.title = "Eliminar";
            deleteBtn.style.cursor = "pointer";
            deleteBtn.addEventListener("click", () => {
                const index =
                  dictionaryWords.indexOf(wordSpan.textContent.trim());
                if (index !== -1)
                {
                    openDeletePopupForDictionary(index);
                }
            });

            btnContainer.appendChild(editBtn);
            btnContainer.appendChild(deleteBtn);
            li.appendChild(btnContainer);
            ul.appendChild(li);
        });

        container.appendChild(ul);
    }

    // ********************************************************************************************************************************
    // Nombre: renderExcludedWordsPanel
    // Fecha modificación: 2025-03-30
    // Autor: mincho77
    // Entradas: Ninguna (usa la variable global excludeWords).
    // Salidas: Ninguna.
    // Descripción: Limpia y renderiza la lista de palabras excluidas en el panel lateral. Ordena las palabras alfabéticamente y 
    // actualiza el localStorage.
    // ********************************************************************************************************************************
    function renderExcludedWordsPanel()
    {
        const container = document.getElementById("normalizer-sidebar");
        if (!container)
        {
            console.warn(`[${
              SCRIPT_NAME}] No se encontró el contenedor "normalizer-sidebar".`);
            return;
        }

        // Limpiar el contenedor para evitar acumulaciones
        container.innerHTML = "";

        // Crear un elemento <ul> para la lista
        const list = document.createElement("ul");
        list.style.listStyle = "none"; // Opcional: eliminar viñetas

        // Iterar sobre cada palabra de la lista excluida
        excludeWords.forEach((word, index) => {
            const li = document.createElement("li");
            li.style.display = "flex";
            li.style.justifyContent = "space-between";
            li.style.alignItems = "center";
            li.style.padding = "5px 0";

            // Crear un <span> que muestre la palabra
            const wordSpan = document.createElement("span");
            wordSpan.textContent = word;
            li.appendChild(wordSpan);

            // Crear un contenedor para los botones
            const btnContainer = document.createElement("div");
            btnContainer.style.display = "flex";
            btnContainer.style.gap = "10px";

            // Botón de editar
            const editBtn = document.createElement("button");
            editBtn.textContent = "✏️";
            editBtn.title = "Editar";
            editBtn.style.cursor = "pointer";
            // Asigna el event listener de editar, pasando el índice y el tipo de lista
            editBtn.addEventListener(
              "click", () => { openEditPopup(index, "excludeWords"); });
            btnContainer.appendChild(editBtn);

            // Botón de borrar
            const deleteBtn = document.createElement("button");
            deleteBtn.textContent = "🗑️";
            deleteBtn.title = "Eliminar";
            deleteBtn.style.cursor = "pointer";
            deleteBtn.addEventListener("click", () => {
                openDeletePopup(index); // Llama a la función para mostrar el modal de confirmación
            });
            btnContainer.appendChild(deleteBtn);
            li.appendChild(btnContainer);
            list.appendChild(li);
        });

        container.appendChild(list);
    }
    function renderExcludedWordsPanel2()
    {
        const container = document.getElementById("normalizer-sidebar");
        if (!container)
        {
            console.warn(`[${
              SCRIPT_NAME}] No se encontró el contenedor "normalizer-sidebar".`);
            return;
        }
        // Limpiar el contenedor para evitar acumulaciones
        container.innerHTML = "";

        // Crear un elemento <ul> para la lista
        const list = document.createElement("ul");
        list.style.listStyle = "none"; // Opcional: eliminar viñetas

        // Iterar sobre cada palabra de la lista excluida
        excludeWords.forEach((word, index) => {
            const li = document.createElement("li");
            li.style.display = "flex";
            li.style.justifyContent = "space-between";
            li.style.alignItems = "center";
            li.style.padding = "5px 0";

            // Crear un <span> que muestre la palabra
            const wordSpan = document.createElement("span");
            wordSpan.textContent = word;
            li.appendChild(wordSpan);

            // Crear un contenedor para los botones
            const btnContainer = document.createElement("div");
            btnContainer.style.display = "flex";
            btnContainer.style.gap = "10px";

            // Botón de editar
            const editBtn = document.createElement("button");
            editBtn.textContent = "✏️";
            editBtn.title = "Editar";
            editBtn.style.cursor = "pointer";
            // Asigna el event listener de editar, pasando el índice y el tipo
            // de lista
            editBtn.addEventListener(
              "click", () => { openEditPopup(index, "excludeWords"); });
            btnContainer.appendChild(editBtn);

            // Botón de borrar
            const deleteBtn = document.createElement("button");
            deleteBtn.textContent = "🗑️";
            deleteBtn.title = "Eliminar";
            deleteBtn.style.cursor = "pointer";
            deleteBtn.addEventListener(
              "click", () => { openDeletePopupForDictionary(index); });
            btnContainer.appendChild(deleteBtn);

            li.appendChild(btnContainer);
            list.appendChild(li);
        });
        container.appendChild(list);
    }

     // ********************************************************************************************************************************
    // Nombre: setupDragAndDrop
    // Fecha modificación: 2025-04-22
    // Hora: 22:37
    // Autor: mincho77
    // Entradas: Ninguna (usa la variable global type).
    // Salidas: Ninguna.
    // Descripción: Soporta archivos .txt y .xml para diccionario ortográfico.
    // ********************************************************************************************************************************
    function setupDragAndDrop({ dropZoneId, onFileProcessed, type })
    {
        const dropZone = document.getElementById(dropZoneId);

        if (!dropZone)
        {
            console.warn(
              `[setupDragAndDrop] No se encontró el elemento con ID '${
                dropZoneId}'`);
            return;
        }

        // 🔁 Evitar que el navegador abra el archivo en toda la ventana
        ["dragenter", "dragover", "drop"].forEach(eventName => {
            window.addEventListener(eventName, e => {
                e.preventDefault();
                e.stopPropagation();
            });
        });

        // 🟩 Efecto visual al arrastrar
        dropZone.addEventListener("dragover", (e) => {
            e.preventDefault();
            e.stopPropagation();
            dropZone.style.borderColor = "#4CAF50";
            dropZone.style.backgroundColor = "#eaffea";
        });

        // 🔙 Restablecer el estilo si sale del área
        dropZone.addEventListener("dragleave", () => {
            dropZone.style.borderColor = "#ccc";
            dropZone.style.backgroundColor = "";
        });

        // 📥 Manejar el archivo soltado
        dropZone.addEventListener("drop", (event) => {
            event.preventDefault();
            event.stopPropagation();
            dropZone.style.borderColor = "#ccc";
            dropZone.style.backgroundColor = "";

            const file = event.dataTransfer.files[0];
            if (!file)
                return;

            const reader = new FileReader();
            reader.onload = (e) => {
                const content = e.target.result.trim();
                let words = [];

                if (file.name.endsWith(".xml"))
                {
                    try
                    {
                        const parser = new DOMParser();
                        const xml =
                          parser.parseFromString(content, "application/xml");
                        const wordNodes = xml.getElementsByTagName("word");
                        words = Array.from(wordNodes)
                                  .map(n => n.textContent.trim())
                                  .filter(Boolean);
                    }
                    catch (err)
                    {
                        console.error("❌ Error al parsear XML:", err);
                        showModal({
                            title : "Error",
                            message : "No se pudo leer el archivo XML.",
                            confirmText : "Aceptar",
                            type : "error",
                        });
                        return;
                    }
                }
                else
                {
                    words = content.split(/\r?\n/)
                              .map(line => line.trim())
                              .filter(Boolean);
                }

                if (typeof onFileProcessed === "function")
                {
                    onFileProcessed(words);
                }
            };

            reader.readAsText(file);
        });
    }
    function setupDragAndDrop2({ dropZoneId, onFileProcessed, type })
    {
        const dropZone = document.getElementById(dropZoneId);

        if (!dropZone)
        {
            console.warn(
              `[setupDragAndDrop] No se encontró el elemento con ID '${
                dropZoneId}'`);
            return;
        }

        // Prevenir comportamiento por defecto en toda la ventana
        window.addEventListener("dragover", e => e.preventDefault());
        window.addEventListener("drop", e => e.preventDefault());

        // Prevenir comportamiento por defecto y aplicar estilo en zona
        ["dragenter", "dragover"].forEach(eventName => {
            dropZone.addEventListener(eventName, (e) => {
                e.preventDefault();
                e.stopPropagation();
                dropZone.style.backgroundColor = "#e6ffe6"; // Verde suave
                dropZone.style.borderColor = "#28a745";
            });
        });

        ["dragleave", "drop"].forEach(eventName => {
            dropZone.addEventListener(eventName, (e) => {
                e.preventDefault();
                e.stopPropagation();
                dropZone.style.backgroundColor = "";
                dropZone.style.borderColor = "#ccc";
            });
        });

        dropZone.addEventListener("drop", (event) => {
            const file = event.dataTransfer.files[0];
            if (!file)
                return;

            const reader = new FileReader();
            reader.onload = (e) => {
                const content = e.target.result;
                let words = [];

                if (file.name.endsWith(".txt"))
                {
                    words = content.split(/\r?\n/)
                              .map(line => line.trim())
                              .filter(Boolean);
                }
                else if (file.name.endsWith(".xml"))
                {
                    try
                    {
                        const parser = new DOMParser();
                        const xml = parser.parseFromString(content, "text/xml");
                        const wordNodes = xml.getElementsByTagName("word");
                        words =
                          Array.from(wordNodes).map(n => n.textContent.trim());
                    }
                    catch (err)
                    {
                        console.error("❌ Error al parsear XML:", err);
                        return;
                    }
                }

                if (typeof onFileProcessed === "function")
                {
                    onFileProcessed(words);
                }
            };

            reader.readAsText(file);
        });
    }

    // ********************************************************************************************************************************
    // Nombre: setupDragAndDropImport
    // Fecha modificación: 2025-03-31
    // Autor: mincho77
    // Entradas: Ninguna.
    // Salidas: Ninguna.
    // Descripción:
    // Activa la funcionalidad de drag & drop sobre el elemento con id "drop-zone" para importar un archivo con palabras excluidas. 
    // Procesa archivos .xml y .txt.
    // ********************************************************************************************************************************
    function setupDragAndDropImport()
    {
        const dropZone = document.getElementById("drop-zone");
        if (!dropZone)
        {
            console.warn(
              "setupDragAndDropImport: No se encontró el elemento #drop-zone");
            return;
        }
        dropZone.addEventListener("dragover", (e) => {
            e.preventDefault();
            dropZone.style.borderColor = "#4CAF50";
            dropZone.style.backgroundColor = "#f0fff0";
            console.log("dragover detectado");
        });
        dropZone.addEventListener("dragleave", (e) => {
            dropZone.style.backgroundColor = "";
            dropZone.style.borderColor = "#ccc";
            console.log("dragleave detectado");
        });
        dropZone.addEventListener("drop", (e) => {
            e.preventDefault();
            dropZone.style.borderColor = "#ccc";
            dropZone.style.backgroundColor = "";
            console.log("drop detectado");
            const file = e.dataTransfer.files[0];
            if (!file)
            {
                console.log("No se detectó ningún archivo");
                return;
            }
            console.log("Archivo soltado:", file.name);
            const reader = new FileReader();
            reader.onload = function(event) {
                console.log("Contenido del archivo:", event.target.result);
                let palabras = [];
                if (file.name.endsWith(".xml"))
                {
                    const parser = new DOMParser();
                    const xml =
                      parser.parseFromString(event.target.result, "text/xml");
                    const nodes = xml.querySelectorAll(
                      "word, palabra, item, excluded, exclude");
                    palabras = Array.from(nodes)
                                 .map((n) => n.textContent.trim())
                                 .filter((p) => p.length > 0);
                }
                else
                {
                    palabras = event.target.result.split(/\r?\n/)
                                 .map((line) => line.trim())
                                 .filter((line) => line.length > 0);
                }
                if (palabras.length === 0)
                { // alert("⚠️ No se encontraron palabras válidas.");
                    showModal({
                        title : "Advertencia",
                        message : "No se encontraron palabras válidas.",
                        type : "warning",
                        autoClose :
                          2000, // El modal desaparecerá después de 2 segundos
                    });
                    return;
                }
                const replace =
                  document.getElementById("replaceExcludeListCheckbox");
                if (replace && replace.checked)
                {
                    excludeWords = [];
                    localStorage.removeItem("excludeWords");
                }
                else
                {
                    excludeWords =
                      JSON.parse(localStorage.getItem("excludeWords")) || [];
                }
                excludeWords = [...new Set([...excludeWords, ...palabras ]) ]
                                 .filter((w) => w.trim().length > 0)
                                 .sort((a, b) => a.localeCompare(b));
                localStorage.setItem("excludeWords",
                                     JSON.stringify(wordLists.excludeWords));
                renderExcludedWordsPanel();
                showModal({
                    title : "Información",
                    message :
                      "Se importaron {prependText} palabras desde el archivo.",
                    prependText : palabras.length,
                    confirmText : "Aceptar",
                    type : "info"
                });               
            };
            reader.readAsText(file);
        });
    }
    // ********************************************************************************************************************************
    // Nombre: handleImportList
    // Fecha modificación: 2025-03-30
    // Autor: mincho77
    // Entradas: Ninguna (depende del input file "importListInput" y checkbox "replaceExcludeListCheckbox"). 
    // Salidas: Ninguna. 
    // Descripción: Lee un archivo seleccionado por el usuario, procesa sus líneas para extraer palabras válidas, y actualiza la lista 
    // de palabras excluidas (localStorage y panel).
    // ********************************************************************************************************************************
    function handleImportList()
    {
        const fileInput = document.getElementById("importListInput");
        const replaceCheckbox =
          document.getElementById("replaceExcludeListCheckbox");

        if (!fileInput || !fileInput.files || fileInput.files.length === 0)
        {
            showModal({
                title : "Información",
                message : "No se seleccionó ningún archivo.",
                confirmText : "Aceptar",
                type : "info"
            });
            return;
        }

        const file = fileInput.files[0];
        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);

            if (lines.length === 0)
            {
                showModal({
                    title : "Error",
                    message : "El archivo no contiene datos válidos.",
                    confirmText : "Aceptar",
                    type : "error"
                });
                return;
            }

            if (replaceCheckbox && replaceCheckbox.checked)
            {
                excludeWords = [];
            }
            else
            {
                excludeWords =
                  JSON.parse(localStorage.getItem("excludeWords")) || [];
            }

            excludeWords = [...new Set([...excludeWords, ...lines ]) ]
                             .filter((w) => w.trim().length > 0)
                             .sort((a, b) => a.localeCompare(b));

            localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
            renderExcludedWordsPanel(); // Refresca la lista después de importar

            showModal({
                title : "Éxito",
                message : `Se importaron ${
                  lines.length} palabras a la lista de palabras especiales.`,
                confirmText : "Aceptar",
                type : "success"
            });

            fileInput.value = ""; // Reinicia el input de archivo
        };

        reader.onerror = function() {
            showModal({
                title : "Error",
                message :
                  "Hubo un problema al leer el archivo. Inténtalo nuevamente.",
                confirmText : "Aceptar",
                type : "error"
            });
        };

        reader.readAsText(file);
    }

    // ********************************************************************************************************************************
    // Nombre: renderSpecialWordsPanel
    // Fecha modificación: 2025-04-25 05:45 GMT-5
    // Autor: mincho77
    // Entradas: Ninguna (usa la variable global specialWords).
    // Salidas: Ninguna.
    // Descripción: Limpia y renderiza la lista de palabras especiales en el panel lateral. Ordena las palabras alfabéticamente y
    // actualiza el localStorage. Se utiliza para mostrar las palabras especiales que se pueden agregar o editar.
    // ********************************************************************************************************************************
    function renderSpecialWordsPanel()
    {
        const container = document.getElementById("special-words-list");
        if (!container)
        {
            console.warn(
              "[PlacesNameNormalizer] No se encontró el contenedor 'special-words-list'.");
            return;
        }

        container.innerHTML = ""; // Limpia el contenedor

        // Ordenar las palabras alfabéticamente
        const sortedWords = specialWords.sort((a, b) => a.localeCompare(b));

        // Crear una lista de palabras
        const ul = document.createElement("ul");
        ul.style.listStyle = "none";

        sortedWords.forEach((word) => {
            const li = document.createElement("li");
            li.textContent = word;
            ul.appendChild(li);
        });

        container.appendChild(ul);
    }

    // ********************************************************************************************************************************
    // Nombre: addWordsToList
    // Fecha modificación: 2025-04-25 05:45 GMT-5
    // Autor: mincho77
    // Entradas:
    // - words (string[]): Palabras a agregar.
    // - listType (string): Tipo de lista ("specialWords" o "dictionaryWords").
    // Salidas: Ninguna.
    // Descripción: Agrega palabras a la lista correspondiente (especiales o del diccionario). Evita duplicados y actualiza el localStorage.
    // También renderiza la lista correspondiente en el panel lateral y muestra un mensaje de éxito.    
    // ********************************************************************************************************************************
    function addWordsToList(words, listType)
    {
        // Determinar la lista correspondiente
        let targetList;
        if (listType === "specialWords") {
            targetList = specialWords;
        } else if (listType === "dictionaryWords") {
            targetList = dictionaryWords;
        } else {
            console.error(`Tipo de lista desconocido: ${listType}`);
            return;
        }

        // Agregar palabras a la lista, evitando duplicados
        const newWords = words.filter((word) => !targetList.includes(word));
        targetList.push(...newWords);

        // Guardar en localStorage
        localStorage.setItem(listType, JSON.stringify(targetList));

        // Renderizar la lista correspondiente
        if (listType === "specialWords") {
            renderSpecialWordsPanel();
        } else if (listType === "dictionaryWords") {
            renderDictionaryWordsPanel();
        }

        // Mostrar mensaje de éxito
        showModal({
            title: "Éxito",
            message: `Se agregaron ${newWords.length} palabra(s) a la lista ${listType}.`,
            confirmText: "Aceptar",
            type: "success",
            autoClose: 1500,
        });
    }

    // ********************************************************************************************************************************
    // Nombre: openAddSpecialWordPopup
    // Fecha modificación: 2025-04-25 04:56
    // Autor: mincho77
    // Entradas:
    // - name (string): Nombre de la palabra o frase a agregar.
    // - listType (string): Tipo de lista ("specialWords" o "excludeWords").
    // Salidas: Ninguna. 
    // Descripción: Abre un modal para agregar palabras especiales o excluidas. Permite seleccionar palabras de una frase y
    // agregarlas a la lista correspondiente. Actualiza el localStorage y renderiza la lista en el panel lateral. Muestra mensajes 
    // de éxito o advertencia según corresponda.
    // ********************************************************************************************************************************
    function openAddSpecialWordPopup(name, listType = "specialWords")
    {
        const words = name.split(/\s+/); // Dividir el nombre en palabras
        const modal = document.createElement("div");
        modal.className = "custom-modal-overlay";
        modal.innerHTML = `
        <div class="custom-modal">
            <div class="custom-modal-header">
                <h3>Agregar Palabras ${
          listType === "excludeWords" ? "Excluidas" : "Especiales"}</h3>
                <button class="close-modal-btn" title="Cerrar">×</button>
            </div>
            <div class="custom-modal-body">
                <p>Selecciona las palabras que deseas agregar como ${
          listType === "excludeWords" ? "excluidas" : "especiales"}:</p>
                <ul style="list-style: none; padding: 0;">
                    ${
          words
            .map((word, index) => `
                            <li>
                                <label>
                                    <input type="checkbox" class="special-word-checkbox" data-word="${
                   word}" id="word-${index}">
                                    ${word}
                                </label>
                            </li>
                        `)
            .join("")}
                </ul>
            </div>
            <div class="custom-modal-footer">
                <button id="add-selected-words-btn" class="modal-btn confirm-btn">Agregar</button>
                <button id="cancel-add-words-btn" class="modal-btn cancel-btn">Cancelar</button>
            </div>
        </div>
        `;

        document.body.appendChild(modal);

        // Manejar el cierre del modal
        modal.querySelector(".close-modal-btn").addEventListener("click", () => modal.remove());
        modal.querySelector("#cancel-add-words-btn").addEventListener("click", () => modal.remove());

        // Manejar la acción de agregar palabras seleccionadas
        modal.querySelector("#add-selected-words-btn")
          .addEventListener("click", () => {
              const selectedWords = Array
                                      .from(modal.querySelectorAll(
                                        ".special-word-checkbox:checked"))
                                      .map((checkbox) => checkbox.dataset.word);

              if (selectedWords.length > 0)
              {
                  selectedWords.forEach((word) => {
                      if (listType === "excludeWords")
                      {
                          if (!excludeWords.includes(word))
                          {
                              excludeWords.push(word);
                          }
                      }
                      else
                      {

                          addWordsToList([ word ], listType);
                      }
                  });

                  // Guardar en localStorage y actualizar la interfaz
                  if (listType === "excludeWords")
                  {
                      localStorage.setItem("excludeWords",
                                           JSON.stringify(excludeWords));
                      renderExcludedWordsPanel();
                  }
                  else
                  {
                      localStorage.setItem("specialWords",
                                           JSON.stringify(specialWords));
                      renderSpecialWordsPanel();
                  }

                  // Mostrar mensaje de éxito con tiempo reducido
                  showModal({
                      title : "Éxito",
                      message :
                        `Se agregaron ${selectedWords.length} palabra(s) como ${
                          listType === "excludeWords" ? "excluidas"
                                                      : "especiales"}.`,
                      
                      type : "success",
                      autoClose : 1000, // Tiempo reducido a 1 segundos
                  });
              }
              else
              {
                  // Mostrar mensaje de advertencia si no se seleccionó ninguna
                  // palabra
                  showModal({
                      title : "Advertencia",
                      message : "No seleccionaste ninguna palabra.",                      
                      type : "warning",
                      autoClose : 1000, // Tiempo reducido a 1 segundos
                  });
              }
              modal.remove();
          });
    }


    // ********************************************************************************************************************************
    // Nombre: normalizePlaceName
    // Fecha modificación: 2025-04-15
    // Autor: mincho77
    // Entradas:
    // - name (string): Nombre del lugar a normalizar.
    // - useSpellingAPI (boolean): Indica si se debe usar la API de ortografía.
    // Salidas: (string): Nombre normalizado.
    // Descripción: Normaliza el nombre del lugar aplicando reglas de capitalización, eliminando artículos innecesarios y corrigiendo 
    // errores ortográficos. Utiliza la API de LanguageTool para verificar la ortografía y aplicar sugerencias. También maneja números
    // romanos y apóstrofes de manera adecuada. La función devuelve el nombre normalizado.
    // ********************************************************************************************************************************
    async function normalizePlaceName(name, useSpellingAPI = false)
    {
        if (!name)
            return "";

        // Obtener el estado del checkbox para usar la API
        const useAPI = document.getElementById("useSpellingAPI")?.checked;
        const normalizeArticles =
          !document.getElementById("normalizeArticles")?.checked;
        // Obtener el estado del checkbox para verificar ortografía
        const articles =
          [ "el", "la", "los", "las", "de", "del", "al", "y", "e" ];
        const words = name.trim().split(/\s+/);
        // Filtrar palabras excluidas
        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|xl)$/i
            .test(word);

        const normalizedWords =
          await Promise.all(words.map(async (word, index) => {
              const lowerWord = word.normalize("NFD").toLowerCase();

              // Si es "Él" o "el", no modificar
              if (lowerWord === "él" || lowerWord === "el")
              {
                  return word; // Mantener la palabra tal como está
              }

              // Reemplazar "SA" por "S.A"
              if (lowerWord === "sa")
              {
                  return "S.A";
              }

              // Reemplazar "SAS" por "S.A.S"
              if (lowerWord === "sas")
              {
                  return "S.A.S";
              }

              // Si es un número, se mantiene igual
              if (/^\d+$/.test(word))
                  return word;

              // Si la palabra está en la lista de excluidas, se devuelve tal
              // cual
              const match = wordLists.excludeWords.find(
                (w) => w.normalize("NFD").toLowerCase() === lowerWord);
              if (match)
                  return match;

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

              // Si contiene un apóstrofo, no capitalizar la letra siguiente
              if (/^[A-Za-z]+'[A-Za-z]/.test(word))
              {
                  return (word.charAt(0).toUpperCase() +
                          word.slice(1, word.indexOf("'") + 1) +
                          word.slice(word.indexOf("'") + 1).toLowerCase());
              }

              // 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 se debe usar la API de ortografía, verificar ortografía
              if (useAPI)
              {
                  try
                  {
                      const errors = await checkSpellingWithAPI(word);
                      if (errors.length > 0)
                      {
                          const suggestion = errors[0].sugerencia || word;
                          return suggestion.charAt(0).toUpperCase() + suggestion.slice(1).toLowerCase();
                      }
                  }
                  catch (error)
                  {
                      console.error("Error al verificar ortografía:", error);
                  }
              }
              // Capitalizar la primera letra de la palabra
              return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
          }));

        let newName =
          normalizedWords.join(" ")
                .replace(/\s*\|\s*/g, " - ")
                .replace(/\s*\[P]\s*/g, "") // Reemplaza [P] por un espacio vacío
            .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());

        // Asegurar que las letras después de un apóstrofo estén en minúscula
        newName = newName.replace(/([A-Za-z])'([A-Za-z])/g,
                                  (match, before, after) =>
                                    `${before}'${after.toLowerCase()}`);

        // Asegurar que la primera letra después de un guion esté en mayúscula
        newName = newName.replace(
          /-\s*([a-z])/g, (match, letter) => `- ${letter.toUpperCase()}`);
        return newName.replace(/\s{2,}/g, " ").trim();
    }
    // ********************************************************************************************************************************
    // Nombre: init
    // Fecha modificación: 2025-04-09
    // 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. Una vez disponible, inicializa la lista de palabras excluidas, crea el 
    // tab lateral personalizado, y espera a que el DOM del tab esté listo para renderizar el panel de palabras excluidas y activar la 
    // funcionalidad de arrastrar y soltar para importar palabras. Finalmente, expone globalmente las funciones applyNormalization y 
    // normalizePlaceName.
    // ********************************************************************************************************************************
    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");
            renderExcludedWordsPanel();

            waitForDOM("#dictionary-words-list", (element) => {
                console.log("Contenedor del diccionario encontrado:", element);
                renderDictionaryWordsPanel();
                attachDictionarySearch(); // ✅ Ejecuta el buscador sobre los
                                          // <li>
            });

            setupDragAndDrop({
                dropZoneId : "drop-zone",
                onFileProcessed : (words) => {
                    excludeWords =
                      [...new Set([...excludeWords, ...words ]) ].sort();
                    localStorage.setItem("excludeWords",
                                         JSON.stringify(excludeWords));
                    renderExcludedWordsPanel();
                    showModal({
                        title : "Éxito",
                        message : `Se importaron ${
                          words
                            .length} palabras a la lista de palabras especiales.`,
                        confirmText : "Aceptar",
                        type : "success",
                    });
                },
                type : "excludeWords",
            });

            setupDragAndDrop({
                dropZoneId : "dictionary-drop-zone",
                onFileProcessed : (words) => {
                    const nuevoDiccionario = {};
                    for (const palabra of words)
                    {
                        const letra = palabra.charAt(0).toLowerCase();
                        if (!nuevoDiccionario[letra])
                        {
                            nuevoDiccionario[letra] = [];
                        }
                        nuevoDiccionario[letra].push(palabra);
                    }

                    for (const letra in nuevoDiccionario)
                    {
                        if (!spellDictionaries[activeDictionaryLang][letra])
                        {
                            spellDictionaries[activeDictionaryLang][letra] = [];
                        }

                        const conjunto = new Set([
                            ...spellDictionaries[activeDictionaryLang][letra],
                            ...nuevoDiccionario[letra]
                        ]);

                        spellDictionaries[activeDictionaryLang][letra] =
                          Array.from(conjunto).sort();
                    }

                    localStorage.setItem(
                      `spellDictionaries_${activeDictionaryLang}`,
                      JSON.stringify(spellDictionaries[activeDictionaryLang]));

                    dictionaryWords =
                      Object.values(spellDictionaries[activeDictionaryLang])
                        .flat()
                        .sort();

                    renderDictionaryWordsPanel();

                    showModal({
                        title : "Éxito",
                        message : `Se importaron ${
                          words.length} palabras al diccionario.`,
                        confirmText : "Aceptar",
                        type : "success",
                    });
                },
                type : "dictionaryWords"
            });

            configurarCambioIdiomaDiccionario();           

            waitForElement("#details-special-words", (detailsElem) => {
                const arrow = document.getElementById("arrow");
                if (detailsElem && arrow)
                {
                    detailsElem.addEventListener("toggle", function() {
                        arrow.style.transform =
                          detailsElem.open ? "rotate(90deg)" : "rotate(0deg)";
                    });
                }
                else
                {
                    console.error(
                      "No se encontró el elemento #details-special-words o #arrow");
                }
            });

            waitForElement("#details-dictionary-words", (detailsElem) => {
                const arrow = document.getElementById("arrow-dic");
                if (detailsElem && arrow)
                {
                    detailsElem.addEventListener("toggle", function() {
                        arrow.style.transform =
                          detailsElem.open ? "rotate(90deg)" : "rotate(0deg)";
                    });
                }
                else
                {
                    console.error(
                      "No se encontró el elemento #details-dictionary-words o #arrow-dic");
                }
            });

            window.applyNormalization = applyNormalization;
            window.normalizePlaceName = normalizePlaceName;

            if (W && W.model && W.model.venues)
            {
                W.model.venues.on("zoomchanged", () => {
                    placesToNormalize = [];
                    const existingPanel =
                      document.getElementById("normalizer-floating-panel");
                    if (existingPanel)
                    {
                        existingPanel.remove();
                    }
                    console.log(
                      "Cambio de zoom detectado: Se ha reiniciado la búsqueda de lugares.");
                });
            }
           
        });
    }

    // Inicia el script
    init();
    // --------------------------------------------------------------------
    // Fin del script principal

    unsafeWindow.normalizePlaceName = normalizePlaceName;
    unsafeWindow.applyNormalization = applyNormalization;
    window.addEventListener("dragover", e => e.preventDefault());
    window.addEventListener("drop", e => e.preventDefault());
})();