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