Ahmad Kholid 2 éve
szülő
commit
f0dca88858
56 módosított fájl, 1745 hozzáadás és 924 törlés
  1. 11 9
      package.json
  2. 6 0
      src/assets/css/tailwind.css
  3. 25 8
      src/background/index.js
  4. 5 1
      src/components/newtab/shared/SharedWysiwyg.vue
  5. 0 1
      src/components/newtab/workflow/WorkflowDetailsCard.vue
  6. 1 1
      src/components/newtab/workflow/WorkflowEditBlock.vue
  7. 1 1
      src/components/newtab/workflow/WorkflowEditor.vue
  8. 31 21
      src/components/newtab/workflow/edit/EditHandleDownload.vue
  9. 65 27
      src/components/newtab/workflow/edit/EditInsertData.vue
  10. 29 0
      src/components/newtab/workflow/edit/EditLink.vue
  11. 15 5
      src/components/newtab/workflow/edit/EditLoopElements.vue
  12. 17 0
      src/components/newtab/workflow/edit/EditSaveAssets.vue
  13. 22 1
      src/components/newtab/workflow/edit/EditTabURL.vue
  14. 2 0
      src/components/newtab/workflow/edit/EditTrigger.vue
  15. 14 4
      src/components/newtab/workflow/edit/EditWorkflowParameters.vue
  16. 4 1
      src/content/blocksHandler/handlerLink.js
  17. 8 2
      src/content/blocksHandler/handlerLoopElements.js
  18. 8 0
      src/content/commandPalette/App.vue
  19. 4 0
      src/content/index.js
  20. 5 0
      src/lib/tmpl.js
  21. 15 4
      src/locales/en/blocks.json
  22. 2 1
      src/locales/en/common.json
  23. 4 0
      src/locales/en/newtab.json
  24. 4 0
      src/locales/it/blocks.json
  25. 67 7
      src/locales/zh/blocks.json
  26. 4 1
      src/locales/zh/common.json
  27. 13 0
      src/locales/zh/newtab.json
  28. 2 1
      src/manifest.chrome.json
  29. 6 2
      src/newtab/App.vue
  30. 138 558
      src/newtab/pages/Workflows.vue
  31. 8 0
      src/newtab/pages/workflows/Host.vue
  32. 23 7
      src/newtab/pages/workflows/Shared.vue
  33. 19 39
      src/newtab/pages/workflows/[id].vue
  34. 600 0
      src/newtab/pages/workflows/index.vue
  35. 31 24
      src/newtab/router.js
  36. 2 1
      src/newtab/utils/blocksValidation.js
  37. 1 3
      src/sandbox/utils/handleBlockExpression.js
  38. 2 0
      src/stores/main.js
  39. 12 2
      src/utils/helper.js
  40. 12 2
      src/utils/shared.js
  41. 20 0
      src/workflowEngine/WorkflowEngine.js
  42. 8 0
      src/workflowEngine/WorkflowState.js
  43. 22 6
      src/workflowEngine/WorkflowWorker.js
  44. 12 0
      src/workflowEngine/blocksHandler/handlerActiveTab.js
  45. 35 4
      src/workflowEngine/blocksHandler/handlerHandleDownload.js
  46. 32 8
      src/workflowEngine/blocksHandler/handlerInsertData.js
  47. 25 0
      src/workflowEngine/blocksHandler/handlerLink.js
  48. 14 3
      src/workflowEngine/blocksHandler/handlerSaveAssets.js
  49. 10 1
      src/workflowEngine/blocksHandler/handlerTabUrl.js
  50. 15 1
      src/workflowEngine/blocksHandler/handlerWebhook.js
  51. 11 1
      src/workflowEngine/index.js
  52. 4 1
      src/workflowEngine/templating/renderString.js
  53. 7 9
      src/workflowEngine/utils/webhookUtil.js
  54. 1 1
      utils/build-zip.js
  55. 6 0
      webpack.config.js
  56. 285 155
      yarn.lock

