Ahmad Kholid 2 năm trước cách đây
mục cha
commit
91b0704038
60 tập tin đã thay đổi với 1574 bổ sung845 xóa
  1. 3 0
      business/dev/blocks/backgroundHandler/index.js
  2. 3 0
      business/dev/blocks/contentHandler/index.js
  3. 3 1
      business/dev/blocks/editComponents/index.js
  4. 3 1
      business/dev/blocks/index.js
  5. 1 3
      business/dev/index.js
  6. 3 1
      business/dev/parameters/index.js
  7. 7 5
      package.json
  8. 4 0
      src/background/WorkflowState.js
  9. 0 78
      src/background/collectionEngine/flowHandler.js
  10. 0 251
      src/background/collectionEngine/index.js
  11. 4 1
      src/background/index.js
  12. 6 4
      src/background/workflowEngine/blocksHandler.js
  13. 48 19
      src/background/workflowEngine/blocksHandler/handlerInsertData.js
  14. 9 1
      src/background/workflowEngine/blocksHandler/handlerLoopData.js
  15. 28 0
      src/background/workflowEngine/blocksHandler/handlerWorkflowState.js
  16. 18 5
      src/background/workflowEngine/engine.js
  17. 10 5
      src/background/workflowEngine/worker.js
  18. 1 1
      src/components/block/BlockNote.vue
  19. 2 2
      src/components/block/BlockPackage.vue
  20. 295 114
      src/components/newtab/logs/LogsHistory.vue
  21. 4 3
      src/components/newtab/package/PackageSettingIOSelect.vue
  22. 4 2
      src/components/newtab/package/PackageSettings.vue
  23. 4 3
      src/components/newtab/shared/SharedWorkflowState.vue
  24. 4 0
      src/components/newtab/workflow/WorkflowBlockList.vue
  25. 21 15
      src/components/newtab/workflow/WorkflowDetailsCard.vue
  26. 1 1
      src/components/newtab/workflow/WorkflowEditBlock.vue
  27. 3 3
      src/components/newtab/workflow/WorkflowEditor.vue
  28. 3 2
      src/components/newtab/workflow/WorkflowRunning.vue
  29. 201 60
      src/components/newtab/workflow/edit/EditInsertData.vue
  30. 7 0
      src/components/newtab/workflow/edit/EditLoopData.vue
  31. 102 66
      src/components/newtab/workflow/edit/EditWorkflowParameters.vue
  32. 63 0
      src/components/newtab/workflow/edit/EditWorkflowState.vue
  33. 156 0
      src/components/newtab/workflow/edit/Parameter/ParameterInputOptions.vue
  34. 46 0
      src/components/newtab/workflow/edit/Parameter/ParameterInputValue.vue
  35. 7 10
      src/components/newtab/workflow/editor/EditorLocalSavedBlocks.vue
  36. 2 2
      src/components/newtab/workflow/editor/EditorPkgActions.vue
  37. 4 2
      src/components/newtab/workflow/editor/EditorUsedCredentials.vue
  38. 2 2
      src/components/ui/UiButton.vue
  39. 95 83
      src/components/ui/UiInput.vue
  40. 3 7
      src/composable/editorBlock.js
  41. 6 4
      src/content/blocksHandler.js
  42. 35 16
      src/content/blocksHandler/handlerTakeScreenshot.js
  43. 6 2
      src/content/index.js
  44. 4 0
      src/lib/vRemixicon.js
  45. 11 0
      src/locales/en/blocks.json
  46. 34 2
      src/locales/zh/blocks.json
  47. 2 0
      src/locales/zh/common.json
  48. 38 3
      src/locales/zh/newtab.json
  49. 5 2
      src/newtab/App.vue
  50. 0 1
      src/newtab/pages/Packages.vue
  51. 1 0
      src/newtab/pages/Workflows.vue
  52. 91 5
      src/newtab/pages/workflows/[id].vue
  53. 41 11
      src/params/App.vue
  54. 3 0
      src/popup/pages/Home.vue
  55. 4 2
      src/utils/editor/DroppedNode.js
  56. 22 10
      src/utils/getFile.js
  57. 6 0
      src/utils/getSharedData.js
  58. 21 3
      src/utils/shared.js
  59. 2 1
      src/utils/testConditions.js
  60. 62 30
      yarn.lock

+ 3 - 0
business/dev/blocks/backgroundHandler/index.js

@@ -0,0 +1,3 @@
+export default function () {
+  return {};
+}

+ 3 - 0
business/dev/blocks/contentHandler/index.js

@@ -0,0 +1,3 @@
+export default function () {
+  return {};
+}

+ 3 - 1
business/dev/blocks/editComponents/index.js

@@ -1 +1,3 @@
-export default {};
+export default function () {
+  return {};
+}

+ 3 - 1
business/dev/blocks/index.js

@@ -1 +1,3 @@
-export default {};
+export default function () {
+  return {};
+}

+ 1 - 3
business/dev/index.js

@@ -1,3 +1 @@
-export default {
-  workflowParameters: {},
-};
+export default function () {}

+ 3 - 1
business/dev/parameters/index.js

@@ -1 +1,3 @@
-export default {};
+export default function () {
+  return {};
+}

+ 7 - 5
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "1.18.1",
+  "version": "1.19.0",
   "description": "An extension for automating your browser by connecting blocks",
   "repository": {
     "type": "git",
@@ -71,6 +71,8 @@
     "v-remixicon": "^0.1.1",
     "vue": "^3.2.37",
     "vue-i18n": "^9.2.0-beta.40",
+    "vue-imask": "^6.4.2",
+    "vue-multiselect": "^3.0.0-alpha.2",
     "vue-router": "^4.1.5",
     "vue-toastification": "^2.0.0-rc.5",
     "vuedraggable": "^4.1.0",
@@ -92,14 +94,14 @@
     "core-js": "^3.25.0",
     "cross-env": "^7.0.3",
     "css-loader": "^6.7.1",
-    "eslint": "^8.22.0",
+    "eslint": "^8.23.0",
     "eslint-config-airbnb-base": "^15.0.0",
     "eslint-config-prettier": "^8.3.0",
     "eslint-friendly-formatter": "^4.0.1",
     "eslint-import-resolver-webpack": "^0.13.2",
     "eslint-plugin-import": "^2.26.0",
     "eslint-plugin-prettier": "^4.0.0",
-    "eslint-plugin-vue": "^9.3.0",
+    "eslint-plugin-vue": "^9.4.0",
     "file-loader": "^6.2.0",
     "fs-extra": "^10.1.0",
     "html-loader": "^4.1.0",
@@ -112,11 +114,11 @@
     "simple-git-hooks": "^2.6.1",
     "source-map-loader": "^4.0.0",
     "tailwindcss": "^3.1.6",
-    "terser-webpack-plugin": "^5.3.5",
+    "terser-webpack-plugin": "^5.3.6",
     "vue-loader": "^17.0.0",
     "web-worker": "^1.2.0",
     "webpack": "^5.73.0",
     "webpack-cli": "^4.10.0",
-    "webpack-dev-server": "^4.10.0"
+    "webpack-dev-server": "^4.11.0"
   }
 }

+ 4 - 0
src/background/WorkflowState.js

@@ -38,6 +38,10 @@ class WorkflowState {
     if (index !== -1) listeners.splice(index, 1);
   }
 
