Xây dựng và chạy các chuỗi tác vụ tự động hóa (workflows) tùy chỉnh trong WME. Tự động điều hướng từ file Excel/CSV/GG Sheets và thực thi các hành động được điều chỉnh sẵn bằng WME SDK.
当前为
// ==UserScript== // @name WME Workflow Engine // @namespace https://greasyfork.org/ // @version 2.1.0 // @description Xây dựng và chạy các chuỗi tác vụ tự động hóa (workflows) tùy chỉnh trong WME. Tự động điều hướng từ file Excel/CSV/GG Sheets và thực thi các hành động được điều chỉnh sẵn bằng WME SDK. // @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* // @connect vinfastauto.com // @connect script.google.com // @connect googleusercontent.com // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @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 // @require https://update.greasyfork.org/scripts/389765/1090053/CommonUtils.js // @require https://update.greasyfork.org/scripts/450160/1704233/WME-Bootstrap.js // @require https://update.greasyfork.org/scripts/450221/1691071/WME-Base.js // @require https://update.greasyfork.org/scripts/450320/1688694/WME-UI.js // @require https://cdn.jsdelivr.net/npm/@turf/[email protected]/turf.min.js // ==/UserScript== /* global require */ /* global $, jQuery */ /* global I18n */ /* global WMEBase, WMEUI, WMEUIHelper, WMEUIHelperFieldset */ /* global Container, Settings, SimpleCache, Tools */ /* global Node$1, Segment, Venue, VenueAddress, WmeSDK */ /* global W, WazeWrap, XLSX */ /* global turf */ (function () { 'use strict'; let permalinks = []; let currentIndex = -1; let allWorkflows = {}; let isLooping = false; // Biến này chỉ ra rằng vòng lặp đang hoạt động hoặc được yêu cầu dừng let workbookData = null; // Lưu workbook để ghi đè let currentFileName = ''; // Tên file hiện tại let statusColumnIndex = -1; // Vị trí cột Status let hasUnsavedChanges = false; let previousIndex = -1; let currentApiData = null; // Lưu dữ liệu JSON trả về từ API let currentRowData = []; // Lưu dữ liệu thô của hàng hiện tại trong Excel let wmeSDK = null; const STORAGE_KEY_SETTINGS = 'wme_wfe_gas_settings'; const STATUS_COL_NAME = 'Status'; let isGasMode = false; let gasHeaders = null; let selectedSubCategory = 'CAR_WASH'; // Provider registry for external charge station providers (extensible) const PROVIDERS_FETCH_API = { vinfast: { brand: "VinFast", async fetchData(id) { return new Promise((resolve, reject) => { if (!id) { PROVIDERS_FETCH_API.vinfast.updatePanel(null); return resolve(null); } GM_xmlhttpRequest({ method: "GET", url: `https://vinfastauto.com/vn_vi/get-locator/${id}`, onload: function (response) { if (response.status === 200) { try { const json = JSON.parse(response.responseText); if (json && json.data) { currentApiData = json.data; PROVIDERS_FETCH_API.vinfast.updatePanel(currentApiData); resolve(json.data); } else { log('API trả về nhưng không có dữ liệu data.', 'warn'); PROVIDERS_FETCH_API.vinfast.updatePanel(null); resolve(null); } } catch (e) { log('Lỗi parse JSON API.', 'error'); PROVIDERS_FETCH_API.vinfast.updatePanel(null); reject(e); } } else { log(`Lỗi gọi API: Status ${response.status}`, 'error'); PROVIDERS_FETCH_API.vinfast.updatePanel(null); reject(new Error(response.statusText)); } }, onerror: function (err) { log('Lỗi kết nối mạng API.', 'error'); PROVIDERS_FETCH_API.vinfast.updatePanel(null); reject(err); } }); }); }, showImagesPopup() { if (!currentApiData || !currentApiData.data.images || currentApiData.data.images.length === 0) { alert("Không có dữ liệu ảnh."); return; } const images = currentApiData.data.images; let html = `<div style="display: flex; flex-wrap: wrap; gap: 15px; justify-content: center; padding: 10px;">`; images.forEach(img => { html += `<a href="${img.url}" target="_blank"><img src="${img.url}" style="max-width: 300px; height: auto; object-fit: cover; border-radius: 4px; border: 1px solid #ccc; cursor: zoom-in;"></a>`; }); html += `</div>`; let imgModal = document.getElementById('img-modal-overlay'); if (!imgModal) { imgModal = document.createElement('div'); imgModal.id = 'img-modal-overlay'; imgModal.style.cssText = ` position: fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.7); z-index:2000; display:flex; justify-content:center; align-items:center; `; imgModal.addEventListener('click', (e) => { if (e.target === imgModal) imgModal.remove(); }); document.body.appendChild(imgModal); } imgModal.innerHTML = ` <div style="background:white; padding:20px; border-radius:8px; max-width:80%; max-height:90%; overflow:auto; position:relative;"> <button id="close-img-modal" style="position:absolute; right:10px; top:10px; border:none; background:none; font-size:20px; cursor:pointer;">✖</button> <h3>Ảnh Trạm Sạc: ${currentApiData.name}</h3> ${html} </div> `; document.getElementById('close-img-modal').addEventListener('click', () => { imgModal.remove(); }); }, renderConnectors(evses) { const connectorInfo = document.getElementById('vf-connectors-info'); if (!connectorInfo) return; if (!evses || evses.length === 0) { connectorInfo.innerHTML = '<i style="color: #777;">Không có cổng sạc nào được liệt kê.</i>'; return; } const acData = {}; const dcData = {}; evses.forEach(evse => { if (evse.connectors) { evse.connectors.forEach(conn => { const powerKw = Math.round(conn.max_electric_power / 1000); const standard = conn.standard || ""; const type = conn.power_type || ""; let typeKey = standard; if (type === 'AC' && !typeKey) typeKey = 'IEC_62196_T2'; if (type === 'DC' && !typeKey) typeKey = 'CCS1'; if (type === 'AC' || (standard.includes('IEC_62196_T2') && !standard.includes('COMBO'))) { if (!acData[powerKw]) acData[powerKw] = { count: 0, typeKey }; acData[powerKw].count++; } else { if (!dcData[powerKw]) dcData[powerKw] = { count: 0, typeKey }; dcData[powerKw].count++; } }); } }); const createBtns = (dataObj, bgColor, borderColor) => { const keys = Object.keys(dataObj).sort((a, b) => a - b); if (keys.length === 0) return ''; return keys.map(kw => { const item = dataObj[kw]; return `<button class="vf-connector-btn" data-type="${item.typeKey}" data-power="${kw}" data-count="${item.count}" style="cursor:pointer; border:1px solid ${borderColor}; background:${bgColor}; padding:4px 8px; margin:2px; border-radius:4px; font-weight:bold; font-size: 11px; color: #333;"> ➕ ${kw} kW (x${item.count}) </button>`; }).join(" "); }; let html = ''; const acHtml = createBtns(acData, '#e8f5e9', '#c8e6c9'); if (acHtml) { html += `<div style="margin-bottom: 8px;"><strong style="color: #2e7d32; font-size: 12px;">⚡ AC (Type 2)</strong><br>${acHtml}</div>`; } const dcHtml = createBtns(dcData, '#ffebee', '#ffcdd2'); if (dcHtml) { html += `<div><strong style="color: #c62828; font-size: 12px;">🚀 DC (CCS2)</strong><br>${dcHtml}</div>`; } if (html === '') { connectorInfo.innerHTML = '<i style="color: #777;">Không tìm thấy cổng chuẩn.</i>'; } else { connectorInfo.innerHTML = html; connectorInfo.querySelectorAll('.vf-connector-btn').forEach(btn => { btn.onclick = (e) => { e.stopPropagation(); const type = btn.getAttribute('data-type'); const power = parseInt(btn.getAttribute('data-power')); const count = parseInt(btn.getAttribute('data-count')); const originalText = btn.innerHTML; btn.innerHTML = "⏳..."; btn.disabled = true; setTimeout(() => { PROVIDERS_FETCH_API.vinfast.addChargeToWmeDirectly(type, power, count); btn.innerHTML = originalText; btn.disabled = false; }, 50); }; }); } }, addChargeToWmeDirectly(typeKey, power, count, venueModel) { try { const venue = venueModel && venueModel.attributes ? venueModel : (WazeWrap.getSelectedFeatures()[0] && WazeWrap.getSelectedFeatures()[0].WW ? WazeWrap.getSelectedFeatures()[0].WW.getObjectModel() : null); if (!venue) throw new Error('No venue model available for addChargeToWmeDirectly'); const venueId = venue.attributes.id; let currentCategoryAttrs = venue.attributes.categoryAttributes; let newAttrs = currentCategoryAttrs ? JSON.parse(JSON.stringify(currentCategoryAttrs)) : {}; const wmeTypeID = CHARGER_TYPE_MAP[typeKey] || 'UNKNOWN'; const portId = `${wmeTypeID}.${power}`; if (!Array.isArray(newAttrs.CHARGING_STATION?.chargingPorts)) { if (!newAttrs.CHARGING_STATION) newAttrs.CHARGING_STATION = {}; newAttrs.CHARGING_STATION.chargingPorts = []; } let ports = newAttrs.CHARGING_STATION.chargingPorts; const existingIndex = ports.findIndex(p => p.maxChargeSpeedKw === power && p.connectorTypes.includes(wmeTypeID) ); if (existingIndex !== -1) { ports[existingIndex].count = count; } else { ports.push({ portId, connectorTypes: [wmeTypeID], maxChargeSpeedKw: power, count }); } newAttrs.CHARGING_STATION.accessType = "PUBLIC"; newAttrs.CHARGING_STATION.paymentMethods = ["CREDIT", "APP", "DEBIT", "ONLINE_PAYMENT"]; newAttrs.CHARGING_STATION.costType = "FEE"; newAttrs.CHARGING_STATION.network = "Vinfast (V-Green)"; newAttrs.CHARGING_STATION.locationInVenue = `${currentApiData.address}`; try { let UpdateObject = require("Waze/Action/UpdateObject"); W.model.actionManager.add(new UpdateObject(venue, { 'categoryAttributes': newAttrs })); log(`✓ Lưu venue ${venueId} thành công!`, 'success'); return true; } catch (actionError) { log("⚠️ Fallback: setAttribute", 'warn'); venue.setAttribute('categoryAttributes', newAttrs); return true; } } catch (e) { log(`❌ Lỗi: ${e.message}`, 'error'); console.error(e); return false; } }, updatePanel(data) { // If panel wasn't created (user requested it disabled), skip UI updates const panelRoot = document.getElementById('vf-panel'); if (!panelRoot) return; const displayDiv = document.getElementById('vf-data-display'); const nameText = document.getElementById('vf-name-text'); const imagesBtn = document.getElementById('vf-images-btn'); const fetchBtn = document.getElementById('vf-fetch-btn'); if (data) { displayDiv.style.display = 'block'; nameText.textContent = data.name || 'N/A'; const imagesCount = data.data.images ? data.data.images.length : 0; imagesBtn.disabled = imagesCount === 0; imagesBtn.textContent = `📷 Xem ${imagesCount} Ảnh`; PROVIDERS_FETCH_API.vinfast.renderConnectors(data.data.evses || []); fetchBtn.textContent = 'Fetch'; fetchBtn.disabled = false; } else { displayDiv.style.display = 'none'; imagesBtn.disabled = true; imagesBtn.textContent = '📷 Xem Ảnh'; const connectorInfo = document.getElementById('vf-connectors-info'); if (connectorInfo) connectorInfo.innerHTML = '<i style="color: #777;">Chưa có dữ liệu cổng sạc.</i>'; currentApiData = null; fetchBtn.textContent = 'Fetch'; fetchBtn.disabled = false; } }, async transferNameToWME(venueModel) { if (!currentApiData || !currentApiData.name) { log("Không có tên trạm VinFast để chuyển.", 'warn'); return; } const newName = `Trạm sạc VinFast - ${currentApiData.name}`.replace(/Cửa hàng xăng dầu/gi, 'CHXD'); const nameUpdated = await updateField( '#venue-edit-general > div:nth-child(2) > wz-text-input', 'input', newName, ); if (!nameUpdated) return; const targetVenueId = venueModel && venueModel.attributes ? venueModel.attributes.id : (WazeWrap.getSelectedFeatures()[0] && WazeWrap.getSelectedFeatures()[0].WW ? WazeWrap.getSelectedFeatures()[0].WW.getObjectModel().attributes.id : null); if (targetVenueId) { wmeSDK.DataModel.Venues.updateVenue({ venueId: targetVenueId, lockRank: 2, phone: '1900 2323 89', url: 'vinfastauto.com', openingHours: [{ days: [0, 1, 2, 3, 4, 5, 6], fromHour: "00:00", toHour: "00:00" }] }); } if (!currentApiData.data || !currentApiData.data.evses) { log("Không có dữ liệu cổng sạc để tự động thêm.", 'warn'); return; } await delay(200); try { const evses = currentApiData.data.evses; const portGroups = {}; evses.forEach(evse => { if (evse.connectors) { evse.connectors.forEach(conn => { const powerKw = Math.round(conn.max_electric_power / 1000); let standard = conn.standard || ""; let type = conn.power_type || ""; let typeKey = standard; if (type === 'AC' && !typeKey) typeKey = 'IEC_62196_T2'; if (type === 'DC' && !typeKey) typeKey = 'CCS1'; const key = `${typeKey}::${powerKw}`; if (!portGroups[key]) portGroups[key] = { typeKey, power: powerKw, count: 0 }; portGroups[key].count++; }); } }); const keys = Object.keys(portGroups); if (keys.length === 0) { log("Không tìm thấy cổng sạc chuẩn nào để thêm.", 'warn'); return; } for (const key of keys) { const item = portGroups[key]; PROVIDERS_FETCH_API.vinfast.addChargeToWmeDirectly(item.typeKey, item.power, item.count); await delay(150); } log(`✓ Hoàn tất: Đã thêm ${keys.length} loại cổng sạc.`, 'success'); } catch (err) { log(`Lỗi khi tự động thêm sạc: ${err.message}`, 'error'); } } }, } const PROVIDERS_NON_API = { //['', 'MIPECORP', 'PV Oil', 'Petrolimex', 'SaigonPetro', 'Satra', 'Thalexim'] petrolimex: { brand: 'Petrolimex', url: 'petrolimex.com.vn', phone: '1900 2828' }, saigonpetro: { brand: 'SaigonPetro', url: 'saigonpetro.com.vn', }, pvoil: { brand: 'PV Oil', url: 'pvoil.com.vn' }, mipecorp: { brand: 'MIPECORP', url: 'mipecorp.com.vn' }, evone: { brand: 'ev-one.vn' } } const CHARGER_TYPE_MAP = { 'IEC_62196_T2': 'TYPE2', // Type 2 'IEC_62196_T2_COMBO': 'CCS_TYPE2', // CCS2 'CCS1': 'CCS_TYPE1', 'CHADEMO': 'CHADEMO', 'TESLA': 'TESLA', 'J1772': 'TYPE1', 'WALL': 'WALL_OUTLET', 'GB_T': 'GB_T' }; const SDK_REGISTRY = { "update_charge_station_api": { name: "Cập nhật Thông tin trạm sạc có fetch API", description: "Dựa vào {{tên cột}} là lấy cột ID trong bảng tính, {{value}} là tên nhà cung cấp dịch vụ", params: [ { key: "id", label: "Tên cột chứa ID để fetch iteration data vd: https://<domain>.com/{id}", type: "text", placeholder: "{{A}}" }, { key: "provider", label: "Tên nhà cung cấp (vinfast,...)", type: "text", placeholder: "{{value}}" } ] }, "update_gas_station": { name: "Cập nhật Thông tin trạm xăng", description: "Dựa vào {{tên cột}} để lấy tên cửa hàng trong bảng tính", params: [ { key: 'name', label: 'Tên cột chứa tên cửa hàng', type: 'text', placeholder: "{{A}}" }, { key: "provider", label: "Tên nhà cung cấp viết liền, không viết hoa (petrolimex,saigonpetro,...)" }, { key: "openHours", label: "Cột giờ mở cửa (vd: 07:00 SA - 05:00 CH)", type: "text", placeholder: "{{F}} (Optional)" }, { key: "phone", label: "Cột số điện thoại (vd: 09000..00)", type: "text", placeholder: "{{C}} (Optional)" } ] }, "update_lock_rank": { name: "Khóa đối tượng (Lock Rank)", description: "Đặt cấp độ khóa cho đối tượng.", params: [ { key: "rank", label: "Cấp độ (1-6)", type: "number", min: 1, max: 6, placeholder: "3" } ] }, "update_segment_city": { name: "Cập nhật tên tỉnh,tp/xã phường mới cho đường", description: "Đổi tên tỉnh,tp/xã phường mới cho các Segment đang chọn. Dùng {{value}} để lấy từ ô nhập liệu.", params: [ { key: "cityName", label: "Tên TP mới", type: "text", placeholder: "{{value}}" } ] }, }; const defaultWorkflows = { "update_vinfast_charge_station": { name: "Cập nhật dữ liệu trạm sạc VF", tasks: [ { taskId: "update_charge_station_api", enabled: true, params: { id: "{{entity_id}}", provider: "Vinfast" } }, { taskId: "update_lock_rank", enabled: true, params: { rank: "2" } } ] }, "wf_update_gas_saition": { name: "Cập nhật dữ liệu trạm xăng", tasks: [ { taskId: "update_gas_station", enabled: true, params: { name: "{{A}}", provider: "Petrolimex", openHours: "{{F}}", phone: "{{C}}" } } ] }, "wf_update_segment_city": { name: "Đổi tên tỉnh,tp/xã phường mới cho Segments", tasks: [ { taskId: "update_segment_city", enabled: true, params: { cityName: "{{value}}" } } ] } }; const SETTINGS = { options: { modal: true, transparent: false, entryPoint: true, lock: true, }, ranges: { radius: 200, collapse: 3, }, providers: { magic: true } } let CATEGORIES = [ { key: 'CAR_SERVICES', subs: ['CAR_WASH', 'CHARGING_STATION', 'GARAGE_AUTOMOTIVE_SHOP', 'GAS_STATION'] }, { key: 'CRISIS_LOCATIONS', subs: ['DONATION_CENTERS', 'SHELTER_LOCATIONS'] }, { key: 'CULTURE_AND_ENTERTAINEMENT', subs: ['ART_GALLERY', 'CASINO', 'CLUB', 'TOURIST_ATTRACTION_HISTORIC_SITE', 'MOVIE_THEATER', 'MUSEUM', 'MUSIC_VENUE', 'PERFORMING_ARTS_VENUE', 'GAME_CLUB', 'STADIUM_ARENA', 'THEME_PARK', 'ZOO_AQUARIUM', 'RACING_TRACK', 'THEATER'], }, { key: 'FOOD_AND_DRINK', subs: ['RESTAURANT', 'BAKERY', 'DESSERT', 'CAFE', 'FAST_FOOD', 'FOOD_COURT', 'BAR', 'ICE_CREAM'] }, { key: 'LODGING', subs: ['HOTEL', 'HOSTEL', 'CAMPING_TRAILER_PARK', 'COTTAGE_CABIN', 'BED_AND_BREAKFAST'] }, { key: 'NATURAL_FEATURES', subs: ['ISLAND', 'SEA_LAKE_POOL', 'RIVER_STREAM', 'FOREST_GROVE', 'FARM', 'CANAL', 'SWAMP_MARSH', 'DAM'] }, { key: 'OTHER', subs: ['CONSTRUCTION_SITE'] }, { key: 'OUTDOORS', subs: ['PARK', 'PLAYGROUND', 'BEACH', 'SPORTS_COURT', 'GOLF_COURSE', 'PLAZA', 'PROMENADE', 'POOL', 'SCENIC_LOOKOUT_VIEWPOINT', 'SKI_AREA'] }, { key: 'PARKING_LOT', subs: ['PARKING_LOT'] }, { key: 'PROFESSIONAL_AND_PUBLIC', subs: [ 'COLLEGE_UNIVERSITY', 'SCHOOL', 'CONVENTIONS_EVENT_CENTER', 'GOVERNMENT', 'LIBRARY', 'CITY_HALL', 'ORGANIZATION_OR_ASSOCIATION', 'PRISON_CORRECTIONAL_FACILITY', 'COURTHOUSE', 'CEMETERY', 'FIRE_DEPARTMENT', 'POLICE_STATION', 'MILITARY', 'HOSPITAL_URGENT_CARE', 'DOCTOR_CLINIC', 'OFFICES', 'POST_OFFICE', 'RELIGIOUS_CENTER', 'KINDERGARDEN', 'FACTORY_INDUSTRIAL', 'EMBASSY_CONSULATE', 'INFORMATION_POINT', 'EMERGENCY_SHELTER', 'TRASH_AND_RECYCLING_FACILITIES', ], }, { key: 'SHOPPING_AND_SERVICES', subs: [ 'ARTS_AND_CRAFTS', 'BANK_FINANCIAL', 'SPORTING_GOODS', 'BOOKSTORE', 'PHOTOGRAPHY', 'CAR_DEALERSHIP', 'FASHION_AND_CLOTHING', 'CONVENIENCE_STORE', 'PERSONAL_CARE', 'DEPARTMENT_STORE', 'PHARMACY', 'ELECTRONICS', 'FLOWERS', 'FURNITURE_HOME_STORE', 'GIFTS', 'GYM_FITNESS', 'SWIMMING_POOL', 'HARDWARE_STORE', 'MARKET', 'SUPERMARKET_GROCERY', 'JEWELRY', 'LAUNDRY_DRY_CLEAN', 'SHOPPING_CENTER', 'MUSIC_STORE', 'PET_STORE_VETERINARIAN_SERVICES', 'TOY_STORE', 'TRAVEL_AGENCY', 'ATM', 'CURRENCY_EXCHANGE', 'CAR_RENTAL', 'TELECOM', ], }, { key: 'TRANSPORTATION', subs: ['AIRPORT', 'BUS_STATION', 'FERRY_PIER', 'SEAPORT_MARINA_HARBOR', 'SUBWAY_STATION', 'TRAIN_STATION', 'BRIDGE', 'TUNNEL', 'TAXI_STATION', 'JUNCTION_INTERCHANGE', 'REST_AREAS', 'CARPOOL_SPOT'], }, ]; const SETTINGS_STORAGE_KEY = 'wme_wfe_settings'; const DELAY_BETWEEN_LOOPS = 300; const STORAGE_KEY = 'wme_custom_workflows'; function loadSettings() { try { const saved = localStorage.getItem(SETTINGS_STORAGE_KEY); if (saved) { const parsed = JSON.parse(saved); if (parsed.options) SETTINGS.options = Object.assign({}, SETTINGS.options, parsed.options); if (parsed.ranges) SETTINGS.ranges = Object.assign({}, SETTINGS.ranges, parsed.ranges); if (parsed.providers) SETTINGS.providers = Object.assign({}, SETTINGS.providers, parsed.providers); log('Loaded saved settings.'); } } catch (e) { log('Lỗi khi tải cài đặt, sử dụng mặc định.', 'error'); } } function bootstrap() { if (typeof WazeWrap !== 'undefined' && WazeWrap.Init) { WazeWrap.Init(() => { const sdk = typeof unsafeWindow !== 'undefined' && unsafeWindow.getWmeSdk ? unsafeWindow.getWmeSdk({ scriptId: 'wme-wfe', scriptName: 'WME Workflow Engine' }) : getWmeSdk({ scriptId: 'wme-wfe', scriptName: 'WME Workflow Engine' }); init(sdk); }); } else { // Fallback initialization if WazeWrap isn't fully ready (shouldn't happen with @require) if (typeof unsafeWindow !== 'undefined' && unsafeWindow.SDK_INITIALIZED) { unsafeWindow.SDK_INITIALIZED.then(() => { const sdk = unsafeWindow.getWmeSdk({ scriptId: 'wme-wfe', scriptName: 'WME Workflow Engine' }); init(sdk); }); } else if (typeof window.SDK_INITIALIZED !== 'undefined') { window.SDK_INITIALIZED.then(() => { const sdk = window.getWmeSdk({ scriptId: 'wme-wfe', scriptName: 'WME Workflow Engine' }); init(sdk); }); } else { log('WME SDK is not available. Script will not run.', 'error'); } } } function init(sdk) { console.log("WME Workflow Engine: Initialized"); wmeSDK = sdk loadWorkflows(); loadSettings(); createUI(); createWorkflowEditorModal(); populateWorkflowSelector(); updateUIState(); registerHotkeys(); window.addEventListener('beforeunload', (e) => { if (hasUnsavedChanges) { const message = '⚠️ Bạn có thay đổi status chưa được lưu! Nhấn "Cập nhật Status" trước khi thoát.'; e.preventDefault(); e.returnValue = message; return message; } }); } /** * Tạo POI mới trên bản đồ Waze * @param {number} lat - Vĩ độ * @param {number} lon - Kinh độ * @param {string} type - 'point' hoặc 'area' */ function createWazePOI(lat, lon, type) { try { // const center = WazeWrap.Geometry.ConvertTo900913(lon, lat); let drawPromise = type === 'point' ? wmeSDK.Map.drawPoint() : wmeSDK.Map.drawPolygon(); drawPromise .then((geometry) => { // Tìm category object chứa selectedSubCategory let newVenue = wmeSDK.DataModel.Venues.addVenue({ category: selectedSubCategory, geometry: geometry, }); // Add a small delay to ensure the venue is fully created before selecting it setTimeout(() => { wmeSDK.Editing.setSelection({ selection: { ids: [newVenue.toString()], objectType: 'venue', }, }); }, 100); }) .catch((err) => { if (err && err.name === 'InvalidStateError') { log('POI drawing was cancelled by the user.'); } else { log(`Error during POI drawing: ${err.message || err}`, 'error'); console.error('POI creation error details:', err); } }); return true; } catch (err) { log(`Lỗi khi tạo POI: ${err.message}`, 'error'); console.error(err); return false; } } function replacePlaceholders(text) { if (!text || typeof text !== 'string') return text; if (!currentRowData || typeof currentRowData !== 'object') { // Fallback nếu không có dữ liệu hàng được tải return text.replace(/{{[A-Z]+}}/g, match => match).replace(/{{[^}]+}}/g, match => match); } // 1. Thay thế biến từ tên cột ({{Tên cột}}) hoặc chữ cái cột ({{A}}, {{B}}) text = text.replace(/{{([^}]+)}}/g, (match, key) => { const trimmedKey = key.trim(); // Cả chế độ Local và GAS đều sử dụng rowData là object, // nên ta có thể tìm theo tên cột hoặc chữ cái cột (A, B, C...) if (currentRowData[trimmedKey] !== undefined) { return currentRowData[trimmedKey]; } return match; // Trả về placeholder nếu không tìm thấy }); // 2. Thay thế {{value}} const manualValue = document.getElementById('workflow_variable_input').value; text = text.replace('{{value}}', manualValue); return text; } function getColumnLetter(colIndex) { let temp, letter = ''; while (colIndex >= 0) { temp = colIndex % 26; letter = String.fromCharCode(temp + 65) + letter; colIndex = Math.floor(colIndex / 26) - 1; } return letter; } function getColumnIndexFromLetter(colLetter) { let colIndex = 0; for (let i = 0; i < colLetter.length; i++) { colIndex = colIndex * 26 + (colLetter.charCodeAt(i) - 64); } return colIndex - 1; // 0-based index } /** * Tìm một phần tử, hỗ trợ tìm kiếm bên trong Shadow DOM. * @param {string} selector - CSS selector cho phần tử chính. * @param {string} [shadowSelector] - CSS selector cho phần tử bên trong shadow DOM. * @returns {Promise<Element|null>} */ async function findElement(selector, shadowSelector = '') { try { const baseElement = await waitForElement(selector); if (!shadowSelector) { return baseElement; } if (baseElement && baseElement.shadowRoot) { await delay(50); // Small delay for shadow DOM content to fully render const shadowElement = baseElement.shadowRoot.querySelector(shadowSelector); if (!shadowElement) { log(`Lỗi: Không tìm thấy phần tử con với selector "${shadowSelector}" trong shadow DOM của "${selector}".`, 'error'); } return shadowElement; } log(`Lỗi: Không tìm thấy shadow root trên phần tử "${selector}".`, 'error'); return null; } catch (error) { log(`Lỗi khi tìm phần tử "${selector}": ${error.message}`, 'error'); throw error; // Re-throw to propagate the error } } async function updateField(baseSelector, shadowSelector, newValue) { try { const hostElement = document.querySelector(baseSelector); if (!hostElement) { return false; } // Xử lý shadowRoot động let inputElement; if (shadowSelector.startsWith('#wz-textarea')) { // Tìm textarea đầu tiên trong shadowRoot (bỏ qua ID động) inputElement = hostElement.shadowRoot.querySelector('textarea'); } else { inputElement = hostElement.shadowRoot.querySelector(shadowSelector); } if (!inputElement) { return false; } inputElement.focus(); inputElement.value = newValue; inputElement.dispatchEvent(new Event("input", { bubbles: true, cancelable: true })); inputElement.dispatchEvent(new Event("change", { bubbles: true, cancelable: true })); inputElement.blur(); return true; } catch (e) { log(`❌ Lỗi: ${e.message}`, 'error'); return false; } } function findCityIdByName(cityName) { if (!cityName) return null; const targetName = cityName.toString().trim(); const cities = W.model.cities.objects; for (const id in cities) { if (cities.hasOwnProperty(id)) { const city = cities[id]; // Kiểm tra attributes tồn tại và so sánh tên chính xác if (city.attributes && city.attributes.name === targetName) { return city.attributes.id; } } } return null; } function getOrCreateStreet(streetName, cityId) { return wmeSDK.DataModel.Streets.getStreet({ streetName, cityId }) ?? wmeSDK.DataModel.Streets.addStreet({ streetName, cityId }); } function convertTo24Hour(timeStr) { // timeStr ví dụ: "05:00 SA" hoặc "05:00 CH" const parts = timeStr.trim().split(' '); if (parts.length !== 2) return null; let [hourMin, meridiem] = parts; let [hourStr, minuteStr] = hourMin.split(':'); const hour = parseInt(hourStr); const minute = parseInt(minuteStr); if (isNaN(hour) || isNaN(minute)) return null; meridiem = meridiem.toUpperCase(); let hour24 = hour; if (meridiem === 'SA') { if (hour === 12) hour24 = 0; // 12:xx SA -> 00:xx } else if (meridiem === 'CH') { if (hour !== 12) hour24 += 12; // 01:xx CH -> 13:xx } else { return null; // Invalid meridiem } // Format back to HH:MM (zero padding) return `${String(hour24).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; } /** * Parse giờ mở cửa từ định dạng Việt Nam sang cấu trúc WME SDK. * @param {string} openHoursString Ví dụ: "07:00 SA - 05:00 CH" hoặc "24/24" * @returns {Array<Object>|null} WME openingHours array hoặc null nếu lỗi. */ function parseVietnameseOpenHours(openHoursString) { if (!openHoursString) return null; openHoursString = openHoursString.toString().trim().toUpperCase(); if (openHoursString === '24/24') { return [{ days: [0, 1, 2, 3, 4, 5, 6], fromHour: "00:00", toHour: "00:00" }]; } // Regex để bắt: HH:MM SA/CH - HH:MM SA/CH const match = openHoursString.match(/(\d{1,2}:\d{2}\s+(?:SA|CH))\s*-\s*(\d{1,2}:\d{2}\s+(?:SA|CH))/); if (!match) { log(`Không thể parse giờ mở cửa: "${openHoursString}".`, 'warn'); return null; } const from24h = convertTo24Hour(match[1]); const to24h = convertTo24Hour(match[2]); if (from24h && to24h) { // Áp dụng cho cả 7 ngày trong tuần return [{ days: [0, 1, 2, 3, 4, 5, 6], fromHour: from24h, toHour: to24h }]; } else { log(`Lỗi chuyển đổi giờ từ "${openHoursString}" sang 24h.`, 'error'); return null; } } function capitalizeWords(string) { const words = string.split(' '); const capitalizedWords = words.map(word => { if (word.length === 0) return ''; return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); }); return capitalizedWords.join(' '); } /** * Thực thi một Task SDK cụ thể */ async function executeSdkTask(task, selectedFeature) { // Parse params with {{variables}} const parsedParams = {}; for (const [key, val] of Object.entries(task.params)) { parsedParams[key] = replacePlaceholders(val); } const WazeActionUpdateObject = require("Waze/Action/UpdateObject"); const WazeActionUpdateFeatureAddress = require("Waze/Action/UpdateFeatureAddress"); const featureModel = selectedFeature.WW.getObjectModel(); // Lấy Waze Model Object switch (task.taskId) { case "update_lock_rank": { const rank = parseInt(parsedParams.rank) || 1; const modelRank = Math.max(0, Math.min(5, rank - 1)); if (featureModel.attributes.lockRank !== modelRank) { W.model.actionManager.add(new WazeActionUpdateObject(featureModel, { lockRank: modelRank })); } break; } case "update_charge_station_api": { // Provider-based handler for external charge-station APIs (extensible) const providerKey = (parsedParams.provider || 'vinfast').toLowerCase(); const handler = PROVIDERS_FETCH_API[providerKey]; if (!handler) { log(`SDK: Provider "${parsedParams.provider}" chưa được hỗ trợ.`, 'warn'); break; } // Luôn tạo panel của provider nếu nó được gọi, để hiển thị dữ liệu API createProviderPanel(handler); // Gọi hàm tạo panel chung if (!parsedParams.id) { log('SDK: Không có ID API cung cấp cho cập nhật trạm sạc.', 'error'); // Vẫn gọi update panel để xóa dữ liệu cũ handler.updatePanel(null); break; } try { // Lấy dữ liệu const apiData = await handler.fetchData(parsedParams.id); if (!apiData) { log(`SDK: Không lấy được dữ liệu từ API ${handler.brand}.`, 'warn'); break; } currentApiData = apiData; const featureModel = selectedFeature?.WW?.getObjectModel ? selectedFeature.WW.getObjectModel() : null; if (!featureModel) { log('SDK: Không có feature model hợp lệ để cập nhật trạm sạc.', 'error'); break; } // Let provider apply name/venue-level changes if (typeof handler.transferNameToWME === 'function') { await handler.transferNameToWME(featureModel); } log(`SDK: Cập nhật trạm sạc từ API ${handler.brand} hoàn tất.`, 'success'); } catch (err) { log(`SDK: Lỗi khi xử lý API trạm sạc: ${err.message}`, 'error'); console.error(err); } break; } case "update_segment_city": { let cityID = findCityIdByName(parsedParams.cityName); let city = W.model.cities.objects[cityID].attributes; let segmentsSelected = wmeSDK.Editing.getSelection() segmentsSelected?.ids.forEach(segmentId => { // Process the city const newCityProperties = { cityName: city.name, countryId: cityID, }; let newCityId = wmeSDK.DataModel.Cities.getById({ cityId: cityID })?.id; if (newCityId == null) { newCityId = wmeSDK.DataModel.Cities.addCity(newCityProperties).id; } // Process the street const newPrimaryStreetId = getOrCreateStreet(wmeSDK.DataModel.Segments.getAddress({ segmentId: segmentId }).street.name, newCityId).id; // Update the segment with the new street wmeSDK.DataModel.Segments.updateAddress({ segmentId, primaryStreetId: newPrimaryStreetId }); }); break; } case "update_gas_station": { // https://www.waze.com/editor/sdk/classes/index.SDK.Venues.html#updatevenue let venueSelected = wmeSDK.Editing.getSelection() const venueId = venueSelected.ids[0] let provider = parsedParams.provider; let name = parsedParams.name; const providerKey = (provider || '').trim().toLowerCase(); const providerConfig = PROVIDERS_NON_API[providerKey]; if (!providerConfig) { log(`Provider "${parsedParams.provider}" chưa được hỗ trợ trong danh sách cấu hình.`, 'warn'); // Sử dụng tên provider thô nếu không tìm thấy config } let providerName = providerConfig?.brand || parsedParams.provider.toString(); let providerPhone = providerConfig?.phone || ''; let providerUrl = providerConfig?.url || ''; name = name.replace(/Cửa hàng xăng dầu/gi, "CHXD"); if (name.includes(provider)) { name = name.replace(new RegExp(provider, "gi"), "").trim(); } let finalName = `${PROVIDERS_NON_API[providerKey].brand} - ${name}`; const updatePayload = { brand: providerName, lockRank: 2, name: finalName, phone: providerPhone, categories: [selectedSubCategory], url: providerUrl, venueId: venueId, }; if (parsedParams.phone) { const phoneValue = parsedParams.phone.toString().trim(); if (phoneValue) { updatePayload.phone = phoneValue; } } if (parsedParams.openHours) { const hours = parseVietnameseOpenHours(parsedParams.openHours); if (hours) { updatePayload.openingHours = hours; } else { log(`SDK: Bỏ qua giờ mở cửa do parse lỗi hoặc định dạng không khớp.`, 'warn'); } } wmeSDK.DataModel.Venues.updateVenue(updatePayload) } } await delay(100); // Nhỏ delay để UI không bị đơ nếu chạy loop } async function runSelectedWorkflow(isCalledByLoop = false) { const workflowId = document.getElementById('workflow_select').value; if (!workflowId || !allWorkflows[workflowId]) { if (isCalledByLoop) throw new Error("Không workflow hợp lệ."); return alert("Chọn workflow hợp lệ!"); } const workflow = allWorkflows[workflowId]; const selection = WazeWrap.getSelectedFeatures(); if (selection.length === 0) { log("❌ Chưa chọn đối tượng nào trên bản đồ!", "error"); if (isCalledByLoop) throw new Error("Không có selection."); return; } // Với SDK, ta có thể xử lý nhiều object cùng lúc, nhưng ở đây ta loop qua object đầu tiên (hoặc tất cả nếu muốn) // Hiện tại script hỗ trợ workflow chạy trên 1 đối tượng focus từ Excel const target = selection[0]; try { const tasksToRun = (workflow.tasks || []).filter(t => t.enabled); if (tasksToRun.length === 0) { log("Workflow không có hành động nào được bật.", "warn"); return; } const workflowHasChargeApi = tasksToRun.some(t => t.taskId === 'update_charge_station_api'); if (!workflowHasChargeApi) { resetApiPanelState(); } for (const task of tasksToRun) { if (isCalledByLoop && !isLooping) throw new Error("Stopped by user"); await executeSdkTask(task, target); } log(`--- ✅ Hoàn thành Workflow ---`, 'success'); } catch (error) { log(`❌ Lỗi Workflow: ${error.message}`, 'error'); console.error(error); throw error; } } async function toggleWorkflowLoop() { if (isLooping) { // Đang chạy, yêu cầu dừng isLooping = false; log("Đã yêu cầu dừng vòng lặp. Sẽ dừng sau khi hoàn thành hoặc giữa bước hiện tại.", 'warn'); // updateUIState() sẽ được gọi bởi executeLoop khi nó thực sự dừng } else { // Không chạy, bắt đầu vòng lặp if (permalinks.length === 0) { log("Vui lòng tải một file Excel/CSV trước khi bắt đầu vòng lặp.", 'warn'); return; } isLooping = true; // Thiết lập trạng thái đang lặp updateUIState(); // Cập nhật UI để hiển thị nút "Dừng Lặp" log("--- Bắt đầu vòng lặp tự động ---", 'special'); await executeLoop(); } } async function executeLoop() { if (currentIndex < 0 && permalinks.length > 0) { currentIndex = 0; } while (isLooping && currentIndex < permalinks.length) { updateUIState(); updateStatus('Đang tạo'); // Đánh dấu đang làm việc try { await processCurrentLink(); if (!isLooping) { break; } await delay(1500); if (!isLooping) { break; } await runSelectedWorkflow(true); const shouldSavePermalink = document.getElementById('save_permalink_after_create')?.checked; // Kiểm tra có đối tượng đang được chọn (để đảm bảo có link hợp lệ) if (shouldSavePermalink) { const newPermalink = wmeSDK.Map.getPermalink(); if (newPermalink) { updatePermalinkInWorkbook(currentIndex, newPermalink); } else { log(`⚠️ (Loop) Không lấy được Permalink (Đối tượng chưa được lưu hoặc chưa có ID).`, 'warn'); } } // Đánh dấu Đã tạo khi hoàn thành workflow updateStatus('Đã tạo'); } catch (error) { if (isLooping) { log(`Lỗi ở mục ${currentIndex + 1}, bỏ qua và tiếp tục. Lỗi: ${error.message}`, 'error'); } else { log(`Vòng lặp đã dừng tại mục ${currentIndex + 1} do yêu cầu dừng.`, 'warn'); } break; } if (!isLooping) break; if (currentIndex < permalinks.length - 1) { previousIndex = currentIndex; // Lưu index hiện tại currentIndex++; // log(`Đang chờ ${DELAY_BETWEEN_LOOPS / 1000}s trước khi đến mục tiếp theo...`, 'info'); if (!isLooping) { break; } await delay(DELAY_BETWEEN_LOOPS); } else { log("Đã đến mục cuối cùng của danh sách.", 'info'); isLooping = false; } } isLooping = false; if (currentIndex >= permalinks.length && permalinks.length > 0) { log("--- ✅ Hoàn thành vòng lặp tự động! ---", 'special'); } else if (permalinks.length === 0) { log("Không có permalink nào để lặp.", 'warn'); } else { log("--- Vòng lặp tự động đã dừng. ---", 'warn'); } updateUIState(); } function handleFile(e) { isGasMode = false; gasHeaders = null; permalinks = []; currentIndex = -1; previousIndex = -1; hasUnsavedChanges = false; const file = e.target.files[0]; if (!file) { updateUIState(); return; } currentFileName = file.name; const urlColumnInput = document.getElementById('url_column').value.toUpperCase(); const urlColumnIndex = getColumnIndexFromLetter(urlColumnInput); // Hàm helper đã có if (urlColumnIndex < 0 || urlColumnIndex > 255) { log(`Lỗi: Cột "${urlColumnInput}" không hợp lệ. Vui lòng nhập A-IV.`, 'error'); updateUIState(); return; } const reader = new FileReader(); reader.onload = (e) => { try { const data = new Uint8Array(e.target.result); const workbook = XLSX.read(data, { type: 'array' }); workbookData = workbook; const firstSheetName = workbook.SheetNames[0]; const worksheet = workbook.Sheets[firstSheetName]; // Lấy JSON header: 1 (array of arrays) để dễ dàng kiểm soát index const json = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: '' }); if (json.length === 0) { log('File không có dữ liệu.', 'error'); updateUIState(); return; } const headerRow = json[0]; statusColumnIndex = headerRow.findIndex(h => h && h.toString().trim().toLowerCase() === STATUS_COL_NAME.toLowerCase()); let hasExistingStatus = statusColumnIndex !== -1; if (!hasExistingStatus) { statusColumnIndex = headerRow.length; headerRow.push(STATUS_COL_NAME); } const headersMap = headerRow.map(h => h.toString().trim() || null); const permalinkData = []; let foundWorkingIndex = -1; for (let i = 1; i < json.length; i++) { // Bỏ qua hàng tiêu đề (i=0) const rawRow = json[i]; while (rawRow.length < statusColumnIndex + 1) { rawRow.push(''); // Đảm bảo hàng có đủ cột } const cellValue = rawRow[urlColumnIndex]; if (cellValue && typeof cellValue === 'string') { const trimmedValue = cellValue.trim(); const isURL = trimmedValue.includes('waze.com/editor') || trimmedValue.includes('waze.com/ul'); const isCoordinate = /^\s*\(?\s*-?\d+\.?\d*\s*,\s*-?\d+\.?\d*\s*\)?\s*$/.test(trimmedValue); if (isURL || isCoordinate) { // Convert raw array row to an object using headers + column letters const rowObject = {}; rawRow.forEach((val, index) => { const headerName = headersMap[index]; if (headerName) { // Store by header name rowObject[headerName] = val; } // Store by column letter (for {{A}}, {{B}} access) const colLetter = getColumnLetter(index); rowObject[colLetter] = val; }); const status = rawRow[statusColumnIndex].toString().trim(); const newItem = { url: trimmedValue, rowIndex: i, // Row index trong Excel (0-based) status: status, rowData: rowObject, // Lưu object data localFileIndexes: { // Lưu trữ chỉ mục cần thiết cho việc ghi lại urlCol: urlColumnIndex, statusCol: statusColumnIndex, sheetName: firstSheetName } }; permalinkData.push(newItem); if (foundWorkingIndex === -1 && status.toLowerCase() === 'đang tạo') { foundWorkingIndex = permalinkData.length - 1; } if (foundWorkingIndex === -1 && status.toLowerCase() !== 'đã tạo') { foundWorkingIndex = permalinkData.length - 1; } } } } permalinks = permalinkData; // Ghi lại worksheet với cột Status (nếu mới tạo) const newWorksheet = XLSX.utils.aoa_to_sheet(json); workbookData.Sheets[firstSheetName] = newWorksheet; if (permalinks.length > 0) { currentIndex = foundWorkingIndex !== -1 ? foundWorkingIndex : 0; updateStatus('Đang tạo'); // Cập nhật status của mục bắt đầu processCurrentLink(); } else { log(`Không tìm thấy URL hoặc tọa độ hợp lệ trong cột ${urlColumnInput}.`, 'warn'); } updateUIState(); } catch (err) { log(`Lỗi khi đọc file: ${err.message}`, 'error'); console.error(err); updateUIState(); } }; reader.readAsArrayBuffer(file); } function saveWorkflows() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(allWorkflows)); log("Đã lưu các workflows."); } catch (e) { log("Lỗi khi lưu workflows vào localStorage.", 'error'); } } function loadWorkflows() { try { const saved = localStorage.getItem(STORAGE_KEY); if (saved) { allWorkflows = JSON.parse(saved); } else { allWorkflows = { ...defaultWorkflows }; log("Đã tải các workflows mặc định. Các thay đổi sẽ được lưu lại."); } } catch (e) { log("Lỗi khi tải workflows từ localStorage, sử dụng các preset mặc định.", 'error'); allWorkflows = { ...defaultWorkflows }; } } function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function resetApiPanelState() { try { currentApiData = null; // Reset API data if (PROVIDERS_FETCH_API.vinfast) { // Dùng VinFast làm đại diện để reset UI panel PROVIDERS_FETCH_API.vinfast.updatePanel(null); } if (apiProviderPanel) { apiProviderPanel.style.display = 'none'; } } catch (e) { console.error("Error resetting API panel state:", e); } } /** * Cập nhật status cho URL hiện tại */ function updateStatus(status) { const shouldSave = isStatusSavingEnabled(); if (isGasMode) { if (permalinks[currentIndex]) { const item = permalinks[currentIndex]; if (shouldSave) { updateGasStatusByRowIndex(item.rowIndex, status); } item.status = status; updateSaveButtonState(); } } else { if (currentIndex >= 0 && permalinks[currentIndex]) { const item = permalinks[currentIndex]; if (shouldSave) { // Kiểm tra trước khi gọi hàm cập nhật file local _updateLocalStatusCell(item.rowIndex, item.localFileIndexes.statusCol, status, item.localFileIndexes.sheetName); } item.status = status; hasUnsavedChanges = true; updateSaveButtonState(); } } } /** * Tách hàm cập nhật trạng thái ô cụ thể trong workbookData (Local File only) * @param {number} rowIndex - Row index (0-based) * @param {number} colIndex - Column index (0-based) * @param {string} value - New status value * @param {string} sheetName - Target sheet name */ function _updateLocalStatusCell(rowIndex, colIndex, value, sheetName) { if (!workbookData) return; try { const worksheet = workbookData.Sheets[sheetName]; const cellAddress = XLSX.utils.encode_cell({ r: rowIndex, c: colIndex }); // Cập nhật giá trị ô worksheet[cellAddress] = { t: 's', v: value }; // Cập nhật lại trạng thái bộ nhớ để kích hoạt nút Save // hasUnsavedChanges = true; updateSaveButtonState(); } catch (err) { log(`Lỗi khi cập nhật cell [${rowIndex}, ${colIndex}] trong file local.`, 'error'); console.error(err); } } /** * Lưu workbook ra file và trigger download */ function saveWorkbookToFile() { if (isGasMode) { log("Chế độ Google Sheets: Status được lưu tự động lên sheet.", 'info'); hasUnsavedChanges = false; updateSaveButtonState(); return; } if (!workbookData) { log('Không có dữ liệu để lưu.', 'warn'); return; } try { const wbout = XLSX.write(workbookData, { bookType: 'xlsx', type: 'array' }); const blob = new Blob([wbout], { type: 'application/octet-stream' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = currentFileName; a.click(); URL.revokeObjectURL(url); hasUnsavedChanges = false; // Reset cờ sau khi lưu updateSaveButtonState(); } catch (err) { log(`Lỗi khi lưu file: ${err.message}`, 'error'); } } /** * Cập nhật trạng thái nút Save Status */ function updateSaveButtonState() { const saveBtn = document.getElementById('save_status_btn'); if (saveBtn) { saveBtn.disabled = !hasUnsavedChanges; if (hasUnsavedChanges) { saveBtn.classList.add('primary'); saveBtn.style.animation = 'pulse 1.5s infinite'; } else { saveBtn.classList.remove('primary'); saveBtn.style.animation = ''; } } } function waitForElement(selector, timeout = 7000) { return new Promise((resolve, reject) => { const intervalTime = 100; let elapsedTime = 0; const interval = setInterval(() => { const element = document.querySelector(selector); // Check if element exists and is visible (offsetParent is not null) 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 log(message, type = 'normal') { const logBox = document.getElementById('log_info'); if (logBox) { const colorMap = { error: '#c0392b', success: '#27ae60', warn: '#e67e22', info: '#2980b9', special: '#8e44ad', normal: 'inherit' }; const div = document.createElement('div'); div.style.color = colorMap[type]; div.innerHTML = `[${new Date().toLocaleTimeString()}] ${message.replace(/</g, "<").replace(/>/g, ">")}`; // Sanitize HTML logBox.prepend(div); // Add to top // Limit log box to 50 entries while (logBox.children.length > 50) { logBox.removeChild(logBox.lastChild); } } console.log(`[WME Workflow] ${message}`); } function createUI() { const panel = document.createElement('div'); panel.id = 'workflow-engine-panel'; panel.style.cssText = ` position: fixed; top: 80px; left: 15px; background: rgba(255, 255, 255, 0.95); border: 1px solid #ccc; padding: 0; z-index: 1001; border-radius: 8px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 11px; width: 300px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); `; panel.innerHTML = ` <h5 id="navigator-header" style="display: flex; justify-content: space-between; align-items: center; margin:0; padding: 3px 3px; cursor: grab; border-bottom: 1px solid #eee; background: #f7f7f7; border-top-left-radius: 8px; border-top-right-radius: 8px;"> <span>WME Workflow Engine</span> <button id="toggle_panel_btn" title="Thu gọn Panel">▲</button> </h5> <div id="wwe-panel-content" style="padding: 5px;"> <!-- Section 1: Điều khiển chính --> <h6 style="margin-top: 0; margin-bottom: 5px; color: #333;">Điều khiển chính</h6> <div style="display: flex; justify-content: space-between; align-items: center; gap: 5px;"> <button id="prev_btn" class="nav-btn" title="Đối tượng trước (Mũi tên trái)" disabled>◀</button> <div style="display: flex; align-items: center; flex-grow: 1;"> <input type="number" id="nav_index_input" min="1" style="width: 100%; text-align: center;" disabled> <span id="nav_total_count" style="margin-left: 5px; font-weight: bold;">/ N/A</span> </div> <button id="next_btn" class="nav-btn" title="Đối tượng tiếp theo (Mũi tên phải)" disabled>▶</button> </div> <div class="wwe-form-group" style="margin-top: 3px;"> <label for="workflow_select">Chọn tác vụ:</label> <select id="workflow_select"></select> </div> <div class="wwe-form-group"> <label for="workflow_variable_input">Giá trị nhập (cho <code>{{value}}</code>):</label> <input type="text" id="workflow_variable_input" placeholder="Tên tỉnh/xã phường hoặc giá trị khác..." /> </div> <button id="run_workflow_btn" class="action-btn primary" style="width: 100%;" title="Chạy workflow (Mũi tên xuống)" disabled>▶️ Chạy Workflow</button> <button id="loop_workflow_btn" class="action-btn secondary" style="width: 100%; margin-top: 8px;" title="Lặp tự động chạy các tác vụ" disabled>🔁 Bắt đầu Lặp</button> <hr style="border: 0; border-top: 1px solid #eee; margin: 5px 0;"> <!-- Nút Save Status --> <button id="save_status_btn" class="action-btn" style="width: 100%; background-color: #28a745; color: white; border-color: #28a745;" title="Lưu trạng thái vào file" disabled>💾 Cập nhật Status</button> <!-- Section 2: Accordion Items --> <div class="accordion-container" style="margin-top: 5px;"> <!-- Accordion: Tải dữ liệu --> <div class="accordion-item"> <button class="accordion-header">Tải & Cấu hình Dữ liệu</button> <div class="accordion-content"> <div class="wwe-form-group"> <label>Nguồn dữ liệu:</label> <div style="display: flex; gap: 10px;"> <label style="font-weight: normal; cursor: pointer;"><input type="radio" name="data_source_mode" value="local" checked> File Local</label> <label style="font-weight: normal; cursor: pointer;"><input type="radio" name="data_source_mode" value="gas"> Google Sheets (GAS)</label> </div> </div> <div id="local_file_config"> <div class="wwe-form-group"> <label for="excel_file">Chọn File Excel/CSV:</label> <input type="file" id="excel_file" accept=".xlsx, .xls, .csv"/> </div> <div class="wwe-form-group row-group"> <label for="url_column">Cột URL/Tọa độ (A-Z):</label> <input type="text" id="url_column" value="F" style="text-transform: uppercase; text-align: center; width: 50px;"> </div> </div> <div id="gas_config" style="display: none;"> <div class="wwe-form-group"> <label for="gas_url">Google Web App URL (GAS):</label> <input type="text" id="gas_url" placeholder="https://script.google.com/macros/s/..." /> </div> <div class="wwe-form-group"> <label for="sheet_name_input">Tên Sheet (Tab) cần xử lý:</label> <input type="text" id="sheet_name_input" value="Sheet1" placeholder="Ví dụ: Data_To_Process"> </div> <div class="wwe-form-group"> <label for="url_col_name">Tên Cột URL/Tọa độ (Header Name):</label> <input type="text" id="url_col_name" value="Link WME" placeholder="Ví dụ: Link WME"> </div> <div class="wwe-form-group" style="margin-top: -5px; margin-bottom: 12px;"> <label for="skip_done_check" style="font-weight: normal; font-size: 13px;"> <input type="checkbox" id="skip_done_check" checked style="width: auto; margin-right: 5px;"> Bỏ qua các dòng có Status là "Đã tạo" khi tải. </label> </div> <button id="load_sheet_btn" class="action-btn primary" style="width: 100%;">Tải Dữ liệu từ Sheet</button> </div> <button id="reselect_btn" class="action-btn secondary" style="width: 100%; margin-top: 3px;" title="Tải lại đối tượng hiện tại (Mũi tên lên)" disabled>🔄 Tải lại & Chọn</button> </div> </div> <div class="accordion-item"> <button class="accordion-header">Zoom & Lưu Permalink</button> <div class="accordion-content"> <div class="wwe-form-group"> <label for="coordinate_zoom">Zoom level cho tọa độ:</label> <input type="number" id="coordinate_zoom" value="20" min="1" max="25" style="width: 50px;"> </div> <div class="wwe-form-group" style="margin-top: 3px;"> <label style="font-weight: normal; display: flex; align-items: center; margin-bottom: 5px;"> <input type="checkbox" id="save_status_enabled" checked style="width: auto; margin-right: 3px;"> Tự động lưu Status </label> <label style="font-weight: normal; display: flex; align-items: center;"> <input type="checkbox" id="save_permalink_after_create" checked style="width: auto; margin-right: 3px;"> Tự động lưu Permalink </label> </div> </div> </div> <!-- Accordion: Chức năng POI --> <div class="accordion-item"> <button class="accordion-header">Công cụ tạo POI</button> <div class="accordion-content"> <label style="font-weight: bold; margin-bottom: 5px; display: block;">Tự động tạo POI mới:</label> <div style="display: flex; gap: 15px; margin-top: 5px; margin-bottom: 3px;"> <label style="font-weight: normal; cursor: pointer;"> <input type="radio" name="poi_creation_mode" value="none" checked> Không tạo </label> <label style="font-weight: normal; cursor: pointer;"> <input type="radio" name="poi_creation_mode" value="point"> Tạo Điểm </label> <label style="font-weight: normal; cursor: pointer;"> <input type="radio" name="poi_creation_mode" value="area"> Tạo Vùng </label> </div> <div class="wwe-form-group"> <label for="poi_category_select">Chọn Category mặc định:</label> <select id="poi_category_select"></select> </div> </div> </div> <!-- Accordion: Quản lý Workflows --> <div class="accordion-item"> <button class="accordion-header">Quản lý Workflows</button> <div class="accordion-content"> <div style="display: flex;margin: 5px 0 5px 0;"> <button id="edit_workflow_btn" class="action-btn" style="flex-grow: 1;">✏️ Sửa</button> <button id="new_workflow_btn" class="action-btn" style="flex-grow: 1;">➕ Tạo mới</button> <button id="delete_workflow_btn" class="action-btn danger" style="flex-grow: 1;">🗑️ Xóa</button> </div> </div> </div> <!-- Accordion: Nhật ký --> <div class="accordion-item"> <button class="accordion-header">Nhật ký Hoạt động</button> <div class="accordion-content"> <div id="log_info" style="font-size: 11px; height: 120px; overflow-y: auto; border: 1px solid #eee; padding: 5px; background: #f8f9fa; border-radius: 4px; margin-top: 5px;"></div> </div> </div> </div> </div> `; document.body.appendChild(panel); const style = document.createElement('style'); style.innerHTML = ` /* CSS Styles (Copied and optimized from original thought process) */ #workflow-engine-panel button, #workflow-editor-modal button { border: 1px solid #ccc; background-color: #f0f0f0; border-radius: 4px; padding: 5px 10px; cursor: pointer; transition: background-color 0.2s, border-color 0.2s; font-family: inherit; } #workflow-engine-panel button:hover:not(:disabled), #workflow-editor-modal button:hover:not(:disabled) { background-color: #e0e0e0; } #workflow-engine-panel button:disabled, #workflow-editor-modal button:disabled { cursor: not-allowed; opacity: 0.5; } #workflow-engine-panel input[type=text], #workflow-engine-panel input[type=number], #workflow-engine-panel input[type=file], #workflow-engine-panel select, #workflow-editor-modal input[type=text], #workflow-editor-modal select { border-radius: 4px; border: 1px solid #ccc; width: 100%; box-sizing: border-box; padding: 5px; font-family: inherit; } #toggle_panel_btn, #toggle_editor_panel_btn { background: none; border: none; cursor: pointer; font-size: 15px; line-height: 1; padding: 0 5px; color: #888; font-weight: bold; } #workflow-engine-panel.is-collapsed #wwe-panel-content { display: none; } #workflow-engine-panel.is-collapsed #navigator-header { border-bottom: none; } #workflow-engine-panel .nav-btn { font-size: 12x; padding: 5px 12px; font-weight: bold; } #workflow-engine-panel .action-btn { font-weight: 500; } #workflow-engine-panel .action-btn.primary { background-color: #007bff; color: white; border-color: #007bff; } #workflow-engine-panel .action-btn.primary:hover:not(:disabled) { background-color: #0056b3; } #workflow-engine-panel .action-btn.secondary { background-color: #6c757d; color: white; border-color: #6c757d; } #workflow-engine-panel .action-btn.secondary:hover:not(:disabled) { background-color: #5a6268; } #workflow-engine-panel .action-btn.danger { background-color: #dc3545; color: white; border-color: #dc3545; } #workflow-engine-panel .action-btn.danger:hover:not(:disabled) { background-color: #c82333; } #loop_workflow_btn.looping { background-color: #ffc107; border-color: #ffc107; color: black; font-weight: bold; } #loop_workflow_btn.looping:hover:not(:disabled) { background-color: #e0a800; border-color: #d39e00;} .workflow-btns { display: flex; justify-content: flex-end; margin-top: 5px; } .workflow-btns button:first-child { margin-right: auto; } .wwe-form-group { display: flex; flex-direction: column; margin-bottom: 3px; } .wwe-form-group label { font-weight: bold; font-size: 12px; margin-bottom: 3px; } .wwe-form-group.row-group label { width: 100%; } /* Accordion Styles */ .accordion-item { border-top: 1px solid #eee; } .accordion-header { background-color: #f7f7f7; color: #444; cursor: pointer; padding: 10px; width: 100%; border: none; text-align: left; outline: none; font-size: 12px; transition: background-color 0.2s; font-weight: bold; } .accordion-header:hover { background-color: #e9e9e9; } .accordion-header::after { content: ' ▼'; font-size: 10px; float: right; margin-top: 3px; } .accordion-header.active::after { content: ' ▲'; } .accordion-content { padding: 0 15px; background-color: white; max-height: 0; overflow: hidden; transition: max-height 0.3s ease-out; } /* Workflow Editor Modal Styles */ #workflow-editor-modal { display: none; position: fixed; z-index: 1002; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.6); } #workflow-editor-content { background-color: #fefefe; padding: 0; border: 1px solid #888; width: 80%; max-width: 700px; border-radius: 8px; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); box-shadow: 0 5px 15px rgba(0,0,0,0.3); } #editor-header { display: flex; justify-content: space-between; align-items: center; padding: 5px 10px !important; cursor: grab; border-bottom: 1px solid #eee; background: #f7f7f7; border-top-left-radius: 8px; border-top-right-radius: 8px; } #workflow-editor-content.is-collapsed #editor-panel-content { display: none; } #workflow-steps_list { list-style: none; padding: 0; min-height: 100px; border: 1px dashed #ccc; padding: 5px; border-radius: 4px; background: #fff; } #workflow_steps_list li { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px; border: 1px solid #ddd; margin-bottom: 3px; border-radius: 4px; background: #fafafa; cursor: grab; } #workflow_steps_list li .step-number { margin-right: 3px; font-weight: bold; color: #888; } #workflow_steps_list li.editing { background-color: #e0eafc !important; border-color: #007bff !important; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } } #save_status_btn:not(:disabled):hover { background-color: #218838 !important; } `; document.head.appendChild(style); // Accordion functionality for main panel panel.querySelectorAll('.accordion-header').forEach(button => { button.addEventListener('click', () => { panel.querySelectorAll('.accordion-header.active').forEach(activeButton => { if (activeButton !== button) { activeButton.classList.remove('active'); activeButton.nextElementSibling.style.maxHeight = null; } }); button.classList.toggle('active'); const content = button.nextElementSibling; if (content.style.maxHeight) { content.style.maxHeight = null; } else { content.style.maxHeight = content.scrollHeight + 10 + "px"; } }); }); // Toggle panel button for main panel document.getElementById('toggle_panel_btn').addEventListener('click', (e) => { e.stopPropagation(); const isCollapsed = panel.classList.toggle('is-collapsed'); e.currentTarget.innerHTML = isCollapsed ? '▼' : '▲'; e.currentTarget.title = isCollapsed ? 'Mở rộng Panel' : 'Thu gọn Panel'; }); const localConfig = document.getElementById('local_file_config'); const gasConfig = document.getElementById('gas_config'); const saveStatusBtn = document.getElementById('save_status_btn'); document.querySelectorAll('input[name="data_source_mode"]').forEach(radio => { radio.addEventListener('change', (e) => { const mode = e.target.value; if (mode === 'local') { localConfig.style.display = 'block'; gasConfig.style.display = 'none'; saveStatusBtn.textContent = '💾 Cập nhật Status vào File'; } else { localConfig.style.display = 'none'; gasConfig.style.display = 'block'; saveStatusBtn.textContent = '☁️ Cập nhật Status (Tự động)'; } }); }); // Event listeners for main panel controls document.getElementById('excel_file').addEventListener('change', handleFile, false); document.getElementById('load_sheet_btn').addEventListener('click', loadFromGoogleSheet); document.getElementById('prev_btn').addEventListener('click', () => navigate(-1)); document.getElementById('next_btn').addEventListener('click', () => navigate(1)); document.getElementById('reselect_btn').addEventListener('click', processCurrentLink); // No argument needed document.getElementById('run_workflow_btn').addEventListener('click', () => runSelectedWorkflow(false)); document.getElementById('loop_workflow_btn').addEventListener('click', toggleWorkflowLoop); document.getElementById('save_status_btn').addEventListener('click', saveWorkbookToFile); document.getElementById('poi_category_select').addEventListener('change', (e) => { selectedSubCategory = e.target.value; }); populateCategorySelector(); loadGasSettings(); document.getElementById('nav_index_input').addEventListener('change', (e) => { const targetIndex = parseInt(e.target.value, 10); if (!isNaN(targetIndex)) { navigate(0, targetIndex - 1); } }); document.getElementById('workflow_select').addEventListener('change', () => { resetApiPanelState(); updateUIState(); if (currentIndex >= 0 && permalinks.length > 0) { processCurrentLink(); } }); document.getElementById('new_workflow_btn').addEventListener('click', () => openWorkflowEditor()); document.getElementById('edit_workflow_btn').addEventListener('click', () => { const id = document.getElementById('workflow_select').value; if (id) openWorkflowEditor(id); }); document.getElementById('delete_workflow_btn').addEventListener('click', deleteSelectedWorkflow); makeDraggable(panel, document.getElementById('navigator-header')); }; let apiProviderPanel = null; function populateCategorySelector() { const select = document.getElementById('poi_category_select'); select.innerHTML = ''; // Clear existing options for (const cat of CATEGORIES) { // Tạo optgroup cho mỗi group key const optgroup = document.createElement('optgroup'); optgroup.label = cat.key.replace(/_/g, ' '); // Thêm các sub categories for (const sub of cat.subs) { const option = document.createElement('option'); option.value = sub; option.textContent = sub.replace(/_/g, ' '); if (sub === selectedSubCategory) { option.selected = true; } optgroup.appendChild(option); } select.appendChild(optgroup); } } let vinfastPanel = null; function createProviderPanel(providerHandler) { if (apiProviderPanel) { // Đã đổi tên // Nếu đã có panel, chỉ cần reset nó providerHandler.updatePanel(null); document.getElementById('vf-panel-title').textContent = `${providerHandler.brand} Charging`; document.getElementById('vf-id-input').placeholder = `Nhập ID ${providerHandler.brand}`; document.getElementById('vf-transfer-name-btn').title = `Chuyển tên ${providerHandler.brand} vào trường Place Name`; return; } const panel = document.createElement('div'); panel.id = 'vf-panel'; panel.style.cssText = ` position: fixed; top: 80px; left: 450px; background: rgba(255, 255, 255, 0.95); border: 1px solid #ccc; padding: 10px; z-index: 1000; border-radius: 8px; width: 300px; max-height: 500px; font-family: inherit; font-size: 13px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); transition: opacity 0.3s; `; panel.innerHTML = ` <h4 id="vf-panel-title" style="margin: 0 0 10px 0; color: #007bff; border-bottom: 1px solid #eee; padding-bottom: 5px;">${providerHandler.brand} Charging</h4> <div style="display: flex; gap: 5px; margin-bottom: 10px;"> <input type="text" id="vf-id-input" placeholder="Nhập ID ${providerHandler.brand}" style="flex-grow: 1; padding: 4px;"> <button id="vf-fetch-btn" class="action-btn primary" style="padding: 4px 8px; font-size: 12px; height: auto;">Fetch</button> </div> <div id="vf-data-display" style="display: none;"> <div id="vf-name-line" style="margin-bottom: 8px; font-weight: bold; display: flex; align-items: center; border-bottom: 1px dashed #ddd; padding-bottom: 5px;"> <span style="flex-shrink: 0; margin-right: 5px;">Tên:</span> <span id="vf-name-text" style="flex-grow: 1; margin-right: 5px;"></span> <!-- Nút chuyển tên --> <button id="vf-transfer-name-btn" title="Chuyển tên ${providerHandler.brand} vào trường Place Name" style="padding: 2px 5px; font-size: 12px; background-color: #f39c12; color: white; border-color: #f39c12; line-height: 1;"><-> Swap</button> </div> <div style="display: flex; gap: 5px; margin-bottom: 10px;"> <button id="vf-images-btn" class="action-btn" style="flex: 1; background-color: #17a2b8; color: white; border-color: #17a2b8; font-size: 12px;">📷 Xem Ảnh</button> </div> <h5 style="margin: 5px 0;">Cổng sạc & Công suất:</h5> <div id="vf-connectors-info" style="border: 1px solid #ddd; padding: 8px; border-radius: 4px; background: #f9f9f9; max-height: 200px; overflow-y: auto;"> <i style="color: #777;">Chưa có dữ liệu cổng sạc.</i> </div> </div> `; document.body.appendChild(panel); apiProviderPanel = panel; // Đã đổi tên // Bật draggable cho panel makeDraggable(panel, panel.querySelector('#vf-panel-title')); // Hàm xử lý fetch chung cho panel async function handleProviderFetchClick() { const id = document.getElementById('vf-id-input').value.trim(); if (id) { const fetchBtn = document.getElementById('vf-fetch-btn'); if (fetchBtn) { fetchBtn.textContent = 'Fetching...'; fetchBtn.disabled = true; } try { // Gọi fetchData từ providerHandler await providerHandler.fetchData(id); } catch (e) { log(`Lỗi khi fetch data cho ${providerHandler.brand}: ${e.message}`, 'error'); } finally { // updatePanel sẽ được gọi trong fetchData, fetchBtn sẽ được reset ở đó } } else { log(`Vui lòng nhập ID ${providerHandler.brand}.`, 'warn'); } } // Attach listeners document.getElementById('vf-fetch-btn').addEventListener('click', handleProviderFetchClick); // Chuyển nút Transfer Name sang gọi trực tiếp từ provider document.getElementById('vf-transfer-name-btn').addEventListener('click', () => { if (providerHandler.transferNameToWME) { providerHandler.transferNameToWME(); } }); // Chuyển nút Images sang gọi trực tiếp từ provider document.getElementById('vf-images-btn').addEventListener('click', () => { if (providerHandler.showImagesPopup) { providerHandler.showImagesPopup(); } }); providerHandler.updatePanel(null); // Reset UI sau khi tạo } /** * Makes an element draggable using its handle. * @param {HTMLElement} elementToMove The element that will be moved. * @param {HTMLElement} dragHandle The element that acts as the drag handle. */ function makeDraggable(elementToMove, dragHandle) { let offsetX, offsetY; let isDragging = false; dragHandle.onmousedown = (e) => { e.preventDefault(); isDragging = true; dragHandle.style.cursor = 'grabbing'; // Change cursor while dragging // Get the element's current computed style to check its position const computedStyle = getComputedStyle(elementToMove); if (computedStyle.position === 'static') { elementToMove.style.position = 'absolute'; // Change to absolute if not already positioned } // If the element has a transform property (like translate for centering), // apply that transform to its top/left before dragging starts. if (computedStyle.transform && computedStyle.transform !== 'none') { const matrix = new DOMMatrixReadOnly(computedStyle.transform); // Adjust element's current top/left by its transform translate values elementToMove.style.left = (elementToMove.offsetLeft + matrix.m41) + 'px'; elementToMove.style.top = (elementToMove.offsetTop + matrix.m42) + 'px'; elementToMove.style.transform = 'none'; // Clear the transform } // Calculate the initial offset from the element's current position to the mouse click const rect = elementToMove.getBoundingClientRect(); offsetX = e.clientX - rect.left; offsetY = e.clientY - rect.top; document.onmousemove = (ev) => { if (!isDragging) return; // Calculate new position based on mouse position and initial offset elementToMove.style.left = (ev.clientX - offsetX) + 'px'; elementToMove.style.top = (ev.clientY - offsetY) + 'px'; }; document.onmouseup = () => { isDragging = false; document.onmouseup = null; document.onmousemove = null; dragHandle.style.cursor = 'grab'; // Reset cursor }; }; } function isStatusSavingEnabled() { return document.getElementById('save_status_enabled')?.checked === true; } function createWorkflowEditorModal() { const modal = document.createElement('div'); modal.id = 'workflow-editor-modal'; // CSS giữ nguyên hoặc tùy chỉnh nhẹ modal.innerHTML = ` <div id="workflow-editor-content" style="width: 600px;"> <h3 id="editor-header"> <span id="editor-title">Chỉnh sửa Workflow (SDK Mode)</span> <span id="close-modal" style="float:right; cursor:pointer; font-size:20px;">×</span> </h3> <div id="editor-panel-content" style="padding: 15px; max-height: 70vh; overflow-y: auto;"> <input type="hidden" id="editing_workflow_id"> <div class="wwe-form-group"> <label>Tên tác vụ:</label> <input type="text" id="workflow_name_input" placeholder="Nhập tên tác vụ..."> </div> <hr> <h4>Chọn các hành động thực thi:</h4> <div id="sdk-tasks-container"> <!-- Tasks will be injected here via JS --> </div> <div class="workflow-btns" style="margin-top: 15px; border-top: 1px solid #eee; padding-top: 10px;"> <button id="save_workflow_btn" class="primary">Lưu các tác vụ</button> <button id="cancel_workflow_btn" class="secondary">Hủy</button> </div> </div> </div>`; document.body.appendChild(modal); // Event listeners document.getElementById('close-modal').onclick = closeWorkflowEditor; document.getElementById('cancel_workflow_btn').onclick = closeWorkflowEditor; document.getElementById('save_workflow_btn').onclick = saveWorkflowFromEditor; makeDraggable(document.getElementById('workflow-editor-content'), document.getElementById('editor-header')); } function renderSdkTasksInEditor(existingTasks = []) { const container = document.getElementById('sdk-tasks-container'); container.innerHTML = ''; Object.keys(SDK_REGISTRY).forEach(taskId => { const def = SDK_REGISTRY[taskId]; // Kiểm tra xem task này đã có trong workflow cũ chưa const existing = existingTasks.find(t => t.taskId === taskId) || { enabled: false, params: {} }; const wrapper = document.createElement('div'); wrapper.style.cssText = "border: 1px solid #ddd; margin-bottom: 8px; padding: 10px; border-radius: 4px; background: #fafafa;"; // Header: Checkbox + Name const header = document.createElement('div'); header.innerHTML = ` <label style="font-weight: bold; color: #333; display: flex; align-items: center; cursor: pointer;"> <input type="checkbox" class="task-enable-cb" data-task-id="${taskId}" ${existing.enabled ? 'checked' : ''} style="width: auto; margin-right: 8px;"> ${def.name} </label> <div style="font-size: 0.85em; color: #666; margin-left: 24px; margin-bottom: 5px;">${def.description}</div> `; wrapper.appendChild(header); // Params Inputs if (def.params.length > 0) { const paramsDiv = document.createElement('div'); paramsDiv.className = 'task-params'; paramsDiv.style.cssText = `margin-left: 24px; display: ${existing.enabled ? 'block' : 'none'};`; def.params.forEach(p => { const row = document.createElement('div'); row.style.marginBottom = '5px'; const val = existing.params[p.key] || ''; row.innerHTML = ` <label style="display:block; font-size: 11px; margin-bottom: 2px;">${p.label}:</label> <input type="text" class="task-param-input" data-task-id="${taskId}" data-param-key="${p.key}" value="${val}" placeholder="${p.placeholder}" style="width: 100%;"> `; paramsDiv.appendChild(row); }); wrapper.appendChild(paramsDiv); } container.appendChild(wrapper); }); // Toggle hiển thị params khi check/uncheck container.querySelectorAll('.task-enable-cb').forEach(cb => { cb.addEventListener('change', (e) => { const paramsDiv = e.target.closest('div').parentElement.querySelector('.task-params'); if (paramsDiv) paramsDiv.style.display = e.target.checked ? 'block' : 'none'; }); }); } function registerHotkeys() { document.addEventListener('keydown', (e) => { // Do not trigger hotkeys if focus is in an input field or a text area, // or if the event originated from within our panels/modals if (e.target.closest('#workflow-engine-panel, #workflow-editor-modal') || e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') { return; } if (e.key === 'ArrowRight' && !document.getElementById('next_btn').disabled) { e.preventDefault(); document.getElementById('next_btn').click(); } if (e.key === 'ArrowLeft' && !document.getElementById('prev_btn').disabled) { e.preventDefault(); document.getElementById('prev_btn').click(); } if (e.key === 'ArrowUp' && !document.getElementById('reselect_btn').disabled) { e.preventDefault(); document.getElementById('reselect_btn').click(); } if (e.key === 'ArrowDown' && !document.getElementById('run_workflow_btn').disabled) { e.preventDefault(); document.getElementById('run_workflow_btn').click(); } if (e.key.toLowerCase() === 'u') { e.preventDefault(); updateStatus('Không tồn tại'); permalinks[currentIndex].status = 'Không tồn tại'; window.alert('Workflow Engine: Đã đánh dấu "Không tồn tại" cho vị trí này.'); } }); } function navigate(direction, targetIndex = null) { if (isLooping) return; if (permalinks.length === 0) return; let newIndex = (targetIndex !== null) ? targetIndex : (currentIndex + direction); if (newIndex >= 0 && newIndex < permalinks.length) { const previousIndex = currentIndex; const navigationDirection = newIndex - currentIndex; if (previousIndex >= 0 && previousIndex !== newIndex) { const currentItemStatus = permalinks[previousIndex].status?.toLowerCase(); const shouldSavePermalink = document.getElementById('save_permalink_after_create')?.checked; if (shouldSavePermalink && navigationDirection > 0) { const newPermalink = wmeSDK.Map.getPermalink(); if (newPermalink) { updatePermalinkInWorkbook(previousIndex, newPermalink); } else { log(`⚠️ Không lấy được Permalink cho mục ${previousIndex + 1}.`, 'warn'); } } if (currentItemStatus === 'đang tạo' || currentItemStatus === '') { updateStatusByIndex(previousIndex, 'Đã tạo'); } } currentIndex = newIndex; // Nếu mục mới chưa có status hoặc là "Đã tạo", đặt lại là Đang tạo if (!permalinks[currentIndex].status || permalinks[currentIndex].status.toLowerCase() !== 'đang tạo') { updateStatus('Đang tạo'); // Cập nhật trạng thái local ngay lập tức permalinks[currentIndex].status = 'Đang tạo'; } updateUIState(); processCurrentLink(); } } /** * Cập nhật status cho một URL cụ thể theo index */ function updateStatusByIndex(index, status) { const shouldSave = isStatusSavingEnabled(); if (isGasMode) { if (index >= 0 && permalinks[index]) { const item = permalinks[index]; if (shouldSave) { updateGasStatusByRowIndex(item.rowIndex, status); } item.status = status; // Cập nhật local state } } else { if (index >= 0 && permalinks[index] && permalinks[index].localFileIndexes) { const item = permalinks[index]; if (shouldSave) { _updateLocalStatusCell(item.rowIndex, item.localFileIndexes.statusCol, status, item.localFileIndexes.sheetName); } item.status = status; } } } function extractCoords(item) { // 1. Thử lấy từ API data nếu có if (currentApiData?.lat || currentApiData?.coordinates?.latitude) { return { lat: parseFloat(currentApiData.lat || currentApiData.coordinates.latitude), lon: parseFloat(currentApiData.lng || currentApiData.coordinates.longitude) }; } // 2. Thử lấy từ định dạng tọa độ thô "10.123, 106.123" const coordMatch = item.url.match(/(-?\d+\.?\d*)\s*,\s*(-?\d+\.?\d*)/); if (coordMatch) return { lat: parseFloat(coordMatch[1]), lon: parseFloat(coordMatch[2]) }; // 3. Thử lấy bằng Regex từ URL Waze (phòng trường hợp URL lỗi nhưng vẫn có số) const urlMatch = item.url.match(/lat=(-?\d+\.\d+)&lon=(-?\d+\.\d+)/); if (urlMatch) return { lat: parseFloat(urlMatch[1]), lon: parseFloat(urlMatch[2]) }; return null; } async function processCurrentLink() { if (currentIndex < 0 || currentIndex >= permalinks.length) return; const item = permalinks[currentIndex]; // permalinks giờ sẽ chứa object {url, rowIndex, rowData...} currentRowData = item.rowData; // Cập nhật dữ liệu hàng hiện tại để hàm replacePlaceholders dùng currentApiData = null; // Reset API data cũ // 1. Xác định Workflow, Provider và API ID cần fetch const selectedWorkflowId = document.getElementById('workflow_select')?.value; const selectedWorkflow = selectedWorkflowId ? allWorkflows[selectedWorkflowId] : null; let targetApiId = ''; let providerHandler = null; if (selectedWorkflow && Array.isArray(selectedWorkflow.tasks)) { const chargeApiTask = selectedWorkflow.tasks.find(t => t.enabled && t.taskId === 'update_charge_station_api'); if (chargeApiTask) { const providerKey = (chargeApiTask.params.provider || 'vinfast').toLowerCase(); providerHandler = PROVIDERS_FETCH_API[providerKey]; // --- LẤY ID TỪ PLACEHOLDER TRONG CẤU HÌNH WORKFLOW --- const idPlaceholder = chargeApiTask.params.id || ''; // Ví dụ: "{{A}}" // Sử dụng replacePlaceholders để resolve cột A, B, C... thành giá trị thực let resolvedId = replacePlaceholders(idPlaceholder); targetApiId = resolvedId ? resolvedId.toString().trim() : ''; } } // 2. Cập nhật UI và Fetch tọa độ nếu cần const vfIdInput = document.getElementById('vf-id-input'); if (vfIdInput) { vfIdInput.value = targetApiId; // Cập nhật input ID trên panel } currentApiData = null; let targetLat = null; let targetLng = null; let isCoordinateFromAPI = false; // Nếu workflow có API sạc và có provider, tạo/reset panel và fetch data nếu có ID if (providerHandler) { createProviderPanel(providerHandler); if (targetApiId) { try { log(`Đang fetch dữ liệu ${providerHandler.brand} cho ID: ${targetApiId}`, 'info'); // Gọi fetchData từ providerHandler const apiResult = await providerHandler.fetchData(targetApiId); if (apiResult && (apiResult.lat || (apiResult.coordinates && apiResult.coordinates.latitude))) { targetLat = parseFloat(apiResult.lat || apiResult.coordinates.latitude); targetLng = parseFloat(apiResult.lng || apiResult.coordinates.longitude); isCoordinateFromAPI = true; } } catch (e) { log(`Lỗi API ${providerHandler.brand}: ${e.message}`, 'warn'); } } } else if (apiProviderPanel) { // Nếu workflow không yêu cầu API sạc, ẩn panel nếu nó tồn tại apiProviderPanel.style.display = 'none'; } const coords = extractCoords(item); const createMode = document.querySelector('input[name="poi_creation_mode"]:checked')?.value || 'none'; if (coords) { const zoom = parseInt(document.getElementById('coordinate_zoom')?.value || 20); W.map.setCenter(WazeWrap.Geometry.ConvertTo900913(coords.lon, coords.lat), zoom); if (createMode !== 'none' && !item.url.includes('venues=')) { await delay(500); createWazePOI(coords.lat, coords.lon, createMode); } } // Cuối cùng mới thực hiện điều hướng (nếu URL cũ có ID sẵn) parseWazeUrlAndNavigate(item.url); } function updatePermalinkInWorkbook(index, newPermalink) { if (!document.getElementById('save_permalink_after_create')?.checked) return; const item = permalinks[index]; if (!item) return; if (isGasMode) { updateGasStatusByRowIndex(item.rowIndex, item.status, newPermalink); item.url = newPermalink; const urlColName = document.getElementById('url_col_name')?.value?.trim() || 'Link WME'; item.rowData[urlColName] = newPermalink; } else { if (!workbookData || !item.localFileIndexes) return; try { const sheet = workbookData.Sheets[item.localFileIndexes.sheetName]; const statusColIndex = item.localFileIndexes.statusCol; const newColIndex = statusColIndex + 1; const NEW_COL_NAME = "New Permalink"; const rowIndex = item.rowIndex; // Excel row index (0-based) const headerAddress = XLSX.utils.encode_cell({ r: 0, c: newColIndex }); if (!sheet[headerAddress] || sheet[headerAddress].v !== NEW_COL_NAME) { sheet[headerAddress] = { t: 's', v: NEW_COL_NAME }; } const cellAddress = XLSX.utils.encode_cell({ r: rowIndex, c: newColIndex }); sheet[cellAddress] = { t: 's', v: newPermalink }; const range = XLSX.utils.decode_range(sheet['!ref']); if (newColIndex > range.e.c) { range.e.c = newColIndex; sheet['!ref'] = XLSX.utils.encode_range(range); } item.url = newPermalink; const urlColLetter = getColumnLetter(item.localFileIndexes.urlCol); item.rowData[urlColLetter] = newPermalink; // Cập nhật bằng chữ cái hasUnsavedChanges = true; updateSaveButtonState(); } catch (err) { log(`❌ Lỗi khi cập nhật file local: ${err.message}`, "error"); console.error(err); } } } async function parseWazeUrlAndNavigate(value) { try { const trimmedValue = value.trim(); const coordMatch = trimmedValue.match(/^\s*\(?\s*(-?\d+\.?\d*)\s*,\s*(-?\d+\.?\d*)\s*\)?\s*$/); if (coordMatch) { // Xử lý tọa độ const lat = parseFloat(coordMatch[1]); const lon = parseFloat(coordMatch[2]); if (isNaN(lat) || isNaN(lon)) { throw new Error('Tọa độ không hợp lệ.'); } // Lấy zoom level từ input hoặc dùng mặc định const zoomInput = document.getElementById('coordinate_zoom'); const defaultZoom = zoomInput ? parseInt(zoomInput.value, 10) : 20; W.map.setCenter(WazeWrap.Geometry.ConvertTo900913(lon, lat), defaultZoom); W.selectionManager.setSelectedModels([]); const createMode = document.querySelector('input[name="poi_creation_mode"]:checked').value; if (createMode !== 'none') { // Đợi map load xong await delay(500); createWazePOI(lat, lon, createMode); // Đợi POI được tạo và WME chọn nó await delay(500); } return; } // Nếu không phải tọa độ, xử lý như URL bình thường const parsedUrl = new URL(trimmedValue); const params = parsedUrl.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); // Set map center and zoom W.map.setCenter(WazeWrap.Geometry.ConvertTo900913(lon, lat), zoom); // Wait for model to be ready, then select objects WazeWrap.Model.onModelReady(() => { (async () => { await delay(1000); 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 từ ID ${segmentIDs.join(',')} sau khi tải.`, 'warn'); } 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 từ ID ${venueIDs.join(',')} sau khi tải.`, 'warn'); } else { objectsToSelect.push(...venues); } } if (objectsToSelect.length > 0) { W.selectionManager.setSelectedModels(objectsToSelect); } })(); }, true); } catch (error) { log(`Lỗi khi xử lý "${value}": ${error.message}`, 'error'); console.error(error); } } function updateUIState() { const hasLinks = permalinks.length > 0; const navIndexInput = document.getElementById('nav_index_input'); const navTotalCount = document.getElementById('nav_total_count'); const workflowSelect = document.getElementById('workflow_select'); const loopBtn = document.getElementById('loop_workflow_btn'); const runWorkflowBtn = document.getElementById('run_workflow_btn'); // Navigation buttons document.getElementById('prev_btn').disabled = !hasLinks || currentIndex <= 0 || isLooping; document.getElementById('next_btn').disabled = !hasLinks || currentIndex >= permalinks.length - 1 || isLooping; document.getElementById('reselect_btn').disabled = !hasLinks || isLooping; // Index input navIndexInput.disabled = !hasLinks || isLooping; if (hasLinks) { navIndexInput.value = currentIndex + 1; navIndexInput.max = permalinks.length; navTotalCount.textContent = ` / ${permalinks.length}`; } else { navIndexInput.value = ''; navTotalCount.textContent = '/ N/A'; } // Workflow action buttons runWorkflowBtn.disabled = !workflowSelect.value || isLooping; loopBtn.disabled = !hasLinks; // Workflow editor related buttons document.getElementById('excel_file').disabled = isLooping; document.getElementById('workflow_select').disabled = isLooping; document.getElementById('workflow_variable_input').disabled = isLooping; document.getElementById('edit_workflow_btn').disabled = !workflowSelect.value || isLooping; document.getElementById('delete_workflow_btn').disabled = !workflowSelect.value || isLooping; document.getElementById('new_workflow_btn').disabled = isLooping; // Update Save Status button updateSaveButtonState(); // Loop button visual state if (isLooping) { loopBtn.textContent = '⏹️ Dừng Lặp'; loopBtn.classList.add('looping'); loopBtn.classList.remove('secondary'); } else { loopBtn.textContent = '🔁 Bắt đầu Lặp'; loopBtn.classList.remove('looping'); if (hasLinks) loopBtn.classList.add('secondary'); } } function populateWorkflowSelector() { const select = document.getElementById('workflow_select'); const currentId = select.value; // Store current selection select.innerHTML = ''; // Clear existing options const emptyOption = document.createElement('option'); emptyOption.value = ''; emptyOption.textContent = Object.keys(allWorkflows).length === 0 ? '--- Không có workflow ---' : '--- Chọn workflow ---'; select.appendChild(emptyOption); for (const id in allWorkflows) { const option = document.createElement('option'); option.value = id; option.textContent = allWorkflows[id].name; select.appendChild(option); } // Restore previous selection or select the first valid one if (currentId && allWorkflows[currentId]) { select.value = currentId; } else if (Object.keys(allWorkflows).length > 0) { // Select the first workflow if none were selected, but prefer the empty option if it exists const firstWorkflowId = Object.keys(allWorkflows)[0]; if (firstWorkflowId) { select.value = firstWorkflowId; } } else { select.value = ''; // No workflows, ensure no value is set } updateUIState(); // Update other UI elements based on selection } function deleteSelectedWorkflow() { const select = document.getElementById('workflow_select'); const idToDelete = select.value; const workflowName = allWorkflows[idToDelete]?.name; if (!idToDelete) { alert("Vui lòng chọn một workflow để xóa."); return; } if (confirm(`Bạn có chắc chắn muốn xóa workflow "${workflowName}" không?`)) { delete allWorkflows[idToDelete]; saveWorkflows(); populateWorkflowSelector(); log(`Đã xóa workflow: "${workflowName}"`, 'info'); } } function openWorkflowEditor(workflowId = null) { const modal = document.getElementById('workflow-editor-modal'); const nameInput = document.getElementById('workflow_name_input'); const idInput = document.getElementById('editing_workflow_id'); const title = document.getElementById('editor-title'); if (workflowId && allWorkflows[workflowId]) { const wf = allWorkflows[workflowId]; title.textContent = "Chỉnh sửa Workflow (SDK)"; nameInput.value = wf.name; idInput.value = workflowId; renderSdkTasksInEditor(wf.tasks || []); } else { title.textContent = "Tạo Workflow Mới (SDK)"; nameInput.value = ''; idInput.value = ''; renderSdkTasksInEditor([]); } modal.style.display = 'block'; } function closeWorkflowEditor() { document.getElementById('workflow-editor-modal').style.display = 'none'; } function saveWorkflowFromEditor() { const name = document.getElementById('workflow_name_input').value.trim(); if (!name) return alert("Vui lòng nhập tên tác vụ."); const tasks = []; document.querySelectorAll('.task-enable-cb').forEach(cb => { if (cb.checked) { const taskId = cb.dataset.taskId; const params = {}; // Thu thập params document.querySelectorAll(`.task-param-input[data-task-id="${taskId}"]`).forEach(inp => { params[inp.dataset.paramKey] = inp.value; }); tasks.push({ taskId, enabled: true, params }); } }); if (tasks.length === 0) return alert("Vui lòng chọn ít nhất một hành động."); let id = document.getElementById('editing_workflow_id').value; if (!id) id = `sdk_wf_${Date.now()}`; allWorkflows[id] = { name, tasks }; saveWorkflows(); populateWorkflowSelector(); document.getElementById('workflow_select').value = id; closeWorkflowEditor(); log(`Đã lưu workflow SDK "${name}"`, 'success'); } function loadGasSettings() { try { const saved = localStorage.getItem(STORAGE_KEY_SETTINGS); if (saved) { const settings = JSON.parse(saved); document.getElementById('gas_url').value = settings.gasUrl || ''; // Sửa: Chỉ truy cập các ID có sẵn const sheetNameInput = document.getElementById('sheet_name_input'); if (sheetNameInput) sheetNameInput.value = settings.sheetName || 'Sheet1'; const urlColNameInput = document.getElementById('url_col_name'); if (urlColNameInput) urlColNameInput.value = settings.urlCol || 'Link WME'; const skipDoneCheck = document.getElementById('skip_done_check'); if (skipDoneCheck) skipDoneCheck.checked = settings.skipDone || false; } } catch (e) { log('Lỗi khi tải cài đặt GAS.', 'error'); } } /** * Tải dữ liệu từ Google Sheets thông qua GAS Web App. */ function loadFromGoogleSheet() { const scriptUrl = document.getElementById('gas_url')?.value?.trim() || ''; const sheetName = document.getElementById('sheet_name_input')?.value?.trim() || ''; const urlColName = document.getElementById('url_col_name')?.value?.trim() || ''; const skipDone = document.getElementById('skip_done_check')?.checked || false; if (!scriptUrl) { alert("Vui lòng nhập Web App URL!"); return; } // Lưu cài đặt GAS localStorage.setItem(STORAGE_KEY_SETTINGS, JSON.stringify({ gasUrl: scriptUrl, sheetName: sheetName, urlCol: urlColName, skipDone: skipDone })); // Reset trạng thái permalinks = []; currentIndex = -1; previousIndex = -1; isGasMode = true; gasHeaders = null; hasUnsavedChanges = false; log("⏳ Đang tải dữ liệu từ Google Sheets..."); const loadBtn = document.getElementById('load_sheet_btn'); if (loadBtn) loadBtn.disabled = true; const readUrl = `${scriptUrl}?action=get&sheetName=${encodeURIComponent(sheetName)}`; GM_xmlhttpRequest({ method: "GET", url: readUrl, onload: function (response) { if (loadBtn) loadBtn.disabled = false; if (response.status !== 200) { log(`❌ Lỗi kết nối GAS: Status ${response.status}. Kiểm tra URL và quyền truy cập.`, 'error'); isGasMode = false; updateUIState(); return; } try { const json = JSON.parse(response.responseText); if (json.result === "success" && json.data) { // Thiết lập gasHeaders và xử lý dữ liệu if (json.headers && Array.isArray(json.headers)) { gasHeaders = json.headers; log("Đã load dữ liệu từ GG Sheets thành công", 'info') } else { log("Cảnh báo: Không nhận được headers từ GAS, mapping {{A}} có thể không chính xác.", 'warn'); } let foundIndex = -1; let tempPermalinks = []; json.data.forEach(row => { const url = row[urlColName] || ""; const stt = row[STATUS_COL_NAME] || ""; const rowIndex = row["_rowIndex"] || null; // _rowIndex là cột đặc biệt dùng để ghi lại if (url && rowIndex !== null) { const statusTrimmed = stt.toString().trim().toLowerCase(); if (skipDone && statusTrimmed === 'đã tạo') { return; // Bỏ qua nếu skipDone được bật } tempPermalinks.push({ url: url.toString().trim(), rowIndex: rowIndex, status: statusTrimmed, rowData: row // Lưu Object data }); // Tìm index để bắt đầu if (foundIndex === -1 && statusTrimmed === 'đang tạo') { foundIndex = tempPermalinks.length - 1; } } }); permalinks = tempPermalinks; if (foundIndex === -1) { foundIndex = permalinks.findIndex(p => p.status !== 'đã tạo'); if (foundIndex === -1 && permalinks.length > 0) foundIndex = 0; } currentIndex = foundIndex === -1 ? 0 : foundIndex; if (permalinks.length > 0) { updateStatus('Đang tạo'); // Dùng hàm local để cập nhật GAS status } updateUIState(); processCurrentLink(); // Bắt đầu xử lý link đầu tiên } else { log("❌ Lỗi Sheet: " + (json.message || "Lỗi dữ liệu trả về.")); isGasMode = false; } } catch (e) { log("❌ Lỗi parse JSON hoặc lỗi xử lý dữ liệu: " + e.message, 'error'); console.error(e); isGasMode = false; } }, onerror: function (err) { if (loadBtn) loadBtn.disabled = false; log("❌ Lỗi kết nối mạng GAS.", 'error'); console.error(err); isGasMode = false; } }); } function updateGasStatusByRowIndex(rowIndex, newStatus, newPermalink = null) { if (!isGasMode || !rowIndex) return; // Lấy các giá trị DOM cần thiết const scriptUrl = document.getElementById('gas_url')?.value?.trim(); const sheetName = document.getElementById('sheet_name_input')?.value?.trim(); if (!scriptUrl || !sheetName) { log('Lỗi: Thiếu Web App URL hoặc Tên Sheet để cập nhật GAS.', 'error'); return; } let url = `${scriptUrl}?action=post&rowIndex=${rowIndex}&status=${encodeURIComponent(newStatus)}&sheetName=${encodeURIComponent(sheetName)}`; if (newPermalink) { const urlColName = document.getElementById('url_col_name')?.value?.trim(); if (urlColName) { url += `&urlCol=${encodeURIComponent(urlColName)}`; url += `&permalink=${encodeURIComponent(newPermalink)}`; } } GM_xmlhttpRequest({ method: "GET", url: url, onload: (response) => { try { const res = JSON.parse(response.responseText); if (res.result !== "success") { log(`⚠️ Lỗi GAS ghi status (Row ${rowIndex}): ${res.message}`, 'warn'); } } catch (e) { log(`⚠️ Lỗi phản hồi JSON từ GAS khi ghi status.`, 'warn'); } }, onerror: (err) => { log(`❌ Lỗi kết nối khi cập nhật GAS status (Row ${rowIndex}).`, 'error'); } }); } // Initialize the script bootstrap(); })();