diff --git a/legacy/src/RobotDuplicate.tsx b/legacy/src/RobotDuplicate.tsx new file mode 100644 index 00000000..bee1ef5b --- /dev/null +++ b/legacy/src/RobotDuplicate.tsx @@ -0,0 +1,172 @@ +import React, { useState, useEffect } from 'react'; +import { GenericModal } from "../ui/GenericModal"; +import { TextField, Typography, Box, Button } from "@mui/material"; +import { modalStyle } from "../recorder/AddWhereCondModal"; +import { useGlobalInfoStore } from '../../context/globalInfo'; +import { duplicateRecording, getStoredRecording } from '../../api/storage'; +import { WhereWhatPair } from 'maxun-core'; +import { useTranslation } from 'react-i18next'; + +interface RobotMeta { + name: string; + id: string; + createdAt: string; + pairs: number; + updatedAt: string; + params: any[]; +} + +interface RobotWorkflow { + workflow: WhereWhatPair[]; +} + +interface ScheduleConfig { + runEvery: number; + runEveryUnit: 'MINUTES' | 'HOURS' | 'DAYS' | 'WEEKS' | 'MONTHS'; + startFrom: 'SUNDAY' | 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'THURSDAY' | 'FRIDAY' | 'SATURDAY'; + atTimeStart?: string; + atTimeEnd?: string; + timezone: string; + lastRunAt?: Date; + nextRunAt?: Date; + cronExpression?: string; +} + +export interface RobotSettings { + id: string; + userId?: number; + recording_meta: RobotMeta; + recording: RobotWorkflow; + google_sheet_email?: string | null; + google_sheet_name?: string | null; + google_sheet_id?: string | null; + google_access_token?: string | null; + google_refresh_token?: string | null; + schedule?: ScheduleConfig | null; +} + +interface RobotSettingsProps { + isOpen: boolean; + handleStart: (settings: RobotSettings) => void; + handleClose: () => void; + initialSettings?: RobotSettings | null; + +} + +export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => { + const { t } = useTranslation(); + const [targetUrl, setTargetUrl] = useState(''); + const [robot, setRobot] = useState(null); + const { recordingId, notify, setRerenderRobots } = useGlobalInfoStore(); + + useEffect(() => { + if (isOpen) { + getRobot(); + } + }, [isOpen]); + + useEffect(() => { + if (robot) { + const lastPair = robot?.recording.workflow[robot?.recording.workflow.length - 1]; + const url = lastPair?.what.find(action => action.action === "goto")?.args?.[0]; + setTargetUrl(url); + } + }, [robot]); + + const getRobot = async () => { + if (recordingId) { + const robot = await getStoredRecording(recordingId); + setRobot(robot); + } else { + notify('error', t('robot_duplication.notifications.robot_not_found')); + } + } + + const handleTargetUrlChange = (e: React.ChangeEvent) => { + setTargetUrl(e.target.value); + }; + + const handleSave = async () => { + if (!robot || !targetUrl) { + notify('error', t('robot_duplication.notifications.url_required')); + return; + } + + try { + const success = await duplicateRecording(robot.recording_meta.id, targetUrl); + + if (success) { + setRerenderRobots(true); + + notify('success', t('robot_duplication.notifications.duplicate_success')); + handleStart(robot); + handleClose(); + } else { + notify('error', t('robot_duplication.notifications.duplicate_error')); + } + } catch (error) { + notify('error', t('robot_duplication.notifications.unknown_error')); + console.error('Error updating Target URL:', error); + } + }; + + return ( + + <> + + {t('robot_duplication.title')} + + + { + robot && ( + <> + + {t('robot_duplication.descriptions.purpose')} + +
+ producthunt.com/topics/api', + url2: 'producthunt.com/topics/database' + }) + }} /> +
+ + {t('robot_duplication.descriptions.warning')} + + + + + + + + ) + } +
+ +
+ ); +}; diff --git a/legacy/src/RobotEdit.tsx b/legacy/src/RobotEdit.tsx new file mode 100644 index 00000000..3b110ba1 --- /dev/null +++ b/legacy/src/RobotEdit.tsx @@ -0,0 +1,586 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { GenericModal } from "../ui/GenericModal"; +import { TextField, Typography, Box, Button, IconButton, InputAdornment } from "@mui/material"; +import { Visibility, VisibilityOff } from '@mui/icons-material'; +import { modalStyle } from "../recorder/AddWhereCondModal"; +import { useGlobalInfoStore } from '../../context/globalInfo'; +import { getStoredRecording, updateRecording } from '../../api/storage'; +import { WhereWhatPair } from 'maxun-core'; + +interface RobotMeta { + name: string; + id: string; + createdAt: string; + pairs: number; + updatedAt: string; + params: any[]; +} + +interface RobotWorkflow { + workflow: WhereWhatPair[]; +} + +interface ScheduleConfig { + runEvery: number; + runEveryUnit: 'MINUTES' | 'HOURS' | 'DAYS' | 'WEEKS' | 'MONTHS'; + startFrom: 'SUNDAY' | 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'THURSDAY' | 'FRIDAY' | 'SATURDAY'; + atTimeStart?: string; + atTimeEnd?: string; + timezone: string; + lastRunAt?: Date; + nextRunAt?: Date; + cronExpression?: string; +} + +export interface RobotSettings { + id: string; + userId?: number; + recording_meta: RobotMeta; + recording: RobotWorkflow; + google_sheet_email?: string | null; + google_sheet_name?: string | null; + google_sheet_id?: string | null; + google_access_token?: string | null; + google_refresh_token?: string | null; + schedule?: ScheduleConfig | null; +} + +interface RobotSettingsProps { + isOpen: boolean; + handleStart: (settings: RobotSettings) => void; + handleClose: () => void; + initialSettings?: RobotSettings | null; +} + +interface CredentialInfo { + value: string; + type: string; +} + +interface Credentials { + [key: string]: CredentialInfo; +} + +interface CredentialVisibility { + [key: string]: boolean; +} + +interface GroupedCredentials { + passwords: string[]; + emails: string[]; + usernames: string[]; + others: string[]; +} + +interface ScrapeListLimit { + pairIndex: number; + actionIndex: number; + argIndex: number; + currentLimit: number; +} + +export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => { + const { t } = useTranslation(); + const [credentials, setCredentials] = useState({}); + const { recordingId, notify, setRerenderRobots } = useGlobalInfoStore(); + const [robot, setRobot] = useState(null); + const [credentialGroups, setCredentialGroups] = useState({ + passwords: [], + emails: [], + usernames: [], + others: [] + }); + const [showPasswords, setShowPasswords] = useState({}); + const [scrapeListLimits, setScrapeListLimits] = useState([]); + + const isEmailPattern = (value: string): boolean => { + return value.includes('@'); + }; + + const isUsernameSelector = (selector: string): boolean => { + return selector.toLowerCase().includes('username') || + selector.toLowerCase().includes('user') || + selector.toLowerCase().includes('email'); + }; + + const determineCredentialType = (selector: string, info: CredentialInfo): 'password' | 'email' | 'username' | 'other' => { + if (info.type === 'password' || selector.toLowerCase().includes('password')) { + return 'password'; + } + if (isEmailPattern(info.value) || selector.toLowerCase().includes('email')) { + return 'email'; + } + if (isUsernameSelector(selector)) { + return 'username'; + } + return 'other'; + }; + + useEffect(() => { + if (isOpen) { + getRobot(); + } + }, [isOpen]); + + useEffect(() => { + if (robot?.recording?.workflow) { + const extractedCredentials = extractInitialCredentials(robot.recording.workflow); + setCredentials(extractedCredentials); + setCredentialGroups(groupCredentialsByType(extractedCredentials)); + + findScrapeListLimits(robot.recording.workflow); + } + }, [robot]); + + const findScrapeListLimits = (workflow: WhereWhatPair[]) => { + const limits: ScrapeListLimit[] = []; + + workflow.forEach((pair, pairIndex) => { + if (!pair.what) return; + + pair.what.forEach((action, actionIndex) => { + if (action.action === 'scrapeList' && action.args && action.args.length > 0) { + // Check if first argument has a limit property + const arg = action.args[0]; + if (arg && typeof arg === 'object' && 'limit' in arg) { + limits.push({ + pairIndex, + actionIndex, + argIndex: 0, + currentLimit: arg.limit + }); + } + } + }); + }); + + setScrapeListLimits(limits); + }; + + function extractInitialCredentials(workflow: any[]): Credentials { + const credentials: Credentials = {}; + + const isPrintableCharacter = (char: string): boolean => { + return char.length === 1 && !!char.match(/^[\x20-\x7E]$/); + }; + + workflow.forEach(step => { + if (!step.what) return; + + let currentSelector = ''; + let currentValue = ''; + let currentType = ''; + let i = 0; + + while (i < step.what.length) { + const action = step.what[i]; + + if (!action.action || !action.args?.[0]) { + i++; + continue; + } + + const selector = action.args[0]; + + // Handle full word type actions first + if (action.action === 'type' && + action.args?.length >= 2 && + typeof action.args[1] === 'string' && + action.args[1].length > 1) { + + if (!credentials[selector]) { + credentials[selector] = { + value: action.args[1], + type: action.args[2] || 'text' + }; + } + i++; + continue; + } + + // Handle character-by-character sequences (both type and press) + if ((action.action === 'type' || action.action === 'press') && + action.args?.length >= 2 && + typeof action.args[1] === 'string') { + + if (selector !== currentSelector) { + if (currentSelector && currentValue) { + credentials[currentSelector] = { + value: currentValue, + type: currentType || 'text' + }; + } + currentSelector = selector; + currentValue = credentials[selector]?.value || ''; + currentType = action.args[2] || credentials[selector]?.type || 'text'; + } + + const character = action.args[1]; + + if (isPrintableCharacter(character)) { + currentValue += character; + } else if (character === 'Backspace') { + currentValue = currentValue.slice(0, -1); + } + + if (!currentType && action.args[2]?.toLowerCase() === 'password') { + currentType = 'password'; + } + + let j = i + 1; + while (j < step.what.length) { + const nextAction = step.what[j]; + if (!nextAction.action || !nextAction.args?.[0] || + nextAction.args[0] !== selector || + (nextAction.action !== 'type' && nextAction.action !== 'press')) { + break; + } + if (nextAction.args[1] === 'Backspace') { + currentValue = currentValue.slice(0, -1); + } else if (isPrintableCharacter(nextAction.args[1])) { + currentValue += nextAction.args[1]; + } + j++; + } + + credentials[currentSelector] = { + value: currentValue, + type: currentType + }; + + i = j; + } else { + i++; + } + } + + if (currentSelector && currentValue) { + credentials[currentSelector] = { + value: currentValue, + type: currentType || 'text' + }; + } + }); + + return credentials; + } + + const groupCredentialsByType = (credentials: Credentials): GroupedCredentials => { + return Object.entries(credentials).reduce((acc: GroupedCredentials, [selector, info]) => { + const credentialType = determineCredentialType(selector, info); + + switch (credentialType) { + case 'password': + acc.passwords.push(selector); + break; + case 'email': + acc.emails.push(selector); + break; + case 'username': + acc.usernames.push(selector); + break; + default: + acc.others.push(selector); + } + + return acc; + }, { passwords: [], emails: [], usernames: [], others: [] }); + }; + + const getRobot = async () => { + if (recordingId) { + const robot = await getStoredRecording(recordingId); + setRobot(robot); + } else { + notify('error', t('robot_edit.notifications.update_failed')); + } + }; + + const handleClickShowPassword = (selector: string) => { + setShowPasswords(prev => ({ + ...prev, + [selector]: !prev[selector] + })); + }; + + const handleRobotNameChange = (newName: string) => { + setRobot((prev) => + prev ? { ...prev, recording_meta: { ...prev.recording_meta, name: newName } } : prev + ); + }; + + const handleCredentialChange = (selector: string, value: string) => { + setCredentials(prev => ({ + ...prev, + [selector]: { + ...prev[selector], + value + } + })); + }; + + const handleLimitChange = (pairIndex: number, actionIndex: number, argIndex: number, newLimit: number) => { + setRobot((prev) => { + if (!prev) return prev; + + const updatedWorkflow = [...prev.recording.workflow]; + if ( + updatedWorkflow.length > pairIndex && + updatedWorkflow[pairIndex]?.what && + updatedWorkflow[pairIndex].what.length > actionIndex && + updatedWorkflow[pairIndex].what[actionIndex].args && + updatedWorkflow[pairIndex].what[actionIndex].args.length > argIndex + ) { + updatedWorkflow[pairIndex].what[actionIndex].args[argIndex].limit = newLimit; + + setScrapeListLimits(prev => { + return prev.map(item => { + if (item.pairIndex === pairIndex && + item.actionIndex === actionIndex && + item.argIndex === argIndex) { + return { ...item, currentLimit: newLimit }; + } + return item; + }); + }); + } + + return { ...prev, recording: { ...prev.recording, workflow: updatedWorkflow } }; + }); + }; + + const handleTargetUrlChange = (newUrl: string) => { + setRobot((prev) => { + if (!prev) return prev; + + const updatedWorkflow = [...prev.recording.workflow]; + const lastPairIndex = updatedWorkflow.length - 1; + + if (lastPairIndex >= 0) { + const gotoAction = updatedWorkflow[lastPairIndex]?.what?.find(action => action.action === "goto"); + if (gotoAction && gotoAction.args && gotoAction.args.length > 0) { + gotoAction.args[0] = newUrl; + } + } + + return { ...prev, recording: { ...prev.recording, workflow: updatedWorkflow } }; + }); + }; + + const renderAllCredentialFields = () => { + return ( + <> + {renderCredentialFields( + credentialGroups.usernames, + t('Username'), + 'text' + )} + + {renderCredentialFields( + credentialGroups.emails, + t('Email'), + 'text' + )} + + {renderCredentialFields( + credentialGroups.passwords, + t('Password'), + 'password' + )} + + {renderCredentialFields( + credentialGroups.others, + t('Other'), + 'text' + )} + + ); + }; + + const renderCredentialFields = (selectors: string[], headerText: string, defaultType: 'text' | 'password' = 'text') => { + if (selectors.length === 0) return null; + + return ( + <> + {selectors.map((selector, index) => { + const isVisible = showPasswords[selector]; + + return ( + handleCredentialChange(selector, e.target.value)} + style={{ marginBottom: '20px' }} + InputProps={{ + endAdornment: ( + + handleClickShowPassword(selector)} + edge="end" + disabled={!credentials[selector]?.value} + > + {isVisible ? : } + + + ), + }} + /> + ); + })} + + ); + }; + + const renderScrapeListLimitFields = () => { + if (scrapeListLimits.length === 0) return null; + + return ( + <> + + {t('List Limits')} + + + {scrapeListLimits.map((limitInfo, index) => ( + { + const value = parseInt(e.target.value, 10); + if (value >= 1) { + handleLimitChange( + limitInfo.pairIndex, + limitInfo.actionIndex, + limitInfo.argIndex, + value + ); + } + }} + inputProps={{ min: 1 }} + style={{ marginBottom: '20px' }} + /> + ))} + + ); + }; + + const handleSave = async () => { + if (!robot) return; + + try { + const credentialsForPayload = Object.entries(credentials).reduce((acc, [selector, info]) => { + const enforceType = info.type === 'password' ? 'password' : 'text'; + + acc[selector] = { + value: info.value, + type: enforceType + }; + return acc; + }, {} as Record); + + const lastPair = robot.recording.workflow[robot.recording.workflow.length - 1]; + const targetUrl = lastPair?.what.find(action => action.action === "goto")?.args?.[0]; + + const payload = { + name: robot.recording_meta.name, + limits: scrapeListLimits.map(limit => ({ + pairIndex: limit.pairIndex, + actionIndex: limit.actionIndex, + argIndex: limit.argIndex, + limit: limit.currentLimit + })), + credentials: credentialsForPayload, + targetUrl: targetUrl, + }; + + const success = await updateRecording(robot.recording_meta.id, payload); + + if (success) { + setRerenderRobots(true); + + notify('success', t('robot_edit.notifications.update_success')); + handleStart(robot); + handleClose(); + } else { + notify('error', t('robot_edit.notifications.update_failed')); + } + } catch (error) { + notify('error', t('robot_edit.notifications.update_error')); + console.error('Error updating robot:', error); + } + }; + + const lastPair = robot?.recording.workflow[robot?.recording.workflow.length - 1]; + const targetUrl = lastPair?.what.find(action => action.action === "goto")?.args?.[0]; + + return ( + + <> + + {t('robot_edit.title')} + + + {robot && ( + <> + handleRobotNameChange(e.target.value)} + style={{ marginBottom: '20px' }} + /> + + handleTargetUrlChange(e.target.value)} + style={{ marginBottom: '20px' }} + /> + + {renderScrapeListLimitFields()} + + {(Object.keys(credentials).length > 0) && ( + <> + + {t('Input Texts')} + + {renderAllCredentialFields()} + + )} + + + + + + + )} + + + + ); +}; \ No newline at end of file diff --git a/legacy/src/RobotSettings.tsx b/legacy/src/RobotSettings.tsx new file mode 100644 index 00000000..c91b4f11 --- /dev/null +++ b/legacy/src/RobotSettings.tsx @@ -0,0 +1,173 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { GenericModal } from "../ui/GenericModal"; +import { TextField, Typography, Box } from "@mui/material"; +import { useGlobalInfoStore } from '../../context/globalInfo'; +import { getStoredRecording } from '../../api/storage'; +import { WhereWhatPair } from 'maxun-core'; +import { getUserById } from "../../api/auth"; + +interface RobotMeta { + name: string; + id: string; + createdAt: string; + pairs: number; + updatedAt: string; + params: any[]; +} + +interface RobotWorkflow { + workflow: WhereWhatPair[]; +} + +interface ScheduleConfig { + runEvery: number; + runEveryUnit: 'MINUTES' | 'HOURS' | 'DAYS' | 'WEEKS' | 'MONTHS'; + startFrom: 'SUNDAY' | 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'THURSDAY' | 'FRIDAY' | 'SATURDAY'; + atTimeStart?: string; + atTimeEnd?: string; + timezone: string; + lastRunAt?: Date; + nextRunAt?: Date; + cronExpression?: string; +} + +export interface RobotSettings { + id: string; + userId?: number; + recording_meta: RobotMeta; + recording: RobotWorkflow; + google_sheet_email?: string | null; + google_sheet_name?: string | null; + google_sheet_id?: string | null; + google_access_token?: string | null; + google_refresh_token?: string | null; + schedule?: ScheduleConfig | null; +} + +interface RobotSettingsProps { + isOpen: boolean; + handleStart: (settings: RobotSettings) => void; + handleClose: () => void; + initialSettings?: RobotSettings | null; +} + +export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => { + const { t } = useTranslation(); + const [userEmail, setUserEmail] = useState(null); + const [robot, setRobot] = useState(null); + const { recordingId, notify } = useGlobalInfoStore(); + + useEffect(() => { + if (isOpen) { + getRobot(); + } + }, [isOpen]); + + const getRobot = async () => { + if (recordingId) { + const robot = await getStoredRecording(recordingId); + setRobot(robot); + } else { + notify('error', t('robot_settings.errors.robot_not_found')); + } + } + + const lastPair = robot?.recording.workflow[robot?.recording.workflow.length - 1]; + + // Find the `goto` action in `what` and retrieve its arguments + const targetUrl = lastPair?.what.find(action => action.action === "goto")?.args?.[0]; + + useEffect(() => { + const fetchUserEmail = async () => { + if (robot && robot.userId) { + const userData = await getUserById(robot.userId.toString()); + if (userData && userData.user) { + setUserEmail(userData.user.email); + } + } + }; + fetchUserEmail(); + }, [robot?.userId]); + + return ( + + <> + + {t('robot_settings.title')} + + + { + robot && ( + <> + + + {robot.recording.workflow?.[0]?.what?.[0]?.args?.[0]?.limit !== undefined && ( + + )} + + + + ) + } + + + + ); +}; + +export const modalStyle = { +top: "50%", +left: "50%", +transform: "translate(-50%, -50%)", +width: "30%", +backgroundColor: "background.paper", +p: 4, +height: "fit-content", +display: "block", +padding: "20px", +}; diff --git a/legacy/src/ScheduleSettings.tsx b/legacy/src/ScheduleSettings.tsx new file mode 100644 index 00000000..7a28d2dc --- /dev/null +++ b/legacy/src/ScheduleSettings.tsx @@ -0,0 +1,314 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { GenericModal } from "../ui/GenericModal"; +import { MenuItem, TextField, Typography, Box } from "@mui/material"; +import { Dropdown } from "../ui/DropdownMui"; +import Button from "@mui/material/Button"; +import { validMomentTimezones } from '../../constants/const'; +import { useGlobalInfoStore } from '../../context/globalInfo'; +import { getSchedule, deleteSchedule } from '../../api/storage'; + +interface ScheduleSettingsProps { + isOpen: boolean; + handleStart: (settings: ScheduleSettings) => Promise; + handleClose: () => void; + initialSettings?: ScheduleSettings | null; +} + +export interface ScheduleSettings { + runEvery: number; + runEveryUnit: string; + startFrom: string; + dayOfMonth?: string; + atTimeStart?: string; + atTimeEnd?: string; + timezone: string; +} + +export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initialSettings }: ScheduleSettingsProps) => { + const { t } = useTranslation(); + const [schedule, setSchedule] = useState(null); + const [settings, setSettings] = useState({ + runEvery: 1, + runEveryUnit: 'HOURS', + startFrom: 'MONDAY', + dayOfMonth: '1', + atTimeStart: '00:00', + atTimeEnd: '01:00', + timezone: 'UTC' + }); + + useEffect(() => { + if (initialSettings) { + setSettings(initialSettings); + } + }, [initialSettings]); + + const handleChange = (field: keyof ScheduleSettings, value: string | number | boolean) => { + setSettings(prev => ({ ...prev, [field]: value })); + }; + + const textStyle = { + width: '150px', + height: '52px', + marginRight: '10px', + }; + + const dropDownStyle = { + marginTop: '2px', + width: '150px', + height: '59px', + marginRight: '10px', + }; + + const units = [ + 'MINUTES', + 'HOURS', + 'DAYS', + 'WEEKS', + 'MONTHS' + ]; + + const days = [ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY' + ]; + + const { recordingId, notify } = useGlobalInfoStore(); + + const deleteRobotSchedule = () => { + if (recordingId) { + deleteSchedule(recordingId); + setSchedule(null); + notify('success', t('Schedule deleted successfully')); + } else { + console.error('No recording id provided'); + } + + setSettings({ + runEvery: 1, + runEveryUnit: 'HOURS', + startFrom: 'MONDAY', + dayOfMonth: '', + atTimeStart: '00:00', + atTimeEnd: '01:00', + timezone: 'UTC' + }); + }; + + const getRobotSchedule = async () => { + if (recordingId) { + const scheduleData = await getSchedule(recordingId); + setSchedule(scheduleData); + } else { + console.error('No recording id provided'); + } + } + + useEffect(() => { + if (isOpen) { + const fetchSchedule = async () => { + await getRobotSchedule(); + }; + fetchSchedule(); + } + }, [isOpen]); + + const getDayOrdinal = (day: string | undefined) => { + if (!day) return ''; + const lastDigit = day.slice(-1); + const lastTwoDigits = day.slice(-2); + + // Special cases for 11, 12, 13 + if (['11', '12', '13'].includes(lastTwoDigits)) { + return t('schedule_settings.labels.on_day.th'); + } + + // Other cases + switch (lastDigit) { + case '1': return t('schedule_settings.labels.on_day.st'); + case '2': return t('schedule_settings.labels.on_day.nd'); + case '3': return t('schedule_settings.labels.on_day.rd'); + default: return t('schedule_settings.labels.on_day.th'); + } + }; + + return ( + + *': { marginBottom: '20px' }, + }}> + {t('schedule_settings.title')} + <> + {schedule !== null ? ( + <> + {t('schedule_settings.run_every')}: {schedule.runEvery} {schedule.runEveryUnit.toLowerCase()} + {['MONTHS', 'WEEKS'].includes(settings.runEveryUnit) ? t('schedule_settings.start_from') : t('schedule_settings.start_from')}: {schedule.startFrom.charAt(0).toUpperCase() + schedule.startFrom.slice(1).toLowerCase()} + {schedule.runEveryUnit === 'MONTHS' && ( + {t('schedule_settings.on_day')}: {schedule.dayOfMonth}{getDayOrdinal(schedule.dayOfMonth)} of the month + )} + {t('schedule_settings.at_around')}: {schedule.atTimeStart}, {schedule.timezone} {t('schedule_settings.timezone')} + + + + + ) : ( + <> + + {t('schedule_settings.labels.run_once_every')} + handleChange('runEvery', parseInt(e.target.value))} + sx={textStyle} + inputProps={{ min: 1 }} + /> + handleChange('runEveryUnit', e.target.value)} + sx={dropDownStyle} + > + {units.map((unit) => ( + {unit.charAt(0).toUpperCase() + unit.slice(1).toLowerCase()} + ))} + + + + + + {['MONTHS', 'WEEKS'].includes(settings.runEveryUnit) ? t('schedule_settings.labels.start_from_label') : t('schedule_settings.labels.start_from_label')} + + handleChange('startFrom', e.target.value)} + sx={dropDownStyle} + > + {days.map((day) => ( + + {day.charAt(0).toUpperCase() + day.slice(1).toLowerCase()} + + ))} + + + + {settings.runEveryUnit === 'MONTHS' && ( + + {t('schedule_settings.labels.on_day_of_month')} + handleChange('dayOfMonth', e.target.value)} + sx={textStyle} + inputProps={{ min: 1, max: 31 }} + /> + + )} + + {['MINUTES', 'HOURS'].includes(settings.runEveryUnit) ? ( + + + {t('schedule_settings.labels.in_between')} + handleChange('atTimeStart', e.target.value)} + sx={textStyle} + /> + handleChange('atTimeEnd', e.target.value)} + sx={textStyle} + /> + + + ) : ( + + {t('schedule_settings.at_around')} + handleChange('atTimeStart', e.target.value)} + sx={textStyle} + /> + + )} + + + {t('schedule_settings.timezone')} + handleChange('timezone', e.target.value)} + sx={dropDownStyle} + > + {validMomentTimezones.map((tz) => ( + {tz.charAt(0).toUpperCase() + tz.slice(1).toLowerCase()} + ))} + + + + + + + + )} + + + + ); +}; + +const modalStyle = { + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: '40%', + backgroundColor: 'background.paper', + p: 4, + height: 'fit-content', + display: 'block', + padding: '20px', +}; \ No newline at end of file diff --git a/src/components/action/ActionDescriptionBox.tsx b/src/components/action/ActionDescriptionBox.tsx index d36db407..e0a7efbb 100644 --- a/src/components/action/ActionDescriptionBox.tsx +++ b/src/components/action/ActionDescriptionBox.tsx @@ -102,7 +102,7 @@ const ActionDescriptionBox = ({ isDarkMode }: { isDarkMode: boolean }) => { sx={{ color: isDarkMode ? 'white' : 'default', '&.Mui-checked': { - color: '#ff33cc', + color: '#ff00c3', }, }} /> @@ -138,4 +138,4 @@ const ActionDescriptionBox = ({ isDarkMode }: { isDarkMode: boolean }) => { ); }; -export default ActionDescriptionBox; \ No newline at end of file +export default ActionDescriptionBox; diff --git a/src/components/dashboard/MainMenu.tsx b/src/components/dashboard/MainMenu.tsx index 3f499dc1..6fe6cf2e 100644 --- a/src/components/dashboard/MainMenu.tsx +++ b/src/components/dashboard/MainMenu.tsx @@ -3,8 +3,8 @@ import Tabs from '@mui/material/Tabs'; import Tab from '@mui/material/Tab'; import Box from '@mui/material/Box'; import { useNavigate, useLocation } from 'react-router-dom'; -import { Paper, Button, useTheme, Modal, Typography, Stack, TextField, InputAdornment, IconButton } from "@mui/material"; -import { AutoAwesome, FormatListBulleted, VpnKey, Usb, CloudQueue, Description, Favorite, ContentCopy, SlowMotionVideo } from "@mui/icons-material"; +import { Paper, Button, useTheme, Modal, Typography, Stack } from "@mui/material"; +import { AutoAwesome, VpnKey, Usb, CloudQueue, Description, Favorite, SlowMotionVideo, PlayArrow } from "@mui/icons-material"; import { useTranslation } from 'react-i18next'; import { useGlobalInfoStore } from "../../context/globalInfo"; @@ -121,7 +121,7 @@ export const MainMenu = ({ value = 'robots', handleChangeContent }: MainMenuProp @@ -155,10 +155,10 @@ export const MainMenu = ({ value = 'robots', handleChangeContent }: MainMenuProp href='https://app.maxun.dev/' target="_blank" rel="noopener noreferrer" - sx={buttonStyles} startIcon={}> + sx={buttonStyles} startIcon={}> Join Maxun Cloud - @@ -172,15 +172,11 @@ export const MainMenu = ({ value = 'robots', handleChangeContent }: MainMenuProp Maxun is built by a small, full-time team. Your donations directly contribute to making it better.
-
- Thank you for your support! 💙 + Thank you for your support! 🩷
- - - diff --git a/src/components/dashboard/NavBar.tsx b/src/components/dashboard/NavBar.tsx index 24e11d09..a8770466 100644 --- a/src/components/dashboard/NavBar.tsx +++ b/src/components/dashboard/NavBar.tsx @@ -158,14 +158,11 @@ export const NavBar: React.FC = ({ }; const renderThemeToggle = () => ( - + {darkMode ? : } diff --git a/src/components/recorder/DOMBrowserRenderer.tsx b/src/components/recorder/DOMBrowserRenderer.tsx index 7fcafdeb..a212f14b 100644 --- a/src/components/recorder/DOMBrowserRenderer.tsx +++ b/src/components/recorder/DOMBrowserRenderer.tsx @@ -908,6 +908,7 @@ export const DOMBrowserRenderer: React.FC = ({ rebuild(snapshotData.snapshot, { doc: iframeDoc, mirror: mirror, + hackCss: false, cache: { stylesWithHoverClass: new Map() }, afterAppend: (node) => { if (node.nodeType === Node.TEXT_NODE && node.textContent) { diff --git a/src/components/robot/pages/ScheduleSettingsPage.tsx b/src/components/robot/pages/ScheduleSettingsPage.tsx index 5108d485..e386cfec 100644 --- a/src/components/robot/pages/ScheduleSettingsPage.tsx +++ b/src/components/robot/pages/ScheduleSettingsPage.tsx @@ -188,8 +188,8 @@ export const ScheduleSettingsPage = ({ display: "flex", flexDirection: "column", alignItems: "flex-start", - padding: "20px", "& > *": { marginBottom: "20px" }, + marginTop: "-20px", }} > <> diff --git a/src/context/theme-provider.tsx b/src/context/theme-provider.tsx index 75dd4b8b..d9a6d745 100644 --- a/src/context/theme-provider.tsx +++ b/src/context/theme-provider.tsx @@ -155,14 +155,14 @@ const darkTheme = createTheme({ styleOverrides: { root: { color: '#ffffff', - "&:hover": { - backgroundColor: 'rgba(255, 0, 195, 0.08)', - }, + // "&:hover": { + // backgroundColor: 'rgba(255, 0, 195, 0.08)', + // }, '&.MuiIconButton-colorError': { color: '#f44336', - "&:hover": { - backgroundColor: 'rgba(244, 67, 54, 0.08)', - }, + // "&:hover": { + // backgroundColor: 'rgba(244, 67, 54, 0.08)', + // }, }, }, }, diff --git a/src/helpers/clientSelectorGenerator.ts b/src/helpers/clientSelectorGenerator.ts index 385aa8ec..d4e5051c 100644 --- a/src/helpers/clientSelectorGenerator.ts +++ b/src/helpers/clientSelectorGenerator.ts @@ -555,38 +555,25 @@ class ClientSelectorGenerator { */ private isMeaningfulElement(element: HTMLElement): boolean { const tagName = element.tagName.toLowerCase(); - - // Fast path for common meaningful elements - if (["a", "img", "input", "button", "select"].includes(tagName)) { + + if (tagName === "img") { + return element.hasAttribute("src"); + } + + if (tagName === "a" && element.hasAttribute("href")) { return true; } + if (element.children.length > 0) { + return false; + } + const text = (element.textContent || "").trim(); - const hasHref = element.hasAttribute("href"); - const hasSrc = element.hasAttribute("src"); - - // Quick checks first - if (text.length > 0 || hasHref || hasSrc) { + + if (text.length > 0) { return true; } - const isCustomElement = tagName.includes("-"); - - // For custom elements, be more lenient about what's considered meaningful - if (isCustomElement) { - const hasChildren = element.children.length > 0; - const hasSignificantAttributes = Array.from(element.attributes).some( - (attr) => !["class", "style", "id"].includes(attr.name.toLowerCase()) - ); - - return ( - hasChildren || - hasSignificantAttributes || - element.hasAttribute("role") || - element.hasAttribute("aria-label") - ); - } - return false; } @@ -2561,12 +2548,9 @@ class ClientSelectorGenerator { const MAX_MEANINGFUL_ELEMENTS = 300; const MAX_NODES_TO_CHECK = 1200; - const MAX_DEPTH = 12; + const MAX_DEPTH = 20; let nodesChecked = 0; - let adjustedMaxDepth = MAX_DEPTH; - const elementDensityThreshold = 50; - const depths: number[] = [0]; let queueIndex = 0; @@ -2576,14 +2560,10 @@ class ClientSelectorGenerator { queueIndex++; nodesChecked++; - if (currentDepth <= 3 && meaningfulDescendants.length > elementDensityThreshold) { - adjustedMaxDepth = Math.max(6, adjustedMaxDepth - 2); - } - if ( nodesChecked > MAX_NODES_TO_CHECK || meaningfulDescendants.length >= MAX_MEANINGFUL_ELEMENTS || - currentDepth > adjustedMaxDepth + currentDepth > MAX_DEPTH ) { break; } @@ -2592,7 +2572,7 @@ class ClientSelectorGenerator { meaningfulDescendants.push(element); } - if (currentDepth >= adjustedMaxDepth) { + if (currentDepth >= MAX_DEPTH) { continue; } @@ -2607,7 +2587,7 @@ class ClientSelectorGenerator { } } - if (element.shadowRoot && currentDepth < adjustedMaxDepth - 1) { + if (element.shadowRoot && currentDepth < MAX_DEPTH - 1) { const shadowChildren = element.shadowRoot.children; const shadowLimit = Math.min(shadowChildren.length, 20); for (let i = 0; i < shadowLimit; i++) { @@ -2716,22 +2696,46 @@ class ClientSelectorGenerator { } if (!addPositionToAll) { - const meaningfulAttrs = ["role", "type", "name", "src", "aria-label"]; + const meaningfulAttrs = ["role", "type"]; for (const attrName of meaningfulAttrs) { if (element.hasAttribute(attrName)) { const value = element.getAttribute(attrName)!.replace(/'/g, "\\'"); - return `${tagName}[@${attrName}='${value}']`; + const isCommonAttribute = this.isAttributeCommonAcrossLists( + element, + attrName, + value, + otherListElements + ); + if (isCommonAttribute) { + return `${tagName}[@${attrName}='${value}']`; + } } } } const testId = element.getAttribute("data-testid"); if (testId && !addPositionToAll) { - return `${tagName}[@data-testid='${testId}']`; + const isCommon = this.isAttributeCommonAcrossLists( + element, + "data-testid", + testId, + otherListElements + ); + if (isCommon) { + return `${tagName}[@data-testid='${testId}']`; + } } if (element.id && !element.id.match(/^\d/) && !addPositionToAll) { - return `${tagName}[@id='${element.id}']`; + const isCommon = this.isAttributeCommonAcrossLists( + element, + "id", + element.id, + otherListElements + ); + if (isCommon) { + return `${tagName}[@id='${element.id}']`; + } } if (!addPositionToAll) { @@ -2742,7 +2746,15 @@ class ClientSelectorGenerator { attr.name !== "data-mx-id" && attr.value ) { - return `${tagName}[@${attr.name}='${attr.value}']`; + const isCommon = this.isAttributeCommonAcrossLists( + element, + attr.name, + attr.value, + otherListElements + ); + if (isCommon) { + return `${tagName}[@${attr.name}='${attr.value}']`; + } } } } @@ -2906,12 +2918,70 @@ class ClientSelectorGenerator { const result = pathParts.length > 0 ? "/" + pathParts.join("/") : null; this.pathCache.set(targetElement, result); - + return result; } + private isAttributeCommonAcrossLists( + targetElement: HTMLElement, + attrName: string, + attrValue: string, + otherListElements: HTMLElement[] + ): boolean { + if (otherListElements.length === 0) { + return true; + } + + const targetPath = this.getElementPath(targetElement); + + for (const otherListElement of otherListElements) { + const correspondingElement = this.findCorrespondingElement( + otherListElement, + targetPath + ); + if (correspondingElement) { + const otherValue = correspondingElement.getAttribute(attrName); + if (otherValue !== attrValue) { + return false; + } + } + } + + return true; + } + + private getElementPath(element: HTMLElement): number[] { + const path: number[] = []; + let current: HTMLElement | null = element; + + while (current && current.parentElement) { + const siblings = Array.from(current.parentElement.children); + path.unshift(siblings.indexOf(current)); + current = current.parentElement; + } + + return path; + } + + private findCorrespondingElement( + rootElement: HTMLElement, + path: number[] + ): HTMLElement | null { + let current: HTMLElement = rootElement; + + for (const index of path) { + const children = Array.from(current.children); + if (index >= children.length) { + return null; + } + current = children[index] as HTMLElement; + } + + return current; + } + private getCommonClassesAcrossLists( - targetElement: HTMLElement, + targetElement: HTMLElement, otherListElements: HTMLElement[] ): string[] { if (otherListElements.length === 0) { @@ -3919,9 +3989,48 @@ class ClientSelectorGenerator { ); if (!deepestElement) return null; + if (!this.isMeaningfulElementCached(deepestElement)) { + const atomicChild = this.findAtomicChildAtPoint(deepestElement, x, y); + if (atomicChild) { + return atomicChild; + } + } + return deepestElement; } + private findAtomicChildAtPoint( + parent: HTMLElement, + x: number, + y: number + ): HTMLElement | null { + const stack: HTMLElement[] = [parent]; + const visited = new Set(); + + while (stack.length > 0) { + const element = stack.pop()!; + if (visited.has(element)) continue; + visited.add(element); + + if (element !== parent && this.isMeaningfulElementCached(element)) { + const rect = element.getBoundingClientRect(); + if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { + return element; + } + } + + for (let i = element.children.length - 1; i >= 0; i--) { + const child = element.children[i] as HTMLElement; + const rect = child.getBoundingClientRect(); + if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { + stack.push(child); + } + } + } + + return null; + } + /** * Helper methods used by the unified getDeepestElementFromPoint */