// ==UserScript==
// @name WME Places Name Normalizer
// @namespace https://gf.qytechs.cn/en/users/mincho77
// @version 1.5.9
// @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*
// @grant none
// @license MIT
// @run-at document-end
// ==/UserScript==
/*global W*/
(() => {
"use strict";
const SCRIPT_NAME = "PlacesNameNormalizer";
const VERSION = "1.5.9";
let excludeWords = JSON.parse(localStorage.getItem("excludeWords")) || ["EDS", "IPS", "McDonald's", "EPS"];
let maxPlaces = 50;
let normalizeArticles = true;
let avoidNormalizeAcronyms = true;
function createSidebarTab() {
const { tabLabel, tabPane } = W.userscripts.registerSidebarTab("PlacesNormalizer");
if (!tabPane) {
console.error(`[${SCRIPT_NAME}] Error: No se pudo registrar el sidebar tab.`);
return;
}
tabLabel.innerText = "Normalizer";
tabLabel.title = "Places Name Normalizer";
tabPane.innerHTML = getSidebarHTML();
setTimeout(() => {
// Llamar a la función para esperar el DOM antes de ejecutar eventos
waitForDOM("#normalizer-tab", attachEvents);
//attachEvents();
}, 500);
}
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);
}
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, las)</label>
</div>
<div>
<input type="checkbox" id="avoidNormalizeAcronyms" ${avoidNormalizeAcronyms ? "checked" : ""}>
<label for="avoidNormalizeAcronyms">No normalizar acrónimos (ej. S.A.S, LTDA)</label>
</div>
<div>
<label>Máximo de Places a buscar: </label>
<input type='number' id='maxPlacesInput' value='${maxPlaces}' min='1' max='500' style='width: 60px;'>
</div>
<div>
<label>Palabras Excluidas:</label>
<input type='text' id='excludeWord' style='width: 120px;'>
<button id='addExcludeWord'>Añadir</button>
<ul id='excludeList'>${excludeWords.map(word => `<li>${word}</li>`).join('')}</ul>
</div>
<button id='scanPlaces' class='btn btn-primary'>Escanear</button>
</div>`;
}
function attachEvents() {
console.log(`[${SCRIPT_NAME}] Adjuntando eventos...`);
let normalizeArticlesCheckbox = document.getElementById("normalizeArticles");
let avoidNormalizeAcronymsCheckbox = document.getElementById("avoidNormalizeAcronyms");
let maxPlacesInput = document.getElementById("maxPlacesInput");
let addExcludeWordButton = document.getElementById("addExcludeWord");
let scanPlacesButton = document.getElementById("scanPlaces");
if (!normalizeArticlesCheckbox || !avoidNormalizeAcronymsCheckbox || !maxPlacesInput || !addExcludeWordButton || !scanPlacesButton) {
console.error(`[${SCRIPT_NAME}] Error: No se encontraron elementos en el DOM.`);
return;
}
normalizeArticlesCheckbox.addEventListener("change", (e) => {
normalizeArticles = e.target.checked;
});
avoidNormalizeAcronymsCheckbox.addEventListener("change", (e) => {
avoidNormalizeAcronyms = e.target.checked;
});
maxPlacesInput.addEventListener("input", (e) => {
maxPlaces = parseInt(e.target.value, 10);
});
addExcludeWordButton.addEventListener("click", () => {
const wordInput = document.getElementById("excludeWord");
const word = wordInput.value.trim();
if (word && !excludeWords.includes(word)) {
excludeWords.push(word);
localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
updateExcludeList();
wordInput.value = "";
}
});
scanPlacesButton.addEventListener("click", scanPlaces);
}
function updateExcludeList() {
const list = document.getElementById("excludeList");
list.innerHTML = excludeWords.map(word => `<li>${word}</li>`).join('');
}
function scanPlaces() {
console.log(`[${SCRIPT_NAME}] Iniciando escaneo de lugares...`);
if (!W || !W.model || !W.model.venues || !W.model.venues.objects) {
console.error(`[${SCRIPT_NAME}] WME no está listo.`);
return;
}
// Obtener el nivel del editor; si no existe, usamos Infinity para incluir todos.
let editorLevel = (W.model.user && typeof W.model.user.level === "number")
? W.model.user.level
: Infinity;
let places = Object.values(W.model.venues.objects);
console.log(`[${SCRIPT_NAME}] Total de lugares encontrados: ${places.length}`);
if (places.length === 0) {
alert("No se encontraron Places en WME.");
return;
}
let placesToNormalize = places
.slice(0, maxPlaces)
.filter(place =>
place &&
place.attributes &&
place.attributes.name &&
// Si existe un nivel para el place, se filtra para que sea <= al nivel del editor.
(typeof place.attributes.level !== "number" || place.attributes.level <= editorLevel) &&
!excludeWords.some(excluded => place.attributes.name.includes(excluded))
);
// Luego se mapea y se sigue con el flujo habitual...
let placesMapped = placesToNormalize.map(place => {
let originalName = place.attributes.name;
let newName = normalizePlaceName(originalName);
return {
id: place.attributes.id,
originalName,
newName
};
});
let filteredPlaces = placesMapped.filter(p =>
p.newName.trim() !== p.originalName.trim()
);
console.log(`[${SCRIPT_NAME}] Lugares que cambiarán: ${filteredPlaces.length}`);
if (filteredPlaces.length === 0) {
alert("No se encontraron Places que requieran cambio.");
return;
}
openFloatingPanel(filteredPlaces);
}
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() {
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();
};
function applyNormalization(selectedPlaces) {
if (!selectedPlaces || selectedPlaces.length === 0) {
alert("No se ha seleccionado ningún lugar.");
return;
}
let changesMade = false;
selectedPlaces.forEach((place) => {
const wazePlace = W.model.venues.getObjectById(place.id);
if (!wazePlace) return;
const oldName = (wazePlace.attributes.name || "").trim();
const newName = place.newName.trim();
if (oldName !== newName) {
const action = new NameChangeAction(wazePlace, oldName, newName);
W.model.actionManager.add(action);
console.log(`Action creada: "${oldName}" → "${newName}" (ID: ${wazePlace.attributes.id})`);
changesMade = true;
}
});
if (changesMade) {
alert("Normalización aplicada. Revisa y guarda los cambios en WME.");
} else {
alert("No se detectaron cambios en los nombres seleccionados.");
}
}
function normalizePlaceName(name) {
if (!name) return "";
const articles = ["el", "la", "los", "las", "del", "de", "y"];
const exclusions = ["IPS", "EDS", "McDonald's"];
// Regex para detectar acrónimos: secuencia de letras mayúsculas, opcionalmente separadas por puntos.
// Ejemplos: S.A.S, LTDA, L.T.D.A, S.A
const acronymRegex = /^(?:[A-Z]\.?)+$/;
let words = name.split(" ");
let normalizedWords = words.map((word) => {
// Si la palabra está en la lista de exclusiones, se deja igual.
if (exclusions.includes(word)) return word;
// Si la palabra es un acrónimo y el checkbox está activado, se devuelve sin modificar.
if (acronymRegex.test(word)) {
if (avoidNormalizeAcronyms) {
return word;
}
}
// Si la palabra es un artículo, se revisa el checkbox de artículos.
if (articles.includes(word.toLowerCase())) {
if (normalizeArticles) {
return word;
} else {
return word[0].toUpperCase() + word.substring(1).toLowerCase();
}
}
// Para el resto de las palabras: capitalizar la primera letra y el resto en minúsculas.
return word[0].toUpperCase() + word.substring(1).toLowerCase();
});
return normalizedWords.join(" ");
}
function openFloatingPanel(placesToNormalize) {
console.log(`[${SCRIPT_NAME}] Creando panel flotante...`);
if (!placesToNormalize || placesToNormalize.length === 0) {
console.warn(`[${SCRIPT_NAME}] No hay lugares para normalizar.`);
return;
}
// Elimina cualquier panel flotante previo
let existingPanel = document.getElementById("normalizer-floating-panel");
if (existingPanel) existingPanel.remove();
// Crear el panel flotante
let panel = document.createElement("div");
panel.id = "normalizer-floating-panel";
panel.style.position = "fixed";
panel.style.top = "100px";
panel.style.right = "20px";
panel.style.width = "420px";
panel.style.background = "white";
panel.style.border = "1px solid black";
panel.style.padding = "10px";
panel.style.boxShadow = "0px 0px 10px rgba(0,0,0,0.5)";
panel.style.zIndex = "10000";
panel.style.overflowY = "auto";
panel.style.maxHeight = "500px";
// Contenido del panel
let panelContent = `
<h3 style="text-align: center;">Lugares para Normalizar</h3>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="border-bottom: 2px solid black;">
<th><input type="checkbox" id="selectAllCheckbox"></th>
<th style="text-align: left;">Nombre Original</th>
<th style="text-align: left;">Nuevo Nombre</th>
</tr>
</thead>
<tbody>
`;
placesToNormalize.forEach((place, index) => {
if (place && place.originalName) {
let originalName = place.originalName;
let newName = place.newName;
panelContent += `
<tr>
<td><input type="checkbox" class="normalize-checkbox" data-index="${index}" checked></td>
<td>${originalName}</td>
<td><input type="text" class="new-name-input" data-index="${index}" value="${newName}" style="width: 100%;"></td>
</tr>
`;
}
});
panelContent += `</tbody></table>`;
// Agregar botones al panel sin eventos inline
panelContent += `
<button id="applyNormalizationBtn" style="margin-top: 10px; width: 100%; padding: 8px; background: #4CAF50; color: white; border: none; cursor: pointer;">
Aplicar Normalización
</button>
<button id="closeFloatingPanelBtn" style="margin-top: 5px; width: 100%; padding: 8px; background: #d9534f; color: white; border: none; cursor: pointer;">
Cerrar
</button>
`;
panel.innerHTML = panelContent;
document.body.appendChild(panel);
// Evento para seleccionar todas las casillas
document.getElementById("selectAllCheckbox").addEventListener("change", function() {
let isChecked = this.checked;
document.querySelectorAll(".normalize-checkbox").forEach(checkbox => {
checkbox.checked = isChecked;
});
});
// Evento para cerrar el panel (CORREGIDO)
document.getElementById("closeFloatingPanelBtn").addEventListener("click", function() {
let panel = document.getElementById("normalizer-floating-panel");
if (panel) panel.remove();
});
// Evento para aplicar normalización
document.getElementById("applyNormalizationBtn").addEventListener("click", function() {
let selectedPlaces = [];
document.querySelectorAll(".normalize-checkbox:checked").forEach(checkbox => {
let index = checkbox.getAttribute("data-index");
let newName = document.querySelector(`.new-name-input[data-index="${index}"]`).value;
selectedPlaces.push({ ...placesToNormalize[index], newName });
});
if (selectedPlaces.length === 0) {
alert("No se ha seleccionado ningún lugar.");
return;
}
applyNormalization(selectedPlaces);
});
}
function waitForWME() {
if (W && W.userscripts && W.model && W.model.venues) {
console.log(`[${SCRIPT_NAME}] Inicializando v${VERSION}`);
createSidebarTab();
} else {
console.log(`[${SCRIPT_NAME}] Esperando que WME esté listo...`);
setTimeout(waitForWME, 1000);
}
}
console.log(window.applyNormalization);
window.applyNormalization = applyNormalization;
waitForWME();
})();