diff --git a/maxun-core/package.json b/maxun-core/package.json index d403360a..5b732a6b 100644 --- a/maxun-core/package.json +++ b/maxun-core/package.json @@ -1,6 +1,6 @@ { "name": "maxun-core", - "version": "0.0.9", + "version": "0.0.10", "description": "Core package for Maxun, responsible for data extraction", "main": "build/index.js", "typings": "build/index.d.ts", diff --git a/maxun-core/src/interpret.ts b/maxun-core/src/interpret.ts index 08a8a74c..d87e2477 100644 --- a/maxun-core/src/interpret.ts +++ b/maxun-core/src/interpret.ts @@ -490,6 +490,14 @@ export default class Interpreter extends EventEmitter { const executeAction = async (invokee: any, methodName: string, args: any) => { console.log("Executing action:", methodName, args); + + if (methodName === 'press' || methodName === 'type') { + // Extract only the first two arguments for these methods + const limitedArgs = Array.isArray(args) ? args.slice(0, 2) : [args]; + await (invokee[methodName])(...limitedArgs); + return; + } + if (!args || Array.isArray(args)) { await (invokee[methodName])(...(args ?? [])); } else { diff --git a/package.json b/package.json index 5dae78a2..768b970f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "maxun", - "version": "0.0.7", + "version": "0.0.8", "author": "Maxun", "license": "AGPL-3.0-or-later", "dependencies": { @@ -47,7 +47,7 @@ "lodash": "^4.17.21", "loglevel": "^1.8.0", "loglevel-plugin-remote": "^0.6.8", - "maxun-core": "^0.0.9", + "maxun-core": "^0.0.10", "minio": "^8.0.1", "moment-timezone": "^0.5.45", "node-cron": "^3.0.3", diff --git a/public/locales/de.json b/public/locales/de.json index 21b71313..51bbc782 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -41,7 +41,8 @@ "duplicate": "Duplizieren", "notifications": { "delete_warning": "Roboter kann nicht gelöscht werden, da zugehörige Ausführungen vorhanden sind", - "delete_success": "Roboter erfolgreich gelöscht" + "delete_success": "Roboter erfolgreich gelöscht", + "auth_success": "Roboter erfolgreich authentifiziert" } }, "mainmenu": { @@ -205,7 +206,8 @@ "confirm_text_fields": "Bitte bestätigen Sie alle Textfelder", "unable_create_settings": "Listeneinstellungen können nicht erstellt werden. Stellen Sie sicher, dass Sie ein Feld für die Liste definiert haben.", "capture_text_discarded": "Texterfassung verworfen", - "capture_list_discarded": "Listenerfassung verworfen" + "capture_list_discarded": "Listenerfassung verworfen", + "label_required": "Beschriftung darf nicht leer sein" } }, "save_recording": { diff --git a/public/locales/en.json b/public/locales/en.json index 669c9b57..a7843bd0 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -32,7 +32,7 @@ "new":"Create Robot", "modal":{ "title":"Enter the URL", - "login_title": "Does this site require login?", + "login_title": "Does this website require login?", "label":"URL", "button":"Start Recording" }, @@ -42,7 +42,8 @@ "search":"Search Robots...", "notifications": { "delete_warning": "Cannot delete robot as it has associated runs", - "delete_success": "Robot deleted successfully" + "delete_success": "Robot deleted successfully", + "auth_success": "Robot successfully authenticated" } }, "mainmenu":{ @@ -207,7 +208,8 @@ "confirm_text_fields": "Please confirm all text fields", "unable_create_settings": "Unable to create list settings. Make sure you have defined a field for the list.", "capture_text_discarded": "Capture Text Discarded", - "capture_list_discarded": "Capture List Discarded" + "capture_list_discarded": "Capture List Discarded", + "label_required": "Label cannot be empty" } }, "save_recording": { diff --git a/public/locales/es.json b/public/locales/es.json index 8ef9b8bf..78e5a9d4 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -42,7 +42,8 @@ "search": "Buscar robots...", "notifications": { "delete_warning": "No se puede eliminar el robot ya que tiene ejecuciones asociadas", - "delete_success": "Robot eliminado exitosamente" + "delete_success": "Robot eliminado exitosamente", + "auth_success": "Robot autenticado exitosamente" } }, "mainmenu": { @@ -206,7 +207,8 @@ "confirm_text_fields": "Por favor confirme todos los campos de texto", "unable_create_settings": "No se pueden crear las configuraciones de la lista. Asegúrese de haber definido un campo para la lista.", "capture_text_discarded": "Captura de texto descartada", - "capture_list_discarded": "Captura de lista descartada" + "capture_list_discarded": "Captura de lista descartada", + "label_required": "La etiqueta no puede estar vacía" } }, "save_recording": { diff --git a/public/locales/ja.json b/public/locales/ja.json index 03f91cf6..fc88abac 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -42,7 +42,8 @@ "search": "ロボットを検索...", "notifications": { "delete_warning": "関連する実行があるため、ロボットを削除できません", - "delete_success": "ロボットが正常に削除されました" + "delete_success": "ロボットが正常に削除されました", + "auth_success": "ロボットの認証に成功しました" } }, "mainmenu": { @@ -206,7 +207,8 @@ "confirm_text_fields": "すべてのテキストフィールドを確認してください", "unable_create_settings": "リスト設定を作成できません。リストのフィールドを定義したことを確認してください。", "capture_text_discarded": "テキスト取得が破棄されました", - "capture_list_discarded": "リスト取得が破棄されました" + "capture_list_discarded": "リスト取得が破棄されました", + "label_required": "ラベルは空にできません" } }, "save_recording": { diff --git a/public/locales/zh.json b/public/locales/zh.json index 41e3a762..1c45464b 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -42,7 +42,8 @@ "search": "搜索机器人...", "notifications": { "delete_warning": "无法删除机器人,因为它有关联的运行记录", - "delete_success": "机器人删除成功" + "delete_success": "机器人删除成功", + "auth_success": "机器人认证成功" } }, "mainmenu": { @@ -206,7 +207,8 @@ "confirm_text_fields": "请确认所有文本字段", "unable_create_settings": "无法创建列表设置。请确保您已为列表定义了字段。", "capture_text_discarded": "文本捕获已放弃", - "capture_list_discarded": "列表捕获已放弃" + "capture_list_discarded": "列表捕获已放弃", + "label_required": "标签不能为空" } }, "save_recording": { diff --git a/server/Dockerfile b/server/Dockerfile index e7d1a061..65e472eb 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -25,7 +25,7 @@ RUN mkdir -p /tmp/chromium-data-dir && \ # Install dependencies RUN apt-get update && apt-get install -y \ - libgbm-dev \ + libgbm1 \ libnss3 \ libatk1.0-0 \ libatk-bridge2.0-0 \ @@ -48,4 +48,4 @@ RUN apt-get update && apt-get install -y \ EXPOSE ${BACKEND_PORT:-8080} # Start the backend using the start script -CMD ["npm", "run", "server"] \ No newline at end of file +CMD ["npm", "run", "server"] diff --git a/server/src/models/Robot.ts b/server/src/models/Robot.ts index 0cd11b51..3b2717d6 100644 --- a/server/src/models/Robot.ts +++ b/server/src/models/Robot.ts @@ -26,7 +26,6 @@ interface RobotAttributes { google_access_token?: string | null; google_refresh_token?: string | null; schedule?: ScheduleConfig | null; - isLogin?: boolean; } interface ScheduleConfig { @@ -55,7 +54,6 @@ class Robot extends Model implements R public google_access_token!: string | null; public google_refresh_token!: string | null; public schedule!: ScheduleConfig | null; - public isLogin!: boolean; } Robot.init( @@ -101,11 +99,6 @@ Robot.init( type: DataTypes.JSONB, allowNull: true, }, - isLogin: { - type: DataTypes.BOOLEAN, - allowNull: false, - defaultValue: false, - }, }, { sequelize, diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 837fcd8a..49af7090 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -380,11 +380,19 @@ router.get( httpOnly: false, maxAge: 60000, }); // 1-minute expiration - res.cookie("robot_auth_message", "Robot successfully authenticated", { + // res.cookie("robot_auth_message", "Robot successfully authenticated", { + // httpOnly: false, + // maxAge: 60000, + // }); + res.cookie('robot_auth_robotId', robotId, { httpOnly: false, maxAge: 60000, }); - res.redirect(`${process.env.PUBLIC_URL}/robots/${robotId}/integrate` as string || `http://localhost:5173/robots/${robotId}/integrate`); + + const baseUrl = process.env.PUBLIC_URL || "http://localhost:5173"; + const redirectUrl = `${baseUrl}/robots/`; + + res.redirect(redirectUrl); } catch (error: any) { res.status(500).json({ message: `Google OAuth error: ${error.message}` }); } diff --git a/server/src/routes/storage.ts b/server/src/routes/storage.ts index 007ac40c..01f1ca6b 100644 --- a/server/src/routes/storage.ts +++ b/server/src/routes/storage.ts @@ -154,48 +154,52 @@ function formatRunResponse(run: any) { return formattedRun; } -interface CredentialUpdate { - [selector: string]: string; +interface CredentialInfo { + value: string; + type: string; } -function updateTypeActionsInWorkflow(workflow: any[], credentials: CredentialUpdate) { +interface Credentials { + [key: string]: CredentialInfo; +} + +function updateTypeActionsInWorkflow(workflow: any[], credentials: Credentials) { return workflow.map(step => { if (!step.what) return step; - // First pass: mark indices to remove const indicesToRemove = new Set(); - step.what.forEach((action: any, index: any) => { + step.what.forEach((action: any, index: number) => { if (!action.action || !action.args?.[0]) return; - // If it's a type/press action for a credential if ((action.action === 'type' || action.action === 'press') && credentials[action.args[0]]) { indicesToRemove.add(index); - // Check if next action is waitForLoadState + if (step.what[index + 1]?.action === 'waitForLoadState') { indicesToRemove.add(index + 1); } } }); - // Filter out marked indices and create new what array - const filteredWhat = step.what.filter((_: any, index: any) => !indicesToRemove.has(index)); + const filteredWhat = step.what.filter((_: any, index: number) => !indicesToRemove.has(index)); - // Add new type actions after click actions - Object.entries(credentials).forEach(([selector, credential]) => { + Object.entries(credentials).forEach(([selector, credentialInfo]) => { const clickIndex = filteredWhat.findIndex((action: any) => action.action === 'click' && action.args?.[0] === selector ); if (clickIndex !== -1) { - const chars = credential.split(''); + const chars = credentialInfo.value.split(''); + chars.forEach((char, i) => { - // Add type action filteredWhat.splice(clickIndex + 1 + (i * 2), 0, { action: 'type', - args: [selector, encrypt(char)] + args: [ + selector, + encrypt(char), + credentialInfo.type + ] }); - // Add waitForLoadState filteredWhat.splice(clickIndex + 2 + (i * 2), 0, { action: 'waitForLoadState', args: ['networkidle'] @@ -346,7 +350,6 @@ router.post('/recordings/:id/duplicate', requireSignIn, async (req: Authenticate updatedAt: currentTimestamp, }, recording: { ...originalRobot.recording, workflow }, - isLogin: originalRobot.isLogin, google_sheet_email: null, google_sheet_name: null, google_sheet_id: null, diff --git a/server/src/storage/db.ts b/server/src/storage/db.ts index 2f0fcde4..cdd84655 100644 --- a/server/src/storage/db.ts +++ b/server/src/storage/db.ts @@ -29,7 +29,13 @@ export const connectDB = async () => { export const syncDB = async () => { try { //setupAssociations(); - await sequelize.sync({ force: false }); // force: true will drop and recreate tables on every run + const isDevelopment = process.env.NODE_ENV === 'development'; + // force: true will drop and recreate tables on every run + // Use `alter: true` only in development mode + await sequelize.sync({ + force: false, + alter: isDevelopment + }); console.log('Database synced successfully!'); } catch (error) { console.error('Failed to sync database:', error); diff --git a/server/src/workflow-management/classes/Generator.ts b/server/src/workflow-management/classes/Generator.ts index 47e6f377..6e36f287 100644 --- a/server/src/workflow-management/classes/Generator.ts +++ b/server/src/workflow-management/classes/Generator.ts @@ -39,6 +39,7 @@ interface MetaData { pairs: number; updatedAt: string; params: string[], + isLogin?: boolean; } /** @@ -97,6 +98,7 @@ export class WorkflowGenerator { pairs: 0, updatedAt: '', params: [], + isLogin: false, } /** @@ -425,6 +427,40 @@ export class WorkflowGenerator { return; } + if ((elementInfo?.tagName === 'INPUT' || elementInfo?.tagName === 'TEXTAREA') && selector) { + // Calculate the exact position within the element + const elementPos = await page.evaluate((selector) => { + const element = document.querySelector(selector); + if (!element) return null; + const rect = element.getBoundingClientRect(); + return { + x: rect.left, + y: rect.top + }; + }, selector); + + if (elementPos) { + const relativeX = coordinates.x - elementPos.x; + const relativeY = coordinates.y - elementPos.y; + + const pair: WhereWhatPair = { + where, + what: [{ + action: 'click', + args: [selector, { position: { x: relativeX, y: relativeY } }] + }] + }; + + if (selector) { + this.generatedData.lastUsedSelector = selector; + this.generatedData.lastAction = 'click'; + } + + await this.addPairToWorkflowAndNotifyClient(pair, page); + return; + } + } + //const element = await getElementMouseIsOver(page, coordinates); //logger.log('debug', `Element: ${JSON.stringify(element, null, 2)}`); if (selector) { @@ -474,6 +510,10 @@ export class WorkflowGenerator { public onKeyboardInput = async (key: string, coordinates: Coordinates, page: Page) => { let where: WhereWhatPair["where"] = { url: this.getBestUrl(page.url()) }; const selector = await this.generateSelector(page, coordinates, ActionType.Keydown); + + const elementInfo = await getElementInformation(page, coordinates, '', false); + const inputType = elementInfo?.attributes?.type || "text"; + if (selector) { where.selectors = [selector]; } @@ -481,7 +521,7 @@ export class WorkflowGenerator { where, what: [{ action: 'press', - args: [selector, encrypt(key)], + args: [selector, encrypt(key), inputType], }], } if (selector) { @@ -670,12 +710,12 @@ export class WorkflowGenerator { pairs: recording.workflow.length, updatedAt: new Date().toLocaleString(), params: this.getParams() || [], + isLogin: isLogin, } const robot = await Robot.create({ userId, recording_meta: this.recordingMeta, recording: recording, - isLogin: isLogin, }); capture( 'maxun-oss-robot-created', @@ -992,6 +1032,7 @@ export class WorkflowGenerator { let input = { selector: '', value: '', + type: '', actionCounter: 0, }; @@ -1006,7 +1047,7 @@ export class WorkflowGenerator { // when more than one press action is present, add a type action pair.what.splice(index - input.actionCounter, input.actionCounter, { action: 'type', - args: [input.selector, encrypt(input.value)], + args: [input.selector, encrypt(input.value), input.type], }, { action: 'waitForLoadState', args: ['networkidle'], @@ -1034,13 +1075,14 @@ export class WorkflowGenerator { action: 'waitForLoadState', args: ['networkidle'], }) - input = { selector: '', value: '', actionCounter: 0 }; + input = { selector: '', value: '', type: '', actionCounter: 0 }; } } else { pushTheOptimizedAction(pair, index); input = { selector: condition.args[0], value: condition.args[1], + type: condition.args[2], actionCounter: 1, }; } @@ -1049,7 +1091,7 @@ export class WorkflowGenerator { if (input.value.length !== 0) { pushTheOptimizedAction(pair, index); // clear the input - input = { selector: '', value: '', actionCounter: 0 }; + input = { selector: '', value: '', type: '', actionCounter: 0 }; } } }); diff --git a/src/api/storage.ts b/src/api/storage.ts index c7901f22..290f6e7f 100644 --- a/src/api/storage.ts +++ b/src/api/storage.ts @@ -5,8 +5,13 @@ import { ScheduleSettings } from "../components/robot/ScheduleSettings"; import { CreateRunResponse, ScheduleRunResponse } from "../pages/MainPage"; import { apiUrl } from "../apiConfig"; +interface CredentialInfo { + value: string; + type: string; +} + interface Credentials { - [key: string]: string; + [key: string]: CredentialInfo; } export const getStoredRecordings = async (): Promise => { diff --git a/src/components/integration/IntegrationSettings.tsx b/src/components/integration/IntegrationSettings.tsx index 835221ca..31c93368 100644 --- a/src/components/integration/IntegrationSettings.tsx +++ b/src/components/integration/IntegrationSettings.tsx @@ -29,20 +29,6 @@ export interface IntegrationSettings { data: string; } -// Helper functions to replace js-cookie functionality -const getCookie = (name: string): string | null => { - const value = `; ${document.cookie}`; - const parts = value.split(`; ${name}=`); - if (parts.length === 2) { - return parts.pop()?.split(';').shift() || null; - } - return null; -}; - -const removeCookie = (name: string): void => { - document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; -}; - export const IntegrationSettingsModal = ({ isOpen, handleStart, @@ -154,17 +140,6 @@ export const IntegrationSettingsModal = ({ }; useEffect(() => { - // Check if there is a success message in cookies - const status = getCookie("robot_auth_status"); - const message = getCookie("robot_auth_message"); - - if (status === "success" && message) { - notify("success", message); - // Clear the cookies after reading - removeCookie("robot_auth_status"); - removeCookie("robot_auth_message"); - } - // Check if we're on the callback URL const urlParams = new URLSearchParams(window.location.search); const code = urlParams.get("code"); diff --git a/src/components/robot/Recordings.tsx b/src/components/robot/Recordings.tsx index 9ddfccc7..e63f5e1c 100644 --- a/src/components/robot/Recordings.tsx +++ b/src/components/robot/Recordings.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { RecordingsTable } from "./RecordingsTable"; import { Grid } from "@mui/material"; import { RunSettings, RunSettingsModal } from "../run/RunSettings"; @@ -8,6 +8,8 @@ import { RobotSettingsModal } from "./RobotSettings"; import { RobotEditModal } from "./RobotEdit"; import { RobotDuplicationModal } from "./RobotDuplicate"; import { useNavigate, useLocation, useParams } from "react-router-dom"; +import { useGlobalInfoStore } from "../../context/globalInfo"; +import { useTranslation } from "react-i18next"; interface RecordingsProps { handleEditRecording: (id: string, fileName: string) => void; @@ -26,6 +28,8 @@ export const Recordings = ({ const location = useLocation(); const { selectedRecordingId } = useParams(); const [params, setParams] = useState([]); + const { notify } = useGlobalInfoStore(); + const { t } = useTranslation(); const handleNavigate = (path: string, id: string, name: string, params: string[]) => { setParams(params); @@ -39,6 +43,31 @@ export const Recordings = ({ navigate("/robots"); // Navigate back to the main robots page }; + useEffect(() => { + // Helper function to get and clear a cookie + const getAndClearCookie = (name: string) => { + const value = document.cookie + .split('; ') + .find(row => row.startsWith(`${name}=`)) + ?.split('=')[1]; + + if (value) { + document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; + } + + return value; + }; + + const authStatus = getAndClearCookie('robot_auth_status'); + const robotId = getAndClearCookie('robot_auth_robotId'); + + if (authStatus === 'success' && robotId) { + notify(authStatus, t("recordingtable.notifications.auth_success")); + + handleNavigate(`/robots/${robotId}/integrate`, robotId, "", []);'' + } + }, []); + // Determine which modal to open based on the current route const getCurrentModal = () => { const currentPath = location.pathname; diff --git a/src/components/robot/RecordingsTable.tsx b/src/components/robot/RecordingsTable.tsx index c679ec14..484b1667 100644 --- a/src/components/robot/RecordingsTable.tsx +++ b/src/components/robot/RecordingsTable.tsx @@ -11,8 +11,32 @@ import TableRow from '@mui/material/TableRow'; import { memo, useCallback, useEffect, useMemo } from "react"; import { WorkflowFile } from "maxun-core"; import SearchIcon from '@mui/icons-material/Search'; -import { IconButton, Button, Box, Typography, TextField, MenuItem, Menu, ListItemIcon, ListItemText, CircularProgress, RadioGroup, FormControlLabel, Radio } from "@mui/material"; -import { Schedule, DeleteForever, Edit, PlayCircle, Settings, Power, ContentCopy, MoreHoriz } from "@mui/icons-material"; +import { + IconButton, + Button, + Box, + Typography, + TextField, + MenuItem, + Menu, + ListItemIcon, + ListItemText, + CircularProgress, + RadioGroup, + FormControlLabel, + Radio, + Checkbox, +} from "@mui/material"; +import { + Schedule, + DeleteForever, + Edit, + PlayCircle, + Settings, + Power, + ContentCopy, + MoreHoriz +} from "@mui/icons-material"; import { useGlobalInfoStore } from "../../context/globalInfo"; import { checkRunsForRecording, deleteRecordingFromStorage, getStoredRecordings } from "../../api/storage"; import { Add } from "@mui/icons-material"; @@ -110,7 +134,14 @@ const TableRowMemoized = memo(({ row, columns, handlers }: any) => { }); -export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handleScheduleRecording, handleIntegrateRecording, handleSettingsRecording, handleEditRobot, handleDuplicateRobot }: RecordingsTableProps) => { +export const RecordingsTable = ({ + handleEditRecording, + handleRunRecording, + handleScheduleRecording, + handleIntegrateRecording, + handleSettingsRecording, + handleEditRobot, + handleDuplicateRobot }: RecordingsTableProps) => { const { t } = useTranslation(); const [page, setPage] = React.useState(0); const [rowsPerPage, setRowsPerPage] = React.useState(10); @@ -128,7 +159,20 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl { id: 'options', label: t('recordingtable.options'), minWidth: 80 }, ], [t]); - const { notify, setRecordings, browserId, setBrowserId, setInitialUrl, recordingUrl, setRecordingUrl, isLogin, setIsLogin, recordingName, setRecordingName, recordingId, setRecordingId } = useGlobalInfoStore(); + const { + notify, + setRecordings, + browserId, + setBrowserId, + setInitialUrl, + recordingUrl, + setRecordingUrl, + isLogin, + setIsLogin, + recordingName, + setRecordingName, + recordingId, + setRecordingId } = useGlobalInfoStore(); const navigate = useNavigate(); const handleChangePage = useCallback((event: unknown, newPage: number) => { @@ -343,7 +387,7 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl onRowsPerPageChange={handleChangeRowsPerPage} /> setModalOpen(false)} modalStyle={modalStyle}> -
+
{t('recordingtable.modal.title')} - {t('recordingtable.modal.login_title')} - setIsLogin(e.target.value === 'yes')} - style={{ marginBottom: '20px' }} - > - } label="Yes" /> - } label="No" /> - + setIsLogin(e.target.checked)} + color="primary" + /> + } + label={t('recordingtable.modal.login_title')} + style={{ marginBottom: '10px' }} + /> +
- - - - ) - } + + + + + + )} ); -}; +}; \ No newline at end of file