Browse Source

Merge branch 'dev' into main

Ahmad Kholid 3 years ago
parent
commit
3f88cc4918
100 changed files with 3500 additions and 2086 deletions
  1. 3 1
      package.json
  2. 5 2
      src/assets/css/tailwind.css
  3. 12 2
      src/background/collection-engine/flow-handler.js
  4. 3 2
      src/background/collection-engine/index.js
  5. 6 2
      src/background/index.js
  6. 8 609
      src/background/workflow-engine/blocks-handler.js
  7. 43 0
      src/background/workflow-engine/blocks-handler/handler-active-tab.js
  8. 33 0
      src/background/workflow-engine/blocks-handler/handler-close-tab.js
  9. 33 0
      src/background/workflow-engine/blocks-handler/handler-condition.js
  10. 14 0
      src/background/workflow-engine/blocks-handler/handler-delay.js
  11. 20 0
      src/background/workflow-engine/blocks-handler/handler-element-exists.js
  12. 72 0
      src/background/workflow-engine/blocks-handler/handler-execute-workflow.js
  13. 15 0
      src/background/workflow-engine/blocks-handler/handler-export-data.js
  14. 32 0
      src/background/workflow-engine/blocks-handler/handler-forward-page.js
  15. 32 0
      src/background/workflow-engine/blocks-handler/handler-go-back.js
  16. 92 0
      src/background/workflow-engine/blocks-handler/handler-interaction-block.js
  17. 33 0
      src/background/workflow-engine/blocks-handler/handler-loop-breakpoint.js
  18. 59 0
      src/background/workflow-engine/blocks-handler/handler-loop-data.js
  19. 79 0
      src/background/workflow-engine/blocks-handler/handler-new-tab.js
  20. 27 0
      src/background/workflow-engine/blocks-handler/handler-new-window.js
  21. 60 0
      src/background/workflow-engine/blocks-handler/handler-proxy.js
  22. 23 0
      src/background/workflow-engine/blocks-handler/handler-repeat-task.js
  23. 39 0
      src/background/workflow-engine/blocks-handler/handler-switch-to.js
  24. 70 0
      src/background/workflow-engine/blocks-handler/handler-take-screenshot.js
  25. 20 0
      src/background/workflow-engine/blocks-handler/handler-trigger.js
  26. 37 0
      src/background/workflow-engine/blocks-handler/handler-webhook.js
  27. 393 0
      src/background/workflow-engine/engine.js
  28. 0 0
      src/background/workflow-engine/execute-content-script.js
  29. 24 0
      src/background/workflow-engine/helper.js
  30. 5 353
      src/background/workflow-engine/index.js
  31. 7 2
      src/components/block/BlockBasic.vue
  32. 18 11
      src/components/block/BlockConditions.vue
  33. 5 3
      src/components/block/BlockDelay.vue
  34. 9 5
      src/components/block/BlockElementExists.vue
  35. 4 2
      src/components/block/BlockExportData.vue
  36. 3 1
      src/components/block/BlockLoopBreakpoint.vue
  37. 9 3
      src/components/block/BlockRepeatTask.vue
  38. 48 23
      src/components/newtab/app/AppSidebar.vue
  39. 10 3
      src/components/newtab/logs/LogsDataViewer.vue
  40. 27 16
      src/components/newtab/logs/LogsFilters.vue
  41. 38 40
      src/components/newtab/shared/SharedCard.vue
  42. 6 3
      src/components/newtab/shared/SharedLogsTable.vue
  43. 21 4
      src/components/newtab/shared/SharedWorkflowState.vue
  44. 39 9
      src/components/newtab/workflow/WorkflowActions.vue
  45. 8 5
      src/components/newtab/workflow/WorkflowBuilder.vue
  46. 9 4
      src/components/newtab/workflow/WorkflowDataColumns.vue
  47. 61 25
      src/components/newtab/workflow/WorkflowDetailsCard.vue
  48. 5 1
      src/components/newtab/workflow/WorkflowEditBlock.vue
  49. 4 1
      src/components/newtab/workflow/WorkflowGlobalData.vue
  50. 5 2
      src/components/newtab/workflow/WorkflowRunning.vue
  51. 14 4
      src/components/newtab/workflow/WorkflowSettings.vue
  52. 0 84
      src/components/newtab/workflow/WorkflowTask.vue
  53. 6 8
      src/components/newtab/workflow/edit/EditAttributeValue.vue
  54. 6 2
      src/components/newtab/workflow/edit/EditCloseTab.vue
  55. 9 5
      src/components/newtab/workflow/edit/EditElementExists.vue
  56. 89 0
      src/components/newtab/workflow/edit/EditExecuteWorkflow.vue
  57. 87 42
      src/components/newtab/workflow/edit/EditForms.vue
  58. 17 0
      src/components/newtab/workflow/edit/EditGetText.vue
  59. 60 29
      src/components/newtab/workflow/edit/EditInteractionBase.vue
  60. 104 45
      src/components/newtab/workflow/edit/EditJavascriptCode.vue
  61. 50 20
      src/components/newtab/workflow/edit/EditLoopData.vue
  62. 10 6
      src/components/newtab/workflow/edit/EditNewTab.vue
  63. 8 7
      src/components/newtab/workflow/edit/EditNewWindow.vue
  64. 77 0
      src/components/newtab/workflow/edit/EditProxy.vue
  65. 14 7
      src/components/newtab/workflow/edit/EditScrollElement.vue
  66. 12 4
      src/components/newtab/workflow/edit/EditSwitchTo.vue
  67. 6 2
      src/components/newtab/workflow/edit/EditTakeScreenshot.vue
  68. 27 24
      src/components/newtab/workflow/edit/EditTrigger.vue
  69. 23 32
      src/components/newtab/workflow/edit/EditTriggerEvent.vue
  70. 15 10
      src/components/newtab/workflow/edit/EditWebhook.vue
  71. 15 2
      src/components/popup/home/HomeWorkflowCard.vue
  72. 0 0
      src/components/ui/UiCheckbox.vue
  73. 16 14
      src/components/ui/UiDialog.vue
  74. 91 0
      src/components/ui/UiImg.vue
  75. 7 6
      src/components/ui/UiPagination.vue
  76. 8 275
      src/content/blocks-handler.js
  77. 27 0
      src/content/blocks-handler/handler-attribute-value.js
  78. 26 0
      src/content/blocks-handler/handler-element-exists.js
  79. 38 0
      src/content/blocks-handler/handler-element-scroll.js
  80. 13 0
      src/content/blocks-handler/handler-event-click.js
  81. 42 0
      src/content/blocks-handler/handler-forms.js
  82. 27 0
      src/content/blocks-handler/handler-get-text.js
  83. 128 0
      src/content/blocks-handler/handler-javascript-code.js
  84. 22 0
      src/content/blocks-handler/handler-link.js
  85. 22 0
      src/content/blocks-handler/handler-switch-to.js
  86. 16 0
      src/content/blocks-handler/handler-trigger-event.js
  87. 343 0
      src/content/element-selector/App.vue
  88. 121 0
      src/content/element-selector/AppBlocks.vue
  89. 39 0
      src/content/element-selector/AppElementAttributes.vue
  90. 0 0
      src/content/element-selector/AppHeader.vue
  91. 57 0
      src/content/element-selector/AppSelector.vue
  92. 0 308
      src/content/element-selector/ElementSelector.ce.vue
  93. 26 0
      src/content/element-selector/comps-ui.js
  94. 25 0
      src/content/element-selector/icons.js
  95. 62 15
      src/content/element-selector/index.js
  96. 17 0
      src/content/element-selector/main.js
  97. 13 0
      src/content/element-selector/vue-i18n.js
  98. 34 0
      src/content/helper.js
  99. 1 1
      src/content/index.js
  100. 2 0
      src/lib/dayjs.js

