Ahmad Kholid 3 rokov pred
rodič
commit
6557466e5e
57 zmenil súbory, kde vykonal 1905 pridanie a 694 odobranie
  1. 4 3
      package.json
  2. 1 1
      src/background/collection-engine/flow-handler.js
  3. 2 2
      src/background/index.js
  4. 7 0
      src/background/workflow-engine/blocks-handler/handler-active-tab.js
  5. 1 1
      src/background/workflow-engine/blocks-handler/handler-browser-event.js
  6. 1 1
      src/background/workflow-engine/blocks-handler/handler-clipboard.js
  7. 52 19
      src/background/workflow-engine/blocks-handler/handler-conditions.js
  8. 2 2
      src/background/workflow-engine/blocks-handler/handler-execute-workflow.js
  9. 1 1
      src/background/workflow-engine/blocks-handler/handler-export-data.js
  10. 1 1
      src/background/workflow-engine/blocks-handler/handler-google-sheets.js
  11. 1 1
      src/background/workflow-engine/blocks-handler/handler-handle-download.js
  12. 8 3
      src/background/workflow-engine/blocks-handler/handler-insert-data.js
  13. 3 30
      src/background/workflow-engine/blocks-handler/handler-interaction-block.js
  14. 50 0
      src/background/workflow-engine/blocks-handler/handler-javascript-code.js
  15. 1 1
      src/background/workflow-engine/blocks-handler/handler-loop-data.js
  16. 8 1
      src/background/workflow-engine/blocks-handler/handler-new-tab.js
  17. 7 0
      src/background/workflow-engine/blocks-handler/handler-switch-tab.js
  18. 2 3
      src/background/workflow-engine/blocks-handler/handler-take-screenshot.js
  19. 2 2
      src/background/workflow-engine/blocks-handler/handler-webhook.js
  20. 21 0
      src/background/workflow-engine/blocks-handler/handler-while-loop.js
  21. 70 13
      src/background/workflow-engine/engine.js
  22. 1 1
      src/components/block/BlockBasic.vue
  23. 41 0
      src/components/block/BlockBasicWithFallback.vue
  24. 15 12
      src/components/block/BlockConditions.vue
  25. 1 1
      src/components/block/BlockElementExists.vue
  26. 123 0
      src/components/newtab/shared/SharedConditionBuilder/ConditionBuilderInputs.vue
  27. 220 0
      src/components/newtab/shared/SharedConditionBuilder/index.vue
  28. 1 1
      src/components/newtab/workflow/edit/EditClipboard.vue
  29. 106 51
      src/components/newtab/workflow/edit/EditConditions.vue
  30. 33 20
      src/components/newtab/workflow/edit/EditJavascriptCode.vue
  31. 108 0
      src/components/newtab/workflow/edit/EditWhileLoop.vue
  32. 169 0
      src/components/ui/UiAutocomplete.vue
  33. 16 2
      src/components/ui/UiExpand.vue
  34. 8 1
      src/components/ui/UiInput.vue
  35. 6 1
      src/components/ui/UiModal.vue
  36. 1 1
      src/content/blocks-handler/handler-event-click.js
  37. 1 1
      src/content/blocks-handler/handler-forms.js
  38. 1 1
      src/content/blocks-handler/handler-hover-element.js
  39. 40 26
      src/content/blocks-handler/handler-javascript-code.js
  40. 1 1
      src/content/blocks-handler/handler-trigger-event.js
  41. 2 2
      src/content/element-selector/App.vue
  42. 3 3
      src/content/element-selector/AppBlocks.vue
  43. 3 17
      src/content/handle-selector.js
  44. 32 0
      src/content/index.js
  45. 4 0
      src/lib/v-remixicon.js
  46. 7 0
      src/locales/en/blocks.json
  47. 6 0
      src/locales/en/newtab.json
  48. 1 1
      src/models/workflow.js
  49. 0 1
      src/newtab/App.vue
  50. 64 30
      src/newtab/pages/logs/[id].vue
  51. 13 0
      src/utils/helper.js
  52. 13 6
      src/utils/reference-data/index.js
  53. 23 5
      src/utils/reference-data/mustache-replacer.js
  54. 83 1
      src/utils/shared.js
  55. 120 0
      src/utils/test-conditions.js
  56. 1 1
      src/utils/workflow-data.js
  57. 393 422
      yarn.lock

