Created
          October 20, 2025 07:30 
        
      - 
      
- 
        Save tranphuquy19/bc38283c592ce98ef24e227dc611cd42 to your computer and use it in GitHub Desktop. 
    Action Recorder
  
        
  
    
      This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
      Learn more about bidirectional Unicode characters
    
  
  
    
  | // ==UserScript== | |
| // @name Smart Web Action Recorder | |
| // @namespace http://tampermonkey.net/ | |
| // @version 2.5.0 | |
| // @description Record user actions with multi-select support, selector validation, and playback | |
| // @author Your Name | |
| // @match *://*/* | |
| // @grant GM_getValue | |
| // @grant GM_setValue | |
| // @grant GM_deleteValue | |
| // @icon data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="red"><circle cx="12" cy="12" r="10"/></svg> | |
| // @run-at document-start | |
| // ==/UserScript== | |
| (function() { | |
| 'use strict'; | |
| // ==================== CONFIGURATION ==================== | |
| const CONFIG = { | |
| STORAGE_KEY: 'web_action_recordings', | |
| SESSION_KEY: 'current_session_id', | |
| RECORDING_STATE: 'is_recording', | |
| BUTTON_CONTAINER_ID: '__recorder_float_buttons__', | |
| POSITION_KEY: 'recorder_button_position', | |
| TEMP_ACTIONS_KEY: 'temp_recording_actions', | |
| Z_INDEX: 2147483647, | |
| HIGHLIGHT_COLOR: '#ff0000', | |
| HIGHLIGHT_DURATION: 500, | |
| MAX_TEXT_LENGTH: 100, | |
| MAX_XPATH_DEPTH: 15, | |
| SPA_CHECK_INTERVAL: 100, | |
| NAVIGATION_DEBOUNCE: 500, | |
| AUTO_SAVE_INTERVAL: 2000, | |
| LABELABLE_ELEMENTS: ['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON', 'A', 'DIV', 'SPAN'], | |
| LABEL_TAGS: ['LABEL', 'SPAN', 'DIV', 'P', 'STRONG', 'B', 'EM', 'I', 'LEGEND', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'TD', 'TH', 'LI', 'DT', 'DD'], | |
| MAX_SIBLING_SEARCH: 5, | |
| MAX_LABEL_TEXT_LENGTH: 150, | |
| MAX_PARENT_SEARCH: 5, | |
| CUSTOM_SELECT_PATTERNS: { | |
| bootstrapSelect: '.bootstrap-select', | |
| select2: '.select2-container', | |
| chosen: '.chosen-container', | |
| niceSelect: '.nice-select', | |
| customSelect: '.custom-select-wrapper' | |
| } | |
| }; | |
| // ==================== TEXT UTILITIES ==================== | |
| function getDirectTextContent(element) { | |
| if (!element) return ''; | |
| let text = ''; | |
| for (let node of element.childNodes) { | |
| if (node.nodeType === Node.TEXT_NODE) { | |
| text += node.textContent; | |
| } | |
| } | |
| return normalizeText(text); | |
| } | |
| function getTextExcludingChildren(element, excludeTags = ['SELECT', 'INPUT', 'BUTTON', 'A', 'SCRIPT', 'STYLE', 'SMALL']) { | |
| if (!element) return ''; | |
| let text = ''; | |
| function traverse(node) { | |
| if (node.nodeType === Node.TEXT_NODE) { | |
| text += node.textContent; | |
| } else if (node.nodeType === Node.ELEMENT_NODE) { | |
| if (excludeTags.includes(node.tagName)) { | |
| return; | |
| } | |
| for (let child of node.childNodes) { | |
| traverse(child); | |
| } | |
| } | |
| } | |
| traverse(element); | |
| return normalizeText(text); | |
| } | |
| function normalizeText(text) { | |
| if (!text) return ''; | |
| return text.trim() | |
| .replace(/\s+/g, ' ') | |
| .substring(0, CONFIG.MAX_LABEL_TEXT_LENGTH); | |
| } | |
| function humanizeAttributeName(name) { | |
| return name | |
| .replace(/[_-]/g, ' ') | |
| .replace(/([a-z])([A-Z])/g, '$1 $2') | |
| .split(' ') | |
| .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) | |
| .join(' '); | |
| } | |
| function hasInteractiveChildren(element) { | |
| const interactiveTags = ['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA']; | |
| return interactiveTags.some(tag => element.querySelector(tag) !== null); | |
| } | |
| // ==================== CUSTOM SELECT DETECTOR ==================== | |
| function findActualSelectElement(element) { | |
| if (element.tagName === 'SELECT') { | |
| return { | |
| select: element, | |
| wrapper: element.closest('[class*="select"]'), | |
| type: 'native', | |
| isMultiple: element.multiple || element.hasAttribute('multiple') | |
| }; | |
| } | |
| // Bootstrap Select | |
| const bootstrapWrapper = element.closest('.bootstrap-select'); | |
| if (bootstrapWrapper) { | |
| const select = bootstrapWrapper.querySelector('select.selectpicker'); | |
| if (select) { | |
| return { | |
| select: select, | |
| wrapper: bootstrapWrapper, | |
| button: bootstrapWrapper.querySelector('button.dropdown-toggle'), | |
| type: 'bootstrap-select', | |
| isMultiple: select.multiple || select.hasAttribute('multiple'), | |
| currentValue: select.multiple ? Array.from(select.selectedOptions).map(o => o.value) : select.value, | |
| currentText: bootstrapWrapper.querySelector('.filter-option')?.textContent?.trim() | |
| }; | |
| } | |
| } | |
| // Select2 | |
| const select2Wrapper = element.closest('.select2-container'); | |
| if (select2Wrapper) { | |
| const selectId = select2Wrapper.getAttribute('id')?.replace('select2-', '').replace('-container', ''); | |
| if (selectId) { | |
| const select = document.getElementById(selectId); | |
| if (select && select.tagName === 'SELECT') { | |
| return { | |
| select: select, | |
| wrapper: select2Wrapper, | |
| type: 'select2', | |
| isMultiple: select.multiple, | |
| currentValue: select.multiple ? Array.from(select.selectedOptions).map(o => o.value) : select.value | |
| }; | |
| } | |
| } | |
| } | |
| // Chosen | |
| const chosenWrapper = element.closest('.chosen-container'); | |
| if (chosenWrapper) { | |
| const selectId = chosenWrapper.getAttribute('id')?.replace('_chosen', ''); | |
| if (selectId) { | |
| const select = document.getElementById(selectId); | |
| if (select && select.tagName === 'SELECT') { | |
| return { | |
| select: select, | |
| wrapper: chosenWrapper, | |
| type: 'chosen', | |
| isMultiple: select.multiple, | |
| currentValue: select.multiple ? Array.from(select.selectedOptions).map(o => o.value) : select.value | |
| }; | |
| } | |
| } | |
| } | |
| // Generic | |
| const container = element.closest('[class*="select"]'); | |
| if (container) { | |
| const select = container.querySelector('select'); | |
| if (select) { | |
| return { | |
| select: select, | |
| wrapper: container, | |
| type: 'custom', | |
| isMultiple: select.multiple, | |
| currentValue: select.multiple ? Array.from(select.selectedOptions).map(o => o.value) : select.value | |
| }; | |
| } | |
| } | |
| // Sibling select | |
| const parent = element.parentElement; | |
| if (parent) { | |
| const siblingSelect = parent.querySelector('select'); | |
| if (siblingSelect) { | |
| return { | |
| select: siblingSelect, | |
| wrapper: parent, | |
| type: 'sibling', | |
| isMultiple: siblingSelect.multiple, | |
| currentValue: siblingSelect.multiple ? Array.from(siblingSelect.selectedOptions).map(o => o.value) : siblingSelect.value | |
| }; | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * Get selected option(s) text and values for multi-select | |
| */ | |
| function getSelectedOptions(selectInfo) { | |
| if (!selectInfo) return null; | |
| const { select, type, wrapper, currentText, isMultiple } = selectInfo; | |
| // For multi-select | |
| if (isMultiple && select) { | |
| const selectedOptions = Array.from(select.selectedOptions); | |
| return { | |
| count: selectedOptions.length, | |
| values: selectedOptions.map(opt => opt.value), | |
| texts: selectedOptions.map(opt => { | |
| // Extract text excluding <small> tags | |
| const optText = getTextExcludingChildren(opt, ['SMALL']); | |
| return optText || opt.textContent.trim(); | |
| }), | |
| fullTexts: selectedOptions.map(opt => opt.textContent.trim()), | |
| displayText: currentText || `${selectedOptions.length} items selected` | |
| }; | |
| } | |
| // Single select | |
| if (currentText) { | |
| return { | |
| count: 1, | |
| values: [select?.value], | |
| texts: [currentText], | |
| displayText: currentText | |
| }; | |
| } | |
| if (select) { | |
| const selectedOption = select.options[select.selectedIndex]; | |
| if (selectedOption) { | |
| const optText = getTextExcludingChildren(selectedOption, ['SMALL']); | |
| return { | |
| count: 1, | |
| values: [selectedOption.value], | |
| texts: [optText || selectedOption.textContent.trim()], | |
| displayText: optText || selectedOption.textContent.trim() | |
| }; | |
| } | |
| } | |
| if (wrapper) { | |
| const visibleText = wrapper.querySelector('.filter-option, .select2-selection__rendered, .chosen-single span'); | |
| if (visibleText) { | |
| return { | |
| count: 1, | |
| texts: [visibleText.textContent.trim()], | |
| displayText: visibleText.textContent.trim() | |
| }; | |
| } | |
| } | |
| return null; | |
| } | |
| // ==================== UNIVERSAL LABEL FINDER ==================== | |
| function findAssociatedLabel(element) { | |
| const labels = []; | |
| const selectInfo = findActualSelectElement(element); | |
| const targetElement = selectInfo?.select || element; | |
| const searchRoot = selectInfo?.wrapper || targetElement; | |
| // Method 1: <label for="id"> | |
| if (targetElement.id) { | |
| const labelByFor = document.querySelector(`label[for="${CSS.escape(targetElement.id)}"]`); | |
| if (labelByFor) { | |
| const labelText = getDirectTextContent(labelByFor); | |
| if (labelText) { | |
| labels.push({ | |
| element: labelByFor, | |
| text: labelText, | |
| method: 'for-attribute', | |
| confidence: 1.0 | |
| }); | |
| } | |
| } | |
| } | |
| // Method 2: Previous <label> sibling | |
| let prevSibling = searchRoot.previousElementSibling; | |
| if (prevSibling && prevSibling.tagName === 'LABEL') { | |
| const labelText = getDirectTextContent(prevSibling); | |
| if (labelText) { | |
| labels.push({ | |
| element: prevSibling, | |
| text: labelText, | |
| method: 'previous-sibling-label', | |
| confidence: 0.95 | |
| }); | |
| } | |
| } | |
| // Method 3: Label in parent containers | |
| let currentParent = searchRoot.parentElement; | |
| let parentLevel = 0; | |
| while (currentParent && parentLevel < CONFIG.MAX_PARENT_SEARCH) { | |
| const labelsInParent = currentParent.querySelectorAll('label'); | |
| for (let label of labelsInParent) { | |
| const labelPosition = label.compareDocumentPosition(searchRoot); | |
| if (labelPosition & Node.DOCUMENT_POSITION_FOLLOWING) { | |
| const labelText = getDirectTextContent(label); | |
| if (labelText) { | |
| const forAttr = label.getAttribute('for'); | |
| let confidence = 0.85 - (parentLevel * 0.05); | |
| let method = `parent-container-label-level-${parentLevel}`; | |
| if (forAttr) { | |
| let ancestor = searchRoot; | |
| let ancestorLevel = 0; | |
| while (ancestor && ancestorLevel < 3) { | |
| if (ancestor.id === forAttr) { | |
| confidence = 0.95; | |
| method = 'parent-label-for-attribute'; | |
| break; | |
| } | |
| ancestor = ancestor.parentElement; | |
| ancestorLevel++; | |
| } | |
| } | |
| labels.push({ | |
| element: label, | |
| text: labelText, | |
| method: method, | |
| confidence: confidence | |
| }); | |
| break; | |
| } | |
| } | |
| } | |
| currentParent = currentParent.parentElement; | |
| parentLevel++; | |
| } | |
| // Method 4: Nested inside <label> | |
| const parentLabel = targetElement.closest('label'); | |
| if (parentLabel) { | |
| const labelText = getTextExcludingChildren(parentLabel, ['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON']); | |
| if (labelText) { | |
| labels.push({ | |
| element: parentLabel, | |
| text: labelText, | |
| method: 'nested-in-label', | |
| confidence: 0.9 | |
| }); | |
| } | |
| } | |
| // Method 5: aria-labelledby | |
| if (targetElement.hasAttribute('aria-labelledby')) { | |
| const labelId = targetElement.getAttribute('aria-labelledby'); | |
| const ariaLabel = document.getElementById(labelId); | |
| if (ariaLabel) { | |
| labels.push({ | |
| element: ariaLabel, | |
| text: normalizeText(ariaLabel.textContent), | |
| method: 'aria-labelledby', | |
| confidence: 1.0 | |
| }); | |
| } | |
| } | |
| // Method 6: aria-label | |
| if (targetElement.hasAttribute('aria-label')) { | |
| const ariaText = targetElement.getAttribute('aria-label').trim(); | |
| if (ariaText) { | |
| labels.push({ | |
| element: targetElement, | |
| text: ariaText, | |
| method: 'aria-label', | |
| confidence: 1.0 | |
| }); | |
| } | |
| } | |
| // Method 7: title attribute | |
| if (targetElement.hasAttribute('title')) { | |
| const titleText = targetElement.getAttribute('title').trim(); | |
| if (titleText && titleText.length < CONFIG.MAX_LABEL_TEXT_LENGTH) { | |
| labels.push({ | |
| element: targetElement, | |
| text: titleText, | |
| method: 'title-attribute', | |
| confidence: 0.7 | |
| }); | |
| } | |
| } | |
| // Method 8: Previous siblings (non-label tags) | |
| const prevSiblings = findPreviousSiblingLabels(searchRoot, selectInfo); | |
| labels.push(...prevSiblings); | |
| // Method 9: Adjacent text nodes | |
| const textNodes = findAdjacentTextNodes(searchRoot, selectInfo); | |
| labels.push(...textNodes); | |
| // Method 10: Placeholder | |
| if (targetElement.hasAttribute('placeholder')) { | |
| const placeholder = targetElement.getAttribute('placeholder').trim(); | |
| if (placeholder) { | |
| labels.push({ | |
| element: targetElement, | |
| text: placeholder, | |
| method: 'placeholder', | |
| confidence: 0.6 | |
| }); | |
| } | |
| } | |
| // Method 11: Button/Link text | |
| if (['BUTTON', 'A'].includes(targetElement.tagName)) { | |
| const text = normalizeText(targetElement.textContent); | |
| if (text && text.length < CONFIG.MAX_LABEL_TEXT_LENGTH) { | |
| labels.push({ | |
| element: targetElement, | |
| text: text, | |
| method: 'element-text', | |
| confidence: 0.9 | |
| }); | |
| } | |
| } | |
| // Method 12: Name attribute fallback | |
| if (targetElement.hasAttribute('name')) { | |
| const name = targetElement.getAttribute('name'); | |
| if (name && name.length < CONFIG.MAX_LABEL_TEXT_LENGTH) { | |
| labels.push({ | |
| element: targetElement, | |
| text: humanizeAttributeName(name), | |
| method: 'name-attribute', | |
| confidence: 0.5 | |
| }); | |
| } | |
| } | |
| if (labels.length > 0) { | |
| labels.sort((a, b) => b.confidence - a.confidence); | |
| return labels[0]; | |
| } | |
| return null; | |
| } | |
| function findPreviousSiblingLabels(element, selectInfo) { | |
| const labels = []; | |
| let sibling = element.previousElementSibling; | |
| let siblingCount = 0; | |
| while (sibling && siblingCount < CONFIG.MAX_SIBLING_SEARCH) { | |
| if (sibling.tagName === 'LABEL') { | |
| sibling = sibling.previousElementSibling; | |
| siblingCount++; | |
| continue; | |
| } | |
| if (CONFIG.LABEL_TAGS.includes(sibling.tagName)) { | |
| const text = getTextExcludingChildren(sibling); | |
| if (text && text.length < CONFIG.MAX_LABEL_TEXT_LENGTH && !hasInteractiveChildren(sibling)) { | |
| labels.push({ | |
| element: sibling, | |
| text: text, | |
| method: `previous-sibling-${sibling.tagName.toLowerCase()}`, | |
| confidence: 0.75 | |
| }); | |
| } | |
| } | |
| sibling = sibling.previousElementSibling; | |
| siblingCount++; | |
| } | |
| return labels; | |
| } | |
| function findAdjacentTextNodes(element, selectInfo) { | |
| const labels = []; | |
| let prevNode = element.previousSibling; | |
| let nodeCount = 0; | |
| while (prevNode && nodeCount < CONFIG.MAX_SIBLING_SEARCH) { | |
| if (prevNode.nodeType === Node.TEXT_NODE) { | |
| const text = normalizeText(prevNode.textContent); | |
| if (text && text.length < CONFIG.MAX_LABEL_TEXT_LENGTH) { | |
| labels.push({ | |
| element: prevNode, | |
| text: text, | |
| method: 'adjacent-text-node', | |
| confidence: 0.65 | |
| }); | |
| break; | |
| } | |
| } | |
| prevNode = prevNode.previousSibling; | |
| nodeCount++; | |
| } | |
| return labels; | |
| } | |
| // ==================== XPATH GENERATOR ==================== | |
| function createLabelBasedXPath(element, labelInfo, selectInfo = null) { | |
| const tagName = element.tagName.toLowerCase(); | |
| const labelText = labelInfo.text.replace(/"/g, '\\"').replace(/\s+/g, ' '); | |
| const xpaths = []; | |
| const targetTag = selectInfo?.select ? 'select' : tagName; | |
| xpaths.push({ | |
| type: 'label-following-exact', | |
| xpath: `//label[normalize-space(text())="${labelText}"]/following::${targetTag}[1]`, | |
| priority: 0.05 | |
| }); | |
| xpaths.push({ | |
| type: 'label-following-contains', | |
| xpath: `//label[contains(normalize-space(text()), "${labelText}")]/following::${targetTag}[1]`, | |
| priority: 0.06 | |
| }); | |
| xpaths.push({ | |
| type: 'any-following-exact', | |
| xpath: `//*[normalize-space(text())="${labelText}"]/following::${targetTag}[1]`, | |
| priority: 0.1 | |
| }); | |
| xpaths.push({ | |
| type: 'label-ancestor-descendant', | |
| xpath: `//label[normalize-space(text())="${labelText}"]/ancestor::*[1]//${targetTag}[1]`, | |
| priority: 0.11 | |
| }); | |
| xpaths.push({ | |
| type: 'label-ancestor-2-descendant', | |
| xpath: `//label[normalize-space(text())="${labelText}"]/ancestor::*[2]//${targetTag}[1]`, | |
| priority: 0.12 | |
| }); | |
| const targetId = selectInfo?.select?.id || element.id; | |
| if (targetId) { | |
| xpaths.push({ | |
| type: 'label-with-id', | |
| xpath: `//label[normalize-space(text())="${labelText}"]/following::${targetTag}[@id="${targetId}"]`, | |
| priority: 0.03 | |
| }); | |
| xpaths.push({ | |
| type: 'label-ancestor-id', | |
| xpath: `//label[normalize-space(text())="${labelText}"]/ancestor::*[1]//${targetTag}[@id="${targetId}"]`, | |
| priority: 0.04 | |
| }); | |
| } | |
| const targetName = selectInfo?.select?.name || element.name; | |
| if (targetName) { | |
| xpaths.push({ | |
| type: 'label-with-name', | |
| xpath: `//label[normalize-space(text())="${labelText}"]/following::${targetTag}[@name="${targetName}"][1]`, | |
| priority: 0.13 | |
| }); | |
| } | |
| const dataRef = (selectInfo?.select || element).getAttribute('data-ref'); | |
| if (dataRef) { | |
| xpaths.push({ | |
| type: 'label-with-data-ref', | |
| xpath: `//label[normalize-space(text())="${labelText}"]/following::${targetTag}[@data-ref="${dataRef}"]`, | |
| priority: 0.08 | |
| }); | |
| xpaths.push({ | |
| type: 'label-ancestor-data-ref', | |
| xpath: `//label[normalize-space(text())="${labelText}"]/ancestor::*[1]//${targetTag}[@data-ref="${dataRef}"]`, | |
| priority: 0.09 | |
| }); | |
| } | |
| if (selectInfo?.wrapper) { | |
| const wrapperClasses = selectInfo.wrapper.className.split(/\s+/).filter(c => c); | |
| if (wrapperClasses.length > 0) { | |
| const mainClass = wrapperClasses[0]; | |
| xpaths.push({ | |
| type: 'label-wrapper-class', | |
| xpath: `//label[normalize-space(text())="${labelText}"]/following::*[contains(@class, "${mainClass}")]//select[1]`, | |
| priority: 0.14 | |
| }); | |
| } | |
| } | |
| const partialText = labelText.substring(0, Math.min(20, labelText.length)); | |
| if (partialText.length >= 3) { | |
| xpaths.push({ | |
| type: 'label-partial', | |
| xpath: `//label[contains(normalize-space(text()), "${partialText}")]/following::${targetTag}[1]`, | |
| priority: 0.2 | |
| }); | |
| } | |
| return xpaths; | |
| } | |
| function createLabelBasedCSS(element, labelInfo, selectInfo = null) { | |
| const selectors = []; | |
| const tagName = element.tagName.toLowerCase(); | |
| if (labelInfo.element && labelInfo.element.id) { | |
| const labelId = labelInfo.element.id; | |
| selectors.push({ | |
| type: 'label-id-adjacent', | |
| value: `#${CSS.escape(labelId)} + ${tagName}`, | |
| priority: 0.4 | |
| }); | |
| selectors.push({ | |
| type: 'label-id-sibling', | |
| value: `#${CSS.escape(labelId)} ~ ${tagName}`, | |
| priority: 0.41 | |
| }); | |
| if (selectInfo?.select) { | |
| selectors.push({ | |
| type: 'label-id-select', | |
| value: `#${CSS.escape(labelId)} ~ select`, | |
| priority: 0.38 | |
| }); | |
| } | |
| } | |
| if (labelInfo.element && labelInfo.element.className && typeof labelInfo.element.className === 'string') { | |
| const labelClasses = labelInfo.element.className.trim().split(/\s+/).filter(c => c); | |
| if (labelClasses.length > 0 && labelClasses.length <= 2) { | |
| const classSelector = labelClasses.map(c => `.${CSS.escape(c)}`).join(''); | |
| selectors.push({ | |
| type: 'label-class-adjacent', | |
| value: `${classSelector} + ${tagName}`, | |
| priority: 0.45 | |
| }); | |
| selectors.push({ | |
| type: 'label-class-sibling', | |
| value: `${classSelector} ~ ${tagName}`, | |
| priority: 0.46 | |
| }); | |
| } | |
| } | |
| return selectors; | |
| } | |
| // ==================== SELECTOR VALIDATION ==================== | |
| function validateSelector(selector, element, type) { | |
| try { | |
| let found = null; | |
| if (type.includes('xpath')) { | |
| const result = document.evaluate(selector, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); | |
| found = result.singleNodeValue; | |
| } else { | |
| found = document.querySelector(selector); | |
| } | |
| return found === element; | |
| } catch (e) { | |
| return false; | |
| } | |
| } | |
| // ==================== COMPREHENSIVE SELECTOR GENERATOR ==================== | |
| function generateSelectors(element) { | |
| const selectors = []; | |
| const selectInfo = findActualSelectElement(element); | |
| const targetElement = selectInfo?.select || element; | |
| const metadata = { | |
| isCustomSelect: !!selectInfo, | |
| selectType: selectInfo?.type, | |
| actualElement: targetElement.tagName, | |
| isMultiSelect: selectInfo?.isMultiple || false | |
| }; | |
| if (selectInfo) { | |
| metadata.wrapperClasses = selectInfo.wrapper?.className; | |
| const selectedOptions = getSelectedOptions(selectInfo); | |
| if (selectedOptions) { | |
| metadata.selectedCount = selectedOptions.count; | |
| metadata.selectedValues = selectedOptions.values; | |
| metadata.selectedTexts = selectedOptions.texts; | |
| metadata.displayText = selectedOptions.displayText; | |
| } | |
| } | |
| // === LABEL-BASED SELECTORS === | |
| const labelInfo = findAssociatedLabel(element); | |
| if (labelInfo && labelInfo.text) { | |
| const labelXPaths = createLabelBasedXPath(targetElement, labelInfo, selectInfo); | |
| labelXPaths.forEach(xpathInfo => { | |
| selectors.push({ | |
| type: `label-xpath-${xpathInfo.type}`, | |
| value: xpathInfo.xpath, | |
| priority: xpathInfo.priority, | |
| metadata: { | |
| ...metadata, | |
| labelText: labelInfo.text, | |
| labelMethod: labelInfo.method, | |
| confidence: labelInfo.confidence | |
| } | |
| }); | |
| }); | |
| const labelCSS = createLabelBasedCSS(targetElement, labelInfo, selectInfo); | |
| labelCSS.forEach(cssInfo => { | |
| selectors.push({ | |
| type: cssInfo.type, | |
| value: cssInfo.value, | |
| priority: cssInfo.priority, | |
| metadata: { | |
| ...metadata, | |
| labelText: labelInfo.text, | |
| labelMethod: labelInfo.method | |
| } | |
| }); | |
| }); | |
| } | |
| // === ID SELECTORS === | |
| if (targetElement.id) { | |
| selectors.push({ | |
| type: 'id', | |
| value: `#${CSS.escape(targetElement.id)}`, | |
| priority: 1, | |
| metadata: { ...metadata, attribute: 'id', value: targetElement.id } | |
| }); | |
| selectors.push({ | |
| type: 'xpath-id', | |
| value: `//*[@id="${targetElement.id}"]`, | |
| priority: 1.01, | |
| metadata: { ...metadata, attribute: 'id', value: targetElement.id } | |
| }); | |
| } | |
| // === DATA ATTRIBUTES === | |
| const dataAttrs = Array.from(targetElement.attributes).filter(attr => attr.name.startsWith('data-')); | |
| dataAttrs.forEach((attr, index) => { | |
| selectors.push({ | |
| type: 'data-attribute', | |
| value: `[${attr.name}="${CSS.escape(attr.value)}"]`, | |
| priority: 2 + (index * 0.01), | |
| metadata: { ...metadata, attribute: attr.name, value: attr.value } | |
| }); | |
| }); | |
| // === NAME ATTRIBUTE === | |
| if (targetElement.name) { | |
| selectors.push({ | |
| type: 'name', | |
| value: `[name="${CSS.escape(targetElement.name)}"]`, | |
| priority: 3, | |
| metadata: { ...metadata, attribute: 'name', value: targetElement.name } | |
| }); | |
| selectors.push({ | |
| type: 'tag-name', | |
| value: `${targetElement.tagName.toLowerCase()}[name="${CSS.escape(targetElement.name)}"]`, | |
| priority: 3.01, | |
| metadata: { ...metadata, attribute: 'name', value: targetElement.name } | |
| }); | |
| } | |
| // === CLASSES === | |
| if (targetElement.className && typeof targetElement.className === 'string') { | |
| const classes = targetElement.className.trim().split(/\s+/).filter(c => c); | |
| if (classes.length > 0 && classes.length <= 3) { | |
| const escapedClasses = classes.map(c => CSS.escape(c)); | |
| selectors.push({ | |
| type: 'class-combination', | |
| value: `.${escapedClasses.join('.')}`, | |
| priority: 4, | |
| metadata: { ...metadata, classes: classes } | |
| }); | |
| } | |
| } | |
| // === CSS PATH === | |
| const cssPath = getCSSPath(targetElement); | |
| if (cssPath) { | |
| selectors.push({ | |
| type: 'css-path', | |
| value: cssPath, | |
| priority: 7, | |
| metadata: { ...metadata, description: 'Full CSS path' } | |
| }); | |
| } | |
| // === XPATH === | |
| const xpath = getXPath(targetElement); | |
| if (xpath) { | |
| selectors.push({ | |
| type: 'xpath-absolute', | |
| value: xpath, | |
| priority: 8, | |
| metadata: { ...metadata, description: 'Absolute XPath' } | |
| }); | |
| } | |
| // === VALIDATE TOP SELECTORS === | |
| const sortedSelectors = selectors.sort((a, b) => a.priority - b.priority); | |
| sortedSelectors.forEach(sel => { | |
| sel.validated = validateSelector(sel.value, targetElement, sel.type); | |
| }); | |
| return sortedSelectors; | |
| } | |
| function getCSSPath(element) { | |
| if (element.id) return `#${CSS.escape(element.id)}`; | |
| const path = []; | |
| let current = element; | |
| let depth = 0; | |
| while (current && current.nodeType === Node.ELEMENT_NODE && depth < CONFIG.MAX_XPATH_DEPTH) { | |
| let selector = current.tagName.toLowerCase(); | |
| if (current.id) { | |
| path.unshift(`#${CSS.escape(current.id)}`); | |
| break; | |
| } | |
| let nth = 1; | |
| let sibling = current; | |
| while (sibling.previousElementSibling) { | |
| sibling = sibling.previousElementSibling; | |
| if (sibling.tagName === current.tagName) nth++; | |
| } | |
| const parent = current.parentElement; | |
| if (parent) { | |
| const siblings = Array.from(parent.children).filter(el => el.tagName === current.tagName); | |
| if (siblings.length > 1) { | |
| selector += `:nth-of-type(${nth})`; | |
| } | |
| } | |
| path.unshift(selector); | |
| current = current.parentElement; | |
| depth++; | |
| } | |
| return path.join(' > '); | |
| } | |
| function getXPath(element) { | |
| if (element.id) return `//*[@id="${element.id}"]`; | |
| const paths = []; | |
| let current = element; | |
| let depth = 0; | |
| while (current && current.nodeType === Node.ELEMENT_NODE && depth < CONFIG.MAX_XPATH_DEPTH) { | |
| let index = 1; | |
| let sibling = current.previousSibling; | |
| while (sibling) { | |
| if (sibling.nodeType === Node.ELEMENT_NODE && sibling.tagName === current.tagName) { | |
| index++; | |
| } | |
| sibling = sibling.previousSibling; | |
| } | |
| const tagName = current.tagName.toLowerCase(); | |
| const pathIndex = (index > 1 || hasFollowingSameTags(current)) ? `[${index}]` : ''; | |
| paths.unshift(`${tagName}${pathIndex}`); | |
| current = current.parentElement; | |
| depth++; | |
| } | |
| return '/' + paths.join('/'); | |
| } | |
| function hasFollowingSameTags(element) { | |
| let sibling = element.nextSibling; | |
| while (sibling) { | |
| if (sibling.nodeType === Node.ELEMENT_NODE && sibling.tagName === element.tagName) { | |
| return true; | |
| } | |
| sibling = sibling.nextSibling; | |
| } | |
| return false; | |
| } | |
| // ==================== ELEMENT UTILITIES ==================== | |
| function getElementPosition(element) { | |
| const rect = element.getBoundingClientRect(); | |
| return { | |
| x: Math.round(rect.left + window.scrollX), | |
| y: Math.round(rect.top + window.scrollY), | |
| width: Math.round(rect.width), | |
| height: Math.round(rect.height), | |
| viewport: { | |
| x: Math.round(rect.left), | |
| y: Math.round(rect.top) | |
| } | |
| }; | |
| } | |
| function getElementContext(element) { | |
| const selectInfo = findActualSelectElement(element); | |
| const targetElement = selectInfo?.select || element; | |
| // Limit textContent to prevent huge strings | |
| const rawText = element.textContent || ''; | |
| const limitedText = rawText.length > CONFIG.MAX_TEXT_LENGTH | |
| ? rawText.substring(0, CONFIG.MAX_TEXT_LENGTH) + '...' | |
| : rawText; | |
| const context = { | |
| tagName: targetElement.tagName, | |
| type: targetElement.type || null, | |
| value: targetElement.value || null, | |
| textContent: normalizeText(limitedText), | |
| attributes: {} | |
| }; | |
| // Only store essential attributes | |
| const essentialAttrs = ['id', 'name', 'class', 'type', 'data-ref', 'placeholder', 'required', 'disabled', 'multiple']; | |
| Array.from(targetElement.attributes).forEach(attr => { | |
| if (essentialAttrs.includes(attr.name) || attr.name.startsWith('data-')) { | |
| context.attributes[attr.name] = attr.value; | |
| } | |
| }); | |
| if (selectInfo) { | |
| const selectedOptions = getSelectedOptions(selectInfo); | |
| context.customSelect = { | |
| type: selectInfo.type, | |
| isMultiple: selectInfo.isMultiple, | |
| wrapperClasses: selectInfo.wrapper?.className | |
| }; | |
| if (selectedOptions) { | |
| context.customSelect.selectedCount = selectedOptions.count; | |
| context.customSelect.selectedValues = selectedOptions.values; | |
| context.customSelect.selectedTexts = selectedOptions.texts; | |
| context.customSelect.displayText = selectedOptions.displayText; | |
| } | |
| } | |
| const labelInfo = findAssociatedLabel(element); | |
| if (labelInfo) { | |
| context.associatedLabel = { | |
| text: labelInfo.text, | |
| method: labelInfo.method, | |
| confidence: labelInfo.confidence | |
| }; | |
| } | |
| return context; | |
| } | |
| function isElementVisible(element) { | |
| if (!element) return false; | |
| const style = window.getComputedStyle(element); | |
| if (style.display === 'none' || style.visibility === 'hidden') return false; | |
| const rect = element.getBoundingClientRect(); | |
| return rect.width > 0 && rect.height > 0; | |
| } | |
| // ==================== STORAGE MANAGER ==================== | |
| const StorageManager = { | |
| getRecordings() { | |
| try { | |
| return JSON.parse(GM_getValue(CONFIG.STORAGE_KEY, '[]')); | |
| } catch (e) { | |
| console.error('[Recorder] Error loading recordings:', e); | |
| return []; | |
| } | |
| }, | |
| saveRecording(recording) { | |
| try { | |
| const recordings = this.getRecordings(); | |
| recordings.push(recording); | |
| GM_setValue(CONFIG.STORAGE_KEY, JSON.stringify(recordings)); | |
| return true; | |
| } catch (e) { | |
| console.error('[Recorder] Error saving recording:', e); | |
| return false; | |
| } | |
| }, | |
| updateCurrentSession(sessionId, actions) { | |
| try { | |
| if (!sessionId) return; | |
| const recordings = this.getRecordings(); | |
| const sessionIndex = recordings.findIndex(r => r.sessionId === sessionId); | |
| if (sessionIndex >= 0) { | |
| recordings[sessionIndex].actions = actions; | |
| recordings[sessionIndex].updatedAt = new Date().toISOString(); | |
| recordings[sessionIndex].actionCount = actions.length; | |
| GM_setValue(CONFIG.STORAGE_KEY, JSON.stringify(recordings)); | |
| } | |
| } catch (e) { | |
| console.error('[Recorder] Error updating session:', e); | |
| } | |
| }, | |
| getCurrentSessionId() { | |
| return GM_getValue(CONFIG.SESSION_KEY); | |
| }, | |
| setCurrentSessionId(sessionId) { | |
| GM_setValue(CONFIG.SESSION_KEY, sessionId); | |
| }, | |
| clearCurrentSession() { | |
| GM_deleteValue(CONFIG.SESSION_KEY); | |
| GM_deleteValue(CONFIG.TEMP_ACTIONS_KEY); | |
| }, | |
| isRecording() { | |
| return GM_getValue(CONFIG.RECORDING_STATE, false); | |
| }, | |
| setRecording(state) { | |
| GM_setValue(CONFIG.RECORDING_STATE, state); | |
| }, | |
| getTempActions() { | |
| try { | |
| return JSON.parse(GM_getValue(CONFIG.TEMP_ACTIONS_KEY, '[]')); | |
| } catch (e) { | |
| return []; | |
| } | |
| }, | |
| saveTempActions(actions) { | |
| try { | |
| GM_setValue(CONFIG.TEMP_ACTIONS_KEY, JSON.stringify(actions)); | |
| } catch (e) { | |
| console.error('[Recorder] Error saving temp actions:', e); | |
| } | |
| }, | |
| getPosition() { | |
| const defaultPos = { bottom: 20, right: 20 }; | |
| try { | |
| const pos = GM_getValue(CONFIG.POSITION_KEY); | |
| return pos ? JSON.parse(pos) : defaultPos; | |
| } catch (e) { | |
| return defaultPos; | |
| } | |
| }, | |
| savePosition(position) { | |
| GM_setValue(CONFIG.POSITION_KEY, JSON.stringify(position)); | |
| }, | |
| exportRecordings() { | |
| try { | |
| const recordings = this.getRecordings(); | |
| if (recordings.length === 0) { | |
| alert('No recordings to export!'); | |
| return; | |
| } | |
| const exportData = { | |
| version: '2.5.0', | |
| exportedAt: new Date().toISOString(), | |
| recordingCount: recordings.length, | |
| recordings: recordings | |
| }; | |
| const dataStr = JSON.stringify(exportData, null, 2); | |
| const dataBlob = new Blob([dataStr], { type: 'application/json' }); | |
| const url = URL.createObjectURL(dataBlob); | |
| const link = document.createElement('a'); | |
| link.href = url; | |
| link.download = `action-recordings_${Date.now()}.json`; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| URL.revokeObjectURL(url); | |
| console.log(`[Recorder] ✅ Exported ${recordings.length} recordings`); | |
| } catch (e) { | |
| console.error('[Recorder] Error exporting:', e); | |
| alert('Error exporting recordings!'); | |
| } | |
| }, | |
| clearAllRecordings() { | |
| if (confirm('⚠️ Delete ALL recordings? This cannot be undone!')) { | |
| GM_setValue(CONFIG.STORAGE_KEY, '[]'); | |
| this.clearCurrentSession(); | |
| console.log('[Recorder] All recordings cleared'); | |
| alert('✅ All recordings deleted!'); | |
| } | |
| }, | |
| getStats() { | |
| const recordings = this.getRecordings(); | |
| const totalActions = recordings.reduce((sum, rec) => sum + (rec.actions?.length || 0), 0); | |
| return { | |
| recordingCount: recordings.length, | |
| totalActions: totalActions, | |
| avgActionsPerRecording: recordings.length > 0 ? Math.round(totalActions / recordings.length) : 0, | |
| storageSize: new Blob([JSON.stringify(recordings)]).size | |
| }; | |
| } | |
| }; | |
| // ==================== SPA NAVIGATION DETECTOR ==================== | |
| class SPANavigationDetector { | |
| constructor(onNavigate) { | |
| this.currentUrl = window.location.href; | |
| this.onNavigate = onNavigate; | |
| this.checkInterval = null; | |
| this.isEnabled = false; | |
| } | |
| start() { | |
| if (this.isEnabled) return; | |
| this.isEnabled = true; | |
| this.currentUrl = window.location.href; | |
| this.monitorHistoryAPI(); | |
| window.addEventListener('hashchange', () => this.handleURLChange('hashchange')); | |
| window.addEventListener('popstate', () => this.handleURLChange('popstate')); | |
| this.startPolling(); | |
| } | |
| stop() { | |
| if (!this.isEnabled) return; | |
| this.isEnabled = false; | |
| if (this.checkInterval) clearInterval(this.checkInterval); | |
| } | |
| monitorHistoryAPI() { | |
| const self = this; | |
| const originalPushState = history.pushState; | |
| history.pushState = function(...args) { | |
| originalPushState.apply(this, args); | |
| self.handleURLChange('pushState'); | |
| }; | |
| const originalReplaceState = history.replaceState; | |
| history.replaceState = function(...args) { | |
| originalReplaceState.apply(this, args); | |
| self.handleURLChange('replaceState'); | |
| }; | |
| } | |
| startPolling() { | |
| this.checkInterval = setInterval(() => { | |
| const newUrl = window.location.href; | |
| if (newUrl !== this.currentUrl) { | |
| this.handleURLChange('polling'); | |
| } | |
| }, CONFIG.SPA_CHECK_INTERVAL); | |
| } | |
| handleURLChange(method) { | |
| if (!this.isEnabled) return; | |
| const newUrl = window.location.href; | |
| if (newUrl === this.currentUrl) return; | |
| const oldUrl = this.currentUrl; | |
| this.currentUrl = newUrl; | |
| setTimeout(() => { | |
| if (this.onNavigate) { | |
| this.onNavigate({ | |
| from: oldUrl, | |
| to: newUrl, | |
| method: method, | |
| timestamp: Date.now(), | |
| title: document.title | |
| }); | |
| } | |
| }, CONFIG.NAVIGATION_DEBOUNCE); | |
| } | |
| } | |
| // ==================== ACTION RECORDER ==================== | |
| class ActionRecorder { | |
| constructor() { | |
| this.actions = []; | |
| this.isRecording = false; | |
| this.sessionId = null; | |
| this.startTime = null; | |
| this.eventHandlers = {}; | |
| this.spaDetector = null; | |
| this.autoSaveInterval = null; | |
| this.beforeUnloadBound = null; | |
| this.lastActionTime = null; | |
| this.isRecording = StorageManager.isRecording(); | |
| if (this.isRecording) { | |
| this.restoreSession(); | |
| } | |
| } | |
| restoreSession() { | |
| this.sessionId = StorageManager.getCurrentSessionId(); | |
| const tempActions = StorageManager.getTempActions(); | |
| if (tempActions && tempActions.length > 0) { | |
| this.actions = tempActions; | |
| console.log(`[Recorder] ✅ Restored ${this.actions.length} actions`); | |
| } | |
| if (this.actions.length > 0) { | |
| const firstAction = this.actions[0]; | |
| this.startTime = firstAction.timestamp - (firstAction.relativeTime || 0); | |
| this.lastActionTime = this.actions[this.actions.length - 1].timestamp; | |
| } else { | |
| this.startTime = Date.now(); | |
| } | |
| } | |
| start() { | |
| if (this.isRecording) return; | |
| this.isRecording = true; | |
| this.actions = []; | |
| this.sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; | |
| this.startTime = Date.now(); | |
| this.lastActionTime = this.startTime; | |
| StorageManager.setCurrentSessionId(this.sessionId); | |
| StorageManager.setRecording(true); | |
| this.createInitialRecording(); | |
| this.attachListeners(); | |
| this.startSPADetection(); | |
| this.startAutoSave(); | |
| this.setupBeforeUnload(); | |
| this.recordNavigation({ | |
| from: null, | |
| to: window.location.href, | |
| method: 'initial-load', | |
| timestamp: Date.now(), | |
| title: document.title | |
| }); | |
| this.showNotification('🔴 Recording Started', 'success'); | |
| console.log('[Recorder] ✅ Started:', this.sessionId); | |
| } | |
| stop() { | |
| if (!this.isRecording) return; | |
| this.isRecording = false; | |
| StorageManager.setRecording(false); | |
| this.stopSPADetection(); | |
| this.stopAutoSave(); | |
| this.removeBeforeUnload(); | |
| this.saveCurrentState(); | |
| this.detachListeners(); | |
| StorageManager.clearCurrentSession(); | |
| this.showNotification(`✅ Saved ${this.actions.length} actions`, 'success'); | |
| console.log(`[Recorder] ⏹ Stopped: ${this.actions.length} actions`); | |
| this.actions = []; | |
| this.sessionId = null; | |
| } | |
| createInitialRecording() { | |
| const recording = { | |
| sessionId: this.sessionId, | |
| url: window.location.href, | |
| hostname: window.location.hostname, | |
| pathname: window.location.pathname, | |
| title: document.title, | |
| startTime: new Date(this.startTime).toISOString(), | |
| endTime: null, | |
| duration: 0, | |
| actionCount: 0, | |
| actions: [], | |
| metadata: { | |
| userAgent: navigator.userAgent, | |
| viewport: `${window.innerWidth}x${window.innerHeight}`, | |
| recorderVersion: '2.5.0' | |
| } | |
| }; | |
| StorageManager.saveRecording(recording); | |
| } | |
| startAutoSave() { | |
| this.autoSaveInterval = setInterval(() => { | |
| if (this.isRecording && this.actions.length > 0) { | |
| this.saveCurrentState(); | |
| } | |
| }, CONFIG.AUTO_SAVE_INTERVAL); | |
| } | |
| stopAutoSave() { | |
| if (this.autoSaveInterval) { | |
| clearInterval(this.autoSaveInterval); | |
| this.autoSaveInterval = null; | |
| } | |
| } | |
| setupBeforeUnload() { | |
| this.beforeUnloadBound = () => { | |
| if (this.isRecording) { | |
| this.saveCurrentState(); | |
| } | |
| }; | |
| window.addEventListener('beforeunload', this.beforeUnloadBound); | |
| } | |
| removeBeforeUnload() { | |
| if (this.beforeUnloadBound) { | |
| window.removeEventListener('beforeunload', this.beforeUnloadBound); | |
| } | |
| } | |
| saveCurrentState() { | |
| if (!this.isRecording || !this.sessionId) return; | |
| StorageManager.saveTempActions(this.actions); | |
| StorageManager.updateCurrentSession(this.sessionId, this.actions); | |
| } | |
| startSPADetection() { | |
| this.spaDetector = new SPANavigationDetector((navData) => { | |
| this.recordNavigation(navData); | |
| }); | |
| this.spaDetector.start(); | |
| } | |
| stopSPADetection() { | |
| if (this.spaDetector) { | |
| this.spaDetector.stop(); | |
| this.spaDetector = null; | |
| } | |
| } | |
| recordNavigation(navigationData) { | |
| if (!this.isRecording) return; | |
| const now = Date.now(); | |
| const action = { | |
| id: `action_${now}_${Math.random().toString(36).substr(2, 9)}`, | |
| type: 'navigation', | |
| timestamp: now, | |
| relativeTime: now - this.startTime, | |
| delayFromPrevious: this.lastActionTime ? now - this.lastActionTime : 0, | |
| navigation: { | |
| from: navigationData.from, | |
| to: navigationData.to, | |
| method: navigationData.method, | |
| title: navigationData.title | |
| }, | |
| url: navigationData.to | |
| }; | |
| this.actions.push(action); | |
| this.lastActionTime = now; | |
| this.saveCurrentState(); | |
| console.log(`[Recorder] 🧭 Navigation: ${navigationData.method}`); | |
| } | |
| recordAction(type, element, additionalData = {}) { | |
| if (!this.isRecording) return; | |
| try { | |
| const now = Date.now(); | |
| const action = { | |
| id: `action_${now}_${Math.random().toString(36).substr(2, 9)}`, | |
| type, | |
| timestamp: now, | |
| relativeTime: now - this.startTime, | |
| delayFromPrevious: this.lastActionTime ? now - this.lastActionTime : 0, | |
| selectors: generateSelectors(element), | |
| position: getElementPosition(element), | |
| context: getElementContext(element), | |
| url: window.location.href, | |
| visible: isElementVisible(element), | |
| ...additionalData | |
| }; | |
| this.actions.push(action); | |
| this.lastActionTime = now; | |
| this.highlightElement(element); | |
| if (['submit', 'click', 'change'].includes(type)) { | |
| this.saveCurrentState(); | |
| } | |
| // Enhanced logging with multi-select support | |
| const label = action.context.associatedLabel; | |
| const customSelect = action.context.customSelect; | |
| if (customSelect && customSelect.isMultiple) { | |
| const count = customSelect.selectedCount || 0; | |
| const values = customSelect.selectedTexts?.slice(0, 3).join(', ') || customSelect.displayText; | |
| console.log(`[Recorder] ${type.toUpperCase()}: "${label?.text || 'Unknown'}" → [${count}] ${values}${count > 3 ? '...' : ''}`); | |
| } else if (customSelect) { | |
| console.log(`[Recorder] ${type.toUpperCase()}: "${label?.text || 'Unknown'}" → "${customSelect.displayText}" (${customSelect.type})`); | |
| } else if (label) { | |
| console.log(`[Recorder] ${type.toUpperCase()}: "${label.text}" (${label.method})`); | |
| } else { | |
| console.log(`[Recorder] ${type.toUpperCase()}: ${element.tagName}`); | |
| } | |
| } catch (e) { | |
| console.error('[Recorder] Error recording action:', e); | |
| } | |
| } | |
| highlightElement(element) { | |
| const original = element.style.outline; | |
| element.style.outline = `2px solid ${CONFIG.HIGHLIGHT_COLOR}`; | |
| element.style.outlineOffset = '2px'; | |
| setTimeout(() => { | |
| element.style.outline = original; | |
| }, CONFIG.HIGHLIGHT_DURATION); | |
| } | |
| attachListeners() { | |
| this.eventHandlers = { | |
| click: this.handleClick.bind(this), | |
| input: this.handleInput.bind(this), | |
| change: this.handleChange.bind(this), | |
| submit: this.handleSubmit.bind(this), | |
| keydown: this.handleKeydown.bind(this) | |
| }; | |
| Object.entries(this.eventHandlers).forEach(([event, handler]) => { | |
| document.addEventListener(event, handler, true); | |
| }); | |
| } | |
| detachListeners() { | |
| Object.entries(this.eventHandlers).forEach(([event, handler]) => { | |
| document.removeEventListener(event, handler, true); | |
| }); | |
| this.eventHandlers = {}; | |
| } | |
| handleClick(e) { | |
| if (this.shouldIgnoreEvent(e)) return; | |
| const selectInfo = findActualSelectElement(e.target); | |
| const isLink = e.target.closest('a[href]'); | |
| const isButton = e.target.tagName === 'BUTTON' || e.target.closest('button'); | |
| this.recordAction('click', e.target, { | |
| button: e.button, | |
| isLink: !!isLink, | |
| isButton: !!isButton, | |
| isSelectOption: !!selectInfo, | |
| href: isLink ? isLink.href : null | |
| }); | |
| if (isLink || isButton || selectInfo) { | |
| this.saveCurrentState(); | |
| } | |
| } | |
| handleInput(e) { | |
| if (this.shouldIgnoreEvent(e)) return; | |
| this.recordAction('input', e.target, { value: e.target.value }); | |
| } | |
| handleChange(e) { | |
| if (this.shouldIgnoreEvent(e)) return; | |
| const selectInfo = findActualSelectElement(e.target); | |
| const selectedOptions = getSelectedOptions(selectInfo); | |
| this.recordAction('change', e.target, { | |
| value: e.target.value, | |
| checked: e.target.checked, | |
| selectedOptions: selectedOptions | |
| }); | |
| } | |
| handleSubmit(e) { | |
| if (this.shouldIgnoreEvent(e)) return; | |
| this.recordAction('submit', e.target, { | |
| action: e.target.action, | |
| method: e.target.method | |
| }); | |
| this.saveCurrentState(); | |
| } | |
| handleKeydown(e) { | |
| if (this.shouldIgnoreEvent(e)) return; | |
| if (['Enter', 'Tab', 'Escape'].includes(e.key)) { | |
| this.recordAction('keydown', e.target, { key: e.key }); | |
| if (e.key === 'Enter' && e.target.closest('form')) { | |
| this.saveCurrentState(); | |
| } | |
| } | |
| } | |
| shouldIgnoreEvent(e) { | |
| return e.target.closest(`#${CONFIG.BUTTON_CONTAINER_ID}`); | |
| } | |
| showNotification(message, type = 'info') { | |
| const colors = { success: '#10b981', info: '#3b82f6', error: '#ef4444' }; | |
| const notification = document.createElement('div'); | |
| notification.style.cssText = ` | |
| position: fixed; top: 20px; right: 20px; | |
| padding: 10px 20px; background: ${colors[type]}; | |
| color: white; border-radius: 6px; | |
| font-family: system-ui; font-size: 13px; font-weight: 500; | |
| z-index: ${CONFIG.Z_INDEX + 1}; | |
| box-shadow: 0 4px 12px rgba(0,0,0,0.15); | |
| `; | |
| notification.textContent = message; | |
| document.body.appendChild(notification); | |
| setTimeout(() => notification.remove(), 2500); | |
| } | |
| } | |
| // ==================== UI MANAGER ==================== | |
| class UIManager { | |
| constructor(recorder) { | |
| this.recorder = recorder; | |
| this.container = null; | |
| this.currentPosition = StorageManager.getPosition(); | |
| } | |
| init() { | |
| this.injectStyles(); | |
| this.createFloatingButtons(); | |
| } | |
| injectStyles() { | |
| const style = document.createElement('style'); | |
| style.id = '__recorder_styles__'; | |
| style.textContent = ` | |
| @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } } | |
| #${CONFIG.BUTTON_CONTAINER_ID} { | |
| position: fixed !important; display: inline-flex !important; | |
| align-items: center !important; gap: 8px !important; | |
| padding: 8px 12px !important; | |
| background: rgba(255, 255, 255, 0.95) !important; | |
| backdrop-filter: blur(10px) !important; | |
| border-radius: 24px !important; | |
| box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12) !important; | |
| z-index: ${CONFIG.Z_INDEX} !important; | |
| font-family: system-ui !important; | |
| user-select: none !important; cursor: move !important; | |
| border: 1px solid rgba(0, 0, 0, 0.08) !important; | |
| } | |
| #${CONFIG.BUTTON_CONTAINER_ID}:hover { | |
| box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18) !important; | |
| } | |
| .recorder-btn { | |
| width: 32px !important; height: 32px !important; | |
| border-radius: 50% !important; border: none !important; | |
| cursor: pointer !important; font-size: 16px !important; | |
| display: flex !important; align-items: center !important; | |
| justify-content: center !important; color: white !important; | |
| transition: all 0.2s ease !important; | |
| box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) !important; | |
| } | |
| .recorder-btn:hover { transform: scale(1.1) !important; } | |
| .recorder-btn:active { transform: scale(0.95) !important; } | |
| .recorder-btn-record { background: linear-gradient(135deg, #ef4444, #dc2626) !important; } | |
| .recorder-btn-stop { background: linear-gradient(135deg, #10b981, #059669) !important; } | |
| .recorder-btn-export { background: linear-gradient(135deg, #3b82f6, #2563eb) !important; } | |
| .recorder-btn-stats { background: linear-gradient(135deg, #8b5cf6, #7c3aed) !important; } | |
| .recorder-btn-clear { background: linear-gradient(135deg, #f59e0b, #d97706) !important; } | |
| .recorder-recording-indicator { animation: pulse 1.5s ease-in-out infinite !important; } | |
| .recorder-drag-handle { | |
| width: 20px !important; height: 20px !important; | |
| display: flex !important; cursor: grab !important; | |
| color: #94a3b8 !important; font-size: 14px !important; | |
| } | |
| .recorder-separator { | |
| width: 1px !important; height: 20px !important; | |
| background: rgba(0, 0, 0, 0.1) !important; margin: 0 4px !important; | |
| } | |
| `; | |
| const oldStyle = document.getElementById('__recorder_styles__'); | |
| if (oldStyle) oldStyle.remove(); | |
| document.head.appendChild(style); | |
| } | |
| createFloatingButtons() { | |
| const oldContainer = document.getElementById(CONFIG.BUTTON_CONTAINER_ID); | |
| if (oldContainer) oldContainer.remove(); | |
| this.container = document.createElement('div'); | |
| this.container.id = CONFIG.BUTTON_CONTAINER_ID; | |
| this.container.style.bottom = this.currentPosition.bottom + 'px'; | |
| this.container.style.right = this.currentPosition.right + 'px'; | |
| const dragHandle = document.createElement('div'); | |
| dragHandle.className = 'recorder-drag-handle'; | |
| dragHandle.innerHTML = '⋮⋮'; | |
| dragHandle.title = 'Drag to move'; | |
| this.container.appendChild(dragHandle); | |
| const recordBtn = this.createButton( | |
| this.recorder.isRecording ? '⏹' : '⏺', | |
| this.recorder.isRecording ? 'recorder-btn-stop' : 'recorder-btn-record', | |
| this.recorder.isRecording ? 'Stop' : 'Record', | |
| () => { | |
| if (this.recorder.isRecording) { | |
| this.recorder.stop(); | |
| recordBtn.textContent = '⏺'; | |
| recordBtn.className = 'recorder-btn recorder-btn-record'; | |
| recordBtn.title = 'Record'; | |
| } else { | |
| this.recorder.start(); | |
| recordBtn.textContent = '⏹'; | |
| recordBtn.className = 'recorder-btn recorder-btn-stop recorder-recording-indicator'; | |
| recordBtn.title = 'Stop'; | |
| } | |
| } | |
| ); | |
| if (this.recorder.isRecording) recordBtn.classList.add('recorder-recording-indicator'); | |
| this.container.appendChild(recordBtn); | |
| this.container.appendChild(this.createSeparator()); | |
| this.container.appendChild(this.createButton('💾', 'recorder-btn-export', 'Export', () => StorageManager.exportRecordings())); | |
| this.container.appendChild(this.createButton('📊', 'recorder-btn-stats', 'Stats', () => this.showStats())); | |
| this.container.appendChild(this.createSeparator()); | |
| this.container.appendChild(this.createButton('🗑', 'recorder-btn-clear', 'Clear', () => StorageManager.clearAllRecordings())); | |
| document.body.appendChild(this.container); | |
| this.setupDragAndDrop(); | |
| } | |
| createButton(text, className, title, onClick) { | |
| const button = document.createElement('button'); | |
| button.textContent = text; | |
| button.className = `recorder-btn ${className}`; | |
| button.title = title; | |
| button.onclick = (e) => { e.stopPropagation(); onClick(); }; | |
| return button; | |
| } | |
| createSeparator() { | |
| const sep = document.createElement('div'); | |
| sep.className = 'recorder-separator'; | |
| return sep; | |
| } | |
| setupDragAndDrop() { | |
| let isDragging = false; | |
| let startX, startY, startBottom, startRight; | |
| this.container.addEventListener('mousedown', (e) => { | |
| if (e.target.classList.contains('recorder-btn')) return; | |
| isDragging = true; | |
| startX = e.clientX; | |
| startY = e.clientY; | |
| const rect = this.container.getBoundingClientRect(); | |
| startBottom = window.innerHeight - rect.bottom; | |
| startRight = window.innerWidth - rect.right; | |
| this.container.style.cursor = 'grabbing'; | |
| }); | |
| document.addEventListener('mousemove', (e) => { | |
| if (!isDragging) return; | |
| const deltaX = startX - e.clientX; | |
| const deltaY = e.clientY - startY; | |
| this.container.style.right = Math.max(0, startRight + deltaX) + 'px'; | |
| this.container.style.bottom = Math.max(0, startBottom - deltaY) + 'px'; | |
| }); | |
| document.addEventListener('mouseup', () => { | |
| if (!isDragging) return; | |
| isDragging = false; | |
| this.container.style.cursor = 'move'; | |
| const rect = this.container.getBoundingClientRect(); | |
| this.currentPosition = { | |
| bottom: window.innerHeight - rect.bottom, | |
| right: window.innerWidth - rect.right | |
| }; | |
| StorageManager.savePosition(this.currentPosition); | |
| }); | |
| } | |
| showStats() { | |
| const stats = StorageManager.getStats(); | |
| const recordings = StorageManager.getRecordings(); | |
| let msg = `📊 Recording Statistics\n\n`; | |
| msg += `Total Sessions: ${stats.recordingCount}\n`; | |
| msg += `Total Actions: ${stats.totalActions}\n`; | |
| msg += `Average Actions/Session: ${stats.avgActionsPerRecording}\n`; | |
| msg += `Storage Size: ${(stats.storageSize / 1024).toFixed(2)} KB\n\n`; | |
| if (recordings.length > 0) { | |
| msg += `Recent Sessions:\n`; | |
| msg += `${'='.repeat(50)}\n`; | |
| recordings.slice(-5).reverse().forEach((rec, idx) => { | |
| const title = rec.title?.substring(0, 35) || 'Untitled'; | |
| const actions = rec.actionCount || 0; | |
| const date = new Date(rec.startTime).toLocaleString(); | |
| msg += `${idx + 1}. ${title}\n`; | |
| msg += ` ${actions} actions | ${date}\n\n`; | |
| }); | |
| } | |
| alert(msg); | |
| } | |
| } | |
| // ==================== INITIALIZATION ==================== | |
| function init() { | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', init); | |
| return; | |
| } | |
| const recorder = new ActionRecorder(); | |
| const ui = new UIManager(recorder); | |
| const initUI = () => { | |
| if (document.body) { | |
| ui.init(); | |
| if (recorder.isRecording) { | |
| recorder.attachListeners(); | |
| recorder.startSPADetection(); | |
| recorder.startAutoSave(); | |
| recorder.setupBeforeUnload(); | |
| recorder.recordNavigation({ | |
| from: document.referrer || 'direct', | |
| to: window.location.href, | |
| method: 'page-load', | |
| timestamp: Date.now(), | |
| title: document.title | |
| }); | |
| console.log('[Recorder] ✅ Session restored and active'); | |
| } | |
| console.log('[Recorder] ✅ Ready'); | |
| } else { | |
| setTimeout(initUI, 100); | |
| } | |
| }; | |
| initUI(); | |
| // Global API exposure | |
| window.__actionRecorder = { | |
| recorder, | |
| storage: StorageManager, | |
| ui, | |
| config: CONFIG, | |
| version: '2.5.0', | |
| utils: { | |
| findActualSelectElement, | |
| getSelectedOptions, | |
| findAssociatedLabel, | |
| generateSelectors, | |
| validateSelector | |
| } | |
| }; | |
| console.log('%c🎬 Smart Web Action Recorder v2.5.0', 'color: #10b981; font-size: 16px; font-weight: bold;'); | |
| console.log('%c✨ Multi-Select Support | Selector Validation | Performance Optimized', 'color: #3b82f6; font-size: 12px;'); | |
| console.log('%c📚 API: window.__actionRecorder', 'color: #8b5cf6; font-size: 11px;'); | |
| } | |
| init(); | |
| })(); | 
  
    
      This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
      Learn more about bidirectional Unicode characters
    
  
  
    
  | <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Action Recorder - Step Extractor</title> | |
| <script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js"></script> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| min-height: 100vh; | |
| padding: 20px; | |
| } | |
| #app { | |
| max-width: 1600px; | |
| margin: 0 auto; | |
| } | |
| .header { | |
| background: white; | |
| border-radius: 16px; | |
| padding: 30px; | |
| margin-bottom: 20px; | |
| box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1); | |
| } | |
| .header h1 { | |
| color: #1a202c; | |
| font-size: 32px; | |
| margin-bottom: 10px; | |
| } | |
| .header p { | |
| color: #718096; | |
| font-size: 14px; | |
| } | |
| .upload-section { | |
| background: white; | |
| border-radius: 16px; | |
| padding: 30px; | |
| margin-bottom: 20px; | |
| box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1); | |
| } | |
| .file-input-wrapper { | |
| position: relative; | |
| display: inline-block; | |
| width: 100%; | |
| } | |
| .file-input-wrapper input[type="file"] { | |
| position: absolute; | |
| opacity: 0; | |
| width: 100%; | |
| height: 100%; | |
| cursor: pointer; | |
| } | |
| .file-input-label { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 40px; | |
| border: 2px dashed #cbd5e0; | |
| border-radius: 12px; | |
| cursor: pointer; | |
| transition: all 0.3s; | |
| background: #f7fafc; | |
| } | |
| .file-input-label:hover { | |
| border-color: #667eea; | |
| background: #edf2f7; | |
| } | |
| .file-input-label.dragover { | |
| border-color: #667eea; | |
| background: #e6f2ff; | |
| } | |
| .file-info { | |
| margin-top: 15px; | |
| padding: 15px; | |
| background: #f7fafc; | |
| border-radius: 8px; | |
| font-size: 14px; | |
| color: #4a5568; | |
| } | |
| .recording-selector { | |
| margin-top: 20px; | |
| } | |
| .recording-selector-label { | |
| font-weight: 600; | |
| color: #2d3748; | |
| margin-bottom: 10px; | |
| display: block; | |
| } | |
| .recording-tabs { | |
| display: flex; | |
| gap: 10px; | |
| flex-wrap: wrap; | |
| } | |
| .recording-tab { | |
| padding: 12px 20px; | |
| border: 2px solid #e2e8f0; | |
| border-radius: 8px; | |
| background: white; | |
| cursor: pointer; | |
| transition: all 0.3s; | |
| flex: 1; | |
| min-width: 200px; | |
| } | |
| .recording-tab:hover { | |
| border-color: #cbd5e0; | |
| background: #f7fafc; | |
| } | |
| .recording-tab.active { | |
| border-color: #667eea; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| color: white; | |
| } | |
| .recording-tab-title { | |
| font-weight: 600; | |
| font-size: 13px; | |
| margin-bottom: 4px; | |
| } | |
| .recording-tab.active .recording-tab-title { | |
| color: white; | |
| } | |
| .recording-tab-info { | |
| font-size: 11px; | |
| color: #718096; | |
| } | |
| .recording-tab.active .recording-tab-info { | |
| color: rgba(255, 255, 255, 0.9); | |
| } | |
| .stats-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); | |
| gap: 15px; | |
| margin-top: 20px; | |
| } | |
| .stat-card { | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| color: white; | |
| padding: 20px; | |
| border-radius: 12px; | |
| text-align: center; | |
| } | |
| .stat-value { | |
| font-size: 32px; | |
| font-weight: bold; | |
| margin-bottom: 5px; | |
| } | |
| .stat-label { | |
| font-size: 12px; | |
| opacity: 0.9; | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| } | |
| .controls { | |
| background: white; | |
| border-radius: 16px; | |
| padding: 20px; | |
| margin-bottom: 20px; | |
| box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1); | |
| display: flex; | |
| gap: 15px; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| } | |
| .btn { | |
| padding: 12px 24px; | |
| border: none; | |
| border-radius: 8px; | |
| font-size: 14px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: all 0.3s; | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .btn-primary { | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| color: white; | |
| } | |
| .btn-primary:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4); | |
| } | |
| .btn-secondary { | |
| background: #edf2f7; | |
| color: #4a5568; | |
| } | |
| .btn-secondary:hover { | |
| background: #e2e8f0; | |
| } | |
| .btn-success { | |
| background: linear-gradient(135deg, #10b981, #059669); | |
| color: white; | |
| } | |
| .btn-success:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 8px 20px rgba(16, 185, 129, 0.4); | |
| } | |
| .btn:disabled { | |
| opacity: 0.5; | |
| cursor: not-allowed; | |
| transform: none !important; | |
| } | |
| .search-box { | |
| flex: 1; | |
| min-width: 250px; | |
| } | |
| .search-box input { | |
| width: 100%; | |
| padding: 12px 16px; | |
| border: 2px solid #e2e8f0; | |
| border-radius: 8px; | |
| font-size: 14px; | |
| transition: all 0.3s; | |
| } | |
| .search-box input:focus { | |
| outline: none; | |
| border-color: #667eea; | |
| box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); | |
| } | |
| .filter-group { | |
| display: flex; | |
| gap: 15px; | |
| align-items: center; | |
| } | |
| .filter-group label { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| font-size: 14px; | |
| color: #4a5568; | |
| cursor: pointer; | |
| user-select: none; | |
| } | |
| .filter-group input[type="checkbox"] { | |
| width: 18px; | |
| height: 18px; | |
| cursor: pointer; | |
| } | |
| .table-container { | |
| background: white; | |
| border-radius: 16px; | |
| padding: 20px; | |
| box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1); | |
| overflow-x: auto; | |
| } | |
| table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| font-size: 14px; | |
| } | |
| thead { | |
| background: #f7fafc; | |
| position: sticky; | |
| top: 0; | |
| z-index: 10; | |
| } | |
| th { | |
| padding: 15px 12px; | |
| text-align: left; | |
| font-weight: 600; | |
| color: #2d3748; | |
| border-bottom: 2px solid #e2e8f0; | |
| white-space: nowrap; | |
| } | |
| td { | |
| padding: 12px; | |
| border-bottom: 1px solid #e2e8f0; | |
| vertical-align: top; | |
| } | |
| tr.disabled-row { | |
| background: #f7fafc; | |
| } | |
| tr.disabled-row td { | |
| color: #a0aec0; | |
| } | |
| tr.main-step { | |
| background: #eef5ff; | |
| } | |
| tr:hover:not(.disabled-row) { | |
| background: #f7fafc; | |
| } | |
| .checkbox-cell { | |
| width: 40px; | |
| text-align: center; | |
| } | |
| .checkbox-cell input[type="checkbox"] { | |
| width: 20px; | |
| height: 20px; | |
| cursor: pointer; | |
| } | |
| .checkbox-cell input[type="checkbox"]:disabled { | |
| cursor: not-allowed; | |
| opacity: 0.5; | |
| } | |
| .index-cell { | |
| width: 60px; | |
| font-weight: 600; | |
| color: #718096; | |
| } | |
| .type-badge { | |
| display: inline-block; | |
| padding: 4px 10px; | |
| border-radius: 6px; | |
| font-size: 11px; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| } | |
| .type-click { background: #dbeafe; color: #1e40af; } | |
| .type-input { background: #d1fae5; color: #065f46; } | |
| .type-change { background: #fef3c7; color: #92400e; } | |
| .type-submit { background: #fce7f3; color: #9f1239; } | |
| .type-navigation { background: #e9d5ff; color: #6b21a8; } | |
| .type-keydown { background: #fecaca; color: #991b1b; } | |
| .selector-list { | |
| max-width: 500px; | |
| } | |
| .selector-item { | |
| margin-bottom: 8px; | |
| } | |
| .selector-item label { | |
| display: flex; | |
| align-items: flex-start; | |
| gap: 8px; | |
| padding: 8px; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| font-size: 12px; | |
| line-height: 1.5; | |
| } | |
| .selector-item label:hover { | |
| background: #f7fafc; | |
| } | |
| .selector-item input[type="radio"] { | |
| margin-top: 2px; | |
| cursor: pointer; | |
| flex-shrink: 0; | |
| } | |
| .selector-item input[type="radio"]:disabled { | |
| cursor: not-allowed; | |
| opacity: 0.5; | |
| } | |
| .selector-content { | |
| flex: 1; | |
| min-width: 0; | |
| } | |
| .selector-header { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| margin-bottom: 4px; | |
| } | |
| .selector-type { | |
| display: inline-block; | |
| padding: 2px 6px; | |
| background: #edf2f7; | |
| border-radius: 4px; | |
| font-size: 10px; | |
| font-weight: 600; | |
| color: #4a5568; | |
| } | |
| .selector-priority { | |
| display: inline-block; | |
| padding: 2px 6px; | |
| background: #d1fae5; | |
| border-radius: 4px; | |
| font-size: 10px; | |
| font-weight: 600; | |
| color: #065f46; | |
| } | |
| .selector-priority.high { | |
| background: #d1fae5; | |
| color: #065f46; | |
| } | |
| .selector-priority.medium { | |
| background: #fef3c7; | |
| color: #92400e; | |
| } | |
| .selector-priority.low { | |
| background: #fee2e2; | |
| color: #991b1b; | |
| } | |
| .selector-value { | |
| color: #2d3748; | |
| word-break: break-all; | |
| font-family: 'Monaco', 'Courier New', monospace; | |
| font-size: 11px; | |
| line-height: 1.6; | |
| } | |
| .selector-expand-btn { | |
| background: #e2e8f0; | |
| border: none; | |
| padding: 6px 12px; | |
| border-radius: 6px; | |
| font-size: 11px; | |
| color: #4a5568; | |
| cursor: pointer; | |
| margin-top: 8px; | |
| transition: all 0.2s; | |
| font-weight: 600; | |
| } | |
| .selector-expand-btn:hover { | |
| background: #cbd5e0; | |
| } | |
| .label-text { | |
| color: #10b981; | |
| font-weight: 600; | |
| font-size: 12px; | |
| margin-bottom: 4px; | |
| } | |
| .context-info { | |
| font-size: 12px; | |
| color: #718096; | |
| margin-top: 4px; | |
| } | |
| .element-type-indicator { | |
| display: inline-block; | |
| padding: 2px 8px; | |
| border-radius: 4px; | |
| font-size: 11px; | |
| font-weight: 600; | |
| margin-right: 6px; | |
| text-transform: uppercase; | |
| } | |
| .element-select { | |
| background: #fef3c7; | |
| color: #92400e; | |
| } | |
| .element-input { | |
| background: #dbeafe; | |
| color: #1e40af; | |
| } | |
| .element-textarea { | |
| background: #e0e7ff; | |
| color: #3730a3; | |
| } | |
| .element-button { | |
| background: #fce7f3; | |
| color: #9f1239; | |
| } | |
| .element-other { | |
| background: #f3f4f6; | |
| color: #4b5563; | |
| } | |
| .value-display { | |
| background: #f7fafc; | |
| padding: 6px 10px; | |
| border-radius: 6px; | |
| border-left: 3px solid #667eea; | |
| margin-top: 6px; | |
| font-family: 'Monaco', 'Courier New', monospace; | |
| font-size: 12px; | |
| color: #2d3748; | |
| } | |
| .value-label { | |
| font-size: 10px; | |
| color: #718096; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| margin-bottom: 2px; | |
| } | |
| .value-text { | |
| color: #667eea; | |
| font-weight: 600; | |
| } | |
| .navigation-info { | |
| font-size: 13px; | |
| color: #4a5568; | |
| } | |
| .navigation-path { | |
| font-family: 'Monaco', 'Courier New', monospace; | |
| color: #667eea; | |
| font-weight: 600; | |
| } | |
| .empty-state { | |
| text-align: center; | |
| padding: 60px 20px; | |
| color: #718096; | |
| } | |
| .empty-state-icon { | |
| font-size: 64px; | |
| margin-bottom: 20px; | |
| } | |
| .empty-state h3 { | |
| font-size: 20px; | |
| color: #2d3748; | |
| margin-bottom: 10px; | |
| } | |
| .modal-overlay { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| background: rgba(0, 0, 0, 0.7); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 1000; | |
| padding: 20px; | |
| } | |
| .modal { | |
| background: white; | |
| border-radius: 16px; | |
| max-width: 800px; | |
| width: 100%; | |
| max-height: 90vh; | |
| overflow-y: auto; | |
| box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); | |
| } | |
| .modal-header { | |
| padding: 24px; | |
| border-bottom: 1px solid #e2e8f0; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| } | |
| .modal-header h2 { | |
| font-size: 24px; | |
| color: #1a202c; | |
| } | |
| .modal-close { | |
| background: none; | |
| border: none; | |
| font-size: 28px; | |
| color: #a0aec0; | |
| cursor: pointer; | |
| padding: 0; | |
| width: 32px; | |
| height: 32px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| border-radius: 6px; | |
| transition: all 0.2s; | |
| } | |
| .modal-close:hover { | |
| background: #f7fafc; | |
| color: #4a5568; | |
| } | |
| .modal-body { | |
| padding: 24px; | |
| } | |
| .json-preview { | |
| background: #1a202c; | |
| color: #e2e8f0; | |
| padding: 20px; | |
| border-radius: 8px; | |
| font-family: 'Monaco', 'Courier New', monospace; | |
| font-size: 13px; | |
| overflow-x: auto; | |
| white-space: pre-wrap; | |
| word-break: break-all; | |
| max-height: 500px; | |
| overflow-y: auto; | |
| } | |
| .modal-footer { | |
| padding: 20px 24px; | |
| border-top: 1px solid #e2e8f0; | |
| display: flex; | |
| gap: 10px; | |
| justify-content: flex-end; | |
| } | |
| .toast { | |
| position: fixed; | |
| top: 20px; | |
| right: 20px; | |
| background: white; | |
| padding: 16px 24px; | |
| border-radius: 8px; | |
| box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| z-index: 2000; | |
| animation: slideIn 0.3s ease-out; | |
| } | |
| @keyframes slideIn { | |
| from { | |
| transform: translateX(400px); | |
| opacity: 0; | |
| } | |
| to { | |
| transform: translateX(0); | |
| opacity: 1; | |
| } | |
| } | |
| .toast-success { border-left: 4px solid #10b981; } | |
| .toast-error { border-left: 4px solid #ef4444; } | |
| .toast-info { border-left: 4px solid #3b82f6; } | |
| .time-cell { | |
| color: #718096; | |
| font-size: 12px; | |
| white-space: nowrap; | |
| } | |
| .url-cell { | |
| max-width: 300px; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| color: #4a5568; | |
| font-size: 12px; | |
| } | |
| .highlight { | |
| background: #fef3c7; | |
| padding: 2px 4px; | |
| border-radius: 3px; | |
| } | |
| .merged-badge { | |
| display: inline-block; | |
| padding: 2px 8px; | |
| background: #fef3c7; | |
| color: #92400e; | |
| border-radius: 4px; | |
| font-size: 11px; | |
| font-weight: 600; | |
| margin-left: 8px; | |
| } | |
| .options-display { | |
| margin-top: 8px; | |
| padding: 8px; | |
| background: #f7fafc; | |
| border-radius: 6px; | |
| border-left: 3px solid #fbbf24; | |
| } | |
| .options-label { | |
| font-size: 10px; | |
| color: #92400e; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| margin-bottom: 4px; | |
| } | |
| .option-item { | |
| padding: 4px 8px; | |
| margin: 2px 0; | |
| background: white; | |
| border-radius: 4px; | |
| font-size: 11px; | |
| color: #4a5568; | |
| } | |
| .option-selected { | |
| background: #fef3c7; | |
| color: #92400e; | |
| font-weight: 600; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="app"> | |
| <!-- Header --> | |
| <div class="header"> | |
| <h1>đŦ Action Recorder - Step Extractor</h1> | |
| <p>Extract and configure main steps from recorded actions for replay automation</p> | |
| </div> | |
| <!-- Upload Section --> | |
| <div class="upload-section"> | |
| <div class="file-input-wrapper"> | |
| <input | |
| type="file" | |
| @change="handleFileUpload" | |
| accept=".json" | |
| ref="fileInput" | |
| > | |
| <div | |
| class="file-input-label" | |
| :class="{ dragover: isDragging }" | |
| @dragover.prevent="isDragging = true" | |
| @dragleave.prevent="isDragging = false" | |
| @drop.prevent="handleFileDrop" | |
| > | |
| <div> | |
| <div style="font-size: 48px; margin-bottom: 10px;">đ</div> | |
| <div style="font-size: 16px; font-weight: 600; color: #2d3748; margin-bottom: 5px;"> | |
| Drop your recording JSON file here | |
| </div> | |
| <div style="font-size: 14px; color: #718096;"> | |
| or click to browse | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div v-if="rawData" class="file-info"> | |
| <div style="font-weight: 600; margin-bottom: 10px;">đ {{ fileName }}</div> | |
| <div style="font-size: 12px; color: #718096;"> | |
| Loaded {{ rawData.recordingCount }} recording(s) from {{ formatDate(rawData.exportedAt) }} | |
| </div> | |
| </div> | |
| <!-- Recording Selector (if multiple recordings) --> | |
| <div v-if="rawData && rawData.recordings.length > 1" class="recording-selector"> | |
| <label class="recording-selector-label"> | |
| đš Select Recording ({{ rawData.recordings.length }} available) | |
| </label> | |
| <div class="recording-tabs"> | |
| <div | |
| v-for="(rec, index) in rawData.recordings" | |
| :key="rec.sessionId" | |
| class="recording-tab" | |
| :class="{ active: selectedRecordingIndex === index }" | |
| @click="selectRecording(index)" | |
| > | |
| <div class="recording-tab-title"> | |
| Recording #{{ index + 1 }} | |
| </div> | |
| <div class="recording-tab-info"> | |
| {{ rec.title || rec.url }} <br> | |
| {{ rec.actionCount }} actions | {{ formatDuration(rec.duration) }} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Stats --> | |
| <div v-if="currentRecording" class="stats-grid"> | |
| <div class="stat-card"> | |
| <div class="stat-value">{{ currentRecording.actionCount }}</div> | |
| <div class="stat-label">Total Actions</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-value">{{ enabledSteps.length }}</div> | |
| <div class="stat-label">Main Steps</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-value">{{ navigationCount }}</div> | |
| <div class="stat-label">Navigations</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-value">{{ formatDuration(currentRecording.duration) }}</div> | |
| <div class="stat-label">Duration</div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Controls --> | |
| <div v-if="steps.length > 0" class="controls"> | |
| <div class="search-box"> | |
| <input | |
| type="text" | |
| v-model="searchQuery" | |
| placeholder="đ Search by type, label, selector, or URL..." | |
| > | |
| </div> | |
| <div class="filter-group"> | |
| <label> | |
| <input type="checkbox" v-model="showAllSteps"> | |
| Show all steps | |
| </label> | |
| <label> | |
| <input type="checkbox" v-model="mergeInputSteps"> | |
| Merge consecutive inputs | |
| </label> | |
| </div> | |
| <button class="btn btn-secondary" @click="toggleAllSteps"> | |
| {{ allEnabled ? 'â Disable All' : 'â Enable All' }} | |
| </button> | |
| <button class="btn btn-primary" @click="showPreview" :disabled="enabledSteps.length === 0"> | |
| đī¸ Preview JSON | |
| </button> | |
| <button class="btn btn-success" @click="exportMainSteps" :disabled="enabledSteps.length === 0"> | |
| đž Export Main Steps | |
| </button> | |
| </div> | |
| <!-- Table --> | |
| <div v-if="displaySteps.length > 0" class="table-container"> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th class="checkbox-cell"> | |
| <input | |
| type="checkbox" | |
| :checked="allDisplayedEnabled" | |
| @change="toggleAllDisplayed" | |
| title="Toggle all visible steps" | |
| > | |
| </th> | |
| <th class="index-cell">#</th> | |
| <th>Type</th> | |
| <th>Time</th> | |
| <th>Element / Target</th> | |
| <th>Selectors</th> | |
| <th>URL</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| <tr | |
| v-for="(step, index) in displaySteps" | |
| :key="step.id" | |
| :class="{ 'disabled-row': !step.enabled, 'main-step': step.enabled }" | |
| > | |
| <td class="checkbox-cell"> | |
| <input | |
| type="checkbox" | |
| v-model="step.enabled" | |
| @change="updateMainSteps" | |
| :disabled="step.isMerged" | |
| > | |
| </td> | |
| <td class="index-cell">{{ step.originalIndex + 1 }}</td> | |
| <td> | |
| <span class="type-badge" :class="`type-${step.type}`"> | |
| {{ step.type }} | |
| </span> | |
| <span v-if="step.mergedCount" class="merged-badge"> | |
| Merged {{ step.mergedCount }} steps | |
| </span> | |
| </td> | |
| <td class="time-cell"> | |
| {{ formatTime(step.relativeTime) }} | |
| </td> | |
| <td> | |
| <!-- Navigation --> | |
| <div v-if="step.type === 'navigation'" class="navigation-info"> | |
| <div class="navigation-path"> | |
| {{ step.navigation.fromPath || 'START' }} â {{ step.navigation.toPath }} | |
| </div> | |
| <div class="context-info" style="margin-top: 6px;"> | |
| Method: {{ step.navigation.method }} | |
| </div> | |
| </div> | |
| <!-- Regular Elements --> | |
| <div v-else> | |
| <!-- Element Type Indicator --> | |
| <span | |
| class="element-type-indicator" | |
| :class="getElementTypeClass(step.context.tagName, step.context.type)" | |
| > | |
| {{ getElementTypeLabel(step.context.tagName, step.context.type) }} | |
| </span> | |
| <!-- Associated Label --> | |
| <div v-if="step.context.associatedLabel" class="label-text"> | |
| đ {{ step.context.associatedLabel.text }} | |
| </div> | |
| <!-- Context Info --> | |
| <div class="context-info"> | |
| Tag: {{ step.context.tagName }} | |
| <span v-if="step.context.type"> | Type: {{ step.context.type }}</span> | |
| <span v-if="step.context.textContent"> | Text: "{{ truncate(step.context.textContent, 40) }}"</span> | |
| </div> | |
| <!-- Value Display (for input/change) --> | |
| <div v-if="(step.type === 'input' || step.type === 'change') && (step.finalValue || step.value || step.context.value)" class="value-display"> | |
| <div class="value-label">{{ step.type === 'change' ? 'Selected Value' : 'Input Value' }}</div> | |
| <div class="value-text"> | |
| "{{ truncate(step.finalValue || step.value || step.context.value, 60) }}" | |
| </div> | |
| </div> | |
| <!-- Select Options Display --> | |
| <div v-if="step.context.tagName === 'SELECT' && step.context.options && step.context.options.length > 0" class="options-display"> | |
| <div class="options-label">Available Options ({{ step.context.options.length }})</div> | |
| <div | |
| v-for="(option, idx) in step.context.options.slice(0, 5)" | |
| :key="idx" | |
| class="option-item" | |
| :class="{ 'option-selected': option.selected || option.value === (step.finalValue || step.value || step.context.value) }" | |
| > | |
| {{ option.text || option.value || '(empty)' }} | |
| <span v-if="option.value" style="color: #9ca3af; font-size: 10px;"> ({{ option.value }})</span> | |
| </div> | |
| <div v-if="step.context.options.length > 5" style="margin-top: 4px; font-size: 10px; color: #9ca3af;"> | |
| + {{ step.context.options.length - 5 }} more options | |
| </div> | |
| </div> | |
| </div> | |
| </td> | |
| <td> | |
| <div v-if="step.type !== 'navigation'" class="selector-list"> | |
| <div | |
| v-for="(selector, sIdx) in (step.showAllSelectors ? step.sortedSelectors : step.sortedSelectors.slice(0, 5))" | |
| :key="sIdx" | |
| class="selector-item" | |
| > | |
| <label> | |
| <input | |
| type="radio" | |
| :name="`selector-${step.id}`" | |
| :value="selector.originalIndex" | |
| v-model="step.selectedSelectorIndex" | |
| @change="updateMainSteps" | |
| :disabled="!step.enabled" | |
| > | |
| <div class="selector-content"> | |
| <div class="selector-header"> | |
| <span class="selector-type">{{ selector.type }}</span> | |
| <span class="selector-priority" :class="getPriorityClass(selector.priority)"> | |
| Priority: {{ selector.priority }} | |
| </span> | |
| </div> | |
| <div class="selector-value">{{ selector.value }}</div> | |
| </div> | |
| </label> | |
| </div> | |
| <button | |
| v-if="step.sortedSelectors && step.sortedSelectors.length > 5" | |
| class="selector-expand-btn" | |
| @click="step.showAllSelectors = !step.showAllSelectors" | |
| > | |
| {{ step.showAllSelectors ? 'ⲠShow less' : `âŧ Show ${step.sortedSelectors.length - 5} more selectors` }} | |
| </button> | |
| </div> | |
| <div v-else style="color: #a0aec0; font-size: 12px;"> | |
| N/A (Navigation) | |
| </div> | |
| </td> | |
| <td> | |
| <div class="url-cell" :title="step.url"> | |
| {{ step.pathname || step.url }} | |
| </div> | |
| </td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| <!-- Empty State --> | |
| <div v-else-if="!rawData" class="table-container"> | |
| <div class="empty-state"> | |
| <div class="empty-state-icon">đ</div> | |
| <h3>No recording loaded</h3> | |
| <p>Upload a JSON file exported from the Action Recorder</p> | |
| </div> | |
| </div> | |
| <div v-else-if="steps.length === 0 && rawData" class="table-container"> | |
| <div class="empty-state"> | |
| <div class="empty-state-icon">â ī¸</div> | |
| <h3>No actions found</h3> | |
| <p>The loaded recording contains no actions</p> | |
| </div> | |
| </div> | |
| <div v-else class="table-container"> | |
| <div class="empty-state"> | |
| <div class="empty-state-icon">đ</div> | |
| <h3>No results found</h3> | |
| <p>Try adjusting your search or filters</p> | |
| </div> | |
| </div> | |
| <!-- Preview Modal --> | |
| <div v-if="showModal" class="modal-overlay" @click.self="showModal = false"> | |
| <div class="modal"> | |
| <div class="modal-header"> | |
| <h2>đ Main Steps JSON Preview</h2> | |
| <button class="modal-close" @click="showModal = false">×</button> | |
| </div> | |
| <div class="modal-body"> | |
| <div class="json-preview">{{ previewJson }}</div> | |
| </div> | |
| <div class="modal-footer"> | |
| <button class="btn btn-secondary" @click="showModal = false">Close</button> | |
| <button class="btn btn-success" @click="copyToClipboard"> | |
| đ Copy to Clipboard | |
| </button> | |
| <button class="btn btn-primary" @click="exportMainSteps"> | |
| đž Download JSON | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Toast Notifications --> | |
| <transition name="toast"> | |
| <div v-if="toast.show" :class="['toast', `toast-${toast.type}`]"> | |
| <div style="font-size: 20px;">{{ toast.icon }}</div> | |
| <div>{{ toast.message }}</div> | |
| </div> | |
| </transition> | |
| </div> | |
| <script> | |
| const { createApp } = Vue; | |
| createApp({ | |
| data() { | |
| return { | |
| rawData: null, | |
| fileName: '', | |
| selectedRecordingIndex: 0, | |
| currentRecording: null, | |
| steps: [], | |
| searchQuery: '', | |
| showAllSteps: false, | |
| mergeInputSteps: true, | |
| isDragging: false, | |
| showModal: false, | |
| previewJson: '', | |
| toast: { | |
| show: false, | |
| type: 'info', | |
| message: '', | |
| icon: 'âšī¸' | |
| } | |
| }; | |
| }, | |
| computed: { | |
| enabledSteps() { | |
| return this.processedSteps.filter(s => s.enabled && !s.isMerged); | |
| }, | |
| navigationCount() { | |
| return this.steps.filter(s => s.type === 'navigation').length; | |
| }, | |
| allEnabled() { | |
| const enableableSteps = this.processedSteps.filter(s => !s.isMerged); | |
| return enableableSteps.length > 0 && enableableSteps.every(s => s.enabled); | |
| }, | |
| allDisplayedEnabled() { | |
| const enableableSteps = this.displaySteps.filter(s => !s.isMerged); | |
| return enableableSteps.length > 0 && enableableSteps.every(s => s.enabled); | |
| }, | |
| processedSteps() { | |
| if (!this.mergeInputSteps) { | |
| return this.steps; | |
| } | |
| const merged = []; | |
| let i = 0; | |
| while (i < this.steps.length) { | |
| const current = this.steps[i]; | |
| if (current.type === 'input' || current.type === 'change') { | |
| const group = [current]; | |
| let j = i + 1; | |
| while (j < this.steps.length) { | |
| const next = this.steps[j]; | |
| if ((next.type === 'input' || next.type === 'change') && | |
| this.isSameElement(current, next)) { | |
| group.push(next); | |
| j++; | |
| } else { | |
| break; | |
| } | |
| } | |
| if (group.length > 1) { | |
| const lastStep = group[group.length - 1]; | |
| const mergedStep = { | |
| ...lastStep, | |
| mergedCount: group.length, | |
| finalValue: lastStep.value || lastStep.context.value, | |
| originalSteps: group | |
| }; | |
| merged.push(mergedStep); | |
| for (let k = 0; k < group.length - 1; k++) { | |
| merged.push({ | |
| ...group[k], | |
| isMerged: true, | |
| enabled: false | |
| }); | |
| } | |
| i = j; | |
| } else { | |
| merged.push(current); | |
| i++; | |
| } | |
| } else { | |
| merged.push(current); | |
| i++; | |
| } | |
| } | |
| return merged; | |
| }, | |
| displaySteps() { | |
| let displayed = this.processedSteps; | |
| // Apply search filter first | |
| if (this.searchQuery.trim()) { | |
| const query = this.searchQuery.toLowerCase(); | |
| displayed = displayed.filter(step => { | |
| if (step.type.toLowerCase().includes(query)) return true; | |
| if (step.context?.associatedLabel?.text?.toLowerCase().includes(query)) return true; | |
| if (step.selectors?.some(s => s.value.toLowerCase().includes(query))) return true; | |
| if (step.url?.toLowerCase().includes(query)) return true; | |
| if (step.navigation?.toPath?.toLowerCase().includes(query)) return true; | |
| return false; | |
| }); | |
| } | |
| // Show all steps filter | |
| if (!this.showAllSteps) { | |
| displayed = displayed.filter(s => s.enabled); | |
| } | |
| return displayed; | |
| } | |
| }, | |
| methods: { | |
| getElementTypeLabel(tagName, type) { | |
| if (tagName === 'SELECT') return 'đŊ Select'; | |
| if (tagName === 'TEXTAREA') return 'đ Textarea'; | |
| if (tagName === 'INPUT') { | |
| if (type === 'text') return 'âī¸ Text Input'; | |
| if (type === 'email') return 'đ§ Email Input'; | |
| if (type === 'password') return 'đ Password'; | |
| if (type === 'number') return 'đĸ Number'; | |
| if (type === 'checkbox') return 'âī¸ Checkbox'; | |
| if (type === 'radio') return 'âĒ Radio'; | |
| return `đĨ ${type}`; | |
| } | |
| if (tagName === 'BUTTON') return 'đ Button'; | |
| if (tagName === 'A') return 'đ Link'; | |
| return tagName.toLowerCase(); | |
| }, | |
| getElementTypeClass(tagName, type) { | |
| if (tagName === 'SELECT') return 'element-select'; | |
| if (tagName === 'INPUT') return 'element-input'; | |
| if (tagName === 'TEXTAREA') return 'element-textarea'; | |
| if (tagName === 'BUTTON') return 'element-button'; | |
| return 'element-other'; | |
| }, | |
| selectRecording(index) { | |
| this.selectedRecordingIndex = index; | |
| this.processRecording(this.rawData); | |
| this.showToast('info', `Switched to Recording #${index + 1}`, 'đš'); | |
| }, | |
| isSameElement(step1, step2) { | |
| const selector1 = step1.selectors?.[0]?.value; | |
| const selector2 = step2.selectors?.[0]?.value; | |
| if (selector1 && selector2 && selector1 === selector2) { | |
| return true; | |
| } | |
| const label1 = step1.context?.associatedLabel?.text; | |
| const label2 = step2.context?.associatedLabel?.text; | |
| if (label1 && label2 && label1 === label2) { | |
| return true; | |
| } | |
| return false; | |
| }, | |
| getPriorityClass(priority) { | |
| if (priority <= 0.5) return 'high'; | |
| if (priority <= 1.5) return 'medium'; | |
| return 'low'; | |
| }, | |
| handleFileUpload(event) { | |
| const file = event.target.files[0]; | |
| if (file) { | |
| this.loadFile(file); | |
| } | |
| }, | |
| handleFileDrop(event) { | |
| this.isDragging = false; | |
| const file = event.dataTransfer.files[0]; | |
| if (file && file.type === 'application/json') { | |
| this.loadFile(file); | |
| } else { | |
| this.showToast('error', 'Please drop a valid JSON file', 'â'); | |
| } | |
| }, | |
| loadFile(file) { | |
| this.fileName = file.name; | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| try { | |
| const data = JSON.parse(e.target.result); | |
| this.rawData = data; | |
| this.selectedRecordingIndex = 0; | |
| this.processRecording(data); | |
| this.showToast('success', 'Recording loaded successfully!', 'â '); | |
| } catch (error) { | |
| console.error('Error parsing JSON:', error); | |
| this.showToast('error', 'Invalid JSON file format', 'â'); | |
| } | |
| }; | |
| reader.readAsText(file); | |
| }, | |
| processRecording(data) { | |
| if (data.recordings && data.recordings.length > 0) { | |
| this.currentRecording = data.recordings[this.selectedRecordingIndex]; | |
| this.steps = this.extractMainSteps(this.currentRecording.actions); | |
| } else { | |
| this.showToast('error', 'No recordings found in file', 'â'); | |
| } | |
| }, | |
| extractMainSteps(actions) { | |
| const mainStepTypes = ['click', 'input', 'change', 'submit', 'navigation']; | |
| return actions.map((action, index) => { | |
| const isMainStep = mainStepTypes.includes(action.type); | |
| let sortedSelectors = null; | |
| if (action.selectors && action.selectors.length > 0) { | |
| sortedSelectors = action.selectors | |
| .map((sel, idx) => ({ ...sel, originalIndex: idx })) | |
| .sort((a, b) => a.priority - b.priority); | |
| } | |
| const selectedSelectorIndex = sortedSelectors ? sortedSelectors[0].originalIndex : null; | |
| return { | |
| ...action, | |
| originalIndex: index, | |
| enabled: isMainStep, | |
| selectedSelectorIndex: selectedSelectorIndex, | |
| sortedSelectors: sortedSelectors, | |
| showAllSelectors: false | |
| }; | |
| }); | |
| }, | |
| updateMainSteps() { | |
| this.steps = [...this.steps]; | |
| }, | |
| toggleAllSteps() { | |
| const newState = !this.allEnabled; | |
| this.processedSteps.forEach(step => { | |
| if (!step.isMerged) { | |
| step.enabled = newState; | |
| } | |
| }); | |
| }, | |
| toggleAllDisplayed() { | |
| const newState = !this.allDisplayedEnabled; | |
| this.displaySteps.forEach(step => { | |
| if (!step.isMerged) { | |
| step.enabled = newState; | |
| } | |
| }); | |
| }, | |
| showPreview() { | |
| const mainSteps = this.generateMainStepsJSON(); | |
| this.previewJson = JSON.stringify(mainSteps, null, 2); | |
| this.showModal = true; | |
| }, | |
| generateMainStepsJSON() { | |
| const enabledProcessedSteps = this.processedSteps.filter(s => s.enabled && !s.isMerged); | |
| const mainSteps = enabledProcessedSteps.map(step => { | |
| const selectedSelector = step.selectors ? step.selectors[step.selectedSelectorIndex] : null; | |
| const mainStep = { | |
| id: step.id, | |
| type: step.type, | |
| timestamp: step.timestamp, | |
| relativeTime: step.relativeTime, | |
| url: step.url, | |
| pathname: step.pathname | |
| }; | |
| if (step.type === 'navigation') { | |
| mainStep.navigation = step.navigation; | |
| } else { | |
| mainStep.selector = selectedSelector; | |
| mainStep.context = { | |
| tagName: step.context.tagName, | |
| type: step.context.type, | |
| value: step.context.value, | |
| textContent: step.context.textContent, | |
| associatedLabel: step.context.associatedLabel | |
| }; | |
| if (step.type === 'input' || step.type === 'change') { | |
| mainStep.value = step.finalValue || step.value || step.context.value; | |
| mainStep.checked = step.checked; | |
| if (step.mergedCount) { | |
| mainStep.mergedCount = step.mergedCount; | |
| } | |
| } | |
| if (step.type === 'click') { | |
| mainStep.button = step.button; | |
| mainStep.ctrlKey = step.ctrlKey; | |
| mainStep.shiftKey = step.shiftKey; | |
| } | |
| if (step.type === 'keydown') { | |
| mainStep.key = step.key; | |
| mainStep.code = step.code; | |
| } | |
| } | |
| return mainStep; | |
| }); | |
| return { | |
| version: '2.1.0', | |
| extractedAt: new Date().toISOString(), | |
| sourceRecording: { | |
| sessionId: this.currentRecording.sessionId, | |
| url: this.currentRecording.url, | |
| title: this.currentRecording.title, | |
| duration: this.currentRecording.duration | |
| }, | |
| stepCount: mainSteps.length, | |
| steps: mainSteps | |
| }; | |
| }, | |
| exportMainSteps() { | |
| const mainSteps = this.generateMainStepsJSON(); | |
| const dataStr = JSON.stringify(mainSteps, null, 2); | |
| const dataBlob = new Blob([dataStr], { type: 'application/json' }); | |
| const url = URL.createObjectURL(dataBlob); | |
| const link = document.createElement('a'); | |
| link.href = url; | |
| link.download = `main-steps_${Date.now()}.json`; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| URL.revokeObjectURL(url); | |
| this.showModal = false; | |
| this.showToast('success', 'Main steps exported successfully!', 'đž'); | |
| }, | |
| async copyToClipboard() { | |
| try { | |
| await navigator.clipboard.writeText(this.previewJson); | |
| this.showToast('success', 'Copied to clipboard!', 'đ'); | |
| } catch (error) { | |
| this.showToast('error', 'Failed to copy to clipboard', 'â'); | |
| } | |
| }, | |
| showToast(type, message, icon) { | |
| this.toast = { show: true, type, message, icon }; | |
| setTimeout(() => { | |
| this.toast.show = false; | |
| }, 3000); | |
| }, | |
| formatDate(dateStr) { | |
| return new Date(dateStr).toLocaleString(); | |
| }, | |
| formatTime(ms) { | |
| const seconds = Math.floor(ms / 1000); | |
| const minutes = Math.floor(seconds / 60); | |
| const hours = Math.floor(minutes / 60); | |
| if (hours > 0) { | |
| return `${hours}h ${minutes % 60}m ${seconds % 60}s`; | |
| } else if (minutes > 0) { | |
| return `${minutes}m ${seconds % 60}s`; | |
| } else { | |
| return `${seconds}s`; | |
| } | |
| }, | |
| formatDuration(ms) { | |
| const seconds = Math.floor(ms / 1000); | |
| const minutes = Math.floor(seconds / 60); | |
| if (minutes > 0) { | |
| return `${minutes}m ${seconds % 60}s`; | |
| } else { | |
| return `${seconds}s`; | |
| } | |
| }, | |
| truncate(str, length) { | |
| if (!str) return ''; | |
| return str.length > length ? str.substring(0, length) + '...' : str; | |
| } | |
| } | |
| }).mount('#app'); | |
| </script> | |
| </body> | |
| </html> | 
  
    Sign up for free
    to join this conversation on GitHub.
    Already have an account?
    Sign in to comment