diff --git a/maxun-core/package.json b/maxun-core/package.json index 5b732a6b..2f04d42a 100644 --- a/maxun-core/package.json +++ b/maxun-core/package.json @@ -1,6 +1,6 @@ { "name": "maxun-core", - "version": "0.0.10", + "version": "0.0.12", "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 d87e2477..88047128 100644 --- a/maxun-core/src/interpret.ts +++ b/maxun-core/src/interpret.ts @@ -1,5 +1,5 @@ /* eslint-disable no-await-in-loop, no-restricted-syntax */ -import { Page, PageScreenshotOptions } from 'playwright'; +import { ElementHandle, Page, PageScreenshotOptions } from 'playwright'; import { PlaywrightBlocker } from '@cliqz/adblocker-playwright'; import fetch from 'cross-fetch'; import path from 'path'; @@ -286,6 +286,12 @@ export default class Interpreter extends EventEmitter { ? arrayToObject(superset[key]) : superset[key]; + if (key === 'selectors' && Array.isArray(value) && Array.isArray(superset[key])) { + return value.some(selector => + (superset[key] as any[]).includes(selector) + ); + } + // Every `subset` key must exist in the `superset` and // have the same value (strict equality), or subset[key] <= superset[key] return parsedSuperset[key] @@ -548,232 +554,312 @@ export default class Interpreter extends EventEmitter { } } - private async handlePagination(page: Page, config: { listSelector: string, fields: any, limit?: number, pagination: any }) { + private async handlePagination(page: Page, config: { + listSelector: string, + fields: any, + limit?: number, + pagination: any +}) { let allResults: Record[] = []; let previousHeight = 0; - // track unique items per page to avoid re-scraping let scrapedItems: Set = new Set(); - let visitedUrls: string[] = []; + let visitedUrls: Set = new Set(); + const MAX_RETRIES = 3; + const RETRY_DELAY = 1000; // 1 second delay between retries - // Debug logging helper const debugLog = (message: string, ...args: any[]) => { - console.log(`[Page ${visitedUrls.length + 1}] ${message}`, ...args); + console.log(`[Page ${visitedUrls.size}] [URL: ${page.url()}] ${message}`, ...args); + }; + + const scrapeCurrentPage = async () => { + const results = await page.evaluate((cfg) => window.scrapeList(cfg), config); + const newResults = results.filter(item => { + const uniqueKey = JSON.stringify(item); + if (scrapedItems.has(uniqueKey)) return false; + scrapedItems.add(uniqueKey); + return true; + }); + allResults = allResults.concat(newResults); + debugLog("Results collected:", allResults.length); + }; + + const checkLimit = () => { + if (config.limit && allResults.length >= config.limit) { + allResults = allResults.slice(0, config.limit); + return true; + } + return false; + }; + + // Enhanced button finder with retry mechanism + const findWorkingButton = async (selectors: string[], retryCount = 0): Promise<{ + button: ElementHandle | null, + workingSelector: string | null + }> => { + for (const selector of selectors) { + try { + const button = await page.waitForSelector(selector, { + state: 'attached', + timeout: 10000 // Reduced timeout for faster checks + }); + if (button) { + debugLog('Found working selector:', selector); + return { button, workingSelector: selector }; + } + } catch (error) { + debugLog(`Selector failed: ${selector}`); + } + } + + // Implement retry mechanism when no selectors work + if (selectors.length > 0 && retryCount < MAX_RETRIES) { + debugLog(`Retry attempt ${retryCount + 1} of ${MAX_RETRIES}`); + await page.waitForTimeout(RETRY_DELAY); + return findWorkingButton(selectors, retryCount + 1); + } + + return { button: null, workingSelector: null }; + }; + + const retryOperation = async (operation: () => Promise, retryCount = 0): Promise => { + try { + return await operation(); + } catch (error) { + if (retryCount < MAX_RETRIES) { + debugLog(`Retrying operation. Attempt ${retryCount + 1} of ${MAX_RETRIES}`); + await page.waitForTimeout(RETRY_DELAY); + return retryOperation(operation, retryCount + 1); + } + debugLog(`Operation failed after ${MAX_RETRIES} retries`); + return false; + } }; let availableSelectors = config.pagination.selector.split(','); - while (true) { + try { + while (true) { + // Reduced timeout for faster performance + await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); + switch (config.pagination.type) { - case 'scrollDown': - await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); - await page.waitForTimeout(2000); + case 'scrollDown': { + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + await page.waitForTimeout(2000); - const currentHeight = await page.evaluate(() => document.body.scrollHeight); - if (currentHeight === previousHeight) { - const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); - allResults = allResults.concat(finalResults); - return allResults; - } + const currentHeight = await page.evaluate(() => document.body.scrollHeight); + if (currentHeight === previousHeight) { + const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); + allResults = allResults.concat(finalResults); + return allResults; + } - previousHeight = currentHeight; - break; + previousHeight = currentHeight; + break; + } - case 'scrollUp': - await page.evaluate(() => window.scrollTo(0, 0)); - await page.waitForTimeout(2000); + case 'scrollUp': { + await page.evaluate(() => window.scrollTo(0, 0)); + await page.waitForTimeout(2000); - const currentTopHeight = await page.evaluate(() => document.documentElement.scrollTop); - if (currentTopHeight === 0) { - const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); - allResults = allResults.concat(finalResults); - return allResults; - } + const currentTopHeight = await page.evaluate(() => document.documentElement.scrollTop); + if (currentTopHeight === 0) { + const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); + allResults = allResults.concat(finalResults); + return allResults; + } - previousHeight = currentTopHeight; - break; + previousHeight = currentTopHeight; + break; + } - case 'clickNext': - debugLog("Current URL:", page.url()); - const pageResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); - - // Filter out already scraped items - const newResults = pageResults.filter(item => { - const uniqueKey = JSON.stringify(item); - if (scrapedItems.has(uniqueKey)) return false; - scrapedItems.add(uniqueKey); - return true; - }); - - allResults = allResults.concat(newResults); - debugLog("Results collected so far:", allResults.length); - - if (config.limit && allResults.length >= config.limit) { - return allResults.slice(0, config.limit); - } + case 'clickNext': { + const currentUrl = page.url(); + visitedUrls.add(currentUrl); + + await scrapeCurrentPage(); + if (checkLimit()) return allResults; - await page.waitForLoadState('networkidle', { timeout: 30000 }); - await page.waitForTimeout(2000); - - let checkButton = null; - let workingSelector = null; - - // Try each selector with explicit waiting - for (const selector of availableSelectors) { + const { button, workingSelector } = await findWorkingButton(availableSelectors); + if (!button || !workingSelector) { + // Final retry for navigation when no selectors work + const success = await retryOperation(async () => { try { - checkButton = await page.waitForSelector(selector, { - state: 'attached', - timeout: 30000 - }); - if (checkButton) { - workingSelector = selector; - debugLog('Found working selector:', selector); - break; - } - } catch (error) { - debugLog(`Selector failed: ${selector} - ${error.message}`); + await page.evaluate(() => window.history.forward()); + const newUrl = page.url(); + return !visitedUrls.has(newUrl); + } catch { + return false; } - } + }); + + if (!success) return allResults; + break; + } - if (!workingSelector) { - debugLog('No working selector found after trying all options'); - return allResults; - } + availableSelectors = availableSelectors.slice( + availableSelectors.indexOf(workingSelector) + ); - const nextButton = await page.$(workingSelector); - if (!nextButton) { - debugLog('Next button not found'); - return allResults; - } - - const selectorIndex = availableSelectors.indexOf(workingSelector); - availableSelectors = availableSelectors.slice(selectorIndex); + let retryCount = 0; + let navigationSuccess = false; + while (retryCount < MAX_RETRIES && !navigationSuccess) { try { - // Store current URL to check if navigation succeeded - const previousUrl = page.url(); - visitedUrls.push(previousUrl); - - // Try both click methods in sequence try { await Promise.all([ page.waitForNavigation({ - waitUntil: 'networkidle', - timeout: 15000 + waitUntil: 'networkidle', + timeout: 15000 }), - nextButton.click() + button.click() ]); - } catch (error) { - // If we're still on the same URL, try dispatch event - if (page.url() === previousUrl) { - await Promise.all([ - page.waitForNavigation({ - waitUntil: 'networkidle', - timeout: 15000 - }), - nextButton.dispatchEvent('click') - ]); + navigationSuccess = true; + } catch (error) { + debugLog(`Regular click failed on attempt ${retryCount + 1}. Trying DispatchEvent`); + + // If regular click fails, try dispatchEvent + if (page.url() === currentUrl) { + try { + await Promise.all([ + page.waitForNavigation({ + waitUntil: 'networkidle', + timeout: 15000 + }), + button.dispatchEvent('click') + ]); + navigationSuccess = true; + } catch (dispatchError) { + debugLog(`DispatchEvent failed on attempt ${retryCount + 1}.`); + } + } else { + navigationSuccess = true; } } - await page.waitForLoadState('domcontentloaded'); - await page.waitForLoadState('networkidle', { timeout: 30000 }); - - const currentUrl = page.url(); - if (visitedUrls.includes(currentUrl)) { - debugLog(`Navigation failed/Detected navigation to previously visited URL: ${currentUrl}`); - return allResults; + const newUrl = page.url(); + if (visitedUrls.has(newUrl)) { + debugLog(`Detected navigation to previously visited URL ${newUrl} on attempt ${retryCount + 1}`); + navigationSuccess = false; } - // Give the page a moment to stabilize after navigation - await page.waitForTimeout(1000); - + if (navigationSuccess) { + await page.waitForTimeout(1000); + } } catch (error) { - debugLog(`Navigation failed completely: ${error.message}`); + debugLog(`Navigation attempt ${retryCount + 1} failed completely.`); + navigationSuccess = false; + } + + if (!navigationSuccess) { + retryCount++; + if (retryCount < MAX_RETRIES) { + debugLog(`Retrying navigation - attempt ${retryCount + 1} of ${MAX_RETRIES}`); + await page.waitForTimeout(RETRY_DELAY); + } + } + } + + if (!navigationSuccess) { + debugLog(`Navigation failed after ${MAX_RETRIES} attempts`); + return allResults; + } + break; + } + + case 'clickLoadMore': { + while (true) { + // Find working button with retry mechanism, consistent with clickNext + const { button: loadMoreButton, workingSelector } = await findWorkingButton(availableSelectors); + + if (!workingSelector || !loadMoreButton) { + debugLog('No working Load More selector found after retries'); + const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); + allResults = allResults.concat(finalResults); return allResults; } - break; - - case 'clickLoadMore': - while (true) { - let checkButton = null; - let workingSelector = null; - - for (const selector of availableSelectors) { - try { - checkButton = await page.waitForSelector(selector, { - state: 'attached', - timeout: 30000 - }); - if (checkButton) { - workingSelector = selector; - debugLog('Found working selector:', selector); - break; - } - } catch (error) { - debugLog(`Load More selector failed: ${selector}`); - } - } - - if (!workingSelector) { - debugLog('No working Load More selector found'); - const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); - allResults = allResults.concat(finalResults); - return allResults; - } - - const loadMoreButton = await page.$(workingSelector); - if (!loadMoreButton) { - debugLog('Load More button not found'); - const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); - allResults = allResults.concat(finalResults); - return allResults; - } - - const selectorIndex = availableSelectors.indexOf(workingSelector); - availableSelectors = availableSelectors.slice(selectorIndex); - + + // Update available selectors to start from the working one + availableSelectors = availableSelectors.slice( + availableSelectors.indexOf(workingSelector) + ); + + // Implement retry mechanism for clicking the button + let retryCount = 0; + let clickSuccess = false; + + while (retryCount < MAX_RETRIES && !clickSuccess) { try { try { await loadMoreButton.click(); + clickSuccess = true; } catch (error) { - await loadMoreButton.dispatchEvent('click'); + debugLog(`Regular click failed on attempt ${retryCount + 1}. Trying DispatchEvent`); + + // If regular click fails, try dispatchEvent + try { + await loadMoreButton.dispatchEvent('click'); + clickSuccess = true; + } catch (dispatchError) { + debugLog(`DispatchEvent failed on attempt ${retryCount + 1}.`); + throw dispatchError; // Propagate error to trigger retry + } + } + + if (clickSuccess) { + await page.waitForTimeout(1000); } } catch (error) { - const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); - allResults = allResults.concat(finalResults); - return allResults; - } - - await page.waitForTimeout(2000); - await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); - await page.waitForTimeout(2000); - - const currentHeight = await page.evaluate(() => document.body.scrollHeight); - if (currentHeight === previousHeight) { - debugLog('No more items loaded after Load More'); - const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); - allResults = allResults.concat(finalResults); - return allResults; - } - previousHeight = currentHeight; - - if (config.limit && allResults.length >= config.limit) { - allResults = allResults.slice(0, config.limit); - break; + debugLog(`Click attempt ${retryCount + 1} failed completely.`); + retryCount++; + + if (retryCount < MAX_RETRIES) { + debugLog(`Retrying click - attempt ${retryCount + 1} of ${MAX_RETRIES}`); + await page.waitForTimeout(RETRY_DELAY); + } } } - break; - - default: - const results = await page.evaluate((cfg) => window.scrapeList(cfg), config); - allResults = allResults.concat(results); - return allResults; - } - - if (config.limit && allResults.length >= config.limit) { - allResults = allResults.slice(0, config.limit); + + if (!clickSuccess) { + debugLog(`Load More clicking failed after ${MAX_RETRIES} attempts`); + const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); + allResults = allResults.concat(finalResults); + return allResults; + } + + // Wait for content to load and check scroll height + await page.waitForTimeout(2000); + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + await page.waitForTimeout(2000); + + const currentHeight = await page.evaluate(() => document.body.scrollHeight); + if (currentHeight === previousHeight) { + debugLog('No more items loaded after Load More'); + const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); + allResults = allResults.concat(finalResults); + return allResults; + } + previousHeight = currentHeight; + + if (config.limit && allResults.length >= config.limit) { + allResults = allResults.slice(0, config.limit); + break; + } + } break; + } + + default: { + await scrapeCurrentPage(); + return allResults; + } } + + if (checkLimit()) break; + } + } catch (error) { + debugLog(`Fatal error: ${error.message}`); + return allResults; } return allResults; diff --git a/package.json b/package.json index 261169f6..0f1e977d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "maxun", - "version": "0.0.8", + "version": "0.0.10", "author": "Maxun", "license": "AGPL-3.0-or-later", "dependencies": { @@ -51,7 +51,7 @@ "lodash": "^4.17.21", "loglevel": "^1.8.0", "loglevel-plugin-remote": "^0.6.8", - "maxun-core": "^0.0.10", + "maxun-core": "^0.0.12", "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 613ddfef..7314b677 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -8,7 +8,16 @@ "register_prompt": "Noch keinen Account?", "register_link": "Registrieren", "welcome_notification": "Willkommen bei Maxun!", - "error_notification": "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut." + "validation": { + "required_fields": "E-Mail und Passwort sind erforderlich", + "password_length": "Das Passwort muss mindestens 6 Zeichen lang sein" + }, + "error": { + "user_not_found": "Benutzer existiert nicht", + "invalid_credentials": "Ungültige E-Mail oder Passwort", + "server_error": "Anmeldung fehlgeschlagen. Bitte versuchen Sie es später erneut", + "generic": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut" + } }, "register": { "title": "Konto registrieren", @@ -19,7 +28,16 @@ "register_prompt": "Bereits ein Konto?", "login_link": "Einloggen", "welcome_notification": "Willkommen bei Maxun!", - "error_notification": "Registrierung fehlgeschlagen. Bitte versuchen Sie es erneut." + "validation": { + "email_required": "E-Mail-Adresse ist erforderlich", + "password_requirements": "Das Passwort muss mindestens 6 Zeichen lang sein" + }, + "error": { + "user_exists": "Ein Benutzer mit dieser E-Mail existiert bereits", + "creation_failed": "Konto konnte nicht erstellt werden", + "server_error": "Serverfehler aufgetreten", + "generic": "Registrierung fehlgeschlagen. Bitte versuchen Sie es erneut" + } }, "recordingtable": { "run": "Ausführen", @@ -32,7 +50,7 @@ "new": "Roboter erstellen", "modal": { "title": "Geben Sie die URL ein", - "login_title": "Erfordert diese Seite eine Anmeldung?", + "login_title": "Ist für diese Website eine Anmeldung erforderlich?", "label": "URL", "button": "Aufnahme starten" }, @@ -469,6 +487,19 @@ }, "navbar": { "project_name": "Maxun", + "notifications": { + "success": { + "logout": "Erfolgreich abgemeldet" + }, + "errors": { + "logout": { + "unauthorized": "Sie sind nicht berechtigt, diese Aktion durchzuführen", + "server": "Serverfehler beim Abmelden", + "network": "Netzwerkfehler beim Abmelden", + "unknown": "Ein unerwarteter Fehler ist beim Abmelden aufgetreten" + } + } + }, "upgrade": { "button": "Upgrade", "modal": { diff --git a/public/locales/en.json b/public/locales/en.json index 85bd3148..703ec9c0 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -8,10 +8,19 @@ "register_prompt": "Don't have an account?", "register_link": "Register", "welcome_notification": "Welcome to Maxun!", - "error_notification": "Login Failed. Please try again." + "validation": { + "required_fields": "Email and password are required", + "password_length": "Password must be at least 6 characters" + }, + "error": { + "user_not_found": "User does not exist", + "invalid_credentials": "Invalid email or password", + "server_error": "Login failed. Please try again later", + "generic": "An error occurred. Please try again" + } }, "register": { - "title": "Register Account", + "title": "Create An Account", "email": "Enter Work Email", "password": "Password", "button": "Register", @@ -19,7 +28,16 @@ "register_prompt": "Already have an account?", "login_link": "Login", "welcome_notification": "Welcome to Maxun!", - "error_notification": "Registeration Failed. Please try again." + "validation": { + "email_required": "Email is required", + "password_requirements": "Password must be at least 6 characters" + }, + "error": { + "user_exists": "User with this email already exists", + "creation_failed": "Could not create account", + "server_error": "Server error occurred", + "generic": "Registration failed. Please try again" + } }, "recordingtable":{ "run": "Run", @@ -32,7 +50,7 @@ "new":"Create Robot", "modal":{ "title":"Enter the URL", - "login_title": "Does this website require login?", + "login_title": "Does this website require logging in?", "label":"URL", "button":"Start Recording" }, @@ -480,6 +498,19 @@ }, "navbar": { "project_name": "Maxun", + "notifications": { + "success": { + "logout": "Logged out successfully" + }, + "errors": { + "logout": { + "unauthorized": "You are not authorized to perform this action", + "server": "Server error occurred during logout", + "network": "Network error while logging out", + "unknown": "An unexpected error occurred during logout" + } + } + }, "upgrade": { "button": "Upgrade", "modal": { diff --git a/public/locales/es.json b/public/locales/es.json index 46bded05..ca4edc5d 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -8,7 +8,16 @@ "register_prompt": "¿No tienes una cuenta?", "register_link": "Registrarse", "welcome_notification": "¡Bienvenido a Maxun!", - "error_notification": "Error al iniciar sesión. Por favor, inténtalo de nuevo." + "validation": { + "required_fields": "El correo electrónico y la contraseña son obligatorios", + "password_length": "La contraseña debe tener al menos 6 caracteres" + }, + "error": { + "user_not_found": "El usuario no existe", + "invalid_credentials": "Correo electrónico o contraseña inválidos", + "server_error": "Error al iniciar sesión. Por favor, inténtelo de nuevo más tarde", + "generic": "Se produjo un error. Por favor, inténtelo de nuevo" + } }, "register": { "title": "Crear cuenta", @@ -19,7 +28,16 @@ "register_prompt": "¿Ya tienes una cuenta?", "login_link": "Iniciar sesión", "welcome_notification": "¡Bienvenido a Maxun!", - "error_notification": "Error en el registro. Por favor, inténtalo de nuevo." + "validation": { + "email_required": "El correo electrónico es obligatorio", + "password_requirements": "La contraseña debe tener al menos 6 caracteres" + }, + "error": { + "user_exists": "Ya existe un usuario con este correo electrónico", + "creation_failed": "No se pudo crear la cuenta", + "server_error": "Ocurrió un error en el servidor", + "generic": "Error en el registro. Por favor, inténtelo de nuevo" + } }, "recordingtable": { "run": "Ejecutar", @@ -32,7 +50,7 @@ "new": "Crear Robot", "modal": { "title": "Ingresa la URL", - "login_title": "¿Este sitio requiere inicio de sesión?", + "login_title": "¿Este sitio web requiere iniciar sesión?", "label": "URL", "button": "Comenzar grabación" }, @@ -470,6 +488,19 @@ }, "navbar": { "project_name": "Maxun", + "notifications": { + "success": { + "logout": "Sesión cerrada exitosamente" + }, + "errors": { + "logout": { + "unauthorized": "No estás autorizado para realizar esta acción", + "server": "Error del servidor durante el cierre de sesión", + "network": "Error de red al cerrar sesión", + "unknown": "Ocurrió un error inesperado al cerrar sesión" + } + } + }, "upgrade": { "button": "Actualizar", "modal": { diff --git a/public/locales/ja.json b/public/locales/ja.json index ad78c8f5..14e8e9e6 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -8,7 +8,16 @@ "register_prompt": "アカウントをお持ちでないですか?", "register_link": "登録する", "welcome_notification": "Maxunへようこそ!", - "error_notification": "ログインに失敗しました。もう一度お試しください。" + "validation": { + "required_fields": "メールアドレスとパスワードは必須です", + "password_length": "パスワードは6文字以上である必要があります" + }, + "error": { + "user_not_found": "ユーザーが存在しません", + "invalid_credentials": "メールアドレスまたはパスワードが無効です", + "server_error": "ログインに失敗しました。後でもう一度お試しください", + "generic": "エラーが発生しました。もう一度お試しください" + } }, "register": { "title": "アカウントを登録する", @@ -19,7 +28,16 @@ "register_prompt": "既にアカウントをお持ちですか?", "login_link": "ログイン", "welcome_notification": "Maxunへようこそ!", - "error_notification": "登録に失敗しました。もう一度お試しください。" + "validation": { + "email_required": "メールアドレスは必須です", + "password_requirements": "パスワードは6文字以上である必要があります" + }, + "error": { + "user_exists": "このメールアドレスは既に登録されています", + "creation_failed": "アカウントを作成できませんでした", + "server_error": "サーバーエラーが発生しました", + "generic": "登録に失敗しました。もう一度お試しください" + } }, "recordingtable": { "run": "実行", @@ -123,11 +141,11 @@ "generate_button": "APIキーを生成", "notifications": { "fetch_error": "APIキーの取得に失敗しました - ${error}", - "generate_success": "APIキーが正常に生成されました", + "generate_success": "APIキーの生成に成功しました", "generate_error": "APIキーの生成に失敗しました - ${error}", - "delete_success": "APIキーが正常に削除されました", + "delete_success": "APIキーの削除に成功しました", "delete_error": "APIキーの削除に失敗しました - ${error}", - "copy_success": "APIキーがコピーされました" + "copy_success": "APIキーのコピーに成功しました" } }, "action_description": { @@ -470,6 +488,19 @@ }, "navbar": { "project_name": "Maxun", + "notifications": { + "success": { + "logout": "ログアウトに成功しました" + }, + "errors": { + "logout": { + "unauthorized": "この操作を実行する権限がありません", + "server": "ログアウト中にサーバーエラーが発生しました", + "network": "ログアウト中にネットワークエラーが発生しました", + "unknown": "ログアウト中に予期せぬエラーが発生しました" + } + } + }, "upgrade": { "button": "アップグレード", "modal": { diff --git a/public/locales/zh.json b/public/locales/zh.json index cd1a4f9f..5f7a367d 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -8,7 +8,16 @@ "register_prompt": "还没有账号?", "register_link": "注册", "welcome_notification": "欢迎使用 Maxun!", - "error_notification": "登录失败。请重试。" + "validation": { + "required_fields": "邮箱和密码为必填项", + "password_length": "密码必须至少6个字符" + }, + "error": { + "user_not_found": "用户不存在", + "invalid_credentials": "邮箱或密码无效", + "server_error": "登录失败,请稍后重试", + "generic": "发生错误,请重试" + } }, "register": { "title": "注册账号", @@ -19,7 +28,16 @@ "register_prompt": "已有账号?", "login_link": "登录", "welcome_notification": "欢迎使用 Maxun!", - "error_notification": "注册失败。请重试。" + "validation": { + "email_required": "邮箱是必填项", + "password_requirements": "密码必须至少6个字符" + }, + "error": { + "user_exists": "该邮箱已被注册", + "creation_failed": "无法创建账户", + "server_error": "服务器错误", + "generic": "注册失败,请重试" + } }, "recordingtable": { "run": "运行", @@ -123,11 +141,11 @@ "generate_button": "生成API密钥", "notifications": { "fetch_error": "获取API密钥失败 - ${error}", - "generate_success": "API密钥生成成功", + "generate_success": "成功生成API密钥", "generate_error": "生成API密钥失败 - ${error}", - "delete_success": "API密钥删除成功", + "delete_success": "成功删除API密钥", "delete_error": "删除API密钥失败 - ${error}", - "copy_success": "API密钥复制成功" + "copy_success": "成功复制API密钥" } }, "action_description": { @@ -470,6 +488,19 @@ }, "navbar": { "project_name": "Maxun", + "notifications": { + "success": { + "logout": "退出登录成功" + }, + "errors": { + "logout": { + "unauthorized": "您没有执行此操作的权限", + "server": "退出登录时发生服务器错误", + "network": "退出登录时发生网络错误", + "unknown": "退出登录时发生未知错误" + } + } + }, "upgrade": { "button": "升级", "modal": { diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 92e7a7e3..af7ecf7e 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -28,29 +28,47 @@ router.post("/register", async (req, res) => { try { const { email, password } = req.body; - if (!email) return res.status(400).send("Email is required"); - if (!password || password.length < 6) - return res - .status(400) - .send("Password is required and must be at least 6 characters"); + if (!email) { + return res.status(400).json({ + error: "VALIDATION_ERROR", + code: "register.validation.email_required" + }); + } + + if (!password || password.length < 6) { + return res.status(400).json({ + error: "VALIDATION_ERROR", + code: "register.validation.password_requirements" + }); + } let userExist = await User.findOne({ raw: true, where: { email } }); - if (userExist) return res.status(400).send("User already exists"); + if (userExist) { + return res.status(400).json({ + error: "USER_EXISTS", + code: "register.error.user_exists" + }); + } const hashedPassword = await hashPassword(password); let user: any; - try { user = await User.create({ email, password: hashedPassword }); } catch (error: any) { console.log(`Could not create user - ${error}`); - return res.status(500).send(`Could not create user - ${error.message}`); + return res.status(500).json({ + error: "DATABASE_ERROR", + code: "register.error.creation_failed" + }); } if (!process.env.JWT_SECRET) { console.log("JWT_SECRET is not defined in the environment"); - return res.status(500).send("Internal Server Error"); + return res.status(500).json({ + error: "SERVER_ERROR", + code: "register.error.server_error" + }); } const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET as string); @@ -58,36 +76,60 @@ router.post("/register", async (req, res) => { res.cookie("token", token, { httpOnly: true, }); + capture("maxun-oss-user-registered", { email: user.email, userId: user.id, registeredAt: new Date().toISOString(), }); + console.log(`User registered`); res.json(user); + } catch (error: any) { console.log(`Could not register user - ${error}`); - res.status(500).send(`Could not register user - ${error.message}`); + return res.status(500).json({ + error: "SERVER_ERROR", + code: "register.error.generic" + }); } }); router.post("/login", async (req, res) => { try { const { email, password } = req.body; - if (!email || !password) - return res.status(400).send("Email and password are required"); - if (password.length < 6) - return res.status(400).send("Password must be at least 6 characters"); + if (!email || !password) { + return res.status(400).json({ + error: "VALIDATION_ERROR", + code: "login.validation.required_fields" + }); + } + + if (password.length < 6) { + return res.status(400).json({ + error: "VALIDATION_ERROR", + code: "login.validation.password_length" + }); + } let user = await User.findOne({ raw: true, where: { email } }); - if (!user) return res.status(400).send("User does not exist"); + if (!user) { + return res.status(404).json({ + error: "USER_NOT_FOUND", + code: "login.error.user_not_found" + }); + } const match = await comparePassword(password, user.password); - if (!match) return res.status(400).send("Invalid email or password"); + if (!match) { + return res.status(401).json({ + error: "INVALID_CREDENTIALS", + code: "login.error.invalid_credentials" + }); + } const token = jwt.sign({ id: user?.id }, process.env.JWT_SECRET as string); - // return user and token to client, exclude hashed password if (user) { user.password = undefined as unknown as string; } @@ -101,30 +143,43 @@ router.post("/login", async (req, res) => { }); res.json(user); } catch (error: any) { - res.status(400).send(`Could not login user - ${error.message}`); - console.log(`Could not login user - ${error}`); + console.error(`Login error: ${error.message}`); + res.status(500).json({ + error: "SERVER_ERROR", + code: "login.error.server_error" + }); } }); router.get("/logout", async (req, res) => { - try { - res.clearCookie("token"); - return res.json({ message: "Logout successful" }); - } catch (error: any) { - res.status(500).send(`Could not logout user - ${error.message}`); + try { + res.clearCookie("token"); + return res.status(200).json({ + ok: true, + message: "Logged out successfully", + code: "success" + }); + } catch (error) { + console.error('Logout error:', error); + return res.status(500).json({ + ok: false, + message: "Error during logout", + code: "server", + error: process.env.NODE_ENV === 'development' ? error : undefined + }); + } } -}); +); router.get( "/current-user", requireSignIn, - async (req: Request, res) => { - const authenticatedReq = req as AuthenticatedRequest; + async (req: AuthenticatedRequest, res) => { try { - if (!authenticatedReq.user) { + if (!req.user) { return res.status(401).json({ ok: false, error: "Unauthorized" }); } - const user = await User.findByPk(authenticatedReq.user.id, { + const user = await User.findByPk(req.user.id, { attributes: { exclude: ["password"] }, }); if (!user) { @@ -147,7 +202,7 @@ router.get( router.get( "/user/:id", requireSignIn, - async (req: Request, res) => { + async (req: AuthenticatedRequest, res) => { try { const { id } = req.params; if (!id) { @@ -176,13 +231,12 @@ router.get( router.post( "/generate-api-key", requireSignIn, - async (req: Request, res) => { - const authenticatedReq = req as AuthenticatedRequest; + async (req: AuthenticatedRequest, res) => { try { - if (!authenticatedReq.user) { + if (!req.user) { return res.status(401).json({ ok: false, error: "Unauthorized" }); } - const user = await User.findByPk(authenticatedReq.user.id, { + const user = await User.findByPk(req.user.id, { attributes: { exclude: ["password"] }, }); @@ -217,28 +271,41 @@ router.post( router.get( "/api-key", requireSignIn, - async (req: Request, res) => { - const authenticatedReq = req as AuthenticatedRequest; + async (req: AuthenticatedRequest, res) => { try { - if (!authenticatedReq.user) { - return res.status(401).json({ ok: false, error: "Unauthorized" }); + if (!req.user) { + return res.status(401).json({ + ok: false, + error: "Unauthorized", + code: "unauthorized" + }); } - const user = await User.findByPk(authenticatedReq.user.id, { + const user = await User.findByPk(req.user.id, { raw: true, attributes: ["api_key"], }); if (!user) { - return res.status(404).json({ message: "User not found" }); + return res.status(404).json({ + ok: false, + error: "User not found", + code: "not_found" + }); } return res.status(200).json({ + ok: true, message: "API key fetched successfully", api_key: user.api_key || null, }); } catch (error) { - return res.status(500).json({ message: "Error fetching API key", error }); + console.error('API Key fetch error:', error); + return res.status(500).json({ + ok: false, + error: "Error fetching API key", + code: "server", + }); } } ); @@ -246,14 +313,13 @@ router.get( router.delete( "/delete-api-key", requireSignIn, - async (req: Request, res) => { - const authenticatedReq = req as AuthenticatedRequest; - if (!authenticatedReq.user) { + async (req: AuthenticatedRequest, res) => { + if (!req.user) { return res.status(401).send({ error: "Unauthorized" }); } try { - const user = await User.findByPk(authenticatedReq.user.id, { raw: true }); + const user = await User.findByPk(req.user.id, { raw: true }); if (!user) { return res.status(404).json({ message: "User not found" }); @@ -263,7 +329,7 @@ router.delete( return res.status(404).json({ message: "API Key not found" }); } - await User.update({ api_key: null }, { where: { id: authenticatedReq.user.id } }); + await User.update({ api_key: null }, { where: { id: req.user.id } }); capture("maxun-oss-api-key-deleted", { user_id: user.id, @@ -309,8 +375,7 @@ router.get("/google", (req, res) => { router.get( "/google/callback", requireSignIn, - async (req: Request, res) => { - const authenticatedReq = req as AuthenticatedRequest; + async (req: AuthenticatedRequest, res) => { const { code, state } = req.query; try { if (!state) { @@ -336,12 +401,12 @@ router.get( return res.status(400).json({ message: "Email not found" }); } - if (!authenticatedReq.user) { + if (!req.user) { return res.status(401).send({ error: "Unauthorized" }); } // Get the currently authenticated user (from `requireSignIn`) - let user = await User.findOne({ where: { id: authenticatedReq.user.id } }); + let user = await User.findOne({ where: { id: req.user.id } }); if (!user) { return res.status(400).json({ message: "User not found" }); @@ -419,13 +484,12 @@ router.get( router.post( "/gsheets/data", requireSignIn, - async (req: Request, res) => { - const authenticatedReq = req as AuthenticatedRequest; + async (req: AuthenticatedRequest, res) => { const { spreadsheetId, robotId } = req.body; - if (!authenticatedReq.user) { + if (!req.user) { return res.status(401).send({ error: "Unauthorized" }); } - const user = await User.findByPk(authenticatedReq.user.id, { raw: true }); + const user = await User.findByPk(req.user.id, { raw: true }); if (!user) { return res.status(400).json({ message: "User not found" }); @@ -537,14 +601,13 @@ router.post("/gsheets/update", requireSignIn, async (req, res) => { router.post( "/gsheets/remove", requireSignIn, - async (req: Request, res) => { - const authenticatedReq = req as AuthenticatedRequest; + async (req: AuthenticatedRequest, res) => { const { robotId } = req.body; if (!robotId) { return res.status(400).json({ message: "Robot ID is required" }); } - if (!authenticatedReq.user) { + if (!req.user) { return res.status(401).send({ error: "Unauthorized" }); } @@ -566,7 +629,7 @@ router.post( }); capture("maxun-oss-google-sheet-integration-removed", { - user_id: authenticatedReq.user.id, + user_id: req.user.id, robot_id: robotId, deleted_at: new Date().toISOString(), }); @@ -866,4 +929,6 @@ router.get("/airtable/tables", async (req: Request, res) => { } catch (error: any) { res.status(500).json({ message: error.message }); } -}); \ No newline at end of file +}); + + diff --git a/server/src/routes/storage.ts b/server/src/routes/storage.ts index 5d2d989f..5ab9ef7a 100644 --- a/server/src/routes/storage.ts +++ b/server/src/routes/storage.ts @@ -26,35 +26,43 @@ chromium.use(stealthPlugin()); export const router = Router(); -export const decryptWorkflowActions = async (workflow: any[],): Promise => { - // Create a deep copy to avoid mutating the original workflow - const processedWorkflow = JSON.parse(JSON.stringify(workflow)); +export const processWorkflowActions = async (workflow: any[], checkLimit: boolean = false): Promise => { + const processedWorkflow = JSON.parse(JSON.stringify(workflow)); - // Process each step in the workflow - for (const step of processedWorkflow) { - if (!step.what) continue; - - // Process each action in the step - for (const action of step.what) { - // Only process type and press actions - if ((action.action === 'type' || action.action === 'press') && Array.isArray(action.args) && action.args.length > 1) { - // The second argument contains the encrypted value - const encryptedValue = action.args[1]; - if (typeof encryptedValue === 'string') { - try { - // Decrypt the value and update the args array - action.args[1] = await decrypt(encryptedValue); - } catch (error) { - console.error('Failed to decrypt value:', error); - // Keep the encrypted value if decryption fails + processedWorkflow.forEach((pair: any) => { + pair.what.forEach((action: any) => { + // Handle limit validation for scrapeList action + if (action.action === 'scrapeList' && checkLimit && Array.isArray(action.args) && action.args.length > 0) { + const scrapeConfig = action.args[0]; + if (scrapeConfig && typeof scrapeConfig === 'object' && 'limit' in scrapeConfig) { + if (typeof scrapeConfig.limit === 'number' && scrapeConfig.limit > 5) { + scrapeConfig.limit = 5; } } } - } - } + + // Handle decryption for type and press actions + if ((action.action === 'type' || action.action === 'press') && Array.isArray(action.args) && action.args.length > 1) { + try { + const encryptedValue = action.args[1]; + if (typeof encryptedValue === 'string') { + const decryptedValue = decrypt(encryptedValue); + action.args[1] = decryptedValue; + } else { + logger.log('error', 'Encrypted value is not a string'); + action.args[1] = ''; + } + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.log('error', `Failed to decrypt input value: ${errorMessage}`); + action.args[1] = ''; + } + } + }); + }); return processedWorkflow; -}; +} /** * Logs information about recordings API. @@ -89,7 +97,7 @@ router.get('/recordings/:id', requireSignIn, async (req, res) => { ); if (data?.recording?.workflow) { - data.recording.workflow = await decryptWorkflowActions( + data.recording.workflow = await processWorkflowActions( data.recording.workflow, ); } @@ -164,54 +172,82 @@ interface Credentials { [key: string]: CredentialInfo; } -function updateTypeActionsInWorkflow(workflow: any[], credentials: Credentials) { +function handleWorkflowActions(workflow: any[], credentials: Credentials) { return workflow.map(step => { if (!step.what) return step; - const indicesToRemove = new Set(); - step.what.forEach((action: any, index: number) => { - if (!action.action || !action.args?.[0]) return; + const newWhat: any[] = []; + const processedSelectors = new Set(); + + for (let i = 0; i < step.what.length; i++) { + const action = step.what[i]; - if ((action.action === 'type' || action.action === 'press') && credentials[action.args[0]]) { - indicesToRemove.add(index); - - if (step.what[index + 1]?.action === 'waitForLoadState') { - indicesToRemove.add(index + 1); - } + if (!action?.action || !action?.args?.[0]) { + newWhat.push(action); + continue; } - }); - const filteredWhat = step.what.filter((_: any, index: number) => !indicesToRemove.has(index)); + const selector = action.args[0]; + const credential = credentials[selector]; - Object.entries(credentials).forEach(([selector, credentialInfo]) => { - const clickIndex = filteredWhat.findIndex((action: any) => - action.action === 'click' && action.args?.[0] === selector - ); + if (!credential) { + newWhat.push(action); + continue; + } - if (clickIndex !== -1) { - const chars = credentialInfo.value.split(''); + if (action.action === 'click') { + newWhat.push(action); - chars.forEach((char, i) => { - filteredWhat.splice(clickIndex + 1 + (i * 2), 0, { + if (!processedSelectors.has(selector) && + i + 1 < step.what.length && + (step.what[i + 1].action === 'type' || step.what[i + 1].action === 'press')) { + + newWhat.push({ action: 'type', - args: [ - selector, - encrypt(char), - credentialInfo.type - ] + args: [selector, encrypt(credential.value), credential.type] }); - filteredWhat.splice(clickIndex + 2 + (i * 2), 0, { + newWhat.push({ action: 'waitForLoadState', args: ['networkidle'] }); + + processedSelectors.add(selector); + + while (i + 1 < step.what.length && + (step.what[i + 1].action === 'type' || + step.what[i + 1].action === 'press' || + step.what[i + 1].action === 'waitForLoadState')) { + i++; + } + } + } else if ((action.action === 'type' || action.action === 'press') && + !processedSelectors.has(selector)) { + newWhat.push({ + action: 'type', + args: [selector, encrypt(credential.value), credential.type] }); + + newWhat.push({ + action: 'waitForLoadState', + args: ['networkidle'] + }); + + processedSelectors.add(selector); + + // Skip subsequent type/press/waitForLoadState actions for this selector + while (i + 1 < step.what.length && + (step.what[i + 1].action === 'type' || + step.what[i + 1].action === 'press' || + step.what[i + 1].action === 'waitForLoadState')) { + i++; + } } - }); + } return { ...step, - what: filteredWhat + what: newWhat }; }); } @@ -244,7 +280,7 @@ router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, r let workflow = [...robot.recording.workflow]; // Create a copy of the workflow if (credentials) { - workflow = updateTypeActionsInWorkflow(workflow, credentials); + workflow = handleWorkflowActions(workflow, credentials); } // Update the limit @@ -282,9 +318,23 @@ router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, r } } - robot.set('recording', { ...robot.recording, workflow }); + const updates: any = { + recording: { + ...robot.recording, + workflow + } + }; - await robot.save(); + if (name) { + updates.recording_meta = { + ...robot.recording_meta, + name + }; + } + + await Robot.update(updates, { + where: { 'recording_meta.id': id } + }); const updatedRobot = await Robot.findOne({ where: { 'recording_meta.id': id } }); @@ -502,6 +552,7 @@ router.put('/runs/:id', requireSignIn, async (req: AuthenticatedRequest, res) => return res.send({ browserId: id, runId: plainRun.runId, + robotMetaId: recording.recording_meta.id, }); } catch (e) { const { message } = e as Error; diff --git a/server/src/workflow-management/classes/Generator.ts b/server/src/workflow-management/classes/Generator.ts index 6e36f287..2eb5f130 100644 --- a/server/src/workflow-management/classes/Generator.ts +++ b/server/src/workflow-management/classes/Generator.ts @@ -22,7 +22,7 @@ import { getBestSelectorForAction } from "../utils"; import { browserPool } from "../../server"; import { uuid } from "uuidv4"; import { capture } from "../../utils/analytics" -import { encrypt } from "../../utils/auth"; +import { decrypt, encrypt } from "../../utils/auth"; interface PersistedGeneratedData { lastUsedSelector: string; @@ -42,6 +42,13 @@ interface MetaData { isLogin?: boolean; } +interface InputState { + selector: string; + value: string; + type: string; + cursorPosition: number; +} + /** * Workflow generator is used to transform the user's interactions into an automatically * generated correct workflows, using the ability of internal state persistence and @@ -428,26 +435,86 @@ export class WorkflowGenerator { } 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); + const positionAndCursor = await page.evaluate( + ({ selector, coords }) => { + const getCursorPosition = (element: any, clickX: any) => { + const text = element.value; + + const mirror = document.createElement('div'); + + const style = window.getComputedStyle(element); + mirror.style.cssText = ` + font: ${style.font}; + line-height: ${style.lineHeight}; + padding: ${style.padding}; + border: ${style.border}; + box-sizing: ${style.boxSizing}; + white-space: ${style.whiteSpace}; + overflow-wrap: ${style.overflowWrap}; + position: absolute; + top: -9999px; + left: -9999px; + width: ${element.offsetWidth}px; + `; + + document.body.appendChild(mirror); + + const paddingLeft = parseFloat(style.paddingLeft); + const borderLeft = parseFloat(style.borderLeftWidth); + + const adjustedClickX = clickX - (paddingLeft + borderLeft); + + let bestIndex = 0; + let bestDiff = Infinity; + + for (let i = 0; i <= text.length; i++) { + const textBeforeCursor = text.substring(0, i); + const span = document.createElement('span'); + span.textContent = textBeforeCursor; + mirror.innerHTML = ''; + mirror.appendChild(span); + + const textWidth = span.getBoundingClientRect().width; + + const diff = Math.abs(adjustedClickX - textWidth); + + if (diff < bestDiff) { + bestIndex = i; + bestDiff = diff; + } + } + + document.body.removeChild(mirror); + + return bestIndex; + }; - if (elementPos) { - const relativeX = coordinates.x - elementPos.x; - const relativeY = coordinates.y - elementPos.y; + const element = document.querySelector(selector) as HTMLInputElement | HTMLTextAreaElement; + if (!element) return null; + + const rect = element.getBoundingClientRect(); + const relativeX = coords.x - rect.left; + + return { + rect: { + x: rect.left, + y: rect.top + }, + cursorIndex: getCursorPosition(element, relativeX) + }; + }, + { selector, coords: coordinates } + ); + + if (positionAndCursor) { + const relativeX = coordinates.x - positionAndCursor.rect.x; + const relativeY = coordinates.y - positionAndCursor.rect.y; const pair: WhereWhatPair = { where, what: [{ action: 'click', - args: [selector, { position: { x: relativeX, y: relativeY } }] + args: [selector, { position: { x: relativeX, y: relativeY } }, { cursorIndex: positionAndCursor.cursorIndex }], }] }; @@ -759,8 +826,7 @@ export class WorkflowGenerator { selectors?.id, selectors?.hrefSelector, selectors?.accessibilitySelector, - selectors?.attrSelector, - selectors?.generalSelector + selectors?.attrSelector ] .filter(selector => selector !== null && selector !== undefined) .join(','); @@ -1027,77 +1093,107 @@ export class WorkflowGenerator { * @param workflow The workflow to be optimized. */ private optimizeWorkflow = (workflow: WorkflowFile) => { - - // replace a sequence of press actions by a single fill action - let input = { - selector: '', - value: '', - type: '', - actionCounter: 0, - }; - - const pushTheOptimizedAction = (pair: WhereWhatPair, index: number) => { - if (input.value.length === 1) { - // when only one press action is present, keep it and add a waitForLoadState action - pair.what.splice(index + 1, 0, { - action: 'waitForLoadState', - args: ['networkidle'], - }) - } else { - // 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), input.type], - }, { - action: 'waitForLoadState', - args: ['networkidle'], - }); + const inputStates = new Map(); + + for (const pair of workflow.workflow) { + let currentIndex = 0; + + while (currentIndex < pair.what.length) { + const condition = pair.what[currentIndex]; + + if (condition.action === 'click' && condition.args?.[2]?.cursorIndex !== undefined) { + const selector = condition.args[0]; + const cursorIndex = condition.args[2].cursorIndex; + + let state = inputStates.get(selector) || { + selector, + value: '', + type: 'text', + cursorPosition: -1 + }; + + state.cursorPosition = cursorIndex; + inputStates.set(selector, state); + + pair.what.splice(currentIndex, 1); + continue; + } + + if (condition.action === 'press' && condition.args?.[1]) { + const [selector, encryptedKey, type] = condition.args; + const key = decrypt(encryptedKey); + + let state = inputStates.get(selector); + if (!state) { + state = { + selector, + value: '', + type: type || 'text', + cursorPosition: -1 + }; + } else { + state.type = type || state.type; + } + + if (key.length === 1) { + if (state.cursorPosition === -1) { + state.value += key; + } else { + state.value = + state.value.slice(0, state.cursorPosition) + + key + + state.value.slice(state.cursorPosition); + state.cursorPosition++; + } + } else if (key === 'Backspace') { + if (state.cursorPosition > 0) { + state.value = + state.value.slice(0, state.cursorPosition - 1) + + state.value.slice(state.cursorPosition); + state.cursorPosition--; + } else if (state.cursorPosition === -1 && state.value.length > 0) { + state.value = state.value.slice(0, -1); + } + } else if (key === 'Delete') { + if (state.cursorPosition >= 0 && state.cursorPosition < state.value.length) { + state.value = + state.value.slice(0, state.cursorPosition) + + state.value.slice(state.cursorPosition + 1); + } else if (state.cursorPosition === -1 && state.value.length > 0) { + state.value = state.value.slice(0, -1); + } + } + + inputStates.set(selector, state); + + pair.what.splice(currentIndex, 1); + continue; + } + + currentIndex++; } } - - - for (const pair of workflow.workflow) { - pair.what.forEach((condition, index) => { - if (condition.action === 'press') { - if (condition.args && condition.args[1]) { - if (!input.selector) { - input.selector = condition.args[0]; - } - if (input.selector === condition.args[0]) { - input.actionCounter++; - if (condition.args[1].length === 1) { - input.value = input.value + condition.args[1]; - } else if (condition.args[1] === 'Backspace') { - input.value = input.value.slice(0, -1); - } else if (condition.args[1] !== 'Shift') { - pushTheOptimizedAction(pair, index); - pair.what.splice(index + 1, 0, { - action: 'waitForLoadState', - args: ['networkidle'], - }) - 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, - }; - } - } - } else { - if (input.value.length !== 0) { - pushTheOptimizedAction(pair, index); - // clear the input - input = { selector: '', value: '', type: '', actionCounter: 0 }; - } + + for (const [selector, state] of inputStates.entries()) { + if (state.value) { + for (let i = workflow.workflow.length - 1; i >= 0; i--) { + const pair = workflow.workflow[i]; + + pair.what.push({ + action: 'type', + args: [selector, encrypt(state.value), state.type] + }, { + action: 'waitForLoadState', + args: ['networkidle'] + }); + + break; } - }); + } } + return workflow; - } + }; /** * Returns workflow params from the stored metadata. diff --git a/server/src/workflow-management/selector.ts b/server/src/workflow-management/selector.ts index 8a9096ec..20a6a529 100644 --- a/server/src/workflow-management/selector.ts +++ b/server/src/workflow-management/selector.ts @@ -1092,12 +1092,16 @@ export const getSelectors = async (page: Page, coordinates: Coordinates) => { newPath.splice(i, 1); const newPathKey = selector(newPath); if (scope.visited.has(newPathKey)) { - return; + continue; } - if (unique(newPath) && same(newPath, input)) { - yield newPath; - scope.visited.set(newPathKey, true); - yield* optimize(newPath, input, scope); + try { + if (unique(newPath) && same(newPath, input)) { + yield newPath; + scope.visited.set(newPathKey, true); + yield* optimize(newPath, input, scope); + } + } catch (e: any) { + continue; } } } @@ -1654,6 +1658,31 @@ export const getNonUniqueSelectors = async (page: Page, coordinates: Coordinates } } + if (element.parentElement) { + // Look for identical siblings + const siblings = Array.from(element.parentElement.children); + const identicalSiblings = siblings.filter(sibling => { + if (sibling === element) return false; + + let siblingSelector = sibling.tagName.toLowerCase(); + const siblingClassName = typeof sibling.className === 'string' ? sibling.className : ''; + if (siblingClassName) { + const siblingClasses = siblingClassName.split(/\s+/).filter(Boolean); + const validSiblingClasses = siblingClasses.filter(cls => !cls.startsWith('!') && !cls.includes(':')); + if (validSiblingClasses.length > 0) { + siblingSelector += '.' + validSiblingClasses.map(cls => CSS.escape(cls)).join('.'); + } + } + + return siblingSelector === selector; + }); + + if (identicalSiblings.length > 0) { + const position = siblings.indexOf(element) + 1; + selector += `:nth-child(${position})`; + } + } + return selector; } @@ -1894,6 +1923,31 @@ export const getNonUniqueSelectors = async (page: Page, coordinates: Coordinates } } + if (element.parentElement) { + // Look for identical siblings + const siblings = Array.from(element.parentElement.children); + const identicalSiblings = siblings.filter(sibling => { + if (sibling === element) return false; + + let siblingSelector = sibling.tagName.toLowerCase(); + const siblingClassName = typeof sibling.className === 'string' ? sibling.className : ''; + if (siblingClassName) { + const siblingClasses = siblingClassName.split(/\s+/).filter(Boolean); + const validSiblingClasses = siblingClasses.filter(cls => !cls.startsWith('!') && !cls.includes(':')); + if (validSiblingClasses.length > 0) { + siblingSelector += '.' + validSiblingClasses.map(cls => CSS.escape(cls)).join('.'); + } + } + + return siblingSelector === selector; + }); + + if (identicalSiblings.length > 0) { + const position = siblings.indexOf(element) + 1; + selector += `:nth-child(${position})`; + } + } + return selector; } @@ -2025,6 +2079,31 @@ export const getChildSelectors = async (page: Page, parentSelector: string): Pro } } + if (element.parentElement) { + // Look for identical siblings + const siblings = Array.from(element.parentElement.children); + const identicalSiblings = siblings.filter(sibling => { + if (sibling === element) return false; + + let siblingSelector = sibling.tagName.toLowerCase(); + const siblingClassName = typeof sibling.className === 'string' ? sibling.className : ''; + if (siblingClassName) { + const siblingClasses = siblingClassName.split(/\s+/).filter(Boolean); + const validSiblingClasses = siblingClasses.filter(cls => !cls.startsWith('!') && !cls.includes(':')); + if (validSiblingClasses.length > 0) { + siblingSelector += '.' + validSiblingClasses.map(cls => CSS.escape(cls)).join('.'); + } + } + + return siblingSelector === selector; + }); + + if (identicalSiblings.length > 0) { + const position = siblings.indexOf(element) + 1; + selector += `:nth-child(${position})`; + } + } + return selector; } diff --git a/src/api/storage.ts b/src/api/storage.ts index 290f6e7f..0bfcaf15 100644 --- a/src/api/storage.ts +++ b/src/api/storage.ts @@ -161,7 +161,7 @@ export const createRunForStoredRecording = async (id: string, settings: RunSetti } } catch (error: any) { console.log(error); - return { browserId: '', runId: '' }; + return { browserId: '', runId: '', robotMetaId: '' }; } } diff --git a/src/components/api/ApiKey.tsx b/src/components/api/ApiKey.tsx index 9d54fe5c..f84f7a55 100644 --- a/src/components/api/ApiKey.tsx +++ b/src/components/api/ApiKey.tsx @@ -14,7 +14,7 @@ import { Tooltip, Paper, } from '@mui/material'; -import { ContentCopy, Visibility, Delete } from '@mui/icons-material'; +import { ContentCopy, Visibility, VisibilityOff, Delete } from '@mui/icons-material'; import styled from 'styled-components'; import axios from 'axios'; import { useGlobalInfoStore } from '../../context/globalInfo'; @@ -137,7 +137,7 @@ const ApiKeyManager = () => { setShowKey(!showKey)}> - + {showKey ? : } diff --git a/src/components/browser/BrowserWindow.tsx b/src/components/browser/BrowserWindow.tsx index 9f62a22d..ffeb0df5 100644 --- a/src/components/browser/BrowserWindow.tsx +++ b/src/components/browser/BrowserWindow.tsx @@ -263,7 +263,12 @@ export const BrowserWindow = () => { } if (getList === true && !listSelector) { - setListSelector(highlighterData.selector); + let cleanedSelector = highlighterData.selector; + if (cleanedSelector.includes('nth-child')) { + cleanedSelector = cleanedSelector.replace(/:nth-child\(\d+\)/g, ''); + } + + setListSelector(cleanedSelector); notify(`info`, t('browser_window.attribute_modal.notifications.list_select_success')); setCurrentListId(Date.now()); setFields({}); @@ -275,13 +280,25 @@ export const BrowserWindow = () => { // Add fields to the list if (options.length === 1) { const attribute = options[0].value; + let currentSelector = highlighterData.selector; + + if (currentSelector.includes('>')) { + const [firstPart, ...restParts] = currentSelector.split('>').map(p => p.trim()); + const listSelectorRightPart = listSelector.split('>').pop()?.trim().replace(/:nth-child\(\d+\)/g, ''); + + if (firstPart.includes('nth-child') && + firstPart.replace(/:nth-child\(\d+\)/g, '') === listSelectorRightPart) { + currentSelector = `${firstPart.replace(/:nth-child\(\d+\)/g, '')} > ${restParts.join(' > ')}`; + } + } + const newField: TextStep = { id: Date.now(), type: 'text', label: `Label ${Object.keys(fields).length + 1}`, data: data, selectorObj: { - selector: highlighterData.selector, + selector: currentSelector, tag: highlighterData.elementInfo?.tagName, shadow: highlighterData.elementInfo?.isShadowRoot, attribute diff --git a/src/components/dashboard/NavBar.tsx b/src/components/dashboard/NavBar.tsx index ec5fcf7c..591b7624 100644 --- a/src/components/dashboard/NavBar.tsx +++ b/src/components/dashboard/NavBar.tsx @@ -24,6 +24,7 @@ import { Clear, YouTube, X, + GitHub, Update, Close, Language, @@ -107,11 +108,39 @@ export const NavBar: React.FC = ({ }; const logout = async () => { - dispatch({ type: "LOGOUT" }); - window.localStorage.removeItem("user"); - const { data } = await axios.get(`${apiUrl}/auth/logout`); - notify("success", data.message); - navigate("/login"); + try { + const { data } = await axios.get(`${apiUrl}/auth/logout`); + if (data.ok) { + dispatch({ type: "LOGOUT" }); + window.localStorage.removeItem("user"); + notify('success', t('navbar.notifications.success.logout')); + navigate("/login"); + } + } catch (error: any) { + const status = error.response?.status; + let errorKey = 'unknown'; + + switch (status) { + case 401: + errorKey = 'unauthorized'; + break; + case 500: + errorKey = 'server'; + break; + default: + if (error.message?.includes('Network Error')) { + errorKey = 'network'; + } + } + + notify( + 'error', + t(`navbar.notifications.errors.logout.${errorKey}`, { + error: error.response?.data?.message || error.message + }) + ); + navigate("/login"); + } }; const goToMainMenu = async () => { @@ -204,7 +233,9 @@ export const NavBar: React.FC = ({
+ cursor: 'pointer' + }} + onClick={() => navigate('/')}>
{t('navbar.project_name')}
= ({
{!isRecording ? ( <> - + + {t('navbar.upgrade.button')} + = ({ )} - + {/* */} = ({ onClose={handleMenuClose} anchorOrigin={{ vertical: 'bottom', - horizontal: 'right', + horizontal: 'center', }} transformOrigin={{ vertical: 'top', - horizontal: 'right', + horizontal: 'center', }} PaperProps={{ sx: { width: '180px' } }} > { handleMenuClose(); logout(); }}> {t('navbar.menu_items.logout')} + + {t('navbar.menu_items.language')} + +
{ window.open('https://docs.maxun.dev', '_blank'); }}> Docs + { + window.open('https://github.com/getmaxun/maxun', '_blank'); + }}> + GitHub + { window.open('https://discord.gg/5GbPjBUkws', '_blank'); }}> @@ -366,20 +415,17 @@ export const NavBar: React.FC = ({ }}> Twitter (X) - - {t('navbar.menu_items.language')} - = ({ onClose={handleMenuClose} anchorOrigin={{ vertical: "bottom", - horizontal: "right", + horizontal: "center", }} transformOrigin={{ vertical: "top", - horizontal: "right", + horizontal: "center", }} > ([]); const [isModalOpen, setModalOpen] = React.useState(false); const [searchTerm, setSearchTerm] = React.useState(''); - const [isLoading, setIsLoading] = React.useState(true); const columns = useMemo(() => [ { id: 'interpret', label: t('recordingtable.run'), minWidth: 80 }, @@ -169,6 +168,8 @@ export const RecordingsTable = ({ setRecordingUrl, isLogin, setIsLogin, + rerenderRobots, + setRerenderRobots, recordingName, setRecordingName, recordingId, @@ -189,32 +190,45 @@ export const RecordingsTable = ({ setPage(0); }, []); + const parseDateString = (dateStr: string): Date => { + try { + if (dateStr.includes('PM') || dateStr.includes('AM')) { + return new Date(dateStr); + } + + return new Date(dateStr.replace(/(\d+)\/(\d+)\//, '$2/$1/')) + } catch { + return new Date(0); + } + }; + const fetchRecordings = useCallback(async () => { - setIsLoading(true); try { const recordings = await getStoredRecordings(); if (recordings) { const parsedRows = recordings - .map((recording: any, index: number) => { - if (recording?.recording_meta) { - return { - id: index, - ...recording.recording_meta, - content: recording.recording - }; - } - return null; - }) - .filter(Boolean); - + .map((recording: any, index: number) => { + if (recording?.recording_meta) { + const parsedDate = parseDateString(recording.recording_meta.createdAt); + + return { + id: index, + ...recording.recording_meta, + content: recording.recording, + parsedDate + }; + } + return null; + }) + .filter(Boolean) + .sort((a, b) => b.parsedDate.getTime() - a.parsedDate.getTime()); + setRecordings(parsedRows.map((recording) => recording.name)); setRows(parsedRows); } } catch (error) { console.error('Error fetching recordings:', error); notify('error', t('recordingtable.notifications.fetch_error')); - } finally { - setIsLoading(false); } }, [setRecordings, notify, t]); @@ -249,6 +263,14 @@ export const RecordingsTable = ({ } }, [fetchRecordings]); + useEffect(() => { + if (rerenderRobots) { + fetchRecordings().then(() => { + setRerenderRobots(false); + }); + } + }, [rerenderRobots, fetchRecordings, setRerenderRobots]); + function useDebounce(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = React.useState(value); @@ -343,39 +365,32 @@ export const RecordingsTable = ({ - {isLoading ? ( - - - - ) : ( - - - - - {columns.map((column) => ( - - {column.label} - - ))} - - - - {visibleRows.map((row) => ( - + +
+ + + {columns.map((column) => ( + + {column.label} + ))} - -
-
- )} + + + + {visibleRows.map((row) => ( + + ))} + + + { const { t } = useTranslation(); - const [robot, setRobot] = useState(null); const [targetUrl, setTargetUrl] = useState(''); - const { recordingId, notify } = useGlobalInfoStore(); + const [robot, setRobot] = useState(null); + const { recordingId, notify, setRerenderRobots } = useGlobalInfoStore(); useEffect(() => { if (isOpen) { @@ -96,13 +96,11 @@ export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initia const success = await duplicateRecording(robot.recording_meta.id, targetUrl); if (success) { + setRerenderRobots(true); + notify('success', t('robot_duplication.notifications.duplicate_success')); handleStart(robot); handleClose(); - - setTimeout(() => { - window.location.reload(); - }, 1000); } else { notify('error', t('robot_duplication.notifications.duplicate_error')); } diff --git a/src/components/robot/RobotEdit.tsx b/src/components/robot/RobotEdit.tsx index f1f79b77..9c1a0cf6 100644 --- a/src/components/robot/RobotEdit.tsx +++ b/src/components/robot/RobotEdit.tsx @@ -7,6 +7,7 @@ import { modalStyle } from "../recorder/AddWhereCondModal"; import { useGlobalInfoStore } from '../../context/globalInfo'; import { getStoredRecording, updateRecording } from '../../api/storage'; import { WhereWhatPair } from 'maxun-core'; +import { useNavigate } from 'react-router-dom'; interface RobotMeta { name: string; @@ -75,9 +76,9 @@ interface GroupedCredentials { export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => { const { t } = useTranslation(); - const [robot, setRobot] = useState(null); const [credentials, setCredentials] = useState({}); - const { recordingId, notify } = useGlobalInfoStore(); + const { recordingId, notify, setRerenderRobots } = useGlobalInfoStore(); + const [robot, setRobot] = useState(null); const [credentialGroups, setCredentialGroups] = useState({ passwords: [], emails: [], @@ -123,81 +124,113 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin } }, [robot]); - const extractInitialCredentials = (workflow: any[]): Credentials => { + function extractInitialCredentials(workflow: any[]): Credentials { const credentials: Credentials = {}; - - // Helper function to check if a character is printable + const isPrintableCharacter = (char: string): boolean => { return char.length === 1 && !!char.match(/^[\x20-\x7E]$/); }; - - // Process each step in the workflow + workflow.forEach(step => { if (!step.what) return; - - // Keep track of the current input field being processed + let currentSelector = ''; let currentValue = ''; let currentType = ''; - - // Process actions in sequence to maintain correct text state - step.what.forEach((action: any) => { - if ( - (action.action === 'type' || action.action === 'press') && - action.args?.length >= 2 && - typeof action.args[1] === 'string' - ) { - const selector: string = action.args[0]; - const character: string = action.args[1]; - const inputType: string = action.args[2] || ''; - - // Detect `input[type="password"]` - if (!currentType && inputType.toLowerCase() === 'password') { - currentType = 'password'; + 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' + }; } - - // If we're dealing with a new selector, store the previous one - if (currentSelector && selector !== currentSelector) { - if (!credentials[currentSelector]) { + 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 + type: currentType || 'text' }; - } else { - credentials[currentSelector].value = currentValue; } - } - - // Update current tracking variables - if (selector !== currentSelector) { currentSelector = selector; currentValue = credentials[selector]?.value || ''; - currentType = inputType || credentials[selector]?.type || ''; + currentType = action.args[2] || credentials[selector]?.type || 'text'; } - - // Handle different types of key actions - if (character === 'Backspace') { - // Remove the last character when backspace is pressed - currentValue = currentValue.slice(0, -1); - } else if (isPrintableCharacter(character)) { - // Add the character to the current value + + const character = action.args[1]; + + if (isPrintableCharacter(character)) { currentValue += character; + } else if (character === 'Backspace') { + currentValue = currentValue.slice(0, -1); } - // Note: We ignore other special keys like 'Shift', 'Enter', etc. + + 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++; } - }); - - // Store the final state of the last processed selector - if (currentSelector) { + } + + if (currentSelector && currentValue) { credentials[currentSelector] = { value: currentValue, - type: currentType + type: currentType || 'text' }; } }); - + return credentials; - }; + } const groupCredentialsByType = (credentials: Credentials): GroupedCredentials => { return Object.entries(credentials).reduce((acc: GroupedCredentials, [selector, info]) => { @@ -366,13 +399,11 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin const success = await updateRecording(robot.recording_meta.id, payload); if (success) { + setRerenderRobots(true); + notify('success', t('robot_edit.notifications.update_success')); handleStart(robot); handleClose(); - - setTimeout(() => { - window.location.reload(); - }, 1000); } else { notify('error', t('robot_edit.notifications.update_failed')); } diff --git a/src/components/robot/RobotSettings.tsx b/src/components/robot/RobotSettings.tsx index fdbf90e2..6ae59f89 100644 --- a/src/components/robot/RobotSettings.tsx +++ b/src/components/robot/RobotSettings.tsx @@ -54,8 +54,8 @@ interface RobotSettingsProps { export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => { const { t } = useTranslation(); - const [robot, setRobot] = useState(null); const [userEmail, setUserEmail] = useState(null); + const [robot, setRobot] = useState(null); const { recordingId, notify } = useGlobalInfoStore(); useEffect(() => { diff --git a/src/components/run/ColapsibleRow.tsx b/src/components/run/ColapsibleRow.tsx index 8cf27d6d..87e38684 100644 --- a/src/components/run/ColapsibleRow.tsx +++ b/src/components/run/ColapsibleRow.tsx @@ -35,8 +35,9 @@ interface CollapsibleRowProps { currentLog: string; abortRunHandler: () => void; runningRecordingName: string; + urlRunId: string | null; } -export const CollapsibleRow = ({ row, handleDelete, isOpen, currentLog, abortRunHandler, runningRecordingName }: CollapsibleRowProps) => { +export const CollapsibleRow = ({ row, handleDelete, isOpen, currentLog, abortRunHandler, runningRecordingName, urlRunId }: CollapsibleRowProps) => { const { t } = useTranslation(); const navigate = useNavigate(); const [open, setOpen] = useState(isOpen); @@ -62,14 +63,18 @@ export const CollapsibleRow = ({ row, handleDelete, isOpen, currentLog, abortRun abortRunHandler(); } + useEffect(() => { + setOpen(urlRunId === row.runId || isOpen); + }, [urlRunId, row.runId, isOpen]); + const handleRowExpand = () => { const newOpen = !open; setOpen(newOpen); - if (newOpen) { - navigate(`/runs/${row.robotMetaId}/run/${row.runId}`); - } else { - navigate(`/runs/${row.robotMetaId}`); - } + navigate( + newOpen + ? `/runs/${row.robotMetaId}/run/${row.runId}` + : `/runs/${row.robotMetaId}` + ); //scrollToLogBottom(); }; diff --git a/src/components/run/RunsTable.tsx b/src/components/run/RunsTable.tsx index 92233214..53d29433 100644 --- a/src/components/run/RunsTable.tsx +++ b/src/components/run/RunsTable.tsx @@ -12,7 +12,7 @@ import TableRow from '@mui/material/TableRow'; import { Accordion, AccordionSummary, AccordionDetails, Typography, Box, TextField, CircularProgress, Tooltip } from '@mui/material'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import SearchIcon from '@mui/icons-material/Search'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import { useGlobalInfoStore } from "../../context/globalInfo"; import { getStoredRuns } from "../../api/storage"; import { RunSettings } from "./RunSettings"; @@ -70,6 +70,13 @@ interface RunsTableProps { runningRecordingName: string; } +interface PaginationState { + [robotMetaId: string]: { + page: number; + rowsPerPage: number; + }; +} + export const RunsTable: React.FC = ({ currentInterpretationLog, abortRunHandler, @@ -78,7 +85,24 @@ export const RunsTable: React.FC = ({ }) => { const { t } = useTranslation(); const navigate = useNavigate(); + const location = useLocation(); + const getUrlParams = () => { + const match = location.pathname.match(/\/runs\/([^\/]+)(?:\/run\/([^\/]+))?/); + return { + robotMetaId: match?.[1] || null, + urlRunId: match?.[2] || null + }; + }; + + const { robotMetaId: urlRobotMetaId, urlRunId } = getUrlParams(); + + const isAccordionExpanded = useCallback((currentRobotMetaId: string) => { + return currentRobotMetaId === urlRobotMetaId; + }, [urlRobotMetaId]); + + const [accordionPage, setAccordionPage] = useState(0); + const [accordionsPerPage, setAccordionsPerPage] = useState(10); const [accordionSortConfigs, setAccordionSortConfigs] = useState({}); const handleSort = useCallback((columnId: keyof Data, robotMetaId: string) => { @@ -107,11 +131,10 @@ export const RunsTable: React.FC = ({ [t] ); - const [page, setPage] = useState(0); - const [rowsPerPage, setRowsPerPage] = useState(10); const [rows, setRows] = useState([]); const [searchTerm, setSearchTerm] = useState(''); - const [isLoading, setIsLoading] = useState(true); + + const [paginationStates, setPaginationStates] = useState({}); const { notify, rerenderRuns, setRerenderRuns } = useGlobalInfoStore(); @@ -119,15 +142,50 @@ export const RunsTable: React.FC = ({ navigate(isExpanded ? `/runs/${robotMetaId}` : '/runs'); }, [navigate]); - const handleChangePage = useCallback((event: unknown, newPage: number) => { - setPage(newPage); + const handleAccordionPageChange = useCallback((event: unknown, newPage: number) => { + setAccordionPage(newPage); + }, []); + + const handleAccordionsPerPageChange = useCallback((event: React.ChangeEvent) => { + setAccordionsPerPage(+event.target.value); + setAccordionPage(0); }, []); - const handleChangeRowsPerPage = useCallback((event: React.ChangeEvent) => { - setRowsPerPage(+event.target.value); - setPage(0); + const handleChangePage = useCallback((robotMetaId: string, newPage: number) => { + setPaginationStates(prev => ({ + ...prev, + [robotMetaId]: { + ...prev[robotMetaId], + page: newPage + } + })); }, []); + const handleChangeRowsPerPage = useCallback((robotMetaId: string, newRowsPerPage: number) => { + setPaginationStates(prev => ({ + ...prev, + [robotMetaId]: { + page: 0, // Reset to first page when changing rows per page + rowsPerPage: newRowsPerPage + } + })); + }, []); + + const getPaginationState = useCallback((robotMetaId: string) => { + const defaultState = { page: 0, rowsPerPage: 10 }; + + if (!paginationStates[robotMetaId]) { + setTimeout(() => { + setPaginationStates(prev => ({ + ...prev, + [robotMetaId]: defaultState + })); + }, 0); + return defaultState; + } + return paginationStates[robotMetaId]; + }, [paginationStates]); + const debouncedSearch = useCallback((fn: Function, delay: number) => { let timeoutId: NodeJS.Timeout; return (...args: any[]) => { @@ -139,14 +197,20 @@ export const RunsTable: React.FC = ({ const handleSearchChange = useCallback((event: React.ChangeEvent) => { const debouncedSetSearch = debouncedSearch((value: string) => { setSearchTerm(value); - setPage(0); + setAccordionPage(0); + setPaginationStates(prev => { + const reset = Object.keys(prev).reduce((acc, robotId) => ({ + ...acc, + [robotId]: { ...prev[robotId], page: 0 } + }), {}); + return reset; + }); }, 300); debouncedSetSearch(event.target.value); }, [debouncedSearch]); const fetchRuns = useCallback(async () => { try { - setIsLoading(true); const runs = await getStoredRuns(); if (runs) { const parsedRows: Data[] = runs.map((run: any, index: number) => ({ @@ -159,8 +223,6 @@ export const RunsTable: React.FC = ({ } } catch (error) { notify('error', t('runstable.notifications.fetch_error')); - } finally { - setIsLoading(false); } }, [notify, t]); @@ -194,18 +256,6 @@ export const RunsTable: React.FC = ({ return result; }, [rows, searchTerm]); - // Group filtered rows by robot meta id - const groupedRows = useMemo(() => - filteredRows.reduce((acc, row) => { - if (!acc[row.robotMetaId]) { - acc[row.robotMetaId] = []; - } - acc[row.robotMetaId].push(row); - return acc; - }, {} as Record), - [filteredRows] - ); - const parseDateString = (dateStr: string): Date => { try { if (dateStr.includes('PM') || dateStr.includes('AM')) { @@ -218,7 +268,37 @@ export const RunsTable: React.FC = ({ } }; + const groupedRows = useMemo(() => { + const groupedData = filteredRows.reduce((acc, row) => { + if (!acc[row.robotMetaId]) { + acc[row.robotMetaId] = []; + } + acc[row.robotMetaId].push(row); + return acc; + }, {} as Record); + + Object.keys(groupedData).forEach(robotId => { + groupedData[robotId].sort((a, b) => + parseDateString(b.startedAt).getTime() - parseDateString(a.startedAt).getTime() + ); + }); + + const robotEntries = Object.entries(groupedData).map(([robotId, runs]) => ({ + robotId, + runs, + latestRunDate: parseDateString(runs[0].startedAt).getTime() + })); + + robotEntries.sort((a, b) => b.latestRunDate - a.latestRunDate); + + return robotEntries.reduce((acc, { robotId, runs }) => { + acc[robotId] = runs; + return acc; + }, {} as Record); + }, [filteredRows]); + const renderTableRows = useCallback((data: Data[], robotMetaId: string) => { + const { page, rowsPerPage } = getPaginationState(robotMetaId); const start = page * rowsPerPage; const end = start + rowsPerPage; @@ -245,13 +325,14 @@ export const RunsTable: React.FC = ({ key={`row-${row.id}`} row={row} handleDelete={handleDelete} - isOpen={runId === row.runId && runningRecordingName === row.name} + isOpen={urlRunId === row.runId || (runId === row.runId && runningRecordingName === row.name)} currentLog={currentInterpretationLog} abortRunHandler={abortRunHandler} runningRecordingName={runningRecordingName} + urlRunId={urlRunId} /> )); - }, [page, rowsPerPage, runId, runningRecordingName, currentInterpretationLog, abortRunHandler, handleDelete, accordionSortConfigs]); + }, [paginationStates, runId, runningRecordingName, currentInterpretationLog, abortRunHandler, handleDelete, accordionSortConfigs]); const renderSortIcon = useCallback((column: Column, robotMetaId: string) => { const sortConfig = accordionSortConfigs[robotMetaId]; @@ -279,14 +360,6 @@ export const RunsTable: React.FC = ({ : ; }, [accordionSortConfigs]); - if (isLoading) { - return ( - - - - ); - } - return ( @@ -305,82 +378,99 @@ export const RunsTable: React.FC = ({ - {Object.entries(groupedRows).map(([robotMetaId, data]) => ( - handleAccordionChange(robotMetaId, isExpanded)} - TransitionProps={{ unmountOnExit: true }} // Optimize accordion rendering - > - }> - {data[data.length - 1].name} - - - - - - - {translatedColumns.map((column) => ( - { - if (column.id === 'startedAt' || column.id === 'finishedAt') { - handleSort(column.id, robotMetaId); - } - }} - > - - ( + handleAccordionChange(robotMetaId, isExpanded)} + TransitionProps={{ unmountOnExit: true }} // Optimize accordion rendering + > + }> + {data[data.length - 1].name} + + +
+ + + + {translatedColumns.map((column) => ( + { + if (column.id === 'startedAt' || column.id === 'finishedAt') { + handleSort(column.id, robotMetaId); } - }}> - {column.label} - + + - {renderSortIcon(column, robotMetaId)} + {column.label} + + {renderSortIcon(column, robotMetaId)} + - - - - ))} - - - - {renderTableRows(data, robotMetaId)} - -
-
-
- ))} + + + ))} + + + + {renderTableRows(data, robotMetaId)} + + + + handleChangePage(robotMetaId, newPage)} + onRowsPerPageChange={(event) => + handleChangeRowsPerPage(robotMetaId, +event.target.value) + } + rowsPerPageOptions={[10, 25, 50, 100]} + /> + + + ))}
diff --git a/src/context/globalInfo.tsx b/src/context/globalInfo.tsx index 28d65b34..eaa6ded7 100644 --- a/src/context/globalInfo.tsx +++ b/src/context/globalInfo.tsx @@ -1,6 +1,44 @@ import React, { createContext, useContext, useState } from "react"; import { AlertSnackbarProps } from "../components/ui/AlertSnackbar"; +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 GlobalInfo { browserId: string | null; @@ -16,6 +54,8 @@ interface GlobalInfo { setRecordings: (recordings: string[]) => void; rerenderRuns: boolean; setRerenderRuns: (rerenderRuns: boolean) => void; + rerenderRobots: boolean; + setRerenderRobots: (rerenderRuns: boolean) => void; recordingLength: number; setRecordingLength: (recordingLength: number) => void; recordingId: string | null; @@ -52,6 +92,7 @@ class GlobalInfoStore implements Partial { recordingId = null; recordings: string[] = []; rerenderRuns = false; + rerenderRobots = false; recordingName = ''; initialUrl = 'https://'; recordingUrl = 'https://'; @@ -75,6 +116,7 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => { const [notification, setNotification] = useState(globalInfoStore.notification); const [recordings, setRecordings] = useState(globalInfoStore.recordings); const [rerenderRuns, setRerenderRuns] = useState(globalInfoStore.rerenderRuns); + const [rerenderRobots, setRerenderRobots] = useState(globalInfoStore.rerenderRobots); const [recordingLength, setRecordingLength] = useState(globalInfoStore.recordingLength); const [recordingId, setRecordingId] = useState(globalInfoStore.recordingId); const [recordingName, setRecordingName] = useState(globalInfoStore.recordingName); @@ -121,6 +163,8 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => { setRecordings, rerenderRuns, setRerenderRuns, + rerenderRobots, + setRerenderRobots, recordingLength, setRecordingLength, recordingId, diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 1b34a1db..b76f1fd8 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -53,8 +53,14 @@ const Login = () => { notify("success", t('login.welcome_notification')); window.localStorage.setItem("user", JSON.stringify(data)); navigate("/"); - } catch (err) { - notify("error", t('login.error_notification')); + } catch (err: any) { + const errorResponse = err.response?.data; + + const errorMessage = errorResponse?.code + ? t(errorResponse.code) + : t('login.error.generic'); + + notify("error", errorMessage); setLoading(false); } }; diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx index b9a4f24f..0801a933 100644 --- a/src/pages/MainPage.tsx +++ b/src/pages/MainPage.tsx @@ -15,6 +15,7 @@ import { ScheduleSettings } from "../components/robot/ScheduleSettings"; import { IntegrationSettings } from "../components/integration/IntegrationSettings"; import { RobotSettings } from "../components/robot/RobotSettings"; import { apiUrl } from "../apiConfig"; +import { useNavigate } from 'react-router-dom'; interface MainPageProps { handleEditRecording: (id: string, fileName: string) => void; @@ -24,6 +25,7 @@ interface MainPageProps { export interface CreateRunResponse { browserId: string; runId: string; + robotMetaId: string; } export interface ScheduleRunResponse { @@ -40,12 +42,14 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps) const [currentInterpretationLog, setCurrentInterpretationLog] = React.useState(''); const [ids, setIds] = React.useState({ browserId: '', - runId: '' + runId: '', + robotMetaId: '' }); let aborted = false; const { notify, setRerenderRuns, setRecordingId } = useGlobalInfoStore(); + const navigate = useNavigate(); const abortRunHandler = (runId: string) => { aborted = true; @@ -88,8 +92,9 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps) }, [currentInterpretationLog]) const handleRunRecording = useCallback((settings: RunSettings) => { - createRunForStoredRecording(runningRecordingId, settings).then(({ browserId, runId }: CreateRunResponse) => { - setIds({ browserId, runId }); + createRunForStoredRecording(runningRecordingId, settings).then(({ browserId, runId, robotMetaId }: CreateRunResponse) => { + setIds({ browserId, runId, robotMetaId }); + navigate(`/runs/${robotMetaId}/run/${runId}`); const socket = io(`${apiUrl}/${browserId}`, { transports: ["websocket"], diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index d9c698a2..909382d0 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -48,7 +48,13 @@ const Register = () => { window.localStorage.setItem("user", JSON.stringify(data)); navigate("/"); } catch (error:any) { - notify("error", error.response.data || t('register.error_notification')); + const errorResponse = error.response?.data; + + const errorMessage = errorResponse?.code + ? t(errorResponse.code) + : t('register.error.generic'); + + notify("error", errorMessage); setLoading(false); } };