+  get getAll() {
+    return this.states;
+  }
+
   async get(stateId) {
     let { states } = this;
 

+ 0 - 78
src/background/collectionEngine/flowHandler.js

@@ -1,78 +0,0 @@
-import dataExporter from '@/utils/dataExporter';
-import WorkflowEngine from '../workflowEngine/engine';
-import blocksHandler from '../workflowEngine/blocksHandler';
-
-export function workflow(flow) {
-  return new Promise((resolve, reject) => {
-    const currentWorkflow = this.workflows.find(({ id }) => id === flow.itemId);
-
-    if (!currentWorkflow) {
-      const error = new Error(`Can't find workflow with ${flow.itemId} ID`);
-      error.name = 'Workflow';
-
-      reject(error);
-      return;
-    }
-
-    if (currentWorkflow.isDisabled) {
-      resolve({
-        type: 'stopped',
-        name: currentWorkflow.name,
-        message: 'workflow-disabled',
-      });
-
-      return;
-    }
-
-    const { globalData } = this.collection;
-
-    const engine = new WorkflowEngine(currentWorkflow, {
-      blocksHandler,
-      states: this.states,
-      logger: this.logger,
-      options: {
-        parentWorkflow: {
-          id: this.id,
-          isCollection: true,
-          name: this.collection.name,
-        },
-        data: {
-          globalData: globalData.trim() === '' ? null : globalData,
-        },
-      },
-    });
-
-    this.executedWorkflow.data = {
-      id: engine.id,
-      name: currentWorkflow.name,
-      icon: currentWorkflow.icon,
-      workflowId: currentWorkflow.id,
-    };
-
-    engine.init();
-    engine.on('destroyed', ({ id, status, message }) => {
-      this.data.push({
-        id,
-        status,
-        errorMessage: message,
-        workflowId: currentWorkflow.id,
-        workflowName: currentWorkflow.name,
-      });
-
-      resolve({
-        id,
-        message,
-        type: status,
-        name: currentWorkflow.name,
-      });
-    });
-  });
-}
-
-export function exportResult() {
-  return new Promise((resolve) => {
-    dataExporter(this.data, { name: this.collection.name, type: 'json' }, true);
-
-    resolve({ name: 'Export result' });
-  });
-}

+ 0 - 251
src/background/collectionEngine/index.js

@@ -1,251 +0,0 @@
-import { nanoid } from 'nanoid';
-import browser from 'webextension-polyfill';
-import { toCamelCase } from '@/utils/helper';
-import * as flowHandler from './flowHandler';
-import blocksHandler from '../workflowEngine/blocksHandler';
-import WorkflowEngine from '../workflowEngine/engine';
-
-const executedWorkflows = (workflows, options) => {
-  if (workflows.length === 0) return;
-
-  const workflow = workflows.shift();
-  const engine = new WorkflowEngine(workflow, options);
-
-  engine.init();
-
-  setTimeout(() => {
-    executedWorkflows(workflows, options);
-  }, 500);
-};
-
-class CollectionEngine {
-  constructor(collection, { states, logger }) {
-    this.id = nanoid();
-    this.states = states;
-    this.logger = logger;
-    this.collection = collection;
-
-    this.data = [];
-    this.history = [];
-    this.workflows = [];
-
-    this.isDestroyed = false;
-    this.currentFlow = null;
-    this.currentIndex = 0;
-    this.eventListeners = {};
-
-    this.executedWorkflow = {
-      data: null,
-      state: null,
-    };
-
-    this.onStatesUpdated = ({ data, id }) => {
-      if (id === this.executedWorkflow.data?.id) {
-        this.executedWorkflow.state = data.state;
-        this.states.update(this.id, { state: this.state });
-      }
-    };
-    this.onStatesStopped = (id) => {
-      if (id !== this.id) return;
-
-      this.stop();
-    };
-  }
-
-  async init() {
-    try {
-      if (this.collection.flow.length === 0) return;
-
-      const { workflows } = await browser.storage.local.get('workflows');
-
-      this.workflows = workflows;
-      this.startedTimestamp = Date.now();
-
-      if (this.collection?.options.atOnce) {
-        const filteredWorkflows = this.collection.flow.reduce(
-          (acc, { itemId, type }) => {
-            if (type !== 'workflow') return acc;
-
-            const currentWorkflow = workflows.find(({ id }) => id === itemId);
-
-            if (currentWorkflow) {
-              acc.push(currentWorkflow);
-            }
-
-            return acc;
-          },
-          []
-        );
-
-        executedWorkflows(filteredWorkflows, {
-          blocksHandler,
-          states: this.states,
-          logger: this.logger,
-        });
-      } else {
-        await this.states.add(this.id, {
-          state: this.state,
-          collectionId: this.collection.id,
-        });
-
-        this.states.on('stop', this.onStatesStopped);
-        this.states.on('update', this.onStatesUpdated);
-
-        this._flowHandler(this.collection.flow[0]);
-      }
-    } catch (error) {
-      console.error(error);
-    }
-  }
-
-  on(name, listener) {
-    (this.eventListeners[name] = this.eventListeners[name] || []).push(
-      listener
-    );
-  }
-
-  dispatchEvent(name, params) {
-    const listeners = this.eventListeners[name];
-
-    if (!listeners) return;
-
-    listeners.forEach((callback) => {
-      callback(params);
-    });
-  }
-
-  async destroy(status) {
-    try {
-      if (this.isDestroyed) return;
-
-      this.isDestroyed = true;
-      this.dispatchEvent('destroyed', { id: this.id });
-
-      const { name, icon } = this.collection;
-
-      await this.logger.add({
-        name,
-        icon,
-        status,
-        id: this.id,
-        data: this.data,
-        endedAt: Date.now(),
-        history: this.history,
-        collectionId: this.collection.id,
-        startedAt: this.startedTimestamp,
-      });
-
-      await this.states.delete(this.id);
-
-      this.states.off('stop', this.onStatesStopped);
-      this.states.off('update', this.onStatesUpdated);
-
-      this.listeners = {};
-    } catch (error) {
-      console.error(error);
-    }
-  }
-
-  nextFlow() {
-    this.currentIndex += 1;
-
-    if (this.currentIndex >= this.collection.flow.length) {
-      this.destroy('success');
-
-      return;
-    }
-
-    this._flowHandler(this.collection.flow[this.currentIndex]);
-  }
-
-  get state() {
-    const data = {
-      id: this.id,
-      currentBlock: [],
-      name: this.collection.name,
-      currentIndex: this.currentIndex,
-      executedWorkflow: this.executedWorkflow,
-      startedTimestamp: this.startedTimestamp,
-    };
-
-    if (this.executedWorkflow.data) {
-      data.currentBlock.push(this.executedWorkflow.data);
-    }
-
-    if (this.executedWorkflow.state) {
-      data.currentBlock.push(this.executedWorkflow.state.currentBlock);
-    }
-
-    return data;
-  }
-
-  async stop() {
-    try {
-      if (this.executedWorkflow.data) {
-        await this.states.stop(this.executedWorkflow.data.id);
-      }
-
-      setTimeout(() => {
-        this.destroy('stopped');
-      }, 1000);
-    } catch (error) {
-      console.error(error);
-    }
-  }
-
-  async _flowHandler(flow) {
-    const currentState = await this.states.get(this.id);
-
-    if (!currentState || currentState.isDestroyed) {
-      if (this.isDestroyed) return;
-
-      await this.destroy('stopped');
-      return;
-    }
-
-    const handlerName =
-      flow.type === 'workflow' ? 'workflow' : toCamelCase(flow.itemId);
-    const handler = flowHandler[handlerName];
-    const started = Date.now();
-
-    this.currentFlow = flow;
-    await this.states.update(this.id, { state: this.state });
-
-    if (!handler) {
-      console.error(`"${flow.type}" flow doesn't have a handler`);
-      return;
-    }
-
-    try {
-      if (flow.type !== 'workflow') {
-        this.executedWorkflow = {
-          data: null,
-          state: null,
-        };
-      }
-
-      const data = await handler.call(this, flow);
-
-      this.history.push({
-        type: data.type || 'success',
-        name: data.name,
-        logId: data.id,
-        message: data.message,
-        duration: Math.round(Date.now() - started),
-      });
-      this.nextFlow();
-    } catch (error) {
-      this.history.push({
-        type: 'error',
-        name: error.name,
-        logId: error.id,
-        message: error.message,
-        duration: Math.round(Date.now() - started),
-      });
-
-      this.nextFlow();
-    }
-  }
-}
-
-export default CollectionEngine;

+ 4 - 1
src/background/index.js

@@ -7,6 +7,7 @@ import getFile from '@/utils/getFile';
 import decryptFlow, { getWorkflowPass } from '@/utils/decryptFlow';
 import convertWorkflowData from '@/utils/convertWorkflowData';
 import getBlockMessage from '@/utils/getBlockMessage';
+import automa from '@business';
 import {
   registerSpecificDay,
   registerContextMenu,
@@ -102,9 +103,9 @@ const workflow = {
     const convertedWorkflow = convertWorkflowData(workflowData);
     const engine = new WorkflowEngine(convertedWorkflow, {
       options,
-      blocksHandler,
       logger: this.logger,
       states: this.states,
+      blocksHandler: blocksHandler(),
     });
 
     engine.init();
@@ -644,4 +645,6 @@ message.on('workflow:register', ({ triggerBlock, workflowId }) => {
   registerWorkflowTrigger(workflowId, triggerBlock);
 });
 
+automa('background', message);
+
 browser.runtime.onMessage.addListener(message.listener());

+ 6 - 4
src/background/workflowEngine/blocksHandler.js

@@ -10,7 +10,9 @@ const handlers = blocksHandler.keys().reduce((acc, key) => {
   return acc;
 }, {});
 
-export default {
-  ...handlers,
-  ...customHandlers,
-};
+export default function () {
+  return {
+    ...handlers,
+    ...customHandlers(),
+  };
+}

+ 48 - 19
src/background/workflowEngine/blocksHandler/handlerInsertData.js

@@ -1,28 +1,57 @@
+import Papa from 'papaparse';
 import { parseJSON } from '@/utils/helper';
+import getFile from '@/utils/getFile';
 import mustacheReplacer from '@/utils/referenceData/mustacheReplacer';
 
-function insertData({ id, data }, { refData }) {
-  return new Promise((resolve) => {
-    const replacedValueList = {};
-    data.dataList.forEach(({ name, value, type }) => {
-      const replacedValue = mustacheReplacer(value, refData);
-      const realValue = parseJSON(replacedValue.value, replacedValue.value);
+async function insertData({ id, data }, { refData }) {
+  const replacedValueList = {};
 
-      Object.assign(replacedValueList, replacedValue.list);
+  for (const item of data.dataList) {
+    let value = '';
+
+    if (item.isFile) {
+      const replacedPath = mustacheReplacer(item.filePath || '', refData);
+      const path = replacedPath.value;
+      const isJSON = path.endsWith('.json');
+      const isCSV = path.endsWith('.csv');
 
-      if (type === 'table') {
-        this.addDataToColumn(name, realValue);
-      } else {
-        this.setVariable(name, realValue);
+      let result = await getFile(path, {
+        returnValue: true,
+        responseType: isJSON ? 'json' : 'text',
+      });
+
+      if (
+        result &&
+        isCSV &&
+        item.csvAction &&
+        item.csvAction.includes('json')
+      ) {
+        const parsedCSV = Papa.parse(result, {
+          header: item.csvAction.includes('header'),
+        });
+        result = parsedCSV.data || [];
       }
-    });
-
-    resolve({
-      data: '',
-      replacedValue: replacedValueList,
-      nextBlockId: this.getBlockConnections(id),
-    });
-  });
+
+      value = result;
+      Object.assign(replacedValueList, replacedPath.list);
+    } else {
+      const replacedValue = mustacheReplacer(item.value, refData);
+      value = parseJSON(replacedValue.value, replacedValue.value);
+      Object.assign(replacedValueList, replacedValue.list);
+    }
+
+    if (item.type === 'table') {
+      this.addDataToColumn(item.name, value);
+    } else {
+      this.setVariable(item.name, value);
+    }
+  }
+
+  return {
+    data: '',
+    replacedValue: replacedValueList,
+    nextBlockId: this.getBlockConnections(id),
+  };
 }
 
 export default insertData;

+ 9 - 1
src/background/workflowEngine/blocksHandler/handlerLoopData.js

@@ -1,3 +1,4 @@
+import objectPath from 'object-path';
 import { parseJSON, isXPath } from '@/utils/helper';
 
 async function loopData({ data, id }, { refData }) {
@@ -28,7 +29,10 @@ async function loopData({ data, id }, { refData }) {
         'data-columns': () => refData.table,
         'google-sheets': () => refData.googleSheets[data.referenceKey],
         variable: () => {
-          const variableVal = refData.variables[data.variableName];
+          const variableVal = objectPath.get(
+            refData.variables,
+            data.variableName
+          );
 
           if (Array.isArray(variableVal)) return variableVal;
 
@@ -76,6 +80,10 @@ async function loopData({ data, id }, { refData }) {
         } else if (data.startIndex > 0) {
           index = data.startIndex;
         }
+
+        if (data.reverseLoop) {
+          currLoopData.reverse();
+        }
       }
 
       const maxToLoop =

+ 28 - 0
src/background/workflowEngine/blocksHandler/handlerWorkflowState.js

@@ -0,0 +1,28 @@
+export default async function ({ data, id }) {
+  let stopCurrent = false;
+
+  if (data.type === 'stop-current') {
+    return {};
+  }
+  if (data.type === 'stop-all') {
+    const ids = [];
+    this.engine.states.getAll.forEach((state) => {
+      ids.push(state.id);
+    });
+
+    for (const stateId of ids) {
+      if (stateId === this.engine.id) {
+        stopCurrent = !data.exceptCurrent;
+      } else {
+        await this.engine.states.stop(stateId);
+      }
+    }
+  }
+
+  if (stopCurrent) return {};
+
+  return {
+    data: '',
+    nextBlockId: this.getBlockConnections(id),
+  };
+}

+ 18 - 5
src/background/workflowEngine/engine.js

@@ -1,10 +1,12 @@
 import browser from 'webextension-polyfill';
 import { nanoid } from 'nanoid';
-import { tasks } from '@/utils/shared';
+import { getBlocks } from '@/utils/getSharedData';
 import { clearCache, sleep, parseJSON, isObject } from '@/utils/helper';
 import dbStorage from '@/db/storage';
 import Worker from './worker';
 
+let blocks = getBlocks();
+
 class WorkflowEngine {
   constructor(workflow, { states, logger, blocksHandler, options }) {
     this.id = nanoid();
@@ -111,6 +113,8 @@ class WorkflowEngine {
         return;
       }
 
+      blocks = getBlocks();
+
       const checkParams = this.options?.checkParams ?? true;
       const hasParams = triggerBlock.data.parameters?.length > 0;
       if (checkParams && hasParams) {
@@ -242,7 +246,7 @@ class WorkflowEngine {
     this.workerId += 1;
 
     const workerId = `worker-${this.workerId}`;
-    const worker = new Worker(workerId, this);
+    const worker = new Worker(workerId, this, { blocksDetail: blocks });
     worker.init(detail);
 
     this.workers.set(worker.id, worker);
@@ -263,7 +267,7 @@ class WorkflowEngine {
       detail.name !== 'delay' ||
       detail.replacedValue ||
       detail.name === 'javascript-code' ||
-      (tasks[detail.name]?.refDataKeys && this.saveLog)
+      (blocks[detail.name]?.refDataKeys && this.saveLog)
     ) {
       const { activeTabUrl, variables, loopData } = JSON.parse(
         JSON.stringify(this.referenceData)
@@ -450,8 +454,17 @@ class WorkflowEngine {
       );
 
       this.isDestroyed = true;
-      this.referenceData = {};
-      this.eventListeners = {};
+      this.referenceData = null;
+      this.eventListeners = null;
+      this.packagesCache = null;
+      this.extractedGroup = null;
+      this.connectionsMap = null;
+      this.waitConnections = null;
+      this.blocks = null;
+      this.history = null;
+      this.columnsId = null;
+      this.historyCtxData = null;
+      this.preloadScripts = null;
     } catch (error) {
       console.error(error);
     }

+ 10 - 5
src/background/workflowEngine/worker.js

@@ -6,17 +6,17 @@ import {
   parseJSON,
   isObject,
 } from '@/utils/helper';
-import { tasks } from '@/utils/shared';
 import referenceData from '@/utils/referenceData';
 import mustacheReplacer from '@/utils/referenceData/mustacheReplacer';
-import injectContentScript from './injectContentScript';
 import { convertData, waitTabLoaded } from './helper';
+import injectContentScript from './injectContentScript';
 
 class Worker {
-  constructor(id, engine) {
+  constructor(id, engine, options = {}) {
     this.id = id;
     this.engine = engine;
     this.settings = engine.workflow.settings;
+    this.blocksDetail = options.blocksDetail || {};
 
     this.loopEls = [];
     this.loopList = {};
@@ -89,6 +89,8 @@ class Worker {
   }
 
   getBlockConnections(blockId, outputIndex = 1) {
+    if (this.engine.isDestroyed) return null;
+
     const outputId = `${blockId}-output-${outputIndex}`;
     return this.engine.connectionsMap[outputId] || null;
   }
@@ -148,7 +150,7 @@ class Worker {
 
     const blockHandler = this.engine.blocksHandler[toCamelCase(block.label)];
     const handler =
-      !blockHandler && tasks[block.label].category === 'interaction'
+      !blockHandler && this.blocksDetail[block.label].category === 'interaction'
         ? this.engine.blocksHandler.interactionBlock
         : blockHandler;
 
@@ -171,7 +173,7 @@ class Worker {
       refKeys:
         isRetry || block.data.disableBlock
           ? null
-          : tasks[block.label].refDataKeys,
+          : this.blocksDetail[block.label].refDataKeys,
     });
     const blockDelay = this.settings?.blockDelay || 0;
     const addBlockLog = (status, obj = {}) => {
@@ -181,6 +183,7 @@ class Worker {
         name: block.label,
         blockId: block.id,
         workerId: this.id,
+        timestamp: startExecuteTime,
         description: block.data.description,
         replacedValue: replacedBlock.replacedValue,
         duration: Math.round(Date.now() - startExecuteTime),
@@ -203,6 +206,8 @@ class Worker {
           ...(execParam || {}),
         });
 
+        if (this.engine.isDestroyed) return;
+
         if (result.replacedValue) {
           replacedBlock.replacedValue = result.replacedValue;
         }

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

@@ -50,7 +50,7 @@
       />
     </div>
     <textarea
-      :model-value="data.note"
+      :value="data.note"
       :style="initialSize"
       :class="[fontSize[data.fontSize || 'regular'].class]"
       placeholder="Write a note here..."

+ 2 - 2
src/components/block/BlockPackage.vue

@@ -4,8 +4,8 @@
       <img
         v-if="data.icon.startsWith('http')"
         :src="data.icon"
-        width="38"
-        height="38"
+        width="36"
+        height="36"
         class="mr-2 rounded-lg"
       />
       <div

+ 295 - 114
src/components/newtab/logs/LogsHistory.vue

@@ -8,132 +8,239 @@
     <v-remixicon name="riArrowLeftLine" class="mr-2" />
     {{ t('log.goBack', { name: parentLog.name }) }}
   </router-link>
-  <div
-    class="p-4 rounded-lg bg-gray-900 dark:bg-gray-800 text-gray-100 dark scroll overflow-auto"
-    style="max-height: 600px"
-  >
-    <slot name="prepend" />
-    <div class="text-sm font-mono space-y-1 w-full overflow-auto">
-      <ui-expand
-        v-for="(item, index) in history"
-        :key="item.id || index"
-        :disabled="!ctxData[item.id]"
-        hide-header-icon
-        class="hoverable rounded-md"
-        active-class="bg-box-transparent"
-        header-class="px-2 w-full text-left focus:ring-0 py-1 rounded-md group cursor-default flex items-start"
-        @click="state.itemId = item.id"
-      >
-        <template #header="{ show }">
-          <span class="w-6">
-            <v-remixicon
-              v-show="ctxData[item.id]"
-              :rotate="show ? 270 : 180"
-              size="20"
-              name="riArrowLeftSLine"
-              class="transition-transform text-gray-400 -ml-1 mr-2"
-            />
-          </span>
-          <span
-            :title="`${t('log.duration')}: ${Math.round(
-              item.duration / 1000
-            )}s`"
-            class="w-14 flex-shrink-0 text-overflow text-gray-400"
-          >
-            {{ countDuration(0, item.duration || 0) }}
-          </span>
-          <span
-            :class="logsType[item.type]?.color"
-            :title="item.type"
-            class="w-2/12 flex-shrink-0 text-overflow"
-          >
+  <div class="flex items-start">
+    <div class="flex-1">
+      <div class="rounded-lg bg-gray-900 dark:bg-gray-800 text-gray-100 dark">
+        <div
+          v-if="currentLog.status === 'error' && errorBlock"
+          class="border-b px-4 pt-4 text-gray-200 pb-4 mb-4"
+        >
+          <p class="leading-tight line-clamp">
+            {{ errorBlock.message }}
+          </p>
+          <p class="cursor-pointer" title="Jump to item" @click="jumpToError">
+            On the {{ errorBlock.name }} block
             <v-remixicon
-              :name="logsType[item.type]?.icon"
+              name="riArrowLeftLine"
+              class="inline-block -ml-1"
               size="18"
-              class="inline-block -mr-1 align-text-top"
+              rotate="135"
             />
-            {{ item.name }}
-          </span>
-          <span
-            :title="`${t('common.description')} (${item.description})`"
-            class="ml-2 w-2/12 text-overflow flex-shrink-0"
-          >
-            {{ item.description }}
-          </span>
-          <p
-            :title="item.message"
-            class="text-sm line-clamp ml-2 flex-1 leading-tight text-gray-600 dark:text-gray-200"
-          >
-            {{ item.message }}
           </p>
-          <router-link
-            v-if="item.logId"
-            v-slot="{ navigate }"
-            :to="{ name: 'logs-details', params: { id: item.logId } }"
-            custom
-          >
-            <v-remixicon
-              title="Open log detail"
-              class="ml-2 text-gray-300 cursor-pointer"
-              size="20"
-              name="riFileTextLine"
-              @click.stop="navigate"
-            />
-          </router-link>
-          <router-link
-            v-if="getBlockPath(item.blockId)"
-            v-show="currentLog.workflowId && item.blockId"
-            :to="getBlockPath(item.blockId)"
-          >
-            <v-remixicon
-              name="riExternalLinkLine"
-              size="20"
-              title="Go to block"
-              class="text-gray-300 cursor-pointer ml-2 invisible group-hover:visible"
-            />
-          </router-link>
-        </template>
-        <pre
-          class="px-2 pb-2 text-gray-300 overflow-auto text-sm ml-6 scroll max-h-96"
-          >{{ ctxData[state.itemId] || 'EMPTY' }}</pre
+        </div>
+        <div
+          id="log-history"
+          style="max-height: 600px"
+          class="scroll p-4 overflow-auto"
         >
-      </ui-expand>
-      <slot name="append-items" />
+          <slot name="prepend" />
+          <div class="text-sm font-mono space-y-1 w-full overflow-auto">
+            <div
+              v-for="(item, index) in history"
+              :key="item.id || index"
+              :disabled="!ctxData[item.id]"
+              :class="{ 'bg-box-transparent': item.id === state.itemId }"
+              hide-header-icon
+              class="hoverable rounded-md px-2 w-full text-left focus:ring-0 py-1 rounded-md group cursor-default flex items-start"
+              @click="setActiveLog(item)"
+            >
+              <div
+                style="min-width: 54px"
+                class="flex-shrink-0 mr-4 text-overflow text-gray-400"
+              >
+                <span
+                  v-if="item.timestamp"
+                  :title="
+                    dayjs(item.timestamp).format('YYYY-MM-DDTHH:mm:ss.SSS')
+                  "
+                >
+                  {{ dayjs(item.timestamp).format('HH:mm:ss') }}
+                  {{ `(${countDuration(0, item.duration || 0).trim()})` }}
+                </span>
+                <span v-else :title="`${Math.round(item.duration / 1000)}s`">
+                  {{ countDuration(0, item.duration || 0) }}
+                </span>
+              </div>
+              <span
+                :class="logsType[item.type]?.color"
+                :title="item.type"
+                class="w-2/12 flex-shrink-0 text-overflow"
+              >
+                <v-remixicon
+                  :name="logsType[item.type]?.icon"
+                  size="18"
+                  class="inline-block -mr-1 align-text-top"
+                />
+                {{ item.name }}
+              </span>
+              <span
+                :title="`${t('common.description')} (${item.description})`"
+                class="ml-2 w-2/12 text-overflow flex-shrink-0"
+              >
+                {{ item.description }}
+              </span>
+              <p
+                :title="item.message"
+                class="text-sm line-clamp ml-2 flex-1 leading-tight text-gray-600 dark:text-gray-200"
+              >
+                {{ item.message }}
+              </p>
+              <router-link
+                v-if="item.logId"
+                v-slot="{ navigate }"
+                :to="{ name: 'logs-details', params: { id: item.logId } }"
+                custom
+              >
+                <v-remixicon
+                  title="Open log detail"
+                  class="ml-2 text-gray-300 cursor-pointer"
+                  size="20"
+                  name="riFileTextLine"
+                  @click.stop="navigate"
+                />
+              </router-link>
+              <router-link
+                v-if="getBlockPath(item.blockId)"
+                v-show="currentLog.workflowId && item.blockId"
+                :to="getBlockPath(item.blockId)"
+              >
+                <v-remixicon
+                  name="riExternalLinkLine"
+                  size="20"
+                  title="Go to block"
+                  class="text-gray-300 cursor-pointer ml-2 invisible group-hover:visible"
+                />
+              </router-link>
+            </div>
+            <slot name="append-items" />
+          </div>
+        </div>
+      </div>
+      <div
+        v-if="currentLog.history.length >= 25"
+        class="flex items-center justify-between mt-4"
+      >
+        <div>
+          {{ t('components.pagination.text1') }}
+          <select v-model="pagination.perPage" class="p-1 rounded-md bg-input">
+            <option
+              v-for="num in [25, 50, 75, 100, 150, 200]"
+              :key="num"
+              :value="num"
+            >
+              {{ num }}
+            </option>
+          </select>
+          {{
+            t('components.pagination.text2', {
+              count: currentLog.history.length,
+            })
+          }}
+        </div>
+        <ui-pagination
+          v-model="pagination.currentPage"
+          :per-page="pagination.perPage"
+          :records="currentLog.history.length"
+        />
+      </div>
     </div>
-  </div>
-  <div
-    v-if="currentLog.history.length >= 25"
-    class="flex items-center justify-between mt-4"
-  >
-    <div>
-      {{ t('components.pagination.text1') }}
-      <select v-model="pagination.perPage" class="p-1 rounded-md bg-input">
-        <option
-          v-for="num in [25, 50, 75, 100, 150, 200]"
-          :key="num"
-          :value="num"
-        >
-          {{ num }}
-        </option>
-      </select>
-      {{
-        t('components.pagination.text2', {
-          count: currentLog.history.length,
-        })
-      }}
+    <div
+      v-if="state.itemId && activeLog"
+      class="w-4/12 ml-8 rounded-lg bg-gray-900 dark:bg-gray-800 text-gray-100 dark"
+    >
+      <div class="p-4 relative">
+        <v-remixicon
+          name="riCloseLine"
+          class="absolute top-2 right-2 cursor-pointer text-gray-500"
+          @click="clearActiveItem"
+        />
+        <table class="w-full ctx-data-table">
+          <thead>
+            <tr>
+              <td class="w-5/12"></td>
+              <td></td>
+            </tr>
+          </thead>
+          <tbody>
+            <tr>
+              <td class="text-gray-300">Name</td>
+              <td>{{ activeLog.name }}</td>
+            </tr>
+            <tr>
+              <td class="text-gray-300">Description</td>
+              <td>
+                <p class="line-clamp leading-tight">
+                  {{ activeLog.description }}
+                </p>
+              </td>
+            </tr>
+            <tr>
+              <td class="text-gray-300">Status</td>
+              <td class="capitalize">{{ activeLog.type }}</td>
+            </tr>
+            <tr>
+              <td class="text-gray-300">Timestamp/Duration</td>
+              <td>
+                <span v-if="activeLog.timestamp">
+                  {{ dayjs(activeLog.timestamp).format('DD MMM, HH:mm:ss') }}
+                  /
+                </span>
+                {{ countDuration(0, activeLog.duration || 0).trim() }}
+              </td>
+            </tr>
+            <tr v-if="activeLog.message">
+              <td class="text-gray-300">Message</td>
+              <td>
+                <p class="line-clamp leading-tight">
+                  {{ activeLog.message }}
+                </p>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+      <template v-if="ctxData[state.itemId]">
+        <div class="px-4 pb-4 flex items-center">
+          <p>Log data</p>
+          <div class="flex-grow" />
+          <ui-select v-model="state.activeTab">
+            <option v-for="option in tabs" :key="option.id" :value="option.id">
+              {{ option.name }}
+            </option>
+          </ui-select>
+        </div>
+        <div class="pb-4 px-2 log-data-prev">
+          <shared-codemirror
+            :model-value="logCtxData"
+            readonly
+            hide-lang
+            lang="json"
+            style="max-height: 460px"
+            class="scroll"
+          />
+        </div>
+      </template>
     </div>
-    <ui-pagination
-      v-model="pagination.currentPage"
-      :per-page="pagination.perPage"
-      :records="currentLog.history.length"
-    />
   </div>
 </template>
 <script setup>
 /* eslint-disable no-use-before-define */
-import { computed, shallowReactive } from 'vue';
+import {
+  computed,
+  shallowReactive,
+  defineAsyncComponent,
+  shallowRef,
+} from 'vue';
 import { useI18n } from 'vue-i18n';
 import { countDuration } from '@/utils/helper';
+import { getBlocks } from '@/utils/getSharedData';
+import dayjs from '@/lib/dayjs';
+import objectPath from 'object-path';
+
+const SharedCodemirror = defineAsyncComponent(() =>
+  import('@/components/newtab/shared/SharedCodemirror.vue')
+);
+const blocks = getBlocks();
 
 const props = defineProps({
   currentLog: {
@@ -172,16 +279,25 @@ const logsType = {
     icon: 'riFlagLine',
   },
 };
+const tabs = [
+  { id: 'all', name: 'All' },
+  { id: 'referenceData.loopData', name: 'Loop data' },
+  { id: 'referenceData.variables', name: 'Variables' },
+  { id: 'referenceData.prevBlockData', name: 'Previous block data' },
+  { id: 'replacedValue', name: 'Replaced value' },
+];
 
 const { t, te } = useI18n();
 
 const state = shallowReactive({
   itemId: '',
+  activeTab: 'all',
 });
 const pagination = shallowReactive({
   perPage: 25,
   currentPage: 1,
 });
+const activeLog = shallowRef(null);
 
 const history = computed(() =>
   props.currentLog.history
@@ -191,7 +307,36 @@ const history = computed(() =>
     )
     .map(translateLog)
 );
+const errorBlock = computed(() => {
+  if (props.currentLog.status !== 'error') return null;
+
+  let block = props.currentLog.history.at(-1);
+  if (!block || block.type !== 'error' || block.id < 25) return null;
+
+  block = translateLog(block);
+  let { name } = block;
+  if (block.description) name += ` (${block.description})`;
 
+  return {
+    name,
+    id: block.id,
+    message: block.message,
+  };
+});
+const logCtxData = computed(() => {
+  if (!state.itemId || !props.ctxData[state.itemId]) return '';
+
+  const data = props.ctxData[state.itemId];
+  const logData =
+    state.activeTab === 'all' ? data : objectPath.get(data, state.activeTab);
+
+  return JSON.stringify(logData, null, 2);
+});
+
+function clearActiveItem() {
+  state.itemId = '';
+  activeLog.value = null;
+}
 function translateLog(log) {
   const copyLog = { ...log };
   const getTranslatation = (path, def) => {
@@ -205,7 +350,7 @@ function translateLog(log) {
   } else {
     copyLog.name = getTranslatation(
       `workflow.blocks.${log.name}.name`,
-      log.name
+      blocks[log.name].name
     );
   }
 
@@ -216,6 +361,10 @@ function translateLog(log) {
 
   return copyLog;
 }
+function setActiveLog(item) {
+  state.itemId = item.id;
+  activeLog.value = item;
+}
 function getBlockPath(blockId) {
   const { workflowId, teamId } = props.currentLog;
   let path = `/workflows/${workflowId}`;
@@ -226,4 +375,36 @@ function getBlockPath(blockId) {
 
   return `${path}?blockId=${blockId}`;
 }
+function jumpToError() {
+  pagination.currentPage = Math.ceil(errorBlock.value.id / pagination.perPage);
+
+  const element = document.querySelector('#log-history');
+  if (!element) return;
+
+  element.scrollTo(0, element.scrollHeight);
+}
 </script>
+<style>
+.ctx-data-table {
+  thead td {
+    padding: 0;
+  }
+  td {
+    @apply p-1;
+  }
+  tr {
+    vertical-align: baseline;
+  }
+}
+.log-data-prev .cm-editor {
+  background-color: transparent;
+  .cm-activeLine.cm-line {
+    background-color: rgb(255 255 255 / 0.05) !important;
+  }
+  .cm-gutters,
+  .cm-activeLineGutter,
+  .cm-gutterElement {
+    background-color: transparent !important;
+  }
+}
+</style>

+ 4 - 3
src/components/newtab/package/PackageSettingIOSelect.vue

@@ -52,7 +52,7 @@
 <script setup>
 /* eslint-disable */
 import { reactive, computed, onMounted } from 'vue';
-import { tasks } from '@/utils/shared';
+import { getBlocks } from '@/utils/getSharedData';
 
 const props = defineProps({
   nodes: {
@@ -70,6 +70,7 @@ const props = defineProps({
 });
 const emit = defineEmits(['update']);
 
+const blocks = getBlocks();
 const handleType = props.type === 'inputs' ? 'target' : 'source';
 
 const state = reactive({
@@ -93,7 +94,7 @@ const items = computed(() => {
       if (data.name) additionalKey = includeQuery(data.name);
       else if (data.description) additionalKey = includeQuery(data.description);
 
-      return includeQuery(tasks[label]?.name || '') || additionalKey;
+      return includeQuery(blocks[label]?.name || '') || additionalKey;
     });
   }
 
@@ -119,7 +120,7 @@ function selectItem(item) {
 }
 function getBlockName(item, type) {
   const { label, data } = item;
-  let name = tasks[label]?.name || '';
+  let name = blocks[label]?.name || '';
 
   if (data.name) name += ` (${data.name})`;
   else if (data.description) name += ` (${data.description})`;

+ 4 - 2
src/components/newtab/package/PackageSettings.vue

@@ -129,7 +129,7 @@
 import { reactive, watch, onMounted } from 'vue';
 import cloneDeep from 'lodash.clonedeep';
 import Draggable from 'vuedraggable';
-import { tasks } from '@/utils/shared';
+import { getBlocks } from '@/utils/getSharedData';
 import { debounce } from '@/utils/helper';
 
 const props = defineProps({
@@ -144,6 +144,8 @@ const props = defineProps({
 });
 const emit = defineEmits(['update', 'goBlock']);
 
+const blocks = getBlocks();
+
 const state = reactive({
   retrieved: false,
 });
@@ -160,7 +162,7 @@ function deleteBlockIo(type, index) {
 const cacheIOName = new Map();
 
 function getNodeName({ label, data }) {
-  let name = tasks[label]?.name || '';
+  let name = blocks[label]?.name || '';
 
   if (data.name) name += ` (${data.name})`;
   else if (data.description) name += ` (${data.description})`;

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

@@ -33,9 +33,9 @@
         :key="block.id || block.name"
         class="flex items-center py-2"
       >
-        <v-remixicon :name="tasks[block.name].icon" />
+        <v-remixicon :name="blocks[block.name].icon" />
         <p class="flex-1 ml-2 mr-4 text-overflow">
-          {{ tasks[block.name].name }}
+          {{ blocks[block.name].name }}
         </p>
         <ui-spinner color="text-accent" size="20" />
       </div>
@@ -59,7 +59,7 @@
 import browser from 'webextension-polyfill';
 import { useI18n } from 'vue-i18n';
 import { sendMessage } from '@/utils/message';
-import { tasks } from '@/utils/shared';
+import { getBlocks } from '@/utils/getSharedData';
 import dayjs from '@/lib/dayjs';
 
 const props = defineProps({
@@ -69,6 +69,7 @@ const props = defineProps({
   },
 });
 
+const blocks = getBlocks();
 const { t } = useI18n();
 
 function formatDate(date, format) {

+ 4 - 0
src/components/newtab/workflow/WorkflowBlockList.vue

@@ -58,6 +58,7 @@
 </template>
 <script setup>
 import { useI18n } from 'vue-i18n';
+import { getBlocks } from '@/utils/getSharedData';
 
 defineProps({
   category: {
@@ -76,9 +77,12 @@ defineProps({
 defineEmits(['pin']);
 
 const { t, te } = useI18n();
+const blocksDetail = getBlocks();
 
 function getBlockTitle({ description, id, name }) {
   const blockPath = `workflow.blocks.${id}`;
+  if (!te(blockPath)) return blocksDetail[id].name;
+
   const descPath = `${blockPath}.${description ? 'description' : 'name'}`;
   let blockDescription = te(descPath) ? t(descPath) : name;
 

+ 21 - 15
src/components/newtab/workflow/WorkflowDetailsCard.vue

@@ -83,8 +83,8 @@ import { computed, ref, onMounted, watch, toRaw } from 'vue';
 import { useI18n } from 'vue-i18n';
 import browser from 'webextension-polyfill';
 import { useShortcut } from '@/composable/shortcut';
-import { tasks, categories } from '@/utils/shared';
-import customBlocks from '@business/blocks';
+import { categories } from '@/utils/shared';
+import { getBlocks } from '@/utils/getSharedData';
 import WorkflowBlockList from './WorkflowBlockList.vue';
 
 defineProps({
@@ -126,20 +126,18 @@ const icons = [
   'riCommandLine',
 ];
 
-const copyTasks = { ...tasks };
-delete copyTasks['block-package'];
+const copyBlocks = getBlocks();
+delete copyBlocks['block-package'];
 
-const blocksArr = Object.entries({ ...copyTasks, ...customBlocks }).map(
-  ([key, block]) => {
-    const localeKey = `workflow.blocks.${key}.name`;
+const blocksArr = Object.entries(copyBlocks).map(([key, block]) => {
+  const localeKey = `workflow.blocks.${key}.name`;
 
-    return {
-      ...block,
-      id: key,
-      name: te(localeKey) ? t(localeKey) : block.name,
-    };
-  }
-);
+  return {
+    ...block,
+    id: key,
+    name: te(localeKey) ? t(localeKey) : block.name,
+  };
+});
 
 const descriptionCollapsed = ref(true);
 
@@ -159,7 +157,15 @@ const blocks = computed(() =>
 );
 const pinnedBlocksList = computed(() =>
   pinnedBlocks.value
-    .map((id) => ({ ...tasks[id], id, name: t(`workflow.blocks.${id}.name`) }))
+    .map((id) => {
+      const namePath = `workflow.blocks.${id}.name`;
+
+      return {
+        ...copyBlocks[id],
+        id,
+        name: te(namePath) ? t(namePath) : copyBlocks[id].name,
+      };
+    })
     .filter(({ name }) =>
       name.toLocaleLowerCase().includes(query.value.toLocaleLowerCase())
     )

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

@@ -74,7 +74,7 @@ const components = editComponents.keys().reduce((acc, key) => {
   return acc;
 }, {});
 
-Object.assign(components, customEditComponents);
+Object.assign(components, customEditComponents());
 
 const props = defineProps({
   data: {

+ 3 - 3
src/components/newtab/workflow/WorkflowEditor.vue

@@ -70,8 +70,8 @@ import {
 } from '@braks/vue-flow';
 import cloneDeep from 'lodash.clonedeep';
 import { useStore } from '@/stores/main';
-import { tasks, categories } from '@/utils/shared';
-import customBlocks from '@business/blocks';
+import { categories } from '@/utils/shared';
+import { getBlocks } from '@/utils/getSharedData';
 import EditorSearchBlocks from './editor/EditorSearchBlocks.vue';
 
 const props = defineProps({
@@ -160,7 +160,7 @@ editor.onEdgeUpdate(({ edge, connection }) => {
   Object.assign(edge, connection);
 });
 
-const blocks = { ...tasks, ...customBlocks };
+const blocks = getBlocks();
 const settings = store.settings.editor;
 const isDisabled = computed(() => props.options.disabled ?? props.disabled);
 

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

@@ -43,7 +43,7 @@
 import browser from 'webextension-polyfill';
 import { useI18n } from 'vue-i18n';
 import { sendMessage } from '@/utils/message';
-import { tasks } from '@/utils/shared';
+import { getBlocks } from '@/utils/getSharedData';
 import dayjs from '@/lib/dayjs';
 
 defineProps({
@@ -54,11 +54,12 @@ defineProps({
 });
 
 const { t } = useI18n();
+const blocks = getBlocks();
 
 function getBlock(item) {
   if (!item.state.currentBlock) return {};
 
-  return tasks[item.state.currentBlock.name];
+  return blocks[item.state.currentBlock.name];
 }
 function formatDate(date, format) {
   if (format === 'relative') return dayjs(date).fromNow();

+ 201 - 60
src/components/newtab/workflow/edit/EditInsertData.vue

@@ -6,67 +6,150 @@
       class="w-full"
       @change="updateData({ description: $event })"
     />
-    <ul v-show="dataList.length > 0" class="mt-4 data-list">
-      <li
-        v-for="(item, index) in dataList"
-        :key="index"
-        class="mb-4 pb-4 border-b"
+    <ui-button
+      class="w-full mt-4 mb-2"
+      variant="accent"
+      @click="showModal = !showModal"
+    >
+      Insert data
+    </ui-button>
+    <ui-modal
+      v-model="showModal"
+      title="Insert data"
+      padding="p-0"
+      content-class="max-w-2xl insert-data-modal"
+    >
+      <ul
+        class="mt-4 data-list px-4 pb-4 overflow-auto scroll"
+        style="max-height: calc(100vh - 13rem)"
       >
-        <div class="flex mb-2">
-          <ui-select
-            :model-value="item.type"
-            class="mr-2 flex-shrink-0"
-            @change="changeItemType(index, $event)"
-          >
-            <option value="table">
-              {{ t('workflow.table.title') }}
-            </option>
-            <option value="variable">
-              {{ t('workflow.variables.title') }}
-            </option>
-          </ui-select>
-          <ui-input
-            v-if="item.type === 'variable'"
-            v-model="item.name"
-            :placeholder="t('workflow.variables.name')"
-            :title="t('workflow.variables.name')"
-            class="flex-1"
-          />
-          <ui-select
-            v-else
-            v-model="item.name"
-            :placeholder="t('workflow.table.select')"
-          >
-            <option
-              v-for="column in workflow.columns.value"
-              :key="column.id"
-              :value="column.id"
+        <li
+          v-for="(item, index) in dataList"
+          :key="index"
+          class="mb-4 rounded-lg border"
+        >
+          <div class="p-2 border-b flex items-center">
+            <ui-select
+              :model-value="item.type"
+              class="mr-2 flex-shrink-0"
+              @change="changeItemType(index, $event)"
             >
-              {{ column.name }}
-            </option>
-          </ui-select>
-        </div>
-        <div class="flex items-start">
-          <ui-textarea
-            v-model="item.value"
-            placeholder="value"
-            title="value"
-            class="flex-1 mr-2"
-          />
-          <ui-button icon @click="dataList.splice(index, 1)">
-            <v-remixicon name="riDeleteBin7Line" />
-          </ui-button>
-        </div>
-      </li>
-    </ul>
-    <ui-button class="mt-4" variant="accent" @click="addItem">
-      {{ t('common.add') }}
-    </ui-button>
+              <option value="table">
+                {{ t('workflow.table.title') }}
+              </option>
+              <option value="variable">
+                {{ t('workflow.variables.title') }}
+              </option>
+            </ui-select>
+            <ui-input
+              v-if="item.type === 'variable'"
+              v-model="item.name"
+              :placeholder="t('workflow.variables.name')"
+              :title="t('workflow.variables.name')"
+              class="flex-1"
+            />
+            <ui-select
+              v-else
+              v-model="item.name"
+              :placeholder="t('workflow.table.select')"
+            >
+              <option
+                v-for="column in workflow.columns.value"
+                :key="column.id"
+                :value="column.id"
+              >
+                {{ column.name }}
+              </option>
+            </ui-select>
+            <div class="flex-grow" />
+            <v-remixicon
+              name="riDeleteBin7Line"
+              class="cursor-pointer"
+              @click="removeItem(index)"
+            />
+          </div>
+          <div class="p-2">
+            <div v-if="hasFileAccess && item.isFile" class="flex items-start">
+              <ui-input
+                v-model="item.filePath"
+                placeholder="File absolute path"
+                class="flex-1"
+              />
+            </div>
+            <ui-textarea
+              v-else
+              v-model="item.value"
+              placeholder="value"
+              title="value"
+              class="w-full"
+            />
+            <div class="flex mt-2 items-center">
+              <ui-button
+                v-tooltip="
+                  hasFileAccess
+                    ? 'Import file'
+                    : 'Don\'t have access, click to learn more'
+                "
+                :class="{ 'text-primary': item.isFile }"
+                icon
+                @click="setAsFile(item)"
+              >
+                <v-remixicon name="riFileLine" />
+              </ui-button>
+              <template v-if="hasFileAccess && item.isFile">
+                <ui-button class="ml-2" @click="previewData(index, item)">
+                  Preview data
+                </ui-button>
+                <ui-button
+                  v-if="previewState.itemId === index"
+                  v-tooltip="'Clear preview'"
+                  class="ml-2"
+                  icon
+                  @click="clearPreview"
+                >
+                  <v-remixicon name="riBrush2Line" />
+                </ui-button>
+                <div class="flex-grow" />
+                <ui-select
+                  v-if="item.filePath.endsWith('.csv')"
+                  v-model="item.csvAction"
+                  placeholder="CSV File Action"
+                >
+                  <option value="text">Read as text</option>
+                  <option value="json">Read as JSON</option>
+                  <option value="json-header">Read as JSON with headers</option>
+                </ui-select>
+              </template>
+            </div>
+            <shared-codemirror
+              v-if="previewState.itemId === index"
+              :model-value="previewState.data"
+              readonly
+              hide-lang
+              class="w-full mt-4"
+              lang="json"
+              style="max-height: 500px"
+            />
+          </div>
+        </li>
+        <ui-button class="mt-4 w-24" variant="accent" @click="addItem">
+          {{ t('common.add') }}
+        </ui-button>
+      </ul>
+    </ui-modal>
   </div>
 </template>
 <script setup>
-import { ref, watch, inject } from 'vue';
+import { ref, watch, inject, shallowReactive, defineAsyncComponent } from 'vue';
 import { useI18n } from 'vue-i18n';
+import { useToast } from 'vue-toastification';
+import Papa from 'papaparse';
+import browser from 'webextension-polyfill';
+import getFile from '@/utils/getFile';
+
+const SharedCodemirror = defineAsyncComponent(() =>
+  import('@/components/newtab/shared/SharedCodemirror.vue')
+);
 
 const props = defineProps({
   data: {
@@ -77,10 +160,26 @@ const props = defineProps({
 const emit = defineEmits(['update:data']);
 
 const { t } = useI18n();
+const toast = useToast();
 
 const workflow = inject('workflow', {});
+const showModal = ref(false);
+const hasFileAccess = ref(false);
 const dataList = ref(JSON.parse(JSON.stringify(props.data.dataList)));
 
+const previewState = shallowReactive({
+  data: '',
+  itemId: '',
+});
+
+function clearPreview() {
+  previewState.itemId = '';
+  previewState.data = '';
+}
+function removeItem(index) {
+  dataList.value.splice(index, 1);
+  clearPreview();
+}
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });
 }
@@ -89,6 +188,9 @@ function addItem() {
     type: 'table',
     name: '',
     value: '',
+    filePath: '',
+    isFile: false,
+    csvAction: 'text',
   });
 }
 function changeItemType(index, type) {
@@ -98,6 +200,47 @@ function changeItemType(index, type) {
     name: '',
   };
 }
+function setAsFile(item) {
+  if (!hasFileAccess.value) {
+    window.open(
+      'https://docs.automa.site/blocks/upload-file.html#requirements'
+    );
+    return;
+  }
+
+  item.isFile = !item.isFile;
+}
+async function previewData(index, item) {
+  try {
+    const path = item.filePath || '';
+    const isJSON = path.endsWith('.json');
+    const isCSV = path.endsWith('.csv');
+
+    let stringify = isJSON;
+    let result = await getFile(path, {
+      returnValue: true,
+      responseType: isJSON ? 'json' : 'text',
+    });
+
+    if (result && isCSV && item.csvAction && item.csvAction.includes('json')) {
+      const parsedCSV = Papa.parse(result, {
+        header: item.csvAction.includes('header'),
+      });
+      result = parsedCSV.data || [];
+      stringify = true;
+    }
+
+    previewState.itemId = index;
+    previewState.data = stringify ? JSON.stringify(result, null, 2) : result;
+  } catch (error) {
+    console.error(error);
+    toast.error(error.message);
+  }
+}
+
+browser.extension.isAllowedFileSchemeAccess().then((value) => {
+  hasFileAccess.value = value;
+});
 
 watch(
   dataList,
@@ -107,10 +250,8 @@ watch(
   { deep: true }
 );
 </script>
-<style scoped>
-.data-list li:last-child {
-  padding-bottom: 0;
-  margin-bottom: 0;
-  border-bottom: 0;
+<style>
+.insert-data-modal .modal-ui__content-header {
+  @apply p-4;
 }
 </style>

+ 7 - 0
src/components/newtab/workflow/edit/EditLoopData.vue

@@ -125,6 +125,13 @@
       >
         {{ t('workflow.blocks.loop-data.resumeLastWorkflow') }}
       </ui-checkbox>
+      <ui-checkbox
+        :model-value="data.reverseLoop"
+        class="mt-1"
+        @change="updateData({ reverseLoop: $event })"
+      >
+        {{ t('workflow.blocks.loop-data.reverse') }}
+      </ui-checkbox>
     </template>
     <ui-modal
       v-model="state.showDataModal"

+ 102 - 66
src/components/newtab/workflow/edit/EditWorkflowParameters.vue

@@ -10,66 +10,75 @@
       No parameters
     </p>
     <table v-else class="w-full">
-      <thead>
-        <tr class="text-sm text-left">
-          <th>Name</th>
-          <th>Type</th>
-          <th>Placeholder</th>
-          <th>Default Value</th>
-          <th></th>
-        </tr>
-      </thead>
-      <tbody>
-        <template v-for="(param, index) in state.parameters" :key="index">
-          <tr class="align-top">
-            <td>
-              <ui-input
-                :model-value="param.name"
-                placeholder="Parameter name"
-                @change="updateParam(index, $event)"
-              />
-            </td>
-            <td>
-              <ui-select
-                :model-value="param.type"
-                @change="updateParamType(index, $event)"
-              >
-                <option
-                  v-for="type in paramTypesArr"
-                  :key="type.id"
-                  :value="type.id"
+      <div class="text-sm grid grid-cols-12 space-x-2">
+        <div class="col-span-3" style="padding-left: 28px">Name</div>
+        <div class="col-span-2">Type</div>
+        <div class="col-span-3">Placeholder</div>
+        <div class="col-span-4">Default Value</div>
+      </div>
+      <draggable
+        v-model="state.parameters"
+        tag="div"
+        item-key="id"
+        handle=".handle"
+      >
+        <template #item="{ element: param, index }">
+          <div class="mb-4">
+            <div class="grid grid-cols-12 space-x-2">
+              <div class="col-span-3 flex items-center">
+                <v-remixicon name="mdiDrag" class="handle mr-2 cursor-move" />
+                <ui-input
+                  :model-value="param.name"
+                  placeholder="Parameter name"
+                  @change="updateParam(index, $event)"
+                />
+              </div>
+              <div class="col-span-2">
+                <ui-select
+                  :model-value="param.type"
+                  @change="updateParamType(index, $event)"
+                >
+                  <option
+                    v-for="type in paramTypesArr"
+                    :key="type.id"
+                    :value="type.id"
+                  >
+                    {{ type.name }}
+                  </option>
+                </ui-select>
+              </div>
+              <div class="col-span-3">
+                <ui-input
+                  v-model="param.placeholder"
+                  placeholder="A parameter"
+                />
+              </div>
+              <div class="col-span-4 flex items-center">
+                <component
+                  :is="paramTypes[param.type].valueComp"
+                  v-if="paramTypes[param.type].valueComp"
+                  v-model="param.defaultValue"
+                  :param-data="param"
+                  :editor="true"
+                  class="flex-1"
+                  style="max-width: 232px"
+                />
+                <ui-input
+                  v-else
+                  v-model="param.defaultValue"
+                  :type="param.type === 'number' ? 'number' : 'text'"
+                  placeholder="NULL"
+                />
+                <ui-button
+                  icon
+                  class="ml-2"
+                  @click="state.parameters.splice(index, 1)"
                 >
-                  {{ type.name }}
-                </option>
-              </ui-select>
-            </td>
-            <td>
-              <ui-input v-model="param.placeholder" placeholder="A parameter" />
-            </td>
-            <td>
-              <component
-                :is="paramTypes[param.type].valueComp"
-                v-if="paramTypes[param.type].valueComp"
-                v-model="param.defaultValue"
-                :param-data="param"
-                :editor="true"
-                max-width="250px"
-              />
-              <ui-input
-                v-else
-                v-model="param.defaultValue"
-                :type="param.type === 'number' ? 'number' : 'text'"
-                placeholder="NULL"
-              />
-            </td>
-            <td>
-              <ui-button icon @click="state.parameters.splice(index, 1)">
-                <v-remixicon name="riDeleteBin7Line" />
-              </ui-button>
-            </td>
-          </tr>
-          <tr v-if="paramTypes[param.type].options">
-            <td colspan="999" style="padding-top: 0">
+                  <v-remixicon name="riDeleteBin7Line" />
+                </ui-button>
+              </div>
+            </div>
+            <div class="w-full">
               <ui-expand
                 hide-header-icon
                 header-class="flex items-center focus:ring-0 w-full"
@@ -83,16 +92,25 @@
                   <span>Options</span>
                 </template>
                 <div class="pl-[28px] mt-2 mb-4">
+                  <ui-textarea
+                    v-model="param.description"
+                    placeholder="Description"
+                    title="Description"
+                    class="mb-2"
+                    style="max-width: 400px"
+                  />
                   <component
                     :is="paramTypes[param.type].options"
+                    v-if="paramTypes[param.type].options"
                     v-model="param.data"
+                    :default-value="paramTypes[param.type].data"
                   />
                 </div>
               </ui-expand>
-            </td>
-          </tr>
+            </div>
+          </div>
         </template>
-      </tbody>
+      </draggable>
     </table>
   </div>
   <ui-button variant="accent" class="mt-4" @click="addParameter">
@@ -101,8 +119,12 @@
 </template>
 <script setup>
 import { reactive, watch } from 'vue';
+import { nanoid } from 'nanoid/non-secure';
 import cloneDeep from 'lodash.clonedeep';
-import * as workflowParameters from '@business/parameters';
+import workflowParameters from '@business/parameters';
+import Draggable from 'vuedraggable';
+import ParameterInputValue from './Parameter/ParameterInputValue.vue';
+import ParameterInputOptions from './Parameter/ParameterInputOptions.vue';
 
 const props = defineProps({
   data: {
@@ -112,30 +134,44 @@ const props = defineProps({
 });
 const emit = defineEmits(['update']);
 
+const customParameters = workflowParameters();
+
 const paramTypes = {
   string: {
     id: 'string',
     name: 'Input (string)',
+    options: ParameterInputOptions,
+    valueComp: ParameterInputValue,
+    data: {
+      masks: [],
+      useMask: false,
+      unmaskValue: false,
+    },
   },
   number: {
     id: 'number',
     name: 'Input (number)',
   },
-  ...workflowParameters,
+  ...customParameters,
 };
 const paramTypesArr = Object.values(paramTypes).filter((item) => item.id);
 
 const state = reactive({
-  parameters: cloneDeep(props.data || []),
+  parameters: cloneDeep(props.data || []).map((item) => {
+    item.id = nanoid(4);
+
+    return item;
+  }),
 });
 
 function addParameter() {
   state.parameters.push({
-    data: {},
     name: 'param',
     type: 'string',
+    description: '',
     defaultValue: '',
     placeholder: 'Text',
+    data: paramTypes.string.data,
   });
 }
 function updateParam(index, value) {

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

@@ -0,0 +1,63 @@
+<template>
+  <div>
+    <ui-textarea
+      :model-value="data.description"
+      class="w-full"
+      :placeholder="t('common.description')"
+      @change="updateData({ description: $event })"
+    />
+    <ui-select
+      :model-value="data.type"
+      label="Action"
+      class="w-full mt-4"
+      @change="updateData({ type: $event })"
+    >
+      <optgroup v-for="action in actions" :key="action.id" :label="action.name">
+        <option
+          v-for="item in actionsItems[action.id]"
+          :key="item.id"
+          :value="item.id"
+        >
+          {{ item.name }}
+        </option>
+      </optgroup>
+    </ui-select>
+    <ui-checkbox
+      v-if="includeExceptions.includes(data.type)"
+      :model-value="data.exceptCurrent"
+      class="mt-2"
+      @change="updateData({ exceptCurrent: $event })"
+    >
+      Execpt for the current workflow
+    </ui-checkbox>
+  </div>
+</template>
+<script setup>
+import { useI18n } from 'vue-i18n';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const { t } = useI18n();
+
+const includeExceptions = ['stop-all'];
+const actions = [
+  { id: 'stop', name: t('workflow.blocks.workflow-state.actions.stop') },
+];
+const actionsItems = {
+  stop: [
+    { id: 'stop-all', name: 'Stop all workflows' },
+    { id: 'stop-current', name: 'Stop current workflow' },
+    // { id: 'stop-specific', name: 'Stop specific workflows' },
+  ],
+};
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+</script>

+ 156 - 0
src/components/newtab/workflow/edit/Parameter/ParameterInputOptions.vue

@@ -0,0 +1,156 @@
+<template>
+  <div class="flex items-center">
+    <label class="flex items-center">
+      <ui-switch v-model="options.useMask" />
+      <span class="ml-2"> Use input masking </span>
+    </label>
+    <v-remixicon
+      v-tooltip="{ content: maskInfo, allowHTML: true }"
+      name="riInformationLine"
+      class="ml-1 text-gray-600 dark:text-gray-200"
+      size="20"
+    />
+    <label v-if="false" class="flex items-center ml-4">
+      <ui-switch v-model="options.unmaskValue" />
+      <span class="ml-2">Return unmask value</span>
+    </label>
+  </div>
+  <div v-if="options.useMask" class="mt-2">
+    <p>Masks</p>
+    <div class="space-y-2">
+      <div
+        v-for="(mask, index) in options.masks"
+        :key="index"
+        class="flex items-center"
+      >
+        <ui-input
+          v-model="options.masks[index].mask"
+          placeholder="aaa-aaa-aaa"
+        />
+        <ui-checkbox v-model="mask.isRegex" class="ml-4">
+          Is RegEx
+        </ui-checkbox>
+        <div class="flex-grow" />
+        <v-remixicon
+          name="riDeleteBin7Line"
+          class="cursor-pointer flex-shrink-0 ml-1"
+          @click="options.masks.splice(index, 1)"
+        />
+      </div>
+    </div>
+    <template v-if="false">
+      <p>Custom tokens</p>
+      <div class="grid grid-cols-2 gap-4">
+        <div
+          v-for="(token, index) in options.customTokens"
+          :key="index"
+          class="flex items-center"
+        >
+          <ui-input
+            v-model="token.symbol"
+            placeholder="Symbol"
+            style="width: 120px"
+          />
+          <ui-input
+            v-model="token.regex"
+            placeholder="RegEx"
+            class="flex-1 ml-2"
+          />
+          <v-remixicon
+            name="riDeleteBin7Line"
+            class="cursor-pointer flex-shrink-0 ml-1"
+            @click="options.customTokens.splice(index, 1)"
+          />
+        </div>
+      </div>
+      <ui-button class="mt-4" @click="addToken"> Add token </ui-button>
+    </template>
+  </div>
+</template>
+<script setup>
+import { reactive, watch, onMounted } from 'vue';
+import cloneDeep from 'lodash.clonedeep';
+
+const props = defineProps({
+  modelValue: {
+    type: [Object, String],
+    default: () => ({}),
+  },
+  defaultValue: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:modelValue']);
+
+const maskInfo = `
+Add mask to the input field
+<p class="mt-2">Supported patterns</p>
+<table class="tokens">
+	<tbody>
+		<tr>
+			<td>0</td>
+			<td>Any digit</td>
+		</tr>
+		<tr>
+			<td>a</td>
+			<td>Any letter</td>
+		</tr>
+		<tr>
+			<td>*</td>
+			<td>Any char</td>
+		</tr>
+		<tr>
+			<td>[]</td>
+			<td>Make input optional</td>
+		</tr>
+		<tr>
+			<td>{}</td>
+			<td>Include fixed part in unmasked value</td>
+		</tr>
+		<tr>
+			<td>\`</td>
+			<td>Prevent symbols shift back</td>
+		</tr>
+    <tr>
+      <td>!</td>
+      <td>Escape char</td>
+    </tr>
+	<tbody>
+</table>
+`;
+
+const cloneData = cloneDeep(props.modelValue || {});
+const options = reactive({
+  ...(props.defaultValue || {}),
+  ...cloneData,
+});
+
+function addMask() {
+  options.masks.push({
+    isRegex: false,
+    mask: '',
+    lazy: false,
+  });
+}
+function addToken() {
+  options.customTokens.push({
+    symbol: '',
+    regex: '',
+  });
+}
+
+watch(
+  options,
+  () => {
+    emit('update:modelValue', options);
+  },
+  { deep: true }
+);
+
+onMounted(() => {
+  if (options.masks.length === 0) {
+    addMask();
+  }
+});
+</script>

+ 46 - 0
src/components/newtab/workflow/edit/Parameter/ParameterInputValue.vue

@@ -0,0 +1,46 @@
+<template>
+  <ui-input
+    :model-value="modelValue"
+    :mask="mask"
+    type="text"
+    class="w-full"
+    :placeholder="paramData.placeholder"
+    @change="$emit('update:modelValue', $event)"
+  />
+</template>
+<script setup>
+import { computed } from 'vue';
+
+const props = defineProps({
+  modelValue: {
+    type: String,
+    default: '',
+  },
+  paramData: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+defineEmits(['update:modelValue']);
+
+const mask = computed(() => {
+  const options = props.paramData.data;
+  if (!options || !options.useMask) return null;
+
+  const masks = options.masks.map((item) => {
+    const cloneMask = { ...item };
+    if (cloneMask.isRegex) cloneMask.mask = new RegExp(cloneMask.mask);
+    else cloneMask.mask = cloneMask.mask.replaceAll('!', '\\');
+
+    delete cloneMask.isRegex;
+
+    return cloneMask;
+  });
+
+  if (masks.length === 1) return masks[0];
+
+  return {
+    mask: masks,
+  };
+});
+</script>

+ 7 - 10
src/components/newtab/workflow/editor/EditorLocalSavedBlocks.vue

@@ -35,12 +35,16 @@
           "
         >
           <div class="flex items-start p-4 flex-1">
-            <div class="w-8 flex-shrink-0">
+            <div
+              v-if="item.icon"
+              :class="{ 'mr-2': item.icon.startsWith('http') }"
+              class="w-8 flex-shrink-0"
+            >
               <img
                 v-if="item.icon.startsWith('http')"
                 :src="item.icon"
-                width="38px"
-                height="38px"
+                width="38"
+                height="38"
                 class="rounded-lg"
               />
               <v-remixicon
@@ -74,13 +78,6 @@
             >
               <v-remixicon name="riExternalLinkLine" size="18" />
             </a>
-            <v-remixicon
-              v-else
-              name="riPencilLine"
-              size="18"
-              class="cursor-pointer"
-              @click="$router.push(`/packages/${item.id}`)"
-            />
             <v-remixicon
               name="riDeleteBin7Line"
               size="18"

+ 2 - 2
src/components/newtab/workflow/editor/EditorPkgActions.vue

@@ -9,7 +9,7 @@
         <ui-button
           :class="{ 'text-primary': isPkgShared }"
           icon
-          type="transparent"
+          btn-type="transparent"
         >
           <v-remixicon name="riShareLine" />
         </ui-button>
@@ -47,7 +47,7 @@
   <ui-card class="pointer-events-auto flex items-center" padding="p-1">
     <ui-popover>
       <template #trigger>
-        <ui-button icon type="transparent">
+        <ui-button icon btn-type="transparent">
           <v-remixicon name="riMore2Line" />
         </ui-button>
       </template>

+ 4 - 2
src/components/newtab/workflow/editor/EditorUsedCredentials.vue

@@ -53,7 +53,7 @@
 import { ref, onMounted } from 'vue';
 import { useI18n } from 'vue-i18n';
 import objectPath from 'object-path';
-import { tasks } from '@/utils/shared';
+import { getBlocks } from '@/utils/getSharedData';
 
 const props = defineProps({
   editor: {
@@ -62,6 +62,8 @@ const props = defineProps({
   },
 });
 
+const blocks = getBlocks();
+
 const { t } = useI18n();
 
 const credentials = ref([]);
@@ -71,7 +73,7 @@ function checkCredentials() {
   const tempCreds = [];
 
   props.editor.getNodes.value.forEach(({ label, id, data }) => {
-    const keys = tasks[label]?.refDataKeys;
+    const keys = blocks[label]?.refDataKeys;
     if (!keys || !data) return;
 
     const usedCredentials = new Set();

+ 2 - 2
src/components/ui/UiButton.vue

@@ -4,7 +4,7 @@
     role="button"
     class="ui-button h-10 relative transition"
     :class="[
-      color ? color : variants[type][variant],
+      color ? color : variants[btnType][variant],
       icon ? 'p-2' : 'py-2 px-4',
       circle ? 'rounded-full' : 'rounded-lg',
       {
@@ -49,7 +49,7 @@ export default {
       type: String,
       default: 'button',
     },
-    type: {
+    btnType: {
       type: String,
       default: 'fill',
     },

+ 95 - 83
src/components/ui/UiInput.vue

@@ -29,6 +29,7 @@
         }"
         :id="componentId"
         v-autofocus="autofocus"
+        v-imask="mask"
         :class="[
           inputClass,
           {
@@ -49,94 +50,105 @@
     </div>
   </div>
 </template>
-<script>
-import { useComponentId } from '@/composable/componentId';
+<script setup>
 /* eslint-disable vue/require-prop-types */
-export default {
-  props: {
-    modelModifiers: {
-      default: () => ({}),
-    },
-    disabled: {
-      type: Boolean,
-      default: false,
-    },
-    readonly: {
-      type: Boolean,
-      default: false,
-    },
-    autofocus: {
-      type: Boolean,
-      default: false,
-    },
-    modelValue: {
-      type: [String, Number],
-      default: '',
-    },
-    inputClass: {
-      type: String,
-      default: '',
-    },
-    prependIcon: {
-      type: String,
-      default: '',
-    },
-    label: {
-      type: String,
-      default: '',
-    },
-    list: {
-      type: String,
-      default: null,
-    },
-    type: {
-      type: String,
-      default: 'text',
-    },
-    placeholder: {
-      type: String,
-      default: '',
-    },
-    max: {
-      type: [String, Number],
-      default: null,
-    },
-    min: {
-      type: [String, Number],
-      default: null,
-    },
-    autocomplete: {
-      type: String,
-      default: null,
-    },
-    step: {
-      type: String,
-      default: null,
-    },
-  },
-  emits: ['update:modelValue', 'change', 'keydown', 'blur', 'keyup', 'focus'],
-  setup(props, { emit }) {
-    const componentId = useComponentId('ui-input');
+import { IMaskDirective as vImask } from 'vue-imask';
+import { useComponentId } from '@/composable/componentId';
+
+const props = defineProps({
+  modelModifiers: {
+    default: () => ({}),
+  },
+  disabled: {
+    type: Boolean,
+    default: false,
+  },
+  readonly: {
+    type: Boolean,
+    default: false,
+  },
+  autofocus: {
+    type: Boolean,
+    default: false,
+  },
+  modelValue: {
+    type: [String, Number, Object],
+    default: '',
+  },
+  inputClass: {
+    type: String,
+    default: '',
+  },
+  prependIcon: {
+    type: String,
+    default: '',
+  },
+  label: {
+    type: String,
+    default: '',
+  },
+  list: {
+    type: String,
+    default: null,
+  },
+  type: {
+    type: String,
+    default: 'text',
+  },
+  placeholder: {
+    type: String,
+    default: '',
+  },
+  max: {
+    type: [String, Number],
+    default: null,
+  },
+  min: {
+    type: [String, Number],
+    default: null,
+  },
+  autocomplete: {
+    type: String,
+    default: null,
+  },
+  step: {
+    type: String,
+    default: null,
+  },
+  mask: {
+    type: [Array, Object],
+    default: null,
+  },
+  unmaskValue: Boolean,
+});
+const emit = defineEmits([
+  'update:modelValue',
+  'change',
+  'keydown',
+  'blur',
+  'keyup',
+  'focus',
+]);
 
-    function emitValue(event) {
-      let { value } = event.target;
+const componentId = useComponentId('ui-input');
 
-      if (props.modelModifiers.lowercase) {
-        value = value.toLocaleLowerCase();
-      } else if (props.modelModifiers.number) {
-        value = +value;
-      }
+function emitValue(event) {
+  let { value } = event.target;
 
-      emit('update:modelValue', value);
-      emit('change', value);
-    }
+  if (props.mask && props.unmaskValue) {
+    const { maskRef } = event.target;
+    if (maskRef && maskRef.unmaskedValue) value = maskRef.unmaskedValue;
+  }
 
-    return {
-      emitValue,
-      componentId,
-    };
-  },
-};
+  if (props.modelModifiers.lowercase) {
+    value = value.toLocaleLowerCase();
+  } else if (props.modelModifiers.number) {
+    value = +value;
+  }
+
+  emit('update:modelValue', value);
+  emit('change', value);
+}
 </script>
 <style>
 .input-ui input[type='color'] {

+ 3 - 7
src/composable/editorBlock.js

@@ -1,13 +1,9 @@
 import { reactive, onMounted } from 'vue';
-import customBlocks from '@business/blocks';
-import { tasks, categories } from '@/utils/shared';
-
-const blocks = {
-  ...tasks,
-  ...customBlocks,
-};
+import { getBlocks } from '@/utils/getSharedData';
+import { categories } from '@/utils/shared';
 
 export function useEditorBlock(label) {
+  const blocks = getBlocks();
   const block = reactive({
     details: {},
     category: {},

+ 6 - 4
src/content/blocksHandler.js

@@ -10,7 +10,9 @@ const handlers = blocksHandler.keys().reduce((acc, key) => {
   return acc;
 }, {});
 
-export default {
-  ...customHandlers,
-  ...handlers,
-};
+export default function () {
+  return {
+    ...(customHandlers() || {}),
+    ...handlers,
+  };
+}

+ 35 - 16
src/content/blocksHandler/handlerTakeScreenshot.js

@@ -124,7 +124,7 @@ export default async function ({ tabId, options, data: { type, selector } }) {
 
   const canvas = document.createElement('canvas');
   const context = canvas.getContext('2d');
-  const maxCanvasSize = 32767;
+  const maxCanvasSize = BROWSER_TYPE === 'firefox' ? 32767 : 65035;
 
   const scrollElement = document.querySelector('.automa-scrollable-el');
   let scrollableElement = scrollElement || findScrollableElement();
@@ -139,7 +139,7 @@ export default async function ({ tabId, options, data: { type, selector } }) {
 
   const style = injectStyle();
   const originalYPosition = window.scrollY;
-  const originalScrollHeight = scrollableElement.scrollHeight;
+  let originalScrollHeight = scrollableElement.scrollHeight;
 
   canvas.height =
     scrollableElement.scrollHeight > maxCanvasSize
@@ -156,7 +156,9 @@ export default async function ({ tabId, options, data: { type, selector } }) {
       else if (position === 'fixed') el.setAttribute('is-fixed', '');
     });
 
+  let scaleDiff = 1;
   let scrollPosition = 0;
+  let canvasAdjusted = false;
 
   if (scrollableElement.tagName === 'HTML') scrollableElement = window;
 
@@ -170,22 +172,39 @@ export default async function ({ tabId, options, data: { type, selector } }) {
     const image = await loadAsyncImg(imageUrl);
     const newScrollPos = scrollPosition + window.innerHeight;
 
-    if (newScrollPos - originalScrollHeight > 0) {
-      context.drawImage(
-        image,
-        0,
-        newScrollPos - originalScrollHeight,
-        image.width,
-        image.height,
-        0,
-        scrollPosition,
-        image.width,
-        image.height
-      );
-    } else {
-      context.drawImage(image, 0, scrollPosition);
+    if (!canvasAdjusted) {
+      if (canvas.width !== image.width) {
+        scaleDiff = image.width / window.innerWidth;
+
+        canvas.width *= scaleDiff;
+        canvas.height *= scaleDiff;
+
+        originalScrollHeight *= scaleDiff;
+
+        if (canvas.height > maxCanvasSize) canvas.height = maxCanvasSize;
+      }
+
+      canvasAdjusted = true;
     }
 
+    const newWidth = image.width * scaleDiff;
+    const newHeight = image.height * scaleDiff;
+
+    const sourceYPos =
+      (scrollPosition + window.innerHeight) * scaleDiff - originalScrollHeight;
+
+    context.drawImage(
+      image,
+      0,
+      sourceYPos > 0 ? sourceYPos : 0,
+      newWidth,
+      newHeight,
+      0,
+      scrollPosition * scaleDiff,
+      newWidth,
+      newHeight
+    );
+
     scrollPosition = newScrollPos;
     scrollableElement.scrollTo(0, newScrollPos);
 

+ 6 - 2
src/content/index.js

@@ -2,6 +2,7 @@ import browser from 'webextension-polyfill';
 import findSelector from '@/lib/findSelector';
 import { toCamelCase } from '@/utils/helper';
 import { nanoid } from 'nanoid';
+import automa from '@business';
 import handleSelector from './handleSelector';
 import blocksHandler from './blocksHandler';
 import showExecutedBlock from './showExecutedBlock';
@@ -67,7 +68,8 @@ async function executeBlock(data) {
       return result;
     }
   }
-  const handler = blocksHandler[toCamelCase(data.name || data.label)];
+  const handlers = blocksHandler();
+  const handler = handlers[toCamelCase(data.name || data.label)];
   if (handler) {
     const result = await handler(data, { handleSelector });
     removeExecutedBlock();
@@ -149,6 +151,8 @@ function messageListener({ data, source }) {
     // window.addEventListener('load', elementObserver);
   }
 
+  automa('content');
+
   browser.runtime.onMessage.addListener((data) => {
     return new Promise((resolve, reject) => {
       if (data.isBlock) {
@@ -169,7 +173,7 @@ function messageListener({ data, source }) {
                 data: blockData,
               };
 
-              blocksHandler
+              blocksHandler()
                 .loopData(loopBlock)
                 .then(() => {
                   executeBlock(data).then(resolve).catch(reject);

+ 4 - 0
src/lib/vRemixicon.js

@@ -21,6 +21,7 @@ import {
   riSortDesc,
   riTimeLine,
   riFlagLine,
+  riFileLine,
   riTeamLine,
   riLinksLine,
   riGroupLine,
@@ -51,6 +52,7 @@ import {
   riEyeOffLine,
   riWindowLine,
   riPencilLine,
+  riBrush2Line,
   riGlobalLine,
   riShieldLine,
   riCursorLine,
@@ -152,6 +154,7 @@ export const icons = {
   riSortDesc,
   riTimeLine,
   riFlagLine,
+  riFileLine,
   riTeamLine,
   riLinksLine,
   riGroupLine,
@@ -182,6 +185,7 @@ export const icons = {
   riEyeOffLine,
   riWindowLine,
   riPencilLine,
+  riBrush2Line,
   riGlobalLine,
   riShieldLine,
   riCursorLine,

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

@@ -109,12 +109,22 @@
           "getAll": "Get all cookies"
         }
       },
+      "note": {
+        "name": "Note"
+      },
       "slice-variable": {
         "name": "Slice variable",
         "description": "Extracts a section of a variable value",
         "start": "Start index",
         "end": "End index"
       },
+      "workflow-state": {
+        "name": "Workflow state",
+        "description": "Manage workflows states",
+        "actions": {
+          "stop": "Stop workflows"
+        }
+      },
       "regex-variable": {
         "name": "RegEx variable",
         "description": "Matching a variable value against a regular expression"
@@ -655,6 +665,7 @@
         "refKey": "Reference key",
         "startIndex": "Start from index",
         "resumeLastWorkflow": "Resume last workflow",
+        "reverse": "Reverse loop order",
         "modal": {
           "fileTooLarge": "File too large to edit",
           "maxFile": "Max file size is 1MB",

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

@@ -52,6 +52,9 @@
             "continue": "继续流程",
             "fallback": "执行回退",
             "restart": "重启流程"
+          },
+          "insertData": {
+            "name": "插入数据"
           }
         },
         "table": {
@@ -96,6 +99,16 @@
         "specificFlow": "只继续一个特定的流程",
         "selectFlow": "选择流程"
       },
+      "cookie": {
+        "name": "Cookie",
+        "description": "获取, 设置, 或移除 cookies",
+        "types": {
+          "get": "获取 cookies",
+          "set": "设置 cookie",
+          "remove": "移除 cookies",
+          "getAll": "获取所有 cookies"
+        }
+      },
       "slice-variable": {
         "name": "切片变量",
         "description": "提取变量值的一部分",
@@ -213,6 +226,22 @@
         "name": "悬停元素",
         "description": "悬停在一个元素上"
       },
+      "create-element": {
+        "name": "创建元素",
+        "description": "创建一个元素并插入到页面",
+        "edit": "编辑元素",
+        "wrap": "将元素包裹在里面",
+        "insertEl": {
+          "title": "插入元素",
+          "items": {
+            "before": "为首个子元素",
+            "after": "为最末子元素",
+            "next-sibling": "为下一个同级元素",
+            "prev-sibling": "为上一个同级元素",
+            "replace": "替换目标元素"
+          }
+        }
+      },
       "upload-file": {
         "name": "上传文件",
         "description": "上传文件到 <input type=\"file\"> 元素",
@@ -327,7 +356,10 @@
         "overwriteNote": "这将覆盖所选工作流的全局数据",
         "select": "选择工作流",
         "executeId": "执行 Id",
-        "description": ""
+        "description": "",
+        "insertAllVars": "插入所有当前工作流变量",
+        "insertVars": "插入当前工作流变量",
+        "useCommas": "使用逗号分隔变量名"
       },
       "google-sheets": {
         "name": "Google sheets",
@@ -683,4 +715,4 @@
       }
     }
   }
-}
+}

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

@@ -25,6 +25,8 @@
     "save": "保存",
     "data": "数据",
     "stop": "停止",
+    "packages": "包",
+    "storage": "存储",
     "editor": "编辑器",
     "running": "运行",
     "globalData": "全局数据",

+ 38 - 3
src/locales/zh/newtab.json

@@ -8,6 +8,21 @@
     "text": "从阅读文档或浏览 Automa 市场中的工作流开始.",
     "marketplace": "市场"
   },
+  "packages": {
+    "name": "包 | 包",
+    "add": "添加包",
+    "icon": "包图标",
+    "open": "打开包",
+    "new": "新建包",
+    "set": "设置为包",
+    "settings": {
+      "asBlock": "把包设为模块"
+    },
+    "categories": {
+      "my": "我的包",
+      "installed": "已安装的包",
+    }
+  },
   "scheduledWorkflow": {
     "title": "计划的工作流",
     "nextRun": "下次运行",
@@ -32,6 +47,14 @@
       "delete": "删除表格"
     }
   },
+  "credential": {
+    "title": "凭据 | 凭据",
+    "add": "添加凭据",
+    "use": {
+      "title": "已用凭据",
+      "description": "此工作流使用了这些凭据"
+    }
+  },
   "workflowPermissions": {
     "title": "工作流权限",
     "description": "此工作流需要这些权限才能正常运行",
@@ -49,7 +72,11 @@
     },
     "downloads": {
       "title": "下载",
-      "description": "保存并重命名下载的文件"
+      "description": "保存页面资产并重命名下载的文件"
+    },
+    "cookies": {
+      "title": "Cookies",
+      "description": "读取,设置,或移除 cookies"
     }
   },
   "updateMessage": {
@@ -154,6 +181,7 @@
     }
   },
   "workflow": {
+    "my": "我的工作流",
     "import": "导入工作流",
     "new": "新建工作流",
     "delete": "删除工作流",
@@ -170,6 +198,11 @@
     "autoAlign": {
       "title": "自动对齐"
     },
+    "blocksFolder": {
+      "title": "模块文件夹",
+      "add": "添加模块到文件夹",
+      "save": "保存到文件夹"
+    },
     "searchBlocks": {
       "title": "在编辑器中搜索模块"
     },
@@ -255,7 +288,9 @@
       "resetZoom": "重置缩放",
       "duplicate": "副本",
       "copy": "复制",
-      "paste": "粘贴"
+      "paste": "粘贴",
+      "group": "分组模块",
+      "ungroup": "未分组模块"
     },
     "settings": {
       "saveLog": "保存工作流日志",
@@ -426,4 +461,4 @@
       "of": "共 {page} 页"
     }
   }
-}
+}

+ 5 - 2
src/newtab/App.vue

@@ -52,8 +52,6 @@
   <app-survey />
 </template>
 <script setup>
-import iconFirefox from '@/assets/svg/logoFirefox.svg';
-import iconChrome from '@/assets/svg/logo.svg';
 import { ref } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useRouter } from 'vue-router';
@@ -71,11 +69,14 @@ import { useHostedWorkflowStore } from '@/stores/hostedWorkflow';
 import { useSharedWorkflowStore } from '@/stores/sharedWorkflow';
 import { loadLocaleMessages, setI18nLanguage } from '@/lib/vueI18n';
 import { getUserWorkflows } from '@/utils/api';
+import automa from '@business';
 import dbLogs from '@/db/logs';
 import dayjs from '@/lib/dayjs';
 import AppSurvey from '@/components/newtab/app/AppSurvey.vue';
 import AppSidebar from '@/components/newtab/app/AppSidebar.vue';
 import dataMigration from '@/utils/dataMigration';
+import iconFirefox from '@/assets/svg/logoFirefox.svg';
+import iconChrome from '@/assets/svg/logo.svg';
 
 let icon;
 if (window.location.protocol === 'moz-extension:') {
@@ -229,6 +230,8 @@ browser.runtime.onMessage.addListener(({ type, data }) => {
     await dataMigration();
     await userStore.loadUser({ useCache: false, ttl: 2 });
 
+    await automa('app');
+
     retrieved.value = true;
 
     await Promise.allSettled([

+ 0 - 1
src/newtab/pages/Packages.vue

@@ -85,7 +85,6 @@
                     tag="a"
                     target="_blank"
                     class="cursor-pointer"
-                    @click="deletePackage(pkg)"
                   >
                     <v-remixicon name="riExternalLinkLine" class="mr-2 -ml-1" />
                     <span>Open package page</span>

+ 1 - 0
src/newtab/pages/Workflows.vue

@@ -310,6 +310,7 @@ function updateActiveTab(data = {}) {
 function addWorkflow() {
   workflowStore.insert({
     name: addWorkflowModal.name,
+    folderId: state.activeFolder,
     description: addWorkflowModal.description,
   });
   clearAddWorkflowModal();

+ 91 - 5
src/newtab/pages/workflows/[id].vue

@@ -297,13 +297,13 @@ import {
 } from '@/composable/shortcut';
 import { getWorkflowPermissions } from '@/utils/workflowData';
 import { fetchApi } from '@/utils/api';
-import { tasks, excludeGroupBlocks } from '@/utils/shared';
+import { getBlocks } from '@/utils/getSharedData';
+import { excludeGroupBlocks } from '@/utils/shared';
 import { functions } from '@/utils/referenceData/mustacheReplacer';
 import { useGroupTooltip } from '@/composable/groupTooltip';
 import { useCommandManager } from '@/composable/commandManager';
 import { debounce, parseJSON, throttle } from '@/utils/helper';
 import { registerWorkflowTrigger } from '@/utils/workflowTrigger';
-import customBlocks from '@business/blocks';
 import browser from 'webextension-polyfill';
 import dbStorage from '@/db/storage';
 import DroppedNode from '@/utils/editor/DroppedNode';
@@ -328,7 +328,7 @@ import EditorLocalSavedBlocks from '@/components/newtab/workflow/editor/EditorLo
 import PackageDetails from '@/components/newtab/package/PackageDetails.vue';
 import PackageSettings from '@/components/newtab/package/PackageSettings.vue';
 
-const blocks = { ...tasks, ...customBlocks };
+const blocks = getBlocks();
 
 let editorCommands = null;
 const executeCommandTimeout = null;
@@ -655,6 +655,53 @@ const onEdgesChange = debounce((changes) => {
   // if (command) commandManager.add(command);
 }, 250);
 
+let nodeTargetHandle = null;
+
+function onClickEditor({ target }) {
+  const targetClass = target.classList;
+  const isHandle = targetClass.contains('vue-flow__handle');
+  const clearActiveTarget = () => {
+    if (nodeTargetHandle) {
+      const targetEl = document.querySelector(
+        `.vue-flow__handle[data-handleid="${nodeTargetHandle.handleId}"]`
+      );
+      if (targetEl) targetEl.classList.remove('ring-2');
+    }
+  };
+
+  if (!isHandle) {
+    clearActiveTarget();
+    nodeTargetHandle = null;
+    return;
+  }
+
+  if (nodeTargetHandle && targetClass.contains('target')) {
+    const { handleid, nodeid } = target.dataset;
+
+    const connectionExist = document.querySelector(
+      `.vue-flow__edge.target-${handleid}.source-${nodeTargetHandle.handleId}`
+    );
+    if (!connectionExist) {
+      editor.value.addEdges([
+        {
+          source: nodeid,
+          sourceHandle: handleid,
+          target: nodeTargetHandle.nodeId,
+          targetHandle: nodeTargetHandle.handleId,
+        },
+      ]);
+    }
+
+    clearActiveTarget();
+    nodeTargetHandle = null;
+  } else {
+    clearActiveTarget();
+    target.classList.add('ring-2');
+
+    const { handleid, nodeid } = target.dataset;
+    nodeTargetHandle = { nodeId: nodeid, handleId: handleid };
+  }
+}
 function goToBlock(blockId) {
   if (!editor.value) return;
 
@@ -910,7 +957,7 @@ function onNodesChange(changes) {
       nodeChanges.removed.push(id);
     } else if (type === 'add') {
       if (isPackage) {
-        const excludeBlocks = ['block-package', 'trigger'];
+        const excludeBlocks = ['block-package', 'trigger', 'execute-workflow'];
         if (excludeBlocks.includes(item.label)) {
           editor.value.removeNodes([item]);
         }
@@ -1015,7 +1062,7 @@ function initEditBlock(data) {
         const blockNameKey = `workflow.blocks.${sourceNode.label}.name`;
         let blockName = te(blockNameKey)
           ? t(blockNameKey)
-          : tasks[sourceNode.label].name;
+          : blocks[sourceNode.label].name;
 
         const { description, name: groupName } = sourceNode.data;
         if (description || groupName)
@@ -1077,11 +1124,40 @@ function onActionUpdated({ data, changedIndicator }) {
 function onEditorInit(instance) {
   editor.value = instance;
 
+  let nodeToConnect = null;
+
   instance.onEdgesChange(onEdgesChange);
   instance.onNodesChange(onNodesChange);
   instance.onEdgeDoubleClick(({ edge }) => {
     instance.removeEdges([edge]);
   });
+  instance.onConnectStart(({ nodeId, handleId, handleType }) => {
+    if (handleType !== 'source') return;
+
+    nodeToConnect = { nodeId, handleId };
+  });
+  instance.onConnectEnd(({ target }) => {
+    if (!nodeToConnect) return;
+
+    const isNotTargetHandle = !target.closest('.vue-flow__handle.target');
+    const targetNode = isNotTargetHandle && target.closest('.vue-flow__node');
+    if (targetNode && targetNode.dataset.id !== nodeToConnect.nodeId) {
+      const nodeId = targetNode.dataset.id;
+      const nodeData = editor.value.getNode.value(nodeId);
+      if (nodeData && nodeData.handleBounds.target.length >= 1) {
+        editor.value.addEdges([
+          {
+            target: nodeId,
+            source: nodeToConnect.nodeId,
+            sourceHandle: nodeToConnect.handleId,
+            targetHandle: nodeData.handleBounds.target[0].id,
+          },
+        ]);
+      }
+    }
+
+    nodeToConnect = null;
+  });
   // instance.onEdgeUpdateEnd(({ edge }) => {
   //   editorCommands.state.edges[edge.id] = edge;
   // });
@@ -1099,6 +1175,11 @@ function onEditorInit(instance) {
     instance.getSelectedEdges.value.map(({ id }) => id)
   );
 
+  const editorContainer = document.querySelector(
+    '.vue-flow__viewport.vue-flow__container'
+  );
+  editorContainer.addEventListener('click', onClickEditor);
+
   const convertToObj = (array) =>
     array.reduce((acc, item) => {
       acc[item.id] = item;
@@ -1514,6 +1595,11 @@ onMounted(() => {
   window.addEventListener('keydown', onKeydown);
 });
 onBeforeUnmount(() => {
+  const editorContainer = document.querySelector(
+    '.vue-flow__viewport.vue-flow__container'
+  );
+  editorContainer.removeEventListener('click', onClickEditor);
+
   window.onbeforeunload = null;
   window.removeEventListener('keydown', onKeydown);
 });

+ 41 - 11
src/params/App.vue

@@ -1,5 +1,8 @@
 <template>
-  <div class="w-full flex flex-col h-full max-w-lg mx-auto dark:text-gray-100">
+  <div
+    v-if="retrieved"
+    class="w-full flex flex-col h-full max-w-lg mx-auto dark:text-gray-100"
+  >
     <nav class="flex items-center w-full p-4 border-b mb-4">
       <span class="p-1 rounded-full bg-box-transparent dark:bg-none">
         <img src="@/assets/svg/logo.svg" class="w-10" />
@@ -41,11 +44,11 @@
           </div>
         </template>
         <div class="px-4 pb-4">
-          <ul class="space-y-2">
+          <ul class="space-y-4 divide-y">
             <li v-for="(param, paramIdx) in workflow.params" :key="paramIdx">
               <component
-                :is="workflowParameters[param.type].valueComp"
-                v-if="workflowParameters[param.type]"
+                :is="paramsList[param.type].valueComp"
+                v-if="paramsList[param.type]"
                 v-model="param.value"
                 :label="param.name"
                 :param-data="param"
@@ -60,6 +63,13 @@
                 class="w-full"
                 @keyup.enter="runWorkflow(index, workflow)"
               />
+              <p
+                v-if="param.description"
+                title="Description"
+                class="ml-1 text-sm"
+              >
+                {{ param.description }}
+              </p>
             </li>
           </ul>
           <div class="flex items-center mt-6">
@@ -81,13 +91,24 @@
 <script setup>
 import { onMounted, ref, computed } from 'vue';
 import browser from 'webextension-polyfill';
-import * as workflowParameters from '@business/parameters';
-import dayjs from '@/lib/dayjs';
+import workflowParameters from '@business/parameters';
+import automa from '@business';
 import { useTheme } from '@/composable/theme';
+import dayjs from '@/lib/dayjs';
+import ParameterInputValue from '@/components/newtab/workflow/edit/Parameter/ParameterInputValue.vue';
+
+const paramsList = {
+  string: {
+    id: 'string',
+    name: 'Input (string)',
+    valueComp: ParameterInputValue,
+  },
+};
 
 const theme = useTheme();
 theme.init();
 
+const retrieved = ref(false);
 const workflows = ref([]);
 
 const sortedWorkflows = computed(() =>
@@ -166,7 +187,7 @@ function runWorkflow(index, { data, params }) {
   const variables = params.reduce((acc, param) => {
     const valueFunc =
       getParamVal[param.type] ||
-      workflowParameters[param.type]?.getValue ||
+      paramsList[param.type]?.getValue ||
       getParamVal.default;
     const value = valueFunc(param.value || param.defaultValue);
     acc[param.name] = value;
@@ -198,10 +219,19 @@ browser.runtime.onMessage.addListener(({ name, data }) => {
   addWorkflow(data);
 });
 
-onMounted(() => {
-  const query = new URLSearchParams(window.location.search);
-  const workflowId = query.get('workflowId');
+onMounted(async () => {
+  try {
+    const query = new URLSearchParams(window.location.search);
+    const workflowId = query.get('workflowId');
 
-  if (workflowId) addWorkflow(workflowId);
+    if (workflowId) addWorkflow(workflowId);
+    await automa('content');
+
+    Object.assign(paramsList, workflowParameters());
+  } catch (error) {
+    // Do nothing
+  } finally {
+    retrieved.value = true;
+  }
 });
 </script>

+ 3 - 0
src/popup/pages/Home.vue

@@ -127,6 +127,7 @@ import { useWorkflowStore } from '@/stores/workflow';
 import { useGroupTooltip } from '@/composable/groupTooltip';
 import { useTeamWorkflowStore } from '@/stores/teamWorkflow';
 import { useHostedWorkflowStore } from '@/stores/hostedWorkflow';
+import automa from '@business';
 import HomeWorkflowCard from '@/components/popup/home/HomeWorkflowCard.vue';
 import HomeTeamWorkflows from '@/components/popup/home/HomeTeamWorkflows.vue';
 import HomeStartRecording from '@/components/popup/home/HomeStartRecording.vue';
@@ -301,6 +302,8 @@ onMounted(async () => {
 
   let activeTab = localStorage.getItem('popup-tab') || 'local';
 
+  await automa('app');
+
   if (activeTab === 'team' && !userStore.user?.teams) activeTab = 'local';
   else if (activeTab === 'host' && hostedWorkflowStore.toArray.length < 0)
     activeTab = 'local';

+ 4 - 2
src/utils/editor/DroppedNode.js

@@ -1,5 +1,6 @@
 import { customAlphabet } from 'nanoid';
-import { tasks, excludeOnError } from '../shared';
+import { excludeOnError } from '../shared';
+import { getBlocks } from '../getSharedData';
 
 const nanoid = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz', 7);
 
@@ -25,7 +26,8 @@ class DroppedNode {
 
     let blockData = block;
     if (block.fromBlockBasic) {
-      blockData = { ...tasks[block.id], id: block.id };
+      const blocks = getBlocks();
+      blockData = { ...blocks[block.id], id: block.id };
     }
 
     const onErrorEnabled =

+ 22 - 10
src/utils/getFile.js

@@ -1,11 +1,19 @@
-async function downloadFile(url) {
+async function downloadFile(url, options) {
   const response = await fetch(url);
-  const blob = await response.blob();
-  const objUrl = URL.createObjectURL(blob);
+  if (!response.ok) throw new Error(response.statusText);
 
-  return { objUrl, path: url, type: blob.type };
+  const type = options.responseType || 'blob';
+  const result = await response[type]();
+
+  if (options.returnValue) {
+    return result;
+  }
+
+  const objUrl = URL.createObjectURL(result);
+
+  return { objUrl, path: url, type: result.type };
 }
-function getLocalFile(path) {
+function getLocalFile(path, options) {
   return new Promise((resolve, reject) => {
     const isFile = /\.(.*)/.test(path);
 
@@ -17,12 +25,16 @@ function getLocalFile(path) {
     const fileUrl = path?.startsWith('file://') ? path : `file://${path}`;
 
     const xhr = new XMLHttpRequest();
-    xhr.responseType = 'blob';
+    xhr.responseType = options.responseType || 'blob';
     xhr.onreadystatechange = () => {
       if (xhr.readyState === XMLHttpRequest.DONE) {
         if (xhr.status === 0 || xhr.status === 200) {
-          const objUrl = URL.createObjectURL(xhr.response);
+          if (options.returnValue) {
+            resolve(xhr.response);
+            return;
+          }
 
+          const objUrl = URL.createObjectURL(xhr.response);
           resolve({ path, objUrl, type: xhr.response.type });
         } else {
           reject(new Error(xhr.statusText));
@@ -39,8 +51,8 @@ function getLocalFile(path) {
   });
 }
 
-export default function (path) {
-  if (path.startsWith('http')) return downloadFile(path);
+export default function (path, options = {}) {
+  if (path.startsWith('http')) return downloadFile(path, options);
 
-  return getLocalFile(path);
+  return getLocalFile(path, options);
 }

+ 6 - 0
src/utils/getSharedData.js

@@ -0,0 +1,6 @@
+import customBlocks from '@business/blocks';
+import { tasks } from './shared';
+
+export function getBlocks() {
+  return { ...tasks, ...customBlocks() };
+}

+ 21 - 3
src/utils/shared.js

@@ -1,5 +1,3 @@
-import customBlocks from '@business/blocks';
-
 export const tasks = {
   trigger: {
     name: 'Trigger',
@@ -715,6 +713,7 @@ export const tasks = {
       description: '',
       variableName: '',
       referenceKey: '',
+      reverseLoop: false,
       elementSelector: '',
       waitForSelector: false,
       waitSelectorTimeout: 5000,
@@ -1297,7 +1296,25 @@ export const tasks = {
       fontSize: 'regular',
     },
   },
-  ...customBlocks,
+  'workflow-state': {
+    name: 'Workflow State',
+    description: 'Manage workflows states',
+    icon: 'riSettings3Line',
+    component: 'BlockBasic',
+    editComponent: 'EditWorkflowState',
+    category: 'general',
+    inputs: 1,
+    outputs: 1,
+    allowedInputs: true,
+    maxConnection: 1,
+    data: {
+      disableBlock: false,
+      description: '',
+      type: 'stop-current',
+      exceptCurrent: false,
+      workflowsToStop: [],
+    },
+  },
 };
 
 export const categories = {
@@ -1452,6 +1469,7 @@ export const excludeGroupBlocks = [
   'webhook',
   'element-exists',
   'while-loop',
+  'block-package',
 ];
 
 export const conditionBuilder = {

+ 2 - 1
src/utils/testConditions.js

@@ -26,7 +26,8 @@ const comparisons = {
   enw: (a, b) => a?.endsWith(b) ?? false,
   rgx: (a, b) => {
     const match = b.match(/^\/(.*?)\/([gimy]*)$/);
-    const regex = new RegExp(match[1], match[2]);
+    const regex = match ? new RegExp(match[1], match[2]) : new RegExp(b);
+
     return regex.test(a);
   },
   itr: (a) => Boolean(isBoolStr(a)),

+ 62 - 30
yarn.lock

@@ -1044,14 +1044,14 @@
   resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70"
   integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==
 
-"@eslint/eslintrc@^1.3.0":
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.0.tgz#29f92c30bb3e771e4a2048c95fa6855392dfac4f"
-  integrity sha512-UWW0TMTmk2d7hLcWD1/e2g5HDM/HQ3csaLSqXCfqwh4uNDuNqlaKWXmEsL4Cs41Z0KnILNvwbHAah3C2yt06kw==
+"@eslint/eslintrc@^1.3.1":
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.1.tgz#de0807bfeffc37b964a7d0400e0c348ce5a2543d"
+  integrity sha512-OhSY22oQQdw3zgPOOwdoj01l/Dzl1Z+xyUP33tkSN+aqyEhymJCcPHyXt+ylW8FSe0TfRC2VG+ROQOapD0aZSQ==
   dependencies:
     ajv "^6.12.4"
     debug "^4.3.2"
-    espree "^9.3.2"
+    espree "^9.4.0"
     globals "^13.15.0"
     ignore "^5.2.0"
     import-fresh "^3.2.1"
@@ -1073,6 +1073,11 @@
   resolved "https://registry.yarnpkg.com/@humanwhocodes/gitignore-to-minimatch/-/gitignore-to-minimatch-1.0.2.tgz#316b0a63b91c10e53f242efb4ace5c3b34e8728d"
   integrity sha512-rSqmMJDdLFUsyxR6FMtD00nfQKKLFb1kv+qBbOVKqErvloEIJLo5bDTJTQNTYgeyp78JsA7u/NPi5jT1GR/MuA==
 
+"@humanwhocodes/module-importer@^1.0.1":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c"
+  integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==
+
 "@humanwhocodes/object-schema@^1.2.1":
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45"
@@ -3340,10 +3345,10 @@ eslint-plugin-prettier@^4.0.0:
   dependencies:
     prettier-linter-helpers "^1.0.0"
 
-eslint-plugin-vue@^9.3.0:
-  version "9.3.0"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-vue/-/eslint-plugin-vue-9.3.0.tgz#c3f5ce515dae387e062428725c5cf96098d9da0b"
-  integrity sha512-iscKKkBZgm6fGZwFt6poRoWC0Wy2dQOlwUPW++CiPoQiw1enctV2Hj5DBzzjJZfyqs+FAXhgzL4q0Ww03AgSmQ==
+eslint-plugin-vue@^9.4.0:
+  version "9.4.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-vue/-/eslint-plugin-vue-9.4.0.tgz#31c2d9002b5bb437b351a5feffdf37c4397e5cb9"
+  integrity sha512-Nzz2QIJ8FG+rtJaqT/7/ru5ie2XgT9KCudkbN0y3uFYhQ41nuHEaboLAiqwMcK006hZPQv/rVMRhUIwEGhIvfQ==
   dependencies:
     eslint-utils "^3.0.0"
     natural-compare "^1.4.0"
@@ -3398,14 +3403,15 @@ eslint-visitor-keys@^3.3.0:
   resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826"
   integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==
 
-eslint@^8.22.0:
-  version "8.22.0"
-  resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.22.0.tgz#78fcb044196dfa7eef30a9d65944f6f980402c48"
-  integrity sha512-ci4t0sz6vSRKdmkOGmprBo6fmI4PrphDFMy5JEq/fNS0gQkJM3rLmrqcp8ipMcdobH3KtUP40KniAE9W19S4wA==
+eslint@^8.23.0:
+  version "8.23.0"
+  resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.23.0.tgz#a184918d288820179c6041bb3ddcc99ce6eea040"
+  integrity sha512-pBG/XOn0MsJcKcTRLr27S5HpzQo4kLr+HjLQIyK4EiCsijDl/TB+h5uEuJU6bQ8Edvwz1XWOjpaP2qgnXGpTcA==
   dependencies:
-    "@eslint/eslintrc" "^1.3.0"
+    "@eslint/eslintrc" "^1.3.1"
     "@humanwhocodes/config-array" "^0.10.4"
     "@humanwhocodes/gitignore-to-minimatch" "^1.0.2"
+    "@humanwhocodes/module-importer" "^1.0.1"
     ajv "^6.10.0"
     chalk "^4.0.0"
     cross-spawn "^7.0.2"
@@ -3415,7 +3421,7 @@ eslint@^8.22.0:
     eslint-scope "^7.1.1"
     eslint-utils "^3.0.0"
     eslint-visitor-keys "^3.3.0"
-    espree "^9.3.3"
+    espree "^9.4.0"
     esquery "^1.4.0"
     esutils "^2.0.2"
     fast-deep-equal "^3.1.3"
@@ -3441,7 +3447,6 @@ eslint@^8.22.0:
     strip-ansi "^6.0.1"
     strip-json-comments "^3.1.0"
     text-table "^0.2.0"
-    v8-compile-cache "^2.0.3"
 
 espree@^6.0.0:
   version "6.2.1"
@@ -3452,7 +3457,7 @@ espree@^6.0.0:
     acorn-jsx "^5.2.0"
     eslint-visitor-keys "^1.1.0"
 
-espree@^9.3.1, espree@^9.3.2, espree@^9.3.3:
+espree@^9.3.1:
   version "9.3.3"
   resolved "https://registry.yarnpkg.com/espree/-/espree-9.3.3.tgz#2dd37c4162bb05f433ad3c1a52ddf8a49dc08e9d"
   integrity sha512-ORs1Rt/uQTqUKjDdGCyrtYxbazf5umATSf/K4qxjmZHORR6HJk+2s/2Pqe+Kk49HHINC/xNIrGfgh8sZcll0ng==
@@ -3461,6 +3466,15 @@ espree@^9.3.1, espree@^9.3.2, espree@^9.3.3:
     acorn-jsx "^5.3.2"
     eslint-visitor-keys "^3.3.0"
 
+espree@^9.4.0:
+  version "9.4.0"
+  resolved "https://registry.yarnpkg.com/espree/-/espree-9.4.0.tgz#cd4bc3d6e9336c433265fc0aa016fc1aaf182f8a"
+  integrity sha512-DQmnRpLj7f6TgN/NYb0MTzJXL+vJF9h3pHy4JhCIs3zwcgez8xmGg3sXHcEO97BrmO2OSvCwMdfdlyl+E9KjOw==
+  dependencies:
+    acorn "^8.8.0"
+    acorn-jsx "^5.3.2"
+    eslint-visitor-keys "^3.3.0"
+
 esprima@1.2.2:
   version "1.2.2"
   resolved "https://registry.yarnpkg.com/esprima/-/esprima-1.2.2.tgz#76a0fd66fcfe154fd292667dc264019750b1657b"
@@ -4143,6 +4157,11 @@ ignore@^5.2.0:
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a"
   integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==
 
+imask@^6.4.2:
+  version "6.4.2"
+  resolved "https://registry.yarnpkg.com/imask/-/imask-6.4.2.tgz#f1c67e6961640bf5b09d7b4a34dcf4f9a3df7e22"
+  integrity sha512-xvEgbTdk6y2dW2UAysq0NRPmO6PuaXM5NHIt4TXEJEwXUHj26M0p/fXqyrSJdNXFaGVOtqYjPRnNdrjQQhDuuA==
+
 import-fresh@^3.0.0, import-fresh@^3.2.1:
   version "3.3.0"
   resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
@@ -6295,10 +6314,10 @@ terser-webpack-plugin@^5.1.3:
     serialize-javascript "^6.0.0"
     terser "^5.7.2"
 
-terser-webpack-plugin@^5.3.5:
-  version "5.3.5"
-  resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.5.tgz#f7d82286031f915a4f8fb81af4bd35d2e3c011bc"
-  integrity sha512-AOEDLDxD2zylUGf/wxHxklEkOe2/r+seuyOWujejFrIxHf11brA1/dWQNIgXa1c6/Wkxgu7zvv0JhOWfc2ELEA==
+terser-webpack-plugin@^5.3.6:
+  version "5.3.6"
+  resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.6.tgz#5590aec31aa3c6f771ce1b1acca60639eab3195c"
+  integrity sha512-kfLFk+PoLUQIbLmB1+PZDMRSZS99Mp+/MHqDNmMA6tOItzRt+Npe3E+fsMs5mfcM0wCtrrdU387UnV+vnSffXQ==
   dependencies:
     "@jridgewell/trace-mapping" "^0.3.14"
     jest-worker "^27.4.5"
@@ -6504,11 +6523,6 @@ v-remixicon@^0.1.1:
   resolved "https://registry.yarnpkg.com/v-remixicon/-/v-remixicon-0.1.4.tgz#98b1de6bc71e17b5b6b64f59abd9330de3b953dc"
   integrity sha512-b3rurcVCoXJmcJXtTZrlTiom0iGKanenQVnr4q87Vbw7oaG6vUkfnNCkM6wiZRVt6vM3ebra2/ZBeKgBV14hsA==
 
-v8-compile-cache@^2.0.3:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"
-  integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==
-
 vary@~1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
@@ -6519,6 +6533,11 @@ vue-demi@*:
   resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.13.6.tgz#f9433cbd75e68a970dec066647f4ba6c08ced48f"
   integrity sha512-02NYpxgyGE2kKGegRPYlNQSL1UWfA/+JqvzhGCOYjhfbLWXU5QQX0+9pAm/R2sCOPKr5NBxVIab7fvFU0B1RxQ==
 
+vue-demi@^0.12.1:
+  version "0.12.5"
+  resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.12.5.tgz#8eeed566a7d86eb090209a11723f887d28aeb2d1"
+  integrity sha512-BREuTgTYlUr0zw0EZn3hnhC3I6gPWv+Kwh4MCih6QcAeaTlaIX0DwOVN0wHej7hSvDPecz4jygy/idsgKfW58Q==
+
 vue-eslint-parser@^9.0.1:
   version "9.0.3"
   resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-9.0.3.tgz#0c17a89e0932cc94fa6a79f0726697e13bfe3c96"
@@ -6542,6 +6561,14 @@ vue-i18n@^9.2.0-beta.40:
     "@intlify/vue-devtools" "9.2.2"
     "@vue/devtools-api" "^6.2.1"
 
+vue-imask@^6.4.2:
+  version "6.4.2"
+  resolved "https://registry.yarnpkg.com/vue-imask/-/vue-imask-6.4.2.tgz#1cffc9cfaab5673ad0ab68c1bd44cfa5e17f2043"
+  integrity sha512-WJgwZa+cENuOwZmI3FY6aiuoXs2/QSbtXjtRHCC9/bX7CTgeVBaBabh2tBX38QFEvQeUfmDnzKL6++SgQdYjQA==
+  dependencies:
+    imask "^6.4.2"
+    vue-demi "^0.12.1"
+
 vue-loader@^17.0.0:
   version "17.0.0"
   resolved "https://registry.yarnpkg.com/vue-loader/-/vue-loader-17.0.0.tgz#2eaa80aab125b19f00faa794b5bd867b17f85acb"
@@ -6551,6 +6578,11 @@ vue-loader@^17.0.0:
     hash-sum "^2.0.0"
     loader-utils "^2.0.0"
 
+vue-multiselect@^3.0.0-alpha.2:
+  version "3.0.0-alpha.2"
+  resolved "https://registry.yarnpkg.com/vue-multiselect/-/vue-multiselect-3.0.0-alpha.2.tgz#58186f781136e71f1272b98690b569a0c00ed161"
+  integrity sha512-Xp9fGJECns45v+v8jXbCIsAkCybYkEg0lNwr7Z6HDUSMyx2TEIK2giipPE+qXiShEc1Ipn+ZtttH2iq9hwXP4Q==
+
 vue-router@^4.1.5:
   version "4.1.5"
   resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.1.5.tgz#256f597e3f5a281a23352a6193aa6e342c8d9f9a"
@@ -6647,10 +6679,10 @@ webpack-dev-middleware@^5.3.1:
     range-parser "^1.2.1"
     schema-utils "^4.0.0"
 
-webpack-dev-server@^4.10.0:
-  version "4.10.0"
-  resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-4.10.0.tgz#de270d0009eba050546912be90116e7fd740a9ca"
-  integrity sha512-7dezwAs+k6yXVFZ+MaL8VnE+APobiO3zvpp3rBHe/HmWQ+avwh0Q3d0xxacOiBybZZ3syTZw9HXzpa3YNbAZDQ==
+webpack-dev-server@^4.11.0:
+  version "4.11.0"
+  resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-4.11.0.tgz#290ee594765cd8260adfe83b2d18115ea04484e7"
+  integrity sha512-L5S4Q2zT57SK7tazgzjMiSMBdsw+rGYIX27MgPgx7LDhWO0lViPrHKoLS7jo5In06PWYAhlYu3PbyoC6yAThbw==
   dependencies:
     "@types/bonjour" "^3.5.9"
     "@types/connect-history-api-fallback" "^1.3.5"