WME Places Name Normalizer

Normaliza nombres de lugares en Waze Map Editor (WME) por ahora solo con reglas de Colombia

Versione datata 26/03/2025. Vedi la nuova versione l'ultima versione.

// ==UserScript==
// @name         WME Places Name Normalizer
// @namespace    https://greasyfork.org/en/users/mincho77
// @version      1.8.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*
// @grant        GM_xmlhttpRequest
// @grant        unsafeWindow
// @license      MIT
// @run-at       document-end
// ==/UserScript==
/*global W*/

(() => {
    "use strict";
    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 = "1.8.1";
    let placesToNormalize = [];
    let excludeWords = JSON.parse(localStorage.getItem("excludeWords")) || ["EDS", "IPS", "McDonald's", "EPS"];
    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])+\.?$/;

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


     unsafeWindow.normalizePlaceName = function(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 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;
            }

            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;
    };

   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);
            resolve(data);
          } catch (err) {
            reject(err);
          }
        } else {
          reject(`Error HTTP: ${response.status}`);
        }
      },
      onerror: function(err) {
        reject(err);
      }
    });
  });
}

    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;
        });
    }

    function renderExcludedWordsSidebar() {
        const container = document.getElementById("normalizer-sidebar");
        if (!container) return;

        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">
        ${excludeWords.sort((a, b) => a.localeCompare(b)).map(w => `<li>${w}</li>`).join("")}
      </ul>
    </div>
  `;

        container.appendChild(excludeListSection);
    }

    

    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>
        <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'>Add</button>

        <!-- Agrega esto justo después del botón -->
        <div style="max-height: 100px; overflow-y: auto; border: 1px solid #ccc; padding: 5px; margin-top: 5px;">
          <ul id="excludedWordsList" style="padding-left: 20px; margin: 0;">
            ${excludeWords.map(w => `<li>${w}</li>`).join("")}
          </ul>
        </div>
      </div>

      <hr>
      <button id="scanPlaces">Scan...</button>
    </div>
  `;
}


    function attachEvents() {
        console.log(`[${SCRIPT_NAME}] Adjuntando eventos...`);

        let normalizeArticlesCheckbox = document.getElementById("normalizeArticles");

        let maxPlacesInput = document.getElementById("maxPlacesInput");
        let addExcludeWordButton = document.getElementById("addExcludeWord");
        let scanPlacesButton = document.getElementById("scanPlaces");
addExcludeWordButton.addEventListener("click", () => {
  const wordInput = document.getElementById("excludedWord");
  const word = wordInput.value.trim();

  if (word && !excludeWords.includes(word)) {
    excludeWords.push(word);
    localStorage.setItem("excludeWords", JSON.stringify(excludeWords));
    updateExcludeList();
  }

  wordInput.value = ""; // limpia el campo
});
        if (!normalizeArticlesCheckbox  || !maxPlacesInput || !addExcludeWordButton || !scanPlacesButton) {
            console.error(`[${SCRIPT_NAME}] Error: No se encontraron elementos en el DOM.`);
            return;
        }

        normalizeArticlesCheckbox.addEventListener("change", (e) => {
            normalizeArticles = 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("excludedWordsList");
        if (!list) return;

        list.innerHTML = excludeWords.map(w => `<li>${w}</li>`).join("");
}
    

function scanPlaces() {
    const allPlaces = W.model.venues.getObjectArray();
    console.log(`[${SCRIPT_NAME}] Iniciando escaneo de lugares...`);

   // const inputValue = document.getElementById("maxPlacesInput")?.value;
    maxPlaces = parseInt(document.getElementById("maxPlacesInput").value, 10) || 50;

    console.log("➡️ Usando maxPlaces =", maxPlaces);

    const venues = Object.values(W.model.venues.objects);
    const sliced = venues.slice(0, maxPlaces);

    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;
    }

    placesToNormalize = allPlaces
        .filter(place =>
                place &&
                typeof place.getID === "function" &&
                place.attributes &&
                typeof place.attributes.name === "string"
               )
        .map(place => ({
        id: place.getID(),
        name: place.attributes.name,
        attributes: place.attributes,
        place: place
    }));


    // 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() {
  const checkboxes = document.querySelectorAll(".normalize-checkbox:checked");
  let changesMade = false;

  if (checkboxes.length === 0) {
    console.log("ℹ️ No hay lugares seleccionados para normalizar.");
    return;
  }

  checkboxes.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();

    console.log(`🧠 Evaluando ID ${placeId}`);
    console.log(`🔹 Actual en mapa: "${currentName}"`);
    console.log(`🔸 Nuevo propuesto: "${newName}"`);

    if (currentName !== newName) {
      try {
        // Esta es la forma correcta para obtener UpdateObject en WME
        const UpdateObject = require("Waze/Action/UpdateObject");
        const action = new UpdateObject(place, { name: newName });
        W.model.actionManager.add(action);
        console.log(`✅ Acción aplicada: "${currentName}" → "${newName}"`);
        changesMade = true;
      } catch (error) {
        console.error("⛔ Error aplicando la acción de actualización:", error);
      }
    } else {
      console.log(`⏭ Sin cambios reales para ID ${placeId}`);
    }
  });

  if (changesMade) {
    console.log("💾 Cambios marcados. Recuerda presionar el botón de guardar en el editor.");

  } else {
    console.log("ℹ️ No hubo cambios para aplicar.");
  }
}



