WME Places Name Normalizer

Normaliza nombres de lugares en Waze Map Editor (WME)

当前为 2025-04-25 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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());
})();