Ahmad Kholid 2 years ago
parent
commit
1c881ed93c
41 changed files with 1508 additions and 642 deletions
  1. 8 6
      package.json
  2. 57 34
      src/background/index.js
  3. 17 11
      src/background/workflowEngine/blocksHandler/handlerBlockPackage.js
  4. 6 1
      src/background/workflowEngine/blocksHandler/handlerWebhook.js
  5. 8 4
      src/background/workflowEngine/engine.js
  6. 30 0
      src/components/content/shared/SharedElementSelector.vue
  7. 167 11
      src/components/newtab/logs/LogsHistory.vue
  8. 173 0
      src/components/newtab/shared/SharedWorkflowTriggers.vue
  9. 7 3
      src/components/newtab/workflow/WorkflowEditor.vue
  10. 1 0
      src/components/newtab/workflow/edit/EditGoogleSheets.vue
  11. 54 41
      src/components/newtab/workflow/edit/EditTrigger.vue
  12. 4 4
      src/components/newtab/workflow/edit/Trigger/TriggerContextMenu.vue
  13. 59 0
      src/components/newtab/workflow/edit/Trigger/TriggerCronJob.vue
  14. 1 1
      src/components/newtab/workflow/edit/Trigger/TriggerDate.vue
  15. 1 1
      src/components/newtab/workflow/edit/Trigger/TriggerElementChange.vue
  16. 1 1
      src/components/newtab/workflow/edit/Trigger/TriggerElementOptions.vue
  17. 32 34
      src/components/newtab/workflow/edit/Trigger/TriggerInterval.vue
  18. 1 1
      src/components/newtab/workflow/edit/Trigger/TriggerKeyboardShortcut.vue
  19. 3 3
      src/components/newtab/workflow/edit/Trigger/TriggerSpecificDay.vue
  20. 1 1
      src/components/newtab/workflow/edit/Trigger/TriggerVisitWeb.vue
  21. 112 0
      src/components/newtab/workflow/editor/EditorCustomEdge.vue
  22. 8 6
      src/components/newtab/workflows/WorkflowsLocal.vue
  23. 1 0
      src/components/ui/UiModal.vue
  24. 4 7
      src/content/blocksHandler/handlerLoopElements.js
  25. 7 0
      src/content/elementSelector/App.vue
  26. 7 1
      src/content/services/shortcutListener.js
  27. 21 0
      src/lib/cronstrue.js
  28. 3 1
      src/locales/en/blocks.json
  29. 12 0
      src/newtab/App.vue
  30. 263 44
      src/newtab/pages/ScheduledWorkflow.vue
  31. 2 2
      src/newtab/pages/logs/Running.vue
  32. 1 37
      src/newtab/pages/settings/SettingsEditor.vue
  33. 32 20
      src/newtab/pages/settings/SettingsIndex.vue
  34. 1 0
      src/newtab/pages/workflows/[id].vue
  35. 2 1
      src/params/App.vue
  36. 1 0
      src/popup/pages/Home.vue
  37. 2 1
      src/stores/main.js
  38. 6 4
      src/utils/convertWorkflowData.js
  39. 8 5
      src/utils/workflowData.js
  40. 56 30
      src/utils/workflowTrigger.js
  41. 328 326
      yarn.lock