+ 4 - 3
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "1.5.1",
+  "version": "1.5.4",
   "description": "An extension for automating your browser by connecting blocks",
   "license": "MIT",
   "repository": {
@@ -23,8 +23,9 @@
   },
   "dependencies": {
     "@codemirror/basic-setup": "^0.19.1",
-    "@codemirror/lang-javascript": "0.19.1",
-    "@codemirror/lang-json": "^0.19.1",
+    "@codemirror/fold": "^0.19.3",
+    "@codemirror/lang-javascript": "^0.19.7",
+    "@codemirror/lang-json": "^0.19.2",
     "@codemirror/theme-one-dark": "^0.19.1",
     "@medv/finder": "^2.1.0",
     "@tiptap/extension-character-count": "^2.0.0-beta.24",

+ 1 - 1
src/background/collection-engine/flow-handler.js

@@ -1,6 +1,6 @@
+import dataExporter from '@/utils/data-exporter';
 import WorkflowEngine from '../workflow-engine/engine';
 import blocksHandler from '../workflow-engine/blocks-handler';
-import dataExporter from '@/utils/data-exporter';
 
 export function workflow(flow) {
   return new Promise((resolve, reject) => {

+ 2 - 2
src/background/index.js

@@ -1,14 +1,14 @@
 import browser from 'webextension-polyfill';
 import { MessageListener } from '@/utils/message';
-import { registerSpecificDay } from '../utils/workflow-trigger';
 import { parseJSON, findTriggerBlock } from '@/utils/helper';
 import getFile from '@/utils/get-file';
+import decryptFlow, { getWorkflowPass } from '@/utils/decrypt-flow';
+import { registerSpecificDay } from '../utils/workflow-trigger';
 import WorkflowState from './workflow-state';
 import CollectionEngine from './collection-engine';
 import WorkflowEngine from './workflow-engine/engine';
 import blocksHandler from './workflow-engine/blocks-handler';
 import WorkflowLogger from './workflow-logger';
-import decryptFlow, { getWorkflowPass } from '@/utils/decrypt-flow';
 
 const validateUrl = (str) => str?.startsWith('http');
 const storage = {

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

@@ -40,6 +40,13 @@ async function activeTab(block) {
     };
     this.windowId = tab.windowId;
 
+    if (this.preloadScripts.length > 0) {
+      const preloadScripts = this.preloadScripts.map((script) =>
+        this._sendMessageToTab(script)
+      );
+      await Promise.allSettled(preloadScripts);
+    }
+
     return data;
   } catch (error) {
     console.error(error);

+ 1 - 1
src/background/workflow-engine/blocks-handler/handler-browser-event.js

@@ -1,6 +1,6 @@
 import browser from 'webextension-polyfill';
-import { getBlockConnection } from '../helper';
 import { isWhitespace } from '@/utils/helper';
+import { getBlockConnection } from '../helper';
 
 function handleEventListener(target, validate) {
   return (data, activeTab) => {

+ 1 - 1
src/background/workflow-engine/blocks-handler/handler-clipboard.js

@@ -21,7 +21,7 @@ export default async function ({ data, outputs }) {
     const copiedText = textarea.value;
 
     if (data.assignVariable) {
-      this.referenceData.variables[data.variableName] = copiedText;
+      this.setVariable(data.variableName, copiedText);
     }
     if (data.saveData) {
       this.addDataToColumn(data.dataColumn, copiedText);

+ 52 - 19
src/background/workflow-engine/blocks-handler/handler-conditions.js

@@ -1,28 +1,60 @@
-import { getBlockConnection } from '../helper';
 import compareBlockValue from '@/utils/compare-block-value';
 import mustacheReplacer from '@/utils/reference-data/mustache-replacer';
+import testConditions from '@/utils/test-conditions';
+import { getBlockConnection } from '../helper';
 
-function conditions({ data, outputs }, { prevBlockData, refData }) {
-  return new Promise((resolve, reject) => {
-    if (data.conditions.length === 0) {
-      reject(new Error('conditions-empty'));
-      return;
-    }
+async function conditions({ data, outputs }, { prevBlockData, refData }) {
+  if (data.conditions.length === 0) {
+    throw new Error('conditions-empty');
+  }
+
+  let resultData = '';
+  let isConditionMatch = false;
+  let outputIndex = data.conditions.length + 1;
+
+  const replacedValue = {};
+  const condition = data.conditions[0];
+  const prevData = Array.isArray(prevBlockData)
+    ? prevBlockData[0]
+    : prevBlockData;
+
+  if (condition && condition.conditions) {
+    const conditionPayload = {
+      refData,
+      activeTab: this.activeTab.id,
+      sendMessage: (payload) =>
+        this._sendMessageToTab({ ...payload, isBlock: false }),
+    };
+
+    for (let index = 0; index < data.conditions.length; index += 1) {
+      const result = await testConditions(
+        data.conditions[index].conditions,
+        conditionPayload
+      );
 
-    let resultData = '';
-    let isConditionMatch = false;
-    let outputIndex = data.conditions.length + 1;
-    const prevData = Array.isArray(prevBlockData)
-      ? prevBlockData[0]
-      : prevBlockData;
+      Object.assign(replacedValue, result?.replacedValue || {});
 
+      if (result.isMatch) {
+        isConditionMatch = true;
+        outputIndex = index + 1;
+
+        break;
+      }
+    }
+  } else {
     data.conditions.forEach(({ type, value, compareValue }, index) => {
       if (isConditionMatch) return;
 
       const firstValue = mustacheReplacer(compareValue ?? prevData, refData);
       const secondValue = mustacheReplacer(value, refData);
 
-      const isMatch = compareBlockValue(type, firstValue, secondValue);
+      Object.assign(replacedValue, firstValue.list, secondValue.list);
+
+      const isMatch = compareBlockValue(
+        type,
+        firstValue.value,
+        secondValue.value
+      );
 
       if (isMatch) {
         resultData = value;
@@ -30,12 +62,13 @@ function conditions({ data, outputs }, { prevBlockData, refData }) {
         isConditionMatch = true;
       }
     });
+  }
 
-    resolve({
-      data: resultData,
-      nextBlockId: getBlockConnection({ outputs }, outputIndex),
-    });
-  });
+  return {
+    replacedValue,
+    data: resultData,
+    nextBlockId: getBlockConnection({ outputs }, outputIndex),
+  };
 }
 
 export default conditions;

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

@@ -1,8 +1,8 @@
 import browser from 'webextension-polyfill';
-import WorkflowEngine from '../engine';
-import { getBlockConnection } from '../helper';
 import { isWhitespace, parseJSON } from '@/utils/helper';
 import decryptFlow, { getWorkflowPass } from '@/utils/decrypt-flow';
+import WorkflowEngine from '../engine';
+import { getBlockConnection } from '../helper';
 
 function workflowListener(workflow, options) {
   return new Promise((resolve, reject) => {

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

@@ -1,6 +1,6 @@
 import browser from 'webextension-polyfill';
-import { getBlockConnection } from '../helper';
 import { default as dataExporter, files } from '@/utils/data-exporter';
+import { getBlockConnection } from '../helper';
 
 async function exportData({ data, outputs }) {
   const nextBlockId = getBlockConnection({ outputs });

+ 1 - 1
src/background/workflow-engine/blocks-handler/handler-google-sheets.js

@@ -1,4 +1,3 @@
-import { getBlockConnection } from '../helper';
 import { googleSheets } from '@/utils/api';
 import {
   convert2DArrayToArrayObj,
@@ -6,6 +5,7 @@ import {
   isWhitespace,
   parseJSON,
 } from '@/utils/helper';
+import { getBlockConnection } from '../helper';
 
 async function getSpreadsheetValues({ spreadsheetId, range, firstRowAsKey }) {
   const response = await googleSheets.getValues({ spreadsheetId, range });

+ 1 - 1
src/background/workflow-engine/blocks-handler/handler-handle-download.js

@@ -51,7 +51,7 @@ function handleDownload({ data, outputs }) {
         this.addDataToColumn(data.dataColumn, currentFilename);
       }
       if (data.assignVariable) {
-        this.referenceData.variables[data.variableName] = currentFilename;
+        this.setVariable(data.variableName, currentFilename);
       }
 
       clearTimeout(timeout);

+ 8 - 3
src/background/workflow-engine/blocks-handler/handler-insert-data.js

@@ -1,21 +1,26 @@
-import { getBlockConnection } from '../helper';
 import { parseJSON } from '@/utils/helper';
 import mustacheReplacer from '@/utils/reference-data/mustache-replacer';
+import { getBlockConnection } from '../helper';
 
 function insertData({ outputs, data }, { refData }) {
   return new Promise((resolve) => {
+    const replacedValueList = {};
     data.dataList.forEach(({ name, value, type }) => {
       const replacedValue = mustacheReplacer(value, refData);
-      const realValue = parseJSON(replacedValue, replacedValue);
+      const realValue = parseJSON(replacedValue.value, replacedValue.value);
+
+      Object.assign(replacedValueList, replacedValue.list);
 
       if (type === 'table') {
         this.addDataToColumn(name, realValue);
       } else {
-        this.referenceData.variables[name] = realValue;
+        this.setVariable(name, realValue);
       }
     });
 
     resolve({
+      data: '',
+      replacedValue: replacedValueList,
       nextBlockId: getBlockConnection({ outputs }),
     });
   });

+ 3 - 30
src/background/workflow-engine/blocks-handler/handler-interaction-block.js

@@ -24,24 +24,13 @@ async function checkAccess(blockName) {
   return true;
 }
 
-async function interactionHandler(block, { refData }) {
+async function interactionHandler(block) {
   await checkAccess(block.name);
 
-  const { executedBlockOnWeb, debugMode } = this.workflow.settings;
-
   const nextBlockId = getBlockConnection(block);
-  const messagePayload = {
-    ...block,
-    debugMode,
-    executedBlockOnWeb,
-    activeTabId: this.activeTab.id,
-    frameSelector: this.frameSelector,
-  };
-
-  if (block.name === 'javascript-code') messagePayload.refData = refData;
 
   try {
-    const data = await this._sendMessageToTab(messagePayload, {
+    const data = await this._sendMessageToTab(block, {
       frameId: this.activeTab.frameId || 0,
     });
 
@@ -75,23 +64,7 @@ async function interactionHandler(block, { refData }) {
     }
 
     if (block.data.assignVariable) {
-      this.referenceData.variables[block.data.variableName] = data;
-    }
-
-    if (block.name === 'javascript-code') {
-      if (data?.variables) {
-        Object.keys(data.variables).forEach((varName) => {
-          this.referenceData.variables[varName] = data.variables[varName];
-        });
-      }
-
-      if (data?.columns.insert) {
-        const params = Array.isArray(data.columns.data)
-          ? data.columns.data
-          : [data.columns.data];
-
-        this.addDataToColumn(params);
-      }
+      this.setVariable(block.data.variableName, data);
     }
 
     return {

+ 50 - 0
src/background/workflow-engine/blocks-handler/handler-javascript-code.js

@@ -0,0 +1,50 @@
+import { getBlockConnection } from '../helper';
+
+export async function javascriptCode({ outputs, data, ...block }, { refData }) {
+  const nextBlockId = getBlockConnection({ outputs });
+
+  try {
+    if (data.everyNewTab) {
+      const isScriptExist = this.preloadScripts.find(
+        ({ id }) => id === block.id
+      );
+
+      if (!isScriptExist) {
+        this.preloadScripts.push({ ...block, data });
+      }
+    }
+    if (!this.activeTab.id) {
+      if (!data.everyNewTab) {
+        throw new Error('no-tab');
+      } else {
+        return { data: '', nextBlockId };
+      }
+    }
+
+    const result = await this._sendMessageToTab({ ...block, data, refData });
+
+    if (result?.variables) {
+      Object.keys(result.variables).forEach((varName) => {
+        this.setVariable(varName, result.variables[varName]);
+      });
+    }
+    if (result?.columns.insert) {
+      const params = Array.isArray(result.columns.data)
+        ? result.columns.data
+        : [result.columns.data];
+
+      this.addDataToColumn(params);
+    }
+
+    return {
+      nextBlockId,
+      data: result?.columns.data || {},
+    };
+  } catch (error) {
+    error.nextBlockId = nextBlockId;
+
+    throw error;
+  }
+}
+
+export default javascriptCode;

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

@@ -1,5 +1,5 @@
-import { getBlockConnection } from '../helper';
 import { parseJSON } from '@/utils/helper';
+import { getBlockConnection } from '../helper';
 
 async function loopData({ data, id, outputs }) {
   const nextBlockId = getBlockConnection({ outputs });

+ 8 - 1
src/background/workflow-engine/blocks-handler/handler-new-tab.js

@@ -1,10 +1,10 @@
 import browser from 'webextension-polyfill';
+import { isWhitespace, sleep } from '@/utils/helper';
 import {
   getBlockConnection,
   attachDebugger,
   sendDebugCommand,
 } from '../helper';
-import { isWhitespace, sleep } from '@/utils/helper';
 
 async function newTab(block) {
   if (this.windowId) {
@@ -84,6 +84,13 @@ async function newTab(block) {
       chrome.debugger.detach({ tabId: tab.id });
     }
 
+    if (this.preloadScripts.length > 0) {
+      const preloadScripts = this.preloadScripts.map((script) =>
+        this._sendMessageToTab(script)
+      );
+      await Promise.allSettled(preloadScripts);
+    }
+
     return {
       data: url,
       nextBlockId,

+ 7 - 0
src/background/workflow-engine/blocks-handler/handler-switch-tab.js

@@ -41,6 +41,13 @@ export default async function ({ data, outputs }) {
   this.activeTab.url = tab.url;
   this.windowId = tab.windowId;
 
+  if (this.preloadScripts.length > 0) {
+    const preloadScripts = this.preloadScripts.map((script) =>
+      this._sendMessageToTab(script)
+    );
+    await Promise.allSettled(preloadScripts);
+  }
+
   return {
     nextBlockId,
     data: tab.url,

+ 2 - 3
src/background/workflow-engine/blocks-handler/handler-take-screenshot.js

@@ -1,6 +1,6 @@
 import browser from 'webextension-polyfill';
-import { getBlockConnection } from '../helper';
 import { fileSaver } from '@/utils/helper';
+import { getBlockConnection } from '../helper';
 
 function saveImage({ fileName, uri, ext }) {
   const image = new Image();
@@ -35,8 +35,7 @@ async function takeScreenshot({ data, outputs, name }) {
       if (data.saveToColumn) this.addDataToColumn(data.dataColumn, dataUrl);
       if (saveToComputer)
         saveImage({ fileName: data.fileName, uri: dataUrl, ext: data.ext });
-      if (data.assignVariable)
-        this.referenceData.variables[data.variableName] = dataUrl;
+      if (data.assignVariable) this.setVariable(data.variableName, dataUrl);
     };
 
     if (data.captureActiveTab) {

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

@@ -1,7 +1,7 @@
 import objectPath from 'object-path';
-import { getBlockConnection } from '../helper';
 import { isWhitespace } from '@/utils/helper';
 import { executeWebhook } from '@/utils/webhookUtil';
+import { getBlockConnection } from '../helper';
 
 export async function webhook({ data, outputs }) {
   const nextBlockId = getBlockConnection({ outputs });
@@ -42,7 +42,7 @@ export async function webhook({ data, outputs }) {
     }
 
     if (data.assignVariable) {
-      this.referenceData.variables[data.variableName] = returnData;
+      this.setVariable(data.variableName, returnData);
     }
     if (data.saveData) {
       this.addDataToColumn(data.dataColumn, returnData);

+ 21 - 0
src/background/workflow-engine/blocks-handler/handler-while-loop.js

@@ -0,0 +1,21 @@
+import testConditions from '@/utils/test-conditions';
+import { getBlockConnection } from '../helper';
+
+async function whileLoop({ data, outputs }, { refData }) {
+  const conditionPayload = {
+    refData,
+    activeTab: this.activeTab.id,
+    sendMessage: (payload) =>
+      this._sendMessageToTab({ ...payload, isBlock: false }),
+  };
+  const result = await testConditions(data.conditions, conditionPayload);
+  const nextBlockId = getBlockConnection({ outputs }, result.isMatch ? 1 : 2);
+
+  return {
+    data: '',
+    nextBlockId,
+    replacedValue: result?.replacedValue || {},
+  };
+}
+
+export default whileLoop;

+ 70 - 13
src/background/workflow-engine/engine.js

@@ -1,7 +1,6 @@
 import browser from 'webextension-polyfill';
 import { nanoid } from 'nanoid';
 import { tasks } from '@/utils/shared';
-import { convertData, waitTabLoaded } from './helper';
 import {
   toCamelCase,
   sleep,
@@ -10,6 +9,7 @@ import {
   objectHasKey,
 } from '@/utils/helper';
 import referenceData from '@/utils/reference-data';
+import { convertData, waitTabLoaded } from './helper';
 import executeContentScript from './execute-content-script';
 
 class WorkflowEngine {
@@ -39,7 +39,9 @@ class WorkflowEngine {
     this.blocks = {};
     this.history = [];
     this.columnsId = {};
+    this.historyCtxData = {};
     this.eventListeners = {};
+    this.preloadScripts = [];
     this.columns = { column: { index: 0, name: 'column', type: 'any' } };
 
     let variables = {};
@@ -96,6 +98,7 @@ class WorkflowEngine {
     this.isUsingProxy = false;
 
     this.history = [];
+    this.preloadScripts = [];
     this.columns = { column: { index: 0, name: 'column', type: 'any' } };
 
     this.activeTab = {
@@ -204,6 +207,29 @@ class WorkflowEngine {
     )
       return;
 
+    const historyId = nanoid();
+    detail.id = historyId;
+
+    if (
+      detail.replacedValue ||
+      (tasks[detail.name]?.refDataKeys && this.saveLog)
+    ) {
+      const { activeTabUrl, loopData, prevBlockData } = JSON.parse(
+        JSON.stringify(this.referenceData)
+      );
+
+      this.historyCtxData[historyId] = {
+        referenceData: {
+          loopData,
+          activeTabUrl,
+          prevBlockData,
+        },
+        replacedValue: detail.replacedValue,
+      };
+
+      delete detail.replacedValue;
+    }
+
     this.history.push(detail);
   }
 
@@ -236,6 +262,10 @@ class WorkflowEngine {
     currentColumn.index += 1;
   }
 
+  setVariable(name, value) {
+    this.referenceData.variables[name] = value;
+  }
+
   async stop() {
     try {
       if (this.childWorkflowId) {
@@ -285,6 +315,11 @@ class WorkflowEngine {
       if (!this.workflow.isTesting) {
         const { name, id } = this.workflow;
 
+        let { logsCtxData } = await browser.storage.local.get('logsCtxData');
+        if (!logsCtxData) logsCtxData = {};
+        logsCtxData[this.id] = this.historyCtxData;
+        await browser.storage.local.set({ logsCtxData });
+
         await this.logger.add({
           name,
           status,
@@ -312,16 +347,18 @@ class WorkflowEngine {
         currentBlock: this.currentBlock,
       });
 
-      browser.storage.local.set({
-        [`last-state:${this.workflow.id}`]: {
-          columns: this.columns,
-          referenceData: {
-            table: this.referenceData.table,
-            variables: this.referenceData.variables,
-            globalData: this.referenceData.globalData,
+      if (this.workflow.settings.reuseLastState) {
+        browser.storage.local.set({
+          [`last-state:${this.workflow.id}`]: {
+            columns: this.columns,
+            referenceData: {
+              table: this.referenceData.table,
+              variables: this.referenceData.variables,
+              globalData: this.referenceData.globalData,
+            },
           },
-        },
-      });
+        });
+      }
 
       this.isDestroyed = true;
       this.eventListeners = {};
@@ -374,10 +411,15 @@ class WorkflowEngine {
         refData: this.referenceData,
       });
 
+      if (result.replacedValue)
+        replacedBlock.replacedValue = result.replacedValue;
+
       this.addLogHistory({
         name: block.name,
         logId: result.logId,
         type: result.status || 'success',
+        description: block.data.description,
+        replacedValue: replacedBlock.replacedValue,
         duration: Math.round(Date.now() - startExecuteTime),
       });
 
@@ -398,6 +440,8 @@ class WorkflowEngine {
         type: 'error',
         message: error.message,
         name: block.name,
+        description: block.data.description,
+        replacedValue: replacedBlock.replacedValue,
         ...(error.data || {}),
       });
 
@@ -483,12 +527,25 @@ class WorkflowEngine {
       }
 
       await waitTabLoaded(this.activeTab.id);
-      await executeContentScript(this.activeTab.id, options.frameId || 0);
+      await executeContentScript(
+        this.activeTab.id,
+        this.activeTab.frameId || 0
+      );
+
+      const { executedBlockOnWeb, debugMode } = this.workflow.settings;
+      const messagePayload = {
+        isBlock: true,
+        debugMode,
+        executedBlockOnWeb,
+        activeTabId: this.activeTab.id,
+        frameSelector: this.frameSelector,
+        ...payload,
+      };
 
       const data = await browser.tabs.sendMessage(
         this.activeTab.id,
-        { isBlock: true, ...payload },
-        options
+        messagePayload,
+        { ...options, frameId: this.activeTab.frameId }
       );
 
       return data;

+ 1 - 1
src/components/block/BlockBasic.vue

@@ -32,7 +32,7 @@
         />
       </div>
     </div>
-    <slot></slot>
+    <slot :block="block"></slot>
     <template #prepend>
       <div
         v-if="block.details.id !== 'trigger'"

+ 41 - 0
src/components/block/BlockBasicWithFallback.vue

@@ -0,0 +1,41 @@
+<template>
+  <block-basic v-slot="{ block }" :editor="editor" class="block-with-fallback">
+    <div class="fallback flex items-center pb-2 justify-end">
+      <v-remixicon
+        v-if="block"
+        :title="t(`workflow.blocks.${block.details.id}.fallback`)"
+        name="riInformationLine"
+        size="18"
+      />
+      <span class="ml-1">
+        {{ t('common.fallback') }}
+      </span>
+    </div>
+  </block-basic>
+</template>
+<script setup>
+import { useI18n } from 'vue-i18n';
+import BlockBasic from './BlockBasic.vue';
+
+const { t } = useI18n();
+
+defineProps({
+  editor: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+</script>
+<style>
+.block-with-fallback .block-base__content {
+  padding-bottom: 0;
+}
+.drawflow-node.webhook .outputs,
+.drawflow-node.while-loop .outputs {
+  top: 64%;
+}
+.drawflow-node.webhook .outputs .output_1,
+.drawflow-node.while-loop .outputs .output_1 {
+  margin-bottom: 14px;
+}
+</style>

+ 15 - 12
src/components/block/BlockConditions.vue

@@ -20,18 +20,23 @@
         @click="editBlock"
       />
     </div>
-    <div
+    <ul
       v-if="block.data.conditions && block.data.conditions.length !== 0"
       class="mt-4 space-y-2"
     >
-      <div
+      <li
         v-for="item in block.data.conditions"
         :key="item.id"
-        class="flex items-center justify-end"
+        class="flex items-center flex-1 p-2 bg-box-transparent rounded-lg overflow-hidden w-44"
       >
-        <div
-          class="flex items-center flex-1 p-2 bg-box-transparent rounded-lg overflow-hidden w-44"
+        <p
+          v-if="item.name"
+          class="text-overflow w-full text-right"
+          :title="item.name"
         >
+          {{ item.name }}
+        </p>
+        <template v-else>
           <p class="w-5/12 text-overflow text-right">
             {{ item.compareValue || '_____' }}
           </p>
@@ -41,18 +46,16 @@
           <p class="w-5/12 text-overflow">
             {{ item.value || '_____' }}
           </p>
-        </div>
-      </div>
+        </template>
+      </li>
       <p
         v-if="block.data.conditions && block.data.conditions.length !== 0"
         class="text-right text-gray-600 dark:text-gray-200"
       >
-        <span :title="t('workflow.blocks.conditions.fallbackTitle')">
-          &#9432;
-        </span>
-        {{ t('common.fallback') }}
+        <span title="Fallback"> &#9432; </span>
+        Fallback
       </p>
-    </div>
+    </ul>
     <input class="trigger hidden" @change="onChange" />
   </div>
 </template>

+ 1 - 1
src/components/block/BlockElementExists.vue

@@ -36,9 +36,9 @@
 <script setup>
 import { useI18n } from 'vue-i18n';
 import emitter from '@/lib/mitt';
-import BlockBase from './BlockBase.vue';
 import { useComponentId } from '@/composable/componentId';
 import { useEditorBlock } from '@/composable/editorBlock';
+import BlockBase from './BlockBase.vue';
 
 const props = defineProps({
   editor: {

+ 123 - 0
src/components/newtab/shared/SharedConditionBuilder/ConditionBuilderInputs.vue

@@ -0,0 +1,123 @@
+<template>
+  <div
+    v-for="(item, index) in inputsData"
+    :key="item.id"
+    class="condition-input"
+  >
+    <div
+      v-if="item.category === 'value'"
+      class="space-y-1 flex items-end space-x-2 flex-wrap"
+    >
+      <ui-select
+        :model-value="item.type"
+        @change="updateValueType($event, index)"
+      >
+        <optgroup
+          v-for="(types, label) in filterValueTypes(index)"
+          :key="label"
+          :label="label"
+        >
+          <option v-for="type in types" :key="type.id" :value="type.id">
+            {{ type.name }}
+          </option>
+        </optgroup>
+      </ui-select>
+      <ui-input
+        v-for="(_, name) in item.data"
+        :key="item.id + name + index"
+        v-model="inputsData[index].data[name]"
+        :title="conditionBuilder.inputTypes[name].label"
+        :placeholder="conditionBuilder.inputTypes[name].label"
+        class="flex-1"
+      />
+    </div>
+    <ui-select
+      v-else-if="item.category === 'compare'"
+      :model-value="inputsData[index].type"
+      @change="updateCompareType($event, index)"
+    >
+      <option
+        v-for="type in conditionBuilder.compareTypes"
+        :key="type.id"
+        :value="type.id"
+      >
+        {{ type.name }}
+      </option>
+    </ui-select>
+  </div>
+</template>
+<script setup>
+import { ref, watch } from 'vue';
+import { nanoid } from 'nanoid';
+import { conditionBuilder } from '@/utils/shared';
+
+const props = defineProps({
+  data: {
+    type: Array,
+    default: () => [],
+  },
+});
+const emit = defineEmits(['update']);
+
+const inputsData = ref(JSON.parse(JSON.stringify(props.data)));
+
+function getDefaultValues(items) {
+  const defaultValues = {
+    value: {
+      id: nanoid(),
+      type: 'value',
+      category: 'value',
+      data: { value: '' },
+    },
+    compare: { id: nanoid(), category: 'compare', type: 'eq' },
+  };
+
+  if (typeof items === 'string') return defaultValues[items];
+
+  return items.map((item) => defaultValues[item]);
+}
+function filterValueTypes(index) {
+  const exclude = ['element#visible', 'element#invisible'];
+
+  return conditionBuilder.valueTypes.reduce((acc, item) => {
+    if (index < 1 || !exclude.includes(item.id)) {
+      (acc[item.category] = acc[item.category] || []).push(item);
+    }
+
+    return acc;
+  }, {});
+}
+function updateValueType(newType, index) {
+  const type = conditionBuilder.valueTypes.find(({ id }) => id === newType);
+
+  if (index === 0 && !type.compareable) {
+    inputsData.value.splice(index + 1);
+  } else if (inputsData.value.length === 1) {
+    inputsData.value.push(...getDefaultValues(['compare', 'value']));
+  }
+
+  inputsData.value[index].type = newType;
+  inputsData.value[index].data = { ...type.data };
+}
+function updateCompareType(newType, index) {
+  const { needValue } = conditionBuilder.compareTypes.find(
+    ({ id }) => id === newType
+  );
+
+  if (!needValue) {
+    inputsData.value.splice(index + 1);
+  } else if (inputsData.value.length === 2) {
+    inputsData.value.push(getDefaultValues('value'));
+  }
+
+  inputsData.value[index].type = newType;
+}
+
+watch(
+  inputsData,
+  (value) => {
+    emit('update', value);
+  },
+  { deep: true }
+);
+</script>

+ 220 - 0
src/components/newtab/shared/SharedConditionBuilder/index.vue

@@ -0,0 +1,220 @@
+<template>
+  <div class="space-y-4">
+    <ui-button v-if="conditions.length === 0" @click="addOrCondition">
+      {{ t('workflow.conditionBuilder.add') }}
+    </ui-button>
+    <div v-for="(item, index) in conditions" :key="item.id">
+      <div class="flex relative condition-group">
+        <div
+          v-show="item.conditions.length > 1"
+          class="and-text mr-4 relative mb-12 flex items-center"
+          :class="{ 'add-line': item.conditions.length > 1 }"
+        >
+          <span
+            class="py-1 w-14 text-center text-white dark:text-black rounded-md dark:bg-blue-300 bg-blue-500 inline-block z-10 relative"
+          >
+            {{ t('workflow.conditionBuilder.and') }}
+          </span>
+        </div>
+        <div class="flex-1 space-y-2">
+          <ui-expand
+            v-for="(inputs, inputsIndex) in item.conditions"
+            :key="inputs.id"
+            class="border rounded-lg w-full"
+            header-class="px-4 py-2 w-full flex items-center h-full rounded-lg overflow-hidden group focus:ring-0"
+          >
+            <template #header>
+              <p class="text-overflow flex-1 text-left space-x-2 w-64">
+                <span
+                  v-for="input in inputs.items"
+                  :key="`text-${input.id}`"
+                  :class="[
+                    input.category === 'compare'
+                      ? 'font-semibold'
+                      : 'text-gray-600 dark:text-gray-200',
+                  ]"
+                >
+                  {{ getConditionText(input) }}
+                </span>
+              </p>
+              <v-remixicon
+                name="riDeleteBin7Line"
+                class="ml-4 group-hover:visible invisible"
+                @click.stop="deleteCondition(index, inputsIndex)"
+              />
+            </template>
+            <div class="space-y-2 px-4 py-2">
+              <condition-builder-inputs
+                :data="inputs.items"
+                @update="
+                  conditions[index].conditions[inputsIndex].items = $event
+                "
+              />
+            </div>
+          </ui-expand>
+          <div class="space-x-2 text-sm">
+            <ui-button @click="addAndCondition(index)">
+              <v-remixicon name="riAddLine" class="-ml-2 mr-1" size="20" />
+              {{ t('workflow.conditionBuilder.and') }}
+            </ui-button>
+            <ui-button
+              v-if="index === conditions.length - 1"
+              @click="addOrCondition"
+            >
+              <v-remixicon name="riAddLine" class="-ml-2 mr-1" size="20" />
+              {{ t('workflow.conditionBuilder.or') }}
+            </ui-button>
+          </div>
+        </div>
+      </div>
+      <div
+        v-show="index !== conditions.length - 1"
+        class="text-left or-text relative mt-4"
+      >
+        <span
+          class="line bg-indigo-500 dark:bg-indigo-400 w-full absolute top-1/2 -translate-y-1/2 left-0"
+          style="height: 2px"
+        ></span>
+        <span
+          class="py-1 dark:text-black rounded-md dark:bg-indigo-300 bg-indigo-500 w-14 relative z-10 inline-block text-white text-center"
+        >
+          {{ t('workflow.conditionBuilder.or') }}
+        </span>
+      </div>
+    </div>
+  </div>
+</template>
+<script setup>
+import { ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { nanoid } from 'nanoid';
+import { conditionBuilder } from '@/utils/shared';
+import ConditionBuilderInputs from './ConditionBuilderInputs.vue';
+
+const props = defineProps({
+  modelValue: {
+    type: Array,
+    default: () => [],
+  },
+});
+const emit = defineEmits(['update:modelValue', 'change']);
+
+const { t } = useI18n();
+
+const conditions = ref(JSON.parse(JSON.stringify(props.modelValue)));
+
+// const conditions = ref([
+//   {
+//     id: nanoid(),
+//     conditions: [
+//       {
+//         id: nanoid(),
+//         items: [
+//           { id: nanoid(), type: 'value', category: 'value', data: { value: '' } },
+//           { id: nanoid(), category: 'compare', type: 'eq' },
+//           { id: nanoid(), type: 'value', category: 'value', data: { value: '' } },
+//         ],
+//       },
+//       {
+//         id: nanoid(),
+//         items: [
+//           { id: nanoid(), type: 'value', category: 'value', data: { value: '' } },
+//           { id: nanoid(), category: 'compare', type: 'lt' },
+//           { id: nanoid(), type: 'value', category: 'value', data: { value: '' } },
+//         ]
+//       }
+//     ],
+//   },
+//   {
+//     id: nanoid(),
+//     conditions: [
+//       {
+//         id: nanoid(),
+//         items: [
+//           { id: nanoid(), type: 'value', category: 'value', data: { value: '' } },
+//           { id: nanoid(), category: 'compare', type: 'eq' },
+//           { id: nanoid(), type: 'value', category: 'value', data: { value: '' } },
+//         ],
+//       }
+//     ]
+//   }
+// ]);
+
+function getDefaultValues(items = ['value', 'compare', 'value']) {
+  const defaultValues = {
+    value: {
+      id: nanoid(),
+      type: 'value',
+      category: 'value',
+      data: { value: '' },
+    },
+    compare: { id: nanoid(), category: 'compare', type: 'eq' },
+  };
+
+  if (typeof items === 'string') return defaultValues[items];
+
+  return items.map((item) => defaultValues[item]);
+}
+function getConditionText({ category, type, data }) {
+  if (category === 'compare') {
+    return conditionBuilder.compareTypes.find(({ id }) => id === type).name;
+  }
+
+  let text = '';
+
+  if (type === 'value') {
+    text = data.value || 'Empty';
+  } else if (type.startsWith('element')) {
+    text = type;
+
+    const textDetail = data.attrName || data.selector;
+
+    if (textDetail) text += `(${textDetail})`;
+  }
+
+  return text;
+}
+function addOrCondition() {
+  const newOrCondition = getDefaultValues();
+
+  conditions.value.push({
+    id: nanoid(),
+    conditions: [{ id: nanoid(), items: newOrCondition }],
+  });
+}
+function addAndCondition(index) {
+  const newAndCondition = getDefaultValues();
+
+  conditions.value[index].conditions.push({
+    id: nanoid(),
+    items: newAndCondition,
+  });
+}
+function deleteCondition(index, itemIndex) {
+  const condition = conditions.value[index].conditions;
+
+  condition.splice(itemIndex, 1);
+
+  if (condition.length === 0) conditions.value.splice(index, 1);
+}
+
+watch(
+  conditions,
+  (value) => {
+    emit('change', value);
+    emit('update:modelValue', value);
+  },
+  { deep: true }
+);
+</script>
+<style scoped>
+.and-text.add-line:before {
+  content: '';
+  position: absolute;
+  top: 0;
+  width: 30px;
+  height: 100%;
+  left: 50%;
+  @apply dark:border-blue-400 border-blue-500 border-2 border-r-0 rounded-bl-lg rounded-tl-lg;
+}
+</style>

+ 1 - 1
src/components/newtab/workflow/edit/EditClipboard.vue

@@ -24,8 +24,8 @@
 </template>
 <script setup>
 import { useI18n } from 'vue-i18n';
-import InsertWorkflowData from './InsertWorkflowData.vue';
 import { useHasPermissions } from '@/composable/hasPermissions';
+import InsertWorkflowData from './InsertWorkflowData.vue';
 
 const props = defineProps({
   data: {

+ 106 - 51
src/components/newtab/workflow/edit/EditConditions.vue

@@ -8,55 +8,65 @@
     >
       {{ t('workflow.blocks.conditions.add') }}
     </ui-button>
-    <ul class="space-y-2">
-      <li
-        v-for="(condition, index) in conditions"
-        :key="index"
-        class="relative rounded-lg bg-input transition-colors group"
+    <ui-list class="space-y-1">
+      <ui-list-item
+        v-for="(item, index) in conditions"
+        :key="item.id"
+        class="group"
       >
-        <input
-          v-model="condition.compareValue"
-          type="text"
-          placeholder="value"
-          class="py-2 px-4 w-full transition rounded-lg bg-transparent"
+        <v-remixicon name="riGuideLine" size="20" class="mr-2 -ml-1" />
+        <p class="flex-1 text-overflow" :title="item.name">
+          {{ item.name }}
+        </p>
+        <v-remixicon
+          class="cursor-pointer group-hover:visible invisible"
+          name="riPencilLine"
+          size="20"
+          @click="editCondition(index)"
         />
-        <button
-          class="bg-white dark:bg-gray-700 absolute top-1/2 right-4 p-2 rounded-lg -translate-y-1/2 group-hover:right-14"
+        <v-remixicon
+          name="riDeleteBin7Line"
+          size="20"
+          class="ml-2 -mr-1 cursor-pointer"
           @click="deleteCondition(index)"
-        >
-          <v-remixicon size="20" name="riDeleteBin7Line" />
-        </button>
-        <select
-          v-model="condition.type"
-          :title="getTitle(index)"
-          class="bg-white dark:bg-gray-700 absolute right-4 font-mono z-10 p-2 top-1/2 leading-tight -translate-y-1/2 text-center transition rounded-lg appearance-none"
-        >
-          <option
-            v-for="(name, type) in conditionTypes"
-            :key="type"
-            :value="type"
-          >
-            {{ type }}
-          </option>
-        </select>
-        <div
-          class="w-full bg-gray-300 dark:bg-gray-700 h-px mx-auto"
-          style="max-width: 89%"
-        ></div>
-        <input
-          v-model="condition.value"
-          type="text"
-          placeholder="value"
-          class="py-2 px-4 w-full transition rounded-lg bg-transparent"
         />
-      </li>
-    </ul>
+      </ui-list-item>
+    </ui-list>
+    <ui-modal v-model="state.showModal" custom-content>
+      <ui-card padding="p-0" class="w-full max-w-3xl">
+        <div class="px-4 pt-4 flex items-center">
+          <p class="flex-1">
+            {{ t('workflow.conditionBuilder.title') }}
+          </p>
+          <v-remixicon
+            name="riCloseLine"
+            class="cursor-pointer"
+            @click="state.showModal = false"
+          />
+        </div>
+        <div
+          class="overflow-auto p-4 mt-4 scroll"
+          style="height: calc(100vh - 8rem)"
+        >
+          <input
+            v-model="conditions[state.conditionsIndex].name"
+            class="text-xl font-semibold mb-4 bg-transparent focus:ring-0"
+          />
+          <shared-condition-builder
+            :model-value="conditions[state.conditionsIndex].conditions"
+            @change="conditions[state.conditionsIndex].conditions = $event"
+          />
+        </div>
+      </ui-card>
+    </ui-modal>
   </div>
 </template>
 <script setup>
-import { ref, watch } from 'vue';
+import { ref, watch, onMounted, shallowReactive } from 'vue';
 import { useI18n } from 'vue-i18n';
+import { nanoid } from 'nanoid';
 import emitter from '@/lib/mitt';
+import SharedConditionBuilder from '@/components/newtab/shared/SharedConditionBuilder/index.vue';
 
 const props = defineProps({
   data: {
@@ -71,22 +81,25 @@ const props = defineProps({
 const emit = defineEmits(['update:data']);
 
 const conditionTypes = {
-  '==': 'equals',
-  '!=': 'ne',
+  '==': 'eq',
+  '!=': 'nq',
   '>': 'gt',
   '>=': 'gte',
   '<': 'lt',
   '<=': 'lte',
-  '()': 'contains',
+  '()': 'cnt',
 };
 const { t } = useI18n();
 
 const conditions = ref(props.data.conditions);
+const state = shallowReactive({
+  showModal: false,
+  conditionsIndex: 0,
+});
 
-function getTitle(index) {
-  const type = conditionTypes[conditions.value[index]?.type] || 'equals';
-
-  return t(`workflow.blocks.conditions.${type}`);
+function editCondition(index) {
+  state.conditionsIndex = index;
+  state.showModal = true;
 }
 function addCondition() {
   if (conditions.value.length >= 10) return;
@@ -95,10 +108,15 @@ function addCondition() {
     id: props.blockId,
   });
 
-  conditions.value.unshift({
-    compareValue: '',
-    value: '',
-    type: '==',
+  conditions.value.push({
+    id: nanoid(),
+    name: `Path ${conditions.value.length + 1}`,
+    conditions: [
+      {
+        id: nanoid(),
+        conditions: [],
+      },
+    ],
   });
 }
 function deleteCondition(index) {
@@ -119,4 +137,41 @@ watch(
   },
   { deep: true }
 );
+
+onMounted(() => {
+  const condition = props.data.conditions[0];
+
+  if (condition && condition.conditions) return;
+
+  const generateConditionItem = (type, data) => {
+    if (type === 'value') {
+      return {
+        id: nanoid(),
+        type: 'value',
+        category: 'value',
+        data: { value: data },
+      };
+    }
+
+    return { id: nanoid(), category: 'compare', type: data };
+  };
+  conditions.value = conditions.value.map((item, index) => {
+    const items = [
+      generateConditionItem('value', item.compareValue),
+      generateConditionItem('compare', conditionTypes[item.type]),
+      generateConditionItem('value', item.value),
+    ];
+
+    return {
+      id: nanoid(),
+      name: `Path ${index + 1}`,
+      conditions: [
+        {
+          id: nanoid(),
+          conditions: [{ id: nanoid(), items }],
+        },
+      ],
+    };
+  });
+});
 </script>

+ 33 - 20
src/components/newtab/workflow/edit/EditJavascriptCode.vue

@@ -8,6 +8,7 @@
       @change="updateData({ description: $event })"
     />
     <ui-input
+      v-if="!data.everyNewTab"
       :model-value="data.timeout"
       :label="t('workflow.blocks.javascript-code.timeout.placeholder')"
       :title="t('workflow.blocks.javascript-code.timeout.title')"
@@ -24,6 +25,13 @@
       @click="state.showCodeModal = true"
       v-text="data.code"
     />
+    <ui-checkbox
+      :model-value="data.everyNewTab"
+      class="mt-2"
+      @change="updateData({ everyNewTab: $event })"
+    >
+      {{ t('workflow.blocks.javascript-code.everyNewTab') }}
+    </ui-checkbox>
     <ui-modal v-model="state.showCodeModal" content-class="max-w-3xl">
       <template #header>
         <ui-tabs v-model="state.activeTab" class="border-none">
@@ -44,28 +52,30 @@
           <shared-codemirror
             v-model="state.code"
             :extensions="codemirrorExts"
+            :style="{ height: data.everyNewTab ? '100%' : '87%' }"
             class="overflow-auto"
-            style="height: 87%"
           />
-          <p class="mt-1 text-sm">
-            {{ t('workflow.blocks.javascript-code.availabeFuncs') }}
-          </p>
-          <p
-            class="space-x-1 whitespace-nowrap overflow-x-auto overflow-y-hidden pb-1 scroll"
-          >
-            <a
-              v-for="func in availableFuncs"
-              :key="func.id"
-              :href="`https://docs.automa.site/blocks/javascript-code.html#${func.id}`"
-              target="_blank"
-              rel="noopener"
-              class="inline-block"
+          <template v-if="!data.everyNewTab">
+            <p class="mt-1 text-sm">
+              {{ t('workflow.blocks.javascript-code.availabeFuncs') }}
+            </p>
+            <p
+              class="space-x-1 whitespace-nowrap overflow-x-auto overflow-y-hidden pb-1 scroll"
             >
-              <code>
-                {{ func.name }}
-              </code>
-            </a>
-          </p>
+              <a
+                v-for="func in availableFuncs"
+                :key="func.id"
+                :href="`https://docs.automa.site/blocks/javascript-code.html#${func.id}`"
+                target="_blank"
+                rel="noopener"
+                class="inline-block"
+              >
+                <code>
+                  {{ func.name }}
+                </code>
+              </a>
+            </p>
+          </template>
         </ui-tab-panel>
         <ui-tab-panel value="preloadScript">
           <div
@@ -83,7 +93,10 @@
               placeholder="http://example.com/script.js"
               class="flex-1 mr-4"
             />
-            <ui-checkbox v-model="state.preloadScripts[index].removeAfterExec">
+            <ui-checkbox
+              v-if="!data.everyNewTab"
+              v-model="state.preloadScripts[index].removeAfterExec"
+            >
               {{ t('workflow.blocks.javascript-code.removeAfterExec') }}
             </ui-checkbox>
           </div>

+ 108 - 0
src/components/newtab/workflow/edit/EditWhileLoop.vue

@@ -0,0 +1,108 @@
+<template>
+  <div>
+    <ui-textarea
+      :model-value="data.description"
+      :placeholder="t('common.description')"
+      class="w-full mb-1"
+      @change="updateData({ description: $event })"
+    />
+    <ui-button
+      variant="accent"
+      class="w-full mt-4"
+      @click="showConditionBuilder = true"
+    >
+      {{ t('workflow.blocks.while-loop.editCondition') }}
+    </ui-button>
+    <ui-modal v-model="showConditionBuilder" custom-content>
+      <ui-card padding="p-0" class="w-full max-w-3xl">
+        <div class="px-4 pt-4 flex items-center">
+          <p class="flex-1">
+            {{ t('workflow.conditionBuilder.title') }}
+          </p>
+          <v-remixicon
+            name="riCloseLine"
+            class="cursor-pointer"
+            @click="showConditionBuilder = false"
+          />
+        </div>
+        <shared-condition-builder
+          :model-value="data.conditions"
+          class="overflow-auto p-4 mt-4 scroll"
+          style="height: calc(100vh - 8rem)"
+          @change="updateData({ conditions: $event })"
+        />
+      </ui-card>
+    </ui-modal>
+  </div>
+</template>
+<script setup>
+import { onMounted, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { nanoid } from 'nanoid';
+import SharedConditionBuilder from '@/components/newtab/shared/SharedConditionBuilder/index.vue';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const { t } = useI18n();
+const defaultConditions = () => [
+  {
+    id: nanoid(),
+    conditions: [
+      {
+        id: nanoid(),
+        items: [
+          {
+            id: nanoid(),
+            type: 'value',
+            category: 'value',
+            data: { value: '' },
+          },
+          { id: nanoid(), category: 'compare', type: 'eq' },
+          {
+            id: nanoid(),
+            type: 'value',
+            category: 'value',
+            data: { value: '' },
+          },
+        ],
+      },
+      {
+        id: nanoid(),
+        items: [
+          {
+            id: nanoid(),
+            type: 'value',
+            category: 'value',
+            data: { value: '' },
+          },
+          { id: nanoid(), category: 'compare', type: 'eq' },
+          {
+            id: nanoid(),
+            type: 'value',
+            category: 'value',
+            data: { value: '' },
+          },
+        ],
+      },
+    ],
+  },
+];
+
+const showConditionBuilder = ref(false);
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+
+onMounted(() => {
+  if (props.data.conditions === null) {
+    updateData({ conditions: defaultConditions() });
+  }
+});
+</script>

+ 169 - 0
src/components/ui/UiAutocomplete.vue

@@ -0,0 +1,169 @@
+<template>
+  <ui-popover
+    v-model="state.showPopover"
+    trigger-width
+    trigger="manual"
+    :padding="`p-2 max-h-56 overflow-auto scroll ${componentId}`"
+  >
+    <template #trigger>
+      <ui-input
+        v-bind="{ modelValue, placeholder, label, prependIcon }"
+        autocomplete="off"
+        @focus="state.showPopover = true"
+        @blur="state.showPopover = false"
+        @keydown="handleKeydown"
+        @change="updateValue"
+        @keyup.enter="selectItem(state.activeIndex)"
+        @keyup.esc="state.showPopover = false"
+      />
+    </template>
+    <p v-if="filteredItems.length === 0" class="text-center">No data to show</p>
+    <ui-list v-else class="space-y-1">
+      <ui-list-item
+        v-for="(item, index) in filteredItems"
+        :id="`list-item-${index}`"
+        :key="getItem(item)"
+        :class="{ 'bg-box-transparent': state.activeIndex === index }"
+        class="cursor-pointer"
+        @mousedown="selectItem(index)"
+        @mouseenter="state.activeIndex = index"
+      >
+        <slot name="item" :item="item">
+          {{ getItem(item) }}
+        </slot>
+      </ui-list-item>
+    </ui-list>
+  </ui-popover>
+</template>
+<script setup>
+import { computed, onMounted, shallowReactive, watch } from 'vue';
+import { useComponentId } from '@/composable/componentId';
+import { debounce } from '@/utils/helper';
+
+const props = defineProps({
+  modelValue: {
+    type: String,
+    default: '',
+  },
+  items: {
+    type: Array,
+    default: () => [],
+  },
+  itemKey: {
+    type: String,
+    default: '',
+  },
+  label: {
+    type: String,
+    default: '',
+  },
+  placeholder: {
+    type: String,
+    default: '',
+  },
+  prependIcon: {
+    type: String,
+    default: '',
+  },
+});
+const emit = defineEmits(['update:modelValue', 'change']);
+
+const componentId = useComponentId('autocomplete');
+
+const state = shallowReactive({
+  activeIndex: -1,
+  showPopover: false,
+  inputChanged: false,
+});
+
+const getItem = (item) => item[props.itemLabel] || item;
+
+const filteredItems = computed(() =>
+  props.items.filter(
+    (item) =>
+      !state.inputChanged ||
+      getItem(item)
+        ?.toLocaleLowerCase()
+        .includes(props.modelValue.toLocaleLowerCase())
+  )
+);
+
+function handleKeydown(event) {
+  if (!state.showPopover) state.showPopover = true;
+
+  const itemsLength = filteredItems.value.length - 1;
+
+  if (event.key === 'ArrowUp') {
+    if (state.activeIndex <= 0) state.activeIndex = itemsLength;
+    else state.activeIndex -= 1;
+
+    event.preventDefault();
+  } else if (event.key === 'ArrowDown') {
+    if (state.activeIndex >= itemsLength) state.activeIndex = 0;
+    else state.activeIndex += 1;
+
+    event.preventDefault();
+  }
+}
+function checkInView(container, element, partial = false) {
+  const cTop = container.scrollTop;
+  const cBottom = cTop + container.clientHeight;
+
+  const eTop = element.offsetTop;
+  const eBottom = eTop + element.clientHeight;
+
+  const isTotal = eTop >= cTop && eBottom <= cBottom;
+  const isPartial =
+    partial &&
+    ((eTop < cTop && eBottom > cTop) || (eBottom > cBottom && eTop < cBottom));
+
+  return isTotal || isPartial;
+}
+function updateValue(value) {
+  if (!state.showPopover) state.showPopover = true;
+
+  state.inputChanged = true;
+
+  emit('change', value);
+  emit('update:modelValue', value);
+}
+function selectItem(index) {
+  const selectedItem = filteredItems.value[index];
+
+  if (!selectedItem) return;
+
+  updateValue(getItem(selectedItem));
+  state.showPopover = false;
+}
+
+watch(
+  () => state.activeIndex,
+  debounce((activeIndex) => {
+    const container = document.querySelector(`.${componentId}`);
+    const element = container.querySelector(`#list-item-${activeIndex}`);
+
+    if (element && !checkInView(container, element)) {
+      element.scrollIntoView({
+        block: 'nearest',
+        behavior: 'smooth',
+      });
+    }
+  }, 100)
+);
+watch(
+  () => state.showPopover,
+  (value) => {
+    if (!value) state.inputChanged = false;
+  }
+);
+
+onMounted(() => {
+  if (props.modelValue) {
+    const activeIndex = props.items(
+      (item) => getItem(item) === props.modelValue
+    );
+
+    if (activeIndex !== -1) state.activeIndex = activeIndex;
+  }
+});
+</script>

+ 16 - 2
src/components/ui/UiExpand.vue

@@ -1,13 +1,22 @@
 <template>
   <div :aria-expanded="show" class="ui-expand">
-    <button :class="headerClass" @click="toggleExpand">
+    <button
+      :class="[headerClass, { [headerActiveClass]: show }]"
+      @click="toggleExpand"
+    >
       <v-remixicon
-        v-if="!hideHeaderIcon"
+        v-if="!hideHeaderIcon && !appendIcon"
         :rotate="show ? 90 : -90"
         name="riArrowLeftSLine"
         class="mr-2 transition-transform -ml-1"
       />
       <slot v-bind="{ show }" name="header" />
+      <v-remixicon
+        v-if="appendIcon"
+        :rotate="show ? 90 : -90"
+        name="riArrowLeftSLine"
+        class="mr-2 transition-transform -ml-1"
+      />
     </button>
     <transition-expand>
       <div v-if="show" :class="panelClass" class="ui-expand__panel">
@@ -32,10 +41,15 @@ const props = defineProps({
     type: String,
     default: 'px-4 py-2 w-full flex items-center h-full',
   },
+  headerActiveClass: {
+    type: String,
+    default: '',
+  },
   hideHeaderIcon: {
     type: Boolean,
     default: false,
   },
+  appendIcon: Boolean,
 });
 const emit = defineEmits(['update:modelValue']);
 

+ 8 - 1
src/components/ui/UiInput.vue

@@ -20,6 +20,7 @@
           readonly: disabled || readonly || null,
           placeholder,
           type,
+          autocomplete,
           autofocus,
           min,
           max,
@@ -38,7 +39,9 @@
         :value="modelValue"
         class="py-2 px-4 rounded-lg w-full bg-input bg-transparent transition"
         @keydown="$emit('keydown', $event)"
+        @keyup="$emit('keyup', $event)"
         @blur="$emit('blur', $event)"
+        @focus="$emit('focus', $event)"
         @input="emitValue"
       />
       <slot name="append" />
@@ -101,8 +104,12 @@ export default {
       type: [String, Number],
       default: null,
     },
+    autocomplete: {
+      type: String,
+      default: null,
+    },
   },
-  emits: ['update:modelValue', 'change', 'keydown', 'blur'],
+  emits: ['update:modelValue', 'change', 'keydown', 'blur', 'keyup', 'focus'],
   setup(props, { emit }) {
     const componentId = useComponentId('ui-input');
 

+ 6 - 1
src/components/ui/UiModal.vue

@@ -7,7 +7,7 @@
       <transition name="modal" mode="out-in">
         <div
           v-if="show"
-          class="bg-black p-5 overflow-y-auto bg-opacity-20 dark:bg-opacity-60 modal-ui__content-container z-50 flex justify-center items-end md:items-center"
+          class="bg-black overflow-y-auto bg-opacity-20 dark:bg-opacity-60 modal-ui__content-container z-50 flex justify-center items-end md:items-center"
           :style="{ 'backdrop-filter': blur && 'blur(2px)' }"
           @click.self="closeModal"
         >
@@ -15,6 +15,7 @@
           <ui-card
             v-else
             class="modal-ui__content shadow-lg w-full"
+            :padding="padding"
             :class="[contentClass]"
           >
             <div class="mb-4">
@@ -59,6 +60,10 @@ export default {
       type: String,
       default: '',
     },
+    padding: {
+      type: String,
+      default: 'p-4',
+    },
     customContent: Boolean,
     persist: Boolean,
     blur: Boolean,

+ 1 - 1
src/content/blocks-handler/handler-event-click.js

@@ -1,5 +1,5 @@
-import handleSelector from '../handle-selector';
 import { sendMessage } from '@/utils/message';
+import handleSelector from '../handle-selector';
 
 function eventClick(block) {
   return new Promise((resolve, reject) => {

+ 1 - 1
src/content/blocks-handler/handler-forms.js

@@ -1,5 +1,5 @@
-import handleSelector, { markElement } from '../handle-selector';
 import handleFormElement from '@/utils/handle-form-element';
+import handleSelector, { markElement } from '../handle-selector';
 
 async function forms(block) {
   const { data } = block;

+ 1 - 1
src/content/blocks-handler/handler-hover-element.js

@@ -1,5 +1,5 @@
-import handleSelector from '../handle-selector';
 import { sendMessage } from '@/utils/message';
+import handleSelector from '../handle-selector';
 
 function eventClick(block) {
   return new Promise((resolve, reject) => {

+ 40 - 26
src/content/blocks-handler/handler-javascript-code.js

@@ -1,7 +1,7 @@
 import { sendMessage } from '@/utils/message';
 
-function getAutomaScript(blockId) {
-  return `
+function getAutomaScript(blockId, everyNewTab) {
+  const str = `
 function automaSetVariable(name, value) {
   const data = JSON.parse(sessionStorage.getItem('automa--${blockId}')) || null;
 
@@ -43,11 +43,19 @@ function automaRefData(keyword, path = '') {
   return findData(data[keyword], path);
 }
   `;
+
+  if (everyNewTab) return '';
+
+  return str;
 }
 
 function javascriptCode(block) {
-  sessionStorage.setItem(`automa--${block.id}`, JSON.stringify(block.refData));
-  const automaScript = getAutomaScript(block.id);
+  if (!block.data.everyNewTab)
+    sessionStorage.setItem(
+      `automa--${block.id}`,
+      JSON.stringify(block.refData)
+    );
+  const automaScript = getAutomaScript(block.id, block.data.everyNewTab);
 
   return new Promise((resolve, reject) => {
     let documentCtx = document;
@@ -65,10 +73,12 @@ function javascriptCode(block) {
       documentCtx = iframeCtx;
     }
 
-    const isScriptExists = documentCtx.getElementById('automa-custom-js');
     const scriptAttr = `block--${block.id}`;
+    const isScriptExists = documentCtx.querySelector(
+      `.automa-custom-js[${scriptAttr}]`
+    );
 
-    if (isScriptExists && isScriptExists.hasAttribute(scriptAttr)) {
+    if (isScriptExists) {
       resolve('');
       return;
     }
@@ -111,37 +121,41 @@ function javascriptCode(block) {
       }, []);
 
       const script = document.createElement('script');
-      let timeout;
 
       script.setAttribute(scriptAttr, '');
-      script.id = 'automa-custom-js';
+      script.classList.add('automa-custom-js');
       script.innerHTML = `(() => {\n${automaScript} ${block.data.code}\n})()`;
 
-      const cleanUp = (columns = '') => {
-        const storageKey = `automa--${block.id}`;
-        const storageRefData = JSON.parse(sessionStorage.getItem(storageKey));
+      if (!block.data.everyNewTab) {
+        let timeout;
+        const cleanUp = (columns = '') => {
+          const storageKey = `automa--${block.id}`;
+          const storageRefData = JSON.parse(sessionStorage.getItem(storageKey));
 
-        script.remove();
-        preloadScripts.forEach((item) => {
-          if (item.removeAfterExec) item.script.remove();
-        });
+          script.remove();
+          preloadScripts.forEach((item) => {
+            if (item.removeAfterExec) item.script.remove();
+          });
+
+          resolve({ columns, variables: storageRefData?.variables });
+        };
 
-        resolve({ columns, variables: storageRefData?.variables });
-      };
+        window.addEventListener('__automa-next-block__', ({ detail }) => {
+          clearTimeout(timeout);
+          cleanUp(detail || {});
+        });
+        window.addEventListener('__automa-reset-timeout__', () => {
+          clearTimeout(timeout);
 
-      window.addEventListener('__automa-next-block__', ({ detail }) => {
-        clearTimeout(timeout);
-        cleanUp(detail || {});
-      });
-      window.addEventListener('__automa-reset-timeout__', () => {
-        clearTimeout(timeout);
+          timeout = setTimeout(cleanUp, block.data.timeout);
+        });
 
         timeout = setTimeout(cleanUp, block.data.timeout);
-      });
+      } else {
+        resolve();
+      }
 
       documentCtx.body.appendChild(script);
-
-      timeout = setTimeout(cleanUp, block.data.timeout);
     });
   });
 }

+ 1 - 1
src/content/blocks-handler/handler-trigger-event.js

@@ -1,7 +1,7 @@
-import handleSelector from '../handle-selector';
 import { sendMessage } from '@/utils/message';
 import simulateEvent from '@/utils/simulate-event';
 import simulateMouseEvent from '@/utils/simulate-event/mouse-event';
+import handleSelector from '../handle-selector';
 
 const modifiers = {
   altKey: 1,

+ 2 - 2
src/content/element-selector/App.vue

@@ -153,10 +153,10 @@
 import { reactive, ref, watch, inject, nextTick } from 'vue';
 import { getCssSelector } from 'css-selector-generator';
 import { debounce } from '@/utils/helper';
+import findElement from '@/utils/find-element';
 import AppBlocks from './AppBlocks.vue';
 import AppSelector from './AppSelector.vue';
 import AppElementList from './AppElementList.vue';
-import findElement from '@/utils/find-element';
 
 const selectedElement = {
   path: [],
@@ -196,7 +196,7 @@ const getElementSelector = (element) =>
   state.selectorType === 'css'
     ? getCssSelector(element, {
         includeTag: true,
-        blacklist: ['[focused]', /focus/],
+        blacklist: ['[focused]', /focus/, /href/, /src/],
       })
     : generateXPath(element);
 

+ 3 - 3
src/content/element-selector/AppBlocks.vue

@@ -36,14 +36,14 @@
 <script setup>
 import { shallowReactive } from 'vue';
 import { tasks } from '@/utils/shared';
+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';
 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: {

+ 3 - 17
src/content/handle-selector.js

@@ -1,4 +1,5 @@
 import FindElement from '@/utils/find-element';
+import { scrollIfNeeded } from '@/utils/helper';
 
 /* eslint-disable consistent-return */
 
@@ -36,21 +37,6 @@ export function waitForSelector({
   });
 }
 
-function scrollIfNeeded(debugMode, element) {
-  if (!debugMode) return;
-
-  const { top, left, bottom, right } = element.getBoundingClientRect();
-  const isInViewport =
-    top >= 0 &&
-    left >= 0 &&
-    bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
-    right <= (window.innerWidth || document.documentElement.clientWidth);
-
-  if (!isInViewport) {
-    element.scrollIntoView();
-  }
-}
-
 export default async function (
   { data, id, frameSelector, debugMode },
   { onSelected, onError, onSuccess, returnElement }
@@ -109,13 +95,13 @@ export default async function (
       await Promise.allSettled(
         Array.from(element).map((el) => {
           markElement(el, { id, data });
-          scrollIfNeeded(debugMode, el);
+          if (debugMode) scrollIfNeeded(el);
           return onSelected(el);
         })
       );
     } else if (element) {
       markElement(element, { id, data });
-      scrollIfNeeded(debugMode, element);
+      if (debugMode) scrollIfNeeded(element);
       await onSelected(element);
     }
 

+ 32 - 0
src/content/index.js

@@ -4,6 +4,35 @@ import { toCamelCase } from '@/utils/helper';
 import executedBlock from './executed-block';
 import blocksHandler from './blocks-handler';
 
+const elementActions = {
+  text: (element) => element.innerText,
+  visible: (element) => {
+    const { visibility, display } = getComputedStyle(element);
+
+    return visibility !== 'hidden' || display !== 'none';
+  },
+  invisible: (element) => !elementActions.visible(element),
+  attribute: (element, { attrName }) => {
+    if (!element.hasAttribute(attrName)) return null;
+
+    return element.getAttribute(attrName);
+  },
+};
+function handleConditionBuilder({ data, type }) {
+  if (!type.startsWith('element')) return null;
+
+  const element = document.querySelector(data.selector);
+  const { 1: actionType } = type.split('#');
+
+  if (!element) {
+    if (actionType === 'visible' || actionType === 'invisible') return false;
+
+    return null;
+  }
+
+  return elementActions[actionType](element, data);
+}
+
 (() => {
   if (window.isAutomaInjected) return;
 
@@ -36,6 +65,9 @@ import blocksHandler from './blocks-handler';
       }
 
       switch (data.type) {
+        case 'condition-builder':
+          resolve(handleConditionBuilder(data.data));
+          break;
         case 'content-script-exists':
           resolve(true);
           break;

+ 4 - 0
src/lib/v-remixicon.js

@@ -4,6 +4,7 @@ import {
   riH2,
   riLinkM,
   riTwitterLine,
+  riGuideLine,
   riChat3Line,
   riDiscordLine,
   riEarthLine,
@@ -36,6 +37,7 @@ import {
   riWindow2Line,
   riArrowUpDownLine,
   riRefreshLine,
+  riRefreshFill,
   riBook3Line,
   riGithubFill,
   riCodeSSlashLine,
@@ -108,6 +110,7 @@ export const icons = {
   riH2,
   riLinkM,
   riTwitterLine,
+  riGuideLine,
   riChat3Line,
   riDiscordLine,
   riEarthLine,
@@ -140,6 +143,7 @@ export const icons = {
   riWindow2Line,
   riArrowUpDownLine,
   riRefreshLine,
+  riRefreshFill,
   riBook3Line,
   riGithubFill,
   riCodeSSlashLine,

+ 7 - 0
src/locales/en/blocks.json

@@ -360,6 +360,7 @@
         "description": "Execute your javascript code in the web page",
         "availabeFuncs": "Available functions:",
         "removeAfterExec": "Remove after block executed",
+        "everyNewTab": "Execute every new tab",
         "modal": {
           "tabs": {
             "code": "JavaScript code",
@@ -425,6 +426,12 @@
           "response": "Response"
         }
       },
+      "while-loop": {
+        "name": "While loop",
+        "description": "Execute blocks while the condition is met",
+        "editCondition": "Edit condition",
+        "fallback": "Execute when the condition is false"
+      },
       "loop-data": {
         "name": "Loop data",
         "description": "Iterate through table or your custom data",

+ 6 - 0
src/locales/en/newtab.json

@@ -79,6 +79,12 @@
     "clickToEnable": "Click to enable",
     "toggleSidebar": "Toggle sidebar",
     "cantEdit": "Can't edit shared workflow",
+    "conditionBuilder": {
+      "title": "Condition builder",
+      "add": "Add condition",
+      "and": "AND",
+      "or": "OR"
+    },
     "host": {
       "title": "Host workflow",
       "set": "Set as host workflow",

+ 1 - 1
src/models/workflow.js

@@ -1,10 +1,10 @@
 import { Model } from '@vuex-orm/core';
 import { nanoid } from 'nanoid';
 import browser from 'webextension-polyfill';
-import Log from './log';
 import { cleanWorkflowTriggers } from '@/utils/workflow-trigger';
 import { fetchApi } from '@/utils/api';
 import decryptFlow, { getWorkflowPass } from '@/utils/decrypt-flow';
+import Log from './log';
 
 class Workflow extends Model {
   static entity = 'workflows';

+ 0 - 1
src/newtab/App.vue

@@ -208,7 +208,6 @@ window.addEventListener('beforeunload', () => {
 (async () => {
   try {
     const { isFirstTime } = await browser.storage.local.get('isFirstTime');
-
     isUpdated.value = !isFirstTime && compare(currentVersion, prevVersion, '>');
 
     await Promise.allSettled([

+ 64 - 30
src/newtab/pages/logs/[id].vue

@@ -32,37 +32,63 @@
             <v-remixicon name="riArrowLeftLine" class="mr-2" />
             {{ t('log.goBack', { name: collectionLog.name }) }}
           </router-link>
-          <ui-list-item v-for="(item, index) in history" :key="index">
-            <span
-              :class="logsType[item.type]?.color"
-              class="p-1 rounded-lg align-middle inline-block mr-2 dark:text-black"
-            >
-              <v-remixicon :name="logsType[item.type]?.icon" size="20" />
-            </span>
-            <div class="flex-1 line-clamp pr-2">
-              <p class="w-full text-overflow leading-tight">
-                {{ item.name }}
-              </p>
-              <p
-                v-if="item.message"
-                :title="item.message"
-                class="text-sm line-clamp text-gray-600 dark:text-gray-200"
+          <ui-expand
+            v-for="(item, index) in history"
+            :key="item.id || index"
+            hide-header-icon
+            class="mb-1"
+            header-active-class="bg-box-transparent rounded-b-none"
+            header-class="flex items-center px-4 py-2 hoverable rounded-lg w-full text-left history-item focus:ring-0"
+          >
+            <template #header="{ show }">
+              <v-remixicon
+                :rotate="show ? 270 : 180"
+                size="20"
+                name="riArrowLeftSLine"
+                class="transition-transform dark:text-gray-200 text-gray-600 -ml-1 mr-2"
+              />
+              <span
+                :class="logsType[item.type]?.color"
+                class="p-1 rounded-lg align-middle inline-block mr-2 dark:text-black"
               >
-                {{ item.message }}
+                <v-remixicon :name="logsType[item.type]?.icon" size="20" />
+              </span>
+              <div class="flex-1 line-clamp pr-2">
+                <p class="w-full text-overflow leading-tight">
+                  {{ item.name }}
+                  <span
+                    v-show="item.description"
+                    :title="item.description"
+                    class="text-overflow text-gray-600 dark:text-gray-200 text-sm"
+                  >
+                    ({{ item.description }})
+                  </span>
+                </p>
+                <p
+                  v-if="item.message"
+                  :title="item.message"
+                  class="text-sm line-clamp text-gray-600 dark:text-gray-200"
+                >
+                  {{ item.message }}
+                </p>
+              </div>
+              <router-link
+                v-if="item.logId"
+                :to="'/logs/' + item.logId"
+                class="mr-4"
+                title="Open log detail"
+              >
+                <v-remixicon name="riExternalLinkLine" />
+              </router-link>
+              <p class="text-gray-600 dark:text-gray-200">
+                {{ countDuration(0, item.duration || 0) }}
               </p>
-            </div>
-            <router-link
-              v-if="item.logId"
-              :to="'/logs/' + item.logId"
-              class="mr-4"
-              title="Open log detail"
+            </template>
+            <pre
+              class="text-sm px-4 max-h-52 overflow-auto scroll bg-box-transparent pb-2 rounded-b-lg"
+              >{{ ctxData[item.id] }}</pre
             >
-              <v-remixicon name="riExternalLinkLine" />
-            </router-link>
-            <p class="text-gray-600 dark:text-gray-200">
-              {{ countDuration(0, item.duration || 0) }}
-            </p>
-          </ui-list-item>
+          </ui-expand>
         </ui-list>
         <div
           v-if="activeLog.history.length >= 10"
@@ -102,9 +128,10 @@
   </div>
 </template>
 <script setup>
-import { computed, onMounted, shallowReactive } from 'vue';
+import { computed, onMounted, shallowReactive, shallowRef } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 import { useI18n } from 'vue-i18n';
+import browser from 'webextension-polyfill';
 import Log from '@/models/log';
 import dayjs from '@/lib/dayjs';
 import { countDuration } from '@/utils/helper';
@@ -137,6 +164,7 @@ const { t, te } = useI18n();
 const route = useRoute();
 const router = useRouter();
 
+const ctxData = shallowRef({});
 const pagination = shallowReactive({
   perPage: 10,
   currentPage: 1,
@@ -191,8 +219,14 @@ function deleteLog() {
   });
 }
 
-onMounted(() => {
+onMounted(async () => {
   if (!activeLog.value) router.replace('/logs');
+
+  const { logsCtxData } = await browser.storage.local.get('logsCtxData');
+  const logCtxData = logsCtxData && logsCtxData[route.params.id];
+  if (logCtxData) {
+    ctxData.value = logCtxData;
+  }
 });
 </script>
 <style>

+ 13 - 0
src/utils/helper.js

@@ -1,3 +1,16 @@
+export function scrollIfNeeded(element) {
+  const { top, left, bottom, right } = element.getBoundingClientRect();
+  const isInViewport =
+    top >= 0 &&
+    left >= 0 &&
+    bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
+    right <= (window.innerWidth || document.documentElement.clientWidth);
+
+  if (!isInViewport) {
+    element.scrollIntoView();
+  }
+}
+
 export function sleep(timeout = 500) {
   return new Promise((resolve) => setTimeout(resolve, timeout));
 }

+ 13 - 6
src/utils/reference-data/index.js

@@ -5,6 +5,11 @@ export default function ({ block, refKeys, data }) {
   if (!refKeys || refKeys.length === 0) return block;
 
   const copyBlock = JSON.parse(JSON.stringify(block));
+  const addReplacedValue = (value) => {
+    if (!copyBlock.replacedValue) copyBlock.replacedValue = {};
+
+    copyBlock.replacedValue = { ...copyBlock.replacedValue, ...value };
+  };
 
   refKeys.forEach((blockDataKey) => {
     const currentData = objectPath.get(copyBlock.data, blockDataKey);
@@ -13,18 +18,20 @@ export default function ({ block, refKeys, data }) {
 
     if (Array.isArray(currentData)) {
       currentData.forEach((str, index) => {
+        const replacedStr = mustacheReplacer(str, data);
+
+        addReplacedValue(replacedStr.list);
         objectPath.set(
           copyBlock.data,
           `${blockDataKey}.${index}`,
-          mustacheReplacer(str, data)
+          replacedStr.value
         );
       });
     } else if (typeof currentData === 'string') {
-      objectPath.set(
-        copyBlock.data,
-        blockDataKey,
-        mustacheReplacer(currentData, data)
-      );
+      const replacedStr = mustacheReplacer(currentData, data);
+
+      addReplacedValue(replacedStr.list);
+      objectPath.set(copyBlock.data, blockDataKey, replacedStr.value);
     }
   });
 

+ 23 - 5
src/utils/reference-data/mustache-replacer.js

@@ -92,7 +92,12 @@ export function keyParser(key, data) {
 }
 
 function replacer(str, { regex, tagLen, modifyPath, data }) {
-  return str.replace(regex, (match) => {
+  const replaceResult = {
+    list: {},
+    value: str,
+  };
+
+  replaceResult.value = str.replace(regex, (match) => {
     let key = match.slice(tagLen, -tagLen).trim();
 
     if (!key) return '';
@@ -114,25 +119,38 @@ function replacer(str, { regex, tagLen, modifyPath, data }) {
       result = objectPath.get(data[dataKey], path) ?? match;
     }
 
-    return typeof result === 'string' ? result : JSON.stringify(result);
+    result = typeof result === 'string' ? result : JSON.stringify(result);
+    replaceResult.list[match] = result;
+
+    return result;
   });
+
+  return replaceResult;
 }
 
 export default function (str, refData) {
   if (!str || typeof str !== 'string') return '';
 
   const data = { ...refData, functions };
+  const replacedList = {};
+
   const replacedStr = replacer(`${str}`, {
     data,
     tagLen: 2,
     regex: /\{\{(.*?)\}\}/g,
-    modifyPath: (path) =>
-      replacer(path, {
+    modifyPath: (path) => {
+      const { value, list } = replacer(path, {
         data,
         tagLen: 1,
         regex: /\[(.*?)\]/g,
-      }),
+      });
+      Object.assign(replacedList, list);
+
+      return value;
+    },
   });
 
+  Object.assign(replacedStr.list, replacedList);
+
   return replacedStr;
 }

+ 83 - 1
src/utils/shared.js

@@ -479,6 +479,7 @@ export const tasks = {
       timeout: 20000,
       code: 'console.log("Hello world!");\nautomaNextBlock()',
       preloadScripts: [],
+      everyNewTab: false,
     },
   },
   'trigger-event': {
@@ -571,7 +572,7 @@ export const tasks = {
     name: 'HTTP Request',
     description: 'make an HTTP request',
     icon: 'riEarthLine',
-    component: 'BlockWebhook',
+    component: 'BlockBasicWithFallback',
     editComponent: 'EditWebhook',
     category: 'general',
     inputs: 1,
@@ -595,6 +596,22 @@ export const tasks = {
       responseType: 'json',
     },
   },
+  'while-loop': {
+    name: 'While loop',
+    description: 'Execute blocks while the condition is met',
+    icon: 'riRefreshFill',
+    component: 'BlockBasicWithFallback',
+    editComponent: 'EditWhileLoop',
+    category: 'general',
+    inputs: 1,
+    outputs: 2,
+    allowedInputs: true,
+    maxConnection: 1,
+    data: {
+      description: '',
+      conditions: null,
+    },
+  },
   'loop-data': {
     name: 'Loop data',
     icon: 'riRefreshLine',
@@ -879,3 +896,68 @@ export const supportLocales = [
   { id: 'vi', name: 'Tiếng Việt' },
   { id: 'fr', name: 'Français' },
 ];
+
+export const conditionBuilder = {
+  valueTypes: [
+    {
+      id: 'value',
+      category: 'value',
+      name: 'Value',
+      compareable: true,
+      data: { value: '' },
+    },
+    {
+      id: 'element#text',
+      category: 'element',
+      name: 'Element text',
+      compareable: true,
+      data: { selector: '' },
+    },
+    {
+      id: 'element#visible',
+      category: 'element',
+      name: 'Element visible',
+      compareable: false,
+      data: { selector: '' },
+    },
+    {
+      id: 'element#invisible',
+      category: 'element',
+      name: 'Element invisible',
+      compareable: false,
+      data: { selector: '' },
+    },
+    {
+      id: 'element#attribute',
+      category: 'element',
+      name: 'Element attribute value',
+      compareable: true,
+      data: { selector: '', attrName: '' },
+    },
+  ],
+  compareTypes: [
+    { id: 'eq', name: 'Equals', needValue: true },
+    { id: 'nq', name: 'Not equals', needValue: true },
+    { id: 'gt', name: 'Greater than', needValue: true },
+    { id: 'gte', name: 'Greater than or equal', needValue: true },
+    { id: 'lt', name: 'Less than', needValue: true },
+    { id: 'lte', name: 'Less than or equal', needValue: true },
+    { id: 'cnt', name: 'Contains', needValue: true },
+    { id: 'itr', name: 'Is truthy', needValue: false },
+    { id: 'ifl', name: 'Is falsy', needValue: false },
+  ],
+  inputTypes: {
+    selector: {
+      placeholder: '.class',
+      label: 'CSS selector',
+    },
+    value: {
+      label: 'Value',
+      placeholder: 'abc123',
+    },
+    attrName: {
+      label: 'Attribute name',
+      placeholder: 'name',
+    },
+  },
+};

+ 120 - 0
src/utils/test-conditions.js

@@ -0,0 +1,120 @@
+/* eslint-disable no-restricted-syntax */
+import mustacheReplacer from './reference-data/mustache-replacer';
+import { conditionBuilder } from './shared';
+
+const isBoolStr = (str) => {
+  if (str === 'true') return true;
+  if (str === 'false') return false;
+
+  return str;
+};
+const isNumStr = (str) => (Number.isNaN(+str) ? str : +str);
+const comparisons = {
+  eq: (a, b) => a === b,
+  nq: (a, b) => a !== b,
+  gt: (a, b) => isNumStr(a) > isNumStr(b),
+  gte: (a, b) => isNumStr(a) >= isNumStr(b),
+  lt: (a, b) => isNumStr(a) < isNumStr(b),
+  lte: (a, b) => isNumStr(a) <= isNumStr(b),
+  cnt: (a, b) => a?.includes(b) ?? false,
+  itr: (a) => Boolean(isBoolStr(a)),
+  ifl: (a) => !isBoolStr(a),
+};
+
+export default async function (conditionsArr, workflowData) {
+  const result = {
+    isMatch: false,
+    replacedValue: {},
+  };
+
+  async function getConditionItemValue({ type, data }) {
+    const copyData = JSON.parse(JSON.stringify(data));
+
+    Object.keys(data).forEach((key) => {
+      const { value, list } = mustacheReplacer(
+        copyData[key],
+        workflowData.refData
+      );
+
+      copyData[key] = value;
+      Object.assign(result.replacedValue, list);
+    });
+
+    if (type === 'value') return copyData.value;
+
+    if (type.startsWith('element')) {
+      const conditionValue = await workflowData.sendMessage({
+        type: 'condition-builder',
+        data: {
+          type,
+          data: copyData,
+        },
+      });
+
+      return conditionValue;
+    }
+
+    return '';
+  }
+  async function checkConditions(items) {
+    let conditionResult = true;
+    const condition = {
+      value: '',
+      operator: '',
+    };
+
+    for (const { category, data, type } of items) {
+      if (!conditionResult) return conditionResult;
+
+      if (category === 'compare') {
+        const isNeedValue = conditionBuilder.compareTypes.find(
+          ({ id }) => id === type
+        ).needValue;
+
+        if (!isNeedValue) {
+          conditionResult = comparisons[type](condition.value);
+
+          return conditionResult;
+        }
+
+        condition.operator = type;
+      } else if (category === 'value') {
+        const conditionValue = await getConditionItemValue({ data, type });
+        const isCompareable = conditionBuilder.valueTypes.find(
+          ({ id }) => id === type
+        ).compareable;
+
+        if (!isCompareable) {
+          conditionResult = conditionValue;
+        } else if (condition.operator) {
+          conditionResult = comparisons[condition.operator](
+            condition.value,
+            conditionValue
+          );
+
+          condition.operator = '';
+        }
+
+        condition.value = conditionValue;
+      }
+    }
+
+    return conditionResult;
+  }
+
+  for (const { conditions } of conditionsArr) {
+    if (result.isMatch) return result;
+
+    let isAllMatch = false;
+
+    for (const { items } of conditions) {
+      isAllMatch = await checkConditions(items, workflowData);
+
+      if (!isAllMatch) break;
+    }
+
+    result.isMatch = isAllMatch;
+  }
+
+  return result;
+}

+ 1 - 1
src/utils/workflow-data.js

@@ -1,5 +1,5 @@
-import { parseJSON, fileSaver, openFilePicker, isObject } from './helper';
 import Workflow from '@/models/workflow';
+import { parseJSON, fileSaver, openFilePicker, isObject } from './helper';
 
 export function importWorkflow(attrs = {}) {
   openFilePicker(['application/json'], attrs)

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 393 - 422
yarn.lock


Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov