Ahmad Kholid 3 lat temu
rodzic
commit
68b2d32c2a
95 zmienionych plików z 2441 dodań i 787 usunięć
  1. 5 1
      .eslintrc.js
  2. 6 0
      .github/dependabot.yml
  3. 2 2
      package.json
  4. 4 0
      src/assets/css/tailwind.css
  5. 33 76
      src/background/WorkflowState.js
  6. 102 12
      src/background/index.js
  7. 0 57
      src/background/messageListener.js
  8. 5 2
      src/background/workflowEngine/blocksHandler/handlerConditions.js
  9. 4 1
      src/background/workflowEngine/blocksHandler/handlerJavascriptCode.js
  10. 2 2
      src/background/workflowEngine/blocksHandler/handlerLoopData.js
  11. 47 0
      src/background/workflowEngine/blocksHandler/handlerNotification.js
  12. 12 3
      src/background/workflowEngine/blocksHandler/handlerProxy.js
  13. 10 2
      src/background/workflowEngine/blocksHandler/handlerWebhook.js
  14. 17 25
      src/background/workflowEngine/engine.js
  15. 1 2
      src/background/workflowEngine/worker.js
  16. 2 1
      src/components/content/selector/SelectorQuery.vue
  17. 1 0
      src/components/content/shared/SharedElementSelector.vue
  18. 6 2
      src/components/newtab/settings/SettingsCloudBackup.vue
  19. 1 1
      src/components/newtab/shared/SharedConditionBuilder/ConditionBuilderInputs.vue
  20. 15 5
      src/components/newtab/shared/SharedConditionBuilder/index.vue
  21. 5 20
      src/components/newtab/shared/SharedWorkflowState.vue
  22. 25 26
      src/components/newtab/workflow/WorkflowBuilder.vue
  23. 160 0
      src/components/newtab/workflow/WorkflowBuilderSearchBlocks.vue
  24. 1 10
      src/components/newtab/workflow/WorkflowEditBlock.vue
  25. 2 0
      src/components/newtab/workflow/WorkflowSettings.vue
  26. 1 1
      src/components/newtab/workflow/WorkflowShare.vue
  27. 1 3
      src/components/newtab/workflow/edit/EditLoopData.vue
  28. 6 2
      src/components/newtab/workflow/edit/EditNewTab.vue
  29. 62 0
      src/components/newtab/workflow/edit/EditNotification.vue
  30. 37 36
      src/components/newtab/workflow/edit/EditProxy.vue
  31. 2 1
      src/components/newtab/workflow/edit/EditSwitchTo.vue
  32. 25 336
      src/components/newtab/workflow/edit/EditTrigger.vue
  33. 5 5
      src/components/newtab/workflow/edit/EditTriggerEvent.vue
  34. 0 1
      src/components/newtab/workflow/edit/EditWebhook.vue
  35. 4 1
      src/components/newtab/workflow/edit/OnBlockError.vue
  36. 102 0
      src/components/newtab/workflow/edit/Trigger/TriggerContextMenu.vue
  37. 47 0
      src/components/newtab/workflow/edit/Trigger/TriggerDate.vue
  38. 130 0
      src/components/newtab/workflow/edit/Trigger/TriggerElementChange.vue
  39. 76 0
      src/components/newtab/workflow/edit/Trigger/TriggerElementOptions.vue
  40. 59 0
      src/components/newtab/workflow/edit/Trigger/TriggerInterval.vue
  41. 92 0
      src/components/newtab/workflow/edit/Trigger/TriggerKeyboardShortcut.vue
  42. 185 0
      src/components/newtab/workflow/edit/Trigger/TriggerSpecificDay.vue
  43. 30 0
      src/components/newtab/workflow/edit/Trigger/TriggerVisitWeb.vue
  44. 0 0
      src/components/newtab/workflow/edit/TriggerEvent/TriggerEventInput.vue
  45. 0 0
      src/components/newtab/workflow/edit/TriggerEvent/TriggerEventKeyboard.vue
  46. 0 0
      src/components/newtab/workflow/edit/TriggerEvent/TriggerEventMouse.vue
  47. 0 0
      src/components/newtab/workflow/edit/TriggerEvent/TriggerEventTouch.vue
  48. 0 0
      src/components/newtab/workflow/edit/TriggerEvent/TriggerEventWheel.vue
  49. 42 1
      src/components/newtab/workflow/settings/SettingsGeneral.vue
  50. 167 0
      src/components/newtab/workflows/WorkflowsFolder.vue
  51. 31 6
      src/components/ui/UiAutocomplete.vue
  52. 9 1
      src/composable/hasPermissions.js
  53. 8 2
      src/composable/shortcut.js
  54. 1 1
      src/content/blocksHandler/handlerForms.js
  55. 4 2
      src/content/blocksHandler/handlerJavascriptCode.js
  56. 1 1
      src/content/blocksHandler/handlerPressKey.js
  57. 3 0
      src/content/blocksHandler/handlerSwitchTo.js
  58. 111 0
      src/content/elementObserver.js
  59. 54 0
      src/content/elementSelector/App.vue
  60. 1 26
      src/content/elementSelector/index.js
  61. 36 3
      src/content/elementSelector/selectorFrameContext.js
  62. 4 2
      src/content/handleSelector.js
  63. 4 2
      src/content/handleTestCondition.js
  64. 25 1
      src/content/index.js
  65. 0 1
      src/content/services/recordWorkflow/recordEvents.js
  66. 11 4
      src/content/services/shortcutListener.js
  67. 14 2
      src/content/utils.js
  68. 6 5
      src/lib/drawflow.js
  69. 2 0
      src/lib/vRemixicon.js
  70. 47 1
      src/locales/en/blocks.json
  71. 2 0
      src/locales/en/common.json
  72. 21 0
      src/locales/en/newtab.json
  73. 61 3
      src/locales/zh/blocks.json
  74. 2 0
      src/locales/zh/common.json
  75. 29 2
      src/locales/zh/newtab.json
  76. 5 1
      src/locales/zh/popup.json
  77. 4 1
      src/manifest.chrome.json
  78. 3 1
      src/manifest.firefox.json
  79. 30 0
      src/models/folder.js
  80. 1 0
      src/models/index.js
  81. 4 1
      src/models/workflow.js
  82. 23 11
      src/newtab/App.vue
  83. 119 9
      src/newtab/pages/Workflows.vue
  84. 4 2
      src/newtab/pages/workflows/Host.vue
  85. 7 10
      src/newtab/pages/workflows/[id].vue
  86. 13 17
      src/popup/pages/Home.vue
  87. 1 1
      src/store/index.js
  88. 6 1
      src/utils/api.js
  89. 1 1
      src/utils/getFile.js
  90. 1 2
      src/utils/handleFormElement.js
  91. 5 6
      src/utils/recordKeys.js
  92. 1 1
      src/utils/referenceData/mustacheReplacer.js
  93. 54 1
      src/utils/shared.js
  94. 63 0
      src/utils/workflowTrigger.js
  95. 68 19
      yarn.lock

+ 5 - 1
.eslintrc.js

@@ -28,14 +28,18 @@ module.exports = {
     },
   },
   // add your custom rules here
