WME City Name Update

Bán tự động điều hướng, chọn và cập nhật thành phố cho segment hoặc place trong WME từ file Excel & CSV.

Від 18.07.2025. Дивіться остання версія.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         WME City Name Update
// @namespace    https://greasyfork.org/
// @version      1.0.6
// @description  Bán tự động điều hướng, chọn và cập nhật thành phố cho segment hoặc place trong WME từ file Excel & CSV.
// @author       Minh Tan
// @match        https://www.waze.com/editor*
// @match        https://www.waze.com/*/editor*
// @match        https://beta.waze.com/editor*
// @match        https://beta.waze.com/*/editor*
// @exclude      https://www.waze.com/*user/editor*
// @grant        none
// @require      https://greasyfork.org/scripts/24851-wazewrap/code/WazeWrap.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js
// ==/UserScript==
/* global W, WazeWrap, $, XLSX */
(function () {
    'use strict';
    let permalinks = [];
    let currentIndex = -1;
    function waitForElement(selector, timeout = 7000) {
        return new Promise((resolve, reject) => {
            const intervalTime = 100;
            let elapsedTime = 0;
            const interval = setInterval(() => {
                const element = document.querySelector(selector);
                if (element && element.offsetParent !== null) {
                    clearInterval(interval);
                    resolve(element);
                }
                elapsedTime += intervalTime;
                if (elapsedTime >= timeout) {
                    clearInterval(interval);
                    reject(new Error(`Element "${selector}" not found or not visible after ${timeout}ms`));
                }
            }, intervalTime);
        });
    }
    function delay(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }
    function bootstrap() {
        if (W && W.map && W.model && W.loginManager.user && $ && WazeWrap.Ready) {
            init();
        } else {
            setTimeout(bootstrap, 500);
        }
    }
    function init() {
        console.log("WME City Name Update: Initialized");
        createUI();
        registerHotkeys();
    }
    function createUI() {
        const panel = document.createElement('div');
        panel.id = 'batch-updater-panel';
        panel.style.cssText = `
            position: fixed;
            top: 80px;
            left: 15px;
            background: #fff;
            border: 1px solid #ccc;
            padding: 10px;
            z-index: 1000;
            border-radius: 5px;
            box-shadow: 0 2px 5px rgba(0,0,0,0.2);
            font-family: sans-serif;
            font-size: 14px;
            width: 280px;
        `;
        panel.innerHTML = `
        <h3 id="navigator-header" style="margin-top:0; cursor: move;">WME City Name Update</h3>
        <div>
            <label for="excel_file">1. Tải file Excel:</label>
            <input type="file" id="excel_file" accept=".xlsx, .xls, .csv" style="margin-top:5px; width: 100%;"/>
        </div>
        <div style="margin-top: 5px;">
             <label for="url_column">Cột chứa URL:</label>
             <input type="text" id="url_column" value="F" size="2" style="text-transform: uppercase;">
        </div>
        <div id="status_info" style="margin-top:10px; font-style:italic;">Chưa có file nào được tải.</div>
        <hr style="margin: 10px 0;">
        <div style="margin-top: 10px; display: flex; justify-content: space-between; align-items: center;">
            <button id="prev_btn" disabled>◀ Trước</button>
            <input type="number" id="nav_index_input" min="1" style="width: 50px; text-align: center;" disabled>
            <span id="nav_total_count">/ N/A</span>
            <button id="next_btn" disabled> Tiếp theo ▶</button>
        </div>
        <button id="reselect_btn" style="width: 100%; margin-top: 10px; background-color: #f0ad4e; color: white; padding: 10px; border: none; cursor: pointer;" disabled>Tải lại & Chọn</button>
        <button id="update_city_btn" style="width: 100%; margin-top: 10px; background-color: #4CAF50; color: white; padding: 10px; border: none; cursor: pointer;" disabled>Áp dụng T.Phố</button>
        <div>
            <label>2. Loại đối tượng:</label><br>
            <input type="radio" id="type_street" name="object_type" value="street" checked> <label for="type_street">Đường (Street)</label><br>
            <input type="radio" id="type_place" name="object_type" value="place"> <label for="type_place">Địa điểm (Place)</label>
        </div>
        <div style="margin-top: 10px;">
            <label for="new_city_name">3. Tên thành phố:</label>
            <input type="text" id="new_city_name" placeholder="Nhập tên thành phố..." style="width: 95%; margin-top:5px;"/>
        </div>
        <div id="log_info" style="margin-top:10px; font-size: 12px; height: 80px; overflow-y: auto; border: 1px solid #eee; padding: 5px; background: #f8f9fa;"></div>
    `;
        document.body.appendChild(panel);
        document.getElementById('excel_file').addEventListener('change', handleFile, false);
        document.getElementById('prev_btn').addEventListener('click', () => navigate(-1));
        document.getElementById('next_btn').addEventListener('click', () => navigate(1));
        document.getElementById('reselect_btn').addEventListener('click', processCurrentLink);
        document.getElementById('update_city_btn').addEventListener('click', updateCityForSelection);
        document.getElementById('nav_index_input').addEventListener('keypress', (e) => {
            if (e.key === 'Enter') {
                const targetIndex = parseInt(e.target.value, 10);
                if (!isNaN(targetIndex)) {
                    navigate(0, targetIndex - 1);
                } else {
                    log('Vui lòng nhập một số hợp lệ cho chỉ số.');
                }
            }
        });
        makeDraggable(panel, document.getElementById('navigator-header'));
    }
    function makeDraggable(panel, header) {
        let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
        header.onmousedown = (e) => {
            e.preventDefault();
            pos3 = e.clientX;
            pos4 = e.clientY;
            document.onmouseup = () => {
                document.onmouseup = null;
                document.onmousemove = null;
            };
            document.onmousemove = (e) => {
                e.preventDefault();
                pos1 = pos3 - e.clientX;
                pos2 = pos4 - e.clientY;
                pos3 = e.clientX;
                pos4 = e.clientY;
                panel.style.top = (panel.offsetTop - pos2) + "px";
                panel.style.left = (panel.offsetLeft - pos1) + "px";
            };
        };
    }
    function log(message) {
        const logBox = document.getElementById('log_info');
        if (logBox) {
            logBox.innerHTML = `[${new Date().toLocaleTimeString()}] ${message}<br>` + logBox.innerHTML;
        }
        console.log(`[Batch Updater] ${message}`);
    }
    function handleFile(e) {
        permalinks = [];
        currentIndex = -1;
        const file = e.target.files[0];
        const urlColumnInput = document.getElementById('url_column').value.toUpperCase();
        const urlColumnIndex = urlColumnInput.charCodeAt(0) - 'A'.charCodeAt(0);
        if (urlColumnIndex < 0 || urlColumnIndex > 25) {
            log(`Lỗi: Cột "${urlColumnInput}" không hợp lệ. Vui lòng nhập A-Z.`);
            return;
        }
        const reader = new FileReader();
        reader.onload = (e) => {
            const data = new Uint8Array(e.target.result);
            const workbook = XLSX.read(data, { type: 'array' });
            const firstSheetName = workbook.SheetNames[0];
            const worksheet = workbook.Sheets[firstSheetName];
            const json = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
            for (let i = 1; i < json.length; i++) {
                const row = json[i];
                if (row && row.length > urlColumnIndex) {
                    const permalink = row[urlColumnIndex];
                    if (permalink && typeof permalink === 'string' && (permalink.includes('waze.com/editor') || permalink.includes('waze.com/ul'))) {
                        permalinks.push(permalink);
                    }
                }
            }
            if (permalinks.length > 0) {
                currentIndex = 0;
                updateUIState();
                processCurrentLink();
            } else {
                log(`Không tìm thấy URL hợp lệ trong cột ${urlColumnInput} của file.`);
                updateUIState();
            }
            updateUIState();
        };
        reader.readAsArrayBuffer(file);
    }
    function updateUIState() {
        const hasLinks = permalinks.length > 0;
        const navIndexInput = document.getElementById('nav_index_input');
        const navTotalCount = document.getElementById('nav_total_count');
        document.getElementById('prev_btn').disabled = !hasLinks || currentIndex <= 0;
        document.getElementById('next_btn').disabled = !hasLinks || currentIndex >= permalinks.length - 1;
        document.getElementById('reselect_btn').disabled = !hasLinks;
        document.getElementById('update_city_btn').disabled = !hasLinks;
        navIndexInput.disabled = !hasLinks;
        navIndexInput.max = permalinks.length;
        if (hasLinks) {
            navIndexInput.value = currentIndex + 1;
            navTotalCount.textContent = ` / ${permalinks.length}`;
            document.getElementById('status_info').textContent = `Đã tải ${permalinks.length} URL.`;
        } else {
            navIndexInput.value = '';
            navTotalCount.textContent = '/ N/A';
            document.getElementById('status_info').textContent = 'Chưa có file nào được tải.';
        }
    }
    function registerHotkeys() {
        document.addEventListener('keydown', (e) => {
            if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
                return;
            }
            switch (e.key) {
                case 'ArrowRight': // Mũi tên phải: Next
                    e.preventDefault();
                    document.getElementById('next_btn').click();
                    break;
                case 'ArrowLeft': // Mũi tên trái: Previous
                    e.preventDefault();
                    document.getElementById('prev_btn').click();
                    break;
                case 'ArrowUp': // Mũi tên lên: Reselect
                    e.preventDefault();
                    document.getElementById('reselect_btn').click();
                    break;
                case 'ArrowDown': // Mũi tên xuống: Update City
                    e.preventDefault();
                    document.getElementById('update_city_btn').click();
                    break;
            }
        });
    }
    function navigate(direction, targetIndex = null) {
        if (permalinks.length === 0) {
            log('Chưa có URL nào được tải.');
            return;
        }
        let newIndex;
        if (targetIndex !== null) {
            newIndex = targetIndex;
        } else {
            newIndex = currentIndex + direction;
        }
        if (newIndex >= 0 && newIndex < permalinks.length) {
            currentIndex = newIndex;
            updateUIState();
            processCurrentLink();
        } else {
            log('Đã ở đầu hoặc cuối danh sách, hoặc chỉ số không hợp lệ.');
            updateUIState();
        }
    }
    function processCurrentLink() {
        if (currentIndex < 0 || currentIndex >= permalinks.length) return log('Vị trí không hợp lệ.');
        const url = permalinks[currentIndex];
        parseWazeUrlAndNavigate(url);
    }
    async function parseWazeUrlAndNavigate(url) {
        try {
            const params = new URL(url).searchParams;
            const lon = parseFloat(params.get('lon'));
            const lat = parseFloat(params.get('lat'));
            const zoom = parseInt(params.get('zoomLevel') || params.get('zoom'), 10) + 2;
            const segmentIDs = (params.get('segments') || '').split(',').filter(id => id);
            const venueIDs = (params.get('venues') || '').split(',').filter(id => id);
            if (!lon || !lat) {
                log('Lỗi: URL không chứa tọa độ (lon/lat).');
                return;
            }
            W.map.setCenter(WazeWrap.Geometry.ConvertTo900913(lon, lat), zoom);
            await delay(1000);
            WazeWrap.Model.onModelReady(() => {
                (async () => {
                    await delay(500);
                    let objectsToSelect = [];
                    if (segmentIDs.length > 0) {
                        const segments = segmentIDs.map(id => W.model.segments.getObjectById(id)).filter(Boolean);
                        if (segments.length === 0) {
                            log(`Cảnh báo: Không tìm thấy segment nào trên bản đồ từ ID ${segmentIDs.join(',')} sau khi tải.`);
                        } else {
                            objectsToSelect.push(...segments);
                        }
                    }
                    if (venueIDs.length > 0) {
                        const venues = venueIDs.map(id => W.model.venues.getObjectById(id)).filter(Boolean);
                        if (venues.length === 0) {
                            log(`Cảnh báo: Không tìm thấy venue nào trên bản đồ từ ID ${venueIDs.join(',')} sau khi tải.`);
                        } else {
                            objectsToSelect.push(...venues);
                        }
                    }
                    if (objectsToSelect.length > 0) {
                        W.selectionManager.setSelectedModels(objectsToSelect);
                    } else {
                        log('Cảnh báo: Không tìm thấy đối tượng nào trên bản đồ từ ID trong URL.');
                    }
                })();
            }, true);
        } catch (error) {
            log(`Lỗi khi xử lý URL: ${error.message}`);
            console.error(error);
        }
    }
    async function updateCityForSelection() {
        const newCityName = document.getElementById('new_city_name').value.trim();
        if (!newCityName) {
            alert('Vui lòng nhập tên thành phố mới!');
            return;
        }
        const objectType = document.querySelector('input[name="object_type"]:checked').value;
        try {
            if (objectType === 'street') {
                const previewSpanSelector = document.querySelector("#segment-edit-general > div:nth-child(1) > div > div > div.preview > wz-card > div > span")
                if (previewSpanSelector && previewSpanSelector.textContent.includes('Multiple streets')) {
                    log('Phát hiện nhiều đối tượng đang được chọn (Multiple). Không thể cập nhật thành phố.');
                    alert('Không thể cập nhật thành phố khi có nhiều đối tượng được chọn (Multiple).');
                    return;
                }
                (async () => {
                    try {
                        const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
                        await document.querySelectorAll('.w-icon.w-icon-pencil-fill.edit-button').forEach(btn => btn.click());
                        await delay(100);
                        const input = await document.querySelector("#segment-edit-general > div:nth-child(1) > div > div > wz-card > form > div:nth-child(3) > wz-autocomplete").shadowRoot.querySelector("#text-input");
                        await delay(100);
                        input.value = newCityName;
                        input.dispatchEvent(new Event("input", { bubbles: true }));
                        input.dispatchEvent(new Event("change", { bubbles: true }));
                        await delay(100);
                        await document.querySelector("#segment-edit-general > div:nth-child(1) > div > div > wz-card > form > div:nth-child(3) > wz-autocomplete").shadowRoot.querySelector("div > wz-menu > wz-menu-item:nth-child(2) > div > div > div > wz-autocomplete-item-text > div").click()
                        await delay(100);
                        await document.querySelectorAll(".save-button").forEach(btn => btn.click());
                    } catch (err) {
                        console.error("❌ Lỗi:", err);
                    }
                })();
            } else {
                (async () => {
                    try {
                        const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
                        await document.querySelectorAll('.w-icon.w-icon-pencil-fill.edit-button').forEach(btn => btn.click());
                        await delay(100);
                        const input = await document.querySelector("#venue-edit-general > div:nth-child(1) > div > div.address-edit-view > wz-card > form > div:nth-child(4) > wz-autocomplete").shadowRoot.querySelector("#text-input");
                        await delay(100);
                        input.value = newCityName;
                        input.dispatchEvent(new Event("input", { bubbles: true }));
                        input.dispatchEvent(new Event("change", { bubbles: true }));
                        await delay(100);
                        await document.querySelector("#venue-edit-general > div:nth-child(1) > div > div > wz-card > form > div:nth-child(4) > wz-autocomplete").shadowRoot.querySelector("div > wz-menu > wz-menu-item:nth-child(2) > div > div > div > wz-autocomplete-item-text > div").click()
                        await delay(100);
                        await document.querySelectorAll(".save-button").forEach(btn => btn.click());
                    } catch (err) {
                        console.error("❌ Lỗi:", err);
                    }
                })();
            }
        } catch (err) {
            console.error("❌ Lỗi:", err);
        }
    }
    bootstrap();
})();