WME PLN Module - Geolocation

Módulo de geolocalización para WME Place Normalizer. No funciona por sí solo.

Dieses Skript sollte nicht direkt installiert werden. Es handelt sich hier um eine Bibliothek für andere Skripte, welche über folgenden Befehl in den Metadaten eines Skriptes eingebunden wird // @require https://update.greasyforks.org/scripts/548859/1657862/WME%20PLN%20Module%20-%20Geolocation.js

// ==UserScript==
// @name         WME PLN Module - Geolocation
// @version      9.0.0
// @description  Módulo de geolocalización para WME Place Normalizer. No funciona por sí solo.
// @author       mincho77
// @license      MIT
// @grant        none
// ==/UserScript==
//Función para obtener coordenadas de un lugar
function getPlaceCoordinates(venueOldModel, venueSDK)
{
    let lat = null;
    let lon = null;
    const placeId = venueOldModel ? venueOldModel.getID() : (venueSDK ? venueSDK.id : 'N/A');
    // PRIORIDAD 1: Usar el método recomendado getOLGeometry() del modelo antiguo, es el más estable.
    if (venueOldModel && typeof venueOldModel.getOLGeometry === 'function')
    {
        try
        {
            const geometry = venueOldModel.getOLGeometry();
            if (geometry && typeof geometry.getCentroid === 'function')
            {
                const centroid = geometry.getCentroid();
                if (centroid && typeof centroid.x === 'number' && typeof centroid.y === 'number')
                {
                    // La geometría de OpenLayers (OL) está en proyección Mercator (EPSG:3857)
                    // Necesitamos transformarla a coordenadas geográficas WGS84 (EPSG:4326)
                    if (typeof OpenLayers !== 'undefined' && OpenLayers.Projection)
                    {
                        const mercatorPoint = new OpenLayers.Geometry.Point(centroid.x, centroid.y);
                        const wgs84Point = mercatorPoint.transform(
                            new OpenLayers.Projection("EPSG:3857"),
                            new OpenLayers.Projection("EPSG:4326")
                        );
                        lat = wgs84Point.y;
                        lon = wgs84Point.x;
                        // Validar que las coordenadas resultantes sean válidas
                        if (typeof lat === 'number' && typeof lon === 'number' && Math.abs(lat) <= 90 && Math.abs(lon) <= 180)
                        {
                            return { lat, lon };
                        }
                    }
                }
            }
        }
        catch (e)
        {
            plnLog('error',`[WME PLN] Error obteniendo coordenadas con getOLGeometry() para ID ${placeId}:`, e);
        }
    }

    // PRIORIDAD 2: Fallback al objeto del SDK si el método anterior falló.
    // Esto es menos ideal porque .geometry está obsoleto, pero sirve como respaldo.
    if (venueSDK && venueSDK.geometry && Array.isArray(venueSDK.geometry.coordinates)) {
        lon = venueSDK.geometry.coordinates[0];
        lat = venueSDK.geometry.coordinates[1];

        if (typeof lat === 'number' && typeof lon === 'number' && Math.abs(lat) <= 90 && Math.abs(lon) <= 180) {
            return { lat, lon };
        }
    }

    // Si todo falló, retornar nulls
    plnLog('geo', `[WME PLN] No se pudieron obtener coordenadas válidas para el ID ${placeId}.`);
    return { lat: null, lon: null };
}//getPlaceCoordinates