// Función de similitud leve entre palabras
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;
}

  function normalizePlaceName(name) {
  if (!name) return "";

  const normalizeArticles = !document.getElementById("normalizeArticles")?.checked;

  const articles = ["el", "la", "los", "las", "de", "del", "al"];
  const words = name.trim().split(/\s+/);

  const normalizedWords = words.map((word, index) => {
    const lowerWord = word.toLowerCase();

    // Si está en la lista de excluidas (ignorando mayúsculas), usarla tal cual está escrita en la lista
    const match = excludeWords.find(ex => ex.toLowerCase() === lowerWord);
    if (match) {
      return match;
    }

    // Si es artículo y se debe conservar en minúsculas
    if (!normalizeArticles && articles.includes(lowerWord) && index !== 0) {
      return lowerWord;
    }

    return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
  });

  name = normalizedWords.join(" ")
    .replace(/\s*\|\s*/g, " - ")
    .replace(/\s{2,}/g, " ")
    .trim();

  return name;
}

   




// ⬅️ Esta línea justo fuera de la función, no adentro
//window.normalizePlaceName = normalizePlaceName;
    // Para exponer al contexto global real desde Tampermonkey
unsafeWindow.normalizePlaceName = normalizePlaceName;



    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.left = "300px"; // deja espacio para la barra lateral
        panel.style.width = "calc(100vw - 400px)"; // margen adicional para que no se desborde
        panel.style.maxWidth = "calc(100vw - 30px)";
        panel.style.zIndex = 10000;
        panel.style.backgroundColor = "white";
        panel.style.border = "1px solid #ccc";
        panel.style.padding = "15px";
        panel.style.boxShadow = "0 2px 10px rgba(0,0,0,0.3)";
        panel.style.borderRadius = "8px";

        // Contenido del panel
        let panelContent = `
    <h3 style="text-align: center;">Lugares para Normalizar</h3>
    <div style="max-height: 60vh; overflow-y: auto; margin-bottom: 10px;">
        <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>
    `;

        maxPlaces = parseInt(document.getElementById("maxPlacesInput").value, 10) || 50;

        placesToNormalize.slice(0, maxPlaces).forEach((place, index) => {
            // placesToNormalize.forEach((place, index) => {
            if (place && place.originalName) {
                const originalName = place.originalName;
                const newName = normalizePlaceName(originalName);
                const placeId = place.id;
                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}" data-place-id="${place.id}" value="${newName}" style="width: 100%;"></td>
            </tr>
        `;
            }
        });
        panelContent += `</tbody></table>`;

        // Agregar botones al panel sin eventos inline
        // Ejemplo de la sección de botones en panelContent:
        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;
            });
        });


        document.getElementById("closeFloatingPanelBtn").addEventListener("click", function() {
            let panel = document.getElementById("normalizer-floating-panel");
            if (panel) panel.remove();
        });
       /* // Evento para corregir ortografía en cada input del panel:
        document.getElementById("checkSpellingBtn").addEventListener("click", function() {
            const inputs = document.querySelectorAll(".new-name-input");
            inputs.forEach(input => {
                const text = input.value;
                applySpellCorrection(text).then(corrected => {
                    input.value = corrected;
                });
            });
        });*/

        // 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);
            let panel = document.getElementById("normalizer-floating-panel");
            if (panel) panel.remove();
           /* // Cerrar panel flotante al aplicar
            let panel = document.getElementById("normalizer-floating-panel");
            if (panel) panel.remove();*/
        });
    }


    function waitForWME() {
        if (W && W.userscripts && W.model && W.model.venues) {
            console.log(`[${SCRIPT_NAME}] Inicializando v${VERSION}`);

            createSidebarTab();
            renderExcludedWordsSidebar();
        } else {
            console.log(`[${SCRIPT_NAME}] Esperando que WME esté listo...`);
            setTimeout(waitForWME, 1000);
        }
    }
    console.log(window.applyNormalization);
window.applyNormalization = applyNormalization;
    waitForWME();
})();