Merge branch 'develop' into integration_airtable
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "maxun-core",
|
"name": "maxun-core",
|
||||||
"version": "0.0.10",
|
"version": "0.0.12",
|
||||||
"description": "Core package for Maxun, responsible for data extraction",
|
"description": "Core package for Maxun, responsible for data extraction",
|
||||||
"main": "build/index.js",
|
"main": "build/index.js",
|
||||||
"typings": "build/index.d.ts",
|
"typings": "build/index.d.ts",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable no-await-in-loop, no-restricted-syntax */
|
/* 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 { PlaywrightBlocker } from '@cliqz/adblocker-playwright';
|
||||||
import fetch from 'cross-fetch';
|
import fetch from 'cross-fetch';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -286,6 +286,12 @@ export default class Interpreter extends EventEmitter {
|
|||||||
? arrayToObject(<any>superset[key])
|
? arrayToObject(<any>superset[key])
|
||||||
: 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
|
// Every `subset` key must exist in the `superset` and
|
||||||
// have the same value (strict equality), or subset[key] <= superset[key]
|
// have the same value (strict equality), or subset[key] <= superset[key]
|
||||||
return parsedSuperset[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<string, any>[] = [];
|
let allResults: Record<string, any>[] = [];
|
||||||
let previousHeight = 0;
|
let previousHeight = 0;
|
||||||
// track unique items per page to avoid re-scraping
|
|
||||||
let scrapedItems: Set<string> = new Set<string>();
|
let scrapedItems: Set<string> = new Set<string>();
|
||||||
let visitedUrls: string[] = [];
|
let visitedUrls: Set<string> = new Set<string>();
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
const RETRY_DELAY = 1000; // 1 second delay between retries
|
||||||
|
|
||||||
// Debug logging helper
|
|
||||||
const debugLog = (message: string, ...args: any[]) => {
|
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<boolean>, retryCount = 0): Promise<boolean> => {
|
||||||
|
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(',');
|
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) {
|
switch (config.pagination.type) {
|
||||||
case 'scrollDown':
|
case 'scrollDown': {
|
||||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
const currentHeight = await page.evaluate(() => document.body.scrollHeight);
|
const currentHeight = await page.evaluate(() => document.body.scrollHeight);
|
||||||
if (currentHeight === previousHeight) {
|
if (currentHeight === previousHeight) {
|
||||||
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
||||||
allResults = allResults.concat(finalResults);
|
allResults = allResults.concat(finalResults);
|
||||||
return allResults;
|
return allResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
previousHeight = currentHeight;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
previousHeight = currentTopHeight;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'clickNext': {
|
||||||
|
const currentUrl = page.url();
|
||||||
|
visitedUrls.add(currentUrl);
|
||||||
|
|
||||||
|
await scrapeCurrentPage();
|
||||||
|
if (checkLimit()) return allResults;
|
||||||
|
|
||||||
|
const { button, workingSelector } = await findWorkingButton(availableSelectors);
|
||||||
|
if (!button || !workingSelector) {
|
||||||
|
// Final retry for navigation when no selectors work
|
||||||
|
const success = await retryOperation(async () => {
|
||||||
|
try {
|
||||||
|
await page.evaluate(() => window.history.forward());
|
||||||
|
const newUrl = page.url();
|
||||||
|
return !visitedUrls.has(newUrl);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
previousHeight = currentHeight;
|
|
||||||
break;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
if (!success) return allResults;
|
||||||
debugLog("Results collected so far:", allResults.length);
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if (config.limit && allResults.length >= config.limit) {
|
availableSelectors = availableSelectors.slice(
|
||||||
return allResults.slice(0, config.limit);
|
availableSelectors.indexOf(workingSelector)
|
||||||
}
|
);
|
||||||
|
|
||||||
await page.waitForLoadState('networkidle', { timeout: 30000 });
|
let retryCount = 0;
|
||||||
await page.waitForTimeout(2000);
|
let navigationSuccess = false;
|
||||||
|
|
||||||
let checkButton = null;
|
|
||||||
let workingSelector = null;
|
|
||||||
|
|
||||||
// Try each selector with explicit waiting
|
|
||||||
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(`Selector failed: ${selector} - ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!workingSelector) {
|
|
||||||
debugLog('No working selector found after trying all options');
|
|
||||||
return allResults;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextButton = await page.$(workingSelector);
|
|
||||||
if (!nextButton) {
|
|
||||||
debugLog('Next button not found');
|
|
||||||
return allResults;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectorIndex = availableSelectors.indexOf(workingSelector);
|
|
||||||
availableSelectors = availableSelectors.slice(selectorIndex);
|
|
||||||
|
|
||||||
|
while (retryCount < MAX_RETRIES && !navigationSuccess) {
|
||||||
try {
|
try {
|
||||||
// Store current URL to check if navigation succeeded
|
|
||||||
const previousUrl = page.url();
|
|
||||||
visitedUrls.push(previousUrl);
|
|
||||||
|
|
||||||
// Try both click methods in sequence
|
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.waitForNavigation({
|
page.waitForNavigation({
|
||||||
waitUntil: 'networkidle',
|
waitUntil: 'networkidle',
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
}),
|
}),
|
||||||
nextButton.click()
|
button.click()
|
||||||
]);
|
]);
|
||||||
|
navigationSuccess = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If we're still on the same URL, try dispatch event
|
debugLog(`Regular click failed on attempt ${retryCount + 1}. Trying DispatchEvent`);
|
||||||
if (page.url() === previousUrl) {
|
|
||||||
await Promise.all([
|
// If regular click fails, try dispatchEvent
|
||||||
page.waitForNavigation({
|
if (page.url() === currentUrl) {
|
||||||
waitUntil: 'networkidle',
|
try {
|
||||||
timeout: 15000
|
await Promise.all([
|
||||||
}),
|
page.waitForNavigation({
|
||||||
nextButton.dispatchEvent('click')
|
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');
|
const newUrl = page.url();
|
||||||
await page.waitForLoadState('networkidle', { timeout: 30000 });
|
if (visitedUrls.has(newUrl)) {
|
||||||
|
debugLog(`Detected navigation to previously visited URL ${newUrl} on attempt ${retryCount + 1}`);
|
||||||
const currentUrl = page.url();
|
navigationSuccess = false;
|
||||||
if (visitedUrls.includes(currentUrl)) {
|
|
||||||
debugLog(`Navigation failed/Detected navigation to previously visited URL: ${currentUrl}`);
|
|
||||||
return allResults;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Give the page a moment to stabilize after navigation
|
if (navigationSuccess) {
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
} catch (error) {
|
} 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;
|
return allResults;
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
|
|
||||||
case 'clickLoadMore':
|
// Update available selectors to start from the working one
|
||||||
while (true) {
|
availableSelectors = availableSelectors.slice(
|
||||||
let checkButton = null;
|
availableSelectors.indexOf(workingSelector)
|
||||||
let workingSelector = null;
|
);
|
||||||
|
|
||||||
for (const selector of availableSelectors) {
|
// Implement retry mechanism for clicking the button
|
||||||
try {
|
let retryCount = 0;
|
||||||
checkButton = await page.waitForSelector(selector, {
|
let clickSuccess = false;
|
||||||
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);
|
|
||||||
|
|
||||||
|
while (retryCount < MAX_RETRIES && !clickSuccess) {
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
await loadMoreButton.click();
|
await loadMoreButton.click();
|
||||||
|
clickSuccess = true;
|
||||||
} catch (error) {
|
} 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) {
|
} catch (error) {
|
||||||
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
debugLog(`Click attempt ${retryCount + 1} failed completely.`);
|
||||||
allResults = allResults.concat(finalResults);
|
retryCount++;
|
||||||
return allResults;
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.waitForTimeout(2000);
|
if (retryCount < MAX_RETRIES) {
|
||||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
debugLog(`Retrying click - attempt ${retryCount + 1} of ${MAX_RETRIES}`);
|
||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(RETRY_DELAY);
|
||||||
|
}
|
||||||
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:
|
if (!clickSuccess) {
|
||||||
const results = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
debugLog(`Load More clicking failed after ${MAX_RETRIES} attempts`);
|
||||||
allResults = allResults.concat(results);
|
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
||||||
return allResults;
|
allResults = allResults.concat(finalResults);
|
||||||
}
|
return allResults;
|
||||||
|
}
|
||||||
|
|
||||||
if (config.limit && allResults.length >= config.limit) {
|
// Wait for content to load and check scroll height
|
||||||
allResults = allResults.slice(0, config.limit);
|
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;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
await scrapeCurrentPage();
|
||||||
|
return allResults;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (checkLimit()) break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debugLog(`Fatal error: ${error.message}`);
|
||||||
|
return allResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
return allResults;
|
return allResults;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "maxun",
|
"name": "maxun",
|
||||||
"version": "0.0.8",
|
"version": "0.0.10",
|
||||||
"author": "Maxun",
|
"author": "Maxun",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"loglevel": "^1.8.0",
|
"loglevel": "^1.8.0",
|
||||||
"loglevel-plugin-remote": "^0.6.8",
|
"loglevel-plugin-remote": "^0.6.8",
|
||||||
"maxun-core": "^0.0.10",
|
"maxun-core": "^0.0.12",
|
||||||
"minio": "^8.0.1",
|
"minio": "^8.0.1",
|
||||||
"moment-timezone": "^0.5.45",
|
"moment-timezone": "^0.5.45",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
|
|||||||
@@ -8,7 +8,16 @@
|
|||||||
"register_prompt": "Noch keinen Account?",
|
"register_prompt": "Noch keinen Account?",
|
||||||
"register_link": "Registrieren",
|
"register_link": "Registrieren",
|
||||||
"welcome_notification": "Willkommen bei Maxun!",
|
"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": {
|
"register": {
|
||||||
"title": "Konto registrieren",
|
"title": "Konto registrieren",
|
||||||
@@ -19,7 +28,16 @@
|
|||||||
"register_prompt": "Bereits ein Konto?",
|
"register_prompt": "Bereits ein Konto?",
|
||||||
"login_link": "Einloggen",
|
"login_link": "Einloggen",
|
||||||
"welcome_notification": "Willkommen bei Maxun!",
|
"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": {
|
"recordingtable": {
|
||||||
"run": "Ausführen",
|
"run": "Ausführen",
|
||||||
@@ -32,7 +50,7 @@
|
|||||||
"new": "Roboter erstellen",
|
"new": "Roboter erstellen",
|
||||||
"modal": {
|
"modal": {
|
||||||
"title": "Geben Sie die URL ein",
|
"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",
|
"label": "URL",
|
||||||
"button": "Aufnahme starten"
|
"button": "Aufnahme starten"
|
||||||
},
|
},
|
||||||
@@ -469,6 +487,19 @@
|
|||||||
},
|
},
|
||||||
"navbar": {
|
"navbar": {
|
||||||
"project_name": "Maxun",
|
"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": {
|
"upgrade": {
|
||||||
"button": "Upgrade",
|
"button": "Upgrade",
|
||||||
"modal": {
|
"modal": {
|
||||||
|
|||||||
@@ -8,10 +8,19 @@
|
|||||||
"register_prompt": "Don't have an account?",
|
"register_prompt": "Don't have an account?",
|
||||||
"register_link": "Register",
|
"register_link": "Register",
|
||||||
"welcome_notification": "Welcome to Maxun!",
|
"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": {
|
"register": {
|
||||||
"title": "Register Account",
|
"title": "Create An Account",
|
||||||
"email": "Enter Work Email",
|
"email": "Enter Work Email",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"button": "Register",
|
"button": "Register",
|
||||||
@@ -19,7 +28,16 @@
|
|||||||
"register_prompt": "Already have an account?",
|
"register_prompt": "Already have an account?",
|
||||||
"login_link": "Login",
|
"login_link": "Login",
|
||||||
"welcome_notification": "Welcome to Maxun!",
|
"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":{
|
"recordingtable":{
|
||||||
"run": "Run",
|
"run": "Run",
|
||||||
@@ -32,7 +50,7 @@
|
|||||||
"new":"Create Robot",
|
"new":"Create Robot",
|
||||||
"modal":{
|
"modal":{
|
||||||
"title":"Enter the URL",
|
"title":"Enter the URL",
|
||||||
"login_title": "Does this website require login?",
|
"login_title": "Does this website require logging in?",
|
||||||
"label":"URL",
|
"label":"URL",
|
||||||
"button":"Start Recording"
|
"button":"Start Recording"
|
||||||
},
|
},
|
||||||
@@ -480,6 +498,19 @@
|
|||||||
},
|
},
|
||||||
"navbar": {
|
"navbar": {
|
||||||
"project_name": "Maxun",
|
"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": {
|
"upgrade": {
|
||||||
"button": "Upgrade",
|
"button": "Upgrade",
|
||||||
"modal": {
|
"modal": {
|
||||||
|
|||||||
@@ -8,7 +8,16 @@
|
|||||||
"register_prompt": "¿No tienes una cuenta?",
|
"register_prompt": "¿No tienes una cuenta?",
|
||||||
"register_link": "Registrarse",
|
"register_link": "Registrarse",
|
||||||
"welcome_notification": "¡Bienvenido a Maxun!",
|
"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": {
|
"register": {
|
||||||
"title": "Crear cuenta",
|
"title": "Crear cuenta",
|
||||||
@@ -19,7 +28,16 @@
|
|||||||
"register_prompt": "¿Ya tienes una cuenta?",
|
"register_prompt": "¿Ya tienes una cuenta?",
|
||||||
"login_link": "Iniciar sesión",
|
"login_link": "Iniciar sesión",
|
||||||
"welcome_notification": "¡Bienvenido a Maxun!",
|
"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": {
|
"recordingtable": {
|
||||||
"run": "Ejecutar",
|
"run": "Ejecutar",
|
||||||
@@ -32,7 +50,7 @@
|
|||||||
"new": "Crear Robot",
|
"new": "Crear Robot",
|
||||||
"modal": {
|
"modal": {
|
||||||
"title": "Ingresa la URL",
|
"title": "Ingresa la URL",
|
||||||
"login_title": "¿Este sitio requiere inicio de sesión?",
|
"login_title": "¿Este sitio web requiere iniciar sesión?",
|
||||||
"label": "URL",
|
"label": "URL",
|
||||||
"button": "Comenzar grabación"
|
"button": "Comenzar grabación"
|
||||||
},
|
},
|
||||||
@@ -470,6 +488,19 @@
|
|||||||
},
|
},
|
||||||
"navbar": {
|
"navbar": {
|
||||||
"project_name": "Maxun",
|
"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": {
|
"upgrade": {
|
||||||
"button": "Actualizar",
|
"button": "Actualizar",
|
||||||
"modal": {
|
"modal": {
|
||||||
|
|||||||
@@ -8,7 +8,16 @@
|
|||||||
"register_prompt": "アカウントをお持ちでないですか?",
|
"register_prompt": "アカウントをお持ちでないですか?",
|
||||||
"register_link": "登録する",
|
"register_link": "登録する",
|
||||||
"welcome_notification": "Maxunへようこそ!",
|
"welcome_notification": "Maxunへようこそ!",
|
||||||
"error_notification": "ログインに失敗しました。もう一度お試しください。"
|
"validation": {
|
||||||
|
"required_fields": "メールアドレスとパスワードは必須です",
|
||||||
|
"password_length": "パスワードは6文字以上である必要があります"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"user_not_found": "ユーザーが存在しません",
|
||||||
|
"invalid_credentials": "メールアドレスまたはパスワードが無効です",
|
||||||
|
"server_error": "ログインに失敗しました。後でもう一度お試しください",
|
||||||
|
"generic": "エラーが発生しました。もう一度お試しください"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"register": {
|
"register": {
|
||||||
"title": "アカウントを登録する",
|
"title": "アカウントを登録する",
|
||||||
@@ -19,7 +28,16 @@
|
|||||||
"register_prompt": "既にアカウントをお持ちですか?",
|
"register_prompt": "既にアカウントをお持ちですか?",
|
||||||
"login_link": "ログイン",
|
"login_link": "ログイン",
|
||||||
"welcome_notification": "Maxunへようこそ!",
|
"welcome_notification": "Maxunへようこそ!",
|
||||||
"error_notification": "登録に失敗しました。もう一度お試しください。"
|
"validation": {
|
||||||
|
"email_required": "メールアドレスは必須です",
|
||||||
|
"password_requirements": "パスワードは6文字以上である必要があります"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"user_exists": "このメールアドレスは既に登録されています",
|
||||||
|
"creation_failed": "アカウントを作成できませんでした",
|
||||||
|
"server_error": "サーバーエラーが発生しました",
|
||||||
|
"generic": "登録に失敗しました。もう一度お試しください"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"recordingtable": {
|
"recordingtable": {
|
||||||
"run": "実行",
|
"run": "実行",
|
||||||
@@ -123,11 +141,11 @@
|
|||||||
"generate_button": "APIキーを生成",
|
"generate_button": "APIキーを生成",
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"fetch_error": "APIキーの取得に失敗しました - ${error}",
|
"fetch_error": "APIキーの取得に失敗しました - ${error}",
|
||||||
"generate_success": "APIキーが正常に生成されました",
|
"generate_success": "APIキーの生成に成功しました",
|
||||||
"generate_error": "APIキーの生成に失敗しました - ${error}",
|
"generate_error": "APIキーの生成に失敗しました - ${error}",
|
||||||
"delete_success": "APIキーが正常に削除されました",
|
"delete_success": "APIキーの削除に成功しました",
|
||||||
"delete_error": "APIキーの削除に失敗しました - ${error}",
|
"delete_error": "APIキーの削除に失敗しました - ${error}",
|
||||||
"copy_success": "APIキーがコピーされました"
|
"copy_success": "APIキーのコピーに成功しました"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"action_description": {
|
"action_description": {
|
||||||
@@ -470,6 +488,19 @@
|
|||||||
},
|
},
|
||||||
"navbar": {
|
"navbar": {
|
||||||
"project_name": "Maxun",
|
"project_name": "Maxun",
|
||||||
|
"notifications": {
|
||||||
|
"success": {
|
||||||
|
"logout": "ログアウトに成功しました"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"logout": {
|
||||||
|
"unauthorized": "この操作を実行する権限がありません",
|
||||||
|
"server": "ログアウト中にサーバーエラーが発生しました",
|
||||||
|
"network": "ログアウト中にネットワークエラーが発生しました",
|
||||||
|
"unknown": "ログアウト中に予期せぬエラーが発生しました"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"upgrade": {
|
"upgrade": {
|
||||||
"button": "アップグレード",
|
"button": "アップグレード",
|
||||||
"modal": {
|
"modal": {
|
||||||
|
|||||||
@@ -8,7 +8,16 @@
|
|||||||
"register_prompt": "还没有账号?",
|
"register_prompt": "还没有账号?",
|
||||||
"register_link": "注册",
|
"register_link": "注册",
|
||||||
"welcome_notification": "欢迎使用 Maxun!",
|
"welcome_notification": "欢迎使用 Maxun!",
|
||||||
"error_notification": "登录失败。请重试。"
|
"validation": {
|
||||||
|
"required_fields": "邮箱和密码为必填项",
|
||||||
|
"password_length": "密码必须至少6个字符"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"user_not_found": "用户不存在",
|
||||||
|
"invalid_credentials": "邮箱或密码无效",
|
||||||
|
"server_error": "登录失败,请稍后重试",
|
||||||
|
"generic": "发生错误,请重试"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"register": {
|
"register": {
|
||||||
"title": "注册账号",
|
"title": "注册账号",
|
||||||
@@ -19,7 +28,16 @@
|
|||||||
"register_prompt": "已有账号?",
|
"register_prompt": "已有账号?",
|
||||||
"login_link": "登录",
|
"login_link": "登录",
|
||||||
"welcome_notification": "欢迎使用 Maxun!",
|
"welcome_notification": "欢迎使用 Maxun!",
|
||||||
"error_notification": "注册失败。请重试。"
|
"validation": {
|
||||||
|
"email_required": "邮箱是必填项",
|
||||||
|
"password_requirements": "密码必须至少6个字符"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"user_exists": "该邮箱已被注册",
|
||||||
|
"creation_failed": "无法创建账户",
|
||||||
|
"server_error": "服务器错误",
|
||||||
|
"generic": "注册失败,请重试"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"recordingtable": {
|
"recordingtable": {
|
||||||
"run": "运行",
|
"run": "运行",
|
||||||
@@ -123,11 +141,11 @@
|
|||||||
"generate_button": "生成API密钥",
|
"generate_button": "生成API密钥",
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"fetch_error": "获取API密钥失败 - ${error}",
|
"fetch_error": "获取API密钥失败 - ${error}",
|
||||||
"generate_success": "API密钥生成成功",
|
"generate_success": "成功生成API密钥",
|
||||||
"generate_error": "生成API密钥失败 - ${error}",
|
"generate_error": "生成API密钥失败 - ${error}",
|
||||||
"delete_success": "API密钥删除成功",
|
"delete_success": "成功删除API密钥",
|
||||||
"delete_error": "删除API密钥失败 - ${error}",
|
"delete_error": "删除API密钥失败 - ${error}",
|
||||||
"copy_success": "API密钥复制成功"
|
"copy_success": "成功复制API密钥"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"action_description": {
|
"action_description": {
|
||||||
@@ -470,6 +488,19 @@
|
|||||||
},
|
},
|
||||||
"navbar": {
|
"navbar": {
|
||||||
"project_name": "Maxun",
|
"project_name": "Maxun",
|
||||||
|
"notifications": {
|
||||||
|
"success": {
|
||||||
|
"logout": "退出登录成功"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"logout": {
|
||||||
|
"unauthorized": "您没有执行此操作的权限",
|
||||||
|
"server": "退出登录时发生服务器错误",
|
||||||
|
"network": "退出登录时发生网络错误",
|
||||||
|
"unknown": "退出登录时发生未知错误"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"upgrade": {
|
"upgrade": {
|
||||||
"button": "升级",
|
"button": "升级",
|
||||||
"modal": {
|
"modal": {
|
||||||
|
|||||||
@@ -28,29 +28,47 @@ router.post("/register", async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { email, password } = req.body;
|
const { email, password } = req.body;
|
||||||
|
|
||||||
if (!email) return res.status(400).send("Email is required");
|
if (!email) {
|
||||||
if (!password || password.length < 6)
|
return res.status(400).json({
|
||||||
return res
|
error: "VALIDATION_ERROR",
|
||||||
.status(400)
|
code: "register.validation.email_required"
|
||||||
.send("Password is required and must be at least 6 characters");
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 } });
|
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);
|
const hashedPassword = await hashPassword(password);
|
||||||
|
|
||||||
let user: any;
|
let user: any;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
user = await User.create({ email, password: hashedPassword });
|
user = await User.create({ email, password: hashedPassword });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log(`Could not create user - ${error}`);
|
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) {
|
if (!process.env.JWT_SECRET) {
|
||||||
console.log("JWT_SECRET is not defined in the environment");
|
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);
|
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, {
|
res.cookie("token", token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
capture("maxun-oss-user-registered", {
|
capture("maxun-oss-user-registered", {
|
||||||
email: user.email,
|
email: user.email,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
registeredAt: new Date().toISOString(),
|
registeredAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`User registered`);
|
console.log(`User registered`);
|
||||||
res.json(user);
|
res.json(user);
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log(`Could not register user - ${error}`);
|
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) => {
|
router.post("/login", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { email, password } = req.body;
|
const { email, password } = req.body;
|
||||||
if (!email || !password)
|
if (!email || !password) {
|
||||||
return res.status(400).send("Email and password are required");
|
return res.status(400).json({
|
||||||
if (password.length < 6)
|
error: "VALIDATION_ERROR",
|
||||||
return res.status(400).send("Password must be at least 6 characters");
|
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 } });
|
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);
|
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);
|
const token = jwt.sign({ id: user?.id }, process.env.JWT_SECRET as string);
|
||||||
|
|
||||||
// return user and token to client, exclude hashed password
|
|
||||||
if (user) {
|
if (user) {
|
||||||
user.password = undefined as unknown as string;
|
user.password = undefined as unknown as string;
|
||||||
}
|
}
|
||||||
@@ -101,30 +143,43 @@ router.post("/login", async (req, res) => {
|
|||||||
});
|
});
|
||||||
res.json(user);
|
res.json(user);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
res.status(400).send(`Could not login user - ${error.message}`);
|
console.error(`Login error: ${error.message}`);
|
||||||
console.log(`Could not login user - ${error}`);
|
res.status(500).json({
|
||||||
|
error: "SERVER_ERROR",
|
||||||
|
code: "login.error.server_error"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/logout", async (req, res) => {
|
router.get("/logout", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
res.clearCookie("token");
|
res.clearCookie("token");
|
||||||
return res.json({ message: "Logout successful" });
|
return res.status(200).json({
|
||||||
} catch (error: any) {
|
ok: true,
|
||||||
res.status(500).send(`Could not logout user - ${error.message}`);
|
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(
|
router.get(
|
||||||
"/current-user",
|
"/current-user",
|
||||||
requireSignIn,
|
requireSignIn,
|
||||||
async (req: Request, res) => {
|
async (req: AuthenticatedRequest, res) => {
|
||||||
const authenticatedReq = req as AuthenticatedRequest;
|
|
||||||
try {
|
try {
|
||||||
if (!authenticatedReq.user) {
|
if (!req.user) {
|
||||||
return res.status(401).json({ ok: false, error: "Unauthorized" });
|
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"] },
|
attributes: { exclude: ["password"] },
|
||||||
});
|
});
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -147,7 +202,7 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
"/user/:id",
|
"/user/:id",
|
||||||
requireSignIn,
|
requireSignIn,
|
||||||
async (req: Request, res) => {
|
async (req: AuthenticatedRequest, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@@ -176,13 +231,12 @@ router.get(
|
|||||||
router.post(
|
router.post(
|
||||||
"/generate-api-key",
|
"/generate-api-key",
|
||||||
requireSignIn,
|
requireSignIn,
|
||||||
async (req: Request, res) => {
|
async (req: AuthenticatedRequest, res) => {
|
||||||
const authenticatedReq = req as AuthenticatedRequest;
|
|
||||||
try {
|
try {
|
||||||
if (!authenticatedReq.user) {
|
if (!req.user) {
|
||||||
return res.status(401).json({ ok: false, error: "Unauthorized" });
|
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"] },
|
attributes: { exclude: ["password"] },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -217,28 +271,41 @@ router.post(
|
|||||||
router.get(
|
router.get(
|
||||||
"/api-key",
|
"/api-key",
|
||||||
requireSignIn,
|
requireSignIn,
|
||||||
async (req: Request, res) => {
|
async (req: AuthenticatedRequest, res) => {
|
||||||
const authenticatedReq = req as AuthenticatedRequest;
|
|
||||||
try {
|
try {
|
||||||
if (!authenticatedReq.user) {
|
if (!req.user) {
|
||||||
return res.status(401).json({ ok: false, error: "Unauthorized" });
|
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,
|
raw: true,
|
||||||
attributes: ["api_key"],
|
attributes: ["api_key"],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
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({
|
return res.status(200).json({
|
||||||
|
ok: true,
|
||||||
message: "API key fetched successfully",
|
message: "API key fetched successfully",
|
||||||
api_key: user.api_key || null,
|
api_key: user.api_key || null,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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(
|
router.delete(
|
||||||
"/delete-api-key",
|
"/delete-api-key",
|
||||||
requireSignIn,
|
requireSignIn,
|
||||||
async (req: Request, res) => {
|
async (req: AuthenticatedRequest, res) => {
|
||||||
const authenticatedReq = req as AuthenticatedRequest;
|
if (!req.user) {
|
||||||
if (!authenticatedReq.user) {
|
|
||||||
return res.status(401).send({ error: "Unauthorized" });
|
return res.status(401).send({ error: "Unauthorized" });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await User.findByPk(authenticatedReq.user.id, { raw: true });
|
const user = await User.findByPk(req.user.id, { raw: true });
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(404).json({ message: "User not found" });
|
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" });
|
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", {
|
capture("maxun-oss-api-key-deleted", {
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
@@ -309,8 +375,7 @@ router.get("/google", (req, res) => {
|
|||||||
router.get(
|
router.get(
|
||||||
"/google/callback",
|
"/google/callback",
|
||||||
requireSignIn,
|
requireSignIn,
|
||||||
async (req: Request, res) => {
|
async (req: AuthenticatedRequest, res) => {
|
||||||
const authenticatedReq = req as AuthenticatedRequest;
|
|
||||||
const { code, state } = req.query;
|
const { code, state } = req.query;
|
||||||
try {
|
try {
|
||||||
if (!state) {
|
if (!state) {
|
||||||
@@ -336,12 +401,12 @@ router.get(
|
|||||||
return res.status(400).json({ message: "Email not found" });
|
return res.status(400).json({ message: "Email not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!authenticatedReq.user) {
|
if (!req.user) {
|
||||||
return res.status(401).send({ error: "Unauthorized" });
|
return res.status(401).send({ error: "Unauthorized" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the currently authenticated user (from `requireSignIn`)
|
// 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) {
|
if (!user) {
|
||||||
return res.status(400).json({ message: "User not found" });
|
return res.status(400).json({ message: "User not found" });
|
||||||
@@ -419,13 +484,12 @@ router.get(
|
|||||||
router.post(
|
router.post(
|
||||||
"/gsheets/data",
|
"/gsheets/data",
|
||||||
requireSignIn,
|
requireSignIn,
|
||||||
async (req: Request, res) => {
|
async (req: AuthenticatedRequest, res) => {
|
||||||
const authenticatedReq = req as AuthenticatedRequest;
|
|
||||||
const { spreadsheetId, robotId } = req.body;
|
const { spreadsheetId, robotId } = req.body;
|
||||||
if (!authenticatedReq.user) {
|
if (!req.user) {
|
||||||
return res.status(401).send({ error: "Unauthorized" });
|
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) {
|
if (!user) {
|
||||||
return res.status(400).json({ message: "User not found" });
|
return res.status(400).json({ message: "User not found" });
|
||||||
@@ -537,14 +601,13 @@ router.post("/gsheets/update", requireSignIn, async (req, res) => {
|
|||||||
router.post(
|
router.post(
|
||||||
"/gsheets/remove",
|
"/gsheets/remove",
|
||||||
requireSignIn,
|
requireSignIn,
|
||||||
async (req: Request, res) => {
|
async (req: AuthenticatedRequest, res) => {
|
||||||
const authenticatedReq = req as AuthenticatedRequest;
|
|
||||||
const { robotId } = req.body;
|
const { robotId } = req.body;
|
||||||
if (!robotId) {
|
if (!robotId) {
|
||||||
return res.status(400).json({ message: "Robot ID is required" });
|
return res.status(400).json({ message: "Robot ID is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!authenticatedReq.user) {
|
if (!req.user) {
|
||||||
return res.status(401).send({ error: "Unauthorized" });
|
return res.status(401).send({ error: "Unauthorized" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,7 +629,7 @@ router.post(
|
|||||||
});
|
});
|
||||||
|
|
||||||
capture("maxun-oss-google-sheet-integration-removed", {
|
capture("maxun-oss-google-sheet-integration-removed", {
|
||||||
user_id: authenticatedReq.user.id,
|
user_id: req.user.id,
|
||||||
robot_id: robotId,
|
robot_id: robotId,
|
||||||
deleted_at: new Date().toISOString(),
|
deleted_at: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
@@ -867,3 +930,5 @@ router.get("/airtable/tables", async (req: Request, res) => {
|
|||||||
res.status(500).json({ message: error.message });
|
res.status(500).json({ message: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -26,35 +26,43 @@ chromium.use(stealthPlugin());
|
|||||||
|
|
||||||
export const router = Router();
|
export const router = Router();
|
||||||
|
|
||||||
export const decryptWorkflowActions = async (workflow: any[],): Promise<any[]> => {
|
export const processWorkflowActions = async (workflow: any[], checkLimit: boolean = false): Promise<any[]> => {
|
||||||
// Create a deep copy to avoid mutating the original workflow
|
const processedWorkflow = JSON.parse(JSON.stringify(workflow));
|
||||||
const processedWorkflow = JSON.parse(JSON.stringify(workflow));
|
|
||||||
|
|
||||||
// Process each step in the workflow
|
processedWorkflow.forEach((pair: any) => {
|
||||||
for (const step of processedWorkflow) {
|
pair.what.forEach((action: any) => {
|
||||||
if (!step.what) continue;
|
// Handle limit validation for scrapeList action
|
||||||
|
if (action.action === 'scrapeList' && checkLimit && Array.isArray(action.args) && action.args.length > 0) {
|
||||||
// Process each action in the step
|
const scrapeConfig = action.args[0];
|
||||||
for (const action of step.what) {
|
if (scrapeConfig && typeof scrapeConfig === 'object' && 'limit' in scrapeConfig) {
|
||||||
// Only process type and press actions
|
if (typeof scrapeConfig.limit === 'number' && scrapeConfig.limit > 5) {
|
||||||
if ((action.action === 'type' || action.action === 'press') && Array.isArray(action.args) && action.args.length > 1) {
|
scrapeConfig.limit = 5;
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
// 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;
|
return processedWorkflow;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logs information about recordings API.
|
* Logs information about recordings API.
|
||||||
@@ -89,7 +97,7 @@ router.get('/recordings/:id', requireSignIn, async (req, res) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (data?.recording?.workflow) {
|
if (data?.recording?.workflow) {
|
||||||
data.recording.workflow = await decryptWorkflowActions(
|
data.recording.workflow = await processWorkflowActions(
|
||||||
data.recording.workflow,
|
data.recording.workflow,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -164,54 +172,82 @@ interface Credentials {
|
|||||||
[key: string]: CredentialInfo;
|
[key: string]: CredentialInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTypeActionsInWorkflow(workflow: any[], credentials: Credentials) {
|
function handleWorkflowActions(workflow: any[], credentials: Credentials) {
|
||||||
return workflow.map(step => {
|
return workflow.map(step => {
|
||||||
if (!step.what) return step;
|
if (!step.what) return step;
|
||||||
|
|
||||||
const indicesToRemove = new Set<number>();
|
const newWhat: any[] = [];
|
||||||
step.what.forEach((action: any, index: number) => {
|
const processedSelectors = new Set<string>();
|
||||||
if (!action.action || !action.args?.[0]) return;
|
|
||||||
|
|
||||||
if ((action.action === 'type' || action.action === 'press') && credentials[action.args[0]]) {
|
for (let i = 0; i < step.what.length; i++) {
|
||||||
indicesToRemove.add(index);
|
const action = step.what[i];
|
||||||
|
|
||||||
if (step.what[index + 1]?.action === 'waitForLoadState') {
|
if (!action?.action || !action?.args?.[0]) {
|
||||||
indicesToRemove.add(index + 1);
|
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]) => {
|
if (!credential) {
|
||||||
const clickIndex = filteredWhat.findIndex((action: any) =>
|
newWhat.push(action);
|
||||||
action.action === 'click' && action.args?.[0] === selector
|
continue;
|
||||||
);
|
}
|
||||||
|
|
||||||
if (clickIndex !== -1) {
|
if (action.action === 'click') {
|
||||||
const chars = credentialInfo.value.split('');
|
newWhat.push(action);
|
||||||
|
|
||||||
chars.forEach((char, i) => {
|
if (!processedSelectors.has(selector) &&
|
||||||
filteredWhat.splice(clickIndex + 1 + (i * 2), 0, {
|
i + 1 < step.what.length &&
|
||||||
|
(step.what[i + 1].action === 'type' || step.what[i + 1].action === 'press')) {
|
||||||
|
|
||||||
|
newWhat.push({
|
||||||
action: 'type',
|
action: 'type',
|
||||||
args: [
|
args: [selector, encrypt(credential.value), credential.type]
|
||||||
selector,
|
|
||||||
encrypt(char),
|
|
||||||
credentialInfo.type
|
|
||||||
]
|
|
||||||
});
|
});
|
||||||
|
|
||||||
filteredWhat.splice(clickIndex + 2 + (i * 2), 0, {
|
newWhat.push({
|
||||||
action: 'waitForLoadState',
|
action: 'waitForLoadState',
|
||||||
args: ['networkidle']
|
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 {
|
return {
|
||||||
...step,
|
...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
|
let workflow = [...robot.recording.workflow]; // Create a copy of the workflow
|
||||||
|
|
||||||
if (credentials) {
|
if (credentials) {
|
||||||
workflow = updateTypeActionsInWorkflow(workflow, credentials);
|
workflow = handleWorkflowActions(workflow, credentials);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the limit
|
// 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 } });
|
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({
|
return res.send({
|
||||||
browserId: id,
|
browserId: id,
|
||||||
runId: plainRun.runId,
|
runId: plainRun.runId,
|
||||||
|
robotMetaId: recording.recording_meta.id,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { message } = e as Error;
|
const { message } = e as Error;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { getBestSelectorForAction } from "../utils";
|
|||||||
import { browserPool } from "../../server";
|
import { browserPool } from "../../server";
|
||||||
import { uuid } from "uuidv4";
|
import { uuid } from "uuidv4";
|
||||||
import { capture } from "../../utils/analytics"
|
import { capture } from "../../utils/analytics"
|
||||||
import { encrypt } from "../../utils/auth";
|
import { decrypt, encrypt } from "../../utils/auth";
|
||||||
|
|
||||||
interface PersistedGeneratedData {
|
interface PersistedGeneratedData {
|
||||||
lastUsedSelector: string;
|
lastUsedSelector: string;
|
||||||
@@ -42,6 +42,13 @@ interface MetaData {
|
|||||||
isLogin?: boolean;
|
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
|
* Workflow generator is used to transform the user's interactions into an automatically
|
||||||
* generated correct workflows, using the ability of internal state persistence and
|
* 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) {
|
if ((elementInfo?.tagName === 'INPUT' || elementInfo?.tagName === 'TEXTAREA') && selector) {
|
||||||
// Calculate the exact position within the element
|
const positionAndCursor = await page.evaluate(
|
||||||
const elementPos = await page.evaluate((selector) => {
|
({ selector, coords }) => {
|
||||||
const element = document.querySelector(selector);
|
const getCursorPosition = (element: any, clickX: any) => {
|
||||||
if (!element) return null;
|
const text = element.value;
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
return {
|
|
||||||
x: rect.left,
|
|
||||||
y: rect.top
|
|
||||||
};
|
|
||||||
}, selector);
|
|
||||||
|
|
||||||
if (elementPos) {
|
const mirror = document.createElement('div');
|
||||||
const relativeX = coordinates.x - elementPos.x;
|
|
||||||
const relativeY = coordinates.y - elementPos.y;
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = {
|
const pair: WhereWhatPair = {
|
||||||
where,
|
where,
|
||||||
what: [{
|
what: [{
|
||||||
action: 'click',
|
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?.id,
|
||||||
selectors?.hrefSelector,
|
selectors?.hrefSelector,
|
||||||
selectors?.accessibilitySelector,
|
selectors?.accessibilitySelector,
|
||||||
selectors?.attrSelector,
|
selectors?.attrSelector
|
||||||
selectors?.generalSelector
|
|
||||||
]
|
]
|
||||||
.filter(selector => selector !== null && selector !== undefined)
|
.filter(selector => selector !== null && selector !== undefined)
|
||||||
.join(',');
|
.join(',');
|
||||||
@@ -1027,77 +1093,107 @@ export class WorkflowGenerator {
|
|||||||
* @param workflow The workflow to be optimized.
|
* @param workflow The workflow to be optimized.
|
||||||
*/
|
*/
|
||||||
private optimizeWorkflow = (workflow: WorkflowFile) => {
|
private optimizeWorkflow = (workflow: WorkflowFile) => {
|
||||||
|
const inputStates = new Map<string, InputState>();
|
||||||
|
|
||||||
// replace a sequence of press actions by a single fill action
|
for (const pair of workflow.workflow) {
|
||||||
let input = {
|
let currentIndex = 0;
|
||||||
selector: '',
|
|
||||||
value: '',
|
|
||||||
type: '',
|
|
||||||
actionCounter: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const pushTheOptimizedAction = (pair: WhereWhatPair, index: number) => {
|
while (currentIndex < pair.what.length) {
|
||||||
if (input.value.length === 1) {
|
const condition = pair.what[currentIndex];
|
||||||
// when only one press action is present, keep it and add a waitForLoadState action
|
|
||||||
pair.what.splice(index + 1, 0, {
|
if (condition.action === 'click' && condition.args?.[2]?.cursorIndex !== undefined) {
|
||||||
action: 'waitForLoadState',
|
const selector = condition.args[0];
|
||||||
args: ['networkidle'],
|
const cursorIndex = condition.args[2].cursorIndex;
|
||||||
})
|
|
||||||
} else {
|
let state = inputStates.get(selector) || {
|
||||||
// when more than one press action is present, add a type action
|
selector,
|
||||||
pair.what.splice(index - input.actionCounter, input.actionCounter, {
|
value: '',
|
||||||
action: 'type',
|
type: 'text',
|
||||||
args: [input.selector, encrypt(input.value), input.type],
|
cursorPosition: -1
|
||||||
}, {
|
};
|
||||||
action: 'waitForLoadState',
|
|
||||||
args: ['networkidle'],
|
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 [selector, state] of inputStates.entries()) {
|
||||||
|
if (state.value) {
|
||||||
|
for (let i = workflow.workflow.length - 1; i >= 0; i--) {
|
||||||
|
const pair = workflow.workflow[i];
|
||||||
|
|
||||||
for (const pair of workflow.workflow) {
|
pair.what.push({
|
||||||
pair.what.forEach((condition, index) => {
|
action: 'type',
|
||||||
if (condition.action === 'press') {
|
args: [selector, encrypt(state.value), state.type]
|
||||||
if (condition.args && condition.args[1]) {
|
}, {
|
||||||
if (!input.selector) {
|
action: 'waitForLoadState',
|
||||||
input.selector = condition.args[0];
|
args: ['networkidle']
|
||||||
}
|
});
|
||||||
if (input.selector === condition.args[0]) {
|
|
||||||
input.actionCounter++;
|
break;
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return workflow;
|
return workflow;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns workflow params from the stored metadata.
|
* Returns workflow params from the stored metadata.
|
||||||
|
|||||||
@@ -1092,12 +1092,16 @@ export const getSelectors = async (page: Page, coordinates: Coordinates) => {
|
|||||||
newPath.splice(i, 1);
|
newPath.splice(i, 1);
|
||||||
const newPathKey = selector(newPath);
|
const newPathKey = selector(newPath);
|
||||||
if (scope.visited.has(newPathKey)) {
|
if (scope.visited.has(newPathKey)) {
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
if (unique(newPath) && same(newPath, input)) {
|
try {
|
||||||
yield newPath;
|
if (unique(newPath) && same(newPath, input)) {
|
||||||
scope.visited.set(newPathKey, true);
|
yield newPath;
|
||||||
yield* optimize(newPath, input, scope);
|
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;
|
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;
|
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;
|
return selector;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export const createRunForStoredRecording = async (id: string, settings: RunSetti
|
|||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
return { browserId: '', runId: '' };
|
return { browserId: '', runId: '', robotMetaId: '' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
Paper,
|
Paper,
|
||||||
} from '@mui/material';
|
} 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 styled from 'styled-components';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useGlobalInfoStore } from '../../context/globalInfo';
|
import { useGlobalInfoStore } from '../../context/globalInfo';
|
||||||
@@ -137,7 +137,7 @@ const ApiKeyManager = () => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={showKey ? t('apikey.actions.hide') : t('apikey.actions.show')}>
|
<Tooltip title={showKey ? t('apikey.actions.hide') : t('apikey.actions.show')}>
|
||||||
<IconButton onClick={() => setShowKey(!showKey)}>
|
<IconButton onClick={() => setShowKey(!showKey)}>
|
||||||
<Visibility />
|
{showKey ? <VisibilityOff /> : <Visibility />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={t('apikey.actions.delete')}>
|
<Tooltip title={t('apikey.actions.delete')}>
|
||||||
|
|||||||
@@ -263,7 +263,12 @@ export const BrowserWindow = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (getList === true && !listSelector) {
|
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'));
|
notify(`info`, t('browser_window.attribute_modal.notifications.list_select_success'));
|
||||||
setCurrentListId(Date.now());
|
setCurrentListId(Date.now());
|
||||||
setFields({});
|
setFields({});
|
||||||
@@ -275,13 +280,25 @@ export const BrowserWindow = () => {
|
|||||||
// Add fields to the list
|
// Add fields to the list
|
||||||
if (options.length === 1) {
|
if (options.length === 1) {
|
||||||
const attribute = options[0].value;
|
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 = {
|
const newField: TextStep = {
|
||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
type: 'text',
|
type: 'text',
|
||||||
label: `Label ${Object.keys(fields).length + 1}`,
|
label: `Label ${Object.keys(fields).length + 1}`,
|
||||||
data: data,
|
data: data,
|
||||||
selectorObj: {
|
selectorObj: {
|
||||||
selector: highlighterData.selector,
|
selector: currentSelector,
|
||||||
tag: highlighterData.elementInfo?.tagName,
|
tag: highlighterData.elementInfo?.tagName,
|
||||||
shadow: highlighterData.elementInfo?.isShadowRoot,
|
shadow: highlighterData.elementInfo?.isShadowRoot,
|
||||||
attribute
|
attribute
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
Clear,
|
Clear,
|
||||||
YouTube,
|
YouTube,
|
||||||
X,
|
X,
|
||||||
|
GitHub,
|
||||||
Update,
|
Update,
|
||||||
Close,
|
Close,
|
||||||
Language,
|
Language,
|
||||||
@@ -107,11 +108,39 @@ export const NavBar: React.FC<NavBarProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
dispatch({ type: "LOGOUT" });
|
try {
|
||||||
window.localStorage.removeItem("user");
|
const { data } = await axios.get(`${apiUrl}/auth/logout`);
|
||||||
const { data } = await axios.get(`${apiUrl}/auth/logout`);
|
if (data.ok) {
|
||||||
notify("success", data.message);
|
dispatch({ type: "LOGOUT" });
|
||||||
navigate("/login");
|
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 () => {
|
const goToMainMenu = async () => {
|
||||||
@@ -204,7 +233,9 @@ export const NavBar: React.FC<NavBarProps> = ({
|
|||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'flex-start',
|
justifyContent: 'flex-start',
|
||||||
}}>
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
onClick={() => navigate('/')}>
|
||||||
<img src={MaxunLogo} width={45} height={40} style={{ borderRadius: '5px', margin: '5px 0px 5px 15px' }} />
|
<img src={MaxunLogo} width={45} height={40} style={{ borderRadius: '5px', margin: '5px 0px 5px 15px' }} />
|
||||||
<div style={{ padding: '11px' }}><ProjectName mode={darkMode ? 'dark' : 'light'}>{t('navbar.project_name')}</ProjectName></div>
|
<div style={{ padding: '11px' }}><ProjectName mode={darkMode ? 'dark' : 'light'}>{t('navbar.project_name')}</ProjectName></div>
|
||||||
<Chip
|
<Chip
|
||||||
@@ -219,14 +250,16 @@ export const NavBar: React.FC<NavBarProps> = ({
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
|
||||||
{!isRecording ? (
|
{!isRecording ? (
|
||||||
<>
|
<>
|
||||||
<Button variant="outlined" onClick={handleUpdateOpen} sx={{
|
<IconButton onClick={handleUpdateOpen} sx={{
|
||||||
marginRight: '40px',
|
display: 'flex',
|
||||||
color: "#00000099",
|
alignItems: 'center',
|
||||||
border: "#00000099 1px solid",
|
borderRadius: '5px',
|
||||||
'&:hover': { color: '#ff00c3', border: '#ff00c3 1px solid' }
|
padding: '8px',
|
||||||
|
marginRight: '20px',
|
||||||
}}>
|
}}>
|
||||||
<Update sx={{ marginRight: '5px' }} /> {t('navbar.upgrade.button')}
|
<Update sx={{ marginRight: '5px' }} />
|
||||||
</Button>
|
<Typography variant="body1">{t('navbar.upgrade.button')}</Typography>
|
||||||
|
</IconButton>
|
||||||
<Modal open={open} onClose={handleUpdateClose}>
|
<Modal open={open} onClose={handleUpdateClose}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -318,7 +351,14 @@ export const NavBar: React.FC<NavBarProps> = ({
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Modal>
|
</Modal>
|
||||||
<iframe src="https://ghbtns.com/github-btn.html?user=getmaxun&repo=maxun&type=star&count=true&size=large" frameBorder="0" scrolling="0" width="170" height="30" title="GitHub"></iframe>
|
{/* <iframe
|
||||||
|
src="https://ghbtns.com/github-btn.html?user=getmaxun&repo=maxun&type=star&count=true&size=large"
|
||||||
|
// frameBorder="0"
|
||||||
|
// scrolling="0"
|
||||||
|
// width="170"
|
||||||
|
// height="30"
|
||||||
|
// title="GitHub">
|
||||||
|
// </iframe>*/}
|
||||||
<IconButton onClick={handleMenuOpen} sx={{
|
<IconButton onClick={handleMenuOpen} sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -335,22 +375,31 @@ export const NavBar: React.FC<NavBarProps> = ({
|
|||||||
onClose={handleMenuClose}
|
onClose={handleMenuClose}
|
||||||
anchorOrigin={{
|
anchorOrigin={{
|
||||||
vertical: 'bottom',
|
vertical: 'bottom',
|
||||||
horizontal: 'right',
|
horizontal: 'center',
|
||||||
}}
|
}}
|
||||||
transformOrigin={{
|
transformOrigin={{
|
||||||
vertical: 'top',
|
vertical: 'top',
|
||||||
horizontal: 'right',
|
horizontal: 'center',
|
||||||
}}
|
}}
|
||||||
PaperProps={{ sx: { width: '180px' } }}
|
PaperProps={{ sx: { width: '180px' } }}
|
||||||
>
|
>
|
||||||
<MenuItem onClick={() => { handleMenuClose(); logout(); }}>
|
<MenuItem onClick={() => { handleMenuClose(); logout(); }}>
|
||||||
<Logout sx={{ marginRight: '5px' }} /> {t('navbar.menu_items.logout')}
|
<Logout sx={{ marginRight: '5px' }} /> {t('navbar.menu_items.logout')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<MenuItem onClick={handleLangMenuOpen}>
|
||||||
|
<Language sx={{ marginRight: '5px' }} /> {t('navbar.menu_items.language')}
|
||||||
|
</MenuItem>
|
||||||
|
<hr />
|
||||||
<MenuItem onClick={() => {
|
<MenuItem onClick={() => {
|
||||||
window.open('https://docs.maxun.dev', '_blank');
|
window.open('https://docs.maxun.dev', '_blank');
|
||||||
}}>
|
}}>
|
||||||
<Description sx={{ marginRight: '5px' }} /> Docs
|
<Description sx={{ marginRight: '5px' }} /> Docs
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<MenuItem onClick={() => {
|
||||||
|
window.open('https://github.com/getmaxun/maxun', '_blank');
|
||||||
|
}}>
|
||||||
|
<GitHub sx={{ marginRight: '5px' }} /> GitHub
|
||||||
|
</MenuItem>
|
||||||
<MenuItem onClick={() => {
|
<MenuItem onClick={() => {
|
||||||
window.open('https://discord.gg/5GbPjBUkws', '_blank');
|
window.open('https://discord.gg/5GbPjBUkws', '_blank');
|
||||||
}}>
|
}}>
|
||||||
@@ -366,20 +415,17 @@ export const NavBar: React.FC<NavBarProps> = ({
|
|||||||
}}>
|
}}>
|
||||||
<X sx={{ marginRight: '5px' }} /> Twitter (X)
|
<X sx={{ marginRight: '5px' }} /> Twitter (X)
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem onClick={handleLangMenuOpen}>
|
|
||||||
<Language sx={{ marginRight: '5px' }} /> {t('navbar.menu_items.language')}
|
|
||||||
</MenuItem>
|
|
||||||
<Menu
|
<Menu
|
||||||
anchorEl={langAnchorEl}
|
anchorEl={langAnchorEl}
|
||||||
open={Boolean(langAnchorEl)}
|
open={Boolean(langAnchorEl)}
|
||||||
onClose={handleMenuClose}
|
onClose={handleMenuClose}
|
||||||
anchorOrigin={{
|
anchorOrigin={{
|
||||||
vertical: "bottom",
|
vertical: "bottom",
|
||||||
horizontal: "right",
|
horizontal: "center",
|
||||||
}}
|
}}
|
||||||
transformOrigin={{
|
transformOrigin={{
|
||||||
vertical: "top",
|
vertical: "top",
|
||||||
horizontal: "right",
|
horizontal: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
@@ -471,11 +517,11 @@ export const NavBar: React.FC<NavBarProps> = ({
|
|||||||
onClose={handleMenuClose}
|
onClose={handleMenuClose}
|
||||||
anchorOrigin={{
|
anchorOrigin={{
|
||||||
vertical: "bottom",
|
vertical: "bottom",
|
||||||
horizontal: "right",
|
horizontal: "center",
|
||||||
}}
|
}}
|
||||||
transformOrigin={{
|
transformOrigin={{
|
||||||
vertical: "top",
|
vertical: "top",
|
||||||
horizontal: "right",
|
horizontal: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
|||||||
@@ -148,7 +148,6 @@ export const RecordingsTable = ({
|
|||||||
const [rows, setRows] = React.useState<Data[]>([]);
|
const [rows, setRows] = React.useState<Data[]>([]);
|
||||||
const [isModalOpen, setModalOpen] = React.useState(false);
|
const [isModalOpen, setModalOpen] = React.useState(false);
|
||||||
const [searchTerm, setSearchTerm] = React.useState('');
|
const [searchTerm, setSearchTerm] = React.useState('');
|
||||||
const [isLoading, setIsLoading] = React.useState(true);
|
|
||||||
|
|
||||||
const columns = useMemo(() => [
|
const columns = useMemo(() => [
|
||||||
{ id: 'interpret', label: t('recordingtable.run'), minWidth: 80 },
|
{ id: 'interpret', label: t('recordingtable.run'), minWidth: 80 },
|
||||||
@@ -169,6 +168,8 @@ export const RecordingsTable = ({
|
|||||||
setRecordingUrl,
|
setRecordingUrl,
|
||||||
isLogin,
|
isLogin,
|
||||||
setIsLogin,
|
setIsLogin,
|
||||||
|
rerenderRobots,
|
||||||
|
setRerenderRobots,
|
||||||
recordingName,
|
recordingName,
|
||||||
setRecordingName,
|
setRecordingName,
|
||||||
recordingId,
|
recordingId,
|
||||||
@@ -189,23 +190,38 @@ export const RecordingsTable = ({
|
|||||||
setPage(0);
|
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 () => {
|
const fetchRecordings = useCallback(async () => {
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const recordings = await getStoredRecordings();
|
const recordings = await getStoredRecordings();
|
||||||
if (recordings) {
|
if (recordings) {
|
||||||
const parsedRows = recordings
|
const parsedRows = recordings
|
||||||
.map((recording: any, index: number) => {
|
.map((recording: any, index: number) => {
|
||||||
if (recording?.recording_meta) {
|
if (recording?.recording_meta) {
|
||||||
return {
|
const parsedDate = parseDateString(recording.recording_meta.createdAt);
|
||||||
id: index,
|
|
||||||
...recording.recording_meta,
|
return {
|
||||||
content: recording.recording
|
id: index,
|
||||||
};
|
...recording.recording_meta,
|
||||||
}
|
content: recording.recording,
|
||||||
return null;
|
parsedDate
|
||||||
})
|
};
|
||||||
.filter(Boolean);
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => b.parsedDate.getTime() - a.parsedDate.getTime());
|
||||||
|
|
||||||
setRecordings(parsedRows.map((recording) => recording.name));
|
setRecordings(parsedRows.map((recording) => recording.name));
|
||||||
setRows(parsedRows);
|
setRows(parsedRows);
|
||||||
@@ -213,8 +229,6 @@ export const RecordingsTable = ({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching recordings:', error);
|
console.error('Error fetching recordings:', error);
|
||||||
notify('error', t('recordingtable.notifications.fetch_error'));
|
notify('error', t('recordingtable.notifications.fetch_error'));
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
}, [setRecordings, notify, t]);
|
}, [setRecordings, notify, t]);
|
||||||
|
|
||||||
@@ -249,6 +263,14 @@ export const RecordingsTable = ({
|
|||||||
}
|
}
|
||||||
}, [fetchRecordings]);
|
}, [fetchRecordings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (rerenderRobots) {
|
||||||
|
fetchRecordings().then(() => {
|
||||||
|
setRerenderRobots(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [rerenderRobots, fetchRecordings, setRerenderRobots]);
|
||||||
|
|
||||||
function useDebounce<T>(value: T, delay: number): T {
|
function useDebounce<T>(value: T, delay: number): T {
|
||||||
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
|
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
|
||||||
|
|
||||||
@@ -343,39 +365,32 @@ export const RecordingsTable = ({
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
{isLoading ? (
|
<TableContainer component={Paper} sx={{ width: '100%', overflow: 'hidden', marginTop: '15px' }}>
|
||||||
<Box display="flex" justifyContent="center" alignItems="center" height="50%">
|
<Table stickyHeader aria-label="sticky table">
|
||||||
<CircularProgress />
|
<TableHead>
|
||||||
</Box>
|
<TableRow>
|
||||||
) : (
|
{columns.map((column) => (
|
||||||
<TableContainer component={Paper} sx={{ width: '100%', overflow: 'hidden', marginTop: '15px' }}>
|
<MemoizedTableCell
|
||||||
<Table stickyHeader aria-label="sticky table">
|
key={column.id}
|
||||||
<TableHead>
|
style={{ minWidth: column.minWidth }}
|
||||||
<TableRow>
|
>
|
||||||
{columns.map((column) => (
|
{column.label}
|
||||||
<MemoizedTableCell
|
</MemoizedTableCell>
|
||||||
key={column.id}
|
|
||||||
// align={column.align}
|
|
||||||
style={{ minWidth: column.minWidth }}
|
|
||||||
>
|
|
||||||
{column.label}
|
|
||||||
</MemoizedTableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{visibleRows.map((row) => (
|
|
||||||
<TableRowMemoized
|
|
||||||
key={row.id}
|
|
||||||
row={row}
|
|
||||||
columns={columns}
|
|
||||||
handlers={handlers}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableRow>
|
||||||
</Table>
|
</TableHead>
|
||||||
</TableContainer>
|
<TableBody>
|
||||||
)}
|
{visibleRows.map((row) => (
|
||||||
|
<TableRowMemoized
|
||||||
|
key={row.id}
|
||||||
|
row={row}
|
||||||
|
columns={columns}
|
||||||
|
handlers={handlers}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
<TablePagination
|
<TablePagination
|
||||||
rowsPerPageOptions={[10, 25, 50, 100]}
|
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||||
|
|||||||
@@ -55,9 +55,9 @@ interface RobotSettingsProps {
|
|||||||
|
|
||||||
export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
|
export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [robot, setRobot] = useState<RobotSettings | null>(null);
|
|
||||||
const [targetUrl, setTargetUrl] = useState<string | undefined>('');
|
const [targetUrl, setTargetUrl] = useState<string | undefined>('');
|
||||||
const { recordingId, notify } = useGlobalInfoStore();
|
const [robot, setRobot] = useState<RobotSettings | null>(null);
|
||||||
|
const { recordingId, notify, setRerenderRobots } = useGlobalInfoStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -96,13 +96,11 @@ export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initia
|
|||||||
const success = await duplicateRecording(robot.recording_meta.id, targetUrl);
|
const success = await duplicateRecording(robot.recording_meta.id, targetUrl);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
|
setRerenderRobots(true);
|
||||||
|
|
||||||
notify('success', t('robot_duplication.notifications.duplicate_success'));
|
notify('success', t('robot_duplication.notifications.duplicate_success'));
|
||||||
handleStart(robot);
|
handleStart(robot);
|
||||||
handleClose();
|
handleClose();
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 1000);
|
|
||||||
} else {
|
} else {
|
||||||
notify('error', t('robot_duplication.notifications.duplicate_error'));
|
notify('error', t('robot_duplication.notifications.duplicate_error'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { modalStyle } from "../recorder/AddWhereCondModal";
|
|||||||
import { useGlobalInfoStore } from '../../context/globalInfo';
|
import { useGlobalInfoStore } from '../../context/globalInfo';
|
||||||
import { getStoredRecording, updateRecording } from '../../api/storage';
|
import { getStoredRecording, updateRecording } from '../../api/storage';
|
||||||
import { WhereWhatPair } from 'maxun-core';
|
import { WhereWhatPair } from 'maxun-core';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
interface RobotMeta {
|
interface RobotMeta {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -75,9 +76,9 @@ interface GroupedCredentials {
|
|||||||
|
|
||||||
export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
|
export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [robot, setRobot] = useState<RobotSettings | null>(null);
|
|
||||||
const [credentials, setCredentials] = useState<Credentials>({});
|
const [credentials, setCredentials] = useState<Credentials>({});
|
||||||
const { recordingId, notify } = useGlobalInfoStore();
|
const { recordingId, notify, setRerenderRobots } = useGlobalInfoStore();
|
||||||
|
const [robot, setRobot] = useState<RobotSettings | null>(null);
|
||||||
const [credentialGroups, setCredentialGroups] = useState<GroupedCredentials>({
|
const [credentialGroups, setCredentialGroups] = useState<GroupedCredentials>({
|
||||||
passwords: [],
|
passwords: [],
|
||||||
emails: [],
|
emails: [],
|
||||||
@@ -123,81 +124,113 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin
|
|||||||
}
|
}
|
||||||
}, [robot]);
|
}, [robot]);
|
||||||
|
|
||||||
const extractInitialCredentials = (workflow: any[]): Credentials => {
|
function extractInitialCredentials(workflow: any[]): Credentials {
|
||||||
const credentials: Credentials = {};
|
const credentials: Credentials = {};
|
||||||
|
|
||||||
// Helper function to check if a character is printable
|
|
||||||
const isPrintableCharacter = (char: string): boolean => {
|
const isPrintableCharacter = (char: string): boolean => {
|
||||||
return char.length === 1 && !!char.match(/^[\x20-\x7E]$/);
|
return char.length === 1 && !!char.match(/^[\x20-\x7E]$/);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Process each step in the workflow
|
|
||||||
workflow.forEach(step => {
|
workflow.forEach(step => {
|
||||||
if (!step.what) return;
|
if (!step.what) return;
|
||||||
|
|
||||||
// Keep track of the current input field being processed
|
|
||||||
let currentSelector = '';
|
let currentSelector = '';
|
||||||
let currentValue = '';
|
let currentValue = '';
|
||||||
let currentType = '';
|
let currentType = '';
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
// Process actions in sequence to maintain correct text state
|
while (i < step.what.length) {
|
||||||
step.what.forEach((action: any) => {
|
const action = step.what[i];
|
||||||
if (
|
|
||||||
(action.action === 'type' || action.action === 'press') &&
|
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 &&
|
action.args?.length >= 2 &&
|
||||||
typeof action.args[1] === 'string'
|
typeof action.args[1] === 'string' &&
|
||||||
) {
|
action.args[1].length > 1) {
|
||||||
const selector: string = action.args[0];
|
|
||||||
const character: string = action.args[1];
|
|
||||||
const inputType: string = action.args[2] || '';
|
|
||||||
|
|
||||||
// Detect `input[type="password"]`
|
if (!credentials[selector]) {
|
||||||
if (!currentType && inputType.toLowerCase() === 'password') {
|
credentials[selector] = {
|
||||||
|
value: action.args[1],
|
||||||
|
type: action.args[2] || 'text'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle character-by-character sequences (both type and press)
|
||||||
|
if ((action.action === 'type' || action.action === 'press') &&
|
||||||
|
action.args?.length >= 2 &&
|
||||||
|
typeof action.args[1] === 'string') {
|
||||||
|
|
||||||
|
if (selector !== currentSelector) {
|
||||||
|
if (currentSelector && currentValue) {
|
||||||
|
credentials[currentSelector] = {
|
||||||
|
value: currentValue,
|
||||||
|
type: currentType || 'text'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
currentSelector = selector;
|
||||||
|
currentValue = credentials[selector]?.value || '';
|
||||||
|
currentType = action.args[2] || credentials[selector]?.type || 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
const character = action.args[1];
|
||||||
|
|
||||||
|
if (isPrintableCharacter(character)) {
|
||||||
|
currentValue += character;
|
||||||
|
} else if (character === 'Backspace') {
|
||||||
|
currentValue = currentValue.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentType && action.args[2]?.toLowerCase() === 'password') {
|
||||||
currentType = 'password';
|
currentType = 'password';
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we're dealing with a new selector, store the previous one
|
let j = i + 1;
|
||||||
if (currentSelector && selector !== currentSelector) {
|
while (j < step.what.length) {
|
||||||
if (!credentials[currentSelector]) {
|
const nextAction = step.what[j];
|
||||||
credentials[currentSelector] = {
|
if (!nextAction.action || !nextAction.args?.[0] ||
|
||||||
value: currentValue,
|
nextAction.args[0] !== selector ||
|
||||||
type: currentType
|
(nextAction.action !== 'type' && nextAction.action !== 'press')) {
|
||||||
};
|
break;
|
||||||
} else {
|
|
||||||
credentials[currentSelector].value = currentValue;
|
|
||||||
}
|
}
|
||||||
|
if (nextAction.args[1] === 'Backspace') {
|
||||||
|
currentValue = currentValue.slice(0, -1);
|
||||||
|
} else if (isPrintableCharacter(nextAction.args[1])) {
|
||||||
|
currentValue += nextAction.args[1];
|
||||||
|
}
|
||||||
|
j++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update current tracking variables
|
credentials[currentSelector] = {
|
||||||
if (selector !== currentSelector) {
|
value: currentValue,
|
||||||
currentSelector = selector;
|
type: currentType
|
||||||
currentValue = credentials[selector]?.value || '';
|
};
|
||||||
currentType = inputType || credentials[selector]?.type || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle different types of key actions
|
i = j;
|
||||||
if (character === 'Backspace') {
|
} else {
|
||||||
// Remove the last character when backspace is pressed
|
i++;
|
||||||
currentValue = currentValue.slice(0, -1);
|
|
||||||
} else if (isPrintableCharacter(character)) {
|
|
||||||
// Add the character to the current value
|
|
||||||
currentValue += character;
|
|
||||||
}
|
|
||||||
// Note: We ignore other special keys like 'Shift', 'Enter', etc.
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
// Store the final state of the last processed selector
|
if (currentSelector && currentValue) {
|
||||||
if (currentSelector) {
|
|
||||||
credentials[currentSelector] = {
|
credentials[currentSelector] = {
|
||||||
value: currentValue,
|
value: currentValue,
|
||||||
type: currentType
|
type: currentType || 'text'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return credentials;
|
return credentials;
|
||||||
};
|
}
|
||||||
|
|
||||||
const groupCredentialsByType = (credentials: Credentials): GroupedCredentials => {
|
const groupCredentialsByType = (credentials: Credentials): GroupedCredentials => {
|
||||||
return Object.entries(credentials).reduce((acc: GroupedCredentials, [selector, info]) => {
|
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);
|
const success = await updateRecording(robot.recording_meta.id, payload);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
|
setRerenderRobots(true);
|
||||||
|
|
||||||
notify('success', t('robot_edit.notifications.update_success'));
|
notify('success', t('robot_edit.notifications.update_success'));
|
||||||
handleStart(robot);
|
handleStart(robot);
|
||||||
handleClose();
|
handleClose();
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 1000);
|
|
||||||
} else {
|
} else {
|
||||||
notify('error', t('robot_edit.notifications.update_failed'));
|
notify('error', t('robot_edit.notifications.update_failed'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ interface RobotSettingsProps {
|
|||||||
|
|
||||||
export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
|
export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [robot, setRobot] = useState<RobotSettings | null>(null);
|
|
||||||
const [userEmail, setUserEmail] = useState<string | null>(null);
|
const [userEmail, setUserEmail] = useState<string | null>(null);
|
||||||
|
const [robot, setRobot] = useState<RobotSettings | null>(null);
|
||||||
const { recordingId, notify } = useGlobalInfoStore();
|
const { recordingId, notify } = useGlobalInfoStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ interface CollapsibleRowProps {
|
|||||||
currentLog: string;
|
currentLog: string;
|
||||||
abortRunHandler: () => void;
|
abortRunHandler: () => void;
|
||||||
runningRecordingName: string;
|
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 { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [open, setOpen] = useState(isOpen);
|
const [open, setOpen] = useState(isOpen);
|
||||||
@@ -62,14 +63,18 @@ export const CollapsibleRow = ({ row, handleDelete, isOpen, currentLog, abortRun
|
|||||||
abortRunHandler();
|
abortRunHandler();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOpen(urlRunId === row.runId || isOpen);
|
||||||
|
}, [urlRunId, row.runId, isOpen]);
|
||||||
|
|
||||||
const handleRowExpand = () => {
|
const handleRowExpand = () => {
|
||||||
const newOpen = !open;
|
const newOpen = !open;
|
||||||
setOpen(newOpen);
|
setOpen(newOpen);
|
||||||
if (newOpen) {
|
navigate(
|
||||||
navigate(`/runs/${row.robotMetaId}/run/${row.runId}`);
|
newOpen
|
||||||
} else {
|
? `/runs/${row.robotMetaId}/run/${row.runId}`
|
||||||
navigate(`/runs/${row.robotMetaId}`);
|
: `/runs/${row.robotMetaId}`
|
||||||
}
|
);
|
||||||
//scrollToLogBottom();
|
//scrollToLogBottom();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import TableRow from '@mui/material/TableRow';
|
|||||||
import { Accordion, AccordionSummary, AccordionDetails, Typography, Box, TextField, CircularProgress, Tooltip } from '@mui/material';
|
import { Accordion, AccordionSummary, AccordionDetails, Typography, Box, TextField, CircularProgress, Tooltip } from '@mui/material';
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
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 { useGlobalInfoStore } from "../../context/globalInfo";
|
||||||
import { getStoredRuns } from "../../api/storage";
|
import { getStoredRuns } from "../../api/storage";
|
||||||
import { RunSettings } from "./RunSettings";
|
import { RunSettings } from "./RunSettings";
|
||||||
@@ -70,6 +70,13 @@ interface RunsTableProps {
|
|||||||
runningRecordingName: string;
|
runningRecordingName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PaginationState {
|
||||||
|
[robotMetaId: string]: {
|
||||||
|
page: number;
|
||||||
|
rowsPerPage: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const RunsTable: React.FC<RunsTableProps> = ({
|
export const RunsTable: React.FC<RunsTableProps> = ({
|
||||||
currentInterpretationLog,
|
currentInterpretationLog,
|
||||||
abortRunHandler,
|
abortRunHandler,
|
||||||
@@ -78,7 +85,24 @@ export const RunsTable: React.FC<RunsTableProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
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<AccordionSortConfig>({});
|
const [accordionSortConfigs, setAccordionSortConfigs] = useState<AccordionSortConfig>({});
|
||||||
|
|
||||||
const handleSort = useCallback((columnId: keyof Data, robotMetaId: string) => {
|
const handleSort = useCallback((columnId: keyof Data, robotMetaId: string) => {
|
||||||
@@ -107,11 +131,10 @@ export const RunsTable: React.FC<RunsTableProps> = ({
|
|||||||
[t]
|
[t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [page, setPage] = useState(0);
|
|
||||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
|
||||||
const [rows, setRows] = useState<Data[]>([]);
|
const [rows, setRows] = useState<Data[]>([]);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
|
const [paginationStates, setPaginationStates] = useState<PaginationState>({});
|
||||||
|
|
||||||
const { notify, rerenderRuns, setRerenderRuns } = useGlobalInfoStore();
|
const { notify, rerenderRuns, setRerenderRuns } = useGlobalInfoStore();
|
||||||
|
|
||||||
@@ -119,15 +142,50 @@ export const RunsTable: React.FC<RunsTableProps> = ({
|
|||||||
navigate(isExpanded ? `/runs/${robotMetaId}` : '/runs');
|
navigate(isExpanded ? `/runs/${robotMetaId}` : '/runs');
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
const handleChangePage = useCallback((event: unknown, newPage: number) => {
|
const handleAccordionPageChange = useCallback((event: unknown, newPage: number) => {
|
||||||
setPage(newPage);
|
setAccordionPage(newPage);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleChangeRowsPerPage = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleAccordionsPerPageChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setRowsPerPage(+event.target.value);
|
setAccordionsPerPage(+event.target.value);
|
||||||
setPage(0);
|
setAccordionPage(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) => {
|
const debouncedSearch = useCallback((fn: Function, delay: number) => {
|
||||||
let timeoutId: NodeJS.Timeout;
|
let timeoutId: NodeJS.Timeout;
|
||||||
return (...args: any[]) => {
|
return (...args: any[]) => {
|
||||||
@@ -139,14 +197,20 @@ export const RunsTable: React.FC<RunsTableProps> = ({
|
|||||||
const handleSearchChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleSearchChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const debouncedSetSearch = debouncedSearch((value: string) => {
|
const debouncedSetSearch = debouncedSearch((value: string) => {
|
||||||
setSearchTerm(value);
|
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);
|
}, 300);
|
||||||
debouncedSetSearch(event.target.value);
|
debouncedSetSearch(event.target.value);
|
||||||
}, [debouncedSearch]);
|
}, [debouncedSearch]);
|
||||||
|
|
||||||
const fetchRuns = useCallback(async () => {
|
const fetchRuns = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
|
||||||
const runs = await getStoredRuns();
|
const runs = await getStoredRuns();
|
||||||
if (runs) {
|
if (runs) {
|
||||||
const parsedRows: Data[] = runs.map((run: any, index: number) => ({
|
const parsedRows: Data[] = runs.map((run: any, index: number) => ({
|
||||||
@@ -159,8 +223,6 @@ export const RunsTable: React.FC<RunsTableProps> = ({
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notify('error', t('runstable.notifications.fetch_error'));
|
notify('error', t('runstable.notifications.fetch_error'));
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
}, [notify, t]);
|
}, [notify, t]);
|
||||||
|
|
||||||
@@ -194,18 +256,6 @@ export const RunsTable: React.FC<RunsTableProps> = ({
|
|||||||
return result;
|
return result;
|
||||||
}, [rows, searchTerm]);
|
}, [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<string, Data[]>),
|
|
||||||
[filteredRows]
|
|
||||||
);
|
|
||||||
|
|
||||||
const parseDateString = (dateStr: string): Date => {
|
const parseDateString = (dateStr: string): Date => {
|
||||||
try {
|
try {
|
||||||
if (dateStr.includes('PM') || dateStr.includes('AM')) {
|
if (dateStr.includes('PM') || dateStr.includes('AM')) {
|
||||||
@@ -218,7 +268,37 @@ export const RunsTable: React.FC<RunsTableProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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<string, Data[]>);
|
||||||
|
|
||||||
|
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<string, Data[]>);
|
||||||
|
}, [filteredRows]);
|
||||||
|
|
||||||
const renderTableRows = useCallback((data: Data[], robotMetaId: string) => {
|
const renderTableRows = useCallback((data: Data[], robotMetaId: string) => {
|
||||||
|
const { page, rowsPerPage } = getPaginationState(robotMetaId);
|
||||||
const start = page * rowsPerPage;
|
const start = page * rowsPerPage;
|
||||||
const end = start + rowsPerPage;
|
const end = start + rowsPerPage;
|
||||||
|
|
||||||
@@ -245,13 +325,14 @@ export const RunsTable: React.FC<RunsTableProps> = ({
|
|||||||
key={`row-${row.id}`}
|
key={`row-${row.id}`}
|
||||||
row={row}
|
row={row}
|
||||||
handleDelete={handleDelete}
|
handleDelete={handleDelete}
|
||||||
isOpen={runId === row.runId && runningRecordingName === row.name}
|
isOpen={urlRunId === row.runId || (runId === row.runId && runningRecordingName === row.name)}
|
||||||
currentLog={currentInterpretationLog}
|
currentLog={currentInterpretationLog}
|
||||||
abortRunHandler={abortRunHandler}
|
abortRunHandler={abortRunHandler}
|
||||||
runningRecordingName={runningRecordingName}
|
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 renderSortIcon = useCallback((column: Column, robotMetaId: string) => {
|
||||||
const sortConfig = accordionSortConfigs[robotMetaId];
|
const sortConfig = accordionSortConfigs[robotMetaId];
|
||||||
@@ -279,14 +360,6 @@ export const RunsTable: React.FC<RunsTableProps> = ({
|
|||||||
: <UnfoldMore fontSize="small" />;
|
: <UnfoldMore fontSize="small" />;
|
||||||
}, [accordionSortConfigs]);
|
}, [accordionSortConfigs]);
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<Box display="flex" justifyContent="center" alignItems="center" height="50vh">
|
|
||||||
<CircularProgress />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||||
@@ -305,82 +378,99 @@ export const RunsTable: React.FC<RunsTableProps> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<TableContainer component={Paper} sx={{ width: '100%', overflow: 'hidden' }}>
|
<TableContainer component={Paper} sx={{ width: '100%', overflow: 'hidden' }}>
|
||||||
{Object.entries(groupedRows).map(([robotMetaId, data]) => (
|
{Object.entries(groupedRows)
|
||||||
<Accordion
|
.slice(
|
||||||
key={robotMetaId}
|
accordionPage * accordionsPerPage,
|
||||||
onChange={(event, isExpanded) => handleAccordionChange(robotMetaId, isExpanded)}
|
accordionPage * accordionsPerPage + accordionsPerPage
|
||||||
TransitionProps={{ unmountOnExit: true }} // Optimize accordion rendering
|
)
|
||||||
>
|
.map(([robotMetaId, data]) => (
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
<Accordion
|
||||||
<Typography variant="h6">{data[data.length - 1].name}</Typography>
|
key={robotMetaId}
|
||||||
</AccordionSummary>
|
onChange={(event, isExpanded) => handleAccordionChange(robotMetaId, isExpanded)}
|
||||||
<AccordionDetails>
|
TransitionProps={{ unmountOnExit: true }} // Optimize accordion rendering
|
||||||
<Table stickyHeader aria-label="sticky table">
|
>
|
||||||
<TableHead>
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
<TableRow>
|
<Typography variant="h6">{data[data.length - 1].name}</Typography>
|
||||||
<TableCell />
|
</AccordionSummary>
|
||||||
{translatedColumns.map((column) => (
|
<AccordionDetails>
|
||||||
<TableCell
|
<Table stickyHeader aria-label="sticky table">
|
||||||
key={column.id}
|
<TableHead>
|
||||||
align={column.align}
|
<TableRow>
|
||||||
style={{
|
<TableCell />
|
||||||
minWidth: column.minWidth,
|
{translatedColumns.map((column) => (
|
||||||
cursor: column.id === 'startedAt' || column.id === 'finishedAt' ? 'pointer' : 'default'
|
<TableCell
|
||||||
}}
|
key={column.id}
|
||||||
onClick={() => {
|
align={column.align}
|
||||||
if (column.id === 'startedAt' || column.id === 'finishedAt') {
|
style={{
|
||||||
handleSort(column.id, robotMetaId);
|
minWidth: column.minWidth,
|
||||||
}
|
cursor: column.id === 'startedAt' || column.id === 'finishedAt' ? 'pointer' : 'default'
|
||||||
}}
|
}}
|
||||||
>
|
onClick={() => {
|
||||||
<Tooltip
|
if (column.id === 'startedAt' || column.id === 'finishedAt') {
|
||||||
title={
|
handleSort(column.id, robotMetaId);
|
||||||
(column.id === 'startedAt' || column.id === 'finishedAt')
|
|
||||||
? t('runstable.sort_tooltip')
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Box sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 1,
|
|
||||||
'&:hover': {
|
|
||||||
'& .sort-icon': {
|
|
||||||
opacity: 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}}>
|
}}
|
||||||
{column.label}
|
>
|
||||||
<Box className="sort-icon" sx={{
|
<Tooltip
|
||||||
|
title={
|
||||||
|
(column.id === 'startedAt' || column.id === 'finishedAt')
|
||||||
|
? t('runstable.sort_tooltip')
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
opacity: accordionSortConfigs[robotMetaId]?.field === column.id ? 1 : 0.3,
|
gap: 1,
|
||||||
transition: 'opacity 0.2s'
|
'&:hover': {
|
||||||
|
'& .sort-icon': {
|
||||||
|
opacity: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
}}>
|
}}>
|
||||||
{renderSortIcon(column, robotMetaId)}
|
{column.label}
|
||||||
|
<Box className="sort-icon" sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
opacity: accordionSortConfigs[robotMetaId]?.field === column.id ? 1 : 0.3,
|
||||||
|
transition: 'opacity 0.2s'
|
||||||
|
}}>
|
||||||
|
{renderSortIcon(column, robotMetaId)}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Tooltip>
|
||||||
</Tooltip>
|
</TableCell>
|
||||||
</TableCell>
|
))}
|
||||||
))}
|
</TableRow>
|
||||||
</TableRow>
|
</TableHead>
|
||||||
</TableHead>
|
<TableBody>
|
||||||
<TableBody>
|
{renderTableRows(data, robotMetaId)}
|
||||||
{renderTableRows(data, robotMetaId)}
|
</TableBody>
|
||||||
</TableBody>
|
</Table>
|
||||||
</Table>
|
|
||||||
</AccordionDetails>
|
<TablePagination
|
||||||
</Accordion>
|
component="div"
|
||||||
))}
|
count={data.length}
|
||||||
|
rowsPerPage={getPaginationState(robotMetaId).rowsPerPage}
|
||||||
|
page={getPaginationState(robotMetaId).page}
|
||||||
|
onPageChange={(_, newPage) => handleChangePage(robotMetaId, newPage)}
|
||||||
|
onRowsPerPageChange={(event) =>
|
||||||
|
handleChangeRowsPerPage(robotMetaId, +event.target.value)
|
||||||
|
}
|
||||||
|
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||||
|
/>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
))}
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
|
|
||||||
<TablePagination
|
<TablePagination
|
||||||
component="div"
|
component="div"
|
||||||
count={filteredRows.length}
|
count={Object.keys(groupedRows).length}
|
||||||
rowsPerPage={rowsPerPage}
|
page={accordionPage}
|
||||||
page={page}
|
rowsPerPage={accordionsPerPage}
|
||||||
onPageChange={handleChangePage}
|
onPageChange={handleAccordionPageChange}
|
||||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
onRowsPerPageChange={handleAccordionsPerPageChange}
|
||||||
rowsPerPageOptions={[10, 25, 50, 100]}
|
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
|||||||
@@ -1,6 +1,44 @@
|
|||||||
import React, { createContext, useContext, useState } from "react";
|
import React, { createContext, useContext, useState } from "react";
|
||||||
import { AlertSnackbarProps } from "../components/ui/AlertSnackbar";
|
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 {
|
interface GlobalInfo {
|
||||||
browserId: string | null;
|
browserId: string | null;
|
||||||
@@ -16,6 +54,8 @@ interface GlobalInfo {
|
|||||||
setRecordings: (recordings: string[]) => void;
|
setRecordings: (recordings: string[]) => void;
|
||||||
rerenderRuns: boolean;
|
rerenderRuns: boolean;
|
||||||
setRerenderRuns: (rerenderRuns: boolean) => void;
|
setRerenderRuns: (rerenderRuns: boolean) => void;
|
||||||
|
rerenderRobots: boolean;
|
||||||
|
setRerenderRobots: (rerenderRuns: boolean) => void;
|
||||||
recordingLength: number;
|
recordingLength: number;
|
||||||
setRecordingLength: (recordingLength: number) => void;
|
setRecordingLength: (recordingLength: number) => void;
|
||||||
recordingId: string | null;
|
recordingId: string | null;
|
||||||
@@ -52,6 +92,7 @@ class GlobalInfoStore implements Partial<GlobalInfo> {
|
|||||||
recordingId = null;
|
recordingId = null;
|
||||||
recordings: string[] = [];
|
recordings: string[] = [];
|
||||||
rerenderRuns = false;
|
rerenderRuns = false;
|
||||||
|
rerenderRobots = false;
|
||||||
recordingName = '';
|
recordingName = '';
|
||||||
initialUrl = 'https://';
|
initialUrl = 'https://';
|
||||||
recordingUrl = 'https://';
|
recordingUrl = 'https://';
|
||||||
@@ -75,6 +116,7 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => {
|
|||||||
const [notification, setNotification] = useState<AlertSnackbarProps>(globalInfoStore.notification);
|
const [notification, setNotification] = useState<AlertSnackbarProps>(globalInfoStore.notification);
|
||||||
const [recordings, setRecordings] = useState<string[]>(globalInfoStore.recordings);
|
const [recordings, setRecordings] = useState<string[]>(globalInfoStore.recordings);
|
||||||
const [rerenderRuns, setRerenderRuns] = useState<boolean>(globalInfoStore.rerenderRuns);
|
const [rerenderRuns, setRerenderRuns] = useState<boolean>(globalInfoStore.rerenderRuns);
|
||||||
|
const [rerenderRobots, setRerenderRobots] = useState<boolean>(globalInfoStore.rerenderRobots);
|
||||||
const [recordingLength, setRecordingLength] = useState<number>(globalInfoStore.recordingLength);
|
const [recordingLength, setRecordingLength] = useState<number>(globalInfoStore.recordingLength);
|
||||||
const [recordingId, setRecordingId] = useState<string | null>(globalInfoStore.recordingId);
|
const [recordingId, setRecordingId] = useState<string | null>(globalInfoStore.recordingId);
|
||||||
const [recordingName, setRecordingName] = useState<string>(globalInfoStore.recordingName);
|
const [recordingName, setRecordingName] = useState<string>(globalInfoStore.recordingName);
|
||||||
@@ -121,6 +163,8 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => {
|
|||||||
setRecordings,
|
setRecordings,
|
||||||
rerenderRuns,
|
rerenderRuns,
|
||||||
setRerenderRuns,
|
setRerenderRuns,
|
||||||
|
rerenderRobots,
|
||||||
|
setRerenderRobots,
|
||||||
recordingLength,
|
recordingLength,
|
||||||
setRecordingLength,
|
setRecordingLength,
|
||||||
recordingId,
|
recordingId,
|
||||||
|
|||||||
@@ -53,8 +53,14 @@ const Login = () => {
|
|||||||
notify("success", t('login.welcome_notification'));
|
notify("success", t('login.welcome_notification'));
|
||||||
window.localStorage.setItem("user", JSON.stringify(data));
|
window.localStorage.setItem("user", JSON.stringify(data));
|
||||||
navigate("/");
|
navigate("/");
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
notify("error", t('login.error_notification'));
|
const errorResponse = err.response?.data;
|
||||||
|
|
||||||
|
const errorMessage = errorResponse?.code
|
||||||
|
? t(errorResponse.code)
|
||||||
|
: t('login.error.generic');
|
||||||
|
|
||||||
|
notify("error", errorMessage);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { ScheduleSettings } from "../components/robot/ScheduleSettings";
|
|||||||
import { IntegrationSettings } from "../components/integration/IntegrationSettings";
|
import { IntegrationSettings } from "../components/integration/IntegrationSettings";
|
||||||
import { RobotSettings } from "../components/robot/RobotSettings";
|
import { RobotSettings } from "../components/robot/RobotSettings";
|
||||||
import { apiUrl } from "../apiConfig";
|
import { apiUrl } from "../apiConfig";
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
interface MainPageProps {
|
interface MainPageProps {
|
||||||
handleEditRecording: (id: string, fileName: string) => void;
|
handleEditRecording: (id: string, fileName: string) => void;
|
||||||
@@ -24,6 +25,7 @@ interface MainPageProps {
|
|||||||
export interface CreateRunResponse {
|
export interface CreateRunResponse {
|
||||||
browserId: string;
|
browserId: string;
|
||||||
runId: string;
|
runId: string;
|
||||||
|
robotMetaId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScheduleRunResponse {
|
export interface ScheduleRunResponse {
|
||||||
@@ -40,12 +42,14 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps)
|
|||||||
const [currentInterpretationLog, setCurrentInterpretationLog] = React.useState('');
|
const [currentInterpretationLog, setCurrentInterpretationLog] = React.useState('');
|
||||||
const [ids, setIds] = React.useState<CreateRunResponse>({
|
const [ids, setIds] = React.useState<CreateRunResponse>({
|
||||||
browserId: '',
|
browserId: '',
|
||||||
runId: ''
|
runId: '',
|
||||||
|
robotMetaId: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
let aborted = false;
|
let aborted = false;
|
||||||
|
|
||||||
const { notify, setRerenderRuns, setRecordingId } = useGlobalInfoStore();
|
const { notify, setRerenderRuns, setRecordingId } = useGlobalInfoStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const abortRunHandler = (runId: string) => {
|
const abortRunHandler = (runId: string) => {
|
||||||
aborted = true;
|
aborted = true;
|
||||||
@@ -88,8 +92,9 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps)
|
|||||||
}, [currentInterpretationLog])
|
}, [currentInterpretationLog])
|
||||||
|
|
||||||
const handleRunRecording = useCallback((settings: RunSettings) => {
|
const handleRunRecording = useCallback((settings: RunSettings) => {
|
||||||
createRunForStoredRecording(runningRecordingId, settings).then(({ browserId, runId }: CreateRunResponse) => {
|
createRunForStoredRecording(runningRecordingId, settings).then(({ browserId, runId, robotMetaId }: CreateRunResponse) => {
|
||||||
setIds({ browserId, runId });
|
setIds({ browserId, runId, robotMetaId });
|
||||||
|
navigate(`/runs/${robotMetaId}/run/${runId}`);
|
||||||
const socket =
|
const socket =
|
||||||
io(`${apiUrl}/${browserId}`, {
|
io(`${apiUrl}/${browserId}`, {
|
||||||
transports: ["websocket"],
|
transports: ["websocket"],
|
||||||
|
|||||||
@@ -48,7 +48,13 @@ const Register = () => {
|
|||||||
window.localStorage.setItem("user", JSON.stringify(data));
|
window.localStorage.setItem("user", JSON.stringify(data));
|
||||||
navigate("/");
|
navigate("/");
|
||||||
} catch (error:any) {
|
} 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);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user