+ 3 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "0.6.0",
+  "version": "0.8.0",
   "description": "An extension for automating your browser by connecting blocks",
   "license": "MIT",
   "repository": {
@@ -35,6 +35,7 @@
     "tippy.js": "^6.3.1",
     "v-remixicon": "^0.1.1",
     "vue": "3.2.19",
+    "vue-i18n": "^9.2.0-beta.20",
     "vue-prism-editor": "^2.0.0-alpha.2",
     "vue-router": "^4.0.11",
     "vue-virtual-scroller": "^2.0.0-alpha.1",
@@ -47,6 +48,7 @@
     "@babel/eslint-parser": "7.15.7",
     "@babel/plugin-proposal-class-properties": "7.14.5",
     "@babel/preset-env": "7.15.6",
+    "@intlify/vue-i18n-loader": "^4.0.1",
     "@vue/compiler-sfc": "3.2.19",
     "archiver": "^5.3.0",
     "autoprefixer": "10.3.6",

+ 5 - 2
src/assets/css/tailwind.css

@@ -2,11 +2,11 @@
 @tailwind components;
 @tailwind utilities;
 
-body {
+:host, body {
   font-family: 'Inter var';
   font-size: 16px;
   font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
-  @apply bg-gray-50 dark:bg-gray-900;
+  @apply bg-gray-50 dark:bg-gray-900 leading-normal;
 }
 table th,
 table td {
@@ -58,4 +58,7 @@ select:focus,
   .bg-box-transparent {
     @apply bg-black bg-opacity-5 dark:bg-gray-200 dark:bg-opacity-5;
   }
+  .bg-box-transparent-2 {
+    @apply bg-black bg-opacity-10 dark:bg-gray-200 dark:bg-opacity-10;
+  }
 }

+ 12 - 2
src/background/collection-engine/flow-handler.js

@@ -1,4 +1,4 @@
-import WorkflowEngine from '../workflow-engine';
+import workflowEngine from '../workflow-engine';
 import dataExporter from '@/utils/data-exporter';
 
 export function workflow(flow) {
@@ -13,10 +13,20 @@ export function workflow(flow) {
       return;
     }
 
+    if (currentWorkflow.isDisabled) {
+      resolve({
+        type: 'stopped',
+        name: currentWorkflow.name,
+        message: 'workflow-disabled',
+      });
+
+      return;
+    }
+
     const { globalData } = this.collection;
     this.currentWorkflow = currentWorkflow;
 
-    const engine = new WorkflowEngine(currentWorkflow, {
+    const engine = workflowEngine(currentWorkflow, {
       isInCollection: true,
       collectionLogId: this.id,
       collectionId: this.collection.id,

+ 3 - 2
src/background/collection-engine/index.js

@@ -3,7 +3,7 @@ import browser from 'webextension-polyfill';
 import { toCamelCase } from '@/utils/helper';
 import * as flowHandler from './flow-handler';
 import workflowState from '../workflow-state';
-import WorkflowEngine from '../workflow-engine';
+import workflowEngine from '../workflow-engine';
 
 class CollectionEngine {
   constructor(collection) {
@@ -37,7 +37,7 @@ class CollectionEngine {
           const currentWorkflow = workflows.find(({ id }) => id === itemId);
 
           if (currentWorkflow) {
-            const engine = new WorkflowEngine(currentWorkflow, {});
+            const engine = workflowEngine(currentWorkflow, {});
 
             engine.init();
           }
@@ -166,6 +166,7 @@ class CollectionEngine {
             type: data.type || 'success',
             name: data.name,
             logId: data.id,
+            message: data.message,
             duration: Math.round(Date.now() - started),
           });
 

+ 6 - 2
src/background/index.js

@@ -1,7 +1,7 @@
 import browser from 'webextension-polyfill';
 import { MessageListener } from '@/utils/message';
 import workflowState from './workflow-state';
-import WorkflowEngine from './workflow-engine';
+import workflowEngine from './workflow-engine';
 import CollectionEngine from './collection-engine';
 import { registerSpecificDay } from '../utils/workflow-trigger';
 
@@ -20,7 +20,7 @@ const runningCollections = {};
 
 async function executeWorkflow(workflow, tabId) {
   try {
-    const engine = new WorkflowEngine(workflow, { tabId });
+    const engine = workflowEngine(workflow, { tabId });
 
     runningWorkflows[engine.id] = engine;
 
@@ -110,6 +110,10 @@ chrome.runtime.onInstalled.addListener((details) => {
 
 const message = new MessageListener('background');
 
+message.on('fetch:text', (url) => {
+  return fetch(url).then((response) => response.text());
+});
+
 message.on('get:sender', (_, sender) => {
   return sender;
 });

+ 8 - 609
src/background/workflow-engine/blocks-handler.js

@@ -1,613 +1,12 @@
-/* eslint-disable no-underscore-dangle */
-import browser from 'webextension-polyfill';
-import { objectHasKey, fileSaver, isObject } from '@/utils/helper';
-import { tasks } from '@/utils/shared';
-import { executeWebhook } from '@/utils/webhookUtil';
-import executeContentScript from '@/utils/execute-content-script';
-import dataExporter, { generateJSON } from '@/utils/data-exporter';
-import compareBlockValue from '@/utils/compare-block-value';
-import errorMessage from './error-message';
+import { toCamelCase } from '@/utils/helper';
 
-function getBlockConnection(block, index = 1) {
-  const blockId = block.outputs[`output_${index}`]?.connections[0]?.node;
+const blocksHandler = require.context('./blocks-handler', false, /\.js$/);
+const handlers = blocksHandler.keys().reduce((acc, key) => {
+  const name = key.replace(/^\.\/handler-|\.js/g, '');
 
-  return blockId;
-}
-function convertData(data, type) {
-  let result = data;
+  acc[toCamelCase(name)] = blocksHandler(key).default;
 
-  switch (type) {
-    case 'integer':
-      result = +data.replace(/\D+/g, '');
-      break;
-    case 'boolean':
-      result = Boolean(data);
-      break;
-    case 'array':
-      result = Array.from(data);
-      break;
-    default:
-  }
+  return acc;
+}, {});
 
-  return result;
-}
-function generateBlockError(block, code) {
-  const message = errorMessage(code || 'no-tab', tasks[block.name]);
-  const error = new Error(message);
-  error.nextBlockId = getBlockConnection(block);
-
-  return error;
-}
-
-export async function closeTab(block) {
-  const nextBlockId = getBlockConnection(block);
-
-  try {
-    const { data } = block;
-    let tabIds;
-
-    if (data.activeTab && this.tabId) {
-      tabIds = this.tabId;
-    } else if (data.url) {
-      tabIds = (await browser.tabs.query({ url: data.url })).map(
-        (tab) => tab.id
-      );
-    }
-
-    if (tabIds) await browser.tabs.remove(tabIds);
-
-    return {
-      nextBlockId,
-      data: '',
-    };
-  } catch (error) {
-    const errorInstance = typeof error === 'string' ? new Error(error) : error;
-    errorInstance.nextBlockId = nextBlockId;
-
-    throw error;
-  }
-}
-export async function trigger(block) {
-  const nextBlockId = getBlockConnection(block);
-  try {
-    if (block.data.type === 'visit-web' && this.tabId) {
-      this.frames = await executeContentScript(this.tabId, 'trigger');
-    }
-
-    return { nextBlockId, data: '' };
-  } catch (error) {
-    const errorInstance = new Error(error);
-    errorInstance.nextBlockId = nextBlockId;
-
-    throw errorInstance;
-  }
-}
-
-export function loopBreakpoint(block, prevBlockData) {
-  return new Promise((resolve) => {
-    const currentLoop = this.loopList[block.data.loopId];
-
-    if (
-      currentLoop &&
-      currentLoop.index < currentLoop.maxLoop - 1 &&
-      currentLoop.index <= currentLoop.data.length - 1
-    ) {
-      resolve({
-        data: '',
-        nextBlockId: currentLoop.blockId,
-      });
-    } else {
-      resolve({
-        data: prevBlockData,
-        nextBlockId: getBlockConnection(block),
-      });
-    }
-  });
-}
-
-export function loopData(block) {
-  return new Promise((resolve) => {
-    const { data } = block;
-
-    if (this.loopList[data.loopId]) {
-      this.loopList[data.loopId].index += 1;
-      this.loopData[data.loopId] =
-        this.loopList[data.loopId].data[this.loopList[data.loopId].index];
-    } else {
-      const currLoopData =
-        data.loopThrough === 'data-columns'
-          ? generateJSON(Object.keys(this.data), this.data)
-          : JSON.parse(data.loopData);
-
-      this.loopList[data.loopId] = {
-        index: 0,
-        data: currLoopData,
-        id: data.loopId,
-        blockId: block.id,
-        maxLoop: data.maxLoop || currLoopData.length,
-      };
-      /* eslint-disable-next-line */
-      this.loopData[data.loopId] = currLoopData[0];
-    }
-
-    resolve({
-      data: this.loopData[data.loopId],
-      nextBlockId: getBlockConnection(block),
-    });
-  });
-}
-
-export function goBack(block) {
-  return new Promise((resolve, reject) => {
-    const nextBlockId = getBlockConnection(block);
-
-    if (!this.tabId) {
-      reject(generateBlockError(block));
-
-      return;
-    }
-
-    browser.tabs
-      .goBack(this.tabId)
-      .then(() => {
-        resolve({
-          nextBlockId,
-          data: '',
-        });
-      })
-      .catch((error) => {
-        error.nextBlockId = nextBlockId;
-        reject(error);
-      });
-  });
-}
-
-export function forwardPage(block) {
-  return new Promise((resolve, reject) => {
-    const nextBlockId = getBlockConnection(block);
-
-    if (!this.tabId) {
-      reject(generateBlockError(block));
-
-      return;
-    }
-
-    browser.tabs
-      .goForward(this.tabId)
-      .then(() => {
-        resolve({
-          nextBlockId,
-          data: '',
-        });
-      })
-      .catch((error) => {
-        error.nextBlockId = nextBlockId;
-        reject(error);
-      });
-  });
-}
-
-export async function newWindow(block) {
-  const nextBlockId = getBlockConnection(block);
-
-  try {
-    const { incognito, windowState } = block.data;
-    const { id } = await browser.windows.create({
-      incognito,
-      state: windowState,
-    });
-
-    this.windowId = id;
-
-    return {
-      data: id,
-      nextBlockId,
-    };
-  } catch (error) {
-    error.nextBlockId = nextBlockId;
-
-    throw error;
-  }
-}
-
-function tabUpdatedListener(tab) {
-  return new Promise((resolve) => {
-    this._listener({
-      name: 'tab-updated',
-      id: tab.id,
-      callback: async (tabId, changeInfo, deleteListener) => {
-        if (changeInfo.status !== 'complete') return;
-
-        const frames = await executeContentScript(tabId, 'newtab');
-
-        deleteListener();
-
-        resolve(frames);
-      },
-    });
-  });
-}
-export async function newTab(block) {
-  if (this.windowId) {
-    try {
-      await browser.windows.get(this.windowId);
-    } catch (error) {
-      this.windowId = null;
-    }
-  }
-
-  try {
-    const { updatePrevTab, url, active, inGroup } = block.data;
-
-    if (updatePrevTab && this.tabId) {
-      await browser.tabs.update(this.tabId, { url, active });
-    } else {
-      const tab = await browser.tabs.create({
-        url,
-        active,
-        windowId: this.windowId,
-      });
-
-      this.tabId = tab.id;
-      this.windowId = tab.windowId;
-    }
-
-    if (inGroup && !updatePrevTab) {
-      const options = {
-        groupId: this.tabGroupId,
-        tabIds: this.tabId,
-      };
-
-      if (!this.tabGroupId) {
-        options.createProperties = {
-          windowId: this.windowId,
-        };
-      }
-
-      chrome.tabs.group(options, (tabGroupId) => {
-        this.tabGroupId = tabGroupId;
-      });
-    }
-
-    this.frameId = 0;
-    this.frames = await tabUpdatedListener.call(this, { id: this.tabId });
-
-    return {
-      data: url,
-      nextBlockId: getBlockConnection(block),
-    };
-  } catch (error) {
-    console.error(error);
-    throw error;
-  }
-}
-
-export async function activeTab(block) {
-  const nextBlockId = getBlockConnection(block);
-
-  try {
-    const data = {
-      nextBlockId,
-      data: '',
-    };
-
-    if (this.tabId) {
-      await browser.tabs.update(this.tabId, { active: true });
-
-      return data;
-    }
-
-    const [tab] = await browser.tabs.query({
-      active: true,
-      currentWindow: true,
-    });
-
-    this.frames = await executeContentScript(tab.id, 'activetab');
-
-    this.frameId = 0;
-    this.tabId = tab.id;
-    this.windowId = tab.windowId;
-
-    return data;
-  } catch (error) {
-    console.error(error);
-    return {
-      data: '',
-      message: error.message || error,
-      nextBlockId,
-    };
-  }
-}
-
-export async function takeScreenshot(block) {
-  const nextBlockId = getBlockConnection(block);
-  const { ext, quality, captureActiveTab, fileName } = block.data;
-
-  function saveImage(uri) {
-    const image = new Image();
-
-    image.onload = () => {
-      const name = `${fileName || 'Screenshot'}.${ext || 'png'}`;
-      const canvas = document.createElement('canvas');
-      canvas.width = image.width;
-      canvas.height = image.height;
-
-      const context = canvas.getContext('2d');
-      context.drawImage(image, 0, 0);
-
-      fileSaver(name, canvas.toDataURL());
-    };
-
-    image.src = uri;
-  }
-
-  try {
-    const options = {
-      quality,
-      format: ext || 'png',
-    };
-
-    if (captureActiveTab) {
-      if (!this.tabId) {
-        throw new Error(errorMessage('no-tab', block));
-      }
-
-      const [tab] = await browser.tabs.query({
-        active: true,
-        currentWindow: true,
-      });
-
-      await browser.windows.update(this.windowId, { focused: true });
-      await browser.tabs.update(this.tabId, { active: true });
-
-      await new Promise((resolve) => setTimeout(resolve, 500));
-
-      const uri = await browser.tabs.captureVisibleTab(options);
-
-      if (tab) {
-        await browser.windows.update(tab.windowId, { focused: true });
-        await browser.tabs.update(tab.id, { active: true });
-      }
-
-      saveImage(uri);
-    } else {
-      const uri = await browser.tabs.captureVisibleTab(options);
-
-      saveImage(uri);
-    }
-
-    return { data: '', nextBlockId };
-  } catch (error) {
-    error.nextBlockId = nextBlockId;
-
-    throw error;
-  }
-}
-
-export async function switchTo(block) {
-  const nextBlockId = getBlockConnection(block);
-
-  try {
-    if (block.data.windowType === 'main-window') {
-      this.frameId = 0;
-
-      return {
-        data: '',
-        nextBlockId,
-      };
-    }
-
-    const { url } = await this._sendMessageToTab(block, { frameId: 0 });
-
-    if (objectHasKey(this.frames, url)) {
-      this.frameId = this.frames[url];
-
-      return {
-        data: this.frameId,
-        nextBlockId,
-      };
-    }
-    throw new Error(errorMessage('no-iframe-id', block.data));
-  } catch (error) {
-    error.nextBlockId = nextBlockId;
-
-    throw error;
-  }
-}
-
-export async function interactionHandler(block) {
-  const nextBlockId = getBlockConnection(block);
-
-  try {
-    const data = await this._sendMessageToTab(block, {
-      frameId: this.frameId || 0,
-    });
-
-    if (block.name === 'link')
-      await new Promise((resolve) => setTimeout(resolve, 5000));
-
-    if (data?.isError) {
-      const error = new Error(data.message);
-      error.nextBlockId = nextBlockId;
-
-      throw error;
-    }
-
-    const getColumn = (name) =>
-      this.workflow.dataColumns.find((item) => item.name === name) || {
-        name: 'column',
-        type: 'text',
-      };
-    const pushData = (column, value) => {
-      this.data[column.name]?.push(convertData(value, column.type));
-    };
-
-    if (objectHasKey(block.data, 'dataColumn')) {
-      const column = getColumn(block.data.dataColumn);
-
-      if (block.data.saveData) {
-        if (Array.isArray(data) && column.type !== 'array') {
-          data.forEach((item) => {
-            pushData(column, item);
-          });
-        } else {
-          pushData(column, data);
-        }
-      }
-    } else if (block.name === 'javascript-code') {
-      const memoColumn = {};
-      const pushObjectData = (obj) => {
-        Object.entries(obj).forEach(([key, value]) => {
-          let column;
-
-          if (memoColumn[key]) {
-            column = memoColumn[key];
-          } else {
-            const currentColumn = getColumn(key);
-
-            column = currentColumn;
-            memoColumn[key] = currentColumn;
-          }
-
-          pushData(column, value);
-        });
-      };
-
-      if (Array.isArray(data)) {
-        data.forEach((obj) => {
-          if (isObject(obj)) pushObjectData(obj);
-        });
-      } else if (isObject(data)) {
-        pushObjectData(data);
-      }
-    }
-
-    return {
-      data,
-      nextBlockId,
-    };
-  } catch (error) {
-    error.nextBlockId = nextBlockId;
-
-    throw error;
-  }
-}
-
-export function delay(block) {
-  return new Promise((resolve) => {
-    setTimeout(() => {
-      resolve({
-        nextBlockId: getBlockConnection(block),
-        data: '',
-      });
-    }, block.data.time);
-  });
-}
-
-export function exportData(block) {
-  return new Promise((resolve) => {
-    dataExporter(this.data, block.data);
-
-    resolve({
-      data: '',
-      nextBlockId: getBlockConnection(block),
-    });
-  });
-}
-
-export function elementExists(block) {
-  return new Promise((resolve, reject) => {
-    this._sendMessageToTab(block)
-      .then((data) => {
-        resolve({
-          data,
-          nextBlockId: getBlockConnection(block, data ? 1 : 2),
-        });
-      })
-      .catch((error) => {
-        error.nextBlockId = getBlockConnection(block);
-
-        reject(error);
-      });
-  });
-}
-
-export function conditions({ data, outputs }, prevBlockData) {
-  return new Promise((resolve, reject) => {
-    if (data.conditions.length === 0) {
-      reject(new Error('Conditions is empty'));
-      return;
-    }
-
-    let outputIndex = data.conditions.length + 1;
-    let resultData = '';
-    const prevData = Array.isArray(prevBlockData)
-      ? prevBlockData[0]
-      : prevBlockData;
-
-    data.conditions.forEach(({ type, value }, index) => {
-      const result = compareBlockValue(type, prevData, value);
-
-      if (result) {
-        resultData = value;
-        outputIndex = index + 1;
-      }
-    });
-
-    resolve({
-      data: resultData,
-      nextBlockId: getBlockConnection({ outputs }, outputIndex),
-    });
-  });
-}
-
-export function repeatTask({ data, id, outputs }) {
-  return new Promise((resolve) => {
-    if (this.repeatedTasks[id] >= data.repeatFor) {
-      resolve({
-        data: data.repeatFor,
-        nextBlockId: getBlockConnection({ outputs }),
-      });
-    } else {
-      this.repeatedTasks[id] = (this.repeatedTasks[id] || 1) + 1;
-
-      resolve({
-        data: data.repeatFor,
-        nextBlockId: getBlockConnection({ outputs }, 2),
-      });
-    }
-  });
-}
-
-export function webhook({ data, outputs }) {
-  return new Promise((resolve, reject) => {
-    const nextBlockId = getBlockConnection({ outputs });
-
-    if (!data.url) {
-      const error = new Error('URL is empty');
-      error.nextBlockId = nextBlockId;
-
-      reject(error);
-      return;
-    }
-
-    if (!data.url.startsWith('http')) {
-      const error = new Error('URL is not valid');
-      error.nextBlockId = nextBlockId;
-
-      reject(error);
-      return;
-    }
-
-    executeWebhook(data)
-      .then(() => {
-        resolve({
-          data: '',
-          nextBlockId: getBlockConnection({ outputs }),
-        });
-      })
-      .catch((error) => {
-        reject(error);
-      });
-  });
-}
+export default handlers;

+ 43 - 0
src/background/workflow-engine/blocks-handler/handler-active-tab.js

@@ -0,0 +1,43 @@
+import browser from 'webextension-polyfill';
+import { getBlockConnection } from '../helper';
+import executeContentScript from '../execute-content-script';
+
+async function activeTab(block) {
+  const nextBlockId = getBlockConnection(block);
+
+  try {
+    const data = {
+      nextBlockId,
+      data: '',
+    };
+
+    if (this.tabId) {
+      await browser.tabs.update(this.tabId, { active: true });
+
+      return data;
+    }
+
+    const [tab] = await browser.tabs.query({
+      active: true,
+      currentWindow: true,
+    });
+
+    this.frames = await executeContentScript(tab.id, 'activetab');
+
+    this.frameId = 0;
+    this.tabId = tab.id;
+    this.activeTabUrl = tab.url;
+    this.windowId = tab.windowId;
+
+    return data;
+  } catch (error) {
+    console.error(error);
+    return {
+      data: '',
+      message: error.message || error,
+      nextBlockId,
+    };
+  }
+}
+
+export default activeTab;

+ 33 - 0
src/background/workflow-engine/blocks-handler/handler-close-tab.js

@@ -0,0 +1,33 @@
+import browser from 'webextension-polyfill';
+import { getBlockConnection } from '../helper';
+
+async function closeTab(block) {
+  const nextBlockId = getBlockConnection(block);
+
+  try {
+    const { data } = block;
+    let tabIds;
+
+    if (data.activeTab && this.tabId) {
+      tabIds = this.tabId;
+    } else if (data.url) {
+      tabIds = (await browser.tabs.query({ url: data.url })).map(
+        (tab) => tab.id
+      );
+    }
+
+    if (tabIds) await browser.tabs.remove(tabIds);
+
+    return {
+      nextBlockId,
+      data: '',
+    };
+  } catch (error) {
+    const errorInstance = typeof error === 'string' ? new Error(error) : error;
+    errorInstance.nextBlockId = nextBlockId;
+
+    throw error;
+  }
+}
+
+export default closeTab;

+ 33 - 0
src/background/workflow-engine/blocks-handler/handler-condition.js

@@ -0,0 +1,33 @@
+import { getBlockConnection } from '../helper';
+import compareBlockValue from '@/utils/compare-block-value';
+
+function conditions({ data, outputs }, prevBlockData) {
+  return new Promise((resolve, reject) => {
+    if (data.conditions.length === 0) {
+      reject(new Error('Conditions is empty'));
+      return;
+    }
+
+    let outputIndex = data.conditions.length + 1;
+    let resultData = '';
+    const prevData = Array.isArray(prevBlockData)
+      ? prevBlockData[0]
+      : prevBlockData;
+
+    data.conditions.forEach(({ type, value }, index) => {
+      const result = compareBlockValue(type, prevData, value);
+
+      if (result) {
+        resultData = value;
+        outputIndex = index + 1;
+      }
+    });
+
+    resolve({
+      data: resultData,
+      nextBlockId: getBlockConnection({ outputs }, outputIndex),
+    });
+  });
+}
+
+export default conditions;

+ 14 - 0
src/background/workflow-engine/blocks-handler/handler-delay.js

@@ -0,0 +1,14 @@
+import { getBlockConnection } from '../helper';
+
+function delay(block) {
+  return new Promise((resolve) => {
+    setTimeout(() => {
+      resolve({
+        nextBlockId: getBlockConnection(block),
+        data: '',
+      });
+    }, block.data.time);
+  });
+}
+
+export default delay;

+ 20 - 0
src/background/workflow-engine/blocks-handler/handler-element-exists.js

@@ -0,0 +1,20 @@
+import { getBlockConnection } from '../helper';
+
+function elementExists(block) {
+  return new Promise((resolve, reject) => {
+    this._sendMessageToTab(block)
+      .then((data) => {
+        resolve({
+          data,
+          nextBlockId: getBlockConnection(block, data ? 1 : 2),
+        });
+      })
+      .catch((error) => {
+        error.nextBlockId = getBlockConnection(block);
+
+        reject(error);
+      });
+  });
+}
+
+export default elementExists;

+ 72 - 0
src/background/workflow-engine/blocks-handler/handler-execute-workflow.js

@@ -0,0 +1,72 @@
+import browser from 'webextension-polyfill';
+import WorkflowEngine from '../index';
+import { getBlockConnection } from '../helper';
+
+function workflowListener(workflow, options) {
+  return new Promise((resolve, reject) => {
+    const engine = new WorkflowEngine(workflow, options);
+    engine.init();
+    engine.on('destroyed', ({ id, status, message, currentBlock }) => {
+      if (status === 'error') {
+        const error = new Error(message);
+        error.data = { logId: id, name: currentBlock.name };
+
+        reject(error);
+        return;
+      }
+
+      resolve({ id, status, message });
+    });
+
+    options.events.onInit(engine);
+  });
+}
+
+async function executeWorkflow(block) {
+  const nextBlockId = getBlockConnection(block);
+  const { data } = block;
+
+  try {
+    if (data.workflowId === '') throw new Error('empty-workflow');
+
+    const { workflows } = await browser.storage.local.get('workflows');
+    const workflow = workflows.find(({ id }) => id === data.workflowId);
+
+    if (!workflow) {
+      const errorInstance = new Error('no-workflow');
+      errorInstance.data = { workflowId: data.workflowId };
+
+      throw errorInstance;
+    }
+
+    const onInit = (engine) => {
+      this.childWorkflow = engine;
+    };
+    const options = {
+      events: { onInit },
+      isChildWorkflow: true,
+      collectionLogId: this.id,
+      collectionId: this.workflow.id,
+      parentWorkflow: { name: this.workflow.name },
+      globalData: !/\S/g.test(data.globalData) ? null : data.globalData,
+    };
+
+    if (workflow.drawflow.includes(this.workflow.id)) {
+      throw new Error('workflow-infinite-loop');
+    }
+
+    const result = await workflowListener(workflow, options);
+
+    return {
+      data: '',
+      logId: result.id,
+      nextBlockId,
+    };
+  } catch (error) {
+    error.nextBlockId = nextBlockId;
+
+    throw error;
+  }
+}
+
+export default executeWorkflow;

+ 15 - 0
src/background/workflow-engine/blocks-handler/handler-export-data.js

@@ -0,0 +1,15 @@
+import { getBlockConnection } from '../helper';
+import dataExporter from '@/utils/data-exporter';
+
+function exportData(block) {
+  return new Promise((resolve) => {
+    dataExporter(this.data, block.data);
+
+    resolve({
+      data: '',
+      nextBlockId: getBlockConnection(block),
+    });
+  });
+}
+
+export default exportData;

+ 32 - 0
src/background/workflow-engine/blocks-handler/handler-forward-page.js

@@ -0,0 +1,32 @@
+import browser from 'webextension-polyfill';
+import { getBlockConnection } from '../helper';
+
+function forwardPage(block) {
+  return new Promise((resolve, reject) => {
+    const nextBlockId = getBlockConnection(block);
+
+    if (!this.tabId) {
+      const error = new Error('no-tab');
+      error.nextBlockId = nextBlockId;
+
+      reject(nextBlockId);
+
+      return;
+    }
+
+    browser.tabs
+      .goForward(this.tabId)
+      .then(() => {
+        resolve({
+          nextBlockId,
+          data: '',
+        });
+      })
+      .catch((error) => {
+        error.nextBlockId = nextBlockId;
+        reject(error);
+      });
+  });
+}
+
+export default forwardPage;

+ 32 - 0
src/background/workflow-engine/blocks-handler/handler-go-back.js

@@ -0,0 +1,32 @@
+import browser from 'webextension-polyfill';
+import { getBlockConnection } from '../helper';
+
+export function goBack(block) {
+  return new Promise((resolve, reject) => {
+    const nextBlockId = getBlockConnection(block);
+
+    if (!this.tabId) {
+      const error = new Error('no-tab');
+      error.nextBlockId = nextBlockId;
+
+      reject(error);
+
+      return;
+    }
+
+    browser.tabs
+      .goBack(this.tabId)
+      .then(() => {
+        resolve({
+          nextBlockId,
+          data: '',
+        });
+      })
+      .catch((error) => {
+        error.nextBlockId = nextBlockId;
+        reject(error);
+      });
+  });
+}
+
+export default goBack;

+ 92 - 0
src/background/workflow-engine/blocks-handler/handler-interaction-block.js

@@ -0,0 +1,92 @@
+import { objectHasKey, isObject } from '@/utils/helper';
+import { getBlockConnection, convertData } from '../helper';
+
+async function interactionHandler(block, prevBlockData) {
+  const nextBlockId = getBlockConnection(block);
+
+  try {
+    const refData = {
+      prevBlockData,
+      dataColumns: this.data,
+      loopData: this.loopData,
+      globalData: this.globalData,
+      activeTabUrl: this.activeTabUrl,
+    };
+    const data = await this._sendMessageToTab(
+      { ...block, refData },
+      {
+        frameId: this.frameId || 0,
+      }
+    );
+
+    if (block.name === 'link')
+      await new Promise((resolve) => setTimeout(resolve, 5000));
+
+    if (data?.isError) {
+      const error = new Error(data.message);
+      error.nextBlockId = nextBlockId;
+
+      throw error;
+    }
+
+    const getColumn = (name) =>
+      this.workflow.dataColumns.find((item) => item.name === name) || {
+        name: 'column',
+        type: 'text',
+      };
+    const pushData = (column, value) => {
+      this.data[column.name]?.push(convertData(value, column.type));
+    };
+
+    if (objectHasKey(block.data, 'dataColumn')) {
+      const column = getColumn(block.data.dataColumn);
+
+      if (block.data.saveData) {
+        if (Array.isArray(data) && column.type !== 'array') {
+          data.forEach((item) => {
+            pushData(column, item);
+          });
+        } else {
+          pushData(column, data);
+        }
+      }
+    } else if (block.name === 'javascript-code') {
+      const memoColumn = {};
+      const pushObjectData = (obj) => {
+        Object.entries(obj).forEach(([key, value]) => {
+          let column;
+
+          if (memoColumn[key]) {
+            column = memoColumn[key];
+          } else {
+            const currentColumn = getColumn(key);
+
+            column = currentColumn;
+            memoColumn[key] = currentColumn;
+          }
+
+          pushData(column, value);
+        });
+      };
+
+      if (Array.isArray(data)) {
+        data.forEach((obj) => {
+          if (isObject(obj)) pushObjectData(obj);
+        });
+      } else if (isObject(data)) {
+        pushObjectData(data);
+      }
+    }
+
+    return {
+      data,
+      nextBlockId,
+    };
+  } catch (error) {
+    error.nextBlockId = nextBlockId;
+
+    throw error;
+  }
+}
+
+export default interactionHandler;

+ 33 - 0
src/background/workflow-engine/blocks-handler/handler-loop-breakpoint.js

@@ -0,0 +1,33 @@
+import { getBlockConnection } from '../helper';
+
+function loopBreakpoint(block, prevBlockData) {
+  const currentLoop = this.loopList[block.data.loopId];
+
+  return new Promise((resolve) => {
+    const validLoopData =
+      currentLoop.type === 'numbers'
+        ? true
+        : currentLoop.index <= currentLoop.data.length - 1;
+
+    if (
+      currentLoop &&
+      currentLoop.index < currentLoop.maxLoop - 1 &&
+      validLoopData
+    ) {
+      resolve({
+        data: '',
+        nextBlockId: currentLoop.blockId,
+      });
+    } else {
+      delete this.loopList[block.data.loopId];
+      delete this.loopData[block.data.loopId];
+
+      resolve({
+        data: prevBlockData,
+        nextBlockId: getBlockConnection(block),
+      });
+    }
+  });
+}
+
+export default loopBreakpoint;

+ 59 - 0
src/background/workflow-engine/blocks-handler/handler-loop-data.js

@@ -0,0 +1,59 @@
+import { generateJSON } from '@/utils/data-exporter';
+import { getBlockConnection } from '../helper';
+
+function loopData(block) {
+  return new Promise((resolve) => {
+    const { data } = block;
+
+    if (this.loopList[data.loopId]) {
+      this.loopList[data.loopId].index += 1;
+
+      let currentLoopData;
+
+      if (data.loopThrough === 'numbers') {
+        currentLoopData = this.loopData[data.loopId] + 1;
+      } else {
+        currentLoopData =
+          this.loopList[data.loopId].data[this.loopList[data.loopId].index];
+      }
+
+      this.loopData[data.loopId] = currentLoopData;
+    } else {
+      let currLoopData;
+
+      switch (data.loopThrough) {
+        case 'numbers':
+          currLoopData = data.fromNumber;
+          break;
+        case 'data-columns':
+          currLoopData = generateJSON(Object.keys(this.data), this.data);
+          break;
+        case 'custom-data':
+          currLoopData = JSON.parse(data.loopData);
+          break;
+        default:
+      }
+
+      this.loopList[data.loopId] = {
+        index: 0,
+        data: currLoopData,
+        id: data.loopId,
+        blockId: block.id,
+        type: data.loopThrough,
+        maxLoop:
+          data.loopThrough === 'numbers'
+            ? data.toNumber + 1 - data.fromNumber
+            : data.maxLoop || currLoopData.length,
+      };
+      /* eslint-disable-next-line */
+      this.loopData[data.loopId] = data.loopThrough === 'numbers' ? data.fromNumber : currLoopData[0];
+    }
+
+    resolve({
+      data: this.loopData[data.loopId],
+      nextBlockId: getBlockConnection(block),
+    });
+  });
+}
+
+export default loopData;

+ 79 - 0
src/background/workflow-engine/blocks-handler/handler-new-tab.js

@@ -0,0 +1,79 @@
+import browser from 'webextension-polyfill';
+import { getBlockConnection } from '../helper';
+import executeContentScript from '../execute-content-script';
+
+function tabUpdatedListener(tab) {
+  return new Promise((resolve) => {
+    this._listener({
+      name: 'tab-updated',
+      id: tab.id,
+      callback: async (tabId, changeInfo, deleteListener) => {
+        if (changeInfo.status !== 'complete') return;
+
+        const frames = await executeContentScript(tabId, 'newtab');
+
+        deleteListener();
+
+        resolve(frames);
+      },
+    });
+  });
+}
+
+async function newTab(block) {
+  if (this.windowId) {
+    try {
+      await browser.windows.get(this.windowId);
+    } catch (error) {
+      this.windowId = null;
+    }
+  }
+
+  try {
+    const { updatePrevTab, url, active, inGroup } = block.data;
+
+    if (updatePrevTab && this.tabId) {
+      await browser.tabs.update(this.tabId, { url, active });
+    } else {
+      const tab = await browser.tabs.create({
+        url,
+        active,
+        windowId: this.windowId,
+      });
+
+      this.tabId = tab.id;
+      this.activeTabUrl = url;
+      this.windowId = tab.windowId;
+    }
+
+    if (inGroup && !updatePrevTab) {
+      const options = {
+        groupId: this.tabGroupId,
+        tabIds: this.tabId,
+      };
+
+      if (!this.tabGroupId) {
+        options.createProperties = {
+          windowId: this.windowId,
+        };
+      }
+
+      chrome.tabs.group(options, (tabGroupId) => {
+        this.tabGroupId = tabGroupId;
+      });
+    }
+
+    this.frameId = 0;
+    this.frames = await tabUpdatedListener.call(this, { id: this.tabId });
+
+    return {
+      data: url,
+      nextBlockId: getBlockConnection(block),
+    };
+  } catch (error) {
+    console.error(error);
+    throw error;
+  }
+}
+
+export default newTab;

+ 27 - 0
src/background/workflow-engine/blocks-handler/handler-new-window.js

@@ -0,0 +1,27 @@
+import browser from 'webextension-polyfill';
+import { getBlockConnection } from '../helper';
+
+export async function newWindow(block) {
+  const nextBlockId = getBlockConnection(block);
+
+  try {
+    const { incognito, windowState } = block.data;
+    const { id } = await browser.windows.create({
+      incognito,
+      state: windowState,
+    });
+
+    this.windowId = id;
+
+    return {
+      data: id,
+      nextBlockId,
+    };
+  } catch (error) {
+    error.nextBlockId = nextBlockId;
+
+    throw error;
+  }
+}
+
+export default newWindow;

+ 60 - 0
src/background/workflow-engine/blocks-handler/handler-proxy.js

@@ -0,0 +1,60 @@
+import { isWhitespace } from '@/utils/helper';
+import { getBlockConnection } from '../helper';
+
+function setProxy({ data, outputs }) {
+  const nextBlockId = getBlockConnection({ outputs });
+
+  return new Promise((resolve, reject) => {
+    if (data.clearProxy) {
+      chrome.proxy.settings.clear({});
+    }
+
+    const config = {
+      mode: 'fixed_servers',
+      rules: {
+        singleProxy: {
+          scheme: data.scheme,
+        },
+        bypassList: isWhitespace(data.bypassList)
+          ? []
+          : data.bypassList.split(','),
+      },
+    };
+
+    if (!isWhitespace(data.host)) {
+      config.rules.singleProxy.host = data.host;
+    } else {
+      if (data.clearProxy) {
+        this.isUsingProxy = false;
+
+        resolve({
+          data: '',
+          nextBlockId,
+        });
+
+        return;
+      }
+
+      const error = new Error('invalid-proxy-host');
+      error.nextBlockId = nextBlockId;
+
+      reject(error);
+      return;
+    }
+
+    if (data.port !== 0) {
+      config.rules.singleProxy.port = data.port;
+    }
+
+    chrome.proxy.settings.set({ value: config, scope: 'regular' }, () => {
+      this.isUsingProxy = true;
+
+      resolve({
+        data: data.host,
+        nextBlockId,
+      });
+    });
+  });
+}
+
+export default setProxy;

+ 23 - 0
src/background/workflow-engine/blocks-handler/handler-repeat-task.js

@@ -0,0 +1,23 @@
+import { getBlockConnection } from '../helper';
+
+function repeatTask({ data, id, outputs }) {
+  return new Promise((resolve) => {
+    if (this.repeatedTasks[id] >= data.repeatFor) {
+      delete this.repeatedTasks[id];
+
+      resolve({
+        data: data.repeatFor,
+        nextBlockId: getBlockConnection({ outputs }),
+      });
+    } else {
+      this.repeatedTasks[id] = (this.repeatedTasks[id] || 1) + 1;
+
+      resolve({
+        data: data.repeatFor,
+        nextBlockId: getBlockConnection({ outputs }, 2),
+      });
+    }
+  });
+}
+
+export default repeatTask;

+ 39 - 0
src/background/workflow-engine/blocks-handler/handler-switch-to.js

@@ -0,0 +1,39 @@
+import { objectHasKey } from '@/utils/helper';
+import { getBlockConnection } from '../helper';
+
+async function switchTo(block) {
+  const nextBlockId = getBlockConnection(block);
+
+  try {
+    if (block.data.windowType === 'main-window') {
+      this.frameId = 0;
+
+      return {
+        data: '',
+        nextBlockId,
+      };
+    }
+
+    const { url } = await this._sendMessageToTab(block, { frameId: 0 });
+
+    if (objectHasKey(this.frames, url)) {
+      this.frameId = this.frames[url];
+
+      return {
+        data: this.frameId,
+        nextBlockId,
+      };
+    }
+
+    const error = new Error('no-iframe-id');
+    error.data = { selector: block.selector };
+
+    throw error;
+  } catch (error) {
+    error.nextBlockId = nextBlockId;
+
+    throw error;
+  }
+}
+
+export default switchTo;

+ 70 - 0
src/background/workflow-engine/blocks-handler/handler-take-screenshot.js

@@ -0,0 +1,70 @@
+import browser from 'webextension-polyfill';
+import { getBlockConnection } from '../helper';
+import { fileSaver } from '@/utils/helper';
+
+function saveImage({ fileName, uri, ext }) {
+  const image = new Image();
+
+  image.onload = () => {
+    const name = `${fileName || 'Screenshot'}.${ext || 'png'}`;
+    const canvas = document.createElement('canvas');
+    canvas.width = image.width;
+    canvas.height = image.height;
+
+    const context = canvas.getContext('2d');
+    context.drawImage(image, 0, 0);
+
+    fileSaver(name, canvas.toDataURL());
+  };
+
+  image.src = uri;
+}
+
+async function takeScreenshot(block) {
+  const nextBlockId = getBlockConnection(block);
+  const { ext, quality, captureActiveTab, fileName } = block.data;
+
+  try {
+    const options = {
+      quality,
+      format: ext || 'png',
+    };
+
+    if (captureActiveTab) {
+      if (!this.tabId) {
+        throw new Error('no-tab');
+      }
+
+      const [tab] = await browser.tabs.query({
+        active: true,
+        currentWindow: true,
+      });
+
+      await browser.windows.update(this.windowId, { focused: true });
+      await browser.tabs.update(this.tabId, { active: true });
+
+      await new Promise((resolve) => setTimeout(resolve, 500));
+
+      const uri = await browser.tabs.captureVisibleTab(options);
+
+      if (tab) {
+        await browser.windows.update(tab.windowId, { focused: true });
+        await browser.tabs.update(tab.id, { active: true });
+      }
+
+      saveImage({ fileName, uri, ext });
+    } else {
+      const uri = await browser.tabs.captureVisibleTab(options);
+
+      saveImage({ fileName, uri, ext });
+    }
+
+    return { data: '', nextBlockId };
+  } catch (error) {
+    error.nextBlockId = nextBlockId;
+
+    throw error;
+  }
+}
+
+export default takeScreenshot;

+ 20 - 0
src/background/workflow-engine/blocks-handler/handler-trigger.js

@@ -0,0 +1,20 @@
+import { getBlockConnection } from '../helper';
+
+async function trigger(block) {
+  const nextBlockId = getBlockConnection(block);
+
+  try {
+    if (block.data.type === 'visit-web' && this.tabId) {
+      this.frames = await executeContentScript(this.tabId, 'trigger');
+    }
+
+    return { nextBlockId, data: '' };
+  } catch (error) {
+    const errorInstance = new Error(error);
+    errorInstance.nextBlockId = nextBlockId;
+
+    throw errorInstance;
+  }
+}
+
+export default trigger;

+ 37 - 0
src/background/workflow-engine/blocks-handler/handler-webhook.js

@@ -0,0 +1,37 @@
+import { getBlockConnection } from '../helper';
+import { executeWebhook } from '@/utils/webhookUtil';
+
+function webhook({ data, outputs }) {
+  return new Promise((resolve, reject) => {
+    const nextBlockId = getBlockConnection({ outputs });
+
+    if (!data.url) {
+      const error = new Error('URL is empty');
+      error.nextBlockId = nextBlockId;
+
+      reject(error);
+      return;
+    }
+
+    if (!data.url.startsWith('http')) {
+      const error = new Error('URL is not valid');
+      error.nextBlockId = nextBlockId;
+
+      reject(error);
+      return;
+    }
+
+    executeWebhook(data)
+      .then(() => {
+        resolve({
+          data: '',
+          nextBlockId: getBlockConnection({ outputs }),
+        });
+      })
+      .catch((error) => {
+        reject(error);
+      });
+  });
+}
+
+export default webhook;

+ 393 - 0
src/background/workflow-engine/engine.js

@@ -0,0 +1,393 @@
+/* eslint-disable no-underscore-dangle */
+import browser from 'webextension-polyfill';
+import { nanoid } from 'nanoid';
+import { tasks } from '@/utils/shared';
+import { toCamelCase, parseJSON } from '@/utils/helper';
+import { generateJSON } from '@/utils/data-exporter';
+import errorMessage from './error-message';
+import referenceData from '@/utils/reference-data';
+import workflowState from '../workflow-state';
+import executeContentScript from './execute-content-script';
+
+let reloadTimeout;
+
+function tabRemovedHandler(tabId) {
+  if (tabId !== this.tabId) return;
+
+  delete this.tabId;
+
+  if (
+    this.currentBlock.name === 'new-tab' ||
+    tasks[this.currentBlock.name].category === 'interaction'
+  ) {
+    this.destroy('error', 'Current active tab is removed');
+  }
+
+  workflowState.update(this.id, this.state);
+}
+function tabUpdatedHandler(tabId, changeInfo, tab) {
+  const listener = this.tabUpdatedListeners[tabId];
+
+  if (listener) {
+    listener.callback(tabId, changeInfo, () => {
+      delete this.tabUpdatedListeners[tabId];
+    });
+  } else if (this.tabId === tabId) {
+    if (!reloadTimeout) {
+      reloadTimeout = setTimeout(() => {
+        this.isPaused = false;
+      }, 15000);
+    }
+
+    this.isPaused = true;
+
+    if (changeInfo.status === 'complete') {
+      clearTimeout(reloadTimeout);
+      reloadTimeout = null;
+
+      executeContentScript(tabId, 'update tab')
+        .then((frames) => {
+          this.tabId = tabId;
+          this.frames = frames;
+          this.isPaused = false;
+          this.activeTabUrl = tab?.url || '';
+        })
+        .catch((error) => {
+          console.error(error);
+          this.isPaused = false;
+        });
+    }
+  }
+}
+
+class WorkflowEngine {
+  constructor(
+    workflow,
+    {
+      globalData,
+      tabId = null,
+      isInCollection,
+      collectionLogId,
+      blocksHandler,
+      parentWorkflow,
+    }
+  ) {
+    const globalDataVal = globalData || workflow.globalData;
+
+    this.id = nanoid();
+    this.tabId = tabId;
+    this.workflow = workflow;
+    this.blocksHandler = blocksHandler;
+    this.isInCollection = isInCollection;
+    this.parentWorkflow = parentWorkflow;
+    this.collectionLogId = collectionLogId;
+    this.globalData = parseJSON(globalDataVal, globalDataVal);
+    this.activeTabUrl = '';
+    this.data = {};
+    this.logs = [];
+    this.blocks = {};
+    this.frames = {};
+    this.loopList = {};
+    this.loopData = {};
+    this.repeatedTasks = {};
+    this.eventListeners = {};
+    this.isPaused = false;
+    this.isDestroyed = false;
+    this.isUsingProxy = false;
+    this.frameId = null;
+    this.windowId = null;
+    this.tabGroupId = null;
+    this.currentBlock = null;
+    this.childWorkflow = null;
+    this.workflowTimeout = null;
+
+    this.tabUpdatedListeners = {};
+    this.tabUpdatedHandler = tabUpdatedHandler.bind(this);
+    this.tabRemovedHandler = tabRemovedHandler.bind(this);
+  }
+
+  init() {
+    if (this.workflow.isDisabled) return;
+
+    const drawflowData =
+      typeof this.workflow.drawflow === 'string'
+        ? JSON.parse(this.workflow.drawflow || '{}')
+        : this.workflow.drawflow;
+    const blocks = drawflowData?.drawflow.Home.data;
+
+    if (!blocks) {
+      console.error(errorMessage('no-block', this.workflow));
+      return;
+    }
+
+    const blocksArr = Object.values(blocks);
+    const triggerBlock = blocksArr.find(({ name }) => name === 'trigger');
+
+    if (!triggerBlock) {
+      console.error(errorMessage('no-trigger-block', this.workflow));
+      return;
+    }
+
+    browser.tabs.onUpdated.addListener(this.tabUpdatedHandler);
+    browser.tabs.onRemoved.addListener(this.tabRemovedHandler);
+
+    const dataColumns = Array.isArray(this.workflow.dataColumns)
+      ? this.workflow.dataColumns
+      : Object.values(this.workflow.dataColumns);
+
+    this.blocks = blocks;
+    this.startedTimestamp = Date.now();
+    this.workflow.dataColumns = dataColumns;
+    this.data = dataColumns.reduce(
+      (acc, column) => {
+        acc[column.name] = [];
+
+        return acc;
+      },
+      { column: [] }
+    );
+
+    workflowState
+      .add(this.id, {
+        state: this.state,
+        workflowId: this.workflow.id,
+        isInCollection: this.isInCollection,
+      })
+      .then(() => {
+        this._blockHandler(triggerBlock);
+      });
+  }
+
+  on(name, listener) {
+    (this.eventListeners[name] = this.eventListeners[name] || []).push(
+      listener
+    );
+  }
+
+  pause(pause = true) {
+    this.isPaused = pause;
+
+    workflowState.update(this.id, this.state);
+  }
+
+  async stop(message) {
+    try {
+      if (this.childWorkflow) {
+        await this.childWorkflow.stop();
+      }
+
+      this.logs.push({
+        message,
+        type: 'stop',
+        name: 'stop',
+      });
+
+      await this.destroy('stopped');
+    } catch (error) {
+      console.error(error);
+    }
+  }
+
+  async destroy(status, message) {
+    try {
+      if (this.isUsingProxy) chrome.proxy.settings.clear({});
+
+      await browser.tabs.onRemoved.removeListener(this.tabRemovedHandler);
+      await browser.tabs.onUpdated.removeListener(this.tabUpdatedHandler);
+
+      await workflowState.delete(this.id);
+
+      clearTimeout(this.workflowTimeout);
+      this.isDestroyed = true;
+      this.endedTimestamp = Date.now();
+
+      if (!this.workflow.isTesting) {
+        const { logs } = await browser.storage.local.get('logs');
+        const { name, icon, id } = this.workflow;
+        const jsonData = generateJSON(Object.keys(this.data), this.data);
+
+        logs.push({
+          name,
+          icon,
+          status,
+          id: this.id,
+          workflowId: id,
+          data: jsonData,
+          history: this.logs,
+          endedAt: this.endedTimestamp,
+          startedAt: this.startedTimestamp,
+          isChildLog: !!this.parentWorkflow,
+          isInCollection: this.isInCollection,
+          collectionLogId: this.collectionLogId,
+        });
+
+        await browser.storage.local.set({ logs });
+      }
+
+      this.dispatchEvent('destroyed', {
+        id: this.id,
+        status,
+        message,
+        currentBlock: this.currentBlock,
+      });
+
+      this.eventListeners = {};
+      this.tabUpdatedListeners = {};
+    } catch (error) {
+      console.error(error);
+    }
+  }
+
+  dispatchEvent(name, params) {
+    const listeners = this.eventListeners[name];
+
+    if (!listeners) return;
+
+    listeners.forEach((callback) => {
+      callback(params);
+    });
+  }
+
+  get state() {
+    const keys = [
+      'tabId',
+      'isPaused',
+      'isDestroyed',
+      'currentBlock',
+      'isInCollection',
+      'startedTimestamp',
+    ];
+    const state = keys.reduce((acc, key) => {
+      acc[key] = this[key];
+
+      return acc;
+    }, {});
+
+    state.name = this.workflow.name;
+    state.icon = this.workflow.icon;
+
+    if (this.parentWorkflow) state.parentState = this.parentWorkflow;
+
+    return state;
+  }
+
+  _blockHandler(block, prevBlockData) {
+    if (this.isDestroyed) return;
+    if (this.isPaused) {
+      setTimeout(() => {
+        this._blockHandler(block, prevBlockData);
+      }, 1000);
+
+      return;
+    }
+
+    const disableTimeoutKeys = ['delay', 'javascript-code'];
+
+    if (!disableTimeoutKeys.includes(block.name)) {
+      this.workflowTimeout = setTimeout(() => {
+        alert('timeout');
+        if (!this.isDestroyed) this.stop('stop-timeout');
+      }, this.workflow.settings.timeout || 120000);
+    }
+
+    this.currentBlock = block;
+
+    workflowState.update(this.id, this.state);
+    this.dispatchEvent('update', this.state);
+
+    const started = Date.now();
+    const blockHandler = this.blocksHandler[toCamelCase(block?.name)];
+    const handler =
+      !blockHandler && tasks[block.name].category === 'interaction'
+        ? this.blocksHandler.interactionBlock
+        : blockHandler;
+
+    if (handler) {
+      const replacedBlock = referenceData(block, {
+        prevBlockData,
+        data: this.data,
+        loopData: this.loopData,
+        globalData: this.globalData,
+        activeTabUrl: this.activeTabUrl,
+      });
+
+      handler
+        .call(this, replacedBlock, prevBlockData)
+        .then((result) => {
+          clearTimeout(this.workflowTimeout);
+          this.workflowTimeout = null;
+          this.logs.push({
+            type: 'success',
+            name: block.name,
+            logId: result.logId,
+            duration: Math.round(Date.now() - started),
+          });
+
+          if (result.nextBlockId) {
+            this._blockHandler(this.blocks[result.nextBlockId], result.data);
+          } else {
+            this.logs.push({
+              type: 'finish',
+              name: 'finish',
+            });
+            this.dispatchEvent('finish');
+            this.destroy('success');
+          }
+        })
+        .catch((error) => {
+          this.logs.push({
+            type: 'error',
+            message: error.message,
+            name: block.name,
+            ...(error.data || {}),
+          });
+
+          if (
+            this.workflow.settings.onError === 'keep-running' &&
+            error.nextBlockId
+          ) {
+            this._blockHandler(
+              this.blocks[error.nextBlockId],
+              error.data || ''
+            );
+          } else {
+            this.destroy('error', error.message);
+          }
+
+          clearTimeout(this.workflowTimeout);
+          this.workflowTimeout = null;
+
+          console.error(error);
+        });
+    } else {
+      console.error(`"${block.name}" block doesn't have a handler`);
+    }
+  }
+
+  _sendMessageToTab(payload, options = {}) {
+    return new Promise((resolve, reject) => {
+      if (!this.tabId) {
+        const error = new Error('no-tab');
+        error.workflowId = this.id;
+
+        reject(error);
+        return;
+      }
+
+      browser.tabs
+        .sendMessage(this.tabId, { isBlock: true, ...payload }, options)
+        .then(resolve)
+        .catch(reject);
+    });
+  }
+
+  _listener({ id, name, callback, once = true, ...options }) {
+    const listenerNames = {
+      event: 'eventListener',
+      'tab-updated': 'tabUpdatedListeners',
+    };
+    this[listenerNames[name]][id] = { callback, once, ...options };
+  }
+}
+
+export default WorkflowEngine;

+ 0 - 0
src/utils/execute-content-script.js → src/background/workflow-engine/execute-content-script.js


+ 24 - 0
src/background/workflow-engine/helper.js

@@ -0,0 +1,24 @@
+export function convertData(data, type) {
+  let result = data;
+
+  switch (type) {
+    case 'integer':
+      result = +data.replace(/\D+/g, '');
+      break;
+    case 'boolean':
+      result = Boolean(data);
+      break;
+    case 'array':
+      result = Array.from(data);
+      break;
+    default:
+  }
+
+  return result;
+}
+
+export function getBlockConnection(block, index = 1) {
+  const blockId = block.outputs[`output_${index}`]?.connections[0]?.node;
+
+  return blockId;
+}

+ 5 - 353
src/background/workflow-engine/index.js

@@ -1,356 +1,8 @@
-/* eslint-disable no-underscore-dangle */
-import browser from 'webextension-polyfill';
-import { nanoid } from 'nanoid';
-import { tasks } from '@/utils/shared';
-import { toCamelCase, parseJSON } from '@/utils/helper';
-import { generateJSON } from '@/utils/data-exporter';
-import errorMessage from './error-message';
-import referenceData from '@/utils/reference-data';
-import workflowState from '../workflow-state';
-import * as blocksHandler from './blocks-handler';
-import executeContentScript from '@/utils/execute-content-script';
+import Engine from './engine';
+import blocksHandler from './blocks-handler';
 
-let reloadTimeout;
+export default function (workflow, options = {}) {
+  const engine = new Engine(workflow, { ...options, blocksHandler });
 
-function tabRemovedHandler(tabId) {
-  if (tabId !== this.tabId) return;
-
-  delete this.tabId;
-
-  if (
-    this.currentBlock.name === 'new-tab' ||
-    tasks[this.currentBlock.name].category === 'interaction'
-  ) {
-    this.destroy('error', 'Current active tab is removed');
-  }
-
-  workflowState.update(this.id, this.state);
-}
-function tabUpdatedHandler(tabId, changeInfo) {
-  const listener = this.tabUpdatedListeners[tabId];
-
-  if (listener) {
-    listener.callback(tabId, changeInfo, () => {
-      delete this.tabUpdatedListeners[tabId];
-    });
-  } else if (this.tabId === tabId) {
-    if (!reloadTimeout) {
-      reloadTimeout = setTimeout(() => {
-        this.isPaused = false;
-      }, 15000);
-    }
-
-    this.isPaused = true;
-
-    if (changeInfo.status === 'complete') {
-      clearTimeout(reloadTimeout);
-      reloadTimeout = null;
-
-      executeContentScript(tabId, 'update tab')
-        .then((frames) => {
-          this.tabId = tabId;
-          this.frames = frames;
-          this.isPaused = false;
-        })
-        .catch((error) => {
-          console.error(error);
-          this.isPaused = false;
-        });
-    }
-  }
-}
-
-class WorkflowEngine {
-  constructor(
-    workflow,
-    { globalData, tabId = null, isInCollection, collectionLogId }
-  ) {
-    const globalDataVal = globalData || workflow.globalData;
-
-    this.id = nanoid();
-    this.tabId = tabId;
-    this.workflow = workflow;
-    this.isInCollection = isInCollection;
-    this.collectionLogId = collectionLogId;
-    this.globalData = parseJSON(globalDataVal, globalDataVal);
-    this.data = {};
-    this.logs = [];
-    this.blocks = {};
-    this.frames = {};
-    this.loopList = {};
-    this.loopData = {};
-    this.repeatedTasks = {};
-    this.eventListeners = {};
-    this.isPaused = false;
-    this.isDestroyed = false;
-    this.frameId = null;
-    this.windowId = null;
-    this.tabGroupId = null;
-    this.currentBlock = null;
-    this.workflowTimeout = null;
-
-    this.tabUpdatedListeners = {};
-    this.tabUpdatedHandler = tabUpdatedHandler.bind(this);
-    this.tabRemovedHandler = tabRemovedHandler.bind(this);
-  }
-
-  init() {
-    const drawflowData =
-      typeof this.workflow.drawflow === 'string'
-        ? JSON.parse(this.workflow.drawflow || '{}')
-        : this.workflow.drawflow;
-    const blocks = drawflowData?.drawflow.Home.data;
-
-    if (!blocks) {
-      console.error(errorMessage('no-block', this.workflow));
-      return;
-    }
-
-    const blocksArr = Object.values(blocks);
-    const triggerBlock = blocksArr.find(({ name }) => name === 'trigger');
-
-    if (!triggerBlock) {
-      console.error(errorMessage('no-trigger-block', this.workflow));
-      return;
-    }
-
-    browser.tabs.onUpdated.addListener(this.tabUpdatedHandler);
-    browser.tabs.onRemoved.addListener(this.tabRemovedHandler);
-
-    const dataColumns = Array.isArray(this.workflow.dataColumns)
-      ? this.workflow.dataColumns
-      : Object.values(this.workflow.dataColumns);
-
-    this.blocks = blocks;
-    this.startedTimestamp = Date.now();
-    this.workflow.dataColumns = dataColumns;
-    this.data = dataColumns.reduce(
-      (acc, column) => {
-        acc[column.name] = [];
-
-        return acc;
-      },
-      { column: [] }
-    );
-
-    workflowState
-      .add(this.id, {
-        state: this.state,
-        workflowId: this.workflow.id,
-        isInCollection: this.isInCollection,
-      })
-      .then(() => {
-        this._blockHandler(triggerBlock);
-      });
-  }
-
-  on(name, listener) {
-    (this.eventListeners[name] = this.eventListeners[name] || []).push(
-      listener
-    );
-  }
-
-  pause(pause = true) {
-    this.isPaused = pause;
-
-    workflowState.update(this.id, this.state);
-  }
-
-  stop(message) {
-    this.logs.push({
-      message,
-      type: 'stop',
-      name: 'Workflow is stopped',
-    });
-    this.destroy('stopped');
-  }
-
-  async destroy(status, message) {
-    try {
-      this.dispatchEvent('destroyed', { id: this.id, status, message });
-
-      this.eventListeners = {};
-      this.tabUpdatedListeners = {};
-
-      await browser.tabs.onRemoved.removeListener(this.tabRemovedHandler);
-      await browser.tabs.onUpdated.removeListener(this.tabUpdatedHandler);
-
-      await workflowState.delete(this.id);
-
-      clearTimeout(this.workflowTimeout);
-      this.isDestroyed = true;
-      this.endedTimestamp = Date.now();
-
-      if (!this.workflow.isTesting) {
-        const { logs } = await browser.storage.local.get('logs');
-        const { name, icon, id } = this.workflow;
-        const jsonData = generateJSON(Object.keys(this.data), this.data);
-
-        logs.push({
-          name,
-          icon,
-          status,
-          id: this.id,
-          workflowId: id,
-          data: jsonData,
-          history: this.logs,
-          endedAt: this.endedTimestamp,
-          startedAt: this.startedTimestamp,
-          isInCollection: this.isInCollection,
-          collectionLogId: this.collectionLogId,
-        });
-
-        await browser.storage.local.set({ logs });
-      }
-    } catch (error) {
-      console.error(error);
-    }
-  }
-
-  dispatchEvent(name, params) {
-    const listeners = this.eventListeners[name];
-
-    if (!listeners) return;
-
-    listeners.forEach((callback) => {
-      callback(params);
-    });
-  }
-
-  get state() {
-    const keys = [
-      'tabId',
-      'isPaused',
-      'isDestroyed',
-      'currentBlock',
-      'isInCollection',
-      'startedTimestamp',
-    ];
-    const state = keys.reduce((acc, key) => {
-      acc[key] = this[key];
-
-      return acc;
-    }, {});
-
-    state.name = this.workflow.name;
-    state.icon = this.workflow.icon;
-
-    return state;
-  }
-
-  _blockHandler(block, prevBlockData) {
-    if (this.isDestroyed) return;
-    if (this.isPaused) {
-      setTimeout(() => {
-        this._blockHandler(block, prevBlockData);
-      }, 1000);
-
-      return;
-    }
-
-    const disableTimeoutKeys = ['delay', 'javascript-code'];
-
-    if (!disableTimeoutKeys.includes(block.name)) {
-      this.workflowTimeout = setTimeout(() => {
-        if (!this.isDestroyed) this.stop('Workflow stopped because of timeout');
-      }, this.workflow.settings.timeout || 120000);
-    }
-
-    this.currentBlock = block;
-
-    workflowState.update(this.id, this.state);
-    this.dispatchEvent('update', this.state);
-
-    const started = Date.now();
-    const blockHandler = blocksHandler[toCamelCase(block?.name)];
-    const handler =
-      !blockHandler && tasks[block.name].category === 'interaction'
-        ? blocksHandler.interactionHandler
-        : blockHandler;
-
-    if (handler) {
-      const replacedBlock = referenceData(block, {
-        prevBlockData,
-        data: this.data,
-        loopData: this.loopData,
-        globalData: this.globalData,
-      });
-
-      handler
-        .call(this, replacedBlock, prevBlockData)
-        .then((result) => {
-          clearTimeout(this.workflowTimeout);
-          this.workflowTimeout = null;
-          this.logs.push({
-            type: 'success',
-            name: tasks[block.name].name,
-            data: result.data,
-            duration: Math.round(Date.now() - started),
-          });
-
-          if (result.nextBlockId) {
-            this._blockHandler(this.blocks[result.nextBlockId], result.data);
-          } else {
-            this.logs.push({
-              type: 'finish',
-              message: 'Workflow finished running',
-              name: 'Finish',
-            });
-            this.dispatchEvent('finish');
-            this.destroy('success');
-          }
-        })
-        .catch((error) => {
-          this.logs.push({
-            type: 'error',
-            message: error.message,
-            name: tasks[block.name].name,
-          });
-
-          if (
-            this.workflow.settings.onError === 'keep-running' &&
-            error.nextBlockId
-          ) {
-            this._blockHandler(
-              this.blocks[error.nextBlockId],
-              error.data || ''
-            );
-          } else {
-            this.destroy('error', error.message);
-          }
-
-          clearTimeout(this.workflowTimeout);
-          this.workflowTimeout = null;
-
-          console.error(error);
-        });
-    } else {
-      console.error(`"${block.name}" block doesn't have a handler`);
-    }
-  }
-
-  _sendMessageToTab(block, options = {}) {
-    return new Promise((resolve, reject) => {
-      if (!this.tabId) {
-        const message = errorMessage('no-tab', tasks[block.name]);
-
-        reject(new Error(message));
-      }
-
-      browser.tabs
-        .sendMessage(this.tabId, { isBlock: true, ...block }, options)
-        .then(resolve)
-        .catch(reject);
-    });
-  }
-
-  _listener({ id, name, callback, once = true, ...options }) {
-    const listenerNames = {
-      event: 'eventListener',
-      'tab-updated': 'tabUpdatedListeners',
-    };
-    this[listenerNames[name]][id] = { callback, once, ...options };
-  }
+  return engine;
 }
-
-export default WorkflowEngine;

+ 7 - 2
src/components/block/BlockBasic.vue

@@ -14,8 +14,11 @@
       <v-remixicon :name="block.details.icon || 'riGlobalLine'" />
     </span>
     <div style="max-width: 200px">
-      <p class="font-semibold leading-none whitespace-nowrap">
-        {{ block.details.name }}
+      <p
+        v-if="block.details.id"
+        class="font-semibold leading-none whitespace-nowrap"
+      >
+        {{ t(`workflow.blocks.${block.details.id}.name`) }}
       </p>
       <p class="text-gray-600 text-overflow leading-tight">
         {{ block.data.description }}
@@ -31,6 +34,7 @@
 </template>
 <script setup>
 import emitter from 'tiny-emitter/instance';
+import { useI18n } from 'vue-i18n';
 import { useEditorBlock } from '@/composable/editorBlock';
 import { useComponentId } from '@/composable/componentId';
 import BlockBase from './BlockBase.vue';
@@ -42,6 +46,7 @@ const props = defineProps({
   },
 });
 
+const { t } = useI18n();
 const componentId = useComponentId('block-base');
 const block = useEditorBlock(`#${componentId}`, props.editor);
 

+ 18 - 11
src/components/block/BlockConditions.vue

@@ -6,7 +6,7 @@
         class="inline-block text-sm mr-4 p-2 rounded-lg"
       >
         <v-remixicon name="riAB" size="20" class="inline-block mr-1" />
-        <span>conditions</span>
+        <span>{{ t('workflow.blocks.conditions.name') }}</span>
       </div>
       <div class="flex-grow"></div>
       <v-remixicon
@@ -41,7 +41,7 @@
         <div class="flex items-center transition bg-input rounded-lg">
           <select
             v-model="block.data.conditions[index].type"
-            :title="conditions[block.data.conditions[index]?.type] || 'Equals'"
+            :title="getTitle(index)"
             class="
               bg-transparent
               font-mono
@@ -74,16 +74,17 @@
         v-if="block.data.conditions && block.data.conditions.length !== 0"
         class="text-right text-gray-600"
       >
-        <span title="Execute when all comparisons don't meet the requirement">
+        <span :title="t('workflow.blocks.conditions.fallbackTitle')">
           &#9432;
         </span>
-        Fallback
+        {{ t('common.fallback') }}
       </p>
     </div>
   </div>
 </template>
 <script setup>
 import { watch, toRaw } from 'vue';
+import { useI18n } from 'vue-i18n';
 import emitter from 'tiny-emitter/instance';
 import { debounce } from '@/utils/helper';
 import { useComponentId } from '@/composable/componentId';
@@ -96,20 +97,26 @@ const props = defineProps({
   },
 });
 
+const { t } = useI18n();
 const componentId = useComponentId('block-conditions');
 const block = useEditorBlock(`#${componentId}`, props.editor);
 
 const conditions = {
-  '==': 'Equals',
-  '>': 'Greater than',
-  '>=': 'Greater than or equal',
-  '<': 'Less than',
-  '<=': 'Less than or equal',
-  '()': 'Contains',
+  '==': 'equals',
+  '>': 'gt',
+  '>=': 'gte',
+  '<': 'lt',
+  '<=': 'lte',
+  '()': 'contains',
 };
 
+function getTitle(index) {
+  const type = conditions[block.data.conditions[index]?.type] || 'equals';
+
+  return t(`workflow.blocks.conditions.${type}`);
+}
 function addComparison() {
-  if (block.data.conditions.length >= 5) return;
+  if (block.data.conditions.length >= 10) return;
 
   block.data.conditions.push({ type: '==', value: '' });
 

+ 5 - 3
src/components/block/BlockDelay.vue

@@ -6,7 +6,7 @@
         class="inline-block text-sm mr-4 p-2 rounded-lg"
       >
         <v-remixicon name="riTimerLine" size="20" class="inline-block mr-1" />
-        <span>Delay</span>
+        <span>{{ t('workflow.blocks.delay.name') }}</span>
       </div>
       <div class="flex-grow"></div>
       <v-remixicon
@@ -18,9 +18,9 @@
     <input
       :value="block.data.time"
       min="0"
-      title="Delay in millisecond"
+      :title="t('workflow.blocks.delay.input.title')"
+      :placeholder="t('workflow.blocks.delay.input.placeholder')"
       class="px-4 py-2 rounded-lg w-36 bg-input"
-      placeholder="(millisecond)"
       type="number"
       required
       @input="handleInput"
@@ -28,6 +28,7 @@
   </div>
 </template>
 <script setup>
+import { useI18n } from 'vue-i18n';
 import emitter from 'tiny-emitter/instance';
 import { useComponentId } from '@/composable/componentId';
 import { useEditorBlock } from '@/composable/editorBlock';
@@ -39,6 +40,7 @@ const props = defineProps({
   },
 });
 
+const { t } = useI18n();
 const componentId = useComponentId('block-delay');
 const block = useEditorBlock(`#${componentId}`, props.editor);
 

+ 9 - 5
src/components/block/BlockElementExists.vue

@@ -10,10 +10,10 @@
       class="inline-block text-sm mb-2 p-2 rounded-lg"
     >
       <v-remixicon name="riFocus3Line" size="20" class="inline-block mr-1" />
-      <span>Element exists</span>
+      <span>{{ t('workflow.blocks.element-exists.name') }}</span>
     </div>
     <p
-      title="Element selector"
+      :title="t('workflow.blocks.element-exists.selector')"
       class="
         text-overflow
         p-2
@@ -26,11 +26,13 @@
       "
       style="max-width: 200px"
     >
-      {{ block.data.selector || 'Element selector' }}
+      {{ block.data.selector || t('workflow.blocks.element-exists.selector') }}
     </p>
     <p class="text-right text-gray-600">
-      <span title="Execute when element doesn't exists"> &#9432; </span>
-      Fallback
+      <span :title="t('workflow.blocks.element-exists.fallbackTitle')">
+        &#9432;
+      </span>
+      {{ t('common.fallback') }}
     </p>
     <input
       type="text"
@@ -41,6 +43,7 @@
   </block-base>
 </template>
 <script setup>
+import { useI18n } from 'vue-i18n';
 import emitter from 'tiny-emitter/instance';
 import BlockBase from './BlockBase.vue';
 import { useComponentId } from '@/composable/componentId';
@@ -53,6 +56,7 @@ const props = defineProps({
   },
 });
 
+const { t } = useI18n();
 const componentId = useComponentId('block-delay');
 const block = useEditorBlock(`#${componentId}`, props.editor);
 

+ 4 - 2
src/components/block/BlockExportData.vue

@@ -10,7 +10,7 @@
           size="20"
           class="inline-block mr-1"
         />
-        <span>Export data</span>
+        <span>{{ t('workflow.blocks.export-data.name') }}</span>
       </div>
       <div class="flex-grow"></div>
       <v-remixicon
@@ -21,8 +21,8 @@
     </div>
     <input
       v-model="block.data.name"
+      :placeholder="t('common.fileName')"
       class="bg-input rounded-lg transition w-40 mb-2 py-2 px-4 block"
-      placeholder="File name"
     />
     <ui-select v-model="block.data.type" class="w-40" placeholder="Export as">
       <option v-for="type in dataExportTypes" :key="type.id" :value="type.id">
@@ -32,6 +32,7 @@
   </div>
 </template>
 <script setup>
+import { useI18n } from 'vue-i18n';
 import { watch } from 'vue';
 import emitter from 'tiny-emitter/instance';
 import { dataExportTypes } from '@/utils/shared';
@@ -46,6 +47,7 @@ const props = defineProps({
   },
 });
 
+const { t } = useI18n();
 const componentId = useComponentId('block-delay');
 const block = useEditorBlock(`#${componentId}`, props.editor);
 

+ 3 - 1
src/components/block/BlockLoopBreakpoint.vue

@@ -6,7 +6,7 @@
         class="inline-block text-sm mr-4 p-2 rounded-lg"
       >
         <v-remixicon name="riStopLine" size="20" class="inline-block mr-1" />
-        <span>Loop breakpoint</span>
+        <span>{{ t('workflow.blocks.loop-breakpoint.name') }}</span>
       </div>
       <div class="flex-grow"></div>
       <v-remixicon
@@ -26,6 +26,7 @@
   </div>
 </template>
 <script setup>
+import { useI18n } from 'vue-i18n';
 import emitter from 'tiny-emitter/instance';
 import { useComponentId } from '@/composable/componentId';
 import { useEditorBlock } from '@/composable/editorBlock';
@@ -37,6 +38,7 @@ const props = defineProps({
   },
 });
 
+const { t } = useI18n();
 const componentId = useComponentId('block-delay');
 const block = useEditorBlock(`#${componentId}`, props.editor);
 

+ 9 - 3
src/components/block/BlockRepeatTask.vue

@@ -6,7 +6,7 @@
         class="inline-block text-sm mr-4 p-2 rounded-lg"
       >
         <v-remixicon name="riRepeat2Line" size="20" class="inline-block mr-1" />
-        <span>Repeat task</span>
+        <span>{{ t('workflow.blocks.repeat-task.name') }}</span>
       </div>
       <div class="flex-grow"></div>
       <v-remixicon
@@ -34,16 +34,22 @@
         required
         @input="handleInput"
       />
-      <span class="text-gray-600">Times</span>
+      <span class="text-gray-600">{{
+        t('workflow.blocks.repeat-task.times')
+      }}</span>
     </label>
-    <p class="text-right text-gray-600">Repeat from</p>
+    <p class="text-right text-gray-600">
+      {{ t('workflow.blocks.repeat-task.repeatFrom') }}
+    </p>
   </div>
 </template>
 <script setup>
+import { useI18n } from 'vue-i18n';
 import emitter from 'tiny-emitter/instance';
 import { useComponentId } from '@/composable/componentId';
 import { useEditorBlock } from '@/composable/editorBlock';
 
+const { t } = useI18n();
 const props = defineProps({
   editor: {
     type: Object,

+ 48 - 23
src/components/newtab/app/AppSidebar.vue

@@ -36,12 +36,12 @@
       <router-link
         v-for="tab in tabs"
         v-slot="{ href, navigate, isActive }"
-        :key="tab.name"
+        :key="tab.id"
         :to="tab.path"
         custom
       >
         <a
-          v-tooltip:right.group="tab.name"
+          v-tooltip:right.group="t(`common.${tab.id}`, 2)"
           :class="{ 'is-active': isActive }"
           :href="href"
           class="
@@ -64,52 +64,77 @@
       </router-link>
     </div>
     <div class="flex-grow"></div>
-    <a
-      v-tooltip:right="'Documentation'"
-      href="https://github.com/kholid060/automa/wiki"
-      rel="noopener"
-      class="mb-8"
-      target="_blank"
-    >
-      <v-remixicon name="riBookOpenLine" />
-    </a>
-    <a
-      v-tooltip:right="'Github'"
-      href="https://github.com/kholid060/automa"
-      rel="noopener"
-      target="_blank"
-    >
-      <v-remixicon name="riGithubFill" />
-    </a>
+    <ui-popover placement="right" trigger="mouseenter click">
+      <template #trigger>
+        <v-remixicon class="cursor-pointer" name="riInformationLine" />
+      </template>
+      <ui-list class="space-y-1">
+        <ui-list-item
+          v-for="item in links"
+          :key="item.name"
+          :href="item.url"
+          tag="a"
+          rel="noopener"
+          target="_blank"
+        >
+          <v-remixicon :name="item.icon" class="-ml-1 mr-2" />
+          <span>{{ item.name }}</span>
+        </ui-list-item>
+      </ui-list>
+    </ui-popover>
   </aside>
 </template>
 <script setup>
 import { ref } from 'vue';
+import { useI18n } from 'vue-i18n';
 import { useGroupTooltip } from '@/composable/groupTooltip';
 
 useGroupTooltip();
+const { t } = useI18n();
 
+const links = [
+  {
+    name: 'Donate',
+    icon: 'riHandHeartLine',
+    url: 'https://paypal.me/akholid060',
+  },
+  {
+    name: t('common.docs', 2),
+    icon: 'riBookOpenLine',
+    url: 'https://github.com/kholid060/automa/wiki',
+  },
+  {
+    name: 'GitHub',
+    icon: 'riGithubFill',
+    url: 'https://github.com/kholid060/automa',
+  },
+];
 const tabs = [
   {
-    name: 'Dashboard',
+    id: 'dashboard',
     icon: 'riHome5Line',
     path: '/',
   },
   {
-    name: 'Workflows',
+    id: 'workflow',
     icon: 'riFlowChart',
     path: '/workflows',
   },
   {
-    name: 'Collections',
+    id: 'collection',
     icon: 'riFolderLine',
     path: '/collections',
   },
   {
-    name: 'Logs',
+    id: 'log',
     icon: 'riHistoryLine',
     path: '/logs',
   },
+  {
+    id: 'settings',
+    icon: 'riSettings3Line',
+    path: '/settings',
+  },
 ];
 const hoverIndicator = ref(null);
 const showHoverIndicator = ref(false);

+ 10 - 3
src/components/newtab/logs/LogsDataViewer.vue

@@ -1,11 +1,15 @@
 <template>
   <div class="flex items-center">
-    <ui-input v-model="fileName" placeholder="File name" title="File name" />
+    <ui-input
+      v-model="fileName"
+      :placeholder="t('common.fileName')"
+      :title="t('common.fileName')"
+    />
     <div class="flex-grow"></div>
     <ui-popover>
       <template #trigger>
         <ui-button variant="accent">
-          <span>Export data</span>
+          <span>{{ t('log.exportData.title') }}</span>
           <v-remixicon name="riArrowDropDownLine" class="ml-2 -mr-1" />
         </ui-button>
       </template>
@@ -17,7 +21,7 @@
           class="cursor-pointer"
           @click="exportData(type.id)"
         >
-          as {{ type.name }}
+          {{ t(`log.exportData.types.${type.id}`) }}
         </ui-list-item>
       </ui-list>
     </ui-popover>
@@ -32,6 +36,7 @@
 </template>
 <script setup>
 import { ref } from 'vue';
+import { useI18n } from 'vue-i18n';
 import { PrismEditor } from 'vue-prism-editor';
 import { highlighter } from '@/lib/prism';
 import { dataExportTypes } from '@/utils/shared';
@@ -48,6 +53,8 @@ const props = defineProps({
   },
 });
 
+const { t } = useI18n();
+
 const data = Array.isArray(props.log.data)
   ? props.log.data
   : generateJSON(Object.keys(props.log.data), props.log.data);

+ 27 - 16
src/components/newtab/logs/LogsFilters.vue

@@ -2,8 +2,8 @@
   <div class="flex items-center mb-6 space-x-4">
     <ui-input
       :model-value="filters.query"
+      :placeholder="`${t('common.search')}...`"
       prepend-icon="riSearch2Line"
-      placeholder="Search..."
       class="flex-1"
       @change="updateFilters('query', $event)"
     />
@@ -19,7 +19,7 @@
       </ui-button>
       <ui-select
         :model-value="sorts.by"
-        placeholder="Sort by"
+        :placeholder="t('sort.sortBy')"
         @change="updateSorts('by', $event)"
       >
         <option v-for="sort in sortsList" :key="sort.id" :value="sort.id">
@@ -31,25 +31,27 @@
       <template #trigger>
         <ui-button>
           <v-remixicon name="riFilter2Line" class="mr-2 -ml-1" />
-          <span>Filters</span>
+          <span>{{ t('log.filter.title') }}</span>
         </ui-button>
       </template>
       <div class="w-48">
-        <p class="flex-1 mb-2 font-semibold">Filters</p>
-        <p class="mb-2 text-sm text-gray-600">By status</p>
+        <p class="flex-1 mb-2 font-semibold">{{ t('log.filter.title') }}</p>
+        <p class="mb-2 text-sm text-gray-600">{{ t('log.filter.byStatus') }}</p>
         <div class="grid grid-cols-2 gap-2">
           <ui-radio
             v-for="status in filterByStatus"
-            :key="status"
+            :key="status.id"
             :model-value="filters.byStatus"
-            :value="status"
+            :value="status.id"
             class="capitalize text-sm"
             @change="updateFilters('byStatus', $event)"
           >
-            {{ status }}
+            {{ status.name }}
           </ui-radio>
         </div>
-        <p class="mb-1 text-sm text-gray-600 mt-3">By date</p>
+        <p class="mb-1 text-sm text-gray-600 mt-3">
+          {{ t('log.filter.byDate.title') }}
+        </p>
         <ui-select
           :model-value="filters.byDate"
           class="w-full"
@@ -64,6 +66,8 @@
   </div>
 </template>
 <script setup>
+import { useI18n } from 'vue-i18n';
+
 defineProps({
   filters: {
     type: Object,
@@ -76,16 +80,23 @@ defineProps({
 });
 const emit = defineEmits(['updateSorts', 'updateFilters']);
 
-const filterByStatus = ['all', 'success', 'stopped', 'error'];
+const { t } = useI18n();
+
+const filterByStatus = [
+  { id: 'all', name: t('common.all') },
+  { id: 'success', name: t('logStatus.success') },
+  { id: 'stopped', name: t('logStatus.stopped') },
+  { id: 'error', name: t('logStatus.error') },
+];
 const filterByDate = [
-  { id: 0, name: 'All' },
-  { id: 1, name: 'Last day' },
-  { id: 7, name: 'Last 7 days' },
-  { id: 30, name: 'Last 30 days' },
+  { id: 0, name: t('common.all') },
+  { id: 1, name: t('log.filter.byDate.items.lastDay') },
+  { id: 7, name: t('log.filter.byDate.items.last7Days') },
+  { id: 30, name: t('log.filter.byDate.items.last30Days') },
 ];
 const sortsList = [
-  { id: 'name', name: 'Name' },
-  { id: 'startedAt', name: 'Created date' },
+  { id: 'name', name: t('sort.name') },
+  { id: 'startedAt', name: t('sort.createdAt') },
 ];
 
 function updateFilters(key, value) {

+ 38 - 40
src/components/newtab/shared/SharedCard.vue

@@ -1,43 +1,45 @@
 <template>
   <ui-card class="hover:ring-2 group hover:ring-accent">
-    <div id="workflowCard" class="flex items-center mb-4">
-      <span v-if="data.isIconFromURL" class="p-2 rounded-lg bg-box-transparent">
-        <img
+    <slot name="header">
+      <div class="flex items-center mb-4">
+        <ui-img
+          v-if="data.icon.startsWith('http')"
+          :src="data.icon"
+          class="overflow-hidden rounded-lg"
+          style="height: 40px; width: 40px"
           alt="Can not display"
-          :src="getIcon"
-          style="max-width: 60px; max-height: 20px"
         />
-      </span>
-      <span v-else class="p-2 rounded-lg bg-box-transparent">
-        <v-remixicon :name="data.icon || icon" />
-      </span>
-      <div class="flex-grow"></div>
-      <button
-        class="invisible group-hover:visible"
-        @click="$emit('execute', data)"
-      >
-        <v-remixicon name="riPlayLine" />
-      </button>
-      <ui-popover v-if="showDetails" class="h-6 ml-2">
-        <template #trigger>
-          <button>
-            <v-remixicon name="riMoreLine" />
-          </button>
-        </template>
-        <ui-list class="w-36 space-y-1">
-          <ui-list-item
-            v-for="item in menu"
-            :key="item.name"
-            v-close-popover
-            class="cursor-pointer"
-            @click="$emit('menuSelected', { name: item.name, data })"
-          >
-            <v-remixicon :name="item.icon" class="mr-2 -ml-1" />
-            <span class="capitalize">{{ item.name }}</span>
-          </ui-list-item>
-        </ui-list>
-      </ui-popover>
-    </div>
+        <span v-else class="p-2 rounded-lg bg-box-transparent">
+          <v-remixicon :name="data.icon || icon" />
+        </span>
+        <div class="flex-grow"></div>
+        <button
+          class="invisible group-hover:visible"
+          @click="$emit('execute', data)"
+        >
+          <v-remixicon name="riPlayLine" />
+        </button>
+        <ui-popover v-if="showDetails" class="h-6 ml-2">
+          <template #trigger>
+            <button>
+              <v-remixicon name="riMoreLine" />
+            </button>
+          </template>
+          <ui-list class="space-y-1" style="min-width: 150px">
+            <ui-list-item
+              v-for="item in menu"
+              :key="item.id"
+              v-close-popover
+              class="cursor-pointer"
+              @click="$emit('menuSelected', { id: item.id, data })"
+            >
+              <v-remixicon :name="item.icon" class="mr-2 -ml-1" />
+              <span class="capitalize">{{ item.name }}</span>
+            </ui-list-item>
+          </ui-list>
+        </ui-popover>
+      </div>
+    </slot>
     <div class="cursor-pointer" @click="$emit('click', data)">
       <p class="line-clamp font-semibold leading-tight">
         {{ data.name }}
@@ -69,10 +71,6 @@ const props = defineProps({
   },
 });
 
-const getIcon = computed(() => {
-  return props.data.icon;
-});
-
 defineEmits(['execute', 'click', 'menuSelected']);
 
 let formattedDate = null;

+ 6 - 3
src/components/newtab/shared/SharedLogsTable.vue

@@ -13,7 +13,7 @@
         </td>
         <td class="log-time">
           <v-remixicon
-            title="Started date"
+            :title="t('log.startedDate')"
             name="riCalendarLine"
             class="mr-2 inline-block align-middle"
           />
@@ -21,7 +21,7 @@
             {{ formatDate(log.startedAt, 'relative') }}
           </span>
         </td>
-        <td class="log-time" title="Duration">
+        <td class="log-time" :title="t('log.duration')">
           <v-remixicon name="riTimerLine"></v-remixicon>
           <span>{{ countDuration(log.startedAt, log.endedAt) }}</span>
         </td>
@@ -31,7 +31,7 @@
             :title="log.status === 'error' ? getErrorMessage(log) : null"
             class="inline-block py-1 w-16 text-center text-sm rounded-lg"
           >
-            {{ log.status }}
+            {{ t(`logStatus.${log.status}`) }}
           </span>
         </td>
         <slot name="item-append" :log="log" />
@@ -40,6 +40,7 @@
   </table>
 </template>
 <script setup>
+import { useI18n } from 'vue-i18n';
 import { countDuration } from '@/utils/helper';
 import dayjs from '@/lib/dayjs';
 
@@ -50,6 +51,8 @@ defineProps({
   },
 });
 
+const { t } = useI18n();
+
 const statusColors = {
   error: 'bg-red-200',
   success: 'bg-green-200',

+ 21 - 4
src/components/newtab/shared/SharedWorkflowState.vue

@@ -22,9 +22,13 @@
       >
         <v-remixicon name="riExternalLinkLine" />
       </ui-button>
-      <ui-button variant="accent" @click="stopWorkflow">
+      <ui-button
+        variant="accent"
+        :disabled="!!data.state.parentState"
+        @click="stopWorkflow"
+      >
         <v-remixicon name="riStopLine" class="mr-2 -ml-1" />
-        <span>Stop</span>
+        <span>{{ t('common.stop') }}</span>
       </ui-button>
     </div>
     <div class="divide-y bg-box-transparent divide-y px-4 rounded-lg">
@@ -34,14 +38,21 @@
         class="flex items-center py-2"
       >
         <v-remixicon :name="block.icon" />
-        <p class="flex-1 ml-2 mr-4">{{ block.name }}</p>
+        <p class="flex-1 ml-2 mr-4 text-overflow">{{ block.name }}</p>
         <ui-spinner color="text-accnet" size="20" />
       </div>
     </div>
+    <div
+      v-if="data.state.parentState"
+      class="py-2 px-4 bg-yellow-200 rounded-lg mt-2 text-sm"
+    >
+      {{ t('workflow.state.executeBy', { name: data.state.parentState.name }) }}
+    </div>
   </ui-card>
 </template>
 <script setup>
 import browser from 'webextension-polyfill';
+import { useI18n } from 'vue-i18n';
 import { sendMessage } from '@/utils/message';
 import { tasks } from '@/utils/shared';
 import dayjs from '@/lib/dayjs';
@@ -53,12 +64,18 @@ const props = defineProps({
   },
 });
 
+const { t } = useI18n();
+
 function getBlock() {
   if (!props.data.state.currentBlock) return [];
 
   if (Array.isArray(props.data.state.currentBlock)) {
     return props.data.state.currentBlock.map((item) => {
-      if (tasks[item.name]) return tasks[item.name];
+      if (tasks[item.name])
+        return {
+          ...tasks[item.name],
+          name: t(`workflow.blocks.${item.name}.name`),
+        };
 
       return item;
     });

+ 39 - 9
src/components/newtab/workflow/WorkflowActions.vue

@@ -12,13 +12,22 @@
   </ui-card>
   <ui-card padding="p-1 ml-4">
     <button
-      v-tooltip.group="'Execute'"
+      v-if="!workflow.isDisabled"
+      v-tooltip.group="t('common.execute')"
       icon
       class="hoverable p-2 rounded-lg"
       @click="$emit('execute')"
     >
       <v-remixicon name="riPlayLine" />
     </button>
+    <button
+      v-else
+      v-tooltip="t('workflow.clickToEnable')"
+      class="p-2"
+      @click="$emit('update', { isDisabled: false })"
+    >
+      {{ t('common.disabled') }}
+    </button>
   </ui-card>
   <ui-card padding="p-1 ml-4 space-x-1">
     <ui-popover>
@@ -28,6 +37,13 @@
         </button>
       </template>
       <ui-list class="w-36">
+        <ui-list-item
+          class="cursor-pointer"
+          @click="$emit('update', { isDisabled: !workflow.isDisabled })"
+        >
+          <v-remixicon name="riToggleLine" class="mr-2 -ml-1" />
+          {{ t(`common.${workflow.isDisabled ? 'enable' : 'disable'}`) }}
+        </ui-list-item>
         <ui-list-item
           v-for="item in moreActions"
           :key="item.id"
@@ -62,11 +78,12 @@
         ></span>
       </span>
       <v-remixicon name="riSaveLine" class="mr-2 -ml-1 my-1" />
-      Save
+      {{ t('common.save') }}
     </ui-button>
   </ui-card>
 </template>
 <script setup>
+import { useI18n } from 'vue-i18n';
 import { useGroupTooltip } from '@/composable/groupTooltip';
 
 defineProps({
@@ -74,42 +91,55 @@ defineProps({
     type: Boolean,
     default: false,
   },
+  workflow: {
+    type: Object,
+    default: () => ({}),
+  },
 });
-defineEmits(['showModal', 'execute', 'rename', 'delete', 'save', 'export']);
+defineEmits([
+  'showModal',
+  'execute',
+  'rename',
+  'delete',
+  'save',
+  'export',
+  'update',
+]);
 
 useGroupTooltip();
+const { t } = useI18n();
 
 const modalActions = [
   {
     id: 'data-columns',
-    name: 'Data columns',
+    name: t('workflow.dataColumns.title'),
     icon: 'riKey2Line',
   },
   {
     id: 'global-data',
-    name: 'Global data',
+    name: t('common.globalData'),
     icon: 'riDatabase2Line',
   },
   {
     id: 'settings',
-    name: 'Settings',
+    name: t('common.settings'),
     icon: 'riSettings3Line',
   },
 ];
 const moreActions = [
   {
     id: 'export',
-    name: 'Export',
+    name: t('common.export'),
     icon: 'riDownloadLine',
   },
   {
     id: 'rename',
-    name: 'Rename',
+    name: t('common.rename'),
     icon: 'riPencilLine',
   },
   {
     id: 'delete',
-    name: 'Delete',
+    name: t('common.delete'),
     icon: 'riDeleteBin7Line',
   },
   {

+ 8 - 5
src/components/newtab/workflow/WorkflowBuilder.vue

@@ -8,7 +8,7 @@
     <slot></slot>
     <div class="absolute z-10 p-4 bottom-0 left-0">
       <button
-        v-tooltip.group="'Reset zoom'"
+        v-tooltip.group="t('workflow.editor.resetZoom')"
         class="p-2 rounded-lg bg-white mr-2"
         @click="editor.zoom_reset()"
       >
@@ -16,7 +16,7 @@
       </button>
       <div class="rounded-lg bg-white inline-block">
         <button
-          v-tooltip.group="'Zoom out'"
+          v-tooltip.group="t('workflow.editor.zoomOut')"
           class="p-2 rounded-lg relative z-10"
           @click="editor.zoom_out()"
         >
@@ -24,7 +24,7 @@
         </button>
         <hr class="h-6 border-r inline-block" />
         <button
-          v-tooltip.group="'Zoom in'"
+          v-tooltip.group="t('workflow.editor.zoomIn')"
           class="p-2 rounded-lg"
           @click="editor.zoom_in()"
         >
@@ -56,6 +56,7 @@
 /* eslint-disable camelcase */
 import { onMounted, shallowRef, reactive, getCurrentInstance } from 'vue';
 import emitter from 'tiny-emitter/instance';
+import { useI18n } from 'vue-i18n';
 import { tasks } from '@/utils/shared';
 import { useGroupTooltip } from '@/composable/groupTooltip';
 import drawflow from '@/lib/drawflow';
@@ -70,18 +71,19 @@ export default {
   emits: ['load', 'deleteBlock'],
   setup(props, { emit }) {
     useGroupTooltip();
+    const { t } = useI18n();
 
     const contextMenuItems = {
       block: [
         {
           id: 'duplicate',
-          name: 'Duplicate',
+          name: t('workflow.editor.duplicate'),
           icon: 'riFileCopyLine',
           event: 'duplicateBlock',
         },
         {
           id: 'delete',
-          name: 'Delete',
+          name: t('common.delete'),
           icon: 'riDeleteBin7Line',
           event: 'deleteBlock',
         },
@@ -254,6 +256,7 @@ export default {
     });
 
     return {
+      t,
       editor,
       contextMenu,
       dropHandler,

+ 9 - 4
src/components/newtab/workflow/WorkflowDataColumns.vue

@@ -3,12 +3,14 @@
     <ui-input
       v-model.lowercase="state.query"
       autofocus
-      placeholder="Search or add column"
+      :placeholder="t('workflow.dataColumns.placeholder')"
       class="mr-2 flex-1"
       @keyup.enter="addColumn"
       @keyup.esc="$emit('close')"
     />
-    <ui-button variant="accent" @click="addColumn">Add</ui-button>
+    <ui-button variant="accent" @click="addColumn">
+      {{ t('common.add') }}
+    </ui-button>
   </div>
   <ul
     class="space-y-2 overflow-y-auto scroll py-1"
@@ -23,12 +25,12 @@
         :model-value="columns[index].name"
         disabled
         class="flex-1"
-        placeholder="Column name"
+        :placeholder="t('workflow.dataColumns.column.name')"
       />
       <ui-select
         v-model="columns[index].type"
         class="flex-1"
-        placeholder="Data type"
+        :placeholder="t('workflow.dataColumns.column.type')"
       >
         <option v-for="type in dataTypes" :key="type.id" :value="type.id">
           {{ type.name }}
@@ -42,6 +44,7 @@
 </template>
 <script setup>
 import { computed, onMounted, watch, reactive } from 'vue';
+import { useI18n } from 'vue-i18n';
 import { debounce } from '@/utils/helper';
 
 const props = defineProps({
@@ -52,6 +55,8 @@ const props = defineProps({
 });
 const emit = defineEmits(['update', 'close']);
 
+const { t } = useI18n();
+
 const dataTypes = [
   { id: 'string', name: 'Text' },
   { id: 'integer', name: 'Number' },

+ 61 - 25
src/components/newtab/workflow/WorkflowDetailsCard.vue

@@ -1,21 +1,45 @@
 <template>
   <div class="px-4 flex items-center mb-2 mt-1">
-    <ui-popover class="mr-2 h-6">
+    <ui-popover class="mr-2 h-8">
       <template #trigger>
-        <span title="Workflow icon" class="cursor-pointer">
-          <v-remixicon :name="workflow.icon" size="26" />
-        </span>
-      </template>
-      <p class="mb-2">Workflow icon</p>
-      <div class="grid grid-cols-4 gap-1">
         <span
-          v-for="icon in icons"
-          :key="icon"
-          class="cursor-pointer rounded-lg inline-block p-2 hoverable"
-          @click="$emit('update', { icon })"
+          :title="t('workflow.sidebar.workflowIcon')"
+          class="cursor-pointer inline-block h-full"
         >
-          <v-remixicon :name="icon" />
+          <ui-img
+            v-if="workflow.icon.startsWith('http')"
+            :src="workflow.icon"
+            class="w-8 h-8"
+          />
+          <v-remixicon v-else :name="workflow.icon" size="26" class="mt-1" />
         </span>
+      </template>
+      <div class="w-56">
+        <p class="mb-2">{{ t('workflow.sidebar.workflowIcon') }}</p>
+        <div class="grid grid-cols-5 mb-2 gap-1">
+          <span
+            v-for="icon in icons"
+            :key="icon"
+            class="
+              cursor-pointer
+              rounded-lg
+              inline-block
+              text-center
+              p-2
+              hoverable
+            "
+            @click="$emit('update', { icon })"
+          >
+            <v-remixicon :name="icon" />
+          </span>
+        </div>
+        <ui-input
+          :model-value="workflow.icon.startsWith('ri') ? '' : workflow.icon"
+          type="url"
+          placeholder="http://example.com/img.png"
+          label="Icon URL"
+          @change="$emit('update', { icon: $event })"
+        />
       </div>
     </ui-popover>
     <p class="font-semibold text-overflow inline-block text-lg flex-1 mr-4">
@@ -24,12 +48,12 @@
   </div>
   <ui-input
     v-model="query"
+    :placeholder="`${t('common.search')}...`"
     prepend-icon="riSearch2Line"
     class="px-4 mt-4 mb-2"
-    placeholder="Search blocks"
   />
   <div class="scroll bg-scroll overflow-auto px-4 flex-1 overflow-auto">
-    <template v-for="(items, catId) in taskList" :key="catId">
+    <template v-for="(items, catId) in blocks" :key="catId">
       <div class="flex items-center top-0 space-x-2 mb-2">
         <span
           :class="categories[catId].color"
@@ -41,7 +65,13 @@
         <div
           v-for="block in items"
           :key="block.id"
-          :title="block.description || block.name"
+          :title="
+            t(
+              `workflow.blocks.${block.id}.${
+                block.description ? 'description' : 'name'
+              }`
+            )
+          "
           draggable="true"
           class="
             transform
@@ -61,7 +91,7 @@
             v-if="block.docs"
             :href="`https://github.com/Kholid060/automa/wiki/Blocks#${block.id}`"
             target="_blank"
-            title="Documentation"
+            :title="t('common.docs')"
             rel="noopener"
             class="absolute top-px right-2"
           >
@@ -78,6 +108,7 @@
 </template>
 <script setup>
 import { computed, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
 import { tasks, categories } from '@/utils/shared';
 
 defineProps({
@@ -92,6 +123,8 @@ defineProps({
 });
 defineEmits(['update']);
 
+const { t } = useI18n();
+
 const icons = [
   'riGlobalLine',
   'riFileTextLine',
@@ -107,16 +140,19 @@ const icons = [
   'riCommandLine',
 ];
 
-const query = ref('');
-const taskList = computed(() =>
-  Object.keys(tasks).reduce((arr, key) => {
-    const task = tasks[key];
+const blocksArr = Object.entries(tasks).map(([key, block]) => ({
+  ...block,
+  id: key,
+  name: t(`workflow.blocks.${key}.name`),
+}));
 
-    if (tasks[key].name.toLowerCase().includes(query.value.toLowerCase())) {
-      (arr[task.category] = arr[task.category] || []).push({
-        id: key,
-        ...task,
-      });
+const query = ref('');
+const blocks = computed(() =>
+  blocksArr.reduce((arr, block) => {
+    if (
+      block.name.toLocaleLowerCase().includes(query.value.toLocaleLowerCase())
+    ) {
+      (arr[block.category] = arr[block.category] || []).push(block);
     }
 
     return arr;

+ 5 - 1
src/components/newtab/workflow/WorkflowEditBlock.vue

@@ -5,7 +5,7 @@
         <v-remixicon name="riArrowLeftLine" />
       </button>
       <p class="font-semibold inline-block align-middle">
-        {{ data.name }}
+        {{ t(`workflow.blocks.${data.id}.name`) }}
       </p>
     </div>
     <component
@@ -19,6 +19,7 @@
 </template>
 <script>
 import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
 
 const editComponents = require.context(
   './edit',
@@ -46,6 +47,8 @@ export default {
   },
   emits: ['close', 'update'],
   setup(props, { emit }) {
+    const { t } = useI18n();
+
     const blockData = computed({
       get() {
         return props.data.data || {};
@@ -56,6 +59,7 @@ export default {
     });
 
     return {
+      t,
       blockData,
     };
   },

+ 4 - 1
src/components/newtab/workflow/WorkflowGlobalData.vue

@@ -6,7 +6,7 @@
       rel="noopener"
       class="inline-block text-primary"
     >
-      Learn how to access the global data in a block
+      {{ t('message.useDynamicData') }}
     </a>
     <p class="float-right clear-both" title="Characters limit">
       {{ globalData.length }}/{{ maxLength.toLocaleString() }}
@@ -21,6 +21,7 @@
 </template>
 <script setup>
 import { ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
 import { PrismEditor } from 'vue-prism-editor';
 import { highlighter } from '@/lib/prism';
 import { debounce } from '@/utils/helper';
@@ -33,6 +34,8 @@ const props = defineProps({
 });
 const emit = defineEmits(['update']);
 
+const { t } = useI18n();
+
 const maxLength = 1e4;
 const globalData = ref(`${props.workflow.globalData}`);
 

+ 5 - 2
src/components/newtab/workflow/WorkflowRunning.vue

@@ -25,7 +25,7 @@
         </ui-button>
         <ui-button variant="accent" @click="stopWorkflow(item)">
           <v-remixicon name="riStopLine" class="mr-2 -ml-1" />
-          <span>Stop</span>
+          <span>{{ t('common.stop') }}</span>
         </ui-button>
       </div>
       <div class="flex items-center bg-box-transparent px-4 py-2 rounded-lg">
@@ -34,13 +34,14 @@
           <p class="flex-1 ml-2 mr-4">{{ getBlock(item).name }}</p>
           <ui-spinner color="text-accnet" size="20" />
         </template>
-        <p v-else>No block</p>
+        <p v-else>{{ t('message.noBlock') }}</p>
       </div>
     </ui-card>
   </div>
 </template>
 <script setup>
 import browser from 'webextension-polyfill';
+import { useI18n } from 'vue-i18n';
 import { sendMessage } from '@/utils/message';
 import { tasks } from '@/utils/shared';
 import dayjs from '@/lib/dayjs';
@@ -52,6 +53,8 @@ defineProps({
   },
 });
 
+const { t } = useI18n();
+
 function getBlock(item) {
   if (!item.state.currentBlock) return {};
 

+ 14 - 4
src/components/newtab/workflow/WorkflowSettings.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="workflow-settings">
     <div class="mb-4">
-      <p class="mb-1">On workflow error</p>
+      <p class="mb-1">{{ t('workflow.settings.onError.title') }}</p>
       <div class="space-x-4">
         <ui-radio
           v-for="item in onError"
@@ -16,7 +16,7 @@
       </div>
     </div>
     <div>
-      <p class="mb-1">Workflow timeout (milliseconds)</p>
+      <p class="mb-1">{{ t('workflow.settings.timeout.title') }}</p>
       <ui-input
         :model-value="workflow.settings.timeout"
         type="number"
@@ -26,6 +26,8 @@
   </div>
 </template>
 <script setup>
+import { useI18n } from 'vue-i18n';
+
 const props = defineProps({
   workflow: {
     type: Object,
@@ -34,9 +36,17 @@ const props = defineProps({
 });
 const emit = defineEmits(['update']);
 
+const { t } = useI18n();
+
 const onError = [
-  { id: 'keep-running', name: 'Keep workflow running' },
-  { id: 'stop-workflow', name: 'Stop workflow' },
+  {
+    id: 'keep-running',
+    name: t('workflow.settings.onError.items.keepRunning'),
+  },
+  {
+    id: 'stop-workflow',
+    name: t('workflow.settings.onError.items.stopWorkflow'),
+  },
 ];
 
 function updateWorkflow(data) {

+ 0 - 84
src/components/newtab/workflow/WorkflowTask.vue

@@ -1,84 +0,0 @@
-<template>
-  <div
-    class="workflow-task rounded-lg group hoverable"
-    :class="{ 'bg-box-transparent': show }"
-  >
-    <div
-      class="
-        flex
-        items-center
-        w-full
-        text-left
-        py-2
-        px-3
-        cursor-pointer
-        rounded-lg
-      "
-      @click="show = !show"
-    >
-      <v-remixicon
-        :rotate="show ? 270 : 180"
-        name="riArrowLeftSLine"
-        class="-ml-1 mr-4 text-gray-600 dark:text-gray-200 transition-transform"
-      />
-      <v-remixicon :name="currentTask.icon" size="22" class="mr-3" />
-      <p class="flex-1 mr-2 text-overflow">
-        {{ task.name || currentTask.name }}
-      </p>
-      <v-remixicon
-        name="riDeleteBin7Line"
-        class="group-hover:visible mr-2 invisible cursor-pointer"
-        size="22"
-        @click.stop="$emit('delete', task)"
-      />
-      <v-remixicon
-        id="drag-handler"
-        name="mdiDrag"
-        style="cursor: grab"
-        @mousedown="show = false"
-      />
-    </div>
-    <transition-expand>
-      <div v-if="show" class="py-2 pr-4 pl-12 max-w-xl">
-        <div class="flex items-center mb-2">
-          <ui-input
-            :model-value="task.name"
-            placeholder="Task name"
-            class="flex-1"
-            @change="updateTask({ name: $event || currentTask.task })"
-          />
-          <ui-input
-            v-if="currentTask.needWebsite"
-            placeholder="Website"
-            class="flex-1 ml-2"
-          />
-        </div>
-        <div v-if="currentTask.needSelector" class="flex items-center">
-          <ui-button icon class="mr-2">
-            <v-remixicon name="riFocus3Line" />
-          </ui-button>
-          <ui-input placeholder="Element selector" class="mr-4 flex-1" />
-          <ui-checkbox>Multiple</ui-checkbox>
-        </div>
-      </div>
-    </transition-expand>
-  </div>
-</template>
-<script setup>
-import { tasks } from '@/utils/shared';
-
-const props = defineProps({
-  task: {
-    type: Object,
-    default: () => ({}),
-  },
-});
-const emit = defineEmits(['delete', 'update']);
-
-const show = ref(false);
-const currentTask = computed(() => tasks[props.task.type]);
-
-function updateTask(data) {
-  emit('update', props.task.id, data);
-}
-</script>

+ 6 - 8
src/components/newtab/workflow/edit/EditAttributeValue.vue

@@ -2,7 +2,7 @@
   <edit-interaction-base v-bind="{ data }" @change="updateData">
     <ui-input
       :model-value="data.attributeName"
-      placeholder="Attribute name"
+      :placeholder="t('workflow.blocks.attribute-value.forms.name')"
       class="mt-3 w-full"
       @change="updateData({ attributeName: $event })"
     />
@@ -11,12 +11,12 @@
       class="mt-3"
       @change="updateData({ saveData: $event })"
     >
-      Save data
+      {{ t('workflow.blocks.attribute-value.forms.checkbox') }}
     </ui-checkbox>
     <div v-if="data.saveData" class="flex items-center mt-1">
       <ui-select
         :model-value="data.dataColumn"
-        placeholder="Data column"
+        :placeholder="t('workflow.blocks.attribute-value.forms.column')"
         class="mr-2 flex-1"
         @change="updateData({ dataColumn: $event })"
       >
@@ -28,11 +28,7 @@
           {{ column.name }}
         </option>
       </ui-select>
-      <ui-button
-        icon
-        title="Data columns"
-        @click="workflow.showDataColumnsModal(true)"
-      >
+      <ui-button icon @click="workflow.showDataColumnsModal(true)">
         <v-remixicon name="riKey2Line" />
       </ui-button>
     </div>
@@ -40,6 +36,7 @@
 </template>
 <script setup>
 import { inject } from 'vue';
+import { useI18n } from 'vue-i18n';
 import EditInteractionBase from './EditInteractionBase.vue';
 
 const props = defineProps({
@@ -50,6 +47,7 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
+const { t } = useI18n();
 const workflow = inject('workflow');
 
 function updateData(value) {

+ 6 - 2
src/components/newtab/workflow/edit/EditCloseTab.vue

@@ -5,7 +5,7 @@
         :model-value="data.activeTab"
         @change="updateData({ activeTab: $event })"
       >
-        Close active tab
+        {{ t('workflow.blocks.close-tab.activeTab') }}
       </ui-checkbox>
     </div>
     <ui-input
@@ -15,7 +15,7 @@
       @change="updateData({ url: $event })"
     >
       <template #label>
-        URL or match pattern
+        {{ t('workflow.blocks.close-tab.url') }}
         <a
           href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns"
           target="_blank"
@@ -29,6 +29,8 @@
   </div>
 </template>
 <script setup>
+import { useI18n } from 'vue-i18n';
+
 const props = defineProps({
   data: {
     type: Object,
@@ -37,6 +39,8 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
+const { t } = useI18n();
+
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });
 }

+ 9 - 5
src/components/newtab/workflow/edit/EditElementExists.vue

@@ -1,24 +1,24 @@
 <template>
   <ui-input
     :model-value="data.selector"
-    label="Element selector"
+    :label="t('workflow.blocks.element-exists.selector')"
     class="mb-1 w-full"
     @change="updateData({ selector: $event })"
   />
   <div class="flex space-x-2">
     <ui-input
       :model-value="data.tryCount"
+      :title="t('workflow.blocks.element-exists.tryFor.title')"
+      :label="t('workflow.blocks.element-exists.tryFor.label')"
       class="flex-1"
       type="number"
-      title="Try check element exists"
-      label="Try for"
       min="1"
       @change="updateData({ tryCount: +$event })"
     />
     <ui-input
       :model-value="data.timeout"
-      label="Timeout(ms)"
-      title="Timeout for each try"
+      :label="t('workflow.blocks.element-exists.timeout.label')"
+      :title="t('workflow.blocks.element-exists.timeout.title')"
       class="flex-1"
       type="number"
       min="200"
@@ -27,6 +27,8 @@
   </div>
 </template>
 <script setup>
+import { useI18n } from 'vue-i18n';
+
 const props = defineProps({
   data: {
     type: Object,
@@ -35,6 +37,8 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
+const { t } = useI18n();
+
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });
 }

+ 89 - 0
src/components/newtab/workflow/edit/EditExecuteWorkflow.vue

@@ -0,0 +1,89 @@
+<template>
+  <div>
+    <ui-select
+      :model-value="data.workflowId"
+      :placeholder="t('workflow.blocks.execute-workflow.select')"
+      class="w-full mb-4"
+      @change="updateData({ workflowId: $event })"
+    >
+      <option
+        v-for="workflow in workflows"
+        :key="workflow.id"
+        :value="workflow.id"
+      >
+        {{ workflow.name }}
+      </option>
+    </ui-select>
+    <p>{{ t('common.globalData') }}</p>
+    <prism-editor
+      v-if="!state.showGlobalData"
+      :model-value="data.globalData"
+      :highlight="highlighter('json')"
+      readonly
+      class="p-4 max-h-80"
+      @click="state.showGlobalData = true"
+    />
+    <ui-modal
+      v-model="state.showGlobalData"
+      title="Global data"
+      content-class="max-w-xl"
+    >
+      <p>{{ t('workflow.blocks.execute-workflow.overwriteNote') }}</p>
+      <prism-editor
+        :model-value="state.globalData"
+        :highlight="highlighter('json')"
+        class="w-full scroll"
+        style="height: calc(100vh - 10rem)"
+        @input="updateGlobalData"
+      />
+    </ui-modal>
+  </div>
+</template>
+<script setup>
+import { computed, shallowReactive } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useRoute } from 'vue-router';
+import { PrismEditor } from 'vue-prism-editor';
+import { highlighter } from '@/lib/prism';
+import Workflow from '@/models/workflow';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+  hideBase: {
+    type: Boolean,
+    default: false,
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const { t } = useI18n();
+const route = useRoute();
+
+const state = shallowReactive({
+  showGlobalData: false,
+  globalData: `${props.data.globalData}`,
+});
+
+const workflows = computed(() =>
+  Workflow.query()
+    .where(
+      ({ id, drawflow }) =>
+        id !== route.params.id && !drawflow.includes(route.params.id)
+    )
+    .orderBy('name', 'asc')
+    .get()
+);
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+function updateGlobalData(event) {
+  const { value } = event.target;
+
+  state.globalData = value;
+  updateData({ globalData: value });
+}
+</script>

+ 87 - 42
src/components/newtab/workflow/edit/EditForms.vue

@@ -1,50 +1,93 @@
 <template>
-  <edit-interaction-base v-bind="{ data }" @change="updateData">
-    <ui-select
-      :model-value="data.type"
-      class="block w-full mt-4 mb-3"
-      placeholder="Form type"
-      @change="updateData({ type: $event })"
-    >
-      <option v-for="form in forms" :key="form.id" :value="form.id">
-        {{ form.name }}
-      </option>
-    </ui-select>
+  <edit-interaction-base v-bind="{ data, hide: hideBase }" @change="updateData">
     <ui-checkbox
-      v-if="data.type === 'checkbox' || data.type === 'radio'"
-      :model-value="data.selected"
-      @change="updateData({ selected: $event })"
+      :model-value="data.getValue"
+      class="my-2"
+      @change="updateData({ getValue: $event })"
     >
-      Selected
+      {{ t('workflow.blocks.forms.getValue') }}
     </ui-checkbox>
-    <template v-if="data.type === 'text-field' || data.type === 'select'">
-      <ui-textarea
-        :model-value="data.value"
-        placeholder="Value"
-        class="w-full"
-        @change="updateData({ value: $event })"
-      />
+    <template v-if="data.getValue">
+      <ui-checkbox
+        :model-value="data.saveData"
+        class="mb-2 ml-2"
+        @change="updateData({ saveData: $event })"
+      >
+        Save data
+      </ui-checkbox>
+      <div class="flex items-center">
+        <ui-select
+          :model-value="data.dataColumn"
+          placeholder="Data column"
+          class="mr-2 flex-1"
+          @change="updateData({ dataColumn: $event })"
+        >
+          <option
+            v-for="column in workflow.data.value.dataColumns"
+            :key="column.name"
+            :value="column.name"
+          >
+            {{ column.name }}
+          </option>
+        </ui-select>
+        <ui-button
+          icon
+          title="Data columns"
+          @click="workflow.showDataColumnsModal(true)"
+        >
+          <v-remixicon name="riKey2Line" />
+        </ui-button>
+      </div>
+    </template>
+    <template v-else>
+      <ui-select
+        :model-value="data.type"
+        class="block w-full mb-3"
+        :placeholder="t('workflow.blocks.forms.type')"
+        @change="updateData({ type: $event })"
+      >
+        <option v-for="form in forms" :key="form" :value="form">
+          {{ t(`workflow.blocks.forms.${form}.name`) }}
+        </option>
+      </ui-select>
       <ui-checkbox
-        :model-value="data.clearValue"
-        class="mb-1 ml-1"
-        @change="updateData({ clearValue: $event })"
+        v-if="data.type === 'checkbox' || data.type === 'radio'"
+        :model-value="data.selected"
+        @change="updateData({ selected: $event })"
       >
-        Clear form value
+        {{ t('workflow.blocks.forms.selected') }}
       </ui-checkbox>
+      <template v-if="data.type === 'text-field' || data.type === 'select'">
+        <ui-textarea
+          :model-value="data.value"
+          :placeholder="t('workflow.blocks.forms.text-field.value')"
+          class="w-full"
+          @change="updateData({ value: $event })"
+        />
+        <ui-checkbox
+          :model-value="data.clearValue"
+          class="mb-1 ml-1"
+          @change="updateData({ clearValue: $event })"
+        >
+          {{ t('workflow.blocks.forms.text-field.clearValue') }}
+        </ui-checkbox>
+      </template>
+      <ui-input
+        v-if="data.type === 'text-field'"
+        :model-value="data.delay"
+        :label="t('workflow.blocks.forms.text-field.delay.label')"
+        :placeholder="t('workflow.blocks.forms.text-field.delay.placeholder')"
+        class="w-full"
+        min="0"
+        type="number"
+        @change="updateData({ delay: +$event })"
+      />
     </template>
-    <ui-input
-      v-if="data.type === 'text-field'"
-      :model-value="data.delay"
-      label="Typing delay (millisecond)(0 to disable)"
-      placeholder="Delay"
-      class="w-full"
-      min="0"
-      type="number"
-      @change="updateData({ delay: +$event })"
-    />
   </edit-interaction-base>
 </template>
 <script setup>
+import { inject } from 'vue';
+import { useI18n } from 'vue-i18n';
 import EditInteractionBase from './EditInteractionBase.vue';
 
 const props = defineProps({
@@ -52,15 +95,17 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
+  hideBase: {
+    type: Boolean,
+    default: false,
+  },
 });
 const emit = defineEmits(['update:data']);
 
-const forms = [
-  { id: 'text-field', name: 'Text field' },
-  { id: 'select', name: 'Select' },
-  { id: 'checkbox', name: 'Checkbox' },
-  { id: 'radio', name: 'Radio' },
-];
+const { t } = useI18n();
+const workflow = inject('workflow');
+
+const forms = ['text-field', 'select', 'checkbox', 'radio'];
 
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });

+ 17 - 0
src/components/newtab/workflow/edit/EditGetText.vue

@@ -55,10 +55,25 @@
         <v-remixicon name="riKey2Line" />
       </ui-button>
     </div>
+    <ui-input
+      :model-value="data.prefixText"
+      :title="t('workflow.blocks.get-text.prefixText.title')"
+      :placeholder="t('workflow.blocks.get-text.prefixText.placeholder')"
+      class="w-full mt-3 mb-2"
+      @change="updateData({ prefixText: $event })"
+    />
+    <ui-input
+      :model-value="data.suffixText"
+      :title="t('workflow.blocks.get-text.suffixText.title')"
+      :placeholder="t('workflow.blocks.get-text.suffixText.placeholder')"
+      class="w-full"
+      @change="updateData({ suffixText: $event })"
+    />
   </edit-interaction-base>
 </template>
 <script setup>
 import { inject, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
 import EditInteractionBase from './EditInteractionBase.vue';
 
 const props = defineProps({
@@ -69,6 +84,8 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
+const { t } = useI18n();
+
 const workflow = inject('workflow');
 const regexExp = ref(props.data.regexExp);
 

+ 60 - 29
src/components/newtab/workflow/edit/EditInteractionBase.vue

@@ -1,47 +1,68 @@
 <template>
   <div>
     <slot name="prepend" />
-    <ui-textarea
-      :model-value="data.description"
-      autoresize
-      placeholder="Description"
-      class="w-full mb-2"
-      @change="updateData({ description: $event })"
-    />
-    <ui-input
-      v-if="!hideSelector"
-      :model-value="data.selector"
-      placeholder="Element selector"
-      class="mb-1 w-full"
-      @change="updateData({ selector: $event })"
-    />
-    <template v-if="!hideSelector">
-      <ui-checkbox
-        v-if="!data.disableMultiple && !hideMultiple"
-        class="mr-6"
-        title="Select multiple elements"
-        :model-value="data.multiple"
-        @change="updateData({ multiple: $event })"
+    <template v-if="!hide">
+      <ui-textarea
+        :model-value="data.description"
+        :placeholder="t('common.description')"
+        autoresize
+        class="w-full mb-2"
+        @change="updateData({ description: $event })"
+      />
+      <ui-select
+        :model-value="data.findBy || 'cssSelector'"
+        :placeholder="t('workflow.blocks.base.findElement.placeholder')"
+        class="w-full mb-2"
+        @change="updateData({ findBy: $event })"
       >
-        Multiple
-      </ui-checkbox>
-      <ui-checkbox
-        :model-value="data.markEl"
-        title="An element will not be selected if have been selected before"
-        @change="updateData({ markEl: $event })"
+        <option v-for="type in selectorTypes" :key="type" :value="type">
+          {{ t(`workflow.blocks.base.findElement.options.${type}`) }}
+        </option>
+      </ui-select>
+      <ui-input
+        v-if="!hideSelector"
+        :model-value="data.selector"
+        :placeholder="t('workflow.blocks.base.selector')"
+        class="mb-1 w-full"
+        @change="updateData({ selector: $event })"
+      />
+      <template
+        v-if="!hideSelector && (data.findBy || 'cssSelector') === 'cssSelector'"
       >
-        Mark element
-      </ui-checkbox>
+        <ui-checkbox
+          v-if="!data.disableMultiple && !hideMultiple"
+          :title="t('workflow.blocks.base.multiple.title')"
+          :model-value="data.multiple"
+          class="mr-6"
+          @change="updateData({ multiple: $event })"
+        >
+          {{ t('workflow.blocks.base.multiple.text') }}
+        </ui-checkbox>
+        <ui-checkbox
+          :model-value="data.markEl"
+          :title="t('workflow.blocks.base.markElement.title')"
+          @change="updateData({ markEl: $event })"
+        >
+          {{ t('workflow.blocks.base.markElement.text') }}
+        </ui-checkbox>
+      </template>
     </template>
     <slot></slot>
   </div>
 </template>
 <script setup>
+import { onMounted } from 'vue';
+import { useI18n } from 'vue-i18n';
+
 const props = defineProps({
   data: {
     type: Object,
     default: () => ({}),
   },
+  hide: {
+    type: Boolean,
+    default: false,
+  },
   hideSelector: {
     type: Boolean,
     default: false,
@@ -53,10 +74,20 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data', 'change']);
 
+const { t } = useI18n();
+
+const selectorTypes = ['cssSelector', 'xpath'];
+
 function updateData(value) {
   const payload = { ...props.data, ...value };
 
   emit('update:data', payload);
   emit('change', payload);
 }
+
+onMounted(() => {
+  if (!props.data.findBy) {
+    updateData({ findBy: 'cssSelector' });
+  }
+});
 </script>

+ 104 - 45
src/components/newtab/workflow/edit/EditJavascriptCode.vue

@@ -3,7 +3,7 @@
     <ui-textarea
       :model-value="data.description"
       autoresize
-      placeholder="Description"
+      :placeholder="t('common.description')"
       class="w-full mb-2"
       @change="updateData({ description: $event })"
     />
@@ -11,57 +11,91 @@
       type="number"
       :model-value="data.timeout"
       class="mb-2 w-full"
-      placeholder="Timeout"
-      title="Javascript code execution timeout"
+      :placeholder="t('workflow.blocks.javascript-code.timeout.placeholder')"
+      :title="t('workflow.blocks.javascript-code.timeout.title')"
       @change="updateData({ timeout: +$event })"
     />
     <prism-editor
-      v-if="!showCodeModal"
+      v-if="!state.showCodeModal"
       :model-value="data.code"
       :highlight="highlighter('javascript')"
       readonly
       class="p-4 max-h-80"
-      @click="showCodeModal = true"
+      @click="state.showCodeModal = true"
     />
-    <ui-modal
-      v-model="showCodeModal"
-      title="Javascript code"
-      content-class="max-w-3xl"
-    >
-      <prism-editor
-        v-model="code"
-        class="py-4"
-        :highlight="highlighter('javascript')"
-        line-numbers
-        style="height: calc(100vh - 18rem)"
-      />
-      <div>
-        Note:
-        <ul class="list-disc pl-5">
-          <li>
-            To execute the next block, you can call the
-            <code>automaNextBlock</code> function. This function accepts one
-            parameter, which you can use to save data to the workflow. Data
-            format:
-            <ul class="list-disc space-y-2 mt-2 text-sm pl-5">
-              <li><code>{ key: value }</code></li>
-              <li>
-                <code>[{ key: value }, { key: value }]</code>
-              </li>
-            </ul>
-            You must use the column that you added as a key.
-          </li>
-          <li>
-            To reset the execution timeout of the code, you can call the
-            <code>automaResetTimeout</code> function.
-          </li>
-        </ul>
-      </div>
+    <ui-modal v-model="state.showCodeModal" content-class="max-w-3xl">
+      <template #header>
+        <ui-tabs v-model="state.activeTab" class="border-none">
+          <ui-tab value="code">
+            {{ t('workflow.blocks.javascript-code.modal.tabs.code') }}
+          </ui-tab>
+          <ui-tab value="preloadScript">
+            {{ t('workflow.blocks.javascript-code.modal.tabs.preloadScript') }}
+          </ui-tab>
+        </ui-tabs>
+      </template>
+      <ui-tab-panels
+        v-model="state.activeTab"
+        class="overflow-auto"
+        style="height: calc(100vh - 9rem)"
+      >
+        <ui-tab-panel value="code" class="h-full">
+          <prism-editor
+            v-model="state.code"
+            :highlight="highlighter('javascript')"
+            line-numbers
+            class="py-4 overflow-auto"
+            style="height: 87%"
+          />
+          <p class="mt-1">
+            {{ t('workflow.blocks.javascript-code.availabeFuncs') }}
+          </p>
+          <p class="space-x-1">
+            <a
+              v-for="func in availableFuncs"
+              :key="func.id"
+              :href="`https://github.com/Kholid060/automa/wiki/Blocks#${func.id}`"
+              target="_blank"
+              rel="noopener"
+              class="inline-block"
+            >
+              <code>
+                {{ func.name }}
+              </code>
+            </a>
+          </p>
+        </ui-tab-panel>
+        <ui-tab-panel value="preloadScript">
+          <div
+            v-for="(script, index) in state.preloadScripts"
+            :key="index"
+            class="flex items-center mt-4"
+          >
+            <v-remixicon
+              name="riDeleteBin7Line"
+              class="mr-2 cursor-pointer"
+              @click="state.preloadScripts.splice(index, 1)"
+            />
+            <ui-input
+              v-model="state.preloadScripts[index].src"
+              placeholder="http://example.com/script.js"
+              class="flex-1 mr-4"
+            />
+            <ui-checkbox v-model="state.preloadScripts[index].removeAfterExec">
+              {{ t('workflow.blocks.javascript-code.removeAfterExec') }}
+            </ui-checkbox>
+          </div>
+          <ui-button variant="accent" class="w-20 mt-4" @click="addScript">
+            {{ t('common.add') }}
+          </ui-button>
+        </ui-tab-panel>
+      </ui-tab-panels>
     </ui-modal>
   </div>
 </template>
 <script setup>
-import { ref, watch } from 'vue';
+import { watch, reactive } from 'vue';
+import { useI18n } from 'vue-i18n';
 import { PrismEditor } from 'vue-prism-editor';
 import { highlighter } from '@/lib/prism';
 
@@ -73,16 +107,41 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
-const code = ref(props.data.code);
-const showCodeModal = ref(false);
+const { t } = useI18n();
+
+const availableFuncs = [
+  { name: 'automaNextBlock(data)', id: 'automanextblockdata' },
+  { name: 'automaRefData(keyword, path)', id: 'automarefdatakeyword-path' },
+  { name: 'automaResetTimeout', id: 'automaresettimeout' },
+];
+
+const state = reactive({
+  activeTab: 'code',
+  code: `${props.data.code}`,
+  preloadScripts: [...(props.data.preloadScripts || [])],
+  showCodeModal: false,
+});
 
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });
 }
+function addScript() {
+  state.preloadScripts.push({ src: '', removeAfterExec: true });
+}
 
-watch(code, (value) => {
-  updateData({ code: value });
-});
+watch(
+  () => state.code,
+  (value) => {
+    updateData({ code: value });
+  }
+);
+watch(
+  () => state.preloadScripts,
+  (value) => {
+    updateData({ preloadScripts: value });
+  },
+  { deep: true }
+);
 </script>
 <style scoped>
 code {

+ 50 - 20
src/components/newtab/workflow/edit/EditLoopData.vue

@@ -2,20 +2,20 @@
   <div>
     <ui-textarea
       :model-value="data.description"
-      placeholder="Description"
+      :placeholder="t('common.description')"
       class="w-full"
       @change="updateData({ description: $event })"
     />
     <ui-input
       :model-value="data.loopId"
       class="w-full mb-3"
-      label="Loop ID"
-      placeholder="Loop ID"
+      :label="t('workflow.blocks.loop-data.loopId')"
+      :placeholder="t('workflow.blocks.loop-data.loopId')"
       @change="updateLoopID"
     />
     <ui-select
       :model-value="data.loopThrough"
-      placeholder="Loop through"
+      :placeholder="t('workflow.blocks.loop-data.loopThrough.placeholder')"
       class="w-full mb-2"
       @change="
         updateData({
@@ -24,17 +24,18 @@
         })
       "
     >
-      <option v-for="type in loopTypes" :key="type.id" :value="type.id">
-        {{ type.name }}
+      <option v-for="type in loopTypes" :key="type" :value="type">
+        {{ t(`workflow.blocks.loop-data.loopThrough.options.${type}`) }}
       </option>
     </ui-select>
     <ui-input
+      v-if="data.loopThrough !== 'numbers'"
       :model-value="data.maxLoop"
+      :label="t('workflow.blocks.loop-data.maxLoop.label')"
+      :title="t('workflow.blocks.loop-data.maxLoop.title')"
       class="w-full mb-4"
       min="0"
       type="number"
-      label="Max data to loop (0 to disable)"
-      title="Max numbers of data to loop"
       @change="updateData({ maxLoop: +$event || 0 })"
     />
     <ui-button
@@ -43,8 +44,34 @@
       variant="accent"
       @click="state.showDataModal = true"
     >
-      Insert data
+      {{ t('workflow.blocks.loop-data.buttons.insert') }}
     </ui-button>
+    <div
+      v-else-if="data.loopThrough === 'numbers'"
+      class="flex items-center space-x-2"
+    >
+      <ui-input
+        :model-value="data.fromNumber"
+        :label="t('workflow.blocks.loop-data.loopThrough.fromNumber')"
+        type="number"
+        @change="
+          updateData({
+            fromNumber: +$event >= data.toNumber ? data.toNumber - 1 : +$event,
+          })
+        "
+      />
+      <ui-input
+        :model-value="data.toNumber"
+        :label="t('workflow.blocks.loop-data.loopThrough.toNumber')"
+        type="number"
+        @change="
+          updateData({
+            toNumber:
+              +$event <= data.fromNumber ? data.fromNumber + 1 : +$event,
+          })
+        "
+      />
+    </div>
     <ui-modal
       v-model="state.showDataModal"
       title="Data"
@@ -52,10 +79,10 @@
     >
       <div class="flex mb-4 items-center">
         <ui-button variant="accent" @click="importFile">
-          Import file
+          {{ t('workflow.blocks.loop-data.buttons.import') }}
         </ui-button>
         <ui-button
-          v-tooltip="'Options'"
+          v-tooltip="t('commons.options')"
           :class="{ 'text-primary': state.showOptions }"
           icon
           class="ml-2"
@@ -65,12 +92,14 @@
         </ui-button>
         <p class="flex-1 text-overflow mx-4">{{ file.name }}</p>
         <template v-if="data.loopData.length > maxStrLength">
-          <p class="mr-2">File too large to edit</p>
+          <p class="mr-2">
+            {{ t('workflow.blocks.loop-data.modal.fileTooLarge') }}
+          </p>
           <ui-button @click="updateData({ loopData: '[]' })">
-            Clear data
+            {{ t('workflow.blocks.loop-data.buttons.clear') }}
           </ui-button>
         </template>
-        <p v-else>Max file size is 1MB</p>
+        <p v-else>{{ t('workflow.blocks.loop-data.modal.maxFile') }}</p>
       </div>
       <div style="height: calc(100vh - 11rem)">
         <prism-editor
@@ -84,7 +113,7 @@
         <div v-show="state.showOptions">
           <p class="font-semibold mb-2">CSV</p>
           <ui-checkbox v-model="options.header">
-            Use the first row as keys
+            {{ t('workflow.blocks.loop-data.modal.options.firstRow') }}
           </ui-checkbox>
         </div>
       </div>
@@ -92,9 +121,11 @@
   </div>
 </template>
 <script setup>
+/* eslint-disable no-alert */
 import { onMounted, shallowReactive } from 'vue';
 import { nanoid } from 'nanoid';
 import { PrismEditor } from 'vue-prism-editor';
+import { useI18n } from 'vue-i18n';
 import Papa from 'papaparse';
 import { highlighter } from '@/lib/prism';
 import { openFilePicker } from '@/utils/helper';
@@ -111,12 +142,11 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
+const { t } = useI18n();
+
 const maxStrLength = 5e4;
 const maxFileSize = 1024 * 1024;
-const loopTypes = [
-  { id: 'data-columns', name: 'Data columns' },
-  { id: 'custom-data', name: 'Custom data' },
-];
+const loopTypes = ['data-columns', 'numbers', 'custom-data'];
 const tempLoopData =
   props.data.loopData.length > maxStrLength
     ? props.data.loopData.slice(0, maxStrLength)
@@ -153,7 +183,7 @@ function importFile() {
   openFilePicker(['application/json', 'text/csv', 'application/vnd.ms-excel'])
     .then(async (fileObj) => {
       if (fileObj.size > maxFileSize) {
-        alert('The file size is the exceeded maximum allowed');
+        alert(t('message.maxSizeExceeded'));
         return;
       }
 

+ 10 - 6
src/components/newtab/workflow/edit/EditNewTab.vue

@@ -2,8 +2,8 @@
   <div class="mb-2 mt-4 space-y-2">
     <ui-textarea
       :model-value="data.description"
+      :placeholder="t('common.description')"
       class="w-full"
-      placeholder="Description"
       @change="updateData({ description: $event })"
     />
     <ui-input
@@ -21,31 +21,33 @@
       target="_blank"
       style="margin-top: 0"
     >
-      Learn how to add dynamic data
+      {{ t('message.useDynamicData') }}
     </a>
     <ui-checkbox
       :model-value="data.updatePrevTab"
       class="leading-tight"
-      title="Use the previously opened new tab instead of creating a new one"
+      :title="t('workflow.blocks.new-tab.updatePrevTab.title')"
       @change="updateData({ updatePrevTab: $event })"
     >
-      Update previously opened tab
+      {{ t('workflow.blocks.new-tab.updatePrevTab.text') }}
     </ui-checkbox>
     <ui-checkbox
       :model-value="data.active"
       @change="updateData({ active: $event })"
     >
-      Set as active tab
+      {{ t('workflow.blocks.new-tab.activeTab') }}
     </ui-checkbox>
     <ui-checkbox
       :model-value="data.inGroup"
       @change="updateData({ inGroup: $event })"
     >
-      Add tab to group
+      {{ t('workflow.blocks.new-tab.tabToGroup') }}
     </ui-checkbox>
   </div>
 </template>
 <script setup>
+import { useI18n } from 'vue-i18n';
+
 const props = defineProps({
   data: {
     type: Object,
@@ -54,6 +56,8 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
+const { t } = useI18n();
+
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });
 }

+ 8 - 7
src/components/newtab/workflow/edit/EditNewWindow.vue

@@ -3,17 +3,17 @@
     <ui-textarea
       :model-value="data.description"
       class="w-full"
-      placeholder="Description"
+      :placeholder="t('common.description')"
       @change="updateData({ description: $event })"
     />
     <ui-select
       :model-value="data.windowState"
       class="w-full"
-      placeholder="Window state"
+      :placeholder="t('workflow.blocks.new-window.windowState.placeholder')"
       @change="updateData({ windowState: $event })"
     >
       <option v-for="state in windowStates" :key="state" :value="state">
-        {{ state }}
+        {{ t(`workflow.blocks.new-window.windowState.options.${state}`) }}
       </option>
     </ui-select>
     <ui-checkbox
@@ -21,10 +21,8 @@
       :disabled="!allowInIncognito"
       @change="updateData({ incognito: $event })"
     >
-      Set as incognito window
-      <span
-        title="You must enable 'Allow in incognito' for this extension to use the option"
-      >
+      {{ t('workflow.blocks.new-window.incognito.text') }}
+      <span :title="t('workflow.blocks.new-window.incognito.note')">
         &#128712;
       </span>
     </ui-checkbox>
@@ -32,6 +30,7 @@
 </template>
 <script setup>
 import { ref, onMounted } from 'vue';
+import { useI18n } from 'vue-i18n';
 import browser from 'webextension-polyfill';
 
 const props = defineProps({
@@ -42,6 +41,8 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
+const { t } = useI18n();
+
 const windowStates = ['normal', 'minimized', 'maximized', 'fullscreen'];
 const allowInIncognito = ref(false);
 

+ 77 - 0
src/components/newtab/workflow/edit/EditProxy.vue

@@ -0,0 +1,77 @@
+<template>
+  <div>
+    <div class="flex items-center mb-1">
+      <ui-select
+        :model-value="data.scheme"
+        label="Scheme"
+        class="mr-2"
+        @change="updateData({ scheme: $event })"
+      >
+        <option v-for="scheme in schemes" :key="scheme" :value="scheme">
+          {{ scheme.toUpperCase() }}
+        </option>
+      </ui-select>
+      <ui-input
+        :model-value="data.port"
+        label="Port"
+        placeholder="443"
+        class="flex-1"
+        type="number"
+        @change="updateData({ port: +$event })"
+      />
+    </div>
+    <ui-input
+      :model-value="data.host"
+      label="Host"
+      placeholder="1.2.3.4"
+      class="w-full mb-2"
+      @change="updateData({ host: $event })"
+    />
+    <ui-input
+      :model-value="data.bypassList"
+      placeholder="example1.com, example2.org"
+      class="w-full"
+      @change="updateData({ bypassList: $event })"
+    >
+      <template #label>
+        {{ t('workflow.blocks.proxy.bypass.label') }}
+        <a
+          href="https://developer.chrome.com/docs/extensions/reference/proxy/#bypass-list"
+          target="_blank"
+          rel="noopener"
+        >
+          &#128712;
+        </a>
+      </template>
+    </ui-input>
+    <p class="text-gray-600 dark:text-gray-200 text-sm">
+      {{ t('workflow.blocks.proxy.bypass.note') }}
+    </p>
+    <ui-checkbox
+      :model-value="data.clearProxy"
+      class="mt-4"
+      @change="updateData({ clearProxy: $event })"
+    >
+      {{ t('workflow.blocks.proxy.clear') }}
+    </ui-checkbox>
+  </div>
+</template>
+<script setup>
+import { useI18n } from 'vue-i18n';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const { t } = useI18n();
+
+const schemes = Object.values(chrome.proxy.Scheme);
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+</script>

+ 14 - 7
src/components/newtab/workflow/edit/EditScrollElement.vue

@@ -1,16 +1,16 @@
 <template>
-  <edit-interaction-base v-bind="{ data }" @change="updateData">
+  <edit-interaction-base v-bind="{ data, hide: hideBase }" @change="updateData">
     <div v-if="!data.scrollIntoView" class="flex items-center mt-3 space-x-2">
       <ui-input
         :model-value="data.scrollX || 0"
+        :label="t('workflow.blocks.element-scroll.scrollX')"
         type="number"
-        label="Scroll horizontal"
         @change="updateData({ scrollX: +$event })"
       />
       <ui-input
         :model-value="data.scrollY || 0"
+        :label="t('workflow.blocks.element-scroll.scrollY')"
         type="number"
-        label="Scroll vertical"
         @change="updateData({ scrollY: +$event })"
       />
     </div>
@@ -20,32 +20,33 @@
         :model-value="data.scrollIntoView"
         @change="updateData({ scrollIntoView: $event })"
       >
-        Scroll into view
+        {{ t('workflow.blocks.element-scroll.intoView') }}
       </ui-checkbox>
       <ui-checkbox
         :model-value="data.smooth"
         @change="updateData({ smooth: $event })"
       >
-        Smooth scroll
+        {{ t('workflow.blocks.element-scroll.smooth') }}
       </ui-checkbox>
       <template v-if="!data.scrollIntoView">
         <ui-checkbox
           :model-value="data.incX"
           @change="updateData({ incX: $event })"
         >
-          Increment horizontal scroll
+          {{ t('workflow.blocks.element-scroll.incScrollX') }}
         </ui-checkbox>
         <ui-checkbox
           :model-value="data.incY"
           @change="updateData({ incY: $event })"
         >
-          Increment vertical scroll
+          {{ t('workflow.blocks.element-scroll.incScrollY') }}
         </ui-checkbox>
       </template>
     </div>
   </edit-interaction-base>
 </template>
 <script setup>
+import { useI18n } from 'vue-i18n';
 import EditInteractionBase from './EditInteractionBase.vue';
 
 const props = defineProps({
@@ -53,9 +54,15 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
+  hideBase: {
+    type: Boolean,
+    default: false,
+  },
 });
 const emit = defineEmits(['update:data']);
 
+const { t } = useI18n();
+
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });
 }

+ 12 - 4
src/components/newtab/workflow/edit/EditSwitchTo.vue

@@ -2,8 +2,8 @@
   <div class="space-y-2">
     <ui-textarea
       :model-value="data.description"
+      :placeholder="t('common.description')"
       autoresize
-      placeholder="Description"
       class="w-full"
       @change="updateData({ description: $event })"
     />
@@ -12,19 +12,25 @@
       class="w-full"
       @change="updateData({ windowType: $event })"
     >
-      <option value="main-window">Main window</option>
-      <option value="iframe">Iframe</option>
+      <option value="main-window">
+        {{ t('workflow.blocks.switch-to.windowTypes.main') }}
+      </option>
+      <option value="iframe">
+        {{ t('workflow.blocks.switch-to.windowTypes.iframe') }}
+      </option>
     </ui-select>
     <ui-input
       v-if="data.windowType === 'iframe'"
       :model-value="data.selector"
-      placeholder="Iframe element selector"
+      :placeholder="t('workflow.blocks.switch-to.iframeSelector')"
       class="mb-1 w-full"
       @change="updateData({ selector: $event })"
     />
   </div>
 </template>
 <script setup>
+import { useI18n } from 'vue-i18n';
+
 const props = defineProps({
   data: {
     type: Object,
@@ -33,6 +39,8 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
+const { t } = useI18n();
+
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });
 }

+ 6 - 2
src/components/newtab/workflow/edit/EditTakeScreenshot.vue

@@ -2,7 +2,7 @@
   <div class="flex items-center mb-2 mt-8">
     <ui-input
       :model-value="data.fileName"
-      placeholder="File name"
+      :placeholder="t('common.fileName')"
       class="flex-1 mr-2"
       title="File name"
       @change="updateData({ fileName: $event })"
@@ -20,7 +20,7 @@
   <div class="bg-box-transparent px-4 mb-4 py-2 rounded-lg flex items-center">
     <input
       :value="data.quality"
-      title="Image quality"
+      :title="t('workflow.blocks.loop.take-screenshot.imageQuality')"
       class="focus:outline-none flex-1"
       type="range"
       min="0"
@@ -38,6 +38,8 @@
   </ui-checkbox>
 </template>
 <script setup>
+import { useI18n } from 'vue-i18n';
+
 const props = defineProps({
   data: {
     type: Object,
@@ -46,6 +48,8 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
+const { t } = useI18n();
+
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });
 }

+ 27 - 24
src/components/newtab/workflow/edit/EditTrigger.vue

@@ -3,27 +3,27 @@
     <ui-textarea
       :model-value="data.description"
       autoresize
-      placeholder="Description"
+      :placeholder="t('common.description')"
       class="w-full mb-2"
       @change="updateData({ description: $event })"
     />
     <ui-select
       :model-value="data.type || 'manual'"
-      placeholder="Trigger workflow"
+      :placeholder="t('workflow.blocks.trigger.forms.triggerWorkflow')"
       class="w-full"
       @change="handleSelectChange"
     >
-      <option v-for="trigger in triggers" :key="trigger.id" :value="trigger.id">
-        {{ trigger.name }}
+      <option v-for="trigger in triggers" :key="trigger" :value="trigger">
+        {{ t(`workflow.blocks.trigger.items.${trigger}`) }}
       </option>
     </ui-select>
     <transition-expand mode="out-in">
       <div v-if="data.type === 'interval'" class="flex items-center mt-1">
         <ui-input
           :model-value="data.interval"
+          :label="t('workflow.blocks.trigger.forms.interval')"
           type="number"
           class="w-full mr-2"
-          label="Interval (minutes)"
           placeholder="1-120"
           min="1"
           max="120"
@@ -35,7 +35,7 @@
           :model-value="data.delay"
           type="number"
           class="w-full"
-          label="Delay (minutes)"
+          :label="t('workflow.blocks.trigger.forms.delay')"
           min="0"
           max="20"
           placeholder="0-20"
@@ -49,16 +49,16 @@
           :model-value="data.date"
           :max="maxDate"
           :min="minDate"
+          :placeholder="t('workflow.blocks.trigger.forms.date')"
           class="w-full"
           type="date"
-          placeholder="Date"
           @change="updateDate({ date: $event })"
         />
         <ui-input
           :model-value="data.time"
+          :placeholder="t('workflow.blocks.trigger.forms.time')"
           type="time"
           class="w-full mt-2"
-          placeholder="Time"
           @change="updateData({ time: $event || '00:00' })"
         />
       </div>
@@ -74,17 +74,17 @@
           <ui-checkbox
             v-for="day in days"
             :key="day.id"
-            :model-value="data.days.includes(day.id)"
+            :model-value="data.days?.includes(day.id)"
             @change="onDayChange($event, day.id)"
           >
-            {{ day.name }}
+            {{ t(`workflow.blocks.trigger.days.${day.id}`) }}
           </ui-checkbox>
         </div>
       </div>
       <div v-else-if="data.type === 'visit-web'" class="mt-2">
         <ui-input
           :model-value="data.url"
-          placeholder="URL or Regex"
+          :placeholder="t('workflow.blocks.trigger.forms.url')"
           class="w-full"
           @change="updateData({ url: $event })"
         />
@@ -93,7 +93,7 @@
           class="mt-1"
           @change="updateData({ isUrlRegex: $event })"
         >
-          Use regex
+          {{ t('workflow.blocks.trigger.useRegex') }}
         </ui-checkbox>
       </div>
       <div v-else-if="data.type === 'keyboard-shortcut'" class="mt-2">
@@ -102,10 +102,10 @@
             :model-value="recordKeys.keys"
             readonly
             class="flex-1 mr-2"
-            placeholder="Shortcut"
+            :placeholder="t('workflow.blocks.trigger.forms.shortcut')"
           />
           <ui-button
-            v-tooltip="'Record shortcut'"
+            v-tooltip="t('workflow.blocks.trigger.shortcut.tooltip')"
             icon
             @click="toggleRecordKeys"
           >
@@ -119,13 +119,13 @@
         <ui-checkbox
           :model-value="data.activeInInput"
           class="mb-1"
-          title="Execute shortcut even when you're in an input element"
+          :title="t('workflow.blocks.trigger.shortcut.checkboxTitle')"
           @change="updateData({ activeInInput: $event })"
         >
-          Active while in input
+          {{ t('workflow.blocks.trigger.shortcut.checkbox') }}
         </ui-checkbox>
         <p class="mt-4 leading-tight text-gray-600 dark:text-gray-200">
-          Note: keyboard shortcut only working when you're on a webpage
+          {{ t('workflow.blocks.trigger.shortcut.note') }}
         </p>
       </div>
     </transition-expand>
@@ -133,6 +133,7 @@
 </template>
 <script setup>
 import { shallowReactive, onUnmounted } from 'vue';
+import { useI18n } from 'vue-i18n';
 import dayjs from 'dayjs';
 
 const props = defineProps({
@@ -143,13 +144,15 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
+const { t } = useI18n();
+
 const triggers = [
-  { id: 'manual', name: 'Manually' },
-  { id: 'interval', name: 'Interval' },
-  { id: 'date', name: 'On specific date' },
-  { id: 'specific-day', name: 'On specific day' },
-  { id: 'visit-web', name: 'When visit a website' },
-  { id: 'keyboard-shortcut', name: 'Keyboard shortcut' },
+  'manual',
+  'interval',
+  'date',
+  'specific-day',
+  'visit-web',
+  'keyboard-shortcut',
 ];
 const days = [
   { id: 0, name: 'Sunday' },
@@ -183,7 +186,7 @@ function updateData(value) {
   emit('update:data', { ...props.data, ...value });
 }
 function onDayChange(value, id) {
-  const dataDays = [...props.data.days];
+  const dataDays = [...(props.data?.days || [])];
 
   if (value) dataDays.push(id);
   else dataDays.splice(dataDays.indexOf(id), 1);

+ 23 - 32
src/components/newtab/workflow/edit/EditTriggerEvent.vue

@@ -1,9 +1,9 @@
 <template>
-  <edit-interaction-base v-bind="{ data }" @change="updateData">
+  <edit-interaction-base v-bind="{ data, hide: hideBase }" @change="updateData">
     <ui-select
       :model-value="data.eventName"
+      :placeholder="t('workflow.blocks.trigger-event.selectEvent')"
       class="w-full mt-2"
-      placeholder="Select an event"
       @change="handleSelectChange"
     >
       <option v-for="event in eventList" :key="event.id" :value="event.id">
@@ -19,7 +19,7 @@
         class="mr-1 transition-transform -ml-1"
         :rotate="showOptions ? 270 : 180"
       />
-      <span class="flex-1">Options</span>
+      <span class="flex-1">{{ t('common.options') }}</span>
       <a
         :href="getEventDetailsUrl()"
         rel="noopener"
@@ -46,8 +46,8 @@
           </ui-checkbox>
         </div>
         <component
-          :is="componentName"
-          v-if="componentName"
+          :is="eventComponents[data.eventType]"
+          v-if="eventComponents[data.eventType]"
           :params="params"
           @update="updateParams({ ...params, ...$event })"
         />
@@ -55,49 +55,42 @@
     </transition-expand>
   </edit-interaction-base>
 </template>
-<script>
-import TriggerEventMouse from './TriggerEventMouse.vue';
-import TriggerEventTouch from './TriggerEventTouch.vue';
-import TriggerEventWheel from './TriggerEventWheel.vue';
-import TriggerEventInput from './TriggerEventInput.vue';
-import TriggerEventKeyboard from './TriggerEventKeyboard.vue';
-
-export default {
-  components: {
-    TriggerEventMouse,
-    TriggerEventWheel,
-    TriggerEventTouch,
-    TriggerEventInput,
-    TriggerEventKeyboard,
-  },
-};
-</script>
 <script setup>
-/* eslint-disable */
 import { ref } from 'vue';
+import { useI18n } from 'vue-i18n';
 import { eventList } from '@/utils/shared';
 import { toCamelCase } from '@/utils/helper';
 import EditInteractionBase from './EditInteractionBase.vue';
+import TriggerEventMouse from './TriggerEventMouse.vue';
+import TriggerEventTouch from './TriggerEventTouch.vue';
+import TriggerEventWheel from './TriggerEventWheel.vue';
+import TriggerEventInput from './TriggerEventInput.vue';
+import TriggerEventKeyboard from './TriggerEventKeyboard.vue';
 
 const props = defineProps({
   data: {
     type: Object,
     default: () => ({}),
   },
+  hideBase: {
+    type: Boolean,
+    default: false,
+  },
 });
 const emit = defineEmits(['update:data']);
 
+const { t } = useI18n();
+
 const eventComponents = {
-  'mouse-event': 'TriggerEventMouse',
+  'mouse-event': TriggerEventMouse,
   'focus-event': '',
-  'event': '',
-  'touch-event': 'TriggerEventTouch',
-  'keyboard-event': 'TriggerEventKeyboard',
-  'wheel-event': 'TriggerEventWheel',
-  'input-event': 'TriggerEventInput',
+  event: '',
+  'touch-event': TriggerEventTouch,
+  'keyboard-event': TriggerEventKeyboard,
+  'wheel-event': TriggerEventWheel,
+  'input-event': TriggerEventInput,
 };
 
-const componentName = ref(eventComponents[props.data.eventType]);
 const params = ref(props.data.eventParams);
 const showOptions = ref(false);
 
@@ -117,8 +110,6 @@ function handleSelectChange(value) {
   const eventType = eventList.find(({ id }) => id === value).type;
   const payload = { eventName: value, eventType };
 
-  componentName.value = eventComponents[eventType];
-
   if (eventType !== props.eventType) {
     const defaultParams = { bubbles: true, cancelable: true };
 

+ 15 - 10
src/components/newtab/workflow/edit/EditWebhook.vue

@@ -2,7 +2,7 @@
   <div class="mb-2 mt-4">
     <ui-textarea
       :model-value="data.description"
-      placeholder="Description"
+      :placeholder="t('common.description')"
       class="w-full mb-2"
       @change="updateData({ description: $event })"
     />
@@ -11,13 +11,13 @@
       class="mb-2 w-full"
       placeholder="https://example.com/postreceive"
       required
-      title="The Post receive URL"
+      :title="t('workflow.blocks.webhook.url')"
       type="url"
       @change="updateData({ url: $event })"
     />
     <ui-select
       :model-value="data.contentType"
-      placeholder="Select a content type"
+      :placeholder="t('workflow.blocks.webhook.contentType')"
       class="mb-2 w-full"
       @change="updateData({ contentType: $event })"
     >
@@ -31,15 +31,17 @@
     </ui-select>
     <ui-input
       :model-value="data.timeout"
+      :placeholder="t('workflow.blocks.webhook.timeout.placeholder')"
+      :title="t('workflow.blocks.webhook.timeout.title')"
       class="mb-2 w-full"
-      placeholder="Timeout"
-      title="Http request execution timeout(ms)"
       type="number"
       @change="updateData({ timeout: +$event })"
     />
     <ui-tabs v-model="activeTab" fill class="mb-4">
-      <ui-tab value="headers">Headers</ui-tab>
-      <ui-tab value="body">Content body</ui-tab>
+      <ui-tab value="headers">{{
+        t('workflow.blocks.webhook.tabs.headers')
+      }}</ui-tab>
+      <ui-tab value="body">{{ t('workflow.blocks.webhook.tabs.body') }}</ui-tab>
     </ui-tabs>
     <ui-tab-panels :model-value="activeTab">
       <ui-tab-panel
@@ -68,7 +70,7 @@
           variant="accent"
           @click="addHeader"
         >
-          <span> Add Header </span>
+          <span> {{ t('workflow.blocks.webhook.buttons.header') }} </span>
         </ui-button>
       </ui-tab-panel>
       <ui-tab-panel value="body">
@@ -85,7 +87,7 @@
     <ui-modal
       v-model="showContentModalRef"
       content-class="max-w-3xl"
-      title="Content Body"
+      :title="t('workflow.blocks.webhook.tabs.body')"
     >
       <prism-editor
         v-model="contentRef"
@@ -101,7 +103,7 @@
           class="border-b text-primary"
           target="_blank"
         >
-          Click here to learn how to add dynamic data
+          {{ t('message.useDynamicData') }}
         </a>
       </div>
     </ui-modal>
@@ -109,6 +111,7 @@
 </template>
 <script setup>
 import { ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
 import { PrismEditor } from 'vue-prism-editor';
 import { highlighter } from '@/lib/prism';
 import { contentTypes } from '@/utils/shared';
@@ -121,6 +124,8 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
+const { t } = useI18n();
+
 const activeTab = ref('headers');
 const contentRef = ref(props.data.body);
 const headerRef = ref(props.data.headers);

+ 15 - 2
src/components/popup/home/HomeWorkflowCard.vue

@@ -11,7 +11,8 @@
         {{ dayjs(workflow.createdAt).fromNow() }}
       </p>
     </div>
-    <button title="Execute" @click="$emit('execute', workflow)">
+    <p v-if="workflow.isDisabled">Disabled</p>
+    <button v-else title="Execute" @click="$emit('execute', workflow)">
       <v-remixicon name="riPlayLine" />
     </button>
     <ui-popover class="h-6">
@@ -21,6 +22,15 @@
         </button>
       </template>
       <ui-list class="w-40 space-y-1">
+        <ui-list-item
+          class="capitalize cursor-pointer"
+          @click="$emit('update', { isDisabled: !workflow.isDisabled })"
+        >
+          <v-remixicon name="riToggleLine" class="mr-2 -ml-1" />
+          <span>{{
+            t(`common.${workflow.isDisabled ? 'enable' : 'disable'}`)
+          }}</span>
+        </ui-list-item>
         <ui-list-item
           v-for="item in menu"
           :key="item.name"
@@ -36,6 +46,7 @@
   </ui-card>
 </template>
 <script setup>
+import { useI18n } from 'vue-i18n';
 import dayjs from '@/lib/dayjs';
 
 defineProps({
@@ -44,7 +55,9 @@ defineProps({
     default: () => ({}),
   },
 });
-defineEmits(['execute', 'rename', 'details', 'delete']);
+defineEmits(['execute', 'rename', 'details', 'delete', 'update']);
+
+const { t } = useI18n();
 
 const menu = [
   { name: 'rename', icon: 'riPencilLine' },

+ 0 - 0
src/components/ui/uiCheckbox.vue → src/components/ui/UiCheckbox.vue


+ 16 - 14
src/components/ui/UiDialog.vue

@@ -5,7 +5,7 @@
     @close="state.show = false"
   >
     <template #header>
-      <h3 class="font-semibold text-lg">{{ state.options.title }}</h3>
+      <h3 class="font-semibold">{{ state.options.title }}</h3>
     </template>
     <p class="text-gray-600 dark:text-gray-200 leading-tight">
       {{ state.options.body }}
@@ -34,23 +34,25 @@
 </template>
 <script>
 import { reactive, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
 import emitter from 'tiny-emitter/instance';
 
-const defaultOptions = {
-  html: false,
-  body: '',
-  title: '',
-  placeholder: '',
-  label: '',
-  okText: 'Confirm',
-  okVariant: 'accent',
-  cancelText: 'Cancel',
-  onConfirm: null,
-  onCancel: null,
-};
-
 export default {
   setup() {
+    const { t } = useI18n();
+
+    const defaultOptions = {
+      html: false,
+      body: '',
+      title: '',
+      placeholder: '',
+      label: '',
+      okText: t('common.confirm'),
+      okVariant: 'accent',
+      cancelText: t('common.cancel'),
+      onConfirm: null,
+      onCancel: null,
+    };
     const state = reactive({
       show: false,
       type: '',

+ 91 - 0
src/components/ui/UiImg.vue

@@ -0,0 +1,91 @@
+<template>
+  <div ref="imageContainer" class="ui-image relative">
+    <div class="flex justify-center items-center">
+      <slot v-if="state.loading" name="loading">
+        <div
+          class="absolute h-full rounded-lg bg-input-dark w-full animate-pulse"
+        ></div>
+      </slot>
+      <slot v-else-if="state.error" name="error">
+        <p class="text-lighter text-center">Failed to load image</p>
+      </slot>
+      <div
+        v-else
+        :style="{
+          backgroundImage: `url(${src})`,
+          backgroundSize: contain ? 'contain' : 'cover',
+        }"
+        v-bind="{ role: alt ? 'img' : null, 'aria-label': alt }"
+        class="h-full absolute top-0 left-0 w-full bg-no-repeat bg-center"
+      >
+        <slot></slot>
+      </div>
+    </div>
+  </div>
+</template>
+<script>
+import { ref, shallowReactive, onMounted } from 'vue';
+
+export default {
+  props: {
+    src: {
+      type: String,
+      default: '',
+    },
+    alt: {
+      type: String,
+      default: '',
+    },
+    lazy: Boolean,
+    contain: Boolean,
+  },
+  emits: ['error', 'load'],
+  setup(props, { emit }) {
+    const imageContainer = ref(null);
+    const state = shallowReactive({
+      loading: true,
+      error: false,
+    });
+
+    function handleImageLoad() {
+      state.loading = false;
+      state.error = false;
+
+      emit('load', true);
+    }
+    function handleImageError() {
+      state.loading = false;
+      state.error = true;
+
+      emit('error', true);
+    }
+    function loadImage() {
+      const image = new Image();
+
+      image.onload = () => handleImageLoad(image);
+      image.onerror = handleImageError;
+      image.src = props.src;
+    }
+
+    const observer = new IntersectionObserver((entries) => {
+      entries.forEach((entry) => {
+        if (entry.isIntersecting) {
+          const { target } = entry;
+          loadImage();
+          observer.unobserve(target);
+        }
+      });
+    });
+
+    onMounted(() => {
+      if (props.lazy) observer.observe(imageContainer.value);
+      else loadImage();
+    });
+
+    return {
+      state,
+      imageContainer,
+    };
+  },
+};
+</script>

+ 7 - 6
src/components/ui/UiPagination.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="flex items-center">
     <ui-button
-      v-tooltip="'Previous page'"
+      v-tooltip="t('components.pagination.prevPage')"
       :disabled="modelValue <= 1"
       icon
       @click="updatePage(modelValue - 1)"
@@ -11,7 +11,7 @@
     <div class="mx-4">
       <input
         ref="inputEl"
-        v-tooltip="'Current page'"
+        v-tooltip="t('components.pagination.currentPage')"
         :value="modelValue"
         :max="maxPage"
         min="0"
@@ -28,11 +28,10 @@
         @click="$event.target.select()"
         @input="updatePage(+$event.target.value, $event.target)"
       />
-      of
-      {{ maxPage }}
+      {{ t('components.pagination.of', { page: maxPage }) }}
     </div>
     <ui-button
-      v-tooltip="'Next page'"
+      v-tooltip="t('components.pagination.nextPage')"
       :disabled="modelValue >= maxPage"
       icon
       @click="updatePage(modelValue + 1)"
@@ -43,6 +42,7 @@
 </template>
 <script setup>
 import { computed, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
 
 const props = defineProps({
   modelValue: {
@@ -60,8 +60,9 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:modelValue', 'paginate']);
 
-const inputEl = ref(null);
+const { t } = useI18n();
 
+const inputEl = ref(null);
 const maxPage = computed(() => Math.round(props.records / props.perPage));
 
 function emitEvent(page) {

+ 8 - 275
src/content/blocks-handler.js

@@ -1,279 +1,12 @@
-/* eslint-disable consistent-return, no-param-reassign */
-import simulateEvent from '@/utils/simulate-event';
-import handleFormElement from '@/utils/handle-form-element';
+import { toCamelCase } from '@/utils/helper';
 
-function markElement(el, { id, data }) {
-  if (data.markEl) {
-    el.setAttribute(`block--${id}`, '');
-  }
-}
-function handleElement({ data, id }, callback, errCallback) {
-  if (!data || !data.selector) return null;
+const blocksHandler = require.context('./blocks-handler', false, /\.js$/);
+const handlers = blocksHandler.keys().reduce((acc, key) => {
+  const name = key.replace(/^\.\/handler-|\.js/g, '');
 
-  try {
-    const blockIdAttr = `block--${id}`;
-    const selector = data.markEl
-      ? `${data.selector.trim()}:not([${blockIdAttr}])`
-      : data.selector;
+  acc[toCamelCase(name)] = blocksHandler(key).default;
 
-    const element = data.multiple
-      ? document.querySelectorAll(selector)
-      : document.querySelector(selector);
+  return acc;
+}, {});
 
-    if (typeof callback === 'boolean' && callback) return element;
-
-    if (data.multiple) {
-      element.forEach((el) => {
-        markElement(el, { id, data });
-        callback(el);
-      });
-    } else if (element) {
-      markElement(element, { id, data });
-      callback(element);
-    } else if (errCallback) {
-      errCallback();
-    }
-  } catch (error) {
-    console.error(error);
-  }
-}
-
-export function switchTo(block) {
-  return new Promise((resolve) => {
-    handleElement(
-      block,
-      (element) => {
-        if (element.tagName !== 'IFRAME') {
-          resolve('');
-          return;
-        }
-
-        resolve({ url: element.src });
-      },
-      () => {
-        resolve('');
-      }
-    );
-  });
-}
-
-export function eventClick(block) {
-  return new Promise((resolve) => {
-    handleElement(block, (element) => {
-      element.click();
-    });
-
-    resolve('');
-  });
-}
-
-export function getText(block) {
-  return new Promise((resolve) => {
-    let regex;
-    const { data } = block;
-    const textResult = [];
-
-    if (data.regex) {
-      regex = new RegExp(data.regex, data.regexExp.join(''));
-    }
-
-    handleElement(block, (element) => {
-      let text = element.innerText;
-
-      if (regex) text = text.match(regex).join(' ');
-
-      textResult.push(text);
-    });
-
-    resolve(textResult);
-  });
-}
-
-const automaScript = `
-function automaNextBlock(data) {
-  window.dispatchEvent(new CustomEvent('__automa-next-block__', { detail: data }));
-}
-function automaResetTimeout() {
- window.dispatchEvent(new CustomEvent('__automa-reset-timeout__'));
-}
-`;
-
-export function javascriptCode(block) {
-  return new Promise((resolve) => {
-    const isScriptExists = document.getElementById('automa-custom-js');
-    const scriptAttr = `block--${block.id}`;
-
-    if (isScriptExists && isScriptExists.hasAttribute(scriptAttr)) {
-      resolve('');
-      return;
-    }
-
-    const script = document.createElement('script');
-    let timeout;
-
-    script.setAttribute(scriptAttr, '');
-    script.id = 'automa-custom-js';
-    script.innerHTML = `${automaScript} ${block.data.code}`;
-
-    window.addEventListener('__automa-next-block__', ({ detail }) => {
-      clearTimeout(timeout);
-      script.remove();
-      resolve(detail || {});
-    });
-    window.addEventListener('__automa-reset-timeout__', () => {
-      clearTimeout(timeout);
-
-      timeout = setTimeout(() => {
-        script.remove();
-        resolve('');
-      }, block.data.timeout);
-    });
-
-    document.body.appendChild(script);
-
-    timeout = setTimeout(() => {
-      script.remove();
-      resolve('');
-    }, block.data.timeout);
-  });
-}
-
-export function elementScroll(block) {
-  function incScrollPos(element, data, vertical = true) {
-    let currentPos = vertical ? element.scrollTop : element.scrollLeft;
-
-    if (data.incY) {
-      currentPos += data.scrollY;
-    } else if (data.incX) {
-      currentPos += data.scrollX;
-    }
-
-    return currentPos;
-  }
-
-  return new Promise((resolve) => {
-    const { data } = block;
-    const behavior = data.smooth ? 'smooth' : 'auto';
-
-    handleElement(block, (element) => {
-      if (data.scrollIntoView) {
-        element.scrollIntoView({ behavior, block: 'center' });
-      } else {
-        element.scroll({
-          behavior,
-          top: data.incY ? incScrollPos(element, data) : data.scrollY,
-          left: data.incX ? incScrollPos(element, data, false) : data.scrollX,
-        });
-      }
-    });
-
-    window.dispatchEvent(new Event('scroll'));
-
-    resolve('');
-  });
-}
-
-export function attributeValue(block) {
-  return new Promise((resolve) => {
-    let result = [];
-    const { attributeName, multiple } = block.data;
-    const isCheckboxOrRadio = (element) => {
-      if (element.tagName !== 'INPUT') return false;
-
-      return ['checkbox', 'radio'].includes(element.getAttribute('type'));
-    };
-
-    handleElement(block, (element) => {
-      const value =
-        attributeName === 'checked' && isCheckboxOrRadio(element)
-          ? element.checked
-          : element.getAttribute(attributeName);
-
-      if (multiple) result.push(value);
-      else result = value;
-    });
-
-    resolve(result);
-  });
-}
-
-export function forms(block) {
-  return new Promise((resolve) => {
-    const { data } = block;
-    const elements = handleElement(block, true);
-
-    if (data.multiple) {
-      const promises = Array.from(elements).map((element) => {
-        return new Promise((eventResolve) => {
-          markElement(element, block);
-          handleFormElement(element, data, eventResolve);
-        });
-      });
-
-      Promise.allSettled(promises).then(() => {
-        resolve('');
-      });
-    } else if (elements) {
-      markElement(elements, block);
-      handleFormElement(elements, data, resolve);
-    } else {
-      resolve('');
-    }
-  });
-}
-
-export function triggerEvent(block) {
-  return new Promise((resolve) => {
-    const { data } = block;
-
-    handleElement(block, (element) => {
-      simulateEvent(element, data.eventName, data.eventParams);
-    });
-
-    resolve(data.eventName);
-  });
-}
-
-export function link(block) {
-  return new Promise((resolve) => {
-    const element = document.querySelector(block.data.selector);
-
-    if (!element) {
-      resolve('');
-      return;
-    }
-
-    markElement(element, block);
-
-    const url = element.href;
-
-    if (url) window.location.href = url;
-
-    resolve(url);
-  });
-}
-
-export function elementExists({ data }) {
-  return new Promise((resolve) => {
-    let trying = 0;
-
-    function checkElement() {
-      if (trying >= (data.tryCount || 1)) {
-        resolve(false);
-        return;
-      }
-
-      const element = document.querySelector(data.selector);
-
-      if (element) {
-        resolve(true);
-      } else {
-        trying += 1;
-
-        setTimeout(checkElement, data.timeout || 500);
-      }
-    }
-
-    checkElement();
-  });
-}
+export default handlers;

+ 27 - 0
src/content/blocks-handler/handler-attribute-value.js

@@ -0,0 +1,27 @@
+import { handleElement } from '../helper';
+
+function attributeValue(block) {
+  return new Promise((resolve) => {
+    let result = [];
+    const { attributeName, multiple } = block.data;
+    const isCheckboxOrRadio = (element) => {
+      if (element.tagName !== 'INPUT') return false;
+
+      return ['checkbox', 'radio'].includes(element.getAttribute('type'));
+    };
+
+    handleElement(block, (element) => {
+      const value =
+        attributeName === 'checked' && isCheckboxOrRadio(element)
+          ? element.checked
+          : element.getAttribute(attributeName);
+
+      if (multiple) result.push(value);
+      else result = value;
+    });
+
+    resolve(result);
+  });
+}
+
+export default attributeValue;

+ 26 - 0
src/content/blocks-handler/handler-element-exists.js

@@ -0,0 +1,26 @@
+function elementExists({ data }) {
+  return new Promise((resolve) => {
+    let trying = 0;
+
+    function checkElement() {
+      if (trying >= (data.tryCount || 1)) {
+        resolve(false);
+        return;
+      }
+
+      const element = document.querySelector(data.selector);
+
+      if (element) {
+        resolve(true);
+      } else {
+        trying += 1;
+
+        setTimeout(checkElement, data.timeout || 500);
+      }
+    }
+
+    checkElement();
+  });
+}
+
+export default elementExists;

+ 38 - 0
src/content/blocks-handler/handler-element-scroll.js

@@ -0,0 +1,38 @@
+import { handleElement } from '../helper';
+
+function elementScroll(block) {
+  function incScrollPos(element, data, vertical = true) {
+    let currentPos = vertical ? element.scrollTop : element.scrollLeft;
+
+    if (data.incY) {
+      currentPos += data.scrollY;
+    } else if (data.incX) {
+      currentPos += data.scrollX;
+    }
+
+    return currentPos;
+  }
+
+  return new Promise((resolve) => {
+    const { data } = block;
+    const behavior = data.smooth ? 'smooth' : 'auto';
+
+    handleElement(block, (element) => {
+      if (data.scrollIntoView) {
+        element.scrollIntoView({ behavior, block: 'center' });
+      } else {
+        element.scroll({
+          behavior,
+          top: data.incY ? incScrollPos(element, data) : data.scrollY,
+          left: data.incX ? incScrollPos(element, data, false) : data.scrollX,
+        });
+      }
+    });
+
+    window.dispatchEvent(new Event('scroll'));
+
+    resolve('');
+  });
+}
+
+export default elementScroll;

+ 13 - 0
src/content/blocks-handler/handler-event-click.js

@@ -0,0 +1,13 @@
+import { handleElement } from '../helper';
+
+function eventClick(block) {
+  return new Promise((resolve) => {
+    handleElement(block, (element) => {
+      element.click();
+    });
+
+    resolve('');
+  });
+}
+
+export default eventClick;

+ 42 - 0
src/content/blocks-handler/handler-forms.js

@@ -0,0 +1,42 @@
+import { handleElement, markElement } from '../helper';
+import handleFormElement from '@/utils/handle-form-element';
+
+function forms(block) {
+  return new Promise((resolve) => {
+    const { data } = block;
+    const elements = handleElement(block, true);
+
+    if (data.getValue) {
+      let result = '';
+
+      if (data.multiple) {
+        result = elements.map((element) => element.value || '');
+      } else {
+        result = elements.value || '';
+      }
+
+      resolve(result);
+      return;
+    }
+
+    if (data.multiple) {
+      const promises = Array.from(elements).map((element) => {
+        return new Promise((eventResolve) => {
+          markElement(element, block);
+          handleFormElement(element, data, eventResolve);
+        });
+      });
+
+      Promise.allSettled(promises).then(() => {
+        resolve('');
+      });
+    } else if (elements) {
+      markElement(elements, block);
+      handleFormElement(elements, data, resolve);
+    } else {
+      resolve('');
+    }
+  });
+}
+
+export default forms;

+ 27 - 0
src/content/blocks-handler/handler-get-text.js

@@ -0,0 +1,27 @@
+import { handleElement } from '../helper';
+
+function getText(block) {
+  return new Promise((resolve) => {
+    let regex;
+    const { regex: regexData, regexExp, prefixText, suffixText } = block.data;
+    const textResult = [];
+
+    if (regexData) {
+      regex = new RegExp(regexData, regexExp.join(''));
+    }
+
+    handleElement(block, (element) => {
+      let text = element.innerText;
+
+      if (regex) text = text.match(regex).join(' ');
+
+      text = (prefixText || '') + text + (suffixText || '');
+
+      textResult.push(text);
+    });
+
+    resolve(textResult);
+  });
+}
+
+export default getText;

+ 128 - 0
src/content/blocks-handler/handler-javascript-code.js

@@ -0,0 +1,128 @@
+import { sendMessage } from '@/utils/message';
+import { generateJSON } from '@/utils/data-exporter';
+
+function getAutomaScript(blockId) {
+  return `
+function automaNextBlock(data) {
+  window.dispatchEvent(new CustomEvent('__automa-next-block__', { detail: data }));
+}
+function automaResetTimeout() {
+ window.dispatchEvent(new CustomEvent('__automa-reset-timeout__'));
+}
+function findData(obj, path) {
+  const paths = path.split('.');
+  const isWhitespace = paths.length === 1 && !/\\S/.test(paths[0]);
+
+  if (paths.length === 0 || isWhitespace) return obj;
+
+  let current = obj;
+
+  for (let i = 0; i < paths.length; i++) {
+    if (current[paths[i]] == undefined) {
+      return undefined;
+    } else {
+      current = current[paths[i]];
+    }
+  }
+
+  return current;
+}
+function automaRefData(keyword, path = '') {
+  const data = JSON.parse(sessionStorage.getItem('automa--${blockId}')) || null;
+
+  if (data === null) return null;
+
+  return findData(data[keyword], path);
+}
+  `;
+}
+
+function javascriptCode(block) {
+  block.refData.dataColumns = generateJSON(
+    Object.keys(block.refData.dataColumns),
+    block.refData.dataColumns
+  );
+
+  sessionStorage.setItem(`automa--${block.id}`, JSON.stringify(block.refData));
+  const automaScript = getAutomaScript(block.id);
+
+  return new Promise((resolve) => {
+    const isScriptExists = document.getElementById('automa-custom-js');
+    const scriptAttr = `block--${block.id}`;
+
+    if (isScriptExists && isScriptExists.hasAttribute(scriptAttr)) {
+      resolve('');
+      return;
+    }
+
+    const promisePreloadScripts =
+      block.data?.preloadScripts.map(async (item) => {
+        try {
+          const { protocol, pathname } = new URL(item.src);
+          const isValidUrl = /https?/.test(protocol) && /\.js$/.test(pathname);
+
+          if (!isValidUrl) return null;
+
+          const script = await sendMessage(
+            'fetch:text',
+            item.src,
+            'background'
+          );
+          const scriptEl = document.createElement('script');
+
+          scriptEl.type = 'text/javascript';
+          scriptEl.innerHTML = script;
+
+          return {
+            ...item,
+            script: scriptEl,
+          };
+        } catch (error) {
+          return null;
+        }
+      }, []) || [];
+
+    Promise.allSettled(promisePreloadScripts).then((result) => {
+      const preloadScripts = result.reduce((acc, { status, value }) => {
+        if (status !== 'fulfilled' || !value) return acc;
+
+        acc.push(value);
+        document.body.appendChild(value.script);
+
+        return acc;
+      }, []);
+
+      const script = document.createElement('script');
+      let timeout;
+
+      script.setAttribute(scriptAttr, '');
+      script.id = 'automa-custom-js';
+      script.innerHTML = `(() => {\n${automaScript} ${block.data.code}\n})()`;
+
+      const cleanUp = (data = '') => {
+        script.remove();
+        preloadScripts.forEach((item) => {
+          if (item.removeAfterExec) item.script.remove();
+        });
+        sessionStorage.removeItem(`automa--${block.id}`);
+        resolve(data);
+      };
+
+      window.addEventListener('__automa-next-block__', ({ detail }) => {
+        clearTimeout(timeout);
+        cleanUp(detail || {});
+      });
+      window.addEventListener('__automa-reset-timeout__', () => {
+        clearTimeout(timeout);
+
+        timeout = setTimeout(cleanUp, block.data.timeout);
+      });
+
+      document.body.appendChild(script);
+
+      timeout = setTimeout(cleanUp, block.data.timeout);
+    });
+  });
+}
+
+export default javascriptCode;

+ 22 - 0
src/content/blocks-handler/handler-link.js

@@ -0,0 +1,22 @@
+import { markElement } from '../helper';
+
+function link(block) {
+  return new Promise((resolve) => {
+    const element = document.querySelector(block.data.selector);
+
+    if (!element) {
+      resolve('');
+      return;
+    }
+
+    markElement(element, block);
+
+    const url = element.href;
+
+    if (url) window.location.href = url;
+
+    resolve(url);
+  });
+}
+
+export default link;

+ 22 - 0
src/content/blocks-handler/handler-switch-to.js

@@ -0,0 +1,22 @@
+import { handleElement } from '../helper';
+
+function switchTo(block) {
+  return new Promise((resolve) => {
+    handleElement(
+      block,
+      (element) => {
+        if (element.tagName !== 'IFRAME') {
+          resolve('');
+          return;
+        }
+
+        resolve({ url: element.src });
+      },
+      () => {
+        resolve('');
+      }
+    );
+  });
+}
+
+export default switchTo;

+ 16 - 0
src/content/blocks-handler/handler-trigger-event.js

@@ -0,0 +1,16 @@
+import { handleElement } from '../helper';
+import simulateEvent from '@/utils/simulate-event';
+
+function triggerEvent(block) {
+  return new Promise((resolve) => {
+    const { data } = block;
+
+    handleElement(block, (element) => {
+      simulateEvent(element, data.eventName, data.eventParams);
+    });
+
+    resolve(data.eventName);
+  });
+}
+
+export default triggerEvent;

+ 343 - 0
src/content/element-selector/App.vue

@@ -0,0 +1,343 @@
+<template>
+  <div
+    :class="{
+      'select-none': state.isDragging,
+      'bg-black bg-opacity-30': !state.hide,
+    }"
+    class="
+      root
+      fixed
+      h-full
+      w-full
+      pointer-events-none
+      top-0
+      text-gray-900
+      left-0
+    "
+    style="z-index: 9999999999; font-family: Inter, sans-serif; font-size: 16px"
+  >
+    <div
+      ref="cardEl"
+      :style="{ transform: `translate(${cardRect.x}px, ${cardRect.y}px)` }"
+      style="width: 320px"
+      class="
+        absolute
+        root-card
+        bg-white
+        shadow-xl
+        z-50
+        p-4
+        pointer-events-auto
+        rounded-lg
+      "
+    >
+      <div
+        class="
+          absolute
+          p-2
+          drag-button
+          shadow-xl
+          bg-white
+          p-1
+          cursor-move
+          rounded-lg
+        "
+        style="top: -15px; left: -15px"
+      >
+        <v-remixicon
+          name="riDragMoveLine"
+          @mousedown="state.isDragging = true"
+        />
+      </div>
+      <div class="flex items-center">
+        <p class="ml-1 text-lg font-semibold">Automa</p>
+        <div class="flex-grow"></div>
+        <ui-button icon class="mr-2" @click="state.hide = !state.hide">
+          <v-remixicon :name="state.hide ? 'riEyeOffLine' : 'riEyeLine'" />
+        </ui-button>
+        <ui-button icon @click="destroy">
+          <v-remixicon name="riCloseLine" />
+        </ui-button>
+      </div>
+      <app-selector
+        :selector="state.elSelector"
+        :selected-count="state.selectedElements.length"
+        @child="selectChildElement"
+        @parent="selectParentElement"
+        @change="updateSelectedElements"
+      />
+      <template v-if="!state.hide && state.selectedElements.length > 0">
+        <ui-tabs v-model="state.activeTab" class="mt-2" fill>
+          <ui-tab value="attributes"> Attributes </ui-tab>
+          <ui-tab value="blocks"> Blocks </ui-tab>
+        </ui-tabs>
+        <ui-tab-panels
+          v-model="state.activeTab"
+          class="overflow-y-auto scroll"
+          style="max-height: calc(100vh - 15rem)"
+        >
+          <ui-tab-panel value="attributes">
+            <app-element-attributes
+              :elements="state.selectedElements"
+              @highlight="
+                state.selectedElements[$event.index].highlight =
+                  $event.highlight
+              "
+            />
+          </ui-tab-panel>
+          <ui-tab-panel value="blocks">
+            <app-blocks
+              :elements="state.selectedElements"
+              :selector="state.elSelector"
+              @execute="state.isExecuting = $event"
+              @update="updateCardSize"
+            />
+          </ui-tab-panel>
+        </ui-tab-panels>
+      </template>
+    </div>
+    <svg
+      v-if="!state.hide"
+      class="h-full w-full absolute top-0 pointer-events-none left-0 z-10"
+    >
+      <rect
+        v-bind="hoverElementRect"
+        stroke-width="2"
+        stroke="#fbbf24"
+        fill="rgba(251, 191, 36, 0.2)"
+      ></rect>
+      <rect
+        v-for="(item, index) in state.selectedElements"
+        v-bind="item"
+        :key="index"
+        :stroke="item.highlight ? '#2563EB' : '#f87171'"
+        :fill="
+          item.highlight ? 'rgb(37, 99, 235, 0.2)' : 'rgba(248, 113, 113, 0.2)'
+        "
+        stroke-width="2"
+      ></rect>
+    </svg>
+  </div>
+</template>
+<script setup>
+import { reactive, ref, watch, inject, nextTick } from 'vue';
+import { finder } from '@medv/finder';
+import { debounce } from '@/utils/helper';
+import AppBlocks from './AppBlocks.vue';
+import AppSelector from './AppSelector.vue';
+import AppElementAttributes from './AppElementAttributes.vue';
+
+const selectedElement = {
+  path: [],
+  pathIndex: 0,
+};
+let lastScrollPosY = window.scrollY;
+let lastScrollPosX = window.scrollX;
+
+const rootElement = inject('rootElement');
+
+const cardEl = ref('cardEl');
+const state = reactive({
+  activeTab: '',
+  elSelector: '',
+  isDragging: false,
+  isExecuting: false,
+  selectedElements: [],
+  hide: window.self !== window.top,
+});
+const hoverElementRect = reactive({
+  x: 0,
+  y: 0,
+  height: 0,
+  width: 0,
+});
+const cardRect = reactive({
+  x: 0,
+  y: 0,
+  height: 0,
+  width: 0,
+});
+
+function getElementRect(target) {
+  if (!target) return {};
+
+  const { x, y, height, width } = target.getBoundingClientRect();
+
+  return {
+    width: width + 4,
+    height: height + 4,
+    x: x - 2,
+    y: y - 2,
+  };
+}
+function updateSelectedElements(selector) {
+  state.elSelector = selector;
+
+  try {
+    const elements = document.querySelectorAll(selector);
+
+    state.selectedElements = Array.from(elements).map((element) => {
+      const attributes = Array.from(element.attributes).map(
+        ({ name, value }) => ({ name, value })
+      );
+
+      return {
+        element,
+        attributes,
+        highlight: false,
+        ...getElementRect(element),
+      };
+    });
+  } catch (error) {
+    state.selectedElements = [];
+  }
+}
+function handleMouseMove({ clientX, clientY, target }) {
+  if (state.isDragging) {
+    const height = window.innerHeight;
+    const width = document.documentElement.clientWidth;
+
+    if (clientY < 10) clientY = 0;
+    else if (cardRect.height + clientY > height)
+      clientY = height - cardRect.height;
+
+    if (clientX < 10) clientX = 0;
+    else if (cardRect.width + clientX > width) clientX = width - cardRect.width;
+
+    cardRect.x = clientX;
+    cardRect.y = clientY;
+
+    return;
+  }
+
+  if (state.hide || rootElement === target) return;
+
+  Object.assign(hoverElementRect, getElementRect(target));
+}
+function handleClick(event) {
+  if (event.target === rootElement || state.hide || state.isExecuting) return;
+
+  event.preventDefault();
+  event.stopPropagation();
+
+  const attributes = Array.from(event.target.attributes).map(
+    ({ name, value }) => ({ name, value })
+  );
+  state.selectedElements = [
+    {
+      ...getElementRect(event.target),
+      attributes,
+      element: event.target,
+      highlight: false,
+    },
+  ];
+  state.elSelector = finder(event.target);
+
+  selectedElement.index = 0;
+  selectedElement.path = event.path;
+}
+function selectChildElement() {
+  if (selectedElement.path.length === 0 || state.hide) return;
+
+  const currentEl = selectedElement.path[selectedElement.pathIndex];
+  let childElement = currentEl;
+
+  if (selectedElement.pathIndex <= 0) {
+    const childEl = Array.from(currentEl.children).find(
+      (el) => !['STYLE', 'SCRIPT'].includes(el.tagName)
+    );
+
+    if (currentEl.childElementCount === 0 || currentEl === childEl) return;
+
+    childElement = childEl;
+    selectedElement.path.unshift(childEl);
+  } else {
+    selectedElement.pathIndex -= 1;
+    childElement = selectedElement.path[selectedElement.pathIndex];
+  }
+
+  updateSelectedElements(finder(childElement));
+}
+function selectParentElement() {
+  if (selectedElement.path.length === 0 || state.hide) return;
+
+  const parentElement = selectedElement.path[selectedElement.pathIndex];
+
+  if (parentElement.tagName === 'HTML') return;
+
+  selectedElement.pathIndex += 1;
+
+  updateSelectedElements(finder(parentElement));
+}
+function handleMouseUp() {
+  if (state.isDragging) state.isDragging = false;
+}
+function updateCardSize() {
+  setTimeout(() => {
+    cardRect.height = cardEl.value.getBoundingClientRect().height;
+  }, 250);
+}
+const handleScroll = debounce(() => {
+  if (state.hide) return;
+
+  const yPos = window.scrollY - lastScrollPosY;
+  const xPos = window.scrollX - lastScrollPosX;
+
+  state.selectedElements.forEach((_, index) => {
+    state.selectedElements[index].x -= xPos;
+    state.selectedElements[index].y -= yPos;
+  });
+
+  hoverElementRect.x -= xPos;
+  hoverElementRect.y -= yPos;
+
+  lastScrollPosX = window.scrollX;
+  lastScrollPosY = window.scrollY;
+}, 100);
+function destroy() {
+  window.removeEventListener('scroll', handleScroll);
+  window.removeEventListener('mouseup', handleMouseUp);
+  window.removeEventListener('mousemove', handleMouseMove);
+  document.removeEventListener('click', handleClick, true);
+
+  const automaElements = document.querySelectorAll('automa-element-selector');
+  automaElements.forEach((element) => {
+    element.remove();
+  });
+
+  rootElement.remove();
+}
+
+window.addEventListener('scroll', handleScroll);
+window.addEventListener('mouseup', handleMouseUp);
+window.addEventListener('mousemove', handleMouseMove);
+document.addEventListener('click', handleClick, true);
+
+watch(
+  () => state.isDragging,
+  (value) => {
+    document.body.toggleAttribute('automa-isDragging', value);
+  }
+);
+watch(() => [state.elSelector, state.activeTab, state.hide], updateCardSize);
+
+nextTick(() => {
+  setTimeout(() => {
+    const { height, width } = cardEl.value.getBoundingClientRect();
+
+    cardRect.x = window.innerWidth - (width + 35);
+    cardRect.y = 20;
+    cardRect.width = width;
+    cardRect.height = height;
+  }, 250);
+});
+</script>
+<style>
+.drag-button {
+  transform: scale(0);
+  transition: transform 200ms ease-in-out;
+}
+.root-card:hover .drag-button {
+  transform: scale(1);
+}
+</style>

+ 121 - 0
src/content/element-selector/AppBlocks.vue

@@ -0,0 +1,121 @@
+<template>
+  <div class="events mt-4">
+    <div class="flex items-center">
+      <ui-select
+        v-model="state.selectedBlock"
+        class="flex-1 mr-4"
+        placeholder="Select block"
+        @change="onSelectChanged"
+      >
+        <option v-for="(block, id) in blocks" :key="id" :value="id">
+          {{ block.name }}
+        </option>
+      </ui-select>
+      <ui-button
+        :disabled="!state.selectedBlock"
+        variant="accent"
+        @click="executeBlock"
+      >
+        Execute
+      </ui-button>
+    </div>
+    <component
+      :is="blocks[state.selectedBlock].component"
+      v-if="state.selectedBlock && blocks[state.selectedBlock].component"
+      :data="state.params"
+      :hide-base="true"
+      @update:data="updateParams"
+    />
+    <prism-editor
+      v-if="state.blockResult"
+      v-model="state.blockResult"
+      :highlight="highlighter('json')"
+      class="h-full scroll mt-2"
+    />
+  </div>
+</template>
+<script setup>
+import { shallowReactive } from 'vue';
+import { PrismEditor } from 'vue-prism-editor';
+import { tasks } from '@/utils/shared';
+import { highlighter } from '@/lib/prism';
+import handleForms from '../blocks-handler/handler-forms';
+import handleGetText from '../blocks-handler/handler-get-text';
+import handleEventClick from '../blocks-handler/handler-event-click';
+import handelTriggerEvent from '../blocks-handler/handler-trigger-event';
+import handleElementScroll from '../blocks-handler/handler-element-scroll';
+import EditForms from '@/components/newtab/workflow/edit/EditForms.vue';
+import EditTriggerEvent from '@/components/newtab/workflow/edit/EditTriggerEvent.vue';
+import EditScrollElement from '@/components/newtab/workflow/edit/EditScrollElement.vue';
+
+const props = defineProps({
+  selector: {
+    type: String,
+    default: '',
+  },
+  elements: {
+    type: Array,
+    default: () => [],
+  },
+});
+const emit = defineEmits(['update', 'execute']);
+
+const blocks = {
+  forms: {
+    ...tasks.forms,
+    component: EditForms,
+    handler: handleForms,
+  },
+  'get-text': {
+    ...tasks['get-text'],
+    component: '',
+    handler: handleGetText,
+  },
+  'event-click': {
+    ...tasks['event-click'],
+    component: '',
+    handler: handleEventClick,
+  },
+  'trigger-event': {
+    ...tasks['trigger-event'],
+    component: EditTriggerEvent,
+    handler: handelTriggerEvent,
+  },
+  'element-scroll': {
+    ...tasks['element-scroll'],
+    component: EditScrollElement,
+    handler: handleElementScroll,
+  },
+};
+
+const state = shallowReactive({
+  params: {},
+  blockResult: '',
+  selectedBlock: '',
+});
+
+function updateParams(data = {}) {
+  state.params = data;
+  emit('update');
+}
+function onSelectChanged(value) {
+  state.params = tasks[value].data;
+  state.blockResult = '';
+  emit('update');
+}
+function executeBlock() {
+  const params = {
+    ...state.params,
+    selector: props.selector,
+    multiple: props.elements.length > 1,
+  };
+
+  emit('execute', true);
+
+  blocks[state.selectedBlock].handler({ data: params }).then((result) => {
+    state.blockResult = JSON.stringify(result, null, 2).trim();
+    emit('update');
+    emit('execute', false);
+  });
+}
+</script>

+ 39 - 0
src/content/element-selector/AppElementAttributes.vue

@@ -0,0 +1,39 @@
+<template>
+  <ul class="space-y-4 mt-2">
+    <li
+      v-for="(element, index) in elements"
+      :key="index"
+      @mouseenter="$emit('highlight', { highlight: true, index })"
+      @mouseleave="$emit('highlight', { highlight: false, index })"
+    >
+      <p
+        class="mb-1 cursor-pointer"
+        title="Scroll into view"
+        @click="
+          element.element.scrollIntoView({ block: 'center', inline: 'center' })
+        "
+      >
+        #{{ index + 1 }} Element
+      </p>
+      <div
+        v-for="attribute in element.attributes"
+        :key="attribute.name"
+        class="bg-box-transparent mb-1 rounded-lg py-2 px-3"
+      >
+        <p class="text-sm text-overflow leading-tight text-gray-600">
+          {{ attribute.name }}
+        </p>
+        <p class="text-overflow">{{ attribute.value }}</p>
+      </div>
+    </li>
+  </ul>
+</template>
+<script setup>
+defineProps({
+  elements: {
+    type: Array,
+    default: () => [],
+  },
+});
+defineEmits(['highlight']);
+</script>

+ 0 - 0
src/content/element-selector/AppHeader.vue


+ 57 - 0
src/content/element-selector/AppSelector.vue

@@ -0,0 +1,57 @@
+<template>
+  <div class="mt-4 flex items-center">
+    <ui-input
+      :model-value="selector"
+      placeholder="Element selector"
+      class="leading-normal flex-1 h-full element-selector"
+      @change="updateSelector"
+    >
+      <template #prepend>
+        <button class="absolute ml-2 left-0" @click="copySelector">
+          <v-remixicon name="riFileCopyLine" />
+        </button>
+      </template>
+    </ui-input>
+    <template v-if="selectedCount === 1">
+      <button class="mr-2 ml-4" @click="$emit('parent')">
+        <v-remixicon rotate="90" name="riArrowLeftLine" />
+      </button>
+      <button @click="$emit('child')">
+        <v-remixicon rotate="-90" name="riArrowLeftLine" />
+      </button>
+    </template>
+  </div>
+</template>
+<script setup>
+import { inject } from 'vue';
+import { debounce } from '@/utils/helper';
+import UiInput from '@/components/ui/UiInput.vue';
+
+const props = defineProps({
+  selector: {
+    type: String,
+    default: '',
+  },
+  selectedCount: {
+    type: Number,
+    default: 0,
+  },
+});
+const emit = defineEmits(['change', 'parent', 'child']);
+
+const rootElement = inject('rootElement');
+
+const updateSelector = debounce((value) => {
+  if (value === props.selector) return;
+
+  emit('change', value);
+}, 250);
+function copySelector() {
+  rootElement.shadowRoot.querySelector('input')?.select();
+
+  navigator.clipboard.writeText(props.selector).catch((error) => {
+    document.execCommand('copy');
+    console.error(error);
+  });
+}
+</script>

+ 0 - 308
src/content/element-selector/ElementSelector.ce.vue

@@ -1,308 +0,0 @@
-<template>
-  <template v-if="!element.hide">
-    <div class="overlay"></div>
-    <div
-      :style="{
-        transform: `translate(${element.hovered.x}px, ${element.hovered.y}px)`,
-        height: element.hovered.height + 'px',
-        width: element.hovered.width + 'px',
-      }"
-      class="indicator pointer-events-auto"
-    ></div>
-    <div
-      v-if="element.selector"
-      :style="{
-        transform: `translate(${element.selected.x}px, ${element.selected.y}px)`,
-        height: element.selected.height + 'px',
-        width: element.selected.width + 'px',
-        zIndex: 99,
-      }"
-      class="indicator selected"
-    ></div>
-  </template>
-  <div class="card">
-    <button
-      title="Toggle hide"
-      class="mr-2"
-      @click="element.hide = !element.hide"
-    >
-      <v-remix-icon :path="element.hide ? riEyeLine : riEyeOffLine" />
-    </button>
-    <div class="selector">
-      <v-remix-icon
-        style="cursor: pointer"
-        title="Copy selector"
-        :path="riFileCopyLine"
-        @click="copySelector"
-      />
-      <input
-        type="text"
-        placeholder="Element selector"
-        title="Element selector"
-        readonly
-        :value="element.selector"
-      />
-    </div>
-    <template v-if="element.selector && !element.hide">
-      <button
-        title="Select parent element (press P)"
-        class="ml-2"
-        @click="selectParentElement"
-      >
-        <v-remix-icon :path="riArrowDownLine" rotate="180" />
-      </button>
-      <button
-        title="Select parent element (press C)"
-        class="ml-2"
-        @click="selectChildElement"
-      >
-        <v-remix-icon :path="riArrowDownLine" />
-      </button>
-    </template>
-    <button class="primary ml-2" @click="destroy">Close</button>
-  </div>
-</template>
-<script setup>
-import { reactive } from 'vue';
-import { finder } from '@medv/finder';
-import { VRemixIcon } from 'v-remixicon';
-import {
-  riFileCopyLine,
-  riArrowDownLine,
-  riEyeLine,
-  riEyeOffLine,
-} from 'v-remixicon/icons';
-
-/* to-do get list of attribute value, add test for each of the web interation block */
-
-const element = reactive({
-  hide: window.self !== window.top,
-  hovered: {},
-  selected: {},
-  selector: '',
-});
-
-let targetEl = null;
-let selectedEl = null;
-let pathIndex = 0;
-let selectedPath = [];
-const root = document.querySelector('element-selector');
-
-function getElementRect(target) {
-  if (!target) return {};
-
-  const { x, y, height, width } = target.getBoundingClientRect();
-
-  return {
-    width,
-    height,
-    x: x - 2,
-    y: y - 2,
-  };
-}
-function handleMouseMove({ target }) {
-  if (element.hide || targetEl === target || target === root) return;
-
-  targetEl = target;
-
-  element.hovered = getElementRect(target);
-}
-function copySelector() {
-  root.shadowRoot.querySelector('input')?.select();
-
-  navigator.clipboard.writeText(element.selector).catch((error) => {
-    document.execCommand('copy');
-    console.error(error);
-  });
-}
-function selectChildElement() {
-  if (selectedPath.length === 0 || element.hide) return;
-
-  const currentEl = selectedPath[pathIndex];
-  let activeEl = currentEl;
-
-  if (pathIndex <= 0) {
-    const childEl = Array.from(currentEl.children).find(
-      (el) => !['STYLE', 'SCRIPT'].includes(el.tagName)
-    );
-
-    if (currentEl.childElementCount === 0 || currentEl === childEl) return;
-
-    activeEl = childEl;
-    selectedPath.unshift(childEl);
-  } else {
-    pathIndex -= 1;
-    activeEl = selectedPath[pathIndex];
-  }
-
-  element.selected = getElementRect(activeEl);
-  element.selector = finder(activeEl);
-  selectedEl = activeEl;
-}
-function selectParentElement() {
-  if (
-    selectedEl.tagName === 'HTML' ||
-    selectedPath.length === 0 ||
-    element.hide
-  )
-    return;
-
-  pathIndex += 1;
-  const activeEl = selectedPath[pathIndex];
-
-  element.selected = getElementRect(activeEl);
-  element.selector = finder(activeEl);
-  selectedEl = activeEl;
-}
-function handleClick(event) {
-  if (event.target === root || element.hide) return;
-
-  if (!element.hide) {
-    event.preventDefault();
-    event.stopPropagation();
-  }
-
-  selectedPath = event.path;
-  element.selected = getElementRect(targetEl);
-  element.selector = finder(targetEl);
-
-  selectedEl = targetEl;
-}
-function handleKeyup({ code }) {
-  const shortcuts = {
-    /* eslint-disable-next-line */
-    Escape: destroy,
-    KeyC: selectChildElement,
-    KeyP: selectParentElement,
-  };
-
-  if (shortcuts[code]) shortcuts[code]();
-}
-function handleScroll() {
-  const { x: hoveredX, y: hoveredY } = getElementRect(targetEl);
-  const { x: selectedX, y: selectedY } = getElementRect(selectedEl);
-
-  element.hovered.x = hoveredX;
-  element.hovered.y = hoveredY;
-  element.selected.x = selectedX;
-  element.selected.y = selectedY;
-}
-function destroy() {
-  window.removeEventListener('keyup', handleKeyup);
-  window.removeEventListener('scroll', handleScroll);
-  document.removeEventListener('click', handleClick, true);
-  window.removeEventListener('mousemove', handleMouseMove);
-
-  root.remove();
-}
-
-window.addEventListener('keyup', handleKeyup);
-window.addEventListener('scroll', handleScroll);
-document.addEventListener('click', handleClick, true);
-window.addEventListener('mousemove', handleMouseMove);
-</script>
-<style>
-:host {
-  position: fixed;
-  height: 100%;
-  width: 100%;
-  top: 0;
-  left: 0;
-  pointer-events: none;
-  z-index: 99999;
-  color: #18181b;
-  font-size: 16px;
-  box-sizing: border-box;
-  font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
-    'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif,
-    'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
-}
-
-:host * {
-  font-size: 16px;
-}
-
-svg {
-  display: inline-block;
-}
-
-button {
-  border: none;
-  background-color: transparent;
-  color: inherit;
-  border-radius: 8px;
-  height: 38px;
-  padding: 0 10px;
-  background-color: #e4e4e7;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  cursor: pointer;
-}
-button.primary {
-  background-color: #18181b;
-  color: white;
-}
-
-.selector {
-  border-radius: 8px;
-  display: flex;
-  align-items: center;
-  padding-left: 12px;
-  background-color: #e4e4e7;
-}
-
-.ml-2 {
-  margin-left: 6px;
-}
-
-.mr-2 {
-  margin-right: 6px;
-}
-
-.overlay {
-  background-color: rgba(0, 0, 0, 0.2);
-  position: absolute;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  pointer-events: none;
-}
-
-input {
-  border: none;
-  color: inherit;
-  background-color: transparent;
-  padding: 10px 12px 10px 6px;
-  width: 150px;
-}
-input:focus {
-  outline: none;
-}
-
-.card {
-  position: absolute;
-  display: flex;
-  align-items: center;
-  bottom: 12px;
-  left: 12px;
-  background-color: white;
-  border-radius: 8px;
-  padding: 12px;
-  color: #1f2937;
-  border: 1px solid #e4e4e7;
-  pointer-events: all;
-  z-index: 999;
-}
-
-.indicator {
-  background-color: rgba(251, 191, 36, 0.2);
-  border: 2px solid #fbbf24;
-  position: absolute;
-}
-.indicator.selected {
-  background-color: rgba(248, 113, 113, 0.2);
-  border-color: #f87171;
-}
-</style>

+ 26 - 0
src/content/element-selector/comps-ui.js

@@ -0,0 +1,26 @@
+import VAutofocus from '@/directives/VAutofocus';
+import UiTab from '@/components/ui/UiTab.vue';
+import UiTabs from '@/components/ui/UiTabs.vue';
+import UiInput from '@/components/ui/UiInput.vue';
+import UiButton from '@/components/ui/UiButton.vue';
+import UiSelect from '@/components/ui/UiSelect.vue';
+import UiTextarea from '@/components/ui/UiTextarea.vue';
+import UiCheckbox from '@/components/ui/UiCheckbox.vue';
+import UiTabPanel from '@/components/ui/UiTabPanel.vue';
+import UiTabPanels from '@/components/ui/UiTabPanels.vue';
+import TransitionExpand from '@/components/transitions/TransitionExpand.vue';
+
+export default function (app) {
+  app.component('UiTab', UiTab);
+  app.component('UiTabs', UiTabs);
+  app.component('UiInput', UiInput);
+  app.component('UiButton', UiButton);
+  app.component('UiSelect', UiSelect);
+  app.component('UiTextarea', UiTextarea);
+  app.component('UiCheckbox', UiCheckbox);
+  app.component('UiTabPanel', UiTabPanel);
+  app.component('UiTabPanels', UiTabPanels);
+  app.component('TransitionExpand', TransitionExpand);
+
+  app.directive('autofocus', VAutofocus);
+}

+ 25 - 0
src/content/element-selector/icons.js

@@ -0,0 +1,25 @@
+import {
+  riEyeLine,
+  riCheckLine,
+  riCloseLine,
+  riEyeOffLine,
+  riFileCopyLine,
+  riDragMoveLine,
+  riArrowLeftLine,
+  riArrowLeftSLine,
+  riInformationLine,
+  riArrowDropDownLine,
+} from 'v-remixicon/icons';
+
+export default {
+  riEyeLine,
+  riCheckLine,
+  riCloseLine,
+  riEyeOffLine,
+  riFileCopyLine,
+  riDragMoveLine,
+  riArrowLeftLine,
+  riArrowLeftSLine,
+  riInformationLine,
+  riArrowDropDownLine,
+};

+ 62 - 15
src/content/element-selector/index.js

@@ -1,21 +1,68 @@
-import '@webcomponents/custom-elements';
-import { defineCustomElement } from 'vue';
-import ElementSelector from './ElementSelector.ce.vue';
+async function getStyles() {
+  try {
+    const response = await fetch(chrome.runtime.getURL('/elementSelector.css'));
+    const mainCSS = await response.text();
 
-/* to-do attribute list */
+    const fontCSS = `
+      :host { font-size: 16px }
+      @font-face {
+        font-family: Inter var;
+        font-weight: 100 900;
+        font-display: swap;
+        font-style: normal;
+        font-named-instance: "Regular";
+        src: url('${chrome.runtime.getURL(
+          '/Inter-roman-latin.var.woff2'
+        )}') format("woff2");
+      }
+    `;
 
-export default function () {
-  const isElementExists = document.querySelector('element-selector');
+    return `${mainCSS}\n${fontCSS}`;
+  } catch (error) {
+    console.error(error);
+    return '';
+  }
+}
+function getLocale() {
+  return new Promise((resolve) => {
+    chrome.storage.local.get('settings', ({ settings }) => {
+      resolve(settings?.locale || 'en');
+    });
+  });
+}
+
+export default async function () {
+  try {
+    const rootElement = document.createElement('div');
+    rootElement.classList.add('automa-element-selector');
+    rootElement.attachShadow({ mode: 'open' });
+
+    const automaStyle = document.createElement('style');
+    automaStyle.classList.add('automa-element-selector');
+    automaStyle.innerHTML = `.automa-element-selector { pointer-events: none } \n [automa-isDragging] { user-select: none }`;
 
-  if (isElementExists) return;
-  if (!customElements.get('element-selector')) {
-    window.customElements.define(
-      'element-selector',
-      defineCustomElement(ElementSelector)
+    const scriptEl = document.createElement('script');
+    scriptEl.setAttribute('type', 'module');
+    scriptEl.setAttribute(
+      'src',
+      chrome.runtime.getURL('/elementSelector.bundle.js')
     );
-  }
 
-  document.documentElement.appendChild(
-    document.createElement('element-selector')
-  );
+    const appContainer = document.createElement('div');
+    appContainer.setAttribute('data-id', chrome.runtime.id);
+    appContainer.setAttribute('data-locale', await getLocale());
+    appContainer.setAttribute('id', 'app');
+
+    const appStyle = document.createElement('style');
+    appStyle.innerHTML = await getStyles();
+
+    rootElement.shadowRoot.appendChild(appContainer);
+    rootElement.shadowRoot.appendChild(appStyle);
+    rootElement.shadowRoot.appendChild(scriptEl);
+
+    document.documentElement.appendChild(rootElement);
+    document.documentElement.appendChild(automaStyle);
+  } catch (error) {
+    console.error(error);
+  }
 }

+ 17 - 0
src/content/element-selector/main.js

@@ -0,0 +1,17 @@
+import { createApp } from 'vue';
+import vRemixicon from 'v-remixicon';
+import App from './App.vue';
+import compsUi from './comps-ui';
+import icons from './icons';
+import vueI18n from './vue-i18n';
+import '@/assets/css/tailwind.css';
+
+const rootElement = document.querySelector('div.automa-element-selector');
+const appRoot = rootElement.shadowRoot.querySelector('#app');
+
+createApp(App)
+  .provide('rootElement', rootElement)
+  .use(vueI18n)
+  .use(vRemixicon, icons)
+  .use(compsUi)
+  .mount(appRoot);

+ 13 - 0
src/content/element-selector/vue-i18n.js

@@ -0,0 +1,13 @@
+import { createI18n } from 'vue-i18n/dist/vue-i18n.esm-bundler';
+import enCommon from '@/locales/en/common.json';
+import enBlocks from '@/locales/en/blocks.json';
+
+const i18n = createI18n({
+  locale: 'en',
+  legacy: false,
+});
+
+i18n.global.mergeLocaleMessage('en', enCommon);
+i18n.global.mergeLocaleMessage('en', enBlocks);
+
+export default i18n;

+ 34 - 0
src/content/helper.js

@@ -0,0 +1,34 @@
+import FindElement from '@/utils/find-element';
+
+/* eslint-disable consistent-return */
+
+export function markElement(el, { id, data }) {
+  if (data.markEl) {
+    el.setAttribute(`block--${id}`, '');
+  }
+}
+
+export function handleElement({ data, id }, callback, errCallback) {
+  if (!data || !data.selector) return null;
+
+  try {
+    data.blockIdAttr = `block--${id}`;
+    const element = FindElement[data.findBy || 'cssSelector'](data);
+
+    if (typeof callback === 'boolean' && callback) return element;
+
+    if (data.multiple && (data.findBy || 'cssSelector') === 'cssSelector') {
+      element.forEach((el) => {
+        markElement(el, { id, data });
+        callback(el);
+      });
+    } else if (element) {
+      markElement(element, { id, data });
+      callback(element);
+    } else if (errCallback) {
+      errCallback();
+    }
+  } catch (error) {
+    console.error(error);
+  }
+}

+ 1 - 1
src/content/index.js

@@ -1,7 +1,7 @@
 import browser from 'webextension-polyfill';
 import { toCamelCase } from '@/utils/helper';
 import elementSelector from './element-selector';
-import * as blocksHandler from './blocks-handler';
+import blocksHandler from './blocks-handler';
 
 (() => {
   browser.runtime.onMessage.addListener((data) => {

+ 2 - 0
src/lib/dayjs.js

@@ -1,5 +1,7 @@
 import dayjs from 'dayjs';
 import relativeTime from 'dayjs/plugin/relativeTime';
+import 'dayjs/locale/zh';
+import 'dayjs/locale/zh-tw';
 
 dayjs.extend(relativeTime);
 

Some files were not shown because too many files changed in this diff