Ahmad Kholid 3 éve
szülő
commit
5545ab2840
72 módosított fájl, 1570 hozzáadás és 833 törlés
  1. 1 1
      package.json
  2. 3 2
      src/background/index.js
  3. 8 6
      src/background/workflow-engine/blocks-handler/handler-blocks-group.js
  4. 38 0
      src/background/workflow-engine/blocks-handler/handler-delete-data.js
  5. 7 7
      src/background/workflow-engine/blocks-handler/handler-execute-workflow.js
  6. 4 4
      src/background/workflow-engine/blocks-handler/handler-export-data.js
  7. 3 3
      src/background/workflow-engine/blocks-handler/handler-google-sheets.js
  8. 3 3
      src/background/workflow-engine/blocks-handler/handler-handle-dialog.js
  9. 1 1
      src/background/workflow-engine/blocks-handler/handler-handle-download.js
  10. 1 1
      src/background/workflow-engine/blocks-handler/handler-hover-element.js
  11. 1 1
      src/background/workflow-engine/blocks-handler/handler-interaction-block.js
  12. 1 1
      src/background/workflow-engine/blocks-handler/handler-loop-breakpoint.js
  13. 9 10
      src/background/workflow-engine/blocks-handler/handler-loop-data.js
  14. 2 2
      src/background/workflow-engine/blocks-handler/handler-new-tab.js
  15. 23 0
      src/background/workflow-engine/blocks-handler/handler-reload-tab.js
  16. 1 1
      src/background/workflow-engine/blocks-handler/handler-switch-tab.js
  17. 11 5
      src/background/workflow-engine/blocks-handler/handler-take-screenshot.js
  18. 70 0
      src/background/workflow-engine/blocks-handler/handler-wait-connections.js
  19. 51 286
      src/background/workflow-engine/engine.js
  20. 1 1
      src/background/workflow-engine/helper.js
  21. 330 0
      src/background/workflow-engine/worker.js
  22. 3 1
      src/components/block/BlockBasic.vue
  23. 27 2
      src/components/block/BlockConditions.vue
  24. 3 1
      src/components/block/BlockElementExists.vue
  25. 6 1
      src/components/newtab/settings/SettingsCloudBackup.vue
  26. 5 8
      src/components/newtab/shared/SharedConditionBuilder/ConditionBuilderInputs.vue
  27. 98 32
      src/components/newtab/workflow/WorkflowBuilder.vue
  28. 55 33
      src/components/newtab/workflow/WorkflowEditBlock.vue
  29. 1 12
      src/components/newtab/workflow/WorkflowGlobalData.vue
  30. 4 12
      src/components/newtab/workflow/edit/EditAttributeValue.vue
  31. 63 0
      src/components/newtab/workflow/edit/EditAutocomplete.vue
  32. 3 12
      src/components/newtab/workflow/edit/EditCloseTab.vue
  33. 22 13
      src/components/newtab/workflow/edit/EditConditions.vue
  34. 110 0
      src/components/newtab/workflow/edit/EditDeleteData.vue
  35. 3 12
      src/components/newtab/workflow/edit/EditElementExists.vue
  36. 3 12
      src/components/newtab/workflow/edit/EditExportData.vue
  37. 4 16
      src/components/newtab/workflow/edit/EditForms.vue
  38. 6 21
      src/components/newtab/workflow/edit/EditGetText.vue
  39. 5 18
      src/components/newtab/workflow/edit/EditGoogleSheets.vue
  40. 3 13
      src/components/newtab/workflow/edit/EditHandleDialog.vue
  41. 3 13
      src/components/newtab/workflow/edit/EditInteractionBase.vue
  42. 3 13
      src/components/newtab/workflow/edit/EditLoopData.vue
  43. 3 13
      src/components/newtab/workflow/edit/EditNewTab.vue
  44. 5 21
      src/components/newtab/workflow/edit/EditSaveAssets.vue
  45. 1 8
      src/components/newtab/workflow/edit/EditScrollElement.vue
  46. 5 21
      src/components/newtab/workflow/edit/EditSwitchTab.vue
  47. 3 6
      src/components/newtab/workflow/edit/EditSwitchTo.vue
  48. 58 34
      src/components/newtab/workflow/edit/EditTakeScreenshot.vue
  49. 1 8
      src/components/newtab/workflow/edit/EditTriggerEvent.vue
  50. 4 16
      src/components/newtab/workflow/edit/EditUploadFile.vue
  51. 66 0
      src/components/newtab/workflow/edit/EditWaitConnections.vue
  52. 3 12
      src/components/newtab/workflow/edit/EditWebhook.vue
  53. 0 5
      src/components/newtab/workflow/edit/EditWhileLoop.vue
  54. 46 17
      src/components/ui/UiAutocomplete.vue
  55. 4 0
      src/content/blocks-handler/handler-javascript-code.js
  56. 58 18
      src/content/blocks-handler/handler-take-screenshot.js
  57. 2 0
      src/lib/v-remixicon.js
  58. 29 1
      src/locales/en/blocks.json
  59. 15 1
      src/locales/en/newtab.json
  60. 2 0
      src/locales/zh/blocks.json
  61. 11 0
      src/locales/zh/newtab.json
  62. 2 0
      src/models/workflow.js
  63. 16 0
      src/newtab/App.vue
  64. 36 24
      src/newtab/pages/Workflows.vue
  65. 7 0
      src/newtab/pages/logs/[id].vue
  66. 45 28
      src/newtab/pages/settings/SettingsBackup.vue
  67. 24 0
      src/newtab/pages/settings/SettingsIndex.vue
  68. 19 0
      src/newtab/pages/workflows/[id].vue
  69. 2 0
      src/store/index.js
  70. 1 1
      src/utils/helper.js
  71. 4 4
      src/utils/reference-data/mustache-replacer.js
  72. 99 15
      src/utils/shared.js

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "1.8.0",
+  "version": "1.9.1",
   "description": "An extension for automating your browser by connecting blocks",
   "license": "MIT",
   "repository": {

+ 3 - 2
src/background/index.js

@@ -148,9 +148,10 @@ checkWorkflowStates();
 async function checkVisitWebTriggers(changeInfo, tab) {
   if (!changeInfo.status || changeInfo.status !== 'complete') return;
 
-  const tabIsUsed = await workflow.states.get(
-    ({ state }) => state.activeTab.id === tab.id
+  const tabIsUsed = await workflow.states.get(({ state }) =>
+    state.tabIds.includes(tab.id)
   );
+
   if (tabIsUsed) return;
 
   const visitWebTriggers = await storage.get('visitWebTriggers');

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

@@ -14,7 +14,9 @@ function blocksGroup({ data, outputs }, { prevBlockData }) {
     }
 
     const blocks = data.blocks.reduce((acc, block, index) => {
-      let nextBlock = data.blocks[index + 1]?.itemId;
+      let nextBlock = {
+        connections: [{ node: data.blocks[index + 1]?.itemId }],
+      };
 
       if (index === data.blocks.length - 1) {
         nextBlock = nextBlockId;
@@ -25,20 +27,20 @@ function blocksGroup({ data, outputs }, { prevBlockData }) {
         id: block.itemId,
         name: block.id,
         outputs: {
-          output_1: {
-            connections: [{ node: nextBlock }],
-          },
+          output_1: nextBlock,
         },
       };
 
       return acc;
     }, {});
 
-    Object.assign(this.blocks, blocks);
+    Object.assign(this.engine.blocks, blocks);
 
     resolve({
       data: prevBlockData,
-      nextBlockId: data.blocks[0].itemId,
+      nextBlockId: {
+        connections: [{ node: data.blocks[0].itemId }],
+      },
     });
   });
 }

+ 38 - 0
src/background/workflow-engine/blocks-handler/handler-delete-data.js

@@ -0,0 +1,38 @@
+import { getBlockConnection } from '../helper';
+
+function deleteData({ data, outputs }) {
+  return new Promise((resolve) => {
+    data.deleteList.forEach((item) => {
+      if (item.type === 'table') {
+        if (item.columnId === '[all]') {
+          this.engine.referenceData.table = [];
+          this.engine.columns = {
+            column: { index: 0, name: 'column', type: 'any' },
+          };
+        } else {
+          const columnName = this.engine.columns[item.columnId].name;
+
+          this.engine.referenceData.table.forEach((_, index) => {
+            const row = this.engine.referenceData.table[index];
+            delete row[columnName];
+
+            if (!row || Object.keys(row).length === 0) {
+              this.engine.referenceData.table[index] = {};
+            }
+          });
+
+          this.engine.columns[item.columnId].index = 0;
+        }
+      } else if (item.variableName) {
+        delete this.engine.referenceData.variables[item.variableName];
+      }
+    });
+
+    resolve({
+      data: '',
+      nextBlockId: getBlockConnection({ outputs }),
+    });
+  });
+}
+
+export default deleteData;

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

@@ -57,8 +57,8 @@ async function executeWorkflow({ outputs, data }) {
           globalData: isWhitespace(data.globalData) ? null : data.globalData,
         },
         parentWorkflow: {
-          id: this.id,
-          name: this.workflow.name,
+          id: this.engine.id,
+          name: this.engine.workflow.name,
         },
       },
       events: {
@@ -70,7 +70,7 @@ async function executeWorkflow({ outputs, data }) {
             const { dataColumns, globalData, googleSheets, table } =
               engine.referenceData;
 
-            this.referenceData.workflow[data.executeId] = {
+            this.engine.referenceData.workflow[data.executeId] = {
               globalData,
               dataColumns,
               googleSheets,
@@ -79,12 +79,12 @@ async function executeWorkflow({ outputs, data }) {
           }
         },
       },
-      states: this.states,
-      logger: this.logger,
-      blocksHandler: this.blocksHandler,
+      states: this.engine.states,
+      logger: this.engine.logger,
+      blocksHandler: this.engine.blocksHandler,
     };
 
-    if (workflow.drawflow.includes(this.workflow.id)) {
+    if (workflow.drawflow.includes(this.engine.workflow.id)) {
       throw new Error('workflow-infinite-loop');
     }
 

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

@@ -2,17 +2,17 @@ import browser from 'webextension-polyfill';
 import { default as dataExporter, files } from '@/utils/data-exporter';
 import { getBlockConnection } from '../helper';
 
-async function exportData({ data, outputs }) {
+async function exportData({ data, outputs }, { refData }) {
   const nextBlockId = getBlockConnection({ outputs });
 
   try {
     const dataToExport = data.dataToExport || 'data-columns';
-    let payload = this.referenceData.table;
+    let payload = refData.table;
 
     if (dataToExport === 'google-sheets') {
-      payload = this.referenceData.googleSheets[data.refKey] || [];
+      payload = refData.googleSheets[data.refKey] || [];
     } else if (dataToExport === 'variable') {
-      payload = this.referenceData.variables[data.variableName] || [];
+      payload = refData.variables[data.variableName] || [];
 
       if (!Array.isArray(payload)) {
         payload = [payload];

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

@@ -60,7 +60,7 @@ async function updateSpreadsheetValues(
   }
 }
 
-export default async function ({ data, outputs }) {
+export default async function ({ data, outputs }, { refData }) {
   const nextBlockId = getBlockConnection({ outputs });
 
   try {
@@ -76,10 +76,10 @@ export default async function ({ data, outputs }) {
       result = spreadsheetValues;
 
       if (data.refKey && !isWhitespace(data.refKey)) {
-        this.referenceData.googleSheets[data.refKey] = spreadsheetValues;
+        refData.googleSheets[data.refKey] = spreadsheetValues;
       }
     } else if (data.type === 'update') {
-      result = await updateSpreadsheetValues(data, this.referenceData.table);
+      result = await updateSpreadsheetValues(data, refData.table);
     }
 
     return {

+ 3 - 3
src/background/workflow-engine/blocks-handler/handler-handle-dialog.js

@@ -4,7 +4,7 @@ function handleDialog({ data, outputs }) {
   const nextBlockId = getBlockConnection({ outputs });
 
   return new Promise((resolve, reject) => {
-    if (!this.workflow.settings.debugMode) {
+    if (!this.settings.debugMode) {
       const error = new Error('not-debug-mode');
       error.nextBlockId = nextBlockId;
 
@@ -18,8 +18,8 @@ function handleDialog({ data, outputs }) {
     };
 
     const methodName = 'Page.javascriptDialogOpening';
-    if (!this.eventListeners[methodName]) {
-      this.on(methodName, () => {
+    if (!this.engine.eventListeners[methodName]) {
+      this.engine.on(methodName, () => {
         sendDebugCommand(
           this.activeTab.id,
           'Page.handleJavaScriptDialog',

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

@@ -71,7 +71,7 @@ function handleDownload({ data, outputs }) {
     };
 
     const handleChanged = ({ state, id, filename }) => {
-      if (this.isDestroyed || isResolved) {
+      if (this.engine.isDestroyed || isResolved) {
         browser.downloads.onChanged.removeListener(handleChanged);
         return;
       }

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

@@ -6,7 +6,7 @@ export async function hoverElement(block) {
   try {
     if (!this.activeTab.id) throw new Error('no-tab');
 
-    const { debugMode, executedBlockOnWeb } = this.workflow.settings;
+    const { debugMode, executedBlockOnWeb } = this.settings;
 
     if (!debugMode) {
       await attachDebugger(this.activeTab.id);

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

@@ -39,7 +39,7 @@ async function interactionHandler(block) {
       (block.data.getValue && block.data.saveData)
     ) {
       const currentColumnType =
-        this.columns[block.data.dataColumn]?.type || 'any';
+        this.engine.columns[block.data.dataColumn]?.type || 'any';
       const insertDataToColumn = (value) => {
         this.addDataToColumn(block.data.dataColumn, value);
 

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

@@ -24,7 +24,7 @@ function loopBreakpoint(block, { prevBlockData }) {
       });
     } else {
       delete this.loopList[block.data.loopId];
-      delete this.referenceData.loopData[block.data.loopId];
+      delete this.engine.referenceData.loopData[block.data.loopId];
 
       resolve({
         data: prevBlockData,

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

@@ -1,7 +1,7 @@
 import { parseJSON } from '@/utils/helper';
 import { getBlockConnection } from '../helper';
 
-async function loopData({ data, id, outputs }) {
+async function loopData({ data, id, outputs }, { refData }) {
   const nextBlockId = getBlockConnection({ outputs });
 
   try {
@@ -13,25 +13,24 @@ async function loopData({ data, id, outputs }) {
       let currentLoopData;
 
       if (data.loopThrough === 'numbers') {
-        currentLoopData = this.referenceData.loopData[data.loopId].data + 1;
+        currentLoopData = refData.loopData[data.loopId].data + 1;
       } else {
         currentLoopData = this.loopList[data.loopId].data[index];
       }
 
-      this.referenceData.loopData[data.loopId] = {
+      refData.loopData[data.loopId] = {
         data: currentLoopData,
         $index: index,
       };
     } else {
       const getLoopData = {
         numbers: () => data.fromNumber,
-        table: () => this.referenceData.table,
+        table: () => refData.table,
         'custom-data': () => JSON.parse(data.loopData),
-        'data-columns': () => this.referenceData.table,
-        'google-sheets': () =>
-          this.referenceData.googleSheets[data.referenceKey],
+        'data-columns': () => refData.table,
+        'google-sheets': () => refData.googleSheets[data.referenceKey],
         variable: () => {
-          const variableVal = this.referenceData.variables[data.variableName];
+          const variableVal = refData.variables[data.variableName];
 
           return parseJSON(variableVal, variableVal);
         },
@@ -75,7 +74,7 @@ async function loopData({ data, id, outputs }) {
             : data.maxLoop || currLoopData.length,
       };
       /* eslint-disable-next-line */
-      this.referenceData.loopData[data.loopId] = {
+      refData.loopData[data.loopId] = {
         data:
           data.loopThrough === 'numbers'
             ? data.fromNumber
@@ -88,7 +87,7 @@ async function loopData({ data, id, outputs }) {
 
     return {
       nextBlockId,
-      data: this.referenceData.loopData[data.loopId],
+      data: refData.loopData[data.loopId],
     };
   } catch (error) {
     error.nextBlockId = nextBlockId;

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

@@ -45,7 +45,7 @@ async function newTab(block) {
 
     this.activeTab.url = url;
     if (tab) {
-      if (this.workflow.settings.debugMode || customUserAgent) {
+      if (this.settings.debugMode || customUserAgent) {
         await attachDebugger(tab.id, this.activeTab.id);
 
         if (customUserAgent) {
@@ -80,7 +80,7 @@ async function newTab(block) {
 
     this.activeTab.frameId = 0;
 
-    if (!this.workflow.settings.debugMode && customUserAgent) {
+    if (!this.settings.debugMode && customUserAgent) {
       chrome.debugger.detach({ tabId: tab.id });
     }
 

+ 23 - 0
src/background/workflow-engine/blocks-handler/handler-reload-tab.js

@@ -0,0 +1,23 @@
+import browser from 'webextension-polyfill';
+import { getBlockConnection } from '../helper';
+
+export async function reloadTab({ outputs }) {
+  const nextBlockId = getBlockConnection({ outputs });
+
+  try {
+    if (!this.activeTab.id) throw new Error('no-tab');
+
+    await browser.tabs.reload(this.activeTab.id);
+
+    return {
+      data: '',
+      nextBlockId,
+    };
+  } catch (error) {
+    error.nextBlockId = nextBlockId;
+
+    throw error;
+  }
+}
+
+export default reloadTab;

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

@@ -32,7 +32,7 @@ export default async function ({ data, outputs }) {
     await browser.tabs.update(tab.id, { active: true });
   }
 
-  if (this.workflow.settings.debugMode) {
+  if (this.settings.debugMode) {
     await attachDebugger(tab.id, this.activeTab.id);
   }
 

+ 11 - 5
src/background/workflow-engine/blocks-handler/handler-take-screenshot.js

@@ -1,6 +1,6 @@
 import browser from 'webextension-polyfill';
 import { fileSaver } from '@/utils/helper';
-import { getBlockConnection } from '../helper';
+import { getBlockConnection, waitTabLoaded } from '../helper';
 
 async function saveImage({ filename, uri, ext }) {
   const hasDownloadAccess = await browser.permissions.contains({
@@ -65,16 +65,20 @@ async function takeScreenshot({ data, outputs, name }) {
         currentWindow: true,
       });
 
-      if (this.windowId)
+      if (this.windowId) {
         await browser.windows.update(this.windowId, { focused: true });
-      await browser.tabs.update(this.activeTab.id, { active: true });
+      }
 
-      await new Promise((resolve) => setTimeout(resolve, 500));
+      await browser.tabs.update(this.activeTab.id, { active: true });
+      await waitTabLoaded(this.activeTab.id);
 
-      screenshot = await (data.fullPage
+      screenshot = await (data.fullPage ||
+      ['element', 'fullpage'].includes(data.type)
         ? this._sendMessageToTab({
             name,
             options,
+            type: data.type,
+            selector: data.selector,
             tabId: this.activeTab.id,
           })
         : browser.tabs.captureVisibleTab(options));
@@ -95,6 +99,8 @@ async function takeScreenshot({ data, outputs, name }) {
   } catch (error) {
     error.nextBlockId = nextBlockId;
 
+    if (data.type === 'element') error.data = { selector: data.selector };
+
     throw error;
   }
 }

+ 70 - 0
src/background/workflow-engine/blocks-handler/handler-wait-connections.js

@@ -0,0 +1,70 @@
+import { getBlockConnection } from '../helper';
+
+async function waitConnections({ data, outputs, inputs, id }, { prevBlock }) {
+  return new Promise((resolve) => {
+    let timeout;
+    let resolved = false;
+
+    const nextBlockId = getBlockConnection({ outputs });
+    const destroyWorker =
+      data.specificFlow && prevBlock?.id !== data.flowBlockId;
+
+    const registerConnections = () => {
+      inputs.input_1.connections.forEach(({ node }) => {
+        this.engine.waitConnections[id][node] = {
+          isHere: false,
+          isContinue: false,
+        };
+      });
+    };
+    const checkConnections = () => {
+      if (resolved) return;
+
+      const state = Object.values(this.engine.waitConnections[id]);
+      const isAllHere = state.every((worker) => worker.isHere);
+
+      if (isAllHere) {
+        this.engine.waitConnections[id][prevBlock.id].isContinue = true;
+        const allContinue = state.every((worker) => worker.isContinue);
+
+        if (allContinue) {
+          registerConnections();
+        }
+
+        clearTimeout(timeout);
+
+        resolve({
+          data: '',
+          nextBlockId,
+          destroyWorker,
+        });
+      } else {
+        setTimeout(() => {
+          checkConnections();
+        }, 1000);
+      }
+    };
+
+    if (!this.engine.waitConnections[id]) {
+      this.engine.waitConnections[id] = {};
+
+      registerConnections();
+    }
+
+    this.engine.waitConnections[id][prevBlock.id].isHere = true;
+
+    timeout = setTimeout(() => {
+      resolved = true;
+
+      resolve({
+        data: '',
+        nextBlockId,
+        destroyWorker,
+      });
+    }, data.timeout);
+
+    checkConnections();
+  });
+}
+
+export default waitConnections;

+ 51 - 286
src/background/workflow-engine/engine.js

@@ -1,17 +1,8 @@
 import browser from 'webextension-polyfill';
 import { nanoid } from 'nanoid';
 import { tasks } from '@/utils/shared';
-import {
-  clearCache,
-  toCamelCase,
-  sleep,
-  parseJSON,
-  isObject,
-  objectHasKey,
-} from '@/utils/helper';
-import referenceData from '@/utils/reference-data';
-import { convertData, waitTabLoaded, getBlockConnection } from './helper';
-import executeContentScript from './execute-content-script';
+import { clearCache, sleep, parseJSON, isObject } from '@/utils/helper';
+import Worker from './worker';
 
 class WorkflowEngine {
   constructor(
@@ -26,13 +17,8 @@ class WorkflowEngine {
     this.parentWorkflow = parentWorkflow;
     this.saveLog = workflow.settings?.saveLog ?? true;
 
-    this.loopList = {};
-    this.repeatedTasks = {};
-
-    this.windowId = null;
-    this.triggerBlock = null;
-    this.currentBlock = null;
-    this.childWorkflowId = null;
+    this.workers = new Map();
+    this.waitConnections = {};
 
     this.isDestroyed = false;
     this.isUsingProxy = false;
@@ -58,13 +44,6 @@ class WorkflowEngine {
     }
     this.options = options;
 
-    this.activeTab = {
-      url: '',
-      frameId: 0,
-      frames: {},
-      groupId: null,
-      id: options?.tabId,
-    };
     this.referenceData = {
       variables,
       table: [],
@@ -87,38 +66,6 @@ class WorkflowEngine {
     };
   }
 
-  reset() {
-    this.loopList = {};
-    this.repeatedTasks = {};
-
-    this.windowId = null;
-    this.currentBlock = null;
-    this.childWorkflowId = null;
-
-    this.isDestroyed = false;
-    this.isUsingProxy = false;
-
-    this.history = [];
-    this.preloadScripts = [];
-    this.columns = { column: { index: 0, name: 'column', type: 'any' } };
-
-    this.activeTab = {
-      url: '',
-      frameId: 0,
-      frames: {},
-      groupId: null,
-      id: this.options?.tabId,
-    };
-    this.referenceData = {
-      table: [],
-      loopData: {},
-      workflow: {},
-      googleSheets: {},
-      variables: this.options.variables,
-      globalData: this.referenceData.globalData,
-    };
-  }
-
   init() {
     if (this.workflow.isDisabled) return;
 
@@ -161,13 +108,12 @@ class WorkflowEngine {
       chrome.debugger.onEvent.addListener(this.onDebugEvent);
     }
     if (this.workflow.settings.reuseLastState) {
-      const lastStateKey = `last-state:${this.workflow.id}`;
+      const lastStateKey = `state:${this.workflow.id}`;
       browser.storage.local.get(lastStateKey).then((value) => {
         const lastState = value[lastStateKey];
-
         if (!lastState) return;
 
-        this.columns = lastState.columns;
+        Object.assign(this.columns, lastState.columns);
         Object.assign(this.referenceData, lastState.referenceData);
       });
     }
@@ -175,7 +121,6 @@ class WorkflowEngine {
     this.blocks = blocks;
     this.startedTimestamp = Date.now();
     this.workflow.table = columns;
-    this.currentBlock = triggerBlock;
 
     this.states.on('stop', this.onWorkflowStopped);
 
@@ -186,7 +131,7 @@ class WorkflowEngine {
         parentState: this.parentWorkflow,
       })
       .then(() => {
-        this.executeBlock(this.currentBlock);
+        this.addWorker({ blockId: triggerBlock.id });
       });
   }
 
@@ -200,6 +145,13 @@ class WorkflowEngine {
     this.init(state.currentBlock);
   }
 
+  addWorker(detail) {
+    const worker = new Worker(this);
+    worker.init(detail);
+
+    this.workers.set(worker.id, worker);
+  }
+
   addLogHistory(detail) {
     if (
       !this.saveLog &&
@@ -215,7 +167,7 @@ class WorkflowEngine {
       detail.replacedValue ||
       (tasks[detail.name]?.refDataKeys && this.saveLog)
     ) {
-      const { activeTabUrl, variables, loopData, prevBlockData } = JSON.parse(
+      const { activeTabUrl, variables, loopData } = JSON.parse(
         JSON.stringify(this.referenceData)
       );
 
@@ -224,7 +176,7 @@ class WorkflowEngine {
           loopData,
           variables,
           activeTabUrl,
-          prevBlockData,
+          prevBlockData: detail.prevBlockData || '',
         },
         replacedValue: detail.replacedValue,
       };
@@ -235,39 +187,6 @@ class WorkflowEngine {
     this.history.push(detail);
   }
 
-  addDataToColumn(key, value) {
-    if (Array.isArray(key)) {
-      key.forEach((item) => {
-        if (!isObject(item)) return;
-
-        Object.entries(item).forEach(([itemKey, itemValue]) => {
-          this.addDataToColumn(itemKey, itemValue);
-        });
-      });
-
-      return;
-    }
-
-    const columnId =
-      (this.columns[key] ? key : this.columnsId[key]) || 'column';
-    const currentColumn = this.columns[columnId];
-    const columnName = currentColumn.name || 'column';
-    const convertedValue = convertData(value, currentColumn.type);
-
-    if (objectHasKey(this.referenceData.table, currentColumn.index)) {
-      this.referenceData.table[currentColumn.index][columnName] =
-        convertedValue;
-    } else {
-      this.referenceData.table.push({ [columnName]: convertedValue });
-    }
-
-    currentColumn.index += 1;
-  }
-
-  setVariable(name, value) {
-    this.referenceData.variables[name] = value;
-  }
-
   async stop() {
     try {
       if (this.childWorkflowId) {
@@ -298,6 +217,19 @@ class WorkflowEngine {
     await browser.storage.local.set({ workflowQueue });
   }
 
+  destroyWorker(workerId) {
+    this.workers.delete(workerId);
+
+    if (this.workers.size === 0) {
+      this.addLogHistory({
+        type: 'finish',
+        name: 'finish',
+      });
+      this.dispatchEvent('finish');
+      this.destroy('success');
+    }
+  }
+
   async destroy(status, message) {
     try {
       if (this.isDestroyed) return;
@@ -312,6 +244,7 @@ class WorkflowEngine {
       }
 
       const endedTimestamp = Date.now();
+      this.workers.clear();
       this.executeQueue();
 
       if (!this.workflow.isTesting) {
@@ -328,10 +261,10 @@ class WorkflowEngine {
           message,
           id: this.id,
           workflowId: id,
-          history: this.saveLog ? this.history : [],
           endedAt: endedTimestamp,
           parentLog: this.parentWorkflow,
           startedAt: this.startedTimestamp,
+          history: this.saveLog ? this.history : [],
           data: {
             table: this.referenceData.table,
             variables: this.referenceData.variables,
@@ -350,19 +283,20 @@ class WorkflowEngine {
       });
 
       if (this.workflow.settings.reuseLastState) {
-        browser.storage.local.set({
-          [`last-state:${this.workflow.id}`]: {
+        const workflowState = {
+          [`state:${this.workflow.id}`]: {
             columns: this.columns,
             referenceData: {
               table: this.referenceData.table,
               variables: this.referenceData.variables,
-              globalData: this.referenceData.globalData,
             },
           },
-        });
-      }
+        };
 
-      if (status === 'success') clearCache(this.workflow);
+        browser.storage.local.set(workflowState);
+      } else if (status === 'success') {
+        clearCache(this.workflow);
+      }
 
       this.isDestroyed = true;
       this.eventListeners = {};
@@ -371,138 +305,21 @@ class WorkflowEngine {
     }
   }
 
-  async executeBlock(block, prevBlockData, isRetry) {
-    const currentState = await this.states.get(this.id);
-
-    if (!currentState || currentState.isDestroyed) {
-      if (this.isDestroyed) return;
-
-      await this.destroy('stopped');
-      return;
-    }
-
-    this.currentBlock = block;
-    this.referenceData.prevBlockData = prevBlockData;
-    this.referenceData.activeTabUrl = this.activeTab.url || '';
-
-    if (!isRetry) {
-      await this.states.update(this.id, { state: this.state });
-      this.dispatchEvent('update', { state: this.state });
-    }
-
-    const startExecuteTime = Date.now();
-
-    const blockHandler = this.blocksHandler[toCamelCase(block.name)];
-    const handler =
-      !blockHandler && tasks[block.name].category === 'interaction'
-        ? this.blocksHandler.interactionBlock
-        : blockHandler;
-
-    if (!handler) {
-      console.error(`"${block.name}" block doesn't have a handler`);
-      this.destroy('stopped');
-      return;
-    }
-
-    const replacedBlock = referenceData({
-      block,
-      data: this.referenceData,
-      refKeys: isRetry ? null : tasks[block.name].refDataKeys,
-    });
-    const blockDelay = this.workflow.settings?.blockDelay || 0;
-    const addBlockLog = (status, obj = {}) => {
-      this.addLogHistory({
-        type: status,
-        name: block.name,
-        description: block.data.description,
-        replacedValue: replacedBlock.replacedValue,
-        duration: Math.round(Date.now() - startExecuteTime),
-        ...obj,
-      });
+  async updateState(data) {
+    const state = {
+      ...this.state,
+      ...data,
+      tabIds: [],
+      currentBlock: [],
     };
 
-    try {
-      const result = await handler.call(this, replacedBlock, {
-        prevBlockData,
-        refData: this.referenceData,
-      });
-
-      if (result.replacedValue)
-        replacedBlock.replacedValue = result.replacedValue;
-
-      addBlockLog(result.status || 'success', {
-        logId: result.logId,
-      });
-
-      if (result.nextBlockId) {
-        setTimeout(() => {
-          this.executeBlock(this.blocks[result.nextBlockId], result.data);
-        }, blockDelay);
-      } else {
-        this.addLogHistory({
-          type: 'finish',
-          name: 'finish',
-        });
-        this.dispatchEvent('finish');
-        this.destroy('success');
-      }
-    } catch (error) {
-      const { onError: blockOnError } = replacedBlock.data;
-      if (blockOnError && blockOnError.enable) {
-        if (blockOnError.retry && blockOnError.retryTimes) {
-          await sleep(blockOnError.retryInterval * 1000);
-          blockOnError.retryTimes -= 1;
-          await this.executeBlock(replacedBlock, prevBlockData, true);
-
-          return;
-        }
-
-        const nextBlockId = getBlockConnection(
-          block,
-          blockOnError.toDo === 'continue' ? 1 : 2
-        );
-        if (blockOnError.toDo !== 'error' && nextBlockId) {
-          this.executeBlock(this.blocks[nextBlockId], '');
-          return;
-        }
-      }
-
-      addBlockLog('error', {
-        message: error.message,
-        ...(error.data || {}),
-      });
-
-      const { onError } = this.workflow.settings;
-
-      if (onError === 'keep-running' && error.nextBlockId) {
-        setTimeout(() => {
-          this.executeBlock(this.blocks[error.nextBlockId], error.data || '');
-        }, blockDelay);
-      } else if (onError === 'restart-workflow' && !this.parentWorkflow) {
-        const restartKey = `restart-count:${this.id}`;
-        const restartCount = +localStorage.getItem(restartKey) || 0;
-        const maxRestart = this.workflow.settings.restartTimes ?? 3;
-
-        if (restartCount >= maxRestart) {
-          localStorage.removeItem(restartKey);
-          this.destroy();
-          return;
-        }
-
-        this.reset();
-
-        const triggerBlock = Object.values(this.blocks).find(
-          ({ name }) => name === 'trigger'
-        );
-        this.executeBlock(triggerBlock);
-
-        localStorage.setItem(restartKey, restartCount + 1);
-      } else {
-        this.destroy('error', error.message);
-      }
+    this.workers.forEach((worker) => {
+      state.tabIds.push(worker.activeTab.id);
+      state.currentBlock.push(worker.currentBlock);
+    });
 
-      console.error(`${block.name}:`, error);
-    }
+    await this.states.update(this.id, { state });
+    this.dispatchEvent('update', { state });
   }
 
   dispatchEvent(name, params) {
@@ -522,16 +339,7 @@ class WorkflowEngine {
   }
 
   get state() {
-    const keys = [
-      'history',
-      'columns',
-      'activeTab',
-      'isUsingProxy',
-      'currentBlock',
-      'referenceData',
-      'childWorkflowId',
-      'startedTimestamp',
-    ];
+    const keys = ['columns', 'referenceData', 'startedTimestamp'];
     const state = {
       name: this.workflow.name,
       icon: this.workflow.icon,
@@ -543,49 +351,6 @@ class WorkflowEngine {
 
     return state;
   }
-
-  async _sendMessageToTab(payload, options = {}) {
-    try {
-      if (!this.activeTab.id) {
-        const error = new Error('no-tab');
-        error.workflowId = this.id;
-
-        throw error;
-      }
-
-      await waitTabLoaded(this.activeTab.id);
-      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,
-        messagePayload,
-        { frameId: this.activeTab.frameId, ...options }
-      );
-
-      return data;
-    } catch (error) {
-      if (error.message?.startsWith('Could not establish connection')) {
-        error.message = 'Could not establish connection to the active tab';
-      } else if (error.message?.startsWith('No tab')) {
-        error.message = 'active-tab-removed';
-      }
-
-      throw error;
-    }
-  }
 }
 
 export default WorkflowEngine;

+ 1 - 1
src/background/workflow-engine/helper.js

@@ -59,7 +59,7 @@ export function convertData(data, type) {
 }
 
 export function getBlockConnection(block, index = 1) {
-  const blockId = block.outputs[`output_${index}`]?.connections[0]?.node;
+  const blockId = block.outputs[`output_${index}`];
 
   return blockId;
 }

+ 330 - 0
src/background/workflow-engine/worker.js

@@ -0,0 +1,330 @@
+import { nanoid } from 'nanoid';
+import browser from 'webextension-polyfill';
+import cloneDeep from 'lodash.clonedeep';
+import { toCamelCase, sleep, objectHasKey, isObject } from '@/utils/helper';
+import { tasks } from '@/utils/shared';
+import referenceData from '@/utils/reference-data';
+import { convertData, waitTabLoaded, getBlockConnection } from './helper';
+import executeContentScript from './execute-content-script';
+
+class Worker {
+  constructor(engine) {
+    this.id = nanoid(5);
+    this.engine = engine;
+    this.settings = engine.workflow.settings;
+
+    this.loopList = {};
+    this.repeatedTasks = {};
+    this.preloadScripts = [];
+
+    this.windowId = null;
+    this.currentBlock = null;
+    this.childWorkflowId = null;
+
+    this.activeTab = {
+      url: '',
+      frameId: 0,
+      frames: {},
+      groupId: null,
+      id: engine.options?.tabId,
+    };
+  }
+
+  init({ blockId, prevBlockData, state }) {
+    if (state) {
+      Object.keys(state).forEach((key) => {
+        this[key] = state[key];
+      });
+    }
+
+    const block = this.engine.blocks[blockId];
+    this.executeBlock(block, prevBlockData);
+  }
+
+  addDataToColumn(key, value) {
+    if (Array.isArray(key)) {
+      key.forEach((item) => {
+        if (!isObject(item)) return;
+
+        Object.entries(item).forEach(([itemKey, itemValue]) => {
+          this.addDataToColumn(itemKey, itemValue);
+        });
+      });
+
+      return;
+    }
+
+    const columnId =
+      (this.engine.columns[key] ? key : this.engine.columnsId[key]) || 'column';
+    const currentColumn = this.engine.columns[columnId];
+    const columnName = currentColumn.name || 'column';
+    const convertedValue = convertData(value, currentColumn.type);
+
+    if (objectHasKey(this.engine.referenceData.table, currentColumn.index)) {
+      this.engine.referenceData.table[currentColumn.index][columnName] =
+        convertedValue;
+    } else {
+      this.engine.referenceData.table.push({ [columnName]: convertedValue });
+    }
+
+    currentColumn.index += 1;
+  }
+
+  setVariable(name, value) {
+    this.engine.referenceData.variables[name] = value;
+  }
+
+  executeNextBlocks(connections, prevBlockData) {
+    connections.forEach(({ node }, index) => {
+      if (index === 0) {
+        this.executeBlock(this.engine.blocks[node], prevBlockData);
+      } else {
+        const state = cloneDeep({
+          windowId: this.windowId,
+          loopList: this.loopList,
+          activeTab: this.activeTab,
+          currentBlock: this.currentBlock,
+          repeatedTasks: this.repeatedTasks,
+          preloadScripts: this.preloadScripts,
+        });
+
+        this.engine.addWorker({
+          state,
+          prevBlockData,
+          blockId: node,
+        });
+      }
+    });
+  }
+
+  async executeBlock(block, prevBlockData, isRetry) {
+    const currentState = await this.engine.states.get(this.engine.id);
+
+    if (!currentState || currentState.isDestroyed) {
+      if (this.engine.isDestroyed) return;
+
+      await this.engine.destroy('stopped');
+      return;
+    }
+
+    const prevBlock = this.currentBlock;
+    this.currentBlock = block;
+
+    if (!isRetry) {
+      await this.engine.updateState({
+        activeTabUrl: this.activeTab.url,
+        childWorkflowId: this.childWorkflowId,
+      });
+    }
+
+    const startExecuteTime = Date.now();
+
+    const blockHandler = this.engine.blocksHandler[toCamelCase(block.name)];
+    const handler =
+      !blockHandler && tasks[block.name].category === 'interaction'
+        ? this.engine.blocksHandler.interactionBlock
+        : blockHandler;
+
+    if (!handler) {
+      this.engine.destroy('stopped');
+      return;
+    }
+
+    const refData = {
+      prevBlockData,
+      ...this.engine.referenceData,
+      activeTabUrl: this.activeTab.url,
+    };
+    const replacedBlock = referenceData({
+      block,
+      data: refData,
+      refKeys:
+        isRetry || block.data.disableBlock
+          ? null
+          : tasks[block.name].refDataKeys,
+    });
+    const blockDelay = this.settings?.blockDelay || 0;
+    const addBlockLog = (status, obj = {}) => {
+      this.engine.addLogHistory({
+        prevBlockData,
+        type: status,
+        name: block.name,
+        workerId: this.id,
+        description: block.data.description,
+        replacedValue: replacedBlock.replacedValue,
+        duration: Math.round(Date.now() - startExecuteTime),
+        ...obj,
+      });
+    };
+
+    try {
+      let result;
+
+      if (block.data.disableBlock) {
+        result = {
+          data: '',
+          nextBlockId: getBlockConnection(block),
+        };
+      } else {
+        result = await handler.call(this, replacedBlock, {
+          refData,
+          prevBlock,
+          prevBlockData,
+        });
+
+        if (result.replacedValue) {
+          replacedBlock.replacedValue = result.replacedValue;
+        }
+
+        addBlockLog(result.status || 'success', {
+          logId: result.logId,
+        });
+      }
+
+      let nodeConnections = null;
+
+      if (typeof result.nextBlockId === 'string') {
+        nodeConnections = [{ node: result.nextBlockId }];
+      } else {
+        nodeConnections = result.nextBlockId.connections;
+      }
+
+      if (nodeConnections.length > 0 && !result.destroyWorker) {
+        setTimeout(() => {
+          this.executeNextBlocks(nodeConnections);
+        }, blockDelay);
+      } else {
+        this.engine.destroyWorker(this.id);
+      }
+    } catch (error) {
+      const { onError: blockOnError } = replacedBlock.data;
+      if (blockOnError && blockOnError.enable) {
+        if (blockOnError.retry && blockOnError.retryTimes) {
+          await sleep(blockOnError.retryInterval * 1000);
+          blockOnError.retryTimes -= 1;
+          await this.executeBlock(replacedBlock, prevBlockData, true);
+
+          return;
+        }
+
+        const nextBlocks = getBlockConnection(
+          block,
+          blockOnError.toDo === 'continue' ? 1 : 2
+        );
+        if (blockOnError.toDo !== 'error' && nextBlocks.connections) {
+          this.executeNextBlocks(nextBlocks.connections, prevBlockData);
+          return;
+        }
+      }
+
+      addBlockLog('error', {
+        message: error.message,
+        ...(error.data || {}),
+      });
+
+      const { onError } = this.settings;
+      const nodeConnections = error.nextBlockId.connections;
+
+      if (onError === 'keep-running' && nodeConnections) {
+        setTimeout(() => {
+          this.executeNextBlocks(nodeConnections, error.data || '');
+        }, blockDelay);
+      } else if (onError === 'restart-workflow' && !this.parentWorkflow) {
+        const restartKey = `restart-count:${this.id}`;
+        const restartCount = +localStorage.getItem(restartKey) || 0;
+        const maxRestart = this.settings.restartTimes ?? 3;
+
+        if (restartCount >= maxRestart) {
+          localStorage.removeItem(restartKey);
+          this.engine.destroy();
+          return;
+        }
+
+        this.reset();
+
+        const triggerBlock = Object.values(this.engine.blocks).find(
+          ({ name }) => name === 'trigger'
+        );
+        this.executeBlock(triggerBlock);
+
+        localStorage.setItem(restartKey, restartCount + 1);
+      } else {
+        this.engine.destroy('error', error.message);
+      }
+    }
+  }
+
+  reset() {
+    this.loopList = {};
+    this.repeatedTasks = {};
+
+    this.windowId = null;
+    this.currentBlock = null;
+    this.childWorkflowId = null;
+
+    this.engine.history = [];
+    this.engine.preloadScripts = [];
+    this.engine.columns = { column: { index: 0, name: 'column', type: 'any' } };
+
+    this.activeTab = {
+      url: '',
+      frameId: 0,
+      frames: {},
+      groupId: null,
+      id: this.options?.tabId,
+    };
+    this.engine.referenceData = {
+      table: [],
+      loopData: {},
+      workflow: {},
+      googleSheets: {},
+      variables: this.engine.options.variables,
+      globalData: this.engine.referenceData.globalData,
+    };
+  }
+
+  async _sendMessageToTab(payload, options = {}) {
+    try {
+      if (!this.activeTab.id) {
+        const error = new Error('no-tab');
+        error.workflowId = this.id;
+
+        throw error;
+      }
+
+      await waitTabLoaded(this.activeTab.id);
+      await executeContentScript(
+        this.activeTab.id,
+        this.activeTab.frameId || 0
+      );
+
+      const { executedBlockOnWeb, debugMode } = this.settings;
+      const messagePayload = {
+        isBlock: true,
+        debugMode,
+        executedBlockOnWeb,
+        activeTabId: this.activeTab.id,
+        frameSelector: this.frameSelector,
+        ...payload,
+      };
+
+      const data = await browser.tabs.sendMessage(
+        this.activeTab.id,
+        messagePayload,
+        { frameId: this.activeTab.frameId, ...options }
+      );
+
+      return data;
+    } catch (error) {
+      if (error.message?.startsWith('Could not establish connection')) {
+        error.message = 'Could not establish connection to the active tab';
+      } else if (error.message?.startsWith('No tab')) {
+        error.message = 'active-tab-removed';
+      }
+
+      throw error;
+    }
+  }
+}
+
+export default Worker;

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

@@ -9,7 +9,9 @@
   >
     <div class="flex items-center">
       <span
-        :class="block.category.color"
+        :class="
+          block.data.disableBlock ? 'bg-box-transparent' : block.category.color
+        "
         class="inline-block p-2 mr-2 rounded-lg dark:text-black"
       >
         <v-remixicon :name="block.details.icon || 'riGlobalLine'" />

+ 27 - 2
src/components/block/BlockConditions.vue

@@ -2,7 +2,9 @@
   <div :id="componentId" class="p-4" @dblclick="editBlock">
     <div class="flex items-center">
       <div
-        :class="block.category.color"
+        :class="
+          block.data.disableBlock ? 'bg-box-transparent' : block.category.color
+        "
         class="inline-block text-sm mr-4 p-2 rounded-lg dark:text-black"
       >
         <v-remixicon name="riAB" size="20" class="inline-block mr-1" />
@@ -79,7 +81,11 @@ const componentId = useComponentId('block-conditions');
 const block = useEditorBlock(`#${componentId}`, props.editor);
 
 function onChange({ detail }) {
-  if (detail.conditions) block.data.conditions = detail.conditions;
+  block.data.disableBlock = detail.disableBlock;
+
+  if (detail.conditions) {
+    block.data.conditions = detail.conditions;
+  }
 }
 function editBlock() {
   emitter.emit('editor:edit-block', {
@@ -106,6 +112,23 @@ function deleteConditionEmit({ index, id }) {
   if (block.data.conditions.length === 0)
     props.editor.removeNodeOutput(block.id, `output_1`);
 }
+function refreshConnections({ id }) {
+  if (id !== block.id) return;
+
+  const node = props.editor.getNodeFromId(block.id);
+  const outputs = Object.keys(node.outputs);
+  const conditionsLen = block.data.conditions.length + 1;
+
+  if (outputs.length > conditionsLen) {
+    const diff = outputs.length - conditionsLen;
+
+    for (let index = 0; index < diff; index += 1) {
+      const output = outputs[outputs.length - 2 - index];
+
+      props.editor.removeNodeOutput(block.id, output);
+    }
+  }
+}
 
 watch(
   () => block.data.conditions,
@@ -125,10 +148,12 @@ watch(
 
 emitter.on('conditions-block:add', addConditionEmit);
 emitter.on('conditions-block:delete', deleteConditionEmit);
+emitter.on('conditions-block:refresh', refreshConnections);
 
 onBeforeUnmount(() => {
   emitter.off('conditions-block:add', addConditionEmit);
   emitter.off('conditions-block:delete', deleteConditionEmit);
+  emitter.off('conditions-block:refresh', refreshConnections);
 });
 </script>
 <style>

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

@@ -6,7 +6,9 @@
     @delete="editor.removeNodeId(`node-${block.id}`)"
   >
     <div
-      :class="block.category.color"
+      :class="
+        block.data.disableBlock ? 'bg-box-transparent' : block.category.color
+      "
       class="inline-block text-sm mb-2 p-2 rounded-lg dark:text-black"
     >
       <v-remixicon name="riFocus3Line" size="20" class="inline-block mr-1" />

+ 6 - 1
src/components/newtab/settings/SettingsCloudBackup.vue

@@ -8,7 +8,9 @@
         prepend-icon="riSearch2Line"
       />
       <ui-list class="mt-4">
-        <p class="mb-1 text-sm text-gray-600 dark:text-gray-200">Location</p>
+        <p class="mb-1 text-sm text-gray-600 dark:text-gray-200">
+          {{ t('settings.backupWorkflows.cloud.location') }}
+        </p>
         <ui-list-item
           v-for="location in ['local', 'cloud']"
           :key="location"
@@ -87,6 +89,9 @@
         </settings-backup-items>
       </template>
       <template v-else>
+        <p class="mb-2">
+          {{ t('settings.backupWorkflows.cloud.selectText') }}
+        </p>
         <settings-backup-items
           v-slot="{ workflow }"
           v-model="state.selectedWorkflows"

+ 5 - 8
src/components/newtab/shared/SharedConditionBuilder/ConditionBuilderInputs.vue

@@ -22,23 +22,19 @@
           </option>
         </optgroup>
       </ui-select>
-      <ui-autocomplete
-        :items="autocomplete"
-        :trigger-char="['{{', '}}']"
-        block
-        hide-empty
+      <edit-autocomplete
+        v-for="(_, name) in item.data"
+        :key="item.id + name + index"
         class="flex-1"
       >
         <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"
           autocomplete="off"
           class="w-full"
         />
-      </ui-autocomplete>
+      </edit-autocomplete>
     </div>
     <ui-select
       v-else-if="item.category === 'compare'"
@@ -59,6 +55,7 @@
 import { ref, watch } from 'vue';
 import { nanoid } from 'nanoid';
 import { conditionBuilder } from '@/utils/shared';
+import EditAutocomplete from '../../workflow/edit/EditAutocomplete.vue';
 
 const props = defineProps({
   data: {

+ 98 - 32
src/components/newtab/workflow/WorkflowBuilder.vue

@@ -1,7 +1,7 @@
 <template>
   <div
+    v-bind="{ arrow: $store.state.settings.editor.arrow }"
     id="drawflow"
-    :class="{ 'with-arrow': $store.state.settings.editor.arrow }"
     class="parent-drawflow relative"
     @drop="dropHandler"
     @dragover.prevent="handleDragOver"
@@ -81,7 +81,11 @@ import { compare } from 'compare-versions';
 import defu from 'defu';
 import SelectionArea from '@viselect/vanilla';
 import emitter from '@/lib/mitt';
-import { useShortcut, getShortcut } from '@/composable/shortcut';
+import {
+  useShortcut,
+  getShortcut,
+  getReadableShortcut,
+} from '@/composable/shortcut';
 import { tasks } from '@/utils/shared';
 import { parseJSON } from '@/utils/helper';
 import { useGroupTooltip } from '@/composable/groupTooltip';
@@ -115,7 +119,23 @@ export default {
     const store = useStore();
 
     const contextMenuItems = {
+      common: [
+        {
+          id: 'paste',
+          name: t('workflow.editor.paste'),
+          icon: 'riFileCopyLine',
+          event: 'pasteBlocks',
+          shortcut: getReadableShortcut('mod+v'),
+        },
+      ],
       block: [
+        {
+          id: 'copy',
+          name: t('workflow.editor.copy'),
+          icon: 'riFileCopyLine',
+          event: 'copyBlocks',
+          shortcut: getReadableShortcut('mod+c'),
+        },
         {
           id: 'duplicate',
           name: t('workflow.editor.duplicate'),
@@ -317,12 +337,14 @@ export default {
     function clearSelectedElements() {
       selection.value.clearSelection();
       selectedElements.forEach(({ el }) => {
+        if (!el) return;
+
         el.classList.remove('selected-list');
       });
       selectedElements = [];
       activeNode = null;
     }
-    function duplicateBlock(nodeId) {
+    function duplicateBlock(nodeId, isPaste = false) {
       const nodes = new Map();
       const addNode = (id) => {
         const node = editor.value.getNodeFromId(id);
@@ -332,14 +354,20 @@ export default {
         nodes.set(node.id, node);
       };
 
-      if (nodeId) addNode(nodeId);
-      else if (activeNode) addNode(activeNode.id);
+      if (isPaste) {
+        store.state.copiedNodes.forEach((node) => {
+          nodes.set(node.id, node);
+        });
+      } else {
+        if (nodeId) addNode(nodeId);
+        else if (activeNode) addNode(activeNode.id);
 
-      selectedElements.forEach((node) => {
-        if (activeNode?.id === node.id || nodeId === node.id) return;
+        selectedElements.forEach((node) => {
+          if (activeNode?.id === node.id || nodeId === node.id) return;
 
-        addNode(node.id);
-      });
+          addNode(node.id);
+        });
+      }
 
       const nodesOutputs = [];
 
@@ -377,6 +405,8 @@ export default {
           posX: parseInt(nodeElement.style.left, 10),
         });
 
+        emitter.emit('editor:data-changed');
+
         if (outputsLen > 0) {
           nodesOutputs.push({ id: newNodeId, outputs: node.outputs });
         }
@@ -435,7 +465,7 @@ export default {
       });
 
       selection.value.on('beforestart', ({ event }) => {
-        if (!event.ctrlKey) return false;
+        if (!event.ctrlKey && !event.metaKey) return false;
 
         editor.value.editor_mode = 'fixed';
         editor.value.editor_selected = false;
@@ -504,7 +534,7 @@ export default {
 
       isDragging = true;
     }
-    function onClick({ ctrlKey, target }) {
+    function onClick({ ctrlKey, metaKey, target }) {
       const nodeEl = target.closest('.drawflow-node');
       if (!nodeEl) {
         if (!hasDragged) clearSelectedElements();
@@ -518,7 +548,7 @@ export default {
         posX: parseInt(nodeEl.style.left, 10),
       };
 
-      if (!ctrlKey && !hasDragged) {
+      if (!ctrlKey && !metaKey && !hasDragged) {
         clearSelectedElements();
 
         activeNode = nodeProperties;
@@ -530,7 +560,7 @@ export default {
       }
       hasDragged = false;
 
-      if (!ctrlKey) return;
+      if (!ctrlKey && !metaKey) return;
 
       const nodeIndex = selectedElements.findIndex(({ el }) =>
         nodeEl.isEqualNode(el)
@@ -545,9 +575,35 @@ export default {
         selectedElements.push(nodeProperties);
       }
     }
-    function onKeyup({ key, target }) {
+    function copyBlocks() {
+      let nodes = selectedElements;
+
+      if (nodes.length === 0) {
+        const selectedEl = document.querySelector('.drawflow-node.selected');
+
+        if (selectedEl) {
+          nodes.push({ id: selectedEl.id.substr(5) });
+        }
+      }
+
+      nodes = nodes.map((node) => editor.value.getNodeFromId(node.id));
+
+      store.commit('updateState', {
+        key: 'copiedNodes',
+        value: nodes,
+      });
+    }
+    function onKeyup({ key, target, ctrlKey, metaKey }) {
+      if (ctrlKey || metaKey) {
+        if (key === 'c') {
+          copyBlocks();
+        } else if (key === 'v') {
+          duplicateBlock(null, true);
+        }
+      }
+
       const isAnInput =
-        ['INPUT', 'TEXTAREA'].includes(target.tagName) &&
+        ['INPUT', 'TEXTAREA'].includes(target.tagName) ||
         target.isContentEditable;
 
       if (key !== 'Delete' || isAnInput) return;
@@ -693,14 +749,11 @@ export default {
       editor.value.on(
         'connectionCreated',
         ({ output_id, input_id, output_class, input_class }) => {
-          const { outputs } = editor.value.getNodeFromId(output_id);
           const { name: inputName } = editor.value.getNodeFromId(input_id);
-          const { allowedInputs, maxConnection } = tasks[inputName];
+          const { allowedInputs } = tasks[inputName];
           const isAllowed = isInputAllowed(allowedInputs, inputName);
-          const isMaxConnections =
-            outputs[output_class]?.connections.length > maxConnection;
 
-          if (!isAllowed || isMaxConnections) {
+          if (!isAllowed) {
             editor.value.removeSingleConnection(
               output_id,
               input_id,
@@ -718,24 +771,35 @@ export default {
       editor.value.on('export', saveEditorState);
       editor.value.on('contextmenu', ({ clientY, clientX, target }) => {
         const isBlock = target.closest('.drawflow .drawflow-node');
+        const virtualEl = {
+          getReferenceClientRect: () => ({
+            width: 0,
+            height: 0,
+            top: clientY,
+            right: clientX,
+            bottom: clientY,
+            left: clientX,
+          }),
+        };
 
         if (isBlock) {
-          const virtualEl = {
-            getReferenceClientRect: () => ({
-              width: 0,
-              height: 0,
-              top: clientY,
-              right: clientX,
-              bottom: clientY,
-              left: clientX,
-            }),
-          };
-
           contextMenu.data = isBlock.id;
           contextMenu.position = virtualEl;
           contextMenu.items = contextMenuItems.block;
           contextMenu.show = true;
         }
+
+        const copiedNodesLen = store.state.copiedNodes.length;
+        if (copiedNodesLen > 0) {
+          if (isBlock) {
+            contextMenu.items.unshift(...contextMenuItems.common);
+          } else {
+            contextMenu.items = contextMenuItems.common;
+          }
+
+          contextMenu.position = virtualEl;
+          contextMenu.show = true;
+        }
       });
 
       checkWorkflowData();
@@ -766,7 +830,9 @@ export default {
       dropHandler,
       handleDragOver,
       contextMenuHandler: {
+        copyBlocks,
         deleteBlock,
+        pasteBlocks: () => duplicateBlock(null, true),
         duplicateBlock: () => duplicateBlock(contextMenu.data.substr(5)),
       },
     };
@@ -785,7 +851,7 @@ export default {
 .drawflow .drawflow-node {
   @apply dark:bg-gray-800;
 }
-#drawflow.with-arrow .drawflow-node .input {
+#drawflow[arrow='true'] .drawflow-node .input {
   background-color: transparent !important;
   border-top: 10px solid transparent;
   border-radius: 0;

+ 55 - 33
src/components/newtab/workflow/WorkflowEditBlock.vue

@@ -1,12 +1,12 @@
 <template>
   <div id="workflow-edit-block" class="px-4 overflow-auto scroll pb-1">
     <div
-      class="sticky top-0 z-20 bg-white dark:bg-gray-800 pb-4 mb-2 flex items-center"
+      class="sticky top-0 z-20 bg-white dark:bg-gray-800 pb-4 mb-2 flex items-center space-x-2"
     >
-      <button class="mr-2" @click="$emit('close')">
+      <button @click="$emit('close')">
         <v-remixicon name="riArrowLeftLine" />
       </button>
-      <p class="font-semibold inline-block flex-1 capitalize">
+      <p class="font-semibold inline-block capitalize">
         {{ t(`workflow.blocks.${data.id}.name`) }}
       </p>
       <a
@@ -14,9 +14,23 @@
         :href="`https://docs.automa.site/blocks/${data.id}.html`"
         rel="noopener"
         target="_blank"
+        class="text-gray-600 dark:text-gray-200"
       >
-        <v-remixicon name="riInformationLine" />
+        <v-remixicon name="riInformationLine" size="20" />
       </a>
+      <div class="flex-grow"></div>
+      <ui-switch
+        v-if="data.id !== 'trigger'"
+        v-tooltip="
+          t(
+            `workflow.blocks.base.toggle.${
+              blockData.disableBlock ? 'enable' : 'disable'
+            }`
+          )
+        "
+        :model-value="!blockData.disableBlock"
+        @change="$emit('update', { ...blockData, disableBlock: !$event })"
+      />
     </div>
     <component
       :is="data.editComponent"
@@ -24,7 +38,9 @@
       :key="data.blockId"
       v-model:data="blockData"
       :block-id="data.blockId"
-      :autocomplete="autocompleteList"
+      v-bind="{
+        connections: data.id === 'wait-connections' ? data.connections : null,
+      }"
     />
     <on-block-error
       v-if="!excludeOnError.includes(data.id)"
@@ -36,9 +52,10 @@
   </div>
 </template>
 <script>
-import { computed, ref, watch } from 'vue';
+import { computed, provide, ref, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { tasks } from '@/utils/shared';
+import { parseJSON } from '@/utils/helper';
 import OnBlockError from './edit/OnBlockError.vue';
 
 const editComponents = require.context(
@@ -80,13 +97,6 @@ export default {
   },
   emits: ['close', 'update', 'update:autocomplete'],
   setup(props, { emit }) {
-    const defaultAutocomplete = [
-      'activeTabUrl',
-      '$date',
-      '$randint',
-      '$getLength',
-      'globalData',
-    ];
     const excludeOnError = [
       'webhook',
       'while-loop',
@@ -96,7 +106,16 @@ export default {
     ];
 
     const { t } = useI18n();
-    const autocompleteData = ref({});
+    const autocompleteData = ref({
+      common: {
+        table: {},
+        globalData: [],
+        activeTabUrl: '',
+        $date: '',
+        $randint: '',
+        $getLength: '',
+      },
+    });
 
     const blockData = computed({
       get() {
@@ -106,16 +125,12 @@ export default {
         emit('update', value);
       },
     });
-    const autocompleteList = computed(() => {
-      const blockId = props.data.itemId || props.data.blockId;
-      const arr = [
-        defaultAutocomplete,
-        autocompleteData.value.table,
-        autocompleteData.value[blockId],
-      ];
-
-      return arr.flatMap((items) => [...(items || [])]);
-    });
+    const autocompleteList = computed(() => ({
+      ...autocompleteData.value.common,
+      ...autocompleteData.value[props.data.itemId || props.data.blockId],
+    }));
+
+    provide('autocompleteData', autocompleteList);
 
     const dataKeywords = {
       loopId: 'loopData',
@@ -123,7 +138,7 @@ export default {
       variableName: 'variables',
     };
     function addAutocompleteData(id, name, data) {
-      if (!autocompleteData.value[id]) autocompleteData.value[id] = new Set();
+      if (!autocompleteData.value[id]) autocompleteData.value[id] = {};
 
       if (!tasks[name].autocomplete) return;
 
@@ -132,7 +147,12 @@ export default {
           key === 'variableName' && !data.assignVariable;
         if (!data[key] || variableNotAssigned) return;
 
-        autocompleteData.value[id].add(`${dataKeywords[key]}@${data[key]}`);
+        const keyword = dataKeywords[key];
+        if (!autocompleteData.value[id][keyword]) {
+          autocompleteData.value[id][keyword] = {};
+        }
+
+        autocompleteData.value[id][keyword][data[key]] = '';
       });
     }
     function getGroupBlockData(blocks, currentItemId) {
@@ -192,12 +212,15 @@ export default {
           traceBlockData(props.data.blockId, currentBlock, blocks);
         }
 
-        if (!autocompleteData.value.table) {
-          autocompleteData.value.table = new Set();
-          props.workflow.table?.forEach((column) => {
-            autocompleteData.value.table.add(`table@${column.name}`);
-          });
-        }
+        props.workflow.table?.forEach((column) => {
+          autocompleteData.value.common.table[column.name] = '';
+        });
+
+        const workflowGlobalData = props.workflow.globalData;
+        autocompleteData.value.common.globalData = parseJSON(
+          workflowGlobalData,
+          workflowGlobalData
+        );
       },
       { immediate: true }
     );
@@ -213,7 +236,6 @@ export default {
       t,
       blockData,
       excludeOnError,
-      autocompleteList,
     };
   },
 };

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

@@ -1,14 +1,6 @@
 <template>
   <div class="global-data">
-    <a
-      href="https://docs.automa.site/api-reference/reference-data.html"
-      target="_blank"
-      rel="noopener"
-      class="inline-block text-primary"
-    >
-      {{ t('message.useDynamicData') }}
-    </a>
-    <p class="float-right clear-both" title="Characters limit">
+    <p class="text-right" title="Characters limit">
       {{ globalData.length }}/{{ maxLength.toLocaleString() }}
     </p>
     <shared-codemirror
@@ -20,7 +12,6 @@
 </template>
 <script setup>
 import { ref, watch, defineAsyncComponent } from 'vue';
-import { useI18n } from 'vue-i18n';
 import { debounce } from '@/utils/helper';
 
 const SharedCodemirror = defineAsyncComponent(() =>
@@ -35,8 +26,6 @@ const props = defineProps({
 });
 const emit = defineEmits(['update']);
 
-const { t } = useI18n();
-
 const maxLength = 1e4;
 const globalData = ref(`${props.workflow.globalData}`);
 

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

@@ -1,12 +1,7 @@
 <template>
-  <edit-interaction-base v-bind="{ data, autocomplete }" @change="updateData">
+  <edit-interaction-base v-bind="{ data }" @change="updateData">
     <hr />
-    <ui-autocomplete
-      :items="autocomplete"
-      :trigger-char="['{{', '}}']"
-      block
-      hide-empty
-    >
+    <edit-autocomplete>
       <ui-input
         :model-value="data.attributeName"
         :label="t('workflow.blocks.attribute-value.forms.name')"
@@ -15,7 +10,7 @@
         class="w-full"
         @change="updateData({ attributeName: $event })"
       />
-    </ui-autocomplete>
+    </edit-autocomplete>
     <insert-workflow-data
       :data="data"
       extra-row
@@ -28,16 +23,13 @@
 import { useI18n } from 'vue-i18n';
 import EditInteractionBase from './EditInteractionBase.vue';
 import InsertWorkflowData from './InsertWorkflowData.vue';
+import EditAutocomplete from './EditAutocomplete.vue';
 
 const props = defineProps({
   data: {
     type: Object,
     default: () => ({}),
   },
-  autocomplete: {
-    type: Array,
-    default: () => [],
-  },
 });
 const emit = defineEmits(['update:data']);
 

+ 63 - 0
src/components/newtab/workflow/edit/EditAutocomplete.vue

@@ -0,0 +1,63 @@
+<template>
+  <ui-autocomplete
+    :items="autocompleteList"
+    :trigger-char="['{{', '}}']"
+    :custom-filter="autocompleteFilter"
+    :replace-after="['@', '.']"
+    block
+    @search="onSearch"
+  >
+    <slot />
+  </ui-autocomplete>
+</template>
+<script setup>
+import { inject, shallowReactive, computed } from 'vue';
+import objectPath from 'object-path';
+
+const autocompleteData = inject('autocompleteData', {});
+const state = shallowReactive({
+  path: '',
+  pathLen: -1,
+});
+
+const cache = new Map();
+
+function autocompleteFilter({ text, item }) {
+  const query = text.replace('@', '.').split('.').pop();
+
+  return item.toLocaleLowerCase().includes(query);
+}
+function onSearch(value) {
+  const path = (value ?? '').replace('@', '.');
+  const pathArr = path.split('.');
+
+  if (pathArr.length <= 1) {
+    state.path = '';
+    state.pathLen = 0;
+
+    return;
+  }
+
+  if (pathArr.length !== state.pathLen) {
+    state.path = path.endsWith('.') ? path.slice(0, -1) : path;
+    state.pathLen = pathArr.length;
+  }
+}
+
+const autocompleteList = computed(() => {
+  if (cache.has(state.path)) {
+    return cache.get(state.path);
+  }
+
+  const data =
+    !state.path || state.pathLen < 1
+      ? autocompleteData.value
+      : objectPath.get(autocompleteData.value, state.path);
+
+  const list = typeof data === 'string' ? [] : Object.keys(data || {});
+
+  cache.set(state.path, list);
+
+  return list;
+});
+</script>

+ 3 - 12
src/components/newtab/workflow/edit/EditCloseTab.vue

@@ -30,13 +30,7 @@
           {{ t('workflow.blocks.close-tab.activeTab') }}
         </ui-checkbox>
       </div>
-      <ui-autocomplete
-        v-if="!data.activeTab"
-        :items="autocomplete"
-        :trigger-char="['{{', '}}']"
-        block
-        hide-empty
-      >
+      <edit-autocomplete v-if="!data.activeTab">
         <ui-input
           :model-value="data.url"
           class="w-full mt-1"
@@ -59,7 +53,7 @@
             </a>
           </template>
         </ui-input>
-      </ui-autocomplete>
+      </edit-autocomplete>
     </template>
     <ui-checkbox
       v-else
@@ -73,16 +67,13 @@
 </template>
 <script setup>
 import { useI18n } from 'vue-i18n';
+import EditAutocomplete from './EditAutocomplete.vue';
 
 const props = defineProps({
   data: {
     type: Object,
     default: () => ({}),
   },
-  autocomplete: {
-    type: Array,
-    default: () => [],
-  },
 });
 const emit = defineEmits(['update:data']);
 

+ 22 - 13
src/components/newtab/workflow/edit/EditConditions.vue

@@ -1,13 +1,22 @@
 <template>
   <div>
-    <ui-button
-      :disabled="conditions.length >= 10"
-      variant="accent"
-      class="mb-4"
-      @click="addCondition"
-    >
-      {{ t('workflow.blocks.conditions.add') }}
-    </ui-button>
+    <div class="mb-4 flex items-center justify-between">
+      <ui-button
+        :disabled="conditions.length >= 10"
+        variant="accent"
+        class="mr-2"
+        @click="addCondition"
+      >
+        {{ t('workflow.blocks.conditions.add') }}
+      </ui-button>
+      <ui-button
+        v-tooltip:bottom="t('workflow.blocks.conditions.refresh')"
+        icon
+        @click="refreshConnections"
+      >
+        <v-remixicon name="riRefreshLine" />
+      </ui-button>
+    </div>
     <draggable
       v-model="conditions"
       item-key="id"
@@ -56,7 +65,6 @@
             class="text-xl font-semibold mb-4 bg-transparent focus:ring-0"
           />
           <shared-condition-builder
-            :autocomplete="autocomplete"
             :model-value="conditions[state.conditionsIndex].conditions"
             @change="conditions[state.conditionsIndex].conditions = $event"
           />
@@ -82,10 +90,6 @@ const props = defineProps({
     type: String,
     default: '',
   },
-  autocomplete: {
-    type: Array,
-    default: () => [],
-  },
 });
 const emit = defineEmits(['update:data']);
 
@@ -131,6 +135,11 @@ function deleteCondition(index) {
     id: props.blockId,
   });
 }
+function refreshConnections() {
+  emitter.emit('conditions-block:refresh', {
+    id: props.blockId,
+  });
+}
 
 watch(
   conditions,

+ 110 - 0
src/components/newtab/workflow/edit/EditDeleteData.vue

@@ -0,0 +1,110 @@
+<template>
+  <div>
+    <ui-textarea
+      :model-value="data.description"
+      class="w-full"
+      :placeholder="t('common.description')"
+      @change="updateData({ description: $event })"
+    />
+    <ul class="delete-list mt-4">
+      <li
+        v-for="(item, index) in deleteList"
+        :key="item.id"
+        class="mb-2 pb-4 border-b"
+      >
+        <div class="flex items-end space-x-2">
+          <ui-select
+            v-model="deleteList[index].type"
+            :label="t('workflow.blocks.delete-data.from')"
+            class="flex-1"
+          >
+            <option v-for="type in types" :key="type.id" :value="type.id">
+              {{ type.name }}
+            </option>
+          </ui-select>
+          <ui-button icon @click="deleteList.splice(index, 1)">
+            <v-remixicon name="riDeleteBin7Line" />
+          </ui-button>
+        </div>
+        <ui-input
+          v-if="item.type === 'variable'"
+          v-model="deleteList[index].variableName"
+          :placeholder="t('workflow.variables.name')"
+          :title="t('workflow.variables.name')"
+          autocomplete="off"
+          class="w-full mt-2"
+        />
+        <ui-select
+          v-else
+          v-model="deleteList[index].columnId"
+          :label="t('workflow.table.select')"
+          class="w-full mt-1"
+        >
+          <option value="[all]">
+            {{ t('workflow.blocks.delete-data.allColumns') }}
+          </option>
+          <option value="column">Column</option>
+          <option
+            v-for="column in workflow.data.value.table"
+            :key="column.id"
+            :value="column.id"
+          >
+            {{ column.name }}
+          </option>
+        </ui-select>
+      </li>
+    </ul>
+    <ui-button class="my-4" variant="accent" @click="addItem">
+      {{ t('common.add') }}
+    </ui-button>
+  </div>
+</template>
+<script setup>
+import { inject, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import cloneDeep from 'lodash.clonedeep';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const { t } = useI18n();
+
+const workflow = inject('workflow', {});
+const deleteList = ref(cloneDeep(props.data.deleteList));
+
+const types = [
+  { id: 'table', name: t('workflow.table.title') },
+  { id: 'variable', name: t('workflow.variables.title') },
+];
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+function addItem() {
+  deleteList.value.push({
+    type: 'table',
+    variableName: '',
+    columnId: '[all]',
+  });
+}
+
+watch(
+  deleteList,
+  (value) => {
+    updateData({ deleteList: value });
+  },
+  { deep: true }
+);
+</script>
+<style scoped>
+.delete-list li:last-child {
+  padding-bottom: 0;
+  margin-bottom: 0;
+  border-bottom: 0;
+}
+</style>

+ 3 - 12
src/components/newtab/workflow/edit/EditElementExists.vue

@@ -10,13 +10,7 @@
         {{ t(`workflow.blocks.base.findElement.options.${type}`) }}
       </option>
     </ui-select>
-    <ui-autocomplete
-      :items="autocomplete"
-      :trigger-char="['{{', '}}']"
-      block
-      hide-empty
-      class="mb-1"
-    >
+    <edit-autocomplete class="mb-1">
       <ui-input
         :model-value="data.selector"
         :label="t('workflow.blocks.element-exists.selector')"
@@ -25,7 +19,7 @@
         class="w-full"
         @change="updateData({ selector: $event })"
       />
-    </ui-autocomplete>
+    </edit-autocomplete>
     <ui-input
       :model-value="data.tryCount"
       :title="t('workflow.blocks.element-exists.tryFor.title')"
@@ -57,16 +51,13 @@
 <script setup>
 import { onMounted } from 'vue';
 import { useI18n } from 'vue-i18n';
+import EditAutocomplete from './EditAutocomplete.vue';
 
 const props = defineProps({
   data: {
     type: Object,
     default: () => ({}),
   },
-  autocomplete: {
-    type: Array,
-    default: () => [],
-  },
 });
 const emit = defineEmits(['update:data']);
 

+ 3 - 12
src/components/newtab/workflow/edit/EditExportData.vue

@@ -32,13 +32,7 @@
       class="w-full mt-2"
       @change="updateData({ variableName: $event })"
     />
-    <ui-autocomplete
-      :items="autocomplete"
-      :trigger-char="['{{', '}}']"
-      block
-      hide-empty
-      class="mt-2"
-    >
+    <edit-autocomplete class="mt-2">
       <ui-input
         :model-value="data.name"
         autocomplete="off"
@@ -47,7 +41,7 @@
         placeholder="unnamed"
         @change="updateData({ name: $event })"
       />
-    </ui-autocomplete>
+    </edit-autocomplete>
     <ui-select
       v-if="permission.has.downloads"
       :model-value="data.onConflict"
@@ -83,16 +77,13 @@
 import { useI18n } from 'vue-i18n';
 import { dataExportTypes } from '@/utils/shared';
 import { useHasPermissions } from '@/composable/hasPermissions';
+import EditAutocomplete from './EditAutocomplete.vue';
 
 const props = defineProps({
   data: {
     type: Object,
     default: () => ({}),
   },
-  autocomplete: {
-    type: Array,
-    default: () => [],
-  },
 });
 const emit = defineEmits(['update:data']);
 

+ 4 - 16
src/components/newtab/workflow/edit/EditForms.vue

@@ -1,8 +1,5 @@
 <template>
-  <edit-interaction-base
-    v-bind="{ data, hide: hideBase, autocomplete }"
-    @change="updateData"
-  >
+  <edit-interaction-base v-bind="{ data, hide: hideBase }" @change="updateData">
     <hr />
     <ui-checkbox
       :model-value="data.getValue"
@@ -35,20 +32,14 @@
         {{ t('workflow.blocks.forms.selected') }}
       </ui-checkbox>
       <template v-if="data.type === 'text-field' || data.type === 'select'">
-        <ui-autocomplete
-          :items="autocomplete"
-          :trigger-char="['{{', '}}']"
-          block
-          hide-empty
-          class="w-full mb-1"
-        >
+        <edit-autocomplete class="w-full mb-1">
           <ui-textarea
             :model-value="data.value"
             :placeholder="t('workflow.blocks.forms.text-field.value')"
             class="w-full"
             @change="updateData({ value: $event })"
           />
-        </ui-autocomplete>
+        </edit-autocomplete>
         <ui-checkbox
           :model-value="data.clearValue"
           @change="updateData({ clearValue: $event })"
@@ -73,6 +64,7 @@
 import { useI18n } from 'vue-i18n';
 import InsertWorkflowData from './InsertWorkflowData.vue';
 import EditInteractionBase from './EditInteractionBase.vue';
+import EditAutocomplete from './EditAutocomplete.vue';
 
 const props = defineProps({
   data: {
@@ -83,10 +75,6 @@ const props = defineProps({
     type: Boolean,
     default: false,
   },
-  autocomplete: {
-    type: Array,
-    default: () => [],
-  },
 });
 const emit = defineEmits(['update:data']);
 

+ 6 - 21
src/components/newtab/workflow/edit/EditGetText.vue

@@ -1,5 +1,5 @@
 <template>
-  <edit-interaction-base v-bind="{ data, autocomplete }" @change="updateData">
+  <edit-interaction-base v-bind="{ data }" @change="updateData">
     <hr />
     <div class="flex rounded-lg bg-input px-4 items-center transition">
       <span>/</span>
@@ -27,13 +27,7 @@
       </ui-popover>
     </div>
     <div class="mt-2 flex space-x-2">
-      <ui-autocomplete
-        :items="autocomplete"
-        :trigger-char="['{{', '}}']"
-        block
-        hide-empty
-        class="w-full"
-      >
+      <edit-autocomplete class="w-full">
         <ui-input
           :model-value="data.prefixText"
           :title="t('workflow.blocks.get-text.prefixText.title')"
@@ -43,14 +37,8 @@
           class="w-full"
           @change="updateData({ prefixText: $event })"
         />
-      </ui-autocomplete>
-      <ui-autocomplete
-        :items="autocomplete"
-        :trigger-char="['{{', '}}']"
-        block
-        hide-empty
-        class="w-full"
-      >
+      </edit-autocomplete>
+      <edit-autocomplete class="w-full">
         <ui-input
           :model-value="data.suffixText"
           :title="t('workflow.blocks.get-text.suffixText.title')"
@@ -60,7 +48,7 @@
           class="w-full"
           @change="updateData({ suffixText: $event })"
         />
-      </ui-autocomplete>
+      </edit-autocomplete>
     </div>
     <ui-checkbox
       :model-value="data.includeTags"
@@ -83,16 +71,13 @@ import { ref } from 'vue';
 import { useI18n } from 'vue-i18n';
 import InsertWorkflowData from './InsertWorkflowData.vue';
 import EditInteractionBase from './EditInteractionBase.vue';
+import EditAutocomplete from './EditAutocomplete.vue';
 
 const props = defineProps({
   data: {
     type: Object,
     default: () => ({}),
   },
-  autocomplete: {
-    type: Array,
-    default: () => [],
-  },
 });
 const emit = defineEmits(['update:data']);
 

+ 5 - 18
src/components/newtab/workflow/edit/EditGoogleSheets.vue

@@ -18,12 +18,7 @@
         {{ t('workflow.blocks.google-sheets.select.update') }}
       </option>
     </ui-select>
-    <ui-autocomplete
-      :items="autocomplete"
-      :trigger-char="['{{', '}}']"
-      block
-      hide-empty
-    >
+    <edit-autocomplete>
       <ui-input
         :model-value="data.spreadsheetId"
         class="w-full"
@@ -42,13 +37,8 @@
           </a>
         </template>
       </ui-input>
-    </ui-autocomplete>
-    <ui-autocomplete
-      :items="autocomplete"
-      :trigger-char="['{{', '}}']"
-      block
-      hide-empty
-    >
+    </edit-autocomplete>
+    <edit-autocomplete>
       <ui-input
         :model-value="data.range"
         class="w-full mt-1"
@@ -67,7 +57,7 @@
           </a>
         </template>
       </ui-input>
-    </ui-autocomplete>
+    </edit-autocomplete>
     <template v-if="data.type === 'get'">
       <ui-input
         :model-value="data.refKey"
@@ -172,6 +162,7 @@ import { shallowReactive, defineAsyncComponent } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { googleSheets } from '@/utils/api';
 import { convert2DArrayToArrayObj } from '@/utils/helper';
+import EditAutocomplete from './EditAutocomplete.vue';
 
 const SharedCodemirror = defineAsyncComponent(() =>
   import('@/components/newtab/shared/SharedCodemirror.vue')
@@ -182,10 +173,6 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
-  autocomplete: {
-    type: Array,
-    default: () => [],
-  },
 });
 const emit = defineEmits(['update:data']);
 

+ 3 - 13
src/components/newtab/workflow/edit/EditHandleDialog.vue

@@ -14,14 +14,7 @@
     >
       {{ t('workflow.blocks.handle-dialog.accept') }}
     </ui-checkbox>
-    <ui-autocomplete
-      v-if="data.accept"
-      :items="autocomplete"
-      :trigger-char="['{{', '}}']"
-      block
-      hide-empty
-      class="mt-1"
-    >
+    <edit-autocomplete v-if="data.accept" class="mt-1">
       <ui-input
         :model-value="data.promptText"
         :label="t('workflow.blocks.handle-dialog.promptText.label')"
@@ -31,21 +24,18 @@
         class="w-full"
         @change="updateData({ promptText: $event })"
       />
-    </ui-autocomplete>
+    </edit-autocomplete>
   </div>
 </template>
 <script setup>
 import { useI18n } from 'vue-i18n';
+import EditAutocomplete from './EditAutocomplete.vue';
 
 const props = defineProps({
   data: {
     type: Object,
     default: () => ({}),
   },
-  autocomplete: {
-    type: Array,
-    default: () => [],
-  },
 });
 const emit = defineEmits(['update:data']);
 

+ 3 - 13
src/components/newtab/workflow/edit/EditInteractionBase.vue

@@ -20,14 +20,7 @@
           {{ t(`workflow.blocks.base.findElement.options.${type}`) }}
         </option>
       </ui-select>
-      <ui-autocomplete
-        v-if="!hideSelector"
-        :items="autocomplete"
-        :trigger-char="['{{', '}}']"
-        block
-        hide-empty
-        class="mb-1"
-      >
+      <edit-autocomplete v-if="!hideSelector" class="mb-1">
         <ui-input
           v-if="!hideSelector"
           :model-value="data.selector"
@@ -36,7 +29,7 @@
           class="w-full"
           @change="updateData({ selector: $event })"
         />
-      </ui-autocomplete>
+      </edit-autocomplete>
       <ui-expand
         v-if="!hideSelector"
         hide-header-icon
@@ -94,6 +87,7 @@
 <script setup>
 import { onMounted } from 'vue';
 import { useI18n } from 'vue-i18n';
+import EditAutocomplete from './EditAutocomplete.vue';
 
 const props = defineProps({
   data: {
@@ -112,10 +106,6 @@ const props = defineProps({
     type: Boolean,
     default: false,
   },
-  autocomplete: {
-    type: Array,
-    default: () => [],
-  },
 });
 const emit = defineEmits(['update:data', 'change']);
 

+ 3 - 13
src/components/newtab/workflow/edit/EditLoopData.vue

@@ -44,14 +44,7 @@
       class="w-full mt-2"
       @change="updateData({ variableName: $event })"
     />
-    <ui-autocomplete
-      v-else-if="data.loopThrough === 'elements'"
-      :items="autocomplete"
-      :trigger-char="['{{', '}}']"
-      block
-      hide-empty
-      class="mt-2"
-    >
+    <edit-autocomplete v-else-if="data.loopThrough === 'elements'" class="mt-2">
       <ui-input
         :model-value="data.elementSelector"
         :label="t('workflow.blocks.base.selector')"
@@ -60,7 +53,7 @@
         class="w-full"
         @change="updateData({ elementSelector: $event })"
       />
-    </ui-autocomplete>
+    </edit-autocomplete>
     <ui-button
       v-else-if="data.loopThrough === 'custom-data'"
       class="w-full mt-4"
@@ -169,6 +162,7 @@ import { useI18n } from 'vue-i18n';
 import { useToast } from 'vue-toastification';
 import Papa from 'papaparse';
 import { openFilePicker } from '@/utils/helper';
+import EditAutocomplete from './EditAutocomplete.vue';
 
 const SharedCodemirror = defineAsyncComponent(() =>
   import('@/components/newtab/shared/SharedCodemirror.vue')
@@ -183,10 +177,6 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
-  autocomplete: {
-    type: Array,
-    default: () => [],
-  },
 });
 const emit = defineEmits(['update:data']);
 

+ 3 - 13
src/components/newtab/workflow/edit/EditNewTab.vue

@@ -6,14 +6,7 @@
       class="w-full"
       @change="updateData({ description: $event })"
     />
-    <ui-autocomplete
-      v-if="!data.activeTab"
-      :items="autocomplete"
-      :trigger-char="['{{', '}}']"
-      block
-      hide-empty
-      class="mt-2"
-    >
+    <edit-autocomplete v-if="!data.activeTab" class="mt-2">
       <ui-input
         :model-value="data.url"
         :label="t('workflow.blocks.new-tab.url')"
@@ -22,7 +15,7 @@
         placeholder="http://example.com/"
         @change="updateData({ url: $event })"
       />
-    </ui-autocomplete>
+    </edit-autocomplete>
     <ui-checkbox
       :model-value="data.updatePrevTab"
       class="leading-tight mt-2"
@@ -63,16 +56,13 @@
 </template>
 <script setup>
 import { useI18n } from 'vue-i18n';
+import EditAutocomplete from './EditAutocomplete.vue';
 
 const props = defineProps({
   data: {
     type: Object,
     default: () => ({}),
   },
-  autocomplete: {
-    type: Array,
-    default: () => [],
-  },
 });
 const emit = defineEmits(['update:data']);
 

+ 5 - 21
src/components/newtab/workflow/edit/EditSaveAssets.vue

@@ -2,7 +2,6 @@
   <edit-interaction-base
     :data="data"
     :hide="!permission.has.downloads"
-    :autocomplete="autocomplete"
     :hide-selector="data.type !== 'element'"
     @change="updateData"
   >
@@ -28,13 +27,7 @@
         </ui-button>
       </template>
     </template>
-    <ui-autocomplete
-      v-if="data.type === 'url'"
-      :items="autocomplete"
-      :trigger-char="['{{', '}}']"
-      block
-      hide-empty
-    >
+    <edit-autocomplete v-if="data.type === 'url'">
       <ui-input
         :model-value="data.url"
         label="URL"
@@ -43,15 +36,9 @@
         placeholder="https://example.com/picture.png"
         @change="updateData({ url: $event })"
       />
-    </ui-autocomplete>
+    </edit-autocomplete>
     <template v-if="permission.has.downloads">
-      <ui-autocomplete
-        :items="autocomplete"
-        :trigger-char="['{{', '}}']"
-        block
-        hide-empty
-        class="mt-4"
-      >
+      <edit-autocomplete class="mt-4">
         <ui-input
           :model-value="data.filename"
           :label="t('workflow.blocks.save-assets.filename')"
@@ -60,7 +47,7 @@
           placeholder="image.jpeg"
           @change="updateData({ filename: $event })"
         />
-      </ui-autocomplete>
+      </edit-autocomplete>
       <ui-select
         :model-value="data.onConflict"
         :label="t('workflow.blocks.handle-download.onConflict')"
@@ -78,16 +65,13 @@
 import { useI18n } from 'vue-i18n';
 import { useHasPermissions } from '@/composable/hasPermissions';
 import EditInteractionBase from './EditInteractionBase.vue';
+import EditAutocomplete from './EditAutocomplete.vue';
 
 const props = defineProps({
   data: {
     type: Object,
     default: () => ({}),
   },
-  autocomplete: {
-    type: Array,
-    default: () => [],
-  },
 });
 const emit = defineEmits(['update:data']);
 

+ 1 - 8
src/components/newtab/workflow/edit/EditScrollElement.vue

@@ -1,8 +1,5 @@
 <template>
-  <edit-interaction-base
-    v-bind="{ data, autocomplete, hide: hideBase }"
-    @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"
@@ -61,10 +58,6 @@ const props = defineProps({
     type: Boolean,
     default: false,
   },
-  autocomplete: {
-    type: Array,
-    default: () => [],
-  },
 });
 const emit = defineEmits(['update:data']);
 

+ 5 - 21
src/components/newtab/workflow/edit/EditSwitchTab.vue

@@ -1,11 +1,6 @@
 <template>
   <div>
-    <ui-autocomplete
-      :items="autocomplete"
-      :trigger-char="['{{', '}}']"
-      block
-      hide-empty
-    >
+    <edit-autocomplete>
       <ui-input
         :model-value="data.matchPattern"
         placeholder="https://example.com/*"
@@ -29,7 +24,7 @@
           </a>
         </template>
       </ui-input>
-    </ui-autocomplete>
+    </edit-autocomplete>
     <ui-checkbox
       :model-value="data.createIfNoMatch"
       class="mt-1"
@@ -37,15 +32,7 @@
     >
       {{ t('workflow.blocks.switch-tab.createIfNoMatch') }}
     </ui-checkbox>
-    <ui-autocomplete
-      v-if="data.createIfNoMatch"
-      :items="autocomplete"
-      :trigger-char="['{{', '}}']"
-      block
-      hide-empty
-      class="mt-2"
-      @change="updateData({ url: $event })"
-    >
+    <edit-autocomplete v-if="data.createIfNoMatch" class="mt-2">
       <ui-input
         :model-value="data.url"
         :label="t('workflow.blocks.switch-tab.url')"
@@ -53,21 +40,18 @@
         class="w-full"
         @change="updateData({ url: $event })"
       />
-    </ui-autocomplete>
+    </edit-autocomplete>
   </div>
 </template>
 <script setup>
 import { useI18n } from 'vue-i18n';
+import EditAutocomplete from './EditAutocomplete.vue';
 
 const props = defineProps({
   data: {
     type: Object,
     default: () => ({}),
   },
-  autocomplete: {
-    type: Array,
-    default: () => [],
-  },
 });
 const emit = defineEmits(['update:data']);
 

+ 3 - 6
src/components/newtab/workflow/edit/EditSwitchTo.vue

@@ -19,7 +19,7 @@
         {{ t('workflow.blocks.switch-to.windowTypes.iframe') }}
       </option>
     </ui-select>
-    <ui-autocomplete
+    <edit-autocomplete
       v-if="data.windowType === 'iframe'"
       :items="autocomplete"
       :trigger-char="['{{', '}}']"
@@ -34,21 +34,18 @@
         class="mb-1 w-full"
         @change="updateData({ selector: $event })"
       />
-    </ui-autocomplete>
+    </edit-autocomplete>
   </div>
 </template>
 <script setup>
 import { useI18n } from 'vue-i18n';
+import EditAutocomplete from './EditAutocomplete.vue';
 
 const props = defineProps({
   data: {
     type: Object,
     default: () => ({}),
   },
-  autocomplete: {
-    type: Array,
-    default: () => [],
-  },
 });
 const emit = defineEmits(['update:data']);
 

+ 58 - 34
src/components/newtab/workflow/edit/EditTakeScreenshot.vue

@@ -1,26 +1,46 @@
 <template>
-  <template v-if="data.ext === 'jpeg'">
-    <p class="text-sm text-gray-600 dark:text-gray-200 ml-2">Image quality</p>
-    <div class="bg-box-transparent px-4 mb-4 py-2 rounded-lg flex items-center">
-      <input
-        :value="data.quality"
-        :title="t('workflow.blocks.take-screenshot.imageQuality')"
-        class="focus:outline-none flex-1"
-        type="range"
-        min="0"
-        max="100"
-        @change="updateQuality"
-      />
-      <span class="w-12 text-right">{{ data.quality }}%</span>
-    </div>
-  </template>
   <div class="take-screenshot">
-    <ui-checkbox
-      :model-value="data.fullPage"
-      @change="updateData({ fullPage: $event })"
+    <ui-textarea
+      :model-value="data.description"
+      :placeholder="t('common.description')"
+      class="w-full"
+      @change="updateData({ description: $event })"
+    />
+    <ui-select
+      :model-value="data.type"
+      :label="t('workflow.blocks.take-screenshot.types.title')"
+      class="w-full mt-2"
+      @change="updateData({ type: $event })"
     >
-      {{ t('workflow.blocks.take-screenshot.fullPage') }}
-    </ui-checkbox>
+      <option v-for="type in types" :key="type" :value="type">
+        {{ t(`workflow.blocks.take-screenshot.types.${type}`) }}
+      </option>
+    </ui-select>
+    <ui-input
+      v-if="data.type === 'element'"
+      :model-value="data.selector"
+      :label="t(`workflow.blocks.base.findElement.options.cssSelector`)"
+      class="mt-2 w-full"
+      placeholder=".element"
+      @change="updateData({ selector: $event })"
+    />
+    <template v-if="data.ext === 'jpeg'">
+      <p class="text-sm text-gray-600 dark:text-gray-200 ml-2 mt-4">
+        {{ t('workflow.blocks.take-screenshot.imageQuality') }}
+      </p>
+      <div class="bg-box-transparent px-4 py-2 rounded-lg flex items-center">
+        <input
+          :value="data.quality"
+          :title="t('workflow.blocks.take-screenshot.imageQuality')"
+          class="focus:outline-none flex-1"
+          type="range"
+          min="0"
+          max="100"
+          @change="updateQuality"
+        />
+        <span class="w-12 text-right">{{ data.quality }}%</span>
+      </div>
+    </template>
     <ui-checkbox
       :model-value="data.saveToComputer"
       class="mt-4"
@@ -29,13 +49,7 @@
       {{ t('workflow.blocks.take-screenshot.saveToComputer') }}
     </ui-checkbox>
     <div v-if="data.saveToComputer" class="flex items-center mt-1">
-      <ui-autocomplete
-        :items="autocomplete"
-        :trigger-char="['{{', '}}']"
-        block
-        hide-empty
-        class="flex-1 mr-2"
-      >
+      <edit-autocomplete class="flex-1 mr-2">
         <ui-input
           :model-value="data.fileName"
           :placeholder="t('common.fileName')"
@@ -44,7 +58,7 @@
           title="File name"
           @change="updateData({ fileName: $event })"
         />
-      </ui-autocomplete>
+      </edit-autocomplete>
       <ui-select
         :model-value="data.ext || 'png'"
         placeholder="Type"
@@ -95,25 +109,25 @@
   </div>
 </template>
 <script setup>
+/* eslint-disable no-unused-expressions */
 import { inject, onMounted } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { objectHasKey } from '@/utils/helper';
+import EditAutocomplete from './EditAutocomplete.vue';
 
 const props = defineProps({
   data: {
     type: Object,
     default: () => ({}),
   },
-  autocomplete: {
-    type: Array,
-    default: () => [],
-  },
 });
 const emit = defineEmits(['update:data']);
 
 const { t } = useI18n();
 const workflow = inject('workflow');
 
+const types = ['page', 'fullpage', 'element'];
+
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });
 }
@@ -127,8 +141,18 @@ function updateQuality({ target }) {
 }
 
 onMounted(() => {
-  if (objectHasKey(props.data, 'saveToComputer')) return;
+  if (!objectHasKey(props.data, 'saveToComputer')) {
+    updateData({ saveToComputer: true, saveToColumn: false });
+  }
+
+  if (!objectHasKey(props.data, 'type')) {
+    const type = 'page';
+
+    if (props.data.fullPage) {
+      type === 'fullpage';
+    }
 
-  updateData({ saveToComputer: true, saveToColumn: false });
+    updateData({ type, fullPage: false });
+  }
 });
 </script>

+ 1 - 8
src/components/newtab/workflow/edit/EditTriggerEvent.vue

@@ -1,8 +1,5 @@
 <template>
-  <edit-interaction-base
-    v-bind="{ data, autocomplete, hide: hideBase }"
-    @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')"
@@ -81,10 +78,6 @@ const props = defineProps({
     type: Boolean,
     default: false,
   },
-  autocomplete: {
-    type: Array,
-    default: () => [],
-  },
 });
 const emit = defineEmits(['update:data']);
 

+ 4 - 16
src/components/newtab/workflow/edit/EditUploadFile.vue

@@ -1,8 +1,5 @@
 <template>
-  <edit-interaction-base
-    v-bind="{ data, autocomplete, hide: hideBase }"
-    @change="updateData"
-  >
+  <edit-interaction-base v-bind="{ data, hide: hideBase }" @change="updateData">
     <template v-if="hasFileAccess">
       <div class="mt-4 space-y-2">
         <div
@@ -10,20 +7,14 @@
           :key="index"
           class="flex items-center group"
         >
-          <ui-autocomplete
-            :items="autocomplete"
-            :trigger-char="['{{', '}}']"
-            block
-            hide-empty
-            class="mr-2"
-          >
+          <edit-autocomplete class="mr-2">
             <ui-input
               v-model="filePaths[index]"
               :placeholder="t('workflow.blocks.upload-file.filePath')"
               autocomplete="off"
               class="w-full"
             />
-          </ui-autocomplete>
+          </edit-autocomplete>
           <v-remixicon
             name="riDeleteBin7Line"
             class="invisible cursor-pointer group-hover:visible"
@@ -57,6 +48,7 @@
 import { useI18n } from 'vue-i18n';
 import { ref, watch } from 'vue';
 import EditInteractionBase from './EditInteractionBase.vue';
+import EditAutocomplete from './EditAutocomplete.vue';
 
 const props = defineProps({
   data: {
@@ -67,10 +59,6 @@ const props = defineProps({
     type: Boolean,
     default: false,
   },
-  autocomplete: {
-    type: Array,
-    default: () => [],
-  },
 });
 const emit = defineEmits(['update:data']);
 

+ 66 - 0
src/components/newtab/workflow/edit/EditWaitConnections.vue

@@ -0,0 +1,66 @@
+<template>
+  <div class="mb-4">
+    <ui-textarea
+      :model-value="data.description"
+      class="w-full"
+      :placeholder="t('common.description')"
+      @change="updateData({ description: $event })"
+    />
+    <ui-input
+      :model-value="data.timeout"
+      :label="t('workflow.blocks.base.timeout')"
+      placeholder="10000"
+      type="number"
+      class="w-full mt-1"
+      @change="updateData({ timeout: +$event })"
+    />
+    <ui-checkbox
+      :model-value="data.specificFlow"
+      class="mt-4"
+      @change="updateData({ specificFlow: $event })"
+    >
+      {{ t('workflow.blocks.wait-connections.specificFlow') }}
+    </ui-checkbox>
+    <ui-select
+      v-if="data.specificFlow"
+      :model-value="data.flowBlockId"
+      :label="t('workflow.blocks.wait-connections.selectFlow')"
+      class="w-full mt-1"
+      @change="updateData({ flowBlockId: $event })"
+    >
+      <option v-for="item in connections" :key="item.id" :value="item.id">
+        {{ item.name }}
+      </option>
+    </ui-select>
+  </div>
+</template>
+<script setup>
+import { onMounted } from 'vue';
+import { useI18n } from 'vue-i18n';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+  connections: {
+    type: Array,
+    default: () => [],
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const { t } = useI18n();
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+
+onMounted(() => {
+  if (props.data.flowBlockId) return;
+
+  updateData({
+    flowBlockId: props.connections[0]?.id || '',
+  });
+});
+</script>

+ 3 - 12
src/components/newtab/workflow/edit/EditWebhook.vue

@@ -16,13 +16,7 @@
         {{ method }}
       </option>
     </ui-select>
-    <ui-autocomplete
-      :items="autocomplete"
-      :trigger-char="['{{', '}}']"
-      block
-      hide-empty
-      class="mb-2"
-    >
+    <edit-autocomplete class="mb-2">
       <ui-input
         :model-value="data.url"
         :label="`${t('workflow.blocks.webhook.url')}*`"
@@ -33,7 +27,7 @@
         type="url"
         @change="updateData({ url: $event })"
       />
-    </ui-autocomplete>
+    </edit-autocomplete>
     <ui-select
       :model-value="data.contentType"
       :label="t('workflow.blocks.webhook.contentType')"
@@ -157,6 +151,7 @@ import { ref, watch, defineAsyncComponent } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { contentTypes } from '@/utils/shared';
 import InsertWorkflowData from './InsertWorkflowData.vue';
+import EditAutocomplete from './EditAutocomplete.vue';
 
 const SharedCodemirror = defineAsyncComponent(() =>
   import('@/components/newtab/shared/SharedCodemirror.vue')
@@ -167,10 +162,6 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
-  autocomplete: {
-    type: Array,
-    default: () => [],
-  },
 });
 const emit = defineEmits(['update:data']);
 

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

@@ -27,7 +27,6 @@
         </div>
         <shared-condition-builder
           :model-value="data.conditions"
-          :autocomplete="autocomplete"
           class="overflow-auto p-4 mt-4 scroll"
           style="height: calc(100vh - 8rem)"
           @change="updateData({ conditions: $event })"
@@ -47,10 +46,6 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
-  autocomplete: {
-    type: Array,
-    default: () => [],
-  },
 });
 const emit = defineEmits(['update:data']);
 

+ 46 - 17
src/components/ui/UiAutocomplete.vue

@@ -46,7 +46,7 @@ const props = defineProps({
     default: '',
   },
   items: {
-    type: Array,
+    type: [Array, Object],
     default: () => [],
   },
   itemKey: {
@@ -65,8 +65,16 @@ const props = defineProps({
     type: Boolean,
     default: false,
   },
+  customFilter: {
+    type: Function,
+    default: null,
+  },
+  replaceAfter: {
+    type: [String, Array],
+    default: null,
+  },
 });
-const emit = defineEmits(['update:modelValue', 'change']);
+const emit = defineEmits(['update:modelValue', 'change', 'search']);
 
 let input = null;
 const componentId = useComponentId('autocomplete');
@@ -89,10 +97,14 @@ const filteredItems = computed(() => {
     triggerChar ? state.searchText : props.modelValue
   ).toLocaleLowerCase();
 
+  const defaultFilter = ({ item, text }) => {
+    return getItem(item)?.toLocaleLowerCase().includes(text);
+  };
+  const filterFunction = props.customFilter || defaultFilter;
+
   return props.items.filter(
-    (item) =>
-      !state.inputChanged ||
-      getItem(item)?.toLocaleLowerCase().includes(searchText)
+    (item, index) =>
+      !state.inputChanged || filterFunction({ item, index, text: searchText })
   );
 });
 
@@ -133,6 +145,8 @@ function showPopover() {
     const charIndex = getLastKeyBeforeCaret(selectionStart);
     const text = getSearchText(selectionStart, charIndex);
 
+    emit('search', text);
+
     if (charIndex >= 0 && text) {
       state.inputChanged = true;
       state.showPopover = true;
@@ -184,26 +198,41 @@ function selectItem(itemIndex) {
     const val = input.value;
     const index = state.charIndex;
     const charLength = props.triggerChar[0].length;
+    const lastSearchIndex = state.searchText.length + index + charLength;
+
+    let charLastIndex = 0;
 
-    caretPosition = index + charLength + selectedItem.length;
+    if (props.replaceAfter) {
+      const lastChars = Array.isArray(props.replaceAfter)
+        ? props.replaceAfter
+        : [props.replaceAfter];
+      lastChars.forEach((char) => {
+        const searchText = val.slice(0, lastSearchIndex);
+        const lastIndex = searchText.lastIndexOf(char);
+
+        if (lastIndex > charLastIndex && lastIndex > index) {
+          charLastIndex = lastIndex - 1;
+        }
+      });
+    }
+
+    caretPosition = index + charLength + selectedItem.length + charLastIndex;
     selectedItem =
-      val.slice(0, index + charLength) +
+      val.slice(0, index + charLength + charLastIndex) +
       selectedItem +
-      val.slice(state.searchText.length + index + charLength, val.length);
+      val.slice(lastSearchIndex, val.length);
   }
 
   updateValue(selectedItem);
 
   if (isTriggerChar) {
-    setTimeout(() => {
-      input.selectionEnd = caretPosition;
-      const isNotTextarea = input.tagName !== 'TEXTAREA';
-
-      if (isNotTextarea) {
-        input.blur();
-        input.focus();
-      }
-    }, 300);
+    input.selectionEnd = caretPosition;
+    const isNotTextarea = input.tagName !== 'TEXTAREA';
+
+    if (isNotTextarea) {
+      input.blur();
+      input.focus();
+    }
   }
 }
 function handleKeydown(event) {

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

@@ -20,6 +20,10 @@ function findData(obj, path) {
   const paths = path.split('.');
   const isWhitespace = paths.length === 1 && !/\\S/.test(paths[0]);
 
+  if (path.startsWith('$last') && Array.isArray(obj)) {
+    paths[0] = obj.length - 1;
+  }
+
   if (paths.length === 0 || isWhitespace) return obj;
   else if (paths.length === 1) return obj[paths[0]];
 

+ 58 - 18
src/content/blocks-handler/handler-take-screenshot.js

@@ -1,5 +1,6 @@
 /* eslint-disable no-await-in-loop */
 import { sendMessage } from '@/utils/message';
+import { sleep } from '@/utils/helper';
 
 function findScrollableElement(
   element = document.documentElement,
@@ -39,39 +40,78 @@ function injectStyle() {
 
   return style;
 }
-
-const loadAsyncImg = (src) =>
-  new Promise((resolve) => {
+function canvasToBase64(canvas, { format, quality }) {
+  return canvas.toDataURL(`image/${format}`, quality / 100);
+}
+function loadAsyncImg(src) {
+  return new Promise((resolve) => {
     const image = new Image();
     image.onload = () => {
       resolve(image);
     };
     image.src = src;
   });
+}
+async function takeScreenshot(tabId, options) {
+  await sendMessage('set:active-tab', tabId, 'background');
+  const imageUrl = await sendMessage(
+    'get:tab-screenshot',
+    options,
+    'background'
+  );
+
+  return imageUrl;
+}
+async function captureElementScreenshot({ selector, tabId, options }) {
+  const element = document.querySelector(selector);
 
-export default async function ({ tabId, options }) {
-  document.body.classList.add('is-screenshotting');
+  if (!element) {
+    const error = new Error('element-not-found');
+
+    throw error;
+  }
+
+  element.scrollIntoView();
+
+  await sleep(500);
+
+  const imageUrl = await takeScreenshot(tabId, options);
+  const image = await loadAsyncImg(imageUrl);
 
   const canvas = document.createElement('canvas');
   const context = canvas.getContext('2d');
-  const maxCanvasSize = 32767;
+  const { height, width, x, y } = element.getBoundingClientRect();
 
-  const scrollElement = document.querySelector('.automa-scrollable-el');
-  let scrollableElement = scrollElement || findScrollableElement();
+  canvas.width = width;
+  canvas.height = height;
+
+  context.drawImage(image, x, y, width, height, 0, 0, width, height);
 
-  const takeScreenshot = async () => {
-    await sendMessage('set:active-tab', tabId, 'background');
-    const imageUrl = await sendMessage(
-      'get:tab-screenshot',
+  return canvasToBase64(canvas, options);
+}
+
+export default async function ({ tabId, options, type, selector }) {
+  if (type === 'element') {
+    const imageUrl = await captureElementScreenshot({
+      tabId,
       options,
-      'background'
-    );
+      selector,
+    });
 
     return imageUrl;
-  };
+  }
+
+  document.body.classList.add('is-screenshotting');
+
+  const canvas = document.createElement('canvas');
+  const context = canvas.getContext('2d');
+  const maxCanvasSize = 32767;
+
+  const scrollElement = document.querySelector('.automa-scrollable-el');
+  let scrollableElement = scrollElement || findScrollableElement();
 
   if (!scrollableElement) {
-    const imageUrl = await takeScreenshot();
+    const imageUrl = await takeScreenshot(tabId, options);
 
     return imageUrl;
   }
@@ -102,7 +142,7 @@ export default async function ({ tabId, options }) {
   if (scrollableElement.tagName === 'HTML') scrollableElement = window;
 
   while (scrollPosition <= originalScrollHeight) {
-    const imageUrl = await takeScreenshot();
+    const imageUrl = await takeScreenshot(tabId, options);
 
     if (scrollPosition > 0 && !document.body.classList.contains('hide-fixed')) {
       document.body.classList.add('hide-fixed');
@@ -139,5 +179,5 @@ export default async function ({ tabId, options }) {
 
   scrollableElement.scrollTo(0, originalYPosition);
 
-  return canvas.toDataURL(`image/${options.format}`, options.quality / 100);
+  return canvasToBase64(canvas, options);
 }

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

@@ -90,6 +90,7 @@ import {
   riArrowLeftSLine,
   riFullscreenLine,
   riFlashlightLine,
+  riTimerFlashLine,
   riBaseStationLine,
   riInformationLine,
   riArrowUpDownLine,
@@ -200,6 +201,7 @@ export const icons = {
   riArrowLeftSLine,
   riFullscreenLine,
   riFlashlightLine,
+  riTimerFlashLine,
   riBaseStationLine,
   riInformationLine,
   riArrowUpDownLine,

+ 29 - 1
src/locales/en/blocks.json

@@ -12,6 +12,11 @@
       "base": {
         "moveToGroup": "Move block to blocks group",
         "selector": "Element selector",
+        "timeout": "Timeout (milliseconds)",
+        "toggle": {
+          "enable": "Enable block",
+          "disable": "Disable block",
+        },
         "onError": {
           "info": "These rules will apply when an error occurs on the block",
           "button": "On error",
@@ -70,6 +75,22 @@
           }
         }
       },
+      "wait-connections": {
+        "name": "Wait connections",
+        "description": "Wait for all connections before continuing to the next block",
+        "specificFlow": "Only continue a specific flow",
+        "selectFlow": "Select flow"
+      },
+      "delete-data": {
+        "name": "Delete data",
+        "description": "Delete table or variable data",
+        "from": "Data from",
+        "allColumns": "[All columns]"
+      },
+      "reload-tab": {
+        "name": "Reload tab",
+        "description": "Reload the active tab"
+      },
       "save-assets": {
         "name": "Save assets",
         "description": "Save assets (image, video, audio, or file) from an element or URL",
@@ -414,6 +435,7 @@
         "name": "Conditions",
         "add": "Add condition",
         "description": "Conditional block",
+        "refresh": "Refresh conditions connections",
         "fallbackTitle": "Execute when all comparisons don't meet the requirement",
         "equals": "Equals",
         "gt": "Greater than",
@@ -513,7 +535,13 @@
         "description": "Take a screenshot of current active tab",
         "imageQuality": "Image quality",
         "saveToColumn": "Insert screenshot to table",
-        "saveToComputer": "Save screenshot to computer"
+        "saveToComputer": "Save screenshot to computer",
+        "types": {
+          "title": "Take a screenshot of",
+          "page": "Page",
+          "fullpage": "Full page",
+          "element": "An element"
+        }
       },
       "switch-to": {
         "name": "Switch frame",

+ 15 - 1
src/locales/en/newtab.json

@@ -37,6 +37,14 @@
         "description": "Add an arrow at the end of the line"
       }
     },
+    "deleteLog": {
+      "title": "Auto delete workflow logs",
+      "after": "Delete after",
+      "deleteAfter": {
+        "never": "Never",
+        "days": "{day} days"
+      }
+    },
     "language": {
       "label": "Language",
       "helpTranslate": "Can't find your language? Help translate.",
@@ -54,6 +62,7 @@
       "invalidPassword": "Invalid password",
       "workflowsAdded": "{count} workflows have been added",
       "name": "Backup workflows",
+      "needSignin": "You need to sign in to your account first",
       "backup": {
         "button": "Backup",
         "encrypt": "Encrypt with password"
@@ -68,6 +77,7 @@
           "local": "Local",
           "cloud": "Cloud"
         },
+        "location": "Location",
         "delete": "Delete backup",
         "title": "Cloud Backup",
         "sync": "Sync",
@@ -90,6 +100,7 @@
     "browse": "Browse workflows",
     "name": "Workflow name",
     "rename": "Rename workflow",
+    "backupCloud": "Backup workflow to cloud",
     "add": "Add workflow",
     "clickToEnable": "Click to enable",
     "toggleSidebar": "Toggle sidebar",
@@ -173,7 +184,9 @@
       "zoomIn": "Zoom in",
       "zoomOut": "Zoom out",
       "resetZoom": "Reset zoom",
-      "duplicate": "Duplicate"
+      "duplicate": "Duplicate",
+      "copy": "Copy",
+      "paste": "Paste"
     },
     "settings": {
       "saveLog": "Save workflow log",
@@ -238,6 +251,7 @@
     }
   },
   "log": {
+    "flowId": "Flow Id",
     "goBack": "Go back to \"{name}\" log",
     "goWorkflow": "Go to workflow",
     "startedDate": "Started date",

+ 2 - 0
src/locales/zh/blocks.json

@@ -306,6 +306,7 @@
           "options": {
             "data-columns": "表格",
             "google-sheets": "Google sheets",
+            "variable": "变量"
           },
         }
       },
@@ -392,6 +393,7 @@
         "description": "在网页中执行您的 javascript 代码",
         "availabeFuncs": "可用函数:",
         "removeAfterExec": "模块执行后移除",
+        "everyNewTab": "每次新建标签都执行",
         "modal": {
           "tabs": {
             "code": "JavaScript 代码",

+ 11 - 0
src/locales/zh/newtab.json

@@ -25,12 +25,17 @@
       "duplicate": "快捷方式已被 \"{name}\" 占用"
     },
     "editor": {
+      "title": "标题",
       "curvature": {
         "title": "编辑器线条曲率",
         "line": "线条",
         "reroute": "重绘路线",
         "rerouteFirstLast": "重绘首尾点"
       },
+      "arrow": {
+        "title": "箭头线",
+        "description": "添加箭头到线的尾端"
+      }
     },
     "language": {
       "label": "语言",
@@ -39,6 +44,7 @@
     },
     "menu": {
       "backup": "备份工作流",
+      "editor": "编辑器",
       "general": "常规",
       "shortcuts": "快捷键",
       "about": "关于"
@@ -172,6 +178,10 @@
     "settings": {
       "saveLog": "保存工作流日志",
       "executedBlockOnWeb": "在网页上显示已执行的模块",
+      "autocomplete": {
+        "title": "自动完成",
+        "description": "在输入块中启用自动完成(如果造成 Automa 不稳定请禁用)"
+      },
       "clearCache": {
         "title": "清理缓存",
         "description": "清除工作流的缓存(状态和循环索引)",
@@ -229,6 +239,7 @@
   },
   "log": {
     "goBack": "返回到 \"{name}\" 日志",
+    "goWorkflow": "转到工作流",
     "startedDate": "启动日期",
     "duration": "期间",
     "selectAll": "选择全部",

+ 2 - 0
src/models/workflow.js

@@ -98,6 +98,8 @@ class Workflow extends Model {
 
         await browser.storage.local.set({ clearCache: true });
       }
+
+      browser.storage.local.remove(`state:${id}`);
     } catch (error) {
       console.error(error);
     }

+ 16 - 0
src/newtab/App.vue

@@ -59,6 +59,8 @@ import browser from 'webextension-polyfill';
 import { useTheme } from '@/composable/theme';
 import { loadLocaleMessages, setI18nLanguage } from '@/lib/vue-i18n';
 import { fetchApi, getSharedWorkflows, getUserWorkflows } from '@/utils/api';
+import dayjs from '@/lib/dayjs';
+import Log from '@/models/log';
 import Workflow from '@/models/workflow';
 import AppSidebar from '@/components/newtab/app/AppSidebar.vue';
 
@@ -181,6 +183,18 @@ async function fetchUserData() {
     console.error(error);
   }
 }
+/* eslint-disable-next-line */
+function autoDeleteLogs() {
+  const deleteAfter = store.state.settings.deleteLogAfter;
+
+  if (deleteAfter === 'never') return;
+
+  Log.delete(({ endedAt }) => {
+    const diff = dayjs().diff(dayjs(endedAt), 'day');
+
+    return diff >= deleteAfter;
+  });
+}
 function handleStorageChanged(change) {
   if (change.logs) {
     store.dispatch('entities/create', {
@@ -223,6 +237,8 @@ window.addEventListener('beforeunload', () => {
 
     await fetchUserData();
     await syncHostWorkflow();
+
+    // autoDeleteLogs();
   } catch (error) {
     retrieved.value = true;
     console.error(error);

+ 36 - 24
src/newtab/pages/Workflows.vue

@@ -29,30 +29,40 @@
           </option>
         </ui-select>
       </div>
-      <ui-button
-        tag="a"
-        href="https://automa.site/workflows"
-        target="_blank"
-        class="inline-block relative"
-        @click="browseWorkflow"
-      >
-        <span
-          v-if="state.highlightBrowse"
-          class="flex h-3 w-3 absolute top-0 right-0 -mr-1 -mt-1"
+      <span v-tooltip:bottom.group="t('workflow.browse')">
+        <ui-button
+          icon
+          tag="a"
+          href="https://automa.site/workflows"
+          target="_blank"
+          class="inline-block relative"
+          @click="browseWorkflow"
         >
           <span
-            class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"
-          ></span>
-          <span
-            class="relative inline-flex rounded-full h-3 w-3 bg-blue-600"
-          ></span>
-        </span>
-        <v-remixicon name="riCompass3Line" class="mr-2 -ml-1" />
-        {{ t('workflow.browse') }}
-      </ui-button>
-      <ui-button @click="importWorkflow({ multiple: true })">
-        <v-remixicon name="riUploadLine" class="mr-2 -ml-1" />
-        {{ t('workflow.import') }}
+            v-if="state.highlightBrowse"
+            class="flex h-3 w-3 absolute top-0 right-0 -mr-1 -mt-1"
+          >
+            <span
+              class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"
+            ></span>
+            <span
+              class="relative inline-flex rounded-full h-3 w-3 bg-blue-600"
+            ></span>
+          </span>
+          <v-remixicon name="riCompass3Line" />
+        </ui-button>
+      </span>
+      <span v-tooltip:bottom.group="t('workflow.backupCloud')">
+        <ui-button tag="router-link" to="/backup" class="inline-block" icon>
+          <v-remixicon name="riUploadCloud2Line" />
+        </ui-button>
+      </span>
+      <ui-button
+        v-tooltip:bottom.group="t('workflow.import')"
+        icon
+        @click="importWorkflow({ multiple: true })"
+      >
+        <v-remixicon name="riUploadLine" />
       </ui-button>
       <div class="flex">
         <ui-button
@@ -212,7 +222,7 @@
               <template #footer-content>
                 <v-remixicon
                   v-if="sharedWorkflows[workflow.id]"
-                  v-tooltip="
+                  v-tooltip:bottom.group="
                     t('workflow.share.sharedAs', {
                       name: sharedWorkflows[workflow.id]?.name.slice(0, 64),
                     })
@@ -223,7 +233,7 @@
                 />
                 <v-remixicon
                   v-if="hostWorkflows[workflow.id]"
-                  v-tooltip="t('workflow.host.title')"
+                  v-tooltip:bottom.group="t('workflow.host.title')"
                   name="riBaseStationLine"
                   size="20"
                   class="ml-2"
@@ -303,6 +313,7 @@ import { useToast } from 'vue-toastification';
 import browser from 'webextension-polyfill';
 import { useDialog } from '@/composable/dialog';
 import { useShortcut } from '@/composable/shortcut';
+import { useGroupTooltip } from '@/composable/groupTooltip';
 import { sendMessage } from '@/utils/message';
 import { fetchApi } from '@/utils/api';
 import { exportWorkflow, importWorkflow } from '@/utils/workflow-data';
@@ -314,6 +325,7 @@ import { findTriggerBlock, isWhitespace } from '@/utils/helper';
 import SharedCard from '@/components/newtab/shared/SharedCard.vue';
 import Workflow from '@/models/workflow';
 
+useGroupTooltip();
 const { t } = useI18n();
 const toast = useToast();
 const store = useStore();

+ 7 - 0
src/newtab/pages/logs/[id].vue

@@ -89,6 +89,13 @@
               >
                 <v-remixicon name="riExternalLinkLine" />
               </router-link>
+              <code
+                v-show="item.workerId"
+                :title="t('log.flowId')"
+                class="text-xs mr-4 bg-box-transparent rounded-lg p-1 rounded-md"
+              >
+                {{ item.workerId }}
+              </code>
               <p class="text-gray-600 dark:text-gray-200">
                 {{ countDuration(0, item.duration || 0) }}
               </p>

+ 45 - 28
src/newtab/pages/settings/SettingsBackup.vue

@@ -1,41 +1,58 @@
 <template>
   <div class="max-w-xl">
-    <ui-card v-if="$store.state.user" class="mb-12">
+    <ui-card class="mb-12">
       <h2 class="font-semibold mb-2">
         {{ t('settings.backupWorkflows.cloud.title') }}
       </h2>
-      <div class="border dark:border-gray-700 p-4 rounded-lg flex items-center">
-        <span class="inline-block p-2 rounded-full bg-box-transparent">
-          <v-remixicon name="riUploadLine" />
-        </span>
-        <div class="flex-1 ml-4 leading-tight">
-          <p class="text-sm text-gray-600 dark:text-gray-200">
-            {{ t('settings.backupWorkflows.cloud.lastBackup') }}
-          </p>
-          <p>{{ formatDate(state.lastBackup) }}</p>
+      <template v-if="$store.state.user">
+        <div
+          class="border dark:border-gray-700 p-4 rounded-lg flex items-center"
+        >
+          <span class="inline-block p-2 rounded-full bg-box-transparent">
+            <v-remixicon name="riUploadLine" />
+          </span>
+          <div class="flex-1 ml-4 leading-tight">
+            <p class="text-sm text-gray-600 dark:text-gray-200">
+              {{ t('settings.backupWorkflows.cloud.lastBackup') }}
+            </p>
+            <p>{{ formatDate(state.lastBackup) }}</p>
+          </div>
+          <ui-button
+            :loading="backupState.loading"
+            @click="backupState.modal = true"
+          >
+            {{ t('settings.backupWorkflows.backup.button') }}
+          </ui-button>
         </div>
-        <ui-button
-          :loading="backupState.loading"
-          @click="backupState.modal = true"
+        <div
+          class="border dark:border-gray-700 p-4 rounded-lg flex items-center mt-2"
         >
-          {{ t('settings.backupWorkflows.backup.button') }}
-        </ui-button>
-      </div>
-      <div
-        class="border dark:border-gray-700 p-4 rounded-lg flex items-center mt-2"
-      >
-        <span class="inline-block p-2 rounded-full bg-box-transparent">
-          <v-remixicon name="riDownloadLine" />
-        </span>
-        <p class="flex-1 ml-4">
-          {{ t('settings.backupWorkflows.cloud.sync') }}
+          <span class="inline-block p-2 rounded-full bg-box-transparent">
+            <v-remixicon name="riDownloadLine" />
+          </span>
+          <p class="flex-1 ml-4">
+            {{ t('settings.backupWorkflows.cloud.sync') }}
+          </p>
+          <ui-button
+            :loading="state.loadingSync"
+            class="ml-2"
+            @click="syncBackupWorkflows"
+          >
+            {{ t('settings.backupWorkflows.cloud.sync') }}
+          </ui-button>
+        </div>
+      </template>
+      <div v-else class="text-center py-4">
+        <p>
+          {{ t('settings.backupWorkflows.needSignin') }}
         </p>
         <ui-button
-          :loading="state.loadingSync"
-          class="ml-2"
-          @click="syncBackupWorkflows"
+          tag="a"
+          href="https://automa.site/auth"
+          target="_blank"
+          class="mt-4 w-44 inline-block"
         >
-          {{ t('settings.backupWorkflows.cloud.sync') }}
+          {{ t('auth.signIn') }}
         </ui-button>
       </div>
       <p v-if="false">

+ 24 - 0
src/newtab/pages/settings/SettingsIndex.vue

@@ -54,6 +54,28 @@
       {{ t('settings.language.reloadPage') }}
     </p>
   </div>
+  <div id="delete-logs" class="mt-12">
+    <p class="font-semibold mb-1">
+      {{ t('settings.deleteLog.title') }}
+    </p>
+    <ui-select
+      label="Delete after"
+      class="w-80"
+      :model-value="settings.deleteLogAfter"
+      @change="
+        updateSetting('deleteLogAfter', $event === 'never' ? 'never' : +$event)
+      "
+    >
+      <option v-for="day in deleteLogDays" :key="day" :value="day">
+        <template v-if="typeof day === 'string'">
+          {{ t('settings.deleteLog.deleteAfter.never') }}
+        </template>
+        <template v-else>
+          {{ t('settings.deleteLog.deleteAfter.days', { day }) }}
+        </template>
+      </option>
+    </ui-select>
+  </div>
 </template>
 <script setup>
 import { computed, ref } from 'vue';
@@ -63,6 +85,8 @@ import browser from 'webextension-polyfill';
 import { useTheme } from '@/composable/theme';
 import { supportLocales } from '@/utils/shared';
 
+const deleteLogDays = ['never', 7, 14, 30, 60, 120];
+
 const { t } = useI18n();
 const store = useStore();
 const theme = useTheme();

+ 19 - 0
src/newtab/pages/workflows/[id].vue

@@ -777,6 +777,25 @@ function editBlock(data) {
   state.isEditBlock = true;
   state.showSidebar = true;
   state.blockData = defu(data, tasks[data.id] || {});
+
+  if (data.id === 'wait-connections') {
+    const node = editor.value.getNodeFromId(data.blockId);
+    const connections = node.inputs.input_1.connections.map((input) => {
+      const inputNode = editor.value.getNodeFromId(input.node);
+      const nodeDesc = inputNode.data.description;
+
+      let name = t(`workflow.blocks.${inputNode.name}.name`);
+
+      if (nodeDesc) name += ` (${nodeDesc})`;
+
+      return {
+        name,
+        id: input.node,
+      };
+    });
+
+    state.blockData.connections = connections;
+  }
 }
 function handleEditorDataChanged() {
   state.isDataChanged = true;

+ 2 - 0
src/store/index.js

@@ -19,8 +19,10 @@ const store = createStore({
     hostWorkflows: {},
     sharedWorkflows: {},
     workflowHosts: {},
+    copiedNodes: [],
     settings: {
       locale: 'en',
+      deleteLogAfter: 30,
       editor: {
         arrow: false,
         disableCurvature: false,

+ 1 - 1
src/utils/helper.js

@@ -203,7 +203,7 @@ export function debounce(callback, time = 200) {
 
 export async function clearCache(workflow) {
   try {
-    await browser.storage.local.remove(`last-state:${workflow.id}`);
+    await browser.storage.local.remove(`state:${workflow.id}`);
 
     const flows = parseJSON(workflow.drawflow, null);
     const blocks = flows && flows.drawflow.Home.data;

+ 4 - 4
src/utils/reference-data/mustache-replacer.js

@@ -25,10 +25,10 @@ export const functions = {
     const isValidDate = date instanceof Date && !isNaN(date);
     const dayjsDate = dayjs(isValidDate ? date : Date.now());
 
-    const result =
-      dateFormat === 'relative'
-        ? dayjsDate.fromNow()
-        : dayjsDate.format(dateFormat);
+    let result = dayjsDate.format(dateFormat);
+
+    if (dateFormat === 'relative') result = dayjsDate.fromNow();
+    else if (dateFormat === 'timestamp') result = dayjsDate.valueOf();
 
     return result;
   },

+ 99 - 15
src/utils/shared.js

@@ -14,6 +14,7 @@ export const tasks = {
     maxConnection: 1,
     refDataKeys: ['url'],
     data: {
+      disableBlock: false,
       description: '',
       type: 'manual',
       interval: 60,
@@ -40,6 +41,7 @@ export const tasks = {
     maxConnection: 1,
     refDataKeys: ['globalData'],
     data: {
+      disableBlock: false,
       executeId: '',
       workflowId: '',
       globalData: '',
@@ -57,7 +59,9 @@ export const tasks = {
     outputs: 1,
     allowedInputs: true,
     maxConnection: 1,
-    data: {},
+    data: {
+      disableBlock: false,
+    },
   },
   'new-tab': {
     name: 'New tab',
@@ -72,6 +76,7 @@ export const tasks = {
     maxConnection: 1,
     refDataKeys: ['url'],
     data: {
+      disableBlock: false,
       description: '',
       url: '',
       userAgent: '',
@@ -94,6 +99,7 @@ export const tasks = {
     maxConnection: 1,
     refDataKeys: ['url', 'matchPattern'],
     data: {
+      disableBlock: false,
       description: '',
       url: '',
       matchPattern: '',
@@ -112,6 +118,7 @@ export const tasks = {
     allowedInputs: true,
     maxConnection: 1,
     data: {
+      disableBlock: false,
       description: '',
       top: 0,
       left: 0,
@@ -133,6 +140,7 @@ export const tasks = {
     maxConnection: 1,
     allowedInputs: true,
     data: {
+      disableBlock: false,
       scheme: 'https',
       host: '',
       port: 443,
@@ -151,7 +159,9 @@ export const tasks = {
     maxConnection: 1,
     disableEdit: true,
     allowedInputs: true,
-    data: {},
+    data: {
+      disableBlock: false,
+    },
   },
   'forward-page': {
     name: 'Go forward',
@@ -164,7 +174,9 @@ export const tasks = {
     maxConnection: 1,
     disableEdit: true,
     allowedInputs: true,
-    data: {},
+    data: {
+      disableBlock: false,
+    },
   },
   'close-tab': {
     name: 'Close tab/window',
@@ -178,6 +190,7 @@ export const tasks = {
     allowedInputs: true,
     refDataKeys: ['url'],
     data: {
+      disableBlock: false,
       url: '',
       description: '',
       activeTab: true,
@@ -199,11 +212,14 @@ export const tasks = {
     refDataKeys: ['fileName'],
     autocomplete: ['variableName'],
     data: {
+      description: '',
+      disableBlock: false,
       fileName: '',
       ext: 'png',
       quality: 100,
       dataColumn: '',
       variableName: '',
+      selector: '',
       fullPage: false,
       saveToColumn: false,
       saveToComputer: true,
@@ -223,6 +239,7 @@ export const tasks = {
     maxConnection: 1,
     allowedInputs: true,
     data: {
+      disableBlock: false,
       description: '',
       timeout: 10000,
       eventName: 'tab:loaded',
@@ -245,6 +262,7 @@ export const tasks = {
     maxConnection: 1,
     refDataKeys: ['selector'],
     data: {
+      disableBlock: false,
       description: '',
       findBy: 'cssSelector',
       waitForSelector: false,
@@ -267,6 +285,7 @@ export const tasks = {
     maxConnection: 1,
     refDataKeys: ['time'],
     data: {
+      disableBlock: false,
       time: 500,
     },
   },
@@ -284,6 +303,7 @@ export const tasks = {
     refDataKeys: ['selector', 'prefixText', 'suffixText', 'extraRowValue'],
     autocomplete: ['variableName'],
     data: {
+      disableBlock: false,
       description: '',
       findBy: 'cssSelector',
       waitForSelector: false,
@@ -317,6 +337,7 @@ export const tasks = {
     maxConnection: 1,
     refDataKeys: ['name'],
     data: {
+      disableBlock: false,
       name: '',
       refKey: '',
       type: 'json',
@@ -339,6 +360,7 @@ export const tasks = {
     maxConnection: 1,
     refDataKeys: ['selector'],
     data: {
+      disableBlock: false,
       description: '',
       findBy: 'cssSelector',
       waitForSelector: false,
@@ -367,6 +389,7 @@ export const tasks = {
     maxConnection: 1,
     refDataKeys: ['selector'],
     data: {
+      disableBlock: false,
       description: '',
       findBy: 'cssSelector',
       waitForSelector: false,
@@ -390,6 +413,7 @@ export const tasks = {
     refDataKeys: ['selector', 'attributeName', 'extraRowValue'],
     autocomplete: ['variableName'],
     data: {
+      disableBlock: false,
       description: '',
       findBy: 'cssSelector',
       waitForSelector: false,
@@ -421,6 +445,7 @@ export const tasks = {
     refDataKeys: ['selector', 'value'],
     autocomplete: ['variableName'],
     data: {
+      disableBlock: false,
       description: '',
       findBy: 'cssSelector',
       waitForSelector: false,
@@ -452,21 +477,10 @@ export const tasks = {
     allowedInputs: true,
     maxConnection: 1,
     data: {
+      disableBlock: false,
       repeatFor: 1,
     },
   },
-  // 'reload-page': {
-  //   name: 'Reload page',
-  //   icon: 'riRestartLine',
-  //   component: 'BlockBasic',
-  //   category: 'interaction',
-  //   inputs: 1,
-  //   outputs: 1,
-  //   allowedInputs: true,
-  //   maxConnection: 1,
-  //   disableEdit: true,
-  //   data: {},
-  // },
   'javascript-code': {
     name: 'JavaScript code',
     description: 'Execute your custom javascript code in a webpage',
@@ -479,6 +493,7 @@ export const tasks = {
     allowedInputs: true,
     maxConnection: 1,
     data: {
+      disableBlock: false,
       description: '',
       timeout: 20000,
       code: 'console.log("Hello world!");\nautomaNextBlock()',
@@ -499,6 +514,7 @@ export const tasks = {
     maxConnection: 1,
     refDataKeys: ['selector', 'eventParams.clientX', 'eventParams.clientY'],
     data: {
+      disableBlock: false,
       description: '',
       findBy: 'cssSelector',
       waitForSelector: false,
@@ -525,6 +541,7 @@ export const tasks = {
     refDataKeys: ['customData', 'range', 'spreadsheetId'],
     autocomplete: ['refKey'],
     data: {
+      disableBlock: false,
       range: '',
       refKey: '',
       type: 'get',
@@ -549,6 +566,7 @@ export const tasks = {
     allowedInputs: true,
     maxConnection: 1,
     data: {
+      disableBlock: false,
       conditions: [],
     },
   },
@@ -565,6 +583,7 @@ export const tasks = {
     maxConnection: 1,
     refDataKeys: ['selector'],
     data: {
+      disableBlock: false,
       findBy: 'cssSelector',
       selector: '',
       tryCount: 1,
@@ -587,6 +606,7 @@ export const tasks = {
     refDataKeys: ['body', 'url'],
     autocomplete: ['variableName'],
     data: {
+      disableBlock: false,
       description: '',
       url: '',
       body: '{}',
@@ -614,6 +634,7 @@ export const tasks = {
     allowedInputs: true,
     maxConnection: 1,
     data: {
+      disableBlock: false,
       description: '',
       conditions: null,
     },
@@ -636,6 +657,7 @@ export const tasks = {
     ],
     autocomplete: ['variableName', 'loopId'],
     data: {
+      disableBlock: false,
       loopId: '',
       maxLoop: 0,
       toNumber: 10,
@@ -662,6 +684,7 @@ export const tasks = {
     allowedInputs: true,
     maxConnection: 1,
     data: {
+      disableBlock: false,
       loopId: '',
     },
   },
@@ -677,6 +700,7 @@ export const tasks = {
     allowedInputs: true,
     maxConnection: 1,
     data: {
+      disableBlock: false,
       name: '',
       blocks: [],
     },
@@ -694,6 +718,7 @@ export const tasks = {
     maxConnection: 1,
     autocomplete: ['variableName'],
     data: {
+      disableBlock: false,
       description: '',
       assignVariable: false,
       variableName: '',
@@ -713,6 +738,7 @@ export const tasks = {
     allowedInputs: true,
     maxConnection: 1,
     data: {
+      disableBlock: false,
       description: '',
       dataList: [],
     },
@@ -730,6 +756,7 @@ export const tasks = {
     maxConnection: 1,
     refDataKeys: ['selector'],
     data: {
+      disableBlock: false,
       findBy: 'cssSelector',
       selector: '',
       windowType: 'main-window',
@@ -748,6 +775,7 @@ export const tasks = {
     maxConnection: 1,
     refDataKeys: ['selector', 'filePaths'],
     data: {
+      disableBlock: false,
       findBy: 'cssSelector',
       waitForSelector: false,
       waitSelectorTimeout: 5000,
@@ -768,6 +796,7 @@ export const tasks = {
     maxConnection: 1,
     refDataKeys: ['selector'],
     data: {
+      disableBlock: false,
       description: '',
       findBy: 'cssSelector',
       waitForSelector: false,
@@ -791,6 +820,7 @@ export const tasks = {
     maxConnection: 1,
     refDataKeys: ['selector', 'url', 'filename'],
     data: {
+      disableBlock: false,
       description: '',
       findBy: 'cssSelector',
       waitForSelector: false,
@@ -818,6 +848,7 @@ export const tasks = {
     maxConnection: 1,
     refDataKeys: ['promptText'],
     data: {
+      disableBlock: false,
       description: '',
       accept: true,
       promptText: '',
@@ -837,6 +868,7 @@ export const tasks = {
     refDataKeys: ['filename'],
     autocomplete: ['variableName'],
     data: {
+      disableBlock: false,
       description: '',
       filename: '',
       timeout: 20000,
@@ -848,6 +880,58 @@ export const tasks = {
       variableName: '',
     },
   },
+  'reload-tab': {
+    name: 'Reload tab',
+    description: 'Reload the active tab',
+    icon: 'riRestartLine',
+    component: 'BlockBasic',
+    category: 'browser',
+    inputs: 1,
+    outputs: 1,
+    allowedInputs: true,
+    maxConnection: 1,
+    disableEdit: true,
+    data: {
+      disableBlock: false,
+    },
+  },
+  'delete-data': {
+    name: 'Delete data',
+    description: 'Delete table or variable data',
+    icon: 'riDeleteBin7Line',
+    editComponent: 'EditDeleteData',
+    component: 'BlockBasic',
+    category: 'general',
+    inputs: 1,
+    outputs: 1,
+    allowedInputs: true,
+    maxConnection: 1,
+    disableEdit: true,
+    data: {
+      disableBlock: false,
+      description: '',
+      deleteList: [],
+    },
+  },
+  'wait-connections': {
+    name: 'Wait connections',
+    description: 'Wait for all connections before continuing to the next block',
+    icon: 'riTimerFlashLine',
+    editComponent: 'EditWaitConnections',
+    component: 'BlockBasic',
+    category: 'general',
+    inputs: 1,
+    outputs: 1,
+    allowedInputs: true,
+    maxConnection: 1,
+    data: {
+      disableBlock: false,
+      description: '',
+      timeout: 10000,
+      specificFlow: false,
+      flowBlockId: '',
+    },
+  },
 };
 
 export const categories = {