Merge branch 'develop' into ui-fix
This commit is contained in:
@@ -7,16 +7,17 @@ COPY package*.json ./
|
|||||||
COPY maxun-core ./maxun-core
|
COPY maxun-core ./maxun-core
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN npm install
|
RUN npm install --legacy-peer-deps
|
||||||
|
|
||||||
# Copy frontend source code and config
|
# Copy frontend source code and config
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
|
COPY public ./public
|
||||||
COPY index.html ./
|
COPY index.html ./
|
||||||
COPY vite.config.js ./
|
COPY vite.config.js ./
|
||||||
COPY tsconfig.json ./
|
COPY tsconfig.json ./
|
||||||
|
|
||||||
# Expose the frontend port
|
# Expose the frontend port
|
||||||
EXPOSE 5173
|
EXPOSE ${FRONTEND_PORT:-5173}
|
||||||
|
|
||||||
# Start the frontend using the client script
|
# Start the frontend using the client script
|
||||||
CMD ["npm", "run", "client", "--", "--host"]
|
CMD ["npm", "run", "client", "--", "--host"]
|
||||||
19
README.md
19
README.md
@@ -1,6 +1,6 @@
|
|||||||
<h1 align="center">
|
<h1 align="center">
|
||||||
<div>
|
<div>
|
||||||
<a href="https://maxun-website.vercel.app/">
|
<a href="https://maxun-website.vercel.app/?ref=ghread">
|
||||||
<img src="/src/assets/maxunlogo.png" width="50" />
|
<img src="/src/assets/maxunlogo.png" width="50" />
|
||||||
<br>
|
<br>
|
||||||
Maxun
|
Maxun
|
||||||
@@ -15,11 +15,11 @@ Maxun lets you train a robot in 2 minutes and scrape the web on auto-pilot. Web
|
|||||||
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://maxun-website.vercel.app/"><b>Website</b></a> |
|
<a href="https://www.maxun.dev/?ref=ghread"><b>Website</b></a> |
|
||||||
<a href="https://discord.gg/5GbPjBUkws"><b>Discord</b></a> |
|
<a href="https://discord.gg/5GbPjBUkws"><b>Discord</b></a> |
|
||||||
<a href="https://x.com/maxun_io"><b>Twitter</b></a> |
|
<a href="https://x.com/maxun_io?ref=ghread"><b>Twitter</b></a> |
|
||||||
<a href="https://docs.google.com/forms/d/e/1FAIpQLSdbD2uhqC4sbg4eLZ9qrFbyrfkXZ2XsI6dQ0USRCQNZNn5pzg/viewform"><b>Join Maxun Cloud</b></a> |
|
<a href="https://docs.google.com/forms/d/e/1FAIpQLSdbD2uhqC4sbg4eLZ9qrFbyrfkXZ2XsI6dQ0USRCQNZNn5pzg/viewform"><b>Join Maxun Cloud</b></a> |
|
||||||
<a href="https://www.youtube.com/@MaxunOSS"><b>Watch Tutorials</b></a>
|
<a href="https://www.youtube.com/@MaxunOSS?ref=ghread"><b>Watch Tutorials</b></a>
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
<a href="https://trendshift.io/repositories/12113" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12113" alt="getmaxun%2Fmaxun | Trendshift" style="width: 250px; height: 55px; margin-top: 10px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/12113" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12113" alt="getmaxun%2Fmaxun | Trendshift" style="width: 250px; height: 55px; margin-top: 10px;" width="250" height="55"/></a>
|
||||||
@@ -29,12 +29,17 @@ Maxun lets you train a robot in 2 minutes and scrape the web on auto-pilot. Web
|
|||||||
|
|
||||||
<img src="https://static.scarf.sh/a.png?x-pxid=c12a77cc-855e-4602-8a0f-614b2d0da56a" />
|
<img src="https://static.scarf.sh/a.png?x-pxid=c12a77cc-855e-4602-8a0f-614b2d0da56a" />
|
||||||
|
|
||||||
> Note: Maxun is in its early stages of development and currently does not support self-hosting. However, you can run Maxun locally. Self-hosting capabilities are planned for a future release and will be available soon.
|
# Installation
|
||||||
|
1. Create a root folder for your project (e.g. 'maxun')
|
||||||
|
2. Create a file named `.env` in the root folder of the project
|
||||||
|
3. Example env file can be viewed [here](https://github.com/getmaxun/maxun/blob/master/ENVEXAMPLE). Copy all content of example env to your `.env` file.
|
||||||
|
4. Choose your installation method below
|
||||||
|
|
||||||
# Local Installation
|
|
||||||
### Docker Compose
|
### Docker Compose
|
||||||
|
1. Copy paste the [docker-compose.yml file](https://github.com/getmaxun/maxun/blob/master/docker-compose.yml) into your root folder
|
||||||
|
2. Ensure you have setup the `.env` file in that same folder
|
||||||
|
3. Run the command below from a terminal
|
||||||
```
|
```
|
||||||
git clone https://github.com/getmaxun/maxun
|
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
You can access the frontend at http://localhost:5173/ and backend at http://localhost:8080/
|
You can access the frontend at http://localhost:5173/ and backend at http://localhost:8080/
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ services:
|
|||||||
#build:
|
#build:
|
||||||
#context: .
|
#context: .
|
||||||
#dockerfile: server/Dockerfile
|
#dockerfile: server/Dockerfile
|
||||||
image: getmaxun/maxun-backend:v0.0.5
|
image: getmaxun/maxun-backend:v0.0.10
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT:-8080}:${BACKEND_PORT:-8080}"
|
- "${BACKEND_PORT:-8080}:${BACKEND_PORT:-8080}"
|
||||||
env_file: .env
|
env_file: .env
|
||||||
@@ -64,28 +64,23 @@ services:
|
|||||||
- redis
|
- redis
|
||||||
- minio
|
- minio
|
||||||
volumes:
|
volumes:
|
||||||
- ./server:/app/server # Mount server source code for hot reloading
|
|
||||||
- ./maxun-core:/app/maxun-core # Mount maxun-core for any shared code updates
|
|
||||||
- /var/run/dbus:/var/run/dbus
|
- /var/run/dbus:/var/run/dbus
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
#build:
|
#build:
|
||||||
#context: .
|
#context: .
|
||||||
#dockerfile: Dockerfile
|
#dockerfile: Dockerfile
|
||||||
image: getmaxun/maxun-frontend:v0.0.2
|
image: getmaxun/maxun-frontend:v0.0.6
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT:-5173}:${FRONTEND_PORT:-5173}"
|
- "${FRONTEND_PORT:-5173}:${FRONTEND_PORT:-5173}"
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
PUBLIC_URL: ${PUBLIC_URL}
|
PUBLIC_URL: ${PUBLIC_URL}
|
||||||
BACKEND_URL: ${BACKEND_URL}
|
BACKEND_URL: ${BACKEND_URL}
|
||||||
volumes:
|
|
||||||
- ./:/app # Mount entire frontend app directory for hot reloading
|
|
||||||
- /app/node_modules # Anonymous volume to prevent overwriting node_modules
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
minio_data:
|
minio_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "maxun-core",
|
"name": "maxun-core",
|
||||||
"version": "0.0.5",
|
"version": "0.0.8",
|
||||||
"description": "Core package for Maxun, responsible for data extraction",
|
"description": "Core package for Maxun, responsible for data extraction",
|
||||||
"main": "build/index.js",
|
"main": "build/index.js",
|
||||||
"typings": "build/index.d.ts",
|
"typings": "build/index.d.ts",
|
||||||
|
|||||||
@@ -188,69 +188,201 @@ function scrapableHeuristics(maxCountPerPage = 50, minArea = 20000, scrolls = 3,
|
|||||||
* @param {Object.<string, {selector: string, tag: string}>} lists The named lists of HTML elements.
|
* @param {Object.<string, {selector: string, tag: string}>} lists The named lists of HTML elements.
|
||||||
* @returns {Array.<Object.<string, string>>}
|
* @returns {Array.<Object.<string, string>>}
|
||||||
*/
|
*/
|
||||||
window.scrapeSchema = function (lists) {
|
window.scrapeSchema = function(lists) {
|
||||||
|
// Utility functions remain the same
|
||||||
function omap(object, f, kf = (x) => x) {
|
function omap(object, f, kf = (x) => x) {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(object)
|
Object.entries(object)
|
||||||
.map(([k, v]) => [kf(k), f(v)]),
|
.map(([k, v]) => [kf(k), f(v)]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ofilter(object, f) {
|
function ofilter(object, f) {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(object)
|
Object.entries(object)
|
||||||
.filter(([k, v]) => f(k, v)),
|
.filter(([k, v]) => f(k, v)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSeedKey(listObj) {
|
function findAllElements(config) {
|
||||||
const maxLength = Math.max(...Object.values(omap(listObj, (x) => document.querySelectorAll(x.selector).length)));
|
// Regular DOM query if no special delimiters
|
||||||
return Object.keys(ofilter(listObj, (_, v) => document.querySelectorAll(v.selector).length === maxLength))[0];
|
if (!config.selector.includes('>>') && !config.selector.includes(':>>')) {
|
||||||
|
return Array.from(document.querySelectorAll(config.selector));
|
||||||
|
}
|
||||||
|
|
||||||
|
// First handle iframe traversal if present
|
||||||
|
if (config.selector.includes(':>>')) {
|
||||||
|
const parts = config.selector.split(':>>').map(s => s.trim());
|
||||||
|
let currentElements = [document];
|
||||||
|
|
||||||
|
// Traverse through each part of the selector
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const part = parts[i];
|
||||||
|
const nextElements = [];
|
||||||
|
const isLast = i === parts.length - 1;
|
||||||
|
|
||||||
|
for (const element of currentElements) {
|
||||||
|
try {
|
||||||
|
// For document or iframe document
|
||||||
|
const doc = element.contentDocument || element || element.contentWindow?.document;
|
||||||
|
if (!doc) continue;
|
||||||
|
|
||||||
|
// Query elements in current context
|
||||||
|
const found = Array.from(doc.querySelectorAll(part));
|
||||||
|
|
||||||
|
if (isLast) {
|
||||||
|
// If it's the last part, keep all matching elements
|
||||||
|
nextElements.push(...found);
|
||||||
|
} else {
|
||||||
|
// If not last, only keep iframes for next iteration
|
||||||
|
const iframes = found.filter(el => el.tagName === 'IFRAME');
|
||||||
|
nextElements.push(...iframes);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Cannot access iframe content:', error, {
|
||||||
|
part,
|
||||||
|
element,
|
||||||
|
index: i
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextElements.length === 0) {
|
||||||
|
console.warn('No elements found for part:', part, 'at depth:', i);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
currentElements = nextElements;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentElements;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle shadow DOM traversal
|
||||||
|
if (config.selector.includes('>>')) {
|
||||||
|
const parts = config.selector.split('>>').map(s => s.trim());
|
||||||
|
let currentElements = [document];
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const nextElements = [];
|
||||||
|
for (const element of currentElements) {
|
||||||
|
// Try regular DOM first
|
||||||
|
const found = Array.from(element.querySelectorAll(part));
|
||||||
|
|
||||||
|
// Then check shadow roots
|
||||||
|
for (const foundEl of found) {
|
||||||
|
if (foundEl.shadowRoot) {
|
||||||
|
nextElements.push(foundEl.shadowRoot);
|
||||||
|
} else {
|
||||||
|
nextElements.push(foundEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentElements = nextElements;
|
||||||
|
}
|
||||||
|
return currentElements.filter(el => !(el instanceof ShadowRoot));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Modified to handle iframe context for URL resolution
|
||||||
|
function getElementValue(element, attribute) {
|
||||||
|
if (!element) return null;
|
||||||
|
|
||||||
|
// Get the base URL for resolving relative URLs
|
||||||
|
const baseURL = element.ownerDocument?.location?.href || window.location.origin;
|
||||||
|
|
||||||
|
switch (attribute) {
|
||||||
|
case 'href': {
|
||||||
|
const relativeHref = element.getAttribute('href');
|
||||||
|
return relativeHref ? new URL(relativeHref, baseURL).href : null;
|
||||||
|
}
|
||||||
|
case 'src': {
|
||||||
|
const relativeSrc = element.getAttribute('src');
|
||||||
|
return relativeSrc ? new URL(relativeSrc, baseURL).href : null;
|
||||||
|
}
|
||||||
|
case 'innerText':
|
||||||
|
return element.innerText?.trim();
|
||||||
|
case 'textContent':
|
||||||
|
return element.textContent?.trim();
|
||||||
|
default:
|
||||||
|
return element.getAttribute(attribute) || element.innerText?.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rest of the functions remain largely the same
|
||||||
|
function getSeedKey(listObj) {
|
||||||
|
const maxLength = Math.max(...Object.values(
|
||||||
|
omap(listObj, (x) => findAllElements(x).length)
|
||||||
|
));
|
||||||
|
return Object.keys(
|
||||||
|
ofilter(listObj, (_, v) => findAllElements(v).length === maxLength)
|
||||||
|
)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find minimal bounding elements
|
||||||
function getMBEs(elements) {
|
function getMBEs(elements) {
|
||||||
return elements.map((element) => {
|
return elements.map((element) => {
|
||||||
let candidate = element;
|
let candidate = element;
|
||||||
const isUniqueChild = (e) => elements
|
const isUniqueChild = (e) => elements
|
||||||
.filter((elem) => e.parentNode?.contains(elem))
|
.filter((elem) => {
|
||||||
|
// Handle both iframe and shadow DOM boundaries
|
||||||
|
const sameContext = elem.getRootNode() === e.getRootNode() &&
|
||||||
|
elem.ownerDocument === e.ownerDocument;
|
||||||
|
return sameContext && e.parentNode?.contains(elem);
|
||||||
|
})
|
||||||
.length === 1;
|
.length === 1;
|
||||||
|
|
||||||
while (candidate && isUniqueChild(candidate)) {
|
while (candidate && isUniqueChild(candidate)) {
|
||||||
candidate = candidate.parentNode;
|
candidate = candidate.parentNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
return candidate;
|
return candidate;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const seedName = getSeedKey(lists);
|
const seedName = getSeedKey(lists);
|
||||||
const seedElements = Array.from(document.querySelectorAll(lists[seedName].selector));
|
const seedElements = findAllElements(lists[seedName]);
|
||||||
const MBEs = getMBEs(seedElements);
|
const MBEs = getMBEs(seedElements);
|
||||||
|
|
||||||
return MBEs.map((mbe) => omap(
|
const mbeResults = MBEs.map((mbe) => omap(
|
||||||
lists,
|
lists,
|
||||||
({ selector, attribute }, key) => {
|
(config) => {
|
||||||
const elem = Array.from(document.querySelectorAll(selector)).find((elem) => mbe.contains(elem));
|
const elem = findAllElements(config)
|
||||||
if (!elem) return undefined;
|
.find((elem) => mbe.contains(elem));
|
||||||
|
|
||||||
switch (attribute) {
|
return elem ? getElementValue(elem, config.attribute) : undefined;
|
||||||
case 'href':
|
},
|
||||||
const relativeHref = elem.getAttribute('href');
|
(key) => key
|
||||||
return relativeHref ? new URL(relativeHref, window.location.origin).href : null;
|
|
||||||
case 'src':
|
|
||||||
const relativeSrc = elem.getAttribute('src');
|
|
||||||
return relativeSrc ? new URL(relativeSrc, window.location.origin).href : null;
|
|
||||||
case 'innerText':
|
|
||||||
return elem.innerText;
|
|
||||||
case 'textContent':
|
|
||||||
return elem.textContent;
|
|
||||||
default:
|
|
||||||
return elem.innerText;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
(key) => key // Use the original key in the output
|
|
||||||
)) || [];
|
)) || [];
|
||||||
}
|
|
||||||
|
// If MBE approach didn't find all elements, try independent scraping
|
||||||
|
if (mbeResults.some(result => Object.values(result).some(v => v === undefined))) {
|
||||||
|
// Fall back to independent scraping
|
||||||
|
const results = [];
|
||||||
|
const foundElements = new Map();
|
||||||
|
|
||||||
|
// Find all elements for each selector
|
||||||
|
Object.entries(lists).forEach(([key, config]) => {
|
||||||
|
const elements = findAllElements(config);
|
||||||
|
foundElements.set(key, elements);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create result objects for each found element
|
||||||
|
foundElements.forEach((elements, key) => {
|
||||||
|
elements.forEach((element, index) => {
|
||||||
|
if (!results[index]) {
|
||||||
|
results[index] = {};
|
||||||
|
}
|
||||||
|
results[index][key] = getElementValue(element, lists[key].attribute);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return results.filter(result => Object.keys(result).length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mbeResults;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scrapes multiple lists of similar items based on a template item.
|
* Scrapes multiple lists of similar items based on a template item.
|
||||||
@@ -262,45 +394,465 @@ function scrapableHeuristics(maxCountPerPage = 50, minArea = 20000, scrolls = 3,
|
|||||||
* @returns {Array.<Array.<Object>>} Array of arrays of scraped items, one sub-array per list
|
* @returns {Array.<Array.<Object>>} Array of arrays of scraped items, one sub-array per list
|
||||||
*/
|
*/
|
||||||
window.scrapeList = async function ({ listSelector, fields, limit = 10 }) {
|
window.scrapeList = async function ({ listSelector, fields, limit = 10 }) {
|
||||||
const scrapedData = [];
|
// Enhanced query function to handle both iframe and shadow DOM
|
||||||
|
const queryElement = (rootElement, selector) => {
|
||||||
|
if (!selector.includes('>>') && !selector.includes(':>>')) {
|
||||||
|
return rootElement.querySelector(selector);
|
||||||
|
}
|
||||||
|
|
||||||
while (scrapedData.length < limit) {
|
const parts = selector.split(/(?:>>|:>>)/).map(part => part.trim());
|
||||||
// Get all parent elements matching the listSelector
|
let currentElement = rootElement;
|
||||||
const parentElements = Array.from(document.querySelectorAll(listSelector));
|
|
||||||
|
|
||||||
// Iterate through each parent element
|
for (let i = 0; i < parts.length; i++) {
|
||||||
for (const parent of parentElements) {
|
if (!currentElement) return null;
|
||||||
if (scrapedData.length >= limit) break;
|
|
||||||
const record = {};
|
|
||||||
|
|
||||||
// For each field, select the corresponding element within the parent
|
// Handle iframe traversal
|
||||||
for (const [label, { selector, attribute }] of Object.entries(fields)) {
|
if (currentElement.tagName === 'IFRAME') {
|
||||||
const fieldElement = parent.querySelector(selector);
|
try {
|
||||||
|
const iframeDoc = currentElement.contentDocument || currentElement.contentWindow.document;
|
||||||
if (fieldElement) {
|
currentElement = iframeDoc.querySelector(parts[i]);
|
||||||
if (attribute === 'innerText') {
|
continue;
|
||||||
record[label] = fieldElement.innerText.trim();
|
} catch (e) {
|
||||||
} else if (attribute === 'innerHTML') {
|
console.warn('Cannot access iframe content:', e);
|
||||||
record[label] = fieldElement.innerHTML.trim();
|
return null;
|
||||||
} else if (attribute === 'src') {
|
}
|
||||||
// Handle relative 'src' URLs
|
|
||||||
const src = fieldElement.getAttribute('src');
|
|
||||||
record[label] = src ? new URL(src, baseUrl).href : null;
|
|
||||||
} else if (attribute === 'href') {
|
|
||||||
// Handle relative 'href' URLs
|
|
||||||
const href = fieldElement.getAttribute('href');
|
|
||||||
record[label] = href ? new URL(href, baseUrl).href : null;
|
|
||||||
} else {
|
|
||||||
record[label] = fieldElement.getAttribute(attribute);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try regular DOM first
|
||||||
|
let nextElement = currentElement.querySelector(parts[i]);
|
||||||
|
|
||||||
|
// Try shadow DOM if not found
|
||||||
|
if (!nextElement && currentElement.shadowRoot) {
|
||||||
|
nextElement = currentElement.shadowRoot.querySelector(parts[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check children's shadow roots if still not found
|
||||||
|
if (!nextElement) {
|
||||||
|
const children = Array.from(currentElement.children || []);
|
||||||
|
for (const child of children) {
|
||||||
|
if (child.shadowRoot) {
|
||||||
|
nextElement = child.shadowRoot.querySelector(parts[i]);
|
||||||
|
if (nextElement) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentElement = nextElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enhanced query all function for both contexts
|
||||||
|
const queryElementAll = (rootElement, selector) => {
|
||||||
|
if (!selector.includes('>>') && !selector.includes(':>>')) {
|
||||||
|
return rootElement.querySelectorAll(selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = selector.split(/(?:>>|:>>)/).map(part => part.trim());
|
||||||
|
let currentElements = [rootElement];
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const nextElements = [];
|
||||||
|
|
||||||
|
for (const element of currentElements) {
|
||||||
|
// Handle iframe traversal
|
||||||
|
if (element.tagName === 'IFRAME') {
|
||||||
|
try {
|
||||||
|
const iframeDoc = element.contentDocument || element.contentWindow.document;
|
||||||
|
nextElements.push(...iframeDoc.querySelectorAll(part));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Cannot access iframe content:', e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Regular DOM elements
|
||||||
|
if (element.querySelectorAll) {
|
||||||
|
nextElements.push(...element.querySelectorAll(part));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shadow DOM elements
|
||||||
|
if (element.shadowRoot) {
|
||||||
|
nextElements.push(...element.shadowRoot.querySelectorAll(part));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check children's shadow roots
|
||||||
|
const children = Array.from(element.children || []);
|
||||||
|
for (const child of children) {
|
||||||
|
if (child.shadowRoot) {
|
||||||
|
nextElements.push(...child.shadowRoot.querySelectorAll(part));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentElements = nextElements;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentElements;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enhanced value extraction with context awareness
|
||||||
|
function extractValue(element, attribute) {
|
||||||
|
if (!element) return null;
|
||||||
|
|
||||||
|
// Get context-aware base URL
|
||||||
|
const baseURL = element.ownerDocument?.location?.href || window.location.origin;
|
||||||
|
|
||||||
|
// Check shadow root first
|
||||||
|
if (element.shadowRoot) {
|
||||||
|
const shadowContent = element.shadowRoot.textContent;
|
||||||
|
if (shadowContent?.trim()) {
|
||||||
|
return shadowContent.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attribute === 'innerText') {
|
||||||
|
return element.innerText.trim();
|
||||||
|
} else if (attribute === 'innerHTML') {
|
||||||
|
return element.innerHTML.trim();
|
||||||
|
} else if (attribute === 'src' || attribute === 'href') {
|
||||||
|
const attrValue = element.getAttribute(attribute);
|
||||||
|
return attrValue ? new URL(attrValue, baseURL).href : null;
|
||||||
|
}
|
||||||
|
return element.getAttribute(attribute);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced table ancestor finding with context support
|
||||||
|
function findTableAncestor(element) {
|
||||||
|
let currentElement = element;
|
||||||
|
const MAX_DEPTH = 5;
|
||||||
|
let depth = 0;
|
||||||
|
|
||||||
|
while (currentElement && depth < MAX_DEPTH) {
|
||||||
|
// Handle shadow DOM
|
||||||
|
if (currentElement.getRootNode() instanceof ShadowRoot) {
|
||||||
|
currentElement = currentElement.getRootNode().host;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentElement.tagName === 'TD') {
|
||||||
|
return { type: 'TD', element: currentElement };
|
||||||
|
} else if (currentElement.tagName === 'TR') {
|
||||||
|
return { type: 'TR', element: currentElement };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle iframe crossing
|
||||||
|
if (currentElement.tagName === 'IFRAME') {
|
||||||
|
try {
|
||||||
|
currentElement = currentElement.contentDocument.body;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentElement = currentElement.parentElement;
|
||||||
|
}
|
||||||
|
depth++;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get cell index
|
||||||
|
function getCellIndex(td) {
|
||||||
|
if (td.getRootNode() instanceof ShadowRoot) {
|
||||||
|
const shadowRoot = td.getRootNode();
|
||||||
|
const allCells = Array.from(shadowRoot.querySelectorAll('td'));
|
||||||
|
return allCells.indexOf(td);
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
let sibling = td;
|
||||||
|
while (sibling = sibling.previousElementSibling) {
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to check for TH elements
|
||||||
|
function hasThElement(row, tableFields) {
|
||||||
|
for (const [_, { selector }] of Object.entries(tableFields)) {
|
||||||
|
const element = queryElement(row, selector);
|
||||||
|
if (element) {
|
||||||
|
let current = element;
|
||||||
|
while (current && current !== row) {
|
||||||
|
if (current.getRootNode() instanceof ShadowRoot) {
|
||||||
|
current = current.getRootNode().host;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.tagName === 'TH') return true;
|
||||||
|
|
||||||
|
if (current.tagName === 'IFRAME') {
|
||||||
|
try {
|
||||||
|
current = current.contentDocument.body;
|
||||||
|
} catch (e) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to filter rows
|
||||||
|
function filterRowsBasedOnTag(rows, tableFields) {
|
||||||
|
for (const row of rows) {
|
||||||
|
if (hasThElement(row, tableFields)) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
scrapedData.push(record);
|
// Include shadow DOM in TH search
|
||||||
|
return rows.filter(row => {
|
||||||
|
const directTH = row.getElementsByTagName('TH').length === 0;
|
||||||
|
const shadowTH = row.shadowRoot ?
|
||||||
|
row.shadowRoot.querySelector('th') === null : true;
|
||||||
|
return directTH && shadowTH;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Class similarity comparison functions
|
||||||
|
function calculateClassSimilarity(classList1, classList2) {
|
||||||
|
const set1 = new Set(classList1);
|
||||||
|
const set2 = new Set(classList2);
|
||||||
|
const intersection = new Set([...set1].filter(x => set2.has(x)));
|
||||||
|
const union = new Set([...set1, ...set2]);
|
||||||
|
return intersection.size / union.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced similar elements finding with context support
|
||||||
|
function findSimilarElements(baseElement, similarityThreshold = 0.7) {
|
||||||
|
const baseClasses = Array.from(baseElement.classList);
|
||||||
|
if (baseClasses.length === 0) return [];
|
||||||
|
|
||||||
|
const allElements = [];
|
||||||
|
|
||||||
|
// Get elements from main document
|
||||||
|
allElements.push(...document.getElementsByTagName(baseElement.tagName));
|
||||||
|
|
||||||
|
// Get elements from shadow DOM
|
||||||
|
if (baseElement.getRootNode() instanceof ShadowRoot) {
|
||||||
|
const shadowHost = baseElement.getRootNode().host;
|
||||||
|
allElements.push(...shadowHost.getElementsByTagName(baseElement.tagName));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get elements from iframes
|
||||||
|
const iframes = document.getElementsByTagName('iframe');
|
||||||
|
for (const iframe of iframes) {
|
||||||
|
try {
|
||||||
|
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
|
||||||
|
allElements.push(...iframeDoc.getElementsByTagName(baseElement.tagName));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Cannot access iframe content:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allElements.filter(element => {
|
||||||
|
if (element === baseElement) return false;
|
||||||
|
const similarity = calculateClassSimilarity(
|
||||||
|
baseClasses,
|
||||||
|
Array.from(element.classList)
|
||||||
|
);
|
||||||
|
return similarity >= similarityThreshold;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main scraping logic with context support
|
||||||
|
let containers = queryElementAll(document, listSelector);
|
||||||
|
containers = Array.from(containers);
|
||||||
|
|
||||||
|
if (containers.length === 0) return [];
|
||||||
|
|
||||||
|
if (limit > 1 && containers.length === 1) {
|
||||||
|
const baseContainer = containers[0];
|
||||||
|
const similarContainers = findSimilarElements(baseContainer);
|
||||||
|
|
||||||
|
if (similarContainers.length > 0) {
|
||||||
|
const newContainers = similarContainers.filter(container =>
|
||||||
|
!container.matches(listSelector)
|
||||||
|
);
|
||||||
|
containers = [...containers, ...newContainers];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return scrapedData
|
|
||||||
};
|
|
||||||
|
|
||||||
|
const containerFields = containers.map(() => ({
|
||||||
|
tableFields: {},
|
||||||
|
nonTableFields: {}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Classify fields
|
||||||
|
containers.forEach((container, containerIndex) => {
|
||||||
|
for (const [label, field] of Object.entries(fields)) {
|
||||||
|
const sampleElement = queryElement(container, field.selector);
|
||||||
|
|
||||||
|
if (sampleElement) {
|
||||||
|
const ancestor = findTableAncestor(sampleElement);
|
||||||
|
if (ancestor) {
|
||||||
|
containerFields[containerIndex].tableFields[label] = {
|
||||||
|
...field,
|
||||||
|
tableContext: ancestor.type,
|
||||||
|
cellIndex: ancestor.type === 'TD' ? getCellIndex(ancestor.element) : -1
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
containerFields[containerIndex].nonTableFields[label] = field;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
containerFields[containerIndex].nonTableFields[label] = field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const tableData = [];
|
||||||
|
const nonTableData = [];
|
||||||
|
|
||||||
|
// Process table data with both iframe and shadow DOM support
|
||||||
|
for (let containerIndex = 0; containerIndex < containers.length; containerIndex++) {
|
||||||
|
const container = containers[containerIndex];
|
||||||
|
const { tableFields } = containerFields[containerIndex];
|
||||||
|
|
||||||
|
if (Object.keys(tableFields).length > 0) {
|
||||||
|
const firstField = Object.values(tableFields)[0];
|
||||||
|
const firstElement = queryElement(container, firstField.selector);
|
||||||
|
let tableContext = firstElement;
|
||||||
|
|
||||||
|
// Find table context including both iframe and shadow DOM
|
||||||
|
while (tableContext && tableContext.tagName !== 'TABLE' && tableContext !== container) {
|
||||||
|
if (tableContext.getRootNode() instanceof ShadowRoot) {
|
||||||
|
tableContext = tableContext.getRootNode().host;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tableContext.tagName === 'IFRAME') {
|
||||||
|
try {
|
||||||
|
tableContext = tableContext.contentDocument.body;
|
||||||
|
} catch (e) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tableContext = tableContext.parentElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tableContext) {
|
||||||
|
// Get rows from all contexts
|
||||||
|
const rows = [];
|
||||||
|
|
||||||
|
// Get rows from regular DOM
|
||||||
|
rows.push(...tableContext.getElementsByTagName('TR'));
|
||||||
|
|
||||||
|
// Get rows from shadow DOM
|
||||||
|
if (tableContext.shadowRoot) {
|
||||||
|
rows.push(...tableContext.shadowRoot.getElementsByTagName('TR'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get rows from iframes
|
||||||
|
if (tableContext.tagName === 'IFRAME') {
|
||||||
|
try {
|
||||||
|
const iframeDoc = tableContext.contentDocument || tableContext.contentWindow.document;
|
||||||
|
rows.push(...iframeDoc.getElementsByTagName('TR'));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Cannot access iframe rows:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const processedRows = filterRowsBasedOnTag(rows, tableFields);
|
||||||
|
|
||||||
|
for (let rowIndex = 0; rowIndex < Math.min(processedRows.length, limit); rowIndex++) {
|
||||||
|
const record = {};
|
||||||
|
const currentRow = processedRows[rowIndex];
|
||||||
|
|
||||||
|
for (const [label, { selector, attribute, cellIndex }] of Object.entries(tableFields)) {
|
||||||
|
let element = null;
|
||||||
|
|
||||||
|
if (cellIndex >= 0) {
|
||||||
|
// Get TD element considering both contexts
|
||||||
|
let td = currentRow.children[cellIndex];
|
||||||
|
|
||||||
|
// Check shadow DOM for td
|
||||||
|
if (!td && currentRow.shadowRoot) {
|
||||||
|
const shadowCells = currentRow.shadowRoot.children;
|
||||||
|
if (shadowCells && shadowCells.length > cellIndex) {
|
||||||
|
td = shadowCells[cellIndex];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (td) {
|
||||||
|
element = queryElement(td, selector);
|
||||||
|
|
||||||
|
if (!element && selector.split(/(?:>>|:>>)/).pop().includes('td:nth-child')) {
|
||||||
|
element = td;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!element) {
|
||||||
|
const tagOnlySelector = selector.split('.')[0];
|
||||||
|
element = queryElement(td, tagOnlySelector);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!element) {
|
||||||
|
let currentElement = td;
|
||||||
|
while (currentElement && currentElement.children.length > 0) {
|
||||||
|
let foundContentChild = false;
|
||||||
|
for (const child of currentElement.children) {
|
||||||
|
if (extractValue(child, attribute)) {
|
||||||
|
currentElement = child;
|
||||||
|
foundContentChild = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!foundContentChild) break;
|
||||||
|
}
|
||||||
|
element = currentElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
element = queryElement(currentRow, selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
record[label] = extractValue(element, attribute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(record).length > 0) {
|
||||||
|
tableData.push(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process non-table data with both contexts support
|
||||||
|
for (let containerIndex = 0; containerIndex < containers.length; containerIndex++) {
|
||||||
|
if (nonTableData.length >= limit) break;
|
||||||
|
|
||||||
|
const container = containers[containerIndex];
|
||||||
|
const { nonTableFields } = containerFields[containerIndex];
|
||||||
|
|
||||||
|
if (Object.keys(nonTableFields).length > 0) {
|
||||||
|
const record = {};
|
||||||
|
|
||||||
|
for (const [label, { selector, attribute }] of Object.entries(nonTableFields)) {
|
||||||
|
// Get the last part of the selector after any context delimiter
|
||||||
|
const relativeSelector = selector.split(/(?:>>|:>>)/).slice(-1)[0];
|
||||||
|
const element = queryElement(container, relativeSelector);
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
record[label] = extractValue(element, attribute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(record).length > 0) {
|
||||||
|
nonTableData.push(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge and limit the results
|
||||||
|
const scrapedData = [...tableData, ...nonTableData];
|
||||||
|
return scrapedData;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets all children of the elements matching the listSelector,
|
* Gets all children of the elements matching the listSelector,
|
||||||
@@ -346,5 +898,5 @@ function scrapableHeuristics(maxCountPerPage = 50, minArea = 20000, scrolls = 3,
|
|||||||
|
|
||||||
return results;
|
return results;
|
||||||
};
|
};
|
||||||
|
|
||||||
})(window);
|
})(window);
|
||||||
@@ -102,7 +102,7 @@ export default class Interpreter extends EventEmitter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaywrightBlocker.fromPrebuiltAdsAndTracking(fetch).then(blocker => {
|
PlaywrightBlocker.fromLists(fetch, ['https://easylist.to/easylist/easylist.txt']).then(blocker => {
|
||||||
this.blocker = blocker;
|
this.blocker = blocker;
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
this.log(`Failed to initialize ad-blocker:`, Level.ERROR);
|
this.log(`Failed to initialize ad-blocker:`, Level.ERROR);
|
||||||
@@ -111,16 +111,71 @@ export default class Interpreter extends EventEmitter {
|
|||||||
|
|
||||||
private async applyAdBlocker(page: Page): Promise<void> {
|
private async applyAdBlocker(page: Page): Promise<void> {
|
||||||
if (this.blocker) {
|
if (this.blocker) {
|
||||||
await this.blocker.enableBlockingInPage(page);
|
try {
|
||||||
|
await this.blocker.enableBlockingInPage(page);
|
||||||
|
} catch (err) {
|
||||||
|
this.log(`Ad-blocker operation failed:`, Level.ERROR);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async disableAdBlocker(page: Page): Promise<void> {
|
private async disableAdBlocker(page: Page): Promise<void> {
|
||||||
if (this.blocker) {
|
if (this.blocker) {
|
||||||
await this.blocker.disableBlockingInPage(page);
|
try {
|
||||||
|
await this.blocker.disableBlockingInPage(page);
|
||||||
|
} catch (err) {
|
||||||
|
this.log(`Ad-blocker operation failed:`, Level.ERROR);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// private getSelectors(workflow: Workflow, actionId: number): string[] {
|
||||||
|
// const selectors: string[] = [];
|
||||||
|
|
||||||
|
// // Validate actionId
|
||||||
|
// if (actionId <= 0) {
|
||||||
|
// console.log("No previous selectors to collect.");
|
||||||
|
// return selectors; // Empty array as there are no previous steps
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Iterate from the start up to (but not including) actionId
|
||||||
|
// for (let index = 0; index < actionId; index++) {
|
||||||
|
// const currentSelectors = workflow[index]?.where?.selectors;
|
||||||
|
// console.log(`Selectors at step ${index}:`, currentSelectors);
|
||||||
|
|
||||||
|
// if (currentSelectors && currentSelectors.length > 0) {
|
||||||
|
// currentSelectors.forEach((selector) => {
|
||||||
|
// if (!selectors.includes(selector)) {
|
||||||
|
// selectors.push(selector); // Avoid duplicates
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// console.log("Collected Selectors:", selectors);
|
||||||
|
// return selectors;
|
||||||
|
// }
|
||||||
|
|
||||||
|
private getSelectors(workflow: Workflow): string[] {
|
||||||
|
const selectorsSet = new Set<string>();
|
||||||
|
|
||||||
|
if (workflow.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = workflow.length - 1; index >= 0; index--) {
|
||||||
|
const currentSelectors = workflow[index]?.where?.selectors;
|
||||||
|
|
||||||
|
if (currentSelectors && currentSelectors.length > 0) {
|
||||||
|
currentSelectors.forEach((selector) => selectorsSet.add(selector));
|
||||||
|
return Array.from(selectorsSet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the context object from given Page and the current workflow.\
|
* Returns the context object from given Page and the current workflow.\
|
||||||
* \
|
* \
|
||||||
@@ -130,45 +185,67 @@ export default class Interpreter extends EventEmitter {
|
|||||||
* @param workflow Current **initialized** workflow (array of where-what pairs).
|
* @param workflow Current **initialized** workflow (array of where-what pairs).
|
||||||
* @returns {PageState} State of the current page.
|
* @returns {PageState} State of the current page.
|
||||||
*/
|
*/
|
||||||
private async getState(page: Page, workflow: Workflow): Promise<PageState> {
|
private async getState(page: Page, workflowCopy: Workflow, selectors: string[]): Promise<PageState> {
|
||||||
/**
|
/**
|
||||||
* All the selectors present in the current Workflow
|
* All the selectors present in the current Workflow
|
||||||
*/
|
*/
|
||||||
const selectors = Preprocessor.extractSelectors(workflow);
|
// const selectors = Preprocessor.extractSelectors(workflow);
|
||||||
|
// console.log("Current selectors:", selectors);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines whether the element targetted by the selector is [actionable](https://playwright.dev/docs/actionability).
|
* Determines whether the element targetted by the selector is [actionable](https://playwright.dev/docs/actionability).
|
||||||
* @param selector Selector to be queried
|
* @param selector Selector to be queried
|
||||||
* @returns True if the targetted element is actionable, false otherwise.
|
* @returns True if the targetted element is actionable, false otherwise.
|
||||||
*/
|
*/
|
||||||
const actionable = async (selector: string): Promise<boolean> => {
|
// const actionable = async (selector: string): Promise<boolean> => {
|
||||||
try {
|
// try {
|
||||||
const proms = [
|
// const proms = [
|
||||||
page.isEnabled(selector, { timeout: 500 }),
|
// page.isEnabled(selector, { timeout: 10000 }),
|
||||||
page.isVisible(selector, { timeout: 500 }),
|
// page.isVisible(selector, { timeout: 10000 }),
|
||||||
];
|
// ];
|
||||||
|
|
||||||
return await Promise.all(proms).then((bools) => bools.every((x) => x));
|
// return await Promise.all(proms).then((bools) => bools.every((x) => x));
|
||||||
} catch (e) {
|
// } catch (e) {
|
||||||
// log(<Error>e, Level.ERROR);
|
// // log(<Error>e, Level.ERROR);
|
||||||
return false;
|
// return false;
|
||||||
}
|
// }
|
||||||
};
|
// };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Object of selectors present in the current page.
|
* Object of selectors present in the current page.
|
||||||
*/
|
*/
|
||||||
|
// const presentSelectors: SelectorArray = await Promise.all(
|
||||||
|
// selectors.map(async (selector) => {
|
||||||
|
// if (await actionable(selector)) {
|
||||||
|
// return [selector];
|
||||||
|
// }
|
||||||
|
// return [];
|
||||||
|
// }),
|
||||||
|
// ).then((x) => x.flat());
|
||||||
|
|
||||||
const presentSelectors: SelectorArray = await Promise.all(
|
const presentSelectors: SelectorArray = await Promise.all(
|
||||||
selectors.map(async (selector) => {
|
selectors.map(async (selector) => {
|
||||||
if (await actionable(selector)) {
|
try {
|
||||||
return [selector];
|
await page.waitForSelector(selector, { state: 'attached' });
|
||||||
}
|
return [selector];
|
||||||
return [];
|
} catch (e) {
|
||||||
}),
|
return [];
|
||||||
|
}
|
||||||
|
}),
|
||||||
).then((x) => x.flat());
|
).then((x) => x.flat());
|
||||||
|
|
||||||
|
const action = workflowCopy[workflowCopy.length - 1];
|
||||||
|
|
||||||
|
// console.log("Next action:", action)
|
||||||
|
|
||||||
|
let url: any = page.url();
|
||||||
|
|
||||||
|
if (action && action.where.url !== url && action.where.url !== "about:blank") {
|
||||||
|
url = action.where.url;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: page.url(),
|
url,
|
||||||
cookies: (await page.context().cookies([page.url()]))
|
cookies: (await page.context().cookies([page.url()]))
|
||||||
.reduce((p, cookie) => (
|
.reduce((p, cookie) => (
|
||||||
{
|
{
|
||||||
@@ -326,7 +403,7 @@ export default class Interpreter extends EventEmitter {
|
|||||||
await this.options.serializableCallback(scrapeResults);
|
await this.options.serializableCallback(scrapeResults);
|
||||||
},
|
},
|
||||||
|
|
||||||
scrapeSchema: async (schema: Record<string, { selector: string; tag: string, attribute: string; }>) => {
|
scrapeSchema: async (schema: Record<string, { selector: string; tag: string, attribute: string; shadow: string}>) => {
|
||||||
await this.ensureScriptsLoaded(page);
|
await this.ensureScriptsLoaded(page);
|
||||||
|
|
||||||
const scrapeResult = await page.evaluate((schemaObj) => window.scrapeSchema(schemaObj), schema);
|
const scrapeResult = await page.evaluate((schemaObj) => window.scrapeSchema(schemaObj), schema);
|
||||||
@@ -365,6 +442,7 @@ export default class Interpreter extends EventEmitter {
|
|||||||
console.log("MERGED results:", mergedResult);
|
console.log("MERGED results:", mergedResult);
|
||||||
|
|
||||||
await this.options.serializableCallback(mergedResult);
|
await this.options.serializableCallback(mergedResult);
|
||||||
|
// await this.options.serializableCallback(scrapeResult);
|
||||||
},
|
},
|
||||||
|
|
||||||
scrapeList: async (config: { listSelector: string, fields: any, limit?: number, pagination: any }) => {
|
scrapeList: async (config: { listSelector: string, fields: any, limit?: number, pagination: any }) => {
|
||||||
@@ -410,6 +488,16 @@ export default class Interpreter extends EventEmitter {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const executeAction = async (invokee: any, methodName: string, args: any) => {
|
||||||
|
console.log("Executing action:", methodName, args);
|
||||||
|
if (!args || Array.isArray(args)) {
|
||||||
|
await (<any>invokee[methodName])(...(args ?? []));
|
||||||
|
} else {
|
||||||
|
await (<any>invokee[methodName])(args);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
for (const step of steps) {
|
for (const step of steps) {
|
||||||
this.log(`Launching ${String(step.action)}`, Level.LOG);
|
this.log(`Launching ${String(step.action)}`, Level.LOG);
|
||||||
|
|
||||||
@@ -427,10 +515,24 @@ export default class Interpreter extends EventEmitter {
|
|||||||
invokee = invokee[level];
|
invokee = invokee[level];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!step.args || Array.isArray(step.args)) {
|
if (methodName === 'waitForLoadState') {
|
||||||
await (<any>invokee[methodName])(...(step.args ?? []));
|
try {
|
||||||
|
await executeAction(invokee, methodName, step.args);
|
||||||
|
} catch (error) {
|
||||||
|
await executeAction(invokee, methodName, 'domcontentloaded');
|
||||||
|
}
|
||||||
|
} else if (methodName === 'click') {
|
||||||
|
try {
|
||||||
|
await executeAction(invokee, methodName, step.args);
|
||||||
|
} catch (error) {
|
||||||
|
try{
|
||||||
|
await executeAction(invokee, methodName, [step.args[0], { force: true }]);
|
||||||
|
} catch (error) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await (<any>invokee[methodName])(step.args);
|
await executeAction(invokee, methodName, step.args);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,6 +577,8 @@ export default class Interpreter extends EventEmitter {
|
|||||||
case 'clickNext':
|
case 'clickNext':
|
||||||
const pageResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
const pageResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
||||||
|
|
||||||
|
// console.log("Page results:", pageResults);
|
||||||
|
|
||||||
// Filter out already scraped items
|
// Filter out already scraped items
|
||||||
const newResults = pageResults.filter(item => {
|
const newResults = pageResults.filter(item => {
|
||||||
const uniqueKey = JSON.stringify(item);
|
const uniqueKey = JSON.stringify(item);
|
||||||
@@ -482,9 +586,9 @@ export default class Interpreter extends EventEmitter {
|
|||||||
scrapedItems.add(uniqueKey); // Mark as scraped
|
scrapedItems.add(uniqueKey); // Mark as scraped
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
allResults = allResults.concat(newResults);
|
allResults = allResults.concat(newResults);
|
||||||
|
|
||||||
if (config.limit && allResults.length >= config.limit) {
|
if (config.limit && allResults.length >= config.limit) {
|
||||||
return allResults.slice(0, config.limit);
|
return allResults.slice(0, config.limit);
|
||||||
}
|
}
|
||||||
@@ -494,7 +598,7 @@ export default class Interpreter extends EventEmitter {
|
|||||||
return allResults; // No more pages to scrape
|
return allResults; // No more pages to scrape
|
||||||
}
|
}
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
nextButton.click(),
|
nextButton.dispatchEvent('click'),
|
||||||
page.waitForNavigation({ waitUntil: 'networkidle' })
|
page.waitForNavigation({ waitUntil: 'networkidle' })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -510,7 +614,7 @@ export default class Interpreter extends EventEmitter {
|
|||||||
return allResults;
|
return allResults;
|
||||||
}
|
}
|
||||||
// Click the 'Load More' button to load additional items
|
// Click the 'Load More' button to load additional items
|
||||||
await loadMoreButton.click();
|
await loadMoreButton.dispatchEvent('click');
|
||||||
await page.waitForTimeout(2000); // Wait for new items to load
|
await page.waitForTimeout(2000); // Wait for new items to load
|
||||||
// After clicking 'Load More', scroll down to load more items
|
// After clicking 'Load More', scroll down to load more items
|
||||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||||
@@ -546,11 +650,66 @@ export default class Interpreter extends EventEmitter {
|
|||||||
return allResults;
|
return allResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getMatchingActionId(workflow: Workflow, pageState: PageState, usedActions: string[]) {
|
||||||
|
for (let actionId = workflow.length - 1; actionId >= 0; actionId--) {
|
||||||
|
const step = workflow[actionId];
|
||||||
|
const isApplicable = this.applicable(step.where, pageState, usedActions);
|
||||||
|
console.log("-------------------------------------------------------------");
|
||||||
|
console.log(`Where:`, step.where);
|
||||||
|
console.log(`Page state:`, pageState);
|
||||||
|
console.log(`Match result: ${isApplicable}`);
|
||||||
|
console.log("-------------------------------------------------------------");
|
||||||
|
|
||||||
|
if (isApplicable) {
|
||||||
|
return actionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeShadowSelectors(workflow: Workflow) {
|
||||||
|
for (let actionId = workflow.length - 1; actionId >= 0; actionId--) {
|
||||||
|
const step = workflow[actionId];
|
||||||
|
|
||||||
|
// Check if step has where and selectors
|
||||||
|
if (step.where && Array.isArray(step.where.selectors)) {
|
||||||
|
// Filter out selectors that contain ">>"
|
||||||
|
step.where.selectors = step.where.selectors.filter(selector => !selector.includes('>>'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return workflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeSpecialSelectors(workflow: Workflow) {
|
||||||
|
for (let actionId = workflow.length - 1; actionId >= 0; actionId--) {
|
||||||
|
const step = workflow[actionId];
|
||||||
|
|
||||||
|
if (step.where && Array.isArray(step.where.selectors)) {
|
||||||
|
// Filter out if selector has EITHER ":>>" OR ">>"
|
||||||
|
step.where.selectors = step.where.selectors.filter(selector =>
|
||||||
|
!(selector.includes(':>>') || selector.includes('>>'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return workflow;
|
||||||
|
}
|
||||||
|
|
||||||
private async runLoop(p: Page, workflow: Workflow) {
|
private async runLoop(p: Page, workflow: Workflow) {
|
||||||
|
let workflowCopy: Workflow = JSON.parse(JSON.stringify(workflow));
|
||||||
|
|
||||||
|
workflowCopy = this.removeSpecialSelectors(workflowCopy);
|
||||||
|
|
||||||
// apply ad-blocker to the current page
|
// apply ad-blocker to the current page
|
||||||
await this.applyAdBlocker(p);
|
try {
|
||||||
|
await this.applyAdBlocker(p);
|
||||||
|
} catch (error) {
|
||||||
|
this.log(`Failed to apply ad-blocker: ${error.message}`, Level.ERROR);
|
||||||
|
}
|
||||||
const usedActions: string[] = [];
|
const usedActions: string[] = [];
|
||||||
|
let selectors: string[] = [];
|
||||||
let lastAction = null;
|
let lastAction = null;
|
||||||
|
let actionId = -1
|
||||||
let repeatCount = 0;
|
let repeatCount = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -559,7 +718,7 @@ export default class Interpreter extends EventEmitter {
|
|||||||
* e.g. via `enqueueLinks`.
|
* e.g. via `enqueueLinks`.
|
||||||
*/
|
*/
|
||||||
p.on('popup', (popup) => {
|
p.on('popup', (popup) => {
|
||||||
this.concurrency.addJob(() => this.runLoop(popup, workflow));
|
this.concurrency.addJob(() => this.runLoop(popup, workflowCopy));
|
||||||
});
|
});
|
||||||
|
|
||||||
/* eslint no-constant-condition: ["warn", { "checkLoops": false }] */
|
/* eslint no-constant-condition: ["warn", { "checkLoops": false }] */
|
||||||
@@ -578,8 +737,11 @@ export default class Interpreter extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let pageState = {};
|
let pageState = {};
|
||||||
|
let getStateTest = "Hello";
|
||||||
try {
|
try {
|
||||||
pageState = await this.getState(p, workflow);
|
pageState = await this.getState(p, workflowCopy, selectors);
|
||||||
|
selectors = [];
|
||||||
|
console.log("Empty selectors:", selectors)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this.log('The browser has been closed.');
|
this.log('The browser has been closed.');
|
||||||
return;
|
return;
|
||||||
@@ -589,32 +751,52 @@ export default class Interpreter extends EventEmitter {
|
|||||||
this.log(`Current state is: \n${JSON.stringify(pageState, null, 2)}`, Level.WARN);
|
this.log(`Current state is: \n${JSON.stringify(pageState, null, 2)}`, Level.WARN);
|
||||||
}
|
}
|
||||||
|
|
||||||
const actionId = workflow.findIndex((step) => {
|
// const actionId = workflow.findIndex((step) => {
|
||||||
const isApplicable = this.applicable(step.where, pageState, usedActions);
|
// const isApplicable = this.applicable(step.where, pageState, usedActions);
|
||||||
console.log(`Where:`, step.where);
|
// console.log("-------------------------------------------------------------");
|
||||||
console.log(`Page state:`, pageState);
|
// console.log(`Where:`, step.where);
|
||||||
console.log(`Match result: ${isApplicable}`);
|
// console.log(`Page state:`, pageState);
|
||||||
return isApplicable;
|
// console.log(`Match result: ${isApplicable}`);
|
||||||
});
|
// console.log("-------------------------------------------------------------");
|
||||||
|
// return isApplicable;
|
||||||
|
// });
|
||||||
|
|
||||||
const action = workflow[actionId];
|
actionId = this.getMatchingActionId(workflowCopy, pageState, usedActions);
|
||||||
|
|
||||||
|
const action = workflowCopy[actionId];
|
||||||
|
|
||||||
|
console.log("MATCHED ACTION:", action);
|
||||||
|
console.log("MATCHED ACTION ID:", actionId);
|
||||||
this.log(`Matched ${JSON.stringify(action?.where)}`, Level.LOG);
|
this.log(`Matched ${JSON.stringify(action?.where)}`, Level.LOG);
|
||||||
|
|
||||||
if (action) { // action is matched
|
if (action) { // action is matched
|
||||||
if (this.options.debugChannel?.activeId) {
|
if (this.options.debugChannel?.activeId) {
|
||||||
this.options.debugChannel.activeId(actionId);
|
this.options.debugChannel.activeId(actionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
repeatCount = action === lastAction ? repeatCount + 1 : 0;
|
repeatCount = action === lastAction ? repeatCount + 1 : 0;
|
||||||
if (this.options.maxRepeats && repeatCount >= this.options.maxRepeats) {
|
|
||||||
|
console.log("REPEAT COUNT", repeatCount);
|
||||||
|
if (this.options.maxRepeats && repeatCount > this.options.maxRepeats) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastAction = action;
|
lastAction = action;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log("Carrying out:", action.what);
|
||||||
await this.carryOutSteps(p, action.what);
|
await this.carryOutSteps(p, action.what);
|
||||||
usedActions.push(action.id ?? 'undefined');
|
usedActions.push(action.id ?? 'undefined');
|
||||||
|
|
||||||
|
workflowCopy.splice(actionId, 1);
|
||||||
|
console.log(`Action with ID ${action.id} removed from the workflow copy.`);
|
||||||
|
|
||||||
|
// const newSelectors = this.getPreviousSelectors(workflow, actionId);
|
||||||
|
const newSelectors = this.getSelectors(workflowCopy);
|
||||||
|
newSelectors.forEach(selector => {
|
||||||
|
if (!selectors.includes(selector)) {
|
||||||
|
selectors.push(selector);
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.log(<Error>e, Level.ERROR);
|
this.log(<Error>e, Level.ERROR);
|
||||||
}
|
}
|
||||||
@@ -643,6 +825,8 @@ export default class Interpreter extends EventEmitter {
|
|||||||
public async run(page: Page, params?: ParamType): Promise<void> {
|
public async run(page: Page, params?: ParamType): Promise<void> {
|
||||||
this.log('Starting the workflow.', Level.LOG);
|
this.log('Starting the workflow.', Level.LOG);
|
||||||
const context = page.context();
|
const context = page.context();
|
||||||
|
|
||||||
|
page.setDefaultNavigationTimeout(100000);
|
||||||
|
|
||||||
// Check proxy settings from context options
|
// Check proxy settings from context options
|
||||||
const contextOptions = (context as any)._options;
|
const contextOptions = (context as any)._options;
|
||||||
|
|||||||
@@ -3,36 +3,36 @@
|
|||||||
*/
|
*/
|
||||||
export default class Concurrency {
|
export default class Concurrency {
|
||||||
/**
|
/**
|
||||||
* Maximum number of workers running in parallel. If set to `null`, there is no limit.
|
* Maximum number of workers running in parallel. If set to `null`, there is no limit.
|
||||||
*/
|
*/
|
||||||
maxConcurrency: number = 1;
|
maxConcurrency: number = 1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Number of currently active workers.
|
* Number of currently active workers.
|
||||||
*/
|
*/
|
||||||
activeWorkers: number = 0;
|
activeWorkers: number = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue of jobs waiting to be completed.
|
* Queue of jobs waiting to be completed.
|
||||||
*/
|
*/
|
||||||
private jobQueue: Function[] = [];
|
private jobQueue: Function[] = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Resolve" callbacks of the waitForCompletion() promises.
|
* "Resolve" callbacks of the waitForCompletion() promises.
|
||||||
*/
|
*/
|
||||||
private waiting: Function[] = [];
|
private waiting: Function[] = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructs a new instance of concurrency manager.
|
* Constructs a new instance of concurrency manager.
|
||||||
* @param {number} maxConcurrency Maximum number of workers running in parallel.
|
* @param {number} maxConcurrency Maximum number of workers running in parallel.
|
||||||
*/
|
*/
|
||||||
constructor(maxConcurrency: number) {
|
constructor(maxConcurrency: number) {
|
||||||
this.maxConcurrency = maxConcurrency;
|
this.maxConcurrency = maxConcurrency;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Takes a waiting job out of the queue and runs it.
|
* Takes a waiting job out of the queue and runs it.
|
||||||
*/
|
*/
|
||||||
private runNextJob(): void {
|
private runNextJob(): void {
|
||||||
const job = this.jobQueue.pop();
|
const job = this.jobQueue.pop();
|
||||||
|
|
||||||
@@ -53,12 +53,12 @@ export default class Concurrency {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pass a job (a time-demanding async function) to the concurrency manager. \
|
* Pass a job (a time-demanding async function) to the concurrency manager. \
|
||||||
* The time of the job's execution depends on the concurrency manager itself
|
* The time of the job's execution depends on the concurrency manager itself
|
||||||
* (given a generous enough `maxConcurrency` value, it might be immediate,
|
* (given a generous enough `maxConcurrency` value, it might be immediate,
|
||||||
* but this is not guaranteed).
|
* but this is not guaranteed).
|
||||||
* @param worker Async function to be executed (job to be processed).
|
* @param worker Async function to be executed (job to be processed).
|
||||||
*/
|
*/
|
||||||
addJob(job: () => Promise<any>): void {
|
addJob(job: () => Promise<any>): void {
|
||||||
// console.debug("Adding a worker!");
|
// console.debug("Adding a worker!");
|
||||||
this.jobQueue.push(job);
|
this.jobQueue.push(job);
|
||||||
@@ -72,11 +72,11 @@ export default class Concurrency {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Waits until there is no running nor waiting job. \
|
* Waits until there is no running nor waiting job. \
|
||||||
* If the concurrency manager is idle at the time of calling this function,
|
* If the concurrency manager is idle at the time of calling this function,
|
||||||
* it waits until at least one job is completed (can be "presubscribed").
|
* it waits until at least one job is completed (can be "presubscribed").
|
||||||
* @returns Promise, resolved after there is no running/waiting worker.
|
* @returns Promise, resolved after there is no running/waiting worker.
|
||||||
*/
|
*/
|
||||||
waitForCompletion(): Promise<void> {
|
waitForCompletion(): Promise<void> {
|
||||||
return new Promise((res) => {
|
return new Promise((res) => {
|
||||||
this.waiting.push(res);
|
this.waiting.push(res);
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "maxun",
|
"name": "maxun",
|
||||||
"version": "0.0.4",
|
"version": "0.0.6",
|
||||||
"author": "Maxun",
|
"author": "Maxun",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -36,13 +36,18 @@
|
|||||||
"fortawesome": "^0.0.1-security",
|
"fortawesome": "^0.0.1-security",
|
||||||
"google-auth-library": "^9.14.1",
|
"google-auth-library": "^9.14.1",
|
||||||
"googleapis": "^144.0.0",
|
"googleapis": "^144.0.0",
|
||||||
|
"i18next": "^24.0.2",
|
||||||
|
"i18next-browser-languagedetector": "^8.0.0",
|
||||||
|
"i18next-http-backend": "^3.0.1",
|
||||||
|
"idcac-playwright": "^0.1.3",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
"joi": "^17.6.0",
|
"joi": "^17.6.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
"loglevel": "^1.8.0",
|
"loglevel": "^1.8.0",
|
||||||
"loglevel-plugin-remote": "^0.6.8",
|
"loglevel-plugin-remote": "^0.6.8",
|
||||||
"maxun-core": "^0.0.5",
|
"maxun-core": "^0.0.8",
|
||||||
"minio": "^8.0.1",
|
"minio": "^8.0.1",
|
||||||
"moment-timezone": "^0.5.45",
|
"moment-timezone": "^0.5.45",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
@@ -56,11 +61,13 @@
|
|||||||
"react": "^18.0.0",
|
"react": "^18.0.0",
|
||||||
"react-dom": "^18.0.0",
|
"react-dom": "^18.0.0",
|
||||||
"react-highlight": "0.15.0",
|
"react-highlight": "0.15.0",
|
||||||
|
"react-i18next": "^15.1.3",
|
||||||
"react-router-dom": "^6.26.1",
|
"react-router-dom": "^6.26.1",
|
||||||
"react-simple-code-editor": "^0.11.2",
|
"react-simple-code-editor": "^0.11.2",
|
||||||
"react-transition-group": "^4.4.2",
|
"react-transition-group": "^4.4.2",
|
||||||
"sequelize": "^6.37.3",
|
"sequelize": "^6.37.3",
|
||||||
"sequelize-typescript": "^2.1.6",
|
"sequelize-typescript": "^2.1.6",
|
||||||
|
"sharp": "^0.33.5",
|
||||||
"socket.io": "^4.4.1",
|
"socket.io": "^4.4.1",
|
||||||
"socket.io-client": "^4.4.1",
|
"socket.io-client": "^4.4.1",
|
||||||
"styled-components": "^5.3.3",
|
"styled-components": "^5.3.3",
|
||||||
@@ -92,6 +99,7 @@
|
|||||||
"@types/cookie-parser": "^1.4.7",
|
"@types/cookie-parser": "^1.4.7",
|
||||||
"@types/express": "^4.17.13",
|
"@types/express": "^4.17.13",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
|
"@types/lodash": "^4.17.14",
|
||||||
"@types/loglevel": "^1.6.3",
|
"@types/loglevel": "^1.6.3",
|
||||||
"@types/node": "22.7.9",
|
"@types/node": "22.7.9",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
|
|||||||
181
perf/performance.ts
Normal file
181
perf/performance.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
// Frontend Performance Monitoring
|
||||||
|
export class FrontendPerformanceMonitor {
|
||||||
|
private metrics: {
|
||||||
|
fps: number[];
|
||||||
|
memoryUsage: MemoryInfo[];
|
||||||
|
renderTime: number[];
|
||||||
|
eventLatency: number[];
|
||||||
|
};
|
||||||
|
private lastFrameTime: number;
|
||||||
|
private frameCount: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.metrics = {
|
||||||
|
fps: [],
|
||||||
|
memoryUsage: [],
|
||||||
|
renderTime: [],
|
||||||
|
eventLatency: [],
|
||||||
|
};
|
||||||
|
this.lastFrameTime = performance.now();
|
||||||
|
this.frameCount = 0;
|
||||||
|
|
||||||
|
// Start monitoring
|
||||||
|
this.startMonitoring();
|
||||||
|
}
|
||||||
|
|
||||||
|
private startMonitoring(): void {
|
||||||
|
// Monitor FPS
|
||||||
|
const measureFPS = () => {
|
||||||
|
const currentTime = performance.now();
|
||||||
|
const elapsed = currentTime - this.lastFrameTime;
|
||||||
|
this.frameCount++;
|
||||||
|
|
||||||
|
if (elapsed >= 1000) { // Calculate FPS every second
|
||||||
|
const fps = Math.round((this.frameCount * 1000) / elapsed);
|
||||||
|
this.metrics.fps.push(fps);
|
||||||
|
this.frameCount = 0;
|
||||||
|
this.lastFrameTime = currentTime;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(measureFPS);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(measureFPS);
|
||||||
|
|
||||||
|
// Monitor Memory Usage
|
||||||
|
if (window.performance && (performance as any).memory) {
|
||||||
|
setInterval(() => {
|
||||||
|
const memory = (performance as any).memory;
|
||||||
|
this.metrics.memoryUsage.push({
|
||||||
|
usedJSHeapSize: memory.usedJSHeapSize,
|
||||||
|
totalJSHeapSize: memory.totalJSHeapSize,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitor Canvas Render Time
|
||||||
|
public measureRenderTime(renderFunction: () => void): void {
|
||||||
|
const startTime = performance.now();
|
||||||
|
renderFunction();
|
||||||
|
const endTime = performance.now();
|
||||||
|
this.metrics.renderTime.push(endTime - startTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitor Event Latency
|
||||||
|
public measureEventLatency(event: MouseEvent | KeyboardEvent): void {
|
||||||
|
const latency = performance.now() - event.timeStamp;
|
||||||
|
this.metrics.eventLatency.push(latency);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Performance Report
|
||||||
|
public getPerformanceReport(): PerformanceReport {
|
||||||
|
return {
|
||||||
|
averageFPS: this.calculateAverage(this.metrics.fps),
|
||||||
|
averageRenderTime: this.calculateAverage(this.metrics.renderTime),
|
||||||
|
averageEventLatency: this.calculateAverage(this.metrics.eventLatency),
|
||||||
|
memoryTrend: this.getMemoryTrend(),
|
||||||
|
lastMemoryUsage: this.metrics.memoryUsage[this.metrics.memoryUsage.length - 1]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateAverage(array: number[]): number {
|
||||||
|
return array.length ? array.reduce((a, b) => a + b) / array.length : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMemoryTrend(): MemoryTrend {
|
||||||
|
if (this.metrics.memoryUsage.length < 2) return 'stable';
|
||||||
|
const latest = this.metrics.memoryUsage[this.metrics.memoryUsage.length - 1];
|
||||||
|
const previous = this.metrics.memoryUsage[this.metrics.memoryUsage.length - 2];
|
||||||
|
const change = latest.usedJSHeapSize - previous.usedJSHeapSize;
|
||||||
|
if (change > 1000000) return 'increasing'; // 1MB threshold
|
||||||
|
if (change < -1000000) return 'decreasing';
|
||||||
|
return 'stable';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backend Performance Monitoring
|
||||||
|
export class BackendPerformanceMonitor {
|
||||||
|
private metrics: {
|
||||||
|
screenshotTimes: number[];
|
||||||
|
emitTimes: number[];
|
||||||
|
memoryUsage: NodeJS.MemoryUsage[];
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.metrics = {
|
||||||
|
screenshotTimes: [],
|
||||||
|
emitTimes: [],
|
||||||
|
memoryUsage: []
|
||||||
|
};
|
||||||
|
this.startMonitoring();
|
||||||
|
}
|
||||||
|
|
||||||
|
private startMonitoring(): void {
|
||||||
|
// Monitor Memory Usage
|
||||||
|
setInterval(() => {
|
||||||
|
this.metrics.memoryUsage.push(process.memoryUsage());
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async measureScreenshotPerformance(
|
||||||
|
makeScreenshot: () => Promise<void>
|
||||||
|
): Promise<void> {
|
||||||
|
const startTime = process.hrtime();
|
||||||
|
await makeScreenshot();
|
||||||
|
const [seconds, nanoseconds] = process.hrtime(startTime);
|
||||||
|
this.metrics.screenshotTimes.push(seconds * 1000 + nanoseconds / 1000000);
|
||||||
|
}
|
||||||
|
|
||||||
|
public measureEmitPerformance(emitFunction: () => void): void {
|
||||||
|
const startTime = process.hrtime();
|
||||||
|
emitFunction();
|
||||||
|
const [seconds, nanoseconds] = process.hrtime(startTime);
|
||||||
|
this.metrics.emitTimes.push(seconds * 1000 + nanoseconds / 1000000);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getPerformanceReport(): BackendPerformanceReport {
|
||||||
|
return {
|
||||||
|
averageScreenshotTime: this.calculateAverage(this.metrics.screenshotTimes),
|
||||||
|
averageEmitTime: this.calculateAverage(this.metrics.emitTimes),
|
||||||
|
currentMemoryUsage: this.metrics.memoryUsage[this.metrics.memoryUsage.length - 1],
|
||||||
|
memoryTrend: this.getMemoryTrend()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateAverage(array: number[]): number {
|
||||||
|
return array.length ? array.reduce((a, b) => a + b) / array.length : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMemoryTrend(): MemoryTrend {
|
||||||
|
if (this.metrics.memoryUsage.length < 2) return 'stable';
|
||||||
|
const latest = this.metrics.memoryUsage[this.metrics.memoryUsage.length - 1];
|
||||||
|
const previous = this.metrics.memoryUsage[this.metrics.memoryUsage.length - 2];
|
||||||
|
const change = latest.heapUsed - previous.heapUsed;
|
||||||
|
if (change > 1000000) return 'increasing';
|
||||||
|
if (change < -1000000) return 'decreasing';
|
||||||
|
return 'stable';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MemoryInfo {
|
||||||
|
usedJSHeapSize: number;
|
||||||
|
totalJSHeapSize: number;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemoryTrend = 'increasing' | 'decreasing' | 'stable';
|
||||||
|
|
||||||
|
interface PerformanceReport {
|
||||||
|
averageFPS: number;
|
||||||
|
averageRenderTime: number;
|
||||||
|
averageEventLatency: number;
|
||||||
|
memoryTrend: MemoryTrend;
|
||||||
|
lastMemoryUsage: MemoryInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackendPerformanceReport {
|
||||||
|
averageScreenshotTime: number;
|
||||||
|
averageEmitTime: number;
|
||||||
|
currentMemoryUsage: NodeJS.MemoryUsage;
|
||||||
|
memoryTrend: MemoryTrend;
|
||||||
|
}
|
||||||
492
public/locales/de.json
Normal file
492
public/locales/de.json
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
{
|
||||||
|
"login": {
|
||||||
|
"title": "Willkommen zurück!",
|
||||||
|
"email": "Geben Sie Ihre geschäftliche E-Mail-Adresse ein",
|
||||||
|
"password": "Passwort",
|
||||||
|
"button": "Einloggen",
|
||||||
|
"loading": "Lädt",
|
||||||
|
"register_prompt": "Noch keinen Account?",
|
||||||
|
"register_link": "Registrieren",
|
||||||
|
"welcome_notification": "Willkommen bei Maxun!",
|
||||||
|
"error_notification": "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut."
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"title": "Konto registrieren",
|
||||||
|
"email": "Geben Sie Ihre geschäftliche E-Mail-Adresse ein",
|
||||||
|
"password": "Passwort",
|
||||||
|
"button": "Registrieren",
|
||||||
|
"loading": "Lädt",
|
||||||
|
"register_prompt": "Bereits ein Konto?",
|
||||||
|
"login_link": "Einloggen",
|
||||||
|
"welcome_notification": "Willkommen bei Maxun!",
|
||||||
|
"error_notification": "Registrierung fehlgeschlagen. Bitte versuchen Sie es erneut."
|
||||||
|
},
|
||||||
|
"recordingtable": {
|
||||||
|
"run": "Ausführen",
|
||||||
|
"name": "Name",
|
||||||
|
"schedule": "Zeitplan",
|
||||||
|
"integrate": "Integrieren",
|
||||||
|
"settings": "Einstellungen",
|
||||||
|
"options": "Optionen",
|
||||||
|
"heading": "Meine Roboter",
|
||||||
|
"new": "Roboter erstellen",
|
||||||
|
"modal": {
|
||||||
|
"title": "Geben Sie die URL ein",
|
||||||
|
"label": "URL",
|
||||||
|
"button": "Aufnahme starten"
|
||||||
|
},
|
||||||
|
"edit": "Bearbeiten",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"duplicate": "Duplizieren",
|
||||||
|
"notifications": {
|
||||||
|
"delete_warning": "Roboter kann nicht gelöscht werden, da zugehörige Ausführungen vorhanden sind",
|
||||||
|
"delete_success": "Roboter erfolgreich gelöscht"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mainmenu": {
|
||||||
|
"recordings": "Roboter",
|
||||||
|
"runs": "Ausführungen",
|
||||||
|
"proxy": "Proxy",
|
||||||
|
"apikey": "API-Schlüssel",
|
||||||
|
"feedback": "Maxun Cloud beitreten",
|
||||||
|
"apidocs": "Website zu API"
|
||||||
|
},
|
||||||
|
"runstable": {
|
||||||
|
"runs": "Alle Ausführungen",
|
||||||
|
"runStatus": "Status",
|
||||||
|
"runName": "Name",
|
||||||
|
"startedAt": "Gestartet am",
|
||||||
|
"finishedAt": "Beendet am",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"settings": "Einstellungen",
|
||||||
|
"search": "Ausführungen suchen...",
|
||||||
|
"notifications": {
|
||||||
|
"no_runs": "Keine Ausführungen gefunden. Bitte versuchen Sie es erneut.",
|
||||||
|
"delete_success": "Ausführung erfolgreich gelöscht"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"proxy": {
|
||||||
|
"title": "Proxy-Konfiguration",
|
||||||
|
"tab_standard": "Standard-Proxy",
|
||||||
|
"tab_rotation": "Automatische Proxy-Rotation",
|
||||||
|
"server_url": "Proxy-Server-URL",
|
||||||
|
"server_url_helper": "Proxy für alle Roboter. HTTP- und SOCKS-Proxys werden unterstützt. Beispiel http://myproxy.com:3128 oder socks5://myproxy.com:3128. Kurzform myproxy.com:3128 wird als HTTP-Proxy behandelt.",
|
||||||
|
"requires_auth": "Authentifizierung erforderlich?",
|
||||||
|
"username": "Benutzername",
|
||||||
|
"password": "Passwort",
|
||||||
|
"add_proxy": "Proxy hinzufügen",
|
||||||
|
"test_proxy": "Proxy testen",
|
||||||
|
"remove_proxy": "Proxy entfernen",
|
||||||
|
"table": {
|
||||||
|
"proxy_url": "Proxy-URL",
|
||||||
|
"requires_auth": "Authentifizierung erforderlich"
|
||||||
|
},
|
||||||
|
"coming_soon": "Demnächst verfügbar - In Open Source (Basis-Rotation) & Cloud (Erweiterte Rotation). Wenn Sie die Infrastruktur nicht selbst verwalten möchten, tragen Sie sich in unsere Cloud-Warteliste ein.",
|
||||||
|
"join_waitlist": "Maxun Cloud Warteliste beitreten",
|
||||||
|
"alert": {
|
||||||
|
"title": "Wenn Ihr Proxy einen Benutzernamen und ein Passwort erfordert, geben Sie diese immer separat von der Proxy-URL an.",
|
||||||
|
"right_way": "Der richtige Weg",
|
||||||
|
"wrong_way": "Der falsche Weg",
|
||||||
|
"proxy_url": "Proxy-URL:",
|
||||||
|
"username": "Benutzername:",
|
||||||
|
"password": "Passwort:"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"config_success": "Proxy-Konfiguration erfolgreich übermittelt",
|
||||||
|
"config_error": "Fehler beim Übermitteln der Proxy-Konfiguration. Bitte erneut versuchen.",
|
||||||
|
"test_success": "Proxy-Konfiguration funktioniert",
|
||||||
|
"test_error": "Fehler beim Testen der Proxy-Konfiguration. Bitte erneut versuchen.",
|
||||||
|
"fetch_success": "Proxy-Konfiguration erfolgreich abgerufen",
|
||||||
|
"remove_success": "Proxy-Konfiguration erfolgreich entfernt",
|
||||||
|
"remove_error": "Fehler beim Entfernen der Proxy-Konfiguration. Bitte erneut versuchen."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"apikey": {
|
||||||
|
"title": "API-Schlüssel verwalten",
|
||||||
|
"default_name": "Maxun API-Schlüssel",
|
||||||
|
"table": {
|
||||||
|
"name": "API-Schlüssel Name",
|
||||||
|
"key": "API-Schlüssel",
|
||||||
|
"actions": "Aktionen"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"copy": "Kopieren",
|
||||||
|
"show": "Anzeigen",
|
||||||
|
"hide": "Ausblenden",
|
||||||
|
"delete": "Löschen"
|
||||||
|
},
|
||||||
|
"no_key_message": "Sie haben noch keinen API-Schlüssel generiert.",
|
||||||
|
"generate_button": "API-Schlüssel generieren",
|
||||||
|
"notifications": {
|
||||||
|
"fetch_error": "API-Schlüssel konnte nicht abgerufen werden - ${error}",
|
||||||
|
"generate_success": "API-Schlüssel erfolgreich generiert",
|
||||||
|
"generate_error": "API-Schlüssel konnte nicht generiert werden - ${error}",
|
||||||
|
"delete_success": "API-Schlüssel erfolgreich gelöscht",
|
||||||
|
"delete_error": "API-Schlüssel konnte nicht gelöscht werden - ${error}",
|
||||||
|
"copy_success": "API-Schlüssel erfolgreich kopiert"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"action_description": {
|
||||||
|
"text": {
|
||||||
|
"title": "Text erfassen",
|
||||||
|
"description": "Fahren Sie über die Texte, die Sie extrahieren möchten, und klicken Sie, um sie auszuwählen"
|
||||||
|
},
|
||||||
|
"screenshot": {
|
||||||
|
"title": "Screenshot erfassen",
|
||||||
|
"description": "Erfassen Sie einen Teil- oder Vollbildschirmfoto der aktuellen Seite."
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"title": "Liste erfassen",
|
||||||
|
"description": "Fahren Sie über die Liste, die Sie extrahieren möchten. Nach der Auswahl können Sie über alle Texte in der ausgewählten Liste fahren. Klicken Sie zum Auswählen."
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"title": "Welche Daten möchten Sie extrahieren?",
|
||||||
|
"description": "Ein Roboter ist darauf ausgelegt, eine Aktion nach der anderen auszuführen. Sie können eine der folgenden Optionen wählen."
|
||||||
|
},
|
||||||
|
"list_stages": {
|
||||||
|
"initial": "Wählen Sie die Liste aus, die Sie extrahieren möchten, zusammen mit den darin enthaltenen Texten",
|
||||||
|
"pagination": "Wählen Sie aus, wie der Roboter den Rest der Liste erfassen kann",
|
||||||
|
"limit": "Wählen Sie die Anzahl der zu extrahierenden Elemente",
|
||||||
|
"complete": "Erfassung ist abgeschlossen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"right_panel": {
|
||||||
|
"buttons": {
|
||||||
|
"capture_list": "Liste erfassen",
|
||||||
|
"capture_text": "Text erfassen",
|
||||||
|
"capture_screenshot": "Screenshot erfassen",
|
||||||
|
"confirm": "Bestätigen",
|
||||||
|
"discard": "Verwerfen",
|
||||||
|
"confirm_capture": "Erfassung bestätigen",
|
||||||
|
"confirm_pagination": "Bestätigen",
|
||||||
|
"confirm_limit": "Bestätigen",
|
||||||
|
"finish_capture": "Erfassung abschließen",
|
||||||
|
"back": "Zurück",
|
||||||
|
"finish": "Fertig",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"delete": "Löschen"
|
||||||
|
},
|
||||||
|
"screenshot": {
|
||||||
|
"capture_fullpage": "Vollständige Seite erfassen",
|
||||||
|
"capture_visible": "Sichtbaren Bereich erfassen",
|
||||||
|
"display_fullpage": "Vollständige Seite Screenshot",
|
||||||
|
"display_visible": "Sichtbarer Bereich Screenshot"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"title": "Wie können wir das nächste Listenelement auf der Seite finden?",
|
||||||
|
"click_next": "Auf 'Weiter' klicken, um zur nächsten Seite zu navigieren",
|
||||||
|
"click_load_more": "Auf 'Mehr laden' klicken, um weitere Elemente zu laden",
|
||||||
|
"scroll_down": "Nach unten scrollen, um mehr Elemente zu laden",
|
||||||
|
"scroll_up": "Nach oben scrollen, um mehr Elemente zu laden",
|
||||||
|
"none": "Keine weiteren Elemente zu laden"
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"title": "Wie viele Zeilen möchten Sie maximal extrahieren?",
|
||||||
|
"custom": "Benutzerdefiniert",
|
||||||
|
"enter_number": "Nummer eingeben"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"label": "Bezeichnung",
|
||||||
|
"data": "Daten",
|
||||||
|
"field_label": "Feldbezeichnung",
|
||||||
|
"field_data": "Felddaten"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"list_selected": "Liste erfolgreich ausgewählt"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"select_pagination": "Bitte wählen Sie einen Paginierungstyp aus.",
|
||||||
|
"select_pagination_element": "Bitte wählen Sie zuerst das Paginierungselement aus.",
|
||||||
|
"select_limit": "Bitte wählen Sie ein Limit oder geben Sie ein benutzerdefiniertes Limit ein.",
|
||||||
|
"invalid_limit": "Bitte geben Sie ein gültiges Limit ein.",
|
||||||
|
"confirm_text_fields": "Bitte bestätigen Sie alle Textfelder",
|
||||||
|
"unable_create_settings": "Listeneinstellungen können nicht erstellt werden. Stellen Sie sicher, dass Sie ein Feld für die Liste definiert haben.",
|
||||||
|
"capture_text_discarded": "Texterfassung verworfen",
|
||||||
|
"capture_list_discarded": "Listenerfassung verworfen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"save_recording": {
|
||||||
|
"title": "Roboter speichern",
|
||||||
|
"robot_name": "Roboter Name",
|
||||||
|
"buttons": {
|
||||||
|
"save": "Speichern",
|
||||||
|
"confirm": "Bestätigen"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"save_success": "Roboter erfolgreich gespeichert"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"user_not_logged": "Benutzer nicht angemeldet. Aufnahme kann nicht gespeichert werden.",
|
||||||
|
"exists_warning": "Ein Roboter mit diesem Namen existiert bereits, bitte bestätigen Sie das Überschreiben des Roboters."
|
||||||
|
},
|
||||||
|
"tooltips": {
|
||||||
|
"saving": "Workflow wird optimiert und gespeichert"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"browser_recording": {
|
||||||
|
"modal": {
|
||||||
|
"confirm_discard": "Sind Sie sicher, dass Sie die Aufnahme verwerfen möchten?"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"terminated": "Aktuelle Aufnahme wurde beendet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"interpretation_log": {
|
||||||
|
"titles": {
|
||||||
|
"output_preview": "Vorschau der Ausgabedaten",
|
||||||
|
"screenshot": "Bildschirmfoto"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"additional_rows": "Weitere Datenzeilen werden nach Abschluss der Aufnahme extrahiert.",
|
||||||
|
"successful_training": "Sie haben den Roboter erfolgreich für Aktionen trainiert! Klicken Sie auf die Schaltfläche unten, um eine Vorschau der Daten zu erhalten, die Ihr Roboter extrahieren wird.",
|
||||||
|
"no_selection": "Sie haben noch nichts zur Extraktion ausgewählt. Sobald Sie dies tun, wird der Roboter hier eine Vorschau Ihrer Auswahl anzeigen."
|
||||||
|
},
|
||||||
|
"data_sections": {
|
||||||
|
"binary_received": "---------- Binäre Ausgabedaten empfangen ----------",
|
||||||
|
"serializable_received": "---------- Serialisierbare Ausgabedaten empfangen ----------",
|
||||||
|
"mimetype": "Medientyp: ",
|
||||||
|
"image_below": "Bild wird unten angezeigt:",
|
||||||
|
"separator": "--------------------------------------------------"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"reset_success": "Vorschau erfolgreich zurückgesetzt"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"interpretation_buttons": {
|
||||||
|
"buttons": {
|
||||||
|
"preview": "Vorschau der Ausgabedaten anzeigen",
|
||||||
|
"reset": "Zurücksetzen",
|
||||||
|
"yes": "Ja",
|
||||||
|
"no": "Nein"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"extracting": "Daten werden extrahiert...bitte warten Sie 10 Sekunden bis 1 Minute",
|
||||||
|
"restart_required": "Bitte starten Sie die Interpretation nach der Aktualisierung der Aufnahme neu",
|
||||||
|
"run_finished": "Durchlauf beendet",
|
||||||
|
"run_failed": "Start fehlgeschlagen"
|
||||||
|
},
|
||||||
|
"modal": {
|
||||||
|
"use_previous": "Möchten Sie Ihre vorherige Auswahl als Bedingung für diese Aktion verwenden?",
|
||||||
|
"previous_action": "Ihre vorherige Aktion war: ",
|
||||||
|
"element_text": "auf einem Element mit Text "
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"recording_page": {
|
||||||
|
"loader": {
|
||||||
|
"browser_startup": "Browser wird gestartet...Navigation zu {{url}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"integration_settings": {
|
||||||
|
"title": "Mit Google Sheet integrieren",
|
||||||
|
"descriptions": {
|
||||||
|
"sync_info": "Wenn Sie diese Option aktivieren, werden bei jeder erfolgreichen Ausführung dieses Roboters die erfassten Daten in Ihrem Google Sheet ergänzt.",
|
||||||
|
"authenticated_as": "Authentifiziert als: {{email}}"
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"success": {
|
||||||
|
"title": "Google Sheet erfolgreich integriert.",
|
||||||
|
"content": "Jedes Mal, wenn dieser Roboter eine erfolgreiche Ausführung erstellt, werden die erfassten Daten Ihrem Google Sheet {{sheetName}} hinzugefügt. Sie können die Datenaktualisierungen",
|
||||||
|
"here": "hier",
|
||||||
|
"note": "Hinweis:",
|
||||||
|
"sync_limitation": "Die vor der Integration mit Google Sheets extrahierten Daten werden nicht im Google Sheet synchronisiert. Nur die nach der Integration extrahierten Daten werden synchronisiert."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"authenticate": "Mit Google authentifizieren",
|
||||||
|
"fetch_sheets": "Google Sheets abrufen",
|
||||||
|
"remove_integration": "Integration entfernen",
|
||||||
|
"submit": "Absenden"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"select_sheet": "Google Sheet auswählen",
|
||||||
|
"selected_sheet": "Ausgewähltes Sheet: {{name}} (ID: {{id}})"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"robot_duplication": {
|
||||||
|
"title": "Roboter duplizieren",
|
||||||
|
"descriptions": {
|
||||||
|
"purpose": "Die Roboter-Duplizierung ist nützlich, um Daten von Seiten mit der gleichen Struktur zu extrahieren.",
|
||||||
|
"example": "Beispiel: Wenn Sie einen Roboter für {{url1}} erstellt haben, können Sie ihn duplizieren, um ähnliche Seiten wie {{url2}} zu durchsuchen, ohne einen Roboter von Grund auf neu zu trainieren.",
|
||||||
|
"warning": "⚠️ Stellen Sie sicher, dass die neue Seite die gleiche Struktur wie die Originalseite hat."
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"target_url": "Roboter Ziel-URL"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"duplicate": "Roboter duplizieren",
|
||||||
|
"cancel": "Abbrechen"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"robot_not_found": "Roboterdetails konnten nicht gefunden werden. Bitte versuchen Sie es erneut.",
|
||||||
|
"url_required": "Ziel-URL ist erforderlich.",
|
||||||
|
"duplicate_success": "Roboter erfolgreich dupliziert.",
|
||||||
|
"duplicate_error": "Fehler beim Aktualisieren der Ziel-URL. Bitte versuchen Sie es erneut.",
|
||||||
|
"unknown_error": "Beim Aktualisieren der Ziel-URL ist ein Fehler aufgetreten."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"robot_settings": {
|
||||||
|
"title": "Roboter-Einstellungen",
|
||||||
|
"target_url": "Roboter-Ziel-URL",
|
||||||
|
"robot_id": "Roboter-ID",
|
||||||
|
"robot_limit": "Roboter-Limit",
|
||||||
|
"created_by_user": "Erstellt von Benutzer",
|
||||||
|
"created_at": "Erstellungsdatum des Roboters",
|
||||||
|
"errors": {
|
||||||
|
"robot_not_found": "Roboterdetails konnten nicht gefunden werden. Bitte versuchen Sie es erneut."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"robot_edit": {
|
||||||
|
"title": "Roboter bearbeiten",
|
||||||
|
"change_name": "Roboternamen ändern",
|
||||||
|
"robot_limit": "Roboter-Limit",
|
||||||
|
"save": "Änderungen speichern",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"notifications": {
|
||||||
|
"update_success": "Roboter erfolgreich aktualisiert.",
|
||||||
|
"update_failed": "Aktualisierung des Roboters fehlgeschlagen. Bitte erneut versuchen.",
|
||||||
|
"update_error": "Beim Aktualisieren des Roboters ist ein Fehler aufgetreten."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schedule_settings": {
|
||||||
|
"title": "Zeitplan-Einstellungen",
|
||||||
|
"run_every": "Ausführen alle",
|
||||||
|
"start_from": "Beginnen ab",
|
||||||
|
"on_day": "An Tag",
|
||||||
|
"at_around": "Um",
|
||||||
|
"timezone": "Zeitzone",
|
||||||
|
"buttons": {
|
||||||
|
"delete_schedule": "Zeitplan löschen",
|
||||||
|
"save_schedule": "Zeitplan speichern",
|
||||||
|
"cancel": "Abbrechen"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"in_between": "Zwischen",
|
||||||
|
"run_once_every": "Ausführen alle",
|
||||||
|
"start_from_label": "Beginnen ab",
|
||||||
|
"on_day_of_month": "Tag des Monats",
|
||||||
|
"on_day": {
|
||||||
|
"st": ".",
|
||||||
|
"nd": ".",
|
||||||
|
"rd": ".",
|
||||||
|
"th": "."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"main_page": {
|
||||||
|
"notifications": {
|
||||||
|
"interpretation_success": "Interpretation des Roboters {{name}} erfolgreich",
|
||||||
|
"interpretation_failed": "Interpretation des Roboters {{name}} fehlgeschlagen",
|
||||||
|
"run_started": "Roboter wird ausgeführt: {{name}}",
|
||||||
|
"run_start_failed": "Fehler beim Ausführen des Roboters: {{name}}",
|
||||||
|
"schedule_success": "Roboter {{name}} erfolgreich geplant",
|
||||||
|
"schedule_failed": "Planen des Roboters {{name}} fehlgeschlagen",
|
||||||
|
"abort_success": "Interpretation des Roboters {{name}} erfolgreich abgebrochen",
|
||||||
|
"abort_failed": "Abbrechen der Interpretation des Roboters {{name}} fehlgeschlagen"
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"recordings": "Roboter",
|
||||||
|
"runs": "Ausführungen",
|
||||||
|
"proxy": "Proxy",
|
||||||
|
"apikey": "API-Schlüssel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"browser_window": {
|
||||||
|
"attribute_modal": {
|
||||||
|
"title": "Attribut auswählen",
|
||||||
|
"notifications": {
|
||||||
|
"list_select_success": "Liste erfolgreich ausgewählt. Wählen Sie die zu extrahierenden Textdaten.",
|
||||||
|
"pagination_select_success": "Paginierungselement erfolgreich ausgewählt."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"attribute_options": {
|
||||||
|
"anchor": {
|
||||||
|
"text": "Text: {{text}}",
|
||||||
|
"url": "URL: {{url}}"
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"alt_text": "Alt-Text: {{altText}}",
|
||||||
|
"image_url": "Bild-URL: {{imageUrl}}"
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"text": "Text: {{text}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"runs_table": {
|
||||||
|
"run_type_chips": {
|
||||||
|
"manual_run": "Manuelle Ausführung",
|
||||||
|
"scheduled_run": "Geplante Ausführung",
|
||||||
|
"api": "API",
|
||||||
|
"unknown_run_type": "Unbekannter Ausführungstyp"
|
||||||
|
},
|
||||||
|
"run_status_chips": {
|
||||||
|
"success": "Erfolg",
|
||||||
|
"running": "Läuft",
|
||||||
|
"scheduled": "Geplant",
|
||||||
|
"failed": "Fehlgeschlagen"
|
||||||
|
},
|
||||||
|
"run_settings_modal": {
|
||||||
|
"title": "Ausführungseinstellungen",
|
||||||
|
"labels": {
|
||||||
|
"run_id": "Ausführungs-ID",
|
||||||
|
"run_by_user": "Ausgeführt von Benutzer",
|
||||||
|
"run_by_schedule": "Ausgeführt nach Zeitplan-ID",
|
||||||
|
"run_by_api": "Ausgeführt durch API",
|
||||||
|
"run_type": "Ausführungstyp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"run_content": {
|
||||||
|
"tabs": {
|
||||||
|
"output_data": "Ausgabedaten",
|
||||||
|
"log": "Protokoll"
|
||||||
|
},
|
||||||
|
"empty_output": "Die Ausgabe ist leer.",
|
||||||
|
"captured_data": {
|
||||||
|
"title": "Erfasste Daten",
|
||||||
|
"download_json": "Als JSON herunterladen",
|
||||||
|
"download_csv": "Als CSV herunterladen"
|
||||||
|
},
|
||||||
|
"captured_screenshot": {
|
||||||
|
"title": "Erfasster Screenshot",
|
||||||
|
"download": "Screenshot herunterladen",
|
||||||
|
"render_failed": "Das Bild konnte nicht gerendert werden"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"stop": "Stoppen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"navbar": {
|
||||||
|
"project_name": "Maxun",
|
||||||
|
"upgrade": {
|
||||||
|
"button": "Upgrade",
|
||||||
|
"modal": {
|
||||||
|
"up_to_date": "🎉 Du bist auf dem neuesten Stand!",
|
||||||
|
"new_version_available": "Eine neue Version ist verfügbar: {{version}}. Aktualisieren Sie auf die neueste Version für Fehlerkorrekturen, Verbesserungen und neue Funktionen!",
|
||||||
|
"view_updates": "Alle Updates anzeigen",
|
||||||
|
"view_updates_link": "hier",
|
||||||
|
"tabs": {
|
||||||
|
"manual_setup": "Manuelles Setup-Upgrade",
|
||||||
|
"docker_setup": "Docker Compose Setup-Upgrade"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"menu_items": {
|
||||||
|
"logout": "Abmelden",
|
||||||
|
"discord": "Discord",
|
||||||
|
"youtube": "YouTube",
|
||||||
|
"twitter": "Twitter (X)",
|
||||||
|
"language": "Sprache"
|
||||||
|
},
|
||||||
|
"recording": {
|
||||||
|
"discard": "Verwerfen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"language_menu": {
|
||||||
|
"en": "Englisch",
|
||||||
|
"es": "Spanisch",
|
||||||
|
"ja": "Japanisch",
|
||||||
|
"zh": "Chinesisch",
|
||||||
|
"de": "Deutsch"
|
||||||
|
}
|
||||||
|
}
|
||||||
502
public/locales/en.json
Normal file
502
public/locales/en.json
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
{
|
||||||
|
"login": {
|
||||||
|
"title": "Welcome Back!",
|
||||||
|
"email": "Enter Work Email",
|
||||||
|
"password": "Password",
|
||||||
|
"button": "Login",
|
||||||
|
"loading": "Loading",
|
||||||
|
"register_prompt": "Don't have an account?",
|
||||||
|
"register_link": "Register",
|
||||||
|
"welcome_notification": "Welcome to Maxun!",
|
||||||
|
"error_notification": "Login Failed. Please try again."
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"title": "Register Account",
|
||||||
|
"email": "Enter Work Email",
|
||||||
|
"password": "Password",
|
||||||
|
"button": "Register",
|
||||||
|
"loading": "Loading",
|
||||||
|
"register_prompt": "Already have an account?",
|
||||||
|
"login_link": "Login",
|
||||||
|
"welcome_notification": "Welcome to Maxun!",
|
||||||
|
"error_notification": "Registeration Failed. Please try again."
|
||||||
|
},
|
||||||
|
"recordingtable":{
|
||||||
|
"run": "Run",
|
||||||
|
"name": "Name",
|
||||||
|
"schedule": "Schedule",
|
||||||
|
"integrate": "Integrate",
|
||||||
|
"settings": "Settings",
|
||||||
|
"options": "Options",
|
||||||
|
"heading":"My Robots",
|
||||||
|
"new":"Create Robot",
|
||||||
|
"modal":{
|
||||||
|
"title":"Enter the URL",
|
||||||
|
"label":"URL",
|
||||||
|
"button":"Start Recording"
|
||||||
|
},
|
||||||
|
"edit":"Edit",
|
||||||
|
"delete":"Delete",
|
||||||
|
"duplicate":"Duplicate",
|
||||||
|
"search":"Search Robots...",
|
||||||
|
"notifications": {
|
||||||
|
"delete_warning": "Cannot delete robot as it has associated runs",
|
||||||
|
"delete_success": "Robot deleted successfully"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mainmenu":{
|
||||||
|
"recordings": "Robots",
|
||||||
|
"runs": "Runs",
|
||||||
|
"proxy": "Proxy",
|
||||||
|
"apikey": "API Key",
|
||||||
|
"feedback":"Join Maxun Cloud",
|
||||||
|
"apidocs":"Website To API"
|
||||||
|
},
|
||||||
|
"runstable":{
|
||||||
|
"runs":"All Runs",
|
||||||
|
"runStatus":"Status",
|
||||||
|
"runName":"Name",
|
||||||
|
"startedAt":"Started At",
|
||||||
|
"finishedAt":"Finished At",
|
||||||
|
"delete":"Delete",
|
||||||
|
"settings":"Settings",
|
||||||
|
"search":"Search Runs...",
|
||||||
|
"notifications": {
|
||||||
|
"no_runs": "No runs found. Please try again.",
|
||||||
|
"delete_success": "Run deleted successfully"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"proxy": {
|
||||||
|
"title": "Proxy Configuration",
|
||||||
|
"tab_standard": "Standard Proxy",
|
||||||
|
"tab_rotation": "Automatic Proxy Rotation",
|
||||||
|
"server_url": "Proxy Server URL",
|
||||||
|
"server_url_helper": "Proxy to be used for all robots. HTTP and SOCKS proxies are supported. Example http://myproxy.com:3128 or socks5://myproxy.com:3128. Short form myproxy.com:3128 is considered an HTTP proxy.",
|
||||||
|
"requires_auth": "Requires Authentication?",
|
||||||
|
"username": "Username",
|
||||||
|
"password": "Password",
|
||||||
|
"add_proxy": "Add Proxy",
|
||||||
|
"test_proxy": "Test Proxy",
|
||||||
|
"remove_proxy": "Remove Proxy",
|
||||||
|
"table": {
|
||||||
|
"proxy_url": "Proxy URL",
|
||||||
|
"requires_auth": "Requires Authentication"
|
||||||
|
},
|
||||||
|
"coming_soon": "Coming Soon - In Open Source (Basic Rotation) & Cloud (Advanced Rotation). If you don't want to manage the infrastructure, join our cloud waitlist to get early access.",
|
||||||
|
"join_waitlist": "Join Maxun Cloud Waitlist",
|
||||||
|
"alert": {
|
||||||
|
"title": "If your proxy requires a username and password, always provide them separately from the proxy URL.",
|
||||||
|
"right_way": "The right way",
|
||||||
|
"wrong_way": "The wrong way",
|
||||||
|
"proxy_url": "Proxy URL:",
|
||||||
|
"username": "Username:",
|
||||||
|
"password": "Password:"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"config_success": "Proxy configuration submitted successfully",
|
||||||
|
"config_error": "Failed to submit proxy configuration. Try again.",
|
||||||
|
"test_success": "Proxy configuration is working",
|
||||||
|
"test_error": "Failed to test proxy configuration. Try again.",
|
||||||
|
"fetch_success": "Proxy configuration fetched successfully",
|
||||||
|
"remove_success": "Proxy configuration removed successfully",
|
||||||
|
"remove_error": "Failed to remove proxy configuration. Try again."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"apikey": {
|
||||||
|
"title": "Manage Your API Key",
|
||||||
|
"default_name": "Maxun API Key",
|
||||||
|
"table": {
|
||||||
|
"name": "API Key Name",
|
||||||
|
"key": "API Key",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"copy": "Copy",
|
||||||
|
"show": "Show",
|
||||||
|
"hide": "Hide",
|
||||||
|
"delete": "Delete"
|
||||||
|
},
|
||||||
|
"no_key_message": "You haven't generated an API key yet.",
|
||||||
|
"generate_button": "Generate API Key",
|
||||||
|
"notifications": {
|
||||||
|
"fetch_error": "Failed to fetch API Key - ${error}",
|
||||||
|
"generate_success": "Generated API Key successfully",
|
||||||
|
"generate_error": "Failed to generate API Key - ${error}",
|
||||||
|
"delete_success": "API Key deleted successfully",
|
||||||
|
"delete_error": "Failed to delete API Key - ${error}",
|
||||||
|
"copy_success": "Copied API Key successfully"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"action_description": {
|
||||||
|
"text": {
|
||||||
|
"title": "Capture Text",
|
||||||
|
"description": "Hover over the texts you want to extract and click to select them"
|
||||||
|
},
|
||||||
|
"screenshot": {
|
||||||
|
"title": "Capture Screenshot",
|
||||||
|
"description": "Capture a partial or full page screenshot of the current page."
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"title": "Capture List",
|
||||||
|
"description": "Hover over the list you want to extract. Once selected, you can hover over all texts inside the list you selected. Click to select them."
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"title": "What data do you want to extract?",
|
||||||
|
"description": "A robot is designed to perform one action at a time. You can choose any of the options below."
|
||||||
|
},
|
||||||
|
"list_stages": {
|
||||||
|
"initial": "Select the list you want to extract along with the texts inside it",
|
||||||
|
"pagination": "Select how the robot can capture the rest of the list",
|
||||||
|
"limit": "Choose the number of items to extract",
|
||||||
|
"complete": "Capture is complete"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"right_panel": {
|
||||||
|
"buttons": {
|
||||||
|
"capture_list": "Capture List",
|
||||||
|
"capture_text": "Capture Text",
|
||||||
|
"capture_screenshot": "Capture Screenshot",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"discard": "Discard",
|
||||||
|
"confirm_capture": "Confirm Capture",
|
||||||
|
"confirm_pagination": "Confirm",
|
||||||
|
"confirm_limit": "Confirm",
|
||||||
|
"finish_capture": "Finish Capture",
|
||||||
|
"back": "Back",
|
||||||
|
"finish": "Finish",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Delete"
|
||||||
|
},
|
||||||
|
"screenshot": {
|
||||||
|
"capture_fullpage": "Capture Fullpage",
|
||||||
|
"capture_visible": "Capture Visible Part",
|
||||||
|
"display_fullpage": "Take Fullpage Screenshot",
|
||||||
|
"display_visible": "Take Visible Part Screenshot"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"title": "How can we find the next list item on the page?",
|
||||||
|
"click_next": "Click on next to navigate to the next page",
|
||||||
|
"click_load_more": "Click on load more to load more items",
|
||||||
|
"scroll_down": "Scroll down to load more items",
|
||||||
|
"scroll_up": "Scroll up to load more items",
|
||||||
|
"none": "No more items to load"
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"title": "What is the maximum number of rows you want to extract?",
|
||||||
|
"custom": "Custom",
|
||||||
|
"enter_number": "Enter number"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"label": "Label",
|
||||||
|
"data": "Data",
|
||||||
|
"field_label": "Field Label",
|
||||||
|
"field_data": "Field Data"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"list_selected": "List Selected Successfully"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"select_pagination": "Please select a pagination type.",
|
||||||
|
"select_pagination_element": "Please select the pagination element first.",
|
||||||
|
"select_limit": "Please select a limit or enter a custom limit.",
|
||||||
|
"invalid_limit": "Please enter a valid limit.",
|
||||||
|
"confirm_text_fields": "Please confirm all text fields",
|
||||||
|
"unable_create_settings": "Unable to create list settings. Make sure you have defined a field for the list.",
|
||||||
|
"capture_text_discarded": "Capture Text Discarded",
|
||||||
|
"capture_list_discarded": "Capture List Discarded"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"save_recording": {
|
||||||
|
"title": "Save Robot",
|
||||||
|
"robot_name": "Robot Name",
|
||||||
|
"buttons": {
|
||||||
|
"save": "Save",
|
||||||
|
"confirm": "Confirm"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"save_success": "Robot saved successfully"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"user_not_logged": "User not logged in. Cannot save recording.",
|
||||||
|
"exists_warning": "Robot with this name already exists, please confirm the Robot's overwrite."
|
||||||
|
},
|
||||||
|
"tooltips": {
|
||||||
|
"saving": "Optimizing and saving the workflow"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"browser_recording": {
|
||||||
|
"modal": {
|
||||||
|
"confirm_discard": "Are you sure you want to discard the recording?"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"terminated": "Current Recording was terminated"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"interpretation_log": {
|
||||||
|
"titles": {
|
||||||
|
"output_preview": "Output Data Preview",
|
||||||
|
"screenshot": "Screenshot"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"additional_rows": "Additional rows of data will be extracted once you finish recording.",
|
||||||
|
"successful_training": "You've successfully trained the robot to perform actions! Click on the button below to get a preview of the data your robot will extract.",
|
||||||
|
"no_selection": "It looks like you have not selected anything for extraction yet. Once you do, the robot will show a preview of your selections here."
|
||||||
|
},
|
||||||
|
"data_sections": {
|
||||||
|
"binary_received": "---------- Binary output data received ----------",
|
||||||
|
"serializable_received": "---------- Serializable output data received ----------",
|
||||||
|
"mimetype": "mimetype: ",
|
||||||
|
"image_below": "Image is rendered below:",
|
||||||
|
"separator": "--------------------------------------------------"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"reset_success": "Output Preview reset successfully"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"interpretation_buttons": {
|
||||||
|
"buttons": {
|
||||||
|
"preview": "Get Preview of Output Data",
|
||||||
|
"reset": "Reset",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"extracting": "Extracting data...please wait for 10secs to 1min",
|
||||||
|
"restart_required": "Please restart the interpretation after updating the recording",
|
||||||
|
"run_finished": "Run finished",
|
||||||
|
"run_failed": "Run failed to start"
|
||||||
|
},
|
||||||
|
"modal": {
|
||||||
|
"use_previous": "Do you want to use your previous selection as a condition for performing this action?",
|
||||||
|
"previous_action": "Your previous action was: ",
|
||||||
|
"element_text": "on an element with text "
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"recording_page": {
|
||||||
|
"loader": {
|
||||||
|
"browser_startup": "Spinning up a browser...Navigating to {{url}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"integration_settings": {
|
||||||
|
"title": "Integrate with Google Sheet",
|
||||||
|
"descriptions": {
|
||||||
|
"sync_info": "If you enable this option, every time this robot runs a task successfully, its captured data will be appended to your Google Sheet.",
|
||||||
|
"authenticated_as": "Authenticated as: {{email}}"
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"success": {
|
||||||
|
"title": "Google Sheet Integrated Successfully.",
|
||||||
|
"content": "Every time this robot creates a successful run, its captured data is appended to your {{sheetName}} Google Sheet. You can check the data updates",
|
||||||
|
"here": "here",
|
||||||
|
"note": "Note:",
|
||||||
|
"sync_limitation": "The data extracted before integrating with Google Sheets will not be synced in the Google Sheet. Only the data extracted after the integration will be synced."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"authenticate": "Authenticate with Google",
|
||||||
|
"fetch_sheets": "Fetch Google Spreadsheets",
|
||||||
|
"remove_integration": "Remove Integration",
|
||||||
|
"submit": "Submit"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"select_sheet": "Select Google Sheet",
|
||||||
|
"selected_sheet": "Selected Sheet: {{name}} (ID: {{id}})"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"auth_error": "Error authenticating with Google",
|
||||||
|
"fetch_error": "Error fetching spreadsheet files: {{message}}",
|
||||||
|
"update_error": "Error updating Google Sheet ID: {{message}}",
|
||||||
|
"remove_error": "Error removing Google Sheets integration: {{message}}"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"sheet_selected": "Google Sheet selected successfully"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"robot_duplication": {
|
||||||
|
"title": "Duplicate Robot",
|
||||||
|
"descriptions": {
|
||||||
|
"purpose": "Robot duplication is useful to extract data from pages with the same structure.",
|
||||||
|
"example": "Example: If you've created a robot for {{url1}}, you can duplicate it to scrape similar pages like {{url2}} without training a robot from scratch.",
|
||||||
|
"warning": "⚠️ Ensure the new page has the same structure as the original page."
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"target_url": "Robot Target URL"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"duplicate": "Duplicate Robot",
|
||||||
|
"cancel": "Cancel"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"robot_not_found": "Could not find robot details. Please try again.",
|
||||||
|
"url_required": "Target URL is required.",
|
||||||
|
"duplicate_success": "Robot duplicated successfully.",
|
||||||
|
"duplicate_error": "Failed to update the Target URL. Please try again.",
|
||||||
|
"unknown_error": "An error occurred while updating the Target URL."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"robot_settings": {
|
||||||
|
"title": "Robot Settings",
|
||||||
|
"target_url": "Robot Target URL",
|
||||||
|
"robot_id": "Robot ID",
|
||||||
|
"robot_limit": "Robot Limit",
|
||||||
|
"created_by_user": "Created By User",
|
||||||
|
"created_at": "Robot Created At",
|
||||||
|
"errors": {
|
||||||
|
"robot_not_found": "Could not find robot details. Please try again."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"robot_edit": {
|
||||||
|
"title": "Edit Robot",
|
||||||
|
"change_name": "Robot Name",
|
||||||
|
"robot_limit": "Robot Limit",
|
||||||
|
"save": "Save Changes",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"notifications": {
|
||||||
|
"update_success": "Robot updated successfully.",
|
||||||
|
"update_failed": "Failed to update the robot. Please try again.",
|
||||||
|
"update_error": "An error occurred while updating the robot."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schedule_settings": {
|
||||||
|
"title": "Schedule Settings",
|
||||||
|
"run_every": "Run every",
|
||||||
|
"start_from": "Start From",
|
||||||
|
"on_day": "On day",
|
||||||
|
"at_around": "At around",
|
||||||
|
"timezone": "Timezone",
|
||||||
|
"buttons": {
|
||||||
|
"delete_schedule": "Delete Schedule",
|
||||||
|
"save_schedule": "Save Schedule",
|
||||||
|
"cancel": "Cancel"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"in_between": "In Between",
|
||||||
|
"run_once_every": "Run once every",
|
||||||
|
"start_from_label": "Start From",
|
||||||
|
"on_day_of_month": "On Day of the Month",
|
||||||
|
"on_day": {
|
||||||
|
"st": "st",
|
||||||
|
"nd": "nd",
|
||||||
|
"rd": "rd",
|
||||||
|
"th": "th"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"main_page": {
|
||||||
|
"notifications": {
|
||||||
|
"interpretation_success": "Interpretation of robot {{name}} succeeded",
|
||||||
|
"interpretation_failed": "Failed to interpret robot {{name}}",
|
||||||
|
"run_started": "Running robot: {{name}}",
|
||||||
|
"run_start_failed": "Failed to run robot: {{name}}",
|
||||||
|
"schedule_success": "Robot {{name}} scheduled successfully",
|
||||||
|
"schedule_failed": "Failed to schedule robot {{name}}",
|
||||||
|
"abort_success": "Interpretation of robot {{name}} aborted successfully",
|
||||||
|
"abort_failed": "Failed to abort the interpretation of robot {{name}}"
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"recordings": "Robots",
|
||||||
|
"runs": "Runs",
|
||||||
|
"proxy": "Proxy",
|
||||||
|
"apikey": "API Key"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"browser_window": {
|
||||||
|
"attribute_modal": {
|
||||||
|
"title": "Select Attribute",
|
||||||
|
"notifications": {
|
||||||
|
"list_select_success": "List has been successfully selected. Please select the text data to extract.",
|
||||||
|
"pagination_select_success": "Pagination element has been successfully selected."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"attribute_options": {
|
||||||
|
"anchor": {
|
||||||
|
"text": "Text: {{text}}",
|
||||||
|
"url": "URL: {{url}}"
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"alt_text": "Alt Text: {{altText}}",
|
||||||
|
"image_url": "Image URL: {{imageUrl}}"
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"text": "Text: {{text}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"runs_table": {
|
||||||
|
"run_type_chips": {
|
||||||
|
"manual_run": "Manual Run",
|
||||||
|
"scheduled_run": "Scheduled Run",
|
||||||
|
"api": "API",
|
||||||
|
"unknown_run_type": "Unknown Run Type"
|
||||||
|
},
|
||||||
|
"run_status_chips": {
|
||||||
|
"success": "Success",
|
||||||
|
"running": "Running",
|
||||||
|
"scheduled": "Scheduled",
|
||||||
|
"failed": "Failed"
|
||||||
|
},
|
||||||
|
"run_settings_modal": {
|
||||||
|
"title": "Run Settings",
|
||||||
|
"labels": {
|
||||||
|
"run_id": "Run ID",
|
||||||
|
"run_by_user": "Run by User",
|
||||||
|
"run_by_schedule": "Run by Schedule ID",
|
||||||
|
"run_by_api": "Run by API",
|
||||||
|
"run_type": "Run Type"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"run_content": {
|
||||||
|
"tabs": {
|
||||||
|
"output_data": "Output Data",
|
||||||
|
"log": "Log"
|
||||||
|
},
|
||||||
|
"empty_output": "The output is empty.",
|
||||||
|
"captured_data": {
|
||||||
|
"title": "Captured Data",
|
||||||
|
"download_json": "Download as JSON",
|
||||||
|
"download_csv": "Download as CSV"
|
||||||
|
},
|
||||||
|
"captured_screenshot": {
|
||||||
|
"title": "Captured Screenshot",
|
||||||
|
"download": "Download Screenshot",
|
||||||
|
"render_failed": "The image failed to render"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"stop": "Stop"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"navbar": {
|
||||||
|
"project_name": "Maxun",
|
||||||
|
"upgrade": {
|
||||||
|
"button": "Upgrade",
|
||||||
|
"modal": {
|
||||||
|
"up_to_date": "🎉 You're up to date!",
|
||||||
|
"new_version_available": "A new version is available: {{version}}. Upgrade to the latest version for bug fixes, enhancements and new features!",
|
||||||
|
"view_updates": "View all the new updates",
|
||||||
|
"view_updates_link": "here",
|
||||||
|
"tabs": {
|
||||||
|
"manual_setup": "Manual Setup Upgrade",
|
||||||
|
"docker_setup": "Docker Compose Setup Upgrade"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"menu_items": {
|
||||||
|
"logout": "Logout",
|
||||||
|
"discord": "Discord",
|
||||||
|
"youtube": "YouTube",
|
||||||
|
"twitter": "Twitter (X)",
|
||||||
|
"language": "Language"
|
||||||
|
},
|
||||||
|
"recording": {
|
||||||
|
"discard": "Discard"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"language_menu": {
|
||||||
|
"en": "English",
|
||||||
|
"es": "Spanish",
|
||||||
|
"ja": "Japanese",
|
||||||
|
"zh": "Chinese",
|
||||||
|
"de": "German"
|
||||||
|
}
|
||||||
|
}
|
||||||
493
public/locales/es.json
Normal file
493
public/locales/es.json
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
{
|
||||||
|
"login": {
|
||||||
|
"title": "¡Bienvenido de nuevo!",
|
||||||
|
"email": "Introducir correo electrónico de trabajo",
|
||||||
|
"password": "Contraseña",
|
||||||
|
"button": "Iniciar sesión",
|
||||||
|
"loading": "Cargando",
|
||||||
|
"register_prompt": "¿No tienes una cuenta?",
|
||||||
|
"register_link": "Registrarse",
|
||||||
|
"welcome_notification": "¡Bienvenido a Maxun!",
|
||||||
|
"error_notification": "Error al iniciar sesión. Por favor, inténtalo de nuevo."
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"title": "Crear cuenta",
|
||||||
|
"email": "Introducir correo electrónico de trabajo",
|
||||||
|
"password": "Contraseña",
|
||||||
|
"button": "Registrarse",
|
||||||
|
"loading": "Cargando",
|
||||||
|
"register_prompt": "¿Ya tienes una cuenta?",
|
||||||
|
"login_link": "Iniciar sesión",
|
||||||
|
"welcome_notification": "¡Bienvenido a Maxun!",
|
||||||
|
"error_notification": "Error en el registro. Por favor, inténtalo de nuevo."
|
||||||
|
},
|
||||||
|
"recordingtable": {
|
||||||
|
"run": "Ejecutar",
|
||||||
|
"name": "Nombre",
|
||||||
|
"schedule": "Programar",
|
||||||
|
"integrate": "Integrar",
|
||||||
|
"settings": "Ajustes",
|
||||||
|
"options": "Opciones",
|
||||||
|
"heading": "Mis Robots",
|
||||||
|
"new": "Crear Robot",
|
||||||
|
"modal": {
|
||||||
|
"title": "Ingresa la URL",
|
||||||
|
"label": "URL",
|
||||||
|
"button": "Comenzar grabación"
|
||||||
|
},
|
||||||
|
"edit": "Editar",
|
||||||
|
"delete": "Eliminar",
|
||||||
|
"duplicate": "Duplicar",
|
||||||
|
"search": "Buscar robots...",
|
||||||
|
"notifications": {
|
||||||
|
"delete_warning": "No se puede eliminar el robot ya que tiene ejecuciones asociadas",
|
||||||
|
"delete_success": "Robot eliminado exitosamente"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mainmenu": {
|
||||||
|
"recordings": "Robots",
|
||||||
|
"runs": "Ejecuciones",
|
||||||
|
"proxy": "Proxy",
|
||||||
|
"apikey": "Clave API",
|
||||||
|
"feedback": "Unirse a Maxun Cloud",
|
||||||
|
"apidocs": "Sitio Web a API"
|
||||||
|
},
|
||||||
|
"runstable": {
|
||||||
|
"runs": "Todas las ejecuciones",
|
||||||
|
"runStatus": "Estado",
|
||||||
|
"runName": "Nombre",
|
||||||
|
"startedAt": "Iniciado el",
|
||||||
|
"finishedAt": "Finalizado el",
|
||||||
|
"delete": "Eliminar",
|
||||||
|
"settings": "Ajustes",
|
||||||
|
"search": "Buscar ejecuciones...",
|
||||||
|
"notifications": {
|
||||||
|
"no_runs": "No se encontraron ejecuciones. Por favor, inténtelo de nuevo.",
|
||||||
|
"delete_success": "Ejecución eliminada con éxito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"proxy": {
|
||||||
|
"title": "Configuración del Proxy",
|
||||||
|
"tab_standard": "Proxy Estándar",
|
||||||
|
"tab_rotation": "Rotación Automática de Proxy",
|
||||||
|
"server_url": "URL del Servidor Proxy",
|
||||||
|
"server_url_helper": "Proxy para usar en todos los robots. Se admiten proxies HTTP y SOCKS. Ejemplo http://myproxy.com:3128 o socks5://myproxy.com:3128. La forma corta myproxy.com:3128 se considera un proxy HTTP.",
|
||||||
|
"requires_auth": "¿Requiere Autenticación?",
|
||||||
|
"username": "Usuario",
|
||||||
|
"password": "Contraseña",
|
||||||
|
"add_proxy": "Agregar Proxy",
|
||||||
|
"test_proxy": "Probar Proxy",
|
||||||
|
"remove_proxy": "Eliminar Proxy",
|
||||||
|
"table": {
|
||||||
|
"proxy_url": "URL del Proxy",
|
||||||
|
"requires_auth": "Requiere Autenticación"
|
||||||
|
},
|
||||||
|
"coming_soon": "Próximamente - En Open Source (Rotación Básica) y Cloud (Rotación Avanzada). Si no desea administrar la infraestructura, únase a nuestra lista de espera en la nube para obtener acceso anticipado.",
|
||||||
|
"join_waitlist": "Unirse a la Lista de Espera de Maxun Cloud",
|
||||||
|
"alert": {
|
||||||
|
"title": "Si su proxy requiere un nombre de usuario y contraseña, proporcione siempre estos datos por separado de la URL del proxy.",
|
||||||
|
"right_way": "La forma correcta",
|
||||||
|
"wrong_way": "La forma incorrecta",
|
||||||
|
"proxy_url": "URL del Proxy:",
|
||||||
|
"username": "Usuario:",
|
||||||
|
"password": "Contraseña:"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"config_success": "Configuración del proxy enviada con éxito",
|
||||||
|
"config_error": "Error al enviar la configuración del proxy. Inténtelo de nuevo.",
|
||||||
|
"test_success": "La configuración del proxy funciona correctamente",
|
||||||
|
"test_error": "Error al probar la configuración del proxy. Inténtelo de nuevo.",
|
||||||
|
"fetch_success": "Configuración del proxy recuperada con éxito",
|
||||||
|
"remove_success": "Configuración del proxy eliminada con éxito",
|
||||||
|
"remove_error": "Error al eliminar la configuración del proxy. Inténtelo de nuevo."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"apikey": {
|
||||||
|
"title": "Gestionar tu Clave API",
|
||||||
|
"default_name": "Clave API de Maxun",
|
||||||
|
"table": {
|
||||||
|
"name": "Nombre de la Clave API",
|
||||||
|
"key": "Clave API",
|
||||||
|
"actions": "Acciones"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"copy": "Copiar",
|
||||||
|
"show": "Mostrar",
|
||||||
|
"hide": "Ocultar",
|
||||||
|
"delete": "Eliminar"
|
||||||
|
},
|
||||||
|
"no_key_message": "Aún no has generado una clave API.",
|
||||||
|
"generate_button": "Generar Clave API",
|
||||||
|
"notifications": {
|
||||||
|
"fetch_error": "Error al obtener la clave API - ${error}",
|
||||||
|
"generate_success": "Clave API generada con éxito",
|
||||||
|
"generate_error": "Error al generar la clave API - ${error}",
|
||||||
|
"delete_success": "Clave API eliminada con éxito",
|
||||||
|
"delete_error": "Error al eliminar la clave API - ${error}",
|
||||||
|
"copy_success": "Clave API copiada con éxito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"action_description": {
|
||||||
|
"text": {
|
||||||
|
"title": "Capturar Texto",
|
||||||
|
"description": "Pase el cursor sobre los textos que desea extraer y haga clic para seleccionarlos"
|
||||||
|
},
|
||||||
|
"screenshot": {
|
||||||
|
"title": "Capturar Pantalla",
|
||||||
|
"description": "Capture una captura de pantalla parcial o completa de la página actual."
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"title": "Capturar Lista",
|
||||||
|
"description": "Pase el cursor sobre la lista que desea extraer. Una vez seleccionada, puede pasar el cursor sobre todos los textos dentro de la lista seleccionada. Haga clic para seleccionarlos."
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"title": "¿Qué datos desea extraer?",
|
||||||
|
"description": "Un robot está diseñado para realizar una acción a la vez. Puede elegir cualquiera de las siguientes opciones."
|
||||||
|
},
|
||||||
|
"list_stages": {
|
||||||
|
"initial": "Seleccione la lista que desea extraer junto con los textos que contiene",
|
||||||
|
"pagination": "Seleccione cómo puede el robot capturar el resto de la lista",
|
||||||
|
"limit": "Elija el número de elementos a extraer",
|
||||||
|
"complete": "Captura completada"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"right_panel": {
|
||||||
|
"buttons": {
|
||||||
|
"capture_list": "Capturar Lista",
|
||||||
|
"capture_text": "Capturar Texto",
|
||||||
|
"capture_screenshot": "Capturar Pantalla",
|
||||||
|
"confirm": "Confirmar",
|
||||||
|
"discard": "Descartar",
|
||||||
|
"confirm_capture": "Confirmar Captura",
|
||||||
|
"confirm_pagination": "Confirmar",
|
||||||
|
"confirm_limit": "Confirmar",
|
||||||
|
"finish_capture": "Finalizar Captura",
|
||||||
|
"back": "Atrás",
|
||||||
|
"finish": "Finalizar",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"delete": "Eliminar"
|
||||||
|
},
|
||||||
|
"screenshot": {
|
||||||
|
"capture_fullpage": "Capturar Página Completa",
|
||||||
|
"capture_visible": "Capturar Parte Visible",
|
||||||
|
"display_fullpage": "Capturar Screenshot de Página Completa",
|
||||||
|
"display_visible": "Capturar Screenshot de Parte Visible"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"title": "¿Cómo podemos encontrar el siguiente elemento de la lista en la página?",
|
||||||
|
"click_next": "Hacer clic en siguiente para navegar a la siguiente página",
|
||||||
|
"click_load_more": "Hacer clic en cargar más para cargar más elementos",
|
||||||
|
"scroll_down": "Desplazarse hacia abajo para cargar más elementos",
|
||||||
|
"scroll_up": "Desplazarse hacia arriba para cargar más elementos",
|
||||||
|
"none": "No hay más elementos para cargar"
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"title": "¿Cuál es el número máximo de filas que desea extraer?",
|
||||||
|
"custom": "Personalizado",
|
||||||
|
"enter_number": "Ingrese número"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"label": "Etiqueta",
|
||||||
|
"data": "Datos",
|
||||||
|
"field_label": "Etiqueta del Campo",
|
||||||
|
"field_data": "Datos del Campo"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"list_selected": "Lista seleccionada exitosamente"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"select_pagination": "Por favor seleccione un tipo de paginación.",
|
||||||
|
"select_pagination_element": "Por favor seleccione primero el elemento de paginación.",
|
||||||
|
"select_limit": "Por favor seleccione un límite o ingrese un límite personalizado.",
|
||||||
|
"invalid_limit": "Por favor ingrese un límite válido.",
|
||||||
|
"confirm_text_fields": "Por favor confirme todos los campos de texto",
|
||||||
|
"unable_create_settings": "No se pueden crear las configuraciones de la lista. Asegúrese de haber definido un campo para la lista.",
|
||||||
|
"capture_text_discarded": "Captura de texto descartada",
|
||||||
|
"capture_list_discarded": "Captura de lista descartada"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"save_recording": {
|
||||||
|
"title": "Guardar Robot",
|
||||||
|
"robot_name": "Nombre del Robot",
|
||||||
|
"buttons": {
|
||||||
|
"save": "Guardar",
|
||||||
|
"confirm": "Confirmar"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"save_success": "Robot guardado exitosamente"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"user_not_logged": "Usuario no conectado. No se puede guardar la grabación.",
|
||||||
|
"exists_warning": "Ya existe un robot con este nombre, por favor confirme la sobrescritura del robot."
|
||||||
|
},
|
||||||
|
"tooltips": {
|
||||||
|
"saving": "Optimizando y guardando el flujo de trabajo"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"browser_recording": {
|
||||||
|
"modal": {
|
||||||
|
"confirm_discard": "¿Está seguro de que desea descartar la grabación?"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"terminated": "La grabación actual fue terminada"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"interpretation_log": {
|
||||||
|
"titles": {
|
||||||
|
"output_preview": "Vista Previa de Datos de Salida",
|
||||||
|
"screenshot": "Captura de pantalla"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"additional_rows": "Se extraerán filas adicionales de datos una vez que termine la grabación.",
|
||||||
|
"successful_training": "¡Has entrenado exitosamente al robot para realizar acciones! Haz clic en el botón de abajo para obtener una vista previa de los datos que tu robot extraerá.",
|
||||||
|
"no_selection": "Parece que aún no has seleccionado nada para extraer. Una vez que lo hagas, el robot mostrará una vista previa de tus selecciones aquí."
|
||||||
|
},
|
||||||
|
"data_sections": {
|
||||||
|
"binary_received": "---------- Datos binarios de salida recibidos ----------",
|
||||||
|
"serializable_received": "---------- Datos serializables de salida recibidos ----------",
|
||||||
|
"mimetype": "tipo MIME: ",
|
||||||
|
"image_below": "La imagen se muestra a continuación:",
|
||||||
|
"separator": "--------------------------------------------------"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"interpretation_buttons": {
|
||||||
|
"buttons": {
|
||||||
|
"preview": "Obtener Vista Previa de Datos de Salida",
|
||||||
|
"reset": "Restablecer",
|
||||||
|
"yes": "Sí",
|
||||||
|
"no": "No"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"extracting": "Extrayendo datos...espere de 10 segundos a 1 minuto",
|
||||||
|
"restart_required": "Por favor, reinicie la interpretación después de actualizar la grabación",
|
||||||
|
"run_finished": "Ejecución finalizada",
|
||||||
|
"run_failed": "Error al iniciar la ejecución"
|
||||||
|
},
|
||||||
|
"modal": {
|
||||||
|
"use_previous": "¿Desea usar su selección anterior como condición para realizar esta acción?",
|
||||||
|
"previous_action": "Su acción anterior fue: ",
|
||||||
|
"element_text": "en un elemento con texto "
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"reset_success": "Vista previa restablecida correctamente"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"recording_page": {
|
||||||
|
"loader": {
|
||||||
|
"browser_startup": "Iniciando el navegador...Navegando a {{url}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"integration_settings": {
|
||||||
|
"title": "Integrar con Google Sheet",
|
||||||
|
"descriptions": {
|
||||||
|
"sync_info": "Si habilitas esta opción, cada vez que este robot ejecute una tarea exitosamente, sus datos capturados se añadirán a tu Google Sheet.",
|
||||||
|
"authenticated_as": "Autenticado como: {{email}}"
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"success": {
|
||||||
|
"title": "Google Sheet integrado exitosamente.",
|
||||||
|
"content": "Cada vez que este robot crea una ejecución exitosa, sus datos capturados se añaden a tu Google Sheet {{sheetName}}. Puedes verificar las actualizaciones de datos",
|
||||||
|
"here": "aquí",
|
||||||
|
"note": "Nota:",
|
||||||
|
"sync_limitation": "Los datos extraídos antes de la integración con Google Sheets no se sincronizarán en el Google Sheet. Solo se sincronizarán los datos extraídos después de la integración."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"authenticate": "Autenticar con Google",
|
||||||
|
"fetch_sheets": "Obtener Google Sheets",
|
||||||
|
"remove_integration": "Eliminar integración",
|
||||||
|
"submit": "Enviar"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"select_sheet": "Seleccionar Google Sheet",
|
||||||
|
"selected_sheet": "Hoja seleccionada: {{name}} (ID: {{id}})"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"robot_duplication": {
|
||||||
|
"title": "Duplicar Robot",
|
||||||
|
"descriptions": {
|
||||||
|
"purpose": "La duplicación de robots es útil para extraer datos de páginas con la misma estructura.",
|
||||||
|
"example": "Ejemplo: Si has creado un robot para {{url1}}, puedes duplicarlo para extraer páginas similares como {{url2}} sin tener que entrenar un robot desde cero.",
|
||||||
|
"warning": "⚠️ Asegúrate de que la nueva página tenga la misma estructura que la página original."
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"target_url": "URL Destino del Robot"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"duplicate": "Duplicar Robot",
|
||||||
|
"cancel": "Cancelar"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"robot_not_found": "No se pudieron encontrar los detalles del robot. Por favor, inténtalo de nuevo.",
|
||||||
|
"url_required": "Se requiere la URL de destino.",
|
||||||
|
"duplicate_success": "Robot duplicado con éxito.",
|
||||||
|
"duplicate_error": "Error al actualizar la URL de destino. Por favor, inténtalo de nuevo.",
|
||||||
|
"unknown_error": "Ocurrió un error al actualizar la URL de destino."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"robot_settings": {
|
||||||
|
"title": "Configuración del Robot",
|
||||||
|
"target_url": "URL de Destino del Robot",
|
||||||
|
"robot_id": "ID del Robot",
|
||||||
|
"robot_limit": "Límite del Robot",
|
||||||
|
"created_by_user": "Creado por Usuario",
|
||||||
|
"created_at": "Fecha de Creación del Robot",
|
||||||
|
"errors": {
|
||||||
|
"robot_not_found": "No se pudieron encontrar los detalles del robot. Inténtelo de nuevo."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"robot_edit": {
|
||||||
|
"title": "Editar Robot",
|
||||||
|
"change_name": "Cambiar Nombre del Robot",
|
||||||
|
"robot_limit": "Límite del Robot",
|
||||||
|
"save": "Guardar Cambios",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"notifications": {
|
||||||
|
"update_success": "Robot actualizado exitosamente.",
|
||||||
|
"update_failed": "Error al actualizar el robot. Intente de nuevo.",
|
||||||
|
"update_error": "Ocurrió un error al actualizar el robot."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schedule_settings": {
|
||||||
|
"title": "Configuración de Programación",
|
||||||
|
"run_every": "Ejecutar cada",
|
||||||
|
"start_from": "Iniciar desde",
|
||||||
|
"on_day": "En día",
|
||||||
|
"at_around": "Alrededor de",
|
||||||
|
"timezone": "Zona horaria",
|
||||||
|
"buttons": {
|
||||||
|
"delete_schedule": "Eliminar Programación",
|
||||||
|
"save_schedule": "Guardar Programación",
|
||||||
|
"cancel": "Cancelar"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"in_between": "Entre",
|
||||||
|
"run_once_every": "Ejecutar cada",
|
||||||
|
"start_from_label": "Iniciar desde",
|
||||||
|
"on_day_of_month": "Día del mes",
|
||||||
|
"on_day": {
|
||||||
|
"st": "º",
|
||||||
|
"nd": "º",
|
||||||
|
"rd": "º",
|
||||||
|
"th": "º"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"main_page": {
|
||||||
|
"notifications": {
|
||||||
|
"interpretation_success": "Interpretación del robot {{name}} completada con éxito",
|
||||||
|
"interpretation_failed": "Error al interpretar el robot {{name}}",
|
||||||
|
"run_started": "Ejecutando robot: {{name}}",
|
||||||
|
"run_start_failed": "Error al ejecutar el robot: {{name}}",
|
||||||
|
"schedule_success": "Robot {{name}} programado exitosamente",
|
||||||
|
"schedule_failed": "Error al programar el robot {{name}}",
|
||||||
|
"abort_success": "Interpretación del robot {{name}} abortada exitosamente",
|
||||||
|
"abort_failed": "Error al abortar la interpretación del robot {{name}}"
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"recordings": "Robots",
|
||||||
|
"runs": "Ejecuciones",
|
||||||
|
"proxy": "Proxy",
|
||||||
|
"apikey": "Clave API"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"browser_window": {
|
||||||
|
"attribute_modal": {
|
||||||
|
"title": "Seleccionar Atributo",
|
||||||
|
"notifications": {
|
||||||
|
"list_select_success": "Lista seleccionada correctamente. Seleccione los datos de texto para extracción.",
|
||||||
|
"pagination_select_success": "Elemento de paginación seleccionado correctamente."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"attribute_options": {
|
||||||
|
"anchor": {
|
||||||
|
"text": "Texto: {{text}}",
|
||||||
|
"url": "URL: {{url}}"
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"alt_text": "Texto Alt: {{altText}}",
|
||||||
|
"image_url": "URL de Imagen: {{imageUrl}}"
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"text": "Texto: {{text}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"runs_table": {
|
||||||
|
"run_type_chips": {
|
||||||
|
"manual_run": "Ejecución Manual",
|
||||||
|
"scheduled_run": "Ejecución Programada",
|
||||||
|
"api": "API",
|
||||||
|
"unknown_run_type": "Tipo de Ejecución Desconocido"
|
||||||
|
},
|
||||||
|
"run_status_chips": {
|
||||||
|
"success": "Éxito",
|
||||||
|
"running": "Ejecutando",
|
||||||
|
"scheduled": "Programado",
|
||||||
|
"failed": "Fallido"
|
||||||
|
},
|
||||||
|
"run_settings_modal": {
|
||||||
|
"title": "Configuración de Ejecución",
|
||||||
|
"labels": {
|
||||||
|
"run_id": "ID de Ejecución",
|
||||||
|
"run_by_user": "Ejecutado por Usuario",
|
||||||
|
"run_by_schedule": "Ejecutado por ID de Programación",
|
||||||
|
"run_by_api": "Ejecutado por API",
|
||||||
|
"run_type": "Tipo de Ejecución"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"run_content": {
|
||||||
|
"tabs": {
|
||||||
|
"output_data": "Datos de Salida",
|
||||||
|
"log": "Registro"
|
||||||
|
},
|
||||||
|
"empty_output": "La salida está vacía.",
|
||||||
|
"captured_data": {
|
||||||
|
"title": "Datos Capturados",
|
||||||
|
"download_json": "Descargar como JSON",
|
||||||
|
"download_csv": "Descargar como CSV"
|
||||||
|
},
|
||||||
|
"captured_screenshot": {
|
||||||
|
"title": "Captura de Pantalla",
|
||||||
|
"download": "Descargar Captura",
|
||||||
|
"render_failed": "No se pudo renderizar la imagen"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"stop": "Detener"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"navbar": {
|
||||||
|
"project_name": "Maxun",
|
||||||
|
"upgrade": {
|
||||||
|
"button": "Actualizar",
|
||||||
|
"modal": {
|
||||||
|
"up_to_date": "¡Estás actualizado!",
|
||||||
|
"new_version_available": "Hay una nueva versión disponible: {{version}}. ¡Actualice a la última versión para correcciones de errores, mejoras y nuevas características!",
|
||||||
|
"view_updates": "Ver todas las actualizaciones",
|
||||||
|
"view_updates_link": "aquí",
|
||||||
|
"tabs": {
|
||||||
|
"manual_setup": "Actualización de Configuración Manual",
|
||||||
|
"docker_setup": "Actualización de Configuración Docker Compose"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"menu_items": {
|
||||||
|
"logout": "Cerrar sesión",
|
||||||
|
"discord": "Discord",
|
||||||
|
"youtube": "YouTube",
|
||||||
|
"twitter": "Twitter (X)",
|
||||||
|
"language": "Idioma"
|
||||||
|
},
|
||||||
|
"recording": {
|
||||||
|
"discard": "Descartar"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"language_menu": {
|
||||||
|
"en": "Inglés",
|
||||||
|
"es": "Español",
|
||||||
|
"ja": "Japonés",
|
||||||
|
"zh": "Chino",
|
||||||
|
"de": "Alemán"
|
||||||
|
}
|
||||||
|
}
|
||||||
493
public/locales/ja.json
Normal file
493
public/locales/ja.json
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
{
|
||||||
|
"login": {
|
||||||
|
"title": "お帰りなさい!",
|
||||||
|
"email": "勤務先メールアドレスを入力",
|
||||||
|
"password": "パスワード",
|
||||||
|
"button": "ログイン",
|
||||||
|
"loading": "読み込み中",
|
||||||
|
"register_prompt": "アカウントをお持ちでないですか?",
|
||||||
|
"register_link": "登録する",
|
||||||
|
"welcome_notification": "Maxunへようこそ!",
|
||||||
|
"error_notification": "ログインに失敗しました。もう一度お試しください。"
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"title": "アカウントを登録する",
|
||||||
|
"email": "勤務先メールアドレスを入力",
|
||||||
|
"password": "パスワード",
|
||||||
|
"button": "登録する",
|
||||||
|
"loading": "読み込み中",
|
||||||
|
"register_prompt": "既にアカウントをお持ちですか?",
|
||||||
|
"login_link": "ログイン",
|
||||||
|
"welcome_notification": "Maxunへようこそ!",
|
||||||
|
"error_notification": "登録に失敗しました。もう一度お試しください。"
|
||||||
|
},
|
||||||
|
"recordingtable": {
|
||||||
|
"run": "実行",
|
||||||
|
"name": "名前",
|
||||||
|
"schedule": "スケジュール",
|
||||||
|
"integrate": "統合",
|
||||||
|
"settings": "設定",
|
||||||
|
"options": "オプション",
|
||||||
|
"heading": "私のロボット",
|
||||||
|
"new": "ロボットを作成",
|
||||||
|
"modal": {
|
||||||
|
"title": "URLを入力してください",
|
||||||
|
"label": "URL",
|
||||||
|
"button": "録画を開始"
|
||||||
|
},
|
||||||
|
"edit": "編集",
|
||||||
|
"delete": "削除",
|
||||||
|
"duplicate": "複製",
|
||||||
|
"search": "ロボットを検索...",
|
||||||
|
"notifications": {
|
||||||
|
"delete_warning": "関連する実行があるため、ロボットを削除できません",
|
||||||
|
"delete_success": "ロボットが正常に削除されました"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mainmenu": {
|
||||||
|
"recordings": "ロボット",
|
||||||
|
"runs": "実行",
|
||||||
|
"proxy": "プロキシ",
|
||||||
|
"apikey": "APIキー",
|
||||||
|
"feedback": "Maxunクラウドに参加する",
|
||||||
|
"apidocs": "WebサイトからAPI"
|
||||||
|
},
|
||||||
|
"runstable": {
|
||||||
|
"runs": "すべての実行",
|
||||||
|
"runStatus": "ステータス",
|
||||||
|
"runName": "名前",
|
||||||
|
"startedAt": "開始日時",
|
||||||
|
"finishedAt": "終了日時",
|
||||||
|
"delete": "削除",
|
||||||
|
"settings": "設定",
|
||||||
|
"search": "実行を検索...",
|
||||||
|
"notifications": {
|
||||||
|
"no_runs": "実行が見つかりません。もう一度お試しください。",
|
||||||
|
"delete_success": "実行が正常に削除されました"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"proxy": {
|
||||||
|
"title": "プロキシ設定",
|
||||||
|
"tab_standard": "標準プロキシ",
|
||||||
|
"tab_rotation": "自動プロキシローテーション",
|
||||||
|
"server_url": "プロキシサーバーURL",
|
||||||
|
"server_url_helper": "すべてのロボットで使用するプロキシ。HTTPとSOCKSプロキシがサポートされています。例:http://myproxy.com:3128 または socks5://myproxy.com:3128。短縮形 myproxy.com:3128 はHTTPプロキシとして扱われます。",
|
||||||
|
"requires_auth": "認証が必要ですか?",
|
||||||
|
"username": "ユーザー名",
|
||||||
|
"password": "パスワード",
|
||||||
|
"add_proxy": "プロキシを追加",
|
||||||
|
"test_proxy": "プロキシをテスト",
|
||||||
|
"remove_proxy": "プロキシを削除",
|
||||||
|
"table": {
|
||||||
|
"proxy_url": "プロキシURL",
|
||||||
|
"requires_auth": "認証が必要"
|
||||||
|
},
|
||||||
|
"coming_soon": "近日公開 - オープンソース(基本ローテーション)とクラウド(高度なローテーション)。インフラストラクチャを管理したくない場合は、クラウドの待機リストに参加して早期アクセスを取得してください。",
|
||||||
|
"join_waitlist": "Maxun Cloud待機リストに参加",
|
||||||
|
"alert": {
|
||||||
|
"title": "プロキシにユーザー名とパスワードが必要な場合は、必ずプロキシURLとは別に指定してください。",
|
||||||
|
"right_way": "正しい方法",
|
||||||
|
"wrong_way": "間違った方法",
|
||||||
|
"proxy_url": "プロキシURL:",
|
||||||
|
"username": "ユーザー名:",
|
||||||
|
"password": "パスワード:"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"config_success": "プロキシ設定が正常に送信されました",
|
||||||
|
"config_error": "プロキシ設定の送信に失敗しました。もう一度お試しください。",
|
||||||
|
"test_success": "プロキシ設定は正常に動作しています",
|
||||||
|
"test_error": "プロキシ設定のテストに失敗しました。もう一度お試しください。",
|
||||||
|
"fetch_success": "プロキシ設定の取得に成功しました",
|
||||||
|
"remove_success": "プロキシ設定が正常に削除されました",
|
||||||
|
"remove_error": "プロキシ設定の削除に失敗しました。もう一度お試しください。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"apikey": {
|
||||||
|
"title": "APIキーの管理",
|
||||||
|
"default_name": "Maxun APIキー",
|
||||||
|
"table": {
|
||||||
|
"name": "APIキー名",
|
||||||
|
"key": "APIキー",
|
||||||
|
"actions": "アクション"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"copy": "コピー",
|
||||||
|
"show": "表示",
|
||||||
|
"hide": "非表示",
|
||||||
|
"delete": "削除"
|
||||||
|
},
|
||||||
|
"no_key_message": "APIキーはまだ生成されていません。",
|
||||||
|
"generate_button": "APIキーを生成",
|
||||||
|
"notifications": {
|
||||||
|
"fetch_error": "APIキーの取得に失敗しました - ${error}",
|
||||||
|
"generate_success": "APIキーが正常に生成されました",
|
||||||
|
"generate_error": "APIキーの生成に失敗しました - ${error}",
|
||||||
|
"delete_success": "APIキーが正常に削除されました",
|
||||||
|
"delete_error": "APIキーの削除に失敗しました - ${error}",
|
||||||
|
"copy_success": "APIキーがコピーされました"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"action_description": {
|
||||||
|
"text": {
|
||||||
|
"title": "テキストを取得",
|
||||||
|
"description": "抽出したいテキストにカーソルを合わせ、クリックして選択してください"
|
||||||
|
},
|
||||||
|
"screenshot": {
|
||||||
|
"title": "スクリーンショットを取得",
|
||||||
|
"description": "現在のページの部分的または全体のスクリーンショットを取得します。"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"title": "リストを取得",
|
||||||
|
"description": "抽出したいリストにカーソルを合わせてください。選択後、選択したリスト内のすべてのテキストにカーソルを合わせることができます。クリックして選択してください。"
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"title": "どのデータを抽出しますか?",
|
||||||
|
"description": "ロボットは一度に1つのアクションを実行するように設計されています。以下のオプションから選択できます。"
|
||||||
|
},
|
||||||
|
"list_stages": {
|
||||||
|
"initial": "抽出したいリストとその中のテキストを選択してください",
|
||||||
|
"pagination": "ロボットがリストの残りをどのように取得するか選択してください",
|
||||||
|
"limit": "抽出するアイテムの数を選択してください",
|
||||||
|
"complete": "取得が完了しました"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"right_panel": {
|
||||||
|
"buttons": {
|
||||||
|
"capture_list": "リストを取得",
|
||||||
|
"capture_text": "テキストを取得",
|
||||||
|
"capture_screenshot": "スクリーンショットを取得",
|
||||||
|
"confirm": "確認",
|
||||||
|
"discard": "破棄",
|
||||||
|
"confirm_capture": "取得を確認",
|
||||||
|
"confirm_pagination": "確認",
|
||||||
|
"confirm_limit": "確認",
|
||||||
|
"finish_capture": "取得を完了",
|
||||||
|
"back": "戻る",
|
||||||
|
"finish": "完了",
|
||||||
|
"cancel": "キャンセル",
|
||||||
|
"delete": "削除"
|
||||||
|
},
|
||||||
|
"screenshot": {
|
||||||
|
"capture_fullpage": "フルページを取得",
|
||||||
|
"capture_visible": "表示部分を取得",
|
||||||
|
"display_fullpage": "フルページスクリーンショットを撮影",
|
||||||
|
"display_visible": "表示部分のスクリーンショットを撮影"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"title": "次のリスト項目をページ上でどのように見つけますか?",
|
||||||
|
"click_next": "次へをクリックして次のページへ移動",
|
||||||
|
"click_load_more": "もっと読み込むをクリックして項目を追加",
|
||||||
|
"scroll_down": "下にスクロールして項目を追加",
|
||||||
|
"scroll_up": "上にスクロールして項目を追加",
|
||||||
|
"none": "これ以上読み込む項目はありません"
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"title": "抽出する最大行数はいくつですか?",
|
||||||
|
"custom": "カスタム",
|
||||||
|
"enter_number": "数値を入力"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"label": "ラベル",
|
||||||
|
"data": "データ",
|
||||||
|
"field_label": "フィールドラベル",
|
||||||
|
"field_data": "フィールドデータ"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"list_selected": "リストが正常に選択されました"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"select_pagination": "ページネーションタイプを選択してください。",
|
||||||
|
"select_pagination_element": "まずページネーション要素を選択してください。",
|
||||||
|
"select_limit": "制限を選択するかカスタム制限を入力してください。",
|
||||||
|
"invalid_limit": "有効な制限を入力してください。",
|
||||||
|
"confirm_text_fields": "すべてのテキストフィールドを確認してください",
|
||||||
|
"unable_create_settings": "リスト設定を作成できません。リストのフィールドを定義したことを確認してください。",
|
||||||
|
"capture_text_discarded": "テキスト取得が破棄されました",
|
||||||
|
"capture_list_discarded": "リスト取得が破棄されました"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"save_recording": {
|
||||||
|
"title": "ロボットを保存",
|
||||||
|
"robot_name": "ロボット名",
|
||||||
|
"buttons": {
|
||||||
|
"save": "保存",
|
||||||
|
"confirm": "確認"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"save_success": "ロボットが正常に保存されました"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"user_not_logged": "ユーザーがログインしていません。録画を保存できません。",
|
||||||
|
"exists_warning": "この名前のロボットは既に存在します。ロボットの上書きを確認してください。"
|
||||||
|
},
|
||||||
|
"tooltips": {
|
||||||
|
"saving": "ワークフローを最適化して保存中"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"browser_recording": {
|
||||||
|
"modal": {
|
||||||
|
"confirm_discard": "録画を破棄してもよろしいですか?"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"terminated": "現在の録画は終了しました"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"interpretation_log": {
|
||||||
|
"titles": {
|
||||||
|
"output_preview": "出力データプレビュー",
|
||||||
|
"screenshot": "スクリーンショット"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"additional_rows": "記録が完了すると、追加のデータ行が抽出されます。",
|
||||||
|
"successful_training": "ロボットのアクショントレーニングが成功しました!下のボタンをクリックすると、ロボットが抽出するデータのプレビューが表示されます。",
|
||||||
|
"no_selection": "まだ抽出対象が選択されていません。選択すると、ロボットがここで選択内容のプレビューを表示します。"
|
||||||
|
},
|
||||||
|
"data_sections": {
|
||||||
|
"binary_received": "---------- バイナリ出力データを受信 ----------",
|
||||||
|
"serializable_received": "---------- シリアライズ可能な出力データを受信 ----------",
|
||||||
|
"mimetype": "MIMEタイプ: ",
|
||||||
|
"image_below": "画像は以下に表示されます:",
|
||||||
|
"separator": "--------------------------------------------------"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"reset_success": "出力プレビューが正常にリセットされました"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"interpretation_buttons": {
|
||||||
|
"buttons": {
|
||||||
|
"preview": "出力データのプレビューを取得",
|
||||||
|
"reset": "リセット",
|
||||||
|
"yes": "はい",
|
||||||
|
"no": "いいえ"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"extracting": "データ抽出中...10秒から1分ほどお待ちください",
|
||||||
|
"restart_required": "録画を更新した後、解釈を再起動してください",
|
||||||
|
"run_finished": "実行完了",
|
||||||
|
"run_failed": "実行の開始に失敗しました"
|
||||||
|
},
|
||||||
|
"modal": {
|
||||||
|
"use_previous": "この操作の条件として前回の選択を使用しますか?",
|
||||||
|
"previous_action": "前回の操作: ",
|
||||||
|
"element_text": "テキスト要素 "
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"recording_page": {
|
||||||
|
"loader": {
|
||||||
|
"browser_startup": "ブラウザを起動中...{{url}}に移動中"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"integration_settings": {
|
||||||
|
"title": "Google Sheetと連携",
|
||||||
|
"descriptions": {
|
||||||
|
"sync_info": "このオプションを有効にすると、このロボットがタスクを正常に実行するたびに、取得したデータがGoogle Sheetに追加されます。",
|
||||||
|
"authenticated_as": "認証済みユーザー: {{email}}"
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"success": {
|
||||||
|
"title": "Google Sheetの連携が完了しました。",
|
||||||
|
"content": "このロボットが正常に実行を完了するたびに、取得したデータはGoogle Sheet {{sheetName}}に追加されます。データの更新は",
|
||||||
|
"here": "こちら",
|
||||||
|
"note": "注意:",
|
||||||
|
"sync_limitation": "Google Sheetsとの連携前に抽出されたデータは同期されません。連携後に抽出されたデータのみが同期されます。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"authenticate": "Googleで認証",
|
||||||
|
"fetch_sheets": "Google Sheetsを取得",
|
||||||
|
"remove_integration": "連携を解除",
|
||||||
|
"submit": "送信"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"select_sheet": "Google Sheetを選択",
|
||||||
|
"selected_sheet": "選択したシート: {{name}} (ID: {{id}})"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"robot_duplication": {
|
||||||
|
"title": "ロボットを複製",
|
||||||
|
"descriptions": {
|
||||||
|
"purpose": "ロボットの複製は、同じ構造のページからデータを抽出する際に便利です。",
|
||||||
|
"example": "例:{{url1}}用のロボットを作成した場合、ロボットを一から作り直すことなく、{{url2}}のような類似のページをスクレイピングするために複製できます。",
|
||||||
|
"warning": "⚠️ 新しいページが元のページと同じ構造であることを確認してください。"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"target_url": "ロボットのターゲットURL"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"duplicate": "ロボットを複製",
|
||||||
|
"cancel": "キャンセル"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"robot_not_found": "ロボットの詳細が見つかりません。もう一度お試しください。",
|
||||||
|
"url_required": "ターゲットURLが必要です。",
|
||||||
|
"duplicate_success": "ロボットが正常に複製されました。",
|
||||||
|
"duplicate_error": "ターゲットURLの更新に失敗しました。もう一度お試しください。",
|
||||||
|
"unknown_error": "ターゲットURLの更新中にエラーが発生しました。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"robot_settings": {
|
||||||
|
"title": "ロボット設定",
|
||||||
|
"target_url": "ロボットのターゲットURL",
|
||||||
|
"robot_id": "ロボットID",
|
||||||
|
"robot_limit": "ロボットの制限",
|
||||||
|
"created_by_user": "作成したユーザー",
|
||||||
|
"created_at": "作成日時",
|
||||||
|
"errors": {
|
||||||
|
"robot_not_found": "ロボットの詳細が見つかりませんでした。もう一度試してください。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"robot_edit": {
|
||||||
|
"title": "ロボットを編集",
|
||||||
|
"change_name": "ロボット名の変更",
|
||||||
|
"robot_limit": "ロボットの制限",
|
||||||
|
"save": "変更を保存",
|
||||||
|
"cancel": "キャンセル",
|
||||||
|
"notifications": {
|
||||||
|
"update_success": "ロボットが正常に更新されました。",
|
||||||
|
"update_failed": "ロボットの更新に失敗しました。もう一度試してください。",
|
||||||
|
"update_error": "ロボットの更新中にエラーが発生しました。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schedule_settings": {
|
||||||
|
"title": "スケジュール設定",
|
||||||
|
"run_every": "実行間隔",
|
||||||
|
"start_from": "開始日",
|
||||||
|
"on_day": "日付",
|
||||||
|
"at_around": "時刻",
|
||||||
|
"timezone": "タイムゾーン",
|
||||||
|
"buttons": {
|
||||||
|
"delete_schedule": "スケジュールを削除",
|
||||||
|
"save_schedule": "スケジュールを保存",
|
||||||
|
"cancel": "キャンセル"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"in_between": "間隔",
|
||||||
|
"run_once_every": "実行間隔",
|
||||||
|
"start_from_label": "開始日",
|
||||||
|
"on_day_of_month": "月の日付",
|
||||||
|
"on_day": {
|
||||||
|
"st": "日",
|
||||||
|
"nd": "日",
|
||||||
|
"rd": "日",
|
||||||
|
"th": "日"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"main_page": {
|
||||||
|
"notifications": {
|
||||||
|
"interpretation_success": "ロボット{{name}}の解釈に成功しました",
|
||||||
|
"interpretation_failed": "ロボット{{name}}の解釈に失敗しました",
|
||||||
|
"run_started": "ロボット{{name}}を実行中",
|
||||||
|
"run_start_failed": "ロボット{{name}}の実行に失敗しました",
|
||||||
|
"schedule_success": "ロボット{{name}}のスケジュールが正常に設定されました",
|
||||||
|
"schedule_failed": "ロボット{{name}}のスケジュール設定に失敗しました",
|
||||||
|
"abort_success": "ロボット{{name}}の解釈を中止しました",
|
||||||
|
"abort_failed": "ロボット{{name}}の解釈中止に失敗しました"
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"recordings": "ロボット",
|
||||||
|
"runs": "実行",
|
||||||
|
"proxy": "プロキシ",
|
||||||
|
"apikey": "APIキー"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"browser_window": {
|
||||||
|
"attribute_modal": {
|
||||||
|
"title": "属性を選択",
|
||||||
|
"notifications": {
|
||||||
|
"list_select_success": "リストが正常に選択されました。抽出するテキストデータを選択してください。",
|
||||||
|
"pagination_select_success": "ページネーション要素が正常に選択されました。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"attribute_options": {
|
||||||
|
"anchor": {
|
||||||
|
"text": "テキスト: {{text}}",
|
||||||
|
"url": "URL: {{url}}"
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"alt_text": "代替テキスト: {{altText}}",
|
||||||
|
"image_url": "画像URL: {{imageUrl}}"
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"text": "テキスト: {{text}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"runs_table": {
|
||||||
|
"run_type_chips": {
|
||||||
|
"manual_run": "手動実行",
|
||||||
|
"scheduled_run": "スケジュール実行",
|
||||||
|
"api": "API",
|
||||||
|
"unknown_run_type": "不明な実行タイプ"
|
||||||
|
},
|
||||||
|
"run_status_chips": {
|
||||||
|
"success": "成功",
|
||||||
|
"running": "実行中",
|
||||||
|
"scheduled": "スケジュール済み",
|
||||||
|
"failed": "失敗"
|
||||||
|
},
|
||||||
|
"run_settings_modal": {
|
||||||
|
"title": "実行設定",
|
||||||
|
"labels": {
|
||||||
|
"run_id": "実行ID",
|
||||||
|
"run_by_user": "ユーザーによる実行",
|
||||||
|
"run_by_schedule": "スケジュールによる実行",
|
||||||
|
"run_by_api": "APIによる実行",
|
||||||
|
"run_type": "実行タイプ"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"run_content": {
|
||||||
|
"tabs": {
|
||||||
|
"output_data": "出力データ",
|
||||||
|
"log": "ログ"
|
||||||
|
},
|
||||||
|
"empty_output": "出力は空です。",
|
||||||
|
"captured_data": {
|
||||||
|
"title": "キャプチャされたデータ",
|
||||||
|
"download_json": "JSONとしてダウンロード",
|
||||||
|
"download_csv": "CSVとしてダウンロード"
|
||||||
|
},
|
||||||
|
"captured_screenshot": {
|
||||||
|
"title": "キャプチャされたスクリーンショット",
|
||||||
|
"download": "スクリーンショットをダウンロード",
|
||||||
|
"render_failed": "画像のレンダリングに失敗しました"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"stop": "停止"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"navbar": {
|
||||||
|
"project_name": "Maxun",
|
||||||
|
"upgrade": {
|
||||||
|
"button": "アップグレード",
|
||||||
|
"modal": {
|
||||||
|
"up_to_date": "最新版です!",
|
||||||
|
"new_version_available": "新しいバージョンが利用可能です: {{version}}。バグ修正、機能強化のために最新版にアップグレードしてください。",
|
||||||
|
"view_updates": "すべての更新を",
|
||||||
|
"view_updates_link": "こちら",
|
||||||
|
"tabs": {
|
||||||
|
"manual_setup": "手動セットアップ",
|
||||||
|
"docker_setup": "Docker Composeセットアップ"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"menu_items": {
|
||||||
|
"logout": "ログアウト",
|
||||||
|
"discord": "Discord",
|
||||||
|
"youtube": "YouTube",
|
||||||
|
"twitter": "Twitter (X)",
|
||||||
|
"language": "言語"
|
||||||
|
},
|
||||||
|
"recording": {
|
||||||
|
"discard": "破棄"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"language_menu": {
|
||||||
|
"en": "英語",
|
||||||
|
"es": "スペイン語",
|
||||||
|
"ja": "日本語",
|
||||||
|
"zh": "中国語",
|
||||||
|
"de": "ドイツ語"
|
||||||
|
}
|
||||||
|
}
|
||||||
493
public/locales/zh.json
Normal file
493
public/locales/zh.json
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
{
|
||||||
|
"login": {
|
||||||
|
"title": "欢迎回来!",
|
||||||
|
"email": "输入工作电子邮箱",
|
||||||
|
"password": "密码",
|
||||||
|
"button": "登录",
|
||||||
|
"loading": "加载中",
|
||||||
|
"register_prompt": "还没有账号?",
|
||||||
|
"register_link": "注册",
|
||||||
|
"welcome_notification": "欢迎使用 Maxun!",
|
||||||
|
"error_notification": "登录失败。请重试。"
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"title": "注册账号",
|
||||||
|
"email": "输入工作电子邮箱",
|
||||||
|
"password": "密码",
|
||||||
|
"button": "注册",
|
||||||
|
"loading": "加载中",
|
||||||
|
"register_prompt": "已有账号?",
|
||||||
|
"login_link": "登录",
|
||||||
|
"welcome_notification": "欢迎使用 Maxun!",
|
||||||
|
"error_notification": "注册失败。请重试。"
|
||||||
|
},
|
||||||
|
"recordingtable": {
|
||||||
|
"run": "运行",
|
||||||
|
"name": "名称",
|
||||||
|
"schedule": "计划",
|
||||||
|
"integrate": "集成",
|
||||||
|
"settings": "设置",
|
||||||
|
"options": "选项",
|
||||||
|
"heading": "我的机器人",
|
||||||
|
"new": "创建机器人",
|
||||||
|
"modal": {
|
||||||
|
"title": "输入URL",
|
||||||
|
"label": "URL",
|
||||||
|
"button": "开始录制"
|
||||||
|
},
|
||||||
|
"edit": "编辑",
|
||||||
|
"delete": "删除",
|
||||||
|
"duplicate": "复制",
|
||||||
|
"search": "搜索机器人...",
|
||||||
|
"notifications": {
|
||||||
|
"delete_warning": "无法删除机器人,因为它有关联的运行记录",
|
||||||
|
"delete_success": "机器人删除成功"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mainmenu": {
|
||||||
|
"recordings": "机器人",
|
||||||
|
"runs": "运行记录",
|
||||||
|
"proxy": "代理",
|
||||||
|
"apikey": "API密钥",
|
||||||
|
"feedback": "加入 Maxun Cloud",
|
||||||
|
"apidocs": "网站转API"
|
||||||
|
},
|
||||||
|
"runstable": {
|
||||||
|
"runs": "所有运行记录",
|
||||||
|
"runStatus": "状态",
|
||||||
|
"runName": "名称",
|
||||||
|
"startedAt": "开始时间",
|
||||||
|
"finishedAt": "结束时间",
|
||||||
|
"delete": "删除",
|
||||||
|
"settings": "设置",
|
||||||
|
"search": "搜索运行记录...",
|
||||||
|
"notifications": {
|
||||||
|
"no_runs": "未找到运行记录。请重试。",
|
||||||
|
"delete_success": "运行记录删除成功"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"proxy": {
|
||||||
|
"title": "代理设置",
|
||||||
|
"tab_standard": "标准代理",
|
||||||
|
"tab_rotation": "自动代理轮换",
|
||||||
|
"server_url": "代理服务器URL",
|
||||||
|
"server_url_helper": "用于所有机器人的代理。支持HTTP和SOCKS代理。示例 http://myproxy.com:3128 或 socks5://myproxy.com:3128。简短形式 myproxy.com:3128 被视为HTTP代理。",
|
||||||
|
"requires_auth": "需要认证?",
|
||||||
|
"username": "用户名",
|
||||||
|
"password": "密码",
|
||||||
|
"add_proxy": "添加代理",
|
||||||
|
"test_proxy": "测试代理",
|
||||||
|
"remove_proxy": "删除代理",
|
||||||
|
"table": {
|
||||||
|
"proxy_url": "代理URL",
|
||||||
|
"requires_auth": "需要认证"
|
||||||
|
},
|
||||||
|
"coming_soon": "即将推出 - 开源版(基础轮换)和云版(高级轮换)。如果您不想管理基础设施,请加入我们的云服务等候名单以获得早期访问权限。",
|
||||||
|
"join_waitlist": "加入Maxun Cloud等候名单",
|
||||||
|
"alert": {
|
||||||
|
"title": "如果您的代理需要用户名和密码,请务必将它们与代理URL分开提供。",
|
||||||
|
"right_way": "正确方式",
|
||||||
|
"wrong_way": "错误方式",
|
||||||
|
"proxy_url": "代理URL:",
|
||||||
|
"username": "用户名:",
|
||||||
|
"password": "密码:"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"config_success": "代理配置提交成功",
|
||||||
|
"config_error": "提交代理配置失败。请重试。",
|
||||||
|
"test_success": "代理配置运行正常",
|
||||||
|
"test_error": "测试代理配置失败。请重试。",
|
||||||
|
"fetch_success": "成功获取代理配置",
|
||||||
|
"remove_success": "成功删除代理配置",
|
||||||
|
"remove_error": "删除代理配置失败。请重试。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"apikey": {
|
||||||
|
"title": "管理API密钥",
|
||||||
|
"default_name": "Maxun API密钥",
|
||||||
|
"table": {
|
||||||
|
"name": "API密钥名称",
|
||||||
|
"key": "API密钥",
|
||||||
|
"actions": "操作"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"copy": "复制",
|
||||||
|
"show": "显示",
|
||||||
|
"hide": "隐藏",
|
||||||
|
"delete": "删除"
|
||||||
|
},
|
||||||
|
"no_key_message": "您还未生成API密钥。",
|
||||||
|
"generate_button": "生成API密钥",
|
||||||
|
"notifications": {
|
||||||
|
"fetch_error": "获取API密钥失败 - ${error}",
|
||||||
|
"generate_success": "API密钥生成成功",
|
||||||
|
"generate_error": "生成API密钥失败 - ${error}",
|
||||||
|
"delete_success": "API密钥删除成功",
|
||||||
|
"delete_error": "删除API密钥失败 - ${error}",
|
||||||
|
"copy_success": "API密钥复制成功"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"action_description": {
|
||||||
|
"text": {
|
||||||
|
"title": "捕获文本",
|
||||||
|
"description": "将鼠标悬停在要提取的文本上并点击选择"
|
||||||
|
},
|
||||||
|
"screenshot": {
|
||||||
|
"title": "捕获截图",
|
||||||
|
"description": "捕获当前页面的部分或全部截图。"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"title": "捕获列表",
|
||||||
|
"description": "将鼠标悬停在要提取的列表上。选择后,您可以将鼠标悬停在所选列表中的所有文本上。点击选择它们。"
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"title": "您想提取什么数据?",
|
||||||
|
"description": "机器人设计为一次执行一个操作。您可以选择以下任何选项。"
|
||||||
|
},
|
||||||
|
"list_stages": {
|
||||||
|
"initial": "选择要提取的列表及其中的文本",
|
||||||
|
"pagination": "选择机器人如何捕获列表的其余部分",
|
||||||
|
"limit": "选择要提取的项目数量",
|
||||||
|
"complete": "捕获完成"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"right_panel": {
|
||||||
|
"buttons": {
|
||||||
|
"capture_list": "捕获列表",
|
||||||
|
"capture_text": "捕获文本",
|
||||||
|
"capture_screenshot": "捕获截图",
|
||||||
|
"confirm": "确认",
|
||||||
|
"discard": "放弃",
|
||||||
|
"confirm_capture": "确认捕获",
|
||||||
|
"confirm_pagination": "确认",
|
||||||
|
"confirm_limit": "确认",
|
||||||
|
"finish_capture": "完成捕获",
|
||||||
|
"back": "返回",
|
||||||
|
"finish": "完成",
|
||||||
|
"cancel": "取消",
|
||||||
|
"delete": "删除"
|
||||||
|
},
|
||||||
|
"screenshot": {
|
||||||
|
"capture_fullpage": "捕获整页",
|
||||||
|
"capture_visible": "捕获可见部分",
|
||||||
|
"display_fullpage": "获取整页截图",
|
||||||
|
"display_visible": "获取可见部分截图"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"title": "如何在页面上找到下一个列表项?",
|
||||||
|
"click_next": "点击下一页导航到下一页",
|
||||||
|
"click_load_more": "点击加载更多来加载更多项目",
|
||||||
|
"scroll_down": "向下滚动加载更多项目",
|
||||||
|
"scroll_up": "向上滚动加载更多项目",
|
||||||
|
"none": "没有更多项目可加载"
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"title": "您想要提取的最大行数是多少?",
|
||||||
|
"custom": "自定义",
|
||||||
|
"enter_number": "输入数字"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"label": "标签",
|
||||||
|
"data": "数据",
|
||||||
|
"field_label": "字段标签",
|
||||||
|
"field_data": "字段数据"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"list_selected": "列表选择成功"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"select_pagination": "请选择分页类型。",
|
||||||
|
"select_pagination_element": "请先选择分页元素。",
|
||||||
|
"select_limit": "请选择限制或输入自定义限制。",
|
||||||
|
"invalid_limit": "请输入有效的限制。",
|
||||||
|
"confirm_text_fields": "请确认所有文本字段",
|
||||||
|
"unable_create_settings": "无法创建列表设置。请确保您已为列表定义了字段。",
|
||||||
|
"capture_text_discarded": "文本捕获已放弃",
|
||||||
|
"capture_list_discarded": "列表捕获已放弃"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"save_recording": {
|
||||||
|
"title": "保存机器人",
|
||||||
|
"robot_name": "机器人名称",
|
||||||
|
"buttons": {
|
||||||
|
"save": "保存",
|
||||||
|
"confirm": "确认"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"save_success": "机器人保存成功"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"user_not_logged": "用户未登录。无法保存录制。",
|
||||||
|
"exists_warning": "已存在同名机器人,请确认是否覆盖机器人。"
|
||||||
|
},
|
||||||
|
"tooltips": {
|
||||||
|
"saving": "正在优化并保存工作流程"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"browser_recording": {
|
||||||
|
"modal": {
|
||||||
|
"confirm_discard": "您确定要放弃录制吗?"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"terminated": "当前录制已终止"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"interpretation_log": {
|
||||||
|
"titles": {
|
||||||
|
"output_preview": "输出数据预览",
|
||||||
|
"screenshot": "截图"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"additional_rows": "完成录制后将提取更多数据行。",
|
||||||
|
"successful_training": "您已成功训练机器人执行操作!点击下方按钮预览机器人将提取的数据。",
|
||||||
|
"no_selection": "看起来您还没有选择要提取的内容。选择后,机器人将在此处显示您的选择预览。"
|
||||||
|
},
|
||||||
|
"data_sections": {
|
||||||
|
"binary_received": "---------- 已接收二进制输出数据 ----------",
|
||||||
|
"serializable_received": "---------- 已接收可序列化输出数据 ----------",
|
||||||
|
"mimetype": "MIME类型:",
|
||||||
|
"image_below": "图片显示如下:",
|
||||||
|
"separator": "--------------------------------------------------"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"reset_success": "输出预览已成功重置"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"interpretation_buttons": {
|
||||||
|
"buttons": {
|
||||||
|
"preview": "获取输出数据预览",
|
||||||
|
"reset": "重置",
|
||||||
|
"yes": "是",
|
||||||
|
"no": "否"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"extracting": "正在提取数据...请等待10秒到1分钟",
|
||||||
|
"restart_required": "更新录制后请重新启动解释",
|
||||||
|
"run_finished": "运行完成",
|
||||||
|
"run_failed": "运行启动失败"
|
||||||
|
},
|
||||||
|
"modal": {
|
||||||
|
"use_previous": "您要将之前的选择用作执行此操作的条件吗?",
|
||||||
|
"previous_action": "您之前的操作是:",
|
||||||
|
"element_text": "在文本元素上 "
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"recording_page": {
|
||||||
|
"loader": {
|
||||||
|
"browser_startup": "正在启动浏览器...正在导航至{{url}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"integration_settings": {
|
||||||
|
"title": "与Google Sheet集成",
|
||||||
|
"descriptions": {
|
||||||
|
"sync_info": "如果启用此选项,每次机器人成功运行任务时,捕获的数据都会追加到您的Google Sheet中。",
|
||||||
|
"authenticated_as": "已验证身份: {{email}}"
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"success": {
|
||||||
|
"title": "Google Sheet集成成功。",
|
||||||
|
"content": "每次此机器人创建成功运行时,捕获的数据都会追加到您的Google Sheet {{sheetName}}中。您可以查看数据更新",
|
||||||
|
"here": "在此处",
|
||||||
|
"note": "注意:",
|
||||||
|
"sync_limitation": "与Google Sheets集成之前提取的数据将不会同步到Google Sheet中。只有集成后提取的数据才会同步。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"authenticate": "使用Google验证",
|
||||||
|
"fetch_sheets": "获取Google Sheets",
|
||||||
|
"remove_integration": "移除集成",
|
||||||
|
"submit": "提交"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"select_sheet": "选择Google Sheet",
|
||||||
|
"selected_sheet": "已选择表格: {{name}} (ID: {{id}})"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"robot_duplication": {
|
||||||
|
"title": "复制机器人",
|
||||||
|
"descriptions": {
|
||||||
|
"purpose": "机器人复制功能用于从具有相同结构的页面提取数据。",
|
||||||
|
"example": "示例:如果您已经为{{url1}}创建了机器人,您可以复制它来抓取类似的页面(如{{url2}}),而无需从头开始训练机器人。",
|
||||||
|
"warning": "⚠️ 确保新页面与原始页面具有相同的结构。"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"target_url": "机器人目标URL"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"duplicate": "复制机器人",
|
||||||
|
"cancel": "取消"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"robot_not_found": "找不到机器人详细信息。请重试。",
|
||||||
|
"url_required": "需要目标URL。",
|
||||||
|
"duplicate_success": "机器人复制成功。",
|
||||||
|
"duplicate_error": "更新目标URL失败。请重试。",
|
||||||
|
"unknown_error": "更新目标URL时发生错误。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"robot_settings": {
|
||||||
|
"title": "机器人设置",
|
||||||
|
"target_url": "机器人目标URL",
|
||||||
|
"robot_id": "机器人ID",
|
||||||
|
"robot_limit": "机器人限制",
|
||||||
|
"created_by_user": "由用户创建",
|
||||||
|
"created_at": "机器人创建时间",
|
||||||
|
"errors": {
|
||||||
|
"robot_not_found": "无法找到机器人详细信息。请重试。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"robot_edit": {
|
||||||
|
"title": "编辑机器人",
|
||||||
|
"change_name": "更改机器人名称",
|
||||||
|
"robot_limit": "机器人限制",
|
||||||
|
"save": "保存更改",
|
||||||
|
"cancel": "取消",
|
||||||
|
"notifications": {
|
||||||
|
"update_success": "机器人更新成功。",
|
||||||
|
"update_failed": "无法更新机器人。请重试。",
|
||||||
|
"update_error": "更新机器人时发生错误。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schedule_settings": {
|
||||||
|
"title": "计划设置",
|
||||||
|
"run_every": "每次运行",
|
||||||
|
"start_from": "开始于",
|
||||||
|
"on_day": "在日",
|
||||||
|
"at_around": "大约在",
|
||||||
|
"timezone": "时区",
|
||||||
|
"buttons": {
|
||||||
|
"delete_schedule": "删除计划",
|
||||||
|
"save_schedule": "保存计划",
|
||||||
|
"cancel": "取消"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"in_between": "之间",
|
||||||
|
"run_once_every": "每次运行",
|
||||||
|
"start_from_label": "开始于",
|
||||||
|
"on_day_of_month": "月份日期",
|
||||||
|
"on_day": {
|
||||||
|
"st": "日",
|
||||||
|
"nd": "日",
|
||||||
|
"rd": "日",
|
||||||
|
"th": "日"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"main_page": {
|
||||||
|
"notifications": {
|
||||||
|
"interpretation_success": "机器人{{name}}解释成功",
|
||||||
|
"interpretation_failed": "机器人{{name}}解释失败",
|
||||||
|
"run_started": "正在运行机器人:{{name}}",
|
||||||
|
"run_start_failed": "机器人运行失败:{{name}}",
|
||||||
|
"schedule_success": "机器人{{name}}调度成功",
|
||||||
|
"schedule_failed": "机器人{{name}}调度失败",
|
||||||
|
"abort_success": "成功中止机器人{{name}}的解释",
|
||||||
|
"abort_failed": "中止机器人{{name}}的解释失败"
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"recordings": "机器人",
|
||||||
|
"runs": "运行",
|
||||||
|
"proxy": "代理",
|
||||||
|
"apikey": "API密钥"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"browser_window": {
|
||||||
|
"attribute_modal": {
|
||||||
|
"title": "选择属性",
|
||||||
|
"notifications": {
|
||||||
|
"list_select_success": "列表选择成功。选择要提取的文本数据。",
|
||||||
|
"pagination_select_success": "分页元素选择成功。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"attribute_options": {
|
||||||
|
"anchor": {
|
||||||
|
"text": "文本: {{text}}",
|
||||||
|
"url": "URL: {{url}}"
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"alt_text": "替代文本: {{altText}}",
|
||||||
|
"image_url": "图像URL: {{imageUrl}}"
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"text": "文本: {{text}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"runs_table": {
|
||||||
|
"run_type_chips": {
|
||||||
|
"manual_run": "手动运行",
|
||||||
|
"scheduled_run": "计划运行",
|
||||||
|
"api": "API",
|
||||||
|
"unknown_run_type": "未知运行类型"
|
||||||
|
},
|
||||||
|
"run_status_chips": {
|
||||||
|
"success": "成功",
|
||||||
|
"running": "运行中",
|
||||||
|
"scheduled": "已计划",
|
||||||
|
"failed": "失败"
|
||||||
|
},
|
||||||
|
"run_settings_modal": {
|
||||||
|
"title": "运行设置",
|
||||||
|
"labels": {
|
||||||
|
"run_id": "运行ID",
|
||||||
|
"run_by_user": "由用户运行",
|
||||||
|
"run_by_schedule": "按计划ID运行",
|
||||||
|
"run_by_api": "由API运行",
|
||||||
|
"run_type": "运行类型"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"run_content": {
|
||||||
|
"tabs": {
|
||||||
|
"output_data": "输出数据",
|
||||||
|
"log": "日志"
|
||||||
|
},
|
||||||
|
"empty_output": "输出为空。",
|
||||||
|
"captured_data": {
|
||||||
|
"title": "捕获的数据",
|
||||||
|
"download_json": "下载为JSON",
|
||||||
|
"download_csv": "下载为CSV"
|
||||||
|
},
|
||||||
|
"captured_screenshot": {
|
||||||
|
"title": "捕获的截图",
|
||||||
|
"download": "下载截图",
|
||||||
|
"render_failed": "图像渲染失败"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"stop": "停止"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"navbar": {
|
||||||
|
"project_name": "Maxun",
|
||||||
|
"upgrade": {
|
||||||
|
"button": "升级",
|
||||||
|
"modal": {
|
||||||
|
"up_to_date": "🎉 您已是最新版本!",
|
||||||
|
"new_version_available": "新版本已可用:{{version}}。升级到最新版本以获取错误修复、增强和新功能!",
|
||||||
|
"view_updates": "查看所有新更新",
|
||||||
|
"view_updates_link": "此处",
|
||||||
|
"tabs": {
|
||||||
|
"manual_setup": "手动设置升级",
|
||||||
|
"docker_setup": "Docker Compose设置升级"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"menu_items": {
|
||||||
|
"logout": "退出登录",
|
||||||
|
"discord": "Discord",
|
||||||
|
"youtube": "YouTube",
|
||||||
|
"twitter": "Twitter (X)",
|
||||||
|
"language": "语言"
|
||||||
|
},
|
||||||
|
"recording": {
|
||||||
|
"discard": "丢弃"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"language_menu": {
|
||||||
|
"en": "英语",
|
||||||
|
"es": "西班牙语",
|
||||||
|
"ja": "日语",
|
||||||
|
"zh": "中文",
|
||||||
|
"de": "德语"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM mcr.microsoft.com/playwright:v1.40.0-jammy
|
FROM mcr.microsoft.com/playwright:v1.46.0-noble
|
||||||
|
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -7,13 +7,14 @@ WORKDIR /app
|
|||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
COPY maxun-core ./maxun-core
|
COPY maxun-core ./maxun-core
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
|
COPY public ./public
|
||||||
COPY server ./server
|
COPY server ./server
|
||||||
COPY tsconfig.json ./
|
COPY tsconfig.json ./
|
||||||
COPY server/tsconfig.json ./server/
|
COPY server/tsconfig.json ./server/
|
||||||
# COPY server/start.sh ./
|
# COPY server/start.sh ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN npm install
|
RUN npm install --legacy-peer-deps
|
||||||
|
|
||||||
# Install Playwright browsers and dependencies
|
# Install Playwright browsers and dependencies
|
||||||
RUN npx playwright install --with-deps chromium
|
RUN npx playwright install --with-deps chromium
|
||||||
@@ -50,7 +51,7 @@ RUN apt-get update && apt-get install -y \
|
|||||||
# RUN chmod +x ./start.sh
|
# RUN chmod +x ./start.sh
|
||||||
|
|
||||||
# Expose the backend port
|
# Expose the backend port
|
||||||
EXPOSE 8080
|
EXPOSE ${BACKEND_PORT:-8080}
|
||||||
|
|
||||||
# Start the backend using the start script
|
# Start the backend using the start script
|
||||||
CMD ["npm", "run", "server"]
|
CMD ["npm", "run", "server"]
|
||||||
@@ -15,6 +15,8 @@ import { io, Socket } from "socket.io-client";
|
|||||||
import { BinaryOutputService } from "../storage/mino";
|
import { BinaryOutputService } from "../storage/mino";
|
||||||
import { AuthenticatedRequest } from "../routes/record"
|
import { AuthenticatedRequest } from "../routes/record"
|
||||||
import {capture} from "../utils/analytics";
|
import {capture} from "../utils/analytics";
|
||||||
|
import { Page } from "playwright";
|
||||||
|
import { WorkflowFile } from "maxun-core";
|
||||||
chromium.use(stealthPlugin());
|
chromium.use(stealthPlugin());
|
||||||
|
|
||||||
const formatRecording = (recordingData: any) => {
|
const formatRecording = (recordingData: any) => {
|
||||||
@@ -533,6 +535,17 @@ function resetRecordingState(browserId: string, id: string) {
|
|||||||
id = '';
|
id = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AddGeneratedFlags(workflow: WorkflowFile) {
|
||||||
|
const copy = JSON.parse(JSON.stringify(workflow));
|
||||||
|
for (let i = 0; i < workflow.workflow.length; i++) {
|
||||||
|
copy.workflow[i].what.unshift({
|
||||||
|
action: 'flag',
|
||||||
|
args: ['generated'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
};
|
||||||
|
|
||||||
async function executeRun(id: string) {
|
async function executeRun(id: string) {
|
||||||
try {
|
try {
|
||||||
const run = await Run.findOne({ where: { runId: id } });
|
const run = await Run.findOne({ where: { runId: id } });
|
||||||
@@ -560,13 +573,14 @@ async function executeRun(id: string) {
|
|||||||
throw new Error('Could not access browser');
|
throw new Error('Could not access browser');
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentPage = await browser.getCurrentPage();
|
let currentPage = await browser.getCurrentPage();
|
||||||
if (!currentPage) {
|
if (!currentPage) {
|
||||||
throw new Error('Could not create a new page');
|
throw new Error('Could not create a new page');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const workflow = AddGeneratedFlags(recording.recording);
|
||||||
const interpretationInfo = await browser.interpreter.InterpretRecording(
|
const interpretationInfo = await browser.interpreter.InterpretRecording(
|
||||||
recording.recording, currentPage, plainRun.interpreterSettings
|
workflow, currentPage, (newPage: Page) => currentPage = newPage, plainRun.interpreterSettings
|
||||||
);
|
);
|
||||||
|
|
||||||
const binaryOutputService = new BinaryOutputService('maxun-run-screenshots');
|
const binaryOutputService = new BinaryOutputService('maxun-run-screenshots');
|
||||||
|
|||||||
@@ -9,14 +9,39 @@ import { chromium } from 'playwright-extra';
|
|||||||
import stealthPlugin from 'puppeteer-extra-plugin-stealth';
|
import stealthPlugin from 'puppeteer-extra-plugin-stealth';
|
||||||
import { PlaywrightBlocker } from '@cliqz/adblocker-playwright';
|
import { PlaywrightBlocker } from '@cliqz/adblocker-playwright';
|
||||||
import fetch from 'cross-fetch';
|
import fetch from 'cross-fetch';
|
||||||
|
import { throttle } from 'lodash';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
import logger from '../../logger';
|
import logger from '../../logger';
|
||||||
import { InterpreterSettings, RemoteBrowserOptions } from "../../types";
|
import { InterpreterSettings, RemoteBrowserOptions } from "../../types";
|
||||||
import { WorkflowGenerator } from "../../workflow-management/classes/Generator";
|
import { WorkflowGenerator } from "../../workflow-management/classes/Generator";
|
||||||
import { WorkflowInterpreter } from "../../workflow-management/classes/Interpreter";
|
import { WorkflowInterpreter } from "../../workflow-management/classes/Interpreter";
|
||||||
import { getDecryptedProxyConfig } from '../../routes/proxy';
|
import { getDecryptedProxyConfig } from '../../routes/proxy';
|
||||||
|
import { getInjectableScript } from 'idcac-playwright';
|
||||||
|
|
||||||
chromium.use(stealthPlugin());
|
chromium.use(stealthPlugin());
|
||||||
|
|
||||||
|
const MEMORY_CONFIG = {
|
||||||
|
gcInterval: 60000, // 1 minute
|
||||||
|
maxHeapSize: 2048 * 1024 * 1024, // 2GB
|
||||||
|
heapUsageThreshold: 0.85 // 85%
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCREENCAST_CONFIG: {
|
||||||
|
format: "jpeg" | "png";
|
||||||
|
maxWidth: number;
|
||||||
|
maxHeight: number;
|
||||||
|
targetFPS: number;
|
||||||
|
compressionQuality: number;
|
||||||
|
maxQueueSize: number;
|
||||||
|
} = {
|
||||||
|
format: 'jpeg',
|
||||||
|
maxWidth: 900,
|
||||||
|
maxHeight: 400,
|
||||||
|
targetFPS: 30,
|
||||||
|
compressionQuality: 0.8,
|
||||||
|
maxQueueSize: 2
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class represents a remote browser instance.
|
* This class represents a remote browser instance.
|
||||||
@@ -65,6 +90,8 @@ export class RemoteBrowser {
|
|||||||
maxRepeats: 1,
|
maxRepeats: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private lastEmittedUrl: string | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@link WorkflowGenerator} instance specific to the remote browser.
|
* {@link WorkflowGenerator} instance specific to the remote browser.
|
||||||
*/
|
*/
|
||||||
@@ -75,6 +102,11 @@ export class RemoteBrowser {
|
|||||||
*/
|
*/
|
||||||
public interpreter: WorkflowInterpreter;
|
public interpreter: WorkflowInterpreter;
|
||||||
|
|
||||||
|
|
||||||
|
private screenshotQueue: Buffer[] = [];
|
||||||
|
private isProcessingScreenshot = false;
|
||||||
|
private screencastInterval: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes a new instances of the {@link Generator} and {@link WorkflowInterpreter} classes and
|
* Initializes a new instances of the {@link Generator} and {@link WorkflowInterpreter} classes and
|
||||||
* assigns the socket instance everywhere.
|
* assigns the socket instance everywhere.
|
||||||
@@ -87,6 +119,117 @@ export class RemoteBrowser {
|
|||||||
this.generator = new WorkflowGenerator(socket);
|
this.generator = new WorkflowGenerator(socket);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private initializeMemoryManagement(): void {
|
||||||
|
setInterval(() => {
|
||||||
|
const memoryUsage = process.memoryUsage();
|
||||||
|
const heapUsageRatio = memoryUsage.heapUsed / MEMORY_CONFIG.maxHeapSize;
|
||||||
|
|
||||||
|
if (heapUsageRatio > MEMORY_CONFIG.heapUsageThreshold) {
|
||||||
|
logger.warn('High memory usage detected, triggering cleanup');
|
||||||
|
this.performMemoryCleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear screenshot queue if it's too large
|
||||||
|
if (this.screenshotQueue.length > SCREENCAST_CONFIG.maxQueueSize) {
|
||||||
|
this.screenshotQueue = this.screenshotQueue.slice(-SCREENCAST_CONFIG.maxQueueSize);
|
||||||
|
}
|
||||||
|
}, MEMORY_CONFIG.gcInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async performMemoryCleanup(): Promise<void> {
|
||||||
|
this.screenshotQueue = [];
|
||||||
|
this.isProcessingScreenshot = false;
|
||||||
|
|
||||||
|
if (global.gc) {
|
||||||
|
global.gc();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset CDP session if needed
|
||||||
|
if (this.client) {
|
||||||
|
try {
|
||||||
|
await this.stopScreencast();
|
||||||
|
this.client = null;
|
||||||
|
if (this.currentPage) {
|
||||||
|
this.client = await this.currentPage.context().newCDPSession(this.currentPage);
|
||||||
|
await this.startScreencast();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error resetting CDP session:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes URLs to prevent navigation loops while maintaining consistent format
|
||||||
|
*/
|
||||||
|
private normalizeUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
// Remove trailing slashes except for root path
|
||||||
|
parsedUrl.pathname = parsedUrl.pathname.replace(/\/+$/, '') || '/';
|
||||||
|
// Ensure consistent protocol handling
|
||||||
|
parsedUrl.protocol = parsedUrl.protocol.toLowerCase();
|
||||||
|
return parsedUrl.toString();
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if a URL change is significant enough to emit
|
||||||
|
*/
|
||||||
|
private shouldEmitUrlChange(newUrl: string): boolean {
|
||||||
|
if (!this.lastEmittedUrl) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const normalizedNew = this.normalizeUrl(newUrl);
|
||||||
|
const normalizedLast = this.normalizeUrl(this.lastEmittedUrl);
|
||||||
|
return normalizedNew !== normalizedLast;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setupPageEventListeners(page: Page) {
|
||||||
|
page.on('framenavigated', async (frame) => {
|
||||||
|
if (frame === page.mainFrame()) {
|
||||||
|
const currentUrl = page.url();
|
||||||
|
if (this.shouldEmitUrlChange(currentUrl)) {
|
||||||
|
this.lastEmittedUrl = currentUrl;
|
||||||
|
this.socket.emit('urlChanged', currentUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle page load events with retry mechanism
|
||||||
|
page.on('load', async () => {
|
||||||
|
const injectScript = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 5000 });
|
||||||
|
|
||||||
|
await page.evaluate(getInjectableScript());
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.log('warn', `Script injection attempt failed: ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const success = await injectScript();
|
||||||
|
console.log("Script injection result:", success);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getUserAgent() {
|
||||||
|
const userAgents = [
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.5845.140 Safari/537.36',
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:117.0) Gecko/20100101 Firefox/117.0',
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.1938.81 Safari/537.36 Edg/116.0.1938.81',
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.5845.96 Safari/537.36 OPR/101.0.4843.25',
|
||||||
|
'Mozilla/5.0 (Windows NT 11.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.5938.62 Safari/537.36',
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:118.0) Gecko/20100101 Firefox/118.0',
|
||||||
|
];
|
||||||
|
|
||||||
|
return userAgents[Math.floor(Math.random() * userAgents.length)];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An asynchronous constructor for asynchronously initialized properties.
|
* An asynchronous constructor for asynchronously initialized properties.
|
||||||
* Must be called right after creating an instance of RemoteBrowser class.
|
* Must be called right after creating an instance of RemoteBrowser class.
|
||||||
@@ -94,37 +237,17 @@ export class RemoteBrowser {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
public initialize = async (userId: string): Promise<void> => {
|
public initialize = async (userId: string): Promise<void> => {
|
||||||
// const launchOptions = {
|
|
||||||
// headless: true,
|
|
||||||
// proxy: options.launchOptions?.proxy,
|
|
||||||
// chromiumSandbox: false,
|
|
||||||
// args: [
|
|
||||||
// '--no-sandbox',
|
|
||||||
// '--disable-setuid-sandbox',
|
|
||||||
// '--headless=new',
|
|
||||||
// '--disable-gpu',
|
|
||||||
// '--disable-dev-shm-usage',
|
|
||||||
// '--disable-software-rasterizer',
|
|
||||||
// '--in-process-gpu',
|
|
||||||
// '--disable-infobars',
|
|
||||||
// '--single-process',
|
|
||||||
// '--no-zygote',
|
|
||||||
// '--disable-notifications',
|
|
||||||
// '--disable-extensions',
|
|
||||||
// '--disable-background-timer-throttling',
|
|
||||||
// ...(options.launchOptions?.args || [])
|
|
||||||
// ],
|
|
||||||
// env: {
|
|
||||||
// ...process.env,
|
|
||||||
// CHROMIUM_FLAGS: '--disable-gpu --no-sandbox --headless=new'
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
// console.log('Launch options before:', options.launchOptions);
|
|
||||||
// this.browser = <Browser>(await options.browser.launch(launchOptions));
|
|
||||||
|
|
||||||
// console.log('Launch options after:', options.launchOptions)
|
|
||||||
this.browser = <Browser>(await chromium.launch({
|
this.browser = <Browser>(await chromium.launch({
|
||||||
headless: true,
|
headless: true,
|
||||||
|
args: [
|
||||||
|
"--disable-blink-features=AutomationControlled",
|
||||||
|
"--disable-web-security",
|
||||||
|
"--disable-features=IsolateOrigins,site-per-process",
|
||||||
|
"--disable-site-isolation-trials",
|
||||||
|
"--disable-extensions",
|
||||||
|
"--no-sandbox",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
],
|
||||||
}));
|
}));
|
||||||
const proxyConfig = await getDecryptedProxyConfig(userId);
|
const proxyConfig = await getDecryptedProxyConfig(userId);
|
||||||
let proxyOptions: { server: string, username?: string, password?: string } = { server: '' };
|
let proxyOptions: { server: string, username?: string, password?: string } = { server: '' };
|
||||||
@@ -140,7 +263,7 @@ export class RemoteBrowser {
|
|||||||
const contextOptions: any = {
|
const contextOptions: any = {
|
||||||
viewport: { height: 400, width: 900 },
|
viewport: { height: 400, width: 900 },
|
||||||
// recordVideo: { dir: 'videos/' }
|
// recordVideo: { dir: 'videos/' }
|
||||||
// Force reduced motion to prevent animation issues
|
// Force reduced motion to prevent animation issues
|
||||||
reducedMotion: 'reduce',
|
reducedMotion: 'reduce',
|
||||||
// Force JavaScript to be enabled
|
// Force JavaScript to be enabled
|
||||||
javaScriptEnabled: true,
|
javaScriptEnabled: true,
|
||||||
@@ -149,7 +272,8 @@ export class RemoteBrowser {
|
|||||||
// Disable hardware acceleration
|
// Disable hardware acceleration
|
||||||
forcedColors: 'none',
|
forcedColors: 'none',
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
hasTouch: false
|
hasTouch: false,
|
||||||
|
userAgent: this.getUserAgent(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (proxyOptions.server) {
|
if (proxyOptions.server) {
|
||||||
@@ -159,26 +283,48 @@ export class RemoteBrowser {
|
|||||||
password: proxyOptions.password ? proxyOptions.password : undefined,
|
password: proxyOptions.password ? proxyOptions.password : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const browserUserAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.5481.38 Safari/537.36";
|
|
||||||
|
|
||||||
|
|
||||||
contextOptions.userAgent = browserUserAgent;
|
|
||||||
this.context = await this.browser.newContext(contextOptions);
|
this.context = await this.browser.newContext(contextOptions);
|
||||||
|
await this.context.addInitScript(
|
||||||
|
`const defaultGetter = Object.getOwnPropertyDescriptor(
|
||||||
|
Navigator.prototype,
|
||||||
|
"webdriver"
|
||||||
|
).get;
|
||||||
|
defaultGetter.apply(navigator);
|
||||||
|
defaultGetter.toString();
|
||||||
|
Object.defineProperty(Navigator.prototype, "webdriver", {
|
||||||
|
set: undefined,
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true,
|
||||||
|
get: new Proxy(defaultGetter, {
|
||||||
|
apply: (target, thisArg, args) => {
|
||||||
|
Reflect.apply(target, thisArg, args);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const patchedGetter = Object.getOwnPropertyDescriptor(
|
||||||
|
Navigator.prototype,
|
||||||
|
"webdriver"
|
||||||
|
).get;
|
||||||
|
patchedGetter.apply(navigator);
|
||||||
|
patchedGetter.toString();`
|
||||||
|
);
|
||||||
this.currentPage = await this.context.newPage();
|
this.currentPage = await this.context.newPage();
|
||||||
|
|
||||||
this.currentPage.on('framenavigated', (frame) => {
|
await this.setupPageEventListeners(this.currentPage);
|
||||||
if (frame === this.currentPage?.mainFrame()) {
|
|
||||||
this.socket.emit('urlChanged', this.currentPage.url());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// await this.currentPage.setExtraHTTPHeaders({
|
try {
|
||||||
// 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'
|
const blocker = await PlaywrightBlocker.fromLists(fetch, ['https://easylist.to/easylist/easylist.txt']);
|
||||||
// });
|
await blocker.enableBlockingInPage(this.currentPage);
|
||||||
const blocker = await PlaywrightBlocker.fromPrebuiltAdsAndTracking(fetch);
|
this.client = await this.currentPage.context().newCDPSession(this.currentPage);
|
||||||
await blocker.enableBlockingInPage(this.currentPage);
|
await blocker.disableBlockingInPage(this.currentPage);
|
||||||
this.client = await this.currentPage.context().newCDPSession(this.currentPage);
|
console.log('Adblocker initialized');
|
||||||
await blocker.disableBlockingInPage(this.currentPage);
|
} catch (error: any) {
|
||||||
|
console.warn('Failed to initialize adblocker, continuing without it:', error.message);
|
||||||
|
// Still need to set up the CDP session even if blocker fails
|
||||||
|
this.client = await this.currentPage.context().newCDPSession(this.currentPage);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -242,7 +388,7 @@ export class RemoteBrowser {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.client.on('Page.screencastFrame', ({ data: base64, sessionId }) => {
|
this.client.on('Page.screencastFrame', ({ data: base64, sessionId }) => {
|
||||||
this.emitScreenshot(base64)
|
this.emitScreenshot(Buffer.from(base64, 'base64'))
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
if (!this.client) {
|
if (!this.client) {
|
||||||
@@ -262,16 +408,49 @@ export class RemoteBrowser {
|
|||||||
* If an interpretation was running it will be stopped.
|
* If an interpretation was running it will be stopped.
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
public switchOff = async (): Promise<void> => {
|
public async switchOff(): Promise<void> {
|
||||||
await this.interpreter.stopInterpretation();
|
try {
|
||||||
if (this.browser) {
|
await this.interpreter.stopInterpretation();
|
||||||
await this.stopScreencast();
|
|
||||||
await this.browser.close();
|
if (this.screencastInterval) {
|
||||||
} else {
|
clearInterval(this.screencastInterval);
|
||||||
logger.log('error', 'Browser wasn\'t initialized');
|
}
|
||||||
logger.log('error', 'Switching off the browser failed');
|
|
||||||
|
if (this.client) {
|
||||||
|
await this.stopScreencast();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.browser) {
|
||||||
|
await this.browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.screenshotQueue = [];
|
||||||
|
//this.performanceMonitor.reset();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error during browser shutdown:', error);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
private async optimizeScreenshot(screenshot: Buffer): Promise<Buffer> {
|
||||||
|
try {
|
||||||
|
return await sharp(screenshot)
|
||||||
|
.jpeg({
|
||||||
|
quality: Math.round(SCREENCAST_CONFIG.compressionQuality * 100),
|
||||||
|
progressive: true
|
||||||
|
})
|
||||||
|
.resize({
|
||||||
|
width: SCREENCAST_CONFIG.maxWidth,
|
||||||
|
height: SCREENCAST_CONFIG.maxHeight,
|
||||||
|
fit: 'inside',
|
||||||
|
withoutEnlargement: true
|
||||||
|
})
|
||||||
|
.toBuffer();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Screenshot optimization failed:', error);
|
||||||
|
return screenshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Makes and emits a single screenshot to the client side.
|
* Makes and emits a single screenshot to the client side.
|
||||||
@@ -281,7 +460,7 @@ export class RemoteBrowser {
|
|||||||
try {
|
try {
|
||||||
const screenshot = await this.currentPage?.screenshot();
|
const screenshot = await this.currentPage?.screenshot();
|
||||||
if (screenshot) {
|
if (screenshot) {
|
||||||
this.emitScreenshot(screenshot.toString('base64'));
|
this.emitScreenshot(screenshot);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { message } = e as Error;
|
const { message } = e as Error;
|
||||||
@@ -370,11 +549,7 @@ export class RemoteBrowser {
|
|||||||
await this.stopScreencast();
|
await this.stopScreencast();
|
||||||
this.currentPage = page;
|
this.currentPage = page;
|
||||||
|
|
||||||
this.currentPage.on('framenavigated', (frame) => {
|
await this.setupPageEventListeners(this.currentPage);
|
||||||
if (frame === this.currentPage?.mainFrame()) {
|
|
||||||
this.socket.emit('urlChanged', this.currentPage.url());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
//await this.currentPage.setViewportSize({ height: 400, width: 900 })
|
//await this.currentPage.setViewportSize({ height: 400, width: 900 })
|
||||||
this.client = await this.currentPage.context().newCDPSession(this.currentPage);
|
this.client = await this.currentPage.context().newCDPSession(this.currentPage);
|
||||||
@@ -402,14 +577,8 @@ export class RemoteBrowser {
|
|||||||
await this.currentPage?.close();
|
await this.currentPage?.close();
|
||||||
this.currentPage = newPage;
|
this.currentPage = newPage;
|
||||||
if (this.currentPage) {
|
if (this.currentPage) {
|
||||||
this.currentPage.on('framenavigated', (frame) => {
|
await this.setupPageEventListeners(this.currentPage);
|
||||||
if (frame === this.currentPage?.mainFrame()) {
|
|
||||||
this.socket.emit('urlChanged', this.currentPage.url());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// this.currentPage.on('load', (page) => {
|
|
||||||
// this.socket.emit('urlChanged', page.url());
|
|
||||||
// })
|
|
||||||
this.client = await this.currentPage.context().newCDPSession(this.currentPage);
|
this.client = await this.currentPage.context().newCDPSession(this.currentPage);
|
||||||
await this.subscribeToScreencast();
|
await this.subscribeToScreencast();
|
||||||
} else {
|
} else {
|
||||||
@@ -423,37 +592,85 @@ export class RemoteBrowser {
|
|||||||
* Should be called only once after the browser is fully initialized.
|
* Should be called only once after the browser is fully initialized.
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
private startScreencast = async (): Promise<void> => {
|
private async startScreencast(): Promise<void> {
|
||||||
if (!this.client) {
|
if (!this.client) {
|
||||||
logger.log('warn', 'client is not initialized');
|
logger.warn('Client is not initialized');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.client.send('Page.startScreencast', { format: 'jpeg', quality: 75 });
|
|
||||||
logger.log('info', `Browser started with screencasting a page.`);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
try {
|
||||||
* Unsubscribes the current page from the screencast session.
|
await this.client.send('Page.startScreencast', {
|
||||||
* @returns {Promise<void>}
|
format: SCREENCAST_CONFIG.format,
|
||||||
*/
|
});
|
||||||
private stopScreencast = async (): Promise<void> => {
|
|
||||||
if (!this.client) {
|
// Set up screencast frame handler
|
||||||
logger.log('error', 'client is not initialized');
|
this.client.on('Page.screencastFrame', async ({ data, sessionId }) => {
|
||||||
logger.log('error', 'Screencast stop failed');
|
try {
|
||||||
} else {
|
const buffer = Buffer.from(data, 'base64');
|
||||||
await this.client.send('Page.stopScreencast');
|
await this.emitScreenshot(buffer);
|
||||||
logger.log('info', `Browser stopped with screencasting.`);
|
await this.client?.send('Page.screencastFrameAck', { sessionId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Screencast frame processing failed:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Screencast started successfully');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to start screencast:', error);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
private async stopScreencast(): Promise<void> {
|
||||||
|
if (!this.client) {
|
||||||
|
logger.error('Client is not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.client.send('Page.stopScreencast');
|
||||||
|
this.screenshotQueue = [];
|
||||||
|
this.isProcessingScreenshot = false;
|
||||||
|
logger.info('Screencast stopped successfully');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to stop screencast:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper for emitting the screenshot of browser's active page through websocket.
|
* Helper for emitting the screenshot of browser's active page through websocket.
|
||||||
* @param payload the screenshot binary data
|
* @param payload the screenshot binary data
|
||||||
* @returns void
|
* @returns void
|
||||||
*/
|
*/
|
||||||
private emitScreenshot = (payload: any): void => {
|
private emitScreenshot = async (payload: Buffer): Promise<void> => {
|
||||||
const dataWithMimeType = ('data:image/jpeg;base64,').concat(payload);
|
if (this.isProcessingScreenshot) {
|
||||||
this.socket.emit('screencast', dataWithMimeType);
|
if (this.screenshotQueue.length < SCREENCAST_CONFIG.maxQueueSize) {
|
||||||
logger.log('debug', `Screenshot emitted`);
|
this.screenshotQueue.push(payload);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isProcessingScreenshot = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const optimizedScreenshot = await this.optimizeScreenshot(payload);
|
||||||
|
const base64Data = optimizedScreenshot.toString('base64');
|
||||||
|
const dataWithMimeType = `data:image/jpeg;base64,${base64Data}`;
|
||||||
|
|
||||||
|
this.socket.emit('screencast', dataWithMimeType);
|
||||||
|
logger.debug('Screenshot emitted');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Screenshot emission failed:', error);
|
||||||
|
} finally {
|
||||||
|
this.isProcessingScreenshot = false;
|
||||||
|
|
||||||
|
if (this.screenshotQueue.length > 0) {
|
||||||
|
const nextScreenshot = this.screenshotQueue.shift();
|
||||||
|
if (nextScreenshot) {
|
||||||
|
setTimeout(() => this.emitScreenshot(nextScreenshot), 1000 / SCREENCAST_CONFIG.targetFPS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import { Socket } from 'socket.io';
|
import { Socket } from 'socket.io';
|
||||||
|
|
||||||
import logger from "../logger";
|
import logger from "../logger";
|
||||||
import { Coordinates, ScrollDeltas, KeyboardInput } from '../types';
|
import { Coordinates, ScrollDeltas, KeyboardInput, DatePickerEventData } from '../types';
|
||||||
import { browserPool } from "../server";
|
import { browserPool } from "../server";
|
||||||
import { WorkflowGenerator } from "../workflow-management/classes/Generator";
|
import { WorkflowGenerator } from "../workflow-management/classes/Generator";
|
||||||
import { Page } from "playwright";
|
import { Page } from "playwright";
|
||||||
@@ -223,6 +223,53 @@ const handleKeydown = async (generator: WorkflowGenerator, page: Page, { key, co
|
|||||||
logger.log('debug', `Key ${key} pressed`);
|
logger.log('debug', `Key ${key} pressed`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the date selection event.
|
||||||
|
* @param generator - the workflow generator {@link Generator}
|
||||||
|
* @param page - the active page of the remote browser
|
||||||
|
* @param data - the data of the date selection event {@link DatePickerEventData}
|
||||||
|
* @category BrowserManagement
|
||||||
|
*/
|
||||||
|
const handleDateSelection = async (generator: WorkflowGenerator, page: Page, data: DatePickerEventData) => {
|
||||||
|
await generator.onDateSelection(page, data);
|
||||||
|
logger.log('debug', `Date ${data.value} selected`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDateSelection = async (data: DatePickerEventData) => {
|
||||||
|
logger.log('debug', 'Handling date selection event emitted from client');
|
||||||
|
await handleWrapper(handleDateSelection, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDropdownSelection = async (generator: WorkflowGenerator, page: Page, data: { selector: string, value: string }) => {
|
||||||
|
await generator.onDropdownSelection(page, data);
|
||||||
|
logger.log('debug', `Dropdown value ${data.value} selected`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDropdownSelection = async (data: { selector: string, value: string }) => {
|
||||||
|
logger.log('debug', 'Handling dropdown selection event emitted from client');
|
||||||
|
await handleWrapper(handleDropdownSelection, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTimeSelection = async (generator: WorkflowGenerator, page: Page, data: { selector: string, value: string }) => {
|
||||||
|
await generator.onTimeSelection(page, data);
|
||||||
|
logger.log('debug', `Time value ${data.value} selected`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTimeSelection = async (data: { selector: string, value: string }) => {
|
||||||
|
logger.log('debug', 'Handling time selection event emitted from client');
|
||||||
|
await handleWrapper(handleTimeSelection, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDateTimeLocalSelection = async (generator: WorkflowGenerator, page: Page, data: { selector: string, value: string }) => {
|
||||||
|
await generator.onDateTimeLocalSelection(page, data);
|
||||||
|
logger.log('debug', `DateTime Local value ${data.value} selected`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDateTimeLocalSelection = async (data: { selector: string, value: string }) => {
|
||||||
|
logger.log('debug', 'Handling datetime-local selection event emitted from client');
|
||||||
|
await handleWrapper(handleDateTimeLocalSelection, data);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A wrapper function for handling the keyup event.
|
* A wrapper function for handling the keyup event.
|
||||||
* @param keyboardInput - the keyboard input of the keyup event
|
* @param keyboardInput - the keyboard input of the keyup event
|
||||||
@@ -378,6 +425,10 @@ const registerInputHandlers = (socket: Socket) => {
|
|||||||
socket.on("input:refresh", onRefresh);
|
socket.on("input:refresh", onRefresh);
|
||||||
socket.on("input:back", onGoBack);
|
socket.on("input:back", onGoBack);
|
||||||
socket.on("input:forward", onGoForward);
|
socket.on("input:forward", onGoForward);
|
||||||
|
socket.on("input:date", onDateSelection);
|
||||||
|
socket.on("input:dropdown", onDropdownSelection);
|
||||||
|
socket.on("input:time", onTimeSelection);
|
||||||
|
socket.on("input:datetime-local", onDateTimeLocalSelection);
|
||||||
socket.on("action", onGenerateAction);
|
socket.on("action", onGenerateAction);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ router.post("/register", async (req, res) => {
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
registeredAt: new Date().toISOString(),
|
registeredAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
console.log(`User registered - ${user.email}`);
|
console.log(`User registered`);
|
||||||
res.json(user);
|
res.json(user);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log(`Could not register user - ${error}`);
|
console.log(`Could not register user - ${error}`);
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import { AuthenticatedRequest } from './record';
|
|||||||
import { computeNextRun } from '../utils/schedule';
|
import { computeNextRun } from '../utils/schedule';
|
||||||
import { capture } from "../utils/analytics";
|
import { capture } from "../utils/analytics";
|
||||||
import { tryCatch } from 'bullmq';
|
import { tryCatch } from 'bullmq';
|
||||||
|
import { WorkflowFile } from 'maxun-core';
|
||||||
|
import { Page } from 'playwright';
|
||||||
chromium.use(stealthPlugin());
|
chromium.use(stealthPlugin());
|
||||||
|
|
||||||
export const router = Router();
|
export const router = Router();
|
||||||
@@ -422,6 +424,17 @@ router.get('/runs/run/:id', requireSignIn, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function AddGeneratedFlags(workflow: WorkflowFile) {
|
||||||
|
const copy = JSON.parse(JSON.stringify(workflow));
|
||||||
|
for (let i = 0; i < workflow.workflow.length; i++) {
|
||||||
|
copy.workflow[i].what.unshift({
|
||||||
|
action: 'flag',
|
||||||
|
args: ['generated'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT endpoint for finishing a run and saving it to the storage.
|
* PUT endpoint for finishing a run and saving it to the storage.
|
||||||
*/
|
*/
|
||||||
@@ -443,10 +456,11 @@ router.post('/runs/run/:id', requireSignIn, async (req: AuthenticatedRequest, re
|
|||||||
|
|
||||||
// interpret the run in active browser
|
// interpret the run in active browser
|
||||||
const browser = browserPool.getRemoteBrowser(plainRun.browserId);
|
const browser = browserPool.getRemoteBrowser(plainRun.browserId);
|
||||||
const currentPage = browser?.getCurrentPage();
|
let currentPage = browser?.getCurrentPage();
|
||||||
if (browser && currentPage) {
|
if (browser && currentPage) {
|
||||||
|
const workflow = AddGeneratedFlags(recording.recording);
|
||||||
const interpretationInfo = await browser.interpreter.InterpretRecording(
|
const interpretationInfo = await browser.interpreter.InterpretRecording(
|
||||||
recording.recording, currentPage, plainRun.interpreterSettings);
|
workflow, currentPage, (newPage: Page) => currentPage = newPage, plainRun.interpreterSettings);
|
||||||
const binaryOutputService = new BinaryOutputService('maxun-run-screenshots');
|
const binaryOutputService = new BinaryOutputService('maxun-run-screenshots');
|
||||||
const uploadedBinaryOutput = await binaryOutputService.uploadAndStoreBinaryOutput(run, interpretationInfo.binaryOutput);
|
const uploadedBinaryOutput = await binaryOutputService.uploadAndStoreBinaryOutput(run, interpretationInfo.binaryOutput);
|
||||||
await destroyRemoteBrowser(plainRun.browserId);
|
await destroyRemoteBrowser(plainRun.browserId);
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
import swaggerJSDoc from 'swagger-jsdoc';
|
import swaggerJSDoc from 'swagger-jsdoc';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
// Dynamically determine API file paths
|
||||||
|
const jsFiles = [path.join(__dirname, '../api/*.js')]
|
||||||
|
const tsFiles = [path.join(__dirname, '../api/*.ts')]
|
||||||
|
|
||||||
|
let apis = fs.existsSync(jsFiles[0]) ? jsFiles : tsFiles;
|
||||||
|
|
||||||
|
if (!apis) {
|
||||||
|
throw new Error('No valid API files found! Ensure either .js or .ts files exist in the ../api/ directory.');
|
||||||
|
}
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
definition: {
|
definition: {
|
||||||
openapi: '3.0.0',
|
openapi: '3.0.0',
|
||||||
info: {
|
info: {
|
||||||
title: 'Maxun API Documentation',
|
title: 'Website to API',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
description: 'API documentation for Maxun (https://github.com/getmaxun/maxun)',
|
description:
|
||||||
|
'Maxun lets you get the data your robot extracted and run robots via API. All you need to do is input the Maxun API key by clicking Authorize below.',
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
securitySchemes: {
|
securitySchemes: {
|
||||||
@@ -15,7 +27,8 @@ const options = {
|
|||||||
type: 'apiKey',
|
type: 'apiKey',
|
||||||
in: 'header',
|
in: 'header',
|
||||||
name: 'x-api-key',
|
name: 'x-api-key',
|
||||||
description: 'API key for authorization. You can find your API key in the "API Key" section on Maxun Dashboard.',
|
description:
|
||||||
|
'API key for authorization. You can find your API key in the "API Key" section on Maxun Dashboard.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -25,7 +38,7 @@ const options = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
apis: process.env.NODE_ENV === 'production' ? [path.join(__dirname, '../api/*.js')] : [path.join(__dirname, '../api/*.ts')]
|
apis,
|
||||||
};
|
};
|
||||||
|
|
||||||
const swaggerSpec = swaggerJSDoc(options);
|
const swaggerSpec = swaggerJSDoc(options);
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ export interface Coordinates {
|
|||||||
y: number;
|
y: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* interface to handle date picker events.
|
||||||
|
* @category Types
|
||||||
|
*/
|
||||||
|
export interface DatePickerEventData {
|
||||||
|
coordinates: Coordinates;
|
||||||
|
selector: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holds the deltas of a wheel/scroll event.
|
* Holds the deltas of a wheel/scroll event.
|
||||||
* @category Types
|
* @category Types
|
||||||
@@ -119,6 +129,17 @@ export interface BaseActionInfo {
|
|||||||
hasOnlyText: boolean;
|
hasOnlyText: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
interface IframeSelector {
|
||||||
|
full: string;
|
||||||
|
isIframe: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShadowSelector {
|
||||||
|
full: string;
|
||||||
|
mode: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holds all the possible css selectors that has been found for an element.
|
* Holds all the possible css selectors that has been found for an element.
|
||||||
* @category Types
|
* @category Types
|
||||||
@@ -133,6 +154,8 @@ export interface Selectors {
|
|||||||
hrefSelector: string|null;
|
hrefSelector: string|null;
|
||||||
accessibilitySelector: string|null;
|
accessibilitySelector: string|null;
|
||||||
formSelector: string|null;
|
formSelector: string|null;
|
||||||
|
iframeSelector: IframeSelector|null;
|
||||||
|
shadowSelector: ShadowSelector|null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -146,7 +169,7 @@ export interface BaseAction extends BaseActionInfo{
|
|||||||
associatedActions: ActionType[];
|
associatedActions: ActionType[];
|
||||||
inputType: string | undefined;
|
inputType: string | undefined;
|
||||||
value: string | undefined;
|
value: string | undefined;
|
||||||
selectors: { [key: string]: string | null };
|
selectors: Selectors;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
isPassword: boolean;
|
isPassword: boolean;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Action, ActionType, Coordinates, TagName } from "../../types";
|
import { Action, ActionType, Coordinates, TagName, DatePickerEventData } from "../../types";
|
||||||
import { WhereWhatPair, WorkflowFile } from 'maxun-core';
|
import { WhereWhatPair, WorkflowFile } from 'maxun-core';
|
||||||
import logger from "../../logger";
|
import logger from "../../logger";
|
||||||
import { Socket } from "socket.io";
|
import { Socket } from "socket.io";
|
||||||
@@ -140,19 +140,22 @@ export class WorkflowGenerator {
|
|||||||
socket.on('decision', async ({ pair, actionType, decision }) => {
|
socket.on('decision', async ({ pair, actionType, decision }) => {
|
||||||
const id = browserPool.getActiveBrowserId();
|
const id = browserPool.getActiveBrowserId();
|
||||||
if (id) {
|
if (id) {
|
||||||
const activeBrowser = browserPool.getRemoteBrowser(id);
|
// const activeBrowser = browserPool.getRemoteBrowser(id);
|
||||||
const currentPage = activeBrowser?.getCurrentPage();
|
// const currentPage = activeBrowser?.getCurrentPage();
|
||||||
if (decision) {
|
if (!decision) {
|
||||||
switch (actionType) {
|
switch (actionType) {
|
||||||
case 'customAction':
|
case 'customAction':
|
||||||
pair.where.selectors = [this.generatedData.lastUsedSelector];
|
// pair.where.selectors = [this.generatedData.lastUsedSelector];
|
||||||
|
pair.where.selectors = pair.where.selectors.filter(
|
||||||
|
(selector: string) => selector !== this.generatedData.lastUsedSelector
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
default: break;
|
default: break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (currentPage) {
|
// if (currentPage) {
|
||||||
await this.addPairToWorkflowAndNotifyClient(pair, currentPage);
|
// await this.addPairToWorkflowAndNotifyClient(pair, currentPage);
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
socket.on('updatePair', (data) => {
|
socket.on('updatePair', (data) => {
|
||||||
@@ -252,6 +255,85 @@ export class WorkflowGenerator {
|
|||||||
logger.log('info', `Workflow emitted`);
|
logger.log('info', `Workflow emitted`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public onDateSelection = async (page: Page, data: DatePickerEventData) => {
|
||||||
|
const { selector, value } = data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.fill(selector, value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fill date value:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pair: WhereWhatPair = {
|
||||||
|
where: { url: this.getBestUrl(page.url()) },
|
||||||
|
what: [{
|
||||||
|
action: 'fill',
|
||||||
|
args: [selector, value],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.addPairToWorkflowAndNotifyClient(pair, page);
|
||||||
|
};
|
||||||
|
|
||||||
|
public onDropdownSelection = async (page: Page, data: { selector: string, value: string }) => {
|
||||||
|
const { selector, value } = data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.selectOption(selector, value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fill date value:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pair: WhereWhatPair = {
|
||||||
|
where: { url: this.getBestUrl(page.url()) },
|
||||||
|
what: [{
|
||||||
|
action: 'selectOption',
|
||||||
|
args: [selector, value],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.addPairToWorkflowAndNotifyClient(pair, page);
|
||||||
|
};
|
||||||
|
|
||||||
|
public onTimeSelection = async (page: Page, data: { selector: string, value: string }) => {
|
||||||
|
const { selector, value } = data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.fill(selector, value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to set time value:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pair: WhereWhatPair = {
|
||||||
|
where: { url: this.getBestUrl(page.url()) },
|
||||||
|
what: [{
|
||||||
|
action: 'fill',
|
||||||
|
args: [selector, value],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.addPairToWorkflowAndNotifyClient(pair, page);
|
||||||
|
};
|
||||||
|
|
||||||
|
public onDateTimeLocalSelection = async (page: Page, data: { selector: string, value: string }) => {
|
||||||
|
const { selector, value } = data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.fill(selector, value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fill datetime-local value:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pair: WhereWhatPair = {
|
||||||
|
where: { url: this.getBestUrl(page.url()) },
|
||||||
|
what: [{
|
||||||
|
action: 'fill',
|
||||||
|
args: [selector, value],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.addPairToWorkflowAndNotifyClient(pair, page);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a pair for the click event.
|
* Generates a pair for the click event.
|
||||||
@@ -263,6 +345,81 @@ export class WorkflowGenerator {
|
|||||||
let where: WhereWhatPair["where"] = { url: this.getBestUrl(page.url()) };
|
let where: WhereWhatPair["where"] = { url: this.getBestUrl(page.url()) };
|
||||||
const selector = await this.generateSelector(page, coordinates, ActionType.Click);
|
const selector = await this.generateSelector(page, coordinates, ActionType.Click);
|
||||||
logger.log('debug', `Element's selector: ${selector}`);
|
logger.log('debug', `Element's selector: ${selector}`);
|
||||||
|
|
||||||
|
const elementInfo = await getElementInformation(page, coordinates, '', false);
|
||||||
|
console.log("Element info: ", elementInfo);
|
||||||
|
|
||||||
|
// Check if clicked element is a select dropdown
|
||||||
|
const isDropdown = elementInfo?.tagName === 'SELECT';
|
||||||
|
|
||||||
|
if (isDropdown && elementInfo.innerHTML) {
|
||||||
|
// Parse options from innerHTML
|
||||||
|
const options = elementInfo.innerHTML
|
||||||
|
.split('<option')
|
||||||
|
.slice(1) // Remove first empty element
|
||||||
|
.map(optionHtml => {
|
||||||
|
const valueMatch = optionHtml.match(/value="([^"]*)"/);
|
||||||
|
const disabledMatch = optionHtml.includes('disabled="disabled"');
|
||||||
|
const selectedMatch = optionHtml.includes('selected="selected"');
|
||||||
|
|
||||||
|
// Extract text content between > and </option>
|
||||||
|
const textMatch = optionHtml.match(/>([^<]*)</);
|
||||||
|
const text = textMatch
|
||||||
|
? textMatch[1]
|
||||||
|
.replace(/\n/g, '') // Remove all newlines
|
||||||
|
.replace(/\s+/g, ' ') // Replace multiple spaces with single space
|
||||||
|
.trim()
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: valueMatch ? valueMatch[1] : '',
|
||||||
|
text,
|
||||||
|
disabled: disabledMatch,
|
||||||
|
selected: selectedMatch
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify client to show dropdown overlay
|
||||||
|
this.socket.emit('showDropdown', {
|
||||||
|
coordinates,
|
||||||
|
selector,
|
||||||
|
options
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if clicked element is a date input
|
||||||
|
const isDateInput = elementInfo?.tagName === 'INPUT' && elementInfo?.attributes?.type === 'date';
|
||||||
|
|
||||||
|
if (isDateInput) {
|
||||||
|
// Notify client to show datepicker overlay
|
||||||
|
this.socket.emit('showDatePicker', {
|
||||||
|
coordinates,
|
||||||
|
selector
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTimeInput = elementInfo?.tagName === 'INPUT' && elementInfo?.attributes?.type === 'time';
|
||||||
|
|
||||||
|
if (isTimeInput) {
|
||||||
|
this.socket.emit('showTimePicker', {
|
||||||
|
coordinates,
|
||||||
|
selector
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDateTimeLocal = elementInfo?.tagName === 'INPUT' && elementInfo?.attributes?.type === 'datetime-local';
|
||||||
|
|
||||||
|
if (isDateTimeLocal) {
|
||||||
|
this.socket.emit('showDateTimePicker', {
|
||||||
|
coordinates,
|
||||||
|
selector
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
//const element = await getElementMouseIsOver(page, coordinates);
|
//const element = await getElementMouseIsOver(page, coordinates);
|
||||||
//logger.log('debug', `Element: ${JSON.stringify(element, null, 2)}`);
|
//logger.log('debug', `Element: ${JSON.stringify(element, null, 2)}`);
|
||||||
if (selector) {
|
if (selector) {
|
||||||
@@ -360,6 +517,8 @@ export class WorkflowGenerator {
|
|||||||
}],
|
}],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.addPairToWorkflowAndNotifyClient(pair, page);
|
||||||
|
|
||||||
if (this.generatedData.lastUsedSelector) {
|
if (this.generatedData.lastUsedSelector) {
|
||||||
const elementInfo = await this.getLastUsedSelectorInfo(page, this.generatedData.lastUsedSelector);
|
const elementInfo = await this.getLastUsedSelectorInfo(page, this.generatedData.lastUsedSelector);
|
||||||
|
|
||||||
@@ -372,9 +531,7 @@ export class WorkflowGenerator {
|
|||||||
innerText: elementInfo.innerText,
|
innerText: elementInfo.innerText,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
}
|
||||||
await this.addPairToWorkflowAndNotifyClient(pair, page);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -541,10 +698,9 @@ export class WorkflowGenerator {
|
|||||||
* @returns {Promise<string|null>}
|
* @returns {Promise<string|null>}
|
||||||
*/
|
*/
|
||||||
private generateSelector = async (page: Page, coordinates: Coordinates, action: ActionType) => {
|
private generateSelector = async (page: Page, coordinates: Coordinates, action: ActionType) => {
|
||||||
const elementInfo = await getElementInformation(page, coordinates);
|
const elementInfo = await getElementInformation(page, coordinates, this.listSelector, this.getList);
|
||||||
|
|
||||||
const selectorBasedOnCustomAction = (this.getList === true)
|
const selectorBasedOnCustomAction = (this.getList === true)
|
||||||
? await getNonUniqueSelectors(page, coordinates)
|
? await getNonUniqueSelectors(page, coordinates, this.listSelector)
|
||||||
: await getSelectors(page, coordinates);
|
: await getSelectors(page, coordinates);
|
||||||
|
|
||||||
const bestSelector = getBestSelectorForAction(
|
const bestSelector = getBestSelectorForAction(
|
||||||
@@ -570,21 +726,30 @@ export class WorkflowGenerator {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
public generateDataForHighlighter = async (page: Page, coordinates: Coordinates) => {
|
public generateDataForHighlighter = async (page: Page, coordinates: Coordinates) => {
|
||||||
const rect = await getRect(page, coordinates);
|
const rect = await getRect(page, coordinates, this.listSelector, this.getList);
|
||||||
const displaySelector = await this.generateSelector(page, coordinates, ActionType.Click);
|
const displaySelector = await this.generateSelector(page, coordinates, ActionType.Click);
|
||||||
const elementInfo = await getElementInformation(page, coordinates);
|
const elementInfo = await getElementInformation(page, coordinates, this.listSelector, this.getList);
|
||||||
if (rect) {
|
if (rect) {
|
||||||
|
const highlighterData = {
|
||||||
|
rect,
|
||||||
|
selector: displaySelector,
|
||||||
|
elementInfo,
|
||||||
|
// Include shadow DOM specific information
|
||||||
|
shadowInfo: elementInfo?.isShadowRoot ? {
|
||||||
|
mode: elementInfo.shadowRootMode,
|
||||||
|
content: elementInfo.shadowRootContent
|
||||||
|
} : null
|
||||||
|
};
|
||||||
|
|
||||||
if (this.getList === true) {
|
if (this.getList === true) {
|
||||||
if (this.listSelector !== '') {
|
if (this.listSelector !== '') {
|
||||||
const childSelectors = await getChildSelectors(page, this.listSelector || '');
|
const childSelectors = await getChildSelectors(page, this.listSelector || '');
|
||||||
this.socket.emit('highlighter', { rect, selector: displaySelector, elementInfo, childSelectors })
|
this.socket.emit('highlighter', { ...highlighterData, childSelectors })
|
||||||
console.log(`Child Selectors: ${childSelectors}`)
|
|
||||||
console.log(`Parent Selector: ${this.listSelector}`)
|
|
||||||
} else {
|
} else {
|
||||||
this.socket.emit('highlighter', { rect, selector: displaySelector, elementInfo });
|
this.socket.emit('highlighter', { ...highlighterData });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.socket.emit('highlighter', { rect, selector: displaySelector, elementInfo });
|
this.socket.emit('highlighter', { ...highlighterData });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,7 +244,12 @@ export class WorkflowInterpreter {
|
|||||||
* @param page The page instance used to interact with the browser.
|
* @param page The page instance used to interact with the browser.
|
||||||
* @param settings The settings to use for the interpretation.
|
* @param settings The settings to use for the interpretation.
|
||||||
*/
|
*/
|
||||||
public InterpretRecording = async (workflow: WorkflowFile, page: Page, settings: InterpreterSettings) => {
|
public InterpretRecording = async (
|
||||||
|
workflow: WorkflowFile,
|
||||||
|
page: Page,
|
||||||
|
updatePageOnPause: (page: Page) => void,
|
||||||
|
settings: InterpreterSettings
|
||||||
|
) => {
|
||||||
const params = settings.params ? settings.params : null;
|
const params = settings.params ? settings.params : null;
|
||||||
delete settings.params;
|
delete settings.params;
|
||||||
|
|
||||||
@@ -262,7 +267,7 @@ export class WorkflowInterpreter {
|
|||||||
this.socket.emit('debugMessage', msg)
|
this.socket.emit('debugMessage', msg)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
serializableCallback: (data: string) => {
|
serializableCallback: (data: any) => {
|
||||||
this.serializableData.push(data);
|
this.serializableData.push(data);
|
||||||
this.socket.emit('serializableCallback', data);
|
this.socket.emit('serializableCallback', data);
|
||||||
},
|
},
|
||||||
@@ -275,6 +280,23 @@ export class WorkflowInterpreter {
|
|||||||
const interpreter = new Interpreter(decryptedWorkflow, options);
|
const interpreter = new Interpreter(decryptedWorkflow, options);
|
||||||
this.interpreter = interpreter;
|
this.interpreter = interpreter;
|
||||||
|
|
||||||
|
interpreter.on('flag', async (page, resume) => {
|
||||||
|
if (this.activeId !== null && this.breakpoints[this.activeId]) {
|
||||||
|
logger.log('debug', `breakpoint hit id: ${this.activeId}`);
|
||||||
|
this.socket.emit('breakpointHit');
|
||||||
|
this.interpretationIsPaused = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.interpretationIsPaused) {
|
||||||
|
this.interpretationResume = resume;
|
||||||
|
logger.log('debug', `Paused inside of flag: ${page.url()}`);
|
||||||
|
updatePageOnPause(page);
|
||||||
|
this.socket.emit('log', '----- The interpretation has been paused -----', false);
|
||||||
|
} else {
|
||||||
|
resume();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const status = await interpreter.run(page, params);
|
const status = await interpreter.run(page, params);
|
||||||
|
|
||||||
const lastArray = this.serializableData.length > 1
|
const lastArray = this.serializableData.length > 1
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import Run from "../../models/Run";
|
|||||||
import { getDecryptedProxyConfig } from "../../routes/proxy";
|
import { getDecryptedProxyConfig } from "../../routes/proxy";
|
||||||
import { BinaryOutputService } from "../../storage/mino";
|
import { BinaryOutputService } from "../../storage/mino";
|
||||||
import { capture } from "../../utils/analytics";
|
import { capture } from "../../utils/analytics";
|
||||||
|
import { WorkflowFile } from "maxun-core";
|
||||||
|
import { Page } from "playwright";
|
||||||
chromium.use(stealthPlugin());
|
chromium.use(stealthPlugin());
|
||||||
|
|
||||||
async function createWorkflowAndStoreMetadata(id: string, userId: string) {
|
async function createWorkflowAndStoreMetadata(id: string, userId: string) {
|
||||||
@@ -71,7 +73,7 @@ async function createWorkflowAndStoreMetadata(id: string, userId: string) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { message } = e as Error;
|
const { message } = e as Error;
|
||||||
logger.log('info', `Error while scheduling a run with id: ${id}`);
|
logger.log('info', `Error while scheduling a run with id: ${id}`);
|
||||||
console.log(message);
|
console.log(`Error while scheduling a run with id: ${id}:`, message);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: message,
|
error: message,
|
||||||
@@ -79,6 +81,17 @@ async function createWorkflowAndStoreMetadata(id: string, userId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AddGeneratedFlags(workflow: WorkflowFile) {
|
||||||
|
const copy = JSON.parse(JSON.stringify(workflow));
|
||||||
|
for (let i = 0; i < workflow.workflow.length; i++) {
|
||||||
|
copy.workflow[i].what.unshift({
|
||||||
|
action: 'flag',
|
||||||
|
args: ['generated'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
};
|
||||||
|
|
||||||
async function executeRun(id: string) {
|
async function executeRun(id: string) {
|
||||||
try {
|
try {
|
||||||
const run = await Run.findOne({ where: { runId: id } });
|
const run = await Run.findOne({ where: { runId: id } });
|
||||||
@@ -106,13 +119,15 @@ async function executeRun(id: string) {
|
|||||||
throw new Error('Could not access browser');
|
throw new Error('Could not access browser');
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentPage = await browser.getCurrentPage();
|
let currentPage = await browser.getCurrentPage();
|
||||||
if (!currentPage) {
|
if (!currentPage) {
|
||||||
throw new Error('Could not create a new page');
|
throw new Error('Could not create a new page');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const workflow = AddGeneratedFlags(recording.recording);
|
||||||
const interpretationInfo = await browser.interpreter.InterpretRecording(
|
const interpretationInfo = await browser.interpreter.InterpretRecording(
|
||||||
recording.recording, currentPage, plainRun.interpreterSettings);
|
workflow, currentPage, (newPage: Page) => currentPage = newPage, plainRun.interpreterSettings
|
||||||
|
);
|
||||||
|
|
||||||
const binaryOutputService = new BinaryOutputService('maxun-run-screenshots');
|
const binaryOutputService = new BinaryOutputService('maxun-run-screenshots');
|
||||||
const uploadedBinaryOutput = await binaryOutputService.uploadAndStoreBinaryOutput(run, interpretationInfo.binaryOutput);
|
const uploadedBinaryOutput = await binaryOutputService.uploadAndStoreBinaryOutput(run, interpretationInfo.binaryOutput);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,16 @@ export const getBestSelectorForAction = (action: Action) => {
|
|||||||
case ActionType.Hover:
|
case ActionType.Hover:
|
||||||
case ActionType.DragAndDrop: {
|
case ActionType.DragAndDrop: {
|
||||||
const selectors = action.selectors;
|
const selectors = action.selectors;
|
||||||
|
|
||||||
|
|
||||||
|
if (selectors?.iframeSelector?.full) {
|
||||||
|
return selectors.iframeSelector.full;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectors?.shadowSelector?.full) {
|
||||||
|
return selectors.shadowSelector.full;
|
||||||
|
}
|
||||||
|
|
||||||
// less than 25 characters, and element only has text inside
|
// less than 25 characters, and element only has text inside
|
||||||
const textSelector =
|
const textSelector =
|
||||||
selectors?.text?.length != null &&
|
selectors?.text?.length != null &&
|
||||||
@@ -75,6 +85,11 @@ export const getBestSelectorForAction = (action: Action) => {
|
|||||||
case ActionType.Input:
|
case ActionType.Input:
|
||||||
case ActionType.Keydown: {
|
case ActionType.Keydown: {
|
||||||
const selectors = action.selectors;
|
const selectors = action.selectors;
|
||||||
|
|
||||||
|
if (selectors?.shadowSelector?.full) {
|
||||||
|
return selectors.shadowSelector.full;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
selectors.testIdSelector ??
|
selectors.testIdSelector ??
|
||||||
selectors?.id ??
|
selectors?.id ??
|
||||||
|
|||||||
94
src/App.tsx
94
src/App.tsx
@@ -1,12 +1,88 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { Routes, Route } from 'react-router-dom';
|
import { Routes, Route } from "react-router-dom";
|
||||||
import { createTheme } from "@mui/material/styles";
|
import { ThemeProvider, createTheme } from "@mui/material/styles";
|
||||||
import { GlobalInfoProvider } from "./context/globalInfo";
|
import { GlobalInfoProvider } from "./context/globalInfo";
|
||||||
import { PageWrapper } from "./pages/PageWrappper";
|
import { PageWrapper } from "./pages/PageWrappper";
|
||||||
|
import i18n from "./i18n";
|
||||||
import ThemeModeProvider from './context/theme-provider';
|
import ThemeModeProvider from './context/theme-provider';
|
||||||
|
|
||||||
|
|
||||||
|
const theme = createTheme({
|
||||||
|
palette: {
|
||||||
|
primary: {
|
||||||
|
main: "#ff00c3",
|
||||||
|
contrastText: "#ffffff",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MuiButton: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
// Default styles for all buttons (optional)
|
||||||
|
textTransform: "none",
|
||||||
|
},
|
||||||
|
containedPrimary: {
|
||||||
|
// Styles for 'contained' variant with 'primary' color
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "#ff66d9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outlined: {
|
||||||
|
// Apply white background for all 'outlined' variant buttons
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "#f0f0f0", // Optional lighter background on hover
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiLink: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
"&:hover": {
|
||||||
|
color: "#ff00c3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiIconButton: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
// '&:hover': {
|
||||||
|
// color: "#ff66d9",
|
||||||
|
// },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiTab: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
textTransform: "none",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiAlert: {
|
||||||
|
styleOverrides: {
|
||||||
|
standardInfo: {
|
||||||
|
backgroundColor: "#fce1f4",
|
||||||
|
color: "#ff00c3",
|
||||||
|
"& .MuiAlert-icon": {
|
||||||
|
color: "#ff00c3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiAlertTitle: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
"& .MuiAlert-icon": {
|
||||||
|
color: "#ffffff",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -17,6 +93,16 @@ function App() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</GlobalInfoProvider>
|
</GlobalInfoProvider>
|
||||||
</ThemeModeProvider>
|
</ThemeModeProvider>
|
||||||
|
|
||||||
|
<!-- <ThemeProvider theme={theme}>
|
||||||
|
|
||||||
|
<GlobalInfoProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/*" element={<PageWrapper />} />
|
||||||
|
</Routes>
|
||||||
|
</GlobalInfoProvider>
|
||||||
|
|
||||||
|
</ThemeProvider> -->
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,6 @@ import { ScheduleSettings } from "../components/molecules/ScheduleSettings";
|
|||||||
import { CreateRunResponse, ScheduleRunResponse } from "../pages/MainPage";
|
import { CreateRunResponse, ScheduleRunResponse } from "../pages/MainPage";
|
||||||
import { apiUrl } from "../apiConfig";
|
import { apiUrl } from "../apiConfig";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const getStoredRecordings = async (): Promise<string[] | null> => {
|
export const getStoredRecordings = async (): Promise<string[] | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${apiUrl}/storage/recordings`);
|
const response = await axios.get(`${apiUrl}/storage/recordings`);
|
||||||
@@ -82,11 +77,7 @@ export const getStoredRecording = async (id: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const checkRunsForRecording = async (id: string): Promise<boolean> => {
|
export const checkRunsForRecording = async (id: string): Promise<boolean> => {
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${apiUrl}/storage/recordings/${id}/runs`);
|
const response = await axios.get(`${apiUrl}/storage/recordings/${id}/runs`);
|
||||||
|
|
||||||
@@ -99,32 +90,26 @@ export const checkRunsForRecording = async (id: string): Promise<boolean> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const deleteRecordingFromStorage = async (id: string): Promise<boolean> => {
|
export const deleteRecordingFromStorage = async (id: string): Promise<boolean> => {
|
||||||
|
|
||||||
const hasRuns = await checkRunsForRecording(id);
|
const hasRuns = await checkRunsForRecording(id);
|
||||||
|
|
||||||
if (hasRuns) {
|
if (hasRuns) {
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.delete(`${apiUrl}/storage/recordings/${id}`);
|
const response = await axios.delete(`${apiUrl}/storage/recordings/${id}`);
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Couldn't delete stored recording ${id}`);
|
throw new Error(`Couldn't delete stored recording ${id}`);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteRunFromStorage = async (id: string): Promise<boolean> => {
|
export const deleteRunFromStorage = async (id: string): Promise<boolean> => {
|
||||||
@@ -159,7 +144,7 @@ export const createRunForStoredRecording = async (id: string, settings: RunSetti
|
|||||||
try {
|
try {
|
||||||
const response = await axios.put(
|
const response = await axios.put(
|
||||||
`${apiUrl}/storage/runs/${id}`,
|
`${apiUrl}/storage/runs/${id}`,
|
||||||
{ ...settings });
|
{ ...settings });
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
return response.data;
|
return response.data;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { emptyWorkflow } from "../shared/constants";
|
|||||||
import { default as axios, AxiosResponse } from "axios";
|
import { default as axios, AxiosResponse } from "axios";
|
||||||
import { apiUrl } from "../apiConfig";
|
import { apiUrl } from "../apiConfig";
|
||||||
|
|
||||||
export const getActiveWorkflow = async(id: string) : Promise<WorkflowFile> => {
|
export const getActiveWorkflow = async (id: string): Promise<WorkflowFile> => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${apiUrl}/workflow/${id}`)
|
const response = await axios.get(`${apiUrl}/workflow/${id}`)
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
@@ -11,13 +11,13 @@ export const getActiveWorkflow = async(id: string) : Promise<WorkflowFile> => {
|
|||||||
} else {
|
} else {
|
||||||
throw new Error('Something went wrong when fetching a recorded workflow');
|
throw new Error('Something went wrong when fetching a recorded workflow');
|
||||||
}
|
}
|
||||||
} catch(error: any) {
|
} catch (error: any) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
return emptyWorkflow;
|
return emptyWorkflow;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getParamsOfActiveWorkflow = async(id: string) : Promise<string[]|null> => {
|
export const getParamsOfActiveWorkflow = async (id: string): Promise<string[] | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${apiUrl}/workflow/params/${id}`)
|
const response = await axios.get(`${apiUrl}/workflow/params/${id}`)
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
@@ -25,15 +25,15 @@ export const getParamsOfActiveWorkflow = async(id: string) : Promise<string[]|nu
|
|||||||
} else {
|
} else {
|
||||||
throw new Error('Something went wrong when fetching the parameters of the recorded workflow');
|
throw new Error('Something went wrong when fetching the parameters of the recorded workflow');
|
||||||
}
|
}
|
||||||
} catch(error: any) {
|
} catch (error: any) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deletePair = async(index: number): Promise<WorkflowFile> => {
|
export const deletePair = async (index: number): Promise<WorkflowFile> => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.delete(`${apiUrl}/workflow/pair/${index}`);
|
const response = await axios.delete(`${apiUrl}/workflow/pair/${index}`);
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
return response.data;
|
return response.data;
|
||||||
} else {
|
} else {
|
||||||
@@ -45,11 +45,11 @@ export const deletePair = async(index: number): Promise<WorkflowFile> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddPair = async(index: number, pair: WhereWhatPair): Promise<WorkflowFile> => {
|
export const AddPair = async (index: number, pair: WhereWhatPair): Promise<WorkflowFile> => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(`${apiUrl}/workflow/pair/${index}`, {
|
const response = await axios.post(`${apiUrl}/workflow/pair/${index}`, {
|
||||||
pair,
|
pair,
|
||||||
}, {headers: {'Content-Type': 'application/json'}});
|
}, { headers: { 'Content-Type': 'application/json' } });
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
return response.data;
|
return response.data;
|
||||||
} else {
|
} else {
|
||||||
@@ -61,11 +61,11 @@ export const AddPair = async(index: number, pair: WhereWhatPair): Promise<Workfl
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UpdatePair = async(index: number, pair: WhereWhatPair): Promise<WorkflowFile> => {
|
export const UpdatePair = async (index: number, pair: WhereWhatPair): Promise<WorkflowFile> => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.put(`${apiUrl}/workflow/pair/${index}`, {
|
const response = await axios.put(`${apiUrl}/workflow/pair/${index}`, {
|
||||||
pair,
|
pair,
|
||||||
}, {headers: {'Content-Type': 'application/json'}});
|
}, { headers: { 'Content-Type': 'application/json' } });
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
return response.data;
|
return response.data;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
74
src/components/atoms/DatePicker.tsx
Normal file
74
src/components/atoms/DatePicker.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useSocketStore } from '../../context/socket';
|
||||||
|
import { Coordinates } from './canvas';
|
||||||
|
|
||||||
|
interface DatePickerProps {
|
||||||
|
coordinates: Coordinates;
|
||||||
|
selector: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DatePicker: React.FC<DatePickerProps> = ({ coordinates, selector, onClose }) => {
|
||||||
|
const { socket } = useSocketStore();
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string>('');
|
||||||
|
|
||||||
|
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSelectedDate(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (socket && selectedDate) {
|
||||||
|
socket.emit('input:date', {
|
||||||
|
selector,
|
||||||
|
value: selectedDate
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${coordinates.x}px`,
|
||||||
|
top: `${coordinates.y}px`,
|
||||||
|
zIndex: 1000,
|
||||||
|
backgroundColor: 'white',
|
||||||
|
boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
|
||||||
|
padding: '10px',
|
||||||
|
borderRadius: '4px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
onChange={handleDateChange}
|
||||||
|
value={selectedDate}
|
||||||
|
className="p-2 border rounded"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-800 border rounded"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!selectedDate}
|
||||||
|
className={`px-3 py-1 text-sm rounded ${
|
||||||
|
selectedDate
|
||||||
|
? 'bg-blue-500 text-white hover:bg-blue-600'
|
||||||
|
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DatePicker;
|
||||||
74
src/components/atoms/DateTimeLocalPicker.tsx
Normal file
74
src/components/atoms/DateTimeLocalPicker.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useSocketStore } from '../../context/socket';
|
||||||
|
import { Coordinates } from './canvas';
|
||||||
|
|
||||||
|
interface DateTimeLocalPickerProps {
|
||||||
|
coordinates: Coordinates;
|
||||||
|
selector: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DateTimeLocalPicker: React.FC<DateTimeLocalPickerProps> = ({ coordinates, selector, onClose }) => {
|
||||||
|
const { socket } = useSocketStore();
|
||||||
|
const [selectedDateTime, setSelectedDateTime] = useState<string>('');
|
||||||
|
|
||||||
|
const handleDateTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSelectedDateTime(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (socket && selectedDateTime) {
|
||||||
|
socket.emit('input:datetime-local', {
|
||||||
|
selector,
|
||||||
|
value: selectedDateTime
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${coordinates.x}px`,
|
||||||
|
top: `${coordinates.y}px`,
|
||||||
|
zIndex: 1000,
|
||||||
|
backgroundColor: 'white',
|
||||||
|
boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
|
||||||
|
padding: '10px',
|
||||||
|
borderRadius: '4px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
onChange={handleDateTimeChange}
|
||||||
|
value={selectedDateTime}
|
||||||
|
className="p-2 border rounded"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-800 border rounded"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!selectedDateTime}
|
||||||
|
className={`px-3 py-1 text-sm rounded ${
|
||||||
|
selectedDateTime
|
||||||
|
? 'bg-blue-500 text-white hover:bg-blue-600'
|
||||||
|
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DateTimeLocalPicker;
|
||||||
85
src/components/atoms/Dropdown.tsx
Normal file
85
src/components/atoms/Dropdown.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useSocketStore } from '../../context/socket';
|
||||||
|
import { Coordinates } from './canvas';
|
||||||
|
|
||||||
|
interface DropdownProps {
|
||||||
|
coordinates: Coordinates;
|
||||||
|
selector: string;
|
||||||
|
options: Array<{
|
||||||
|
value: string;
|
||||||
|
text: string;
|
||||||
|
disabled: boolean;
|
||||||
|
selected: boolean;
|
||||||
|
}>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Dropdown = ({ coordinates, selector, options, onClose }: DropdownProps) => {
|
||||||
|
const { socket } = useSocketStore();
|
||||||
|
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const handleSelect = (value: string) => {
|
||||||
|
if (socket) {
|
||||||
|
socket.emit('input:dropdown', { selector, value });
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const containerStyle: React.CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
left: coordinates.x,
|
||||||
|
top: coordinates.y,
|
||||||
|
zIndex: 1000,
|
||||||
|
width: '200px',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
border: '1px solid rgb(169, 169, 169)',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.15)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollContainerStyle: React.CSSProperties = {
|
||||||
|
maxHeight: '180px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOptionStyle = (option: any, index: number): React.CSSProperties => ({
|
||||||
|
fontSize: '13.333px',
|
||||||
|
lineHeight: '18px',
|
||||||
|
padding: '0 3px',
|
||||||
|
cursor: option.disabled ? 'default' : 'default',
|
||||||
|
backgroundColor: hoveredIndex === index ? '#0078D7' :
|
||||||
|
option.selected ? '#0078D7' :
|
||||||
|
option.disabled ? '#f8f8f8' : 'white',
|
||||||
|
color: (hoveredIndex === index || option.selected) ? 'white' :
|
||||||
|
option.disabled ? '#a0a0a0' : 'black',
|
||||||
|
userSelect: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={containerStyle}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div style={scrollContainerStyle}>
|
||||||
|
{options.map((option, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={getOptionStyle(option, index)}
|
||||||
|
onMouseEnter={() => !option.disabled && setHoveredIndex(index)}
|
||||||
|
onMouseLeave={() => setHoveredIndex(null)}
|
||||||
|
onClick={() => !option.disabled && handleSelect(option.value)}
|
||||||
|
>
|
||||||
|
{option.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dropdown;
|
||||||
130
src/components/atoms/TimePicker.tsx
Normal file
130
src/components/atoms/TimePicker.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useSocketStore } from '../../context/socket';
|
||||||
|
import { Coordinates } from './canvas';
|
||||||
|
|
||||||
|
interface TimePickerProps {
|
||||||
|
coordinates: Coordinates;
|
||||||
|
selector: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TimePicker = ({ coordinates, selector, onClose }: TimePickerProps) => {
|
||||||
|
const { socket } = useSocketStore();
|
||||||
|
const [hoveredHour, setHoveredHour] = useState<number | null>(null);
|
||||||
|
const [hoveredMinute, setHoveredMinute] = useState<number | null>(null);
|
||||||
|
const [selectedHour, setSelectedHour] = useState<number | null>(null);
|
||||||
|
const [selectedMinute, setSelectedMinute] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const handleHourSelect = (hour: number) => {
|
||||||
|
setSelectedHour(hour);
|
||||||
|
// If minute is already selected, complete the selection
|
||||||
|
if (selectedMinute !== null) {
|
||||||
|
const formattedHour = hour.toString().padStart(2, '0');
|
||||||
|
const formattedMinute = selectedMinute.toString().padStart(2, '0');
|
||||||
|
if (socket) {
|
||||||
|
socket.emit('input:time', {
|
||||||
|
selector,
|
||||||
|
value: `${formattedHour}:${formattedMinute}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMinuteSelect = (minute: number) => {
|
||||||
|
setSelectedMinute(minute);
|
||||||
|
// If hour is already selected, complete the selection
|
||||||
|
if (selectedHour !== null) {
|
||||||
|
const formattedHour = selectedHour.toString().padStart(2, '0');
|
||||||
|
const formattedMinute = minute.toString().padStart(2, '0');
|
||||||
|
if (socket) {
|
||||||
|
socket.emit('input:time', {
|
||||||
|
selector,
|
||||||
|
value: `${formattedHour}:${formattedMinute}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const containerStyle: React.CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
left: coordinates.x,
|
||||||
|
top: coordinates.y,
|
||||||
|
zIndex: 1000,
|
||||||
|
display: 'flex',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
border: '1px solid rgb(169, 169, 169)',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.15)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const columnStyle: React.CSSProperties = {
|
||||||
|
width: '60px',
|
||||||
|
maxHeight: '180px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
borderRight: '1px solid rgb(169, 169, 169)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOptionStyle = (value: number, isHour: boolean): React.CSSProperties => {
|
||||||
|
const isHovered = isHour ? hoveredHour === value : hoveredMinute === value;
|
||||||
|
const isSelected = isHour ? selectedHour === value : selectedMinute === value;
|
||||||
|
|
||||||
|
return {
|
||||||
|
fontSize: '13.333px',
|
||||||
|
lineHeight: '18px',
|
||||||
|
padding: '0 3px',
|
||||||
|
cursor: 'default',
|
||||||
|
backgroundColor: isSelected ? '#0078D7' : isHovered ? '#0078D7' : 'white',
|
||||||
|
color: (isSelected || isHovered) ? 'white' : 'black',
|
||||||
|
userSelect: 'none',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const hours = Array.from({ length: 24 }, (_, i) => i);
|
||||||
|
const minutes = Array.from({ length: 60 }, (_, i) => i);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={containerStyle}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Hours column */}
|
||||||
|
<div style={columnStyle}>
|
||||||
|
{hours.map((hour) => (
|
||||||
|
<div
|
||||||
|
key={hour}
|
||||||
|
style={getOptionStyle(hour, true)}
|
||||||
|
onMouseEnter={() => setHoveredHour(hour)}
|
||||||
|
onMouseLeave={() => setHoveredHour(null)}
|
||||||
|
onClick={() => handleHourSelect(hour)}
|
||||||
|
>
|
||||||
|
{hour.toString().padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Minutes column */}
|
||||||
|
<div style={{...columnStyle, borderRight: 'none'}}>
|
||||||
|
{minutes.map((minute) => (
|
||||||
|
<div
|
||||||
|
key={minute}
|
||||||
|
style={getOptionStyle(minute, false)}
|
||||||
|
onMouseEnter={() => setHoveredMinute(minute)}
|
||||||
|
onMouseLeave={() => setHoveredMinute(null)}
|
||||||
|
onClick={() => handleMinuteSelect(minute)}
|
||||||
|
>
|
||||||
|
{minute.toString().padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TimePicker;
|
||||||
@@ -1,17 +1,147 @@
|
|||||||
import React, { useCallback, useEffect, useRef } from 'react';
|
import React, { useCallback, useEffect, useRef, useMemo, Suspense } from 'react';
|
||||||
import { useSocketStore } from '../../context/socket';
|
import { useSocketStore } from '../../context/socket';
|
||||||
import { getMappedCoordinates } from "../../helpers/inputHelpers";
|
|
||||||
import { useGlobalInfoStore } from "../../context/globalInfo";
|
import { useGlobalInfoStore } from "../../context/globalInfo";
|
||||||
import { useActionContext } from '../../context/browserActions';
|
import { useActionContext } from '../../context/browserActions';
|
||||||
|
const DatePicker = React.lazy(() => import('./DatePicker'));
|
||||||
|
const Dropdown = React.lazy(() => import('./Dropdown'));
|
||||||
|
const TimePicker = React.lazy(() => import('./TimePicker'));
|
||||||
|
const DateTimeLocalPicker = React.lazy(() => import('./DateTimeLocalPicker'));
|
||||||
|
|
||||||
interface CreateRefCallback {
|
class RAFScheduler {
|
||||||
(ref: React.RefObject<HTMLCanvasElement>): void;
|
private queue: Set<() => void> = new Set();
|
||||||
|
private isProcessing: boolean = false;
|
||||||
|
private frameId: number | null = null;
|
||||||
|
|
||||||
|
schedule(callback: () => void): void {
|
||||||
|
this.queue.add(callback);
|
||||||
|
if (!this.isProcessing) {
|
||||||
|
this.process();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private process = (): void => {
|
||||||
|
this.isProcessing = true;
|
||||||
|
this.frameId = requestAnimationFrame(() => {
|
||||||
|
const callbacks = Array.from(this.queue);
|
||||||
|
this.queue.clear();
|
||||||
|
|
||||||
|
callbacks.forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('RAF Scheduler error:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.isProcessing = false;
|
||||||
|
this.frameId = null;
|
||||||
|
|
||||||
|
if (this.queue.size > 0) {
|
||||||
|
this.process();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.queue.clear();
|
||||||
|
if (this.frameId !== null) {
|
||||||
|
cancelAnimationFrame(this.frameId);
|
||||||
|
this.frameId = null;
|
||||||
|
}
|
||||||
|
this.isProcessing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EventDebouncer {
|
||||||
|
private highPriorityQueue: Array<() => void> = [];
|
||||||
|
private lowPriorityQueue: Array<() => void> = [];
|
||||||
|
private processing: boolean = false;
|
||||||
|
private scheduler: RAFScheduler;
|
||||||
|
|
||||||
|
constructor(scheduler: RAFScheduler) {
|
||||||
|
this.scheduler = scheduler;
|
||||||
|
}
|
||||||
|
|
||||||
|
add(callback: () => void, highPriority: boolean = false): void {
|
||||||
|
if (highPriority) {
|
||||||
|
this.highPriorityQueue.push(callback);
|
||||||
|
} else {
|
||||||
|
this.lowPriorityQueue.push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.processing) {
|
||||||
|
this.process();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private process(): void {
|
||||||
|
this.processing = true;
|
||||||
|
this.scheduler.schedule(() => {
|
||||||
|
while (this.highPriorityQueue.length > 0) {
|
||||||
|
const callback = this.highPriorityQueue.shift();
|
||||||
|
callback?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.lowPriorityQueue.length > 0) {
|
||||||
|
const callback = this.lowPriorityQueue.shift();
|
||||||
|
callback?.();
|
||||||
|
|
||||||
|
if (this.lowPriorityQueue.length > 0) {
|
||||||
|
this.process();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processing = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.highPriorityQueue = [];
|
||||||
|
this.lowPriorityQueue = [];
|
||||||
|
this.processing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimized measurement cache with LRU
|
||||||
|
class MeasurementCache {
|
||||||
|
private cache: Map<HTMLElement, DOMRect>;
|
||||||
|
private maxSize: number;
|
||||||
|
|
||||||
|
constructor(maxSize: number = 100) {
|
||||||
|
this.cache = new Map();
|
||||||
|
this.maxSize = maxSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(element: HTMLElement): DOMRect | undefined {
|
||||||
|
const cached = this.cache.get(element);
|
||||||
|
if (cached) {
|
||||||
|
// Refresh the entry
|
||||||
|
this.cache.delete(element);
|
||||||
|
this.cache.set(element, cached);
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(element: HTMLElement, rect: DOMRect): void {
|
||||||
|
if (this.cache.size >= this.maxSize) {
|
||||||
|
// Remove oldest entry
|
||||||
|
const firstKey = this.cache.keys().next().value;
|
||||||
|
if (firstKey !== undefined) {
|
||||||
|
this.cache.delete(firstKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.cache.set(element, rect);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CanvasProps {
|
interface CanvasProps {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
onCreateRef: CreateRefCallback;
|
onCreateRef: (ref: React.RefObject<HTMLCanvasElement>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,134 +152,229 @@ export interface Coordinates {
|
|||||||
y: number;
|
y: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Canvas = ({ width, height, onCreateRef }: CanvasProps) => {
|
const Canvas = React.memo(({ width, height, onCreateRef }: CanvasProps) => {
|
||||||
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const { socket } = useSocketStore();
|
const { socket } = useSocketStore();
|
||||||
const { setLastAction, lastAction } = useGlobalInfoStore();
|
const { setLastAction, lastAction } = useGlobalInfoStore();
|
||||||
const { getText, getList } = useActionContext();
|
const { getText, getList } = useActionContext();
|
||||||
const getTextRef = useRef(getText);
|
|
||||||
const getListRef = useRef(getList);
|
|
||||||
|
|
||||||
const notifyLastAction = (action: string) => {
|
const scheduler = useRef(new RAFScheduler());
|
||||||
if (lastAction !== action) {
|
const debouncer = useRef(new EventDebouncer(scheduler.current));
|
||||||
setLastAction(action);
|
const measurementCache = useRef(new MeasurementCache(50));
|
||||||
|
//const performanceMonitor = useRef(new FrontendPerformanceMonitor());
|
||||||
|
|
||||||
|
const refs = useRef({
|
||||||
|
getText,
|
||||||
|
getList,
|
||||||
|
lastMousePosition: { x: 0, y: 0 },
|
||||||
|
lastFrameTime: 0,
|
||||||
|
context: null as CanvasRenderingContext2D | null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [state, dispatch] = React.useReducer((state: any, action: any) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'BATCH_UPDATE':
|
||||||
|
return { ...state, ...action.payload };
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
}
|
}
|
||||||
};
|
}, {
|
||||||
|
datePickerInfo: null,
|
||||||
|
dropdownInfo: null,
|
||||||
|
timePickerInfo: null,
|
||||||
|
dateTimeLocalInfo: null
|
||||||
|
});
|
||||||
|
|
||||||
const lastMousePosition = useRef<Coordinates>({ x: 0, y: 0 });
|
const getEventCoordinates = useCallback((event: MouseEvent): { x: number; y: number } => {
|
||||||
|
if (!canvasRef.current) return { x: 0, y: 0 };
|
||||||
|
|
||||||
useEffect(() => {
|
let rect = measurementCache.current.get(canvasRef.current);
|
||||||
getTextRef.current = getText;
|
if (!rect) {
|
||||||
getListRef.current = getList;
|
rect = canvasRef.current.getBoundingClientRect();
|
||||||
}, [getText, getList]);
|
measurementCache.current.set(canvasRef.current, rect);
|
||||||
|
}
|
||||||
|
|
||||||
const onMouseEvent = useCallback((event: MouseEvent) => {
|
return {
|
||||||
if (socket && canvasRef.current) {
|
x: event.clientX - rect.left,
|
||||||
// Get the canvas bounding rectangle
|
y: event.clientY - rect.top
|
||||||
const rect = canvasRef.current.getBoundingClientRect();
|
};
|
||||||
const clickCoordinates = {
|
}, []);
|
||||||
x: event.clientX - rect.left, // Use relative x coordinate
|
|
||||||
y: event.clientY - rect.top, // Use relative y coordinate
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (event.type) {
|
const handleMouseEvent = useCallback((event: MouseEvent) => {
|
||||||
case 'mousedown':
|
if (!socket || !canvasRef.current) return;
|
||||||
if (getTextRef.current === true) {
|
|
||||||
|
//performanceMonitor.current.measureEventLatency(event);
|
||||||
|
const coordinates = getEventCoordinates(event);
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case 'mousedown':
|
||||||
|
debouncer.current.add(() => {
|
||||||
|
if (refs.current.getText) {
|
||||||
console.log('Capturing Text...');
|
console.log('Capturing Text...');
|
||||||
} else if (getListRef.current === true) {
|
} else if (refs.current.getList) {
|
||||||
console.log('Capturing List...');
|
console.log('Capturing List...');
|
||||||
} else {
|
} else {
|
||||||
socket.emit('input:mousedown', clickCoordinates);
|
socket.emit('input:mousedown', coordinates);
|
||||||
}
|
}
|
||||||
notifyLastAction('click');
|
setLastAction('click');
|
||||||
break;
|
}, true); // High priority
|
||||||
case 'mousemove':
|
break;
|
||||||
if (lastMousePosition.current.x !== clickCoordinates.x ||
|
|
||||||
lastMousePosition.current.y !== clickCoordinates.y) {
|
|
||||||
lastMousePosition.current = {
|
|
||||||
x: clickCoordinates.x,
|
|
||||||
y: clickCoordinates.y,
|
|
||||||
};
|
|
||||||
socket.emit('input:mousemove', {
|
|
||||||
x: clickCoordinates.x,
|
|
||||||
y: clickCoordinates.y,
|
|
||||||
});
|
|
||||||
notifyLastAction('move');
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'wheel':
|
|
||||||
const wheelEvent = event as WheelEvent;
|
|
||||||
const deltas = {
|
|
||||||
deltaX: Math.round(wheelEvent.deltaX),
|
|
||||||
deltaY: Math.round(wheelEvent.deltaY),
|
|
||||||
};
|
|
||||||
socket.emit('input:wheel', deltas);
|
|
||||||
notifyLastAction('scroll');
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
console.log('Default mouseEvent registered');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [socket]);
|
|
||||||
|
|
||||||
const onKeyboardEvent = useCallback((event: KeyboardEvent) => {
|
case 'mousemove':
|
||||||
if (socket) {
|
if (refs.current.lastMousePosition.x !== coordinates.x ||
|
||||||
|
refs.current.lastMousePosition.y !== coordinates.y) {
|
||||||
|
debouncer.current.add(() => {
|
||||||
|
refs.current.lastMousePosition = coordinates;
|
||||||
|
socket.emit('input:mousemove', coordinates);
|
||||||
|
setLastAction('move');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'wheel':
|
||||||
|
const wheelEvent = event as WheelEvent;
|
||||||
|
debouncer.current.add(() => {
|
||||||
|
socket.emit('input:wheel', {
|
||||||
|
deltaX: Math.round(wheelEvent.deltaX),
|
||||||
|
deltaY: Math.round(wheelEvent.deltaY)
|
||||||
|
});
|
||||||
|
setLastAction('scroll');
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [socket, getEventCoordinates]);
|
||||||
|
|
||||||
|
const handleKeyboardEvent = useCallback((event: KeyboardEvent) => {
|
||||||
|
if (!socket) return;
|
||||||
|
|
||||||
|
debouncer.current.add(() => {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'keydown':
|
case 'keydown':
|
||||||
socket.emit('input:keydown', { key: event.key, coordinates: lastMousePosition.current });
|
socket.emit('input:keydown', {
|
||||||
notifyLastAction(`${event.key} pressed`);
|
key: event.key,
|
||||||
|
coordinates: refs.current.lastMousePosition
|
||||||
|
});
|
||||||
|
setLastAction(`${event.key} pressed`);
|
||||||
break;
|
break;
|
||||||
case 'keyup':
|
case 'keyup':
|
||||||
socket.emit('input:keyup', event.key);
|
socket.emit('input:keyup', event.key);
|
||||||
break;
|
break;
|
||||||
default:
|
|
||||||
console.log('Default keyEvent registered');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}, event.type === 'keydown'); // High priority for keydown
|
||||||
}, [socket]);
|
}, [socket]);
|
||||||
|
|
||||||
|
// Setup and cleanup
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current) return;
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
refs.current.context = canvas.getContext('2d', {
|
||||||
|
alpha: false,
|
||||||
|
desynchronized: true
|
||||||
|
});
|
||||||
|
|
||||||
|
onCreateRef(canvasRef);
|
||||||
|
|
||||||
|
const options = { passive: true };
|
||||||
|
canvas.addEventListener('mousedown', handleMouseEvent, options);
|
||||||
|
canvas.addEventListener('mousemove', handleMouseEvent, options);
|
||||||
|
canvas.addEventListener('wheel', handleMouseEvent, options);
|
||||||
|
canvas.addEventListener('keydown', handleKeyboardEvent, options);
|
||||||
|
canvas.addEventListener('keyup', handleKeyboardEvent, options);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
canvas.removeEventListener('mousedown', handleMouseEvent);
|
||||||
|
canvas.removeEventListener('mousemove', handleMouseEvent);
|
||||||
|
canvas.removeEventListener('wheel', handleMouseEvent);
|
||||||
|
canvas.removeEventListener('keydown', handleKeyboardEvent);
|
||||||
|
canvas.removeEventListener('keyup', handleKeyboardEvent);
|
||||||
|
|
||||||
|
scheduler.current.clear();
|
||||||
|
debouncer.current.clear();
|
||||||
|
measurementCache.current.clear();
|
||||||
|
};
|
||||||
|
}, [handleMouseEvent, handleKeyboardEvent, onCreateRef]);
|
||||||
|
|
||||||
|
// Performance monitoring
|
||||||
|
// useEffect(() => {
|
||||||
|
// const intervalId = setInterval(() => {
|
||||||
|
// console.log('Performance Report:', performanceMonitor.current.getPerformanceReport());
|
||||||
|
// }, 20000);
|
||||||
|
|
||||||
|
// return () => clearInterval(intervalId);
|
||||||
|
// }, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (canvasRef.current) {
|
if (!socket) return;
|
||||||
onCreateRef(canvasRef);
|
|
||||||
canvasRef.current.addEventListener('mousedown', onMouseEvent);
|
|
||||||
canvasRef.current.addEventListener('mousemove', onMouseEvent);
|
|
||||||
canvasRef.current.addEventListener('wheel', onMouseEvent, { passive: true });
|
|
||||||
canvasRef.current.addEventListener('keydown', onKeyboardEvent);
|
|
||||||
canvasRef.current.addEventListener('keyup', onKeyboardEvent);
|
|
||||||
|
|
||||||
return () => {
|
const handlers = {
|
||||||
if (canvasRef.current) {
|
showDatePicker: (info: any) => dispatch({ type: 'BATCH_UPDATE', payload: { datePickerInfo: info } }),
|
||||||
canvasRef.current.removeEventListener('mousedown', onMouseEvent);
|
showDropdown: (info: any) => dispatch({ type: 'BATCH_UPDATE', payload: { dropdownInfo: info } }),
|
||||||
canvasRef.current.removeEventListener('mousemove', onMouseEvent);
|
showTimePicker: (info: any) => dispatch({ type: 'BATCH_UPDATE', payload: { timePickerInfo: info } }),
|
||||||
canvasRef.current.removeEventListener('wheel', onMouseEvent);
|
showDateTimePicker: (info: any) => dispatch({ type: 'BATCH_UPDATE', payload: { dateTimeLocalInfo: info } })
|
||||||
canvasRef.current.removeEventListener('keydown', onKeyboardEvent);
|
};
|
||||||
canvasRef.current.removeEventListener('keyup', onKeyboardEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
};
|
Object.entries(handlers).forEach(([event, handler]) => socket.on(event, handler));
|
||||||
} else {
|
return () => {
|
||||||
console.log('Canvas not initialized');
|
Object.keys(handlers).forEach(event => socket.off(event));
|
||||||
}
|
};
|
||||||
|
}, [socket]);
|
||||||
|
|
||||||
}, [onMouseEvent]);
|
const memoizedDimensions = useMemo(() => ({
|
||||||
|
width: width || 900,
|
||||||
|
height: height || 400
|
||||||
|
}), [width, height]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ borderRadius: '0px 0px 5px 5px', overflow: 'hidden', backgroundColor: 'white' }}>
|
<div className="relative bg-white rounded-b-md overflow-hidden">
|
||||||
<canvas
|
<canvas
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
height={1000}
|
height={memoizedDimensions.height}
|
||||||
width={900}
|
width={memoizedDimensions.width}
|
||||||
style={{ display: 'block' }}
|
className="block"
|
||||||
/>
|
/>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
{state.datePickerInfo && (
|
||||||
|
<DatePicker
|
||||||
|
coordinates={state.datePickerInfo.coordinates}
|
||||||
|
selector={state.datePickerInfo.selector}
|
||||||
|
onClose={() => dispatch({
|
||||||
|
type: 'BATCH_UPDATE',
|
||||||
|
payload: { datePickerInfo: null }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{state.dropdownInfo && (
|
||||||
|
<Dropdown
|
||||||
|
coordinates={state.dropdownInfo.coordinates}
|
||||||
|
selector={state.dropdownInfo.selector}
|
||||||
|
options={state.dropdownInfo.options}
|
||||||
|
onClose={() => dispatch({
|
||||||
|
type: 'BATCH_UPDATE',
|
||||||
|
payload: { dropdownInfo: null }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{state.timePickerInfo && (
|
||||||
|
<TimePicker
|
||||||
|
coordinates={state.timePickerInfo.coordinates}
|
||||||
|
selector={state.timePickerInfo.selector}
|
||||||
|
onClose={() => dispatch({ type: 'SET_TIME_PICKER', payload: null })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{state.dateTimeLocalInfo && (
|
||||||
|
<DateTimeLocalPicker
|
||||||
|
coordinates={state.dateTimeLocalInfo.coordinates}
|
||||||
|
selector={state.dateTimeLocalInfo.selector}
|
||||||
|
onClose={() => dispatch({ type: 'SET_DATETIME_PICKER', payload: null })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
};
|
Canvas.displayName = 'Canvas';
|
||||||
|
|
||||||
|
|
||||||
export default Canvas;
|
export default Canvas;
|
||||||
@@ -3,6 +3,7 @@ import styled from 'styled-components';
|
|||||||
import { Typography, FormControlLabel, Checkbox, Box } from '@mui/material';
|
import { Typography, FormControlLabel, Checkbox, Box } from '@mui/material';
|
||||||
import { useActionContext } from '../../context/browserActions';
|
import { useActionContext } from '../../context/browserActions';
|
||||||
import MaxunLogo from "../../assets/maxunlogo.png";
|
import MaxunLogo from "../../assets/maxunlogo.png";
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface CustomBoxContainerProps {
|
interface CustomBoxContainerProps {
|
||||||
isDarkMode: boolean;
|
isDarkMode: boolean;
|
||||||
@@ -48,7 +49,9 @@ const Content = styled.div`
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
||||||
const ActionDescriptionBox = ({ isDarkMode }: { isDarkMode: boolean }) => {
|
const ActionDescriptionBox = ({ isDarkMode }: { isDarkMode: boolean }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { getText, getScreenshot, getList, captureStage } = useActionContext() as {
|
const { getText, getScreenshot, getList, captureStage } = useActionContext() as {
|
||||||
getText: boolean;
|
getText: boolean;
|
||||||
getScreenshot: boolean;
|
getScreenshot: boolean;
|
||||||
@@ -57,10 +60,10 @@ const ActionDescriptionBox = ({ isDarkMode }: { isDarkMode: boolean }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const messages = [
|
const messages = [
|
||||||
{ stage: 'initial', text: 'Select the list you want to extract along with the texts inside it' },
|
{ stage: 'initial' as const, text: t('action_description.list_stages.initial') },
|
||||||
{ stage: 'pagination', text: 'Select how the robot can capture the rest of the list' },
|
{ stage: 'pagination' as const, text: t('action_description.list_stages.pagination') },
|
||||||
{ stage: 'limit', text: 'Choose the number of items to extract' },
|
{ stage: 'limit' as const, text: t('action_description.list_stages.limit') },
|
||||||
{ stage: 'complete', text: 'Capture is complete' },
|
{ stage: 'complete' as const, text: t('action_description.list_stages.complete') },
|
||||||
];
|
];
|
||||||
|
|
||||||
const stages = messages.map(({ stage }) => stage);
|
const stages = messages.map(({ stage }) => stage);
|
||||||
@@ -70,23 +73,23 @@ const ActionDescriptionBox = ({ isDarkMode }: { isDarkMode: boolean }) => {
|
|||||||
if (getText) {
|
if (getText) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Typography variant="subtitle2" gutterBottom>Capture Text</Typography>
|
<Typography variant="subtitle2" gutterBottom>{t('action_description.text.title')}</Typography>
|
||||||
<Typography variant="body2" gutterBottom>Hover over the texts you want to extract and click to select them</Typography>
|
<Typography variant="body2" gutterBottom>{t('action_description.text.description')}</Typography>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
} else if (getScreenshot) {
|
} else if (getScreenshot) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Typography variant="subtitle2" gutterBottom>Capture Screenshot</Typography>
|
<Typography variant="subtitle2" gutterBottom>{t('action_description.screenshot.title')}</Typography>
|
||||||
<Typography variant="body2" gutterBottom>Capture a partial or full page screenshot of the current page.</Typography>
|
<Typography variant="body2" gutterBottom>{t('action_description.screenshot.description')}</Typography>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
} else if (getList) {
|
} else if (getList) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Typography variant="subtitle2" gutterBottom>Capture List</Typography>
|
<Typography variant="subtitle2" gutterBottom>{t('action_description.list.title')}</Typography>
|
||||||
<Typography variant="body2" gutterBottom>
|
<Typography variant="body2" gutterBottom>
|
||||||
Hover over the list you want to extract. Once selected, you can hover over all texts inside the list you selected. Click to select them.
|
{t('action_description.list.description')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box>
|
<Box>
|
||||||
{messages.map(({ stage, text }, index) => (
|
{messages.map(({ stage, text }, index) => (
|
||||||
@@ -117,8 +120,8 @@ const ActionDescriptionBox = ({ isDarkMode }: { isDarkMode: boolean }) => {
|
|||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Typography variant="subtitle2" gutterBottom>What data do you want to extract?</Typography>
|
<Typography variant="subtitle2" gutterBottom>{t('action_description.default.title')}</Typography>
|
||||||
<Typography variant="body2" gutterBottom>A robot is designed to perform one action at a time. You can choose any of the options below.</Typography>
|
<Typography variant="body2" gutterBottom>{t('action_description.default.description')}</Typography>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -135,4 +138,4 @@ const ActionDescriptionBox = ({ isDarkMode }: { isDarkMode: boolean }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ActionDescriptionBox;
|
export default ActionDescriptionBox;
|
||||||
@@ -5,8 +5,10 @@ import { useGlobalInfoStore } from '../../context/globalInfo';
|
|||||||
import { stopRecording } from "../../api/recording";
|
import { stopRecording } from "../../api/recording";
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { GenericModal } from "../atoms/GenericModal";
|
import { GenericModal } from "../atoms/GenericModal";
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const BrowserRecordingSave = () => {
|
const BrowserRecordingSave = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [openModal, setOpenModal] = useState<boolean>(false);
|
const [openModal, setOpenModal] = useState<boolean>(false);
|
||||||
const { recordingName, browserId, setBrowserId, notify } = useGlobalInfoStore();
|
const { recordingName, browserId, setBrowserId, notify } = useGlobalInfoStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -14,7 +16,7 @@ const BrowserRecordingSave = () => {
|
|||||||
const goToMainMenu = async () => {
|
const goToMainMenu = async () => {
|
||||||
if (browserId) {
|
if (browserId) {
|
||||||
await stopRecording(browserId);
|
await stopRecording(browserId);
|
||||||
notify('warning', 'Current Recording was terminated');
|
notify('warning', t('browser_recording.notifications.terminated'));
|
||||||
setBrowserId(null);
|
setBrowserId(null);
|
||||||
}
|
}
|
||||||
navigate('/');
|
navigate('/');
|
||||||
@@ -24,32 +26,31 @@ const BrowserRecordingSave = () => {
|
|||||||
<Grid container>
|
<Grid container>
|
||||||
<Grid item xs={12} md={3} lg={3}>
|
<Grid item xs={12} md={3} lg={3}>
|
||||||
<div style={{
|
<div style={{
|
||||||
|
marginTop: '12px',
|
||||||
// marginLeft: '10px',
|
|
||||||
color: 'white',
|
color: 'white',
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
background: '#ff00c3',
|
background: '#ff00c3',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '0px 0px 8px 8px',
|
borderRadius: '0px 0px 8px 8px',
|
||||||
padding: '7.5px',
|
padding: '7.5px',
|
||||||
width: '100%', // Ensure it takes full width but with padding
|
width: 'calc(100% - 20px)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
height:"48px"
|
height:"48px"
|
||||||
}}>
|
}}>
|
||||||
<Button onClick={() => setOpenModal(true)} variant="outlined" style={{ marginLeft: "25px" }} size="small" color="error">
|
<Button onClick={() => setOpenModal(true)} variant="outlined" style={{ marginLeft: "25px" }} size="small" color="error">
|
||||||
Discard
|
{t('right_panel.buttons.discard')}
|
||||||
</Button>
|
</Button>
|
||||||
<GenericModal isOpen={openModal} onClose={() => setOpenModal(false)} modalStyle={modalStyle}>
|
<GenericModal isOpen={openModal} onClose={() => setOpenModal(false)} modalStyle={modalStyle}>
|
||||||
<Box p={2}>
|
<Box p={2}>
|
||||||
<Typography variant="h6">Are you sure you want to discard the recording?</Typography>
|
<Typography variant="h6">{t('browser_recording.modal.confirm_discard')}</Typography>
|
||||||
<Box display="flex" justifyContent="space-between" mt={2}>
|
<Box display="flex" justifyContent="space-between" mt={2}>
|
||||||
<Button onClick={goToMainMenu} variant="contained" color="error">
|
<Button onClick={goToMainMenu} variant="contained" color="error">
|
||||||
Discard
|
{t('right_panel.buttons.discard')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setOpenModal(false)} variant="outlined">
|
<Button onClick={() => setOpenModal(false)} variant="outlined">
|
||||||
Cancel
|
{t('right_panel.buttons.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -61,7 +62,7 @@ const BrowserRecordingSave = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default BrowserRecordingSave
|
export default BrowserRecordingSave;
|
||||||
|
|
||||||
const modalStyle = {
|
const modalStyle = {
|
||||||
top: '25%',
|
top: '25%',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { RunContent } from "./RunContent";
|
|||||||
import { GenericModal } from "../atoms/GenericModal";
|
import { GenericModal } from "../atoms/GenericModal";
|
||||||
import { modalStyle } from "./AddWhereCondModal";
|
import { modalStyle } from "./AddWhereCondModal";
|
||||||
import { getUserById } from "../../api/auth";
|
import { getUserById } from "../../api/auth";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface RunTypeChipProps {
|
interface RunTypeChipProps {
|
||||||
runByUserId?: string;
|
runByUserId?: string;
|
||||||
@@ -18,10 +19,12 @@ interface RunTypeChipProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const RunTypeChip: React.FC<RunTypeChipProps> = ({ runByUserId, runByScheduledId, runByAPI }) => {
|
const RunTypeChip: React.FC<RunTypeChipProps> = ({ runByUserId, runByScheduledId, runByAPI }) => {
|
||||||
if (runByUserId) return <Chip label="Manual Run" color="primary" variant="outlined" />;
|
const { t } = useTranslation();
|
||||||
if (runByScheduledId) return <Chip label="Scheduled Run" color="primary" variant="outlined" />;
|
|
||||||
if (runByAPI) return <Chip label="API" color="primary" variant="outlined" />;
|
if (runByUserId) return <Chip label={t('runs_table.run_type_chips.manual_run')} color="primary" variant="outlined" />;
|
||||||
return <Chip label="Unknown Run Type" color="primary" variant="outlined" />;
|
if (runByScheduledId) return <Chip label={t('runs_table.run_type_chips.scheduled_run')} color="primary" variant="outlined" />;
|
||||||
|
if (runByAPI) return <Chip label={t('runs_table.run_type_chips.api')} color="primary" variant="outlined" />;
|
||||||
|
return <Chip label={t('runs_table.run_type_chips.unknown_run_type')} color="primary" variant="outlined" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface CollapsibleRowProps {
|
interface CollapsibleRowProps {
|
||||||
@@ -33,6 +36,7 @@ interface CollapsibleRowProps {
|
|||||||
runningRecordingName: string;
|
runningRecordingName: string;
|
||||||
}
|
}
|
||||||
export const CollapsibleRow = ({ row, handleDelete, isOpen, currentLog, abortRunHandler, runningRecordingName }: CollapsibleRowProps) => {
|
export const CollapsibleRow = ({ row, handleDelete, isOpen, currentLog, abortRunHandler, runningRecordingName }: CollapsibleRowProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(isOpen);
|
const [open, setOpen] = useState(isOpen);
|
||||||
const [openSettingsModal, setOpenSettingsModal] = useState(false);
|
const [openSettingsModal, setOpenSettingsModal] = useState(false);
|
||||||
const [userEmail, setUserEmail] = useState<string | null>(null);
|
const [userEmail, setUserEmail] = useState<string | null>(null);
|
||||||
@@ -99,12 +103,12 @@ export const CollapsibleRow = ({ row, handleDelete, isOpen, currentLog, abortRun
|
|||||||
} else {
|
} else {
|
||||||
switch (column.id) {
|
switch (column.id) {
|
||||||
case 'runStatus':
|
case 'runStatus':
|
||||||
return (
|
return (
|
||||||
<TableCell key={column.id} align={column.align}>
|
<TableCell key={column.id} align={column.align}>
|
||||||
{row.status === 'success' && <Chip label="Success" color="success" variant="outlined" />}
|
{row.status === 'success' && <Chip label={t('runs_table.run_status_chips.success')} color="success" variant="outlined" />}
|
||||||
{row.status === 'running' && <Chip label="Running" color="warning" variant="outlined" />}
|
{row.status === 'running' && <Chip label={t('runs_table.run_status_chips.running')} color="warning" variant="outlined" />}
|
||||||
{row.status === 'scheduled' && <Chip label="Scheduled" variant="outlined" />}
|
{row.status === 'scheduled' && <Chip label={t('runs_table.run_status_chips.scheduled')} variant="outlined" />}
|
||||||
{row.status === 'failed' && <Chip label="Failed" color="error" variant="outlined" />}
|
{row.status === 'failed' && <Chip label={t('runs_table.run_status_chips.failed')} color="error" variant="outlined" />}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)
|
)
|
||||||
case 'delete':
|
case 'delete':
|
||||||
@@ -133,21 +137,35 @@ export const CollapsibleRow = ({ row, handleDelete, isOpen, currentLog, abortRun
|
|||||||
modalStyle={modalStyle}
|
modalStyle={modalStyle}
|
||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
<Typography variant="h5" style={{ marginBottom: '20px' }}>Run Settings</Typography>
|
<Typography variant="h5" style={{ marginBottom: '20px' }}>
|
||||||
|
{t('runs_table.run_settings_modal.title')}
|
||||||
|
</Typography>
|
||||||
<Box style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
<Box style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||||
<TextField
|
<TextField
|
||||||
label="Run ID"
|
label={t('runs_table.run_settings_modal.labels.run_id')}
|
||||||
value={row.runId}
|
value={row.runId}
|
||||||
InputProps={{ readOnly: true }}
|
InputProps={{ readOnly: true }}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label={row.runByUserId ? "Run by User" : row.runByScheduleId ? "Run by Schedule ID" : "Run by API"}
|
label={
|
||||||
|
row.runByUserId
|
||||||
|
? t('runs_table.run_settings_modal.labels.run_by_user')
|
||||||
|
: row.runByScheduleId
|
||||||
|
? t('runs_table.run_settings_modal.labels.run_by_schedule')
|
||||||
|
: t('runs_table.run_settings_modal.labels.run_by_api')
|
||||||
|
}
|
||||||
value={runByLabel}
|
value={runByLabel}
|
||||||
InputProps={{ readOnly: true }}
|
InputProps={{ readOnly: true }}
|
||||||
/>
|
/>
|
||||||
<Box style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
<Box style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
<Typography variant="body1">Run Type:</Typography>
|
<Typography variant="body1">
|
||||||
<RunTypeChip runByUserId={row.runByUserId} runByScheduledId={row.runByScheduleId} runByAPI={row.runByAPI ?? false} />
|
{t('runs_table.run_settings_modal.labels.run_type')}:
|
||||||
|
</Typography>
|
||||||
|
<RunTypeChip
|
||||||
|
runByUserId={row.runByUserId}
|
||||||
|
runByScheduledId={row.runByScheduleId}
|
||||||
|
runByAPI={row.runByAPI ?? false}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import axios from "axios";
|
|||||||
import { useGlobalInfoStore } from "../../context/globalInfo";
|
import { useGlobalInfoStore } from "../../context/globalInfo";
|
||||||
import { getStoredRecording } from "../../api/storage";
|
import { getStoredRecording } from "../../api/storage";
|
||||||
import { apiUrl } from "../../apiConfig.js";
|
import { apiUrl } from "../../apiConfig.js";
|
||||||
|
import Cookies from 'js-cookie';
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
|
||||||
interface IntegrationProps {
|
interface IntegrationProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -46,6 +49,7 @@ export const IntegrationSettingsModal = ({
|
|||||||
handleStart,
|
handleStart,
|
||||||
handleClose,
|
handleClose,
|
||||||
}: IntegrationProps) => {
|
}: IntegrationProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [settings, setSettings] = useState<IntegrationSettings>({
|
const [settings, setSettings] = useState<IntegrationSettings>({
|
||||||
spreadsheetId: "",
|
spreadsheetId: "",
|
||||||
spreadsheetName: "",
|
spreadsheetName: "",
|
||||||
@@ -90,9 +94,9 @@ export const IntegrationSettingsModal = ({
|
|||||||
);
|
);
|
||||||
notify(
|
notify(
|
||||||
"error",
|
"error",
|
||||||
`Error fetching spreadsheet files: ${
|
t('integration_settings.errors.fetch_error', {
|
||||||
error.response?.data?.message || error.message
|
message: error.response?.data?.message || error.message
|
||||||
}`
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -121,7 +125,7 @@ export const IntegrationSettingsModal = ({
|
|||||||
},
|
},
|
||||||
{ withCredentials: true }
|
{ withCredentials: true }
|
||||||
);
|
);
|
||||||
notify(`success`, `Google Sheet selected successfully`);
|
notify(`success`, t('integration_settings.notifications.sheet_selected'));
|
||||||
console.log("Google Sheet ID updated:", response.data);
|
console.log("Google Sheet ID updated:", response.data);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -182,37 +186,28 @@ export const IntegrationSettingsModal = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GenericModal isOpen={isOpen} onClose={handleClose} modalStyle={modalStyle}>
|
<GenericModal isOpen={isOpen} onClose={handleClose} modalStyle={modalStyle}>
|
||||||
<div
|
<div style={{
|
||||||
style={{
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
marginLeft: "65px",
|
marginLeft: "65px",
|
||||||
}}
|
}}>
|
||||||
>
|
|
||||||
<Typography variant="h6">
|
<Typography variant="h6">
|
||||||
Integrate with Google Sheet{" "}
|
{t('integration_settings.title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{recording && recording.google_sheet_id ? (
|
{recording && recording.google_sheet_id ? (
|
||||||
<>
|
<>
|
||||||
<Alert severity="info" sx={{ marginTop: '10px', border: '1px solid #ff00c3' }}>
|
<Alert severity="info" sx={{ marginTop: '10px', border: '1px solid #ff00c3' }}>
|
||||||
<AlertTitle>Google Sheet Integrated Successfully.</AlertTitle>
|
<AlertTitle>{t('integration_settings.alerts.success.title')}</AlertTitle>
|
||||||
Every time this robot creates a successful run, its captured data
|
{t('integration_settings.alerts.success.content', { sheetName: recording.google_sheet_name })}
|
||||||
is appended to your {recording.google_sheet_name} Google Sheet.
|
<a href={`https://docs.google.com/spreadsheets/d/${recording.google_sheet_id}`}
|
||||||
You can check the data updates{" "}
|
|
||||||
<a
|
|
||||||
href={`https://docs.google.com/spreadsheets/d/${recording.google_sheet_id}`}
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer">
|
||||||
>
|
{t('integration_settings.alerts.success.here')}
|
||||||
here
|
</a>.
|
||||||
</a>
|
|
||||||
.
|
|
||||||
<br />
|
<br />
|
||||||
<strong>Note:</strong> The data extracted before integrating with
|
<strong>{t('integration_settings.alerts.success.note')}</strong> {t('integration_settings.alerts.success.sync_limitation')}
|
||||||
Google Sheets will not be synced in the Google Sheet. Only the
|
|
||||||
data extracted after the integration will be synced.
|
|
||||||
</Alert>
|
</Alert>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -220,31 +215,29 @@ export const IntegrationSettingsModal = ({
|
|||||||
onClick={removeIntegration}
|
onClick={removeIntegration}
|
||||||
style={{ marginTop: "15px" }}
|
style={{ marginTop: "15px" }}
|
||||||
>
|
>
|
||||||
Remove Integration
|
{t('integration_settings.buttons.remove_integration')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{!recording?.google_sheet_email ? (
|
{!recording?.google_sheet_email ? (
|
||||||
<>
|
<>
|
||||||
<p>
|
<p>{t('integration_settings.descriptions.sync_info')}</p>
|
||||||
If you enable this option, every time this robot runs a task
|
|
||||||
successfully, its captured data will be appended to your
|
|
||||||
Google Sheet.
|
|
||||||
</p>
|
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={authenticateWithGoogle}
|
onClick={authenticateWithGoogle}
|
||||||
>
|
>
|
||||||
Authenticate with Google
|
{t('integration_settings.buttons.authenticate')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{recording.google_sheet_email && (
|
{recording.google_sheet_email && (
|
||||||
<Typography sx={{ margin: "20px 0px 30px 0px" }}>
|
<Typography sx={{ margin: "20px 0px 30px 0px" }}>
|
||||||
Authenticated as: {recording.google_sheet_email}
|
{t('integration_settings.descriptions.authenticated_as', {
|
||||||
|
email: recording.google_sheet_email
|
||||||
|
})}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -260,14 +253,14 @@ export const IntegrationSettingsModal = ({
|
|||||||
color="primary"
|
color="primary"
|
||||||
onClick={fetchSpreadsheetFiles}
|
onClick={fetchSpreadsheetFiles}
|
||||||
>
|
>
|
||||||
Fetch Google Spreadsheets
|
{t('integration_settings.buttons.fetch_sheets')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="error"
|
color="error"
|
||||||
onClick={removeIntegration}
|
onClick={removeIntegration}
|
||||||
>
|
>
|
||||||
Remove Integration
|
{t('integration_settings.buttons.remove_integration')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -276,7 +269,7 @@ export const IntegrationSettingsModal = ({
|
|||||||
<TextField
|
<TextField
|
||||||
sx={{ marginBottom: "15px" }}
|
sx={{ marginBottom: "15px" }}
|
||||||
select
|
select
|
||||||
label="Select Google Sheet"
|
label={t('integration_settings.fields.select_sheet')}
|
||||||
required
|
required
|
||||||
value={settings.spreadsheetId}
|
value={settings.spreadsheetId}
|
||||||
onChange={handleSpreadsheetSelect}
|
onChange={handleSpreadsheetSelect}
|
||||||
@@ -291,13 +284,10 @@ export const IntegrationSettingsModal = ({
|
|||||||
|
|
||||||
{settings.spreadsheetId && (
|
{settings.spreadsheetId && (
|
||||||
<Typography sx={{ marginBottom: "10px" }}>
|
<Typography sx={{ marginBottom: "10px" }}>
|
||||||
Selected Sheet:{" "}
|
{t('integration_settings.fields.selected_sheet', {
|
||||||
{
|
name: spreadsheets.find((s) => s.id === settings.spreadsheetId)?.name,
|
||||||
spreadsheets.find(
|
id: settings.spreadsheetId
|
||||||
(s) => s.id === settings.spreadsheetId
|
})}
|
||||||
)?.name
|
|
||||||
}{" "}
|
|
||||||
(ID: {settings.spreadsheetId})
|
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -311,7 +301,7 @@ export const IntegrationSettingsModal = ({
|
|||||||
style={{ marginTop: "10px" }}
|
style={{ marginTop: "10px" }}
|
||||||
disabled={!settings.spreadsheetId || loading}
|
disabled={!settings.spreadsheetId || loading}
|
||||||
>
|
>
|
||||||
Submit
|
{t('integration_settings.buttons.submit')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useGlobalInfoStore } from "../../context/globalInfo";
|
|||||||
import { GenericModal } from "../atoms/GenericModal";
|
import { GenericModal } from "../atoms/GenericModal";
|
||||||
import { WhereWhatPair } from "maxun-core";
|
import { WhereWhatPair } from "maxun-core";
|
||||||
import HelpIcon from '@mui/icons-material/Help';
|
import HelpIcon from '@mui/icons-material/Help';
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface InterpretationButtonsProps {
|
interface InterpretationButtonsProps {
|
||||||
enableStepping: (isPaused: boolean) => void;
|
enableStepping: (isPaused: boolean) => void;
|
||||||
@@ -23,6 +24,7 @@ const interpretationInfo: InterpretationInfo = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const InterpretationButtons = ({ enableStepping }: InterpretationButtonsProps) => {
|
export const InterpretationButtons = ({ enableStepping }: InterpretationButtonsProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [info, setInfo] = useState<InterpretationInfo>(interpretationInfo);
|
const [info, setInfo] = useState<InterpretationInfo>(interpretationInfo);
|
||||||
const [decisionModal, setDecisionModal] = useState<{
|
const [decisionModal, setDecisionModal] = useState<{
|
||||||
pair: WhereWhatPair | null,
|
pair: WhereWhatPair | null,
|
||||||
@@ -44,9 +46,9 @@ export const InterpretationButtons = ({ enableStepping }: InterpretationButtonsP
|
|||||||
|
|
||||||
const breakpointHitHandler = useCallback(() => {
|
const breakpointHitHandler = useCallback(() => {
|
||||||
setInfo({ running: false, isPaused: true });
|
setInfo({ running: false, isPaused: true });
|
||||||
notify('warning', 'Please restart the interpretation after updating the recording');
|
notify('warning', t('interpretation_buttons.messages.restart_required'));
|
||||||
enableStepping(true);
|
enableStepping(true);
|
||||||
}, [enableStepping]);
|
}, [enableStepping, t]);
|
||||||
|
|
||||||
const decisionHandler = useCallback(
|
const decisionHandler = useCallback(
|
||||||
({ pair, actionType, lastData }: { pair: WhereWhatPair | null, actionType: string, lastData: { selector: string, action: string, tagName: string, innerText: string } }) => {
|
({ pair, actionType, lastData }: { pair: WhereWhatPair | null, actionType: string, lastData: { selector: string, action: string, tagName: string, innerText: string } }) => {
|
||||||
@@ -73,11 +75,12 @@ export const InterpretationButtons = ({ enableStepping }: InterpretationButtonsP
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Typography>
|
<Typography>
|
||||||
Do you want to use your previous selection as a condition for performing this action?
|
{t('interpretation_buttons.modal.use_previous')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box style={{ marginTop: '4px' }}>
|
<Box style={{ marginTop: '4px' }}>
|
||||||
<Typography>
|
<Typography>
|
||||||
Your previous action was: <b>{decisionModal.action}</b>, on an element with text <b>{decisionModal.innerText}</b>
|
{t('interpretation_buttons.modal.previous_action')} <b>{decisionModal.action}</b>,
|
||||||
|
{t('interpretation_buttons.modal.element_text')} <b>{decisionModal.innerText}</b>
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
@@ -105,9 +108,9 @@ export const InterpretationButtons = ({ enableStepping }: InterpretationButtonsP
|
|||||||
const finished = await interpretCurrentRecording();
|
const finished = await interpretCurrentRecording();
|
||||||
setInfo({ ...info, running: false });
|
setInfo({ ...info, running: false });
|
||||||
if (finished) {
|
if (finished) {
|
||||||
notify('info', 'Run finished');
|
notify('info', t('interpretation_buttons.messages.run_finished'));
|
||||||
} else {
|
} else {
|
||||||
notify('error', 'Run failed to start');
|
notify('error', t('interpretation_buttons.messages.run_failed'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -139,9 +142,12 @@ export const InterpretationButtons = ({ enableStepping }: InterpretationButtonsP
|
|||||||
disabled={info.running}
|
disabled={info.running}
|
||||||
sx={{ display: 'grid' }}
|
sx={{ display: 'grid' }}
|
||||||
>
|
>
|
||||||
{info.running ? <Box sx={{ display: 'flex', alignItems: 'center' }}>
|
{info.running ? (
|
||||||
<CircularProgress size={22} color="inherit" sx={{ marginRight: '10px' }} /> Extracting data...please wait for 10secs to 1min
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
</Box> : 'Get Preview of Output Data'}
|
<CircularProgress size={22} color="inherit" sx={{ marginRight: '10px' }} />
|
||||||
|
{t('interpretation_buttons.messages.extracting')}
|
||||||
|
</Box>
|
||||||
|
) : t('interpretation_buttons.buttons.preview')}
|
||||||
</Button>
|
</Button>
|
||||||
<GenericModal
|
<GenericModal
|
||||||
onClose={() => { }}
|
onClose={() => { }}
|
||||||
@@ -166,8 +172,12 @@ export const InterpretationButtons = ({ enableStepping }: InterpretationButtonsP
|
|||||||
<HelpIcon />
|
<HelpIcon />
|
||||||
{handleDescription()}
|
{handleDescription()}
|
||||||
<div style={{ float: 'right' }}>
|
<div style={{ float: 'right' }}>
|
||||||
<Button onClick={() => handleDecision(true)} color='success'>Yes</Button>
|
<Button onClick={() => handleDecision(true)} color='success'>
|
||||||
<Button onClick={() => handleDecision(false)} color='error'>No</Button>
|
{t('interpretation_buttons.buttons.yes')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => handleDecision(false)} color='error'>
|
||||||
|
{t('interpretation_buttons.buttons.no')}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</GenericModal>
|
</GenericModal>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
|||||||
import { SidePanelHeader } from './SidePanelHeader';
|
import { SidePanelHeader } from './SidePanelHeader';
|
||||||
import { useGlobalInfoStore } from '../../context/globalInfo';
|
import { useGlobalInfoStore } from '../../context/globalInfo';
|
||||||
import { useThemeMode } from '../../context/theme-provider';
|
import { useThemeMode } from '../../context/theme-provider';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface InterpretationLogProps {
|
interface InterpretationLogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -25,6 +26,7 @@ interface InterpretationLogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, setIsOpen }) => {
|
export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, setIsOpen }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [log, setLog] = useState<string>('');
|
const [log, setLog] = useState<string>('');
|
||||||
const [customValue, setCustomValue] = useState('');
|
const [customValue, setCustomValue] = useState('');
|
||||||
const [tableData, setTableData] = useState<any[]>([]);
|
const [tableData, setTableData] = useState<any[]>([]);
|
||||||
@@ -34,7 +36,7 @@ export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, se
|
|||||||
|
|
||||||
const { width } = useBrowserDimensionsStore();
|
const { width } = useBrowserDimensionsStore();
|
||||||
const { socket } = useSocketStore();
|
const { socket } = useSocketStore();
|
||||||
const { currentWorkflowActionsState } = useGlobalInfoStore();
|
const { currentWorkflowActionsState, shouldResetInterpretationLog, notify } = useGlobalInfoStore();
|
||||||
|
|
||||||
const toggleDrawer = (newOpen: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => {
|
const toggleDrawer = (newOpen: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => {
|
||||||
if (
|
if (
|
||||||
@@ -64,34 +66,43 @@ export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, se
|
|||||||
|
|
||||||
const handleSerializableCallback = useCallback((data: any) => {
|
const handleSerializableCallback = useCallback((data: any) => {
|
||||||
setLog((prevState) =>
|
setLog((prevState) =>
|
||||||
prevState + '\n' + '---------- Serializable output data received ----------' + '\n'
|
prevState + '\n' + t('interpretation_log.data_sections.serializable_received') + '\n'
|
||||||
+ JSON.stringify(data, null, 2) + '\n' + '--------------------------------------------------');
|
+ JSON.stringify(data, null, 2) + '\n' + t('interpretation_log.data_sections.separator'));
|
||||||
|
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
setTableData(data);
|
setTableData(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollLogToBottom();
|
scrollLogToBottom();
|
||||||
}, [log, scrollLogToBottom]);
|
}, [log, scrollLogToBottom, t]);
|
||||||
|
|
||||||
const handleBinaryCallback = useCallback(({ data, mimetype }: any) => {
|
const handleBinaryCallback = useCallback(({ data, mimetype }: any) => {
|
||||||
const base64String = Buffer.from(data).toString('base64');
|
const base64String = Buffer.from(data).toString('base64');
|
||||||
const imageSrc = `data:${mimetype};base64,${base64String}`;
|
const imageSrc = `data:${mimetype};base64,${base64String}`;
|
||||||
|
|
||||||
setLog((prevState) =>
|
setLog((prevState) =>
|
||||||
prevState + '\n' + '---------- Binary output data received ----------' + '\n'
|
prevState + '\n' + t('interpretation_log.data_sections.binary_received') + '\n'
|
||||||
+ `mimetype: ${mimetype}` + '\n' + 'Image is rendered below:' + '\n'
|
+ t('interpretation_log.data_sections.mimetype') + mimetype + '\n'
|
||||||
+ '------------------------------------------------');
|
+ t('interpretation_log.data_sections.image_below') + '\n'
|
||||||
|
+ t('interpretation_log.data_sections.separator'));
|
||||||
|
|
||||||
setBinaryData(imageSrc);
|
setBinaryData(imageSrc);
|
||||||
scrollLogToBottom();
|
scrollLogToBottom();
|
||||||
}, [log, scrollLogToBottom]);
|
}, [log, scrollLogToBottom, t]);
|
||||||
|
|
||||||
|
|
||||||
const handleCustomValueChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleCustomValueChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setCustomValue(event.target.value);
|
setCustomValue(event.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldResetInterpretationLog) {
|
||||||
|
setLog('');
|
||||||
|
setTableData([]);
|
||||||
|
setBinaryData(null);
|
||||||
|
}
|
||||||
|
}, [shouldResetInterpretationLog]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
socket?.on('log', handleLog);
|
socket?.on('log', handleLog);
|
||||||
socket?.on('serializableCallback', handleSerializableCallback);
|
socket?.on('serializableCallback', handleSerializableCallback);
|
||||||
@@ -141,7 +152,8 @@ export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, se
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ArrowUpwardIcon fontSize="inherit" sx={{ marginRight: '10px'}} /> Output Data Preview
|
<ArrowUpwardIcon fontSize="inherit" sx={{ marginRight: '10px'}} />
|
||||||
|
{t('interpretation_log.titles.output_preview')}
|
||||||
</Button>
|
</Button>
|
||||||
<SwipeableDrawer
|
<SwipeableDrawer
|
||||||
anchor="bottom"
|
anchor="bottom"
|
||||||
@@ -160,9 +172,10 @@ export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, se
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="h6" gutterBottom style={{ display: 'flex', alignItems: 'center' }}>
|
<Typography variant="h6" gutterBottom style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
<StorageIcon style={{ marginRight: '8px' }} /> Output Data Preview
|
<StorageIcon style={{ marginRight: '8px' }} />
|
||||||
</Typography>
|
{t('interpretation_log.titles.output_preview')}
|
||||||
|
</Typography>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: '50vh',
|
height: '50vh',
|
||||||
@@ -173,8 +186,10 @@ export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, se
|
|||||||
{
|
{
|
||||||
binaryData ? (
|
binaryData ? (
|
||||||
<div style={{ marginBottom: '20px' }}>
|
<div style={{ marginBottom: '20px' }}>
|
||||||
<Typography variant="body1" gutterBottom>Screenshot</Typography>
|
<Typography variant="body1" gutterBottom>
|
||||||
<img src={binaryData} alt="Binary Output" style={{ maxWidth: '100%' }} />
|
{t('interpretation_log.titles.screenshot')}
|
||||||
|
</Typography>
|
||||||
|
<img src={binaryData} alt={t('interpretation_log.titles.screenshot')} style={{ maxWidth: '100%' }} />
|
||||||
</div>
|
</div>
|
||||||
) : tableData.length > 0 ? (
|
) : tableData.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
@@ -198,7 +213,9 @@ export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, se
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
<span style={{ marginLeft: '15px', marginTop: '10px', fontSize: '12px' }}>Additional rows of data will be extracted once you finish recording. </span>
|
<span style={{ marginLeft: '15px', marginTop: '10px', fontSize: '12px' }}>
|
||||||
|
{t('interpretation_log.messages.additional_rows')}
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Grid container justifyContent="center" alignItems="center" style={{ height: '100%' }}>
|
<Grid container justifyContent="center" alignItems="center" style={{ height: '100%' }}>
|
||||||
@@ -206,13 +223,13 @@ export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, se
|
|||||||
{hasScrapeListAction || hasScrapeSchemaAction || hasScreenshotAction ? (
|
{hasScrapeListAction || hasScrapeSchemaAction || hasScreenshotAction ? (
|
||||||
<>
|
<>
|
||||||
<Typography variant="h6" gutterBottom align="left">
|
<Typography variant="h6" gutterBottom align="left">
|
||||||
You've successfully trained the robot to perform actions! Click on the button below to get a preview of the data your robot will extract.
|
{t('interpretation_log.messages.successful_training')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<SidePanelHeader />
|
<SidePanelHeader />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Typography variant="h6" gutterBottom align="left">
|
<Typography variant="h6" gutterBottom align="left">
|
||||||
It looks like you have not selected anything for extraction yet. Once you do, the robot will show a preview of your selections here.
|
{t('interpretation_log.messages.no_selection')}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -224,4 +241,4 @@ export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, se
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useContext } from 'react';
|
import { useTranslation } from "react-i18next";
|
||||||
|
import React, { useState, useContext, useEffect } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
@@ -20,173 +21,592 @@ import styled from "styled-components";
|
|||||||
|
|
||||||
import { stopRecording } from "../../api/recording";
|
import { stopRecording } from "../../api/recording";
|
||||||
import { useGlobalInfoStore } from "../../context/globalInfo";
|
import { useGlobalInfoStore } from "../../context/globalInfo";
|
||||||
|
import { IconButton, Menu, MenuItem, Typography, Chip, Button, Modal, Tabs, Tab, Box, Snackbar } from "@mui/material";
|
||||||
|
import { AccountCircle, Logout, Clear, YouTube, X, Update, Close, Language } from "@mui/icons-material";
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { AuthContext } from '../../context/auth';
|
import { AuthContext } from '../../context/auth';
|
||||||
import { SaveRecording } from '../molecules/SaveRecording';
|
import { SaveRecording } from '../molecules/SaveRecording';
|
||||||
import DiscordIcon from '../atoms/DiscordIcon';
|
import DiscordIcon from '../atoms/DiscordIcon';
|
||||||
import { apiUrl } from '../../apiConfig';
|
import { apiUrl } from '../../apiConfig';
|
||||||
import MaxunLogo from "../../assets/maxunlogo.png";
|
import MaxunLogo from "../../assets/maxunlogo.png";
|
||||||
import { useThemeMode } from '../../context/theme-provider';
|
import { useThemeMode } from '../../context/theme-provider';
|
||||||
|
import packageJson from "../../../package.json"
|
||||||
|
|
||||||
interface NavBarProps {
|
interface NavBarProps {
|
||||||
recordingName: string;
|
recordingName: string;
|
||||||
isRecording: boolean;
|
isRecording: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NavBar: React.FC<NavBarProps> = ({ recordingName, isRecording }) => {
|
export const NavBar: React.FC<NavBarProps> = ({
|
||||||
|
recordingName,
|
||||||
|
isRecording,
|
||||||
|
}) => {
|
||||||
const { notify, browserId, setBrowserId } = useGlobalInfoStore();
|
const { notify, browserId, setBrowserId } = useGlobalInfoStore();
|
||||||
const { state, dispatch } = useContext(AuthContext);
|
const { state, dispatch } = useContext(AuthContext);
|
||||||
const { user } = state;
|
const { user } = state;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { darkMode, toggleTheme } = useThemeMode();
|
const { darkMode, toggleTheme } = useThemeMode();
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
|
|
||||||
|
const [langAnchorEl, setLangAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
|
|
||||||
|
const currentVersion = packageJson.version;
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [latestVersion, setLatestVersion] = useState<string | null>(null);
|
||||||
|
const [tab, setTab] = useState(0);
|
||||||
|
const [isUpdateAvailable, setIsUpdateAvailable] = useState(false);
|
||||||
|
|
||||||
|
const fetchLatestVersion = async (): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("https://api.github.com/repos/getmaxun/maxun/releases/latest");
|
||||||
|
const data = await response.json();
|
||||||
|
const version = data.tag_name.replace(/^v/, ""); // Remove 'v' prefix
|
||||||
|
return version;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch latest version:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateOpen = () => {
|
||||||
|
setOpen(true);
|
||||||
|
fetchLatestVersion();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateClose = () => {
|
||||||
|
setOpen(false);
|
||||||
|
setTab(0); // Reset tab to the first tab
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateTabChange = (event: React.SyntheticEvent, newValue: number) => {
|
||||||
|
setTab(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
setAnchorEl(event.currentTarget);
|
setAnchorEl(event.currentTarget);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLangMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
|
setLangAnchorEl(event.currentTarget);
|
||||||
|
};
|
||||||
|
|
||||||
const handleMenuClose = () => {
|
const handleMenuClose = () => {
|
||||||
setAnchorEl(null);
|
setAnchorEl(null);
|
||||||
|
setLangAnchorEl(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
dispatch({ type: 'LOGOUT' });
|
dispatch({ type: "LOGOUT" });
|
||||||
window.localStorage.removeItem('user');
|
window.localStorage.removeItem("user");
|
||||||
const { data } = await axios.get(`${apiUrl}/auth/logout`);
|
const { data } = await axios.get(`${apiUrl}/auth/logout`);
|
||||||
notify('success', data.message);
|
notify("success", data.message);
|
||||||
navigate('/login');
|
navigate("/login");
|
||||||
};
|
};
|
||||||
|
|
||||||
const goToMainMenu = async () => {
|
const goToMainMenu = async () => {
|
||||||
if (browserId) {
|
if (browserId) {
|
||||||
await stopRecording(browserId);
|
await stopRecording(browserId);
|
||||||
notify('warning', 'Current Recording was terminated');
|
notify("warning", t('browser_recording.notifications.terminated'));
|
||||||
setBrowserId(null);
|
setBrowserId(null);
|
||||||
}
|
}
|
||||||
navigate('/');
|
navigate("/");
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderBrandSection = () => (
|
const changeLanguage = (lang: string) => {
|
||||||
<BrandContainer>
|
i18n.changeLanguage(lang);
|
||||||
<LogoImage src={MaxunLogo} alt="Maxun Logo" />
|
localStorage.setItem("language", lang);
|
||||||
<ProjectName mode={darkMode ? 'dark' : 'light'}>Maxun</ProjectName>
|
};
|
||||||
<Chip
|
|
||||||
label="beta"
|
|
||||||
variant="outlined"
|
|
||||||
sx={{
|
|
||||||
marginTop: '10px',
|
|
||||||
borderColor: '#ff00c3',
|
|
||||||
color: '#ff00c3'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</BrandContainer>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderSocialButtons = () => (
|
// const renderBrandSection = () => (
|
||||||
<>
|
// <BrandContainer>
|
||||||
<IconButton
|
// <LogoImage src={MaxunLogo} alt="Maxun Logo" />
|
||||||
component="a"
|
// <ProjectName mode={darkMode ? 'dark' : 'light'}>Maxun</ProjectName>
|
||||||
href="https://discord.gg/5GbPjBUkws"
|
// <Chip
|
||||||
target="_blank"
|
// label="beta"
|
||||||
rel="noopener noreferrer"
|
// variant="outlined"
|
||||||
sx={{
|
// sx={{
|
||||||
...styles.socialButton,
|
// marginTop: '10px',
|
||||||
color: darkMode ? '#ffffff' : '#333333',
|
// borderColor: '#ff00c3',
|
||||||
'&:hover': {
|
// color: '#ff00c3'
|
||||||
color: '#ff00c3'
|
// }}
|
||||||
}
|
// />
|
||||||
}}
|
// </BrandContainer>
|
||||||
>
|
// );
|
||||||
<DiscordIcon sx={{ marginRight: '5px' }} />
|
|
||||||
</IconButton>
|
|
||||||
<iframe
|
|
||||||
src="https://ghbtns.com/github-btn.html?user=getmaxun&repo=maxun&type=star&count=true&size=large"
|
|
||||||
frameBorder="0"
|
|
||||||
scrolling="0"
|
|
||||||
width="170"
|
|
||||||
height="30"
|
|
||||||
title="GitHub"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderUserMenu = () => (
|
// const renderSocialButtons = () => (
|
||||||
<>
|
// <>
|
||||||
<IconButton
|
// <IconButton
|
||||||
onClick={handleMenuOpen}
|
// component="a"
|
||||||
sx={styles.userButton(darkMode)}
|
// href="https://discord.gg/5GbPjBUkws"
|
||||||
>
|
// target="_blank"
|
||||||
<AccountCircle sx={{ marginRight: '5px' }} />
|
// rel="noopener noreferrer"
|
||||||
<Typography variant="body1">{user?.email}</Typography>
|
// sx={{
|
||||||
</IconButton>
|
// ...styles.socialButton,
|
||||||
<Menu
|
// color: darkMode ? '#ffffff' : '#333333',
|
||||||
anchorEl={anchorEl}
|
// '&:hover': {
|
||||||
open={Boolean(anchorEl)}
|
// color: '#ff00c3'
|
||||||
onClose={handleMenuClose}
|
// }
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
// }}
|
||||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
// >
|
||||||
PaperProps={{
|
// <DiscordIcon sx={{ marginRight: '5px' }} />
|
||||||
sx: {
|
// </IconButton>
|
||||||
backgroundColor: darkMode ? '#1e2124' : '#ffffff',
|
// <iframe
|
||||||
color: darkMode ? '#ffffff' : '#333333'
|
// src="https://ghbtns.com/github-btn.html?user=getmaxun&repo=maxun&type=star&count=true&size=large"
|
||||||
}
|
// frameBorder="0"
|
||||||
}}
|
// scrolling="0"
|
||||||
>
|
// width="170"
|
||||||
<MenuItem onClick={() => { handleMenuClose(); logout(); }}>
|
// height="30"
|
||||||
<Logout sx={{ marginRight: '5px' }} /> Logout
|
// title="GitHub"
|
||||||
</MenuItem>
|
// />
|
||||||
</Menu>
|
// </>
|
||||||
</>
|
// );
|
||||||
);
|
|
||||||
|
|
||||||
const renderThemeToggle = () => (
|
// const renderUserMenu = () => (
|
||||||
<Tooltip title="Toggle light/dark theme">
|
// <>
|
||||||
<IconButton
|
// <IconButton
|
||||||
onClick={toggleTheme}
|
// onClick={handleMenuOpen}
|
||||||
sx={{
|
// sx={styles.userButton(darkMode)}
|
||||||
color: darkMode ? '#ffffff' : '#333333',
|
// >
|
||||||
'&:hover': {
|
// <AccountCircle sx={{ marginRight: '5px' }} />
|
||||||
color: '#ff00c3'
|
// <Typography variant="body1">{user?.email}</Typography>
|
||||||
}
|
// </IconButton>
|
||||||
}}
|
// <Menu
|
||||||
>
|
// anchorEl={anchorEl}
|
||||||
{darkMode ? <Brightness7 /> : <Brightness4 />}
|
// open={Boolean(anchorEl)}
|
||||||
</IconButton>
|
// onClose={handleMenuClose}
|
||||||
</Tooltip>
|
// anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||||
);
|
// transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||||
|
// PaperProps={{
|
||||||
|
// sx: {
|
||||||
|
// backgroundColor: darkMode ? '#1e2124' : '#ffffff',
|
||||||
|
// color: darkMode ? '#ffffff' : '#333333'
|
||||||
|
// }
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// <MenuItem onClick={() => { handleMenuClose(); logout(); }}>
|
||||||
|
// <Logout sx={{ marginRight: '5px' }} /> Logout
|
||||||
|
// </MenuItem>
|
||||||
|
// </Menu>
|
||||||
|
// </>
|
||||||
|
// );
|
||||||
|
|
||||||
const renderRecordingControls = () => (
|
// const renderThemeToggle = () => (
|
||||||
<>
|
// <Tooltip title="Toggle light/dark theme">
|
||||||
<IconButton
|
// <IconButton
|
||||||
onClick={goToMainMenu}
|
// onClick={toggleTheme}
|
||||||
sx={styles.discardButton}
|
// sx={{
|
||||||
>
|
// color: darkMode ? '#ffffff' : '#333333',
|
||||||
<Clear sx={{ marginRight: '5px' }} />
|
// '&:hover': {
|
||||||
Discard
|
// color: '#ff00c3'
|
||||||
</IconButton>
|
// }
|
||||||
<SaveRecording fileName={recordingName} />
|
// }}
|
||||||
</>
|
// >
|
||||||
);
|
// {darkMode ? <Brightness7 /> : <Brightness4 />}
|
||||||
|
// </IconButton>
|
||||||
|
// </Tooltip>
|
||||||
|
// );
|
||||||
|
|
||||||
return (
|
// const renderRecordingControls = () => (
|
||||||
|
// <>
|
||||||
|
// <IconButton
|
||||||
|
// onClick={goToMainMenu}
|
||||||
|
// sx={styles.discardButton}
|
||||||
|
// >
|
||||||
|
// <Clear sx={{ marginRight: '5px' }} />
|
||||||
|
// Discard
|
||||||
|
// </IconButton>
|
||||||
|
// <SaveRecording fileName={recordingName} />
|
||||||
|
// </>
|
||||||
|
// );
|
||||||
|
|
||||||
<NavBarWrapper mode={darkMode ? 'dark' : 'light'}>
|
// return (
|
||||||
{renderBrandSection()}
|
|
||||||
{user && (
|
// <NavBarWrapper mode={darkMode ? 'dark' : 'light'}>
|
||||||
<ControlsContainer>
|
// {renderBrandSection()}
|
||||||
{!isRecording ? (
|
// {user && (
|
||||||
<>
|
// <ControlsContainer>
|
||||||
{renderSocialButtons()}
|
// {!isRecording ? (
|
||||||
{renderUserMenu()}
|
// <>
|
||||||
{renderThemeToggle()}
|
// {renderSocialButtons()}
|
||||||
</>
|
// {renderUserMenu()}
|
||||||
) : (
|
// {renderThemeToggle()}
|
||||||
renderRecordingControls()
|
// </>
|
||||||
)}
|
// ) : (
|
||||||
</ControlsContainer>
|
// renderRecordingControls()
|
||||||
)}
|
// )}
|
||||||
|
// </ControlsContainer>
|
||||||
|
// )}
|
||||||
|
|
||||||
|
|
||||||
</NavBarWrapper>
|
// </NavBarWrapper>
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkForUpdates = async () => {
|
||||||
|
const latestVersion = await fetchLatestVersion();
|
||||||
|
setLatestVersion(latestVersion);
|
||||||
|
if (latestVersion && latestVersion !== currentVersion) {
|
||||||
|
setIsUpdateAvailable(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkForUpdates();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isUpdateAvailable && (
|
||||||
|
<Snackbar
|
||||||
|
open={isUpdateAvailable}
|
||||||
|
onClose={() => setIsUpdateAvailable(false)}
|
||||||
|
message={
|
||||||
|
`${t('navbar.upgrade.modal.new_version_available', { version: latestVersion })} ${t('navbar.upgrade.modal.view_updates')}`
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={handleUpdateOpen}
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#ff00c3',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textTransform: 'none',
|
||||||
|
marginRight: '8px',
|
||||||
|
borderRadius: '5px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('navbar.upgrade.button')}
|
||||||
|
</Button>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="close"
|
||||||
|
color="inherit"
|
||||||
|
onClick={() => setIsUpdateAvailable(false)}
|
||||||
|
style={{ color: 'black' }}
|
||||||
|
>
|
||||||
|
<Close />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
ContentProps={{
|
||||||
|
sx: {
|
||||||
|
background: "white",
|
||||||
|
color: "black",
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<NavBarWrapper mode={darkMode ? 'dark' : 'light'}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
}}>
|
||||||
|
<img src={MaxunLogo} width={45} height={40} style={{ borderRadius: '5px', margin: '5px 0px 5px 15px' }} />
|
||||||
|
<div style={{ padding: '11px' }}><ProjectName>{t('navbar.project_name')}</ProjectName></div>
|
||||||
|
<Chip
|
||||||
|
label={`${currentVersion}`}
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ marginTop: '10px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
user ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
|
||||||
|
{!isRecording ? (
|
||||||
|
<>
|
||||||
|
<Button variant="outlined" onClick={handleUpdateOpen} sx={{
|
||||||
|
marginRight: '40px',
|
||||||
|
color: "#00000099",
|
||||||
|
border: "#00000099 1px solid",
|
||||||
|
'&:hover': { color: '#ff00c3', border: '#ff00c3 1px solid' }
|
||||||
|
}}>
|
||||||
|
<Update sx={{ marginRight: '5px' }} /> {t('navbar.upgrade.button')} Maxun
|
||||||
|
</Button>
|
||||||
|
<Modal open={open} onClose={handleUpdateClose}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
width: 500,
|
||||||
|
bgcolor: "background.paper",
|
||||||
|
boxShadow: 24,
|
||||||
|
p: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{latestVersion === null ? (
|
||||||
|
<Typography>Checking for updates...</Typography>
|
||||||
|
) : currentVersion === latestVersion ? (
|
||||||
|
<Typography variant="h6" textAlign="center">
|
||||||
|
{t('navbar.upgrade.modal.up_to_date')}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Typography variant="body1" textAlign="left" sx={{ marginLeft: '30px' }}>
|
||||||
|
{t('navbar.upgrade.modal.new_version_available', { version: latestVersion })}
|
||||||
|
<br />
|
||||||
|
{t('navbar.upgrade.modal.view_updates')}
|
||||||
|
<a href="https://github.com/getmaxun/maxun/releases/" target="_blank" style={{ textDecoration: 'none' }}>{' '}here.</a>
|
||||||
|
</Typography>
|
||||||
|
<Tabs
|
||||||
|
value={tab}
|
||||||
|
onChange={handleUpdateTabChange}
|
||||||
|
sx={{ marginTop: 2, marginBottom: 2 }}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Tab label={t('navbar.upgrade.modal.tabs.manual_setup')} />
|
||||||
|
<Tab label={t('navbar.upgrade.modal.tabs.docker_setup')} />
|
||||||
|
</Tabs>
|
||||||
|
{tab === 0 && (
|
||||||
|
<Box sx={{ marginLeft: '30px', background: '#cfd0d1', padding: 1, borderRadius: 3 }}>
|
||||||
|
<code style={{ color: 'black' }}>
|
||||||
|
<p>Run the commands below</p>
|
||||||
|
# cd to project directory (eg: maxun)
|
||||||
|
<br />
|
||||||
|
cd maxun
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
# pull latest changes
|
||||||
|
<br />
|
||||||
|
git pull origin master
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
# install dependencies
|
||||||
|
<br />
|
||||||
|
npm install
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
# start maxun
|
||||||
|
<br />
|
||||||
|
npm run start
|
||||||
|
</code>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{tab === 1 && (
|
||||||
|
<Box sx={{ marginLeft: '30px', background: '#cfd0d1', padding: 1, borderRadius: 3 }}>
|
||||||
|
<code style={{ color: 'black' }}>
|
||||||
|
<p>Run the commands below</p>
|
||||||
|
# cd to project directory (eg: maxun)
|
||||||
|
<br />
|
||||||
|
cd maxun
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
# stop the working containers
|
||||||
|
<br />
|
||||||
|
docker-compose down
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
# pull latest docker images
|
||||||
|
<br />
|
||||||
|
docker-compose pull
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
# start maxun
|
||||||
|
<br />
|
||||||
|
docker-compose up -d
|
||||||
|
</code>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
<iframe src="https://ghbtns.com/github-btn.html?user=getmaxun&repo=maxun&type=star&count=true&size=large" frameBorder="0" scrolling="0" width="170" height="30" title="GitHub"></iframe>
|
||||||
|
<IconButton onClick={handleMenuOpen} sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: '5px',
|
||||||
|
padding: '8px',
|
||||||
|
marginRight: '10px',
|
||||||
|
'&:hover': { backgroundColor: 'white', color: '#ff00c3' }
|
||||||
|
}}>
|
||||||
|
<AccountCircle sx={{ marginRight: '5px' }} />
|
||||||
|
<Typography variant="body1">{user.email}</Typography>
|
||||||
|
</IconButton>
|
||||||
|
<Menu
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
open={Boolean(anchorEl)}
|
||||||
|
onClose={handleMenuClose}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'right',
|
||||||
|
}}
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'right',
|
||||||
|
}}
|
||||||
|
PaperProps={{ sx: { width: '180px' } }}
|
||||||
|
>
|
||||||
|
<MenuItem onClick={() => { handleMenuClose(); logout(); }}>
|
||||||
|
<Logout sx={{ marginRight: '5px' }} /> {t('navbar.menu_items.logout')}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={() => {
|
||||||
|
window.open('https://discord.gg/5GbPjBUkws', '_blank');
|
||||||
|
}}>
|
||||||
|
<DiscordIcon sx={{ marginRight: '5px' }} /> Discord
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={() => {
|
||||||
|
window.open('https://www.youtube.com/@MaxunOSS/videos?ref=app', '_blank');
|
||||||
|
}}>
|
||||||
|
<YouTube sx={{ marginRight: '5px' }} /> YouTube
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={() => {
|
||||||
|
window.open('https://x.com/maxun_io?ref=app', '_blank');
|
||||||
|
}}>
|
||||||
|
<X sx={{ marginRight: '5px' }} /> Twitter (X)
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={handleLangMenuOpen}>
|
||||||
|
<Language sx={{ marginRight: '5px' }} /> {t('navbar.menu_items.language')}
|
||||||
|
</MenuItem>
|
||||||
|
<Menu
|
||||||
|
anchorEl={langAnchorEl}
|
||||||
|
open={Boolean(langAnchorEl)}
|
||||||
|
onClose={handleMenuClose}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: "bottom",
|
||||||
|
horizontal: "right",
|
||||||
|
}}
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: "top",
|
||||||
|
horizontal: "right",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
changeLanguage("en");
|
||||||
|
handleMenuClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
English
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
changeLanguage("es");
|
||||||
|
handleMenuClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Español
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
changeLanguage("ja");
|
||||||
|
handleMenuClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
日本語
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
changeLanguage("zh");
|
||||||
|
handleMenuClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
中文
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
changeLanguage("de");
|
||||||
|
handleMenuClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Deutsch
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<IconButton onClick={goToMainMenu} sx={{
|
||||||
|
borderRadius: '5px',
|
||||||
|
padding: '8px',
|
||||||
|
background: 'red',
|
||||||
|
color: 'white',
|
||||||
|
marginRight: '10px',
|
||||||
|
'&:hover': { color: 'white', backgroundColor: 'red' }
|
||||||
|
}}>
|
||||||
|
<Clear sx={{ marginRight: '5px' }} />
|
||||||
|
{t('navbar.recording.discard')}
|
||||||
|
</IconButton>
|
||||||
|
<SaveRecording fileName={recordingName} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<><IconButton
|
||||||
|
onClick={handleLangMenuOpen}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
borderRadius: "5px",
|
||||||
|
padding: "8px",
|
||||||
|
marginRight: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Language sx={{ marginRight: '5px' }} /><Typography variant="body1">{t("Language")}</Typography>
|
||||||
|
</IconButton>
|
||||||
|
<Menu
|
||||||
|
anchorEl={langAnchorEl}
|
||||||
|
open={Boolean(langAnchorEl)}
|
||||||
|
onClose={handleMenuClose}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: "bottom",
|
||||||
|
horizontal: "right",
|
||||||
|
}}
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: "top",
|
||||||
|
horizontal: "right",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
changeLanguage("en");
|
||||||
|
handleMenuClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
English
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
changeLanguage("es");
|
||||||
|
handleMenuClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Español
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
changeLanguage("ja");
|
||||||
|
handleMenuClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
日本語
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
changeLanguage("zh");
|
||||||
|
handleMenuClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
中文
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
changeLanguage("de");
|
||||||
|
handleMenuClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Deutsch
|
||||||
|
</MenuItem>
|
||||||
|
</Menu></>
|
||||||
|
)}
|
||||||
|
</NavBarWrapper>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { FC, useState } from 'react';
|
import React, { FC, useState } from 'react';
|
||||||
import { Stack, Button, IconButton, Tooltip, Chip, Badge } from "@mui/material";
|
import { Stack, Button, IconButton, Tooltip, Badge } from "@mui/material";
|
||||||
import { AddPair, deletePair, UpdatePair } from "../../api/workflow";
|
import { AddPair, deletePair, UpdatePair } from "../../api/workflow";
|
||||||
import { WorkflowFile } from "maxun-core";
|
import { WorkflowFile } from "maxun-core";
|
||||||
import { ClearButton } from "../atoms/buttons/ClearButton";
|
import { ClearButton } from "../atoms/buttons/ClearButton";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import Paper from '@mui/material/Paper';
|
import Paper from '@mui/material/Paper';
|
||||||
import Table from '@mui/material/Table';
|
import Table from '@mui/material/Table';
|
||||||
import TableBody from '@mui/material/TableBody';
|
import TableBody from '@mui/material/TableBody';
|
||||||
@@ -19,6 +20,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { stopRecording } from "../../api/recording";
|
import { stopRecording } from "../../api/recording";
|
||||||
import { GenericModal } from '../atoms/GenericModal';
|
import { GenericModal } from '../atoms/GenericModal';
|
||||||
|
|
||||||
|
|
||||||
/** TODO:
|
/** TODO:
|
||||||
* 1. allow editing existing robot after persisting browser steps
|
* 1. allow editing existing robot after persisting browser steps
|
||||||
*/
|
*/
|
||||||
@@ -31,31 +33,6 @@ interface Column {
|
|||||||
format?: (value: string) => string;
|
format?: (value: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: readonly Column[] = [
|
|
||||||
{ id: 'interpret', label: 'Run', minWidth: 80 },
|
|
||||||
{ id: 'name', label: 'Name', minWidth: 80 },
|
|
||||||
{
|
|
||||||
id: 'schedule',
|
|
||||||
label: 'Schedule',
|
|
||||||
minWidth: 80,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'integrate',
|
|
||||||
label: 'Integrate',
|
|
||||||
minWidth: 80,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'settings',
|
|
||||||
label: 'Settings',
|
|
||||||
minWidth: 80,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'options',
|
|
||||||
label: 'Options',
|
|
||||||
minWidth: 80,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface Data {
|
interface Data {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -76,12 +53,38 @@ interface RecordingsTableProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handleScheduleRecording, handleIntegrateRecording, handleSettingsRecording, handleEditRobot, handleDuplicateRobot }: RecordingsTableProps) => {
|
export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handleScheduleRecording, handleIntegrateRecording, handleSettingsRecording, handleEditRobot, handleDuplicateRobot }: RecordingsTableProps) => {
|
||||||
|
const {t} = useTranslation();
|
||||||
const [page, setPage] = React.useState(0);
|
const [page, setPage] = React.useState(0);
|
||||||
const [rowsPerPage, setRowsPerPage] = React.useState(10);
|
const [rowsPerPage, setRowsPerPage] = React.useState(10);
|
||||||
const [rows, setRows] = React.useState<Data[]>([]);
|
const [rows, setRows] = React.useState<Data[]>([]);
|
||||||
const [isModalOpen, setModalOpen] = React.useState(false);
|
const [isModalOpen, setModalOpen] = React.useState(false);
|
||||||
const [searchTerm, setSearchTerm] = React.useState('');
|
const [searchTerm, setSearchTerm] = React.useState('');
|
||||||
|
|
||||||
|
const columns: readonly Column[] = [
|
||||||
|
{ id: 'interpret', label: t('recordingtable.run'), minWidth: 80 },
|
||||||
|
{ id: 'name', label: t('recordingtable.name'), minWidth: 80 },
|
||||||
|
{
|
||||||
|
id: 'schedule',
|
||||||
|
label: t('recordingtable.schedule'),
|
||||||
|
minWidth: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'integrate',
|
||||||
|
label: t('recordingtable.integrate'),
|
||||||
|
minWidth: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'settings',
|
||||||
|
label: t('recordingtable.settings'),
|
||||||
|
minWidth: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'options',
|
||||||
|
label: t('recordingtable.options'),
|
||||||
|
minWidth: 80,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const { notify, setRecordings, browserId, setBrowserId, recordingUrl, setRecordingUrl, recordingName, setRecordingName, recordingId, setRecordingId } = useGlobalInfoStore();
|
const { notify, setRecordings, browserId, setBrowserId, recordingUrl, setRecordingUrl, recordingName, setRecordingName, recordingId, setRecordingId } = useGlobalInfoStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -151,16 +154,17 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl
|
|||||||
row.name.toLowerCase().includes(searchTerm.toLowerCase())
|
row.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<Box display="flex" justifyContent="space-between" alignItems="center">
|
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
My Robots
|
{t('recordingtable.heading')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box display="flex" alignItems="center" gap={2}>
|
<Box display="flex" alignItems="center" gap={2}>
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
placeholder="Search robots..."
|
placeholder={t('recordingtable.search')}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={handleSearchChange}
|
onChange={handleSearchChange}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
@@ -187,7 +191,7 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl
|
|||||||
'&:hover': { color: 'white', backgroundColor: '#ff00c3' }
|
'&:hover': { color: 'white', backgroundColor: '#ff00c3' }
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Add sx={{ marginRight: '5px' }} /> Create Robot
|
<Add sx={{ marginRight: '5px' }} /> {t('recordingtable.new')}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -253,14 +257,14 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl
|
|||||||
|
|
||||||
checkRunsForRecording(row.id).then((result: boolean) => {
|
checkRunsForRecording(row.id).then((result: boolean) => {
|
||||||
if (result) {
|
if (result) {
|
||||||
notify('warning', 'Cannot delete robot as it has associated runs');
|
notify('warning', t('recordingtable.notifications.delete_warning'));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
deleteRecordingFromStorage(row.id).then((result: boolean) => {
|
deleteRecordingFromStorage(row.id).then((result: boolean) => {
|
||||||
if (result) {
|
if (result) {
|
||||||
setRows([]);
|
setRows([]);
|
||||||
notify('success', 'Robot deleted successfully');
|
notify('success', t('recordingtable.notifications.delete_success'));
|
||||||
fetchRecordings();
|
fetchRecordings();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -297,9 +301,9 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl
|
|||||||
/>
|
/>
|
||||||
<GenericModal isOpen={isModalOpen} onClose={() => setModalOpen(false)} modalStyle={modalStyle}>
|
<GenericModal isOpen={isModalOpen} onClose={() => setModalOpen(false)} modalStyle={modalStyle}>
|
||||||
<div style={{ padding: '20px' }}>
|
<div style={{ padding: '20px' }}>
|
||||||
<Typography variant="h6" gutterBottom>Enter URL To Extract Data</Typography>
|
<Typography variant="h6" gutterBottom>{t('recordingtable.modal.title')}</Typography>
|
||||||
<TextField
|
<TextField
|
||||||
label="URL"
|
label={t('recordingtable.modal.label')}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
fullWidth
|
fullWidth
|
||||||
value={recordingUrl}
|
value={recordingUrl}
|
||||||
@@ -312,7 +316,7 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl
|
|||||||
onClick={startRecording}
|
onClick={startRecording}
|
||||||
disabled={!recordingUrl}
|
disabled={!recordingUrl}
|
||||||
>
|
>
|
||||||
Start Training Robot
|
{t('recordingtable.modal.button')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</GenericModal>
|
</GenericModal>
|
||||||
@@ -397,6 +401,8 @@ const OptionsButton = ({ handleEdit, handleDelete, handleDuplicate }: OptionsBut
|
|||||||
setAnchorEl(null);
|
setAnchorEl(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const {t} = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -415,19 +421,21 @@ const OptionsButton = ({ handleEdit, handleDelete, handleDuplicate }: OptionsBut
|
|||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<Edit fontSize="small" />
|
<Edit fontSize="small" />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText>Edit</ListItemText>
|
<ListItemText>{t('recordingtable.edit')}</ListItemText>
|
||||||
</MenuItem>
|
|
||||||
<MenuItem onClick={() => { handleDuplicate(); handleClose(); }}>
|
|
||||||
<ListItemIcon>
|
|
||||||
<ContentCopy fontSize="small" />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText>Duplicate</ListItemText>
|
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|
||||||
<MenuItem onClick={() => { handleDelete(); handleClose(); }}>
|
<MenuItem onClick={() => { handleDelete(); handleClose(); }}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<DeleteForever fontSize="small" />
|
<DeleteForever fontSize="small" />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText>Delete</ListItemText>
|
<ListItemText>{t('recordingtable.delete')}</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
<MenuItem onClick={() => { handleDuplicate(); handleClose(); }}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<ContentCopy fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>{t('recordingtable.duplicate')}</ListItemText>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { GenericModal } from "../atoms/GenericModal";
|
import { GenericModal } from "../atoms/GenericModal";
|
||||||
import { TextField, Typography, Box, Button, Chip } from "@mui/material";
|
import { TextField, Typography, Box, Button } from "@mui/material";
|
||||||
import { modalStyle } from "./AddWhereCondModal";
|
import { modalStyle } from "./AddWhereCondModal";
|
||||||
import { useGlobalInfoStore } from '../../context/globalInfo';
|
import { useGlobalInfoStore } from '../../context/globalInfo';
|
||||||
import { duplicateRecording, getStoredRecording } from '../../api/storage';
|
import { duplicateRecording, getStoredRecording } from '../../api/storage';
|
||||||
import { WhereWhatPair } from 'maxun-core';
|
import { WhereWhatPair } from 'maxun-core';
|
||||||
import { getUserById } from "../../api/auth";
|
import { getUserById } from "../../api/auth";
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface RobotMeta {
|
interface RobotMeta {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -54,6 +55,7 @@ interface RobotSettingsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
|
export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [robot, setRobot] = useState<RobotSettings | null>(null);
|
const [robot, setRobot] = useState<RobotSettings | null>(null);
|
||||||
const [targetUrl, setTargetUrl] = useState<string | undefined>('');
|
const [targetUrl, setTargetUrl] = useState<string | undefined>('');
|
||||||
const { recordingId, notify } = useGlobalInfoStore();
|
const { recordingId, notify } = useGlobalInfoStore();
|
||||||
@@ -65,7 +67,6 @@ export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initia
|
|||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Update the targetUrl when the robot data is loaded
|
|
||||||
if (robot) {
|
if (robot) {
|
||||||
const lastPair = robot?.recording.workflow[robot?.recording.workflow.length - 1];
|
const lastPair = robot?.recording.workflow[robot?.recording.workflow.length - 1];
|
||||||
const url = lastPair?.what.find(action => action.action === "goto")?.args?.[0];
|
const url = lastPair?.what.find(action => action.action === "goto")?.args?.[0];
|
||||||
@@ -78,43 +79,36 @@ export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initia
|
|||||||
const robot = await getStoredRecording(recordingId);
|
const robot = await getStoredRecording(recordingId);
|
||||||
setRobot(robot);
|
setRobot(robot);
|
||||||
} else {
|
} else {
|
||||||
notify('error', 'Could not find robot details. Please try again.');
|
notify('error', t('robot_duplication.notifications.robot_not_found'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// const lastPair = robot?.recording.workflow[robot?.recording.workflow.length - 1];
|
|
||||||
|
|
||||||
// // Find the `goto` action in `what` and retrieve its arguments
|
|
||||||
// const targetUrl = lastPair?.what.find(action => action.action === "goto")?.args?.[0];
|
|
||||||
|
|
||||||
const handleTargetUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleTargetUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setTargetUrl(e.target.value);
|
setTargetUrl(e.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!robot || !targetUrl) {
|
if (!robot || !targetUrl) {
|
||||||
notify('error', 'Target URL is required.');
|
notify('error', t('robot_duplication.notifications.url_required'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("handle save");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const success = await duplicateRecording(robot.recording_meta.id, targetUrl);
|
const success = await duplicateRecording(robot.recording_meta.id, targetUrl);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
notify('success', 'Robot duplicated successfully.');
|
notify('success', t('robot_duplication.notifications.duplicate_success'));
|
||||||
handleStart(robot); // Inform parent about the updated robot
|
handleStart(robot);
|
||||||
handleClose();
|
handleClose();
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} else {
|
} else {
|
||||||
notify('error', 'Failed to update the Target URL. Please try again.');
|
notify('error', t('robot_duplication.notifications.duplicate_error'));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notify('error', 'An error occurred while updating the Target URL.');
|
notify('error', t('robot_duplication.notifications.unknown_error'));
|
||||||
console.error('Error updating Target URL:', error);
|
console.error('Error updating Target URL:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -126,34 +120,40 @@ export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initia
|
|||||||
modalStyle={modalStyle}
|
modalStyle={modalStyle}
|
||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
<Typography variant="h5" style={{ marginBottom: '20px' }}>Duplicate Robot</Typography>
|
<Typography variant="h5" style={{ marginBottom: '20px' }}>
|
||||||
|
{t('robot_duplication.title')}
|
||||||
|
</Typography>
|
||||||
<Box style={{ display: 'flex', flexDirection: 'column' }}>
|
<Box style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
{
|
{
|
||||||
robot && (
|
robot && (
|
||||||
<>
|
<>
|
||||||
<span>Robot duplication is useful to extract data from pages with the same structure.</span>
|
<span>
|
||||||
|
{t('robot_duplication.descriptions.purpose')}
|
||||||
|
</span>
|
||||||
|
<br />
|
||||||
|
<span dangerouslySetInnerHTML={{
|
||||||
|
__html: t('robot_duplication.descriptions.example', {
|
||||||
|
url1: '<code>producthunt.com/topics/api</code>',
|
||||||
|
url2: '<code>producthunt.com/topics/database</code>'
|
||||||
|
})
|
||||||
|
}}/>
|
||||||
<br />
|
<br />
|
||||||
<span>
|
<span>
|
||||||
Example: If you've created a robot for <code>producthunt.com/topics/api</code>, you can duplicate it to scrape similar pages
|
<b>{t('robot_duplication.descriptions.warning')}</b>
|
||||||
like <code>producthunt.com/topics/database</code> without training a robot from scratch.
|
|
||||||
</span>
|
|
||||||
<br />
|
|
||||||
<span>
|
|
||||||
<b>⚠️ Ensure the new page has the same structure as the original page.</b>
|
|
||||||
</span>
|
</span>
|
||||||
<TextField
|
<TextField
|
||||||
label="Robot Target URL"
|
label={t('robot_duplication.fields.target_url')}
|
||||||
key="Robot Target URL"
|
key={t('robot_duplication.fields.target_url')}
|
||||||
value={targetUrl}
|
value={targetUrl}
|
||||||
onChange={handleTargetUrlChange}
|
onChange={handleTargetUrlChange}
|
||||||
style={{ marginBottom: '20px', marginTop: '30px' }}
|
style={{ marginBottom: '20px', marginTop: '30px' }}
|
||||||
/>
|
/>
|
||||||
<Box mt={2} display="flex" justifyContent="flex-end" onClick={handleSave}>
|
<Box mt={2} display="flex" justifyContent="flex-end">
|
||||||
<Button variant="contained" color="primary">
|
<Button variant="contained" color="primary" onClick={handleSave}>
|
||||||
Duplicate Robot
|
{t('robot_duplication.buttons.duplicate')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleClose} color="primary" variant="outlined" style={{ marginLeft: '10px' }}>
|
<Button onClick={handleClose} color="primary" variant="outlined" style={{ marginLeft: '10px' }}>
|
||||||
Cancel
|
{t('robot_duplication.buttons.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { GenericModal } from "../atoms/GenericModal";
|
import { GenericModal } from "../atoms/GenericModal";
|
||||||
import { TextField, Typography, Box, Button } from "@mui/material";
|
import { TextField, Typography, Box, Button } from "@mui/material";
|
||||||
import { modalStyle } from "./AddWhereCondModal";
|
import { modalStyle } from "./AddWhereCondModal";
|
||||||
@@ -54,10 +55,10 @@ interface RobotSettingsProps {
|
|||||||
handleStart: (settings: RobotSettings) => void;
|
handleStart: (settings: RobotSettings) => void;
|
||||||
handleClose: () => void;
|
handleClose: () => void;
|
||||||
initialSettings?: RobotSettings | null;
|
initialSettings?: RobotSettings | null;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
|
export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [robot, setRobot] = useState<RobotSettings | null>(null);
|
const [robot, setRobot] = useState<RobotSettings | null>(null);
|
||||||
const { recordingId, notify } = useGlobalInfoStore();
|
const { recordingId, notify } = useGlobalInfoStore();
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin
|
|||||||
const robot = await getStoredRecording(recordingId);
|
const robot = await getStoredRecording(recordingId);
|
||||||
setRobot(robot);
|
setRobot(robot);
|
||||||
} else {
|
} else {
|
||||||
notify('error', 'Could not find robot details. Please try again.');
|
notify('error', t('robot_edit.notifications.update_failed'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +103,7 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin
|
|||||||
return { ...prev, recording: { ...prev.recording, workflow: updatedWorkflow } };
|
return { ...prev, recording: { ...prev.recording, workflow: updatedWorkflow } };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!robot) return;
|
if (!robot) return;
|
||||||
|
|
||||||
@@ -114,7 +116,7 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin
|
|||||||
const success = await updateRecording(robot.recording_meta.id, payload);
|
const success = await updateRecording(robot.recording_meta.id, payload);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
notify('success', 'Robot updated successfully.');
|
notify('success', t('robot_edit.notifications.update_success'));
|
||||||
handleStart(robot); // Inform parent about the updated robot
|
handleStart(robot); // Inform parent about the updated robot
|
||||||
handleClose();
|
handleClose();
|
||||||
|
|
||||||
@@ -122,10 +124,10 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin
|
|||||||
window.location.reload();
|
window.location.reload();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} else {
|
} else {
|
||||||
notify('error', 'Failed to update the robot. Please try again.');
|
notify('error', t('robot_edit.notifications.update_failed'));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notify('error', 'An error occurred while updating the robot.');
|
notify('error', t('robot_edit.notifications.update_error'));
|
||||||
console.error('Error updating robot:', error);
|
console.error('Error updating robot:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -137,14 +139,16 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin
|
|||||||
modalStyle={modalStyle}
|
modalStyle={modalStyle}
|
||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
<Typography variant="h5" style={{ marginBottom: '20px' }}>Edit Robot</Typography>
|
<Typography variant="h5" style={{ marginBottom: '20px' }}>
|
||||||
|
{t('robot_edit.title')}
|
||||||
|
</Typography>
|
||||||
<Box style={{ display: 'flex', flexDirection: 'column' }}>
|
<Box style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
{
|
{
|
||||||
robot && (
|
robot && (
|
||||||
<>
|
<>
|
||||||
<TextField
|
<TextField
|
||||||
label="Change Robot Name"
|
label={t('robot_edit.change_name')}
|
||||||
key="Change Robot Name"
|
key="Robot Name"
|
||||||
type='text'
|
type='text'
|
||||||
value={robot.recording_meta.name}
|
value={robot.recording_meta.name}
|
||||||
onChange={(e) => handleRobotNameChange(e.target.value)}
|
onChange={(e) => handleRobotNameChange(e.target.value)}
|
||||||
@@ -152,7 +156,7 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin
|
|||||||
/>
|
/>
|
||||||
{robot.recording.workflow?.[0]?.what?.[0]?.args?.[0]?.limit !== undefined && (
|
{robot.recording.workflow?.[0]?.what?.[0]?.args?.[0]?.limit !== undefined && (
|
||||||
<TextField
|
<TextField
|
||||||
label="Robot Limit"
|
label={t('robot_edit.robot_limit')}
|
||||||
type="number"
|
type="number"
|
||||||
value={robot.recording.workflow[0].what[0].args[0].limit || ''}
|
value={robot.recording.workflow[0].what[0].args[0].limit || ''}
|
||||||
onChange={(e) =>{
|
onChange={(e) =>{
|
||||||
@@ -166,12 +170,17 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box mt={2} display="flex" justifyContent="flex-end" onClick={handleSave}>
|
<Box mt={2} display="flex" justifyContent="flex-end">
|
||||||
<Button variant="contained" color="primary">
|
<Button variant="contained" color="primary" onClick={handleSave}>
|
||||||
Save Changes
|
{t('robot_edit.save')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleClose} color="primary" variant="outlined" style={{ marginLeft: '10px' }}>
|
<Button
|
||||||
Cancel
|
onClick={handleClose}
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
style={{ marginLeft: '10px' }}
|
||||||
|
>
|
||||||
|
{t('robot_edit.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { GenericModal } from "../atoms/GenericModal";
|
import { GenericModal } from "../atoms/GenericModal";
|
||||||
import { TextField, Typography, Box } from "@mui/material";
|
import { TextField, Typography, Box } from "@mui/material";
|
||||||
import { modalStyle } from "./AddWhereCondModal";
|
import { modalStyle } from "./AddWhereCondModal";
|
||||||
@@ -50,10 +51,10 @@ interface RobotSettingsProps {
|
|||||||
handleStart: (settings: RobotSettings) => void;
|
handleStart: (settings: RobotSettings) => void;
|
||||||
handleClose: () => void;
|
handleClose: () => void;
|
||||||
initialSettings?: RobotSettings | null;
|
initialSettings?: RobotSettings | null;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
|
export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [robot, setRobot] = useState<RobotSettings | null>(null);
|
const [robot, setRobot] = useState<RobotSettings | null>(null);
|
||||||
const [userEmail, setUserEmail] = useState<string | null>(null);
|
const [userEmail, setUserEmail] = useState<string | null>(null);
|
||||||
const { recordingId, notify } = useGlobalInfoStore();
|
const { recordingId, notify } = useGlobalInfoStore();
|
||||||
@@ -69,7 +70,7 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
|
|||||||
const robot = await getStoredRecording(recordingId);
|
const robot = await getStoredRecording(recordingId);
|
||||||
setRobot(robot);
|
setRobot(robot);
|
||||||
} else {
|
} else {
|
||||||
notify('error', 'Could not find robot details. Please try again.');
|
notify('error', t('robot_settings.errors.robot_not_found'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,13 +98,15 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
|
|||||||
modalStyle={modalStyle}
|
modalStyle={modalStyle}
|
||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
<Typography variant="h5" style={{ marginBottom: '20px' }}>Robot Settings</Typography>
|
<Typography variant="h5" style={{ marginBottom: '20px' }}>
|
||||||
|
{t('robot_settings.title')}
|
||||||
|
</Typography>
|
||||||
<Box style={{ display: 'flex', flexDirection: 'column' }}>
|
<Box style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
{
|
{
|
||||||
robot && (
|
robot && (
|
||||||
<>
|
<>
|
||||||
<TextField
|
<TextField
|
||||||
label="Robot Target URL"
|
label={t('robot_settings.target_url')}
|
||||||
key="Robot Target URL"
|
key="Robot Target URL"
|
||||||
value={targetUrl}
|
value={targetUrl}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
@@ -112,7 +115,7 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
|
|||||||
style={{ marginBottom: '20px' }}
|
style={{ marginBottom: '20px' }}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Robot ID"
|
label={t('robot_settings.robot_id')}
|
||||||
key="Robot ID"
|
key="Robot ID"
|
||||||
value={robot.recording_meta.id}
|
value={robot.recording_meta.id}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
@@ -122,17 +125,17 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
|
|||||||
/>
|
/>
|
||||||
{robot.recording.workflow?.[0]?.what?.[0]?.args?.[0]?.limit !== undefined && (
|
{robot.recording.workflow?.[0]?.what?.[0]?.args?.[0]?.limit !== undefined && (
|
||||||
<TextField
|
<TextField
|
||||||
label="Robot Limit"
|
label={t('robot_settings.robot_limit')}
|
||||||
type="number"
|
type="number"
|
||||||
value={robot.recording.workflow[0].what[0].args[0].limit || ''}
|
value={robot.recording.workflow[0].what[0].args[0].limit || ''}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
}}
|
}}
|
||||||
style={{ marginBottom: '20px' }}
|
style={{ marginBottom: '20px' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<TextField
|
<TextField
|
||||||
label="Created By User"
|
label={t('robot_settings.created_by_user')}
|
||||||
key="Created By User"
|
key="Created By User"
|
||||||
value={userEmail ? userEmail : ''}
|
value={userEmail ? userEmail : ''}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
@@ -141,7 +144,7 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
|
|||||||
style={{ marginBottom: '20px' }}
|
style={{ marginBottom: '20px' }}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Robot Created At"
|
label={t('robot_settings.created_at')}
|
||||||
key="Robot Created At"
|
key="Robot Created At"
|
||||||
value={robot.recording_meta.createdAt}
|
value={robot.recording_meta.createdAt}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
@@ -156,4 +159,4 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
|
|||||||
</>
|
</>
|
||||||
</GenericModal>
|
</GenericModal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -13,6 +13,7 @@ import TableContainer from '@mui/material/TableContainer';
|
|||||||
import TableHead from '@mui/material/TableHead';
|
import TableHead from '@mui/material/TableHead';
|
||||||
import TableRow from '@mui/material/TableRow';
|
import TableRow from '@mui/material/TableRow';
|
||||||
import 'highlight.js/styles/github.css';
|
import 'highlight.js/styles/github.css';
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface RunContentProps {
|
interface RunContentProps {
|
||||||
row: Data,
|
row: Data,
|
||||||
@@ -23,6 +24,7 @@ interface RunContentProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRef, abortRunHandler }: RunContentProps) => {
|
export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRef, abortRunHandler }: RunContentProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [tab, setTab] = React.useState<string>('log');
|
const [tab, setTab] = React.useState<string>('log');
|
||||||
const [tableData, setTableData] = useState<any[]>([]);
|
const [tableData, setTableData] = useState<any[]>([]);
|
||||||
const [columns, setColumns] = useState<string[]>([]);
|
const [columns, setColumns] = useState<string[]>([]);
|
||||||
@@ -76,49 +78,49 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
|
|||||||
<TabContext value={tab}>
|
<TabContext value={tab}>
|
||||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||||
<Tabs
|
<Tabs
|
||||||
value={tab}
|
value={tab}
|
||||||
onChange={(e, newTab) => setTab(newTab)}
|
onChange={(e, newTab) => setTab(newTab)}
|
||||||
aria-label="run-content-tabs"
|
aria-label="run-content-tabs"
|
||||||
sx={{
|
sx={{
|
||||||
// Remove the default blue indicator
|
// Remove the default blue indicator
|
||||||
'& .MuiTabs-indicator': {
|
'& .MuiTabs-indicator': {
|
||||||
backgroundColor: '#FF00C3', // Change to pink
|
backgroundColor: '#FF00C3', // Change to pink
|
||||||
},
|
},
|
||||||
// Remove default transition effects
|
// Remove default transition effects
|
||||||
'& .MuiTab-root': {
|
'& .MuiTab-root': {
|
||||||
'&.Mui-selected': {
|
'&.Mui-selected': {
|
||||||
color: '#FF00C3',
|
color: '#FF00C3',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tab
|
<Tab
|
||||||
label="Output Data"
|
label={t('run_content.tabs.output_data')}
|
||||||
value='output'
|
value='output'
|
||||||
sx={{
|
sx={{
|
||||||
color: (theme) => theme.palette.mode === 'dark' ? '#fff' : '#000',
|
color: (theme) => theme.palette.mode === 'dark' ? '#fff' : '#000',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
color: '#FF00C3'
|
color: '#FF00C3'
|
||||||
},
|
},
|
||||||
'&.Mui-selected': {
|
'&.Mui-selected': {
|
||||||
color: '#FF00C3',
|
color: '#FF00C3',
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tab
|
<Tab
|
||||||
label="Log"
|
label={t('run_content.tabs.log')}
|
||||||
value='log'
|
value='log'
|
||||||
sx={{
|
sx={{
|
||||||
color: (theme) => theme.palette.mode === 'dark' ? '#fff' : '#000',
|
color: (theme) => theme.palette.mode === 'dark' ? '#fff' : '#000',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
color: '#FF00C3'
|
color: '#FF00C3'
|
||||||
},
|
},
|
||||||
'&.Mui-selected': {
|
'&.Mui-selected': {
|
||||||
color: '#FF00C3',
|
color: '#FF00C3',
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
<TabPanel value='log'>
|
<TabPanel value='log'>
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
@@ -142,32 +144,32 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
|
|||||||
color="error"
|
color="error"
|
||||||
onClick={abortRunHandler}
|
onClick={abortRunHandler}
|
||||||
>
|
>
|
||||||
Stop
|
{t('run_content.buttons.stop')}
|
||||||
</Button> : null}
|
</Button> : null}
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel value='output' sx={{ width: '700px' }}>
|
<TabPanel value='output' sx={{ width: '700px' }}>
|
||||||
{!row || !row.serializableOutput || !row.binaryOutput
|
{!row || !row.serializableOutput || !row.binaryOutput
|
||||||
|| (Object.keys(row.serializableOutput).length === 0 && Object.keys(row.binaryOutput).length === 0)
|
|| (Object.keys(row.serializableOutput).length === 0 && Object.keys(row.binaryOutput).length === 0)
|
||||||
? <Typography>The output is empty.</Typography> : null}
|
? <Typography>{t('run_content.empty_output')}</Typography> : null}
|
||||||
|
|
||||||
{row.serializableOutput &&
|
{row.serializableOutput &&
|
||||||
Object.keys(row.serializableOutput).length !== 0 &&
|
Object.keys(row.serializableOutput).length !== 0 &&
|
||||||
<div>
|
<div>
|
||||||
<Typography variant='h6' sx={{ display: 'flex', alignItems: 'center' }}>
|
<Typography variant='h6' sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
<ArticleIcon sx={{ marginRight: '15px' }} />
|
<ArticleIcon sx={{ marginRight: '15px' }} />
|
||||||
Captured Data
|
{t('run_content.captured_data.title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mt: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mt: 2 }}>
|
||||||
<Typography>
|
<Typography>
|
||||||
<a style={{ textDecoration: 'none' }} href={`data:application/json;utf8,${JSON.stringify(row.serializableOutput, null, 2)}`}
|
<a style={{ textDecoration: 'none' }} href={`data:application/json;utf8,${JSON.stringify(row.serializableOutput, null, 2)}`}
|
||||||
download="data.json">
|
download="data.json">
|
||||||
Download as JSON
|
{t('run_content.captured_data.download_json')}
|
||||||
</a>
|
</a>
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
onClick={downloadCSV}
|
onClick={downloadCSV}
|
||||||
>
|
>
|
||||||
<a style={{ textDecoration: 'none', cursor: 'pointer' }}>Download as CSV</a>
|
<a style={{ textDecoration: 'none', cursor: 'pointer' }}>{t('run_content.captured_data.download_csv')}</a>
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
{tableData.length > 0 ? (
|
{tableData.length > 0 ? (
|
||||||
@@ -212,7 +214,7 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
|
|||||||
<div>
|
<div>
|
||||||
<Typography variant='h6' sx={{ display: 'flex', alignItems: 'center' }}>
|
<Typography variant='h6' sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
<ImageIcon sx={{ marginRight: '15px' }} />
|
<ImageIcon sx={{ marginRight: '15px' }} />
|
||||||
Captured Screenshot
|
{t('run_content.captured_screenshot.title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
{Object.keys(row.binaryOutput).map((key) => {
|
{Object.keys(row.binaryOutput).map((key) => {
|
||||||
try {
|
try {
|
||||||
@@ -222,7 +224,7 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
|
|||||||
width: 'max-content',
|
width: 'max-content',
|
||||||
}}>
|
}}>
|
||||||
<Typography sx={{ margin: '20px 0px' }}>
|
<Typography sx={{ margin: '20px 0px' }}>
|
||||||
<a href={imageUrl} download={key} style={{ textDecoration: 'none' }}>Download Screenshot</a>
|
<a href={imageUrl} download={key} style={{ textDecoration: 'none' }}>{t('run_content.captured_screenshot.download')}</a>
|
||||||
</Typography>
|
</Typography>
|
||||||
<img src={imageUrl} alt={key} height='auto' width='700px' />
|
<img src={imageUrl} alt={key} height='auto' width='700px' />
|
||||||
</Box>
|
</Box>
|
||||||
@@ -230,7 +232,7 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e)
|
console.log(e)
|
||||||
return <Typography key={`number-of-binary-output-${key}`}>
|
return <Typography key={`number-of-binary-output-${key}`}>
|
||||||
{key}: The image failed to render
|
{key}: {t('run_content.captured_screenshot.render_failed')}
|
||||||
</Typography>
|
</Typography>
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import Paper from '@mui/material/Paper';
|
import Paper from '@mui/material/Paper';
|
||||||
import Table from '@mui/material/Table';
|
import Table from '@mui/material/Table';
|
||||||
import TableBody from '@mui/material/TableBody';
|
import TableBody from '@mui/material/TableBody';
|
||||||
@@ -7,14 +9,24 @@ import TableContainer from '@mui/material/TableContainer';
|
|||||||
import TableHead from '@mui/material/TableHead';
|
import TableHead from '@mui/material/TableHead';
|
||||||
import TablePagination from '@mui/material/TablePagination';
|
import TablePagination from '@mui/material/TablePagination';
|
||||||
import TableRow from '@mui/material/TableRow';
|
import TableRow from '@mui/material/TableRow';
|
||||||
import { useEffect, useState } from "react";
|
import { Accordion, AccordionSummary, AccordionDetails, Typography, Box, TextField } from '@mui/material';
|
||||||
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
|
||||||
import { useGlobalInfoStore } from "../../context/globalInfo";
|
import { useGlobalInfoStore } from "../../context/globalInfo";
|
||||||
import { getStoredRuns } from "../../api/storage";
|
import { getStoredRuns } from "../../api/storage";
|
||||||
import { RunSettings } from "./RunSettings";
|
import { RunSettings } from "./RunSettings";
|
||||||
import { CollapsibleRow } from "./ColapsibleRow";
|
import { CollapsibleRow } from "./ColapsibleRow";
|
||||||
import { Accordion, AccordionSummary, AccordionDetails, Typography, Box, TextField } from '@mui/material';
|
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
// Export columns before the component
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
export const columns: readonly Column[] = [
|
||||||
|
{ id: 'runStatus', label: 'Status', minWidth: 80 },
|
||||||
|
{ id: 'name', label: 'Name', minWidth: 80 },
|
||||||
|
{ id: 'startedAt', label: 'Started At', minWidth: 80 },
|
||||||
|
{ id: 'finishedAt', label: 'Finished At', minWidth: 80 },
|
||||||
|
{ id: 'settings', label: 'Settings', minWidth: 80 },
|
||||||
|
{ id: 'delete', label: 'Delete', minWidth: 80 },
|
||||||
|
];
|
||||||
|
|
||||||
interface Column {
|
interface Column {
|
||||||
id: 'runStatus' | 'name' | 'startedAt' | 'finishedAt' | 'delete' | 'settings';
|
id: 'runStatus' | 'name' | 'startedAt' | 'finishedAt' | 'delete' | 'settings';
|
||||||
@@ -24,15 +36,6 @@ interface Column {
|
|||||||
format?: (value: string) => string;
|
format?: (value: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const columns: readonly Column[] = [
|
|
||||||
{ id: 'runStatus', label: 'Status', minWidth: 80 },
|
|
||||||
{ id: 'name', label: 'Robot Name', minWidth: 80 },
|
|
||||||
{ id: 'startedAt', label: 'Started at', minWidth: 80 },
|
|
||||||
{ id: 'finishedAt', label: 'Finished at', minWidth: 80 },
|
|
||||||
{ id: 'settings', label: 'Settings', minWidth: 80 },
|
|
||||||
{ id: 'delete', label: 'Delete', minWidth: 80 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface Data {
|
export interface Data {
|
||||||
id: number;
|
id: number;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -58,15 +61,25 @@ interface RunsTableProps {
|
|||||||
runningRecordingName: string;
|
runningRecordingName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RunsTable = (
|
export const RunsTable: React.FC<RunsTableProps> = ({
|
||||||
{ currentInterpretationLog, abortRunHandler, runId, runningRecordingName }: RunsTableProps) => {
|
currentInterpretationLog,
|
||||||
|
abortRunHandler,
|
||||||
|
runId,
|
||||||
|
runningRecordingName
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Update column labels using translation if needed
|
||||||
|
const translatedColumns = columns.map(column => ({
|
||||||
|
...column,
|
||||||
|
label: t(`runstable.${column.id}`, column.label)
|
||||||
|
}));
|
||||||
|
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||||
const [rows, setRows] = useState<Data[]>([]);
|
const [rows, setRows] = useState<Data[]>([]);
|
||||||
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
|
||||||
const { notify, rerenderRuns, setRerenderRuns } = useGlobalInfoStore();
|
const { notify, rerenderRuns, setRerenderRuns } = useGlobalInfoStore();
|
||||||
|
|
||||||
const handleChangePage = (event: unknown, newPage: number) => {
|
const handleChangePage = (event: unknown, newPage: number) => {
|
||||||
@@ -86,16 +99,13 @@ export const RunsTable = (
|
|||||||
const fetchRuns = async () => {
|
const fetchRuns = async () => {
|
||||||
const runs = await getStoredRuns();
|
const runs = await getStoredRuns();
|
||||||
if (runs) {
|
if (runs) {
|
||||||
const parsedRows: Data[] = [];
|
const parsedRows: Data[] = runs.map((run: any, index: number) => ({
|
||||||
runs.map((run: any, index) => {
|
id: index,
|
||||||
parsedRows.push({
|
...run,
|
||||||
id: index,
|
}));
|
||||||
...run,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
setRows(parsedRows);
|
setRows(parsedRows);
|
||||||
} else {
|
} else {
|
||||||
notify('error', 'No runs found. Please try again.')
|
notify('error', t('runstable.notifications.no_runs'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -104,15 +114,14 @@ export const RunsTable = (
|
|||||||
fetchRuns();
|
fetchRuns();
|
||||||
setRerenderRuns(false);
|
setRerenderRuns(false);
|
||||||
}
|
}
|
||||||
}, [rerenderRuns]);
|
}, [rerenderRuns, rows.length, setRerenderRuns]);
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
setRows([]);
|
setRows([]);
|
||||||
notify('success', 'Run deleted successfully');
|
notify('success', t('runstable.notifications.delete_success'));
|
||||||
fetchRuns();
|
fetchRuns();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Filter rows based on search term
|
// Filter rows based on search term
|
||||||
const filteredRows = rows.filter((row) =>
|
const filteredRows = rows.filter((row) =>
|
||||||
row.name.toLowerCase().includes(searchTerm.toLowerCase())
|
row.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
@@ -120,7 +129,6 @@ export const RunsTable = (
|
|||||||
|
|
||||||
// Group filtered rows by robot meta id
|
// Group filtered rows by robot meta id
|
||||||
const groupedRows = filteredRows.reduce((acc, row) => {
|
const groupedRows = filteredRows.reduce((acc, row) => {
|
||||||
|
|
||||||
if (!acc[row.robotMetaId]) {
|
if (!acc[row.robotMetaId]) {
|
||||||
acc[row.robotMetaId] = [];
|
acc[row.robotMetaId] = [];
|
||||||
}
|
}
|
||||||
@@ -132,11 +140,11 @@ export const RunsTable = (
|
|||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
All Runs
|
{t('runstable.runs', 'Runs')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
placeholder="Search runs..."
|
placeholder={t('runstable.search', 'Search runs...')}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={handleSearchChange}
|
onChange={handleSearchChange}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
@@ -149,16 +157,14 @@ export const RunsTable = (
|
|||||||
{Object.entries(groupedRows).map(([id, data]) => (
|
{Object.entries(groupedRows).map(([id, data]) => (
|
||||||
<Accordion key={id}>
|
<Accordion key={id}>
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
|
|
||||||
<Typography variant="h6">{data[data.length - 1].name}</Typography>
|
<Typography variant="h6">{data[data.length - 1].name}</Typography>
|
||||||
|
|
||||||
</AccordionSummary>
|
</AccordionSummary>
|
||||||
<AccordionDetails>
|
<AccordionDetails>
|
||||||
<Table stickyHeader aria-label="sticky table">
|
<Table stickyHeader aria-label="sticky table">
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell />
|
<TableCell />
|
||||||
{columns.map((column) => (
|
{translatedColumns.map((column) => (
|
||||||
<TableCell
|
<TableCell
|
||||||
key={column.id}
|
key={column.id}
|
||||||
align={column.align}
|
align={column.align}
|
||||||
@@ -200,4 +206,4 @@ export const RunsTable = (
|
|||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -9,13 +9,14 @@ import { TextField, Typography } from "@mui/material";
|
|||||||
import { WarningText } from "../atoms/texts";
|
import { WarningText } from "../atoms/texts";
|
||||||
import NotificationImportantIcon from "@mui/icons-material/NotificationImportant";
|
import NotificationImportantIcon from "@mui/icons-material/NotificationImportant";
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface SaveRecordingProps {
|
interface SaveRecordingProps {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SaveRecording = ({ fileName }: SaveRecordingProps) => {
|
export const SaveRecording = ({ fileName }: SaveRecordingProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [openModal, setOpenModal] = useState<boolean>(false);
|
const [openModal, setOpenModal] = useState<boolean>(false);
|
||||||
const [needConfirm, setNeedConfirm] = useState<boolean>(false);
|
const [needConfirm, setNeedConfirm] = useState<boolean>(false);
|
||||||
const [recordingName, setRecordingName] = useState<string>(fileName);
|
const [recordingName, setRecordingName] = useState<string>(fileName);
|
||||||
@@ -46,7 +47,7 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const exitRecording = useCallback(async () => {
|
const exitRecording = useCallback(async () => {
|
||||||
notify('success', 'Robot saved successfully');
|
notify('success', t('save_recording.notifications.save_success'));
|
||||||
if (browserId) {
|
if (browserId) {
|
||||||
await stopRecording(browserId);
|
await stopRecording(browserId);
|
||||||
}
|
}
|
||||||
@@ -63,7 +64,7 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => {
|
|||||||
setWaitingForSave(true);
|
setWaitingForSave(true);
|
||||||
console.log(`Saving the recording as ${recordingName} for userId ${user.id}`);
|
console.log(`Saving the recording as ${recordingName} for userId ${user.id}`);
|
||||||
} else {
|
} else {
|
||||||
console.error('User not logged in. Cannot save recording.');
|
console.error(t('save_recording.notifications.user_not_logged'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -76,35 +77,42 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Button onClick={() => setOpenModal(true)} variant='contained' sx={{ marginRight: '20px',backgroundColor: '#ff00c3',color: 'white' }} size="small" color="success">
|
<!-- <Button onClick={() => setOpenModal(true)} variant='contained' sx={{ marginRight: '20px',backgroundColor: '#ff00c3',color: 'white' }} size="small" color="success">
|
||||||
Finish
|
Finish -->
|
||||||
|
|
||||||
|
<Button onClick={() => setOpenModal(true)} variant="outlined" sx={{ marginRight: '20px' }} size="small" color="success">
|
||||||
|
{t('right_panel.buttons.finish')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<GenericModal isOpen={openModal} onClose={() => setOpenModal(false)} modalStyle={modalStyle}>
|
<GenericModal isOpen={openModal} onClose={() => setOpenModal(false)} modalStyle={modalStyle}>
|
||||||
<form onSubmit={handleSaveRecording} style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
|
<form onSubmit={handleSaveRecording} style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||||
<Typography variant="h6">Save Robot</Typography>
|
<Typography variant="h6">{t('save_recording.title')}</Typography>
|
||||||
<TextField
|
<TextField
|
||||||
required
|
required
|
||||||
sx={{ width: '300px', margin: '15px 0px' }}
|
sx={{ width: '300px', margin: '15px 0px' }}
|
||||||
onChange={handleChangeOfTitle}
|
onChange={handleChangeOfTitle}
|
||||||
id="title"
|
id="title"
|
||||||
label="Robot Name"
|
label={t('save_recording.robot_name')}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
defaultValue={recordingName ? recordingName : null}
|
defaultValue={recordingName ? recordingName : null}
|
||||||
/>
|
/>
|
||||||
{needConfirm
|
{needConfirm
|
||||||
?
|
?
|
||||||
(<React.Fragment>
|
(<React.Fragment>
|
||||||
<Button color="error" variant="contained" onClick={saveRecording} sx={{ marginTop: '10px' }}>Confirm</Button>
|
<Button color="error" variant="contained" onClick={saveRecording} sx={{ marginTop: '10px' }}>
|
||||||
|
{t('save_recording.buttons.confirm')}
|
||||||
|
</Button>
|
||||||
<WarningText>
|
<WarningText>
|
||||||
<NotificationImportantIcon color="warning" />
|
<NotificationImportantIcon color="warning" />
|
||||||
Robot with this name already exists, please confirm the Robot's overwrite.
|
{t('save_recording.errors.exists_warning')}
|
||||||
</WarningText>
|
</WarningText>
|
||||||
</React.Fragment>)
|
</React.Fragment>)
|
||||||
: <Button type="submit" variant="contained" sx={{ marginTop: '10px' }}>Save</Button>
|
: <Button type="submit" variant="contained" sx={{ marginTop: '10px' }}>
|
||||||
|
{t('save_recording.buttons.save')}
|
||||||
|
</Button>
|
||||||
}
|
}
|
||||||
{waitingForSave &&
|
{waitingForSave &&
|
||||||
<Tooltip title='Optimizing and saving the workflow' placement={"bottom"}>
|
<Tooltip title={t('save_recording.tooltips.optimizing')} placement={"bottom"}>
|
||||||
<Box sx={{ width: '100%', marginTop: '10px' }}>
|
<Box sx={{ width: '100%', marginTop: '10px' }}>
|
||||||
<LinearProgress />
|
<LinearProgress />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { GenericModal } from "../atoms/GenericModal";
|
import { GenericModal } from "../atoms/GenericModal";
|
||||||
import { MenuItem, TextField, Typography, Box } from "@mui/material";
|
import { MenuItem, TextField, Typography, Box } from "@mui/material";
|
||||||
import { Dropdown } from "../atoms/DropdownMui";
|
import { Dropdown } from "../atoms/DropdownMui";
|
||||||
@@ -25,6 +26,7 @@ export interface ScheduleSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initialSettings }: ScheduleSettingsProps) => {
|
export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initialSettings }: ScheduleSettingsProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [schedule, setSchedule] = useState<ScheduleSettings | null>(null);
|
const [schedule, setSchedule] = useState<ScheduleSettings | null>(null);
|
||||||
const [settings, setSettings] = useState<ScheduleSettings>({
|
const [settings, setSettings] = useState<ScheduleSettings>({
|
||||||
runEvery: 1,
|
runEvery: 1,
|
||||||
@@ -77,12 +79,13 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
|
|||||||
'SUNDAY'
|
'SUNDAY'
|
||||||
];
|
];
|
||||||
|
|
||||||
const { recordingId } = useGlobalInfoStore();
|
const { recordingId, notify } = useGlobalInfoStore();
|
||||||
|
|
||||||
const deleteRobotSchedule = () => {
|
const deleteRobotSchedule = () => {
|
||||||
if (recordingId) {
|
if (recordingId) {
|
||||||
deleteSchedule(recordingId);
|
deleteSchedule(recordingId);
|
||||||
setSchedule(null);
|
setSchedule(null);
|
||||||
|
notify('success', t('Schedule deleted successfully'));
|
||||||
} else {
|
} else {
|
||||||
console.error('No recording id provided');
|
console.error('No recording id provided');
|
||||||
}
|
}
|
||||||
@@ -116,6 +119,25 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
|
|||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const getDayOrdinal = (day: string | undefined) => {
|
||||||
|
if (!day) return '';
|
||||||
|
const lastDigit = day.slice(-1);
|
||||||
|
const lastTwoDigits = day.slice(-2);
|
||||||
|
|
||||||
|
// Special cases for 11, 12, 13
|
||||||
|
if (['11', '12', '13'].includes(lastTwoDigits)) {
|
||||||
|
return t('schedule_settings.labels.on_day.th');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other cases
|
||||||
|
switch (lastDigit) {
|
||||||
|
case '1': return t('schedule_settings.labels.on_day.st');
|
||||||
|
case '2': return t('schedule_settings.labels.on_day.nd');
|
||||||
|
case '3': return t('schedule_settings.labels.on_day.rd');
|
||||||
|
default: return t('schedule_settings.labels.on_day.th');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GenericModal
|
<GenericModal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
@@ -129,30 +151,30 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
|
|||||||
padding: '20px',
|
padding: '20px',
|
||||||
'& > *': { marginBottom: '20px' },
|
'& > *': { marginBottom: '20px' },
|
||||||
}}>
|
}}>
|
||||||
<Typography variant="h6" sx={{ marginBottom: '20px' }}>Schedule Settings</Typography>
|
<Typography variant="h6" sx={{ marginBottom: '20px' }}>{t('schedule_settings.title')}</Typography>
|
||||||
<>
|
<>
|
||||||
{schedule !== null ? (
|
{schedule !== null ? (
|
||||||
<>
|
<>
|
||||||
<Typography>Run every: {schedule.runEvery} {schedule.runEveryUnit.toLowerCase()}</Typography>
|
<Typography>{t('schedule_settings.run_every')}: {schedule.runEvery} {schedule.runEveryUnit.toLowerCase()}</Typography>
|
||||||
<Typography>{['MONTHS', 'WEEKS'].includes(settings.runEveryUnit) ? "Start From" : "On"} {schedule.startFrom.charAt(0).toUpperCase() + schedule.startFrom.slice(1).toLowerCase()}</Typography>
|
<Typography>{['MONTHS', 'WEEKS'].includes(settings.runEveryUnit) ? t('schedule_settings.start_from') : t('schedule_settings.start_from')}: {schedule.startFrom.charAt(0).toUpperCase() + schedule.startFrom.slice(1).toLowerCase()}</Typography>
|
||||||
{schedule.runEveryUnit === 'MONTHS' && (
|
{schedule.runEveryUnit === 'MONTHS' && (
|
||||||
<Typography>On day: {schedule.dayOfMonth}{['1', '21', '31'].includes(schedule.dayOfMonth || '') ? 'st' : ['2', '22'].includes(schedule.dayOfMonth || '') ? 'nd' : ['3', '23'].includes(schedule.dayOfMonth || '') ? 'rd' : 'th'} of the month</Typography>
|
<Typography>{t('schedule_settings.on_day')}: {schedule.dayOfMonth}{getDayOrdinal(schedule.dayOfMonth)} of the month</Typography>
|
||||||
)}
|
)}
|
||||||
<Typography>At around: {schedule.atTimeStart}, {schedule.timezone} Timezone</Typography>
|
<Typography>{t('schedule_settings.at_around')}: {schedule.atTimeStart}, {schedule.timezone} {t('schedule_settings.timezone')}</Typography>
|
||||||
<Box mt={2} display="flex" justifyContent="space-between">
|
<Box mt={2} display="flex" justifyContent="space-between">
|
||||||
<Button
|
<Button
|
||||||
onClick={deleteRobotSchedule}
|
onClick={deleteRobotSchedule}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="error"
|
color="error"
|
||||||
>
|
>
|
||||||
Delete Schedule
|
{t('schedule_settings.buttons.delete_schedule')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
||||||
<Typography sx={{ marginRight: '10px' }}>Run once every</Typography>
|
<Typography sx={{ marginRight: '10px' }}>{t('schedule_settings.labels.run_once_every')}</Typography>
|
||||||
<TextField
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
value={settings.runEvery}
|
value={settings.runEvery}
|
||||||
@@ -174,7 +196,9 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
||||||
<Typography sx={{ marginBottom: '5px', marginRight: '25px' }}>{['MONTHS', 'WEEKS'].includes(settings.runEveryUnit) ? "Start From" : "On"}</Typography>
|
<Typography sx={{ marginBottom: '5px', marginRight: '25px' }}>
|
||||||
|
{['MONTHS', 'WEEKS'].includes(settings.runEveryUnit) ? t('schedule_settings.labels.start_from_label') : t('schedule_settings.labels.start_from_label')}
|
||||||
|
</Typography>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
label=""
|
label=""
|
||||||
id="startFrom"
|
id="startFrom"
|
||||||
@@ -190,7 +214,7 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
|
|||||||
|
|
||||||
{settings.runEveryUnit === 'MONTHS' && (
|
{settings.runEveryUnit === 'MONTHS' && (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
||||||
<Typography sx={{ marginBottom: '5px', marginRight: '25px' }}>On Day of the Month</Typography>
|
<Typography sx={{ marginBottom: '5px', marginRight: '25px' }}>{t('schedule_settings.labels.on_day_of_month')}</Typography>
|
||||||
<TextField
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
value={settings.dayOfMonth}
|
value={settings.dayOfMonth}
|
||||||
@@ -204,7 +228,7 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
|
|||||||
{['MINUTES', 'HOURS'].includes(settings.runEveryUnit) ? (
|
{['MINUTES', 'HOURS'].includes(settings.runEveryUnit) ? (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
||||||
<Box sx={{ marginRight: '20px' }}>
|
<Box sx={{ marginRight: '20px' }}>
|
||||||
<Typography sx={{ marginBottom: '5px' }}>In Between</Typography>
|
<Typography sx={{ marginBottom: '5px' }}>{t('schedule_settings.labels.in_between')}</Typography>
|
||||||
<TextField
|
<TextField
|
||||||
type="time"
|
type="time"
|
||||||
value={settings.atTimeStart}
|
value={settings.atTimeStart}
|
||||||
@@ -221,7 +245,7 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
|
|||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
||||||
<Typography sx={{ marginBottom: '5px', marginRight: '10px' }}>At Around</Typography>
|
<Typography sx={{ marginBottom: '5px', marginRight: '10px' }}>{t('schedule_settings.at_around')}</Typography>
|
||||||
<TextField
|
<TextField
|
||||||
type="time"
|
type="time"
|
||||||
value={settings.atTimeStart}
|
value={settings.atTimeStart}
|
||||||
@@ -232,7 +256,7 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
||||||
<Typography sx={{ marginRight: '10px' }}>Timezone</Typography>
|
<Typography sx={{ marginRight: '10px' }}>{t('schedule_settings.timezone')}</Typography>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
label=""
|
label=""
|
||||||
id="timezone"
|
id="timezone"
|
||||||
@@ -247,10 +271,10 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
|
|||||||
</Box>
|
</Box>
|
||||||
<Box mt={2} display="flex" justifyContent="flex-end">
|
<Box mt={2} display="flex" justifyContent="flex-end">
|
||||||
<Button onClick={() => handleStart(settings)} variant="contained" color="primary">
|
<Button onClick={() => handleStart(settings)} variant="contained" color="primary">
|
||||||
Save Schedule
|
{t('schedule_settings.buttons.save_schedule')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleClose} color="primary" variant="outlined" style={{ marginLeft: '10px' }}>
|
<Button onClick={handleClose} color="primary" variant="outlined" style={{ marginLeft: '10px' }}>
|
||||||
Cancel
|
{t('schedule_settings.buttons.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
@@ -271,4 +295,4 @@ const modalStyle = {
|
|||||||
height: 'fit-content',
|
height: 'fit-content',
|
||||||
display: 'block',
|
display: 'block',
|
||||||
padding: '20px',
|
padding: '20px',
|
||||||
};
|
};
|
||||||
@@ -19,6 +19,7 @@ import styled from 'styled-components';
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useGlobalInfoStore } from '../../context/globalInfo';
|
import { useGlobalInfoStore } from '../../context/globalInfo';
|
||||||
import { apiUrl } from '../../apiConfig';
|
import { apiUrl } from '../../apiConfig';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const Container = styled(Box)`
|
const Container = styled(Box)`
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -29,24 +30,21 @@ const Container = styled(Box)`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const ApiKeyManager = () => {
|
const ApiKeyManager = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [apiKey, setApiKey] = useState<string | null>(null);
|
const [apiKey, setApiKey] = useState<string | null>(null);
|
||||||
const [apiKeyName, setApiKeyName] = useState<string>('Maxun API Key');
|
const [apiKeyName, setApiKeyName] = useState<string>(t('apikey.default_name'));
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [showKey, setShowKey] = useState<boolean>(false);
|
const [showKey, setShowKey] = useState<boolean>(false);
|
||||||
const [copySuccess, setCopySuccess] = useState<boolean>(false);
|
const [copySuccess, setCopySuccess] = useState<boolean>(false);
|
||||||
const { notify } = useGlobalInfoStore();
|
const { notify } = useGlobalInfoStore();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchApiKey = async () => {
|
const fetchApiKey = async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get(`${apiUrl}/auth/api-key`);
|
const { data } = await axios.get(`${apiUrl}/auth/api-key`);
|
||||||
setApiKey(data.api_key);
|
setApiKey(data.api_key);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
notify('error', `Failed to fetch API Key - ${error.message}`);
|
notify('error', t('apikey.notifications.fetch_error', { error: error.message }));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -62,9 +60,9 @@ const ApiKeyManager = () => {
|
|||||||
const { data } = await axios.post(`${apiUrl}/auth/generate-api-key`);
|
const { data } = await axios.post(`${apiUrl}/auth/generate-api-key`);
|
||||||
setApiKey(data.api_key);
|
setApiKey(data.api_key);
|
||||||
|
|
||||||
notify('success', `Generated API Key successfully`);
|
notify('success', t('apikey.notifications.generate_success'));
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
notify('error', `Failed to generate API Key - ${error.message}`);
|
notify('error', t('apikey.notifications.generate_error', { error: error.message }));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -75,9 +73,9 @@ const ApiKeyManager = () => {
|
|||||||
try {
|
try {
|
||||||
await axios.delete(`${apiUrl}/auth/delete-api-key`);
|
await axios.delete(`${apiUrl}/auth/delete-api-key`);
|
||||||
setApiKey(null);
|
setApiKey(null);
|
||||||
notify('success', 'API Key deleted successfully');
|
notify('success', t('apikey.notifications.delete_success'));
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
notify('error', `Failed to delete API Key - ${error.message}`);
|
notify('error', t('apikey.notifications.delete_error', { error: error.message }));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -88,7 +86,7 @@ const ApiKeyManager = () => {
|
|||||||
navigator.clipboard.writeText(apiKey);
|
navigator.clipboard.writeText(apiKey);
|
||||||
setCopySuccess(true);
|
setCopySuccess(true);
|
||||||
setTimeout(() => setCopySuccess(false), 2000);
|
setTimeout(() => setCopySuccess(false), 2000);
|
||||||
notify('info', 'Copied API Key successfully');
|
notify('info', t('apikey.notifications.copy_success'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -111,34 +109,38 @@ const ApiKeyManager = () => {
|
|||||||
return (
|
return (
|
||||||
<Container sx={{ alignSelf: 'flex-start' }}>
|
<Container sx={{ alignSelf: 'flex-start' }}>
|
||||||
<Typography variant="h6" gutterBottom component="div" style={{ marginBottom: '20px' }}>
|
<Typography variant="h6" gutterBottom component="div" style={{ marginBottom: '20px' }}>
|
||||||
Manage Your API Key
|
{t('apikey.title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
{apiKey ? (
|
{apiKey ? (
|
||||||
<TableContainer component={Paper} sx={{ width: '100%', overflow: 'hidden' }}>
|
<TableContainer component={Paper} sx={{ width: '100%', overflow: 'hidden' }}>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>API Key Name</TableCell>
|
<TableCell>{t('apikey.table.name')}</TableCell>
|
||||||
<TableCell>API Key</TableCell>
|
<TableCell>{t('apikey.table.key')}</TableCell>
|
||||||
<TableCell>Actions</TableCell>
|
<TableCell>{t('apikey.table.actions')}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>{apiKeyName}</TableCell>
|
<TableCell>{apiKeyName}</TableCell>
|
||||||
<TableCell>{showKey ? `${apiKey?.substring(0, 10)}...` : '***************'}</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Tooltip title="Copy">
|
<Box sx={{ fontFamily: 'monospace', width: '10ch' }}>
|
||||||
|
{showKey ? `${apiKey?.substring(0, 10)}...` : '**********'}
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Tooltip title={t('apikey.actions.copy')}>
|
||||||
<IconButton onClick={copyToClipboard}>
|
<IconButton onClick={copyToClipboard}>
|
||||||
<ContentCopy />
|
<ContentCopy />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={showKey ? 'Hide' : 'Show'}>
|
<Tooltip title={showKey ? t('apikey.actions.hide') : t('apikey.actions.show')}>
|
||||||
<IconButton onClick={() => setShowKey(!showKey)}>
|
<IconButton onClick={() => setShowKey(!showKey)}>
|
||||||
<Visibility />
|
<Visibility />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="Delete">
|
<Tooltip title={t('apikey.actions.delete')}>
|
||||||
<IconButton onClick={deleteApiKey} color="error">
|
<IconButton onClick={deleteApiKey} color="error">
|
||||||
<Delete />
|
<Delete />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -150,9 +152,9 @@ const ApiKeyManager = () => {
|
|||||||
</TableContainer>
|
</TableContainer>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Typography>You haven't generated an API key yet.</Typography>
|
<Typography>{t('apikey.no_key_message')}</Typography>
|
||||||
<Button onClick={generateApiKey} variant="contained" color="primary" sx={{ marginTop: '15px' }}>
|
<Button onClick={generateApiKey} variant="contained" color="primary" sx={{ marginTop: '15px' }}>
|
||||||
Generate API Key
|
{t('apikey.generate_button')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import { GenericModal } from '../atoms/GenericModal';
|
|||||||
import { useActionContext } from '../../context/browserActions';
|
import { useActionContext } from '../../context/browserActions';
|
||||||
import { useBrowserSteps, TextStep } from '../../context/browserSteps';
|
import { useBrowserSteps, TextStep } from '../../context/browserSteps';
|
||||||
import { useGlobalInfoStore } from '../../context/globalInfo';
|
import { useGlobalInfoStore } from '../../context/globalInfo';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface ElementInfo {
|
interface ElementInfo {
|
||||||
tagName: string;
|
tagName: string;
|
||||||
hasOnlyText?: boolean;
|
hasOnlyText?: boolean;
|
||||||
|
isIframeContent?: boolean;
|
||||||
|
isShadowRoot?: boolean;
|
||||||
innerText?: string;
|
innerText?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
@@ -52,6 +54,7 @@ const getAttributeOptions = (tagName: string, elementInfo: ElementInfo | null):
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const BrowserWindow = () => {
|
export const BrowserWindow = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [canvasRef, setCanvasReference] = useState<React.RefObject<HTMLCanvasElement> | undefined>(undefined);
|
const [canvasRef, setCanvasReference] = useState<React.RefObject<HTMLCanvasElement> | undefined>(undefined);
|
||||||
const [screenShot, setScreenShot] = useState<string>("");
|
const [screenShot, setScreenShot] = useState<string>("");
|
||||||
const [highlighterData, setHighlighterData] = useState<{ rect: DOMRect, selector: string, elementInfo: ElementInfo | null, childSelectors?: string[] } | null>(null);
|
const [highlighterData, setHighlighterData] = useState<{ rect: DOMRect, selector: string, elementInfo: ElementInfo | null, childSelectors?: string[] } | null>(null);
|
||||||
@@ -66,7 +69,7 @@ export const BrowserWindow = () => {
|
|||||||
|
|
||||||
const { socket } = useSocketStore();
|
const { socket } = useSocketStore();
|
||||||
const { notify } = useGlobalInfoStore();
|
const { notify } = useGlobalInfoStore();
|
||||||
const { getText, getList, paginationMode, paginationType, limitMode } = useActionContext();
|
const { getText, getList, paginationMode, paginationType, limitMode, captureStage } = useActionContext();
|
||||||
const { addTextStep, addListStep } = useBrowserSteps();
|
const { addTextStep, addListStep } = useBrowserSteps();
|
||||||
|
|
||||||
const onMouseMove = (e: MouseEvent) => {
|
const onMouseMove = (e: MouseEvent) => {
|
||||||
@@ -115,34 +118,81 @@ export const BrowserWindow = () => {
|
|||||||
}, [screenShot, canvasRef, socket, screencastHandler]);
|
}, [screenShot, canvasRef, socket, screencastHandler]);
|
||||||
|
|
||||||
const highlighterHandler = useCallback((data: { rect: DOMRect, selector: string, elementInfo: ElementInfo | null, childSelectors?: string[] }) => {
|
const highlighterHandler = useCallback((data: { rect: DOMRect, selector: string, elementInfo: ElementInfo | null, childSelectors?: string[] }) => {
|
||||||
|
console.log("LIST SELECTOR", listSelector);
|
||||||
|
console.log("DATA SELECTOR", data.selector);
|
||||||
|
console.log("CHILD SELECTORS", data.childSelectors);
|
||||||
if (getList === true) {
|
if (getList === true) {
|
||||||
if (listSelector) {
|
if (listSelector) {
|
||||||
socket?.emit('listSelector', { selector: listSelector });
|
socket?.emit('listSelector', { selector: listSelector });
|
||||||
|
const hasValidChildSelectors = Array.isArray(data.childSelectors) && data.childSelectors.length > 0;
|
||||||
|
|
||||||
if (limitMode) {
|
if (limitMode) {
|
||||||
setHighlighterData(null);
|
setHighlighterData(null);
|
||||||
} else if (paginationMode) {
|
} else if (paginationMode) {
|
||||||
// only set highlighterData if type is not empty, 'none', 'scrollDown', or 'scrollUp'
|
// Only set highlighterData if type is not empty, 'none', 'scrollDown', or 'scrollUp'
|
||||||
if (paginationType !== '' && !['none', 'scrollDown', 'scrollUp'].includes(paginationType)) {
|
if (paginationType !== '' && !['none', 'scrollDown', 'scrollUp'].includes(paginationType)) {
|
||||||
setHighlighterData(data);
|
setHighlighterData(data);
|
||||||
} else {
|
} else {
|
||||||
setHighlighterData(null);
|
setHighlighterData(null);
|
||||||
}
|
}
|
||||||
} else if (data.childSelectors && data.childSelectors.includes(data.selector)) {
|
} else if (data.childSelectors && data.childSelectors.includes(data.selector)) {
|
||||||
// highlight only valid child elements within the listSelector
|
// Highlight only valid child elements within the listSelector
|
||||||
setHighlighterData(data);
|
setHighlighterData(data);
|
||||||
} else {
|
} else if (data.elementInfo?.isIframeContent && data.childSelectors) {
|
||||||
|
// Handle pure iframe elements - similar to previous shadow DOM logic but using iframe syntax
|
||||||
|
// Check if the selector matches any iframe child selectors
|
||||||
|
const isIframeChild = data.childSelectors.some(childSelector =>
|
||||||
|
data.selector.includes(':>>') && // Iframe uses :>> for traversal
|
||||||
|
childSelector.split(':>>').some(part =>
|
||||||
|
data.selector.includes(part.trim())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setHighlighterData(isIframeChild ? data : null);
|
||||||
|
} else if (data.selector.includes(':>>') && hasValidChildSelectors) {
|
||||||
|
// Handle mixed DOM cases with iframes
|
||||||
|
// Split the selector into parts and check each against child selectors
|
||||||
|
const selectorParts = data.selector.split(':>>').map(part => part.trim());
|
||||||
|
const isValidMixedSelector = selectorParts.some(part =>
|
||||||
|
// We know data.childSelectors is defined due to hasValidChildSelectors check
|
||||||
|
data.childSelectors!.some(childSelector =>
|
||||||
|
childSelector.includes(part)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setHighlighterData(isValidMixedSelector ? data : null);
|
||||||
|
} else if (data.elementInfo?.isShadowRoot && data.childSelectors) {
|
||||||
|
// New case: Handle pure Shadow DOM elements
|
||||||
|
// Check if the selector matches any shadow root child selectors
|
||||||
|
const isShadowChild = data.childSelectors.some(childSelector =>
|
||||||
|
data.selector.includes('>>') && // Shadow DOM uses >> for piercing
|
||||||
|
childSelector.split('>>').some(part =>
|
||||||
|
data.selector.includes(part.trim())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setHighlighterData(isShadowChild ? data : null);
|
||||||
|
} else if (data.selector.includes('>>') && hasValidChildSelectors) {
|
||||||
|
// New case: Handle mixed DOM cases
|
||||||
|
// Split the selector into parts and check each against child selectors
|
||||||
|
const selectorParts = data.selector.split('>>').map(part => part.trim());
|
||||||
|
const isValidMixedSelector = selectorParts.some(part =>
|
||||||
|
// Now we know data.childSelectors is defined
|
||||||
|
data.childSelectors!.some(childSelector =>
|
||||||
|
childSelector.includes(part)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setHighlighterData(isValidMixedSelector ? data : null);
|
||||||
|
} else {
|
||||||
// if !valid child in normal mode, clear the highlighter
|
// if !valid child in normal mode, clear the highlighter
|
||||||
setHighlighterData(null);
|
setHighlighterData(null);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// set highlighterData for the initial listSelector selection
|
// Set highlighterData for the initial listSelector selection
|
||||||
setHighlighterData(data);
|
setHighlighterData(data);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// for non-list steps
|
// For non-list steps
|
||||||
setHighlighterData(data);
|
setHighlighterData(data);
|
||||||
}
|
}
|
||||||
}, [highlighterData, getList, socket, listSelector, paginationMode, paginationType]);
|
}, [highlighterData, getList, socket, listSelector, paginationMode, paginationType, captureStage]);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -156,6 +206,13 @@ export const BrowserWindow = () => {
|
|||||||
};
|
};
|
||||||
}, [socket, onMouseMove]);
|
}, [socket, onMouseMove]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (captureStage === 'initial' && listSelector) {
|
||||||
|
socket?.emit('setGetList', { getList: true });
|
||||||
|
socket?.emit('listSelector', { selector: listSelector });
|
||||||
|
}
|
||||||
|
}, [captureStage, listSelector, socket]);
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
if (highlighterData && canvasRef?.current) {
|
if (highlighterData && canvasRef?.current) {
|
||||||
const canvasRect = canvasRef.current.getBoundingClientRect();
|
const canvasRect = canvasRef.current.getBoundingClientRect();
|
||||||
@@ -183,6 +240,7 @@ export const BrowserWindow = () => {
|
|||||||
addTextStep('', data, {
|
addTextStep('', data, {
|
||||||
selector: highlighterData.selector,
|
selector: highlighterData.selector,
|
||||||
tag: highlighterData.elementInfo?.tagName,
|
tag: highlighterData.elementInfo?.tagName,
|
||||||
|
shadow: highlighterData.elementInfo?.isShadowRoot,
|
||||||
attribute
|
attribute
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -190,7 +248,7 @@ export const BrowserWindow = () => {
|
|||||||
setAttributeOptions(options);
|
setAttributeOptions(options);
|
||||||
setSelectedElement({
|
setSelectedElement({
|
||||||
selector: highlighterData.selector,
|
selector: highlighterData.selector,
|
||||||
info: highlighterData.elementInfo
|
info: highlighterData.elementInfo,
|
||||||
});
|
});
|
||||||
setShowAttributeModal(true);
|
setShowAttributeModal(true);
|
||||||
}
|
}
|
||||||
@@ -200,7 +258,7 @@ export const BrowserWindow = () => {
|
|||||||
// Only allow selection in pagination mode if type is not empty, 'scrollDown', or 'scrollUp'
|
// Only allow selection in pagination mode if type is not empty, 'scrollDown', or 'scrollUp'
|
||||||
if (paginationType !== '' && paginationType !== 'scrollDown' && paginationType !== 'scrollUp' && paginationType !== 'none') {
|
if (paginationType !== '' && paginationType !== 'scrollDown' && paginationType !== 'scrollUp' && paginationType !== 'none') {
|
||||||
setPaginationSelector(highlighterData.selector);
|
setPaginationSelector(highlighterData.selector);
|
||||||
notify(`info`, `Pagination element selected successfully.`);
|
notify(`info`, t('browser_window.attribute_modal.notifications.pagination_select_success'));
|
||||||
addListStep(listSelector!, fields, currentListId || 0, { type: paginationType, selector: highlighterData.selector });
|
addListStep(listSelector!, fields, currentListId || 0, { type: paginationType, selector: highlighterData.selector });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -208,7 +266,7 @@ export const BrowserWindow = () => {
|
|||||||
|
|
||||||
if (getList === true && !listSelector) {
|
if (getList === true && !listSelector) {
|
||||||
setListSelector(highlighterData.selector);
|
setListSelector(highlighterData.selector);
|
||||||
notify(`info`, `List selected succesfully. Select the text data for extraction.`)
|
notify(`info`, t('browser_window.attribute_modal.notifications.list_select_success'));
|
||||||
setCurrentListId(Date.now());
|
setCurrentListId(Date.now());
|
||||||
setFields({});
|
setFields({});
|
||||||
} else if (getList === true && listSelector && currentListId) {
|
} else if (getList === true && listSelector && currentListId) {
|
||||||
@@ -227,6 +285,7 @@ export const BrowserWindow = () => {
|
|||||||
selectorObj: {
|
selectorObj: {
|
||||||
selector: highlighterData.selector,
|
selector: highlighterData.selector,
|
||||||
tag: highlighterData.elementInfo?.tagName,
|
tag: highlighterData.elementInfo?.tagName,
|
||||||
|
shadow: highlighterData.elementInfo?.isShadowRoot,
|
||||||
attribute
|
attribute
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -274,6 +333,7 @@ export const BrowserWindow = () => {
|
|||||||
addTextStep('', data, {
|
addTextStep('', data, {
|
||||||
selector: selectedElement.selector,
|
selector: selectedElement.selector,
|
||||||
tag: selectedElement.info?.tagName,
|
tag: selectedElement.info?.tagName,
|
||||||
|
shadow: selectedElement.info?.isShadowRoot,
|
||||||
attribute: attribute
|
attribute: attribute
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -286,6 +346,7 @@ export const BrowserWindow = () => {
|
|||||||
selectorObj: {
|
selectorObj: {
|
||||||
selector: selectedElement.selector,
|
selector: selectedElement.selector,
|
||||||
tag: selectedElement.info?.tagName,
|
tag: selectedElement.info?.tagName,
|
||||||
|
shadow: selectedElement.info?.isShadowRoot,
|
||||||
attribute: attribute
|
attribute: attribute
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -317,7 +378,6 @@ export const BrowserWindow = () => {
|
|||||||
}
|
}
|
||||||
}, [paginationMode, resetPaginationSelector]);
|
}, [paginationMode, resetPaginationSelector]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div onClick={handleClick} style={{ width: '900px', height: "400px" , borderRadius: '8px 8px 0px 0px '}} id="browser-window">
|
<div onClick={handleClick} style={{ width: '900px', height: "400px" , borderRadius: '8px 8px 0px 0px '}} id="browser-window">
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import { AutoAwesome, FormatListBulleted, VpnKey, Usb, Article, CloudQueue,Code,
|
|||||||
|
|
||||||
|
|
||||||
import { apiUrl } from "../../apiConfig";
|
import { apiUrl } from "../../apiConfig";
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import i18n from '../../i18n';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
interface MainMenuProps {
|
interface MainMenuProps {
|
||||||
@@ -17,6 +20,7 @@ interface MainMenuProps {
|
|||||||
|
|
||||||
export const MainMenu = ({ value = 'recordings', handleChangeContent }: MainMenuProps) => {
|
export const MainMenu = ({ value = 'recordings', handleChangeContent }: MainMenuProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const {t} = useTranslation();
|
||||||
|
|
||||||
const handleChange = (event: React.SyntheticEvent, newValue: string) => {
|
const handleChange = (event: React.SyntheticEvent, newValue: string) => {
|
||||||
handleChangeContent(newValue);
|
handleChangeContent(newValue);
|
||||||
@@ -70,28 +74,28 @@ export const MainMenu = ({ value = 'recordings', handleChangeContent }: MainMenu
|
|||||||
>
|
>
|
||||||
<Tab
|
<Tab
|
||||||
value="recordings"
|
value="recordings"
|
||||||
label="Robots"
|
label={t('mainmenu.recordings')}
|
||||||
icon={<AutoAwesome />}
|
icon={<AutoAwesome />}
|
||||||
iconPosition="start"
|
iconPosition="start"
|
||||||
|
|
||||||
/>
|
/>
|
||||||
<Tab
|
<Tab
|
||||||
value="runs"
|
value="runs"
|
||||||
label="Runs"
|
label={t('mainmenu.runs')}
|
||||||
icon={<FormatListBulleted />}
|
icon={<FormatListBulleted />}
|
||||||
iconPosition="start"
|
iconPosition="start"
|
||||||
|
|
||||||
/>
|
/>
|
||||||
<Tab
|
<Tab
|
||||||
value="proxy"
|
value="proxy"
|
||||||
label="Proxy"
|
label={t('mainmenu.proxy')}
|
||||||
icon={<Usb />}
|
icon={<Usb />}
|
||||||
iconPosition="start"
|
iconPosition="start"
|
||||||
|
|
||||||
/>
|
/>
|
||||||
<Tab
|
<Tab
|
||||||
value="apikey"
|
value="apikey"
|
||||||
label="API Key"
|
label={t('mainmenu.apikey')}
|
||||||
icon={<VpnKey />}
|
icon={<VpnKey />}
|
||||||
iconPosition="start"
|
iconPosition="start"
|
||||||
|
|
||||||
@@ -99,13 +103,11 @@ export const MainMenu = ({ value = 'recordings', handleChangeContent }: MainMenu
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
<hr />
|
<hr />
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1rem', textAlign: 'left' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1rem', textAlign: 'left' }}>
|
||||||
|
<Button href={`${apiUrl}/api-docs/`} target="_blank" rel="noopener noreferrer" sx={buttonStyles} startIcon={<Code />}>
|
||||||
<Button href="/api-docs" target="_blank" rel="noopener noreferrer" sx={buttonStyles} startIcon={<Article />}>
|
{t('mainmenu.apidocs')}
|
||||||
API Docs
|
|
||||||
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button href="https://forms.gle/hXjgqDvkEhPcaBW76" target="_blank" rel="noopener noreferrer" sx={buttonStyles} startIcon={<CloudQueue />}>
|
<Button href="https://forms.gle/hXjgqDvkEhPcaBW76" target="_blank" rel="noopener noreferrer" sx={buttonStyles} startIcon={<CloudQueue />}>
|
||||||
Join Maxun Cloud
|
{t('mainmenu.feedback')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import { sendProxyConfig, getProxyConfig, testProxyConfig, deleteProxyConfig } from '../../api/proxy';
|
import { sendProxyConfig, getProxyConfig, testProxyConfig, deleteProxyConfig } from '../../api/proxy';
|
||||||
import { useGlobalInfoStore } from '../../context/globalInfo';
|
import { useGlobalInfoStore } from '../../context/globalInfo';
|
||||||
import { useThemeMode } from '../../context/theme-provider';
|
import { useThemeMode } from '../../context/theme-provider';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
// Custom styled Tabs component
|
// Custom styled Tabs component
|
||||||
const CustomTabs = styled(Tabs)(({ theme }) => ({
|
const CustomTabs = styled(Tabs)(({ theme }) => ({
|
||||||
@@ -44,6 +45,7 @@ const CustomTab = styled(Tab)(({ theme }) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
||||||
const FormContainer = styled(Box)({
|
const FormContainer = styled(Box)({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@@ -56,6 +58,7 @@ const FormControl = styled(Box)({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const ProxyForm: React.FC = () => {
|
const ProxyForm: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [proxyConfigForm, setProxyConfigForm] = useState({
|
const [proxyConfigForm, setProxyConfigForm] = useState({
|
||||||
server_url: '',
|
server_url: '',
|
||||||
username: '',
|
username: '',
|
||||||
@@ -119,13 +122,13 @@ const ProxyForm: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const response = await sendProxyConfig(proxyConfigForm);
|
const response = await sendProxyConfig(proxyConfigForm);
|
||||||
if (response) {
|
if (response) {
|
||||||
notify('success', 'Proxy configuration submitted successfully');
|
notify('success', t('proxy.notifications.config_success'));
|
||||||
} else {
|
} else {
|
||||||
notify('error', `Failed to submit proxy configuration. Try again. ${response}`);
|
notify('error', t('proxy.notifications.config_error'));
|
||||||
console.log(`Failed to submit proxy configuration. Try again. ${response}`)
|
console.log(`${t('proxy.notifications.config_error')} ${response}`)
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
notify('error', `${error} : Failed to submit proxy configuration`);
|
notify('error', `${error} : ${t('proxy.notifications.config_error')}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -136,9 +139,9 @@ const ProxyForm: React.FC = () => {
|
|||||||
const testProxy = async () => {
|
const testProxy = async () => {
|
||||||
await testProxyConfig().then((response) => {
|
await testProxyConfig().then((response) => {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
notify('success', 'Proxy configuration is working');
|
notify('success', t('proxy.notifications.test_success'));
|
||||||
} else {
|
} else {
|
||||||
notify('error', 'Failed to test proxy configuration. Try again.');
|
notify('error', t('proxy.notifications.test_error'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -149,7 +152,7 @@ const ProxyForm: React.FC = () => {
|
|||||||
if (response.proxy_url) {
|
if (response.proxy_url) {
|
||||||
setIsProxyConfigured(true);
|
setIsProxyConfigured(true);
|
||||||
setProxy(response);
|
setProxy(response);
|
||||||
notify('success', 'Proxy configuration fetched successfully');
|
notify('success', t('proxy.notifications.fetch_success'));
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
notify('error', error);
|
notify('error', error);
|
||||||
@@ -159,11 +162,11 @@ const ProxyForm: React.FC = () => {
|
|||||||
const removeProxy = async () => {
|
const removeProxy = async () => {
|
||||||
await deleteProxyConfig().then((response) => {
|
await deleteProxyConfig().then((response) => {
|
||||||
if (response) {
|
if (response) {
|
||||||
notify('success', 'Proxy configuration removed successfully');
|
notify('success', t('proxy.notifications.remove_success'));
|
||||||
setIsProxyConfigured(false);
|
setIsProxyConfigured(false);
|
||||||
setProxy({ proxy_url: '', auth: false });
|
setProxy({ proxy_url: '', auth: false });
|
||||||
} else {
|
} else {
|
||||||
notify('error', 'Failed to remove proxy configuration. Try again.');
|
notify('error', t('proxy.notifications.remove_error'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -179,7 +182,7 @@ const ProxyForm: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<FormContainer>
|
<FormContainer>
|
||||||
<Typography variant="h6" gutterBottom component="div" style={{ marginTop: '20px' }}>
|
<Typography variant="h6" gutterBottom component="div" style={{ marginTop: '20px' }}>
|
||||||
Proxy Configuration
|
{t('proxy.title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<CustomTabs
|
<CustomTabs
|
||||||
value={tabIndex}
|
value={tabIndex}
|
||||||
@@ -191,18 +194,24 @@ const ProxyForm: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CustomTab
|
<CustomTab
|
||||||
label="Standard Proxy"
|
label={t('proxy.tab_standard')}
|
||||||
style={{
|
style={{
|
||||||
color: tabIndex === 0 ? '#FF69B4' : (isDarkMode ? 'white' : 'black')
|
color: tabIndex === 0 ? '#FF69B4' : (isDarkMode ? 'white' : 'black')
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<CustomTab
|
<CustomTab
|
||||||
label="Automatic Proxy Rotation"
|
label={t('proxy.tab_rotation')}
|
||||||
style={{
|
style={{
|
||||||
color: tabIndex === 1 ? '#FF69B4' : (isDarkMode ? 'white' : 'black')
|
color: tabIndex === 1 ? '#FF69B4' : (isDarkMode ? 'white' : 'black')
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</CustomTabs>
|
</CustomTabs>
|
||||||
|
|
||||||
|
<!-- <Tabs value={tabIndex} onChange={handleTabChange}>
|
||||||
|
<Tab label={t('proxy.tab_standard')} />
|
||||||
|
<Tab label={t('proxy.tab_rotation')} />
|
||||||
|
</Tabs> -->
|
||||||
|
|
||||||
{tabIndex === 0 && (
|
{tabIndex === 0 && (
|
||||||
isProxyConfigured ? (
|
isProxyConfigured ? (
|
||||||
<Box sx={{ maxWidth: 600, width: '100%', marginTop: '5px' }}>
|
<Box sx={{ maxWidth: 600, width: '100%', marginTop: '5px' }}>
|
||||||
@@ -210,8 +219,8 @@ const ProxyForm: React.FC = () => {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell><strong>Proxy URL</strong></TableCell>
|
<TableCell><strong>{t('proxy.table.proxy_url')}</strong></TableCell>
|
||||||
<TableCell><strong>Requires Authentication</strong></TableCell>
|
<TableCell><strong>{t('proxy.table.requires_auth')}</strong></TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -223,39 +232,37 @@ const ProxyForm: React.FC = () => {
|
|||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
<Button variant="outlined" color="primary" onClick={testProxy}>
|
<Button variant="outlined" color="primary" onClick={testProxy}>
|
||||||
Test Proxy
|
{t('proxy.test_proxy')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outlined" color="error" onClick={removeProxy} sx={{ marginLeft: '10px' }}>
|
<Button variant="outlined" color="error" onClick={removeProxy} sx={{ marginLeft: '10px' }}>
|
||||||
Remove Proxy
|
{t('proxy.remove_proxy')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Box component="form" onSubmit={handleSubmit} sx={{ maxWidth: 400, width: '100%' }}>
|
<Box component="form" onSubmit={handleSubmit} sx={{ maxWidth: 400, width: '100%' }}>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TextField
|
<TextField
|
||||||
label="Proxy Server URL"
|
label={t('proxy.server_url')}
|
||||||
name="server_url"
|
name="server_url"
|
||||||
value={proxyConfigForm.server_url}
|
value={proxyConfigForm.server_url}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
fullWidth
|
fullWidth
|
||||||
required
|
required
|
||||||
error={!!errors.server_url}
|
error={!!errors.server_url}
|
||||||
helperText={errors.server_url || `Proxy to be used for all robots. HTTP and SOCKS proxies are supported.
|
helperText={errors.server_url || t('proxy.server_url_helper')}
|
||||||
Example http://myproxy.com:3128 or socks5://myproxy.com:3128.
|
|
||||||
Short form myproxy.com:3128 is considered an HTTP proxy.`}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={<Switch checked={requiresAuth} onChange={handleAuthToggle} />}
|
control={<Switch checked={requiresAuth} onChange={handleAuthToggle} />}
|
||||||
label="Requires Authentication?"
|
label={t('proxy.requires_auth')}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
{requiresAuth && (
|
{requiresAuth && (
|
||||||
<>
|
<>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TextField
|
<TextField
|
||||||
label="Username"
|
label={t('proxy.username')}
|
||||||
name="username"
|
name="username"
|
||||||
value={proxyConfigForm.username}
|
value={proxyConfigForm.username}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
@@ -267,7 +274,7 @@ const ProxyForm: React.FC = () => {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TextField
|
<TextField
|
||||||
label="Password"
|
label={t('proxy.password')}
|
||||||
name="password"
|
name="password"
|
||||||
value={proxyConfigForm.password}
|
value={proxyConfigForm.password}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
@@ -287,7 +294,7 @@ const ProxyForm: React.FC = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
disabled={!proxyConfigForm.server_url || (requiresAuth && (!proxyConfigForm.username || !proxyConfigForm.password))}
|
disabled={!proxyConfigForm.server_url || (requiresAuth && (!proxyConfigForm.username || !proxyConfigForm.password))}
|
||||||
>
|
>
|
||||||
Add Proxy
|
{t('proxy.add_proxy')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
@@ -295,31 +302,37 @@ const ProxyForm: React.FC = () => {
|
|||||||
<Box sx={{ maxWidth: 400, width: '100%', textAlign: 'center', marginTop: '20px' }}>
|
<Box sx={{ maxWidth: 400, width: '100%', textAlign: 'center', marginTop: '20px' }}>
|
||||||
<>
|
<>
|
||||||
<Typography variant="body1" gutterBottom component="div">
|
<Typography variant="body1" gutterBottom component="div">
|
||||||
Coming Soon - In Open Source (Basic Rotation) & Cloud (Advanced Rotation). If you don't want to manage the infrastructure, join our cloud waitlist to get early access.
|
{t('proxy.coming_soon')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button variant="contained" color="primary" sx={{ marginTop: '20px',backgroundColor: '#ff00c3' }}>
|
|
||||||
<a style={{ color: 'white', textDecoration: 'none' }} href="https://forms.gle/hXjgqDvkEhPcaBW76">Join Maxun Cloud Waitlist</a>
|
<!-- <Button variant="contained" color="primary" sx={{ marginTop: '20px',backgroundColor: '#ff00c3' }}>
|
||||||
|
<a style={{ color: 'white', textDecoration: 'none' }} href="https://forms.gle/hXjgqDvkEhPcaBW76">Join Maxun Cloud Waitlist</a> -->
|
||||||
|
|
||||||
|
<Button variant="contained" color="primary" sx={{ marginTop: '20px' }}>
|
||||||
|
<a style={{ color: 'white', textDecoration: 'none' }} href="https://forms.gle/hXjgqDvkEhPcaBW76">{t('proxy.join_waitlist')}</a>
|
||||||
|
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</FormContainer>
|
</FormContainer>
|
||||||
<Alert severity="info" sx={{ marginTop: '80px', marginLeft: '50px', height: '230px', width: '450px', border: '1px solid #ff00c3', bgcolor: isDarkMode ? '#3b002d' : '#ffc4f1', color: isDarkMode ? 'white' : 'black' }}>
|
|
||||||
<AlertTitle>If your proxy requires a username and password, always provide them separately from the proxy URL. </AlertTitle>
|
<Alert severity="info" sx={{ marginTop: '80px', marginLeft: '50px', height: '230px', width: '450px', border: '1px solid #ff00c3' }}>
|
||||||
|
<AlertTitle>{t('proxy.alert.title')}</AlertTitle>
|
||||||
<br />
|
<br />
|
||||||
<b>The right way</b>
|
<b>{t('proxy.alert.right_way')}</b>
|
||||||
<br />
|
<br />
|
||||||
Proxy URL: http://proxy.com:1337
|
{t('proxy.alert.proxy_url')} http://proxy.com:1337
|
||||||
<br />
|
<br />
|
||||||
Username: myusername
|
{t('proxy.alert.username')} myusername
|
||||||
<br />
|
<br />
|
||||||
Password: mypassword
|
{t('proxy.alert.password')} mypassword
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
<b>The wrong way</b>
|
<b>{t('proxy.alert.wrong_way')}</b>
|
||||||
<br />
|
|
||||||
Proxy URL: http://myusername:mypassword@proxy.com:1337
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
|
{t('proxy.alert.proxy_url')} http://myusername:mypassword@proxy.com:1337
|
||||||
</Alert>
|
</Alert>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { getActiveWorkflow } from "../../api/workflow";
|
|||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import ActionDescriptionBox from '../molecules/ActionDescriptionBox';
|
import ActionDescriptionBox from '../molecules/ActionDescriptionBox';
|
||||||
import { useThemeMode } from '../../context/theme-provider';
|
import { useThemeMode } from '../../context/theme-provider';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const fetchWorkflow = (id: string, callback: (response: WorkflowFile) => void) => {
|
const fetchWorkflow = (id: string, callback: (response: WorkflowFile) => void) => {
|
||||||
getActiveWorkflow(id).then(
|
getActiveWorkflow(id).then(
|
||||||
@@ -56,11 +57,14 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
const [showCaptureText, setShowCaptureText] = useState(true);
|
const [showCaptureText, setShowCaptureText] = useState(true);
|
||||||
const [hoverStates, setHoverStates] = useState<{ [id: string]: boolean }>({});
|
const [hoverStates, setHoverStates] = useState<{ [id: string]: boolean }>({});
|
||||||
const [browserStepIdList, setBrowserStepIdList] = useState<number[]>([]);
|
const [browserStepIdList, setBrowserStepIdList] = useState<number[]>([]);
|
||||||
|
const [isCaptureTextConfirmed, setIsCaptureTextConfirmed] = useState(false);
|
||||||
|
const [isCaptureListConfirmed, setIsCaptureListConfirmed] = useState(false);
|
||||||
|
|
||||||
const { lastAction, notify, currentWorkflowActionsState, setCurrentWorkflowActionsState } = useGlobalInfoStore();
|
const { lastAction, notify, currentWorkflowActionsState, setCurrentWorkflowActionsState, resetInterpretationLog } = useGlobalInfoStore();
|
||||||
const { getText, startGetText, stopGetText, getScreenshot, startGetScreenshot, stopGetScreenshot, getList, startGetList, stopGetList, startPaginationMode, stopPaginationMode, paginationType, updatePaginationType, limitType, customLimit, updateLimitType, updateCustomLimit, stopLimitMode, startLimitMode, captureStage, setCaptureStage } = useActionContext();
|
const { getText, startGetText, stopGetText, getScreenshot, startGetScreenshot, stopGetScreenshot, getList, startGetList, stopGetList, startPaginationMode, stopPaginationMode, paginationType, updatePaginationType, limitType, customLimit, updateLimitType, updateCustomLimit, stopLimitMode, startLimitMode, captureStage, setCaptureStage } = useActionContext();
|
||||||
const { browserSteps, updateBrowserTextStepLabel, deleteBrowserStep, addScreenshotStep, updateListTextFieldLabel, removeListTextField } = useBrowserSteps();
|
const { browserSteps, updateBrowserTextStepLabel, deleteBrowserStep, addScreenshotStep, updateListTextFieldLabel, removeListTextField } = useBrowserSteps();
|
||||||
const { id, socket } = useSocketStore();
|
const { id, socket } = useSocketStore();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const workflowHandler = useCallback((data: WorkflowFile) => {
|
const workflowHandler = useCallback((data: WorkflowFile) => {
|
||||||
setWorkflow(data);
|
setWorkflow(data);
|
||||||
@@ -129,6 +133,16 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
|
|
||||||
const handlePairDelete = () => { }
|
const handlePairDelete = () => { }
|
||||||
|
|
||||||
|
const handleStartGetText = () => {
|
||||||
|
setIsCaptureTextConfirmed(false);
|
||||||
|
startGetText();
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStartGetList = () => {
|
||||||
|
setIsCaptureListConfirmed(false);
|
||||||
|
startGetList();
|
||||||
|
}
|
||||||
|
|
||||||
const handleTextLabelChange = (id: number, label: string, listId?: number, fieldKey?: string) => {
|
const handleTextLabelChange = (id: number, label: string, listId?: number, fieldKey?: string) => {
|
||||||
if (listId !== undefined && fieldKey !== undefined) {
|
if (listId !== undefined && fieldKey !== undefined) {
|
||||||
// Prevent editing if the field is confirmed
|
// Prevent editing if the field is confirmed
|
||||||
@@ -140,7 +154,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
setTextLabels(prevLabels => ({ ...prevLabels, [id]: label }));
|
setTextLabels(prevLabels => ({ ...prevLabels, [id]: label }));
|
||||||
}
|
}
|
||||||
if (!label.trim()) {
|
if (!label.trim()) {
|
||||||
setErrors(prevErrors => ({ ...prevErrors, [id]: 'Label cannot be empty' }));
|
setErrors(prevErrors => ({ ...prevErrors, [id]: t('right_panel.errors.label_required') }));
|
||||||
} else {
|
} else {
|
||||||
setErrors(prevErrors => ({ ...prevErrors, [id]: '' }));
|
setErrors(prevErrors => ({ ...prevErrors, [id]: '' }));
|
||||||
}
|
}
|
||||||
@@ -152,7 +166,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
updateBrowserTextStepLabel(id, label);
|
updateBrowserTextStepLabel(id, label);
|
||||||
setConfirmedTextSteps(prev => ({ ...prev, [id]: true }));
|
setConfirmedTextSteps(prev => ({ ...prev, [id]: true }));
|
||||||
} else {
|
} else {
|
||||||
setErrors(prevErrors => ({ ...prevErrors, [id]: 'Label cannot be empty' }));
|
setErrors(prevErrors => ({ ...prevErrors, [id]: t('right_panel.errors.label_required') }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -168,6 +182,22 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTextStepDelete = (id: number) => {
|
||||||
|
deleteBrowserStep(id);
|
||||||
|
setTextLabels(prevLabels => {
|
||||||
|
const { [id]: _, ...rest } = prevLabels;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
setConfirmedTextSteps(prev => {
|
||||||
|
const { [id]: _, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
setErrors(prevErrors => {
|
||||||
|
const { [id]: _, ...rest } = prevErrors;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleListTextFieldConfirm = (listId: number, fieldKey: string) => {
|
const handleListTextFieldConfirm = (listId: number, fieldKey: string) => {
|
||||||
setConfirmedListTextFields(prev => ({
|
setConfirmedListTextFields(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -194,6 +224,22 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleListTextFieldDelete = (listId: number, fieldKey: string) => {
|
||||||
|
removeListTextField(listId, fieldKey);
|
||||||
|
setConfirmedListTextFields(prev => {
|
||||||
|
const updatedListFields = { ...(prev[listId] || {}) };
|
||||||
|
delete updatedListFields[fieldKey];
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[listId]: updatedListFields
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setErrors(prev => {
|
||||||
|
const { [fieldKey]: _, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const getTextSettingsObject = useCallback(() => {
|
const getTextSettingsObject = useCallback(() => {
|
||||||
const settings: Record<string, { selector: string; tag?: string;[key: string]: any }> = {};
|
const settings: Record<string, { selector: string; tag?: string;[key: string]: any }> = {};
|
||||||
browserSteps.forEach(step => {
|
browserSteps.forEach(step => {
|
||||||
@@ -214,18 +260,19 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
const stopCaptureAndEmitGetTextSettings = useCallback(() => {
|
const stopCaptureAndEmitGetTextSettings = useCallback(() => {
|
||||||
const hasUnconfirmedTextSteps = browserSteps.some(step => step.type === 'text' && !confirmedTextSteps[step.id]);
|
const hasUnconfirmedTextSteps = browserSteps.some(step => step.type === 'text' && !confirmedTextSteps[step.id]);
|
||||||
if (hasUnconfirmedTextSteps) {
|
if (hasUnconfirmedTextSteps) {
|
||||||
notify('error', 'Please confirm all text fields');
|
notify('error', t('right_panel.errors.confirm_text_fields'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
stopGetText();
|
stopGetText();
|
||||||
const settings = getTextSettingsObject();
|
const settings = getTextSettingsObject();
|
||||||
console.log("SETTINGS", settings);
|
|
||||||
const hasTextSteps = browserSteps.some(step => step.type === 'text');
|
const hasTextSteps = browserSteps.some(step => step.type === 'text');
|
||||||
if (hasTextSteps) {
|
if (hasTextSteps) {
|
||||||
socket?.emit('action', { action: 'scrapeSchema', settings });
|
socket?.emit('action', { action: 'scrapeSchema', settings });
|
||||||
}
|
}
|
||||||
|
setIsCaptureTextConfirmed(true);
|
||||||
|
resetInterpretationLog();
|
||||||
onFinishCapture();
|
onFinishCapture();
|
||||||
}, [stopGetText, getTextSettingsObject, socket, browserSteps, confirmedTextSteps]);
|
}, [stopGetText, getTextSettingsObject, socket, browserSteps, confirmedTextSteps, resetInterpretationLog]);
|
||||||
|
|
||||||
const getListSettingsObject = useCallback(() => {
|
const getListSettingsObject = useCallback(() => {
|
||||||
let settings: {
|
let settings: {
|
||||||
@@ -279,7 +326,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
if (settings) {
|
if (settings) {
|
||||||
socket?.emit('action', { action: 'scrapeList', settings });
|
socket?.emit('action', { action: 'scrapeList', settings });
|
||||||
} else {
|
} else {
|
||||||
notify('error', 'Unable to create list settings. Make sure you have defined a field for the list.');
|
notify('error', t('right_panel.errors.unable_create_settings'));
|
||||||
}
|
}
|
||||||
handleStopGetList();
|
handleStopGetList();
|
||||||
onFinishCapture();
|
onFinishCapture();
|
||||||
@@ -297,13 +344,13 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
|
|
||||||
case 'pagination':
|
case 'pagination':
|
||||||
if (!paginationType) {
|
if (!paginationType) {
|
||||||
notify('error', 'Please select a pagination type.');
|
notify('error', t('right_panel.errors.select_pagination'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const settings = getListSettingsObject();
|
const settings = getListSettingsObject();
|
||||||
const paginationSelector = settings.pagination?.selector;
|
const paginationSelector = settings.pagination?.selector;
|
||||||
if (['clickNext', 'clickLoadMore'].includes(paginationType) && !paginationSelector) {
|
if (['clickNext', 'clickLoadMore'].includes(paginationType) && !paginationSelector) {
|
||||||
notify('error', 'Please select the pagination element first.');
|
notify('error', t('right_panel.errors.select_pagination_element'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
stopPaginationMode();
|
stopPaginationMode();
|
||||||
@@ -315,16 +362,17 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
|
|
||||||
case 'limit':
|
case 'limit':
|
||||||
if (!limitType || (limitType === 'custom' && !customLimit)) {
|
if (!limitType || (limitType === 'custom' && !customLimit)) {
|
||||||
notify('error', 'Please select a limit or enter a custom limit.');
|
notify('error', t('right_panel.errors.select_limit'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const limit = limitType === 'custom' ? parseInt(customLimit) : parseInt(limitType);
|
const limit = limitType === 'custom' ? parseInt(customLimit) : parseInt(limitType);
|
||||||
if (isNaN(limit) || limit <= 0) {
|
if (isNaN(limit) || limit <= 0) {
|
||||||
notify('error', 'Please enter a valid limit.');
|
notify('error', t('right_panel.errors.invalid_limit'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
stopLimitMode();
|
stopLimitMode();
|
||||||
setShowLimitOptions(false);
|
setShowLimitOptions(false);
|
||||||
|
setIsCaptureListConfirmed(true);
|
||||||
stopCaptureAndEmitGetListSettings();
|
stopCaptureAndEmitGetListSettings();
|
||||||
setCaptureStage('complete');
|
setCaptureStage('complete');
|
||||||
break;
|
break;
|
||||||
@@ -335,6 +383,23 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
}
|
}
|
||||||
}, [captureStage, paginationType, limitType, customLimit, startPaginationMode, stopPaginationMode, startLimitMode, stopLimitMode, notify, stopCaptureAndEmitGetListSettings, getListSettingsObject]);
|
}, [captureStage, paginationType, limitType, customLimit, startPaginationMode, stopPaginationMode, startLimitMode, stopLimitMode, notify, stopCaptureAndEmitGetListSettings, getListSettingsObject]);
|
||||||
|
|
||||||
|
const handleBackCaptureList = useCallback(() => {
|
||||||
|
switch (captureStage) {
|
||||||
|
case 'limit':
|
||||||
|
stopLimitMode();
|
||||||
|
setShowLimitOptions(false);
|
||||||
|
startPaginationMode();
|
||||||
|
setShowPaginationOptions(true);
|
||||||
|
setCaptureStage('pagination');
|
||||||
|
break;
|
||||||
|
case 'pagination':
|
||||||
|
stopPaginationMode();
|
||||||
|
setShowPaginationOptions(false);
|
||||||
|
setCaptureStage('initial');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [captureStage, stopLimitMode, startPaginationMode, stopPaginationMode]);
|
||||||
|
|
||||||
const handlePaginationSettingSelect = (option: PaginationType) => {
|
const handlePaginationSettingSelect = (option: PaginationType) => {
|
||||||
updatePaginationType(option);
|
updatePaginationType(option);
|
||||||
};
|
};
|
||||||
@@ -349,7 +414,8 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
setTextLabels({});
|
setTextLabels({});
|
||||||
setErrors({});
|
setErrors({});
|
||||||
setConfirmedTextSteps({});
|
setConfirmedTextSteps({});
|
||||||
notify('error', 'Capture Text Discarded');
|
setIsCaptureTextConfirmed(false);
|
||||||
|
notify('error', t('right_panel.errors.capture_text_discarded'));
|
||||||
}, [browserSteps, stopGetText, deleteBrowserStep]);
|
}, [browserSteps, stopGetText, deleteBrowserStep]);
|
||||||
|
|
||||||
const discardGetList = useCallback(() => {
|
const discardGetList = useCallback(() => {
|
||||||
@@ -364,7 +430,8 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
setShowLimitOptions(false);
|
setShowLimitOptions(false);
|
||||||
setCaptureStage('initial');
|
setCaptureStage('initial');
|
||||||
setConfirmedListTextFields({});
|
setConfirmedListTextFields({});
|
||||||
notify('error', 'Capture List Discarded');
|
setIsCaptureListConfirmed(false);
|
||||||
|
notify('error', t('right_panel.errors.capture_list_discarded'));
|
||||||
}, [browserSteps, stopGetList, deleteBrowserStep, resetListState]);
|
}, [browserSteps, stopGetList, deleteBrowserStep, resetListState]);
|
||||||
|
|
||||||
|
|
||||||
@@ -420,37 +487,54 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
</SimpleBox> */}
|
</SimpleBox> */}
|
||||||
<ActionDescriptionBox isDarkMode={isDarkMode} />
|
<ActionDescriptionBox isDarkMode={isDarkMode} />
|
||||||
<Box display="flex" flexDirection="column" gap={2} style={{ margin: '13px',background: isDarkMode?'#1E2124': 'inherit',color: isDarkMode ? 'white' : 'inherit' }}>
|
<Box display="flex" flexDirection="column" gap={2} style={{ margin: '13px',background: isDarkMode?'#1E2124': 'inherit',color: isDarkMode ? 'white' : 'inherit' }}>
|
||||||
{!getText && !getScreenshot && !getList && showCaptureList && <Button variant="contained" sx={{backgroundColor:"#ff00c3",color:`${isDarkMode?'white':'black'}`}} onClick={startGetList}>Capture List</Button>}
|
{!getText && !getScreenshot && !getList && showCaptureList && <Button variant="contained" sx={{backgroundColor:"#ff00c3",color:`${isDarkMode?'white':'black'}`}} onClick={startGetList}>{t('right_panel.buttons.capture_list')}</Button>}
|
||||||
|
<!-- <ActionDescriptionBox />
|
||||||
|
<Box display="flex" flexDirection="column" gap={2} style={{ margin: '13px' }}>
|
||||||
|
{!getText && !getScreenshot && !getList && showCaptureList && <Button variant="contained" onClick={startGetList}>{t('right_panel.buttons.capture_list')}</Button>}
|
||||||
|
--!>
|
||||||
{getList && (
|
{getList && (
|
||||||
<>
|
<>
|
||||||
<Box display="flex" justifyContent="space-between" gap={2} style={{ margin: '15px' }}>
|
<Box display="flex" justifyContent="space-between" gap={2} style={{ margin: '15px' }}>
|
||||||
|
{(captureStage === 'pagination' || captureStage === 'limit') && (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={handleBackCaptureList}
|
||||||
|
>
|
||||||
|
{t('right_panel.buttons.back')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={handleConfirmListCapture}
|
onClick={handleConfirmListCapture}
|
||||||
disabled={captureStage === 'initial' ? isConfirmCaptureDisabled : hasUnconfirmedListTextFields}
|
disabled={captureStage === 'initial' ? isConfirmCaptureDisabled : hasUnconfirmedListTextFields}
|
||||||
>
|
>
|
||||||
{captureStage === 'initial' ? 'Confirm Capture' :
|
{captureStage === 'initial' ? t('right_panel.buttons.confirm_capture') :
|
||||||
captureStage === 'pagination' ? 'Confirm Pagination' :
|
captureStage === 'pagination' ? t('right_panel.buttons.confirm_pagination') :
|
||||||
captureStage === 'limit' ? 'Confirm Limit' : 'Finish Capture'}
|
captureStage === 'limit' ? t('right_panel.buttons.confirm_limit') :
|
||||||
|
t('right_panel.buttons.finish_capture')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" color="error" onClick={discardGetList}>
|
||||||
|
{t('right_panel.buttons.discard')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outlined" color="error" onClick={discardGetList}>Discard</Button>
|
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{showPaginationOptions && (
|
{showPaginationOptions && (
|
||||||
<Box display="flex" flexDirection="column" gap={2} style={{ margin: '13px' }}>
|
<Box display="flex" flexDirection="column" gap={2} style={{ margin: '13px' }}>
|
||||||
<Typography>How can we find the next list item on the page?</Typography>
|
<Typography>{t('right_panel.pagination.title')}</Typography>
|
||||||
<Button variant={paginationType === 'clickNext' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('clickNext')}>Click on next to navigate to the next page</Button>
|
<Button variant={paginationType === 'clickNext' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('clickNext')}>{t('right_panel.pagination.click_next')}</Button>
|
||||||
<Button variant={paginationType === 'clickLoadMore' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('clickLoadMore')}>Click on load more to load more items</Button>
|
<Button variant={paginationType === 'clickLoadMore' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('clickLoadMore')}>{t('right_panel.pagination.click_load_more')}</Button>
|
||||||
<Button variant={paginationType === 'scrollDown' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('scrollDown')}>Scroll down to load more items</Button>
|
<Button variant={paginationType === 'scrollDown' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('scrollDown')}>{t('right_panel.pagination.scroll_down')}</Button>
|
||||||
<Button variant={paginationType === 'scrollUp' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('scrollUp')}>Scroll up to load more items</Button>
|
<Button variant={paginationType === 'scrollUp' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('scrollUp')}>{t('right_panel.pagination.scroll_up')}</Button>
|
||||||
<Button variant={paginationType === 'none' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('none')}>No more items to load</Button>
|
<Button variant={paginationType === 'none' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('none')}>{t('right_panel.pagination.none')}</Button>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{showLimitOptions && (
|
{showLimitOptions && (
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel style={{ marginBottom: '10px', background: isDarkMode ? "#1E2124" : 'white', color: isDarkMode ? "white" : 'black' }}>
|
<FormLabel style={{ marginBottom: '10px', background: isDarkMode ? "#1E2124" : 'white', color: isDarkMode ? "white" : 'black' }}>
|
||||||
<h4>What is the maximum number of rows you want to extract?</h4>
|
<h4>{t('right_panel.limit.title')}</h4>
|
||||||
|
<!-- <FormLabel>
|
||||||
|
<h4>{t('right_panel.limit.title')}</h4> -->
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={limitType}
|
value={limitType}
|
||||||
@@ -464,13 +548,28 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
<FormControlLabel value="10" control={<Radio />} label="10" />
|
<FormControlLabel value="10" control={<Radio />} label="10" />
|
||||||
<FormControlLabel value="100" control={<Radio />} label="100" />
|
<FormControlLabel value="100" control={<Radio />} label="100" />
|
||||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
<FormControlLabel value="custom" control={<Radio />} label="Custom" />
|
<FormControlLabel value="custom" control={<Radio />} label={t('right_panel.limit.custom')} />
|
||||||
{limitType === 'custom' && (
|
{limitType === 'custom' && (
|
||||||
<TextField
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
value={customLimit}
|
value={customLimit}
|
||||||
onChange={(e) => updateCustomLimit(e.target.value)}
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
placeholder="Enter number"
|
const value = parseInt(e.target.value);
|
||||||
|
// Only update if the value is greater than or equal to 1 or if the field is empty
|
||||||
|
if (e.target.value === '' || value >= 1) {
|
||||||
|
updateCustomLimit(e.target.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
inputProps={{
|
||||||
|
min: 1,
|
||||||
|
onKeyPress: (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
const value = (e.target as HTMLInputElement).value + e.key;
|
||||||
|
if (parseInt(value) < 1) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t('right_panel.limit.enter_number')}
|
||||||
sx={{
|
sx={{
|
||||||
marginLeft: '10px',
|
marginLeft: '10px',
|
||||||
'& input': {
|
'& input': {
|
||||||
@@ -487,21 +586,24 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
{!getText && !getScreenshot && !getList && showCaptureText && <Button variant="contained" sx={{backgroundColor:"#ff00c3",color:`${isDarkMode?'white':'black'}`}} onClick={startGetText}>Capture Text</Button>}
|
{!getText && !getScreenshot && !getList && showCaptureText && <Button variant="contained" sx={{backgroundColor:"#ff00c3",color:`${isDarkMode?'white':'black'}`}} onClick={startGetText}>{t('right_panel.buttons.capture_text')}</Button>}
|
||||||
|
|
||||||
|
// {!getText && !getScreenshot && !getList && showCaptureText && <Button variant="contained" onClick={handleStartGetText}>{t('right_panel.buttons.capture_text')}</Button>}
|
||||||
{getText &&
|
{getText &&
|
||||||
<>
|
<>
|
||||||
<Box display="flex" justifyContent="space-between" gap={2} style={{ margin: '15px' }}>
|
<Box display="flex" justifyContent="space-between" gap={2} style={{ margin: '15px' }}>
|
||||||
<Button variant="outlined" onClick={stopCaptureAndEmitGetTextSettings} >Confirm</Button>
|
<Button variant="outlined" onClick={stopCaptureAndEmitGetTextSettings} >{t('right_panel.buttons.confirm')}</Button>
|
||||||
<Button variant="outlined" color="error" onClick={discardGetText} >Discard</Button>
|
<Button variant="outlined" color="error" onClick={discardGetText} >{t('right_panel.buttons.discard')}</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
{!getText && !getScreenshot && !getList && showCaptureScreenshot && <Button variant="contained" sx={{backgroundColor:"#ff00c3",color:`${isDarkMode?'white':'black'}`}} onClick={startGetScreenshot}>Capture Screenshot</Button>}
|
{!getText && !getScreenshot && !getList && showCaptureScreenshot && <Button variant="contained" sx={{backgroundColor:"#ff00c3",color:`${isDarkMode?'white':'black'}`}} onClick={startGetScreenshot}>{t('right_panel.buttons.capture_screenshot')}</Button>}
|
||||||
|
// {!getText && !getScreenshot && !getList && showCaptureScreenshot && <Button variant="contained" onClick={startGetScreenshot}>{t('right_panel.buttons.capture_screenshot')}</Button>}
|
||||||
{getScreenshot && (
|
{getScreenshot && (
|
||||||
<Box display="flex" flexDirection="column" gap={2}>
|
<Box display="flex" flexDirection="column" gap={2}>
|
||||||
<Button variant="contained" onClick={() => captureScreenshot(true)}>Capture Fullpage</Button>
|
<Button variant="contained" onClick={() => captureScreenshot(true)}>{t('right_panel.screenshot.capture_fullpage')}</Button>
|
||||||
<Button variant="contained" onClick={() => captureScreenshot(false)}>Capture Visible Part</Button>
|
<Button variant="contained" onClick={() => captureScreenshot(false)}>{t('right_panel.screenshot.capture_visible')}</Button>
|
||||||
<Button variant="outlined" color="error" onClick={stopGetScreenshot}>Discard</Button>
|
<Button variant="outlined" color="error" onClick={stopGetScreenshot}>{t('right_panel.buttons.discard')}</Button>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -512,7 +614,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
step.type === 'text' && (
|
step.type === 'text' && (
|
||||||
<>
|
<>
|
||||||
<TextField
|
<TextField
|
||||||
label="Label"
|
label={t('right_panel.fields.label')}
|
||||||
value={textLabels[step.id] || step.label || ''}
|
value={textLabels[step.id] || step.label || ''}
|
||||||
onChange={(e) => handleTextLabelChange(step.id, e.target.value)}
|
onChange={(e) => handleTextLabelChange(step.id, e.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -531,7 +633,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
sx={{ background: isDarkMode ? "#1E2124" : 'white', color: isDarkMode ? "white" : 'black' }}
|
sx={{ background: isDarkMode ? "#1E2124" : 'white', color: isDarkMode ? "white" : 'black' }}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Data"
|
label={t('right_panel.fields.data')}
|
||||||
value={step.data}
|
value={step.data}
|
||||||
fullWidth
|
fullWidth
|
||||||
margin="normal"
|
margin="normal"
|
||||||
@@ -545,10 +647,20 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
}}
|
}}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
{!confirmedTextSteps[step.id] && (
|
{!confirmedTextSteps[step.id] ? (
|
||||||
<Box display="flex" justifyContent="space-between" gap={2}>
|
<Box display="flex" justifyContent="space-between" gap={2}>
|
||||||
<Button variant="contained" onClick={() => handleTextStepConfirm(step.id)} disabled={!textLabels[step.id]?.trim()}>Confirm</Button>
|
<Button variant="contained" onClick={() => handleTextStepConfirm(step.id)} disabled={!textLabels[step.id]?.trim()}>{t('right_panel.buttons.confirm')}</Button>
|
||||||
<Button variant="contained" color="error" onClick={() => handleTextStepDiscard(step.id)}>Discard</Button>
|
<Button variant="contained" color="error" onClick={() => handleTextStepDiscard(step.id)}>{t('right_panel.buttons.discard')}</Button>
|
||||||
|
</Box>
|
||||||
|
) : !isCaptureTextConfirmed && (
|
||||||
|
<Box display="flex" justifyContent="flex-end" gap={2}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
onClick={() => handleTextStepDelete(step.id)}
|
||||||
|
>
|
||||||
|
{t('right_panel.buttons.delete')}
|
||||||
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -557,17 +669,19 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
<Box display="flex" alignItems="center">
|
<Box display="flex" alignItems="center">
|
||||||
<DocumentScannerIcon sx={{ mr: 1 }} />
|
<DocumentScannerIcon sx={{ mr: 1 }} />
|
||||||
<Typography>
|
<Typography>
|
||||||
{`Take ${step.fullPage ? 'Fullpage' : 'Visible Part'} Screenshot`}
|
{step.fullPage ?
|
||||||
|
t('right_panel.screenshot.display_fullpage') :
|
||||||
|
t('right_panel.screenshot.display_visible')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{step.type === 'list' && (
|
{step.type === 'list' && (
|
||||||
<>
|
<>
|
||||||
<Typography>List Selected Successfully</Typography>
|
<Typography>{t('right_panel.messages.list_selected')}</Typography>
|
||||||
{Object.entries(step.fields).map(([key, field]) => (
|
{Object.entries(step.fields).map(([key, field]) => (
|
||||||
<Box key={key} sx={{ padding: '10px', margin: '11px', borderRadius: '5px', position: 'relative', background: `${isDarkMode ? "#1E2124" : 'white'}` }}>
|
<Box key={key} sx={{ padding: '10px', margin: '11px', borderRadius: '5px', position: 'relative', background: `${isDarkMode ? "#1E2124" : 'white'}` }}>
|
||||||
<TextField
|
<TextField
|
||||||
label="Field Label"
|
label={t('right_panel.fields.field_label')}
|
||||||
value={field.label || ''}
|
value={field.label || ''}
|
||||||
onChange={(e) => handleTextLabelChange(field.id, e.target.value, step.id, key)}
|
onChange={(e) => handleTextLabelChange(field.id, e.target.value, step.id, key)}
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -584,7 +698,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
|
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Field Data"
|
label={t('right_panel.fields.field_data')}
|
||||||
value={field.data || ''}
|
value={field.data || ''}
|
||||||
fullWidth
|
fullWidth
|
||||||
margin="normal"
|
margin="normal"
|
||||||
@@ -598,21 +712,31 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
}}
|
}}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
{!confirmedListTextFields[step.id]?.[key] && (
|
{!confirmedListTextFields[step.id]?.[key] ? (
|
||||||
<Box display="flex" justifyContent="space-between" gap={2}>
|
<Box display="flex" justifyContent="space-between" gap={2}>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={() => handleListTextFieldConfirm(step.id, key)}
|
onClick={() => handleListTextFieldConfirm(step.id, key)}
|
||||||
disabled={!field.label?.trim()}
|
disabled={!field.label?.trim()}
|
||||||
>
|
>
|
||||||
Confirm
|
{t('right_panel.buttons.confirm')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="error"
|
color="error"
|
||||||
onClick={() => handleListTextFieldDiscard(step.id, key)}
|
onClick={() => handleListTextFieldDiscard(step.id, key)}
|
||||||
>
|
>
|
||||||
Discard
|
{t('right_panel.buttons.discard')}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
) : !isCaptureListConfirmed && (
|
||||||
|
<Box display="flex" justifyContent="flex-end" gap={2}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
onClick={() => handleListTextFieldDelete(step.id, key)}
|
||||||
|
>
|
||||||
|
{t('right_panel.buttons.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useReducer, createContext, useEffect } from 'react';
|
import { useReducer, createContext, useEffect, useCallback } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { apiUrl } from "../apiConfig";
|
import { apiUrl } from "../apiConfig";
|
||||||
@@ -14,12 +14,16 @@ interface ActionType {
|
|||||||
|
|
||||||
type InitialStateType = {
|
type InitialStateType = {
|
||||||
user: any;
|
user: any;
|
||||||
|
lastActivityTime?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
user: null,
|
user: null,
|
||||||
|
lastActivityTime: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const AUTO_LOGOUT_TIME = 4 * 60 * 60 * 1000; // 4 hours in milliseconds
|
||||||
|
|
||||||
const AuthContext = createContext<{
|
const AuthContext = createContext<{
|
||||||
state: InitialStateType;
|
state: InitialStateType;
|
||||||
dispatch: React.Dispatch<ActionType>;
|
dispatch: React.Dispatch<ActionType>;
|
||||||
@@ -34,11 +38,13 @@ const reducer = (state: InitialStateType, action: ActionType) => {
|
|||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
user: action.payload,
|
user: action.payload,
|
||||||
|
lastActivityTime: Date.now(),
|
||||||
};
|
};
|
||||||
case 'LOGOUT':
|
case 'LOGOUT':
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
user: null,
|
user: null,
|
||||||
|
lastActivityTime: undefined,
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
@@ -50,6 +56,39 @@ const AuthProvider = ({ children }: AuthProviderProps) => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
axios.defaults.withCredentials = true;
|
axios.defaults.withCredentials = true;
|
||||||
|
|
||||||
|
const handleLogout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await axios.get(`${apiUrl}/auth/logout`);
|
||||||
|
dispatch({ type: 'LOGOUT' });
|
||||||
|
window.localStorage.removeItem('user');
|
||||||
|
navigate('/login');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Logout error:', err);
|
||||||
|
}
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const checkAutoLogout = useCallback(() => {
|
||||||
|
if (state.user && state.lastActivityTime) {
|
||||||
|
const currentTime = Date.now();
|
||||||
|
const timeSinceLastActivity = currentTime - state.lastActivityTime;
|
||||||
|
|
||||||
|
if (timeSinceLastActivity >= AUTO_LOGOUT_TIME) {
|
||||||
|
handleLogout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [state.user, state.lastActivityTime, handleLogout]);
|
||||||
|
|
||||||
|
// Update last activity time on user interactions
|
||||||
|
const updateActivityTime = useCallback(() => {
|
||||||
|
if (state.user) {
|
||||||
|
dispatch({
|
||||||
|
type: 'LOGIN',
|
||||||
|
payload: state.user // Reuse existing user data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [state.user]);
|
||||||
|
|
||||||
|
// Initialize user from localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedUser = window.localStorage.getItem('user');
|
const storedUser = window.localStorage.getItem('user');
|
||||||
if (storedUser) {
|
if (storedUser) {
|
||||||
@@ -57,21 +96,54 @@ const AuthProvider = ({ children }: AuthProviderProps) => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Set up activity listeners
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.user) {
|
||||||
|
// List of events to track for user activity
|
||||||
|
const events = ['mousedown', 'keydown', 'scroll', 'touchstart'];
|
||||||
|
|
||||||
|
// Throttled event handler
|
||||||
|
let timeoutId: NodeJS.Timeout;
|
||||||
|
const handleActivity = () => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
timeoutId = setTimeout(updateActivityTime, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
events.forEach(event => {
|
||||||
|
window.addEventListener(event, handleActivity);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up periodic check for auto logout
|
||||||
|
const checkInterval = setInterval(checkAutoLogout, 60000); // Check every minute
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
events.forEach(event => {
|
||||||
|
window.removeEventListener(event, handleActivity);
|
||||||
|
});
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [state.user, updateActivityTime, checkAutoLogout]);
|
||||||
|
|
||||||
axios.interceptors.response.use(
|
axios.interceptors.response.use(
|
||||||
function (response) {
|
function (response) {
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
function (error) {
|
function (error) {
|
||||||
const res = error.response;
|
const res = error.response;
|
||||||
if (res.status === 401 && res.config && !res.config.__isRetryRequest) {
|
if (res?.status === 401 && res.config && !res.config.__isRetryRequest) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((_, reject) => {
|
||||||
axios
|
handleLogout()
|
||||||
.get(`${apiUrl}/auth/logout`)
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('/401 error > logout');
|
console.log('/401 error > logout');
|
||||||
dispatch({ type: 'LOGOUT' });
|
reject(error);
|
||||||
window.localStorage.removeItem('user');
|
|
||||||
navigate('/login');
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error('AXIOS INTERCEPTORS ERROR:', err);
|
console.error('AXIOS INTERCEPTORS ERROR:', err);
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ interface ActionContextProps {
|
|||||||
paginationType: PaginationType;
|
paginationType: PaginationType;
|
||||||
limitType: LimitType;
|
limitType: LimitType;
|
||||||
customLimit: string;
|
customLimit: string;
|
||||||
captureStage: CaptureStage; // New captureStage property
|
captureStage: CaptureStage;
|
||||||
setCaptureStage: (stage: CaptureStage) => void; // Setter for captureStage
|
setCaptureStage: (stage: CaptureStage) => void;
|
||||||
startPaginationMode: () => void;
|
startPaginationMode: () => void;
|
||||||
startGetText: () => void;
|
startGetText: () => void;
|
||||||
stopGetText: () => void;
|
stopGetText: () => void;
|
||||||
@@ -53,6 +53,7 @@ export const ActionProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
const startPaginationMode = () => {
|
const startPaginationMode = () => {
|
||||||
setPaginationMode(true);
|
setPaginationMode(true);
|
||||||
setCaptureStage('pagination');
|
setCaptureStage('pagination');
|
||||||
|
socket?.emit('setGetList', { getList: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopPaginationMode = () => setPaginationMode(false);
|
const stopPaginationMode = () => setPaginationMode(false);
|
||||||
@@ -75,7 +76,6 @@ export const ActionProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
|
|
||||||
const stopGetList = () => {
|
const stopGetList = () => {
|
||||||
setGetList(false);
|
setGetList(false);
|
||||||
socket?.emit('setGetList', { getList: false });
|
|
||||||
setPaginationType('');
|
setPaginationType('');
|
||||||
setLimitType('');
|
setLimitType('');
|
||||||
setCustomLimit('');
|
setCustomLimit('');
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export interface SelectorObject {
|
|||||||
selector: string;
|
selector: string;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
attribute?: string;
|
attribute?: string;
|
||||||
|
shadow?: boolean;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,26 +63,35 @@ export const BrowserStepsProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
const addListStep = (listSelector: string, newFields: { [key: string]: TextStep }, listId: number, pagination?: { type: string; selector: string }, limit?: number) => {
|
const addListStep = (listSelector: string, newFields: { [key: string]: TextStep }, listId: number, pagination?: { type: string; selector: string }, limit?: number) => {
|
||||||
setBrowserSteps(prevSteps => {
|
setBrowserSteps(prevSteps => {
|
||||||
const existingListStepIndex = prevSteps.findIndex(step => step.type === 'list' && step.id === listId);
|
const existingListStepIndex = prevSteps.findIndex(step => step.type === 'list' && step.id === listId);
|
||||||
|
|
||||||
if (existingListStepIndex !== -1) {
|
if (existingListStepIndex !== -1) {
|
||||||
const updatedSteps = [...prevSteps];
|
const updatedSteps = [...prevSteps];
|
||||||
const existingListStep = updatedSteps[existingListStepIndex] as ListStep;
|
const existingListStep = updatedSteps[existingListStepIndex] as ListStep;
|
||||||
|
|
||||||
const filteredNewFields = Object.entries(newFields).reduce((acc, [key, value]) => {
|
// Preserve existing labels for fields
|
||||||
|
const mergedFields = Object.entries(newFields).reduce((acc, [key, field]) => {
|
||||||
if (!discardedFields.has(`${listId}-${key}`)) {
|
if (!discardedFields.has(`${listId}-${key}`)) {
|
||||||
acc[key] = value;
|
// If field exists, preserve its label
|
||||||
|
if (existingListStep.fields[key]) {
|
||||||
|
acc[key] = {
|
||||||
|
...field,
|
||||||
|
label: existingListStep.fields[key].label
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
acc[key] = field;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as { [key: string]: TextStep });
|
}, {} as { [key: string]: TextStep });
|
||||||
|
|
||||||
updatedSteps[existingListStepIndex] = {
|
updatedSteps[existingListStepIndex] = {
|
||||||
...existingListStep,
|
...existingListStep,
|
||||||
fields: { ...existingListStep.fields, ...filteredNewFields },
|
fields: mergedFields,
|
||||||
pagination: pagination,
|
pagination: pagination || existingListStep.pagination,
|
||||||
limit: limit,
|
limit: limit
|
||||||
};
|
};
|
||||||
return updatedSteps;
|
return updatedSteps;
|
||||||
} else {
|
} else {
|
||||||
// Create a new ListStep
|
|
||||||
return [
|
return [
|
||||||
...prevSteps,
|
...prevSteps,
|
||||||
{ id: listId, type: 'list', listSelector, fields: newFields, pagination, limit }
|
{ id: listId, type: 'list', listSelector, fields: newFields, pagination, limit }
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ interface GlobalInfo {
|
|||||||
hasScreenshotAction: boolean;
|
hasScreenshotAction: boolean;
|
||||||
hasScrapeSchemaAction: boolean;
|
hasScrapeSchemaAction: boolean;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
shouldResetInterpretationLog: boolean;
|
||||||
|
resetInterpretationLog: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
class GlobalInfoStore implements Partial<GlobalInfo> {
|
class GlobalInfoStore implements Partial<GlobalInfo> {
|
||||||
@@ -53,6 +55,7 @@ class GlobalInfoStore implements Partial<GlobalInfo> {
|
|||||||
hasScreenshotAction: false,
|
hasScreenshotAction: false,
|
||||||
hasScrapeSchemaAction: false,
|
hasScrapeSchemaAction: false,
|
||||||
};
|
};
|
||||||
|
shouldResetInterpretationLog = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const globalInfoStore = new GlobalInfoStore();
|
const globalInfoStore = new GlobalInfoStore();
|
||||||
@@ -71,6 +74,7 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => {
|
|||||||
const [recordingName, setRecordingName] = useState<string>(globalInfoStore.recordingName);
|
const [recordingName, setRecordingName] = useState<string>(globalInfoStore.recordingName);
|
||||||
const [recordingUrl, setRecordingUrl] = useState<string>(globalInfoStore.recordingUrl);
|
const [recordingUrl, setRecordingUrl] = useState<string>(globalInfoStore.recordingUrl);
|
||||||
const [currentWorkflowActionsState, setCurrentWorkflowActionsState] = useState(globalInfoStore.currentWorkflowActionsState);
|
const [currentWorkflowActionsState, setCurrentWorkflowActionsState] = useState(globalInfoStore.currentWorkflowActionsState);
|
||||||
|
const [shouldResetInterpretationLog, setShouldResetInterpretationLog] = useState<boolean>(globalInfoStore.shouldResetInterpretationLog);
|
||||||
|
|
||||||
const notify = (severity: 'error' | 'warning' | 'info' | 'success', message: string) => {
|
const notify = (severity: 'error' | 'warning' | 'info' | 'success', message: string) => {
|
||||||
setNotification({ severity, message, isOpen: true });
|
setNotification({ severity, message, isOpen: true });
|
||||||
@@ -87,6 +91,14 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resetInterpretationLog = () => {
|
||||||
|
setShouldResetInterpretationLog(true);
|
||||||
|
// Reset the flag after a short delay to allow components to respond
|
||||||
|
setTimeout(() => {
|
||||||
|
setShouldResetInterpretationLog(false);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<globalInfoContext.Provider
|
<globalInfoContext.Provider
|
||||||
value={{
|
value={{
|
||||||
@@ -111,6 +123,8 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => {
|
|||||||
setRecordingUrl,
|
setRecordingUrl,
|
||||||
currentWorkflowActionsState,
|
currentWorkflowActionsState,
|
||||||
setCurrentWorkflowActionsState,
|
setCurrentWorkflowActionsState,
|
||||||
|
shouldResetInterpretationLog,
|
||||||
|
resetInterpretationLog,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
22
src/i18n.ts
Normal file
22
src/i18n.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import i18n from 'i18next';
|
||||||
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
import Backend from 'i18next-http-backend';
|
||||||
|
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||||
|
|
||||||
|
i18n
|
||||||
|
.use(Backend)
|
||||||
|
.use(LanguageDetector)
|
||||||
|
.use(initReactI18next)
|
||||||
|
.init({
|
||||||
|
fallbackLng: 'en',
|
||||||
|
debug: import.meta.env.DEV,
|
||||||
|
supportedLngs: ['en', 'es', 'ja', 'zh','de'],
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false, // React already escapes
|
||||||
|
},
|
||||||
|
backend: {
|
||||||
|
loadPath: '/locales/{{lng}}.json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client';
|
|||||||
import './index.css';
|
import './index.css';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
import i18n from "./i18n"
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(
|
const root = ReactDOM.createRoot(
|
||||||
document.getElementById('root') as HTMLElement
|
document.getElementById('root') as HTMLElement
|
||||||
|
|||||||
@@ -5,10 +5,20 @@ import { AuthContext } from "../context/auth";
|
|||||||
import { Box, Typography, TextField, Button, CircularProgress } from "@mui/material";
|
import { Box, Typography, TextField, Button, CircularProgress } from "@mui/material";
|
||||||
import { useGlobalInfoStore } from "../context/globalInfo";
|
import { useGlobalInfoStore } from "../context/globalInfo";
|
||||||
import { apiUrl } from "../apiConfig";
|
import { apiUrl } from "../apiConfig";
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import i18n from '../i18n';
|
||||||
import { useThemeMode } from "../context/theme-provider";
|
import { useThemeMode } from "../context/theme-provider";
|
||||||
|
|
||||||
const Login = () => {
|
const Login = () => {
|
||||||
const [form, setForm] = useState({ email: "", password: "" });
|
const { t } = useTranslation();
|
||||||
|
// just don't remove these logs - god knows why it's not working without them
|
||||||
|
console.log(i18n)
|
||||||
|
console.log(t)
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const { notify } = useGlobalInfoStore();
|
const { notify } = useGlobalInfoStore();
|
||||||
const { email, password } = form;
|
const { email, password } = form;
|
||||||
@@ -40,11 +50,11 @@ const Login = () => {
|
|||||||
{ withCredentials: true }
|
{ withCredentials: true }
|
||||||
);
|
);
|
||||||
dispatch({ type: "LOGIN", payload: data });
|
dispatch({ type: "LOGIN", payload: data });
|
||||||
notify("success", "Welcome to Maxun!");
|
notify("success", t('login.welcome_notification'));
|
||||||
window.localStorage.setItem("user", JSON.stringify(data));
|
window.localStorage.setItem("user", JSON.stringify(data));
|
||||||
navigate("/");
|
navigate("/");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify("error", "Login Failed. Please try again.");
|
notify("error", t('login.error_notification'));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -79,23 +89,13 @@ const Login = () => {
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img src="../src/assets/maxunlogo.png" alt="logo" height={55} width={60} style={{ marginBottom: 20, borderRadius: "20%", alignItems: "center" }} />
|
||||||
src="../src/assets/maxunlogo.png"
|
|
||||||
alt="logo"
|
|
||||||
height={55}
|
|
||||||
width={60}
|
|
||||||
style={{
|
|
||||||
marginBottom: 20,
|
|
||||||
borderRadius: "20%",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Typography variant="h4" gutterBottom>
|
<Typography variant="h4" gutterBottom>
|
||||||
Welcome Back!
|
{t('login.title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Email"
|
label={t('login.email')}
|
||||||
name="email"
|
name="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
@@ -114,7 +114,7 @@ const Login = () => {
|
|||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Password"
|
label={t('login.password')}
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
@@ -149,17 +149,17 @@ const Login = () => {
|
|||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<CircularProgress size={20} sx={{ mr: 2, color: "#ffffff" }} />
|
<CircularProgress size={20} sx={{ mr: 2 }} />
|
||||||
Loading
|
{t('login.loading')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Login"
|
t('login.button')
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Typography variant="body2" align="center" sx={{ color: darkMode ? "#ffffff" : "#333333" }}>
|
<Typography variant="body2" align="center">
|
||||||
Don’t have an account?{" "}
|
{t('login.register_prompt')}{" "}
|
||||||
<Link to="/register" style={{ textDecoration: "none", color: "#ff33cc" }}>
|
<Link to="/register" style={{ textDecoration: "none", color: "#ff33cc" }}>
|
||||||
Register
|
{t('login.register_link')}
|
||||||
</Link>
|
</Link>
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -167,4 +167,4 @@ const Login = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Login;
|
export default Login;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useCallback, useEffect } from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { MainMenu } from "../components/organisms/MainMenu";
|
import { MainMenu } from "../components/organisms/MainMenu";
|
||||||
import { Stack } from "@mui/material";
|
import { Stack } from "@mui/material";
|
||||||
import { Recordings } from "../components/organisms/Recordings";
|
import { Recordings } from "../components/organisms/Recordings";
|
||||||
@@ -30,7 +31,7 @@ export interface ScheduleRunResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const MainPage = ({ handleEditRecording }: MainPageProps) => {
|
export const MainPage = ({ handleEditRecording }: MainPageProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [content, setContent] = React.useState('recordings');
|
const [content, setContent] = React.useState('recordings');
|
||||||
const [sockets, setSockets] = React.useState<Socket[]>([]);
|
const [sockets, setSockets] = React.useState<Socket[]>([]);
|
||||||
const [runningRecordingId, setRunningRecordingId] = React.useState('');
|
const [runningRecordingId, setRunningRecordingId] = React.useState('');
|
||||||
@@ -49,10 +50,10 @@ export const MainPage = ({ handleEditRecording }: MainPageProps) => {
|
|||||||
aborted = true;
|
aborted = true;
|
||||||
notifyAboutAbort(runId).then(async (response) => {
|
notifyAboutAbort(runId).then(async (response) => {
|
||||||
if (response) {
|
if (response) {
|
||||||
notify('success', `Interpretation of robot ${runningRecordingName} aborted successfully`);
|
notify('success', t('main_page.notifications.abort_success', { name: runningRecordingName }));
|
||||||
await stopRecording(ids.browserId);
|
await stopRecording(ids.browserId);
|
||||||
} else {
|
} else {
|
||||||
notify('error', `Failed to abort the interpretation of ${runningRecordingName} robot`);
|
notify('error', t('main_page.notifications.abort_failed', { name: runningRecordingName }));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -67,9 +68,9 @@ export const MainPage = ({ handleEditRecording }: MainPageProps) => {
|
|||||||
interpretStoredRecording(runId).then(async (interpretation: boolean) => {
|
interpretStoredRecording(runId).then(async (interpretation: boolean) => {
|
||||||
if (!aborted) {
|
if (!aborted) {
|
||||||
if (interpretation) {
|
if (interpretation) {
|
||||||
notify('success', `Interpretation of robot ${runningRecordingName} succeeded`);
|
notify('success', t('main_page.notifications.interpretation_success', { name: runningRecordingName }));
|
||||||
} else {
|
} else {
|
||||||
notify('success', `Failed to interpret ${runningRecordingName} robot`);
|
notify('success', t('main_page.notifications.interpretation_failed', { name: runningRecordingName }));
|
||||||
// destroy the created browser
|
// destroy the created browser
|
||||||
await stopRecording(browserId);
|
await stopRecording(browserId);
|
||||||
}
|
}
|
||||||
@@ -98,9 +99,9 @@ export const MainPage = ({ handleEditRecording }: MainPageProps) => {
|
|||||||
socket.on('debugMessage', debugMessageHandler);
|
socket.on('debugMessage', debugMessageHandler);
|
||||||
setContent('runs');
|
setContent('runs');
|
||||||
if (browserId) {
|
if (browserId) {
|
||||||
notify('info', `Running robot: ${runningRecordingName}`);
|
notify('info', t('main_page.notifications.run_started', { name: runningRecordingName }));
|
||||||
} else {
|
} else {
|
||||||
notify('error', `Failed to run robot: ${runningRecordingName}`);
|
notify('error', t('main_page.notifications.run_start_failed', { name: runningRecordingName }));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return (socket: Socket, browserId: string, runId: string) => {
|
return (socket: Socket, browserId: string, runId: string) => {
|
||||||
@@ -113,9 +114,9 @@ export const MainPage = ({ handleEditRecording }: MainPageProps) => {
|
|||||||
scheduleStoredRecording(runningRecordingId, settings)
|
scheduleStoredRecording(runningRecordingId, settings)
|
||||||
.then(({ message, runId }: ScheduleRunResponse) => {
|
.then(({ message, runId }: ScheduleRunResponse) => {
|
||||||
if (message === 'success') {
|
if (message === 'success') {
|
||||||
notify('success', `Robot ${runningRecordingName} scheduled successfully`);
|
notify('success', t('main_page.notifications.schedule_success', { name: runningRecordingName }));
|
||||||
} else {
|
} else {
|
||||||
notify('error', `Failed to schedule robot ${runningRecordingName}`);
|
notify('error', t('main_page.notifications.schedule_failed', { name: runningRecordingName }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -151,4 +152,4 @@ export const MainPage = ({ handleEditRecording }: MainPageProps) => {
|
|||||||
{DisplayContent()}
|
{DisplayContent()}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -16,6 +16,7 @@ import { WhereWhatPair } from "maxun-core";
|
|||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import BrowserRecordingSave from '../components/molecules/BrowserRecordingSave';
|
import BrowserRecordingSave from '../components/molecules/BrowserRecordingSave';
|
||||||
import { useThemeMode } from '../context/theme-provider';
|
import { useThemeMode } from '../context/theme-provider';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface RecordingPageProps {
|
interface RecordingPageProps {
|
||||||
recordingName?: string;
|
recordingName?: string;
|
||||||
@@ -28,6 +29,7 @@ export interface PairForEdit {
|
|||||||
|
|
||||||
export const RecordingPage = ({ recordingName }: RecordingPageProps) => {
|
export const RecordingPage = ({ recordingName }: RecordingPageProps) => {
|
||||||
const { darkMode } = useThemeMode();
|
const { darkMode } = useThemeMode();
|
||||||
|
const { t } = useTranslation();
|
||||||
const [isLoaded, setIsLoaded] = React.useState(false);
|
const [isLoaded, setIsLoaded] = React.useState(false);
|
||||||
const [hasScrollbar, setHasScrollbar] = React.useState(false);
|
const [hasScrollbar, setHasScrollbar] = React.useState(false);
|
||||||
const [pairForEdit, setPairForEdit] = useState<PairForEdit>({
|
const [pairForEdit, setPairForEdit] = useState<PairForEdit>({
|
||||||
@@ -150,7 +152,7 @@ export const RecordingPage = ({ recordingName }: RecordingPageProps) => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Loader text={`Spinning up a browser...Navigating to ${recordingUrl}`} />
|
<Loader text={t('recording_page.loader.browser_startup', { url: recordingUrl })} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</BrowserStepsProvider>
|
</BrowserStepsProvider>
|
||||||
|
|||||||
@@ -6,9 +6,16 @@ import { Box, Typography, TextField, Button, CircularProgress } from "@mui/mater
|
|||||||
import { useGlobalInfoStore } from "../context/globalInfo";
|
import { useGlobalInfoStore } from "../context/globalInfo";
|
||||||
import { apiUrl } from "../apiConfig";
|
import { apiUrl } from "../apiConfig";
|
||||||
import { useThemeMode } from "../context/theme-provider";
|
import { useThemeMode } from "../context/theme-provider";
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import i18n from '../i18n';
|
||||||
|
|
||||||
|
|
||||||
const Register = () => {
|
const Register = () => {
|
||||||
const [form, setForm] = useState({ email: "", password: "" });
|
const {t} = useTranslation();
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const { notify } = useGlobalInfoStore();
|
const { notify } = useGlobalInfoStore();
|
||||||
const { email, password } = form;
|
const { email, password } = form;
|
||||||
@@ -37,11 +44,11 @@ const Register = () => {
|
|||||||
const { data } = await axios.post(`${apiUrl}/auth/register`, { email, password });
|
const { data } = await axios.post(`${apiUrl}/auth/register`, { email, password });
|
||||||
console.log(data);
|
console.log(data);
|
||||||
dispatch({ type: "LOGIN", payload: data });
|
dispatch({ type: "LOGIN", payload: data });
|
||||||
notify("success", "Registration Successful!");
|
notify("success", t('register.welcome_notification'));
|
||||||
window.localStorage.setItem("user", JSON.stringify(data));
|
window.localStorage.setItem("user", JSON.stringify(data));
|
||||||
navigate("/");
|
navigate("/");
|
||||||
} catch (error: any) {
|
} catch (error:any) {
|
||||||
notify("error", error.response?.data || "Registration Failed. Please try again.");
|
notify("error", error.response.data || t('register.error_notification'));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -88,11 +95,11 @@ const Register = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Typography variant="h4" gutterBottom>
|
<Typography variant="h4" gutterBottom>
|
||||||
Create an Account
|
{t('register.title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Email"
|
label={t('register.email')}
|
||||||
name="email"
|
name="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
@@ -111,7 +118,7 @@ const Register = () => {
|
|||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Password"
|
label={t('register.password')}
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
@@ -150,13 +157,13 @@ const Register = () => {
|
|||||||
Loading
|
Loading
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Register"
|
t('register.button')
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Typography variant="body2" align="center" sx={{ color: darkMode ? "#ffffff" : "#333333" }}>
|
<Typography variant="body2" align="center" sx={{ color: darkMode ? "#ffffff" : "#333333" }}>
|
||||||
Already have an account?{" "}
|
{t('register.register_prompt')}{" "}
|
||||||
<Link to="/login" style={{ textDecoration: "none", color: "#ff33cc" }}>
|
<Link to="/login" style={{ textDecoration: "none", color: "#ff33cc" }}>
|
||||||
Login
|
{t('register.login_link')}
|
||||||
</Link>
|
</Link>
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
Reference in New Issue
Block a user