diff --git a/.github/.github/COMMIT_CONVENTION.md b/.github/COMMIT_CONVENTION.md similarity index 100% rename from .github/.github/COMMIT_CONVENTION.md rename to .github/COMMIT_CONVENTION.md diff --git a/Dockerfile b/Dockerfile index 719e7814..33df05e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,16 +7,17 @@ COPY package*.json ./ COPY maxun-core ./maxun-core # Install dependencies -RUN npm install +RUN npm install --legacy-peer-deps # Copy frontend source code and config COPY src ./src +COPY public ./public COPY index.html ./ COPY vite.config.js ./ COPY tsconfig.json ./ # Expose the frontend port -EXPOSE 5173 +EXPOSE ${FRONTEND_PORT:-5173} # Start the frontend using the client script CMD ["npm", "run", "client", "--", "--host"] \ No newline at end of file diff --git a/README.md b/README.md index ac74d21c..47e170b5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

- +
Maxun @@ -15,11 +15,11 @@ Maxun lets you train a robot in 2 minutes and scrape the web on auto-pilot. Web

- Website | + Website | Discord | - Twitter | + Twitter | Join Maxun Cloud | - Watch Tutorials + Watch Tutorials

getmaxun%2Fmaxun | Trendshift @@ -29,12 +29,17 @@ Maxun lets you train a robot in 2 minutes and scrape the web on auto-pilot. Web -> 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 +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 ``` You can access the frontend at http://localhost:5173/ and backend at http://localhost:8080/ diff --git a/docker-compose.yml b/docker-compose.yml index 46cc72c4..6506a4c5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,7 +43,7 @@ services: #build: #context: . #dockerfile: server/Dockerfile - image: getmaxun/maxun-backend:v0.0.5 + image: getmaxun/maxun-backend:v0.0.10 ports: - "${BACKEND_PORT:-8080}:${BACKEND_PORT:-8080}" env_file: .env @@ -64,28 +64,23 @@ services: - redis - minio 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 frontend: #build: #context: . #dockerfile: Dockerfile - image: getmaxun/maxun-frontend:v0.0.2 + image: getmaxun/maxun-frontend:v0.0.6 ports: - "${FRONTEND_PORT:-5173}:${FRONTEND_PORT:-5173}" env_file: .env environment: PUBLIC_URL: ${PUBLIC_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: - backend volumes: postgres_data: minio_data: - redis_data: + redis_data: \ No newline at end of file diff --git a/maxun-core/package.json b/maxun-core/package.json index 90ee01b7..ddaaa510 100644 --- a/maxun-core/package.json +++ b/maxun-core/package.json @@ -1,6 +1,6 @@ { "name": "maxun-core", - "version": "0.0.5", + "version": "0.0.8", "description": "Core package for Maxun, responsible for data extraction", "main": "build/index.js", "typings": "build/index.d.ts", diff --git a/maxun-core/src/browserSide/scraper.js b/maxun-core/src/browserSide/scraper.js index 369a08be..f6b53da2 100644 --- a/maxun-core/src/browserSide/scraper.js +++ b/maxun-core/src/browserSide/scraper.js @@ -188,69 +188,201 @@ function scrapableHeuristics(maxCountPerPage = 50, minArea = 20000, scrolls = 3, * @param {Object.} lists The named lists of HTML elements. * @returns {Array.>} */ - window.scrapeSchema = function (lists) { + window.scrapeSchema = function(lists) { + // Utility functions remain the same function omap(object, f, kf = (x) => x) { return Object.fromEntries( Object.entries(object) - .map(([k, v]) => [kf(k), f(v)]), + .map(([k, v]) => [kf(k), f(v)]), ); } function ofilter(object, f) { return Object.fromEntries( Object.entries(object) - .filter(([k, v]) => f(k, v)), + .filter(([k, v]) => f(k, v)), ); } - function getSeedKey(listObj) { - const maxLength = Math.max(...Object.values(omap(listObj, (x) => document.querySelectorAll(x.selector).length))); - return Object.keys(ofilter(listObj, (_, v) => document.querySelectorAll(v.selector).length === maxLength))[0]; + function findAllElements(config) { + // Regular DOM query if no special delimiters + 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) { return elements.map((element) => { let candidate = element; 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; - + while (candidate && isUniqueChild(candidate)) { candidate = candidate.parentNode; } - + return candidate; }); } const seedName = getSeedKey(lists); - const seedElements = Array.from(document.querySelectorAll(lists[seedName].selector)); + const seedElements = findAllElements(lists[seedName]); const MBEs = getMBEs(seedElements); - - return MBEs.map((mbe) => omap( - lists, - ({ selector, attribute }, key) => { - const elem = Array.from(document.querySelectorAll(selector)).find((elem) => mbe.contains(elem)); - if (!elem) return undefined; - - switch (attribute) { - case 'href': - const relativeHref = elem.getAttribute('href'); - 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 + + const mbeResults = MBEs.map((mbe) => omap( + lists, + (config) => { + const elem = findAllElements(config) + .find((elem) => mbe.contains(elem)); + + return elem ? getElementValue(elem, config.attribute) : undefined; + }, + (key) => key )) || []; - } + + // 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. @@ -262,45 +394,465 @@ function scrapableHeuristics(maxCountPerPage = 50, minArea = 20000, scrolls = 3, * @returns {Array.>} Array of arrays of scraped items, one sub-array per list */ 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) { - // Get all parent elements matching the listSelector - const parentElements = Array.from(document.querySelectorAll(listSelector)); + const parts = selector.split(/(?:>>|:>>)/).map(part => part.trim()); + let currentElement = rootElement; - // Iterate through each parent element - for (const parent of parentElements) { - if (scrapedData.length >= limit) break; - const record = {}; + for (let i = 0; i < parts.length; i++) { + if (!currentElement) return null; - // For each field, select the corresponding element within the parent - for (const [label, { selector, attribute }] of Object.entries(fields)) { - const fieldElement = parent.querySelector(selector); - - if (fieldElement) { - if (attribute === 'innerText') { - record[label] = fieldElement.innerText.trim(); - } else if (attribute === 'innerHTML') { - record[label] = fieldElement.innerHTML.trim(); - } 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); - } + // Handle iframe traversal + if (currentElement.tagName === 'IFRAME') { + try { + const iframeDoc = currentElement.contentDocument || currentElement.contentWindow.document; + currentElement = iframeDoc.querySelector(parts[i]); + continue; + } catch (e) { + console.warn('Cannot access iframe content:', e); + return null; + } } + + // 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, @@ -346,5 +898,5 @@ function scrapableHeuristics(maxCountPerPage = 50, minArea = 20000, scrolls = 3, return results; }; - + })(window); \ No newline at end of file diff --git a/maxun-core/src/interpret.ts b/maxun-core/src/interpret.ts index a7a5de47..e09ac5d5 100644 --- a/maxun-core/src/interpret.ts +++ b/maxun-core/src/interpret.ts @@ -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; }).catch(err => { 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 { 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 { 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(); + + 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.\ * \ @@ -130,45 +185,67 @@ export default class Interpreter extends EventEmitter { * @param workflow Current **initialized** workflow (array of where-what pairs). * @returns {PageState} State of the current page. */ - private async getState(page: Page, workflow: Workflow): Promise { + private async getState(page: Page, workflowCopy: Workflow, selectors: string[]): Promise { /** * 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). * @param selector Selector to be queried * @returns True if the targetted element is actionable, false otherwise. */ - const actionable = async (selector: string): Promise => { - try { - const proms = [ - page.isEnabled(selector, { timeout: 500 }), - page.isVisible(selector, { timeout: 500 }), - ]; + // const actionable = async (selector: string): Promise => { + // try { + // const proms = [ + // page.isEnabled(selector, { timeout: 10000 }), + // page.isVisible(selector, { timeout: 10000 }), + // ]; - return await Promise.all(proms).then((bools) => bools.every((x) => x)); - } catch (e) { - // log(e, Level.ERROR); - return false; - } - }; + // return await Promise.all(proms).then((bools) => bools.every((x) => x)); + // } catch (e) { + // // log(e, Level.ERROR); + // return false; + // } + // }; /** * 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( - selectors.map(async (selector) => { - if (await actionable(selector)) { - return [selector]; - } - return []; - }), + selectors.map(async (selector) => { + try { + await page.waitForSelector(selector, { state: 'attached' }); + return [selector]; + } catch (e) { + return []; + } + }), ).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 { - url: page.url(), + url, cookies: (await page.context().cookies([page.url()])) .reduce((p, cookie) => ( { @@ -326,7 +403,7 @@ export default class Interpreter extends EventEmitter { await this.options.serializableCallback(scrapeResults); }, - scrapeSchema: async (schema: Record) => { + scrapeSchema: async (schema: Record) => { await this.ensureScriptsLoaded(page); 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); await this.options.serializableCallback(mergedResult); + // await this.options.serializableCallback(scrapeResult); }, 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 (invokee[methodName])(...(args ?? [])); + } else { + await (invokee[methodName])(args); + } + }; + + for (const step of steps) { this.log(`Launching ${String(step.action)}`, Level.LOG); @@ -427,10 +515,24 @@ export default class Interpreter extends EventEmitter { invokee = invokee[level]; } - if (!step.args || Array.isArray(step.args)) { - await (invokee[methodName])(...(step.args ?? [])); + if (methodName === 'waitForLoadState') { + 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 { - await (invokee[methodName])(step.args); + await executeAction(invokee, methodName, step.args); } } @@ -475,6 +577,8 @@ export default class Interpreter extends EventEmitter { case 'clickNext': const pageResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); + // console.log("Page results:", pageResults); + // Filter out already scraped items const newResults = pageResults.filter(item => { const uniqueKey = JSON.stringify(item); @@ -482,9 +586,9 @@ export default class Interpreter extends EventEmitter { scrapedItems.add(uniqueKey); // Mark as scraped return true; }); - + allResults = allResults.concat(newResults); - + if (config.limit && allResults.length >= 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 } await Promise.all([ - nextButton.click(), + nextButton.dispatchEvent('click'), page.waitForNavigation({ waitUntil: 'networkidle' }) ]); @@ -510,7 +614,7 @@ export default class Interpreter extends EventEmitter { return allResults; } // 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 // After clicking 'Load More', scroll down to load more items await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); @@ -546,11 +650,66 @@ export default class Interpreter extends EventEmitter { 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) { + let workflowCopy: Workflow = JSON.parse(JSON.stringify(workflow)); + + workflowCopy = this.removeSpecialSelectors(workflowCopy); + // 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[] = []; + let selectors: string[] = []; let lastAction = null; + let actionId = -1 let repeatCount = 0; /** @@ -559,7 +718,7 @@ export default class Interpreter extends EventEmitter { * e.g. via `enqueueLinks`. */ 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 }] */ @@ -578,8 +737,11 @@ export default class Interpreter extends EventEmitter { } let pageState = {}; + let getStateTest = "Hello"; try { - pageState = await this.getState(p, workflow); + pageState = await this.getState(p, workflowCopy, selectors); + selectors = []; + console.log("Empty selectors:", selectors) } catch (e: any) { this.log('The browser has been closed.'); return; @@ -589,32 +751,52 @@ export default class Interpreter extends EventEmitter { this.log(`Current state is: \n${JSON.stringify(pageState, null, 2)}`, Level.WARN); } - const actionId = workflow.findIndex((step) => { - const isApplicable = this.applicable(step.where, pageState, usedActions); - console.log(`Where:`, step.where); - console.log(`Page state:`, pageState); - console.log(`Match result: ${isApplicable}`); - return isApplicable; - }); + // const actionId = workflow.findIndex((step) => { + // 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("-------------------------------------------------------------"); + // 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); if (action) { // action is matched if (this.options.debugChannel?.activeId) { this.options.debugChannel.activeId(actionId); } - + 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; } lastAction = action; - + try { + console.log("Carrying out:", action.what); await this.carryOutSteps(p, action.what); 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) { this.log(e, Level.ERROR); } @@ -643,6 +825,8 @@ export default class Interpreter extends EventEmitter { public async run(page: Page, params?: ParamType): Promise { this.log('Starting the workflow.', Level.LOG); const context = page.context(); + + page.setDefaultNavigationTimeout(100000); // Check proxy settings from context options const contextOptions = (context as any)._options; diff --git a/maxun-core/src/utils/concurrency.ts b/maxun-core/src/utils/concurrency.ts index e1ebb87b..56c15fd9 100644 --- a/maxun-core/src/utils/concurrency.ts +++ b/maxun-core/src/utils/concurrency.ts @@ -3,36 +3,36 @@ */ 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; /** - * Number of currently active workers. - */ + * Number of currently active workers. + */ activeWorkers: number = 0; /** - * Queue of jobs waiting to be completed. - */ + * Queue of jobs waiting to be completed. + */ private jobQueue: Function[] = []; /** - * "Resolve" callbacks of the waitForCompletion() promises. - */ + * "Resolve" callbacks of the waitForCompletion() promises. + */ private waiting: Function[] = []; /** - * Constructs a new instance of concurrency manager. - * @param {number} maxConcurrency Maximum number of workers running in parallel. - */ + * Constructs a new instance of concurrency manager. + * @param {number} maxConcurrency Maximum number of workers running in parallel. + */ constructor(maxConcurrency: number) { 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 { 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. \ - * The time of the job's execution depends on the concurrency manager itself - * (given a generous enough `maxConcurrency` value, it might be immediate, - * but this is not guaranteed). - * @param worker Async function to be executed (job to be processed). - */ + * 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 + * (given a generous enough `maxConcurrency` value, it might be immediate, + * but this is not guaranteed). + * @param worker Async function to be executed (job to be processed). + */ addJob(job: () => Promise): void { // console.debug("Adding a worker!"); this.jobQueue.push(job); @@ -72,11 +72,11 @@ export default class Concurrency { } /** - * Waits until there is no running nor waiting job. \ - * 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"). - * @returns Promise, resolved after there is no running/waiting worker. - */ + * Waits until there is no running nor waiting job. \ + * 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"). + * @returns Promise, resolved after there is no running/waiting worker. + */ waitForCompletion(): Promise { return new Promise((res) => { this.waiting.push(res); diff --git a/package.json b/package.json index 977daada..36062666 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "maxun", - "version": "0.0.4", + "version": "0.0.6", "author": "Maxun", "license": "AGPL-3.0-or-later", "dependencies": { @@ -36,13 +36,18 @@ "fortawesome": "^0.0.1-security", "google-auth-library": "^9.14.1", "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", "joi": "^17.6.0", "jsonwebtoken": "^9.0.2", "jwt-decode": "^4.0.0", + "lodash": "^4.17.21", "loglevel": "^1.8.0", "loglevel-plugin-remote": "^0.6.8", - "maxun-core": "^0.0.5", + "maxun-core": "^0.0.8", "minio": "^8.0.1", "moment-timezone": "^0.5.45", "node-cron": "^3.0.3", @@ -56,11 +61,13 @@ "react": "^18.0.0", "react-dom": "^18.0.0", "react-highlight": "0.15.0", + "react-i18next": "^15.1.3", "react-router-dom": "^6.26.1", "react-simple-code-editor": "^0.11.2", "react-transition-group": "^4.4.2", "sequelize": "^6.37.3", "sequelize-typescript": "^2.1.6", + "sharp": "^0.33.5", "socket.io": "^4.4.1", "socket.io-client": "^4.4.1", "styled-components": "^5.3.3", @@ -92,6 +99,7 @@ "@types/cookie-parser": "^1.4.7", "@types/express": "^4.17.13", "@types/js-cookie": "^3.0.6", + "@types/lodash": "^4.17.14", "@types/loglevel": "^1.6.3", "@types/node": "22.7.9", "@types/node-cron": "^3.0.11", diff --git a/perf/performance.ts b/perf/performance.ts new file mode 100644 index 00000000..c50ef850 --- /dev/null +++ b/perf/performance.ts @@ -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 + ): Promise { + 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; +} \ No newline at end of file diff --git a/public/locales/de.json b/public/locales/de.json new file mode 100644 index 00000000..b9b4185b --- /dev/null +++ b/public/locales/de.json @@ -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" + } +} diff --git a/public/locales/en.json b/public/locales/en.json new file mode 100644 index 00000000..bd8acce3 --- /dev/null +++ b/public/locales/en.json @@ -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" + } +} \ No newline at end of file diff --git a/public/locales/es.json b/public/locales/es.json new file mode 100644 index 00000000..94210880 --- /dev/null +++ b/public/locales/es.json @@ -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" + } +} \ No newline at end of file diff --git a/public/locales/ja.json b/public/locales/ja.json new file mode 100644 index 00000000..0bcba967 --- /dev/null +++ b/public/locales/ja.json @@ -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": "ドイツ語" + } +} diff --git a/public/locales/zh.json b/public/locales/zh.json new file mode 100644 index 00000000..a19fe439 --- /dev/null +++ b/public/locales/zh.json @@ -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": "德语" + } +} \ No newline at end of file diff --git a/server/Dockerfile b/server/Dockerfile index ae26e8eb..7e197fd3 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -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 WORKDIR /app @@ -7,13 +7,14 @@ WORKDIR /app COPY package*.json ./ COPY maxun-core ./maxun-core COPY src ./src +COPY public ./public COPY server ./server COPY tsconfig.json ./ COPY server/tsconfig.json ./server/ # COPY server/start.sh ./ # Install dependencies -RUN npm install +RUN npm install --legacy-peer-deps # Install Playwright browsers and dependencies RUN npx playwright install --with-deps chromium @@ -50,7 +51,7 @@ RUN apt-get update && apt-get install -y \ # RUN chmod +x ./start.sh # Expose the backend port -EXPOSE 8080 +EXPOSE ${BACKEND_PORT:-8080} # Start the backend using the start script CMD ["npm", "run", "server"] \ No newline at end of file diff --git a/server/src/api/record.ts b/server/src/api/record.ts index 5b33b12f..05560487 100644 --- a/server/src/api/record.ts +++ b/server/src/api/record.ts @@ -15,6 +15,8 @@ import { io, Socket } from "socket.io-client"; import { BinaryOutputService } from "../storage/mino"; import { AuthenticatedRequest } from "../routes/record" import {capture} from "../utils/analytics"; +import { Page } from "playwright"; +import { WorkflowFile } from "maxun-core"; chromium.use(stealthPlugin()); const formatRecording = (recordingData: any) => { @@ -533,6 +535,17 @@ function resetRecordingState(browserId: string, id: string) { 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) { try { const run = await Run.findOne({ where: { runId: id } }); @@ -560,13 +573,14 @@ async function executeRun(id: string) { throw new Error('Could not access browser'); } - const currentPage = await browser.getCurrentPage(); + let currentPage = await browser.getCurrentPage(); if (!currentPage) { throw new Error('Could not create a new page'); } + const workflow = AddGeneratedFlags(recording.recording); 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'); diff --git a/server/src/browser-management/classes/RemoteBrowser.ts b/server/src/browser-management/classes/RemoteBrowser.ts index 769787da..8ff4f601 100644 --- a/server/src/browser-management/classes/RemoteBrowser.ts +++ b/server/src/browser-management/classes/RemoteBrowser.ts @@ -9,14 +9,39 @@ import { chromium } from 'playwright-extra'; import stealthPlugin from 'puppeteer-extra-plugin-stealth'; import { PlaywrightBlocker } from '@cliqz/adblocker-playwright'; import fetch from 'cross-fetch'; +import { throttle } from 'lodash'; +import sharp from 'sharp'; import logger from '../../logger'; import { InterpreterSettings, RemoteBrowserOptions } from "../../types"; import { WorkflowGenerator } from "../../workflow-management/classes/Generator"; import { WorkflowInterpreter } from "../../workflow-management/classes/Interpreter"; import { getDecryptedProxyConfig } from '../../routes/proxy'; +import { getInjectableScript } from 'idcac-playwright'; + 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. @@ -65,6 +90,8 @@ export class RemoteBrowser { maxRepeats: 1, }; + private lastEmittedUrl: string | null = null; + /** * {@link WorkflowGenerator} instance specific to the remote browser. */ @@ -75,6 +102,11 @@ export class RemoteBrowser { */ 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 * assigns the socket instance everywhere. @@ -87,6 +119,117 @@ export class RemoteBrowser { 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 { + 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 => { + 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. * Must be called right after creating an instance of RemoteBrowser class. @@ -94,37 +237,17 @@ export class RemoteBrowser { * @returns {Promise} */ public initialize = async (userId: string): Promise => { - // 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 = (await options.browser.launch(launchOptions)); - - // console.log('Launch options after:', options.launchOptions) this.browser = (await chromium.launch({ 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); let proxyOptions: { server: string, username?: string, password?: string } = { server: '' }; @@ -140,7 +263,7 @@ export class RemoteBrowser { const contextOptions: any = { viewport: { height: 400, width: 900 }, // recordVideo: { dir: 'videos/' } - // Force reduced motion to prevent animation issues + // Force reduced motion to prevent animation issues reducedMotion: 'reduce', // Force JavaScript to be enabled javaScriptEnabled: true, @@ -149,7 +272,8 @@ export class RemoteBrowser { // Disable hardware acceleration forcedColors: 'none', isMobile: false, - hasTouch: false + hasTouch: false, + userAgent: this.getUserAgent(), }; if (proxyOptions.server) { @@ -159,26 +283,48 @@ export class RemoteBrowser { 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); + 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.on('framenavigated', (frame) => { - if (frame === this.currentPage?.mainFrame()) { - this.socket.emit('urlChanged', this.currentPage.url()); - } - }); + await this.setupPageEventListeners(this.currentPage); - // await this.currentPage.setExtraHTTPHeaders({ - // '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.fromPrebuiltAdsAndTracking(fetch); - await blocker.enableBlockingInPage(this.currentPage); - this.client = await this.currentPage.context().newCDPSession(this.currentPage); - await blocker.disableBlockingInPage(this.currentPage); + try { + const blocker = await PlaywrightBlocker.fromLists(fetch, ['https://easylist.to/easylist/easylist.txt']); + await blocker.enableBlockingInPage(this.currentPage); + this.client = await this.currentPage.context().newCDPSession(this.currentPage); + await blocker.disableBlockingInPage(this.currentPage); + console.log('Adblocker initialized'); + } 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; } this.client.on('Page.screencastFrame', ({ data: base64, sessionId }) => { - this.emitScreenshot(base64) + this.emitScreenshot(Buffer.from(base64, 'base64')) setTimeout(async () => { try { if (!this.client) { @@ -262,16 +408,49 @@ export class RemoteBrowser { * If an interpretation was running it will be stopped. * @returns {Promise} */ - public switchOff = async (): Promise => { - await this.interpreter.stopInterpretation(); - if (this.browser) { - await this.stopScreencast(); - await this.browser.close(); - } else { - logger.log('error', 'Browser wasn\'t initialized'); - logger.log('error', 'Switching off the browser failed'); + public async switchOff(): Promise { + try { + await this.interpreter.stopInterpretation(); + + if (this.screencastInterval) { + clearInterval(this.screencastInterval); + } + + 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 { + 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. @@ -281,7 +460,7 @@ export class RemoteBrowser { try { const screenshot = await this.currentPage?.screenshot(); if (screenshot) { - this.emitScreenshot(screenshot.toString('base64')); + this.emitScreenshot(screenshot); } } catch (e) { const { message } = e as Error; @@ -370,11 +549,7 @@ export class RemoteBrowser { await this.stopScreencast(); this.currentPage = page; - this.currentPage.on('framenavigated', (frame) => { - if (frame === this.currentPage?.mainFrame()) { - this.socket.emit('urlChanged', this.currentPage.url()); - } - }); + await this.setupPageEventListeners(this.currentPage); //await this.currentPage.setViewportSize({ height: 400, width: 900 }) this.client = await this.currentPage.context().newCDPSession(this.currentPage); @@ -402,14 +577,8 @@ export class RemoteBrowser { await this.currentPage?.close(); this.currentPage = newPage; if (this.currentPage) { - this.currentPage.on('framenavigated', (frame) => { - if (frame === this.currentPage?.mainFrame()) { - this.socket.emit('urlChanged', this.currentPage.url()); - } - }); - // this.currentPage.on('load', (page) => { - // this.socket.emit('urlChanged', page.url()); - // }) + await this.setupPageEventListeners(this.currentPage); + this.client = await this.currentPage.context().newCDPSession(this.currentPage); await this.subscribeToScreencast(); } else { @@ -423,37 +592,85 @@ export class RemoteBrowser { * Should be called only once after the browser is fully initialized. * @returns {Promise} */ - private startScreencast = async (): Promise => { + private async startScreencast(): Promise { if (!this.client) { - logger.log('warn', 'client is not initialized'); + logger.warn('Client is not initialized'); return; } - await this.client.send('Page.startScreencast', { format: 'jpeg', quality: 75 }); - logger.log('info', `Browser started with screencasting a page.`); - }; - /** - * Unsubscribes the current page from the screencast session. - * @returns {Promise} - */ - private stopScreencast = async (): Promise => { - if (!this.client) { - logger.log('error', 'client is not initialized'); - logger.log('error', 'Screencast stop failed'); - } else { - await this.client.send('Page.stopScreencast'); - logger.log('info', `Browser stopped with screencasting.`); + try { + await this.client.send('Page.startScreencast', { + format: SCREENCAST_CONFIG.format, + }); + + // Set up screencast frame handler + this.client.on('Page.screencastFrame', async ({ data, sessionId }) => { + try { + const buffer = Buffer.from(data, 'base64'); + await this.emitScreenshot(buffer); + 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 { + 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. * @param payload the screenshot binary data * @returns void */ - private emitScreenshot = (payload: any): void => { - const dataWithMimeType = ('data:image/jpeg;base64,').concat(payload); - this.socket.emit('screencast', dataWithMimeType); - logger.log('debug', `Screenshot emitted`); + private emitScreenshot = async (payload: Buffer): Promise => { + if (this.isProcessingScreenshot) { + if (this.screenshotQueue.length < SCREENCAST_CONFIG.maxQueueSize) { + 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); + } + } + } }; + } diff --git a/server/src/browser-management/inputHandlers.ts b/server/src/browser-management/inputHandlers.ts index d6902b3f..bf365053 100644 --- a/server/src/browser-management/inputHandlers.ts +++ b/server/src/browser-management/inputHandlers.ts @@ -6,7 +6,7 @@ import { Socket } from 'socket.io'; import logger from "../logger"; -import { Coordinates, ScrollDeltas, KeyboardInput } from '../types'; +import { Coordinates, ScrollDeltas, KeyboardInput, DatePickerEventData } from '../types'; import { browserPool } from "../server"; import { WorkflowGenerator } from "../workflow-management/classes/Generator"; import { Page } from "playwright"; @@ -223,6 +223,53 @@ const handleKeydown = async (generator: WorkflowGenerator, page: Page, { key, co 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. * @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:back", onGoBack); 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); }; diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index cc3d879b..084edb4b 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -52,7 +52,7 @@ router.post("/register", async (req, res) => { userId: user.id, registeredAt: new Date().toISOString(), }); - console.log(`User registered - ${user.email}`); + console.log(`User registered`); res.json(user); } catch (error: any) { console.log(`Could not register user - ${error}`); diff --git a/server/src/routes/storage.ts b/server/src/routes/storage.ts index d1f648f8..ddadf240 100644 --- a/server/src/routes/storage.ts +++ b/server/src/routes/storage.ts @@ -18,6 +18,8 @@ import { AuthenticatedRequest } from './record'; import { computeNextRun } from '../utils/schedule'; import { capture } from "../utils/analytics"; import { tryCatch } from 'bullmq'; +import { WorkflowFile } from 'maxun-core'; +import { Page } from 'playwright'; chromium.use(stealthPlugin()); 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. */ @@ -443,10 +456,11 @@ router.post('/runs/run/:id', requireSignIn, async (req: AuthenticatedRequest, re // interpret the run in active browser const browser = browserPool.getRemoteBrowser(plainRun.browserId); - const currentPage = browser?.getCurrentPage(); + let currentPage = browser?.getCurrentPage(); if (browser && currentPage) { + const workflow = AddGeneratedFlags(recording.recording); 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 uploadedBinaryOutput = await binaryOutputService.uploadAndStoreBinaryOutput(run, interpretationInfo.binaryOutput); await destroyRemoteBrowser(plainRun.browserId); diff --git a/server/src/swagger/config.ts b/server/src/swagger/config.ts index c9c12210..bf115ed1 100644 --- a/server/src/swagger/config.ts +++ b/server/src/swagger/config.ts @@ -1,13 +1,25 @@ import swaggerJSDoc from 'swagger-jsdoc'; 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 = { definition: { openapi: '3.0.0', info: { - title: 'Maxun API Documentation', + title: 'Website to API', 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: { securitySchemes: { @@ -15,7 +27,8 @@ const options = { type: 'apiKey', in: 'header', 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); diff --git a/server/src/types/index.ts b/server/src/types/index.ts index 4fe761f1..75aac802 100644 --- a/server/src/types/index.ts +++ b/server/src/types/index.ts @@ -20,6 +20,16 @@ export interface Coordinates { 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. * @category Types @@ -119,6 +129,17 @@ export interface BaseActionInfo { 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. * @category Types @@ -133,6 +154,8 @@ export interface Selectors { hrefSelector: string|null; accessibilitySelector: string|null; formSelector: string|null; + iframeSelector: IframeSelector|null; + shadowSelector: ShadowSelector|null; } /** @@ -146,7 +169,7 @@ export interface BaseAction extends BaseActionInfo{ associatedActions: ActionType[]; inputType: string | undefined; value: string | undefined; - selectors: { [key: string]: string | null }; + selectors: Selectors; timestamp: number; isPassword: boolean; /** diff --git a/server/src/workflow-management/classes/Generator.ts b/server/src/workflow-management/classes/Generator.ts index c9dc3385..d1bccbe4 100644 --- a/server/src/workflow-management/classes/Generator.ts +++ b/server/src/workflow-management/classes/Generator.ts @@ -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 logger from "../../logger"; import { Socket } from "socket.io"; @@ -140,19 +140,22 @@ export class WorkflowGenerator { socket.on('decision', async ({ pair, actionType, decision }) => { const id = browserPool.getActiveBrowserId(); if (id) { - const activeBrowser = browserPool.getRemoteBrowser(id); - const currentPage = activeBrowser?.getCurrentPage(); - if (decision) { + // const activeBrowser = browserPool.getRemoteBrowser(id); + // const currentPage = activeBrowser?.getCurrentPage(); + if (!decision) { switch (actionType) { 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; default: break; } } - if (currentPage) { - await this.addPairToWorkflowAndNotifyClient(pair, currentPage); - } + // if (currentPage) { + // await this.addPairToWorkflowAndNotifyClient(pair, currentPage); + // } } }) socket.on('updatePair', (data) => { @@ -252,6 +255,85 @@ export class WorkflowGenerator { 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. @@ -263,6 +345,81 @@ export class WorkflowGenerator { let where: WhereWhatPair["where"] = { url: this.getBestUrl(page.url()) }; const selector = await this.generateSelector(page, coordinates, ActionType.Click); 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(' { + const valueMatch = optionHtml.match(/value="([^"]*)"/); + const disabledMatch = optionHtml.includes('disabled="disabled"'); + const selectedMatch = optionHtml.includes('selected="selected"'); + + // Extract text content between > and + const textMatch = optionHtml.match(/>([^<]*)} */ 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) - ? await getNonUniqueSelectors(page, coordinates) + ? await getNonUniqueSelectors(page, coordinates, this.listSelector) : await getSelectors(page, coordinates); const bestSelector = getBestSelectorForAction( @@ -570,21 +726,30 @@ export class WorkflowGenerator { * @returns {Promise} */ 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 elementInfo = await getElementInformation(page, coordinates); + const elementInfo = await getElementInformation(page, coordinates, this.listSelector, this.getList); 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.listSelector !== '') { const childSelectors = await getChildSelectors(page, this.listSelector || ''); - this.socket.emit('highlighter', { rect, selector: displaySelector, elementInfo, childSelectors }) - console.log(`Child Selectors: ${childSelectors}`) - console.log(`Parent Selector: ${this.listSelector}`) + this.socket.emit('highlighter', { ...highlighterData, childSelectors }) } else { - this.socket.emit('highlighter', { rect, selector: displaySelector, elementInfo }); + this.socket.emit('highlighter', { ...highlighterData }); } } else { - this.socket.emit('highlighter', { rect, selector: displaySelector, elementInfo }); + this.socket.emit('highlighter', { ...highlighterData }); } } } diff --git a/server/src/workflow-management/classes/Interpreter.ts b/server/src/workflow-management/classes/Interpreter.ts index d53259b7..b982b172 100644 --- a/server/src/workflow-management/classes/Interpreter.ts +++ b/server/src/workflow-management/classes/Interpreter.ts @@ -244,7 +244,12 @@ export class WorkflowInterpreter { * @param page The page instance used to interact with the browser. * @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; delete settings.params; @@ -262,7 +267,7 @@ export class WorkflowInterpreter { this.socket.emit('debugMessage', msg) }, }, - serializableCallback: (data: string) => { + serializableCallback: (data: any) => { this.serializableData.push(data); this.socket.emit('serializableCallback', data); }, @@ -275,6 +280,23 @@ export class WorkflowInterpreter { const interpreter = new Interpreter(decryptedWorkflow, options); 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 lastArray = this.serializableData.length > 1 diff --git a/server/src/workflow-management/scheduler/index.ts b/server/src/workflow-management/scheduler/index.ts index 02ca905f..ade7d969 100644 --- a/server/src/workflow-management/scheduler/index.ts +++ b/server/src/workflow-management/scheduler/index.ts @@ -11,6 +11,8 @@ import Run from "../../models/Run"; import { getDecryptedProxyConfig } from "../../routes/proxy"; import { BinaryOutputService } from "../../storage/mino"; import { capture } from "../../utils/analytics"; +import { WorkflowFile } from "maxun-core"; +import { Page } from "playwright"; chromium.use(stealthPlugin()); async function createWorkflowAndStoreMetadata(id: string, userId: string) { @@ -71,7 +73,7 @@ async function createWorkflowAndStoreMetadata(id: string, userId: string) { } catch (e) { const { message } = e as Error; 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 { success: false, 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) { try { const run = await Run.findOne({ where: { runId: id } }); @@ -106,13 +119,15 @@ async function executeRun(id: string) { throw new Error('Could not access browser'); } - const currentPage = await browser.getCurrentPage(); + let currentPage = await browser.getCurrentPage(); if (!currentPage) { throw new Error('Could not create a new page'); } + const workflow = AddGeneratedFlags(recording.recording); 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 uploadedBinaryOutput = await binaryOutputService.uploadAndStoreBinaryOutput(run, interpretationInfo.binaryOutput); diff --git a/server/src/workflow-management/selector.ts b/server/src/workflow-management/selector.ts index 193de891..8a9096ec 100644 --- a/server/src/workflow-management/selector.ts +++ b/server/src/workflow-management/selector.ts @@ -1,60 +1,10 @@ import { Page } from "playwright"; -import { Action, ActionType, Coordinates, TagName } from "../types"; +import { Coordinates } from "../types"; import { WhereWhatPair, WorkflowFile } from "maxun-core"; import logger from "../logger"; -import { getBestSelectorForAction } from "./utils"; - -/*TODO: -1. Handle TS errors (here we definetly know better) -2. Add pending function descriptions + thought process (esp. selector generation) -*/ type Workflow = WorkflowFile["workflow"]; -/** - * Returns a {@link Rectangle} object representing - * the coordinates, width, height and corner points of the element. - * If an element is not found, returns null. - * @param page The page instance. - * @param coordinates Coordinates of an element. - * @category WorkflowManagement-Selectors - * @returns {Promise} - */ -export const getRect = async (page: Page, coordinates: Coordinates) => { - try { - const rect = await page.evaluate( - async ({ x, y }) => { - const el = document.elementFromPoint(x, y) as HTMLElement; - if (el) { - const { parentElement } = el; - // Match the logic in recorder.ts for link clicks - const element = parentElement?.tagName === 'A' ? parentElement : el; - const rectangle = element?.getBoundingClientRect(); - // @ts-ignore - if (rectangle) { - return { - x: rectangle.x, - y: rectangle.y, - width: rectangle.width, - height: rectangle.height, - top: rectangle.top, - right: rectangle.right, - bottom: rectangle.bottom, - left: rectangle.left, - }; - } - } - }, - { x: coordinates.x, y: coordinates.y }, - ); - return rect; - } catch (error) { - const { message, stack } = error as Error; - logger.log('error', `Error while retrieving selector: ${message}`); - logger.log('error', `Stack: ${stack}`); - } -} - /** * Checks the basic info about an element and returns a {@link BaseActionInfo} object. * If the element is not found, returns undefined. @@ -65,61 +15,406 @@ export const getRect = async (page: Page, coordinates: Coordinates) => { */ export const getElementInformation = async ( page: Page, - coordinates: Coordinates + coordinates: Coordinates, + listSelector: string, + getList: boolean ) => { try { - const elementInfo = await page.evaluate( - async ({ x, y }) => { - const el = document.elementFromPoint(x, y) as HTMLElement; - if (el) { - const { parentElement } = el; - const element = parentElement?.tagName === 'A' ? parentElement : el; + if (!getList || listSelector !== '') { + const elementInfo = await page.evaluate( + async ({ x, y }) => { + const getDeepestElementFromPoint = (x: number, y: number): HTMLElement | null => { + // First, get the element at the clicked coordinates in the main document + let element = document.elementFromPoint(x, y) as HTMLElement; + if (!element) return null; - let info: { - tagName: string; - hasOnlyText?: boolean; - innerText?: string; - url?: string; - imageUrl?: string; - attributes?: Record; - innerHTML?: string; - outerHTML?: string; - } = { - tagName: element?.tagName ?? '', + // Track the deepest element found + let deepestElement = element; + + // Function to traverse shadow DOM + const traverseShadowDOM = (element: HTMLElement): HTMLElement => { + let current = element; + let shadowRoot = current.shadowRoot; + let deepest = current; + + while (shadowRoot) { + const shadowElement = shadowRoot.elementFromPoint(x, y) as HTMLElement; + if (!shadowElement || shadowElement === current) break; + + deepest = shadowElement; + current = shadowElement; + shadowRoot = current.shadowRoot; + } + + return deepest; + }; + + // Handle iframe traversal + if (element.tagName === 'IFRAME') { + let currentIframe = element as HTMLIFrameElement; + + while (currentIframe) { + try { + // Convert coordinates to iframe's local space + const iframeRect = currentIframe.getBoundingClientRect(); + const iframeX = x - iframeRect.left; + const iframeY = y - iframeRect.top; + + const iframeDocument = currentIframe.contentDocument || currentIframe.contentWindow?.document; + if (!iframeDocument) break; + + const iframeElement = iframeDocument.elementFromPoint(iframeX, iframeY) as HTMLElement; + if (!iframeElement) break; + + // Update deepest element and check for shadow DOM + deepestElement = traverseShadowDOM(iframeElement); + + // Continue traversing if we found another iframe + if (iframeElement.tagName === 'IFRAME') { + currentIframe = iframeElement as HTMLIFrameElement; + } else { + break; + } + } catch (error) { + console.warn('Cannot access iframe content:', error); + break; + } + } + } else { + // If not an iframe, check for shadow DOM + deepestElement = traverseShadowDOM(element); + } + + return deepestElement; }; - if (element) { - info.attributes = Array.from(element.attributes).reduce( - (acc, attr) => { - acc[attr.name] = attr.value; - return acc; - }, - {} as Record - ); + // Get the element and its iframe path + const el = getDeepestElementFromPoint(x, y); + + if (el) { + // Handle potential anchor parent + const { parentElement } = el; + const targetElement = parentElement?.tagName === 'A' ? parentElement : el; + + // Get containing context information + const ownerDocument = targetElement.ownerDocument; + const frameElement = ownerDocument?.defaultView?.frameElement as HTMLIFrameElement; + const isIframeContent = Boolean(frameElement); + + // Get the containing shadow root if any + const containingShadowRoot = targetElement.getRootNode() as ShadowRoot; + const isShadowRoot = containingShadowRoot instanceof ShadowRoot; + + let info: { + tagName: string; + hasOnlyText?: boolean; + innerText?: string; + url?: string; + imageUrl?: string; + attributes?: Record; + innerHTML?: string; + outerHTML?: string; + isIframeContent?: boolean; + iframeURL?: string; + iframeIndex?: number; + frameHierarchy?: string[]; + isShadowRoot?: boolean; + shadowRootMode?: string; + shadowRootContent?: string; + } = { + tagName: targetElement?.tagName ?? '', + isIframeContent, + isShadowRoot + }; + + if (isIframeContent) { + // Include iframe specific information + info.iframeURL = frameElement.src; + + // Calculate the frame's position in the hierarchy + let currentFrame = frameElement; + const frameHierarchy: string[] = []; + let frameIndex = 0; + + while (currentFrame) { + // Store the frame's identifier (src, id, or index) + frameHierarchy.unshift( + currentFrame.id || + currentFrame.src || + `iframe[${frameIndex}]` + ); + + // Move up to parent frame if it exists + const parentDoc = currentFrame.ownerDocument; + currentFrame = parentDoc?.defaultView?.frameElement as HTMLIFrameElement; + frameIndex++; + } + + info.frameHierarchy = frameHierarchy; + info.iframeIndex = frameIndex - 1; // Adjust for 0-based index + } + + if (isShadowRoot) { + // Include shadow root specific information + info.shadowRootMode = containingShadowRoot.mode; + info.shadowRootContent = containingShadowRoot.innerHTML; + } + + // Collect element attributes and properties + if (targetElement) { + info.attributes = Array.from(targetElement.attributes).reduce( + (acc, attr) => { + acc[attr.name] = attr.value; + return acc; + }, + {} as Record + ); + + if (targetElement.tagName === 'A') { + info.url = (targetElement as HTMLAnchorElement).href; + info.innerText = targetElement.textContent ?? ''; + } else if (targetElement.tagName === 'IMG') { + info.imageUrl = (targetElement as HTMLImageElement).src; + } else if (targetElement?.tagName === 'SELECT') { + const selectElement = targetElement as HTMLSelectElement; + info.innerText = selectElement.options[selectElement.selectedIndex]?.text ?? ''; + info.attributes = { + ...info.attributes, + selectedValue: selectElement.value, + }; + } else if (targetElement?.tagName === 'INPUT' && (targetElement as HTMLInputElement).type === 'time' || (targetElement as HTMLInputElement).type === 'date') { + info.innerText = (targetElement as HTMLInputElement).value; + } + else { + info.hasOnlyText = targetElement.children.length === 0 && + (targetElement.textContent !== null && + targetElement.textContent.trim().length > 0); + info.innerText = targetElement.textContent ?? ''; + } + + info.innerHTML = targetElement.innerHTML; + info.outerHTML = targetElement.outerHTML; + } + + return info; } + return null; + }, + { x: coordinates.x, y: coordinates.y } + ); + return elementInfo; + } else { + const elementInfo = await page.evaluate( + async ({ x, y }) => { + // Enhanced helper function to get element from point including shadow DOM + const getDeepestElementFromPoint = (x: number, y: number): HTMLElement | null => { + // First, get the element at the clicked coordinates in the main document + let element = document.elementFromPoint(x, y) as HTMLElement; + if (!element) return null; - // Gather specific information based on the tag - if (element?.tagName === 'A') { - info.url = (element as HTMLAnchorElement).href; - info.innerText = element.innerText ?? ''; - } else if (element?.tagName === 'IMG') { - info.imageUrl = (element as HTMLImageElement).src; - } else { - info.hasOnlyText = element?.children?.length === 0 && - element?.innerText?.length > 0; - info.innerText = element?.innerText ?? ''; + // Track the deepest element found + let deepestElement = element; + + // Function to traverse shadow DOM + const traverseShadowDOM = (element: HTMLElement): HTMLElement => { + let current = element; + let shadowRoot = current.shadowRoot; + let deepest = current; + + while (shadowRoot) { + const shadowElement = shadowRoot.elementFromPoint(x, y) as HTMLElement; + if (!shadowElement || shadowElement === current) break; + + deepest = shadowElement; + current = shadowElement; + shadowRoot = current.shadowRoot; + } + + return deepest; + }; + + // Handle iframe traversal + if (element.tagName === 'IFRAME') { + let currentIframe = element as HTMLIFrameElement; + + while (currentIframe) { + try { + // Convert coordinates to iframe's local space + const iframeRect = currentIframe.getBoundingClientRect(); + const iframeX = x - iframeRect.left; + const iframeY = y - iframeRect.top; + + const iframeDocument = currentIframe.contentDocument || currentIframe.contentWindow?.document; + if (!iframeDocument) break; + + const iframeElement = iframeDocument.elementFromPoint(iframeX, iframeY) as HTMLElement; + if (!iframeElement) break; + + // Update deepest element and check for shadow DOM + deepestElement = traverseShadowDOM(iframeElement); + + // Continue traversing if we found another iframe + if (iframeElement.tagName === 'IFRAME') { + currentIframe = iframeElement as HTMLIFrameElement; + } else { + break; + } + } catch (error) { + console.warn('Cannot access iframe content:', error); + break; + } + } + } else { + // If not an iframe, check for shadow DOM + deepestElement = traverseShadowDOM(element); + } + + return deepestElement; + }; + + const originalEl = getDeepestElementFromPoint(x, y); + if (originalEl) { + let element = originalEl; + + if (element.tagName === 'TD' || element.tagName === 'TH') { + const tableParent = element.closest('table'); + if (tableParent) { + element = tableParent; + } + } + + if (element.tagName !== 'TABLE') { + while (element.parentElement) { + if (element.tagName.toLowerCase() === 'body' || + element.tagName.toLowerCase() === 'html') { + break; + } + + const parentRect = element.parentElement.getBoundingClientRect(); + const childRect = element.getBoundingClientRect(); + + const fullyContained = + parentRect.left <= childRect.left && + parentRect.right >= childRect.right && + parentRect.top <= childRect.top && + parentRect.bottom >= childRect.bottom; + + const significantOverlap = + (childRect.width * childRect.height) / + (parentRect.width * parentRect.height) > 0.5; + + if (fullyContained && significantOverlap) { + const nextParent = element.parentElement; + if (nextParent.tagName.toLowerCase() !== 'body' && + nextParent.tagName.toLowerCase() !== 'html') { + element = nextParent; + } else { + break; + } + } else { + break; + } + } + } + + const ownerDocument = element.ownerDocument; + const frameElement = ownerDocument?.defaultView?.frameElement as HTMLIFrameElement; + const isIframeContent = Boolean(frameElement); + + const containingShadowRoot = element.getRootNode() as ShadowRoot; + const isShadowRoot = containingShadowRoot instanceof ShadowRoot; + + let info: { + tagName: string; + hasOnlyText?: boolean; + innerText?: string; + url?: string; + imageUrl?: string; + attributes?: Record; + innerHTML?: string; + outerHTML?: string; + isIframeContent?: boolean; + iframeURL?: string; + iframeIndex?: number; + frameHierarchy?: string[]; + isShadowRoot?: boolean; + shadowRootMode?: string; + shadowRootContent?: string; + } = { + tagName: element?.tagName ?? '', + isIframeContent, + isShadowRoot + }; + + if (isIframeContent) { + // Include iframe specific information + info.iframeURL = frameElement.src; + + // Calculate the frame's position in the hierarchy + let currentFrame = frameElement; + const frameHierarchy: string[] = []; + let frameIndex = 0; + + while (currentFrame) { + // Store the frame's identifier (src, id, or index) + frameHierarchy.unshift( + currentFrame.id || + currentFrame.src || + `iframe[${frameIndex}]` + ); + + // Move up to parent frame if it exists + const parentDoc = currentFrame.ownerDocument; + currentFrame = parentDoc?.defaultView?.frameElement as HTMLIFrameElement; + frameIndex++; + } + + info.frameHierarchy = frameHierarchy; + info.iframeIndex = frameIndex - 1; // Adjust for 0-based index + }; + + if (isShadowRoot) { + // Include shadow root specific information + info.shadowRootMode = containingShadowRoot.mode; + info.shadowRootContent = containingShadowRoot.innerHTML; + } + + if (element) { + // Get attributes including those from shadow DOM context + info.attributes = Array.from(element.attributes).reduce( + (acc, attr) => { + acc[attr.name] = attr.value; + return acc; + }, + {} as Record + ); + + // Handle specific element types + if (element.tagName === 'A') { + info.url = (element as HTMLAnchorElement).href; + info.innerText = element.textContent ?? ''; + } else if (element.tagName === 'IMG') { + info.imageUrl = (element as HTMLImageElement).src; + } else { + // Handle text content with proper null checking + info.hasOnlyText = element.children.length === 0 && + (element.textContent !== null && + element.textContent.trim().length > 0); + info.innerText = element.textContent ?? ''; + } + + info.innerHTML = element.innerHTML; + info.outerHTML = element.outerHTML; + } + + return info; } - - info.innerHTML = element.innerHTML; - info.outerHTML = element.outerHTML; - - return info; - } - return null; - }, - { x: coordinates.x, y: coordinates.y }, - ); - return elementInfo; + return null; + }, + { x: coordinates.x, y: coordinates.y }, + ); + return elementInfo; + } } catch (error) { const { message, stack } = error as Error; console.error('Error while retrieving selector:', message); @@ -127,6 +422,313 @@ export const getElementInformation = async ( } }; +export const getRect = async (page: Page, coordinates: Coordinates, listSelector: string, getList: boolean) => { + try { + if (!getList || listSelector !== '') { + const rect = await page.evaluate( + async ({ x, y }) => { + // Enhanced helper function to get element from point including iframes + const getDeepestElementFromPoint = (x: number, y: number): HTMLElement | null => { + // First, get the element at the clicked coordinates in the main document + let element = document.elementFromPoint(x, y) as HTMLElement; + if (!element) return null; + + // Track the deepest element found + let deepestElement = element; + + // Function to traverse shadow DOM + const traverseShadowDOM = (element: HTMLElement): HTMLElement => { + let current = element; + let shadowRoot = current.shadowRoot; + let deepest = current; + + while (shadowRoot) { + const shadowElement = shadowRoot.elementFromPoint(x, y) as HTMLElement; + if (!shadowElement || shadowElement === current) break; + + deepest = shadowElement; + current = shadowElement; + shadowRoot = current.shadowRoot; + } + + return deepest; + }; + + // Handle iframe traversal + if (element.tagName === 'IFRAME') { + let currentIframe = element as HTMLIFrameElement; + + while (currentIframe) { + try { + // Convert coordinates to iframe's local space + const iframeRect = currentIframe.getBoundingClientRect(); + const iframeX = x - iframeRect.left; + const iframeY = y - iframeRect.top; + + const iframeDocument = currentIframe.contentDocument || currentIframe.contentWindow?.document; + if (!iframeDocument) break; + + const iframeElement = iframeDocument.elementFromPoint(iframeX, iframeY) as HTMLElement; + if (!iframeElement) break; + + // Update deepest element and check for shadow DOM + deepestElement = traverseShadowDOM(iframeElement); + + // Continue traversing if we found another iframe + if (iframeElement.tagName === 'IFRAME') { + currentIframe = iframeElement as HTMLIFrameElement; + } else { + break; + } + } catch (error) { + console.warn('Cannot access iframe content:', error); + break; + } + } + } else { + // If not an iframe, check for shadow DOM + deepestElement = traverseShadowDOM(element); + } + + return deepestElement; + }; + + const el = getDeepestElementFromPoint(x, y); + if (el) { + const { parentElement } = el; + const element = parentElement?.tagName === 'A' ? parentElement : el; + const rectangle = element?.getBoundingClientRect(); + if (rectangle) { + const createRectObject = (rect: DOMRect) => ({ + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + toJSON() { + return { + x: this.x, + y: this.y, + width: this.width, + height: this.height, + top: this.top, + right: this.right, + bottom: this.bottom, + left: this.left + }; + } + }); + + // For elements inside iframes, adjust coordinates relative to the top window + let adjustedRect = createRectObject(rectangle); + let currentWindow = element.ownerDocument.defaultView; + + while (currentWindow !== window.top) { + const frameElement = currentWindow?.frameElement as HTMLIFrameElement; + if (!frameElement) break; + + const frameRect = frameElement.getBoundingClientRect(); + adjustedRect = createRectObject({ + x: adjustedRect.x + frameRect.x, + y: adjustedRect.y + frameRect.y, + width: adjustedRect.width, + height: adjustedRect.height, + top: adjustedRect.top + frameRect.top, + right: adjustedRect.right + frameRect.left, + bottom: adjustedRect.bottom + frameRect.top, + left: adjustedRect.left + frameRect.left, + } as DOMRect); + + currentWindow = frameElement.ownerDocument.defaultView; + } + + return adjustedRect; + } + } + return null; + }, + { x: coordinates.x, y: coordinates.y } + ); + return rect; + } else { + const rect = await page.evaluate( + async ({ x, y }) => { + const getDeepestElementFromPoint = (x: number, y: number): HTMLElement | null => { + // First, get the element at the clicked coordinates in the main document + let element = document.elementFromPoint(x, y) as HTMLElement; + if (!element) return null; + + // Track the deepest element found + let deepestElement = element; + + // Function to traverse shadow DOM + const traverseShadowDOM = (element: HTMLElement): HTMLElement => { + let current = element; + let shadowRoot = current.shadowRoot; + let deepest = current; + + while (shadowRoot) { + const shadowElement = shadowRoot.elementFromPoint(x, y) as HTMLElement; + if (!shadowElement || shadowElement === current) break; + + deepest = shadowElement; + current = shadowElement; + shadowRoot = current.shadowRoot; + } + + return deepest; + }; + + // Handle iframe traversal + if (element.tagName === 'IFRAME') { + let currentIframe = element as HTMLIFrameElement; + + while (currentIframe) { + try { + // Convert coordinates to iframe's local space + const iframeRect = currentIframe.getBoundingClientRect(); + const iframeX = x - iframeRect.left; + const iframeY = y - iframeRect.top; + + const iframeDocument = currentIframe.contentDocument || currentIframe.contentWindow?.document; + if (!iframeDocument) break; + + const iframeElement = iframeDocument.elementFromPoint(iframeX, iframeY) as HTMLElement; + if (!iframeElement) break; + + // Update deepest element and check for shadow DOM + deepestElement = traverseShadowDOM(iframeElement); + + // Continue traversing if we found another iframe + if (iframeElement.tagName === 'IFRAME') { + currentIframe = iframeElement as HTMLIFrameElement; + } else { + break; + } + } catch (error) { + console.warn('Cannot access iframe content:', error); + break; + } + } + } else { + // If not an iframe, check for shadow DOM + deepestElement = traverseShadowDOM(element); + } + + return deepestElement; + }; + + const originalEl = getDeepestElementFromPoint(x, y); + if (originalEl) { + let element = originalEl; + + if (element.tagName === 'TD' || element.tagName === 'TH') { + const tableParent = element.closest('table'); + if (tableParent) { + element = tableParent; + } + } + + if (element.tagName !== 'TABLE') { + while (element.parentElement) { + if (element.tagName.toLowerCase() === 'body' || + element.tagName.toLowerCase() === 'html') { + break; + } + + const parentRect = element.parentElement.getBoundingClientRect(); + const childRect = element.getBoundingClientRect(); + + const fullyContained = + parentRect.left <= childRect.left && + parentRect.right >= childRect.right && + parentRect.top <= childRect.top && + parentRect.bottom >= childRect.bottom; + + const significantOverlap = + (childRect.width * childRect.height) / + (parentRect.width * parentRect.height) > 0.5; + + if (fullyContained && significantOverlap) { + const nextParent = element.parentElement; + if (nextParent.tagName.toLowerCase() !== 'body' && + nextParent.tagName.toLowerCase() !== 'html') { + element = nextParent; + } else { + break; + } + } else { + break; + } + } + } + + const rectangle = element?.getBoundingClientRect(); + if (rectangle) { + const createRectObject = (rect: DOMRect) => ({ + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + toJSON() { + return { + x: this.x, + y: this.y, + width: this.width, + height: this.height, + top: this.top, + right: this.right, + bottom: this.bottom, + left: this.left + }; + } + }); + + // Same coordinate adjustment for iframe elements as above + let adjustedRect = createRectObject(rectangle); + let currentWindow = element.ownerDocument.defaultView; + + while (currentWindow !== window.top) { + const frameElement = currentWindow?.frameElement as HTMLIFrameElement; + if (!frameElement) break; + + const frameRect = frameElement.getBoundingClientRect(); + adjustedRect = createRectObject({ + x: adjustedRect.x + frameRect.x, + y: adjustedRect.y + frameRect.y, + width: adjustedRect.width, + height: adjustedRect.height, + top: adjustedRect.top + frameRect.top, + right: adjustedRect.right + frameRect.left, + bottom: adjustedRect.bottom + frameRect.top, + left: adjustedRect.left + frameRect.left, + } as DOMRect); + + currentWindow = frameElement.ownerDocument.defaultView; + } + + return adjustedRect; + } + } + return null; + }, + { x: coordinates.x, y: coordinates.y } + ); + return rect; + } + } catch (error) { + const { message, stack } = error as Error; + console.error('Error while retrieving selector:', message); + console.error('Stack:', stack); + } +}; /** * Returns the best and unique css {@link Selectors} for the element on the page. @@ -597,6 +1199,206 @@ export const getSelectors = async (page: Page, coordinates: Coordinates) => { } return output; } + + const getDeepestElementFromPoint = (x: number, y: number): HTMLElement | null => { + // Helper function to traverse shadow DOM + const traverseShadowDOM = (element: HTMLElement, depth: number = 0): HTMLElement => { + const MAX_SHADOW_DEPTH = 4; + let current = element; + let deepest = current; + + while (current && depth < MAX_SHADOW_DEPTH) { + const shadowRoot = current.shadowRoot; + if (!shadowRoot) break; + + const shadowElement = shadowRoot.elementFromPoint(x, y) as HTMLElement; + if (!shadowElement || shadowElement === current) break; + + deepest = shadowElement; + current = shadowElement; + depth++; + } + + return deepest; + }; + + // Start with the element at the specified coordinates + let element = document.elementFromPoint(x, y) as HTMLElement; + if (!element) return null; + + // Initialize tracking variables + let deepestElement = element; + let depth = 0; + const MAX_IFRAME_DEPTH = 4; + + // First check if the initial element has a shadow root + deepestElement = traverseShadowDOM(element); + + // If it's an iframe, traverse through iframe hierarchy + if (deepestElement.tagName === 'IFRAME') { + let currentIframe = deepestElement as HTMLIFrameElement; + + while (currentIframe && depth < MAX_IFRAME_DEPTH) { + try { + // Convert coordinates to iframe's local space + const iframeRect = currentIframe.getBoundingClientRect(); + const iframeX = x - iframeRect.left; + const iframeY = y - iframeRect.top; + + // Access iframe's document + const iframeDoc = currentIframe.contentDocument || currentIframe.contentWindow?.document; + if (!iframeDoc) break; + + // Get element at transformed coordinates in iframe + const iframeElement = iframeDoc.elementFromPoint(iframeX, iframeY) as HTMLElement; + if (!iframeElement) break; + + // Check for shadow DOM within iframe + const shadowResult = traverseShadowDOM(iframeElement); + deepestElement = shadowResult; + + // If we found another iframe, continue traversing + if (shadowResult.tagName === 'IFRAME') { + currentIframe = shadowResult as HTMLIFrameElement; + depth++; + } else { + break; + } + } catch (error) { + console.warn('Cannot access iframe content:', error); + break; + } + } + } + + return deepestElement; + }; + + const genSelectorForIframe = (element: HTMLElement) => { + // Helper function to get the complete iframe path up to document root + const getIframePath = (el: HTMLElement) => { + const path = []; + let current = el; + let depth = 0; + const MAX_DEPTH = 4; + + while (current && depth < MAX_DEPTH) { + // Get the owner document of the current element + const ownerDocument = current.ownerDocument; + + // Check if this document belongs to an iframe + const frameElement = ownerDocument?.defaultView?.frameElement as HTMLIFrameElement; + + if (frameElement) { + path.unshift({ + frame: frameElement, + document: ownerDocument, + element: current + }); + // Move up to the parent document's element (the iframe) + current = frameElement; + depth++; + } else { + break; + } + } + return path; + }; + + const iframePath = getIframePath(element); + if (iframePath.length === 0) return null; + + try { + const selectorParts: string[] = []; + + // Generate selector for each iframe boundary + iframePath.forEach((context, index) => { + // Get selector for the iframe element + const frameSelector = finder(context.frame, { + root: index === 0 ? document.body : + (iframePath[index - 1].document.body as Element) + }); + + // For the last context, get selector for target element + if (index === iframePath.length - 1) { + const elementSelector = finder(element, { + root: context.document.body as Element + }); + selectorParts.push(`${frameSelector} :>> ${elementSelector}`); + } else { + selectorParts.push(frameSelector); + } + }); + + return { + fullSelector: selectorParts.join(' :>> '), + isFrameContent: true + }; + } catch (e) { + console.warn('Error generating iframe selector:', e); + return null; + } + }; + + // Helper function to generate selectors for shadow DOM elements + const genSelectorForShadowDOM = (element: HTMLElement) => { + // Get complete path up to document root + const getShadowPath = (el: HTMLElement) => { + const path = []; + let current = el; + let depth = 0; + const MAX_DEPTH = 4; + + while (current && depth < MAX_DEPTH) { + const rootNode = current.getRootNode(); + if (rootNode instanceof ShadowRoot) { + path.unshift({ + host: rootNode.host as HTMLElement, + root: rootNode, + element: current + }); + current = rootNode.host as HTMLElement; + depth++; + } else { + break; + } + } + return path; + }; + + const shadowPath = getShadowPath(element); + if (shadowPath.length === 0) return null; + + try { + const selectorParts: string[] = []; + + // Generate selector for each shadow DOM boundary + shadowPath.forEach((context, index) => { + // Get selector for the host element + const hostSelector = finder(context.host, { + root: index === 0 ? document.body : (shadowPath[index - 1].root as unknown as Element) + }); + + // For the last context, get selector for target element + if (index === shadowPath.length - 1) { + const elementSelector = finder(element, { + root: context.root as unknown as Element + }); + selectorParts.push(`${hostSelector} >> ${elementSelector}`); + } else { + selectorParts.push(hostSelector); + } + }); + + return { + fullSelector: selectorParts.join(' >> '), + mode: shadowPath[shadowPath.length - 1].root.mode + }; + } catch (e) { + console.warn('Error generating shadow DOM selector:', e); + return null; + } + }; const genSelectors = (element: HTMLElement | null) => { if (element == null) { @@ -617,6 +1419,10 @@ export const getSelectors = async (page: Page, coordinates: Coordinates) => { } catch (e) { } + + const iframeSelector = genSelectorForIframe(element); + const shadowSelector = genSelectorForShadowDOM(element); + const hrefSelector = genSelectorForAttributes(element, ['href']); const formSelector = genSelectorForAttributes(element, [ 'name', @@ -663,9 +1469,19 @@ export const getSelectors = async (page: Page, coordinates: Coordinates) => { hrefSelector, accessibilitySelector, formSelector, + iframeSelector: iframeSelector ? { + full: iframeSelector.fullSelector, + isIframe: iframeSelector.isFrameContent, + } : null, + shadowSelector: shadowSelector ? { + full: shadowSelector.fullSelector, + mode: shadowSelector.mode + } : null }; } + + function genAttributeSet(element: HTMLElement, attributes: string[]) { return new Set( attributes.filter((attr) => { @@ -705,7 +1521,8 @@ export const getSelectors = async (page: Page, coordinates: Coordinates) => { return char.length === 1 && char.match(/[0-9]/); } - const hoveredElement = document.elementFromPoint(x, y) as HTMLElement; + const hoveredElement = getDeepestElementFromPoint(x, y) as HTMLElement; + if ( hoveredElement != null && !hoveredElement.closest('#overlay-controls') != null @@ -739,58 +1556,451 @@ interface SelectorResult { * @returns {Promise} */ -export const getNonUniqueSelectors = async (page: Page, coordinates: Coordinates): Promise => { +export const getNonUniqueSelectors = async (page: Page, coordinates: Coordinates, listSelector: string): Promise => { + interface DOMContext { + type: 'iframe' | 'shadow'; + element: HTMLElement; + container: HTMLIFrameElement | ShadowRoot; + host?: HTMLElement; + document?: Document; + } + try { - const selectors = await page.evaluate(({ x, y }: { x: number, y: number }) => { + if (!listSelector) { + const selectors = await page.evaluate(({ x, y }: { x: number, y: number }) => { + const getDeepestElementFromPoint = (x: number, y: number): HTMLElement | null => { + // Function to traverse shadow DOM + const traverseShadowDOM = (element: HTMLElement): HTMLElement => { + let current = element; + let deepest = current; + let shadowRoot = current.shadowRoot; + + while (shadowRoot) { + const shadowElement = shadowRoot.elementFromPoint(x, y) as HTMLElement; + if (!shadowElement || shadowElement === current) break; + + deepest = shadowElement; + current = shadowElement; + shadowRoot = current.shadowRoot; + } + + return deepest; + }; - function getNonUniqueSelector(element: HTMLElement): string { - let selector = element.tagName.toLowerCase(); + // Start with the element at coordinates + let element = document.elementFromPoint(x, y) as HTMLElement; + if (!element) return null; - if (element.className) { - const classes = element.className.split(/\s+/).filter((cls: string) => Boolean(cls)); - if (classes.length > 0) { - const validClasses = classes.filter((cls: string) => !cls.startsWith('!') && !cls.includes(':')); - if (validClasses.length > 0) { - selector += '.' + validClasses.map(cls => CSS.escape(cls)).join('.'); + let deepestElement = element; + let depth = 0; + const MAX_DEPTH = 4; + + // Handle iframe traversal + if (element.tagName === 'IFRAME') { + let currentIframe = element as HTMLIFrameElement; + + while (currentIframe && depth < MAX_DEPTH) { + try { + const iframeRect = currentIframe.getBoundingClientRect(); + const iframeX = x - iframeRect.left; + const iframeY = y - iframeRect.top; + + const iframeDoc = currentIframe.contentDocument || currentIframe.contentWindow?.document; + if (!iframeDoc) break; + + const iframeElement = iframeDoc.elementFromPoint(iframeX, iframeY) as HTMLElement; + if (!iframeElement) break; + + // Check for shadow DOM within iframe + deepestElement = traverseShadowDOM(iframeElement); + + if (deepestElement.tagName === 'IFRAME') { + currentIframe = deepestElement as HTMLIFrameElement; + depth++; + } else { + break; + } + } catch (error) { + console.warn('Cannot access iframe content:', error); + break; + } + } + } else { + // If not an iframe, check for shadow DOM + deepestElement = traverseShadowDOM(element); + } + + return deepestElement; + }; + + // Basic selector generation + function getNonUniqueSelector(element: HTMLElement): string { + let selector = element.tagName.toLowerCase(); + + if (selector === 'td' && element.parentElement) { + // Find position among td siblings + const siblings = Array.from(element.parentElement.children); + const position = siblings.indexOf(element) + 1; + return `${selector}:nth-child(${position})`; + } + + if (element.className) { + const classes = element.className.split(/\s+/).filter((cls: string) => Boolean(cls)); + if (classes.length > 0) { + const validClasses = classes.filter((cls: string) => !cls.startsWith('!') && !cls.includes(':')); + if (validClasses.length > 0) { + selector += '.' + validClasses.map(cls => CSS.escape(cls)).join('.'); + } + } + } + + return selector; + } + + + function getContextPath(element: HTMLElement): DOMContext[] { + const path: DOMContext[] = []; + let current = element; + let depth = 0; + const MAX_DEPTH = 4; + + while (current && depth < MAX_DEPTH) { + // Check for shadow DOM + const rootNode = current.getRootNode(); + if (rootNode instanceof ShadowRoot) { + path.unshift({ + type: 'shadow', + element: current, + container: rootNode, + host: rootNode.host as HTMLElement + }); + current = rootNode.host as HTMLElement; + depth++; + continue; + } + + // Check for iframe + const ownerDocument = current.ownerDocument; + const frameElement = ownerDocument?.defaultView?.frameElement as HTMLIFrameElement; + + if (frameElement) { + path.unshift({ + type: 'iframe', + element: current, + container: frameElement, + document: ownerDocument + }); + current = frameElement; + depth++; + continue; + } + + break; + } + + return path; + } + + function getSelectorPath(element: HTMLElement | null): string { + if (!element) return ''; + + // Get the complete context path + const contextPath = getContextPath(element); + if (contextPath.length > 0) { + const selectorParts: string[] = []; + + contextPath.forEach((context, index) => { + const containerSelector = getNonUniqueSelector( + context.type === 'shadow' ? context.host! : context.container as HTMLElement + ); + + if (index === contextPath.length - 1) { + const elementSelector = getNonUniqueSelector(element); + const delimiter = context.type === 'shadow' ? ' >> ' : ' :>> '; + selectorParts.push(`${containerSelector}${delimiter}${elementSelector}`); + } else { + selectorParts.push(containerSelector); + } + }); + + return selectorParts.join(contextPath[0].type === 'shadow' ? ' >> ' : ' :>> '); + } + + // Regular DOM path generation + const path: string[] = []; + let currentElement = element; + const MAX_DEPTH = 2; + let depth = 0; + + while (currentElement && currentElement !== document.body && depth < MAX_DEPTH) { + const selector = getNonUniqueSelector(currentElement); + path.unshift(selector); + + if (!currentElement.parentElement) break; + currentElement = currentElement.parentElement; + depth++; + } + + return path.join(' > '); + } + + // Main logic to get element and generate selector + const originalEl = getDeepestElementFromPoint(x, y); + if (!originalEl) return null; + + let element = originalEl; + + if (element.tagName === 'TD' || element.tagName === 'TH') { + const tableParent = element.closest('table'); + if (tableParent) { + element = tableParent; + } + } + + // if (listSelector === '') { + if (element.tagName !== 'TABLE') { + while (element.parentElement) { + if (element.tagName.toLowerCase() === 'body' || + element.tagName.toLowerCase() === 'html') { + break; + } + + const parentRect = element.parentElement.getBoundingClientRect(); + const childRect = element.getBoundingClientRect(); + + const fullyContained = + parentRect.left <= childRect.left && + parentRect.right >= childRect.right && + parentRect.top <= childRect.top && + parentRect.bottom >= childRect.bottom; + + const significantOverlap = + (childRect.width * childRect.height) / + (parentRect.width * parentRect.height) > 0.5; + + if (fullyContained && significantOverlap) { + const nextParent = element.parentElement; + if (nextParent.tagName.toLowerCase() !== 'body' && + nextParent.tagName.toLowerCase() !== 'html') { + element = nextParent; + } else { + break; + } + } else { + break; } } } - return selector; - } + const generalSelector = getSelectorPath(element); + return { generalSelector }; + }, coordinates); - function getSelectorPath(element: HTMLElement | null): string { - const path: string[] = []; - let depth = 0; - const maxDepth = 2; + return selectors || { generalSelector: '' }; + } else { + // When we have a list selector, we need special handling while maintaining shadow DOM support + const selectors = await page.evaluate(({ x, y }: { x: number, y: number }) => { + const getDeepestElementFromPoint = (x: number, y: number): HTMLElement | null => { + // Helper function to traverse shadow DOM + const traverseShadowDOM = (element: HTMLElement, depth: number = 0): HTMLElement => { + const MAX_SHADOW_DEPTH = 4; + let current = element; + let deepest = current; + + while (current && depth < MAX_SHADOW_DEPTH) { + const shadowRoot = current.shadowRoot; + if (!shadowRoot) break; + + const shadowElement = shadowRoot.elementFromPoint(x, y) as HTMLElement; + if (!shadowElement || shadowElement === current) break; + + deepest = shadowElement; + current = shadowElement; + depth++; + } + + return deepest; + }; + + // Start with the element at the specified coordinates + let element = document.elementFromPoint(x, y) as HTMLElement; + if (!element) return null; + + // Initialize tracking variables + let deepestElement = element; + let depth = 0; + const MAX_IFRAME_DEPTH = 4; + + // First check if the initial element has a shadow root + deepestElement = traverseShadowDOM(element); + + // If it's an iframe, traverse through iframe hierarchy + if (deepestElement.tagName === 'IFRAME') { + let currentIframe = deepestElement as HTMLIFrameElement; + + while (currentIframe && depth < MAX_IFRAME_DEPTH) { + try { + // Convert coordinates to iframe's local space + const iframeRect = currentIframe.getBoundingClientRect(); + const iframeX = x - iframeRect.left; + const iframeY = y - iframeRect.top; + + // Access iframe's document + const iframeDoc = currentIframe.contentDocument || currentIframe.contentWindow?.document; + if (!iframeDoc) break; + + // Get element at transformed coordinates in iframe + const iframeElement = iframeDoc.elementFromPoint(iframeX, iframeY) as HTMLElement; + if (!iframeElement) break; + + // Check for shadow DOM within iframe + const shadowResult = traverseShadowDOM(iframeElement); + deepestElement = shadowResult; + + // If we found another iframe, continue traversing + if (shadowResult.tagName === 'IFRAME') { + currentIframe = shadowResult as HTMLIFrameElement; + depth++; + } else { + break; + } + } catch (error) { + console.warn('Cannot access iframe content:', error); + break; + } + } + } + + return deepestElement; + }; - while (element && element !== document.body && depth < maxDepth) { - const selector = getNonUniqueSelector(element); - path.unshift(selector); - element = element.parentElement; - depth++; + // Generate basic selector from element's tag and classes + function getNonUniqueSelector(element: HTMLElement): string { + let selector = element.tagName.toLowerCase(); + + if (selector === 'td' && element.parentElement) { + const siblings = Array.from(element.parentElement.children); + const position = siblings.indexOf(element) + 1; + return `${selector}:nth-child(${position})`; + } + + if (element.className) { + const classes = element.className.split(/\s+/).filter(Boolean); + if (classes.length > 0) { + const validClasses = classes.filter(cls => !cls.startsWith('!') && !cls.includes(':')); + if (validClasses.length > 0) { + selector += '.' + validClasses.map(cls => CSS.escape(cls)).join('.'); + } + } + } + + return selector; } - return path.join(' > '); - } + // Get complete context path (both iframe and shadow DOM) + function getContextPath(element: HTMLElement): DOMContext[] { + const path: DOMContext[] = []; + let current = element; + let depth = 0; + const MAX_DEPTH = 4; + + while (current && depth < MAX_DEPTH) { + // Check for shadow DOM + const rootNode = current.getRootNode(); + if (rootNode instanceof ShadowRoot) { + path.unshift({ + type: 'shadow', + element: current, + container: rootNode, + host: rootNode.host as HTMLElement + }); + current = rootNode.host as HTMLElement; + depth++; + continue; + } - const element = document.elementFromPoint(x, y) as HTMLElement | null; - if (!element) return null; + // Check for iframe + const ownerDocument = current.ownerDocument; + const frameElement = ownerDocument?.defaultView?.frameElement as HTMLIFrameElement; + + if (frameElement) { + path.unshift({ + type: 'iframe', + element: current, + container: frameElement, + document: ownerDocument + }); + current = frameElement; + depth++; + continue; + } - const generalSelector = getSelectorPath(element); - return { - generalSelector, - }; + break; + } + + return path; + } + + function getSelectorPath(element: HTMLElement | null): string { + if (!element) return ''; + + // Get the complete context path + const contextPath = getContextPath(element); + if (contextPath.length > 0) { + const selectorParts: string[] = []; + + contextPath.forEach((context, index) => { + const containerSelector = getNonUniqueSelector( + context.type === 'shadow' ? context.host! : context.container as HTMLElement + ); + + if (index === contextPath.length - 1) { + const elementSelector = getNonUniqueSelector(element); + const delimiter = context.type === 'shadow' ? ' >> ' : ' :>> '; + selectorParts.push(`${containerSelector}${delimiter}${elementSelector}`); + } else { + selectorParts.push(containerSelector); + } + }); + + return selectorParts.join(contextPath[0].type === 'shadow' ? ' >> ' : ' :>> '); + } + + // Regular DOM path generation + const path: string[] = []; + let currentElement = element; + const MAX_DEPTH = 2; + let depth = 0; + + while (currentElement && currentElement !== document.body && depth < MAX_DEPTH) { + const selector = getNonUniqueSelector(currentElement); + path.unshift(selector); + + if (!currentElement.parentElement) break; + currentElement = currentElement.parentElement; + depth++; + } + + return path.join(' > '); + } + + const originalEl = getDeepestElementFromPoint(x, y); + if (!originalEl) return { generalSelector: '' }; + + let element = originalEl; + + const generalSelector = getSelectorPath(element); + return { generalSelector }; }, coordinates); return selectors || { generalSelector: '' }; + } } catch (error) { console.error('Error in getNonUniqueSelectors:', error); return { generalSelector: '' }; } }; - export const getChildSelectors = async (page: Page, parentSelector: string): Promise => { try { const childSelectors = await page.evaluate((parentSelector: string) => { @@ -798,6 +2008,12 @@ export const getChildSelectors = async (page: Page, parentSelector: string): Pro function getNonUniqueSelector(element: HTMLElement): string { let selector = element.tagName.toLowerCase(); + if (selector === 'td' && element.parentElement) { + const siblings = Array.from(element.parentElement.children); + const position = siblings.indexOf(element) + 1; + return `${selector}:nth-child(${position})`; + } + const className = typeof element.className === 'string' ? element.className : ''; if (className) { const classes = className.split(/\s+/).filter((cls: string) => Boolean(cls)); @@ -813,42 +2029,158 @@ export const getChildSelectors = async (page: Page, parentSelector: string): Pro } // Function to generate selector path from an element to its parent - function getSelectorPath(element: HTMLElement | null): string { + function getSelectorPath(element: HTMLElement): string { if (!element || !element.parentElement) return ''; - const parentSelector = getNonUniqueSelector(element.parentElement); const elementSelector = getNonUniqueSelector(element); + + // Check for shadow DOM context + const rootNode = element.getRootNode(); + if (rootNode instanceof ShadowRoot) { + const hostSelector = getNonUniqueSelector(rootNode.host as HTMLElement); + return `${hostSelector} >> ${elementSelector}`; + } + // Check for iframe context + const ownerDocument = element.ownerDocument; + const frameElement = ownerDocument?.defaultView?.frameElement as HTMLIFrameElement; + if (frameElement) { + const frameSelector = getNonUniqueSelector(frameElement); + return `${frameSelector} :>> ${elementSelector}`; + } + + // Regular DOM context + const parentSelector = getNonUniqueSelector(element.parentElement); return `${parentSelector} > ${elementSelector}`; } - // Function to recursively get all descendant selectors + + // Function to get all children from special contexts + function getSpecialContextChildren(element: HTMLElement): HTMLElement[] { + const children: HTMLElement[] = []; + + // Get shadow DOM children + const shadowRoot = element.shadowRoot; + if (shadowRoot) { + const shadowElements = Array.from(shadowRoot.querySelectorAll('*')) as HTMLElement[]; + children.push(...shadowElements); + } + + // Get iframe children + const iframes = Array.from(element.querySelectorAll('iframe')) as HTMLIFrameElement[]; + for (const iframe of iframes) { + try { + const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document; + if (iframeDoc) { + const iframeElements = Array.from(iframeDoc.querySelectorAll('*')) as HTMLElement[]; + children.push(...iframeElements); + } + } catch (error) { + console.warn('Cannot access iframe content:', error); + continue; + } + } + + return children; + } + + // Function to recursively get all descendant selectors including shadow DOM and iframes function getAllDescendantSelectors(element: HTMLElement): string[] { let selectors: string[] = []; + + // Handle regular DOM children const children = Array.from(element.children) as HTMLElement[]; - for (const child of children) { const childPath = getSelectorPath(child); if (childPath) { - selectors.push(childPath); // Add direct child path - selectors = selectors.concat(getAllDescendantSelectors(child)); // Recursively process descendants + selectors.push(childPath); + + // Process regular descendants + selectors = selectors.concat(getAllDescendantSelectors(child)); + + // Process special context children (shadow DOM and iframes) + const specialChildren = getSpecialContextChildren(child); + for (const specialChild of specialChildren) { + const specialPath = getSelectorPath(specialChild); + if (specialPath) { + selectors.push(specialPath); + selectors = selectors.concat(getAllDescendantSelectors(specialChild)); + } + } + } + } + + // Handle direct special context children + const specialChildren = getSpecialContextChildren(element); + for (const specialChild of specialChildren) { + const specialPath = getSelectorPath(specialChild); + if (specialPath) { + selectors.push(specialPath); + selectors = selectors.concat(getAllDescendantSelectors(specialChild)); } } return selectors; } - // Find all occurrences of the parent selector in the DOM - const parentElements = Array.from(document.querySelectorAll(parentSelector)) as HTMLElement[]; + // Handle both shadow DOM and iframe parent selectors + let parentElements: HTMLElement[] = []; + + // Check for special context traversal in parent selector + if (parentSelector.includes('>>') || parentSelector.includes(':>>')) { + // Split by both types of delimiters + const selectorParts = parentSelector.split(/(?:>>|:>>)/).map(part => part.trim()); + + // Start with initial elements + parentElements = Array.from(document.querySelectorAll(selectorParts[0])) as HTMLElement[]; + + // Traverse through parts + for (let i = 1; i < selectorParts.length; i++) { + const newParentElements: HTMLElement[] = []; + + for (const element of parentElements) { + // Check for shadow DOM + if (element.shadowRoot) { + const shadowChildren = Array.from( + element.shadowRoot.querySelectorAll(selectorParts[i]) + ) as HTMLElement[]; + newParentElements.push(...shadowChildren); + } + + // Check for iframe + if (element.tagName === 'IFRAME') { + try { + const iframeDoc = (element as HTMLIFrameElement).contentDocument || + (element as HTMLIFrameElement).contentWindow?.document; + if (iframeDoc) { + const iframeChildren = Array.from( + iframeDoc.querySelectorAll(selectorParts[i]) + ) as HTMLElement[]; + newParentElements.push(...iframeChildren); + } + } catch (error) { + console.warn('Cannot access iframe content during traversal:', error); + continue; + } + } + } + + parentElements = newParentElements; + } + } else { + // Regular DOM selector + parentElements = Array.from(document.querySelectorAll(parentSelector)) as HTMLElement[]; + } + const allChildSelectors = new Set(); // Use a set to ensure uniqueness // Process each parent element and its descendants parentElements.forEach((parentElement) => { const descendantSelectors = getAllDescendantSelectors(parentElement); - descendantSelectors.forEach((selector) => allChildSelectors.add(selector)); // Add selectors to the set + descendantSelectors.forEach((selector) => allChildSelectors.add(selector)); }); - return Array.from(allChildSelectors); // Convert the set back to an array + return Array.from(allChildSelectors); }, parentSelector); return childSelectors || []; diff --git a/server/src/workflow-management/utils.ts b/server/src/workflow-management/utils.ts index b3dadd60..0804aa78 100644 --- a/server/src/workflow-management/utils.ts +++ b/server/src/workflow-management/utils.ts @@ -12,6 +12,16 @@ export const getBestSelectorForAction = (action: Action) => { case ActionType.Hover: case ActionType.DragAndDrop: { 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 const textSelector = selectors?.text?.length != null && @@ -75,6 +85,11 @@ export const getBestSelectorForAction = (action: Action) => { case ActionType.Input: case ActionType.Keydown: { const selectors = action.selectors; + + if (selectors?.shadowSelector?.full) { + return selectors.shadowSelector.full; + } + return ( selectors.testIdSelector ?? selectors?.id ?? diff --git a/src/App.tsx b/src/App.tsx index 896723a1..3b6a1ccd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,88 @@ -import React from 'react'; -import { Routes, Route } from 'react-router-dom'; -import { createTheme } from "@mui/material/styles"; +import React from "react"; +import { Routes, Route } from "react-router-dom"; +import { ThemeProvider, createTheme } from "@mui/material/styles"; import { GlobalInfoProvider } from "./context/globalInfo"; import { PageWrapper } from "./pages/PageWrappper"; +import i18n from "./i18n"; 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() { return ( @@ -17,6 +93,16 @@ function App() { + + ); } diff --git a/src/api/storage.ts b/src/api/storage.ts index 4b2f4e80..18c793c0 100644 --- a/src/api/storage.ts +++ b/src/api/storage.ts @@ -5,11 +5,6 @@ import { ScheduleSettings } from "../components/molecules/ScheduleSettings"; import { CreateRunResponse, ScheduleRunResponse } from "../pages/MainPage"; import { apiUrl } from "../apiConfig"; - - - - - export const getStoredRecordings = async (): Promise => { try { 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 => { - - try { const response = await axios.get(`${apiUrl}/storage/recordings/${id}/runs`); @@ -99,32 +90,26 @@ export const checkRunsForRecording = async (id: string): Promise => { } }; - export const deleteRecordingFromStorage = async (id: string): Promise => { - const hasRuns = await checkRunsForRecording(id); - + if (hasRuns) { - + return false; } try { const response = await axios.delete(`${apiUrl}/storage/recordings/${id}`); if (response.status === 200) { - + return true; } else { throw new Error(`Couldn't delete stored recording ${id}`); } } catch (error: any) { console.log(error); - + return false; } - - - - }; export const deleteRunFromStorage = async (id: string): Promise => { @@ -159,7 +144,7 @@ export const createRunForStoredRecording = async (id: string, settings: RunSetti try { const response = await axios.put( `${apiUrl}/storage/runs/${id}`, - { ...settings }); + { ...settings }); if (response.status === 200) { return response.data; } else { diff --git a/src/api/workflow.ts b/src/api/workflow.ts index 03b677b1..40ac0d99 100644 --- a/src/api/workflow.ts +++ b/src/api/workflow.ts @@ -3,7 +3,7 @@ import { emptyWorkflow } from "../shared/constants"; import { default as axios, AxiosResponse } from "axios"; import { apiUrl } from "../apiConfig"; -export const getActiveWorkflow = async(id: string) : Promise => { +export const getActiveWorkflow = async (id: string): Promise => { try { const response = await axios.get(`${apiUrl}/workflow/${id}`) if (response.status === 200) { @@ -11,13 +11,13 @@ export const getActiveWorkflow = async(id: string) : Promise => { } else { throw new Error('Something went wrong when fetching a recorded workflow'); } - } catch(error: any) { + } catch (error: any) { console.log(error); return emptyWorkflow; } }; -export const getParamsOfActiveWorkflow = async(id: string) : Promise => { +export const getParamsOfActiveWorkflow = async (id: string): Promise => { try { const response = await axios.get(`${apiUrl}/workflow/params/${id}`) if (response.status === 200) { @@ -25,15 +25,15 @@ export const getParamsOfActiveWorkflow = async(id: string) : Promise => { +export const deletePair = async (index: number): Promise => { try { - const response = await axios.delete(`${apiUrl}/workflow/pair/${index}`); + const response = await axios.delete(`${apiUrl}/workflow/pair/${index}`); if (response.status === 200) { return response.data; } else { @@ -45,11 +45,11 @@ export const deletePair = async(index: number): Promise => { } }; -export const AddPair = async(index: number, pair: WhereWhatPair): Promise => { +export const AddPair = async (index: number, pair: WhereWhatPair): Promise => { try { const response = await axios.post(`${apiUrl}/workflow/pair/${index}`, { pair, - }, {headers: {'Content-Type': 'application/json'}}); + }, { headers: { 'Content-Type': 'application/json' } }); if (response.status === 200) { return response.data; } else { @@ -61,11 +61,11 @@ export const AddPair = async(index: number, pair: WhereWhatPair): Promise => { +export const UpdatePair = async (index: number, pair: WhereWhatPair): Promise => { try { const response = await axios.put(`${apiUrl}/workflow/pair/${index}`, { pair, - }, {headers: {'Content-Type': 'application/json'}}); + }, { headers: { 'Content-Type': 'application/json' } }); if (response.status === 200) { return response.data; } else { diff --git a/src/components/atoms/DatePicker.tsx b/src/components/atoms/DatePicker.tsx new file mode 100644 index 00000000..30d3b869 --- /dev/null +++ b/src/components/atoms/DatePicker.tsx @@ -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 = ({ coordinates, selector, onClose }) => { + const { socket } = useSocketStore(); + const [selectedDate, setSelectedDate] = useState(''); + + const handleDateChange = (e: React.ChangeEvent) => { + setSelectedDate(e.target.value); + }; + + const handleConfirm = () => { + if (socket && selectedDate) { + socket.emit('input:date', { + selector, + value: selectedDate + }); + onClose(); + } + }; + + return ( +