// Función para detectar nombres duplicados cercanos y generar alertas
function detectAndAlertDuplicateNames(allScannedPlacesData)
{
    const DISTANCE_THRESHOLD_METERS = 50; // Umbral de distancia para considerar "cerca" (en metros)
    const duplicatesGroupedForAlert = new Map(); // Almacenará {normalizedName: [{places}, {places}]}

    // Paso 1: Agrupar por nombre NORMALIZADO y encontrar duplicados cercanos
    allScannedPlacesData.forEach(p1 =>
    {
        if (p1.lat === null || p1.lon === null) return; // Saltar si no tiene coordenadas

        // Buscar otros lugares con el mismo nombre normalizado
        const nearbyMatches = allScannedPlacesData.filter(p2 =>
        {
            if (p2.id === p1.id || p2.lat === null || p2.lon === null || p1.normalized !== p2.normalized) {
                return false;
            }
            const calcDist = PLNCore?.utils?.calculateDistance;
            const distance = typeof calcDist === 'function' ? calcDist(p1.lat, p1.lon, p2.lat, p2.lon) : Infinity;
            return distance <= DISTANCE_THRESHOLD_METERS;
        });

        if (nearbyMatches.length > 0)
        {
            // Si encontramos duplicados cercanos para p1, agruparlos
            const groupKey = p1.normalized.toLowerCase();
            if (!duplicatesGroupedForAlert.has(groupKey))
            {
                duplicatesGroupedForAlert.set(groupKey, new Set());
            }
            duplicatesGroupedForAlert.get(groupKey).add(p1); // Añadir p1
            nearbyMatches.forEach(p => duplicatesGroupedForAlert.get(groupKey).add(p)); // Añadir todos sus duplicados
        }
    });
    // Paso 2: Generar el mensaje de alerta final
    if (duplicatesGroupedForAlert.size > 0)
    {
        let totalNearbyDuplicateGroups = 0; // Para contar la cantidad de "nombres" con duplicados
        const duplicateEntriesHtml = []; // Para almacenar las líneas HTML de la alerta formateadas

        duplicatesGroupedForAlert.forEach((placesSet, normalizedName) =>
        {
            const uniquePlacesInGroup = Array.from(placesSet); // Convertir Set a Array
            if (uniquePlacesInGroup.length > 1) { // Solo si realmente hay más de un lugar en el grupo
                totalNearbyDuplicateGroups++;

                // Obtener los números de línea para cada lugar en este grupo
                const lineNumbers = uniquePlacesInGroup.map(p => {
                    const originalPlaceInInconsistents = allScannedPlacesData.find(item => item.id === p.id);
                    return originalPlaceInInconsistents ? (allScannedPlacesData.indexOf(originalPlaceInInconsistents) + 1) : 'N/A';
                }).filter(num => num !== 'N/A').sort((a, b) => a - b); // Asegurarse que son números y ordenarlos

                // Marcar los lugares en `allScannedPlacesData` para el `⚠️` visual
                uniquePlacesInGroup.forEach(p => {
                    const originalPlaceInInconsistents = allScannedPlacesData.find(item => item.id === p.id);
                    if (originalPlaceInInconsistents)
                    {
                        originalPlaceInInconsistents.isDuplicate = true;
                    }
                });
                // Construir la línea para el modal
                duplicateEntriesHtml.push(`
                    <div style="margin-bottom: 5px; font-size: 15px; text-align: left;">
                        <b>${totalNearbyDuplicateGroups}.</b> Nombre: <b>${normalizedName}</b><br>
                        <span style="font-weight: bold; color: #007bff;">Registros: [${lineNumbers.join("],[")}]</span>
                    </div>
                `);
            }
        });
        // Solo mostrar la alerta si realmente hay grupos de más de 1 duplicado cercano
        if (duplicateEntriesHtml.length > 0)
        {
            // Crear el modal
            const modal = document.createElement("div");
            modal.setAttribute("role", "dialog");
            modal.setAttribute("aria-label", "Duplicados cercanos");
            modal.style.position = "fixed";
            modal.style.top = "50%";
            modal.style.left = "50%";
            modal.style.transform = "translate(-50%, -50%)";
            modal.style.background = "#fff";
            modal.style.border = "1px solid #aad";
            modal.style.padding = "28px 32px 20px 32px";
            modal.style.zIndex = "20000"; // Z-INDEX ALTO para asegurar que esté encima
            modal.style.boxShadow = "0 4px 24px rgba(0,0,0,0.18)";
            modal.style.fontFamily = "sans-serif";
            modal.style.borderRadius = "10px";
            modal.style.textAlign = "center";
            modal.style.minWidth = "400px";
            modal.style.maxWidth = "600px";
            modal.style.maxHeight = "80vh"; // Para scroll si hay muchos duplicados
            modal.style.overflowY = "auto"; // Para scroll si hay muchos duplicados

            // Ícono visual
            const iconElement = document.createElement("div");
            iconElement.innerHTML = "⚠️"; // Signo de advertencia
            iconElement.style.fontSize = "38px";
            iconElement.style.marginBottom = "10px";
            modal.appendChild(iconElement);

            // Mensaje principal
            const messageTitle = document.createElement("div");
            messageTitle.innerHTML = `<b>¡Atención! Se encontraron ${duplicateEntriesHtml.length} nombres duplicados.</b>`;
            messageTitle.style.fontSize = "20px";
            messageTitle.style.marginBottom = "8px";
            modal.appendChild(messageTitle);

            const messageExplanation = document.createElement("div");
            messageExplanation.textContent = `Los siguientes grupos de lugares se encuentran a menos de ${DISTANCE_THRESHOLD_METERS}m uno del otro. El algoritmo asume que son el mismo lugar, por favor revisa los registros indicados en el panel flotante:`;
            messageExplanation.style.fontSize = "15px";
            messageExplanation.style.color = "#555";
            messageExplanation.style.marginBottom = "18px";
            messageExplanation.style.textAlign = "left"; // Alinear texto explicativo a la izquierda
            modal.appendChild(messageExplanation);

            // Lista de duplicados
            const duplicatesListDiv = document.createElement("div");
            duplicatesListDiv.style.textAlign = "left"; // Alinear la lista a la izquierda
            duplicatesListDiv.style.paddingLeft = "10px"; // Pequeño padding para los números
            duplicatesListDiv.innerHTML = duplicateEntriesHtml.join('');
            modal.appendChild(duplicatesListDiv);

            // Botón OK
            const buttonWrapper = document.createElement("div");
            buttonWrapper.style.display = "flex";
            buttonWrapper.style.justifyContent = "center";
            buttonWrapper.style.gap = "18px";
            buttonWrapper.style.marginTop = "20px"; // Espacio superior

            const okBtn = document.createElement("button");
            okBtn.textContent = "OK";
            okBtn.style.padding = "7px 18px";
            okBtn.style.background = "#007bff";
            okBtn.style.color = "#fff";
            okBtn.style.border = "none";
            okBtn.style.borderRadius = "4px";
            okBtn.style.cursor = "pointer";
            okBtn.style.fontWeight = "bold";

            okBtn.addEventListener("click", () => modal.remove()); // Cierra el modal

            buttonWrapper.appendChild(okBtn);
            modal.appendChild(buttonWrapper);

            document.body.appendChild(modal); // Añadir el modal al body
        }
    }
}//detectAndAlertDuplicateNames
// Función para aplicar la ciudad seleccionada a un lugar
async function plnApplyCityToVenue(venueId, selectedCityId, selectedCityName)
{
    plnLog('geo', 'apply:start', { venueId, selectedCityId, selectedCityName });
    if (!wmeSDK?.DataModel?.Venues?.updateAddress)
    {
        plnLog('geo', 'apply:sdkNotReady');
        return;
    }
    try
    {
        const venueIdStr    = String(venueId);
        const cityIdNum     = Number(selectedCityId) || 0;
        // Intento obtener houseNumber (no bloqueante), es una buena práctica mantenerlo.
        let houseNumber = '';
        try
        {
            const v0 = wmeSDK.DataModel.Venues.getById?.({ venueId: venueIdStr });
            if (v0?.address?.houseNumber) houseNumber = String(v0.address.houseNumber);
        }
        catch (_)
        { /* noop */ }

        // MODIFICACIÓN CLAVE: Se elimina la lógica de espera y el "Plan B (bridge)".
        // Simplemente llamamos a la función que aplica la ciudad y confiamos en que funciona.
        const attemptKind = plnApplyCityOnce(venueIdStr, cityIdNum, houseNumber);
        if (attemptKind)
        {
            // Si attemptKind no es nulo, significa que se pudo construir y enviar la solicitud al SDK.
            // Asumimos el éxito aquí, ya que la espera en la UI es el punto de fallo.
            plnLog('geo', 'apply:doneWithSDK: optimistic success');

            // El llamado a plnTryAutoApplyAddressPanel ya está dentro de plnApplyCityOnce,
            // por lo que se ejecutará automáticamente.
            return;
        }
        // Si plnApplyCityOnce devuelve null, significa que no pudo encontrar los IDs necesarios.
        // Solo en este caso, lanzamos el error.
        plnLog('geo', 'apply:noSdkVenueOrAddress', { reason: "Could not resolve IDs for city.", cityIdNum });
    }
    catch (e)
    {
        plnLog('error', 'apply:sdkBranchError', e);
    }
}//plnApplyCityToVenue