+ 11 - 9
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "1.24.3",
+  "version": "1.25.1",
   "description": "An extension for automating your browser by connecting blocks",
   "repository": {
     "type": "git",
@@ -38,23 +38,23 @@
     "@medv/finder": "^2.1.0",
     "@n8n_io/riot-tmpl": "^1.0.1",
     "@tiptap/core": "^2.0.0-beta.205",
-    "@tiptap/extension-character-count": "^2.0.0-beta.205",
+    "@tiptap/extension-character-count": "^2.0.0-beta.209",
     "@tiptap/extension-history": "^2.0.0-beta.205",
     "@tiptap/extension-image": "^2.0.0-beta.205",
     "@tiptap/extension-link": "^2.0.0-beta.205",
     "@tiptap/extension-placeholder": "^2.0.0-beta.205",
-    "@tiptap/starter-kit": "^2.0.0-beta.205",
-    "@tiptap/vue-3": "^2.0.0-beta.205",
+    "@tiptap/starter-kit": "^2.0.0-beta.209",
+    "@tiptap/vue-3": "^2.0.0-beta.209",
     "@viselect/vanilla": "^3.1.0",
-    "@vue-flow/additional-components": "^1.2.4",
-    "@vue-flow/core": "^1.5.0",
+    "@vue-flow/additional-components": "^1.3.3",
+    "@vue-flow/core": "^1.9.1",
     "@vueuse/head": "^1.0.22",
     "@vueuse/rxjs": "^9.1.1",
     "@vuex-orm/core": "^0.36.4",
     "codemirror": "^6.0.1",
     "compare-versions": "^5.0.1",
     "cron-parser": "^4.6.0",
-    "cronstrue": "^2.11.0",
+    "cronstrue": "^2.21.0",
     "crypto-js": "^4.1.1",
     "css-selector-generator": "^3.6.4",
     "dagre": "^0.8.5",
@@ -80,6 +80,7 @@
     "prosemirror-history": "^1.3.0",
     "prosemirror-keymap": "^1.2.0",
     "prosemirror-schema-list": "^1.2.2",
+    "read-excel-file": "^5.5.3",
     "rxjs": "^7.5.7",
     "sizzle": "^2.3.8",
     "tippy.js": "^6.3.1",
@@ -91,7 +92,8 @@
     "vue-toastification": "^2.0.0-rc.5",
     "vuedraggable": "^4.1.0",
     "vuex": "^4.0.2",
-    "webextension-polyfill": "^0.10.0"
+    "webextension-polyfill": "^0.10.0",
+    "xlsx": "https://cdn.sheetjs.com/xlsx-0.19.1/xlsx-0.19.1.tgz"
   },
   "devDependencies": {
     "@babel/core": "^7.19.6",
@@ -117,7 +119,7 @@
     "eslint-plugin-prettier": "^4.0.0",
     "eslint-plugin-vue": "^9.4.0",
     "file-loader": "^6.2.0",
-    "fs-extra": "^10.1.0",
+    "fs-extra": "^11.1.0",
     "html-loader": "^4.2.0",
     "html-webpack-plugin": "^5.5.0",
     "lint-staged": "^13.0.2",

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

@@ -104,8 +104,14 @@ pre {
   &::-webkit-scrollbar-track {
     background: transparent;
   }
+
+  &.scroll-xs::-webkit-scrollbar {
+    width: 5px;
+    height: 5px;
+  }
 }
 
+
 .tippy-box[data-theme~='tooltip-theme'] {
   @apply px-2 py-1 bg-gray-900 dark:bg-gray-200 dark:text-black text-sm text-gray-200 rounded-md;
 }

+ 25 - 8
src/background/index.js

@@ -1,7 +1,7 @@
 import browser from 'webextension-polyfill';
 import { MessageListener } from '@/utils/message';
 import { sleep } from '@/utils/helper';
-import getFile from '@/utils/getFile';
+import getFile, { readFileAsBase64 } from '@/utils/getFile';
 import automa from '@business';
 import { workflowState } from '@/workflowEngine';
 import { registerWorkflowTrigger } from '../utils/workflowTrigger';
@@ -9,6 +9,8 @@ import BackgroundUtils from './BackgroundUtils';
 import BackgroundWorkflowUtils from './BackgroundWorkflowUtils';
 import BackgroundEventsListeners from './BackgroundEventsListeners';
 
+const isFirefox = BROWSER_TYPE === 'firefox';
+
 browser.alarms.onAlarm.addListener(BackgroundEventsListeners.onAlarms);
 
 browser.commands.onCommand.addListener(BackgroundEventsListeners.onCommand);
@@ -31,8 +33,7 @@ browser.webNavigation.onHistoryStateUpdated.addListener(
   BackgroundEventsListeners.onHistoryStateUpdated
 );
 
-const contextMenu =
-  BROWSER_TYPE === 'firefox' ? browser.menus : browser.contextMenus;
+const contextMenu = isFirefox ? browser.menus : browser.contextMenus;
 if (contextMenu && contextMenu.onClicked) {
   contextMenu.onClicked.addListener(
     BackgroundEventsListeners.onContextMenuClicked
@@ -47,12 +48,22 @@ if (browser.notifications && browser.notifications.onClicked) {
 
 const message = new MessageListener('background');
 
-message.on('fetch', ({ type, resource }) => {
-  return fetch(resource.url, resource).then((response) => {
-    if (!response.ok) throw new Error(response.statusText);
+message.on('fetch', async ({ type, resource }) => {
+  const response = await fetch(resource.url, resource);
+  if (!response.ok) throw new Error(response.statusText);
 
-    return response[type]();
-  });
+  let result = null;
+
+  if (type === 'base64') {
+    const blob = await response.blob();
+    const base64 = await readFileAsBase64(blob);
+
+    result = base64;
+  } else {
+    result = await response[type]();
+  }
+
+  return result;
 });
 message.on('fetch:text', (url) => {
   return fetch(url).then((response) => response.text());
@@ -228,4 +239,10 @@ if (!isMV2) {
   });
 
   keepAlive();
+} else if (!isFirefox) {
+  const sandboxIframe = document.createElement('iframe');
+  sandboxIframe.src = '/sandbox.html';
+  sandboxIframe.id = 'sandbox';
+
+  document.body.appendChild(sandboxIframe);
 }

+ 5 - 1
src/components/newtab/shared/SharedWysiwyg.vue

@@ -2,7 +2,7 @@
   <div class="wysiwyg-editor">
     <slot v-if="editor" name="prepend" :editor="editor" />
     <div
-      v-if="editor && !readonly"
+      v-if="editor && toolbar && !readonly"
       class="p-2 rounded-lg backdrop-blur flex items-center sticky top-0 z-50 bg-box-transparent space-x-1 mb-2"
     >
       <button
@@ -115,6 +115,10 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
+  toolbar: {
+    type: Boolean,
+    default: true,
+  },
   readonly: Boolean,
 });
 const emit = defineEmits(['update:modelValue', 'count', 'change']);

+ 0 - 1
src/components/newtab/workflow/WorkflowDetailsCard.vue

@@ -44,7 +44,6 @@
         :class="descriptionCollapsed ? 'line-clamp' : 'whitespace-pre-wrap'"
         @click="descriptionCollapsed = !descriptionCollapsed"
       >
-        <!-- description here -->
         {{ workflow.description }}
       </p>
     </div>

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

@@ -1,5 +1,5 @@
 <template>
-  <div id="workflow-edit-block" class="px-4 overflow-auto scroll pb-1">
+  <div id="workflow-edit-block" class="px-4 overflow-auto scroll pb-1 h-full">
     <div
       class="sticky top-0 z-20 bg-white dark:bg-gray-800 pb-4 mb-2 flex items-center space-x-2"
     >

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

@@ -280,7 +280,7 @@ function applyFlowData() {
   }
 
   editor.setNodes(
-    props.data?.nodes.map((node) => ({ ...node, events: {} })) || []
+    props.data?.nodes?.map((node) => ({ ...node, events: {} })) || []
   );
   editor.setEdges(props.data?.edges || []);
   editor.setTransform({

+ 31 - 21
src/components/newtab/workflow/edit/EditHandleDownload.vue

@@ -16,22 +16,31 @@
         @change="updateData({ timeout: +$event || 1000 })"
       />
       <ui-input
-        :model-value="data.filename"
-        :label="`${t('common.fileName')} (${t('common.optional')})`"
-        placeholder="file"
-        class="mt-2 w-full"
-        @change="updateData({ filename: $event })"
+        :model-value="data.downloadId"
+        :label="t('workflow.blocks.handle-download.downloadId')"
+        class="w-full mt-2"
+        placeholder="0"
+        @change="updateData({ downloadId: $event })"
       />
-      <ui-select
-        :model-value="data.onConflict"
-        :label="t('workflow.blocks.handle-download.onConflict')"
-        class="mt-2 w-full"
-        @change="updateData({ onConflict: $event })"
-      >
-        <option v-for="item in onConflict" :key="item" :value="item">
-          {{ t(`workflow.blocks.base.downloads.onConflict.${item}`) }}
-        </option>
-      </ui-select>
+      <template v-if="!data.downloadId?.trim()">
+        <ui-input
+          :model-value="data.filename"
+          :label="`${t('common.fileName')} (${t('common.optional')})`"
+          placeholder="file"
+          class="mt-2 w-full"
+          @change="updateData({ filename: $event })"
+        />
+        <ui-select
+          :model-value="data.onConflict"
+          :label="t('workflow.blocks.handle-download.onConflict')"
+          class="mt-2 w-full"
+          @change="updateData({ onConflict: $event })"
+        >
+          <option v-for="item in onConflict" :key="item" :value="item">
+            {{ t(`workflow.blocks.base.downloads.onConflict.${item}`) }}
+          </option>
+        </ui-select>
+      </template>
       <ui-checkbox
         :model-value="data.waitForDownload"
         class="mt-4"
@@ -39,12 +48,13 @@
       >
         {{ t('workflow.blocks.handle-download.waitFile') }}
       </ui-checkbox>
-      <insert-workflow-data
-        v-if="data.waitForDownload"
-        :data="data"
-        variables
-        @update="updateData"
-      />
+      <template v-if="data.waitForDownload">
+        <hr class="my-4 w-full" />
+        <p class="text-sm dark:text-gray-300 text-gray-600">
+          {{ t('workflow.blocks.handle-download.filePath') }}
+        </p>
+        <insert-workflow-data :data="data" variables @update="updateData" />
+      </template>
     </template>
     <template v-else>
       <p class="mt-4">

+ 65 - 27
src/components/newtab/workflow/edit/EditInsertData.vue

@@ -17,7 +17,7 @@
       v-model="showModal"
       title="Insert data"
       padding="p-0"
-      content-class="max-w-2xl insert-data-modal"
+      content-class="max-w-3xl insert-data-modal"
     >
       <ul
         class="mt-4 data-list px-4 pb-4 overflow-auto scroll"
@@ -69,18 +69,36 @@
             />
           </div>
           <div class="p-2">
-            <edit-autocomplete
-              v-if="hasFileAccess && item.isFile"
-              class="w-full"
-            >
-              <ui-input
-                v-model="item.filePath"
-                class="w-full"
-                :placeholder="
-                  isFirefox ? 'File URL' : 'File absolute path/File URL'
+            <div v-if="hasFileAccess && item.isFile" class="flex items-end">
+              <edit-autocomplete class="w-full">
+                <ui-input
+                  v-model="item.filePath"
+                  class="w-full"
+                  :placeholder="
+                    isFirefox ? 'File URL' : 'File absolute path/File URL'
+                  "
+                />
+              </edit-autocomplete>
+              <template
+                v-if="
+                  /.xlsx?$/.test(item.filePath) &&
+                  (item.action || item.csvAction)?.includes?.('json')
                 "
-              />
-            </edit-autocomplete>
+              >
+                <ui-input
+                  v-model="item.xlsSheet"
+                  label="Sheet (optional)"
+                  class="ml-2"
+                  placeholder="Sheet1"
+                />
+                <ui-input
+                  v-model="item.xlsRange"
+                  label="Range (optional)"
+                  class="ml-2"
+                  placeholder="A1:C10"
+                />
+              </template>
+            </div>
             <edit-autocomplete v-else class="w-full">
               <ui-textarea
                 v-model="item.value"
@@ -124,8 +142,8 @@
                   <option value="default">Default</option>
                   <option value="base64">Read as base64</option>
                   <optgroup
-                    v-if="item.filePath.endsWith('.csv')"
-                    label="CSV File"
+                    v-if="/.(csv|xlsx?)$/.test(item.filePath)"
+                    label="CSV/Excel File"
                   >
                     <option value="json">Read as JSON</option>
                     <option value="json-header">
@@ -157,6 +175,7 @@
 import { ref, watch, inject, shallowReactive, defineAsyncComponent } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useToast } from 'vue-toastification';
+import { read as readXlsx, utils as utilsXlsx } from 'xlsx';
 import Papa from 'papaparse';
 import browser from 'webextension-polyfill';
 import getFile, { readFileAsBase64 } from '@/utils/getFile';
@@ -229,34 +248,53 @@ function setAsFile(item) {
 async function previewData(index, item) {
   try {
     const path = item.filePath || '';
+    const isExcel = /.xlsx?$/.test(path);
     const isJSON = path.endsWith('.json');
-    const isCSV = path.endsWith('.csv');
-    let action = item.action || item.csvAction || 'default';
 
-    if (action === 'text' && !isCSV) action = 'default';
+    const action = item.action || item.csvAction || 'default';
+    let responseType = 'text';
 
-    let stringify = isJSON && action !== 'base64';
-    let responseType = isJSON ? 'json' : 'text';
-
-    if (action === 'base64') responseType = 'blob';
+    if (isJSON) responseType = 'json';
+    else if (action === 'base64' || (isExcel && action !== 'default'))
+      responseType = 'blob';
 
     let result = await getFile(path, {
       responseType,
       returnValue: true,
     });
 
-    if (result && isCSV && action && action.includes('json')) {
+    const readAsJson = action.includes('json');
+
+    if (action === 'base64') {
+      result = await readFileAsBase64(result);
+    } else if (result && path.endsWith('.csv') && readAsJson) {
       const parsedCSV = Papa.parse(result, {
         header: action.includes('header'),
       });
-      result = parsedCSV.data || [];
-      stringify = true;
-    } else if (action === 'base64') {
-      result = await readFileAsBase64(result);
+      result = JSON.stringify(parsedCSV.data || [], null, 2);
+    } else if (isJSON) {
+      result = JSON.stringify(result, null, 2);
+    } else if (isExcel && readAsJson) {
+      const base64Xls = await readFileAsBase64(result);
+      const wb = readXlsx(base64Xls.slice(base64Xls.indexOf(',')), {
+        type: 'base64',
+      });
+
+      const inputtedSheet = (item.xlsSheet || '').trim();
+      const sheetName = wb.SheetNames.includes(inputtedSheet)
+        ? inputtedSheet
+        : wb.SheetNames[0];
+
+      const options = {};
+      if (item.xlsRange) options.range = item.xlsRange;
+      if (!action.includes('header')) options.header = 1;
+
+      const sheetData = utilsXlsx.sheet_to_json(wb.Sheets[sheetName], options);
+      result = JSON.stringify(sheetData, null, 2);
     }
 
     previewState.itemId = index;
-    previewState.data = stringify ? JSON.stringify(result, null, 2) : result;
+    previewState.data = result;
   } catch (error) {
     console.error(error);
     toast.error(error.message);

+ 29 - 0
src/components/newtab/workflow/edit/EditLink.vue

@@ -0,0 +1,29 @@
+<template>
+  <edit-interaction-base v-bind="{ data }" @change="updateData">
+    <ui-checkbox
+      :model-value="data.openInNewTab"
+      class="mt-4"
+      @change="updateData({ openInNewTab: $event })"
+    >
+      {{ t('workflow.blocks.link.openInNewTab') }}
+    </ui-checkbox>
+  </edit-interaction-base>
+</template>
+<script setup>
+import { useI18n } from 'vue-i18n';
+import EditInteractionBase from './EditInteractionBase.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>

+ 15 - 5
src/components/newtab/workflow/edit/EditLoopElements.vue

@@ -35,7 +35,7 @@
       <ui-select
         :model-value="data.loadMoreAction"
         :label="t('common.action')"
-        class="mt-2"
+        class="mt-2 w-full"
         @change="updateData({ loadMoreAction: $event })"
       >
         <option v-for="action in actions" :key="action" :value="action">
@@ -62,7 +62,9 @@
         />
       </edit-autocomplete>
       <ui-input
-        v-if="['click-element', 'scroll'].includes(data.loadMoreAction)"
+        v-if="
+          ['click-element', 'scroll', 'scroll-up'].includes(data.loadMoreAction)
+        "
         :model-value="data.actionElMaxWaitTime"
         label="Max seconds wait for more elements"
         class="w-full mt-2"
@@ -71,12 +73,20 @@
         @change="updateData({ actionElMaxWaitTime: +$event })"
       />
       <ui-checkbox
-        v-if="data.loadMoreAction === 'scroll'"
+        v-if="data.loadMoreAction.includes('scroll')"
         :model-value="data.scrollToBottom"
         class="mt-4"
         @change="updateData({ scrollToBottom: $event })"
       >
-        {{ t('workflow.blocks.loop-elements.scrollToBottom') }}
+        {{
+          t(
+            `workflow.blocks.loop-elements.${
+              data.loadMoreAction === 'scroll-up'
+                ? 'scrollToTop'
+                : 'scrollToBottom'
+            }`
+          )
+        }}
       </ui-checkbox>
       <ui-input
         v-if="data.loadMoreAction === 'click-link'"
@@ -106,7 +116,7 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
-const actions = ['none', 'click-element', 'click-link', 'scroll'];
+const actions = ['none', 'click-element', 'click-link', 'scroll', 'scroll-up'];
 
 const { t } = useI18n();
 

+ 17 - 0
src/components/newtab/workflow/edit/EditSaveAssets.vue

@@ -58,6 +58,22 @@
           {{ t(`workflow.blocks.base.downloads.onConflict.${item}`) }}
         </option>
       </ui-select>
+      <hr class="w-full my-4" />
+      <label class="flex items-center">
+        <ui-switch
+          :model-value="data.saveDownloadIds"
+          @change="updateData({ saveDownloadIds: $event })"
+        />
+        <p class="ml-2">
+          {{ t('workflow.blocks.save-assets.saveDownloadIds') }}
+        </p>
+      </label>
+      <insert-workflow-data
+        v-if="data.saveDownloadIds"
+        :data="data"
+        variables
+        @update="updateData"
+      />
     </template>
   </edit-interaction-base>
 </template>
@@ -66,6 +82,7 @@ import { useI18n } from 'vue-i18n';
 import { useHasPermissions } from '@/composable/hasPermissions';
 import EditInteractionBase from './EditInteractionBase.vue';
 import EditAutocomplete from './EditAutocomplete.vue';
+import InsertWorkflowData from './InsertWorkflowData.vue';
 
 const props = defineProps({
   data: {

+ 22 - 1
src/components/newtab/workflow/edit/EditTabURL.vue

@@ -16,7 +16,28 @@
         {{ t(`workflow.blocks.tab-url.types.${type}`) }}
       </option>
     </ui-select>
-    <insert-workflow-data :data="data" variables @update="updateData" />
+    <div v-if="data.type === 'all'" class="mt-4 p-2 rounded-lg border">
+      <p class="text-sm text-gray-600">
+        {{ t('workflow.blocks.tab-url.query.title') }}
+      </p>
+      <ui-input
+        :model-value="data.qMatchPatterns"
+        class="mt-2 w-full"
+        placeholder="https://example.com/*"
+        @change="updateData({ qMatchPatterns: $event })"
+      >
+        <template #label>
+          {{ t('workflow.blocks.tab-url.query.matchPatterns') }}
+        </template>
+      </ui-input>
+      <ui-input
+        :model-value="data.qTitle"
+        :label="t('workflow.blocks.tab-url.query.tabTitle')"
+        class="mt-2 w-full"
+        @change="updateData({ qTitle: $event })"
+      />
+    </div>
+    <insert-workflow-data variables @update="updateData" />
   </div>
 </template>
 <script setup>

+ 2 - 0
src/components/newtab/workflow/edit/EditTrigger.vue

@@ -24,8 +24,10 @@
       content-class="max-w-4xl"
     >
       <edit-workflow-parameters
+        :prefer-tab="data.preferParamsInTab"
         :data="data.parameters"
         @update="updateData({ parameters: $event })"
+        @update:prefer-tab="updateData({ preferParamsInTab: $event })"
       />
     </ui-modal>
     <ui-modal

+ 14 - 4
src/components/newtab/workflow/edit/EditWorkflowParameters.vue

@@ -122,9 +122,18 @@
       </draggable>
     </table>
   </div>
-  <ui-button variant="accent" class="mt-4" @click="addParameter">
-    Add parameter
-  </ui-button>
+  <div class="flex items-center mt-4">
+    <ui-button variant="accent" @click="addParameter">
+      {{ $t('workflow.parameters.add') }}
+    </ui-button>
+    <div class="flex-grow" />
+    <ui-checkbox
+      :model-value="preferTab"
+      @change="$emit('update:preferTab', $event)"
+    >
+      {{ $t('workflow.parameters.preferInTab') }}
+    </ui-checkbox>
+  </div>
 </template>
 <script setup>
 import { reactive, watch } from 'vue';
@@ -141,8 +150,9 @@ const props = defineProps({
     type: Array,
     default: () => [],
   },
+  preferTab: Boolean,
 });
-const emit = defineEmits(['update']);
+const emit = defineEmits(['update', 'update:preferTab']);
 
 const customParameters = workflowParameters();
 

+ 4 - 1
src/content/blocksHandler/handlerLink.js

@@ -6,11 +6,14 @@ async function link(block) {
   if (!element) {
     throw new Error('element-not-found');
   }
+  if (element.tagName !== 'A') {
+    throw new Error('Element is not a link');
+  }
 
   markElement(element, block);
 
   const url = element.href;
-  if (url) window.open(url, '_self');
+  if (url && !block.data.openInNewTab) window.open(url, '_self');
 
   return url;
 }

+ 8 - 2
src/content/blocksHandler/handlerLoopElements.js

@@ -60,7 +60,7 @@ export default async function ({ data, id }) {
     };
     let elements = null;
 
-    if (data.type === 'scroll') {
+    if (data.type.includes('scroll')) {
       const loopItems = document.querySelectorAll(
         `[automa-loop*="${data.loopAttrId}"]`
       );
@@ -71,7 +71,13 @@ export default async function ({ data, id }) {
 
       if (data.scrollToBottom) {
         const { scrollHeight } = scrollableParent;
-        scrollableParent.scrollTo(0, scrollHeight + 30);
+        scrollableParent.scrollTo(
+          0,
+          data.type === 'scroll-up' ? 0 : scrollHeight + 30
+        );
+      } else if (data.type === 'scroll-up') {
+        const [firstElement] = loopItems;
+        firstElement.scrollIntoView();
       } else {
         const lastElement = loopItems[loopItems.length - 1];
         lastElement.scrollIntoView();

+ 8 - 0
src/content/commandPalette/App.vue

@@ -462,6 +462,14 @@ watch(
   }, 100)
 );
 
+window.initPaletteParams = (data) => {
+  paramsState.items = data.params;
+  paramsState.workflow = data.workflow;
+  paramsState.active = true;
+
+  state.active = true;
+};
+
 onMounted(() => {
   browser.storage.local.get('automaShortcut').then(({ automaShortcut }) => {
     if (Array.isArray(automaShortcut) && automaShortcut.length < 1) return;

+ 4 - 0
src/content/index.js

@@ -250,6 +250,10 @@ function messageListener({ data, source }) {
           });
       } else {
         switch (data.type) {
+          case 'input-workflow-params':
+            window.initPaletteParams?.(data.data);
+            resolve(Boolean(window.initPaletteParams));
+            break;
           case 'content-script-exists':
             resolve(true);
             break;

+ 5 - 0
src/lib/tmpl.js

@@ -0,0 +1,5 @@
+import * as tmpl from '@n8n_io/riot-tmpl';
+
+tmpl.brackets.set('{{ }}');
+
+export default tmpl;

+ 15 - 4
src/locales/en/blocks.json

@@ -26,7 +26,7 @@
           "title": "Block settings",
           "blockTimeout": {
             "title": "Block execution timeout (millisecond)",
-            "description": "The maximum time the block is executing (0 to disable)"
+            "description": "The maximum execution time of the block (0 to disable)"
           },
           "line": {
             "title": "Lines",
@@ -186,6 +186,11 @@
         "types": {
           "active-tab": "Active tab",
           "all": "All tabs"
+        },
+        "query": {
+          "title": "Query",
+          "matchPatterns": "@:workflow.blocks.switch-tab.matchPattern (optional)",
+          "tabTitle": "Tab title (optional)"
         }
       },
       "reload-tab": {
@@ -207,6 +212,7 @@
         "name": "Save assets",
         "description": "Save assets (image, video, audio, or file) from an element or URL",
         "filename": "Filename (optional)",
+        "saveDownloadIds": "Save items' download ids",
         "contentTypes": {
           "title": "Type",
           "element": "Media element (image, audio, or video)",
@@ -228,7 +234,9 @@
         "timeout": "Timeout (milliseconds)",
         "noPermission": "Don't have permission to access the downloads",
         "onConflict": "On conflict",
-        "waitFile": "Wait for the file to be downloaded"
+        "waitFile": "Wait for the file to be downloaded",
+        "downloadId": "The file download id (optional)",
+        "filePath": "File path"
       },
       "insert-data": {
         "name": "Insert data",
@@ -552,7 +560,8 @@
       },
       "link": {
         "name": "Link",
-        "description": "Open link element"
+        "description": "Open link element",
+        "openInNewTab": "Open in new tab"
       },
       "attribute-value": {
         "name": "Attribute value",
@@ -689,11 +698,13 @@
         "description": "Iterate through elements",
         "loadMore": "Load more elements",
         "scrollToBottom": "Scroll to bottom",
+        "scrollToTop": "Scroll to top",
         "actions": {
           "none": "None",
           "click-element": "Click an element",
           "scroll": "Scroll down",
-          "click-link": "Click a link"
+          "click-link": "Click a link",
+          "scroll-up": "Scroll up"
         }
       },
       "loop-data": {

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

@@ -60,7 +60,8 @@
   "sort": {
     "sortBy": "Sort by",
     "name": "Name",
-    "createdAt": "Created date"
+    "createdAt": "Created date",
+    "mostUsed": "Most used"
   },
   "logStatus": {
     "stopped": "stopped",

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

@@ -190,6 +190,10 @@
       "unpin": "Unpin workflow",
       "pinned": "Pinned workflows"
     },
+    "parameters": {
+      "add": "Add parameter",
+      "preferInTab": "Prefer input parameters in the tab"
+    },
     "my": "My workflows",
     "import": "Import workflow",
     "new": "New workflow",

+ 4 - 0
src/locales/it/blocks.json

@@ -24,6 +24,10 @@
         },
         "settings": {
           "title": "Impostazioni del blocco",
+          "blockTimeout": {
+            "title": "Timeout di esecuzione (millisecondi)",
+            "description": "Il tempo massimo di esecuzione del blocco (0 per disabilitare)"
+          },
           "line": {
             "title": "Linee",
             "label": "Etichetta",

+ 67 - 7
src/locales/zh/blocks.json

@@ -17,8 +17,17 @@
         "timeout": "超时 (毫秒)",
         "noPermission": "Automa 没有足够的权限执行此操作",
         "grantPermission": "授予权限",
+	      "action": "操作",
+        "element": {
+          "select": "选择一个元素",
+          "verify": "验证选择器"
+        },
         "settings": {
           "title": "模块设置",
+          "blockTimeout": {
+            "title": "模块执行超时(毫秒)",
+            "description": "模块的最大执行时间(0表示禁用)"
+          },
           "line": {
             "title": "线段",
             "label": "线段标签",
@@ -107,7 +116,11 @@
           "set": "设置 cookie",
           "remove": "移除 cookies",
           "getAll": "获取所有 cookies"
-        }
+        },
+        "useJson": "使用 JSON 格式"
+      },
+      "note": {
+        "name": "注释"
       },
       "slice-variable": {
         "name": "切片变量",
@@ -115,6 +128,13 @@
         "start": "开始序列",
         "end": "结束序列"
       },
+      "workflow-state": {
+        "name": "工作流状态",
+        "description": "管理工作流状态",
+        "actions": {
+          "stop": "停止工作流"
+        }
+      },
       "regex-variable": {
         "name": "RegEx 变量",
         "description": "将变量值与正则表达式匹配"
@@ -166,6 +186,11 @@
         "types": {
           "active-tab": "活动标签页",
           "all": "所有标签页"
+        },
+        "query": {
+          "title": "查询",
+          "matchPatterns": "@:workflow.blocks.switch-tab.matchPattern (可选项)",
+          "tabTitle": "表格标题 (可选项)"
         }
       },
       "reload-tab": {
@@ -177,12 +202,17 @@
         "description": "按键或组合键",
         "target": "目标元素 (可选项)",
         "key": "按键",
-        "detect": "检测按键"
+        "detect": "检测按键",
+        "actions": {
+          "press-key": "按下一个按键",
+          "multiple-keys": "按下多个按键"
+        }
       },
       "save-assets": {
         "name": "保存资源",
         "description": "保存资源 (图像, 视频, 音频, 或文件) 从一个元素或 URL",
         "filename": "文件名 (可选项)",
+        "saveDownloadIds": "保存条目'下载 id",
         "contentTypes": {
           "title": "类型",
           "element": "多媒体元素 (图像, 音频, 或视频)",
@@ -204,7 +234,9 @@
         "timeout": "超时 (毫秒)",
         "noPermission": "没有访问下载的权限",
         "onConflict": "冲突时",
-        "waitFile": "等待下载文件"
+        "waitFile": "等待下载文件",
+        "downloadId": "文件下载 id (可选项)",
+        "filePath": "文件路径"
       },
       "insert-data": {
         "name": "插入数据",
@@ -307,7 +339,8 @@
           "date": "日期",
           "time": "时间",
           "url": "URL 或 正则表达式",
-          "shortcut": "快捷键"
+          "shortcut": "快捷键",
+          "cron-expression": "Cron 表达式"
         },
         "element-change": {
           "target": "要监测的目标元素",
@@ -342,6 +375,7 @@
         "items": {
           "manual": "手动",
           "interval": "间隔",
+          "cron-job": "Cron 任务",
           "date": "在特定日期",
           "context-menu": "上下文菜单",
           "element-change": "元素更改时",
@@ -394,7 +428,8 @@
           "get": "获取电子表格单元格值",
           "getRange": "获取电子表格范围",
           "update": "更新电子表格单元格值",
-          "append": "追加电子表格单元格值"
+          "append": "追加电子表格单元格值",
+          "clear": "清除电子表格单元格值"
         }
       },
       "active-tab": {
@@ -461,6 +496,9 @@
           "placeholder": "(毫秒)"
         }
       },
+      "parameter-prompt": {
+        "name": "参数提示"
+      },
       "get-text": {
         "name": "获取文本",
         "description": "从元素中获取文本",
@@ -522,7 +560,8 @@
       },
       "link": {
         "name": "链接",
-        "description": "打开链接元素"
+        "description": "打开链接元素",
+        "openInNewTab": "在新标签页打开"
       },
       "attribute-value": {
         "name": "属性值",
@@ -575,6 +614,13 @@
         "availabeFuncs": "可用函数:",
         "removeAfterExec": "模块执行后移除",
         "everyNewTab": "每次新建标签都执行",
+        "context": {
+          "name": "执行上下文",
+          "items": {
+            "website": "当前标签页",
+            "background": "背景"
+          }
+        },
         "modal": {
           "tabs": {
             "code": "JavaScript 代码",
@@ -624,7 +670,6 @@
       "webhook": {
         "name": "HTTP 请求",
         "description": "发出 HTTP 请求",
-        "url": "接收 POST 请求的 URL",
         "contentType": "内容类型",
         "method": "请求方法",
         "url": "请求 URL",
@@ -648,6 +693,20 @@
         "editCondition": "编辑条件",
         "fallback": "当条件为 false 时执行"
       },
+      "loop-elements": {
+        "name": "循环元素",
+        "description": "遍历元素",
+        "loadMore": "载入更多元素",
+        "scrollToBottom": "滚动到底部",
+        "scrollToTop": "滚动到顶部",
+        "actions": {
+          "none": "无",
+          "click-element": "点击一个元素",
+          "scroll": "向下滚动",
+          "click-link": "点击一个链接",
+          "scroll-up": "向上滚动"
+        }
+      },
       "loop-data": {
         "name": "循环数据",
         "description": "遍历表格或您的自定义数据",
@@ -655,6 +714,7 @@
         "refKey": "参考键",
         "startIndex": "从索引开始",
         "resumeLastWorkflow": "恢复上次工作流",
+        "reverse": "反转循环顺序",
         "modal": {
           "fileTooLarge": "文件太大,无法编辑",
           "maxFile": "最大文件尺寸为 1MB",

+ 4 - 1
src/locales/zh/common.json

@@ -5,6 +5,7 @@
     "collection": "集合 | 集合",
     "log": "日志 | 日志",
     "block": "单元 | 单元",
+    "schedule": "计划",
     "folder": "文件夹 | 文件夹",
     "new": "新建",
     "docs": "文档",
@@ -25,6 +26,7 @@
     "save": "保存",
     "data": "数据",
     "stop": "停止",
+    "action": "操作 | 操作",
     "packages": "包",
     "storage": "存储",
     "editor": "编辑器",
@@ -58,7 +60,8 @@
   "sort": {
     "sortBy": "排序方式",
     "name": "名称",
-    "createdAt": "创建日期"
+    "createdAt": "创建日期",
+    "mostUsed": "最常用"
   },
   "logStatus": {
     "stopped": "停止",

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

@@ -181,6 +181,19 @@
     }
   },
   "workflow": {
+      "previewMode": {
+      "title": "预览模式",
+      "description": "正处于预览模式,你所做的修改不会被保存下来"
+    },
+    "pinWorkflow": {
+      "pin": "固定工作流",
+      "unpin": "取消固定工作流",
+      "pinned": "已固定的工作流"
+    },
+    "parameters": {
+      "add": "添加参数",
+      "preferInTab": "在标签页中输入参数"
+    },
     "my": "我的工作流",
     "import": "导入工作流",
     "new": "新建工作流",

+ 2 - 1
src/manifest.chrome.json

@@ -26,6 +26,7 @@
       "matches": ["<all_urls>"],
       "js": ["contentScript.bundle.js"],
       "run_at": "document_start",
+      "match_about_blank": true,
       "all_frames": true
     },
     {
@@ -77,4 +78,4 @@
   "content_security_policy": {
     "sandbox": "sandbox allow-scripts allow-forms allow-popups allow-modals; script-src 'self' 'unsafe-inline' 'unsafe-eval'; child-src 'self';"
   }
-}
+}

+ 6 - 2
src/newtab/App.vue

@@ -375,8 +375,12 @@ watch(
     if (isRecording) {
       router.push('/recording');
 
-      await browser.action.setBadgeBackgroundColor({ color: '#ef4444' });
-      await browser.action.setBadgeText({ text: 'rec' });
+      await (browser.action || browser.browserAction).setBadgeBackgroundColor({
+        color: '#ef4444',
+      });
+      await (browser.action || browser.browserAction).setBadgeText({
+        text: 'rec',
+      });
     }
 
     autoDeleteLogs();

+ 138 - 558
src/newtab/pages/Workflows.vue

@@ -1,599 +1,179 @@
 <template>
-  <div class="container pt-8 pb-4">
-    <h1 class="text-2xl font-semibold capitalize">
-      {{ t('common.workflow', 2) }}
-    </h1>
-    <div class="flex items-start mt-8">
-      <div class="w-60 sticky top-8 hidden lg:block">
-        <div class="flex w-full">
-          <ui-button
-            :title="shortcut['action:new'].readable"
-            variant="accent"
-            class="border-r rounded-r-none flex-1 font-semibold"
-            @click="addWorkflowModal.show = true"
-          >
-            {{ t('workflow.new') }}
-          </ui-button>
-          <ui-popover>
-            <template #trigger>
-              <ui-button icon class="rounded-l-none" variant="accent">
-                <v-remixicon name="riArrowLeftSLine" rotate="-90" />
-              </ui-button>
-            </template>
-            <ui-list class="space-y-1">
-              <ui-list-item
-                v-close-popover
-                class="cursor-pointer"
-                @click="openImportDialog"
-              >
-                {{ t('workflow.import') }}
-              </ui-list-item>
-              <ui-list-item
-                v-close-popover
-                class="cursor-pointer"
-                @click="initRecordWorkflow"
-              >
-                {{ t('home.record.title') }}
-              </ui-list-item>
-              <ui-list-item
-                v-close-popover
-                class="cursor-pointer"
-                @click="addHostedWorkflow"
-              >
-                {{ t('workflow.host.add') }}
-              </ui-list-item>
-            </ui-list>
-          </ui-popover>
-        </div>
-        <ui-list class="mt-6 space-y-2">
-          <ui-list-item
-            tag="a"
-            href="https://www.automa.site/workflows"
-            target="_blank"
-          >
-            <v-remixicon name="riCompass3Line" />
-            <span class="ml-4 capitalize">
-              {{ t('workflow.browse') }}
-            </span>
-          </ui-list-item>
-          <ui-expand
-            v-if="state.teams.length > 0"
-            append-icon
-            header-class="px-4 py-2 rounded-lg mb-1 hoverable w-full flex items-center"
-          >
-            <template #header>
-              <v-remixicon name="riTeamLine" />
-              <span class="ml-4 capitalize flex-1 text-left">
-                Team Workflows
-              </span>
-            </template>
-            <ui-list class="space-y-1">
-              <ui-list-item
-                v-for="team in state.teams"
-                :key="team.id"
-                :active="state.teamId === team.id || +state.teamId === team.id"
-                :title="team.name"
-                color="bg-box-transparent font-semibold"
-                class="pl-14 cursor-pointer"
-                @click="updateActiveTab({ activeTab: 'team', teamId: team.id })"
-              >
-                <span class="text-overflow">
-                  {{ team.name }}
-                </span>
-              </ui-list-item>
-            </ui-list>
-          </ui-expand>
-          <ui-expand
-            :model-value="true"
-            append-icon
-            header-class="px-4 py-2 rounded-lg hoverable w-full flex items-center"
-          >
-            <template #header>
-              <v-remixicon name="riFlowChart" />
-              <span class="ml-4 capitalize flex-1 text-left">
-                {{ t('workflow.my') }}
-              </span>
-            </template>
-            <ui-list class="space-y-1 mt-1">
-              <ui-list-item
-                tag="button"
-                :active="state.activeTab === 'local'"
-                color="bg-box-transparent font-semibold"
-                class="pl-14"
-                @click="updateActiveTab({ activeTab: 'local' })"
-              >
-                <span class="capitalize">
-                  {{ t('workflow.type.local') }}
-                </span>
-              </ui-list-item>
-              <ui-list-item
-                v-if="userStore.user"
-                :active="state.activeTab === 'shared'"
-                tag="button"
-                color="bg-box-transparent font-semibold"
-                class="pl-14"
-                @click="updateActiveTab({ activeTab: 'shared' })"
-              >
-                <span class="capitalize">
-                  {{ t('workflow.type.shared') }}
-                </span>
-              </ui-list-item>
-              <ui-list-item
-                v-if="hostedWorkflows?.length > 0"
-                :active="state.activeTab === 'host'"
-                color="bg-box-transparent font-semibold"
-                tag="button"
-                class="pl-14"
-                @click="updateActiveTab({ activeTab: 'host' })"
-              >
-                <span class="capitalize">
-                  {{ t('workflow.type.host') }}
-                </span>
-              </ui-list-item>
-            </ui-list>
-          </ui-expand>
-        </ui-list>
-        <workflows-folder
-          v-if="state.activeTab === 'local'"
-          v-model="state.activeFolder"
-        />
-      </div>
-      <div
-        class="flex-1 workflows-list lg:ml-8"
-        style="min-height: calc(100vh - 8rem)"
-        @dblclick="clearSelectedWorkflows"
+  <div class="flex flex-col">
+    <div class="flex items-center border-b h-10">
+      <draggable
+        v-model="state.tabs"
+        item-key="id"
+        class="scroll overflow-auto text-gray-600 h-full dark:text-gray-300 scroll-xs flex items-center text-sm"
       >
-        <div class="flex items-center flex-wrap">
-          <div class="flex items-center w-full md:w-auto">
-            <ui-input
-              id="search-input"
-              v-model="state.query"
-              class="flex-1 md:w-auto"
-              :placeholder="`${t(`common.search`)}... (${
-                shortcut['action:search'].readable
-              })`"
-              prepend-icon="riSearch2Line"
-            />
-            <ui-popover>
-              <template #trigger>
-                <ui-button variant="accent" class="md:hidden ml-4">
-                  <v-remixicon name="riAddLine" class="mr-2 -ml-1" />
-                  <span>{{ t('common.workflow') }}</span>
-                </ui-button>
-              </template>
-              <ui-list class="space-y-1">
-                <ui-list-item
-                  v-close-popover
-                  class="cursor-pointer"
-                  @click="addWorkflowModal.show = true"
-                >
-                  {{ t('workflow.new') }}
-                </ui-list-item>
-                <ui-list-item
-                  v-close-popover
-                  class="cursor-pointer"
-                  @click="openImportDialog"
-                >
-                  {{ t('workflow.import') }}
-                </ui-list-item>
-                <ui-list-item
-                  v-close-popover
-                  class="cursor-pointer"
-                  @click="initRecordWorkflow"
-                >
-                  {{ t('home.record.title') }}
-                </ui-list-item>
-                <ui-list-item
-                  v-close-popover
-                  class="cursor-pointer"
-                  @click="addHostedWorkflow"
-                >
-                  {{ t('workflow.host.add') }}
-                </ui-list-item>
-              </ui-list>
-            </ui-popover>
-          </div>
-          <div class="flex-grow"></div>
-          <div class="w-full md:w-auto flex items-center mt-4 md:mt-0">
+        <template #item="{ element: tab, index }">
+          <button
+            :value="tab.id"
+            :class="[
+              state.activeTab === tab.id
+                ? 'border-accent dark:border-accent'
+                : 'border-transparent dark:border-transparent',
+              {
+                'bg-box-transparent text-black dark:text-gray-100':
+                  state.activeTab === tab.id,
+              },
+            ]"
+            class="flex items-center h-full px-4 cursor-default focus:ring-0 hoverable border-b-2"
+            @click="state.activeTab = tab.id"
+          >
+            <p
+              :title="tab.name"
+              class="flex-1 mr-2 text-overflow max-w-[170px]"
+            >
+              {{ tab.name }}
+            </p>
             <span
-              v-tooltip:bottom.group="t('workflow.backupCloud')"
-              class="mr-4"
+              class="p-0.5 rounded-full hoverable text-gray-600 dark:text-gray-300"
+              title="Close tab"
+              @click.stop="closeTab(index, tab)"
             >
-              <ui-button
-                tag="router-link"
-                to="/backup"
-                class="inline-block"
-                icon
-              >
-                <v-remixicon name="riUploadCloud2Line" />
-              </ui-button>
+              <v-remixicon name="riCloseLine" size="20" />
             </span>
-            <div class="flex items-center workflow-sort flex-1">
-              <ui-button
-                icon
-                class="rounded-r-none border-gray-300 dark:border-gray-700 border-r"
-                @click="
-                  state.sortOrder = state.sortOrder === 'asc' ? 'desc' : 'asc'
-                "
-              >
-                <v-remixicon
-                  :name="state.sortOrder === 'asc' ? 'riSortAsc' : 'riSortDesc'"
-                />
-              </ui-button>
-              <ui-select
-                v-model="state.sortBy"
-                :placeholder="t('sort.sortBy')"
-                class="flex-1"
-              >
-                <option v-for="sort in sorts" :key="sort" :value="sort">
-                  {{ t(`sort.${sort}`) }}
-                </option>
-              </ui-select>
-            </div>
-          </div>
-        </div>
-        <ui-tab-panels v-model="state.activeTab" class="flex-1 mt-6">
-          <ui-tab-panel value="team" cache>
-            <workflows-user-team
-              :active="state.activeTab === 'team'"
-              :team-id="state.teamId"
-              :search="state.query"
-              :sort="{ by: state.sortBy, order: state.sortOrder }"
-            />
-          </ui-tab-panel>
-          <ui-tab-panel value="shared" class="workflows-container">
-            <workflows-shared
-              :search="state.query"
-              :sort="{ by: state.sortBy, order: state.sortOrder }"
-            />
-          </ui-tab-panel>
-          <ui-tab-panel value="host" class="workflows-container">
-            <workflows-hosted
-              :search="state.query"
-              :sort="{ by: state.sortBy, order: state.sortOrder }"
-            />
-          </ui-tab-panel>
-          <ui-tab-panel value="local">
-            <workflows-local
-              :search="state.query"
-              :per-page="state.perPage"
-              :folder-id="state.activeFolder"
-              :sort="{ by: state.sortBy, order: state.sortOrder }"
-            />
-          </ui-tab-panel>
-        </ui-tab-panels>
-        <ui-card
-          v-if="workflowStore.isFirstTime"
-          class="mt-8 first-card relative dark:text-gray-200"
-        >
-          <v-remixicon
-            name="riCloseLine"
-            class="absolute top-4 right-4 cursor-pointer"
-            @click="workflowStore.isFirstTime = false"
-          />
-          <p>Create your first workflow by recording your actions:</p>
-          <ol class="list-decimal list-inside">
-            <li>Open your browser and go to your destination URL</li>
-            <li>
-              Click the "Record workflow" button, and do your simple repetitive
-              task
-            </li>
-            <li>
-              Need more help? Join
-              <a
-                href="https://discord.gg/C6khwwTE84"
-                target="_blank"
-                rel="noreferer"
-                >the community</a
-              >, or email us at
-              <a href="mailto:support@automa.site" target="_blank"
-                >support@automa.site</a
-              >
-            </li>
-          </ol>
-          <p class="mt-4">
-            Learn more about recording in
-            <a
-              href="https://docs.automa.site/guide/quick-start.html#recording-actions"
-              target="_blank"
-              >the documentation</a
-            >
-          </p>
-        </ui-card>
-      </div>
+          </button>
+        </template>
+      </draggable>
+      <button class="px-2 h-full" @click="addTab()">
+        <v-remixicon name="riAddLine" />
+      </button>
+    </div>
+    <div class="flex-1">
+      <router-view v-slot="{ Component }">
+        <keep-alive>
+          <component :is="Component" :key="$route.fullPath"></component>
+        </keep-alive>
+      </router-view>
     </div>
-    <ui-modal v-model="addWorkflowModal.show" title="Workflow">
-      <ui-input
-        v-model="addWorkflowModal.name"
-        :placeholder="t('common.name')"
-        autofocus
-        class="w-full mb-4"
-        @keyup.enter="
-          addWorkflowModal.type === 'manual'
-            ? addWorkflow()
-            : startRecordWorkflow()
-        "
-      />
-      <ui-textarea
-        v-model="addWorkflowModal.description"
-        :placeholder="t('common.description')"
-        height="165px"
-        class="w-full dark:text-gray-200"
-        max="300"
-      />
-      <p class="mb-6 text-right text-gray-600 dark:text-gray-200">
-        {{ addWorkflowModal.description.length }}/300
-      </p>
-      <div class="space-x-2 flex">
-        <ui-button class="w-full" @click="clearAddWorkflowModal">
-          {{ t('common.cancel') }}
-        </ui-button>
-        <ui-button
-          variant="accent"
-          class="w-full"
-          @click="
-            addWorkflowModal.type === 'manual'
-              ? addWorkflow()
-              : startRecordWorkflow()
-          "
-        >
-          {{
-            addWorkflowModal.type === 'manual'
-              ? t('common.add')
-              : t('home.record.button')
-          }}
-        </ui-button>
-      </div>
-    </ui-modal>
-    <shared-permissions-modal
-      v-model="permissionState.showModal"
-      :permissions="permissionState.items"
-    />
   </div>
 </template>
 <script setup>
-import { computed, shallowReactive, watch, onMounted } from 'vue';
-import { useI18n } from 'vue-i18n';
-import { useRouter } from 'vue-router';
-import { useToast } from 'vue-toastification';
-import { useDialog } from '@/composable/dialog';
-import { useShortcut } from '@/composable/shortcut';
-import { useGroupTooltip } from '@/composable/groupTooltip';
-import { fetchApi } from '@/utils/api';
-import { useUserStore } from '@/stores/user';
-import { useWorkflowStore } from '@/stores/workflow';
-import { useTeamWorkflowStore } from '@/stores/teamWorkflow';
-import { useHostedWorkflowStore } from '@/stores/hostedWorkflow';
-import { registerWorkflowTrigger } from '@/utils/workflowTrigger';
-import { isWhitespace, findTriggerBlock } from '@/utils/helper';
-import { importWorkflow, getWorkflowPermissions } from '@/utils/workflowData';
-import recordWorkflow from '@/newtab/utils/startRecordWorkflow';
-import WorkflowsLocal from '@/components/newtab/workflows/WorkflowsLocal.vue';
-import WorkflowsShared from '@/components/newtab/workflows/WorkflowsShared.vue';
-import WorkflowsHosted from '@/components/newtab/workflows/WorkflowsHosted.vue';
-import WorkflowsFolder from '@/components/newtab/workflows/WorkflowsFolder.vue';
-import WorkflowsUserTeam from '@/components/newtab/workflows/WorkflowsUserTeam.vue';
-import SharedPermissionsModal from '@/components/newtab/shared/SharedPermissionsModal.vue';
+import { reactive, onMounted, watch } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { nanoid } from 'nanoid/non-secure';
+import Draggable from 'vuedraggable';
+import { parseJSON } from '@/utils/helper';
 
-useGroupTooltip();
-const { t } = useI18n();
-const toast = useToast();
-const dialog = useDialog();
-const router = useRouter();
-const userStore = useUserStore();
-const workflowStore = useWorkflowStore();
-const teamWorkflowStore = useTeamWorkflowStore();
-const hostedWorkflowStore = useHostedWorkflowStore();
+let tabTitleTimeout = null;
 
-const sorts = ['name', 'createdAt'];
-const { teamId, active } = router.currentRoute.value.query;
-const savedSorts = JSON.parse(localStorage.getItem('workflow-sorts') || '{}');
-const validTeamId = userStore.user?.teams?.some(
-  ({ id }) => id === teamId || id === +teamId
-);
+const route = useRoute();
+const router = useRouter();
 
-const state = shallowReactive({
-  teams: [],
-  query: '',
-  activeFolder: '',
-  activeTab: active || 'local',
-  teamId: validTeamId ? teamId : '',
-  perPage: savedSorts.perPage || 18,
-  sortBy: savedSorts.sortBy || 'createdAt',
-  sortOrder: savedSorts.sortOrder || 'desc',
-});
-const addWorkflowModal = shallowReactive({
-  name: '',
-  show: false,
-  type: 'manual',
-  description: '',
-});
-const permissionState = shallowReactive({
-  items: [],
-  showModal: false,
+const state = reactive({
+  tabs: [],
+  activeTab: '',
+  tabChanging: false,
 });
 
-const hostedWorkflows = computed(() => hostedWorkflowStore.toArray);
+function addTab(detail = {}) {
+  const workflowsTab = state.tabs.find(
+    (tab) => tab.path === '/' || tab.path === '/workflows'
+  );
 
-function clearAddWorkflowModal() {
-  Object.assign(addWorkflowModal, {
-    name: '',
-    show: false,
-    type: 'manual',
-    description: '',
-  });
-}
-function initRecordWorkflow() {
-  addWorkflowModal.show = true;
-  addWorkflowModal.type = 'recording';
-}
-function startRecordWorkflow() {
-  recordWorkflow({
-    name: addWorkflowModal.name,
-    description: addWorkflowModal.description,
-  }).then(() => {
-    router.push('/recording');
-  });
-}
-function updateActiveTab(data = {}) {
-  if (data.activeTab !== 'team') data.teamId = '';
+  if (workflowsTab) {
+    state.activeTab = workflowsTab.id;
+    return;
+  }
 
-  Object.assign(state, data);
-}
-function addWorkflow() {
-  workflowStore.insert({
-    name: addWorkflowModal.name,
-    folderId: state.activeFolder,
-    description: addWorkflowModal.description,
+  const tabId = nanoid();
+
+  state.tabs.push({
+    id: tabId,
+    path: '/',
+    name: 'Workflows',
+    ...detail,
   });
-  clearAddWorkflowModal();
+  state.activeTab = tabId;
 }
-async function checkWorkflowPermissions(workflows) {
-  let requiredPermissions = [];
-
-  for (const workflow of workflows) {
-    if (workflow.drawflow) {
-      const permissions = await getWorkflowPermissions(workflow.drawflow);
-      requiredPermissions.push(...permissions);
-    }
+function closeTab(index, tab) {
+  if (state.tabs.length === 1) {
+    state.tabs[0] = {
+      path: '/',
+      id: nanoid(),
+      name: 'Workflows',
+    };
+  } else {
+    state.tabs.splice(index, 1);
   }
 
-  requiredPermissions = Array.from(new Set(requiredPermissions));
-  if (requiredPermissions.length === 0) return;
-
-  permissionState.items = requiredPermissions;
-  permissionState.showModal = true;
+  if (tab.id === state.activeTab) {
+    state.activeTab = state.tabs[0].id;
+  }
 }
-function addHostedWorkflow() {
-  dialog.prompt({
-    async: true,
-    inputType: 'url',
-    okText: t('common.add'),
-    title: t('workflow.host.add'),
-    label: t('workflow.host.id'),
-    placeholder: 'abcd123',
-    onConfirm: async (value) => {
-      if (isWhitespace(value)) return false;
-      const hostId = value.replace(/\s/g, '');
-
-      try {
-        if (!userStore.user && hostedWorkflowStore.toArray.length >= 3)
-          throw new Error('rate-exceeded');
-
-        const isTheUserHost = userStore.getHostedWorkflows.some(
-          (host) => hostId === host.hostId
-        );
-        if (isTheUserHost) throw new Error('exist');
-
-        const response = await fetchApi('/workflows/hosted', {
-          method: 'POST',
-          body: JSON.stringify({ hostId }),
-        });
-        const result = await response.json();
+function getTabTitle() {
+  if (route.name === 'workflows') return 'Workflows';
 
-        if (!response.ok) {
-          const error = new Error(result.message);
-          error.data = result.data;
-
-          throw error;
-        }
-
-        if (result === null) throw new Error('not-found');
-
-        result.hostId = `${hostId}`;
-        result.createdAt = Date.now();
-
-        await checkWorkflowPermissions([result]);
-        await hostedWorkflowStore.insert(result, hostId);
+  return `${document.title}`.replace(' - Automa', '');
+}
 
-        const triggerBlock = findTriggerBlock(result.drawflow);
-        await registerWorkflowTrigger(hostId, triggerBlock);
+watch(
+  () => state.activeTab,
+  (id) => {
+    const tab = state.tabs.find((item) => item.id === id);
+    if (!tab) return;
 
-        return true;
-      } catch (error) {
-        console.error(error);
-        const messages = {
-          exists: t('workflow.host.messages.hostExist'),
-          'rate-exceeded': t('message.rateExceeded'),
-          'not-found': t('workflow.host.messages.notFound', { id: hostId }),
-        };
-        const errorMessage = messages[error.message] || error.message;
+    state.tabChanging = true;
 
-        toast.error(errorMessage);
+    localStorage.setItem('activeTab', state.activeTab);
+    router.replace(tab.path);
 
-        return false;
-      }
-    },
-  });
-}
-async function openImportDialog() {
-  try {
-    const workflows = await importWorkflow({ multiple: true });
-    await checkWorkflowPermissions(Object.values(workflows));
-  } catch (error) {
-    console.error(error);
+    setTimeout(() => {
+      state.tabChanging = false;
+    }, 1000);
   }
-}
+);
+watch(
+  () => route.path,
+  () => {
+    if (state.tabChanging) return;
 
-const shortcut = useShortcut(['action:search', 'action:new'], ({ id }) => {
-  if (id === 'action:search') {
-    const searchInput = document.querySelector('#search-input input');
-    searchInput?.focus();
-  } else {
-    addWorkflowModal.show = true;
-  }
-});
+    const index = state.tabs.findIndex((tab) => tab.id === state.activeTab);
+    if (index === -1) return;
 
-watch(
-  () => [state.sortOrder, state.sortBy, state.perPage],
-  ([sortOrder, sortBy, perPage]) => {
-    localStorage.setItem(
-      'workflow-sorts',
-      JSON.stringify({ sortOrder, sortBy, perPage })
+    const duplicateTab = state.tabs.find(
+      (tab) => tab.path === route.path && tab.id !== state.activeTab
     );
+    if (duplicateTab) {
+      state.activeTab = duplicateTab.id;
+      state.tabs.splice(index, 1);
+      return;
+    }
+
+    clearTimeout(tabTitleTimeout);
+
+    tabTitleTimeout = setTimeout(() => {
+      Object.assign(state.tabs[index], {
+        path: route.path,
+        name: getTabTitle(),
+      });
+    }, 1000);
   }
 );
 watch(
-  () => [state.activeTab, state.teamId],
-  ([activeTab, teamIdQuery]) => {
-    const query = { active: activeTab };
+  () => state.tabs,
+  () => {
+    localStorage.setItem('tabs', JSON.stringify(state.tabs));
+  },
+  { deep: true }
+);
 
-    if (teamIdQuery) query.teamId = teamIdQuery;
+onMounted(() => {
+  const tabs = parseJSON(localStorage.getItem('tabs'), null);
+  if (tabs) {
+    state.tabs = tabs;
 
-    router.replace({ ...router.currentRoute.value, query });
+    const activeTab = localStorage.getItem('activeTab');
+    state.activeTab = activeTab || tabs[0].id;
   }
-);
 
-onMounted(() => {
-  const teams = [];
-  let unknownInputted = false;
-  Object.keys(teamWorkflowStore.workflows).forEach((id) => {
-    const userTeam = userStore.user?.teams?.find(
-      (team) => team.id === id || team.id === +id
-    );
+  if (state.tabs.length !== 0) return;
 
-    if (userTeam) {
-      teams.push({ name: userTeam.name, id: userTeam.id });
-    } else if (!unknownInputted && teamWorkflowStore.getByTeam(id).length > 0) {
-      unknownInputted = true;
-      teams.unshift({ name: '(unknown)', id: '(unknown)' });
-    }
+  addTab({
+    path: route.path,
+    name: getTabTitle(),
   });
-
-  state.teams = teams;
 });
 </script>
-<style>
-.workflow-sort select {
-  @apply rounded-l-none !important;
-}
-.workflows-container {
-  @apply grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4;
-}
-
-.first-card {
-  a {
-    @apply text-blue-400 underline;
-  }
-}
-</style>

+ 8 - 0
src/newtab/pages/workflows/Host.vue

@@ -108,6 +108,7 @@
 import { computed, reactive, onMounted, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useRoute, useRouter } from 'vue-router';
+import { useHead } from '@vueuse/head';
 import { useDialog } from '@/composable/dialog';
 import { useShortcut } from '@/composable/shortcut';
 import { useGroupTooltip } from '@/composable/groupTooltip';
@@ -155,6 +156,13 @@ const workflowStates = computed(() =>
   workflowStore.getWorkflowStates(workflowId)
 );
 
+useHead({
+  title: () =>
+    workflow.value?.name
+      ? `${workflow.value.name} workflow`
+      : 'Hosted workflow',
+});
+
 function openLogs() {
   emitter.emit('ui:logs', {
     workflowId,

+ 23 - 7
src/newtab/pages/workflows/Shared.vue

@@ -149,8 +149,9 @@
   </ui-modal>
 </template>
 <script setup>
-import { reactive, onMounted, watch, shallowRef } from 'vue';
+import { reactive, onMounted, watch, shallowRef, computed } from 'vue';
 import { useI18n } from 'vue-i18n';
+import { useHead } from '@vueuse/head';
 import { useRoute, useRouter } from 'vue-router';
 import { useToast } from 'vue-toastification';
 import browser from 'webextension-polyfill';
@@ -201,7 +202,15 @@ const state = reactive({
   trigger: 'Trigger: Manually',
 });
 const editor = shallowRef(null);
-const workflow = shallowRef(null);
+
+const workflow = computed(() => sharedWorkflowStore.getById(route.params.id));
+
+useHead({
+  title: () =>
+    workflow.value?.name
+      ? `${workflow.value.name} workflow`
+      : 'Shared workflow',
+});
 
 const changingKeys = new Set();
 
@@ -210,7 +219,10 @@ function updateSharedWorkflow(data = {}) {
     changingKeys.add(key);
   });
 
-  Object.assign(workflow.value, data);
+  sharedWorkflowStore.update({
+    data,
+    id: workflowId,
+  });
 
   if (data.drawflow) {
     editor.value.setNodes(data.drawflow.nodes);
@@ -349,14 +361,18 @@ watch(workflow, () => {
 });
 
 onMounted(() => {
-  const currentWorkflow = sharedWorkflowStore.getById(workflowId);
-  if (!currentWorkflow) {
+  if (!workflow.value) {
     router.push('/workflows');
     return;
   }
 
-  const convertedData = convertWorkflowData(currentWorkflow);
-  workflow.value = convertedData;
+  const convertedData = convertWorkflowData(workflow.value);
+  sharedWorkflowStore.update({
+    id: workflowId,
+    data: {
+      drawflow: convertedData.drawflow ?? workflow.value.drawflow,
+    },
+  });
 
   state.hasLocalCopy = workflowStore.getWorkflows.some(
     ({ id }) => id === workflowId

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

@@ -155,7 +155,7 @@
             />
           </ui-tab-panel>
         </template>
-        <ui-tab-panel cache value="editor" class="w-full">
+        <ui-tab-panel cache value="editor" class="w-full" @keydown="onKeydown">
           <workflow-editor
             v-if="state.workflowConverted"
             :id="route.params.id"
@@ -166,6 +166,7 @@
             tabindex="0"
             @init="onEditorInit"
             @edit="initEditBlock"
+            @click="onClickEditor"
             @update:node="state.dataChanged = true"
             @delete:node="state.dataChanged = true"
             @update:settings="onUpdateBlockSettings"
@@ -1226,11 +1227,6 @@ function onEditorInit(instance) {
     });
   });
 
-  const editorContainer = document.querySelector(
-    '.vue-flow__viewport.vue-flow__container'
-  );
-  editorContainer.addEventListener('click', onClickEditor);
-
   const convertToObj = (array) =>
     array.reduce((acc, item) => {
       acc[item.id] = item;
@@ -1514,12 +1510,14 @@ function undoRedoCommand(type, { target }) {
 
   executeCommand(type);
 }
-function onKeydown({ ctrlKey, metaKey, shiftKey, key, target }) {
+function onKeydown({ ctrlKey, metaKey, shiftKey, key, target, repeat }) {
+  if (repeat) return;
+
   const els = ['INPUT', 'SELECT', 'TEXTAREA'];
   if (
     els.includes(target.tagName) ||
     target.isContentEditable ||
-    !target.classList.contains('workflow-editor')
+    !target.closest('.workflow-editor')
   )
     return;
 
@@ -1570,6 +1568,18 @@ function checkWorkflowUpdate() {
       console.error(error);
     });
 }
+/* eslint-disable consistent-return */
+function onBeforeLeave() {
+  updateHostedWorkflow();
+
+  const dataNotChanged = !state.dataChanged || !haveEditAccess.value;
+  const isExternalPkg = isPackage && workflow.value.isExternal;
+  if (dataNotChanged || isExternalPkg) return;
+
+  const confirm = window.confirm(t('message.notSaved'));
+
+  if (!confirm) return false;
+}
 
 useHead({
   title: () =>
@@ -1604,28 +1614,8 @@ watch(
     window.isDataChanged = isDataChanged && haveEditAccess.value;
   }
 );
-watch(
-  () => route.params.id,
-  (value, oldValue) => {
-    if (route.name !== 'workflows-details') return;
-    if (value && oldValue && value !== oldValue) {
-      window.location.reload();
-    }
-  }
-);
-
-/* eslint-disable consistent-return */
-onBeforeRouteLeave(() => {
-  updateHostedWorkflow();
-
-  const dataNotChanged = !state.dataChanged || !haveEditAccess.value;
-  const isExternalPkg = isPackage && workflow.value.isExternal;
-  if (dataNotChanged || isExternalPkg) return;
 
-  const confirm = window.confirm(t('message.notSaved'));
-
-  if (!confirm) return false;
-});
+onBeforeRouteLeave(onBeforeLeave);
 onMounted(() => {
   if (!workflow.value) {
     router.replace(isPackage ? '/packages' : '/');
@@ -1658,18 +1648,8 @@ onMounted(() => {
   }
 
   initAutocomplete();
-
-  window.addEventListener('keydown', onKeydown);
 });
 onBeforeUnmount(() => {
-  const editorContainer = document.querySelector(
-    '.vue-flow__viewport.vue-flow__container'
-  );
-  if (editorContainer)
-    editorContainer.removeEventListener('click', onClickEditor);
-
-  window.removeEventListener('keydown', onKeydown);
-
   if (isPackage && workflow.value.isExternal) return;
   updateHostedWorkflow();
 });

+ 600 - 0
src/newtab/pages/workflows/index.vue

@@ -0,0 +1,600 @@
+<template>
+  <div class="container pt-8 pb-4">
+    <h1 class="text-2xl font-semibold capitalize">
+      {{ t('common.workflow', 2) }}
+    </h1>
+    <div class="flex items-start mt-8">
+      <div class="w-60 sticky top-8 hidden lg:block">
+        <div class="flex w-full">
+          <ui-button
+            :title="shortcut['action:new'].readable"
+            variant="accent"
+            class="border-r rounded-r-none flex-1 font-semibold"
+            @click="addWorkflowModal.show = true"
+          >
+            {{ t('workflow.new') }}
+          </ui-button>
+          <ui-popover>
+            <template #trigger>
+              <ui-button icon class="rounded-l-none" variant="accent">
+                <v-remixicon name="riArrowLeftSLine" rotate="-90" />
+              </ui-button>
+            </template>
+            <ui-list class="space-y-1">
+              <ui-list-item
+                v-close-popover
+                class="cursor-pointer"
+                @click="openImportDialog"
+              >
+                {{ t('workflow.import') }}
+              </ui-list-item>
+              <ui-list-item
+                v-close-popover
+                class="cursor-pointer"
+                @click="initRecordWorkflow"
+              >
+                {{ t('home.record.title') }}
+              </ui-list-item>
+              <ui-list-item
+                v-close-popover
+                class="cursor-pointer"
+                @click="addHostedWorkflow"
+              >
+                {{ t('workflow.host.add') }}
+              </ui-list-item>
+            </ui-list>
+          </ui-popover>
+        </div>
+        <ui-list class="mt-6 space-y-2">
+          <ui-list-item
+            tag="a"
+            href="https://www.automa.site/workflows"
+            target="_blank"
+          >
+            <v-remixicon name="riCompass3Line" />
+            <span class="ml-4 capitalize">
+              {{ t('workflow.browse') }}
+            </span>
+          </ui-list-item>
+          <ui-expand
+            v-if="state.teams.length > 0"
+            append-icon
+            header-class="px-4 py-2 rounded-lg mb-1 hoverable w-full flex items-center"
+          >
+            <template #header>
+              <v-remixicon name="riTeamLine" />
+              <span class="ml-4 capitalize flex-1 text-left">
+                Team Workflows
+              </span>
+            </template>
+            <ui-list class="space-y-1">
+              <ui-list-item
+                v-for="team in state.teams"
+                :key="team.id"
+                :active="state.teamId === team.id || +state.teamId === team.id"
+                :title="team.name"
+                color="bg-box-transparent font-semibold"
+                class="pl-14 cursor-pointer"
+                @click="updateActiveTab({ activeTab: 'team', teamId: team.id })"
+              >
+                <span class="text-overflow">
+                  {{ team.name }}
+                </span>
+              </ui-list-item>
+            </ui-list>
+          </ui-expand>
+          <ui-expand
+            :model-value="true"
+            append-icon
+            header-class="px-4 py-2 rounded-lg hoverable w-full flex items-center"
+          >
+            <template #header>
+              <v-remixicon name="riFlowChart" />
+              <span class="ml-4 capitalize flex-1 text-left">
+                {{ t('workflow.my') }}
+              </span>
+            </template>
+            <ui-list class="space-y-1 mt-1">
+              <ui-list-item
+                tag="button"
+                :active="state.activeTab === 'local'"
+                color="bg-box-transparent font-semibold"
+                class="pl-14"
+                @click="updateActiveTab({ activeTab: 'local' })"
+              >
+                <span class="capitalize">
+                  {{ t('workflow.type.local') }}
+                </span>
+              </ui-list-item>
+              <ui-list-item
+                v-if="userStore.user"
+                :active="state.activeTab === 'shared'"
+                tag="button"
+                color="bg-box-transparent font-semibold"
+                class="pl-14"
+                @click="updateActiveTab({ activeTab: 'shared' })"
+              >
+                <span class="capitalize">
+                  {{ t('workflow.type.shared') }}
+                </span>
+              </ui-list-item>
+              <ui-list-item
+                v-if="hostedWorkflows?.length > 0"
+                :active="state.activeTab === 'host'"
+                color="bg-box-transparent font-semibold"
+                tag="button"
+                class="pl-14"
+                @click="updateActiveTab({ activeTab: 'host' })"
+              >
+                <span class="capitalize">
+                  {{ t('workflow.type.host') }}
+                </span>
+              </ui-list-item>
+            </ui-list>
+          </ui-expand>
+        </ui-list>
+        <workflows-folder
+          v-if="state.activeTab === 'local'"
+          v-model="state.activeFolder"
+        />
+      </div>
+      <div
+        class="flex-1 workflows-list lg:ml-8"
+        style="min-height: calc(100vh - 8rem)"
+        @dblclick="clearSelectedWorkflows"
+      >
+        <div class="flex items-center flex-wrap">
+          <div class="flex items-center w-full md:w-auto">
+            <ui-input
+              id="search-input"
+              v-model="state.query"
+              class="flex-1 md:w-auto"
+              :placeholder="`${t(`common.search`)}... (${
+                shortcut['action:search'].readable
+              })`"
+              prepend-icon="riSearch2Line"
+            />
+            <ui-popover>
+              <template #trigger>
+                <ui-button variant="accent" class="md:hidden ml-4">
+                  <v-remixicon name="riAddLine" class="mr-2 -ml-1" />
+                  <span>{{ t('common.workflow') }}</span>
+                </ui-button>
+              </template>
+              <ui-list class="space-y-1">
+                <ui-list-item
+                  v-close-popover
+                  class="cursor-pointer"
+                  @click="addWorkflowModal.show = true"
+                >
+                  {{ t('workflow.new') }}
+                </ui-list-item>
+                <ui-list-item
+                  v-close-popover
+                  class="cursor-pointer"
+                  @click="openImportDialog"
+                >
+                  {{ t('workflow.import') }}
+                </ui-list-item>
+                <ui-list-item
+                  v-close-popover
+                  class="cursor-pointer"
+                  @click="initRecordWorkflow"
+                >
+                  {{ t('home.record.title') }}
+                </ui-list-item>
+                <ui-list-item
+                  v-close-popover
+                  class="cursor-pointer"
+                  @click="addHostedWorkflow"
+                >
+                  {{ t('workflow.host.add') }}
+                </ui-list-item>
+              </ui-list>
+            </ui-popover>
+          </div>
+          <div class="flex-grow"></div>
+          <div class="w-full md:w-auto flex items-center mt-4 md:mt-0">
+            <span
+              v-tooltip:bottom.group="t('workflow.backupCloud')"
+              class="mr-4"
+            >
+              <ui-button
+                tag="router-link"
+                to="/backup"
+                class="inline-block"
+                icon
+              >
+                <v-remixicon name="riUploadCloud2Line" />
+              </ui-button>
+            </span>
+            <div class="flex items-center workflow-sort flex-1">
+              <ui-button
+                icon
+                class="rounded-r-none border-gray-300 dark:border-gray-700 border-r"
+                @click="
+                  state.sortOrder = state.sortOrder === 'asc' ? 'desc' : 'asc'
+                "
+              >
+                <v-remixicon
+                  :name="state.sortOrder === 'asc' ? 'riSortAsc' : 'riSortDesc'"
+                />
+              </ui-button>
+              <ui-select
+                v-model="state.sortBy"
+                :placeholder="t('sort.sortBy')"
+                class="flex-1"
+              >
+                <option v-for="sort in sorts" :key="sort" :value="sort">
+                  {{ t(`sort.${sort}`) }}
+                </option>
+              </ui-select>
+            </div>
+          </div>
+        </div>
+        <ui-tab-panels v-model="state.activeTab" class="flex-1 mt-6">
+          <ui-tab-panel value="team" cache>
+            <workflows-user-team
+              :active="state.activeTab === 'team'"
+              :team-id="state.teamId"
+              :search="state.query"
+              :sort="{ by: state.sortBy, order: state.sortOrder }"
+            />
+          </ui-tab-panel>
+          <ui-tab-panel value="shared" class="workflows-container">
+            <workflows-shared
+              :search="state.query"
+              :sort="{ by: state.sortBy, order: state.sortOrder }"
+            />
+          </ui-tab-panel>
+          <ui-tab-panel value="host" class="workflows-container">
+            <workflows-hosted
+              :search="state.query"
+              :sort="{ by: state.sortBy, order: state.sortOrder }"
+            />
+          </ui-tab-panel>
+          <ui-tab-panel value="local">
+            <workflows-local
+              :search="state.query"
+              :per-page="state.perPage"
+              :folder-id="state.activeFolder"
+              :sort="{ by: state.sortBy, order: state.sortOrder }"
+            />
+          </ui-tab-panel>
+        </ui-tab-panels>
+        <ui-card
+          v-if="workflowStore.isFirstTime"
+          class="mt-8 first-card relative dark:text-gray-200"
+        >
+          <v-remixicon
+            name="riCloseLine"
+            class="absolute top-4 right-4 cursor-pointer"
+            @click="workflowStore.isFirstTime = false"
+          />
+          <p>Create your first workflow by recording your actions:</p>
+          <ol class="list-decimal list-inside">
+            <li>Open your browser and go to your destination URL</li>
+            <li>
+              Click the "Record workflow" button, and do your simple repetitive
+              task
+            </li>
+            <li>
+              Need more help? Join
+              <a
+                href="https://discord.gg/C6khwwTE84"
+                target="_blank"
+                rel="noreferer"
+                >the community</a
+              >, or email us at
+              <a href="mailto:support@automa.site" target="_blank"
+                >support@automa.site</a
+              >
+            </li>
+          </ol>
+          <p class="mt-4">
+            Learn more about recording in
+            <a
+              href="https://docs.automa.site/guide/quick-start.html#recording-actions"
+              target="_blank"
+              >the documentation</a
+            >
+          </p>
+        </ui-card>
+      </div>
+    </div>
+    <ui-modal v-model="addWorkflowModal.show" title="Workflow">
+      <ui-input
+        v-model="addWorkflowModal.name"
+        :placeholder="t('common.name')"
+        autofocus
+        class="w-full mb-4"
+        @keyup.enter="
+          addWorkflowModal.type === 'manual'
+            ? addWorkflow()
+            : startRecordWorkflow()
+        "
+      />
+      <ui-textarea
+        v-model="addWorkflowModal.description"
+        :placeholder="t('common.description')"
+        height="165px"
+        class="w-full dark:text-gray-200"
+        max="300"
+      />
+      <p class="mb-6 text-right text-gray-600 dark:text-gray-200">
+        {{ addWorkflowModal.description.length }}/300
+      </p>
+      <div class="space-x-2 flex">
+        <ui-button class="w-full" @click="clearAddWorkflowModal">
+          {{ t('common.cancel') }}
+        </ui-button>
+        <ui-button
+          variant="accent"
+          class="w-full"
+          @click="
+            addWorkflowModal.type === 'manual'
+              ? addWorkflow()
+              : startRecordWorkflow()
+          "
+        >
+          {{
+            addWorkflowModal.type === 'manual'
+              ? t('common.add')
+              : t('home.record.button')
+          }}
+        </ui-button>
+      </div>
+    </ui-modal>
+    <shared-permissions-modal
+      v-model="permissionState.showModal"
+      :permissions="permissionState.items"
+    />
+  </div>
+</template>
+<script setup>
+import { computed, shallowReactive, watch, onMounted } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useRouter } from 'vue-router';
+import { useToast } from 'vue-toastification';
+import { useDialog } from '@/composable/dialog';
+import { useShortcut } from '@/composable/shortcut';
+import { useGroupTooltip } from '@/composable/groupTooltip';
+import { fetchApi } from '@/utils/api';
+import { useUserStore } from '@/stores/user';
+import { useWorkflowStore } from '@/stores/workflow';
+import { useTeamWorkflowStore } from '@/stores/teamWorkflow';
+import { useHostedWorkflowStore } from '@/stores/hostedWorkflow';
+import { registerWorkflowTrigger } from '@/utils/workflowTrigger';
+import { isWhitespace, findTriggerBlock } from '@/utils/helper';
+import { importWorkflow, getWorkflowPermissions } from '@/utils/workflowData';
+import recordWorkflow from '@/newtab/utils/startRecordWorkflow';
+import WorkflowsLocal from '@/components/newtab/workflows/WorkflowsLocal.vue';
+import WorkflowsShared from '@/components/newtab/workflows/WorkflowsShared.vue';
+import WorkflowsHosted from '@/components/newtab/workflows/WorkflowsHosted.vue';
+import WorkflowsFolder from '@/components/newtab/workflows/WorkflowsFolder.vue';
+import WorkflowsUserTeam from '@/components/newtab/workflows/WorkflowsUserTeam.vue';
+import SharedPermissionsModal from '@/components/newtab/shared/SharedPermissionsModal.vue';
+
+useGroupTooltip();
+
+const { t } = useI18n();
+const toast = useToast();
+const dialog = useDialog();
+const router = useRouter();
+const userStore = useUserStore();
+const workflowStore = useWorkflowStore();
+const teamWorkflowStore = useTeamWorkflowStore();
+const hostedWorkflowStore = useHostedWorkflowStore();
+
+const sorts = ['name', 'createdAt', 'mostUsed'];
+const { teamId, active } = router.currentRoute.value.query;
+const savedSorts = JSON.parse(localStorage.getItem('workflow-sorts') || '{}');
+const validTeamId = userStore.user?.teams?.some(
+  ({ id }) => id === teamId || id === +teamId
+);
+
+const state = shallowReactive({
+  teams: [],
+  query: '',
+  activeFolder: '',
+  activeTab: active || 'local',
+  teamId: validTeamId ? teamId : '',
+  perPage: savedSorts.perPage || 18,
+  sortBy: savedSorts.sortBy || 'createdAt',
+  sortOrder: savedSorts.sortOrder || 'desc',
+});
+const addWorkflowModal = shallowReactive({
+  name: '',
+  show: false,
+  type: 'manual',
+  description: '',
+});
+const permissionState = shallowReactive({
+  items: [],
+  showModal: false,
+});
+
+const hostedWorkflows = computed(() => hostedWorkflowStore.toArray);
+
+function clearAddWorkflowModal() {
+  Object.assign(addWorkflowModal, {
+    name: '',
+    show: false,
+    type: 'manual',
+    description: '',
+  });
+}
+function initRecordWorkflow() {
+  addWorkflowModal.show = true;
+  addWorkflowModal.type = 'recording';
+}
+function startRecordWorkflow() {
+  recordWorkflow({
+    name: addWorkflowModal.name,
+    description: addWorkflowModal.description,
+  }).then(() => {
+    router.push('/recording');
+  });
+}
+function updateActiveTab(data = {}) {
+  if (data.activeTab !== 'team') data.teamId = '';
+
+  Object.assign(state, data);
+}
+function addWorkflow() {
+  workflowStore.insert({
+    name: addWorkflowModal.name,
+    folderId: state.activeFolder,
+    description: addWorkflowModal.description,
+  });
+  clearAddWorkflowModal();
+}
+async function checkWorkflowPermissions(workflows) {
+  let requiredPermissions = [];
+
+  for (const workflow of workflows) {
+    if (workflow.drawflow) {
+      const permissions = await getWorkflowPermissions(workflow.drawflow);
+      requiredPermissions.push(...permissions);
+    }
+  }
+
+  requiredPermissions = Array.from(new Set(requiredPermissions));
+  if (requiredPermissions.length === 0) return;
+
+  permissionState.items = requiredPermissions;
+  permissionState.showModal = true;
+}
+function addHostedWorkflow() {
+  dialog.prompt({
+    async: true,
+    inputType: 'url',
+    okText: t('common.add'),
+    title: t('workflow.host.add'),
+    label: t('workflow.host.id'),
+    placeholder: 'abcd123',
+    onConfirm: async (value) => {
+      if (isWhitespace(value)) return false;
+      const hostId = value.replace(/\s/g, '');
+
+      try {
+        if (!userStore.user && hostedWorkflowStore.toArray.length >= 3)
+          throw new Error('rate-exceeded');
+
+        const isTheUserHost = userStore.getHostedWorkflows.some(
+          (host) => hostId === host.hostId
+        );
+        if (isTheUserHost) throw new Error('exist');
+
+        const response = await fetchApi('/workflows/hosted', {
+          method: 'POST',
+          body: JSON.stringify({ hostId }),
+        });
+        const result = await response.json();
+
+        if (!response.ok) {
+          const error = new Error(result.message);
+          error.data = result.data;
+
+          throw error;
+        }
+
+        if (result === null) throw new Error('not-found');
+
+        result.hostId = `${hostId}`;
+        result.createdAt = Date.now();
+
+        await checkWorkflowPermissions([result]);
+        await hostedWorkflowStore.insert(result, hostId);
+
+        const triggerBlock = findTriggerBlock(result.drawflow);
+        await registerWorkflowTrigger(hostId, triggerBlock);
+
+        return true;
+      } catch (error) {
+        console.error(error);
+        const messages = {
+          exists: t('workflow.host.messages.hostExist'),
+          'rate-exceeded': t('message.rateExceeded'),
+          'not-found': t('workflow.host.messages.notFound', { id: hostId }),
+        };
+        const errorMessage = messages[error.message] || error.message;
+
+        toast.error(errorMessage);
+
+        return false;
+      }
+    },
+  });
+}
+async function openImportDialog() {
+  try {
+    const workflows = await importWorkflow({ multiple: true });
+    await checkWorkflowPermissions(Object.values(workflows));
+  } catch (error) {
+    console.error(error);
+  }
+}
+
+const shortcut = useShortcut(['action:search', 'action:new'], ({ id }) => {
+  if (id === 'action:search') {
+    const searchInput = document.querySelector('#search-input input');
+    searchInput?.focus();
+  } else {
+    addWorkflowModal.show = true;
+  }
+});
+
+watch(
+  () => [state.sortOrder, state.sortBy, state.perPage],
+  ([sortOrder, sortBy, perPage]) => {
+    localStorage.setItem(
+      'workflow-sorts',
+      JSON.stringify({ sortOrder, sortBy, perPage })
+    );
+  }
+);
+watch(
+  () => [state.activeTab, state.teamId],
+  ([activeTab, teamIdQuery]) => {
+    const query = { active: activeTab };
+
+    if (teamIdQuery) query.teamId = teamIdQuery;
+
+    router.replace({ ...router.currentRoute.value, query });
+  }
+);
+
+onMounted(() => {
+  const teams = [];
+  let unknownInputted = false;
+  Object.keys(teamWorkflowStore.workflows).forEach((id) => {
+    const userTeam = userStore.user?.teams?.find(
+      (team) => team.id === id || team.id === +id
+    );
+
+    if (userTeam) {
+      teams.push({ name: userTeam.name, id: userTeam.id });
+    } else if (!unknownInputted && teamWorkflowStore.getByTeam(id).length > 0) {
+      unknownInputted = true;
+      teams.unshift({ name: '(unknown)', id: '(unknown)' });
+    }
+  });
+
+  state.teams = teams;
+});
+</script>
+<style>
+.workflow-sort select {
+  @apply rounded-l-none !important;
+}
+.workflows-container {
+  @apply grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4;
+}
+
+.first-card {
+  a {
+    @apply text-blue-400 underline;
+  }
+}
+</style>

+ 31 - 24
src/newtab/router.js

@@ -1,7 +1,8 @@
 import { createRouter, createWebHashHistory } from 'vue-router';
 import Welcome from './pages/Welcome.vue';
 import Packages from './pages/Packages.vue';
-import Workflows from './pages/Workflows.vue';
+import Workflows from './pages/workflows/index.vue';
+import WorkflowContainer from './pages/Workflows.vue';
 import WorkflowHost from './pages/workflows/Host.vue';
 import WorkflowDetails from './pages/workflows/[id].vue';
 import WorkflowShared from './pages/workflows/Shared.vue';
@@ -45,35 +46,41 @@ const routes = [
     component: WorkflowDetails,
   },
   {
-    name: 'workflows',
     path: '/workflows',
-    component: Workflows,
+    component: WorkflowContainer,
+    children: [
+      {
+        path: '',
+        name: 'workflows',
+        component: Workflows,
+      },
+      {
+        path: ':id',
+        name: 'workflows-details',
+        component: WorkflowDetails,
+      },
+      {
+        name: 'team-workflows',
+        path: '/teams/:teamId/workflows/:id',
+        component: WorkflowDetails,
+      },
+      {
+        name: 'workflow-host',
+        path: '/workflows/:id/host',
+        component: WorkflowHost,
+      },
+      {
+        name: 'workflow-shared',
+        path: '/workflows/:id/shared',
+        component: WorkflowShared,
+      },
+    ],
   },
   {
     name: 'schedule',
     path: '/schedule',
     component: ScheduledWorkflow,
   },
-  {
-    name: 'team-workflows',
-    path: '/teams/:teamId/workflows/:id',
-    component: WorkflowDetails,
-  },
-  {
-    name: 'workflows-details',
-    path: '/workflows/:id',
-    component: WorkflowDetails,
-  },
-  {
-    name: 'workflow-host',
-    path: '/workflows/:id/host',
-    component: WorkflowHost,
-  },
-  {
-    name: 'workflow-shared',
-    path: '/workflows/:id/shared',
-    component: WorkflowShared,
-  },
   {
     name: 'storage',
     path: '/storage',
@@ -86,7 +93,7 @@ const routes = [
   },
   {
     name: 'logs-details',
-    path: '/logs/:id',
+    path: '/logs/:id?',
     component: LogsDetails,
   },
   {

+ 2 - 1
src/newtab/utils/blocksValidation.js

@@ -247,7 +247,8 @@ export async function validateSaveAssets(data) {
     errors.push(
       "Don't have download permission (ignore if you already grant the permissions)"
     );
-  else if (isEmptyStr(data.selector)) errors.push('The Selector is empty');
+  else if (isEmptyStr(data.selector) && data.type === 'element')
+    errors.push('The Selector is empty');
 
   return errors;
 }

+ 1 - 3
src/sandbox/utils/handleBlockExpression.js

@@ -1,8 +1,6 @@
-import * as tmpl from '@n8n_io/riot-tmpl';
+import tmpl from '@/lib/tmpl';
 import functions from '@/workflowEngine/templating/templatingFunctions';
 
-tmpl.brackets.set('{{ }}');
-
 const templatingFunctions = Object.keys(functions).reduce((acc, funcName) => {
   acc[`$${funcName}`] = functions[funcName];
 

+ 2 - 0
src/stores/main.js

@@ -5,9 +5,11 @@ import deepmerge from 'lodash.merge';
 
 export const useStore = defineStore('main', {
   storageMap: {
+    tabs: 'tabs',
     settings: 'settings',
   },
   state: () => ({
+    tabs: [],
     copiedEls: {
       edges: [],
       nodes: [],

+ 12 - 2
src/utils/helper.js

@@ -281,12 +281,22 @@ export async function clearCache(workflow) {
 }
 
 export function arraySorter({ data, key, order = 'asc' }) {
+  let runCounts = {};
   const copyData = data.slice();
 
+  if (key === 'mostUsed') {
+    runCounts = parseJSON(localStorage.getItem('runCounts'), {}) || {};
+  }
+
   return copyData.sort((a, b) => {
     let comparison = 0;
-    const itemA = a[key] || a;
-    const itemB = b[key] || b;
+    let itemA = a[key] || a;
+    let itemB = b[key] || b;
+
+    if (key === 'mostUsed') {
+      itemA = runCounts[a.id] || 0;
+      itemB = runCounts[b.id] || 0;
+    }
 
     if (itemA > itemB) {
       comparison = 1;

+ 12 - 2
src/utils/shared.js

@@ -27,6 +27,7 @@ export const tasks = {
       contextMenuName: '',
       contextTypes: [],
       parameters: [],
+      preferParamsInTab: false,
       observeElement: {
         selector: '',
         baseSelector: '',
@@ -414,7 +415,7 @@ export const tasks = {
     description: 'Open link element',
     icon: 'riLink',
     component: 'BlockBasic',
-    editComponent: 'EditInteractionBase',
+    editComponent: 'EditLink',
     category: 'interaction',
     inputs: 1,
     outputs: 1,
@@ -430,6 +431,7 @@ export const tasks = {
       selector: '',
       markEl: false,
       disableMultiple: true,
+      openInNewTab: false,
     },
   },
   'attribute-value': {
@@ -942,7 +944,12 @@ export const tasks = {
       type: 'element',
       url: '',
       filename: '',
+      saveDownloadIds: false,
       onConflict: 'uniquify',
+      dataColumn: '',
+      saveData: true,
+      assignVariable: false,
+      variableName: '',
     },
   },
   'press-key': {
@@ -997,7 +1004,7 @@ export const tasks = {
     outputs: 1,
     allowedInputs: true,
     maxConnection: 1,
-    refDataKeys: ['filename'],
+    refDataKeys: ['filename', 'downloadId'],
     autocomplete: ['variableName'],
     data: {
       disableBlock: false,
@@ -1010,6 +1017,7 @@ export const tasks = {
       saveData: true,
       assignVariable: false,
       variableName: '',
+      downloadId: '',
     },
   },
   'reload-tab': {
@@ -1124,6 +1132,8 @@ export const tasks = {
       saveData: true,
       assignVariable: false,
       variableName: '',
+      qTitle: '',
+      qMatchPatterns: '',
     },
   },
   'slice-variable': {

+ 20 - 0
src/workflowEngine/WorkflowEngine.js

@@ -143,6 +143,25 @@ class WorkflowEngine {
       if (hasParams) {
         this.eventListeners = {};
 
+        if (triggerBlock.data.preferParamsInTab) {
+          const [activeTab] = await browser.tabs.query({
+            active: true,
+            url: '*://*/*',
+            lastFocusedWindow: true,
+          });
+          if (activeTab) {
+            const result = await browser.tabs.sendMessage(activeTab.id, {
+              type: 'input-workflow-params',
+              data: {
+                workflow: this.workflow,
+                params: triggerBlock.data.parameters,
+              },
+            });
+
+            if (result) return;
+          }
+        }
+
         const paramUrl = browser.runtime.getURL('params.html');
         const tabs = await browser.tabs.query({});
         const paramTab = tabs.find((tab) => tab.url?.includes(paramUrl));
@@ -323,6 +342,7 @@ class WorkflowEngine {
           prevBlockData: detail.prevBlockData || '',
         },
         replacedValue: cloneDeep(detail.replacedValue),
+        ...(detail?.ctxData || {}),
       };
 
       delete detail.replacedValue;

+ 8 - 0
src/workflowEngine/WorkflowState.js

@@ -1,4 +1,5 @@
 /* eslint-disable  no-param-reassign */
+import browser from 'webextension-polyfill';
 
 class WorkflowState {
   constructor({ storage, key = 'workflowState' }) {
@@ -9,6 +10,11 @@ class WorkflowState {
     this.eventListeners = {};
   }
 
+  _updateBadge() {
+    const browserAction = browser.action || browser.browserAction;
+    browserAction.setBadgeText({ text: (this.states.size || '').toString() });
+  }
+
   _saveToStorage() {
     const states = Object.fromEntries(this.states);
     return this.storage.set(this.key, states);
@@ -58,6 +64,7 @@ class WorkflowState {
 
   async add(id, data = {}) {
     this.states.set(id, data);
+    this._updateBadge();
     await this._saveToStorage(this.key);
   }
 
@@ -84,6 +91,7 @@ class WorkflowState {
   async delete(id) {
     this.states.delete(id);
     this.dispatchEvent('delete', id);
+    this._updateBadge();
     await this._saveToStorage();
   }
 }

+ 22 - 6
src/workflowEngine/WorkflowWorker.js

@@ -109,7 +109,18 @@ class WorkflowWorker {
   }
 
   setVariable(name, value) {
-    this.engine.referenceData.variables[name] = value;
+    const vars = this.engine.referenceData.variables;
+
+    if (name.startsWith('$push:')) {
+      const { 1: varName } = name.split('$push:');
+
+      if (!objectHasKey(vars, varName)) vars[varName] = [];
+      else if (!Array.isArray(vars[varName])) vars[varName] = [vars[varName]];
+
+      vars[varName].push(value);
+    }
+
+    vars[name] = value;
     this.engine.addRefDataSnapshot('variables');
   }
 
@@ -256,6 +267,7 @@ class WorkflowWorker {
 
         addBlockLog(result.status || 'success', {
           logId: result.logId,
+          ctxData: result?.ctxData,
         });
       }
 
@@ -268,6 +280,13 @@ class WorkflowWorker {
       }
     } catch (error) {
       console.error(error);
+
+      const errorLogData = {
+        message: error.message,
+        ...(error.data || {}),
+        ...(error.ctxData || {}),
+      };
+
       const { onError: blockOnError } = replacedBlock.data;
       if (blockOnError && blockOnError.enable) {
         if (blockOnError.retry && blockOnError.retryTimes) {
@@ -298,10 +317,7 @@ class WorkflowWorker {
           blockOnError.toDo === 'continue' ? 1 : 'fallback'
         );
         if (blockOnError.toDo !== 'error' && nextBlocks) {
-          addBlockLog('error', {
-            message: error.message,
-            ...(error.data || {}),
-          });
+          addBlockLog('error', errorLogData);
 
           this.executeNextBlocks(nextBlocks, prevBlockData);
 
@@ -309,7 +325,7 @@ class WorkflowWorker {
         }
       }
 
-      const errorLogItem = { message: error.message, ...(error.data || {}) };
+      const errorLogItem = errorLogData;
       addBlockLog('error', errorLogItem);
 
       errorLogItem.blockId = block.id;

+ 12 - 0
src/workflowEngine/blocksHandler/handlerActiveTab.js

@@ -44,6 +44,18 @@ async function activeTab(block) {
       if (windowId) tabsQuery.windowId = windowId;
       else if (windows.length > 2) tabsQuery.lastFocusedWindow = true;
     } else {
+      const dashboardTabs = await browser.tabs.query({
+        url: browser.runtime.getURL('/newtab.html'),
+      });
+      await Promise.all(
+        dashboardTabs.map((item) =>
+          browser.windows.update(item.windowId, {
+            state: 'minimized',
+            focused: false,
+          })
+        )
+      );
+
       tabsQuery.currentWindow = true;
     }
 

+ 35 - 4
src/workflowEngine/blocksHandler/handlerHandleDownload.js

@@ -27,12 +27,40 @@ function determineFilenameListener(item, suggest) {
   return false;
 }
 
-function handleDownload({ data, id: blockId }) {
+async function handleDownload({ data, id: blockId }) {
   const nextBlockId = this.getBlockConnections(blockId);
   const getFilesname = () =>
     JSON.parse(sessionStorage.getItem('rename-downloaded-files')) || {};
 
-  return new Promise((resolve) => {
+  let downloadId = null;
+  if (data.downloadId?.trim()) {
+    if (Number.isNaN(+data.downloadId))
+      throw new Error('Download id is not a number');
+
+    const [downloadItem] = await browser.downloads.search({
+      id: +data.downloadId,
+    });
+    if (!downloadItem)
+      throw new Error(`Can't find download item with ${data.downloadId} id`);
+
+    if (downloadItem.state === 'complete') {
+      if (data.saveData) {
+        this.addDataToColumn(data.dataColumn, downloadItem.filename);
+      }
+      if (data.assignVariable) {
+        this.setVariable(data.variableName, downloadItem.filename);
+      }
+
+      return {
+        nextBlockId,
+        data: downloadItem.filename,
+      };
+    }
+
+    downloadId = +data.downloadId;
+  }
+
+  const result = await new Promise((resolve) => {
     if (!this.activeTab.id) throw new Error('no-tab');
 
     const hasListener =
@@ -44,7 +72,6 @@ function handleDownload({ data, id: blockId }) {
       );
     }
 
-    let downloadId = null;
     const handleCreated = ({ id }) => {
       if (downloadId) return;
 
@@ -56,7 +83,9 @@ function handleDownload({ data, id: blockId }) {
 
       browser.downloads.onCreated.removeListener(handleCreated);
     };
-    browser.downloads.onCreated.addListener(handleCreated);
+    if (!downloadId) {
+      browser.downloads.onCreated.addListener(handleCreated);
+    }
 
     if (!data.waitForDownload) {
       resolve({
@@ -132,6 +161,8 @@ function handleDownload({ data, id: blockId }) {
 
     browser.downloads.onChanged.addListener(handleChanged);
   });
+
+  return result;
 }
 
 export default handleDownload;

+ 32 - 8
src/workflowEngine/blocksHandler/handlerInsertData.js

@@ -1,3 +1,4 @@
+import { read as readXlsx, utils as utilsXlsx } from 'xlsx';
 import Papa from 'papaparse';
 import { parseJSON } from '@/utils/helper';
 import getFile, { readFileAsBase64 } from '@/utils/getFile';
@@ -16,27 +17,50 @@ async function insertData({ id, data }, { refData }) {
         this.engine.isPopup
       );
       const path = replacedPath.value;
+      const isExcel = /.xlsx?$/.test(path);
       const isJSON = path.endsWith('.json');
-      const isCSV = path.endsWith('.csv');
 
-      let action = item.action || item.csvAction || 'default';
-      if (action === 'text' && !isCSV) action = 'default';
+      const action = item.action || item.csvAction || 'default';
+      let responseType = 'text';
 
-      let responseType = isJSON ? 'json' : 'text';
-      if (action === 'base64') responseType = 'blob';
+      if (isJSON) responseType = 'json';
+      else if (action === 'base64' || (isExcel && action !== 'default'))
+        responseType = 'blob';
 
       let result = await getFile(path, {
         responseType,
         returnValue: true,
       });
 
-      if (result && isCSV && action && action.includes('json')) {
+      const readAsJson = action.includes('json');
+
+      if (action === 'base64') {
+        result = await readFileAsBase64(result);
+      } else if (result && path.endsWith('.csv') && readAsJson) {
         const parsedCSV = Papa.parse(result, {
           header: action.includes('header'),
         });
         result = parsedCSV.data || [];
-      } else if (action === 'base64') {
-        result = await readFileAsBase64(result);
+      } else if (isExcel && readAsJson) {
+        const base64Xls = await readFileAsBase64(result);
+        const wb = readXlsx(base64Xls.slice(base64Xls.indexOf(',')), {
+          type: 'base64',
+        });
+
+        const inputtedSheet = (item.xlsSheet || '').trim();
+        const sheetName = wb.SheetNames.includes(inputtedSheet)
+          ? inputtedSheet
+          : wb.SheetNames[0];
+
+        const options = {};
+        if (item.xlsRange) options.range = item.xlsRange;
+        if (!action.includes('header')) options.header = 1;
+
+        const sheetData = utilsXlsx.sheet_to_json(
+          wb.Sheets[sheetName],
+          options
+        );
+        result = sheetData;
       }
 
       value = result;

+ 25 - 0
src/workflowEngine/blocksHandler/handlerLink.js

@@ -0,0 +1,25 @@
+import browser from 'webextension-polyfill';
+
+export default async function ({ data, id, label }) {
+  const url = await this._sendMessageToTab({
+    id,
+    data,
+    label,
+  });
+
+  if (data.openInNewTab) {
+    const tab = await browser.tabs.create({
+      url,
+      windowId: this.activeTab.windowId,
+    });
+
+    this.activeTab.url = url;
+    this.activeTab.frameId = 0;
+    this.activeTab.id = tab.id;
+  }
+
+  return {
+    data: url,
+    nextBlockId: this.getBlockConnections(id),
+  };
+}

+ 14 - 3
src/workflowEngine/blocksHandler/handlerSaveAssets.js

@@ -43,6 +43,8 @@ export default async function ({ data, id, label }) {
     return browser.downloads.download(options);
   };
 
+  let downloadIds = null;
+
   if (data.type === 'element') {
     sources = await this._sendMessageToTab({
       id,
@@ -51,13 +53,22 @@ export default async function ({ data, id, label }) {
       tabId: this.activeTab.id,
     });
 
-    await Promise.all(sources.map((url) => downloadFile(url)));
+    downloadIds = await Promise.all(sources.map((url) => downloadFile(url)));
   } else if (data.type === 'url') {
-    await downloadFile(data.url);
+    downloadIds = [await downloadFile(data.url)];
+  }
+
+  if (data.saveDownloadIds) {
+    if (data.assignVariable) {
+      this.setVariable(data.variableName, downloadIds);
+    }
+    if (data.saveData) {
+      this.addDataToColumn(data.dataColumn, downloadIds);
+    }
   }
 
   return {
-    data: sources,
+    data: { sources, downloadIds },
     nextBlockId: this.getBlockConnections(id),
   };
 }

+ 10 - 1
src/workflowEngine/blocksHandler/handlerTabUrl.js

@@ -9,7 +9,16 @@ export async function logData({ id, data }) {
     const tab = await browser.tabs.get(this.activeTab.id);
     urls = tab.url || tab.pendingUrl || '';
   } else {
-    const tabs = await browser.tabs.query({});
+    const query = {};
+
+    if (data.qMatchPatterns) {
+      query.url = data.qMatchPatterns;
+    }
+    if (data.qTitle) {
+      query.title = data.qTitle;
+    }
+
+    const tabs = await browser.tabs.query(query);
     urls = tabs.map((tab) => tab.url);
   }
 

+ 15 - 1
src/workflowEngine/blocksHandler/handlerWebhook.js

@@ -37,14 +37,28 @@ export async function webhook({ data, id }, { refData }) {
     const response = await executeWebhook({ ...data, headers: newHeaders });
 
     if (!response.ok) {
+      const { status, statusText } = response;
+      const responseData = await (data.responseType === 'json'
+        ? response.json()
+        : response.text());
+      const ctxData = {
+        ctxData: {
+          request: { status, statusText, data: responseData },
+        },
+      };
+
       if (fallbackOutput && fallbackOutput.length > 0) {
         return {
+          ctxData,
           data: '',
           nextBlockId: fallbackOutput,
         };
       }
 
-      throw new Error(`(${response.status}) ${response.statusText}`);
+      const error = new Error(`(${response.status}) ${response.statusText}`);
+      error.ctxData = ctxData;
+
+      throw error;
     }
 
     if (!data.assignVariable && !data.saveData) {

+ 11 - 1
src/workflowEngine/index.js

@@ -1,3 +1,4 @@
+/* eslint-disable no-restricted-globals */
 import { toRaw } from 'vue';
 import browser from 'webextension-polyfill';
 import dayjs from '@/lib/dayjs';
@@ -36,6 +37,14 @@ export function stopWorkflowExec(executionId) {
 }
 
 export function startWorkflowExec(workflowData, options, isPopup = true) {
+  if (self.localStorage) {
+    const runCounts =
+      parseJSON(self.localStorage.getItem('runCounts'), {}) || {};
+    runCounts[workflowData.id] = (runCounts[workflowData.id] || 0) + 1;
+
+    self.localStorage.setItem('runCounts', JSON.stringify(runCounts));
+  }
+
   if (workflowData.isProtected) {
     const flow = parseJSON(workflowData.drawflow, null);
 
@@ -154,7 +163,8 @@ export function executeWorkflow(workflowData, options) {
   if (!workflowData || workflowData.isDisabled) return;
 
   const isMV2 = browser.runtime.getManifest().manifest_version === 2;
-  const context = workflowData.settings.execContext;
+  const context = workflowData?.settings?.execContext;
+
   if (isMV2 || context === 'background') {
     sendMessage('workflow:execute', { ...workflowData, options }, 'background');
     return;

+ 4 - 1
src/workflowEngine/templating/renderString.js

@@ -1,7 +1,9 @@
+import browser from 'webextension-polyfill';
 import { messageSandbox } from '../helper';
 import mustacheReplacer from './mustacheReplacer';
 
 const isFirefox = BROWSER_TYPE === 'firefox';
+const isMV2 = browser.runtime.getManifest().manifest_version === 2;
 
 export default async function (str, data, isPopup = true) {
   if (!str || typeof str !== 'string') return '';
@@ -16,7 +18,8 @@ export default async function (str, data, isPopup = true) {
 
   let renderedValue = {};
   const evaluateJS = str.startsWith('!!');
-  if (evaluateJS && !isFirefox && isPopup) {
+
+  if (evaluateJS && !isFirefox && (isMV2 || isPopup)) {
     const refKeysRegex =
       /(variables|table|secrets|loopData|workflow|googleSheets|globalData)@/g;
     const strToRender = str.replace(refKeysRegex, '$1.');

+ 7 - 9
src/workflowEngine/utils/webhookUtil.js

@@ -2,16 +2,16 @@ import { parseJSON, isWhitespace } from '@/utils/helper';
 import getFile from '@/utils/getFile';
 
 const renderContent = async (content, contentType) => {
-  if (contentType === 'text/plain') return content;
+  if (contentType === 'text') return content;
 
   const renderedJson = parseJSON(content, new Error('invalid-body'));
 
   if (renderedJson instanceof Error) throw renderedJson;
 
-  if (contentType === 'application/x-www-form-urlencoded') {
+  if (contentType === 'form') {
     return new URLSearchParams(renderedJson);
   }
-  if (contentType === 'multipart/form-data') {
+  if (contentType === 'form-data') {
     if (!Array.isArray(renderedJson) || !Array.isArray(renderedJson[0])) {
       throw new Error('The body must be 2D Array');
     }
@@ -96,19 +96,17 @@ export async function executeWebhook({
 
   try {
     const finalHeaders = filterHeaders(headers);
-    const contentTypeHeader = contentTypes[contentType || 'json'];
+    if (contentType !== 'form-data')
+      finalHeaders['Content-Type'] = contentTypes[contentType || 'json'];
 
     const payload = {
+      headers: finalHeaders,
       method: method || 'POST',
-      headers: {
-        'Content-Type': contentTypeHeader,
-        ...finalHeaders,
-      },
       signal: controller.signal,
     };
 
     if (!notHaveBody.includes(method || 'POST') && !isWhitespace(body)) {
-      payload.body = await renderContent(body, payload.headers['Content-Type']);
+      payload.body = await renderContent(body, contentType);
     }
 
     const response = await fetch(url, payload);

+ 1 - 1
utils/build-zip.js

@@ -12,7 +12,7 @@ const destDir = path.join(__dirname, '../build');
 const zipDir = path.join(__dirname, '../build-zip', appVersion);
 
 if (!fs.existsSync(zipDir)) {
-  fs.mkdirSync(zipDir);
+  fs.mkdirSync(zipDir, { recursive: true });
 }
 
 const archive = archiver('zip', { zlib: { level: 9 } });

+ 6 - 0
webpack.config.js

@@ -216,6 +216,12 @@ const options = {
       __VUE_OPTIONS_API__: true,
       __VUE_PROD_DEVTOOLS__: false,
     }),
+    // Fix i18n warning
+    new webpack.DefinePlugin({
+      __VUE_I18N_FULL_INSTALL__: JSON.stringify(true),
+      __INTLIFY_PROD_DEVTOOLS__: JSON.stringify(false),
+      __VUE_I18N_LEGACY_API__: JSON.stringify(false),
+    }),
   ],
   infrastructureLogging: {
     level: 'info',

+ 285 - 155
yarn.lock

@@ -1293,98 +1293,108 @@
   resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-2.0.0-beta.205.tgz#477ee72fd66fffeea7a72114b0fda881ef93ddda"
   integrity sha512-uGV+U5UfKkhu3sidyG2LC2IzqeU1MfkuHxv3NF1PvvFDX6cSNbOy8ZBxXHfIqUMsloqzMv9mXrrqiPUB3jQisg==
 
-"@tiptap/extension-blockquote@^2.0.0-beta.205":
-  version "2.0.0-beta.205"
-  resolved "https://registry.yarnpkg.com/@tiptap/extension-blockquote/-/extension-blockquote-2.0.0-beta.205.tgz#19a2be40a2e6be85fd1136b57906d601d47c0018"
-  integrity sha512-3FDSHe7bLok1+6Pq4T5zVaml4o/ZrtoS8i9dIcPYmFJ1+LxDH0IiBHkTI/rr0wMhkZoo/MTZ4DG17Rw0sU9OsA==
-
-"@tiptap/extension-bold@^2.0.0-beta.205":
-  version "2.0.0-beta.205"
-  resolved "https://registry.yarnpkg.com/@tiptap/extension-bold/-/extension-bold-2.0.0-beta.205.tgz#a3b60adb2c0680878f350e872b2110fd11d3511a"
-  integrity sha512-ojCN61iVQ+XdeHVfe70i9iMBOQ+/XAtXpe2Uc4+6Gk9+To8l7LHz1OHg3/NPir+xTKMR0jH0HhLPD1nsO77gyA==
-
-"@tiptap/extension-bubble-menu@^2.0.0-beta.205":
-  version "2.0.0-beta.205"
-  resolved "https://registry.yarnpkg.com/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.0.0-beta.205.tgz#b032d769d6e1e4fd80b08c72caff01a322f333d2"
-  integrity sha512-XqHo698TioWBBomk6l7gfWZ8bj7Oj+UHYSgmuvTAZLAEjOvDQcykjqS2ks9CN0M+f8Hd+IhnSXMvbLciKQIp4g==
-  dependencies:
-    "@tiptap/core" "^2.0.0-beta.205"
+"@tiptap/core@^2.0.0-beta.209":
+  version "2.0.0-beta.209"
+  resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-2.0.0-beta.209.tgz#fd0edb8f87a5eea70c6a11e0d7e628738a10ef3f"
+  integrity sha512-DOOzfo2XKD5Qt2oEGW33/6ugwSnvpl4WbxtlKdPadLoApk6Kja3K1Eps3pihBgIGmo4tkctkCzmj8wNWS7KeWg==
+
+"@tiptap/extension-blockquote@^2.0.0-beta.209":
+  version "2.0.0-beta.209"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-blockquote/-/extension-blockquote-2.0.0-beta.209.tgz#553a620007bbf039167036eab434a354934a7ceb"
+  integrity sha512-ay5c+SJ1vQOL5zpsr94jN15tCt0ytd7zPMM433pkhi9ZL0qqf1fZ+D0KzDs2z8N49rfArVpoo238V3ZChBh2sA==
+
+"@tiptap/extension-bold@^2.0.0-beta.209":
+  version "2.0.0-beta.209"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-bold/-/extension-bold-2.0.0-beta.209.tgz#27268311f3fd53af8e658e2fd0d65f2e43130268"
+  integrity sha512-8jaoZSe55iwuEvwdM1mPhlgE+/tDyveECv0d1qogUcbPdIkhDQaNlIOmuH9Ftr465iIDthMjt4GB6AWi5tfsMg==
+
+"@tiptap/extension-bubble-menu@^2.0.0-beta.209":
+  version "2.0.0-beta.209"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.0.0-beta.209.tgz#dcef008f84286c7719f43e54ebdd7de778837974"
+  integrity sha512-tceZAuDpy3J96uGyCzpJFD3fHABJDTJTq5E0hm+TRQT+eVGVqZI0PE3/4yVFgkCshioTuJq8veMDFcqNsSkKsQ==
+  dependencies:
+    "@tiptap/core" "^2.0.0-beta.209"
     lodash "^4.17.21"
     prosemirror-state "^1.4.1"
     prosemirror-view "^1.28.2"
     tippy.js "^6.3.7"
 
-"@tiptap/extension-bullet-list@^2.0.0-beta.205":
-  version "2.0.0-beta.205"
-  resolved "https://registry.yarnpkg.com/@tiptap/extension-bullet-list/-/extension-bullet-list-2.0.0-beta.205.tgz#ac2785433ee9144de61897bf8da351c5f66e6add"
-  integrity sha512-zg/puvi7HtQLndYrhGkbsWg5aNxaXdoRs2kErBQDM1KoGGFeKaQKw/AuhK0eu0i7D2tfJ4Yvb65NGIlIU0x0ig==
-
-"@tiptap/extension-character-count@^2.0.0-beta.205":
-  version "2.0.0-beta.205"
-  resolved "https://registry.yarnpkg.com/@tiptap/extension-character-count/-/extension-character-count-2.0.0-beta.205.tgz#aaaf398ecde78e6164e24df0ed0be5872f785865"
-  integrity sha512-umdJ+o/7z6/jKL6i7XPRgQB69ttqC5IhoANKIS4tR61kCbaFuZwmmxP9xw+6hTi1esD/w6r6ICIxuKKE6L7CXA==
-
-"@tiptap/extension-code-block@^2.0.0-beta.205":
-  version "2.0.0-beta.205"
-  resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block/-/extension-code-block-2.0.0-beta.205.tgz#3fd4440251ec2c4f49aa2d4544b67ad8bbd3fbcb"
-  integrity sha512-dMNoEpiK93We0gByw+TO1a4jlkuwCI4tavpyGF0IN+itTeBsZ7s0cL567Jv3ZZQ/vX5C0/GfWoM1rABpwU4Oyw==
-
-"@tiptap/extension-code@^2.0.0-beta.205":
-  version "2.0.0-beta.205"
-  resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-2.0.0-beta.205.tgz#c5c03007e608f91a152df5964391245b8cdcc51b"
-  integrity sha512-F/YBt/pUZ91diNOSB29wFs6tiOvEu+aomU7Hvu86OVg1tOI0WuKjmDlWXkf8NRfodiHeR9cOJhVT9Z0kH5ib8A==
-
-"@tiptap/extension-document@^2.0.0-beta.205":
-  version "2.0.0-beta.205"
-  resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-2.0.0-beta.205.tgz#a20348e6c8d99b36c23b314a9c5d69eec7688110"
-  integrity sha512-ANnO2ryaEq60wkWzteC2e0L4Wv/uExdukGU/GjyBSmXCDG9CLNkZcGBmv+f1rDzvR/2otmcV74XtrmsOaUGtVw==
-
-"@tiptap/extension-dropcursor@^2.0.0-beta.205":
-  version "2.0.0-beta.205"
-  resolved "https://registry.yarnpkg.com/@tiptap/extension-dropcursor/-/extension-dropcursor-2.0.0-beta.205.tgz#c47d6089b400d3d8ec2e146e92f025f38d076f6e"
-  integrity sha512-0DL1uQwtRZMw356WyYla4l2ailci6DVtJ6pgNpUX5Zlv8+IfwBQ2jU9XXbSUAJOlRPLI8NgUlB/JWg4A6MrO4g==
-
-"@tiptap/extension-floating-menu@^2.0.0-beta.205":
-  version "2.0.0-beta.205"
-  resolved "https://registry.yarnpkg.com/@tiptap/extension-floating-menu/-/extension-floating-menu-2.0.0-beta.205.tgz#c8e8bcd12a9670e1565916f47698a9822a4fa8ff"
-  integrity sha512-L6GV0jfYqOpDQplRiB3dnOCnxOPVRLr76VxRQgEVlE7FamPd9/KlL+7Bk+3w7qwlcXYAo3ROz8EL6Q1ZzoJeXw==
+"@tiptap/extension-bullet-list@^2.0.0-beta.209":
+  version "2.0.0-beta.209"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-bullet-list/-/extension-bullet-list-2.0.0-beta.209.tgz#86e5023d330ce0dc39e42083ec839af2dbec51bc"
+  integrity sha512-NGoSYakXCiKb5xrVe339Acu2iherOGQUR1bAeWgOKf+dINvIdjawnud6fIeB3n1h95aDvsmYuH1o9B+/bd7e3w==
+
+"@tiptap/extension-character-count@^2.0.0-beta.209":
+  version "2.0.0-beta.209"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-character-count/-/extension-character-count-2.0.0-beta.209.tgz#09e7fdcd56cb4eeb6a9a023713e96254de771e8d"
+  integrity sha512-ejye00l40wwzycNj86fHbWxngl8IiE+eQm1juUGK0ztR+r/P4MpgdeatjTLchtY79wd9qR3hON2+pcNwy9KKew==
+
+"@tiptap/extension-code-block@^2.0.0-beta.209":
+  version "2.0.0-beta.209"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block/-/extension-code-block-2.0.0-beta.209.tgz#653043d94293e546289912bac3ded75637980d5d"
+  integrity sha512-FlMud3yhAilHrcHbW4iUEagAdvpOJW1lTSiiDfbtVpyybjNJQQMa5zhSKi4blG2xBEGXZhqL0XuWDGERNsVawQ==
+
+"@tiptap/extension-code@^2.0.0-beta.209":
+  version "2.0.0-beta.209"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-2.0.0-beta.209.tgz#dda857528043c5aa4c451bb9f63731aa809182cb"
+  integrity sha512-LCcfQMroYps6o9ASpVZqYbbdTkSwxTokjmkkKKmWZlZSJ/h+1kThOnRZgcPkfSeaaC30T+LSxAXXyf1dMgl5+Q==
+
+"@tiptap/extension-document@^2.0.0-beta.209":
+  version "2.0.0-beta.209"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-2.0.0-beta.209.tgz#7bb4025737a5361c06d52e56406d7e0649387e88"
+  integrity sha512-ZRTC5j0J6fNTtIcU6UnxJm5KZrfJI2pygCJ172mMNzwE89upJMhRSP0CvPWTY7nf0odmQTJ5vD99QDR4CdOTng==
+
+"@tiptap/extension-dropcursor@^2.0.0-beta.209":
+  version "2.0.0-beta.209"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-dropcursor/-/extension-dropcursor-2.0.0-beta.209.tgz#5107e255a4c1c5ca3dae606854019d6ec6051481"
+  integrity sha512-b4RxbZg4hza4p1Lp+m4CWkIIMVgoAKSo49OyvO/Y/igtQ0DcdQutSJDEPeEhuqy+jPdQFaU5GBonSvVi89Loog==
+
+"@tiptap/extension-floating-menu@^2.0.0-beta.209":
+  version "2.0.0-beta.209"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-floating-menu/-/extension-floating-menu-2.0.0-beta.209.tgz#1c5f7ffca9fdd2d272069949345c3b888ea57049"
+  integrity sha512-m5ucAguqDxuOvNcsmvuSLcN8TMkbhFmiC6dTJOyaAGjGn6d8Ly6aZh+lEwU228TebM0TKHTp8Xob1cLjV4TGgg==
   dependencies:
     tippy.js "^6.3.7"
 
-"@tiptap/extension-gapcursor@^2.0.0-beta.205":
-  version "2.0.0-beta.205"
-  resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-2.0.0-beta.205.tgz#4e47070adb51da41552b7d0f4b42d607da2d3772"
-  integrity sha512-2tH3+yjmQMwdFGze8J1G1j6yiJgA4zE4iHjqlZtyLVdB1jLoIRVrkIgWhSKKIoANQUBPSGLGYVndQYxrRs1gSQ==
+"@tiptap/extension-gapcursor@^2.0.0-beta.209":
+  version "2.0.0-beta.209"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-2.0.0-beta.209.tgz#91eadf2863cfd6ba0325d378aff49af95c2783b5"
+  integrity sha512-F03mr2VV5bZycIVWHCIYpQTzs9tC+goWJFhbJgPrT62f1gUAnlc1ZRc79mSqw1AxTsfbDvAc65OlUJb0QfxDWA==
 
-"@tiptap/extension-hard-break@^2.0.0-beta.205":
-  version "2.0.0-beta.205"
-  resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-2.0.0-beta.205.tgz#223f415928a51931acc265e54637a7bf7fdae194"
-  integrity sha512-8ZC8KOh/v9cl4VLrtG8GgXasIKt6wXCcZxd0rCOU2RdIwyiIMW/+Mpyru3XckP5sSd7bklcf59aVEZ/u50S86w==
+"@tiptap/extension-hard-break@^2.0.0-beta.209":
+  version "2.0.0-beta.209"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-2.0.0-beta.209.tgz#9e845bb69d7707fd041701513a096302073cb94f"
+  integrity sha512-BS0z9SshfJ5ESssiVaVe61901BrTLCAgxc9NPmi4Va2sszXJysI2Vm8q4jDHL6IehkCQpQZNAihT9eSBPHQR0w==
 
-"@tiptap/extension-heading@^2.0.0-beta.205":
-  version "2.0.0-beta.205"
-  resolved "https://registry.yarnpkg.com/@tiptap/extension-heading/-/extension-heading-2.0.0-beta.205.tgz#32616cbe787a86432604247276af119b9823e9e2"
-  integrity sha512-ofEBstbyNm/Fu3gDhYiOW52YDsxgLw9k9Gl5Ocqr27ApRo2N8h96Q86mVj7UNLVO/qrzIFwdlnif6K8wvuCZBg==
+"@tiptap/extension-heading@^2.0.0-beta.209":
+  version "2.0.0-beta.209"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-heading/-/extension-heading-2.0.0-beta.209.tgz#7ae7e3f1adac7eaad20dcb30effb4b25f53a453d"
+  integrity sha512-eqq9if0XsPjLvivM5gNUqSHj5I4Zpiv66NPO+pM4ig0Wq2CjjxWzzgmdSLfTPGRfsZe9kPCOgO86AAB07am3fQ==
 
 "@tiptap/extension-history@^2.0.0-beta.205":
   version "2.0.0-beta.205"
   resolved "https://registry.yarnpkg.com/@tiptap/extension-history/-/extension-history-2.0.0-beta.205.tgz#9f7f70da338f3437bb17b6037a4fea596a081e36"
   integrity sha512-rDtwyM/mlFT7tiVu/SlmDbWGZJBFBswd2Lklu9coxHwbu4EH/2inaiff+6zGZ0WOSw5LwX44eJfsyD6NH8HcHg==
 
-"@tiptap/extension-horizontal-rule@^2.0.0-beta.205":
-  version "2.0.0-beta.205"
-  resolved "https://registry.yarnpkg.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.0.0-beta.205.tgz#567b7176d04ec5ccb1310774e9c49238b9164405"
-  integrity sha512-68rDOHXxykqGd/PujuPvplNnOIWeOY8KRjkfA81xUJwCFNJnBDzE91beZnKly3AvCCdpe8YeW9+RyjqQq+zCbQ==
+"@tiptap/extension-history@^2.0.0-beta.209":
+  version "2.0.0-beta.209"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-history/-/extension-history-2.0.0-beta.209.tgz#4d082a92bd434232b3d3db6584b8dd499086f343"
+  integrity sha512-P5nw+r47gBdac4igeaBvW6gxsZUnS67SRgbAyQSmXVe45NXc1t0EUb2Be9YuHRKDVxhJUhGT8NawPY70Fgk4mQ==
+
+"@tiptap/extension-horizontal-rule@^2.0.0-beta.209":
+  version "2.0.0-beta.209"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.0.0-beta.209.tgz#06d8c9ca43933e338fca31963332639d8dcbe7e8"
+  integrity sha512-53RU9kDVb1jowJ3Frx8QW0E05uEOCpeG3HfUCMjz8anGtefxFtMS7xYZ9sC+niJeVmXC+mUSjFGageL4iRIdqA==
 
 "@tiptap/extension-image@^2.0.0-beta.205":
   version "2.0.0-beta.205"
   resolved "https://registry.yarnpkg.com/@tiptap/extension-image/-/extension-image-2.0.0-beta.205.tgz#f5bc3d6d4629764b84fa4380ce8819b705507d1b"
   integrity sha512-wsugHfFVyD6TU/5Ij9T08zN9YHZILR7fc5p49zUU2V6RjNqwjaAqeMuyifu7QinvuUoo7dEe4HvI9zgfDT+aYQ==
 
-"@tiptap/extension-italic@^2.0.0-beta.205":
-  version "2.0.0-beta.205"
-  resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-2.0.0-beta.205.tgz#1387b833d41fd94b58882e0cb773b8b5541a3be0"
-  integrity sha512-bdd5VJOWbeWa8OkvcdyFrUewpm2fre7QMBuwzvzTPSl2b9K8MFICmvRrn4SsYBOH5yKprDJ2ZB/ycXkL0o77Xg==
+"@tiptap/extension-italic@^2.0.0-beta.209":
+  version "2.0.0-beta.209"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-2.0.0-beta.209.tgz#892ff90de43a5e1e88197e7a9839bbd877027321"
+  integrity sha512-KnRdbqfD01tcCnUNypA3TX3FqmQSFwu7/9YU3vwS8Zyaz+OIc/g/vJai5twg1DzFAvIcYWzRFPTFcqkjwkcW1w==
 
 "@tiptap/extension-link@^2.0.0-beta.205":
   version "2.0.0-beta.205"
@@ -1393,68 +1403,68 @@
   dependencies:
     linkifyjs "^3.0.5"
 
-"@tiptap/extension-list-item@^2.0.0-beta.205":
-  version "2.0.0-beta.205"
-  resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-2.0.0-beta.205.tgz#8b2972757fefee88cb3df62a872f5fc0351a7873"
-  integrity sha512-tMhE91UD+qFwhoy2A+bvv9POPXvERKaXoeLPE24OUoOywB2IjlRXpk+ZlX6DyHgq323u3COR2tPLG1CH7PK5oA==
+"@tiptap/extension-list-item@^2.0.0-beta.209":
+  version "2.0.0-beta.209"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-2.0.0-beta.209.tgz#d7d6b5c3dce4c048d7216e307c2b2234a5032b65"
+  integrity sha512-qkHwymyGfXIVAiqLXvL66UzGLhYpD2BYbSSAIQ6Rmuvk4aeNrsBvFv9tL7+YsYLKvlOa4+Q+PN2uhST+lOH0hw==
 
-"@tiptap/extension-ordered-list@^2.0.0-beta.205":
-  version "2.0.0-beta.205"
-  resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-2.0.0-beta.205.tgz#cb962eb56a65fc6d28b48b0092888c4fe6e5fb52"
-  integrity sha512-czyZ8o/wQiFlMfqCqiOeFxYddecXRBR65DQxNLV68nXSA23Cps2aVASSY8zF5I2pvpVSiDfjnO9ZE5mX/AYH2w==
+"@tiptap/extension-ordered-list@^2.0.0-beta.209":
+  version "2.0.0-beta.209"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-2.0.0-beta.209.tgz#d22539313c3fb5879d5ee8cd0bab5aff78a6353c"
+  integrity sha512-PhJ9uqxqKVO97rb2MzW/TzQJ9XQicp9gsV/y0QbAEv1ZOH9QI/qF5sCe6BfeN8ZoMyYUEh6de3yxQL8iXSFWsw==
 
-"@tiptap/extension-paragraph@^2.0.0-beta.205":
-  version "2.0.0-beta.205"
-  resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.0.0-beta.205.tgz#bc463aab46faf533d5a18cddde95d1870562c9d8"
-  integrity sha512-NdGqxt5PeR7eBrQjKfIjcrNeruEMGifshSKARiqCYafeiSbUlk33fNrzobIMJjRi39bbYktEnON66EI9BBUBBA==
+"@tiptap/extension-paragraph@^2.0.0-beta.209":
+  version "2.0.0-beta.209"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.0.0-beta.209.tgz#6c26e94eebfd0207e14601691c121dbf68fc17c2"
+  integrity sha512-XkiguVbOX/KJwux2wdurvZRwG1UulpZ3Uhw7Yl59sLBf7YDw8H781EMgVvaLSWf3B1o27/yOyc+kiepW/Pp9Wg==
 
 "@tiptap/extension-placeholder@^2.0.0-beta.205":
   version "2.0.0-beta.205"
   resolved "https://registry.yarnpkg.com/@tiptap/extension-placeholder/-/extension-placeholder-2.0.0-beta.205.tgz#e898d7144ed971c49bc6ea4676944bbc9221a9d9"
   integrity sha512-qQ3g7QJV8ut0Ywuf64vh48SylQZ2iuU0gAWpYW/VPo1HbZ9zh8yP5+6xgGcDybJEnN6DnrDYZLVFio27LpgkPQ==
 
-"@tiptap/extension-strike@^2.0.0-beta.205":
-  version "2.0.0-beta.205"
-  resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-2.0.0-beta.205.tgz#8bc41ce6035d5d2d855926c3788c9a06b0d4bfa5"
-  integrity sha512-nfLRCV/TwBgHH/u7ygl2C0hZBbm5ZB+14sG6KQGyWqyXa1Jlr25x7fbjrln1bgN439m9Hn+euNdYnmOlVO2o2A==
-
-"@tiptap/extension-text@^2.0.0-beta.205":
-  version "2.0.0-beta.205"
-  resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-2.0.0-beta.205.tgz#50f4a4e0a2943f2d50d27f9b9fae6772ab18c552"
-  integrity sha512-Tp5MoVhHGP8s24gD+EmM3RqLrQiLZvv4xvjlkozLZIMbZW6UX1yZANnpzZl74nWQ4W7plNOok42hnzWfenlq+g==
-
-"@tiptap/starter-kit@^2.0.0-beta.205":
-  version "2.0.0-beta.205"
-  resolved "https://registry.yarnpkg.com/@tiptap/starter-kit/-/starter-kit-2.0.0-beta.205.tgz#5cfdee7317795220046843112079bc29975b2f71"
-  integrity sha512-PvDBgRPry8IEtHeWhIvUVykgk/LgchNzgEVrheCktqJ2J+XHrZc7LZ+Ldxxfs2OFXRb2wG/Q6TqapqLYk2+twQ==
-  dependencies:
-    "@tiptap/core" "^2.0.0-beta.205"
-    "@tiptap/extension-blockquote" "^2.0.0-beta.205"
-    "@tiptap/extension-bold" "^2.0.0-beta.205"
-    "@tiptap/extension-bullet-list" "^2.0.0-beta.205"
-    "@tiptap/extension-code" "^2.0.0-beta.205"
-    "@tiptap/extension-code-block" "^2.0.0-beta.205"
-    "@tiptap/extension-document" "^2.0.0-beta.205"
-    "@tiptap/extension-dropcursor" "^2.0.0-beta.205"
-    "@tiptap/extension-gapcursor" "^2.0.0-beta.205"
-    "@tiptap/extension-hard-break" "^2.0.0-beta.205"
-    "@tiptap/extension-heading" "^2.0.0-beta.205"
-    "@tiptap/extension-history" "^2.0.0-beta.205"
-    "@tiptap/extension-horizontal-rule" "^2.0.0-beta.205"
-    "@tiptap/extension-italic" "^2.0.0-beta.205"
-    "@tiptap/extension-list-item" "^2.0.0-beta.205"
-    "@tiptap/extension-ordered-list" "^2.0.0-beta.205"
-    "@tiptap/extension-paragraph" "^2.0.0-beta.205"
-    "@tiptap/extension-strike" "^2.0.0-beta.205"
-    "@tiptap/extension-text" "^2.0.0-beta.205"
-
-"@tiptap/vue-3@^2.0.0-beta.205":
-  version "2.0.0-beta.205"
-  resolved "https://registry.yarnpkg.com/@tiptap/vue-3/-/vue-3-2.0.0-beta.205.tgz#cdcfc6edbcdbbbbbd6f59c4090dcc54ee7c348a8"
-  integrity sha512-Vq/t0UBB/a56FOE0Wapag7Jkf7/FXh/aUWZRGvWh3T9PQ2SabHEJ3gupx1Yx4QVx5SdDySjldJ7vGxEYTq4hhQ==
-  dependencies:
-    "@tiptap/extension-bubble-menu" "^2.0.0-beta.205"
-    "@tiptap/extension-floating-menu" "^2.0.0-beta.205"
+"@tiptap/extension-strike@^2.0.0-beta.209":
+  version "2.0.0-beta.209"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-2.0.0-beta.209.tgz#f601b07e186f9079cadff7f29e29107909f5478c"
+  integrity sha512-k8yaeyMYBzdq5U1zv5DYZt3KtpglPHV2JX7dYfNyoFpiX+6IJ2EwSuTXUGilZGRpyUw6UxeDF0yJbiOGMeEIDA==
+
+"@tiptap/extension-text@^2.0.0-beta.209":
+  version "2.0.0-beta.209"
+  resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-2.0.0-beta.209.tgz#f34a6cda849c16d7dbf9677e2cf6242cc6987a9e"
+  integrity sha512-12PTPTQViDR7xDLwxGMPiYaV89E9olH/+4Zfoh6QiOjHqhmgYu3+/c8YZ3eARgXnfpy/EzUD0PBxiAyDZJ1vdw==
+
+"@tiptap/starter-kit@^2.0.0-beta.209":
+  version "2.0.0-beta.209"
+  resolved "https://registry.yarnpkg.com/@tiptap/starter-kit/-/starter-kit-2.0.0-beta.209.tgz#cc21d50b826abf02c97ea426f8fb04cf516f8606"
+  integrity sha512-uR68ZfDZ5PeygGey3xc9ZuFIP+K7VRElrABnZcM6t9/Crrs70UFwSTNlkS0ezx9woj8h+8N78a6r8W1YC04TOw==
+  dependencies:
+    "@tiptap/core" "^2.0.0-beta.209"
+    "@tiptap/extension-blockquote" "^2.0.0-beta.209"
+    "@tiptap/extension-bold" "^2.0.0-beta.209"
+    "@tiptap/extension-bullet-list" "^2.0.0-beta.209"
+    "@tiptap/extension-code" "^2.0.0-beta.209"
+    "@tiptap/extension-code-block" "^2.0.0-beta.209"
+    "@tiptap/extension-document" "^2.0.0-beta.209"
+    "@tiptap/extension-dropcursor" "^2.0.0-beta.209"
+    "@tiptap/extension-gapcursor" "^2.0.0-beta.209"
+    "@tiptap/extension-hard-break" "^2.0.0-beta.209"
+    "@tiptap/extension-heading" "^2.0.0-beta.209"
+    "@tiptap/extension-history" "^2.0.0-beta.209"
+    "@tiptap/extension-horizontal-rule" "^2.0.0-beta.209"
+    "@tiptap/extension-italic" "^2.0.0-beta.209"
+    "@tiptap/extension-list-item" "^2.0.0-beta.209"
+    "@tiptap/extension-ordered-list" "^2.0.0-beta.209"
+    "@tiptap/extension-paragraph" "^2.0.0-beta.209"
+    "@tiptap/extension-strike" "^2.0.0-beta.209"
+    "@tiptap/extension-text" "^2.0.0-beta.209"
+
+"@tiptap/vue-3@^2.0.0-beta.209":
+  version "2.0.0-beta.209"
+  resolved "https://registry.yarnpkg.com/@tiptap/vue-3/-/vue-3-2.0.0-beta.209.tgz#4bd3c5fe7b68e1f84cc4f1379ad2f916f2410ebf"
+  integrity sha512-v0GUO6mcRgDfT6bNX3fHJds9QIpYqFBiGaanvTDVlOhErOSgrOx2+QuEieG0F9nUQ1az9h0kj5NZxvP9CeTlcQ==
+  dependencies:
+    "@tiptap/extension-bubble-menu" "^2.0.0-beta.209"
+    "@tiptap/extension-floating-menu" "^2.0.0-beta.209"
 
 "@types/body-parser@*":
   version "1.19.2"
@@ -1670,20 +1680,20 @@
   resolved "https://registry.yarnpkg.com/@viselect/vanilla/-/vanilla-3.2.2.tgz#8f6d343c5a4f26429906c8446be0f809489a2cde"
   integrity sha512-3HRnbDOqkaLTSmfkSMnBImd1TyNVq5esiG8HhfKYpGE3Z8bjH1HeNbMrK87MjK9YX0h1SWJGD5dGn2C4o7tttg==
 
-"@vue-flow/additional-components@^1.2.4":
-  version "1.3.1"
-  resolved "https://registry.yarnpkg.com/@vue-flow/additional-components/-/additional-components-1.3.1.tgz#73dd2c80f5bc19690564cadd1e8a559ae48b31e1"
-  integrity sha512-t/1qi14gi9kL1fmhj8WvBWSsJ242ueQtcDOr9uNVcqV4K4xXusNPP2+r7QqBBfdGFdJmn/WeVBD/2x0G7IB0fQ==
+"@vue-flow/additional-components@^1.3.3":
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/@vue-flow/additional-components/-/additional-components-1.3.3.tgz#383fe2f79534a7d3a3fecc51006be790c321ca87"
+  integrity sha512-AZhz0diM7VIN7MGKODiuqiu+xiujFQSs2UdiThgNI5vGSwwizd0g9dGzB+LK0Dt4FCRJ1g64xzxqbrAFFfzuFw==
   dependencies:
     d3-selection "^3.0.0"
     d3-zoom "^3.0.0"
 
-"@vue-flow/core@^1.5.0":
-  version "1.5.3"
-  resolved "https://registry.yarnpkg.com/@vue-flow/core/-/core-1.5.3.tgz#8e284e940e2ea0e5f11c00b279cc6781da237922"
-  integrity sha512-/mqkETlJYKcCi3+D55GvVho+J4Bdm7kRKJpp/A3njT29a21jNuH26UFsc/FvWER7wONgakdFNXBLVjzbQd8uyw==
+"@vue-flow/core@^1.9.1":
+  version "1.9.1"
+  resolved "https://registry.yarnpkg.com/@vue-flow/core/-/core-1.9.1.tgz#579ee228f74194a0c322cefc160f1c70c8356684"
+  integrity sha512-bV7pIeZVScb/8w0LAF9EYTjODLQfX+PNm4udCQw8Pm/z9NOeGzxcVJowZJjG5AMQqLMVZ20gCQxBDx21UTEc1g==
   dependencies:
-    "@vueuse/core" "^9.5.0"
+    "@vueuse/core" "^9.6.0"
     d3-drag "^3.0.0"
     d3-selection "^3.0.0"
     d3-zoom "^3.0.0"
@@ -1783,14 +1793,14 @@
   resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.2.45.tgz#a3fffa7489eafff38d984e23d0236e230c818bc2"
   integrity sha512-Ewzq5Yhimg7pSztDV+RH1UDKBzmtqieXQlpTVm2AwraoRL/Rks96mvd8Vgi7Lj+h+TH8dv7mXD3FRZR3TUvbSg==
 
-"@vueuse/core@^9.5.0":
-  version "9.6.0"
-  resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-9.6.0.tgz#de1d4730849cdbe28a9ebcf6cad167a700919603"
-  integrity sha512-qGUcjKQXHgN+jqXEgpeZGoxdCbIDCdVPz3QiF1uyecVGbMuM63o96I1GjYx5zskKgRI0FKSNsVWM7rwrRMTf6A==
+"@vueuse/core@^9.6.0":
+  version "9.9.0"
+  resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-9.9.0.tgz#ad6849cd03ba7ee396ef93fa01d4f2e5b11a4942"
+  integrity sha512-JdDb7TrE0imZnwBhMF4+0PCJqGD3AxzH8S2sfk54P0rqvklK+EAtAR/mPb1HwV/JPujQFQJhghQ190Yq03YpVw==
   dependencies:
     "@types/web-bluetooth" "^0.0.16"
-    "@vueuse/metadata" "9.6.0"
-    "@vueuse/shared" "9.6.0"
+    "@vueuse/metadata" "9.9.0"
+    "@vueuse/shared" "9.9.0"
     vue-demi "*"
 
 "@vueuse/head@^1.0.22":
@@ -1803,10 +1813,10 @@
     "@unhead/ssr" "^1.0.9"
     "@unhead/vue" "^1.0.9"
 
-"@vueuse/metadata@9.6.0":
-  version "9.6.0"
-  resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-9.6.0.tgz#b0a73277538cebef5d477983f74fdd2aa21ce5f9"
-  integrity sha512-sIC8R+kWkIdpi5X2z2Gk8TRYzmczDwHRhEFfCu2P+XW2JdPoXrziqsGpDDsN7ykBx4ilwieS7JUIweVGhvZ93w==
+"@vueuse/metadata@9.9.0":
+  version "9.9.0"
+  resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-9.9.0.tgz#d3f3f40dcedb6a045e7940a1fba78828a70d9b7e"
+  integrity sha512-pgxsUJv/d7IjKpLeB6TthggEsaBwM3ffc5jPrr5TmxAm/fup0mGR5VTzrdA/PSx85tpb+CIvP92D+55qBNc8ag==
 
 "@vueuse/rxjs@^9.1.1":
   version "9.6.0"
@@ -1823,6 +1833,13 @@
   dependencies:
     vue-demi "*"
 
+"@vueuse/shared@9.9.0":
+  version "9.9.0"
+  resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-9.9.0.tgz#3f02e6a86d9c789c3dd73308bd3adea958860086"
+  integrity sha512-+D0XFwHG0T+uaIbCSlROBwm1wzs71B7n3KyDOxnvfEMMHDOzl09rYKwaE2AENmYwYPXfHPbSBRDD2gBVHbvTcg==
+  dependencies:
+    vue-demi "*"
+
 "@vuex-orm/core@^0.36.4":
   version "0.36.4"
   resolved "https://registry.yarnpkg.com/@vuex-orm/core/-/core-0.36.4.tgz#9e2b1b8dfd74c2a508f1862ffa3e4a2c1e4cc60c"
@@ -1968,6 +1985,11 @@
   resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-1.7.0.tgz#e1993689ac42d2b16e9194376cfb6753f6254db1"
   integrity sha512-oxnCNGj88fL+xzV+dacXs44HcDwf1ovs3AuEzvP7mqXw7fQntqIhQ1BRmynh4qEKQSSSRSWVyXRjmTbZIX9V2Q==
 
+"@xmldom/xmldom@^0.8.2":
+  version "0.8.6"
+  resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.6.tgz#8a1524eb5bd5e965c1e3735476f0262469f71440"
+  integrity sha512-uRjjusqpoqfmRkTaNuLJ2VohVr67Q5YwDATW3VU7PfzTj6IRaihGrYI7zckGZjxQPBIp63nfvJbM+Yu5ICh0Bg==
+
 "@xtuc/ieee754@^1.2.0":
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790"
@@ -2299,6 +2321,11 @@ batch@0.6.1:
   resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16"
   integrity sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==
 
+big-integer@^1.6.17:
+  version "1.6.51"
+  resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.51.tgz#0df92a5d9880560d3ff2d5fd20245c889d130686"
+  integrity sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==
+
 big.js@^5.2.2:
   version "5.2.2"
   resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328"
@@ -2309,6 +2336,14 @@ binary-extensions@^2.0.0:
   resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
   integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
 
+binary@~0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/binary/-/binary-0.3.0.tgz#9f60553bc5ce8c3386f3b553cff47462adecaa79"
+  integrity sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==
+  dependencies:
+    buffers "~0.1.1"
+    chainsaw "~0.1.0"
+
 bl@^4.0.3:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a"
@@ -2318,6 +2353,11 @@ bl@^4.0.3:
     inherits "^2.0.4"
     readable-stream "^3.4.0"
 
+bluebird@~3.4.1:
+  version "3.4.7"
+  resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.4.7.tgz#f72d760be09b7f76d08ed8fae98b289a8d05fab3"
+  integrity sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==
+
 body-parser@1.20.1:
   version "1.20.1"
   resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668"
@@ -2398,6 +2438,11 @@ buffer-from@^1.0.0:
   resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
   integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
 
+buffer-indexof-polyfill@~1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz#d2732135c5999c64b277fcf9b1abe3498254729c"
+  integrity sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==
+
 buffer@^5.5.0:
   version "5.7.1"
   resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0"
@@ -2406,6 +2451,11 @@ buffer@^5.5.0:
     base64-js "^1.3.1"
     ieee754 "^1.1.13"
 
+buffers@~0.1.1:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/buffers/-/buffers-0.1.1.tgz#b24579c3bed4d6d396aeee6d9a8ae7f5482ab7bb"
+  integrity sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==
+
 bytes@3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048"
@@ -2461,6 +2511,13 @@ canvg@^3.0.6:
     stackblur-canvas "^2.0.0"
     svg-pathdata "^6.0.3"
 
+chainsaw@~0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/chainsaw/-/chainsaw-0.1.0.tgz#5eab50b28afe58074d0d58291388828b5e5fbc98"
+  integrity sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==
+  dependencies:
+    traverse ">=0.3.0 <0.4"
+
 chalk@^2.0.0, chalk@^2.0.1:
   version "2.4.2"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
@@ -2770,10 +2827,10 @@ cron-parser@^4.6.0:
   dependencies:
     luxon "^3.1.0"
 
-cronstrue@^2.11.0:
-  version "2.20.0"
-  resolved "https://registry.yarnpkg.com/cronstrue/-/cronstrue-2.20.0.tgz#709ef674ccf385f3c0e77c40a5141911e38b5d05"
-  integrity sha512-cGGxqXGt+yf46nQw2nipLpQLaqlXbeAdcR2HoHYar91bpWRFViw16XCwbSLs0uwhzzYjHwN9BVw5SYIXNqIFwg==
+cronstrue@^2.21.0:
+  version "2.21.0"
+  resolved "https://registry.yarnpkg.com/cronstrue/-/cronstrue-2.21.0.tgz#278d19aa0b9e7ecc90a0c1dbd4f84ceece724094"
+  integrity sha512-YxabE1ZSHA1zJZMPCTSEbc0u4cRRenjqqTgCwJT7OvkspPSvfYFITuPFtsT+VkBuavJtFv2kJXT+mKSnlUJxfg==
 
 cross-env@^7.0.3:
   version "7.0.3"
@@ -3119,6 +3176,13 @@ dot-case@^3.0.4:
     no-case "^3.0.4"
     tslib "^2.0.3"
 
+duplexer2@~0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1"
+  integrity sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==
+  dependencies:
+    readable-stream "^2.0.2"
+
 eastasianwidth@^0.2.0:
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb"
@@ -3680,6 +3744,11 @@ fflate@^0.4.8:
   resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.4.8.tgz#f90b82aefbd8ac174213abb338bd7ef848f0f5ae"
   integrity sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==
 
+fflate@^0.7.3:
+  version "0.7.4"
+  resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.7.4.tgz#61587e5d958fdabb5a9368a302c25363f4f69f50"
+  integrity sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==
+
 file-entry-cache@^6.0.1:
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027"
@@ -3783,10 +3852,10 @@ fs-constants@^1.0.0:
   resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
   integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
 
-fs-extra@^10.1.0:
-  version "10.1.0"
-  resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf"
-  integrity sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==
+fs-extra@^11.1.0:
+  version "11.1.0"
+  resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.1.0.tgz#5784b102104433bb0e090f48bfc4a30742c357ed"
+  integrity sha512-0rcTq621PD5jM/e0a3EJoGC/1TC5ZBCERW82LQuwfGnCa1V8w7dpYH1yNu+SLb6E5dkeCBzKEyLGlFrnr+dUyw==
   dependencies:
     graceful-fs "^4.2.0"
     jsonfile "^6.0.1"
@@ -3807,6 +3876,16 @@ fsevents@~2.3.2:
   resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
   integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
 
+fstream@^1.0.12:
+  version "1.0.12"
+  resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.12.tgz#4e8ba8ee2d48be4f7d0de505455548eae5932045"
+  integrity sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==
+  dependencies:
+    graceful-fs "^4.1.2"
+    inherits "~2.0.0"
+    mkdirp ">=0.5 0"
+    rimraf "2"
+
 function-bind@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
@@ -3919,7 +3998,7 @@ globby@^6.1.0:
     pify "^2.0.0"
     pinkie-promise "^2.0.0"
 
-graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.6, graceful-fs@^4.2.9:
+graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.2, graceful-fs@^4.2.4, graceful-fs@^4.2.6, graceful-fs@^4.2.9:
   version "4.2.10"
   resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c"
   integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==
@@ -4209,7 +4288,7 @@ inflight@^1.0.4:
     once "^1.3.0"
     wrappy "1"
 
-inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3:
+inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.3:
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
   integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -4621,6 +4700,11 @@ lint-staged@^13.0.2:
     string-argv "^0.3.1"
     yaml "^2.1.3"
 
+listenercount@~1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/listenercount/-/listenercount-1.0.1.tgz#84c8a72ab59c4725321480c975e6508342e70937"
+  integrity sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==
+
 listr2@^5.0.5:
   version "5.0.6"
   resolved "https://registry.yarnpkg.com/listr2/-/listr2-5.0.6.tgz#3c61153383869ffaad08a8908d63edfde481dff8"
@@ -4869,6 +4953,13 @@ mitt@^3.0.0:
   resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.0.tgz#69ef9bd5c80ff6f57473e8d89326d01c414be0bd"
   integrity sha512-7dX2/10ITVyqh4aOSVI9gdape+t9l2/8QxHrFmUXu4EEUpdlxl6RudZUPZoc+zuY2hk1j7XxVroIVIan/pD/SQ==
 
+"mkdirp@>=0.5 0":
+  version "0.5.6"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6"
+  integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==
+  dependencies:
+    minimist "^1.2.6"
+
 mousetrap@^1.6.5:
   version "1.6.5"
   resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.5.tgz#8a766d8c272b08393d5f56074e0b5ec183485bf9"
@@ -5590,7 +5681,16 @@ read-cache@^1.0.0:
   dependencies:
     pify "^2.3.0"
 
-readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.5:
+read-excel-file@^5.5.3:
+  version "5.5.3"
+  resolved "https://registry.yarnpkg.com/read-excel-file/-/read-excel-file-5.5.3.tgz#859737f97a1ee0aa845f4515aee43cde9c2875b5"
+  integrity sha512-g43pCe+Tyyq1Z40pNnghqAjoKd/ixGZ2qPgatomVrj158jIeLq7Zs874MxLG08RWEsYUQBL3qGSt/PHbaupKKA==
+  dependencies:
+    "@xmldom/xmldom" "^0.8.2"
+    fflate "^0.7.3"
+    unzipper "^0.10.11"
+
+readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@~2.3.6:
   version "2.3.7"
   resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
   integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==
@@ -5775,7 +5875,7 @@ rgbcolor@^1.0.1:
   resolved "https://registry.yarnpkg.com/rgbcolor/-/rgbcolor-1.0.1.tgz#d6505ecdb304a6595da26fa4b43307306775945d"
   integrity sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==
 
-rimraf@^2.6.3:
+rimraf@2, rimraf@^2.6.3:
   version "2.7.1"
   resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
   integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==
@@ -5938,6 +6038,11 @@ serve-static@1.15.0:
     parseurl "~1.3.3"
     send "0.18.0"
 
+setimmediate@~1.0.4:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285"
+  integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==
+
 setprototypeof@1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656"
@@ -6362,6 +6467,11 @@ toidentifier@1.0.1:
   resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
   integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
 
+"traverse@>=0.3.0 <0.4":
+  version "0.3.9"
+  resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.3.9.tgz#717b8f220cc0bb7b44e40514c22b2e8bbc70d8b9"
+  integrity sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==
+
 tsconfig-paths@^3.14.1:
   version "3.14.1"
   resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz#ba0734599e8ea36c862798e920bcf163277b137a"
@@ -6457,6 +6567,22 @@ unpipe@1.0.0, unpipe@~1.0.0:
   resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
   integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
 
+unzipper@^0.10.11:
+  version "0.10.11"
+  resolved "https://registry.yarnpkg.com/unzipper/-/unzipper-0.10.11.tgz#0b4991446472cbdb92ee7403909f26c2419c782e"
+  integrity sha512-+BrAq2oFqWod5IESRjL3S8baohbevGcVA+teAIOYWM3pDVdseogqbzhhvvmiyQrUNKFUnDMtELW3X8ykbyDCJw==
+  dependencies:
+    big-integer "^1.6.17"
+    binary "~0.3.0"
+    bluebird "~3.4.1"
+    buffer-indexof-polyfill "~1.0.0"
+    duplexer2 "~0.1.4"
+    fstream "^1.0.12"
+    graceful-fs "^4.2.2"
+    listenercount "~1.0.1"
+    readable-stream "~2.3.6"
+    setimmediate "~1.0.4"
+
 update-browserslist-db@^1.0.9:
   version "1.0.10"
   resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz#0f54b876545726f17d00cd9a2561e6dade943ff3"
@@ -6798,6 +6924,10 @@ ws@^8.4.2:
   resolved "https://registry.yarnpkg.com/ws/-/ws-8.11.0.tgz#6a0d36b8edfd9f96d8b25683db2f8d7de6e8e143"
   integrity sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==
 
+"xlsx@https://cdn.sheetjs.com/xlsx-0.19.1/xlsx-0.19.1.tgz":
+  version "0.19.1"
+  resolved "https://cdn.sheetjs.com/xlsx-0.19.1/xlsx-0.19.1.tgz#26b48611bb82799de36add2f63b197bec4fc6a8d"
+
 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"