您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Normaliza nombres de lugares en Waze Map Editor (WME)
当前为
// ==UserScript== // @name WME Places Name Normalizer // @namespace https://gf.qytechs.cn/en/users/mincho77 // @version 4.0 // @description Normaliza nombres de lugares en Waze Map Editor (WME) // @author mincho77 // @match https://www.waze.com/*editor* // @match https://beta.waze.com/*user/editor* // @grant GM_xmlhttpRequest // @connect api.languagetool.org // @grant unsafeWindow // @license MIT // @run-at document-end // ==/UserScript== /*global W*/ (() => { "use strict"; // Variables globales básicas const SCRIPT_NAME = "PlacesNameNormalizer"; const VERSION = "4.0"; let excludeWords = []; let maxPlaces = 100; let normalizeArticles = true; let placesToNormalize = []; // Declaración global de placesToNormalize // -------------------------------------------------------------------- // Prevención global del comportamiento por defecto en drag & drop // (Evita que se abra el archivo en otra ventana) // Se aplican los eventos de arrastre y suelta a todo el documento. // Se previene el comportamiento por defecto para todos los eventos // de arrastre y suelta, excepto en el drop-zone. // Se establece el efecto de arrastre como "none" para evitar // cualquier efecto visual no deseado. // -------------------------------------------------------------------- ["dragenter", "dragover", "dragleave", "drop"].forEach((evt) => { document.body.addEventListener(evt, (e) => { // Si el evento se origina en (o es descendiente de) #drop-zone, no // se bloquea. if (e.target && e.target.closest && e.target.closest("#drop-zone")) { return; // Permite que el drop-zone maneje el evento. } e.preventDefault(); e.stopPropagation(); if (e.dataTransfer) { e.dataTransfer.dropEffect = "none"; e.dataTransfer.effectAllowed = "none"; } return false; }, { capture : true }); }); // ***************************************************************************************************** // Nombre: showNoPlacesFoundMessage // Fecha modificación: 2025-04-10 // Autor: mincho77 // Entradas: Ninguna // Salidas: Ninguna. Crea un modal que informa al usuario que no se // encontraron lugares que cumplan con los criterios actuales. Descripción: // Muestra un mensaje modal cuando no se encuentran lugares. Este mensaje // incluye un botón para cerrar el modal. Se utiliza para mostrar // información al usuario sobre la falta de lugares encontrados. // Ejemplo de uso: showNoPlacesFoundMessage(); // ***************************************************************************************************** function showNoPlacesFoundMessage() { // Crear el modal const modal = document.createElement("div"); modal.className = "no-places-modal-overlay"; modal.innerHTML = ` <div class="no-places-modal"> <div class="no-places-header"> <h3>⚠️ No se encontraron lugares</h3> </div> <div class="no-places-body"> <p>No se encontraron lugares que cumplan con los criterios actuales.</p> <p>Intenta ajustar los filtros o ampliar el área de búsqueda.</p> </div> <div class="no-places-footer"> <button id="close-no-places-btn" class="no-places-btn">Aceptar</button> </div> </div> `; // Agregar el modal al documento document.body.appendChild(modal); // Manejar el evento de cierre document.getElementById("close-no-places-btn") .addEventListener("click", () => { modal.remove(); }); } // Estilos CSS para el mensaje const noPlacesStyles = ` <style> .no-places-modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.6); display: flex; justify-content: center; align-items: center; z-index: 10000; animation: fadeIn 0.3s ease-in-out; } .no-places-modal { background: #fff; border-radius: 10px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3); width: 90%; max-width: 400px; overflow: hidden; animation: slideIn 0.3s ease-in-out; text-align: center; padding: 20px; } .no-places-header { background: #f39c12; color: white; padding: 15px; font-size: 18px; font-weight: bold; border-radius: 10px 10px 0 0; } .no-places-body { padding: 20px; font-size: 14px; color: #333; } .no-places-footer { padding: 15px; background: #f4f4f4; text-align: center; } .no-places-btn { padding: 10px 20px; border: none; border-radius: 5px; cursor: pointer; font-size: 14px; font-weight: bold; background: #3498db; color: white; transition: background 0.3s, transform 0.2s; } .no-places-btn:hover { background: #2980b9; transform: scale(1.05); } /* Animaciones */ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes slideIn { from { transform: translateY(-20px); } to { transform: translateY(0); } } </style> `; // Insertar los estilos en el documento document.head.insertAdjacentHTML("beforeend", noPlacesStyles); // ***************************************************************************************************** // Nombre: showModal // Fecha modificación: 2025-04-10 // Autor: mincho77 // Entradas: title (string): Título del modal. // message (string): Mensaje a mostrar en el modal. // confirmText (string): Texto del botón de confirmación. // cancelText (string): Texto del botón de cancelación. // onConfirm (function): Función a ejecutar al confirmar. // onCancel (function): Función a ejecutar al cancelar. // type (string): Tipo de modal (info, error, warning, question). // autoClose (number): Tiempo en milisegundos para cerrar // automáticamente. prependText (string): Texto a mostrar antes del // mensaje. // Salidas: Ninguna. Crea un modal en el DOM. // Descripción: // Esta función crea un modal personalizado en el DOM. Permite mostrar // mensajes de información, advertencia, error o pregunta. Incluye botones // de confirmación y cancelación, así como la opción de cerrar // automáticamente después de un tiempo. Se pueden agregar estilos // personalizados y un texto opcional al principio del mensaje. ejemplos: // modal con texto adicional al principio showModal({ title: "Advertencia", // message: "Esta acción podría tener consecuencias.", prependText: "⚠️ // Atención: Esto es importante.", confirmText: "Entendido", cancelText: // "Cancelar", type: "warning" // }); // Modal de confirmación con signo de interrogación // showModal({ // title: "¿Estás seguro?", // message: "Esta acción no se puede deshacer.", // confirmText: "Sí, estoy seguro", // cancelText: "No, cancelar", // type: "question", // isQuestion: true, // onConfirm: () => { console.log("Acción confirmada"); }, // onCancel: () => { console.log("Acción cancelada"); } // }); // Modal de exito que desaparece automaticamente // showModal({ // title: "Éxito", // message: "La operación se completó con éxito.", // confirmText: "Aceptar", // type: "info", // autoClose: 3000, // Cierra automáticamente después de 3 segundos // onConfirm: () => { console.log("Modal cerrado automáticamente"); } // }); // Modal con prependtext // showModal({ // title: "Información", // message: "El proceso {prependText} se completó correctamente.", // prependText: "de importación", // confirmText: "Aceptar", // type: "info", // }); // ***************************************************************************************************** function showModal({ title, message, confirmText, cancelText, onConfirm, onCancel, type = "info", autoClose = null, prependText = "", }) { // Determinar el ícono según el tipo let icon; switch (type) { case "error": icon = "⛔"; // Ícono para error break; case "warning": icon = "⚠️"; // Ícono para advertencia break; case "info": icon = "ℹ️"; // Ícono para información break; case "question": icon = "❓"; // Ícono para preguntas break; case "success": icon = "✅"; // Ícono para éxito break; default: icon = "ℹ️"; // Ícono por defecto break; } // Reemplazar el marcador de posición `{prependText}` en el mensaje const fullMessage = message.replace("{prependText}", prependText); // Crear el modal const modal = document.createElement("div"); modal.className = "custom-modal-overlay"; modal.innerHTML = ` <div class="custom-modal"> <div class="custom-modal-header"> <h3>${icon} ${title}</h3> <button class="close-modal-btn" title="Cerrar">×</button> </div> <div class="custom-modal-body"> <p>${fullMessage}</p> </div> <div class="custom-modal-footer"> ${ cancelText ? `<button id="modal-cancel-btn" class="modal-btn cancel-btn">${ cancelText}</button>` : ""} ${ confirmText ? `<button id="modal-confirm-btn" class="modal-btn confirm-btn">${ confirmText}</button>` : ""} </div> </div> `; // Agregar el modal al documento document.body.appendChild(modal); // Manejar eventos de los botones if (confirmText) { document.getElementById("modal-confirm-btn") .addEventListener("click", () => { if (onConfirm) onConfirm(); modal.remove(); }); } if (cancelText) { document.getElementById("modal-cancel-btn") .addEventListener("click", () => { if (onCancel) onCancel(); modal.remove(); }); } // Cerrar modal al hacer clic en el botón de cerrar modal.querySelector(".close-modal-btn") .addEventListener("click", () => { modal.remove(); }); // Cerrar automáticamente si se especifica autoClose if (autoClose) { setTimeout(() => { modal.remove(); }, autoClose); } } // Estilos CSS mejorados para el modal const modalStyles = ` <style> .custom-modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.6); display: flex; justify-content: center; align-items: center; z-index: 10000; animation: fadeIn 0.3s ease-in-out; } .custom-modal { background: #fff; border-radius: 10px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3); width: 90%; max-width: 400px; overflow: hidden; animation: slideIn 0.3s ease-in-out; } .custom-modal-header { background: #3498db; color: white; padding: 15px; display: flex; justify-content: space-between; align-items: center; } .custom-modal-header h3 { margin: 0; font-size: 18px; } .close-modal-btn { background: none; border: none; color: white; font-size: 20px; cursor: pointer; transition: color 0.3s; } .close-modal-btn:hover { color: #e74c3c; } .custom-modal-body { padding: 20px; font-size: 14px; color: #333; text-align: center; } .custom-modal-footer { display: flex; justify-content: space-between; padding: 15px; background: #f4f4f4; } .modal-btn { padding: 10px 20px; border: none; border-radius: 5px; cursor: pointer; font-size: 14px; font-weight: bold; transition: background 0.3s, transform 0.2s; } .confirm-btn { background: #27ae60; color: white; } .confirm-btn:hover { background: #2ecc71; transform: scale(1.05); } .cancel-btn { background: #e74c3c; color: white; } .cancel-btn:hover { background: #c0392b; transform: scale(1.05); } /* Animaciones */ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes slideIn { from { transform: translateY(-20px); } to { transform: translateY(0); } } </style> `; // Insertar los estilos en el documento document.head.insertAdjacentHTML("beforeend", modalStyles); // ***************************************************************************************************** // Nombre: openEditPopup // Fecha modificación: 2025-04-15 04:55 // Autor: mincho77 // Entradas: index (number): Índice de la palabra a editar en la lista // excludeWords. Salidas: Ninguna. Crea un modal para editar la palabra // seleccionada. Descripción: Muestra un modal para permitir al usuario // editar una palabra en la lista excludeWords. Se valida que la palabra no // esté vacía y que no exista otra igual en la lista. Si la palabra es // válida, se actualiza la lista y se guarda en localStorage. // ***************************************************************************************************** function openEditPopup(index) { const currentWord = excludeWords[index]; if (!currentWord) return; showModal({ title : "Editar palabra", message : `<input type="text" id="editWordInput" value="${ currentWord}" style="width: 95%; padding: 5px; border-radius: 4px; border: 1px solid #ccc;">`, confirmText : "Guardar", cancelText : "Cancelar", type : "question", onConfirm : () => { const newWord = document.getElementById('editWordInput').value.trim(); if (!newWord) { showModal({ title : "Error", message : "La palabra no puede estar vacía.", confirmText : "Aceptar", type : "error" }); return; } if (excludeWords.includes(newWord) && excludeWords[index] !== newWord) { showModal({ title : "Duplicada", message : "Esa palabra ya está en la lista.", confirmText : "Aceptar", type : "warning" }); return; } excludeWords[index] = newWord; localStorage.setItem("excludeWords", JSON.stringify(excludeWords)); renderExcludedWordsPanel(); showModal({ title : "Actualizada", message : "La palabra fue modificada correctamente.", confirmText : "Aceptar", type : "success", autoClose : 3000 }); } }); } // ******************************************************************************************************************************** // Nombre: waitForElement // Fecha modificación: 2025-04-10 // Autor: mincho77 // Entradas: // - selector (string): El selector CSS del elemento que se desea esperar en // el DOM. // - callback (function): Función que se ejecutará una vez que el elemento // se encuentre en el DOM. // - interval (number, opcional): Tiempo en milisegundos entre cada intento // de búsqueda (por defecto: 300ms). // - maxAttempts (number, opcional): Número máximo de intentos antes de // abandonar la búsqueda (por defecto: 20). Salidas: Ninguna. Ejecuta el // callback pasando el elemento encontrado o muestra una advertencia en la // consola si no se encuentra. Prerrequisitos: // - El DOM debe estar cargado. // Descripción: // Esta función espera a que un elemento definido por un selector CSS // aparezca en el DOM. Utiliza un intervalo de tiempo (interval) para // realizar múltiples comprobaciones, hasta un máximo definido // (maxAttempts). Si el elemento se encuentra dentro de esos intentos, se // ejecuta la función callback con el elemento como argumento. Si no se // encuentra después de los intentos máximos, se detiene y se muestra una // advertencia en la consola. Esto es útil para asegurarse de que elementos // dinámicos estén disponibles antes de asignarles event listeners o // manipularlos. // ******************************************************************************************************************************** function waitForElement( selector, callback, interval = 300, maxAttempts = 20) { let attempts = 0; const checkExist = setInterval(() => { const element = document.querySelector(selector); attempts++; if (element) { clearInterval(checkExist); callback(element); } else if (attempts >= maxAttempts) { clearInterval(checkExist); console.warn(`No se encontró el elemento ${ selector} después de ${maxAttempts} intentos.`); } }, interval); } // ******************************************************************************************************************************** // Nombre: safeRedirect // Fecha modificación: 2025-03-31 // Autor: mincho77 // Entradas: // - url (string): La URL a la que se desea redirigir. // Salidas: Ninguna. // Descripción: // Esta función redirige a una URL solo si el dominio es uno de los // permitidos. // ******************************************************************************************************************************** function safeRedirect(url) { const allowedDomains = [ "example.com", "mywebsite.com" ]; // Dominios permitidos try { const parsedUrl = new URL(url); if (allowedDomains.includes(parsedUrl.hostname)) { window.location.href = url; // Redirige solo si el dominio es válido } else { console.error("Redirección no permitida a:", url); } } catch (e) { console.error("URL inválida:", url); } } // ******************************************************************************************************************************** // Nombre: redirectTo // Fecha modificación: 2025-03-31 // Autor: mincho77 // Entradas: path (string): Ruta a la que se desea redirigir. // ******************************************************************************************************************************** function redirectTo(path) { const allowedPaths = [ "/home", "/profile", "/settings" ]; // Rutas permitidas if (allowedPaths.includes(path)) { window.location.pathname = path; } else { console.error("Ruta no permitida:", path); } } // ******************************************************************************************************************************** // Nombre: sanitizeUrl // Fecha modificación: 2025-03-31 // Autor: mincho77 // Entradas: url (string): La URL que se desea sanitizar. // Salidas: string: La URL sanitizada. // ******************************************************************************************************************************** function sanitizeUrl(url) { const div = document.createElement("div"); div.innerText = url; return div.innerHTML; // Escapa caracteres peligrosos } // ******************************************************************************************************************************** // Nombre: waitForDOM // Fecha modificación: 2025-03-31 // Autor: mincho77 // Entradas: // - selector (string): Selector del elemento a esperar. // - callback (function): Función a ejecutar cuando se encuentre el // elemento. // - interval (number, opcional): Intervalo de tiempo entre intentos // (default: 500ms). // - maxAttempts (number, opcional): Número máximo de intentos (default: // 10). Salidas: Ninguna. Descripción: Espera a que un elemento identificado // por el selector exista en el DOM. Si se encuentra antes de llegar al // número máximo de intentos, se ejecuta el callback. // ******************************************************************************************************************************** function waitForDOM(selector, callback, interval = 500, maxAttempts = 10) { let attempts = 0; const checkExist = setInterval(() => { const element = document.querySelector(selector); if (element) { clearInterval(checkExist); callback(element); } else if (attempts >= maxAttempts) { clearInterval(checkExist); console.error(`[${ SCRIPT_NAME}] Error: No se encontraron elementos con selector "${ selector}" después de ${maxAttempts} intentos.`); } attempts++; }, interval); } // ******************************************************************************************************************************** // Nombre: initializeExcludeWords // Fecha modificación: 2025-03-30 // Autor: mincho77 // Entradas: Ninguna // Salidas: Ninguna // Prerrequisitos si existen: // - localStorage debe estar disponible. // Descripción: // Inicializa la lista de palabras excluidas a partir del localStorage, // combinando con las palabras ya cargadas en la variable global // excludeWords y actualizando el almacenamiento local. // ******************************************************************************************************************************** function initializeExcludeWords() { const saved = JSON.parse(localStorage.getItem("excludeWords")) || []; // Se combinan sin duplicados y se ordena const merged = [...new Set([...saved, ...excludeWords ]) ].sort( (a, b) => a.localeCompare(b)); if (JSON.stringify(saved.sort()) !== JSON.stringify(merged)) { localStorage.setItem("excludeWords", JSON.stringify(merged)); console.log(`[initializeExcludeWords] Actualizado: ${ merged.length} palabras.`); } else { console.log("[initializeExcludeWords] Sin cambios en la lista."); } excludeWords = merged; } // ******************************************************************************************************************************** // Nombre: getSidebarHTML // Fecha modificación: 2025-04-09 // Autor: mincho77 // Entradas: Ninguna // Salidas: Retorna un string que contiene el HTML para el panel lateral del // normalizador. Descripción: Esta función construye el HTML que se // inyectará en el panel lateral del script. Se agregó un nuevo bloque para // incluir un dropdown (select) con id "categoryDropdown" que permite // filtrar por categoría de los places. La opción por defecto es // "Categorías". Además, se incluyen controles para manejar el número máximo // de places, palabras excluidas, exportación/importación de la lista de // palabras excluidas, y un área de drag & drop para importar archivos. // Finalmente, se incluye un botón "Scan..." para iniciar el escaneo de // lugares. // ******************************************************************************************************************************** function getSidebarHTML() { return ` <div id="normalizer-tab"> <h4>Places Name Normalizer <span style="font-size:11px;">${ VERSION}</span></h4> <!-- No Normalizar artículos --> <div style="margin-top: 15px;"> <input type="checkbox" id="normalizeArticles" ${ normalizeArticles ? "checked" : ""}> <label for="normalizeArticles">No Normalizar artículos (el, la, los, ...)</label> </div> <!-- Máximo de Places a buscar --> <div style="margin-top: 15px;"> <label>Máximo de Places a buscar: </label> <input type="number" id="maxPlacesInput" value="${ maxPlaces}" min="1" max="800" style="width: 60px;"> </div> <!-- Palabras Especiales con flecha --> <details id="details-special-words" style="margin-top: 15px;"> <summary style="cursor: pointer; font-weight: bold; list-style: none;"> <span id="arrow" style="display: inline-block; transition: transform 0.2s;">▶</span> Palabras Especiales </summary> <div style="margin-top: 10px; display: flex; gap: 5px;"> <input type="text" id="excludeWord" placeholder="Agregar palabra..." style="flex: 1; padding: 5px; border: 1px solid #ccc; border-radius: 4px;"> <button id="addExcludeWord" style="background: #3498db; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;"> Agregar </button> </div> <!-- Campo para buscar y filtrar palabras --> <div style="margin-top: 10px; display: flex; gap: 5px;"> <input type="text" id="searchWord" placeholder="Buscar palabra..." style="flex: 1; padding: 5px; border: 1px solid #ccc; border-radius: 4px;"> <button id="searchExcludeWord" style="background: #27ae60; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;"> Buscar </button> </div> <div id="normalizer-sidebar" style="margin-top: 10px; max-height: 200px; overflow-y: auto;"></div> <button id="exportExcludeWords" style="margin-top: 10px;">Exportar Palabras</button> <button id="importExcludeWordsUnifiedBtn" style="margin-top: 5px;">Importar Lista</button> <input type="file" id="hiddenImportInput" accept=".xml,.txt" style="display: none;"> <div style="margin-top: 5px;"> <input type="checkbox" id="replaceExcludeListCheckbox"> <label for="replaceExcludeListCheckbox">Reemplazar lista actual</label> </div> <!-- Drag & Drop --> <div id="drop-zone" style="border: 2px dashed #ccc; border-radius: 6px; padding: 15px; margin: 15px 0; text-align: center; font-style: italic; color: #555; background-color: #f8f9fa;"> 📂 Arrastra aquí tu archivo .txt o .xml para importar palabras especiales </div> </details> <hr> <!-- Botón Scan --> <button id="scanPlaces">Scan...</button> <script> (function waitForSearchElements() { const searchInput = document.getElementById('searchWord'); const searchButton = document.getElementById('searchExcludeWord'); if (searchInput && searchButton) { // Rotación de flecha const detailsElem = document.getElementById('details-special-words'); const arrow = document.getElementById('arrow'); if (detailsElem && arrow) { detailsElem.addEventListener('toggle', function() { arrow.style.transform = detailsElem.open ? 'rotate(90deg)' : 'rotate(0deg)'; }); } // Evento de búsqueda searchButton.addEventListener('click', function () { const query = searchInput?.value?.toLowerCase()?.trim() || ""; const items = document.querySelectorAll('#normalizer-sidebar div'); items.forEach(item => { const text = item.textContent.toLowerCase(); item.style.display = text.includes(query) ? 'flex' : 'none'; }); }); } else { // Reintenta en 200ms setTimeout(waitForSearchElements, 200); } })(); </script> </div> `; } // ******************************************************************************************************************************** // Nombre: attachEvents // Fecha modificación: 2025-03-30 // Autor: mincho77 // Entradas: Ninguna // Salidas: Ninguna // Prerrequisitos si existen: // - Deben existir en el DOM los elementos con los siguientes IDs: // "normalizeArticles", "maxPlacesInput", "addExcludeWord", "scanPlaces", // "hiddenImportInput", "importExcludeWordsUnifiedBtn" y // "exportExcludeWords". // - Debe existir la función handleImportList y la función scanPlaces. // - Debe estar definida la variable global excludeWords y la función // renderExcludedWordsPanel. Descripción: Esta función adjunta los event // listeners necesarios para gestionar la interacción del usuario con el // panel del normalizador de nombres. Se encargan de: // - Actualizar la opción de normalizar artículos al cambiar el estado del // checkbox. // - Modificar el número máximo de lugares a procesar a través de un input. // - Exportar la lista de palabras excluidas a un archivo XML. // - Añadir nuevas palabras a la lista de palabras excluidas, evitando // duplicados, y actualizar el panel. // - Activar el botón unificado para la importación de palabras excluidas // mediante un input oculto. // - Ejecutar la función de escaneo de lugares al hacer clic en el botón // correspondiente. // ******************************************************************************************************************************** function attachEvents() { console.log(`[${SCRIPT_NAME}] Adjuntando eventos...`); const normalizeArticlesCheckbox = document.getElementById("normalizeArticles"); const maxPlacesInput = document.getElementById("maxPlacesInput"); const addExcludeWordButton = document.getElementById("addExcludeWord"); const scanPlacesButton = document.getElementById("scanPlaces"); const hiddenInput = document.getElementById("hiddenImportInput"); const importButtonUnified = document.getElementById("importExcludeWordsUnifiedBtn"); // Validación de elementos necesarios if (!normalizeArticlesCheckbox || !maxPlacesInput || !addExcludeWordButton || !scanPlacesButton) { console.error( `[${SCRIPT_NAME}] Error: No se encontraron elementos en el DOM.`); return; } // Evento: cambiar estado de "no normalizar artículos" normalizeArticlesCheckbox.addEventListener( "change", (e) => { normalizeArticles = e.target.checked; }); // Evento: cambiar número máximo de places maxPlacesInput.addEventListener( "input", (e) => { maxPlaces = parseInt(e.target.value, 10); }); // Evento: exportar palabras excluidas a XML document.getElementById("exportExcludeWords") .addEventListener("click", () => { const savedWords = JSON.parse(localStorage.getItem("excludeWords")) || []; if (savedWords.length === 0) { showModal({ title : "Error", message : "No hay palabras excluidas para exportar.", confirmText : "Aceptar", onConfirm : () => { console.log("El usuario cerró el modal."); } }); return; } const sortedWords = [...savedWords ].sort((a, b) => a.localeCompare(b)); const xmlContent = `<?xml version="1.0" encoding="UTF-8"?> <ExcludedWords> ${sortedWords.map((word) => ` <word>${word}</word>`).join("\n ")} </ExcludedWords>`; const blob = new Blob([ xmlContent ], { type : "application/xml" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = "excluded_words.xml"; document.body.appendChild(link); link.click(); document.body.removeChild(link); }); // Evento: añadir palabra excluida sin duplicados addExcludeWordButton.addEventListener("click", () => { const wordInput = document.getElementById("excludeWord") || document.getElementById("excludedWord"); const word = wordInput?.value.trim(); if (!word) return; const lowerWord = word.toLowerCase(); const alreadyExists = excludeWords.some((w) => w.toLowerCase() === lowerWord); if (!alreadyExists) { excludeWords.push(word); localStorage.setItem("excludeWords", JSON.stringify(excludeWords)); renderExcludedWordsPanel(); } wordInput.value = ""; }); // Evento: nuevo botón unificado de importación importButtonUnified.addEventListener("click", () => { hiddenInput.click(); }); hiddenInput.addEventListener("change", () => { handleImportList(); }); // Evento: escanear lugares scanPlacesButton.addEventListener("click", scanPlaces); } // ******************************************************************************************************************************** // Nombre: populateCategoryDropdownStatic // Fecha modificación: 2025-04-10 // Autor: mincho77 // Entradas: Ninguna. // Salidas: Ninguna. Actualiza el contenido del dropdown con id // "categoryDropdown" usando una // lista de categorías estáticas. // Prerrequisitos: // - El DOM debe contener un <select id="categoryDropdown">. // Descripción: // Rellena el dropdown con una lista estática de categorías. Primero limpia // el contenido actual y añade la opción "Todos los places", luego recorre // un arreglo de categorías definidas de forma estática y agrega una opción // (<option>) para cada categoría. // ******************************************************************************************************************************** function populateCategoryDropdownStatic() { waitForElement("#categoryDropdown", function(dropdown) { console.log( ">> populateCategoryDropdownStatic: Elemento encontrado:", dropdown); const categoriesStatic = [ { id : "FOOD", name : "Food & Drink" }, { id : "SHOP", name : "Shopping" }, { id : "TRANSPORT", name : "Transportation" }, { id : "LODGING", name : "Lodging" }, { id : "ENTERTAINMENT", name : "Entertainment" }, { id : "HEALTH", name : "Health" }, { id : "EDUCATION", name : "Education" }, { id : "GOVERNMENT", name : "Government" }, { id : "FINANCE", name : "Finance" }, { id : "RESIDENTIAL", name : "Residential" }, { id : "COMMERCIAL", name : "Commercial" } ]; // Limpia el contenido actual dropdown.innerHTML = ""; // Agrega la opción por defecto: "Todas las Categorías" const optAll = document.createElement("option"); optAll.value = "all"; optAll.textContent = "All"; dropdown.appendChild(optAll); // Agrega cada categoría estática categoriesStatic.forEach((cat) => { const option = document.createElement("option"); option.value = cat.id; option.textContent = cat.name; dropdown.appendChild(option); }); console.log(">> Dropdown poblado:", dropdown.innerHTML); for (let prop in W.model) { console.log("W.model prop:", prop, W.model[prop]); } }); } // ******************************************************************************************************************************** // Nombre: populateCategoryDropdown // Fecha modificación: 2025-06-20 14:30 GMT-5 // Autor: mincho77 // Entradas: // - Ninguna (usa el elemento DOM con id "categoryDropdown") // Salidas: Ninguna. Modifica el DOM al poblar el dropdown de categorías. // Prerrequisitos: // - El DOM debe contener un elemento <select> con id "categoryDropdown" // - El objeto global W debe estar disponible (entorno WME) // Descripción: // Esta función llena un dropdown con todas las categorías de lugares // disponibles en WME. Primero intenta obtener las categorías dinámicamente // del modelo WME (W.model.venues.getCategories()). Si falla la carga // dinámica, utiliza una lista estática completa de categorías como // respaldo. Las categorías se ordenan alfabéticamente y se añade siempre // una opción "Todas las categorías" al inicio. La función incluye manejo // robusto de errores y registra mensajes en la consola para diagnóstico. // ******************************************************************************************************************************** function populateCategoryDropdown() { waitForElement("#categoryDropdown", (dropdown) => { if (!dropdown) { console.error("No se encontró el dropdown de categorías"); return; } // Limpiar dropdown existente dropdown.innerHTML = ""; // 1. Añadir opción "Todas" por defecto const defaultOption = document.createElement("option"); defaultOption.value = "all"; defaultOption.textContent = "Todas"; dropdown.appendChild(defaultOption); // 2. Intentar cargar categorías dinámicas de WME try { const categories = W.model.venues.getCategories?.() || []; if (categories.length > 0) { // Ordenar alfabéticamente categories.sort((a, b) => a.name.localeCompare(b.name)); // Agregar al dropdown categories.forEach((cat) => { const option = document.createElement("option"); option.value = cat.id; option.textContent = cat.name; dropdown.appendChild(option); }); console.log( `Categorías cargadas desde WME: ${categories.length}`); return; } } catch (e) { console.error("Error al cargar categorías de WME:", e); } // 3. Respaldo: Categorías estáticas completas const staticCategories = [ { id : "AIRPORT", name : "Aeropuerto" }, { id : "ATM", name : "Cajero automático" }, { id : "BANK", name : "Banco" }, { id : "BAR", name : "Bar" }, { id : "CAFE", name : "Cafetería" }, { id : "CAR_RENTAL", name : "Renta de autos" }, { id : "CAR_REPAIR", name : "Taller mecánico" }, { id : "DELIVERY", name : "Entrega a domicilio" }, { id : "EDUCATION", name : "Educación" }, { id : "EMBASSY", name : "Embajada" }, { id : "EMERGENCY", name : "Emergencia" }, { id : "ENTERTAINMENT", name : "Entretenimiento" }, { id : "FERRY", name : "Ferry" }, { id : "FOOD", name : "Comida" }, { id : "GAS_STATION", name : "Gasolinera" }, { id : "GOVERNMENT", name : "Gobierno" }, { id : "HOSPITAL", name : "Hospital" }, { id : "HOTEL", name : "Hotel" }, { id : "PARKING", name : "Estacionamiento" }, { id : "PHARMACY", name : "Farmacia" }, { id : "POLICE", name : "Policía" }, { id : "POST_OFFICE", name : "Correo" }, { id : "PUBLIC_TRANSPORT", name : "Transporte público" }, { id : "RELIGIOUS", name : "Lugar religioso" }, { id : "RESTAURANT", name : "Restaurante" }, { id : "SHOPPING", name : "Compras" }, { id : "TAXI", name : "Taxi" }, { id : "TOURISM", name : "Turismo" }, { id : "TRAIN_STATION", name : "Estación de tren" }, { id : "UNIVERSITY", name : "Universidad" }, { id : "ZOO", name : "Zoológico" } ]; staticCategories.forEach((cat) => { const option = document.createElement("option"); option.value = cat.id; option.textContent = cat.name; dropdown.appendChild(option); }); console.log("Categorías cargadas desde lista estática"); }); } // ******************************************************************************************************************************** // Nombre: getCategoriaTextoDesdeIDs // Fecha modificación: 2025-04-10 // Autor: mincho77 // Entradas: // - venue (object): Objeto de lugar de WME. // Salidas: string: Texto de la categoría o "Sin categoría" si no se // encuentra. // Descripción: // Esta función toma un objeto de lugar de WME y devuelve un string que // representa la categoría del lugar. Si el lugar no tiene categorías // asignadas, devuelve "Sin categoría". Si se producen errores durante el // proceso, se captura la excepción y se devuelve "Sin categoría". // ******************************************************************************************************************************** function getCategoriaTextoDesdeIDs(ids = []) { try { const allCats = W.model?.categories?.objects; if (!allCats) throw new Error("No se pudo acceder a las categorías"); return ids .map(id => allCats[id]?.name || `ID ${id}`) .join(", "); } catch (err) { console.error("Error al mapear IDs de categoría:", err); return "Sin categoría"; } } // ******************************************************************************************************************************** // Nombre: createSidebarTab // Fecha modificación: 2025-04-09 // Autor: mincho77 // Entradas: Ninguna. // Salidas: Ninguna. // Prerrequisitos si existen: // - La función W.userscripts.registerSidebarTab debe estar disponible en el // entorno WME. Descripción: Crea y registra una nueva pestaña lateral en el // WME para el normalizador. Inyecta el HTML generado por getSidebarHTML() y // espera a que se renderice el DOM para adjuntar los eventos. // ******************************************************************************************************************************** function createSidebarTab() { try { // Check if the sidebar system is ready if (!W || !W.userscripts) { console.error( `[${SCRIPT_NAME}] WME not ready for sidebar creation`); return; } // Check for existing tab and clean up if needed const existingTab = document.getElementById("normalizer-tab"); if (existingTab) { console.log(`[${SCRIPT_NAME}] Removing existing tab...`); existingTab.remove(); } // Register new tab with error handling let registration; try { registration = W.userscripts.registerSidebarTab("PlacesNormalizer"); } catch (e) { if (e.message.includes("already been registered")) { console.warn(`[${ SCRIPT_NAME}] Tab registration conflict, attempting cleanup...`); // Additional cleanup could go here return; } throw e; } const { tabLabel, tabPane } = registration; if (!tabLabel || !tabPane) { throw new Error( "Tab registration failed to return required elements"); } // Configure tab tabLabel.innerHTML = ` <img src="data:image/jpeg;base64,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" style="height: 16px; vertical-align: middle; margin-right: 5px;"> NrmliZer `; tabLabel.title = "Places Name Normalizer"; // Set content and attach events tabPane.innerHTML = getSidebarHTML(); waitForDOM("#normalizer-tab", (element) => { attachEvents(); console.log(`[${SCRIPT_NAME}] Tab created and events attached`); }, 500, 10); } catch (error) { console.error(`[${SCRIPT_NAME}] Error in createSidebarTab:`, error); } } // ***************************************************************************************************** // Nombre: checkSpellingWithAPI // Fecha modificación: 2025-03-30 // Autor: mincho77 // Entradas: text (string) – Texto a evaluar ortográficamente. // Salidas: Promise – Resuelve con lista de errores ortográficos detectados. // Prerrequisitos: Requiere permisos GM_xmlhttpRequest y @connect a // api.languagetool.org Descripción: Consulta la API de LanguageTool para // verificar ortografía del texto. // ***************************************************************************************************** function checkSpellingWithAPI(text) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method : "POST", url : "https://api.languagetool.org/v2/check", headers : { "Content-Type" : "application/x-www-form-urlencoded" }, data : `language=es&text=${encodeURIComponent(text)}`, onload : function(response) { if (response.status === 200) { const result = JSON.parse(response.responseText); const errores = result.matches.map( (match) => ({ palabra : match.context.text.substring( match.context.offset, match.context.offset + match.context.length), sugerencia : match.replacements.length > 0 ? match.replacements[0].value : "(sin sugerencia)" })); resolve(errores); } else { reject("❌ Error en respuesta de LanguageTool"); } }, onerror : function( err) { reject("❌ Error de red al contactar LanguageTool"); } }); }); } window.checkSpellingWithAPI = checkSpellingWithAPI; // ***************************************************************************************************** // Nombre: evaluarOrtografiaCompleta // Fecha modificación: 2025-04-10 22:15 GMT-5 // Autor: mincho77 // Entradas: // - texto (string): Texto a evaluar // - config (opcional): { // usarAPI: true, // Usar LanguageTool // reglasLocales: true, // Aplicar reglas de tildes // timeout: 5000 // Tiempo máximo para API // } // Salidas: // Promise<{ // original: string, // normalizado: string, // errores: Array<{ // palabra: string, // sugerencia: string, // tipo: 'ortografia'|'tilde'|'gramatica', // severidad: 'alta'|'media'|'baja' // }>, // metadata: { // totalErrores: number, // apiUsada: boolean, // tiempoProcesamiento: number // } // }> // Descripción: // Sistema completo que combina normalización y revisión ortográfica real // ***************************************************************************************************** async function evaluarOrtografiaCompleta(texto, config = {}) { const inicio = Date.now(); const resultadoBase = { original : texto, normalizado : "", errores : [], metadata : { totalErrores : 0, apiUsada : false, tiempoProcesamiento : 0 } }; // 1. Normalización básica inicial const normalizado = normalizePlaceName(texto); resultadoBase.normalizado = normalizado; // 2. Detección de errores locales (síncrono) if (config.reglasLocales !== false) { const erroresLocales = detectarErroresLocales(texto, normalizado); resultadoBase.errores.push(...erroresLocales); } // 3. Revisión con API LanguageTool (asíncrono) if (config.usarAPI !== false && texto.length > 1) { try { const resultadoAPI = await revisarConLanguageTool(texto, config.timeout); resultadoBase.errores.push(...resultadoAPI.errores); resultadoBase.metadata.apiUsada = true; } catch (error) { console.error("Error en API LanguageTool:", error); } } // 4. Filtrar y clasificar errores resultadoBase.errores = filtrarErrores(resultadoBase.errores); resultadoBase.metadata.totalErrores = resultadoBase.errores.length; resultadoBase.metadata.tiempoProcesamiento = Date.now() - inicio; return resultadoBase; } // ==================== FUNCIONES DE SOPORTE ==================== // ***************************************************************************************************** // Nombre: detectarErroresLocales // Descripción: Detecta errores de tildes y mayúsculas // ***************************************************************************************************** function detectarErroresLocales(original, normalizado) { const errores = []; const palabrasOriginal = original.split(/\s+/); const palabrasNormalizado = normalizado.split(/\s+/); palabrasOriginal.forEach((palabra, i) => { const palabraNormalizada = palabrasNormalizado[i] || palabra; // 1. Comparación directa para detectar cambios if (palabra !== palabraNormalizada) { errores.push({ palabra, sugerencia : palabraNormalizada, tipo : "ortografia", severidad : "media" }); } // 2. Detección específica de tildes if (tieneTildesIncorrectas(palabra)) { errores.push({ palabra, sugerencia : corregirTildeLocal(palabra), tipo : "tilde", severidad : "alta" }); } }); return errores; } // ***************************************************************************************************** // Nombre: revisarConLanguageTool // Fecha modificación: 2025-04-10 22:15 GMT-5 // Autor: mincho77 // Entradas: // - texto (string): Texto a evaluar // - timeout (opcional): Tiempo máximo para la API (en milisegundos) // Salidas: // Promise<{ // errores: Array<{ // palabra: string, // sugerencia: string, // tipo: 'ortografia'|'gramatica', // severidad: 'alta'|'media' // }>, // apiStatus: // 'success'|'timeout'|'parse_error'|'api_error'|'network_error' // }> // Prerrequisitos: Requiere permisos GM_xmlhttpRequest y @connect a // api.languagetool.org Descripción: Consulta la API para errores // ortográficos y gramaticales // ***************************************************************************************************** // Descripción: Consulta la API para errores avanzados // ***************************************************************************************************** function revisarConLanguageTool(texto, timeout = 5000) { return new Promise((resolve) => { const timer = setTimeout( () => { resolve({ errores : [], apiStatus : "timeout" }); }, timeout); GM_xmlhttpRequest({ method : "POST", url : "https://api.languagetool.org/v2/check", headers : { "Content-Type" : "application/x-www-form-urlencoded" }, data : `language=es&text=${encodeURIComponent(texto)}`, onload : function(response) { clearTimeout(timer); if (response.status === 200) { try { const data = JSON.parse(response.responseText); const errores = data.matches.map( (match) => ({ palabra : match.context.text.substring( match.context.offset, match.context.offset + match.context.length), sugerencia : match.replacements[0]?.value || match.context.text, tipo : match.rule.category.id === "TYPOS" ? "ortografia" : "gramatica", severidad : match.rule.issueType === "misspelling" ? "alta" : "media" })); resolve({ errores, apiStatus : "success" }); } catch (e) { resolve( { errores : [], apiStatus : "parse_error" }); } } else { resolve({ errores : [], apiStatus : "api_error" }); } }, onerror : function() { clearTimeout(timer); resolve({ errores : [], apiStatus : "network_error" }); } }); }); } // ***************************************************************************************************** // Nombre: filtrarErrores // Descripción: Elimina duplicados y errores menores // ***************************************************************************************************** function filtrarErrores(errores) { const unicos = []; const vistas = new Set(); errores.forEach((error) => { const clave = `${error.palabra}-${error.sugerencia}-${error.tipo}`; if (!vistas.has(clave)) { vistas.add(clave); unicos.push(error); } }); return unicos.sort((a, b) => { if (a.severidad === b.severidad) return 0; return a.severidad === "alta" ? -1 : 1; }); } // ***************************************************************************************************** // Nombre: tieneTildesIncorrectas // Fecha modificación: 2025-04-10 21:30 GMT-5 // Autor: mincho77 // Entradas: // - palabra (string): Palabra a evaluar // - config (opcional): { // ignorarMayusculas: true, // considerarAdverbios: true, // considerarMonosílabos: false // } // Salidas: boolean - true si la palabra requiere corrección de tilde // Descripción: // Evalúa si una palabra en español tiene tildes incorrectas según las // reglas RAE. Incluye casos especiales para adverbios, hiatos, diptongos y // monosílabos. // ***************************************************************************************************** function tieneTildesIncorrectas(palabra, config = {}) { if (typeof palabra !== "string" || palabra.length === 0) return false; const settings = { ignorarMayusculas : config.ignorarMayusculas !== false, // No marcar errores en MAYÚSCULAS considerarAdverbios : config.considerarAdverbios !== false, // Evaluar adverbios terminados en -mente considerarMonosílabos : config.considerarMonosílabos || false, // Seguir reglas pre-2010 }; // Normalizar palabra (quitar tildes existentes para evaluación) const palabraNormalizada = palabra.normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .toLowerCase(); const tieneTildeActual = /[áéíóú]/.test(palabra); // 1. Reglas para palabras específicas (excepciones) const reglasEspecificas = { // Adverbios terminados en -mente mente : settings.considerarAdverbios && /mente$/i.test(palabra) ? tieneTildesIncorrectas(palabra.replace(/mente$/i, ""), config) : false, // Monosílabos monosilabos : settings.considerarMonosílabos && [ "fe", "fue", "fui", "vio", "dio", "lia", "lie", "lio", "rion", "ries", "se", "te", "de", "si", "ti" ].includes(palabraNormalizada), // Casos especiales solo : palabraNormalizada === "solo" && !tieneTildeActual, este : /^este(s)?$/i.test(palabraNormalizada) && !tieneTildeActual, aun : palabraNormalizada === "aun" && !tieneTildeActual, guion : palabraNormalizada === "guion" && !tieneTildeActual, hui : palabraNormalizada === "hui" && !tieneTildeActual }; if (Object.values(reglasEspecificas).some((v) => v)) return true; // 2. Reglas generales de acentuación const silabas = separarSilabas(palabraNormalizada); const numSilabas = silabas.length; const ultimaLetra = palabraNormalizada.slice(-1); // Palabras agudas (tildan en última sílaba) if (numSilabas === 1) return false; // Monosílabos ya evaluados const esAguda = numSilabas === 1 || (numSilabas > 1 && silabas[numSilabas - 1].acento); const debeTildarAguda = esAguda && /[nsaeiouáéíóú]$/i.test(palabraNormalizada); const palabraLower = palabra.toLowerCase(); if (correccionesEspecificas[palabraLower]) { return aplicarCapitalizacion(palabra, correccionesEspecificas[palabraLower]); } // Determinar sílaba a tildar if (numSilabas > 2 && esEsdrujula(palabra)) { silabaTildada = numSilabas - 3; } else if (numSilabas > 1 && esGrave(palabra)) { silabaTildada = numSilabas - 2; } else if (esAguda(palabra)) { silabaTildada = numSilabas - 1; } if (silabaTildada >= 0) { return aplicarTildeSilaba(palabra, silabas, silabaTildada); } return palabra; } // ==================== FUNCIONES AUXILIARES ==================== // ***************************************************************************************************** // Nombre: separarSilabas // Fecha modificación: 2025-04-10 22:15 GMT-5 // Autor: mincho77 // Entradas: palabra (string) – Palabra a separar en sílabas. // Salidas: Array<{ texto: string, acento: boolean }> – Lista de sílabas // Descripción: Separa la palabra en sílabas y determina si cada sílaba // tiene acento. Implementación simplificada para propósitos de // normalización visual. // ***************************************************************************************************** function separarSilabas(palabra) { // Implementación simplificada (usar librería completa en producción) const vocalesFuertes = /[aeoáéó]/; const vocalesDebiles = /[iuü]/; const silabas = []; let silabaActual = ""; let tieneVocalFuerte = false; for (let i = 0; i < palabra.length; i++) { const c = palabra[i]; silabaActual += c; if (vocalesFuertes.test(c)) { tieneVocalFuerte = true; } // Lógica simplificada de separación if (i < palabra.length - 1 && ((vocalesFuertes.test(c) && vocalesFuertes.test(palabra[i + 1])) || (vocalesDebiles.test(c) && vocalesFuertes.test(palabra[i + 1]) && !tieneVocalFuerte))) { silabas.push( { texto : silabaActual, acento : tieneVocalFuerte }); silabaActual = ""; tieneVocalFuerte = false; } } if (silabaActual) { silabas.push({ texto : silabaActual, acento : tieneVocalFuerte }); } return silabas; } // ***************************************************************************************************** // Nombre: aplicarCapitalizacion // Fecha modificación: 2025-04-10 22:15 GMT-5 // Autor: mincho77 // Entradas: original (string) – Palabra original // corregida (string) – Palabra corregida // Salidas: string – Palabra corregida con mayúsculas/minúsculas // Descripción: Aplica mayúsculas/minúsculas a la palabra corregida // según la original. Mantiene mayúsculas y minúsculas en la primera letra // y el resto de la palabra. // ***************************************************************************************************** function aplicarCapitalizacion(original, corregida) { if (original === original.toUpperCase()) { return corregida.toUpperCase(); } else if (original[0] === original[0].toUpperCase()) { return corregida[0].toUpperCase() + corregida.slice(1); } return corregida; } // ***************************************************************************************************** // Nombre: aplicarTildeSilaba // Descripción: Aplica tilde a la sílaba especificada // ***************************************************************************************************** function aplicarTildeSilaba(palabra, silabas, indiceSilaba) { let resultado = ""; let posActual = 0; silabas.forEach((silaba, i) => { if (i === indiceSilaba) { const conTilde = silaba.texto.replace( /([aeiou])([^aeiou]*)$/, (match, vocal, resto) => { return ( vocal.normalize("NFD").replace(/[\u0300-\u036f]/g, "") + "́" + resto); }); resultado += conTilde; } else { resultado += silaba.texto; } }); return resultado; } // ***************************************************************************************************** // Nombre: normalizePlaceNameOnly // Fecha modificación: 2025-03-30 // Autor: mincho77 // Entradas: name (string) – Nombre del lugar a normalizar. // Salidas: texto normalizado sin validación ortográfica. // Descripción: // Realiza normalización visual del nombre: capitaliza, ajusta espacios, // formatea guiones, paréntesis, y símbolos. No evalúa ortografía ni // acentos. // ***************************************************************************************************** function normalizePlaceNameOnly(name) { if (!name) return ""; const normalizeArticles = !document.getElementById("normalizeArticles")?.checked; const articles = [ "el", "la", "los", "las", "de", "del", "al", "y" ]; const words = name.trim().split(/\s+/); const isRoman = (word) => /^(i{1,3}|iv|v|vi{0,3}|ix|x|xi{0,3}|xiv|xv|xvi{0,3}|xix|xx|xxi{0,3}|xxiv|xxv)$/i .test(word); const normalizedWords = words.map((word, index) => { const lowerWord = word.toLowerCase(); // Si contiene "&", convertir a mayúsculas if (/^[A-Za-z]&[A-Za-z]$/.test(word)) return word.toUpperCase(); // Verificar si está en la lista de excluidas const matchExcluded = excludeWords.find((w) => w.toLowerCase() === lowerWord); if (matchExcluded) return matchExcluded; // Si es un número romano, convertir a mayúsculas if (isRoman(word)) return word.toUpperCase(); // Si no se deben normalizar artículos y es un artículo, mantener en // minúsculas if (!normalizeArticles && articles.includes(lowerWord) && index !== 0) return lowerWord; // Si es un número o un símbolo como "-", no modificar if (/^\d+$/.test(word) || word === "-") return word; // Verificar ortografía usando la API de LanguageTool return checkSpellingWithAPI(word) .then((errors) => { if (errors.length > 0) { const suggestion = errors[0].sugerencia || word; return (suggestion.charAt(0).toUpperCase() + suggestion.slice(1).toLowerCase()); } return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); }) .catch(() => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()); }); let newName = normalizedWords.join(" ") .replace(/\s*\|\s*/g, " - ") .replace(/([(["'])\s*([\p{L}])/gu, (match, p1, p2) => p1 + p2.toUpperCase()) .replace(/\s*-\s*/g, " - ") .replace(/\b(\d+)([A-Z])\b/g, (match, num, letter) => num + letter.toUpperCase()) .replace(/\.$/, "") .replace(/&(\s*)([A-Z])/g, (match, space, letter) => "&" + space + letter.toUpperCase()); return newName.replace(/\s{2,}/g, " ").trim(); } //************************************************************************** // Nombre: applyNormalization // Fecha modificación: 2025-04-15 // Hora: 13:30:00 // Autor: mincho77 // Entradas: Ninguna directamente (usa el arreglo `changes` ya cargado) // Salidas: Aplica acciones en WME y muestra resultados // Prerrequisitos: `changes` debe contener objetos válidos con `place`, `newName`, y opcionalmente `delete` //************************************************************************** function applyNormalization(changes) { if (!Array.isArray(changes) || changes.length === 0) { showModal({ title: "Información", message: "No hay cambios seleccionados para aplicar", confirmText: "Aceptar", type: "info" }); return; } let lastAttemptedPlace = null; let cambiosRechazados = 0; try { changes.forEach((change) => { lastAttemptedPlace = { name: change.originalName || change.place.attributes?.name || "Sin nombre", id: change.place.getID?.() || "ID no disponible" }; if (change.delete) { const DeleteObject = require("Waze/Action/DeleteObject"); const action = new DeleteObject(change.place); W.model.actionManager.add(action); } else { const UpdateObject = require("Waze/Action/UpdateObject"); const action = new UpdateObject(change.place, { name: change.newName }); W.model.actionManager.add(action); } }); observarErroresDeWME(changes.length, lastAttemptedPlace); W.controller?.setModified?.(true); showModal({ title: "Éxito", message: `${changes.length} cambio(s) enviados. Clic en Guardar para aplicar en WME.`, type: "success", autoClose: 2000 }); } catch (error) { console.error("Error aplicando cambios:", error); showModal({ title: "Error", message: "Error al aplicar cambios. Ver consola para detalles.", confirmText: "Aceptar", type: "error" }); } } // ***************************************************************************************************** // Nombre: evaluarOrtografiaConTildes // Fecha modificación: 2025-04-02 // Autor: mincho77 // Entradas: name (string) - Nombre del lugar // Salidas: objeto con errores detectados // Descripción: // Evalúa palabra por palabra si falta una tilde en las palabras que lo // requieren, según las reglas del español. Primero normaliza el nombre y // luego verifica si las palabras necesitan una tilde. // ***************************************************************************************************** function evaluarOrtografiaConTildes(name) { // Si el nombre está vacío, retornar inmediatamente una promesa resuelta if (!name) { return Promise.resolve( { hasSpellingWarning : false, spellingWarnings : [] }); } const palabras = name.trim().split(/\s+/); const spellingWarnings = []; console.log( `[evaluarOrtografiaConTildes] Verificando ortografía de: ${name}`); palabras.forEach( (palabra, index) => { // Normalizar la palabra antes de cualquier verificación let normalizada = normalizePlaceNameOnly(palabra); // Ignorar palabras con "&" o que sean emoticonos if (/^[A-Za-z]&[A-Za-z]$/.test(normalizada) || /^[\u263a-\u263c\u2764\u1f600-\u1f64f\u1f680-\u1f6ff]+$/.test( normalizada)) { return; // No verificar ortografía } // Excluir palabras específicas como "y" o "Y" if (normalizada.toLowerCase() === "y" || /^\d+$/.test(normalizada) || normalizada === "-") { return; // Ignorar } // Excluir palabras específicas como "e" o "E" if (normalizada.toLowerCase() === "e" || /^\d+$/.test(normalizada) || normalizada === "-") { return; // Ignorar } // Verificar si la palabra está en la lista de excluidas if (excludeWords.some((w) => w.toLowerCase() === normalizada.toLowerCase())) { return; // Ignorar palabra excluida } // Validar que no tenga más de una tilde const cantidadTildes = (normalizada.match(/[áéíóú]/g) || []).length; if (cantidadTildes > 1) { spellingWarnings.push({ original : palabra, sugerida : null, // No hay sugerencia válida tipo : "Error de tildes", posicion : index }); return; } // Verificar ortografía usando la API de LanguageTool checkSpellingWithAPI(normalizada) .then((errores) => { errores.forEach((error) => { spellingWarnings.push({ original : error.palabra, sugerida : error.sugerencia, tipo : "LanguageTool", posicion : index }); }); }) .catch((err) => { console.error( "Error al verificar ortografía con LanguageTool:", err); }); }); return { hasSpellingWarning : spellingWarnings.length > 0, spellingWarnings }; } // ***************************************************************************************************** // Nombre: toggleSpinner // Fecha modificación: 2025-03-31 // Autor: mincho77 // Entradas: // show (boolean) - true para mostrar el spinner, false para ocultarlo // message (string, opcional) - mensaje personalizado a mostrar junto al // spinner Salidas: ninguna (modifica el DOM) Prerrequisitos: debe existir // el estilo CSS del spinner en el documento Descripción: Muestra u oculta // un indicador visual de carga con un mensaje opcional. El spinner usa un // emoji de reloj de arena (⏳) con animación de rotación para indicar que // el proceso está en curso. // ***************************************************************************************************** function toggleSpinner( show, message = "Revisando ortografía...", progress = null) { let existingSpinner = document.querySelector(".spinner-overlay"); if (existingSpinner) { if (show) { // Actualizar el mensaje y el progreso si el spinner ya existe const spinnerMessage = existingSpinner.querySelector(".spinner-message"); spinnerMessage.innerHTML = ` ${message} ${ progress !== null ? `<br><strong>${progress}% completado</strong>` : ""} `; } else { existingSpinner.remove(); // Ocultar el spinner } return; } if (show) { const spinner = document.createElement("div"); spinner.className = "spinner-overlay"; spinner.innerHTML = ` <div class="spinner-content"> <div class="spinner-icon">⏳</div> <div class="spinner-message"> ${message} ${ progress !== null ? `<br><strong>${progress}% completado</strong>` : ""} </div> </div> `; document.body.appendChild(spinner); } } // Agregar los estilos CSS necesarios const spinnerStyles = ` <style> .spinner-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); display: flex; justify-content: center; align-items: center; z-index: 10000; } .spinner-content { background: white; padding: 20px; border-radius: 8px; text-align: center; box-shadow: 0 2px 10px rgba(0,0,0,0.3); } .spinner-icon { font-size: 24px; margin-bottom: 10px; animation: spin 1s linear infinite; /* Aseguramos que la animación esté activa */ display: inline-block; } .spinner-message { color: #333; font-size: 14px; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } </style>`; // Insertar los estilos al inicio del documento document.head.insertAdjacentHTML("beforeend", spinnerStyles); if (!Array.prototype.flat) { Array.prototype.flat = function(depth = 1) { return this.reduce(function(flat, toFlatten) { return flat.concat(Array.isArray(toFlatten) ? toFlatten.flat(depth - 1) : toFlatten); }, []); }; } // ***************************************************************************************************** // Nombre: validarWordSpelling // Fecha modificación: 2025-03-30 // Autor: mincho77 // Entradas: palabra (string) - Palabra en español a validar // ortográficamente Salidas: true si cumple reglas ortográficas básicas, // false si no Descripción: Evalúa si una palabra tiene el uso correcto de // tilde o si le falta una tilde según las reglas del español: esdrújulas // siempre con tilde, agudas con tilde si terminan en n, s o vocal, y llanas // con tildse si NO terminan en n, s o vocal. Se asegura que solo haya una // tilde por palabra. // ***************************************************************************************************** function validarWordSpelling(palabra) { if (!palabra) return false; // Ignorar siglas con formato X&X if (/^[A-Za-z]&[A-Za-z]$/.test(palabra)) return true; // Si la palabra es un número, no necesita validación if (/^\d+$/.test(palabra)) return true; const tieneTilde = /[áéíóú]/.test(palabra); const cantidadTildes = (palabra.match(/[áéíóú]/g) || []).length; if (cantidadTildes > 1) return false; // Solo se permite una tilde const silabas = palabra.normalize("NFD") .replace(/[^aeiouAEIOU\u0300-\u036f]/g, "") .match(/[aeiouáéíóú]+/gi); if (!silabas || silabas.length === 0) return false; const totalSilabas = silabas.length; const ultimaLetra = palabra.slice(-1).toLowerCase(); let tipo = ""; if (totalSilabas >= 3 && /[áéíóú]/.test(palabra)) { tipo = "esdrújula"; } else if (totalSilabas >= 2) { const penultimaSilaba = silabas[totalSilabas - 2]; if (/[áéíóú]/.test(penultimaSilaba)) tipo = "grave"; } if (!tipo) tipo = /[áéíóú]/.test(silabas[totalSilabas - 1]) ? "aguda" : "sin tilde"; if (tipo === "esdrújula") return tieneTilde; if (tipo === "aguda") { return ((/[nsáéíóúaeiou]$/.test(ultimaLetra) && tieneTilde) || (!/[nsáéíóúaeiou]$/.test(ultimaLetra) && !tieneTilde)); } if (tipo === "grave") { return ((!/[nsáéíóúaeiou]$/.test(ultimaLetra) && tieneTilde) || (/[nsáéíóúaeiou]$/.test(ultimaLetra) && !tieneTilde)); } return true; } // ***************************************************************************************************** // Nombre: getCategoriaTexto // Fecha modificación: 2025-04-15 // Autor: mincho77 // Entradas: venue (object) - Objeto de lugar de Waze // Salidas: string - Texto de categoría // Descripción: Obtiene el texto de la categoría del lugar. Si no hay // categoría, devuelve "Sin categoría". Maneja errores y excepciones. // ***************************************************************************************************** function getCategoriaTexto(venue) { try { if (!venue) return "Sin categoría"; const allCategories = venue.getCategories?.(); if (Array.isArray(allCategories) && allCategories.length > 0) { return allCategories.map(cat => cat?.name || "").join(", "); } const mainCategory = venue.getMainCategory?.(); if (mainCategory?.name) { return mainCategory.name; } return "Sin categoría"; } catch (e) { console.warn("Error al obtener categorías:", e); return "Sin categoría"; } } // ***************************************************************************************************** // Nombre: escapeHtml // Fecha modificación: 2025-06-20 18:30 GMT-5 // Autor: mincho77 // Entradas: // - unsafe (string|any): Valor a escapar // Salidas: // - string: Texto escapado seguro para usar en HTML // Prerrequisitos: // - Ninguno // Descripción: // Convierte caracteres especiales en entidades HTML para prevenir XSS. // Escapa los siguientes caracteres: // & → & // < → < // > → > // " → " // ' → ' // 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: getCategoriaTexto // Fecha modificación: 2025-04-15 // Hora: 13:01:25 // Autor: mincho77 // Entradas: venue (object) - Objeto de lugar de Waze // Salidas: string - Texto de categoría // Descripción: Obtiene el texto de la categoría del lugar. Si no hay // categoría, devuelve "Sin categoría". Maneja errores y excepciones. //************************************************************************** function getCategoriaTexto(venue) { try { if (!venue) return "Sin categoría"; const allCategories = venue.getCategories?.(); if (Array.isArray(allCategories) && allCategories.length > 0) { return allCategories.map(cat => cat?.name || "").join(", "); } const mainCategory = venue.getMainCategory?.(); if (mainCategory?.name) { return mainCategory.name; } return "Sin categoría"; } catch (e) { console.warn("Error al obtener categorías:", e); return "Sin categoría"; } } //************************************************************************** // Nombre: openFloatingPanel // Fecha modificación: 2025-04-15 // Hora: 13:01:25 // Autor: mincho77 // Entradas: Arreglo placesToNormalize // Salidas: Panel flotante HTML para normalización // Prerrequisitos: Los objetos place deben incluir un .place con métodos // getCategories y getMainCategory Descripción: Construye un panel // interactivo para visualizar y aplicar normalizaciones y correcciones // ortográficas //************************************************************************** function openFloatingPanel(placesToNormalize) { const panel = document.createElement("div"); panel.id = "normalizer-floating-panel"; panel.style.cssText = ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 90%; max-width: 1200px; max-height: 80vh; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 0 25px rgba(0,0,0,0.4); z-index: 10000; overflow-y: auto; font-family: Arial, sans-serif; `; let html = ` <style> #normalizer-table { width: 100%; border-collapse: collapse; margin: 15px 0; } #normalizer-table th { background: #2c3e50; color: white; padding: 10px; text-align: left; } #normalizer-table td { padding: 8px 10px; border-bottom: 1px solid #eee; } .warning-row { background: #fff8e1; } .normalize-btn, .apply-btn, .add-exclude-btn { padding: 6px 12px; margin: 2px; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; transition: all 0.3s; } .normalize-btn { background: #3498db; color: white; } .apply-btn { background: #2ecc71; color: white; } .add-exclude-btn { background: #e67e22; color: white; } .close-btn { position: absolute; top: 10px; right: 10px; background: #e74c3c; color: white; border: none; width: 30px; height: 30px; border-radius: 50%; font-weight: bold; } .tool-source { font-size: 0.8em; color: #7f8c8d; margin-top: 3px; font-style: italic; } input[type="checkbox"] { transform: scale(1.3); margin: 0 5px; } input[type="text"] { width: 100%; padding: 5px; box-sizing: border-box; } </style> <button class="close-btn" id="close-panel-btn">×</button> <h2 style="color: #2c3e50; margin-top: 5px;">Normalizador de Nombres</h2> <div style="margin: 10px 0; color: #7f8c8d;"> <span id="places-count">${ placesToNormalize.length} lugares para revisar</span> </div> <table id="normalizer-table"> <thead> <tr> <th width="5%">Aplicar</th> <th width="5%">Eliminar</th> <th width="15%">Categoría</th> <th width="25%">Nombre Actual</th> <th width="25%">Nombre Normalizado</th> <th width="15%">Problema Detectado</th> <th width="10%">Acciones</th> </tr> </thead> <tbody>`; placesToNormalize.forEach((place, index) => { const { originalName, newName, hasSpellingWarning, spellingWarnings, place : venue } = place; const category = getCategoriaTextoDesdeIDs(venue); const placeId = venue.getID(); html += ` <tr> <td><input type="checkbox" class="normalize-checkbox" data-index="${ index}" data-type="full"></td> <td><input type="checkbox" class="delete-checkbox" data-index="${ index}"></td> <td title="${escapeHtml(category)}">${ escapeHtml(category)}</td> <td>${escapeHtml(originalName)}</td> <td> <input type="text" class="new-name-input" value="${ escapeHtml(newName)}" data-index="${index}" data-place-id="${ placeId}" data-type="full" data-original="${escapeHtml(originalName)}"> </td> <td>${ originalName !== newName ? "Normalización necesaria" : "-"}</td> <td> <button class="normalize-btn" data-index="${ index}">NrmliZer</button> </td> </tr>`; spellingWarnings.forEach((warning, warningIndex) => { html += ` <tr class="warning-row"> <td><input type="checkbox" class="normalize-checkbox" data-index="${ index}" data-warning-index="${ warningIndex}" data-type="warning"></td> <td></td> <td title="${escapeHtml(category)}">${ escapeHtml(category)}</td> <td>${escapeHtml(warning.original)}</td> <td><input type="text" class="new-name-input" value="${ escapeHtml(warning.sugerida || newName)}" data-index="${index}" data-place-id="${ placeId}" data-warning-index="${ warningIndex}" data-type="warning"></td> <td>${escapeHtml(warning.tipo || "Error ortográfico")} <div class="tool-source">${ warning.origen || "Reglas locales"}</div> </td> <td> <button class="apply-btn" data-index="${ index}" data-warning-index="${warningIndex}">Aplicar</button> <button class="add-exclude-btn" data-word="${ escapeHtml(warning.original)}">Excluir</button> </td> </tr>`; }); }); html += `</tbody></table> <div style="margin-top: 20px; text-align: right;"> <button id="apply-all-btn" style="background: #27ae60; color: white; padding: 10px 20px; border: none; border-radius: 4px; font-weight: bold;">Aplicar Cambios Seleccionados</button> <button id="cancel-btn" style="background: #e74c3c; color: white; padding: 10px 20px; border: none; border-radius: 4px; margin-left: 10px; font-weight: bold;">Cancelar</button> </div>`; panel.innerHTML = html; document.body.appendChild(panel); document.getElementById("close-panel-btn") .addEventListener("click", () => panel.remove()); document.getElementById("cancel-btn") .addEventListener("click", () => panel.remove()); document.getElementById("apply-all-btn") .addEventListener("click", () => { const selectedPlaces = placesToNormalize.filter((place, index) => { const checkbox = panel.querySelector( `.normalize-checkbox[data-index="${index}"]` ); return checkbox && checkbox.checked; // Solo incluir lugares seleccionados }); if (selectedPlaces.length === 0) { showModal({ title: "Advertencia", message: "No se seleccionaron lugares para aplicar cambios.", confirmText: "Aceptar", type: "warning" }); return; } applyNormalization(selectedPlaces); panel.remove(); }); panel.querySelectorAll(".normalize-btn").forEach((btn) => { btn.addEventListener("click", function() { const index = this.dataset.index; const input = panel.querySelector( `.new-name-input[data-index="${index}"][data-type="full"]`); if (input) { input.value = normalizePlaceName(input.value); panel .querySelector(`.normalize-checkbox[data-index="${ index}"][data-type="full"]`) .checked = true; this.textContent = "✓ Listo"; this.style.backgroundColor = "#95a5a6"; this.disabled = true; } }); }); panel.querySelectorAll(".apply-btn").forEach((btn) => { btn.addEventListener("click", function() { const index = this.dataset.index; const warningIndex = this.dataset.warningIndex; const checkbox = panel.querySelector(`.normalize-checkbox[data-index="${ index}"][data-warning-index="${warningIndex}"]`); if (checkbox) { checkbox.checked = true; this.textContent = "✓ Aplicado"; this.style.backgroundColor = "#95a5a6"; this.disabled = true; } }); }); panel.querySelectorAll(".new-name-input").forEach((input) => { input.addEventListener("input", function() { const index = parseInt(this.dataset.index, 10); const original = this.dataset.original?.trim() || ""; const currentValue = this.value.trim(); const checkbox = panel.querySelector(`.normalize-checkbox[data-index="${ index}"][data-type="full"]`); const normalizeButton = panel.querySelector(`.normalize-btn[data-index="${index}"]`); if (!checkbox || !normalizeButton) return; if (currentValue !== original) { checkbox.checked = true; normalizeButton.textContent = "Listo"; normalizeButton.style.backgroundColor = "#95a5a6"; normalizeButton.disabled = true; } else { checkbox.checked = false; normalizeButton.textContent = "NrmliZer"; normalizeButton.style.backgroundColor = "#3498db"; normalizeButton.disabled = false; } }); }); panel.querySelectorAll(".add-exclude-btn").forEach((btn) => { btn.addEventListener("click", function() { const word = this.dataset.word; if (word && !excludeWords.includes(word)) { excludeWords.push(word); localStorage.setItem("excludeWords", JSON.stringify(excludeWords)); renderExcludedWordsPanel(); this.textContent = "✓ Excluida"; this.style.backgroundColor = "#95a5a6"; this.disabled = true; } }); }); } // ***************************************************************************************************** // Nombre: checkOnlyTildes (4) // Fecha modificación: 2025-06-21 // Autor: mincho77 // Entradas: // - original (string): Palabra original a comparar. // - sugerida (string): Palabra sugerida a comparar. // Salidas: // - boolean: // - true si las palabras son iguales excepto por tildes. // - false si difieren en otros caracteres o si alguna es // undefined/null. // Descripción: // Compara dos palabras ignorando tildes/diacríticos para determinar si la // única diferencia entre ellas es la acentuación. Utiliza normalización // Unicode para una comparación precisa. Optimizada para reducir operaciones // innecesarias. // ***************************************************************************************************** function checkOnlyTildes(original, sugerida) { if (typeof original !== "string" || typeof sugerida !== "string") { return false; } if (original === sugerida) { return false; } const normalize = (str) => str.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase(); return normalize(original) === normalize(sugerida); } // ***************************************************************************************************** // Nombre: deleteWord // Fecha modificación: 2025-04-14 // Autor: mincho77 // Entradas: // - index (number): Índice de la palabra a eliminar. // Salidas: Ninguna. Muestra un modal de confirmación. // Descripción: // Muestra un modal de confirmación para eliminar una palabra de la lista de // exclusiones. Si el usuario confirma, elimina la palabra de la lista y // actualiza el almacenamiento local. // ***************************************************************************************************** function deleteWord(index) { const wordToDelete = excludeWords[index]; if (!wordToDelete) return; showModal({ title : "Eliminar palabra", message : `¿Estás seguro de que deseas eliminar la palabra <strong>${ wordToDelete}</strong>?`, confirmText : "Eliminar", cancelText : "Cancelar", type : "question", onConfirm : () => { excludeWords.splice(index, 1); localStorage.setItem("excludeWords", JSON.stringify(excludeWords)); renderExcludedWordsPanel(); showModal({ title : "Eliminada", message : "La palabra fue eliminada correctamente.", confirmText : "Aceptar", type : "success", autoClose : 3000 }); } }); } // ***************************************************************************************************** // Nombre: openDeletePopup // Fecha modificación: 2025-04-14 // Autor: mincho77 // Entradas: // - index (number): Índice de la palabra a eliminar. // Salidas: Ninguna. Muestra un modal de confirmación. // Descripción: // Muestra un modal de confirmación para eliminar una palabra de la lista de // exclusiones. Si el usuario confirma, elimina la palabra de la lista y // actualiza el almacenamiento local. // ***************************************************************************************************** function openDeletePopup(index) { const wordToDelete = excludeWords[index]; if (!wordToDelete) { console.error(`No se encontró la palabra en el índice ${index}`); return; } showModal({ title : "Eliminar palabra", message : `¿Estás seguro de que deseas eliminar la palabra <strong>${ wordToDelete}</strong>?`, confirmText : "Eliminar", cancelText : "Cancelar", type : "warning", onConfirm : () => { excludeWords.splice(index, 1); localStorage.setItem("excludeWords", JSON.stringify(excludeWords)); renderExcludedWordsPanel(); showModal({ title : "Éxito", message : "Palabra eliminada correctamente.", confirmText : "Aceptar", type : "success", autoClose : 3000 }); } }); } // ***************************************************************************************************** // Nombre: evaluarOrtografiaNombre // Fecha modificación: 2025-04-10 20:45 GMT-5 // Autor: mincho77 // Entradas: // - name (string): Texto a evaluar // - opciones (object opcional): { // timeout: 5000, // Tiempo máximo en ms // usarCache: true, // Almacenar resultados temporalmente // modoEstricto: false // Verificar mayúsculas y puntuación // } // Salidas: // Promise que resuelve a { // hasSpellingWarning: boolean, // spellingWarnings: Array<{ // original: string, // sugerida: string, // tipo: string, // origen: 'API'|'Reglas locales', // regla?: string, // contexto?: string // }>, // metadata: { // apiStatus: string, // tiempoRespuesta?: number // } // } // Descripción: // Evalúa ortografía usando API LanguageTool + reglas locales con: // - Validación robusta de entrada // - Timeout configurable // - Cache local de resultados // - Detección de tildes incorrectas // - Manejo completo de errores // Prerrequisitos: // - GM_xmlhttpRequest disponible // - Función tieneTildesIncorrectas() definida // ***************************************************************************************************** function evaluarOrtografiaNombre(name, opciones = {}) { const config = { timeout : opciones.timeout || 5000, usarCache : opciones.usarCache !== false, modoEstricto : opciones.modoEstricto || false }; // Cache simple (evita llamadas duplicadas durante la sesión) const cache = evaluarOrtografiaNombre.cache || (evaluarOrtografiaNombre.cache = new Map()); const cacheKey = `${config.modoEstricto}-${name}`; if (config.usarCache && cache.has(cacheKey)) { return Promise.resolve(cache.get(cacheKey)); } return new Promise((resolve) => { // 1. Validación de entrada if (typeof name !== "string" || name.trim().length === 0) { const resultado = { hasSpellingWarning : false, spellingWarnings : [], metadata : { apiStatus : "invalid_input" } }; cache.set(cacheKey, resultado); return resolve(resultado); } const inicio = Date.now(); let timeoutExcedido = false; // 2. Timeout de seguridad const timeoutId = setTimeout(() => { timeoutExcedido = true; const resultado = { hasSpellingWarning : false, spellingWarnings : [], metadata : { apiStatus : "timeout", tiempoRespuesta : Date.now() - inicio } }; cache.set(cacheKey, resultado); resolve(resultado); }, config.timeout); // 3. Primero verificar reglas locales (sincrónicas) const problemasLocales = []; const palabras = name.split(/\s+/); palabras.forEach((palabra) => { if (tieneTildesIncorrectas(palabra)) { problemasLocales.push({ original : palabra, sugerida : corregirTildeLocal(palabra), tipo : "Tilde incorrecta", origen : "Reglas locales" }); } }); // 4. Si hay problemas locales y no es modo estricto, devolver // inmediato if (problemasLocales.length > 0 && !config.modoEstricto) { clearTimeout(timeoutId); const resultado = { hasSpellingWarning : true, spellingWarnings : problemasLocales, metadata : { apiStatus : "local_rules_applied" } }; cache.set(cacheKey, resultado); return resolve(resultado); } // 5. Consultar API LanguageTool GM_xmlhttpRequest({ method : "POST", url : "https://api.languagetool.org/v2/check", headers : { "Content-Type" : "application/x-www-form-urlencoded", Accept : "application/json" }, data : `language=es&text=${encodeURIComponent(name)}`, onload : (response) => { if (timeoutExcedido) return; clearTimeout(timeoutId); const tiempoRespuesta = Date.now() - inicio; let resultado; try { if (response.status === 200) { const data = JSON.parse(response.responseText); const problemasAPI = data.matches.map( (match) => ({ original : match.context.text.substring( match.context.offset, match.context.offset + match.context.length), sugerida : match.replacements[0]?.value || match.context.text, tipo : match.rule.category.name || "Ortografía", origen : "API", regla : match.rule.id, contexto : match.context.text })); // Combinar resultados locales y de API const todosProblemas = [...problemasLocales, ...problemasAPI ]; resultado = { hasSpellingWarning : todosProblemas.length > 0, spellingWarnings : todosProblemas, metadata : { apiStatus : "success", tiempoRespuesta, totalErrores : todosProblemas.length } }; } else { resultado = { hasSpellingWarning : problemasLocales.length > 0, spellingWarnings : problemasLocales, metadata : { apiStatus : `api_error_${response.status}`, tiempoRespuesta } }; } } catch (error) { resultado = { hasSpellingWarning : problemasLocales.length > 0, spellingWarnings : problemasLocales, metadata : { apiStatus : "parse_error", tiempoRespuesta } }; } cache.set(cacheKey, resultado); resolve(resultado); }, onerror : () => { if (timeoutExcedido) return; clearTimeout(timeoutId); const resultado = { hasSpellingWarning : problemasLocales.length > 0, spellingWarnings : problemasLocales, metadata : { apiStatus : "network_error", tiempoRespuesta : Date.now() - inicio } }; cache.set(cacheKey, resultado); resolve(resultado); } }); }); } // Funciones auxiliares requeridas // ***************************************************************************************************** // Nombre: corregirTildeLocal // Fecha modificación: 2025-04-10 20:45 GMT-5 // Autor: mincho77 // Entradas: // - palabra (string): Palabra a corregir // Salidas: (string): Palabra corregida o la original si no hay corrección. // Descripción: Esta función corrige las tildes de palabras específicas en // español. Se basa en un objeto de correcciones predefinido. Si la palabra // no está en el objeto, se devuelve la palabra original. // ***************************************************************************************************** function corregirTildeLocal(palabra) { const correcciones = { solo : "sólo", aun : "aún", // ... otras correcciones }; return correcciones[palabra.toLowerCase()] || palabra; } // ***************************************************************************************************** // Nombre: populateCategoryDropdownFromWaze // Fecha modificación: 2025-04-09 // Autor: mincho77 // Entradas: Ninguna. Se asume que W.model.venues.getCategories() está // disponible y devuelve un arreglo // de objetos de categoría (cada objeto debe tener al menos las // propiedades "id" y "name"). // Salidas: Ninguna. Actualiza el contenido del dropdown con id // "categoryDropdown" agregando las opciones correspondientes a // cada categoría encontrada. // Prerrequisitos si existen: // - W.model.venues.getCategories() debe estar definido y devolver un // arreglo válido. // - El DOM debe contener un elemento <select> con id "categoryDropdown". // Descripción: // La función limpia el contenido actual del elemento dropdown y crea una // opción por defecto ("Categorías"). Luego, llama a // W.model.venues.getCategories() para obtener las categorías disponibles en // el modelo de WME. Por cada categoría en el arreglo, crea una opción // (<option>) asignándole como valor (value) la propiedad "id" y como texto // (textContent) la propiedad "name". Finalmente, agrega cada opción al // dropdown. Esto permite que el usuario seleccione la categoría por la que // desea filtrar los places. // ***************************************************************************************************** function populateCategoryDropdownFromWaze() { const dropdown = document.getElementById("categoryDropdown"); if (!dropdown) return; dropdown.innerHTML = ""; const optAll = document.createElement("option"); optAll.value = "all"; optAll.textContent = "All"; dropdown.appendChild(optAll); // Obtener categorías mediante getCategories() const catData = W.model.venues.getCategories && W.model.venues.getCategories(); console.log("getCategories():", catData); if (Array.isArray(catData)) { // catData debe ser un arreglo de objetos {id, name, ...} catData.forEach((catObj) => { const option = document.createElement("option"); option.value = catObj.id; option.textContent = catObj.name; dropdown.appendChild(option); }); } else { console.warn("No se encontraron categorías usando getCategories()"); } } // ***************************************************************************************************** // Nombre: populateCategoryDropdownWithSubcategories // Fecha modificación: 2025-04-08 // Autor: mincho77 // Entradas: Ninguna. Se asume que existe el objeto global // W.model.categories con las propiedades // "groups" y "items", y que en el DOM hay un elemento <select> con // id "categoryDropdown". // Salidas: Ninguna. La función actualiza el contenido del dropdown con id // "categoryDropdown", // agregando una opción por defecto ("Categorías"), las categorías // principales y sus subcategorías de forma jerárquica. // Prerrequisitos si existen: // - W.model.categories debe estar definido y contener "groups" (categorías // principales) y "items" (subcategorías). // - El DOM debe incluir un elemento <select> con id "categoryDropdown". // Descripción: // Esta función rellena el dropdown con las categorías y subcategorías // disponibles en la aplicación WME. Primero, limpia el contenido actual del // dropdown y agrega la opción por defecto "Todos los places". Luego, // recorre cada categoría principal en W.model.categories.groups y agrega // una opción para dicha categoría, marcándola visualmente como grupo (por // ejemplo, con un prefijo "[+ Grupo]"). A continuación, para cada grupo, si // existen subcategorías (la propiedad subCategories es un arreglo), recorre // cada subcategoría y crea una opción adicional para cada una, con un // indentado visual (por ejemplo, " └ ") que indique su jerarquía. Esto // permite al usuario seleccionar la categoría específica por la que desea // filtrar los places. // ***************************************************************************************************** function populateCategoryDropdownWithSubcategories() { const dropdown = document.getElementById("categoryDropdown"); if (!dropdown) return; // Limpiar el contenido actual del dropdown. dropdown.innerHTML = ""; // Agregar la opción por defecto: "Categorías". const optAll = document.createElement("option"); optAll.value = "all"; optAll.textContent = "All"; dropdown.appendChild(optAll); // Obtener las categorías principales del modelo de WME. const groups = W.model.categories.groups; for (const groupKey in groups) { const groupData = groups[groupKey]; // Agregar la opción de la categoría principal. const optionGroup = document.createElement("option"); optionGroup.value = groupKey; // Puedes usar groupData.name si se requiere optionGroup.textContent = `[+ Grupo] ${groupData.name}`; dropdown.appendChild(optionGroup); // Si existen subcategorías para este grupo, agregarlas con un // indentado. if (groupData.subCategories && groupData.subCategories.length > 0) { groupData.subCategories.forEach((subCatKey) => { const subCatData = W.model.categories.items[subCatKey]; if (!subCatData) return; // Seguridad en caso de datos faltantes. const optionSub = document.createElement("option"); optionSub.value = subCatKey; // Se agrega un prefijo de indentado para indicar la // jerarquía. optionSub.textContent = ` └ ${subCatData.name}`; dropdown.appendChild(optionSub); }); } } } // ***************************************************************************************************** // Nombre: scanPlaces // Fecha modificación: 2025-04-10 18:30 GMT-5 // Autor: mincho77 // Entradas: Ninguna (usa elementos del DOM y el modelo WME) // Salidas: Ninguna. Ejecuta el escaneo de lugares y muestra resultados. // Prerrequisitos: // - El dropdown de categorías debe estar inicializado // - El modelo WME debe estar cargado (W.model.venues) // - Deben existir las funciones: normalizePlaceName, // evaluarOrtografiaNombre, // openFloatingPanel, toggleSpinner // Descripción: // Escanea los lugares visibles en el WME, filtrando por categoría // seleccionada. Normaliza nombres, revisa ortografía usando API // LanguageTool y reglas locales, y muestra resultados en panel flotante. // Incluye: // - Spinner de carga con progreso // - Manejo robusto de errores // - Validación de tildes según reglas del español // - Procesamiento por lotes asíncrono // ***************************************************************************************************** function scanPlaces() { const selectedCategory = document.getElementById("categoryDropdown")?.value || "all"; const maxPlaces = parseInt(document.getElementById("maxPlacesInput")?.value || 100, 10); if (!W?.model?.venues?.objects) { console.error("Modelo WME no disponible"); return; } // Convertir a string para comparación consistente const selectedCategoryStr = String(selectedCategory); const allPlaces = Object.values(W.model.venues.objects) .filter((place) => { if (!place?.attributes?.name) return false; // Comparación de categoría mejorada if (selectedCategoryStr !== "all") { const placeCategory = String(place.attributes.category || ""); return placeCategory === selectedCategoryStr; } return true; }) .slice(0, maxPlaces); if (allPlaces.length === 0) { toggleSpinner(false); showNoPlacesFoundMessage(); // Mostrar el mensaje mejorado return; } // 6. Procesamiento asíncrono con progreso let processedCount = 0; const placesToNormalize = []; const processBatch = async (index) => { if (index >= allPlaces.length) { toggleSpinner(false); if (placesToNormalize.length > 0) { openFloatingPanel(placesToNormalize); } else { showModal({ title : "Advertencia", message : "No se encontraron lugares que requieran normalización.", confirmText : "Entendido", type : "warning" }); } return; } const place = allPlaces[index]; try { const originalName = place.attributes.name; const normalizedName = normalizePlaceName(originalName); // Actualizar progreso processedCount++; toggleSpinner( true, `Procesando lugares... (${processedCount}/${ allPlaces.length})`, Math.round((processedCount / allPlaces.length) * 100)); // Evaluar ortografía (usando el modo seleccionado) const ortografia = checkOnlyTildes ? await evaluarOrtografiaConTildes(normalizedName) : await evaluarOrtografiaNombre(normalizedName); if (ortografia.hasSpellingWarning || originalName !== normalizedName) { placesToNormalize.push({ id : place.getID(), originalName, newName : normalizedName, category : place.attributes.category || "Sin categoría", hasSpellingWarning : ortografia.hasSpellingWarning, spellingWarnings : ortografia.spellingWarnings, place }); } // Procesar siguiente lugar con pequeño retardo para no bloquear // UI setTimeout(() => processBatch(index + 1), 50); } catch (error) { console.error(`Error procesando lugar ${place.getID()}:`, error); // Continuar con el siguiente lugar a pesar del error setTimeout(() => processBatch(index + 1), 50); } }; // Iniciar procesamiento por lotes processBatch(0); } // ***************************************************************************************************** // Nombre: renderExcludedWordsPanel // Fecha modificación: 2025-03-30 // Autor: mincho77 // Entradas: Ninguna (usa la variable global excludeWords). // Salidas: Ninguna. // Descripción: // Limpia y renderiza la lista de palabras excluidas en el panel lateral. // Ordena las palabras alfabéticamente y actualiza el localStorage. // ***************************************************************************************************** function renderExcludedWordsPanel() { // 1. Obtener el contenedor del panel const container = document.getElementById("normalizer-sidebar"); if (!container) { console.warn(`[${ SCRIPT_NAME}] No se encontró el contenedor "normalizer-sidebar".`); return; } // 2. Limpiar el contenido del contenedor container.innerHTML = ""; // 3. Crear el título "Palabras Especiales" // const title = document.createElement("h4"); // title.textContent = "Palabras Especiales"; // title.style.marginBottom = "10px"; // 4. Crear la sección de palabras excluidas const excludeListSection = document.createElement("div"); excludeListSection.style.marginTop = "10px"; excludeListSection.innerHTML = ` <div style="max-height: 150px; overflow-y: auto; border: 1px solid #ccc; padding: 8px; font-size: 13px; border-radius: 4px;"> <ul style="margin: 0; padding-left: 0; list-style: none;" id="excludeWordsList"></ul> </div> `; // 5. Agregar los elementos al contenedor en el orden deseado // container.appendChild(title); // "Palabras Especiales" container.appendChild(excludeListSection); // Lista de palabras excluidas // 6. Función para renderizar la lista de palabras const renderList = () => { const list = document.getElementById("excludeWordsList"); if (!list) return; // Ordenar las palabras alfabéticamente const sortedWords = excludeWords.sort((a, b) => a.localeCompare(b)); // Actualizar el contenido de la lista list.innerHTML = sortedWords .map( (word, index) => ` <li style="display: flex; justify-content: space-between; align-items: center; padding: 5px 0;"> <span>${word}</span> <div style="display: flex; gap: 10px;"> <span class="edit-word-icon" data-index="${ index}" style="cursor: pointer; color: #3498db;" title="Editar">✏️</span> <span class="delete-word-icon" data-index="${ index}" style="cursor: pointer; color: #e74c3c;" title="Eliminar">🗑️</span> </div> </li> `).join(""); // Agregar eventos para los íconos de edición y eliminación list.querySelectorAll(".edit-word-icon").forEach((icon) => { icon.addEventListener("click", (e) => { const index = parseInt( e.target.closest(".edit-word-icon").dataset.index, 10); openEditPopup(index); }); }); list.querySelectorAll(".delete-word-icon").forEach(icon => { icon.addEventListener("click", (e) => { const index = parseInt( e.target.closest(".delete-word-icon").dataset.index, 10); openDeletePopup(index); }); }); }; // Renderizar la lista inicial renderList(); // 7. Agregar funcionalidad de búsqueda en tiempo real const searchInput = document.getElementById("searchWord"); if (searchInput) { searchInput.addEventListener("input", () => { const query = searchInput.value.toLowerCase().trim(); const items = document.querySelectorAll("#excludeWordsList li"); if (!items.length) { console.warn( "No se encontraron elementos en la lista de palabras excluidas."); return; } items.forEach((item) => { const text = item.querySelector("span")?.textContent.toLowerCase() || ""; item.style.display = text.includes(query) ? "flex" : "none"; }); }); } } // ***************************************************************************************************** // Nombre: setupDragAndDropImport // Fecha modificación: 2025-03-31 // Autor: mincho77 // Entradas: Ninguna. // Salidas: Ninguna. // Descripción: // Activa la funcionalidad de drag & drop sobre el elemento con id // "drop-zone" para importar un archivo con palabras excluidas. Procesa // archivos .xml y .txt. // ***************************************************************************************************** function setupDragAndDropImport() { const dropZone = document.getElementById("drop-zone"); if (!dropZone) { console.warn( "setupDragAndDropImport: No se encontró el elemento #drop-zone"); return; } dropZone.addEventListener("dragover", (e) => { e.preventDefault(); dropZone.style.borderColor = "#4CAF50"; dropZone.style.backgroundColor = "#f0fff0"; console.log("dragover detectado"); }); dropZone.addEventListener("dragleave", (e) => { dropZone.style.borderColor = "#ccc"; dropZone.style.backgroundColor = ""; console.log("dragleave detectado"); }); dropZone.addEventListener("drop", (e) => { e.preventDefault(); dropZone.style.borderColor = "#ccc"; dropZone.style.backgroundColor = ""; console.log("drop detectado"); const file = e.dataTransfer.files[0]; if (!file) { console.log("No se detectó ningún archivo"); return; } console.log("Archivo soltado:", file.name); const reader = new FileReader(); reader.onload = function(event) { console.log("Contenido del archivo:", event.target.result); let palabras = []; if (file.name.endsWith(".xml")) { const parser = new DOMParser(); const xml = parser.parseFromString(event.target.result, "text/xml"); const nodes = xml.querySelectorAll( "word, palabra, item, excluded, exclude"); palabras = Array.from(nodes) .map((n) => n.textContent.trim()) .filter((p) => p.length > 0); } else { palabras = event.target.result.split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line.length > 0); } if (palabras.length === 0) { // alert("⚠️ No se encontraron palabras válidas."); showModal({ title : "Advertencia", message : "No se encontraron palabras válidas.", type : "warning", autoClose : 3000, // El modal desaparecerá después de 3 segundos }); return; } const replace = document.getElementById("replaceExcludeListCheckbox"); if (replace && replace.checked) { excludeWords = []; localStorage.removeItem("excludeWords"); } else { excludeWords = JSON.parse(localStorage.getItem("excludeWords")) || []; } excludeWords = [...new Set([...excludeWords, ...palabras ]) ] .filter((w) => w.trim().length > 0) .sort((a, b) => a.localeCompare(b)); localStorage.setItem("excludeWords", JSON.stringify(excludeWords)); renderExcludedWordsPanel(); showModal({ title : "Información", message : "Se importaron {prependText} palabras desde el archivo.", prependText : palabras.length, confirmText : "Aceptar", type : "info" }); // alert(`✅ Se importaron ${palabras.length} palabras desde el // archivo.`); }; reader.readAsText(file); }); } // ***************************************************************************************************** // Nombre: handleImportList // Fecha modificación: 2025-03-30 // Autor: mincho77 // Entradas: Ninguna (depende del input file "importListInput" y checkbox // "replaceExcludeListCheckbox"). Salidas: Ninguna. Descripción: Lee un // archivo seleccionado por el usuario, procesa sus líneas para extraer // palabras válidas, y actualiza la lista de palabras excluidas // (localStorage y panel). // ***************************************************************************************************** function handleImportList() { const fileInput = document.getElementById("importListInput"); const replaceCheckbox = document.getElementById("replaceExcludeListCheckbox"); if (!fileInput || !fileInput.files || fileInput.files.length === 0) { // alert("No se seleccionó ningún archivo."); showModal({ title : "Inoformación", message : "No se seleccionó ningun archivo.", confirmText : "Aceptar", type : "info" }); return; } const reader = new FileReader(); reader.onload = function(event) { const rawLines = event.target.result.split(/\r?\n/); const lines = rawLines .map((line) => line.replace(/[^\p{L}\p{N}().\s-]/gu, "").trim()) .filter((line) => line.length > 0); const eliminadas = rawLines.length - lines.length; if (eliminadas > 0) { console.warn(`[handleImportList] Se ignoraron ${ eliminadas} líneas inválidas.`); } if (replaceCheckbox && replaceCheckbox.checked) { excludeWords = []; } else { excludeWords = JSON.parse(localStorage.getItem("excludeWords")) || excludeWords || []; } excludeWords = [...new Set([...excludeWords, ...lines ]) ] .filter((w) => w.trim().length > 0) .sort((a, b) => a.localeCompare(b)); localStorage.setItem("excludeWords", JSON.stringify(excludeWords)); renderExcludedWordsPanel(); setupDragAndDropImport(); showModal({ title : "Éxito", message : "Palabras excluidas importadas correctamente: {prependText}.", prependText : excludeWords.length, type : "info", autoClose : 2000, // El modal desaparecerá después de 3 segundos }); // alert(`✅ Palabras excluidas importadas correctamente: ${ // excludeWords.length}`); fileInput.value = ""; }; reader.readAsText(fileInput.files[0]); } // ***************************************************************************************************** // Nombre: isSimilar // Fecha modificación: 2025-03-30 // Autor: mincho77 // Entradas: // - a (string): Primera palabra a comparar. // - b (string): Segunda palabra a comparar. // Salidas: // - boolean: Retorna true si las palabras son consideradas similares de // forma leve; de lo contrario, retorna false. Prerrequisitos si existen: // Ninguno. Descripción: Esta función evalúa la similitud leve entre dos // palabras. Primero, verifica si ambas palabras son idénticas, en cuyo caso // retorna true. Luego, comprueba si la diferencia en la cantidad de // caracteres entre ambas es mayor a 2; si es así, retorna false. // Posteriormente, compara carácter por carácter hasta el largo mínimo de // las palabras, contando las diferencias. Si el número de discrepancias // excede 2, se considera que las palabras no son similares y retorna false; // en caso contrario, retorna true. // ***************************************************************************************************** function isSimilar(a, b) { if (a === b) return true; if (Math.abs(a.length - b.length) > 2) return false; let mismatches = 0; for (let i = 0; i < Math.min(a.length, b.length); i++) { if (a[i] !== b[i]) mismatches++; if (mismatches > 2) return false; } return true; } // ***************************************************************************************************** // Nombre: NameChangeAction // Fecha modificación: 2025-03-30 // Autor: mincho77 // Entradas: // - venue (object): Objeto Place que contiene la información del lugar a // modificar. // - oldName (string): Nombre actual del lugar. // - newName (string): Nuevo nombre sugerido para el lugar. // Salidas: Ninguna (función constructora que crea un objeto de acción). // Prerrequisitos si existen: // - El objeto venue debe contar con la propiedad attributes, y dentro de // ésta, con el campo id. Descripción: Esta función constructora crea un // objeto que representa la acción de cambio de nombre de un Place en el // Waze Map Editor (WME). Asigna las propiedades correspondientes: // - Guarda el objeto venue, el nombre original (oldName) y el nuevo nombre // (newName). // - Extrae y guarda el ID único del lugar desde venue.attributes.id. // - Establece metadatos para identificar la acción, asignando el tipo // "NameChangeAction" // y marcando isGeometryEdit como false para indicar que no se trata de // una edición de geometría. // Estos metadatos pueden ser utilizados por WME y otros plugins para // gestionar y mostrar la acción. // ***************************************************************************************************** function NameChangeAction(venue, oldName, newName) { this.venue = venue; this.oldName = oldName; this.newName = newName; this.venueId = venue.attributes.id; this.type = "NameChangeAction"; this.isGeometryEdit = false; } // ***************************************************************************************************** // Nombre: cleanupEventListeners // Fecha modificación: 2025-03-30 // Autor: mincho77 // Entradas: Ninguna // Salidas: Ninguna // Prerrequisitos si existen: // - Debe existir un elemento en el DOM con el id // "normalizer-floating-panel". Descripción: Esta función elimina los event // listeners asociados al panel flotante de normalización. Lo hace clonando // el nodo del panel y reemplazándolo en el DOM, lo que remueve todos los // event listeners asignados a ese nodo, evitando posibles fugas de memoria // o comportamientos inesperados. // ***************************************************************************************************** function cleanupEventListeners() { const panel = document.getElementById("normalizer-floating-panel"); if (panel) { const clone = panel.cloneNode(true); panel.parentNode.replaceChild(clone, panel); } } // ***************************************************************************************************** // Nombre: normalizePlaceName // Fecha modificación: 2025-04-14 11:45 GMT-5 // Autor: mincho77 // Entradas: // - name (string): El nombre original del lugar. // Salidas: // - string: Nombre normalizado. // Descripción: // Normaliza el nombre del lugar aplicando capitalización, manejo de // artículos, y ajustes de espacios y símbolos. Respeta la configuración del // checkbox "normalizeArticles" y la lista de palabras excluidas. Además, no // capitaliza letras después de un apóstrofo. // ***************************************************************************************************** function normalizePlaceName(name) { if (!name) return ""; const normalizeArticles = !document.getElementById("normalizeArticles")?.checked; const articles = [ "el", "la", "los", "las", "de", "del", "al", "y", "e" ]; const words = name.trim().split(/\s+/); const isRoman = (word) => /^(i{1,3}|iv|v|vi{0,3}|ix|x|xi{0,3}|xiv|xv|xvi{0,3}|xix|xx|xxi{0,3}|xxiv|xxv|xl)$/i .test(word); const normalizedWords = words.map((word, index) => { const lowerWord = word.normalize("NFD").toLowerCase(); // Si es un número, se mantiene igual if (/^\d+$/.test(word)) return word; // Si la palabra está en la lista de excluidas, se devuelve // EXACTAMENTE tal cual está. const match = excludeWords.find( (w) => w.normalize("NFD").toLowerCase() === lowerWord); if (match) return match; // Si es un número romano, convertir a mayúsculas if (isRoman(word)) return word.toUpperCase(); // Si contiene un apóstrofo, no capitalizar la letra siguiente if (/^[A-Za-z]+'[A-Za-z]/.test(word)) { return (word.charAt(0).toUpperCase() + word.slice(1, word.indexOf("'") + 1) + word.slice(word.indexOf("'") + 1).toLowerCase()); } // Si no se deben normalizar artículos y es un artículo, mantener en // minúsculas if (!normalizeArticles && articles.includes(lowerWord) && index !== 0) return lowerWord; // Capitalizar la primera letra de la palabra return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); }); let newName = normalizedWords.join(" ") .replace(/\s*\|\s*/g, " - ") .replace(/([(["'])\s*([\p{L}])/gu, (match, p1, p2) => p1 + p2.toUpperCase()) .replace(/\s*-\s*/g, " - ") .replace(/\b(\d+)([A-Z])\b/g, (match, num, letter) => num + letter.toUpperCase()) .replace(/\.$/, "") .replace(/&(\s*)([A-Z])/g, (match, space, letter) => "&" + space + letter.toUpperCase()); // Asegurar que las letras después de un apóstrofo estén en minúscula newName = newName.replace( /([A-Za-z])'([A-Za-z])/g, (match, before, after) => { return `${before}'${after.toLowerCase()}`; }); // Asegurar que la primera letra después de un guion esté en mayúscula newName = newName.replace( /-\s*([a-z])/g, (match, letter) => `- ${letter.toUpperCase()}`); return newName.replace(/\s{2,}/g, " ").trim(); } // ***************************************************************************************************** // Nombre: init // Fecha modificación: 2025-04-09 // Autor: mincho77 // Entradas: Ninguna // Salidas: Ninguna // Prerrequisitos si existen: // - El objeto global W debe estar disponible. // - Deben estar definidas las funciones: initializeExcludeWords, // createSidebarTab, waitForDOM, renderExcludedWordsPanel y // setupDragAndDropImport. Descripción: Esta función espera a que el entorno // de edición de Waze (WME) esté completamente cargado, verificando que // existan los objetos necesarios para iniciar el script. Una vez // disponible, inicializa la lista de palabras excluidas, crea el tab // lateral personalizado, y espera a que el DOM del tab esté listo para // renderizar el panel de palabras excluidas y activar la funcionalidad de // arrastrar y soltar para importar palabras. Finalmente, expone globalmente // las funciones applyNormalization y normalizePlaceName. // ***************************************************************************************************** function init() { if (!W || !W.userscripts || !W.model || !W.model.venues) { console.log(`[${SCRIPT_NAME}] Esperando que WME esté listo...`); setTimeout(init, 1000); return; } console.log(`[${SCRIPT_NAME}] Inicializando v${VERSION}`); initializeExcludeWords(); createSidebarTab(); waitForDOM("#normalizer-tab", () => { console.log("[init] Sidebar listo"); renderExcludedWordsPanel(); setupDragAndDropImport(); // Cargar categorías con 2 intentos (inmediato y con retardo) populateCategoryDropdown(); setTimeout(populateCategoryDropdown, 3000); // Respaldo por si carga async }); // Agregar el evento para rotar la flecha en el elemento <details> waitForElement("#details-special-words", (detailsElem) => { const arrow = document.getElementById("arrow"); if (detailsElem && arrow) { detailsElem.addEventListener("toggle", function() { arrow.style.transform = detailsElem.open ? "rotate(90deg)" : "rotate(0deg)"; }); } else { console.error( "No se encontró el elemento #details-special-words o #arrow"); } }); window.applyNormalization = applyNormalization; window.normalizePlaceName = normalizePlaceName; // reinicializa la lista y cierra el panel cuando cambia el zoom if (W && W.model && W.model.venues) { // Suponiendo que W.model.venues emite el evento 'mapviewchanged' o // 'zoomchanged'. W.model.venues.on("zoomchanged", () => { // Reinicia la variable // global placesToNormalize = []; // Cierra el panel flotante, si está presente const existingPanel = document.getElementById("normalizer-floating-panel"); if (existingPanel) { existingPanel.remove(); } console.log( "Cambio de zoom detectado: Se ha reiniciado la búsqueda de lugares."); }); } console.log("W.model.categories:", W.model.categories); console.log("W.model.venues.getCategories():", W.model.venues.getCategories?.()); } // Inicia el script init(); // -------------------------------------------------------------------- // Fin del script principal // Exponer algunas funciones clave globalmente (opcional) unsafeWindow.normalizePlaceName = normalizePlaceName; unsafeWindow.applyNormalization = applyNormalization; })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址