// ==UserScript==
// @name WME Places Name Normalizer
// @namespace https://gf.qytechs.cn/en/users/mincho77
// @version 3.1
// @description Normaliza nombres de lugares en Waze Map Editor (WME) por ahora solo con reglas de Colombia
// @author mincho77
// @match https://www.waze.com/*editor*
// @match https://beta.waze.com/*user/editor*
// @grant GM_xmlhttpRequest
// @connect api.languagetool.org
// @connect *
// @grant unsafeWindow
// @license MIT
// @run-at document-end
// ==/UserScript==
/*global W*/
(() => {
"use strict";
try {
// Insertar estilos globales en el <head>
const styles = `
<style>
/* Estilos para los botones */
.apply-suggestion-btn {
background-color: #4CAF50;
color: white;
border: none;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: background-color 0.3s ease;
}
.apply-suggestion-btn:hover {
background-color: #45a049;
}
#apply-changes-btn {
background-color: #28a745;
color: white;
border: none;
padding: 10px 15px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
font-weight: bold;
display: flex;
align-items: center;
gap: 8px;
}
#apply-changes-btn:hover {
background-color: #218838;
}
#cancel-btn {
background-color: #dc3545;
color: white;
border: none;
padding: 10px 15px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
font-weight: bold;
display: flex;
align-items: center;
gap: 8px;
}
#cancel-btn:hover {
background-color: #c82333;
}
</style>
`;
document.head.insertAdjacentHTML('beforeend', styles);
// Capturar todos los eventos de drag & drop a nivel de <body>
// Agregar al inicio del script, justo después del "use strict"
// Prevenir comportamiento por defecto de drag & drop a nivel global
document.addEventListener("dragover", function(e) {
const dropZone = document.getElementById("drop-zone");
if (e.target === dropZone || dropZone?.contains(e.target)) {
return; // Permitir el drop en la zona designada
}
e.preventDefault();
e.stopPropagation();
}, { passive: false }); // Agrega { passive: false }
document.addEventListener("drop", function(e) {
const dropZone = document.getElementById("drop-zone");
if (e.target === dropZone || dropZone?.contains(e.target)) {
return; // Permitir el drop en la zona designada
}
e.preventDefault();
e.stopPropagation();
}, { passive: false }); // Agrega { passive: false }
//**************************************************************************
//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
}
// 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
};
}
// Función auxiliar para corregir tildes
function corregirTilde(palabra) {
// Implementar lógica para corregir tildes según las reglas del español
// Por ejemplo: "medellin" → "Medellín"
return palabra; // Retornar la palabra corregida
}
//**************************************************************************
//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);
}, []);
};
}
const SCRIPT_NAME = "PlacesNameNormalizer";
const VERSION = "3.1";
let placesToNormalize = [];
let excludeWords = [];
let maxPlaces = 50;
let normalizeArticles = true;
// Expresión regular para detectar siglas (por ejemplo, "S.a.s", "L.T.D.A")
const siglaRegex = /^[A-Za-z](\.[A-Za-z])+\.?$/;
//**************************************************************************
//Nombre: waitForSidebar
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas:
// retries (número, opcional) – Número máximo de intentos de verificación del sidebar (default: 20).
// delay (número, opcional) – Tiempo en milisegundos entre intentos (default: 1000ms).
//Salidas:
// Promesa que se resuelve con el elemento del sidebar si se encuentra, o se rechaza si no se encuentra después de los intentos.
//Descripción:
// Esta función espera a que el DOM cargue completamente el elemento con ID "sidebar".
// Realiza múltiples intentos con intervalos definidos, y resuelve la promesa cuando el sidebar esté disponible.
// Es útil para asegurarse de que el entorno de WME esté completamente cargado antes de continuar.
//**************************************************************************
function waitForSidebar(retries = 20, delay = 1000) {
return new Promise((resolve, reject) => {
const check = (attempt = 1) => {
const sidebar = document.querySelector("#sidebar");
if (sidebar) {
console.log("✅ Sidebar disponible.");
resolve(sidebar);
} else if (attempt <= retries) {
console.warn(`⚠️ Sidebar no disponible aún. Reintentando... (${attempt})`);
setTimeout(() => check(attempt + 1), delay);
} else {
reject("❌ Sidebar no disponible después de múltiples intentos.");
}
};
check();
});
}
//**************************************************************************
//Nombre: initializeExcludeWords
//Fecha modificación: 2025-03-31
//Autor: mincho77
//Entradas: Ninguna
//Salidas: Ninguna
//Prerrequisitos: excludeWords debe estar declarado globalmente.
//Descripción: Inicializa la lista de palabras excluidas desde localStorage sin borrar entradas ya existentes.
//**************************************************************************
function initializeExcludeWords() {
const saved = JSON.parse(localStorage.getItem("excludeWords")) || [];
// Combinar con las actuales sin duplicar
const merged = [...new Set([...saved, ...excludeWords])].sort((a, b) => a.localeCompare(b));
// Solo guardar si hay diferencias
const originalString = JSON.stringify(saved.sort());
const newString = JSON.stringify(merged);
if (originalString !== newString) {
localStorage.setItem("excludeWords", newString);
console.log(`[initializeExcludeWords] 💾 excludeWords actualizado con ${merged.length} palabras.`);
} else {
console.log(`[initializeExcludeWords] 🟢 Sin cambios en excludeWords.`);
}
// Actualizar variable global
excludeWords = merged;
}
//Modulo de Ortografía
// 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);
}, []);
};
}
//Modulo de Ortografía
//**************************************************************************
//Nombre: getUbicacionAcento
//Fecha modificación: 2025-03-27
//Autor: mincho77
//Entradas: palabra (string) - Palabra en español a evaluar
//Salidas: Posición del acento (última, penúltima, antepenúltima) o null si no aplica
//Prerrequisitos si existen: Ninguno
//Descripción: Determina la posición silábica del acento en una palabra según la ortografía del español.
//**************************************************************************
function getUbicacionAcento(palabra) {
const original = palabra;
const normalized = palabra.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); // Remove diacritics
const silabas = normalized
.replace(/[^aeiou]/gi, "")
.match(/[aeiou]+/gi);
if (!silabas || silabas.length === 0) return null;
const conTilde = silabas.findIndex((s, i) => {
const originalSyllable = original.slice(
normalized.indexOf(s),
normalized.indexOf(s) + s.length
);
return /[áéíóú]/.test(originalSyllable);
});
if (conTilde !== -1) {
const posicion = silabas.length - 1 - conTilde;
if (posicion === 0) return "última";
if (posicion === 1) return "penúltima";
if (posicion === 2) return "antepenúltima";
return "otras";
}
return "sin tilde";
}
//**************************************************************************
//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
.filter(match => match.rule.issueType === "misspelling")
.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;
// Combinar la lógica de checkSpelling
function checkSpelling(text) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "POST",
url: "https://api.languagetool.org/v2/check",
data: `text=${encodeURIComponent(text)}&language=es`,
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
onload: function (response) {
if (response.status === 200) {
try {
const data = JSON.parse(response.responseText);
const errores = data.matches
.filter(match => match.rule.issueType === "misspelling")
.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);
} catch (err) {
reject(err);
}
} else {
reject(`Error HTTP: ${response.status}`);
}
},
onerror: function (err) {
reject(err);
}
});
});
}
//**************************************************************************
//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: separarSilabas
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: palabra (string) – Palabra en español.
//Salidas: array de sílabas (aproximado).
//Descripción:
// Separa una palabra en sílabas usando reglas heurísticas.
// Esta versión simplificada considera diptongos y combinaciones comunes.
//**************************************************************************
function separarSilabas(palabra) {
// Normaliza y quita acentos para facilitar la segmentación
const limpia = palabra.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
// Divide por vocales agrupadas como aproximación de sílabas
const silabas = limpia.match(/[bcdfghjklmnñpqrstvwxyz]*[aeiou]{1,2}[bcdfghjklmnñpqrstvwxyz]*/g);
return silabas || [palabra]; // fallback si no separa nada
}
//**************************************************************************
//Nombre: clasificarPalabra
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: silabas (array) – Arreglo con las sílabas de la palabra.
//Salidas: string – 'aguda', 'grave' (llana), 'esdrújula'.
//Descripción:
// Determina el tipo de palabra según el número de sílabas y la posición
// de la tilde (si existe).
//**************************************************************************
function clasificarPalabra(silabas) {
const palabra = silabas.join("");
const tieneTilde = /[áéíóúÁÉÍÓÚ]/.test(palabra);
if (tieneTilde) {
const posicionTilde = silabas.findIndex(s => /[áéíóúÁÉÍÓÚ]/.test(s));
const posicionDesdeFinal = silabas.length - 1 - posicionTilde;
if (posicionDesdeFinal === 0) return "aguda";
if (posicionDesdeFinal === 1) return "grave";
if (posicionDesdeFinal >= 2) return "esdrújula";
}
// Si no tiene tilde, asumimos que es:
if (silabas.length === 1) return "aguda";
if (silabas.length === 2) return "grave";
return "grave"; // por convención para 3+ sin tilde
}
//**************************************************************************
//Nombre: evaluarOrtografiaNombre
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: name (string) - Nombre del lugar
//Salidas: objeto con errores detectados
//Descripción: Evalúa palabra por palabra si hay errores ortográficos o falta de tildes.
// Ya no utiliza sugerencias automáticas para correcciones.
//**************************************************************************
function evaluarOrtografiaNombre(name) {
if (!name) return {
hasSpellingWarning: false,
spellingWarnings: []
};
const checkOnlyTildes = document.getElementById("checkOnlyTildes")?.checked;
const palabras = name.trim().split(/\s+/);
const spellingWarnings = [];
console.log(`[evaluarOrtografiaNombre] Verificando ortografía de: ${name}`);
if (checkOnlyTildes) {
palabras.forEach((palabra, index) => {
// Verificar si la palabra está en la lista de exclusiones
if (excludeWords.some(w => w.toLowerCase() === palabra.toLowerCase()) || /^\d+$/.test(palabra)) {
return; // Ignorar palabra excluida
}
if (!validarWordSpelling(palabra)) {
spellingWarnings.push({
original: palabra,
sugerida: null,
tipo: "Tilde",
posicion: index // Guardar la posición en la frase
});
}
});
return Promise.resolve({
hasSpellingWarning: spellingWarnings.length > 0,
spellingWarnings
});
} else {
return checkSpellingWithAPI(name)
.then(errores => {
errores.forEach(error => {
// Verificar si la palabra está en exclusiones
if (!excludeWords.some(w => w.toLowerCase() === error.palabra.toLowerCase())) {
spellingWarnings.push({
original: error.palabra,
sugerida: error.sugerencia,
tipo: "LanguageTool",
posicion: name.indexOf(error.palabra)
});
}
});
return {
hasSpellingWarning: spellingWarnings.length > 0,
spellingWarnings
};
});
}
}
//**************************************************************************
//Nombre: handleImportList
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: ninguna directa, depende del input file y checkbox en el DOM
//Salidas: actualiza excludeWords en localStorage y visualmente
//Prerrequisitos: existir un input file con id="importListInput" y checkbox con id="replaceExcludeListCheckbox"
//Descripción: Importa una lista de palabras para excluir, opcionalmente reemplazando la lista actual
//**************************************************************************
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.");
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(`[Importar Lista] ⚠️ ${eliminadas} líneas inválidas fueron ignoradas (vacías, caracteres no permitidos o basura).`);
}
if (replaceCheckbox && replaceCheckbox.checked) {
// Si se marcó reemplazar, limpiar todo
excludeWords = [];
} else {
// Si no, recuperar la actual del localStorage
excludeWords = JSON.parse(localStorage.getItem("excludeWords")) || excludeWords || [];
}
// Unificar, eliminar duplicados y ordenar
excludeWords = [...new Set([...excludeWords, ...lines])]
.filter(w => w.trim().length > 0)
.sort((a, b) => a.localeCompare(b));
localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
console.log("[handleImportList] Palabras actuales excluidas:", excludeWords);
renderExcludedWordsPanel(); // O renderExcludeWordList() si usas otro nombre
setupDragAndDropImport(); // Activa drag and drop
alert(`✅ Palabras excluidas importadas correctamente: ${excludeWords.length}`);
//Limpia el input para permitir recarga posterior
fileInput.value = "";
};
reader.readAsText(fileInput.files[0]);
}
//**************************************************************************
//Nombre: setupDragAndDropImport
//Fecha modificación: 2025-03-31
//Autor: mincho77
//Entradas: ninguna
//Salidas: habilita funcionalidad de arrastrar y soltar archivos al panel
//Descripción:
// Permite arrastrar y soltar un archivo .txt sobre el panel lateral (#normalizer-sidebar).
// Extrae las palabras del archivo y las agrega a excludeWords sin duplicados.
//**************************************************************************
function setupDragAndDropImport() {
const dropArea = document.getElementById("drop-zone");
if (!dropArea) {
console.warn("[setupDragAndDropImport] No se encontró la zona de drop");
return;
}
// Highlight drop zone when dragging over it
const highlight = (e) => {
e.preventDefault();
e.stopPropagation();
dropArea.classList.add("drag-over");
dropArea.style.backgroundColor = "#e8f5e9";
dropArea.style.borderColor = "#4CAF50";
};
// Remove highlighting
const unhighlight = (e) => {
e.preventDefault();
e.stopPropagation();
dropArea.classList.remove("drag-over");
dropArea.style.backgroundColor = "";
dropArea.style.borderColor = "#ccc";
};
// Handle the dropped files
const handleDrop = (e) => {
console.log("[handleDrop] Evento drop detectado");
e.preventDefault();
e.stopPropagation();
unhighlight(e);
const file = e.dataTransfer.files[0];
if (!file) {
alert("❌ No se detectó ningún archivo");
return;
}
// Validar extensión del archivo
if (!file.name.match(/\.(txt|xml)$/i)) {
alert("❌ Por favor, arrastra un archivo .txt o .xml");
return;
}
console.log(`[handleDrop] Procesando archivo: ${file.name}`);
const reader = new FileReader();
reader.onload = (event) => {
try {
const content = event.target.result;
const isXML = file.name.toLowerCase().endsWith('.xml');
let words = [];
if (isXML) {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(content, "text/xml");
if (xmlDoc.getElementsByTagName("parsererror").length > 0) {
throw new Error("XML inválido");
}
words = Array.from(xmlDoc.getElementsByTagName("word"))
.map(node => node.textContent.trim())
.filter(w => w.length > 0);
} else {
words = content.split(/\r?\n/)
.map(line => line.trim())
.filter(line => line.length > 0);
}
if (words.length === 0) {
alert("⚠️ No se encontraron palabras válidas en el archivo");
return;
}
// Actualizar lista de palabras excluidas
excludeWords = [...new Set([...excludeWords, ...words])].sort();
localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
renderExcludedWordsPanel();
alert(`✅ Se importaron ${words.length} palabras exitosamente`);
console.log(`[handleDrop] Importadas ${words.length} palabras`);
} catch (error) {
console.error("[handleDrop] Error procesando archivo:", error);
alert("❌ Error procesando el archivo");
}
};
reader.onerror = () => {
console.error("[handleDrop] Error leyendo archivo");
alert("❌ Error leyendo el archivo");
};
reader.readAsText(file);
};
// Attach the event listeners
dropArea.addEventListener("dragenter", highlight, false);
dropArea.addEventListener("dragover", highlight, false);
dropArea.addEventListener("dragleave", unhighlight, false);
dropArea.addEventListener("drop", handleDrop, false);
console.log("[setupDragAndDropImport] Eventos de drag & drop configurados");
}
// Función auxiliar para procesar el archivo
function handleImportedFile(content, isXML) {
let words = [];
if (isXML) {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(content, "text/xml");
const nodes = xmlDoc.getElementsByTagName("word");
words = Array.from(nodes).map(node => node.textContent.trim());
} else {
words = content.split(/\r?\n/).map(line => line.trim()).filter(line => line);
}
if (words.length === 0) {
alert("No se encontraron palabras válidas en el archivo");
return;
}
// Actualizar lista de palabras excluidas
excludeWords = [...new Set([...excludeWords, ...words])].sort();
localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
renderExcludedWordsPanel();
alert(`✅ Se importaron ${words.length} palabras exitosamente`);
}
//**************************************************************************
//Nombre: renderExcludedWordsPanel
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: ninguna (usa la variable global excludeWords)
//Salidas: ninguna (actualiza el DOM y el localStorage)
//Prerrequisitos si existen: Debe existir un contenedor con id="normalizer-sidebar"
//Descripción:
// Esta función limpia y vuelve a renderizar la lista de palabras excluidas
// dentro del panel lateral del normalizador. Muestra una lista ordenada
// alfabéticamente, evitando palabras vacías. Además, asegura que el localStorage
// se actualice correctamente con la lista limpia y depurada.
//**************************************************************************
function renderExcludedWordsPanel() {
const container = document.getElementById("normalizer-sidebar");
if (!container) {
console.warn("[renderExcludedWordsPanel] ❌ No se encontró el contenedor 'normalizer-sidebar'");
return;
}
//Limpiar el contenedor visual
container.innerHTML = "";
console.log("[renderExcludedWordsPanel] ✅ Contenedor limpiado.");
//Limpiar palabras vacías y ordenar
const sortedWords = excludeWords.filter(w => !!w).sort((a, b) => a.localeCompare(b));
console.log(`[renderExcludedWordsPanel] 📋 Lista excluida depurada: ${sortedWords.length} palabras`, sortedWords);
const excludeListSection = document.createElement("div");
excludeListSection.style.marginTop = "20px";
excludeListSection.innerHTML = `
<h4 style="margin-bottom: 5px;">Palabras Excluidas</h4>
<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: 18px;" id="excludeWordsList">
${sortedWords.map(w => `<li>${w}</li>`).join("")}
</ul>
</div>
`;
container.appendChild(excludeListSection);
console.log("[renderExcludedWordsPanel] ✅ Lista renderizada en el DOM.");
//Guardar en localStorage
localStorage.setItem("excludeWords", JSON.stringify(sortedWords));
console.log("[renderExcludedWordsPanel] 💾 excludeWords actualizado en localStorage.");
}
//**************************************************************************
//Nombre: normalizePlaceName (unsafeWindow)
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas:
// - name (string): el nombre original del lugar.
//Salidas:
// - string: nombre normalizado, respetando exclusiones y opciones del usuario.
//Prerrequisitos si existen:
// - Debe estar cargada la lista global excludeWords.
// - Debe existir un checkbox con id “normalizeArticles” para definir si se normalizan artículos.
//Descripción:
// Esta versión expuesta globalmente permite acceder a la normalización básica del nombre de un lugar
// desde otros contextos como el navegador o Tampermonkey. Capitaliza cada palabra, respeta las excluidas
// y no aplica normalización a artículos si el checkbox lo indica.
// Realiza limpieza básica: reemplazo de pipes, eliminación de espacios dobles y trim final.
//**************************************************************************
unsafeWindow.normalizePlaceName = function(name)
{
if (!name) return "";
const normalizeArticles = !document.getElementById("normalizeArticles")?.checked;
const articles = ["el", "la", "los", "las", "de", "del", "al", "y", "o"];
const words = name.trim().split(/\s+/);
const normalizedWords = words.map((word, index) => {
const lowerWord = word.toLowerCase();
// Saltar palabras excluidas
if (excludeWords.includes(word)) return word;
// Saltar artículos si el checkbox está activo y no es la primera palabra
if (!normalizeArticles && articles.includes(lowerWord) && index !== 0) {
return lowerWord;
}
//Mayúsculas
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
});
name = normalizedWords.join(" ");
name = name.replace(/\s*\|\s*/g, " - ");
name = name.replace(/\s{2,}/g, " ").trim();
return name;
};
//**************************************************************************
//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: validarOrtografiaConAPI
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: texto (string) - palabra o frase a evaluar
//Salidas: Promesa con resultado de corrección ortográfica
//Descripción: Consulta la API pública de LanguageTool para identificar errores ortográficos
//**************************************************************************
function validarOrtografiaConAPI2(texto) {
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: `text=${encodeURIComponent(texto)}&language=es`,
onload: function (response) {
if (response.status === 200) {
const result = JSON.parse(response.responseText);
resolve(result.matches || []);
} else {
reject("Error en la API de LanguageTool");
}
},
onerror: function () {
reject("Fallo la solicitud a la API de LanguageTool");
}
});
});
}
//**************************************************************************
//Nombre: importExcludeList
//Fecha modificación: 2025-03-31
//Autor: mincho77
//Entradas:
// file (File) – Archivo cargado por el usuario, que contiene una lista de palabras excluidas.
//Salidas:
// Ninguna (actualiza el array global excludeWords y el localStorage).
//Prerrequisitos:
// Debe existir un panel visual para mostrar la lista y una función renderExcludeList().
//Descripción:
// Lee un archivo .txt línea por línea, limpia espacios, elimina duplicados y vacíos,
// ordena alfabéticamente la lista resultante y actualiza la lista global de palabras
// excluidas (excludeWords). Guarda la lista en localStorage y actualiza el panel visual.
//**************************************************************************
function importExcludeList(file) {
const reader = new FileReader();
reader.onload = function (e) {
const newWords = e.target.result
.split(/\r?\n/)
.map(w => w.trim())
.filter(w => w.length > 0); // eliminar vacíos
excludeWords = [...new Set(newWords)].sort((a, b) => a.localeCompare(b));
localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
renderExcludeList(); // actualiza el panel visual
};
reader.readAsText(file);
}
//**************************************************************************
//Nombre: applySpellCorrection
//Fecha modificación: 2025-03-31
//Autor: mincho77
//Entradas:
// text (string) – Texto que se desea corregir automáticamente con sugerencias ortográficas.
//Salidas:
// Promise<string> – Texto corregido con las mejores sugerencias aplicadas.
//Prerrequisitos:
// Debe existir la función checkSpelling que retorna los errores detectados por LanguageTool.
//Descripción:
// Llama a checkSpelling y aplica la primera sugerencia de reemplazo para cada error ortográfico,
// procesando los reemplazos de atrás hacia adelante (para evitar desajustes de índice).
// Devuelve el texto corregido como resultado final.
//**************************************************************************
function applySpellCorrection(text) {
return checkSpelling(text).then(data => {
let corrected = text;
// Ordenar los matches de mayor a menor offset
const matches = data.matches.sort((a, b) => b.offset - a.offset);
matches.forEach(match => {
if (match.replacements && match.replacements.length > 0) {
const replacement = match.replacements[0].value;
corrected = corrected.substring(0, match.offset) + replacement + corrected.substring(match.offset + match.length);
}
});
return corrected;
});
}
//**************************************************************************
//Nombre: createSidebarTab
//Fecha modificación: 2025-03-31
//Autor: mincho77
//Entradas: Ninguna directa. Usa `W.userscripts` para registrar el tab.
//Salidas: Renderiza un nuevo tab lateral personalizado con interfaz del normalizador.
//Prerrequisitos:
// - `W.userscripts.registerSidebarTab` debe estar disponible.
// - `getSidebarHTML()` debe retornar el HTML necesario.
//Descripción:
// Crea y registra una nueva pestaña en el sidebar de WME con el título
// "Places Name Normalizer". Al cargar correctamente, inserta el HTML
// generado por `getSidebarHTML()` y espera a que se renderice completamente
// para ejecutar los eventos mediante `waitForDOM`.
//**************************************************************************
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: 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) {
alert("No hay palabras excluidas para exportar.");
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); // Correctly appends the link
link.click();
document.body.removeChild(link); // Correctly removes the 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(); // Usar renderExcludedWordsPanel en lugar de updateExcludeList
}
wordInput.value = "";
});
// ✅ Evento: nuevo botón unificado de importación
importButtonUnified.addEventListener("click", () => {
hiddenInput.click(); // abre el file input oculto
});
hiddenInput.addEventListener("change", () => {
handleImportList(); // ✅ Llama a la función handleImportList al importar
});
// ✅ Evento: escanear lugares
scanPlacesButton.addEventListener("click", scanPlaces);
}
function updateExcludeList() {
const list = document.getElementById("excludedWordsList");
if (!list) return;
// Ordena una copia del array para no alterar el original
const sortedWords = [...excludeWords].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
list.innerHTML = sortedWords.map(word => `<li>${word}</li>`).join("");
}
//**************************************************************************
//Nombre: scanPlaces
//Fecha modificación: 2025-04-02
//Autor: mincho77
//Entradas: ninguna directamente (usa datos del modelo WME y del input #maxPlacesInput)
//Salidas: abre panel flotante con lugares que deben ser normalizados
//Descripción:
// Escanea los lugares visibles en el WME, normaliza los nombres y verifica
// si les falta una tilde en las palabras que lo requieren. Si se selecciona
// "Revisar solo tildes", utiliza la función evaluarOrtografiaConTildes.
//**************************************************************************
// ==UserScript==
// @name WME Places Name Normalizer
// @namespace https://gf.qytechs.cn/en/users/mincho77
// @version 2.1
// @description Normaliza nombres de lugares en Waze Map Editor (WME) por ahora solo con reglas de Colombia
// @author mincho77
// @match https://www.waze.com/*editor*
// @match https://beta.waze.com/*user/editor*
// @grant GM_xmlhttpRequest
// @connect api.languagetool.org
// @connect *
// @grant unsafeWindow
// @license MIT
// @run-at document-end
// @downloadURL https://update.gf.qytechs.cn/scripts/530268/WME%20Places%20Name%20Normalizer.user.js
// @updateURL https://update.gf.qytechs.cn/scripts/530268/WME%20Places%20Name%20Normalizer.meta.js
// ==/UserScript==
/*global W*/
(() => {
"use strict";
try {
// Insertar estilos globales en el <head>
const styles = `
<style>
/* Estilos para los botones */
.apply-suggestion-btn {
background-color: #4CAF50;
color: white;
border: none;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: background-color 0.3s ease;
}
.apply-suggestion-btn:hover {
background-color: #45a049;
}
#apply-changes-btn {
background-color: #28a745;
color: white;
border: none;
padding: 10px 15px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
font-weight: bold;
display: flex;
align-items: center;
gap: 8px;
}
#apply-changes-btn:hover {
background-color: #218838;
}
#cancel-btn {
background-color: #dc3545;
color: white;
border: none;
padding: 10px 15px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
font-weight: bold;
display: flex;
align-items: center;
gap: 8px;
}
#cancel-btn:hover {
background-color: #c82333;
}
</style>
`;
document.head.insertAdjacentHTML('beforeend', styles);
// Capturar todos los eventos de drag & drop a nivel de <body>
// Agregar al inicio del script, justo después del "use strict"
// Prevenir comportamiento por defecto de drag & drop a nivel global
document.addEventListener("dragover", function(e) {
const dropZone = document.getElementById("drop-zone");
if (e.target === dropZone || dropZone?.contains(e.target)) {
return; // Permitir el drop en la zona designada
}
e.preventDefault();
e.stopPropagation();
}, { passive: false }); // Agrega { passive: false }
document.addEventListener("drop", function(e) {
const dropZone = document.getElementById("drop-zone");
if (e.target === dropZone || dropZone?.contains(e.target)) {
return; // Permitir el drop en la zona designada
}
e.preventDefault();
e.stopPropagation();
}, { passive: false }); // Agrega { passive: false }
//**************************************************************************
//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) {
return new Promise((resolve) => {
if (!name) {
return resolve({
hasSpellingWarning: false,
spellingWarnings: []
});
}
const palabras = name.trim().split(/\s+/);
const spellingWarnings = [];
const totalPalabras = palabras.length;
let procesadas = 0;
// Función para procesar cada palabra secuencialmente
function procesarPalabra(index) {
if (index >= palabras.length) {
// Todas las palabras procesadas
toggleSpinner(false);
return resolve({
hasSpellingWarning: spellingWarnings.length > 0,
spellingWarnings
});
}
const palabra = palabras[index];
let normalizada = normalizePlaceNameOnly(palabra);
// Actualizar progreso
procesadas++;
const progress = Math.round((procesadas / totalPalabras) * 100);
toggleSpinner(true, 'Revisando tildes...', progress);
// Lógica de verificación existente
if (/^[A-Za-z]&[A-Za-z]$/.test(normalizada) ||
/^[\u263a-\u263c\u2764\u1f600-\u1f64f\u1f680-\u1f6ff]+$/.test(normalizada)) {
return procesarPalabra(index + 1);
}
if (normalizada.toLowerCase() === "y" || /^\d+$/.test(normalizada) || normalizada === "-") {
return procesarPalabra(index + 1);
}
if (excludeWords.some(w => w.toLowerCase() === normalizada.toLowerCase())) {
return procesarPalabra(index + 1);
}
const cantidadTildes = (normalizada.match(/[áéíóú]/g) || []).length;
if (cantidadTildes > 1) {
spellingWarnings.push({
original: palabra,
sugerida: null,
tipo: "Error de tildes",
posicion: index
});
}
// Procesar siguiente palabra
procesarPalabra(index + 1);
}
// Iniciar el procesamiento
toggleSpinner(true, 'Revisando tildes...', 0);
procesarPalabra(0);
});
}
// Función auxiliar para corregir tildes
function corregirTilde(palabra) {
// Implementar lógica para corregir tildes según las reglas del español
// Por ejemplo: "medellin" → "Medellín"
return palabra; // Retornar la palabra corregida
}
//**************************************************************************
//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);
}, []);
};
}
const SCRIPT_NAME = "PlacesNameNormalizer";
const VERSION = "2.1";
let placesToNormalize = [];
let excludeWords = [];
let maxPlaces = 100;
let normalizeArticles = true;
// Expresión regular para detectar siglas (por ejemplo, "S.a.s", "L.T.D.A")
const siglaRegex = /^[A-Za-z](\.[A-Za-z])+\.?$/;
//**************************************************************************
//Nombre: waitForSidebar
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas:
// retries (número, opcional) – Número máximo de intentos de verificación del sidebar (default: 20).
// delay (número, opcional) – Tiempo en milisegundos entre intentos (default: 1000ms).
//Salidas:
// Promesa que se resuelve con el elemento del sidebar si se encuentra, o se rechaza si no se encuentra después de los intentos.
//Descripción:
// Esta función espera a que el DOM cargue completamente el elemento con ID "sidebar".
// Realiza múltiples intentos con intervalos definidos, y resuelve la promesa cuando el sidebar esté disponible.
// Es útil para asegurarse de que el entorno de WME esté completamente cargado antes de continuar.
//**************************************************************************
function waitForSidebar(retries = 20, delay = 1000) {
return new Promise((resolve, reject) => {
const check = (attempt = 1) => {
const sidebar = document.querySelector("#sidebar");
if (sidebar) {
console.log("✅ Sidebar disponible.");
resolve(sidebar);
} else if (attempt <= retries) {
console.warn(`⚠️ Sidebar no disponible aún. Reintentando... (${attempt})`);
setTimeout(() => check(attempt + 1), delay);
} else {
reject("❌ Sidebar no disponible después de múltiples intentos.");
}
};
check();
});
}
//**************************************************************************
//Nombre: initializeExcludeWords
//Fecha modificación: 2025-03-31
//Autor: mincho77
//Entradas: Ninguna
//Salidas: Ninguna
//Prerrequisitos: excludeWords debe estar declarado globalmente.
//Descripción: Inicializa la lista de palabras excluidas desde localStorage sin borrar entradas ya existentes.
//**************************************************************************
function initializeExcludeWords() {
const saved = JSON.parse(localStorage.getItem("excludeWords")) || [];
// Combinar con las actuales sin duplicar
const merged = [...new Set([...saved, ...excludeWords])].sort((a, b) => a.localeCompare(b));
// Solo guardar si hay diferencias
const originalString = JSON.stringify(saved.sort());
const newString = JSON.stringify(merged);
if (originalString !== newString) {
localStorage.setItem("excludeWords", newString);
console.log(`[initializeExcludeWords] 💾 excludeWords actualizado con ${merged.length} palabras.`);
} else {
console.log(`[initializeExcludeWords] 🟢 Sin cambios en excludeWords.`);
}
// Actualizar variable global
excludeWords = merged;
}
//Modulo de Ortografía
// 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);
}, []);
};
}
//Modulo de Ortografía
//**************************************************************************
//Nombre: getUbicacionAcento
//Fecha modificación: 2025-03-27
//Autor: mincho77
//Entradas: palabra (string) - Palabra en español a evaluar
//Salidas: Posición del acento (última, penúltima, antepenúltima) o null si no aplica
//Prerrequisitos si existen: Ninguno
//Descripción: Determina la posición silábica del acento en una palabra según la ortografía del español.
//**************************************************************************
function getUbicacionAcento(palabra) {
const original = palabra;
const normalized = palabra.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); // Remove diacritics
const silabas = normalized
.replace(/[^aeiou]/gi, "")
.match(/[aeiou]+/gi);
if (!silabas || silabas.length === 0) return null;
const conTilde = silabas.findIndex((s, i) => {
const originalSyllable = original.slice(
normalized.indexOf(s),
normalized.indexOf(s) + s.length
);
return /[áéíóú]/.test(originalSyllable);
});
if (conTilde !== -1) {
const posicion = silabas.length - 1 - conTilde;
if (posicion === 0) return "última";
if (posicion === 1) return "penúltima";
if (posicion === 2) return "antepenúltima";
return "otras";
}
return "sin tilde";
}
//**************************************************************************
//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
.filter(match => match.rule.issueType === "misspelling")
.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;
// Combinar la lógica de checkSpelling
function checkSpelling(text) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "POST",
url: "https://api.languagetool.org/v2/check",
data: `text=${encodeURIComponent(text)}&language=es`,
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
onload: function (response) {
if (response.status === 200) {
try {
const data = JSON.parse(response.responseText);
const errores = data.matches
.filter(match => match.rule.issueType === "misspelling")
.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);
} catch (err) {
reject(err);
}
} else {
reject(`Error HTTP: ${response.status}`);
}
},
onerror: function (err) {
reject(err);
}
});
});
}
//**************************************************************************
//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: separarSilabas
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: palabra (string) – Palabra en español.
//Salidas: array de sílabas (aproximado).
//Descripción:
// Separa una palabra en sílabas usando reglas heurísticas.
// Esta versión simplificada considera diptongos y combinaciones comunes.
//**************************************************************************
function separarSilabas(palabra) {
// Normaliza y quita acentos para facilitar la segmentación
const limpia = palabra.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
// Divide por vocales agrupadas como aproximación de sílabas
const silabas = limpia.match(/[bcdfghjklmnñpqrstvwxyz]*[aeiou]{1,2}[bcdfghjklmnñpqrstvwxyz]*/g);
return silabas || [palabra]; // fallback si no separa nada
}
//**************************************************************************
//Nombre: clasificarPalabra
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: silabas (array) – Arreglo con las sílabas de la palabra.
//Salidas: string – 'aguda', 'grave' (llana), 'esdrújula'.
//Descripción:
// Determina el tipo de palabra según el número de sílabas y la posición
// de la tilde (si existe).
//**************************************************************************
function clasificarPalabra(silabas) {
const palabra = silabas.join("");
const tieneTilde = /[áéíóúÁÉÍÓÚ]/.test(palabra);
if (tieneTilde) {
const posicionTilde = silabas.findIndex(s => /[áéíóúÁÉÍÓÚ]/.test(s));
const posicionDesdeFinal = silabas.length - 1 - posicionTilde;
if (posicionDesdeFinal === 0) return "aguda";
if (posicionDesdeFinal === 1) return "grave";
if (posicionDesdeFinal >= 2) return "esdrújula";
}
// Si no tiene tilde, asumimos que es:
if (silabas.length === 1) return "aguda";
if (silabas.length === 2) return "grave";
return "grave"; // por convención para 3+ sin tilde
}
//**************************************************************************
//Nombre: evaluarOrtografiaNombre
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: name (string) - Nombre del lugar
//Salidas: objeto con errores detectados
//Descripción: Evalúa palabra por palabra si hay errores ortográficos o falta de tildes.
// Ya no utiliza sugerencias automáticas para correcciones.
//**************************************************************************
function evaluarOrtografiaNombre(name) {
if (!name) return {
hasSpellingWarning: false,
spellingWarnings: []
};
const checkOnlyTildes = document.getElementById("checkOnlyTildes")?.checked;
const palabras = name.trim().split(/\s+/);
const spellingWarnings = [];
console.log(`[evaluarOrtografiaNombre] Verificando ortografía de: ${name}`);
if (checkOnlyTildes) {
palabras.forEach((palabra, index) => {
// Verificar si la palabra está en la lista de exclusiones
if (excludeWords.some(w => w.toLowerCase() === palabra.toLowerCase()) || /^\d+$/.test(palabra)) {
return; // Ignorar palabra excluida
}
if (!validarWordSpelling(palabra)) {
spellingWarnings.push({
original: palabra,
sugerida: null,
tipo: "Tilde",
posicion: index // Guardar la posición en la frase
});
}
});
return Promise.resolve({
hasSpellingWarning: spellingWarnings.length > 0,
spellingWarnings
});
} else {
return checkSpellingWithAPI(name)
.then(errores => {
errores.forEach(error => {
// Verificar si la palabra está en exclusiones
if (!excludeWords.some(w => w.toLowerCase() === error.palabra.toLowerCase())) {
spellingWarnings.push({
original: error.palabra,
sugerida: error.sugerencia,
tipo: "LanguageTool",
posicion: name.indexOf(error.palabra)
});
}
});
return {
hasSpellingWarning: spellingWarnings.length > 0,
spellingWarnings
};
});
}
}
//**************************************************************************
//Nombre: handleImportList
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: ninguna directa, depende del input file y checkbox en el DOM
//Salidas: actualiza excludeWords en localStorage y visualmente
//Prerrequisitos: existir un input file con id="importListInput" y checkbox con id="replaceExcludeListCheckbox"
//Descripción: Importa una lista de palabras para excluir, opcionalmente reemplazando la lista actual
//**************************************************************************
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.");
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(`[Importar Lista] ⚠️ ${eliminadas} líneas inválidas fueron ignoradas (vacías, caracteres no permitidos o basura).`);
}
if (replaceCheckbox && replaceCheckbox.checked) {
// Si se marcó reemplazar, limpiar todo
excludeWords = [];
} else {
// Si no, recuperar la actual del localStorage
excludeWords = JSON.parse(localStorage.getItem("excludeWords")) || excludeWords || [];
}
// Unificar, eliminar duplicados y ordenar
excludeWords = [...new Set([...excludeWords, ...lines])]
.filter(w => w.trim().length > 0)
.sort((a, b) => a.localeCompare(b));
localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
console.log("[handleImportList] Palabras actuales excluidas:", excludeWords);
renderExcludedWordsPanel(); // O renderExcludeWordList() si usas otro nombre
setupDragAndDropImport(); // Activa drag and drop
alert(`✅ Palabras excluidas importadas correctamente: ${excludeWords.length}`);
//Limpia el input para permitir recarga posterior
fileInput.value = "";
};
reader.readAsText(fileInput.files[0]);
}
//**************************************************************************
//Nombre: setupDragAndDropImport
//Fecha modificación: 2025-03-31
//Autor: mincho77
//Entradas: ninguna
//Salidas: habilita funcionalidad de arrastrar y soltar archivos al panel
//Descripción:
// Permite arrastrar y soltar un archivo .txt sobre el panel lateral (#normalizer-sidebar).
// Extrae las palabras del archivo y las agrega a excludeWords sin duplicados.
//**************************************************************************
function setupDragAndDropImport() {
const dropArea = document.getElementById("drop-zone");
if (!dropArea) {
console.warn("[setupDragAndDropImport] No se encontró la zona de drop");
return;
}
// Highlight drop zone when dragging over it
const highlight = (e) => {
e.preventDefault();
e.stopPropagation();
dropArea.classList.add("drag-over");
dropArea.style.backgroundColor = "#e8f5e9";
dropArea.style.borderColor = "#4CAF50";
};
// Remove highlighting
const unhighlight = (e) => {
e.preventDefault();
e.stopPropagation();
dropArea.classList.remove("drag-over");
dropArea.style.backgroundColor = "";
dropArea.style.borderColor = "#ccc";
};
// Handle the dropped files
const handleDrop = (e) => {
console.log("[handleDrop] Evento drop detectado");
e.preventDefault();
e.stopPropagation();
unhighlight(e);
const file = e.dataTransfer.files[0];
if (!file) {
alert("❌ No se detectó ningún archivo");
return;
}
// Validar extensión del archivo
if (!file.name.match(/\.(txt|xml)$/i)) {
alert("❌ Por favor, arrastra un archivo .txt o .xml");
return;
}
console.log(`[handleDrop] Procesando archivo: ${file.name}`);
const reader = new FileReader();
reader.onload = (event) => {
try {
const content = event.target.result;
const isXML = file.name.toLowerCase().endsWith('.xml');
let words = [];
if (isXML) {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(content, "text/xml");
if (xmlDoc.getElementsByTagName("parsererror").length > 0) {
throw new Error("XML inválido");
}
words = Array.from(xmlDoc.getElementsByTagName("word"))
.map(node => node.textContent.trim())
.filter(w => w.length > 0);
} else {
words = content.split(/\r?\n/)
.map(line => line.trim())
.filter(line => line.length > 0);
}
if (words.length === 0) {
alert("⚠️ No se encontraron palabras válidas en el archivo");
return;
}
// Actualizar lista de palabras excluidas
excludeWords = [...new Set([...excludeWords, ...words])].sort();
localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
renderExcludedWordsPanel();
alert(`✅ Se importaron ${words.length} palabras exitosamente`);
console.log(`[handleDrop] Importadas ${words.length} palabras`);
} catch (error) {
console.error("[handleDrop] Error procesando archivo:", error);
alert("❌ Error procesando el archivo");
}
};
reader.onerror = () => {
console.error("[handleDrop] Error leyendo archivo");
alert("❌ Error leyendo el archivo");
};
reader.readAsText(file);
};
// Attach the event listeners
dropArea.addEventListener("dragenter", highlight, false);
dropArea.addEventListener("dragover", highlight, false);
dropArea.addEventListener("dragleave", unhighlight, false);
dropArea.addEventListener("drop", handleDrop, false);
console.log("[setupDragAndDropImport] Eventos de drag & drop configurados");
}
// Función auxiliar para procesar el archivo
function handleImportedFile(content, isXML) {
let words = [];
if (isXML) {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(content, "text/xml");
const nodes = xmlDoc.getElementsByTagName("word");
words = Array.from(nodes).map(node => node.textContent.trim());
} else {
words = content.split(/\r?\n/).map(line => line.trim()).filter(line => line);
}
if (words.length === 0) {
alert("No se encontraron palabras válidas en el archivo");
return;
}
// Actualizar lista de palabras excluidas
excludeWords = [...new Set([...excludeWords, ...words])].sort();
localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
renderExcludedWordsPanel();
alert(`✅ Se importaron ${words.length} palabras exitosamente`);
}
//**************************************************************************
//Nombre: renderExcludedWordsPanel
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: ninguna (usa la variable global excludeWords)
//Salidas: ninguna (actualiza el DOM y el localStorage)
//Prerrequisitos si existen: Debe existir un contenedor con id="normalizer-sidebar"
//Descripción:
// Esta función limpia y vuelve a renderizar la lista de palabras excluidas
// dentro del panel lateral del normalizador. Muestra una lista ordenada
// alfabéticamente, evitando palabras vacías. Además, asegura que el localStorage
// se actualice correctamente con la lista limpia y depurada.
//**************************************************************************
function renderExcludedWordsPanel() {
const container = document.getElementById("normalizer-sidebar");
if (!container) {
console.warn("[renderExcludedWordsPanel] ❌ No se encontró el contenedor 'normalizer-sidebar'");
return;
}
//Limpiar el contenedor visual
container.innerHTML = "";
console.log("[renderExcludedWordsPanel] ✅ Contenedor limpiado.");
//Limpiar palabras vacías y ordenar
const sortedWords = excludeWords.filter(w => !!w).sort((a, b) => a.localeCompare(b));
console.log(`[renderExcludedWordsPanel] 📋 Lista excluida depurada: ${sortedWords.length} palabras`, sortedWords);
const excludeListSection = document.createElement("div");
excludeListSection.style.marginTop = "20px";
excludeListSection.innerHTML = `
<h4 style="margin-bottom: 5px;">Palabras Excluidas</h4>
<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: 18px;" id="excludeWordsList">
${sortedWords.map(w => `<li>${w}</li>`).join("")}
</ul>
</div>
`;
container.appendChild(excludeListSection);
console.log("[renderExcludedWordsPanel] ✅ Lista renderizada en el DOM.");
//Guardar en localStorage
localStorage.setItem("excludeWords", JSON.stringify(sortedWords));
console.log("[renderExcludedWordsPanel] 💾 excludeWords actualizado en localStorage.");
}
//**************************************************************************
//Nombre: normalizePlaceName (unsafeWindow)
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas:
// - name (string): el nombre original del lugar.
//Salidas:
// - string: nombre normalizado, respetando exclusiones y opciones del usuario.
//Prerrequisitos si existen:
// - Debe estar cargada la lista global excludeWords.
// - Debe existir un checkbox con id “normalizeArticles” para definir si se normalizan artículos.
//Descripción:
// Esta versión expuesta globalmente permite acceder a la normalización básica del nombre de un lugar
// desde otros contextos como el navegador o Tampermonkey. Capitaliza cada palabra, respeta las excluidas
// y no aplica normalización a artículos si el checkbox lo indica.
// Realiza limpieza básica: reemplazo de pipes, eliminación de espacios dobles y trim final.
//**************************************************************************
unsafeWindow.normalizePlaceName = function(name)
{
if (!name) return "";
const normalizeArticles = !document.getElementById("normalizeArticles")?.checked;
const articles = ["el", "la", "los", "las", "de", "del", "al", "y", "o"];
const words = name.trim().split(/\s+/);
const normalizedWords = words.map((word, index) => {
const lowerWord = word.toLowerCase();
// Saltar palabras excluidas
if (excludeWords.includes(word)) return word;
// Saltar artículos si el checkbox está activo y no es la primera palabra
if (!normalizeArticles && articles.includes(lowerWord) && index !== 0) {
return lowerWord;
}
//Mayúsculas
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
});
name = normalizedWords.join(" ");
name = name.replace(/\s*\|\s*/g, " - ");
name = name.replace(/\s{2,}/g, " ").trim();
return name;
};
//**************************************************************************
//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: validarOrtografiaConAPI
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: texto (string) - palabra o frase a evaluar
//Salidas: Promesa con resultado de corrección ortográfica
//Descripción: Consulta la API pública de LanguageTool para identificar errores ortográficos
//**************************************************************************
function validarOrtografiaConAPI2(texto) {
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: `text=${encodeURIComponent(texto)}&language=es`,
onload: function (response) {
if (response.status === 200) {
const result = JSON.parse(response.responseText);
resolve(result.matches || []);
} else {
reject("Error en la API de LanguageTool");
}
},
onerror: function () {
reject("Fallo la solicitud a la API de LanguageTool");
}
});
});
}
//**************************************************************************
//Nombre: importExcludeList
//Fecha modificación: 2025-03-31
//Autor: mincho77
//Entradas:
// file (File) – Archivo cargado por el usuario, que contiene una lista de palabras excluidas.
//Salidas:
// Ninguna (actualiza el array global excludeWords y el localStorage).
//Prerrequisitos:
// Debe existir un panel visual para mostrar la lista y una función renderExcludeList().
//Descripción:
// Lee un archivo .txt línea por línea, limpia espacios, elimina duplicados y vacíos,
// ordena alfabéticamente la lista resultante y actualiza la lista global de palabras
// excluidas (excludeWords). Guarda la lista en localStorage y actualiza el panel visual.
//**************************************************************************
function importExcludeList(file) {
const reader = new FileReader();
reader.onload = function (e) {
const newWords = e.target.result
.split(/\r?\n/)
.map(w => w.trim())
.filter(w => w.length > 0); // eliminar vacíos
excludeWords = [...new Set(newWords)].sort((a, b) => a.localeCompare(b));
localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
renderExcludeList(); // actualiza el panel visual
};
reader.readAsText(file);
}
//**************************************************************************
//Nombre: applySpellCorrection
//Fecha modificación: 2025-03-31
//Autor: mincho77
//Entradas:
// text (string) – Texto que se desea corregir automáticamente con sugerencias ortográficas.
//Salidas:
// Promise<string> – Texto corregido con las mejores sugerencias aplicadas.
//Prerrequisitos:
// Debe existir la función checkSpelling que retorna los errores detectados por LanguageTool.
//Descripción:
// Llama a checkSpelling y aplica la primera sugerencia de reemplazo para cada error ortográfico,
// procesando los reemplazos de atrás hacia adelante (para evitar desajustes de índice).
// Devuelve el texto corregido como resultado final.
//**************************************************************************
function applySpellCorrection(text) {
return checkSpelling(text).then(data => {
let corrected = text;
// Ordenar los matches de mayor a menor offset
const matches = data.matches.sort((a, b) => b.offset - a.offset);
matches.forEach(match => {
if (match.replacements && match.replacements.length > 0) {
const replacement = match.replacements[0].value;
corrected = corrected.substring(0, match.offset) + replacement + corrected.substring(match.offset + match.length);
}
});
return corrected;
});
}
//**************************************************************************
//Nombre: createSidebarTab
//Fecha modificación: 2025-03-31
//Autor: mincho77
//Entradas: Ninguna directa. Usa `W.userscripts` para registrar el tab.
//Salidas: Renderiza un nuevo tab lateral personalizado con interfaz del normalizador.
//Prerrequisitos:
// - `W.userscripts.registerSidebarTab` debe estar disponible.
// - `getSidebarHTML()` debe retornar el HTML necesario.
//Descripción:
// Crea y registra una nueva pestaña en el sidebar de WME con el título
// "Places Name Normalizer". Al cargar correctamente, inserta el HTML
// generado por `getSidebarHTML()` y espera a que se renderice completamente
// para ejecutar los eventos mediante `waitForDOM`.
//**************************************************************************
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: 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) {
alert("No hay palabras excluidas para exportar.");
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); // Correctly appends the link
link.click();
document.body.removeChild(link); // Correctly removes the 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(); // Usar renderExcludedWordsPanel en lugar de updateExcludeList
}
wordInput.value = "";
});
// ✅ Evento: nuevo botón unificado de importación
importButtonUnified.addEventListener("click", () => {
hiddenInput.click(); // abre el file input oculto
});
hiddenInput.addEventListener("change", () => {
handleImportList(); // ✅ Llama a la función handleImportList al importar
});
// ✅ Evento: escanear lugares
scanPlacesButton.addEventListener("click", scanPlaces);
}
function updateExcludeList() {
const list = document.getElementById("excludedWordsList");
if (!list) return;
// Ordena una copia del array para no alterar el original
const sortedWords = [...excludeWords].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
list.innerHTML = sortedWords.map(word => `<li>${word}</li>`).join("");
}
//**************************************************************************
//Nombre: scanPlaces
//Fecha modificación: 2025-04-02
//Autor: mincho77
//Entradas: ninguna directamente (usa datos del modelo WME y del input #maxPlacesInput)
//Salidas: abre panel flotante con lugares que deben ser normalizados
//Descripción:
// Escanea los lugares visibles en el WME, normaliza los nombres y verifica
// si les falta una tilde en las palabras que lo requieren. Si se selecciona
// "Revisar solo tildes", utiliza la función evaluarOrtografiaConTildes.
//**************************************************************************
function scanPlaces() {
console.log("scanPlaces ejecutado");
if (!W || !W.model || !W.model.venues || !W.model.venues.objects) {
console.error(`[${SCRIPT_NAME}] WME no está listo.`);
return;
}
const allPlaces = Object.values(W.model.venues.objects);
console.log(`[${SCRIPT_NAME}] Total de lugares encontrados: ${allPlaces.length}`);
const maxPlaces = parseInt(document.getElementById("maxPlacesInput").value, 10) || 20;
const checkOnlyTildes = document.getElementById("checkOnlyTildes")?.checked; // Verificar el estado del checkbox
const placesToNormalize = allPlaces
.filter(p => {
let isValid = true;
if (!p) {
console.log("Lugar descartado: p es null o undefined", p);
isValid = false;
} else if (typeof p.getID !== "function") {
console.log("Lugar descartado: p.getID no es una función", p);
isValid = false;
} else if (!p.attributes) {
console.log("Lugar descartado: p.attributes es null o undefined", p);
isValid = false;
} else if (typeof p.attributes.name !== "string") {
console.log("Lugar descartado: p.attributes.name no es una cadena", p);
isValid = false;
} else if (!p.attributes.name.trim()) {
console.log("Lugar descartado: p.attributes.name está vacío después de trim", p);
isValid = false;
}
return isValid;
})
.slice(0, maxPlaces)
.map(place => ({
id: place.getID(),
name: place.attributes.name,
attributes: place.attributes,
place
}));
console.log("placesToNormalize:", placesToNormalize);
toggleSpinner(true, 'Revisando ortografía...', 0); // Mostrar spinner al inicio con 0%
const totalPlaces = placesToNormalize.length;
let processedPlaces = 0;
let results = []; // Almacenar resultados parciales
function processPlace(index) {
if (index >= totalPlaces) {
toggleSpinner(false); // Ocultar spinner al terminar
if (results.length === 0) {
alert("No se encontraron Places que requieran cambio.");
} else {
openFloatingPanel(results); // Mostrar resultados parciales
}
return;
}
const place = placesToNormalize[index];
const originalName = place.name;
const normalized = normalizePlaceName(originalName);
const ortografia = checkOnlyTildes
? evaluarOrtografiaConTildes(normalized) // Usar la función para revisar solo tildes
: evaluarOrtografiaNombre(normalized);
Promise.resolve(ortografia)
.then(ortografiaResult => {
processedPlaces++;
const progress = Math.round((processedPlaces / totalPlaces) * 100);
toggleSpinner(true, 'Revisando ortografía...', progress); // Actualizar progreso
results.push({
id: place.id,
originalName,
newName: normalized,
hasSpellingWarning: ortografiaResult.hasSpellingWarning,
spellingWarnings: ortografiaResult.spellingWarnings
});
processPlace(index + 1); // Procesar el siguiente lugar
})
.catch(error => {
console.error("Error durante el escaneo de lugares:", error);
alert(`Error durante el escaneo de lugares: ${error}`);
toggleSpinner(false); // Ocultar spinner en caso de error
openFloatingPanel(results); // Mostrar resultados parciales
});
}
processPlace(0); // Iniciar el procesamiento desde el primer lugar
}
//**************************************************************************
//Nombre: applyNormalization
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: Ninguna
//Salidas: Ninguna
//Prerrequisitos si existen:
// - Debe existir un elemento en el DOM con las clases .normalize-checkbox, .delete-checkbox y .new-name-input.
// - El objeto global W debe estar disponible, incluyendo W.model.venues, W.model.actionManager y W.controller.
// - Deben estar definidos los módulos "Waze/Action/UpdateObject" y "Waze/Action/DeleteObject" (accesibles mediante require()).
// - Debe existir la variable placesToNormalize, que contiene datos de los lugares a normalizar, incluyendo sugerencias ortográficas.
//Descripción:
// Esta función aplica la normalización y/o eliminación de nombres de lugares en el Waze Map Editor
// según las selecciones realizadas en el panel flotante. Primero, obtiene los checkboxes seleccionados
// para normalización y eliminación. Si no hay ningún elemento seleccionado, se informa y se cancela la operación.
// Si se han seleccionado TODOS los checkboxes de eliminación, se solicita una confirmación adicional.
// Para cada checkbox de normalización seleccionado, se verifica si se debe aplicar la sugerencia ortográfica
// (cuando se ha hecho clic en el botón correspondiente) o el nombre completo modificado, y se actualiza el lugar
// mediante la acción de actualización. Posteriormente, se procesan los checkboxes de eliminación aplicando la
// acción de eliminación a los lugares correspondientes. Si se realizaron cambios, se marca el modelo como modificado.
// Finalmente, se cierra el panel flotante.
//**************************************************************************
function applyNormalization() {
const normalizeCheckboxes = document.querySelectorAll(".normalize-checkbox:checked");
const deleteCheckboxes = document.querySelectorAll(".delete-checkbox:checked");
let changesMade = false;
if (normalizeCheckboxes.length === 0 && deleteCheckboxes.length === 0) {
alert("No hay lugares seleccionados para normalizar o eliminar.");
return;
}
// Procesar normalización
normalizeCheckboxes.forEach(cb => {
const index = cb.dataset.index;
const input = document.querySelector(`.new-name-input[data-index="${index}"]`);
const newName = input?.value?.trim();
const placeId = input?.getAttribute("data-place-id");
const place = W.model.venues.getObjectById(placeId);
if (!place || !place.attributes?.name) {
console.warn(`No se encontró el lugar con ID: ${placeId}`);
return;
}
const currentName = place.attributes.name.trim();
if (currentName !== newName) {
try {
const UpdateObject = require("Waze/Action/UpdateObject");
const action = new UpdateObject(place, { name: newName });
W.model.actionManager.add(action);
console.log(`Nombre actualizado: "${currentName}" → "${newName}"`);
changesMade = true;
} catch (error) {
console.error("Error aplicando la acción de actualización:", error);
}
}
});
// Procesar eliminación
deleteCheckboxes.forEach(cb => {
const index = cb.dataset.index;
const placeId = document.querySelector(`.new-name-input[data-index="${index}"]`)?.getAttribute("data-place-id");
const place = W.model.venues.getObjectById(placeId);
if (!place) {
console.warn(`No se encontró el lugar con ID para eliminar: ${placeId}`);
return;
}
try {
const DeleteObject = require("Waze/Action/DeleteObject");
const deleteAction = new DeleteObject(place);
W.model.actionManager.add(deleteAction);
console.log(`Lugar eliminado: ${placeId}`);
changesMade = true;
} catch (error) {
console.error("Error eliminando el lugar:", error);
}
});
if (changesMade) {
alert("Cambios aplicados correctamente.");
} else {
alert("No se realizaron cambios.");
}
// Cerrar el panel flotante
const panel = document.getElementById("normalizer-floating-panel");
if (panel) panel.remove();
}
//**************************************************************************
//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: normalizePlaceName
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: name (string) - Nombre del lugar
//Salidas: texto normalizado (string)
//Descripción: Normaliza un nombre aplicando capitalización, manejo de artículos, números y paréntesis.
//**************************************************************************
function normalizePlaceName(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 la palabra es un número, no la analizamos
if (/^\d+$/.test(word)) return word;
// Si la palabra está en la lista de exclusión, no la modificamos
if (excludeWords.some(w => w.toLowerCase() === lowerWord)) return word;
// Si es un número romano, lo dejamos en mayúsculas
if (isRoman(word)) return word.toUpperCase();
// Si es una sigla con estructura T&T o a&A, convertirla a mayúsculas
if (/^[A-Za-z]&[A-Za-z]$/.test(word)) return word.toUpperCase();
// Si es una sigla con apóstrofe como "E's", también la dejamos igual
if (/^[A-Z]'[A-Z][a-z]+$/.test(word)) return word;
// Si no se deben normalizar artículos y es un artículo, lo dejamos en minúsculas (excepto la primera palabra)
if (!normalizeArticles && articles.includes(lowerWord) && index !== 0) return lowerWord;
// Si es un número seguido de letras, lo dejamos igual
if (/^\d+[A-Z][a-zA-Z]*$/.test(word)) return word;
// Si está entre paréntesis y es todo mayúsculas o minúsculas, lo dejamos igual
if (/^\(.*\)$/.test(word)) {
const inner = word.slice(1, -1);
if (inner === inner.toUpperCase() || inner === inner.toLowerCase()) return word;
}
// Capitalizamos la palabra (primera letra en mayúscula, el resto en minúscula)
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());
return newName.replace(/\s{2,}/g, " ").trim();
}
// Para exponer al contexto global real desde Tampermonkey
unsafeWindow.normalizePlaceName = normalizePlaceName;
//**************************************************************************
//Nombre: openFloatingPanel
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: placesToNormalize (array de lugares con nombre original y sugerencias)
//Salidas: Panel flotante con opciones de normalización y eliminación
//Prerrequisitos si existen: Debe haberse definido normalizePlaceName y cargado excludeWords correctamente
//Descripción: Crea un panel interactivo donde se presentan los lugares que requieren cambios,
// permitiendo su corrección o eliminación. Solo muestra lugares que requieren cambio
// y errores ortográficos verdaderos.
//**************************************************************************
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%);
background: white;
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
z-index: 1000;
max-height: 80vh;
overflow-y: auto;
min-width: 800px;
`;
let panelContent = `
<style>
#normalizer-table { width: 100%; border-collapse: collapse; }
#normalizer-table th, #normalizer-table td { padding: 8px; border: 1px solid #ddd; }
#normalizer-table tr:nth-child(even) { background-color: #f9f9f9; }
.warning-row { background-color: #fff3cd !important; }
.close-btn {
position: absolute;
top: 10px;
right: 10px;
background: #ccc;
color: black;
border: none;
border-radius: 4px;
width: 25px;
height: 25px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
text-align: center;
line-height: 25px;
transition: background-color 0.3s ease;
}
.close-btn:hover {
background: #bbb;
}
.footer-buttons {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 20px;
}
</style>
<button class="close-btn" id="close-panel-btn">X</button>
<h3>Places to Normalize</h3>
<table id="normalizer-table">
<thead>
<tr>
<th>Normalizar</th>
<th>Eliminar</th>
<th>Estado</th>
<th>Nombre Original</th>
<th>Nombre Sugerido</th>
<th>Corrección</th>
<th>Acción</th>
</tr>
</thead>
<tbody>
`;
// Procesar cada lugar y sus advertencias
placesToNormalize.forEach((place, index) => {
const { originalName, newName, spellingWarnings = [] } = place;
// Crear una fila por cada advertencia ortográfica
spellingWarnings.forEach((warning, warningIndex) => {
const suggestionId = `suggestion-${index}-${warningIndex}`;
panelContent += `
<tr class="warning-row">
<td style="text-align: center;">
<input type="checkbox" class="normalize-checkbox"
data-index="${index}"
data-warning-index="${warningIndex}"
data-suggestion-id="${suggestionId}">
</td>
<td style="text-align: center;">
<input type="checkbox" class="delete-checkbox"
data-index="${index}"
data-warning-index="${warningIndex}">
</td>
<td style="text-align: center;">⚠️</td>
<td id="name-cell-${index}-${warningIndex}">${originalName}</td>
<td>
<input type="text" class="new-name-input"
data-index="${index}"
data-warning-index="${warningIndex}"
data-place-id="${place.id}"
data-suggestion-id="${suggestionId}"
value="${newName}">
</td>
<td>${warning.original} → ${warning.sugerida} (${warning.tipo})</td>
<td style="text-align: center;">
<button class="apply-suggestion-btn"
data-index="${index}"
data-warning-index="${warningIndex}"
title="Corregir ortografía de la palabra"
style="background-color: #4CAF50; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">
Fix
</button>
<button class="add-exclude-btn"
data-word="${warning.original}"
title="Adicionar palabra excluida nueva"
style="background-color: #007bff; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">
Add
</button>
</td>
</tr>`;
});
});
panelContent += `
</tbody>
</table>
<div class="footer-buttons">
<button class="apply-changes-btn" id="apply-changes-btn">✔️ Apply Changes</button>
<button class="cancel-btn" id="cancel-btn">❌ Cancel</button>
</div>
`;
panel.innerHTML = panelContent;
document.body.appendChild(panel);
// Eventos para los botones
document.getElementById('close-panel-btn').addEventListener('click', () => {
panel.remove(); // Cerrar el panel flotante
});
document.getElementById('apply-changes-btn').addEventListener('click', () => {
window.applyNormalization(); // Llamar a la función global para aplicar cambios
});
document.getElementById('cancel-btn').addEventListener('click', () => {
panel.remove(); // Cerrar el panel flotante
});
// Asignar eventos a los botones "Fix"
document.querySelectorAll('.apply-suggestion-btn').forEach(button => {
button.addEventListener('click', function () {
const index = this.dataset.index;
const warningIndex = this.dataset.warningIndex;
const input = document.querySelector(
`.new-name-input[data-index="${index}"][data-warning-index="${warningIndex}"]`
);
const checkbox = document.querySelector(
`.normalize-checkbox[data-index="${index}"][data-warning-index="${warningIndex}"]`
);
if (input && checkbox) {
// Aplicar la corrección ortográfica al campo de entrada
const warning = placesToNormalize[index].spellingWarnings[warningIndex];
input.value = input.value.replace(warning.original, warning.sugerida);
checkbox.checked = true; // Marcar el checkbox
}
});
});
// Asignar eventos a los botones "Add Excld Word"
document.querySelectorAll('.add-exclude-btn').forEach(button => {
button.addEventListener('click', function () {
const word = this.dataset.word;
// Verificar si es una sigla con formato X&X
if (/^[A-Za-z]&[A-Za-z]$/.test(word)) {
alert("⚠️ No es necesario adicionar palabras excluidas que tengan '&'.");
return;
}
if (!excludeWords.includes(word)) {
excludeWords.push(word);
excludeWords.sort((a, b) => a.localeCompare(b)); // Ordenar alfabéticamente
localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
renderExcludedWordsPanel(); // Actualizar el panel lateral
// Mostrar popup en el centro del panel flotante
const panel = document.getElementById("normalizer-floating-panel");
const popup = document.createElement('div');
popup.textContent = `✅ The Word "${word}" has been added to the exclusion list.`;
popup.style.position = 'absolute';
popup.style.top = '50%';
popup.style.left = '50%';
popup.style.transform = 'translate(-50%, -50%)';
popup.style.backgroundColor = '#4CAF50';
popup.style.color = 'white';
popup.style.padding = '10px 20px';
popup.style.borderRadius = '5px';
popup.style.zIndex = '10000';
popup.style.opacity = '1';
popup.style.transition = 'opacity 1s ease-in-out';
panel.appendChild(popup);
setTimeout(() => {
popup.style.opacity = '0';
setTimeout(() => panel.removeChild(popup), 1000);
}, 2000);
// Bloquear el botón y cambiar su texto
this.textContent = 'Added';
this.disabled = true;
this.style.backgroundColor = '#6c757d'; // Cambiar color a gris
this.style.cursor = 'not-allowed';
} else {
alert(`⚠️ The word "${word}" is already on the exclusion list.`);
}
});
});
}
//**************************************************************************
//Nombre: loadExcludeWordsFromXML
//Fecha modificación:
//Autor: mincho77
//Entradas:
// - callback: función opcional que se ejecuta una vez se cargan y procesan las palabras excluidas.
//Salidas: ninguna directa. Actualiza la variable global `excludeWords`.
//Prerrequisitos:
// - Debe existir un archivo llamado 'excludeWords.xml' accesible por fetch.
// - Debe estar definida la variable global `excludeWords`.
//Descripción:
// Carga un archivo XML que contiene una lista de palabras excluidas.
// Combina las palabras nuevas con las que ya están guardadas en localStorage
// y actualiza la lista global `excludeWords`. Si el XML no puede ser cargado,
// se usa únicamente el contenido almacenado localmente como respaldo.
//**************************************************************************
function loadExcludeWordsFromXML(callback) {
fetch("excludeWords.xml")
.then(response => response.text()) // Corrected the syntax here
.then(xmlText => {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlText, "text/xml");
const wordNodes = xmlDoc.getElementsByTagName("word");
const wordsFromXML = Array.from(wordNodes).map(node => node.textContent.trim());
const existing = JSON.parse(localStorage.getItem("excludeWords")) || [];
excludeWords = [...new Set([...existing, ...wordsFromXML])].sort((a, b) => a.localeCompare(b));
localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
if (callback) callback();
})
.catch(() => {
console.warn("⚠️ No se pudo cargar excludeWords.xml. Solo se usará localStorage.");
excludeWords = JSON.parse(localStorage.getItem("excludeWords")) || ["EDS", "IPS", "McDonald's", "EPS"];
localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
if (callback) callback();
});
}
function exportExcludeWordsToXML() {
const xmlContent = `<?xml version="1.0" encoding="UTF-8"?>
<ExcludedWords>
${excludeWords.map(word => ` <word>${word}</word>`).join("\n")}
</ExcludedWords>`;
const blob = new Blob([xmlContent], { type: "application/xml" });
const url = URL.createObjectURL(blob); // ✅ ESTA LÍNEA ESTABA FALTANDO
const link = document.createElement("a");
link.href = url;
link.download = "excludeWords.xml";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function showFloatingMessage(message) {
const msg = document.createElement("div");
msg.textContent = message;
msg.style.position = "fixed";
msg.style.bottom = "30px";
msg.style.left = "50%";
msg.style.transform = "translateX(-50%)";
msg.style.backgroundColor = "#333";
msg.style.color = "#fff";
msg.style.padding = "10px 20px";
msg.style.borderRadius = "5px";
msg.style.zIndex = 9999;
msg.style.opacity = "0.95";
msg.style.transition = "opacity 1s ease-in-out";
document.body.appendChild(msg);
setTimeout(() => {
msg.style.opacity = "0";
setTimeout(() => document.body.removeChild(msg), 1000);
}, 3000);
}
//**************************************************************************
//Nombre: waitForWME
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: Ninguna
//Salidas: Ninguna
//Prerrequisitos si existen:
// - El objeto global W debe estar disponible.
// - Deben estar definidas las funciones: initializeExcludeWords, createSidebarTab y renderExcludedWordsPanel.
//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 desde localStorage,
// crea el tab lateral personalizado y renderiza la lista visual de palabras excluidas.
// Si WME aún no está listo, vuelve a intentar cada 1000 ms.
//**************************************************************************
function waitForWME() {
if (W && W.userscripts && W.model && W.model.venues) {
console.log(`[${SCRIPT_NAME}] Inicializando v${VERSION}`);
initializeExcludeWords(); // ⚠️ Usa solo localStorage
createSidebarTab();
waitForDOM("#normalizer-tab", () => {
console.log("[waitForWME] 🧩 Sidebar listo, renderizando palabras excluidas");
renderExcludedWordsPanel(); // Muestra las palabras
setupDragAndDropImport(); // Activa drag & drop
//Evita que se abra el archivo si cae fuera del área
// window.addEventListener("dragover", e => e.preventDefault(), false);
// window.addEventListener("drop", e => e.preventDefault(), false);
});
} else {
console.log(`[${SCRIPT_NAME}] Esperando que WME esté listo...`);
setTimeout(waitForWME, 1000);
}
}
//**************************************************************************
//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 limpia los event listeners asociados al panel flotante de normalización.
// Lo hace clonando el nodo del panel y reemplazándolo en el DOM, lo cual elimina todos
// los listeners previamente 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 (unsafeWindow)
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas:
// - name (string): el nombre original del lugar.
//Salidas:
// - string: nombre normalizado, respetando exclusiones y opciones del usuario.
//Prerrequisitos si existen:
// - Debe estar cargada la lista global excludeWords.
// - Debe existir un checkbox con id “normalizeArticles” para definir si se normalizan artículos.
//Descripción:
// Esta versión expuesta globalmente permite acceder a la normalización básica del nombre de un lugar
// desde otros contextos como el navegador o Tampermonkey. Capitaliza cada palabra, respeta las excluidas
// y no aplica normalización a artículos si el checkbox lo indica.
// Realiza limpieza básica: reemplazo de pipes, eliminación de espacios dobles y trim final.
//**************************************************************************
unsafeWindow.normalizePlaceName = function(name)
{
if (!name) return "";
const normalizeArticles = !document.getElementById("normalizeArticles")?.checked;
const articles = ["el", "la", "los", "las", "de", "del", "al", "y", "o"];
const words = name.trim().split(/\s+/);
const normalizedWords = words.map((word, index) => {
const lowerWord = word.toLowerCase();
// Saltar palabras excluidas
if (excludeWords.includes(word)) return word;
// Saltar artículos si el checkbox está activo y no es la primera palabra
if (!normalizeArticles && articles.includes(lowerWord) && index !== 0) {
return lowerWord;
}
//Mayúsculas
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
});
name = normalizedWords.join(" ");
name = name.replace(/\s*\|\s*/g, " - ");
name = name.replace(/\s{2,}/g, " ").trim();
return name;
};
//**************************************************************************
//Nombre: waitForDOM
//Fecha modificación: 2025-03-31
//Autor: mincho77
//Entradas:
// selector (string): Selector CSS del nodo a esperar.
// callback (function): Función a ejecutar cuando el nodo esté disponible.
// interval (int, opcional): Tiempo entre intentos en milisegundos. Default: 500ms.
// maxAttempts (int, opcional): Máximo de intentos antes de abortar. Default: 10.
//Salidas: Ejecuta el callback si encuentra el selector dentro del tiempo.
//Descripción:
// Esta función monitorea el DOM en intervalos constantes hasta que
// encuentra un nodo que coincida con el selector. Si lo encuentra,
// ejecuta el callback con ese nodo. Si no, muestra un error por consola.
//**************************************************************************
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 en el DOM después de ${maxAttempts} intentos.`);
}
attempts++;
}, interval);
}
//**************************************************************************
//Nombre: getSidebarHTML
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: Ninguna.
//Salidas: Retorna un string con HTML que define el contenido del panel lateral del script.
//Prerrequisitos si existen: Debe estar disponible el valor de las variables globales:
// - normalizeArticles (boolean): Define si los artículos deben ser normalizados o no.
// - maxPlaces (number): Número máximo de lugares a escanear.
//Descripción:
// Esta función construye el HTML que se inyecta en el panel lateral (sidebar) de WME.
// Incluye controles para:
// - Activar o desactivar normalización de artículos ("el", "la", etc).
// - Definir la cantidad máxima de lugares a procesar.
// - Agregar palabras excluidas manualmente.
// - Exportar palabras excluidas a un archivo XML.
// - Importar una lista de palabras excluidas desde archivo XML.
// - Disparar el escaneo de lugares para normalización.
// La lista de palabras excluidas **no se renderiza aquí directamente**, sino en el div
// con id "normalizer-sidebar" para permitir que sea manejada dinámicamente.
//**************************************************************************
function getSidebarHTML() {
return `
<div id="normalizer-tab">
<h4>Places Name Normalizer <span style="font-size:11px;">v${VERSION}</span></h4>
<div>
<input type="checkbox" id="normalizeArticles" ${normalizeArticles ? "checked" : ""}>
<label for="normalizeArticles">No Normalizar artículos (el, la, los, ...)</label>
</div>
<div>
<label>Máximo de Places a buscar: </label>
<input type='number' id='maxPlacesInput' value='${maxPlaces}' min='1' max='800' style='width: 60px;'>
</div>
<div>
<label>Palabras Excluidas:</label>
<input type='text' id='excludeWord' style='width: 120px;'>
<button id='addExcludeWord'>Add Word</button>
<div id="normalizer-sidebar" style="margin-top: 20px;"></div>
<button id="exportExcludeWords" style="margin-top: 5px;">Export Words</button>
<br>
<button id="importExcludeWordsUnifiedBtn" style="margin-top: 5px;">Import List...</button>
<input type="file" id="hiddenImportInput" accept=".xml" style="display: none;">
</div>
<div>
<input type="checkbox" id="checkOnlyTildes" checked>
<label for="checkOnlyTildes">Revisar solo tildes</label>
</div>
<div id="drop-zone" style="border: 2px dashed #ccc; border-radius: 6px; padding: 15px; margin: 15px 0; text-align: center; font-style: italic; color: #555; background-color: #f8f9fa; transition: all 0.3s ease;">
📂 Arrastra aquí tu archivo .txt o .xml para importar palabras excluidas
</div>
<hr>
<button id="scanPlaces">Scan...</button>
</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 attachEvents2()
{
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) {
alert("No hay palabras excluidas para exportar.");
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); // Correctly appends the link
link.click();
document.body.removeChild(link); // Correctly removes the 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(); // Usar renderExcludedWordsPanel en lugar de updateExcludeList
}
wordInput.value = "";
});
// ✅ Evento: nuevo botón unificado de importación
importButtonUnified.addEventListener("click", () => {
hiddenInput.click(); // abre el file input oculto
});
hiddenInput.addEventListener("change", () => {
handleImportList(); // ✅ Llama a la función handleImportList al importar
});
// ✅ Evento: escanear lugares
scanPlacesButton.addEventListener("click", scanPlaces);
}
//**************************************************************************
//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)
{
// Referencia al Place y los nombres
this.venue = venue;
this.oldName = oldName;
this.newName = newName;
// ID único del Place
this.venueId = venue.attributes.id;
// Metadatos que WME/Plugins pueden usar
this.type = "NameChangeAction";
this.isGeometryEdit = false; // no es una edición de geometría
}
/**
* 1) getActionName: nombre de la acción en el historial.
*/
NameChangeAction.prototype.getActionName = function() {
return "Update place name";
};
/** 2) getActionText: texto corto que WME a veces muestra. */
NameChangeAction.prototype.getActionText = function() {
return "Update place name";
};
/** 3) getName: algunas versiones llaman a getName(). */
NameChangeAction.prototype.getName = function() {
return "Update place name";
};
/** 4) getDescription: descripción detallada de la acción. */
NameChangeAction.prototype.getDescription = function() {
return `Place name changed from "${this.oldName}" to "${this.newName}".`;
};
/** 5) getT: título (a veces requerido por plugins). */
NameChangeAction.prototype.getT = function() {
return "Update place name";
};
/** 6) getID: si un plugin llama a e.getID(). */
NameChangeAction.prototype.getID = function() {
return `NameChangeAction-${this.venueId}`;
};
/** 7) doAction: asigna el nuevo nombre (WME llama a esto al crear la acción). */
NameChangeAction.prototype.doAction = function() {
this.venue.attributes.name = this.newName;
this.venue.isDirty = true;
if (typeof W.model.venues.markObjectEdited === "function") {
W.model.venues.markObjectEdited(this.venue);
}
};
/** 8) undoAction: revertir al nombre anterior (Ctrl+Z). */
NameChangeAction.prototype.undoAction = function() {
this.venue.attributes.name = this.oldName;
this.venue.isDirty = true;
if (typeof W.model.venues.markObjectEdited === "function") {
W.model.venues.markObjectEdited(this.venue);
}
};
/** 9) redoAction: rehacer (Ctrl+Shift+Z), vuelve a doAction. */
NameChangeAction.prototype.redoAction = function() {
return this.doAction();
};
/** 10) undoSupported / redoSupported: indica si se puede des/rehacer. */
NameChangeAction.prototype.undoSupported = function() {
return true;
};
NameChangeAction.prototype.redoSupported = function() {
return true;
};
/** 11) accept / supersede: evita fusionar con otras acciones. */
NameChangeAction.prototype.accept = function() {
return false;
};
NameChangeAction.prototype.supersede = function() {
return false;
};
/** 12) isEditAction: true => habilita "Guardar". */
NameChangeAction.prototype.isEditAction = function() {
return true;
};
/** 13) getAffectedUniqueIds: objetos que se alteran. */
NameChangeAction.prototype.getAffectedUniqueIds = function() {
return [this.venueId];
};
/** 14) isSerializable: si no implementas serialize(), pon false. */
NameChangeAction.prototype.isSerializable = function() {
return false;
};
/** 15) isActionStackable: false => no combina con otras ediciones. */
NameChangeAction.prototype.isActionStackable = function() {
return false;
};
/** 16) getFocusFeatures: WME/Plugins pueden usarlo para "enfocar" el objeto. */
NameChangeAction.prototype.getFocusFeatures = function() {
// Devolvemos el venue para indicar que ese es el foco (o un array vacío si prefieres).
return [this.venue];
};
/** 17) Métodos vacíos para evitar futuros "no es una función" si WME pide estos. */
NameChangeAction.prototype.getFocusSegments = function() {
return [];
};
NameChangeAction.prototype.getFocusNodes = function() {
return [];
};
NameChangeAction.prototype.getFocusClosures = function() {
return [];
};
/** 18) getTimestamp: método nuevo que WME/Plugins están llamando. */
NameChangeAction.prototype.getTimestamp = function() {
// Devolvemos un timestamp numérico (ms desde época UNIX).
return Date.now();
};
//**************************************************************************
//Nombre: openFloatingPanel
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: placesToNormalize (array de lugares con nombre original y sugerencias)
//Salidas: Panel flotante con opciones de normalización y eliminación
//Prerrequisitos si existen: Debe haberse definido normalizePlaceName y cargado excludeWords correctamente
//Descripción: Crea un panel interactivo donde se presentan los lugares que requieren cambios,
// permitiendo su corrección o eliminación. Solo muestra lugares que requieren cambio
// y errores ortográficos verdaderos.
//**************************************************************************
function openFloatingPanel2(placesToNormalize) {
const panel = document.createElement("div");
panel.id = "normalizer-floating-panel";
panel.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
z-index: 1000;
max-height: 80vh;
overflow-y: auto;
min-width: 800px;
`;
let panelContent = `
<style>
#normalizer-table { width: 100%; border-collapse: collapse; }
#normalizer-table th, #normalizer-table td { padding: 8px; border: 1px solid #ddd; }
#normalizer-table tr:nth-child(even) { background-color: #f9f9f9; }
.warning-row { background-color: #fff3cd !important; }
.close-btn {
position: absolute;
top: 10px;
right: 10px;
background: #ccc;
color: black;
border: none;
border-radius: 4px;
width: 25px;
height: 25px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
text-align: center;
line-height: 25px;
transition: background-color 0.3s ease;
}
.close-btn:hover {
background: #bbb;
}
.footer-buttons {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 20px;
}
</style>
<button class="close-btn" id="close-panel-btn">X</button>
<h3>Places to Normalize</h3>
<table id="normalizer-table">
<thead>
<tr>
<th>Normalizar</th>
<th>Eliminar</th>
<th>Estado</th>
<th>Nombre Original</th>
<th>Nombre Sugerido</th>
<th>Corrección</th>
<th>Acción</th>
</tr>
</thead>
<tbody>
`;
// Procesar cada lugar y sus advertencias
placesToNormalize.forEach((place, index) => {
const { originalName, newName, spellingWarnings = [] } = place;
// Crear una fila por cada advertencia ortográfica
spellingWarnings.forEach((warning, warningIndex) => {
const suggestionId = `suggestion-${index}-${warningIndex}`;
panelContent += `
<tr class="warning-row">
<td style="text-align: center;">
<input type="checkbox" class="normalize-checkbox"
data-index="${index}"
data-warning-index="${warningIndex}"
data-suggestion-id="${suggestionId}">
</td>
<td style="text-align: center;">
<input type="checkbox" class="delete-checkbox"
data-index="${index}"
data-warning-index="${warningIndex}">
</td>
<td style="text-align: center;">⚠️</td>
<td id="name-cell-${index}-${warningIndex}">${originalName}</td>
<td>
<input type="text" class="new-name-input"
data-index="${index}"
data-warning-index="${warningIndex}"
data-place-id="${place.id}"
data-suggestion-id="${suggestionId}"
value="${newName}">
</td>
<td>${warning.original} → ${warning.sugerida} (${warning.tipo})</td>
<td style="text-align: center;">
<button class="apply-suggestion-btn"
data-index="${index}"
data-warning-index="${warningIndex}"
title="Corregir ortografía de la palabra"
style="background-color: #4CAF50; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">
Fix
</button>
<button class="add-exclude-btn"
data-word="${warning.original}"
title="Adicionar palabra excluida nueva"
style="background-color: #007bff; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">
Add Excld
</button>
</td>
</tr>`;
});
});
panelContent += `
</tbody>
</table>
<div class="footer-buttons">
<button class="apply-changes-btn" id="apply-changes-btn">✔️ Apply Changes</button>
<button class="cancel-btn" id="cancel-btn">❌ Cancel</button>
</div>
`;
panel.innerHTML = panelContent;
document.body.appendChild(panel);
// Eventos para los botones
document.getElementById('close-panel-btn').addEventListener('click', () => {
panel.remove(); // Cerrar el panel flotante
});
document.getElementById('apply-changes-btn').addEventListener('click', () => {
window.applyNormalization(); // Llamar a la función global para aplicar cambios
});
document.getElementById('cancel-btn').addEventListener('click', () => {
panel.remove(); // Cerrar el panel flotante
});
// Asignar eventos a los botones "Fix"
document.querySelectorAll('.apply-suggestion-btn').forEach(button => {
button.addEventListener('click', function () {
const index = this.dataset.index;
const warningIndex = this.dataset.warningIndex;
const input = document.querySelector(
`.new-name-input[data-index="${index}"][data-warning-index="${warningIndex}"]`
);
const checkbox = document.querySelector(
`.normalize-checkbox[data-index="${index}"][data-warning-index="${warningIndex}"]`
);
if (input && checkbox) {
const warning = placesToNormalize[index].spellingWarnings[warningIndex];
input.value = input.value.replace(warning.original, warning.sugerida || warning.original);
checkbox.checked = true; // Marcar el checkbox
}
});
});
// Asignar eventos a los botones "Add Excld Word"
document.querySelectorAll('.add-exclude-btn').forEach(button => {
button.addEventListener('click', function () {
const word = this.dataset.word;
if (!excludeWords.includes(word)) {
excludeWords.push(word);
excludeWords.sort((a, b) => a.localeCompare(b)); // Ordenar alfabéticamente
localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
renderExcludedWordsPanel(); // Actualizar el panel lateral
// Mostrar popup en el centro del panel flotante
const panel = document.getElementById("normalizer-floating-panel");
const popup = document.createElement('div');
popup.textContent = `✅ The Word "${word}" has been added to the exclusion list.`;
popup.style.position = 'absolute';
popup.style.top = '50%';
popup.style.left = '50%';
popup.style.transform = 'translate(-50%, -50%)';
popup.style.backgroundColor = '#4CAF50';
popup.style.color = 'white';
popup.style.padding = '10px 20px';
popup.style.borderRadius = '5px';
popup.style.zIndex = '10000';
popup.style.opacity = '1';
popup.style.transition = 'opacity 1s ease-in-out';
panel.appendChild(popup);
setTimeout(() => {
popup.style.opacity = '0';
setTimeout(() => panel.removeChild(popup), 1000);
}, 2000);
// Bloquear el botón y cambiar su texto
this.textContent = 'Excluded Word Added';
this.disabled = true;
this.style.backgroundColor = '#6c757d'; // Cambiar color a gris
this.style.cursor = 'not-allowed';
} else {
alert(`⚠️ The word "${word}" is already on the exclusion list.`);
}
});
});
}
//**************************************************************************
//Nombre: waitForWME
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: Ninguna
//Salidas: Ninguna
//Prerrequisitos si existen:
// - El objeto global W debe estar disponible.
// - Deben estar definidas las funciones: initializeExcludeWords, createSidebarTab y renderExcludedWordsPanel.
//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 desde localStorage,
// crea el tab lateral personalizado y renderiza la lista visual de palabras excluidas.
// Si WME aún no está listo, vuelve a intentar cada 1000 ms.
//**************************************************************************
function waitForWME2() {
if (W && W.userscripts && W.model && W.model.venues) {
console.log(`[${SCRIPT_NAME}] Inicializando v${VERSION}`);
initializeExcludeWords(); // ⚠️ Usa solo localStorage
createSidebarTab();
waitForDOM("#normalizer-tab", () => {
console.log("[waitForWME] 🧩 Sidebar listo, renderizando palabras excluidas");
renderExcludedWordsPanel(); // Muestra las palabras
setupDragAndDropImport(); // Activa drag & drop
//Evita que se abra el archivo si cae fuera del área
// window.addEventListener("dragover", e => e.preventDefault(), false);
// window.addEventListener("drop", e => e.preventDefault(), false);
});
} else {
console.log(`[${SCRIPT_NAME}] Esperando que WME esté listo...`);
setTimeout(waitForWME, 1000);
}
}
//**************************************************************************
//Nombre: init
//Fecha modificación: 2025-03-30
//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. Si WME aún no está listo,
// reintenta la inicialización cada 1000 ms. Una vez disponible, inicializa la lista de palabras
// excluidas, crea el tab lateral personalizado, espera a que el DOM del tab esté listo para
// renderizar el contenido (palabras excluidas y funcionalidad drag & drop), y expone globalmente
// las funciones applyNormalization y normalizePlaceName para uso externo.
//**************************************************************************
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, renderizando palabras excluidas");
renderExcludedWordsPanel();
setupDragAndDropImport();
});
window.applyNormalization = applyNormalization;
window.normalizePlaceName = normalizePlaceName;
}
init();
} catch (e) {
console.error('[PlacesNameNormalizer] Fatal initialization error:', e);
}
})();
//**************************************************************************
//Nombre: applyNormalization
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: Ninguna
//Salidas: Ninguna
//Prerrequisitos si existen:
// - Debe existir un elemento en el DOM con las clases .normalize-checkbox, .delete-checkbox y .new-name-input.
// - El objeto global W debe estar disponible, incluyendo W.model.venues, W.model.actionManager y W.controller.
// - Deben estar definidos los módulos "Waze/Action/UpdateObject" y "Waze/Action/DeleteObject" (accesibles mediante require()).
// - Debe existir la variable placesToNormalize, que contiene datos de los lugares a normalizar, incluyendo sugerencias ortográficas.
//Descripción:
// Esta función aplica la normalización y/o eliminación de nombres de lugares en el Waze Map Editor
// según las selecciones realizadas en el panel flotante. Primero, obtiene los checkboxes seleccionados
// para normalización y eliminación. Si no hay ningún elemento seleccionado, se informa y se cancela la operación.
// Si se han seleccionado TODOS los checkboxes de eliminación, se solicita una confirmación adicional.
// Para cada checkbox de normalización seleccionado, se verifica si se debe aplicar la sugerencia ortográfica
// (cuando se ha hecho clic en el botón correspondiente) o el nombre completo modificado, y se actualiza el lugar
// mediante la acción de actualización. Posteriormente, se procesan los checkboxes de eliminación aplicando la
// acción de eliminación a los lugares correspondientes. Si se realizaron cambios, se marca el modelo como modificado.
// Finalmente, se cierra el panel flotante.
//**************************************************************************
function applyNormalization() {
const normalizeCheckboxes = document.querySelectorAll(".normalize-checkbox:checked");
const deleteCheckboxes = document.querySelectorAll(".delete-checkbox:checked");
let changesMade = false;
if (normalizeCheckboxes.length === 0 && deleteCheckboxes.length === 0) {
alert("No hay lugares seleccionados para normalizar o eliminar.");
return;
}
// Procesar normalización
normalizeCheckboxes.forEach(cb => {
const index = cb.dataset.index;
const input = document.querySelector(`.new-name-input[data-index="${index}"]`);
const newName = input?.value?.trim();
const placeId = input?.getAttribute("data-place-id");
const place = W.model.venues.getObjectById(placeId);
if (!place || !place.attributes?.name) {
console.warn(`No se encontró el lugar con ID: ${placeId}`);
return;
}
const currentName = place.attributes.name.trim();
if (currentName !== newName) {
try {
const UpdateObject = require("Waze/Action/UpdateObject");
const action = new UpdateObject(place, { name: newName });
W.model.actionManager.add(action);
console.log(`Nombre actualizado: "${currentName}" → "${newName}"`);
changesMade = true;
} catch (error) {
console.error("Error aplicando la acción de actualización:", error);
}
}
});
// Procesar eliminación
deleteCheckboxes.forEach(cb => {
const index = cb.dataset.index;
const placeId = document.querySelector(`.new-name-input[data-index="${index}"]`)?.getAttribute("data-place-id");
const place = W.model.venues.getObjectById(placeId);
if (!place) {
console.warn(`No se encontró el lugar con ID para eliminar: ${placeId}`);
return;
}
try {
const DeleteObject = require("Waze/Action/DeleteObject");
const deleteAction = new DeleteObject(place);
W.model.actionManager.add(deleteAction);
console.log(`Lugar eliminado: ${placeId}`);
changesMade = true;
} catch (error) {
console.error("Error eliminando el lugar:", error);
}
});
if (changesMade) {
alert("Cambios aplicados correctamente.");
} else {
alert("No se realizaron cambios.");
}
// Cerrar el panel flotante
const panel = document.getElementById("normalizer-floating-panel");
if (panel) panel.remove();
}
//**************************************************************************
//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: normalizePlaceName
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: name (string) - Nombre del lugar
//Salidas: texto normalizado (string)
//Descripción: Normaliza un nombre aplicando capitalización, manejo de artículos, números y paréntesis.
//**************************************************************************
function normalizePlaceName(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 la palabra es un número, no la analizamos
if (/^\d+$/.test(word)) return word;
// Si la palabra está en la lista de exclusión, no la modificamos
if (excludeWords.some(w => w.toLowerCase() === lowerWord)) return word;
// Si es un número romano, lo dejamos en mayúsculas
if (isRoman(word)) return word.toUpperCase();
// Si es una sigla con estructura T&T o a&A, convertirla a mayúsculas
if (/^[A-Za-z]&[A-Za-z]$/.test(word)) return word.toUpperCase();
// Si es una sigla con apóstrofe como "E's", también la dejamos igual
if (/^[A-Z]'[A-Z][a-z]+$/.test(word)) return word;
// Si no se deben normalizar artículos y es un artículo, lo dejamos en minúsculas (excepto la primera palabra)
if (!normalizeArticles && articles.includes(lowerWord) && index !== 0) return lowerWord;
// Si es un número seguido de letras, lo dejamos igual
if (/^\d+[A-Z][a-zA-Z]*$/.test(word)) return word;
// Si está entre paréntesis y es todo mayúsculas o minúsculas, lo dejamos igual
if (/^\(.*\)$/.test(word)) {
const inner = word.slice(1, -1);
if (inner === inner.toUpperCase() || inner === inner.toLowerCase()) return word;
}
// Capitalizamos la palabra (primera letra en mayúscula, el resto en minúscula)
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());
return newName.replace(/\s{2,}/g, " ").trim();
}
// Para exponer al contexto global real desde Tampermonkey
unsafeWindow.normalizePlaceName = normalizePlaceName;
//**************************************************************************
//Nombre: openFloatingPanel
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: placesToNormalize (array de lugares con nombre original y sugerencias)
//Salidas: Panel flotante con opciones de normalización y eliminación
//Prerrequisitos si existen: Debe haberse definido normalizePlaceName y cargado excludeWords correctamente
//Descripción: Crea un panel interactivo donde se presentan los lugares que requieren cambios,
// permitiendo su corrección o eliminación. Solo muestra lugares que requieren cambio
// y errores ortográficos verdaderos.
//**************************************************************************
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%);
background: white;
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
z-index: 1000;
max-height: 80vh;
overflow-y: auto;
min-width: 800px;
`;
let panelContent = `
<style>
#normalizer-table { width: 100%; border-collapse: collapse; }
#normalizer-table th, #normalizer-table td { padding: 8px; border: 1px solid #ddd; }
#normalizer-table tr:nth-child(even) { background-color: #f9f9f9; }
.warning-row { background-color: #fff3cd !important; }
.close-btn {
position: absolute;
top: 10px;
right: 10px;
background: #ccc;
color: black;
border: none;
border-radius: 4px;
width: 25px;
height: 25px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
text-align: center;
line-height: 25px;
transition: background-color 0.3s ease;
}
.close-btn:hover {
background: #bbb;
}
.footer-buttons {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 20px;
}
</style>
<button class="close-btn" id="close-panel-btn">X</button>
<h3>Places to Normalize</h3>
<table id="normalizer-table">
<thead>
<tr>
<th>Normalizar</th>
<th>Eliminar</th>
<th>Estado</th>
<th>Nombre Original</th>
<th>Nombre Sugerido</th>
<th>Corrección</th>
<th>Acción</th>
</tr>
</thead>
<tbody>
`;
// Procesar cada lugar y sus advertencias
placesToNormalize.forEach((place, index) => {
const { originalName, newName, spellingWarnings = [] } = place;
// Crear una fila por cada advertencia ortográfica
spellingWarnings.forEach((warning, warningIndex) => {
const suggestionId = `suggestion-${index}-${warningIndex}`;
panelContent += `
<tr class="warning-row">
<td style="text-align: center;">
<input type="checkbox" class="normalize-checkbox"
data-index="${index}"
data-warning-index="${warningIndex}"
data-suggestion-id="${suggestionId}">
</td>
<td style="text-align: center;">
<input type="checkbox" class="delete-checkbox"
data-index="${index}"
data-warning-index="${warningIndex}">
</td>
<td style="text-align: center;">⚠️</td>
<td id="name-cell-${index}-${warningIndex}">${originalName}</td>
<td>
<input type="text" class="new-name-input"
data-index="${index}"
data-warning-index="${warningIndex}"
data-place-id="${place.id}"
data-suggestion-id="${suggestionId}"
value="${newName}">
</td>
<td>${warning.original} → ${warning.sugerida} (${warning.tipo})</td>
<td style="text-align: center;">
<button class="apply-suggestion-btn"
data-index="${index}"
data-warning-index="${warningIndex}"
title="Corregir ortografía de la palabra"
style="background-color: #4CAF50; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">
Fix
</button>
<button class="add-exclude-btn"
data-word="${warning.original}"
title="Adicionar palabra excluida nueva"
style="background-color: #007bff; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">
Add
</button>
</td>
</tr>`;
});
});
panelContent += `
</tbody>
</table>
<div class="footer-buttons">
<button class="apply-changes-btn" id="apply-changes-btn">✔️ Apply Changes</button>
<button class="cancel-btn" id="cancel-btn">❌ Cancel</button>
</div>
`;
panel.innerHTML = panelContent;
document.body.appendChild(panel);
// Eventos para los botones
document.getElementById('close-panel-btn').addEventListener('click', () => {
panel.remove(); // Cerrar el panel flotante
});
document.getElementById('apply-changes-btn').addEventListener('click', () => {
window.applyNormalization(); // Llamar a la función global para aplicar cambios
});
document.getElementById('cancel-btn').addEventListener('click', () => {
panel.remove(); // Cerrar el panel flotante
});
// Asignar eventos a los botones "Fix"
document.querySelectorAll('.apply-suggestion-btn').forEach(button => {
button.addEventListener('click', function () {
const index = this.dataset.index;
const warningIndex = this.dataset.warningIndex;
const input = document.querySelector(
`.new-name-input[data-index="${index}"][data-warning-index="${warningIndex}"]`
);
const checkbox = document.querySelector(
`.normalize-checkbox[data-index="${index}"][data-warning-index="${warningIndex}"]`
);
if (input && checkbox) {
// Aplicar la corrección ortográfica al campo de entrada
const warning = placesToNormalize[index].spellingWarnings[warningIndex];
input.value = input.value.replace(warning.original, warning.sugerida);
checkbox.checked = true; // Marcar el checkbox
}
});
});
// Asignar eventos a los botones "Add Excld Word"
document.querySelectorAll('.add-exclude-btn').forEach(button => {
button.addEventListener('click', function () {
const word = this.dataset.word;
// Verificar si es una sigla con formato X&X
if (/^[A-Za-z]&[A-Za-z]$/.test(word)) {
alert("⚠️ No es necesario adicionar palabras excluidas que tengan '&'.");
return;
}
if (!excludeWords.includes(word)) {
excludeWords.push(word);
excludeWords.sort((a, b) => a.localeCompare(b)); // Ordenar alfabéticamente
localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
renderExcludedWordsPanel(); // Actualizar el panel lateral
// Mostrar popup en el centro del panel flotante
const panel = document.getElementById("normalizer-floating-panel");
const popup = document.createElement('div');
popup.textContent = `✅ The Word "${word}" has been added to the exclusion list.`;
popup.style.position = 'absolute';
popup.style.top = '50%';
popup.style.left = '50%';
popup.style.transform = 'translate(-50%, -50%)';
popup.style.backgroundColor = '#4CAF50';
popup.style.color = 'white';
popup.style.padding = '10px 20px';
popup.style.borderRadius = '5px';
popup.style.zIndex = '10000';
popup.style.opacity = '1';
popup.style.transition = 'opacity 1s ease-in-out';
panel.appendChild(popup);
setTimeout(() => {
popup.style.opacity = '0';
setTimeout(() => panel.removeChild(popup), 1000);
}, 2000);
// Bloquear el botón y cambiar su texto
this.textContent = 'Added';
this.disabled = true;
this.style.backgroundColor = '#6c757d'; // Cambiar color a gris
this.style.cursor = 'not-allowed';
} else {
alert(`⚠️ The word "${word}" is already on the exclusion list.`);
}
});
});
}
//**************************************************************************
//Nombre: loadExcludeWordsFromXML
//Fecha modificación:
//Autor: mincho77
//Entradas:
// - callback: función opcional que se ejecuta una vez se cargan y procesan las palabras excluidas.
//Salidas: ninguna directa. Actualiza la variable global `excludeWords`.
//Prerrequisitos:
// - Debe existir un archivo llamado 'excludeWords.xml' accesible por fetch.
// - Debe estar definida la variable global `excludeWords`.
//Descripción:
// Carga un archivo XML que contiene una lista de palabras excluidas.
// Combina las palabras nuevas con las que ya están guardadas en localStorage
// y actualiza la lista global `excludeWords`. Si el XML no puede ser cargado,
// se usa únicamente el contenido almacenado localmente como respaldo.
//**************************************************************************
function loadExcludeWordsFromXML(callback) {
fetch("excludeWords.xml")
.then(response => response.text()) // Corrected the syntax here
.then(xmlText => {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlText, "text/xml");
const wordNodes = xmlDoc.getElementsByTagName("word");
const wordsFromXML = Array.from(wordNodes).map(node => node.textContent.trim());
const existing = JSON.parse(localStorage.getItem("excludeWords")) || [];
excludeWords = [...new Set([...existing, ...wordsFromXML])].sort((a, b) => a.localeCompare(b));
localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
if (callback) callback();
})
.catch(() => {
console.warn("⚠️ No se pudo cargar excludeWords.xml. Solo se usará localStorage.");
excludeWords = JSON.parse(localStorage.getItem("excludeWords")) || ["EDS", "IPS", "McDonald's", "EPS"];
localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
if (callback) callback();
});
}
function exportExcludeWordsToXML() {
const xmlContent = `<?xml version="1.0" encoding="UTF-8"?>
<ExcludedWords>
${excludeWords.map(word => ` <word>${word}</word>`).join("\n")}
</ExcludedWords>`;
const blob = new Blob([xmlContent], { type: "application/xml" });
const url = URL.createObjectURL(blob); // ✅ ESTA LÍNEA ESTABA FALTANDO
const link = document.createElement("a");
link.href = url;
link.download = "excludeWords.xml";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function showFloatingMessage(message) {
const msg = document.createElement("div");
msg.textContent = message;
msg.style.position = "fixed";
msg.style.bottom = "30px";
msg.style.left = "50%";
msg.style.transform = "translateX(-50%)";
msg.style.backgroundColor = "#333";
msg.style.color = "#fff";
msg.style.padding = "10px 20px";
msg.style.borderRadius = "5px";
msg.style.zIndex = 9999;
msg.style.opacity = "0.95";
msg.style.transition = "opacity 1s ease-in-out";
document.body.appendChild(msg);
setTimeout(() => {
msg.style.opacity = "0";
setTimeout(() => document.body.removeChild(msg), 1000);
}, 3000);
}
//**************************************************************************
//Nombre: waitForWME
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: Ninguna
//Salidas: Ninguna
//Prerrequisitos si existen:
// - El objeto global W debe estar disponible.
// - Deben estar definidas las funciones: initializeExcludeWords, createSidebarTab y renderExcludedWordsPanel.
//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 desde localStorage,
// crea el tab lateral personalizado y renderiza la lista visual de palabras excluidas.
// Si WME aún no está listo, vuelve a intentar cada 1000 ms.
//**************************************************************************
function waitForWME() {
if (W && W.userscripts && W.model && W.model.venues) {
console.log(`[${SCRIPT_NAME}] Inicializando v${VERSION}`);
initializeExcludeWords(); // ⚠️ Usa solo localStorage
createSidebarTab();
waitForDOM("#normalizer-tab", () => {
console.log("[waitForWME] 🧩 Sidebar listo, renderizando palabras excluidas");
renderExcludedWordsPanel(); // Muestra las palabras
setupDragAndDropImport(); // Activa drag & drop
//Evita que se abra el archivo si cae fuera del área
// window.addEventListener("dragover", e => e.preventDefault(), false);
// window.addEventListener("drop", e => e.preventDefault(), false);
});
} else {
console.log(`[${SCRIPT_NAME}] Esperando que WME esté listo...`);
setTimeout(waitForWME, 1000);
}
}
//**************************************************************************
//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 limpia los event listeners asociados al panel flotante de normalización.
// Lo hace clonando el nodo del panel y reemplazándolo en el DOM, lo cual elimina todos
// los listeners previamente 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 (unsafeWindow)
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas:
// - name (string): el nombre original del lugar.
//Salidas:
// - string: nombre normalizado, respetando exclusiones y opciones del usuario.
//Prerrequisitos si existen:
// - Debe estar cargada la lista global excludeWords.
// - Debe existir un checkbox con id “normalizeArticles” para definir si se normalizan artículos.
//Descripción:
// Esta versión expuesta globalmente permite acceder a la normalización básica del nombre de un lugar
// desde otros contextos como el navegador o Tampermonkey. Capitaliza cada palabra, respeta las excluidas
// y no aplica normalización a artículos si el checkbox lo indica.
// Realiza limpieza básica: reemplazo de pipes, eliminación de espacios dobles y trim final.
//**************************************************************************
unsafeWindow.normalizePlaceName = function(name)
{
if (!name) return "";
const normalizeArticles = !document.getElementById("normalizeArticles")?.checked;
const articles = ["el", "la", "los", "las", "de", "del", "al", "y", "o"];
const words = name.trim().split(/\s+/);
const normalizedWords = words.map((word, index) => {
const lowerWord = word.toLowerCase();
// Saltar palabras excluidas
if (excludeWords.includes(word)) return word;
// Saltar artículos si el checkbox está activo y no es la primera palabra
if (!normalizeArticles && articles.includes(lowerWord) && index !== 0) {
return lowerWord;
}
//Mayúsculas
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
});
name = normalizedWords.join(" ");
name = name.replace(/\s*\|\s*/g, " - ");
name = name.replace(/\s{2,}/g, " ").trim();
return name;
};
//**************************************************************************
//Nombre: waitForDOM
//Fecha modificación: 2025-03-31
//Autor: mincho77
//Entradas:
// selector (string): Selector CSS del nodo a esperar.
// callback (function): Función a ejecutar cuando el nodo esté disponible.
// interval (int, opcional): Tiempo entre intentos en milisegundos. Default: 500ms.
// maxAttempts (int, opcional): Máximo de intentos antes de abortar. Default: 10.
//Salidas: Ejecuta el callback si encuentra el selector dentro del tiempo.
//Descripción:
// Esta función monitorea el DOM en intervalos constantes hasta que
// encuentra un nodo que coincida con el selector. Si lo encuentra,
// ejecuta el callback con ese nodo. Si no, muestra un error por consola.
//**************************************************************************
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 en el DOM después de ${maxAttempts} intentos.`);
}
attempts++;
}, interval);
}
//**************************************************************************
//Nombre: getSidebarHTML
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: Ninguna.
//Salidas: Retorna un string con HTML que define el contenido del panel lateral del script.
//Prerrequisitos si existen: Debe estar disponible el valor de las variables globales:
// - normalizeArticles (boolean): Define si los artículos deben ser normalizados o no.
// - maxPlaces (number): Número máximo de lugares a escanear.
//Descripción:
// Esta función construye el HTML que se inyecta en el panel lateral (sidebar) de WME.
// Incluye controles para:
// - Activar o desactivar normalización de artículos ("el", "la", etc).
// - Definir la cantidad máxima de lugares a procesar.
// - Agregar palabras excluidas manualmente.
// - Exportar palabras excluidas a un archivo XML.
// - Importar una lista de palabras excluidas desde archivo XML.
// - Disparar el escaneo de lugares para normalización.
// La lista de palabras excluidas **no se renderiza aquí directamente**, sino en el div
// con id "normalizer-sidebar" para permitir que sea manejada dinámicamente.
//**************************************************************************
function getSidebarHTML() {
return `
<div id="normalizer-tab">
<h4>Places Name Normalizer <span style="font-size:11px;">v${VERSION}</span></h4>
<div>
<input type="checkbox" id="normalizeArticles" ${normalizeArticles ? "checked" : ""}>
<label for="normalizeArticles">No Normalizar artículos (el, la, los, ...)</label>
</div>
<div>
<label>Máximo de Places a buscar: </label>
<input type='number' id='maxPlacesInput' value='${maxPlaces}' min='1' max='800' style='width: 60px;'>
</div>
<div>
<label>Palabras Excluidas:</label>
<input type='text' id='excludeWord' style='width: 120px;'>
<button id='addExcludeWord'>Add Word</button>
<div id="normalizer-sidebar" style="margin-top: 20px;"></div>
<button id="exportExcludeWords" style="margin-top: 5px;">Export Words</button>
<br>
<button id="importExcludeWordsUnifiedBtn" style="margin-top: 5px;">Import List...</button>
<input type="file" id="hiddenImportInput" accept=".xml" style="display: none;">
</div>
<div>
<input type="checkbox" id="checkOnlyTildes" checked>
<label for="checkOnlyTildes">Revisar solo tildes</label>
</div>
<div id="drop-zone" style="border: 2px dashed #ccc; border-radius: 6px; padding: 15px; margin: 15px 0; text-align: center; font-style: italic; color: #555; background-color: #f8f9fa; transition: all 0.3s ease;">
📂 Arrastra aquí tu archivo .txt o .xml para importar palabras excluidas
</div>
<hr>
<button id="scanPlaces">Scan...</button>
</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 attachEvents2()
{
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) {
alert("No hay palabras excluidas para exportar.");
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); // Correctly appends the link
link.click();
document.body.removeChild(link); // Correctly removes the 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(); // Usar renderExcludedWordsPanel en lugar de updateExcludeList
}
wordInput.value = "";
});
// ✅ Evento: nuevo botón unificado de importación
importButtonUnified.addEventListener("click", () => {
hiddenInput.click(); // abre el file input oculto
});
hiddenInput.addEventListener("change", () => {
handleImportList(); // ✅ Llama a la función handleImportList al importar
});
// ✅ Evento: escanear lugares
scanPlacesButton.addEventListener("click", scanPlaces);
}
//**************************************************************************
//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)
{
// Referencia al Place y los nombres
this.venue = venue;
this.oldName = oldName;
this.newName = newName;
// ID único del Place
this.venueId = venue.attributes.id;
// Metadatos que WME/Plugins pueden usar
this.type = "NameChangeAction";
this.isGeometryEdit = false; // no es una edición de geometría
}
/**
* 1) getActionName: nombre de la acción en el historial.
*/
NameChangeAction.prototype.getActionName = function() {
return "Update place name";
};
/** 2) getActionText: texto corto que WME a veces muestra. */
NameChangeAction.prototype.getActionText = function() {
return "Update place name";
};
/** 3) getName: algunas versiones llaman a getName(). */
NameChangeAction.prototype.getName = function() {
return "Update place name";
};
/** 4) getDescription: descripción detallada de la acción. */
NameChangeAction.prototype.getDescription = function() {
return `Place name changed from "${this.oldName}" to "${this.newName}".`;
};
/** 5) getT: título (a veces requerido por plugins). */
NameChangeAction.prototype.getT = function() {
return "Update place name";
};
/** 6) getID: si un plugin llama a e.getID(). */
NameChangeAction.prototype.getID = function() {
return `NameChangeAction-${this.venueId}`;
};
/** 7) doAction: asigna el nuevo nombre (WME llama a esto al crear la acción). */
NameChangeAction.prototype.doAction = function() {
this.venue.attributes.name = this.newName;
this.venue.isDirty = true;
if (typeof W.model.venues.markObjectEdited === "function") {
W.model.venues.markObjectEdited(this.venue);
}
};
/** 8) undoAction: revertir al nombre anterior (Ctrl+Z). */
NameChangeAction.prototype.undoAction = function() {
this.venue.attributes.name = this.oldName;
this.venue.isDirty = true;
if (typeof W.model.venues.markObjectEdited === "function") {
W.model.venues.markObjectEdited(this.venue);
}
};
/** 9) redoAction: rehacer (Ctrl+Shift+Z), vuelve a doAction. */
NameChangeAction.prototype.redoAction = function() {
return this.doAction();
};
/** 10) undoSupported / redoSupported: indica si se puede des/rehacer. */
NameChangeAction.prototype.undoSupported = function() {
return true;
};
NameChangeAction.prototype.redoSupported = function() {
return true;
};
/** 11) accept / supersede: evita fusionar con otras acciones. */
NameChangeAction.prototype.accept = function() {
return false;
};
NameChangeAction.prototype.supersede = function() {
return false;
};
/** 12) isEditAction: true => habilita "Guardar". */
NameChangeAction.prototype.isEditAction = function() {
return true;
};
/** 13) getAffectedUniqueIds: objetos que se alteran. */
NameChangeAction.prototype.getAffectedUniqueIds = function() {
return [this.venueId];
};
/** 14) isSerializable: si no implementas serialize(), pon false. */
NameChangeAction.prototype.isSerializable = function() {
return false;
};
/** 15) isActionStackable: false => no combina con otras ediciones. */
NameChangeAction.prototype.isActionStackable = function() {
return false;
};
/** 16) getFocusFeatures: WME/Plugins pueden usarlo para "enfocar" el objeto. */
NameChangeAction.prototype.getFocusFeatures = function() {
// Devolvemos el venue para indicar que ese es el foco (o un array vacío si prefieres).
return [this.venue];
};
/** 17) Métodos vacíos para evitar futuros "no es una función" si WME pide estos. */
NameChangeAction.prototype.getFocusSegments = function() {
return [];
};
NameChangeAction.prototype.getFocusNodes = function() {
return [];
};
NameChangeAction.prototype.getFocusClosures = function() {
return [];
};
/** 18) getTimestamp: método nuevo que WME/Plugins están llamando. */
NameChangeAction.prototype.getTimestamp = function() {
// Devolvemos un timestamp numérico (ms desde época UNIX).
return Date.now();
};
//**************************************************************************
//Nombre: openFloatingPanel
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: placesToNormalize (array de lugares con nombre original y sugerencias)
//Salidas: Panel flotante con opciones de normalización y eliminación
//Prerrequisitos si existen: Debe haberse definido normalizePlaceName y cargado excludeWords correctamente
//Descripción: Crea un panel interactivo donde se presentan los lugares que requieren cambios,
// permitiendo su corrección o eliminación. Solo muestra lugares que requieren cambio
// y errores ortográficos verdaderos.
//**************************************************************************
function openFloatingPanel2(placesToNormalize) {
const panel = document.createElement("div");
panel.id = "normalizer-floating-panel";
panel.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
z-index: 1000;
max-height: 80vh;
overflow-y: auto;
min-width: 800px;
`;
let panelContent = `
<style>
#normalizer-table { width: 100%; border-collapse: collapse; }
#normalizer-table th, #normalizer-table td { padding: 8px; border: 1px solid #ddd; }
#normalizer-table tr:nth-child(even) { background-color: #f9f9f9; }
.warning-row { background-color: #fff3cd !important; }
.close-btn {
position: absolute;
top: 10px;
right: 10px;
background: #ccc;
color: black;
border: none;
border-radius: 4px;
width: 25px;
height: 25px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
text-align: center;
line-height: 25px;
transition: background-color 0.3s ease;
}
.close-btn:hover {
background: #bbb;
}
.footer-buttons {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 20px;
}
</style>
<button class="close-btn" id="close-panel-btn">X</button>
<h3>Places to Normalize</h3>
<table id="normalizer-table">
<thead>
<tr>
<th>Normalizar</th>
<th>Eliminar</th>
<th>Estado</th>
<th>Nombre Original</th>
<th>Nombre Sugerido</th>
<th>Corrección</th>
<th>Acción</th>
</tr>
</thead>
<tbody>
`;
// Procesar cada lugar y sus advertencias
placesToNormalize.forEach((place, index) => {
const { originalName, newName, spellingWarnings = [] } = place;
// Crear una fila por cada advertencia ortográfica
spellingWarnings.forEach((warning, warningIndex) => {
const suggestionId = `suggestion-${index}-${warningIndex}`;
panelContent += `
<tr class="warning-row">
<td style="text-align: center;">
<input type="checkbox" class="normalize-checkbox"
data-index="${index}"
data-warning-index="${warningIndex}"
data-suggestion-id="${suggestionId}">
</td>
<td style="text-align: center;">
<input type="checkbox" class="delete-checkbox"
data-index="${index}"
data-warning-index="${warningIndex}">
</td>
<td style="text-align: center;">⚠️</td>
<td id="name-cell-${index}-${warningIndex}">${originalName}</td>
<td>
<input type="text" class="new-name-input"
data-index="${index}"
data-warning-index="${warningIndex}"
data-place-id="${place.id}"
data-suggestion-id="${suggestionId}"
value="${newName}">
</td>
<td>${warning.original} → ${warning.sugerida} (${warning.tipo})</td>
<td style="text-align: center;">
<button class="apply-suggestion-btn"
data-index="${index}"
data-warning-index="${warningIndex}"
title="Corregir ortografía de la palabra"
style="background-color: #4CAF50; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">
Fix
</button>
<button class="add-exclude-btn"
data-word="${warning.original}"
title="Adicionar palabra excluida nueva"
style="background-color: #007bff; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">
Add Excld
</button>
</td>
</tr>`;
});
});
panelContent += `
</tbody>
</table>
<div class="footer-buttons">
<button class="apply-changes-btn" id="apply-changes-btn">✔️ Apply Changes</button>
<button class="cancel-btn" id="cancel-btn">❌ Cancel</button>
</div>
`;
panel.innerHTML = panelContent;
document.body.appendChild(panel);
// Eventos para los botones
document.getElementById('close-panel-btn').addEventListener('click', () => {
panel.remove(); // Cerrar el panel flotante
});
document.getElementById('apply-changes-btn').addEventListener('click', () => {
window.applyNormalization(); // Llamar a la función global para aplicar cambios
});
document.getElementById('cancel-btn').addEventListener('click', () => {
panel.remove(); // Cerrar el panel flotante
});
// Asignar eventos a los botones "Fix"
document.querySelectorAll('.apply-suggestion-btn').forEach(button => {
button.addEventListener('click', function () {
const index = this.dataset.index;
const warningIndex = this.dataset.warningIndex;
const input = document.querySelector(
`.new-name-input[data-index="${index}"][data-warning-index="${warningIndex}"]`
);
const checkbox = document.querySelector(
`.normalize-checkbox[data-index="${index}"][data-warning-index="${warningIndex}"]`
);
if (input && checkbox) {
const warning = placesToNormalize[index].spellingWarnings[warningIndex];
input.value = input.value.replace(warning.original, warning.sugerida || warning.original);
checkbox.checked = true; // Marcar el checkbox
}
});
});
// Asignar eventos a los botones "Add Excld Word"
document.querySelectorAll('.add-exclude-btn').forEach(button => {
button.addEventListener('click', function () {
const word = this.dataset.word;
if (!excludeWords.includes(word)) {
excludeWords.push(word);
excludeWords.sort((a, b) => a.localeCompare(b)); // Ordenar alfabéticamente
localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
renderExcludedWordsPanel(); // Actualizar el panel lateral
// Mostrar popup en el centro del panel flotante
const panel = document.getElementById("normalizer-floating-panel");
const popup = document.createElement('div');
popup.textContent = `✅ The Word "${word}" has been added to the exclusion list.`;
popup.style.position = 'absolute';
popup.style.top = '50%';
popup.style.left = '50%';
popup.style.transform = 'translate(-50%, -50%)';
popup.style.backgroundColor = '#4CAF50';
popup.style.color = 'white';
popup.style.padding = '10px 20px';
popup.style.borderRadius = '5px';
popup.style.zIndex = '10000';
popup.style.opacity = '1';
popup.style.transition = 'opacity 1s ease-in-out';
panel.appendChild(popup);
setTimeout(() => {
popup.style.opacity = '0';
setTimeout(() => panel.removeChild(popup), 1000);
}, 2000);
// Bloquear el botón y cambiar su texto
this.textContent = 'Excluded Word Added';
this.disabled = true;
this.style.backgroundColor = '#6c757d'; // Cambiar color a gris
this.style.cursor = 'not-allowed';
} else {
alert(`⚠️ The word "${word}" is already on the exclusion list.`);
}
});
});
}
//**************************************************************************
//Nombre: waitForWME
//Fecha modificación: 2025-03-30
//Autor: mincho77
//Entradas: Ninguna
//Salidas: Ninguna
//Prerrequisitos si existen:
// - El objeto global W debe estar disponible.
// - Deben estar definidas las funciones: initializeExcludeWords, createSidebarTab y renderExcludedWordsPanel.
//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 desde localStorage,
// crea el tab lateral personalizado y renderiza la lista visual de palabras excluidas.
// Si WME aún no está listo, vuelve a intentar cada 1000 ms.
//**************************************************************************
function waitForWME2() {
if (W && W.userscripts && W.model && W.model.venues) {
console.log(`[${SCRIPT_NAME}] Inicializando v${VERSION}`);
initializeExcludeWords(); // ⚠️ Usa solo localStorage
createSidebarTab();
waitForDOM("#normalizer-tab", () => {
console.log("[waitForWME] 🧩 Sidebar listo, renderizando palabras excluidas");
renderExcludedWordsPanel(); // Muestra las palabras
setupDragAndDropImport(); // Activa drag & drop
//Evita que se abra el archivo si cae fuera del área
// window.addEventListener("dragover", e => e.preventDefault(), false);
// window.addEventListener("drop", e => e.preventDefault(), false);
});
} else {
console.log(`[${SCRIPT_NAME}] Esperando que WME esté listo...`);
setTimeout(waitForWME, 1000);
}
}
//**************************************************************************
//Nombre: init
//Fecha modificación: 2025-03-30
//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. Si WME aún no está listo,
// reintenta la inicialización cada 1000 ms. Una vez disponible, inicializa la lista de palabras
// excluidas, crea el tab lateral personalizado, espera a que el DOM del tab esté listo para
// renderizar el contenido (palabras excluidas y funcionalidad drag & drop), y expone globalmente
// las funciones applyNormalization y normalizePlaceName para uso externo.
//**************************************************************************
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, renderizando palabras excluidas");
renderExcludedWordsPanel();
setupDragAndDropImport();
});
window.applyNormalization = applyNormalization;
window.normalizePlaceName = normalizePlaceName;
}
init();
} catch (e) {
console.error('[PlacesNameNormalizer] Fatal initialization error:', e);
}
})();