// ==UserScript==
// @name WME Workflow Engine
// @namespace https://greasyfork.org/
// @version 2.0.2
// @description Xây dựng và chạy các chuỗi tự động hóa (workflows) tùy chỉnh trong WME. Tự động điều hướng từ file Excel/CSV và thực thi các hành động như click, nhập liệu trên các đối tượng.
// @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;
let allWorkflows = {};
let isLooping = false;
const DELAY_BETWEEN_LOOPS = 300;
const STORAGE_KEY = 'wme_custom_workflows';
const defaultWorkflows = {
"street_city_update": {
name: "Cập nhật T.Phố (Street)",
steps: [
{ type: 'click', selector: '.w-icon.w-icon-pencil-fill.edit-button', desc: "Click nút Edit Address" },
{ type: 'delay', delay: 100, desc: "Chờ UI load" },
{ type: 'input', selector: '#segment-edit-general > div:nth-child(2) > div > div > wz-card > form > div:nth-child(3) > wz-autocomplete', shadowSelector: '#text-input', value: '{{value}}', desc: "Nhập tên TP mới" },
{ type: 'delay', delay: 100, desc: "Chờ gợi ý hiện ra" },
{ type: 'click', selector: '#segment-edit-general > div:nth-child(2) > div > div > wz-card > form > div:nth-child(3) > wz-autocomplete', shadowSelector: 'wz-menu-item:nth-child(2)', desc: "Chọn gợi ý đầu tiên" },
{ type: 'delay', delay: 100, desc: "Chờ xử lý" },
{ type: 'click', selector: '#segment-edit-general .save-button', desc: "Click nút Save" }
]
},
"place_city_update": {
name: "Cập nhật T.Phố (Place)",
steps: [
{ type: 'click', selector: '#venue-edit-general .w-icon-pencil-fill.edit-button', desc: "Click nút Edit Address" },
{ type: 'delay', delay: 100, desc: "Chờ UI load" },
{ type: 'input', selector: '#venue-edit-general > div:nth-child(1) > div > div.address-edit-view > wz-card > form > div:nth-child(4) > wz-autocomplete', shadowSelector: '#text-input', value: '{{value}}', desc: "Nhập tên TP mới" },
{ type: 'delay', delay: 100, desc: "Chờ gợi ý hiện ra" },
{ type: 'click', selector: '#venue-edit-general > div:nth-child(1) > div > div.address-edit-view > wz-card > form > div:nth-child(4) > wz-autocomplete', shadowSelector: 'wz-menu-item:nth-child(2)', desc: "Chọn gợi ý đầu tiên" },
{ type: 'delay', delay: 100, desc: "Chờ xử lý" },
{ type: 'click', selector: '#venue-edit-general .save-button', desc: "Click nút Save" }
]
}
};
function bootstrap() {
if (W && W.map && W.model && W.loginManager.user && $ && WazeWrap.Ready) {
init();
} else {
setTimeout(bootstrap, 500);
}
}
function init() {
console.log("WME Workflow Engine: Initialized");
loadWorkflows();
createUI();
createWorkflowEditorModal();
populateWorkflowSelector();
updateUIState();
registerHotkeys();
}
/**
* 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);
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');
return null;
}
}
/**
* Thực thi một bước (step) của workflow.
* @param {object} step - Đối tượng step.
* @param {string} variableValue - Giá trị để thay thế cho placeholder {{value}}.
*/
async function executeStep(step, variableValue) {
try {
switch (step.type) {
case 'click':
{
const element = await findElement(step.selector, step.shadowSelector);
if (element) {
element.click();
} else throw new Error("Phần tử không tồn tại để click.");
break;
}
case 'input':
{
const element = await findElement(step.selector, step.shadowSelector);
if (element) {
const valueToInput = step.value.replace('{{value}}', variableValue);
element.value = valueToInput;
element.dispatchEvent(new Event("input", { bubbles: true, cancelable: true }));
element.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
} else throw new Error("Phần tử không tồn tại để nhập liệu.");
break;
}
case 'delay':
{
await delay(step.delay);
break;
}
case 'log':
{
break;
}
default:
log(`Loại step không xác định: ${step.type}`, 'error');
}
} catch (err) {
log(`❌ Lỗi khi thực thi step (${step.type}): ${err.message}`, 'error');
throw err;
}
}
/**
* Chạy toàn bộ workflow đã chọn.
*/
async function runSelectedWorkflow() {
const workflowId = document.getElementById('workflow_select').value;
const variableValue = document.getElementById('workflow_variable_input').value;
if (!workflowId || !allWorkflows[workflowId]) {
alert("Vui lòng chọn một workflow hợp lệ!");
throw new Error("Workflow không hợp lệ.");
}
const workflow = allWorkflows[workflowId];
try {
for (const step of workflow.steps) {
if (!isLooping && step.type !== 'log') { // Cho phép dừng giữa chừng, trừ log
const userWantsToStop = await new Promise(resolve => {
// Nếu vòng lặp không hoạt động, không cần kiểm tra
if (!isLooping) resolve(false);
else resolve(false); // Bỏ qua kiểm tra trong bản cập nhật này để đơn giản hóa
});
if (userWantsToStop) {
throw new Error("Workflow bị dừng bởi người dùng.");
}
}
await executeStep(step, variableValue);
}
} catch (error) {
log(`--- ❌ Workflow "${workflow.name}" đã dừng: ${error.message} ---`, 'error');
throw error;
}
}
async function toggleWorkflowLoop() {
isLooping = !isLooping;
updateUIState();
if (isLooping) {
log("--- Bắt đầu vòng lặp tự động ---", 'special');
await executeLoop();
} else {
log("Đã yêu cầu dừng vòng lặp. Sẽ dừng sau khi hoàn thành mục hiện tại.", 'warn');
}
}
async function executeLoop() {
while (isLooping && currentIndex < permalinks.length) {
updateUIState(); // Cập nhật UI để hiển thị đúng index
try {
// Điều hướng và chọn đối tượng
await processCurrentLink();
await delay(1500); // Chờ cho WME ổn định sau khi điều hướng
// Chạy workflow
await runSelectedWorkflow();
} catch (error) {
log(`Lỗi ở mục ${currentIndex + 1}, bỏ qua và tiếp tục. Lỗi: ${error.message}`, 'error');
}
// Kiểm tra lần nữa nếu người dùng đã nhấn dừng trong lúc workflow chạy
if (!isLooping) break;
// Chuyển sang mục tiếp theo
if (currentIndex < permalinks.length - 1) {
currentIndex++;
log(`Đang chờ ${DELAY_BETWEEN_LOOPS / 1000}s trước khi đến mục tiếp theo...`, 'info');
await delay(DELAY_BETWEEN_LOOPS);
} else {
log("Đã đến mục cuối cùng của danh sách.", 'info');
break; // Thoát vòng lặp khi đã ở mục cuối
}
}
isLooping = false;
if (currentIndex >= permalinks.length -1) {
log("--- ✅ Hoàn thành vòng lặp tự động! ---", 'special');
} else {
log("--- Vòng lặp tự động đã dừng. ---", 'warn');
}
updateUIState(); // Cập nhật UI lần cuối để mở lại các nút
}
function handleFile(e) {
permalinks = [];
currentIndex = -1;
const file = e.target.files[0];
if (!file) {
updateUIState();
return;
}
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.`, 'error');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
try {
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 });
permalinks = json
.slice(1) // Bỏ qua hàng tiêu đề
.map(row => row[urlColumnIndex])
.filter(pl => pl && typeof pl === 'string' && (pl.includes('waze.com/editor') || pl.includes('waze.com/ul'))); // Chỉ lấy permalinks hợp lệ
if (permalinks.length > 0) {
currentIndex = 0;
processCurrentLink();
} else {
log(`Không tìm thấy URL hợp lệ trong cột ${urlColumnInput} của file.`, 'warn');
}
updateUIState();
} catch (err) {
log(`Lỗi khi đọc file: ${err.message}`, 'error');
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 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.85); border: 1px solid #ccc;
padding: 0; z-index: 1001; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 14px; width: 420px;
`;
panel.innerHTML = `
<h3 id="navigator-header" style="display: flex; justify-content: space-between; align-items: center; margin:0; padding: 10px 15px; cursor: grab; border-bottom: 1px solid #eee;">
<span>WME Workflow Engine</span>
<button id="toggle_panel_btn" title="Thu gọn Panel">▲</button>
</h3>
<div id="wwe-panel-content" style="padding: 15px;">
<!-- Section 1: Điều khiển chính -->
<h4 style="margin-top: 0; margin-bottom: 12px;">Điều khiển chính</h4>
<div style="display: flex; justify-content: space-between; align-items: center; gap: 10px;">
<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;">/ 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">
<label for="workflow_select">Chọn Workflow:</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 thành phố, 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" style="width: 100%;" title="Tự động chạy workflow cho tất cả đối tượng" disabled>🔁 Bắt đầu Lặp</button>
<!-- Section 2: Accordion Items -->
<div class="accordion-container" style="margin-top: 15px;">
<!-- 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">
<input type="file" id="excel_file" accept=".xlsx, .xls, .csv"/>
</div>
<div class="wwe-form-group">
<label for="url_column">Cột URL:</label>
<input type="text" id="url_column" value="F" size="5" style="text-transform: uppercase; text-align: center;">
</div>
<button id="reselect_btn" class="action-btn secondary" style="width: 100%; margin-top: 10px;" 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>
<!-- 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; gap: 10px; margin-top: 10px;">
<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: 12px; 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 = `
/* Global button/input styles for both panel and modal */
#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;
}
#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; /* Added padding for better input appearance */
}
/* Toggle buttons styles */
#toggle_panel_btn, #toggle_editor_panel_btn {
background: none;
border: none;
cursor: pointer;
font-size: 20px;
line-height: 1;
padding: 0 5px;
color: #888;
font-weight: bold;
}
#toggle_panel_btn:hover, #toggle_editor_panel_btn:hover {
color: #000;
}
/* Main Panel Specific Styles */
#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: 16px; padding: 5px 12px; }
#workflow-engine-panel .action-btn { padding: 8px 10px; 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; }
#loop_workflow_btn.looping:hover:not(:disabled) { background-color: #e0a800; border-color: #d39e00;}
/* UI Căn chỉnh cho các form group */
.wwe-form-group {
display: flex;
flex-direction: column;
margin-bottom: 8px; /* Added margin for spacing */
}
.wwe-form-group label {
font-weight: bold;
font-size: 13px;
margin-bottom: 4px;
}
/* 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: 14px; transition: background-color 0.2s; font-weight: bold;
}
.accordion-header:hover { background-color: #e9e9e9; }
.accordion-header::after { content: ' ▼'; font-size: 10px; float: right; }
.accordion-header.active::after { content: ' ▲'; }
.accordion-content {
padding: 0 15px 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; /* Hidden by default */
position: fixed; /* Stay in place */
z-index: 1002; /* Sit on top */
left: 0; top: 0;
width: 100%; height: 100%;
overflow: auto; /* Enable scroll if needed */
background-color: rgba(0,0,0,0.4); /* Black w/ opacity */
}
#workflow-editor-content {
background-color: #fefefe;
padding: 0; /* Changed to 0 as header/content div will have padding */
border: 1px solid #888;
width: 80%;
max-width: 600px;
border-radius: 8px;
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
position: absolute; /* Allows top/left to be set for dragging */
top: 50%; /* Initial centering */
left: 50%; /* Initial centering */
transform: translate(-50%, -50%); /* Initial centering */
}
#editor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin:0;
padding: 10px 15px;
cursor: grab; /* Indicate draggable */
border-bottom: 1px solid #eee;
font-size: 18px; /* Larger title for modal header */
font-weight: bold;
}
#editor-title {
flex-grow: 1; /* Allows title to take available space */
}
#close-modal {
margin-right: 10px; /* Space between close and toggle buttons */
}
/* Rules for collapsing the editor modal */
#workflow-editor-content.is-collapsed #editor-panel-content {
display: none;
}
#workflow-editor-content.is-collapsed #editor-header {
border-bottom: none; /* Remove border when collapsed */
}
/* Workflow steps list */
#workflow_steps_list {
list-style: none;
padding: 0;
min-height: 100px;
border: 1px dashed #ccc;
padding: 10px;
border-radius: 4px;
margin-top: 10px; /* Added margin */
}
#workflow_steps_list li {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px;
border: 1px solid #ddd;
margin-bottom: 5px;
border-radius: 4px;
background: #fafafa;
cursor: grab;
}
#workflow_steps_list li .step-number {
margin-right: 8px; /* Space out the number */
font-weight: bold;
color: #888;
}
#workflow_steps_list li.editing {
background-color: #e0eafc !important;
border-color: #007bff !important;
}
#workflow_steps_list li.dragging {
opacity: 0.5;
}
#add-step-form {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #eee;
}
`;
document.head.appendChild(style);
// Accordion functionality for main panel
panel.querySelectorAll('.accordion-header').forEach(button => {
button.addEventListener('click', () => {
// Close other open accordions
panel.querySelectorAll('.accordion-header.active').forEach(activeButton => {
if (activeButton !== button) {
activeButton.classList.remove('active');
activeButton.nextElementSibling.style.maxHeight = null;
}
});
// Toggle current accordion
button.classList.toggle('active');
const content = button.nextElementSibling;
if (content.style.maxHeight) {
content.style.maxHeight = null;
} else {
content.style.maxHeight = content.scrollHeight + "px";
}
});
});
// Toggle panel button for main panel
document.getElementById('toggle_panel_btn').addEventListener('click', (e) => {
e.stopPropagation(); // Ngăn sự kiện kéo-thả của header
const isCollapsed = panel.classList.toggle('is-collapsed');
e.currentTarget.innerHTML = isCollapsed ? '▼' : '▲';
e.currentTarget.title = isCollapsed ? 'Mở rộng Panel' : 'Thu gọn Panel';
});
// Event listeners for main panel controls
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('run_workflow_btn').addEventListener('click', runSelectedWorkflow);
document.getElementById('loop_workflow_btn').addEventListener('click', toggleWorkflowLoop);
document.getElementById('nav_index_input').addEventListener('change', (e) => {
const targetIndex = parseInt(e.target.value, 10);
if (!isNaN(targetIndex)) {
navigate(0, targetIndex - 1); // targetIndex is 1-based, array index is 0-based
}
});
document.getElementById('workflow_select').addEventListener('change', updateUIState);
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);
// Make the main panel draggable
makeDraggable(panel, document.getElementById('navigator-header'));
};
/**
* 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 createWorkflowEditorModal() {
const modal = document.createElement('div');
modal.id = 'workflow-editor-modal';
// Modal background and centering is removed here, workflow-editor-content handles positioning
modal.style.cssText = `
display: none; /* Hidden by default, shown by JS */
position: fixed; /* Stay in place */
z-index: 1002; /* Sit on top */
left: 0; top: 0;
width: 100%; height: 100%;
overflow: auto; /* Enable scroll if needed */
background-color: rgba(0,0,0,0.4); /* Black w/ opacity */
`;
modal.innerHTML = `
<div id="workflow-editor-content" style="background-color: #fefefe; padding: 0; border: 1px solid #888; width: 80%; max-width: 600px; border-radius: 8px; box-shadow: 0 5px 15px rgba(0,0,0,0.3); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">
<h3 id="editor-header" style="display: flex; justify-content: space-between; align-items: center; margin:0; padding: 10px 15px; cursor: grab; border-bottom: 1px solid #eee;">
<span id="editor-title">Chỉnh sửa Workflow</span>
<div>
<span id="close-modal" style="color: #aaa; font-size: 28px; font-weight: bold; cursor: pointer; margin-right: 10px;">×</span>
<button id="toggle_editor_panel_btn" title="Thu gọn/Mở rộng Panel">▲</button>
</div>
</h3>
<div id="editor-panel-content" style="padding: 15px;">
<input type="hidden" id="editing_workflow_id">
<div class="wwe-form-group">
<label for="workflow_name_input">Tên Workflow:</label>
<input type="text" id="workflow_name_input" placeholder="VD: Khóa đường cấm xe máy">
</div>
<h4>Các bước (kéo thả để sắp xếp):</h4>
<ul id="workflow_steps_list"></ul>
<div id="add-step-form">
<h4>Thêm/Sửa bước:</h4>
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 10px; align-items: center;">
<label>Loại hành động:</label>
<select id="step_type_select">
<option value="click">Click</option>
<option value="input">Input</option>
<option value="delay">Delay</option>
<option value="log">Log</option>
</select>
<label>Mô tả ngắn:</label>
<input type="text" id="step_desc_input" placeholder="VD: Click nút Lưu">
<label>Selector (JS Path):</label>
<input type="text" id="step_selector_input" placeholder="CSS selector, VD: .save-button">
<label>Shadow Selector (Nếu có):</label>
<input type="text" id="step_shadow_input" placeholder="VD: #text-input">
<label>Value / Delay (ms):</label>
<input type="text" id="step_value_input" placeholder="Nhập {{value}} hoặc 500">
</div>
<button id="add_step_btn" style="margin-top: 15px;">Thêm bước</button>
</div>
<div style="text-align: right; margin-top: 20px;">
<button id="save_workflow_btn">Lưu Workflow</button>
<button id="cancel_workflow_btn" style="margin-left: 10px;">Hủy</button>
</div>
</div>
</div>`;
document.body.appendChild(modal);
// Event listeners for the modal
document.getElementById('close-modal').onclick = closeWorkflowEditor;
document.getElementById('cancel_workflow_btn').onclick = closeWorkflowEditor;
document.getElementById('add_step_btn').onclick = addStepToEditor;
document.getElementById('save_workflow_btn').onclick = saveWorkflowFromEditor;
// Toggle panel button for the editor modal
const editorPanelContent = document.getElementById('workflow-editor-content');
const toggleEditorBtn = document.getElementById('toggle_editor_panel_btn');
toggleEditorBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isCollapsed = editorPanelContent.classList.toggle('is-collapsed');
toggleEditorBtn.innerHTML = isCollapsed ? '▼' : '▲';
toggleEditorBtn.title = isCollapsed ? 'Mở rộng Panel' : 'Thu gọn Panel';
});
// Make the modal draggable
makeDraggable(document.getElementById('workflow-editor-content'), document.getElementById('editor-header'));
// Close modal when clicking outside of the content area
window.onclick = function (event) {
if (event.target == modal) {
closeWorkflowEditor();
}
}
}
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();
}
});
}
function navigate(direction, targetIndex = null) {
if (isLooping) { log("Không thể điều hướng thủ công khi vòng lặp đang chạy.", "warn"); return; }
if (permalinks.length === 0) return;
let newIndex = (targetIndex !== null) ? targetIndex : (currentIndex + direction);
if (newIndex >= 0 && newIndex < permalinks.length) {
currentIndex = newIndex;
updateUIState();
processCurrentLink();
} else {
log('Đã đạt đến đầu/cuối danh sách.', 'warn');
}
}
function processCurrentLink() {
if (currentIndex < 0 || currentIndex >= permalinks.length) return log('Vị trí không hợp lệ.', 'warn');
const url = permalinks[currentIndex];
parseWazeUrlAndNavigate(url);
}
async function parseWazeUrlAndNavigate(url) {
try {
const parsedUrl = new URL(url);
const params = parsedUrl.searchParams;
const lon = parseFloat(params.get('lon'));
const lat = parseFloat(params.get('lat'));
// WME editor zoom levels are usually 1-2 levels higher than what's in the URL for direct map.setCenter
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 (isNaN(lon) || isNaN(lat)) throw new Error('URL không chứa tọa độ (lon/lat) hợp lệ.');
// Set map center and zoom first
W.map.setCenter(WazeWrap.Geometry.ConvertTo900913(lon, lat), zoom);
// Wait for model to be ready after map change, then select objects
WazeWrap.Model.onModelReady(() => {
(async () => {
await delay(1000); // Give WME some time to load objects after navigating
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.`, '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 trên bản đồ từ ID ${venueIDs.join(',')} sau khi tải.`, 'warn');
} 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.', 'warn');
}
})();
}, true); // The 'true' ensures it runs once immediately if already ready, or on next ready event.
} catch (error) {
log(`Lỗi khi xử lý URL: ${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');
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('run_workflow_btn').disabled = !hasLinks || !workflowSelect.value; // Disable run if no workflow selected
navIndexInput.disabled = !hasLinks || isLooping;
if (hasLinks) {
navIndexInput.value = currentIndex + 1;
navIndexInput.max = permalinks.length;
navTotalCount.textContent = ` / ${permalinks.length}`;
//document.getElementById('status_info').textContent = `Đang ở: ${currentIndex + 1}/${permalinks.length}`;
} else {
navIndexInput.value = '';
navTotalCount.textContent = '/ N/A';
//document.getElementById('status_info').textContent = 'Chưa có file nào được tải.';
}
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;
document.getElementById('run_workflow_btn').disabled = !hasLinks || isLooping;
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;
loopBtn.disabled = !hasLinks;
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');
loopBtn.classList.add('secondary');
}
}
function populateWorkflowSelector() {
const select = document.getElementById('workflow_select');
const currentId = select.value; // Store current selection
select.innerHTML = ''; // Clear existing options
// Add a default "Select Workflow" option if no workflows are available or selected
if (Object.keys(allWorkflows).length === 0) {
const defaultOption = document.createElement('option');
defaultOption.value = '';
defaultOption.textContent = '--- Không có workflow ---';
select.appendChild(defaultOption);
} else {
const emptyOption = document.createElement('option');
emptyOption.value = '';
emptyOption.textContent = '--- 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.value = Object.keys(allWorkflows)[0]; // Select the first workflow if none were selected
} 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');
}
}
let draggedItem = null; // For drag-and-drop reordering of steps
/**
* Resets the step editor form to its default state.
*/
function resetStepEditorForm() {
document.querySelectorAll('#workflow_steps_list li.editing').forEach(el => el.classList.remove('editing'));
document.getElementById('step_type_select').value = 'click';
document.getElementById('step_desc_input').value = '';
document.getElementById('step_selector_input').value = '';
document.getElementById('step_shadow_input').value = '';
document.getElementById('step_value_input').value = '';
const addBtn = document.getElementById('add_step_btn');
addBtn.textContent = 'Thêm bước';
delete addBtn.dataset.editingIndex; // Remove the editing index
}
function openWorkflowEditor(workflowId = null) {
const modal = document.getElementById('workflow-editor-modal');
const title = document.getElementById('editor-title');
const nameInput = document.getElementById('workflow_name_input');
const idInput = document.getElementById('editing_workflow_id');
const stepsList = document.getElementById('workflow_steps_list');
stepsList.innerHTML = ''; // Clear previous steps
resetStepEditorForm(); // Reset the add/edit step form
if (workflowId && allWorkflows[workflowId]) {
const wf = allWorkflows[workflowId];
title.textContent = "Chỉnh sửa Workflow";
nameInput.value = wf.name;
idInput.value = workflowId;
wf.steps.forEach((step, index) => renderStepInEditor(step, index));
} else {
title.textContent = "Tạo Workflow Mới";
nameInput.value = '';
idInput.value = '';
}
modal.style.display = 'block'; // Show the modal (block to allow centering)
}
function closeWorkflowEditor() {
document.getElementById('workflow-editor-modal').style.display = 'none';
}
function renderStepInEditor(step, index) {
const list = document.getElementById('workflow_steps_list');
const li = document.createElement('li');
li.dataset.index = index;
li.dataset.step = JSON.stringify(step); // Store step data on the element
li.draggable = true;
li.innerHTML = `
<div>
<span class="step-number">${index + 1}.</span>
<span><strong>${step.type.toUpperCase()}:</strong> ${step.desc || 'Không có mô tả'}</span>
</div>
<button class="delete-step-btn" title="Xóa bước này" style="background: none; border: none; color: #e74c3c; font-weight: bold; cursor: pointer; padding: 0;">✖</button>
`;
li.addEventListener('click', (e) => {
// If delete button was clicked, don't enter edit mode
if (e.target.classList.contains('delete-step-btn')) return;
resetStepEditorForm(); // Reset any other editing state
li.classList.add('editing'); // Mark this item as currently being edited
const stepData = JSON.parse(li.dataset.step);
document.getElementById('step_type_select').value = stepData.type;
document.getElementById('step_desc_input').value = stepData.desc || '';
document.getElementById('step_selector_input').value = stepData.selector || '';
document.getElementById('step_shadow_input').value = stepData.shadowSelector || '';
document.getElementById('step_value_input').value = stepData.type === 'delay' ? stepData.delay : (stepData.value || '');
const addBtn = document.getElementById('add_step_btn');
addBtn.textContent = `Cập nhật bước ${index + 1}`;
addBtn.dataset.editingIndex = index; // Store the index of the step being edited
});
li.querySelector('.delete-step-btn').onclick = () => {
li.remove();
updateStepNumbers(); // Re-index steps after deletion
resetStepEditorForm(); // Clear editor form if the deleted step was being edited
};
// Drag and drop event listeners
li.addEventListener('dragstart', (e) => {
draggedItem = e.target;
e.target.classList.add('dragging'); // Add class for visual feedback
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', index); // Set data for Firefox
setTimeout(() => e.target.style.opacity = '0.5', 0); // Hide the original element slightly
});
li.addEventListener('dragend', (e) => {
e.target.classList.remove('dragging');
e.target.style.opacity = '1';
draggedItem = null;
updateStepNumbers(); // Update numbers and data-index after drop
});
li.addEventListener('dragover', (e) => {
e.preventDefault(); // Allow drop
const afterElement = getDragAfterElement(list, e.clientY);
if (draggedItem && draggedItem !== e.target) { // Ensure it's not dragging over itself
if (afterElement == null) {
list.appendChild(draggedItem);
} else {
list.insertBefore(draggedItem, afterElement);
}
}
});
list.appendChild(li);
}
function updateStepNumbers() {
const list = document.getElementById('workflow_steps_list');
const currentEditingIndex = document.getElementById('add_step_btn').dataset.editingIndex;
let newEditingIndex = -1;
Array.from(list.children).forEach((li, i) => {
li.dataset.index = i;
li.querySelector('.step-number').textContent = `${i + 1}.`;
const stepData = JSON.parse(li.dataset.step);
// Re-render text content in case the type or description was changed on a dragged item
li.querySelector('span:not(.step-number)').innerHTML = `<strong>${stepData.type.toUpperCase()}:</strong> ${stepData.desc || 'Không có mô tả'}`;
if (li.classList.contains('editing')) {
newEditingIndex = i;
}
});
// Update the 'Add/Update Step' button's text if the edited item moved
if (newEditingIndex !== -1) {
const addBtn = document.getElementById('add_step_btn');
addBtn.textContent = `Cập nhật bước ${newEditingIndex + 1}`;
addBtn.dataset.editingIndex = newEditingIndex; // Update the stored index
}
}
function getDragAfterElement(container, y) {
const draggableElements = [...container.querySelectorAll('li:not(.dragging)')];
return draggableElements.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset: offset, element: child };
} else {
return closest;
}
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
function addStepToEditor() {
const addBtn = document.getElementById('add_step_btn');
const editingIndex = addBtn.dataset.editingIndex; // This will be undefined if adding a new step
const stepData = {
type: document.getElementById('step_type_select').value,
desc: document.getElementById('step_desc_input').value.trim()
};
const selector = document.getElementById('step_selector_input').value.trim();
const shadowSelector = document.getElementById('step_shadow_input').value.trim();
const valueOrDelay = document.getElementById('step_value_input').value.trim();
if (selector) stepData.selector = selector;
if (shadowSelector) stepData.shadowSelector = shadowSelector;
if (stepData.type === 'delay') {
stepData.delay = parseInt(valueOrDelay, 10) || 100; // Default to 100ms
} else if (stepData.type === 'input') {
if (valueOrDelay) stepData.value = valueOrDelay;
else { alert("Vui lòng nhập giá trị cho bước Input."); return; }
} else if (stepData.type === 'log') {
// Log step doesn't require selector or value beyond description
stepData.value = valueOrDelay; // Can use value for log message if desired
delete stepData.selector;
delete stepData.shadowSelector;
}
// Basic validation
if (!stepData.desc) { alert("Vui lòng nhập mô tả ngắn cho bước này."); return; }
if ((stepData.type === 'click' || stepData.type === 'input') && !stepData.selector) { alert("Vui lòng nhập Selector (JS Path)."); return; }
if (editingIndex !== undefined && editingIndex !== '') { // If editing an existing step
const list = document.getElementById('workflow_steps_list');
const liToUpdate = list.querySelector(`li[data-index='${editingIndex}']`);
if (liToUpdate) {
liToUpdate.dataset.step = JSON.stringify(stepData); // Update stored data
liToUpdate.querySelector('span:not(.step-number)').innerHTML = `<strong>${stepData.type.toUpperCase()}:</strong> ${stepData.desc || 'Không có mô tả'}`;
}
} else { // Adding a new step
const list = document.getElementById('workflow_steps_list');
renderStepInEditor(stepData, list.children.length); // Add to the end
}
resetStepEditorForm();
}
function saveWorkflowFromEditor() {
const name = document.getElementById('workflow_name_input').value.trim();
if (!name) {
alert("Vui lòng nhập tên cho workflow.");
return;
}
const steps = [];
document.querySelectorAll('#workflow_steps_list li').forEach(li => {
steps.push(JSON.parse(li.dataset.step));
});
if (steps.length === 0) {
alert("Workflow phải có ít nhất một bước.");
return;
}
let id = document.getElementById('editing_workflow_id').value;
if (!id) {
// Generate a unique ID for new workflows
id = `custom_${Date.now()}`;
}
allWorkflows[id] = { name, steps };
saveWorkflows();
populateWorkflowSelector();
document.getElementById('workflow_select').value = id; // Select the newly saved workflow
closeWorkflowEditor();
log(`Đã lưu workflow "${name}"`, 'success');
}
// Initialize the script
bootstrap();
})();