From 4345fc29fe0e1b6623382dc69d7b97bc2e6f4b3a Mon Sep 17 00:00:00 2001 From: Rohit Rajan Date: Sun, 19 Oct 2025 22:48:28 +0530 Subject: [PATCH 01/28] feat: recorder revamp ui changes --- src/components/browser/BrowserWindow.tsx | 103 +- src/components/recorder/RightSidePanel.tsx | 582 ++-------- src/components/run/InterpretationLog.tsx | 1114 ++++++++++++++++---- src/components/run/RunContent.tsx | 765 +++++++++----- src/context/browserSteps.tsx | 461 ++++++-- src/context/globalInfo.tsx | 6 + src/helpers/capturedElementHighlighter.ts | 85 ++ 7 files changed, 2044 insertions(+), 1072 deletions(-) create mode 100644 src/helpers/capturedElementHighlighter.ts diff --git a/src/components/browser/BrowserWindow.tsx b/src/components/browser/BrowserWindow.tsx index 5bb84101..e18a1b89 100644 --- a/src/components/browser/BrowserWindow.tsx +++ b/src/components/browser/BrowserWindow.tsx @@ -5,13 +5,14 @@ import Canvas from "../recorder/Canvas"; import { Highlighter } from "../recorder/Highlighter"; import { GenericModal } from '../ui/GenericModal'; import { useActionContext } from '../../context/browserActions'; -import { useBrowserSteps, TextStep } from '../../context/browserSteps'; +import { useBrowserSteps, TextStep, ListStep } from '../../context/browserSteps'; import { useGlobalInfoStore } from '../../context/globalInfo'; import { useTranslation } from 'react-i18next'; import { AuthContext } from '../../context/auth'; import { coordinateMapper } from '../../helpers/coordinateMapper'; import { useBrowserDimensionsStore } from '../../context/browserDimensions'; import { clientSelectorGenerator, ElementFingerprint } from "../../helpers/clientSelectorGenerator"; +import { capturedElementHighlighter } from "../../helpers/capturedElementHighlighter"; import DatePicker from "../pickers/DatePicker"; import Dropdown from "../pickers/Dropdown"; import TimePicker from "../pickers/TimePicker"; @@ -182,10 +183,13 @@ export const BrowserWindow = () => { count?: number; } | null>(null); + const [initialAutoFieldIds, setInitialAutoFieldIds] = useState>(new Set()); + const [manuallyAddedFieldIds, setManuallyAddedFieldIds] = useState>(new Set()); + const { socket } = useSocketStore(); const { notify, currentTextActionId, currentListActionId, updateDOMMode, isDOMMode, currentSnapshot } = useGlobalInfoStore(); const { getText, getList, paginationMode, paginationType, limitMode, captureStage } = useActionContext(); - const { addTextStep, addListStep } = useBrowserSteps(); + const { addTextStep, addListStep, browserSteps } = useBrowserSteps(); const [currentGroupInfo, setCurrentGroupInfo] = useState<{ isGroupElement: boolean; @@ -1159,6 +1163,7 @@ export const BrowserWindow = () => { if (Object.keys(autoFields).length > 0) { setFields(autoFields); + setInitialAutoFieldIds(new Set(Object.keys(autoFields).map(id => parseInt(id)))); addListStep( listSelector, @@ -1195,6 +1200,11 @@ export const BrowserWindow = () => { cachedListSelector, pendingNotification, notify, + createFieldsFromChildSelectors, + currentListId, + currentListActionId, + paginationSelector, + addListStep ]); useEffect(() => { @@ -1203,6 +1213,77 @@ export const BrowserWindow = () => { } }, [listSelector]); + useEffect(() => { + if (!getList || !listSelector || initialAutoFieldIds.size === 0 || !currentListActionId) return; + + const currentListStep = browserSteps.find( + step => step.type === 'list' && step.actionId === currentListActionId + ); + + if (!currentListStep || currentListStep.type !== 'list' || !currentListStep.fields) return; + + const currentFieldIds = new Set(Object.keys(currentListStep.fields).map(id => parseInt(id))); + const newManualIds = new Set(); + + currentFieldIds.forEach(fieldId => { + if (!initialAutoFieldIds.has(fieldId)) { + newManualIds.add(fieldId); + } + }); + + if (newManualIds.size !== manuallyAddedFieldIds.size || + ![...newManualIds].every(id => manuallyAddedFieldIds.has(id))) { + setManuallyAddedFieldIds(newManualIds); + } + }, [browserSteps, getList, listSelector, initialAutoFieldIds, currentListActionId, manuallyAddedFieldIds]); + + useEffect(() => { + if (!isDOMMode) { + capturedElementHighlighter.clearHighlights(); + return; + } + + const capturedSelectors: Array<{ selector: string }> = []; + + if (getText && currentTextActionId) { + const textSteps = browserSteps.filter( + (step): step is TextStep => step.type === 'text' && step.actionId === currentTextActionId + ); + + textSteps.forEach(step => { + if (step.selectorObj?.selector) { + capturedSelectors.push({ + selector: step.selectorObj.selector, + }); + } + }); + } + + if (getList && listSelector && currentListActionId && manuallyAddedFieldIds.size > 0) { + const listSteps = browserSteps.filter( + step => step.type === 'list' && step.actionId === currentListActionId + ) as ListStep[]; + + listSteps.forEach(listStep => { + if (listStep.fields) { + Object.entries(listStep.fields).forEach(([fieldId, field]: [string, any]) => { + if (manuallyAddedFieldIds.has(parseInt(fieldId)) && field.selectorObj?.selector) { + capturedSelectors.push({ + selector: field.selectorObj.selector, + }); + } + }); + } + }); + } + + if (capturedSelectors.length > 0) { + capturedElementHighlighter.applyHighlights(capturedSelectors); + } else { + capturedElementHighlighter.clearHighlights(); + } + }, [browserSteps, getText, getList, listSelector, currentTextActionId, currentListActionId, isDOMMode, manuallyAddedFieldIds]); + useEffect(() => { coordinateMapper.updateDimensions(dimensions.width, dimensions.height, viewportInfo.width, viewportInfo.height); }, [viewportInfo, dimensions.width, dimensions.height]); @@ -1216,7 +1297,6 @@ export const BrowserWindow = () => { useEffect(() => { const storedListSelector = sessionStorage.getItem('recordingListSelector'); - // Only restore state if it exists in sessionStorage if (storedListSelector && !listSelector) { setListSelector(storedListSelector); } @@ -1225,7 +1305,6 @@ export const BrowserWindow = () => { const onMouseMove = (e: MouseEvent) => { if (canvasRef && canvasRef.current && highlighterData) { const canvasRect = canvasRef.current.getBoundingClientRect(); - // mousemove outside the browser window if ( e.pageX < canvasRect.left || e.pageX > canvasRect.right @@ -1242,6 +1321,8 @@ export const BrowserWindow = () => { setFields({}); setCurrentListId(null); setCachedChildSelectors([]); + setInitialAutoFieldIds(new Set()); + setManuallyAddedFieldIds(new Set()); }, []); useEffect(() => { @@ -1262,7 +1343,7 @@ export const BrowserWindow = () => { } } } - }, [screenShot, user?.id]); + }, [user?.id]); useEffect(() => { if (socket) { @@ -1456,7 +1537,6 @@ export const BrowserWindow = () => { } highlighterUpdateRef.current = now; - // Map the incoming DOMRect from browser coordinates to canvas coordinates const mappedRect = new DOMRect( data.rect.x, data.rect.y, @@ -1477,17 +1557,14 @@ export const BrowserWindow = () => { if (limitMode) { setHighlighterData(null); } else if (paginationMode) { - // Only set highlighterData if type is not empty, 'none', 'scrollDown', or 'scrollUp' if (paginationType !== '' && !['none', 'scrollDown', 'scrollUp'].includes(paginationType)) { setHighlighterData(mappedData); } else { setHighlighterData(null); } } else if (mappedData.childSelectors && mappedData.childSelectors.includes(mappedData.selector)) { - // Highlight only valid child elements within the listSelector setHighlighterData(mappedData); } else if (mappedData.elementInfo?.isIframeContent && mappedData.childSelectors) { - // Handle iframe elements const isIframeChild = mappedData.childSelectors.some(childSelector => mappedData.selector.includes(':>>') && childSelector.split(':>>').some(part => @@ -1496,7 +1573,6 @@ export const BrowserWindow = () => { ); setHighlighterData(isIframeChild ? mappedData : null); } else if (mappedData.selector.includes(':>>') && hasValidChildSelectors) { - // Handle mixed DOM cases with iframes const selectorParts = mappedData.selector.split(':>>').map(part => part.trim()); const isValidMixedSelector = selectorParts.some(part => mappedData.childSelectors!.some(childSelector => @@ -1505,7 +1581,6 @@ export const BrowserWindow = () => { ); setHighlighterData(isValidMixedSelector ? mappedData : null); } else if (mappedData.elementInfo?.isShadowRoot && mappedData.childSelectors) { - // Handle Shadow DOM elements const isShadowChild = mappedData.childSelectors.some(childSelector => mappedData.selector.includes('>>') && childSelector.split('>>').some(part => @@ -1514,7 +1589,6 @@ export const BrowserWindow = () => { ); setHighlighterData(isShadowChild ? mappedData : null); } else if (mappedData.selector.includes('>>') && hasValidChildSelectors) { - // Handle mixed DOM cases const selectorParts = mappedData.selector.split('>>').map(part => part.trim()); const isValidMixedSelector = selectorParts.some(part => mappedData.childSelectors!.some(childSelector => @@ -1523,15 +1597,12 @@ export const BrowserWindow = () => { ); setHighlighterData(isValidMixedSelector ? mappedData : null); } else { - // If not a valid child in normal mode, clear the highlighter setHighlighterData(null); } } else { - // Set highlighterData for the initial listSelector selection setHighlighterData(mappedData); } } else { - // For non-list steps setHighlighterData(mappedData); } }, [getList, socket, listSelector, paginationMode, paginationType, limitMode]); @@ -2114,7 +2185,7 @@ export const BrowserWindow = () => { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } - `} + `}j {(getText === true || getList === true) && !showAttributeModal && diff --git a/src/components/recorder/RightSidePanel.tsx b/src/components/recorder/RightSidePanel.tsx index bf0a10c4..e22ab08b 100644 --- a/src/components/recorder/RightSidePanel.tsx +++ b/src/components/recorder/RightSidePanel.tsx @@ -1,19 +1,13 @@ import React, { useState, useCallback, useEffect, useMemo } from 'react'; import { Button, Paper, Box, TextField, IconButton, Tooltip } from "@mui/material"; -import EditIcon from '@mui/icons-material/Edit'; -import TextFieldsIcon from '@mui/icons-material/TextFields'; -import DocumentScannerIcon from '@mui/icons-material/DocumentScanner'; import { WorkflowFile } from "maxun-core"; import Typography from "@mui/material/Typography"; import { useGlobalInfoStore } from "../../context/globalInfo"; import { PaginationType, useActionContext, LimitType } from '../../context/browserActions'; import { BrowserStep, useBrowserSteps } from '../../context/browserSteps'; import { useSocketStore } from '../../context/socket'; -import { ScreenshotSettings } from '../../shared/types'; -import InputAdornment from '@mui/material/InputAdornment'; import FormControlLabel from '@mui/material/FormControlLabel'; import FormControl from '@mui/material/FormControl'; -import FormLabel from '@mui/material/FormLabel'; import Radio from '@mui/material/Radio'; import RadioGroup from '@mui/material/RadioGroup'; import { getActiveWorkflow } from "../../api/workflow"; @@ -49,13 +43,9 @@ export const RightSidePanel: React.FC = ({ onFinishCapture const [showCaptureList, setShowCaptureList] = useState(true); const [showCaptureScreenshot, setShowCaptureScreenshot] = useState(true); const [showCaptureText, setShowCaptureText] = useState(true); - const [hoverStates, setHoverStates] = useState<{ [id: string]: boolean }>({}); - const [browserStepIdList, setBrowserStepIdList] = useState([]); - const [isCaptureTextConfirmed, setIsCaptureTextConfirmed] = useState(false); - const [isCaptureListConfirmed, setIsCaptureListConfirmed] = useState(false); const { panelHeight } = useBrowserDimensionsStore(); - const { lastAction, notify, currentWorkflowActionsState, setCurrentWorkflowActionsState, resetInterpretationLog, currentListActionId, setCurrentListActionId, currentTextActionId, setCurrentTextActionId, currentScreenshotActionId, setCurrentScreenshotActionId, isDOMMode, setIsDOMMode, currentSnapshot, setCurrentSnapshot, updateDOMMode, initialUrl, setRecordingUrl } = useGlobalInfoStore(); + const { lastAction, notify, currentWorkflowActionsState, setCurrentWorkflowActionsState, resetInterpretationLog, currentListActionId, setCurrentListActionId, currentTextActionId, setCurrentTextActionId, currentScreenshotActionId, setCurrentScreenshotActionId, isDOMMode, setIsDOMMode, currentSnapshot, setCurrentSnapshot, updateDOMMode, initialUrl, setRecordingUrl, currentTextGroupName } = useGlobalInfoStore(); const { getText, startGetText, stopGetText, getList, startGetList, stopGetList, @@ -72,7 +62,7 @@ export const RightSidePanel: React.FC = ({ onFinishCapture startAction, finishAction } = useActionContext(); - const { browserSteps, updateBrowserTextStepLabel, deleteBrowserStep, addScreenshotStep, updateListTextFieldLabel, removeListTextField, updateListStepLimit, deleteStepsByActionId, updateListStepData, updateScreenshotStepData } = useBrowserSteps(); + const { browserSteps, updateBrowserTextStepLabel, deleteBrowserStep, addScreenshotStep, updateListTextFieldLabel, removeListTextField, updateListStepLimit, deleteStepsByActionId, updateListStepData, updateScreenshotStepData, emitActionForStep } = useBrowserSteps(); const { id, socket } = useSocketStore(); const { t } = useTranslation(); @@ -183,6 +173,7 @@ export const RightSidePanel: React.FC = ({ onFinishCapture if (screenshotSteps.length > 0) { const latestStep = screenshotSteps[screenshotSteps.length - 1]; updateScreenshotStepData(latestStep.id, data.screenshot); + emitActionForStep(latestStep); } setCurrentScreenshotActionId(''); @@ -194,7 +185,7 @@ export const RightSidePanel: React.FC = ({ onFinishCapture socket.off('directScreenshotCaptured', handleDirectScreenshot); }; } - }, [socket, id, notify, t, currentScreenshotActionId, updateScreenshotStepData, setCurrentScreenshotActionId]); + }, [socket, id, notify, t, currentScreenshotActionId, updateScreenshotStepData, setCurrentScreenshotActionId, emitActionForStep, browserSteps]); const extractDataClientSide = useCallback( ( @@ -271,26 +262,41 @@ export const RightSidePanel: React.FC = ({ onFinishCapture } } }, - [isDOMMode, currentSnapshot, updateListStepData, socket, notify] + [isDOMMode, currentSnapshot, updateListStepData, socket, notify, currentWorkflowActionsState] ); - const handleMouseEnter = (id: number) => { - setHoverStates(prev => ({ ...prev, [id]: true })); - }; + useEffect(() => { + if (!getList) return; - const handleMouseLeave = (id: number) => { - setHoverStates(prev => ({ ...prev, [id]: false })); - }; + const currentListStep = browserSteps.find( + step => step.type === 'list' && step.actionId === currentListActionId + ) as (BrowserStep & { type: 'list'; listSelector?: string; fields?: Record }) | undefined; + + if (!currentListStep || !currentListStep.listSelector || !currentListStep.fields) return; + + const fieldCount = Object.keys(currentListStep.fields).length; + + if (fieldCount > 0) { + extractDataClientSide( + currentListStep.listSelector, + currentListStep.fields, + currentListStep.id + ); + + setCurrentWorkflowActionsState({ + ...currentWorkflowActionsState, + hasScrapeListAction: true + }); + } + }, [browserSteps, currentListActionId, getList, extractDataClientSide, setCurrentWorkflowActionsState, currentWorkflowActionsState]); const handleStartGetText = () => { - setIsCaptureTextConfirmed(false); const newActionId = `text-${crypto.randomUUID()}`; setCurrentTextActionId(newActionId); startGetText(); } const handleStartGetList = () => { - setIsCaptureListConfirmed(false); const newActionId = `list-${crypto.randomUUID()}`; setCurrentListActionId(newActionId); startGetList(); @@ -302,230 +308,24 @@ export const RightSidePanel: React.FC = ({ onFinishCapture startGetScreenshot(); }; - const handleTextLabelChange = (id: number, label: string, listId?: number, fieldKey?: string) => { - if (listId !== undefined && fieldKey !== undefined) { - // Prevent editing if the field is confirmed - if (confirmedListTextFields[listId]?.[fieldKey]) { - return; - } - updateListTextFieldLabel(listId, fieldKey, label); - } else { - setTextLabels(prevLabels => ({ ...prevLabels, [id]: label })); - } - if (!label.trim()) { - setErrors(prevErrors => ({ ...prevErrors, [id]: t('right_panel.errors.label_required') })); - } else { - setErrors(prevErrors => ({ ...prevErrors, [id]: '' })); - } - }; - - const handleTextStepConfirm = (id: number) => { - const label = textLabels[id]?.trim(); - if (!label) { - setErrors(prevErrors => ({ ...prevErrors, [id]: t('right_panel.errors.label_required') })); - return; - } - - const existingLabels = browserSteps - .filter(step => - step.type === 'text' && - step.id !== id && - confirmedTextSteps[step.id] && - 'label' in step && - step.label - ) - .map(step => (step as any).label); - - if (existingLabels.includes(label)) { - setErrors(prevErrors => ({ - ...prevErrors, - [id]: t('right_panel.errors.duplicate_label') || `Label "${label}" already exists. Please use a unique label.` - })); - return; - } - - updateBrowserTextStepLabel(id, label); - setConfirmedTextSteps(prev => ({ ...prev, [id]: true })); - setErrors(prevErrors => ({ ...prevErrors, [id]: '' })); - }; - - const handleTextStepDiscard = (id: number) => { - deleteBrowserStep(id); - setTextLabels(prevLabels => { - const { [id]: _, ...rest } = prevLabels; - return rest; - }); - setErrors(prevErrors => { - const { [id]: _, ...rest } = prevErrors; - return rest; - }); - }; - - const handleTextStepDelete = (id: number) => { - deleteBrowserStep(id); - setTextLabels(prevLabels => { - const { [id]: _, ...rest } = prevLabels; - return rest; - }); - setConfirmedTextSteps(prev => { - const { [id]: _, ...rest } = prev; - return rest; - }); - setErrors(prevErrors => { - const { [id]: _, ...rest } = prevErrors; - return rest; - }); - }; - - const handleListTextFieldConfirm = (listId: number, fieldKey: string) => { - setConfirmedListTextFields(prev => ({ - ...prev, - [listId]: { - ...(prev[listId] || {}), - [fieldKey]: true - } - })); - }; - - const handleListTextFieldDiscard = (listId: number, fieldKey: string) => { - removeListTextField(listId, fieldKey); - setConfirmedListTextFields(prev => { - const updatedListFields = { ...(prev[listId] || {}) }; - delete updatedListFields[fieldKey]; - return { - ...prev, - [listId]: updatedListFields - }; - }); - setErrors(prev => { - const { [fieldKey]: _, ...rest } = prev; - return rest; - }); - }; - - const handleListTextFieldDelete = (listId: number, fieldKey: string) => { - removeListTextField(listId, fieldKey); - setConfirmedListTextFields(prev => { - const updatedListFields = { ...(prev[listId] || {}) }; - delete updatedListFields[fieldKey]; - return { - ...prev, - [listId]: updatedListFields - }; - }); - setErrors(prev => { - const { [fieldKey]: _, ...rest } = prev; - return rest; - }); - }; - - const getTextSettingsObject = useCallback(() => { - const settings: Record = {}; - - browserSteps.forEach(step => { - if (browserStepIdList.includes(step.id)) { - return; - } - - if (step.type === 'text' && step.label && step.selectorObj?.selector) { - settings[step.label] = { - ...step.selectorObj, - selector: step.selectorObj.selector - }; - } - setBrowserStepIdList(prevList => [...prevList, step.id]); - }); - - return settings; - }, [browserSteps, browserStepIdList]); - const stopCaptureAndEmitGetTextSettings = useCallback(() => { - const hasTextStepsForCurrentAction = browserSteps.some(step => step.type === 'text' && step.actionId === currentTextActionId); - if (!hasTextStepsForCurrentAction) { + const currentTextActionStep = browserSteps.find(step => step.type === 'text' && step.actionId === currentTextActionId); + if (!currentTextActionStep) { notify('error', t('right_panel.errors.no_text_captured')); return; } - const hasUnconfirmedTextStepsForCurrentAction = browserSteps.some(step => - step.type === 'text' && - step.actionId === currentTextActionId && - !confirmedTextSteps[step.id] - ); - if (hasUnconfirmedTextStepsForCurrentAction) { - notify('error', t('right_panel.errors.confirm_text_fields')); - return; - } stopGetText(); - const settings = getTextSettingsObject(); - if (hasTextStepsForCurrentAction) { - socket?.emit('action', { action: 'scrapeSchema', settings }); + if (currentTextActionStep) { + emitActionForStep(currentTextActionStep); } - setIsCaptureTextConfirmed(true); setCurrentTextActionId(''); resetInterpretationLog(); finishAction('text'); onFinishCapture(); clientSelectorGenerator.cleanup(); - }, [stopGetText, getTextSettingsObject, socket, browserSteps, confirmedTextSteps, resetInterpretationLog, finishAction, notify, onFinishCapture, t]); + }, [stopGetText, socket, browserSteps, resetInterpretationLog, finishAction, notify, onFinishCapture, t, currentTextActionId, currentTextGroupName, emitActionForStep]); - const getListSettingsObject = useCallback(() => { - let settings: { - listSelector?: string; - fields?: Record; - pagination?: { - type: string; - selector?: string; - isShadow?: boolean; - }; - limit?: number; - isShadow?: boolean; - } = {}; - - browserSteps.forEach(step => { - if (step.type === 'list' && step.listSelector && Object.keys(step.fields).length > 0) { - const fields: Record = {}; - - Object.entries(step.fields).forEach(([id, field]) => { - if (field.selectorObj?.selector) { - fields[field.label] = { - selector: field.selectorObj.selector, - tag: field.selectorObj.tag, - attribute: field.selectorObj.attribute, - isShadow: field.selectorObj.isShadow - }; - } - }); - - settings = { - listSelector: step.listSelector, - fields: fields, - pagination: { - type: paginationType, - selector: step.pagination?.selector, - isShadow: step.isShadow - }, - limit: parseInt(limitType === 'custom' ? customLimit : limitType), - isShadow: step.isShadow - }; - } - }); - - return settings; - }, [browserSteps, paginationType, limitType, customLimit]); const resetListState = useCallback(() => { setShowPaginationOptions(false); @@ -541,32 +341,33 @@ export const RightSidePanel: React.FC = ({ onFinishCapture }, [stopGetList, resetListState]); const stopCaptureAndEmitGetListSettings = useCallback(() => { - const settings = getListSettingsObject(); - const latestListStep = getLatestListStep(browserSteps); - if (latestListStep && settings) { + if (latestListStep) { extractDataClientSide(latestListStep.listSelector!, latestListStep.fields, latestListStep.id); + + setCurrentWorkflowActionsState({ + ...currentWorkflowActionsState, + hasScrapeListAction: true + }); - socket?.emit('action', { action: 'scrapeList', settings }); + emitActionForStep(latestListStep); + + handleStopGetList(); + setCurrentListActionId(''); + resetInterpretationLog(); + finishAction('list'); + onFinishCapture(); + clientSelectorGenerator.cleanup(); } else { notify('error', t('right_panel.errors.unable_create_settings')); + handleStopGetList(); + setCurrentListActionId(''); + resetInterpretationLog(); + finishAction('list'); + onFinishCapture(); + clientSelectorGenerator.cleanup(); } - - handleStopGetList(); - setCurrentListActionId(''); - resetInterpretationLog(); - finishAction('list'); - onFinishCapture(); - clientSelectorGenerator.cleanup(); - }, [getListSettingsObject, socket, notify, handleStopGetList, resetInterpretationLog, finishAction, onFinishCapture, t, browserSteps, extractDataClientSide]); - - const hasUnconfirmedListTextFields = browserSteps.some(step => - step.type === 'list' && - step.actionId === currentListActionId && - Object.entries(step.fields).some(([fieldKey]) => - !confirmedListTextFields[step.id]?.[fieldKey] - ) - ); + }, [socket, notify, handleStopGetList, resetInterpretationLog, finishAction, onFinishCapture, t, browserSteps, extractDataClientSide, setCurrentWorkflowActionsState, currentWorkflowActionsState, emitActionForStep]); const getLatestListStep = (steps: BrowserStep[]) => { const listSteps = steps.filter(step => step.type === 'list'); @@ -590,19 +391,6 @@ export const RightSidePanel: React.FC = ({ onFinishCapture return; } - const hasUnconfirmedListTextFieldsForCurrentAction = browserSteps.some(step => - step.type === 'list' && - step.actionId === currentListActionId && - Object.entries(step.fields).some(([fieldKey]) => - !confirmedListTextFields[step.id]?.[fieldKey] - ) - ); - - if (hasUnconfirmedListTextFieldsForCurrentAction) { - notify('error', t('right_panel.errors.confirm_all_list_fields')); - return; - } - startPaginationMode(); setShowPaginationOptions(true); setCaptureStage('pagination'); @@ -613,11 +401,17 @@ export const RightSidePanel: React.FC = ({ onFinishCapture notify('error', t('right_panel.errors.select_pagination')); return; } - const settings = getListSettingsObject(); - const paginationSelector = settings.pagination?.selector; - if (['clickNext', 'clickLoadMore'].includes(paginationType) && !paginationSelector) { - notify('error', t('right_panel.errors.select_pagination_element')); - return; + + const currentListStepForPagination = browserSteps.find( + step => step.type === 'list' && step.actionId === currentListActionId + ) as (BrowserStep & { type: 'list' }) | undefined; + + if (currentListStepForPagination) { + const paginationSelector = currentListStepForPagination.pagination?.selector; + if (['clickNext', 'clickLoadMore'].includes(paginationType) && !paginationSelector) { + notify('error', t('right_panel.errors.select_pagination_element')); + return; + } } stopPaginationMode(); setShowPaginationOptions(false); @@ -644,7 +438,6 @@ export const RightSidePanel: React.FC = ({ onFinishCapture stopLimitMode(); setShowLimitOptions(false); - setIsCaptureListConfirmed(true); stopCaptureAndEmitGetListSettings(); setCaptureStage('complete'); break; @@ -653,7 +446,7 @@ export const RightSidePanel: React.FC = ({ onFinishCapture setCaptureStage('initial'); break; } - }, [captureStage, paginationType, limitType, customLimit, startPaginationMode, setShowPaginationOptions, setCaptureStage, getListSettingsObject, notify, stopPaginationMode, startLimitMode, setShowLimitOptions, stopLimitMode, setIsCaptureListConfirmed, stopCaptureAndEmitGetListSettings, t, browserSteps, currentListActionId, confirmedListTextFields]); + }, [captureStage, paginationType, limitType, customLimit, startPaginationMode, setShowPaginationOptions, setCaptureStage, notify, stopPaginationMode, startLimitMode, setShowLimitOptions, stopLimitMode, stopCaptureAndEmitGetListSettings, t, browserSteps, currentListActionId, updateListStepLimit]); const handleBackCaptureList = useCallback(() => { switch (captureStage) { @@ -679,61 +472,28 @@ export const RightSidePanel: React.FC = ({ onFinishCapture const discardGetText = useCallback(() => { stopGetText(); - if (currentTextActionId) { - const stepsToDelete = browserSteps - .filter(step => step.type === 'text' && step.actionId === currentTextActionId) - .map(step => step.id); - + if (currentTextActionId) { deleteStepsByActionId(currentTextActionId); - setTextLabels(prevLabels => { - const newLabels = { ...prevLabels }; - stepsToDelete.forEach(id => { - delete newLabels[id]; - }); - return newLabels; - }); - - setErrors(prevErrors => { - const newErrors = { ...prevErrors }; - stepsToDelete.forEach(id => { - delete newErrors[id]; - }); - return newErrors; - }); - - setConfirmedTextSteps(prev => { - const newConfirmed = { ...prev }; - stepsToDelete.forEach(id => { - delete newConfirmed[id]; - }); - return newConfirmed; - }); + if (socket) { + socket.emit('removeAction', { actionId: currentTextActionId }); + } } setCurrentTextActionId(''); - setIsCaptureTextConfirmed(false); clientSelectorGenerator.cleanup(); notify('error', t('right_panel.errors.capture_text_discarded')); - }, [currentTextActionId, browserSteps, stopGetText, deleteStepsByActionId, notify, t]); + }, [currentTextActionId, browserSteps, stopGetText, deleteStepsByActionId, notify, t, socket]); const discardGetList = useCallback(() => { stopGetList(); if (currentListActionId) { - const listStepsToDelete = browserSteps - .filter(step => step.type === 'list' && step.actionId === currentListActionId) - .map(step => step.id); - deleteStepsByActionId(currentListActionId); - setConfirmedListTextFields(prev => { - const newConfirmed = { ...prev }; - listStepsToDelete.forEach(id => { - delete newConfirmed[id]; - }); - return newConfirmed; - }); + if (socket) { + socket.emit('removeAction', { actionId: currentListActionId }); + } } resetListState(); @@ -743,12 +503,14 @@ export const RightSidePanel: React.FC = ({ onFinishCapture setShowLimitOptions(false); setCaptureStage('initial'); setCurrentListActionId(''); - setIsCaptureListConfirmed(false); clientSelectorGenerator.cleanup(); notify('error', t('right_panel.errors.capture_list_discarded')); - }, [currentListActionId, browserSteps, stopGetList, deleteStepsByActionId, resetListState, setShowPaginationOptions, setShowLimitOptions, setCaptureStage, notify, t]); + }, [currentListActionId, browserSteps, stopGetList, deleteStepsByActionId, resetListState, setShowPaginationOptions, setShowLimitOptions, setCaptureStage, notify, t, stopPaginationMode, stopLimitMode, socket]); const captureScreenshot = (fullPage: boolean) => { + const screenshotCount = browserSteps.filter(s => s.type === 'screenshot').length + 1; + const screenshotName = `Screenshot ${screenshotCount}`; + const screenshotSettings = { fullPage, type: 'png' as const, @@ -756,38 +518,18 @@ export const RightSidePanel: React.FC = ({ onFinishCapture animations: 'allow' as const, caret: 'hide' as const, scale: 'device' as const, + name: screenshotName, + actionId: currentScreenshotActionId }; socket?.emit('captureDirectScreenshot', screenshotSettings); - socket?.emit('action', { action: 'screenshot', settings: screenshotSettings }); addScreenshotStep(fullPage, currentScreenshotActionId); stopGetScreenshot(); resetInterpretationLog(); finishAction('screenshot'); - clientSelectorGenerator.cleanup(); onFinishCapture(); + clientSelectorGenerator.cleanup(); }; - const isConfirmCaptureDisabled = useMemo(() => { - if (captureStage !== 'initial') return false; - - const hasValidListSelectorForCurrentAction = browserSteps.some(step => - step.type === 'list' && - step.actionId === currentListActionId && - step.listSelector && - Object.keys(step.fields).length > 0 - ); - - const hasUnconfirmedListTextFieldsForCurrentAction = browserSteps.some(step => - step.type === 'list' && - step.actionId === currentListActionId && - Object.entries(step.fields).some(([fieldKey]) => - !confirmedListTextFields[step.id]?.[fieldKey] - ) - ); - - return !hasValidListSelectorForCurrentAction || hasUnconfirmedListTextFieldsForCurrentAction; - }, [captureStage, browserSteps, currentListActionId, confirmedListTextFields]); - const theme = useThemeMode(); const isDarkMode = theme.darkMode; @@ -842,33 +584,20 @@ export const RightSidePanel: React.FC = ({ onFinishCapture {t('right_panel.buttons.back')} )} - - - - - + - - - ) : !isCaptureTextConfirmed && ( - - - - )} - - )} - {step.type === 'screenshot' && ( - - - - {step.fullPage ? - t('right_panel.screenshot.display_fullpage') : - t('right_panel.screenshot.display_visible')} - - - )} - {step.type === 'list' && ( - Object.entries(step.fields).length === 0 ? ( - {t('right_panel.messages.list_empty')} - ) : ( - <> - {t('right_panel.messages.list_selected')} - {Object.entries(step.fields).map(([key, field]) => ( - - handleTextLabelChange(field.id, e.target.value, step.id, key)} - fullWidth - margin="normal" - InputProps={{ - readOnly: confirmedListTextFields[field.id]?.[key], - startAdornment: ( - - - - ) - }} - /> - - - - ) - }} - /> - {!confirmedListTextFields[step.id]?.[key] && ( - - - - - )} - - ))} - - ) - )} - - ))} - ); }; diff --git a/src/components/run/InterpretationLog.tsx b/src/components/run/InterpretationLog.tsx index faf965e9..96fd2867 100644 --- a/src/components/run/InterpretationLog.tsx +++ b/src/components/run/InterpretationLog.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import SwipeableDrawer from '@mui/material/SwipeableDrawer'; import Typography from '@mui/material/Typography'; -import { Button, Grid, Box } from '@mui/material'; +import { Button, Grid, Box, TextField, IconButton, Tooltip } from '@mui/material'; import { useCallback, useEffect, useRef, useState } from "react"; import { useBrowserDimensionsStore } from "../../context/browserDimensions"; import Table from '@mui/material/Table'; @@ -13,11 +13,14 @@ import TableRow from '@mui/material/TableRow'; import Paper from '@mui/material/Paper'; import StorageIcon from '@mui/icons-material/Storage'; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; +import CloseIcon from '@mui/icons-material/Close'; +import CheckIcon from '@mui/icons-material/Check'; import { SidePanelHeader } from '../recorder/SidePanelHeader'; import { useGlobalInfoStore } from '../../context/globalInfo'; import { useThemeMode } from '../../context/theme-provider'; import { useTranslation } from 'react-i18next'; import { useBrowserSteps } from '../../context/browserSteps'; +import { useActionContext } from '../../context/browserActions'; interface InterpretationLogProps { isOpen: boolean; @@ -26,24 +29,48 @@ interface InterpretationLogProps { export const InterpretationLog: React.FC = ({ isOpen, setIsOpen }) => { const { t } = useTranslation(); - + const [captureListData, setCaptureListData] = useState([]); const [captureTextData, setCaptureTextData] = useState([]); const [screenshotData, setScreenshotData] = useState([]); - const [captureListPage, setCaptureListPage] = useState(0); - const [screenshotPage, setScreenshotPage] = useState(0); - const [activeTab, setActiveTab] = useState(0); - - const logEndRef = useRef(null); + const [activeListTab, setActiveListTab] = useState(0); + const [activeScreenshotTab, setActiveScreenshotTab] = useState(0); + + const [editingField, setEditingField] = useState<{listId: number, fieldKey: string} | null>(null); + const [editingValue, setEditingValue] = useState(''); + + const [editingListName, setEditingListName] = useState(null); + const [editingListNameValue, setEditingListNameValue] = useState(''); + + const [editingTextGroupName, setEditingTextGroupName] = useState(false); + const [editingTextGroupNameValue, setEditingTextGroupNameValue] = useState('Text Data'); + + const [editingTextLabel, setEditingTextLabel] = useState(null); + const [editingTextLabelValue, setEditingTextLabelValue] = useState(''); + + const [editingScreenshotName, setEditingScreenshotName] = useState(null); + const [editingScreenshotNameValue, setEditingScreenshotNameValue] = useState(''); + + const logEndRef = useRef(null); + const autoFocusedListIds = useRef>(new Set()); + const previousDataLengths = useRef>(new Map()); + const hasAutoFocusedTextTab = useRef(false); + const previousGetText = useRef(false); + const autoFocusedScreenshotIndices = useRef>(new Set()); + + const { browserSteps, updateListTextFieldLabel, removeListTextField, updateListStepName, updateScreenshotStepName, updateBrowserTextStepLabel, deleteBrowserStep, emitForStepId } = useBrowserSteps(); + const { captureStage, getText } = useActionContext(); - const { browserSteps } = useBrowserSteps(); - const { browserWidth, outputPreviewHeight, outputPreviewWidth } = useBrowserDimensionsStore(); - const { currentWorkflowActionsState, shouldResetInterpretationLog } = useGlobalInfoStore(); + const { currentWorkflowActionsState, shouldResetInterpretationLog, currentTextGroupName, setCurrentTextGroupName } = useGlobalInfoStore(); const [showPreviewData, setShowPreviewData] = useState(false); + const userClosedDrawer = useRef(false); + const lastListDataLength = useRef(0); + const lastTextDataLength = useRef(0); + const lastScreenshotDataLength = useRef(0); const toggleDrawer = (newOpen: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => { if ( @@ -53,50 +80,216 @@ export const InterpretationLog: React.FC = ({ isOpen, se ) { return; } + if (!newOpen && isOpen) { + userClosedDrawer.current = true; + } setIsOpen(newOpen); }; + const handleStartEdit = (listId: number, fieldKey: string, currentLabel: string) => { + setEditingField({ listId, fieldKey }); + setEditingValue(currentLabel); + }; + + const handleSaveEdit = () => { + if (editingField && editingValue.trim()) { + const listStep = browserSteps.find(step => step.id === editingField.listId); + const actionId = listStep?.actionId; + + updateListTextFieldLabel(editingField.listId, editingField.fieldKey, editingValue.trim()); + + if (actionId) { + setTimeout(() => emitForStepId(actionId), 0); + } + + setEditingField(null); + setEditingValue(''); + } + }; + + const handleCancelEdit = () => { + setEditingField(null); + setEditingValue(''); + }; + + const handleDeleteField = (listId: number, fieldKey: string) => { + const listStep = browserSteps.find(step => step.id === listId); + const actionId = listStep?.actionId; + + removeListTextField(listId, fieldKey); + + if (actionId) { + setTimeout(() => emitForStepId(actionId), 0); + } + }; + + const handleStartEditListName = (listId: number, currentName: string) => { + setEditingListName(listId); + setEditingListNameValue(currentName); + }; + + const handleSaveListName = () => { + if (editingListName !== null) { + const trimmedName = editingListNameValue.trim(); + const finalName = trimmedName || `List Data ${captureListData.findIndex(l => l.id === editingListName) + 1}`; + + updateListStepName(editingListName, finalName); + + const listStep = browserSteps.find(step => step.id === editingListName); + if (listStep?.actionId) { + setTimeout(() => emitForStepId(listStep.actionId!), 0); + } + + setEditingListName(null); + setEditingListNameValue(''); + } + }; + + const handleStartEditTextGroupName = () => { + setEditingTextGroupName(true); + setEditingTextGroupNameValue(currentTextGroupName); + }; + + const handleSaveTextGroupName = () => { + const trimmedName = editingTextGroupNameValue.trim(); + const finalName = trimmedName || 'Text Data'; + + setCurrentTextGroupName(finalName); + setEditingTextGroupName(false); + + setTimeout(() => { + const activeTextStep = captureTextData.find(step => step.actionId); + if (activeTextStep?.actionId) emitForStepId(activeTextStep.actionId); + }, 0); + }; + + + const handleStartEditTextLabel = (textId: number, currentLabel: string) => { + setEditingTextLabel(textId); + setEditingTextLabelValue(currentLabel); + }; + + const handleSaveTextLabel = () => { + if (editingTextLabel !== null && editingTextLabelValue.trim()) { + const textStep = browserSteps.find(step => step.id === editingTextLabel); + const actionId = textStep?.actionId; + + updateBrowserTextStepLabel(editingTextLabel, editingTextLabelValue.trim()); + + if (actionId) { + setTimeout(() => emitForStepId(actionId), 0); + } + + setEditingTextLabel(null); + setEditingTextLabelValue(''); + } + }; + + const handleCancelTextLabel = () => { + setEditingTextLabel(null); + setEditingTextLabelValue(''); + }; + + const handleDeleteTextStep = (textId: number) => { + const textStep = browserSteps.find(step => step.id === textId); + const actionId = textStep?.actionId; + + deleteBrowserStep(textId); + + if (actionId) { + setTimeout(() => emitForStepId(actionId), 0); + } + }; + + const handleStartEditScreenshotName = (screenshotStepId: number, currentName: string) => { + setEditingScreenshotName(screenshotStepId); + setEditingScreenshotNameValue(currentName); + }; + + const handleSaveScreenshotName = () => { + if (editingScreenshotName !== null) { + const trimmedName = editingScreenshotNameValue.trim(); + const screenshotSteps = browserSteps.filter(step => step.type === 'screenshot'); + const screenshotIndex = screenshotSteps.findIndex(s => s.id === editingScreenshotName); + const finalName = trimmedName || `Screenshot ${screenshotIndex + 1}`; + + updateScreenshotStepName(editingScreenshotName, finalName); + + const screenshotStep = browserSteps.find(step => step.id === editingScreenshotName); + if (screenshotStep?.actionId) { + const originalName = screenshotStep.name?.trim() || ""; + const trimmedName = editingScreenshotNameValue.trim(); + + if (trimmedName && trimmedName !== originalName) { + setTimeout(() => emitForStepId(screenshotStep.actionId!), 500); + } + } + + setEditingScreenshotName(null); + setEditingScreenshotNameValue(''); + } + }; + + + const previousTabsCount = useRef({ lists: 0, texts: 0, screenshots: 0 }); + const updateActiveTab = useCallback(() => { const availableTabs = getAvailableTabs(); - - if (captureListData.length > 0 && availableTabs.findIndex(tab => tab.id === 'captureList') !== -1) { + const hasNewListData = captureListData.length > previousTabsCount.current.lists; + const hasNewTextData = captureTextData.length > previousTabsCount.current.texts; + const hasNewScreenshotData = screenshotData.length > previousTabsCount.current.screenshots; + + previousTabsCount.current = { + lists: captureListData.length, + texts: captureTextData.length, + screenshots: screenshotData.length + }; + + if (hasNewListData && availableTabs.findIndex(tab => tab.id === 'captureList') !== -1) { setActiveTab(availableTabs.findIndex(tab => tab.id === 'captureList')); - } else if (captureTextData.length > 0 && availableTabs.findIndex(tab => tab.id === 'captureText') !== -1) { + } else if (hasNewTextData && availableTabs.findIndex(tab => tab.id === 'captureText') !== -1) { setActiveTab(availableTabs.findIndex(tab => tab.id === 'captureText')); - } else if (screenshotData.length > 0 && availableTabs.findIndex(tab => tab.id === 'captureScreenshot') !== -1) { + } else if (hasNewScreenshotData && availableTabs.findIndex(tab => tab.id === 'captureScreenshot') !== -1) { setActiveTab(availableTabs.findIndex(tab => tab.id === 'captureScreenshot')); + setActiveScreenshotTab(screenshotData.length - 1); } }, [captureListData.length, captureTextData.length, screenshotData.length]); + useEffect(() => { const textSteps = browserSteps.filter(step => step.type === 'text'); - if (textSteps.length > 0) { - const textDataRow: Record = {}; - - textSteps.forEach(step => { - textDataRow[step.label] = step.data; - }); - - setCaptureTextData([textDataRow]); - } - - const listSteps = browserSteps.filter(step => step.type === 'list'); - if (listSteps.length > 0) { - setCaptureListData(listSteps); + setCaptureTextData(textSteps); + + if (!getText && previousGetText.current && textSteps.length > 0) { + if (!hasAutoFocusedTextTab.current) { + hasAutoFocusedTextTab.current = true; + setTimeout(() => { + handleStartEditTextGroupName(); + }, 300); + } } - const screenshotSteps = browserSteps.filter(step => + previousGetText.current = getText; + + const listSteps = browserSteps.filter(step => step.type === 'list'); + setCaptureListData(listSteps); + + const screenshotSteps = browserSteps.filter(step => step.type === 'screenshot' - ) as Array<{ type: 'screenshot'; id: number; fullPage: boolean; actionId?: string; screenshotData?: string }>; + ) as Array<{ type: 'screenshot'; id: number; name?: string; fullPage: boolean; actionId?: string; screenshotData?: string }>; const screenshotsWithData = screenshotSteps.filter(step => step.screenshotData); - if (screenshotsWithData.length > 0) { - const screenshots = screenshotsWithData.map(step => step.screenshotData!); - setScreenshotData(screenshots); + const screenshots = screenshotsWithData.map(step => step.screenshotData!); + setScreenshotData(screenshots); + + if (textSteps.length > 0 || listSteps.length > 0 || screenshots.length > 0) { + setShowPreviewData(true); + } else { + setShowPreviewData(false); } - + updateActiveTab(); - }, [browserSteps, updateActiveTab]); + }, [browserSteps, updateActiveTab, getText]); useEffect(() => { if (shouldResetInterpretationLog) { @@ -104,32 +297,40 @@ export const InterpretationLog: React.FC = ({ isOpen, se setCaptureTextData([]); setScreenshotData([]); setActiveTab(0); - setCaptureListPage(0); - setScreenshotPage(0); setShowPreviewData(false); + autoFocusedListIds.current.clear(); + previousDataLengths.current.clear(); + autoFocusedScreenshotIndices.current.clear(); + userClosedDrawer.current = false; + lastListDataLength.current = 0; + lastTextDataLength.current = 0; + lastScreenshotDataLength.current = 0; + previousTabsCount.current = { lists: 0, texts: 0, screenshots: 0 }; + hasAutoFocusedTextTab.current = false; + previousGetText.current = false; } }, [shouldResetInterpretationLog]); const getAvailableTabs = useCallback(() => { const tabs = []; - + if (captureListData.length > 0) { tabs.push({ id: 'captureList', label: 'Lists' }); } - + if (captureTextData.length > 0) { tabs.push({ id: 'captureText', label: 'Texts' }); } - + if (screenshotData.length > 0) { tabs.push({ id: 'captureScreenshot', label: 'Screenshots' }); } - + return tabs; }, [captureListData.length, captureTextData.length, screenshotData.length, showPreviewData]); const availableTabs = getAvailableTabs(); - + useEffect(() => { if (activeTab >= availableTabs.length && availableTabs.length > 0) { setActiveTab(0); @@ -139,15 +340,105 @@ export const InterpretationLog: React.FC = ({ isOpen, se const { hasScrapeListAction, hasScreenshotAction, hasScrapeSchemaAction } = currentWorkflowActionsState; useEffect(() => { - if (hasScrapeListAction || hasScrapeSchemaAction || hasScreenshotAction) { - setIsOpen(true); + let shouldOpenDrawer = false; + let switchToTextTab = false; + let switchToScreenshotTab = false; + + if (hasScrapeListAction && captureListData.length > 0 && captureListData[0]?.data?.length > 0) { + setShowPreviewData(true); + if (captureListData.length > lastListDataLength.current) { + userClosedDrawer.current = false; + shouldOpenDrawer = true; + } + lastListDataLength.current = captureListData.length; } - }, [hasScrapeListAction, hasScrapeSchemaAction, hasScreenshotAction, setIsOpen]); + + if (hasScrapeSchemaAction && captureTextData.length > 0 && !getText) { + setShowPreviewData(true); + if (captureTextData.length > lastTextDataLength.current) { + userClosedDrawer.current = false; + shouldOpenDrawer = true; + switchToTextTab = true; + } + lastTextDataLength.current = captureTextData.length; + } + + if (hasScreenshotAction && screenshotData.length > 0) { + setShowPreviewData(true); + if (screenshotData.length > lastScreenshotDataLength.current) { + userClosedDrawer.current = false; + shouldOpenDrawer = true; + switchToScreenshotTab = true; + } + lastScreenshotDataLength.current = screenshotData.length; + } + + if (shouldOpenDrawer) { + setIsOpen(true); + if (switchToTextTab) { + setTimeout(() => { + const textTabIndex = getAvailableTabs().findIndex(tab => tab.id === 'captureText'); + if (textTabIndex !== -1) { + setActiveTab(textTabIndex); + } + }, 100); + } else if (switchToScreenshotTab) { + setTimeout(() => { + const screenshotTabIndex = getAvailableTabs().findIndex(tab => tab.id === 'captureScreenshot'); + if (screenshotTabIndex !== -1) { + setActiveTab(screenshotTabIndex); + const latestIndex = screenshotData.length - 1; + setActiveScreenshotTab(latestIndex); + + if (!autoFocusedScreenshotIndices.current.has(latestIndex)) { + autoFocusedScreenshotIndices.current.add(latestIndex); + setTimeout(() => { + const screenshotSteps = browserSteps.filter(step => step.type === 'screenshot') as Array<{ id: number; name?: string; type: 'screenshot' }>; + const latestScreenshotStep = screenshotSteps[latestIndex]; + if (latestScreenshotStep) { + const screenshotName = latestScreenshotStep.name || `Screenshot ${latestIndex + 1}`; + handleStartEditScreenshotName(latestScreenshotStep.id, screenshotName); + } + }, 300); + } + } + }, 100); + } + } + }, [hasScrapeListAction, hasScrapeSchemaAction, hasScreenshotAction, captureListData, captureTextData, screenshotData, setIsOpen, getText]); + + useEffect(() => { + if (captureListData.length > 0 && isOpen && captureStage === 'initial') { + const latestListIndex = captureListData.length - 1; + const latestList = captureListData[latestListIndex]; + if (latestList && latestList.data && latestList.data.length > 0 && !editingListName) { + const previousLength = previousDataLengths.current.get(latestList.id) || 0; + const currentLength = latestList.data.length; + + if (previousLength === 0 && currentLength > 0) { + if (!autoFocusedListIds.current.has(latestList.id)) { + autoFocusedListIds.current.add(latestList.id); + setActiveListTab(latestListIndex); + setTimeout(() => { + handleStartEditListName(latestList.id, latestList.name || `List Data ${latestListIndex + 1}`); + }, 300); + } + } + + previousDataLengths.current.set(latestList.id, currentLength); + } + } + }, [captureListData.length, isOpen, captureStage]); + + useEffect(() => { + if (screenshotData.length > 0 && isOpen) { + const latestScreenshotIndex = screenshotData.length - 1; + setActiveScreenshotTab(latestScreenshotIndex); + } + }, [screenshotData.length, isOpen]); const { darkMode } = useThemeMode(); - const getCaptureTextColumns = captureTextData.length > 0 ? Object.keys(captureTextData[0]) : []; - const shouldShowTabs = availableTabs.length > 1; const getSingleContentType = () => { @@ -193,10 +484,10 @@ export const InterpretationLog: React.FC = ({ isOpen, se onOpen={toggleDrawer(true)} PaperProps={{ sx: { - background: `${darkMode ? '#1e2124' : 'white'}`, + background: `${darkMode ? '#1d1c1cff' : 'white'}`, color: `${darkMode ? 'white' : 'black'}`, padding: '10px', - height: outputPreviewHeight, + height: "calc(100% - 140px)", width: outputPreviewWidth, display: 'flex', flexDirection: 'column', @@ -208,16 +499,16 @@ export const InterpretationLog: React.FC = ({ isOpen, se {t('interpretation_log.titles.output_preview')} - - {showPreviewData && availableTabs.length > 0 ? ( + + {showPreviewData && availableTabs.length > 0 && ( <> {shouldShowTabs && ( - {availableTabs.map((tab, index) => ( @@ -228,15 +519,14 @@ export const InterpretationLog: React.FC = ({ isOpen, se px: 4, py: 2, cursor: 'pointer', - borderBottom: activeTab === index ? '2px solid' : 'none', borderColor: activeTab === index ? (darkMode ? '#ff00c3' : '#ff00c3') : 'transparent', - backgroundColor: activeTab === index ? (darkMode ? '#34404d' : '#e9ecef') : 'transparent', + backgroundColor: activeTab === index ? (darkMode ? '#121111ff' : '#e9ecef') : 'transparent', color: darkMode ? 'white' : 'black', fontWeight: activeTab === index ? 500 : 400, textAlign: 'center', position: 'relative', '&:hover': { - backgroundColor: activeTab !== index ? (darkMode ? '#303b49' : '#e2e6ea') : undefined + backgroundColor: activeTab !== index ? (darkMode ? '#121111ff' : '#e2e6ea') : undefined } }} > @@ -247,79 +537,248 @@ export const InterpretationLog: React.FC = ({ isOpen, se ))} )} - - - {(activeTab === availableTabs.findIndex(tab => tab.id === 'captureList') || singleContentType === 'captureList') && captureListData.length > 0 && ( - - - - {`Table ${captureListPage + 1} of ${captureListData.length}`} - - - - - - - - - - - {Object.values(captureListData[captureListPage]?.fields || {}).map((field: any, index) => ( - + {(activeTab === availableTabs.findIndex(tab => tab.id === 'captureList') || + singleContentType === 'captureList') && + captureListData.length > 0 && ( + + + {captureListData.map((listItem, index) => { + const isEditing = editingListName === listItem.id; + const isActive = activeListTab === index; + + return ( + + { + if (!isEditing) { + setActiveListTab(index); + } + }} + onDoubleClick={() => { + handleStartEditListName( + listItem.id, + listItem.name || `List Data ${index + 1}` + ); + }} + sx={{ + px: 3, + py: 1.25, + cursor: isEditing ? 'text' : 'pointer', + borderRadius: '8px 8px 0 0', + backgroundColor: darkMode + ? '#131313ff' + : '#ffffff', + color: isActive + ? darkMode + ? '#ffffff' + : '#000000' + : darkMode + ? '#b0b0b0' + : '#555555', + fontWeight: isActive ? 600 : 400, + fontSize: '0.875rem', + border: '1px solid', + borderColor: darkMode ? '#2a2a2a' : '#d0d0d0', + borderBottom: isActive + ? darkMode + ? '2px solid #1c1c1c' + : '2px solid #ffffff' + : '2px solid transparent', + transition: 'all 0.2s ease', + '&:hover': { + backgroundColor: isActive + ? undefined + : darkMode + ? '#161616' + : '#e9ecef', + }, }} > - {field.label} - - ))} + {isEditing ? ( + setEditingListNameValue(e.target.value)} + onBlur={handleSaveListName} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveListName(); + if (e.key === 'Escape') { + setEditingListName(null); + setEditingListNameValue(''); + } + }} + autoFocus + size="small" + variant="standard" + sx={{ + minWidth: '120px', + '& .MuiInputBase-input': { + color: darkMode ? '#fff' : '#000', + fontSize: 'inherit', + fontWeight: 'inherit', + padding: 0, + }, + '& .MuiInput-underline:before': { display: 'none' }, + '& .MuiInput-underline:after': { display: 'none' }, + '& .MuiInput-underline:hover:before': { display: 'none' }, + }} + /> + ) : ( + listItem.name || `List Data ${index + 1}` + )} + + + ); + })} + + + +
+ + + {Object.entries(captureListData[activeListTab]?.fields || {}).map(([fieldKey, field]: [string, any]) => { + const isEditing = editingField?.listId === captureListData[activeListTab]?.id && editingField?.fieldKey === fieldKey; + + return ( + + {isEditing ? ( + + setEditingValue(e.target.value)} + onBlur={handleSaveEdit} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveEdit(); + if (e.key === 'Escape') handleCancelEdit(); + }} + autoFocus + size="small" + sx={{ + flex: 1, + minWidth: '150px', + '& .MuiInputBase-root': { + backgroundColor: darkMode ? '#2a2929' : '#fff' + } + }} + /> + + + + + ) : ( + + + handleStartEdit(captureListData[activeListTab]?.id, fieldKey, field.label)} + > + {field.label} + + + handleDeleteField(captureListData[activeListTab]?.id, fieldKey)} + sx={{ + position: 'absolute', + right: 4, + top: '50%', + transform: 'translateY(-50%)', + opacity: 0, + transition: 'opacity 0.2s', + color: darkMode ? '#999' : '#666', + padding: '4px', + '&:hover': { + color: '#f44336', + backgroundColor: darkMode ? 'rgba(244, 67, 54, 0.1)' : 'rgba(244, 67, 54, 0.05)' + } + }} + > + + + + )} + + ); + })} - {(captureListData[captureListPage]?.data || []) - .slice(0, Math.min(captureListData[captureListPage]?.limit || 10, 5)) + {(captureListData[activeListTab]?.data || []) + .slice(0, Math.min(captureListData[activeListTab]?.limit || 10, 5)) .map((row: any, rowIndex: any) => ( - - {Object.values(captureListData[captureListPage]?.fields || {}).map((field: any, colIndex) => ( - ( + - {row[field.label]} + {typeof row[field.label] === 'object' ? JSON.stringify(row[field.label]) : String(row[field.label] || '')} ))} @@ -328,76 +787,237 @@ export const InterpretationLog: React.FC = ({ isOpen, se
+
+ )} + + + {(activeTab === availableTabs.findIndex(tab => tab.id === 'captureScreenshot') || singleContentType === 'captureScreenshot') && screenshotData.length > 0 && ( + + + {(() => { + const screenshotSteps = browserSteps.filter(step => step.type === 'screenshot' && step.screenshotData) as Array<{ id: number; name?: string; type: 'screenshot'; screenshotData?: string }>; + return screenshotData.map((screenshot, index) => { + const screenshotStep = screenshotSteps[index]; + if (!screenshotStep) return null; + + const isActive = activeScreenshotTab === index; + const isEditing = editingScreenshotName === screenshotStep.id; + const screenshotName = screenshotStep.name || `Screenshot ${index + 1}`; + + return ( + + { + if (!isEditing) { + setActiveScreenshotTab(index); + } + }} + onDoubleClick={() => { + handleStartEditScreenshotName(screenshotStep.id, screenshotName); + }} + sx={{ + px: 3, + py: 1.25, + cursor: isEditing ? 'text' : 'pointer', + borderRadius: '8px 8px 0 0', + backgroundColor: darkMode ? '#131313ff' : '#ffffff', + color: isActive + ? darkMode + ? '#ffffff' + : '#000000' + : darkMode + ? '#b0b0b0' + : '#555555', + fontWeight: isActive ? 600 : 400, + fontSize: '0.875rem', + border: '1px solid', + borderColor: darkMode ? '#2a2a2a' : '#d0d0d0', + borderBottom: isActive + ? darkMode + ? '2px solid #1c1c1c' + : '2px solid #ffffff' + : '2px solid transparent', + transition: 'all 0.2s ease', + '&:hover': { + backgroundColor: isActive + ? undefined + : darkMode + ? '#161616' + : '#e9ecef', + }, + }} + > + {isEditing ? ( + setEditingScreenshotNameValue(e.target.value)} + onBlur={handleSaveScreenshotName} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveScreenshotName(); + if (e.key === 'Escape') { + setEditingScreenshotName(null); + setEditingScreenshotNameValue(''); + } + }} + autoFocus + size="small" + variant="standard" + sx={{ + minWidth: '120px', + '& .MuiInputBase-input': { + color: darkMode ? '#fff' : '#000', + fontSize: 'inherit', + fontWeight: 'inherit', + padding: 0, + }, + '& .MuiInput-underline:before': { display: 'none' }, + '& .MuiInput-underline:after': { display: 'none' }, + '& .MuiInput-underline:hover:before': { display: 'none' }, + }} + /> + ) : ( + screenshotName + )} + + + ); + }); + })()} + + + + {`Screenshot + )} - {(activeTab === availableTabs.findIndex(tab => tab.id === 'captureScreenshot') || singleContentType === 'captureScreenshot') && screenshotData.length > 0 && ( - - {screenshotData.length > 1 && ( - - - {`Screenshot ${screenshotPage + 1} of ${screenshotData.length}`} - - - - - - - )} - {screenshotData.length > 0 && ( - - - {t('interpretation_log.titles.screenshot')} {screenshotPage + 1} - - {`${t('interpretation_log.titles.screenshot')} - - )} - - )} - {(activeTab === availableTabs.findIndex(tab => tab.id === 'captureText') || singleContentType === 'captureText') && captureTextData.length > 0 && ( - - - + + + + + {editingTextGroupName ? ( + setEditingTextGroupNameValue(e.target.value)} + onBlur={handleSaveTextGroupName} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveTextGroupName(); + if (e.key === 'Escape') { + setEditingTextGroupName(false); + setEditingTextGroupNameValue(currentTextGroupName); + } + }} + autoFocus + size="small" + variant="standard" + sx={{ + minWidth: '120px', + '& .MuiInputBase-input': { + color: darkMode ? '#fff' : '#000', + fontSize: 'inherit', + fontWeight: 'inherit', + padding: 0, + }, + '& .MuiInput-underline:before': { display: 'none' }, + '& .MuiInput-underline:after': { display: 'none' }, + '& .MuiInput-underline:hover:before': { display: 'none' }, + }} + /> + ) : ( + currentTextGroupName + )} + + + + + +
- Label - Value @@ -405,33 +1025,112 @@ export const InterpretationLog: React.FC = ({ isOpen, se - {getCaptureTextColumns.map((column, index) => ( - - { + const isEditing = editingTextLabel === textStep.id; + + return ( + - {column} - - - {captureTextData[0][column]} - - - ))} + + {isEditing ? ( + + setEditingTextLabelValue(e.target.value)} + onBlur={handleSaveTextLabel} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveTextLabel(); + if (e.key === 'Escape') handleCancelTextLabel(); + }} + autoFocus + size="small" + sx={{ + flex: 1, + minWidth: '150px', + '& .MuiInputBase-root': { + backgroundColor: darkMode ? '#2a2929' : '#fff' + } + }} + /> + + + + + ) : ( + + + handleStartEditTextLabel(textStep.id, textStep.label)} + > + {textStep.label} + + + handleDeleteTextStep(textStep.id)} + sx={{ + position: 'absolute', + right: 4, + top: '50%', + transform: 'translateY(-50%)', + opacity: 0, + transition: 'opacity 0.2s', + color: darkMode ? '#999' : '#666', + padding: '4px', + '&:hover': { + color: '#f44336', + backgroundColor: darkMode ? 'rgba(244, 67, 54, 0.1)' : 'rgba(244, 67, 54, 0.05)' + } + }} + > + + + + )} + + + {typeof textStep.data === 'object' ? JSON.stringify(textStep.data) : String(textStep.data || '')} + + + ); + })}
@@ -439,27 +1138,10 @@ export const InterpretationLog: React.FC = ({ isOpen, se )}
- ) : ( - - - {hasScrapeListAction || hasScrapeSchemaAction || hasScreenshotAction ? ( - <> - - {t('interpretation_log.messages.successful_training')} - - setShowPreviewData(true)} /> - - ) : ( - - {t('interpretation_log.messages.no_selection')} - - )} - - )}
); -}; \ No newline at end of file +}; diff --git a/src/components/run/RunContent.tsx b/src/components/run/RunContent.tsx index b23af630..ceffc025 100644 --- a/src/components/run/RunContent.tsx +++ b/src/components/run/RunContent.tsx @@ -8,16 +8,13 @@ import { CircularProgress, Accordion, AccordionSummary, - AccordionDetails, - ButtonGroup + AccordionDetails } from "@mui/material"; import Highlight from "react-highlight"; import * as React from "react"; import { Data } from "./RunsTable"; import { TabPanel, TabContext } from "@mui/lab"; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import { useEffect, useState } from "react"; import Table from '@mui/material/Table'; import TableBody from '@mui/material/TableBody'; @@ -43,21 +40,25 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe const [schemaData, setSchemaData] = useState([]); const [schemaColumns, setSchemaColumns] = useState([]); + const [schemaKeys, setSchemaKeys] = useState([]); + const [schemaDataByKey, setSchemaDataByKey] = useState>({}); + const [schemaColumnsByKey, setSchemaColumnsByKey] = useState>({}); const [isSchemaTabular, setIsSchemaTabular] = useState(false); const [listData, setListData] = useState([]); const [listColumns, setListColumns] = useState([]); + const [listKeys, setListKeys] = useState([]); const [currentListIndex, setCurrentListIndex] = useState(0); const [screenshotKeys, setScreenshotKeys] = useState([]); + const [screenshotKeyMap, setScreenshotKeyMap] = useState>({}); const [currentScreenshotIndex, setCurrentScreenshotIndex] = useState(0); + const [currentSchemaIndex, setCurrentSchemaIndex] = useState(0); const [legacyData, setLegacyData] = useState([]); const [legacyColumns, setLegacyColumns] = useState([]); const [isLegacyData, setIsLegacyData] = useState(false); - const { darkMode } = useThemeMode(); - useEffect(() => { setTab(tab); }, [interpretationInProgress]); @@ -66,8 +67,12 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe if (row.status === 'running' || row.status === 'queued' || row.status === 'scheduled') { setSchemaData([]); setSchemaColumns([]); + setSchemaKeys([]); + setSchemaDataByKey({}); + setSchemaColumnsByKey({}); setListData([]); setListColumns([]); + setListKeys([]); setLegacyData([]); setLegacyColumns([]); setIsLegacyData(false); @@ -77,10 +82,11 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe if (!row.serializableOutput) return; - if (!row.serializableOutput.scrapeSchema && - !row.serializableOutput.scrapeList && - Object.keys(row.serializableOutput).length > 0) { + const hasLegacySchema = row.serializableOutput.scrapeSchema && Array.isArray(row.serializableOutput.scrapeSchema); + const hasLegacyList = row.serializableOutput.scrapeList && Array.isArray(row.serializableOutput.scrapeList); + const hasOldFormat = !row.serializableOutput.scrapeSchema && !row.serializableOutput.scrapeList && Object.keys(row.serializableOutput).length > 0; + if (hasLegacySchema || hasLegacyList || hasOldFormat) { setIsLegacyData(true); processLegacyData(row.serializableOutput); return; @@ -100,44 +106,106 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe useEffect(() => { if (row.status === 'running' || row.status === 'queued' || row.status === 'scheduled') { setScreenshotKeys([]); + setScreenshotKeyMap({}); setCurrentScreenshotIndex(0); return; } if (row.binaryOutput && Object.keys(row.binaryOutput).length > 0) { - setScreenshotKeys(Object.keys(row.binaryOutput)); + const rawKeys = Object.keys(row.binaryOutput); + + const isLegacyPattern = rawKeys.every(key => /^item-\d+-\d+$/.test(key)); + + if (isLegacyPattern) { + const renamedKeys = rawKeys.map((_, index) => `Screenshot ${index + 1}`); + const keyMap: Record = {}; + + renamedKeys.forEach((displayName, index) => { + keyMap[displayName] = rawKeys[index]; + }); + + setScreenshotKeys(renamedKeys); + setScreenshotKeyMap(keyMap); + } else { + const keyMap: Record = {}; + rawKeys.forEach(key => { + keyMap[key] = key; + }); + + setScreenshotKeys(rawKeys); + setScreenshotKeyMap(keyMap); + } + setCurrentScreenshotIndex(0); } else { setScreenshotKeys([]); + setScreenshotKeyMap({}); setCurrentScreenshotIndex(0); } }, [row.binaryOutput, row.status]); const processLegacyData = (legacyOutput: Record) => { - let allData: any[] = []; + const convertedSchema: Record = {}; + const convertedList: Record = {}; - Object.keys(legacyOutput).forEach(key => { + const keys = Object.keys(legacyOutput); + + keys.forEach((key) => { const data = legacyOutput[key]; + if (Array.isArray(data)) { - const filteredData = data.filter(row => - Object.values(row).some(value => value !== undefined && value !== "") - ); - allData = [...allData, ...filteredData]; + const isNestedArray = data.length > 0 && Array.isArray(data[0]); + + if (isNestedArray) { + data.forEach((subArray, index) => { + if (Array.isArray(subArray) && subArray.length > 0) { + const filteredData = subArray.filter(row => + row && typeof row === 'object' && Object.values(row).some(value => value !== undefined && value !== "") + ); + + if (filteredData.length > 0) { + const autoName = `List ${Object.keys(convertedList).length + 1}`; + convertedList[autoName] = filteredData; + } + } + }); + } else { + const filteredData = data.filter(row => + row && typeof row === 'object' && Object.values(row).some(value => value !== undefined && value !== "") + ); + + if (filteredData.length > 0) { + const schemaCount = Object.keys(convertedSchema).length; + const autoName = `Text ${schemaCount + 1}`; + convertedSchema[autoName] = filteredData; + } + } } }); - if (allData.length > 0) { - const allColumns = new Set(); - allData.forEach(item => { - Object.keys(item).forEach(key => allColumns.add(key)); - }); + if (Object.keys(convertedSchema).length === 1) { + const singleKey = Object.keys(convertedSchema)[0]; + const singleData = convertedSchema[singleKey]; + delete convertedSchema[singleKey]; + convertedSchema["Texts"] = singleData; + } - setLegacyData(allData); - setLegacyColumns(Array.from(allColumns)); + if (Object.keys(convertedSchema).length > 0) { + processSchemaData(convertedSchema); + } + + if (Object.keys(convertedList).length > 0) { + processScrapeList(convertedList); } }; const processSchemaData = (schemaOutput: any) => { + const keys = Object.keys(schemaOutput); + setSchemaKeys(keys); + + const dataByKey: Record = {}; + const columnsByKey: Record = {}; + if (Array.isArray(schemaOutput)) { const filteredData = schemaOutput.filter(row => row && Object.values(row).some(value => value !== undefined && value !== "") @@ -156,41 +224,32 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe } } - if (schemaOutput['schema-tabular']) { - const tabularData = schemaOutput['schema-tabular']; - if (Array.isArray(tabularData) && tabularData.length > 0) { - const filteredData = tabularData.filter(row => - Object.values(row).some(value => value !== undefined && value !== "") - ); - - if (filteredData.length > 0) { - const allColumns = new Set(); - filteredData.forEach(item => { - Object.keys(item).forEach(key => allColumns.add(key)); - }); - - setSchemaData(filteredData); - setSchemaColumns(Array.from(allColumns)); - setIsSchemaTabular(true); - return; - } - } - } - let allData: any[] = []; let hasMultipleEntries = false; - Object.keys(schemaOutput).forEach(key => { + keys.forEach(key => { const data = schemaOutput[key]; if (Array.isArray(data)) { const filteredData = data.filter(row => Object.values(row).some(value => value !== undefined && value !== "") ); + + dataByKey[key] = filteredData; + + const columnsForKey = new Set(); + filteredData.forEach(item => { + Object.keys(item).forEach(col => columnsForKey.add(col)); + }); + columnsByKey[key] = Array.from(columnsForKey); + allData = [...allData, ...filteredData]; if (filteredData.length > 1) hasMultipleEntries = true; } }); + setSchemaDataByKey(dataByKey); + setSchemaColumnsByKey(columnsByKey); + if (allData.length > 0) { const allColumns = new Set(); allData.forEach(item => { @@ -206,42 +265,22 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe const processScrapeList = (scrapeListData: any) => { const tablesList: any[][] = []; const columnsList: string[][] = []; + const keys: string[] = []; - if (Array.isArray(scrapeListData)) { - scrapeListData.forEach(tableData => { - if (Array.isArray(tableData) && tableData.length > 0) { - const filteredData = tableData.filter(row => - Object.values(row).some(value => value !== undefined && value !== "") - ); - - if (filteredData.length > 0) { - tablesList.push(filteredData); - - const tableColumns = new Set(); - filteredData.forEach(item => { - Object.keys(item).forEach(key => tableColumns.add(key)); - }); - - columnsList.push(Array.from(tableColumns)); - } - } - }); - } else if (typeof scrapeListData === 'object') { + if (typeof scrapeListData === 'object') { Object.keys(scrapeListData).forEach(key => { const tableData = scrapeListData[key]; if (Array.isArray(tableData) && tableData.length > 0) { const filteredData = tableData.filter(row => Object.values(row).some(value => value !== undefined && value !== "") ); - if (filteredData.length > 0) { tablesList.push(filteredData); - + keys.push(key); const tableColumns = new Set(); filteredData.forEach(item => { Object.keys(item).forEach(key => tableColumns.add(key)); }); - columnsList.push(Array.from(tableColumns)); } } @@ -250,6 +289,7 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe setListData(tablesList); setListColumns(columnsList); + setListKeys(keys); setCurrentListIndex(0); }; @@ -308,21 +348,6 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe }, 100); }; - const navigateListTable = (direction: 'next' | 'prev') => { - if (direction === 'next' && currentListIndex < listData.length - 1) { - setCurrentListIndex(currentListIndex + 1); - } else if (direction === 'prev' && currentListIndex > 0) { - setCurrentListIndex(currentListIndex - 1); - } - }; - - const navigateScreenshots = (direction: 'next' | 'prev') => { - if (direction === 'next' && currentScreenshotIndex < screenshotKeys.length - 1) { - setCurrentScreenshotIndex(currentScreenshotIndex + 1); - } else if (direction === 'prev' && currentScreenshotIndex > 0) { - setCurrentScreenshotIndex(currentScreenshotIndex - 1); - } - }; const renderDataTable = ( data: any[], @@ -333,15 +358,127 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe isPaginatedList: boolean = false, isSchemaData: boolean = false ) => { - if (!isPaginatedList && data.length === 0) return null; - if (isPaginatedList && (listData.length === 0 || currentListIndex >= listData.length)) return null; + if (data.length === 0) return null; - const currentData = isPaginatedList ? listData[currentListIndex] : data; - const currentColumns = isPaginatedList ? listColumns[currentListIndex] : columns; + const shouldShowAsKeyValue = isSchemaData && !isSchemaTabular && data.length === 1; - if (!currentData || currentData.length === 0) return null; + if (title === '') { + return ( + <> + + + + + + {shouldShowAsKeyValue ? ( + <> + theme.palette.mode === 'dark' ? '#11111' : '#f8f9fa' + }} + > + Label + + theme.palette.mode === 'dark' ? '#11111' : '#f8f9fa' + }} + > + Value + + + ) : ( + columns.map((column) => ( + theme.palette.mode === 'dark' ? '#11111' : '#f8f9fa' + }} + > + {column} + + )) + )} + + + + {shouldShowAsKeyValue ? ( + columns.map((column) => ( + + + {column} + + + {data[0][column] === undefined || data[0][column] === "" + ? "-" + : (typeof data[0][column] === 'object' + ? JSON.stringify(data[0][column]) + : String(data[0][column]))} + + + )) + ) : ( + data.map((row, index) => ( + + {columns.map((column) => ( + + {row[column] === undefined || row[column] === "" + ? "-" + : (typeof row[column] === 'object' + ? JSON.stringify(row[column]) + : String(row[column]))} + + ))} + + )) + )} + +
+
+
+ + + - const shouldShowAsKeyValue = isSchemaData && !isSchemaTabular && currentData.length === 1; + + + + + ); + } return ( @@ -359,11 +496,11 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe - - - - {isPaginatedList && listData.length > 1 && ( - - - - - )} @@ -434,33 +540,27 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe {shouldShowAsKeyValue ? ( <> - theme.palette.mode === 'dark' ? '#11111' : '#f8f9fa' }} > Label - theme.palette.mode === 'dark' ? '#11111' : '#f8f9fa' }} > Value ) : ( - (isPaginatedList ? currentColumns : columns).map((column) => ( - ( + theme.palette.mode === 'dark' ? '#11111' : '#f8f9fa' }} > {column} @@ -471,24 +571,30 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe {shouldShowAsKeyValue ? ( - // Single schema entry - show as key-value pairs - currentColumns.map((column) => ( + columns.map((column) => ( {column} - {currentData[0][column] === undefined || currentData[0][column] === "" ? "-" : currentData[0][column]} + {data[0][column] === undefined || data[0][column] === "" + ? "-" + : (typeof data[0][column] === 'object' + ? JSON.stringify(data[0][column]) + : String(data[0][column]))} )) ) : ( - // Multiple entries or list data - show as table - currentData.map((row, index) => ( + data.map((row, index) => ( - {(isPaginatedList ? currentColumns : columns).map((column) => ( + {columns.map((column) => ( - {row[column] === undefined || row[column] === "" ? "-" : row[column]} + {row[column] === undefined || row[column] === "" + ? "-" + : (typeof row[column] === 'object' + ? JSON.stringify(row[column]) + : String(row[column]))} ))} @@ -601,23 +707,201 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe {!isLegacyData && ( <> - {renderDataTable( - schemaData, - schemaColumns, - t('run_content.captured_data.schema_title', 'Captured Texts'), - 'schema_data.csv', - 'schema_data.json', - false, - true + {schemaData.length > 0 && ( + + }> + + + {t('run_content.captured_data.schema_title', 'Captured Texts')} + + + + + {schemaKeys.length > 0 && ( + + {schemaKeys.map((key, idx) => ( + setCurrentSchemaIndex(idx)} + sx={{ + px: 3, + py: 1, + cursor: 'pointer', + backgroundColor: + currentSchemaIndex === idx + ? (theme) => theme.palette.mode === 'dark' + ? '#121111ff' + : '#e9ecef' + : 'transparent', + fontWeight: currentSchemaIndex === idx ? 600 : 400, + color: (theme) => theme.palette.mode === 'dark' ? '#fff' : '#000', + }} + > + {key} + + ))} + + )} + + {renderDataTable( + schemaDataByKey[schemaKeys[currentSchemaIndex]] || schemaData, + schemaColumnsByKey[schemaKeys[currentSchemaIndex]] || schemaColumns, + '', + `${schemaKeys[currentSchemaIndex] || 'schema_data'}.csv`, + `${schemaKeys[currentSchemaIndex] || 'schema_data'}.json`, + false, + true + )} + + )} - {listData.length > 0 && renderDataTable( - [], - [], - t('run_content.captured_data.list_title', 'Captured Lists'), - 'list_data.csv', - 'list_data.json', - true + {listData.length > 0 && ( + + }> + + + {t('run_content.captured_data.list_title', 'Captured Lists')} + + + + + + {listKeys.map((key, idx) => ( + setCurrentListIndex(idx)} + sx={{ + px: 3, + py: 1, + cursor: 'pointer', + backgroundColor: + currentListIndex === idx + ? (theme) => theme.palette.mode === 'dark' + ? '#121111ff' + : '#e9ecef' + : 'transparent', + fontWeight: currentListIndex === idx ? 600 : 400, + color: (theme) => theme.palette.mode === 'dark' ? '#fff' : '#000', + }} + > + {key} + + ))} + + + +
+ + + {(listColumns[currentListIndex] || []).map((column) => ( + theme.palette.mode === 'dark' ? '#11111' : '#f8f9fa' + }} + > + {column} + + ))} + + + + + {(listData[currentListIndex] || []).map((rowItem, idx) => ( + + {(listColumns[currentListIndex] || []).map((column) => ( + + {rowItem[column] === undefined || rowItem[column] === '' + ? '-' + : typeof rowItem[column] === 'object' + ? JSON.stringify(rowItem[column]) + : String(rowItem[column])} + + ))} + + ))} + +
+
+ + + + + + + + +
+
)} )} @@ -625,102 +909,73 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe )} {hasScreenshots && ( - <> - - } - aria-controls="screenshot-content" - id="screenshot-header" + + }> + + + {t('run_content.captured_screenshot.title', 'Captured Screenshots')} + + + + + - - - {t('run_content.captured_screenshot.title', 'Screenshots')} - - - - - - - - {screenshotKeys.length > 1 && ( - - - - - )} - - - - - {`Screenshot + {key} + + ))} - - - - + )} + + + + {screenshotKeys.length > 0 && ( + {`Screenshot + )} + + + )} diff --git a/src/context/browserSteps.tsx b/src/context/browserSteps.tsx index 0931813e..de7a6460 100644 --- a/src/context/browserSteps.tsx +++ b/src/context/browserSteps.tsx @@ -1,4 +1,6 @@ -import React, { createContext, useContext, useState } from 'react'; +import React, { createContext, useContext, useEffect, useRef, useState } from 'react'; +import { useSocketStore } from "./socket"; +import { useGlobalInfoStore } from "./globalInfo"; export interface TextStep { id: number; @@ -8,11 +10,13 @@ export interface TextStep { isShadow?: boolean; selectorObj: SelectorObject; actionId?: string; + name?: string; } -interface ScreenshotStep { +export interface ScreenshotStep { id: number; type: 'screenshot'; + name?: string; fullPage: boolean; actionId?: string; screenshotData?: string; @@ -21,6 +25,7 @@ interface ScreenshotStep { export interface ListStep { id: number; type: 'list'; + name?: string; listSelector: string; isShadow?: boolean; fields: { [key: string]: TextStep }; @@ -31,112 +36,331 @@ export interface ListStep { }; limit?: number; actionId?: string; + data?: any[]; } export type BrowserStep = TextStep | ScreenshotStep | ListStep; export interface SelectorObject { selector: string; + isShadow?: boolean; tag?: string; attribute?: string; - isShadow?: boolean; [key: string]: any; } interface BrowserStepsContextType { browserSteps: BrowserStep[]; - addTextStep: (label: string, data: string, selectorObj: SelectorObject, actionId: string) => void; - addListStep: (listSelector: string, fields: { [key: string]: TextStep }, listId: number, actionId: string, pagination?: { type: string; selector: string, isShadow?: boolean }, limit?: number, isShadow?: boolean) => void + addTextStep: ( + label: string, + data: string, + selectorObj: SelectorObject, + actionId: string + ) => void; + addListStep: ( + listSelector: string, + fields: { [key: string]: TextStep }, + listId: number, + actionId: string, + pagination?: { + type: string; + selector: string; + isShadow?: boolean; + }, + limit?: number, + isShadow?: boolean + ) => void; addScreenshotStep: (fullPage: boolean, actionId: string) => void; deleteBrowserStep: (id: number) => void; updateBrowserTextStepLabel: (id: number, newLabel: string) => void; - updateListTextFieldLabel: (listId: number, fieldKey: string, newLabel: string) => void; + updateListTextFieldLabel: ( + listId: number, + fieldKey: string, + newLabel: string + ) => void; updateListStepLimit: (listId: number, limit: number) => void; updateListStepData: (listId: number, extractedData: any[]) => void; + updateListStepName: (listId: number, name: string) => void; + updateScreenshotStepName: (id: number, name: string) => void; removeListTextField: (listId: number, fieldKey: string) => void; deleteStepsByActionId: (actionId: string) => void; updateScreenshotStepData: (id: number, screenshotData: string) => void; + emitActionForStep: (step: BrowserStep) => void; + emitForStepId: (actionId: string, nameOverride?: string) => void; } const BrowserStepsContext = createContext(undefined); export const BrowserStepsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { socket } = useSocketStore(); + const { currentTextGroupName } = useGlobalInfoStore(); const [browserSteps, setBrowserSteps] = useState([]); const [discardedFields, setDiscardedFields] = useState>(new Set()); + const browserStepsRef = useRef(browserSteps); + useEffect(() => { + browserStepsRef.current = browserSteps; + }, [browserSteps]); + + const currentTextGroupNameRef = useRef(currentTextGroupName); + + useEffect(() => { + currentTextGroupNameRef.current = currentTextGroupName; + }, [currentTextGroupName]); + + const getListSettingsObject = (listStep: ListStep) => { + const fields: Record = {}; + + Object.entries(listStep.fields).forEach(([id, field]) => { + if (field.selectorObj?.selector) { + fields[field.label] = { + selector: field.selectorObj.selector, + tag: field.selectorObj.tag, + attribute: field.selectorObj.attribute, + isShadow: field.selectorObj.isShadow + }; + } + }); + + return { + listSelector: listStep.listSelector, + fields: fields, + pagination: { + type: listStep.pagination?.type || "", + selector: listStep.pagination?.selector, + isShadow: listStep.isShadow + }, + limit: listStep.limit, + isShadow: listStep.isShadow + }; + }; + + const emitActionForStep = (step: BrowserStep) => { + if (!socket) return; + if (!step.actionId) return; + if (!socket.connected) return; + + let action = ""; + let settings: any = {}; + + // Always read the latest steps from the ref to prevent stale data + const latestSteps = browserStepsRef.current; + + if (step.type === "list") { + action = "scrapeList"; + const baseSettings = getListSettingsObject(step); + settings = { + ...baseSettings, + name: step.name || `List Data ${latestSteps.filter(s => s.type === "list").length}`, + }; + + } else if (step.type === "text") { + action = "scrapeSchema"; + + const freshTextSteps = latestSteps.filter( + (s): s is TextStep => s.type === "text" && s.actionId === step.actionId + ); + + // Build schema settings from text steps + const fieldSettings: Record< + string, + { + selector: string; + tag?: string; + [key: string]: any; + } + > = {}; + + freshTextSteps.forEach((textStep) => { + if (textStep.selectorObj?.selector && textStep.label) { + fieldSettings[textStep.label] = { + selector: textStep.selectorObj.selector, + tag: textStep.selectorObj.tag, + attribute: textStep.selectorObj.attribute, + isShadow: textStep.selectorObj.isShadow, + }; + } + }); + + settings = { + ...fieldSettings, + name: currentTextGroupNameRef.current || "Text Data", + }; + + } else if (step.type === "screenshot") { + action = "screenshot"; + + const freshScreenshot = latestSteps.find( + (s) => s.type === "screenshot" && s.actionId === step.actionId + ) as ScreenshotStep | undefined; + + settings = { + name: + step.name || + freshScreenshot?.name || + `Screenshot ${latestSteps.filter((s) => s.type === "screenshot").length}`, + type: "png", + caret: "hide", + scale: "device", + timeout: 30000, + fullPage: freshScreenshot?.fullPage ?? step.fullPage ?? true, + animations: "allow", + }; + } + + socket.emit("action", { action, actionId: step.actionId, settings }); + }; + + const emitForStepId = (actionId: string, nameOverride?: string) => { + const step = browserStepsRef.current.find(s => s.actionId === actionId); + if (!step) return; + + let enrichedStep = { ...step }; + + if (step.type === "text") { + enrichedStep = { ...step, name: currentTextGroupNameRef.current }; + } + + if (step.type === "screenshot") { + const freshScreenshot = browserStepsRef.current.find( + s => s.type === "screenshot" && s.actionId === actionId + ) as ScreenshotStep | undefined; + + if (freshScreenshot) { + enrichedStep = { ...freshScreenshot }; + + if (nameOverride && freshScreenshot.name !== nameOverride) { + enrichedStep.name = nameOverride; + browserStepsRef.current = browserStepsRef.current.map(s => + s.id === freshScreenshot.id ? { ...s, name: nameOverride } : s + ); + setBrowserSteps(prev => + prev.map(s => + s.id === freshScreenshot.id ? { ...s, name: nameOverride } : s + ) + ); + } + } + } + + if (step.type === "list") { + const freshList = browserStepsRef.current.find( + s => s.type === "list" && s.actionId === actionId + ) as ListStep | undefined; + + if (freshList) { + enrichedStep = { ...freshList }; + } + } + + emitActionForStep(enrichedStep); + }; + const addTextStep = (label: string, data: string, selectorObj: SelectorObject, actionId: string) => { - setBrowserSteps(prevSteps => [ - ...prevSteps, - { id: Date.now(), type: 'text', label, data, selectorObj, actionId } - ]); + setBrowserSteps((prevSteps) => { + const textCount = prevSteps.filter(s => s.type === 'text').length + 1; + const generatedLabel = label || `Label ${textCount}`; + return [ + ...prevSteps, + { + id: Date.now(), + type: "text", + label: generatedLabel, + data, + selectorObj, + actionId, + }, + ]; + }); }; const addListStep = ( - listSelector: string, - newFields: { [key: string]: TextStep }, - listId: number, - actionId: string, - pagination?: { type: string; selector: string; isShadow?: boolean }, + listSelector: string, + newFields: { [key: string]: TextStep }, + listId: number, + actionId: string, + pagination?: { + type: string; + selector: string; + isShadow?: boolean; + }, limit?: number, isShadow?: boolean ) => { - setBrowserSteps(prevSteps => { - const existingListStepIndex = prevSteps.findIndex(step => step.type === 'list' && step.id === listId); - + setBrowserSteps((prevSteps) => { + const existingListStepIndex = prevSteps.findIndex( + (step) => step.type === "list" && step.id === listId + ); + if (existingListStepIndex !== -1) { const updatedSteps = [...prevSteps]; - const existingListStep = updatedSteps[existingListStepIndex] as ListStep; + const existingListStep = updatedSteps[ + existingListStepIndex + ] as ListStep; // Preserve existing labels for fields - const mergedFields = Object.entries(newFields).reduce((acc, [key, field]) => { - if (!discardedFields.has(`${listId}-${key}`)) { - // If field exists, preserve its label - if (existingListStep.fields[key]) { - acc[key] = { - ...field, - label: existingListStep.fields[key].label, - actionId - }; - } else { - acc[key] = { - ...field, - actionId - }; + const mergedFields = Object.entries(newFields).reduce( + (acc, [key, field]) => { + if (!discardedFields.has(`${listId}-${key}`)) { + // If field exists, preserve its label + if (existingListStep.fields[key]) { + acc[key] = { + ...field, + label: existingListStep.fields[key].label, + actionId, + }; + } else { + acc[key] = { + ...field, + actionId, + }; + } } - } - return acc; - }, {} as { [key: string]: TextStep }); + return acc; + }, + {} as { [key: string]: TextStep } + ); updatedSteps[existingListStepIndex] = { ...existingListStep, + listSelector, fields: mergedFields, pagination: pagination || existingListStep.pagination, limit: limit, + isShadow: isShadow !== undefined ? isShadow : existingListStep.isShadow, actionId, - isShadow: isShadow !== undefined ? isShadow : existingListStep.isShadow }; return updatedSteps; } else { - const fieldsWithActionId = Object.entries(newFields).reduce((acc, [key, field]) => { - acc[key] = { - ...field, - actionId - }; - return acc; - }, {} as { [key: string]: TextStep }); + const fieldsWithActionId = Object.entries(newFields).reduce( + (acc, [key, field]) => { + acc[key] = { + ...field, + actionId, + }; + return acc; + }, + {} as { [key: string]: TextStep } + ); + const listCount = prevSteps.filter(s => s.type === 'list').length + 1; return [ ...prevSteps, - { - id: listId, - type: 'list', - listSelector, - fields: fieldsWithActionId, - pagination, - limit, + { + id: listId, + type: "list", + name: `List Data ${listCount}`, + listSelector, + fields: fieldsWithActionId, + pagination, + limit, actionId, - isShadow - } + }, ]; } }); @@ -165,22 +389,39 @@ export const BrowserStepsProvider: React.FC<{ children: React.ReactNode }> = ({ ); }; - const updateListTextFieldLabel = (listId: number, fieldKey: string, newLabel: string) => { - setBrowserSteps(prevSteps => - prevSteps.map(step => { - if (step.type === 'list' && step.id === listId) { - // Ensure deep copy of the fields object + const updateListTextFieldLabel = ( + listId: number, + fieldKey: string, + newLabel: string + ) => { + setBrowserSteps((prevSteps) => + prevSteps.map((step) => { + if (step.type === "list" && step.id === listId) { + const oldLabel = step.fields[fieldKey].label; + const updatedFields = { ...step.fields, [fieldKey]: { ...step.fields[fieldKey], - label: newLabel - } + label: newLabel, + }, }; + const updatedData = step.data?.map((row: any) => { + if (row[oldLabel] !== undefined) { + const { [oldLabel]: value, ...rest } = row; + return { + ...rest, + [newLabel]: value, + }; + } + return row; + }); + return { ...step, - fields: updatedFields + fields: updatedFields, + data: updatedData, }; } return step; @@ -194,7 +435,7 @@ export const BrowserStepsProvider: React.FC<{ children: React.ReactNode }> = ({ if (step.type === 'list' && step.id === listId) { return { ...step, - data: extractedData // Add the extracted data to the step + data: extractedData }; } return step; @@ -217,49 +458,83 @@ export const BrowserStepsProvider: React.FC<{ children: React.ReactNode }> = ({ }; const updateListStepLimit = (listId: number, limit: number) => { - setBrowserSteps(prevSteps => - prevSteps.map(step => { - if (step.type === 'list' && step.id === listId) { - return { - ...step, - limit: limit - }; - } - return step; - }) - ); - }; - - const removeListTextField = (listId: number, fieldKey: string) => { setBrowserSteps(prevSteps => prevSteps.map(step => { if (step.type === 'list' && step.id === listId) { - const { [fieldKey]: _, ...remainingFields } = step.fields; return { ...step, - fields: remainingFields + limit: limit }; } return step; }) ); - setDiscardedFields(prevDiscarded => new Set(prevDiscarded).add(`${listId}-${fieldKey}`)); + }; + + const updateListStepName = (listId: number, name: string) => { + setBrowserSteps((prevSteps) => + prevSteps.map((step) => { + if (step.type === "list" && step.id === listId) { + return { + ...step, + name: name, + }; + } + return step; + }) + ); + }; + + const updateScreenshotStepName = (id: number, name: string) => { + setBrowserSteps(prevSteps => { + const updated = prevSteps.map(step => + step.id === id && step.type === 'screenshot' + ? { ...step, name } + : step + ); + browserStepsRef.current = updated; + return updated; + }); + }; + + const removeListTextField = (listId: number, fieldKey: string) => { + setBrowserSteps((prevSteps) => + prevSteps.map((step) => { + if (step.type === "list" && step.id === listId) { + const { [fieldKey]: _, ...remainingFields } = step.fields; + return { + ...step, + fields: remainingFields, + }; + } + return step; + }) + ); + setDiscardedFields((prevDiscarded) => + new Set(prevDiscarded).add(`${listId}-${fieldKey}`) + ); }; return ( - + {children} ); diff --git a/src/context/globalInfo.tsx b/src/context/globalInfo.tsx index a9b20bfa..69969a09 100644 --- a/src/context/globalInfo.tsx +++ b/src/context/globalInfo.tsx @@ -139,6 +139,8 @@ interface GlobalInfo { setCurrentListActionId: (actionId: string) => void; currentScreenshotActionId: string; setCurrentScreenshotActionId: (actionId: string) => void; + currentTextGroupName: string; + setCurrentTextGroupName: (name: string) => void; isDOMMode: boolean; setIsDOMMode: (isDOMMode: boolean) => void; currentSnapshot: ProcessedSnapshot | null; @@ -173,6 +175,7 @@ class GlobalInfoStore implements Partial { currentTextActionId = ''; currentListActionId = ''; currentScreenshotActionId = ''; + currentTextGroupName = 'Text Data'; isDOMMode = false; currentSnapshot = null; }; @@ -282,6 +285,7 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => { const [currentTextActionId, setCurrentTextActionId] = useState(''); const [currentListActionId, setCurrentListActionId] = useState(''); const [currentScreenshotActionId, setCurrentScreenshotActionId] = useState(''); + const [currentTextGroupName, setCurrentTextGroupName] = useState('Text Data'); const [isDOMMode, setIsDOMMode] = useState(globalInfoStore.isDOMMode); const [currentSnapshot, setCurrentSnapshot] = useState(globalInfoStore.currentSnapshot); @@ -363,6 +367,8 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => { setCurrentListActionId, currentScreenshotActionId, setCurrentScreenshotActionId, + currentTextGroupName, + setCurrentTextGroupName, isDOMMode, setIsDOMMode, currentSnapshot, diff --git a/src/helpers/capturedElementHighlighter.ts b/src/helpers/capturedElementHighlighter.ts new file mode 100644 index 00000000..4eb7aa3e --- /dev/null +++ b/src/helpers/capturedElementHighlighter.ts @@ -0,0 +1,85 @@ +/** + * Helper class for managing persistent highlights of captured elements. + * Shows dotted highlights for elements that have been captured but not yet confirmed. + */ +class CapturedElementHighlighter { + private static readonly STYLE_ID = 'maxun-captured-elements-style'; + + /** + * Apply persistent dotted highlights to captured elements in the DOM iframe + * @param selectors Array of captured element selectors + */ + public applyHighlights(selectors: Array<{ selector: string }>): void { + const iframeDoc = this.getIframeDocument(); + if (!iframeDoc) return; + + // Remove existing highlights + this.clearHighlights(); + + // Create CSS rules for each captured selector + const cssRules: string[] = []; + + selectors.forEach(({ selector }) => { + const cssSelector = this.getCSSSelector(selector); + + if (cssSelector) { + cssRules.push(` + ${cssSelector} { + outline: 2px dotted #ff00c3 !important; + outline-offset: 2px !important; + background-color: rgba(255, 0, 195, 0.08) !important; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.5) !important; + } + `); + } + }); + + // Inject style element + if (cssRules.length > 0) { + const styleElement = iframeDoc.createElement('style'); + styleElement.id = CapturedElementHighlighter.STYLE_ID; + styleElement.textContent = cssRules.join('\n'); + iframeDoc.head.appendChild(styleElement); + } + } + + /** + * Clear all persistent highlights from the DOM iframe + */ + public clearHighlights(): void { + const iframeDoc = this.getIframeDocument(); + if (!iframeDoc) return; + + const existingStyle = iframeDoc.getElementById(CapturedElementHighlighter.STYLE_ID); + if (existingStyle) { + existingStyle.remove(); + } + } + + /** + * Get the iframe document + */ + private getIframeDocument(): Document | null { + const iframeElement = document.querySelector('#dom-browser-iframe') as HTMLIFrameElement; + return iframeElement?.contentDocument || null; + } + + /** + * Convert selector to CSS format for highlighting + */ + private getCSSSelector(selector: string): string { + // Handle XPath selectors by extracting data-mx-id + if (selector.startsWith('//') || selector.startsWith('(//')) { + const mxIdMatch = selector.match(/data-mx-id='([^']+)'/); + if (mxIdMatch) { + return `[data-mx-id='${mxIdMatch[1]}']`; + } + return ''; + } + + // Already a CSS selector + return selector; + } +} + +export const capturedElementHighlighter = new CapturedElementHighlighter(); From eafe11aef433e0b38119125530c2063274727143 Mon Sep 17 00:00:00 2001 From: Rohit Rajan Date: Mon, 20 Oct 2025 20:28:38 +0530 Subject: [PATCH 02/28] feat: recorder revamp core changes --- maxun-core/src/interpret.ts | 205 +++++++++++++++++++++++++++---- maxun-core/src/preprocessor.ts | 2 + maxun-core/src/types/workflow.ts | 4 +- 3 files changed, 184 insertions(+), 27 deletions(-) diff --git a/maxun-core/src/interpret.ts b/maxun-core/src/interpret.ts index 2937c40b..08efb120 100644 --- a/maxun-core/src/interpret.ts +++ b/maxun-core/src/interpret.ts @@ -73,6 +73,15 @@ export default class Interpreter extends EventEmitter { private cumulativeResults: Record[] = []; + private namedResults: Record> = {}; + + private screenshotCounter: number = 0; + + private serializableDataByType: Record> = { + scrapeList: {}, + scrapeSchema: {} + }; + constructor(workflow: WorkflowFile, options?: Partial) { super(); this.workflow = workflow.workflow; @@ -402,15 +411,37 @@ export default class Interpreter extends EventEmitter { * Beware of false linter errors - here, we know better! */ const wawActions: Record void> = { - screenshot: async (params: PageScreenshotOptions) => { + screenshot: async ( + params: PageScreenshotOptions, + nameOverride?: string + ) => { if (this.options.debugChannel?.setActionType) { - this.options.debugChannel.setActionType('screenshot'); + this.options.debugChannel.setActionType("screenshot"); } const screenshotBuffer = await page.screenshot({ - ...params, path: undefined, + ...params, + path: undefined, }); - await this.options.binaryCallback(screenshotBuffer, 'image/png'); + + const explicitName = (typeof nameOverride === 'string' && nameOverride.trim().length > 0) ? nameOverride.trim() : null; + let screenshotName: string; + + if (explicitName) { + screenshotName = explicitName; + } else { + this.screenshotCounter += 1; + screenshotName = `Screenshot ${this.screenshotCounter}`; + } + + await this.options.binaryCallback( + { + name: screenshotName, + data: screenshotBuffer, + mimeType: "image/png", + }, + "image/png" + ); }, enqueueLinks: async (selector: string) => { if (this.options.debugChannel?.setActionType) { @@ -476,21 +507,55 @@ export default class Interpreter extends EventEmitter { this.cumulativeResults = []; } - if (this.cumulativeResults.length === 0) { - this.cumulativeResults.push({}); - } - - const mergedResult = this.cumulativeResults[0]; const resultToProcess = Array.isArray(scrapeResult) ? scrapeResult[0] : scrapeResult; - Object.entries(resultToProcess).forEach(([key, value]) => { - if (value !== undefined) { - mergedResult[key] = value; + if (this.cumulativeResults.length === 0) { + const newRow = {}; + Object.entries(resultToProcess).forEach(([key, value]) => { + if (value !== undefined) { + newRow[key] = value; + } + }); + this.cumulativeResults.push(newRow); + } else { + const lastRow = this.cumulativeResults[this.cumulativeResults.length - 1]; + const newResultKeys = Object.keys(resultToProcess).filter(key => resultToProcess[key] !== undefined); + const hasRepeatedKeys = newResultKeys.some(key => lastRow.hasOwnProperty(key)); + + if (hasRepeatedKeys) { + const newRow = {}; + Object.entries(resultToProcess).forEach(([key, value]) => { + if (value !== undefined) { + newRow[key] = value; + } + }); + this.cumulativeResults.push(newRow); + } else { + Object.entries(resultToProcess).forEach(([key, value]) => { + if (value !== undefined) { + lastRow[key] = value; + } + }); } - }); + } - console.log("Updated merged result:", mergedResult); - await this.options.serializableCallback([mergedResult]); + const actionType = "scrapeSchema"; + const actionName = (schema as any).__name || "Texts"; + + if (!this.namedResults[actionType]) this.namedResults[actionType] = {}; + this.namedResults[actionType][actionName] = this.cumulativeResults; + + if (!this.serializableDataByType[actionType]) this.serializableDataByType[actionType] = {}; + if (!this.serializableDataByType[actionType][actionName]) { + this.serializableDataByType[actionType][actionName] = []; + } + + this.serializableDataByType[actionType][actionName] = [...this.cumulativeResults]; + + await this.options.serializableCallback({ + scrapeList: this.serializableDataByType.scrapeList, + scrapeSchema: this.serializableDataByType.scrapeSchema + }); }, scrapeList: async (config: { listSelector: string, fields: any, limit?: number, pagination: any }) => { @@ -508,18 +573,62 @@ export default class Interpreter extends EventEmitter { return; } - await this.ensureScriptsLoaded(page); + try { + await this.ensureScriptsLoaded(page); - if (this.options.debugChannel?.incrementScrapeListIndex) { - this.options.debugChannel.incrementScrapeListIndex(); - } + if (this.options.debugChannel?.incrementScrapeListIndex) { + this.options.debugChannel.incrementScrapeListIndex(); + } - if (!config.pagination) { - const scrapeResults: Record[] = await page.evaluate((cfg) => window.scrapeList(cfg), config); - await this.options.serializableCallback(scrapeResults); - } else { - const scrapeResults: Record[] = await this.handlePagination(page, config); - await this.options.serializableCallback(scrapeResults); + let scrapeResults = []; + + if (!config.pagination) { + scrapeResults = await page.evaluate((cfg) => { + try { + return window.scrapeList(cfg); + } catch (error) { + console.warn('ScrapeList evaluation failed:', error.message); + return []; + } + }, config); + } else { + scrapeResults = await this.handlePagination(page, config); + } + + if (!Array.isArray(scrapeResults)) { + scrapeResults = []; + } + + const actionType = "scrapeList"; + const actionName = (config as any).__name || "List"; + + if (!this.serializableDataByType[actionType]) this.serializableDataByType[actionType] = {}; + if (!this.serializableDataByType[actionType][actionName]) { + this.serializableDataByType[actionType][actionName] = []; + } + + this.serializableDataByType[actionType][actionName].push(...scrapeResults); + + await this.options.serializableCallback({ + scrapeList: this.serializableDataByType.scrapeList, + scrapeSchema: this.serializableDataByType.scrapeSchema + }); + } catch (error) { + console.error('ScrapeList action failed completely:', error.message); + + const actionType = "scrapeList"; + const actionName = (config as any).__name || "List"; + + if (!this.namedResults[actionType]) this.namedResults[actionType] = {}; + this.namedResults[actionType][actionName] = []; + + if (!this.serializableDataByType[actionType]) this.serializableDataByType[actionType] = {}; + this.serializableDataByType[actionType][actionName] = []; + + await this.options.serializableCallback({ + scrapeList: this.serializableDataByType.scrapeList, + scrapeSchema: this.serializableDataByType.scrapeSchema + }); } }, @@ -595,12 +704,56 @@ export default class Interpreter extends EventEmitter { for (const step of steps) { + if (this.isAborted) { + this.log('Workflow aborted during step execution', Level.WARN); + return; + } + this.log(`Launching ${String(step.action)}`, Level.LOG); + let stepName: string | null = null; + try { + const debug = this.options.debugChannel; + if (debug?.setActionType) { + debug.setActionType(String(step.action)); + } + + if ((step as any)?.name) { + stepName = (step as any).name; + } else if ( + Array.isArray((step as any)?.args) && + (step as any).args.length > 0 && + typeof (step as any).args[0] === "object" && + "__name" in (step as any).args[0] + ) { + stepName = (step as any).args[0].__name; + } else if ( + typeof (step as any)?.args === "object" && + step?.args !== null && + "__name" in (step as any).args + ) { + stepName = (step as any).args.__name; + } + + if (!stepName) { + stepName = String(step.action); + } + + if (debug && typeof (debug as any).setActionName === "function") { + (debug as any).setActionName(stepName); + } + } catch (err) { + this.log(`Failed to set action name/type: ${(err as Error).message}`, Level.WARN); + } + if (step.action in wawActions) { // "Arrayifying" here should not be needed (TS + syntax checker - only arrays; but why not) const params = !step.args || Array.isArray(step.args) ? step.args : [step.args]; - await wawActions[step.action as CustomFunctions](...(params ?? [])); + if (step.action === 'screenshot') { + await (wawActions.screenshot as any)(...(params ?? []), stepName ?? undefined); + } else { + await wawActions[step.action as CustomFunctions](...(params ?? [])); + } } else { if (this.options.debugChannel?.setActionType) { this.options.debugChannel.setActionType(String(step.action)); diff --git a/maxun-core/src/preprocessor.ts b/maxun-core/src/preprocessor.ts index 7957c06b..3d4307a9 100644 --- a/maxun-core/src/preprocessor.ts +++ b/maxun-core/src/preprocessor.ts @@ -36,6 +36,8 @@ export default class Preprocessor { what: Joi.array().items({ action: Joi.string().required(), args: Joi.array().items(Joi.any()), + name: Joi.string(), + actionId: Joi.string() }).required(), }), ).required(), diff --git a/maxun-core/src/types/workflow.ts b/maxun-core/src/types/workflow.ts index f7cf180d..08b76ef9 100644 --- a/maxun-core/src/types/workflow.ts +++ b/maxun-core/src/types/workflow.ts @@ -32,7 +32,9 @@ export type CustomFunctions = 'scrape' | 'scrapeSchema' | 'scroll' | 'screenshot export type What = { action: MethodNames | CustomFunctions, - args?: any[] + args?: any[], + name?: string, + actionId?: string }; export type PageState = Partial; From 5be2b3175baf13bfa569ba27869f12a0d38a4cb3 Mon Sep 17 00:00:00 2001 From: Rohit Rajan Date: Tue, 21 Oct 2025 00:43:08 +0530 Subject: [PATCH 03/28] feat: recorder revamp server changes --- server/src/api/record.ts | 199 ++++++++++----- .../src/browser-management/inputHandlers.ts | 72 +++++- server/src/pgboss-worker.ts | 167 ++++++++----- .../workflow-management/classes/Generator.ts | 158 ++++++++++-- .../classes/Interpreter.ts | 231 ++++++++++++------ .../integrations/airtable.ts | 117 ++++++--- .../integrations/gsheet.ts | 86 ++++--- .../workflow-management/scheduler/index.ts | 206 +++++++++++----- src/context/browserSteps.tsx | 12 +- 9 files changed, 879 insertions(+), 369 deletions(-) diff --git a/server/src/api/record.ts b/server/src/api/record.ts index 5d9a68cd..29d1f261 100644 --- a/server/src/api/record.ts +++ b/server/src/api/record.ts @@ -1,16 +1,14 @@ -import { readFile, readFiles } from "../workflow-management/storage"; import { Router, Request, Response } from 'express'; import { chromium } from "playwright-extra"; import stealthPlugin from 'puppeteer-extra-plugin-stealth'; import { requireAPIKey } from "../middlewares/api"; import Robot from "../models/Robot"; import Run from "../models/Run"; -const router = Router(); import { getDecryptedProxyConfig } from "../routes/proxy"; import { v4 as uuid } from "uuid"; import { createRemoteBrowserForRun, destroyRemoteBrowser } from "../browser-management/controller"; import logger from "../logger"; -import { browserPool } from "../server"; +import { browserPool, io as serverIo } from "../server"; import { io, Socket } from "socket.io-client"; import { BinaryOutputService } from "../storage/mino"; import { AuthenticatedRequest } from "../routes/record" @@ -20,8 +18,11 @@ import { WorkflowFile } from "maxun-core"; import { googleSheetUpdateTasks, processGoogleSheetUpdates } from "../workflow-management/integrations/gsheet"; import { airtableUpdateTasks, processAirtableUpdates } from "../workflow-management/integrations/airtable"; import { sendWebhook } from "../routes/webhook"; + chromium.use(stealthPlugin()); +const router = Router(); + const formatRecording = (recordingData: any) => { const recordingMeta = recordingData.recording_meta; const workflow = recordingData.recording.workflow || []; @@ -334,7 +335,7 @@ function formatRunResponse(run: any) { id: run.id, status: run.status, name: run.name, - robotId: run.robotMetaId, // Renaming robotMetaId to robotId + robotId: run.robotMetaId, startedAt: run.startedAt, finishedAt: run.finishedAt, runId: run.runId, @@ -342,20 +343,20 @@ function formatRunResponse(run: any) { runByScheduleId: run.runByScheduleId, runByAPI: run.runByAPI, data: { - textData: [], - listData: [] + textData: {}, + listData: {} }, screenshots: [] as any[], }; - if (run.serializableOutput) { - if (run.serializableOutput.scrapeSchema && run.serializableOutput.scrapeSchema.length > 0) { - formattedRun.data.textData = run.serializableOutput.scrapeSchema; - } + const output = run.serializableOutput || {}; - if (run.serializableOutput.scrapeList && run.serializableOutput.scrapeList.length > 0) { - formattedRun.data.listData = run.serializableOutput.scrapeList; - } + if (output.scrapeSchema && typeof output.scrapeSchema === 'object') { + formattedRun.data.textData = output.scrapeSchema; + } + + if (output.scrapeList && typeof output.scrapeList === 'object') { + formattedRun.data.listData = output.scrapeList; } if (run.binaryOutput) { @@ -505,10 +506,30 @@ async function createWorkflowAndStoreMetadata(id: string, userId: string) { runByAPI: true, serializableOutput: {}, binaryOutput: {}, + retryCount: 0 }); const plainRun = run.toJSON(); + try { + const runStartedData = { + runId: plainRun.runId, + robotMetaId: plainRun.robotMetaId, + robotName: plainRun.name, + status: 'running', + startedAt: plainRun.startedAt, + runByUserId: plainRun.runByUserId, + runByScheduleId: plainRun.runByScheduleId, + runByAPI: plainRun.runByAPI || false, + browserId: plainRun.browserId + }; + + serverIo.of('/queued-run').to(`user-${userId}`).emit('run-started', runStartedData); + logger.log('info', `API run started notification sent for run: ${plainRun.runId} to user-${userId}`); + } catch (socketError: any) { + logger.log('warn', `Failed to send run-started notification for API run ${plainRun.runId}: ${socketError.message}`); + } + return { browserId, runId: plainRun.runId, @@ -525,6 +546,29 @@ async function createWorkflowAndStoreMetadata(id: string, userId: string) { } } +async function triggerIntegrationUpdates(runId: string, robotMetaId: string): Promise { + try { + googleSheetUpdateTasks[runId] = { + robotId: robotMetaId, + runId: runId, + status: 'pending', + retries: 5, + }; + + airtableUpdateTasks[runId] = { + robotId: robotMetaId, + runId: runId, + status: 'pending', + retries: 5, + }; + + processAirtableUpdates().catch(err => logger.log('error', `Airtable update error: ${err.message}`)); + processGoogleSheetUpdates().catch(err => logger.log('error', `Google Sheets update error: ${err.message}`)); + } catch (err: any) { + logger.log('error', `Failed to update integrations for run: ${runId}: ${err.message}`); + } +} + async function readyForRunHandler(browserId: string, id: string, userId: string){ try { const result = await executeRun(id, userId); @@ -565,6 +609,8 @@ function AddGeneratedFlags(workflow: WorkflowFile) { }; async function executeRun(id: string, userId: string) { + let browser: any = null; + try { const run = await Run.findOne({ where: { runId: id } }); if (!run) { @@ -576,6 +622,27 @@ async function executeRun(id: string, userId: string) { const plainRun = run.toJSON(); + if (run.status === 'aborted' || run.status === 'aborting') { + logger.log('info', `API Run ${id} has status ${run.status}, skipping execution`); + return { success: true }; + } + + if (run.status === 'queued') { + logger.log('info', `API Run ${id} has status 'queued', skipping stale execution - will be handled by recovery`); + return { success: true }; + } + + const retryCount = plainRun.retryCount || 0; + if (retryCount >= 3) { + logger.log('warn', `API Run ${id} has exceeded max retries (${retryCount}/3), marking as failed`); + await run.update({ + status: 'failed', + finishedAt: new Date().toLocaleString(), + log: `Max retries exceeded (${retryCount}/3) - Run permanently failed` + }); + return { success: false, error: 'Max retries exceeded' }; + } + const recording = await Robot.findOne({ where: { 'recording_meta.id': plainRun.robotMetaId }, raw: true }); if (!recording) { return { @@ -586,7 +653,7 @@ async function executeRun(id: string, userId: string) { plainRun.status = 'running'; - const browser = browserPool.getRemoteBrowser(plainRun.browserId); + browser = browserPool.getRemoteBrowser(plainRun.browserId); if (!browser) { throw new Error('Could not access browser'); } @@ -597,41 +664,33 @@ async function executeRun(id: string, userId: string) { } const workflow = AddGeneratedFlags(recording.recording); - - browser.interpreter.setRunId(id); + + browser.interpreter.setRunId(plainRun.runId); const interpretationInfo = await browser.interpreter.InterpretRecording( workflow, currentPage, (newPage: Page) => currentPage = newPage, plainRun.interpreterSettings ); + const binaryOutputService = new BinaryOutputService('maxun-run-screenshots'); + const uploadedBinaryOutput = await binaryOutputService.uploadAndStoreBinaryOutput(run, interpretationInfo.binaryOutput); + await destroyRemoteBrowser(plainRun.browserId, userId); const updatedRun = await run.update({ status: 'success', finishedAt: new Date().toLocaleString(), log: interpretationInfo.log.join('\n'), + binaryOutput: uploadedBinaryOutput, }); - // Upload binary output to MinIO and update run with MinIO URLs - const finalRun = await Run.findOne({ where: { runId: id } }); - if (finalRun && finalRun.binaryOutput && Object.keys(finalRun.binaryOutput).length > 0) { - try { - const binaryService = new BinaryOutputService('maxun-run-screenshots'); - await binaryService.uploadAndStoreBinaryOutput(finalRun, finalRun.binaryOutput); - logger.log('info', `Uploaded binary output to MinIO for API run ${id}`); - } catch (minioError: any) { - logger.log('error', `Failed to upload binary output to MinIO for API run ${id}: ${minioError.message}`); - } - } - let totalSchemaItemsExtracted = 0; let totalListItemsExtracted = 0; let extractedScreenshotsCount = 0; - if (finalRun) { - if (finalRun.serializableOutput) { - if (finalRun.serializableOutput.scrapeSchema) { - Object.values(finalRun.serializableOutput.scrapeSchema).forEach((schemaResult: any) => { + if (updatedRun) { + if (updatedRun.dataValues.serializableOutput) { + if (updatedRun.dataValues.serializableOutput.scrapeSchema) { + Object.values(updatedRun.dataValues.serializableOutput.scrapeSchema).forEach((schemaResult: any) => { if (Array.isArray(schemaResult)) { totalSchemaItemsExtracted += schemaResult.length; } else if (schemaResult && typeof schemaResult === 'object') { @@ -640,8 +699,8 @@ async function executeRun(id: string, userId: string) { }); } - if (finalRun.serializableOutput.scrapeList) { - Object.values(finalRun.serializableOutput.scrapeList).forEach((listResult: any) => { + if (updatedRun.dataValues.serializableOutput.scrapeList) { + Object.values(updatedRun.dataValues.serializableOutput.scrapeList).forEach((listResult: any) => { if (Array.isArray(listResult)) { totalListItemsExtracted += listResult.length; } @@ -649,8 +708,8 @@ async function executeRun(id: string, userId: string) { } } - if (finalRun.binaryOutput) { - extractedScreenshotsCount = Object.keys(finalRun.binaryOutput).length; + if (updatedRun.dataValues.binaryOutput) { + extractedScreenshotsCount = Object.keys(updatedRun.dataValues.binaryOutput).length; } } @@ -667,17 +726,31 @@ async function executeRun(id: string, userId: string) { } ) + const parsedOutput = + typeof updatedRun.dataValues.serializableOutput === "string" + ? JSON.parse(updatedRun.dataValues.serializableOutput) + : updatedRun.dataValues.serializableOutput || {}; + + const parsedList = + typeof parsedOutput.scrapeList === "string" + ? JSON.parse(parsedOutput.scrapeList) + : parsedOutput.scrapeList || {}; + + const parsedSchema = + typeof parsedOutput.scrapeSchema === "string" + ? JSON.parse(parsedOutput.scrapeSchema) + : parsedOutput.scrapeSchema || {}; + const webhookPayload = { robot_id: plainRun.robotMetaId, run_id: plainRun.runId, robot_name: recording.recording_meta.name, - status: 'success', + status: "success", started_at: plainRun.startedAt, finished_at: new Date().toLocaleString(), extracted_data: { - captured_texts: finalRun?.serializableOutput?.scrapeSchema ? Object.values(finalRun.serializableOutput.scrapeSchema).flat() : [], - captured_lists: finalRun?.serializableOutput?.scrapeList || {}, - total_rows: totalRowsExtracted, + captured_texts: parsedSchema || {}, + captured_lists: parsedList || {}, captured_texts_count: totalSchemaItemsExtracted, captured_lists_count: totalListItemsExtracted, screenshots_count: extractedScreenshotsCount @@ -685,7 +758,7 @@ async function executeRun(id: string, userId: string) { metadata: { browser_id: plainRun.browserId, user_id: userId, - } + }, }; try { @@ -695,26 +768,7 @@ async function executeRun(id: string, userId: string) { logger.log('error', `Failed to send webhooks for run ${plainRun.runId}: ${webhookError.message}`); } - try { - googleSheetUpdateTasks[id] = { - robotId: plainRun.robotMetaId, - runId: id, - status: 'pending', - retries: 5, - }; - - airtableUpdateTasks[id] = { - robotId: plainRun.robotMetaId, - runId: id, - status: 'pending', - retries: 5, - }; - - processAirtableUpdates().catch(err => logger.log('error', `Airtable update error: ${err.message}`)); - processGoogleSheetUpdates().catch(err => logger.log('error', `Google Sheets update error: ${err.message}`)); - } catch (err: any) { - logger.log('error', `Failed to update Google Sheet for run: ${plainRun.runId}: ${err.message}`); - } + await triggerIntegrationUpdates(plainRun.runId, plainRun.robotMetaId); return { success: true, @@ -728,8 +782,29 @@ async function executeRun(id: string, userId: string) { await run.update({ status: 'failed', finishedAt: new Date().toLocaleString(), + log: (run.log ? run.log + '\n' : '') + `Error: ${error.message}\n` + (error.stack ? error.stack : ''), }); + try { + const recording = await Robot.findOne({ where: { 'recording_meta.id': run.robotMetaId }, raw: true }); + const failureData = { + runId: run.runId, + robotMetaId: run.robotMetaId, + robotName: recording ? recording.recording_meta.name : 'Unknown Robot', + status: 'failed', + finishedAt: new Date().toLocaleString(), + runByUserId: run.runByUserId, + runByScheduleId: run.runByScheduleId, + runByAPI: run.runByAPI || false, + browserId: run.browserId + }; + + serverIo.of('/queued-run').to(`user-${userId}`).emit('run-completed', failureData); + logger.log('info', `API run permanently failed notification sent for run: ${run.runId} to user-${userId}`); + } catch (socketError: any) { + logger.log('warn', `Failed to send run-completed notification for permanently failed API run ${run.runId}: ${socketError.message}`); + } + const recording = await Robot.findOne({ where: { 'recording_meta.id': run.robotMetaId }, raw: true }); // Trigger webhooks for run failure @@ -814,7 +889,7 @@ async function waitForRunCompletion(runId: string, interval: number = 2000) { if (!run) throw new Error('Run not found'); if (run.status === 'success') { - return run; + return run.toJSON(); } else if (run.status === 'failed') { throw new Error('Run failed'); } diff --git a/server/src/browser-management/inputHandlers.ts b/server/src/browser-management/inputHandlers.ts index c014af3d..3e58664e 100644 --- a/server/src/browser-management/inputHandlers.ts +++ b/server/src/browser-management/inputHandlers.ts @@ -65,6 +65,7 @@ const handleWrapper = async ( interface CustomActionEventData { action: CustomActions; settings: any; + actionId?: string; } /** @@ -84,23 +85,24 @@ const onGenerateAction = async (customActionEventData: CustomActionEventData, us * @param page The active page * @param action The custom action * @param settings The custom action settings + * @param actionId Optional action ID for tracking and updating specific actions * @category BrowserManagement */ const handleGenerateAction = - async (activeBrowser: RemoteBrowser, page: Page, { action, settings }: CustomActionEventData) => { - try { - if (page.isClosed()) { - logger.log("debug", `Ignoring generate action event: page is closed`); - return; - } + async (activeBrowser: RemoteBrowser, page: Page, { action, settings, actionId }: CustomActionEventData) => { + try { + if (page.isClosed()) { + logger.log("debug", `Ignoring generate action event: page is closed`); + return; + } - const generator = activeBrowser.generator; - await generator.customAction(action, settings, page); - } catch (e) { - const { message } = e as Error; - logger.log("warn", `Error handling generate action event: ${message}`); - } + const generator = activeBrowser.generator; + await generator.customAction(action, actionId || '', settings, page); + } catch (e) { + const { message } = e as Error; + logger.log("warn", `Error handling generate action event: ${message}`); } + } /** * A wrapper function for handling mousedown event. @@ -819,6 +821,49 @@ const onDOMWorkflowPair = async ( await handleWrapper(handleWorkflowPair, userId, data); }; +/** + * Handles the remove action event. + * This is called when a user discards a capture action (list or text) that was already emitted to the backend. + * @param activeBrowser - the active remote browser instance + * @param page - the active page of the remote browser + * @param data - the data containing the actionId to remove + * @category BrowserManagement + */ +const handleRemoveAction = async ( + activeBrowser: RemoteBrowser, + page: Page, + data: { actionId: string } +) => { + try { + const { actionId } = data; + const generator = activeBrowser.generator; + const removed = generator.removeAction(actionId); + + if (removed) { + logger.log("info", `Action ${actionId} successfully removed from workflow`); + } else { + logger.log("debug", `Action ${actionId} not found in workflow`); + } + } catch (e) { + const { message } = e as Error; + logger.log("warn", `Error handling remove action event: ${message}`); + } +}; + +/** + * A wrapper function for handling the remove action event. + * @param data - the data containing the actionId to remove + * @param userId - the user ID + * @category HelperFunctions + */ +const onRemoveAction = async ( + data: { actionId: string }, + userId: string +) => { + logger.log("debug", "Handling remove action event emitted from client"); + await handleWrapper(handleRemoveAction, userId, data); +}; + /** * Helper function for registering the handlers onto established websocket connection. * Registers various input handlers. @@ -831,7 +876,7 @@ const onDOMWorkflowPair = async ( * @returns void * @category BrowserManagement */ -const registerInputHandlers = (socket: Socket, userId: string) => { +const registerInputHandlers = (socket: Socket, userId: string) => { // Register handlers with the socket socket.on("input:mousedown", (data) => onMousedown(data, userId)); socket.on("input:wheel", (data) => onWheel(data, userId)); @@ -847,6 +892,7 @@ const registerInputHandlers = (socket: Socket, userId: string) => { socket.on("input:time", (data) => onTimeSelection(data, userId)); socket.on("input:datetime-local", (data) => onDateTimeLocalSelection(data, userId)); socket.on("action", (data) => onGenerateAction(data, userId)); + socket.on("removeAction", (data) => onRemoveAction(data, userId)); socket.on("dom:click", (data) => onDOMClickAction(data, userId)); socket.on("dom:keypress", (data) => onDOMKeyboardAction(data, userId)); diff --git a/server/src/pgboss-worker.ts b/server/src/pgboss-worker.ts index c1511b2f..b9f41100 100644 --- a/server/src/pgboss-worker.ts +++ b/server/src/pgboss-worker.ts @@ -149,14 +149,20 @@ async function processRunExecution(job: Job) { let browser = browserPool.getRemoteBrowser(browserId); const browserWaitStart = Date.now(); let lastLogTime = 0; + let pollAttempts = 0; + const MAX_POLL_ATTEMPTS = 15; - while (!browser && (Date.now() - browserWaitStart) < BROWSER_INIT_TIMEOUT) { + while (!browser && (Date.now() - browserWaitStart) < BROWSER_INIT_TIMEOUT && pollAttempts < MAX_POLL_ATTEMPTS) { const currentTime = Date.now(); + pollAttempts++; const browserStatus = browserPool.getBrowserStatus(browserId); if (browserStatus === null) { throw new Error(`Browser slot ${browserId} does not exist in pool`); } + if (browserStatus === "failed") { + throw new Error(`Browser ${browserId} initialization failed`); + } if (currentTime - lastLogTime > 10000) { logger.log('info', `Browser ${browserId} not ready yet (status: ${browserStatus}), waiting... (${Math.round((currentTime - browserWaitStart) / 1000)}s elapsed)`); @@ -183,17 +189,25 @@ async function processRunExecution(job: Job) { } const isRunAborted = async (): Promise => { - const currentRun = await Run.findOne({ where: { runId: data.runId } }); - return currentRun ? (currentRun.status === 'aborted' || currentRun.status === 'aborting') : false; + try { + const currentRun = await Run.findOne({ where: { runId: data.runId } }); + return currentRun ? (currentRun.status === 'aborted' || currentRun.status === 'aborting') : false; + } catch (error: any) { + logger.log('error', `Error checking if run ${data.runId} is aborted: ${error.message}`); + return false; + } }; let currentPage = browser.getCurrentPage(); const pageWaitStart = Date.now(); let lastPageLogTime = 0; + let pageAttempts = 0; + const MAX_PAGE_ATTEMPTS = 15; - while (!currentPage && (Date.now() - pageWaitStart) < BROWSER_PAGE_TIMEOUT) { + while (!currentPage && (Date.now() - pageWaitStart) < BROWSER_PAGE_TIMEOUT && pageAttempts < MAX_PAGE_ATTEMPTS) { const currentTime = Date.now(); + pageAttempts++; if (currentTime - lastPageLogTime > 5000) { logger.log('info', `Page not ready for browser ${browserId}, waiting... (${Math.round((currentTime - pageWaitStart) / 1000)}s elapsed)`); @@ -209,6 +223,26 @@ async function processRunExecution(job: Job) { } logger.log('info', `Starting workflow execution for run ${data.runId}`); + + await run.update({ + status: 'running', + log: 'Workflow execution started' + }); + + try { + const startedData = { + runId: data.runId, + robotMetaId: plainRun.robotMetaId, + robotName: recording.recording_meta.name, + status: 'running', + startedAt: new Date().toLocaleString() + }; + + serverIo.of(browserId).emit('run-started', startedData); + serverIo.of('/queued-run').to(`user-${data.userId}`).emit('run-started', startedData); + } catch (socketError: any) { + logger.log('warn', `Failed to send run-started notification for API run ${plainRun.runId}: ${socketError.message}`); + } // Execute the workflow const workflow = AddGeneratedFlags(recording.recording); @@ -231,6 +265,19 @@ async function processRunExecution(job: Job) { } logger.log('info', `Workflow execution completed for run ${data.runId}`); + + const binaryOutputService = new BinaryOutputService('maxuncloud-run-screenshots'); + const uploadedBinaryOutput = await binaryOutputService.uploadAndStoreBinaryOutput( + run, + interpretationInfo.binaryOutput + ); + + // Get the already persisted and credit-validated data from the run record + const finalRun = await Run.findByPk(run.id); + const categorizedOutput = { + scrapeSchema: finalRun?.serializableOutput?.scrapeSchema || {}, + scrapeList: finalRun?.serializableOutput?.scrapeList || {} + }; if (await isRunAborted()) { logger.log('info', `Run ${data.runId} was aborted while processing results, not updating status`); @@ -240,48 +287,39 @@ async function processRunExecution(job: Job) { await run.update({ status: 'success', finishedAt: new Date().toLocaleString(), - log: interpretationInfo.log.join('\n') + log: interpretationInfo.log.join('\n'), + serializableOutput: JSON.parse(JSON.stringify({ + scrapeSchema: categorizedOutput.scrapeSchema || {}, + scrapeList: categorizedOutput.scrapeList || {}, + })), + binaryOutput: uploadedBinaryOutput, }); - // Upload binary output to MinIO and update run with MinIO URLs - const updatedRun = await Run.findOne({ where: { runId: data.runId } }); - if (updatedRun && updatedRun.binaryOutput && Object.keys(updatedRun.binaryOutput).length > 0) { - try { - const binaryService = new BinaryOutputService('maxun-run-screenshots'); - await binaryService.uploadAndStoreBinaryOutput(updatedRun, updatedRun.binaryOutput); - logger.log('info', `Uploaded binary output to MinIO for run ${data.runId}`); - } catch (minioError: any) { - logger.log('error', `Failed to upload binary output to MinIO for run ${data.runId}: ${minioError.message}`); - } - } - let totalSchemaItemsExtracted = 0; let totalListItemsExtracted = 0; let extractedScreenshotsCount = 0; - if (updatedRun) { - if (updatedRun.serializableOutput) { - if (updatedRun.serializableOutput.scrapeSchema) { - Object.values(updatedRun.serializableOutput.scrapeSchema).forEach((schemaResult: any) => { - if (Array.isArray(schemaResult)) { - totalSchemaItemsExtracted += schemaResult.length; - } else if (schemaResult && typeof schemaResult === 'object') { - totalSchemaItemsExtracted += 1; - } - }); - } - - if (updatedRun.serializableOutput.scrapeList) { - Object.values(updatedRun.serializableOutput.scrapeList).forEach((listResult: any) => { - if (Array.isArray(listResult)) { - totalListItemsExtracted += listResult.length; - } - }); - } + if (categorizedOutput) { + if (categorizedOutput.scrapeSchema) { + Object.values(categorizedOutput.scrapeSchema).forEach((schemaResult: any) => { + if (Array.isArray(schemaResult)) { + totalSchemaItemsExtracted += schemaResult.length; + } else if (schemaResult && typeof schemaResult === 'object') { + totalSchemaItemsExtracted += 1; + } + }); } - if (updatedRun.binaryOutput) { - extractedScreenshotsCount = Object.keys(updatedRun.binaryOutput).length; + if (categorizedOutput.scrapeList) { + Object.values(categorizedOutput.scrapeList).forEach((listResult: any) => { + if (Array.isArray(listResult)) { + totalListItemsExtracted += listResult.length; + } + }); + } + + if (run.binaryOutput) { + extractedScreenshotsCount = Object.keys(run.binaryOutput).length; } } @@ -302,6 +340,21 @@ async function processRunExecution(job: Job) { } ); + try { + const completionData = { + runId: data.runId, + robotMetaId: plainRun.robotMetaId, + robotName: recording.recording_meta.name, + status: 'success', + finishedAt: new Date().toLocaleString() + }; + + serverIo.of(browserId).emit('run-completed', completionData); + serverIo.of('/queued-run').to(`user-${data.userId}`).emit('run-completed', completionData); + } catch (socketError: any) { + logger.log('warn', `Failed to send run-completed notification for API run ${plainRun.runId}: ${socketError.message}`); + } + const webhookPayload = { robot_id: plainRun.robotMetaId, run_id: data.runId, @@ -310,12 +363,16 @@ async function processRunExecution(job: Job) { started_at: plainRun.startedAt, finished_at: new Date().toLocaleString(), extracted_data: { - captured_texts: updatedRun?.serializableOutput?.scrapeSchema ? Object.values(updatedRun.serializableOutput.scrapeSchema).flat() : [], - captured_lists: updatedRun?.serializableOutput?.scrapeList || {}, - total_rows: totalRowsExtracted, + captured_texts: Object.keys(categorizedOutput.scrapeSchema || {}).length > 0 + ? Object.entries(categorizedOutput.scrapeSchema).reduce((acc, [name, value]) => { + acc[name] = Array.isArray(value) ? value : [value]; + return acc; + }, {} as Record) + : {}, + captured_lists: categorizedOutput.scrapeList, captured_texts_count: totalSchemaItemsExtracted, captured_lists_count: totalListItemsExtracted, - screenshots_count: extractedScreenshotsCount, + screenshots_count: extractedScreenshotsCount }, metadata: { browser_id: plainRun.browserId, @@ -330,26 +387,8 @@ async function processRunExecution(job: Job) { logger.log('error', `Failed to send webhooks for run ${data.runId}: ${webhookError.message}`); } - // Schedule updates for Google Sheets and Airtable await triggerIntegrationUpdates(plainRun.runId, plainRun.robotMetaId); - // Flush any remaining persistence buffer before emitting socket event - if (browser && browser.interpreter) { - await browser.interpreter.flushPersistenceBuffer(); - logger.log('debug', `Flushed persistence buffer before emitting run-completed for run ${data.runId}`); - } - - const completionData = { - runId: data.runId, - robotMetaId: plainRun.robotMetaId, - robotName: recording.recording_meta.name, - status: 'success', - finishedAt: new Date().toLocaleString() - }; - - serverIo.of(browserId).emit('run-completed', completionData); - serverIo.of('/queued-run').to(`user-${data.userId}`).emit('run-completed', completionData); - await destroyRemoteBrowser(browserId, data.userId); logger.log('info', `Browser ${browserId} destroyed after successful run ${data.runId}`); @@ -416,9 +455,13 @@ async function processRunExecution(job: Job) { }, partial_data_extracted: partialDataExtracted, extracted_data: partialDataExtracted ? { - captured_texts: Object.values(partialUpdateData.serializableOutput?.scrapeSchema || []).flat() || [], + captured_texts: Object.keys(partialUpdateData.serializableOutput?.scrapeSchema || {}).length > 0 + ? Object.entries(partialUpdateData.serializableOutput.scrapeSchema).reduce((acc, [name, value]) => { + acc[name] = Array.isArray(value) ? value : [value]; + return acc; + }, {} as Record) + : {}, captured_lists: partialUpdateData.serializableOutput?.scrapeList || {}, - total_data_points_extracted: partialData?.totalDataPointsExtracted || 0, captured_texts_count: partialData?.totalSchemaItemsExtracted || 0, captured_lists_count: partialData?.totalListItemsExtracted || 0, screenshots_count: partialData?.extractedScreenshotsCount || 0 diff --git a/server/src/workflow-management/classes/Generator.ts b/server/src/workflow-management/classes/Generator.ts index a5bc2edc..57a30863 100644 --- a/server/src/workflow-management/classes/Generator.ts +++ b/server/src/workflow-management/classes/Generator.ts @@ -726,34 +726,108 @@ export class WorkflowGenerator { /** * Generates a pair for the custom action event. + * * @param action The type of the custom action. - * @param settings The settings of the custom action. + * @param actionId The unique identifier for this action (for updates) + * @param settings The settings of the custom action (may include name and actionId). * @param page The page to use for obtaining the needed data. */ - public customAction = async (action: CustomActions, settings: any, page: Page) => { - const pair: WhereWhatPair = { - where: { url: this.getBestUrl(page.url()) }, - what: [{ - action, - args: settings ? Array.isArray(settings) ? settings : [settings] : [], - }], - } + public customAction = async (action: CustomActions, actionId: string, settings: any, page: Page) => { + try { + let actionSettings = settings; + let actionName: string | undefined; - await this.addPairToWorkflowAndNotifyClient(pair, page); + if (settings && !Array.isArray(settings)) { + actionName = settings.name; + actionSettings = JSON.parse(JSON.stringify(settings)); + delete actionSettings.name; + } - if (this.generatedData.lastUsedSelector) { - const elementInfo = await this.getLastUsedSelectorInfo(page, this.generatedData.lastUsedSelector); + const pair: WhereWhatPair = { + where: { url: this.getBestUrl(page.url()) }, + what: [{ + action, + args: actionSettings + ? Array.isArray(actionSettings) + ? actionSettings + : [actionSettings] + : [], + ...(actionName ? { name: actionName } : {}), + ...(actionId ? { actionId } : {}), + }], + }; - this.socket.emit('decision', { - pair, actionType: 'customAction', - lastData: { - selector: this.generatedData.lastUsedSelector, - action: this.generatedData.lastAction, - tagName: elementInfo.tagName, - innerText: elementInfo.innerText, + if (actionId) { + const existingIndex = this.workflowRecord.workflow.findIndex( + (workflowPair) => + Array.isArray(workflowPair.what) && + workflowPair.what.some((whatItem: any) => whatItem.actionId === actionId) + ); + + if (existingIndex !== -1) { + const existingPair = this.workflowRecord.workflow[existingIndex]; + const existingAction = existingPair.what.find((whatItem: any) => whatItem.actionId === actionId); + + const updatedAction = { + ...existingAction, + action, + args: Array.isArray(actionSettings) + ? actionSettings + : [actionSettings], + name: actionName || existingAction?.name || '', + actionId, + }; + + this.workflowRecord.workflow[existingIndex] = { + where: JSON.parse(JSON.stringify(existingPair.where)), + what: existingPair.what.map((whatItem: any) => + whatItem.actionId === actionId ? updatedAction : whatItem + ), + }; + + if (action === 'scrapeSchema' && actionName) { + this.workflowRecord.workflow.forEach((pair, index) => { + pair.what.forEach((whatItem: any, whatIndex: number) => { + if (whatItem.action === 'scrapeSchema' && whatItem.actionId !== actionId) { + this.workflowRecord.workflow[index].what[whatIndex] = { + ...whatItem, + name: actionName + }; + } + }); + }); + } + + } else { + await this.addPairToWorkflowAndNotifyClient(pair, page); + logger.log("debug", `Added new workflow action: ${action} with actionId: ${actionId}`); } - }); - } + } else { + await this.addPairToWorkflowAndNotifyClient(pair, page); + logger.log("debug", `Added new workflow action: ${action} without actionId`); + } + + if (this.generatedData.lastUsedSelector) { + const elementInfo = await this.getLastUsedSelectorInfo( + page, + this.generatedData.lastUsedSelector + ); + + this.socket.emit('decision', { + pair, + actionType: 'customAction', + lastData: { + selector: this.generatedData.lastUsedSelector, + action: this.generatedData.lastAction, + tagName: elementInfo.tagName, + innerText: elementInfo.innerText, + }, + }); + } + } catch (e) { + const { message } = e as Error; + logger.log("warn", `Error handling customAction: ${message}`); + } }; /** @@ -810,6 +884,48 @@ export class WorkflowGenerator { } }; + /** + * Removes an action with the given actionId from the workflow. + * Only removes the specific action from the what array, not the entire pair. + * If the what array becomes empty after removal, then the entire pair is removed. + * @param actionId The actionId of the action to remove + * @returns boolean indicating whether an action was removed + */ + public removeAction = (actionId: string): boolean => { + let actionWasRemoved = false; + + this.workflowRecord.workflow = this.workflowRecord.workflow + .map((pair) => { + const filteredWhat = pair.what.filter( + (whatItem: any) => whatItem.actionId !== actionId + ); + + if (filteredWhat.length < pair.what.length) { + actionWasRemoved = true; + + if (filteredWhat.length > 0) { + return { + ...pair, + what: filteredWhat + }; + } + + return null; + } + + return pair; + }) + .filter((pair) => pair !== null) as WhereWhatPair[]; // Remove null entries + + if (actionWasRemoved) { + logger.log("info", `Action with actionId ${actionId} removed from workflow`); + } else { + logger.log("debug", `No action found with actionId ${actionId}`); + } + + return actionWasRemoved; + }; + /** * Updates the socket used for communication with the client. * @param socket The socket to be used for communication. diff --git a/server/src/workflow-management/classes/Interpreter.ts b/server/src/workflow-management/classes/Interpreter.ts index 5e843a80..6142ed24 100644 --- a/server/src/workflow-management/classes/Interpreter.ts +++ b/server/src/workflow-management/classes/Interpreter.ts @@ -91,13 +91,16 @@ export class WorkflowInterpreter { * Storage for different types of serializable data */ public serializableDataByType: { - scrapeSchema: any[], - scrapeList: any[], + scrapeSchema: Record; + scrapeList: Record; + [key: string]: any; } = { - scrapeSchema: [], - scrapeList: [], + scrapeSchema: {}, + scrapeList: {}, }; + private currentActionName: string | null = null; + /** * Track the current action type being processed */ @@ -106,7 +109,7 @@ export class WorkflowInterpreter { /** * An array of all the binary data extracted from the run. */ - public binaryData: { mimetype: string, data: string }[] = []; + public binaryData: { name: string; mimeType: string; data: string }[] = []; /** * Track current scrapeList index @@ -259,14 +262,19 @@ export class WorkflowInterpreter { } }, binaryCallback: async (data: string, mimetype: string) => { - const binaryItem = { mimetype, data: JSON.stringify(data) }; + // For editor mode, we don't have the name yet, so use a timestamp-based name + const binaryItem = { + name: `Screenshot ${Date.now()}`, + mimeType: mimetype, + data: JSON.stringify(data) + }; this.binaryData.push(binaryItem); - + // Persist binary data to database await this.persistBinaryDataToDatabase(binaryItem); - - this.socket.emit('binaryCallback', { - data, + + this.socket.emit('binaryCallback', { + data, mimetype, type: 'captureScreenshot' }); @@ -364,9 +372,10 @@ export class WorkflowInterpreter { this.breakpoints = []; this.interpretationResume = null; this.currentActionType = null; + this.currentActionName = null; this.serializableDataByType = { - scrapeSchema: [], - scrapeList: [], + scrapeSchema: {}, + scrapeList: {}, }; this.binaryData = []; this.currentScrapeListIndex = 0; @@ -409,7 +418,7 @@ export class WorkflowInterpreter { * Persists binary data to database in real-time * @private */ - private persistBinaryDataToDatabase = async (binaryItem: { mimetype: string, data: string }): Promise => { + private persistBinaryDataToDatabase = async (binaryItem: { name: string; mimeType: string; data: string }): Promise => { if (!this.currentRunId) { logger.log('debug', 'No run ID available for binary data persistence'); return; @@ -422,22 +431,29 @@ export class WorkflowInterpreter { return; } - const currentBinaryOutput = run.binaryOutput ? - JSON.parse(JSON.stringify(run.binaryOutput)) : - {}; - - const uniqueKey = `item-${Date.now()}-${Object.keys(currentBinaryOutput).length}`; - + const currentBinaryOutput = + run.binaryOutput && typeof run.binaryOutput === 'object' + ? JSON.parse(JSON.stringify(run.binaryOutput)) + : {}; + + const baseName = binaryItem.name?.trim() || `Screenshot ${Object.keys(currentBinaryOutput).length + 1}`; + + let uniqueName = baseName; + let counter = 1; + while (currentBinaryOutput[uniqueName]) { + uniqueName = `${baseName} (${counter++})`; + } + const updatedBinaryOutput = { ...currentBinaryOutput, - [uniqueKey]: binaryItem + [uniqueName]: binaryItem, }; await run.update({ binaryOutput: updatedBinaryOutput }); - - logger.log('debug', `Persisted binary data for run ${this.currentRunId}: ${binaryItem.mimetype}`); + + logger.log('debug', `Persisted binary data for run ${this.currentRunId}: ${binaryItem.name} (${binaryItem.mimeType})`); } catch (error: any) { logger.log('error', `Failed to persist binary data in real-time for run ${this.currentRunId}: ${error.message}`); } @@ -478,41 +494,101 @@ export class WorkflowInterpreter { }, incrementScrapeListIndex: () => { this.currentScrapeListIndex++; - } + }, + setActionName: (name: string) => { + this.currentActionName = name; + }, }, serializableCallback: async (data: any) => { - if (this.currentActionType === 'scrapeSchema') { - if (Array.isArray(data) && data.length > 0) { - mergedScrapeSchema = { ...mergedScrapeSchema, ...data[0] }; - this.serializableDataByType.scrapeSchema.push(data); - } else { - mergedScrapeSchema = { ...mergedScrapeSchema, ...data }; - this.serializableDataByType.scrapeSchema.push([data]); + try { + if (!data || typeof data !== "object") return; + + if (!this.currentActionType && Array.isArray(data) && data.length > 0) { + const first = data[0]; + if (first && Object.keys(first).some(k => k.toLowerCase().includes("label") || k.toLowerCase().includes("text"))) { + this.currentActionType = "scrapeSchema"; + } } - - // Persist the cumulative scrapeSchema data - const cumulativeScrapeSchemaData = Object.keys(mergedScrapeSchema).length > 0 ? [mergedScrapeSchema] : []; - if (cumulativeScrapeSchemaData.length > 0) { - await this.persistDataToDatabase('scrapeSchema', cumulativeScrapeSchemaData); + + let typeKey = this.currentActionType || "unknown"; + + if (this.currentActionType === "scrapeList") { + typeKey = "scrapeList"; + } else if (this.currentActionType === "scrapeSchema") { + typeKey = "scrapeSchema"; } - } else if (this.currentActionType === 'scrapeList') { - if (data && Array.isArray(data) && data.length > 0) { - // Use the current index for persistence - await this.persistDataToDatabase('scrapeList', data, this.currentScrapeListIndex); + + if (this.currentActionType === "scrapeList" && data.scrapeList) { + data = data.scrapeList; + } else if (this.currentActionType === "scrapeSchema" && data.scrapeSchema) { + data = data.scrapeSchema; } - this.serializableDataByType.scrapeList[this.currentScrapeListIndex] = data; - } - - this.socket.emit('serializableCallback', data); + + let actionName = this.currentActionName || ""; + + if (!actionName) { + if (!Array.isArray(data) && Object.keys(data).length === 1) { + const soleKey = Object.keys(data)[0]; + const soleValue = data[soleKey]; + if (Array.isArray(soleValue) || typeof soleValue === "object") { + actionName = soleKey; + data = soleValue; + } + } + } + + if (!actionName) { + actionName = "Unnamed Action"; + } + + const flattened = Array.isArray(data) + ? data + : (data?.List ?? (data && typeof data === 'object' ? Object.values(data).flat?.() ?? data : [])); + + if (!this.serializableDataByType[typeKey]) { + this.serializableDataByType[typeKey] = {}; + } + + this.serializableDataByType[typeKey][actionName] = flattened; + + await this.persistDataToDatabase(typeKey, { [actionName]: flattened }); + + this.socket.emit("serializableCallback", { + type: typeKey, + name: actionName, + data: flattened, + }); + + this.currentActionType = null; + this.currentActionName = null; + } catch (err: any) { + logger.log('error', `serializableCallback handler failed: ${err.message}`); + } }, - binaryCallback: async (data: string, mimetype: string) => { - const binaryItem = { mimetype, data: JSON.stringify(data) }; - this.binaryData.push(binaryItem); - - // Persist binary data to database - await this.persistBinaryDataToDatabase(binaryItem); - - this.socket.emit('binaryCallback', { data, mimetype }); + binaryCallback: async (payload: { name: string; data: Buffer; mimeType: string }) => { + try { + const { name, data, mimeType } = payload; + + const base64Data = data.toString("base64"); + + const binaryItem = { + name, + mimeType, + data: base64Data + }; + + this.binaryData.push(binaryItem); + + await this.persistBinaryDataToDatabase(binaryItem); + + this.socket.emit("binaryCallback", { + name, + data: base64Data, + mimeType + }); + } catch (err: any) { + logger.log("error", `binaryCallback handler failed: ${err.message}`); + } } } @@ -542,20 +618,13 @@ export class WorkflowInterpreter { const result = { log: this.debugMessages, result: status, - scrapeSchemaOutput: Object.keys(mergedScrapeSchema).length > 0 - ? { "schema_merged": [mergedScrapeSchema] } - : this.serializableDataByType.scrapeSchema.reduce((reducedObject, item, index) => { - reducedObject[`schema_${index}`] = item; - return reducedObject; - }, {} as Record), - scrapeListOutput: this.serializableDataByType.scrapeList.reduce((reducedObject, item, index) => { - reducedObject[`list_${index}`] = item; - return reducedObject; - }, {} as Record), - binaryOutput: this.binaryData.reduce((reducedObject, item, index) => { - reducedObject[`item_${index}`] = item; - return reducedObject; - }, {} as Record) + scrapeSchemaOutput: this.serializableDataByType.scrapeSchema, + scrapeListOutput: this.serializableDataByType.scrapeList, + binaryOutput: this.binaryData.reduce>((acc, item) => { + const key = item.name || `Screenshot ${Object.keys(acc).length + 1}`; + acc[key] = { data: item.data, mimeType: item.mimeType }; + return acc; + }, {}) } logger.log('debug', `Interpretation finished`); @@ -642,19 +711,37 @@ export class WorkflowInterpreter { const currentSerializableOutput = run.serializableOutput ? JSON.parse(JSON.stringify(run.serializableOutput)) : { scrapeSchema: [], scrapeList: [] }; + + if (Array.isArray(currentSerializableOutput.scrapeList)) { + currentSerializableOutput.scrapeList = {}; + } + if (Array.isArray(currentSerializableOutput.scrapeSchema)) { + currentSerializableOutput.scrapeSchema = {}; + } let hasUpdates = false; + const mergeLists = (target: Record, updates: Record) => { + for (const [key, val] of Object.entries(updates)) { + const flattened = Array.isArray(val) + ? val + : (val?.List ?? (val && typeof val === 'object' ? Object.values(val).flat?.() ?? val : [])); + target[key] = flattened; + } + }; + for (const item of batchToProcess) { if (item.actionType === 'scrapeSchema') { - const newSchemaData = Array.isArray(item.data) ? item.data : [item.data]; - currentSerializableOutput.scrapeSchema = newSchemaData; - hasUpdates = true; - } else if (item.actionType === 'scrapeList' && typeof item.listIndex === 'number') { - if (!Array.isArray(currentSerializableOutput.scrapeList)) { - currentSerializableOutput.scrapeList = []; + if (!currentSerializableOutput.scrapeSchema || typeof currentSerializableOutput.scrapeSchema !== 'object') { + currentSerializableOutput.scrapeSchema = {}; } - currentSerializableOutput.scrapeList[item.listIndex] = item.data; + mergeLists(currentSerializableOutput.scrapeSchema, item.data); + hasUpdates = true; + } else if (item.actionType === 'scrapeList') { + if (!currentSerializableOutput.scrapeList || typeof currentSerializableOutput.scrapeList !== 'object') { + currentSerializableOutput.scrapeList = {}; + } + mergeLists(currentSerializableOutput.scrapeList, item.data); hasUpdates = true; } } diff --git a/server/src/workflow-management/integrations/airtable.ts b/server/src/workflow-management/integrations/airtable.ts index e1f27264..0437afc3 100644 --- a/server/src/workflow-management/integrations/airtable.ts +++ b/server/src/workflow-management/integrations/airtable.ts @@ -12,8 +12,8 @@ interface AirtableUpdateTask { } interface SerializableOutput { - scrapeSchema?: any[]; - scrapeList?: any[]; + scrapeSchema?: Record; + scrapeList?: Record; } const MAX_RETRIES = 3; @@ -48,47 +48,94 @@ async function refreshAirtableToken(refreshToken: string) { function mergeRelatedData(serializableOutput: SerializableOutput, binaryOutput: Record) { const allRecords: Record[] = []; - const schemaData: Array<{key: string, value: any}> = []; + const schemaData: Array<{ Group: string; Field: string; Value: any }> = []; const listData: any[] = []; const screenshotData: Array<{key: string, url: string}> = []; // Collect schema data if (serializableOutput.scrapeSchema) { - for (const schemaArray of serializableOutput.scrapeSchema) { - if (!Array.isArray(schemaArray)) continue; - for (const schemaItem of schemaArray) { - Object.entries(schemaItem).forEach(([key, value]) => { - if (key && key.trim() !== '' && value !== null && value !== undefined && value !== '') { - schemaData.push({key, value}); - } - }); + if (Array.isArray(serializableOutput.scrapeSchema)) { + for (const schemaArray of serializableOutput.scrapeSchema) { + if (!Array.isArray(schemaArray)) continue; + for (const schemaItem of schemaArray) { + Object.entries(schemaItem || {}).forEach(([key, value]) => { + if (key && key.trim() !== "" && value !== null && value !== undefined && value !== "") { + schemaData.push({ Group: "Default", Field: key, Value: value }); + } + }); + } + } + } else if (typeof serializableOutput.scrapeSchema === "object") { + for (const [groupName, schemaArray] of Object.entries(serializableOutput.scrapeSchema)) { + if (!Array.isArray(schemaArray)) continue; + for (const schemaItem of schemaArray) { + Object.entries(schemaItem || {}).forEach(([fieldName, value]) => { + if (fieldName && fieldName.trim() !== "" && value !== null && value !== undefined && value !== "") { + schemaData.push({ + Group: groupName, + Field: fieldName, + Value: value, + }); + } + }); + } } } } // Collect list data if (serializableOutput.scrapeList) { - for (const listArray of serializableOutput.scrapeList) { - if (!Array.isArray(listArray)) continue; - listArray.forEach(listItem => { - const hasContent = Object.values(listItem).some(value => - value !== null && value !== undefined && value !== '' - ); - if (hasContent) { - listData.push(listItem); - } - }); + if (Array.isArray(serializableOutput.scrapeList)) { + for (const listArray of serializableOutput.scrapeList) { + if (!Array.isArray(listArray)) continue; + listArray.forEach((listItem) => { + const hasContent = Object.values(listItem || {}).some( + (value) => value !== null && value !== undefined && value !== "" + ); + if (hasContent) listData.push(listItem); + }); + } + } else if (typeof serializableOutput.scrapeList === "object") { + for (const [listName, listArray] of Object.entries(serializableOutput.scrapeList)) { + if (!Array.isArray(listArray)) continue; + listArray.forEach((listItem) => { + const hasContent = Object.values(listItem || {}).some( + (value) => value !== null && value !== undefined && value !== "" + ); + if (hasContent) listData.push({ List: listName, ...listItem }); + }); + } } } // Collect screenshot data - if (binaryOutput && Object.keys(binaryOutput).length > 0) { - Object.entries(binaryOutput).forEach(([key, url]) => { - if (key && key.trim() !== '' && url && url.trim() !== '') { - screenshotData.push({key, url}); - } - }); - } + // if (binaryOutput && Object.keys(binaryOutput).length > 0) { + // Object.entries(binaryOutput).forEach(([key, rawValue]: [string, any]) => { + // if (!key || key.trim() === "") return; + + // let urlString = ""; + + // // Case 1: old format (string URL) + // if (typeof rawValue === "string") { + // urlString = rawValue; + // } + // // Case 2: new format (object with { url?, data?, mimeType? }) + // else if (rawValue && typeof rawValue === "object") { + // const valueObj = rawValue as { url?: string; data?: string; mimeType?: string }; + + // if (typeof valueObj.url === "string") { + // urlString = valueObj.url; + // } else if (typeof valueObj.data === "string") { + // const mime = valueObj.mimeType || "image/png"; + // urlString = `data:${mime};base64,${valueObj.data}`; + // } + // } + + // if (typeof urlString === "string" && urlString.trim() !== "") { + // screenshotData.push({ key, url: urlString }); + // } + // }); + // } // Mix all data types together to create consecutive records const maxLength = Math.max(schemaData.length, listData.length, screenshotData.length); @@ -97,8 +144,9 @@ function mergeRelatedData(serializableOutput: SerializableOutput, binaryOutput: const record: Record = {}; if (i < schemaData.length) { - record.Label = schemaData[i].key; - record.Value = schemaData[i].value; + record.Group = schemaData[i].Group; + record.Label = schemaData[i].Field; + record.Value = schemaData[i].Value; } if (i < listData.length) { @@ -120,20 +168,15 @@ function mergeRelatedData(serializableOutput: SerializableOutput, binaryOutput: } for (let i = maxLength; i < schemaData.length; i++) { - allRecords.push({ - Label: schemaData[i].key, - Value: schemaData[i].value - }); + allRecords.push({ Label: schemaData[i].Field, Value: schemaData[i].Value }); } - for (let i = maxLength; i < listData.length; i++) { allRecords.push(listData[i]); } - for (let i = maxLength; i < screenshotData.length; i++) { allRecords.push({ Key: screenshotData[i].key, - Screenshot: screenshotData[i].url + Screenshot: screenshotData[i].url, }); } diff --git a/server/src/workflow-management/integrations/gsheet.ts b/server/src/workflow-management/integrations/gsheet.ts index fcf9b95c..c32e4fe0 100644 --- a/server/src/workflow-management/integrations/gsheet.ts +++ b/server/src/workflow-management/integrations/gsheet.ts @@ -49,25 +49,34 @@ export async function updateGoogleSheet(robotId: string, runId: string) { const serializableOutput = plainRun.serializableOutput as SerializableOutput; if (serializableOutput) { - if (serializableOutput.scrapeSchema && serializableOutput.scrapeSchema.length > 0) { - await processOutputType( - robotId, - spreadsheetId, - 'Text', - serializableOutput.scrapeSchema, - plainRobot - ); + if (serializableOutput.scrapeSchema && typeof serializableOutput.scrapeSchema === "object") { + for (const [groupName, schemaArray] of Object.entries(serializableOutput.scrapeSchema)) { + if (!Array.isArray(schemaArray) || schemaArray.length === 0) continue; + + await processOutputType( + robotId, + spreadsheetId, + `Schema - ${groupName}`, + schemaArray, + plainRobot + ); + } } - - if (serializableOutput.scrapeList && serializableOutput.scrapeList.length > 0) { - await processOutputType( - robotId, - spreadsheetId, - 'List', - serializableOutput.scrapeList, - plainRobot - ); + + if (serializableOutput.scrapeList && typeof serializableOutput.scrapeList === "object") { + for (const [listName, listArray] of Object.entries(serializableOutput.scrapeList)) { + if (!Array.isArray(listArray) || listArray.length === 0) continue; + + await processOutputType( + robotId, + spreadsheetId, + `List - ${listName}`, + listArray, + plainRobot + ); + } } + } if (plainRun.binaryOutput && Object.keys(plainRun.binaryOutput).length > 0) { @@ -102,30 +111,27 @@ async function processOutputType( outputData: any[], robotConfig: any ) { - for (let i = 0; i < outputData.length; i++) { - const data = outputData[i]; - - if (!data || data.length === 0) { - console.log(`No data to write for ${outputType}-${i}. Skipping.`); - continue; - } - - const sheetName = `${outputType}-${i}`; - - await ensureSheetExists(spreadsheetId, sheetName, robotConfig); - - let formattedData = data; - if (outputType === 'Text' && data.length > 0) { - const schemaItem = data[0]; - formattedData = Object.entries(schemaItem).map(([key, value]) => ({ - Label: key, - Value: value - })); - } - - await writeDataToSheet(robotId, spreadsheetId, formattedData, sheetName, robotConfig); - console.log(`Data written to ${sheetName} sheet for ${outputType} data`); + const data = outputData; + const sheetName = outputType; + + if (!Array.isArray(data) || data.length === 0) { + console.log(`No data to write for ${sheetName}. Skipping.`); + return; } + + await ensureSheetExists(spreadsheetId, sheetName, robotConfig); + + const formattedData = data.map(item => { + const flatRow: Record = {}; + for (const [key, value] of Object.entries(item || {})) { + flatRow[key] = + typeof value === "object" && value !== null ? JSON.stringify(value) : value; + } + return flatRow; + }); + + await writeDataToSheet(robotId, spreadsheetId, formattedData, sheetName, robotConfig); + console.log(`Data written to ${sheetName} sheet for ${outputType} data`); } async function ensureSheetExists(spreadsheetId: string, sheetName: string, robotConfig: any) { diff --git a/server/src/workflow-management/scheduler/index.ts b/server/src/workflow-management/scheduler/index.ts index ce272689..899cb7f6 100644 --- a/server/src/workflow-management/scheduler/index.ts +++ b/server/src/workflow-management/scheduler/index.ts @@ -4,7 +4,7 @@ import stealthPlugin from 'puppeteer-extra-plugin-stealth'; import { io, Socket } from "socket.io-client"; import { createRemoteBrowserForRun, destroyRemoteBrowser } from '../../browser-management/controller'; import logger from '../../logger'; -import { browserPool } from "../../server"; +import { browserPool, io as serverIo } from "../../server"; import { googleSheetUpdateTasks, processGoogleSheetUpdates } from "../integrations/gsheet"; import Robot from "../../models/Robot"; import Run from "../../models/Run"; @@ -46,7 +46,7 @@ async function createWorkflowAndStoreMetadata(id: string, userId: string) { }; } - const browserId = createRemoteBrowserForRun( userId); + const browserId = createRemoteBrowserForRun(userId); const runId = uuid(); const run = await Run.create({ @@ -63,10 +63,30 @@ async function createWorkflowAndStoreMetadata(id: string, userId: string) { runByScheduleId: uuid(), serializableOutput: {}, binaryOutput: {}, + retryCount: 0 }); const plainRun = run.toJSON(); + try { + const runScheduledData = { + runId: plainRun.runId, + robotMetaId: plainRun.robotMetaId, + robotName: plainRun.name, + status: 'scheduled', + startedAt: plainRun.startedAt, + runByUserId: plainRun.runByUserId, + runByScheduleId: plainRun.runByScheduleId, + runByAPI: plainRun.runByAPI || false, + browserId: plainRun.browserId + }; + + serverIo.of('/queued-run').to(`user-${userId}`).emit('run-scheduled', runScheduledData); + logger.log('info', `Scheduled run notification sent for run: ${plainRun.runId} to user-${userId}`); + } catch (socketError: any) { + logger.log('warn', `Failed to send run-scheduled notification for run ${plainRun.runId}: ${socketError.message}`); + } + return { browserId, runId: plainRun.runId, @@ -83,6 +103,29 @@ async function createWorkflowAndStoreMetadata(id: string, userId: string) { } } +async function triggerIntegrationUpdates(runId: string, robotMetaId: string): Promise { + try { + googleSheetUpdateTasks[runId] = { + robotId: robotMetaId, + runId: runId, + status: 'pending', + retries: 5, + }; + + airtableUpdateTasks[runId] = { + robotId: robotMetaId, + runId: runId, + status: 'pending', + retries: 5, + }; + + processAirtableUpdates().catch(err => logger.log('error', `Airtable update error: ${err.message}`)); + processGoogleSheetUpdates().catch(err => logger.log('error', `Google Sheets update error: ${err.message}`)); + } catch (err: any) { + logger.log('error', `Failed to update integrations for run: ${runId}: ${err.message}`); + } +} + function AddGeneratedFlags(workflow: WorkflowFile) { const copy = JSON.parse(JSON.stringify(workflow)); for (let i = 0; i < workflow.workflow.length; i++) { @@ -95,6 +138,8 @@ function AddGeneratedFlags(workflow: WorkflowFile) { }; async function executeRun(id: string, userId: string) { + let browser: any = null; + try { const run = await Run.findOne({ where: { runId: id } }); if (!run) { @@ -133,6 +178,21 @@ async function executeRun(id: string, userId: string) { log: plainRun.log ? `${plainRun.log}\nMax retries exceeded (3/3) - Run failed after multiple attempts.` : `Max retries exceeded (3/3) - Run failed after multiple attempts.` }); + try { + const failureSocketData = { + runId: plainRun.runId, + robotMetaId: plainRun.robotMetaId, + robotName: recording ? recording.recording_meta.name : 'Unknown Robot', + status: 'failed', + finishedAt: new Date().toLocaleString() + }; + + serverIo.of(run.browserId).emit('run-completed', failureSocketData); + serverIo.of('/queued-run').to(`user-${userId}`).emit('run-completed', failureSocketData); + } catch (socketError: any) { + logger.log('warn', `Failed to emit failure event in main catch: ${socketError.message}`); + } + return { success: false, error: 'Max retries exceeded' @@ -149,7 +209,22 @@ async function executeRun(id: string, userId: string) { plainRun.status = 'running'; - const browser = browserPool.getRemoteBrowser(plainRun.browserId); + try { + const runStartedData = { + runId: plainRun.runId, + robotMetaId: plainRun.robotMetaId, + robotName: recording ? recording.recording_meta.name : 'Unknown Robot', + status: 'running', + startedAt: plainRun.startedAt + }; + + serverIo.of('/queued-run').to(`user-${userId}`).emit('run-started', runStartedData); + logger.log('info', `Run started notification sent for run: ${plainRun.runId} to user-${userId}`); + } catch (socketError: any) { + logger.log('warn', `Failed to send run-started notification for run ${plainRun.runId}: ${socketError.message}`); + } + + browser = browserPool.getRemoteBrowser(plainRun.browserId); if (!browser) { throw new Error('Could not access browser'); } @@ -168,56 +243,52 @@ async function executeRun(id: string, userId: string) { workflow, currentPage, (newPage: Page) => currentPage = newPage, plainRun.interpreterSettings ); + const binaryOutputService = new BinaryOutputService('maxun-run-screenshots'); + const uploadedBinaryOutput = await binaryOutputService.uploadAndStoreBinaryOutput(run, interpretationInfo.binaryOutput); + + const finalRun = await Run.findByPk(run.id); + const categorizedOutput = { + scrapeSchema: finalRun?.serializableOutput?.scrapeSchema || {}, + scrapeList: finalRun?.serializableOutput?.scrapeList || {}, + }; + await destroyRemoteBrowser(plainRun.browserId, userId); await run.update({ status: 'success', finishedAt: new Date().toLocaleString(), log: interpretationInfo.log.join('\n'), + binaryOutput: uploadedBinaryOutput }); - // Upload binary output to MinIO and update run with MinIO URLs - const updatedRun = await Run.findOne({ where: { runId: id } }); - if (updatedRun && updatedRun.binaryOutput && Object.keys(updatedRun.binaryOutput).length > 0) { - try { - const binaryService = new BinaryOutputService('maxun-run-screenshots'); - await binaryService.uploadAndStoreBinaryOutput(updatedRun, updatedRun.binaryOutput); - logger.log('info', `Uploaded binary output to MinIO for scheduled run ${id}`); - } catch (minioError: any) { - logger.log('error', `Failed to upload binary output to MinIO for scheduled run ${id}: ${minioError.message}`); - } - } - // Get metrics from persisted data for analytics and webhooks let totalSchemaItemsExtracted = 0; let totalListItemsExtracted = 0; let extractedScreenshotsCount = 0; - if (updatedRun) { - if (updatedRun.serializableOutput) { - if (updatedRun.serializableOutput.scrapeSchema) { - Object.values(updatedRun.serializableOutput.scrapeSchema).forEach((schemaResult: any) => { - if (Array.isArray(schemaResult)) { - totalSchemaItemsExtracted += schemaResult.length; - } else if (schemaResult && typeof schemaResult === 'object') { - totalSchemaItemsExtracted += 1; - } - }); - } - - if (updatedRun.serializableOutput.scrapeList) { - Object.values(updatedRun.serializableOutput.scrapeList).forEach((listResult: any) => { - if (Array.isArray(listResult)) { - totalListItemsExtracted += listResult.length; - } - }); - } + if (categorizedOutput) { + if (categorizedOutput.scrapeSchema) { + Object.values(categorizedOutput.scrapeSchema).forEach((schemaResult: any) => { + if (Array.isArray(schemaResult)) { + totalSchemaItemsExtracted += schemaResult.length; + } else if (schemaResult && typeof schemaResult === 'object') { + totalSchemaItemsExtracted += 1; + } + }); } - if (updatedRun.binaryOutput) { - extractedScreenshotsCount = Object.keys(updatedRun.binaryOutput).length; + if (categorizedOutput.scrapeList) { + Object.values(categorizedOutput.scrapeList).forEach((listResult: any) => { + if (Array.isArray(listResult)) { + totalListItemsExtracted += listResult.length; + } + }); } } + + if (run.binaryOutput) { + extractedScreenshotsCount = Object.keys(run.binaryOutput).length; + } const totalRowsExtracted = totalSchemaItemsExtracted + totalListItemsExtracted; @@ -234,6 +305,21 @@ async function executeRun(id: string, userId: string) { } ); + try { + const completionData = { + runId: plainRun.runId, + robotMetaId: plainRun.robotMetaId, + robotName: recording.recording_meta.name, + status: 'success', + finishedAt: new Date().toLocaleString() + }; + + serverIo.of(plainRun.browserId).emit('run-completed', completionData); + serverIo.of('/queued-run').to(`user-${userId}`).emit('run-completed', completionData); + } catch (emitError: any) { + logger.log('warn', `Failed to emit success event: ${emitError.message}`); + } + const webhookPayload = { robot_id: plainRun.robotMetaId, run_id: plainRun.runId, @@ -242,16 +328,20 @@ async function executeRun(id: string, userId: string) { started_at: plainRun.startedAt, finished_at: new Date().toLocaleString(), extracted_data: { - captured_texts: updatedRun?.serializableOutput?.scrapeSchema ? Object.values(updatedRun.serializableOutput.scrapeSchema).flat() : [], - captured_lists: updatedRun?.serializableOutput?.scrapeList || {}, - total_rows: totalRowsExtracted, + captured_texts: Object.keys(categorizedOutput.scrapeSchema || {}).length > 0 + ? Object.entries(categorizedOutput.scrapeSchema).reduce((acc, [name, value]) => { + acc[name] = Array.isArray(value) ? value : [value]; + return acc; + }, {} as Record) + : {}, + captured_lists: categorizedOutput.scrapeList, captured_texts_count: totalSchemaItemsExtracted, captured_lists_count: totalListItemsExtracted, screenshots_count: extractedScreenshotsCount }, metadata: { browser_id: plainRun.browserId, - user_id: userId + user_id: userId, } }; @@ -262,26 +352,7 @@ async function executeRun(id: string, userId: string) { logger.log('error', `Failed to send webhooks for run ${plainRun.runId}: ${webhookError.message}`); } - try { - googleSheetUpdateTasks[plainRun.runId] = { - robotId: plainRun.robotMetaId, - runId: plainRun.runId, - status: 'pending', - retries: 5, - }; - - airtableUpdateTasks[plainRun.runId] = { - robotId: plainRun.robotMetaId, - runId: plainRun.runId, - status: 'pending', - retries: 5, - }; - - processAirtableUpdates().catch(err => logger.log('error', `Airtable update error: ${err.message}`)); - processGoogleSheetUpdates().catch(err => logger.log('error', `Google Sheets update error: ${err.message}`)); - } catch (err: any) { - logger.log('error', `Failed to update Google Sheet for run: ${plainRun.runId}: ${err.message}`); - } + await triggerIntegrationUpdates(plainRun.runId, plainRun.robotMetaId); return true; } catch (error: any) { logger.log('info', `Error while running a robot with id: ${id} - ${error.message}`); @@ -320,6 +391,21 @@ async function executeRun(id: string, userId: string) { } catch (webhookError: any) { logger.log('error', `Failed to send failure webhooks for run ${run.runId}: ${webhookError.message}`); } + + try { + const failureSocketData = { + runId: run.runId, + robotMetaId: run.robotMetaId, + robotName: recording ? recording.recording_meta.name : 'Unknown Robot', + status: 'failed', + finishedAt: new Date().toLocaleString() + }; + + serverIo.of(run.browserId).emit('run-completed', failureSocketData); + serverIo.of('/queued-run').to(`user-${userId}`).emit('run-completed', failureSocketData); + } catch (socketError: any) { + logger.log('warn', `Failed to emit failure event in main catch: ${socketError.message}`); + } } capture( 'maxun-oss-run-created-scheduled', diff --git a/src/context/browserSteps.tsx b/src/context/browserSteps.tsx index de7a6460..d28aef46 100644 --- a/src/context/browserSteps.tsx +++ b/src/context/browserSteps.tsx @@ -1,6 +1,7 @@ import React, { createContext, useContext, useEffect, useRef, useState } from 'react'; import { useSocketStore } from "./socket"; import { useGlobalInfoStore } from "./globalInfo"; +import { useActionContext } from './browserActions'; export interface TextStep { id: number; @@ -96,6 +97,7 @@ export const BrowserStepsProvider: React.FC<{ children: React.ReactNode }> = ({ const { currentTextGroupName } = useGlobalInfoStore(); const [browserSteps, setBrowserSteps] = useState([]); const [discardedFields, setDiscardedFields] = useState>(new Set()); + const { paginationType, limitType, customLimit } = useActionContext(); const browserStepsRef = useRef(browserSteps); useEffect(() => { @@ -127,15 +129,21 @@ export const BrowserStepsProvider: React.FC<{ children: React.ReactNode }> = ({ } }); + const livePaginationType = paginationType || listStep.pagination?.type || ""; + const liveLimit = + limitType === "custom" + ? parseInt(customLimit || "0", 10) + : parseInt(limitType || "0", 10); + return { listSelector: listStep.listSelector, fields: fields, pagination: { - type: listStep.pagination?.type || "", + type: livePaginationType, selector: listStep.pagination?.selector, isShadow: listStep.isShadow }, - limit: listStep.limit, + limit: liveLimit > 0 ? liveLimit : listStep.limit, isShadow: listStep.isShadow }; }; From ebbadeb16eb9fa6624163027be484f1e04ee6d59 Mon Sep 17 00:00:00 2001 From: amhsirak Date: Wed, 22 Oct 2025 13:17:57 +0530 Subject: [PATCH 04/28] feat: remove run logs tab --- src/components/run/RunContent.tsx | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/components/run/RunContent.tsx b/src/components/run/RunContent.tsx index ceffc025..79045cdc 100644 --- a/src/components/run/RunContent.tsx +++ b/src/components/run/RunContent.tsx @@ -643,19 +643,6 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe } }} /> - theme.palette.mode === 'dark' ? '#fff' : '#000', - '&:hover': { - color: '#FF00C3' - }, - '&.Mui-selected': { - color: '#FF00C3', - } - }} - /> From 2b06cb51b2b8c864297a2fdd7aa6ab18951f8e51 Mon Sep 17 00:00:00 2001 From: amhsirak Date: Wed, 22 Oct 2025 13:26:00 +0530 Subject: [PATCH 05/28] feat: remove output data tab --- src/components/run/RunContent.tsx | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/components/run/RunContent.tsx b/src/components/run/RunContent.tsx index 79045cdc..aac07d20 100644 --- a/src/components/run/RunContent.tsx +++ b/src/components/run/RunContent.tsx @@ -630,19 +630,6 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe } }} > - theme.palette.mode === 'dark' ? '#fff' : '#000', - '&:hover': { - color: '#FF00C3' - }, - '&.Mui-selected': { - color: '#FF00C3', - } - }} - /> From ad809e8b8a84089060c16bcde755cc973041a498 Mon Sep 17 00:00:00 2001 From: amhsirak Date: Wed, 22 Oct 2025 13:28:01 +0530 Subject: [PATCH 06/28] feat: remove output data tab panel --- src/components/run/RunContent.tsx | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/src/components/run/RunContent.tsx b/src/components/run/RunContent.tsx index aac07d20..74053bf0 100644 --- a/src/components/run/RunContent.tsx +++ b/src/components/run/RunContent.tsx @@ -632,31 +632,6 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe > - - -
- - {row.status === 'running' ? currentLog : row.log} - -
-
- - {row.status === 'running' || row.status === 'queued' ? : null} - {row.status === 'running' || row.status === 'queued' ? ( From cf13b11417729edd882e04daea1fd962da149a4c Mon Sep 17 00:00:00 2001 From: amhsirak Date: Wed, 22 Oct 2025 13:29:10 +0530 Subject: [PATCH 07/28] feat: remove tabs --- src/components/run/RunContent.tsx | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/components/run/RunContent.tsx b/src/components/run/RunContent.tsx index 74053bf0..b717548d 100644 --- a/src/components/run/RunContent.tsx +++ b/src/components/run/RunContent.tsx @@ -615,22 +615,6 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe - setTab(newTab)} - aria-label="run-content-tabs" - sx={{ - '& .MuiTabs-indicator': { - backgroundColor: '#FF00C3', - }, - '& .MuiTab-root': { - '&.Mui-selected': { - color: '#FF00C3', - }, - } - }} - > - {row.status === 'running' || row.status === 'queued' ? ( From e9e20ced3fda0d0f2e3a8912c91bfaeb54b9a69c Mon Sep 17 00:00:00 2001 From: amhsirak Date: Wed, 22 Oct 2025 13:32:05 +0530 Subject: [PATCH 08/28] feat: remove unused box --- src/components/run/RunContent.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/run/RunContent.tsx b/src/components/run/RunContent.tsx index b717548d..65a49a2e 100644 --- a/src/components/run/RunContent.tsx +++ b/src/components/run/RunContent.tsx @@ -614,8 +614,6 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe return ( - - {row.status === 'running' || row.status === 'queued' ? ( From faba6a22f1380da50b0a4a4401d0113618d426d9 Mon Sep 17 00:00:00 2001 From: amhsirak Date: Wed, 22 Oct 2025 13:38:20 +0530 Subject: [PATCH 09/28] feat: remove tab panel --- src/components/run/RunContent.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/run/RunContent.tsx b/src/components/run/RunContent.tsx index 65a49a2e..c5e4ab06 100644 --- a/src/components/run/RunContent.tsx +++ b/src/components/run/RunContent.tsx @@ -614,7 +614,6 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe return ( - {row.status === 'running' || row.status === 'queued' ? ( @@ -908,7 +907,6 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe )} - ); From f6b781e452807b07496f5db65351c2de89e7f3bd Mon Sep 17 00:00:00 2001 From: amhsirak Date: Wed, 22 Oct 2025 13:43:14 +0530 Subject: [PATCH 10/28] fix: bring back tab panel --- src/components/run/RunContent.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/run/RunContent.tsx b/src/components/run/RunContent.tsx index c5e4ab06..65a49a2e 100644 --- a/src/components/run/RunContent.tsx +++ b/src/components/run/RunContent.tsx @@ -614,6 +614,7 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe return ( + {row.status === 'running' || row.status === 'queued' ? ( @@ -907,6 +908,7 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe )} + ); From dda96bbfc178f29e4ac001f888b8ae51378fdb6e Mon Sep 17 00:00:00 2001 From: amhsirak Date: Wed, 22 Oct 2025 13:44:34 +0530 Subject: [PATCH 11/28] fix: increase max width of tab panel --- src/components/run/RunContent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/run/RunContent.tsx b/src/components/run/RunContent.tsx index 65a49a2e..1102a5cf 100644 --- a/src/components/run/RunContent.tsx +++ b/src/components/run/RunContent.tsx @@ -614,7 +614,7 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe return ( - + {row.status === 'running' || row.status === 'queued' ? ( From b752a9fd2fcc75a150b0cf47fcc56c43e7d633e0 Mon Sep 17 00:00:00 2001 From: amhsirak Date: Wed, 22 Oct 2025 13:50:39 +0530 Subject: [PATCH 12/28] fix: download json label --- src/components/run/RunContent.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/run/RunContent.tsx b/src/components/run/RunContent.tsx index 1102a5cf..f8d444f4 100644 --- a/src/components/run/RunContent.tsx +++ b/src/components/run/RunContent.tsx @@ -454,7 +454,7 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe } }} > - {t('run_content.captured_data.download_json', 'Download as JSON')} + {t('run_content.captured_data.download_json', 'Download JSON')} - - - ) : !isCaptureTextConfirmed && ( - - - - )} - - )} - {step.type === 'screenshot' && ( - - - - {step.fullPage ? - t('right_panel.screenshot.display_fullpage') : - t('right_panel.screenshot.display_visible')} - - - )} - {step.type === 'list' && ( - Object.entries(step.fields).length === 0 ? ( - {t('right_panel.messages.list_empty')} - ) : ( - <> - {t('right_panel.messages.list_selected')} - {Object.entries(step.fields).map(([key, field]) => ( - - handleTextLabelChange(field.id, e.target.value, step.id, key)} - fullWidth - margin="normal" - InputProps={{ - readOnly: confirmedListTextFields[field.id]?.[key], - startAdornment: ( - - - - ) - }} - /> - - - - ) - }} - /> - {!confirmedListTextFields[step.id]?.[key] && ( - - - - - )} - - ))} - - ) - )} - - ))} - ); }; From 26f8336622ff4908ff69972d35fef426f2d50c09 Mon Sep 17 00:00:00 2001 From: Rohit Rajan Date: Wed, 22 Oct 2025 16:07:28 +0530 Subject: [PATCH 26/28] feat: add no action selected message --- src/components/run/InterpretationLog.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/components/run/InterpretationLog.tsx b/src/components/run/InterpretationLog.tsx index 6cc7404b..74c46f4e 100644 --- a/src/components/run/InterpretationLog.tsx +++ b/src/components/run/InterpretationLog.tsx @@ -513,6 +513,16 @@ export const InterpretationLog: React.FC = ({ isOpen, se {t('interpretation_log.titles.output_preview')} + {!(hasScrapeListAction || hasScrapeSchemaAction || hasScreenshotAction) && ( + + + + {t('interpretation_log.messages.no_selection')} + + + + )} + {showPreviewData && availableTabs.length > 0 && ( <> {shouldShowTabs && ( From f9309a743891faf7bca907e0c07fea8ff3e947fc Mon Sep 17 00:00:00 2001 From: Rohit Rajan Date: Wed, 22 Oct 2025 16:10:41 +0530 Subject: [PATCH 27/28] chore: rm bg-color fixed highlighting --- src/helpers/capturedElementHighlighter.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/helpers/capturedElementHighlighter.ts b/src/helpers/capturedElementHighlighter.ts index 4eb7aa3e..3d05af54 100644 --- a/src/helpers/capturedElementHighlighter.ts +++ b/src/helpers/capturedElementHighlighter.ts @@ -27,7 +27,6 @@ class CapturedElementHighlighter { ${cssSelector} { outline: 2px dotted #ff00c3 !important; outline-offset: 2px !important; - background-color: rgba(255, 0, 195, 0.08) !important; box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.5) !important; } `); From c2264b66c87269b44446275a6fcdb5606e4b4074 Mon Sep 17 00:00:00 2001 From: Rohit Rajan Date: Wed, 22 Oct 2025 21:04:24 +0530 Subject: [PATCH 28/28] fix: child selector gen specificity --- src/helpers/clientSelectorGenerator.ts | 46 -------------------------- 1 file changed, 46 deletions(-) diff --git a/src/helpers/clientSelectorGenerator.ts b/src/helpers/clientSelectorGenerator.ts index d4e5051c..03ad67a2 100644 --- a/src/helpers/clientSelectorGenerator.ts +++ b/src/helpers/clientSelectorGenerator.ts @@ -2713,52 +2713,6 @@ class ClientSelectorGenerator { } } - const testId = element.getAttribute("data-testid"); - if (testId && !addPositionToAll) { - const isCommon = this.isAttributeCommonAcrossLists( - element, - "data-testid", - testId, - otherListElements - ); - if (isCommon) { - return `${tagName}[@data-testid='${testId}']`; - } - } - - if (element.id && !element.id.match(/^\d/) && !addPositionToAll) { - const isCommon = this.isAttributeCommonAcrossLists( - element, - "id", - element.id, - otherListElements - ); - if (isCommon) { - return `${tagName}[@id='${element.id}']`; - } - } - - if (!addPositionToAll) { - for (const attr of Array.from(element.attributes)) { - if ( - attr.name.startsWith("data-") && - attr.name !== "data-testid" && - attr.name !== "data-mx-id" && - attr.value - ) { - const isCommon = this.isAttributeCommonAcrossLists( - element, - attr.name, - attr.value, - otherListElements - ); - if (isCommon) { - return `${tagName}[@${attr.name}='${attr.value}']`; - } - } - } - } - const position = this.getSiblingPosition(element, parent); if (addPositionToAll || classes.length === 0) {