+ 8 - 6
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "1.20.0",
+  "version": "1.21.0",
   "description": "An extension for automating your browser by connecting blocks",
   "repository": {
     "type": "git",
@@ -28,7 +28,7 @@
     "*.{js,ts,vue}": "eslint --fix"
   },
   "dependencies": {
-    "@braks/vue-flow": "0.4.30",
+    "@braks/vue-flow": "^0.4.40",
     "@codemirror/autocomplete": "^6.1.0",
     "@codemirror/lang-css": "^6.0.0",
     "@codemirror/lang-html": "^6.1.0",
@@ -41,13 +41,15 @@
     "@tiptap/extension-image": "^2.0.0-beta.30",
     "@tiptap/extension-link": "^2.0.0-beta.43",
     "@tiptap/extension-placeholder": "^2.0.0-beta.53",
-    "@tiptap/starter-kit": "^2.0.0-beta.191",
+    "@tiptap/starter-kit": "^2.0.0-beta.195",
     "@tiptap/vue-3": "^2.0.0-beta.96",
-    "@viselect/vanilla": "^3.0.0-beta.13",
+    "@viselect/vanilla": "^3.1.0",
     "@vueuse/rxjs": "^9.1.1",
     "@vuex-orm/core": "^0.36.4",
     "codemirror": "^6.0.1",
     "compare-versions": "^4.1.2",
+    "cron-parser": "^4.6.0",
+    "cronstrue": "^2.11.0",
     "crypto-js": "^4.1.1",
     "css-selector-generator": "^3.6.4",
     "dagre": "^0.8.5",
@@ -65,7 +67,7 @@
     "nanoid": "^4.0.0",
     "object-path": "^0.11.8",
     "papaparse": "^5.3.1",
-    "pinia": "^2.0.18",
+    "pinia": "^2.0.22",
     "rxjs": "^7.5.5",
     "tippy.js": "^6.3.1",
     "v-remixicon": "^0.1.1",
@@ -85,7 +87,7 @@
     "@babel/preset-env": "^7.18.2",
     "@intlify/vue-i18n-loader": "^4.2.0",
     "@tailwindcss/typography": "^0.5.1",
-    "@vue/compiler-sfc": "^3.2.37",
+    "@vue/compiler-sfc": "^3.2.39",
     "archiver": "^5.3.1",
     "autoprefixer": "^10.4.7",
     "babel-loader": "^8.2.2",

+ 57 - 34
src/background/index.js

@@ -9,6 +9,7 @@ import convertWorkflowData from '@/utils/convertWorkflowData';
 import getBlockMessage from '@/utils/getBlockMessage';
 import automa from '@business';
 import {
+  registerCronJob,
   registerSpecificDay,
   registerContextMenu,
   registerWorkflowTrigger,
@@ -233,21 +234,28 @@ async function openDashboard(url) {
   }
 }
 async function checkVisitWebTriggers(tabId, tabUrl) {
+  const visitWebTriggers = await browserStorage.get('visitWebTriggers');
+  if (!visitWebTriggers || visitWebTriggers.length === 0) return;
+
   const workflowState = await workflow.states.get(({ state }) =>
     state.tabIds.includes(tabId)
   );
-  const visitWebTriggers = await browserStorage.get('visitWebTriggers');
   const triggeredWorkflow = visitWebTriggers?.find(({ url, isRegex, id }) => {
     if (url.trim() === '') return false;
 
     const matchUrl = tabUrl.match(isRegex ? new RegExp(url, 'g') : url);
 
-    return matchUrl && id !== workflowState?.workflowId;
+    return matchUrl && !id.includes(workflowState?.workflowId);
   });
 
   if (triggeredWorkflow) {
-    const workflowData = await workflow.get(triggeredWorkflow.id);
+    let workflowId = triggeredWorkflow.id;
+    if (triggeredWorkflow.id.startsWith('trigger')) {
+      const { 1: triggerWorkflowId } = triggeredWorkflow.id.split(':');
+      workflowId = triggerWorkflowId;
+    }
 
+    const workflowData = await workflow.get(workflowId);
     if (workflowData) workflow.execute(workflowData, { tabId });
   }
 }
@@ -359,25 +367,44 @@ browser.tabs.onCreated.addListener(async (tab) => {
   await browser.storage.local.set({ recording });
 });
 browser.alarms.onAlarm.addListener(async ({ name }) => {
-  const currentWorkflow = await workflow.get(name);
+  let workflowId = name;
+  let triggerId = null;
+
+  if (name.startsWith('trigger')) {
+    const { 1: triggerWorkflowId, 2: triggerItemId } = name.split(':');
+    triggerId = triggerItemId;
+    workflowId = triggerWorkflowId;
+  }
+
+  const currentWorkflow = await workflow.get(workflowId);
   if (!currentWorkflow) return;
 
-  const drawflow =
-    typeof currentWorkflow.drawflow === 'string'
-      ? parseJSON(currentWorkflow.drawflow, {})
-      : currentWorkflow.drawflow;
-  const { data } = findTriggerBlock(drawflow) || {};
+  let data = currentWorkflow.trigger;
+  if (!data) {
+    const drawflow =
+      typeof currentWorkflow.drawflow === 'string'
+        ? parseJSON(currentWorkflow.drawflow, {})
+        : currentWorkflow.drawflow;
+    const { data: triggerBlockData } = findTriggerBlock(drawflow) || {};
+    data = triggerBlockData;
+  }
+
+  if (triggerId) {
+    data = data.triggers.find((trigger) => trigger.id === triggerId);
+    if (data) data = { ...data, ...data.data };
+  }
+
   if (data && data.type === 'interval' && data.fixedDelay) {
     const workflowState = await workflow.states.get(
-      ({ workflowId }) => name === workflowId
+      (item) => item.workflowId === workflowId
     );
 
     if (workflowState) {
       let { workflowQueue } = await browser.storage.local.get('workflowQueue');
       workflowQueue = workflowQueue || [];
 
-      if (!workflowQueue.includes(name)) {
-        (workflowQueue = workflowQueue || []).push(name);
+      if (!workflowQueue.includes(workflowId)) {
+        (workflowQueue = workflowQueue || []).push(workflowId);
         await browser.storage.local.set({ workflowQueue });
       }
 
@@ -396,8 +423,14 @@ browser.alarms.onAlarm.addListener(async ({ name }) => {
 
   workflow.execute(currentWorkflow);
 
-  if (data && data.type === 'specific-day') {
-    registerSpecificDay(currentWorkflow.id, data);
+  if (!data) return;
+
+  if (['specific-day', 'cron-job'].includes(data.type)) {
+    if (data.type === 'specific-day') {
+      registerSpecificDay(name, data);
+    } else {
+      registerCronJob(name, data);
+    }
   }
 });
 
@@ -413,7 +446,13 @@ if (contextMenu && contextMenu.onClicked) {
           frameId: 0,
           type: 'context-element',
         });
-        const workflowData = await workflow.get(menuItemId);
+        let workflowId = menuItemId;
+        if (menuItemId.startsWith('trigger')) {
+          const { 1: triggerWorkflowId } = menuItemId.split(':');
+          workflowId = triggerWorkflowId;
+        }
+
+        const workflowData = await workflow.get(workflowId);
 
         workflow.execute(workflowData, {
           data: {
@@ -516,26 +555,10 @@ browser.runtime.onStartup.addListener(async () => {
     }
 
     if (triggerBlock) {
-      if (triggerBlock.type === 'specific-day') {
-        const alarm = await browser.alarms.get(currWorkflow.id);
-
-        if (!alarm) await registerSpecificDay(currWorkflow.id, triggerBlock);
-      } else if (triggerBlock.type === 'date' && triggerBlock.date) {
-        const [hour, minute] = triggerBlock.time.split(':');
-        const date = dayjs(triggerBlock.date)
-          .hour(hour)
-          .minute(minute)
-          .second(0);
-
-        const isBefore = dayjs().isBefore(date);
-
-        if (isBefore) {
-          await browser.alarms.create(currWorkflow.id, {
-            when: date.valueOf(),
-          });
-        }
-      } else if (triggerBlock.type === 'on-startup') {
+      if (triggerBlock.type === 'on-startup') {
         workflow.execute(currWorkflow);
+      } else {
+        await registerWorkflowTrigger(currWorkflow.id, { data: triggerBlock });
       }
     }
   }

+ 17 - 11
src/background/workflowEngine/blocksHandler/handlerBlockPackage.js

@@ -9,6 +9,7 @@ export default async function (
   const pkgCache = this.engine.packagesCache[id];
 
   const { 1: targetId } = prevTarget.split('input-');
+  const addBlockPrefix = (itemId) => `${id}__${itemId}`;
   const hasCache = pkgCache.nodes[targetId];
   if (hasCache)
     return {
@@ -21,7 +22,7 @@ export default async function (
     throw new Error('Input not found');
   }
   const block = data.data.nodes.find((node) => node.id === input.blockId);
-  pkgCache.nodes[targetId] = block.id;
+  pkgCache.nodes[targetId] = addBlockPrefix(block.id);
 
   const connections = {};
 
@@ -29,8 +30,11 @@ export default async function (
     const outputsMap = new Set();
 
     data.inputs.forEach((item) => {
-      connections[item.id] = [
-        { id: item.blockId, targetId: `${block.id}-input-1` },
+      connections[addBlockPrefix(item.id)] = [
+        {
+          id: addBlockPrefix(item.blockId),
+          targetId: `${addBlockPrefix(block.id)}-input-1`,
+        },
       ];
     });
     data.outputs.forEach((output) => {
@@ -40,11 +44,12 @@ export default async function (
         this.engine.connectionsMap[`${id}-output-${output.id}`];
       if (!connection) return;
 
-      connections[output.handleId] = [...connection];
+      connections[addBlockPrefix(output.handleId)] = [...connection];
     });
 
     data.data.nodes.forEach((node) => {
-      this.engine.blocks[node.id] = { ...node };
+      const newNodeId = addBlockPrefix(node.id);
+      this.engine.blocks[newNodeId] = { ...node, id: newNodeId };
     });
 
     if (!block) {
@@ -54,11 +59,12 @@ export default async function (
     data.data.edges.forEach(({ sourceHandle, target, targetHandle }) => {
       if (outputsMap.has(sourceHandle)) return;
 
-      if (!connections[sourceHandle]) connections[sourceHandle] = [];
-      connections[sourceHandle].push({
-        id: target,
-        targetHandle,
-        sourceHandle,
+      const nodeSourceHandle = addBlockPrefix(sourceHandle);
+      if (!connections[nodeSourceHandle]) connections[nodeSourceHandle] = [];
+      connections[nodeSourceHandle].push({
+        id: addBlockPrefix(target),
+        sourceHandle: nodeSourceHandle,
+        targetHandle: addBlockPrefix(targetHandle),
       });
     });
 
@@ -69,6 +75,6 @@ export default async function (
 
   return {
     data: prevBlockData,
-    nextBlockId: [{ id: block.id }],
+    nextBlockId: [{ id: addBlockPrefix(block.id) }],
   };
 }

+ 6 - 1
src/background/workflowEngine/blocksHandler/handlerWebhook.js

@@ -9,7 +9,12 @@ export async function webhook({ data, id }, { refData }) {
 
   try {
     if (isWhitespace(data.url)) throw new Error('url-empty');
-    if (!data.url.startsWith('http')) throw new Error('invalid-url');
+    if (!data.url.startsWith('http')) {
+      const error = new Error('invalid-active-tab');
+      error.data = { url: data.url };
+
+      throw error;
+    }
 
     const newHeaders = [];
     data.headers.forEach(({ value, name }) => {

+ 8 - 4
src/background/workflowEngine/engine.js

@@ -46,11 +46,11 @@ class WorkflowEngine {
     };
     this.rowData = {};
 
+    this.logsLimit = 1001;
     this.logHistoryId = 0;
 
     let variables = {};
     let { globalData } = workflow;
-
     if (options && options?.data) {
       globalData = options.data.globalData || globalData;
       variables = isObject(options.data.variables)
@@ -196,8 +196,7 @@ class WorkflowEngine {
 
       if (BROWSER_TYPE !== 'chrome') {
         this.workflow.settings.debugMode = false;
-      }
-      if (this.workflow.settings.debugMode) {
+      } else if (this.workflow.settings.debugMode) {
         chrome.debugger.onEvent.addListener(this.onDebugEvent);
       }
       if (
@@ -214,6 +213,11 @@ class WorkflowEngine {
         }
       }
 
+      const { settings: userSettings } = await browser.storage.local.get(
+        'settings'
+      );
+      this.logsLimit = userSettings?.logsLimit || 1001;
+
       this.workflow.table = columns;
       this.startedTimestamp = Date.now();
 
@@ -255,7 +259,7 @@ class WorkflowEngine {
   addLogHistory(detail) {
     if (detail.name === 'blocks-group') return;
 
-    const isLimit = this.history.length >= 1001;
+    const isLimit = this.history.length >= this.logsLimit;
     const notErrorLog = detail.type !== 'error';
 
     if ((isLimit || !this.saveLog) && notErrorLog) return;

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

@@ -28,6 +28,7 @@
   <teleport to="body">
     <div
       v-if="!disabled"
+      id="automa-selector-overlay"
       style="
         z-index: 9999999;
         position: fixed;
@@ -75,6 +76,10 @@ let frameElement = null;
 let frameElementRect = null;
 let lastScrollPosY = window.scrollY;
 let lastScrollPosX = window.scrollX;
+const mousePosition = {
+  x: 0,
+  y: 0,
+};
 
 let hoveredElements = [];
 const elementsState = reactive({
@@ -256,8 +261,31 @@ function retrieveElementsRect({ clientX, clientY, target: eventTarget }, type) {
 function onMousemove(event) {
   if (props.pause) return;
 
+  mousePosition.x = event.clientX;
+  mousePosition.y = event.clientY;
   retrieveElementsRect(event, 'hovered');
 }
+function onKeydown(event) {
+  if (props.pause || event.repeat || event.code !== 'Space') return;
+
+  const { 1: selectedElement } = document.elementsFromPoint(
+    mousePosition.x,
+    mousePosition.y
+  );
+  if (selectedElement.id === 'automa-selector-overlay') return;
+
+  event.preventDefault();
+  event.stopPropagation();
+
+  retrieveElementsRect(
+    {
+      target: selectedElement,
+      clientX: mousePosition.x,
+      clientY: mousePosition.y,
+    },
+    'selected'
+  );
+}
 function onClick(event) {
   retrieveElementsRect(event, 'selected');
 }
@@ -282,12 +310,14 @@ function attachListeners() {
   window.addEventListener('scroll', onScroll);
   document.addEventListener('click', onClick);
   window.addEventListener('message', onMessage);
+  document.addEventListener('keydown', onKeydown);
   window.addEventListener('mousemove', onMousemove);
 }
 function detachListeners() {
   window.removeEventListener('scroll', onScroll);
   document.removeEventListener('click', onClick);
   window.removeEventListener('message', onMessage);
+  document.removeEventListener('keydown', onKeydown);
   window.removeEventListener('mousemove', onMousemove);
 }
 

+ 167 - 11
src/components/newtab/logs/LogsHistory.vue

@@ -17,6 +17,20 @@
           <div v-if="currentLog.status === 'error' && errorBlock">
             <p class="leading-tight line-clamp">
               {{ errorBlock.message }}
+              <a
+                v-if="errorBlock.messageId"
+                :href="`https://docs.automa.site/guide/workflow-errors.html#${errorBlock.messageId}`"
+                target="_blank"
+                title="About the error"
+                @click.stop
+              >
+                <v-remixicon
+                  name="riArrowLeftLine"
+                  size="20"
+                  class="text-gray-300 inline-block"
+                  rotate="135"
+                />
+              </a>
             </p>
             <p class="cursor-pointer" title="Jump to item" @click="jumpToError">
               On the {{ errorBlock.name }} block
@@ -28,7 +42,27 @@
               />
             </p>
           </div>
+          <slot name="header-prepend" />
           <div class="flex-grow" />
+          <ui-popover trigger-width class="mr-4">
+            <template #trigger>
+              <ui-button>
+                <span>Export logs</span>
+                <v-remixicon name="riArrowDropDownLine" class="ml-2 -mr-1" />
+              </ui-button>
+            </template>
+            <ui-list class="space-y-1">
+              <ui-list-item
+                v-for="type in dataExportTypes"
+                :key="type.id"
+                v-close-popover
+                class="cursor-pointer"
+                @click="exportLogs(type.id)"
+              >
+                {{ t(`log.exportData.types.${type.id}`) }}
+              </ui-list-item>
+            </ui-list>
+          </ui-popover>
           <ui-input
             v-model="state.search"
             :placeholder="t('common.search')"
@@ -91,6 +125,20 @@
                 class="text-sm line-clamp ml-2 flex-1 leading-tight text-gray-600 dark:text-gray-200"
               >
                 {{ item.message }}
+                <a
+                  v-if="item.messageId"
+                  :href="`https://docs.automa.site/guide/workflow-errors.html#${item.messageId}`"
+                  target="_blank"
+                  title="About the error"
+                  @click.stop
+                >
+                  <v-remixicon
+                    name="riArrowLeftLine"
+                    size="20"
+                    class="text-gray-300 inline-block"
+                    rotate="135"
+                  />
+                </a>
               </p>
               <router-link
                 v-if="item.logId"
@@ -140,14 +188,14 @@
           </select>
           {{
             t('components.pagination.text2', {
-              count: currentLog.history.length,
+              count: filteredLog.length,
             })
           }}
         </div>
         <ui-pagination
           v-model="pagination.currentPage"
           :per-page="pagination.perPage"
-          :records="currentLog.history.length"
+          :records="filteredLog.length"
         />
       </div>
     </div>
@@ -239,10 +287,12 @@ import {
   shallowRef,
 } from 'vue';
 import { useI18n } from 'vue-i18n';
-import { countDuration } from '@/utils/helper';
+import Papa from 'papaparse';
+import objectPath from 'object-path';
+import { countDuration, fileSaver } from '@/utils/helper';
 import { getBlocks } from '@/utils/getSharedData';
+import { dataExportTypes } from '@/utils/shared';
 import dayjs from '@/lib/dayjs';
-import objectPath from 'object-path';
 
 const SharedCodemirror = defineAsyncComponent(() =>
   import('@/components/newtab/shared/SharedCodemirror.vue')
@@ -264,6 +314,20 @@ const props = defineProps({
   },
 });
 
+const files = {
+  'plain-text': {
+    mime: 'text/plain',
+    ext: '.txt',
+  },
+  json: {
+    mime: 'application/json',
+    ext: '.json',
+  },
+  csv: {
+    mime: 'text/csv',
+    ext: '.csv',
+  },
+};
 const logsType = {
   success: {
     color: 'text-green-400',
@@ -293,6 +357,13 @@ const tabs = [
   { id: 'referenceData.prevBlockData', name: 'Previous block data' },
   { id: 'replacedValue', name: 'Replaced value' },
 ];
+const messageHasReferences = [
+  'no-tab',
+  'invalid-active-tab',
+  'no-match-tab',
+  'invalid-body',
+  'element-not-found',
+];
 
 const { t, te } = useI18n();
 
@@ -310,16 +381,15 @@ const activeLog = shallowRef(null);
 const translatedLog = computed(() =>
   props.currentLog.history.map(translateLog)
 );
-const filteredLog = computed(() =>
-  translatedLog.value.filter((log) => {
-    const query = state.search.toLocaleLowerCase();
+const filteredLog = computed(() => {
+  const query = state.search.toLocaleLowerCase();
 
-    return (
+  return translatedLog.value.filter(
+    (log) =>
       log.name.toLocaleLowerCase().includes(query) ||
       log.description?.toLocaleLowerCase().includes(query)
-    );
-  })
-);
+  );
+});
 const history = computed(() =>
   filteredLog.value.slice(
     (pagination.currentPage - 1) * pagination.perPage,
@@ -333,6 +403,7 @@ const errorBlock = computed(() => {
   if (!block || block.type !== 'error' || block.id < 25) return null;
 
   block = translateLog(block);
+
   let { name } = block;
   if (block.description) name += ` (${block.description})`;
 
@@ -340,6 +411,7 @@ const errorBlock = computed(() => {
     name,
     id: block.id,
     message: block.message,
+    messageId: block.messageId,
   };
 });
 const logCtxData = computed(() => {
@@ -352,6 +424,86 @@ const logCtxData = computed(() => {
   return JSON.stringify(logData, null, 2);
 });
 
+function exportLogs(type) {
+  let data = type === 'plain-text' ? '' : [];
+  const getItemData = {
+    'plain-text': ([
+      timestamp,
+      status,
+      name,
+      description,
+      message,
+      ctxData,
+    ]) => {
+      data += `${timestamp} - ${status} - ${name} - ${description} - ${message} - ${JSON.stringify(
+        ctxData
+      )} \n`;
+    },
+    json: ([timestamp, status, name, description, message, ctxData]) => {
+      data.push({
+        timestamp,
+        status,
+        name,
+        description,
+        message,
+        data: ctxData,
+      });
+    },
+    csv: (item, index) => {
+      if (index === 0) {
+        data.unshift([
+          'timestamp',
+          'status',
+          'name',
+          'description',
+          'message',
+          'data',
+        ]);
+      }
+
+      item[item.length - 1] = JSON.stringify(item[item.length - 1]);
+
+      data.push(item);
+    },
+  };
+  translatedLog.value.forEach((item, index) => {
+    getItemData[type](
+      [
+        dayjs(item.timestamp || Date.now()).format('DD-MM-YYYY, hh:mm:ss'),
+        item.type.toUpperCase(),
+        item.name,
+        item.description || 'NULL',
+        item.message || 'NULL',
+        props.ctxData[item.id] || null,
+      ],
+      index
+    );
+  });
+
+  switch (type) {
+    case 'plain-text':
+      data = [data];
+      break;
+    case 'csv':
+      data = [Papa.unparse(data)];
+      data.unshift(new Uint8Array([0xef, 0xbb, 0xbf]));
+      break;
+    case 'json':
+      data = [JSON.stringify(data, null, 2)];
+      break;
+    default:
+  }
+
+  const { mime, ext } = files[type];
+  const blobUrl = URL.createObjectURL(new Blob(data, { type: mime }));
+  const filename = `[${dayjs().format('DD-MM-YYYY, HH:mm:ss')}] ${
+    props.currentLog.name
+  } - logs`;
+
+  fileSaver(`${filename}${ext}`, blobUrl);
+
+  URL.revokeObjectURL(blobUrl);
+}
 function clearActiveItem() {
   state.itemId = '';
   activeLog.value = null;
@@ -373,6 +525,10 @@ function translateLog(log) {
     );
   }
 
+  if (copyLog.message && messageHasReferences.includes(copyLog.message)) {
+    copyLog.messageId = `${copyLog.message}`;
+  }
+
   copyLog.message = getTranslatation(
     { path: `log.messages.${log.message}`, params: log },
     log.message

+ 173 - 0
src/components/newtab/shared/SharedWorkflowTriggers.vue

@@ -0,0 +1,173 @@
+<template>
+  <div
+    class="overflow-auto scroll"
+    style="min-height: 350px; max-height: calc(100vh - 14rem)"
+  >
+    <ui-expand
+      v-for="(trigger, index) in triggersList"
+      :key="index"
+      class="border rounded-lg mb-2 trigger-item"
+    >
+      <template #header>
+        <p class="flex-1">
+          {{ t(`workflow.blocks.trigger.items.${trigger.type}`) }}
+        </p>
+        <v-remixicon
+          name="riDeleteBin7Line"
+          size="20"
+          class="delete-btn cursor-pointer"
+          @click.stop="triggersList.splice(index, 1)"
+        />
+      </template>
+      <div class="px-4 py-2">
+        <component
+          :is="triggersData[trigger.type]?.component"
+          :data="trigger.data"
+          @update="updateTriggerData(index, $event)"
+        />
+      </div>
+    </ui-expand>
+    <ui-popover class="mt-4">
+      <template #trigger>
+        <ui-button>
+          Add trigger
+          <hr class="h-4 border-r" />
+          <v-remixicon
+            name="riArrowLeftSLine"
+            class="ml-2 -mr-1"
+            rotate="-90"
+          />
+        </ui-button>
+      </template>
+      <ui-list class="space-y-1">
+        <ui-list-item
+          v-for="triggerType in triggersTypes"
+          :key="triggerType"
+          v-close-popover
+          class="cursor-pointer"
+          small
+          @click="addTrigger(triggerType)"
+        >
+          {{ t(`workflow.blocks.trigger.items.${triggerType}`) }}
+        </ui-list-item>
+      </ui-list>
+    </ui-popover>
+  </div>
+</template>
+<script setup>
+import { ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { nanoid } from 'nanoid/non-secure';
+import cloneDeep from 'lodash.clonedeep';
+import TriggerDate from '../workflow/edit/Trigger/TriggerDate.vue';
+import TriggerCronJob from '../workflow/edit/Trigger/TriggerCronJob.vue';
+import TriggerInterval from '../workflow/edit/Trigger/TriggerInterval.vue';
+import TriggerVisitWeb from '../workflow/edit/Trigger/TriggerVisitWeb.vue';
+import TriggerContextMenu from '../workflow/edit/Trigger/TriggerContextMenu.vue';
+import TriggerSpecificDay from '../workflow/edit/Trigger/TriggerSpecificDay.vue';
+// import TriggerElementChange from '../workflow/edit/Trigger/TriggerElementChange.vue';
+import TriggerKeyboardShortcut from '../workflow/edit/Trigger/TriggerKeyboardShortcut.vue';
+
+const props = defineProps({
+  triggers: {
+    type: Array,
+    default: () => [],
+  },
+  exclude: {
+    type: Array,
+    default: null,
+  },
+});
+const emit = defineEmits(['update:triggers', 'update']);
+
+const triggersData = {
+  // 'element-change': TriggerElementChange,
+  interval: {
+    component: TriggerInterval,
+    data: {
+      interval: 60,
+      delay: 5,
+      fixedDelay: false,
+    },
+  },
+  'cron-job': {
+    component: TriggerCronJob,
+    data: {
+      expression: '',
+    },
+  },
+  'context-menu': {
+    onlyOne: true,
+    component: TriggerContextMenu,
+    data: {
+      contextMenuName: '',
+      contextTypes: [],
+    },
+  },
+  date: {
+    component: TriggerDate,
+    data: {
+      date: '',
+    },
+  },
+  'specific-day': {
+    component: TriggerSpecificDay,
+    data: {
+      days: [],
+      time: '00:00',
+    },
+  },
+  'on-startup': {
+    onlyOne: true,
+    component: null,
+    data: null,
+  },
+  'visit-web': {
+    component: TriggerVisitWeb,
+    data: {
+      url: '',
+      isUrlRegex: false,
+    },
+  },
+  'keyboard-shortcut': {
+    component: TriggerKeyboardShortcut,
+    data: {
+      shortcut: '',
+    },
+  },
+};
+
+const triggersTypes = props.exclude
+  ? Object.keys(triggersData).filter((type) => !props.exclude.includes(type))
+  : Object.keys(triggersData);
+
+const { t } = useI18n();
+const triggersList = ref([...(props.triggers || [])]);
+
+function addTrigger(type) {
+  if (triggersData[type].onlyOne) {
+    const trigerExists = triggersList.value.some(
+      (trigger) => trigger.type === type
+    );
+    if (trigerExists) return;
+  }
+
+  triggersList.value.push({
+    id: nanoid(5),
+    type,
+    data: cloneDeep(triggersData[type].data),
+  });
+}
+function updateTriggerData(index, data) {
+  Object.assign(triggersList.value[index].data, data);
+}
+
+watch(
+  triggersList,
+  (newData) => {
+    emit('update', newData);
+    emit('update:triggers', newData);
+  },
+  { deep: true }
+);
+</script>

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

@@ -3,9 +3,9 @@
     :id="props.id"
     :class="{ disabled: isDisabled }"
     :default-edge-options="{
+      type: 'custom',
       updatable: true,
       selectable: true,
-      type: settings.lineType,
       markerEnd: settings.arrow ? MarkerType.ArrowClosed : '',
     }"
   >
@@ -13,12 +13,12 @@
     <MiniMap v-if="minimap" :node-class-name="minimapNodeClassName" />
     <div
       v-if="editorControls"
-      class="flex items-end absolute w-full p-4 left-0 bottom-0 z-10 pr-60"
+      class="flex items-center absolute w-full p-4 left-0 bottom-0 z-10 pr-60"
     >
       <slot name="controls-prepend" />
       <editor-search-blocks :editor="editor" />
-      <slot name="controls-append" />
       <div class="flex-grow pointer-events-none" />
+      <slot name="controls-append" />
       <button
         v-tooltip.group="t('workflow.editor.resetZoom')"
         class="control-button mr-2"
@@ -56,6 +56,9 @@
         @update="updateBlockData(nodeProps.id, $event)"
       />
     </template>
+    <template #edge-custom="edgeProps">
+      <editor-custom-edge v-bind="edgeProps" />
+    </template>
   </vue-flow>
 </template>
 <script setup>
@@ -72,6 +75,7 @@ import cloneDeep from 'lodash.clonedeep';
 import { useStore } from '@/stores/main';
 import { categories } from '@/utils/shared';
 import { getBlocks } from '@/utils/getSharedData';
+import EditorCustomEdge from './editor/EditorCustomEdge.vue';
 import EditorSearchBlocks from './editor/EditorSearchBlocks.vue';
 
 const props = defineProps({

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

@@ -128,6 +128,7 @@
         </option>
       </ui-select>
       <ui-select
+        v-if="data.type === 'append'"
         :model-value="data.insertDataOption || 'INSERT_ROWS'"
         class="w-full mt-2"
         @change="updateData({ insertDataOption: $event })"

+ 54 - 41
src/components/newtab/workflow/edit/EditTrigger.vue

@@ -7,47 +7,44 @@
       class="w-full mb-2"
       @change="updateData({ description: $event })"
     />
-    <ui-select
-      :model-value="data.type || 'manual'"
-      :placeholder="t('workflow.blocks.trigger.forms.triggerWorkflow')"
-      class="w-full"
-      @change="updateData({ type: $event })"
+    <ui-button
+      variant="accent"
+      class="w-full mt-4"
+      @click="state.showTriggersModal = true"
     >
-      <option v-for="(_, trigger) in triggers" :key="trigger" :value="trigger">
-        {{ t(`workflow.blocks.trigger.items.${trigger}`) }}
-      </option>
-    </ui-select>
-    <transition-expand mode="out-in">
-      <keep-alive>
-        <component
-          :is="triggers[data.type]"
-          :data="data"
-          @update="updateData"
-        />
-      </keep-alive>
-    </transition-expand>
-    <ui-button class="mt-4" @click="showModal = true">
+      Edit Triggers
+    </ui-button>
+    <ui-button class="mt-4" @click="state.showParamModal = true">
       <v-remixicon name="riCommandLine" class="mr-2 -ml-1" />
       <span>Parameters</span>
     </ui-button>
-    <ui-modal v-model="showModal" title="Parameters" content-class="max-w-4xl">
+    <ui-modal
+      v-model="state.showParamModal"
+      title="Parameters"
+      content-class="max-w-4xl"
+    >
       <edit-workflow-parameters
         :data="data.parameters"
         @update="updateData({ parameters: $event })"
       />
     </ui-modal>
+    <ui-modal
+      v-model="state.showTriggersModal"
+      title="Workflow Triggers"
+      content-class="max-w-2xl"
+    >
+      <shared-workflow-triggers
+        :triggers="state.triggers"
+        @update="updateWorkflow"
+      />
+    </ui-modal>
   </div>
 </template>
 <script setup>
-import { shallowRef } from 'vue';
+import { onMounted, reactive } from 'vue';
 import { useI18n } from 'vue-i18n';
-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';
+import { nanoid } from 'nanoid/non-secure';
+import SharedWorkflowTriggers from '@/components/newtab/shared/SharedWorkflowTriggers.vue';
 import EditWorkflowParameters from './EditWorkflowParameters.vue';
 
 const props = defineProps({
@@ -58,23 +55,39 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
-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 { t } = useI18n();
 
-const showModal = shallowRef(false);
+const state = reactive({
+  showParamModal: false,
+  showTriggersModal: false,
+  triggers: [...(props.data?.triggers || [])],
+});
 
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });
 }
+function updateWorkflow(triggers) {
+  state.triggers = triggers;
+  updateData({ triggers });
+}
+
+onMounted(() => {
+  if (props.data.triggers) return;
+
+  state.triggers = [
+    { type: props.data.type, data: { ...props.data }, id: nanoid(5) },
+  ];
+});
 </script>
+<style>
+.trigger-item > button {
+  @apply focus:ring-0;
+  text-align: left;
+  .delete-btn {
+    visibility: hidden;
+  }
+  &:hover .delete-btn {
+    visibility: visible;
+  }
+}
+</style>

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

@@ -1,5 +1,5 @@
 <template>
-  <div class="mt-4">
+  <div>
     <template v-if="!permission.has[permissionName]">
       <p>
         {{ t('workflow.blocks.trigger.contextMenus.noPermission') }}
@@ -8,7 +8,7 @@
         {{ t('workflow.blocks.trigger.contextMenus.grantPermission') }}
       </ui-button>
     </template>
-    <template v-else>
+    <template v-else-if="workflow.data">
       <ui-input
         :label="t('workflow.blocks.trigger.contextMenus.contextName')"
         :placeholder="workflow.data.value.name"
@@ -79,7 +79,7 @@ const permissionName = BROWSER_TYPE === 'firefox' ? 'menus' : 'contextMenus';
 const { t } = useI18n();
 const permission = useHasPermissions([permissionName]);
 
-const workflow = inject('workflow');
+const workflow = inject('workflow', {});
 
 function onSelectContextType(selected, type) {
   const contextTypes = [...props.data.contextTypes];
@@ -95,7 +95,7 @@ function onSelectContextType(selected, type) {
 }
 
 onMounted(() => {
-  if (props.data.contextMenuName.trim()) return;
+  if (props.data.contextMenuName.trim() || !workflow?.data) return;
 
   emit('update', { contextMenuName: workflow.data.value.name });
 });

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

@@ -0,0 +1,59 @@
+<template>
+  <ui-input
+    :model-value="data.expression"
+    :label="t('workflow.blocks.trigger.forms.cron-expression')"
+    class="w-full -mt-2"
+    placeholder="0 15 10 ? * *"
+    @change="updateCronExpression($event, true)"
+  />
+  <p
+    class="ml-1 leading-tight mt-1"
+    :class="{ 'text-red-400 dark:text-red-500': state.isError }"
+  >
+    {{ state.nextSchedule }}
+  </p>
+</template>
+<script setup>
+import { shallowReactive, onMounted } from 'vue';
+import { useI18n } from 'vue-i18n';
+import cronParser from 'cron-parser';
+import { debounce } from '@/utils/helper';
+import { readableCron } from '@/lib/cronstrue';
+import dayjs from '@/lib/dayjs';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update']);
+
+const { t } = useI18n();
+
+const state = shallowReactive({
+  isError: false,
+  readableCron: '',
+  nextSchedule: '',
+});
+
+const updateCronExpression = debounce((expression, update = false) => {
+  try {
+    const cronExpression = cronParser.parseExpression(expression);
+
+    state.isError = false;
+    state.nextSchedule = `${readableCron(expression)} - ${t(
+      'scheduledWorkflow.nextRun'
+    )}: ${dayjs(cronExpression.next()).format('DD MMM YYYY, HH:mm:ss')}`;
+
+    if (update) emit('update', { expression });
+  } catch (error) {
+    state.isError = true;
+    state.nextSchedule = error.message;
+  }
+}, 100);
+
+onMounted(() => {
+  updateCronExpression(props.data.expression);
+});
+</script>

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

@@ -1,5 +1,5 @@
 <template>
-  <div class="mt-2">
+  <div>
     <ui-input
       :model-value="data.date"
       :max="maxDate"

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

@@ -1,5 +1,5 @@
 <template>
-  <div class="mt-4">
+  <div>
     <ui-input
       v-model="observeDetail.matchPattern"
       :label="t('workflow.blocks.trigger.element-change.target')"

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

@@ -1,5 +1,5 @@
 <template>
-  <ul class="space-y-2 mt-1">
+  <ul class="space-y-2">
     <li v-for="option in types" :key="option" class="group">
       <ui-checkbox
         :model-value="modelValue[option]"

+ 32 - 34
src/components/newtab/workflow/edit/Trigger/TriggerInterval.vue

@@ -1,39 +1,37 @@
 <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 class="flex items-center">
+    <ui-input
+      :model-value="data.interval"
+      :label="t('workflow.blocks.trigger.forms.interval')"
+      type="number"
+      class="w-full"
+      placeholder="1-360"
+      min="1"
+      max="360"
+      @change="
+        updateIntervalInput($event, { key: 'interval', min: 1, max: 360 })
+      "
+    />
+    <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>
 </template>
 <script setup>
 import { useI18n } from 'vue-i18n';

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

@@ -1,5 +1,5 @@
 <template>
-  <div class="mt-2">
+  <div>
     <div class="flex items-center mb-2">
       <ui-input
         :model-value="getReadableShortcut(recordKeys.keys)"

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

@@ -1,5 +1,5 @@
 <template>
-  <div class="mt-4">
+  <div>
     <ui-popover
       :options="{ animation: null }"
       trigger-width
@@ -44,11 +44,11 @@
         {{ t('workflow.blocks.trigger.addTime') }}
       </ui-button>
     </div>
-    <div class="my-2">
+    <div class="grid grid-cols-2 gap-x-4 gap-y-2 mt-4">
       <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"
+        header-class="focus:ring-0 flex items-center w-full group text-left"
         type="time"
         class="w-full"
       >

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

@@ -1,5 +1,5 @@
 <template>
-  <div class="mt-2">
+  <div>
     <ui-input
       :model-value="data.url"
       :placeholder="t('workflow.blocks.trigger.forms.url')"

+ 112 - 0
src/components/newtab/workflow/editor/EditorCustomEdge.vue

@@ -0,0 +1,112 @@
+<template>
+  <path
+    :id="id"
+    :style="style"
+    class="vue-flow__edge-path"
+    :d="edgePath"
+    :marker-end="markerEnd"
+  />
+  <edge-text
+    v-if="label"
+    :x="center[0]"
+    :y="center[1]"
+    :label="label"
+    :label-style="{ fill: 'white' }"
+    :label-show-bg="true"
+    :label-bg-style="{ fill: '#3b82f6' }"
+    :label-bg-padding="[2, 4]"
+    :label-bg-border-radius="2"
+  />
+</template>
+<script setup>
+import { computed } from 'vue';
+import {
+  getBezierPath,
+  getSmoothStepPath,
+  getEdgeCenter,
+  EdgeText,
+} from '@braks/vue-flow';
+
+const props = defineProps({
+  id: {
+    type: String,
+    required: true,
+  },
+  sourceX: {
+    type: Number,
+    required: true,
+  },
+  sourceY: {
+    type: Number,
+    required: true,
+  },
+  targetX: {
+    type: Number,
+    required: true,
+  },
+  targetY: {
+    type: Number,
+    required: true,
+  },
+  sourcePosition: {
+    type: String,
+    required: true,
+  },
+  targetPosition: {
+    type: String,
+    required: true,
+  },
+  data: {
+    type: Object,
+    required: false,
+    default: () => ({}),
+  },
+  markerEnd: {
+    type: String,
+    required: false,
+    default: '',
+  },
+  label: {
+    type: String,
+    required: false,
+    default: '',
+  },
+  style: {
+    type: Object,
+    required: false,
+    default: null,
+  },
+});
+
+const center = computed(() => {
+  if (!props.label) return null;
+
+  return getEdgeCenter({
+    sourceX: props.sourceX,
+    sourceY: props.sourceY,
+    targetX: props.targetX,
+    targetY: props.targetY,
+  });
+});
+const edgePath = computed(() => {
+  const options = {
+    sourceX: props.sourceX,
+    sourceY: props.sourceY,
+    sourcePosition: props.sourcePosition,
+    targetX: props.targetX,
+    targetY: props.targetY,
+    targetPosition: props.targetPosition,
+  };
+
+  if (props.sourceX > props.targetX) {
+    return getSmoothStepPath(options);
+  }
+
+  return getBezierPath(options);
+});
+</script>
+<script>
+export default {
+  inheritAttrs: false,
+};
+</script>

+ 8 - 6
src/components/newtab/workflows/WorkflowsLocal.vue

@@ -26,7 +26,7 @@
           :is-pinned="true"
           :menu="menu"
           @dragstart="onDragStart"
-          @execute="executeWorkflow"
+          @execute="executeWorkflow(workflow)"
           @toggle-pin="togglePinWorkflow(workflow)"
           @toggle-disable="toggleDisableWorkflow(workflow)"
         />
@@ -42,7 +42,7 @@
         :is-pinned="state.pinnedWorkflows.includes(workflow.id)"
         :menu="menu"
         @dragstart="onDragStart"
-        @execute="executeWorkflow"
+        @execute="executeWorkflow(workflow)"
         @toggle-pin="togglePinWorkflow(workflow)"
         @toggle-disable="toggleDisableWorkflow(workflow)"
       />
@@ -105,6 +105,7 @@ import { shallowReactive, computed, onMounted, onBeforeUnmount } from 'vue';
 import { useI18n } from 'vue-i18n';
 import SelectionArea from '@viselect/vanilla';
 import browser from 'webextension-polyfill';
+import cloneDeep from 'lodash.clonedeep';
 import { arraySorter } from '@/utils/helper';
 import { sendMessage } from '@/utils/message';
 import { useUserStore } from '@/stores/user';
@@ -303,16 +304,17 @@ function deleteSelectedWorkflows({ target, key }) {
   }
 }
 function duplicateWorkflow(workflow) {
-  const copyWorkflow = { ...workflow, createdAt: Date.now() };
+  const clonedWorkflow = cloneDeep(workflow);
   const delKeys = ['$id', 'data', 'id', 'isDisabled'];
 
   delKeys.forEach((key) => {
-    delete copyWorkflow[key];
+    delete clonedWorkflow[key];
   });
 
-  copyWorkflow.name += ' - copy';
+  clonedWorkflow.createdAt = Date.now();
+  clonedWorkflow.name += ' - copy';
 
-  workflowStore.insert(copyWorkflow);
+  workflowStore.insert(clonedWorkflow);
 }
 function onDragStart({ dataTransfer, target }) {
   const payload = [...state.selectedWorkflows];

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

@@ -23,6 +23,7 @@
                 <span class="content-header">
                   <slot name="header">{{ title }}</slot>
                 </span>
+                <slot name="header-append" />
                 <v-remixicon
                   v-show="!persist"
                   class="text-gray-600 dark:text-gray-300 cursor-pointer"

+ 4 - 7
src/content/blocksHandler/handlerLoopElements.js

@@ -69,17 +69,14 @@ export default async function ({ data, id }) {
       const scrollableParent = getScrollParent(loopItems[0]);
       if (!scrollableParent) return { continue: true };
 
-      let scrollHeight = 0;
       if (data.scrollToBottom) {
-        scrollHeight = scrollableParent.scrollHeight;
+        const { scrollHeight } = scrollableParent;
+        scrollableParent.scrollTo(0, scrollHeight + 30);
       } else {
-        loopItems.forEach((item) => {
-          scrollHeight += item.getBoundingClientRect().height;
-        });
+        const lastElement = loopItems[loopItems.length - 1];
+        lastElement.scrollIntoView();
       }
 
-      scrollableParent.scrollTo(0, scrollHeight + 30);
-
       await sleep(500);
 
       elements = await handleSelector(getNewElementsOptions);

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

@@ -36,12 +36,14 @@
         <div class="flex-grow"></div>
         <button
           class="mr-2 hoverable p-1 rounded-md transition"
+          @mousedown.stop.prevent
           @click.stop.prevent="state.hide = !state.hide"
         >
           <v-remixicon :name="state.hide ? 'riEyeOffLine' : 'riEyeLine'" />
         </button>
         <button
           class="hoverable p-1 rounded-md transition"
+          @mousedown.stop.prevent
           @click.stop.prevent="destroy"
         >
           <v-remixicon name="riCloseLine" />
@@ -120,6 +122,11 @@
             </li>
           </ul>
         </div>
+        <p class="mt-1 text-sm text-gray-600">
+          Click or press
+          <kbd class="p-1 rounded-md bg-box-transparent">Space</kbd> to select
+          an element
+        </p>
       </div>
     </div>
   </div>

+ 7 - 1
src/content/services/shortcutListener.js

@@ -31,7 +31,13 @@ function workflowShortcutsListener(findWorkflow, shortcutsObj) {
   if (shortcuts.length === 0) return;
 
   const keyboardShortcuts = shortcuts.reduce((acc, [id, value]) => {
-    const workflow = findWorkflow(id);
+    let workflowId = id;
+    if (id.startsWith('trigger')) {
+      const { 1: triggerWorkflowId } = id.split(':');
+      workflowId = triggerWorkflowId;
+    }
+
+    const workflow = findWorkflow(workflowId);
     if (!workflow) return acc;
 
     (acc[value] = acc[value] || []).push({

+ 21 - 0
src/lib/cronstrue.js

@@ -0,0 +1,21 @@
+import cronstrue from 'cronstrue';
+import 'cronstrue/locales/fr';
+import 'cronstrue/locales/zh_TW';
+import 'cronstrue/locales/zh_CN';
+
+const supportedLocales = ['en', 'zh', 'zh-tw', 'fr'];
+const altLocaleId = {
+  zh: 'zh_CN',
+  'zh-tw': 'zh_TW',
+};
+
+export function readableCron(expression) {
+  const currentLang = document.documentElement.lang;
+  const locale = supportedLocales.includes(currentLang)
+    ? altLocaleId[currentLang] || currentLang
+    : 'en';
+
+  return cronstrue.toString(expression, { locale });
+}
+
+export default cronstrue;

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

@@ -322,7 +322,8 @@
           "date": "Date",
           "time": "Time",
           "url": "URL or Regex",
-          "shortcut": "Shortcut"
+          "shortcut": "Shortcut",
+          "cron-expression": "Cron expression"
         },
         "element-change": {
           "target": "Target element to observe",
@@ -357,6 +358,7 @@
         "items": {
           "manual": "Manually",
           "interval": "Interval",
+          "cron-job": "Cron job",
           "date": "On a specific date",
           "context-menu": "Context menu",
           "element-change": "When element change",

+ 12 - 0
src/newtab/App.vue

@@ -212,6 +212,18 @@ browser.runtime.onMessage.addListener(({ type, data }) => {
 
 (async () => {
   try {
+    const tabs = await browser.tabs.query({
+      url: browser.runtime.getURL('/newtab.html'),
+    });
+    if (tabs.length > 1) {
+      const firstTab = tabs.shift();
+      await browser.windows.update(firstTab.windowId, { focused: true });
+      await browser.tabs.update(firstTab.id, { active: true });
+
+      await browser.tabs.remove(tabs.map((tab) => tab.id));
+      return;
+    }
+
     const { isFirstTime } = await browser.storage.local.get('isFirstTime');
     isUpdated.value = !isFirstTime && compare(currentVersion, prevVersion, '>');
 

+ 263 - 44
src/newtab/pages/ScheduledWorkflow.vue

@@ -1,18 +1,25 @@
 <template>
   <div class="container pt-8 pb-4">
-    <h1 class="text-2xl font-semibold mb-8 capitalize">
+    <h1 class="text-2xl font-semibold mb-12 capitalize">
       {{ t('scheduledWorkflow.title', 2) }}
     </h1>
-    <ui-input
-      v-model="state.query"
-      prepend-icon="riSearch2Line"
-      :placeholder="t('common.search')"
-    />
+    <div class="flex items-center">
+      <ui-input
+        v-model="state.query"
+        prepend-icon="riSearch2Line"
+        :placeholder="t('common.search')"
+      />
+      <div class="flex-grow" />
+      <ui-button @click="scheduleState.showModal = true">
+        <v-remixicon name="riAddLine" class="-ml mr-2" />
+        Schedule workflow
+      </ui-button>
+    </div>
     <ui-table
       :headers="tableHeaders"
       :items="triggers"
       item-key="id"
-      class="w-full mt-4"
+      class="w-full mt-8"
     >
       <template #item-name="{ item }">
         <router-link
@@ -50,19 +57,80 @@
         </button>
       </template>
     </ui-table>
+    <ui-modal
+      v-model="scheduleState.showModal"
+      title="Workflow Triggers"
+      persist
+      content-class="max-w-2xl"
+    >
+      <template #header-append>
+        <div>
+          <ui-button @click="clearAddWorkflowSchedule">
+            {{ t('common.cancel') }}
+          </ui-button>
+          <ui-button
+            class="ml-4"
+            variant="accent"
+            @click="updateWorkflowTrigger"
+          >
+            {{ t('common.save') }}
+          </ui-button>
+        </div>
+      </template>
+      <ui-autocomplete
+        v-if="!scheduleState.selectedWorkflow.id"
+        :model-value="scheduleState.selectedWorkflow.query"
+        :items="workflowStore.getWorkflows"
+        block
+        class="mt-2"
+        item-key="id"
+        item-label="name"
+        @selected="onSelectedWorkflow"
+      >
+        <ui-input
+          v-model="scheduleState.selectedWorkflow.query"
+          class="w-full"
+          autocomplete="off"
+          placeholder="Search workflow"
+        />
+      </ui-autocomplete>
+      <template v-else>
+        <p class="font-semibold">
+          {{ scheduleState.selectedWorkflow.name }}
+        </p>
+        <shared-workflow-triggers
+          :key="scheduleState.selectedWorkflow.id"
+          v-model:triggers="scheduleState.selectedWorkflow.triggers"
+          :exclude="[
+            'context-menu',
+            'on-startup',
+            'visit-web',
+            'keyboard-shortcut',
+          ]"
+          class="mt-4"
+        />
+      </template>
+    </ui-modal>
   </div>
 </template>
 <script setup>
 import { onMounted, reactive, computed } from 'vue';
 import { useI18n } from 'vue-i18n';
+import { nanoid } from 'nanoid';
 import dayjs from 'dayjs';
+import cloneDeep from 'lodash.clonedeep';
 import browser from 'webextension-polyfill';
 import { useUserStore } from '@/stores/user';
 import { useWorkflowStore } from '@/stores/workflow';
 import { useTeamWorkflowStore } from '@/stores/teamWorkflow';
 import { useHostedWorkflowStore } from '@/stores/hostedWorkflow';
+import { readableCron } from '@/lib/cronstrue';
 import { findTriggerBlock, objectHasKey } from '@/utils/helper';
-import { registerWorkflowTrigger } from '@/utils/workflowTrigger';
+import {
+  registerWorkflowTrigger,
+  workflowTriggersMap,
+} from '@/utils/workflowTrigger';
+import SharedWorkflowTriggers from '@/components/newtab/shared/SharedWorkflowTriggers.vue';
 
 const { t } = useI18n();
 const userStore = useUserStore();
@@ -76,9 +144,18 @@ const state = reactive({
   triggers: [],
   activeTrigger: 'scheduled',
 });
+const scheduleState = reactive({
+  query: '',
+  showModal: false,
+  selectedWorkflow: {
+    id: '',
+    name: '',
+    triggers: [],
+  },
+});
 
 let rowId = 0;
-const scheduledTypes = ['interval', 'date', 'specific-day'];
+const scheduledTypes = ['interval', 'date', 'specific-day', 'cron-job'];
 const tableHeaders = [
   {
     value: 'name',
@@ -158,56 +235,106 @@ function scheduleText(data) {
         'DD MMM YYYY, hh:mm:ss A'
       );
       break;
+    case 'cron-job':
+      text.schedule = readableCron(data.expression);
+      break;
     default:
   }
 
   return text;
 }
-async function getTriggerObj(trigger, { id, name }) {
-  if (!trigger || !scheduledTypes.includes(trigger.type)) return null;
-
-  rowId += 1;
-  const triggerObj = {
-    name,
-    id: rowId,
-    nextRun: '-',
-    schedule: '',
-    active: false,
-    type: trigger.type,
-    workflowId: id,
-  };
-
+async function getTriggersData(triggerData, { id, name }) {
   try {
-    const alarm = await browser.alarms.get(id);
-    if (alarm) {
-      triggerObj.active = true;
-      triggerObj.nextRun = dayjs(alarm.scheduledTime).format(
-        'DD MMM YYYY, hh:mm:ss A'
-      );
-    }
+    const alarms = await browser.alarms.getAll();
+    const getTrigger = async (trigger) => {
+      try {
+        if (!trigger || !scheduledTypes.includes(trigger.type)) return null;
+
+        rowId += 1;
+        const triggerObj = {
+          name,
+          id: rowId,
+          nextRun: '-',
+          schedule: '',
+          active: false,
+          type: trigger.type,
+          workflowId: id,
+          triggerId: trigger.id || null,
+        };
+
+        const alarm = alarms.find((alarmItem) => {
+          if (trigger.id) return alarmItem.name.includes(trigger.id);
+
+          return alarmItem.name.includes(id);
+        });
+        if (alarm) {
+          triggerObj.active = true;
+          triggerObj.nextRun = dayjs(alarm.scheduledTime).format(
+            'DD MMM YYYY, hh:mm:ss A'
+          );
+        }
 
-    triggersData[rowId] = {
-      ...trigger,
-      workflow: { id, name },
+        triggersData[rowId] = {
+          ...trigger,
+          workflow: { id, name },
+        };
+        Object.assign(triggerObj, scheduleText(trigger));
+
+        return triggerObj;
+      } catch (error) {
+        return null;
+      }
     };
-    Object.assign(triggerObj, scheduleText(trigger));
 
-    return triggerObj;
+    if (triggerData?.triggers) {
+      const result = await Promise.all(
+        triggerData.triggers.map((trigger) => {
+          const triggerItemData = { ...trigger };
+          Object.assign(triggerItemData, triggerItemData.data);
+
+          delete triggerItemData.data;
+
+          return getTrigger(triggerItemData);
+        })
+      );
+
+      return result.reduce((acc, item) => {
+        if (item) {
+          acc.push(item);
+        }
+
+        return acc;
+      }, []);
+    }
+    const result = await getTrigger(triggerData);
+    if (!result) return [];
+
+    return [result];
   } catch (error) {
     console.error(error);
-    return null;
+    return [];
   }
 }
 async function refreshSchedule(id) {
   try {
-    const triggerData = triggersData[id];
+    const triggerData = triggersData[id] ? cloneDeep(triggersData[id]) : null;
     if (!triggerData) return;
 
+    const handler = workflowTriggersMap[triggerData.type];
+    if (!handler) return;
+
+    if (triggerData.id) {
+      triggerData.workflow.id = `trigger:${triggerData.workflow.id}:${triggerData.id}`;
+    }
+
     await registerWorkflowTrigger(triggerData.workflow.id, {
       data: triggerData,
     });
 
-    const triggerObj = await getTriggerObj(triggerData, triggerData.workflow);
+    const [triggerObj] = await getTriggersData(
+      triggerData,
+      triggerData.workflow
+    );
     if (!triggerObj) return;
 
     const triggerIndex = state.triggers.findIndex(
@@ -233,12 +360,13 @@ async function getWorkflowTrigger(workflow, { location, path }) {
     trigger = findTriggerBlock(drawflow)?.data;
   }
 
-  const obj = await getTriggerObj(trigger, workflow);
-
-  if (obj) {
-    obj.path = path;
-    obj.location = location;
-    state.triggers.push(obj);
+  const triggersList = await getTriggersData(trigger, workflow);
+  if (triggersList.length !== 0) {
+    triggersList.forEach((triggerData) => {
+      triggerData.path = path;
+      triggerData.location = location;
+      state.triggers.push(triggerData);
+    });
   }
 }
 function iterateWorkflows({ workflows, path, location }) {
@@ -250,6 +378,97 @@ function iterateWorkflows({ workflows, path, location }) {
 
   return Promise.allSettled(promises);
 }
+function onSelectedWorkflow({ item }) {
+  if (!item.drawflow?.nodes) return;
+
+  const triggerBlock = findTriggerBlock(item.drawflow);
+  if (!triggerBlock) return;
+
+  let { triggersList } = triggerBlock.data;
+  if (!triggersList) {
+    triggersList = [
+      {
+        data: { ...triggerBlock.data },
+        type: triggerBlock.data.type,
+        id: nanoid(5),
+      },
+    ];
+  }
+
+  scheduleState.selectedWorkflow.id = item.id;
+  scheduleState.selectedWorkflow.name = item.name;
+  scheduleState.selectedWorkflow.triggers = [...triggersList];
+}
+function clearAddWorkflowSchedule() {
+  Object.assign(scheduleState, {
+    query: '',
+    showModal: false,
+    selectedWorkflow: {
+      id: '',
+      name: '',
+      triggers: [],
+    },
+  });
+}
+async function updateWorkflowTrigger() {
+  try {
+    const {
+      triggers: workflowTriggers,
+      id,
+      name,
+    } = scheduleState.selectedWorkflow;
+    const workflowData = workflowStore.getById(id);
+    if (!workflowData || !workflowData?.drawflow?.nodes) return;
+
+    const triggerBlockIndex = workflowData.drawflow.nodes.findIndex(
+      (node) => node.label === 'trigger'
+    );
+    if (triggerBlockIndex === -1) return;
+
+    const copyNodes = [...workflowData.drawflow.nodes];
+    copyNodes[triggerBlockIndex].data.triggers = cloneDeep(workflowTriggers);
+    await workflowStore.update({
+      id,
+      data: {
+        trigger: { triggers: workflowTriggers },
+        drawflow: {
+          ...workflowData.drawflow,
+          nodes: copyNodes,
+        },
+      },
+    });
+
+    state.triggers = state.triggers.filter((trigger) => {
+      const isNotMatch =
+        scheduleState.selectedWorkflow.id !== trigger.workflowId;
+      if (!isNotMatch) {
+        delete triggersData[trigger.id];
+      }
+
+      return isNotMatch;
+    });
+
+    await registerWorkflowTrigger(id, {
+      data: { triggers: workflowTriggers },
+    });
+
+    const triggersList = await getTriggersData(
+      { triggers: workflowTriggers },
+      { id, name }
+    );
+    if (triggersList.length !== 0) {
+      triggersList.forEach((triggerData) => {
+        triggerData.location = 'Local';
+        triggerData.path = `/workflows/${id}`;
+        state.triggers.push(triggerData);
+      });
+    }
+
+    clearAddWorkflowSchedule();
+  } catch (error) {
+    console.error(error);
+  }
+}
 
 onMounted(async () => {
   try {

+ 2 - 2
src/newtab/pages/logs/Running.vue

@@ -26,8 +26,8 @@
           workflowId: running.workflowId,
         }"
       >
-        <template #prepend>
-          <div class="mb-4 text-sm">
+        <template #header-prepend>
+          <div>
             <h3 class="leading-tight">
               {{ t('common.log', 2) }}
             </h3>

+ 1 - 37
src/newtab/pages/settings/SettingsEditor.vue

@@ -1,34 +1,6 @@
 <template>
   <div class="max-w-2xl">
-    <p class="font-semibold">
-      {{ t('settings.editor.curvature.title') }}
-    </p>
-    <div class="flex items-center space-x-4 mt-2">
-      <div
-        v-for="item in lineTypes"
-        :key="item.id"
-        class="cursor-pointer"
-        role="button"
-        @click="settings.lineType = item.id"
-      >
-        <div
-          :class="{
-            'ring ring-accent': item.id === settings.lineType,
-          }"
-          class="p-0.5 rounded-lg"
-        >
-          <img
-            :src="require(`@/assets/images/${item.img}.png`)"
-            width="140"
-            class="rounded-lg"
-          />
-        </div>
-        <span class="text-sm text-gray-600 dark:text-gray-200 ml-1">
-          {{ item.name }}
-        </span>
-      </div>
-    </div>
-    <p class="font-semibold mt-8">Zoom</p>
+    <p class="font-semibold">Zoom</p>
     <div class="flex items-center mt-1 space-x-4">
       <ui-input
         v-model.number="settings.minZoom"
@@ -91,14 +63,6 @@ import { useI18n } from 'vue-i18n';
 import cloneDeep from 'lodash.clonedeep';
 import { useStore } from '@/stores/main';
 
-const lineTypes = [
-  { id: 'default', name: 'Default', img: 'default' },
-  { id: 'step', name: 'Step', img: 'step' },
-  { id: 'straight', name: 'Straight', img: 'straight' },
-  { id: 'smoothstep', name: 'Smooth step', img: 'smooth-step' },
-  { id: 'simplebezier', name: 'Simple bezier', img: 'default' },
-];
-
 const { t } = useI18n();
 const store = useStore();
 

+ 32 - 20
src/newtab/pages/settings/SettingsIndex.vue

@@ -55,26 +55,38 @@
     </p>
   </div>
   <div id="delete-logs" class="mt-12">
-    <p class="font-semibold mb-1">
-      {{ t('settings.deleteLog.title') }}
-    </p>
-    <ui-select
-      label="Delete after"
-      class="w-80"
-      :model-value="settings.deleteLogAfter"
-      @change="
-        updateSetting('deleteLogAfter', $event === 'never' ? 'never' : +$event)
-      "
-    >
-      <option v-for="day in deleteLogDays" :key="day" :value="day">
-        <template v-if="typeof day === 'string'">
-          {{ t('settings.deleteLog.deleteAfter.never') }}
-        </template>
-        <template v-else>
-          {{ t('settings.deleteLog.deleteAfter.days', { day }) }}
-        </template>
-      </option>
-    </ui-select>
+    <p class="font-semibold mb-1">Workflow Logs</p>
+    <div class="flex items-center">
+      <ui-select
+        :model-value="settings.deleteLogAfter"
+        :label="t('settings.deleteLog.title')"
+        placeholder="Delete after"
+        class="w-80"
+        @change="
+          updateSetting(
+            'deleteLogAfter',
+            $event === 'never' ? 'never' : +$event
+          )
+        "
+      >
+        <option v-for="day in deleteLogDays" :key="day" :value="day">
+          <template v-if="typeof day === 'string'">
+            {{ t('settings.deleteLog.deleteAfter.never') }}
+          </template>
+          <template v-else>
+            {{ t('settings.deleteLog.deleteAfter.days', { day }) }}
+          </template>
+        </option>
+      </ui-select>
+      <ui-input
+        :model-value="settings.logsLimit"
+        class="ml-4"
+        type="number"
+        label="Logs limit"
+        min="10"
+        @change="updateSetting('logsLimit', +$event <= 0 ? 1000 : +$event)"
+      />
+    </div>
   </div>
 </template>
 <script setup>

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

@@ -1066,6 +1066,7 @@ function initEditBlock(data) {
     editState.blockData.connections = connections;
   }
 
+  state.showSidebar = true;
   editState.editing = true;
 }
 async function updateWorkflow(data) {

+ 2 - 1
src/params/App.vue

@@ -179,9 +179,10 @@ async function addWorkflow(workflowId) {
 
     const params = triggerBlock.data.parameters.map((param) => ({
       ...param,
-      value: '',
+      value: param.defaultValue,
       inputType: param.type === 'string' ? 'text' : 'number',
     }));
+
     workflows.value.push({
       params,
       data: workflow,

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

@@ -93,6 +93,7 @@
         :workflow="workflow"
         :tab="state.activeTab"
         :pinned="true"
+        class="mb-2"
         @details="openWorkflowPage"
         @update="updateWorkflow(workflow.id, $event)"
         @execute="executeWorkflow"

+ 2 - 1
src/stores/main.js

@@ -15,10 +15,11 @@ export const useStore = defineStore('main', {
     settings: {
       locale: 'en',
       deleteLogAfter: 30,
+      logsLimit: 1000,
       editor: {
         minZoom: 0.6,
         maxZoom: 1.3,
-        arrow: false,
+        arrow: true,
         snapToGrid: false,
         lineType: 'default',
         snapGrid: { 0: 15, 1: 15 },

+ 6 - 4
src/utils/convertWorkflowData.js

@@ -1,10 +1,12 @@
 import { parseJSON, findTriggerBlock } from './helper';
 
+const getFlowData = (workflow) =>
+  typeof workflow.drawflow === 'string'
+    ? parseJSON(workflow.drawflow, {})
+    : workflow.drawflow;
+
 export default function (workflow) {
-  const data =
-    typeof workflow.drawflow === 'string'
-      ? parseJSON(workflow.drawflow, {})
-      : workflow.drawflow;
+  const data = getFlowData(workflow);
   if (!data?.drawflow) return workflow;
 
   const triggerBlock = findTriggerBlock(data);

+ 8 - 5
src/utils/workflowData.js

@@ -93,11 +93,14 @@ export function importWorkflow(attrs = {}) {
                 currentWorkflow.table || currentWorkflow.dataColumns;
               delete currentWorkflow.dataColumns;
 
-              workflowStore.insert({
-                ...currentWorkflow,
-                id: workflowId,
-                createdAt: Date.now(),
-              });
+              workflowStore.insert(
+                {
+                  ...currentWorkflow,
+                  id: workflowId,
+                  createdAt: Date.now(),
+                },
+                { duplicateId: true }
+              );
             });
 
             delete workflow.includedWorkflows;

+ 56 - 30
src/utils/workflowTrigger.js

@@ -1,5 +1,6 @@
 import browser from 'webextension-polyfill';
 import dayjs from 'dayjs';
+import cronParser from 'cron-parser';
 import { isObject } from './helper';
 
 export function registerContextMenu(workflowId, data) {
@@ -14,7 +15,7 @@ export function registerContextMenu(workflowId, data) {
     const browserContext = isFirefox ? browser.menus : browser.contextMenus;
 
     if (!browserContext) {
-      reject(new Error("Don't have context menu permission"));
+      resolve();
       return;
     }
 
@@ -59,7 +60,9 @@ export function registerContextMenu(workflowId, data) {
 
 async function removeFromWorkflowQueue(workflowId) {
   const { workflowQueue } = await browser.storage.local.get('workflowQueue');
-  const queueIndex = (workflowQueue || []).indexOf(workflowId);
+  const queueIndex = (workflowQueue || []).findIndex((id) =>
+    id.includes(workflowId)
+  );
 
   if (!workflowQueue || queueIndex === -1) return;
 
@@ -70,7 +73,12 @@ async function removeFromWorkflowQueue(workflowId) {
 
 export async function cleanWorkflowTriggers(workflowId) {
   try {
-    await browser.alarms.clear(workflowId);
+    const alarms = await browser.alarms.getAll();
+    for (const alarm of alarms) {
+      if (alarm.name.includes(workflowId)) {
+        await browser.alarms.clear(alarm.name);
+      }
+    }
 
     const { visitWebTriggers, onStartupTriggers, shortcuts } =
       await browser.storage.local.get([
@@ -80,27 +88,25 @@ export async function cleanWorkflowTriggers(workflowId) {
       ]);
 
     const keyboardShortcuts = Array.isArray(shortcuts) ? {} : shortcuts || {};
-    delete keyboardShortcuts[workflowId];
+    Object.keys(keyboardShortcuts).forEach((shortcutId) => {
+      if (!shortcutId.includes(workflowId)) return;
 
-    const startupTriggers = onStartupTriggers || [];
-    const startupTriggerIndex = startupTriggers.indexOf(workflowId);
-    if (startupTriggerIndex !== -1) {
-      startupTriggers.splice(startupTriggerIndex, 1);
-    }
+      delete keyboardShortcuts[shortcutId];
+    });
 
-    const visitWebTriggerIndex = visitWebTriggers.findIndex(
-      (item) => item.id === workflowId
+    const startupTriggers = (onStartupTriggers || []).filter(
+      (id) => !id.includes(workflowId)
+    );
+    const filteredVisitWebTriggers = visitWebTriggers.filter(
+      (item) => !item.id.includes(workflowId)
     );
-    if (visitWebTriggerIndex !== -1) {
-      visitWebTriggers.splice(visitWebTriggerIndex, 1);
-    }
 
-    await removeFromWorkflowQueue();
+    await removeFromWorkflowQueue(workflowId);
 
     await browser.storage.local.set({
-      visitWebTriggers,
       shortcuts: keyboardShortcuts,
       onStartupTriggers: startupTriggers,
+      visitWebTriggers: filteredVisitWebTriggers,
     });
 
     const removeFromContextMenu = async () => {
@@ -166,7 +172,7 @@ export function registerInterval(workflowId, data) {
   return browser.alarms.create(workflowId, alarmInfo);
 }
 
-export function registerSpecificDate(workflowId, data) {
+export async function registerSpecificDate(workflowId, data) {
   let date = Date.now() + 60000;
 
   if (data.date) {
@@ -178,7 +184,9 @@ export function registerSpecificDate(workflowId, data) {
       .valueOf();
   }
 
-  return browser.alarms.create(workflowId, {
+  if (Date.now() > date) return;
+
+  await browser.alarms.create(workflowId, {
     when: date,
   });
 }
@@ -227,22 +235,40 @@ export async function registerOnStartup() {
   // Do nothing
 }
 
+export async function registerCronJob(workflowId, data) {
+  try {
+    const cronExpression = cronParser.parseExpression(data.expression);
+    const nextSchedule = cronExpression.next();
+
+    await browser.alarms.create(workflowId, { when: nextSchedule.getTime() });
+  } catch (error) {
+    console.error(error);
+  }
+}
+
+export const workflowTriggersMap = {
+  interval: registerInterval,
+  date: registerSpecificDate,
+  'cron-job': registerCronJob,
+  'visit-web': registerVisitWeb,
+  'on-startup': registerOnStartup,
+  'specific-day': registerSpecificDay,
+  'context-menu': registerContextMenu,
+  'keyboard-shortcut': registerKeyboardShortcut,
+};
+
 export async function registerWorkflowTrigger(workflowId, { data }) {
   try {
     await cleanWorkflowTriggers(workflowId);
 
-    const triggersHandler = {
-      interval: registerInterval,
-      date: registerSpecificDate,
-      'visit-web': registerVisitWeb,
-      'on-startup': registerOnStartup,
-      'specific-day': registerSpecificDay,
-      'context-menu': registerContextMenu,
-      'keyboard-shortcut': registerKeyboardShortcut,
-    };
-
-    if (triggersHandler[data.type]) {
-      await triggersHandler[data.type](workflowId, data);
+    if (data.triggers) {
+      for (const trigger of data.triggers) {
+        const handler = workflowTriggersMap[trigger.type];
+        if (handler)
+          await handler(`trigger:${workflowId}:${trigger.id}`, trigger.data);
+      }
+    } else if (workflowTriggersMap[data.type]) {
+      await workflowTriggersMap[data.type](workflowId, data);
     }
   } catch (error) {
     console.error(error);

File diff suppressed because it is too large
+ 328 - 326
yarn.lock


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