WME Places Name Normalizer

Normaliza nombres de lugares en Waze Map Editor (WME)

目前为 2025-05-04 提交的版本。查看 最新版本

// ==UserScript==
// @name         WME Places Name Normalizer
// @namespace    https://gf.qytechs.cn/en/users/mincho77
// @version      5.1.0
// @description  Normaliza nombres de lugares en Waze Map Editor (WME)
// @author       mincho77
// @match        https://www.waze.com/*editor*
// @match        https://beta.waze.com/*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 = 100;
    let normalizeArticles = false;
    let placesToNormalize = [];
    let wordLists = {
        excludeWords : JSON.parse(localStorage.getItem("excludeWords")) || [],
        dictionaryWords :
          JSON.parse(localStorage.getItem("dictionaryWords")) || []
    };
    // ==============================================
    // Internacionalización (i18n)
    // ==============================================
    const uiStrings = {
        SP : {
            activeLangLabel : "Idioma activo:",
            normalizeArticlesLabel : "Normalizar artículos (el, la, los, ...)",
            useApiLabel : "Usar API de ortografía",
            maxPlacesLabel : "Máximo de Places a buscar:",
            specialWordsLabel : "Palabras Especiales",
            addWordPlaceholder : "Agregar palabra...",
            addButton : "Agregar",
            searchWordPlaceholder : "Buscar palabra...",
            exportWordsButton : "Exportar Palabras",
            importListButton : "Importar Lista",
            replaceListLabel : "Reemplazar lista actual",
            dropZoneText :
              "📂 Arrastra aquí tu archivo .txt o .xml para importar palabras especiales",
            dictionaryLabel : "Diccionario Ortográfico",
            searchDictionaryPlaceholder : "Buscar palabra...",
            exportDictionaryButton : "📤 Exportar Diccionario",
            importDictionaryButton : "📥 Importar Diccionario",
            clearDictionaryButton : "🧹 Limpiar Diccionario",
            dictionaryDropZoneText :
              "📂 Arrastra aquí tu archivo de palabras del diccionario (.xml o .txt)",
            scanButton : "Scan...",
            clearSpecialWordsButton : "Eliminar Palabras Especiales",
            // Floating Panel Headers & Text
            panelTitle : "Normalizador de Nombres",
            placesToReview : "lugares para revisar",
            applyCol : "Aplicar",
            deleteCol : "Eliminar",
            typeCol : "Tipo Place",
            permaCol : "Perma",
            categoryCol : "Categoría", // Nueva traducción
            currentNameCol : "Nombre Actual",
            normalizedNameCol : "Nombre Normalizado",
            problemCol : "Problema Detectado",
            actionsCol : "Acciones",
            applySelectedButton : "Aplicar Cambios Seleccionados",
            cancelButton : "Cancelar",
            errorDetailTitle : "error(es) encontrado(s)",
            // Panel flotante - Tabla
            normalizationLabel : "Normalización",
            normalizeButtonLabel : "NrmliZer", // O "Normalizar" si prefieres
            excludeWordButtonLabel : "ExcludeWrd", // O "Excluir Palabra"
            errorInLabel : "Error en:",
            suggestionLabel : "Sugerencia:",
            useSuggestionButtonLabel : "Usar sugerencia",
            // Tooltips
            placeTypeAreaTooltip : "Tipo de Lugar: Área",
            // Spinner & Progress
            spinnerProcessingMessage : "Procesando lugares...", // Nuevo
            spinnerCheckingMessage : "Revisando ortografía...",
            spinnerProgressMessage : "% completado",
            placesReadyToNormalize :
              "lugares marcados para Normalización", // Nuevo contador
            placeTypePointTooltip : "Tipo de Lugar: Punto"
        },
        EN : {
            activeLangLabel : "Active Language:",
            normalizeArticlesLabel :
              "Do not normalize articles (the, a, an, ...)", // Will be hidden
            useApiLabel : "Verify Spelling With API",
            maxPlacesLabel : "Maximum Places to search:",
            specialWordsLabel : "Special Words",
            addWordPlaceholder : "Add word...",
            addButton : "Add",
            searchWordPlaceholder : "Search word...",
            exportWordsButton : "Export Words",
            importListButton : "Import List",
            replaceListLabel : "Replace current list",
            dropZoneText :
              "📂 Drag your .txt or .xml file here to import special words",
            dictionaryLabel : "Spelling Dictionary",
            searchDictionaryPlaceholder : "Search word...",
            exportDictionaryButton : "📤 Export Dictionary",
            importDictionaryButton : "📥 Import Dictionary",
            clearDictionaryButton : "🧹 Clear Dictionary",
            dictionaryDropZoneText :
              "📂 Drag your dictionary words file (.xml or .txt) here",
            scanButton : "Scan...",
            clearSpecialWordsButton : "Delete Special Words",
            // Floating Panel Headers & Text
            panelTitle : "Name Normalizer",
            placesToReview : "places to review",
            applyCol : "Apply",
            deleteCol : "Delete",
            typeCol : "Place Type",
            permaCol : "Perma",
            categoryCol : "Category", // Nueva traducción
            currentNameCol : "Current Name",
            normalizedNameCol : "Normalized Name",
            problemCol : "Problem Detected",
            actionsCol : "Actions",
            applySelectedButton : "Apply Selected Changes",
            cancelButton : "Cancel",
            errorDetailTitle : "error(s) found",
            // Floating Panel - Table
            normalizationLabel : "Normalization",
            normalizeButtonLabel : "NrmliZer",     // Or "Normalize"
            excludeWordButtonLabel : "ExcludeWrd", // Or "Exclude Word"
            errorInLabel : "Error in:",
            suggestionLabel : "Suggestion:",
            useSuggestionButtonLabel : "Use suggestion",
            // Tooltips
            placeTypeAreaTooltip : "Place Type: Area",
            // Spinner & Progress
            spinnerProcessingMessage : "Processing places...", // New
            spinnerCheckingMessage : "Checking spelling...",
            spinnerProgressMessage : "% complete",
            placesReadyToNormalize :
              "places checked to normalize", // Nuevo contador
            placeTypePointTooltip : "Place Type: Point"
        }
    };

    // Acá se obtienen los textos según el idioma activo
    function S(key)
    {
        return uiStrings[activeDictionaryLang]?.[key] ||
               uiStrings['SP']?.[key] ||
               `[${key}]`; // Fallback a SP y luego a la key
    }
    // Helper para obtener código de idioma para la API
    function getApiLangCode(internalLang)
    {
        return internalLang === 'EN' ? 'en' : 'es';
    }

    // ==============================================
    // 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;

    // Asegura que normalizeArticles se inicialice correctamente según el
    // idioma Si es inglés, la normalización de artículos SIEMPRE está
    // desactivada.
    if (activeDictionaryLang === 'EN')
    {
        normalizeArticles = false; // Forzar desactivación para inglés
    }
    else
    {
        // Para español, cargar desde localStorage o usar valor por defecto
        // (true = NO normalizar)
        normalizeArticles =
          localStorage.getItem('normalizeArticles_SP') !== null
            ? JSON.parse(localStorage.getItem('normalizeArticles_SP'))
            : false;
    }
    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, passive : false });
    });

    //********************************************************************************************************************************
    // Nombre: obtenerListaEsdrujulas
    // Fecha modificación: 2025-07-29
    // Autor: mincho77
    // Entradas: Ninguna
    // Salidas: Array<string> - Una lista de palabras esdrújulas comunes.
    // Descripción: Devuelve una lista predefinida de palabras esdrújulas en español,
    //              que siempre deben llevar tilde. Útil para validaciones o mejoras
    //              en la detección de acentos.
    //********************************************************************************************************************************
    function obtenerListaEsdrujulas() {
        return [
            "música",
            "pájaro",
            "teléfono",
            "brújula",
            "matemáticas",
            "miércoles",
            "cámara",
            "rápido",
            "América", // También nombres propios
            "clásico",
            "página",
            "último",
            "oxígeno",
            "plátano",
            "semáforo",
            "clínica",
            "vehículos",
            "láminas",
            "sábado",
            "número",
            "crédito",
            "público",
            "ejército",
            "océano",
            "gramática",
            "fábrica",
            "máquina",
            "plástico",
            "cerámica",
            "económico",
            "política",
            "académico",
            "simpático",
            "fantástico",
            "práctico",
            "círculo",
            "triángulo",
            "rectángulo",
            "hipótesis",
            "análisis",
            "síntesis",
            "catástrofe",
            "metáfora",
            "paréntesis",
            "helicóptero",
            "termómetro",
            "kilómetro",
            "centímetro",
            "milímetro",
            "décimo",
            "vigésimo",
            "trigésimo",
            "cuadragésimo",
            "quincuagésimo",
            "sexagésimo",
            "septuagésimo",
            "octogésimo",
            "nonagésimo",
            "centésimo",
            "milésimo"
            // Puedes añadir más palabras aquí si lo necesitas
        ];
    }
    //********************************************************************************************************************************
    // Nombre: generarPluralesEsdrujulas
    // Fecha modificación: 2025-07-29
    // Autor: Chat Asistente (basado en solicitud)
    // Entradas: listaSingulares (Array<string>) - Lista de palabras esdrújulas
    // en singular. Salidas: Array<string> - Lista combinada de singulares y sus
    // plurales correspondientes. Descripción: Genera formas plurales para una
    // lista de palabras esdrújulas.
    //              Maneja casos comunes como añadir 's' a vocales y palabras
    //              invariables terminadas en 's'. Devuelve una lista única y
    //              ordenada alfabéticamente.
    //********************************************************************************************************************************
    function generarPluralesEsdrujulas(listaSingulares)
    {
        if (!Array.isArray(listaSingulares))
        {
            console.error(
              "generarPluralesEsdrujulas: La entrada debe ser un array.");
            return [];
        }

        const resultadoSet =
          new Set(); // Usar un Set para evitar duplicados automáticamente

        listaSingulares.forEach(palabra => {
            if (typeof palabra !== 'string' || palabra.trim() === '')
                return; // Ignorar entradas inválidas

            resultadoSet.add(palabra); // Añadir la palabra original

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

            // Regla general para plurales de esdrújulas:
            if ('sx'.includes(ultimaLetra))
            {
                // Palabras terminadas en 's' o 'x' (no agudas) suelen ser
                // invariables
                resultadoSet.add(
                  palabra); // Añadir la misma palabra (Set maneja duplicados)
            }
            else if ('aeiouáéíóú'.includes(ultimaLetra))
            {
                // Palabras terminadas en vocal: añadir 's'
                resultadoSet.add(palabra + 's');
            }
            // Nota: Es raro que esdrújulas terminen en otras consonantes, pero
            // se podrían añadir más reglas aquí si fuera necesario.
        });

        return Array.from(resultadoSet)
          .sort((a, b) => a.localeCompare(b)); // Convertir a array y ordenar
    }

    // Acá se normaliza una palabra: se pasa a minúsculas y se quitan las tildes
    function normalizarPalabra(palabra)
    {
        return palabra.normalize("NFD")
          .replace(/[\u0300-\u036f]/g, "") // Quitar tildes
          .toLowerCase();
    }

    // =======================
    // Integración con API LanguageTool y gestión de sugerencias ortográficas
    // =======================
    // Estructura interna: cada error ortográfico sugerido por la API se
    // almacena así: { word: 'Frayle', suggestion: 'Fraile', origin: 'API',
    // message: 'Sugerencia de la API', index: ... }

    // Mapa de sugerencias API por placeId (o por nombre si es necesario)
    let spellingApiSuggestions = {}; // { [placeId]: [ {word, suggestion,
                                     // origin, message, index} ] }

    //********************************************************************************************************************************
    // Nombre: checkSpellingWithAPI (Acá se llama a la API de LanguageTool)
    // Descripción: Llama a la API de LanguageTool para revisar la ortografía de
    // un texto.
    //              Acá se guardan las sugerencias encontradas para un 'placeId'
    //              específico, pero NO se aplican automáticamente al nombre.
    //              Acá se ejecuta un 'callback' con las sugerencias obtenidas.
    //********************************************************************************************************************************
    function checkSpellingWithAPI(text, lang, placeId, callback)
    {
        // Limpieza y validación inicial
        if (!placeId || !text || typeof callback !== 'function')
        {
            console.warn(`checkSpellingWithAPI: Faltan parámetros (placeId: ${
              !!placeId}, text: "${text}", callback: ${
              typeof callback}). Llamando callback(null).`);
              if (typeof callback === 'function')
                  callback(null); // <-- LLAMAR AL CALLBACK AQUÍ
              return;             // <-- Salir temprano
        }
        if (!spellingApiSuggestions[placeId])
        {
            spellingApiSuggestions[placeId] = [];
        }

        // Llamada a la API
        console.log(
          `[checkSpellingWithAPI] Initiating GM_xmlhttpRequest for placeId ${
            placeId}`); // <-- Nuevo Log
        GM_xmlhttpRequest({
            method : "POST",
            url : "https://api.languagetool.org/v2/check",
            headers : { "Content-Type" : "application/x-www-form-urlencoded" },
            data : `text=${encodeURIComponent(text)}&language=${
              getApiLangCode(lang)}`,
            onload : function(response) {
                console.log(
                  `[checkSpellingWithAPI] onload triggered for placeId ${
                    placeId}, status: ${response.status}`); // <-- Nuevo Log
                try
                {
                    if (response.status === 200)
                    {
                        const result = JSON.parse(response.responseText);
                        const suggestions = result.matches.map(
                          (match) => ({
                              word : match.context.text.substring(
                                match.context.offset,
                                match.context.offset + match.context.length),
                              suggestion :
                                match.replacements.length > 0
                                  ? match.replacements[0].value
                                  : null, // Mantener null si no hay sugerencia
                              message : match.message,
                              rule : match.rule.id,
                          }));

                        // Almacenar sugerencias en el objeto global
                        spellingApiSuggestions[placeId] = suggestions;

                        // Llamar al callback con las sugerencias
                        callback(suggestions);
                    }
                    else
                    {
                        console.error("Error en la API de LanguageTool:",
                                      response.status,
                                      response.statusText);
                        // safeCallback(null) ya se llama en el catch o al final
                        // del try
                        callback(null); // o callback([])
                    }
                }
                catch (e)
                { // <--- Añadir catch
                    console.error("Error procesando respuesta de la API:", e);
                    callback(null); // Llamar con null si hay error en el try
                }
            },
            onerror : function(error) { // <--- Añadir parámetro error
                console.log(
                  `[checkSpellingWithAPI] onerror triggered for placeId ${
                    placeId}`); // <-- Nuevo Log
                console.error("Error al contactar la API de LanguageTool:",
                              error);
                // Llamar al callback con null o vacío en caso de error de API
                callback(null); // o callback([])
            },
            ontimeout : function() { // Añadir handler de timeout
                console.log(
                  `[checkSpellingWithAPI] ontimeout triggered for placeId ${
                    placeId}`); // <-- Nuevo Log
                console.error("Timeout al contactar la API de LanguageTool.");
                callback(null); // Llamar con null en timeout
            },
            timeout : 30000 // Aumentar timeout a 30 segundos
        });
    }

    // Acá se revisa si la palabra está en las especiales
    function esPalabraEspecial(palabra)
    {
        const palabraNormalizada = normalizarPalabra(palabra);
        for (const especial of specialWords)
        {
            const especialNormalizada = normalizarPalabra(
              especial); // Se normaliza la palabra especial para comparar
            if (palabraNormalizada === especialNormalizada)
            {
                return especial; // Retorna la versión oficial con tildes y
                                 // formato
            }
        }
        return null;
    }

    //********************************************************************************************************************************
    // Nombre: debugDictionaries (Acá se depuran los diccionarios)
    // Fecha de modificación: 2025-04-15 12:17
    // Autor: mincho77
    // Entradas: Nada
    // Salidas: Nada. Se 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 que se está usando. Sirve para depurar y ver cómo están
    // configurados los diccionarios. Permite verificar que el idioma y el
    // diccionario se hayan cargado correctamente. Se puede usar directamente
    // desde la consola del navegador para revisar el estado.
    //*********************************************************************************************************************************
    unsafeWindow.debugDictionaries = function ()
    {
        console.log("Idioma activo:", activeDictionaryLang);
        console.log("Diccionario actual:",
                    spellDictionaries[activeDictionaryLang]);
    };

    // --- Inicio: Adaptación del código Python para silabificación ---

    const VOWEL_SET = new Set('AEIOUaeiouÀÁÄÈÉËÌÍÏÒÓÖÙÚÜàáäèéëìíïòóöùúü');
    const OPEN_PLAIN = new Set('aeo');
    const OPEN_ACCENTED = new Set('áàéèóò'); // Usando caracteres explícitos
    const OPEN_FULL = new Set([...OPEN_PLAIN, ...OPEN_ACCENTED ]);
    const CLOSED_PLAIN = new Set('iu');
    const CLOSED_ACCENTED = new Set('íìúù'); // Usando caracteres explícitos

    const BEFORE_L_GROUP = new Set('bvckfgpt');
    const BEFORE_R_GROUP = new Set('bvcdkfgpt');
    const FOREIGN_GROUP = new Set('slrnc');
    const CONSONANT_PAIRS =
      new Set([ 'pt', 'ct', 'cn', 'ps', 'mn', 'gn', 'ft', 'pn', 'cz', 'ts' ]);

    function isConsonant(character)
    {
        return character && !VOWEL_SET.has(character);
    }

    //********************************************************************************************************************************
    // Nombre: onsetPy (Acá se procesa el ataque de la sílaba)
    // Descripción: Parte de la adaptación del código Python para
    // silabificación. Acá se procesa la parte inicial consonántica (ataque) de
    // una sílaba en una palabra dada, empezando desde la posición 'pos'.
    //********************************************************************************************************************************
    function onsetPy(word, pos)
    {
        let last_consonant = 'a'; // Equivalente a u'a'
        const length = word.length;

        while (pos < length && isConsonant(word[pos]) &&
               word[pos].toLowerCase() !== 'y')
        {
            last_consonant = word[pos];
            pos += 1;
        }

        if (length <= pos)
        {
            return pos;
        }

        const c1 = word[pos].toLowerCase();
        if (pos < length - 1)
        {
            if (c1 === 'u')
            {
                if (last_consonant === 'q')
                {
                    pos += 1;
                }
                else if (last_consonant === 'g')
                {
                    const c2 = word[pos + 1].toLowerCase();
                    if ('eéií'.includes(c2))
                    { // Simple string check for these vowels
                        pos += 1;
                    }
                }
            }
            else if (c1 === 'ü' && last_consonant === 'g')
            {
                pos += 1;
            }
        }
        return pos;
    }

    function nucleusPy(word, pos)
    {
        let previous = 0;
        const length = word.length;
        let stress_found = false;

        if (pos >= length)
        {
            return { pos : pos, stress_found : stress_found };
        }

        if (word[pos].toLowerCase() === 'y')
        {
            pos += 1;
        }

        if (pos < length)
        {
            const cr = word[pos].toLowerCase();
            if (OPEN_ACCENTED.has(cr))
            {
                stress_found = true;
                previous = 0;
                pos += 1;
            }
            else if (OPEN_PLAIN.has(cr))
            {
                previous = 0;
                pos += 1;
            }
            else if (CLOSED_ACCENTED.has(cr) || cr === 'ü')
            {
                stress_found = true;
                return { pos : pos + 1, stress_found : stress_found };
            }
            else if (CLOSED_PLAIN.has(cr))
            {
                previous = 2;
                pos += 1;
            }
        }

        let aitch = false;
        if (pos < length)
        {
            const cr = word[pos].toLowerCase();
            if (cr === 'h')
            {
                pos += 1;
                aitch = true;
            }
        }

        if (pos < length)
        {
            const cr = word[pos].toLowerCase();
            if (OPEN_FULL.has(cr))
            {
                if (OPEN_ACCENTED.has(cr))
                {
                    stress_found = true;
                }
                if (previous === 0)
                {
                    if (aitch)
                    {
                        pos -= 1;
                    }
                    return { pos : pos, stress_found : stress_found };
                }
                else
                {
                    pos += 1;
                }
            }
            else if (CLOSED_ACCENTED.has(cr))
            {
                stress_found = true;
                if (previous !== 0)
                {
                    pos += 1;
                }
                else if (aitch)
                {
                    pos -= 1;
                }
                return { pos : pos, stress_found : stress_found };
            }
            else if (CLOSED_PLAIN.has(cr) || cr === 'ü')
            {
                if (pos < length - 1)
                {
                    const cr_next = word[pos + 1].toLowerCase();
                    if (!isConsonant(cr_next))
                    {
                        if (pos > 0 && word[pos - 1].toLowerCase() === 'h')
                        {
                            pos -= 1;
                        }
                        return { pos : pos, stress_found : stress_found };
                    }
                }
                // Check needed: word[pos-1] might be out of bounds if pos=0
                if (pos > 0 &&
                    word[pos].toLowerCase() !== word[pos - 1].toLowerCase())
                {
                    pos += 1;
                }
                else if (pos === 0)
                { // If it's the first char, still might need to advance
                    pos += 1;
                }

                return { pos : pos, stress_found : stress_found };
            }
        }

        if (pos < length)
        {
            if (CLOSED_PLAIN.has(word[pos].toLowerCase()))
            {
                return { pos : pos + 1, stress_found : stress_found };
            }
        }

        return { pos : pos, stress_found : stress_found };
    }

    function codaPy(word, pos)
    {
        const length = word.length;
        if (pos >= length || !isConsonant(word[pos]))
        {
            return pos;
        }
        else if (pos === length - 1)
        {
            return pos + 1;
        }

        const c1 = word[pos].toLowerCase();
        const c2 = word[pos + 1].toLowerCase();
        if (!isConsonant(c2))
        {
            // Specific check for 'y' acting as vowel sound after consonant
            if (c2 === 'y' && pos + 1 === length - 1)
                return pos; // e.g., rey -> keep c1 with nucleus
            return pos;     // Only one consonant in coda
        }

        if (pos < length - 2)
        {
            const c3 = word[pos + 2].toLowerCase();

            if (!isConsonant(c3))
            { // Pattern C-C-V
                if (c1 === 'l' && c2 === 'l')
                    return pos;
                if (c1 === 'c' && c2 === 'h')
                    return pos;
                if (c1 === 'r' && c2 === 'r')
                    return pos;
                if (c1 !== 's' && c1 !== 'r' && c2 === 'h')
                    return pos;
                if (c2 === 'y')
                    return FOREIGN_GROUP.has(c1) ? pos : pos + 1;
                if (BEFORE_L_GROUP.has(c1) && c2 === 'l')
                    return pos;
                if (BEFORE_R_GROUP.has(c1) && c2 === 'r')
                    return pos;
                return pos + 1; // Split C1 | C2-V
            }
            else
            { // Pattern C-C-C...
                if (pos + 3 === length)
                { // Ends in CCC
                    if (c2 === 'y')
                        return FOREIGN_GROUP.has(c1) ? pos : pos + 1;
                    if (c3 === 'y')
                        return pos + 1; // Ends V-CCy -> VC | Cy
                    return pos + 3;     // Ends V-CCC -> VCCC |
                }
                // Check next char after CCC
                const c4 = word[pos + 3].toLowerCase();
                if (!isConsonant(c4))
                { // Pattern C-C-C-V
                    if (c2 === 'y')
                        return FOREIGN_GROUP.has(c1) ? pos : pos + 1;
                    if (CONSONANT_PAIRS.has(word.substring(pos + 1, pos + 3)))
                        return pos + 1; // V-C | CCV (pair starts syllable)
                    if ((c3 === 'l' || c3 === 'r') ||
                        (c2 === 'c' && c3 === 'h') || (c3 === 'y'))
                        return pos +
                               1;   // V-C | CCV (blend/ch/y starts syllable)
                    return pos + 2; // V-CC | CV
                }
                else
                {
                    // Pattern C-C-C-C... very rare, assume split after C2 for
                    // simplicity
                    return pos + 2;
                }
            }
        }
        else
        { // Pattern C-C at end of word
            if (c2 === 'y')
                return pos; // Treat final 'y' like a vowel sound attached to C1
            return pos + 2; // Ends in CC
        }
    }

    function obtenerSilabasAproximadas(word)
    {
        if (!word || typeof word !== 'string')
            return [];
        word = word.normalize("NFC"); // Keep NFC normalization

        let pos = 0;
        const positions = [];
        const length = word.length;
        // let stress_found = false; // Ignored for now
        // let stressed = 0; // Ignored for now

        while (pos < length)
        {
            positions.push(pos);
            pos = onsetPy(word, pos);
            let nucleusResult = nucleusPy(word, pos);
            pos = nucleusResult.pos;
            // stress_found = nucleusResult.stress_found; // Ignored
            pos = codaPy(word, pos);

            // Stress logic from Python ignored for now
        }
        positions.push(length);

        const syllabes = [];
        for (let i = 0; i < positions.length - 1; i++)
        {
            // Avoid creating empty syllables if positions are duplicated (can
            // happen with complex logic)
            if (positions[i + 1] > positions[i])
            {
                syllabes.push(word.substring(positions[i], positions[i + 1]));
            }
        }

        // Filter out potential empty strings just in case
        return syllabes.filter(s => s.length > 0);
    }

    // --- Fin: Adaptación del código Python ---

    //***********************************************************************************************************
    // Nombre: detectarTilde
    // Descripción: Analiza tildes, clasifica y valida acentuación básica
    // (simplificado). Versión final revisada para asegurar definición de
    // variables y lógica de validación.
    //***********************************************************************************************************
    function detectarTilde(palabra)
    {
        // 1. Normalizar la palabra original primero
        palabra = palabra.normalize("NFC");

        // 2. Limpiar puntuación final común ANTES de cualquier validación
        const palabraLimpia =
          palabra.replace(/[.,;:)]+$/, ''); // Añadido ')' a la regex

        // 3. Validación inicial (AHORA usa palabraLimpia después de declararla)
        if (!palabraLimpia || typeof palabraLimpia !== "string" ||
            palabraLimpia.trim().length === 0)
        {
            // Retornar basado en la entrada original si es necesario
            return {
                tieneTilde : false,
                tipo : null,
                silabaTildada : null,
                esValida : true // Considerar palabra vacía o inválida como
                                // "válida" para no marcarla como error
            };
        }

        // --- Resto de la lógica usando palabraLimpia ---

        // Definiciones de vocales con y sin tilde
        const vocalesConTilde = "áéíóúÁÉÍÓÚ";
        const vocales = "aeiouáéíóúÁÉÍÓÚ";

        // Información sobre la última letra para reglas de acentuación
        const ultimaLetra = palabraLimpia
                              .slice(-1) // <-- Usar palabraLimpia
                              .normalize("NFD")
                              .replace(/[\u0300-\u036f]/g, "")
                              .toLowerCase();
        const terminaEnVocalNS =
          [ 'a', 'e', 'i', 'o', 'u', 'n', 's' ].includes(ultimaLetra);

        // Obtiene una aproximación de las sílabas 
        
        const silabas =
          obtenerSilabasAproximadas(palabraLimpia); // <-- Usar palabraLimpia
        const totalSilabas = silabas.length;

        // Detecta si hay tilde gráfica y en qué sílaba
        let silabaTildadaIndex = -1; // Índice base 0
        for (let i = 0; i < silabas.length; i++)
        {
            for (let char of silabas[i])
            {
                // Busca si el carácter es una vocal tildada (usando la variable
                // definida)
                if (vocalesConTilde.includes(char))
                {
                    silabaTildadaIndex = i;
                    break; // Tilde encontrada
                }
            }
            if (silabaTildadaIndex !== -1)
                break; // Tilde encontrada en alguna sílaba
        }

        const tieneTilde = silabaTildadaIndex !== -1;
        let tipo = null;      // Tipo de palabra (aguda, grave, etc.)
        let esValida = false; // Indica si cumple reglas de acentuación
        let silabaTildadaOrdinal = null; // Número ordinal (base 1)

        if (tieneTilde)
        {
            // Si tiene tilde, el tipo y la validación se basan en su posición.
            silabaTildadaOrdinal = silabaTildadaIndex + 1;
            const posicionDesdeFinal =
              totalSilabas -
              silabaTildadaOrdinal; // 0=última, 1=penúltima, etc.

            // *** NUEVO: Chequeo prioritario para hiato acentual con vocal abierta ***
            const silabaConTilde = silabas[silabaTildadaIndex];
            let caracterTildado = '';
            for (let char of silabaConTilde) {
                if (vocalesConTilde.includes(char)) {
                    caracterTildado = char;
                    break;
                }
            }

            // Verificación de hiato con vocal débil acentuada (í, ú)
            const hiatoConVocalDebil = () => {
                if (!tieneTilde || silabaTildadaIndex === -1)
                    return false;
                const silaba = silabas[silabaTildadaIndex];
                const vocalDebilTildada =
                  /[íúÍÚ]/i.test(silaba); // Incluir mayúsculas
                if (!vocalDebilTildada)
                    return false;
                const anterior = silabas[silabaTildadaIndex - 1] || "";
                const siguiente = silabas[silabaTildadaIndex + 1] || "";
                const vocalFuerte = /[aeoáéó]/i;
                return vocalFuerte.test(anterior.slice(-1)) ||
                       vocalFuerte.test(siguiente);
            };

            // *** MODIFICADO: Si la tilde está en vocal abierta (á,é,ó), es válida (hiato) ***
            if (OPEN_ACCENTED.has(caracterTildado.toLowerCase())) // <-- Chequea si es á, é, ó
            {
                tipo = "hiato_abierta_acentuada"; // Nuevo tipo para claridad
                esValida = true; // Tilde en vocal abierta para hiato es válida
            }
            else if (hiatoConVocalDebil()) // <-- Ahora es else if
            {
                tipo = "hiato_debil_acentuada"; // Podría ser grave o aguda
                                                // dependiendo de la estructura
                esValida = true; // Hiato con tilde en débil siempre es válido
            }
            else if (totalSilabas === 1)
            {
                tipo = "monosílaba";
                esValida =
                  true; // Monosílabos con tilde (diacrítica) asumidos válidos.
            }
            else if (posicionDesdeFinal === 0)
            {
                tipo = "aguda";
                esValida = terminaEnVocalNS; // Aguda con tilde es válida si
                                             // termina en vocal/n/s
            }
            else if (posicionDesdeFinal === 1)
            {
                tipo = "grave";
                esValida = !terminaEnVocalNS; // Grave con tilde es válida si NO
                                              // termina en vocal/n/s
            }
            else if (posicionDesdeFinal === 2)
            {
                tipo = "esdrújula";
                esValida = true; // Esdrújulas siempre llevan tilde y son
                                 // válidas si las tienen.
            }
            else
            { // posicionDesdeFinal >= 3
                tipo = "sobresdrújula";
                esValida = true; // Sobresdrújulas siempre llevan tilde y son
                                 // válidas si las tienen.
            }
        }
        else // No tiene tilde
        {
            silabaTildadaOrdinal = null; // No hay sílaba tildada

            if (totalSilabas <= 1)
            { // Considerar palabras de 0 o 1 sílaba como monosílabas
                tipo = "monosílaba";
                esValida = true; // Monosílabos sin tilde son válidos.
            }
            else
            {
                // Clasificar tipo por acento natural según terminación
                // *** Lógica corregida para determinar tipo sin tilde ***
                if (terminaEnVocalNS) { // Si termina en vocal, n, o s -> Naturalmente grave
                    tipo = "grave"; // Acento natural en penúltima
                }
                else
                {
                    tipo = "aguda"; // Acento natural en última
                }

                // *** NUEVO: Heurística para palabras terminadas en -ción/-sión ***
                // Si termina en 'cion' o 'sion' y no tiene tilde, es casi seguro que es aguda.
                const lowerPalabraLimpia = palabraLimpia.toLowerCase();
                if (lowerPalabraLimpia.endsWith('cion') || lowerPalabraLimpia.endsWith('sion')) {
                    tipo = "aguda"; // Corregir tipo a aguda
                }

                // Validar si DEBERÍA llevar tilde pero no la tiene
                if ((tipo === "aguda" && terminaEnVocalNS) ||
                    (tipo === "grave" && !terminaEnVocalNS))
                    // Ya no se asume esdrújula aquí, las esdrújulas SIEMPRE deben tener tilde gráfica
                {
                    esValida = false; // Debería llevar tilde pero no la tiene
                }
                else
                {
                    esValida = true; // Es válida sin tilde
                }
            }
        }

        // Validación extra: hiato obligatorio (palabras conocidas)
        if (!tieneTilde && esHiatoObligatorio(palabraLimpia))
        {
            tipo = "hiato_debil_acentuada_forzada";
            esValida = false;
        }

        if (!esValida)
        {
            // Usar 'palabra' (original con posible puntuación) para el log de
            // error
            console.log(`❌ Palabra: ${palabra}, Tipo: ${tipo}, Tilde en: ${
              silabaTildadaOrdinal ||
              'N/A'}, Total Sílabas: ${totalSilabas}, Es válida: ${esValida}`);
        }

        return {
            tieneTilde,
            tipo,
            silabaTildada : silabaTildadaOrdinal,
            esValida
        };
    }

    window.detectarTilde = detectarTilde;

    function validarReglasAcentuacion(tipo, ultimaLetra, tieneTilde)
    {
        if (tipo === "aguda")
        {
            // Agudas llevan tilde si terminan en vocal, "n" o "s"
            return (tieneTilde && /[nsaeiou]$/.test(ultimaLetra)) ||
                   (!tieneTilde && !/[nsaeiou]$/.test(ultimaLetra));
        }
        else if (tipo === "grave")
        {
            // Graves llevan tilde si NO terminan en vocal, "n" o "s"
            return (!tieneTilde && /[nsaeiou]$/.test(ultimaLetra)) ||
                   (tieneTilde && !/[nsaeiou]$/.test(ultimaLetra));
        }
        else if (tipo === "esdrújula")
        {
            // Esdrújulas siempre llevan tilde
            return tieneTilde;
        }
        return true; // Otros casos (monosílabos, sobresdrújulas, etc.)
    }

    // =======================
    // Normalización y gestión de sugerencias ortográficas
    // =======================

    // Esta función NO debe aplicar sugerencias API automáticamente
    function normalizePlaceName(originalName, placeId, callback)
    {
        // Aquí va la lógica de normalización local: tildes, diccionario, etc.
        // ...
        // Si se desea usar la API, sólo agregar sugerencias, NO reemplazar el
        // valor
        const useApi = document.getElementById("useSpellingAPI")?.checked;
        if (useApi)
        {
            checkSpellingWithAPI(originalName,
                                 activeDictionaryLang,
                                 placeId,
                                 function(apiSuggestions) {
                                     // Almacenar sugerencias API en el formato
                                     // requerido
                                     spellingApiSuggestions[placeId] =
                                       apiSuggestions.map(
                                         s => ({
                                             original : s.word,
                                             suggestion : s.suggestion,
                                             message : s.message,
                                             origin : "API"
                                         }));
                                     // No se hace ningún reemplazo aquí, sólo
                                     // se pasa la sugerencia al callback/panel
                                     if (typeof callback === "function")
                                         callback({
                                             normalized : originalName,
                                             apiSuggestions : apiSuggestions
                                         });
                                 });
        }
        else
        {
            // Normalización tradicional aquí (si aplica)
            if (typeof callback === "function")
                callback({ normalized : originalName, apiSuggestions : [] });
        }
    }

    // =======================
    // Panel flotante: renderizado de errores ortográficos y sugerencias API
    // =======================

    // Renderiza el panel flotante de errores ortográficos, incluyendo
    // sugerencias de origen API
    function openFloatingPanel(placesToNormalize)
    {
        // Cierra panel previo si existe
        const existingPanel =
          document.getElementById("normalizer-floating-panel");
        if (existingPanel)
        {
            existingPanel.remove();
        }

        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: 0; 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; vertical-align: top; }
            .normalize-btn, .apply-btn, .add-exclude-btn { padding: 8px 16px; 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; }
            .close-btn { position: absolute; top: 15px; right: 15px; background: #e74c3c; color: white; border: none; width: 30px; height: 30px; border-radius: 50%; font-weight: bold; cursor: pointer; z-index: 11; }
            input[type="checkbox"] { transform: scale(1.3); margin: 0 5px; }
            input[type="text"] { width: 100%; padding: 5px; box-sizing: border-box; }
        </style>
        <div style="padding: 20px;">
            <button class="close-btn" id="close-panel-btn">×</button>
            <h2 style="color: #2c3e50; margin-top: 0;">Normalizador de Nombres</h2>
            <table id="normalizer-table">
                <thead>
                    <tr>
                        <th>Aplicar</th>
                        <th>Eliminar</th>
                        <th>Nombre Actual</th>
                        <th>Nombre Normalizado</th>
                        <th>Problemas Detectados</th>
                        <th>Acciones</th>
                    </tr>
                </thead>
                <tbody>
    `;

        placesToNormalize.forEach((place, index) => {
            // Asegura incluir spellingWarnings y hasSpellingWarning desde
            // spellingApiSuggestions
            place.spellingWarnings =
              spellingApiSuggestions[place.placeId] || [];
            place.hasSpellingWarning = place.spellingWarnings.length > 0;
            const {
                originalName,
                newName,
                hasSpellingWarning,
                spellingWarnings = []
            } = place;

            html += `
            <tr>
                <td><input type="checkbox" class="normalize-checkbox" data-index="${
              index}"></td>
                <td><input type="checkbox" class="delete-checkbox" data-index="${
              index}"></td>
                <td>${originalName}</td>
                <td><input type="text" class="new-name-input" value="${
              newName}" data-index="${index}"></td>
                <td>${
              hasSpellingWarning ? `${spellingWarnings.length} errores`
                                 : "Ninguno"}</td>
                <td>
                    <button class="normalize-btn" data-index="${
              index}">Normalizar</button>
                    <button class="add-exclude-btn" data-word="${
              originalName}" data-index="${index}">Excluir</button>
                </td>
            </tr>
        `;

            if (spellingWarnings.length > 0)
            {
                html += `
                <tr>
                    <td colspan="6">
                        <details>
                            <summary>${
                  spellingWarnings.length} errores encontrados</summary>
                            <ul>
                                ${
                  spellingWarnings
                    .map(warning => `
                                    <li>
                                        Error en "${warning.original}": ${
                           warning.message}<br>
                                        Sugerencia: <strong>${
                           warning.suggestion}</strong>
                                        <button class="apply-suggestion-btn" data-index="${
                           index}" data-suggestion="${
                           warning.suggestion}">Aplicar</button>
                                    </li>
                                `)
                    .join('')}
                            </ul>
                        </details>
                    </td>
                </tr>
            `;
            }
        });

        html += `
                </tbody>
            </table>
            <div style="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</button>
                <button id="cancel-btn" style="background: #e74c3c; color: white; padding: 10px 20px; border: none; border-radius: 4px; font-weight: bold;">Cancelar</button>
            </div>
        </div>
    `;

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

        // Cerrar panel
        document.getElementById("close-panel-btn")
          .addEventListener("click", () => panel.remove());

        // Aplicar todos los cambios
        document.getElementById("apply-all-btn")
          .addEventListener("click", () => {
              const selectedChanges = placesToNormalize.filter((_, index) => {
                  const checkbox = panel.querySelector(
                    `.normalize-checkbox[data-index="${index}"]`);
                  return checkbox && checkbox.checked;
              });

              if (selectedChanges.length === 0)
              {
                  alert("No se seleccionaron cambios para aplicar.");
                  return;
              }

              console.log("Aplicando cambios:", selectedChanges);
              panel.remove();
          });

        // Cancelar
        document.getElementById("cancel-btn")
          .addEventListener("click", () => panel.remove());

        // Aplicar sugerencias
        panel.querySelectorAll(".apply-suggestion-btn").forEach(btn => {
            btn.addEventListener("click", function() {
                const index = this.dataset.index;
                const suggestion = this.dataset.suggestion;
                const input =
                  panel.querySelector(`.new-name-input[data-index="${index}"]`);
                if (input)
                {
                    input.value = suggestion;
                    const checkbox = panel.querySelector(
                      `.normalize-checkbox[data-index="${index}"]`);
                    if (checkbox)
                        checkbox.checked = true;
                }
            });
        });

        // Normalizar individualmente
        panel.querySelectorAll(".normalize-btn").forEach(btn => {
            btn.addEventListener("click", function() {
                const index = this.dataset.index;
                const input =
                  panel.querySelector(`.new-name-input[data-index="${index}"]`);
                if (input)
                {
                    input.value = input.value.trim()
                                    .toUpperCase(); // Ejemplo de normalización
                    const checkbox = panel.querySelector(
                      `.normalize-checkbox[data-index="${index}"]`);
                    if (checkbox)
                        checkbox.checked = true;
                }
            });
        });

        // Excluir palabras
        panel.querySelectorAll(".add-exclude-btn").forEach(btn => {
            btn.addEventListener("click", function() {
                const word = this.dataset.word;
                console.log(`Palabra excluida: ${word}`);
            });
        });
    }

    //********************************************************************************************************************************
    // Nombre: showNoPlacesFoundMessage (Acá se muestra un mensaje si no se
    // encuentran lugares) Fecha de modificación: 2025-04-10 Autor: mincho77
    // Entradas: Nada
    // Salidas: Nada. Se crea un modal que informa que no se encontraron lugares
    // con los criterios actuales. Descripción: Muestra un mensaje modal cuando
    // la búsqueda de lugares no devuelve resultados.
    //              Incluye un botón para cerrar el modal. Sirve para informar
    //              al usuario.
    //********************************************************************************************************************************
    function showNoPlacesFoundMessage()
    { // Se crea el elemento div para 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>
      `;
        // Se agrega el modal al cuerpo del documento
        document.body.appendChild(modal);
        // Se maneja el evento de clic en el botón para cerrar el modal
        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 (Acá se muestra un modal personalizado)
    // Fecha de modificación: 2025-04-10
    // Autor: mincho77
    // Entradas:
    // - title (string): Título del modal.
    // - message (string): Mensaje a mostrar.
    // - confirmText (string): Texto del botón de confirmar.
    // - cancelText (string): Texto del botón de cancelar.
    // - onConfirm (function): Qué hacer al confirmar.
    // - onCancel (function): Qué hacer al cancelar.
    // - type (string): Tipo de modal (info, error, warning, question, success).
    // - autoClose (number): Tiempo en ms para que se cierre solo.
    // - prependText (string): Texto extra antes del mensaje.
    // Salidas: Nada. Se crea un modal personalizado con título, mensaje y
    // botones. 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 = "",
    })
    {
        // Se determina el ícono según el tipo de modal
        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);

        // Se crea el elemento div para 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>
        `;

        // Se agrega el modal al cuerpo del documento
        document.body.appendChild(modal);

        // Se manejan los eventos de clic en 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
              });
        }

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

        // Se cierra automáticamente si se especificó un tiempo en 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 (Acá se abre un popup para editar una palabra)
    // Fecha de 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: Nada. Se abre un popup para editar una palabra en la lista
    // especificada. Descripción: Esta función abre un popup que permite al
    // usuario modificar una palabra de la lista (ya sea de las excluidas o del
    // diccionario). Se valida que la palabra no esté vacía y que no sea
    // duplicada. o su diccionario ortográfico personalizado.
    //********************************************************************************************************************************
    function openEditPopup(index, listType = "excludeWords")
    {
        const wordList =
          listType === "dictionaryWords" ? dictionaryWords : excludeWords;
        const wordToEdit = wordList[index];
        if (!wordToEdit)
        {
            // Se muestra un error si no se encuentra la palabra en el índice
            // dado
            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 =
                  // Se obtiene el nuevo valor del input
                  document.getElementById("editWordInput").value.trim();
                if (!newWord)
                {
                    showModal({
                        title : "Error",
                        message : "La palabra no puede estar vacía.",
                        confirmText : "Aceptar",
                        type : "error"
                    });
                    return;
                }

                // Se verifica si la nueva palabra ya existe en la lista (y no
                // es la misma palabra original)
                if (wordList.includes(newWord) && wordList[index] !== newWord)
                {
                    showModal({
                        title : "Duplicada",
                        message : "Esa palabra ya está en la lista.",
                        confirmText : "Aceptar",
                        type : "warning"
                    });
                    return;
                }

                // Se actualiza la palabra en la lista correspondiente
                wordList[index] = newWord;
                if (listType === "dictionaryWords")
                {
                    // *** Lógica corregida para guardar en el diccionario estructurado ***
                    const originalWord = wordToEdit; // Palabra antes de editar
                    const currentDictLang = spellDictionaries[activeDictionaryLang];

                    // 1. Eliminar la palabra original de la estructura
                    const originalLetter = originalWord.charAt(0).toLowerCase();
                    if (currentDictLang && currentDictLang[originalLetter]) {
                        const originalIndexInLetter = currentDictLang[originalLetter].indexOf(originalWord);
                        if (originalIndexInLetter > -1) {
                            currentDictLang[originalLetter].splice(originalIndexInLetter, 1);
                            // Si la letra queda vacía, eliminarla
                            if (currentDictLang[originalLetter].length === 0) {
                                delete currentDictLang[originalLetter];
                            }
                        }
                    }

                    // 2. Agregar la nueva palabra a la estructura
                    const newLetter = newWord.charAt(0).toLowerCase();
                    if (!currentDictLang[newLetter]) {
                        currentDictLang[newLetter] = [];
                    }
                    if (!currentDictLang[newLetter].includes(newWord)) { // Evitar duplicados al agregar
                        currentDictLang[newLetter].push(newWord);
                        currentDictLang[newLetter].sort(); // Mantener ordenado
                    }

                    // 3. Guardar la estructura actualizada en localStorage con la clave correcta
                    localStorage.setItem(`spellDictionaries_${activeDictionaryLang}`, JSON.stringify(currentDictLang));
                    // 4. Actualizar la lista plana global
                    dictionaryWords = Object.values(currentDictLang).flat().sort();
                    renderDictionaryWordsPanel(); // Re-renderizar el panel
                }
                else
                {
                    wordLists.excludeWords = excludeWords;
                    localStorage.setItem("excludeWords",
                                         JSON.stringify(excludeWords));
                    renderExcludedWordsPanel();
                }

                // Se muestra un mensaje de éxito
                showModal({
                    title : "Actualizada",
                    message : "La palabra fue modificada correctamente.",
                    confirmText : "Aceptar",
                    type : "success",
                    autoClose : 2000
                });
            }
        });
    }

    //***********************************************************************************************************************************************************
    // Nombre: waitForElement (Acá se espera a que un elemento aparezca en el
    // DOM) Fecha de modificación: 2025-04-10 Autor: mincho77 Entradas:
    // - selector (string): El selector CSS del elemento que se espera.
    // - callback (function): Función que se ejecuta cuando se encuentra el
    // elemento.
    // - interval (number, opcional): Tiempo en ms entre cada intento (defecto:
    // 300ms).
    // - maxAttempts (number, opcional): Número máximo de intentos (defecto:
    // 20). Salidas: Nada. Se ejecuta el callback con el elemento encontrado o
    // se muestra una advertencia si no se encuentra. Descripción: Esta función
    // espera a que un elemento (definido por un selector CSS) aparezca en el
    // DOM. Se revisa cada cierto tiempo (interval) hasta un número máximo de
    // veces (maxAttempts). Si se encuentra el elemento, se llama a la función
    // 'callback' pasándole 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(() => { // Se crea un intervalo para revisar
              const element = document.querySelector(selector);
              attempts++;
              if (element)
              { // Si se encuentra el elemento
                  clearInterval(checkExist);
                  callback(element);
              }
              else if (attempts >= maxAttempts)
              { // Si se supera el número máximo de intentos
                  clearInterval(checkExist); // Se detiene la búsqueda
                  console.warn(`No se encontró el elemento ${
                    selector} después de ${maxAttempts} intentos.`);
              }
          }, interval);
    }

    //********************************************************************************************************************************
    // Nombre: handleLanguageChange (Acá se maneja el cambio de idioma)
    // Fecha de modificación: 2025-05-02
    // Hora: 06:45
    // Autor: mincho77
    // Entradas: Event (opcional) - El evento 'change' del selector.
    // Descripción: Maneja la lógica cuando el usuario cambia el idioma en el
    // selector.
    //********************************************************************************************************************************
    function handleLanguageChange()
    {
        const selector = document.getElementById("dictionaryLanguageSelect");
        if (!selector)
            return; // Salir si el selector no existe

        // Se guarda el estado de normalizeArticles específico para español
        if (activeDictionaryLang === 'SP')
        {
            localStorage.setItem('normalizeArticles_SP',
                                 JSON.stringify(normalizeArticles));
        }

        // Forzar normalizeArticles a false si el idioma es inglés
        if (activeDictionaryLang === 'EN')
        {
            normalizeArticles = false;
        }

        const previousLang = activeDictionaryLang;
        const newLang = selector.value;

        if (previousLang &&
            spellDictionaries[previousLang]) // Si había un idioma anterior y
                                             // diccionario
        {
            // Guardar el diccionario del idioma anterior
            localStorage.setItem(
              `spellDictionaries_${previousLang}`,
              JSON.stringify(spellDictionaries[previousLang]));
            // Guardar estado de normalizeArticles si era español
            if (previousLang === 'SP')
            {
                localStorage.setItem('normalizeArticles_SP',
                                     JSON.stringify(normalizeArticles));
            }
        }

        activeDictionaryLang = newLang; // Se actualiza el idioma activo
        localStorage.setItem("activeDictionaryLang", activeDictionaryLang);

        // Se carga el diccionario del nuevo idioma desde localStorage
        const storedDictionary = JSON.parse(
          localStorage.getItem(`spellDictionaries_${activeDictionaryLang}`));
        // Si hay diccionario guardado, se usa
        if (storedDictionary)
        {
            spellDictionaries[activeDictionaryLang] = storedDictionary;
        }
        else if (!spellDictionaries[activeDictionaryLang] ||
                 Object.keys(spellDictionaries[activeDictionaryLang]).length ===
                   0)
        { // Si no hay diccionario guardado o está vacío, se usan los valores
          // por defecto
            if (activeDictionaryLang === "SP")
            {
                spellDictionaries.SP = { a : [ "árbol" ], b : [ "barco" ] };
            }
            else if (activeDictionaryLang === "EN")
            {
                spellDictionaries.EN = { a : [ "apple" ], b : [ "boat" ] };
            }
            // Se guarda el diccionario por defecto en localStorage
            localStorage.setItem(
              `spellDictionaries_${activeDictionaryLang}`,
              JSON.stringify(spellDictionaries[activeDictionaryLang]));
        }
        // Se actualiza la lista plana de palabras del diccionario
        dictionaryWords = // Se actualiza la lista plana de palabras del
                          // diccionario
          Object.values(spellDictionaries[activeDictionaryLang]).flat().sort();

        // Actualizar estado de normalizeArticles según el nuevo idioma
        if (activeDictionaryLang === 'EN')
        {
            normalizeArticles = false; // Desactivar para inglés
        }
        else
        {
            // Cargar estado guardado para español o usar default
            normalizeArticles =
              localStorage.getItem('normalizeArticles_SP') !== null
                ? JSON.parse(localStorage.getItem('normalizeArticles_SP'))
                : true;
        }

        // --- INICIO: Se refresca la interfaz sin volver a registrar la pestaña
        // ---
        const pane =
          document.getElementById("normalizer-tab-pane"); // Usar el ID asignado
        if (pane)
        {
            pane.innerHTML =
              getSidebarHTML(); // Regenerar contenido con nuevo idioma
            // listeners
            waitForElement("#normalizeArticles", () => {
                console.log(`[${
                  SCRIPT_NAME}] ✅ Refreshing sidebar events after lang change`);
                attachEvents();               // Re-adjunta listeners generales
                initSearchSpecialWords();     // Re-inicializa búsqueda palabras
                                              // especiales
                renderDictionaryWordsPanel(); // Re-renderiza panel diccionario
                attachDictionarySearch();     // Re-adjunta búsqueda diccionario
                // Se asegura que el dropdown muestre el idioma correcto Y SE
                // VUELVE A ADJUNTAR EL LISTENER
                waitForElement("#dictionaryLanguageSelect", (sel) => {
                    sel.value = activeDictionaryLang;
                    attachLanguageChangeListener(); 
                });
                attachDetailsToggleListeners(); 
            });
        }
        else
        {
            console.error(
              "No se encontró el panel de la pestaña para refrescar.");
        }
        // --- FIN: Se refresca la interfaz ---
        // console.log("Idioma activo:", activeDictionaryLang);
        console.log("Datos del diccionario:",
                    spellDictionaries[activeDictionaryLang]);
    }
    //***********************************************************************************************************************************************************
    // Nombre: attachLanguageChangeListener (Acá se adjunta el listener para el
    // cambio de idioma) Fecha de modificación: 2025-07-27 Autor: mincho77
    // Entradas: Ninguna
    // Salidas: Ninguna.
    // Descripción: Busca el selector de idioma y le adjunta el listener
    // 'handleLanguageChange'.
    //***********************************************************************************************************************************************************
    function attachLanguageChangeListener()
    {
        const selector = document.getElementById("dictionaryLanguageSelect");
        if (selector)
        {
            // Remover listener anterior por si acaso (evita duplicados si algo
            // sale mal) // Se quita el listener anterior por si acaso
            selector.removeEventListener('change', handleLanguageChange);
            selector.addEventListener(
              'change', handleLanguageChange); // Se adjunta el listener
            console.log(`[${SCRIPT_NAME}] Language change listener attached.`);
        }
        else
        {
            console.error(
              "attachLanguageChangeListener: No se encontró #dictionaryLanguageSelect.");
        }
    }
    //***********************************************************************************************************************************************************
    // Nombre: attachDetailsToggleListeners (Acá se conectan los listeners para
    // desplegar detalles)
    // Fecha modificación: 2025-07-27
    // Autor: mincho77
    // Entradas: Ninguna
    // Salidas: Ninguna.
    // Descripción: Adjunta los listeners 'toggle' a los elementos <details>
    // para animar las flechas de despliegue.
    //***********************************************************************************************************************************************************
    function attachDetailsToggleListeners()
    {
        waitForElement("#details-special-words", (detailsElem) => {
            const arrow = document.getElementById("arrow");
            if (detailsElem && arrow)
            {
                // Remover listener anterior por si acaso
                detailsElem.removeEventListener("toggle", toggleArrowRotation);
                // Adjuntar nuevo listener
                detailsElem.addEventListener("toggle", toggleArrowRotation);
            }
        });

        waitForElement("#details-dictionary-words", (detailsElem) => {
            const arrow = document.getElementById("arrow-dic");
            if (detailsElem && arrow)
            {
                // Remover listener anterior por si acaso
                detailsElem.removeEventListener("toggle", toggleArrowRotation);
                // Adjuntar nuevo listener
                detailsElem.addEventListener("toggle", toggleArrowRotation);
            }
        });
        console.log(`[${SCRIPT_NAME}] Details toggle listeners attached.`);
    }

    //***********************************************************************************************************************************************************
    // Nombre: toggleArrowRotation (Acá se rota la flecha de despliegue)
    // Fecha modificación: 2025-05-01 06:00
    // Autor: mincho77
    // Entradas: Ninguna
    // Salidas: Ninguna. Rota la flecha de despliegue según el estado del
    // elemento <details>. Descripción: Esta función rota la flecha de
    // despliegue según el estado del elemento <details> al que está asociada.
    // Si el elemento está abierto, la flecha se rota 90 grados; si está
    // cerrado, se rota a su posición original. Se utiliza para proporcionar una
    // indicación visual del estado del elemento <details> al usuario. La
    // función se adjunta como un event listener al evento 'toggle' del elemento
    // <details>.
    //***********************************************************************************************************************************************************
    function toggleArrowRotation()
    { // 'this' se refiere al elemento <details>
        const arrowId =
          this.id === 'details-special-words' ? 'arrow' : 'arrow-dic';
        const arrow = document.getElementById(arrowId);
        if (arrow)
        {
            arrow.style.transform =
              this.open ? "rotate(90deg)" : "rotate(0deg)";
        }
    }

    //***********************************************************************************************************************************************************
    // 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> ${
          S('dictionaryLabel')}
                </summary>

         

                <!-- Buscar palabra -->
                <div style="margin-top: 10px;">
                    <input type="text" id="searchDictionaryWord" placeholder="${
          S('searchDictionaryPlaceholder')}" 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">${
          S('exportDictionaryButton')}</button>
                    <button id="importDictionaryBtn">${
          S('importDictionaryButton')}</button>
                    <button id="clear-dictionary-btn" style="margin-left: 10px;">${
          S('clearDictionaryButton')}</button>
                    <input type="file" id="hiddenImportDictionaryInput" accept=".xml" style="display: none;">
                </div>

                <!-- Drag & Drop Diccionario-->
                <div id="dictionary-drop-zone" style="border: 2px dashed #ccc; padding: 10px; margin: 10px;">
                    ${S('dictionaryDropZoneText')}
                </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>

            <!-- Selector de idioma (Movido aquí) -->
            <div style="margin-top: 10px;">
                <label for="dictionaryLanguageSelect"><b>${
          S('activeLangLabel')}</b></label>
                <select id="dictionaryLanguageSelect" style="width: 100%; margin-top: 5px; padding: 4px;">
                    <option value="SP">Español 🇪🇸</option>
                    <option value="EN">English 🇬🇧</option>
                </select>
            </div>
            <!-- No Normalizar artículos -->
            <div style="margin-top: 15px; display: ${
          activeDictionaryLang === 'EN' ? 'none' : 'block'};">
                <input type="checkbox" id="normalizeArticles" ${
          normalizeArticles ? "checked" : ""}>
                <label for="normalizeArticles">${
          S('normalizeArticlesLabel')}</label>
            </div>
            <div style="margin-top: 15px;">
                <input type="checkbox" id="useSpellingAPI">
                <label for="useSpellingAPI">${S('useApiLabel')}</label>
            </div>
            <!-- Máximo de Places a buscar -->
            <div style="margin-top: 15px;">
                <label>${S('maxPlacesLabel')} </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> ${
          S('specialWordsLabel')}
                </summary>
                <div style="margin-top: 10px; display: flex; gap: 5px;">
                  <input type="text" id="excludeWord" placeholder="${
          S('addWordPlaceholder')}" 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;">${
          S('addButton')}</button>
                </div>
                <div style="margin-top: 10px; display: flex; gap: 5px;">
                   <input type="text" id="searchWord" placeholder="${
          S('searchWordPlaceholder')}" 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;">${
          S('exportWordsButton')}</button>
                <button id="importExcludeWordsUnifiedBtn" style="margin-top: 5px;">${
          S('importListButton')}</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">${
          S('replaceListLabel')}</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;">
                   ${S('dictionaryDropZoneText')}
                </div>
                     ${S('dropZoneText')}
                </div>
            </details>
            <hr>
            <!-- Sección de Diccionario Ortográfico -->
            ${renderSpellDictionaryPanel()}
            <hr>
            <!-- Botón Scan -->
          <button id="scanPlaces">${S('scanButton')}</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;">
           ${S('clearSpecialWordsButton')}
        </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;
            // Guardar preferencia solo si es español
            if (activeDictionaryLang === 'SP')
            {
                localStorage.setItem('normalizeArticles_SP',
                                     JSON.stringify(normalizeArticles));
            }
        });
        // 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;
            }

            let registration;
            const tabId = "normalizer-tab-pane"; // Usar un ID consistente
                                                 // para el CONTENEDOR

            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) // tabPane es el div contenedor
            {
                throw new Error(
                  "Tab registration failed to return required elements");
            }

            // Limpiar el contenido anterior del panel ANTES de añadir el
            // nuevo NO eliminar el tabPane en sí.
            tabPane.innerHTML = '';

            // 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
          `;
            tabPane.id = tabId; // Asignar ID al CONTENEDOR (tabPane)
            // Inyectar HTML del panel
            tabPane.innerHTML = getSidebarHTML();

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

                // Activar búsqueda para palabras especiales
                initSearchSpecialWords();     // Mover aquí dentro
                renderDictionaryWordsPanel(); // Renderizar diccionario
                                              // inicial
                attachDictionarySearch();     // Adjuntar búsqueda diccionario
                                              // inicial

                // Mover la configuración del selector aquí, después de que
                // el HTML se inyecta
                waitForElement("#dictionaryLanguageSelect", (selectorElem) => {
                    console.log(
                      `[${SCRIPT_NAME}] Attaching language change listener`);
                    // El elemento ya está disponible como 'selectorElem'
                    selectorElem.value =
                      activeDictionaryLang;         // Asegura valor inicial
                    attachLanguageChangeListener(); // Llamar a la función
                                                    // que adjunta el
                                                    // listener
                });
            });
            // waitForElement("#normalizeArticles", ...)
            // Exponer depuración por consola
            unsafeWindow.debugDictionaries = function() {
                // Renderizar el panel del diccionario después de que el HTML
                // esté listo
                renderDictionaryWordsPanel();
                attachDictionarySearch();
                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" },
                // Usar el idioma activo para la API
                data : `language=${getApiLangCode(activeDictionaryLang)}&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: mostrarErroresOrtograficosEnPanel
    // Fecha modificación: 2025-05-03
    // Hora: 16:33
    // Autor: mincho77
    // Entradas:
    // - errores (Array): Lista de errores con palabra, sugerencia, tipo y
    // severidad Salidas: Ninguna Descripción: Muestra los errores ortográficos
    // en el panel flotante, incluyendo sugerencias desde API o reglas locales
    //********************************************************************************************************************************
    function mostrarErroresOrtograficosEnPanel(errores)
    {
        if (!Array.isArray(errores) || errores.length === 0)
            return;

        errores.forEach((error) => {
            const row = document.querySelector(
              `.normalizer-row[data-original="${error.palabra}"]`);

            if (row)
            {
                const warningIcon = row.querySelector(".warning-icon");
                const suggestionsDropdown =
                  row.querySelector(".suggestions-dropdown");

                if (warningIcon)
                {
                    warningIcon.classList.remove("hidden");
                    warningIcon.title =
                      `Error (${error.tipo}) detectado: ${error.palabra}`;
                }

                if (suggestionsDropdown && error.sugerencia)
                {
                    const option = document.createElement("option");
                    option.value = error.sugerencia;
                    option.textContent = `🔁 ${error.sugerencia} (API)`;
                    suggestionsDropdown.appendChild(option);
                }
            }
            else
            {
                console.warn(
                  `❗ No se encontró fila para la palabra: ${error.palabra}`);
            }
        });
    }

    //********************************************************************************************************************************
    // 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 : texto,
            errores : [],
            metadata :
              { totalErrores : 0, apiUsada : false, tiempoProcesamiento : 0 }
        };

        // 1. Normalización básica inicial
        const normalizado = await normalizePlaceName(texto, true);
        // const normalizado = await normalizePlaceName(texto, placeId); // <--
        // Pasar placeId
        resultadoBase.normalizado =
          normalizado; // <-- Usar el resultado de normalizePlaceName

        // 2. Revisión por palabras especiales antes de hacer cualquier otra
        // cosa
        const originalWords = texto.split(/\s+/);
        const especialesLower = wordLists.excludeWords.map(
          w =>
            w.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase());
        for (let i = 0; i < originalWords.length; i++)
        {
            const palabraOriginal = originalWords[i];
            const palabraNormalizada = palabraOriginal.normalize("NFD")
                                         .replace(/[\u0300-\u036f]/g, "")
                                         .toLowerCase();

            const index =
              especialesLower.findIndex(w => w === palabraNormalizada);
            if (index !== -1)
            {
                resultadoBase.normalizado = resultadoBase.normalizado.replace(
                  new RegExp(`\\b${palabraOriginal}\\b`, "g"),
                  wordLists.excludeWords[index]);
            }
        }

        // 3. Detección de errores locales (tilde y ortografía)
        if (config.reglasLocales !== false)
        {
            const erroresLocales = detectarErroresLocales(
              texto,
              resultadoBase.normalizado); // <-- Pasar original y normalizado

            resultadoBase.errores.push(...erroresLocales);

            const palabrasNoValidas =
              erroresLocales.filter(e => e.tipo === "tilde" && e.sugerencia &&
                                         e.sugerencia.length > 0);
            if (palabrasNoValidas.length > 0 &&
                typeof mostrarErroresOrtograficosEnPanel === "function")
            {
                mostrarErroresOrtograficosEnPanel(palabrasNoValidas);
            }
        }

        // 4. Revisión con API de LanguageTool, pero sin aplicar cambios
        // automáticos
        // *** Lógica condicional revisada ***
        let apiResult = { errores : [], apiStatus : 'skipped' }; // Inicializar
        if (config.usarAPI === true && activeDictionaryLang !== 'EN' &&
            resultadoBase.normalizado.length > 1)
        { // <-- Usar === true y verificar idioma EN y longitud
            console.log(`[evaluarOrtografiaCompleta] Llamando API para idioma ${
              activeDictionaryLang}`); // Log para confirmar

            try
            {
                // Llamar a la API con el texto YA NORMALIZADO
                apiResult = await revisarConLanguageTool(
                  resultadoBase.normalizado, config.timeout);

                resultadoBase.metadata.apiUsada = true;               
            }
            catch (error)
            {
                console.error("Error llamando a revisarConLanguageTool:",
                              error);
                apiResult = {
                    errores : [],
                    apiStatus : 'error'
                }; // Marcar como error si la llamada falla
            }
        }
        else
        {
            // Log detallado de por qué se omitió la API
            if (config.usarAPI !== true)
            {
                console.log(
                  `[evaluarOrtografiaCompleta] API omitida (checkbox desmarcado).`);
            }
            else if (activeDictionaryLang === 'EN')
            {
                console.log(
                  `[evaluarOrtografiaCompleta] API omitida (idioma EN).`);
            }
            else if (resultadoBase.normalizado.length <= 1)
            {
                console.log(
                  `[evaluarOrtografiaCompleta] API omitida (texto normalizado muy corto).`);
            }
        }

        // Añadir errores de API (si los hubo) a la lista principal
        apiResult.errores.forEach(e => {
            // Asegura que los errores de API tengan los campos necesarios
            e.origen = 'API';
            e.tipo = e.tipo || 'ortografia'; // Default si no viene de la API
            e.severidad = e.severidad || 'media'; // Default
        });
        resultadoBase.errores.push(...apiResult.errores);

        // (Opcional) Mostrar errores combinados en el panel si la función
        // existe if (typeof mostrarErroresOrtograficosEnPanel === "function") {
        //     mostrarErroresOrtograficosEnPanel(resultadoBase.errores);
        // }

        // 5. Filtrado final
        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 = []; // Array para almacenar los errores encontrados
        const palabrasOriginal = original.split(/\s+/);
        // let palabrasNormalizadas = [...palabrasOriginal]; // Esta variable no
        // se usa para construir el resultado de errores

        // Itera sobre cada palabra original
        palabrasOriginal.forEach((palabra, i) => {
            const contieneNumeros = /\d/.test(palabra);
            // Limpiar puntuación final común ANTES de cualquier validación
            const palabraLimpia = palabra.replace(/[.,;:!?()"']+$/, ''); // Añadir más puntuación
            const contieneSoloLetras =
              /^[a-zA-ZáéíóúÁÉÍÓÚüÜñÑ]+$/.test(palabraLimpia); // Usar palabraLimpia aquí también

            // Aplicar regla solo si no tiene números, son letras puras y no
            // está vacía
            if (!contieneNumeros && contieneSoloLetras &&
                palabraLimpia.length > 0) // Añadir check de longitud > 0
            {
                let errorAgregado = false; // Flag para prevenir múltiples errores para la misma palabra

                // 1. Verificar Exclusiones y Palabras Especiales PRIMERO
                const lowerPalabraLimpiaNormalizada = normalizarPalabra(palabraLimpia);
                const isExcluded = excludeWords.some(excluded => normalizarPalabra(excluded) === lowerPalabraLimpiaNormalizada);
                if (isExcluded) return; // Saltar palabra excluida

                const palabraEspecialCorrecta = esPalabraEspecial(palabraLimpia); // Devuelve la forma correcta o null
                if (palabraEspecialCorrecta) // <-- Corregido: usar palabraEspecialCorrecta
                {
                    if (palabraLimpia !== palabraEspecialCorrecta) {
                        errores.push({
                            palabraOriginal: palabra,
                            palabraProblematica: palabraLimpia,
                            sugerida: palabraEspecialCorrecta,
                            tipo: "especial_incorrecta",
                            severidad: "media",
                            motivo: `Forma incorrecta de palabra especial. Usar: "${palabraEspecialCorrecta}"`,
                            origen: 'Local (Especial)'
                        });
                        errorAgregado = true;
                    }
                    return; // Si es especial (correcta o no), no hacer más checks locales
                }
                // 2. Verificar Diccionario (Coincidencia EXACTA)
                const estaEnDiccionarioExacto = dictionaryWords.includes(palabraLimpia);

                if (estaEnDiccionarioExacto)
                {
                    console.log(`[DEBUG] "${
                      palabraLimpia}" está en diccionario. Verificando ambigüedad...`); // LOG AMBIGÜEDAD 1

                    // *** NUEVO: Lógica de ambigüedad de acento ***
                    const tieneTildeActual = /[áéíóúÁÉÍÓÚ]/.test(palabraLimpia);
                    if (!tieneTildeActual)
                    {
                        // La palabra SIN tilde está en el diccionario.
                        // ¿Existe una versión CON tilde en el diccionario?
                        const palabraConTildePotencial = generarSugerenciaTilde(palabraLimpia); // Intentar generar versión con tilde
                        if (palabraConTildePotencial !== palabraLimpia)
                        { // Si se pudo generar una versión diferente (con tilde)
                            console.log(`[DEBUG] Posible versión con tilde: "${
                              palabraConTildePotencial}"`); // LOG AMBIGÜEDAD 2
                            // Verificar si la palabra con tilde está en el diccionario
                            // const tildeNormalizadaDic = normalizarPalabra(palabraConTildePotencial); // <-- Ya no se usa normalizado aquí
                            // const tildeEnDiccionario = dictionaryWords.some(dictWord => normalizarPalabra(dictWord) === tildeNormalizadaDic); // <-- Ya no se usa normalizado aquí
                            const tildeEnDiccionario = dictionaryWords.includes(palabraConTildePotencial); // <-- Check EXACTO

                            if (tildeEnDiccionario)
                            {
                                // ¡Ambigüedad! Ambas formas (con y sin tilde) están en el diccionario.
                                console.log(`[detectarErroresLocales] Ambigüedad detectada para "${palabraLimpia}" / "${palabraConTildePotencial}" (ambas en diccionario).`);
                                console.log(
                                  `[DEBUG] Pushing error tilde_ambigua`); // LOG
                                                                          // AMBIGÜEDAD
                                                                          // 3

                                errores.push({
                                    palabraOriginal: palabra, // <-- Usar palabraOriginal
                                    sugerencia: palabraConTildePotencial, // Sugerir la versión con tilde por defecto
                                    tipo: "tilde_ambigua",
                                    severidad: "baja", // Es una advertencia más que un error claro
                                    motivo: `Ambigüedad: "${palabraLimpia}" y "${palabraConTildePotencial}" están en diccionario. Verificar contexto.`, // Motivo más claro
                                    palabraOriginal: palabra,
                                    palabraProblematica: palabraLimpia,
                                    origen: 'Local (Diccionario)'
                                });
                                errorAgregado = true; // Marcar que se agregó un error (ambigüedad)
                            }
                            console.log(`[DEBUG] Versión con tilde "${
                              palabraConTildePotencial}" NO encontrada en diccionario.`); // LOG AMBIGÜEDAD 4
                        }
                        console.log(
                          `[DEBUG] No se pudo generar versión con tilde o es igual a la original.`); // LOG AMBIGÜEDAD 5
                    }
                    // Si no hay ambigüedad o la palabra ya tenía tilde, simplemente omitir revisión local.
                    console.log(`[detectarErroresLocales] Palabra "${palabraLimpia}" encontrada en diccionario (sin ambigüedad). Omitiendo revisión local.`);
                    console.log(`[DEBUG] Omitiendo revisión local para "${
                      palabraLimpia}" (en diccionario, sin ambigüedad).`); // LOG
                                                                           // AMBIGÜEDAD
                                                                           // 6
                    if (!errorAgregado) // Si no se reportó ambigüedad, omitir más revisiones
                    return; // Palabra en diccionario, sin ambigüedad detectada, omitir otras revisiones.
                }
                // 3. Si NO está en el diccionario EXACTO, aplicar reglas de Tilde y Ortografía
                else if (!errorAgregado) { // Asegura que solo se ejecute si no está en diccionario exacto y no hay error previo
                }
                const tildeData =
                  detectarTilde(palabraLimpia); // <-- Usar palabraLimpia para detectar tilde
                console.log(
                  `[DEBUG] "${
                    palabraLimpia}" NO está en diccionario. Resultado detectarTilde:`,
                  tildeData); // LOG ORTO 1

                // 3.1 Caso: `detectarTilde` dice VÁLIDA pero SIN TILDE -> ¿Está la versión CON TILDE en el diccionario?
                if (!errorAgregado && tildeData.esValida && !tildeData.tieneTilde) {
                    const sugerenciaConTilde = generarSugerenciaTilde(palabraLimpia);
                    if (sugerenciaConTilde !== palabraLimpia) {
                        // Verificar si la versión CON tilde SÍ está en el diccionario
                        const tildeEnDiccionario = dictionaryWords.includes(sugerenciaConTilde);
                        if (tildeEnDiccionario) {
                            // ¡Caso "drogueria" encontrado!
                            console.log(`[DEBUG] Pushing error tilde_faltante_dic para "${palabraLimpia}" -> "${sugerenciaConTilde}"`);
                            errores.push({
                                palabraOriginal: palabra,
                                palabraProblematica: palabraLimpia,
                                sugerida: sugerenciaConTilde,
                                tipo: "tilde_faltante_dic", // Tipo específico
                                severidad: "alta",
                                motivo: `Posible falta de tilde. Forma correcta "${sugerenciaConTilde}" encontrada en diccionario.`,
                                origen: 'Local (Diccionario/Tilde)'
                            });
                            errorAgregado = true; // Marcar que se agregó un error
                        }
                    }
                }

                // 3.2 Caso: `detectarTilde` dice INVÁLIDA (y no se agregó error antes)
                // (La condición original `!tildeData.esValida && tildeData.tieneTilde`
                // podría ser la que causa problemas con
                // palabras como "Portón" si el 'tipo' fue mal clasificado como
                // grave pero tenía tilde)
                else if (!errorAgregado && !tildeData // <-- Cambiado a else if
                       .esValida /*&& tildeData.tieneTilde*/) // Podrías probar
                                                              // a eliminar la
                                                              // // <-- Usar
                                                              // palabraLimpia

                // segunda parte
                // de la condición
                { // <-- Este bloque ahora solo se ejecuta si !tildeData.esValida Y !errorAgregado
                    console.log(`❌ Palabra inválida (detectada localmente): "${
                      palabra}" - Tipo detectado: ${
                      tildeData.tipo}, Es válida según regla local: ${
                      tildeData.esValida}`);

                    // Aquí se decide si añadir el error.
                    // Quizás necesites refinar esta condición:
                    // Añadir el error si es inválida localmente Y NO está en la
                    // lista de excluidas
                    // const lowerExcluidas = // <-- Ya no es necesario, se verifica antes
                    //     excludeWords.map(w => w.toLowerCase());
                    // console.log(`[DEBUG] Verificando exclusión para "${
                    //   palabraLimpia}"...`); // LOG ORTO 2

                    // if (!lowerExcluidas.includes(palabraLimpia.toLowerCase())) // <-- Ya no es necesario
                    { // Generar sugerencia usando la palabra limpia
                        const sugerida = generarSugerenciaTilde(palabraLimpia);
                        // El bloque comentado anterior sobre corregirTildeLocal fue eliminado por sintaxis incorrecta.
                        // *** NUEVO: Lógica mejorada para buscar corrección
                        // ortográfica ***
                        const sugerenciaTildeNormalizada =
                          normalizarPalabra(sugerida); // <-- Cambiado de sugerenciaTilde a sugerida
                        const sugerenciaTildeEnDiccionario =
                          dictionaryWords.some(dictWord =>
                                                 normalizarPalabra(dictWord) ===
                                                 sugerenciaTildeNormalizada);

                        let posibleCorreccionOrtografica = null;
                        console.log(
                          `[DEBUG] Buscando corrección ortográfica para "${
                            palabraLimpia}" en diccionario...`); // LOG ORTO 3

                        // Si la sugerencia de tilde NO está en el diccionario,
                        // buscar una corrección ortográfica más probable. O
                        // incluso si está, podríamos buscar una mejor
                        // corrección ortográfica. Vamos a buscar siempre que
                        // haya un error de tilde detectado.
                        const palabraLimpiaNormalizada =
                          normalizarPalabra(palabraLimpia);
                        // Buscar palabra en diccionario con base similar (ej.
                        // Levenshtein 1 o reemplazo s/z/c/b/v) Simplificación:
                        // Buscar misma longitud y diferencia en s/z/c/b/v
                        for (const dictWord of dictionaryWords)
                        {
                            const dictWordNormalizada =
                              normalizarPalabra(dictWord);
                            if (dictWordNormalizada.length ===
                                palabraLimpiaNormalizada.length)
                            {
                                let diffCount = 0;
                                let diffIndex = -1;
                                for (let k = 0; k < dictWordNormalizada.length;
                                     k++)
                                {
                                    if (dictWordNormalizada[k] !==
                                        palabraLimpiaNormalizada[k])
                                    {
                                        diffCount++;
                                        diffIndex = k;
                                    }
                                }
                                // Si difiere en 1 caracter y es una sustitución
                                // común (simplificado)
                                if (diffCount === 1)
                                {
                                    const char1 =
                                      palabraLimpiaNormalizada[diffIndex];
                                    const char2 =                                      
                                        dictWordNormalizada[diffIndex];
                                    console.log(`[DEBUG] Comparando "${
                                      palabraLimpiaNormalizada}" vs "${
                                      dictWordNormalizada}" (diff: ${
                                      diffCount})`); // LOG ORTO 4
                                    const commonSubs = [
                                        [ 's', 'z' ],
                                        [ 'z', 's' ],
                                        [ 's', 'c' ],
                                        [ 'c', 's' ],
                                        [ 'b', 'v' ],
                                        [ 'v', 'b' ]
                                    ];
                                    if (commonSubs.some(pair =>
                                                          pair[0] === char1 &&
                                                          pair[1] === char2))
                                    {
                                        console.log(
                                          `[DEBUG] Encontrada corrección ortográfica: "${
                                            dictWord}"`); // LOG ORTO 5

                                        posibleCorreccionOrtografica =
                                          dictWord; // Encontramos corrección
                                                    // ortográfica
                                        break;
                                    }
                                }
                            }
                        }

                        // Decidir qué error reportar
                        console.log(`[DEBUG] Resultado búsqueda ortográfica: ${
                          posibleCorreccionOrtografica}`); // LOG ORTO 6
                        // Si encontramos una corrección ortográfica válida,
                        // reportar eso
                        if (posibleCorreccionOrtografica)
                        {
                            console.log(
                              `[DEBUG] Pushing error ortografia_dic`); // LOG
                                                                       // ORTO 7

                            // Priorizar error ortográfico encontrado en
                            // diccionario
                            errores.push({
                                palabra : palabra,
                                sugerencia : posibleCorreccionOrtografica,
                                tipo : "ortografia_dic",
                                severidad : "alta",
                                motivo :
                                  `Posible error ortográfico. Encontrado en diccionario: "${
                                    posibleCorreccionOrtografica}"`,
                                palabraOriginal : palabra,
                                palabraProblematica : palabraLimpia,
                                origen : 'Local (Diccionario)'
                            }); // <-- Aquí debería ser origen 'Local (Diccionario/Orto)'?
                            errorAgregado = true; // Marcar que se agregó un error
                        }
                        else
                        {
                            console.log(
                              `[DEBUG] Pushing error tilde (detectarTilde inválido)`); // LOG ORTO 8

                            // Si no, reportar el error de tilde original
                            errores.push({
                                palabra : palabra,
                                sugerencia : sugerida, // <-- Cambiado de sugerenciaTilde a sugerida
                                tipo : "tilde",
                                severidad : "media",
                                motivo : `Tipo detectado: ${
                                  tildeData.tipo}, Válida localmente: ${
                                  tildeData.esValida}`,
                                palabraOriginal : palabra,
                                palabraProblematica : palabraLimpia,
                                origen : 'Local'
                            }); // <-- Aquí debería ser origen 'Local (Tilde)'?
                            errorAgregado = true; // Marcar que se agregó un error
                        }
                    }
                    // console.log(`[DEBUG] Palabra "${ // <-- Ya no es necesario
                    //   palabraLimpia}" está excluida o es especial.`); // LOG ORTO 9
                }
            }
        });

        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" },
                // Usar el idioma activo para la API
                data : `language=${getApiLangCode(activeDictionaryLang)}&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 palabraProblematica = // <-- Renombrar
                                                            // para claridad

                                  match?.context?.text?.substring(
                                    match?.context?.offset || 0,
                                    (match?.context?.offset || 0) +
                                      (match?.context?.length || 0)) ||
                                  "(sin contexto)";

                                // Obtener la palabra original del contexto si
                                // es posible
                                const palabraOriginal =
                                  match?.context?.text?.substring(
                                    match?.context?.offset || 0,
                                    (match?.context?.offset || 0) +
                                      (match?.context?.length || 0)) ||
                                  palabraProblematica; // Fallback a la
                                                       // problemática

                                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";

                                const motivo =
                                  match?.message ||
                                  `Regla API: ${
                                    match?.rule
                                      ?.id}`; // <-- Mensaje de la regla

                                return {
                                    palabraOriginal, // <-- Palabra original del
                                                     // contexto
                                    palabraProblematica, // <-- Palabra que
                                                         // disparó la regla
                                    sugerencia,
                                    tipo,
                                    severidad,
                                    motivo,        // <-- Mensaje de la regla
                                    origen : 'API' // <-- Indicar origen
                                };
                            });
                            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 = []; // Array para almacenar errores únicos
        const vistas = new Set();
        errores.forEach((error) => {
            // Usar palabraProblematica para la clave de unicidad
            const clave =
              `${error.palabraProblematica}-${error.sugerencia}-${error.tipo}`;

            if (!vistas.has(clave))
            // Si la clave no está en el Set, es un error único
            {
                vistas.add(clave);
                unicos.push(error);
            }
        });
        return unicos.sort((a, b) => {
            if (a.severidad === b.severidad)
                // Ordenar por severidad (alta primero)
                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;
    }

    function esGrave(palabra)
    {
        if (!palabra || typeof palabra !== "string")
        {
            return false;
        }

        const silabas = separarSilabas(palabra);
        const numSilabas = silabas.length;
        const tieneTilde = /[áéíóú]/.test(palabra);
        let silabaTonicaIndex = -1;

        if (tieneTilde)
        {
            for (let i = 0; i < silabas.length; i++)
            {
                if (/[áéíóú]/.test(silabas[i]))
                {
                    silabaTonicaIndex = i;
                    break;
                }
            }
        }

        // Si tiene tilde, verificar si está en la penúltima sílaba
        if (tieneTilde && silabaTonicaIndex === numSilabas - 2)
        {
            return true; // Es grave con tilde correcta
        }

        // Si NO tiene tilde, verificar si DEBERÍA ser grave
        if (!tieneTilde && numSilabas > 1 &&
            !/[ns]$/.test(palabra.slice(-1).toLowerCase()) &&
            /[aeiou]$/.test(palabra.slice(-1).toLowerCase()))
        {
            return true; // Debería ser grave (sin tilde)
        }

        return false;
    }

    function esAguda(palabra)
    {
        if (!palabra || typeof palabra !== "string")
        {
            return false;
        }

        const silabas = separarSilabas(palabra); // **NECESITA IMPLEMENTARSE**
        const numSilabas = silabas.length;
        const tieneTilde = /[áéíóú]/.test(palabra);
        let silabaTonicaIndex = -1;

        if (tieneTilde)
        {
            for (let i = 0; i < silabas.length; i++)
            {
                if (/[áéíóú]/.test(silabas[i]))
                {
                    silabaTonicaIndex = i;
                    break;
                }
            }
        }

        // Si tiene tilde, verificar si está en la última sílaba
        if (tieneTilde && silabaTonicaIndex === numSilabas - 1)
        {
            return true; // Es aguda con tilde correcta
        }

        // Si NO tiene tilde, verificar si DEBERÍA ser aguda
        if (!tieneTilde && numSilabas > 1 &&
            /[ns]$/.test(palabra.slice(-1).toLowerCase()) &&
            /[aeiou]$/.test(palabra.slice(-1).toLowerCase()))
        {
            return true; // Debería ser aguda (sin tilde)
        }

        return false;
    }

    //---------------------------------------------------
    function tipoVocal(vocal)
    {
        const debiles = "iu";
        const fuertes = "aeoáéó";
        if (debiles.includes(vocal.toLowerCase()))
        {
            return "débil";
        }
        if (fuertes.includes(vocal.toLowerCase()))
        {
            return "fuerte";
        }
        return null; // No es una vocal
    }

    function agruparVocales(silaba)
    {
        let nuevaSilaba = "";
        let i = 0;
        while (i < silaba.length)
        {
            const char = silaba[i];
            const siguiente = silaba[i + 1];
            const siguiente2 = silaba[i + 2];

            const tipo1 = tipoVocal(char);

            if (tipo1)
            {
                if (siguiente && tipoVocal(siguiente))
                {
                    const tipo2 = tipoVocal(siguiente);
                    if (tipo1 === "fuerte" && tipo2 === "fuerte")
                    {
                        nuevaSilaba += char; // Hiato: se separan
                        i++;
                        continue;
                    }
                    else if (tipo1 === "fuerte" && tipo2 === "débil")
                    {
                        if (siguiente2 && tipoVocal(siguiente2) === "débil")
                        {
                            nuevaSilaba +=
                              char + siguiente + siguiente2; // Triptongo
                            i += 3;
                            continue;
                        }
                        nuevaSilaba += char + siguiente; // Diptongo creciente
                        i += 2;
                        continue;
                    }
                    else if (tipo1 === "débil" && tipo2 === "fuerte")
                    {
                        nuevaSilaba += char + siguiente; // Diptongo decreciente
                        i += 2;
                        continue;
                    }
                    else if (tipo1 === "débil" && tipo2 === "débil")
                    {
                        nuevaSilaba += char + siguiente; // Diptongo
                        i += 2;
                        continue;
                    }
                }
                nuevaSilaba += char;
                i++;
            }
            else
            {
                nuevaSilaba += char;
                i++;
            }
        }
        return nuevaSilaba;
    }
    function separarSilabas(palabra)
    {
        if (!palabra || typeof palabra !== "string")
        {
            return [];
        }

        palabra = palabra.toLowerCase();
        let silabas = [];
        let buffer = "";

        for (let i = 0; i < palabra.length; i++)
        {
            buffer += palabra[i];
            if (esSeparable(palabra, i))
            {
                silabas.push(agruparVocales(buffer));
                buffer = "";
            }
        }
        silabas.push(agruparVocales(buffer)); // Añadir la última sílaba

        return silabas;
    }

    function esSeparable(palabra, indice)
    {
        if (indice === palabra.length - 1)
        {
            return false; // No separar al final
        }

        const char = palabra[indice];
        const siguiente = palabra[indice + 1];

        if (!tipoVocal(char) && tipoVocal(siguiente))
        {
            return true; // Consonante seguida de vocal
        }

        if (tipoVocal(char) && tipoVocal(siguiente))
        {
            return tipoVocal(char) === "fuerte" &&
                   tipoVocal(siguiente) === "fuerte"; // Hiato
        }

        if (!tipoVocal(char) && !tipoVocal(siguiente))
        {
            // Dos consonantes seguidas
            if (indice > 0 && tipoVocal(palabra[indice - 1]))
            {
                if ([ "l", "r" ].includes(siguiente) &&
                    !["b", "c", "d", "f", "g", "k", "p", "t"].includes(char))
                {
                    return false; // No separar "bl", "cl", "dr", etc.
                }
                return true;
            }
        }

        return false;
    }
    ///-----------------------------------------

    //********************************************************************************************************************************
    // 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: esHiatoObligatorio
    // Fecha modificación: 2025-05-03 23:12
    // Autor: mincho77
    // Entradas: palabra (string)
    // Salidas: booleano (true si se considera que debería llevar tilde por
    // hiato obligatorio) Descripción: Evalúa si una palabra debería llevar
    // tilde por formar hiato acentual aunque no la tenga escrita.
    //********************************************************************************************************************************
    function esHiatoObligatorio(palabra)
    {
        const hiatosConocidos = [
            "vehiculo",
            "vehiculos",
            "rio",
            "tio",
            "mia",
            "mio",
            "frio",
            "envio",
            "tardia",
            "estaria",
            "baul",
            "continua",
            "confia",
            "rubi",
            "paul"
        ];
        const palabraSinTilde = palabra.normalize("NFD")
                                  .replace(/[\u0300-\u036f]/g, "")
                                  .toLowerCase();
        return hiatosConocidos.includes(palabraSinTilde);
    }

    //********************************************************************************************************************************

    //********************************************************************************************************************************
    // 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 = S('spinnerCheckingMessage'),
                           progress = null) // Usar S() para mensaje por defecto
    {
        let existingSpinner = document.querySelector(".spinner-overlay");
        if (existingSpinner)
        {
            if (show)
            { // Actualiza el mensaje y el progreso si el spinner ya existe
                const spinnerMessage =
                  existingSpinner.querySelector(".spinner-message");
                spinnerMessage.innerHTML = `
                  ${message}
                  ${
                  progress !== null
                    ? `<br><strong>${progress}${
                        S('spinnerProgressMessage')}</strong>` // Usar S() para
                                                               // parte del
                                                               // progreso

                    : ""}
              `;
            }
            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}${
                    S('spinnerProgressMessage')}</strong>` // Usar S() para
                                                           // parte del progreso

                : ""}
                  </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: 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;");
    }

    //********************************************************************************************************************************
    // Nombre: escapeRegExp
    // Descripción: Escapa caracteres especiales para usar en new RegExp().
    //********************************************************************************************************************************
    function escapeRegExp(string)
    {
        // $& significa la cadena completa coincidente
        return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    }

    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: 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)
    {
        // Cierra panel previo si existe
        const existingPanel =
          document.getElementById("normalizer-floating-panel");
        if (existingPanel)
        {
            existingPanel.remove();
        }

        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: 0; /* Quitar padding principal para controlar scroll interno */ 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; vertical-align: top; /* Alinear contenido arriba */ }
            .warning-row { background: #fff8e1; }
            .normalize-btn, .apply-btn, .add-exclude-btn, .add-special-btn, .view-place-btn { /* Añadido .view-place-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; }
            /* Estilo modificado para el botón Ver/Link */
            .view-place-btn {
                background: none; /* Sin fondo */
                color: #333; /* Color oscuro para el icono */
                padding: 2px; /* Padding reducido */
                font-size: 16px; /* Ajustar tamaño si es necesario */
            }
            .add-special-btn { background: #9b59b6; color: white; }
            /* Estilo para el botón de cerrar */
            .close-btn {
                position: absolute; /* Posición absoluta relativa al header sticky */ top: 15px; right: 15px;
                background: #e74c3c; color: white; border: none;
                width: 30px; height: 30px; border-radius: 50%; font-weight: bold;
                cursor: pointer; z-index: 11; /* Encima de todo */
            }
            input[type="checkbox"] { transform: scale(1.3); margin: 0 5px; }
            input[type="text"] { width: 100%; padding: 5px; box-sizing: border-box; }
            /* Estilos para sticky header */
            .panel-header-sticky {
                position: sticky;
                top: 0;
                background-color: white;
                padding: 15px 20px 10px 20px; /* Padding para el header */
                z-index: 10;
                border-bottom: 1px solid #ccc; /* Línea divisoria */
            }
            #normalizer-table th {
                position: sticky;
                top: 75px; /* Ajusta esto según la altura del header */
                z-index: 5; /* Debajo del header principal pero encima del contenido */
            }
        </style>
        <style> /* Estilos adicionales para <details> */
            details > summary { list-style: none; cursor: pointer; }
            details > summary::-webkit-details-marker { display: none; } /* Ocultar marcador por defecto en Chrome/Safari */
            .error-summary-arrow { display: inline-block; transition: transform 0.2s; margin-right: 5px; }
            details[open] .error-summary-arrow { transform: rotate(90deg); }
        </style>
        <!-- Contenedor para header pegajoso -->
        <div class="panel-header-sticky">
            <button class="close-btn" id="close-panel-btn">×</button>
            <h2 style="color: #2c3e50; margin-top: 0; margin-bottom: 5px;">Normalizador de Nombres</h2>
            <div style="margin: 0; color: #7f8c8d;">
  <span id="places-count">${placesToNormalize.length} ${
          S('placesToReview')}</span> |
                 <span id="ready-count-display" style="color: #27ae60; font-weight: bold;">0 ${
          S('placesReadyToNormalize')}</span> <!-- Nuevo contador -->
            </div>
        </div>
        <div style="padding: 0 20px 20px 20px;"> <!-- Contenedor para el resto del contenido con padding -->

        <table id="normalizer-table">
                 <thead> <!-- Usar helper S() para cabeceras -->
                <tr>
                    <th width="5%">${S('applyCol')}</th>   <!-- 1 -->
                    <th width="5%">${S('deleteCol')}</th>  <!-- 2 -->
                    <th width="5%">${S('permaCol')}</th>   <!-- 3 - Perma -->
                    <th width="8%">${
          S('categoryCol')}</th> <!-- Nueva Columna Categoría -->
                    <th width="5%">${
          S('typeCol')}</th>    <!-- 4 - Tipo Place -->
                          <th width="20%">${
          S('currentNameCol')}</th>    <!-- 5 -->
                    <th width="25%">${S('normalizedNameCol')}</th> <!-- 6 -->
                    <th width="15%">${S('problemCol')}</th>      <!-- 7 -->
                    <th width="10%">${S('actionsCol')}</th>       <!-- 8 -->
                </tr>
            </thead>
        <tbody>`;

        placesToNormalize.forEach((place, index) => {
            const {
                originalName,
                newName,
                hasSpellingWarning,
                spellingWarnings = [], // This now contains objects from
                                       // detectarTilde where esValida is false
                place : venue
            } = place;

            const placeId = venue.getID();
            // Obtener la primera categoría o un guion si no hay
            const category = venue.attributes.categories?.[0] || '-';

            // Determinar tipo de lugar (Área o Punto)
            // --- Lógica robusta para obtener tipo de geometría ---
            const geometry = venue.getOLGeometry();
            let geometryType = null;
            if (geometry)
            {
                if (typeof geometry.getType === 'function')
                {
                    geometryType = geometry.getType(); // Método estándar OL3+
                }
                else if (geometry.CLASS_NAME)
                {
                    // Fallback para versiones anteriores/diferentes (ej:
                    // "OpenLayers.Geometry.Polygon")
                    geometryType = geometry.CLASS_NAME.split('.').pop();
                }
            }
            const isArea = geometryType === 'Polygon';
            const placeTypeIcon = isArea ? '⭔' : '⊙';
            // Usar S() para obtener el tooltip correcto según el idioma y tipo
            const placeTypeTitle =
              isArea ? S('placeTypeAreaTooltip') : S('placeTypePointTooltip');

            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> <!-- 3 - Celda para el botón Perma/Ver -->
                 <button class="view-place-btn" data-index="${
              index}" title="Ver detalles del lugar">🔗</button>
            </td>
            <td>${escapeHtml(category)}</td> <!-- Nueva Celda para Categoría -->
            </td>
            <td> <!-- 4 - Celda para el icono de tipo con tooltip -->
                <span title="${placeTypeTitle}">${placeTypeIcon}</span>
           
            </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 ? S('normalizationLabel') : ""}${
              spellingWarnings.length > 0
                ? ` / ${spellingWarnings.length} Errores`
                : ""}</td>
            <td>              
                <button class="normalize-btn" data-index="${
              index}">NrmliZer</button>
                
                <button class="add-exclude-btn" data-word="${
              escapeHtml(
                originalName)}" data-index="${index}">ExcludeWrd</button>
            </td>
        </tr>`;

            // Si hay errores, añadir la fila desplegable
            if (spellingWarnings.length > 0)
            {
                html += `
                     <tr>
                   <td colspan="9" style="padding-top: 0; padding-bottom: 5px;"> <!-- Ajustado colspan a 9 -->
                        <details>
                              <summary style="color: red;">
                                <span class="error-summary-arrow">▶</span> ${
                  // Usar helper S()
                  spellingWarnings.length} error(es) encontrado(s)
                            </summary>
                            <ul style="margin-top: 5px; padding-left: 20px;">
                                ${
                  spellingWarnings
                    .map((warning, warningIndex) =>
                           ` <li style="margin-bottom: 5px;">
                                      ${S('errorInLabel')} "${
                             escapeHtml(warning.palabraProblematica ||
                                        warning.palabraOriginal)}"
                                        (${
                             escapeHtml(warning.motivo || warning.tipo ||
                                        'Ortografía')})<br>
                                        ${S('suggestionLabel')} "<strong>${
                             escapeHtml(warning.sugerida)}</strong>"
                                        <button class="btn apply-suggestion-btn"
                                                data-index="${index}"
                                                data-problem-word="${
                             escapeHtml(warning.palabraProblematica ||
                                        warning.palabraOriginal)}"
                                                data-suggestion="${
                             escapeHtml(warning.sugerida)}"
                                     
                                                style="margin-left: 10px; background-color: #27ae60; color: white; padding: 2px 6px; font-size: 0.9em;">
                                            ${S('useSuggestionButtonLabel')}
                                        </button>
                                    </li>
                                `)
                    .join('')}
                            </ul>
                        </details>
                    </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;">${
          S('applySelectedButton')}</button>
                 <button id="cancel-btn" style="background: #e74c3c; color: white; padding: 10px 20px;
                    border: none; border-radius: 4px; margin-left: 10px; font-weight: bold;">${
          S('cancelButton')}</button>
            </div>
        </div>`;

        panel.innerHTML = html;

        document.body.appendChild(panel);

        // --- Función para actualizar el contador de listos ---
        function updateReadyCount()
        {
            const readyCount =
              panel
                .querySelectorAll(
                  '.normalize-checkbox[data-type="full"]:checked')
                .length;
            const countDisplay = panel.querySelector('#ready-count-display');
            if (countDisplay)
            {
                countDisplay.innerHTML =
                  `${readyCount} ${S('placesReadyToNormalize')}`;
            }
        }
        // --- Fin función contador ---

        // Llamada inicial para establecer el contador en 0
        updateReadyCount();

        // --- Añadir listener directo a checkboxes de aplicar ---
        panel.querySelectorAll('.normalize-checkbox[data-type="full"]')
          .forEach(checkbox => {
              checkbox.addEventListener(
                'change',
                updateReadyCount); // Llamar a update en cualquier cambio
          });
        // --- Fin listener checkboxes ---

        // --- Añadir listener para el botón de cerrar (X) ---
        const closePanelBtn = panel.querySelector("#close-panel-btn");
        if (closePanelBtn)
        {
            closePanelBtn.addEventListener("click", () => panel.remove());
        }
        else
        {
            console.error("Error: No se encontró el botón #close-panel-btn");
        }
        // --- Fin listener botón cerrar ---

        //********
        // Eventos dinámicos para cada lugar
        panel.querySelectorAll('.normalize-btn').forEach(btn => {
            btn.addEventListener("click", function() {
                const row = this.closest("tr");
                const input = row.querySelector(".new-name-input");
                const checkbox = row.querySelector(".normalize-checkbox");
                if (input && checkbox)
                {
                    checkbox.checked = true;
                    this.textContent = "Listo";
                }
            });
        });
        panel.querySelectorAll('.add-special-btn').forEach(btn => {
            btn.addEventListener("click", function() {
                const word = this.dataset.word;
                if (word)
                {
                    addSpecialWord(word);
                    const row = this.closest("tr");
                    const checkbox = row.querySelector(".normalize-checkbox");
                    const normalizeButton = row.querySelector(".normalize-btn");
                    if (checkbox)
                        checkbox.checked = false;
                    if (normalizeButton)
                        normalizeButton.textContent = "NrmliZer";
                }
                // Actualiza contador después de cambiar estado
                updateReadyCount();
            });
        });

        panel.querySelectorAll('.new-name-input').forEach(input => {
            input.addEventListener("input", function() {
                const row = this.closest("tr");
                const checkbox = row.querySelector(".normalize-checkbox");
                const normalizeButton = row.querySelector(".normalize-btn");
                // const originalNormalized = this.dataset.originalNormalized;
                const originalNormalized = this.dataset.original;
                if (checkbox && normalizeButton)
                {
                    if (this.value.trim() !== originalNormalized.trim())
                    {
                        checkbox.checked = true;
                        normalizeButton.textContent = "Listo";
                    }
                    else
                    {
                        checkbox.checked = false;
                        normalizeButton.textContent = "NrmliZer";
                    }
                }
                // Actualizar contador después de cambiar estado por input
                updateReadyCount();
            });
        });
        //*****

        // Nuevo: Botones especiales dinámicos
        panel.querySelectorAll('[id^="special-btn-"]').forEach(btn => {
            btn.addEventListener("click", (e) => {
                const placeId = e.target.getAttribute("data-place-id");
                console.log("Click en botón especial, placeId:", placeId);
                //*****

                // Eventos dinámicos por cada lugar
                placesToNormalize.forEach((place, index) => {
                    const normalizedInput =
                      document.getElementById(`normalized-name-${index}`);
                    const normalizeButton =
                      document.getElementById(`normalize-btn-${index}`);
                    if (normalizeButton)
                    {
                        normalizeButton.addEventListener("click", () => {
                            if (checkbox)
                                checkbox.checked = true;
                            normalizeButton.textContent = "Listo";
                        });
                    }
                    else
                    {
                        console.warn(`No se encontró normalize-btn-${index}`);
                    }
                    // const fixButton =
                    // document.getElementById(`fix-btn-${index}`);

                    const specialButton =
                      document.getElementById(`special-btn-${index}`);
                    if (specialButton)
                    {
                        specialButton.addEventListener("click", () => {
                            if (place.originalName)
                            {
                                addSpecialWord(place.originalName);
                                if (checkbox)
                                    checkbox.checked = false;
                                if (normalizeButton)
                                    normalizeButton.textContent = "NrmliZer";
                            }
                        });
                    }
                    else
                    {
                        console.warn(`No se encontró special-btn-${index}`);
                    }
                    const checkbox = panel.querySelector(
                      `.normalize-checkbox[data-index="${index}"]`);

                    // Cambio manual del texto
                    if (normalizedInput && checkbox && normalizeButton)
                    {
                        normalizedInput.addEventListener("input", () => {
                            if (normalizedInput.value.trim() !==
                                place.normalizedName.trim())
                            {
                                checkbox.checked = true;
                                normalizeButton.textContent = "Listo";
                            }
                            else
                            {
                                checkbox.checked = false;
                                normalizeButton.textContent = "NrmliZer";
                            }
                        });
                    }

                    // Botón agregar palabra especial
                    if (specialButton)
                    {
                        specialButton.addEventListener("click", () => {
                            if (place.originalName)
                            {
                                addSpecialWord(place.originalName);
                                if (checkbox)
                                    checkbox.checked = false;
                                if (normalizeButton)
                                    normalizeButton.textContent = "NrmliZer";
                            }
                        });
                    }
                    else
                    {
                        console.error(`No se encontró special-btn-${index}`);
                    }
                });
                //*****
            });
        });

        // Botón cancelar
        // const cancelBtn = document.getElementById("cancel-btn");
        const cancelBtn = panel.querySelector("#cancel-btn");
        if (cancelBtn)
        {
            cancelBtn.addEventListener("click", () => panel.remove());
        }
        else
        {
            console.error("El botón 'cancel-btn' no se encontró en el DOM.");
        }

        // Botón aplicar todos
        const applyAllBtn = panel.querySelector("#apply-all-btn");
        if (applyAllBtn)
        {
            applyAllBtn.addEventListener("click", () => { // 1. Filtrar los
                                                          // lugares marcados
                                                          // para aplicar
                const selectedChanges =
                  placesToNormalize
                    .filter((_, index) => {
                        const checkbox = panel.querySelector(
                          // Asegúrate de seleccionar solo los checkboxes
                          // principales o todos si quieres aplicar cambios de
                          // advertencias también
                          `.normalize-checkbox[data-index="${
                            index}"][data-type="full"]`);
                        return checkbox && checkbox.checked;
                    })
                    .map(
                      placeData =>
                        ({...placeData })); // Crear copias para no mutar el
                                            // array original directamente aquí

                // 2. Actualiza 'newName' con el valor actual del input ANTES
                // de aplicar
                selectedChanges.forEach((change) => {
                    const index = placesToNormalize.findIndex(
                      p =>
                        p.id === change.id); // Encontrar índice original por ID

                    const inputElement =
                      panel.querySelector(`.new-name-input[data-index="${
                        index}"][data-type="full"]`);
                    if (inputElement)
                    {
                        change.newName =
                          inputElement.value; // Sobrescribir newName con el
                                              // valor del input
                    }
                });

                if (selectedChanges.length === 0)
                {
                    showModal({
                        title : "Advertencia",
                        message :
                          "No se seleccionaron lugares para aplicar cambios.",
                        confirmText : "Aceptar",
                        type : "warning"
                    });
                    return;
                }
                // 3. Llamar a applyNormalization con los datos actualizados
                applyNormalization(selectedChanges);
                panel.remove();
            });
        }
        else
        {
            console.error("El botón 'apply-all-btn' no se encontró en el DOM.");
        }

        // 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;
                    }
                    // Actualizar contador después de cambiar estado por input
                    updateReadyCount();
                }
            });
        });

        // 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;
                    // Actualizar contador si se marca eliminar
                    updateReadyCount();
                }
            });
        });

        // 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;
                    // Actualizar contador después de normalizar
                    updateReadyCount();
                }
                catch (error)
                {
                    console.error("Error al normalizar:", error);
                    clearInterval(interval);
                    this.textContent = originalText;
                }
            });
        });

        // Evento para los botones "Usar sugerencia" dentro de <details>
        panel.querySelectorAll(".apply-suggestion-btn").forEach((btn) => {
            btn.addEventListener("click", function() {
                const index = parseInt(this.dataset.index, 10);
                const problemWord = this.dataset.problemWord;
                const suggestion = this.dataset.suggestion;

                if (isNaN(index) || !problemWord || !suggestion)
                {
                    console.error("Datos inválidos en botón de sugerencia:",
                                  this.dataset);
                    return;
                }
                const placeData = placesToNormalize[index];
                // const warning = placeData.spellingWarnings[warningIndex];
                const mainInput = panel.querySelector(
                  `.new-name-input[data-index="${index}"][data-type="full"]`);
                const mainCheckbox =
                  panel.querySelector(`.normalize-checkbox[data-index="${
                    index}"][data-type="full"]`);

                // if (!placeData || !warning || !mainInput || !mainCheckbox)
                if (!placeData || !mainInput || !mainCheckbox)
                    return;

                // Verificar si la palabra original tiene tildes incorrectas
                const currentValue = mainInput.value;

                const regex =
                  new RegExp('\\b' + escapeRegExp(problemWord) + '\\b',
                             'i'); // Busca la palabra exacta (case-insensitive)
                const newValue = currentValue.replace(regex, suggestion);

                mainInput.value = newValue;

                placeData.newName = newValue;

                mainCheckbox.checked = true;
                // Actualizar contador después de aplicar sugerencia
                updateReadyCount();
                this.disabled = true;
                this.textContent = "Corregido";
            });
        });

        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
                }
            });
        });

        // Evento para el botón "Ver Place" (icono del ojo)
        panel.querySelectorAll(".view-place-btn").forEach((btn) => {
            btn.addEventListener("click", function() {
                const index = parseInt(this.dataset.index, 10);
                if (isNaN(index) || index < 0 ||
                    index >= placesToNormalize.length)
                {
                    console.error("Índice de lugar inválido para 'Ver Place':",
                                  this.dataset.index);
                    return;
                }
                const placeObject = placesToNormalize[index].place;
                if (placeObject && W && W.selectionManager)
                {
                    W.selectionManager.setSelectedModels([ placeObject ]);
                }
                else
                {
                    console.error("No se pudo seleccionar el lugar:",
                                  placeObject);
                }
            });
        });
    }

    //********************************************************************************************************************************
    // 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: 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: openDeletePopupForDictionary
    // Fecha modificación: 2025-07-28
    // Autor: mincho77
    // Entradas:
    // - index (number): Índice de la palabra a eliminar en la lista
    // `dictionaryWords`. Salidas: Ninguna. Muestra un modal de confirmación
    // para eliminar del diccionario. Descripción: Muestra un modal para
    // confirmar la eliminación de una palabra del diccionario activo. Si el
    // usuario confirma, elimina la palabra de `spellDictionaries`, actualiza
    // `localStorage`, `dictionaryWords` y renderiza el panel.
    //********************************************************************************************************************************
    function openDeletePopupForDictionary(index)
    {
        const wordToDelete =
          dictionaryWords[index]; // Obtener la palabra de la lista plana
        if (!wordToDelete)
        {
            console.error(
              `[Diccionario] No se encontró la palabra en el índice ${index}`);
            return;
        }

        showModal({
            title : "Eliminar palabra del Diccionario",
            message :
              `¿Estás seguro de que deseas eliminar la palabra <strong>${
                escapeHtml(wordToDelete)}</strong> del diccionario?`,
            confirmText : "Eliminar",
            cancelText : "Cancelar",
            type : "warning",
            onConfirm : () => {
                // 1. Encontrar y eliminar la palabra de la estructura principal
                // spellDictionaries
                const firstLetter = wordToDelete.charAt(0).toLowerCase();
                const currentDictLang = spellDictionaries[activeDictionaryLang];

                if (currentDictLang && currentDictLang[firstLetter])
                {
                    const letterArray = currentDictLang[firstLetter];
                    const wordIndexInLetterArray =
                      letterArray.indexOf(wordToDelete);

                    if (wordIndexInLetterArray > -1)
                    {
                        letterArray.splice(wordIndexInLetterArray,
                                           1); // Eliminar del array de la letra

                        // Opcional: Si el array de la letra queda vacío,
                        // eliminar la letra
                        if (letterArray.length === 0)
                        {
                            delete currentDictLang[firstLetter];
                        }

                        // 2. Actualizar localStorage
                        localStorage.setItem(
                          `spellDictionaries_${activeDictionaryLang}`,
                          JSON.stringify(currentDictLang));

                        // 3. Actualizar la lista plana global y renderizar
                        dictionaryWords =
                          Object.values(currentDictLang).flat().sort();
                        renderDictionaryWordsPanel();

                        showModal({
                            title : "Éxito",
                            message :
                              "La palabra fue eliminada correctamente del diccionario.",
                            confirmText : "Aceptar",
                            type : "success",
                            autoClose : 2000,
                        });
                    }
                    else
                    {
                        console.error(`[Diccionario] No se encontró "${
                          wordToDelete}" en el array de la letra '${
                          firstLetter}'.`);
                    }
                }
                else
                {
                    console.error(`[Diccionario] No se encontró la letra '${
                      firstLetter}' o el diccionario para el idioma ${
                      activeDictionaryLang}.`);
                }
            },
        });
    }
    //********************************************************************************************************************************
    // 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: addTildeToVowel
    // Fecha modificación: 2025-07-26
    // Autor: mincho77
    // Entradas: vocal (string) - Una vocal sin tilde (a, e, i, o, u)
    // Salidas: string - La vocal con tilde (á, é, í, ó, ú)
    // Descripción: Añade la tilde a una vocal.
    //********************************************************************************************************************************
    function addTildeToVowel(vowel)
    {
        const tildes = {
            'a' : 'á',
            'e' : 'é',
            'i' : 'í',
            'o' : 'ó',
            'u' : 'ú',
            'A' : 'Á',
            'E' : 'É',
            'I' : 'Í',
            'O' : 'Ó',
            'U' : 'Ú'
        };
        return tildes[vowel] || vowel;
    }

    //********************************************************************************************************************************
    // Nombre: generarSugerenciaTilde
    // Fecha modificación: 2025-07-26
    // Autor: mincho77
    // Entradas: palabra (string) - La palabra con posible error de tilde.
    // Salidas: string - La palabra con la tilde corregida, o la original si no
    // se pudo corregir. Descripción: Intenta corregir la tilde de una palabra
    // basándose en las reglas detectadas por detectarTilde.
    //              Prioriza añadir tildes faltantes.
    //********************************************************************************************************************************
    function generarSugerenciaTilde(palabra)
    {
        const tildeData = detectarTilde(palabra);
        // No retornar si es válida, intentar corregir de todas formas por si
        // falta tilde
        const silabas = obtenerSilabasAproximadas(palabra);
        const totalSilabas = silabas.length;
        const terminaEnVocalNS = [ 'a', 'e', 'i', 'o', 'u', 'n', 's' ].includes(
          palabra.slice(-1)
            .normalize("NFD")
            .replace(/[\u0300-\u036f]/g, "")
            .toLowerCase());

        // Intentar añadir tilde si falta
        if (!tildeData.tieneTilde)
        {
            let silabaIndexTarget = -1;
            if (tildeData.tipo === 'aguda' && terminaEnVocalNS &&
                totalSilabas > 0)
                silabaIndexTarget = totalSilabas - 1;
            else if (tildeData.tipo === 'grave' && !terminaEnVocalNS &&
                     totalSilabas > 1)
                silabaIndexTarget = totalSilabas - 2;
            else if (tildeData.tipo === 'esdrújula' && totalSilabas > 2)
                silabaIndexTarget = totalSilabas - 3;
            else if (tildeData.tipo === 'sobresdrújula' && totalSilabas > 3)
                silabaIndexTarget =
                  totalSilabas - 4; // Asumiendo la 4ta desde el final

            if (silabaIndexTarget >= 0 && silabaIndexTarget < silabas.length)
            {
                const silabaTarget = silabas[silabaIndexTarget];
                let palabraCorregidaArray = palabra.split('');
                let offset =
                  silabas.slice(0, silabaIndexTarget).join('').length;
                for (let i = silabaTarget.length - 1; i >= 0; i--)
                { // Buscar última vocal en la sílaba
                    const charIndex = offset + i;
                    const char = palabraCorregidaArray[charIndex];
                    if ("aeiouAEIOU".includes(char))
                    {
                        palabraCorregidaArray[charIndex] =
                          addTildeToVowel(char);
                        return palabraCorregidaArray.join('');
                    }
                }
            }
        }
        return palabra; // Devolver original si no se pudo corregir
    }

    //********************************************************************************************************************************
    // 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 maxPlacesToScan = // Renombrar para evitar confusión con la
                                // variable global
          parseInt(document.getElementById("maxPlacesInput")?.value || 100, 10);

        if (!W?.model?.venues?.objects)
        { // Verificar si el modelo de WME está disponible
            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, maxPlacesToScan); // Usar la variable local

        if (allPlaces.length === 0)
        { // Si no se encontraron lugares
            toggleSpinner(false);
            showNoPlacesFoundMessage(); // Mostrar el mensaje mejorado
            return;
        }

        let processedCount = 0;
        const useAPI = document.getElementById("useSpellingAPI")
                         ?.checked; // Verificar si se usará la API
        const placesToNormalize = [];

        const processBatch = async (index) => {
            if (index >= allPlaces.length)
            { // Si ya se procesaron todos los lugares
                // Ocultar spinner
                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];
            const originalName = place.attributes.name ||
                                 ""; // Asegura que originalName sea string
            const placeId = place.getID(); // Obtener ID
            try
            {                
                // *** MODIFICADO: Verificar si el lugar es editable y si la función existe ***
                let isEditable = true; // Asumir editable por defecto si la función no existe
                if (typeof place.isEditable === 'function') {
                    isEditable = place.isEditable();
                }
                if (!isEditable) {
                    console.log(`[scanPlaces] Saltando lugar ${placeId} (no editable por el usuario actual).`);
                    // Opcional: Podrías añadirlo a una lista separada o simplemente saltarlo
                    processedCount++; // Asegúrate de incrementar el contador
                    setTimeout(() => processBatch(index + 1), 10); // Procesar el siguiente rápido
                    return; // Saltar el resto del procesamiento para este lugar
                }
                console.log(`[scanPlaces] Processing index ${index}, placeId ${
                  placeId}, name: "${originalName}"`); // Log inicio
                // 1. Llamar a la función centralizada de evaluación
                const evalConfig = {
                    usarAPI : useAPI, // Usar el valor del checkbox
                    reglasLocales :
                      true, // Asumimos que siempre queremos reglas locales
                    timeout : 30000 // Timeout para la API
                };
                // Pasar placeId a evaluarOrtografiaCompleta para que pueda
                // usarlo normalizePlaceName
                const evalResult = await evaluarOrtografiaCompleta(
                  originalName, placeId, evalConfig);

                // 2. Mapear los errores al formato esperado por el panel
                // flotante Los errores ya deberían tener 'palabraOriginal' y
                // 'palabraProblematica'
                const spellingWarningsForPanel = evalResult.errores.map(
                  error => ({
                      palabraOriginal : error.palabraOriginal ||
                                          error.palabraProblematica ||
                                          '?', // Fallback
                      palabraProblematica : error.palabraProblematica ||
                                              error.palabraOriginal ||
                                              '?', // Fallback
                      sugerida : error.sugerencia,
                      tipo : error.tipo,
                      motivo : error.motivo || `Severidad: ${error.severidad}`,
                      origen :
                        error.origen || 'Desconocido' // Origen (Local o API)
                  }));

                // 3. Determinar el nombre final (el 'normalizado' de la
                // evaluación)
                const finalCorrectedName = evalResult.normalizado;

                processedCount++;
                toggleSpinner(
                  true,
                  `${S('spinnerProcessingMessage')} (${processedCount}/${
                    // Usar S() aquí`Procesando lugares... (${processedCount}/${
                    allPlaces.length})`, // Usar allPlaces.length
                  Math.round((processedCount / allPlaces.length) * 100));

                console.log(
                  `Lugar: ${originalName}, Errores locales detectados:`,
                  spellingWarningsForPanel);

                // 4. Añadir al panel si hubo advertencias o si el nombre final
                // difiere del original
                console.log(
                  `[scanPlaces] Checking if place ${placeId} needs review`);
                if (spellingWarningsForPanel.length > 0 ||
                    originalName !== finalCorrectedName)
                {
                    placesToNormalize.push({
                        id : placeId, // Usar el ID obtenido al inicio
                        originalName,
                        newName : // Usar el nombre normalizado/corregido de la
                                  // evaluación

                                    finalCorrectedName, // Nombre corregido SOLO
                                                        // por reglas locales
                                                        // inicialmente
                        hasSpellingWarning :
                          spellingWarningsForPanel.length > 0,
                        spellingWarnings :
                          spellingWarningsForPanel, // Pasar las advertencias
                                                    // detalladas
                        place
                    });
                    console.log(`[scanPlaces] Place ${
                      placeId} added to normalization list.`);
                }
                else
                {
                    console.log(
                      `[scanPlaces] Place ${placeId} does NOT need review.`);
                }
                setTimeout(() => processBatch(index + 1), 50);
                // Asegura que el contador avance y el spinner se actualice
                // incluso si hay error
            }
            catch (error)
            {
                console.error(`Error procesando lugar ${place.getID()} (${
                                originalName || 'N/A'}):`,
                              error);
                // Asegura que el contador avance y el spinner se actualice
                processedCount++;
                toggleSpinner(
                  true,
                  `Procesando lugares... (${processedCount}/${
                    allPlaces.length})`, // Usar allPlaces.length
                  Math.round((processedCount / allPlaces.length) * 100));
                setTimeout(() => processBatch(index + 1), 50);
            }
        };
        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: 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: 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);
    }

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

    //********************************************************************************************************************************
    // 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
            .filter((word) => {
                // Si es para excluir, no mostrar si ya está excluida
                // (comparando en minúsculas)
                return !(listType === "excludeWords" &&
                         excludeWords.some((ex) => ex.toLowerCase() ===
                                                   word.toLowerCase()));
            })
            .map((word, index) => `
                            <li>
                                <label>
                                    <input type="checkbox" class="special-word-checkbox" data-word="${
                   escapeHtml(word)}" id="word-${index}">
                                    ${escapeHtml(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.
    //           placeId (string): ID del lugar (opcional, para caché o logs
    //           futuros).
    // Salidas: Promise<string>: Promesa que resuelve con el nombre normalizado
    // básico. Descripción: Aplica normalización básica (mayúsculas, artículos,
    // exclusiones, especiales).
    //              YA NO llama a la API de ortografía directamente para aplicar
    //              sugerencias.
    //********************************************************************************************************************************
    async function normalizePlaceName(name,
                                      placeId = null) // useSpellingAPI = false)
    {
        if (!name)
        {
            return "";
        }

        // Usar la variable global 'normalizeArticles' que se actualiza con el
        // listener. Recordar: normalizeArticles = true significa NO normalizar
        // (checkbox marcado). Por lo tanto, la condición para normalizar (poner
        // en minúscula) debe ser cuando normalizeArticles es false.
        const shouldLowercaseArticle =
          activeDictionaryLang === 'SP' && !normalizeArticles;
        const articles =
          [ "el", "la", "los", "las", "de", "del", "al", "y", "e", "en" ];

        const words = name.trim().split(/\s+/);

        const isRoman = (word) =>
          /^(i{1,3}|iv|v|vi{0,3}|ix|x|xi{0,3}|xiv|xv|xvi{0,3}|xix|xx|xxi{0,3}|xxiv|xxv|xl)$/i
            .test(word);

        const normalizedWords =
          await Promise.all(words.map(async (word, index) => {
              // 🛑 Verificar si es palabra especial y retornarla intacta
              const palabraEspecial = esPalabraEspecial(word);
              if (palabraEspecial)
                  return palabraEspecial;

              const lowerWord = word.normalize("NFD").toLowerCase();

              // Reglas de reemplazo...
              if (lowerWord === "él" || lowerWord === "el")
                  return word;
              if (lowerWord === "sa" || lowerWord === "s.a")
                  return "S.A";
              if (lowerWord === "sas" || lowerWord === "s.a.s")
                  return "S.A.S";
              if (lowerWord === "[p]")
                  return "";
              if (/^\d+$/.test(word))
                  return word;
              if (isRoman(word))
                  return word.toUpperCase();
              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());
              }
              // Poner artículo en minúscula solo si está permitido (checkbox
              // desmarcado en SP)
              if (shouldLowercaseArticle && articles.includes(lowerWord) &&
                  index !== 0)
                  return lowerWord;

              // --- Exclude Word Check (CRITICAL) ---
              const matchedExcludeWord = wordLists.excludeWords.find(
                (w) => w.normalize("NFD").toLowerCase() === lowerWord);
              if (matchedExcludeWord)
              {
                  console.log(
                    `"${word}" matched exclude word: "${matchedExcludeWord}"`);
                  return matchedExcludeWord; // Return the *original* form
              }
              // --- Main Normalization Logic ---

              // Ya no usamos spellingSuggestion aquí, solo capitalización
              // básica
              let normalizedWord;
              normalizedWord =
                word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();

              return normalizedWord;
          }));

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

        newName = newName.replace(/([A-Za-z])'([A-Za-z])/g,
                                  (match, before, after) =>
                                    `${before}'${after.toLowerCase()}`);
        newName = newName.replace(
          /-\s*([a-z])/g, (match, letter) => `- ${letter.toUpperCase()}`);

        // *** NUEVO: Capitalizar después de punto y espacio ***
        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();  // Movido arriba
                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"
            });

            // Configurar el selector de idioma DESPUÉS de que el HTML esté en
            // el DOM
            waitForElement("#dictionaryLanguageSelect", (selector) => {
                selector.value =
                  activeDictionaryLang; // Establecer valor inicial
                // configurarCambioIdiomaDiccionario(); // Ya no se llama aquí,
                // se llama desde attachLanguageChangeListener
                attachLanguageChangeListener(); // Adjuntar el listener inicial
            });

            attachDetailsToggleListeners(); // Adjuntar listeners de detalles
                                            // iniciales

            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
    // **************************************************************************
    // Nombre: NormalizerUtils
    // Fecha modificación: 2025-04-29
    // Hora: 07:50
    // Autor: mincho77
    // Entradas: Ninguna
    // Salidas: Objeto global con funciones relacionadas a ortografía y tildes
    // Prerrequisitos: Las funciones detectarTilde, separarSilabas y
    // validarReglasAcentuacion deben estar ya definidas Descripción: Define un
    // objeto global con utilidades de ortografía para facilitar su uso en
    // consola o desde otros módulos
    // **************************************************************************
    window.NormalizerUtils = {
        detectarTilde,
        separarSilabas : obtenerSilabasAproximadas, // Usar la versión adaptada
        validarReglasAcentuacion                    // Mantener si existe

    };
    unsafeWindow.NormalizerUtils = window.NormalizerUtils;
    unsafeWindow.normalizePlaceName = normalizePlaceName;
    unsafeWindow.applyNormalization = applyNormalization;
    window.addEventListener("dragover",
                            e => e.preventDefault(),
                            { passive : false }); // <-- Añadir passive: false
    window.addEventListener("drop",
                            e => e.preventDefault(),
                            { passive : false }); // <-- Añadir passive: false
    function exposeNormalizerTools()
    {
        if (typeof window.NormalizerUtils === "undefined")
        {
            window.NormalizerUtils = {};
        }

        window.NormalizerUtils.detectarTilde = detectarTilde;
        window.NormalizerUtils.separarSilabas =
          obtenerSilabasAproximadas; // Exponer la versión adaptada
        window.NormalizerUtils.validarReglasAcentuacion =
          validarReglasAcentuacion;

        console.log("✅ NormalizerUtils disponible en window");
    }

    // Siempre exponer NormalizerTools después de un pequeño retardo
    setTimeout(exposeNormalizerTools,
               2000); 
})();

QingJ © 2025

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