+
+ +
+ + +
+
+
+ ); +}; + +export default DatePicker; \ No newline at end of file diff --git a/src/components/atoms/DateTimeLocalPicker.tsx b/src/components/atoms/DateTimeLocalPicker.tsx new file mode 100644 index 00000000..dc62a79b --- /dev/null +++ b/src/components/atoms/DateTimeLocalPicker.tsx @@ -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 = ({ coordinates, selector, onClose }) => { + const { socket } = useSocketStore(); + const [selectedDateTime, setSelectedDateTime] = useState(''); + + const handleDateTimeChange = (e: React.ChangeEvent) => { + setSelectedDateTime(e.target.value); + }; + + const handleConfirm = () => { + if (socket && selectedDateTime) { + socket.emit('input:datetime-local', { + selector, + value: selectedDateTime + }); + onClose(); + } + }; + + return ( +
+
+ +
+ + +
+
+
+ ); +}; + +export default DateTimeLocalPicker; \ No newline at end of file diff --git a/src/components/atoms/Dropdown.tsx b/src/components/atoms/Dropdown.tsx new file mode 100644 index 00000000..c7ead64b --- /dev/null +++ b/src/components/atoms/Dropdown.tsx @@ -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(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 ( +
+
e.stopPropagation()} + > +
+ {options.map((option, index) => ( +
!option.disabled && setHoveredIndex(index)} + onMouseLeave={() => setHoveredIndex(null)} + onClick={() => !option.disabled && handleSelect(option.value)} + > + {option.text} +
+ ))} +
+
+
+ ); +}; + +export default Dropdown; \ No newline at end of file diff --git a/src/components/atoms/TimePicker.tsx b/src/components/atoms/TimePicker.tsx new file mode 100644 index 00000000..31353c7a --- /dev/null +++ b/src/components/atoms/TimePicker.tsx @@ -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(null); + const [hoveredMinute, setHoveredMinute] = useState(null); + const [selectedHour, setSelectedHour] = useState(null); + const [selectedMinute, setSelectedMinute] = useState(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 ( +
+
e.stopPropagation()} + > + {/* Hours column */} +
+ {hours.map((hour) => ( +
setHoveredHour(hour)} + onMouseLeave={() => setHoveredHour(null)} + onClick={() => handleHourSelect(hour)} + > + {hour.toString().padStart(2, '0')} +
+ ))} +
+ + {/* Minutes column */} +
+ {minutes.map((minute) => ( +
setHoveredMinute(minute)} + onMouseLeave={() => setHoveredMinute(null)} + onClick={() => handleMinuteSelect(minute)} + > + {minute.toString().padStart(2, '0')} +
+ ))} +
+
+
+ ); +}; + +export default TimePicker; \ No newline at end of file diff --git a/src/components/atoms/canvas.tsx b/src/components/atoms/canvas.tsx index e31a7094..8fd4f791 100644 --- a/src/components/atoms/canvas.tsx +++ b/src/components/atoms/canvas.tsx @@ -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 { getMappedCoordinates } from "../../helpers/inputHelpers"; import { useGlobalInfoStore } from "../../context/globalInfo"; 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 { - (ref: React.RefObject): void; +class RAFScheduler { + 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; + 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 { width: number; height: number; - onCreateRef: CreateRefCallback; + onCreateRef: (ref: React.RefObject) => void; } /** @@ -22,134 +152,229 @@ export interface Coordinates { y: number; }; -const Canvas = ({ width, height, onCreateRef }: CanvasProps) => { - +const Canvas = React.memo(({ width, height, onCreateRef }: CanvasProps) => { const canvasRef = useRef(null); const { socket } = useSocketStore(); const { setLastAction, lastAction } = useGlobalInfoStore(); const { getText, getList } = useActionContext(); - const getTextRef = useRef(getText); - const getListRef = useRef(getList); - const notifyLastAction = (action: string) => { - if (lastAction !== action) { - setLastAction(action); + const scheduler = useRef(new RAFScheduler()); + const debouncer = useRef(new EventDebouncer(scheduler.current)); + 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({ x: 0, y: 0 }); + const getEventCoordinates = useCallback((event: MouseEvent): { x: number; y: number } => { + if (!canvasRef.current) return { x: 0, y: 0 }; - useEffect(() => { - getTextRef.current = getText; - getListRef.current = getList; - }, [getText, getList]); + let rect = measurementCache.current.get(canvasRef.current); + if (!rect) { + rect = canvasRef.current.getBoundingClientRect(); + measurementCache.current.set(canvasRef.current, rect); + } - const onMouseEvent = useCallback((event: MouseEvent) => { - if (socket && canvasRef.current) { - // Get the canvas bounding rectangle - 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 - }; + return { + x: event.clientX - rect.left, + y: event.clientY - rect.top + }; + }, []); - switch (event.type) { - case 'mousedown': - if (getTextRef.current === true) { + const handleMouseEvent = useCallback((event: MouseEvent) => { + if (!socket || !canvasRef.current) return; + + //performanceMonitor.current.measureEventLatency(event); + const coordinates = getEventCoordinates(event); + + switch (event.type) { + case 'mousedown': + debouncer.current.add(() => { + if (refs.current.getText) { console.log('Capturing Text...'); - } else if (getListRef.current === true) { + } else if (refs.current.getList) { console.log('Capturing List...'); } else { - socket.emit('input:mousedown', clickCoordinates); + socket.emit('input:mousedown', coordinates); } - notifyLastAction('click'); - break; - case 'mousemove': - 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]); + setLastAction('click'); + }, true); // High priority + break; - const onKeyboardEvent = useCallback((event: KeyboardEvent) => { - if (socket) { + case 'mousemove': + 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) { case 'keydown': - socket.emit('input:keydown', { key: event.key, coordinates: lastMousePosition.current }); - notifyLastAction(`${event.key} pressed`); + socket.emit('input:keydown', { + key: event.key, + coordinates: refs.current.lastMousePosition + }); + setLastAction(`${event.key} pressed`); break; case 'keyup': socket.emit('input:keyup', event.key); break; - default: - console.log('Default keyEvent registered'); - return; } - } + }, event.type === 'keydown'); // High priority for keydown }, [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(() => { - if (canvasRef.current) { - 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); + if (!socket) return; - return () => { - if (canvasRef.current) { - canvasRef.current.removeEventListener('mousedown', onMouseEvent); - canvasRef.current.removeEventListener('mousemove', onMouseEvent); - canvasRef.current.removeEventListener('wheel', onMouseEvent); - canvasRef.current.removeEventListener('keydown', onKeyboardEvent); - canvasRef.current.removeEventListener('keyup', onKeyboardEvent); - } + const handlers = { + showDatePicker: (info: any) => dispatch({ type: 'BATCH_UPDATE', payload: { datePickerInfo: info } }), + showDropdown: (info: any) => dispatch({ type: 'BATCH_UPDATE', payload: { dropdownInfo: info } }), + showTimePicker: (info: any) => dispatch({ type: 'BATCH_UPDATE', payload: { timePickerInfo: info } }), + showDateTimePicker: (info: any) => dispatch({ type: 'BATCH_UPDATE', payload: { dateTimeLocalInfo: info } }) + }; - }; - } else { - console.log('Canvas not initialized'); - } + Object.entries(handlers).forEach(([event, handler]) => socket.on(event, handler)); + return () => { + Object.keys(handlers).forEach(event => socket.off(event)); + }; + }, [socket]); - }, [onMouseEvent]); + const memoizedDimensions = useMemo(() => ({ + width: width || 900, + height: height || 400 + }), [width, height]); return ( -
+
+ + {state.datePickerInfo && ( + dispatch({ + type: 'BATCH_UPDATE', + payload: { datePickerInfo: null } + })} + /> + )} + {state.dropdownInfo && ( + dispatch({ + type: 'BATCH_UPDATE', + payload: { dropdownInfo: null } + })} + /> + )} + {state.timePickerInfo && ( + dispatch({ type: 'SET_TIME_PICKER', payload: null })} + /> + )} + {state.dateTimeLocalInfo && ( + dispatch({ type: 'SET_DATETIME_PICKER', payload: null })} + /> + )} +
); +}); -}; - +Canvas.displayName = 'Canvas'; export default Canvas; \ No newline at end of file diff --git a/src/components/molecules/ActionDescriptionBox.tsx b/src/components/molecules/ActionDescriptionBox.tsx index 747cad18..97c979ec 100644 --- a/src/components/molecules/ActionDescriptionBox.tsx +++ b/src/components/molecules/ActionDescriptionBox.tsx @@ -3,6 +3,7 @@ import styled from 'styled-components'; import { Typography, FormControlLabel, Checkbox, Box } from '@mui/material'; import { useActionContext } from '../../context/browserActions'; import MaxunLogo from "../../assets/maxunlogo.png"; +import { useTranslation } from 'react-i18next'; interface CustomBoxContainerProps { isDarkMode: boolean; @@ -48,7 +49,9 @@ const Content = styled.div` text-align: left; `; + const ActionDescriptionBox = ({ isDarkMode }: { isDarkMode: boolean }) => { + const { t } = useTranslation(); const { getText, getScreenshot, getList, captureStage } = useActionContext() as { getText: boolean; getScreenshot: boolean; @@ -57,10 +60,10 @@ const ActionDescriptionBox = ({ isDarkMode }: { isDarkMode: boolean }) => { }; const messages = [ - { stage: 'initial', text: 'Select the list you want to extract along with the texts inside it' }, - { stage: 'pagination', text: 'Select how the robot can capture the rest of the list' }, - { stage: 'limit', text: 'Choose the number of items to extract' }, - { stage: 'complete', text: 'Capture is complete' }, + { stage: 'initial' as const, text: t('action_description.list_stages.initial') }, + { stage: 'pagination' as const, text: t('action_description.list_stages.pagination') }, + { stage: 'limit' as const, text: t('action_description.list_stages.limit') }, + { stage: 'complete' as const, text: t('action_description.list_stages.complete') }, ]; const stages = messages.map(({ stage }) => stage); @@ -70,23 +73,23 @@ const ActionDescriptionBox = ({ isDarkMode }: { isDarkMode: boolean }) => { if (getText) { return ( <> - Capture Text - Hover over the texts you want to extract and click to select them + {t('action_description.text.title')} + {t('action_description.text.description')} ); } else if (getScreenshot) { return ( <> - Capture Screenshot - Capture a partial or full page screenshot of the current page. + {t('action_description.screenshot.title')} + {t('action_description.screenshot.description')} ); } else if (getList) { return ( <> - Capture List + {t('action_description.list.title')} - 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')} {messages.map(({ stage, text }, index) => ( @@ -117,8 +120,8 @@ const ActionDescriptionBox = ({ isDarkMode }: { isDarkMode: boolean }) => { } else { return ( <> - What data do you want to extract? - A robot is designed to perform one action at a time. You can choose any of the options below. + {t('action_description.default.title')} + {t('action_description.default.description')} ); } @@ -135,4 +138,4 @@ const ActionDescriptionBox = ({ isDarkMode }: { isDarkMode: boolean }) => { ); }; -export default ActionDescriptionBox; +export default ActionDescriptionBox; \ No newline at end of file diff --git a/src/components/molecules/BrowserRecordingSave.tsx b/src/components/molecules/BrowserRecordingSave.tsx index 09c32f5c..668c83bb 100644 --- a/src/components/molecules/BrowserRecordingSave.tsx +++ b/src/components/molecules/BrowserRecordingSave.tsx @@ -5,8 +5,10 @@ import { useGlobalInfoStore } from '../../context/globalInfo'; import { stopRecording } from "../../api/recording"; import { useNavigate } from 'react-router-dom'; import { GenericModal } from "../atoms/GenericModal"; +import { useTranslation } from 'react-i18next'; const BrowserRecordingSave = () => { + const { t } = useTranslation(); const [openModal, setOpenModal] = useState(false); const { recordingName, browserId, setBrowserId, notify } = useGlobalInfoStore(); const navigate = useNavigate(); @@ -14,7 +16,7 @@ const BrowserRecordingSave = () => { const goToMainMenu = async () => { if (browserId) { await stopRecording(browserId); - notify('warning', 'Current Recording was terminated'); + notify('warning', t('browser_recording.notifications.terminated')); setBrowserId(null); } navigate('/'); @@ -24,32 +26,31 @@ const BrowserRecordingSave = () => {
setOpenModal(false)} modalStyle={modalStyle}> - Are you sure you want to discard the recording? + {t('browser_recording.modal.confirm_discard')} @@ -61,7 +62,7 @@ const BrowserRecordingSave = () => { ); } -export default BrowserRecordingSave +export default BrowserRecordingSave; const modalStyle = { top: '25%', diff --git a/src/components/molecules/ColapsibleRow.tsx b/src/components/molecules/ColapsibleRow.tsx index 95b144d7..5e4be9da 100644 --- a/src/components/molecules/ColapsibleRow.tsx +++ b/src/components/molecules/ColapsibleRow.tsx @@ -10,6 +10,7 @@ import { RunContent } from "./RunContent"; import { GenericModal } from "../atoms/GenericModal"; import { modalStyle } from "./AddWhereCondModal"; import { getUserById } from "../../api/auth"; +import { useTranslation } from "react-i18next"; interface RunTypeChipProps { runByUserId?: string; @@ -18,10 +19,12 @@ interface RunTypeChipProps { } const RunTypeChip: React.FC = ({ runByUserId, runByScheduledId, runByAPI }) => { - if (runByUserId) return ; - if (runByScheduledId) return ; - if (runByAPI) return ; - return ; + const { t } = useTranslation(); + + if (runByUserId) return ; + if (runByScheduledId) return ; + if (runByAPI) return ; + return ; }; interface CollapsibleRowProps { @@ -33,6 +36,7 @@ interface CollapsibleRowProps { runningRecordingName: string; } export const CollapsibleRow = ({ row, handleDelete, isOpen, currentLog, abortRunHandler, runningRecordingName }: CollapsibleRowProps) => { + const { t } = useTranslation(); const [open, setOpen] = useState(isOpen); const [openSettingsModal, setOpenSettingsModal] = useState(false); const [userEmail, setUserEmail] = useState(null); @@ -99,12 +103,12 @@ export const CollapsibleRow = ({ row, handleDelete, isOpen, currentLog, abortRun } else { switch (column.id) { case 'runStatus': - return ( + return ( - {row.status === 'success' && } - {row.status === 'running' && } - {row.status === 'scheduled' && } - {row.status === 'failed' && } + {row.status === 'success' && } + {row.status === 'running' && } + {row.status === 'scheduled' && } + {row.status === 'failed' && } ) case 'delete': @@ -133,21 +137,35 @@ export const CollapsibleRow = ({ row, handleDelete, isOpen, currentLog, abortRun modalStyle={modalStyle} > <> - Run Settings + + {t('runs_table.run_settings_modal.title')} + - Run Type: - + + {t('runs_table.run_settings_modal.labels.run_type')}: + + diff --git a/src/components/molecules/IntegrationSettings.tsx b/src/components/molecules/IntegrationSettings.tsx index 8e38b5a0..d4bc8abe 100644 --- a/src/components/molecules/IntegrationSettings.tsx +++ b/src/components/molecules/IntegrationSettings.tsx @@ -14,6 +14,9 @@ import axios from "axios"; import { useGlobalInfoStore } from "../../context/globalInfo"; import { getStoredRecording } from "../../api/storage"; import { apiUrl } from "../../apiConfig.js"; +import Cookies from 'js-cookie'; +import { useTranslation } from "react-i18next"; + interface IntegrationProps { isOpen: boolean; @@ -46,6 +49,7 @@ export const IntegrationSettingsModal = ({ handleStart, handleClose, }: IntegrationProps) => { + const { t } = useTranslation(); const [settings, setSettings] = useState({ spreadsheetId: "", spreadsheetName: "", @@ -90,9 +94,9 @@ export const IntegrationSettingsModal = ({ ); notify( "error", - `Error fetching spreadsheet files: ${ - error.response?.data?.message || error.message - }` + t('integration_settings.errors.fetch_error', { + message: error.response?.data?.message || error.message + }) ); } }; @@ -121,7 +125,7 @@ export const IntegrationSettingsModal = ({ }, { withCredentials: true } ); - notify(`success`, `Google Sheet selected successfully`); + notify(`success`, t('integration_settings.notifications.sheet_selected')); console.log("Google Sheet ID updated:", response.data); } catch (error: any) { console.error( @@ -182,37 +186,28 @@ export const IntegrationSettingsModal = ({ return ( -
+ }}> - Integrate with Google Sheet{" "} + {t('integration_settings.title')} {recording && recording.google_sheet_id ? ( <> - Google Sheet Integrated Successfully. - Every time this robot creates a successful run, its captured data - is appended to your {recording.google_sheet_name} Google Sheet. - You can check the data updates{" "} - {t('integration_settings.alerts.success.title')} + {t('integration_settings.alerts.success.content', { sheetName: recording.google_sheet_name })} + - here - - . + rel="noreferrer"> + {t('integration_settings.alerts.success.here')} + .
- Note: 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. + {t('integration_settings.alerts.success.note')} {t('integration_settings.alerts.success.sync_limitation')}
) : ( <> {!recording?.google_sheet_email ? ( <> -

- If you enable this option, every time this robot runs a task - successfully, its captured data will be appended to your - Google Sheet. -

+

{t('integration_settings.descriptions.sync_info')}

) : ( <> {recording.google_sheet_email && ( - Authenticated as: {recording.google_sheet_email} + {t('integration_settings.descriptions.authenticated_as', { + email: recording.google_sheet_email + })} )} @@ -260,14 +253,14 @@ export const IntegrationSettingsModal = ({ color="primary" onClick={fetchSpreadsheetFiles} > - Fetch Google Spreadsheets + {t('integration_settings.buttons.fetch_sheets')}
@@ -276,7 +269,7 @@ export const IntegrationSettingsModal = ({ - Selected Sheet:{" "} - { - spreadsheets.find( - (s) => s.id === settings.spreadsheetId - )?.name - }{" "} - (ID: {settings.spreadsheetId}) + {t('integration_settings.fields.selected_sheet', { + name: spreadsheets.find((s) => s.id === settings.spreadsheetId)?.name, + id: settings.spreadsheetId + })} )} @@ -311,7 +301,7 @@ export const IntegrationSettingsModal = ({ style={{ marginTop: "10px" }} disabled={!settings.spreadsheetId || loading} > - Submit + {t('integration_settings.buttons.submit')} )} diff --git a/src/components/molecules/InterpretationButtons.tsx b/src/components/molecules/InterpretationButtons.tsx index 9d983761..624a57b4 100644 --- a/src/components/molecules/InterpretationButtons.tsx +++ b/src/components/molecules/InterpretationButtons.tsx @@ -7,6 +7,7 @@ import { useGlobalInfoStore } from "../../context/globalInfo"; import { GenericModal } from "../atoms/GenericModal"; import { WhereWhatPair } from "maxun-core"; import HelpIcon from '@mui/icons-material/Help'; +import { useTranslation } from "react-i18next"; interface InterpretationButtonsProps { enableStepping: (isPaused: boolean) => void; @@ -23,6 +24,7 @@ const interpretationInfo: InterpretationInfo = { }; export const InterpretationButtons = ({ enableStepping }: InterpretationButtonsProps) => { + const { t } = useTranslation(); const [info, setInfo] = useState(interpretationInfo); const [decisionModal, setDecisionModal] = useState<{ pair: WhereWhatPair | null, @@ -44,9 +46,9 @@ export const InterpretationButtons = ({ enableStepping }: InterpretationButtonsP const breakpointHitHandler = useCallback(() => { 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]); + }, [enableStepping, t]); const decisionHandler = useCallback( ({ 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 ( <> - Do you want to use your previous selection as a condition for performing this action? + {t('interpretation_buttons.modal.use_previous')} - Your previous action was: {decisionModal.action}, on an element with text {decisionModal.innerText} + {t('interpretation_buttons.modal.previous_action')} {decisionModal.action}, + {t('interpretation_buttons.modal.element_text')} {decisionModal.innerText} @@ -105,9 +108,9 @@ export const InterpretationButtons = ({ enableStepping }: InterpretationButtonsP const finished = await interpretCurrentRecording(); setInfo({ ...info, running: false }); if (finished) { - notify('info', 'Run finished'); + notify('info', t('interpretation_buttons.messages.run_finished')); } 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} sx={{ display: 'grid' }} > - {info.running ? - Extracting data...please wait for 10secs to 1min - : 'Get Preview of Output Data'} + {info.running ? ( + + + {t('interpretation_buttons.messages.extracting')} + + ) : t('interpretation_buttons.buttons.preview')} { }} @@ -166,8 +172,12 @@ export const InterpretationButtons = ({ enableStepping }: InterpretationButtonsP {handleDescription()}
- - + +
diff --git a/src/components/molecules/InterpretationLog.tsx b/src/components/molecules/InterpretationLog.tsx index c384ca25..df686878 100644 --- a/src/components/molecules/InterpretationLog.tsx +++ b/src/components/molecules/InterpretationLog.tsx @@ -18,6 +18,7 @@ import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import { SidePanelHeader } from './SidePanelHeader'; import { useGlobalInfoStore } from '../../context/globalInfo'; import { useThemeMode } from '../../context/theme-provider'; +import { useTranslation } from 'react-i18next'; interface InterpretationLogProps { isOpen: boolean; @@ -25,6 +26,7 @@ interface InterpretationLogProps { } export const InterpretationLog: React.FC = ({ isOpen, setIsOpen }) => { + const { t } = useTranslation(); const [log, setLog] = useState(''); const [customValue, setCustomValue] = useState(''); const [tableData, setTableData] = useState([]); @@ -34,7 +36,7 @@ export const InterpretationLog: React.FC = ({ isOpen, se const { width } = useBrowserDimensionsStore(); const { socket } = useSocketStore(); - const { currentWorkflowActionsState } = useGlobalInfoStore(); + const { currentWorkflowActionsState, shouldResetInterpretationLog, notify } = useGlobalInfoStore(); const toggleDrawer = (newOpen: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => { if ( @@ -64,34 +66,43 @@ export const InterpretationLog: React.FC = ({ isOpen, se const handleSerializableCallback = useCallback((data: any) => { setLog((prevState) => - prevState + '\n' + '---------- Serializable output data received ----------' + '\n' - + JSON.stringify(data, null, 2) + '\n' + '--------------------------------------------------'); + prevState + '\n' + t('interpretation_log.data_sections.serializable_received') + '\n' + + JSON.stringify(data, null, 2) + '\n' + t('interpretation_log.data_sections.separator')); if (Array.isArray(data)) { setTableData(data); } scrollLogToBottom(); - }, [log, scrollLogToBottom]); + }, [log, scrollLogToBottom, t]); const handleBinaryCallback = useCallback(({ data, mimetype }: any) => { const base64String = Buffer.from(data).toString('base64'); const imageSrc = `data:${mimetype};base64,${base64String}`; setLog((prevState) => - prevState + '\n' + '---------- Binary output data received ----------' + '\n' - + `mimetype: ${mimetype}` + '\n' + 'Image is rendered below:' + '\n' - + '------------------------------------------------'); + prevState + '\n' + t('interpretation_log.data_sections.binary_received') + '\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); scrollLogToBottom(); - }, [log, scrollLogToBottom]); + }, [log, scrollLogToBottom, t]); const handleCustomValueChange = (event: React.ChangeEvent) => { setCustomValue(event.target.value); }; + useEffect(() => { + if (shouldResetInterpretationLog) { + setLog(''); + setTableData([]); + setBinaryData(null); + } + }, [shouldResetInterpretationLog]); + useEffect(() => { socket?.on('log', handleLog); socket?.on('serializableCallback', handleSerializableCallback); @@ -141,7 +152,8 @@ export const InterpretationLog: React.FC = ({ isOpen, se }, }} > - Output Data Preview + + {t('interpretation_log.titles.output_preview')} = ({ isOpen, se }, }} > - - Output Data Preview - + + + {t('interpretation_log.titles.output_preview')} +
= ({ isOpen, se { binaryData ? (
- Screenshot - Binary Output + + {t('interpretation_log.titles.screenshot')} + + {t('interpretation_log.titles.screenshot')}
) : tableData.length > 0 ? ( <> @@ -198,7 +213,9 @@ export const InterpretationLog: React.FC = ({ isOpen, se - Additional rows of data will be extracted once you finish recording. + + {t('interpretation_log.messages.additional_rows')} + ) : ( @@ -206,13 +223,13 @@ export const InterpretationLog: React.FC = ({ isOpen, se {hasScrapeListAction || hasScrapeSchemaAction || hasScreenshotAction ? ( <> - 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')} ) : ( - 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')} )} @@ -224,4 +241,4 @@ export const InterpretationLog: React.FC = ({ isOpen, se ); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/src/components/molecules/NavBar.tsx b/src/components/molecules/NavBar.tsx index c2bf8510..3bb8d758 100644 --- a/src/components/molecules/NavBar.tsx +++ b/src/components/molecules/NavBar.tsx @@ -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 { useNavigate } from 'react-router-dom'; import { @@ -20,173 +21,592 @@ import styled from "styled-components"; import { stopRecording } from "../../api/recording"; 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 { SaveRecording } from '../molecules/SaveRecording'; import DiscordIcon from '../atoms/DiscordIcon'; import { apiUrl } from '../../apiConfig'; import MaxunLogo from "../../assets/maxunlogo.png"; import { useThemeMode } from '../../context/theme-provider'; +import packageJson from "../../../package.json" interface NavBarProps { recordingName: string; isRecording: boolean; } -export const NavBar: React.FC = ({ recordingName, isRecording }) => { +export const NavBar: React.FC = ({ + recordingName, + isRecording, +}) => { const { notify, browserId, setBrowserId } = useGlobalInfoStore(); const { state, dispatch } = useContext(AuthContext); const { user } = state; const navigate = useNavigate(); const { darkMode, toggleTheme } = useThemeMode(); + const { t, i18n } = useTranslation(); + const [anchorEl, setAnchorEl] = useState(null); + const [langAnchorEl, setLangAnchorEl] = useState(null); + + const currentVersion = packageJson.version; + + const [open, setOpen] = useState(false); + const [latestVersion, setLatestVersion] = useState(null); + const [tab, setTab] = useState(0); + const [isUpdateAvailable, setIsUpdateAvailable] = useState(false); + + const fetchLatestVersion = async (): Promise => { + 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) => { setAnchorEl(event.currentTarget); }; + const handleLangMenuOpen = (event: React.MouseEvent) => { + setLangAnchorEl(event.currentTarget); + }; + const handleMenuClose = () => { setAnchorEl(null); + setLangAnchorEl(null); }; const logout = async () => { - dispatch({ type: 'LOGOUT' }); - window.localStorage.removeItem('user'); + dispatch({ type: "LOGOUT" }); + window.localStorage.removeItem("user"); const { data } = await axios.get(`${apiUrl}/auth/logout`); - notify('success', data.message); - navigate('/login'); + notify("success", data.message); + navigate("/login"); }; const goToMainMenu = async () => { if (browserId) { await stopRecording(browserId); - notify('warning', 'Current Recording was terminated'); + notify("warning", t('browser_recording.notifications.terminated')); setBrowserId(null); } - navigate('/'); + navigate("/"); }; - const renderBrandSection = () => ( - - - Maxun - - - ); + const changeLanguage = (lang: string) => { + i18n.changeLanguage(lang); + localStorage.setItem("language", lang); + }; - const renderSocialButtons = () => ( - <> - - - - + + + {user.email} + + + { handleMenuClose(); logout(); }}> + {t('navbar.menu_items.logout')} + + { + window.open('https://discord.gg/5GbPjBUkws', '_blank'); + }}> + Discord + + { + window.open('https://www.youtube.com/@MaxunOSS/videos?ref=app', '_blank'); + }}> + YouTube + + { + window.open('https://x.com/maxun_io?ref=app', '_blank'); + }}> + Twitter (X) + + + {t('navbar.menu_items.language')} + + + { + changeLanguage("en"); + handleMenuClose(); + }} + > + English + + { + changeLanguage("es"); + handleMenuClose(); + }} + > + Español + + { + changeLanguage("ja"); + handleMenuClose(); + }} + > + 日本語 + + { + changeLanguage("zh"); + handleMenuClose(); + }} + > + 中文 + + { + changeLanguage("de"); + handleMenuClose(); + }} + > + Deutsch + + + + + ) : ( + <> + + + {t('navbar.recording.discard')} + + + + )} +
+ ) : ( + <> + {t("Language")} + + + { + changeLanguage("en"); + handleMenuClose(); + }} + > + English + + { + changeLanguage("es"); + handleMenuClose(); + }} + > + Español + + { + changeLanguage("ja"); + handleMenuClose(); + }} + > + 日本語 + + { + changeLanguage("zh"); + handleMenuClose(); + }} + > + 中文 + + { + changeLanguage("de"); + handleMenuClose(); + }} + > + Deutsch + + + )} + + ); }; diff --git a/src/components/molecules/Pair.tsx b/src/components/molecules/Pair.tsx index b05b912d..3c332600 100644 --- a/src/components/molecules/Pair.tsx +++ b/src/components/molecules/Pair.tsx @@ -1,5 +1,5 @@ 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 { WorkflowFile } from "maxun-core"; import { ClearButton } from "../atoms/buttons/ClearButton"; diff --git a/src/components/molecules/RecordingsTable.tsx b/src/components/molecules/RecordingsTable.tsx index 651d3677..f8a0ba37 100644 --- a/src/components/molecules/RecordingsTable.tsx +++ b/src/components/molecules/RecordingsTable.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { useTranslation } from 'react-i18next'; import Paper from '@mui/material/Paper'; import Table from '@mui/material/Table'; import TableBody from '@mui/material/TableBody'; @@ -19,6 +20,7 @@ import { useNavigate } from 'react-router-dom'; import { stopRecording } from "../../api/recording"; import { GenericModal } from '../atoms/GenericModal'; + /** TODO: * 1. allow editing existing robot after persisting browser steps */ @@ -31,31 +33,6 @@ interface Column { 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 { id: string; name: string; @@ -76,12 +53,38 @@ interface RecordingsTableProps { } export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handleScheduleRecording, handleIntegrateRecording, handleSettingsRecording, handleEditRobot, handleDuplicateRobot }: RecordingsTableProps) => { + const {t} = useTranslation(); const [page, setPage] = React.useState(0); const [rowsPerPage, setRowsPerPage] = React.useState(10); const [rows, setRows] = React.useState([]); const [isModalOpen, setModalOpen] = React.useState(false); 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 navigate = useNavigate(); @@ -151,16 +154,17 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl row.name.toLowerCase().includes(searchTerm.toLowerCase()) ); + return ( - My Robots + {t('recordingtable.heading')} - Create Robot + {t('recordingtable.new')} @@ -253,14 +257,14 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl checkRunsForRecording(row.id).then((result: boolean) => { 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) => { if (result) { setRows([]); - notify('success', 'Robot deleted successfully'); + notify('success', t('recordingtable.notifications.delete_success')); fetchRecordings(); } }) @@ -297,9 +301,9 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl /> setModalOpen(false)} modalStyle={modalStyle}>
- Enter URL To Extract Data + {t('recordingtable.modal.title')} - Start Training Robot + {t('recordingtable.modal.button')}
@@ -397,6 +401,8 @@ const OptionsButton = ({ handleEdit, handleDelete, handleDuplicate }: OptionsBut setAnchorEl(null); }; + const {t} = useTranslation(); + return ( <> - Edit - - { handleDuplicate(); handleClose(); }}> - - - - Duplicate + {t('recordingtable.edit')} + { handleDelete(); handleClose(); }}> - Delete + {t('recordingtable.delete')} + + + { handleDuplicate(); handleClose(); }}> + + + + {t('recordingtable.duplicate')} diff --git a/src/components/molecules/RobotDuplicate.tsx b/src/components/molecules/RobotDuplicate.tsx index 850614b0..ce3ee5ca 100644 --- a/src/components/molecules/RobotDuplicate.tsx +++ b/src/components/molecules/RobotDuplicate.tsx @@ -1,11 +1,12 @@ import React, { useState, useEffect } from 'react'; 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 { useGlobalInfoStore } from '../../context/globalInfo'; import { duplicateRecording, getStoredRecording } from '../../api/storage'; import { WhereWhatPair } from 'maxun-core'; import { getUserById } from "../../api/auth"; +import { useTranslation } from 'react-i18next'; interface RobotMeta { name: string; @@ -54,6 +55,7 @@ interface RobotSettingsProps { } export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => { + const { t } = useTranslation(); const [robot, setRobot] = useState(null); const [targetUrl, setTargetUrl] = useState(''); const { recordingId, notify } = useGlobalInfoStore(); @@ -65,7 +67,6 @@ export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initia }, [isOpen]); useEffect(() => { - // Update the targetUrl when the robot data is loaded if (robot) { const lastPair = robot?.recording.workflow[robot?.recording.workflow.length - 1]; 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); setRobot(robot); } 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) => { setTargetUrl(e.target.value); }; const handleSave = async () => { if (!robot || !targetUrl) { - notify('error', 'Target URL is required.'); + notify('error', t('robot_duplication.notifications.url_required')); return; } - console.log("handle save"); - try { const success = await duplicateRecording(robot.recording_meta.id, targetUrl); if (success) { - notify('success', 'Robot duplicated successfully.'); - handleStart(robot); // Inform parent about the updated robot + notify('success', t('robot_duplication.notifications.duplicate_success')); + handleStart(robot); handleClose(); setTimeout(() => { window.location.reload(); }, 1000); } else { - notify('error', 'Failed to update the Target URL. Please try again.'); + notify('error', t('robot_duplication.notifications.duplicate_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); } }; @@ -126,34 +120,40 @@ export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initia modalStyle={modalStyle} > <> - Duplicate Robot + + {t('robot_duplication.title')} + { robot && ( <> - Robot duplication is useful to extract data from pages with the same structure. + + {t('robot_duplication.descriptions.purpose')} + +
+ producthunt.com/topics/api', + url2: 'producthunt.com/topics/database' + }) + }}/>
- Example: If you've created a robot for producthunt.com/topics/api, you can duplicate it to scrape similar pages - like producthunt.com/topics/database without training a robot from scratch. - -
- - ⚠️ Ensure the new page has the same structure as the original page. + {t('robot_duplication.descriptions.warning')} - - diff --git a/src/components/molecules/RobotEdit.tsx b/src/components/molecules/RobotEdit.tsx index 9441ecef..6547d93b 100644 --- a/src/components/molecules/RobotEdit.tsx +++ b/src/components/molecules/RobotEdit.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import { GenericModal } from "../atoms/GenericModal"; import { TextField, Typography, Box, Button } from "@mui/material"; import { modalStyle } from "./AddWhereCondModal"; @@ -54,10 +55,10 @@ interface RobotSettingsProps { handleStart: (settings: RobotSettings) => void; handleClose: () => void; initialSettings?: RobotSettings | null; - } export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => { + const { t } = useTranslation(); const [robot, setRobot] = useState(null); const { recordingId, notify } = useGlobalInfoStore(); @@ -72,7 +73,7 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin const robot = await getStoredRecording(recordingId); setRobot(robot); } 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 } }; }); }; + const handleSave = async () => { if (!robot) return; @@ -114,7 +116,7 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin const success = await updateRecording(robot.recording_meta.id, payload); if (success) { - notify('success', 'Robot updated successfully.'); + notify('success', t('robot_edit.notifications.update_success')); handleStart(robot); // Inform parent about the updated robot handleClose(); @@ -122,10 +124,10 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin window.location.reload(); }, 1000); } else { - notify('error', 'Failed to update the robot. Please try again.'); + notify('error', t('robot_edit.notifications.update_failed')); } } 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); } }; @@ -137,14 +139,16 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin modalStyle={modalStyle} > <> - Edit Robot + + {t('robot_edit.title')} + { robot && ( <> 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 && ( { @@ -166,12 +170,17 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin /> )} - - - diff --git a/src/components/molecules/RobotSettings.tsx b/src/components/molecules/RobotSettings.tsx index 53d21d7b..d952f43d 100644 --- a/src/components/molecules/RobotSettings.tsx +++ b/src/components/molecules/RobotSettings.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import { GenericModal } from "../atoms/GenericModal"; import { TextField, Typography, Box } from "@mui/material"; import { modalStyle } from "./AddWhereCondModal"; @@ -50,10 +51,10 @@ interface RobotSettingsProps { handleStart: (settings: RobotSettings) => void; handleClose: () => void; initialSettings?: RobotSettings | null; - } export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => { + const { t } = useTranslation(); const [robot, setRobot] = useState(null); const [userEmail, setUserEmail] = useState(null); const { recordingId, notify } = useGlobalInfoStore(); @@ -69,7 +70,7 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe const robot = await getStoredRecording(recordingId); setRobot(robot); } 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} > <> - Robot Settings + + {t('robot_settings.title')} + { robot && ( <> {robot.recording.workflow?.[0]?.what?.[0]?.args?.[0]?.limit !== undefined && ( )} ); -}; +}; \ No newline at end of file diff --git a/src/components/molecules/RunContent.tsx b/src/components/molecules/RunContent.tsx index b9c0f5fe..d335ada7 100644 --- a/src/components/molecules/RunContent.tsx +++ b/src/components/molecules/RunContent.tsx @@ -13,6 +13,7 @@ import TableContainer from '@mui/material/TableContainer'; import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import 'highlight.js/styles/github.css'; +import { useTranslation } from "react-i18next"; interface RunContentProps { row: Data, @@ -23,6 +24,7 @@ interface RunContentProps { } export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRef, abortRunHandler }: RunContentProps) => { + const { t } = useTranslation(); const [tab, setTab] = React.useState('log'); const [tableData, setTableData] = useState([]); const [columns, setColumns] = useState([]); @@ -76,49 +78,49 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe setTab(newTab)} - aria-label="run-content-tabs" - sx={{ - // Remove the default blue indicator - '& .MuiTabs-indicator': { - backgroundColor: '#FF00C3', // Change to pink - }, - // Remove default transition effects - '& .MuiTab-root': { - '&.Mui-selected': { - color: '#FF00C3', - }, - } - }} -> - theme.palette.mode === 'dark' ? '#fff' : '#000', - '&:hover': { - color: '#FF00C3' - }, - '&.Mui-selected': { - color: '#FF00C3', - } - }} - /> - theme.palette.mode === 'dark' ? '#fff' : '#000', - '&:hover': { - color: '#FF00C3' - }, - '&.Mui-selected': { - color: '#FF00C3', - } - }} - /> - + value={tab} + onChange={(e, newTab) => setTab(newTab)} + aria-label="run-content-tabs" + sx={{ + // Remove the default blue indicator + '& .MuiTabs-indicator': { + backgroundColor: '#FF00C3', // Change to pink + }, + // Remove default transition effects + '& .MuiTab-root': { + '&.Mui-selected': { + color: '#FF00C3', + }, + } + }} + > + theme.palette.mode === 'dark' ? '#fff' : '#000', + '&:hover': { + color: '#FF00C3' + }, + '&.Mui-selected': { + color: '#FF00C3', + } + }} + /> + theme.palette.mode === 'dark' ? '#fff' : '#000', + '&:hover': { + color: '#FF00C3' + }, + '&.Mui-selected': { + color: '#FF00C3', + } + }} + /> + - Stop + {t('run_content.buttons.stop')} : null} {!row || !row.serializableOutput || !row.binaryOutput || (Object.keys(row.serializableOutput).length === 0 && Object.keys(row.binaryOutput).length === 0) - ? The output is empty. : null} + ? {t('run_content.empty_output')} : null} {row.serializableOutput && Object.keys(row.serializableOutput).length !== 0 &&
- Captured Data + {t('run_content.captured_data.title')} - Download as JSON + {t('run_content.captured_data.download_json')} - Download as CSV + {t('run_content.captured_data.download_csv')} {tableData.length > 0 ? ( @@ -212,7 +214,7 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
- Captured Screenshot + {t('run_content.captured_screenshot.title')} {Object.keys(row.binaryOutput).map((key) => { try { @@ -222,7 +224,7 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe width: 'max-content', }}> - Download Screenshot + {t('run_content.captured_screenshot.download')} {key} @@ -230,7 +232,7 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe } catch (e) { console.log(e) return - {key}: The image failed to render + {key}: {t('run_content.captured_screenshot.render_failed')} } })} diff --git a/src/components/molecules/RunsTable.tsx b/src/components/molecules/RunsTable.tsx index 669cecd6..61e139ec 100644 --- a/src/components/molecules/RunsTable.tsx +++ b/src/components/molecules/RunsTable.tsx @@ -1,4 +1,6 @@ import * as React from 'react'; +import { useEffect, useState } from "react"; +import { useTranslation } from 'react-i18next'; import Paper from '@mui/material/Paper'; import Table from '@mui/material/Table'; import TableBody from '@mui/material/TableBody'; @@ -7,14 +9,24 @@ import TableContainer from '@mui/material/TableContainer'; import TableHead from '@mui/material/TableHead'; import TablePagination from '@mui/material/TablePagination'; 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 { getStoredRuns } from "../../api/storage"; import { RunSettings } from "./RunSettings"; import { CollapsibleRow } from "./ColapsibleRow"; -import { Accordion, AccordionSummary, AccordionDetails, Typography, Box, TextField } from '@mui/material'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import SearchIcon from '@mui/icons-material/Search'; + +// Export columns before the component +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 { id: 'runStatus' | 'name' | 'startedAt' | 'finishedAt' | 'delete' | 'settings'; @@ -24,15 +36,6 @@ interface Column { 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 { id: number; status: string; @@ -58,15 +61,25 @@ interface RunsTableProps { runningRecordingName: string; } -export const RunsTable = ( - { currentInterpretationLog, abortRunHandler, runId, runningRecordingName }: RunsTableProps) => { +export const RunsTable: React.FC = ({ + 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 [rowsPerPage, setRowsPerPage] = useState(10); const [rows, setRows] = useState([]); - const [searchTerm, setSearchTerm] = useState(''); - const { notify, rerenderRuns, setRerenderRuns } = useGlobalInfoStore(); const handleChangePage = (event: unknown, newPage: number) => { @@ -86,16 +99,13 @@ export const RunsTable = ( const fetchRuns = async () => { const runs = await getStoredRuns(); if (runs) { - const parsedRows: Data[] = []; - runs.map((run: any, index) => { - parsedRows.push({ - id: index, - ...run, - }); - }); + const parsedRows: Data[] = runs.map((run: any, index: number) => ({ + id: index, + ...run, + })); setRows(parsedRows); } else { - notify('error', 'No runs found. Please try again.') + notify('error', t('runstable.notifications.no_runs')); } }; @@ -104,15 +114,14 @@ export const RunsTable = ( fetchRuns(); setRerenderRuns(false); } - }, [rerenderRuns]); + }, [rerenderRuns, rows.length, setRerenderRuns]); const handleDelete = () => { setRows([]); - notify('success', 'Run deleted successfully'); + notify('success', t('runstable.notifications.delete_success')); fetchRuns(); }; - // Filter rows based on search term const filteredRows = rows.filter((row) => row.name.toLowerCase().includes(searchTerm.toLowerCase()) @@ -120,7 +129,6 @@ export const RunsTable = ( // Group filtered rows by robot meta id const groupedRows = filteredRows.reduce((acc, row) => { - if (!acc[row.robotMetaId]) { acc[row.robotMetaId] = []; } @@ -132,11 +140,11 @@ export const RunsTable = ( - All Runs + {t('runstable.runs', 'Runs')} ( }> - {data[data.length - 1].name} - - {columns.map((column) => ( + {translatedColumns.map((column) => ( ); -}; +}; \ No newline at end of file diff --git a/src/components/molecules/SaveRecording.tsx b/src/components/molecules/SaveRecording.tsx index 7f138f40..f0919239 100644 --- a/src/components/molecules/SaveRecording.tsx +++ b/src/components/molecules/SaveRecording.tsx @@ -9,13 +9,14 @@ import { TextField, Typography } from "@mui/material"; import { WarningText } from "../atoms/texts"; import NotificationImportantIcon from "@mui/icons-material/NotificationImportant"; import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; interface SaveRecordingProps { fileName: string; } export const SaveRecording = ({ fileName }: SaveRecordingProps) => { - + const { t } = useTranslation(); const [openModal, setOpenModal] = useState(false); const [needConfirm, setNeedConfirm] = useState(false); const [recordingName, setRecordingName] = useState(fileName); @@ -46,7 +47,7 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => { }; const exitRecording = useCallback(async () => { - notify('success', 'Robot saved successfully'); + notify('success', t('save_recording.notifications.save_success')); if (browserId) { await stopRecording(browserId); } @@ -63,7 +64,7 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => { setWaitingForSave(true); console.log(`Saving the recording as ${recordingName} for userId ${user.id}`); } 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 (
- setOpenModal(false)} modalStyle={modalStyle}>
- Save Robot + {t('save_recording.title')} {needConfirm ? ( - + - Robot with this name already exists, please confirm the Robot's overwrite. + {t('save_recording.errors.exists_warning')} ) - : + : } {waitingForSave && - + diff --git a/src/components/molecules/ScheduleSettings.tsx b/src/components/molecules/ScheduleSettings.tsx index 00e842ea..917696c9 100644 --- a/src/components/molecules/ScheduleSettings.tsx +++ b/src/components/molecules/ScheduleSettings.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import { GenericModal } from "../atoms/GenericModal"; import { MenuItem, TextField, Typography, Box } from "@mui/material"; import { Dropdown } from "../atoms/DropdownMui"; @@ -25,6 +26,7 @@ export interface ScheduleSettings { } export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initialSettings }: ScheduleSettingsProps) => { + const { t } = useTranslation(); const [schedule, setSchedule] = useState(null); const [settings, setSettings] = useState({ runEvery: 1, @@ -77,12 +79,13 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia 'SUNDAY' ]; - const { recordingId } = useGlobalInfoStore(); + const { recordingId, notify } = useGlobalInfoStore(); const deleteRobotSchedule = () => { if (recordingId) { deleteSchedule(recordingId); setSchedule(null); + notify('success', t('Schedule deleted successfully')); } else { console.error('No recording id provided'); } @@ -116,6 +119,25 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia } }, [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 ( *': { marginBottom: '20px' }, }}> - Schedule Settings + {t('schedule_settings.title')} <> {schedule !== null ? ( <> - Run every: {schedule.runEvery} {schedule.runEveryUnit.toLowerCase()} - {['MONTHS', 'WEEKS'].includes(settings.runEveryUnit) ? "Start From" : "On"} {schedule.startFrom.charAt(0).toUpperCase() + schedule.startFrom.slice(1).toLowerCase()} + {t('schedule_settings.run_every')}: {schedule.runEvery} {schedule.runEveryUnit.toLowerCase()} + {['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()} {schedule.runEveryUnit === 'MONTHS' && ( - 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 + {t('schedule_settings.on_day')}: {schedule.dayOfMonth}{getDayOrdinal(schedule.dayOfMonth)} of the month )} - At around: {schedule.atTimeStart}, {schedule.timezone} Timezone + {t('schedule_settings.at_around')}: {schedule.atTimeStart}, {schedule.timezone} {t('schedule_settings.timezone')} ) : ( <> - Run once every + {t('schedule_settings.labels.run_once_every')} - {['MONTHS', 'WEEKS'].includes(settings.runEveryUnit) ? "Start From" : "On"} + + {['MONTHS', 'WEEKS'].includes(settings.runEveryUnit) ? t('schedule_settings.labels.start_from_label') : t('schedule_settings.labels.start_from_label')} + - On Day of the Month + {t('schedule_settings.labels.on_day_of_month')} - In Between + {t('schedule_settings.labels.in_between')} ) : ( - At Around + {t('schedule_settings.at_around')} - Timezone + {t('schedule_settings.timezone')} @@ -271,4 +295,4 @@ const modalStyle = { height: 'fit-content', display: 'block', padding: '20px', -}; +}; \ No newline at end of file diff --git a/src/components/organisms/ApiKey.tsx b/src/components/organisms/ApiKey.tsx index e6a00a91..9d54fe5c 100644 --- a/src/components/organisms/ApiKey.tsx +++ b/src/components/organisms/ApiKey.tsx @@ -19,6 +19,7 @@ import styled from 'styled-components'; import axios from 'axios'; import { useGlobalInfoStore } from '../../context/globalInfo'; import { apiUrl } from '../../apiConfig'; +import { useTranslation } from 'react-i18next'; const Container = styled(Box)` display: flex; @@ -29,24 +30,21 @@ const Container = styled(Box)` `; const ApiKeyManager = () => { + const { t } = useTranslation(); const [apiKey, setApiKey] = useState(null); - const [apiKeyName, setApiKeyName] = useState('Maxun API Key'); + const [apiKeyName, setApiKeyName] = useState(t('apikey.default_name')); const [loading, setLoading] = useState(true); const [showKey, setShowKey] = useState(false); const [copySuccess, setCopySuccess] = useState(false); const { notify } = useGlobalInfoStore(); - - - - useEffect(() => { const fetchApiKey = async () => { try { const { data } = await axios.get(`${apiUrl}/auth/api-key`); setApiKey(data.api_key); } catch (error: any) { - notify('error', `Failed to fetch API Key - ${error.message}`); + notify('error', t('apikey.notifications.fetch_error', { error: error.message })); } finally { setLoading(false); } @@ -62,9 +60,9 @@ const ApiKeyManager = () => { const { data } = await axios.post(`${apiUrl}/auth/generate-api-key`); setApiKey(data.api_key); - notify('success', `Generated API Key successfully`); + notify('success', t('apikey.notifications.generate_success')); } catch (error: any) { - notify('error', `Failed to generate API Key - ${error.message}`); + notify('error', t('apikey.notifications.generate_error', { error: error.message })); } finally { setLoading(false); } @@ -75,9 +73,9 @@ const ApiKeyManager = () => { try { await axios.delete(`${apiUrl}/auth/delete-api-key`); setApiKey(null); - notify('success', 'API Key deleted successfully'); + notify('success', t('apikey.notifications.delete_success')); } catch (error: any) { - notify('error', `Failed to delete API Key - ${error.message}`); + notify('error', t('apikey.notifications.delete_error', { error: error.message })); } finally { setLoading(false); } @@ -88,7 +86,7 @@ const ApiKeyManager = () => { navigator.clipboard.writeText(apiKey); setCopySuccess(true); setTimeout(() => setCopySuccess(false), 2000); - notify('info', 'Copied API Key successfully'); + notify('info', t('apikey.notifications.copy_success')); } }; @@ -111,34 +109,38 @@ const ApiKeyManager = () => { return ( - Manage Your API Key + {t('apikey.title')} {apiKey ? (
- API Key Name - API Key - Actions + {t('apikey.table.name')} + {t('apikey.table.key')} + {t('apikey.table.actions')} {apiKeyName} - {showKey ? `${apiKey?.substring(0, 10)}...` : '***************'} - + + {showKey ? `${apiKey?.substring(0, 10)}...` : '**********'} + + + + - + setShowKey(!showKey)}> - + @@ -150,9 +152,9 @@ const ApiKeyManager = () => { ) : ( <> - You haven't generated an API key yet. + {t('apikey.no_key_message')} )} diff --git a/src/components/organisms/BrowserWindow.tsx b/src/components/organisms/BrowserWindow.tsx index 30e67fa4..409079a9 100644 --- a/src/components/organisms/BrowserWindow.tsx +++ b/src/components/organisms/BrowserWindow.tsx @@ -7,11 +7,13 @@ import { GenericModal } from '../atoms/GenericModal'; import { useActionContext } from '../../context/browserActions'; import { useBrowserSteps, TextStep } from '../../context/browserSteps'; import { useGlobalInfoStore } from '../../context/globalInfo'; - +import { useTranslation } from 'react-i18next'; interface ElementInfo { tagName: string; hasOnlyText?: boolean; + isIframeContent?: boolean; + isShadowRoot?: boolean; innerText?: string; url?: string; imageUrl?: string; @@ -52,6 +54,7 @@ const getAttributeOptions = (tagName: string, elementInfo: ElementInfo | null): }; export const BrowserWindow = () => { + const { t } = useTranslation(); const [canvasRef, setCanvasReference] = useState | undefined>(undefined); const [screenShot, setScreenShot] = useState(""); 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 { notify } = useGlobalInfoStore(); - const { getText, getList, paginationMode, paginationType, limitMode } = useActionContext(); + const { getText, getList, paginationMode, paginationType, limitMode, captureStage } = useActionContext(); const { addTextStep, addListStep } = useBrowserSteps(); const onMouseMove = (e: MouseEvent) => { @@ -115,34 +118,81 @@ export const BrowserWindow = () => { }, [screenShot, canvasRef, socket, screencastHandler]); 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 (listSelector) { socket?.emit('listSelector', { selector: listSelector }); + const hasValidChildSelectors = Array.isArray(data.childSelectors) && data.childSelectors.length > 0; + if (limitMode) { setHighlighterData(null); } 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)) { setHighlighterData(data); } else { setHighlighterData(null); } } 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); - } 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 setHighlighterData(null); - } - } else { - // set highlighterData for the initial listSelector selection + } + } else { + // Set highlighterData for the initial listSelector selection setHighlighterData(data); - } - } else { - // for non-list steps + } + } else { + // For non-list steps setHighlighterData(data); - } - }, [highlighterData, getList, socket, listSelector, paginationMode, paginationType]); + } + }, [highlighterData, getList, socket, listSelector, paginationMode, paginationType, captureStage]); useEffect(() => { @@ -156,6 +206,13 @@ export const BrowserWindow = () => { }; }, [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) => { if (highlighterData && canvasRef?.current) { const canvasRect = canvasRef.current.getBoundingClientRect(); @@ -183,6 +240,7 @@ export const BrowserWindow = () => { addTextStep('', data, { selector: highlighterData.selector, tag: highlighterData.elementInfo?.tagName, + shadow: highlighterData.elementInfo?.isShadowRoot, attribute }); } else { @@ -190,7 +248,7 @@ export const BrowserWindow = () => { setAttributeOptions(options); setSelectedElement({ selector: highlighterData.selector, - info: highlighterData.elementInfo + info: highlighterData.elementInfo, }); setShowAttributeModal(true); } @@ -200,7 +258,7 @@ export const BrowserWindow = () => { // Only allow selection in pagination mode if type is not empty, 'scrollDown', or 'scrollUp' if (paginationType !== '' && paginationType !== 'scrollDown' && paginationType !== 'scrollUp' && paginationType !== 'none') { 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 }); } return; @@ -208,7 +266,7 @@ export const BrowserWindow = () => { if (getList === true && !listSelector) { 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()); setFields({}); } else if (getList === true && listSelector && currentListId) { @@ -227,6 +285,7 @@ export const BrowserWindow = () => { selectorObj: { selector: highlighterData.selector, tag: highlighterData.elementInfo?.tagName, + shadow: highlighterData.elementInfo?.isShadowRoot, attribute } }; @@ -274,6 +333,7 @@ export const BrowserWindow = () => { addTextStep('', data, { selector: selectedElement.selector, tag: selectedElement.info?.tagName, + shadow: selectedElement.info?.isShadowRoot, attribute: attribute }); } @@ -286,6 +346,7 @@ export const BrowserWindow = () => { selectorObj: { selector: selectedElement.selector, tag: selectedElement.info?.tagName, + shadow: selectedElement.info?.isShadowRoot, attribute: attribute } }; @@ -317,7 +378,6 @@ export const BrowserWindow = () => { } }, [paginationMode, resetPaginationSelector]); - return (
{ diff --git a/src/components/organisms/MainMenu.tsx b/src/components/organisms/MainMenu.tsx index 60c9a082..6f319c3b 100644 --- a/src/components/organisms/MainMenu.tsx +++ b/src/components/organisms/MainMenu.tsx @@ -8,6 +8,9 @@ import { AutoAwesome, FormatListBulleted, VpnKey, Usb, Article, CloudQueue,Code, import { apiUrl } from "../../apiConfig"; +import { useTranslation } from 'react-i18next'; +import i18n from '../../i18n'; + interface MainMenuProps { @@ -17,6 +20,7 @@ interface MainMenuProps { export const MainMenu = ({ value = 'recordings', handleChangeContent }: MainMenuProps) => { const theme = useTheme(); + const {t} = useTranslation(); const handleChange = (event: React.SyntheticEvent, newValue: string) => { handleChangeContent(newValue); @@ -70,28 +74,28 @@ export const MainMenu = ({ value = 'recordings', handleChangeContent }: MainMenu > } iconPosition="start" /> } iconPosition="start" /> } iconPosition="start" /> } iconPosition="start" @@ -99,13 +103,11 @@ export const MainMenu = ({ value = 'recordings', handleChangeContent }: MainMenu
- - diff --git a/src/components/organisms/ProxyForm.tsx b/src/components/organisms/ProxyForm.tsx index acd71e4e..0308f9a0 100644 --- a/src/components/organisms/ProxyForm.tsx +++ b/src/components/organisms/ProxyForm.tsx @@ -22,6 +22,7 @@ import { import { sendProxyConfig, getProxyConfig, testProxyConfig, deleteProxyConfig } from '../../api/proxy'; import { useGlobalInfoStore } from '../../context/globalInfo'; import { useThemeMode } from '../../context/theme-provider'; +import { useTranslation } from 'react-i18next'; // Custom styled Tabs component const CustomTabs = styled(Tabs)(({ theme }) => ({ @@ -44,6 +45,7 @@ const CustomTab = styled(Tab)(({ theme }) => ({ }, })); + const FormContainer = styled(Box)({ display: 'flex', flexDirection: 'column', @@ -56,6 +58,7 @@ const FormControl = styled(Box)({ }); const ProxyForm: React.FC = () => { + const { t } = useTranslation(); const [proxyConfigForm, setProxyConfigForm] = useState({ server_url: '', username: '', @@ -119,13 +122,13 @@ const ProxyForm: React.FC = () => { try { const response = await sendProxyConfig(proxyConfigForm); if (response) { - notify('success', 'Proxy configuration submitted successfully'); + notify('success', t('proxy.notifications.config_success')); } else { - notify('error', `Failed to submit proxy configuration. Try again. ${response}`); - console.log(`Failed to submit proxy configuration. Try again. ${response}`) + notify('error', t('proxy.notifications.config_error')); + console.log(`${t('proxy.notifications.config_error')} ${response}`) } } 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 () => { await testProxyConfig().then((response) => { if (response.success) { - notify('success', 'Proxy configuration is working'); + notify('success', t('proxy.notifications.test_success')); } 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) { setIsProxyConfigured(true); setProxy(response); - notify('success', 'Proxy configuration fetched successfully'); + notify('success', t('proxy.notifications.fetch_success')); } } catch (error: any) { notify('error', error); @@ -159,11 +162,11 @@ const ProxyForm: React.FC = () => { const removeProxy = async () => { await deleteProxyConfig().then((response) => { if (response) { - notify('success', 'Proxy configuration removed successfully'); + notify('success', t('proxy.notifications.remove_success')); setIsProxyConfigured(false); setProxy({ proxy_url: '', auth: false }); } 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 = () => { <> - Proxy Configuration + {t('proxy.title')} { }} > + + + {tabIndex === 0 && ( isProxyConfigured ? ( @@ -210,8 +219,8 @@ const ProxyForm: React.FC = () => {
- Proxy URL - Requires Authentication + {t('proxy.table.proxy_url')} + {t('proxy.table.requires_auth')} @@ -223,39 +232,37 @@ const ProxyForm: React.FC = () => {
) : ( } - label="Requires Authentication?" + label={t('proxy.requires_auth')} /> {requiresAuth && ( <> { { fullWidth disabled={!proxyConfigForm.server_url || (requiresAuth && (!proxyConfigForm.username || !proxyConfigForm.password))} > - Add Proxy + {t('proxy.add_proxy')} ))} @@ -295,31 +302,37 @@ const ProxyForm: React.FC = () => { <> - 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')} - )} - - If your proxy requires a username and password, always provide them separately from the proxy URL. + + + {t('proxy.alert.title')}
- The right way + {t('proxy.alert.right_way')}
- Proxy URL: http://proxy.com:1337 + {t('proxy.alert.proxy_url')} http://proxy.com:1337
- Username: myusername + {t('proxy.alert.username')} myusername
- Password: mypassword + {t('proxy.alert.password')} mypassword

- The wrong way -
- Proxy URL: http://myusername:mypassword@proxy.com:1337 + {t('proxy.alert.wrong_way')}
+ + {t('proxy.alert.proxy_url')} http://myusername:mypassword@proxy.com:1337
); diff --git a/src/components/organisms/RightSidePanel.tsx b/src/components/organisms/RightSidePanel.tsx index b58e97a0..cf62606d 100644 --- a/src/components/organisms/RightSidePanel.tsx +++ b/src/components/organisms/RightSidePanel.tsx @@ -23,6 +23,7 @@ import { getActiveWorkflow } from "../../api/workflow"; import DeleteIcon from '@mui/icons-material/Delete'; import ActionDescriptionBox from '../molecules/ActionDescriptionBox'; import { useThemeMode } from '../../context/theme-provider'; +import { useTranslation } from 'react-i18next'; const fetchWorkflow = (id: string, callback: (response: WorkflowFile) => void) => { getActiveWorkflow(id).then( @@ -56,11 +57,14 @@ export const RightSidePanel: React.FC = ({ onFinishCapture const [showCaptureText, setShowCaptureText] = useState(true); const [hoverStates, setHoverStates] = useState<{ [id: string]: boolean }>({}); const [browserStepIdList, setBrowserStepIdList] = useState([]); + 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 { browserSteps, updateBrowserTextStepLabel, deleteBrowserStep, addScreenshotStep, updateListTextFieldLabel, removeListTextField } = useBrowserSteps(); const { id, socket } = useSocketStore(); + const { t } = useTranslation(); const workflowHandler = useCallback((data: WorkflowFile) => { setWorkflow(data); @@ -129,6 +133,16 @@ export const RightSidePanel: React.FC = ({ onFinishCapture const handlePairDelete = () => { } + const handleStartGetText = () => { + setIsCaptureTextConfirmed(false); + startGetText(); + } + + const handleStartGetList = () => { + setIsCaptureListConfirmed(false); + startGetList(); + } + const handleTextLabelChange = (id: number, label: string, listId?: number, fieldKey?: string) => { if (listId !== undefined && fieldKey !== undefined) { // Prevent editing if the field is confirmed @@ -140,7 +154,7 @@ export const RightSidePanel: React.FC = ({ onFinishCapture setTextLabels(prevLabels => ({ ...prevLabels, [id]: label })); } if (!label.trim()) { - setErrors(prevErrors => ({ ...prevErrors, [id]: 'Label cannot be empty' })); + setErrors(prevErrors => ({ ...prevErrors, [id]: t('right_panel.errors.label_required') })); } else { setErrors(prevErrors => ({ ...prevErrors, [id]: '' })); } @@ -152,7 +166,7 @@ export const RightSidePanel: React.FC = ({ onFinishCapture updateBrowserTextStepLabel(id, label); setConfirmedTextSteps(prev => ({ ...prev, [id]: true })); } 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 = ({ 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) => { setConfirmedListTextFields(prev => ({ ...prev, @@ -194,6 +224,22 @@ export const RightSidePanel: React.FC = ({ 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 settings: Record = {}; browserSteps.forEach(step => { @@ -214,18 +260,19 @@ export const RightSidePanel: React.FC = ({ onFinishCapture const stopCaptureAndEmitGetTextSettings = useCallback(() => { const hasUnconfirmedTextSteps = browserSteps.some(step => step.type === 'text' && !confirmedTextSteps[step.id]); if (hasUnconfirmedTextSteps) { - notify('error', 'Please confirm all text fields'); + notify('error', t('right_panel.errors.confirm_text_fields')); return; } stopGetText(); const settings = getTextSettingsObject(); - console.log("SETTINGS", settings); const hasTextSteps = browserSteps.some(step => step.type === 'text'); if (hasTextSteps) { socket?.emit('action', { action: 'scrapeSchema', settings }); } + setIsCaptureTextConfirmed(true); + resetInterpretationLog(); onFinishCapture(); - }, [stopGetText, getTextSettingsObject, socket, browserSteps, confirmedTextSteps]); + }, [stopGetText, getTextSettingsObject, socket, browserSteps, confirmedTextSteps, resetInterpretationLog]); const getListSettingsObject = useCallback(() => { let settings: { @@ -279,7 +326,7 @@ export const RightSidePanel: React.FC = ({ onFinishCapture if (settings) { socket?.emit('action', { action: 'scrapeList', settings }); } 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(); onFinishCapture(); @@ -297,13 +344,13 @@ export const RightSidePanel: React.FC = ({ onFinishCapture case 'pagination': if (!paginationType) { - notify('error', 'Please select a pagination type.'); + notify('error', t('right_panel.errors.select_pagination')); return; } const settings = getListSettingsObject(); const paginationSelector = settings.pagination?.selector; if (['clickNext', 'clickLoadMore'].includes(paginationType) && !paginationSelector) { - notify('error', 'Please select the pagination element first.'); + notify('error', t('right_panel.errors.select_pagination_element')); return; } stopPaginationMode(); @@ -315,16 +362,17 @@ export const RightSidePanel: React.FC = ({ onFinishCapture case 'limit': 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; } const limit = limitType === 'custom' ? parseInt(customLimit) : parseInt(limitType); if (isNaN(limit) || limit <= 0) { - notify('error', 'Please enter a valid limit.'); + notify('error', t('right_panel.errors.invalid_limit')); return; } stopLimitMode(); setShowLimitOptions(false); + setIsCaptureListConfirmed(true); stopCaptureAndEmitGetListSettings(); setCaptureStage('complete'); break; @@ -335,6 +383,23 @@ export const RightSidePanel: React.FC = ({ onFinishCapture } }, [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) => { updatePaginationType(option); }; @@ -349,7 +414,8 @@ export const RightSidePanel: React.FC = ({ onFinishCapture setTextLabels({}); setErrors({}); setConfirmedTextSteps({}); - notify('error', 'Capture Text Discarded'); + setIsCaptureTextConfirmed(false); + notify('error', t('right_panel.errors.capture_text_discarded')); }, [browserSteps, stopGetText, deleteBrowserStep]); const discardGetList = useCallback(() => { @@ -364,7 +430,8 @@ export const RightSidePanel: React.FC = ({ onFinishCapture setShowLimitOptions(false); setCaptureStage('initial'); setConfirmedListTextFields({}); - notify('error', 'Capture List Discarded'); + setIsCaptureListConfirmed(false); + notify('error', t('right_panel.errors.capture_list_discarded')); }, [browserSteps, stopGetList, deleteBrowserStep, resetListState]); @@ -420,37 +487,54 @@ export const RightSidePanel: React.FC = ({ onFinishCapture */} - {!getText && !getScreenshot && !getList && showCaptureList && } + {!getText && !getScreenshot && !getList && showCaptureList && } + {getList && ( <> + {(captureStage === 'pagination' || captureStage === 'limit') && ( + + )} + - )} {showPaginationOptions && ( - How can we find the next list item on the page? - - - - - + {t('right_panel.pagination.title')} + + + + + )} {showLimitOptions && ( -

What is the maximum number of rows you want to extract?

+

{t('right_panel.limit.title')}

+
= ({ onFinishCapture } label="10" /> } label="100" />
- } label="Custom" /> + } label={t('right_panel.limit.custom')} /> {limitType === 'custom' && ( updateCustomLimit(e.target.value)} - placeholder="Enter number" + onChange={(e: React.ChangeEvent) => { + 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) => { + const value = (e.target as HTMLInputElement).value + e.key; + if (parseInt(value) < 1) { + e.preventDefault(); + } + } + }} + placeholder={t('right_panel.limit.enter_number')} sx={{ marginLeft: '10px', '& input': { @@ -487,21 +586,24 @@ export const RightSidePanel: React.FC = ({ onFinishCapture )} - {!getText && !getScreenshot && !getList && showCaptureText && } + {!getText && !getScreenshot && !getList && showCaptureText && } + +// {!getText && !getScreenshot && !getList && showCaptureText && } {getText && <> - - + + } - {!getText && !getScreenshot && !getList && showCaptureScreenshot && } + {!getText && !getScreenshot && !getList && showCaptureScreenshot && } +// {!getText && !getScreenshot && !getList && showCaptureScreenshot && } {getScreenshot && ( - - - + + + )} @@ -512,7 +614,7 @@ export const RightSidePanel: React.FC = ({ onFinishCapture step.type === 'text' && ( <> handleTextLabelChange(step.id, e.target.value)} fullWidth @@ -531,7 +633,7 @@ export const RightSidePanel: React.FC = ({ onFinishCapture sx={{ background: isDarkMode ? "#1E2124" : 'white', color: isDarkMode ? "white" : 'black' }} /> = ({ onFinishCapture }} /> - {!confirmedTextSteps[step.id] && ( + {!confirmedTextSteps[step.id] ? ( - - + + + + ) : !isCaptureTextConfirmed && ( + + )} @@ -557,17 +669,19 @@ export const RightSidePanel: React.FC = ({ onFinishCapture - {`Take ${step.fullPage ? 'Fullpage' : 'Visible Part'} Screenshot`} + {step.fullPage ? + t('right_panel.screenshot.display_fullpage') : + t('right_panel.screenshot.display_visible')} )} {step.type === 'list' && ( <> - List Selected Successfully + {t('right_panel.messages.list_selected')} {Object.entries(step.fields).map(([key, field]) => ( handleTextLabelChange(field.id, e.target.value, step.id, key)} fullWidth @@ -584,7 +698,7 @@ export const RightSidePanel: React.FC = ({ onFinishCapture /> = ({ onFinishCapture }} /> - {!confirmedListTextFields[step.id]?.[key] && ( + {!confirmedListTextFields[step.id]?.[key] ? ( + + ) : !isCaptureListConfirmed && ( + + )} diff --git a/src/context/auth.tsx b/src/context/auth.tsx index 5c46e4de..a02711f3 100644 --- a/src/context/auth.tsx +++ b/src/context/auth.tsx @@ -1,4 +1,4 @@ -import { useReducer, createContext, useEffect } from 'react'; +import { useReducer, createContext, useEffect, useCallback } from 'react'; import axios from 'axios'; import { useNavigate } from 'react-router-dom'; import { apiUrl } from "../apiConfig"; @@ -14,12 +14,16 @@ interface ActionType { type InitialStateType = { user: any; + lastActivityTime?: number; }; const initialState = { user: null, + lastActivityTime: Date.now(), }; +const AUTO_LOGOUT_TIME = 4 * 60 * 60 * 1000; // 4 hours in milliseconds + const AuthContext = createContext<{ state: InitialStateType; dispatch: React.Dispatch; @@ -34,11 +38,13 @@ const reducer = (state: InitialStateType, action: ActionType) => { return { ...state, user: action.payload, + lastActivityTime: Date.now(), }; case 'LOGOUT': return { ...state, user: null, + lastActivityTime: undefined, }; default: return state; @@ -50,6 +56,39 @@ const AuthProvider = ({ children }: AuthProviderProps) => { const navigate = useNavigate(); 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(() => { const storedUser = window.localStorage.getItem('user'); 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( function (response) { return response; }, function (error) { const res = error.response; - if (res.status === 401 && res.config && !res.config.__isRetryRequest) { - return new Promise((resolve, reject) => { - axios - .get(`${apiUrl}/auth/logout`) + if (res?.status === 401 && res.config && !res.config.__isRetryRequest) { + return new Promise((_, reject) => { + handleLogout() .then(() => { console.log('/401 error > logout'); - dispatch({ type: 'LOGOUT' }); - window.localStorage.removeItem('user'); - navigate('/login'); + reject(error); }) .catch((err) => { console.error('AXIOS INTERCEPTORS ERROR:', err); diff --git a/src/context/browserActions.tsx b/src/context/browserActions.tsx index ef303f82..fd951f92 100644 --- a/src/context/browserActions.tsx +++ b/src/context/browserActions.tsx @@ -14,8 +14,8 @@ interface ActionContextProps { paginationType: PaginationType; limitType: LimitType; customLimit: string; - captureStage: CaptureStage; // New captureStage property - setCaptureStage: (stage: CaptureStage) => void; // Setter for captureStage + captureStage: CaptureStage; + setCaptureStage: (stage: CaptureStage) => void; startPaginationMode: () => void; startGetText: () => void; stopGetText: () => void; @@ -53,6 +53,7 @@ export const ActionProvider = ({ children }: { children: ReactNode }) => { const startPaginationMode = () => { setPaginationMode(true); setCaptureStage('pagination'); + socket?.emit('setGetList', { getList: false }); }; const stopPaginationMode = () => setPaginationMode(false); @@ -75,7 +76,6 @@ export const ActionProvider = ({ children }: { children: ReactNode }) => { const stopGetList = () => { setGetList(false); - socket?.emit('setGetList', { getList: false }); setPaginationType(''); setLimitType(''); setCustomLimit(''); diff --git a/src/context/browserSteps.tsx b/src/context/browserSteps.tsx index 7630f559..fd311a35 100644 --- a/src/context/browserSteps.tsx +++ b/src/context/browserSteps.tsx @@ -32,6 +32,7 @@ export interface SelectorObject { selector: string; tag?: string; attribute?: string; + shadow?: boolean; [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) => { setBrowserSteps(prevSteps => { const existingListStepIndex = prevSteps.findIndex(step => step.type === 'list' && step.id === listId); + if (existingListStepIndex !== -1) { const updatedSteps = [...prevSteps]; 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}`)) { - 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; }, {} as { [key: string]: TextStep }); - + updatedSteps[existingListStepIndex] = { ...existingListStep, - fields: { ...existingListStep.fields, ...filteredNewFields }, - pagination: pagination, - limit: limit, + fields: mergedFields, + pagination: pagination || existingListStep.pagination, + limit: limit }; return updatedSteps; } else { - // Create a new ListStep return [ ...prevSteps, { id: listId, type: 'list', listSelector, fields: newFields, pagination, limit } diff --git a/src/context/globalInfo.tsx b/src/context/globalInfo.tsx index 58589c3a..ac281630 100644 --- a/src/context/globalInfo.tsx +++ b/src/context/globalInfo.tsx @@ -32,6 +32,8 @@ interface GlobalInfo { hasScreenshotAction: boolean; hasScrapeSchemaAction: boolean; }) => void; + shouldResetInterpretationLog: boolean; + resetInterpretationLog: () => void; }; class GlobalInfoStore implements Partial { @@ -53,6 +55,7 @@ class GlobalInfoStore implements Partial { hasScreenshotAction: false, hasScrapeSchemaAction: false, }; + shouldResetInterpretationLog = false; }; const globalInfoStore = new GlobalInfoStore(); @@ -71,6 +74,7 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => { const [recordingName, setRecordingName] = useState(globalInfoStore.recordingName); const [recordingUrl, setRecordingUrl] = useState(globalInfoStore.recordingUrl); const [currentWorkflowActionsState, setCurrentWorkflowActionsState] = useState(globalInfoStore.currentWorkflowActionsState); + const [shouldResetInterpretationLog, setShouldResetInterpretationLog] = useState(globalInfoStore.shouldResetInterpretationLog); const notify = (severity: 'error' | 'warning' | 'info' | 'success', message: string) => { 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 ( { setRecordingUrl, currentWorkflowActionsState, setCurrentWorkflowActionsState, + shouldResetInterpretationLog, + resetInterpretationLog, }} > {children} diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 00000000..c5e84364 --- /dev/null +++ b/src/i18n.ts @@ -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; \ No newline at end of file diff --git a/src/index.tsx b/src/index.tsx index 8c14f60a..96f914ff 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'; import './index.css'; import { BrowserRouter } from 'react-router-dom'; import App from './App'; +import i18n from "./i18n" const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 12f49e57..86346e4b 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -5,10 +5,20 @@ import { AuthContext } from "../context/auth"; import { Box, Typography, TextField, Button, CircularProgress } from "@mui/material"; import { useGlobalInfoStore } from "../context/globalInfo"; import { apiUrl } from "../apiConfig"; +import { useTranslation } from 'react-i18next'; +import i18n from '../i18n'; import { useThemeMode } from "../context/theme-provider"; 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 { notify } = useGlobalInfoStore(); const { email, password } = form; @@ -40,11 +50,11 @@ const Login = () => { { withCredentials: true } ); dispatch({ type: "LOGIN", payload: data }); - notify("success", "Welcome to Maxun!"); + notify("success", t('login.welcome_notification')); window.localStorage.setItem("user", JSON.stringify(data)); navigate("/"); } catch (err) { - notify("error", "Login Failed. Please try again."); + notify("error", t('login.error_notification')); setLoading(false); } }; @@ -79,23 +89,13 @@ const Login = () => { width: "100%", }} > - logo + logo - Welcome Back! + {t('login.title')} { /> { > {loading ? ( <> - - Loading + + {t('login.loading')} ) : ( - "Login" + t('login.button') )} - - Don’t have an account?{" "} + + {t('login.register_prompt')}{" "} - Register + {t('login.register_link')} @@ -167,4 +167,4 @@ const Login = () => { ); }; -export default Login; +export default Login; \ No newline at end of file diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx index 8af3d3c5..4a82170e 100644 --- a/src/pages/MainPage.tsx +++ b/src/pages/MainPage.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import { MainMenu } from "../components/organisms/MainMenu"; import { Stack } from "@mui/material"; import { Recordings } from "../components/organisms/Recordings"; @@ -30,7 +31,7 @@ export interface ScheduleRunResponse { } export const MainPage = ({ handleEditRecording }: MainPageProps) => { - + const { t } = useTranslation(); const [content, setContent] = React.useState('recordings'); const [sockets, setSockets] = React.useState([]); const [runningRecordingId, setRunningRecordingId] = React.useState(''); @@ -49,10 +50,10 @@ export const MainPage = ({ handleEditRecording }: MainPageProps) => { aborted = true; notifyAboutAbort(runId).then(async (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); } 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) => { if (!aborted) { if (interpretation) { - notify('success', `Interpretation of robot ${runningRecordingName} succeeded`); + notify('success', t('main_page.notifications.interpretation_success', { name: runningRecordingName })); } else { - notify('success', `Failed to interpret ${runningRecordingName} robot`); + notify('success', t('main_page.notifications.interpretation_failed', { name: runningRecordingName })); // destroy the created browser await stopRecording(browserId); } @@ -98,9 +99,9 @@ export const MainPage = ({ handleEditRecording }: MainPageProps) => { socket.on('debugMessage', debugMessageHandler); setContent('runs'); if (browserId) { - notify('info', `Running robot: ${runningRecordingName}`); + notify('info', t('main_page.notifications.run_started', { name: runningRecordingName })); } 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) => { @@ -113,9 +114,9 @@ export const MainPage = ({ handleEditRecording }: MainPageProps) => { scheduleStoredRecording(runningRecordingId, settings) .then(({ message, runId }: ScheduleRunResponse) => { if (message === 'success') { - notify('success', `Robot ${runningRecordingName} scheduled successfully`); + notify('success', t('main_page.notifications.schedule_success', { name: runningRecordingName })); } 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()} ); -}; +}; \ No newline at end of file diff --git a/src/pages/RecordingPage.tsx b/src/pages/RecordingPage.tsx index 8f9eeda0..000de254 100644 --- a/src/pages/RecordingPage.tsx +++ b/src/pages/RecordingPage.tsx @@ -16,6 +16,7 @@ import { WhereWhatPair } from "maxun-core"; import styled from "styled-components"; import BrowserRecordingSave from '../components/molecules/BrowserRecordingSave'; import { useThemeMode } from '../context/theme-provider'; +import { useTranslation } from 'react-i18next'; interface RecordingPageProps { recordingName?: string; @@ -28,6 +29,7 @@ export interface PairForEdit { export const RecordingPage = ({ recordingName }: RecordingPageProps) => { const { darkMode } = useThemeMode(); + const { t } = useTranslation(); const [isLoaded, setIsLoaded] = React.useState(false); const [hasScrollbar, setHasScrollbar] = React.useState(false); const [pairForEdit, setPairForEdit] = useState({ @@ -150,7 +152,7 @@ export const RecordingPage = ({ recordingName }: RecordingPageProps) => { ) : ( - + )}
diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index 474f0182..9c72798b 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -6,9 +6,16 @@ import { Box, Typography, TextField, Button, CircularProgress } from "@mui/mater import { useGlobalInfoStore } from "../context/globalInfo"; import { apiUrl } from "../apiConfig"; import { useThemeMode } from "../context/theme-provider"; +import { useTranslation } from 'react-i18next'; +import i18n from '../i18n'; + const Register = () => { - const [form, setForm] = useState({ email: "", password: "" }); + const {t} = useTranslation(); + const [form, setForm] = useState({ + email: "", + password: "", + }); const [loading, setLoading] = useState(false); const { notify } = useGlobalInfoStore(); const { email, password } = form; @@ -37,11 +44,11 @@ const Register = () => { const { data } = await axios.post(`${apiUrl}/auth/register`, { email, password }); console.log(data); dispatch({ type: "LOGIN", payload: data }); - notify("success", "Registration Successful!"); + notify("success", t('register.welcome_notification')); window.localStorage.setItem("user", JSON.stringify(data)); navigate("/"); - } catch (error: any) { - notify("error", error.response?.data || "Registration Failed. Please try again."); + } catch (error:any) { + notify("error", error.response.data || t('register.error_notification')); setLoading(false); } }; @@ -88,11 +95,11 @@ const Register = () => { }} /> - Create an Account + {t('register.title')} { /> { Loading ) : ( - "Register" + t('register.button') )} - Already have an account?{" "} - - Login + {t('register.register_prompt')}{" "} + + {t('register.login_link')}