diff --git a/public/locales/de.json b/public/locales/de.json index 650e024d9..81c1f1407 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -249,7 +249,8 @@ }, "tooltips": { "saving": "Workflow wird optimiert und gespeichert" - } + }, + "description": "Beschreibung (optional)" }, "browser_recording": { "modal": { @@ -426,7 +427,8 @@ "created_at": "Erstellungsdatum des Roboters", "errors": { "robot_not_found": "Roboterdetails konnten nicht gefunden werden. Bitte versuchen Sie es erneut." - } + }, + "description": "Beschreibung (optional)" }, "robot_edit": { "title": "Roboter bearbeiten", diff --git a/public/locales/en.json b/public/locales/en.json index 4b981a58b..5f720fba9 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -262,7 +262,11 @@ }, "tooltips": { "saving": "Optimizing and saving the workflow" - } + }, + + "description": "Description (optional)" + + }, "browser_recording": { "modal": { @@ -439,7 +443,8 @@ "created_at": "Robot Created At", "errors": { "robot_not_found": "Could not find robot details. Please try again." - } + }, + "description":"Description (optional)" }, "robot_edit": { "title": "Edit Robot", diff --git a/public/locales/es.json b/public/locales/es.json index 8236157c0..94eb06ab0 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -250,7 +250,8 @@ }, "tooltips": { "saving": "Optimizando y guardando el flujo de trabajo" - } + }, + "description": "Descripción (opcional)" }, "browser_recording": { "modal": { @@ -427,7 +428,8 @@ "created_at": "Fecha de Creación del Robot", "errors": { "robot_not_found": "No se pudieron encontrar los detalles del robot. Inténtelo de nuevo." - } + }, + "description": "Descripción (opcional)" }, "robot_edit": { "title": "Editar Robot", diff --git a/public/locales/ja.json b/public/locales/ja.json index ce4b74e5e..7ae958fd4 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -427,7 +427,8 @@ "created_at": "作成日時", "errors": { "robot_not_found": "ロボットの詳細が見つかりませんでした。もう一度試してください。" - } + }, + "description": "説明(任意)" }, "robot_edit": { "title": "ロボットを編集", diff --git a/public/locales/zh.json b/public/locales/zh.json index 3abcfa405..646d72258 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -250,7 +250,8 @@ }, "tooltips": { "saving": "正在优化并保存工作流程" - } + }, + "description": "描述(可选)" }, "browser_recording": { "modal": { @@ -427,7 +428,8 @@ "created_at": "机器人创建时间", "errors": { "robot_not_found": "无法找到机器人详细信息。请重试。" - } + }, + "description": "描述(可选)" }, "robot_edit": { "title": "编辑机器人", diff --git a/server/src/db/config/database.js b/server/src/db/config/database.js index 4607c899e..a8f9e9263 100644 --- a/server/src/db/config/database.js +++ b/server/src/db/config/database.js @@ -1,5 +1,7 @@ -const dotenv = require('dotenv'); -dotenv.config({ path: './.env' }); + +require('dotenv').config({ path: './.env' }); + + // Validate required environment variables const requiredEnvVars = ['DB_USER', 'DB_PASSWORD', 'DB_NAME', 'DB_HOST', 'DB_PORT']; @@ -10,7 +12,6 @@ requiredEnvVars.forEach(envVar => { } }); - module.exports = { development: { username: process.env.DB_USER, diff --git a/server/src/db/migrations/20250501194640-add-description-to-robot.js b/server/src/db/migrations/20250501194640-add-description-to-robot.js new file mode 100644 index 000000000..bbe634d4e --- /dev/null +++ b/server/src/db/migrations/20250501194640-add-description-to-robot.js @@ -0,0 +1,14 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn('robot', 'description', { + type: Sequelize.TEXT, + allowNull: true + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.removeColumn('robot', 'description'); + } +}; \ No newline at end of file diff --git a/server/src/models/Robot.ts b/server/src/models/Robot.ts index eae9438ec..3aadc2b36 100644 --- a/server/src/models/Robot.ts +++ b/server/src/models/Robot.ts @@ -31,6 +31,7 @@ interface WebhookConfig { interface RobotAttributes { id: string; userId?: number; + description?: string | null; recording_meta: RobotMeta; recording: RobotWorkflow; google_sheet_email?: string | null; @@ -38,11 +39,11 @@ interface RobotAttributes { google_sheet_id?: string | null; google_access_token?: string | null; google_refresh_token?: string | null; - airtable_base_id?: string | null; - airtable_base_name?: string | null; - airtable_table_name?: string | null; - airtable_access_token?: string | null; - airtable_refresh_token?: string | null; + airtable_base_id?: string | null; + airtable_base_name?: string | null; + airtable_table_name?: string | null; + airtable_access_token?: string | null; + airtable_refresh_token?: string | null; schedule?: ScheduleConfig | null; airtable_table_id?: string | null; webhooks?: WebhookConfig[] | null; @@ -66,6 +67,7 @@ interface RobotCreationAttributes extends Optional { } class Robot extends Model implements RobotAttributes { public id!: string; public userId!: number; + public description!: string | null; public recording_meta!: RobotMeta; public recording!: RobotWorkflow; public google_sheet_email!: string | null; @@ -73,12 +75,12 @@ class Robot extends Model implements R public google_sheet_id!: string | null; public google_access_token!: string | null; public google_refresh_token!: string | null; - public airtable_base_id!: string | null; - public airtable_base_name!: string | null; - public airtable_table_name!: string | null; - public airtable_access_token!: string | null; - public airtable_refresh_token!: string | null; - public airtable_table_id!: string | null; + public airtable_base_id!: string | null; + public airtable_base_name!: string | null; + public airtable_table_name!: string | null; + public airtable_access_token!: string | null; + public airtable_refresh_token!: string | null; + public airtable_table_id!: string | null; public schedule!: ScheduleConfig | null; public webhooks!: WebhookConfig[] | null; } @@ -94,6 +96,10 @@ Robot.init( type: DataTypes.INTEGER, allowNull: false, }, + description: { + type: DataTypes.TEXT, + allowNull: true, + }, recording_meta: { type: DataTypes.JSONB, allowNull: false, diff --git a/server/src/routes/storage.ts b/server/src/routes/storage.ts index 35491f8ca..a35442e1d 100644 --- a/server/src/routes/storage.ts +++ b/server/src/routes/storage.ts @@ -254,10 +254,12 @@ function handleWorkflowActions(workflow: any[], credentials: Credentials) { router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, res) => { try { const { id } = req.params; - const { name, limits, credentials, targetUrl } = req.body; + + const { name, limit, credentials, targetUrl,description } = req.body; + // Validate input - if (!name && !limits && !credentials && !targetUrl) { + if (!name && !limit && !credentials && !targetUrl) { return res.status(400).json({ error: 'Either "name", "limits", "credentials" or "target_url" must be provided.' }); } @@ -272,6 +274,10 @@ router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, r if (name) { robot.set('recording_meta', { ...robot.recording_meta, name }); } + + if(description){ + robot.set('description', description); + } if (targetUrl) { const updatedWorkflow = [...robot.recording.workflow]; @@ -294,6 +300,7 @@ router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, r } } } + } await robot.save(); @@ -304,8 +311,8 @@ router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, r workflow = handleWorkflowActions(workflow, credentials); } - if (limits && Array.isArray(limits) && limits.length > 0) { - for (const limitInfo of limits) { + if (limit && Array.isArray(limit) && limit.length > 0) { + for (const limitInfo of limit) { const { pairIndex, actionIndex, argIndex, limit } = limitInfo; const pair = workflow[pairIndex]; diff --git a/server/src/workflow-management/classes/Generator.ts b/server/src/workflow-management/classes/Generator.ts index 27123e22c..187af1e2c 100644 --- a/server/src/workflow-management/classes/Generator.ts +++ b/server/src/workflow-management/classes/Generator.ts @@ -141,9 +141,9 @@ export class WorkflowGenerator { */ private registerEventHandlers = (socket: Socket) => { socket.on('save', (data) => { - const { fileName, userId, isLogin, robotId } = data; + const { fileName, userId, isLogin, robotId,description } = data; logger.log('debug', `Saving workflow ${fileName} for user ID ${userId}`); - this.saveNewWorkflow(fileName, userId, isLogin, robotId); + this.saveNewWorkflow(fileName, userId, isLogin, robotId,description); }); socket.on('new-recording', (data) => { this.workflowRecord = { @@ -767,7 +767,7 @@ export class WorkflowGenerator { * @param fileName The name of the file. * @returns {Promise} */ - public saveNewWorkflow = async (fileName: string, userId: number, isLogin: boolean, robotId?: string) => { + public saveNewWorkflow = async (fileName: string, userId: number, isLogin: boolean, robotId?: string,description?: string) => { const recording = this.optimizeWorkflow(this.workflowRecord); let actionType = 'saved'; @@ -784,10 +784,11 @@ export class WorkflowGenerator { params: this.getParams() || [], updatedAt: new Date().toLocaleString(), }, + description: description, }) actionType = 'retrained'; - logger.log('info', `Robot retrained with id: ${robot.id}`); + logger.log('info', `Robot retrained with id: ${robot.id} and name: ${robot.description}`); } } else { this.recordingMeta = { @@ -803,6 +804,7 @@ export class WorkflowGenerator { userId, recording_meta: this.recordingMeta, recording: recording, + description: description, }); capture( 'maxun-oss-robot-created', @@ -813,7 +815,7 @@ export class WorkflowGenerator { ) actionType = 'saved'; - logger.log('info', `Robot saved with id: ${robot.id}`); + logger.log('info', `Robot saved with id: ${robot.id} with description: ${robot.description}`); } } catch (e) { diff --git a/src/api/storage.ts b/src/api/storage.ts index d3fa3eb80..434bf9a85 100644 --- a/src/api/storage.ts +++ b/src/api/storage.ts @@ -28,12 +28,9 @@ export const getStoredRecordings = async (): Promise => { } }; -export const updateRecording = async (id: string, data: { - name?: string; - limits?: Array<{pairIndex: number, actionIndex: number, argIndex: number, limit: number}>; - credentials?: Credentials; - targetUrl?: string -}): Promise => { + +export const updateRecording = async (id: string, data: { name?: string; limit?: number, credentials?: Credentials, targetUrl?: string,description?:string }): Promise => { + try { const response = await axios.put(`${apiUrl}/storage/recordings/${id}`, data); if (response.status === 200) { diff --git a/src/components/recorder/AddWhereCondModal.tsx b/src/components/recorder/AddWhereCondModal.tsx index 7c5c284c8..2ace72516 100644 --- a/src/components/recorder/AddWhereCondModal.tsx +++ b/src/components/recorder/AddWhereCondModal.tsx @@ -139,6 +139,10 @@ export const AddWhereCondModal = ({ isOpen, onClose, pair, index }: AddWhereCond ) } + + + + export const modalStyle = { top: '40%', left: '50%', diff --git a/src/components/recorder/SaveRecording.tsx b/src/components/recorder/SaveRecording.tsx index a85cb868f..4bcccfa69 100644 --- a/src/components/recorder/SaveRecording.tsx +++ b/src/components/recorder/SaveRecording.tsx @@ -1,15 +1,15 @@ -import React, { useCallback, useEffect, useState, useContext } from 'react'; +import React, { useCallback, useEffect, useState, useContext } from "react"; import { Button, Box, LinearProgress, Tooltip } from "@mui/material"; import { GenericModal } from "../ui/GenericModal"; import { stopRecording } from "../../api/recording"; import { useGlobalInfoStore } from "../../context/globalInfo"; -import { AuthContext } from '../../context/auth'; +import { AuthContext } from "../../context/auth"; import { useSocketStore } from "../../context/socket"; import { TextField, Typography } from "@mui/material"; import { WarningText } from "../ui/texts"; import NotificationImportantIcon from "@mui/icons-material/NotificationImportant"; -import { useNavigate } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; interface SaveRecordingProps { fileName: string; @@ -20,9 +20,19 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => { const [openModal, setOpenModal] = useState(false); const [needConfirm, setNeedConfirm] = useState(false); const [saveRecordingName, setSaveRecordingName] = useState(fileName); + const [saveRecordingDescription, setSaveRecordingDescription] = + useState(""); const [waitingForSave, setWaitingForSave] = useState(false); - const { browserId, setBrowserId, notify, recordings, isLogin, recordingName, retrainRobotId } = useGlobalInfoStore(); + const { + browserId, + setBrowserId, + notify, + recordings, + isLogin, + recordingName, + retrainRobotId, + } = useGlobalInfoStore(); const { socket } = useSocketStore(); const { state, dispatch } = useContext(AuthContext); const { user } = state; @@ -40,12 +50,21 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => { setNeedConfirm(false); } setSaveRecordingName(value); - } + }; + + const handleChangeOfDescription = ( + event: React.ChangeEvent + ) => { + const { value } = event.target; + setSaveRecordingDescription(value); + }; const handleSaveRecording = async (event: React.SyntheticEvent) => { event.preventDefault(); if (recordings.includes(saveRecordingName)) { - if (needConfirm) { return; } + if (needConfirm) { + return; + } setNeedConfirm(true); } else { await saveRecording(); @@ -60,68 +79,84 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => { } }; - const exitRecording = useCallback(async (data?: { actionType: string }) => { - let successMessage = t('save_recording.notifications.save_success'); - - if (data && data.actionType) { - if (data.actionType === 'retrained') { - successMessage = t('save_recording.notifications.retrain_success'); - } else if (data.actionType === 'saved') { - successMessage = t('save_recording.notifications.save_success'); - } else if (data.actionType === 'error') { - successMessage = t('save_recording.notifications.save_error'); + const exitRecording = useCallback( + async (data?: { actionType: string }) => { + let successMessage = t("save_recording.notifications.save_success"); + + if (data && data.actionType) { + if (data.actionType === "retrained") { + successMessage = t("save_recording.notifications.retrain_success"); + } else if (data.actionType === "saved") { + successMessage = t("save_recording.notifications.save_success"); + } else if (data.actionType === "error") { + successMessage = t("save_recording.notifications.save_error"); + } } - } - - const notificationData = { - type: 'success', - message: successMessage, - timestamp: Date.now() - }; - - if (window.opener) { - window.opener.postMessage({ - type: 'recording-notification', - notification: notificationData - }, '*'); - - window.opener.postMessage({ - type: 'session-data-clear', - timestamp: Date.now() - }, '*'); - } - - if (browserId) { - await stopRecording(browserId); - } - setBrowserId(null); - - window.close(); - }, [setBrowserId, browserId, t]); + + const notificationData = { + type: "success", + message: successMessage, + timestamp: Date.now(), + }; + + if (window.opener) { + window.opener.postMessage( + { + type: "recording-notification", + notification: notificationData, + }, + "*" + ); + + window.opener.postMessage( + { + type: "session-data-clear", + timestamp: Date.now(), + }, + "*" + ); + } + + if (browserId) { + await stopRecording(browserId); + } + setBrowserId(null); + + window.close(); + }, + [setBrowserId, browserId, t] + ); // notifies backed to save the recording in progress, // releases resources and changes the view for main page by clearing the global browserId const saveRecording = async () => { if (user) { - const payload = { - fileName: saveRecordingName || recordingName, - userId: user.id, + const payload = { + fileName: saveRecordingName || recordingName, + userId: user.id, isLogin: isLogin, robotId: retrainRobotId, + description: saveRecordingDescription, }; - socket?.emit('save', payload); + + console.log(payload); + socket?.emit("save", payload); setWaitingForSave(true); - console.log(`Saving the recording as ${saveRecordingName || recordingName} for userId ${user.id}`); + console.log( + `Saving the recording as ${ + saveRecordingName || recordingName + } for userId ${user.id} with description: ${saveRecordingDescription}` + ); } else { - console.error(t('save_recording.notifications.user_not_logged')); + console.error(t("save_recording.notifications.user_not_logged")); } }; useEffect(() => { - socket?.on('fileSaved', exitRecording); + socket?.on("fileSaved", exitRecording); return () => { - socket?.off('fileSaved', exitRecording); - } + socket?.off("fileSaved", exitRecording); + }; }, [socket, exitRecording]); return ( @@ -131,64 +166,121 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => { variant="outlined" color="success" sx={{ - marginRight: '20px', - color: '#00c853 !important', - borderColor: '#00c853 !important', - backgroundColor: 'whitesmoke !important', + marginRight: "20px", + color: "#00c853 !important", + borderColor: "#00c853 !important", + backgroundColor: "whitesmoke !important", }} size="small" > - {t('right_panel.buttons.finish')} + {t("right_panel.buttons.finish")} - setOpenModal(false)} modalStyle={modalStyle}> -
- {t('save_recording.title')} + setOpenModal(false)} + modalStyle={modalStyle} + > + + + {t("save_recording.title")} + + + + + setSaveRecordingDescription(e.target.value.slice(0, 50)) + } + multiline + rows={2} + inputProps={{ maxLength: 50 }} + helperText={ + + {50 - saveRecordingDescription.length} / 50 + + } /> - {needConfirm - ? - ( - - + - {t('save_recording.errors.exists_warning')} + {t("save_recording.errors.exists_warning")} - ) - : - } - {waitingForSave && - - - - + )} + + {waitingForSave && ( + + - } + )}
); -} +}; const modalStyle = { - top: '25%', - left: '50%', - transform: 'translate(-50%, -50%)', - width: '30%', - backgroundColor: 'background.paper', - p: 4, - height: 'fit-content', - display: 'block', - padding: '20px', -}; \ No newline at end of file + top: "25%", + left: "50%", + transform: "translate(-50%, -50%)", + width: "500px", // Close to the old 300px field + some padding + maxWidth: "90vw", + bgcolor: "background.paper", + boxShadow: "0px 4px 24px rgba(0,0,0,0.2)", + p: 3, + borderRadius: 2, + display: "block", +}; diff --git a/src/components/robot/RobotEdit.tsx b/src/components/robot/RobotEdit.tsx index 3b110ba17..7d8a978f5 100644 --- a/src/components/robot/RobotEdit.tsx +++ b/src/components/robot/RobotEdit.tsx @@ -1,586 +1,673 @@ -import React, { useState, useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; +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 { + 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'; +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[]; + name: string; + id: string; + createdAt: string; + pairs: number; + updatedAt: string; + params: any[]; } interface RobotWorkflow { - workflow: WhereWhatPair[]; + 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; + 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; + 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; + description?: string; } interface RobotSettingsProps { - isOpen: boolean; - handleStart: (settings: RobotSettings) => void; - handleClose: () => void; - initialSettings?: RobotSettings | null; + isOpen: boolean; + handleStart: (settings: RobotSettings) => void; + handleClose: () => void; + initialSettings?: RobotSettings | null; } interface CredentialInfo { - value: string; - type: string; + value: string; + type: string; } interface Credentials { - [key: string]: CredentialInfo; + [key: string]: CredentialInfo; } interface CredentialVisibility { - [key: string]: boolean; + [key: string]: boolean; } interface GroupedCredentials { - passwords: string[]; - emails: string[]; - usernames: string[]; - others: string[]; + passwords: string[]; + emails: string[]; + usernames: string[]; + others: string[]; } interface ScrapeListLimit { - pairIndex: number; - actionIndex: number; - argIndex: number; - currentLimit: number; + 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'); - }; +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"; + }; - 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'; + 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) { + const arg = action.args[0]; + if (arg && typeof arg === 'object' && 'limit' in arg) { + limits.push({ + pairIndex, + actionIndex, + argIndex: 0, + currentLimit: arg.limit + }); + } } - return 'other'; - }; + }); + }); + + setScrapeListLimits(limits); + }; - 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]); + function extractInitialCredentials(workflow: any[]): Credentials { + const credentials: Credentials = {}; - 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); + const isPrintableCharacter = (char: string): boolean => { + return char.length === 1 && !!char.match(/^[\x20-\x7E]$/); }; - 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; + workflow.forEach((step) => { + if (!step.what) return; - let currentSelector = ''; - let currentValue = ''; - let currentType = ''; - let i = 0; + 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; - } + while (i < step.what.length) { + const action = step.what[i]; - 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; - } + if (!action.action || !action.args?.[0]) { + 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++; - } - } + 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' - }; + 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); + 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++; + } - return acc; - }, { passwords: [], emails: [], usernames: [], others: [] }); - }; + credentials[currentSelector] = { + value: currentValue, + type: currentType, + }; - const getRobot = async () => { - if (recordingId) { - const robot = await getStoredRecording(recordingId); - setRobot(robot); + i = j; } else { - notify('error', t('robot_edit.notifications.update_failed')); + i++; } - }; - - 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 } }; - }); - }; + if (currentSelector && currentValue) { + credentials[currentSelector] = { + value: currentValue, + type: currentType || "text", + }; + } + }); - const handleTargetUrlChange = (newUrl: string) => { - setRobot((prev) => { - if (!prev) return prev; + 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); + } - const updatedWorkflow = [...prev.recording.workflow]; - const lastPairIndex = updatedWorkflow.length - 1; + 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 + ); + }; - 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; - } + const handleRobotNameDescription = (newDescription: string) => { + setRobot((prev) => + prev ? { ...prev, description: newDescription } : 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 { ...prev, recording: { ...prev.recording, workflow: updatedWorkflow } }; + return item; + }); }); - }; + } - 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' - )} - - ); - }; + return { + ...prev, + recording: { ...prev.recording, workflow: updatedWorkflow }, + }; + }); + }; - const renderCredentialFields = (selectors: string[], headerText: string, defaultType: 'text' | 'password' = 'text') => { - if (selectors.length === 0) return null; + const handleTargetUrlChange = (newUrl: string) => { + setRobot((prev) => { + if (!prev) return prev; - 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 updatedWorkflow = [...prev.recording.workflow]; + const lastPairIndex = updatedWorkflow.length - 1; - 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' }} - /> - ))} - + if (lastPairIndex >= 0) { + const gotoAction = updatedWorkflow[lastPairIndex]?.what?.find( + (action) => action.action === "goto" ); - }; - - 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, - }; + if (gotoAction && gotoAction.args && gotoAction.args.length > 0) { + gotoAction.args[0] = newUrl; + } + } - const success = await updateRecording(robot.recording_meta.id, payload); + return { + ...prev, + recording: { ...prev.recording, workflow: updatedWorkflow }, + }; + }); + }; - if (success) { - setRerenderRobots(true); + 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")} + + ); + }; - 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 renderCredentialFields = ( + selectors: string[], + headerText: string, + defaultType: "text" | "password" = "text" + ) => { + if (selectors.length === 0) return null; - const lastPair = robot?.recording.workflow[robot?.recording.workflow.length - 1]; - const targetUrl = lastPair?.what.find(action => action.action === "goto")?.args?.[0]; + 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 description = robot.description || ""; + 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, + description: description, + }; + + 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 && ( <> - - {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()} - - )} - - - - - - - )} - + handleRobotNameChange(e.target.value)} + style={{ marginBottom: "20px" }} + /> + + handleTargetUrlChange(e.target.value)} + style={{ marginBottom: "20px" }} + /> + + {renderScrapeListLimitFields()} + + {robot.description?.length !== undefined && + handleRobotNameDescription(e.target.value.slice(0, 50)) + } + style={{ marginBottom: "20px" }} + inputProps={{ maxLength: 50 }} + helperText={ + + {50 - (robot.description?.length ?? 0)} / 50 + + } + />} + + {Object.keys(credentials).length > 0 && ( + <> + + {t("Input Texts")} + + {renderAllCredentialFields()} + + )} + + + + + - - ); + )} + + + + ); }; \ No newline at end of file diff --git a/src/components/robot/RobotSettings.tsx b/src/components/robot/RobotSettings.tsx index 6ae59f89b..e4e57a794 100644 --- a/src/components/robot/RobotSettings.tsx +++ b/src/components/robot/RobotSettings.tsx @@ -14,6 +14,7 @@ interface RobotMeta { pairs: number; updatedAt: string; params: any[]; + } interface RobotWorkflow { @@ -43,6 +44,7 @@ export interface RobotSettings { google_access_token?: string | null; google_refresh_token?: string | null; schedule?: ScheduleConfig | null; + description?: string|null; } interface RobotSettingsProps { @@ -151,6 +153,17 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe }} style={{ marginBottom: '20px' }} /> + {robot.description && ( + + )} ) }