WME Places Name Normalizer

Normaliza nombres de lugares en Waze Map Editor (WME)

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

// ==UserScript==
// @name         WME Places Name Normalizer
// @namespace    https://gf.qytechs.cn/en/users/mincho77
// @version      4.0
// @description  Normaliza nombres de lugares en Waze Map Editor (WME)
// @author       mincho77
// @match        https://www.waze.com/*editor*
// @match        https://beta.waze.com/*user/editor*
// @grant        GM_xmlhttpRequest
// @connect      api.languagetool.org
// @grant        unsafeWindow
// @license      MIT
// @run-at       document-end
// ==/UserScript==
/*global W*/
(() => {
    "use strict";
    // Variables globales básicas
    const SCRIPT_NAME = "PlacesNameNormalizer";
    const VERSION = "4.0";
    let excludeWords = [];
    let maxPlaces = 100;
    let normalizeArticles = true;
    let placesToNormalize = [];
    // 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.body.addEventListener(evt, (e) => {
            // Si el evento se origina en (o es descendiente de) #drop-zone, no
            // se bloquea.
            if (e.target && e.target.closest && e.target.closest("#drop-zone"))
            {
                return; // Permite que el drop-zone maneje el evento.
            }
            e.preventDefault();
            e.stopPropagation();
            if (e.dataTransfer)
            {
                e.dataTransfer.dropEffect = "none";
                e.dataTransfer.effectAllowed = "none";
            }
            return false;
        }, { capture : true });
    });
    // *****************************************************************************************************
    // Nombre: showNoPlacesFoundMessage
    // Fecha modificación: 2025-04-10
    // Autor: mincho77
    // Entradas: Ninguna
    // Salidas: Ninguna. Crea un modal que informa al usuario que no se
    // encontraron lugares que cumplan con los criterios actuales. Descripción:
    // Muestra un mensaje modal cuando no se encuentran lugares. Este mensaje
    // incluye un botón para cerrar el modal. Se utiliza para mostrar
    // información al usuario sobre la falta de lugares encontrados.
    // Ejemplo de uso: showNoPlacesFoundMessage();
    // *****************************************************************************************************
    function showNoPlacesFoundMessage()
    { // Crear el modal
        const modal = document.createElement("div");
        modal.className = "no-places-modal-overlay";
        modal.innerHTML = `
          <div class="no-places-modal">
              <div class="no-places-header">
                  <h3>⚠️ No se encontraron lugares</h3>
              </div>
              <div class="no-places-body">
                  <p>No se encontraron lugares que cumplan con los criterios actuales.</p>
                  <p>Intenta ajustar los filtros o ampliar el área de búsqueda.</p>
              </div>
              <div class="no-places-footer">
                  <button id="close-no-places-btn" class="no-places-btn">Aceptar</button>
              </div>
          </div>
      `;
        // Agregar el modal al documento
        document.body.appendChild(modal);
        // Manejar el evento de cierre
        document.getElementById("close-no-places-btn")
          .addEventListener("click", () => { modal.remove(); });
    }
    // Estilos CSS para el mensaje
    const noPlacesStyles = `
  <style>
  .no-places-modal-overlay
  {
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background: rgba(0, 0, 0, 0.6);
      display: flex;
      justify-content: center;
      align-items: center;
      z-index: 10000;
      animation: fadeIn 0.3s ease-in-out;
  }

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

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

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

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

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

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

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

  @keyframes slideIn {
      from {
          transform: translateY(-20px);
      }
      to {
          transform: translateY(0);
      }
  }
  </style>
  `;
    // Insertar los estilos en el documento
    document.head.insertAdjacentHTML("beforeend", noPlacesStyles);
    // *****************************************************************************************************
    // Nombre: showModal
    // Fecha modificación: 2025-04-10
    // Autor: mincho77
    // Entradas: title (string): Título del modal.
    //          message (string): Mensaje a mostrar en el modal.
    //          confirmText (string): Texto del botón de confirmación.
    //          cancelText (string): Texto del botón de cancelación.
    //          onConfirm (function): Función a ejecutar al confirmar.
    //          onCancel (function): Función a ejecutar al cancelar.
    //          type (string): Tipo de modal (info, error, warning, question).
    //          autoClose (number): Tiempo en milisegundos para cerrar
    //          automáticamente. prependText (string): Texto a mostrar antes del
    //          mensaje.
    // Salidas: Ninguna. Crea un modal en el DOM.
    // Descripción:
    // Esta función crea un modal personalizado en el DOM. Permite mostrar
    // mensajes de información, advertencia, error o pregunta. Incluye botones
    // de confirmación y cancelación, así como la opción de cerrar
    // automáticamente después de un tiempo. Se pueden agregar estilos
    // personalizados y un texto opcional al principio del mensaje. ejemplos:
    // modal con texto adicional al principio showModal({ title: "Advertencia",
    // message: "Esta acción podría tener consecuencias.", prependText: "⚠️
    // Atención: Esto es importante.", confirmText: "Entendido", cancelText:
    // "Cancelar", type: "warning"
    // });
    // Modal de confirmación con signo de interrogación
    // showModal({
    // title: "¿Estás seguro?",
    // message: "Esta acción no se puede deshacer.",
    // confirmText: "Sí, estoy seguro",
    // cancelText: "No, cancelar",
    // type: "question",
    // isQuestion: true,
    // onConfirm: () => { console.log("Acción confirmada"); },
    // onCancel: () => { console.log("Acción cancelada"); }
    // });
    // Modal de exito que desaparece automaticamente
    // showModal({
    // title: "Éxito",
    // message: "La operación se completó con éxito.",
    // confirmText: "Aceptar",
    // type: "info",
    // autoClose: 3000, // Cierra automáticamente después de 3 segundos
    // onConfirm: () => { console.log("Modal cerrado automáticamente"); }
    // });
    // Modal con prependtext
    // showModal({
    // title: "Información",
    // message:  "El proceso {prependText} se completó correctamente.",
    // prependText: "de importación",
    // confirmText: "Aceptar",
    // type: "info",
    // });
    // *****************************************************************************************************
    function showModal({
        title,
        message,
        confirmText,
        cancelText,
        onConfirm,
        onCancel,
        type = "info",
        autoClose = null,
        prependText = "",
    })
    {
        // Determinar el ícono según el tipo
        let icon;
        switch (type)
        {
            case "error":
                icon = "⛔"; // Ícono para error
                break;
            case "warning":
                icon = "⚠️"; // Ícono para advertencia
                break;
            case "info":
                icon = "ℹ️"; // Ícono para información
                break;
            case "question":
                icon = "❓"; // Ícono para preguntas
                break;
            case "success":
                icon = "✅"; // Ícono para éxito
                break;
            default:
                icon = "ℹ️"; // Ícono por defecto
                break;
        }
        // Reemplazar el marcador de posición `{prependText}` en el mensaje
        const fullMessage = message.replace("{prependText}", prependText);
        // Crear el modal
        const modal = document.createElement("div");
        modal.className = "custom-modal-overlay";
        modal.innerHTML = `
          <div class="custom-modal">
              <div class="custom-modal-header">
                  <h3>${icon} ${title}</h3>
                  <button class="close-modal-btn" title="Cerrar">×</button>
              </div>
              <div class="custom-modal-body">
                  <p>${fullMessage}</p>
              </div>
              <div class="custom-modal-footer">
                  ${
          cancelText
            ? `<button id="modal-cancel-btn" class="modal-btn cancel-btn">${
                cancelText}</button>`
            : ""}
                  ${
          confirmText
            ? `<button id="modal-confirm-btn" class="modal-btn confirm-btn">${
                confirmText}</button>`
            : ""}
              </div>
          </div>
      `;
        // Agregar el modal al documento
        document.body.appendChild(modal);
        // Manejar eventos de los botones
        if (confirmText)
        {
            document.getElementById("modal-confirm-btn")
              .addEventListener("click", () => {
                  if (onConfirm)
                      onConfirm();

                  modal.remove();
              });
        }
        if (cancelText)
        {
            document.getElementById("modal-cancel-btn")
              .addEventListener("click", () => {
                  if (onCancel)
                      onCancel();

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@keyframes slideIn {
  from {
      transform: translateY(-20px);
  }
  to {
      transform: translateY(0);
  }
}
</style>
`;
    // Insertar los estilos en el documento
    document.head.insertAdjacentHTML("beforeend", modalStyles);
    // *****************************************************************************************************
    // Nombre: openEditPopup
    // Fecha modificación: 2025-04-15 04:55
    // Autor: mincho77
    // Entradas: index (number): Índice de la palabra a editar en la lista
    // excludeWords. Salidas: Ninguna. Crea un modal para editar la palabra
    // seleccionada. Descripción: Muestra un modal para permitir al usuario
    // editar una palabra en la lista excludeWords. Se valida que la palabra no
    // esté vacía y que no exista otra igual en la lista. Si la palabra es
    // válida, se actualiza la lista y se guarda en localStorage.
    // *****************************************************************************************************
    function openEditPopup(index)
    {
        const currentWord = excludeWords[index];
        if (!currentWord)
            return;

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

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

                excludeWords[index] = newWord;
                localStorage.setItem("excludeWords",
                                     JSON.stringify(excludeWords));
                renderExcludedWordsPanel();
                showModal({
                    title : "Actualizada",
                    message : "La palabra fue modificada correctamente.",
                    confirmText : "Aceptar",
                    type : "success",
                    autoClose : 3000
                });
            }
        });
    }

    // ********************************************************************************************************************************
    // Nombre: waitForElement
    // Fecha modificación: 2025-04-10
    // Autor: mincho77
    // Entradas:
    // - selector (string): El selector CSS del elemento que se desea esperar en
    // el DOM.
    // - callback (function): Función que se ejecutará una vez que el elemento
    // se encuentre en el DOM.
    // - interval (number, opcional): Tiempo en milisegundos entre cada intento
    // de búsqueda (por defecto: 300ms).
    // - maxAttempts (number, opcional): Número máximo de intentos antes de
    // abandonar la búsqueda (por defecto: 20). Salidas: Ninguna. Ejecuta el
    // callback pasando el elemento encontrado o muestra una advertencia en la
    // consola si no se encuentra. Prerrequisitos:
    // - El DOM debe estar cargado.
    // Descripción:
    // Esta función espera a que un elemento definido por un selector CSS
    // aparezca en el DOM. Utiliza un intervalo de tiempo (interval) para
    // realizar múltiples comprobaciones, hasta un máximo definido
    // (maxAttempts). Si el elemento se encuentra dentro de esos intentos, se
    // ejecuta la función callback con el elemento como argumento. Si no se
    // encuentra después de los intentos máximos, se detiene y se muestra una
    // advertencia en la consola. Esto es útil para asegurarse de que elementos
    // dinámicos estén disponibles antes de asignarles event listeners o
    // manipularlos.
    // ********************************************************************************************************************************
    function waitForElement(
      selector, callback, interval = 300, maxAttempts = 20)
    {
        let attempts = 0;
        const checkExist = setInterval(() => {
            const element = document.querySelector(selector);
            attempts++;
            if (element)
            {
                clearInterval(checkExist);
                callback(element);
            }
            else if (attempts >= maxAttempts)
            {
                clearInterval(checkExist);
                console.warn(`No se encontró el elemento ${
                  selector} después de ${maxAttempts} intentos.`);
            }
        }, interval);
    }
    // ********************************************************************************************************************************
    // Nombre: safeRedirect
    // Fecha modificación: 2025-03-31
    // Autor: mincho77
    // Entradas:
    // - url (string): La URL a la que se desea redirigir.
    // Salidas: Ninguna.
    // Descripción:
    // Esta función redirige a una URL solo si el dominio es uno de los
    // permitidos.
    // ********************************************************************************************************************************
    function safeRedirect(url)
    {
        const allowedDomains =
          [ "example.com", "mywebsite.com" ]; // Dominios permitidos
        try
        {
            const parsedUrl = new URL(url);
            if (allowedDomains.includes(parsedUrl.hostname))
            {
                window.location.href =
                  url; // Redirige solo si el dominio es válido
            }
            else
            {
                console.error("Redirección no permitida a:", url);
            }
        }
        catch (e)
        {
            console.error("URL inválida:", url);
        }
    }
    // ********************************************************************************************************************************
    // Nombre: redirectTo
    // Fecha modificación: 2025-03-31
    // Autor: mincho77
    // Entradas: path (string): Ruta a la que se desea redirigir.
    // ********************************************************************************************************************************
    function redirectTo(path)
    {
        const allowedPaths =
          [ "/home", "/profile", "/settings" ]; // Rutas permitidas
        if (allowedPaths.includes(path))
        {
            window.location.pathname = path;
        }
        else
        {
            console.error("Ruta no permitida:", path);
        }
    }
    // ********************************************************************************************************************************
    // Nombre: sanitizeUrl
    // Fecha modificación: 2025-03-31
    // Autor: mincho77
    // Entradas: url (string): La URL que se desea sanitizar.
    // Salidas: string: La URL sanitizada.
    // ********************************************************************************************************************************
    function sanitizeUrl(url)
    {
        const div = document.createElement("div");
        div.innerText = url;
        return div.innerHTML; // Escapa caracteres peligrosos
    }
    // ********************************************************************************************************************************
    // Nombre: waitForDOM
    // Fecha modificación: 2025-03-31
    // Autor: mincho77
    // Entradas:
    // - selector (string): Selector del elemento a esperar.
    // - callback (function): Función a ejecutar cuando se encuentre el
    // elemento.
    // - interval (number, opcional): Intervalo de tiempo entre intentos
    // (default: 500ms).
    // - maxAttempts (number, opcional): Número máximo de intentos (default:
    // 10). Salidas: Ninguna. Descripción: Espera a que un elemento identificado
    // por el selector exista en el DOM. Si se encuentra antes de llegar al
    // número máximo de intentos, se ejecuta el callback.
    // ********************************************************************************************************************************
    function waitForDOM(selector, callback, interval = 500, maxAttempts = 10)
    {
        let attempts = 0;
        const checkExist = setInterval(() => {
            const element = document.querySelector(selector);
            if (element)
            {
                clearInterval(checkExist);
                callback(element);
            }
            else if (attempts >= maxAttempts)
            {
                clearInterval(checkExist);
                console.error(`[${
                  SCRIPT_NAME}] Error: No se encontraron elementos con selector "${
                  selector}" después de ${maxAttempts} intentos.`);
            }
            attempts++;
        }, interval);
    }
    // ********************************************************************************************************************************
    // 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")) || [];
        // Se combinan sin duplicados y se ordena
        const merged = [...new Set([...saved, ...excludeWords ]) ].sort(
          (a, b) => a.localeCompare(b));
        if (JSON.stringify(saved.sort()) !== JSON.stringify(merged))
        {
            localStorage.setItem("excludeWords", JSON.stringify(merged));
            console.log(`[initializeExcludeWords] Actualizado: ${
              merged.length} palabras.`);
        }
        else
        {
            console.log("[initializeExcludeWords] Sin cambios en la lista.");
        }
        excludeWords = merged;
    }
    // ********************************************************************************************************************************
    // Nombre: getSidebarHTML
    // Fecha modificación: 2025-04-09
    // Autor: mincho77
    // Entradas: Ninguna
    // Salidas: Retorna un string que contiene el HTML para el panel lateral del
    // normalizador. Descripción: Esta función construye el HTML que se
    // inyectará en el panel lateral del script. Se agregó un nuevo bloque para
    // incluir un dropdown (select) con id "categoryDropdown" que permite
    // filtrar por categoría de los places. La opción por defecto es
    // "Categorías". Además, se incluyen controles para manejar el número máximo
    // de places, palabras excluidas, exportación/importación de la lista de
    // palabras excluidas, y un área de drag & drop para importar archivos.
    // Finalmente, se incluye un botón "Scan..." para iniciar el escaneo de
    // lugares.
    // ********************************************************************************************************************************
    function getSidebarHTML()
    {
        return `
      <div id="normalizer-tab">
          <h4>Places Name Normalizer <span style="font-size:11px;">${
          VERSION}</span></h4>

          <!-- No Normalizar artículos -->
          <div style="margin-top: 15px;">
              <input type="checkbox" id="normalizeArticles" ${
          normalizeArticles ? "checked" : ""}>
              <label for="normalizeArticles">No Normalizar artículos (el, la, los, ...)</label>
          </div>

          <!-- Máximo de Places a buscar -->
          <div style="margin-top: 15px;">
              <label>Máximo de Places a buscar: </label>
              <input type="number" id="maxPlacesInput" value="${
          maxPlaces}" min="1" max="800" style="width: 60px;">
          </div>

          <!-- Palabras Especiales con flecha -->
          <details id="details-special-words" style="margin-top: 15px;">
            <summary style="cursor: pointer; font-weight: bold; list-style: none;">
              <span id="arrow" style="display: inline-block; transition: transform 0.2s;">▶</span> Palabras Especiales
            </summary>

            <div style="margin-top: 10px; display: flex; gap: 5px;">
              <input type="text" id="excludeWord" placeholder="Agregar palabra..." style="flex: 1; padding: 5px; border: 1px solid #ccc; border-radius: 4px;">
              <button id="addExcludeWord" style="background: #3498db; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">
                Agregar
              </button>
            </div>

            <!-- Campo para buscar y filtrar palabras -->
            <div style="margin-top: 10px; display: flex; gap: 5px;">
              <input type="text" id="searchWord" placeholder="Buscar palabra..." style="flex: 1; padding: 5px; border: 1px solid #ccc; border-radius: 4px;">
              <button id="searchExcludeWord" style="background: #27ae60; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">
                Buscar
              </button>
            </div>

            <div id="normalizer-sidebar" style="margin-top: 10px; max-height: 200px; overflow-y: auto;"></div>

            <button id="exportExcludeWords" style="margin-top: 10px;">Exportar Palabras</button>
            <button id="importExcludeWordsUnifiedBtn" style="margin-top: 5px;">Importar Lista</button>
            <input type="file" id="hiddenImportInput" accept=".xml,.txt" style="display: none;">

            <div style="margin-top: 5px;">
              <input type="checkbox" id="replaceExcludeListCheckbox">
              <label for="replaceExcludeListCheckbox">Reemplazar lista actual</label>
            </div>

            <!-- Drag & Drop -->
            <div id="drop-zone" style="border: 2px dashed #ccc; border-radius: 6px; padding: 15px; margin: 15px 0; text-align: center; font-style: italic; color: #555; background-color: #f8f9fa;">
              📂 Arrastra aquí tu archivo .txt o .xml para importar palabras especiales
            </div>
          </details>

          <hr>

          <!-- Botón Scan -->
          <button id="scanPlaces">Scan...</button>

          <script>
          (function waitForSearchElements() {
              const searchInput = document.getElementById('searchWord');
              const searchButton = document.getElementById('searchExcludeWord');

              if (searchInput && searchButton) {
                  // Rotación de flecha
                  const detailsElem = document.getElementById('details-special-words');
                  const arrow = document.getElementById('arrow');
                  if (detailsElem && arrow) {
                      detailsElem.addEventListener('toggle', function() {
                          arrow.style.transform = detailsElem.open ? 'rotate(90deg)' : 'rotate(0deg)';
                      });
                  }

                  // Evento de búsqueda
                  searchButton.addEventListener('click', function () {
                      const query = searchInput?.value?.toLowerCase()?.trim() || "";
                      const items = document.querySelectorAll('#normalizer-sidebar div');
                      items.forEach(item => {
                          const text = item.textContent.toLowerCase();
                          item.style.display = text.includes(query) ? 'flex' : 'none';
                      });
                  });
              } else {
                  // Reintenta en 200ms
                  setTimeout(waitForSearchElements, 200);
              }
          })();
          </script>
      </div>
      `;
    }
    // ********************************************************************************************************************************
    // Nombre: attachEvents
    // Fecha modificación: 2025-03-30
    // Autor: mincho77
    // Entradas: Ninguna
    // Salidas: Ninguna
    // Prerrequisitos si existen:
    // - Deben existir en el DOM los elementos con los siguientes IDs:
    // "normalizeArticles", "maxPlacesInput", "addExcludeWord", "scanPlaces",
    // "hiddenImportInput", "importExcludeWordsUnifiedBtn" y
    // "exportExcludeWords".
    // - Debe existir la función handleImportList y la función scanPlaces.
    // - Debe estar definida la variable global excludeWords y la función
    // renderExcludedWordsPanel. Descripción: Esta función adjunta los event
    // listeners necesarios para gestionar la interacción del usuario con el
    // panel del normalizador de nombres. Se encargan de:
    // - Actualizar la opción de normalizar artículos al cambiar el estado del
    // checkbox.
    // - Modificar el número máximo de lugares a procesar a través de un input.
    // - Exportar la lista de palabras excluidas a un archivo XML.
    // - Añadir nuevas palabras a la lista de palabras excluidas, evitando
    // duplicados, y actualizar el panel.
    // - Activar el botón unificado para la importación de palabras excluidas
    // mediante un input oculto.
    // - Ejecutar la función de escaneo de lugares al hacer clic en el botón
    // correspondiente.
    // ********************************************************************************************************************************
    function attachEvents()
    {
        console.log(`[${SCRIPT_NAME}] Adjuntando eventos...`);
        const normalizeArticlesCheckbox =
          document.getElementById("normalizeArticles");
        const maxPlacesInput = document.getElementById("maxPlacesInput");
        const addExcludeWordButton = document.getElementById("addExcludeWord");
        const scanPlacesButton = document.getElementById("scanPlaces");
        const hiddenInput = document.getElementById("hiddenImportInput");
        const importButtonUnified =
          document.getElementById("importExcludeWordsUnifiedBtn");
        // Validación de elementos necesarios
        if (!normalizeArticlesCheckbox || !maxPlacesInput ||
            !addExcludeWordButton || !scanPlacesButton)
        {
            console.error(
              `[${SCRIPT_NAME}] Error: No se encontraron elementos en el DOM.`);
            return;
        }
        // Evento: cambiar estado de "no normalizar artículos"
        normalizeArticlesCheckbox.addEventListener(
          "change", (e) => { normalizeArticles = e.target.checked; });
        // Evento: cambiar número máximo de places
        maxPlacesInput.addEventListener(
          "input", (e) => { maxPlaces = parseInt(e.target.value, 10); });
        // Evento: exportar palabras excluidas a XML
        document.getElementById("exportExcludeWords")
          .addEventListener("click", () => {
              const savedWords =
                JSON.parse(localStorage.getItem("excludeWords")) || [];
              if (savedWords.length === 0)
              {
                  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)
            {
                excludeWords.push(word);
                localStorage.setItem("excludeWords",
                                     JSON.stringify(excludeWords));
                renderExcludedWordsPanel();
            }
            wordInput.value = "";
        });
        // Evento: nuevo botón unificado de importación
        importButtonUnified.addEventListener("click",
                                             () => { hiddenInput.click(); });
        hiddenInput.addEventListener("change", () => { handleImportList(); });
        // Evento: escanear lugares
        scanPlacesButton.addEventListener("click", scanPlaces);
    }
    // ********************************************************************************************************************************
    // Nombre: populateCategoryDropdownStatic
    // Fecha modificación: 2025-04-10
    // Autor: mincho77
    // Entradas: Ninguna.
    // Salidas: Ninguna. Actualiza el contenido del dropdown con id
    // "categoryDropdown" usando una
    //          lista de categorías estáticas.
    // Prerrequisitos:
    // - El DOM debe contener un <select id="categoryDropdown">.
    // Descripción:
    // Rellena el dropdown con una lista estática de categorías. Primero limpia
    // el contenido actual y añade la opción "Todos los places", luego recorre
    // un arreglo de categorías definidas de forma estática y agrega una opción
    // (<option>) para cada categoría.
    // ********************************************************************************************************************************
    function populateCategoryDropdownStatic()
    {
        waitForElement("#categoryDropdown", function(dropdown) {
            console.log(
              ">> populateCategoryDropdownStatic: Elemento encontrado:",
              dropdown);
            const categoriesStatic = [
                { id : "FOOD", name : "Food & Drink" },
                { id : "SHOP", name : "Shopping" },
                { id : "TRANSPORT", name : "Transportation" },
                { id : "LODGING", name : "Lodging" },
                { id : "ENTERTAINMENT", name : "Entertainment" },
                { id : "HEALTH", name : "Health" },
                { id : "EDUCATION", name : "Education" },
                { id : "GOVERNMENT", name : "Government" },
                { id : "FINANCE", name : "Finance" },
                { id : "RESIDENTIAL", name : "Residential" },
                { id : "COMMERCIAL", name : "Commercial" }
            ];
            // Limpia el contenido actual
            dropdown.innerHTML = "";
            // Agrega la opción por defecto: "Todas las Categorías"
            const optAll = document.createElement("option");
            optAll.value = "all";
            optAll.textContent = "All";
            dropdown.appendChild(optAll);
            // Agrega cada categoría estática
            categoriesStatic.forEach((cat) => {
                const option = document.createElement("option");
                option.value = cat.id;
                option.textContent = cat.name;
                dropdown.appendChild(option);
            });
            console.log(">> Dropdown poblado:", dropdown.innerHTML);
            for (let prop in W.model)
            {
                console.log("W.model prop:", prop, W.model[prop]);
            }
        });
    }
    // ********************************************************************************************************************************
    // Nombre: populateCategoryDropdown
    // Fecha modificación: 2025-06-20 14:30 GMT-5
    // Autor: mincho77
    // Entradas:
    // - Ninguna (usa el elemento DOM con id "categoryDropdown")
    // Salidas: Ninguna. Modifica el DOM al poblar el dropdown de categorías.
    // Prerrequisitos:
    // - El DOM debe contener un elemento <select> con id "categoryDropdown"
    // - El objeto global W debe estar disponible (entorno WME)
    // Descripción:
    // Esta función llena un dropdown con todas las categorías de lugares
    // disponibles en WME. Primero intenta obtener las categorías dinámicamente
    // del modelo WME (W.model.venues.getCategories()). Si falla la carga
    // dinámica, utiliza una lista estática completa de categorías como
    // respaldo. Las categorías se ordenan alfabéticamente y se añade siempre
    // una opción "Todas las categorías" al inicio. La función incluye manejo
    // robusto de errores y registra mensajes en la consola para diagnóstico.
    // ********************************************************************************************************************************
    function populateCategoryDropdown()
    {
        waitForElement("#categoryDropdown", (dropdown) => {
            if (!dropdown)
            {
                console.error("No se encontró el dropdown de categorías");
                return;
            }
            // Limpiar dropdown existente
            dropdown.innerHTML = "";
            // 1. Añadir opción "Todas" por defecto
            const defaultOption = document.createElement("option");
            defaultOption.value = "all";
            defaultOption.textContent = "Todas";
            dropdown.appendChild(defaultOption);
            // 2. Intentar cargar categorías dinámicas de WME
            try
            {
                const categories = W.model.venues.getCategories?.() || [];
                if (categories.length > 0)
                { // Ordenar alfabéticamente
                    categories.sort((a, b) => a.name.localeCompare(b.name));
                    // Agregar al dropdown
                    categories.forEach((cat) => {
                        const option = document.createElement("option");
                        option.value = cat.id;
                        option.textContent = cat.name;
                        dropdown.appendChild(option);
                    });
                    console.log(
                      `Categorías cargadas desde WME: ${categories.length}`);
                    return;
                }
            }
            catch (e)
            {
                console.error("Error al cargar categorías de WME:", e);
            }
            // 3. Respaldo: Categorías estáticas completas
            const staticCategories = [
                { id : "AIRPORT", name : "Aeropuerto" },
                { id : "ATM", name : "Cajero automático" },
                { id : "BANK", name : "Banco" },
                { id : "BAR", name : "Bar" },
                { id : "CAFE", name : "Cafetería" },
                { id : "CAR_RENTAL", name : "Renta de autos" },
                { id : "CAR_REPAIR", name : "Taller mecánico" },
                { id : "DELIVERY", name : "Entrega a domicilio" },
                { id : "EDUCATION", name : "Educación" },
                { id : "EMBASSY", name : "Embajada" },
                { id : "EMERGENCY", name : "Emergencia" },
                { id : "ENTERTAINMENT", name : "Entretenimiento" },
                { id : "FERRY", name : "Ferry" },
                { id : "FOOD", name : "Comida" },
                { id : "GAS_STATION", name : "Gasolinera" },
                { id : "GOVERNMENT", name : "Gobierno" },
                { id : "HOSPITAL", name : "Hospital" },
                { id : "HOTEL", name : "Hotel" },
                { id : "PARKING", name : "Estacionamiento" },
                { id : "PHARMACY", name : "Farmacia" },
                { id : "POLICE", name : "Policía" },
                { id : "POST_OFFICE", name : "Correo" },
                { id : "PUBLIC_TRANSPORT", name : "Transporte público" },
                { id : "RELIGIOUS", name : "Lugar religioso" },
                { id : "RESTAURANT", name : "Restaurante" },
                { id : "SHOPPING", name : "Compras" },
                { id : "TAXI", name : "Taxi" },
                { id : "TOURISM", name : "Turismo" },
                { id : "TRAIN_STATION", name : "Estación de tren" },
                { id : "UNIVERSITY", name : "Universidad" },
                { id : "ZOO", name : "Zoológico" }
            ];
            staticCategories.forEach((cat) => {
                const option = document.createElement("option");
                option.value = cat.id;
                option.textContent = cat.name;
                dropdown.appendChild(option);
            });
            console.log("Categorías cargadas desde lista estática");
        });
    }
    // ********************************************************************************************************************************
    // Nombre: getCategoriaTextoDesdeIDs
    // Fecha modificación: 2025-04-10
    // Autor: mincho77
    // Entradas:
    // - venue (object): Objeto de lugar de WME.
    // Salidas: string: Texto de la categoría o "Sin categoría" si no se
    // encuentra.
    // Descripción:
    // Esta función toma un objeto de lugar de WME y devuelve un string que
    // representa la categoría del lugar. Si el lugar no tiene categorías
    // asignadas, devuelve "Sin categoría". Si se producen errores durante el
    // proceso, se captura la excepción y se devuelve "Sin categoría".
    // ********************************************************************************************************************************
    function getCategoriaTextoDesdeIDs(ids = [])
    {
        try {
            const allCats = W.model?.categories?.objects;
            if (!allCats) throw new Error("No se pudo acceder a las categorías");

            return ids
                .map(id => allCats[id]?.name || `ID ${id}`)
                .join(", ");
        } catch (err) {
            console.error("Error al mapear IDs de categoría:", err);
            return "Sin categoría";
        }
    }




    // ********************************************************************************************************************************
    // Nombre: createSidebarTab
    // Fecha modificación: 2025-04-09
    // Autor: mincho77
    // Entradas: Ninguna.
    // Salidas: Ninguna.
    // Prerrequisitos si existen:
    // - La función W.userscripts.registerSidebarTab debe estar disponible en el
    // entorno WME. Descripción: Crea y registra una nueva pestaña lateral en el
    // WME para el normalizador. Inyecta el HTML generado por getSidebarHTML() y
    // espera a que se renderice el DOM para adjuntar los eventos.
    // ********************************************************************************************************************************
    function createSidebarTab()
    {
        try
        { // Check if the sidebar system is ready
            if (!W || !W.userscripts)
            {
                console.error(
                  `[${SCRIPT_NAME}] WME not ready for sidebar creation`);
                return;
            }
            // Check for existing tab and clean up if needed
            const existingTab = document.getElementById("normalizer-tab");
            if (existingTab)
            {
                console.log(`[${SCRIPT_NAME}] Removing existing tab...`);
                existingTab.remove();
            }
            // Register new tab with error handling
            let registration;
            try
            {
                registration =
                  W.userscripts.registerSidebarTab("PlacesNormalizer");
            }
            catch (e)
            {
                if (e.message.includes("already been registered"))
                {
                    console.warn(`[${
                      SCRIPT_NAME}] Tab registration conflict, attempting cleanup...`);
                    // Additional cleanup could go here
                    return;
                }
                throw e;
            }
            const { tabLabel, tabPane } = registration;
            if (!tabLabel || !tabPane)
            {
                throw new Error(
                  "Tab registration failed to return required elements");
            }
            // Configure tab
            tabLabel.innerHTML = `
          <img src="data:image/jpeg;base64,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"
          style="height: 16px; vertical-align: middle; margin-right: 5px;">
          NrmliZer
          `;
            tabLabel.title = "Places Name Normalizer";
            // Set content and attach events
            tabPane.innerHTML = getSidebarHTML();
            waitForDOM("#normalizer-tab", (element) => {
                attachEvents();
                console.log(`[${SCRIPT_NAME}] Tab created and events attached`);
            }, 500, 10);
        }
        catch (error)
        {
            console.error(`[${SCRIPT_NAME}] Error in createSidebarTab:`, error);
        }
    }
    // *****************************************************************************************************
    // Nombre: checkSpellingWithAPI
    // Fecha modificación: 2025-03-30
    // Autor: mincho77
    // Entradas: text (string) – Texto a evaluar ortográficamente.
    // Salidas: Promise – Resuelve con lista de errores ortográficos detectados.
    // Prerrequisitos: Requiere permisos GM_xmlhttpRequest y @connect a
    // api.languagetool.org Descripción: Consulta la API de LanguageTool para
    // verificar ortografía del texto.
    // *****************************************************************************************************
    function checkSpellingWithAPI(text)
    {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method : "POST",
                url : "https://api.languagetool.org/v2/check",
                headers :
                  { "Content-Type" : "application/x-www-form-urlencoded" },
                data : `language=es&text=${encodeURIComponent(text)}`,
                onload : function(response) {
                    if (response.status === 200)
                    {
                        const result = JSON.parse(response.responseText);
                        const errores = result.matches.map(
                          (match) => ({
                              palabra : match.context.text.substring(
                                match.context.offset,
                                match.context.offset + match.context.length),
                              sugerencia : match.replacements.length > 0
                                             ? match.replacements[0].value
                                             : "(sin sugerencia)"
                          }));
                        resolve(errores);
                    }
                    else
                    {
                        reject("❌ Error en respuesta de LanguageTool");
                    }
                },
                onerror : function(
                  err) { reject("❌ Error de red al contactar LanguageTool"); }
            });
        });
    }
    window.checkSpellingWithAPI = checkSpellingWithAPI;
    // *****************************************************************************************************
    // Nombre: evaluarOrtografiaCompleta
    // Fecha modificación: 2025-04-10 22:15 GMT-5
    // Autor: mincho77
    // Entradas:
    // - texto (string): Texto a evaluar
    // - config (opcional): {
    //       usarAPI: true,       // Usar LanguageTool
    //       reglasLocales: true, // Aplicar reglas de tildes
    //       timeout: 5000        // Tiempo máximo para API
    //     }
    // Salidas:
    // Promise<{
    //     original: string,
    //     normalizado: string,
    //     errores: Array<{
    //       palabra: string,
    //       sugerencia: string,
    //       tipo: 'ortografia'|'tilde'|'gramatica',
    //       severidad: 'alta'|'media'|'baja'
    //     }>,
    //     metadata: {
    //       totalErrores: number,
    //       apiUsada: boolean,
    //       tiempoProcesamiento: number
    //     }
    // }>
    // Descripción:
    // Sistema completo que combina normalización y revisión ortográfica real
    // *****************************************************************************************************
    async function evaluarOrtografiaCompleta(texto, config = {})
    {
        const inicio = Date.now();
        const resultadoBase = {
            original : texto,
            normalizado : "",
            errores : [],
            metadata :
              { totalErrores : 0, apiUsada : false, tiempoProcesamiento : 0 }
        };
        // 1. Normalización básica inicial
        const normalizado = normalizePlaceName(texto);
        resultadoBase.normalizado = normalizado;
        // 2. Detección de errores locales (síncrono)
        if (config.reglasLocales !== false)
        {
            const erroresLocales = detectarErroresLocales(texto, normalizado);
            resultadoBase.errores.push(...erroresLocales);
        }
        // 3. Revisión con API LanguageTool (asíncrono)
        if (config.usarAPI !== false && texto.length > 1)
        {
            try
            {
                const resultadoAPI =
                  await revisarConLanguageTool(texto, config.timeout);
                resultadoBase.errores.push(...resultadoAPI.errores);
                resultadoBase.metadata.apiUsada = true;
            }
            catch (error)
            {
                console.error("Error en API LanguageTool:", error);
            }
        }
        // 4. Filtrar y clasificar errores
        resultadoBase.errores = filtrarErrores(resultadoBase.errores);
        resultadoBase.metadata.totalErrores = resultadoBase.errores.length;
        resultadoBase.metadata.tiempoProcesamiento = Date.now() - inicio;
        return resultadoBase;
    }
    // ==================== FUNCIONES DE SOPORTE ====================
    // *****************************************************************************************************
    // Nombre: detectarErroresLocales
    // Descripción: Detecta errores de tildes y mayúsculas
    // *****************************************************************************************************
    function detectarErroresLocales(original, normalizado)
    {
        const errores = [];
        const palabrasOriginal = original.split(/\s+/);
        const palabrasNormalizado = normalizado.split(/\s+/);
        palabrasOriginal.forEach((palabra, i) => {
            const palabraNormalizada = palabrasNormalizado[i] || palabra;
            // 1. Comparación directa para detectar cambios
            if (palabra !== palabraNormalizada)
            {
                errores.push({
                    palabra,
                    sugerencia : palabraNormalizada,
                    tipo : "ortografia",
                    severidad : "media"
                });
            }
            // 2. Detección específica de tildes
            if (tieneTildesIncorrectas(palabra))
            {
                errores.push({
                    palabra,
                    sugerencia : corregirTildeLocal(palabra),
                    tipo : "tilde",
                    severidad : "alta"
                });
            }
        });
        return errores;
    }
    // *****************************************************************************************************
    // Nombre: revisarConLanguageTool
    // Fecha modificación: 2025-04-10 22:15 GMT-5
    // Autor: mincho77
    // Entradas:
    // - texto (string): Texto a evaluar
    // - timeout (opcional): Tiempo máximo para la API (en milisegundos)
    // Salidas:
    // Promise<{
    //     errores: Array<{
    //       palabra: string,
    //       sugerencia: string,
    //       tipo: 'ortografia'|'gramatica',
    //       severidad: 'alta'|'media'
    //     }>,
    //     apiStatus:
    //     'success'|'timeout'|'parse_error'|'api_error'|'network_error'
    // }>
    // Prerrequisitos: Requiere permisos GM_xmlhttpRequest y @connect a
    // api.languagetool.org Descripción: Consulta la API para errores
    // ortográficos y gramaticales
    // *****************************************************************************************************
    // Descripción: Consulta la API para errores avanzados
    // *****************************************************************************************************
    function revisarConLanguageTool(texto, timeout = 5000)
    {
        return new Promise((resolve) => {
            const timer = setTimeout(
              () => { resolve({ errores : [], apiStatus : "timeout" }); },
              timeout);
            GM_xmlhttpRequest({
                method : "POST",
                url : "https://api.languagetool.org/v2/check",
                headers :
                  { "Content-Type" : "application/x-www-form-urlencoded" },
                data : `language=es&text=${encodeURIComponent(texto)}`,
                onload : function(response) {
                    clearTimeout(timer);
                    if (response.status === 200)
                    {
                        try
                        {
                            const data = JSON.parse(response.responseText);
                            const errores = data.matches.map(
                              (match) => ({
                                  palabra : match.context.text.substring(
                                    match.context.offset,
                                    match.context.offset +
                                      match.context.length),
                                  sugerencia : match.replacements[0]?.value ||
                                                 match.context.text,
                                  tipo : match.rule.category.id === "TYPOS"
                                           ? "ortografia"
                                           : "gramatica",
                                  severidad :
                                    match.rule.issueType === "misspelling"
                                      ? "alta"
                                      : "media"
                              }));
                            resolve({ errores, apiStatus : "success" });
                        }
                        catch (e)
                        {
                            resolve(
                              { errores : [], apiStatus : "parse_error" });
                        }
                    }
                    else
                    {
                        resolve({ errores : [], apiStatus : "api_error" });
                    }
                },
                onerror : function() {
                    clearTimeout(timer);
                    resolve({ errores : [], apiStatus : "network_error" });
                }
            });
        });
    }
    // *****************************************************************************************************
    // Nombre: filtrarErrores
    // Descripción: Elimina duplicados y errores menores
    // *****************************************************************************************************
    function filtrarErrores(errores)
    {
        const unicos = [];
        const vistas = new Set();
        errores.forEach((error) => {
            const clave = `${error.palabra}-${error.sugerencia}-${error.tipo}`;
            if (!vistas.has(clave))
            {
                vistas.add(clave);
                unicos.push(error);
            }
        });
        return unicos.sort((a, b) => {
            if (a.severidad === b.severidad)
                return 0;

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

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

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

        // Monosílabos ya evaluados
        const esAguda = numSilabas === 1 ||
                        (numSilabas > 1 && silabas[numSilabas - 1].acento);
        const debeTildarAguda =
          esAguda && /[nsaeiouáéíóú]$/i.test(palabraNormalizada);
        const palabraLower = palabra.toLowerCase();
        if (correccionesEspecificas[palabraLower])
        {
            return aplicarCapitalizacion(palabra,
                                         correccionesEspecificas[palabraLower]);
        }
        // Determinar sílaba a tildar
        if (numSilabas > 2 && esEsdrujula(palabra))
        {
            silabaTildada = numSilabas - 3;
        }
        else if (numSilabas > 1 && esGrave(palabra))
        {
            silabaTildada = numSilabas - 2;
        }
        else if (esAguda(palabra))
        {
            silabaTildada = numSilabas - 1;
        }
        if (silabaTildada >= 0)
        {
            return aplicarTildeSilaba(palabra, silabas, silabaTildada);
        }
        return palabra;
    }
    // ==================== FUNCIONES AUXILIARES ====================
    // *****************************************************************************************************
    // Nombre: separarSilabas
    // Fecha modificación: 2025-04-10 22:15 GMT-5
    // Autor: mincho77
    // Entradas: palabra (string) – Palabra a separar en sílabas.
    // Salidas: Array<{ texto: string, acento: boolean }> – Lista de sílabas
    // Descripción: Separa la palabra en sílabas y determina si cada sílaba
    // tiene acento. Implementación simplificada para propósitos de
    // normalización visual.
    // *****************************************************************************************************
    function separarSilabas(palabra)
    { // Implementación simplificada (usar librería completa en producción)
        const vocalesFuertes = /[aeoáéó]/;
        const vocalesDebiles = /[iuü]/;
        const silabas = [];
        let silabaActual = "";
        let tieneVocalFuerte = false;
        for (let i = 0; i < palabra.length; i++)
        {
            const c = palabra[i];
            silabaActual += c;
            if (vocalesFuertes.test(c))
            {
                tieneVocalFuerte = true;
            }
            // Lógica simplificada de separación
            if (i < palabra.length - 1 &&
                ((vocalesFuertes.test(c) &&
                  vocalesFuertes.test(palabra[i + 1])) ||
                 (vocalesDebiles.test(c) &&
                  vocalesFuertes.test(palabra[i + 1]) && !tieneVocalFuerte)))
            {
                silabas.push(
                  { texto : silabaActual, acento : tieneVocalFuerte });
                silabaActual = "";
                tieneVocalFuerte = false;
            }
        }
        if (silabaActual)
        {
            silabas.push({ texto : silabaActual, acento : tieneVocalFuerte });
        }
        return silabas;
    }
    // *****************************************************************************************************
    // Nombre: aplicarCapitalizacion
    // Fecha modificación: 2025-04-10 22:15 GMT-5
    // Autor: mincho77
    // Entradas: original (string) – Palabra original
    //           corregida (string) – Palabra corregida
    // Salidas: string – Palabra corregida con mayúsculas/minúsculas
    // Descripción: Aplica mayúsculas/minúsculas a la palabra corregida
    // según la original. Mantiene mayúsculas y minúsculas en la primera letra
    // y el resto de la palabra.
    // *****************************************************************************************************
    function aplicarCapitalizacion(original, corregida)
    {
        if (original === original.toUpperCase())
        {
            return corregida.toUpperCase();
        }
        else if (original[0] === original[0].toUpperCase())
        {
            return corregida[0].toUpperCase() + corregida.slice(1);
        }
        return corregida;
    }
    // *****************************************************************************************************
    // Nombre: aplicarTildeSilaba
    // Descripción: Aplica tilde a la sílaba especificada
    // *****************************************************************************************************
    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: normalizePlaceNameOnly
    // Fecha modificación: 2025-03-30
    // Autor: mincho77
    // Entradas: name (string) – Nombre del lugar a normalizar.
    // Salidas: texto normalizado sin validación ortográfica.
    // Descripción:
    // Realiza normalización visual del nombre: capitaliza, ajusta espacios,
    // formatea guiones, paréntesis, y símbolos. No evalúa ortografía ni
    // acentos.
    // *****************************************************************************************************
    function normalizePlaceNameOnly(name)
    {
        if (!name)
            return "";

        const normalizeArticles =
          !document.getElementById("normalizeArticles")?.checked;
        const articles = [ "el", "la", "los", "las", "de", "del", "al", "y" ];
        const words = name.trim().split(/\s+/);
        const isRoman = (word) =>
          /^(i{1,3}|iv|v|vi{0,3}|ix|x|xi{0,3}|xiv|xv|xvi{0,3}|xix|xx|xxi{0,3}|xxiv|xxv)$/i
            .test(word);
        const normalizedWords = words.map((word, index) => {
            const lowerWord = word.toLowerCase();
            // Si contiene "&", convertir a mayúsculas
            if (/^[A-Za-z]&[A-Za-z]$/.test(word))
                return word.toUpperCase();

            // Verificar si está en la lista de excluidas
            const matchExcluded =
              excludeWords.find((w) => w.toLowerCase() === lowerWord);
            if (matchExcluded)
                return matchExcluded;

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

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

            // Si es un número o un símbolo como "-", no modificar
            if (/^\d+$/.test(word) || word === "-")
                return word;

            // Verificar ortografía usando la API de LanguageTool
            return checkSpellingWithAPI(word)
              .then((errors) => {
                  if (errors.length > 0)
                  {
                      const suggestion = errors[0].sugerencia || word;
                      return (suggestion.charAt(0).toUpperCase() +
                              suggestion.slice(1).toLowerCase());
                  }
                  return word.charAt(0).toUpperCase() +
                         word.slice(1).toLowerCase();
              })
              .catch(() => word.charAt(0).toUpperCase() +
                           word.slice(1).toLowerCase());
        });
        let newName =
          normalizedWords.join(" ")
            .replace(/\s*\|\s*/g, " - ")
            .replace(/([(["'])\s*([\p{L}])/gu,
                     (match, p1, p2) => p1 + p2.toUpperCase())
            .replace(/\s*-\s*/g, " - ")
            .replace(/\b(\d+)([A-Z])\b/g,
                     (match, num, letter) => num + letter.toUpperCase())
            .replace(/\.$/, "")
            .replace(/&(\s*)([A-Z])/g,
                     (match, space, letter) =>
                       "&" + space + letter.toUpperCase());
        return newName.replace(/\s{2,}/g, " ").trim();
    }

    //**************************************************************************
    // Nombre: 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(
          (palabra,
           index) => { // Normalizar la palabra antes de cualquier verificación
              let normalizada = normalizePlaceNameOnly(palabra);
              // Ignorar palabras con "&" o que sean emoticonos
              if (/^[A-Za-z]&[A-Za-z]$/.test(normalizada) ||
                  /^[\u263a-\u263c\u2764\u1f600-\u1f64f\u1f680-\u1f6ff]+$/.test(
                    normalizada))
              {
                  return; // No verificar ortografía
              }
              // Excluir palabras específicas como "y" o "Y"
              if (normalizada.toLowerCase() === "y" ||
                  /^\d+$/.test(normalizada) || normalizada === "-")
              {
                  return; // Ignorar
              }
              // Excluir palabras específicas como "e" o "E"
              if (normalizada.toLowerCase() === "e" ||
                  /^\d+$/.test(normalizada) || normalizada === "-")
              {
                  return; // Ignorar
              }
              // Verificar si la palabra está en la lista de excluidas
              if (excludeWords.some((w) => w.toLowerCase() ===
                                           normalizada.toLowerCase()))
              {
                  return; // Ignorar palabra excluida
              }
              // Validar que no tenga más de una tilde
              const cantidadTildes =
                (normalizada.match(/[áéíóú]/g) || []).length;
              if (cantidadTildes > 1)
              {
                  spellingWarnings.push({
                      original : palabra,
                      sugerida : null, // No hay sugerencia válida
                      tipo : "Error de tildes",
                      posicion : index
                  });
                  return;
              }
              // Verificar ortografía usando la API de LanguageTool
              checkSpellingWithAPI(normalizada)
                .then((errores) => {
                    errores.forEach((error) => {
                        spellingWarnings.push({
                            original : error.palabra,
                            sugerida : error.sugerencia,
                            tipo : "LanguageTool",
                            posicion : index
                        });
                    });
                })
                .catch((err) => {
                    console.error(
                      "Error al verificar ortografía con LanguageTool:", err);
                });
          });
        return {
            hasSpellingWarning : spellingWarnings.length > 0,
            spellingWarnings
        };
    }
    // *****************************************************************************************************
    // Nombre: toggleSpinner
    // Fecha modificación: 2025-03-31
    // Autor: mincho77
    // Entradas:
    // show (boolean) - true para mostrar el spinner, false para ocultarlo
    // message (string, opcional) - mensaje personalizado a mostrar junto al
    // spinner Salidas: ninguna (modifica el DOM) Prerrequisitos: debe existir
    // el estilo CSS del spinner en el documento Descripción: Muestra u oculta
    // un indicador visual de carga con un mensaje opcional. El spinner usa un
    // emoji de reloj de arena (⏳) con animación de rotación para indicar que
    // el proceso está en curso.
    // *****************************************************************************************************
    function toggleSpinner(
      show, message = "Revisando ortografía...", progress = null)
    {
        let existingSpinner = document.querySelector(".spinner-overlay");
        if (existingSpinner)
        {
            if (show)
            { // Actualizar el mensaje y el progreso si el spinner ya existe
                const spinnerMessage =
                  existingSpinner.querySelector(".spinner-message");
                spinnerMessage.innerHTML = `
                  ${message}
                  ${
                  progress !== null
                    ? `<br><strong>${progress}% completado</strong>`
                    : ""}
              `;
            }
            else
            {
                existingSpinner.remove(); // Ocultar el spinner
            }
            return;
        }
        if (show)
        {
            const spinner = document.createElement("div");
            spinner.className = "spinner-overlay";
            spinner.innerHTML = `
              <div class="spinner-content">
                  <div class="spinner-icon">⏳</div>
                  <div class="spinner-message">
                      ${message}
                      ${
              progress !== null ? `<br><strong>${progress}% completado</strong>`
                                : ""}
                  </div>
              </div>
          `;
            document.body.appendChild(spinner);
        }
    }
    // Agregar los estilos CSS necesarios
    const spinnerStyles = `
  <style>
  .spinner-overlay {
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background: rgba(0, 0, 0, 0.5);
      display: flex;
      justify-content: center;
      align-items: center;
      z-index: 10000;
  }

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

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

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

  @keyframes spin {
      0% { transform: rotate(0deg); }
      100% { transform: rotate(360deg); }
  }
  </style>`;
    // Insertar los estilos al inicio del documento
    document.head.insertAdjacentHTML("beforeend", spinnerStyles);
    if (!Array.prototype.flat)
    {
        Array.prototype.flat = function(depth = 1) {
            return this.reduce(function(flat, toFlatten) {
                return flat.concat(Array.isArray(toFlatten)
                                     ? toFlatten.flat(depth - 1)
                                     : toFlatten);
            }, []);
        };
    }
    // *****************************************************************************************************
    // Nombre: validarWordSpelling
    // Fecha modificación: 2025-03-30
    // Autor: mincho77
    // Entradas: palabra (string) - Palabra en español a validar
    // ortográficamente Salidas: true si cumple reglas ortográficas básicas,
    // false si no Descripción: Evalúa si una palabra tiene el uso correcto de
    // tilde o si le falta una tilde según las reglas del español: esdrújulas
    // siempre con tilde, agudas con tilde si terminan en n, s o vocal, y llanas
    // con tildse si NO terminan en n, s o vocal. Se asegura que solo haya una
    // tilde por palabra.
    // *****************************************************************************************************
    function validarWordSpelling(palabra)
    {
        if (!palabra)
            return false;

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

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

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

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

        const totalSilabas = silabas.length;
        const ultimaLetra = palabra.slice(-1).toLowerCase();
        let tipo = "";
        if (totalSilabas >= 3 && /[áéíóú]/.test(palabra))
        {
            tipo = "esdrújula";
        }
        else if (totalSilabas >= 2)
        {
            const penultimaSilaba = silabas[totalSilabas - 2];
            if (/[áéíóú]/.test(penultimaSilaba))
                tipo = "grave";
        }
        if (!tipo)
            tipo = /[áéíóú]/.test(silabas[totalSilabas - 1]) ? "aguda"
                                                                  : "sin tilde";

        if (tipo === "esdrújula")
            return tieneTilde;

        if (tipo === "aguda")
        {
            return ((/[nsáéíóúaeiou]$/.test(ultimaLetra) && tieneTilde) ||
                    (!/[nsáéíóúaeiou]$/.test(ultimaLetra) && !tieneTilde));
        }
        if (tipo === "grave")
        {
            return ((!/[nsáéíóúaeiou]$/.test(ultimaLetra) && tieneTilde) ||
                    (/[nsáéíóúaeiou]$/.test(ultimaLetra) && !tieneTilde));
        }
        return true;
    }
    // *****************************************************************************************************
    // Nombre: getCategoriaTexto
    // Fecha modificación: 2025-04-15
    // Autor: mincho77
    // Entradas: venue (object) - Objeto de lugar de Waze
    // Salidas: string - Texto de categoría
    // Descripción: Obtiene el texto de la categoría del lugar. Si no hay
    // categoría, devuelve "Sin categoría". Maneja errores y excepciones.
    // *****************************************************************************************************
    function getCategoriaTexto(venue)
    {

        try
        {
            if (!venue)
                return "Sin categoría";

            const allCategories = venue.getCategories?.();
            if (Array.isArray(allCategories) && allCategories.length > 0)
            {
                return allCategories.map(cat => cat?.name || "").join(", ");
            }

            const mainCategory = venue.getMainCategory?.();
            if (mainCategory?.name)
            {
                return mainCategory.name;
            }

            return "Sin categoría";
        }
        catch (e)
        {
            console.warn("Error al obtener categorías:", e);
            return "Sin categoría";
        }
    }

    // *****************************************************************************************************
    // Nombre: escapeHtml
    // Fecha modificación: 2025-06-20 18:30 GMT-5
    // Autor: mincho77
    // Entradas:
    // - unsafe (string|any): Valor a escapar
    // Salidas:
    // - string: Texto escapado seguro para usar en HTML
    // Prerrequisitos:
    // - Ninguno
    // Descripción:
    // Convierte caracteres especiales en entidades HTML para prevenir XSS.
    // Escapa los siguientes caracteres:
    // & → &amp;
    // < → &lt;
    // > → &gt;
    // " → &quot;
    // ' → &#039;
    // Si el input no es string, lo convierte a string.
    // Devuelve string vacío si el input es null/undefined.
    // *****************************************************************************************************
    function escapeHtml(unsafe)
    {
        if (unsafe === null || unsafe === undefined)
            return "";

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

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

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

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

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

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

                        break;
                    }
                }
            }
        });

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

    //**************************************************************************
    // Nombre: getCategoriaTexto
    // Fecha modificación: 2025-04-15
    // Hora: 13:01:25
    // Autor: mincho77
    // Entradas: venue (object) - Objeto de lugar de Waze
    // Salidas: string - Texto de categoría
    // Descripción: Obtiene el texto de la categoría del lugar. Si no hay
    // categoría, devuelve "Sin categoría". Maneja errores y excepciones.
    //**************************************************************************
    function getCategoriaTexto(venue)
    {
        try
        {
            if (!venue)
                return "Sin categoría";

            const allCategories = venue.getCategories?.();
            if (Array.isArray(allCategories) && allCategories.length > 0)
            {
                return allCategories.map(cat => cat?.name || "").join(", ");
            }

            const mainCategory = venue.getMainCategory?.();
            if (mainCategory?.name)
            {
                return mainCategory.name;
            }

            return "Sin categoría";
        }
        catch (e)
        {
            console.warn("Error al obtener categorías:", e);
            return "Sin categoría";
        }
    }
    //**************************************************************************
    // Nombre: openFloatingPanel
    // Fecha modificación: 2025-04-15
    // Hora: 13:01:25
    // Autor: mincho77
    // Entradas: Arreglo placesToNormalize
    // Salidas: Panel flotante HTML para normalización
    // Prerrequisitos: Los objetos place deben incluir un .place con métodos
    // getCategories y getMainCategory Descripción: Construye un panel
    // interactivo para visualizar y aplicar normalizaciones y correcciones
    // ortográficas
    //**************************************************************************
    function openFloatingPanel(placesToNormalize)
    {
        const panel = document.createElement("div");
        panel.id = "normalizer-floating-panel";
        panel.style.cssText = `
            position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
            width: 90%; max-width: 1200px; max-height: 80vh; background: white;
            padding: 20px; border-radius: 8px; box-shadow: 0 0 25px rgba(0,0,0,0.4);
            z-index: 10000; overflow-y: auto; font-family: Arial, sans-serif;
        `;

        let html = `
            <style>
                #normalizer-table { width: 100%; border-collapse: collapse; margin: 15px 0; }
                #normalizer-table th { background: #2c3e50; color: white; padding: 10px; text-align: left; }
                #normalizer-table td { padding: 8px 10px; border-bottom: 1px solid #eee; }
                .warning-row { background: #fff8e1; }
                .normalize-btn, .apply-btn, .add-exclude-btn {
                    padding: 6px 12px; 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: 10px; right: 10px;
                    background: #e74c3c; color: white; border: none;
                    width: 30px; height: 30px; border-radius: 50%; font-weight: bold;
                }
                .tool-source {
                    font-size: 0.8em; color: #7f8c8d; margin-top: 3px;
                    font-style: italic;
                }
                input[type="checkbox"] { transform: scale(1.3); margin: 0 5px; }
                input[type="text"] { width: 100%; padding: 5px; box-sizing: border-box; }
            </style>

            <button class="close-btn" id="close-panel-btn">×</button>
            <h2 style="color: #2c3e50; margin-top: 5px;">Normalizador de Nombres</h2>
            <div style="margin: 10px 0; color: #7f8c8d;">
                <span id="places-count">${
          placesToNormalize.length} lugares para revisar</span>
            </div>

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

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

            const category = getCategoriaTextoDesdeIDs(venue);
            const placeId = venue.getID();

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

            spellingWarnings.forEach((warning, warningIndex) => {
                html += `
                <tr class="warning-row">
                    <td><input type="checkbox" class="normalize-checkbox" data-index="${
                  index}" data-warning-index="${
                  warningIndex}" data-type="warning"></td>
                    <td></td>
                    <td title="${escapeHtml(category)}">${
                  escapeHtml(category)}</td>
                    <td>${escapeHtml(warning.original)}</td>
                    <td><input type="text" class="new-name-input" value="${
                  escapeHtml(warning.sugerida || newName)}"
                        data-index="${index}" data-place-id="${
                  placeId}" data-warning-index="${
                  warningIndex}" data-type="warning"></td>
                    <td>${escapeHtml(warning.tipo || "Error ortográfico")}
                        <div class="tool-source">${
                  warning.origen || "Reglas locales"}</div>
                    </td>
                    <td>
                        <button class="apply-btn" data-index="${
                  index}" data-warning-index="${warningIndex}">Aplicar</button>
                        <button class="add-exclude-btn" data-word="${
                  escapeHtml(warning.original)}">Excluir</button>
                    </td>
                </tr>`;
            });
        });

        html += `</tbody></table>
            <div style="margin-top: 20px; text-align: right;">
                <button id="apply-all-btn" style="background: #27ae60; color: white; padding: 10px 20px;
                    border: none; border-radius: 4px; font-weight: bold;">Aplicar Cambios Seleccionados</button>
                <button id="cancel-btn" style="background: #e74c3c; color: white; padding: 10px 20px;
                    border: none; border-radius: 4px; margin-left: 10px; font-weight: bold;">Cancelar</button>
            </div>`;

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

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

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

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

        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}"][data-type="full"]`);
                if (input)
                {
                    input.value = normalizePlaceName(input.value);
                    panel
                      .querySelector(`.normalize-checkbox[data-index="${
                        index}"][data-type="full"]`)
                      .checked = true;
                    this.textContent = "✓ Listo";
                    this.style.backgroundColor = "#95a5a6";
                    this.disabled = true;
                }
            });
        });

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

        panel.querySelectorAll(".new-name-input").forEach((input) => {
            input.addEventListener("input", function() {
                const index = parseInt(this.dataset.index, 10);
                const original = this.dataset.original?.trim() || "";
                const currentValue = this.value.trim();
                const checkbox =
                  panel.querySelector(`.normalize-checkbox[data-index="${
                    index}"][data-type="full"]`);
                const normalizeButton =
                  panel.querySelector(`.normalize-btn[data-index="${index}"]`);

                if (!checkbox || !normalizeButton)
                    return;

                if (currentValue !== original)
                {
                    checkbox.checked = true;
                    normalizeButton.textContent = "Listo";
                    normalizeButton.style.backgroundColor = "#95a5a6";
                    normalizeButton.disabled = true;
                }
                else
                {
                    checkbox.checked = false;
                    normalizeButton.textContent = "NrmliZer";
                    normalizeButton.style.backgroundColor = "#3498db";
                    normalizeButton.disabled = false;
                }
            });
        });

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

        showModal({
            title : "Eliminar palabra",
            message :
              `¿Estás seguro de que deseas eliminar la palabra <strong>${
                wordToDelete}</strong>?`,
            confirmText : "Eliminar",
            cancelText : "Cancelar",
            type : "question",
            onConfirm : () => {
                excludeWords.splice(index, 1);
                localStorage.setItem("excludeWords",
                                     JSON.stringify(excludeWords));
                renderExcludedWordsPanel();
                showModal({
                    title : "Eliminada",
                    message : "La palabra fue eliminada correctamente.",
                    confirmText : "Aceptar",
                    type : "success",
                    autoClose : 3000
                });
            }
        });
    }

    // *****************************************************************************************************
    // 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 : () => {
                excludeWords.splice(index, 1);
                localStorage.setItem("excludeWords",
                                     JSON.stringify(excludeWords));
                renderExcludedWordsPanel();
                showModal({
                    title : "Éxito",
                    message : "Palabra eliminada correctamente.",
                    confirmText : "Aceptar",
                    type : "success",
                    autoClose : 3000
                });
            }
        });
    }
    // *****************************************************************************************************
    // Nombre: evaluarOrtografiaNombre
    // Fecha modificación: 2025-04-10 20:45 GMT-5
    // Autor: mincho77
    // Entradas:
    // - name (string): Texto a evaluar
    // - opciones (object opcional): {
    //       timeout: 5000,       // Tiempo máximo en ms
    //       usarCache: true,     // Almacenar resultados temporalmente
    //       modoEstricto: false  // Verificar mayúsculas y puntuación
    //     }
    // Salidas:
    // Promise que resuelve a {
    //     hasSpellingWarning: boolean,
    //     spellingWarnings: Array<{
    //       original: string,
    //       sugerida: string,
    //       tipo: string,
    //       origen: 'API'|'Reglas locales',
    //       regla?: string,
    //       contexto?: string
    //     }>,
    //     metadata: {
    //       apiStatus: string,
    //       tiempoRespuesta?: number
    //     }
    // }
    // Descripción:
    // Evalúa ortografía usando API LanguageTool + reglas locales con:
    // - Validación robusta de entrada
    // - Timeout configurable
    // - Cache local de resultados
    // - Detección de tildes incorrectas
    // - Manejo completo de errores
    // Prerrequisitos:
    // - GM_xmlhttpRequest disponible
    // - Función tieneTildesIncorrectas() definida
    // *****************************************************************************************************
    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 :
                                    match.rule.category.name || "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);
                }
            });
        });
    }
    // Funciones auxiliares requeridas
    // *****************************************************************************************************
    // Nombre: corregirTildeLocal
    // Fecha modificación: 2025-04-10 20: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 = {
            solo : "sólo",
            aun : "aún",
            // ... otras correcciones
        };
        return correcciones[palabra.toLowerCase()] || palabra;
    }
    // *****************************************************************************************************
    // Nombre: populateCategoryDropdownFromWaze
    // Fecha modificación: 2025-04-09
    // Autor: mincho77
    // Entradas: Ninguna. Se asume que W.model.venues.getCategories() está
    // disponible y devuelve un arreglo
    //          de objetos de categoría (cada objeto debe tener al menos las
    //          propiedades "id" y "name").
    // Salidas: Ninguna. Actualiza el contenido del dropdown con id
    // "categoryDropdown" agregando las opciones correspondientes a
    //         cada categoría encontrada.
    // Prerrequisitos si existen:
    // - W.model.venues.getCategories() debe estar definido y devolver un
    // arreglo válido.
    // - El DOM debe contener un elemento <select> con id "categoryDropdown".
    // Descripción:
    // La función limpia el contenido actual del elemento dropdown y crea una
    // opción por defecto ("Categorías"). Luego, llama a
    // W.model.venues.getCategories() para obtener las categorías disponibles en
    // el modelo de WME. Por cada categoría en el arreglo, crea una opción
    // (<option>) asignándole como valor (value) la propiedad "id" y como texto
    // (textContent) la propiedad "name". Finalmente, agrega cada opción al
    // dropdown. Esto permite que el usuario seleccione la categoría por la que
    // desea filtrar los places.
    // *****************************************************************************************************
    function populateCategoryDropdownFromWaze()
    {
        const dropdown = document.getElementById("categoryDropdown");
        if (!dropdown)
            return;

        dropdown.innerHTML = "";
        const optAll = document.createElement("option");
        optAll.value = "all";
        optAll.textContent = "All";
        dropdown.appendChild(optAll);
        // Obtener categorías mediante getCategories()
        const catData =
          W.model.venues.getCategories && W.model.venues.getCategories();
        console.log("getCategories():", catData);
        if (Array.isArray(catData))
        { // catData debe ser un arreglo de objetos {id, name, ...}
            catData.forEach((catObj) => {
                const option = document.createElement("option");
                option.value = catObj.id;
                option.textContent = catObj.name;
                dropdown.appendChild(option);
            });
        }
        else
        {
            console.warn("No se encontraron categorías usando getCategories()");
        }
    }
    // *****************************************************************************************************
    // Nombre: populateCategoryDropdownWithSubcategories
    // Fecha modificación: 2025-04-08
    // Autor: mincho77
    // Entradas: Ninguna. Se asume que existe el objeto global
    // W.model.categories con las propiedades
    //          "groups" y "items", y que en el DOM hay un elemento <select> con
    //          id "categoryDropdown".
    // Salidas: Ninguna. La función actualiza el contenido del dropdown con id
    // "categoryDropdown",
    //         agregando una opción por defecto ("Categorías"), las categorías
    //         principales y sus subcategorías de forma jerárquica.
    // Prerrequisitos si existen:
    // - W.model.categories debe estar definido y contener "groups" (categorías
    // principales) y "items" (subcategorías).
    // - El DOM debe incluir un elemento <select> con id "categoryDropdown".
    // Descripción:
    // Esta función rellena el dropdown con las categorías y subcategorías
    // disponibles en la aplicación WME. Primero, limpia el contenido actual del
    // dropdown y agrega la opción por defecto "Todos los places". Luego,
    // recorre cada categoría principal en W.model.categories.groups y agrega
    // una opción para dicha categoría, marcándola visualmente como grupo (por
    // ejemplo, con un prefijo "[+ Grupo]"). A continuación, para cada grupo, si
    // existen subcategorías (la propiedad subCategories es un arreglo), recorre
    // cada subcategoría y crea una opción adicional para cada una, con un
    // indentado visual (por ejemplo, "   └ ") que indique su jerarquía. Esto
    // permite al usuario seleccionar la categoría específica por la que desea
    // filtrar los places.
    // *****************************************************************************************************
    function populateCategoryDropdownWithSubcategories()
    {
        const dropdown = document.getElementById("categoryDropdown");
        if (!dropdown)
            return;

        // Limpiar el contenido actual del dropdown.
        dropdown.innerHTML = "";
        // Agregar la opción por defecto: "Categorías".
        const optAll = document.createElement("option");
        optAll.value = "all";
        optAll.textContent = "All";
        dropdown.appendChild(optAll);
        // Obtener las categorías principales del modelo de WME.
        const groups = W.model.categories.groups;
        for (const groupKey in groups)
        {
            const groupData = groups[groupKey];
            // Agregar la opción de la categoría principal.
            const optionGroup = document.createElement("option");
            optionGroup.value =
              groupKey; // Puedes usar groupData.name si se requiere
            optionGroup.textContent = `[+ Grupo] ${groupData.name}`;
            dropdown.appendChild(optionGroup);
            // Si existen subcategorías para este grupo, agregarlas con un
            // indentado.
            if (groupData.subCategories && groupData.subCategories.length > 0)
            {
                groupData.subCategories.forEach((subCatKey) => {
                    const subCatData = W.model.categories.items[subCatKey];
                    if (!subCatData)
                        return;

                    // Seguridad en caso de datos faltantes.
                    const optionSub = document.createElement("option");
                    optionSub.value = subCatKey;
                    // Se agrega un prefijo de indentado para indicar la
                    // jerarquía.
                    optionSub.textContent = `   └ ${subCatData.name}`;
                    dropdown.appendChild(optionSub);
                });
            }
        }
    }
    // *****************************************************************************************************
    // Nombre: scanPlaces
    // Fecha modificación: 2025-04-10 18:30 GMT-5
    // Autor: mincho77
    // Entradas: Ninguna (usa elementos del DOM y el modelo WME)
    // Salidas: Ninguna. Ejecuta el escaneo de lugares y muestra resultados.
    // Prerrequisitos:
    // - El dropdown de categorías debe estar inicializado
    // - El modelo WME debe estar cargado (W.model.venues)
    // - Deben existir las funciones: normalizePlaceName,
    // evaluarOrtografiaNombre,
    //     openFloatingPanel, toggleSpinner
    // Descripción:
    // Escanea los lugares visibles en el WME, filtrando por categoría
    // seleccionada. Normaliza nombres, revisa ortografía usando API
    // LanguageTool y reglas locales, y muestra resultados en panel flotante.
    // Incluye:
    // - Spinner de carga con progreso
    // - Manejo robusto de errores
    // - Validación de tildes según reglas del español
    // - Procesamiento por lotes asíncrono
    // *****************************************************************************************************
    function scanPlaces()
    {
        const selectedCategory =
          document.getElementById("categoryDropdown")?.value || "all";
        const maxPlaces =
          parseInt(document.getElementById("maxPlacesInput")?.value || 100, 10);
        if (!W?.model?.venues?.objects)
        {
            console.error("Modelo WME no disponible");
            return;
        }
        // Convertir a string para comparación consistente
        const selectedCategoryStr = String(selectedCategory);
        const allPlaces =
          Object.values(W.model.venues.objects)
            .filter((place) => {
                if (!place?.attributes?.name)
                    return false;

                // Comparación de categoría mejorada
                if (selectedCategoryStr !== "all")
                {
                    const placeCategory =
                      String(place.attributes.category || "");
                    return placeCategory === selectedCategoryStr;
                }
                return true;
            })
            .slice(0, maxPlaces);
        if (allPlaces.length === 0)
        {
            toggleSpinner(false);
            showNoPlacesFoundMessage(); // Mostrar el mensaje mejorado
            return;
        }
        // 6. Procesamiento asíncrono con progreso
        let processedCount = 0;
        const placesToNormalize = [];
        const processBatch = async (index) => {
            if (index >= allPlaces.length)
            {
                toggleSpinner(false);
                if (placesToNormalize.length > 0)
                {
                    openFloatingPanel(placesToNormalize);
                }
                else
                {
                    showModal({
                        title : "Advertencia",
                        message :
                          "No se encontraron lugares que requieran normalización.",
                        confirmText : "Entendido",
                        type : "warning"
                    });
                }
                return;
            }
            const place = allPlaces[index];
            try
            {
                const originalName = place.attributes.name;
                const normalizedName = normalizePlaceName(originalName);
                // Actualizar progreso
                processedCount++;
                toggleSpinner(
                  true,
                  `Procesando lugares... (${processedCount}/${
                    allPlaces.length})`,
                  Math.round((processedCount / allPlaces.length) * 100));
                // Evaluar ortografía (usando el modo seleccionado)
                const ortografia =
                  checkOnlyTildes
                    ? await evaluarOrtografiaConTildes(normalizedName)
                    : await evaluarOrtografiaNombre(normalizedName);
                if (ortografia.hasSpellingWarning ||
                    originalName !== normalizedName)
                {
                    placesToNormalize.push({
                        id : place.getID(),
                        originalName,
                        newName : normalizedName,
                        category : place.attributes.category || "Sin categoría",
                        hasSpellingWarning : ortografia.hasSpellingWarning,
                        spellingWarnings : ortografia.spellingWarnings,
                        place
                    });
                }
                // Procesar siguiente lugar con pequeño retardo para no bloquear
                // UI
                setTimeout(() => processBatch(index + 1), 50);
            }
            catch (error)
            {
                console.error(`Error procesando lugar ${place.getID()}:`,
                              error);
                // Continuar con el siguiente lugar a pesar del error
                setTimeout(() => processBatch(index + 1), 50);
            }
        };
        // Iniciar procesamiento por lotes
        processBatch(0);
    }
    // *****************************************************************************************************
    // Nombre: 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()
    { // 1. Obtener el contenedor del panel
        const container = document.getElementById("normalizer-sidebar");
        if (!container)
        {
            console.warn(`[${
              SCRIPT_NAME}] No se encontró el contenedor "normalizer-sidebar".`);
            return;
        }
        // 2. Limpiar el contenido del contenedor
        container.innerHTML = "";
        // 3. Crear el título "Palabras Especiales"
        // const title = document.createElement("h4");
        // title.textContent = "Palabras Especiales";
        // title.style.marginBottom = "10px";
        // 4. Crear la sección de palabras excluidas
        const excludeListSection = document.createElement("div");
        excludeListSection.style.marginTop = "10px";
        excludeListSection.innerHTML = `
          <div style="max-height: 150px; overflow-y: auto; border: 1px solid #ccc; padding: 8px; font-size: 13px; border-radius: 4px;">
              <ul style="margin: 0; padding-left: 0; list-style: none;" id="excludeWordsList"></ul>
          </div>
      `;
        // 5. Agregar los elementos al contenedor en el orden deseado
        // container.appendChild(title); // "Palabras Especiales"
        container.appendChild(excludeListSection);
        // Lista de palabras excluidas
        // 6. Función para renderizar la lista de palabras
        const renderList = () => {
            const list = document.getElementById("excludeWordsList");
            if (!list)
                return;

            // Ordenar las palabras alfabéticamente
            const sortedWords = excludeWords.sort((a, b) => a.localeCompare(b));
            // Actualizar el contenido de la lista
            list.innerHTML =
              sortedWords
                .map(
                  (word, index) => `
          <li style="display: flex; justify-content: space-between; align-items: center; padding: 5px 0;">
              <span>${word}</span>
              <div style="display: flex; gap: 10px;">
                  <span class="edit-word-icon" data-index="${
                    index}" style="cursor: pointer; color: #3498db;" title="Editar">✏️</span>
                  <span class="delete-word-icon" data-index="${
                    index}" style="cursor: pointer; color: #e74c3c;" title="Eliminar">🗑️</span>
              </div>
          </li>
          `).join("");
            // Agregar eventos para los íconos de edición y eliminación
            list.querySelectorAll(".edit-word-icon").forEach((icon) => {
                icon.addEventListener("click", (e) => {
                    const index = parseInt(
                      e.target.closest(".edit-word-icon").dataset.index, 10);
                    openEditPopup(index);
                });
            });
            list.querySelectorAll(".delete-word-icon").forEach(icon => {
                icon.addEventListener("click", (e) => {
                    const index = parseInt(
                      e.target.closest(".delete-word-icon").dataset.index, 10);
                    openDeletePopup(index);
                });
            });
        };
        // Renderizar la lista inicial
        renderList();
        // 7. Agregar funcionalidad de búsqueda en tiempo real
        const searchInput = document.getElementById("searchWord");
        if (searchInput)
        {
            searchInput.addEventListener("input", () => {
                const query = searchInput.value.toLowerCase().trim();
                const items = document.querySelectorAll("#excludeWordsList li");
                if (!items.length)
                {
                    console.warn(
                      "No se encontraron elementos en la lista de palabras excluidas.");
                    return;
                }
                items.forEach((item) => {
                    const text =
                      item.querySelector("span")?.textContent.toLowerCase() ||
                      "";
                    item.style.display = text.includes(query) ? "flex" : "none";
                });
            });
        }
    }
    // *****************************************************************************************************
    // Nombre: setupDragAndDropImport
    // Fecha modificación: 2025-03-31
    // Autor: mincho77
    // Entradas: Ninguna.
    // Salidas: Ninguna.
    // Descripción:
    // Activa la funcionalidad de drag & drop sobre el elemento con id
    // "drop-zone" para importar un archivo con palabras excluidas. Procesa
    // archivos .xml y .txt.
    // *****************************************************************************************************
    function setupDragAndDropImport()
    {
        const dropZone = document.getElementById("drop-zone");
        if (!dropZone)
        {
            console.warn(
              "setupDragAndDropImport: No se encontró el elemento #drop-zone");
            return;
        }
        dropZone.addEventListener("dragover", (e) => {
            e.preventDefault();
            dropZone.style.borderColor = "#4CAF50";
            dropZone.style.backgroundColor = "#f0fff0";
            console.log("dragover detectado");
        });
        dropZone.addEventListener("dragleave", (e) => {
            dropZone.style.borderColor = "#ccc";
            dropZone.style.backgroundColor = "";
            console.log("dragleave detectado");
        });
        dropZone.addEventListener("drop", (e) => {
            e.preventDefault();
            dropZone.style.borderColor = "#ccc";
            dropZone.style.backgroundColor = "";
            console.log("drop detectado");
            const file = e.dataTransfer.files[0];
            if (!file)
            {
                console.log("No se detectó ningún archivo");
                return;
            }
            console.log("Archivo soltado:", file.name);
            const reader = new FileReader();
            reader.onload = function(event) {
                console.log("Contenido del archivo:", event.target.result);
                let palabras = [];
                if (file.name.endsWith(".xml"))
                {
                    const parser = new DOMParser();
                    const xml =
                      parser.parseFromString(event.target.result, "text/xml");
                    const nodes = xml.querySelectorAll(
                      "word, palabra, item, excluded, exclude");
                    palabras = Array.from(nodes)
                                 .map((n) => n.textContent.trim())
                                 .filter((p) => p.length > 0);
                }
                else
                {
                    palabras = event.target.result.split(/\r?\n/)
                                 .map((line) => line.trim())
                                 .filter((line) => line.length > 0);
                }
                if (palabras.length === 0)
                { // alert("⚠️ No se encontraron palabras válidas.");
                    showModal({
                        title : "Advertencia",
                        message : "No se encontraron palabras válidas.",
                        type : "warning",
                        autoClose :
                          3000, // El modal desaparecerá después de 3 segundos
                    });
                    return;
                }
                const replace =
                  document.getElementById("replaceExcludeListCheckbox");
                if (replace && replace.checked)
                {
                    excludeWords = [];
                    localStorage.removeItem("excludeWords");
                }
                else
                {
                    excludeWords =
                      JSON.parse(localStorage.getItem("excludeWords")) || [];
                }
                excludeWords = [...new Set([...excludeWords, ...palabras ]) ]
                                 .filter((w) => w.trim().length > 0)
                                 .sort((a, b) => a.localeCompare(b));
                localStorage.setItem("excludeWords",
                                     JSON.stringify(excludeWords));
                renderExcludedWordsPanel();
                showModal({
                    title : "Información",
                    message :
                      "Se importaron {prependText} palabras desde el archivo.",
                    prependText : palabras.length,
                    confirmText : "Aceptar",
                    type : "info"
                });
                // alert(`✅ Se importaron ${palabras.length} palabras desde el
                // archivo.`);
            };
            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)
        {
            // alert("No se seleccionó ningún archivo.");
            showModal({
                title : "Inoformación",
                message : "No se seleccionó ningun archivo.",
                confirmText : "Aceptar",
                type : "info"
            });
            return;
        }
        const reader = new FileReader();
        reader.onload = function(event) {
            const rawLines = event.target.result.split(/\r?\n/);
            const lines =
              rawLines
                .map((line) => line.replace(/[^\p{L}\p{N}().\s-]/gu, "").trim())
                .filter((line) => line.length > 0);
            const eliminadas = rawLines.length - lines.length;
            if (eliminadas > 0)
            {
                console.warn(`[handleImportList] Se ignoraron ${
                  eliminadas} líneas inválidas.`);
            }
            if (replaceCheckbox && replaceCheckbox.checked)
            {
                excludeWords = [];
            }
            else
            {
                excludeWords =
                  JSON.parse(localStorage.getItem("excludeWords")) ||
                  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();
            setupDragAndDropImport();
            showModal({
                title : "Éxito",
                message :
                  "Palabras excluidas importadas correctamente: {prependText}.",
                prependText : excludeWords.length,
                type : "info",
                autoClose : 2000, // El modal desaparecerá después de 3 segundos
            });
            // alert(`✅ Palabras excluidas importadas correctamente: ${
            //   excludeWords.length}`);
            fileInput.value = "";
        };
        reader.readAsText(fileInput.files[0]);
    }
    // *****************************************************************************************************
    // Nombre: isSimilar
    // Fecha modificación: 2025-03-30
    // Autor: mincho77
    // Entradas:
    // - a (string): Primera palabra a comparar.
    // - b (string): Segunda palabra a comparar.
    // Salidas:
    // - boolean: Retorna true si las palabras son consideradas similares de
    // forma leve; de lo contrario, retorna false. Prerrequisitos si existen:
    // Ninguno. Descripción: Esta función evalúa la similitud leve entre dos
    // palabras. Primero, verifica si ambas palabras son idénticas, en cuyo caso
    // retorna true. Luego, comprueba si la diferencia en la cantidad de
    // caracteres entre ambas es mayor a 2; si es así, retorna false.
    // Posteriormente, compara carácter por carácter hasta el largo mínimo de
    // las palabras, contando las diferencias. Si el número de discrepancias
    // excede 2, se considera que las palabras no son similares y retorna false;
    // en caso contrario, retorna true.
    // *****************************************************************************************************
    function isSimilar(a, b)
    {
        if (a === b)
            return true;

        if (Math.abs(a.length - b.length) > 2)
            return false;

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

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


    // *****************************************************************************************************
    // Nombre: cleanupEventListeners
    // Fecha modificación: 2025-03-30
    // Autor: mincho77
    // Entradas: Ninguna
    // Salidas: Ninguna
    // Prerrequisitos si existen:
    // - Debe existir un elemento en el DOM con el id
    // "normalizer-floating-panel". Descripción: Esta función elimina los event
    // listeners asociados al panel flotante de normalización. Lo hace clonando
    // el nodo del panel y reemplazándolo en el DOM, lo que remueve todos los
    // event listeners asignados a ese nodo, evitando posibles fugas de memoria
    // o comportamientos inesperados.
    // *****************************************************************************************************
    function cleanupEventListeners()
    {
        const panel = document.getElementById("normalizer-floating-panel");
        if (panel)
        {
            const clone = panel.cloneNode(true);
            panel.parentNode.replaceChild(clone, panel);
        }
    }
    // *****************************************************************************************************
    // Nombre: normalizePlaceName
    // Fecha modificación: 2025-04-14 11:45 GMT-5
    // Autor: mincho77
    // Entradas:
    // - name (string): El nombre original del lugar.
    // Salidas:
    // - string: Nombre normalizado.
    // Descripción:
    // Normaliza el nombre del lugar aplicando capitalización, manejo de
    // artículos, y ajustes de espacios y símbolos. Respeta la configuración del
    // checkbox "normalizeArticles" y la lista de palabras excluidas. Además, no
    // capitaliza letras después de un apóstrofo.
    // *****************************************************************************************************
    function normalizePlaceName(name)
    {
        if (!name)
            return "";

        const normalizeArticles =
          !document.getElementById("normalizeArticles")?.checked;
        const articles =
          [ "el", "la", "los", "las", "de", "del", "al", "y", "e" ];
        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 = words.map((word, index) => {
            const lowerWord = word.normalize("NFD").toLowerCase();
            // Si es un número, se mantiene igual
            if (/^\d+$/.test(word))
                return word;

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

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

            // Si contiene un apóstrofo, no capitalizar la letra siguiente
            if (/^[A-Za-z]+'[A-Za-z]/.test(word))
            {
                return (word.charAt(0).toUpperCase() +
                        word.slice(1, word.indexOf("'") + 1) +
                        word.slice(word.indexOf("'") + 1).toLowerCase());
            }
            // Si no se deben normalizar artículos y es un artículo, mantener en
            // minúsculas
            if (!normalizeArticles && articles.includes(lowerWord) &&
                index !== 0)
                return lowerWord;

            // Capitalizar la primera letra de la palabra
            return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
        });
        let newName =
          normalizedWords.join(" ")
            .replace(/\s*\|\s*/g, " - ")
            .replace(/([(["'])\s*([\p{L}])/gu,
                     (match, p1, p2) => p1 + p2.toUpperCase())
            .replace(/\s*-\s*/g, " - ")
            .replace(/\b(\d+)([A-Z])\b/g,
                     (match, num, letter) => num + letter.toUpperCase())
            .replace(/\.$/, "")
            .replace(/&(\s*)([A-Z])/g,
                     (match, space, letter) =>
                       "&" + space + letter.toUpperCase());
        // Asegurar que las letras después de un apóstrofo estén en minúscula
        newName = newName.replace(
          /([A-Za-z])'([A-Za-z])/g,
          (match,
           before,
           after) => { return `${before}'${after.toLowerCase()}`; });
        // Asegurar que la primera letra después de un guion esté en mayúscula
        newName = newName.replace(
          /-\s*([a-z])/g, (match, letter) => `- ${letter.toUpperCase()}`);
        return newName.replace(/\s{2,}/g, " ").trim();
    }
    // *****************************************************************************************************
    // Nombre: init
    // Fecha modificación: 2025-04-09
    // Autor: mincho77
    // Entradas: Ninguna
    // Salidas: Ninguna
    // Prerrequisitos si existen:
    // - El objeto global W debe estar disponible.
    // - Deben estar definidas las funciones: initializeExcludeWords,
    // createSidebarTab, waitForDOM, renderExcludedWordsPanel y
    // setupDragAndDropImport. Descripción: Esta función espera a que el entorno
    // de edición de Waze (WME) esté completamente cargado, verificando que
    // existan los objetos necesarios para iniciar el script. Una vez
    // disponible, inicializa la lista de palabras excluidas, crea el tab
    // lateral personalizado, y espera a que el DOM del tab esté listo para
    // renderizar el panel de palabras excluidas y activar la funcionalidad de
    // arrastrar y soltar para importar palabras. Finalmente, expone globalmente
    // las funciones applyNormalization y normalizePlaceName.
    // *****************************************************************************************************
    function init()
    {
        if (!W || !W.userscripts || !W.model || !W.model.venues)
        {
            console.log(`[${SCRIPT_NAME}] Esperando que WME esté listo...`);
            setTimeout(init, 1000);
            return;
        }
        console.log(`[${SCRIPT_NAME}] Inicializando v${VERSION}`);
        initializeExcludeWords();
        createSidebarTab();
        waitForDOM("#normalizer-tab", () => {
            console.log("[init] Sidebar listo");
            renderExcludedWordsPanel();
            setupDragAndDropImport();
            // Cargar categorías con 2 intentos (inmediato y con retardo)
            populateCategoryDropdown();
            setTimeout(populateCategoryDropdown,
                       3000); // Respaldo por si carga async
        });
        // Agregar el evento para rotar la flecha en el elemento <details>
        waitForElement("#details-special-words", (detailsElem) => {
            const arrow = document.getElementById("arrow");
            if (detailsElem && arrow)
            {
                detailsElem.addEventListener("toggle", function() {
                    arrow.style.transform =
                      detailsElem.open ? "rotate(90deg)" : "rotate(0deg)";
                });
            }
            else
            {
                console.error(
                  "No se encontró el elemento #details-special-words o #arrow");
            }
        });
        window.applyNormalization = applyNormalization;
        window.normalizePlaceName = normalizePlaceName;
        // reinicializa la lista y cierra el panel cuando cambia el zoom
        if (W && W.model && W.model.venues)
        {
            // Suponiendo que W.model.venues emite el evento 'mapviewchanged' o
            // 'zoomchanged'.
            W.model.venues.on("zoomchanged", () => { // Reinicia la variable
                                                     // global
                placesToNormalize = [];
                // Cierra el panel flotante, si está presente
                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.");
            });
        }
        console.log("W.model.categories:", W.model.categories);
        console.log("W.model.venues.getCategories():",
                    W.model.venues.getCategories?.());
    }
    // Inicia el script
    init();
    // --------------------------------------------------------------------
    // Fin del script principal
    // Exponer algunas funciones clave globalmente (opcional)
    unsafeWindow.normalizePlaceName = normalizePlaceName;
    unsafeWindow.applyNormalization = applyNormalization;
})();

QingJ © 2025

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