//**************************************************************************
    //Nombre: plnExtractAddressIds
    //Fecha modificación: 2025-08-10
    //Descripción: SDK‑only. Obtiene countryID y stateID desde sdkVenue.address,
    //             incluso cuando Street/City están vacíos.
    //**************************************************************************
    function plnExtractAddressIds(venueId, sdkVenue) {
      plnLog('geo', 'extractIds:start', { venueId, hasSdkVenue: !!sdkVenue });
      const out = { countryID: null, stateID: null, streetName: '', houseNumber: '' };
      if (sdkVenue && sdkVenue.address) {
        const addr = sdkVenue.address;
        out.countryID   = addr?.country?.id ?? addr?.countryID ?? addr?.countryId ?? null;
        out.stateID     = addr?.state?.id   ?? addr?.stateID   ?? addr?.stateId   ?? null;
        out.streetName  = addr?.street?.name ?? addr?.streetName ?? '';
        out.houseNumber = addr?.houseNumber ?? '';
      }
      plnLog('geo', 'extractIds:fromSDK', out);
      return out;
    }

    //**************************************************************************
    //Nombre: plnResolveIdsFromCity
    //Fecha modificación: 2025-08-10
    //Descripción: SDK-only. A partir de cityId intenta obtener stateID y countryID
    //             usando los repositorios del SDK (Cities → States → Countries).
    //**************************************************************************
    function plnResolveIdsFromCity(cityId)
    {
        const out = { countryID: null, stateID: null };
        try {
            if (!wmeSDK || !wmeSDK.DataModel) return out;
            const cityIdNum = Number(cityId);

            let city = null;
            try {
            if (wmeSDK.DataModel.Cities?.getById) {
                city = wmeSDK.DataModel.Cities.getById({ cityId: cityIdNum }); // <-- number
            }
            } catch(_) {}
            plnLog('geo', 'resolveFromCity:city', { requested: cityIdNum, found: !!city });
            if (!city) return out;

            let stateId = city.state?.id ?? city.stateID ?? city.stateId ?? city.attributes?.state?.attributes?.id ?? city.attributes?.state?.id ?? null;
            let countryId = city.country?.id ?? city.countryID ?? city.countryId ?? city.attributes?.country?.attributes?.id ?? city.attributes?.country?.id ?? null;

            if (!countryId && stateId && wmeSDK.DataModel.States?.getById) {
            try {
                const state = wmeSDK.DataModel.States.getById({ stateId: Number(stateId) }); // <-- number
                countryId = state?.country?.id ?? state?.countryID ?? state?.countryId ?? null;
            } catch(_) {}
            }

            if (stateId) out.stateID = Number(stateId);
            if (countryId) out.countryID = Number(countryId);
            plnLog('geo', 'resolveFromCity:result', out);
        } catch (e) {
            plnLog('error','resolveFromCity:error', e);
        }
        return out;
    }//plnResolveIdsFromCity