+  globals: {
+    BROWSER_TYPE: true,
+  },
   rules: {
-    'no-undef': 'off',
+    camelcase: 'off',
     'no-await-in-loop': 'off',
     'no-console': ['warn', { allow: ['warn', 'error'] }],
     'no-underscore-dangle': 'off',
     'func-names': 'off',
     'import/no-named-default': 'off',
     'no-restricted-syntax': 'off',
+    'vue/multi-word-component-names': 'off',
     'import/extensions': [
       'error',
       'always',

+ 6 - 0
.github/dependabot.yml

@@ -0,0 +1,6 @@
+version: 2
+updates:
+  - package-ecosystem: "npm"
+    directory: "/"
+    schedule:
+      interval: "daily"

+ 2 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "1.11.3",
+  "version": "1.12.0",
   "description": "An extension for automating your browser by connecting blocks",
   "license": "MIT",
   "repository": {
@@ -88,7 +88,7 @@
     "eslint-import-resolver-webpack": "^0.13.2",
     "eslint-plugin-import": "^2.24.2",
     "eslint-plugin-prettier": "^4.0.0",
-    "eslint-plugin-vue": "7.18.0",
+    "eslint-plugin-vue": "^9.1.0",
     "file-loader": "^6.2.0",
     "fs-extra": "10.0.0",
     "html-loader": "2.1.2",

+ 4 - 0
src/assets/css/tailwind.css

@@ -93,3 +93,7 @@ pre {
 .ProseMirror img.ProseMirror-selectednode {
   outline: 3px solid #68CEF8;
 }
+
+.input-label {
+  @apply ml-1 text-sm text-gray-600 dark:text-gray-200;
+}

+ 33 - 76
src/background/WorkflowState.js

@@ -5,27 +5,13 @@ class WorkflowState {
     this.key = key;
     this.storage = storage;
 
-    this.cache = null;
+    this.states = new Map();
     this.eventListeners = {};
   }
 
-  async _updater(callback, event) {
-    try {
-      const storageStates = await this.get();
-      const states = callback(storageStates);
-
-      await this.storage.set(this.key, states);
-
-      if (event) {
-        this.dispatchEvent(event.name, event.params);
-      }
-
-      return states;
-    } catch (error) {
-      console.error(error);
-
-      return [];
-    }
+  _saveToStorage() {
+    const states = Object.fromEntries(this.states);
+    return this.storage.set(this.key, states);
   }
 
   dispatchEvent(name, params) {
@@ -53,77 +39,48 @@ class WorkflowState {
   }
 
   async get(stateId) {
-    try {
-      let states = this.cache ?? ((await this.storage.get(this.key)) || {});
-
-      if (Array.isArray(states)) {
-        states = {};
-        await this.storage.set(this.key, {});
-      }
-
-      if (typeof stateId === 'function') {
-        states = Object.values(states).find(stateId);
-      } else if (stateId) {
-        states = states[stateId];
-      } else {
-        this.cache = states;
-      }
-
-      return states;
-    } catch (error) {
-      console.error(error);
-
-      return null;
+    let { states } = this;
+
+    if (typeof stateId === 'function') {
+      states = Array.from(states.entries()).find(({ 1: state }) =>
+        stateId(state)
+      );
+    } else if (stateId) {
+      states = this.states.get(stateId);
     }
-  }
 
-  add(id, data = {}) {
-    return this._updater((states) => {
-      states[id] = {
-        id,
-        isPaused: false,
-        isDestroyed: false,
-        ...data,
-      };
+    return states;
+  }
 
-      return states;
-    });
+  async add(id, data = {}) {
+    this.states.set(id, data);
+    await this._saveToStorage(this.key);
   }
 
   async stop(id) {
-    await this.update(id, { isDestroyed: true });
+    const isStateExist = await this.get(id);
+    if (!isStateExist) {
+      await this.delete(id);
+      this.dispatchEvent('stop', id);
+      return id;
+    }
 
+    await this.update(id, { isDestroyed: true });
     this.dispatchEvent('stop', id);
-
     return id;
   }
 
-  update(id, data = {}) {
-    const event = {
-      name: 'update',
-      params: { id, data },
-    };
-
-    return this._updater((states) => {
-      if (states[id]) {
-        states[id] = { ...states[id], ...data };
-      }
-
-      return states;
-    }, event);
+  async update(id, data = {}) {
+    const state = this.states.get(id);
+    this.states.set(id, { ...state, ...data });
+    this.dispatchEvent('update', { id, data });
+    await this._saveToStorage();
   }
 
-  delete(id) {
-    const event = {
-      name: 'delete',
-      params: id,
-    };
-
-    return this._updater((states) => {
-      delete states[id];
-
-      return states;
-    }, event);
+  async delete(id) {
+    this.states.delete(id);
+    this.dispatchEvent('delete', id);
+    await this._saveToStorage();
   }
 }
 

+ 102 - 12
src/background/index.js

@@ -2,10 +2,12 @@ import browser from 'webextension-polyfill';
 import dayjs from '@/lib/dayjs';
 import { MessageListener } from '@/utils/message';
 import { parseJSON, findTriggerBlock } from '@/utils/helper';
+import { fetchApi } from '@/utils/api';
 import getFile from '@/utils/getFile';
 import decryptFlow, { getWorkflowPass } from '@/utils/decryptFlow';
 import {
   registerSpecificDay,
+  registerContextMenu,
   registerWorkflowTrigger,
 } from '../utils/workflowTrigger';
 import WorkflowState from './WorkflowState';
@@ -15,7 +17,7 @@ import blocksHandler from './workflowEngine/blocksHandler';
 import WorkflowLogger from './WorkflowLogger';
 
 const validateUrl = (str) => str?.startsWith('http');
-const storage = {
+const browserStorage = {
   async get(key) {
     try {
       const result = await browser.storage.local.get(key);
@@ -34,9 +36,21 @@ const storage = {
     }
   },
 };
+const localStateStorage = {
+  get(key) {
+    const data = parseJSON(localStorage.getItem(key), null);
+
+    return data;
+  },
+  set(key, value) {
+    const data = typeof value === 'object' ? JSON.stringify(value) : value;
+
+    return localStorage.setItem(key, data);
+  },
+};
 const workflow = {
-  states: new WorkflowState({ storage }),
-  logger: new WorkflowLogger({ storage }),
+  states: new WorkflowState({ storage: localStateStorage }),
+  logger: new WorkflowLogger({ storage: browserStorage }),
   async get(workflowId) {
     const { workflows, workflowHosts } = await browser.storage.local.get([
       'workflows',
@@ -78,6 +92,37 @@ const workflow = {
       engine.resume(options.state);
     } else {
       engine.init();
+      engine.on('destroyed', ({ id, status }) => {
+        browser.permissions
+          .contains({ permissions: ['notifications'] })
+          .then((hasPermission) => {
+            if (!hasPermission || !workflowData.settings.notification) return;
+
+            const name = workflowData.name.slice(0, 32);
+
+            browser.notifications.create(`logs:${id}`, {
+              type: 'basic',
+              iconUrl: browser.runtime.getURL('icon-128.png'),
+              title: status === 'success' ? 'Success' : 'Error',
+              message: `${
+                status === 'success' ? 'Successfully' : 'Failed'
+              } to run the "${name}" workflow`,
+            });
+          })
+          .catch((error) => {
+            console.error(error);
+          });
+      });
+
+      const lastCheckStatus = localStorage.getItem('check-status');
+      const isSameDay = dayjs().isSame(lastCheckStatus, 'day');
+      if (!isSameDay) {
+        fetchApi('/status')
+          .then((response) => response.json())
+          .then(() => {
+            localStorage.setItem('check-status', new Date());
+          });
+      }
     }
 
     return engine;
@@ -112,7 +157,7 @@ async function openDashboard(url) {
     if (tab) {
       await browser.tabs.update(tab.id, tabOptions);
 
-      if (tab.url.includes('workflows/')) {
+      if (tabOptions.url.includes('workflows/')) {
         await browser.tabs.reload(tab.id);
       }
     } else {
@@ -126,7 +171,7 @@ async function checkWorkflowStates() {
   const states = await workflow.states.get();
   // const sessionStates = parseJSON(sessionStorage.getItem('workflowState'), {});
 
-  Object.values(states || {}).forEach((state) => {
+  states.forEach((state) => {
     /* Enable when using manifest 3 */
     // const resumeWorkflow =
     //   !state.isDestroyed && objectHasKey(sessionStates, state.id);
@@ -139,18 +184,18 @@ async function checkWorkflowStates() {
         });
       });
     } else {
-      delete states[state.id];
+      workflow.states.states.delete(state.id);
     }
   });
 
-  await storage.set('workflowState', states);
+  await browserStorage.set('workflowState', states);
 }
 checkWorkflowStates();
 async function checkVisitWebTriggers(tabId, tabUrl) {
   const workflowState = await workflow.states.get(({ state }) =>
     state.tabIds.includes(tabId)
   );
-  const visitWebTriggers = await storage.get('visitWebTriggers');
+  const visitWebTriggers = await browserStorage.get('visitWebTriggers');
   const triggeredWorkflow = visitWebTriggers?.find(({ url, isRegex, id }) => {
     if (url.trim() === '') return false;
 
@@ -168,7 +213,7 @@ async function checkVisitWebTriggers(tabId, tabUrl) {
 async function checkRecordingWorkflow(tabId, tabUrl) {
   if (!validateUrl(tabUrl)) return;
 
-  const isRecording = await storage.get('isRecording');
+  const isRecording = await browserStorage.get('isRecording');
   if (!isRecording) return;
 
   await browser.tabs.executeScript(tabId, {
@@ -302,6 +347,41 @@ browser.alarms.onAlarm.addListener(async ({ name }) => {
   }
 });
 
+const contextMenu =
+  BROWSER_TYPE === 'firefox' ? browser.menus : browser.contextMenus;
+if (contextMenu && contextMenu.onClicked) {
+  contextMenu.onClicked.addListener(
+    async ({ parentMenuItemId, menuItemId }, tab) => {
+      try {
+        if (parentMenuItemId !== 'automaContextMenu') return;
+
+        const message = await browser.tabs.sendMessage(tab.id, {
+          frameId: 0,
+          type: 'context-element',
+        });
+        const workflowData = await workflow.get(menuItemId);
+
+        workflow.execute(workflowData, {
+          data: {
+            variables: message,
+          },
+        });
+      } catch (error) {
+        console.error(error);
+      }
+    }
+  );
+}
+
+if (browser.notifications && browser.notifications.onClicked) {
+  browser.notifications.onClicked.addListener((notificationId) => {
+    if (notificationId.startsWith('logs')) {
+      const { 1: logId } = notificationId.split(':');
+      openDashboard(`/logs/${logId}`);
+    }
+  });
+}
+
 browser.runtime.onInstalled.addListener(async ({ reason }) => {
   try {
     if (reason === 'install') {
@@ -334,9 +414,13 @@ browser.runtime.onInstalled.addListener(async ({ reason }) => {
           workflowTrigger = findTriggerBlock(flows)?.data;
         }
 
-        if (!alarmTypes.includes(workflowTrigger.type)) return;
+        const triggerType = workflowTrigger?.type;
 
-        registerWorkflowTrigger(id, { data: workflowTrigger });
+        if (alarmTypes.includes(triggerType)) {
+          registerWorkflowTrigger(id, { data: workflowTrigger });
+        } else if (triggerType === 'context-menu') {
+          registerContextMenu(id, workflowTrigger);
+        }
       }
     }
   } catch (error) {
@@ -418,7 +502,13 @@ message.on('collection:execute', (collection) => {
   engine.init();
 });
 
-message.on('workflow:execute', (workflowData) => {
+message.on('workflow:execute', (workflowData, sender) => {
+  if (workflowData.includeTabId) {
+    if (!workflowData.options) workflowData.options = {};
+
+    workflowData.options.tabId = sender.tab.id;
+  }
+
   workflow.execute(workflowData, workflowData?.options || {});
 });
 message.on('workflow:stop', (id) => workflow.states.stop(id));

+ 0 - 57
src/background/messageListener.js

@@ -1,57 +0,0 @@
-export default function () {
-  const message = new MessageListener('background');
-
-  message.on('fetch:text', (url) => {
-    return fetch(url).then((response) => response.text());
-  });
-  message.on('open:dashboard', async (url) => {
-    const tabOptions = {
-      active: true,
-      url: browser.runtime.getURL(
-        `/newtab.html#${typeof url === 'string' ? url : ''}`
-      ),
-    };
-
-    try {
-      const [tab] = await browser.tabs.query({
-        url: browser.runtime.getURL('/newtab.html'),
-      });
-
-      if (tab) {
-        await browser.tabs.update(tab.id, tabOptions);
-        await browser.tabs.reload(tab.id);
-      } else {
-        browser.tabs.create(tabOptions);
-      }
-    } catch (error) {
-      console.error(error);
-    }
-  });
-  message.on('get:sender', (_, sender) => {
-    return sender;
-  });
-
-  message.on('collection:execute', executeCollection);
-  message.on('collection:stop', (id) => {
-    const collection = runningCollections[id];
-    if (!collection) {
-      workflowState.delete(id);
-      return;
-    }
-
-    collection.stop();
-  });
-
-  message.on('workflow:check-state', checkRunnigWorkflows);
-  message.on('workflow:execute', (workflow) => executeWorkflow(workflow));
-  message.on('workflow:stop', (id) => {
-    const workflow = runningWorkflows[id];
-
-    if (!workflow) {
-      workflowState.delete(id);
-      return;
-    }
-
-    workflow.stop();
-  });
-}

+ 5 - 2
src/background/workflowEngine/blocksHandler/handlerConditions.js

@@ -78,8 +78,11 @@ async function conditions({ data, outputs }, { prevBlockData, refData }) {
     data.conditions.forEach(({ type, value, compareValue }, index) => {
       if (isConditionMet) return;
 
-      const firstValue = mustacheReplacer(compareValue ?? prevData, refData);
-      const secondValue = mustacheReplacer(value, refData);
+      const firstValue = mustacheReplacer(
+        compareValue ?? prevData,
+        refData
+      ).value;
+      const secondValue = mustacheReplacer(value, refData).value;
 
       Object.assign(replacedValue, firstValue.list, secondValue.list);
 

+ 4 - 1
src/background/workflowEngine/blocksHandler/handlerJavascriptCode.js

@@ -21,7 +21,10 @@ export async function javascriptCode({ outputs, data, ...block }, { refData }) {
       }
     }
 
-    const result = await this._sendMessageToTab({ ...block, data, refData });
+    const payload = { ...block, data, refData: { variables: {} } };
+    if (data.code.includes('automaRefData')) payload.refData = refData;
+
+    const result = await this._sendMessageToTab(payload);
 
     if (result) {
       if (result.columns.data?.$error) {

+ 2 - 2
src/background/workflowEngine/blocksHandler/handlerLoopData.js

@@ -40,7 +40,7 @@ async function loopData({ data, id, outputs }, { refData }) {
             name: 'loop-data',
             data: {
               multiple: true,
-              max: data.maxLoop,
+              max: +data.maxLoop || 0,
               selector: data.elementSelector,
               findBy: isXPath(data.elementSelector) ? 'xpath' : 'cssSelector',
             },
@@ -74,7 +74,7 @@ async function loopData({ data, id, outputs }, { refData }) {
         maxLoop:
           data.loopThrough === 'numbers'
             ? data.toNumber + 1 - data.fromNumber
-            : data.maxLoop || currLoopData.length,
+            : +data.maxLoop || currLoopData.length,
       };
       /* eslint-disable-next-line */
       refData.loopData[data.loopId] = {

+ 47 - 0
src/background/workflowEngine/blocksHandler/handlerNotification.js

@@ -0,0 +1,47 @@
+import { nanoid } from 'nanoid';
+import browser from 'webextension-polyfill';
+import { getBlockConnection } from '../helper';
+
+export default async function ({ data, outputs }) {
+  const nextBlockId = getBlockConnection({ outputs });
+
+  try {
+    const hasPermission = await browser.permissions.contains({
+      permissions: ['notifications'],
+    });
+
+    if (!hasPermission) {
+      const error = new Error('no-permission');
+      error.data = { permission: 'notifications' };
+
+      throw error;
+    }
+
+    const options = {
+      title: data.title,
+      message: data.message,
+      iconUrl: browser.runtime.getURL('icon-128.png'),
+    };
+
+    ['iconUrl', 'imageUrl'].forEach((key) => {
+      const url = data[key];
+      if (!url || !url.startsWith('http')) return;
+
+      options[key] = url;
+    });
+
+    await browser.notifications.create(nanoid(), {
+      ...options,
+      type: options.imageUrl ? 'image' : 'basic',
+    });
+
+    return {
+      data: '',
+      nextBlockId,
+    };
+  } catch (error) {
+    error.nextBlockId = nextBlockId;
+
+    throw error;
+  }
+}

+ 12 - 3
src/background/workflowEngine/blocksHandler/handlerProxy.js

@@ -23,7 +23,16 @@ function setProxy({ data, outputs }) {
     };
 
     if (!isWhitespace(data.host)) {
-      config.rules.singleProxy.host = data.host;
+      let proxyHost = data.host;
+
+      const schemeRegex = /^https?|socks4|socks5/i;
+      if (schemeRegex.test(data.host)) {
+        const [scheme, host] = data.host.split(/:\/\/(.*)/);
+        proxyHost = host;
+        config.rules.singleProxy.scheme = scheme;
+      }
+
+      config.rules.singleProxy.host = proxyHost;
     } else {
       if (data.clearProxy) {
         this.engine.isUsingProxy = false;
@@ -44,10 +53,10 @@ function setProxy({ data, outputs }) {
     }
 
     if (data.port !== 0) {
-      config.rules.singleProxy.port = data.port;
+      config.rules.singleProxy.port = +data.port;
     }
 
-    browser.proxy.settings.set({ value: config, scope: 'regular' }).then(() => {
+    chrome.proxy.settings.set({ value: config, scope: 'regular' }, () => {
       this.engine.isUsingProxy = true;
 
       resolve({

+ 10 - 2
src/background/workflowEngine/blocksHandler/handlerWebhook.js

@@ -1,9 +1,10 @@
 import objectPath from 'object-path';
 import { isWhitespace } from '@/utils/helper';
 import { executeWebhook } from '@/utils/webhookUtil';
+import mustacheReplacer from '@/utils/referenceData/mustacheReplacer';
 import { getBlockConnection } from '../helper';
 
-export async function webhook({ data, outputs }) {
+export async function webhook({ data, outputs }, { refData }) {
   const nextBlockId = getBlockConnection({ outputs });
   const fallbackOutput = getBlockConnection({ outputs }, 2);
 
@@ -11,7 +12,14 @@ export async function webhook({ data, outputs }) {
     if (isWhitespace(data.url)) throw new Error('url-empty');
     if (!data.url.startsWith('http')) throw new Error('invalid-url');
 
-    const response = await executeWebhook(data);
+    const newHeaders = [];
+    data.headers.forEach(({ value, name }) => {
+      const newValue = mustacheReplacer(value, refData).value;
+
+      newHeaders.push({ name, value: newValue });
+    });
+
+    const response = await executeWebhook({ ...data, headers: newHeaders });
 
     if (!response.ok) {
       if (fallbackOutput.connections.length > 0) {

+ 17 - 25
src/background/workflowEngine/engine.js

@@ -38,6 +38,8 @@ class WorkflowEngine {
       },
     };
 
+    this.logHistoryId = 0;
+
     let variables = {};
     let { globalData } = workflow;
 
@@ -143,6 +145,7 @@ class WorkflowEngine {
 
     this.states
       .add(this.id, {
+        id: this.id,
         state: this.state,
         workflowId: this.workflow.id,
         parentState: this.parentWorkflow,
@@ -170,15 +173,15 @@ class WorkflowEngine {
   }
 
   addLogHistory(detail) {
-    if (
-      !this.saveLog &&
-      (this.history.length >= 1001 || detail.name === 'blocks-group') &&
-      detail.type !== 'error'
-    )
-      return;
+    if (detail.name === 'blocks-group') return;
 
-    const historyId = nanoid();
-    detail.id = historyId;
+    const isLimit = this.history.length >= 1001;
+    const notErrorLog = detail.type !== 'error';
+
+    if ((!this.saveLog || isLimit) && notErrorLog) return;
+
+    this.logHistoryId += 1;
+    detail.id = this.logHistoryId;
 
     if (
       detail.replacedValue ||
@@ -188,7 +191,7 @@ class WorkflowEngine {
         JSON.stringify(this.referenceData)
       );
 
-      this.historyCtxData[historyId] = {
+      this.historyCtxData[this.logHistoryId] = {
         referenceData: {
           loopData,
           variables,
@@ -327,15 +330,18 @@ class WorkflowEngine {
 
   async updateState(data) {
     const state = {
-      ...this.state,
       ...data,
       tabIds: [],
       currentBlock: [],
+      name: this.workflow.name,
+      startedTimestamp: this.startedTimestamp,
     };
 
     this.workers.forEach((worker) => {
+      const { id, name } = worker.currentBlock;
+
+      state.currentBlock.push({ id, name });
       state.tabIds.push(worker.activeTab.id);
-      state.currentBlock.push(worker.currentBlock);
     });
 
     await this.states.update(this.id, { state });
@@ -357,20 +363,6 @@ class WorkflowEngine {
       listener
     );
   }
-
-  get state() {
-    const keys = ['columns', 'referenceData', 'startedTimestamp'];
-    const state = {
-      name: this.workflow.name,
-      icon: this.workflow.icon,
-    };
-
-    keys.forEach((key) => {
-      state[key] = this[key];
-    });
-
-    return state;
-  }
 }
 
 export default WorkflowEngine;

+ 1 - 2
src/background/workflowEngine/worker.js

@@ -1,6 +1,5 @@
 import { nanoid } from 'nanoid';
 import browser from 'webextension-polyfill';
-import cloneDeep from 'lodash.clonedeep';
 import { toCamelCase, sleep, objectHasKey, isObject } from '@/utils/helper';
 import { tasks } from '@/utils/shared';
 import referenceData from '@/utils/referenceData';
@@ -84,7 +83,7 @@ class Worker {
       if (index === 0) {
         this.executeBlock(this.engine.blocks[node], prevBlockData);
       } else {
-        const state = cloneDeep({
+        const state = structuredClone({
           windowId: this.windowId,
           loopList: this.loopList,
           activeTab: this.activeTab,

+ 2 - 1
src/components/content/selector/SelectorQuery.vue

@@ -24,9 +24,9 @@
     <div class="mt-2 flex items-center">
       <ui-input
         :model-value="selector"
-        readonly
         placeholder="Element selector"
         class="leading-normal flex-1 h-full element-selector"
+        @change="$emit('selector', $event)"
       >
         <template #prepend>
           <button class="absolute ml-2 left-0" @click="copySelector">
@@ -76,6 +76,7 @@ defineEmits([
   'list',
   'parent',
   'child',
+  'selector',
   'update:selectorType',
   'update:selectList',
 ]);

+ 1 - 0
src/components/content/shared/SharedElementSelector.vue

@@ -58,6 +58,7 @@ const props = defineProps({
     default: () => [],
   },
   list: Boolean,
+  hide: Boolean,
   pause: Boolean,
   disabled: Boolean,
   onlyInList: Boolean,

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

@@ -287,15 +287,19 @@ async function backupWorkflowsToCloud(workflowId) {
         '__id',
       ]);
       delete workflow.extVersion;
+      workflow.drawflow =
+        typeof workflow.drawflow === 'string'
+          ? parseJSON(workflow.drawflow, { drawflow: { Home: { data: {} } } })
+          : workflow.drawflow;
 
-      if (!workflow.__id) delete workflow.__id;
+      if (!workflow.__id) workflow.__id = null;
 
       acc.push(workflow);
 
       return acc;
     }, []);
 
-    const response = await fetchApi('/me/workflows?type=backup', {
+    const response = await fetchApi('/me/workflows/backup', {
       method: 'POST',
       body: JSON.stringify({ workflows: workflowsPayload }),
     });

+ 1 - 1
src/components/newtab/shared/SharedConditionBuilder/ConditionBuilderInputs.vue

@@ -23,7 +23,7 @@
           </option>
         </optgroup>
       </ui-select>
-      <template v-for="(_, name) in item.data" :key="item.id + name + index">
+      <template v-for="(_, name) in item.data" :key="item.id + name">
         <v-remixicon
           v-if="name === 'code'"
           :title="t('workflow.conditionBuilder.topAwait')"

+ 15 - 5
src/components/newtab/shared/SharedConditionBuilder/index.vue

@@ -20,6 +20,7 @@
           <draggable
             v-model="conditions[index].conditions"
             item-key="id"
+            handle=".handle"
             group="conditions"
             class="space-y-2"
             @end="onDragEnd"
@@ -49,7 +50,10 @@
                       class="ml-4 group-hover:visible invisible"
                       @click.stop="deleteCondition(index, inputsIndex)"
                     />
-                    <v-remixicon name="mdiDrag" class="ml-2 cursor-move" />
+                    <v-remixicon
+                      name="mdiDrag"
+                      class="ml-2 cursor-move handle"
+                    />
                   </template>
                   <div class="space-y-2 px-4 py-2">
                     <condition-builder-inputs
@@ -124,17 +128,23 @@ const conditions = ref(cloneDeep(props.modelValue));
 function getDefaultValues(items = ['value', 'compare', 'value']) {
   const defaultValues = {
     value: {
-      id: nanoid(),
       type: 'value',
       category: 'value',
       data: { value: '' },
     },
-    compare: { id: nanoid(), category: 'compare', type: 'eq' },
+    compare: { category: 'compare', type: 'eq' },
   };
+  const selectValue = (type) =>
+    cloneDeep({
+      ...defaultValues[type],
+      id: nanoid(),
+    });
 
-  if (typeof items === 'string') return defaultValues[items];
+  if (typeof items === 'string') {
+    return selectValue(items);
+  }
 
-  return items.map((item) => defaultValues[item]);
+  return items.map((item) => selectValue(item));
 }
 function getConditionText({ category, type, data }) {
   if (category === 'compare') {

+ 5 - 20
src/components/newtab/shared/SharedWorkflowState.vue

@@ -29,12 +29,14 @@
     </div>
     <div class="divide-y bg-box-transparent divide-y px-4 rounded-lg">
       <div
-        v-for="block in getBlock()"
+        v-for="block in data.state.currentBlock"
         :key="block.id || block.name"
         class="flex items-center py-2"
       >
-        <v-remixicon :name="block.icon" />
-        <p class="flex-1 ml-2 mr-4 text-overflow">{{ block.name }}</p>
+        <v-remixicon :name="tasks[block.name].icon" />
+        <p class="flex-1 ml-2 mr-4 text-overflow">
+          {{ tasks[block.name].name }}
+        </p>
         <ui-spinner color="text-accent" size="20" />
       </div>
     </div>
@@ -69,23 +71,6 @@ const props = defineProps({
 
 const { t } = useI18n();
 
-function getBlock() {
-  const block = props.data.state.currentBlock;
-
-  if (!block) return [];
-
-  const blockArr = Array.isArray(block) ? block : [block];
-
-  return blockArr.map((item) => {
-    if (tasks[item.name] && item.outputs)
-      return {
-        ...tasks[item.name],
-        name: t(`workflow.blocks.${item.name}.name`),
-      };
-
-    return item;
-  });
-}
 function formatDate(date, format) {
   if (format === 'relative') return dayjs(date).fromNow();
 

+ 25 - 26
src/components/newtab/workflow/WorkflowBuilder.vue

@@ -34,6 +34,7 @@
             <v-remixicon name="riAddLine" />
           </button>
         </div>
+        <workflow-builder-search-blocks :editor="editor" />
       </div>
       <slot v-bind="{ editor }"></slot>
     </div>
@@ -87,12 +88,14 @@ import {
   getShortcut,
   getReadableShortcut,
 } from '@/composable/shortcut';
-import { tasks } from '@/utils/shared';
+import { tasks, excludeOnError } from '@/utils/shared';
 import { parseJSON } from '@/utils/helper';
 import { useGroupTooltip } from '@/composable/groupTooltip';
 import drawflow from '@/lib/drawflow';
+import WorkflowBuilderSearchBlocks from './WorkflowBuilderSearchBlocks.vue';
 
 export default {
+  components: { WorkflowBuilderSearchBlocks },
   props: {
     data: {
       type: [Object, String],
@@ -248,6 +251,13 @@ export default {
           targetBlock = { ...tasks[block.id], id: block.id };
         }
 
+        const onErrorEnabled =
+          targetNode.data?.onError?.enable &&
+          !excludeOnError.includes(targetBlock.id);
+        const newNodeData = onErrorEnabled
+          ? { ...targetBlock.data, onError: targetNode.data.onError }
+          : targetBlock.data;
+
         const newNodeId = editor.value.addNode(
           targetBlock.id,
           targetBlock.inputs,
@@ -255,10 +265,15 @@ export default {
           targetNode.pos_x,
           targetNode.pos_y,
           targetBlock.id,
-          targetBlock.data,
+          newNodeData,
           targetBlock.component,
           'vue'
         );
+
+        if (onErrorEnabled && targetNode.data.onError.toDo === 'fallback') {
+          editor.value.addNodeOutput(newNodeId);
+        }
+
         const duplicateConnections = (nodeIO, type) => {
           if (block[type] === 0) return;
 
@@ -490,14 +505,6 @@ export default {
       editor.value.editor_mode = props.isShared ? 'fixed' : 'edit';
       editor.value.container.classList.toggle('is-shared', props.isShared);
     }
-    function refreshConnection() {
-      const nodes = document.querySelectorAll('#drawflow .drawflow-node');
-      nodes.forEach((node) => {
-        if (!node.id) return;
-
-        editor.value.updateConnectionNodes(node.id);
-      });
-    }
     function saveEditorState() {
       const editorStates =
         parseJSON(localStorage.getItem('editor-states'), {}) || {};
@@ -703,17 +710,6 @@ export default {
           ...store.state.settings.editor,
         },
       });
-
-      const editorStates =
-        parseJSON(localStorage.getItem('editor-states'), {}) || {};
-      const editorState = editorStates[workflowId];
-
-      if (editorState) {
-        editor.value.zoom = editorState.zoom;
-        editor.value.canvas_x = editorState.canvas_x;
-        editor.value.canvas_y = editorState.canvas_y;
-      }
-
       editor.value.start();
 
       emit('load', editor.value);
@@ -870,13 +866,16 @@ export default {
         }
       });
 
+      const editorStates =
+        parseJSON(localStorage.getItem('editor-states'), {}) || {};
+      const editorState = editorStates[workflowId];
+      if (editorState) {
+        const { canvas_x, canvas_y, zoom } = editorState;
+        editor.value.translate_to(canvas_x, canvas_y, zoom);
+      }
+
       checkWorkflowData();
       initSelectArea();
-
-      setTimeout(() => {
-        editor.value.zoom_refresh();
-        refreshConnection();
-      }, 500);
     });
     onBeforeUnmount(() => {
       const element = document.querySelector('#drawflow');

+ 160 - 0
src/components/newtab/workflow/WorkflowBuilderSearchBlocks.vue

@@ -0,0 +1,160 @@
+<template>
+  <div
+    class="bg-white dark:bg-gray-800 ml-2 inline-flex items-center rounded-lg"
+  >
+    <button
+      v-tooltip="
+        `${t('workflow.searchBlocks.title')} (${
+          shortcut['editor:search-blocks'].readable
+        })`
+      "
+      class="hoverable p-2 rounded-lg rounded-lg"
+      icon
+      @click="toggleActiveSearch"
+    >
+      <v-remixicon name="riSearch2Line" />
+    </button>
+    <ui-autocomplete
+      :model-value="state.query"
+      :items="state.autocompleteItems"
+      :custom-filter="searchNodes"
+      item-key="id"
+      item-label="name"
+      @select="onSelectItem"
+      @selected="onItemSelected"
+    >
+      <input
+        id="search-blocks"
+        v-model="state.query"
+        :placeholder="t('common.search')"
+        :style="{ width: state.active ? '250px' : '0px' }"
+        type="search"
+        autocomplete="off"
+        class="py-2 focus:ring-0 rounded-lg bg-transparent"
+        @focus="extractBlocks"
+        @blur="clearState"
+      />
+      <template #item="{ item }">
+        <div class="flex-1 overflow-hidden">
+          <p class="text-overflow">
+            {{ item.name }}
+          </p>
+          <p
+            class="text-sm text-overflow leading-none text-gray-600 dark:text-gray-300"
+          >
+            {{ item.description }}
+          </p>
+        </div>
+      </template>
+    </ui-autocomplete>
+  </div>
+</template>
+<script setup>
+import { reactive } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useShortcut } from '@/composable/shortcut';
+
+const props = defineProps({
+  editor: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+
+const { t } = useI18n();
+
+const initialState = {
+  zoom: 1,
+  rectX: 0,
+  rectY: 0,
+  canvasX: 0,
+  canvasY: 0,
+};
+const state = reactive({
+  query: '',
+  active: false,
+  selected: false,
+  autocompleteItems: [],
+});
+
+const shortcut = useShortcut('editor:search-blocks', () => {
+  state.active = true;
+  document.querySelector('#search-blocks')?.focus();
+});
+
+function searchNodes({ item, text }) {
+  const query = text.toLocaleLowerCase();
+
+  return (
+    item.name.toLocaleLowerCase().includes(query) ||
+    item.description.toLocaleLowerCase().includes(query)
+  );
+}
+function toggleActiveSearch() {
+  state.active = !state.active;
+
+  if (state.active) {
+    document.querySelector('#search-blocks')?.focus();
+  }
+}
+function extractBlocks() {
+  const { width, height } = props.editor.container.getBoundingClientRect();
+  initialState.rectX = width / 2;
+  initialState.rectY = height / 2;
+  initialState.zoom = props.editor.zoom;
+  initialState.canvasX = props.editor.canvas_x;
+  initialState.canvasY = props.editor.canvas_y;
+
+  const { drawflow } = props.editor.export();
+  state.autocompleteItems = Object.values(drawflow.Home.data).map(
+    ({ id, name, data, pos_x, pos_y }) => ({
+      id,
+      pos_x,
+      pos_y,
+      description: data.description || '',
+      name: t(`workflow.blocks.${name}.name`),
+    })
+  );
+}
+function clearState() {
+  if (!state.selected) {
+    const { canvasX, canvasY, zoom } = initialState;
+    props.editor.translate_to(canvasX, canvasY, zoom);
+  }
+
+  state.query = '';
+  state.active = false;
+  state.selected = false;
+
+  Object.assign(initialState, {
+    zoom: 1,
+    rectX: 0,
+    rectY: 0,
+    canvasX: 0,
+    canvasY: 0,
+  });
+}
+function onSelectItem({ item }) {
+  if (props.editor.zoom !== 1) {
+    /* eslint-disable-next-line */
+    props.editor.zoom = 1;
+    props.editor.zoom_refresh();
+  }
+
+  const { rectX, rectY } = initialState;
+  props.editor.translate_to(
+    -(item.pos_x - rectX),
+    -(item.pos_y - rectY),
+    props.editor.zoom
+  );
+}
+function onItemSelected(event) {
+  state.selected = true;
+  onSelectItem(event);
+}
+</script>
+<style scoped>
+input {
+  transition: width 250ms ease;
+}
+</style>

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

@@ -54,7 +54,7 @@
 <script>
 import { computed, provide, ref, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
-import { tasks } from '@/utils/shared';
+import { tasks, excludeOnError } from '@/utils/shared';
 import { parseJSON } from '@/utils/helper';
 import OnBlockError from './edit/OnBlockError.vue';
 
@@ -97,15 +97,6 @@ export default {
   },
   emits: ['close', 'update', 'update:autocomplete'],
   setup(props, { emit }) {
-    const excludeOnError = [
-      'delay',
-      'webhook',
-      'trigger',
-      'while-loop',
-      'conditions',
-      'element-exists',
-    ];
-
     const { t } = useI18n();
     const autocompleteData = ref({
       common: {

+ 2 - 0
src/components/newtab/workflow/WorkflowSettings.vue

@@ -70,7 +70,9 @@ const tabs = [
 
 const activeTab = ref('general');
 const settings = reactive({
+  publicId: '',
   restartTimes: 3,
+  notification: true,
   tabLoadTimeout: 30000,
   inputAutocomplete: true,
   insertDefaultColumn: true,

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

@@ -237,7 +237,7 @@ async function publishWorkflow() {
     });
     const result = await response.json();
 
-    if (response.status !== 200) {
+    if (!response.ok) {
       const error = new Error(response.statusText);
       error.data = result.data;
 

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

@@ -94,9 +94,7 @@
         :label="t('workflow.blocks.loop-data.maxLoop.label')"
         :title="t('workflow.blocks.loop-data.maxLoop.title')"
         class="w-full mt-2"
-        min="0"
-        type="number"
-        @change="updateData({ maxLoop: +$event || 0 })"
+        @change="updateData({ maxLoop: $event })"
       />
       <ui-input
         v-if="!data.resumeLastWorkflow"

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

@@ -7,9 +7,13 @@
       @change="updateData({ description: $event })"
     />
     <edit-autocomplete v-if="!data.activeTab" class="mt-2">
-      <ui-input
+      <label for="new-tab-url" class="input-label">
+        {{ t('workflow.blocks.new-tab.url') }}
+      </label>
+      <ui-textarea
+        id="new-tab-url"
+        key="anu"
         :model-value="data.url"
-        :label="t('workflow.blocks.new-tab.url')"
         class="w-full"
         autocomplete="off"
         placeholder="http://example.com/"

+ 62 - 0
src/components/newtab/workflow/edit/EditNotification.vue

@@ -0,0 +1,62 @@
+<template>
+  <div>
+    <ui-textarea
+      :model-value="data.description"
+      :placeholder="t('common.description')"
+      class="w-full mb-2"
+      @change="updateData({ description: $event })"
+    />
+    <edit-autocomplete class="mb-2">
+      <ui-input
+        :model-value="data.title"
+        :label="t('workflow.blocks.notification.title')"
+        placeholder="Hello world!"
+        class="w-full"
+        @change="updateData({ title: $event })"
+      />
+    </edit-autocomplete>
+    <label class="input-label" for="notification-message">
+      {{ t('workflow.blocks.notification.message') }}
+    </label>
+    <edit-autocomplete>
+      <ui-textarea
+        id="notification-message"
+        :model-value="data.message"
+        placeholder="Notification message"
+        class="w-full"
+        @change="updateData({ message: $event })"
+      />
+    </edit-autocomplete>
+    <edit-autocomplete
+      v-for="type in ['iconUrl', 'imageUrl']"
+      :key="type"
+      class="mt-2"
+    >
+      <ui-input
+        :model-value="data[type]"
+        :label="t(`workflow.blocks.notification.${type}`)"
+        class="w-full"
+        placeholder="https://example.com/image.png"
+        @change="updateData({ [type]: $event })"
+      />
+    </edit-autocomplete>
+  </div>
+</template>
+<script setup>
+import { useI18n } from 'vue-i18n';
+import EditAutocomplete from './EditAutocomplete.vue';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const { t } = useI18n();
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+</script>

+ 37 - 36
src/components/newtab/workflow/edit/EditProxy.vue

@@ -1,49 +1,52 @@
 <template>
   <div>
-    <div class="flex items-center mb-2">
-      <ui-select
-        :model-value="data.scheme"
-        label="Scheme"
-        class="mr-2"
-        @change="updateData({ scheme: $event })"
-      >
-        <option v-for="scheme in schemes" :key="scheme" :value="scheme">
-          {{ scheme.toUpperCase() }}
-        </option>
-      </ui-select>
-      <ui-input
-        :model-value="data.port"
-        label="Port"
-        placeholder="443"
-        class="flex-1"
-        type="number"
-        @change="updateData({ port: +$event })"
-      />
-    </div>
+    <ui-textarea
+      :model-value="data.description"
+      :placeholder="t('common.description')"
+      class="w-full mb-2"
+      @change="updateData({ description: $event })"
+    />
     <ui-input
       :model-value="data.host"
-      label="Host"
-      placeholder="1.2.3.4"
+      placeholder="socks5://1.2.3.4"
       class="w-full mb-2"
       @change="updateData({ host: $event })"
-    />
+    >
+      <template #label>
+        <span class="input-label"> Host </span>
+        <v-remixicon
+          title="Supported protocols: http, https, socks4, and socks5"
+          name="riInformationLine"
+          class="inline-block"
+          size="18"
+        />
+      </template>
+    </ui-input>
     <ui-input
+      :model-value="data.port"
+      label="Port"
+      placeholder="443"
+      class="w-full mb-2"
+      @change="updateData({ port: +$event })"
+    />
+    <label for="input-bypass" class="input-label">
+      {{ t('workflow.blocks.proxy.bypass.label') }}
+      <a
+        href="https://developer.chrome.com/docs/extensions/reference/proxy/#bypass-list"
+        target="_blank"
+        rel="noopener"
+      >
+        &#128712;
+      </a>
+    </label>
+    <ui-textarea
+      id="input-bypass"
       :model-value="data.bypassList"
       placeholder="example1.com, example2.org"
       class="w-full"
       @change="updateData({ bypassList: $event })"
     >
-      <template #label>
-        {{ t('workflow.blocks.proxy.bypass.label') }}
-        <a
-          href="https://developer.chrome.com/docs/extensions/reference/proxy/#bypass-list"
-          target="_blank"
-          rel="noopener"
-        >
-          &#128712;
-        </a>
-      </template>
-    </ui-input>
+    </ui-textarea>
     <p class="text-gray-600 dark:text-gray-200 text-sm">
       {{ t('workflow.blocks.proxy.bypass.note') }}
     </p>
@@ -69,8 +72,6 @@ const emit = defineEmits(['update:data']);
 
 const { t } = useI18n();
 
-const schemes = ['http', 'https', 'socks4', 'socks5'];
-
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });
 }

+ 2 - 1
src/components/newtab/workflow/edit/EditSwitchTo.vue

@@ -29,7 +29,8 @@
     >
       <ui-input
         :model-value="data.selector"
-        :placeholder="t('workflow.blocks.switch-to.iframeSelector')"
+        :label="t('workflow.blocks.switch-to.iframeSelector')"
+        placeholder="CSS Selector or XPath"
         autocomplete="off"
         class="mb-1 w-full"
         @change="updateData({ selector: $event })"

+ 25 - 336
src/components/newtab/workflow/edit/EditTrigger.vue

@@ -11,209 +11,32 @@
       :model-value="data.type || 'manual'"
       :placeholder="t('workflow.blocks.trigger.forms.triggerWorkflow')"
       class="w-full"
-      @change="handleSelectChange"
+      @change="updateData({ type: $event })"
     >
-      <option v-for="trigger in triggers" :key="trigger" :value="trigger">
+      <option v-for="(_, trigger) in triggers" :key="trigger" :value="trigger">
         {{ t(`workflow.blocks.trigger.items.${trigger}`) }}
       </option>
     </ui-select>
     <transition-expand mode="out-in">
-      <div v-if="data.type === 'interval'">
-        <div class="flex items-center mt-1">
-          <ui-input
-            :model-value="data.interval"
-            :label="t('workflow.blocks.trigger.forms.interval')"
-            type="number"
-            class="w-full"
-            placeholder="1-120"
-            min="1"
-            max="120"
-            @change="
-              updateIntervalInput($event, { key: 'interval', min: 1, max: 120 })
-            "
-          />
-          <ui-input
-            v-if="!data.fixedDelay"
-            :model-value="data.delay"
-            type="number"
-            class="w-full ml-2"
-            :label="t('workflow.blocks.trigger.forms.delay')"
-            min="0"
-            max="20"
-            placeholder="0-20"
-            @change="
-              updateIntervalInput($event, { key: 'delay', min: 0, max: 20 })
-            "
-          />
-        </div>
-        <ui-checkbox
-          :model-value="data.fixedDelay"
-          block
-          class="mt-2"
-          @change="updateData({ fixedDelay: $event })"
-        >
-          {{ t('workflow.blocks.trigger.fixedDelay') }}
-        </ui-checkbox>
-      </div>
-      <div v-else-if="data.type === 'date'" class="mt-2">
-        <ui-input
-          :model-value="data.date"
-          :max="maxDate"
-          :min="minDate"
-          :placeholder="t('workflow.blocks.trigger.forms.date')"
-          class="w-full"
-          type="date"
-          @change="updateDate({ date: $event })"
+      <keep-alive>
+        <component
+          :is="triggers[data.type]"
+          :data="data"
+          @update="updateData"
         />
-        <ui-input
-          :model-value="data.time"
-          :placeholder="t('workflow.blocks.trigger.forms.time')"
-          type="time"
-          class="w-full mt-2"
-          @change="updateData({ time: $event || '00:00' })"
-        />
-      </div>
-      <div v-else-if="data.type === 'specific-day'" class="mt-4">
-        <ui-popover
-          :options="{ animation: null }"
-          trigger-width
-          class="w-full mb-2"
-          trigger-class="w-full"
-        >
-          <template #trigger>
-            <ui-button class="w-full">
-              <p class="text-left flex-1 text-overflow mr-2">
-                {{
-                  tempDate.days.length === 0
-                    ? t('workflow.blocks.trigger.selectDay')
-                    : getDaysText(tempDate.days)
-                }}
-              </p>
-              <v-remixicon
-                size="28"
-                name="riArrowDropDownLine"
-                class="text-gray-600 dark:text-gray-200 -mr-2"
-              />
-            </ui-button>
-          </template>
-          <div class="grid gap-2 grid-cols-2">
-            <ui-checkbox
-              v-for="(day, id) in days"
-              :key="id"
-              :model-value="data.days?.includes(id)"
-              @change="onSelectDayChange($event, id)"
-            >
-              {{ t(`workflow.blocks.trigger.days.${id}`) }}
-            </ui-checkbox>
-          </div>
-        </ui-popover>
-        <div class="flex items-center">
-          <ui-input v-model="tempDate.time" type="time" class="flex-1 mr-2" />
-          <ui-button variant="accent" @click="addTime">
-            {{ t('workflow.blocks.trigger.addTime') }}
-          </ui-button>
-        </div>
-        <div class="my-2">
-          <ui-expand
-            v-for="(day, index) in sortedDaysArr"
-            :key="day.id"
-            header-class="focus:ring-0 flex items-center py-2 w-full group text-left"
-            type="time"
-            class="w-full"
-          >
-            <template #header>
-              <p class="flex-1">
-                {{ t(`workflow.blocks.trigger.days.${day.id}`) }}
-              </p>
-              <span class="text-gray-600 dark:text-gray-200">
-                <v-remixicon
-                  name="riDeleteBin7Line"
-                  class="mr-1 group invisible group-hover:visible inline-block"
-                  @click="daysArr.splice(index, 1)"
-                />
-                {{ day.times.length }}x
-              </span>
-            </template>
-            <div class="grid grid-cols-2 gap-1 mb-1">
-              <div
-                v-for="(time, timeIndex) in day.times"
-                :key="time"
-                class="flex items-center px-4 py-2 border rounded-lg group"
-              >
-                <span class="flex-1"> {{ formatTime(time) }} </span>
-                <v-remixicon
-                  name="riDeleteBin7Line"
-                  class="cursor-pointer"
-                  @click="removeDayTime(index, timeIndex)"
-                />
-              </div>
-            </div>
-          </ui-expand>
-        </div>
-      </div>
-      <div v-else-if="data.type === 'visit-web'" class="mt-2">
-        <ui-input
-          :model-value="data.url"
-          :placeholder="t('workflow.blocks.trigger.forms.url')"
-          class="w-full"
-          @change="updateData({ url: $event })"
-        />
-        <ui-checkbox
-          :model-value="data.isUrlRegex"
-          class="mt-1"
-          @change="updateData({ isUrlRegex: $event })"
-        >
-          {{ t('workflow.blocks.trigger.useRegex') }}
-        </ui-checkbox>
-      </div>
-      <div v-else-if="data.type === 'keyboard-shortcut'" class="mt-2">
-        <div class="flex items-center mb-2">
-          <ui-input
-            :model-value="recordKeys.keys"
-            readonly
-            class="flex-1 mr-2"
-            :placeholder="t('workflow.blocks.trigger.forms.shortcut')"
-          />
-          <ui-button
-            v-tooltip="
-              t(
-                `workflow.blocks.trigger.shortcut.${
-                  recordKeys.isRecording ? 'stopRecord' : 'tooltip'
-                }`
-              )
-            "
-            icon
-            @click="toggleRecordKeys"
-          >
-            <v-remixicon
-              :name="
-                recordKeys.isRecording ? 'riStopLine' : 'riRecordCircleLine'
-              "
-            />
-          </ui-button>
-        </div>
-        <ui-checkbox
-          :model-value="data.activeInInput"
-          class="mb-1"
-          :title="t('workflow.blocks.trigger.shortcut.checkboxTitle')"
-          @change="updateData({ activeInInput: $event })"
-        >
-          {{ t('workflow.blocks.trigger.shortcut.checkbox') }}
-        </ui-checkbox>
-        <p class="mt-4 leading-tight text-gray-600 dark:text-gray-200">
-          {{ t('workflow.blocks.trigger.shortcut.note') }}
-        </p>
-      </div>
+      </keep-alive>
     </transition-expand>
   </div>
 </template>
 <script setup>
-import { reactive, ref, computed, watch, onMounted, onUnmounted } from 'vue';
 import { useI18n } from 'vue-i18n';
-import { useToast } from 'vue-toastification';
-import dayjs from 'dayjs';
-import { isObject } from '@/utils/helper';
-import { recordShortcut } from '@/utils/recordKeys';
+import TriggerDate from './Trigger/TriggerDate.vue';
+import TriggerInterval from './Trigger/TriggerInterval.vue';
+import TriggerVisitWeb from './Trigger/TriggerVisitWeb.vue';
+import TriggerContextMenu from './Trigger/TriggerContextMenu.vue';
+import TriggerSpecificDay from './Trigger/TriggerSpecificDay.vue';
+// import TriggerElementChange from './Trigger/TriggerElementChange.vue';
+import TriggerKeyboardShortcut from './Trigger/TriggerKeyboardShortcut.vue';
 
 const props = defineProps({
   data: {
@@ -224,154 +47,20 @@ const props = defineProps({
 const emit = defineEmits(['update:data']);
 
 const { t } = useI18n();
-const toast = useToast();
 
-const triggers = [
-  'manual',
-  'interval',
-  'date',
-  'specific-day',
-  'on-startup',
-  'visit-web',
-  'keyboard-shortcut',
-];
-const days = {
-  0: 'Sunday',
-  1: 'Monday',
-  2: 'Tuesday',
-  3: 'Wednesday',
-  4: 'Thursday',
-  5: 'Friday',
-  6: 'Saturday',
+const triggers = {
+  manual: null,
+  interval: TriggerInterval,
+  'context-menu': TriggerContextMenu,
+  // 'element-change': TriggerElementChange,
+  date: TriggerDate,
+  'specific-day': TriggerSpecificDay,
+  'on-startup': null,
+  'visit-web': TriggerVisitWeb,
+  'keyboard-shortcut': TriggerKeyboardShortcut,
 };
-const maxDate = dayjs().add(30, 'day').format('YYYY-MM-DD');
-const minDate = dayjs().format('YYYY-MM-DD');
-
-const recordKeys = reactive({
-  isRecording: false,
-  keys: props.data.shortcut,
-});
-const tempDate = reactive({
-  days: [],
-  time: '00:00',
-});
-const daysArr = ref(null);
-
-const sortedDaysArr = computed(() =>
-  daysArr.value ? daysArr.value.slice().sort((a, b) => a.id - b.id) : []
-);
 
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });
 }
-function getDaysText(dayIds) {
-  return dayIds
-    .map((day) => t(`workflow.blocks.trigger.days.${day}`))
-    .join(', ');
-}
-function formatTime(time) {
-  const [hour, minute] = time.split(':');
-
-  return dayjs().hour(hour).minute(minute).format('hh:mm A');
-}
-function removeDayTime(index, timeIndex) {
-  daysArr.value[index].times.splice(timeIndex, 1);
-
-  if (daysArr.value[index].times.length === 0) {
-    daysArr.value.splice(index, 1);
-  }
-}
-function onSelectDayChange(value, id) {
-  if (value) tempDate.days.push(+id);
-  else tempDate.days.splice(tempDate.days.indexOf(+id), 1);
-}
-function addTime() {
-  tempDate.days.forEach((dayId) => {
-    const dayIndex = daysArr.value.findIndex(({ id }) => id === dayId);
-
-    if (dayIndex === -1) {
-      daysArr.value.push({
-        id: dayId,
-        times: [tempDate.time],
-      });
-    } else {
-      const isTimeExist = daysArr.value[dayIndex].times.includes(tempDate.time);
-
-      if (isTimeExist) {
-        const message = t('workflow.blocks.trigger.timeExist', {
-          time: formatTime(tempDate.time),
-          day: t(`workflow.blocks.trigger.days.${dayId}`),
-        });
-
-        toast.error(message);
-
-        return;
-      }
-
-      daysArr.value[dayIndex].times.push(tempDate.time);
-    }
-  });
-}
-function handleKeydownEvent(event) {
-  recordShortcut(event, (keys) => {
-    recordKeys.keys = keys.join('+');
-    updateData({ shortcut: recordKeys.keys });
-  });
-}
-function toggleRecordKeys() {
-  if (recordKeys.isRecording) {
-    window.removeEventListener('keydown', handleKeydownEvent);
-  } else {
-    window.addEventListener('keydown', handleKeydownEvent);
-  }
-
-  recordKeys.isRecording = !recordKeys.isRecording;
-}
-function handleSelectChange(type) {
-  if (recordKeys.isRecording) {
-    window.removeEventListener('keydown', handleKeydownEvent);
-    recordKeys.isRecording = false;
-  }
-
-  updateData({ type });
-}
-function updateIntervalInput(value, { key, min, max }) {
-  let num = +value;
-
-  if (num < min) num = min;
-  else if (num > max) num = max;
-
-  updateData({ [key]: num });
-}
-function updateDate(value) {
-  if (!value) return;
-
-  let date = value?.date ?? minDate;
-
-  if (dayjs(minDate).isAfter(date)) date = minDate;
-  else if (dayjs(maxDate).isBefore(date)) date = maxDate;
-
-  updateData({ date });
-}
-
-watch(
-  daysArr,
-  (value, oldValue) => {
-    if (!oldValue) return;
-
-    updateData({ days: value });
-  },
-  { deep: true }
-);
-
-onMounted(() => {
-  const isStringDay =
-    props.data.days.length > 0 && !isObject(props.data.days[0]);
-  daysArr.value = isStringDay
-    ? props.data.days.map((day) => ({ id: day, times: [props.data.time] }))
-    : props.data.days;
-});
-onUnmounted(() => {
-  window.removeEventListener('keydown', handleKeydownEvent);
-});
 </script>

+ 5 - 5
src/components/newtab/workflow/edit/EditTriggerEvent.vue

@@ -63,11 +63,11 @@ import { useI18n } from 'vue-i18n';
 import { eventList } from '@/utils/shared';
 import { toCamelCase } from '@/utils/helper';
 import EditInteractionBase from './EditInteractionBase.vue';
-import TriggerEventMouse from './TriggerEventMouse.vue';
-import TriggerEventTouch from './TriggerEventTouch.vue';
-import TriggerEventWheel from './TriggerEventWheel.vue';
-import TriggerEventInput from './TriggerEventInput.vue';
-import TriggerEventKeyboard from './TriggerEventKeyboard.vue';
+import TriggerEventMouse from './TriggerEvent/TriggerEventMouse.vue';
+import TriggerEventTouch from './TriggerEvent/TriggerEventTouch.vue';
+import TriggerEventWheel from './TriggerEvent/TriggerEventWheel.vue';
+import TriggerEventInput from './TriggerEvent/TriggerEventInput.vue';
+import TriggerEventKeyboard from './TriggerEvent/TriggerEventKeyboard.vue';
 
 const props = defineProps({
   data: {

+ 0 - 1
src/components/newtab/workflow/edit/EditWebhook.vue

@@ -20,7 +20,6 @@
       <ui-textarea
         :model-value="data.url"
         :label="`${t('workflow.blocks.webhook.url')}*`"
-        rows="1"
         placeholder="http://api.example.com"
         class="w-full"
         autocomplete="off"

+ 4 - 1
src/components/newtab/workflow/edit/OnBlockError.vue

@@ -1,6 +1,9 @@
 <template>
   <div class="on-block-error">
-    <ui-button @click="state.showModal = true">
+    <ui-button
+      :class="{ 'text-primary': state.data.enable }"
+      @click="state.showModal = true"
+    >
       <v-remixicon name="riShieldLine" class="-ml-1 mr-2" />
       <span>
         {{ t('workflow.blocks.base.onError.button') }}

+ 102 - 0
src/components/newtab/workflow/edit/Trigger/TriggerContextMenu.vue

@@ -0,0 +1,102 @@
+<template>
+  <div class="mt-4">
+    <template v-if="!permission.has[permissionName]">
+      <p>
+        {{ t('workflow.blocks.trigger.contextMenus.noPermission') }}
+      </p>
+      <ui-button class="mt-2" @click="permission.request(true)">
+        {{ t('workflow.blocks.trigger.contextMenus.grantPermission') }}
+      </ui-button>
+    </template>
+    <template v-else>
+      <ui-input
+        :label="t('workflow.blocks.trigger.contextMenus.contextName')"
+        :placeholder="workflow.data.value.name"
+        :model-value="data.contextMenuName"
+        class="w-full"
+        @change="$emit('update', { contextMenuName: $event })"
+      />
+      <ui-popover
+        :options="{ animation: null }"
+        trigger-width
+        class="w-full mt-2"
+        trigger-class="w-full"
+      >
+        <template #trigger>
+          <span class="text-sm ml-1 text-gray-600 dark:text-gray-200">
+            {{ t('workflow.blocks.trigger.contextMenus.appearIn') }}
+          </span>
+          <ui-button class="w-full">
+            <p class="text-left flex-1 text-overflow mr-2">
+              {{ data.contextTypes.join(', ') || 'All' }}
+            </p>
+            <v-remixicon
+              size="28"
+              name="riArrowDropDownLine"
+              class="text-gray-600 dark:text-gray-200 -mr-2"
+            />
+          </ui-button>
+        </template>
+        <div class="grid gap-2 grid-cols-2">
+          <ui-checkbox
+            v-for="type in types"
+            :key="type"
+            :model-value="data.contextTypes?.includes(type)"
+            @change="onSelectContextType($event, type)"
+          >
+            <span class="capitalize">{{ type }}</span>
+          </ui-checkbox>
+        </div>
+      </ui-popover>
+    </template>
+  </div>
+</template>
+<script setup>
+import { onMounted, inject } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useHasPermissions } from '@/composable/hasPermissions';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update']);
+
+const types = [
+  'audio',
+  'editable',
+  'image',
+  'link',
+  'page',
+  'password',
+  'selection',
+  'video',
+];
+const permissionName = BROWSER_TYPE === 'firefox' ? 'menus' : 'contextMenus';
+
+const { t } = useI18n();
+const permission = useHasPermissions([permissionName]);
+
+const workflow = inject('workflow');
+
+function onSelectContextType(selected, type) {
+  const contextTypes = [...props.data.contextTypes];
+
+  if (selected) {
+    contextTypes.push(type);
+  } else {
+    const index = contextTypes.indexOf(type);
+    contextTypes.splice(index, 1);
+  }
+
+  emit('update', { contextTypes });
+}
+
+onMounted(() => {
+  if (props.data.contextMenuName.trim()) return;
+
+  emit('update', { contextMenuName: workflow.data.value.name });
+});
+</script>

+ 47 - 0
src/components/newtab/workflow/edit/Trigger/TriggerDate.vue

@@ -0,0 +1,47 @@
+<template>
+  <div class="mt-2">
+    <ui-input
+      :model-value="data.date"
+      :max="maxDate"
+      :min="minDate"
+      :placeholder="t('workflow.blocks.trigger.forms.date')"
+      class="w-full"
+      type="date"
+      @change="updateDate({ date: $event })"
+    />
+    <ui-input
+      :model-value="data.time"
+      :placeholder="t('workflow.blocks.trigger.forms.time')"
+      type="time"
+      class="w-full mt-2"
+      @change="$emit('update', { time: $event || '00:00' })"
+    />
+  </div>
+</template>
+<script setup>
+import { useI18n } from 'vue-i18n';
+import dayjs from '@/lib/dayjs';
+
+defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update']);
+
+const { t } = useI18n();
+const maxDate = dayjs().add(30, 'day').format('YYYY-MM-DD');
+const minDate = dayjs().format('YYYY-MM-DD');
+
+function updateDate(value) {
+  if (!value) return;
+
+  let date = value?.date ?? minDate;
+
+  if (dayjs(minDate).isAfter(date)) date = minDate;
+  else if (dayjs(maxDate).isBefore(date)) date = maxDate;
+
+  emit('update', { date });
+}
+</script>

+ 130 - 0
src/components/newtab/workflow/edit/Trigger/TriggerElementChange.vue

@@ -0,0 +1,130 @@
+<template>
+  <div class="mt-4">
+    <ui-input
+      v-model="observeDetail.matchPattern"
+      :label="t('workflow.blocks.trigger.element-change.target')"
+      class="w-full"
+      placeholder="https://web.telegram.org/*"
+    >
+      <template #label>
+        {{ t('workflow.blocks.switch-tab.matchPattern') }}
+        <a
+          :title="t('workflow.blocks.trigger.element-change.targetWebsite')"
+          href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns#examples"
+          target="_blank"
+          rel="noopener"
+          class="inline-block"
+        >
+          <v-remixicon name="riInformationLine" class="info-icon" size="18" />
+        </a>
+      </template>
+    </ui-input>
+    <ui-input
+      v-model="observeDetail.baseSelector"
+      class="w-full mt-4"
+      placeholder="CSS Selector or XPath"
+    >
+      <template #label>
+        <span>
+          {{ t('workflow.blocks.trigger.element-change.baseEl.title') }}
+        </span>
+        <v-remixicon
+          :title="
+            t('workflow.blocks.trigger.element-change.baseEl.description')
+          "
+          name="riInformationLine"
+          class="info-icon"
+          size="18"
+        />
+      </template>
+    </ui-input>
+    <ui-expand header-class="w-full group flex items-center focus:ring-0">
+      <template #header>
+        {{ t('common.options') }}
+      </template>
+      <trigger-element-options v-model="observeDetail.baseElOptions" />
+    </ui-expand>
+    <ui-input
+      v-model="observeDetail.selector"
+      :label="t('workflow.blocks.trigger.element-change.target')"
+      class="w-full mt-4"
+      placeholder="CSS Selector or XPath"
+    />
+    <ui-expand header-class="w-full flex items-center focus:ring-0 group">
+      <template #header>
+        {{ t('common.options') }}
+        <v-remixicon
+          :title="t('workflow.blocks.trigger.element-change.optionsInfo')"
+          class="info-icon invisible group-hover:visible"
+          size="18"
+          name="riInformationLine"
+        />
+      </template>
+      <trigger-element-options
+        v-model="observeDetail.targetOptions"
+        show-desc
+      />
+    </ui-expand>
+  </div>
+</template>
+<script setup>
+import { onMounted, reactive, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import cloneDeep from 'lodash.clonedeep';
+import TriggerElementOptions from './TriggerElementOptions.vue';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update']);
+
+const { t } = useI18n();
+
+const state = reactive({
+  retrieved: false,
+});
+const observeDetail = reactive({
+  selector: '',
+  baseSelector: '',
+  matchPattern: '',
+  targetOptions: {
+    subtree: false,
+    childList: true,
+    attributes: false,
+    attributeFilter: [],
+    characterData: false,
+  },
+  baseElOptions: {
+    subtree: false,
+    childList: true,
+    attributes: false,
+    attributeFilter: [],
+    characterData: false,
+  },
+});
+
+watch(
+  observeDetail,
+  (value) => {
+    if (!state.retrieved) return;
+
+    emit('update', { observeElement: value });
+  },
+  { deep: true }
+);
+
+onMounted(() => {
+  Object.assign(observeDetail, cloneDeep(props.data.observeElement));
+  setTimeout(() => {
+    state.retrieved = true;
+  }, 100);
+});
+</script>
+<style>
+.info-icon {
+  @apply text-gray-600 dark:text-gray-300 inline-block;
+}
+</style>

+ 76 - 0
src/components/newtab/workflow/edit/Trigger/TriggerElementOptions.vue

@@ -0,0 +1,76 @@
+<template>
+  <ul class="space-y-2 mt-1">
+    <li v-for="option in types" :key="option" class="group">
+      <ui-checkbox
+        :model-value="modelValue[option]"
+        @change="
+          $emit('update:modelValue', { ...modelValue, [option]: $event })
+        "
+      >
+        {{ t(`workflow.blocks.trigger.element-change.${option}.title`) }}
+        <v-remixicon
+          :title="
+            t(`workflow.blocks.trigger.element-change.${option}.description`)
+          "
+          class="info-icon invisible group-hover:visible"
+          size="18"
+          name="riInformationLine"
+        />
+      </ui-checkbox>
+      <template v-if="option === 'attributes' && modelValue.attributes">
+        <ui-input
+          :model-value="modelValue.attributeFilter.join(',')"
+          :label="
+            t('workflow.blocks.trigger.element-change.subtree.description')
+          "
+          class="w-full block"
+          placeholder="id,label,class"
+          @change="onAttrFilterChange"
+        >
+          <template #label>
+            {{
+              t('workflow.blocks.trigger.element-change.attributeFilter.title')
+            }}
+            <v-remixicon
+              :title="
+                t(
+                  'workflow.blocks.trigger.element-change.attributeFilter.description'
+                )
+              "
+              class="info-icon"
+              size="18"
+              name="riInformationLine"
+            />
+          </template>
+        </ui-input>
+        <span class="text-sm text-gray-600 dark:text-gray-200">
+          {{
+            t('workflow.blocks.trigger.element-change.attributeFilter.separate')
+          }}
+        </span>
+      </template>
+    </li>
+  </ul>
+</template>
+<script setup>
+import { useI18n } from 'vue-i18n';
+import { debounce } from '@/utils/helper';
+
+const props = defineProps({
+  modelValue: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:modelValue']);
+
+const types = ['subtree', 'childList', 'attributes', 'characterData'];
+const { t } = useI18n();
+
+const onAttrFilterChange = debounce((value) => {
+  emit('update:modelValue', {
+    ...props.modelValue,
+    attributeFilter: value.split(','),
+  });
+}, 200);
+</script>

+ 59 - 0
src/components/newtab/workflow/edit/Trigger/TriggerInterval.vue

@@ -0,0 +1,59 @@
+<template>
+  <div>
+    <div class="flex items-center mt-1">
+      <ui-input
+        :model-value="data.interval"
+        :label="t('workflow.blocks.trigger.forms.interval')"
+        type="number"
+        class="w-full"
+        placeholder="1-120"
+        min="1"
+        max="120"
+        @change="
+          updateIntervalInput($event, { key: 'interval', min: 1, max: 120 })
+        "
+      />
+      <ui-input
+        v-if="!data.fixedDelay"
+        :model-value="data.delay"
+        type="number"
+        class="w-full ml-2"
+        :label="t('workflow.blocks.trigger.forms.delay')"
+        min="0"
+        max="20"
+        placeholder="0-20"
+        @change="updateIntervalInput($event, { key: 'delay', min: 0, max: 20 })"
+      />
+    </div>
+    <ui-checkbox
+      :model-value="data.fixedDelay"
+      block
+      class="mt-2"
+      @change="emit('update', { fixedDelay: $event })"
+    >
+      {{ t('workflow.blocks.trigger.fixedDelay') }}
+    </ui-checkbox>
+  </div>
+</template>
+<script setup>
+import { useI18n } from 'vue-i18n';
+
+defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update']);
+
+const { t } = useI18n();
+
+function updateIntervalInput(value, { key, min, max }) {
+  let num = +value;
+
+  if (num < min) num = min;
+  else if (num > max) num = max;
+
+  emit('update', { [key]: num });
+}
+</script>

+ 92 - 0
src/components/newtab/workflow/edit/Trigger/TriggerKeyboardShortcut.vue

@@ -0,0 +1,92 @@
+<template>
+  <div class="mt-2">
+    <div class="flex items-center mb-2">
+      <ui-input
+        :model-value="getReadableShortcut(recordKeys.keys)"
+        readonly
+        class="flex-1 mr-2"
+        :placeholder="t('workflow.blocks.trigger.forms.shortcut')"
+      />
+      <ui-button
+        v-tooltip="
+          t(
+            `workflow.blocks.trigger.shortcut.${
+              recordKeys.isRecording ? 'stopRecord' : 'tooltip'
+            }`
+          )
+        "
+        icon
+        @click="toggleRecordKeys"
+      >
+        <v-remixicon
+          :name="recordKeys.isRecording ? 'riStopLine' : 'riRecordCircleLine'"
+        />
+      </ui-button>
+    </div>
+    <ui-checkbox
+      :model-value="data.activeInInput"
+      class="mb-1"
+      :title="t('workflow.blocks.trigger.shortcut.checkboxTitle')"
+      @change="$emit('update', { activeInInput: $event })"
+    >
+      {{ t('workflow.blocks.trigger.shortcut.checkbox') }}
+    </ui-checkbox>
+    <p class="mt-4 leading-tight text-gray-600 dark:text-gray-200">
+      {{ t('workflow.blocks.trigger.shortcut.note') }}
+    </p>
+  </div>
+</template>
+<script setup>
+import { reactive, onBeforeUnmount, onDeactivated } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { recordShortcut } from '@/utils/recordKeys';
+import { getReadableShortcut } from '@/composable/shortcut';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update']);
+
+const { t } = useI18n();
+
+const recordKeys = reactive({
+  isRecording: false,
+  keys: `${props.data.shortcut}`,
+});
+
+function onKeydown(event) {
+  event.preventDefault();
+  event.stopPropagation();
+
+  recordShortcut(event, (keys) => {
+    recordKeys.keys = keys.join('+');
+    emit('update', { shortcut: recordKeys.keys });
+  });
+}
+function attachKeyEvents() {
+  window.addEventListener('keydown', onKeydown);
+  /* eslint-disable-next-line */
+  window.addEventListener('keyup', detachKeyEvents);
+}
+function detachKeyEvents() {
+  recordKeys.isRecording = false;
+
+  window.removeEventListener('keydown', onKeydown);
+  window.removeEventListener('keyup', detachKeyEvents);
+}
+function toggleRecordKeys() {
+  recordKeys.isRecording = !recordKeys.isRecording;
+
+  if (recordKeys.isRecording) {
+    attachKeyEvents();
+  } else {
+    detachKeyEvents();
+  }
+}
+
+onDeactivated(detachKeyEvents);
+onBeforeUnmount(detachKeyEvents);
+</script>

+ 185 - 0
src/components/newtab/workflow/edit/Trigger/TriggerSpecificDay.vue

@@ -0,0 +1,185 @@
+<template>
+  <div class="mt-4">
+    <ui-popover
+      :options="{ animation: null }"
+      trigger-width
+      class="w-full mb-2"
+      trigger-class="w-full"
+    >
+      <template #trigger>
+        <ui-button class="w-full">
+          <p class="text-left flex-1 text-overflow mr-2">
+            {{
+              tempDate.days.length === 0
+                ? t('workflow.blocks.trigger.selectDay')
+                : getDaysText(tempDate.days)
+            }}
+          </p>
+          <v-remixicon
+            size="28"
+            name="riArrowDropDownLine"
+            class="text-gray-600 dark:text-gray-200 -mr-2"
+          />
+        </ui-button>
+      </template>
+      <div class="grid gap-2 grid-cols-2">
+        <ui-checkbox
+          v-for="(day, id) in days"
+          :key="id"
+          :model-value="data.days?.includes(id)"
+          @change="onSelectDayChange($event, id)"
+        >
+          {{ t(`workflow.blocks.trigger.days.${id}`) }}
+        </ui-checkbox>
+      </div>
+    </ui-popover>
+    <div class="flex items-center">
+      <ui-input v-model="tempDate.time" type="time" class="flex-1 mr-2" />
+      <ui-button variant="accent" @click="addTime">
+        {{ t('workflow.blocks.trigger.addTime') }}
+      </ui-button>
+    </div>
+    <div class="my-2">
+      <ui-expand
+        v-for="(day, index) in sortedDaysArr"
+        :key="day.id"
+        header-class="focus:ring-0 flex items-center py-2 w-full group text-left"
+        type="time"
+        class="w-full"
+      >
+        <template #header>
+          <p class="flex-1">
+            {{ t(`workflow.blocks.trigger.days.${day.id}`) }}
+          </p>
+          <span class="text-gray-600 dark:text-gray-200">
+            <v-remixicon
+              name="riDeleteBin7Line"
+              class="mr-1 group invisible group-hover:visible inline-block"
+              @click="daysArr.splice(index, 1)"
+            />
+            {{ day.times.length }}x
+          </span>
+        </template>
+        <div class="grid grid-cols-2 gap-1 mb-1">
+          <div
+            v-for="(time, timeIndex) in day.times"
+            :key="time"
+            class="flex items-center px-4 py-2 border rounded-lg group"
+          >
+            <span class="flex-1"> {{ formatTime(time) }} </span>
+            <v-remixicon
+              name="riDeleteBin7Line"
+              class="cursor-pointer"
+              @click="removeDayTime(index, timeIndex)"
+            />
+          </div>
+        </div>
+      </ui-expand>
+    </div>
+  </div>
+</template>
+<script setup>
+import { reactive, computed, ref, watch, onMounted } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useToast } from 'vue-toastification';
+import dayjs from '@/lib/dayjs';
+import { isObject } from '@/utils/helper';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update']);
+
+const days = {
+  0: 'Sunday',
+  1: 'Monday',
+  2: 'Tuesday',
+  3: 'Wednesday',
+  4: 'Thursday',
+  5: 'Friday',
+  6: 'Saturday',
+};
+
+const { t } = useI18n();
+const toast = useToast();
+
+const daysArr = ref(null);
+const tempDate = reactive({
+  days: [],
+  time: '00:00',
+});
+
+const sortedDaysArr = computed(() =>
+  daysArr.value ? daysArr.value.slice().sort((a, b) => a.id - b.id) : []
+);
+
+function formatTime(time) {
+  const [hour, minute] = time.split(':');
+
+  return dayjs().hour(hour).minute(minute).format('hh:mm A');
+}
+function removeDayTime(index, timeIndex) {
+  daysArr.value[index].times.splice(timeIndex, 1);
+
+  if (daysArr.value[index].times.length === 0) {
+    daysArr.value.splice(index, 1);
+  }
+}
+function addTime() {
+  tempDate.days.forEach((dayId) => {
+    const dayIndex = daysArr.value.findIndex(({ id }) => id === dayId);
+
+    if (dayIndex === -1) {
+      daysArr.value.push({
+        id: dayId,
+        times: [tempDate.time],
+      });
+    } else {
+      const isTimeExist = daysArr.value[dayIndex].times.includes(tempDate.time);
+
+      if (isTimeExist) {
+        const message = t('workflow.blocks.trigger.timeExist', {
+          time: formatTime(tempDate.time),
+          day: t(`workflow.blocks.trigger.days.${dayId}`),
+        });
+
+        toast.error(message);
+
+        return;
+      }
+
+      daysArr.value[dayIndex].times.push(tempDate.time);
+    }
+  });
+}
+function onSelectDayChange(value, id) {
+  if (value) tempDate.days.push(+id);
+  else tempDate.days.splice(tempDate.days.indexOf(+id), 1);
+}
+function getDaysText(dayIds) {
+  return dayIds
+    .map((day) => t(`workflow.blocks.trigger.days.${day}`))
+    .join(', ');
+}
+
+watch(
+  daysArr,
+  (value, oldValue) => {
+    if (!oldValue) return;
+
+    emit('update', { days: value });
+  },
+  { deep: true }
+);
+
+onMounted(() => {
+  const isStringDay =
+    props.data.days.length > 0 && !isObject(props.data.days[0]);
+  daysArr.value = isStringDay
+    ? props.data.days.map((day) => ({ id: day, times: [props.data.time] }))
+    : props.data.days;
+});
+</script>

+ 30 - 0
src/components/newtab/workflow/edit/Trigger/TriggerVisitWeb.vue

@@ -0,0 +1,30 @@
+<template>
+  <div class="mt-2">
+    <ui-input
+      :model-value="data.url"
+      :placeholder="t('workflow.blocks.trigger.forms.url')"
+      class="w-full"
+      @change="$emit('update', { url: $event })"
+    />
+    <ui-checkbox
+      :model-value="data.isUrlRegex"
+      class="mt-1"
+      @change="$emit('update', { isUrlRegex: $event })"
+    >
+      {{ t('workflow.blocks.trigger.useRegex') }}
+    </ui-checkbox>
+  </div>
+</template>
+<script setup>
+import { useI18n } from 'vue-i18n';
+
+defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+defineEmits(['update']);
+
+const { t } = useI18n();
+</script>

+ 0 - 0
src/components/newtab/workflow/edit/TriggerEventInput.vue → src/components/newtab/workflow/edit/TriggerEvent/TriggerEventInput.vue


+ 0 - 0
src/components/newtab/workflow/edit/TriggerEventKeyboard.vue → src/components/newtab/workflow/edit/TriggerEvent/TriggerEventKeyboard.vue


+ 0 - 0
src/components/newtab/workflow/edit/TriggerEventMouse.vue → src/components/newtab/workflow/edit/TriggerEvent/TriggerEventMouse.vue


+ 0 - 0
src/components/newtab/workflow/edit/TriggerEventTouch.vue → src/components/newtab/workflow/edit/TriggerEvent/TriggerEventTouch.vue


+ 0 - 0
src/components/newtab/workflow/edit/TriggerEventWheel.vue → src/components/newtab/workflow/edit/TriggerEvent/TriggerEventWheel.vue


+ 42 - 1
src/components/newtab/workflow/settings/SettingsGeneral.vue

@@ -32,6 +32,30 @@
       </span>
     </div>
   </div>
+  <div class="flex items-center pt-4">
+    <div class="mr-4 flex-1">
+      <p>
+        {{ t('workflow.settings.notification.title') }}
+      </p>
+      <p class="text-gray-600 dark:text-gray-200 text-sm leading-tight">
+        {{
+          t(
+            `workflow.settings.notification.${
+              permissions.has.notifications ? 'description' : 'noPermission'
+            }`
+          )
+        }}
+      </p>
+    </div>
+    <ui-switch
+      v-if="permissions.has.notifications"
+      :model-value="settings.notification"
+      @change="updateSetting('notification', $event)"
+    />
+    <ui-button v-else @click="permissions.request(true)">
+      {{ t('workflow.blocks.clipboard.grantPermission') }}
+    </ui-button>
+  </div>
   <div
     v-for="item in settingItems"
     :key="item.id"
@@ -74,13 +98,29 @@
       {{ t('workflow.settings.clearCache.btn') }}
     </ui-button>
   </div>
+  <div class="flex items-center pt-4">
+    <div class="mr-4 flex-1">
+      <p>
+        {{ t('workflow.settings.publicId.title') }}
+      </p>
+      <p class="text-gray-600 dark:text-gray-200 text-sm leading-tight">
+        {{ t('workflow.settings.publicId.description') }}
+      </p>
+    </div>
+    <ui-input
+      :model-value="settings.publicId"
+      placeholder="myWorkflowPublicId"
+      @change="updateSetting('publicId', $event)"
+    />
+  </div>
 </template>
 <script setup>
 import { useI18n } from 'vue-i18n';
 import { useToast } from 'vue-toastification';
 import { clearCache } from '@/utils/helper';
+import { useHasPermissions } from '@/composable/hasPermissions';
 
-defineProps({
+const props = defineProps({
   settings: {
     type: Object,
     default: () => ({}),
@@ -90,6 +130,7 @@ const emit = defineEmits(['update']);
 
 const { t } = useI18n();
 const toast = useToast();
+const permissions = useHasPermissions(['notifications']);
 
 const browserType = BROWSER_TYPE;
 const onError = [

+ 167 - 0
src/components/newtab/workflows/WorkflowsFolder.vue

@@ -0,0 +1,167 @@
+<template>
+  <div class="mt-6 pt-4 border-t">
+    <div class="flex items-center text-gray-600 dark:text-gray-300">
+      <span class="flex-1"> Folders </span>
+      <button
+        class="dark:hover:text-gray-100 hover:text-black rounded-md transition"
+        @click="newFolder"
+      >
+        <v-remixicon
+          size="20"
+          name="riAddLine"
+          class="inline-block align-sub"
+        />
+        <span>{{ t('common.new') }}</span>
+      </button>
+    </div>
+    <ui-list class="mt-2 space-y-1">
+      <ui-list-item
+        small
+        class="cursor-pointer"
+        :active="modelValue === ''"
+        @dragover="onDragover($event, true)"
+        @dragleave="onDragover($event, false)"
+        @drop="onWorkflowsDrop($event, '')"
+        @click="$emit('update:modelValue', '')"
+      >
+        <v-remixicon name="riFolderLine" class="mr-2" />
+        <p class="flex-1 text-overflow">All</p>
+      </ui-list-item>
+      <ui-list-item
+        v-for="folder in folders"
+        :key="folder.id"
+        :active="folder.id === modelValue"
+        small
+        class="group overflow-hidden cursor-pointer"
+        @dragover="onDragover($event, true)"
+        @dragleave="onDragover($event, false)"
+        @drop="onWorkflowsDrop($event, folder.id)"
+        @click="$emit('update:modelValue', folder.id)"
+      >
+        <v-remixicon name="riFolderLine" class="mr-2" />
+        <p class="flex-1 text-overflow">
+          {{ folder.name }}
+        </p>
+        <ui-popover class="leading-none">
+          <template #trigger>
+            <v-remixicon
+              name="riMoreLine"
+              class="group-hover:visible cursor-pointer invisible"
+            />
+          </template>
+          <ui-list class="w-36 space-y-1">
+            <ui-list-item
+              v-close-popover
+              class="cursor-pointer"
+              @click="renameFolder(folder)"
+            >
+              <v-remixicon name="riPencilLine" class="mr-2 -ml-1" />
+              <span>
+                {{ t('common.rename') }}
+              </span>
+            </ui-list-item>
+            <ui-list-item
+              v-close-popover
+              class="cursor-pointer"
+              @click="deleteFolder(folder)"
+            >
+              <v-remixicon name="riDeleteBin7Line" class="mr-2 -ml-1" />
+              <span>
+                {{ t('common.delete') }}
+              </span>
+            </ui-list-item>
+          </ui-list>
+        </ui-popover>
+      </ui-list-item>
+    </ui-list>
+  </div>
+</template>
+<script setup>
+import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useDialog } from '@/composable/dialog';
+import { parseJSON } from '@/utils/helper';
+import Folder from '@/models/folder';
+import Workflow from '@/models/workflow';
+
+defineProps({
+  modelValue: {
+    type: String,
+    default: '',
+  },
+});
+const emit = defineEmits(['update:modelValue']);
+
+const { t } = useI18n();
+const dialog = useDialog();
+
+const folders = computed(() => Folder.query().orderBy('name', 'asc').get());
+
+function onDragover(event, toggle) {
+  const parent = event.target.closest('.ui-list-item');
+  if (!parent) return;
+
+  event.preventDefault();
+  parent.classList.toggle('ring-2', toggle);
+}
+function newFolder() {
+  dialog.prompt({
+    title: t('workflows.folder.new'),
+    placeholder: t('workflows.folder.name'),
+    okText: t('common.add'),
+    onConfirm(value) {
+      if (!value || !value.trim()) return false;
+
+      Folder.insert({
+        data: {
+          name: value,
+        },
+      });
+
+      return true;
+    },
+  });
+}
+function deleteFolder({ name, id }) {
+  dialog.confirm({
+    title: t('workflows.folder.delete'),
+    body: t('message.delete', { name }),
+    okText: t('common.delete'),
+    okVariant: 'danger',
+    onConfirm() {
+      Folder.delete(id);
+
+      emit('update:modelValue', '');
+    },
+  });
+}
+function renameFolder({ id, name }) {
+  dialog.prompt({
+    inputValue: name,
+    okText: t('common.rename'),
+    title: t('workflows.folder.rename'),
+    placeholder: t('workflows.folder.name'),
+    onConfirm(newName) {
+      if (!newName || !newName.trim()) return false;
+
+      Folder.update({
+        where: id,
+        data: { name: newName },
+      });
+
+      return true;
+    },
+  });
+}
+function onWorkflowsDrop({ dataTransfer }, folderId) {
+  const ids = parseJSON(dataTransfer.getData('workflows'), null);
+  if (!ids || !Array.isArray(ids)) return;
+
+  ids.forEach((id) => {
+    Workflow.update({
+      where: id,
+      data: { folderId },
+    });
+  });
+}
+</script>

+ 31 - 6
src/components/ui/UiAutocomplete.vue

@@ -18,10 +18,10 @@
       <ui-list-item
         v-for="(item, index) in filteredItems"
         :id="`list-item-${index}`"
-        :key="getItem(item)"
+        :key="getItem(item, true)"
         :class="{ 'bg-box-transparent': state.activeIndex === index }"
         class="cursor-pointer"
-        @mousedown="selectItem(index)"
+        @mousedown="selectItem(index, true)"
         @mouseenter="state.activeIndex = index"
       >
         <slot name="item" :item="item">
@@ -56,6 +56,10 @@ const props = defineProps({
     type: String,
     default: '',
   },
+  itemLabel: {
+    type: String,
+    default: '',
+  },
   triggerChar: {
     type: Array,
     default: () => [],
@@ -72,7 +76,14 @@ const props = defineProps({
   disabled: Boolean,
   hideEmpty: Boolean,
 });
-const emit = defineEmits(['update:modelValue', 'change', 'search']);
+const emit = defineEmits([
+  'update:modelValue',
+  'change',
+  'search',
+  'select',
+  'cancel',
+  'selected',
+]);
 
 let input = null;
 const { t } = useI18n();
@@ -86,7 +97,8 @@ const state = shallowReactive({
   inputChanged: false,
 });
 
-const getItem = (item) => item[props.itemLabel] || item;
+const getItem = (item, key) =>
+  item[key ? props.itemKey : props.itemLabel] || item;
 
 const filteredItems = computed(() => {
   if (!state.showPopover) return [];
@@ -185,7 +197,7 @@ function updateValue(value) {
   input.value = value;
   input.dispatchEvent(new Event('input'));
 }
-function selectItem(itemIndex) {
+function selectItem(itemIndex, selected) {
   let selectedItem = filteredItems.value[itemIndex];
 
   if (!selectedItem) return;
@@ -226,6 +238,13 @@ function selectItem(itemIndex) {
 
   updateValue(selectedItem);
 
+  if (selected) {
+    emit('selected', {
+      index: itemIndex,
+      item: filteredItems.value[itemIndex],
+    });
+  }
+
   if (isTriggerChar) {
     input.selectionEnd = caretPosition;
     const isNotTextarea = input.tagName !== 'TEXTAREA';
@@ -250,10 +269,11 @@ function handleKeydown(event) {
 
     event.preventDefault();
   } else if (event.key === 'Enter' && state.showPopover) {
-    selectItem(state.activeIndex);
+    selectItem(state.activeIndex, true);
 
     event.preventDefault();
   } else if (event.key === 'Escape') {
+    emit('cancel');
     state.showPopover = false;
   }
 }
@@ -299,6 +319,11 @@ watch(
         behavior: 'smooth',
       });
     }
+
+    emit('select', {
+      index: activeIndex,
+      item: filteredItems.value[activeIndex],
+    });
   }, 100)
 );
 watch(

+ 9 - 1
src/composable/hasPermissions.js

@@ -7,7 +7,7 @@ export function useHasPermissions(permissions) {
   function handlePermission(name, status) {
     hasPermissions[name] = status;
   }
-  function request() {
+  function request(needReload = false) {
     const reqPermissions = permissions.filter(
       (permission) => !hasPermissions[permission]
     );
@@ -20,6 +20,14 @@ export function useHasPermissions(permissions) {
         reqPermissions.forEach((permission) => {
           handlePermission(permission, true);
         });
+
+        if (needReload) {
+          alert('Automa needs to reload to make this feature work');
+          browser.runtime.reload();
+        }
+      })
+      .catch((error) => {
+        console.error(error);
       });
   }
 

+ 8 - 2
src/composable/shortcut.js

@@ -36,6 +36,10 @@ const defaultShortcut = {
     id: 'editor:duplicate-block',
     combo: 'mod+option+d',
   },
+  'editor:search-blocks': {
+    id: 'editor:search-blocks',
+    combo: 'mod+b',
+  },
   'editor:save': {
     id: 'editor:save',
     combo: 'mod+shift+s',
@@ -99,10 +103,12 @@ export function useShortcut(shortcuts, handler) {
       ...extractedShortcuts.data[shortcutId],
     };
 
+    if (shortcutId) event.preventDefault();
+
     if (typeof params.data === 'function') {
       params.data(params);
-    } else {
-      handler?.(params);
+    } else if (handler) {
+      handler(params);
     }
   };
   const addShortcutData = ({ combo, id, readable, ...rest }) => {

+ 1 - 1
src/content/blocksHandler/handlerForms.js

@@ -24,7 +24,7 @@ async function forms(block) {
   if (data.multiple) {
     const promises = Array.from(elements).map(async (element) => {
       markElement(element, block);
-      await handleFormElement(element, data, eventResolve);
+      await handleFormElement(element, data);
     });
 
     await Promise.allSettled(promises);

+ 4 - 2
src/content/blocksHandler/handlerJavascriptCode.js

@@ -1,9 +1,11 @@
-import { nanoid } from 'nanoid/non-secure';
+import { customAlphabet } from 'nanoid/non-secure';
 import { sendMessage } from '@/utils/message';
 import { automaRefDataStr } from '../utils';
 
+const nanoid = customAlphabet('1234567890abcdef', 5);
+
 function getAutomaScript(refData, everyNewTab) {
-  const varName = `automa${nanoid(5)}`;
+  const varName = `automa${nanoid()}`;
 
   let str = `
 const ${varName} = ${JSON.stringify(refData)};

+ 1 - 1
src/content/blocksHandler/handlerPressKey.js

@@ -61,9 +61,9 @@ function pressKeyWithJs(element, keys) {
 
       if (isEditable || isTextField) {
         const isDigit = /^[0-9]$/.test(key);
+        const contentKey = isEditable ? 'textContent' : 'value';
 
         if (isLetter || isDigit) {
-          const contentKey = isEditable ? 'textContent' : 'value';
           element[contentKey] += key;
 
           return;

+ 3 - 0
src/content/blocksHandler/handlerSwitchTo.js

@@ -1,7 +1,10 @@
+import { isXPath } from '@/utils/helper';
 import handleSelector from '../handleSelector';
 
 function switchTo(block) {
   return new Promise((resolve, reject) => {
+    block.data.findBy = isXPath(block.data.selector) ? 'xpath' : 'cssSelector';
+
     handleSelector(block, {
       onSelected(element) {
         if (element.tagName !== 'IFRAME') {

+ 111 - 0
src/content/elementObserver.js

@@ -0,0 +1,111 @@
+import browser from 'webextension-polyfill';
+import { isXPath, debounce } from '@/utils/helper';
+import { sendMessage } from '@/utils/message';
+import FindElement from '@/utils/FindElement';
+
+const observeElements = {};
+
+const targetMutationCallback = debounce(([{ target }]) => {
+  let workflowId = target.getAttribute('automa-id');
+
+  if (!workflowId) {
+    const element = target.closest('[automa-id]');
+    if (!element) return;
+    workflowId = element.getAttribute('automa-id');
+  }
+  if (!observeElements[workflowId]) return;
+
+  const { workflow } = observeElements[workflowId];
+  workflow.includeTabId = true;
+
+  sendMessage('workflow:execute', workflow, 'background');
+}, 250);
+const targetObserver = new MutationObserver(targetMutationCallback);
+
+const baseMutationCallback = debounce(() => {
+  targetObserver.disconnect();
+  Object.values(observeElements).forEach((detail) => {
+    /* eslint-disable-next-line */
+    tryObserve({ ...detail, observer: targetObserver });
+  });
+}, 250);
+const baseObserver = new MutationObserver(baseMutationCallback);
+
+export function matchPatternToRegex(str) {
+  const regexStr = str.replace(/[*?^$]/g, (char) => {
+    if (char === '*') return '[a-zA-Z0-9]*';
+
+    return `\\${char}`;
+  });
+  const regex = new RegExp(regexStr);
+
+  return regex;
+}
+function tryObserve({ selector, observer, options, id }) {
+  let tryCount = 0;
+
+  const findElement = () => {
+    if (tryCount > 10) return;
+
+    const selectorType = isXPath(selector) ? 'xpath' : 'cssSelector';
+    const element = FindElement[selectorType]({ selector });
+
+    if (!element) {
+      tryCount += 1;
+      setTimeout(findElement, 1000);
+      return;
+    }
+
+    if (id) element.setAttribute('automa-id', id);
+
+    if (!options.attributes || options.attributeFilter.length === 0)
+      delete options.attributeFilter;
+    observer.observe(element, options);
+  };
+
+  findElement();
+}
+
+export default async function () {
+  const { workflows } = await browser.storage.local.get('workflows');
+  workflows.forEach(({ trigger, id, ...workflowDetail }) => {
+    if (
+      !trigger ||
+      trigger.type !== 'element-change' ||
+      !trigger.observeElement?.selector ||
+      !trigger.observeElement?.matchPattern
+    )
+      return;
+
+    const {
+      baseSelector,
+      baseElOptions,
+      selector,
+      targetOptions,
+      matchPattern,
+    } = trigger.observeElement;
+
+    const regex = matchPatternToRegex(matchPattern);
+    if (!regex.test(window.location.href)) return;
+
+    if (baseSelector)
+      tryObserve({
+        selector: baseSelector,
+        options: baseElOptions,
+        observer: baseObserver,
+      });
+
+    observeElements[id] = {
+      id,
+      selector,
+      options: targetOptions,
+      workflow: { id, trigger, ...workflowDetail },
+    };
+    tryObserve({
+      selector,
+      options: targetOptions,
+      observer: targetObserver,
+      id,
+    });
+  });
+}

+ 54 - 0
src/content/elementSelector/App.vue

@@ -50,6 +50,7 @@
           v-model:selectList="state.selectList"
           :selector="state.elSelector"
           :selected-count="state.selectedElements.length"
+          @selector="updateSelector"
           @parent="selectElementPath('up')"
           @child="selectElementPath('down')"
         />
@@ -79,7 +80,9 @@
 </template>
 <script setup>
 import { reactive, ref, watch, inject, onMounted, onBeforeUnmount } from 'vue';
+import { debounce } from '@/utils/helper';
 import { finder } from '@medv/finder';
+import FindElement from '@/utils/FindElement';
 import SelectorQuery from '@/components/content/selector/SelectorQuery.vue';
 import SharedElementSelector from '@/components/content/shared/SharedElementSelector.vue';
 import SelectorElementsDetail from '@/components/content/selector/SelectorElementsDetail.vue';
@@ -120,6 +123,50 @@ const cardElementObserver = new ResizeObserver(([entry]) => {
   cardRect.height = height;
 });
 
+const updateSelector = debounce((selector) => {
+  let frameSelector;
+  let elSelector = selector;
+
+  if (selector.includes('|>')) {
+    [frameSelector, elSelector] = selector.split(/\|>(.+)/);
+  }
+
+  const selectorType = state.selectorType === 'css' ? 'cssSelector' : 'xpath';
+
+  try {
+    if (frameSelector) {
+      const frame = FindElement[selectorType]({
+        selector: frameSelector,
+        multiple: false,
+      });
+      if (!['IFRAME', 'FRAME'].includes(frame.tagName)) return;
+
+      const { top, left } = frame.getBoundingClientRect();
+      frame.contentWindow.postMessage(
+        {
+          selectorType,
+          selector: elSelector,
+          type: 'automa:find-element',
+          frameRect: { top, left },
+        },
+        '*'
+      );
+      return;
+    }
+
+    const elements = FindElement[selectorType]({
+      selector: elSelector,
+      multiple: true,
+    });
+    state.selectedElements = Array.from(elements || []).map((el) =>
+      getElementRect(el, true)
+    );
+  } catch (error) {
+    console.error(error);
+    state.selectedElements = [];
+  }
+}, 200);
+
 function toggleHighlightElement({ index, highlight }) {
   state.selectedElements[index].highlight = highlight;
 }
@@ -180,6 +227,11 @@ function selectElementPath(type) {
 function onMouseup() {
   if (state.isDragging) state.isDragging = false;
 }
+function onMessage({ data }) {
+  if (data.type !== 'automa:selected-elements') return;
+
+  state.selectedElements = data.elements;
+}
 function destroy() {
   rootElement.style.display = 'none';
 
@@ -203,12 +255,14 @@ function destroy() {
 function attachListeners() {
   cardElementObserver.observe(cardEl.value);
 
+  window.addEventListener('message', onMessage);
   window.addEventListener('mouseup', onMouseup);
   window.addEventListener('mousemove', onMousemove);
 }
 function detachListeners() {
   cardElementObserver.disconnect();
 
+  window.removeEventListener('message', onMessage);
   window.removeEventListener('mouseup', onMouseup);
   window.removeEventListener('mousemove', onMousemove);
 }

+ 1 - 26
src/content/elementSelector/index.js

@@ -1,39 +1,14 @@
-import browser from 'webextension-polyfill';
+import { elementSelectorInstance } from '../utils';
 import initElementSelector from './main';
 import injectAppStyles from '../injectAppStyles';
 import selectorFrameContext from './selectorFrameContext';
 
-function elementSelectorInstance() {
-  const rootElementExist = document.querySelector(
-    '#app-container.automa-element-selector'
-  );
-
-  if (rootElementExist) {
-    rootElementExist.style.display = 'block';
-
-    return true;
-  }
-
-  return false;
-}
-
 (async function () {
-  browser.runtime.onMessage.addListener((data) => {
-    return new Promise((resolve) => {
-      if (data.type === 'automa-element-selector') {
-        elementSelectorInstance();
-
-        resolve(true);
-      }
-    });
-  });
-
   try {
     const isMainFrame = window.self === window.top;
 
     if (isMainFrame) {
       const isAppExists = elementSelectorInstance();
-
       if (isAppExists) return;
 
       const rootElement = document.createElement('div');

+ 36 - 3
src/content/elementSelector/selectorFrameContext.js

@@ -1,3 +1,4 @@
+import FindElement from '@/utils/FindElement';
 import { getElementRect } from '../utils';
 import findElementList from './listSelector';
 import generateElementsSelector from './generateElementsSelector';
@@ -76,10 +77,42 @@ function resetElementSelector(data) {
     prevSelectedElement = null;
   }
 }
+function findElement({ selector, selectorType, frameRect }) {
+  const payload = {
+    elements: [],
+    type: 'automa:selected-elements',
+  };
+
+  try {
+    const elements = FindElement[selectorType]({ multiple: true, selector });
+
+    payload.elements = Array.from(elements || []).map((el) =>
+      getElementRectWithOffset(el, {
+        withAttributes: true,
+        click: true,
+        ...frameRect,
+      })
+    );
+  } catch (error) {
+    console.error(error);
+    payload.elements = [];
+  }
+
+  window.top.postMessage(payload, '*');
+}
 function onMessage({ data }) {
-  if (data.type === 'automa:get-element-rect') getElementsRect(data);
-  else if (data.type === 'automa:reset-element-selector')
-    resetElementSelector(data);
+  switch (data.type) {
+    case 'automa:get-element-rect':
+      getElementsRect(data);
+      break;
+    case 'automa:reset-element-selector':
+      resetElementSelector(data);
+      break;
+    case 'automa:find-element':
+      findElement(data);
+      break;
+    default:
+  }
 }
 
 export default function () {

+ 4 - 2
src/content/handleSelector.js

@@ -1,5 +1,5 @@
 import FindElement from '@/utils/FindElement';
-import { visibleInViewport } from '@/utils/helper';
+import { visibleInViewport, isXPath } from '@/utils/helper';
 
 /* eslint-disable consistent-return */
 
@@ -13,7 +13,9 @@ export function getDocumentCtx(frameSelector) {
   let documentCtx = document;
 
   if (frameSelector) {
-    documentCtx = document.querySelector(frameSelector)?.contentDocument;
+    const type = isXPath(frameSelector) ? 'xpath' : 'cssSelector';
+    const element = FindElement[type]({ selector: frameSelector });
+    documentCtx = element?.contentDocument;
   }
 
   return documentCtx;

+ 4 - 2
src/content/handleTestCondition.js

@@ -1,8 +1,10 @@
-import { nanoid } from 'nanoid/non-secure';
+import { customAlphabet } from 'nanoid/non-secure';
 import { visibleInViewport, isXPath } from '@/utils/helper';
 import FindElement from '@/utils/FindElement';
 import { automaRefDataStr } from './utils';
 
+const nanoid = customAlphabet('1234567890abcdef', 5);
+
 function handleConditionElement({ data, type }) {
   const selectorType = isXPath(data.selector) ? 'xpath' : 'cssSelector';
 
@@ -44,7 +46,7 @@ function handleConditionElement({ data, type }) {
 }
 function injectJsCode({ data, refData }) {
   return new Promise((resolve, reject) => {
-    const varName = `automa${nanoid(5)}`;
+    const varName = `automa${nanoid()}`;
 
     const scriptEl = document.createElement('script');
     scriptEl.textContent = `

+ 25 - 1
src/content/index.js

@@ -1,9 +1,12 @@
 import browser from 'webextension-polyfill';
+import { finder } from '@medv/finder';
 import { toCamelCase } from '@/utils/helper';
 import blocksHandler from './blocksHandler';
 import showExecutedBlock from './showExecutedBlock';
 import handleTestCondition from './handleTestCondition';
 import shortcutListener from './services/shortcutListener';
+// import elementObserver from './elementObserver';
+import { elementSelectorInstance } from './utils';
 
 const isMainFrame = window.self === window.top;
 
@@ -132,7 +135,17 @@ function messageListener({ data, source }) {
   window.isAutomaInjected = true;
   window.addEventListener('message', messageListener);
 
-  if (isMainFrame) shortcutListener();
+  let contextElement = null;
+  let $ctxTextSelection = '';
+
+  if (isMainFrame) {
+    shortcutListener();
+    window.addEventListener('contextmenu', ({ target }) => {
+      contextElement = target;
+      $ctxTextSelection = window.getSelection().toString();
+    });
+    // window.addEventListener('load', elementObserver);
+  }
 
   browser.runtime.onMessage.addListener((data) => {
     return new Promise((resolve, reject) => {
@@ -154,6 +167,17 @@ function messageListener({ data, source }) {
             resolve(selectorInstance);
             break;
           }
+          case 'context-element': {
+            let $ctxElSelector = '';
+
+            if (contextElement) {
+              $ctxElSelector = finder(contextElement);
+              contextElement = null;
+            }
+
+            resolve({ $ctxElSelector, $ctxTextSelection });
+            break;
+          }
           default:
             resolve(null);
         }

+ 0 - 1
src/content/services/recordWorkflow/recordEvents.js

@@ -95,7 +95,6 @@ function onChange({ target }) {
       id: 'trigger-event',
       data: {
         selector,
-        description,
         eventName: 'change',
         eventType: 'event',
         waitForSelector: true,

+ 11 - 4
src/content/services/shortcutListener.js

@@ -10,9 +10,10 @@ function automaCustomEventListener(findWorkflow) {
   window.addEventListener(
     'automa:execute-workflow',
     ({ detail }) => {
-      if (!detail || !detail.id) return;
+      if (!detail || (!detail.id && !detail.publicId)) return;
 
-      const workflow = findWorkflow(detail.id);
+      const workflowId = detail.id || detail.publicId;
+      const workflow = findWorkflow(workflowId, Boolean(detail.publicId));
 
       if (!workflow) return;
 
@@ -64,8 +65,14 @@ export default async function () {
         'workflows',
         'workflowHosts',
       ]);
-    const findWorkflow = (id) => {
-      let workflow = workflows.find((item) => item.id === id);
+    const findWorkflow = (id, publicId = false) => {
+      let workflow = workflows.find((item) => {
+        if (publicId) {
+          return item.settings.publicId === id;
+        }
+
+        return item.id === id;
+      });
 
       if (!workflow) {
         workflow = Object.values(workflowHosts || {}).find(

+ 14 - 2
src/content/utils.js

@@ -1,3 +1,17 @@
+export function elementSelectorInstance() {
+  const rootElementExist = document.querySelector(
+    '#app-container.automa-element-selector'
+  );
+
+  if (rootElementExist) {
+    rootElementExist.style.display = 'block';
+
+    return true;
+  }
+
+  return false;
+}
+
 export function getElementRect(target, withAttributes) {
   if (!target) return {};
 
@@ -109,8 +123,6 @@ function messageTopFrame(windowCtx) {
     };
 
     timeout = setTimeout(() => {
-      if (isResolved) return;
-
       windowCtx.removeEventListener('message', messageListener);
       resolve(null);
     }, 5000);

+ 6 - 5
src/lib/drawflow.js

@@ -8,15 +8,16 @@ export default function (element, { context, options = {} }) {
   const editor = new Drawflow(element, { render, version: 3, h }, context);
 
   editor.useuuid = true;
-  editor.translate_to = function (x, y) {
+  editor.translate_to = function (x, y, zoom) {
+    if (typeof x !== 'number' || typeof y !== 'number') return;
+
     this.canvas_x = x;
     this.canvas_y = y;
 
-    const storedZoom = this.zoom;
-
     this.zoom = 1;
-    this.precanvas.style.transform = `translate("${this.canvas_x}"px, "${this.canvas_y}"px) scale("${this.zoom}")`;
-    this.zoom = storedZoom;
+
+    this.precanvas.style.transform = `translate(${this.canvas_x}px, ${this.canvas_y}px) scale(${this.zoom})`;
+    this.zoom = zoom;
     this.zoom_last_value = 1;
     this.zoom_refresh();
   };

+ 2 - 0
src/lib/vRemixicon.js

@@ -104,6 +104,7 @@ import {
   riFileDownloadLine,
   riShieldKeyholeLine,
   riArrowDropDownLine,
+  riNotification3Line,
   riArrowLeftRightLine,
   riArrowGoForwardLine,
   riCheckboxCircleLine,
@@ -215,6 +216,7 @@ export const icons = {
   riFileDownloadLine,
   riShieldKeyholeLine,
   riArrowDropDownLine,
+  riNotification3Line,
   riArrowLeftRightLine,
   riArrowGoForwardLine,
   riCheckboxCircleLine,

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

@@ -83,6 +83,14 @@
         "specificFlow": "Only continue a specific flow",
         "selectFlow": "Select flow"
       },
+      "notification": {
+        "name": "notification",
+        "description": "Display a notification",
+        "title": "Title",
+        "message": "Message",
+        "imageUrl": "Image URL (optional)",
+        "iconUrl": "Icon URL (optional)"
+      },
       "delete-data": {
         "name": "Delete data",
         "description": "Delete table or variable data",
@@ -173,6 +181,12 @@
         "selectDay": "Select day",
         "timeExist": "You alread add {time} on {day}",
         "fixedDelay": "Fixed delay",
+        "contextMenus": {
+          "noPermission": "This trigger requires \"contextMenus\" permission to be working",
+          "grantPermission": "Grant permission",
+          "appearIn": "Will appear in",
+          "contextName": "Workflow name in the context menu"
+        },
         "days": [
           "Sunday",
           "Monday",
@@ -199,10 +213,42 @@
           "url": "URL or Regex",
           "shortcut": "Shortcut"
         },
+        "element-change": {
+          "target": "Target element to observe",
+          "optionsInfo": "Which element mutation will trigger the workflow",
+          "targetWebsite": "The Match Pattern of the website where the target element is (click to see more Match Pattern examples)",
+          "baseEl": {
+            "title": "Base element (optional)",
+            "description": "Automa will restart observing the target element when this element changed"
+          },
+          "subtree": {
+            "title": "Include subtree",
+            "description": "Extend monitoring to the entire subtree of the target element"
+          },
+          "childList": {
+            "title": "Child list",
+            "description": "Monitor for the addition of new child elements or removal of existing child elements"
+          },
+          "attributes": {
+            "title": "Attributes",
+            "description": "Watch for changes to the value of attributes on the target element"
+          },
+          "attributeFilter": {
+            "title": "Attribute filter",
+            "separate": "Use commas(,) to separate attribute name",
+            "description": "Only monitor specific attributes (leave blank to monitor all)"
+          },
+          "characterData": {
+            "title": "Character data",
+            "description": "Monitor changes to the character data/text within the target element"
+          },
+        },
         "items": {
           "manual": "Manually",
           "interval": "Interval",
           "date": "On a specific date",
+          "context-menu": "Context menu",
+          "element-change": "When element change",
           "specific-day": "On a specific day",
           "visit-web": "When visit a website",
           "on-startup": "On browser startup",
@@ -213,7 +259,7 @@
         "name": "Execute workflow",
         "overwriteNote": "This will overwrite the global data of the selected workflow",
         "select": "Select workflow",
-        "executeId": "Execute Id",
+        "executeId": "Execute Id (optional)",
         "description": ""
       },
       "google-sheets": {

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

@@ -5,6 +5,8 @@
     "collection": "Collection | Collections",
     "log": "Log | Logs",
     "block": "Block | Blocks",
+    "folder": "Folder | Folders",
+    "new": "New",
     "docs": "Documentation",
     "search": "Search",
     "example": "Example | Examples",

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

@@ -12,6 +12,14 @@
     "text1": "Automa has been updated to v{version},",
     "text2": "see what's new."
   },
+  "workflows": {
+    "folder": {
+      "new": "New folder",
+      "name": "Folder name",
+      "delete": "Delete folder",
+      "rename": "Rename folder"
+    }
+  },
   "auth": {
     "title": "Auth",
     "signIn": "Sign in",
@@ -106,6 +114,9 @@
     "clickToEnable": "Click to enable",
     "toggleSidebar": "Toggle sidebar",
     "cantEdit": "Can't edit shared workflow",
+    "searchBlocks": {
+      "title": "Search blocks in the editor"
+    },
     "conditionBuilder": {
       "title": "Condition builder",
       "add": "Add condition",
@@ -193,6 +204,15 @@
     "settings": {
       "saveLog": "Save workflow log",
       "executedBlockOnWeb": "Show executed block on web page",
+      "notification": {
+        "title": "Workflow notification",
+        "description": "Show workflow status(success or failed) after it executed",
+        "noPermission": "Automa requires \"notifications\" permission to make this work"
+      },
+      "publicId": {
+        "title": "Workflow public Id",
+        "description": "Use this public id to execute workflow using JS custom event"
+      },
       "defaultColumn": {
         "title": "Insert into the default column",
         "description": "Insert data to the default column if there's no column selected in the block",
@@ -300,6 +320,7 @@
       "no-clipboard-acces": "Don't have permission to access clipboard",
       "browser-not-supported": "This feature not supported in {browser} browser",
       "element-not-found": "Can't find an element with \"{selector}\" selector.",
+      "no-permission": "Don't have \"{permission}\" permission to do this action",
       "not-iframe": "Element with \"{selector}\" selector is not an Iframe element",
       "iframe-not-found": "Can't find an Iframe element with \"{selector}\" selector.",
       "workflow-infinite-loop": "Can't execute the workflow to prevent an infinite loop",

+ 61 - 3
src/locales/zh/blocks.json

@@ -13,6 +13,7 @@
         "title": "模块",
         "moveToGroup": "移动模块到模块组",
         "selector": "元素选择器",
+        "selectorOptions": "选择器选项",
         "timeout": "超时 (毫秒)",
         "toggle": {
           "enable": "启用模块",
@@ -82,6 +83,14 @@
         "specificFlow": "只继续一个特定的流程",
         "selectFlow": "选择流程"
       },
+      "notification": {
+        "name": "通知",
+        "description": "显示通知",
+        "title": "标题",
+        "message": "消息",
+        "imageUrl": "图片 URL (可选项)",
+        "iconUrl": "图标 URL (可选项)"
+      },
       "delete-data": {
         "name": "删除数据",
         "description": "删除表格或变量",
@@ -92,6 +101,13 @@
         "name": "重载标签页",
         "description": "重新加载激活的标签页"
       },
+      "press-key": {
+        "name": "按键",
+        "description": "按键或组合键",
+        "target": "目标元素 (可选项)",
+        "key": "Key",
+        "detect": "Detect key"
+      },
       "save-assets": {
         "name": "保存资源",
         "description": "保存资源 (图像, 视频, 音频, 或文件) 从一个元素或 URL",
@@ -165,6 +181,12 @@
         "selectDay": "选择星期几",
         "timeExist": "你已经添加 {time} 在 {day}",
         "fixedDelay": "固定延迟",
+        "contextMenus": {
+          "noPermission": "此触发器需要 \"contextMenus\" 权限才能工作",
+          "grantPermission": "授予权限",
+          "appearIn": "会使",
+          "contextName": "工作流名称出现在上下文菜单中"
+        },
         "days": [
           "星期日",
           "星期一",
@@ -191,12 +213,44 @@
           "url": "URL 或 正则表达式",
           "shortcut": "快捷键"
         },
+        "element-change": {
+          "target": "要监测的目标元素",
+          "optionsInfo": "哪个元素突变会触发工作流",
+          "targetWebsite": "目标元素所在网站的匹配模式(点击查看更多匹配模式示例)",
+          "baseEl": {
+            "title": "基础元素 (可选项)",
+            "description": "当此元素发生变化时,Automa 将重新开始监测目标元素"
+          },
+          "subtree": {
+            "title": "包含子树",
+            "description": "将监测扩展到目标元素的整个子树"
+          },
+          "childList": {
+            "title": "子列表",
+            "description": "监测新的子元素的增加或现有子元素的删除"
+          },
+          "attributes": {
+            "title": "属性",
+            "description": "监测目标元素属性值的变化"
+          },
+          "attributeFilter": {
+            "title": "属性过滤器",
+            "separate": "使用逗号(,)来分隔属性名称",
+            "description": "只监测特定的属性(留空以监测所有属性)。"
+          },
+          "characterData": {
+            "title": "字符数据",
+            "description": "监测目标元素内的字符数据/文本的变化"
+          },
+        },
         "items": {
           "manual": "手动",
           "interval": "间隔",
           "date": "在特定日期",
+          "context-menu": "上下文菜单",
+          "element-change": "元素更改时",
           "specific-day": "在特定的星期几",
-          "visit-web": "访问网页时",
+          "visit-web": "访问网时",
           "on-startup": "浏览器启动时",
           "keyboard-shortcut": "键盘快捷键"
         }
@@ -216,6 +270,7 @@
         "keysAsFirstRow": "使用主键作为第一行",
         "insertData": "插入数据",
         "valueInputOption": "输入选项值",
+        "insertDataOption": "插入数据选项",
         "rangeToSearch": "开始搜索的范围",
         "dataFrom": {
           "label": "数据来自",
@@ -230,7 +285,7 @@
         },
         "spreadsheetId": {
           "label": "电子表格 Id",
-          "link": "查看如何获取电子表格 ID"
+          "link": "查看如何获取电子表格 Id"
         },
         "range": {
           "label": "范围",
@@ -239,7 +294,8 @@
         "select": {
           "get": "获取电子表格单元格值",
           "getRange": "获取电子表格范围",
-          "update": "更新电子表格单元格值"
+          "update": "更新电子表格单元格值",
+          "append": "追加电子表格单元格值"
         }
       },
       "active-tab": {
@@ -359,6 +415,7 @@
         "customUserAgent": "使用自定义 User-Agent",
         "activeTab": "设为活动标签页",
         "tabToGroup": "标签页分组",
+        "waitTabLoaded": "等待标签页加载完毕",
         "updatePrevTab": {
           "title": "使用之前打开的新标签页而不是新建标签页",
           "text": "更新之前打开的标签页"
@@ -438,6 +495,7 @@
       "conditions": {
         "name": "条件",
         "add": "添加条件",
+        "retryConditions": "如果所有条件都不满足,则重试",
         "description": "条件模块",
         "refresh": "刷新条件连接",
         "fallbackTitle": "当所有比较不满足要求时执行",

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

@@ -5,6 +5,8 @@
     "collection": "集合 | 集合",
     "log": "日志 | 日志",
     "block": "单元 | 单元",
+    "folder": "文件夹 | 文件夹",
+    "new": "新建",
     "docs": "文档",
     "search": "搜索",
     "example": "示例 | 示例",

+ 29 - 2
src/locales/zh/newtab.json

@@ -12,6 +12,14 @@
     "text1": "Automa 已更新为 v{version},",
     "text2": "看看有什么新东西."
   },
+  "workflows": {
+    "folder": {
+      "new": "新建文件夹",
+      "name": "文件夹名",
+      "delete": "删除文件夹",
+      "rename": "重命名文件夹"
+    }
+  },
   "auth": {
     "title": "作者",
     "signIn": "登录",
@@ -102,14 +110,19 @@
     "rename": "重命名工作流",
     "backupCloud": "备份工作流到云端",
     "add": "添加工作流",
+    "add": "添加工作流",
     "clickToEnable": "单击启用",
     "toggleSidebar": "切换侧栏",
     "cantEdit": "无法编辑共享的工作流",
+    "searchBlocks": {
+      "title": "在编辑器中搜索模块"
+    },
     "conditionBuilder": {
       "title": "条件生成器",
       "add": "附加条件",
       "and": "AND",
-      "or": "OR"
+      "or": "OR",
+      "topAwait": "支持 top-level await 和\"automaRefData\"函数"
     },
     "host": {
       "title": "主机工作流程",
@@ -191,6 +204,15 @@
     "settings": {
       "saveLog": "保存工作流日志",
       "executedBlockOnWeb": "在网页上显示已执行的模块",
+      "notification": {
+        "title": "工作流通知",
+        "description": "执行后显示工作流状态(成功或失败)",
+        "noPermission": "Automa 需要 \"notifications\" 权限才能正常工作"
+      },
+      "publicId": {
+        "title": "工作流公开 Id",
+        "description": "用于JS自定义事件使用此公共ID执行工作流"
+      },
       "defaultColumn": {
         "title": "插入信息到默认列",
         "description": "如果模块中没有选择列,则将数据插入默认列",
@@ -234,7 +256,11 @@
       "blockDelay": {
         "title": "模块延迟(毫秒)",
         "description": "在执行每个块之前添加延迟"
-      }
+      },
+      "tabLoadTimeout": {
+        "title": "标签页载入超时",
+        "description": "加载标签的最大时间,以毫秒为单位,通过0来禁用超时。"
+      },
     }
   },
   "collection": {
@@ -294,6 +320,7 @@
       "no-clipboard-acces": "无权访问剪贴板",
       "browser-not-supported": "{browser} 浏览器不支持此功能",
       "element-not-found": "找不到含 \"{selector}\" 选择器的元素.",
+      "no-permission": "没有执行此操作的 \"{permission}\" 权限",
       "not-iframe": "含 \"{selector}\" 选择器的元素并不是一个 Iframe 元素",
       "iframe-not-found": "找不到含 \"{selector}\" 选择器的 Iframe 元素.",
       "workflow-infinite-loop": "无法执行工作流以防止无限循环",

+ 5 - 1
src/locales/zh/popup.json

@@ -22,7 +22,11 @@
     "workflow": {
       "new": "新建工作流",
       "rename": "重命名工作流",
-      "delete": "删除工作流"
+      "delete": "删除工作流",
+      "type": {
+        "host": "主机",
+        "local": "本地",
+      }
     },
   }
 }

+ 4 - 1
src/manifest.chrome.json

@@ -35,6 +35,7 @@
     },
     {
       "matches": [
+        "http://localhost/*",
         "*://*.automa.site/*",
         "*://automa.vercel.app/*"
       ],
@@ -46,7 +47,9 @@
   ],
   "optional_permissions": [
     "clipboardRead",
-    "downloads"
+    "downloads",
+    "contextMenus",
+    "notifications"
   ],
   "permissions": [
     "tabs",

+ 3 - 1
src/manifest.firefox.json

@@ -51,11 +51,13 @@
   ],
   "optional_permissions": [
     "clipboardRead",
-    "downloads"
+    "downloads",
+    "notifications"
   ],
   "permissions": [
     "tabs",
     "proxy",
+    "menus",
     "alarms",
     "storage",
     "webNavigation",

+ 30 - 0
src/models/folder.js

@@ -0,0 +1,30 @@
+import { Model } from '@vuex-orm/core';
+import { nanoid } from 'nanoid';
+import Workflow from './workflow';
+
+class Folder extends Model {
+  static entity = 'folders';
+
+  static primaryKey = 'id';
+
+  static autoSave = true;
+
+  static fields() {
+    return {
+      id: this.uid(() => nanoid()),
+      name: this.string(''),
+      createdAt: this.number(),
+      workflows: this.hasMany(Workflow, 'folderId'),
+    };
+  }
+
+  static async insert(payload) {
+    const res = await super.insert(payload);
+
+    await this.store().dispatch('saveToStorage', 'folders');
+
+    return res;
+  }
+}
+
+export default Folder;

+ 1 - 0
src/models/index.js

@@ -1,3 +1,4 @@
 export { default as Workflow } from './workflow';
 export { default as Collection } from './collection';
 export { default as Log } from './log';
+export { default as Folder } from './folder';

+ 4 - 1
src/models/workflow.js

@@ -20,6 +20,7 @@ class Workflow extends Model {
       name: this.string(''),
       icon: this.string('riGlobalLine'),
       data: this.attr(null),
+      folderId: this.attr(null),
       drawflow: this.attr(''),
       table: this.attr([]),
       dataColumns: this.attr([]),
@@ -31,10 +32,12 @@ class Workflow extends Model {
       isDisabled: this.boolean(false),
       isProtected: this.boolean(false),
       settings: this.attr({
+        publicId: '',
         blockDelay: 0,
         saveLog: true,
         debugMode: false,
         restartTimes: 3,
+        notification: true,
         reuseLastState: false,
         inputAutocomplete: true,
         onError: 'stop-workflow',
@@ -43,7 +46,7 @@ class Workflow extends Model {
         defaultColumnName: 'column',
       }),
       logs: this.hasMany(Log, 'workflowId'),
-      globalData: this.string('[{ "key": "value" }]'),
+      globalData: this.string('{\n\t"key": "value"\n}'),
     };
   }
 

+ 23 - 11
src/newtab/App.vue

@@ -83,10 +83,12 @@
 import { ref, shallowReactive, computed } from 'vue';
 import { useStore } from 'vuex';
 import { useI18n } from 'vue-i18n';
+import { useRoute } from 'vue-router';
 import { compare } from 'compare-versions';
 import browser from 'webextension-polyfill';
 import { useTheme } from '@/composable/theme';
 import { loadLocaleMessages, setI18nLanguage } from '@/lib/vueI18n';
+import { parseJSON } from '@/utils/helper';
 import { fetchApi, getSharedWorkflows, getUserWorkflows } from '@/utils/api';
 import dayjs from '@/lib/dayjs';
 import Log from '@/models/log';
@@ -96,6 +98,7 @@ import AppSidebar from '@/components/newtab/app/AppSidebar.vue';
 const { t } = useI18n();
 const store = useStore();
 const theme = useTheme();
+const route = useRoute();
 
 theme.init();
 
@@ -251,16 +254,6 @@ function handleStorageChanged(change) {
       data: change.logs.newValue,
     });
   }
-
-  if (change.workflowState) {
-    store.commit('updateState', {
-      key: 'workflowState',
-      value: Object.values(change.workflowState.newValue || {}).filter(
-        ({ isDestroyed, parentState }) =>
-          !isDestroyed && !parentState?.isCollection
-      ),
-    });
-  }
 }
 function closeModal() {
   let value = true;
@@ -295,6 +288,20 @@ window.addEventListener('beforeunload', () => {
   browser.storage.onChanged.removeListener(handleStorageChanged);
 });
 
+const includeRoutes = ['home', 'workflows-details'];
+window.addEventListener('storage', ({ key, newValue }) => {
+  if (key !== 'workflowState' || !includeRoutes.includes(route.name)) return;
+
+  const states = parseJSON(newValue, {});
+  store.commit('updateState', {
+    key: 'workflowState',
+    value: Object.values(states).filter(
+      ({ isDestroyed, parentState }) =>
+        !isDestroyed && !parentState?.isCollection
+    ),
+  });
+});
+
 (async () => {
   try {
     const { isFirstTime } = await browser.storage.local.get('isFirstTime');
@@ -309,7 +316,12 @@ window.addEventListener('beforeunload', () => {
     }
 
     await Promise.allSettled([
-      store.dispatch('retrieve', ['workflows', 'logs', 'collections']),
+      store.dispatch('retrieve', [
+        'workflows',
+        'logs',
+        'collections',
+        'folders',
+      ]),
       store.dispatch('retrieveWorkflowState'),
     ]);
 

+ 119 - 9
src/newtab/pages/Workflows.vue

@@ -99,8 +99,16 @@
             </ui-list>
           </ui-expand>
         </ui-list>
+        <workflows-folder
+          v-if="state.activeTab === 'local'"
+          v-model="state.activeFolder"
+        />
       </div>
-      <div class="flex-1 ml-8">
+      <div
+        class="flex-1 workflows-list ml-8"
+        style="min-height: calc(100vh - 8rem)"
+        @dblclick="clearSelectedWorkflows"
+      >
         <div class="flex items-center">
           <ui-input
             id="search-input"
@@ -143,6 +151,7 @@
                 :key="workflow.id"
                 :data="workflow"
                 :show-details="false"
+                @execute="executeWorkflow(workflow)"
                 @click="$router.push(`/workflows/${$event.id}?shared=true`)"
               />
             </div>
@@ -154,6 +163,7 @@
                 :key="workflow.hostId"
                 :data="workflow"
                 :menu="workflowHostMenu"
+                @execute="executeWorkflow(workflow)"
                 @click="$router.push(`/workflows/${$event.hostId}/host`)"
                 @menuSelected="deleteWorkflowHost(workflow)"
               />
@@ -180,6 +190,10 @@
                   v-for="workflow in localWorkflows"
                   :key="workflow.id"
                   :data="workflow"
+                  :data-workflow="workflow.id"
+                  draggable="true"
+                  class="cursor-default select-none ring-accent local-workflow"
+                  @dragstart="onDragStart"
                   @click="$router.push(`/workflows/${$event.id}`)"
                 >
                   <template #header>
@@ -192,7 +206,10 @@
                           style="height: 40px; width: 40px"
                           alt="Can not display"
                         />
-                        <span v-else class="p-2 rounded-lg bg-box-transparent">
+                        <span
+                          v-else
+                          class="p-2 rounded-lg bg-box-transparent inline-block"
+                        >
                           <v-remixicon :name="workflow.icon" />
                         </span>
                       </template>
@@ -344,7 +361,13 @@
   </div>
 </template>
 <script setup>
-import { computed, shallowReactive, watch } from 'vue';
+import {
+  computed,
+  shallowReactive,
+  watch,
+  onMounted,
+  onBeforeUnmount,
+} from 'vue';
 import { useStore } from 'vuex';
 import { useI18n } from 'vue-i18n';
 import { useToast } from 'vue-toastification';
@@ -362,6 +385,8 @@ import {
 import { findTriggerBlock, isWhitespace } from '@/utils/helper';
 import SharedCard from '@/components/newtab/shared/SharedCard.vue';
 import Workflow from '@/models/workflow';
+import WorkflowsFolder from '@/components/newtab/workflows/WorkflowsFolder.vue';
+import SelectionArea from '@viselect/vanilla';
 
 useGroupTooltip();
 const { t } = useI18n();
@@ -383,7 +408,9 @@ const workflowHostMenu = [
 const savedSorts = JSON.parse(localStorage.getItem('workflow-sorts') || '{}');
 const state = shallowReactive({
   query: '',
+  activeFolder: '',
   activeTab: 'local',
+  selectedWorkflows: [],
   sortBy: savedSorts.sortBy || 'createdAt',
   sortOrder: savedSorts.sortOrder || 'desc',
 });
@@ -397,13 +424,46 @@ const pagination = shallowReactive({
   perPage: savedSorts.perPage || 18,
 });
 
+const selection = new SelectionArea({
+  container: '.workflows-list',
+  startareas: ['.workflows-list'],
+  boundaries: ['.workflows-list'],
+  selectables: ['.local-workflow'],
+});
+selection
+  .on('beforestart', ({ event }) => {
+    return (
+      event.target.tagName !== 'INPUT' &&
+      !event.target.closest('.local-workflow')
+    );
+  })
+  .on('start', () => {
+    /* eslint-disable-next-line */
+  clearSelectedWorkflows();
+  })
+  .on('move', (event) => {
+    event.store.changed.added.forEach((el) => {
+      el.classList.add('ring-2');
+    });
+    event.store.changed.removed.forEach((el) => {
+      el.classList.remove('ring-2');
+    });
+  })
+  .on('stop', (event) => {
+    state.selectedWorkflows = event.store.selected.map(
+      (el) => el.dataset.workflow
+    );
+  });
+
 const hostWorkflows = computed(() => store.state.hostWorkflows || {});
 const workflowHosts = computed(() => Object.values(store.state.workflowHosts));
 const sharedWorkflows = computed(() => store.state.sharedWorkflows || {});
 const workflows = computed(() =>
   Workflow.query()
-    .where(({ name }) =>
-      name.toLocaleLowerCase().includes(state.query.toLocaleLowerCase())
+    .where(
+      ({ name, folderId }) =>
+        name.toLocaleLowerCase().includes(state.query.toLocaleLowerCase()) &&
+        (!state.activeFolder || state.activeFolder === folderId)
     )
     .orderBy(state.sortBy, state.sortOrder)
     .get()
@@ -415,6 +475,22 @@ const localWorkflows = computed(() =>
   )
 );
 
+function clearSelectedWorkflows() {
+  state.selectedWorkflows = [];
+
+  selection.getSelection().forEach((el) => {
+    el.classList.remove('ring-2');
+  });
+  selection.clearSelection();
+}
+function onDragStart({ dataTransfer, target }) {
+  const payload = [...state.selectedWorkflows];
+
+  const targetId = target.dataset.workflow;
+  if (targetId && !payload.includes(targetId)) payload.push(targetId);
+
+  dataTransfer.setData('workflows', JSON.stringify(payload));
+}
 async function deleteWorkflowHost(workflow) {
   dialog.confirm({
     title: t('workflow.delete'),
@@ -471,14 +547,14 @@ function addHostWorkflow() {
           return false;
         }
 
-        const response = await fetchApi('/host', {
+        const response = await fetchApi('/workflows/hosted', {
           method: 'POST',
-          body: JSON.stringify({ length, hostId }),
+          body: JSON.stringify({ hostId }),
         });
         const result = await response.json();
 
-        if (response.status !== 200) {
-          const error = new Error(response.statusText);
+        if (!response.ok) {
+          const error = new Error(result.message);
           error.data = result.data;
 
           throw error;
@@ -549,6 +625,33 @@ function deleteWorkflow({ name, id }) {
     },
   });
 }
+function deleteSelectedWorkflows({ target, key }) {
+  const excludeTags = ['INPUT', 'TEXTAREA', 'SELECT'];
+  if (
+    excludeTags.includes(target.tagName) ||
+    key !== 'Delete' ||
+    state.selectedWorkflows.length === 0
+  )
+    return;
+
+  if (state.selectedWorkflows.length === 1) {
+    const workflow = Workflow.find(state.selectedWorkflows[0]);
+    deleteWorkflow(workflow);
+  } else {
+    dialog.confirm({
+      title: t('workflow.delete'),
+      okVariant: 'danger',
+      body: t('message.delete', {
+        name: `${state.selectedWorkflows.length} workflows`,
+      }),
+      onConfirm: () => {
+        state.selectedWorkflows.forEach((id) => {
+          Workflow.delete(id);
+        });
+      },
+    });
+  }
+}
 function renameWorkflow({ id, name, description }) {
   Object.assign(workflowModal, {
     id,
@@ -622,6 +725,13 @@ watch(
     );
   }
 );
+
+onMounted(() => {
+  window.addEventListener('keydown', deleteSelectedWorkflows);
+});
+onBeforeUnmount(() => {
+  window.removeEventListener('keydown', deleteSelectedWorkflows);
+});
 </script>
 <style>
 .workflow-sort select {

+ 4 - 2
src/newtab/pages/workflows/Host.vue

@@ -130,10 +130,12 @@ import { computed, reactive, onMounted, watch } from 'vue';
 import { useStore } from 'vuex';
 import { useI18n } from 'vue-i18n';
 import { useRoute, useRouter } from 'vue-router';
+import browser from 'webextension-polyfill';
 import { useDialog } from '@/composable/dialog';
 import { useShortcut } from '@/composable/shortcut';
 import { useGroupTooltip } from '@/composable/groupTooltip';
 import { parseJSON, findTriggerBlock } from '@/utils/helper';
+import { cleanWorkflowTriggers } from '@/utils/workflowTrigger';
 import { sendMessage } from '@/utils/message';
 import Log from '@/models/log';
 import getTriggerText from '@/utils/triggerText';
@@ -233,7 +235,7 @@ function deleteLog(logId) {
   });
 }
 async function retrieveTriggerText() {
-  const flow = parseJSON(workflow.value.drawflow, null);
+  const flow = parseJSON(workflow.value?.drawflow, null);
   const triggerBlock = findTriggerBlock(flow);
 
   if (!triggerBlock) return;
@@ -247,7 +249,7 @@ async function retrieveTriggerText() {
 }
 
 watch(
-  () => workflow.value.drawflow,
+  () => workflow.value?.drawflow,
   () => {
     state.editorKey += 1;
     retrieveTriggerText();

+ 7 - 10
src/newtab/pages/workflows/[id].vue

@@ -445,7 +445,7 @@ async function updateHostedWorkflow() {
       );
     }
 
-    const response = await fetchApi(`/me/workflows?id=${workflowId}`, {
+    const response = await fetchApi(`/me/workflows/${workflowId}`, {
       method: 'PUT',
       keepalive: true,
       body: JSON.stringify({
@@ -480,12 +480,9 @@ function unpublishSharedWorkflow() {
       try {
         workflowData.isUnpublishing = true;
 
-        const response = await fetchApi(
-          `/me/workflows/shared?workflowId=${workflowId}`,
-          {
-            method: 'DELETE',
-          }
-        );
+        const response = await fetchApi(`/me/workflows/shared/${workflowId}`, {
+          method: 'DELETE',
+        });
 
         if (response.status !== 200) {
           throw new Error(response.statusText);
@@ -526,7 +523,7 @@ async function saveUpdatedSharedWorkflow() {
       }
     });
 
-    const url = `/me/workflows/shared?workflowId=${workflowId}`;
+    const url = `/me/workflows/shared/${workflowId}`;
     const response = await fetchApi(url, {
       method: 'PUT',
       body: JSON.stringify(payload),
@@ -621,11 +618,11 @@ async function setAsHostWorkflow(isHost) {
       workflowPaylod.drawflow = parseJSON(workflow.value.drawflow, null);
       delete workflowPaylod.extVersion;
 
-      url += `?type=host`;
+      url += `/host`;
       payload = {
         method: 'POST',
         body: JSON.stringify({
-          workflows: workflowPaylod,
+          workflow: workflowPaylod,
         }),
       };
     } else {

+ 13 - 17
src/popup/pages/Home.vue

@@ -211,25 +211,18 @@ function openDashboard(url) {
 }
 async function initElementSelector() {
   const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
+  const result = await browser.tabs.sendMessage(tab.id, {
+    type: 'automa-element-selector',
+  });
 
-  try {
-    await browser.tabs.sendMessage(tab.id, {
-      type: 'automa-element-selector',
+  if (!result) {
+    await browser.tabs.executeScript(tab.id, {
+      allFrames: true,
+      file: './elementSelector.bundle.js',
     });
-
-    window.close();
-  } catch (error) {
-    if (error.message.includes('Could not establish connection.')) {
-      await browser.tabs.executeScript(tab.id, {
-        allFrames: true,
-        file: './elementSelector.bundle.js',
-      });
-
-      initElementSelector();
-    }
-
-    console.error(error);
   }
+
+  window.close();
 }
 async function recordWorkflow(options = {}) {
   try {
@@ -264,7 +257,10 @@ async function recordWorkflow(options = {}) {
 
     const tabs = await browser.tabs.query({});
     for (const tab of tabs) {
-      if (tab.url.startsWith('http')) {
+      if (
+        tab.url.startsWith('http') &&
+        !tab.url.includes('chrome.google.com')
+      ) {
         await browser.tabs.executeScript(tab.id, {
           allFrames: true,
           file: 'recordWorkflow.bundle.js',

+ 1 - 1
src/store/index.js

@@ -133,7 +133,7 @@ const store = createStore({
     async fetchWorkflowHosts({ commit, state }, hosts) {
       if (!hosts || hosts.length === 0) return null;
 
-      const response = await fetchApi('/host', {
+      const response = await fetchApi('/workflows/hosted', {
         method: 'POST',
         body: JSON.stringify({ hosts }),
       });

+ 6 - 1
src/utils/api.js

@@ -17,7 +17,12 @@ function queryBuilder(obj) {
 export function fetchApi(path, options) {
   const urlPath = path.startsWith('/') ? path : `/${path}`;
 
-  return fetch(`${secrets.baseApiUrl}${urlPath}`, options);
+  return fetch(`${secrets.baseApiUrl}${urlPath}`, {
+    headers: {
+      'Content-Type': 'application/json',
+    },
+    ...options,
+  });
 }
 
 export const googleSheets = {

+ 1 - 1
src/utils/getFile.js

@@ -14,7 +14,7 @@ function getLocalFile(path) {
       return;
     }
 
-    const fileUrl = path.startsWith('file://') ? path : `file://${path}`;
+    const fileUrl = path?.startsWith('file://') ? path : `file://${path}`;
 
     const xhr = new XMLHttpRequest();
     xhr.responseType = 'blob';

+ 1 - 2
src/utils/handleFormElement.js

@@ -52,10 +52,9 @@ function formEvent(element, data) {
 async function inputText({ data, element, isEditable }) {
   const elementKey = isEditable ? 'textContent' : 'value';
 
-  if (data.delay > 0) {
+  if (data.delay > 0 && !document.hidden) {
     for (let index = 0; index < data.value.length; index += 1) {
       const currentChar = data.value[index];
-
       element[elementKey] += currentChar;
 
       if (elementKey === 'value') reactJsEvent(element, element.value);

+ 5 - 6
src/utils/recordKeys.js

@@ -33,14 +33,13 @@ const allowedKeys = {
   Escape: 'escape',
   Enter: 'enter',
 };
-export function recordShortcut(event, callback) {
-  event.preventDefault();
-  event.stopPropagation();
-
-  if (event.repeat) return;
+export function recordShortcut(
+  { ctrlKey, altKey, metaKey, shiftKey, key, repeat },
+  callback
+) {
+  if (repeat) return;
 
   const keys = [];
-  const { ctrlKey, altKey, metaKey, shiftKey, key } = event;
 
   if (ctrlKey || metaKey) keys.push('mod');
   if (altKey) keys.push('option');

+ 1 - 1
src/utils/referenceData/mustacheReplacer.js

@@ -134,7 +134,7 @@ function replacer(str, { regex, tagLen, modifyPath, data }) {
     }
 
     result = typeof result === 'string' ? result : JSON.stringify(result);
-    replaceResult.list[match] = result;
+    replaceResult.list[match] = result.slice(0, 512);
 
     return result;
   });

+ 54 - 1
src/utils/shared.js

@@ -26,6 +26,27 @@ export const tasks = {
       activeInInput: false,
       isUrlRegex: false,
       days: [],
+      contextMenuName: '',
+      contextTypes: [],
+      observeElement: {
+        selector: '',
+        baseSelector: '',
+        matchPattern: '',
+        targetOptions: {
+          subtree: false,
+          childList: true,
+          attributes: false,
+          attributeFilter: [],
+          characterData: false,
+        },
+        baseElOptions: {
+          subtree: false,
+          childList: true,
+          attributes: false,
+          attributeFilter: [],
+          characterData: false,
+        },
+      },
     },
   },
   'execute-workflow': {
@@ -140,7 +161,9 @@ export const tasks = {
     outputs: 1,
     maxConnection: 1,
     allowedInputs: true,
+    refDataKeys: ['host', 'port', 'scheme'],
     data: {
+      description: '',
       disableBlock: false,
       scheme: 'https',
       host: '',
@@ -664,7 +687,7 @@ export const tasks = {
       'referenceKey',
       'elementSelector',
     ],
-    autocomplete: ['variableName', 'loopId'],
+    autocomplete: ['variableName', 'loopId', 'maxLoop'],
     data: {
       disableBlock: false,
       loopId: '',
@@ -959,6 +982,27 @@ export const tasks = {
       flowBlockId: '',
     },
   },
+  notification: {
+    name: 'Notification',
+    description: 'Display a notification',
+    icon: 'riNotification3Line',
+    editComponent: 'EditNotification',
+    component: 'BlockBasic',
+    category: 'general',
+    inputs: 1,
+    outputs: 1,
+    allowedInputs: true,
+    maxConnection: 1,
+    refDataKeys: ['message', 'title', 'iconUrl', 'imageUrl'],
+    data: {
+      disableBlock: false,
+      description: '',
+      message: '',
+      iconUrl: '',
+      imageUrl: '',
+      title: 'Hello world!',
+    },
+  },
 };
 
 export const categories = {
@@ -1036,6 +1080,15 @@ export const workflowCategories = {
   productivity: 'Productivity',
 };
 
+export const excludeOnError = [
+  'delay',
+  'webhook',
+  'trigger',
+  'while-loop',
+  'conditions',
+  'element-exists',
+];
+
 export const contentTypes = [
   { name: 'application/json', value: 'json' },
   { name: 'application/x-www-form-urlencoded', value: 'form' },

+ 63 - 0
src/utils/workflowTrigger.js

@@ -2,6 +2,56 @@ import browser from 'webextension-polyfill';
 import dayjs from 'dayjs';
 import { isObject } from './helper';
 
+export function registerContextMenu(workflowId, data) {
+  return new Promise((resolve, reject) => {
+    const documentUrlPatterns = ['https://*/*', 'http://*/*'];
+    const contextTypes =
+      !data.contextTypes || data.contextTypes.length === 0
+        ? ['all']
+        : data.contextTypes;
+
+    const isFirefox = BROWSER_TYPE === 'firefox';
+    const browserContext = isFirefox ? browser.menus : browser.contextMenus;
+
+    browserContext.create(
+      {
+        id: workflowId,
+        documentUrlPatterns,
+        contexts: contextTypes,
+        title: data.contextMenuName,
+        parentId: 'automaContextMenu',
+      },
+      () => {
+        const error = browser.runtime.lastError;
+
+        if (error) {
+          if (error.message.includes('automaContextMenu')) {
+            browserContext.create(
+              {
+                documentUrlPatterns,
+                contexts: ['all'],
+                id: 'automaContextMenu',
+                title: 'Run Automa workflow',
+              },
+              () => {
+                registerContextMenu(workflowId, data)
+                  .then(resolve)
+                  .catch(reject);
+              }
+            );
+            return;
+          }
+
+          reject(error.message);
+        } else {
+          if (browserContext.refresh) browserContext.refresh();
+          resolve();
+        }
+      }
+    );
+  });
+}
+
 async function removeFromWorkflowQueue(workflowId) {
   const { workflowQueue } = await browser.storage.local.get('workflowQueue');
   const queueIndex = (workflowQueue || []).indexOf(workflowId);
@@ -47,6 +97,18 @@ export async function cleanWorkflowTriggers(workflowId) {
       shortcuts: keyboardShortcuts,
       onStartupTriggers: startupTriggers,
     });
+
+    const removeFromContextMenu = async () => {
+      try {
+        await (BROWSER_TYPE === 'firefox'
+          ? browser.menus
+          : browser.contextMenus
+        )?.remove(workflowId);
+      } catch (error) {
+        // Do nothing
+      }
+    };
+    await removeFromContextMenu();
   } catch (error) {
     console.error(error);
   }
@@ -163,6 +225,7 @@ export async function registerWorkflowTrigger(workflowId, { data }) {
       'visit-web': registerVisitWeb,
       'on-startup': registerOnStartup,
       'specific-day': registerSpecificDay,
+      'context-menu': registerContextMenu,
       'keyboard-shortcut': registerKeyboardShortcut,
     };
 

+ 68 - 19
yarn.lock

@@ -1983,7 +1983,7 @@ acorn-import-assertions@^1.7.6:
   resolved "https://registry.yarnpkg.com/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz#ba2b5939ce62c238db6d93d81c9b111b29b855e9"
   integrity sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==
 
-acorn-jsx@^5.2.0, acorn-jsx@^5.3.1:
+acorn-jsx@^5.2.0, acorn-jsx@^5.3.1, acorn-jsx@^5.3.2:
   version "5.3.2"
   resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
   integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
@@ -2012,6 +2012,11 @@ acorn@^8.4.1, acorn@^8.5.0:
   resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.0.tgz#90951fde0f8f09df93549481e5fc141445b791cf"
   integrity sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==
 
+acorn@^8.7.1:
+  version "8.7.1"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30"
+  integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==
+
 aggregate-error@^3.0.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a"
@@ -3012,7 +3017,7 @@ debug@^3.1.1, debug@^3.2.7:
   dependencies:
     ms "^2.1.1"
 
-debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2:
+debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4:
   version "4.3.4"
   resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
   integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
@@ -3461,15 +3466,18 @@ eslint-plugin-prettier@^4.0.0:
   dependencies:
     prettier-linter-helpers "^1.0.0"
 
-eslint-plugin-vue@7.18.0:
-  version "7.18.0"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-vue/-/eslint-plugin-vue-7.18.0.tgz#02a452142330c7f27c242db21a1b9e25238540f6"
-  integrity sha512-ceDXlXYMMPMSXw7tdKUR42w9jlzthJGJ3Kvm3YrZ0zuQfvAySNxe8sm6VHuksBW0+060GzYXhHJG6IHVOfF83Q==
+eslint-plugin-vue@^9.1.0:
+  version "9.1.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-vue/-/eslint-plugin-vue-9.1.0.tgz#b528941325e26a24bc5d5c5030c0a8996c36659c"
+  integrity sha512-EPCeInPicQ/YyfOWJDr1yfEeSNoFCMzUus107lZyYi37xejdOolNzS5MXGXp8+9bkoKZMdv/1AcZzQebME6r+g==
   dependencies:
-    eslint-utils "^2.1.0"
+    eslint-utils "^3.0.0"
     natural-compare "^1.4.0"
-    semver "^6.3.0"
-    vue-eslint-parser "^7.10.0"
+    nth-check "^2.0.1"
+    postcss-selector-parser "^6.0.9"
+    semver "^7.3.5"
+    vue-eslint-parser "^9.0.1"
+    xml-name-validator "^4.0.0"
 
 eslint-scope@5.1.1, eslint-scope@^5.1.1:
   version "5.1.1"
@@ -3479,6 +3487,14 @@ eslint-scope@5.1.1, eslint-scope@^5.1.1:
     esrecurse "^4.3.0"
     estraverse "^4.1.1"
 
+eslint-scope@^7.1.1:
+  version "7.1.1"
+  resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.1.1.tgz#fff34894c2f65e5226d3041ac480b4513a163642"
+  integrity sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==
+  dependencies:
+    esrecurse "^4.3.0"
+    estraverse "^5.2.0"
+
 eslint-utils@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-2.1.0.tgz#d2de5e03424e707dc10c74068ddedae708741b27"
@@ -3486,6 +3502,13 @@ eslint-utils@^2.1.0:
   dependencies:
     eslint-visitor-keys "^1.1.0"
 
+eslint-utils@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-3.0.0.tgz#8aebaface7345bb33559db0a1f13a1d2d48c3672"
+  integrity sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==
+  dependencies:
+    eslint-visitor-keys "^2.0.0"
+
 eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.3.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz#30ebd1ef7c2fdff01c3a4f151044af25fab0523e"
@@ -3496,6 +3519,11 @@ eslint-visitor-keys@^2.0.0, eslint-visitor-keys@^2.1.0:
   resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303"
   integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==
 
+eslint-visitor-keys@^3.3.0:
+  version "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@7.32.0:
   version "7.32.0"
   resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.32.0.tgz#c6d328a14be3fb08c8d1d21e12c02fdb7a2a812d"
@@ -3542,7 +3570,7 @@ eslint@7.32.0:
     text-table "^0.2.0"
     v8-compile-cache "^2.0.3"
 
-espree@^6.0.0, espree@^6.2.1:
+espree@^6.0.0:
   version "6.2.1"
   resolved "https://registry.yarnpkg.com/espree/-/espree-6.2.1.tgz#77fc72e1fd744a2052c20f38a5b575832e82734a"
   integrity sha512-ysCxRQY3WaXJz9tdbWOwuWr5Y/XrPTGX9Kiz3yoUXwW0VZ4w30HTkQLaGx/+ttFjF8i+ACbArnB4ce68a9m5hw==
@@ -3560,6 +3588,15 @@ espree@^7.3.0, espree@^7.3.1:
     acorn-jsx "^5.3.1"
     eslint-visitor-keys "^1.3.0"
 
+espree@^9.3.1:
+  version "9.3.2"
+  resolved "https://registry.yarnpkg.com/espree/-/espree-9.3.2.tgz#f58f77bd334731182801ced3380a8cc859091596"
+  integrity sha512-D211tC7ZwouTIuY5x9XnS0E9sWNChB7IYKX/Xp5eQj3nFXhqmiUDB9q27y76oFl8jTg3pXcQx/bpxMfs3CIZbA==
+  dependencies:
+    acorn "^8.7.1"
+    acorn-jsx "^5.3.2"
+    eslint-visitor-keys "^3.3.0"
+
 esprima@^4.0.0:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
@@ -6393,6 +6430,13 @@ semver@^7.2.1, semver@^7.3.5:
   dependencies:
     lru-cache "^6.0.0"
 
+semver@^7.3.6:
+  version "7.3.7"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f"
+  integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==
+  dependencies:
+    lru-cache "^6.0.0"
+
 send@0.17.2:
   version "0.17.2"
   resolved "https://registry.yarnpkg.com/send/-/send-0.17.2.tgz#926622f76601c41808012c8bf1688fe3906f7820"
@@ -7198,18 +7242,18 @@ vary@~1.1.2:
   resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
   integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=
 
-vue-eslint-parser@^7.10.0:
-  version "7.11.0"
-  resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-7.11.0.tgz#214b5dea961007fcffb2ee65b8912307628d0daf"
-  integrity sha512-qh3VhDLeh773wjgNTl7ss0VejY9bMMa0GoDG2fQVyDzRFdiU3L7fw74tWZDHNQXdZqxO3EveQroa9ct39D2nqg==
+vue-eslint-parser@^9.0.1:
+  version "9.0.2"
+  resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-9.0.2.tgz#d2535516f3f55adb387939427fe741065eb7948a"
+  integrity sha512-uCPQwTGjOtAYrwnU+76pYxalhjsh7iFBsHwBqDHiOPTxtICDaraO4Szw54WFTNZTAEsgHHzqFOu1mmnBOBRzDA==
   dependencies:
-    debug "^4.1.1"
-    eslint-scope "^5.1.1"
-    eslint-visitor-keys "^1.1.0"
-    espree "^6.2.1"
+    debug "^4.3.4"
+    eslint-scope "^7.1.1"
+    eslint-visitor-keys "^3.3.0"
+    espree "^9.3.1"
     esquery "^1.4.0"
     lodash "^4.17.21"
-    semver "^6.3.0"
+    semver "^7.3.6"
 
 vue-i18n@^9.2.0-beta.29:
   version "9.2.0-beta.33"
@@ -7505,6 +7549,11 @@ ws@^6.2.1:
   dependencies:
     async-limiter "~1.0.0"
 
+xml-name-validator@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz#79a006e2e63149a8600f15430f0a4725d1524835"
+  integrity sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==
+
 xtend@^4.0.1, xtend@^4.0.2:
   version "4.0.2"
   resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"