// ==UserScript==
// @name WME Places Name Normalizer
// @namespace https://greasyfork.org/en/users/mincho77
// @version 5.0.2
// @description Normaliza nombres de lugares en Waze Map Editor (WME)
// @author mincho77
// @match https://www.waze.com/*editor*
// @match https://beta.waze.com/*user/editor*
// @grant GM_xmlhttpRequest
// @connect api.languagetool.org
// @grant unsafeWindow
// @license MIT
// @run-at document-end
// ==/UserScript==
/*global W*/
(() => {
"use strict";
// Variables globales básicas
const SCRIPT_NAME = GM_info.script.name;
const VERSION = GM_info.script.version.toString();
// Inicializar la lista de palabras especiales
let specialWords = JSON.parse(localStorage.getItem("specialWords")) || [];
let maxPlaces = 50;
let normalizeArticles = true;
let placesToNormalize = [];
let wordLists = {
excludeWords : JSON.parse(localStorage.getItem("excludeWords")) || [],
dictionaryWords :
JSON.parse(localStorage.getItem("dictionaryWords")) || []
};
// ==============================================
// Inicialización de idioma y diccionarios
// ==============================================
// Idioma activo: cargado desde memoria o por defecto "SP"
let activeDictionaryLang =
localStorage.getItem("activeDictionaryLang") || "SP";
// Diccionarios por defecto (solo si no hay nada guardado)
const defaultDictionaries = {
SP : { a : [ "árbol" ], b : [ "barco" ] },
EN : { a : [ "apple" ], b : [ "boat" ] }
};
// Diccionario principal, comenzamos con los valores por defecto
const spellDictionaries = {
SP : {...defaultDictionaries.SP },
EN : {...defaultDictionaries.EN }
};
// Si hay datos guardados en localStorage, los sobreescribimos
const savedSP = localStorage.getItem("spellDictionaries_SP");
const savedEN = localStorage.getItem("spellDictionaries_EN");
if (savedSP)
{
try
{
spellDictionaries.SP = JSON.parse(savedSP);
}
catch (e)
{
console.warn(
"❌ Diccionario SP corrupto en memoria, se usará el de ejemplo.");
}
}
if (savedEN)
{
try
{
spellDictionaries.EN = JSON.parse(savedEN);
}
catch (e)
{
console.warn(
"❌ Diccionario EN corrupto en memoria, se usará el de ejemplo.");
}
}
// Crear la lista visible a partir del idioma actual
let dictionaryWords =
Object.values(spellDictionaries[activeDictionaryLang]).flat().sort();
unsafeWindow.debugLang = activeDictionaryLang;
unsafeWindow.debugDict = spellDictionaries;
let excludeWords = wordLists.excludeWords || [];
// ********************************************************************************************************************************
// Declaración global de placesToNormalize
// --------------------------------------------------------------------------------------------------------------------------------
// Prevención global del comportamiento por defecto en drag & drop
// (Evita que se abra el archivo en otra ventana)
// Se aplican los eventos de arrastre y suelta a todo el documento.
// Se previene el comportamiento por defecto para todos los eventos
// de arrastre y suelta, excepto en el drop-zone.
// Se establece el efecto de arrastre como "none" para evitar
// cualquier efecto visual no deseado.
// --------------------------------------------------------------------------------------------------------------------------------
["dragenter", "dragover", "dragleave", "drop"].forEach((evt) => {
document.addEventListener(evt, (e) => {
// Si el evento ocurre dentro del área de drop-zone, no lo
// bloquea
if (e.target && e.target.closest && e.target.closest("#drop-zone"))
{
return; // Permitir que el drop-zone maneje el evento
}
if (e.target && e.target.closest &&
e.target.closest("#dictionary-drop-zone"))
{
return; // Permitir que el dictionary-drop-zone maneje el evento
}
e.preventDefault(); // Prevenir el comportamiento predeterminado
e.stopPropagation(); // Detener la propagación del evento
}, { capture : true });
});
// ********************************************************************************************************************************
// Nombre: debugDictionaries
// Fecha modificación: 2025-04-15 12:17
// Autor: mincho77
// Entradas: Ninguna
// Salidas: Ninguna. Muestra en la consola el idioma activo y el diccionario actual.
// Descripción:
// Esta función muestra en la consola el idioma activo y el diccionario actual. Se utiliza para depurar
// y verificar el estado de los diccionarios. Permite al verificar que el idioma y el
// diccionario se han configurado correctamente. La función se puede invocar manualmente desde la
// consola del navegador para obtener información sobre el estado actual de los diccionarios.
// *********************************************************************************************************************************
unsafeWindow.debugDictionaries = function ()
{
console.log("Idioma activo:", activeDictionaryLang);
console.log("Diccionario actual:", spellDictionaries[activeDictionaryLang]);
};
// ********************************************************************************************************************************
// Nombre: renderExcludedWordsList
// Fecha modificación: 2025-04-15 12:17
// Autor: mincho77
// Entradas: Ninguna
// Salidas: Ninguna. Renderiza el panel de palabras excluidas.
// Descripción:
// Esta función renderiza el panel de palabras excluidas en la interfaz
// de usuario. Se encarga de crear la estructura HTML necesaria para
// mostrar la lista de palabras excluidas, así como los botones para
// agregar, eliminar y editar palabras. La función también maneja la
// lógica de búsqueda y filtrado de palabras en la lista. Se utiliza para
// permitir al usuario gestionar su lista de palabras excluidas de manera
// eficiente. La función se activa al cargar la página y cada vez que se
// actualiza la lista de palabras excluidas. Se utiliza para mejorar la
// experiencia del usuario al permitirle ver y gestionar fácilmente las
// palabras excluidas. La función también se encarga de renderizar la
// lista de palabras del diccionario después de agregar una nueva palabra.
// *********************************************************************************************************************************
function renderDictionaryWordsList()
{
const container = document.getElementById("dictionary-words-list");
if (!container)
{
console.warn(`[${SCRIPT_NAME}] No se encontró el contenedor 'dictionary-words-list'.`);
return;
}
container.innerHTML = ""; // Limpia el contenedor
const selectedLang = activeDictionaryLang || "SP";
const dictByLetter = spellDictionaries[selectedLang];
if (!dictByLetter || Object.keys(dictByLetter).length === 0)
{
console.warn(`[${SCRIPT_NAME}] No hay datos para el idioma activo: ${selectedLang}`);
container.innerHTML = "<p>No hay palabras en el diccionario para este idioma.</p>";
return;
}
const words = Object.values(dictByLetter).flat().sort((a, b) => a.localeCompare(b));
const ul = document.createElement("ul");
ul.style.listStyle = "none";
words.forEach((word) => {
const li = document.createElement("li");
li.textContent = word;
ul.appendChild(li);
});
container.appendChild(ul);
}
// ********************************************************************************************************************************
// Nombre: showNoPlacesFoundMessage
// Fecha modificación: 2025-04-10
// Autor: mincho77
// Entradas: Ninguna
// Salidas: Ninguna. Crea un modal que informa al usuario que no se encontraron lugares que cumplan con los criterios actuales.
// Descripción:
// Muestra un mensaje modal cuando no se encuentran lugares. Este mensaje incluye un botón para cerrar el modal. Se utiliza para
// mostrar información al usuario sobre la falta de lugares encontrados.
// ********************************************************************************************************************************
function showNoPlacesFoundMessage()
{ // Crear el modal
const modal = document.createElement("div");
modal.className = "no-places-modal-overlay";
modal.innerHTML = `
<div class="no-places-modal">
<div class="no-places-header">
<h3>⚠️ No se encontraron lugares</h3>
</div>
<div class="no-places-body">
<p>No se encontraron lugares que cumplan con los criterios actuales.</p>
<p>Intenta ajustar los filtros o ampliar el área de búsqueda.</p>
</div>
<div class="no-places-footer">
<button id="close-no-places-btn" class="no-places-btn">Aceptar</button>
</div>
</div>
`;
// Agregar el modal al documento
document.body.appendChild(modal);
// Manejar el evento de cierre
document.getElementById("close-no-places-btn")
.addEventListener("click", () => { modal.remove(); });
}
// Estilos CSS para el mensaje
const noPlacesStyles = `
<style>
.no-places-modal-overlay
{
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 10000;
animation: fadeIn 0.3s ease-in-out;
}
.no-places-modal {
background: #fff;
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
width: 90%;
max-width: 400px;
overflow: hidden;
animation: slideIn 0.3s ease-in-out;
text-align: center;
padding: 20px;
}
.no-places-header {
background: #f39c12;
color: white;
padding: 15px;
font-size: 18px;
font-weight: bold;
border-radius: 10px 10px 0 0;
}
.no-places-body {
padding: 20px;
font-size: 14px;
color: #333;
}
.no-places-footer {
padding: 15px;
background: #f4f4f4;
text-align: center;
}
.no-places-btn {
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
background: #3498db;
color: white;
transition: background 0.3s, transform 0.2s;
}
.no-places-btn:hover {
background: #2980b9;
transform: scale(1.05);
}
/* Animaciones */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
#dictionary-drop-zone
{
border: 2px dashed #ccc;
padding: 10px;
margin: 10px;
text-align: center;
font-style: italic;
color: #555;
background-color: #f8f9fa;
}
@keyframes slideIn {
from {
transform: translateY(-20px);
}
to {
transform: translateY(0);
}
}
</style>
`;
// Insertar los estilos en el documento
document.head.insertAdjacentHTML("beforeend", noPlacesStyles);
// ********************************************************************************************************************************
// Nombre: showModal
// Fecha modificación: 2025-04-10
// Autor: mincho77
// Entradas: title (string): Título del modal.
// message (string): Mensaje a mostrar en el modal.
// confirmText (string): Texto del botón de confirmación.
// cancelText (string): Texto del botón de cancelación.
// onConfirm (function): Función a ejecutar
// al hacer clic en el botón de confirmación.
// onCancel (function): Función a ejecutar
// al hacer clic en el botón de cancelación.
// type (string): Tipo de modal (info, error, warning, question,
// success).
// autoClose (number): Tiempo en milisegundos para cerrar
// automáticamente el modal.
// prependText (string): Texto a mostrar antes del mensaje.
// Salidas: Ninguna. Crea un modal personalizado con título, mensaje y botones de confirmación y cancelación. Permite al usuario
// interactuar
// con el modal y ejecutar funciones específicas al hacer clic en los botones. Se utiliza para mostrar mensajes de advertencia,
// información o error al usuario. El modal se cierra automáticamente después de un tiempo especificado si se indica.
// Descripción:
// Esta función crea un modal personalizado que se muestra en la pantalla con un título, un mensaje y botones de confirmación y
// cancelación. El modal se puede personalizar con diferentes tipos (info, error, warning, question, success) y se puede cerrar
// automáticamente después de un tiempo especificado. Permite al usuario interactuar con el modal y ejecutar funciones específicas
// al hacer clic en los botones. Se utiliza para mostrar mensajes de advertencia, información o error al usuario.
// El modal se cierra automáticamente después de un tiempo especificado si se indica.
// ********************************************************************************************************************************
function showModal({
title,
message,
confirmText,
cancelText,
onConfirm,
onCancel,
type = "info",
autoClose = null,
prependText = "",
})
{
// Determinar el ícono según el tipo
let icon;
switch (type)
{
case "error":
icon = "⛔";
break;
case "warning":
icon = "⚠️";
break;
case "info":
icon = "ℹ️";
break;
case "question":
icon = "❓";
break;
case "success":
icon = "✅";
break;
default:
icon = "ℹ️";
break;
}
const fullMessage = message.replace("{prependText}", prependText);
// Crear el modal
const modal = document.createElement("div");
modal.className = "custom-modal-overlay";
modal.innerHTML = `
<div class="custom-modal">
<div class="custom-modal-header">
<h3>${icon} ${title}</h3>
<button class="close-modal-btn" title="Cerrar">×</button>
</div>
<div class="custom-modal-body">
<p>${fullMessage}</p>
</div>
<div class="custom-modal-footer">
${
cancelText
? `<button id="modal-cancel-btn" class="modal-btn cancel-btn">${
cancelText}</button>`
: ""}
${
confirmText
? `<button id="modal-confirm-btn" class="modal-btn confirm-btn">${
confirmText}</button>`
: ""}
</div>
</div>
`;
// Agregar el modal al documento
document.body.appendChild(modal);
// Manejar eventos de los botones
if (confirmText)
{
document.getElementById("modal-confirm-btn")
.addEventListener("click", () => {
if (onConfirm)
onConfirm(); // Ejecutar la función de confirmación
modal.remove(); // Cerrar el modal
});
}
if (cancelText)
{
document.getElementById("modal-cancel-btn")
.addEventListener("click", () => {
if (onCancel)
onCancel(); // Ejecutar la función de cancelación
modal.remove(); // Cerrar el modal
});
}
// Cerrar modal al hacer clic en el botón de cerrar
modal.querySelector(".close-modal-btn")
.addEventListener("click", () => { modal.remove(); });
// Cerrar automáticamente si se especifica autoClose
if (autoClose)
{
setTimeout(() => { modal.remove(); }, autoClose);
}
}
// Estilos CSS para el modal
const modalStyles = `
<style>
.custom-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 10000;
animation: fadeIn 0.3s ease-in-out;
}
.custom-modal {
background: #fff;
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
width: 90%;
max-width: 400px;
overflow: hidden;
animation: slideIn 0.3s ease-in-out;
}
.custom-modal-header {
background: #3498db;
color: white;
padding: 15px;
display: flex;
justify-content: space-between;
align-items: center;
}
.custom-modal-header h3 {
margin: 0;
font-size: 18px;
}
.close-modal-btn {
background: none;
border: none;
color: white;
font-size: 20px;
cursor: pointer;
transition: color 0.3s;
}
.close-modal-btn:hover {
color: #e74c3c;
}
.custom-modal-body {
padding: 20px;
font-size: 14px;
color: #333;
text-align: center;
}
.custom-modal-footer {
display: flex;
justify-content: space-between;
padding: 15px;
background: #f4f4f4;
}
.modal-btn {
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: background 0.3s, transform 0.2s;
}
.confirm-btn {
background: #27ae60;
color: white;
}
.confirm-btn:hover {
background: #2ecc71;
transform: scale(1.05);
}
.cancel-btn {
background: #e74c3c;
color: white;
}
.cancel-btn:hover {
background: #c0392b;
transform: scale(1.05);
}
/* Animaciones */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideIn {
from {
transform: translateY(-20px);
}
to {
transform: translateY(0);
}
}
</style>
`;
// Insertar los estilos en el documento
document.head.insertAdjacentHTML("beforeend", modalStyles);
// ********************************************************************************************************************************
// Nombre: openEditPopup
// Fecha modificación: 2025-04-22 06:00
// Autor: mincho77
// Entradas: index (number): Índice de la palabra a editar.
// listType (string): Tipo de lista (excludeWords o
// dictionaryWords).
// Salidas: Ninguna. Abre un popup para editar una palabra en la lista
// Descripción:
// Esta función abre un popup para editar una palabra en la lista especificada (excludeWords o dictionaryWords). Permite al usuario
// modificar la palabra y actualizar la lista correspondiente. Si la palabra ya existe en la lista, se muestra un mensaje de
// advertencia. La función también valida que la palabra no esté vacía y que no sea duplicada. Se utiliza para permitir al usuario
// gestionar su lista de palabras excluidas o su diccionario ortográfico personalizado.
// ********************************************************************************************************************************
function openEditPopup(index, listType = "excludeWords")
{
const wordList = listType === "dictionaryWords" ? dictionaryWords : excludeWords;
const wordToEdit = wordList[index];
if (!wordToEdit)
{
console.error(`No se encontró la palabra en el índice ${index}`);
return;
}
showModal(
{
title : "Editar palabra",
message : `<input type="text" id="editWordInput" value="${
wordToEdit}" style="width: 95%; padding: 5px; border-radius: 4px; border: 1px solid #ccc;">`,
confirmText : "Guardar",
cancelText : "Cancelar",
type : "question",
onConfirm : () => {
const newWord =
document.getElementById("editWordInput").value.trim();
if (!newWord)
{
showModal({
title : "Error",
message : "La palabra no puede estar vacía.",
confirmText : "Aceptar",
type : "error"
});
return;
}
if (wordList.includes(newWord) && wordList[index] !== newWord)
{
showModal({
title : "Duplicada",
message : "Esa palabra ya está en la lista.",
confirmText : "Aceptar",
type : "warning"
});
return;
}
wordList[index] = newWord;
if (listType === "dictionaryWords")
{
wordLists.dictionaryWords = dictionaryWords;
localStorage.setItem("dictionaryWords",
JSON.stringify(dictionaryWords));
renderDictionaryWordsPanel();
}
else
{
wordLists.excludeWords = excludeWords;
localStorage.setItem("excludeWords",
JSON.stringify(excludeWords));
renderExcludedWordsPanel();
}
showModal({
title : "Actualizada",
message : "La palabra fue modificada correctamente.",
confirmText : "Aceptar",
type : "success",
autoClose : 2000
});
}
});
}
// ***********************************************************************************************************************************************************
// Nombre: waitForElement
// Fecha modificación: 2025-04-10
// Autor: mincho77
// Entradas:
// - selector (string): El selector CSS del elemento que se desea esperar en el DOM.
// - callback (function): Función que se ejecutará una vez que el elemento se encuentre en el DOM.
// - interval (number, opcional): Tiempo en milisegundos entre cada intento de búsqueda (por defecto: 300ms).
// - maxAttempts (number, opcional): Número máximo de intentos antes de abandonar la búsqueda (por defecto: 20).
// Salidas: Ninguna. Ejecuta el callback pasando el elemento encontrado o muestra una advertencia en la
// consola si no se encuentra.
// Prerrequisitos: - El DOM debe estar cargado.
// Descripción: Esta función espera a que un elemento definido por un selector CSS aparezca en el DOM. Utiliza un intervalo de
// tiempo (interval) para realizar múltiples comprobaciones, hasta un máximo definido(maxAttempts). Si el elemento se encuentra
// dentro de esos intentos, se ejecuta la función callback con el elemento como argumento. Si no se encuentra después de los
// intentos máximos, se detiene y se muestra una advertencia en la consola. Esto es útil para asegurarse de que elementos
// dinámicos estén disponibles antes de asignarles event listeners o manipularlos.
// ***********************************************************************************************************************************************************
function waitForElement(selector, callback, interval = 300, maxAttempts = 20)
{
let attempts = 0;
const checkExist = setInterval(() => {
const element = document.querySelector(selector);
attempts++;
if (element)
{
clearInterval(checkExist);
callback(element);
}
else if (attempts >= maxAttempts)
{
clearInterval(checkExist);
console.warn(`No se encontró el elemento ${
selector} después de ${maxAttempts} intentos.`);
}
}, interval);
}
// ********************************************************************************************************************************
// Nombre: configurarCambioIdiomaDiccionario
// Fecha modificación: 2025-04-22
// Hora: 07:45
// Autor: mincho77
// Entradas: Ninguna directa (se usa el selector del DOM)
// Salidas: Cambia el idioma del diccionario y conserva el contenido anterior
// Descripción: Esta función configura el evento de cambio de idioma del diccionario. Guarda el idioma anterior, carga el nuevo
// desde localStorage y actualiza el panel.
// ********************************************************************************************************************************
function configurarCambioIdiomaDiccionario()
{
const selector = document.getElementById("dictionaryLanguageSelect");
if (!selector)
{
setTimeout(configurarCambioIdiomaDiccionario, 200);
return;
}
selector.addEventListener("change", () => {
const previousLang = activeDictionaryLang;
const newLang = selector.value;
if (previousLang && spellDictionaries[previousLang])
{
localStorage.setItem(
`spellDictionaries_${previousLang}`,
JSON.stringify(spellDictionaries[previousLang]));
}
activeDictionaryLang = newLang;
localStorage.setItem("activeDictionaryLang", activeDictionaryLang);
const storedDictionary = JSON.parse(localStorage.getItem(
`spellDictionaries_${activeDictionaryLang}`));
if (storedDictionary)
{
spellDictionaries[activeDictionaryLang] = storedDictionary;
}
else if (!spellDictionaries[activeDictionaryLang] ||
Object.keys(spellDictionaries[activeDictionaryLang])
.length === 0)
{
if (activeDictionaryLang === "SP")
{
spellDictionaries.SP = { a : [ "árbol" ], b : [ "barco" ] };
}
else if (activeDictionaryLang === "EN")
{
spellDictionaries.EN = { a : [ "apple" ], b : [ "boat" ] };
}
localStorage.setItem(
`spellDictionaries_${activeDictionaryLang}`,
JSON.stringify(spellDictionaries[activeDictionaryLang]));
}
dictionaryWords =
Object.values(spellDictionaries[activeDictionaryLang])
.flat()
.sort();
renderDictionaryWordsPanel();
});
console.log("Idioma activo:", activeDictionaryLang);
console.log("Datos del diccionario:",
spellDictionaries[activeDictionaryLang]);
}
// ***********************************************************************************************************************************************************
// Nombre: waitForDictionaryLangSelectAndConfigure
// Fecha modificación: 2025-04-22 06:17
// Autor: mincho77
// Entradas: Ninguna
// Salidas: Ninguna. Espera a que el selector de idioma del diccionario esté disponible en el DOM y luego configura el evento de
// cambio de idioma.
// Descripción:
// Esta función espera a que el selector de idioma del diccionario esté disponible en el DOM. Una vez que se encuentra, llama a
// la función configurarCambioIdiomaDiccionario para configurar el evento de cambio de idioma. Se utiliza para asegurarse de que
// el selector esté listo antes de intentar agregarle un event listener. Esto es útil en situaciones donde el DOM se carga de
// manera asíncrona o el elementopuede no estar presente en el momento de la ejecución del script.
// ***********************************************************************************************************************************************************
function waitForDictionaryLangSelectAndConfigure()
{
const selector = document.getElementById("dictionaryLanguageSelect");
if (selector)
{
// Asignar el idioma activo al selector
selector.value = activeDictionaryLang;
// Configurar el evento de cambio de idioma
dictionaryWords =
Object.values(spellDictionaries[activeDictionaryLang])
.flat()
.sort();
// Renderizar el panel del diccionario ortográfico
renderDictionaryWordsPanel();
// Configurar el evento de cambio de idioma
configurarCambioIdiomaDiccionario();
}
else
{
setTimeout(waitForDictionaryLangSelectAndConfigure, 200);
}
}
// ***********************************************************************************************************************************************************
// Nombre: renderSpellDictionaryPanel
// Fecha modificación: 2025-04-15 12:17
// Autor: mincho77
// Entradas: Ninguna
// Salidas: string: HTML para el panel del diccionario ortográfico.
// Descripción: Esta función genera el HTML para el panel del diccionario ortográfico. Incluye un selector para elegir el idioma
// del diccionario, un campo de texto para agregar nuevas palabras, un botón para agregar palabras, un campo de búsqueda para
// filtrar palabras en la lista, y botones para importar y exportar el diccionario. El panel se puede mostrar u ocultar al hacer
// clic en el encabezado. Se utiliza para permitir al usuario gestionar un diccionario ortográfico personalizado
// para el normalizador de nombres de lugares. Se incluye un icono representativo para cada idioma (España e Inglaterra) junto a
// la opción correspondiente en el selector. El campo de búsqueda permite filtrar las palabras en la lista del diccionario,
// facilitando la búsqueda de palabras específicas. Los botones de importar y exportar permiten al usuario gestionar su diccionario
// ortográfico, facilitando la importación de palabras desde un archivo XML y la exportación de palabras a un archivo XML.
// Se utiliza para mejorar la experiencia del usuario al permitirle personalizar su diccionario ortográfico según sus necesidades.
// ***********************************************************************************************************************************************************
function renderSpellDictionaryPanel()
{
return `
<details id="details-dictionary-words" style="margin-top: 15px;">
<summary style="cursor: pointer; font-weight: bold; list-style: none;">
<span id="arrow-dic" style="display: inline-block; transition: transform 0.2s;">▶</span> Diccionario Ortográfico
</summary>
<!-- Selector de idioma -->
<div style="margin-top: 10px;">
<label for="dictionaryLanguageSelect"><b>Idioma activo:</b></label>
<select id="dictionaryLanguageSelect" style="width: 100%; margin-top: 5px; padding: 4px;">
<option value="SP">Español 🇪🇸</option>
<option value="EN">Inglés 🇬🇧</option>
</select>
</div>
<!-- Buscar palabra -->
<div style="margin-top: 10px;">
<input type="text" id="searchDictionaryWord" placeholder="Buscar palabra..." style="width: 100%; padding: 5px; border: 1px solid #ccc; border-radius: 4px;">
</div>
<div id="dictionary-words-list" style="margin-top: 10px; max-height: 200px; overflow-y: auto;">
</div>
<!-- Botones de archivo -->
<div style="margin-top: 10px;">
<button id="exportDictionaryBtn">📤 Exportar Diccionario</button>
<button id="importDictionaryBtn">📥 Importar Diccionario</button>
<button id="clear-dictionary-btn" style="margin-left: 10px;">🧹 Limpiar Diccionario</button>
<input type="file" id="hiddenImportDictionaryInput" accept=".xml" style="display: none;">
</div>
<!-- Drag & Drop -->
<div id="dictionary-drop-zone" style="border: 2px dashed #ccc; padding: 10px; margin: 10px;">
📂 Arrastra aquí tu archivo de palabras del diccionario (.xml o .txt)
</div>
</details>
`;
}
// ***********************************************************************************************************************************************************
// Nombre: initializeExcludeWords
// Fecha modificación: 2025-03-30
// Autor: mincho77
// Entradas: Ninguna
// Salidas: Ninguna
// Prerrequisitos si existen:
// - localStorage debe estar disponible.
// Descripción: Inicializa la lista de palabras excluidas a partir del localStorage, combinando con las palabras ya cargadas en la
// variable global excludeWords y actualizando el almacenamiento local.
// ***********************************************************************************************************************************************************
function initializeExcludeWords()
{
const saved = JSON.parse(localStorage.getItem("excludeWords")) || [];
wordLists.excludeWords =
[...new Set([...saved, ...wordLists.excludeWords ]) ].sort();
excludeWords = wordLists.excludeWords; // Sincronizar
localStorage.setItem("excludeWords",
JSON.stringify(wordLists.excludeWords));
}
// ***********************************************************************************************************************************************************
// Nombre: initSearchSpecialWords
// Fecha modificación: 2025-04-10
// Autor: mincho77
// Entradas: Ninguna
// Salidas: Ninguna
// Descripción: Esta función inicializa la búsqueda de palabras especiales en el panel lateral del normalizador. Agrega un evento de
// entrada al campo de búsqueda que filtra los elementos de la lista de palabras especiales según el texto ingresado. Si el campo de
// búsqueda no está disponible, espera 200 ms y vuelve a intentar. Esto es útil para permitir al usuario buscar y filtrar palabras
// especiales en la lista de manera eficiente.
// ***********************************************************************************************************************************************************
function initSearchSpecialWords()
{
const searchInput = document.getElementById("searchWord");
const normalizerSidebar = document.getElementById("normalizer-sidebar");
if (searchInput && normalizerSidebar)
{
searchInput.addEventListener("input", function() {
const query = searchInput.value.toLowerCase().trim();
const items = normalizerSidebar.querySelectorAll("li");
items.forEach(item => {
const text =
item.querySelector("span")?.textContent.toLowerCase() ||
"";
item.style.display = text.includes(query) ? "flex" : "none";
});
});
}
else
{
setTimeout(initSearchSpecialWords, 200);
}
}
// ***********************************************************************************************************************************************************
// Nombre: getSidebarHTML
// Fecha modificación: 2025-04-09
// Autor: mincho77
// Entradas: Ninguna
// Salidas: string: HTML para el panel lateral del normalizador.
// Descripción: Esta función genera el HTML para el panel lateral del normalizador de nombres de lugares. Incluye opciones para
// normalizar artículos, un campo para ingresar el máximo de lugares a buscar, una sección para palabras especiales
// con un botón para agregar palabras, un campo de búsqueda, y botones para importar y exportar la lista de palabras especiales.
// También incluye un botón para limpiar la lista de palabras especiales. El panel se puede mostrar u ocultar al hacer clic en el
// encabezado. Se utiliza para permitir al usuario gestionar su lista de palabras especiales y personalizar el comportamiento del
// normalizador de nombres de lugares. El HTML incluye estilos en línea para mejorar la apariencia y la usabilidad del panel.
// ***********************************************************************************************************************************************************
function getSidebarHTML()
{
return `
<div id="normalizer-tab">
<h4>Places Name Normalizer <span style="font-size:11px;">${
VERSION}</span></h4>
<!-- No Normalizar artículos -->
<div style="margin-top: 15px;">
<input type="checkbox" id="normalizeArticles" ${
normalizeArticles ? "checked" : ""}>
<label for="normalizeArticles">No Normalizar artículos (el, la, los, ...)</label>
</div>
<div style="margin-top: 15px;">
<input type="checkbox" id="useSpellingAPI">
<label for="useSpellingAPI">Usar API de ortografía</label>
</div>
<!-- Máximo de Places a buscar -->
<div style="margin-top: 15px;">
<label>Máximo de Places a buscar: </label>
<input type="number" id="maxPlacesInput" value="${
maxPlaces}" min="1" max="800" style="width: 60px;">
</div>
<!-- Sección de Palabras Especiales -->
<details id="details-special-words" style="margin-top: 15px;">
<summary style="cursor: pointer; font-weight: bold; list-style: none;">
<span id="arrow" style="display: inline-block; transition: transform 0.2s;">▶</span> Palabras Especiales
</summary>
<div style="margin-top: 10px; display: flex; gap: 5px;">
<input type="text" id="excludeWord" placeholder="Agregar palabra..." style="flex: 1; padding: 5px; border: 1px solid #ccc; border-radius: 4px;">
<button id="addExcludeWord" style="background: #3498db; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">Agregar</button>
</div>
<div style="margin-top: 10px; display: flex; gap: 5px;">
<input type="text" id="searchWord" placeholder="Buscar palabra..." style="flex: 1; padding: 5px; border: 1px solid #ccc; border-radius: 4px;">
</div>
<div id="normalizer-sidebar" style="margin-top: 10px; max-height: 200px; overflow-y: auto;"></div>
<button id="exportExcludeWords" style="margin-top: 10px;">Exportar Palabras</button>
<button id="importExcludeWordsUnifiedBtn" style="margin-top: 5px;">Importar Lista</button>
<input type="file" id="hiddenImportInput" accept=".xml,.txt" style="display: none;">
<div style="margin-top: 5px;">
<input type="checkbox" id="replaceExcludeListCheckbox">
<label for="replaceExcludeListCheckbox">Reemplazar lista actual</label>
</div>
<div id="drop-zone" style="border: 2px dashed #ccc; border-radius: 6px; padding: 15px; margin: 15px 0; text-align: center; font-style: italic; color: #555; background-color: #f8f9fa;">
📂 Arrastra aquí tu archivo .txt o .xml para importar palabras especiales
</div>
</details>
<hr>
<!-- Sección de Diccionario Ortográfico -->
${renderSpellDictionaryPanel()}
<hr>
<!-- Botón Scan -->
<button id="scanPlaces">Scan...</button>
</div>
<hr>
<!-- Botón de limpieza -->
<button id="customButton" style="background:rgb(219, 96, 52); color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; font-weight: bold; margin-top: 10px;">
Eliminar Palabras Especiales
</button>
`;
}
// ***********************************************************************************************************************************************************
// Nombre: clearExcludeWordsList
// Fecha modificación: 2025-04-10
// Autor: mincho77
// Entradas: Ninguna
// Salidas: Ninguna
// Prerrequisitos si existen:
// - La variable global excludeWords debe estar definida.
// - La función renderExcludedWordsPanel debe estar definida.
// Descripción: Esta función limpia la lista de palabras excluidas almacenadas en localStorage y actualiza la variable global
// excludeWords.
// ***********************************************************************************************************************************************************
function clearExcludeWordsList()
{
excludeWords = []; // Limpia la lista de palabras excluidas
wordLists.excludeWords = excludeWords; // Sincronizar
localStorage.removeItem(
"excludeWords"); // Elimina las palabras del almacenamiento local
// Limpia manualmente el contenedor antes de renderizar
const container = document.getElementById("normalizer-sidebar");
if (container)
{
container.innerHTML = ""; // Limpia el contenido del contenedor
}
renderExcludedWordsPanel(); // Refresca la lista en la interfaz
showModal({
title : "Lista Limpiada",
message : "La lista de palabras excluidas ha sido limpiada.",
type : "success",
autoClose : 1500
});
}
// ***********************************************************************************************************************************************************
// Nombre: clearActiveDictionary
// Fecha modificación: 2025-04-22
// Autor: mincho77
// Entradas: Ninguna
// Salidas: Ninguna
// Prerrequisitos si existen:
// - La variable global activeDictionaryLang debe estar definida.
// - La variable global spellDictionaries debe estar definida.
// - La función renderDictionaryWordsPanel debe estar definida.
// Descripción: Esta función limpia el diccionario ortográfico activo, eliminando todas las palabras y actualizando el almacenamiento
// local. También muestra un modal de confirmación al usuario.
// ***********************************************************************************************************************************************************
function clearActiveDictionary()
{
if (!spellDictionaries[activeDictionaryLang])
{
console.warn("⚠️ No se encontró el diccionario del idioma activo.");
return;
}
// Limpiar las letras
spellDictionaries[activeDictionaryLang] = {};
// Actualizar localStorage
localStorage.setItem(
`spellDictionaries_${activeDictionaryLang}`,
JSON.stringify(spellDictionaries[activeDictionaryLang]));
// Limpiar visualmente
dictionaryWords = [];
renderDictionaryWordsPanel();
showModal({
title : "Diccionario borrado",
message : `Se eliminó todo el contenido del diccionario en idioma ${
activeDictionaryLang}.`,
confirmText : "Aceptar",
type : "info",
});
}
// ***********************************************************************************************************************************************************
// Nombre: attachEvents
// Fecha modificación: 2025-03-30
// Autor: mincho77
// Entradas: Ninguna
// Salidas: Ninguna
// Prerrequisitos si existen:
// - Deben existir en el DOM los elementos con los siguientes IDs:
// "normalizeArticles", "maxPlacesInput", "addExcludeWord", "scanPlaces",
// "hiddenImportInput", "importExcludeWordsUnifiedBtn" y
// "exportExcludeWords".
// - Debe existir la función handleImportList y la función scanPlaces.
// - Debe estar definida la variable global excludeWords y la funciónrenderExcludedWordsPanel. Descripción: Esta función adjunta
// los event listeners necesarios para gestionar la interacción del usuario con el panel del normalizador de nombres.
// Se encargan de:
// - Actualizar la opción de normalizar artículos al cambiar el estado del checkbox.
// - Modificar el número máximo de lugares a procesar a través de un input.
// - Exportar la lista de palabras excluidas a un archivo XML.
// - Añadir nuevas palabras a la lista de palabras excluidas, evitando duplicados, y actualizar el panel.
// - Activar el botón unificado para la importación de palabras excluidas mediante un input oculto.
// - Ejecutar la función de escaneo de lugares al hacer clic en el botón correspondiente.
// ***********************************************************************************************************************************************************
function attachEvents()
{
console.log(`[${SCRIPT_NAME}] Adjuntando eventos...`);
const normalizeArticlesCheckbox =
document.getElementById("normalizeArticles");
const maxPlacesInput = document.getElementById("maxPlacesInput");
const addExcludeWordButton = document.getElementById("addExcludeWord");
const scanPlacesButton = document.getElementById("scanPlaces");
const hiddenInput = document.getElementById("hiddenImportInput");
const importButtonUnified =
document.getElementById("importExcludeWordsUnifiedBtn");
// Validación de elementos necesarios
if (!normalizeArticlesCheckbox || !maxPlacesInput ||
!addExcludeWordButton || !scanPlacesButton)
{
console.error(
`[${SCRIPT_NAME}] Error: No se encontraron elementos en el DOM.`);
return;
}
// Evento: cambiar estado de "no normalizar artículos"
normalizeArticlesCheckbox.addEventListener(
"change", (e) => { normalizeArticles = e.target.checked; });
// Evento: cambiar número máximo de places
maxPlacesInput.addEventListener(
"input", (e) => { maxPlaces = parseInt(e.target.value, 10); });
// Evento para el botón personalizado
const customButton = document.getElementById("customButton");
if (customButton)
{
customButton.addEventListener("click", () => {
showModal({
title : "Confirmación",
message :
"¿Estás seguro de que deseas limpiar la lista de palabras excluidas?",
confirmText : "Sí, limpiar",
cancelText : "Cancelar",
type : "question",
onConfirm: () => { clearExcludeWordsList(); }, // Llama a la función para limpiar la lista},
onCancel : () => { console.log("El usuario canceló la limpieza de la lista.");
}
});
});
}
// Evento: exportar palabras excluidas a XML
document.getElementById("exportExcludeWords")
.addEventListener("click", () => {
const savedWords =
JSON.parse(localStorage.getItem("excludeWords")) || [];
if (savedWords.length === 0)
{
showModal({
title : "Error",
message : "No hay palabras excluidas para exportar.",
confirmText : "Aceptar",
onConfirm :
() => { console.log("El usuario cerró el modal."); }
});
return;
}
const sortedWords =
[...savedWords ].sort((a, b) => a.localeCompare(b));
const xmlContent = `<?xml version="1.0" encoding="UTF-8"?>
<ExcludedWords>
${sortedWords.map((word) => ` <word>${word}</word>`).join("\n ")}
</ExcludedWords>`;
const blob =
new Blob([ xmlContent ], { type : "application/xml" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "excluded_words.xml";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
// Evento: añadir palabra excluida sin duplicados
addExcludeWordButton.addEventListener("click", () => {
const wordInput = document.getElementById("excludeWord") ||
document.getElementById("excludedWord");
const word = wordInput?.value.trim();
if (!word)
return;
const lowerWord = word.toLowerCase();
const alreadyExists =
excludeWords.some((w) => w.toLowerCase() === lowerWord);
if (!alreadyExists)
{
wordLists.excludeWords.push(word);
localStorage.setItem("excludeWords",
JSON.stringify(wordLists.excludeWords));
renderExcludedWordsPanel(); // Refresca la lista después de agregar la palabra
}
wordInput.value = ""; // Limpia el campo de entrada
});
// Evento: nuevo botón unificado de importación
importButtonUnified.addEventListener("click",
() => { hiddenInput.click(); });
hiddenInput.addEventListener("change", () => { handleImportList(); });
// limpiardiccionario
waitForElement("#clear-dictionary-btn", (btn) => {
btn.addEventListener("click", () => {
const confirmClear = confirm(
"¿Seguro que deseas borrar TODO el diccionario activo?");
if (confirmClear)
clearActiveDictionary();
});
});
// Evento: escanear lugares
scanPlacesButton.addEventListener("click", scanPlaces);
}
// ***********************************************************************************************************************************************************
// Nombre: attachDictionarySearch
// Fecha modificación: 2025-04-10
// Autor: mincho77
// Entradas: Ninguna
// Salidas: Ninguna
// Prerrequisitos si existen:
// - Debe existir en el DOM el campo de búsqueda con id "searchDictionaryWord" y el contenedor de palabras del diccionario con id
// "dictionary-words-list". Descripción: Esta función adjunta un evento de búsqueda al campo de búsqueda del diccionario ortográfico.
// Filtra la lista de palabras mostradas en el contenedor "dictionary-words-list" según la entrada del usuario. Se utiliza para
// mejorar la experiencia del usuario al permitirle buscar rápidamente palabras específicas en el diccionario ortográfico.
// ***********************************************************************************************************************************************************
function attachDictionarySearch()
{
const dictionarySearchInput =
document.getElementById("searchDictionaryWord");
const dictionaryWordsContainer =
document.getElementById("dictionary-words-list");
if (!dictionarySearchInput || !dictionaryWordsContainer)
{
console.error(
"[PlacesNameNormalizer] No se encontró el campo 'searchDictionaryWord' o 'dictionary-words-list'.");
return;
}
// Solo modifica .style.display para ocultar/mostrar
dictionarySearchInput.addEventListener("input", () => {
const query = dictionarySearchInput.value.toLowerCase().trim();
const items = dictionaryWordsContainer.querySelectorAll("li");
items.forEach(item => {
const text =
item.querySelector("span")?.textContent.toLowerCase() || "";
item.style.display = text.includes(query) ? "flex" : "none";
});
});
}
// ***********************************************************************************************************************************************************
// Nombre: createSidebarTab
// Fecha modificación: 2025-04-22
// Hora: 06:50
// Autor: mincho77
// Entradas: Ninguna
// Salidas: Ninguna
// Prerrequisitos si existen:
// - Debe existir la función W.userscripts.registerSidebarTab.
// Descripción: Esta función crea una pestaña en la barra lateral de WME para el normalizador de nombres de lugares.
// Primero, verifica si la pestaña ya existe y la elimina si es necesario. Luego, registra una nueva pestaña utilizando la función
// W.userscripts.registerSidebarTab. Si la pestaña se registra correctamente, se configura su contenido y se añaden los eventos necesarios.
// Se utiliza para proporcionar una interfaz de usuario para el normalizador de nombres de lugares dentro de WME, permitiendo al usuario
// acceder a las funciones del script de manera fácil y rápida. La pestaña incluye opciones para normalizar artículos, un campo
// para ingresar el máximo de lugares a buscar, una sección para palabras especiales con un botón para agregar palabras, un campo de búsqueda,
// y botones para importar y exportar la lista de palabras especiales. También incluye un botón para limpiar la lista de palabras especiales.
// ***********************************************************************************************************************************************************
function createSidebarTab()
{
try
{
if (!W || !W.userscripts)
{
console.error(
`[${SCRIPT_NAME}] WME not ready for sidebar creation`);
return;
}
const existingTab = document.getElementById("normalizer-tab");
if (existingTab)
{
console.log(`[${SCRIPT_NAME}] Removing existing tab...`);
existingTab.remove();
}
let registration;
try
{
registration =
W.userscripts.registerSidebarTab("PlacesNormalizer");
}
catch (e)
{
if (e.message.includes("already been registered"))
{
console.warn(`[${
SCRIPT_NAME}] Tab registration conflict, skipping...`);
return;
}
throw e;
}
const { tabLabel, tabPane } = registration;
if (!tabLabel || !tabPane)
{
throw new Error(
"Tab registration failed to return required elements");
}
// Configure tab
tabLabel.innerHTML = `
<img src="data:image/jpeg;base64,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"
style="height: 16px; vertical-align: middle; margin-right: 5px;">
NrmliZer
`;
// Inyectar HTML del panel
tabPane.innerHTML = getSidebarHTML();
// Esperar que el DOM esté listo antes de adjuntar eventos
waitForElement("#normalizeArticles", () => {
console.log(`[${
SCRIPT_NAME}] ✅ Elementos del DOM listos, adjuntando eventos`);
attachEvents();
});
// Activar búsqueda para palabras especiales
initSearchSpecialWords();
// Esperar que el selector de idioma esté en el DOM antes de configurar
function waitForDictionaryLangSelectAndConfigure()
{
const selector =
document.getElementById("dictionaryLanguageSelect");
if (selector)
{
configurarCambioIdiomaDiccionario();
}
else
{
setTimeout(waitForDictionaryLangSelectAndConfigure, 200);
}
}
waitForDictionaryLangSelectAndConfigure();
// Exponer depuración por consola
unsafeWindow.debugDictionaries = function() {
console.log("Idioma activo:", activeDictionaryLang);
console.log("Diccionario actual:",
spellDictionaries[activeDictionaryLang]);
};
}
catch (error)
{
console.error(`[${SCRIPT_NAME}] Error creating sidebar tab:`,
error);
}
}
// ********************************************************************************************************************************
// Nombre: checkSpellingWithAPI
// Fecha modificación: 2025-03-30
// Autor: mincho77
// Entradas: text (string) – Texto a evaluar ortográficamente.
// Salidas: Promise – Resuelve con lista de errores ortográficos detectados.
// Prerrequisitos: Requiere permisos GM_xmlhttpRequest y @connect a
// api.languagetool.org Descripción: Consulta la API de LanguageTool para
// verificar ortografía del texto.
// ********************************************************************************************************************************
function checkSpellingWithAPI(text)
{
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method : "POST",
url : "https://api.languagetool.org/v2/check",
headers :
{ "Content-Type" : "application/x-www-form-urlencoded" },
data : `language=es&text=${encodeURIComponent(text)}`,
onload : function(response) {
if (response.status === 200)
{
const result = JSON.parse(response.responseText);
const errores = result.matches.map(
(match) => ({
palabra : match.context.text.substring(
match.context.offset,
match.context.offset + match.context.length),
sugerencia :
match.replacements.length > 0
? match.replacements[0].value
: match.context
.text // Mantener la palabra original si
// no hay sugerencias
}));
resolve(errores);
}
else
{
reject("❌ Error en respuesta de LanguageTool");
}
},
onerror : function(
err) { reject("❌ Error de red al contactar LanguageTool"); }
});
});
}
window.checkSpellingWithAPI = checkSpellingWithAPI;
// ********************************************************************************************************************************
// Nombre: evaluarOrtografiaCompleta
// Fecha modificación: 2025-04-10 22:15 GMT-5
// Autor: mincho77
// Entradas:
// - texto (string): Texto a evaluar
// - config (opcional): {
// usarAPI: true, // Usar LanguageTool
// reglasLocales: true, // Aplicar reglas de tildes
// timeout: 5000 // Tiempo máximo para API
// }
// Salidas:
// Promise<{
// original: string,
// normalizado: string,
// errores: Array<{
// palabra: string,
// sugerencia: string,
// tipo: 'ortografia'|'tilde'|'gramatica',
// severidad: 'alta'|'media'|'baja'
// }>,
// metadata: {
// totalErrores: number,
// apiUsada: boolean,
// tiempoProcesamiento: number
// }
// }>
// Descripción:
// Sistema completo que combina normalización y revisión ortográfica real
// ********************************************************************************************************************************
async function evaluarOrtografiaCompleta(texto, config = {})
{
const inicio = Date.now();
const resultadoBase = {
original : texto,
normalizado : "",
errores : [],
metadata :
{ totalErrores : 0, apiUsada : false, tiempoProcesamiento : 0 }
};
// 1. Normalización básica inicial
const normalizado = await normalizePlaceName(texto, true);
resultadoBase.normalizado = normalizado;
// 2. Detección de errores locales (síncrono)
if (config.reglasLocales !== false)
{
const erroresLocales = detectarErroresLocales(texto, normalizado);
resultadoBase.errores.push(...erroresLocales);
}
// 3. Revisión con API LanguageTool (asíncrono)
if (config.usarAPI !== false && texto.length > 1)
{
try
{
const resultadoAPI =
await revisarConLanguageTool(texto, config.timeout);
resultadoBase.errores.push(...resultadoAPI.errores);
resultadoBase.metadata.apiUsada = true;
}
catch (error)
{
console.error("Error en API LanguageTool:", error);
}
}
// 4. Filtrar y clasificar errores
resultadoBase.errores = filtrarErrores(resultadoBase.errores);
resultadoBase.metadata.totalErrores = resultadoBase.errores.length;
resultadoBase.metadata.tiempoProcesamiento = Date.now() - inicio;
return resultadoBase;
}
// ==================== FUNCIONES DE SOPORTE ====================
// ********************************************************************************************************************************
// Nombre: detectarErroresLocales
// Descripción: Detecta errores de tildes y mayúsculas
// ********************************************************************************************************************************
function detectarErroresLocales(original, normalizado)
{
const errores = [];
const palabrasOriginal = original.split(/\s+/);
const palabrasNormalizado = normalizado.split(/\s+/);
palabrasOriginal.forEach((palabra, i) => {
const palabraNormalizada = palabrasNormalizado[i] || palabra;
// 1. Comparación directa para detectar cambios
if (palabra !== palabraNormalizada)
{
errores.push({
palabra,
sugerencia : palabraNormalizada,
tipo : "ortografia",
severidad : "media"
});
}
// 2. Detección específica de tildes
if (tieneTildesIncorrectas(palabra))
{
errores.push({
palabra,
sugerencia : corregirTildeLocal(palabra),
tipo : "tilde",
severidad : "alta"
});
}
});
return errores;
}
// ********************************************************************************************************************************
// Nombre: revisarConLanguageTool
// Fecha modificación: 2025-04-10 22:15 GMT-5
// Autor: mincho77
// Entradas:
// - texto (string): Texto a evaluar
// - timeout (opcional): Tiempo máximo para la API (en milisegundos)
// Salidas:
// Promise<{
// errores: Array<{
// palabra: string,
// sugerencia: string,
// tipo: 'ortografia'|'gramatica',
// severidad: 'alta'|'media'
// }>,
// apiStatus:
// 'success'|'timeout'|'parse_error'|'api_error'|'network_error'
// }>
// Prerrequisitos: Requiere permisos GM_xmlhttpRequest y @connect a
// api.languagetool.org Descripción: Consulta la API para errores
// ortográficos y gramaticales
// ********************************************************************************************************************************
// Descripción: Consulta la API para errores avanzados
// ********************************************************************************************************************************
function revisarConLanguageTool(texto, timeout = 5000)
{
return new Promise((resolve) => {
const timer = setTimeout(
() => { resolve({ errores : [], apiStatus : "timeout" }); },
timeout);
GM_xmlhttpRequest({
method : "POST",
url : "https://api.languagetool.org/v2/check",
headers :
{ "Content-Type" : "application/x-www-form-urlencoded" },
data : `language=es&text=${encodeURIComponent(texto)}`,
onload : function(response) {
clearTimeout(timer);
if (response.status === 200)
{
try
{
const data = JSON.parse(response.responseText);
const errores = data.matches.map((match) => {
// Validar que match y sus propiedades existan
const palabra =
match?.context?.text?.substring(
match?.context?.offset || 0,
(match?.context?.offset || 0) +
(match?.context?.length || 0)) ||
"(sin contexto)";
const sugerencia =
match?.replacements?.[0]?.value ||
match?.context?.text || "(sin sugerencia)";
const tipo =
"ortografia"; // Valor predeterminado ya que
// se eliminó la categoría
const severidad =
match?.rule?.issueType === "misspelling"
? "alta"
: "media";
return { palabra, sugerencia, tipo, severidad };
});
resolve({ errores, apiStatus : "success" });
}
catch (e)
{
resolve(
{ errores : [], apiStatus : "parse_error" });
}
}
else
{
resolve({ errores : [], apiStatus : "api_error" });
}
},
onerror : function() {
clearTimeout(timer);
resolve({ errores : [], apiStatus : "network_error" });
}
});
});
}
// ********************************************************************************************************************************
// Nombre: filtrarErrores
// Descripción: Elimina duplicados y errores menores
// ********************************************************************************************************************************
function filtrarErrores(errores)
{
const unicos = [];
const vistas = new Set();
errores.forEach((error) => {
const clave = `${error.palabra}-${error.sugerencia}-${error.tipo}`;
if (!vistas.has(clave))
{
vistas.add(clave);
unicos.push(error);
}
});
return unicos.sort((a, b) => {
if (a.severidad === b.severidad)
return 0;
return a.severidad === "alta" ? -1 : 1;
});
}
// ********************************************************************************************************************************
// Nombre: tieneTildesIncorrectas
// Fecha modificación: 2025-04-10 21:30 GMT-5
// Autor: mincho77
// Entradas:
// - palabra (string): Palabra a evaluar
// - config (opcional): {
// ignorarMayusculas: true,
// considerarAdverbios: true,
// considerarMonosílabos: false
// }
// Salidas: boolean - true si la palabra requiere corrección de tilde
// Descripción:
// Evalúa si una palabra en español tiene tildes incorrectas según las
// reglas RAE. Incluye casos especiales para adverbios, hiatos, diptongos y
// monosílabos.
// ********************************************************************************************************************************
function tieneTildesIncorrectas(palabra, config = {})
{
if (typeof palabra !== "string" || palabra.length === 0)
return false;
const settings = {
ignorarMayusculas : config.ignorarMayusculas !==
false, // No marcar errores en MAYÚSCULAS
considerarAdverbios :
config.considerarAdverbios !==
false, // Evaluar adverbios terminados en -mente
considerarMonosílabos :
config.considerarMonosílabos || false, // Seguir reglas pre-2010
};
// Normalizar palabra (quitar tildes existentes para evaluación)
const palabraNormalizada = palabra.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase();
const tieneTildeActual = /[áéíóú]/.test(palabra);
// 1. Reglas para palabras específicas (excepciones)
const reglasEspecificas = {
// Adverbios terminados en -mente
mente :
settings.considerarAdverbios && /mente$/i.test(palabra)
? tieneTildesIncorrectas(palabra.replace(/mente$/i, ""), config)
: false,
// Monosílabos
monosilabos : settings.considerarMonosílabos &&
[
"fe",
"fue",
"fui",
"vio",
"dio",
"lia",
"lie",
"lio",
"rion",
"ries",
"se",
"te",
"de",
"si",
"ti"
].includes(palabraNormalizada),
// Casos especiales
solo : palabraNormalizada === "solo" && !tieneTildeActual,
este : /^este(s)?$/i.test(palabraNormalizada) && !tieneTildeActual,
aun : palabraNormalizada === "aun" && !tieneTildeActual,
guion : palabraNormalizada === "guion" && !tieneTildeActual,
hui : palabraNormalizada === "hui" && !tieneTildeActual
};
if (Object.values(reglasEspecificas).some((v) => v))
return true;
// 2. Reglas generales de acentuación
const silabas = separarSilabas(palabraNormalizada);
const numSilabas = silabas.length;
const ultimaLetra = palabraNormalizada.slice(-1);
// Palabras agudas (tildan en última sílaba)
if (numSilabas === 1)
return false;
// Monosílabos ya evaluados
const esAguda = numSilabas === 1 ||
(numSilabas > 1 && silabas[numSilabas - 1].acento);
const debeTildarAguda =
esAguda && /[nsaeiouáéíóú]$/i.test(palabraNormalizada);
const palabraLower = palabra.toLowerCase();
if (correccionesEspecificas[palabraLower])
{
return aplicarCapitalizacion(palabra,
correccionesEspecificas[palabraLower]);
}
// Determinar sílaba a tildar
if (numSilabas > 2 && esEsdrujula(palabra))
{
silabaTildada = numSilabas - 3;
}
else if (numSilabas > 1 && esGrave(palabra))
{
silabaTildada = numSilabas - 2;
}
else if (esAguda(palabra))
{
silabaTildada = numSilabas - 1;
}
if (silabaTildada >= 0)
{
return aplicarTildeSilaba(palabra, silabas, silabaTildada);
}
return palabra;
}
// ==================== FUNCIONES AUXILIARES ====================
// ********************************************************************************************************************************
// Nombre: separarSilabas
// Fecha modificación: 2025-04-10 22:15 GMT-5
// Autor: mincho77
// Entradas: palabra (string) – Palabra a separar en sílabas.
// Salidas: Array<{ texto: string, acento: boolean }> – Lista de sílabas
// Descripción: Separa la palabra en sílabas y determina si cada sílaba
// tiene acento. Implementación simplificada para propósitos de
// normalización visual.
// ********************************************************************************************************************************
function separarSilabas(palabra)
{ // Implementación simplificada (usar librería completa en producción)
const vocalesFuertes = /[aeoáéó]/;
const vocalesDebiles = /[iuü]/;
const silabas = [];
let silabaActual = "";
let tieneVocalFuerte = false;
for (let i = 0; i < palabra.length; i++)
{
const c = palabra[i];
silabaActual += c;
if (vocalesFuertes.test(c))
{
tieneVocalFuerte = true;
}
// Lógica simplificada de separación
if (i < palabra.length - 1 &&
((vocalesFuertes.test(c) &&
vocalesFuertes.test(palabra[i + 1])) ||
(vocalesDebiles.test(c) &&
vocalesFuertes.test(palabra[i + 1]) && !tieneVocalFuerte)))
{
silabas.push(
{ texto : silabaActual, acento : tieneVocalFuerte });
silabaActual = "";
tieneVocalFuerte = false;
}
}
if (silabaActual)
{
silabas.push({ texto : silabaActual, acento : tieneVocalFuerte });
}
return silabas;
}
// ********************************************************************************************************************************
// Nombre: aplicarCapitalizacion
// Fecha modificación: 2025-04-10 22:15 GMT-5
// Autor: mincho77
// Entradas: original (string) – Palabra original
// corregida (string) – Palabra corregida
// Salidas: string – Palabra corregida con mayúsculas/minúsculas
// Descripción: Aplica mayúsculas/minúsculas a la palabra corregida
// según la original. Mantiene mayúsculas y minúsculas en la primera letra
// y el resto de la palabra.
// ********************************************************************************************************************************
function aplicarCapitalizacion(original, corregida)
{
if (original === original.toUpperCase())
{
return corregida.toUpperCase();
}
else if (original[0] === original[0].toUpperCase())
{
return corregida[0].toUpperCase() + corregida.slice(1);
}
return corregida;
}
// ********************************************************************************************************************************
// Nombre: aplicarTildeSilaba
// Fecha modificación: 2025-04-10 22:15 GMT-5
// Autor: mincho77
// Entradas: palabra (string) – Palabra original
// silabas (Array<{ texto: string, acento: boolean }>) – Lista de
// sílabas
// indiceSilaba (number) – Índice de la sílaba a tildar
// Salidas: string – Palabra con tilde aplicada
// Descripción: Aplica tilde a la sílaba especificada
// según las reglas de acentuación. La sílaba se identifica por su índice
// en la lista de sílabas. La función asume que la palabra ya ha sido
// separada en sílabas y que el índice es válido.
// ********************************************************************************************************************************
function aplicarTildeSilaba(palabra, silabas, indiceSilaba)
{
let resultado = "";
let posActual = 0;
silabas.forEach((silaba, i) => {
if (i === indiceSilaba)
{
const conTilde = silaba.texto.replace(
/([aeiou])([^aeiou]*)$/, (match, vocal, resto) => {
return (
vocal.normalize("NFD").replace(/[\u0300-\u036f]/g, "") +
"́" + resto);
});
resultado += conTilde;
}
else
{
resultado += silaba.texto;
}
});
return resultado;
}
// ********************************************************************************************************************************
// Nombre: applyNormalization
// Fecha modificación: 2025-04-15
// Hora: 13:30:00
// Autor: mincho77
// Entradas: Ninguna directamente (usa el arreglo `changes` ya cargado)
// Salidas: Aplica acciones en WME y muestra resultados
// Prerrequisitos: `changes` debe contener objetos válidos con `place`,
// `newName`, y opcionalmente `delete`
// ********************************************************************************************************************************
function applyNormalization(changes)
{
if (!Array.isArray(changes) || changes.length === 0)
{
showModal({
title : "Información",
message : "No hay cambios seleccionados para aplicar",
confirmText : "Aceptar",
type : "info"
});
return;
}
let lastAttemptedPlace = null;
let cambiosRechazados = 0;
try
{
changes.forEach((change) => {
lastAttemptedPlace = {
name : change.originalName ||
change.place.attributes?.name || "Sin nombre",
id : change.place.getID?.() || "ID no disponible"
};
if (change.delete)
{
const DeleteObject = require("Waze/Action/DeleteObject");
const action = new DeleteObject(change.place);
W.model.actionManager.add(action);
}
else
{
const UpdateObject = require("Waze/Action/UpdateObject");
const action =
new UpdateObject(change.place, { name : change.newName });
W.model.actionManager.add(action);
}
});
observarErroresDeWME(changes.length, lastAttemptedPlace);
W.controller?.setModified?.(true);
showModal({
title : "Éxito",
message : `${
changes
.length} cambio(s) enviados. Clic en Guardar para aplicar en WME.`,
type : "success",
autoClose : 2000
});
}
catch (error)
{
console.error("Error aplicando cambios:", error);
showModal({
title : "Error",
message :
"Error al aplicar cambios. Ver consola para detalles.",
confirmText : "Aceptar",
type : "error"
});
}
}
// ********************************************************************************************************************************
// Nombre: evaluarOrtografiaConTildes
// Fecha modificación: 2025-04-02
// Autor: mincho77
// Entradas: name (string) - Nombre del lugar
// Salidas: objeto con errores detectados
// Descripción:
// Evalúa palabra por palabra si falta una tilde en las palabras que lo
// requieren, según las reglas del español. Primero normaliza el nombre y
// luego verifica si las palabras necesitan una tilde.
// ********************************************************************************************************************************
function evaluarOrtografiaConTildes(name)
{ // Si el nombre está vacío, retornar inmediatamente una promesa resuelta
if (!name)
{
return Promise.resolve(
{ hasSpellingWarning : false, spellingWarnings : [] });
}
const palabras = name.trim().split(/\s+/);
const spellingWarnings = [];
console.log(
`[evaluarOrtografiaConTildes] Verificando ortografía de: ${name}`);
palabras.forEach(
async (
palabra,
index) => { // Normalizar la palabra antes de cualquier verificación
let normalizada = await normalizePlaceName(palabra, true);
// Ignorar palabras con "&" o que sean emoticonos
if (/^[A-Za-z]&[A-Za-z]$/.test(normalizada) ||
/^[\u263a-\u263c\u2764\u1f600-\u1f64f\u1f680-\u1f6ff]+$/.test(
normalizada))
{
return; // No verificar ortografía
}
// Excluir palabras específicas como "y" o "Y"
if (normalizada.toLowerCase() === "y" ||
/^\d+$/.test(normalizada) || normalizada === "-")
{
return; // Ignorar
}
// Excluir palabras específicas como "e" o "E"
if (normalizada.toLowerCase() === "e" ||
/^\d+$/.test(normalizada) || normalizada === "-")
{
return; // Ignorar
}
// Verificar si la palabra está en la lista de excluidas
if (excludeWords.some((w) => w.toLowerCase() ===
normalizada.toLowerCase()))
{
return; // Ignorar palabra excluida
}
// Validar que no tenga más de una tilde
const cantidadTildes =
(normalizada.match(/[áéíóú]/g) || []).length;
if (cantidadTildes > 1)
{
spellingWarnings.push({
original : palabra,
sugerida : null, // No hay sugerencia válida
tipo : "Error de tildes",
posicion : index
});
return;
}
// Verificar ortografía usando la API de LanguageTool
checkSpellingWithAPI(normalizada)
.then((errores) => {
errores.forEach((error) => {
spellingWarnings.push({
original : error.palabra,
sugerida : error.sugerencia,
tipo : "LanguageTool",
posicion : index
});
});
})
.catch((err) => {
console.error(
"Error al verificar ortografía con LanguageTool:", err);
});
});
return {
hasSpellingWarning : spellingWarnings.length > 0,
spellingWarnings
};
}
// ********************************************************************************************************************************
// Nombre: toggleSpinner
// Fecha modificación: 2025-03-31
// Autor: mincho77
// Entradas:
// show (boolean) - true para mostrar el spinner, false para ocultarlo
// message (string, opcional) - mensaje personalizado a mostrar junto al
// spinner Salidas: ninguna (modifica el DOM) Prerrequisitos: debe existir
// el estilo CSS del spinner en el documento Descripción: Muestra u oculta
// un indicador visual de carga con un mensaje opcional. El spinner usa un
// emoji de reloj de arena (⏳) con animación de rotación para indicar que
// el proceso está en curso.
// ********************************************************************************************************************************
function toggleSpinner(
show, message = "Revisando ortografía...", progress = null)
{
let existingSpinner = document.querySelector(".spinner-overlay");
if (existingSpinner)
{
if (show)
{ // Actualizar el mensaje y el progreso si el spinner ya existe
const spinnerMessage =
existingSpinner.querySelector(".spinner-message");
spinnerMessage.innerHTML = `
${message}
${
progress !== null
? `<br><strong>${progress}% completado</strong>`
: ""}
`;
}
else
{
existingSpinner.remove(); // Ocultar el spinner
}
return;
}
if (show)
{
const spinner = document.createElement("div");
spinner.className = "spinner-overlay";
spinner.innerHTML = `
<div class="spinner-content">
<div class="spinner-icon">⏳</div>
<div class="spinner-message">
${message}
${
progress !== null ? `<br><strong>${progress}% completado</strong>`
: ""}
</div>
</div>
`;
document.body.appendChild(spinner);
}
}
// Agregar los estilos CSS necesarios
const spinnerStyles = `
<style>
.spinner-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 10000;
}
.spinner-content {
background: white;
padding: 20px;
border-radius: 8px;
text-align: center;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
}
.spinner-icon {
font-size: 24px;
margin-bottom: 10px;
animation: spin 1s linear infinite; /* Aseguramos que la animación esté activa */
display: inline-block;
}
.spinner-message {
color: #333;
font-size: 14px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>`;
// Insertar los estilos al inicio del documento
document.head.insertAdjacentHTML("beforeend", spinnerStyles);
if (!Array.prototype.flat)
{
Array.prototype.flat = function(depth = 1) {
return this.reduce(function(flat, toFlatten) {
return flat.concat(Array.isArray(toFlatten)
? toFlatten.flat(depth - 1)
: toFlatten);
}, []);
};
}
// ********************************************************************************************************************************
// Nombre: validarWordSpelling
// Fecha modificación: 2025-03-30
// Autor: mincho77
// Entradas: palabra (string) - Palabra en español a validar
// ortográficamente Salidas: true si cumple reglas ortográficas básicas,
// false si no Descripción: Evalúa si una palabra tiene el uso correcto de
// tilde o si le falta una tilde según las reglas del español: esdrújulas
// siempre con tilde, agudas con tilde si terminan en n, s o vocal, y llanas
// con tildse si NO terminan en n, s o vocal. Se asegura que solo haya una
// tilde por palabra.
// ********************************************************************************************************************************
function validarWordSpelling(palabra)
{
if (!palabra)
return false;
// Ignorar siglas con formato X&X
if (/^[A-Za-z]&[A-Za-z]$/.test(palabra))
return true;
// Si la palabra es un número, no necesita validación
if (/^\d+$/.test(palabra))
return true;
const tieneTilde = /[áéíóú]/.test(palabra);
const cantidadTildes = (palabra.match(/[áéíóú]/g) || []).length;
if (cantidadTildes > 1)
return false;
// Solo se permite una tilde
const silabas = palabra.normalize("NFD")
.replace(/[^aeiouAEIOU\u0300-\u036f]/g, "")
.match(/[aeiouáéíóú]+/gi);
if (!silabas || silabas.length === 0)
return false;
const totalSilabas = silabas.length;
const ultimaLetra = palabra.slice(-1).toLowerCase();
let tipo = "";
if (totalSilabas >= 3 && /[áéíóú]/.test(palabra))
{
tipo = "esdrújula";
}
else if (totalSilabas >= 2)
{
const penultimaSilaba = silabas[totalSilabas - 2];
if (/[áéíóú]/.test(penultimaSilaba))
tipo = "grave";
}
if (!tipo)
tipo = /[áéíóú]/.test(silabas[totalSilabas - 1]) ? "aguda"
: "sin tilde";
if (tipo === "esdrújula")
return tieneTilde;
if (tipo === "aguda")
{
return ((/[nsáéíóúaeiou]$/.test(ultimaLetra) && tieneTilde) ||
(!/[nsáéíóúaeiou]$/.test(ultimaLetra) && !tieneTilde));
}
if (tipo === "grave")
{
return ((!/[nsáéíóúaeiou]$/.test(ultimaLetra) && tieneTilde) ||
(/[nsáéíóúaeiou]$/.test(ultimaLetra) && !tieneTilde));
}
return true;
}
// ********************************************************************************************************************************
// Nombre: escapeHtml
// Fecha modificación: 2025-06-20 18:30 GMT-5
// Autor: mincho77
// Entradas:
// - unsafe (string|any): Valor a escapar
// Salidas:
// - string: Texto escapado seguro para usar en HTML
// Prerrequisitos:
// - Ninguno
// Descripción:
// Convierte caracteres especiales en entidades HTML para prevenir XSS.
// Escapa los siguientes caracteres:
// & → &
// < → <
// > → >
// " → "
// ' → '
// 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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
let cambiosRechazados = 0;
//**********************************************************************
// Nombre: observarErroresDeWME
// Fecha modificación: 2025-04-15
// Hora: 13:01:25
// Autor: mincho77
// Entradas: Ninguna
// Salidas: Ninguna
// Descripción: Observa errores de WME y muestra un modal si se detecta
// un mensaje de error relacionado con restricciones de edición.
// Prerrequisitos: Ninguno
//**********************************************************************
function observarErroresDeWME(totalEsperado, lastAttemptedPlace)
{
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList)
{
for (const node of mutation.addedNodes)
{
if (node.nodeType === 1 &&
node.innerText?.includes(
"That change isn't allowed at this time"))
{
observer.disconnect();
const ahora = new Date().toLocaleString("es-CO");
const historico = JSON.parse(
localStorage.getItem("rechazosWME") || "[]");
historico.push({
timestamp : ahora,
motivo : "Cambio no permitido por WME",
lugar : lastAttemptedPlace?.name || "Desconocido",
id : lastAttemptedPlace?.id || "N/A"
});
localStorage.setItem("rechazosWME",
JSON.stringify(historico));
showModal({
title : "Resultado parcial",
message :
`⚠️ Algunos lugares no pudieron ser modificados por restricciones de WME.\n` +
`Verifica el historial o vuelve a intentarlo.`,
confirmText : "Aceptar",
type : "warning"
});
break;
}
}
}
});
observer.observe(document.body, { childList : true, subtree : true });
}
//**********************************************************************
// Nombre: waitForDOM
// Fecha modificación: 2025-04-15
// Hora: 13:01:25
// Autor: mincho77
// Entradas: selector (string) - Selector CSS del elemento a esperar
// callback (function) - Función a ejecutar cuando se encuentra el
// elemento
// interval (number) - Intervalo de tiempo entre intentos en ms
// maxAttempts (number) - Número máximo de intentos
// Salidas: Ninguna
// Descripción: Espera a que un elemento del DOM esté disponible y ejecuta
// la función de callback. Si no se encuentra el elemento después de un
// número máximo de intentos, se muestra un mensaje de advertencia en la
// consola.
//**********************************************************************
function waitForDOM(selector, callback, interval = 300, maxAttempts = 20)
{
let attempts = 0;
const checkExist = setInterval(() => {
const element = document.querySelector(selector);
attempts++;
if (element)
{
clearInterval(checkExist);
callback(element);
}
else if (attempts >= maxAttempts)
{
clearInterval(checkExist);
console.warn(
`[PlacesNameNormalizer] No se encontró el elemento ${
selector} después de ${maxAttempts} intentos.`);
}
}, interval);
}
// ******************************************************************************************************************************************************************
// Nombre: openFloatingPanel
// Fecha modificación: 2025-04-15
// Hora: 13:01:25
// Autor: mincho77
// Entradas: placesToNormalize (array) - Arreglo de lugares a normalizar
// Salidas: Ninguna
// Descripción: Abre un panel flotante con una tabla para normalizar nombres
// de lugares. Permite aplicar cambios, excluir palabras y agregar
// palabras especiales. Incluye un botón para cerrar el panel.
// Prerrequisitos: Ninguno
// ******************************************************************************************************************************************************************
function openFloatingPanel(placesToNormalize)
{
const panel = document.createElement("div");
panel.id = "normalizer-floating-panel";
panel.style.cssText = `
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
width: 90%; max-width: 1200px; max-height: 80vh; background: white;
padding: 20px; border-radius: 8px; box-shadow: 0 0 25px rgba(0,0,0,0.4);
z-index: 10000; overflow-y: auto; font-family: Arial, sans-serif;
`;
let html = `
<style>
#normalizer-table { width: 100%; border-collapse: collapse; margin: 15px 0; }
#normalizer-table th { background: #2c3e50; color: white; padding: 10px; text-align: left; }
#normalizer-table td { padding: 8px 10px; border-bottom: 1px solid #eee; }
.warning-row { background: #fff8e1; }
.normalize-btn, .apply-btn, .add-exclude-btn, .add-special-btn {
padding: 8px 16px; /* Aumentar el tamaño del botón */
margin: 2px;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
transition: all 0.3s;
}
.normalize-btn { background: #3498db; color: white; }
.apply-btn { background: #2ecc71; color: white; }
.add-exclude-btn { background: #e67e22; color: white; }
.add-special-btn { background: #9b59b6; color: white; }
.close-btn {
position: absolute; top: 10px; right: 10px;
background: #e74c3c; color: white; border: none;
width: 30px; height: 30px; border-radius: 50%; font-weight: bold;
}
input[type="checkbox"] { transform: scale(1.3); margin: 0 5px; }
input[type="text"] { width: 100%; padding: 5px; box-sizing: border-box; }
</style>
<button class="close-btn" id="close-panel-btn">×</button>
<h2 style="color: #2c3e50; margin-top: 5px;">Normalizador de Nombres</h2>
<div style="margin: 10px 0; color: #7f8c8d;">
<span id="places-count">${
placesToNormalize.length} lugares para revisar</span>
</div>
<table id="normalizer-table">
<thead>
<tr>
<th width="5%">Aplicar</th>
<th width="5%">Eliminar</th>
<th width="25%">Nombre Actual</th>
<th width="25%">Nombre Normalizado</th>
<th width="15%">Problema Detectado</th>
<th width="10%">Acciones</th>
</tr>
</thead>
<tbody>`;
placesToNormalize.forEach((place, index) => {
const {
originalName,
newName,
hasSpellingWarning,
spellingWarnings,
place : venue
} = place;
const placeId = venue.getID();
html += `
<tr>
<td>
<input type="checkbox" class="normalize-checkbox" data-index="${
index}" data-type="full">
</td>
<td>
<input type="checkbox" class="delete-checkbox" data-index="${
index}">
</td>
<td>${escapeHtml(originalName)}</td>
<td>
<input type="text" class="new-name-input" value="${
escapeHtml(newName)}"
data-index="${index}" data-place-id="${
placeId}" data-type="full"
data-original="${escapeHtml(originalName)}">
</td>
<td>${
originalName !== newName ? "Normalización necesaria" : "-"}</td>
<td>
<button class="normalize-btn" data-index="${
index}">NrmliZer</button>
<button class="add-special-btn" data-word="${
escapeHtml(originalName)}">AddWrdDic</button>
<button class="add-exclude-btn" data-word="${
escapeHtml(
originalName)}" data-index="${index}">ExcludeWrd</button>
</td>
</tr>`;
spellingWarnings.forEach((warning, warningIndex) => {
html += `
<tr class="warning-row">
<td><input type="checkbox" class="normalize-checkbox" data-index="${index}" data-warning-index="${warningIndex}" data-type="warning"></td>
<td></td>
<td>${escapeHtml(warning.original)}</td>
<td><input type="text" class="new-name-input" value="${escapeHtml(warning.sugerida || newName)}"
data-index="${index}" data-place-id="${placeId}" data-warning-index="${warningIndex}" data-type="warning"></td>
<td>${escapeHtml(warning.tipo || "Error ortográfico")}
<div class="tool-source">${warning.origen || "Reglas locales"}</div>
</td>
<td>
<button class="apply-btn" data-index="${index}" data-warning-index="${warningIndex}">Aplicar</button>
<button class="add-exclude-btn" data-word="${escapeHtml(warning.original)}" data-index="${index}">Excluir</button>
</td>
</tr>`;
});
});
html += `</tbody></table>
<div style="margin-top: 20px; text-align: right;">
<button id="apply-all-btn" style="background: #27ae60; color: white; padding: 10px 20px;
border: none; border-radius: 4px; font-weight: bold;">Aplicar Cambios Seleccionados</button>
<button id="cancel-btn" style="background: #e74c3c; color: white; padding: 10px 20px;
border: none; border-radius: 4px; margin-left: 10px; font-weight: bold;">Cancelar</button>
</div>`;
panel.innerHTML = html;
document.body.appendChild(panel);
document.getElementById("close-panel-btn")
.addEventListener("click", () => panel.remove());
document.getElementById("cancel-btn")
.addEventListener("click", () => panel.remove());
document.getElementById("apply-all-btn")
.addEventListener("click", () => {
const selectedPlaces =
placesToNormalize.filter((place, index) => {
const checkbox = panel.querySelector(
`.normalize-checkbox[data-index="${index}"]`);
return checkbox && checkbox.checked;
});
if (selectedPlaces.length === 0)
{
showModal({
title : "Advertencia",
message :
"No se seleccionaron lugares para aplicar cambios.",
confirmText : "Aceptar",
type : "warning"
});
return;
}
applyNormalization(selectedPlaces);
panel.remove();
});
// Evento para marcar el checkbox de "Aplicar" al modificar un texto,
// y lógica de exclusión para "Eliminar"
panel.querySelectorAll(".new-name-input").forEach((input) => {
input.addEventListener("input", function () {
const row = this.closest("tr");
const applyCheckbox = row?.querySelector(".normalize-checkbox");
const deleteCheckbox = row?.querySelector(".delete-checkbox");
const original = this.dataset.original || "";
const current = this.value.trim();
if (applyCheckbox && deleteCheckbox) {
if (current !== original) {
applyCheckbox.checked = true;
deleteCheckbox.checked = false;
} else {
applyCheckbox.checked = false;
}
}
});
});
// Evento para marcar "Aplicar" si se selecciona "Eliminar" (sólo una vez)
panel.querySelectorAll(".delete-checkbox").forEach((checkbox) => {
checkbox.addEventListener("change", function () {
const row = this.closest("tr");
const applyCheckbox = row?.querySelector(".normalize-checkbox");
if (this.checked && applyCheckbox) {
applyCheckbox.checked = true;
}
});
});
// Evento para normalizar el nombre al hacer clic en "NrmliZer"
panel.querySelectorAll(".normalize-btn").forEach((btn) => {
btn.addEventListener("click", async function() {
const row = this.closest("tr");
const input =
row.querySelector(".new-name-input[data-type='full']");
const applyCheckbox =
row.querySelector("input.normalize-checkbox");
const deleteCheckbox =
row.querySelector("input.delete-checkbox");
if (!input)
return;
// Animación
let dots = 0;
const originalText = "NrmliZer";
const interval = setInterval(() => {
dots = (dots + 1) % 4;
this.textContent = originalText + ".".repeat(dots);
}, 500);
try
{
input.value = await normalizePlaceName(input.value, true);
if (applyCheckbox)
applyCheckbox.checked = true;
if (deleteCheckbox)
deleteCheckbox.checked = false;
clearInterval(interval);
this.textContent = "✓ Ready";
this.style.backgroundColor = "#95a5a6";
this.disabled = true;
}
catch (error)
{
console.error("Error al normalizar:", error);
clearInterval(interval);
this.textContent = originalText;
}
});
});
panel.querySelectorAll(".apply-btn").forEach((btn) => {
btn.addEventListener("click", function() {
const index = this.dataset.index;
const warningIndex = this.dataset.warningIndex;
const checkbox =
panel.querySelector(`.normalize-checkbox[data-index="${
index}"][data-warning-index="${warningIndex}"]`);
if (checkbox)
{
checkbox.checked = true;
this.textContent = "✓ Aplicado";
this.style.backgroundColor = "#95a5a6";
this.disabled = true;
}
});
});
panel.querySelectorAll(".add-special-btn").forEach((btn) => {
btn.addEventListener("click", function() {
const name = this.dataset.word;
openAddSpecialWordPopup(
name); // Llamar al modal para seleccionar palabras
});
});
panel.querySelectorAll(".add-exclude-btn").forEach((btn) => {
btn.addEventListener("click", function() {
const word = this.dataset.word;
if (word)
{
openAddSpecialWordPopup(
word, "excludeWords"); // Llama al popup para agregar a palabras excluidas
}
});
});
}
// ********************************************************************************************************************************
// Nombre: checkOnlyTildes (4)
// Fecha modificación: 2025-06-21
// Autor: mincho77
// Entradas:
// - original (string): Palabra original a comparar.
// - sugerida (string): Palabra sugerida a comparar.
// Salidas:
// - boolean:
// - true si las palabras son iguales excepto por tildes.
// - false si difieren en otros caracteres o si alguna es
// undefined/null.
// Descripción:
// Compara dos palabras ignorando tildes/diacríticos para determinar si la
// única diferencia entre ellas es la acentuación. Utiliza normalización
// Unicode para una comparación precisa. Optimizada para reducir operaciones
// innecesarias.
// ********************************************************************************************************************************
function checkOnlyTildes(original, sugerida)
{
if (typeof original !== "string" || typeof sugerida !== "string")
{
return false;
}
if (original === sugerida)
{
return false;
}
const normalize = (str) =>
str.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
return normalize(original) === normalize(sugerida);
}
// ********************************************************************************************************************************
// Nombre: deleteWord
// Fecha modificación: 2025-04-14
// Autor: mincho77
// Entradas:
// - index (number): Índice de la palabra a eliminar.
// Salidas: Ninguna. Muestra un modal de confirmación.
// Descripción:
// Muestra un modal de confirmación para eliminar una palabra de la lista de
// exclusiones. Si el usuario confirma, elimina la palabra de la lista y
// actualiza el almacenamiento local.
// ********************************************************************************************************************************
function deleteWord(index)
{
const wordToDelete = excludeWords[index];
if (!wordToDelete)
return;
showModal({
title : "Eliminar palabra",
message :
`¿Estás seguro de que deseas eliminar la palabra <strong>${
wordToDelete}</strong>?`,
confirmText : "Eliminar",
cancelText : "Cancelar",
type : "warning",
onConfirm : () => {
// Eliminar la palabra de la lista
excludeWords.splice(index, 1);
// Actualizar localStorage
localStorage.setItem("excludeWords",
JSON.stringify(excludeWords));
// Actualizar la interfaz
renderExcludedWordsPanel();
showModal({
title : "Éxito",
message : "La palabra fue eliminada correctamente.",
confirmText : "Aceptar",
type : "success",
autoClose : 2000,
});
},
});
}
// ********************************************************************************************************************************
// Nombre: openDeletePopup
// Fecha modificación: 2025-04-14
// Autor: mincho77
// Entradas:
// - index (number): Índice de la palabra a eliminar.
// Salidas: Ninguna. Muestra un modal de confirmación.
// Descripción:
// Muestra un modal de confirmación para eliminar una palabra de la lista de exclusiones. Si el usuario confirma, elimina la
// palabra de la lista y actualiza el almacenamiento local.
// ********************************************************************************************************************************
function openDeletePopup(index)
{
const wordToDelete = excludeWords[index];
if (!wordToDelete)
{
console.error(`No se encontró la palabra en el índice ${index}`);
return;
}
showModal({
title : "Eliminar palabra",
message :
`¿Estás seguro de que deseas eliminar la palabra <strong>${
wordToDelete}</strong>?`,
confirmText : "Eliminar",
cancelText : "Cancelar",
type : "warning",
onConfirm : () => {
// Eliminar la palabra de la lista
excludeWords.splice(index, 1);
// Actualizar localStorage
localStorage.setItem("excludeWords",
JSON.stringify(excludeWords));
// Actualizar la interfaz
renderExcludedWordsPanel();
showModal({
title : "Éxito",
message : "La palabra fue eliminada correctamente.",
confirmText : "Aceptar",
type : "success",
autoClose : 2000,
});
},
});
}
// ********************************************************************************************************************************
// Nombre: evaluarOrtografiaNombre
// Fecha modificación: 2025-04-10 20:45 GMT-5
// Autor: mincho77
// Entradas:
// - name (string): Nombre a evaluar.
// - opciones (object): Opciones de configuración.
// - timeout (number): Tiempo máximo de espera para la API (ms).
// - usarCache (boolean): Si se debe usar caché para resultados
// - modoEstricto (boolean): Si se debe aplicar modo estricto.
// Salidas:
// - Promise: Objeto que contiene el resultado de la evaluación.
// Descripción: Evalúa la ortografía de un nombre utilizando reglas locales y la API de LanguageTool. Devuelve un objeto con
// advertencias de ortografía y metadatos sobre la evaluación. Incluye un sistema de caché para evitar llamadas duplicadas durante
// la sesión.
// Prerrequisitos: Funciones auxiliares como tieneTildesIncorrectas y corregirTildeLocal.
// ********************************************************************************************************************************
function evaluarOrtografiaNombre(name, opciones = {})
{
const config = {
timeout : opciones.timeout || 5000,
usarCache : opciones.usarCache !== false,
modoEstricto : opciones.modoEstricto || false
};
// Cache simple (evita llamadas duplicadas durante la sesión)
const cache = evaluarOrtografiaNombre.cache ||
(evaluarOrtografiaNombre.cache = new Map());
const cacheKey = `${config.modoEstricto}-${name}`;
if (config.usarCache && cache.has(cacheKey))
{
return Promise.resolve(cache.get(cacheKey));
}
return new Promise((resolve) => { // 1. Validación de entrada
if (typeof name !== "string" || name.trim().length === 0)
{
const resultado = {
hasSpellingWarning : false,
spellingWarnings : [],
metadata : { apiStatus : "invalid_input" }
};
cache.set(cacheKey, resultado);
return resolve(resultado);
}
const inicio = Date.now();
let timeoutExcedido = false;
// 2. Timeout de seguridad
const timeoutId = setTimeout(() => {
timeoutExcedido = true;
const resultado = {
hasSpellingWarning : false,
spellingWarnings : [],
metadata : {
apiStatus : "timeout",
tiempoRespuesta : Date.now() - inicio
}
};
cache.set(cacheKey, resultado);
resolve(resultado);
}, config.timeout);
// 3. Primero verificar reglas locales (sincrónicas)
const problemasLocales = [];
const palabras = name.split(/\s+/);
palabras.forEach((palabra) => {
if (tieneTildesIncorrectas(palabra))
{
problemasLocales.push({
original : palabra,
sugerida : corregirTildeLocal(palabra),
tipo : "Tilde incorrecta",
origen : "Reglas locales"
});
}
});
// 4. Si hay problemas locales y no es modo estricto, devolver
// inmediato
if (problemasLocales.length > 0 && !config.modoEstricto)
{
clearTimeout(timeoutId);
const resultado = {
hasSpellingWarning : true,
spellingWarnings : problemasLocales,
metadata : { apiStatus : "local_rules_applied" }
};
cache.set(cacheKey, resultado);
return resolve(resultado);
}
// 5. Consultar API LanguageTool
GM_xmlhttpRequest({
method : "POST",
url : "https://api.languagetool.org/v2/check",
headers : {
"Content-Type" : "application/x-www-form-urlencoded",
Accept : "application/json"
},
data : `language=es&text=${encodeURIComponent(name)}`,
onload : (response) => {
if (timeoutExcedido)
return;
clearTimeout(timeoutId);
const tiempoRespuesta = Date.now() - inicio;
let resultado;
try
{
if (response.status === 200)
{
const data = JSON.parse(response.responseText);
const problemasAPI = data.matches.map(
(match) => ({
original : match.context.text.substring(
match.context.offset,
match.context.offset +
match.context.length),
sugerida : match.replacements[0]?.value ||
match.context.text,
tipo :
"Ortografía", // Cambiar a "Ortografía"
origen : "API",
regla : match.rule.id,
contexto : match.context.text
}));
// Combinar resultados locales y de API
const todosProblemas =
[...problemasLocales, ...problemasAPI ];
resultado = {
hasSpellingWarning : todosProblemas.length > 0,
spellingWarnings : todosProblemas,
metadata : {
apiStatus : "success",
tiempoRespuesta,
totalErrores : todosProblemas.length
}
};
}
else
{
resultado = {
hasSpellingWarning :
problemasLocales.length > 0,
spellingWarnings : problemasLocales,
metadata : {
apiStatus : `api_error_${response.status}`,
tiempoRespuesta
}
};
}
}
catch (error)
{
resultado = {
hasSpellingWarning : problemasLocales.length > 0,
spellingWarnings : problemasLocales,
metadata :
{ apiStatus : "parse_error", tiempoRespuesta }
};
}
cache.set(cacheKey, resultado);
resolve(resultado);
},
onerror : () => {
if (timeoutExcedido)
return;
clearTimeout(timeoutId);
const resultado = {
hasSpellingWarning : problemasLocales.length > 0,
spellingWarnings : problemasLocales,
metadata : {
apiStatus : "network_error",
tiempoRespuesta : Date.now() - inicio
}
};
cache.set(cacheKey, resultado);
resolve(resultado);
}
});
});
}
// ********************************************************************************************************************************
// Nombre: corregirTildeLocal
// Fecha modificación: 2025-04-25 05:45 GMT-5
// Autor: mincho77
// Entradas:
// - palabra (string): Palabra a corregir
// Salidas: (string): Palabra corregida o la original si no hay corrección.
// Descripción: Esta función corrige las tildes de palabras específicas en español. Se basa en un objeto de correcciones
// predefinido. Si la palabra no está en el objeto, se devuelve la palabra original.
// ********************************************************************************************************************************
function corregirTildeLocal(palabra)
{
const correcciones = {
aun : "aún", // Adverbio de tiempo
tu : "tú", // Pronombre personal
mi : "mí", // Pronombre personal
el : "él", // Pronombre personal
si : "sí", // Afirmación o pronombre reflexivo
de : "dé", // Verbo dar
se : "sé", // Verbo saber o ser
mas : "más", // Adverbio de cantidad
te : "té", // Sustantivo (bebida)
que : "qué", // Interrogativo o exclamativo
quien : "quién", // Interrogativo o exclamativo
como : "cómo", // Interrogativo o exclamativo
cuando : "cuándo", // Interrogativo o exclamativo
donde : "dónde", // Interrogativo o exclamativo
cual : "cuál", // Interrogativo o exclamativo
cuanto : "cuánto", // Interrogativo o exclamativo
porque : "porqué", // Sustantivo (la razón)
porqué : "por qué", // Interrogativo o exclamativo
};
return correcciones[palabra.toLowerCase()] || palabra;
}
// ********************************************************************************************************************************
// Nombre: scanPlaces
// Fecha modificación: 2025-04-10 18:30 GMT-5
// Autor: mincho77
// Entradas: Ninguna
// Salidas: Ninguna
// Descripción: Escanea los lugares en el mapa y normaliza sus nombres. Filtra los lugares que no tienen nombre y procesa aquellos
// que requieren normalización. Muestra un panel flotante con los lugares a normalizar y
// permite aplicar cambios, excluir palabras y agregar palabras especiales. Incluye un botón para cerrar el panel.
// Prerrequisitos: Funciones auxiliares como normalizePlaceName y evaluarOrtografiaNombre.
// ********************************************************************************************************************************
function scanPlaces()
{
const maxPlaces =
parseInt(document.getElementById("maxPlacesInput")?.value || 100, 10);
if (!W?.model?.venues?.objects)
{
console.error("Modelo WME no disponible");
return;
}
const allPlaces = Object.values(W.model.venues.objects)
.filter((place) => {
// Filtrar lugares que no tienen nombre
if (!place?.attributes?.name)
{
return false;
}
return true;
})
.slice(0, maxPlaces);
if (allPlaces.length === 0)
{
toggleSpinner(false);
showNoPlacesFoundMessage(); // Mostrar el mensaje mejorado
return;
}
// 6. Procesamiento asíncrono con progreso
let processedCount = 0;
const placesToNormalize = [];
const processBatch = async (index) => {
if (index >= allPlaces.length)
{
toggleSpinner(false);
if (placesToNormalize.length > 0)
{
openFloatingPanel(placesToNormalize);
}
else
{
showModal({
title : "Advertencia",
message :
"No se encontraron lugares que requieran normalización.",
confirmText : "Entendido",
type : "warning"
});
}
return;
}
const place = allPlaces[index];
try
{
const originalName = place.attributes.name;
const normalizedName =
await normalizePlaceName(originalName, true);
// Actualizar progreso
processedCount++;
toggleSpinner(
true,
`Procesando lugares... (${processedCount}/${
allPlaces.length})`,
Math.round((processedCount / allPlaces.length) * 100));
// Evaluar ortografía (usando el modo seleccionado)
const ortografia =
checkOnlyTildes
? await evaluarOrtografiaConTildes(normalizedName)
: await evaluarOrtografiaNombre(normalizedName);
if (ortografia.hasSpellingWarning ||
originalName !== normalizedName)
{
placesToNormalize.push({
id : place.getID(),
originalName,
newName : normalizedName,
hasSpellingWarning : ortografia.hasSpellingWarning,
spellingWarnings : ortografia.spellingWarnings,
place
});
}
// Procesar siguiente lugar con pequeño retardo para no bloquear UI
setTimeout(() => processBatch(index + 1), 50);
}
catch (error)
{
console.error(`Error procesando lugar ${place.getID()}:`, error);
// Continuar con el siguiente lugar a pesar del error
setTimeout(() => processBatch(index + 1), 50);
}
};
// Iniciar procesamiento por lotes
processBatch(0);
}
// ********************************************************************************************************************************
// Nombre: renderDictionaryWordsPanel
// Fecha modificación: 2025-04-14
// Autor: mincho77
// Entradas: Ninguna (usa la variable global dictionaryWords).
// Salidas: Ninguna.
// Descripción:
// Limpia y renderiza la lista de palabras del diccionario en el panel
// lateral. Ordena las palabras alfabéticamente y actualiza el localStorage.
// ********************************************************************************************************************************
function renderDictionaryWordsPanel()
{
const container = document.getElementById("dictionary-words-list");
if (!container)
{
console.warn(
"[PlacesNameNormalizer] No se encontró el contenedor 'dictionary-words-list'.");
return;
}
container.innerHTML = "";
const dict = spellDictionaries[activeDictionaryLang] || {};
const words =
Object.values(dict).flat().sort((a, b) => a.localeCompare(b));
dictionaryWords = words; // Actualiza global para búsquedas
const ul = document.createElement("ul");
ul.style.listStyle = "none";
words.forEach((word) => {
const li = document.createElement("li");
li.style.display = "flex";
li.style.justifyContent = "space-between";
li.style.alignItems = "center";
li.style.padding = "5px 0";
const wordSpan = document.createElement("span");
wordSpan.textContent = word;
li.appendChild(wordSpan);
const btnContainer = document.createElement("div");
btnContainer.style.display = "flex";
btnContainer.style.gap = "10px";
const editBtn = document.createElement("button");
editBtn.textContent = "✏️";
editBtn.title = "Editar";
editBtn.style.cursor = "pointer";
editBtn.addEventListener("click", () => {
const index =
dictionaryWords.indexOf(wordSpan.textContent.trim());
if (index !== -1)
{
openEditPopup(index, "dictionaryWords");
}
});
const deleteBtn = document.createElement("button");
deleteBtn.textContent = "🗑️";
deleteBtn.title = "Eliminar";
deleteBtn.style.cursor = "pointer";
deleteBtn.addEventListener("click", () => {
const index =
dictionaryWords.indexOf(wordSpan.textContent.trim());
if (index !== -1)
{
openDeletePopupForDictionary(index);
}
});
btnContainer.appendChild(editBtn);
btnContainer.appendChild(deleteBtn);
li.appendChild(btnContainer);
ul.appendChild(li);
});
container.appendChild(ul);
}
// ********************************************************************************************************************************
// Nombre: renderExcludedWordsPanel
// Fecha modificación: 2025-03-30
// Autor: mincho77
// Entradas: Ninguna (usa la variable global excludeWords).
// Salidas: Ninguna.
// Descripción: Limpia y renderiza la lista de palabras excluidas en el panel lateral. Ordena las palabras alfabéticamente y
// actualiza el localStorage.
// ********************************************************************************************************************************
function renderExcludedWordsPanel()
{
const container = document.getElementById("normalizer-sidebar");
if (!container)
{
console.warn(`[${
SCRIPT_NAME}] No se encontró el contenedor "normalizer-sidebar".`);
return;
}
// Limpiar el contenedor para evitar acumulaciones
container.innerHTML = "";
// Crear un elemento <ul> para la lista
const list = document.createElement("ul");
list.style.listStyle = "none"; // Opcional: eliminar viñetas
// Iterar sobre cada palabra de la lista excluida
excludeWords.forEach((word, index) => {
const li = document.createElement("li");
li.style.display = "flex";
li.style.justifyContent = "space-between";
li.style.alignItems = "center";
li.style.padding = "5px 0";
// Crear un <span> que muestre la palabra
const wordSpan = document.createElement("span");
wordSpan.textContent = word;
li.appendChild(wordSpan);
// Crear un contenedor para los botones
const btnContainer = document.createElement("div");
btnContainer.style.display = "flex";
btnContainer.style.gap = "10px";
// Botón de editar
const editBtn = document.createElement("button");
editBtn.textContent = "✏️";
editBtn.title = "Editar";
editBtn.style.cursor = "pointer";
// Asigna el event listener de editar, pasando el índice y el tipo de lista
editBtn.addEventListener(
"click", () => { openEditPopup(index, "excludeWords"); });
btnContainer.appendChild(editBtn);
// Botón de borrar
const deleteBtn = document.createElement("button");
deleteBtn.textContent = "🗑️";
deleteBtn.title = "Eliminar";
deleteBtn.style.cursor = "pointer";
deleteBtn.addEventListener("click", () => {
openDeletePopup(index); // Llama a la función para mostrar el modal de confirmación
});
btnContainer.appendChild(deleteBtn);
li.appendChild(btnContainer);
list.appendChild(li);
});
container.appendChild(list);
}
function renderExcludedWordsPanel2()
{
const container = document.getElementById("normalizer-sidebar");
if (!container)
{
console.warn(`[${
SCRIPT_NAME}] No se encontró el contenedor "normalizer-sidebar".`);
return;
}
// Limpiar el contenedor para evitar acumulaciones
container.innerHTML = "";
// Crear un elemento <ul> para la lista
const list = document.createElement("ul");
list.style.listStyle = "none"; // Opcional: eliminar viñetas
// Iterar sobre cada palabra de la lista excluida
excludeWords.forEach((word, index) => {
const li = document.createElement("li");
li.style.display = "flex";
li.style.justifyContent = "space-between";
li.style.alignItems = "center";
li.style.padding = "5px 0";
// Crear un <span> que muestre la palabra
const wordSpan = document.createElement("span");
wordSpan.textContent = word;
li.appendChild(wordSpan);
// Crear un contenedor para los botones
const btnContainer = document.createElement("div");
btnContainer.style.display = "flex";
btnContainer.style.gap = "10px";
// Botón de editar
const editBtn = document.createElement("button");
editBtn.textContent = "✏️";
editBtn.title = "Editar";
editBtn.style.cursor = "pointer";
// Asigna el event listener de editar, pasando el índice y el tipo
// de lista
editBtn.addEventListener(
"click", () => { openEditPopup(index, "excludeWords"); });
btnContainer.appendChild(editBtn);
// Botón de borrar
const deleteBtn = document.createElement("button");
deleteBtn.textContent = "🗑️";
deleteBtn.title = "Eliminar";
deleteBtn.style.cursor = "pointer";
deleteBtn.addEventListener(
"click", () => { openDeletePopupForDictionary(index); });
btnContainer.appendChild(deleteBtn);
li.appendChild(btnContainer);
list.appendChild(li);
});
container.appendChild(list);
}
// ********************************************************************************************************************************
// Nombre: setupDragAndDrop
// Fecha modificación: 2025-04-22
// Hora: 22:37
// Autor: mincho77
// Entradas: Ninguna (usa la variable global type).
// Salidas: Ninguna.
// Descripción: Soporta archivos .txt y .xml para diccionario ortográfico.
// ********************************************************************************************************************************
function setupDragAndDrop({ dropZoneId, onFileProcessed, type })
{
const dropZone = document.getElementById(dropZoneId);
if (!dropZone)
{
console.warn(
`[setupDragAndDrop] No se encontró el elemento con ID '${
dropZoneId}'`);
return;
}
// 🔁 Evitar que el navegador abra el archivo en toda la ventana
["dragenter", "dragover", "drop"].forEach(eventName => {
window.addEventListener(eventName, e => {
e.preventDefault();
e.stopPropagation();
});
});
// 🟩 Efecto visual al arrastrar
dropZone.addEventListener("dragover", (e) => {
e.preventDefault();
e.stopPropagation();
dropZone.style.borderColor = "#4CAF50";
dropZone.style.backgroundColor = "#eaffea";
});
// 🔙 Restablecer el estilo si sale del área
dropZone.addEventListener("dragleave", () => {
dropZone.style.borderColor = "#ccc";
dropZone.style.backgroundColor = "";
});
// 📥 Manejar el archivo soltado
dropZone.addEventListener("drop", (event) => {
event.preventDefault();
event.stopPropagation();
dropZone.style.borderColor = "#ccc";
dropZone.style.backgroundColor = "";
const file = event.dataTransfer.files[0];
if (!file)
return;
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target.result.trim();
let words = [];
if (file.name.endsWith(".xml"))
{
try
{
const parser = new DOMParser();
const xml =
parser.parseFromString(content, "application/xml");
const wordNodes = xml.getElementsByTagName("word");
words = Array.from(wordNodes)
.map(n => n.textContent.trim())
.filter(Boolean);
}
catch (err)
{
console.error("❌ Error al parsear XML:", err);
showModal({
title : "Error",
message : "No se pudo leer el archivo XML.",
confirmText : "Aceptar",
type : "error",
});
return;
}
}
else
{
words = content.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean);
}
if (typeof onFileProcessed === "function")
{
onFileProcessed(words);
}
};
reader.readAsText(file);
});
}
function setupDragAndDrop2({ dropZoneId, onFileProcessed, type })
{
const dropZone = document.getElementById(dropZoneId);
if (!dropZone)
{
console.warn(
`[setupDragAndDrop] No se encontró el elemento con ID '${
dropZoneId}'`);
return;
}
// Prevenir comportamiento por defecto en toda la ventana
window.addEventListener("dragover", e => e.preventDefault());
window.addEventListener("drop", e => e.preventDefault());
// Prevenir comportamiento por defecto y aplicar estilo en zona
["dragenter", "dragover"].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
dropZone.style.backgroundColor = "#e6ffe6"; // Verde suave
dropZone.style.borderColor = "#28a745";
});
});
["dragleave", "drop"].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
dropZone.style.backgroundColor = "";
dropZone.style.borderColor = "#ccc";
});
});
dropZone.addEventListener("drop", (event) => {
const file = event.dataTransfer.files[0];
if (!file)
return;
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target.result;
let words = [];
if (file.name.endsWith(".txt"))
{
words = content.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean);
}
else if (file.name.endsWith(".xml"))
{
try
{
const parser = new DOMParser();
const xml = parser.parseFromString(content, "text/xml");
const wordNodes = xml.getElementsByTagName("word");
words =
Array.from(wordNodes).map(n => n.textContent.trim());
}
catch (err)
{
console.error("❌ Error al parsear XML:", err);
return;
}
}
if (typeof onFileProcessed === "function")
{
onFileProcessed(words);
}
};
reader.readAsText(file);
});
}
// ********************************************************************************************************************************
// Nombre: setupDragAndDropImport
// Fecha modificación: 2025-03-31
// Autor: mincho77
// Entradas: Ninguna.
// Salidas: Ninguna.
// Descripción:
// Activa la funcionalidad de drag & drop sobre el elemento con id "drop-zone" para importar un archivo con palabras excluidas.
// Procesa archivos .xml y .txt.
// ********************************************************************************************************************************
function setupDragAndDropImport()
{
const dropZone = document.getElementById("drop-zone");
if (!dropZone)
{
console.warn(
"setupDragAndDropImport: No se encontró el elemento #drop-zone");
return;
}
dropZone.addEventListener("dragover", (e) => {
e.preventDefault();
dropZone.style.borderColor = "#4CAF50";
dropZone.style.backgroundColor = "#f0fff0";
console.log("dragover detectado");
});
dropZone.addEventListener("dragleave", (e) => {
dropZone.style.backgroundColor = "";
dropZone.style.borderColor = "#ccc";
console.log("dragleave detectado");
});
dropZone.addEventListener("drop", (e) => {
e.preventDefault();
dropZone.style.borderColor = "#ccc";
dropZone.style.backgroundColor = "";
console.log("drop detectado");
const file = e.dataTransfer.files[0];
if (!file)
{
console.log("No se detectó ningún archivo");
return;
}
console.log("Archivo soltado:", file.name);
const reader = new FileReader();
reader.onload = function(event) {
console.log("Contenido del archivo:", event.target.result);
let palabras = [];
if (file.name.endsWith(".xml"))
{
const parser = new DOMParser();
const xml =
parser.parseFromString(event.target.result, "text/xml");
const nodes = xml.querySelectorAll(
"word, palabra, item, excluded, exclude");
palabras = Array.from(nodes)
.map((n) => n.textContent.trim())
.filter((p) => p.length > 0);
}
else
{
palabras = event.target.result.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
}
if (palabras.length === 0)
{ // alert("⚠️ No se encontraron palabras válidas.");
showModal({
title : "Advertencia",
message : "No se encontraron palabras válidas.",
type : "warning",
autoClose :
2000, // El modal desaparecerá después de 2 segundos
});
return;
}
const replace =
document.getElementById("replaceExcludeListCheckbox");
if (replace && replace.checked)
{
excludeWords = [];
localStorage.removeItem("excludeWords");
}
else
{
excludeWords =
JSON.parse(localStorage.getItem("excludeWords")) || [];
}
excludeWords = [...new Set([...excludeWords, ...palabras ]) ]
.filter((w) => w.trim().length > 0)
.sort((a, b) => a.localeCompare(b));
localStorage.setItem("excludeWords",
JSON.stringify(wordLists.excludeWords));
renderExcludedWordsPanel();
showModal({
title : "Información",
message :
"Se importaron {prependText} palabras desde el archivo.",
prependText : palabras.length,
confirmText : "Aceptar",
type : "info"
});
};
reader.readAsText(file);
});
}
// ********************************************************************************************************************************
// Nombre: handleImportList
// Fecha modificación: 2025-03-30
// Autor: mincho77
// Entradas: Ninguna (depende del input file "importListInput" y checkbox "replaceExcludeListCheckbox").
// Salidas: Ninguna.
// Descripción: Lee un archivo seleccionado por el usuario, procesa sus líneas para extraer palabras válidas, y actualiza la lista
// de palabras excluidas (localStorage y panel).
// ********************************************************************************************************************************
function handleImportList()
{
const fileInput = document.getElementById("importListInput");
const replaceCheckbox =
document.getElementById("replaceExcludeListCheckbox");
if (!fileInput || !fileInput.files || fileInput.files.length === 0)
{
showModal({
title : "Información",
message : "No se seleccionó ningún archivo.",
confirmText : "Aceptar",
type : "info"
});
return;
}
const file = fileInput.files[0];
const reader = new FileReader();
reader.onload = function(event) {
const rawLines = event.target.result.split(/\r?\n/);
const lines =
rawLines
.map((line) => line.replace(/[^\p{L}\p{N}().\s-]/gu, "").trim())
.filter((line) => line.length > 0);
if (lines.length === 0)
{
showModal({
title : "Error",
message : "El archivo no contiene datos válidos.",
confirmText : "Aceptar",
type : "error"
});
return;
}
if (replaceCheckbox && replaceCheckbox.checked)
{
excludeWords = [];
}
else
{
excludeWords =
JSON.parse(localStorage.getItem("excludeWords")) || [];
}
excludeWords = [...new Set([...excludeWords, ...lines ]) ]
.filter((w) => w.trim().length > 0)
.sort((a, b) => a.localeCompare(b));
localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
renderExcludedWordsPanel(); // Refresca la lista después de importar
showModal({
title : "Éxito",
message : `Se importaron ${
lines.length} palabras a la lista de palabras especiales.`,
confirmText : "Aceptar",
type : "success"
});
fileInput.value = ""; // Reinicia el input de archivo
};
reader.onerror = function() {
showModal({
title : "Error",
message :
"Hubo un problema al leer el archivo. Inténtalo nuevamente.",
confirmText : "Aceptar",
type : "error"
});
};
reader.readAsText(file);
}
// ********************************************************************************************************************************
// Nombre: renderSpecialWordsPanel
// Fecha modificación: 2025-04-25 05:45 GMT-5
// Autor: mincho77
// Entradas: Ninguna (usa la variable global specialWords).
// Salidas: Ninguna.
// Descripción: Limpia y renderiza la lista de palabras especiales en el panel lateral. Ordena las palabras alfabéticamente y
// actualiza el localStorage. Se utiliza para mostrar las palabras especiales que se pueden agregar o editar.
// ********************************************************************************************************************************
function renderSpecialWordsPanel()
{
const container = document.getElementById("special-words-list");
if (!container)
{
console.warn(
"[PlacesNameNormalizer] No se encontró el contenedor 'special-words-list'.");
return;
}
container.innerHTML = ""; // Limpia el contenedor
// Ordenar las palabras alfabéticamente
const sortedWords = specialWords.sort((a, b) => a.localeCompare(b));
// Crear una lista de palabras
const ul = document.createElement("ul");
ul.style.listStyle = "none";
sortedWords.forEach((word) => {
const li = document.createElement("li");
li.textContent = word;
ul.appendChild(li);
});
container.appendChild(ul);
}
// ********************************************************************************************************************************
// Nombre: addWordsToList
// Fecha modificación: 2025-04-25 05:45 GMT-5
// Autor: mincho77
// Entradas:
// - words (string[]): Palabras a agregar.
// - listType (string): Tipo de lista ("specialWords" o "dictionaryWords").
// Salidas: Ninguna.
// Descripción: Agrega palabras a la lista correspondiente (especiales o del diccionario). Evita duplicados y actualiza el localStorage.
// También renderiza la lista correspondiente en el panel lateral y muestra un mensaje de éxito.
// ********************************************************************************************************************************
function addWordsToList(words, listType)
{
// Determinar la lista correspondiente
let targetList;
if (listType === "specialWords") {
targetList = specialWords;
} else if (listType === "dictionaryWords") {
targetList = dictionaryWords;
} else {
console.error(`Tipo de lista desconocido: ${listType}`);
return;
}
// Agregar palabras a la lista, evitando duplicados
const newWords = words.filter((word) => !targetList.includes(word));
targetList.push(...newWords);
// Guardar en localStorage
localStorage.setItem(listType, JSON.stringify(targetList));
// Renderizar la lista correspondiente
if (listType === "specialWords") {
renderSpecialWordsPanel();
} else if (listType === "dictionaryWords") {
renderDictionaryWordsPanel();
}
// Mostrar mensaje de éxito
showModal({
title: "Éxito",
message: `Se agregaron ${newWords.length} palabra(s) a la lista ${listType}.`,
confirmText: "Aceptar",
type: "success",
autoClose: 1500,
});
}
// ********************************************************************************************************************************
// Nombre: openAddSpecialWordPopup
// Fecha modificación: 2025-04-25 04:56
// Autor: mincho77
// Entradas:
// - name (string): Nombre de la palabra o frase a agregar.
// - listType (string): Tipo de lista ("specialWords" o "excludeWords").
// Salidas: Ninguna.
// Descripción: Abre un modal para agregar palabras especiales o excluidas. Permite seleccionar palabras de una frase y
// agregarlas a la lista correspondiente. Actualiza el localStorage y renderiza la lista en el panel lateral. Muestra mensajes
// de éxito o advertencia según corresponda.
// ********************************************************************************************************************************
function openAddSpecialWordPopup(name, listType = "specialWords")
{
const words = name.split(/\s+/); // Dividir el nombre en palabras
const modal = document.createElement("div");
modal.className = "custom-modal-overlay";
modal.innerHTML = `
<div class="custom-modal">
<div class="custom-modal-header">
<h3>Agregar Palabras ${
listType === "excludeWords" ? "Excluidas" : "Especiales"}</h3>
<button class="close-modal-btn" title="Cerrar">×</button>
</div>
<div class="custom-modal-body">
<p>Selecciona las palabras que deseas agregar como ${
listType === "excludeWords" ? "excluidas" : "especiales"}:</p>
<ul style="list-style: none; padding: 0;">
${
words
.map((word, index) => `
<li>
<label>
<input type="checkbox" class="special-word-checkbox" data-word="${
word}" id="word-${index}">
${word}
</label>
</li>
`)
.join("")}
</ul>
</div>
<div class="custom-modal-footer">
<button id="add-selected-words-btn" class="modal-btn confirm-btn">Agregar</button>
<button id="cancel-add-words-btn" class="modal-btn cancel-btn">Cancelar</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Manejar el cierre del modal
modal.querySelector(".close-modal-btn").addEventListener("click", () => modal.remove());
modal.querySelector("#cancel-add-words-btn").addEventListener("click", () => modal.remove());
// Manejar la acción de agregar palabras seleccionadas
modal.querySelector("#add-selected-words-btn")
.addEventListener("click", () => {
const selectedWords = Array
.from(modal.querySelectorAll(
".special-word-checkbox:checked"))
.map((checkbox) => checkbox.dataset.word);
if (selectedWords.length > 0)
{
selectedWords.forEach((word) => {
if (listType === "excludeWords")
{
if (!excludeWords.includes(word))
{
excludeWords.push(word);
}
}
else
{
addWordsToList([ word ], listType);
}
});
// Guardar en localStorage y actualizar la interfaz
if (listType === "excludeWords")
{
localStorage.setItem("excludeWords",
JSON.stringify(excludeWords));
renderExcludedWordsPanel();
}
else
{
localStorage.setItem("specialWords",
JSON.stringify(specialWords));
renderSpecialWordsPanel();
}
// Mostrar mensaje de éxito con tiempo reducido
showModal({
title : "Éxito",
message :
`Se agregaron ${selectedWords.length} palabra(s) como ${
listType === "excludeWords" ? "excluidas"
: "especiales"}.`,
type : "success",
autoClose : 1000, // Tiempo reducido a 1 segundos
});
}
else
{
// Mostrar mensaje de advertencia si no se seleccionó ninguna
// palabra
showModal({
title : "Advertencia",
message : "No seleccionaste ninguna palabra.",
type : "warning",
autoClose : 1000, // Tiempo reducido a 1 segundos
});
}
modal.remove();
});
}
// ********************************************************************************************************************************
// Nombre: normalizePlaceName
// Fecha modificación: 2025-04-15
// Autor: mincho77
// Entradas:
// - name (string): Nombre del lugar a normalizar.
// - useSpellingAPI (boolean): Indica si se debe usar la API de ortografía.
// Salidas: (string): Nombre normalizado.
// Descripción: Normaliza el nombre del lugar aplicando reglas de capitalización, eliminando artículos innecesarios y corrigiendo
// errores ortográficos. Utiliza la API de LanguageTool para verificar la ortografía y aplicar sugerencias. También maneja números
// romanos y apóstrofes de manera adecuada. La función devuelve el nombre normalizado.
// ********************************************************************************************************************************
async function normalizePlaceName(name, useSpellingAPI = false)
{
if (!name)
return "";
// Obtener el estado del checkbox para usar la API
const useAPI = document.getElementById("useSpellingAPI")?.checked;
const normalizeArticles =
!document.getElementById("normalizeArticles")?.checked;
// Obtener el estado del checkbox para verificar ortografía
const articles =
[ "el", "la", "los", "las", "de", "del", "al", "y", "e" ];
const words = name.trim().split(/\s+/);
// Filtrar palabras excluidas
const isRoman = (word) =>
/^(i{1,3}|iv|v|vi{0,3}|ix|x|xi{0,3}|xiv|xv|xvi{0,3}|xix|xx|xxi{0,3}|xxiv|xxv|xl)$/i
.test(word);
const normalizedWords =
await Promise.all(words.map(async (word, index) => {
const lowerWord = word.normalize("NFD").toLowerCase();
// Si es "Él" o "el", no modificar
if (lowerWord === "él" || lowerWord === "el")
{
return word; // Mantener la palabra tal como está
}
// Reemplazar "SA" por "S.A"
if (lowerWord === "sa")
{
return "S.A";
}
// Reemplazar "SAS" por "S.A.S"
if (lowerWord === "sas")
{
return "S.A.S";
}
// Si es un número, se mantiene igual
if (/^\d+$/.test(word))
return word;
// Si la palabra está en la lista de excluidas, se devuelve tal
// cual
const match = wordLists.excludeWords.find(
(w) => w.normalize("NFD").toLowerCase() === lowerWord);
if (match)
return match;
// Si es un número romano, convertir a mayúsculas
if (isRoman(word))
return word.toUpperCase();
// Si contiene un apóstrofo, no capitalizar la letra siguiente
if (/^[A-Za-z]+'[A-Za-z]/.test(word))
{
return (word.charAt(0).toUpperCase() +
word.slice(1, word.indexOf("'") + 1) +
word.slice(word.indexOf("'") + 1).toLowerCase());
}
// Si no se deben normalizar artículos y es un artículo, mantener en minúsculas
if (!normalizeArticles && articles.includes(lowerWord) && index !== 0)
return lowerWord;
// Si se debe usar la API de ortografía, verificar ortografía
if (useAPI)
{
try
{
const errors = await checkSpellingWithAPI(word);
if (errors.length > 0)
{
const suggestion = errors[0].sugerencia || word;
return suggestion.charAt(0).toUpperCase() + suggestion.slice(1).toLowerCase();
}
}
catch (error)
{
console.error("Error al verificar ortografía:", error);
}
}
// Capitalizar la primera letra de la palabra
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
}));
let newName =
normalizedWords.join(" ")
.replace(/\s*\|\s*/g, " - ")
.replace(/\s*\[P]\s*/g, "") // Reemplaza [P] por un espacio vacío
.replace(/([(["'])\s*([\p{L}])/gu,
(match, p1, p2) => p1 + p2.toUpperCase())
.replace(/\s*-\s*/g, " - ")
.replace(/\b(\d+)([A-Z])\b/g,
(match, num, letter) => num + letter.toUpperCase())
.replace(/\.$/, "")
.replace(/&(\s*)([A-Z])/g,
(match, space, letter) =>
"&" + space + letter.toUpperCase());
// Asegurar que las letras después de un apóstrofo estén en minúscula
newName = newName.replace(/([A-Za-z])'([A-Za-z])/g,
(match, before, after) =>
`${before}'${after.toLowerCase()}`);
// Asegurar que la primera letra después de un guion esté en mayúscula
newName = newName.replace(
/-\s*([a-z])/g, (match, letter) => `- ${letter.toUpperCase()}`);
return newName.replace(/\s{2,}/g, " ").trim();
}
// ********************************************************************************************************************************
// Nombre: init
// Fecha modificación: 2025-04-09
// Autor: mincho77
// Entradas: Ninguna
// Salidas: Ninguna
// Prerrequisitos si existen:
// - El objeto global W debe estar disponible.
// - Deben estar definidas las funciones: initializeExcludeWords,
// createSidebarTab, waitForDOM, renderExcludedWordsPanel y setupDragAndDropImport.
// Descripción: Esta función espera a que el entorno de edición de Waze (WME) esté completamente cargado, verificando que
// existan los objetos necesarios para iniciar el script. Una vez disponible, inicializa la lista de palabras excluidas, crea el
// tab lateral personalizado, y espera a que el DOM del tab esté listo para renderizar el panel de palabras excluidas y activar la
// funcionalidad de arrastrar y soltar para importar palabras. Finalmente, expone globalmente las funciones applyNormalization y
// normalizePlaceName.
// ********************************************************************************************************************************
function init()
{
if (!W || !W.userscripts || !W.model || !W.model.venues)
{
console.log(`[${SCRIPT_NAME}] Esperando que WME esté listo...`);
setTimeout(init, 1000);
return;
}
console.log(`[${SCRIPT_NAME}] Inicializando v${VERSION}`);
initializeExcludeWords();
createSidebarTab();
waitForDOM("#normalizer-tab", () => {
console.log("[init] Sidebar listo");
renderExcludedWordsPanel();
waitForDOM("#dictionary-words-list", (element) => {
console.log("Contenedor del diccionario encontrado:", element);
renderDictionaryWordsPanel();
attachDictionarySearch(); // ✅ Ejecuta el buscador sobre los
// <li>
});
setupDragAndDrop({
dropZoneId : "drop-zone",
onFileProcessed : (words) => {
excludeWords =
[...new Set([...excludeWords, ...words ]) ].sort();
localStorage.setItem("excludeWords",
JSON.stringify(excludeWords));
renderExcludedWordsPanel();
showModal({
title : "Éxito",
message : `Se importaron ${
words
.length} palabras a la lista de palabras especiales.`,
confirmText : "Aceptar",
type : "success",
});
},
type : "excludeWords",
});
setupDragAndDrop({
dropZoneId : "dictionary-drop-zone",
onFileProcessed : (words) => {
const nuevoDiccionario = {};
for (const palabra of words)
{
const letra = palabra.charAt(0).toLowerCase();
if (!nuevoDiccionario[letra])
{
nuevoDiccionario[letra] = [];
}
nuevoDiccionario[letra].push(palabra);
}
for (const letra in nuevoDiccionario)
{
if (!spellDictionaries[activeDictionaryLang][letra])
{
spellDictionaries[activeDictionaryLang][letra] = [];
}
const conjunto = new Set([
...spellDictionaries[activeDictionaryLang][letra],
...nuevoDiccionario[letra]
]);
spellDictionaries[activeDictionaryLang][letra] =
Array.from(conjunto).sort();
}
localStorage.setItem(
`spellDictionaries_${activeDictionaryLang}`,
JSON.stringify(spellDictionaries[activeDictionaryLang]));
dictionaryWords =
Object.values(spellDictionaries[activeDictionaryLang])
.flat()
.sort();
renderDictionaryWordsPanel();
showModal({
title : "Éxito",
message : `Se importaron ${
words.length} palabras al diccionario.`,
confirmText : "Aceptar",
type : "success",
});
},
type : "dictionaryWords"
});
configurarCambioIdiomaDiccionario();
waitForElement("#details-special-words", (detailsElem) => {
const arrow = document.getElementById("arrow");
if (detailsElem && arrow)
{
detailsElem.addEventListener("toggle", function() {
arrow.style.transform =
detailsElem.open ? "rotate(90deg)" : "rotate(0deg)";
});
}
else
{
console.error(
"No se encontró el elemento #details-special-words o #arrow");
}
});
waitForElement("#details-dictionary-words", (detailsElem) => {
const arrow = document.getElementById("arrow-dic");
if (detailsElem && arrow)
{
detailsElem.addEventListener("toggle", function() {
arrow.style.transform =
detailsElem.open ? "rotate(90deg)" : "rotate(0deg)";
});
}
else
{
console.error(
"No se encontró el elemento #details-dictionary-words o #arrow-dic");
}
});
window.applyNormalization = applyNormalization;
window.normalizePlaceName = normalizePlaceName;
if (W && W.model && W.model.venues)
{
W.model.venues.on("zoomchanged", () => {
placesToNormalize = [];
const existingPanel =
document.getElementById("normalizer-floating-panel");
if (existingPanel)
{
existingPanel.remove();
}
console.log(
"Cambio de zoom detectado: Se ha reiniciado la búsqueda de lugares.");
});
}
});
}
// Inicia el script
init();
// --------------------------------------------------------------------
// Fin del script principal
unsafeWindow.normalizePlaceName = normalizePlaceName;
unsafeWindow.applyNormalization = applyNormalization;
window.addEventListener("dragover", e => e.preventDefault());
window.addEventListener("drop", e => e.preventDefault());
})();