//Permite obtener el ID de la calle vacía (empty street) para una ciudad dada.
function plnGetEmptyStreetIdForCity(cityId)
{
    const cidNum = Number(cityId);
    try
    {
        if (wmeSDK?.DataModel?.Streets?.getStreet)
        {
            const st = wmeSDK.DataModel.Streets.getStreet({ cityId: cidNum, streetName: '' }); // <-- number
            if (st && st.id != null) { plnLog('geo', 'streets:emptyFound', { cityId: cidNum, streetId: Number(st.id) }); return Number(st.id); }
        }
    }
    catch (_)
    { }

    try
    {
        const all = (wmeSDK?.DataModel?.Streets?.getAll?.() || []);
        const found = all.find(s => Number(s?.city?.id) === cidNum && (s?.isEmpty || s?.name === '' || s?.streetName === ''));
        if (found) { plnLog('geo', 'streets:emptyFound', { cityId: cidNum, streetId: Number(found.id) }); return Number(found.id); }
    }
    catch (_)
    { }

    try
    {
        for (let i = 0; i < 8; i++)
        {
            const all = (wmeSDK?.DataModel?.Streets?.getAll?.() || []);
            const found = all.find(s => Number(s?.city?.id) === cidNum && (s?.isEmpty || s?.name === '' || s?.streetName === ''));
            if (found)
            {
                plnLog('geo', 'streets:emptyFound', { cityId: cidNum, streetId: Number(found.id) }); return Number(found.id);
            }
        }
    }
    catch (_)
    { }

    return null;
}//plnGetEmptyStreetIdForCity
//Permite obtener la ciudad asignada a un lugar en este momento (sincrónico).
function plnGetVenueCityIdNow(venueIdStr)
{
    try
    {
        const v = wmeSDK?.DataModel?.Venues?.getById?.({ venueId: String(venueIdStr) });
        const cid = v?.address?.city?.id ?? v?.address?.cityID ?? v?.address?.cityId ?? null;
        return (cid != null) ? Number(cid) : null;
    }
    catch (_)
    { return null; }
}
//Permite esperar hasta que un lugar tenga asignada la ciudad esperada (o se agote el tiempo).
function plnWaitVenueCity(venueIdStr, expectedCityId, timeoutMs = 1500)
{
    return new Promise(resolve =>
    {
    const start = Date.now();
    const target = Number(expectedCityId);
        const tick = setInterval(() =>
        {
            const cid = plnGetVenueCityIdNow(venueIdStr);
            if (cid === target){ clearInterval(tick); return resolve(true); }
            if (Date.now() - start > timeoutMs){ clearInterval(tick); return resolve(false); }
        }, 120);
    });
}
//Permite buscar una ciudad puente (bridge) en un estado dado.
function plnFindBridgeCityIdInState(stateId)
{
    try
    {
        const all = (wmeSDK?.DataModel?.Streets?.getAll?.() || []);
        const match = all.find(s =>
        (s?.isEmpty || s?.name === '' || s?.streetName === '') &&
        Number(s?.city?.state?.id ?? s?.city?.stateID ?? s?.city?.stateId) === Number(stateId)
        );
        return match?.city?.id != null ? Number(match.city.id) : null;
    }
    catch (_)
    {
        return null;
    }
}
//Permite aplicar una ciudad a un lugar, una sola vez.
function plnApplyCityOnce(venueIdStr, cityIdNum, houseNumber)
{
    // Ruta 1: street vacío específico
    const emptyStreetId = plnGetEmptyStreetIdForCity(cityIdNum);
    if (emptyStreetId != null)
    {
        const args = { venueId: venueIdStr, streetId: Number(emptyStreetId) };
        if (houseNumber) args.houseNumber = houseNumber;
        plnLog('geo', 'apply:updateAddress(args)', args);
        wmeSDK.DataModel.Venues.updateAddress(args);
        setTimeout(()=>{ try{ plnTryAutoApplyAddressPanel?.(); }catch{} }, 200);
        return 'streetId';
    }
    // Ruta 2: IDs completos con emptyStreet:true
    const ids = plnResolveIdsFromCity(cityIdNum);
    plnLog('geo', 'apply:fallbackIds', ids);
    if (ids.countryID && ids.stateID)
    {
        const args2 =
        {
            venueId:   venueIdStr,
            countryID: Number(ids.countryID),
            stateID:   Number(ids.stateID),
            cityID:    Number(cityIdNum),
            emptyStreet: true
        };
        if (houseNumber) args2.houseNumber = houseNumber;
        plnLog('geo', 'apply:updateAddress(args2)', args2);
        wmeSDK.DataModel.Venues.updateAddress(args2);
        setTimeout(()=>{ try{ plnTryAutoApplyAddressPanel?.(); }catch{} }, 200);
        return { type:'ids', ids };
    }

    return null;
}//plnApplyCityOnce



长期地址
遇到问题?请前往 GitHub 提 Issues。