WME Places Name Normalizer

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

当前为 2025-03-26 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         WME Places Name Normalizer
// @namespace    https://greasyfork.org/en/users/mincho77
// @version      1.8
// @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";
    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.map(word => `<li>${word}</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();
})();