Skip to content

Instantly share code, notes, and snippets.

@tranphuquy19
Created October 20, 2025 07:30
Show Gist options
  • Save tranphuquy19/bc38283c592ce98ef24e227dc611cd42 to your computer and use it in GitHub Desktop.
Save tranphuquy19/bc38283c592ce98ef24e227dc611cd42 to your computer and use it in GitHub Desktop.
Action Recorder
// ==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();
})();
<!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">&times;</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