Merge branch 'develop' into ui-fix

This commit is contained in:
Rohit
2025-01-08 12:50:46 +05:30
committed by GitHub
67 changed files with 7978 additions and 1189 deletions

View File

@@ -7,16 +7,17 @@ COPY package*.json ./
COPY maxun-core ./maxun-core COPY maxun-core ./maxun-core
# Install dependencies # Install dependencies
RUN npm install RUN npm install --legacy-peer-deps
# Copy frontend source code and config # Copy frontend source code and config
COPY src ./src COPY src ./src
COPY public ./public
COPY index.html ./ COPY index.html ./
COPY vite.config.js ./ COPY vite.config.js ./
COPY tsconfig.json ./ COPY tsconfig.json ./
# Expose the frontend port # Expose the frontend port
EXPOSE 5173 EXPOSE ${FRONTEND_PORT:-5173}
# Start the frontend using the client script # Start the frontend using the client script
CMD ["npm", "run", "client", "--", "--host"] CMD ["npm", "run", "client", "--", "--host"]

View File

@@ -1,6 +1,6 @@
<h1 align="center"> <h1 align="center">
<div> <div>
<a href="https://maxun-website.vercel.app/"> <a href="https://maxun-website.vercel.app/?ref=ghread">
<img src="/src/assets/maxunlogo.png" width="50" /> <img src="/src/assets/maxunlogo.png" width="50" />
<br> <br>
Maxun Maxun
@@ -15,11 +15,11 @@ Maxun lets you train a robot in 2 minutes and scrape the web on auto-pilot. Web
<p align="center"> <p align="center">
<a href="https://maxun-website.vercel.app/"><b>Website</b></a> | <a href="https://www.maxun.dev/?ref=ghread"><b>Website</b></a> |
<a href="https://discord.gg/5GbPjBUkws"><b>Discord</b></a> | <a href="https://discord.gg/5GbPjBUkws"><b>Discord</b></a> |
<a href="https://x.com/maxun_io"><b>Twitter</b></a> | <a href="https://x.com/maxun_io?ref=ghread"><b>Twitter</b></a> |
<a href="https://docs.google.com/forms/d/e/1FAIpQLSdbD2uhqC4sbg4eLZ9qrFbyrfkXZ2XsI6dQ0USRCQNZNn5pzg/viewform"><b>Join Maxun Cloud</b></a> | <a href="https://docs.google.com/forms/d/e/1FAIpQLSdbD2uhqC4sbg4eLZ9qrFbyrfkXZ2XsI6dQ0USRCQNZNn5pzg/viewform"><b>Join Maxun Cloud</b></a> |
<a href="https://www.youtube.com/@MaxunOSS"><b>Watch Tutorials</b></a> <a href="https://www.youtube.com/@MaxunOSS?ref=ghread"><b>Watch Tutorials</b></a>
<br /> <br />
<br /> <br />
<a href="https://trendshift.io/repositories/12113" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12113" alt="getmaxun%2Fmaxun | Trendshift" style="width: 250px; height: 55px; margin-top: 10px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/12113" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12113" alt="getmaxun%2Fmaxun | Trendshift" style="width: 250px; height: 55px; margin-top: 10px;" width="250" height="55"/></a>
@@ -29,12 +29,17 @@ Maxun lets you train a robot in 2 minutes and scrape the web on auto-pilot. Web
<img src="https://static.scarf.sh/a.png?x-pxid=c12a77cc-855e-4602-8a0f-614b2d0da56a" /> <img src="https://static.scarf.sh/a.png?x-pxid=c12a77cc-855e-4602-8a0f-614b2d0da56a" />
> Note: Maxun is in its early stages of development and currently does not support self-hosting. However, you can run Maxun locally. Self-hosting capabilities are planned for a future release and will be available soon. # Installation
1. Create a root folder for your project (e.g. 'maxun')
2. Create a file named `.env` in the root folder of the project
3. Example env file can be viewed [here](https://github.com/getmaxun/maxun/blob/master/ENVEXAMPLE). Copy all content of example env to your `.env` file.
4. Choose your installation method below
# Local Installation
### Docker Compose ### Docker Compose
1. Copy paste the [docker-compose.yml file](https://github.com/getmaxun/maxun/blob/master/docker-compose.yml) into your root folder
2. Ensure you have setup the `.env` file in that same folder
3. Run the command below from a terminal
``` ```
git clone https://github.com/getmaxun/maxun
docker-compose up -d docker-compose up -d
``` ```
You can access the frontend at http://localhost:5173/ and backend at http://localhost:8080/ You can access the frontend at http://localhost:5173/ and backend at http://localhost:8080/

View File

@@ -43,7 +43,7 @@ services:
#build: #build:
#context: . #context: .
#dockerfile: server/Dockerfile #dockerfile: server/Dockerfile
image: getmaxun/maxun-backend:v0.0.5 image: getmaxun/maxun-backend:v0.0.10
ports: ports:
- "${BACKEND_PORT:-8080}:${BACKEND_PORT:-8080}" - "${BACKEND_PORT:-8080}:${BACKEND_PORT:-8080}"
env_file: .env env_file: .env
@@ -64,28 +64,23 @@ services:
- redis - redis
- minio - minio
volumes: volumes:
- ./server:/app/server # Mount server source code for hot reloading
- ./maxun-core:/app/maxun-core # Mount maxun-core for any shared code updates
- /var/run/dbus:/var/run/dbus - /var/run/dbus:/var/run/dbus
frontend: frontend:
#build: #build:
#context: . #context: .
#dockerfile: Dockerfile #dockerfile: Dockerfile
image: getmaxun/maxun-frontend:v0.0.2 image: getmaxun/maxun-frontend:v0.0.6
ports: ports:
- "${FRONTEND_PORT:-5173}:${FRONTEND_PORT:-5173}" - "${FRONTEND_PORT:-5173}:${FRONTEND_PORT:-5173}"
env_file: .env env_file: .env
environment: environment:
PUBLIC_URL: ${PUBLIC_URL} PUBLIC_URL: ${PUBLIC_URL}
BACKEND_URL: ${BACKEND_URL} BACKEND_URL: ${BACKEND_URL}
volumes:
- ./:/app # Mount entire frontend app directory for hot reloading
- /app/node_modules # Anonymous volume to prevent overwriting node_modules
depends_on: depends_on:
- backend - backend
volumes: volumes:
postgres_data: postgres_data:
minio_data: minio_data:
redis_data: redis_data:

View File

@@ -1,6 +1,6 @@
{ {
"name": "maxun-core", "name": "maxun-core",
"version": "0.0.5", "version": "0.0.8",
"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",

View File

@@ -188,69 +188,201 @@ function scrapableHeuristics(maxCountPerPage = 50, minArea = 20000, scrolls = 3,
* @param {Object.<string, {selector: string, tag: string}>} lists The named lists of HTML elements. * @param {Object.<string, {selector: string, tag: string}>} lists The named lists of HTML elements.
* @returns {Array.<Object.<string, string>>} * @returns {Array.<Object.<string, string>>}
*/ */
window.scrapeSchema = function (lists) { window.scrapeSchema = function(lists) {
// Utility functions remain the same
function omap(object, f, kf = (x) => x) { function omap(object, f, kf = (x) => x) {
return Object.fromEntries( return Object.fromEntries(
Object.entries(object) Object.entries(object)
.map(([k, v]) => [kf(k), f(v)]), .map(([k, v]) => [kf(k), f(v)]),
); );
} }
function ofilter(object, f) { function ofilter(object, f) {
return Object.fromEntries( return Object.fromEntries(
Object.entries(object) Object.entries(object)
.filter(([k, v]) => f(k, v)), .filter(([k, v]) => f(k, v)),
); );
} }
function getSeedKey(listObj) { function findAllElements(config) {
const maxLength = Math.max(...Object.values(omap(listObj, (x) => document.querySelectorAll(x.selector).length))); // Regular DOM query if no special delimiters
return Object.keys(ofilter(listObj, (_, v) => document.querySelectorAll(v.selector).length === maxLength))[0]; if (!config.selector.includes('>>') && !config.selector.includes(':>>')) {
return Array.from(document.querySelectorAll(config.selector));
}
// First handle iframe traversal if present
if (config.selector.includes(':>>')) {
const parts = config.selector.split(':>>').map(s => s.trim());
let currentElements = [document];
// Traverse through each part of the selector
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const nextElements = [];
const isLast = i === parts.length - 1;
for (const element of currentElements) {
try {
// For document or iframe document
const doc = element.contentDocument || element || element.contentWindow?.document;
if (!doc) continue;
// Query elements in current context
const found = Array.from(doc.querySelectorAll(part));
if (isLast) {
// If it's the last part, keep all matching elements
nextElements.push(...found);
} else {
// If not last, only keep iframes for next iteration
const iframes = found.filter(el => el.tagName === 'IFRAME');
nextElements.push(...iframes);
}
} catch (error) {
console.warn('Cannot access iframe content:', error, {
part,
element,
index: i
});
}
}
if (nextElements.length === 0) {
console.warn('No elements found for part:', part, 'at depth:', i);
return [];
}
currentElements = nextElements;
}
return currentElements;
}
// Handle shadow DOM traversal
if (config.selector.includes('>>')) {
const parts = config.selector.split('>>').map(s => s.trim());
let currentElements = [document];
for (const part of parts) {
const nextElements = [];
for (const element of currentElements) {
// Try regular DOM first
const found = Array.from(element.querySelectorAll(part));
// Then check shadow roots
for (const foundEl of found) {
if (foundEl.shadowRoot) {
nextElements.push(foundEl.shadowRoot);
} else {
nextElements.push(foundEl);
}
}
}
currentElements = nextElements;
}
return currentElements.filter(el => !(el instanceof ShadowRoot));
}
return [];
} }
// Modified to handle iframe context for URL resolution
function getElementValue(element, attribute) {
if (!element) return null;
// Get the base URL for resolving relative URLs
const baseURL = element.ownerDocument?.location?.href || window.location.origin;
switch (attribute) {
case 'href': {
const relativeHref = element.getAttribute('href');
return relativeHref ? new URL(relativeHref, baseURL).href : null;
}
case 'src': {
const relativeSrc = element.getAttribute('src');
return relativeSrc ? new URL(relativeSrc, baseURL).href : null;
}
case 'innerText':
return element.innerText?.trim();
case 'textContent':
return element.textContent?.trim();
default:
return element.getAttribute(attribute) || element.innerText?.trim();
}
}
// Rest of the functions remain largely the same
function getSeedKey(listObj) {
const maxLength = Math.max(...Object.values(
omap(listObj, (x) => findAllElements(x).length)
));
return Object.keys(
ofilter(listObj, (_, v) => findAllElements(v).length === maxLength)
)[0];
}
// Find minimal bounding elements
function getMBEs(elements) { function getMBEs(elements) {
return elements.map((element) => { return elements.map((element) => {
let candidate = element; let candidate = element;
const isUniqueChild = (e) => elements const isUniqueChild = (e) => elements
.filter((elem) => e.parentNode?.contains(elem)) .filter((elem) => {
// Handle both iframe and shadow DOM boundaries
const sameContext = elem.getRootNode() === e.getRootNode() &&
elem.ownerDocument === e.ownerDocument;
return sameContext && e.parentNode?.contains(elem);
})
.length === 1; .length === 1;
while (candidate && isUniqueChild(candidate)) { while (candidate && isUniqueChild(candidate)) {
candidate = candidate.parentNode; candidate = candidate.parentNode;
} }
return candidate; return candidate;
}); });
} }
const seedName = getSeedKey(lists); const seedName = getSeedKey(lists);
const seedElements = Array.from(document.querySelectorAll(lists[seedName].selector)); const seedElements = findAllElements(lists[seedName]);
const MBEs = getMBEs(seedElements); const MBEs = getMBEs(seedElements);
return MBEs.map((mbe) => omap( const mbeResults = MBEs.map((mbe) => omap(
lists, lists,
({ selector, attribute }, key) => { (config) => {
const elem = Array.from(document.querySelectorAll(selector)).find((elem) => mbe.contains(elem)); const elem = findAllElements(config)
if (!elem) return undefined; .find((elem) => mbe.contains(elem));
switch (attribute) { return elem ? getElementValue(elem, config.attribute) : undefined;
case 'href': },
const relativeHref = elem.getAttribute('href'); (key) => key
return relativeHref ? new URL(relativeHref, window.location.origin).href : null;
case 'src':
const relativeSrc = elem.getAttribute('src');
return relativeSrc ? new URL(relativeSrc, window.location.origin).href : null;
case 'innerText':
return elem.innerText;
case 'textContent':
return elem.textContent;
default:
return elem.innerText;
}
},
(key) => key // Use the original key in the output
)) || []; )) || [];
}
// If MBE approach didn't find all elements, try independent scraping
if (mbeResults.some(result => Object.values(result).some(v => v === undefined))) {
// Fall back to independent scraping
const results = [];
const foundElements = new Map();
// Find all elements for each selector
Object.entries(lists).forEach(([key, config]) => {
const elements = findAllElements(config);
foundElements.set(key, elements);
});
// Create result objects for each found element
foundElements.forEach((elements, key) => {
elements.forEach((element, index) => {
if (!results[index]) {
results[index] = {};
}
results[index][key] = getElementValue(element, lists[key].attribute);
});
});
return results.filter(result => Object.keys(result).length > 0);
}
return mbeResults;
};
/** /**
* Scrapes multiple lists of similar items based on a template item. * Scrapes multiple lists of similar items based on a template item.
@@ -262,45 +394,465 @@ function scrapableHeuristics(maxCountPerPage = 50, minArea = 20000, scrolls = 3,
* @returns {Array.<Array.<Object>>} Array of arrays of scraped items, one sub-array per list * @returns {Array.<Array.<Object>>} Array of arrays of scraped items, one sub-array per list
*/ */
window.scrapeList = async function ({ listSelector, fields, limit = 10 }) { window.scrapeList = async function ({ listSelector, fields, limit = 10 }) {
const scrapedData = []; // Enhanced query function to handle both iframe and shadow DOM
const queryElement = (rootElement, selector) => {
if (!selector.includes('>>') && !selector.includes(':>>')) {
return rootElement.querySelector(selector);
}
while (scrapedData.length < limit) { const parts = selector.split(/(?:>>|:>>)/).map(part => part.trim());
// Get all parent elements matching the listSelector let currentElement = rootElement;
const parentElements = Array.from(document.querySelectorAll(listSelector));
// Iterate through each parent element for (let i = 0; i < parts.length; i++) {
for (const parent of parentElements) { if (!currentElement) return null;
if (scrapedData.length >= limit) break;
const record = {};
// For each field, select the corresponding element within the parent // Handle iframe traversal
for (const [label, { selector, attribute }] of Object.entries(fields)) { if (currentElement.tagName === 'IFRAME') {
const fieldElement = parent.querySelector(selector); try {
const iframeDoc = currentElement.contentDocument || currentElement.contentWindow.document;
if (fieldElement) { currentElement = iframeDoc.querySelector(parts[i]);
if (attribute === 'innerText') { continue;
record[label] = fieldElement.innerText.trim(); } catch (e) {
} else if (attribute === 'innerHTML') { console.warn('Cannot access iframe content:', e);
record[label] = fieldElement.innerHTML.trim(); return null;
} else if (attribute === 'src') { }
// Handle relative 'src' URLs
const src = fieldElement.getAttribute('src');
record[label] = src ? new URL(src, baseUrl).href : null;
} else if (attribute === 'href') {
// Handle relative 'href' URLs
const href = fieldElement.getAttribute('href');
record[label] = href ? new URL(href, baseUrl).href : null;
} else {
record[label] = fieldElement.getAttribute(attribute);
}
} }
// Try regular DOM first
let nextElement = currentElement.querySelector(parts[i]);
// Try shadow DOM if not found
if (!nextElement && currentElement.shadowRoot) {
nextElement = currentElement.shadowRoot.querySelector(parts[i]);
}
// Check children's shadow roots if still not found
if (!nextElement) {
const children = Array.from(currentElement.children || []);
for (const child of children) {
if (child.shadowRoot) {
nextElement = child.shadowRoot.querySelector(parts[i]);
if (nextElement) break;
}
}
}
currentElement = nextElement;
}
return currentElement;
};
// Enhanced query all function for both contexts
const queryElementAll = (rootElement, selector) => {
if (!selector.includes('>>') && !selector.includes(':>>')) {
return rootElement.querySelectorAll(selector);
}
const parts = selector.split(/(?:>>|:>>)/).map(part => part.trim());
let currentElements = [rootElement];
for (const part of parts) {
const nextElements = [];
for (const element of currentElements) {
// Handle iframe traversal
if (element.tagName === 'IFRAME') {
try {
const iframeDoc = element.contentDocument || element.contentWindow.document;
nextElements.push(...iframeDoc.querySelectorAll(part));
} catch (e) {
console.warn('Cannot access iframe content:', e);
continue;
}
} else {
// Regular DOM elements
if (element.querySelectorAll) {
nextElements.push(...element.querySelectorAll(part));
}
// Shadow DOM elements
if (element.shadowRoot) {
nextElements.push(...element.shadowRoot.querySelectorAll(part));
}
// Check children's shadow roots
const children = Array.from(element.children || []);
for (const child of children) {
if (child.shadowRoot) {
nextElements.push(...child.shadowRoot.querySelectorAll(part));
}
}
}
}
currentElements = nextElements;
}
return currentElements;
};
// Enhanced value extraction with context awareness
function extractValue(element, attribute) {
if (!element) return null;
// Get context-aware base URL
const baseURL = element.ownerDocument?.location?.href || window.location.origin;
// Check shadow root first
if (element.shadowRoot) {
const shadowContent = element.shadowRoot.textContent;
if (shadowContent?.trim()) {
return shadowContent.trim();
}
}
if (attribute === 'innerText') {
return element.innerText.trim();
} else if (attribute === 'innerHTML') {
return element.innerHTML.trim();
} else if (attribute === 'src' || attribute === 'href') {
const attrValue = element.getAttribute(attribute);
return attrValue ? new URL(attrValue, baseURL).href : null;
}
return element.getAttribute(attribute);
}
// Enhanced table ancestor finding with context support
function findTableAncestor(element) {
let currentElement = element;
const MAX_DEPTH = 5;
let depth = 0;
while (currentElement && depth < MAX_DEPTH) {
// Handle shadow DOM
if (currentElement.getRootNode() instanceof ShadowRoot) {
currentElement = currentElement.getRootNode().host;
continue;
}
if (currentElement.tagName === 'TD') {
return { type: 'TD', element: currentElement };
} else if (currentElement.tagName === 'TR') {
return { type: 'TR', element: currentElement };
}
// Handle iframe crossing
if (currentElement.tagName === 'IFRAME') {
try {
currentElement = currentElement.contentDocument.body;
} catch (e) {
return null;
}
} else {
currentElement = currentElement.parentElement;
}
depth++;
}
return null;
}
// Helper function to get cell index
function getCellIndex(td) {
if (td.getRootNode() instanceof ShadowRoot) {
const shadowRoot = td.getRootNode();
const allCells = Array.from(shadowRoot.querySelectorAll('td'));
return allCells.indexOf(td);
}
let index = 0;
let sibling = td;
while (sibling = sibling.previousElementSibling) {
index++;
}
return index;
}
// Helper function to check for TH elements
function hasThElement(row, tableFields) {
for (const [_, { selector }] of Object.entries(tableFields)) {
const element = queryElement(row, selector);
if (element) {
let current = element;
while (current && current !== row) {
if (current.getRootNode() instanceof ShadowRoot) {
current = current.getRootNode().host;
continue;
}
if (current.tagName === 'TH') return true;
if (current.tagName === 'IFRAME') {
try {
current = current.contentDocument.body;
} catch (e) {
break;
}
} else {
current = current.parentElement;
}
}
}
}
return false;
}
// Helper function to filter rows
function filterRowsBasedOnTag(rows, tableFields) {
for (const row of rows) {
if (hasThElement(row, tableFields)) {
return rows;
}
} }
scrapedData.push(record); // Include shadow DOM in TH search
return rows.filter(row => {
const directTH = row.getElementsByTagName('TH').length === 0;
const shadowTH = row.shadowRoot ?
row.shadowRoot.querySelector('th') === null : true;
return directTH && shadowTH;
});
}
// Class similarity comparison functions
function calculateClassSimilarity(classList1, classList2) {
const set1 = new Set(classList1);
const set2 = new Set(classList2);
const intersection = new Set([...set1].filter(x => set2.has(x)));
const union = new Set([...set1, ...set2]);
return intersection.size / union.size;
}
// Enhanced similar elements finding with context support
function findSimilarElements(baseElement, similarityThreshold = 0.7) {
const baseClasses = Array.from(baseElement.classList);
if (baseClasses.length === 0) return [];
const allElements = [];
// Get elements from main document
allElements.push(...document.getElementsByTagName(baseElement.tagName));
// Get elements from shadow DOM
if (baseElement.getRootNode() instanceof ShadowRoot) {
const shadowHost = baseElement.getRootNode().host;
allElements.push(...shadowHost.getElementsByTagName(baseElement.tagName));
}
// Get elements from iframes
const iframes = document.getElementsByTagName('iframe');
for (const iframe of iframes) {
try {
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
allElements.push(...iframeDoc.getElementsByTagName(baseElement.tagName));
} catch (e) {
console.warn('Cannot access iframe content:', e);
}
}
return allElements.filter(element => {
if (element === baseElement) return false;
const similarity = calculateClassSimilarity(
baseClasses,
Array.from(element.classList)
);
return similarity >= similarityThreshold;
});
}
// Main scraping logic with context support
let containers = queryElementAll(document, listSelector);
containers = Array.from(containers);
if (containers.length === 0) return [];
if (limit > 1 && containers.length === 1) {
const baseContainer = containers[0];
const similarContainers = findSimilarElements(baseContainer);
if (similarContainers.length > 0) {
const newContainers = similarContainers.filter(container =>
!container.matches(listSelector)
);
containers = [...containers, ...newContainers];
} }
} }
return scrapedData
};
const containerFields = containers.map(() => ({
tableFields: {},
nonTableFields: {}
}));
// Classify fields
containers.forEach((container, containerIndex) => {
for (const [label, field] of Object.entries(fields)) {
const sampleElement = queryElement(container, field.selector);
if (sampleElement) {
const ancestor = findTableAncestor(sampleElement);
if (ancestor) {
containerFields[containerIndex].tableFields[label] = {
...field,
tableContext: ancestor.type,
cellIndex: ancestor.type === 'TD' ? getCellIndex(ancestor.element) : -1
};
} else {
containerFields[containerIndex].nonTableFields[label] = field;
}
} else {
containerFields[containerIndex].nonTableFields[label] = field;
}
}
});
const tableData = [];
const nonTableData = [];
// Process table data with both iframe and shadow DOM support
for (let containerIndex = 0; containerIndex < containers.length; containerIndex++) {
const container = containers[containerIndex];
const { tableFields } = containerFields[containerIndex];
if (Object.keys(tableFields).length > 0) {
const firstField = Object.values(tableFields)[0];
const firstElement = queryElement(container, firstField.selector);
let tableContext = firstElement;
// Find table context including both iframe and shadow DOM
while (tableContext && tableContext.tagName !== 'TABLE' && tableContext !== container) {
if (tableContext.getRootNode() instanceof ShadowRoot) {
tableContext = tableContext.getRootNode().host;
continue;
}
if (tableContext.tagName === 'IFRAME') {
try {
tableContext = tableContext.contentDocument.body;
} catch (e) {
break;
}
} else {
tableContext = tableContext.parentElement;
}
}
if (tableContext) {
// Get rows from all contexts
const rows = [];
// Get rows from regular DOM
rows.push(...tableContext.getElementsByTagName('TR'));
// Get rows from shadow DOM
if (tableContext.shadowRoot) {
rows.push(...tableContext.shadowRoot.getElementsByTagName('TR'));
}
// Get rows from iframes
if (tableContext.tagName === 'IFRAME') {
try {
const iframeDoc = tableContext.contentDocument || tableContext.contentWindow.document;
rows.push(...iframeDoc.getElementsByTagName('TR'));
} catch (e) {
console.warn('Cannot access iframe rows:', e);
}
}
const processedRows = filterRowsBasedOnTag(rows, tableFields);
for (let rowIndex = 0; rowIndex < Math.min(processedRows.length, limit); rowIndex++) {
const record = {};
const currentRow = processedRows[rowIndex];
for (const [label, { selector, attribute, cellIndex }] of Object.entries(tableFields)) {
let element = null;
if (cellIndex >= 0) {
// Get TD element considering both contexts
let td = currentRow.children[cellIndex];
// Check shadow DOM for td
if (!td && currentRow.shadowRoot) {
const shadowCells = currentRow.shadowRoot.children;
if (shadowCells && shadowCells.length > cellIndex) {
td = shadowCells[cellIndex];
}
}
if (td) {
element = queryElement(td, selector);
if (!element && selector.split(/(?:>>|:>>)/).pop().includes('td:nth-child')) {
element = td;
}
if (!element) {
const tagOnlySelector = selector.split('.')[0];
element = queryElement(td, tagOnlySelector);
}
if (!element) {
let currentElement = td;
while (currentElement && currentElement.children.length > 0) {
let foundContentChild = false;
for (const child of currentElement.children) {
if (extractValue(child, attribute)) {
currentElement = child;
foundContentChild = true;
break;
}
}
if (!foundContentChild) break;
}
element = currentElement;
}
}
} else {
element = queryElement(currentRow, selector);
}
if (element) {
record[label] = extractValue(element, attribute);
}
}
if (Object.keys(record).length > 0) {
tableData.push(record);
}
}
}
}
}
// Process non-table data with both contexts support
for (let containerIndex = 0; containerIndex < containers.length; containerIndex++) {
if (nonTableData.length >= limit) break;
const container = containers[containerIndex];
const { nonTableFields } = containerFields[containerIndex];
if (Object.keys(nonTableFields).length > 0) {
const record = {};
for (const [label, { selector, attribute }] of Object.entries(nonTableFields)) {
// Get the last part of the selector after any context delimiter
const relativeSelector = selector.split(/(?:>>|:>>)/).slice(-1)[0];
const element = queryElement(container, relativeSelector);
if (element) {
record[label] = extractValue(element, attribute);
}
}
if (Object.keys(record).length > 0) {
nonTableData.push(record);
}
}
}
// Merge and limit the results
const scrapedData = [...tableData, ...nonTableData];
return scrapedData;
};
/** /**
* Gets all children of the elements matching the listSelector, * Gets all children of the elements matching the listSelector,
@@ -346,5 +898,5 @@ function scrapableHeuristics(maxCountPerPage = 50, minArea = 20000, scrolls = 3,
return results; return results;
}; };
})(window); })(window);

View File

@@ -102,7 +102,7 @@ export default class Interpreter extends EventEmitter {
}; };
} }
PlaywrightBlocker.fromPrebuiltAdsAndTracking(fetch).then(blocker => { PlaywrightBlocker.fromLists(fetch, ['https://easylist.to/easylist/easylist.txt']).then(blocker => {
this.blocker = blocker; this.blocker = blocker;
}).catch(err => { }).catch(err => {
this.log(`Failed to initialize ad-blocker:`, Level.ERROR); this.log(`Failed to initialize ad-blocker:`, Level.ERROR);
@@ -111,16 +111,71 @@ export default class Interpreter extends EventEmitter {
private async applyAdBlocker(page: Page): Promise<void> { private async applyAdBlocker(page: Page): Promise<void> {
if (this.blocker) { if (this.blocker) {
await this.blocker.enableBlockingInPage(page); try {
await this.blocker.enableBlockingInPage(page);
} catch (err) {
this.log(`Ad-blocker operation failed:`, Level.ERROR);
}
} }
} }
private async disableAdBlocker(page: Page): Promise<void> { private async disableAdBlocker(page: Page): Promise<void> {
if (this.blocker) { if (this.blocker) {
await this.blocker.disableBlockingInPage(page); try {
await this.blocker.disableBlockingInPage(page);
} catch (err) {
this.log(`Ad-blocker operation failed:`, Level.ERROR);
}
} }
} }
// private getSelectors(workflow: Workflow, actionId: number): string[] {
// const selectors: string[] = [];
// // Validate actionId
// if (actionId <= 0) {
// console.log("No previous selectors to collect.");
// return selectors; // Empty array as there are no previous steps
// }
// // Iterate from the start up to (but not including) actionId
// for (let index = 0; index < actionId; index++) {
// const currentSelectors = workflow[index]?.where?.selectors;
// console.log(`Selectors at step ${index}:`, currentSelectors);
// if (currentSelectors && currentSelectors.length > 0) {
// currentSelectors.forEach((selector) => {
// if (!selectors.includes(selector)) {
// selectors.push(selector); // Avoid duplicates
// }
// });
// }
// }
// console.log("Collected Selectors:", selectors);
// return selectors;
// }
private getSelectors(workflow: Workflow): string[] {
const selectorsSet = new Set<string>();
if (workflow.length === 0) {
return [];
}
for (let index = workflow.length - 1; index >= 0; index--) {
const currentSelectors = workflow[index]?.where?.selectors;
if (currentSelectors && currentSelectors.length > 0) {
currentSelectors.forEach((selector) => selectorsSet.add(selector));
return Array.from(selectorsSet);
}
}
return [];
}
/** /**
* Returns the context object from given Page and the current workflow.\ * Returns the context object from given Page and the current workflow.\
* \ * \
@@ -130,45 +185,67 @@ export default class Interpreter extends EventEmitter {
* @param workflow Current **initialized** workflow (array of where-what pairs). * @param workflow Current **initialized** workflow (array of where-what pairs).
* @returns {PageState} State of the current page. * @returns {PageState} State of the current page.
*/ */
private async getState(page: Page, workflow: Workflow): Promise<PageState> { private async getState(page: Page, workflowCopy: Workflow, selectors: string[]): Promise<PageState> {
/** /**
* All the selectors present in the current Workflow * All the selectors present in the current Workflow
*/ */
const selectors = Preprocessor.extractSelectors(workflow); // const selectors = Preprocessor.extractSelectors(workflow);
// console.log("Current selectors:", selectors);
/** /**
* Determines whether the element targetted by the selector is [actionable](https://playwright.dev/docs/actionability). * Determines whether the element targetted by the selector is [actionable](https://playwright.dev/docs/actionability).
* @param selector Selector to be queried * @param selector Selector to be queried
* @returns True if the targetted element is actionable, false otherwise. * @returns True if the targetted element is actionable, false otherwise.
*/ */
const actionable = async (selector: string): Promise<boolean> => { // const actionable = async (selector: string): Promise<boolean> => {
try { // try {
const proms = [ // const proms = [
page.isEnabled(selector, { timeout: 500 }), // page.isEnabled(selector, { timeout: 10000 }),
page.isVisible(selector, { timeout: 500 }), // page.isVisible(selector, { timeout: 10000 }),
]; // ];
return await Promise.all(proms).then((bools) => bools.every((x) => x)); // return await Promise.all(proms).then((bools) => bools.every((x) => x));
} catch (e) { // } catch (e) {
// log(<Error>e, Level.ERROR); // // log(<Error>e, Level.ERROR);
return false; // return false;
} // }
}; // };
/** /**
* Object of selectors present in the current page. * Object of selectors present in the current page.
*/ */
// const presentSelectors: SelectorArray = await Promise.all(
// selectors.map(async (selector) => {
// if (await actionable(selector)) {
// return [selector];
// }
// return [];
// }),
// ).then((x) => x.flat());
const presentSelectors: SelectorArray = await Promise.all( const presentSelectors: SelectorArray = await Promise.all(
selectors.map(async (selector) => { selectors.map(async (selector) => {
if (await actionable(selector)) { try {
return [selector]; await page.waitForSelector(selector, { state: 'attached' });
} return [selector];
return []; } catch (e) {
}), return [];
}
}),
).then((x) => x.flat()); ).then((x) => x.flat());
const action = workflowCopy[workflowCopy.length - 1];
// console.log("Next action:", action)
let url: any = page.url();
if (action && action.where.url !== url && action.where.url !== "about:blank") {
url = action.where.url;
}
return { return {
url: page.url(), url,
cookies: (await page.context().cookies([page.url()])) cookies: (await page.context().cookies([page.url()]))
.reduce((p, cookie) => ( .reduce((p, cookie) => (
{ {
@@ -326,7 +403,7 @@ export default class Interpreter extends EventEmitter {
await this.options.serializableCallback(scrapeResults); await this.options.serializableCallback(scrapeResults);
}, },
scrapeSchema: async (schema: Record<string, { selector: string; tag: string, attribute: string; }>) => { scrapeSchema: async (schema: Record<string, { selector: string; tag: string, attribute: string; shadow: string}>) => {
await this.ensureScriptsLoaded(page); await this.ensureScriptsLoaded(page);
const scrapeResult = await page.evaluate((schemaObj) => window.scrapeSchema(schemaObj), schema); const scrapeResult = await page.evaluate((schemaObj) => window.scrapeSchema(schemaObj), schema);
@@ -365,6 +442,7 @@ export default class Interpreter extends EventEmitter {
console.log("MERGED results:", mergedResult); console.log("MERGED results:", mergedResult);
await this.options.serializableCallback(mergedResult); await this.options.serializableCallback(mergedResult);
// await this.options.serializableCallback(scrapeResult);
}, },
scrapeList: async (config: { listSelector: string, fields: any, limit?: number, pagination: any }) => { scrapeList: async (config: { listSelector: string, fields: any, limit?: number, pagination: any }) => {
@@ -410,6 +488,16 @@ export default class Interpreter extends EventEmitter {
}), }),
}; };
const executeAction = async (invokee: any, methodName: string, args: any) => {
console.log("Executing action:", methodName, args);
if (!args || Array.isArray(args)) {
await (<any>invokee[methodName])(...(args ?? []));
} else {
await (<any>invokee[methodName])(args);
}
};
for (const step of steps) { for (const step of steps) {
this.log(`Launching ${String(step.action)}`, Level.LOG); this.log(`Launching ${String(step.action)}`, Level.LOG);
@@ -427,10 +515,24 @@ export default class Interpreter extends EventEmitter {
invokee = invokee[level]; invokee = invokee[level];
} }
if (!step.args || Array.isArray(step.args)) { if (methodName === 'waitForLoadState') {
await (<any>invokee[methodName])(...(step.args ?? [])); try {
await executeAction(invokee, methodName, step.args);
} catch (error) {
await executeAction(invokee, methodName, 'domcontentloaded');
}
} else if (methodName === 'click') {
try {
await executeAction(invokee, methodName, step.args);
} catch (error) {
try{
await executeAction(invokee, methodName, [step.args[0], { force: true }]);
} catch (error) {
continue
}
}
} else { } else {
await (<any>invokee[methodName])(step.args); await executeAction(invokee, methodName, step.args);
} }
} }
@@ -475,6 +577,8 @@ export default class Interpreter extends EventEmitter {
case 'clickNext': case 'clickNext':
const pageResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); const pageResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
// console.log("Page results:", pageResults);
// Filter out already scraped items // Filter out already scraped items
const newResults = pageResults.filter(item => { const newResults = pageResults.filter(item => {
const uniqueKey = JSON.stringify(item); const uniqueKey = JSON.stringify(item);
@@ -482,9 +586,9 @@ export default class Interpreter extends EventEmitter {
scrapedItems.add(uniqueKey); // Mark as scraped scrapedItems.add(uniqueKey); // Mark as scraped
return true; return true;
}); });
allResults = allResults.concat(newResults); allResults = allResults.concat(newResults);
if (config.limit && allResults.length >= config.limit) { if (config.limit && allResults.length >= config.limit) {
return allResults.slice(0, config.limit); return allResults.slice(0, config.limit);
} }
@@ -494,7 +598,7 @@ export default class Interpreter extends EventEmitter {
return allResults; // No more pages to scrape return allResults; // No more pages to scrape
} }
await Promise.all([ await Promise.all([
nextButton.click(), nextButton.dispatchEvent('click'),
page.waitForNavigation({ waitUntil: 'networkidle' }) page.waitForNavigation({ waitUntil: 'networkidle' })
]); ]);
@@ -510,7 +614,7 @@ export default class Interpreter extends EventEmitter {
return allResults; return allResults;
} }
// Click the 'Load More' button to load additional items // Click the 'Load More' button to load additional items
await loadMoreButton.click(); await loadMoreButton.dispatchEvent('click');
await page.waitForTimeout(2000); // Wait for new items to load await page.waitForTimeout(2000); // Wait for new items to load
// After clicking 'Load More', scroll down to load more items // After clicking 'Load More', scroll down to load more items
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
@@ -546,11 +650,66 @@ export default class Interpreter extends EventEmitter {
return allResults; return allResults;
} }
private getMatchingActionId(workflow: Workflow, pageState: PageState, usedActions: string[]) {
for (let actionId = workflow.length - 1; actionId >= 0; actionId--) {
const step = workflow[actionId];
const isApplicable = this.applicable(step.where, pageState, usedActions);
console.log("-------------------------------------------------------------");
console.log(`Where:`, step.where);
console.log(`Page state:`, pageState);
console.log(`Match result: ${isApplicable}`);
console.log("-------------------------------------------------------------");
if (isApplicable) {
return actionId;
}
}
}
private removeShadowSelectors(workflow: Workflow) {
for (let actionId = workflow.length - 1; actionId >= 0; actionId--) {
const step = workflow[actionId];
// Check if step has where and selectors
if (step.where && Array.isArray(step.where.selectors)) {
// Filter out selectors that contain ">>"
step.where.selectors = step.where.selectors.filter(selector => !selector.includes('>>'));
}
}
return workflow;
}
private removeSpecialSelectors(workflow: Workflow) {
for (let actionId = workflow.length - 1; actionId >= 0; actionId--) {
const step = workflow[actionId];
if (step.where && Array.isArray(step.where.selectors)) {
// Filter out if selector has EITHER ":>>" OR ">>"
step.where.selectors = step.where.selectors.filter(selector =>
!(selector.includes(':>>') || selector.includes('>>'))
);
}
}
return workflow;
}
private async runLoop(p: Page, workflow: Workflow) { private async runLoop(p: Page, workflow: Workflow) {
let workflowCopy: Workflow = JSON.parse(JSON.stringify(workflow));
workflowCopy = this.removeSpecialSelectors(workflowCopy);
// apply ad-blocker to the current page // apply ad-blocker to the current page
await this.applyAdBlocker(p); try {
await this.applyAdBlocker(p);
} catch (error) {
this.log(`Failed to apply ad-blocker: ${error.message}`, Level.ERROR);
}
const usedActions: string[] = []; const usedActions: string[] = [];
let selectors: string[] = [];
let lastAction = null; let lastAction = null;
let actionId = -1
let repeatCount = 0; let repeatCount = 0;
/** /**
@@ -559,7 +718,7 @@ export default class Interpreter extends EventEmitter {
* e.g. via `enqueueLinks`. * e.g. via `enqueueLinks`.
*/ */
p.on('popup', (popup) => { p.on('popup', (popup) => {
this.concurrency.addJob(() => this.runLoop(popup, workflow)); this.concurrency.addJob(() => this.runLoop(popup, workflowCopy));
}); });
/* eslint no-constant-condition: ["warn", { "checkLoops": false }] */ /* eslint no-constant-condition: ["warn", { "checkLoops": false }] */
@@ -578,8 +737,11 @@ export default class Interpreter extends EventEmitter {
} }
let pageState = {}; let pageState = {};
let getStateTest = "Hello";
try { try {
pageState = await this.getState(p, workflow); pageState = await this.getState(p, workflowCopy, selectors);
selectors = [];
console.log("Empty selectors:", selectors)
} catch (e: any) { } catch (e: any) {
this.log('The browser has been closed.'); this.log('The browser has been closed.');
return; return;
@@ -589,32 +751,52 @@ export default class Interpreter extends EventEmitter {
this.log(`Current state is: \n${JSON.stringify(pageState, null, 2)}`, Level.WARN); this.log(`Current state is: \n${JSON.stringify(pageState, null, 2)}`, Level.WARN);
} }
const actionId = workflow.findIndex((step) => { // const actionId = workflow.findIndex((step) => {
const isApplicable = this.applicable(step.where, pageState, usedActions); // const isApplicable = this.applicable(step.where, pageState, usedActions);
console.log(`Where:`, step.where); // console.log("-------------------------------------------------------------");
console.log(`Page state:`, pageState); // console.log(`Where:`, step.where);
console.log(`Match result: ${isApplicable}`); // console.log(`Page state:`, pageState);
return isApplicable; // console.log(`Match result: ${isApplicable}`);
}); // console.log("-------------------------------------------------------------");
// return isApplicable;
// });
const action = workflow[actionId]; actionId = this.getMatchingActionId(workflowCopy, pageState, usedActions);
const action = workflowCopy[actionId];
console.log("MATCHED ACTION:", action);
console.log("MATCHED ACTION ID:", actionId);
this.log(`Matched ${JSON.stringify(action?.where)}`, Level.LOG); this.log(`Matched ${JSON.stringify(action?.where)}`, Level.LOG);
if (action) { // action is matched if (action) { // action is matched
if (this.options.debugChannel?.activeId) { if (this.options.debugChannel?.activeId) {
this.options.debugChannel.activeId(actionId); this.options.debugChannel.activeId(actionId);
} }
repeatCount = action === lastAction ? repeatCount + 1 : 0; repeatCount = action === lastAction ? repeatCount + 1 : 0;
if (this.options.maxRepeats && repeatCount >= this.options.maxRepeats) {
console.log("REPEAT COUNT", repeatCount);
if (this.options.maxRepeats && repeatCount > this.options.maxRepeats) {
return; return;
} }
lastAction = action; lastAction = action;
try { try {
console.log("Carrying out:", action.what);
await this.carryOutSteps(p, action.what); await this.carryOutSteps(p, action.what);
usedActions.push(action.id ?? 'undefined'); usedActions.push(action.id ?? 'undefined');
workflowCopy.splice(actionId, 1);
console.log(`Action with ID ${action.id} removed from the workflow copy.`);
// const newSelectors = this.getPreviousSelectors(workflow, actionId);
const newSelectors = this.getSelectors(workflowCopy);
newSelectors.forEach(selector => {
if (!selectors.includes(selector)) {
selectors.push(selector);
}
});
} catch (e) { } catch (e) {
this.log(<Error>e, Level.ERROR); this.log(<Error>e, Level.ERROR);
} }
@@ -643,6 +825,8 @@ export default class Interpreter extends EventEmitter {
public async run(page: Page, params?: ParamType): Promise<void> { public async run(page: Page, params?: ParamType): Promise<void> {
this.log('Starting the workflow.', Level.LOG); this.log('Starting the workflow.', Level.LOG);
const context = page.context(); const context = page.context();
page.setDefaultNavigationTimeout(100000);
// Check proxy settings from context options // Check proxy settings from context options
const contextOptions = (context as any)._options; const contextOptions = (context as any)._options;

View File

@@ -3,36 +3,36 @@
*/ */
export default class Concurrency { export default class Concurrency {
/** /**
* Maximum number of workers running in parallel. If set to `null`, there is no limit. * Maximum number of workers running in parallel. If set to `null`, there is no limit.
*/ */
maxConcurrency: number = 1; maxConcurrency: number = 1;
/** /**
* Number of currently active workers. * Number of currently active workers.
*/ */
activeWorkers: number = 0; activeWorkers: number = 0;
/** /**
* Queue of jobs waiting to be completed. * Queue of jobs waiting to be completed.
*/ */
private jobQueue: Function[] = []; private jobQueue: Function[] = [];
/** /**
* "Resolve" callbacks of the waitForCompletion() promises. * "Resolve" callbacks of the waitForCompletion() promises.
*/ */
private waiting: Function[] = []; private waiting: Function[] = [];
/** /**
* Constructs a new instance of concurrency manager. * Constructs a new instance of concurrency manager.
* @param {number} maxConcurrency Maximum number of workers running in parallel. * @param {number} maxConcurrency Maximum number of workers running in parallel.
*/ */
constructor(maxConcurrency: number) { constructor(maxConcurrency: number) {
this.maxConcurrency = maxConcurrency; this.maxConcurrency = maxConcurrency;
} }
/** /**
* Takes a waiting job out of the queue and runs it. * Takes a waiting job out of the queue and runs it.
*/ */
private runNextJob(): void { private runNextJob(): void {
const job = this.jobQueue.pop(); const job = this.jobQueue.pop();
@@ -53,12 +53,12 @@ export default class Concurrency {
} }
/** /**
* Pass a job (a time-demanding async function) to the concurrency manager. \ * Pass a job (a time-demanding async function) to the concurrency manager. \
* The time of the job's execution depends on the concurrency manager itself * The time of the job's execution depends on the concurrency manager itself
* (given a generous enough `maxConcurrency` value, it might be immediate, * (given a generous enough `maxConcurrency` value, it might be immediate,
* but this is not guaranteed). * but this is not guaranteed).
* @param worker Async function to be executed (job to be processed). * @param worker Async function to be executed (job to be processed).
*/ */
addJob(job: () => Promise<any>): void { addJob(job: () => Promise<any>): void {
// console.debug("Adding a worker!"); // console.debug("Adding a worker!");
this.jobQueue.push(job); this.jobQueue.push(job);
@@ -72,11 +72,11 @@ export default class Concurrency {
} }
/** /**
* Waits until there is no running nor waiting job. \ * Waits until there is no running nor waiting job. \
* If the concurrency manager is idle at the time of calling this function, * If the concurrency manager is idle at the time of calling this function,
* it waits until at least one job is completed (can be "presubscribed"). * it waits until at least one job is completed (can be "presubscribed").
* @returns Promise, resolved after there is no running/waiting worker. * @returns Promise, resolved after there is no running/waiting worker.
*/ */
waitForCompletion(): Promise<void> { waitForCompletion(): Promise<void> {
return new Promise((res) => { return new Promise((res) => {
this.waiting.push(res); this.waiting.push(res);

View File

@@ -1,6 +1,6 @@
{ {
"name": "maxun", "name": "maxun",
"version": "0.0.4", "version": "0.0.6",
"author": "Maxun", "author": "Maxun",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
@@ -36,13 +36,18 @@
"fortawesome": "^0.0.1-security", "fortawesome": "^0.0.1-security",
"google-auth-library": "^9.14.1", "google-auth-library": "^9.14.1",
"googleapis": "^144.0.0", "googleapis": "^144.0.0",
"i18next": "^24.0.2",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-http-backend": "^3.0.1",
"idcac-playwright": "^0.1.3",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
"joi": "^17.6.0", "joi": "^17.6.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"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.5", "maxun-core": "^0.0.8",
"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",
@@ -56,11 +61,13 @@
"react": "^18.0.0", "react": "^18.0.0",
"react-dom": "^18.0.0", "react-dom": "^18.0.0",
"react-highlight": "0.15.0", "react-highlight": "0.15.0",
"react-i18next": "^15.1.3",
"react-router-dom": "^6.26.1", "react-router-dom": "^6.26.1",
"react-simple-code-editor": "^0.11.2", "react-simple-code-editor": "^0.11.2",
"react-transition-group": "^4.4.2", "react-transition-group": "^4.4.2",
"sequelize": "^6.37.3", "sequelize": "^6.37.3",
"sequelize-typescript": "^2.1.6", "sequelize-typescript": "^2.1.6",
"sharp": "^0.33.5",
"socket.io": "^4.4.1", "socket.io": "^4.4.1",
"socket.io-client": "^4.4.1", "socket.io-client": "^4.4.1",
"styled-components": "^5.3.3", "styled-components": "^5.3.3",
@@ -92,6 +99,7 @@
"@types/cookie-parser": "^1.4.7", "@types/cookie-parser": "^1.4.7",
"@types/express": "^4.17.13", "@types/express": "^4.17.13",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/lodash": "^4.17.14",
"@types/loglevel": "^1.6.3", "@types/loglevel": "^1.6.3",
"@types/node": "22.7.9", "@types/node": "22.7.9",
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11",

181
perf/performance.ts Normal file
View File

@@ -0,0 +1,181 @@
// Frontend Performance Monitoring
export class FrontendPerformanceMonitor {
private metrics: {
fps: number[];
memoryUsage: MemoryInfo[];
renderTime: number[];
eventLatency: number[];
};
private lastFrameTime: number;
private frameCount: number;
constructor() {
this.metrics = {
fps: [],
memoryUsage: [],
renderTime: [],
eventLatency: [],
};
this.lastFrameTime = performance.now();
this.frameCount = 0;
// Start monitoring
this.startMonitoring();
}
private startMonitoring(): void {
// Monitor FPS
const measureFPS = () => {
const currentTime = performance.now();
const elapsed = currentTime - this.lastFrameTime;
this.frameCount++;
if (elapsed >= 1000) { // Calculate FPS every second
const fps = Math.round((this.frameCount * 1000) / elapsed);
this.metrics.fps.push(fps);
this.frameCount = 0;
this.lastFrameTime = currentTime;
}
requestAnimationFrame(measureFPS);
};
requestAnimationFrame(measureFPS);
// Monitor Memory Usage
if (window.performance && (performance as any).memory) {
setInterval(() => {
const memory = (performance as any).memory;
this.metrics.memoryUsage.push({
usedJSHeapSize: memory.usedJSHeapSize,
totalJSHeapSize: memory.totalJSHeapSize,
timestamp: Date.now()
});
}, 1000);
}
}
// Monitor Canvas Render Time
public measureRenderTime(renderFunction: () => void): void {
const startTime = performance.now();
renderFunction();
const endTime = performance.now();
this.metrics.renderTime.push(endTime - startTime);
}
// Monitor Event Latency
public measureEventLatency(event: MouseEvent | KeyboardEvent): void {
const latency = performance.now() - event.timeStamp;
this.metrics.eventLatency.push(latency);
}
// Get Performance Report
public getPerformanceReport(): PerformanceReport {
return {
averageFPS: this.calculateAverage(this.metrics.fps),
averageRenderTime: this.calculateAverage(this.metrics.renderTime),
averageEventLatency: this.calculateAverage(this.metrics.eventLatency),
memoryTrend: this.getMemoryTrend(),
lastMemoryUsage: this.metrics.memoryUsage[this.metrics.memoryUsage.length - 1]
};
}
private calculateAverage(array: number[]): number {
return array.length ? array.reduce((a, b) => a + b) / array.length : 0;
}
private getMemoryTrend(): MemoryTrend {
if (this.metrics.memoryUsage.length < 2) return 'stable';
const latest = this.metrics.memoryUsage[this.metrics.memoryUsage.length - 1];
const previous = this.metrics.memoryUsage[this.metrics.memoryUsage.length - 2];
const change = latest.usedJSHeapSize - previous.usedJSHeapSize;
if (change > 1000000) return 'increasing'; // 1MB threshold
if (change < -1000000) return 'decreasing';
return 'stable';
}
}
// Backend Performance Monitoring
export class BackendPerformanceMonitor {
private metrics: {
screenshotTimes: number[];
emitTimes: number[];
memoryUsage: NodeJS.MemoryUsage[];
};
constructor() {
this.metrics = {
screenshotTimes: [],
emitTimes: [],
memoryUsage: []
};
this.startMonitoring();
}
private startMonitoring(): void {
// Monitor Memory Usage
setInterval(() => {
this.metrics.memoryUsage.push(process.memoryUsage());
}, 1000);
}
public async measureScreenshotPerformance(
makeScreenshot: () => Promise<void>
): Promise<void> {
const startTime = process.hrtime();
await makeScreenshot();
const [seconds, nanoseconds] = process.hrtime(startTime);
this.metrics.screenshotTimes.push(seconds * 1000 + nanoseconds / 1000000);
}
public measureEmitPerformance(emitFunction: () => void): void {
const startTime = process.hrtime();
emitFunction();
const [seconds, nanoseconds] = process.hrtime(startTime);
this.metrics.emitTimes.push(seconds * 1000 + nanoseconds / 1000000);
}
public getPerformanceReport(): BackendPerformanceReport {
return {
averageScreenshotTime: this.calculateAverage(this.metrics.screenshotTimes),
averageEmitTime: this.calculateAverage(this.metrics.emitTimes),
currentMemoryUsage: this.metrics.memoryUsage[this.metrics.memoryUsage.length - 1],
memoryTrend: this.getMemoryTrend()
};
}
private calculateAverage(array: number[]): number {
return array.length ? array.reduce((a, b) => a + b) / array.length : 0;
}
private getMemoryTrend(): MemoryTrend {
if (this.metrics.memoryUsage.length < 2) return 'stable';
const latest = this.metrics.memoryUsage[this.metrics.memoryUsage.length - 1];
const previous = this.metrics.memoryUsage[this.metrics.memoryUsage.length - 2];
const change = latest.heapUsed - previous.heapUsed;
if (change > 1000000) return 'increasing';
if (change < -1000000) return 'decreasing';
return 'stable';
}
}
interface MemoryInfo {
usedJSHeapSize: number;
totalJSHeapSize: number;
timestamp: number;
}
type MemoryTrend = 'increasing' | 'decreasing' | 'stable';
interface PerformanceReport {
averageFPS: number;
averageRenderTime: number;
averageEventLatency: number;
memoryTrend: MemoryTrend;
lastMemoryUsage: MemoryInfo;
}
interface BackendPerformanceReport {
averageScreenshotTime: number;
averageEmitTime: number;
currentMemoryUsage: NodeJS.MemoryUsage;
memoryTrend: MemoryTrend;
}

492
public/locales/de.json Normal file
View File

@@ -0,0 +1,492 @@
{
"login": {
"title": "Willkommen zurück!",
"email": "Geben Sie Ihre geschäftliche E-Mail-Adresse ein",
"password": "Passwort",
"button": "Einloggen",
"loading": "Lädt",
"register_prompt": "Noch keinen Account?",
"register_link": "Registrieren",
"welcome_notification": "Willkommen bei Maxun!",
"error_notification": "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut."
},
"register": {
"title": "Konto registrieren",
"email": "Geben Sie Ihre geschäftliche E-Mail-Adresse ein",
"password": "Passwort",
"button": "Registrieren",
"loading": "Lädt",
"register_prompt": "Bereits ein Konto?",
"login_link": "Einloggen",
"welcome_notification": "Willkommen bei Maxun!",
"error_notification": "Registrierung fehlgeschlagen. Bitte versuchen Sie es erneut."
},
"recordingtable": {
"run": "Ausführen",
"name": "Name",
"schedule": "Zeitplan",
"integrate": "Integrieren",
"settings": "Einstellungen",
"options": "Optionen",
"heading": "Meine Roboter",
"new": "Roboter erstellen",
"modal": {
"title": "Geben Sie die URL ein",
"label": "URL",
"button": "Aufnahme starten"
},
"edit": "Bearbeiten",
"delete": "Löschen",
"duplicate": "Duplizieren",
"notifications": {
"delete_warning": "Roboter kann nicht gelöscht werden, da zugehörige Ausführungen vorhanden sind",
"delete_success": "Roboter erfolgreich gelöscht"
}
},
"mainmenu": {
"recordings": "Roboter",
"runs": "Ausführungen",
"proxy": "Proxy",
"apikey": "API-Schlüssel",
"feedback": "Maxun Cloud beitreten",
"apidocs": "Website zu API"
},
"runstable": {
"runs": "Alle Ausführungen",
"runStatus": "Status",
"runName": "Name",
"startedAt": "Gestartet am",
"finishedAt": "Beendet am",
"delete": "Löschen",
"settings": "Einstellungen",
"search": "Ausführungen suchen...",
"notifications": {
"no_runs": "Keine Ausführungen gefunden. Bitte versuchen Sie es erneut.",
"delete_success": "Ausführung erfolgreich gelöscht"
}
},
"proxy": {
"title": "Proxy-Konfiguration",
"tab_standard": "Standard-Proxy",
"tab_rotation": "Automatische Proxy-Rotation",
"server_url": "Proxy-Server-URL",
"server_url_helper": "Proxy für alle Roboter. HTTP- und SOCKS-Proxys werden unterstützt. Beispiel http://myproxy.com:3128 oder socks5://myproxy.com:3128. Kurzform myproxy.com:3128 wird als HTTP-Proxy behandelt.",
"requires_auth": "Authentifizierung erforderlich?",
"username": "Benutzername",
"password": "Passwort",
"add_proxy": "Proxy hinzufügen",
"test_proxy": "Proxy testen",
"remove_proxy": "Proxy entfernen",
"table": {
"proxy_url": "Proxy-URL",
"requires_auth": "Authentifizierung erforderlich"
},
"coming_soon": "Demnächst verfügbar - In Open Source (Basis-Rotation) & Cloud (Erweiterte Rotation). Wenn Sie die Infrastruktur nicht selbst verwalten möchten, tragen Sie sich in unsere Cloud-Warteliste ein.",
"join_waitlist": "Maxun Cloud Warteliste beitreten",
"alert": {
"title": "Wenn Ihr Proxy einen Benutzernamen und ein Passwort erfordert, geben Sie diese immer separat von der Proxy-URL an.",
"right_way": "Der richtige Weg",
"wrong_way": "Der falsche Weg",
"proxy_url": "Proxy-URL:",
"username": "Benutzername:",
"password": "Passwort:"
},
"notifications": {
"config_success": "Proxy-Konfiguration erfolgreich übermittelt",
"config_error": "Fehler beim Übermitteln der Proxy-Konfiguration. Bitte erneut versuchen.",
"test_success": "Proxy-Konfiguration funktioniert",
"test_error": "Fehler beim Testen der Proxy-Konfiguration. Bitte erneut versuchen.",
"fetch_success": "Proxy-Konfiguration erfolgreich abgerufen",
"remove_success": "Proxy-Konfiguration erfolgreich entfernt",
"remove_error": "Fehler beim Entfernen der Proxy-Konfiguration. Bitte erneut versuchen."
}
},
"apikey": {
"title": "API-Schlüssel verwalten",
"default_name": "Maxun API-Schlüssel",
"table": {
"name": "API-Schlüssel Name",
"key": "API-Schlüssel",
"actions": "Aktionen"
},
"actions": {
"copy": "Kopieren",
"show": "Anzeigen",
"hide": "Ausblenden",
"delete": "Löschen"
},
"no_key_message": "Sie haben noch keinen API-Schlüssel generiert.",
"generate_button": "API-Schlüssel generieren",
"notifications": {
"fetch_error": "API-Schlüssel konnte nicht abgerufen werden - ${error}",
"generate_success": "API-Schlüssel erfolgreich generiert",
"generate_error": "API-Schlüssel konnte nicht generiert werden - ${error}",
"delete_success": "API-Schlüssel erfolgreich gelöscht",
"delete_error": "API-Schlüssel konnte nicht gelöscht werden - ${error}",
"copy_success": "API-Schlüssel erfolgreich kopiert"
}
},
"action_description": {
"text": {
"title": "Text erfassen",
"description": "Fahren Sie über die Texte, die Sie extrahieren möchten, und klicken Sie, um sie auszuwählen"
},
"screenshot": {
"title": "Screenshot erfassen",
"description": "Erfassen Sie einen Teil- oder Vollbildschirmfoto der aktuellen Seite."
},
"list": {
"title": "Liste erfassen",
"description": "Fahren Sie über die Liste, die Sie extrahieren möchten. Nach der Auswahl können Sie über alle Texte in der ausgewählten Liste fahren. Klicken Sie zum Auswählen."
},
"default": {
"title": "Welche Daten möchten Sie extrahieren?",
"description": "Ein Roboter ist darauf ausgelegt, eine Aktion nach der anderen auszuführen. Sie können eine der folgenden Optionen wählen."
},
"list_stages": {
"initial": "Wählen Sie die Liste aus, die Sie extrahieren möchten, zusammen mit den darin enthaltenen Texten",
"pagination": "Wählen Sie aus, wie der Roboter den Rest der Liste erfassen kann",
"limit": "Wählen Sie die Anzahl der zu extrahierenden Elemente",
"complete": "Erfassung ist abgeschlossen"
}
},
"right_panel": {
"buttons": {
"capture_list": "Liste erfassen",
"capture_text": "Text erfassen",
"capture_screenshot": "Screenshot erfassen",
"confirm": "Bestätigen",
"discard": "Verwerfen",
"confirm_capture": "Erfassung bestätigen",
"confirm_pagination": "Bestätigen",
"confirm_limit": "Bestätigen",
"finish_capture": "Erfassung abschließen",
"back": "Zurück",
"finish": "Fertig",
"cancel": "Abbrechen",
"delete": "Löschen"
},
"screenshot": {
"capture_fullpage": "Vollständige Seite erfassen",
"capture_visible": "Sichtbaren Bereich erfassen",
"display_fullpage": "Vollständige Seite Screenshot",
"display_visible": "Sichtbarer Bereich Screenshot"
},
"pagination": {
"title": "Wie können wir das nächste Listenelement auf der Seite finden?",
"click_next": "Auf 'Weiter' klicken, um zur nächsten Seite zu navigieren",
"click_load_more": "Auf 'Mehr laden' klicken, um weitere Elemente zu laden",
"scroll_down": "Nach unten scrollen, um mehr Elemente zu laden",
"scroll_up": "Nach oben scrollen, um mehr Elemente zu laden",
"none": "Keine weiteren Elemente zu laden"
},
"limit": {
"title": "Wie viele Zeilen möchten Sie maximal extrahieren?",
"custom": "Benutzerdefiniert",
"enter_number": "Nummer eingeben"
},
"fields": {
"label": "Bezeichnung",
"data": "Daten",
"field_label": "Feldbezeichnung",
"field_data": "Felddaten"
},
"messages": {
"list_selected": "Liste erfolgreich ausgewählt"
},
"errors": {
"select_pagination": "Bitte wählen Sie einen Paginierungstyp aus.",
"select_pagination_element": "Bitte wählen Sie zuerst das Paginierungselement aus.",
"select_limit": "Bitte wählen Sie ein Limit oder geben Sie ein benutzerdefiniertes Limit ein.",
"invalid_limit": "Bitte geben Sie ein gültiges Limit ein.",
"confirm_text_fields": "Bitte bestätigen Sie alle Textfelder",
"unable_create_settings": "Listeneinstellungen können nicht erstellt werden. Stellen Sie sicher, dass Sie ein Feld für die Liste definiert haben.",
"capture_text_discarded": "Texterfassung verworfen",
"capture_list_discarded": "Listenerfassung verworfen"
}
},
"save_recording": {
"title": "Roboter speichern",
"robot_name": "Roboter Name",
"buttons": {
"save": "Speichern",
"confirm": "Bestätigen"
},
"notifications": {
"save_success": "Roboter erfolgreich gespeichert"
},
"errors": {
"user_not_logged": "Benutzer nicht angemeldet. Aufnahme kann nicht gespeichert werden.",
"exists_warning": "Ein Roboter mit diesem Namen existiert bereits, bitte bestätigen Sie das Überschreiben des Roboters."
},
"tooltips": {
"saving": "Workflow wird optimiert und gespeichert"
}
},
"browser_recording": {
"modal": {
"confirm_discard": "Sind Sie sicher, dass Sie die Aufnahme verwerfen möchten?"
},
"notifications": {
"terminated": "Aktuelle Aufnahme wurde beendet"
}
},
"interpretation_log": {
"titles": {
"output_preview": "Vorschau der Ausgabedaten",
"screenshot": "Bildschirmfoto"
},
"messages": {
"additional_rows": "Weitere Datenzeilen werden nach Abschluss der Aufnahme extrahiert.",
"successful_training": "Sie haben den Roboter erfolgreich für Aktionen trainiert! Klicken Sie auf die Schaltfläche unten, um eine Vorschau der Daten zu erhalten, die Ihr Roboter extrahieren wird.",
"no_selection": "Sie haben noch nichts zur Extraktion ausgewählt. Sobald Sie dies tun, wird der Roboter hier eine Vorschau Ihrer Auswahl anzeigen."
},
"data_sections": {
"binary_received": "---------- Binäre Ausgabedaten empfangen ----------",
"serializable_received": "---------- Serialisierbare Ausgabedaten empfangen ----------",
"mimetype": "Medientyp: ",
"image_below": "Bild wird unten angezeigt:",
"separator": "--------------------------------------------------"
},
"notifications": {
"reset_success": "Vorschau erfolgreich zurückgesetzt"
}
},
"interpretation_buttons": {
"buttons": {
"preview": "Vorschau der Ausgabedaten anzeigen",
"reset": "Zurücksetzen",
"yes": "Ja",
"no": "Nein"
},
"messages": {
"extracting": "Daten werden extrahiert...bitte warten Sie 10 Sekunden bis 1 Minute",
"restart_required": "Bitte starten Sie die Interpretation nach der Aktualisierung der Aufnahme neu",
"run_finished": "Durchlauf beendet",
"run_failed": "Start fehlgeschlagen"
},
"modal": {
"use_previous": "Möchten Sie Ihre vorherige Auswahl als Bedingung für diese Aktion verwenden?",
"previous_action": "Ihre vorherige Aktion war: ",
"element_text": "auf einem Element mit Text "
}
},
"recording_page": {
"loader": {
"browser_startup": "Browser wird gestartet...Navigation zu {{url}}"
}
},
"integration_settings": {
"title": "Mit Google Sheet integrieren",
"descriptions": {
"sync_info": "Wenn Sie diese Option aktivieren, werden bei jeder erfolgreichen Ausführung dieses Roboters die erfassten Daten in Ihrem Google Sheet ergänzt.",
"authenticated_as": "Authentifiziert als: {{email}}"
},
"alerts": {
"success": {
"title": "Google Sheet erfolgreich integriert.",
"content": "Jedes Mal, wenn dieser Roboter eine erfolgreiche Ausführung erstellt, werden die erfassten Daten Ihrem Google Sheet {{sheetName}} hinzugefügt. Sie können die Datenaktualisierungen",
"here": "hier",
"note": "Hinweis:",
"sync_limitation": "Die vor der Integration mit Google Sheets extrahierten Daten werden nicht im Google Sheet synchronisiert. Nur die nach der Integration extrahierten Daten werden synchronisiert."
}
},
"buttons": {
"authenticate": "Mit Google authentifizieren",
"fetch_sheets": "Google Sheets abrufen",
"remove_integration": "Integration entfernen",
"submit": "Absenden"
},
"fields": {
"select_sheet": "Google Sheet auswählen",
"selected_sheet": "Ausgewähltes Sheet: {{name}} (ID: {{id}})"
}
},
"robot_duplication": {
"title": "Roboter duplizieren",
"descriptions": {
"purpose": "Die Roboter-Duplizierung ist nützlich, um Daten von Seiten mit der gleichen Struktur zu extrahieren.",
"example": "Beispiel: Wenn Sie einen Roboter für {{url1}} erstellt haben, können Sie ihn duplizieren, um ähnliche Seiten wie {{url2}} zu durchsuchen, ohne einen Roboter von Grund auf neu zu trainieren.",
"warning": "⚠️ Stellen Sie sicher, dass die neue Seite die gleiche Struktur wie die Originalseite hat."
},
"fields": {
"target_url": "Roboter Ziel-URL"
},
"buttons": {
"duplicate": "Roboter duplizieren",
"cancel": "Abbrechen"
},
"notifications": {
"robot_not_found": "Roboterdetails konnten nicht gefunden werden. Bitte versuchen Sie es erneut.",
"url_required": "Ziel-URL ist erforderlich.",
"duplicate_success": "Roboter erfolgreich dupliziert.",
"duplicate_error": "Fehler beim Aktualisieren der Ziel-URL. Bitte versuchen Sie es erneut.",
"unknown_error": "Beim Aktualisieren der Ziel-URL ist ein Fehler aufgetreten."
}
},
"robot_settings": {
"title": "Roboter-Einstellungen",
"target_url": "Roboter-Ziel-URL",
"robot_id": "Roboter-ID",
"robot_limit": "Roboter-Limit",
"created_by_user": "Erstellt von Benutzer",
"created_at": "Erstellungsdatum des Roboters",
"errors": {
"robot_not_found": "Roboterdetails konnten nicht gefunden werden. Bitte versuchen Sie es erneut."
}
},
"robot_edit": {
"title": "Roboter bearbeiten",
"change_name": "Roboternamen ändern",
"robot_limit": "Roboter-Limit",
"save": "Änderungen speichern",
"cancel": "Abbrechen",
"notifications": {
"update_success": "Roboter erfolgreich aktualisiert.",
"update_failed": "Aktualisierung des Roboters fehlgeschlagen. Bitte erneut versuchen.",
"update_error": "Beim Aktualisieren des Roboters ist ein Fehler aufgetreten."
}
},
"schedule_settings": {
"title": "Zeitplan-Einstellungen",
"run_every": "Ausführen alle",
"start_from": "Beginnen ab",
"on_day": "An Tag",
"at_around": "Um",
"timezone": "Zeitzone",
"buttons": {
"delete_schedule": "Zeitplan löschen",
"save_schedule": "Zeitplan speichern",
"cancel": "Abbrechen"
},
"labels": {
"in_between": "Zwischen",
"run_once_every": "Ausführen alle",
"start_from_label": "Beginnen ab",
"on_day_of_month": "Tag des Monats",
"on_day": {
"st": ".",
"nd": ".",
"rd": ".",
"th": "."
}
}
},
"main_page": {
"notifications": {
"interpretation_success": "Interpretation des Roboters {{name}} erfolgreich",
"interpretation_failed": "Interpretation des Roboters {{name}} fehlgeschlagen",
"run_started": "Roboter wird ausgeführt: {{name}}",
"run_start_failed": "Fehler beim Ausführen des Roboters: {{name}}",
"schedule_success": "Roboter {{name}} erfolgreich geplant",
"schedule_failed": "Planen des Roboters {{name}} fehlgeschlagen",
"abort_success": "Interpretation des Roboters {{name}} erfolgreich abgebrochen",
"abort_failed": "Abbrechen der Interpretation des Roboters {{name}} fehlgeschlagen"
},
"menu": {
"recordings": "Roboter",
"runs": "Ausführungen",
"proxy": "Proxy",
"apikey": "API-Schlüssel"
}
},
"browser_window": {
"attribute_modal": {
"title": "Attribut auswählen",
"notifications": {
"list_select_success": "Liste erfolgreich ausgewählt. Wählen Sie die zu extrahierenden Textdaten.",
"pagination_select_success": "Paginierungselement erfolgreich ausgewählt."
}
},
"attribute_options": {
"anchor": {
"text": "Text: {{text}}",
"url": "URL: {{url}}"
},
"image": {
"alt_text": "Alt-Text: {{altText}}",
"image_url": "Bild-URL: {{imageUrl}}"
},
"default": {
"text": "Text: {{text}}"
}
}
},
"runs_table": {
"run_type_chips": {
"manual_run": "Manuelle Ausführung",
"scheduled_run": "Geplante Ausführung",
"api": "API",
"unknown_run_type": "Unbekannter Ausführungstyp"
},
"run_status_chips": {
"success": "Erfolg",
"running": "Läuft",
"scheduled": "Geplant",
"failed": "Fehlgeschlagen"
},
"run_settings_modal": {
"title": "Ausführungseinstellungen",
"labels": {
"run_id": "Ausführungs-ID",
"run_by_user": "Ausgeführt von Benutzer",
"run_by_schedule": "Ausgeführt nach Zeitplan-ID",
"run_by_api": "Ausgeführt durch API",
"run_type": "Ausführungstyp"
}
}
},
"run_content": {
"tabs": {
"output_data": "Ausgabedaten",
"log": "Protokoll"
},
"empty_output": "Die Ausgabe ist leer.",
"captured_data": {
"title": "Erfasste Daten",
"download_json": "Als JSON herunterladen",
"download_csv": "Als CSV herunterladen"
},
"captured_screenshot": {
"title": "Erfasster Screenshot",
"download": "Screenshot herunterladen",
"render_failed": "Das Bild konnte nicht gerendert werden"
},
"buttons": {
"stop": "Stoppen"
}
},
"navbar": {
"project_name": "Maxun",
"upgrade": {
"button": "Upgrade",
"modal": {
"up_to_date": "🎉 Du bist auf dem neuesten Stand!",
"new_version_available": "Eine neue Version ist verfügbar: {{version}}. Aktualisieren Sie auf die neueste Version für Fehlerkorrekturen, Verbesserungen und neue Funktionen!",
"view_updates": "Alle Updates anzeigen",
"view_updates_link": "hier",
"tabs": {
"manual_setup": "Manuelles Setup-Upgrade",
"docker_setup": "Docker Compose Setup-Upgrade"
}
}
},
"menu_items": {
"logout": "Abmelden",
"discord": "Discord",
"youtube": "YouTube",
"twitter": "Twitter (X)",
"language": "Sprache"
},
"recording": {
"discard": "Verwerfen"
}
},
"language_menu": {
"en": "Englisch",
"es": "Spanisch",
"ja": "Japanisch",
"zh": "Chinesisch",
"de": "Deutsch"
}
}

502
public/locales/en.json Normal file
View File

@@ -0,0 +1,502 @@
{
"login": {
"title": "Welcome Back!",
"email": "Enter Work Email",
"password": "Password",
"button": "Login",
"loading": "Loading",
"register_prompt": "Don't have an account?",
"register_link": "Register",
"welcome_notification": "Welcome to Maxun!",
"error_notification": "Login Failed. Please try again."
},
"register": {
"title": "Register Account",
"email": "Enter Work Email",
"password": "Password",
"button": "Register",
"loading": "Loading",
"register_prompt": "Already have an account?",
"login_link": "Login",
"welcome_notification": "Welcome to Maxun!",
"error_notification": "Registeration Failed. Please try again."
},
"recordingtable":{
"run": "Run",
"name": "Name",
"schedule": "Schedule",
"integrate": "Integrate",
"settings": "Settings",
"options": "Options",
"heading":"My Robots",
"new":"Create Robot",
"modal":{
"title":"Enter the URL",
"label":"URL",
"button":"Start Recording"
},
"edit":"Edit",
"delete":"Delete",
"duplicate":"Duplicate",
"search":"Search Robots...",
"notifications": {
"delete_warning": "Cannot delete robot as it has associated runs",
"delete_success": "Robot deleted successfully"
}
},
"mainmenu":{
"recordings": "Robots",
"runs": "Runs",
"proxy": "Proxy",
"apikey": "API Key",
"feedback":"Join Maxun Cloud",
"apidocs":"Website To API"
},
"runstable":{
"runs":"All Runs",
"runStatus":"Status",
"runName":"Name",
"startedAt":"Started At",
"finishedAt":"Finished At",
"delete":"Delete",
"settings":"Settings",
"search":"Search Runs...",
"notifications": {
"no_runs": "No runs found. Please try again.",
"delete_success": "Run deleted successfully"
}
},
"proxy": {
"title": "Proxy Configuration",
"tab_standard": "Standard Proxy",
"tab_rotation": "Automatic Proxy Rotation",
"server_url": "Proxy Server URL",
"server_url_helper": "Proxy to be used for all robots. HTTP and SOCKS proxies are supported. Example http://myproxy.com:3128 or socks5://myproxy.com:3128. Short form myproxy.com:3128 is considered an HTTP proxy.",
"requires_auth": "Requires Authentication?",
"username": "Username",
"password": "Password",
"add_proxy": "Add Proxy",
"test_proxy": "Test Proxy",
"remove_proxy": "Remove Proxy",
"table": {
"proxy_url": "Proxy URL",
"requires_auth": "Requires Authentication"
},
"coming_soon": "Coming Soon - In Open Source (Basic Rotation) & Cloud (Advanced Rotation). If you don't want to manage the infrastructure, join our cloud waitlist to get early access.",
"join_waitlist": "Join Maxun Cloud Waitlist",
"alert": {
"title": "If your proxy requires a username and password, always provide them separately from the proxy URL.",
"right_way": "The right way",
"wrong_way": "The wrong way",
"proxy_url": "Proxy URL:",
"username": "Username:",
"password": "Password:"
},
"notifications": {
"config_success": "Proxy configuration submitted successfully",
"config_error": "Failed to submit proxy configuration. Try again.",
"test_success": "Proxy configuration is working",
"test_error": "Failed to test proxy configuration. Try again.",
"fetch_success": "Proxy configuration fetched successfully",
"remove_success": "Proxy configuration removed successfully",
"remove_error": "Failed to remove proxy configuration. Try again."
}
},
"apikey": {
"title": "Manage Your API Key",
"default_name": "Maxun API Key",
"table": {
"name": "API Key Name",
"key": "API Key",
"actions": "Actions"
},
"actions": {
"copy": "Copy",
"show": "Show",
"hide": "Hide",
"delete": "Delete"
},
"no_key_message": "You haven't generated an API key yet.",
"generate_button": "Generate API Key",
"notifications": {
"fetch_error": "Failed to fetch API Key - ${error}",
"generate_success": "Generated API Key successfully",
"generate_error": "Failed to generate API Key - ${error}",
"delete_success": "API Key deleted successfully",
"delete_error": "Failed to delete API Key - ${error}",
"copy_success": "Copied API Key successfully"
}
},
"action_description": {
"text": {
"title": "Capture Text",
"description": "Hover over the texts you want to extract and click to select them"
},
"screenshot": {
"title": "Capture Screenshot",
"description": "Capture a partial or full page screenshot of the current page."
},
"list": {
"title": "Capture List",
"description": "Hover over the list you want to extract. Once selected, you can hover over all texts inside the list you selected. Click to select them."
},
"default": {
"title": "What data do you want to extract?",
"description": "A robot is designed to perform one action at a time. You can choose any of the options below."
},
"list_stages": {
"initial": "Select the list you want to extract along with the texts inside it",
"pagination": "Select how the robot can capture the rest of the list",
"limit": "Choose the number of items to extract",
"complete": "Capture is complete"
}
},
"right_panel": {
"buttons": {
"capture_list": "Capture List",
"capture_text": "Capture Text",
"capture_screenshot": "Capture Screenshot",
"confirm": "Confirm",
"discard": "Discard",
"confirm_capture": "Confirm Capture",
"confirm_pagination": "Confirm",
"confirm_limit": "Confirm",
"finish_capture": "Finish Capture",
"back": "Back",
"finish": "Finish",
"cancel": "Cancel",
"delete": "Delete"
},
"screenshot": {
"capture_fullpage": "Capture Fullpage",
"capture_visible": "Capture Visible Part",
"display_fullpage": "Take Fullpage Screenshot",
"display_visible": "Take Visible Part Screenshot"
},
"pagination": {
"title": "How can we find the next list item on the page?",
"click_next": "Click on next to navigate to the next page",
"click_load_more": "Click on load more to load more items",
"scroll_down": "Scroll down to load more items",
"scroll_up": "Scroll up to load more items",
"none": "No more items to load"
},
"limit": {
"title": "What is the maximum number of rows you want to extract?",
"custom": "Custom",
"enter_number": "Enter number"
},
"fields": {
"label": "Label",
"data": "Data",
"field_label": "Field Label",
"field_data": "Field Data"
},
"messages": {
"list_selected": "List Selected Successfully"
},
"errors": {
"select_pagination": "Please select a pagination type.",
"select_pagination_element": "Please select the pagination element first.",
"select_limit": "Please select a limit or enter a custom limit.",
"invalid_limit": "Please enter a valid limit.",
"confirm_text_fields": "Please confirm all text fields",
"unable_create_settings": "Unable to create list settings. Make sure you have defined a field for the list.",
"capture_text_discarded": "Capture Text Discarded",
"capture_list_discarded": "Capture List Discarded"
}
},
"save_recording": {
"title": "Save Robot",
"robot_name": "Robot Name",
"buttons": {
"save": "Save",
"confirm": "Confirm"
},
"notifications": {
"save_success": "Robot saved successfully"
},
"errors": {
"user_not_logged": "User not logged in. Cannot save recording.",
"exists_warning": "Robot with this name already exists, please confirm the Robot's overwrite."
},
"tooltips": {
"saving": "Optimizing and saving the workflow"
}
},
"browser_recording": {
"modal": {
"confirm_discard": "Are you sure you want to discard the recording?"
},
"notifications": {
"terminated": "Current Recording was terminated"
}
},
"interpretation_log": {
"titles": {
"output_preview": "Output Data Preview",
"screenshot": "Screenshot"
},
"messages": {
"additional_rows": "Additional rows of data will be extracted once you finish recording.",
"successful_training": "You've successfully trained the robot to perform actions! Click on the button below to get a preview of the data your robot will extract.",
"no_selection": "It looks like you have not selected anything for extraction yet. Once you do, the robot will show a preview of your selections here."
},
"data_sections": {
"binary_received": "---------- Binary output data received ----------",
"serializable_received": "---------- Serializable output data received ----------",
"mimetype": "mimetype: ",
"image_below": "Image is rendered below:",
"separator": "--------------------------------------------------"
},
"notifications": {
"reset_success": "Output Preview reset successfully"
}
},
"interpretation_buttons": {
"buttons": {
"preview": "Get Preview of Output Data",
"reset": "Reset",
"yes": "Yes",
"no": "No"
},
"messages": {
"extracting": "Extracting data...please wait for 10secs to 1min",
"restart_required": "Please restart the interpretation after updating the recording",
"run_finished": "Run finished",
"run_failed": "Run failed to start"
},
"modal": {
"use_previous": "Do you want to use your previous selection as a condition for performing this action?",
"previous_action": "Your previous action was: ",
"element_text": "on an element with text "
}
},
"recording_page": {
"loader": {
"browser_startup": "Spinning up a browser...Navigating to {{url}}"
}
},
"integration_settings": {
"title": "Integrate with Google Sheet",
"descriptions": {
"sync_info": "If you enable this option, every time this robot runs a task successfully, its captured data will be appended to your Google Sheet.",
"authenticated_as": "Authenticated as: {{email}}"
},
"alerts": {
"success": {
"title": "Google Sheet Integrated Successfully.",
"content": "Every time this robot creates a successful run, its captured data is appended to your {{sheetName}} Google Sheet. You can check the data updates",
"here": "here",
"note": "Note:",
"sync_limitation": "The data extracted before integrating with Google Sheets will not be synced in the Google Sheet. Only the data extracted after the integration will be synced."
}
},
"buttons": {
"authenticate": "Authenticate with Google",
"fetch_sheets": "Fetch Google Spreadsheets",
"remove_integration": "Remove Integration",
"submit": "Submit"
},
"fields": {
"select_sheet": "Select Google Sheet",
"selected_sheet": "Selected Sheet: {{name}} (ID: {{id}})"
},
"errors": {
"auth_error": "Error authenticating with Google",
"fetch_error": "Error fetching spreadsheet files: {{message}}",
"update_error": "Error updating Google Sheet ID: {{message}}",
"remove_error": "Error removing Google Sheets integration: {{message}}"
},
"notifications": {
"sheet_selected": "Google Sheet selected successfully"
}
},
"robot_duplication": {
"title": "Duplicate Robot",
"descriptions": {
"purpose": "Robot duplication is useful to extract data from pages with the same structure.",
"example": "Example: If you've created a robot for {{url1}}, you can duplicate it to scrape similar pages like {{url2}} without training a robot from scratch.",
"warning": "⚠️ Ensure the new page has the same structure as the original page."
},
"fields": {
"target_url": "Robot Target URL"
},
"buttons": {
"duplicate": "Duplicate Robot",
"cancel": "Cancel"
},
"notifications": {
"robot_not_found": "Could not find robot details. Please try again.",
"url_required": "Target URL is required.",
"duplicate_success": "Robot duplicated successfully.",
"duplicate_error": "Failed to update the Target URL. Please try again.",
"unknown_error": "An error occurred while updating the Target URL."
}
},
"robot_settings": {
"title": "Robot Settings",
"target_url": "Robot Target URL",
"robot_id": "Robot ID",
"robot_limit": "Robot Limit",
"created_by_user": "Created By User",
"created_at": "Robot Created At",
"errors": {
"robot_not_found": "Could not find robot details. Please try again."
}
},
"robot_edit": {
"title": "Edit Robot",
"change_name": "Robot Name",
"robot_limit": "Robot Limit",
"save": "Save Changes",
"cancel": "Cancel",
"notifications": {
"update_success": "Robot updated successfully.",
"update_failed": "Failed to update the robot. Please try again.",
"update_error": "An error occurred while updating the robot."
}
},
"schedule_settings": {
"title": "Schedule Settings",
"run_every": "Run every",
"start_from": "Start From",
"on_day": "On day",
"at_around": "At around",
"timezone": "Timezone",
"buttons": {
"delete_schedule": "Delete Schedule",
"save_schedule": "Save Schedule",
"cancel": "Cancel"
},
"labels": {
"in_between": "In Between",
"run_once_every": "Run once every",
"start_from_label": "Start From",
"on_day_of_month": "On Day of the Month",
"on_day": {
"st": "st",
"nd": "nd",
"rd": "rd",
"th": "th"
}
}
},
"main_page": {
"notifications": {
"interpretation_success": "Interpretation of robot {{name}} succeeded",
"interpretation_failed": "Failed to interpret robot {{name}}",
"run_started": "Running robot: {{name}}",
"run_start_failed": "Failed to run robot: {{name}}",
"schedule_success": "Robot {{name}} scheduled successfully",
"schedule_failed": "Failed to schedule robot {{name}}",
"abort_success": "Interpretation of robot {{name}} aborted successfully",
"abort_failed": "Failed to abort the interpretation of robot {{name}}"
},
"menu": {
"recordings": "Robots",
"runs": "Runs",
"proxy": "Proxy",
"apikey": "API Key"
}
},
"browser_window": {
"attribute_modal": {
"title": "Select Attribute",
"notifications": {
"list_select_success": "List has been successfully selected. Please select the text data to extract.",
"pagination_select_success": "Pagination element has been successfully selected."
}
},
"attribute_options": {
"anchor": {
"text": "Text: {{text}}",
"url": "URL: {{url}}"
},
"image": {
"alt_text": "Alt Text: {{altText}}",
"image_url": "Image URL: {{imageUrl}}"
},
"default": {
"text": "Text: {{text}}"
}
}
},
"runs_table": {
"run_type_chips": {
"manual_run": "Manual Run",
"scheduled_run": "Scheduled Run",
"api": "API",
"unknown_run_type": "Unknown Run Type"
},
"run_status_chips": {
"success": "Success",
"running": "Running",
"scheduled": "Scheduled",
"failed": "Failed"
},
"run_settings_modal": {
"title": "Run Settings",
"labels": {
"run_id": "Run ID",
"run_by_user": "Run by User",
"run_by_schedule": "Run by Schedule ID",
"run_by_api": "Run by API",
"run_type": "Run Type"
}
}
},
"run_content": {
"tabs": {
"output_data": "Output Data",
"log": "Log"
},
"empty_output": "The output is empty.",
"captured_data": {
"title": "Captured Data",
"download_json": "Download as JSON",
"download_csv": "Download as CSV"
},
"captured_screenshot": {
"title": "Captured Screenshot",
"download": "Download Screenshot",
"render_failed": "The image failed to render"
},
"buttons": {
"stop": "Stop"
}
},
"navbar": {
"project_name": "Maxun",
"upgrade": {
"button": "Upgrade",
"modal": {
"up_to_date": "🎉 You're up to date!",
"new_version_available": "A new version is available: {{version}}. Upgrade to the latest version for bug fixes, enhancements and new features!",
"view_updates": "View all the new updates",
"view_updates_link": "here",
"tabs": {
"manual_setup": "Manual Setup Upgrade",
"docker_setup": "Docker Compose Setup Upgrade"
}
}
},
"menu_items": {
"logout": "Logout",
"discord": "Discord",
"youtube": "YouTube",
"twitter": "Twitter (X)",
"language": "Language"
},
"recording": {
"discard": "Discard"
}
},
"language_menu": {
"en": "English",
"es": "Spanish",
"ja": "Japanese",
"zh": "Chinese",
"de": "German"
}
}

493
public/locales/es.json Normal file
View File

@@ -0,0 +1,493 @@
{
"login": {
"title": "¡Bienvenido de nuevo!",
"email": "Introducir correo electrónico de trabajo",
"password": "Contraseña",
"button": "Iniciar sesión",
"loading": "Cargando",
"register_prompt": "¿No tienes una cuenta?",
"register_link": "Registrarse",
"welcome_notification": "¡Bienvenido a Maxun!",
"error_notification": "Error al iniciar sesión. Por favor, inténtalo de nuevo."
},
"register": {
"title": "Crear cuenta",
"email": "Introducir correo electrónico de trabajo",
"password": "Contraseña",
"button": "Registrarse",
"loading": "Cargando",
"register_prompt": "¿Ya tienes una cuenta?",
"login_link": "Iniciar sesión",
"welcome_notification": "¡Bienvenido a Maxun!",
"error_notification": "Error en el registro. Por favor, inténtalo de nuevo."
},
"recordingtable": {
"run": "Ejecutar",
"name": "Nombre",
"schedule": "Programar",
"integrate": "Integrar",
"settings": "Ajustes",
"options": "Opciones",
"heading": "Mis Robots",
"new": "Crear Robot",
"modal": {
"title": "Ingresa la URL",
"label": "URL",
"button": "Comenzar grabación"
},
"edit": "Editar",
"delete": "Eliminar",
"duplicate": "Duplicar",
"search": "Buscar robots...",
"notifications": {
"delete_warning": "No se puede eliminar el robot ya que tiene ejecuciones asociadas",
"delete_success": "Robot eliminado exitosamente"
}
},
"mainmenu": {
"recordings": "Robots",
"runs": "Ejecuciones",
"proxy": "Proxy",
"apikey": "Clave API",
"feedback": "Unirse a Maxun Cloud",
"apidocs": "Sitio Web a API"
},
"runstable": {
"runs": "Todas las ejecuciones",
"runStatus": "Estado",
"runName": "Nombre",
"startedAt": "Iniciado el",
"finishedAt": "Finalizado el",
"delete": "Eliminar",
"settings": "Ajustes",
"search": "Buscar ejecuciones...",
"notifications": {
"no_runs": "No se encontraron ejecuciones. Por favor, inténtelo de nuevo.",
"delete_success": "Ejecución eliminada con éxito"
}
},
"proxy": {
"title": "Configuración del Proxy",
"tab_standard": "Proxy Estándar",
"tab_rotation": "Rotación Automática de Proxy",
"server_url": "URL del Servidor Proxy",
"server_url_helper": "Proxy para usar en todos los robots. Se admiten proxies HTTP y SOCKS. Ejemplo http://myproxy.com:3128 o socks5://myproxy.com:3128. La forma corta myproxy.com:3128 se considera un proxy HTTP.",
"requires_auth": "¿Requiere Autenticación?",
"username": "Usuario",
"password": "Contraseña",
"add_proxy": "Agregar Proxy",
"test_proxy": "Probar Proxy",
"remove_proxy": "Eliminar Proxy",
"table": {
"proxy_url": "URL del Proxy",
"requires_auth": "Requiere Autenticación"
},
"coming_soon": "Próximamente - En Open Source (Rotación Básica) y Cloud (Rotación Avanzada). Si no desea administrar la infraestructura, únase a nuestra lista de espera en la nube para obtener acceso anticipado.",
"join_waitlist": "Unirse a la Lista de Espera de Maxun Cloud",
"alert": {
"title": "Si su proxy requiere un nombre de usuario y contraseña, proporcione siempre estos datos por separado de la URL del proxy.",
"right_way": "La forma correcta",
"wrong_way": "La forma incorrecta",
"proxy_url": "URL del Proxy:",
"username": "Usuario:",
"password": "Contraseña:"
},
"notifications": {
"config_success": "Configuración del proxy enviada con éxito",
"config_error": "Error al enviar la configuración del proxy. Inténtelo de nuevo.",
"test_success": "La configuración del proxy funciona correctamente",
"test_error": "Error al probar la configuración del proxy. Inténtelo de nuevo.",
"fetch_success": "Configuración del proxy recuperada con éxito",
"remove_success": "Configuración del proxy eliminada con éxito",
"remove_error": "Error al eliminar la configuración del proxy. Inténtelo de nuevo."
}
},
"apikey": {
"title": "Gestionar tu Clave API",
"default_name": "Clave API de Maxun",
"table": {
"name": "Nombre de la Clave API",
"key": "Clave API",
"actions": "Acciones"
},
"actions": {
"copy": "Copiar",
"show": "Mostrar",
"hide": "Ocultar",
"delete": "Eliminar"
},
"no_key_message": "Aún no has generado una clave API.",
"generate_button": "Generar Clave API",
"notifications": {
"fetch_error": "Error al obtener la clave API - ${error}",
"generate_success": "Clave API generada con éxito",
"generate_error": "Error al generar la clave API - ${error}",
"delete_success": "Clave API eliminada con éxito",
"delete_error": "Error al eliminar la clave API - ${error}",
"copy_success": "Clave API copiada con éxito"
}
},
"action_description": {
"text": {
"title": "Capturar Texto",
"description": "Pase el cursor sobre los textos que desea extraer y haga clic para seleccionarlos"
},
"screenshot": {
"title": "Capturar Pantalla",
"description": "Capture una captura de pantalla parcial o completa de la página actual."
},
"list": {
"title": "Capturar Lista",
"description": "Pase el cursor sobre la lista que desea extraer. Una vez seleccionada, puede pasar el cursor sobre todos los textos dentro de la lista seleccionada. Haga clic para seleccionarlos."
},
"default": {
"title": "¿Qué datos desea extraer?",
"description": "Un robot está diseñado para realizar una acción a la vez. Puede elegir cualquiera de las siguientes opciones."
},
"list_stages": {
"initial": "Seleccione la lista que desea extraer junto con los textos que contiene",
"pagination": "Seleccione cómo puede el robot capturar el resto de la lista",
"limit": "Elija el número de elementos a extraer",
"complete": "Captura completada"
}
},
"right_panel": {
"buttons": {
"capture_list": "Capturar Lista",
"capture_text": "Capturar Texto",
"capture_screenshot": "Capturar Pantalla",
"confirm": "Confirmar",
"discard": "Descartar",
"confirm_capture": "Confirmar Captura",
"confirm_pagination": "Confirmar",
"confirm_limit": "Confirmar",
"finish_capture": "Finalizar Captura",
"back": "Atrás",
"finish": "Finalizar",
"cancel": "Cancelar",
"delete": "Eliminar"
},
"screenshot": {
"capture_fullpage": "Capturar Página Completa",
"capture_visible": "Capturar Parte Visible",
"display_fullpage": "Capturar Screenshot de Página Completa",
"display_visible": "Capturar Screenshot de Parte Visible"
},
"pagination": {
"title": "¿Cómo podemos encontrar el siguiente elemento de la lista en la página?",
"click_next": "Hacer clic en siguiente para navegar a la siguiente página",
"click_load_more": "Hacer clic en cargar más para cargar más elementos",
"scroll_down": "Desplazarse hacia abajo para cargar más elementos",
"scroll_up": "Desplazarse hacia arriba para cargar más elementos",
"none": "No hay más elementos para cargar"
},
"limit": {
"title": "¿Cuál es el número máximo de filas que desea extraer?",
"custom": "Personalizado",
"enter_number": "Ingrese número"
},
"fields": {
"label": "Etiqueta",
"data": "Datos",
"field_label": "Etiqueta del Campo",
"field_data": "Datos del Campo"
},
"messages": {
"list_selected": "Lista seleccionada exitosamente"
},
"errors": {
"select_pagination": "Por favor seleccione un tipo de paginación.",
"select_pagination_element": "Por favor seleccione primero el elemento de paginación.",
"select_limit": "Por favor seleccione un límite o ingrese un límite personalizado.",
"invalid_limit": "Por favor ingrese un límite válido.",
"confirm_text_fields": "Por favor confirme todos los campos de texto",
"unable_create_settings": "No se pueden crear las configuraciones de la lista. Asegúrese de haber definido un campo para la lista.",
"capture_text_discarded": "Captura de texto descartada",
"capture_list_discarded": "Captura de lista descartada"
}
},
"save_recording": {
"title": "Guardar Robot",
"robot_name": "Nombre del Robot",
"buttons": {
"save": "Guardar",
"confirm": "Confirmar"
},
"notifications": {
"save_success": "Robot guardado exitosamente"
},
"errors": {
"user_not_logged": "Usuario no conectado. No se puede guardar la grabación.",
"exists_warning": "Ya existe un robot con este nombre, por favor confirme la sobrescritura del robot."
},
"tooltips": {
"saving": "Optimizando y guardando el flujo de trabajo"
}
},
"browser_recording": {
"modal": {
"confirm_discard": "¿Está seguro de que desea descartar la grabación?"
},
"notifications": {
"terminated": "La grabación actual fue terminada"
}
},
"interpretation_log": {
"titles": {
"output_preview": "Vista Previa de Datos de Salida",
"screenshot": "Captura de pantalla"
},
"messages": {
"additional_rows": "Se extraerán filas adicionales de datos una vez que termine la grabación.",
"successful_training": "¡Has entrenado exitosamente al robot para realizar acciones! Haz clic en el botón de abajo para obtener una vista previa de los datos que tu robot extraerá.",
"no_selection": "Parece que aún no has seleccionado nada para extraer. Una vez que lo hagas, el robot mostrará una vista previa de tus selecciones aquí."
},
"data_sections": {
"binary_received": "---------- Datos binarios de salida recibidos ----------",
"serializable_received": "---------- Datos serializables de salida recibidos ----------",
"mimetype": "tipo MIME: ",
"image_below": "La imagen se muestra a continuación:",
"separator": "--------------------------------------------------"
}
},
"interpretation_buttons": {
"buttons": {
"preview": "Obtener Vista Previa de Datos de Salida",
"reset": "Restablecer",
"yes": "Sí",
"no": "No"
},
"messages": {
"extracting": "Extrayendo datos...espere de 10 segundos a 1 minuto",
"restart_required": "Por favor, reinicie la interpretación después de actualizar la grabación",
"run_finished": "Ejecución finalizada",
"run_failed": "Error al iniciar la ejecución"
},
"modal": {
"use_previous": "¿Desea usar su selección anterior como condición para realizar esta acción?",
"previous_action": "Su acción anterior fue: ",
"element_text": "en un elemento con texto "
},
"notifications": {
"reset_success": "Vista previa restablecida correctamente"
}
},
"recording_page": {
"loader": {
"browser_startup": "Iniciando el navegador...Navegando a {{url}}"
}
},
"integration_settings": {
"title": "Integrar con Google Sheet",
"descriptions": {
"sync_info": "Si habilitas esta opción, cada vez que este robot ejecute una tarea exitosamente, sus datos capturados se añadirán a tu Google Sheet.",
"authenticated_as": "Autenticado como: {{email}}"
},
"alerts": {
"success": {
"title": "Google Sheet integrado exitosamente.",
"content": "Cada vez que este robot crea una ejecución exitosa, sus datos capturados se añaden a tu Google Sheet {{sheetName}}. Puedes verificar las actualizaciones de datos",
"here": "aquí",
"note": "Nota:",
"sync_limitation": "Los datos extraídos antes de la integración con Google Sheets no se sincronizarán en el Google Sheet. Solo se sincronizarán los datos extraídos después de la integración."
}
},
"buttons": {
"authenticate": "Autenticar con Google",
"fetch_sheets": "Obtener Google Sheets",
"remove_integration": "Eliminar integración",
"submit": "Enviar"
},
"fields": {
"select_sheet": "Seleccionar Google Sheet",
"selected_sheet": "Hoja seleccionada: {{name}} (ID: {{id}})"
}
},
"robot_duplication": {
"title": "Duplicar Robot",
"descriptions": {
"purpose": "La duplicación de robots es útil para extraer datos de páginas con la misma estructura.",
"example": "Ejemplo: Si has creado un robot para {{url1}}, puedes duplicarlo para extraer páginas similares como {{url2}} sin tener que entrenar un robot desde cero.",
"warning": "⚠️ Asegúrate de que la nueva página tenga la misma estructura que la página original."
},
"fields": {
"target_url": "URL Destino del Robot"
},
"buttons": {
"duplicate": "Duplicar Robot",
"cancel": "Cancelar"
},
"notifications": {
"robot_not_found": "No se pudieron encontrar los detalles del robot. Por favor, inténtalo de nuevo.",
"url_required": "Se requiere la URL de destino.",
"duplicate_success": "Robot duplicado con éxito.",
"duplicate_error": "Error al actualizar la URL de destino. Por favor, inténtalo de nuevo.",
"unknown_error": "Ocurrió un error al actualizar la URL de destino."
}
},
"robot_settings": {
"title": "Configuración del Robot",
"target_url": "URL de Destino del Robot",
"robot_id": "ID del Robot",
"robot_limit": "Límite del Robot",
"created_by_user": "Creado por Usuario",
"created_at": "Fecha de Creación del Robot",
"errors": {
"robot_not_found": "No se pudieron encontrar los detalles del robot. Inténtelo de nuevo."
}
},
"robot_edit": {
"title": "Editar Robot",
"change_name": "Cambiar Nombre del Robot",
"robot_limit": "Límite del Robot",
"save": "Guardar Cambios",
"cancel": "Cancelar",
"notifications": {
"update_success": "Robot actualizado exitosamente.",
"update_failed": "Error al actualizar el robot. Intente de nuevo.",
"update_error": "Ocurrió un error al actualizar el robot."
}
},
"schedule_settings": {
"title": "Configuración de Programación",
"run_every": "Ejecutar cada",
"start_from": "Iniciar desde",
"on_day": "En día",
"at_around": "Alrededor de",
"timezone": "Zona horaria",
"buttons": {
"delete_schedule": "Eliminar Programación",
"save_schedule": "Guardar Programación",
"cancel": "Cancelar"
},
"labels": {
"in_between": "Entre",
"run_once_every": "Ejecutar cada",
"start_from_label": "Iniciar desde",
"on_day_of_month": "Día del mes",
"on_day": {
"st": "º",
"nd": "º",
"rd": "º",
"th": "º"
}
}
},
"main_page": {
"notifications": {
"interpretation_success": "Interpretación del robot {{name}} completada con éxito",
"interpretation_failed": "Error al interpretar el robot {{name}}",
"run_started": "Ejecutando robot: {{name}}",
"run_start_failed": "Error al ejecutar el robot: {{name}}",
"schedule_success": "Robot {{name}} programado exitosamente",
"schedule_failed": "Error al programar el robot {{name}}",
"abort_success": "Interpretación del robot {{name}} abortada exitosamente",
"abort_failed": "Error al abortar la interpretación del robot {{name}}"
},
"menu": {
"recordings": "Robots",
"runs": "Ejecuciones",
"proxy": "Proxy",
"apikey": "Clave API"
}
},
"browser_window": {
"attribute_modal": {
"title": "Seleccionar Atributo",
"notifications": {
"list_select_success": "Lista seleccionada correctamente. Seleccione los datos de texto para extracción.",
"pagination_select_success": "Elemento de paginación seleccionado correctamente."
}
},
"attribute_options": {
"anchor": {
"text": "Texto: {{text}}",
"url": "URL: {{url}}"
},
"image": {
"alt_text": "Texto Alt: {{altText}}",
"image_url": "URL de Imagen: {{imageUrl}}"
},
"default": {
"text": "Texto: {{text}}"
}
}
},
"runs_table": {
"run_type_chips": {
"manual_run": "Ejecución Manual",
"scheduled_run": "Ejecución Programada",
"api": "API",
"unknown_run_type": "Tipo de Ejecución Desconocido"
},
"run_status_chips": {
"success": "Éxito",
"running": "Ejecutando",
"scheduled": "Programado",
"failed": "Fallido"
},
"run_settings_modal": {
"title": "Configuración de Ejecución",
"labels": {
"run_id": "ID de Ejecución",
"run_by_user": "Ejecutado por Usuario",
"run_by_schedule": "Ejecutado por ID de Programación",
"run_by_api": "Ejecutado por API",
"run_type": "Tipo de Ejecución"
}
}
},
"run_content": {
"tabs": {
"output_data": "Datos de Salida",
"log": "Registro"
},
"empty_output": "La salida está vacía.",
"captured_data": {
"title": "Datos Capturados",
"download_json": "Descargar como JSON",
"download_csv": "Descargar como CSV"
},
"captured_screenshot": {
"title": "Captura de Pantalla",
"download": "Descargar Captura",
"render_failed": "No se pudo renderizar la imagen"
},
"buttons": {
"stop": "Detener"
}
},
"navbar": {
"project_name": "Maxun",
"upgrade": {
"button": "Actualizar",
"modal": {
"up_to_date": "¡Estás actualizado!",
"new_version_available": "Hay una nueva versión disponible: {{version}}. ¡Actualice a la última versión para correcciones de errores, mejoras y nuevas características!",
"view_updates": "Ver todas las actualizaciones",
"view_updates_link": "aquí",
"tabs": {
"manual_setup": "Actualización de Configuración Manual",
"docker_setup": "Actualización de Configuración Docker Compose"
}
}
},
"menu_items": {
"logout": "Cerrar sesión",
"discord": "Discord",
"youtube": "YouTube",
"twitter": "Twitter (X)",
"language": "Idioma"
},
"recording": {
"discard": "Descartar"
}
},
"language_menu": {
"en": "Inglés",
"es": "Español",
"ja": "Japonés",
"zh": "Chino",
"de": "Alemán"
}
}

493
public/locales/ja.json Normal file
View File

@@ -0,0 +1,493 @@
{
"login": {
"title": "お帰りなさい!",
"email": "勤務先メールアドレスを入力",
"password": "パスワード",
"button": "ログイン",
"loading": "読み込み中",
"register_prompt": "アカウントをお持ちでないですか?",
"register_link": "登録する",
"welcome_notification": "Maxunへようこそ",
"error_notification": "ログインに失敗しました。もう一度お試しください。"
},
"register": {
"title": "アカウントを登録する",
"email": "勤務先メールアドレスを入力",
"password": "パスワード",
"button": "登録する",
"loading": "読み込み中",
"register_prompt": "既にアカウントをお持ちですか?",
"login_link": "ログイン",
"welcome_notification": "Maxunへようこそ",
"error_notification": "登録に失敗しました。もう一度お試しください。"
},
"recordingtable": {
"run": "実行",
"name": "名前",
"schedule": "スケジュール",
"integrate": "統合",
"settings": "設定",
"options": "オプション",
"heading": "私のロボット",
"new": "ロボットを作成",
"modal": {
"title": "URLを入力してください",
"label": "URL",
"button": "録画を開始"
},
"edit": "編集",
"delete": "削除",
"duplicate": "複製",
"search": "ロボットを検索...",
"notifications": {
"delete_warning": "関連する実行があるため、ロボットを削除できません",
"delete_success": "ロボットが正常に削除されました"
}
},
"mainmenu": {
"recordings": "ロボット",
"runs": "実行",
"proxy": "プロキシ",
"apikey": "APIキー",
"feedback": "Maxunクラウドに参加する",
"apidocs": "WebサイトからAPI"
},
"runstable": {
"runs": "すべての実行",
"runStatus": "ステータス",
"runName": "名前",
"startedAt": "開始日時",
"finishedAt": "終了日時",
"delete": "削除",
"settings": "設定",
"search": "実行を検索...",
"notifications": {
"no_runs": "実行が見つかりません。もう一度お試しください。",
"delete_success": "実行が正常に削除されました"
}
},
"proxy": {
"title": "プロキシ設定",
"tab_standard": "標準プロキシ",
"tab_rotation": "自動プロキシローテーション",
"server_url": "プロキシサーバーURL",
"server_url_helper": "すべてのロボットで使用するプロキシ。HTTPとSOCKSプロキシがサポートされています。例http://myproxy.com:3128 または socks5://myproxy.com:3128。短縮形 myproxy.com:3128 はHTTPプロキシとして扱われます。",
"requires_auth": "認証が必要ですか?",
"username": "ユーザー名",
"password": "パスワード",
"add_proxy": "プロキシを追加",
"test_proxy": "プロキシをテスト",
"remove_proxy": "プロキシを削除",
"table": {
"proxy_url": "プロキシURL",
"requires_auth": "認証が必要"
},
"coming_soon": "近日公開 - オープンソース(基本ローテーション)とクラウド(高度なローテーション)。インフラストラクチャを管理したくない場合は、クラウドの待機リストに参加して早期アクセスを取得してください。",
"join_waitlist": "Maxun Cloud待機リストに参加",
"alert": {
"title": "プロキシにユーザー名とパスワードが必要な場合は、必ずプロキシURLとは別に指定してください。",
"right_way": "正しい方法",
"wrong_way": "間違った方法",
"proxy_url": "プロキシURL",
"username": "ユーザー名:",
"password": "パスワード:"
},
"notifications": {
"config_success": "プロキシ設定が正常に送信されました",
"config_error": "プロキシ設定の送信に失敗しました。もう一度お試しください。",
"test_success": "プロキシ設定は正常に動作しています",
"test_error": "プロキシ設定のテストに失敗しました。もう一度お試しください。",
"fetch_success": "プロキシ設定の取得に成功しました",
"remove_success": "プロキシ設定が正常に削除されました",
"remove_error": "プロキシ設定の削除に失敗しました。もう一度お試しください。"
}
},
"apikey": {
"title": "APIキーの管理",
"default_name": "Maxun APIキー",
"table": {
"name": "APIキー名",
"key": "APIキー",
"actions": "アクション"
},
"actions": {
"copy": "コピー",
"show": "表示",
"hide": "非表示",
"delete": "削除"
},
"no_key_message": "APIキーはまだ生成されていません。",
"generate_button": "APIキーを生成",
"notifications": {
"fetch_error": "APIキーの取得に失敗しました - ${error}",
"generate_success": "APIキーが正常に生成されました",
"generate_error": "APIキーの生成に失敗しました - ${error}",
"delete_success": "APIキーが正常に削除されました",
"delete_error": "APIキーの削除に失敗しました - ${error}",
"copy_success": "APIキーがコピーされました"
}
},
"action_description": {
"text": {
"title": "テキストを取得",
"description": "抽出したいテキストにカーソルを合わせ、クリックして選択してください"
},
"screenshot": {
"title": "スクリーンショットを取得",
"description": "現在のページの部分的または全体のスクリーンショットを取得します。"
},
"list": {
"title": "リストを取得",
"description": "抽出したいリストにカーソルを合わせてください。選択後、選択したリスト内のすべてのテキストにカーソルを合わせることができます。クリックして選択してください。"
},
"default": {
"title": "どのデータを抽出しますか?",
"description": "ロボットは一度に1つのアクションを実行するように設計されています。以下のオプションから選択できます。"
},
"list_stages": {
"initial": "抽出したいリストとその中のテキストを選択してください",
"pagination": "ロボットがリストの残りをどのように取得するか選択してください",
"limit": "抽出するアイテムの数を選択してください",
"complete": "取得が完了しました"
}
},
"right_panel": {
"buttons": {
"capture_list": "リストを取得",
"capture_text": "テキストを取得",
"capture_screenshot": "スクリーンショットを取得",
"confirm": "確認",
"discard": "破棄",
"confirm_capture": "取得を確認",
"confirm_pagination": "確認",
"confirm_limit": "確認",
"finish_capture": "取得を完了",
"back": "戻る",
"finish": "完了",
"cancel": "キャンセル",
"delete": "削除"
},
"screenshot": {
"capture_fullpage": "フルページを取得",
"capture_visible": "表示部分を取得",
"display_fullpage": "フルページスクリーンショットを撮影",
"display_visible": "表示部分のスクリーンショットを撮影"
},
"pagination": {
"title": "次のリスト項目をページ上でどのように見つけますか?",
"click_next": "次へをクリックして次のページへ移動",
"click_load_more": "もっと読み込むをクリックして項目を追加",
"scroll_down": "下にスクロールして項目を追加",
"scroll_up": "上にスクロールして項目を追加",
"none": "これ以上読み込む項目はありません"
},
"limit": {
"title": "抽出する最大行数はいくつですか?",
"custom": "カスタム",
"enter_number": "数値を入力"
},
"fields": {
"label": "ラベル",
"data": "データ",
"field_label": "フィールドラベル",
"field_data": "フィールドデータ"
},
"messages": {
"list_selected": "リストが正常に選択されました"
},
"errors": {
"select_pagination": "ページネーションタイプを選択してください。",
"select_pagination_element": "まずページネーション要素を選択してください。",
"select_limit": "制限を選択するかカスタム制限を入力してください。",
"invalid_limit": "有効な制限を入力してください。",
"confirm_text_fields": "すべてのテキストフィールドを確認してください",
"unable_create_settings": "リスト設定を作成できません。リストのフィールドを定義したことを確認してください。",
"capture_text_discarded": "テキスト取得が破棄されました",
"capture_list_discarded": "リスト取得が破棄されました"
}
},
"save_recording": {
"title": "ロボットを保存",
"robot_name": "ロボット名",
"buttons": {
"save": "保存",
"confirm": "確認"
},
"notifications": {
"save_success": "ロボットが正常に保存されました"
},
"errors": {
"user_not_logged": "ユーザーがログインしていません。録画を保存できません。",
"exists_warning": "この名前のロボットは既に存在します。ロボットの上書きを確認してください。"
},
"tooltips": {
"saving": "ワークフローを最適化して保存中"
}
},
"browser_recording": {
"modal": {
"confirm_discard": "録画を破棄してもよろしいですか?"
},
"notifications": {
"terminated": "現在の録画は終了しました"
}
},
"interpretation_log": {
"titles": {
"output_preview": "出力データプレビュー",
"screenshot": "スクリーンショット"
},
"messages": {
"additional_rows": "記録が完了すると、追加のデータ行が抽出されます。",
"successful_training": "ロボットのアクショントレーニングが成功しました!下のボタンをクリックすると、ロボットが抽出するデータのプレビューが表示されます。",
"no_selection": "まだ抽出対象が選択されていません。選択すると、ロボットがここで選択内容のプレビューを表示します。"
},
"data_sections": {
"binary_received": "---------- バイナリ出力データを受信 ----------",
"serializable_received": "---------- シリアライズ可能な出力データを受信 ----------",
"mimetype": "MIMEタイプ: ",
"image_below": "画像は以下に表示されます:",
"separator": "--------------------------------------------------"
},
"notifications": {
"reset_success": "出力プレビューが正常にリセットされました"
}
},
"interpretation_buttons": {
"buttons": {
"preview": "出力データのプレビューを取得",
"reset": "リセット",
"yes": "はい",
"no": "いいえ"
},
"messages": {
"extracting": "データ抽出中...10秒から1分ほどお待ちください",
"restart_required": "録画を更新した後、解釈を再起動してください",
"run_finished": "実行完了",
"run_failed": "実行の開始に失敗しました"
},
"modal": {
"use_previous": "この操作の条件として前回の選択を使用しますか?",
"previous_action": "前回の操作: ",
"element_text": "テキスト要素 "
}
},
"recording_page": {
"loader": {
"browser_startup": "ブラウザを起動中...{{url}}に移動中"
}
},
"integration_settings": {
"title": "Google Sheetと連携",
"descriptions": {
"sync_info": "このオプションを有効にすると、このロボットがタスクを正常に実行するたびに、取得したデータがGoogle Sheetに追加されます。",
"authenticated_as": "認証済みユーザー: {{email}}"
},
"alerts": {
"success": {
"title": "Google Sheetの連携が完了しました。",
"content": "このロボットが正常に実行を完了するたびに、取得したデータはGoogle Sheet {{sheetName}}に追加されます。データの更新は",
"here": "こちら",
"note": "注意:",
"sync_limitation": "Google Sheetsとの連携前に抽出されたデータは同期されません。連携後に抽出されたデータのみが同期されます。"
}
},
"buttons": {
"authenticate": "Googleで認証",
"fetch_sheets": "Google Sheetsを取得",
"remove_integration": "連携を解除",
"submit": "送信"
},
"fields": {
"select_sheet": "Google Sheetを選択",
"selected_sheet": "選択したシート: {{name}} (ID: {{id}})"
}
},
"robot_duplication": {
"title": "ロボットを複製",
"descriptions": {
"purpose": "ロボットの複製は、同じ構造のページからデータを抽出する際に便利です。",
"example": "例:{{url1}}用のロボットを作成した場合、ロボットを一から作り直すことなく、{{url2}}のような類似のページをスクレイピングするために複製できます。",
"warning": "⚠️ 新しいページが元のページと同じ構造であることを確認してください。"
},
"fields": {
"target_url": "ロボットのターゲットURL"
},
"buttons": {
"duplicate": "ロボットを複製",
"cancel": "キャンセル"
},
"notifications": {
"robot_not_found": "ロボットの詳細が見つかりません。もう一度お試しください。",
"url_required": "ターゲットURLが必要です。",
"duplicate_success": "ロボットが正常に複製されました。",
"duplicate_error": "ターゲットURLの更新に失敗しました。もう一度お試しください。",
"unknown_error": "ターゲットURLの更新中にエラーが発生しました。"
}
},
"robot_settings": {
"title": "ロボット設定",
"target_url": "ロボットのターゲットURL",
"robot_id": "ロボットID",
"robot_limit": "ロボットの制限",
"created_by_user": "作成したユーザー",
"created_at": "作成日時",
"errors": {
"robot_not_found": "ロボットの詳細が見つかりませんでした。もう一度試してください。"
}
},
"robot_edit": {
"title": "ロボットを編集",
"change_name": "ロボット名の変更",
"robot_limit": "ロボットの制限",
"save": "変更を保存",
"cancel": "キャンセル",
"notifications": {
"update_success": "ロボットが正常に更新されました。",
"update_failed": "ロボットの更新に失敗しました。もう一度試してください。",
"update_error": "ロボットの更新中にエラーが発生しました。"
}
},
"schedule_settings": {
"title": "スケジュール設定",
"run_every": "実行間隔",
"start_from": "開始日",
"on_day": "日付",
"at_around": "時刻",
"timezone": "タイムゾーン",
"buttons": {
"delete_schedule": "スケジュールを削除",
"save_schedule": "スケジュールを保存",
"cancel": "キャンセル"
},
"labels": {
"in_between": "間隔",
"run_once_every": "実行間隔",
"start_from_label": "開始日",
"on_day_of_month": "月の日付",
"on_day": {
"st": "日",
"nd": "日",
"rd": "日",
"th": "日"
}
}
},
"main_page": {
"notifications": {
"interpretation_success": "ロボット{{name}}の解釈に成功しました",
"interpretation_failed": "ロボット{{name}}の解釈に失敗しました",
"run_started": "ロボット{{name}}を実行中",
"run_start_failed": "ロボット{{name}}の実行に失敗しました",
"schedule_success": "ロボット{{name}}のスケジュールが正常に設定されました",
"schedule_failed": "ロボット{{name}}のスケジュール設定に失敗しました",
"abort_success": "ロボット{{name}}の解釈を中止しました",
"abort_failed": "ロボット{{name}}の解釈中止に失敗しました"
},
"menu": {
"recordings": "ロボット",
"runs": "実行",
"proxy": "プロキシ",
"apikey": "APIキー"
}
},
"browser_window": {
"attribute_modal": {
"title": "属性を選択",
"notifications": {
"list_select_success": "リストが正常に選択されました。抽出するテキストデータを選択してください。",
"pagination_select_success": "ページネーション要素が正常に選択されました。"
}
},
"attribute_options": {
"anchor": {
"text": "テキスト: {{text}}",
"url": "URL: {{url}}"
},
"image": {
"alt_text": "代替テキスト: {{altText}}",
"image_url": "画像URL: {{imageUrl}}"
},
"default": {
"text": "テキスト: {{text}}"
}
}
},
"runs_table": {
"run_type_chips": {
"manual_run": "手動実行",
"scheduled_run": "スケジュール実行",
"api": "API",
"unknown_run_type": "不明な実行タイプ"
},
"run_status_chips": {
"success": "成功",
"running": "実行中",
"scheduled": "スケジュール済み",
"failed": "失敗"
},
"run_settings_modal": {
"title": "実行設定",
"labels": {
"run_id": "実行ID",
"run_by_user": "ユーザーによる実行",
"run_by_schedule": "スケジュールによる実行",
"run_by_api": "APIによる実行",
"run_type": "実行タイプ"
}
}
},
"run_content": {
"tabs": {
"output_data": "出力データ",
"log": "ログ"
},
"empty_output": "出力は空です。",
"captured_data": {
"title": "キャプチャされたデータ",
"download_json": "JSONとしてダウンロード",
"download_csv": "CSVとしてダウンロード"
},
"captured_screenshot": {
"title": "キャプチャされたスクリーンショット",
"download": "スクリーンショットをダウンロード",
"render_failed": "画像のレンダリングに失敗しました"
},
"buttons": {
"stop": "停止"
}
},
"navbar": {
"project_name": "Maxun",
"upgrade": {
"button": "アップグレード",
"modal": {
"up_to_date": "最新版です!",
"new_version_available": "新しいバージョンが利用可能です: {{version}}。バグ修正、機能強化のために最新版にアップグレードしてください。",
"view_updates": "すべての更新を",
"view_updates_link": "こちら",
"tabs": {
"manual_setup": "手動セットアップ",
"docker_setup": "Docker Composeセットアップ"
}
}
},
"menu_items": {
"logout": "ログアウト",
"discord": "Discord",
"youtube": "YouTube",
"twitter": "Twitter (X)",
"language": "言語"
},
"recording": {
"discard": "破棄"
}
},
"language_menu": {
"en": "英語",
"es": "スペイン語",
"ja": "日本語",
"zh": "中国語",
"de": "ドイツ語"
}
}

493
public/locales/zh.json Normal file
View File

@@ -0,0 +1,493 @@
{
"login": {
"title": "欢迎回来!",
"email": "输入工作电子邮箱",
"password": "密码",
"button": "登录",
"loading": "加载中",
"register_prompt": "还没有账号?",
"register_link": "注册",
"welcome_notification": "欢迎使用 Maxun",
"error_notification": "登录失败。请重试。"
},
"register": {
"title": "注册账号",
"email": "输入工作电子邮箱",
"password": "密码",
"button": "注册",
"loading": "加载中",
"register_prompt": "已有账号?",
"login_link": "登录",
"welcome_notification": "欢迎使用 Maxun",
"error_notification": "注册失败。请重试。"
},
"recordingtable": {
"run": "运行",
"name": "名称",
"schedule": "计划",
"integrate": "集成",
"settings": "设置",
"options": "选项",
"heading": "我的机器人",
"new": "创建机器人",
"modal": {
"title": "输入URL",
"label": "URL",
"button": "开始录制"
},
"edit": "编辑",
"delete": "删除",
"duplicate": "复制",
"search": "搜索机器人...",
"notifications": {
"delete_warning": "无法删除机器人,因为它有关联的运行记录",
"delete_success": "机器人删除成功"
}
},
"mainmenu": {
"recordings": "机器人",
"runs": "运行记录",
"proxy": "代理",
"apikey": "API密钥",
"feedback": "加入 Maxun Cloud",
"apidocs": "网站转API"
},
"runstable": {
"runs": "所有运行记录",
"runStatus": "状态",
"runName": "名称",
"startedAt": "开始时间",
"finishedAt": "结束时间",
"delete": "删除",
"settings": "设置",
"search": "搜索运行记录...",
"notifications": {
"no_runs": "未找到运行记录。请重试。",
"delete_success": "运行记录删除成功"
}
},
"proxy": {
"title": "代理设置",
"tab_standard": "标准代理",
"tab_rotation": "自动代理轮换",
"server_url": "代理服务器URL",
"server_url_helper": "用于所有机器人的代理。支持HTTP和SOCKS代理。示例 http://myproxy.com:3128 或 socks5://myproxy.com:3128。简短形式 myproxy.com:3128 被视为HTTP代理。",
"requires_auth": "需要认证?",
"username": "用户名",
"password": "密码",
"add_proxy": "添加代理",
"test_proxy": "测试代理",
"remove_proxy": "删除代理",
"table": {
"proxy_url": "代理URL",
"requires_auth": "需要认证"
},
"coming_soon": "即将推出 - 开源版(基础轮换)和云版(高级轮换)。如果您不想管理基础设施,请加入我们的云服务等候名单以获得早期访问权限。",
"join_waitlist": "加入Maxun Cloud等候名单",
"alert": {
"title": "如果您的代理需要用户名和密码请务必将它们与代理URL分开提供。",
"right_way": "正确方式",
"wrong_way": "错误方式",
"proxy_url": "代理URL",
"username": "用户名:",
"password": "密码:"
},
"notifications": {
"config_success": "代理配置提交成功",
"config_error": "提交代理配置失败。请重试。",
"test_success": "代理配置运行正常",
"test_error": "测试代理配置失败。请重试。",
"fetch_success": "成功获取代理配置",
"remove_success": "成功删除代理配置",
"remove_error": "删除代理配置失败。请重试。"
}
},
"apikey": {
"title": "管理API密钥",
"default_name": "Maxun API密钥",
"table": {
"name": "API密钥名称",
"key": "API密钥",
"actions": "操作"
},
"actions": {
"copy": "复制",
"show": "显示",
"hide": "隐藏",
"delete": "删除"
},
"no_key_message": "您还未生成API密钥。",
"generate_button": "生成API密钥",
"notifications": {
"fetch_error": "获取API密钥失败 - ${error}",
"generate_success": "API密钥生成成功",
"generate_error": "生成API密钥失败 - ${error}",
"delete_success": "API密钥删除成功",
"delete_error": "删除API密钥失败 - ${error}",
"copy_success": "API密钥复制成功"
}
},
"action_description": {
"text": {
"title": "捕获文本",
"description": "将鼠标悬停在要提取的文本上并点击选择"
},
"screenshot": {
"title": "捕获截图",
"description": "捕获当前页面的部分或全部截图。"
},
"list": {
"title": "捕获列表",
"description": "将鼠标悬停在要提取的列表上。选择后,您可以将鼠标悬停在所选列表中的所有文本上。点击选择它们。"
},
"default": {
"title": "您想提取什么数据?",
"description": "机器人设计为一次执行一个操作。您可以选择以下任何选项。"
},
"list_stages": {
"initial": "选择要提取的列表及其中的文本",
"pagination": "选择机器人如何捕获列表的其余部分",
"limit": "选择要提取的项目数量",
"complete": "捕获完成"
}
},
"right_panel": {
"buttons": {
"capture_list": "捕获列表",
"capture_text": "捕获文本",
"capture_screenshot": "捕获截图",
"confirm": "确认",
"discard": "放弃",
"confirm_capture": "确认捕获",
"confirm_pagination": "确认",
"confirm_limit": "确认",
"finish_capture": "完成捕获",
"back": "返回",
"finish": "完成",
"cancel": "取消",
"delete": "删除"
},
"screenshot": {
"capture_fullpage": "捕获整页",
"capture_visible": "捕获可见部分",
"display_fullpage": "获取整页截图",
"display_visible": "获取可见部分截图"
},
"pagination": {
"title": "如何在页面上找到下一个列表项?",
"click_next": "点击下一页导航到下一页",
"click_load_more": "点击加载更多来加载更多项目",
"scroll_down": "向下滚动加载更多项目",
"scroll_up": "向上滚动加载更多项目",
"none": "没有更多项目可加载"
},
"limit": {
"title": "您想要提取的最大行数是多少?",
"custom": "自定义",
"enter_number": "输入数字"
},
"fields": {
"label": "标签",
"data": "数据",
"field_label": "字段标签",
"field_data": "字段数据"
},
"messages": {
"list_selected": "列表选择成功"
},
"errors": {
"select_pagination": "请选择分页类型。",
"select_pagination_element": "请先选择分页元素。",
"select_limit": "请选择限制或输入自定义限制。",
"invalid_limit": "请输入有效的限制。",
"confirm_text_fields": "请确认所有文本字段",
"unable_create_settings": "无法创建列表设置。请确保您已为列表定义了字段。",
"capture_text_discarded": "文本捕获已放弃",
"capture_list_discarded": "列表捕获已放弃"
}
},
"save_recording": {
"title": "保存机器人",
"robot_name": "机器人名称",
"buttons": {
"save": "保存",
"confirm": "确认"
},
"notifications": {
"save_success": "机器人保存成功"
},
"errors": {
"user_not_logged": "用户未登录。无法保存录制。",
"exists_warning": "已存在同名机器人,请确认是否覆盖机器人。"
},
"tooltips": {
"saving": "正在优化并保存工作流程"
}
},
"browser_recording": {
"modal": {
"confirm_discard": "您确定要放弃录制吗?"
},
"notifications": {
"terminated": "当前录制已终止"
}
},
"interpretation_log": {
"titles": {
"output_preview": "输出数据预览",
"screenshot": "截图"
},
"messages": {
"additional_rows": "完成录制后将提取更多数据行。",
"successful_training": "您已成功训练机器人执行操作!点击下方按钮预览机器人将提取的数据。",
"no_selection": "看起来您还没有选择要提取的内容。选择后,机器人将在此处显示您的选择预览。"
},
"data_sections": {
"binary_received": "---------- 已接收二进制输出数据 ----------",
"serializable_received": "---------- 已接收可序列化输出数据 ----------",
"mimetype": "MIME类型",
"image_below": "图片显示如下:",
"separator": "--------------------------------------------------"
},
"notifications": {
"reset_success": "输出预览已成功重置"
}
},
"interpretation_buttons": {
"buttons": {
"preview": "获取输出数据预览",
"reset": "重置",
"yes": "是",
"no": "否"
},
"messages": {
"extracting": "正在提取数据...请等待10秒到1分钟",
"restart_required": "更新录制后请重新启动解释",
"run_finished": "运行完成",
"run_failed": "运行启动失败"
},
"modal": {
"use_previous": "您要将之前的选择用作执行此操作的条件吗?",
"previous_action": "您之前的操作是:",
"element_text": "在文本元素上 "
}
},
"recording_page": {
"loader": {
"browser_startup": "正在启动浏览器...正在导航至{{url}}"
}
},
"integration_settings": {
"title": "与Google Sheet集成",
"descriptions": {
"sync_info": "如果启用此选项每次机器人成功运行任务时捕获的数据都会追加到您的Google Sheet中。",
"authenticated_as": "已验证身份: {{email}}"
},
"alerts": {
"success": {
"title": "Google Sheet集成成功。",
"content": "每次此机器人创建成功运行时捕获的数据都会追加到您的Google Sheet {{sheetName}}中。您可以查看数据更新",
"here": "在此处",
"note": "注意:",
"sync_limitation": "与Google Sheets集成之前提取的数据将不会同步到Google Sheet中。只有集成后提取的数据才会同步。"
}
},
"buttons": {
"authenticate": "使用Google验证",
"fetch_sheets": "获取Google Sheets",
"remove_integration": "移除集成",
"submit": "提交"
},
"fields": {
"select_sheet": "选择Google Sheet",
"selected_sheet": "已选择表格: {{name}} (ID: {{id}})"
}
},
"robot_duplication": {
"title": "复制机器人",
"descriptions": {
"purpose": "机器人复制功能用于从具有相同结构的页面提取数据。",
"example": "示例:如果您已经为{{url1}}创建了机器人,您可以复制它来抓取类似的页面(如{{url2}}),而无需从头开始训练机器人。",
"warning": "⚠️ 确保新页面与原始页面具有相同的结构。"
},
"fields": {
"target_url": "机器人目标URL"
},
"buttons": {
"duplicate": "复制机器人",
"cancel": "取消"
},
"notifications": {
"robot_not_found": "找不到机器人详细信息。请重试。",
"url_required": "需要目标URL。",
"duplicate_success": "机器人复制成功。",
"duplicate_error": "更新目标URL失败。请重试。",
"unknown_error": "更新目标URL时发生错误。"
}
},
"robot_settings": {
"title": "机器人设置",
"target_url": "机器人目标URL",
"robot_id": "机器人ID",
"robot_limit": "机器人限制",
"created_by_user": "由用户创建",
"created_at": "机器人创建时间",
"errors": {
"robot_not_found": "无法找到机器人详细信息。请重试。"
}
},
"robot_edit": {
"title": "编辑机器人",
"change_name": "更改机器人名称",
"robot_limit": "机器人限制",
"save": "保存更改",
"cancel": "取消",
"notifications": {
"update_success": "机器人更新成功。",
"update_failed": "无法更新机器人。请重试。",
"update_error": "更新机器人时发生错误。"
}
},
"schedule_settings": {
"title": "计划设置",
"run_every": "每次运行",
"start_from": "开始于",
"on_day": "在日",
"at_around": "大约在",
"timezone": "时区",
"buttons": {
"delete_schedule": "删除计划",
"save_schedule": "保存计划",
"cancel": "取消"
},
"labels": {
"in_between": "之间",
"run_once_every": "每次运行",
"start_from_label": "开始于",
"on_day_of_month": "月份日期",
"on_day": {
"st": "日",
"nd": "日",
"rd": "日",
"th": "日"
}
}
},
"main_page": {
"notifications": {
"interpretation_success": "机器人{{name}}解释成功",
"interpretation_failed": "机器人{{name}}解释失败",
"run_started": "正在运行机器人:{{name}}",
"run_start_failed": "机器人运行失败:{{name}}",
"schedule_success": "机器人{{name}}调度成功",
"schedule_failed": "机器人{{name}}调度失败",
"abort_success": "成功中止机器人{{name}}的解释",
"abort_failed": "中止机器人{{name}}的解释失败"
},
"menu": {
"recordings": "机器人",
"runs": "运行",
"proxy": "代理",
"apikey": "API密钥"
}
},
"browser_window": {
"attribute_modal": {
"title": "选择属性",
"notifications": {
"list_select_success": "列表选择成功。选择要提取的文本数据。",
"pagination_select_success": "分页元素选择成功。"
}
},
"attribute_options": {
"anchor": {
"text": "文本: {{text}}",
"url": "URL: {{url}}"
},
"image": {
"alt_text": "替代文本: {{altText}}",
"image_url": "图像URL: {{imageUrl}}"
},
"default": {
"text": "文本: {{text}}"
}
}
},
"runs_table": {
"run_type_chips": {
"manual_run": "手动运行",
"scheduled_run": "计划运行",
"api": "API",
"unknown_run_type": "未知运行类型"
},
"run_status_chips": {
"success": "成功",
"running": "运行中",
"scheduled": "已计划",
"failed": "失败"
},
"run_settings_modal": {
"title": "运行设置",
"labels": {
"run_id": "运行ID",
"run_by_user": "由用户运行",
"run_by_schedule": "按计划ID运行",
"run_by_api": "由API运行",
"run_type": "运行类型"
}
}
},
"run_content": {
"tabs": {
"output_data": "输出数据",
"log": "日志"
},
"empty_output": "输出为空。",
"captured_data": {
"title": "捕获的数据",
"download_json": "下载为JSON",
"download_csv": "下载为CSV"
},
"captured_screenshot": {
"title": "捕获的截图",
"download": "下载截图",
"render_failed": "图像渲染失败"
},
"buttons": {
"stop": "停止"
}
},
"navbar": {
"project_name": "Maxun",
"upgrade": {
"button": "升级",
"modal": {
"up_to_date": "🎉 您已是最新版本!",
"new_version_available": "新版本已可用:{{version}}。升级到最新版本以获取错误修复、增强和新功能!",
"view_updates": "查看所有新更新",
"view_updates_link": "此处",
"tabs": {
"manual_setup": "手动设置升级",
"docker_setup": "Docker Compose设置升级"
}
}
},
"menu_items": {
"logout": "退出登录",
"discord": "Discord",
"youtube": "YouTube",
"twitter": "Twitter (X)",
"language": "语言"
},
"recording": {
"discard": "丢弃"
}
},
"language_menu": {
"en": "英语",
"es": "西班牙语",
"ja": "日语",
"zh": "中文",
"de": "德语"
}
}

View File

@@ -1,4 +1,4 @@
FROM mcr.microsoft.com/playwright:v1.40.0-jammy FROM mcr.microsoft.com/playwright:v1.46.0-noble
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
@@ -7,13 +7,14 @@ WORKDIR /app
COPY package*.json ./ COPY package*.json ./
COPY maxun-core ./maxun-core COPY maxun-core ./maxun-core
COPY src ./src COPY src ./src
COPY public ./public
COPY server ./server COPY server ./server
COPY tsconfig.json ./ COPY tsconfig.json ./
COPY server/tsconfig.json ./server/ COPY server/tsconfig.json ./server/
# COPY server/start.sh ./ # COPY server/start.sh ./
# Install dependencies # Install dependencies
RUN npm install RUN npm install --legacy-peer-deps
# Install Playwright browsers and dependencies # Install Playwright browsers and dependencies
RUN npx playwright install --with-deps chromium RUN npx playwright install --with-deps chromium
@@ -50,7 +51,7 @@ RUN apt-get update && apt-get install -y \
# RUN chmod +x ./start.sh # RUN chmod +x ./start.sh
# Expose the backend port # Expose the backend port
EXPOSE 8080 EXPOSE ${BACKEND_PORT:-8080}
# Start the backend using the start script # Start the backend using the start script
CMD ["npm", "run", "server"] CMD ["npm", "run", "server"]

View File

@@ -15,6 +15,8 @@ import { io, Socket } from "socket.io-client";
import { BinaryOutputService } from "../storage/mino"; import { BinaryOutputService } from "../storage/mino";
import { AuthenticatedRequest } from "../routes/record" import { AuthenticatedRequest } from "../routes/record"
import {capture} from "../utils/analytics"; import {capture} from "../utils/analytics";
import { Page } from "playwright";
import { WorkflowFile } from "maxun-core";
chromium.use(stealthPlugin()); chromium.use(stealthPlugin());
const formatRecording = (recordingData: any) => { const formatRecording = (recordingData: any) => {
@@ -533,6 +535,17 @@ function resetRecordingState(browserId: string, id: string) {
id = ''; id = '';
} }
function AddGeneratedFlags(workflow: WorkflowFile) {
const copy = JSON.parse(JSON.stringify(workflow));
for (let i = 0; i < workflow.workflow.length; i++) {
copy.workflow[i].what.unshift({
action: 'flag',
args: ['generated'],
});
}
return copy;
};
async function executeRun(id: string) { async function executeRun(id: string) {
try { try {
const run = await Run.findOne({ where: { runId: id } }); const run = await Run.findOne({ where: { runId: id } });
@@ -560,13 +573,14 @@ async function executeRun(id: string) {
throw new Error('Could not access browser'); throw new Error('Could not access browser');
} }
const currentPage = await browser.getCurrentPage(); let currentPage = await browser.getCurrentPage();
if (!currentPage) { if (!currentPage) {
throw new Error('Could not create a new page'); throw new Error('Could not create a new page');
} }
const workflow = AddGeneratedFlags(recording.recording);
const interpretationInfo = await browser.interpreter.InterpretRecording( const interpretationInfo = await browser.interpreter.InterpretRecording(
recording.recording, currentPage, plainRun.interpreterSettings workflow, currentPage, (newPage: Page) => currentPage = newPage, plainRun.interpreterSettings
); );
const binaryOutputService = new BinaryOutputService('maxun-run-screenshots'); const binaryOutputService = new BinaryOutputService('maxun-run-screenshots');

View File

@@ -9,14 +9,39 @@ import { chromium } from 'playwright-extra';
import stealthPlugin from 'puppeteer-extra-plugin-stealth'; import stealthPlugin from 'puppeteer-extra-plugin-stealth';
import { PlaywrightBlocker } from '@cliqz/adblocker-playwright'; import { PlaywrightBlocker } from '@cliqz/adblocker-playwright';
import fetch from 'cross-fetch'; import fetch from 'cross-fetch';
import { throttle } from 'lodash';
import sharp from 'sharp';
import logger from '../../logger'; import logger from '../../logger';
import { InterpreterSettings, RemoteBrowserOptions } from "../../types"; import { InterpreterSettings, RemoteBrowserOptions } from "../../types";
import { WorkflowGenerator } from "../../workflow-management/classes/Generator"; import { WorkflowGenerator } from "../../workflow-management/classes/Generator";
import { WorkflowInterpreter } from "../../workflow-management/classes/Interpreter"; import { WorkflowInterpreter } from "../../workflow-management/classes/Interpreter";
import { getDecryptedProxyConfig } from '../../routes/proxy'; import { getDecryptedProxyConfig } from '../../routes/proxy';
import { getInjectableScript } from 'idcac-playwright';
chromium.use(stealthPlugin()); chromium.use(stealthPlugin());
const MEMORY_CONFIG = {
gcInterval: 60000, // 1 minute
maxHeapSize: 2048 * 1024 * 1024, // 2GB
heapUsageThreshold: 0.85 // 85%
};
const SCREENCAST_CONFIG: {
format: "jpeg" | "png";
maxWidth: number;
maxHeight: number;
targetFPS: number;
compressionQuality: number;
maxQueueSize: number;
} = {
format: 'jpeg',
maxWidth: 900,
maxHeight: 400,
targetFPS: 30,
compressionQuality: 0.8,
maxQueueSize: 2
};
/** /**
* This class represents a remote browser instance. * This class represents a remote browser instance.
@@ -65,6 +90,8 @@ export class RemoteBrowser {
maxRepeats: 1, maxRepeats: 1,
}; };
private lastEmittedUrl: string | null = null;
/** /**
* {@link WorkflowGenerator} instance specific to the remote browser. * {@link WorkflowGenerator} instance specific to the remote browser.
*/ */
@@ -75,6 +102,11 @@ export class RemoteBrowser {
*/ */
public interpreter: WorkflowInterpreter; public interpreter: WorkflowInterpreter;
private screenshotQueue: Buffer[] = [];
private isProcessingScreenshot = false;
private screencastInterval: NodeJS.Timeout | null = null
/** /**
* Initializes a new instances of the {@link Generator} and {@link WorkflowInterpreter} classes and * Initializes a new instances of the {@link Generator} and {@link WorkflowInterpreter} classes and
* assigns the socket instance everywhere. * assigns the socket instance everywhere.
@@ -87,6 +119,117 @@ export class RemoteBrowser {
this.generator = new WorkflowGenerator(socket); this.generator = new WorkflowGenerator(socket);
} }
private initializeMemoryManagement(): void {
setInterval(() => {
const memoryUsage = process.memoryUsage();
const heapUsageRatio = memoryUsage.heapUsed / MEMORY_CONFIG.maxHeapSize;
if (heapUsageRatio > MEMORY_CONFIG.heapUsageThreshold) {
logger.warn('High memory usage detected, triggering cleanup');
this.performMemoryCleanup();
}
// Clear screenshot queue if it's too large
if (this.screenshotQueue.length > SCREENCAST_CONFIG.maxQueueSize) {
this.screenshotQueue = this.screenshotQueue.slice(-SCREENCAST_CONFIG.maxQueueSize);
}
}, MEMORY_CONFIG.gcInterval);
}
private async performMemoryCleanup(): Promise<void> {
this.screenshotQueue = [];
this.isProcessingScreenshot = false;
if (global.gc) {
global.gc();
}
// Reset CDP session if needed
if (this.client) {
try {
await this.stopScreencast();
this.client = null;
if (this.currentPage) {
this.client = await this.currentPage.context().newCDPSession(this.currentPage);
await this.startScreencast();
}
} catch (error) {
logger.error('Error resetting CDP session:', error);
}
}
}
/**
* Normalizes URLs to prevent navigation loops while maintaining consistent format
*/
private normalizeUrl(url: string): string {
try {
const parsedUrl = new URL(url);
// Remove trailing slashes except for root path
parsedUrl.pathname = parsedUrl.pathname.replace(/\/+$/, '') || '/';
// Ensure consistent protocol handling
parsedUrl.protocol = parsedUrl.protocol.toLowerCase();
return parsedUrl.toString();
} catch {
return url;
}
}
/**
* Determines if a URL change is significant enough to emit
*/
private shouldEmitUrlChange(newUrl: string): boolean {
if (!this.lastEmittedUrl) {
return true;
}
const normalizedNew = this.normalizeUrl(newUrl);
const normalizedLast = this.normalizeUrl(this.lastEmittedUrl);
return normalizedNew !== normalizedLast;
}
private async setupPageEventListeners(page: Page) {
page.on('framenavigated', async (frame) => {
if (frame === page.mainFrame()) {
const currentUrl = page.url();
if (this.shouldEmitUrlChange(currentUrl)) {
this.lastEmittedUrl = currentUrl;
this.socket.emit('urlChanged', currentUrl);
}
}
});
// Handle page load events with retry mechanism
page.on('load', async () => {
const injectScript = async (): Promise<boolean> => {
try {
await page.waitForLoadState('networkidle', { timeout: 5000 });
await page.evaluate(getInjectableScript());
return true;
} catch (error: any) {
logger.log('warn', `Script injection attempt failed: ${error.message}`);
return false;
}
};
const success = await injectScript();
console.log("Script injection result:", success);
});
}
private getUserAgent() {
const userAgents = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.5845.140 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:117.0) Gecko/20100101 Firefox/117.0',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.1938.81 Safari/537.36 Edg/116.0.1938.81',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.5845.96 Safari/537.36 OPR/101.0.4843.25',
'Mozilla/5.0 (Windows NT 11.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.5938.62 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:118.0) Gecko/20100101 Firefox/118.0',
];
return userAgents[Math.floor(Math.random() * userAgents.length)];
}
/** /**
* An asynchronous constructor for asynchronously initialized properties. * An asynchronous constructor for asynchronously initialized properties.
* Must be called right after creating an instance of RemoteBrowser class. * Must be called right after creating an instance of RemoteBrowser class.
@@ -94,37 +237,17 @@ export class RemoteBrowser {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
public initialize = async (userId: string): Promise<void> => { public initialize = async (userId: string): Promise<void> => {
// const launchOptions = {
// headless: true,
// proxy: options.launchOptions?.proxy,
// chromiumSandbox: false,
// args: [
// '--no-sandbox',
// '--disable-setuid-sandbox',
// '--headless=new',
// '--disable-gpu',
// '--disable-dev-shm-usage',
// '--disable-software-rasterizer',
// '--in-process-gpu',
// '--disable-infobars',
// '--single-process',
// '--no-zygote',
// '--disable-notifications',
// '--disable-extensions',
// '--disable-background-timer-throttling',
// ...(options.launchOptions?.args || [])
// ],
// env: {
// ...process.env,
// CHROMIUM_FLAGS: '--disable-gpu --no-sandbox --headless=new'
// }
// };
// console.log('Launch options before:', options.launchOptions);
// this.browser = <Browser>(await options.browser.launch(launchOptions));
// console.log('Launch options after:', options.launchOptions)
this.browser = <Browser>(await chromium.launch({ this.browser = <Browser>(await chromium.launch({
headless: true, headless: true,
args: [
"--disable-blink-features=AutomationControlled",
"--disable-web-security",
"--disable-features=IsolateOrigins,site-per-process",
"--disable-site-isolation-trials",
"--disable-extensions",
"--no-sandbox",
"--disable-dev-shm-usage",
],
})); }));
const proxyConfig = await getDecryptedProxyConfig(userId); const proxyConfig = await getDecryptedProxyConfig(userId);
let proxyOptions: { server: string, username?: string, password?: string } = { server: '' }; let proxyOptions: { server: string, username?: string, password?: string } = { server: '' };
@@ -140,7 +263,7 @@ export class RemoteBrowser {
const contextOptions: any = { const contextOptions: any = {
viewport: { height: 400, width: 900 }, viewport: { height: 400, width: 900 },
// recordVideo: { dir: 'videos/' } // recordVideo: { dir: 'videos/' }
// Force reduced motion to prevent animation issues // Force reduced motion to prevent animation issues
reducedMotion: 'reduce', reducedMotion: 'reduce',
// Force JavaScript to be enabled // Force JavaScript to be enabled
javaScriptEnabled: true, javaScriptEnabled: true,
@@ -149,7 +272,8 @@ export class RemoteBrowser {
// Disable hardware acceleration // Disable hardware acceleration
forcedColors: 'none', forcedColors: 'none',
isMobile: false, isMobile: false,
hasTouch: false hasTouch: false,
userAgent: this.getUserAgent(),
}; };
if (proxyOptions.server) { if (proxyOptions.server) {
@@ -159,26 +283,48 @@ export class RemoteBrowser {
password: proxyOptions.password ? proxyOptions.password : undefined, password: proxyOptions.password ? proxyOptions.password : undefined,
}; };
} }
const browserUserAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.5481.38 Safari/537.36";
contextOptions.userAgent = browserUserAgent;
this.context = await this.browser.newContext(contextOptions); this.context = await this.browser.newContext(contextOptions);
await this.context.addInitScript(
`const defaultGetter = Object.getOwnPropertyDescriptor(
Navigator.prototype,
"webdriver"
).get;
defaultGetter.apply(navigator);
defaultGetter.toString();
Object.defineProperty(Navigator.prototype, "webdriver", {
set: undefined,
enumerable: true,
configurable: true,
get: new Proxy(defaultGetter, {
apply: (target, thisArg, args) => {
Reflect.apply(target, thisArg, args);
return false;
},
}),
});
const patchedGetter = Object.getOwnPropertyDescriptor(
Navigator.prototype,
"webdriver"
).get;
patchedGetter.apply(navigator);
patchedGetter.toString();`
);
this.currentPage = await this.context.newPage(); this.currentPage = await this.context.newPage();
this.currentPage.on('framenavigated', (frame) => { await this.setupPageEventListeners(this.currentPage);
if (frame === this.currentPage?.mainFrame()) {
this.socket.emit('urlChanged', this.currentPage.url());
}
});
// await this.currentPage.setExtraHTTPHeaders({ try {
// 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3' const blocker = await PlaywrightBlocker.fromLists(fetch, ['https://easylist.to/easylist/easylist.txt']);
// }); await blocker.enableBlockingInPage(this.currentPage);
const blocker = await PlaywrightBlocker.fromPrebuiltAdsAndTracking(fetch); this.client = await this.currentPage.context().newCDPSession(this.currentPage);
await blocker.enableBlockingInPage(this.currentPage); await blocker.disableBlockingInPage(this.currentPage);
this.client = await this.currentPage.context().newCDPSession(this.currentPage); console.log('Adblocker initialized');
await blocker.disableBlockingInPage(this.currentPage); } catch (error: any) {
console.warn('Failed to initialize adblocker, continuing without it:', error.message);
// Still need to set up the CDP session even if blocker fails
this.client = await this.currentPage.context().newCDPSession(this.currentPage);
}
}; };
/** /**
@@ -242,7 +388,7 @@ export class RemoteBrowser {
return; return;
} }
this.client.on('Page.screencastFrame', ({ data: base64, sessionId }) => { this.client.on('Page.screencastFrame', ({ data: base64, sessionId }) => {
this.emitScreenshot(base64) this.emitScreenshot(Buffer.from(base64, 'base64'))
setTimeout(async () => { setTimeout(async () => {
try { try {
if (!this.client) { if (!this.client) {
@@ -262,16 +408,49 @@ export class RemoteBrowser {
* If an interpretation was running it will be stopped. * If an interpretation was running it will be stopped.
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
public switchOff = async (): Promise<void> => { public async switchOff(): Promise<void> {
await this.interpreter.stopInterpretation(); try {
if (this.browser) { await this.interpreter.stopInterpretation();
await this.stopScreencast();
await this.browser.close(); if (this.screencastInterval) {
} else { clearInterval(this.screencastInterval);
logger.log('error', 'Browser wasn\'t initialized'); }
logger.log('error', 'Switching off the browser failed');
if (this.client) {
await this.stopScreencast();
}
if (this.browser) {
await this.browser.close();
}
this.screenshotQueue = [];
//this.performanceMonitor.reset();
} catch (error) {
logger.error('Error during browser shutdown:', error);
} }
}; }
private async optimizeScreenshot(screenshot: Buffer): Promise<Buffer> {
try {
return await sharp(screenshot)
.jpeg({
quality: Math.round(SCREENCAST_CONFIG.compressionQuality * 100),
progressive: true
})
.resize({
width: SCREENCAST_CONFIG.maxWidth,
height: SCREENCAST_CONFIG.maxHeight,
fit: 'inside',
withoutEnlargement: true
})
.toBuffer();
} catch (error) {
logger.error('Screenshot optimization failed:', error);
return screenshot;
}
}
/** /**
* Makes and emits a single screenshot to the client side. * Makes and emits a single screenshot to the client side.
@@ -281,7 +460,7 @@ export class RemoteBrowser {
try { try {
const screenshot = await this.currentPage?.screenshot(); const screenshot = await this.currentPage?.screenshot();
if (screenshot) { if (screenshot) {
this.emitScreenshot(screenshot.toString('base64')); this.emitScreenshot(screenshot);
} }
} catch (e) { } catch (e) {
const { message } = e as Error; const { message } = e as Error;
@@ -370,11 +549,7 @@ export class RemoteBrowser {
await this.stopScreencast(); await this.stopScreencast();
this.currentPage = page; this.currentPage = page;
this.currentPage.on('framenavigated', (frame) => { await this.setupPageEventListeners(this.currentPage);
if (frame === this.currentPage?.mainFrame()) {
this.socket.emit('urlChanged', this.currentPage.url());
}
});
//await this.currentPage.setViewportSize({ height: 400, width: 900 }) //await this.currentPage.setViewportSize({ height: 400, width: 900 })
this.client = await this.currentPage.context().newCDPSession(this.currentPage); this.client = await this.currentPage.context().newCDPSession(this.currentPage);
@@ -402,14 +577,8 @@ export class RemoteBrowser {
await this.currentPage?.close(); await this.currentPage?.close();
this.currentPage = newPage; this.currentPage = newPage;
if (this.currentPage) { if (this.currentPage) {
this.currentPage.on('framenavigated', (frame) => { await this.setupPageEventListeners(this.currentPage);
if (frame === this.currentPage?.mainFrame()) {
this.socket.emit('urlChanged', this.currentPage.url());
}
});
// this.currentPage.on('load', (page) => {
// this.socket.emit('urlChanged', page.url());
// })
this.client = await this.currentPage.context().newCDPSession(this.currentPage); this.client = await this.currentPage.context().newCDPSession(this.currentPage);
await this.subscribeToScreencast(); await this.subscribeToScreencast();
} else { } else {
@@ -423,37 +592,85 @@ export class RemoteBrowser {
* Should be called only once after the browser is fully initialized. * Should be called only once after the browser is fully initialized.
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
private startScreencast = async (): Promise<void> => { private async startScreencast(): Promise<void> {
if (!this.client) { if (!this.client) {
logger.log('warn', 'client is not initialized'); logger.warn('Client is not initialized');
return; return;
} }
await this.client.send('Page.startScreencast', { format: 'jpeg', quality: 75 });
logger.log('info', `Browser started with screencasting a page.`);
};
/** try {
* Unsubscribes the current page from the screencast session. await this.client.send('Page.startScreencast', {
* @returns {Promise<void>} format: SCREENCAST_CONFIG.format,
*/ });
private stopScreencast = async (): Promise<void> => {
if (!this.client) { // Set up screencast frame handler
logger.log('error', 'client is not initialized'); this.client.on('Page.screencastFrame', async ({ data, sessionId }) => {
logger.log('error', 'Screencast stop failed'); try {
} else { const buffer = Buffer.from(data, 'base64');
await this.client.send('Page.stopScreencast'); await this.emitScreenshot(buffer);
logger.log('info', `Browser stopped with screencasting.`); await this.client?.send('Page.screencastFrameAck', { sessionId });
} catch (error) {
logger.error('Screencast frame processing failed:', error);
}
});
logger.info('Screencast started successfully');
} catch (error) {
logger.error('Failed to start screencast:', error);
} }
}; }
private async stopScreencast(): Promise<void> {
if (!this.client) {
logger.error('Client is not initialized');
return;
}
try {
await this.client.send('Page.stopScreencast');
this.screenshotQueue = [];
this.isProcessingScreenshot = false;
logger.info('Screencast stopped successfully');
} catch (error) {
logger.error('Failed to stop screencast:', error);
}
}
/** /**
* Helper for emitting the screenshot of browser's active page through websocket. * Helper for emitting the screenshot of browser's active page through websocket.
* @param payload the screenshot binary data * @param payload the screenshot binary data
* @returns void * @returns void
*/ */
private emitScreenshot = (payload: any): void => { private emitScreenshot = async (payload: Buffer): Promise<void> => {
const dataWithMimeType = ('data:image/jpeg;base64,').concat(payload); if (this.isProcessingScreenshot) {
this.socket.emit('screencast', dataWithMimeType); if (this.screenshotQueue.length < SCREENCAST_CONFIG.maxQueueSize) {
logger.log('debug', `Screenshot emitted`); this.screenshotQueue.push(payload);
}
return;
}
this.isProcessingScreenshot = true;
try {
const optimizedScreenshot = await this.optimizeScreenshot(payload);
const base64Data = optimizedScreenshot.toString('base64');
const dataWithMimeType = `data:image/jpeg;base64,${base64Data}`;
this.socket.emit('screencast', dataWithMimeType);
logger.debug('Screenshot emitted');
} catch (error) {
logger.error('Screenshot emission failed:', error);
} finally {
this.isProcessingScreenshot = false;
if (this.screenshotQueue.length > 0) {
const nextScreenshot = this.screenshotQueue.shift();
if (nextScreenshot) {
setTimeout(() => this.emitScreenshot(nextScreenshot), 1000 / SCREENCAST_CONFIG.targetFPS);
}
}
}
}; };
} }

View File

@@ -6,7 +6,7 @@
import { Socket } from 'socket.io'; import { Socket } from 'socket.io';
import logger from "../logger"; import logger from "../logger";
import { Coordinates, ScrollDeltas, KeyboardInput } from '../types'; import { Coordinates, ScrollDeltas, KeyboardInput, DatePickerEventData } from '../types';
import { browserPool } from "../server"; import { browserPool } from "../server";
import { WorkflowGenerator } from "../workflow-management/classes/Generator"; import { WorkflowGenerator } from "../workflow-management/classes/Generator";
import { Page } from "playwright"; import { Page } from "playwright";
@@ -223,6 +223,53 @@ const handleKeydown = async (generator: WorkflowGenerator, page: Page, { key, co
logger.log('debug', `Key ${key} pressed`); logger.log('debug', `Key ${key} pressed`);
}; };
/**
* Handles the date selection event.
* @param generator - the workflow generator {@link Generator}
* @param page - the active page of the remote browser
* @param data - the data of the date selection event {@link DatePickerEventData}
* @category BrowserManagement
*/
const handleDateSelection = async (generator: WorkflowGenerator, page: Page, data: DatePickerEventData) => {
await generator.onDateSelection(page, data);
logger.log('debug', `Date ${data.value} selected`);
}
const onDateSelection = async (data: DatePickerEventData) => {
logger.log('debug', 'Handling date selection event emitted from client');
await handleWrapper(handleDateSelection, data);
}
const handleDropdownSelection = async (generator: WorkflowGenerator, page: Page, data: { selector: string, value: string }) => {
await generator.onDropdownSelection(page, data);
logger.log('debug', `Dropdown value ${data.value} selected`);
}
const onDropdownSelection = async (data: { selector: string, value: string }) => {
logger.log('debug', 'Handling dropdown selection event emitted from client');
await handleWrapper(handleDropdownSelection, data);
}
const handleTimeSelection = async (generator: WorkflowGenerator, page: Page, data: { selector: string, value: string }) => {
await generator.onTimeSelection(page, data);
logger.log('debug', `Time value ${data.value} selected`);
}
const onTimeSelection = async (data: { selector: string, value: string }) => {
logger.log('debug', 'Handling time selection event emitted from client');
await handleWrapper(handleTimeSelection, data);
}
const handleDateTimeLocalSelection = async (generator: WorkflowGenerator, page: Page, data: { selector: string, value: string }) => {
await generator.onDateTimeLocalSelection(page, data);
logger.log('debug', `DateTime Local value ${data.value} selected`);
}
const onDateTimeLocalSelection = async (data: { selector: string, value: string }) => {
logger.log('debug', 'Handling datetime-local selection event emitted from client');
await handleWrapper(handleDateTimeLocalSelection, data);
}
/** /**
* A wrapper function for handling the keyup event. * A wrapper function for handling the keyup event.
* @param keyboardInput - the keyboard input of the keyup event * @param keyboardInput - the keyboard input of the keyup event
@@ -378,6 +425,10 @@ const registerInputHandlers = (socket: Socket) => {
socket.on("input:refresh", onRefresh); socket.on("input:refresh", onRefresh);
socket.on("input:back", onGoBack); socket.on("input:back", onGoBack);
socket.on("input:forward", onGoForward); socket.on("input:forward", onGoForward);
socket.on("input:date", onDateSelection);
socket.on("input:dropdown", onDropdownSelection);
socket.on("input:time", onTimeSelection);
socket.on("input:datetime-local", onDateTimeLocalSelection);
socket.on("action", onGenerateAction); socket.on("action", onGenerateAction);
}; };

View File

@@ -52,7 +52,7 @@ router.post("/register", async (req, res) => {
userId: user.id, userId: user.id,
registeredAt: new Date().toISOString(), registeredAt: new Date().toISOString(),
}); });
console.log(`User registered - ${user.email}`); 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}`);

View File

@@ -18,6 +18,8 @@ import { AuthenticatedRequest } from './record';
import { computeNextRun } from '../utils/schedule'; import { computeNextRun } from '../utils/schedule';
import { capture } from "../utils/analytics"; import { capture } from "../utils/analytics";
import { tryCatch } from 'bullmq'; import { tryCatch } from 'bullmq';
import { WorkflowFile } from 'maxun-core';
import { Page } from 'playwright';
chromium.use(stealthPlugin()); chromium.use(stealthPlugin());
export const router = Router(); export const router = Router();
@@ -422,6 +424,17 @@ router.get('/runs/run/:id', requireSignIn, async (req, res) => {
} }
}); });
function AddGeneratedFlags(workflow: WorkflowFile) {
const copy = JSON.parse(JSON.stringify(workflow));
for (let i = 0; i < workflow.workflow.length; i++) {
copy.workflow[i].what.unshift({
action: 'flag',
args: ['generated'],
});
}
return copy;
};
/** /**
* PUT endpoint for finishing a run and saving it to the storage. * PUT endpoint for finishing a run and saving it to the storage.
*/ */
@@ -443,10 +456,11 @@ router.post('/runs/run/:id', requireSignIn, async (req: AuthenticatedRequest, re
// interpret the run in active browser // interpret the run in active browser
const browser = browserPool.getRemoteBrowser(plainRun.browserId); const browser = browserPool.getRemoteBrowser(plainRun.browserId);
const currentPage = browser?.getCurrentPage(); let currentPage = browser?.getCurrentPage();
if (browser && currentPage) { if (browser && currentPage) {
const workflow = AddGeneratedFlags(recording.recording);
const interpretationInfo = await browser.interpreter.InterpretRecording( const interpretationInfo = await browser.interpreter.InterpretRecording(
recording.recording, currentPage, plainRun.interpreterSettings); workflow, currentPage, (newPage: Page) => currentPage = newPage, plainRun.interpreterSettings);
const binaryOutputService = new BinaryOutputService('maxun-run-screenshots'); const binaryOutputService = new BinaryOutputService('maxun-run-screenshots');
const uploadedBinaryOutput = await binaryOutputService.uploadAndStoreBinaryOutput(run, interpretationInfo.binaryOutput); const uploadedBinaryOutput = await binaryOutputService.uploadAndStoreBinaryOutput(run, interpretationInfo.binaryOutput);
await destroyRemoteBrowser(plainRun.browserId); await destroyRemoteBrowser(plainRun.browserId);

View File

@@ -1,13 +1,25 @@
import swaggerJSDoc from 'swagger-jsdoc'; import swaggerJSDoc from 'swagger-jsdoc';
import path from 'path'; import path from 'path';
import fs from 'fs';
// Dynamically determine API file paths
const jsFiles = [path.join(__dirname, '../api/*.js')]
const tsFiles = [path.join(__dirname, '../api/*.ts')]
let apis = fs.existsSync(jsFiles[0]) ? jsFiles : tsFiles;
if (!apis) {
throw new Error('No valid API files found! Ensure either .js or .ts files exist in the ../api/ directory.');
}
const options = { const options = {
definition: { definition: {
openapi: '3.0.0', openapi: '3.0.0',
info: { info: {
title: 'Maxun API Documentation', title: 'Website to API',
version: '1.0.0', version: '1.0.0',
description: 'API documentation for Maxun (https://github.com/getmaxun/maxun)', description:
'Maxun lets you get the data your robot extracted and run robots via API. All you need to do is input the Maxun API key by clicking Authorize below.',
}, },
components: { components: {
securitySchemes: { securitySchemes: {
@@ -15,7 +27,8 @@ const options = {
type: 'apiKey', type: 'apiKey',
in: 'header', in: 'header',
name: 'x-api-key', name: 'x-api-key',
description: 'API key for authorization. You can find your API key in the "API Key" section on Maxun Dashboard.', description:
'API key for authorization. You can find your API key in the "API Key" section on Maxun Dashboard.',
}, },
}, },
}, },
@@ -25,7 +38,7 @@ const options = {
}, },
], ],
}, },
apis: process.env.NODE_ENV === 'production' ? [path.join(__dirname, '../api/*.js')] : [path.join(__dirname, '../api/*.ts')] apis,
}; };
const swaggerSpec = swaggerJSDoc(options); const swaggerSpec = swaggerJSDoc(options);

View File

@@ -20,6 +20,16 @@ export interface Coordinates {
y: number; y: number;
} }
/**
* interface to handle date picker events.
* @category Types
*/
export interface DatePickerEventData {
coordinates: Coordinates;
selector: string;
value: string;
}
/** /**
* Holds the deltas of a wheel/scroll event. * Holds the deltas of a wheel/scroll event.
* @category Types * @category Types
@@ -119,6 +129,17 @@ export interface BaseActionInfo {
hasOnlyText: boolean; hasOnlyText: boolean;
} }
interface IframeSelector {
full: string;
isIframe: boolean;
}
interface ShadowSelector {
full: string;
mode: string;
}
/** /**
* Holds all the possible css selectors that has been found for an element. * Holds all the possible css selectors that has been found for an element.
* @category Types * @category Types
@@ -133,6 +154,8 @@ export interface Selectors {
hrefSelector: string|null; hrefSelector: string|null;
accessibilitySelector: string|null; accessibilitySelector: string|null;
formSelector: string|null; formSelector: string|null;
iframeSelector: IframeSelector|null;
shadowSelector: ShadowSelector|null;
} }
/** /**
@@ -146,7 +169,7 @@ export interface BaseAction extends BaseActionInfo{
associatedActions: ActionType[]; associatedActions: ActionType[];
inputType: string | undefined; inputType: string | undefined;
value: string | undefined; value: string | undefined;
selectors: { [key: string]: string | null }; selectors: Selectors;
timestamp: number; timestamp: number;
isPassword: boolean; isPassword: boolean;
/** /**

View File

@@ -1,4 +1,4 @@
import { Action, ActionType, Coordinates, TagName } from "../../types"; import { Action, ActionType, Coordinates, TagName, DatePickerEventData } from "../../types";
import { WhereWhatPair, WorkflowFile } from 'maxun-core'; import { WhereWhatPair, WorkflowFile } from 'maxun-core';
import logger from "../../logger"; import logger from "../../logger";
import { Socket } from "socket.io"; import { Socket } from "socket.io";
@@ -140,19 +140,22 @@ export class WorkflowGenerator {
socket.on('decision', async ({ pair, actionType, decision }) => { socket.on('decision', async ({ pair, actionType, decision }) => {
const id = browserPool.getActiveBrowserId(); const id = browserPool.getActiveBrowserId();
if (id) { if (id) {
const activeBrowser = browserPool.getRemoteBrowser(id); // const activeBrowser = browserPool.getRemoteBrowser(id);
const currentPage = activeBrowser?.getCurrentPage(); // const currentPage = activeBrowser?.getCurrentPage();
if (decision) { if (!decision) {
switch (actionType) { switch (actionType) {
case 'customAction': case 'customAction':
pair.where.selectors = [this.generatedData.lastUsedSelector]; // pair.where.selectors = [this.generatedData.lastUsedSelector];
pair.where.selectors = pair.where.selectors.filter(
(selector: string) => selector !== this.generatedData.lastUsedSelector
);
break; break;
default: break; default: break;
} }
} }
if (currentPage) { // if (currentPage) {
await this.addPairToWorkflowAndNotifyClient(pair, currentPage); // await this.addPairToWorkflowAndNotifyClient(pair, currentPage);
} // }
} }
}) })
socket.on('updatePair', (data) => { socket.on('updatePair', (data) => {
@@ -252,6 +255,85 @@ export class WorkflowGenerator {
logger.log('info', `Workflow emitted`); logger.log('info', `Workflow emitted`);
}; };
public onDateSelection = async (page: Page, data: DatePickerEventData) => {
const { selector, value } = data;
try {
await page.fill(selector, value);
} catch (error) {
console.error("Failed to fill date value:", error);
}
const pair: WhereWhatPair = {
where: { url: this.getBestUrl(page.url()) },
what: [{
action: 'fill',
args: [selector, value],
}],
};
await this.addPairToWorkflowAndNotifyClient(pair, page);
};
public onDropdownSelection = async (page: Page, data: { selector: string, value: string }) => {
const { selector, value } = data;
try {
await page.selectOption(selector, value);
} catch (error) {
console.error("Failed to fill date value:", error);
}
const pair: WhereWhatPair = {
where: { url: this.getBestUrl(page.url()) },
what: [{
action: 'selectOption',
args: [selector, value],
}],
};
await this.addPairToWorkflowAndNotifyClient(pair, page);
};
public onTimeSelection = async (page: Page, data: { selector: string, value: string }) => {
const { selector, value } = data;
try {
await page.fill(selector, value);
} catch (error) {
console.error("Failed to set time value:", error);
}
const pair: WhereWhatPair = {
where: { url: this.getBestUrl(page.url()) },
what: [{
action: 'fill',
args: [selector, value],
}],
};
await this.addPairToWorkflowAndNotifyClient(pair, page);
};
public onDateTimeLocalSelection = async (page: Page, data: { selector: string, value: string }) => {
const { selector, value } = data;
try {
await page.fill(selector, value);
} catch (error) {
console.error("Failed to fill datetime-local value:", error);
}
const pair: WhereWhatPair = {
where: { url: this.getBestUrl(page.url()) },
what: [{
action: 'fill',
args: [selector, value],
}],
};
await this.addPairToWorkflowAndNotifyClient(pair, page);
};
/** /**
* Generates a pair for the click event. * Generates a pair for the click event.
@@ -263,6 +345,81 @@ export class WorkflowGenerator {
let where: WhereWhatPair["where"] = { url: this.getBestUrl(page.url()) }; let where: WhereWhatPair["where"] = { url: this.getBestUrl(page.url()) };
const selector = await this.generateSelector(page, coordinates, ActionType.Click); const selector = await this.generateSelector(page, coordinates, ActionType.Click);
logger.log('debug', `Element's selector: ${selector}`); logger.log('debug', `Element's selector: ${selector}`);
const elementInfo = await getElementInformation(page, coordinates, '', false);
console.log("Element info: ", elementInfo);
// Check if clicked element is a select dropdown
const isDropdown = elementInfo?.tagName === 'SELECT';
if (isDropdown && elementInfo.innerHTML) {
// Parse options from innerHTML
const options = elementInfo.innerHTML
.split('<option')
.slice(1) // Remove first empty element
.map(optionHtml => {
const valueMatch = optionHtml.match(/value="([^"]*)"/);
const disabledMatch = optionHtml.includes('disabled="disabled"');
const selectedMatch = optionHtml.includes('selected="selected"');
// Extract text content between > and </option>
const textMatch = optionHtml.match(/>([^<]*)</);
const text = textMatch
? textMatch[1]
.replace(/\n/g, '') // Remove all newlines
.replace(/\s+/g, ' ') // Replace multiple spaces with single space
.trim()
: '';
return {
value: valueMatch ? valueMatch[1] : '',
text,
disabled: disabledMatch,
selected: selectedMatch
};
});
// Notify client to show dropdown overlay
this.socket.emit('showDropdown', {
coordinates,
selector,
options
});
return;
}
// Check if clicked element is a date input
const isDateInput = elementInfo?.tagName === 'INPUT' && elementInfo?.attributes?.type === 'date';
if (isDateInput) {
// Notify client to show datepicker overlay
this.socket.emit('showDatePicker', {
coordinates,
selector
});
return;
}
const isTimeInput = elementInfo?.tagName === 'INPUT' && elementInfo?.attributes?.type === 'time';
if (isTimeInput) {
this.socket.emit('showTimePicker', {
coordinates,
selector
});
return;
}
const isDateTimeLocal = elementInfo?.tagName === 'INPUT' && elementInfo?.attributes?.type === 'datetime-local';
if (isDateTimeLocal) {
this.socket.emit('showDateTimePicker', {
coordinates,
selector
});
return;
}
//const element = await getElementMouseIsOver(page, coordinates); //const element = await getElementMouseIsOver(page, coordinates);
//logger.log('debug', `Element: ${JSON.stringify(element, null, 2)}`); //logger.log('debug', `Element: ${JSON.stringify(element, null, 2)}`);
if (selector) { if (selector) {
@@ -360,6 +517,8 @@ export class WorkflowGenerator {
}], }],
} }
await this.addPairToWorkflowAndNotifyClient(pair, page);
if (this.generatedData.lastUsedSelector) { if (this.generatedData.lastUsedSelector) {
const elementInfo = await this.getLastUsedSelectorInfo(page, this.generatedData.lastUsedSelector); const elementInfo = await this.getLastUsedSelectorInfo(page, this.generatedData.lastUsedSelector);
@@ -372,9 +531,7 @@ export class WorkflowGenerator {
innerText: elementInfo.innerText, innerText: elementInfo.innerText,
} }
}); });
} else { }
await this.addPairToWorkflowAndNotifyClient(pair, page);
}
}; };
/** /**
@@ -541,10 +698,9 @@ export class WorkflowGenerator {
* @returns {Promise<string|null>} * @returns {Promise<string|null>}
*/ */
private generateSelector = async (page: Page, coordinates: Coordinates, action: ActionType) => { private generateSelector = async (page: Page, coordinates: Coordinates, action: ActionType) => {
const elementInfo = await getElementInformation(page, coordinates); const elementInfo = await getElementInformation(page, coordinates, this.listSelector, this.getList);
const selectorBasedOnCustomAction = (this.getList === true) const selectorBasedOnCustomAction = (this.getList === true)
? await getNonUniqueSelectors(page, coordinates) ? await getNonUniqueSelectors(page, coordinates, this.listSelector)
: await getSelectors(page, coordinates); : await getSelectors(page, coordinates);
const bestSelector = getBestSelectorForAction( const bestSelector = getBestSelectorForAction(
@@ -570,21 +726,30 @@ export class WorkflowGenerator {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
public generateDataForHighlighter = async (page: Page, coordinates: Coordinates) => { public generateDataForHighlighter = async (page: Page, coordinates: Coordinates) => {
const rect = await getRect(page, coordinates); const rect = await getRect(page, coordinates, this.listSelector, this.getList);
const displaySelector = await this.generateSelector(page, coordinates, ActionType.Click); const displaySelector = await this.generateSelector(page, coordinates, ActionType.Click);
const elementInfo = await getElementInformation(page, coordinates); const elementInfo = await getElementInformation(page, coordinates, this.listSelector, this.getList);
if (rect) { if (rect) {
const highlighterData = {
rect,
selector: displaySelector,
elementInfo,
// Include shadow DOM specific information
shadowInfo: elementInfo?.isShadowRoot ? {
mode: elementInfo.shadowRootMode,
content: elementInfo.shadowRootContent
} : null
};
if (this.getList === true) { if (this.getList === true) {
if (this.listSelector !== '') { if (this.listSelector !== '') {
const childSelectors = await getChildSelectors(page, this.listSelector || ''); const childSelectors = await getChildSelectors(page, this.listSelector || '');
this.socket.emit('highlighter', { rect, selector: displaySelector, elementInfo, childSelectors }) this.socket.emit('highlighter', { ...highlighterData, childSelectors })
console.log(`Child Selectors: ${childSelectors}`)
console.log(`Parent Selector: ${this.listSelector}`)
} else { } else {
this.socket.emit('highlighter', { rect, selector: displaySelector, elementInfo }); this.socket.emit('highlighter', { ...highlighterData });
} }
} else { } else {
this.socket.emit('highlighter', { rect, selector: displaySelector, elementInfo }); this.socket.emit('highlighter', { ...highlighterData });
} }
} }
} }

View File

@@ -244,7 +244,12 @@ export class WorkflowInterpreter {
* @param page The page instance used to interact with the browser. * @param page The page instance used to interact with the browser.
* @param settings The settings to use for the interpretation. * @param settings The settings to use for the interpretation.
*/ */
public InterpretRecording = async (workflow: WorkflowFile, page: Page, settings: InterpreterSettings) => { public InterpretRecording = async (
workflow: WorkflowFile,
page: Page,
updatePageOnPause: (page: Page) => void,
settings: InterpreterSettings
) => {
const params = settings.params ? settings.params : null; const params = settings.params ? settings.params : null;
delete settings.params; delete settings.params;
@@ -262,7 +267,7 @@ export class WorkflowInterpreter {
this.socket.emit('debugMessage', msg) this.socket.emit('debugMessage', msg)
}, },
}, },
serializableCallback: (data: string) => { serializableCallback: (data: any) => {
this.serializableData.push(data); this.serializableData.push(data);
this.socket.emit('serializableCallback', data); this.socket.emit('serializableCallback', data);
}, },
@@ -275,6 +280,23 @@ export class WorkflowInterpreter {
const interpreter = new Interpreter(decryptedWorkflow, options); const interpreter = new Interpreter(decryptedWorkflow, options);
this.interpreter = interpreter; this.interpreter = interpreter;
interpreter.on('flag', async (page, resume) => {
if (this.activeId !== null && this.breakpoints[this.activeId]) {
logger.log('debug', `breakpoint hit id: ${this.activeId}`);
this.socket.emit('breakpointHit');
this.interpretationIsPaused = true;
}
if (this.interpretationIsPaused) {
this.interpretationResume = resume;
logger.log('debug', `Paused inside of flag: ${page.url()}`);
updatePageOnPause(page);
this.socket.emit('log', '----- The interpretation has been paused -----', false);
} else {
resume();
}
});
const status = await interpreter.run(page, params); const status = await interpreter.run(page, params);
const lastArray = this.serializableData.length > 1 const lastArray = this.serializableData.length > 1

View File

@@ -11,6 +11,8 @@ import Run from "../../models/Run";
import { getDecryptedProxyConfig } from "../../routes/proxy"; import { getDecryptedProxyConfig } from "../../routes/proxy";
import { BinaryOutputService } from "../../storage/mino"; import { BinaryOutputService } from "../../storage/mino";
import { capture } from "../../utils/analytics"; import { capture } from "../../utils/analytics";
import { WorkflowFile } from "maxun-core";
import { Page } from "playwright";
chromium.use(stealthPlugin()); chromium.use(stealthPlugin());
async function createWorkflowAndStoreMetadata(id: string, userId: string) { async function createWorkflowAndStoreMetadata(id: string, userId: string) {
@@ -71,7 +73,7 @@ async function createWorkflowAndStoreMetadata(id: string, userId: string) {
} catch (e) { } catch (e) {
const { message } = e as Error; const { message } = e as Error;
logger.log('info', `Error while scheduling a run with id: ${id}`); logger.log('info', `Error while scheduling a run with id: ${id}`);
console.log(message); console.log(`Error while scheduling a run with id: ${id}:`, message);
return { return {
success: false, success: false,
error: message, error: message,
@@ -79,6 +81,17 @@ async function createWorkflowAndStoreMetadata(id: string, userId: string) {
} }
} }
function AddGeneratedFlags(workflow: WorkflowFile) {
const copy = JSON.parse(JSON.stringify(workflow));
for (let i = 0; i < workflow.workflow.length; i++) {
copy.workflow[i].what.unshift({
action: 'flag',
args: ['generated'],
});
}
return copy;
};
async function executeRun(id: string) { async function executeRun(id: string) {
try { try {
const run = await Run.findOne({ where: { runId: id } }); const run = await Run.findOne({ where: { runId: id } });
@@ -106,13 +119,15 @@ async function executeRun(id: string) {
throw new Error('Could not access browser'); throw new Error('Could not access browser');
} }
const currentPage = await browser.getCurrentPage(); let currentPage = await browser.getCurrentPage();
if (!currentPage) { if (!currentPage) {
throw new Error('Could not create a new page'); throw new Error('Could not create a new page');
} }
const workflow = AddGeneratedFlags(recording.recording);
const interpretationInfo = await browser.interpreter.InterpretRecording( const interpretationInfo = await browser.interpreter.InterpretRecording(
recording.recording, currentPage, plainRun.interpreterSettings); workflow, currentPage, (newPage: Page) => currentPage = newPage, plainRun.interpreterSettings
);
const binaryOutputService = new BinaryOutputService('maxun-run-screenshots'); const binaryOutputService = new BinaryOutputService('maxun-run-screenshots');
const uploadedBinaryOutput = await binaryOutputService.uploadAndStoreBinaryOutput(run, interpretationInfo.binaryOutput); const uploadedBinaryOutput = await binaryOutputService.uploadAndStoreBinaryOutput(run, interpretationInfo.binaryOutput);

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,16 @@ export const getBestSelectorForAction = (action: Action) => {
case ActionType.Hover: case ActionType.Hover:
case ActionType.DragAndDrop: { case ActionType.DragAndDrop: {
const selectors = action.selectors; const selectors = action.selectors;
if (selectors?.iframeSelector?.full) {
return selectors.iframeSelector.full;
}
if (selectors?.shadowSelector?.full) {
return selectors.shadowSelector.full;
}
// less than 25 characters, and element only has text inside // less than 25 characters, and element only has text inside
const textSelector = const textSelector =
selectors?.text?.length != null && selectors?.text?.length != null &&
@@ -75,6 +85,11 @@ export const getBestSelectorForAction = (action: Action) => {
case ActionType.Input: case ActionType.Input:
case ActionType.Keydown: { case ActionType.Keydown: {
const selectors = action.selectors; const selectors = action.selectors;
if (selectors?.shadowSelector?.full) {
return selectors.shadowSelector.full;
}
return ( return (
selectors.testIdSelector ?? selectors.testIdSelector ??
selectors?.id ?? selectors?.id ??

View File

@@ -1,12 +1,88 @@
import React from 'react'; import React from "react";
import { Routes, Route } from 'react-router-dom'; import { Routes, Route } from "react-router-dom";
import { createTheme } from "@mui/material/styles"; import { ThemeProvider, createTheme } from "@mui/material/styles";
import { GlobalInfoProvider } from "./context/globalInfo"; import { GlobalInfoProvider } from "./context/globalInfo";
import { PageWrapper } from "./pages/PageWrappper"; import { PageWrapper } from "./pages/PageWrappper";
import i18n from "./i18n";
import ThemeModeProvider from './context/theme-provider'; import ThemeModeProvider from './context/theme-provider';
const theme = createTheme({
palette: {
primary: {
main: "#ff00c3",
contrastText: "#ffffff",
},
},
components: {
MuiButton: {
styleOverrides: {
root: {
// Default styles for all buttons (optional)
textTransform: "none",
},
containedPrimary: {
// Styles for 'contained' variant with 'primary' color
"&:hover": {
backgroundColor: "#ff66d9",
},
},
outlined: {
// Apply white background for all 'outlined' variant buttons
backgroundColor: "#ffffff",
"&:hover": {
backgroundColor: "#f0f0f0", // Optional lighter background on hover
},
},
},
},
MuiLink: {
styleOverrides: {
root: {
"&:hover": {
color: "#ff00c3",
},
},
},
},
MuiIconButton: {
styleOverrides: {
root: {
// '&:hover': {
// color: "#ff66d9",
// },
},
},
},
MuiTab: {
styleOverrides: {
root: {
textTransform: "none",
},
},
},
MuiAlert: {
styleOverrides: {
standardInfo: {
backgroundColor: "#fce1f4",
color: "#ff00c3",
"& .MuiAlert-icon": {
color: "#ff00c3",
},
},
},
},
MuiAlertTitle: {
styleOverrides: {
root: {
"& .MuiAlert-icon": {
color: "#ffffff",
},
},
},
},
},
});
function App() { function App() {
return ( return (
@@ -17,6 +93,16 @@ function App() {
</Routes> </Routes>
</GlobalInfoProvider> </GlobalInfoProvider>
</ThemeModeProvider> </ThemeModeProvider>
<!-- <ThemeProvider theme={theme}>
<GlobalInfoProvider>
<Routes>
<Route path="/*" element={<PageWrapper />} />
</Routes>
</GlobalInfoProvider>
</ThemeProvider> -->
); );
} }

View File

@@ -5,11 +5,6 @@ import { ScheduleSettings } from "../components/molecules/ScheduleSettings";
import { CreateRunResponse, ScheduleRunResponse } from "../pages/MainPage"; import { CreateRunResponse, ScheduleRunResponse } from "../pages/MainPage";
import { apiUrl } from "../apiConfig"; import { apiUrl } from "../apiConfig";
export const getStoredRecordings = async (): Promise<string[] | null> => { export const getStoredRecordings = async (): Promise<string[] | null> => {
try { try {
const response = await axios.get(`${apiUrl}/storage/recordings`); const response = await axios.get(`${apiUrl}/storage/recordings`);
@@ -82,11 +77,7 @@ export const getStoredRecording = async (id: string) => {
} }
} }
export const checkRunsForRecording = async (id: string): Promise<boolean> => { export const checkRunsForRecording = async (id: string): Promise<boolean> => {
try { try {
const response = await axios.get(`${apiUrl}/storage/recordings/${id}/runs`); const response = await axios.get(`${apiUrl}/storage/recordings/${id}/runs`);
@@ -99,32 +90,26 @@ export const checkRunsForRecording = async (id: string): Promise<boolean> => {
} }
}; };
export const deleteRecordingFromStorage = async (id: string): Promise<boolean> => { export const deleteRecordingFromStorage = async (id: string): Promise<boolean> => {
const hasRuns = await checkRunsForRecording(id); const hasRuns = await checkRunsForRecording(id);
if (hasRuns) { if (hasRuns) {
return false; return false;
} }
try { try {
const response = await axios.delete(`${apiUrl}/storage/recordings/${id}`); const response = await axios.delete(`${apiUrl}/storage/recordings/${id}`);
if (response.status === 200) { if (response.status === 200) {
return true; return true;
} else { } else {
throw new Error(`Couldn't delete stored recording ${id}`); throw new Error(`Couldn't delete stored recording ${id}`);
} }
} catch (error: any) { } catch (error: any) {
console.log(error); console.log(error);
return false; return false;
} }
}; };
export const deleteRunFromStorage = async (id: string): Promise<boolean> => { export const deleteRunFromStorage = async (id: string): Promise<boolean> => {
@@ -159,7 +144,7 @@ export const createRunForStoredRecording = async (id: string, settings: RunSetti
try { try {
const response = await axios.put( const response = await axios.put(
`${apiUrl}/storage/runs/${id}`, `${apiUrl}/storage/runs/${id}`,
{ ...settings }); { ...settings });
if (response.status === 200) { if (response.status === 200) {
return response.data; return response.data;
} else { } else {

View File

@@ -3,7 +3,7 @@ import { emptyWorkflow } from "../shared/constants";
import { default as axios, AxiosResponse } from "axios"; import { default as axios, AxiosResponse } from "axios";
import { apiUrl } from "../apiConfig"; import { apiUrl } from "../apiConfig";
export const getActiveWorkflow = async(id: string) : Promise<WorkflowFile> => { export const getActiveWorkflow = async (id: string): Promise<WorkflowFile> => {
try { try {
const response = await axios.get(`${apiUrl}/workflow/${id}`) const response = await axios.get(`${apiUrl}/workflow/${id}`)
if (response.status === 200) { if (response.status === 200) {
@@ -11,13 +11,13 @@ export const getActiveWorkflow = async(id: string) : Promise<WorkflowFile> => {
} else { } else {
throw new Error('Something went wrong when fetching a recorded workflow'); throw new Error('Something went wrong when fetching a recorded workflow');
} }
} catch(error: any) { } catch (error: any) {
console.log(error); console.log(error);
return emptyWorkflow; return emptyWorkflow;
} }
}; };
export const getParamsOfActiveWorkflow = async(id: string) : Promise<string[]|null> => { export const getParamsOfActiveWorkflow = async (id: string): Promise<string[] | null> => {
try { try {
const response = await axios.get(`${apiUrl}/workflow/params/${id}`) const response = await axios.get(`${apiUrl}/workflow/params/${id}`)
if (response.status === 200) { if (response.status === 200) {
@@ -25,15 +25,15 @@ export const getParamsOfActiveWorkflow = async(id: string) : Promise<string[]|nu
} else { } else {
throw new Error('Something went wrong when fetching the parameters of the recorded workflow'); throw new Error('Something went wrong when fetching the parameters of the recorded workflow');
} }
} catch(error: any) { } catch (error: any) {
console.log(error); console.log(error);
return null; return null;
} }
}; };
export const deletePair = async(index: number): Promise<WorkflowFile> => { export const deletePair = async (index: number): Promise<WorkflowFile> => {
try { try {
const response = await axios.delete(`${apiUrl}/workflow/pair/${index}`); const response = await axios.delete(`${apiUrl}/workflow/pair/${index}`);
if (response.status === 200) { if (response.status === 200) {
return response.data; return response.data;
} else { } else {
@@ -45,11 +45,11 @@ export const deletePair = async(index: number): Promise<WorkflowFile> => {
} }
}; };
export const AddPair = async(index: number, pair: WhereWhatPair): Promise<WorkflowFile> => { export const AddPair = async (index: number, pair: WhereWhatPair): Promise<WorkflowFile> => {
try { try {
const response = await axios.post(`${apiUrl}/workflow/pair/${index}`, { const response = await axios.post(`${apiUrl}/workflow/pair/${index}`, {
pair, pair,
}, {headers: {'Content-Type': 'application/json'}}); }, { headers: { 'Content-Type': 'application/json' } });
if (response.status === 200) { if (response.status === 200) {
return response.data; return response.data;
} else { } else {
@@ -61,11 +61,11 @@ export const AddPair = async(index: number, pair: WhereWhatPair): Promise<Workfl
} }
}; };
export const UpdatePair = async(index: number, pair: WhereWhatPair): Promise<WorkflowFile> => { export const UpdatePair = async (index: number, pair: WhereWhatPair): Promise<WorkflowFile> => {
try { try {
const response = await axios.put(`${apiUrl}/workflow/pair/${index}`, { const response = await axios.put(`${apiUrl}/workflow/pair/${index}`, {
pair, pair,
}, {headers: {'Content-Type': 'application/json'}}); }, { headers: { 'Content-Type': 'application/json' } });
if (response.status === 200) { if (response.status === 200) {
return response.data; return response.data;
} else { } else {

View File

@@ -0,0 +1,74 @@
import React, { useState } from 'react';
import { useSocketStore } from '../../context/socket';
import { Coordinates } from './canvas';
interface DatePickerProps {
coordinates: Coordinates;
selector: string;
onClose: () => void;
}
const DatePicker: React.FC<DatePickerProps> = ({ coordinates, selector, onClose }) => {
const { socket } = useSocketStore();
const [selectedDate, setSelectedDate] = useState<string>('');
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSelectedDate(e.target.value);
};
const handleConfirm = () => {
if (socket && selectedDate) {
socket.emit('input:date', {
selector,
value: selectedDate
});
onClose();
}
};
return (
<div
style={{
position: 'absolute',
left: `${coordinates.x}px`,
top: `${coordinates.y}px`,
zIndex: 1000,
backgroundColor: 'white',
boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
padding: '10px',
borderRadius: '4px'
}}
>
<div className="flex flex-col space-y-2">
<input
type="date"
onChange={handleDateChange}
value={selectedDate}
className="p-2 border rounded"
autoFocus
/>
<div className="flex justify-end space-x-2">
<button
onClick={onClose}
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-800 border rounded"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={!selectedDate}
className={`px-3 py-1 text-sm rounded ${
selectedDate
? 'bg-blue-500 text-white hover:bg-blue-600'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}`}
>
Confirm
</button>
</div>
</div>
</div>
);
};
export default DatePicker;

View File

@@ -0,0 +1,74 @@
import React, { useState } from 'react';
import { useSocketStore } from '../../context/socket';
import { Coordinates } from './canvas';
interface DateTimeLocalPickerProps {
coordinates: Coordinates;
selector: string;
onClose: () => void;
}
const DateTimeLocalPicker: React.FC<DateTimeLocalPickerProps> = ({ coordinates, selector, onClose }) => {
const { socket } = useSocketStore();
const [selectedDateTime, setSelectedDateTime] = useState<string>('');
const handleDateTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSelectedDateTime(e.target.value);
};
const handleConfirm = () => {
if (socket && selectedDateTime) {
socket.emit('input:datetime-local', {
selector,
value: selectedDateTime
});
onClose();
}
};
return (
<div
style={{
position: 'absolute',
left: `${coordinates.x}px`,
top: `${coordinates.y}px`,
zIndex: 1000,
backgroundColor: 'white',
boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
padding: '10px',
borderRadius: '4px'
}}
>
<div className="flex flex-col space-y-2">
<input
type="datetime-local"
onChange={handleDateTimeChange}
value={selectedDateTime}
className="p-2 border rounded"
autoFocus
/>
<div className="flex justify-end space-x-2">
<button
onClick={onClose}
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-800 border rounded"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={!selectedDateTime}
className={`px-3 py-1 text-sm rounded ${
selectedDateTime
? 'bg-blue-500 text-white hover:bg-blue-600'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}`}
>
Confirm
</button>
</div>
</div>
</div>
);
};
export default DateTimeLocalPicker;

View File

@@ -0,0 +1,85 @@
import React, { useState } from 'react';
import { useSocketStore } from '../../context/socket';
import { Coordinates } from './canvas';
interface DropdownProps {
coordinates: Coordinates;
selector: string;
options: Array<{
value: string;
text: string;
disabled: boolean;
selected: boolean;
}>;
onClose: () => void;
}
const Dropdown = ({ coordinates, selector, options, onClose }: DropdownProps) => {
const { socket } = useSocketStore();
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const handleSelect = (value: string) => {
if (socket) {
socket.emit('input:dropdown', { selector, value });
}
onClose();
};
const containerStyle: React.CSSProperties = {
position: 'absolute',
left: coordinates.x,
top: coordinates.y,
zIndex: 1000,
width: '200px',
backgroundColor: 'white',
border: '1px solid rgb(169, 169, 169)',
boxShadow: '0 2px 4px rgba(0,0,0,0.15)',
};
const scrollContainerStyle: React.CSSProperties = {
maxHeight: '180px',
overflowY: 'auto',
overflowX: 'hidden',
};
const getOptionStyle = (option: any, index: number): React.CSSProperties => ({
fontSize: '13.333px',
lineHeight: '18px',
padding: '0 3px',
cursor: option.disabled ? 'default' : 'default',
backgroundColor: hoveredIndex === index ? '#0078D7' :
option.selected ? '#0078D7' :
option.disabled ? '#f8f8f8' : 'white',
color: (hoveredIndex === index || option.selected) ? 'white' :
option.disabled ? '#a0a0a0' : 'black',
userSelect: 'none',
});
return (
<div
className="fixed inset-0"
onClick={onClose}
>
<div
style={containerStyle}
onClick={e => e.stopPropagation()}
>
<div style={scrollContainerStyle}>
{options.map((option, index) => (
<div
key={index}
style={getOptionStyle(option, index)}
onMouseEnter={() => !option.disabled && setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
onClick={() => !option.disabled && handleSelect(option.value)}
>
{option.text}
</div>
))}
</div>
</div>
</div>
);
};
export default Dropdown;

View File

@@ -0,0 +1,130 @@
import React, { useState } from 'react';
import { useSocketStore } from '../../context/socket';
import { Coordinates } from './canvas';
interface TimePickerProps {
coordinates: Coordinates;
selector: string;
onClose: () => void;
}
const TimePicker = ({ coordinates, selector, onClose }: TimePickerProps) => {
const { socket } = useSocketStore();
const [hoveredHour, setHoveredHour] = useState<number | null>(null);
const [hoveredMinute, setHoveredMinute] = useState<number | null>(null);
const [selectedHour, setSelectedHour] = useState<number | null>(null);
const [selectedMinute, setSelectedMinute] = useState<number | null>(null);
const handleHourSelect = (hour: number) => {
setSelectedHour(hour);
// If minute is already selected, complete the selection
if (selectedMinute !== null) {
const formattedHour = hour.toString().padStart(2, '0');
const formattedMinute = selectedMinute.toString().padStart(2, '0');
if (socket) {
socket.emit('input:time', {
selector,
value: `${formattedHour}:${formattedMinute}`
});
}
onClose();
}
};
const handleMinuteSelect = (minute: number) => {
setSelectedMinute(minute);
// If hour is already selected, complete the selection
if (selectedHour !== null) {
const formattedHour = selectedHour.toString().padStart(2, '0');
const formattedMinute = minute.toString().padStart(2, '0');
if (socket) {
socket.emit('input:time', {
selector,
value: `${formattedHour}:${formattedMinute}`
});
}
onClose();
}
};
const containerStyle: React.CSSProperties = {
position: 'absolute',
left: coordinates.x,
top: coordinates.y,
zIndex: 1000,
display: 'flex',
backgroundColor: 'white',
border: '1px solid rgb(169, 169, 169)',
boxShadow: '0 2px 4px rgba(0,0,0,0.15)',
};
const columnStyle: React.CSSProperties = {
width: '60px',
maxHeight: '180px',
overflowY: 'auto',
overflowX: 'hidden',
borderRight: '1px solid rgb(169, 169, 169)',
};
const getOptionStyle = (value: number, isHour: boolean): React.CSSProperties => {
const isHovered = isHour ? hoveredHour === value : hoveredMinute === value;
const isSelected = isHour ? selectedHour === value : selectedMinute === value;
return {
fontSize: '13.333px',
lineHeight: '18px',
padding: '0 3px',
cursor: 'default',
backgroundColor: isSelected ? '#0078D7' : isHovered ? '#0078D7' : 'white',
color: (isSelected || isHovered) ? 'white' : 'black',
userSelect: 'none',
};
};
const hours = Array.from({ length: 24 }, (_, i) => i);
const minutes = Array.from({ length: 60 }, (_, i) => i);
return (
<div
className="fixed inset-0"
onClick={onClose}
>
<div
style={containerStyle}
onClick={e => e.stopPropagation()}
>
{/* Hours column */}
<div style={columnStyle}>
{hours.map((hour) => (
<div
key={hour}
style={getOptionStyle(hour, true)}
onMouseEnter={() => setHoveredHour(hour)}
onMouseLeave={() => setHoveredHour(null)}
onClick={() => handleHourSelect(hour)}
>
{hour.toString().padStart(2, '0')}
</div>
))}
</div>
{/* Minutes column */}
<div style={{...columnStyle, borderRight: 'none'}}>
{minutes.map((minute) => (
<div
key={minute}
style={getOptionStyle(minute, false)}
onMouseEnter={() => setHoveredMinute(minute)}
onMouseLeave={() => setHoveredMinute(null)}
onClick={() => handleMinuteSelect(minute)}
>
{minute.toString().padStart(2, '0')}
</div>
))}
</div>
</div>
</div>
);
};
export default TimePicker;

View File

@@ -1,17 +1,147 @@
import React, { useCallback, useEffect, useRef } from 'react'; import React, { useCallback, useEffect, useRef, useMemo, Suspense } from 'react';
import { useSocketStore } from '../../context/socket'; import { useSocketStore } from '../../context/socket';
import { getMappedCoordinates } from "../../helpers/inputHelpers";
import { useGlobalInfoStore } from "../../context/globalInfo"; import { useGlobalInfoStore } from "../../context/globalInfo";
import { useActionContext } from '../../context/browserActions'; import { useActionContext } from '../../context/browserActions';
const DatePicker = React.lazy(() => import('./DatePicker'));
const Dropdown = React.lazy(() => import('./Dropdown'));
const TimePicker = React.lazy(() => import('./TimePicker'));
const DateTimeLocalPicker = React.lazy(() => import('./DateTimeLocalPicker'));
interface CreateRefCallback { class RAFScheduler {
(ref: React.RefObject<HTMLCanvasElement>): void; private queue: Set<() => void> = new Set();
private isProcessing: boolean = false;
private frameId: number | null = null;
schedule(callback: () => void): void {
this.queue.add(callback);
if (!this.isProcessing) {
this.process();
}
}
private process = (): void => {
this.isProcessing = true;
this.frameId = requestAnimationFrame(() => {
const callbacks = Array.from(this.queue);
this.queue.clear();
callbacks.forEach(callback => {
try {
callback();
} catch (error) {
console.error('RAF Scheduler error:', error);
}
});
this.isProcessing = false;
this.frameId = null;
if (this.queue.size > 0) {
this.process();
}
});
}
clear(): void {
this.queue.clear();
if (this.frameId !== null) {
cancelAnimationFrame(this.frameId);
this.frameId = null;
}
this.isProcessing = false;
}
}
class EventDebouncer {
private highPriorityQueue: Array<() => void> = [];
private lowPriorityQueue: Array<() => void> = [];
private processing: boolean = false;
private scheduler: RAFScheduler;
constructor(scheduler: RAFScheduler) {
this.scheduler = scheduler;
}
add(callback: () => void, highPriority: boolean = false): void {
if (highPriority) {
this.highPriorityQueue.push(callback);
} else {
this.lowPriorityQueue.push(callback);
}
if (!this.processing) {
this.process();
}
}
private process(): void {
this.processing = true;
this.scheduler.schedule(() => {
while (this.highPriorityQueue.length > 0) {
const callback = this.highPriorityQueue.shift();
callback?.();
}
if (this.lowPriorityQueue.length > 0) {
const callback = this.lowPriorityQueue.shift();
callback?.();
if (this.lowPriorityQueue.length > 0) {
this.process();
}
}
this.processing = false;
});
}
clear(): void {
this.highPriorityQueue = [];
this.lowPriorityQueue = [];
this.processing = false;
}
}
// Optimized measurement cache with LRU
class MeasurementCache {
private cache: Map<HTMLElement, DOMRect>;
private maxSize: number;
constructor(maxSize: number = 100) {
this.cache = new Map();
this.maxSize = maxSize;
}
get(element: HTMLElement): DOMRect | undefined {
const cached = this.cache.get(element);
if (cached) {
// Refresh the entry
this.cache.delete(element);
this.cache.set(element, cached);
}
return cached;
}
set(element: HTMLElement, rect: DOMRect): void {
if (this.cache.size >= this.maxSize) {
// Remove oldest entry
const firstKey = this.cache.keys().next().value;
if (firstKey !== undefined) {
this.cache.delete(firstKey);
}
}
this.cache.set(element, rect);
}
clear(): void {
this.cache.clear();
}
} }
interface CanvasProps { interface CanvasProps {
width: number; width: number;
height: number; height: number;
onCreateRef: CreateRefCallback; onCreateRef: (ref: React.RefObject<HTMLCanvasElement>) => void;
} }
/** /**
@@ -22,134 +152,229 @@ export interface Coordinates {
y: number; y: number;
}; };
const Canvas = ({ width, height, onCreateRef }: CanvasProps) => { const Canvas = React.memo(({ width, height, onCreateRef }: CanvasProps) => {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const { socket } = useSocketStore(); const { socket } = useSocketStore();
const { setLastAction, lastAction } = useGlobalInfoStore(); const { setLastAction, lastAction } = useGlobalInfoStore();
const { getText, getList } = useActionContext(); const { getText, getList } = useActionContext();
const getTextRef = useRef(getText);
const getListRef = useRef(getList);
const notifyLastAction = (action: string) => { const scheduler = useRef(new RAFScheduler());
if (lastAction !== action) { const debouncer = useRef(new EventDebouncer(scheduler.current));
setLastAction(action); const measurementCache = useRef(new MeasurementCache(50));
//const performanceMonitor = useRef(new FrontendPerformanceMonitor());
const refs = useRef({
getText,
getList,
lastMousePosition: { x: 0, y: 0 },
lastFrameTime: 0,
context: null as CanvasRenderingContext2D | null,
});
const [state, dispatch] = React.useReducer((state: any, action: any) => {
switch (action.type) {
case 'BATCH_UPDATE':
return { ...state, ...action.payload };
default:
return state;
} }
}; }, {
datePickerInfo: null,
dropdownInfo: null,
timePickerInfo: null,
dateTimeLocalInfo: null
});
const lastMousePosition = useRef<Coordinates>({ x: 0, y: 0 }); const getEventCoordinates = useCallback((event: MouseEvent): { x: number; y: number } => {
if (!canvasRef.current) return { x: 0, y: 0 };
useEffect(() => { let rect = measurementCache.current.get(canvasRef.current);
getTextRef.current = getText; if (!rect) {
getListRef.current = getList; rect = canvasRef.current.getBoundingClientRect();
}, [getText, getList]); measurementCache.current.set(canvasRef.current, rect);
}
const onMouseEvent = useCallback((event: MouseEvent) => { return {
if (socket && canvasRef.current) { x: event.clientX - rect.left,
// Get the canvas bounding rectangle y: event.clientY - rect.top
const rect = canvasRef.current.getBoundingClientRect(); };
const clickCoordinates = { }, []);
x: event.clientX - rect.left, // Use relative x coordinate
y: event.clientY - rect.top, // Use relative y coordinate
};
switch (event.type) { const handleMouseEvent = useCallback((event: MouseEvent) => {
case 'mousedown': if (!socket || !canvasRef.current) return;
if (getTextRef.current === true) {
//performanceMonitor.current.measureEventLatency(event);
const coordinates = getEventCoordinates(event);
switch (event.type) {
case 'mousedown':
debouncer.current.add(() => {
if (refs.current.getText) {
console.log('Capturing Text...'); console.log('Capturing Text...');
} else if (getListRef.current === true) { } else if (refs.current.getList) {
console.log('Capturing List...'); console.log('Capturing List...');
} else { } else {
socket.emit('input:mousedown', clickCoordinates); socket.emit('input:mousedown', coordinates);
} }
notifyLastAction('click'); setLastAction('click');
break; }, true); // High priority
case 'mousemove': break;
if (lastMousePosition.current.x !== clickCoordinates.x ||
lastMousePosition.current.y !== clickCoordinates.y) {
lastMousePosition.current = {
x: clickCoordinates.x,
y: clickCoordinates.y,
};
socket.emit('input:mousemove', {
x: clickCoordinates.x,
y: clickCoordinates.y,
});
notifyLastAction('move');
}
break;
case 'wheel':
const wheelEvent = event as WheelEvent;
const deltas = {
deltaX: Math.round(wheelEvent.deltaX),
deltaY: Math.round(wheelEvent.deltaY),
};
socket.emit('input:wheel', deltas);
notifyLastAction('scroll');
break;
default:
console.log('Default mouseEvent registered');
return;
}
}
}, [socket]);
const onKeyboardEvent = useCallback((event: KeyboardEvent) => { case 'mousemove':
if (socket) { if (refs.current.lastMousePosition.x !== coordinates.x ||
refs.current.lastMousePosition.y !== coordinates.y) {
debouncer.current.add(() => {
refs.current.lastMousePosition = coordinates;
socket.emit('input:mousemove', coordinates);
setLastAction('move');
});
}
break;
case 'wheel':
const wheelEvent = event as WheelEvent;
debouncer.current.add(() => {
socket.emit('input:wheel', {
deltaX: Math.round(wheelEvent.deltaX),
deltaY: Math.round(wheelEvent.deltaY)
});
setLastAction('scroll');
});
break;
}
}, [socket, getEventCoordinates]);
const handleKeyboardEvent = useCallback((event: KeyboardEvent) => {
if (!socket) return;
debouncer.current.add(() => {
switch (event.type) { switch (event.type) {
case 'keydown': case 'keydown':
socket.emit('input:keydown', { key: event.key, coordinates: lastMousePosition.current }); socket.emit('input:keydown', {
notifyLastAction(`${event.key} pressed`); key: event.key,
coordinates: refs.current.lastMousePosition
});
setLastAction(`${event.key} pressed`);
break; break;
case 'keyup': case 'keyup':
socket.emit('input:keyup', event.key); socket.emit('input:keyup', event.key);
break; break;
default:
console.log('Default keyEvent registered');
return;
} }
} }, event.type === 'keydown'); // High priority for keydown
}, [socket]); }, [socket]);
// Setup and cleanup
useEffect(() => {
if (!canvasRef.current) return;
const canvas = canvasRef.current;
refs.current.context = canvas.getContext('2d', {
alpha: false,
desynchronized: true
});
onCreateRef(canvasRef);
const options = { passive: true };
canvas.addEventListener('mousedown', handleMouseEvent, options);
canvas.addEventListener('mousemove', handleMouseEvent, options);
canvas.addEventListener('wheel', handleMouseEvent, options);
canvas.addEventListener('keydown', handleKeyboardEvent, options);
canvas.addEventListener('keyup', handleKeyboardEvent, options);
return () => {
canvas.removeEventListener('mousedown', handleMouseEvent);
canvas.removeEventListener('mousemove', handleMouseEvent);
canvas.removeEventListener('wheel', handleMouseEvent);
canvas.removeEventListener('keydown', handleKeyboardEvent);
canvas.removeEventListener('keyup', handleKeyboardEvent);
scheduler.current.clear();
debouncer.current.clear();
measurementCache.current.clear();
};
}, [handleMouseEvent, handleKeyboardEvent, onCreateRef]);
// Performance monitoring
// useEffect(() => {
// const intervalId = setInterval(() => {
// console.log('Performance Report:', performanceMonitor.current.getPerformanceReport());
// }, 20000);
// return () => clearInterval(intervalId);
// }, []);
useEffect(() => { useEffect(() => {
if (canvasRef.current) { if (!socket) return;
onCreateRef(canvasRef);
canvasRef.current.addEventListener('mousedown', onMouseEvent);
canvasRef.current.addEventListener('mousemove', onMouseEvent);
canvasRef.current.addEventListener('wheel', onMouseEvent, { passive: true });
canvasRef.current.addEventListener('keydown', onKeyboardEvent);
canvasRef.current.addEventListener('keyup', onKeyboardEvent);
return () => { const handlers = {
if (canvasRef.current) { showDatePicker: (info: any) => dispatch({ type: 'BATCH_UPDATE', payload: { datePickerInfo: info } }),
canvasRef.current.removeEventListener('mousedown', onMouseEvent); showDropdown: (info: any) => dispatch({ type: 'BATCH_UPDATE', payload: { dropdownInfo: info } }),
canvasRef.current.removeEventListener('mousemove', onMouseEvent); showTimePicker: (info: any) => dispatch({ type: 'BATCH_UPDATE', payload: { timePickerInfo: info } }),
canvasRef.current.removeEventListener('wheel', onMouseEvent); showDateTimePicker: (info: any) => dispatch({ type: 'BATCH_UPDATE', payload: { dateTimeLocalInfo: info } })
canvasRef.current.removeEventListener('keydown', onKeyboardEvent); };
canvasRef.current.removeEventListener('keyup', onKeyboardEvent);
}
}; Object.entries(handlers).forEach(([event, handler]) => socket.on(event, handler));
} else { return () => {
console.log('Canvas not initialized'); Object.keys(handlers).forEach(event => socket.off(event));
} };
}, [socket]);
}, [onMouseEvent]); const memoizedDimensions = useMemo(() => ({
width: width || 900,
height: height || 400
}), [width, height]);
return ( return (
<div style={{ borderRadius: '0px 0px 5px 5px', overflow: 'hidden', backgroundColor: 'white' }}> <div className="relative bg-white rounded-b-md overflow-hidden">
<canvas <canvas
tabIndex={0} tabIndex={0}
ref={canvasRef} ref={canvasRef}
height={1000} height={memoizedDimensions.height}
width={900} width={memoizedDimensions.width}
style={{ display: 'block' }} className="block"
/> />
<Suspense fallback={null}>
{state.datePickerInfo && (
<DatePicker
coordinates={state.datePickerInfo.coordinates}
selector={state.datePickerInfo.selector}
onClose={() => dispatch({
type: 'BATCH_UPDATE',
payload: { datePickerInfo: null }
})}
/>
)}
{state.dropdownInfo && (
<Dropdown
coordinates={state.dropdownInfo.coordinates}
selector={state.dropdownInfo.selector}
options={state.dropdownInfo.options}
onClose={() => dispatch({
type: 'BATCH_UPDATE',
payload: { dropdownInfo: null }
})}
/>
)}
{state.timePickerInfo && (
<TimePicker
coordinates={state.timePickerInfo.coordinates}
selector={state.timePickerInfo.selector}
onClose={() => dispatch({ type: 'SET_TIME_PICKER', payload: null })}
/>
)}
{state.dateTimeLocalInfo && (
<DateTimeLocalPicker
coordinates={state.dateTimeLocalInfo.coordinates}
selector={state.dateTimeLocalInfo.selector}
onClose={() => dispatch({ type: 'SET_DATETIME_PICKER', payload: null })}
/>
)}
</Suspense>
</div> </div>
); );
});
}; Canvas.displayName = 'Canvas';
export default Canvas; export default Canvas;

View File

@@ -3,6 +3,7 @@ import styled from 'styled-components';
import { Typography, FormControlLabel, Checkbox, Box } from '@mui/material'; import { Typography, FormControlLabel, Checkbox, Box } from '@mui/material';
import { useActionContext } from '../../context/browserActions'; import { useActionContext } from '../../context/browserActions';
import MaxunLogo from "../../assets/maxunlogo.png"; import MaxunLogo from "../../assets/maxunlogo.png";
import { useTranslation } from 'react-i18next';
interface CustomBoxContainerProps { interface CustomBoxContainerProps {
isDarkMode: boolean; isDarkMode: boolean;
@@ -48,7 +49,9 @@ const Content = styled.div`
text-align: left; text-align: left;
`; `;
const ActionDescriptionBox = ({ isDarkMode }: { isDarkMode: boolean }) => { const ActionDescriptionBox = ({ isDarkMode }: { isDarkMode: boolean }) => {
const { t } = useTranslation();
const { getText, getScreenshot, getList, captureStage } = useActionContext() as { const { getText, getScreenshot, getList, captureStage } = useActionContext() as {
getText: boolean; getText: boolean;
getScreenshot: boolean; getScreenshot: boolean;
@@ -57,10 +60,10 @@ const ActionDescriptionBox = ({ isDarkMode }: { isDarkMode: boolean }) => {
}; };
const messages = [ const messages = [
{ stage: 'initial', text: 'Select the list you want to extract along with the texts inside it' }, { stage: 'initial' as const, text: t('action_description.list_stages.initial') },
{ stage: 'pagination', text: 'Select how the robot can capture the rest of the list' }, { stage: 'pagination' as const, text: t('action_description.list_stages.pagination') },
{ stage: 'limit', text: 'Choose the number of items to extract' }, { stage: 'limit' as const, text: t('action_description.list_stages.limit') },
{ stage: 'complete', text: 'Capture is complete' }, { stage: 'complete' as const, text: t('action_description.list_stages.complete') },
]; ];
const stages = messages.map(({ stage }) => stage); const stages = messages.map(({ stage }) => stage);
@@ -70,23 +73,23 @@ const ActionDescriptionBox = ({ isDarkMode }: { isDarkMode: boolean }) => {
if (getText) { if (getText) {
return ( return (
<> <>
<Typography variant="subtitle2" gutterBottom>Capture Text</Typography> <Typography variant="subtitle2" gutterBottom>{t('action_description.text.title')}</Typography>
<Typography variant="body2" gutterBottom>Hover over the texts you want to extract and click to select them</Typography> <Typography variant="body2" gutterBottom>{t('action_description.text.description')}</Typography>
</> </>
); );
} else if (getScreenshot) { } else if (getScreenshot) {
return ( return (
<> <>
<Typography variant="subtitle2" gutterBottom>Capture Screenshot</Typography> <Typography variant="subtitle2" gutterBottom>{t('action_description.screenshot.title')}</Typography>
<Typography variant="body2" gutterBottom>Capture a partial or full page screenshot of the current page.</Typography> <Typography variant="body2" gutterBottom>{t('action_description.screenshot.description')}</Typography>
</> </>
); );
} else if (getList) { } else if (getList) {
return ( return (
<> <>
<Typography variant="subtitle2" gutterBottom>Capture List</Typography> <Typography variant="subtitle2" gutterBottom>{t('action_description.list.title')}</Typography>
<Typography variant="body2" gutterBottom> <Typography variant="body2" gutterBottom>
Hover over the list you want to extract. Once selected, you can hover over all texts inside the list you selected. Click to select them. {t('action_description.list.description')}
</Typography> </Typography>
<Box> <Box>
{messages.map(({ stage, text }, index) => ( {messages.map(({ stage, text }, index) => (
@@ -117,8 +120,8 @@ const ActionDescriptionBox = ({ isDarkMode }: { isDarkMode: boolean }) => {
} else { } else {
return ( return (
<> <>
<Typography variant="subtitle2" gutterBottom>What data do you want to extract?</Typography> <Typography variant="subtitle2" gutterBottom>{t('action_description.default.title')}</Typography>
<Typography variant="body2" gutterBottom>A robot is designed to perform one action at a time. You can choose any of the options below.</Typography> <Typography variant="body2" gutterBottom>{t('action_description.default.description')}</Typography>
</> </>
); );
} }
@@ -135,4 +138,4 @@ const ActionDescriptionBox = ({ isDarkMode }: { isDarkMode: boolean }) => {
); );
}; };
export default ActionDescriptionBox; export default ActionDescriptionBox;

View File

@@ -5,8 +5,10 @@ import { useGlobalInfoStore } from '../../context/globalInfo';
import { stopRecording } from "../../api/recording"; import { stopRecording } from "../../api/recording";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { GenericModal } from "../atoms/GenericModal"; import { GenericModal } from "../atoms/GenericModal";
import { useTranslation } from 'react-i18next';
const BrowserRecordingSave = () => { const BrowserRecordingSave = () => {
const { t } = useTranslation();
const [openModal, setOpenModal] = useState<boolean>(false); const [openModal, setOpenModal] = useState<boolean>(false);
const { recordingName, browserId, setBrowserId, notify } = useGlobalInfoStore(); const { recordingName, browserId, setBrowserId, notify } = useGlobalInfoStore();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -14,7 +16,7 @@ const BrowserRecordingSave = () => {
const goToMainMenu = async () => { const goToMainMenu = async () => {
if (browserId) { if (browserId) {
await stopRecording(browserId); await stopRecording(browserId);
notify('warning', 'Current Recording was terminated'); notify('warning', t('browser_recording.notifications.terminated'));
setBrowserId(null); setBrowserId(null);
} }
navigate('/'); navigate('/');
@@ -24,32 +26,31 @@ const BrowserRecordingSave = () => {
<Grid container> <Grid container>
<Grid item xs={12} md={3} lg={3}> <Grid item xs={12} md={3} lg={3}>
<div style={{ <div style={{
marginTop: '12px',
// marginLeft: '10px',
color: 'white', color: 'white',
position: 'absolute', position: 'absolute',
background: '#ff00c3', background: '#ff00c3',
border: 'none', border: 'none',
borderRadius: '0px 0px 8px 8px', borderRadius: '0px 0px 8px 8px',
padding: '7.5px', padding: '7.5px',
width: '100%', // Ensure it takes full width but with padding width: 'calc(100% - 20px)',
overflow: 'hidden', overflow: 'hidden',
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
height:"48px" height:"48px"
}}> }}>
<Button onClick={() => setOpenModal(true)} variant="outlined" style={{ marginLeft: "25px" }} size="small" color="error"> <Button onClick={() => setOpenModal(true)} variant="outlined" style={{ marginLeft: "25px" }} size="small" color="error">
Discard {t('right_panel.buttons.discard')}
</Button> </Button>
<GenericModal isOpen={openModal} onClose={() => setOpenModal(false)} modalStyle={modalStyle}> <GenericModal isOpen={openModal} onClose={() => setOpenModal(false)} modalStyle={modalStyle}>
<Box p={2}> <Box p={2}>
<Typography variant="h6">Are you sure you want to discard the recording?</Typography> <Typography variant="h6">{t('browser_recording.modal.confirm_discard')}</Typography>
<Box display="flex" justifyContent="space-between" mt={2}> <Box display="flex" justifyContent="space-between" mt={2}>
<Button onClick={goToMainMenu} variant="contained" color="error"> <Button onClick={goToMainMenu} variant="contained" color="error">
Discard {t('right_panel.buttons.discard')}
</Button> </Button>
<Button onClick={() => setOpenModal(false)} variant="outlined"> <Button onClick={() => setOpenModal(false)} variant="outlined">
Cancel {t('right_panel.buttons.cancel')}
</Button> </Button>
</Box> </Box>
</Box> </Box>
@@ -61,7 +62,7 @@ const BrowserRecordingSave = () => {
); );
} }
export default BrowserRecordingSave export default BrowserRecordingSave;
const modalStyle = { const modalStyle = {
top: '25%', top: '25%',

View File

@@ -10,6 +10,7 @@ import { RunContent } from "./RunContent";
import { GenericModal } from "../atoms/GenericModal"; import { GenericModal } from "../atoms/GenericModal";
import { modalStyle } from "./AddWhereCondModal"; import { modalStyle } from "./AddWhereCondModal";
import { getUserById } from "../../api/auth"; import { getUserById } from "../../api/auth";
import { useTranslation } from "react-i18next";
interface RunTypeChipProps { interface RunTypeChipProps {
runByUserId?: string; runByUserId?: string;
@@ -18,10 +19,12 @@ interface RunTypeChipProps {
} }
const RunTypeChip: React.FC<RunTypeChipProps> = ({ runByUserId, runByScheduledId, runByAPI }) => { const RunTypeChip: React.FC<RunTypeChipProps> = ({ runByUserId, runByScheduledId, runByAPI }) => {
if (runByUserId) return <Chip label="Manual Run" color="primary" variant="outlined" />; const { t } = useTranslation();
if (runByScheduledId) return <Chip label="Scheduled Run" color="primary" variant="outlined" />;
if (runByAPI) return <Chip label="API" color="primary" variant="outlined" />; if (runByUserId) return <Chip label={t('runs_table.run_type_chips.manual_run')} color="primary" variant="outlined" />;
return <Chip label="Unknown Run Type" color="primary" variant="outlined" />; if (runByScheduledId) return <Chip label={t('runs_table.run_type_chips.scheduled_run')} color="primary" variant="outlined" />;
if (runByAPI) return <Chip label={t('runs_table.run_type_chips.api')} color="primary" variant="outlined" />;
return <Chip label={t('runs_table.run_type_chips.unknown_run_type')} color="primary" variant="outlined" />;
}; };
interface CollapsibleRowProps { interface CollapsibleRowProps {
@@ -33,6 +36,7 @@ interface CollapsibleRowProps {
runningRecordingName: string; runningRecordingName: string;
} }
export const CollapsibleRow = ({ row, handleDelete, isOpen, currentLog, abortRunHandler, runningRecordingName }: CollapsibleRowProps) => { export const CollapsibleRow = ({ row, handleDelete, isOpen, currentLog, abortRunHandler, runningRecordingName }: CollapsibleRowProps) => {
const { t } = useTranslation();
const [open, setOpen] = useState(isOpen); const [open, setOpen] = useState(isOpen);
const [openSettingsModal, setOpenSettingsModal] = useState(false); const [openSettingsModal, setOpenSettingsModal] = useState(false);
const [userEmail, setUserEmail] = useState<string | null>(null); const [userEmail, setUserEmail] = useState<string | null>(null);
@@ -99,12 +103,12 @@ export const CollapsibleRow = ({ row, handleDelete, isOpen, currentLog, abortRun
} else { } else {
switch (column.id) { switch (column.id) {
case 'runStatus': case 'runStatus':
return ( return (
<TableCell key={column.id} align={column.align}> <TableCell key={column.id} align={column.align}>
{row.status === 'success' && <Chip label="Success" color="success" variant="outlined" />} {row.status === 'success' && <Chip label={t('runs_table.run_status_chips.success')} color="success" variant="outlined" />}
{row.status === 'running' && <Chip label="Running" color="warning" variant="outlined" />} {row.status === 'running' && <Chip label={t('runs_table.run_status_chips.running')} color="warning" variant="outlined" />}
{row.status === 'scheduled' && <Chip label="Scheduled" variant="outlined" />} {row.status === 'scheduled' && <Chip label={t('runs_table.run_status_chips.scheduled')} variant="outlined" />}
{row.status === 'failed' && <Chip label="Failed" color="error" variant="outlined" />} {row.status === 'failed' && <Chip label={t('runs_table.run_status_chips.failed')} color="error" variant="outlined" />}
</TableCell> </TableCell>
) )
case 'delete': case 'delete':
@@ -133,21 +137,35 @@ export const CollapsibleRow = ({ row, handleDelete, isOpen, currentLog, abortRun
modalStyle={modalStyle} modalStyle={modalStyle}
> >
<> <>
<Typography variant="h5" style={{ marginBottom: '20px' }}>Run Settings</Typography> <Typography variant="h5" style={{ marginBottom: '20px' }}>
{t('runs_table.run_settings_modal.title')}
</Typography>
<Box style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}> <Box style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<TextField <TextField
label="Run ID" label={t('runs_table.run_settings_modal.labels.run_id')}
value={row.runId} value={row.runId}
InputProps={{ readOnly: true }} InputProps={{ readOnly: true }}
/> />
<TextField <TextField
label={row.runByUserId ? "Run by User" : row.runByScheduleId ? "Run by Schedule ID" : "Run by API"} label={
row.runByUserId
? t('runs_table.run_settings_modal.labels.run_by_user')
: row.runByScheduleId
? t('runs_table.run_settings_modal.labels.run_by_schedule')
: t('runs_table.run_settings_modal.labels.run_by_api')
}
value={runByLabel} value={runByLabel}
InputProps={{ readOnly: true }} InputProps={{ readOnly: true }}
/> />
<Box style={{ display: 'flex', alignItems: 'center', gap: '10px' }}> <Box style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<Typography variant="body1">Run Type:</Typography> <Typography variant="body1">
<RunTypeChip runByUserId={row.runByUserId} runByScheduledId={row.runByScheduleId} runByAPI={row.runByAPI ?? false} /> {t('runs_table.run_settings_modal.labels.run_type')}:
</Typography>
<RunTypeChip
runByUserId={row.runByUserId}
runByScheduledId={row.runByScheduleId}
runByAPI={row.runByAPI ?? false}
/>
</Box> </Box>
</Box> </Box>
</> </>

View File

@@ -14,6 +14,9 @@ import axios from "axios";
import { useGlobalInfoStore } from "../../context/globalInfo"; import { useGlobalInfoStore } from "../../context/globalInfo";
import { getStoredRecording } from "../../api/storage"; import { getStoredRecording } from "../../api/storage";
import { apiUrl } from "../../apiConfig.js"; import { apiUrl } from "../../apiConfig.js";
import Cookies from 'js-cookie';
import { useTranslation } from "react-i18next";
interface IntegrationProps { interface IntegrationProps {
isOpen: boolean; isOpen: boolean;
@@ -46,6 +49,7 @@ export const IntegrationSettingsModal = ({
handleStart, handleStart,
handleClose, handleClose,
}: IntegrationProps) => { }: IntegrationProps) => {
const { t } = useTranslation();
const [settings, setSettings] = useState<IntegrationSettings>({ const [settings, setSettings] = useState<IntegrationSettings>({
spreadsheetId: "", spreadsheetId: "",
spreadsheetName: "", spreadsheetName: "",
@@ -90,9 +94,9 @@ export const IntegrationSettingsModal = ({
); );
notify( notify(
"error", "error",
`Error fetching spreadsheet files: ${ t('integration_settings.errors.fetch_error', {
error.response?.data?.message || error.message message: error.response?.data?.message || error.message
}` })
); );
} }
}; };
@@ -121,7 +125,7 @@ export const IntegrationSettingsModal = ({
}, },
{ withCredentials: true } { withCredentials: true }
); );
notify(`success`, `Google Sheet selected successfully`); notify(`success`, t('integration_settings.notifications.sheet_selected'));
console.log("Google Sheet ID updated:", response.data); console.log("Google Sheet ID updated:", response.data);
} catch (error: any) { } catch (error: any) {
console.error( console.error(
@@ -182,37 +186,28 @@ export const IntegrationSettingsModal = ({
return ( return (
<GenericModal isOpen={isOpen} onClose={handleClose} modalStyle={modalStyle}> <GenericModal isOpen={isOpen} onClose={handleClose} modalStyle={modalStyle}>
<div <div style={{
style={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
alignItems: "flex-start", alignItems: "flex-start",
marginLeft: "65px", marginLeft: "65px",
}} }}>
>
<Typography variant="h6"> <Typography variant="h6">
Integrate with Google Sheet{" "} {t('integration_settings.title')}
</Typography> </Typography>
{recording && recording.google_sheet_id ? ( {recording && recording.google_sheet_id ? (
<> <>
<Alert severity="info" sx={{ marginTop: '10px', border: '1px solid #ff00c3' }}> <Alert severity="info" sx={{ marginTop: '10px', border: '1px solid #ff00c3' }}>
<AlertTitle>Google Sheet Integrated Successfully.</AlertTitle> <AlertTitle>{t('integration_settings.alerts.success.title')}</AlertTitle>
Every time this robot creates a successful run, its captured data {t('integration_settings.alerts.success.content', { sheetName: recording.google_sheet_name })}
is appended to your {recording.google_sheet_name} Google Sheet. <a href={`https://docs.google.com/spreadsheets/d/${recording.google_sheet_id}`}
You can check the data updates{" "}
<a
href={`https://docs.google.com/spreadsheets/d/${recording.google_sheet_id}`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer">
> {t('integration_settings.alerts.success.here')}
here </a>.
</a>
.
<br /> <br />
<strong>Note:</strong> The data extracted before integrating with <strong>{t('integration_settings.alerts.success.note')}</strong> {t('integration_settings.alerts.success.sync_limitation')}
Google Sheets will not be synced in the Google Sheet. Only the
data extracted after the integration will be synced.
</Alert> </Alert>
<Button <Button
variant="outlined" variant="outlined"
@@ -220,31 +215,29 @@ export const IntegrationSettingsModal = ({
onClick={removeIntegration} onClick={removeIntegration}
style={{ marginTop: "15px" }} style={{ marginTop: "15px" }}
> >
Remove Integration {t('integration_settings.buttons.remove_integration')}
</Button> </Button>
</> </>
) : ( ) : (
<> <>
{!recording?.google_sheet_email ? ( {!recording?.google_sheet_email ? (
<> <>
<p> <p>{t('integration_settings.descriptions.sync_info')}</p>
If you enable this option, every time this robot runs a task
successfully, its captured data will be appended to your
Google Sheet.
</p>
<Button <Button
variant="contained" variant="contained"
color="primary" color="primary"
onClick={authenticateWithGoogle} onClick={authenticateWithGoogle}
> >
Authenticate with Google {t('integration_settings.buttons.authenticate')}
</Button> </Button>
</> </>
) : ( ) : (
<> <>
{recording.google_sheet_email && ( {recording.google_sheet_email && (
<Typography sx={{ margin: "20px 0px 30px 0px" }}> <Typography sx={{ margin: "20px 0px 30px 0px" }}>
Authenticated as: {recording.google_sheet_email} {t('integration_settings.descriptions.authenticated_as', {
email: recording.google_sheet_email
})}
</Typography> </Typography>
)} )}
@@ -260,14 +253,14 @@ export const IntegrationSettingsModal = ({
color="primary" color="primary"
onClick={fetchSpreadsheetFiles} onClick={fetchSpreadsheetFiles}
> >
Fetch Google Spreadsheets {t('integration_settings.buttons.fetch_sheets')}
</Button> </Button>
<Button <Button
variant="outlined" variant="outlined"
color="error" color="error"
onClick={removeIntegration} onClick={removeIntegration}
> >
Remove Integration {t('integration_settings.buttons.remove_integration')}
</Button> </Button>
</div> </div>
</> </>
@@ -276,7 +269,7 @@ export const IntegrationSettingsModal = ({
<TextField <TextField
sx={{ marginBottom: "15px" }} sx={{ marginBottom: "15px" }}
select select
label="Select Google Sheet" label={t('integration_settings.fields.select_sheet')}
required required
value={settings.spreadsheetId} value={settings.spreadsheetId}
onChange={handleSpreadsheetSelect} onChange={handleSpreadsheetSelect}
@@ -291,13 +284,10 @@ export const IntegrationSettingsModal = ({
{settings.spreadsheetId && ( {settings.spreadsheetId && (
<Typography sx={{ marginBottom: "10px" }}> <Typography sx={{ marginBottom: "10px" }}>
Selected Sheet:{" "} {t('integration_settings.fields.selected_sheet', {
{ name: spreadsheets.find((s) => s.id === settings.spreadsheetId)?.name,
spreadsheets.find( id: settings.spreadsheetId
(s) => s.id === settings.spreadsheetId })}
)?.name
}{" "}
(ID: {settings.spreadsheetId})
</Typography> </Typography>
)} )}
@@ -311,7 +301,7 @@ export const IntegrationSettingsModal = ({
style={{ marginTop: "10px" }} style={{ marginTop: "10px" }}
disabled={!settings.spreadsheetId || loading} disabled={!settings.spreadsheetId || loading}
> >
Submit {t('integration_settings.buttons.submit')}
</Button> </Button>
</> </>
)} )}

View File

@@ -7,6 +7,7 @@ import { useGlobalInfoStore } from "../../context/globalInfo";
import { GenericModal } from "../atoms/GenericModal"; import { GenericModal } from "../atoms/GenericModal";
import { WhereWhatPair } from "maxun-core"; import { WhereWhatPair } from "maxun-core";
import HelpIcon from '@mui/icons-material/Help'; import HelpIcon from '@mui/icons-material/Help';
import { useTranslation } from "react-i18next";
interface InterpretationButtonsProps { interface InterpretationButtonsProps {
enableStepping: (isPaused: boolean) => void; enableStepping: (isPaused: boolean) => void;
@@ -23,6 +24,7 @@ const interpretationInfo: InterpretationInfo = {
}; };
export const InterpretationButtons = ({ enableStepping }: InterpretationButtonsProps) => { export const InterpretationButtons = ({ enableStepping }: InterpretationButtonsProps) => {
const { t } = useTranslation();
const [info, setInfo] = useState<InterpretationInfo>(interpretationInfo); const [info, setInfo] = useState<InterpretationInfo>(interpretationInfo);
const [decisionModal, setDecisionModal] = useState<{ const [decisionModal, setDecisionModal] = useState<{
pair: WhereWhatPair | null, pair: WhereWhatPair | null,
@@ -44,9 +46,9 @@ export const InterpretationButtons = ({ enableStepping }: InterpretationButtonsP
const breakpointHitHandler = useCallback(() => { const breakpointHitHandler = useCallback(() => {
setInfo({ running: false, isPaused: true }); setInfo({ running: false, isPaused: true });
notify('warning', 'Please restart the interpretation after updating the recording'); notify('warning', t('interpretation_buttons.messages.restart_required'));
enableStepping(true); enableStepping(true);
}, [enableStepping]); }, [enableStepping, t]);
const decisionHandler = useCallback( const decisionHandler = useCallback(
({ pair, actionType, lastData }: { pair: WhereWhatPair | null, actionType: string, lastData: { selector: string, action: string, tagName: string, innerText: string } }) => { ({ pair, actionType, lastData }: { pair: WhereWhatPair | null, actionType: string, lastData: { selector: string, action: string, tagName: string, innerText: string } }) => {
@@ -73,11 +75,12 @@ export const InterpretationButtons = ({ enableStepping }: InterpretationButtonsP
return ( return (
<> <>
<Typography> <Typography>
Do you want to use your previous selection as a condition for performing this action? {t('interpretation_buttons.modal.use_previous')}
</Typography> </Typography>
<Box style={{ marginTop: '4px' }}> <Box style={{ marginTop: '4px' }}>
<Typography> <Typography>
Your previous action was: <b>{decisionModal.action}</b>, on an element with text <b>{decisionModal.innerText}</b> {t('interpretation_buttons.modal.previous_action')} <b>{decisionModal.action}</b>,
{t('interpretation_buttons.modal.element_text')} <b>{decisionModal.innerText}</b>
</Typography> </Typography>
</Box> </Box>
</> </>
@@ -105,9 +108,9 @@ export const InterpretationButtons = ({ enableStepping }: InterpretationButtonsP
const finished = await interpretCurrentRecording(); const finished = await interpretCurrentRecording();
setInfo({ ...info, running: false }); setInfo({ ...info, running: false });
if (finished) { if (finished) {
notify('info', 'Run finished'); notify('info', t('interpretation_buttons.messages.run_finished'));
} else { } else {
notify('error', 'Run failed to start'); notify('error', t('interpretation_buttons.messages.run_failed'));
} }
} }
}; };
@@ -139,9 +142,12 @@ export const InterpretationButtons = ({ enableStepping }: InterpretationButtonsP
disabled={info.running} disabled={info.running}
sx={{ display: 'grid' }} sx={{ display: 'grid' }}
> >
{info.running ? <Box sx={{ display: 'flex', alignItems: 'center' }}> {info.running ? (
<CircularProgress size={22} color="inherit" sx={{ marginRight: '10px' }} /> Extracting data...please wait for 10secs to 1min <Box sx={{ display: 'flex', alignItems: 'center' }}>
</Box> : 'Get Preview of Output Data'} <CircularProgress size={22} color="inherit" sx={{ marginRight: '10px' }} />
{t('interpretation_buttons.messages.extracting')}
</Box>
) : t('interpretation_buttons.buttons.preview')}
</Button> </Button>
<GenericModal <GenericModal
onClose={() => { }} onClose={() => { }}
@@ -166,8 +172,12 @@ export const InterpretationButtons = ({ enableStepping }: InterpretationButtonsP
<HelpIcon /> <HelpIcon />
{handleDescription()} {handleDescription()}
<div style={{ float: 'right' }}> <div style={{ float: 'right' }}>
<Button onClick={() => handleDecision(true)} color='success'>Yes</Button> <Button onClick={() => handleDecision(true)} color='success'>
<Button onClick={() => handleDecision(false)} color='error'>No</Button> {t('interpretation_buttons.buttons.yes')}
</Button>
<Button onClick={() => handleDecision(false)} color='error'>
{t('interpretation_buttons.buttons.no')}
</Button>
</div> </div>
</div> </div>
</GenericModal> </GenericModal>

View File

@@ -18,6 +18,7 @@ import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import { SidePanelHeader } from './SidePanelHeader'; import { SidePanelHeader } from './SidePanelHeader';
import { useGlobalInfoStore } from '../../context/globalInfo'; import { useGlobalInfoStore } from '../../context/globalInfo';
import { useThemeMode } from '../../context/theme-provider'; import { useThemeMode } from '../../context/theme-provider';
import { useTranslation } from 'react-i18next';
interface InterpretationLogProps { interface InterpretationLogProps {
isOpen: boolean; isOpen: boolean;
@@ -25,6 +26,7 @@ interface InterpretationLogProps {
} }
export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, setIsOpen }) => { export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, setIsOpen }) => {
const { t } = useTranslation();
const [log, setLog] = useState<string>(''); const [log, setLog] = useState<string>('');
const [customValue, setCustomValue] = useState(''); const [customValue, setCustomValue] = useState('');
const [tableData, setTableData] = useState<any[]>([]); const [tableData, setTableData] = useState<any[]>([]);
@@ -34,7 +36,7 @@ export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, se
const { width } = useBrowserDimensionsStore(); const { width } = useBrowserDimensionsStore();
const { socket } = useSocketStore(); const { socket } = useSocketStore();
const { currentWorkflowActionsState } = useGlobalInfoStore(); const { currentWorkflowActionsState, shouldResetInterpretationLog, notify } = useGlobalInfoStore();
const toggleDrawer = (newOpen: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => { const toggleDrawer = (newOpen: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => {
if ( if (
@@ -64,34 +66,43 @@ export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, se
const handleSerializableCallback = useCallback((data: any) => { const handleSerializableCallback = useCallback((data: any) => {
setLog((prevState) => setLog((prevState) =>
prevState + '\n' + '---------- Serializable output data received ----------' + '\n' prevState + '\n' + t('interpretation_log.data_sections.serializable_received') + '\n'
+ JSON.stringify(data, null, 2) + '\n' + '--------------------------------------------------'); + JSON.stringify(data, null, 2) + '\n' + t('interpretation_log.data_sections.separator'));
if (Array.isArray(data)) { if (Array.isArray(data)) {
setTableData(data); setTableData(data);
} }
scrollLogToBottom(); scrollLogToBottom();
}, [log, scrollLogToBottom]); }, [log, scrollLogToBottom, t]);
const handleBinaryCallback = useCallback(({ data, mimetype }: any) => { const handleBinaryCallback = useCallback(({ data, mimetype }: any) => {
const base64String = Buffer.from(data).toString('base64'); const base64String = Buffer.from(data).toString('base64');
const imageSrc = `data:${mimetype};base64,${base64String}`; const imageSrc = `data:${mimetype};base64,${base64String}`;
setLog((prevState) => setLog((prevState) =>
prevState + '\n' + '---------- Binary output data received ----------' + '\n' prevState + '\n' + t('interpretation_log.data_sections.binary_received') + '\n'
+ `mimetype: ${mimetype}` + '\n' + 'Image is rendered below:' + '\n' + t('interpretation_log.data_sections.mimetype') + mimetype + '\n'
+ '------------------------------------------------'); + t('interpretation_log.data_sections.image_below') + '\n'
+ t('interpretation_log.data_sections.separator'));
setBinaryData(imageSrc); setBinaryData(imageSrc);
scrollLogToBottom(); scrollLogToBottom();
}, [log, scrollLogToBottom]); }, [log, scrollLogToBottom, t]);
const handleCustomValueChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleCustomValueChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setCustomValue(event.target.value); setCustomValue(event.target.value);
}; };
useEffect(() => {
if (shouldResetInterpretationLog) {
setLog('');
setTableData([]);
setBinaryData(null);
}
}, [shouldResetInterpretationLog]);
useEffect(() => { useEffect(() => {
socket?.on('log', handleLog); socket?.on('log', handleLog);
socket?.on('serializableCallback', handleSerializableCallback); socket?.on('serializableCallback', handleSerializableCallback);
@@ -141,7 +152,8 @@ export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, se
}, },
}} }}
> >
<ArrowUpwardIcon fontSize="inherit" sx={{ marginRight: '10px'}} /> Output Data Preview <ArrowUpwardIcon fontSize="inherit" sx={{ marginRight: '10px'}} />
{t('interpretation_log.titles.output_preview')}
</Button> </Button>
<SwipeableDrawer <SwipeableDrawer
anchor="bottom" anchor="bottom"
@@ -160,9 +172,10 @@ export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, se
}, },
}} }}
> >
<Typography variant="h6" gutterBottom style={{ display: 'flex', alignItems: 'center' }}> <Typography variant="h6" gutterBottom style={{ display: 'flex', alignItems: 'center' }}>
<StorageIcon style={{ marginRight: '8px' }} /> Output Data Preview <StorageIcon style={{ marginRight: '8px' }} />
</Typography> {t('interpretation_log.titles.output_preview')}
</Typography>
<div <div
style={{ style={{
height: '50vh', height: '50vh',
@@ -173,8 +186,10 @@ export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, se
{ {
binaryData ? ( binaryData ? (
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '20px' }}>
<Typography variant="body1" gutterBottom>Screenshot</Typography> <Typography variant="body1" gutterBottom>
<img src={binaryData} alt="Binary Output" style={{ maxWidth: '100%' }} /> {t('interpretation_log.titles.screenshot')}
</Typography>
<img src={binaryData} alt={t('interpretation_log.titles.screenshot')} style={{ maxWidth: '100%' }} />
</div> </div>
) : tableData.length > 0 ? ( ) : tableData.length > 0 ? (
<> <>
@@ -198,7 +213,9 @@ export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, se
</TableBody> </TableBody>
</Table> </Table>
</TableContainer> </TableContainer>
<span style={{ marginLeft: '15px', marginTop: '10px', fontSize: '12px' }}>Additional rows of data will be extracted once you finish recording. </span> <span style={{ marginLeft: '15px', marginTop: '10px', fontSize: '12px' }}>
{t('interpretation_log.messages.additional_rows')}
</span>
</> </>
) : ( ) : (
<Grid container justifyContent="center" alignItems="center" style={{ height: '100%' }}> <Grid container justifyContent="center" alignItems="center" style={{ height: '100%' }}>
@@ -206,13 +223,13 @@ export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, se
{hasScrapeListAction || hasScrapeSchemaAction || hasScreenshotAction ? ( {hasScrapeListAction || hasScrapeSchemaAction || hasScreenshotAction ? (
<> <>
<Typography variant="h6" gutterBottom align="left"> <Typography variant="h6" gutterBottom align="left">
You've successfully trained the robot to perform actions! Click on the button below to get a preview of the data your robot will extract. {t('interpretation_log.messages.successful_training')}
</Typography> </Typography>
<SidePanelHeader /> <SidePanelHeader />
</> </>
) : ( ) : (
<Typography variant="h6" gutterBottom align="left"> <Typography variant="h6" gutterBottom align="left">
It looks like you have not selected anything for extraction yet. Once you do, the robot will show a preview of your selections here. {t('interpretation_log.messages.no_selection')}
</Typography> </Typography>
)} )}
</Grid> </Grid>
@@ -224,4 +241,4 @@ export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, se
</Grid> </Grid>
</Grid> </Grid>
); );
} };

View File

@@ -1,4 +1,5 @@
import React, { useState, useContext } from 'react'; import { useTranslation } from "react-i18next";
import React, { useState, useContext, useEffect } from 'react';
import axios from 'axios'; import axios from 'axios';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
@@ -20,173 +21,592 @@ import styled from "styled-components";
import { stopRecording } from "../../api/recording"; import { stopRecording } from "../../api/recording";
import { useGlobalInfoStore } from "../../context/globalInfo"; import { useGlobalInfoStore } from "../../context/globalInfo";
import { IconButton, Menu, MenuItem, Typography, Chip, Button, Modal, Tabs, Tab, Box, Snackbar } from "@mui/material";
import { AccountCircle, Logout, Clear, YouTube, X, Update, Close, Language } from "@mui/icons-material";
import { useNavigate } from 'react-router-dom';
import { AuthContext } from '../../context/auth'; import { AuthContext } from '../../context/auth';
import { SaveRecording } from '../molecules/SaveRecording'; import { SaveRecording } from '../molecules/SaveRecording';
import DiscordIcon from '../atoms/DiscordIcon'; import DiscordIcon from '../atoms/DiscordIcon';
import { apiUrl } from '../../apiConfig'; import { apiUrl } from '../../apiConfig';
import MaxunLogo from "../../assets/maxunlogo.png"; import MaxunLogo from "../../assets/maxunlogo.png";
import { useThemeMode } from '../../context/theme-provider'; import { useThemeMode } from '../../context/theme-provider';
import packageJson from "../../../package.json"
interface NavBarProps { interface NavBarProps {
recordingName: string; recordingName: string;
isRecording: boolean; isRecording: boolean;
} }
export const NavBar: React.FC<NavBarProps> = ({ recordingName, isRecording }) => { export const NavBar: React.FC<NavBarProps> = ({
recordingName,
isRecording,
}) => {
const { notify, browserId, setBrowserId } = useGlobalInfoStore(); const { notify, browserId, setBrowserId } = useGlobalInfoStore();
const { state, dispatch } = useContext(AuthContext); const { state, dispatch } = useContext(AuthContext);
const { user } = state; const { user } = state;
const navigate = useNavigate(); const navigate = useNavigate();
const { darkMode, toggleTheme } = useThemeMode(); const { darkMode, toggleTheme } = useThemeMode();
const { t, i18n } = useTranslation();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [langAnchorEl, setLangAnchorEl] = useState<null | HTMLElement>(null);
const currentVersion = packageJson.version;
const [open, setOpen] = useState(false);
const [latestVersion, setLatestVersion] = useState<string | null>(null);
const [tab, setTab] = useState(0);
const [isUpdateAvailable, setIsUpdateAvailable] = useState(false);
const fetchLatestVersion = async (): Promise<string | null> => {
try {
const response = await fetch("https://api.github.com/repos/getmaxun/maxun/releases/latest");
const data = await response.json();
const version = data.tag_name.replace(/^v/, ""); // Remove 'v' prefix
return version;
} catch (error) {
console.error("Failed to fetch latest version:", error);
return null;
}
};
const handleUpdateOpen = () => {
setOpen(true);
fetchLatestVersion();
};
const handleUpdateClose = () => {
setOpen(false);
setTab(0); // Reset tab to the first tab
};
const handleUpdateTabChange = (event: React.SyntheticEvent, newValue: number) => {
setTab(newValue);
};
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => { const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget); setAnchorEl(event.currentTarget);
}; };
const handleLangMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setLangAnchorEl(event.currentTarget);
};
const handleMenuClose = () => { const handleMenuClose = () => {
setAnchorEl(null); setAnchorEl(null);
setLangAnchorEl(null);
}; };
const logout = async () => { const logout = async () => {
dispatch({ type: 'LOGOUT' }); dispatch({ type: "LOGOUT" });
window.localStorage.removeItem('user'); window.localStorage.removeItem("user");
const { data } = await axios.get(`${apiUrl}/auth/logout`); const { data } = await axios.get(`${apiUrl}/auth/logout`);
notify('success', data.message); notify("success", data.message);
navigate('/login'); navigate("/login");
}; };
const goToMainMenu = async () => { const goToMainMenu = async () => {
if (browserId) { if (browserId) {
await stopRecording(browserId); await stopRecording(browserId);
notify('warning', 'Current Recording was terminated'); notify("warning", t('browser_recording.notifications.terminated'));
setBrowserId(null); setBrowserId(null);
} }
navigate('/'); navigate("/");
}; };
const renderBrandSection = () => ( const changeLanguage = (lang: string) => {
<BrandContainer> i18n.changeLanguage(lang);
<LogoImage src={MaxunLogo} alt="Maxun Logo" /> localStorage.setItem("language", lang);
<ProjectName mode={darkMode ? 'dark' : 'light'}>Maxun</ProjectName> };
<Chip
label="beta"
variant="outlined"
sx={{
marginTop: '10px',
borderColor: '#ff00c3',
color: '#ff00c3'
}}
/>
</BrandContainer>
);
const renderSocialButtons = () => ( // const renderBrandSection = () => (
<> // <BrandContainer>
<IconButton // <LogoImage src={MaxunLogo} alt="Maxun Logo" />
component="a" // <ProjectName mode={darkMode ? 'dark' : 'light'}>Maxun</ProjectName>
href="https://discord.gg/5GbPjBUkws" // <Chip
target="_blank" // label="beta"
rel="noopener noreferrer" // variant="outlined"
sx={{ // sx={{
...styles.socialButton, // marginTop: '10px',
color: darkMode ? '#ffffff' : '#333333', // borderColor: '#ff00c3',
'&:hover': { // color: '#ff00c3'
color: '#ff00c3' // }}
} // />
}} // </BrandContainer>
> // );
<DiscordIcon sx={{ marginRight: '5px' }} />
</IconButton>
<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"
/>
</>
);
const renderUserMenu = () => ( // const renderSocialButtons = () => (
<> // <>
<IconButton // <IconButton
onClick={handleMenuOpen} // component="a"
sx={styles.userButton(darkMode)} // href="https://discord.gg/5GbPjBUkws"
> // target="_blank"
<AccountCircle sx={{ marginRight: '5px' }} /> // rel="noopener noreferrer"
<Typography variant="body1">{user?.email}</Typography> // sx={{
</IconButton> // ...styles.socialButton,
<Menu // color: darkMode ? '#ffffff' : '#333333',
anchorEl={anchorEl} // '&:hover': {
open={Boolean(anchorEl)} // color: '#ff00c3'
onClose={handleMenuClose} // }
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} // }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }} // >
PaperProps={{ // <DiscordIcon sx={{ marginRight: '5px' }} />
sx: { // </IconButton>
backgroundColor: darkMode ? '#1e2124' : '#ffffff', // <iframe
color: darkMode ? '#ffffff' : '#333333' // src="https://ghbtns.com/github-btn.html?user=getmaxun&repo=maxun&type=star&count=true&size=large"
} // frameBorder="0"
}} // scrolling="0"
> // width="170"
<MenuItem onClick={() => { handleMenuClose(); logout(); }}> // height="30"
<Logout sx={{ marginRight: '5px' }} /> Logout // title="GitHub"
</MenuItem> // />
</Menu> // </>
</> // );
);
const renderThemeToggle = () => ( // const renderUserMenu = () => (
<Tooltip title="Toggle light/dark theme"> // <>
<IconButton // <IconButton
onClick={toggleTheme} // onClick={handleMenuOpen}
sx={{ // sx={styles.userButton(darkMode)}
color: darkMode ? '#ffffff' : '#333333', // >
'&:hover': { // <AccountCircle sx={{ marginRight: '5px' }} />
color: '#ff00c3' // <Typography variant="body1">{user?.email}</Typography>
} // </IconButton>
}} // <Menu
> // anchorEl={anchorEl}
{darkMode ? <Brightness7 /> : <Brightness4 />} // open={Boolean(anchorEl)}
</IconButton> // onClose={handleMenuClose}
</Tooltip> // anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
); // transformOrigin={{ vertical: 'top', horizontal: 'right' }}
// PaperProps={{
// sx: {
// backgroundColor: darkMode ? '#1e2124' : '#ffffff',
// color: darkMode ? '#ffffff' : '#333333'
// }
// }}
// >
// <MenuItem onClick={() => { handleMenuClose(); logout(); }}>
// <Logout sx={{ marginRight: '5px' }} /> Logout
// </MenuItem>
// </Menu>
// </>
// );
const renderRecordingControls = () => ( // const renderThemeToggle = () => (
<> // <Tooltip title="Toggle light/dark theme">
<IconButton // <IconButton
onClick={goToMainMenu} // onClick={toggleTheme}
sx={styles.discardButton} // sx={{
> // color: darkMode ? '#ffffff' : '#333333',
<Clear sx={{ marginRight: '5px' }} /> // '&:hover': {
Discard // color: '#ff00c3'
</IconButton> // }
<SaveRecording fileName={recordingName} /> // }}
</> // >
); // {darkMode ? <Brightness7 /> : <Brightness4 />}
// </IconButton>
// </Tooltip>
// );
return ( // const renderRecordingControls = () => (
// <>
// <IconButton
// onClick={goToMainMenu}
// sx={styles.discardButton}
// >
// <Clear sx={{ marginRight: '5px' }} />
// Discard
// </IconButton>
// <SaveRecording fileName={recordingName} />
// </>
// );
<NavBarWrapper mode={darkMode ? 'dark' : 'light'}> // return (
{renderBrandSection()}
{user && ( // <NavBarWrapper mode={darkMode ? 'dark' : 'light'}>
<ControlsContainer> // {renderBrandSection()}
{!isRecording ? ( // {user && (
<> // <ControlsContainer>
{renderSocialButtons()} // {!isRecording ? (
{renderUserMenu()} // <>
{renderThemeToggle()} // {renderSocialButtons()}
</> // {renderUserMenu()}
) : ( // {renderThemeToggle()}
renderRecordingControls() // </>
)} // ) : (
</ControlsContainer> // renderRecordingControls()
)} // )}
// </ControlsContainer>
// )}
</NavBarWrapper> // </NavBarWrapper>
useEffect(() => {
const checkForUpdates = async () => {
const latestVersion = await fetchLatestVersion();
setLatestVersion(latestVersion);
if (latestVersion && latestVersion !== currentVersion) {
setIsUpdateAvailable(true);
}
};
checkForUpdates();
}, []);
return (
<>
{isUpdateAvailable && (
<Snackbar
open={isUpdateAvailable}
onClose={() => setIsUpdateAvailable(false)}
message={
`${t('navbar.upgrade.modal.new_version_available', { version: latestVersion })} ${t('navbar.upgrade.modal.view_updates')}`
}
action={
<>
<Button
color="primary"
size="small"
onClick={handleUpdateOpen}
style={{
backgroundColor: '#ff00c3',
color: 'white',
fontWeight: 'bold',
textTransform: 'none',
marginRight: '8px',
borderRadius: '5px',
}}
>
{t('navbar.upgrade.button')}
</Button>
<IconButton
size="small"
aria-label="close"
color="inherit"
onClick={() => setIsUpdateAvailable(false)}
style={{ color: 'black' }}
>
<Close />
</IconButton>
</>
}
ContentProps={{
sx: {
background: "white",
color: "black",
}
}}
/>
)}
<NavBarWrapper mode={darkMode ? 'dark' : 'light'}>
<div style={{
display: 'flex',
justifyContent: 'flex-start',
}}>
<img src={MaxunLogo} width={45} height={40} style={{ borderRadius: '5px', margin: '5px 0px 5px 15px' }} />
<div style={{ padding: '11px' }}><ProjectName>{t('navbar.project_name')}</ProjectName></div>
<Chip
label={`${currentVersion}`}
color="primary"
variant="outlined"
sx={{ marginTop: '10px' }}
/>
</div>
{
user ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
{!isRecording ? (
<>
<Button variant="outlined" onClick={handleUpdateOpen} sx={{
marginRight: '40px',
color: "#00000099",
border: "#00000099 1px solid",
'&:hover': { color: '#ff00c3', border: '#ff00c3 1px solid' }
}}>
<Update sx={{ marginRight: '5px' }} /> {t('navbar.upgrade.button')} Maxun
</Button>
<Modal open={open} onClose={handleUpdateClose}>
<Box
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 500,
bgcolor: "background.paper",
boxShadow: 24,
p: 4,
borderRadius: 2,
}}
>
{latestVersion === null ? (
<Typography>Checking for updates...</Typography>
) : currentVersion === latestVersion ? (
<Typography variant="h6" textAlign="center">
{t('navbar.upgrade.modal.up_to_date')}
</Typography>
) : (
<>
<Typography variant="body1" textAlign="left" sx={{ marginLeft: '30px' }}>
{t('navbar.upgrade.modal.new_version_available', { version: latestVersion })}
<br />
{t('navbar.upgrade.modal.view_updates')}
<a href="https://github.com/getmaxun/maxun/releases/" target="_blank" style={{ textDecoration: 'none' }}>{' '}here.</a>
</Typography>
<Tabs
value={tab}
onChange={handleUpdateTabChange}
sx={{ marginTop: 2, marginBottom: 2 }}
centered
>
<Tab label={t('navbar.upgrade.modal.tabs.manual_setup')} />
<Tab label={t('navbar.upgrade.modal.tabs.docker_setup')} />
</Tabs>
{tab === 0 && (
<Box sx={{ marginLeft: '30px', background: '#cfd0d1', padding: 1, borderRadius: 3 }}>
<code style={{ color: 'black' }}>
<p>Run the commands below</p>
# cd to project directory (eg: maxun)
<br />
cd maxun
<br />
<br />
# pull latest changes
<br />
git pull origin master
<br />
<br />
# install dependencies
<br />
npm install
<br />
<br />
# start maxun
<br />
npm run start
</code>
</Box>
)}
{tab === 1 && (
<Box sx={{ marginLeft: '30px', background: '#cfd0d1', padding: 1, borderRadius: 3 }}>
<code style={{ color: 'black' }}>
<p>Run the commands below</p>
# cd to project directory (eg: maxun)
<br />
cd maxun
<br />
<br />
# stop the working containers
<br />
docker-compose down
<br />
<br />
# pull latest docker images
<br />
docker-compose pull
<br />
<br />
# start maxun
<br />
docker-compose up -d
</code>
</Box>
)}
</>
)}
</Box>
</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>
<IconButton onClick={handleMenuOpen} sx={{
display: 'flex',
alignItems: 'center',
borderRadius: '5px',
padding: '8px',
marginRight: '10px',
'&:hover': { backgroundColor: 'white', color: '#ff00c3' }
}}>
<AccountCircle sx={{ marginRight: '5px' }} />
<Typography variant="body1">{user.email}</Typography>
</IconButton>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
PaperProps={{ sx: { width: '180px' } }}
>
<MenuItem onClick={() => { handleMenuClose(); logout(); }}>
<Logout sx={{ marginRight: '5px' }} /> {t('navbar.menu_items.logout')}
</MenuItem>
<MenuItem onClick={() => {
window.open('https://discord.gg/5GbPjBUkws', '_blank');
}}>
<DiscordIcon sx={{ marginRight: '5px' }} /> Discord
</MenuItem>
<MenuItem onClick={() => {
window.open('https://www.youtube.com/@MaxunOSS/videos?ref=app', '_blank');
}}>
<YouTube sx={{ marginRight: '5px' }} /> YouTube
</MenuItem>
<MenuItem onClick={() => {
window.open('https://x.com/maxun_io?ref=app', '_blank');
}}>
<X sx={{ marginRight: '5px' }} /> Twitter (X)
</MenuItem>
<MenuItem onClick={handleLangMenuOpen}>
<Language sx={{ marginRight: '5px' }} /> {t('navbar.menu_items.language')}
</MenuItem>
<Menu
anchorEl={langAnchorEl}
open={Boolean(langAnchorEl)}
onClose={handleMenuClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
>
<MenuItem
onClick={() => {
changeLanguage("en");
handleMenuClose();
}}
>
English
</MenuItem>
<MenuItem
onClick={() => {
changeLanguage("es");
handleMenuClose();
}}
>
Español
</MenuItem>
<MenuItem
onClick={() => {
changeLanguage("ja");
handleMenuClose();
}}
>
</MenuItem>
<MenuItem
onClick={() => {
changeLanguage("zh");
handleMenuClose();
}}
>
</MenuItem>
<MenuItem
onClick={() => {
changeLanguage("de");
handleMenuClose();
}}
>
Deutsch
</MenuItem>
</Menu>
</Menu>
</>
) : (
<>
<IconButton onClick={goToMainMenu} sx={{
borderRadius: '5px',
padding: '8px',
background: 'red',
color: 'white',
marginRight: '10px',
'&:hover': { color: 'white', backgroundColor: 'red' }
}}>
<Clear sx={{ marginRight: '5px' }} />
{t('navbar.recording.discard')}
</IconButton>
<SaveRecording fileName={recordingName} />
</>
)}
</div>
) : (
<><IconButton
onClick={handleLangMenuOpen}
sx={{
display: "flex",
alignItems: "center",
borderRadius: "5px",
padding: "8px",
marginRight: "10px",
}}
>
<Language sx={{ marginRight: '5px' }} /><Typography variant="body1">{t("Language")}</Typography>
</IconButton>
<Menu
anchorEl={langAnchorEl}
open={Boolean(langAnchorEl)}
onClose={handleMenuClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
>
<MenuItem
onClick={() => {
changeLanguage("en");
handleMenuClose();
}}
>
English
</MenuItem>
<MenuItem
onClick={() => {
changeLanguage("es");
handleMenuClose();
}}
>
Español
</MenuItem>
<MenuItem
onClick={() => {
changeLanguage("ja");
handleMenuClose();
}}
>
</MenuItem>
<MenuItem
onClick={() => {
changeLanguage("zh");
handleMenuClose();
}}
>
</MenuItem>
<MenuItem
onClick={() => {
changeLanguage("de");
handleMenuClose();
}}
>
Deutsch
</MenuItem>
</Menu></>
)}
</NavBarWrapper>
</>
); );
}; };

View File

@@ -1,5 +1,5 @@
import React, { FC, useState } from 'react'; import React, { FC, useState } from 'react';
import { Stack, Button, IconButton, Tooltip, Chip, Badge } from "@mui/material"; import { Stack, Button, IconButton, Tooltip, Badge } from "@mui/material";
import { AddPair, deletePair, UpdatePair } from "../../api/workflow"; import { AddPair, deletePair, UpdatePair } from "../../api/workflow";
import { WorkflowFile } from "maxun-core"; import { WorkflowFile } from "maxun-core";
import { ClearButton } from "../atoms/buttons/ClearButton"; import { ClearButton } from "../atoms/buttons/ClearButton";

View File

@@ -1,4 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { useTranslation } from 'react-i18next';
import Paper from '@mui/material/Paper'; import Paper from '@mui/material/Paper';
import Table from '@mui/material/Table'; import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody'; import TableBody from '@mui/material/TableBody';
@@ -19,6 +20,7 @@ import { useNavigate } from 'react-router-dom';
import { stopRecording } from "../../api/recording"; import { stopRecording } from "../../api/recording";
import { GenericModal } from '../atoms/GenericModal'; import { GenericModal } from '../atoms/GenericModal';
/** TODO: /** TODO:
* 1. allow editing existing robot after persisting browser steps * 1. allow editing existing robot after persisting browser steps
*/ */
@@ -31,31 +33,6 @@ interface Column {
format?: (value: string) => string; format?: (value: string) => string;
} }
const columns: readonly Column[] = [
{ id: 'interpret', label: 'Run', minWidth: 80 },
{ id: 'name', label: 'Name', minWidth: 80 },
{
id: 'schedule',
label: 'Schedule',
minWidth: 80,
},
{
id: 'integrate',
label: 'Integrate',
minWidth: 80,
},
{
id: 'settings',
label: 'Settings',
minWidth: 80,
},
{
id: 'options',
label: 'Options',
minWidth: 80,
},
];
interface Data { interface Data {
id: string; id: string;
name: string; name: string;
@@ -76,12 +53,38 @@ interface RecordingsTableProps {
} }
export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handleScheduleRecording, handleIntegrateRecording, handleSettingsRecording, handleEditRobot, handleDuplicateRobot }: RecordingsTableProps) => { export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handleScheduleRecording, handleIntegrateRecording, handleSettingsRecording, handleEditRobot, handleDuplicateRobot }: RecordingsTableProps) => {
const {t} = useTranslation();
const [page, setPage] = React.useState(0); const [page, setPage] = React.useState(0);
const [rowsPerPage, setRowsPerPage] = React.useState(10); const [rowsPerPage, setRowsPerPage] = React.useState(10);
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 columns: readonly Column[] = [
{ id: 'interpret', label: t('recordingtable.run'), minWidth: 80 },
{ id: 'name', label: t('recordingtable.name'), minWidth: 80 },
{
id: 'schedule',
label: t('recordingtable.schedule'),
minWidth: 80,
},
{
id: 'integrate',
label: t('recordingtable.integrate'),
minWidth: 80,
},
{
id: 'settings',
label: t('recordingtable.settings'),
minWidth: 80,
},
{
id: 'options',
label: t('recordingtable.options'),
minWidth: 80,
},
];
const { notify, setRecordings, browserId, setBrowserId, recordingUrl, setRecordingUrl, recordingName, setRecordingName, recordingId, setRecordingId } = useGlobalInfoStore(); const { notify, setRecordings, browserId, setBrowserId, recordingUrl, setRecordingUrl, recordingName, setRecordingName, recordingId, setRecordingId } = useGlobalInfoStore();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -151,16 +154,17 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl
row.name.toLowerCase().includes(searchTerm.toLowerCase()) row.name.toLowerCase().includes(searchTerm.toLowerCase())
); );
return ( return (
<React.Fragment> <React.Fragment>
<Box display="flex" justifyContent="space-between" alignItems="center"> <Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
My Robots {t('recordingtable.heading')}
</Typography> </Typography>
<Box display="flex" alignItems="center" gap={2}> <Box display="flex" alignItems="center" gap={2}>
<TextField <TextField
size="small" size="small"
placeholder="Search robots..." placeholder={t('recordingtable.search')}
value={searchTerm} value={searchTerm}
onChange={handleSearchChange} onChange={handleSearchChange}
InputProps={{ InputProps={{
@@ -187,7 +191,7 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl
'&:hover': { color: 'white', backgroundColor: '#ff00c3' } '&:hover': { color: 'white', backgroundColor: '#ff00c3' }
}} }}
> >
<Add sx={{ marginRight: '5px' }} /> Create Robot <Add sx={{ marginRight: '5px' }} /> {t('recordingtable.new')}
</IconButton> </IconButton>
</Box> </Box>
</Box> </Box>
@@ -253,14 +257,14 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl
checkRunsForRecording(row.id).then((result: boolean) => { checkRunsForRecording(row.id).then((result: boolean) => {
if (result) { if (result) {
notify('warning', 'Cannot delete robot as it has associated runs'); notify('warning', t('recordingtable.notifications.delete_warning'));
} }
}) })
deleteRecordingFromStorage(row.id).then((result: boolean) => { deleteRecordingFromStorage(row.id).then((result: boolean) => {
if (result) { if (result) {
setRows([]); setRows([]);
notify('success', 'Robot deleted successfully'); notify('success', t('recordingtable.notifications.delete_success'));
fetchRecordings(); fetchRecordings();
} }
}) })
@@ -297,9 +301,9 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl
/> />
<GenericModal isOpen={isModalOpen} onClose={() => setModalOpen(false)} modalStyle={modalStyle}> <GenericModal isOpen={isModalOpen} onClose={() => setModalOpen(false)} modalStyle={modalStyle}>
<div style={{ padding: '20px' }}> <div style={{ padding: '20px' }}>
<Typography variant="h6" gutterBottom>Enter URL To Extract Data</Typography> <Typography variant="h6" gutterBottom>{t('recordingtable.modal.title')}</Typography>
<TextField <TextField
label="URL" label={t('recordingtable.modal.label')}
variant="outlined" variant="outlined"
fullWidth fullWidth
value={recordingUrl} value={recordingUrl}
@@ -312,7 +316,7 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl
onClick={startRecording} onClick={startRecording}
disabled={!recordingUrl} disabled={!recordingUrl}
> >
Start Training Robot {t('recordingtable.modal.button')}
</Button> </Button>
</div> </div>
</GenericModal> </GenericModal>
@@ -397,6 +401,8 @@ const OptionsButton = ({ handleEdit, handleDelete, handleDuplicate }: OptionsBut
setAnchorEl(null); setAnchorEl(null);
}; };
const {t} = useTranslation();
return ( return (
<> <>
<IconButton <IconButton
@@ -415,19 +421,21 @@ const OptionsButton = ({ handleEdit, handleDelete, handleDuplicate }: OptionsBut
<ListItemIcon> <ListItemIcon>
<Edit fontSize="small" /> <Edit fontSize="small" />
</ListItemIcon> </ListItemIcon>
<ListItemText>Edit</ListItemText> <ListItemText>{t('recordingtable.edit')}</ListItemText>
</MenuItem>
<MenuItem onClick={() => { handleDuplicate(); handleClose(); }}>
<ListItemIcon>
<ContentCopy fontSize="small" />
</ListItemIcon>
<ListItemText>Duplicate</ListItemText>
</MenuItem> </MenuItem>
<MenuItem onClick={() => { handleDelete(); handleClose(); }}> <MenuItem onClick={() => { handleDelete(); handleClose(); }}>
<ListItemIcon> <ListItemIcon>
<DeleteForever fontSize="small" /> <DeleteForever fontSize="small" />
</ListItemIcon> </ListItemIcon>
<ListItemText>Delete</ListItemText> <ListItemText>{t('recordingtable.delete')}</ListItemText>
</MenuItem>
<MenuItem onClick={() => { handleDuplicate(); handleClose(); }}>
<ListItemIcon>
<ContentCopy fontSize="small" />
</ListItemIcon>
<ListItemText>{t('recordingtable.duplicate')}</ListItemText>
</MenuItem> </MenuItem>
</Menu> </Menu>
</> </>

View File

@@ -1,11 +1,12 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { GenericModal } from "../atoms/GenericModal"; import { GenericModal } from "../atoms/GenericModal";
import { TextField, Typography, Box, Button, Chip } from "@mui/material"; import { TextField, Typography, Box, Button } from "@mui/material";
import { modalStyle } from "./AddWhereCondModal"; import { modalStyle } from "./AddWhereCondModal";
import { useGlobalInfoStore } from '../../context/globalInfo'; import { useGlobalInfoStore } from '../../context/globalInfo';
import { duplicateRecording, getStoredRecording } from '../../api/storage'; import { duplicateRecording, getStoredRecording } from '../../api/storage';
import { WhereWhatPair } from 'maxun-core'; import { WhereWhatPair } from 'maxun-core';
import { getUserById } from "../../api/auth"; import { getUserById } from "../../api/auth";
import { useTranslation } from 'react-i18next';
interface RobotMeta { interface RobotMeta {
name: string; name: string;
@@ -54,6 +55,7 @@ interface RobotSettingsProps {
} }
export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => { export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
const { t } = useTranslation();
const [robot, setRobot] = useState<RobotSettings | null>(null); const [robot, setRobot] = useState<RobotSettings | null>(null);
const [targetUrl, setTargetUrl] = useState<string | undefined>(''); const [targetUrl, setTargetUrl] = useState<string | undefined>('');
const { recordingId, notify } = useGlobalInfoStore(); const { recordingId, notify } = useGlobalInfoStore();
@@ -65,7 +67,6 @@ export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initia
}, [isOpen]); }, [isOpen]);
useEffect(() => { useEffect(() => {
// Update the targetUrl when the robot data is loaded
if (robot) { if (robot) {
const lastPair = robot?.recording.workflow[robot?.recording.workflow.length - 1]; const lastPair = robot?.recording.workflow[robot?.recording.workflow.length - 1];
const url = lastPair?.what.find(action => action.action === "goto")?.args?.[0]; const url = lastPair?.what.find(action => action.action === "goto")?.args?.[0];
@@ -78,43 +79,36 @@ export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initia
const robot = await getStoredRecording(recordingId); const robot = await getStoredRecording(recordingId);
setRobot(robot); setRobot(robot);
} else { } else {
notify('error', 'Could not find robot details. Please try again.'); notify('error', t('robot_duplication.notifications.robot_not_found'));
} }
} }
// const lastPair = robot?.recording.workflow[robot?.recording.workflow.length - 1];
// // Find the `goto` action in `what` and retrieve its arguments
// const targetUrl = lastPair?.what.find(action => action.action === "goto")?.args?.[0];
const handleTargetUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleTargetUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setTargetUrl(e.target.value); setTargetUrl(e.target.value);
}; };
const handleSave = async () => { const handleSave = async () => {
if (!robot || !targetUrl) { if (!robot || !targetUrl) {
notify('error', 'Target URL is required.'); notify('error', t('robot_duplication.notifications.url_required'));
return; return;
} }
console.log("handle save");
try { try {
const success = await duplicateRecording(robot.recording_meta.id, targetUrl); const success = await duplicateRecording(robot.recording_meta.id, targetUrl);
if (success) { if (success) {
notify('success', 'Robot duplicated successfully.'); notify('success', t('robot_duplication.notifications.duplicate_success'));
handleStart(robot); // Inform parent about the updated robot handleStart(robot);
handleClose(); handleClose();
setTimeout(() => { setTimeout(() => {
window.location.reload(); window.location.reload();
}, 1000); }, 1000);
} else { } else {
notify('error', 'Failed to update the Target URL. Please try again.'); notify('error', t('robot_duplication.notifications.duplicate_error'));
} }
} catch (error) { } catch (error) {
notify('error', 'An error occurred while updating the Target URL.'); notify('error', t('robot_duplication.notifications.unknown_error'));
console.error('Error updating Target URL:', error); console.error('Error updating Target URL:', error);
} }
}; };
@@ -126,34 +120,40 @@ export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initia
modalStyle={modalStyle} modalStyle={modalStyle}
> >
<> <>
<Typography variant="h5" style={{ marginBottom: '20px' }}>Duplicate Robot</Typography> <Typography variant="h5" style={{ marginBottom: '20px' }}>
{t('robot_duplication.title')}
</Typography>
<Box style={{ display: 'flex', flexDirection: 'column' }}> <Box style={{ display: 'flex', flexDirection: 'column' }}>
{ {
robot && ( robot && (
<> <>
<span>Robot duplication is useful to extract data from pages with the same structure.</span> <span>
{t('robot_duplication.descriptions.purpose')}
</span>
<br />
<span dangerouslySetInnerHTML={{
__html: t('robot_duplication.descriptions.example', {
url1: '<code>producthunt.com/topics/api</code>',
url2: '<code>producthunt.com/topics/database</code>'
})
}}/>
<br /> <br />
<span> <span>
Example: If you've created a robot for <code>producthunt.com/topics/api</code>, you can duplicate it to scrape similar pages <b>{t('robot_duplication.descriptions.warning')}</b>
like <code>producthunt.com/topics/database</code> without training a robot from scratch.
</span>
<br />
<span>
<b>⚠️ Ensure the new page has the same structure as the original page.</b>
</span> </span>
<TextField <TextField
label="Robot Target URL" label={t('robot_duplication.fields.target_url')}
key="Robot Target URL" key={t('robot_duplication.fields.target_url')}
value={targetUrl} value={targetUrl}
onChange={handleTargetUrlChange} onChange={handleTargetUrlChange}
style={{ marginBottom: '20px', marginTop: '30px' }} style={{ marginBottom: '20px', marginTop: '30px' }}
/> />
<Box mt={2} display="flex" justifyContent="flex-end" onClick={handleSave}> <Box mt={2} display="flex" justifyContent="flex-end">
<Button variant="contained" color="primary"> <Button variant="contained" color="primary" onClick={handleSave}>
Duplicate Robot {t('robot_duplication.buttons.duplicate')}
</Button> </Button>
<Button onClick={handleClose} color="primary" variant="outlined" style={{ marginLeft: '10px' }}> <Button onClick={handleClose} color="primary" variant="outlined" style={{ marginLeft: '10px' }}>
Cancel {t('robot_duplication.buttons.cancel')}
</Button> </Button>
</Box> </Box>
</> </>

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { GenericModal } from "../atoms/GenericModal"; import { GenericModal } from "../atoms/GenericModal";
import { TextField, Typography, Box, Button } from "@mui/material"; import { TextField, Typography, Box, Button } from "@mui/material";
import { modalStyle } from "./AddWhereCondModal"; import { modalStyle } from "./AddWhereCondModal";
@@ -54,10 +55,10 @@ interface RobotSettingsProps {
handleStart: (settings: RobotSettings) => void; handleStart: (settings: RobotSettings) => void;
handleClose: () => void; handleClose: () => void;
initialSettings?: RobotSettings | null; initialSettings?: RobotSettings | null;
} }
export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => { export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
const { t } = useTranslation();
const [robot, setRobot] = useState<RobotSettings | null>(null); const [robot, setRobot] = useState<RobotSettings | null>(null);
const { recordingId, notify } = useGlobalInfoStore(); const { recordingId, notify } = useGlobalInfoStore();
@@ -72,7 +73,7 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin
const robot = await getStoredRecording(recordingId); const robot = await getStoredRecording(recordingId);
setRobot(robot); setRobot(robot);
} else { } else {
notify('error', 'Could not find robot details. Please try again.'); notify('error', t('robot_edit.notifications.update_failed'));
} }
} }
@@ -102,6 +103,7 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin
return { ...prev, recording: { ...prev.recording, workflow: updatedWorkflow } }; return { ...prev, recording: { ...prev.recording, workflow: updatedWorkflow } };
}); });
}; };
const handleSave = async () => { const handleSave = async () => {
if (!robot) return; if (!robot) return;
@@ -114,7 +116,7 @@ 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) {
notify('success', 'Robot updated successfully.'); notify('success', t('robot_edit.notifications.update_success'));
handleStart(robot); // Inform parent about the updated robot handleStart(robot); // Inform parent about the updated robot
handleClose(); handleClose();
@@ -122,10 +124,10 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin
window.location.reload(); window.location.reload();
}, 1000); }, 1000);
} else { } else {
notify('error', 'Failed to update the robot. Please try again.'); notify('error', t('robot_edit.notifications.update_failed'));
} }
} catch (error) { } catch (error) {
notify('error', 'An error occurred while updating the robot.'); notify('error', t('robot_edit.notifications.update_error'));
console.error('Error updating robot:', error); console.error('Error updating robot:', error);
} }
}; };
@@ -137,14 +139,16 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin
modalStyle={modalStyle} modalStyle={modalStyle}
> >
<> <>
<Typography variant="h5" style={{ marginBottom: '20px' }}>Edit Robot</Typography> <Typography variant="h5" style={{ marginBottom: '20px' }}>
{t('robot_edit.title')}
</Typography>
<Box style={{ display: 'flex', flexDirection: 'column' }}> <Box style={{ display: 'flex', flexDirection: 'column' }}>
{ {
robot && ( robot && (
<> <>
<TextField <TextField
label="Change Robot Name" label={t('robot_edit.change_name')}
key="Change Robot Name" key="Robot Name"
type='text' type='text'
value={robot.recording_meta.name} value={robot.recording_meta.name}
onChange={(e) => handleRobotNameChange(e.target.value)} onChange={(e) => handleRobotNameChange(e.target.value)}
@@ -152,7 +156,7 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin
/> />
{robot.recording.workflow?.[0]?.what?.[0]?.args?.[0]?.limit !== undefined && ( {robot.recording.workflow?.[0]?.what?.[0]?.args?.[0]?.limit !== undefined && (
<TextField <TextField
label="Robot Limit" label={t('robot_edit.robot_limit')}
type="number" type="number"
value={robot.recording.workflow[0].what[0].args[0].limit || ''} value={robot.recording.workflow[0].what[0].args[0].limit || ''}
onChange={(e) =>{ onChange={(e) =>{
@@ -166,12 +170,17 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin
/> />
)} )}
<Box mt={2} display="flex" justifyContent="flex-end" onClick={handleSave}> <Box mt={2} display="flex" justifyContent="flex-end">
<Button variant="contained" color="primary"> <Button variant="contained" color="primary" onClick={handleSave}>
Save Changes {t('robot_edit.save')}
</Button> </Button>
<Button onClick={handleClose} color="primary" variant="outlined" style={{ marginLeft: '10px' }}> <Button
Cancel onClick={handleClose}
color="primary"
variant="outlined"
style={{ marginLeft: '10px' }}
>
{t('robot_edit.cancel')}
</Button> </Button>
</Box> </Box>
</> </>

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { GenericModal } from "../atoms/GenericModal"; import { GenericModal } from "../atoms/GenericModal";
import { TextField, Typography, Box } from "@mui/material"; import { TextField, Typography, Box } from "@mui/material";
import { modalStyle } from "./AddWhereCondModal"; import { modalStyle } from "./AddWhereCondModal";
@@ -50,10 +51,10 @@ interface RobotSettingsProps {
handleStart: (settings: RobotSettings) => void; handleStart: (settings: RobotSettings) => void;
handleClose: () => void; handleClose: () => void;
initialSettings?: RobotSettings | null; initialSettings?: RobotSettings | null;
} }
export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => { export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
const { t } = useTranslation();
const [robot, setRobot] = useState<RobotSettings | null>(null); const [robot, setRobot] = useState<RobotSettings | null>(null);
const [userEmail, setUserEmail] = useState<string | null>(null); const [userEmail, setUserEmail] = useState<string | null>(null);
const { recordingId, notify } = useGlobalInfoStore(); const { recordingId, notify } = useGlobalInfoStore();
@@ -69,7 +70,7 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
const robot = await getStoredRecording(recordingId); const robot = await getStoredRecording(recordingId);
setRobot(robot); setRobot(robot);
} else { } else {
notify('error', 'Could not find robot details. Please try again.'); notify('error', t('robot_settings.errors.robot_not_found'));
} }
} }
@@ -97,13 +98,15 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
modalStyle={modalStyle} modalStyle={modalStyle}
> >
<> <>
<Typography variant="h5" style={{ marginBottom: '20px' }}>Robot Settings</Typography> <Typography variant="h5" style={{ marginBottom: '20px' }}>
{t('robot_settings.title')}
</Typography>
<Box style={{ display: 'flex', flexDirection: 'column' }}> <Box style={{ display: 'flex', flexDirection: 'column' }}>
{ {
robot && ( robot && (
<> <>
<TextField <TextField
label="Robot Target URL" label={t('robot_settings.target_url')}
key="Robot Target URL" key="Robot Target URL"
value={targetUrl} value={targetUrl}
InputProps={{ InputProps={{
@@ -112,7 +115,7 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
style={{ marginBottom: '20px' }} style={{ marginBottom: '20px' }}
/> />
<TextField <TextField
label="Robot ID" label={t('robot_settings.robot_id')}
key="Robot ID" key="Robot ID"
value={robot.recording_meta.id} value={robot.recording_meta.id}
InputProps={{ InputProps={{
@@ -122,17 +125,17 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
/> />
{robot.recording.workflow?.[0]?.what?.[0]?.args?.[0]?.limit !== undefined && ( {robot.recording.workflow?.[0]?.what?.[0]?.args?.[0]?.limit !== undefined && (
<TextField <TextField
label="Robot Limit" label={t('robot_settings.robot_limit')}
type="number" type="number"
value={robot.recording.workflow[0].what[0].args[0].limit || ''} value={robot.recording.workflow[0].what[0].args[0].limit || ''}
InputProps={{ InputProps={{
readOnly: true, readOnly: true,
}} }}
style={{ marginBottom: '20px' }} style={{ marginBottom: '20px' }}
/> />
)} )}
<TextField <TextField
label="Created By User" label={t('robot_settings.created_by_user')}
key="Created By User" key="Created By User"
value={userEmail ? userEmail : ''} value={userEmail ? userEmail : ''}
InputProps={{ InputProps={{
@@ -141,7 +144,7 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
style={{ marginBottom: '20px' }} style={{ marginBottom: '20px' }}
/> />
<TextField <TextField
label="Robot Created At" label={t('robot_settings.created_at')}
key="Robot Created At" key="Robot Created At"
value={robot.recording_meta.createdAt} value={robot.recording_meta.createdAt}
InputProps={{ InputProps={{
@@ -156,4 +159,4 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
</> </>
</GenericModal> </GenericModal>
); );
}; };

View File

@@ -13,6 +13,7 @@ import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead'; import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow'; import TableRow from '@mui/material/TableRow';
import 'highlight.js/styles/github.css'; import 'highlight.js/styles/github.css';
import { useTranslation } from "react-i18next";
interface RunContentProps { interface RunContentProps {
row: Data, row: Data,
@@ -23,6 +24,7 @@ interface RunContentProps {
} }
export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRef, abortRunHandler }: RunContentProps) => { export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRef, abortRunHandler }: RunContentProps) => {
const { t } = useTranslation();
const [tab, setTab] = React.useState<string>('log'); const [tab, setTab] = React.useState<string>('log');
const [tableData, setTableData] = useState<any[]>([]); const [tableData, setTableData] = useState<any[]>([]);
const [columns, setColumns] = useState<string[]>([]); const [columns, setColumns] = useState<string[]>([]);
@@ -76,49 +78,49 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
<TabContext value={tab}> <TabContext value={tab}>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}> <Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs <Tabs
value={tab} value={tab}
onChange={(e, newTab) => setTab(newTab)} onChange={(e, newTab) => setTab(newTab)}
aria-label="run-content-tabs" aria-label="run-content-tabs"
sx={{ sx={{
// Remove the default blue indicator // Remove the default blue indicator
'& .MuiTabs-indicator': { '& .MuiTabs-indicator': {
backgroundColor: '#FF00C3', // Change to pink backgroundColor: '#FF00C3', // Change to pink
}, },
// Remove default transition effects // Remove default transition effects
'& .MuiTab-root': { '& .MuiTab-root': {
'&.Mui-selected': { '&.Mui-selected': {
color: '#FF00C3', color: '#FF00C3',
}, },
} }
}} }}
> >
<Tab <Tab
label="Output Data" label={t('run_content.tabs.output_data')}
value='output' value='output'
sx={{ sx={{
color: (theme) => theme.palette.mode === 'dark' ? '#fff' : '#000', color: (theme) => theme.palette.mode === 'dark' ? '#fff' : '#000',
'&:hover': { '&:hover': {
color: '#FF00C3' color: '#FF00C3'
}, },
'&.Mui-selected': { '&.Mui-selected': {
color: '#FF00C3', color: '#FF00C3',
} }
}} }}
/> />
<Tab <Tab
label="Log" label={t('run_content.tabs.log')}
value='log' value='log'
sx={{ sx={{
color: (theme) => theme.palette.mode === 'dark' ? '#fff' : '#000', color: (theme) => theme.palette.mode === 'dark' ? '#fff' : '#000',
'&:hover': { '&:hover': {
color: '#FF00C3' color: '#FF00C3'
}, },
'&.Mui-selected': { '&.Mui-selected': {
color: '#FF00C3', color: '#FF00C3',
} }
}} }}
/> />
</Tabs> </Tabs>
</Box> </Box>
<TabPanel value='log'> <TabPanel value='log'>
<Box sx={{ <Box sx={{
@@ -142,32 +144,32 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
color="error" color="error"
onClick={abortRunHandler} onClick={abortRunHandler}
> >
Stop {t('run_content.buttons.stop')}
</Button> : null} </Button> : null}
</TabPanel> </TabPanel>
<TabPanel value='output' sx={{ width: '700px' }}> <TabPanel value='output' sx={{ width: '700px' }}>
{!row || !row.serializableOutput || !row.binaryOutput {!row || !row.serializableOutput || !row.binaryOutput
|| (Object.keys(row.serializableOutput).length === 0 && Object.keys(row.binaryOutput).length === 0) || (Object.keys(row.serializableOutput).length === 0 && Object.keys(row.binaryOutput).length === 0)
? <Typography>The output is empty.</Typography> : null} ? <Typography>{t('run_content.empty_output')}</Typography> : null}
{row.serializableOutput && {row.serializableOutput &&
Object.keys(row.serializableOutput).length !== 0 && Object.keys(row.serializableOutput).length !== 0 &&
<div> <div>
<Typography variant='h6' sx={{ display: 'flex', alignItems: 'center' }}> <Typography variant='h6' sx={{ display: 'flex', alignItems: 'center' }}>
<ArticleIcon sx={{ marginRight: '15px' }} /> <ArticleIcon sx={{ marginRight: '15px' }} />
Captured Data {t('run_content.captured_data.title')}
</Typography> </Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mt: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mt: 2 }}>
<Typography> <Typography>
<a style={{ textDecoration: 'none' }} href={`data:application/json;utf8,${JSON.stringify(row.serializableOutput, null, 2)}`} <a style={{ textDecoration: 'none' }} href={`data:application/json;utf8,${JSON.stringify(row.serializableOutput, null, 2)}`}
download="data.json"> download="data.json">
Download as JSON {t('run_content.captured_data.download_json')}
</a> </a>
</Typography> </Typography>
<Typography <Typography
onClick={downloadCSV} onClick={downloadCSV}
> >
<a style={{ textDecoration: 'none', cursor: 'pointer' }}>Download as CSV</a> <a style={{ textDecoration: 'none', cursor: 'pointer' }}>{t('run_content.captured_data.download_csv')}</a>
</Typography> </Typography>
</Box> </Box>
{tableData.length > 0 ? ( {tableData.length > 0 ? (
@@ -212,7 +214,7 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
<div> <div>
<Typography variant='h6' sx={{ display: 'flex', alignItems: 'center' }}> <Typography variant='h6' sx={{ display: 'flex', alignItems: 'center' }}>
<ImageIcon sx={{ marginRight: '15px' }} /> <ImageIcon sx={{ marginRight: '15px' }} />
Captured Screenshot {t('run_content.captured_screenshot.title')}
</Typography> </Typography>
{Object.keys(row.binaryOutput).map((key) => { {Object.keys(row.binaryOutput).map((key) => {
try { try {
@@ -222,7 +224,7 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
width: 'max-content', width: 'max-content',
}}> }}>
<Typography sx={{ margin: '20px 0px' }}> <Typography sx={{ margin: '20px 0px' }}>
<a href={imageUrl} download={key} style={{ textDecoration: 'none' }}>Download Screenshot</a> <a href={imageUrl} download={key} style={{ textDecoration: 'none' }}>{t('run_content.captured_screenshot.download')}</a>
</Typography> </Typography>
<img src={imageUrl} alt={key} height='auto' width='700px' /> <img src={imageUrl} alt={key} height='auto' width='700px' />
</Box> </Box>
@@ -230,7 +232,7 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
} catch (e) { } catch (e) {
console.log(e) console.log(e)
return <Typography key={`number-of-binary-output-${key}`}> return <Typography key={`number-of-binary-output-${key}`}>
{key}: The image failed to render {key}: {t('run_content.captured_screenshot.render_failed')}
</Typography> </Typography>
} }
})} })}

View File

@@ -1,4 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { useEffect, useState } from "react";
import { useTranslation } from 'react-i18next';
import Paper from '@mui/material/Paper'; import Paper from '@mui/material/Paper';
import Table from '@mui/material/Table'; import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody'; import TableBody from '@mui/material/TableBody';
@@ -7,14 +9,24 @@ import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead'; import TableHead from '@mui/material/TableHead';
import TablePagination from '@mui/material/TablePagination'; import TablePagination from '@mui/material/TablePagination';
import TableRow from '@mui/material/TableRow'; import TableRow from '@mui/material/TableRow';
import { useEffect, useState } from "react"; import { Accordion, AccordionSummary, AccordionDetails, Typography, Box, TextField } from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import SearchIcon from '@mui/icons-material/Search';
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";
import { CollapsibleRow } from "./ColapsibleRow"; import { CollapsibleRow } from "./ColapsibleRow";
import { Accordion, AccordionSummary, AccordionDetails, Typography, Box, TextField } from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; // Export columns before the component
import SearchIcon from '@mui/icons-material/Search'; export const columns: readonly Column[] = [
{ id: 'runStatus', label: 'Status', minWidth: 80 },
{ id: 'name', label: 'Name', minWidth: 80 },
{ id: 'startedAt', label: 'Started At', minWidth: 80 },
{ id: 'finishedAt', label: 'Finished At', minWidth: 80 },
{ id: 'settings', label: 'Settings', minWidth: 80 },
{ id: 'delete', label: 'Delete', minWidth: 80 },
];
interface Column { interface Column {
id: 'runStatus' | 'name' | 'startedAt' | 'finishedAt' | 'delete' | 'settings'; id: 'runStatus' | 'name' | 'startedAt' | 'finishedAt' | 'delete' | 'settings';
@@ -24,15 +36,6 @@ interface Column {
format?: (value: string) => string; format?: (value: string) => string;
} }
export const columns: readonly Column[] = [
{ id: 'runStatus', label: 'Status', minWidth: 80 },
{ id: 'name', label: 'Robot Name', minWidth: 80 },
{ id: 'startedAt', label: 'Started at', minWidth: 80 },
{ id: 'finishedAt', label: 'Finished at', minWidth: 80 },
{ id: 'settings', label: 'Settings', minWidth: 80 },
{ id: 'delete', label: 'Delete', minWidth: 80 },
];
export interface Data { export interface Data {
id: number; id: number;
status: string; status: string;
@@ -58,15 +61,25 @@ interface RunsTableProps {
runningRecordingName: string; runningRecordingName: string;
} }
export const RunsTable = ( export const RunsTable: React.FC<RunsTableProps> = ({
{ currentInterpretationLog, abortRunHandler, runId, runningRecordingName }: RunsTableProps) => { currentInterpretationLog,
abortRunHandler,
runId,
runningRecordingName
}) => {
const { t } = useTranslation();
// Update column labels using translation if needed
const translatedColumns = columns.map(column => ({
...column,
label: t(`runstable.${column.id}`, column.label)
}));
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10); const [rowsPerPage, setRowsPerPage] = useState(10);
const [rows, setRows] = useState<Data[]>([]); const [rows, setRows] = useState<Data[]>([]);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const { notify, rerenderRuns, setRerenderRuns } = useGlobalInfoStore(); const { notify, rerenderRuns, setRerenderRuns } = useGlobalInfoStore();
const handleChangePage = (event: unknown, newPage: number) => { const handleChangePage = (event: unknown, newPage: number) => {
@@ -86,16 +99,13 @@ export const RunsTable = (
const fetchRuns = async () => { const fetchRuns = async () => {
const runs = await getStoredRuns(); const runs = await getStoredRuns();
if (runs) { if (runs) {
const parsedRows: Data[] = []; const parsedRows: Data[] = runs.map((run: any, index: number) => ({
runs.map((run: any, index) => { id: index,
parsedRows.push({ ...run,
id: index, }));
...run,
});
});
setRows(parsedRows); setRows(parsedRows);
} else { } else {
notify('error', 'No runs found. Please try again.') notify('error', t('runstable.notifications.no_runs'));
} }
}; };
@@ -104,15 +114,14 @@ export const RunsTable = (
fetchRuns(); fetchRuns();
setRerenderRuns(false); setRerenderRuns(false);
} }
}, [rerenderRuns]); }, [rerenderRuns, rows.length, setRerenderRuns]);
const handleDelete = () => { const handleDelete = () => {
setRows([]); setRows([]);
notify('success', 'Run deleted successfully'); notify('success', t('runstable.notifications.delete_success'));
fetchRuns(); fetchRuns();
}; };
// Filter rows based on search term // Filter rows based on search term
const filteredRows = rows.filter((row) => const filteredRows = rows.filter((row) =>
row.name.toLowerCase().includes(searchTerm.toLowerCase()) row.name.toLowerCase().includes(searchTerm.toLowerCase())
@@ -120,7 +129,6 @@ export const RunsTable = (
// Group filtered rows by robot meta id // Group filtered rows by robot meta id
const groupedRows = filteredRows.reduce((acc, row) => { const groupedRows = filteredRows.reduce((acc, row) => {
if (!acc[row.robotMetaId]) { if (!acc[row.robotMetaId]) {
acc[row.robotMetaId] = []; acc[row.robotMetaId] = [];
} }
@@ -132,11 +140,11 @@ export const RunsTable = (
<React.Fragment> <React.Fragment>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}> <Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
All Runs {t('runstable.runs', 'Runs')}
</Typography> </Typography>
<TextField <TextField
size="small" size="small"
placeholder="Search runs..." placeholder={t('runstable.search', 'Search runs...')}
value={searchTerm} value={searchTerm}
onChange={handleSearchChange} onChange={handleSearchChange}
InputProps={{ InputProps={{
@@ -149,16 +157,14 @@ export const RunsTable = (
{Object.entries(groupedRows).map(([id, data]) => ( {Object.entries(groupedRows).map(([id, data]) => (
<Accordion key={id}> <Accordion key={id}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}> <AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">{data[data.length - 1].name}</Typography> <Typography variant="h6">{data[data.length - 1].name}</Typography>
</AccordionSummary> </AccordionSummary>
<AccordionDetails> <AccordionDetails>
<Table stickyHeader aria-label="sticky table"> <Table stickyHeader aria-label="sticky table">
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell /> <TableCell />
{columns.map((column) => ( {translatedColumns.map((column) => (
<TableCell <TableCell
key={column.id} key={column.id}
align={column.align} align={column.align}
@@ -200,4 +206,4 @@ export const RunsTable = (
/> />
</React.Fragment> </React.Fragment>
); );
}; };

View File

@@ -9,13 +9,14 @@ import { TextField, Typography } from "@mui/material";
import { WarningText } from "../atoms/texts"; import { WarningText } from "../atoms/texts";
import NotificationImportantIcon from "@mui/icons-material/NotificationImportant"; import NotificationImportantIcon from "@mui/icons-material/NotificationImportant";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
interface SaveRecordingProps { interface SaveRecordingProps {
fileName: string; fileName: string;
} }
export const SaveRecording = ({ fileName }: SaveRecordingProps) => { export const SaveRecording = ({ fileName }: SaveRecordingProps) => {
const { t } = useTranslation();
const [openModal, setOpenModal] = useState<boolean>(false); const [openModal, setOpenModal] = useState<boolean>(false);
const [needConfirm, setNeedConfirm] = useState<boolean>(false); const [needConfirm, setNeedConfirm] = useState<boolean>(false);
const [recordingName, setRecordingName] = useState<string>(fileName); const [recordingName, setRecordingName] = useState<string>(fileName);
@@ -46,7 +47,7 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => {
}; };
const exitRecording = useCallback(async () => { const exitRecording = useCallback(async () => {
notify('success', 'Robot saved successfully'); notify('success', t('save_recording.notifications.save_success'));
if (browserId) { if (browserId) {
await stopRecording(browserId); await stopRecording(browserId);
} }
@@ -63,7 +64,7 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => {
setWaitingForSave(true); setWaitingForSave(true);
console.log(`Saving the recording as ${recordingName} for userId ${user.id}`); console.log(`Saving the recording as ${recordingName} for userId ${user.id}`);
} else { } else {
console.error('User not logged in. Cannot save recording.'); console.error(t('save_recording.notifications.user_not_logged'));
} }
}; };
@@ -76,35 +77,42 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => {
return ( return (
<div> <div>
<Button onClick={() => setOpenModal(true)} variant='contained' sx={{ marginRight: '20px',backgroundColor: '#ff00c3',color: 'white' }} size="small" color="success"> <!-- <Button onClick={() => setOpenModal(true)} variant='contained' sx={{ marginRight: '20px',backgroundColor: '#ff00c3',color: 'white' }} size="small" color="success">
Finish Finish -->
<Button onClick={() => setOpenModal(true)} variant="outlined" sx={{ marginRight: '20px' }} size="small" color="success">
{t('right_panel.buttons.finish')}
</Button> </Button>
<GenericModal isOpen={openModal} onClose={() => setOpenModal(false)} modalStyle={modalStyle}> <GenericModal isOpen={openModal} onClose={() => setOpenModal(false)} modalStyle={modalStyle}>
<form onSubmit={handleSaveRecording} style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}> <form onSubmit={handleSaveRecording} style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
<Typography variant="h6">Save Robot</Typography> <Typography variant="h6">{t('save_recording.title')}</Typography>
<TextField <TextField
required required
sx={{ width: '300px', margin: '15px 0px' }} sx={{ width: '300px', margin: '15px 0px' }}
onChange={handleChangeOfTitle} onChange={handleChangeOfTitle}
id="title" id="title"
label="Robot Name" label={t('save_recording.robot_name')}
variant="outlined" variant="outlined"
defaultValue={recordingName ? recordingName : null} defaultValue={recordingName ? recordingName : null}
/> />
{needConfirm {needConfirm
? ?
(<React.Fragment> (<React.Fragment>
<Button color="error" variant="contained" onClick={saveRecording} sx={{ marginTop: '10px' }}>Confirm</Button> <Button color="error" variant="contained" onClick={saveRecording} sx={{ marginTop: '10px' }}>
{t('save_recording.buttons.confirm')}
</Button>
<WarningText> <WarningText>
<NotificationImportantIcon color="warning" /> <NotificationImportantIcon color="warning" />
Robot with this name already exists, please confirm the Robot's overwrite. {t('save_recording.errors.exists_warning')}
</WarningText> </WarningText>
</React.Fragment>) </React.Fragment>)
: <Button type="submit" variant="contained" sx={{ marginTop: '10px' }}>Save</Button> : <Button type="submit" variant="contained" sx={{ marginTop: '10px' }}>
{t('save_recording.buttons.save')}
</Button>
} }
{waitingForSave && {waitingForSave &&
<Tooltip title='Optimizing and saving the workflow' placement={"bottom"}> <Tooltip title={t('save_recording.tooltips.optimizing')} placement={"bottom"}>
<Box sx={{ width: '100%', marginTop: '10px' }}> <Box sx={{ width: '100%', marginTop: '10px' }}>
<LinearProgress /> <LinearProgress />
</Box> </Box>

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { GenericModal } from "../atoms/GenericModal"; import { GenericModal } from "../atoms/GenericModal";
import { MenuItem, TextField, Typography, Box } from "@mui/material"; import { MenuItem, TextField, Typography, Box } from "@mui/material";
import { Dropdown } from "../atoms/DropdownMui"; import { Dropdown } from "../atoms/DropdownMui";
@@ -25,6 +26,7 @@ export interface ScheduleSettings {
} }
export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initialSettings }: ScheduleSettingsProps) => { export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initialSettings }: ScheduleSettingsProps) => {
const { t } = useTranslation();
const [schedule, setSchedule] = useState<ScheduleSettings | null>(null); const [schedule, setSchedule] = useState<ScheduleSettings | null>(null);
const [settings, setSettings] = useState<ScheduleSettings>({ const [settings, setSettings] = useState<ScheduleSettings>({
runEvery: 1, runEvery: 1,
@@ -77,12 +79,13 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
'SUNDAY' 'SUNDAY'
]; ];
const { recordingId } = useGlobalInfoStore(); const { recordingId, notify } = useGlobalInfoStore();
const deleteRobotSchedule = () => { const deleteRobotSchedule = () => {
if (recordingId) { if (recordingId) {
deleteSchedule(recordingId); deleteSchedule(recordingId);
setSchedule(null); setSchedule(null);
notify('success', t('Schedule deleted successfully'));
} else { } else {
console.error('No recording id provided'); console.error('No recording id provided');
} }
@@ -116,6 +119,25 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
} }
}, [isOpen]); }, [isOpen]);
const getDayOrdinal = (day: string | undefined) => {
if (!day) return '';
const lastDigit = day.slice(-1);
const lastTwoDigits = day.slice(-2);
// Special cases for 11, 12, 13
if (['11', '12', '13'].includes(lastTwoDigits)) {
return t('schedule_settings.labels.on_day.th');
}
// Other cases
switch (lastDigit) {
case '1': return t('schedule_settings.labels.on_day.st');
case '2': return t('schedule_settings.labels.on_day.nd');
case '3': return t('schedule_settings.labels.on_day.rd');
default: return t('schedule_settings.labels.on_day.th');
}
};
return ( return (
<GenericModal <GenericModal
isOpen={isOpen} isOpen={isOpen}
@@ -129,30 +151,30 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
padding: '20px', padding: '20px',
'& > *': { marginBottom: '20px' }, '& > *': { marginBottom: '20px' },
}}> }}>
<Typography variant="h6" sx={{ marginBottom: '20px' }}>Schedule Settings</Typography> <Typography variant="h6" sx={{ marginBottom: '20px' }}>{t('schedule_settings.title')}</Typography>
<> <>
{schedule !== null ? ( {schedule !== null ? (
<> <>
<Typography>Run every: {schedule.runEvery} {schedule.runEveryUnit.toLowerCase()}</Typography> <Typography>{t('schedule_settings.run_every')}: {schedule.runEvery} {schedule.runEveryUnit.toLowerCase()}</Typography>
<Typography>{['MONTHS', 'WEEKS'].includes(settings.runEveryUnit) ? "Start From" : "On"} {schedule.startFrom.charAt(0).toUpperCase() + schedule.startFrom.slice(1).toLowerCase()}</Typography> <Typography>{['MONTHS', 'WEEKS'].includes(settings.runEveryUnit) ? t('schedule_settings.start_from') : t('schedule_settings.start_from')}: {schedule.startFrom.charAt(0).toUpperCase() + schedule.startFrom.slice(1).toLowerCase()}</Typography>
{schedule.runEveryUnit === 'MONTHS' && ( {schedule.runEveryUnit === 'MONTHS' && (
<Typography>On day: {schedule.dayOfMonth}{['1', '21', '31'].includes(schedule.dayOfMonth || '') ? 'st' : ['2', '22'].includes(schedule.dayOfMonth || '') ? 'nd' : ['3', '23'].includes(schedule.dayOfMonth || '') ? 'rd' : 'th'} of the month</Typography> <Typography>{t('schedule_settings.on_day')}: {schedule.dayOfMonth}{getDayOrdinal(schedule.dayOfMonth)} of the month</Typography>
)} )}
<Typography>At around: {schedule.atTimeStart}, {schedule.timezone} Timezone</Typography> <Typography>{t('schedule_settings.at_around')}: {schedule.atTimeStart}, {schedule.timezone} {t('schedule_settings.timezone')}</Typography>
<Box mt={2} display="flex" justifyContent="space-between"> <Box mt={2} display="flex" justifyContent="space-between">
<Button <Button
onClick={deleteRobotSchedule} onClick={deleteRobotSchedule}
variant="outlined" variant="outlined"
color="error" color="error"
> >
Delete Schedule {t('schedule_settings.buttons.delete_schedule')}
</Button> </Button>
</Box> </Box>
</> </>
) : ( ) : (
<> <>
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}> <Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
<Typography sx={{ marginRight: '10px' }}>Run once every</Typography> <Typography sx={{ marginRight: '10px' }}>{t('schedule_settings.labels.run_once_every')}</Typography>
<TextField <TextField
type="number" type="number"
value={settings.runEvery} value={settings.runEvery}
@@ -174,7 +196,9 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}> <Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
<Typography sx={{ marginBottom: '5px', marginRight: '25px' }}>{['MONTHS', 'WEEKS'].includes(settings.runEveryUnit) ? "Start From" : "On"}</Typography> <Typography sx={{ marginBottom: '5px', marginRight: '25px' }}>
{['MONTHS', 'WEEKS'].includes(settings.runEveryUnit) ? t('schedule_settings.labels.start_from_label') : t('schedule_settings.labels.start_from_label')}
</Typography>
<Dropdown <Dropdown
label="" label=""
id="startFrom" id="startFrom"
@@ -190,7 +214,7 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
{settings.runEveryUnit === 'MONTHS' && ( {settings.runEveryUnit === 'MONTHS' && (
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}> <Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
<Typography sx={{ marginBottom: '5px', marginRight: '25px' }}>On Day of the Month</Typography> <Typography sx={{ marginBottom: '5px', marginRight: '25px' }}>{t('schedule_settings.labels.on_day_of_month')}</Typography>
<TextField <TextField
type="number" type="number"
value={settings.dayOfMonth} value={settings.dayOfMonth}
@@ -204,7 +228,7 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
{['MINUTES', 'HOURS'].includes(settings.runEveryUnit) ? ( {['MINUTES', 'HOURS'].includes(settings.runEveryUnit) ? (
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}> <Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
<Box sx={{ marginRight: '20px' }}> <Box sx={{ marginRight: '20px' }}>
<Typography sx={{ marginBottom: '5px' }}>In Between</Typography> <Typography sx={{ marginBottom: '5px' }}>{t('schedule_settings.labels.in_between')}</Typography>
<TextField <TextField
type="time" type="time"
value={settings.atTimeStart} value={settings.atTimeStart}
@@ -221,7 +245,7 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
</Box> </Box>
) : ( ) : (
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}> <Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
<Typography sx={{ marginBottom: '5px', marginRight: '10px' }}>At Around</Typography> <Typography sx={{ marginBottom: '5px', marginRight: '10px' }}>{t('schedule_settings.at_around')}</Typography>
<TextField <TextField
type="time" type="time"
value={settings.atTimeStart} value={settings.atTimeStart}
@@ -232,7 +256,7 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
)} )}
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}> <Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
<Typography sx={{ marginRight: '10px' }}>Timezone</Typography> <Typography sx={{ marginRight: '10px' }}>{t('schedule_settings.timezone')}</Typography>
<Dropdown <Dropdown
label="" label=""
id="timezone" id="timezone"
@@ -247,10 +271,10 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
</Box> </Box>
<Box mt={2} display="flex" justifyContent="flex-end"> <Box mt={2} display="flex" justifyContent="flex-end">
<Button onClick={() => handleStart(settings)} variant="contained" color="primary"> <Button onClick={() => handleStart(settings)} variant="contained" color="primary">
Save Schedule {t('schedule_settings.buttons.save_schedule')}
</Button> </Button>
<Button onClick={handleClose} color="primary" variant="outlined" style={{ marginLeft: '10px' }}> <Button onClick={handleClose} color="primary" variant="outlined" style={{ marginLeft: '10px' }}>
Cancel {t('schedule_settings.buttons.cancel')}
</Button> </Button>
</Box> </Box>
</> </>
@@ -271,4 +295,4 @@ const modalStyle = {
height: 'fit-content', height: 'fit-content',
display: 'block', display: 'block',
padding: '20px', padding: '20px',
}; };

View File

@@ -19,6 +19,7 @@ import styled from 'styled-components';
import axios from 'axios'; import axios from 'axios';
import { useGlobalInfoStore } from '../../context/globalInfo'; import { useGlobalInfoStore } from '../../context/globalInfo';
import { apiUrl } from '../../apiConfig'; import { apiUrl } from '../../apiConfig';
import { useTranslation } from 'react-i18next';
const Container = styled(Box)` const Container = styled(Box)`
display: flex; display: flex;
@@ -29,24 +30,21 @@ const Container = styled(Box)`
`; `;
const ApiKeyManager = () => { const ApiKeyManager = () => {
const { t } = useTranslation();
const [apiKey, setApiKey] = useState<string | null>(null); const [apiKey, setApiKey] = useState<string | null>(null);
const [apiKeyName, setApiKeyName] = useState<string>('Maxun API Key'); const [apiKeyName, setApiKeyName] = useState<string>(t('apikey.default_name'));
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [showKey, setShowKey] = useState<boolean>(false); const [showKey, setShowKey] = useState<boolean>(false);
const [copySuccess, setCopySuccess] = useState<boolean>(false); const [copySuccess, setCopySuccess] = useState<boolean>(false);
const { notify } = useGlobalInfoStore(); const { notify } = useGlobalInfoStore();
useEffect(() => { useEffect(() => {
const fetchApiKey = async () => { const fetchApiKey = async () => {
try { try {
const { data } = await axios.get(`${apiUrl}/auth/api-key`); const { data } = await axios.get(`${apiUrl}/auth/api-key`);
setApiKey(data.api_key); setApiKey(data.api_key);
} catch (error: any) { } catch (error: any) {
notify('error', `Failed to fetch API Key - ${error.message}`); notify('error', t('apikey.notifications.fetch_error', { error: error.message }));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -62,9 +60,9 @@ const ApiKeyManager = () => {
const { data } = await axios.post(`${apiUrl}/auth/generate-api-key`); const { data } = await axios.post(`${apiUrl}/auth/generate-api-key`);
setApiKey(data.api_key); setApiKey(data.api_key);
notify('success', `Generated API Key successfully`); notify('success', t('apikey.notifications.generate_success'));
} catch (error: any) { } catch (error: any) {
notify('error', `Failed to generate API Key - ${error.message}`); notify('error', t('apikey.notifications.generate_error', { error: error.message }));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -75,9 +73,9 @@ const ApiKeyManager = () => {
try { try {
await axios.delete(`${apiUrl}/auth/delete-api-key`); await axios.delete(`${apiUrl}/auth/delete-api-key`);
setApiKey(null); setApiKey(null);
notify('success', 'API Key deleted successfully'); notify('success', t('apikey.notifications.delete_success'));
} catch (error: any) { } catch (error: any) {
notify('error', `Failed to delete API Key - ${error.message}`); notify('error', t('apikey.notifications.delete_error', { error: error.message }));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -88,7 +86,7 @@ const ApiKeyManager = () => {
navigator.clipboard.writeText(apiKey); navigator.clipboard.writeText(apiKey);
setCopySuccess(true); setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000); setTimeout(() => setCopySuccess(false), 2000);
notify('info', 'Copied API Key successfully'); notify('info', t('apikey.notifications.copy_success'));
} }
}; };
@@ -111,34 +109,38 @@ const ApiKeyManager = () => {
return ( return (
<Container sx={{ alignSelf: 'flex-start' }}> <Container sx={{ alignSelf: 'flex-start' }}>
<Typography variant="h6" gutterBottom component="div" style={{ marginBottom: '20px' }}> <Typography variant="h6" gutterBottom component="div" style={{ marginBottom: '20px' }}>
Manage Your API Key {t('apikey.title')}
</Typography> </Typography>
{apiKey ? ( {apiKey ? (
<TableContainer component={Paper} sx={{ width: '100%', overflow: 'hidden' }}> <TableContainer component={Paper} sx={{ width: '100%', overflow: 'hidden' }}>
<Table> <Table>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell>API Key Name</TableCell> <TableCell>{t('apikey.table.name')}</TableCell>
<TableCell>API Key</TableCell> <TableCell>{t('apikey.table.key')}</TableCell>
<TableCell>Actions</TableCell> <TableCell>{t('apikey.table.actions')}</TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
<TableRow> <TableRow>
<TableCell>{apiKeyName}</TableCell> <TableCell>{apiKeyName}</TableCell>
<TableCell>{showKey ? `${apiKey?.substring(0, 10)}...` : '***************'}</TableCell>
<TableCell> <TableCell>
<Tooltip title="Copy"> <Box sx={{ fontFamily: 'monospace', width: '10ch' }}>
{showKey ? `${apiKey?.substring(0, 10)}...` : '**********'}
</Box>
</TableCell>
<TableCell>
<Tooltip title={t('apikey.actions.copy')}>
<IconButton onClick={copyToClipboard}> <IconButton onClick={copyToClipboard}>
<ContentCopy /> <ContentCopy />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title={showKey ? 'Hide' : 'Show'}> <Tooltip title={showKey ? t('apikey.actions.hide') : t('apikey.actions.show')}>
<IconButton onClick={() => setShowKey(!showKey)}> <IconButton onClick={() => setShowKey(!showKey)}>
<Visibility /> <Visibility />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Delete"> <Tooltip title={t('apikey.actions.delete')}>
<IconButton onClick={deleteApiKey} color="error"> <IconButton onClick={deleteApiKey} color="error">
<Delete /> <Delete />
</IconButton> </IconButton>
@@ -150,9 +152,9 @@ const ApiKeyManager = () => {
</TableContainer> </TableContainer>
) : ( ) : (
<> <>
<Typography>You haven't generated an API key yet.</Typography> <Typography>{t('apikey.no_key_message')}</Typography>
<Button onClick={generateApiKey} variant="contained" color="primary" sx={{ marginTop: '15px' }}> <Button onClick={generateApiKey} variant="contained" color="primary" sx={{ marginTop: '15px' }}>
Generate API Key {t('apikey.generate_button')}
</Button> </Button>
</> </>
)} )}

View File

@@ -7,11 +7,13 @@ import { GenericModal } from '../atoms/GenericModal';
import { useActionContext } from '../../context/browserActions'; import { useActionContext } from '../../context/browserActions';
import { useBrowserSteps, TextStep } from '../../context/browserSteps'; import { useBrowserSteps, TextStep } from '../../context/browserSteps';
import { useGlobalInfoStore } from '../../context/globalInfo'; import { useGlobalInfoStore } from '../../context/globalInfo';
import { useTranslation } from 'react-i18next';
interface ElementInfo { interface ElementInfo {
tagName: string; tagName: string;
hasOnlyText?: boolean; hasOnlyText?: boolean;
isIframeContent?: boolean;
isShadowRoot?: boolean;
innerText?: string; innerText?: string;
url?: string; url?: string;
imageUrl?: string; imageUrl?: string;
@@ -52,6 +54,7 @@ const getAttributeOptions = (tagName: string, elementInfo: ElementInfo | null):
}; };
export const BrowserWindow = () => { export const BrowserWindow = () => {
const { t } = useTranslation();
const [canvasRef, setCanvasReference] = useState<React.RefObject<HTMLCanvasElement> | undefined>(undefined); const [canvasRef, setCanvasReference] = useState<React.RefObject<HTMLCanvasElement> | undefined>(undefined);
const [screenShot, setScreenShot] = useState<string>(""); const [screenShot, setScreenShot] = useState<string>("");
const [highlighterData, setHighlighterData] = useState<{ rect: DOMRect, selector: string, elementInfo: ElementInfo | null, childSelectors?: string[] } | null>(null); const [highlighterData, setHighlighterData] = useState<{ rect: DOMRect, selector: string, elementInfo: ElementInfo | null, childSelectors?: string[] } | null>(null);
@@ -66,7 +69,7 @@ export const BrowserWindow = () => {
const { socket } = useSocketStore(); const { socket } = useSocketStore();
const { notify } = useGlobalInfoStore(); const { notify } = useGlobalInfoStore();
const { getText, getList, paginationMode, paginationType, limitMode } = useActionContext(); const { getText, getList, paginationMode, paginationType, limitMode, captureStage } = useActionContext();
const { addTextStep, addListStep } = useBrowserSteps(); const { addTextStep, addListStep } = useBrowserSteps();
const onMouseMove = (e: MouseEvent) => { const onMouseMove = (e: MouseEvent) => {
@@ -115,34 +118,81 @@ export const BrowserWindow = () => {
}, [screenShot, canvasRef, socket, screencastHandler]); }, [screenShot, canvasRef, socket, screencastHandler]);
const highlighterHandler = useCallback((data: { rect: DOMRect, selector: string, elementInfo: ElementInfo | null, childSelectors?: string[] }) => { const highlighterHandler = useCallback((data: { rect: DOMRect, selector: string, elementInfo: ElementInfo | null, childSelectors?: string[] }) => {
console.log("LIST SELECTOR", listSelector);
console.log("DATA SELECTOR", data.selector);
console.log("CHILD SELECTORS", data.childSelectors);
if (getList === true) { if (getList === true) {
if (listSelector) { if (listSelector) {
socket?.emit('listSelector', { selector: listSelector }); socket?.emit('listSelector', { selector: listSelector });
const hasValidChildSelectors = Array.isArray(data.childSelectors) && data.childSelectors.length > 0;
if (limitMode) { if (limitMode) {
setHighlighterData(null); setHighlighterData(null);
} else if (paginationMode) { } else if (paginationMode) {
// only set highlighterData if type is not empty, 'none', 'scrollDown', or 'scrollUp' // Only set highlighterData if type is not empty, 'none', 'scrollDown', or 'scrollUp'
if (paginationType !== '' && !['none', 'scrollDown', 'scrollUp'].includes(paginationType)) { if (paginationType !== '' && !['none', 'scrollDown', 'scrollUp'].includes(paginationType)) {
setHighlighterData(data); setHighlighterData(data);
} else { } else {
setHighlighterData(null); setHighlighterData(null);
} }
} else if (data.childSelectors && data.childSelectors.includes(data.selector)) { } else if (data.childSelectors && data.childSelectors.includes(data.selector)) {
// highlight only valid child elements within the listSelector // Highlight only valid child elements within the listSelector
setHighlighterData(data); setHighlighterData(data);
} else { } else if (data.elementInfo?.isIframeContent && data.childSelectors) {
// Handle pure iframe elements - similar to previous shadow DOM logic but using iframe syntax
// Check if the selector matches any iframe child selectors
const isIframeChild = data.childSelectors.some(childSelector =>
data.selector.includes(':>>') && // Iframe uses :>> for traversal
childSelector.split(':>>').some(part =>
data.selector.includes(part.trim())
)
);
setHighlighterData(isIframeChild ? data : null);
} else if (data.selector.includes(':>>') && hasValidChildSelectors) {
// Handle mixed DOM cases with iframes
// Split the selector into parts and check each against child selectors
const selectorParts = data.selector.split(':>>').map(part => part.trim());
const isValidMixedSelector = selectorParts.some(part =>
// We know data.childSelectors is defined due to hasValidChildSelectors check
data.childSelectors!.some(childSelector =>
childSelector.includes(part)
)
);
setHighlighterData(isValidMixedSelector ? data : null);
} else if (data.elementInfo?.isShadowRoot && data.childSelectors) {
// New case: Handle pure Shadow DOM elements
// Check if the selector matches any shadow root child selectors
const isShadowChild = data.childSelectors.some(childSelector =>
data.selector.includes('>>') && // Shadow DOM uses >> for piercing
childSelector.split('>>').some(part =>
data.selector.includes(part.trim())
)
);
setHighlighterData(isShadowChild ? data : null);
} else if (data.selector.includes('>>') && hasValidChildSelectors) {
// New case: Handle mixed DOM cases
// Split the selector into parts and check each against child selectors
const selectorParts = data.selector.split('>>').map(part => part.trim());
const isValidMixedSelector = selectorParts.some(part =>
// Now we know data.childSelectors is defined
data.childSelectors!.some(childSelector =>
childSelector.includes(part)
)
);
setHighlighterData(isValidMixedSelector ? data : null);
} else {
// if !valid child in normal mode, clear the highlighter // if !valid child in normal mode, clear the highlighter
setHighlighterData(null); setHighlighterData(null);
} }
} else { } else {
// set highlighterData for the initial listSelector selection // Set highlighterData for the initial listSelector selection
setHighlighterData(data); setHighlighterData(data);
} }
} else { } else {
// for non-list steps // For non-list steps
setHighlighterData(data); setHighlighterData(data);
} }
}, [highlighterData, getList, socket, listSelector, paginationMode, paginationType]); }, [highlighterData, getList, socket, listSelector, paginationMode, paginationType, captureStage]);
useEffect(() => { useEffect(() => {
@@ -156,6 +206,13 @@ export const BrowserWindow = () => {
}; };
}, [socket, onMouseMove]); }, [socket, onMouseMove]);
useEffect(() => {
if (captureStage === 'initial' && listSelector) {
socket?.emit('setGetList', { getList: true });
socket?.emit('listSelector', { selector: listSelector });
}
}, [captureStage, listSelector, socket]);
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => { const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (highlighterData && canvasRef?.current) { if (highlighterData && canvasRef?.current) {
const canvasRect = canvasRef.current.getBoundingClientRect(); const canvasRect = canvasRef.current.getBoundingClientRect();
@@ -183,6 +240,7 @@ export const BrowserWindow = () => {
addTextStep('', data, { addTextStep('', data, {
selector: highlighterData.selector, selector: highlighterData.selector,
tag: highlighterData.elementInfo?.tagName, tag: highlighterData.elementInfo?.tagName,
shadow: highlighterData.elementInfo?.isShadowRoot,
attribute attribute
}); });
} else { } else {
@@ -190,7 +248,7 @@ export const BrowserWindow = () => {
setAttributeOptions(options); setAttributeOptions(options);
setSelectedElement({ setSelectedElement({
selector: highlighterData.selector, selector: highlighterData.selector,
info: highlighterData.elementInfo info: highlighterData.elementInfo,
}); });
setShowAttributeModal(true); setShowAttributeModal(true);
} }
@@ -200,7 +258,7 @@ export const BrowserWindow = () => {
// Only allow selection in pagination mode if type is not empty, 'scrollDown', or 'scrollUp' // Only allow selection in pagination mode if type is not empty, 'scrollDown', or 'scrollUp'
if (paginationType !== '' && paginationType !== 'scrollDown' && paginationType !== 'scrollUp' && paginationType !== 'none') { if (paginationType !== '' && paginationType !== 'scrollDown' && paginationType !== 'scrollUp' && paginationType !== 'none') {
setPaginationSelector(highlighterData.selector); setPaginationSelector(highlighterData.selector);
notify(`info`, `Pagination element selected successfully.`); notify(`info`, t('browser_window.attribute_modal.notifications.pagination_select_success'));
addListStep(listSelector!, fields, currentListId || 0, { type: paginationType, selector: highlighterData.selector }); addListStep(listSelector!, fields, currentListId || 0, { type: paginationType, selector: highlighterData.selector });
} }
return; return;
@@ -208,7 +266,7 @@ export const BrowserWindow = () => {
if (getList === true && !listSelector) { if (getList === true && !listSelector) {
setListSelector(highlighterData.selector); setListSelector(highlighterData.selector);
notify(`info`, `List selected succesfully. Select the text data for extraction.`) notify(`info`, t('browser_window.attribute_modal.notifications.list_select_success'));
setCurrentListId(Date.now()); setCurrentListId(Date.now());
setFields({}); setFields({});
} else if (getList === true && listSelector && currentListId) { } else if (getList === true && listSelector && currentListId) {
@@ -227,6 +285,7 @@ export const BrowserWindow = () => {
selectorObj: { selectorObj: {
selector: highlighterData.selector, selector: highlighterData.selector,
tag: highlighterData.elementInfo?.tagName, tag: highlighterData.elementInfo?.tagName,
shadow: highlighterData.elementInfo?.isShadowRoot,
attribute attribute
} }
}; };
@@ -274,6 +333,7 @@ export const BrowserWindow = () => {
addTextStep('', data, { addTextStep('', data, {
selector: selectedElement.selector, selector: selectedElement.selector,
tag: selectedElement.info?.tagName, tag: selectedElement.info?.tagName,
shadow: selectedElement.info?.isShadowRoot,
attribute: attribute attribute: attribute
}); });
} }
@@ -286,6 +346,7 @@ export const BrowserWindow = () => {
selectorObj: { selectorObj: {
selector: selectedElement.selector, selector: selectedElement.selector,
tag: selectedElement.info?.tagName, tag: selectedElement.info?.tagName,
shadow: selectedElement.info?.isShadowRoot,
attribute: attribute attribute: attribute
} }
}; };
@@ -317,7 +378,6 @@ export const BrowserWindow = () => {
} }
}, [paginationMode, resetPaginationSelector]); }, [paginationMode, resetPaginationSelector]);
return ( return (
<div onClick={handleClick} style={{ width: '900px', height: "400px" , borderRadius: '8px 8px 0px 0px '}} id="browser-window"> <div onClick={handleClick} style={{ width: '900px', height: "400px" , borderRadius: '8px 8px 0px 0px '}} id="browser-window">
{ {

View File

@@ -8,6 +8,9 @@ import { AutoAwesome, FormatListBulleted, VpnKey, Usb, Article, CloudQueue,Code,
import { apiUrl } from "../../apiConfig"; import { apiUrl } from "../../apiConfig";
import { useTranslation } from 'react-i18next';
import i18n from '../../i18n';
interface MainMenuProps { interface MainMenuProps {
@@ -17,6 +20,7 @@ interface MainMenuProps {
export const MainMenu = ({ value = 'recordings', handleChangeContent }: MainMenuProps) => { export const MainMenu = ({ value = 'recordings', handleChangeContent }: MainMenuProps) => {
const theme = useTheme(); const theme = useTheme();
const {t} = useTranslation();
const handleChange = (event: React.SyntheticEvent, newValue: string) => { const handleChange = (event: React.SyntheticEvent, newValue: string) => {
handleChangeContent(newValue); handleChangeContent(newValue);
@@ -70,28 +74,28 @@ export const MainMenu = ({ value = 'recordings', handleChangeContent }: MainMenu
> >
<Tab <Tab
value="recordings" value="recordings"
label="Robots" label={t('mainmenu.recordings')}
icon={<AutoAwesome />} icon={<AutoAwesome />}
iconPosition="start" iconPosition="start"
/> />
<Tab <Tab
value="runs" value="runs"
label="Runs" label={t('mainmenu.runs')}
icon={<FormatListBulleted />} icon={<FormatListBulleted />}
iconPosition="start" iconPosition="start"
/> />
<Tab <Tab
value="proxy" value="proxy"
label="Proxy" label={t('mainmenu.proxy')}
icon={<Usb />} icon={<Usb />}
iconPosition="start" iconPosition="start"
/> />
<Tab <Tab
value="apikey" value="apikey"
label="API Key" label={t('mainmenu.apikey')}
icon={<VpnKey />} icon={<VpnKey />}
iconPosition="start" iconPosition="start"
@@ -99,13 +103,11 @@ export const MainMenu = ({ value = 'recordings', handleChangeContent }: MainMenu
</Tabs> </Tabs>
<hr /> <hr />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1rem', textAlign: 'left' }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: '1rem', textAlign: 'left' }}>
<Button href={`${apiUrl}/api-docs/`} target="_blank" rel="noopener noreferrer" sx={buttonStyles} startIcon={<Code />}>
<Button href="/api-docs" target="_blank" rel="noopener noreferrer" sx={buttonStyles} startIcon={<Article />}> {t('mainmenu.apidocs')}
API Docs
</Button> </Button>
<Button href="https://forms.gle/hXjgqDvkEhPcaBW76" target="_blank" rel="noopener noreferrer" sx={buttonStyles} startIcon={<CloudQueue />}> <Button href="https://forms.gle/hXjgqDvkEhPcaBW76" target="_blank" rel="noopener noreferrer" sx={buttonStyles} startIcon={<CloudQueue />}>
Join Maxun Cloud {t('mainmenu.feedback')}
</Button> </Button>
</Box> </Box>
</Box> </Box>

View File

@@ -22,6 +22,7 @@ import {
import { sendProxyConfig, getProxyConfig, testProxyConfig, deleteProxyConfig } from '../../api/proxy'; import { sendProxyConfig, getProxyConfig, testProxyConfig, deleteProxyConfig } from '../../api/proxy';
import { useGlobalInfoStore } from '../../context/globalInfo'; import { useGlobalInfoStore } from '../../context/globalInfo';
import { useThemeMode } from '../../context/theme-provider'; import { useThemeMode } from '../../context/theme-provider';
import { useTranslation } from 'react-i18next';
// Custom styled Tabs component // Custom styled Tabs component
const CustomTabs = styled(Tabs)(({ theme }) => ({ const CustomTabs = styled(Tabs)(({ theme }) => ({
@@ -44,6 +45,7 @@ const CustomTab = styled(Tab)(({ theme }) => ({
}, },
})); }));
const FormContainer = styled(Box)({ const FormContainer = styled(Box)({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@@ -56,6 +58,7 @@ const FormControl = styled(Box)({
}); });
const ProxyForm: React.FC = () => { const ProxyForm: React.FC = () => {
const { t } = useTranslation();
const [proxyConfigForm, setProxyConfigForm] = useState({ const [proxyConfigForm, setProxyConfigForm] = useState({
server_url: '', server_url: '',
username: '', username: '',
@@ -119,13 +122,13 @@ const ProxyForm: React.FC = () => {
try { try {
const response = await sendProxyConfig(proxyConfigForm); const response = await sendProxyConfig(proxyConfigForm);
if (response) { if (response) {
notify('success', 'Proxy configuration submitted successfully'); notify('success', t('proxy.notifications.config_success'));
} else { } else {
notify('error', `Failed to submit proxy configuration. Try again. ${response}`); notify('error', t('proxy.notifications.config_error'));
console.log(`Failed to submit proxy configuration. Try again. ${response}`) console.log(`${t('proxy.notifications.config_error')} ${response}`)
} }
} catch (error: any) { } catch (error: any) {
notify('error', `${error} : Failed to submit proxy configuration`); notify('error', `${error} : ${t('proxy.notifications.config_error')}`);
} }
}; };
@@ -136,9 +139,9 @@ const ProxyForm: React.FC = () => {
const testProxy = async () => { const testProxy = async () => {
await testProxyConfig().then((response) => { await testProxyConfig().then((response) => {
if (response.success) { if (response.success) {
notify('success', 'Proxy configuration is working'); notify('success', t('proxy.notifications.test_success'));
} else { } else {
notify('error', 'Failed to test proxy configuration. Try again.'); notify('error', t('proxy.notifications.test_error'));
} }
}); });
}; };
@@ -149,7 +152,7 @@ const ProxyForm: React.FC = () => {
if (response.proxy_url) { if (response.proxy_url) {
setIsProxyConfigured(true); setIsProxyConfigured(true);
setProxy(response); setProxy(response);
notify('success', 'Proxy configuration fetched successfully'); notify('success', t('proxy.notifications.fetch_success'));
} }
} catch (error: any) { } catch (error: any) {
notify('error', error); notify('error', error);
@@ -159,11 +162,11 @@ const ProxyForm: React.FC = () => {
const removeProxy = async () => { const removeProxy = async () => {
await deleteProxyConfig().then((response) => { await deleteProxyConfig().then((response) => {
if (response) { if (response) {
notify('success', 'Proxy configuration removed successfully'); notify('success', t('proxy.notifications.remove_success'));
setIsProxyConfigured(false); setIsProxyConfigured(false);
setProxy({ proxy_url: '', auth: false }); setProxy({ proxy_url: '', auth: false });
} else { } else {
notify('error', 'Failed to remove proxy configuration. Try again.'); notify('error', t('proxy.notifications.remove_error'));
} }
}); });
} }
@@ -179,7 +182,7 @@ const ProxyForm: React.FC = () => {
<> <>
<FormContainer> <FormContainer>
<Typography variant="h6" gutterBottom component="div" style={{ marginTop: '20px' }}> <Typography variant="h6" gutterBottom component="div" style={{ marginTop: '20px' }}>
Proxy Configuration {t('proxy.title')}
</Typography> </Typography>
<CustomTabs <CustomTabs
value={tabIndex} value={tabIndex}
@@ -191,18 +194,24 @@ const ProxyForm: React.FC = () => {
}} }}
> >
<CustomTab <CustomTab
label="Standard Proxy" label={t('proxy.tab_standard')}
style={{ style={{
color: tabIndex === 0 ? '#FF69B4' : (isDarkMode ? 'white' : 'black') color: tabIndex === 0 ? '#FF69B4' : (isDarkMode ? 'white' : 'black')
}} }}
/> />
<CustomTab <CustomTab
label="Automatic Proxy Rotation" label={t('proxy.tab_rotation')}
style={{ style={{
color: tabIndex === 1 ? '#FF69B4' : (isDarkMode ? 'white' : 'black') color: tabIndex === 1 ? '#FF69B4' : (isDarkMode ? 'white' : 'black')
}} }}
/> />
</CustomTabs> </CustomTabs>
<!-- <Tabs value={tabIndex} onChange={handleTabChange}>
<Tab label={t('proxy.tab_standard')} />
<Tab label={t('proxy.tab_rotation')} />
</Tabs> -->
{tabIndex === 0 && ( {tabIndex === 0 && (
isProxyConfigured ? ( isProxyConfigured ? (
<Box sx={{ maxWidth: 600, width: '100%', marginTop: '5px' }}> <Box sx={{ maxWidth: 600, width: '100%', marginTop: '5px' }}>
@@ -210,8 +219,8 @@ const ProxyForm: React.FC = () => {
<Table> <Table>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell><strong>Proxy URL</strong></TableCell> <TableCell><strong>{t('proxy.table.proxy_url')}</strong></TableCell>
<TableCell><strong>Requires Authentication</strong></TableCell> <TableCell><strong>{t('proxy.table.requires_auth')}</strong></TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
@@ -223,39 +232,37 @@ const ProxyForm: React.FC = () => {
</Table> </Table>
</TableContainer> </TableContainer>
<Button variant="outlined" color="primary" onClick={testProxy}> <Button variant="outlined" color="primary" onClick={testProxy}>
Test Proxy {t('proxy.test_proxy')}
</Button> </Button>
<Button variant="outlined" color="error" onClick={removeProxy} sx={{ marginLeft: '10px' }}> <Button variant="outlined" color="error" onClick={removeProxy} sx={{ marginLeft: '10px' }}>
Remove Proxy {t('proxy.remove_proxy')}
</Button> </Button>
</Box> </Box>
) : ( ) : (
<Box component="form" onSubmit={handleSubmit} sx={{ maxWidth: 400, width: '100%' }}> <Box component="form" onSubmit={handleSubmit} sx={{ maxWidth: 400, width: '100%' }}>
<FormControl> <FormControl>
<TextField <TextField
label="Proxy Server URL" label={t('proxy.server_url')}
name="server_url" name="server_url"
value={proxyConfigForm.server_url} value={proxyConfigForm.server_url}
onChange={handleChange} onChange={handleChange}
fullWidth fullWidth
required required
error={!!errors.server_url} error={!!errors.server_url}
helperText={errors.server_url || `Proxy to be used for all robots. HTTP and SOCKS proxies are supported. helperText={errors.server_url || t('proxy.server_url_helper')}
Example http://myproxy.com:3128 or socks5://myproxy.com:3128.
Short form myproxy.com:3128 is considered an HTTP proxy.`}
/> />
</FormControl> </FormControl>
<FormControl> <FormControl>
<FormControlLabel <FormControlLabel
control={<Switch checked={requiresAuth} onChange={handleAuthToggle} />} control={<Switch checked={requiresAuth} onChange={handleAuthToggle} />}
label="Requires Authentication?" label={t('proxy.requires_auth')}
/> />
</FormControl> </FormControl>
{requiresAuth && ( {requiresAuth && (
<> <>
<FormControl> <FormControl>
<TextField <TextField
label="Username" label={t('proxy.username')}
name="username" name="username"
value={proxyConfigForm.username} value={proxyConfigForm.username}
onChange={handleChange} onChange={handleChange}
@@ -267,7 +274,7 @@ const ProxyForm: React.FC = () => {
</FormControl> </FormControl>
<FormControl> <FormControl>
<TextField <TextField
label="Password" label={t('proxy.password')}
name="password" name="password"
value={proxyConfigForm.password} value={proxyConfigForm.password}
onChange={handleChange} onChange={handleChange}
@@ -287,7 +294,7 @@ const ProxyForm: React.FC = () => {
fullWidth fullWidth
disabled={!proxyConfigForm.server_url || (requiresAuth && (!proxyConfigForm.username || !proxyConfigForm.password))} disabled={!proxyConfigForm.server_url || (requiresAuth && (!proxyConfigForm.username || !proxyConfigForm.password))}
> >
Add Proxy {t('proxy.add_proxy')}
</Button> </Button>
</Box> </Box>
))} ))}
@@ -295,31 +302,37 @@ const ProxyForm: React.FC = () => {
<Box sx={{ maxWidth: 400, width: '100%', textAlign: 'center', marginTop: '20px' }}> <Box sx={{ maxWidth: 400, width: '100%', textAlign: 'center', marginTop: '20px' }}>
<> <>
<Typography variant="body1" gutterBottom component="div"> <Typography variant="body1" gutterBottom component="div">
Coming Soon - In Open Source (Basic Rotation) & Cloud (Advanced Rotation). If you don't want to manage the infrastructure, join our cloud waitlist to get early access. {t('proxy.coming_soon')}
</Typography> </Typography>
<Button variant="contained" color="primary" sx={{ marginTop: '20px',backgroundColor: '#ff00c3' }}>
<a style={{ color: 'white', textDecoration: 'none' }} href="https://forms.gle/hXjgqDvkEhPcaBW76">Join Maxun Cloud Waitlist</a> <!-- <Button variant="contained" color="primary" sx={{ marginTop: '20px',backgroundColor: '#ff00c3' }}>
<a style={{ color: 'white', textDecoration: 'none' }} href="https://forms.gle/hXjgqDvkEhPcaBW76">Join Maxun Cloud Waitlist</a> -->
<Button variant="contained" color="primary" sx={{ marginTop: '20px' }}>
<a style={{ color: 'white', textDecoration: 'none' }} href="https://forms.gle/hXjgqDvkEhPcaBW76">{t('proxy.join_waitlist')}</a>
</Button> </Button>
</> </>
</Box> </Box>
)} )}
</FormContainer> </FormContainer>
<Alert severity="info" sx={{ marginTop: '80px', marginLeft: '50px', height: '230px', width: '450px', border: '1px solid #ff00c3', bgcolor: isDarkMode ? '#3b002d' : '#ffc4f1', color: isDarkMode ? 'white' : 'black' }}>
<AlertTitle>If your proxy requires a username and password, always provide them separately from the proxy URL. </AlertTitle> <Alert severity="info" sx={{ marginTop: '80px', marginLeft: '50px', height: '230px', width: '450px', border: '1px solid #ff00c3' }}>
<AlertTitle>{t('proxy.alert.title')}</AlertTitle>
<br /> <br />
<b>The right way</b> <b>{t('proxy.alert.right_way')}</b>
<br /> <br />
Proxy URL: http://proxy.com:1337 {t('proxy.alert.proxy_url')} http://proxy.com:1337
<br /> <br />
Username: myusername {t('proxy.alert.username')} myusername
<br /> <br />
Password: mypassword {t('proxy.alert.password')} mypassword
<br /> <br />
<br /> <br />
<b>The wrong way</b> <b>{t('proxy.alert.wrong_way')}</b>
<br />
Proxy URL: http://myusername:mypassword@proxy.com:1337
<br /> <br />
{t('proxy.alert.proxy_url')} http://myusername:mypassword@proxy.com:1337
</Alert> </Alert>
</> </>
); );

View File

@@ -23,6 +23,7 @@ import { getActiveWorkflow } from "../../api/workflow";
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import ActionDescriptionBox from '../molecules/ActionDescriptionBox'; import ActionDescriptionBox from '../molecules/ActionDescriptionBox';
import { useThemeMode } from '../../context/theme-provider'; import { useThemeMode } from '../../context/theme-provider';
import { useTranslation } from 'react-i18next';
const fetchWorkflow = (id: string, callback: (response: WorkflowFile) => void) => { const fetchWorkflow = (id: string, callback: (response: WorkflowFile) => void) => {
getActiveWorkflow(id).then( getActiveWorkflow(id).then(
@@ -56,11 +57,14 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
const [showCaptureText, setShowCaptureText] = useState(true); const [showCaptureText, setShowCaptureText] = useState(true);
const [hoverStates, setHoverStates] = useState<{ [id: string]: boolean }>({}); const [hoverStates, setHoverStates] = useState<{ [id: string]: boolean }>({});
const [browserStepIdList, setBrowserStepIdList] = useState<number[]>([]); const [browserStepIdList, setBrowserStepIdList] = useState<number[]>([]);
const [isCaptureTextConfirmed, setIsCaptureTextConfirmed] = useState(false);
const [isCaptureListConfirmed, setIsCaptureListConfirmed] = useState(false);
const { lastAction, notify, currentWorkflowActionsState, setCurrentWorkflowActionsState } = useGlobalInfoStore(); const { lastAction, notify, currentWorkflowActionsState, setCurrentWorkflowActionsState, resetInterpretationLog } = useGlobalInfoStore();
const { getText, startGetText, stopGetText, getScreenshot, startGetScreenshot, stopGetScreenshot, getList, startGetList, stopGetList, startPaginationMode, stopPaginationMode, paginationType, updatePaginationType, limitType, customLimit, updateLimitType, updateCustomLimit, stopLimitMode, startLimitMode, captureStage, setCaptureStage } = useActionContext(); const { getText, startGetText, stopGetText, getScreenshot, startGetScreenshot, stopGetScreenshot, getList, startGetList, stopGetList, startPaginationMode, stopPaginationMode, paginationType, updatePaginationType, limitType, customLimit, updateLimitType, updateCustomLimit, stopLimitMode, startLimitMode, captureStage, setCaptureStage } = useActionContext();
const { browserSteps, updateBrowserTextStepLabel, deleteBrowserStep, addScreenshotStep, updateListTextFieldLabel, removeListTextField } = useBrowserSteps(); const { browserSteps, updateBrowserTextStepLabel, deleteBrowserStep, addScreenshotStep, updateListTextFieldLabel, removeListTextField } = useBrowserSteps();
const { id, socket } = useSocketStore(); const { id, socket } = useSocketStore();
const { t } = useTranslation();
const workflowHandler = useCallback((data: WorkflowFile) => { const workflowHandler = useCallback((data: WorkflowFile) => {
setWorkflow(data); setWorkflow(data);
@@ -129,6 +133,16 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
const handlePairDelete = () => { } const handlePairDelete = () => { }
const handleStartGetText = () => {
setIsCaptureTextConfirmed(false);
startGetText();
}
const handleStartGetList = () => {
setIsCaptureListConfirmed(false);
startGetList();
}
const handleTextLabelChange = (id: number, label: string, listId?: number, fieldKey?: string) => { const handleTextLabelChange = (id: number, label: string, listId?: number, fieldKey?: string) => {
if (listId !== undefined && fieldKey !== undefined) { if (listId !== undefined && fieldKey !== undefined) {
// Prevent editing if the field is confirmed // Prevent editing if the field is confirmed
@@ -140,7 +154,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
setTextLabels(prevLabels => ({ ...prevLabels, [id]: label })); setTextLabels(prevLabels => ({ ...prevLabels, [id]: label }));
} }
if (!label.trim()) { if (!label.trim()) {
setErrors(prevErrors => ({ ...prevErrors, [id]: 'Label cannot be empty' })); setErrors(prevErrors => ({ ...prevErrors, [id]: t('right_panel.errors.label_required') }));
} else { } else {
setErrors(prevErrors => ({ ...prevErrors, [id]: '' })); setErrors(prevErrors => ({ ...prevErrors, [id]: '' }));
} }
@@ -152,7 +166,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
updateBrowserTextStepLabel(id, label); updateBrowserTextStepLabel(id, label);
setConfirmedTextSteps(prev => ({ ...prev, [id]: true })); setConfirmedTextSteps(prev => ({ ...prev, [id]: true }));
} else { } else {
setErrors(prevErrors => ({ ...prevErrors, [id]: 'Label cannot be empty' })); setErrors(prevErrors => ({ ...prevErrors, [id]: t('right_panel.errors.label_required') }));
} }
}; };
@@ -168,6 +182,22 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
}); });
}; };
const handleTextStepDelete = (id: number) => {
deleteBrowserStep(id);
setTextLabels(prevLabels => {
const { [id]: _, ...rest } = prevLabels;
return rest;
});
setConfirmedTextSteps(prev => {
const { [id]: _, ...rest } = prev;
return rest;
});
setErrors(prevErrors => {
const { [id]: _, ...rest } = prevErrors;
return rest;
});
};
const handleListTextFieldConfirm = (listId: number, fieldKey: string) => { const handleListTextFieldConfirm = (listId: number, fieldKey: string) => {
setConfirmedListTextFields(prev => ({ setConfirmedListTextFields(prev => ({
...prev, ...prev,
@@ -194,6 +224,22 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
}); });
}; };
const handleListTextFieldDelete = (listId: number, fieldKey: string) => {
removeListTextField(listId, fieldKey);
setConfirmedListTextFields(prev => {
const updatedListFields = { ...(prev[listId] || {}) };
delete updatedListFields[fieldKey];
return {
...prev,
[listId]: updatedListFields
};
});
setErrors(prev => {
const { [fieldKey]: _, ...rest } = prev;
return rest;
});
};
const getTextSettingsObject = useCallback(() => { const getTextSettingsObject = useCallback(() => {
const settings: Record<string, { selector: string; tag?: string;[key: string]: any }> = {}; const settings: Record<string, { selector: string; tag?: string;[key: string]: any }> = {};
browserSteps.forEach(step => { browserSteps.forEach(step => {
@@ -214,18 +260,19 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
const stopCaptureAndEmitGetTextSettings = useCallback(() => { const stopCaptureAndEmitGetTextSettings = useCallback(() => {
const hasUnconfirmedTextSteps = browserSteps.some(step => step.type === 'text' && !confirmedTextSteps[step.id]); const hasUnconfirmedTextSteps = browserSteps.some(step => step.type === 'text' && !confirmedTextSteps[step.id]);
if (hasUnconfirmedTextSteps) { if (hasUnconfirmedTextSteps) {
notify('error', 'Please confirm all text fields'); notify('error', t('right_panel.errors.confirm_text_fields'));
return; return;
} }
stopGetText(); stopGetText();
const settings = getTextSettingsObject(); const settings = getTextSettingsObject();
console.log("SETTINGS", settings);
const hasTextSteps = browserSteps.some(step => step.type === 'text'); const hasTextSteps = browserSteps.some(step => step.type === 'text');
if (hasTextSteps) { if (hasTextSteps) {
socket?.emit('action', { action: 'scrapeSchema', settings }); socket?.emit('action', { action: 'scrapeSchema', settings });
} }
setIsCaptureTextConfirmed(true);
resetInterpretationLog();
onFinishCapture(); onFinishCapture();
}, [stopGetText, getTextSettingsObject, socket, browserSteps, confirmedTextSteps]); }, [stopGetText, getTextSettingsObject, socket, browserSteps, confirmedTextSteps, resetInterpretationLog]);
const getListSettingsObject = useCallback(() => { const getListSettingsObject = useCallback(() => {
let settings: { let settings: {
@@ -279,7 +326,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
if (settings) { if (settings) {
socket?.emit('action', { action: 'scrapeList', settings }); socket?.emit('action', { action: 'scrapeList', settings });
} else { } else {
notify('error', 'Unable to create list settings. Make sure you have defined a field for the list.'); notify('error', t('right_panel.errors.unable_create_settings'));
} }
handleStopGetList(); handleStopGetList();
onFinishCapture(); onFinishCapture();
@@ -297,13 +344,13 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
case 'pagination': case 'pagination':
if (!paginationType) { if (!paginationType) {
notify('error', 'Please select a pagination type.'); notify('error', t('right_panel.errors.select_pagination'));
return; return;
} }
const settings = getListSettingsObject(); const settings = getListSettingsObject();
const paginationSelector = settings.pagination?.selector; const paginationSelector = settings.pagination?.selector;
if (['clickNext', 'clickLoadMore'].includes(paginationType) && !paginationSelector) { if (['clickNext', 'clickLoadMore'].includes(paginationType) && !paginationSelector) {
notify('error', 'Please select the pagination element first.'); notify('error', t('right_panel.errors.select_pagination_element'));
return; return;
} }
stopPaginationMode(); stopPaginationMode();
@@ -315,16 +362,17 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
case 'limit': case 'limit':
if (!limitType || (limitType === 'custom' && !customLimit)) { if (!limitType || (limitType === 'custom' && !customLimit)) {
notify('error', 'Please select a limit or enter a custom limit.'); notify('error', t('right_panel.errors.select_limit'));
return; return;
} }
const limit = limitType === 'custom' ? parseInt(customLimit) : parseInt(limitType); const limit = limitType === 'custom' ? parseInt(customLimit) : parseInt(limitType);
if (isNaN(limit) || limit <= 0) { if (isNaN(limit) || limit <= 0) {
notify('error', 'Please enter a valid limit.'); notify('error', t('right_panel.errors.invalid_limit'));
return; return;
} }
stopLimitMode(); stopLimitMode();
setShowLimitOptions(false); setShowLimitOptions(false);
setIsCaptureListConfirmed(true);
stopCaptureAndEmitGetListSettings(); stopCaptureAndEmitGetListSettings();
setCaptureStage('complete'); setCaptureStage('complete');
break; break;
@@ -335,6 +383,23 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
} }
}, [captureStage, paginationType, limitType, customLimit, startPaginationMode, stopPaginationMode, startLimitMode, stopLimitMode, notify, stopCaptureAndEmitGetListSettings, getListSettingsObject]); }, [captureStage, paginationType, limitType, customLimit, startPaginationMode, stopPaginationMode, startLimitMode, stopLimitMode, notify, stopCaptureAndEmitGetListSettings, getListSettingsObject]);
const handleBackCaptureList = useCallback(() => {
switch (captureStage) {
case 'limit':
stopLimitMode();
setShowLimitOptions(false);
startPaginationMode();
setShowPaginationOptions(true);
setCaptureStage('pagination');
break;
case 'pagination':
stopPaginationMode();
setShowPaginationOptions(false);
setCaptureStage('initial');
break;
}
}, [captureStage, stopLimitMode, startPaginationMode, stopPaginationMode]);
const handlePaginationSettingSelect = (option: PaginationType) => { const handlePaginationSettingSelect = (option: PaginationType) => {
updatePaginationType(option); updatePaginationType(option);
}; };
@@ -349,7 +414,8 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
setTextLabels({}); setTextLabels({});
setErrors({}); setErrors({});
setConfirmedTextSteps({}); setConfirmedTextSteps({});
notify('error', 'Capture Text Discarded'); setIsCaptureTextConfirmed(false);
notify('error', t('right_panel.errors.capture_text_discarded'));
}, [browserSteps, stopGetText, deleteBrowserStep]); }, [browserSteps, stopGetText, deleteBrowserStep]);
const discardGetList = useCallback(() => { const discardGetList = useCallback(() => {
@@ -364,7 +430,8 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
setShowLimitOptions(false); setShowLimitOptions(false);
setCaptureStage('initial'); setCaptureStage('initial');
setConfirmedListTextFields({}); setConfirmedListTextFields({});
notify('error', 'Capture List Discarded'); setIsCaptureListConfirmed(false);
notify('error', t('right_panel.errors.capture_list_discarded'));
}, [browserSteps, stopGetList, deleteBrowserStep, resetListState]); }, [browserSteps, stopGetList, deleteBrowserStep, resetListState]);
@@ -420,37 +487,54 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
</SimpleBox> */} </SimpleBox> */}
<ActionDescriptionBox isDarkMode={isDarkMode} /> <ActionDescriptionBox isDarkMode={isDarkMode} />
<Box display="flex" flexDirection="column" gap={2} style={{ margin: '13px',background: isDarkMode?'#1E2124': 'inherit',color: isDarkMode ? 'white' : 'inherit' }}> <Box display="flex" flexDirection="column" gap={2} style={{ margin: '13px',background: isDarkMode?'#1E2124': 'inherit',color: isDarkMode ? 'white' : 'inherit' }}>
{!getText && !getScreenshot && !getList && showCaptureList && <Button variant="contained" sx={{backgroundColor:"#ff00c3",color:`${isDarkMode?'white':'black'}`}} onClick={startGetList}>Capture List</Button>} {!getText && !getScreenshot && !getList && showCaptureList && <Button variant="contained" sx={{backgroundColor:"#ff00c3",color:`${isDarkMode?'white':'black'}`}} onClick={startGetList}>{t('right_panel.buttons.capture_list')}</Button>}
<!-- <ActionDescriptionBox />
<Box display="flex" flexDirection="column" gap={2} style={{ margin: '13px' }}>
{!getText && !getScreenshot && !getList && showCaptureList && <Button variant="contained" onClick={startGetList}>{t('right_panel.buttons.capture_list')}</Button>}
--!>
{getList && ( {getList && (
<> <>
<Box display="flex" justifyContent="space-between" gap={2} style={{ margin: '15px' }}> <Box display="flex" justifyContent="space-between" gap={2} style={{ margin: '15px' }}>
{(captureStage === 'pagination' || captureStage === 'limit') && (
<Button
variant="outlined"
onClick={handleBackCaptureList}
>
{t('right_panel.buttons.back')}
</Button>
)}
<Button <Button
variant="outlined" variant="outlined"
onClick={handleConfirmListCapture} onClick={handleConfirmListCapture}
disabled={captureStage === 'initial' ? isConfirmCaptureDisabled : hasUnconfirmedListTextFields} disabled={captureStage === 'initial' ? isConfirmCaptureDisabled : hasUnconfirmedListTextFields}
> >
{captureStage === 'initial' ? 'Confirm Capture' : {captureStage === 'initial' ? t('right_panel.buttons.confirm_capture') :
captureStage === 'pagination' ? 'Confirm Pagination' : captureStage === 'pagination' ? t('right_panel.buttons.confirm_pagination') :
captureStage === 'limit' ? 'Confirm Limit' : 'Finish Capture'} captureStage === 'limit' ? t('right_panel.buttons.confirm_limit') :
t('right_panel.buttons.finish_capture')}
</Button>
<Button variant="outlined" color="error" onClick={discardGetList}>
{t('right_panel.buttons.discard')}
</Button> </Button>
<Button variant="outlined" color="error" onClick={discardGetList}>Discard</Button>
</Box> </Box>
</> </>
)} )}
{showPaginationOptions && ( {showPaginationOptions && (
<Box display="flex" flexDirection="column" gap={2} style={{ margin: '13px' }}> <Box display="flex" flexDirection="column" gap={2} style={{ margin: '13px' }}>
<Typography>How can we find the next list item on the page?</Typography> <Typography>{t('right_panel.pagination.title')}</Typography>
<Button variant={paginationType === 'clickNext' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('clickNext')}>Click on next to navigate to the next page</Button> <Button variant={paginationType === 'clickNext' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('clickNext')}>{t('right_panel.pagination.click_next')}</Button>
<Button variant={paginationType === 'clickLoadMore' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('clickLoadMore')}>Click on load more to load more items</Button> <Button variant={paginationType === 'clickLoadMore' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('clickLoadMore')}>{t('right_panel.pagination.click_load_more')}</Button>
<Button variant={paginationType === 'scrollDown' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('scrollDown')}>Scroll down to load more items</Button> <Button variant={paginationType === 'scrollDown' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('scrollDown')}>{t('right_panel.pagination.scroll_down')}</Button>
<Button variant={paginationType === 'scrollUp' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('scrollUp')}>Scroll up to load more items</Button> <Button variant={paginationType === 'scrollUp' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('scrollUp')}>{t('right_panel.pagination.scroll_up')}</Button>
<Button variant={paginationType === 'none' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('none')}>No more items to load</Button> <Button variant={paginationType === 'none' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('none')}>{t('right_panel.pagination.none')}</Button>
</Box> </Box>
)} )}
{showLimitOptions && ( {showLimitOptions && (
<FormControl> <FormControl>
<FormLabel style={{ marginBottom: '10px', background: isDarkMode ? "#1E2124" : 'white', color: isDarkMode ? "white" : 'black' }}> <FormLabel style={{ marginBottom: '10px', background: isDarkMode ? "#1E2124" : 'white', color: isDarkMode ? "white" : 'black' }}>
<h4>What is the maximum number of rows you want to extract?</h4> <h4>{t('right_panel.limit.title')}</h4>
<!-- <FormLabel>
<h4>{t('right_panel.limit.title')}</h4> -->
</FormLabel> </FormLabel>
<RadioGroup <RadioGroup
value={limitType} value={limitType}
@@ -464,13 +548,28 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
<FormControlLabel value="10" control={<Radio />} label="10" /> <FormControlLabel value="10" control={<Radio />} label="10" />
<FormControlLabel value="100" control={<Radio />} label="100" /> <FormControlLabel value="100" control={<Radio />} label="100" />
<div style={{ display: 'flex', alignItems: 'center' }}> <div style={{ display: 'flex', alignItems: 'center' }}>
<FormControlLabel value="custom" control={<Radio />} label="Custom" /> <FormControlLabel value="custom" control={<Radio />} label={t('right_panel.limit.custom')} />
{limitType === 'custom' && ( {limitType === 'custom' && (
<TextField <TextField
type="number" type="number"
value={customLimit} value={customLimit}
onChange={(e) => updateCustomLimit(e.target.value)} onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
placeholder="Enter number" const value = parseInt(e.target.value);
// Only update if the value is greater than or equal to 1 or if the field is empty
if (e.target.value === '' || value >= 1) {
updateCustomLimit(e.target.value);
}
}}
inputProps={{
min: 1,
onKeyPress: (e: React.KeyboardEvent<HTMLInputElement>) => {
const value = (e.target as HTMLInputElement).value + e.key;
if (parseInt(value) < 1) {
e.preventDefault();
}
}
}}
placeholder={t('right_panel.limit.enter_number')}
sx={{ sx={{
marginLeft: '10px', marginLeft: '10px',
'& input': { '& input': {
@@ -487,21 +586,24 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
</RadioGroup> </RadioGroup>
</FormControl> </FormControl>
)} )}
{!getText && !getScreenshot && !getList && showCaptureText && <Button variant="contained" sx={{backgroundColor:"#ff00c3",color:`${isDarkMode?'white':'black'}`}} onClick={startGetText}>Capture Text</Button>} {!getText && !getScreenshot && !getList && showCaptureText && <Button variant="contained" sx={{backgroundColor:"#ff00c3",color:`${isDarkMode?'white':'black'}`}} onClick={startGetText}>{t('right_panel.buttons.capture_text')}</Button>}
// {!getText && !getScreenshot && !getList && showCaptureText && <Button variant="contained" onClick={handleStartGetText}>{t('right_panel.buttons.capture_text')}</Button>}
{getText && {getText &&
<> <>
<Box display="flex" justifyContent="space-between" gap={2} style={{ margin: '15px' }}> <Box display="flex" justifyContent="space-between" gap={2} style={{ margin: '15px' }}>
<Button variant="outlined" onClick={stopCaptureAndEmitGetTextSettings} >Confirm</Button> <Button variant="outlined" onClick={stopCaptureAndEmitGetTextSettings} >{t('right_panel.buttons.confirm')}</Button>
<Button variant="outlined" color="error" onClick={discardGetText} >Discard</Button> <Button variant="outlined" color="error" onClick={discardGetText} >{t('right_panel.buttons.discard')}</Button>
</Box> </Box>
</> </>
} }
{!getText && !getScreenshot && !getList && showCaptureScreenshot && <Button variant="contained" sx={{backgroundColor:"#ff00c3",color:`${isDarkMode?'white':'black'}`}} onClick={startGetScreenshot}>Capture Screenshot</Button>} {!getText && !getScreenshot && !getList && showCaptureScreenshot && <Button variant="contained" sx={{backgroundColor:"#ff00c3",color:`${isDarkMode?'white':'black'}`}} onClick={startGetScreenshot}>{t('right_panel.buttons.capture_screenshot')}</Button>}
// {!getText && !getScreenshot && !getList && showCaptureScreenshot && <Button variant="contained" onClick={startGetScreenshot}>{t('right_panel.buttons.capture_screenshot')}</Button>}
{getScreenshot && ( {getScreenshot && (
<Box display="flex" flexDirection="column" gap={2}> <Box display="flex" flexDirection="column" gap={2}>
<Button variant="contained" onClick={() => captureScreenshot(true)}>Capture Fullpage</Button> <Button variant="contained" onClick={() => captureScreenshot(true)}>{t('right_panel.screenshot.capture_fullpage')}</Button>
<Button variant="contained" onClick={() => captureScreenshot(false)}>Capture Visible Part</Button> <Button variant="contained" onClick={() => captureScreenshot(false)}>{t('right_panel.screenshot.capture_visible')}</Button>
<Button variant="outlined" color="error" onClick={stopGetScreenshot}>Discard</Button> <Button variant="outlined" color="error" onClick={stopGetScreenshot}>{t('right_panel.buttons.discard')}</Button>
</Box> </Box>
)} )}
</Box> </Box>
@@ -512,7 +614,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
step.type === 'text' && ( step.type === 'text' && (
<> <>
<TextField <TextField
label="Label" label={t('right_panel.fields.label')}
value={textLabels[step.id] || step.label || ''} value={textLabels[step.id] || step.label || ''}
onChange={(e) => handleTextLabelChange(step.id, e.target.value)} onChange={(e) => handleTextLabelChange(step.id, e.target.value)}
fullWidth fullWidth
@@ -531,7 +633,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
sx={{ background: isDarkMode ? "#1E2124" : 'white', color: isDarkMode ? "white" : 'black' }} sx={{ background: isDarkMode ? "#1E2124" : 'white', color: isDarkMode ? "white" : 'black' }}
/> />
<TextField <TextField
label="Data" label={t('right_panel.fields.data')}
value={step.data} value={step.data}
fullWidth fullWidth
margin="normal" margin="normal"
@@ -545,10 +647,20 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
}} }}
/> />
{!confirmedTextSteps[step.id] && ( {!confirmedTextSteps[step.id] ? (
<Box display="flex" justifyContent="space-between" gap={2}> <Box display="flex" justifyContent="space-between" gap={2}>
<Button variant="contained" onClick={() => handleTextStepConfirm(step.id)} disabled={!textLabels[step.id]?.trim()}>Confirm</Button> <Button variant="contained" onClick={() => handleTextStepConfirm(step.id)} disabled={!textLabels[step.id]?.trim()}>{t('right_panel.buttons.confirm')}</Button>
<Button variant="contained" color="error" onClick={() => handleTextStepDiscard(step.id)}>Discard</Button> <Button variant="contained" color="error" onClick={() => handleTextStepDiscard(step.id)}>{t('right_panel.buttons.discard')}</Button>
</Box>
) : !isCaptureTextConfirmed && (
<Box display="flex" justifyContent="flex-end" gap={2}>
<Button
variant="contained"
color="error"
onClick={() => handleTextStepDelete(step.id)}
>
{t('right_panel.buttons.delete')}
</Button>
</Box> </Box>
)} )}
</> </>
@@ -557,17 +669,19 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
<Box display="flex" alignItems="center"> <Box display="flex" alignItems="center">
<DocumentScannerIcon sx={{ mr: 1 }} /> <DocumentScannerIcon sx={{ mr: 1 }} />
<Typography> <Typography>
{`Take ${step.fullPage ? 'Fullpage' : 'Visible Part'} Screenshot`} {step.fullPage ?
t('right_panel.screenshot.display_fullpage') :
t('right_panel.screenshot.display_visible')}
</Typography> </Typography>
</Box> </Box>
)} )}
{step.type === 'list' && ( {step.type === 'list' && (
<> <>
<Typography>List Selected Successfully</Typography> <Typography>{t('right_panel.messages.list_selected')}</Typography>
{Object.entries(step.fields).map(([key, field]) => ( {Object.entries(step.fields).map(([key, field]) => (
<Box key={key} sx={{ padding: '10px', margin: '11px', borderRadius: '5px', position: 'relative', background: `${isDarkMode ? "#1E2124" : 'white'}` }}> <Box key={key} sx={{ padding: '10px', margin: '11px', borderRadius: '5px', position: 'relative', background: `${isDarkMode ? "#1E2124" : 'white'}` }}>
<TextField <TextField
label="Field Label" label={t('right_panel.fields.field_label')}
value={field.label || ''} value={field.label || ''}
onChange={(e) => handleTextLabelChange(field.id, e.target.value, step.id, key)} onChange={(e) => handleTextLabelChange(field.id, e.target.value, step.id, key)}
fullWidth fullWidth
@@ -584,7 +698,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
/> />
<TextField <TextField
label="Field Data" label={t('right_panel.fields.field_data')}
value={field.data || ''} value={field.data || ''}
fullWidth fullWidth
margin="normal" margin="normal"
@@ -598,21 +712,31 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
}} }}
/> />
{!confirmedListTextFields[step.id]?.[key] && ( {!confirmedListTextFields[step.id]?.[key] ? (
<Box display="flex" justifyContent="space-between" gap={2}> <Box display="flex" justifyContent="space-between" gap={2}>
<Button <Button
variant="contained" variant="contained"
onClick={() => handleListTextFieldConfirm(step.id, key)} onClick={() => handleListTextFieldConfirm(step.id, key)}
disabled={!field.label?.trim()} disabled={!field.label?.trim()}
> >
Confirm {t('right_panel.buttons.confirm')}
</Button> </Button>
<Button <Button
variant="contained" variant="contained"
color="error" color="error"
onClick={() => handleListTextFieldDiscard(step.id, key)} onClick={() => handleListTextFieldDiscard(step.id, key)}
> >
Discard {t('right_panel.buttons.discard')}
</Button>
</Box>
) : !isCaptureListConfirmed && (
<Box display="flex" justifyContent="flex-end" gap={2}>
<Button
variant="contained"
color="error"
onClick={() => handleListTextFieldDelete(step.id, key)}
>
{t('right_panel.buttons.delete')}
</Button> </Button>
</Box> </Box>
)} )}

View File

@@ -1,4 +1,4 @@
import { useReducer, createContext, useEffect } from 'react'; import { useReducer, createContext, useEffect, useCallback } from 'react';
import axios from 'axios'; import axios from 'axios';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { apiUrl } from "../apiConfig"; import { apiUrl } from "../apiConfig";
@@ -14,12 +14,16 @@ interface ActionType {
type InitialStateType = { type InitialStateType = {
user: any; user: any;
lastActivityTime?: number;
}; };
const initialState = { const initialState = {
user: null, user: null,
lastActivityTime: Date.now(),
}; };
const AUTO_LOGOUT_TIME = 4 * 60 * 60 * 1000; // 4 hours in milliseconds
const AuthContext = createContext<{ const AuthContext = createContext<{
state: InitialStateType; state: InitialStateType;
dispatch: React.Dispatch<ActionType>; dispatch: React.Dispatch<ActionType>;
@@ -34,11 +38,13 @@ const reducer = (state: InitialStateType, action: ActionType) => {
return { return {
...state, ...state,
user: action.payload, user: action.payload,
lastActivityTime: Date.now(),
}; };
case 'LOGOUT': case 'LOGOUT':
return { return {
...state, ...state,
user: null, user: null,
lastActivityTime: undefined,
}; };
default: default:
return state; return state;
@@ -50,6 +56,39 @@ const AuthProvider = ({ children }: AuthProviderProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
axios.defaults.withCredentials = true; axios.defaults.withCredentials = true;
const handleLogout = useCallback(async () => {
try {
await axios.get(`${apiUrl}/auth/logout`);
dispatch({ type: 'LOGOUT' });
window.localStorage.removeItem('user');
navigate('/login');
} catch (err) {
console.error('Logout error:', err);
}
}, [navigate]);
const checkAutoLogout = useCallback(() => {
if (state.user && state.lastActivityTime) {
const currentTime = Date.now();
const timeSinceLastActivity = currentTime - state.lastActivityTime;
if (timeSinceLastActivity >= AUTO_LOGOUT_TIME) {
handleLogout();
}
}
}, [state.user, state.lastActivityTime, handleLogout]);
// Update last activity time on user interactions
const updateActivityTime = useCallback(() => {
if (state.user) {
dispatch({
type: 'LOGIN',
payload: state.user // Reuse existing user data
});
}
}, [state.user]);
// Initialize user from localStorage
useEffect(() => { useEffect(() => {
const storedUser = window.localStorage.getItem('user'); const storedUser = window.localStorage.getItem('user');
if (storedUser) { if (storedUser) {
@@ -57,21 +96,54 @@ const AuthProvider = ({ children }: AuthProviderProps) => {
} }
}, []); }, []);
// Set up activity listeners
useEffect(() => {
if (state.user) {
// List of events to track for user activity
const events = ['mousedown', 'keydown', 'scroll', 'touchstart'];
// Throttled event handler
let timeoutId: NodeJS.Timeout;
const handleActivity = () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(updateActivityTime, 1000);
};
// Add event listeners
events.forEach(event => {
window.addEventListener(event, handleActivity);
});
// Set up periodic check for auto logout
const checkInterval = setInterval(checkAutoLogout, 60000); // Check every minute
// Cleanup
return () => {
events.forEach(event => {
window.removeEventListener(event, handleActivity);
});
clearInterval(checkInterval);
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}
}, [state.user, updateActivityTime, checkAutoLogout]);
axios.interceptors.response.use( axios.interceptors.response.use(
function (response) { function (response) {
return response; return response;
}, },
function (error) { function (error) {
const res = error.response; const res = error.response;
if (res.status === 401 && res.config && !res.config.__isRetryRequest) { if (res?.status === 401 && res.config && !res.config.__isRetryRequest) {
return new Promise((resolve, reject) => { return new Promise((_, reject) => {
axios handleLogout()
.get(`${apiUrl}/auth/logout`)
.then(() => { .then(() => {
console.log('/401 error > logout'); console.log('/401 error > logout');
dispatch({ type: 'LOGOUT' }); reject(error);
window.localStorage.removeItem('user');
navigate('/login');
}) })
.catch((err) => { .catch((err) => {
console.error('AXIOS INTERCEPTORS ERROR:', err); console.error('AXIOS INTERCEPTORS ERROR:', err);

View File

@@ -14,8 +14,8 @@ interface ActionContextProps {
paginationType: PaginationType; paginationType: PaginationType;
limitType: LimitType; limitType: LimitType;
customLimit: string; customLimit: string;
captureStage: CaptureStage; // New captureStage property captureStage: CaptureStage;
setCaptureStage: (stage: CaptureStage) => void; // Setter for captureStage setCaptureStage: (stage: CaptureStage) => void;
startPaginationMode: () => void; startPaginationMode: () => void;
startGetText: () => void; startGetText: () => void;
stopGetText: () => void; stopGetText: () => void;
@@ -53,6 +53,7 @@ export const ActionProvider = ({ children }: { children: ReactNode }) => {
const startPaginationMode = () => { const startPaginationMode = () => {
setPaginationMode(true); setPaginationMode(true);
setCaptureStage('pagination'); setCaptureStage('pagination');
socket?.emit('setGetList', { getList: false });
}; };
const stopPaginationMode = () => setPaginationMode(false); const stopPaginationMode = () => setPaginationMode(false);
@@ -75,7 +76,6 @@ export const ActionProvider = ({ children }: { children: ReactNode }) => {
const stopGetList = () => { const stopGetList = () => {
setGetList(false); setGetList(false);
socket?.emit('setGetList', { getList: false });
setPaginationType(''); setPaginationType('');
setLimitType(''); setLimitType('');
setCustomLimit(''); setCustomLimit('');

View File

@@ -32,6 +32,7 @@ export interface SelectorObject {
selector: string; selector: string;
tag?: string; tag?: string;
attribute?: string; attribute?: string;
shadow?: boolean;
[key: string]: any; [key: string]: any;
} }
@@ -62,26 +63,35 @@ export const BrowserStepsProvider: React.FC<{ children: React.ReactNode }> = ({
const addListStep = (listSelector: string, newFields: { [key: string]: TextStep }, listId: number, pagination?: { type: string; selector: string }, limit?: number) => { const addListStep = (listSelector: string, newFields: { [key: string]: TextStep }, listId: number, pagination?: { type: string; selector: string }, limit?: number) => {
setBrowserSteps(prevSteps => { setBrowserSteps(prevSteps => {
const existingListStepIndex = prevSteps.findIndex(step => step.type === 'list' && step.id === listId); const existingListStepIndex = prevSteps.findIndex(step => step.type === 'list' && step.id === listId);
if (existingListStepIndex !== -1) { if (existingListStepIndex !== -1) {
const updatedSteps = [...prevSteps]; const updatedSteps = [...prevSteps];
const existingListStep = updatedSteps[existingListStepIndex] as ListStep; const existingListStep = updatedSteps[existingListStepIndex] as ListStep;
const filteredNewFields = Object.entries(newFields).reduce((acc, [key, value]) => { // Preserve existing labels for fields
const mergedFields = Object.entries(newFields).reduce((acc, [key, field]) => {
if (!discardedFields.has(`${listId}-${key}`)) { if (!discardedFields.has(`${listId}-${key}`)) {
acc[key] = value; // If field exists, preserve its label
if (existingListStep.fields[key]) {
acc[key] = {
...field,
label: existingListStep.fields[key].label
};
} else {
acc[key] = field;
}
} }
return acc; return acc;
}, {} as { [key: string]: TextStep }); }, {} as { [key: string]: TextStep });
updatedSteps[existingListStepIndex] = { updatedSteps[existingListStepIndex] = {
...existingListStep, ...existingListStep,
fields: { ...existingListStep.fields, ...filteredNewFields }, fields: mergedFields,
pagination: pagination, pagination: pagination || existingListStep.pagination,
limit: limit, limit: limit
}; };
return updatedSteps; return updatedSteps;
} else { } else {
// Create a new ListStep
return [ return [
...prevSteps, ...prevSteps,
{ id: listId, type: 'list', listSelector, fields: newFields, pagination, limit } { id: listId, type: 'list', listSelector, fields: newFields, pagination, limit }

View File

@@ -32,6 +32,8 @@ interface GlobalInfo {
hasScreenshotAction: boolean; hasScreenshotAction: boolean;
hasScrapeSchemaAction: boolean; hasScrapeSchemaAction: boolean;
}) => void; }) => void;
shouldResetInterpretationLog: boolean;
resetInterpretationLog: () => void;
}; };
class GlobalInfoStore implements Partial<GlobalInfo> { class GlobalInfoStore implements Partial<GlobalInfo> {
@@ -53,6 +55,7 @@ class GlobalInfoStore implements Partial<GlobalInfo> {
hasScreenshotAction: false, hasScreenshotAction: false,
hasScrapeSchemaAction: false, hasScrapeSchemaAction: false,
}; };
shouldResetInterpretationLog = false;
}; };
const globalInfoStore = new GlobalInfoStore(); const globalInfoStore = new GlobalInfoStore();
@@ -71,6 +74,7 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => {
const [recordingName, setRecordingName] = useState<string>(globalInfoStore.recordingName); const [recordingName, setRecordingName] = useState<string>(globalInfoStore.recordingName);
const [recordingUrl, setRecordingUrl] = useState<string>(globalInfoStore.recordingUrl); const [recordingUrl, setRecordingUrl] = useState<string>(globalInfoStore.recordingUrl);
const [currentWorkflowActionsState, setCurrentWorkflowActionsState] = useState(globalInfoStore.currentWorkflowActionsState); const [currentWorkflowActionsState, setCurrentWorkflowActionsState] = useState(globalInfoStore.currentWorkflowActionsState);
const [shouldResetInterpretationLog, setShouldResetInterpretationLog] = useState<boolean>(globalInfoStore.shouldResetInterpretationLog);
const notify = (severity: 'error' | 'warning' | 'info' | 'success', message: string) => { const notify = (severity: 'error' | 'warning' | 'info' | 'success', message: string) => {
setNotification({ severity, message, isOpen: true }); setNotification({ severity, message, isOpen: true });
@@ -87,6 +91,14 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => {
} }
} }
const resetInterpretationLog = () => {
setShouldResetInterpretationLog(true);
// Reset the flag after a short delay to allow components to respond
setTimeout(() => {
setShouldResetInterpretationLog(false);
}, 100);
}
return ( return (
<globalInfoContext.Provider <globalInfoContext.Provider
value={{ value={{
@@ -111,6 +123,8 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => {
setRecordingUrl, setRecordingUrl,
currentWorkflowActionsState, currentWorkflowActionsState,
setCurrentWorkflowActionsState, setCurrentWorkflowActionsState,
shouldResetInterpretationLog,
resetInterpretationLog,
}} }}
> >
{children} {children}

22
src/i18n.ts Normal file
View File

@@ -0,0 +1,22 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import Backend from 'i18next-http-backend';
import LanguageDetector from 'i18next-browser-languagedetector';
i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: 'en',
debug: import.meta.env.DEV,
supportedLngs: ['en', 'es', 'ja', 'zh','de'],
interpolation: {
escapeValue: false, // React already escapes
},
backend: {
loadPath: '/locales/{{lng}}.json',
},
});
export default i18n;

View File

@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client';
import './index.css'; import './index.css';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import App from './App'; import App from './App';
import i18n from "./i18n"
const root = ReactDOM.createRoot( const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement document.getElementById('root') as HTMLElement

View File

@@ -5,10 +5,20 @@ import { AuthContext } from "../context/auth";
import { Box, Typography, TextField, Button, CircularProgress } from "@mui/material"; import { Box, Typography, TextField, Button, CircularProgress } from "@mui/material";
import { useGlobalInfoStore } from "../context/globalInfo"; import { useGlobalInfoStore } from "../context/globalInfo";
import { apiUrl } from "../apiConfig"; import { apiUrl } from "../apiConfig";
import { useTranslation } from 'react-i18next';
import i18n from '../i18n';
import { useThemeMode } from "../context/theme-provider"; import { useThemeMode } from "../context/theme-provider";
const Login = () => { const Login = () => {
const [form, setForm] = useState({ email: "", password: "" }); const { t } = useTranslation();
// just don't remove these logs - god knows why it's not working without them
console.log(i18n)
console.log(t)
const [form, setForm] = useState({
email: "",
password: "",
});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { notify } = useGlobalInfoStore(); const { notify } = useGlobalInfoStore();
const { email, password } = form; const { email, password } = form;
@@ -40,11 +50,11 @@ const Login = () => {
{ withCredentials: true } { withCredentials: true }
); );
dispatch({ type: "LOGIN", payload: data }); dispatch({ type: "LOGIN", payload: data });
notify("success", "Welcome to Maxun!"); 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) {
notify("error", "Login Failed. Please try again."); notify("error", t('login.error_notification'));
setLoading(false); setLoading(false);
} }
}; };
@@ -79,23 +89,13 @@ const Login = () => {
width: "100%", width: "100%",
}} }}
> >
<img <img src="../src/assets/maxunlogo.png" alt="logo" height={55} width={60} style={{ marginBottom: 20, borderRadius: "20%", alignItems: "center" }} />
src="../src/assets/maxunlogo.png"
alt="logo"
height={55}
width={60}
style={{
marginBottom: 20,
borderRadius: "20%",
alignItems: "center",
}}
/>
<Typography variant="h4" gutterBottom> <Typography variant="h4" gutterBottom>
Welcome Back! {t('login.title')}
</Typography> </Typography>
<TextField <TextField
fullWidth fullWidth
label="Email" label={t('login.email')}
name="email" name="email"
value={email} value={email}
onChange={handleChange} onChange={handleChange}
@@ -114,7 +114,7 @@ const Login = () => {
/> />
<TextField <TextField
fullWidth fullWidth
label="Password" label={t('login.password')}
name="password" name="password"
type="password" type="password"
value={password} value={password}
@@ -149,17 +149,17 @@ const Login = () => {
> >
{loading ? ( {loading ? (
<> <>
<CircularProgress size={20} sx={{ mr: 2, color: "#ffffff" }} /> <CircularProgress size={20} sx={{ mr: 2 }} />
Loading {t('login.loading')}
</> </>
) : ( ) : (
"Login" t('login.button')
)} )}
</Button> </Button>
<Typography variant="body2" align="center" sx={{ color: darkMode ? "#ffffff" : "#333333" }}> <Typography variant="body2" align="center">
Dont have an account?{" "} {t('login.register_prompt')}{" "}
<Link to="/register" style={{ textDecoration: "none", color: "#ff33cc" }}> <Link to="/register" style={{ textDecoration: "none", color: "#ff33cc" }}>
Register {t('login.register_link')}
</Link> </Link>
</Typography> </Typography>
</Box> </Box>
@@ -167,4 +167,4 @@ const Login = () => {
); );
}; };
export default Login; export default Login;

View File

@@ -1,4 +1,5 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { MainMenu } from "../components/organisms/MainMenu"; import { MainMenu } from "../components/organisms/MainMenu";
import { Stack } from "@mui/material"; import { Stack } from "@mui/material";
import { Recordings } from "../components/organisms/Recordings"; import { Recordings } from "../components/organisms/Recordings";
@@ -30,7 +31,7 @@ export interface ScheduleRunResponse {
} }
export const MainPage = ({ handleEditRecording }: MainPageProps) => { export const MainPage = ({ handleEditRecording }: MainPageProps) => {
const { t } = useTranslation();
const [content, setContent] = React.useState('recordings'); const [content, setContent] = React.useState('recordings');
const [sockets, setSockets] = React.useState<Socket[]>([]); const [sockets, setSockets] = React.useState<Socket[]>([]);
const [runningRecordingId, setRunningRecordingId] = React.useState(''); const [runningRecordingId, setRunningRecordingId] = React.useState('');
@@ -49,10 +50,10 @@ export const MainPage = ({ handleEditRecording }: MainPageProps) => {
aborted = true; aborted = true;
notifyAboutAbort(runId).then(async (response) => { notifyAboutAbort(runId).then(async (response) => {
if (response) { if (response) {
notify('success', `Interpretation of robot ${runningRecordingName} aborted successfully`); notify('success', t('main_page.notifications.abort_success', { name: runningRecordingName }));
await stopRecording(ids.browserId); await stopRecording(ids.browserId);
} else { } else {
notify('error', `Failed to abort the interpretation of ${runningRecordingName} robot`); notify('error', t('main_page.notifications.abort_failed', { name: runningRecordingName }));
} }
}) })
} }
@@ -67,9 +68,9 @@ export const MainPage = ({ handleEditRecording }: MainPageProps) => {
interpretStoredRecording(runId).then(async (interpretation: boolean) => { interpretStoredRecording(runId).then(async (interpretation: boolean) => {
if (!aborted) { if (!aborted) {
if (interpretation) { if (interpretation) {
notify('success', `Interpretation of robot ${runningRecordingName} succeeded`); notify('success', t('main_page.notifications.interpretation_success', { name: runningRecordingName }));
} else { } else {
notify('success', `Failed to interpret ${runningRecordingName} robot`); notify('success', t('main_page.notifications.interpretation_failed', { name: runningRecordingName }));
// destroy the created browser // destroy the created browser
await stopRecording(browserId); await stopRecording(browserId);
} }
@@ -98,9 +99,9 @@ export const MainPage = ({ handleEditRecording }: MainPageProps) => {
socket.on('debugMessage', debugMessageHandler); socket.on('debugMessage', debugMessageHandler);
setContent('runs'); setContent('runs');
if (browserId) { if (browserId) {
notify('info', `Running robot: ${runningRecordingName}`); notify('info', t('main_page.notifications.run_started', { name: runningRecordingName }));
} else { } else {
notify('error', `Failed to run robot: ${runningRecordingName}`); notify('error', t('main_page.notifications.run_start_failed', { name: runningRecordingName }));
} }
}) })
return (socket: Socket, browserId: string, runId: string) => { return (socket: Socket, browserId: string, runId: string) => {
@@ -113,9 +114,9 @@ export const MainPage = ({ handleEditRecording }: MainPageProps) => {
scheduleStoredRecording(runningRecordingId, settings) scheduleStoredRecording(runningRecordingId, settings)
.then(({ message, runId }: ScheduleRunResponse) => { .then(({ message, runId }: ScheduleRunResponse) => {
if (message === 'success') { if (message === 'success') {
notify('success', `Robot ${runningRecordingName} scheduled successfully`); notify('success', t('main_page.notifications.schedule_success', { name: runningRecordingName }));
} else { } else {
notify('error', `Failed to schedule robot ${runningRecordingName}`); notify('error', t('main_page.notifications.schedule_failed', { name: runningRecordingName }));
} }
}); });
} }
@@ -151,4 +152,4 @@ export const MainPage = ({ handleEditRecording }: MainPageProps) => {
{DisplayContent()} {DisplayContent()}
</Stack> </Stack>
); );
}; };

View File

@@ -16,6 +16,7 @@ import { WhereWhatPair } from "maxun-core";
import styled from "styled-components"; import styled from "styled-components";
import BrowserRecordingSave from '../components/molecules/BrowserRecordingSave'; import BrowserRecordingSave from '../components/molecules/BrowserRecordingSave';
import { useThemeMode } from '../context/theme-provider'; import { useThemeMode } from '../context/theme-provider';
import { useTranslation } from 'react-i18next';
interface RecordingPageProps { interface RecordingPageProps {
recordingName?: string; recordingName?: string;
@@ -28,6 +29,7 @@ export interface PairForEdit {
export const RecordingPage = ({ recordingName }: RecordingPageProps) => { export const RecordingPage = ({ recordingName }: RecordingPageProps) => {
const { darkMode } = useThemeMode(); const { darkMode } = useThemeMode();
const { t } = useTranslation();
const [isLoaded, setIsLoaded] = React.useState(false); const [isLoaded, setIsLoaded] = React.useState(false);
const [hasScrollbar, setHasScrollbar] = React.useState(false); const [hasScrollbar, setHasScrollbar] = React.useState(false);
const [pairForEdit, setPairForEdit] = useState<PairForEdit>({ const [pairForEdit, setPairForEdit] = useState<PairForEdit>({
@@ -150,7 +152,7 @@ export const RecordingPage = ({ recordingName }: RecordingPageProps) => {
</Grid> </Grid>
</> </>
) : ( ) : (
<Loader text={`Spinning up a browser...Navigating to ${recordingUrl}`} /> <Loader text={t('recording_page.loader.browser_startup', { url: recordingUrl })} />
)} )}
</div> </div>
</BrowserStepsProvider> </BrowserStepsProvider>

View File

@@ -6,9 +6,16 @@ import { Box, Typography, TextField, Button, CircularProgress } from "@mui/mater
import { useGlobalInfoStore } from "../context/globalInfo"; import { useGlobalInfoStore } from "../context/globalInfo";
import { apiUrl } from "../apiConfig"; import { apiUrl } from "../apiConfig";
import { useThemeMode } from "../context/theme-provider"; import { useThemeMode } from "../context/theme-provider";
import { useTranslation } from 'react-i18next';
import i18n from '../i18n';
const Register = () => { const Register = () => {
const [form, setForm] = useState({ email: "", password: "" }); const {t} = useTranslation();
const [form, setForm] = useState({
email: "",
password: "",
});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { notify } = useGlobalInfoStore(); const { notify } = useGlobalInfoStore();
const { email, password } = form; const { email, password } = form;
@@ -37,11 +44,11 @@ const Register = () => {
const { data } = await axios.post(`${apiUrl}/auth/register`, { email, password }); const { data } = await axios.post(`${apiUrl}/auth/register`, { email, password });
console.log(data); console.log(data);
dispatch({ type: "LOGIN", payload: data }); dispatch({ type: "LOGIN", payload: data });
notify("success", "Registration Successful!"); notify("success", t('register.welcome_notification'));
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 || "Registration Failed. Please try again."); notify("error", error.response.data || t('register.error_notification'));
setLoading(false); setLoading(false);
} }
}; };
@@ -88,11 +95,11 @@ const Register = () => {
}} }}
/> />
<Typography variant="h4" gutterBottom> <Typography variant="h4" gutterBottom>
Create an Account {t('register.title')}
</Typography> </Typography>
<TextField <TextField
fullWidth fullWidth
label="Email" label={t('register.email')}
name="email" name="email"
value={email} value={email}
onChange={handleChange} onChange={handleChange}
@@ -111,7 +118,7 @@ const Register = () => {
/> />
<TextField <TextField
fullWidth fullWidth
label="Password" label={t('register.password')}
name="password" name="password"
type="password" type="password"
value={password} value={password}
@@ -150,13 +157,13 @@ const Register = () => {
Loading Loading
</> </>
) : ( ) : (
"Register" t('register.button')
)} )}
</Button> </Button>
<Typography variant="body2" align="center" sx={{ color: darkMode ? "#ffffff" : "#333333" }}> <Typography variant="body2" align="center" sx={{ color: darkMode ? "#ffffff" : "#333333" }}>
Already have an account?{" "} {t('register.register_prompt')}{" "}
<Link to="/login" style={{ textDecoration: "none", color: "#ff33cc" }}> <Link to="/login" style={{ textDecoration: "none", color: "#ff33cc" }}>
Login {t('register.login_link')}
</Link> </Link>
</Typography> </Typography>
</Box> </Box>