Merge branch 'develop' into internationalization2
This commit is contained in:
@@ -16,7 +16,7 @@ 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"]
|
||||||
11
README.md
11
README.md
@@ -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.7
|
||||||
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.3
|
||||||
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.6",
|
||||||
"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",
|
||||||
|
|||||||
@@ -265,41 +265,72 @@ function scrapableHeuristics(maxCountPerPage = 50, minArea = 20000, scrolls = 3,
|
|||||||
const scrapedData = [];
|
const scrapedData = [];
|
||||||
|
|
||||||
while (scrapedData.length < limit) {
|
while (scrapedData.length < limit) {
|
||||||
// Get all parent elements matching the listSelector
|
let parentElements = Array.from(document.querySelectorAll(listSelector));
|
||||||
const parentElements = Array.from(document.querySelectorAll(listSelector));
|
|
||||||
|
// If we only got one element or none, try a more generic approach
|
||||||
|
if (limit > 1 && parentElements.length <= 1) {
|
||||||
|
const [containerSelector, _] = listSelector.split('>').map(s => s.trim());
|
||||||
|
const container = document.querySelector(containerSelector);
|
||||||
|
|
||||||
|
if (container) {
|
||||||
|
const allChildren = Array.from(container.children);
|
||||||
|
|
||||||
|
const firstMatch = document.querySelector(listSelector);
|
||||||
|
if (firstMatch) {
|
||||||
|
// Get classes from the first matching element
|
||||||
|
const firstMatchClasses = Array.from(firstMatch.classList);
|
||||||
|
|
||||||
|
// Find similar elements by matching most of their classes
|
||||||
|
parentElements = allChildren.filter(element => {
|
||||||
|
const elementClasses = Array.from(element.classList);
|
||||||
|
|
||||||
// Iterate through each parent element
|
// Element should share at least 70% of classes with the first match
|
||||||
for (const parent of parentElements) {
|
const commonClasses = firstMatchClasses.filter(cls =>
|
||||||
if (scrapedData.length >= limit) break;
|
elementClasses.includes(cls));
|
||||||
const record = {};
|
return commonClasses.length >= Math.floor(firstMatchClasses.length * 0.7);
|
||||||
|
});
|
||||||
// For each field, select the corresponding element within the parent
|
}
|
||||||
for (const [label, { selector, attribute }] of Object.entries(fields)) {
|
|
||||||
const fieldElement = parent.querySelector(selector);
|
|
||||||
|
|
||||||
if (fieldElement) {
|
|
||||||
if (attribute === 'innerText') {
|
|
||||||
record[label] = fieldElement.innerText.trim();
|
|
||||||
} else if (attribute === 'innerHTML') {
|
|
||||||
record[label] = fieldElement.innerHTML.trim();
|
|
||||||
} else if (attribute === 'src') {
|
|
||||||
// Handle relative 'src' URLs
|
|
||||||
const src = fieldElement.getAttribute('src');
|
|
||||||
record[label] = src ? new URL(src, window.location.origin).href : null;
|
|
||||||
} else if (attribute === 'href') {
|
|
||||||
// Handle relative 'href' URLs
|
|
||||||
const href = fieldElement.getAttribute('href');
|
|
||||||
record[label] = href ? new URL(href, window.location.origin).href : null;
|
|
||||||
} else {
|
|
||||||
record[label] = fieldElement.getAttribute(attribute);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
scrapedData.push(record);
|
|
||||||
}
|
// Iterate through each parent element
|
||||||
|
for (const parent of parentElements) {
|
||||||
|
if (scrapedData.length >= limit) break;
|
||||||
|
const record = {};
|
||||||
|
|
||||||
|
// For each field, select the corresponding element within the parent
|
||||||
|
for (const [label, { selector, attribute }] of Object.entries(fields)) {
|
||||||
|
const fieldElement = parent.querySelector(selector);
|
||||||
|
|
||||||
|
if (fieldElement) {
|
||||||
|
if (attribute === 'innerText') {
|
||||||
|
record[label] = fieldElement.innerText.trim();
|
||||||
|
} else if (attribute === 'innerHTML') {
|
||||||
|
record[label] = fieldElement.innerHTML.trim();
|
||||||
|
} else if (attribute === 'src') {
|
||||||
|
// Handle relative 'src' URLs
|
||||||
|
const src = fieldElement.getAttribute('src');
|
||||||
|
record[label] = src ? new URL(src, window.location.origin).href : null;
|
||||||
|
} else if (attribute === 'href') {
|
||||||
|
// Handle relative 'href' URLs
|
||||||
|
const href = fieldElement.getAttribute('href');
|
||||||
|
record[label] = href ? new URL(href, window.location.origin).href : null;
|
||||||
|
} else {
|
||||||
|
record[label] = fieldElement.getAttribute(attribute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scrapedData.push(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we've processed all available elements and still haven't reached the limit,
|
||||||
|
// break to avoid infinite loop
|
||||||
|
if (parentElements.length === 0 || scrapedData.length >= parentElements.length) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return scrapedData
|
return scrapedData;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -192,8 +192,8 @@ export default class Interpreter extends EventEmitter {
|
|||||||
// const actionable = async (selector: string): Promise<boolean> => {
|
// const actionable = async (selector: string): Promise<boolean> => {
|
||||||
// try {
|
// try {
|
||||||
// const proms = [
|
// const proms = [
|
||||||
// page.isEnabled(selector, { timeout: 5000 }),
|
// page.isEnabled(selector, { timeout: 10000 }),
|
||||||
// page.isVisible(selector, { timeout: 5000 }),
|
// 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));
|
||||||
@@ -214,6 +214,17 @@ export default class Interpreter extends EventEmitter {
|
|||||||
// return [];
|
// return [];
|
||||||
// }),
|
// }),
|
||||||
// ).then((x) => x.flat());
|
// ).then((x) => x.flat());
|
||||||
|
|
||||||
|
const presentSelectors: SelectorArray = await Promise.all(
|
||||||
|
selectors.map(async (selector) => {
|
||||||
|
try {
|
||||||
|
await page.waitForSelector(selector, { state: 'attached' });
|
||||||
|
return [selector];
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
).then((x) => x.flat());
|
||||||
|
|
||||||
const action = workflowCopy[workflowCopy.length - 1];
|
const action = workflowCopy[workflowCopy.length - 1];
|
||||||
|
|
||||||
@@ -233,7 +244,7 @@ export default class Interpreter extends EventEmitter {
|
|||||||
...p,
|
...p,
|
||||||
[cookie.name]: cookie.value,
|
[cookie.name]: cookie.value,
|
||||||
}), {}),
|
}), {}),
|
||||||
selectors,
|
selectors: presentSelectors,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,7 +517,11 @@ export default class Interpreter extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
await executeAction(invokee, methodName, step.args);
|
await executeAction(invokee, methodName, step.args);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await executeAction(invokee, methodName, [step.args[0], { force: true }]);
|
try{
|
||||||
|
await executeAction(invokee, methodName, [step.args[0], { force: true }]);
|
||||||
|
} catch (error) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await executeAction(invokee, methodName, step.args);
|
await executeAction(invokee, methodName, step.args);
|
||||||
@@ -767,6 +782,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);
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"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.6",
|
||||||
"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",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -50,7 +50,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"]
|
||||||
@@ -104,7 +104,7 @@ export class RemoteBrowser {
|
|||||||
} catch {
|
} catch {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines if a URL change is significant enough to emit
|
* Determines if a URL change is significant enough to emit
|
||||||
@@ -130,11 +130,11 @@ export class RemoteBrowser {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Handle page load events with retry mechanism
|
// Handle page load events with retry mechanism
|
||||||
page.on('load', async () => {
|
page.on('load', async () => {
|
||||||
const injectScript = async (): Promise<boolean> => {
|
const injectScript = async (): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
await page.waitForLoadState('networkidle', { timeout: 5000 });
|
await page.waitForLoadState('networkidle', { timeout: 5000 });
|
||||||
|
|
||||||
await page.evaluate(getInjectableScript());
|
await page.evaluate(getInjectableScript());
|
||||||
return true;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -148,6 +148,19 @@ export class RemoteBrowser {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -155,37 +168,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: '' };
|
||||||
@@ -201,7 +194,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,
|
||||||
@@ -210,7 +203,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) {
|
||||||
@@ -220,19 +214,38 @@ 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();
|
||||||
|
|
||||||
await this.setupPageEventListeners(this.currentPage);
|
await this.setupPageEventListeners(this.currentPage);
|
||||||
|
|
||||||
// await this.currentPage.setExtraHTTPHeaders({
|
const blocker = await PlaywrightBlocker.fromLists(fetch, ['https://easylist.to/easylist/easylist.txt']);
|
||||||
// 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'
|
|
||||||
// });
|
|
||||||
const blocker = await PlaywrightBlocker.fromPrebuiltAdsAndTracking(fetch);
|
|
||||||
await blocker.enableBlockingInPage(this.currentPage);
|
await blocker.enableBlockingInPage(this.currentPage);
|
||||||
this.client = await this.currentPage.context().newCDPSession(this.currentPage);
|
this.client = await this.currentPage.context().newCDPSession(this.currentPage);
|
||||||
await blocker.disableBlockingInPage(this.currentPage);
|
await blocker.disableBlockingInPage(this.currentPage);
|
||||||
@@ -456,7 +469,7 @@ export class RemoteBrowser {
|
|||||||
this.currentPage = newPage;
|
this.currentPage = newPage;
|
||||||
if (this.currentPage) {
|
if (this.currentPage) {
|
||||||
await this.setupPageEventListeners(this.currentPage);
|
await this.setupPageEventListeners(this.currentPage);
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,16 +726,14 @@ 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) {
|
||||||
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', { rect, selector: displaySelector, elementInfo, 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', { rect, selector: displaySelector, elementInfo });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,161 @@
|
|||||||
import { Page } from "playwright";
|
import { Page } from "playwright";
|
||||||
import { Action, ActionType, Coordinates, TagName } from "../types";
|
import { Coordinates } from "../types";
|
||||||
import { WhereWhatPair, WorkflowFile } from "maxun-core";
|
import { WhereWhatPair, WorkflowFile } from "maxun-core";
|
||||||
import logger from "../logger";
|
import logger from "../logger";
|
||||||
import { getBestSelectorForAction } from "./utils";
|
|
||||||
|
|
||||||
/*TODO:
|
|
||||||
1. Handle TS errors (here we definetly know better)
|
|
||||||
2. Add pending function descriptions + thought process (esp. selector generation)
|
|
||||||
*/
|
|
||||||
|
|
||||||
type Workflow = WorkflowFile["workflow"];
|
type Workflow = WorkflowFile["workflow"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the basic info about an element and returns a {@link BaseActionInfo} object.
|
||||||
|
* If the element is not found, returns undefined.
|
||||||
|
* @param page The page instance.
|
||||||
|
* @param coordinates Coordinates of an element.
|
||||||
|
* @category WorkflowManagement-Selectors
|
||||||
|
* @returns {Promise<BaseActionInfo|undefined>}
|
||||||
|
*/
|
||||||
|
export const getElementInformation = async (
|
||||||
|
page: Page,
|
||||||
|
coordinates: Coordinates,
|
||||||
|
listSelector: string,
|
||||||
|
getList: boolean
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (!getList || listSelector !== '') {
|
||||||
|
const elementInfo = await page.evaluate(
|
||||||
|
async ({ x, y }) => {
|
||||||
|
const el = document.elementFromPoint(x, y) as HTMLElement;
|
||||||
|
if (el) {
|
||||||
|
const { parentElement } = el;
|
||||||
|
const element = parentElement?.tagName === 'A' ? parentElement : el;
|
||||||
|
let info: {
|
||||||
|
tagName: string;
|
||||||
|
hasOnlyText?: boolean;
|
||||||
|
innerText?: string;
|
||||||
|
url?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
attributes?: Record<string, string>;
|
||||||
|
innerHTML?: string;
|
||||||
|
outerHTML?: string;
|
||||||
|
} = {
|
||||||
|
tagName: element?.tagName ?? '',
|
||||||
|
};
|
||||||
|
if (element) {
|
||||||
|
info.attributes = Array.from(element.attributes).reduce(
|
||||||
|
(acc, attr) => {
|
||||||
|
acc[attr.name] = attr.value;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Gather specific information based on the tag
|
||||||
|
if (element?.tagName === 'A') {
|
||||||
|
info.url = (element as HTMLAnchorElement).href;
|
||||||
|
info.innerText = element.innerText ?? '';
|
||||||
|
} else if (element?.tagName === 'IMG') {
|
||||||
|
info.imageUrl = (element as HTMLImageElement).src;
|
||||||
|
} else if (element?.tagName === 'SELECT') {
|
||||||
|
const selectElement = element as HTMLSelectElement;
|
||||||
|
info.innerText = selectElement.options[selectElement.selectedIndex]?.text ?? '';
|
||||||
|
info.attributes = {
|
||||||
|
...info.attributes,
|
||||||
|
selectedValue: selectElement.value,
|
||||||
|
};
|
||||||
|
} else if (element?.tagName === 'INPUT' && (element as HTMLInputElement).type === 'time' || (element as HTMLInputElement).type === 'date') {
|
||||||
|
info.innerText = (element as HTMLInputElement).value;
|
||||||
|
} else {
|
||||||
|
info.hasOnlyText = element?.children?.length === 0 &&
|
||||||
|
element?.innerText?.length > 0;
|
||||||
|
info.innerText = element?.innerText ?? '';
|
||||||
|
}
|
||||||
|
info.innerHTML = element.innerHTML;
|
||||||
|
info.outerHTML = element.outerHTML;
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
{ x: coordinates.x, y: coordinates.y },
|
||||||
|
);
|
||||||
|
return elementInfo;
|
||||||
|
} else {
|
||||||
|
const elementInfo = await page.evaluate(
|
||||||
|
async ({ x, y }) => {
|
||||||
|
const originalEl = document.elementFromPoint(x, y) as HTMLElement;
|
||||||
|
if (originalEl) {
|
||||||
|
let element = originalEl;
|
||||||
|
|
||||||
|
while (element.parentElement) {
|
||||||
|
const parentRect = element.parentElement.getBoundingClientRect();
|
||||||
|
const childRect = element.getBoundingClientRect();
|
||||||
|
|
||||||
|
const fullyContained =
|
||||||
|
parentRect.left <= childRect.left &&
|
||||||
|
parentRect.right >= childRect.right &&
|
||||||
|
parentRect.top <= childRect.top &&
|
||||||
|
parentRect.bottom >= childRect.bottom;
|
||||||
|
|
||||||
|
const significantOverlap =
|
||||||
|
(childRect.width * childRect.height) /
|
||||||
|
(parentRect.width * parentRect.height) > 0.5;
|
||||||
|
|
||||||
|
if (fullyContained && significantOverlap) {
|
||||||
|
element = element.parentElement;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let info: {
|
||||||
|
tagName: string;
|
||||||
|
hasOnlyText?: boolean;
|
||||||
|
innerText?: string;
|
||||||
|
url?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
attributes?: Record<string, string>;
|
||||||
|
innerHTML?: string;
|
||||||
|
outerHTML?: string;
|
||||||
|
} = {
|
||||||
|
tagName: element?.tagName ?? '',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
info.attributes = Array.from(element.attributes).reduce(
|
||||||
|
(acc, attr) => {
|
||||||
|
acc[attr.name] = attr.value;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element?.tagName === 'A') {
|
||||||
|
info.url = (element as HTMLAnchorElement).href;
|
||||||
|
info.innerText = element.innerText ?? '';
|
||||||
|
} else if (element?.tagName === 'IMG') {
|
||||||
|
info.imageUrl = (element as HTMLImageElement).src;
|
||||||
|
} else {
|
||||||
|
info.hasOnlyText = element?.children?.length === 0 &&
|
||||||
|
element?.innerText?.length > 0;
|
||||||
|
info.innerText = element?.innerText ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
info.innerHTML = element.innerHTML;
|
||||||
|
info.outerHTML = element.outerHTML;
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
{ x: coordinates.x, y: coordinates.y },
|
||||||
|
);
|
||||||
|
return elementInfo;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const { message, stack } = error as Error;
|
||||||
|
console.error('Error while retrieving selector:', message);
|
||||||
|
console.error('Stack:', stack);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a {@link Rectangle} object representing
|
* Returns a {@link Rectangle} object representing
|
||||||
* the coordinates, width, height and corner points of the element.
|
* the coordinates, width, height and corner points of the element.
|
||||||
@@ -20,182 +165,89 @@ type Workflow = WorkflowFile["workflow"];
|
|||||||
* @category WorkflowManagement-Selectors
|
* @category WorkflowManagement-Selectors
|
||||||
* @returns {Promise<Rectangle|undefined|null>}
|
* @returns {Promise<Rectangle|undefined|null>}
|
||||||
*/
|
*/
|
||||||
export const getElementInformation = async (
|
export const getRect = async (page: Page, coordinates: Coordinates, listSelector: string, getList: boolean) => {
|
||||||
page: Page,
|
|
||||||
coordinates: Coordinates
|
|
||||||
) => {
|
|
||||||
try {
|
try {
|
||||||
const elementInfo = await page.evaluate(
|
if (!getList || listSelector !== '') {
|
||||||
async ({ x, y }) => {
|
const rect = await page.evaluate(
|
||||||
const originalEl = document.elementFromPoint(x, y) as HTMLElement;
|
async ({ x, y }) => {
|
||||||
if (originalEl) {
|
const el = document.elementFromPoint(x, y) as HTMLElement;
|
||||||
let element = originalEl;
|
if (el) {
|
||||||
|
const { parentElement } = el;
|
||||||
// if (originalEl.tagName === 'A') {
|
// Match the logic in recorder.ts for link clicks
|
||||||
// element = originalEl;
|
const element = parentElement?.tagName === 'A' ? parentElement : el;
|
||||||
// } else if (originalEl.parentElement?.tagName === 'A') {
|
const rectangle = element?.getBoundingClientRect();
|
||||||
// element = originalEl.parentElement;
|
if (rectangle) {
|
||||||
// } else {
|
return {
|
||||||
// Generic parent finding logic based on visual containment
|
x: rectangle.x,
|
||||||
const containerTags = ['DIV', 'SECTION', 'ARTICLE', 'MAIN', 'HEADER', 'FOOTER', 'NAV', 'ASIDE',
|
y: rectangle.y,
|
||||||
'ADDRESS', 'BLOCKQUOTE', 'DETAILS', 'DIALOG', 'FIGURE', 'FIGCAPTION', 'MAIN', 'MARK', 'SUMMARY', 'TIME',
|
width: rectangle.width,
|
||||||
'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR', 'TH', 'TD', 'CAPTION', 'COLGROUP', 'COL', 'FORM', 'FIELDSET',
|
height: rectangle.height,
|
||||||
'LEGEND', 'LABEL', 'INPUT', 'BUTTON', 'SELECT', 'DATALIST', 'OPTGROUP', 'OPTION', 'TEXTAREA', 'OUTPUT',
|
top: rectangle.top,
|
||||||
'PROGRESS', 'METER', 'DETAILS', 'SUMMARY', 'MENU', 'MENUITEM', 'MENUITEM', 'APPLET', 'EMBED', 'OBJECT',
|
right: rectangle.right,
|
||||||
'PARAM', 'VIDEO', 'AUDIO', 'SOURCE', 'TRACK', 'CANVAS', 'MAP', 'AREA', 'SVG', 'IFRAME', 'FRAME', 'FRAMESET',
|
bottom: rectangle.bottom,
|
||||||
'LI', 'UL', 'OL', 'DL', 'DT', 'DD', 'HR', 'P', 'PRE', 'LISTING', 'PLAINTEXT', 'A'
|
left: rectangle.left,
|
||||||
];
|
};
|
||||||
while (element.parentElement) {
|
}
|
||||||
const parentRect = element.parentElement.getBoundingClientRect();
|
}
|
||||||
const childRect = element.getBoundingClientRect();
|
},
|
||||||
|
{ x: coordinates.x, y: coordinates.y },
|
||||||
|
);
|
||||||
|
return rect;
|
||||||
|
} else {
|
||||||
|
const rect = await page.evaluate(
|
||||||
|
async ({ x, y }) => {
|
||||||
|
const originalEl = document.elementFromPoint(x, y) as HTMLElement;
|
||||||
|
if (originalEl) {
|
||||||
|
let element = originalEl;
|
||||||
|
|
||||||
if (!containerTags.includes(element.parentElement.tagName)) {
|
while (element.parentElement) {
|
||||||
break;
|
const parentRect = element.parentElement.getBoundingClientRect();
|
||||||
|
const childRect = element.getBoundingClientRect();
|
||||||
|
|
||||||
|
const fullyContained =
|
||||||
|
parentRect.left <= childRect.left &&
|
||||||
|
parentRect.right >= childRect.right &&
|
||||||
|
parentRect.top <= childRect.top &&
|
||||||
|
parentRect.bottom >= childRect.bottom;
|
||||||
|
|
||||||
|
const significantOverlap =
|
||||||
|
(childRect.width * childRect.height) /
|
||||||
|
(parentRect.width * parentRect.height) > 0.5;
|
||||||
|
|
||||||
|
if (fullyContained && significantOverlap) {
|
||||||
|
element = element.parentElement;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if parent visually contains the child
|
const rectangle = element?.getBoundingClientRect();
|
||||||
const fullyContained =
|
|
||||||
parentRect.left <= childRect.left &&
|
|
||||||
parentRect.right >= childRect.right &&
|
|
||||||
parentRect.top <= childRect.top &&
|
|
||||||
parentRect.bottom >= childRect.bottom;
|
|
||||||
|
|
||||||
// Additional checks for more comprehensive containment
|
if (rectangle) {
|
||||||
const significantOverlap =
|
return {
|
||||||
(childRect.width * childRect.height) /
|
x: rectangle.x,
|
||||||
(parentRect.width * parentRect.height) > 0.5;
|
y: rectangle.y,
|
||||||
|
width: rectangle.width,
|
||||||
if (fullyContained && significantOverlap) {
|
height: rectangle.height,
|
||||||
element = element.parentElement;
|
top: rectangle.top,
|
||||||
} else {
|
right: rectangle.right,
|
||||||
break;
|
bottom: rectangle.bottom,
|
||||||
// }
|
left: rectangle.left,
|
||||||
} }
|
};
|
||||||
|
|
||||||
let info: {
|
|
||||||
tagName: string;
|
|
||||||
hasOnlyText?: boolean;
|
|
||||||
innerText?: string;
|
|
||||||
url?: string;
|
|
||||||
imageUrl?: string;
|
|
||||||
attributes?: Record<string, string>;
|
|
||||||
innerHTML?: string;
|
|
||||||
outerHTML?: string;
|
|
||||||
} = {
|
|
||||||
tagName: element?.tagName ?? '',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (element) {
|
|
||||||
info.attributes = Array.from(element.attributes).reduce(
|
|
||||||
(acc, attr) => {
|
|
||||||
acc[attr.name] = attr.value;
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, string>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Existing tag-specific logic
|
|
||||||
if (element?.tagName === 'A') {
|
|
||||||
info.url = (element as HTMLAnchorElement).href;
|
|
||||||
info.innerText = element.innerText ?? '';
|
|
||||||
} else if (element?.tagName === 'IMG') {
|
|
||||||
info.imageUrl = (element as HTMLImageElement).src;
|
|
||||||
} else {
|
|
||||||
info.hasOnlyText = element?.children?.length === 0 &&
|
|
||||||
element?.innerText?.length > 0;
|
|
||||||
info.innerText = element?.innerText ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
info.innerHTML = element.innerHTML;
|
|
||||||
info.outerHTML = element.outerHTML;
|
|
||||||
return info;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
{ x: coordinates.x, y: coordinates.y },
|
|
||||||
);
|
|
||||||
return elementInfo;
|
|
||||||
} catch (error) {
|
|
||||||
const { message, stack } = error as Error;
|
|
||||||
console.error('Error while retrieving selector:', message);
|
|
||||||
console.error('Stack:', stack);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getRect = async (page: Page, coordinates: Coordinates) => {
|
|
||||||
try {
|
|
||||||
const rect = await page.evaluate(
|
|
||||||
async ({ x, y }) => {
|
|
||||||
const originalEl = document.elementFromPoint(x, y) as HTMLElement;
|
|
||||||
if (originalEl) {
|
|
||||||
let element = originalEl;
|
|
||||||
|
|
||||||
// if (originalEl.tagName === 'A') {
|
|
||||||
// element = originalEl;
|
|
||||||
// } else if (originalEl.parentElement?.tagName === 'A') {
|
|
||||||
// element = originalEl.parentElement;
|
|
||||||
// } else {
|
|
||||||
const containerTags = ['DIV', 'SECTION', 'ARTICLE', 'MAIN', 'HEADER', 'FOOTER', 'NAV', 'ASIDE',
|
|
||||||
'ADDRESS', 'BLOCKQUOTE', 'DETAILS', 'DIALOG', 'FIGURE', 'FIGCAPTION', 'MAIN', 'MARK', 'SUMMARY', 'TIME',
|
|
||||||
'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR', 'TH', 'TD', 'CAPTION', 'COLGROUP', 'COL', 'FORM', 'FIELDSET',
|
|
||||||
'LEGEND', 'LABEL', 'INPUT', 'BUTTON', 'SELECT', 'DATALIST', 'OPTGROUP', 'OPTION', 'TEXTAREA', 'OUTPUT',
|
|
||||||
'PROGRESS', 'METER', 'DETAILS', 'SUMMARY', 'MENU', 'MENUITEM', 'MENUITEM', 'APPLET', 'EMBED', 'OBJECT',
|
|
||||||
'PARAM', 'VIDEO', 'AUDIO', 'SOURCE', 'TRACK', 'CANVAS', 'MAP', 'AREA', 'SVG', 'IFRAME', 'FRAME', 'FRAMESET',
|
|
||||||
'LI', 'UL', 'OL', 'DL', 'DT', 'DD', 'HR', 'P', 'PRE', 'LISTING', 'PLAINTEXT', 'A'
|
|
||||||
];
|
|
||||||
while (element.parentElement) {
|
|
||||||
const parentRect = element.parentElement.getBoundingClientRect();
|
|
||||||
const childRect = element.getBoundingClientRect();
|
|
||||||
|
|
||||||
if (!containerTags.includes(element.parentElement.tagName)) {
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const fullyContained =
|
|
||||||
parentRect.left <= childRect.left &&
|
|
||||||
parentRect.right >= childRect.right &&
|
|
||||||
parentRect.top <= childRect.top &&
|
|
||||||
parentRect.bottom >= childRect.bottom;
|
|
||||||
|
|
||||||
const significantOverlap =
|
|
||||||
(childRect.width * childRect.height) /
|
|
||||||
(parentRect.width * parentRect.height) > 0.5;
|
|
||||||
|
|
||||||
if (fullyContained && significantOverlap) {
|
|
||||||
element = element.parentElement;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
// }
|
|
||||||
}}
|
|
||||||
|
|
||||||
//element = element?.parentElement?.tagName === 'A' ? element?.parentElement : element;
|
|
||||||
const rectangle = element?.getBoundingClientRect();
|
|
||||||
|
|
||||||
if (rectangle) {
|
|
||||||
return {
|
|
||||||
x: rectangle.x,
|
|
||||||
y: rectangle.y,
|
|
||||||
width: rectangle.width,
|
|
||||||
height: rectangle.height,
|
|
||||||
top: rectangle.top,
|
|
||||||
right: rectangle.right,
|
|
||||||
bottom: rectangle.bottom,
|
|
||||||
left: rectangle.left,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
return null;
|
||||||
},
|
},
|
||||||
{ x: coordinates.x, y: coordinates.y },
|
{ x: coordinates.x, y: coordinates.y },
|
||||||
);
|
);
|
||||||
return rect;
|
return rect;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const { message, stack } = error as Error;
|
const { message, stack } = error as Error;
|
||||||
logger.log('error', `Error while retrieving selector: ${message}`);
|
logger.log('error', `Error while retrieving selector: ${message}`);
|
||||||
logger.log('error', `Stack: ${stack}`);
|
logger.log('error', `Stack: ${stack}`);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -809,86 +861,123 @@ interface SelectorResult {
|
|||||||
* @returns {Promise<Selectors|null|undefined>}
|
* @returns {Promise<Selectors|null|undefined>}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const getNonUniqueSelectors = async (page: Page, coordinates: Coordinates): Promise<SelectorResult> => {
|
export const getNonUniqueSelectors = async (page: Page, coordinates: Coordinates, listSelector: string): Promise<SelectorResult> => {
|
||||||
try {
|
try {
|
||||||
const selectors = await page.evaluate(({ x, y }: { x: number, y: number }) => {
|
if (!listSelector) {
|
||||||
function getNonUniqueSelector(element: HTMLElement): string {
|
console.log(`NON UNIQUE: MODE 1`)
|
||||||
let selector = element.tagName.toLowerCase();
|
const selectors = await page.evaluate(({ x, y }: { x: number, y: number }) => {
|
||||||
|
function getNonUniqueSelector(element: HTMLElement): string {
|
||||||
|
let selector = element.tagName.toLowerCase();
|
||||||
|
|
||||||
if (element.className) {
|
if (element.className) {
|
||||||
const classes = element.className.split(/\s+/).filter((cls: string) => Boolean(cls));
|
const classes = element.className.split(/\s+/).filter((cls: string) => Boolean(cls));
|
||||||
if (classes.length > 0) {
|
if (classes.length > 0) {
|
||||||
const validClasses = classes.filter((cls: string) => !cls.startsWith('!') && !cls.includes(':'));
|
const validClasses = classes.filter((cls: string) => !cls.startsWith('!') && !cls.includes(':'));
|
||||||
if (validClasses.length > 0) {
|
if (validClasses.length > 0) {
|
||||||
selector += '.' + validClasses.map(cls => CSS.escape(cls)).join('.');
|
selector += '.' + validClasses.map(cls => CSS.escape(cls)).join('.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return selector;
|
||||||
}
|
}
|
||||||
|
|
||||||
return selector;
|
function getSelectorPath(element: HTMLElement | null): string {
|
||||||
}
|
const path: string[] = [];
|
||||||
|
let depth = 0;
|
||||||
|
const maxDepth = 2;
|
||||||
|
|
||||||
function getSelectorPath(element: HTMLElement | null): string {
|
while (element && element !== document.body && depth < maxDepth) {
|
||||||
const path: string[] = [];
|
const selector = getNonUniqueSelector(element);
|
||||||
let depth = 0;
|
path.unshift(selector);
|
||||||
const maxDepth = 2;
|
element = element.parentElement;
|
||||||
|
depth++;
|
||||||
|
}
|
||||||
|
|
||||||
while (element && element !== document.body && depth < maxDepth) {
|
return path.join(' > ');
|
||||||
const selector = getNonUniqueSelector(element);
|
|
||||||
path.unshift(selector);
|
|
||||||
element = element.parentElement;
|
|
||||||
depth++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return path.join(' > ');
|
const originalEl = document.elementFromPoint(x, y) as HTMLElement;
|
||||||
}
|
if (!originalEl) return null;
|
||||||
|
|
||||||
const originalEl = document.elementFromPoint(x, y) as HTMLElement;
|
let element = originalEl;
|
||||||
if (!originalEl) return null;
|
|
||||||
|
|
||||||
let element = originalEl;
|
// if (listSelector === '') {
|
||||||
|
while (element.parentElement) {
|
||||||
|
const parentRect = element.parentElement.getBoundingClientRect();
|
||||||
|
const childRect = element.getBoundingClientRect();
|
||||||
|
|
||||||
const containerTags = ['DIV', 'SECTION', 'ARTICLE', 'MAIN', 'HEADER', 'FOOTER', 'NAV', 'ASIDE',
|
const fullyContained =
|
||||||
'ADDRESS', 'BLOCKQUOTE', 'DETAILS', 'DIALOG', 'FIGURE', 'FIGCAPTION', 'MAIN', 'MARK', 'SUMMARY', 'TIME',
|
parentRect.left <= childRect.left &&
|
||||||
'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR', 'TH', 'TD', 'CAPTION', 'COLGROUP', 'COL', 'FORM', 'FIELDSET',
|
parentRect.right >= childRect.right &&
|
||||||
'LEGEND', 'LABEL', 'INPUT', 'BUTTON', 'SELECT', 'DATALIST', 'OPTGROUP', 'OPTION', 'TEXTAREA', 'OUTPUT',
|
parentRect.top <= childRect.top &&
|
||||||
'PROGRESS', 'METER', 'DETAILS', 'SUMMARY', 'MENU', 'MENUITEM', 'MENUITEM', 'APPLET', 'EMBED', 'OBJECT',
|
parentRect.bottom >= childRect.bottom;
|
||||||
'PARAM', 'VIDEO', 'AUDIO', 'SOURCE', 'TRACK', 'CANVAS', 'MAP', 'AREA', 'SVG', 'IFRAME', 'FRAME', 'FRAMESET',
|
|
||||||
'LI', 'UL', 'OL', 'DL', 'DT', 'DD', 'HR', 'P', 'PRE', 'LISTING', 'PLAINTEXT', 'A'
|
|
||||||
];
|
|
||||||
|
|
||||||
while (element.parentElement) {
|
|
||||||
const parentRect = element.parentElement.getBoundingClientRect();
|
|
||||||
const childRect = element.getBoundingClientRect();
|
|
||||||
|
|
||||||
if (!containerTags.includes(element.parentElement.tagName)) {
|
const significantOverlap =
|
||||||
break;
|
(childRect.width * childRect.height) /
|
||||||
|
(parentRect.width * parentRect.height) > 0.5;
|
||||||
|
|
||||||
|
if (fullyContained && significantOverlap) {
|
||||||
|
element = element.parentElement;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// }
|
||||||
|
|
||||||
|
const generalSelector = getSelectorPath(element);
|
||||||
|
return {
|
||||||
|
generalSelector,
|
||||||
|
};
|
||||||
|
}, coordinates);
|
||||||
|
return selectors || { generalSelector: '' };
|
||||||
|
} else {
|
||||||
|
console.log(`NON UNIQUE: MODE 2`)
|
||||||
|
const selectors = await page.evaluate(({ x, y }: { x: number, y: number }) => {
|
||||||
|
function getNonUniqueSelector(element: HTMLElement): string {
|
||||||
|
let selector = element.tagName.toLowerCase();
|
||||||
|
|
||||||
|
if (element.className) {
|
||||||
|
const classes = element.className.split(/\s+/).filter((cls: string) => Boolean(cls));
|
||||||
|
if (classes.length > 0) {
|
||||||
|
const validClasses = classes.filter((cls: string) => !cls.startsWith('!') && !cls.includes(':'));
|
||||||
|
if (validClasses.length > 0) {
|
||||||
|
selector += '.' + validClasses.map(cls => CSS.escape(cls)).join('.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selector;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullyContained =
|
function getSelectorPath(element: HTMLElement | null): string {
|
||||||
parentRect.left <= childRect.left &&
|
const path: string[] = [];
|
||||||
parentRect.right >= childRect.right &&
|
let depth = 0;
|
||||||
parentRect.top <= childRect.top &&
|
const maxDepth = 2;
|
||||||
parentRect.bottom >= childRect.bottom;
|
|
||||||
|
|
||||||
const significantOverlap =
|
while (element && element !== document.body && depth < maxDepth) {
|
||||||
(childRect.width * childRect.height) /
|
const selector = getNonUniqueSelector(element);
|
||||||
(parentRect.width * parentRect.height) > 0.5;
|
path.unshift(selector);
|
||||||
|
element = element.parentElement;
|
||||||
|
depth++;
|
||||||
|
}
|
||||||
|
|
||||||
if (fullyContained && significantOverlap) {
|
return path.join(' > ');
|
||||||
element = element.parentElement;
|
}
|
||||||
} else {
|
|
||||||
break;
|
const originalEl = document.elementFromPoint(x, y) as HTMLElement;
|
||||||
}
|
if (!originalEl) return null;
|
||||||
|
|
||||||
|
let element = originalEl;
|
||||||
|
|
||||||
|
const generalSelector = getSelectorPath(element);
|
||||||
|
return {
|
||||||
|
generalSelector,
|
||||||
|
};
|
||||||
|
}, coordinates);
|
||||||
|
return selectors || { generalSelector: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const generalSelector = getSelectorPath(element);
|
|
||||||
return {
|
|
||||||
generalSelector,
|
|
||||||
};
|
|
||||||
}, coordinates);
|
|
||||||
|
|
||||||
return selectors || { generalSelector: '' };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in getNonUniqueSelectors:', error);
|
console.error('Error in getNonUniqueSelectors:', error);
|
||||||
return { generalSelector: '' };
|
return { generalSelector: '' };
|
||||||
|
|||||||
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;
|
||||||
@@ -3,6 +3,10 @@ import { useSocketStore } from '../../context/socket';
|
|||||||
import { getMappedCoordinates } from "../../helpers/inputHelpers";
|
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';
|
||||||
|
import DatePicker from './DatePicker';
|
||||||
|
import Dropdown from './Dropdown';
|
||||||
|
import TimePicker from './TimePicker';
|
||||||
|
import DateTimeLocalPicker from './DateTimeLocalPicker';
|
||||||
|
|
||||||
interface CreateRefCallback {
|
interface CreateRefCallback {
|
||||||
(ref: React.RefObject<HTMLCanvasElement>): void;
|
(ref: React.RefObject<HTMLCanvasElement>): void;
|
||||||
@@ -31,6 +35,32 @@ const Canvas = ({ width, height, onCreateRef }: CanvasProps) => {
|
|||||||
const getTextRef = useRef(getText);
|
const getTextRef = useRef(getText);
|
||||||
const getListRef = useRef(getList);
|
const getListRef = useRef(getList);
|
||||||
|
|
||||||
|
const [datePickerInfo, setDatePickerInfo] = React.useState<{
|
||||||
|
coordinates: Coordinates;
|
||||||
|
selector: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const [dropdownInfo, setDropdownInfo] = React.useState<{
|
||||||
|
coordinates: Coordinates;
|
||||||
|
selector: string;
|
||||||
|
options: Array<{
|
||||||
|
value: string;
|
||||||
|
text: string;
|
||||||
|
disabled: boolean;
|
||||||
|
selected: boolean;
|
||||||
|
}>;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const [timePickerInfo, setTimePickerInfo] = React.useState<{
|
||||||
|
coordinates: Coordinates;
|
||||||
|
selector: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const [dateTimeLocalInfo, setDateTimeLocalInfo] = React.useState<{
|
||||||
|
coordinates: Coordinates;
|
||||||
|
selector: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const notifyLastAction = (action: string) => {
|
const notifyLastAction = (action: string) => {
|
||||||
if (lastAction !== action) {
|
if (lastAction !== action) {
|
||||||
setLastAction(action);
|
setLastAction(action);
|
||||||
@@ -44,6 +74,42 @@ const Canvas = ({ width, height, onCreateRef }: CanvasProps) => {
|
|||||||
getListRef.current = getList;
|
getListRef.current = getList;
|
||||||
}, [getText, getList]);
|
}, [getText, getList]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (socket) {
|
||||||
|
socket.on('showDatePicker', (info: {coordinates: Coordinates, selector: string}) => {
|
||||||
|
setDatePickerInfo(info);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('showDropdown', (info: {
|
||||||
|
coordinates: Coordinates,
|
||||||
|
selector: string,
|
||||||
|
options: Array<{
|
||||||
|
value: string;
|
||||||
|
text: string;
|
||||||
|
disabled: boolean;
|
||||||
|
selected: boolean;
|
||||||
|
}>;
|
||||||
|
}) => {
|
||||||
|
setDropdownInfo(info);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('showTimePicker', (info: {coordinates: Coordinates, selector: string}) => {
|
||||||
|
setTimePickerInfo(info);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('showDateTimePicker', (info: {coordinates: Coordinates, selector: string}) => {
|
||||||
|
setDateTimeLocalInfo(info);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off('showDatePicker');
|
||||||
|
socket.off('showDropdown');
|
||||||
|
socket.off('showTimePicker');
|
||||||
|
socket.off('showDateTimePicker');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [socket]);
|
||||||
|
|
||||||
const onMouseEvent = useCallback((event: MouseEvent) => {
|
const onMouseEvent = useCallback((event: MouseEvent) => {
|
||||||
if (socket && canvasRef.current) {
|
if (socket && canvasRef.current) {
|
||||||
// Get the canvas bounding rectangle
|
// Get the canvas bounding rectangle
|
||||||
@@ -146,6 +212,35 @@ const Canvas = ({ width, height, onCreateRef }: CanvasProps) => {
|
|||||||
width={900}
|
width={900}
|
||||||
style={{ display: 'block' }}
|
style={{ display: 'block' }}
|
||||||
/>
|
/>
|
||||||
|
{datePickerInfo && (
|
||||||
|
<DatePicker
|
||||||
|
coordinates={datePickerInfo.coordinates}
|
||||||
|
selector={datePickerInfo.selector}
|
||||||
|
onClose={() => setDatePickerInfo(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{dropdownInfo && (
|
||||||
|
<Dropdown
|
||||||
|
coordinates={dropdownInfo.coordinates}
|
||||||
|
selector={dropdownInfo.selector}
|
||||||
|
options={dropdownInfo.options}
|
||||||
|
onClose={() => setDropdownInfo(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{timePickerInfo && (
|
||||||
|
<TimePicker
|
||||||
|
coordinates={timePickerInfo.coordinates}
|
||||||
|
selector={timePickerInfo.selector}
|
||||||
|
onClose={() => setTimePickerInfo(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{dateTimeLocalInfo && (
|
||||||
|
<DateTimeLocalPicker
|
||||||
|
coordinates={dateTimeLocalInfo.coordinates}
|
||||||
|
selector={dateTimeLocalInfo.selector}
|
||||||
|
onClose={() => setDateTimeLocalInfo(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,4 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next"; // Import useTranslation hook
|
|
||||||
|
|
||||||
import React, { useState, useContext, useEffect } from 'react';
|
import React, { useState, useContext, useEffect } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
@@ -62,7 +50,7 @@ export const NavBar: React.FC<NavBarProps> = ({
|
|||||||
return version;
|
return version;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch latest version:", error);
|
console.error("Failed to fetch latest version:", error);
|
||||||
return null; // Handle errors gracefully
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -112,297 +100,207 @@ export const NavBar: React.FC<NavBarProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const changeLanguage = (lang: string) => {
|
const changeLanguage = (lang: string) => {
|
||||||
i18n.changeLanguage(lang); // Change language dynamically
|
i18n.changeLanguage(lang);
|
||||||
localStorage.setItem("language", lang); // Persist language to localStorage
|
localStorage.setItem("language", lang);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkForUpdates = async () => {
|
const checkForUpdates = async () => {
|
||||||
const latestVersion = await fetchLatestVersion();
|
const latestVersion = await fetchLatestVersion();
|
||||||
setLatestVersion(latestVersion); // Set the latest version state
|
setLatestVersion(latestVersion);
|
||||||
if (latestVersion && latestVersion !== currentVersion) {
|
if (latestVersion && latestVersion !== currentVersion) {
|
||||||
setIsUpdateAvailable(true); // Show a notification or highlight the "Upgrade" button
|
setIsUpdateAvailable(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
checkForUpdates();
|
checkForUpdates();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<NavBarWrapper>
|
||||||
<NavBarWrapper>
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
<div
|
justifyContent: 'flex-start',
|
||||||
style={{
|
}}>
|
||||||
display: "flex",
|
<img src={MaxunLogo} width={45} height={40} style={{ borderRadius: '5px', margin: '5px 0px 5px 15px' }} />
|
||||||
justifyContent: "flex-start",
|
<div style={{ padding: '11px' }}><ProjectName>Maxun</ProjectName></div>
|
||||||
}}
|
<Chip
|
||||||
>
|
label={`${currentVersion}`}
|
||||||
<img
|
color="primary"
|
||||||
src={MaxunLogo}
|
variant="outlined"
|
||||||
width={45}
|
sx={{ marginTop: '10px' }}
|
||||||
height={40}
|
/>
|
||||||
style={{ borderRadius: "5px", margin: "5px 0px 5px 15px" }}
|
|
||||||
/>
|
|
||||||
<div style={{ padding: "11px" }}>
|
|
||||||
<ProjectName>Maxun</ProjectName>
|
|
||||||
</div>
|
</div>
|
||||||
<Chip
|
{
|
||||||
label="beta"
|
user ? (
|
||||||
color="primary"
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
|
||||||
variant="outlined"
|
{!isRecording ? (
|
||||||
sx={{ marginTop: "10px" }}
|
<>
|
||||||
/>
|
<Button variant="outlined" onClick={handleUpdateOpen} sx={{
|
||||||
|
marginRight: '40px',
|
||||||
|
color: "#00000099",
|
||||||
</div>
|
border: "#00000099 1px solid",
|
||||||
{user ? (
|
'&:hover': { color: '#ff00c3', border: '#ff00c3 1px solid' }
|
||||||
<div
|
}}>
|
||||||
style={{
|
<Update sx={{ marginRight: '5px' }} /> Upgrade Maxun
|
||||||
display: "flex",
|
</Button>
|
||||||
alignItems: "center",
|
<Modal open={open} onClose={handleUpdateClose}>
|
||||||
justifyContent: "flex-end",
|
<Box
|
||||||
}}
|
sx={{
|
||||||
>
|
position: "absolute",
|
||||||
{!isRecording ? (
|
top: "50%",
|
||||||
<>
|
left: "50%",
|
||||||
<IconButton
|
transform: "translate(-50%, -50%)",
|
||||||
component="a"
|
width: 500,
|
||||||
href="https://discord.gg/5GbPjBUkws"
|
bgcolor: "background.paper",
|
||||||
target="_blank"
|
boxShadow: 24,
|
||||||
rel="noopener noreferrer"
|
p: 4,
|
||||||
sx={{
|
borderRadius: 2,
|
||||||
display: "flex",
|
}}
|
||||||
alignItems: "center",
|
>
|
||||||
borderRadius: "5px",
|
{latestVersion === null ? (
|
||||||
padding: "8px",
|
<Typography>Checking for updates...</Typography>
|
||||||
marginRight: "30px",
|
) : currentVersion === latestVersion ? (
|
||||||
}}
|
<Typography variant="h6" textAlign="center">
|
||||||
>
|
🎉 You're up to date!
|
||||||
<DiscordIcon sx={{ marginRight: "5px" }} />
|
</Typography>
|
||||||
</IconButton>
|
) : (
|
||||||
<iframe
|
<>
|
||||||
src="https://ghbtns.com/github-btn.html?user=getmaxun&repo=maxun&type=star&count=true&size=large"
|
<Typography variant="body1" textAlign="left" sx={{ marginLeft: '30px' }}>
|
||||||
frameBorder="0"
|
A new version is available: {latestVersion}. Upgrade to the latest version for bug fixes, enhancements and new features!
|
||||||
scrolling="0"
|
<br />
|
||||||
width="170"
|
View all the new updates
|
||||||
height="30"
|
<a href="https://github.com/getmaxun/maxun/releases/" target="_blank" style={{ textDecoration: 'none' }}>{' '}here.</a>
|
||||||
title="GitHub"
|
</Typography>
|
||||||
></iframe>
|
<Tabs
|
||||||
<IconButton
|
value={tab}
|
||||||
onClick={handleMenuOpen}
|
onChange={handleUpdateTabChange}
|
||||||
sx={{
|
sx={{ marginTop: 2, marginBottom: 2 }}
|
||||||
display: "flex",
|
centered
|
||||||
alignItems: "center",
|
>
|
||||||
borderRadius: "5px",
|
<Tab label="Manual Setup Upgrade" />
|
||||||
padding: "8px",
|
<Tab label="Docker Compose Setup Upgrade" />
|
||||||
marginRight: "10px",
|
</Tabs>
|
||||||
"&:hover": { backgroundColor: "white", color: "#ff00c3" },
|
{tab === 0 && (
|
||||||
}}
|
<Box sx={{ marginLeft: '30px', background: '#cfd0d1', padding: 1, borderRadius: 3 }}>
|
||||||
>
|
<code style={{ color: 'black' }}>
|
||||||
<AccountCircle sx={{ marginRight: "5px" }} />
|
<p>Run the commands below</p>
|
||||||
<Typography variant="body1">{user.email}</Typography>
|
# cd to project directory (eg: maxun)
|
||||||
</IconButton>
|
<br />
|
||||||
<Menu
|
cd maxun
|
||||||
anchorEl={anchorEl}
|
<br />
|
||||||
open={Boolean(anchorEl)}
|
<br />
|
||||||
onClose={handleMenuClose}
|
# pull latest changes
|
||||||
anchorOrigin={{
|
<br />
|
||||||
vertical: "bottom",
|
git pull origin master
|
||||||
horizontal: "right",
|
<br />
|
||||||
}}
|
<br />
|
||||||
transformOrigin={{
|
# install dependencies
|
||||||
vertical: "top",
|
<br />
|
||||||
horizontal: "right",
|
npm install
|
||||||
}}
|
<br />
|
||||||
>
|
<br />
|
||||||
<MenuItem
|
# start maxun
|
||||||
onClick={() => {
|
<br />
|
||||||
handleMenuClose();
|
npm run start
|
||||||
logout();
|
</code>
|
||||||
}}
|
</Box>
|
||||||
>
|
)}
|
||||||
<Logout sx={{ marginRight: "5px" }} /> {t("logout")}
|
{tab === 1 && (
|
||||||
</MenuItem>
|
<Box sx={{ marginLeft: '30px', background: '#cfd0d1', padding: 1, borderRadius: 3 }}>
|
||||||
</Menu>
|
<code style={{ color: 'black' }}>
|
||||||
|
<p>Run the commands below</p>
|
||||||
</>
|
# cd to project directory (eg: maxun)
|
||||||
) : (
|
<br />
|
||||||
<>
|
cd maxun
|
||||||
<IconButton
|
<br />
|
||||||
onClick={goToMainMenu}
|
<br />
|
||||||
sx={{
|
# stop the working containers
|
||||||
borderRadius: "5px",
|
<br />
|
||||||
padding: "8px",
|
docker-compose down
|
||||||
background: "red",
|
<br />
|
||||||
color: "white",
|
<br />
|
||||||
marginRight: "10px",
|
# pull latest docker images
|
||||||
"&:hover": { color: "white", backgroundColor: "red" },
|
<br />
|
||||||
}}
|
docker-compose pull
|
||||||
>
|
<br />
|
||||||
<Clear sx={{ marginRight: "5px" }} />
|
<br />
|
||||||
{t("discard")}
|
# start maxun
|
||||||
</IconButton>
|
<br />
|
||||||
<SaveRecording fileName={recordingName} />
|
docker-compose up -d
|
||||||
</>
|
</code>
|
||||||
)}
|
</Box>
|
||||||
<IconButton
|
)}
|
||||||
onClick={handleLangMenuOpen}
|
</>
|
||||||
sx={{
|
)}
|
||||||
display: "flex",
|
</Box>
|
||||||
alignItems: "center",
|
</Modal>
|
||||||
borderRadius: "5px",
|
<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>
|
||||||
padding: "8px",
|
<IconButton onClick={handleMenuOpen} sx={{
|
||||||
marginRight: "10px",
|
display: 'flex',
|
||||||
}}
|
alignItems: 'center',
|
||||||
>
|
borderRadius: '5px',
|
||||||
<Typography variant="body1">
|
padding: '8px',
|
||||||
<Language />
|
marginRight: '10px',
|
||||||
</Typography>
|
'&:hover': { backgroundColor: 'white', color: '#ff00c3' }
|
||||||
</IconButton>
|
}}>
|
||||||
<Menu
|
<AccountCircle sx={{ marginRight: '5px' }} />
|
||||||
anchorEl={langAnchorEl}
|
<Typography variant="body1">{user.email}</Typography>
|
||||||
open={Boolean(langAnchorEl)}
|
</IconButton>
|
||||||
onClose={handleMenuClose}
|
<Menu
|
||||||
anchorOrigin={{
|
anchorEl={anchorEl}
|
||||||
vertical: "bottom",
|
open={Boolean(anchorEl)}
|
||||||
horizontal: "right",
|
onClose={handleMenuClose}
|
||||||
}}
|
anchorOrigin={{
|
||||||
transformOrigin={{
|
vertical: 'bottom',
|
||||||
vertical: "top",
|
horizontal: 'right',
|
||||||
horizontal: "right",
|
}}
|
||||||
}}
|
transformOrigin={{
|
||||||
>
|
vertical: 'top',
|
||||||
<MenuItem
|
horizontal: 'right',
|
||||||
onClick={() => {
|
}}
|
||||||
changeLanguage("en");
|
PaperProps={{ sx: { width: '180px' } }}
|
||||||
handleMenuClose();
|
>
|
||||||
}}
|
<MenuItem onClick={() => { handleMenuClose(); logout(); }}>
|
||||||
>
|
<Logout sx={{ marginRight: '5px' }} /> Logout
|
||||||
English
|
</MenuItem>
|
||||||
</MenuItem>
|
<MenuItem onClick={() => {
|
||||||
<MenuItem
|
window.open('https://discord.gg/5GbPjBUkws', '_blank');
|
||||||
onClick={() => {
|
}}>
|
||||||
changeLanguage("es");
|
<DiscordIcon sx={{ marginRight: '5px' }} /> Discord
|
||||||
handleMenuClose();
|
</MenuItem>
|
||||||
}}
|
<MenuItem onClick={() => {
|
||||||
>
|
window.open('https://www.youtube.com/@MaxunOSS/videos?ref=app', '_blank');
|
||||||
Español
|
}}>
|
||||||
</MenuItem>
|
<YouTube sx={{ marginRight: '5px' }} /> YouTube
|
||||||
<MenuItem
|
</MenuItem>
|
||||||
onClick={() => {
|
<MenuItem onClick={() => {
|
||||||
changeLanguage("ja");
|
window.open('https://x.com/maxun_io?ref=app', '_blank');
|
||||||
handleMenuClose();
|
}}>
|
||||||
}}
|
<X sx={{ marginRight: '5px' }} /> Twiiter (X)
|
||||||
>
|
</MenuItem>
|
||||||
日本語
|
</Menu>
|
||||||
</MenuItem>
|
</>
|
||||||
{/* <MenuItem
|
) : (
|
||||||
onClick={() => {
|
<>
|
||||||
changeLanguage("ar");
|
<IconButton onClick={goToMainMenu} sx={{
|
||||||
handleMenuClose();
|
borderRadius: '5px',
|
||||||
}}
|
padding: '8px',
|
||||||
>
|
background: 'red',
|
||||||
العربية
|
color: 'white',
|
||||||
</MenuItem> */}
|
marginRight: '10px',
|
||||||
<MenuItem
|
'&:hover': { color: 'white', backgroundColor: 'red' }
|
||||||
onClick={() => {
|
}}>
|
||||||
changeLanguage("zh");
|
<Clear sx={{ marginRight: '5px' }} />
|
||||||
handleMenuClose();
|
Discard
|
||||||
}}
|
</IconButton>
|
||||||
>
|
<SaveRecording fileName={recordingName} />
|
||||||
中文
|
</>
|
||||||
</MenuItem>
|
)}
|
||||||
<MenuItem
|
</div>
|
||||||
onClick={() => {
|
) : ""
|
||||||
changeLanguage("de");
|
}
|
||||||
handleMenuClose();
|
</NavBarWrapper>
|
||||||
}}
|
</>
|
||||||
>
|
|
||||||
Deutsch
|
|
||||||
</MenuItem>
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<><IconButton
|
|
||||||
onClick={handleLangMenuOpen}
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
borderRadius: "5px",
|
|
||||||
padding: "8px",
|
|
||||||
marginRight: "10px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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("ar");
|
|
||||||
handleMenuClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
العربية
|
|
||||||
</MenuItem> */}
|
|
||||||
<MenuItem
|
|
||||||
onClick={() => {
|
|
||||||
changeLanguage("zh");
|
|
||||||
handleMenuClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
中文
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem
|
|
||||||
onClick={() => {
|
|
||||||
changeLanguage("de");
|
|
||||||
handleMenuClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Deutsch
|
|
||||||
</MenuItem>
|
|
||||||
</Menu></>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
</NavBarWrapper>
|
|
||||||
|
|
||||||
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user