Ahmad Kholid 3 years ago
parent
commit
4cfe2d225b
89 changed files with 2755 additions and 1163 deletions
  1. 2 0
      .eslintrc.js
  2. 22 0
      .github/workflows/release.yml
  3. 32 27
      package.json
  4. 13 5
      src/assets/css/fonts.css
  5. 20 0
      src/assets/css/tailwind.css
  6. BIN
      src/assets/fonts/jetbrains-mono-v6-latin-regular.woff
  7. BIN
      src/assets/fonts/jetbrains-mono-v6-latin-regular.woff2
  8. BIN
      src/assets/fonts/source-code-pro-v21-latin-600.woff
  9. BIN
      src/assets/fonts/source-code-pro-v21-latin-600.woff2
  10. BIN
      src/assets/fonts/source-code-pro-v21-latin-regular.woff
  11. BIN
      src/assets/fonts/source-code-pro-v21-latin-regular.woff2
  12. 10 0
      src/assets/svg/logoFirefox.svg
  13. 11 7
      src/background/WorkflowLogger.js
  14. 7 1
      src/background/index.js
  15. 2 2
      src/background/workflowEngine/blocksHandler/handlerConditions.js
  16. 3 0
      src/background/workflowEngine/blocksHandler/handlerExportData.js
  17. 3 1
      src/background/workflowEngine/blocksHandler/handlerJavascriptCode.js
  18. 8 0
      src/background/workflowEngine/blocksHandler/handlerLoopBreakpoint.js
  19. 17 3
      src/background/workflowEngine/blocksHandler/handlerLoopData.js
  20. 3 2
      src/background/workflowEngine/blocksHandler/handlerSwitchTab.js
  21. 2 2
      src/background/workflowEngine/blocksHandler/handlerSwitchTo.js
  22. 36 26
      src/background/workflowEngine/engine.js
  23. 1 0
      src/background/workflowEngine/helper.js
  24. 47 0
      src/background/workflowEngine/injectContentScript.js
  25. 17 6
      src/background/workflowEngine/worker.js
  26. 14 7
      src/components/newtab/app/AppSidebar.vue
  27. 72 56
      src/components/newtab/logs/LogsDataViewer.vue
  28. 5 6
      src/components/newtab/logs/LogsFilters.vue
  29. 218 0
      src/components/newtab/logs/LogsHistory.vue
  30. 140 0
      src/components/newtab/logs/LogsTable.vue
  31. 71 0
      src/components/newtab/logs/LogsVariables.vue
  32. 1 20
      src/components/newtab/shared/SharedCard.vue
  33. 4 3
      src/components/newtab/shared/SharedCodemirror.vue
  34. 1 3
      src/components/newtab/shared/SharedConditionBuilder/ConditionBuilderInputs.vue
  35. 128 44
      src/components/newtab/shared/SharedLogsTable.vue
  36. 1 1
      src/components/newtab/shared/SharedWysiwyg.vue
  37. 62 19
      src/components/newtab/workflow/WorkflowBuilder.vue
  38. 34 1
      src/components/newtab/workflow/WorkflowBuilderSearchBlocks.vue
  39. 1 0
      src/components/newtab/workflow/WorkflowEditBlock.vue
  40. 28 6
      src/components/newtab/workflow/edit/EditExportData.vue
  41. 32 3
      src/components/newtab/workflow/edit/EditGoogleSheets.vue
  42. 2 2
      src/components/newtab/workflow/edit/EditInsertData.vue
  43. 1 1
      src/components/newtab/workflow/edit/EditNewTab.vue
  44. 7 0
      src/components/newtab/workflow/edit/EditSwitchTab.vue
  45. 0 1
      src/components/newtab/workflow/edit/EditSwitchTo.vue
  46. 1 0
      src/components/newtab/workflow/edit/EditWebhook.vue
  47. 4 0
      src/components/ui/UiAutocomplete.vue
  48. 14 1
      src/components/ui/UiExpand.vue
  49. 170 0
      src/components/ui/UiTable.vue
  50. 3 7
      src/components/ui/UiTextarea.vue
  51. 6 0
      src/composable/liveQuery.js
  52. 13 5
      src/composable/shortcut.js
  53. 12 4
      src/content/blocksHandler/handlerConditions.js
  54. 13 2
      src/content/blocksHandler/handlerEventClick.js
  55. 9 6
      src/content/blocksHandler/handlerJavascriptCode.js
  56. 36 10
      src/content/blocksHandler/handlerLoopData.js
  57. 1 1
      src/content/blocksHandler/handlerTakeScreenshot.js
  58. 30 7
      src/content/index.js
  59. 22 0
      src/db/logs.js
  60. 6 0
      src/lib/vRemixicon.js
  61. 2 0
      src/locales/en/common.json
  62. 22 1
      src/locales/en/newtab.json
  63. 2 1
      src/manifest.firefox.json
  64. 46 31
      src/newtab/App.vue
  65. 12 4
      src/newtab/pages/Collections.vue
  66. 39 67
      src/newtab/pages/Home.vue
  67. 49 60
      src/newtab/pages/Logs.vue
  68. 222 0
      src/newtab/pages/ScheduledWorkflow.vue
  69. 49 28
      src/newtab/pages/Welcome.vue
  70. 11 14
      src/newtab/pages/collections/[id].vue
  71. 127 0
      src/newtab/pages/logs/Running.vue
  72. 142 214
      src/newtab/pages/logs/[id].vue
  73. 1 1
      src/newtab/pages/settings/SettingsEditor.vue
  74. 1 1
      src/newtab/pages/settings/SettingsIndex.vue
  75. 6 15
      src/newtab/pages/workflows/Host.vue
  76. 47 43
      src/newtab/pages/workflows/[id].vue
  77. 14 2
      src/newtab/router.js
  78. 42 17
      src/store/index.js
  79. 2 2
      src/utils/dataExporter.js
  80. 56 0
      src/utils/dataMigration.js
  81. 9 1
      src/utils/handleFormElement.js
  82. 5 1
      src/utils/helper.js
  83. 34 1
      src/utils/referenceData/mustacheReplacer.js
  84. 13 0
      src/utils/shared.js
  85. 1 0
      src/utils/testConditions.js
  86. 1 1
      tailwind.config.js
  87. 24 15
      utils/webserver.js
  88. 8 11
      webpack.config.js
  89. 350 334
      yarn.lock

+ 2 - 0
.eslintrc.js

@@ -34,9 +34,11 @@ module.exports = {
   rules: {
     camelcase: 'off',
     'no-await-in-loop': 'off',
+    'import/no-import-module-exports': 'off',
     'no-console': ['warn', { allow: ['warn', 'error'] }],
     'no-underscore-dangle': 'off',
     'func-names': 'off',
+    'vue/v-on-event-hyphenation': 'off',
     'import/no-named-default': 'off',
     'no-restricted-syntax': 'off',
     'vue/multi-word-component-names': 'off',

+ 22 - 0
.github/workflows/release.yml

@@ -0,0 +1,22 @@
+name: Release
+
+on:
+  push:
+    tags:
+      - 'v*'
+
+jobs:
+  release:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+        with:
+          fetch-depth: 0
+
+      - uses: actions/setup-node@v3
+        with:
+          node-version: 16.x
+
+      - run: npx changelogithub
+        env:
+          GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}

+ 32 - 27
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "1.12.2",
+  "version": "1.12.3",
   "description": "An extension for automating your browser by connecting blocks",
   "license": "MIT",
   "repository": {
@@ -17,7 +17,8 @@
     "dev": "node utils/webserver.js",
     "dev:firefox": "cross-env BROWSER=firefox yarn dev",
     "prettier": "prettier --write '**/*.{js,jsx,css,html}'",
-    "lint": "eslint --ext .js,.vue --ignore-path .gitignore ."
+    "lint": "eslint --ext .js,.vue --ignore-path .gitignore .",
+    "release": "bumpp --tag --commit --push"
   },
   "engines": {
     "node": ">=14.18.1"
@@ -29,11 +30,10 @@
     "*.{js,ts,vue}": "eslint --fix"
   },
   "dependencies": {
-    "@codemirror/basic-setup": "^0.20.0",
-    "@codemirror/fold": "^0.19.4",
-    "@codemirror/lang-javascript": "^0.20.1",
-    "@codemirror/lang-json": "^0.20.0",
-    "@codemirror/theme-one-dark": "^0.20.0",
+    "@codemirror/lang-javascript": "^6.0.0",
+    "@codemirror/lang-json": "^6.0.0",
+    "@codemirror/language": "^6.0.0",
+    "@codemirror/theme-one-dark": "^6.0.0",
     "@medv/finder": "^2.1.0",
     "@tiptap/extension-character-count": "^2.0.0-beta.24",
     "@tiptap/extension-image": "^2.0.0-beta.25",
@@ -42,12 +42,15 @@
     "@tiptap/starter-kit": "^2.0.0-beta.181",
     "@tiptap/vue-3": "^2.0.0-beta.90",
     "@viselect/vanilla": "^3.0.0-beta.13",
+    "@vueuse/rxjs": "^8.6.0",
     "@vuex-orm/core": "^0.36.4",
+    "codemirror": "^6.0.0",
     "compare-versions": "^4.1.2",
     "crypto-js": "^4.1.1",
     "css-selector-generator": "^3.6.1",
     "dayjs": "^1.10.7",
     "defu": "^6.0.0",
+    "dexie": "^3.2.2",
     "drawflow": "^0.0.58",
     "idb": "^7.0.0",
     "lodash.clonedeep": "^4.5.0",
@@ -56,11 +59,12 @@
     "nanoid": "^3.2.0",
     "object-path": "^0.11.8",
     "papaparse": "^5.3.1",
+    "rxjs": "^7.5.5",
     "tippy.js": "^6.3.1",
     "v-remixicon": "^0.1.1",
-    "vue": "^3.2.31",
+    "vue": "^3.2.37",
     "vue-i18n": "^9.2.0-beta.29",
-    "vue-inspector-agnostic": "^0.0.6",
+    "vue-inspector-agnostic": "^1.0.0",
     "vue-router": "^4.0.11",
     "vue-toastification": "^2.0.0-rc.5",
     "vuedraggable": "^4.1.0",
@@ -68,22 +72,23 @@
     "webextension-polyfill": "^0.9.0"
   },
   "devDependencies": {
-    "@babel/core": "7.15.5",
-    "@babel/eslint-parser": "7.15.7",
-    "@babel/preset-env": "7.15.6",
+    "@babel/core": "^7.18.5",
+    "@babel/eslint-parser": "^7.18.2",
+    "@babel/preset-env": "^7.18.2",
     "@intlify/vue-i18n-loader": "^4.2.0",
     "@tailwindcss/typography": "^0.5.1",
-    "@vue/compiler-sfc": "3.2.19",
-    "archiver": "^5.3.0",
-    "autoprefixer": "10.3.6",
+    "@vue/compiler-sfc": "^3.2.37",
+    "archiver": "^5.3.1",
+    "autoprefixer": "^10.4.7",
     "babel-loader": "^8.2.2",
+    "bumpp": "^7.1.1",
     "clean-webpack-plugin": "4.0.0",
-    "copy-webpack-plugin": "9.0.1",
+    "copy-webpack-plugin": "^11.0.0",
     "core-js": "3",
     "cross-env": "^7.0.3",
     "css-loader": "5.2.7",
     "eslint": "7.32.0",
-    "eslint-config-airbnb-base": "^14.2.1",
+    "eslint-config-airbnb-base": "^15.0.0",
     "eslint-config-prettier": "^8.3.0",
     "eslint-friendly-formatter": "^4.0.1",
     "eslint-import-resolver-webpack": "^0.13.2",
@@ -92,20 +97,20 @@
     "eslint-plugin-vue": "^9.1.0",
     "file-loader": "^6.2.0",
     "fs-extra": "10.0.0",
-    "html-loader": "2.1.2",
-    "html-webpack-plugin": "5.3.2",
-    "lint-staged": "^11.1.2",
+    "html-loader": "3.1.0",
+    "html-webpack-plugin": "^5.5.0",
+    "lint-staged": "^13.0.1",
     "mini-css-extract-plugin": "^2.3.0",
-    "postcss": "8.3.8",
-    "postcss-loader": "^6.1.1",
+    "postcss": "8.4.14",
+    "postcss-loader": "^7.0.0",
     "prettier": "^2.6.2",
     "simple-git-hooks": "^2.6.1",
-    "source-map-loader": "3.0.0",
+    "source-map-loader": "^4.0.0",
     "tailwindcss": "^3.0.7",
-    "terser-webpack-plugin": "5.2.4",
-    "vue-loader": "16.8.1",
-    "webpack": "5.55.1",
+    "terser-webpack-plugin": "^5.3.3",
+    "vue-loader": "^17.0.0",
+    "webpack": "^5.73.0",
     "webpack-cli": "4.9.2",
-    "webpack-dev-server": "3.11.2"
+    "webpack-dev-server": "^4.9.2"
   }
 }

+ 13 - 5
src/assets/css/fonts.css

@@ -7,13 +7,21 @@
   src: url('../fonts/Inter-roman-latin.var.woff2') format("woff2");
 }
 
-/* fira-code-regular - latin */
-/* jetbrains-mono-regular - latin */
+/* source-code-pro-regular - latin */
 @font-face {
-  font-family: 'JetBrains Mono';
+  font-family: 'Source Code Pro';
   font-style: normal;
   font-weight: 400;
   src: local(''),
-       url('../fonts/jetbrains-mono-v6-latin-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
-       url('../fonts/jetbrains-mono-v6-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+       url('../fonts/source-code-pro-v21-latin-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
+       url('../fonts/source-code-pro-v21-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
 }
+/* source-code-pro-600 - latin */
+@font-face {
+  font-family: 'Source Code Pro';
+  font-style: normal;
+  font-weight: 600;
+  src: local(''),
+       url('../fonts/source-code-pro-v21-latin-600.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
+       url('../fonts/source-code-pro-v21-latin-600.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+}

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

@@ -63,6 +63,26 @@ select:focus,
   overflow: hidden;
   text-overflow: ellipsis;
 }
+
+
+.custom-table thead {
+  @apply bg-box-transparent;
+}
+.custom-table thead th {
+  @apply font-semibold;
+}
+.custom-table thead th:first-child {
+  @apply rounded-l-lg;
+}
+.custom-table thead th:last-child {
+  @apply rounded-r-lg;
+}
+.custom-table tbody {
+  @apply divide-y;
+}
+
+
+
 pre {
   font-size: 15px;
 }

BIN
src/assets/fonts/jetbrains-mono-v6-latin-regular.woff


BIN
src/assets/fonts/jetbrains-mono-v6-latin-regular.woff2


BIN
src/assets/fonts/source-code-pro-v21-latin-600.woff


BIN
src/assets/fonts/source-code-pro-v21-latin-600.woff2


BIN
src/assets/fonts/source-code-pro-v21-latin-regular.woff


BIN
src/assets/fonts/source-code-pro-v21-latin-regular.woff2


+ 10 - 0
src/assets/svg/logoFirefox.svg

@@ -0,0 +1,10 @@
+<svg width="600" height="600" viewBox="0 0 600 600" fill="none" xmlns="http://www.w3.org/2000/svg">
+	<rect x="53" y="58" width="452" height="452" rx="80" stroke="context-fill" stroke-width="30" stroke-linejoin="round"/>
+
+	<g fill-rule='evenodd' fill='context-fill'>
+		<path d="
+			M175 90h292A80 80 0 0 1 547 170v292A80 80 0 0 1 467 542h-292A80 80 0 0 1 95 462v-292A80 80 0 0 1 175 90M95 90M321 316
+			M293.667 220.666C304.723 198.554 336.277 198.554 347.333 220.666L432.308 390.616C444.814 415.628 419.067 443.212 392.87 433.428C363.012 422.277 332.936 412.348 320.5 412.348C308.064 412.348 277.988 422.277 248.13 433.428C221.933 443.212 196.186 415.628 208.692 390.616L293.667 220.666Z
+		" rx="80"/>
+	</g>
+</svg>

+ 11 - 7
src/background/WorkflowLogger.js

@@ -1,15 +1,19 @@
+import dbLogs, { defaultLogItem } from '@/db/logs';
+/* eslint-disable class-methods-use-this */
 class WorkflowLogger {
-  constructor({ storage, key = 'logs' }) {
+  constructor({ key = 'logs' }) {
     this.key = key;
-    this.storage = storage;
   }
 
-  async add(data) {
-    const logs = (await this.storage.get(this.key)) || [];
+  async add({ detail, history, ctxData, data }) {
+    const logDetail = { ...defaultLogItem, ...detail };
 
-    logs.unshift(data);
-
-    await this.storage.set(this.key, logs);
+    await Promise.all([
+      dbLogs.logsData.add(data),
+      dbLogs.ctxData.add(ctxData),
+      dbLogs.items.add(logDetail),
+      dbLogs.histories.add(history),
+    ]);
   }
 }
 

+ 7 - 1
src/background/index.js

@@ -93,6 +93,8 @@ const workflow = {
     } else {
       engine.init();
       engine.on('destroyed', ({ id, status }) => {
+        if (status === 'stopped') return;
+
         browser.permissions
           .contains({ permissions: ['notifications'] })
           .then((hasPermission) => {
@@ -318,7 +320,11 @@ browser.alarms.onAlarm.addListener(async ({ name }) => {
   const currentWorkflow = await workflow.get(name);
   if (!currentWorkflow) return;
 
-  const { data } = findTriggerBlock(JSON.parse(currentWorkflow.drawflow)) || {};
+  const drawflow =
+    typeof currentWorkflow.drawflow === 'string'
+      ? parseJSON(currentWorkflow.drawflow, {})
+      : currentWorkflow.drawflow;
+  const { data } = findTriggerBlock(drawflow) || {};
   if (data && data.type === 'interval' && data.fixedDelay) {
     const workflowState = await workflow.states.get(
       ({ workflowId }) => name === workflowId

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

@@ -42,7 +42,7 @@ function checkConditions(data, conditionOptions) {
   });
 }
 
-async function conditions({ data, outputs }, { prevBlockData, refData }) {
+async function conditions({ data, outputs, id }, { prevBlockData, refData }) {
   if (data.conditions.length === 0) {
     throw new Error('conditions-empty');
   }
@@ -62,7 +62,7 @@ async function conditions({ data, outputs }, { prevBlockData, refData }) {
       refData,
       activeTab: this.activeTab.id,
       sendMessage: (payload) =>
-        this._sendMessageToTab({ ...payload, isBlock: false }),
+        this._sendMessageToTab({ ...payload.data, name: 'conditions', id }),
     };
 
     const conditionsResult = await checkConditions(data, conditionPayload);

+ 3 - 0
src/background/workflowEngine/blocksHandler/handlerExportData.js

@@ -27,6 +27,9 @@ async function exportData({ data, outputs }, { refData }) {
     });
     const blobUrl = dataExporter(payload, {
       ...data,
+      csvOptions: {
+        delimiter: data.csvDelimiter || ',',
+      },
       returnUrl: hasDownloadAccess,
     });
 

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

@@ -24,8 +24,10 @@ export async function javascriptCode({ outputs, data, ...block }, { refData }) {
     const payload = { ...block, data, refData: { variables: {} } };
     if (data.code.includes('automaRefData')) payload.refData = refData;
 
-    const result = await this._sendMessageToTab(payload);
+    if (!data.code.includes('automaNextBlock'))
+      payload.data.code += `\nautomaNextBlock()`;
 
+    const result = await this._sendMessageToTab(payload);
     if (result) {
       if (result.columns.data?.$error) {
         throw new Error(result.columns.data.message);

+ 8 - 0
src/background/workflowEngine/blocksHandler/handlerLoopBreakpoint.js

@@ -23,6 +23,14 @@ function loopBreakpoint(block, { prevBlockData }) {
         nextBlockId: currentLoop.blockId,
       });
     } else {
+      if (currentLoop.type === 'elements') {
+        const loopElsIndex = this.loopEls.findIndex(
+          ({ blockId }) => blockId === currentLoop.blockId
+        );
+
+        if (loopElsIndex !== -1) this.loopEls.splice(loopElsIndex, 1);
+      }
+
       delete this.loopList[block.data.loopId];
       delete this.engine.referenceData.loopData[block.data.loopId];
 

+ 17 - 3
src/background/workflowEngine/blocksHandler/handlerLoopData.js

@@ -32,19 +32,33 @@ async function loopData({ data, id, outputs }, { refData }) {
         variable: () => {
           const variableVal = refData.variables[data.variableName];
 
+          if (Array.isArray(variableVal)) return variableVal;
+
           return parseJSON(variableVal, variableVal);
         },
         elements: async () => {
-          const elements = await this._sendMessageToTab({
+          const max = +data.maxLoop || 0;
+          const findBy = isXPath(data.elementSelector)
+            ? 'xpath'
+            : 'cssSelector';
+          const { elements, url, loopId } = await this._sendMessageToTab({
             id,
             name: 'loop-data',
             data: {
+              max,
+              findBy,
               multiple: true,
-              max: +data.maxLoop || 0,
               selector: data.elementSelector,
-              findBy: isXPath(data.elementSelector) ? 'xpath' : 'cssSelector',
             },
           });
+          this.loopEls.push({
+            url,
+            max,
+            loopId,
+            findBy,
+            blockId: id,
+            selector: data.elementSelector,
+          });
 
           return elements;
         },

+ 3 - 2
src/background/workflowEngine/blocksHandler/handlerSwitchTab.js

@@ -12,6 +12,7 @@ export default async function ({ data, outputs }) {
     return error;
   };
   this.windowId = null;
+  const activeTab = data.activeTab ?? true;
   let [tab] = await browser.tabs.query({ url: data.matchPattern });
 
   if (!tab) {
@@ -21,7 +22,7 @@ export default async function ({ data, outputs }) {
       }
 
       tab = await browser.tabs.create({
-        active: true,
+        active: activeTab,
         url: data.url,
         windowId: this.windowId,
       });
@@ -29,7 +30,7 @@ export default async function ({ data, outputs }) {
       throw generateError('no-match-tab', { pattern: data.matchPattern });
     }
   } else {
-    await browser.tabs.update(tab.id, { active: true });
+    await browser.tabs.update(tab.id, { active: activeTab });
   }
 
   if (this.settings.debugMode) {

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

@@ -1,4 +1,4 @@
-import { objectHasKey } from '@/utils/helper';
+import { objectHasKey, sleep } from '@/utils/helper';
 import { getBlockConnection, getFrames } from '../helper';
 
 async function switchTo(block) {
@@ -34,7 +34,7 @@ async function switchTo(block) {
     if (objectHasKey(frames, url)) {
       this.activeTab.frameId = frames[url];
 
-      await new Promise((resolve) => setTimeout(resolve, 1000));
+      await sleep(1000);
 
       return {
         data: this.activeTab.frameId,

+ 36 - 26
src/background/workflowEngine/engine.js

@@ -5,18 +5,16 @@ import { clearCache, sleep, parseJSON, isObject } from '@/utils/helper';
 import Worker from './worker';
 
 class WorkflowEngine {
-  constructor(
-    workflow,
-    { states, logger, blocksHandler, parentWorkflow, options }
-  ) {
+  constructor(workflow, { states, logger, blocksHandler, options }) {
     this.id = nanoid();
     this.states = states;
     this.logger = logger;
     this.workflow = workflow;
     this.blocksHandler = blocksHandler;
-    this.parentWorkflow = parentWorkflow;
+    this.parentWorkflow = options?.parentWorkflow;
     this.saveLog = workflow.settings?.saveLog ?? true;
 
+    this.workerId = 0;
     this.workers = new Map();
     this.waitConnections = {};
 
@@ -166,25 +164,29 @@ class WorkflowEngine {
   }
 
   addWorker(detail) {
-    const worker = new Worker(this);
+    this.workerId += 1;
+
+    const workerId = `worker-${this.workerId}`;
+    const worker = new Worker(workerId, this);
     worker.init(detail);
 
     this.workers.set(worker.id, worker);
   }
 
   addLogHistory(detail) {
-    if (detail.name === 'blocks-group') return;
+    if (['blocks-group', 'delay'].includes(detail.name)) return;
 
     const isLimit = this.history.length >= 1001;
     const notErrorLog = detail.type !== 'error';
 
-    if ((!this.saveLog || isLimit) && notErrorLog) return;
+    if ((isLimit || !this.saveLog) && notErrorLog) return;
 
     this.logHistoryId += 1;
     detail.id = this.logHistoryId;
 
     if (
       detail.replacedValue ||
+      detail.name === 'javascript-code' ||
       (tasks[detail.name]?.refDataKeys && this.saveLog)
     ) {
       const { activeTabUrl, variables, loopData } = JSON.parse(
@@ -273,24 +275,31 @@ class WorkflowEngine {
       if (!this.workflow.isTesting) {
         const { name, id } = this.workflow;
 
-        let { logsCtxData } = await browser.storage.local.get('logsCtxData');
-        if (!logsCtxData) logsCtxData = {};
-        logsCtxData[this.id] = this.historyCtxData;
-        await browser.storage.local.set({ logsCtxData });
-
         await this.logger.add({
-          name,
-          status,
-          message,
-          id: this.id,
-          workflowId: id,
-          endedAt: endedTimestamp,
-          parentLog: this.parentWorkflow,
-          startedAt: this.startedTimestamp,
-          history: this.saveLog ? this.history : [],
+          detail: {
+            name,
+            status,
+            message,
+            id: this.id,
+            workflowId: id,
+            endedAt: endedTimestamp,
+            parentLog: this.parentWorkflow,
+            startedAt: this.startedTimestamp,
+          },
+          history: {
+            logId: this.id,
+            data: this.saveLog ? this.history : [],
+          },
+          ctxData: {
+            logId: this.id,
+            data: this.historyCtxData,
+          },
           data: {
-            table: this.referenceData.table,
-            variables: this.referenceData.variables,
+            logId: this.id,
+            data: {
+              table: this.referenceData.table,
+              variables: this.referenceData.variables,
+            },
           },
         });
       }
@@ -334,13 +343,14 @@ class WorkflowEngine {
       tabIds: [],
       currentBlock: [],
       name: this.workflow.name,
+      logs: this.history.slice(-5),
       startedTimestamp: this.startedTimestamp,
     };
 
     this.workers.forEach((worker) => {
-      const { id, name } = worker.currentBlock;
+      const { id, name, startedAt } = worker.currentBlock;
 
-      state.currentBlock.push({ id, name });
+      state.currentBlock.push({ id, name, startedAt });
       state.tabIds.push(worker.activeTab.id);
     });
 

+ 1 - 0
src/background/workflowEngine/helper.js

@@ -93,6 +93,7 @@ export function convertData(data, type) {
 
   switch (type) {
     case 'integer':
+      /* eslint-disable-next-line */
       result = typeof data !== 'number' ? +data?.replace(/\D+/g, '') : data;
       break;
     case 'boolean':

+ 47 - 0
src/background/workflowEngine/injectContentScript.js

@@ -0,0 +1,47 @@
+import browser from 'webextension-polyfill';
+
+async function contentScriptExist(tabId, frameId = 0) {
+  try {
+    await browser.tabs.sendMessage(
+      tabId,
+      { type: 'content-script-exists' },
+      { frameId }
+    );
+
+    return true;
+  } catch (error) {
+    return false;
+  }
+}
+
+export default function (tabId, frameId = 0) {
+  return new Promise((resolve) => {
+    const currentFrameId = typeof frameId !== 'number' ? 0 : frameId;
+    const tryCount = 0;
+
+    (async function tryExecute() {
+      try {
+        if (tryCount > 3) {
+          resolve(false);
+          return;
+        }
+
+        await browser.tabs.executeScript(tabId, {
+          allFrames: true,
+          runAt: 'document_end',
+          file: './contentScript.bundle.js',
+        });
+        const isScriptExists = await contentScriptExist(tabId, currentFrameId);
+
+        if (isScriptExists) {
+          resolve(true);
+        } else {
+          setTimeout(tryExecute, 1000);
+        }
+      } catch (error) {
+        console.error(error);
+        setTimeout(tryExecute, 1000);
+      }
+    })();
+  });
+}

+ 17 - 6
src/background/workflowEngine/worker.js

@@ -1,16 +1,17 @@
-import { nanoid } from 'nanoid';
 import browser from 'webextension-polyfill';
 import { toCamelCase, sleep, objectHasKey, isObject } from '@/utils/helper';
 import { tasks } from '@/utils/shared';
 import referenceData from '@/utils/referenceData';
+import injectContentScript from './injectContentScript';
 import { convertData, waitTabLoaded, getBlockConnection } from './helper';
 
 class Worker {
-  constructor(engine) {
-    this.id = nanoid(5);
+  constructor(id, engine) {
+    this.id = id;
     this.engine = engine;
     this.settings = engine.workflow.settings;
 
+    this.loopEls = [];
     this.loopList = {};
     this.repeatedTasks = {};
     this.preloadScripts = [];
@@ -111,8 +112,9 @@ class Worker {
       return;
     }
 
+    const startExecuteTime = Date.now();
     const prevBlock = this.currentBlock;
-    this.currentBlock = block;
+    this.currentBlock = { ...block, startedAt: startExecuteTime };
 
     if (!isRetry) {
       await this.engine.updateState({
@@ -121,8 +123,6 @@ class Worker {
       });
     }
 
-    const startExecuteTime = Date.now();
-
     const blockHandler = this.engine.blocksHandler[toCamelCase(block.name)];
     const handler =
       !blockHandler && tasks[block.name].category === 'interaction'
@@ -153,6 +153,7 @@ class Worker {
         prevBlockData,
         type: status,
         name: block.name,
+        blockId: block.id,
         workerId: this.id,
         description: block.data.description,
         replacedValue: replacedBlock.replacedValue,
@@ -319,6 +320,7 @@ class Worker {
         isBlock: true,
         debugMode,
         executedBlockOnWeb,
+        loopEls: this.loopEls,
         activeTabId: this.activeTab.id,
         frameSelector: this.frameSelector,
         ...payload,
@@ -334,6 +336,15 @@ class Worker {
     } catch (error) {
       console.error(error);
       if (error.message?.startsWith('Could not establish connection')) {
+        const isScriptInjected = await injectContentScript(
+          this.activeTab.id,
+          this.activeTab.frameId
+        );
+
+        if (isScriptInjected) {
+          const result = await this._sendMessageToTab(payload, options);
+          return result;
+        }
         error.message = 'Could not establish connection to the active tab';
       } else if (error.message?.startsWith('No tab')) {
         error.message = 'active-tab-removed';

+ 14 - 7
src/components/newtab/app/AppSidebar.vue

@@ -37,6 +37,12 @@
           <div class="p-2 rounded-lg transition-colors inline-block">
             <v-remixicon :name="tab.icon" />
           </div>
+          <span
+            v-if="tab.id === 'log' && runningWorkflowsLen > 0"
+            class="absolute h-4 w-4 text-xs dark:text-black text-white rounded-full bg-accent -top-1 right-2"
+          >
+            {{ runningWorkflowsLen }}
+          </span>
         </a>
       </router-link>
     </div>
@@ -80,7 +86,7 @@
   </aside>
 </template>
 <script setup>
-import { ref } from 'vue';
+import { ref, computed } from 'vue';
 import { useStore } from 'vuex';
 import { useI18n } from 'vue-i18n';
 import { useRouter } from 'vue-router';
@@ -97,18 +103,18 @@ const router = useRouter();
 
 const extensionVersion = browser.runtime.getManifest().version;
 const tabs = [
-  {
-    id: 'dashboard',
-    icon: 'riHome5Line',
-    path: '/',
-    shortcut: getShortcut('page:dashboard', '/'),
-  },
   {
     id: 'workflow',
     icon: 'riFlowChart',
     path: '/workflows',
     shortcut: getShortcut('page:workflows', '/workflows'),
   },
+  {
+    id: 'schedule',
+    icon: 'riTimeLine',
+    path: '/schedule',
+    shortcut: getShortcut('page:schedule', '/triggers'),
+  },
   {
     id: 'collection',
     icon: 'riFolderLine',
@@ -130,6 +136,7 @@ const tabs = [
 ];
 const hoverIndicator = ref(null);
 const showHoverIndicator = ref(false);
+const runningWorkflowsLen = computed(() => store.state.workflowState.length);
 
 useShortcut(
   tabs.map(({ shortcut }) => shortcut),

+ 72 - 56
src/components/newtab/logs/LogsDataViewer.vue

@@ -1,50 +1,61 @@
 <template>
-  <div class="flex items-center mb-2">
-    <ui-input
-      v-model="state.fileName"
-      :placeholder="t('common.fileName')"
-      :title="t('common.fileName')"
-    />
-    <div class="flex-grow"></div>
-    <ui-popover trigger-width>
-      <template #trigger>
-        <ui-button variant="accent">
-          <span>{{ t('log.exportData.title') }}</span>
-          <v-remixicon name="riArrowDropDownLine" class="ml-2 -mr-1" />
-        </ui-button>
-      </template>
-      <ui-list class="space-y-1">
-        <ui-list-item
-          v-for="type in dataExportTypes"
-          :key="type.id"
-          v-close-popover
-          class="cursor-pointer"
-          @click="exportData(type.id)"
-        >
-          {{ t(`log.exportData.types.${type.id}`) }}
-        </ui-list-item>
-      </ui-list>
-    </ui-popover>
+  <div v-if="state.status === 'loading'" class="text-center py-8">
+    <ui-spinner color="text-primary" />
   </div>
-  <ui-tabs v-if="objectHasKey(log.data, 'table')" v-model="state.activeTab">
-    <ui-tab value="table">
-      {{ t('workflow.table.title') }}
-    </ui-tab>
-    <ui-tab value="variables">
-      {{ t('workflow.variables.title', 2) }}
-    </ui-tab>
-  </ui-tabs>
-  <shared-codemirror
-    :model-value="dataStr"
-    :class="editorClass"
-    class="rounded-t-none"
-    lang="json"
-    readonly
-  />
+  <template v-else-if="state.status === 'idle'">
+    <div class="flex items-center mb-2">
+      <ui-input
+        v-model="state.fileName"
+        :placeholder="t('common.fileName')"
+        :title="t('common.fileName')"
+      />
+      <div class="flex-grow"></div>
+      <ui-popover trigger-width>
+        <template #trigger>
+          <ui-button variant="accent">
+            <span>{{ t('log.exportData.title') }}</span>
+            <v-remixicon name="riArrowDropDownLine" class="ml-2 -mr-1" />
+          </ui-button>
+        </template>
+        <ui-list class="space-y-1">
+          <ui-list-item
+            v-for="type in dataExportTypes"
+            :key="type.id"
+            v-close-popover
+            class="cursor-pointer"
+            @click="exportData(type.id)"
+          >
+            {{ t(`log.exportData.types.${type.id}`) }}
+          </ui-list-item>
+        </ui-list>
+      </ui-popover>
+    </div>
+    <ui-tabs v-if="objectHasKey(logsData, 'table')" v-model="state.activeTab">
+      <ui-tab value="table">
+        {{ t('workflow.table.title') }}
+      </ui-tab>
+      <ui-tab value="variables">
+        {{ t('workflow.variables.title', 2) }}
+      </ui-tab>
+    </ui-tabs>
+    <shared-codemirror
+      :model-value="dataStr"
+      :class="editorClass"
+      class="rounded-t-none"
+      lang="json"
+      readonly
+    />
+  </template>
 </template>
 <script setup>
-import { shallowReactive, computed, defineAsyncComponent } from 'vue';
+import {
+  shallowReactive,
+  computed,
+  defineAsyncComponent,
+  onMounted,
+} from 'vue';
 import { useI18n } from 'vue-i18n';
+import dbLogs from '@/db/logs';
 import { dataExportTypes } from '@/utils/shared';
 import { objectHasKey } from '@/utils/helper';
 import dataExporter from '@/utils/dataExporter';
@@ -67,35 +78,40 @@ const props = defineProps({
 const { t } = useI18n();
 
 const state = shallowReactive({
+  status: 'loading',
   activeTab: 'table',
   fileName: props.log.name,
 });
-const cache = {
+const logsData = {
   table: '',
   variables: '',
 };
 
 const dataStr = computed(() => {
-  if (cache[state.activeTab]) return cache[state.activeTab];
-
-  let { data } = props.log;
-
-  if (objectHasKey(props.log.data, 'table')) {
-    data = props.log.data[state.activeTab];
-  }
-
-  data = JSON.stringify(data, null, 2);
-  /* eslint-disable-next-line */
-  cache[state.activeTab] = data;
+  if (state.status !== 'idle') return '';
 
-  return data;
+  return logsData[state.activeTab] ? logsData[state.activeTab] : '';
 });
 
 function exportData(type) {
   dataExporter(
-    props.log.data?.table || props.log.data,
+    logsData?.table || logsData,
     { name: state.fileName, type },
     true
   );
 }
+
+onMounted(async () => {
+  const data = await dbLogs.logsData.where('logId').equals(props.log.id).last();
+
+  if (!data) {
+    state.status = 'error';
+    return;
+  }
+
+  Object.keys(data.data).forEach((key) => {
+    logsData[key] = JSON.stringify(data.data[key], null, 2);
+  });
+  state.status = 'idle';
+});
 </script>

+ 5 - 6
src/components/newtab/logs/LogsFilters.vue

@@ -68,12 +68,11 @@
         </ui-select>
       </div>
     </ui-popover>
-    <ui-button
-      v-tooltip:bottom="t('log.clearLogs.title')"
-      icon
-      @click="$emit('clear')"
-    >
-      <v-remixicon name="riDeleteBin7Line" />
+    <ui-button @click="$emit('clear')">
+      <v-remixicon name="riDeleteBin7Line" class="mr-2 -ml-1" />
+      <span>
+        {{ t('log.clearLogs.title') }}
+      </span>
     </ui-button>
   </div>
 </template>

+ 218 - 0
src/components/newtab/logs/LogsHistory.vue

@@ -0,0 +1,218 @@
+<template>
+  <router-link
+    v-if="parentLog"
+    replace
+    :to="'/logs/' + currentLog.parentLog?.id || currentLog.collectionLogId"
+    class="mb-4 flex"
+  >
+    <v-remixicon name="riArrowLeftLine" class="mr-2" />
+    {{ t('log.goBack', { name: parentLog.name }) }}
+  </router-link>
+  <div
+    class="p-4 rounded-lg bg-gray-900 dark:bg-gray-800 text-gray-100 dark scroll overflow-auto"
+    style="max-height: 600px"
+  >
+    <slot name="prepend" />
+    <div class="text-sm font-mono space-y-1 w-full overflow-auto">
+      <ui-expand
+        v-for="(item, index) in history"
+        :key="item.id || index"
+        :disabled="!ctxData[item.id]"
+        hide-header-icon
+        class="hoverable rounded-md"
+        active-class="bg-box-transparent"
+        header-class="px-2 w-full text-left focus:ring-0 py-1 rounded-md group cursor-default flex items-start"
+        @click="state.itemId = item.id"
+      >
+        <template #header="{ show }">
+          <span class="w-6">
+            <v-remixicon
+              v-show="ctxData[item.id]"
+              :rotate="show ? 270 : 180"
+              size="20"
+              name="riArrowLeftSLine"
+              class="transition-transform text-gray-400 -ml-1 mr-2"
+            />
+          </span>
+          <span
+            :title="`${t('log.duration')}: ${Math.round(
+              item.duration / 1000
+            )}s`"
+            class="w-14 flex-shrink-0 text-overflow text-gray-400"
+          >
+            {{ countDuration(0, item.duration || 0) }}
+          </span>
+          <span
+            :class="logsType[item.type]?.color"
+            :title="item.type"
+            class="w-2/12 flex-shrink-0 text-overflow"
+          >
+            <v-remixicon
+              :name="logsType[item.type]?.icon"
+              size="18"
+              class="inline-block -mr-1 align-text-top"
+            />
+            {{ item.name }}
+          </span>
+          <span
+            :title="`${t('common.description')} (${item.description})`"
+            class="ml-2 w-2/12 text-overflow flex-shrink-0"
+          >
+            {{ item.description }}
+          </span>
+          <p
+            :title="item.message"
+            class="text-sm line-clamp ml-2 flex-1 leading-tight text-gray-600 dark:text-gray-200"
+          >
+            {{ item.message }}
+          </p>
+          <router-link
+            v-if="item.logId"
+            v-slot="{ navigate }"
+            :to="{ name: 'logs-details', params: { id: item.logId } }"
+            custom
+          >
+            <v-remixicon
+              title="Open log detail"
+              class="ml-2 text-gray-300 cursor-pointer"
+              size="20"
+              name="riFileTextLine"
+              @click.stop="navigate"
+            />
+          </router-link>
+          <router-link
+            v-show="currentLog.workflowId && item.blockId"
+            :to="`/workflows/${currentLog.workflowId}?blockId=${item.blockId}`"
+          >
+            <v-remixicon
+              name="riExternalLinkLine"
+              size="20"
+              title="Go to block"
+              class="text-gray-300 cursor-pointer ml-2 invisible group-hover:visible"
+            />
+          </router-link>
+        </template>
+        <pre
+          class="px-2 pb-2 text-gray-300 overflow-auto text-sm ml-6 scroll max-h-96"
+          >{{ ctxData[state.itemId] || 'EMPTY' }}</pre
+        >
+      </ui-expand>
+      <slot name="append-items" />
+    </div>
+  </div>
+  <div
+    v-if="currentLog.history.length >= 25"
+    class="flex items-center justify-between mt-4"
+  >
+    <div>
+      {{ t('components.pagination.text1') }}
+      <select v-model="pagination.perPage" class="p-1 rounded-md bg-input">
+        <option
+          v-for="num in [25, 50, 75, 100, 150, 200]"
+          :key="num"
+          :value="num"
+        >
+          {{ num }}
+        </option>
+      </select>
+      {{
+        t('components.pagination.text2', {
+          count: currentLog.history.length,
+        })
+      }}
+    </div>
+    <ui-pagination
+      v-model="pagination.currentPage"
+      :per-page="pagination.perPage"
+      :records="currentLog.history.length"
+    />
+  </div>
+</template>
+<script setup>
+/* eslint-disable no-use-before-define */
+import { computed, shallowReactive } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { countDuration } from '@/utils/helper';
+
+const props = defineProps({
+  currentLog: {
+    type: Object,
+    default: () => ({}),
+  },
+  ctxData: {
+    type: Object,
+    default: () => ({}),
+  },
+  parentLog: {
+    type: Object,
+    default: null,
+  },
+});
+
+const logsType = {
+  success: {
+    color: 'text-green-400',
+    icon: 'riCheckLine',
+  },
+  stop: {
+    color: 'text-yellow-400',
+    icon: 'riStopLine',
+  },
+  stopped: {
+    color: 'text-yellow-400',
+    icon: 'riStopLine',
+  },
+  error: {
+    color: 'text-red-400',
+    icon: 'riErrorWarningLine',
+  },
+  finish: {
+    color: 'text-blue-300',
+    icon: 'riFlagLine',
+  },
+};
+
+const { t, te } = useI18n();
+
+const state = shallowReactive({
+  itemId: '',
+});
+const pagination = shallowReactive({
+  perPage: 25,
+  currentPage: 1,
+});
+
+const history = computed(() =>
+  props.currentLog.history
+    .slice(
+      (pagination.currentPage - 1) * pagination.perPage,
+      pagination.currentPage * pagination.perPage
+    )
+    .map(translateLog)
+);
+
+function translateLog(log) {
+  const copyLog = { ...log };
+  const getTranslatation = (path, def) => {
+    const params = typeof path === 'string' ? { path } : path;
+
+    return te(params.path) ? t(params.path, params.params) : def;
+  };
+
+  if (['finish', 'stop'].includes(log.type)) {
+    copyLog.name = t(`log.types.${log.type}`);
+  } else {
+    copyLog.name = getTranslatation(
+      `workflow.blocks.${log.name}.name`,
+      log.name
+    );
+  }
+
+  copyLog.message = getTranslatation(
+    { path: `log.messages.${log.message}`, params: log },
+    log.message
+  );
+
+  return copyLog;
+}
+</script>

+ 140 - 0
src/components/newtab/logs/LogsTable.vue

@@ -0,0 +1,140 @@
+<template>
+  <div v-if="tableData.body.length === 0" class="text-center">
+    <img src="@/assets/svg/files-and-folder.svg" class="mx-auto max-w-sm" />
+    <p class="text-xl font-semibold">{{ t('message.noData') }}</p>
+  </div>
+  <template v-else>
+    <div class="flex items-center">
+      <ui-tabs
+        v-model="state.activeTab"
+        type="fill"
+        class="mb-4"
+        color=""
+        style="padding: 0"
+      >
+        <ui-tab value="table"> Table </ui-tab>
+        <ui-tab value="raw"> Raw </ui-tab>
+      </ui-tabs>
+      <div class="flex-grow"></div>
+      <ui-input
+        v-if="state.activeTab === 'table'"
+        v-model="state.query"
+        :placeholder="t('common.search')"
+        class="mr-4"
+        prepend-icon="riSearch2Line"
+        type="search"
+      />
+      <ui-popover trigger-width>
+        <template #trigger>
+          <ui-button variant="accent">
+            <span>{{ t('log.exportData.title') }}</span>
+            <v-remixicon name="riArrowDropDownLine" class="ml-2 -mr-1" />
+          </ui-button>
+        </template>
+        <ui-list class="space-y-1">
+          <ui-list-item
+            v-for="type in dataExportTypes"
+            :key="type.id"
+            v-close-popover
+            class="cursor-pointer"
+            @click="exportData(type.id)"
+          >
+            {{ t(`log.exportData.types.${type.id}`) }}
+          </ui-list-item>
+        </ui-list>
+      </ui-popover>
+    </div>
+    <shared-codemirror
+      v-show="state.activeTab === 'raw'"
+      :model-value="JSON.stringify(currentLog.data.table, null, 2)"
+      readonly
+      lang="json"
+      style="max-height: 600px"
+    />
+    <ui-table
+      v-show="state.activeTab === 'table'"
+      :headers="tableData.header"
+      :items="rows"
+      :search="state.query"
+      item-key="id"
+      class="w-full"
+    />
+    <div
+      v-if="
+        state.activeTab === 'table' &&
+        tableData.body &&
+        tableData.body.length >= 10
+      "
+      class="flex items-center justify-between mt-4"
+    >
+      <div>
+        {{ t('components.pagination.text1') }}
+        <select v-model="pagination.perPage" class="p-1 rounded-md bg-input">
+          <option
+            v-for="num in [10, 15, 25, 50, 100, 150]"
+            :key="num"
+            :value="num"
+          >
+            {{ num }}
+          </option>
+        </select>
+        {{
+          t('components.pagination.text2', {
+            count: tableData.body.length,
+          })
+        }}
+      </div>
+      <ui-pagination
+        v-model="pagination.currentPage"
+        :per-page="pagination.perPage"
+        :records="tableData.body.length"
+      />
+    </div>
+  </template>
+</template>
+<script setup>
+import { computed, shallowReactive, defineAsyncComponent } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { dataExportTypes } from '@/utils/shared';
+import dataExporter from '@/utils/dataExporter';
+
+const SharedCodemirror = defineAsyncComponent(() =>
+  import('@/components/newtab/shared/SharedCodemirror.vue')
+);
+
+const props = defineProps({
+  tableData: {
+    type: Object,
+    default: () => ({}),
+  },
+  currentLog: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+
+const { t } = useI18n();
+
+const state = shallowReactive({
+  query: '',
+  activeTab: 'table',
+});
+const pagination = shallowReactive({
+  perPage: 10,
+  currentPage: 1,
+});
+const rows = computed(() =>
+  props.tableData.body.slice(
+    (pagination.currentPage - 1) * pagination.perPage,
+    pagination.currentPage * pagination.perPage
+  )
+);
+
+function exportData(type) {
+  dataExporter(
+    props.currentLog.data.table,
+    { name: props.currentLog.name, type },
+    true
+  );
+}
+</script>

+ 71 - 0
src/components/newtab/logs/LogsVariables.vue

@@ -0,0 +1,71 @@
+<template>
+  <div v-if="Object.keys(variables).length === 0" class="text-center">
+    <img src="@/assets/svg/files-and-folder.svg" class="mx-auto max-w-sm" />
+    <p class="text-xl font-semibold">{{ t('message.noData') }}</p>
+  </div>
+  <template v-else>
+    <ui-tabs
+      v-model="state.activeTab"
+      type="fill"
+      class="mb-4"
+      color=""
+      style="padding: 0"
+    >
+      <ui-tab value="gui"> GUI </ui-tab>
+      <ui-tab value="raw"> Raw </ui-tab>
+    </ui-tabs>
+    <div v-if="state.activeTab === 'gui'" class="mt-4">
+      <ul class="space-y-2 grid grid-cols-1 md:grid-cols-2 gap-4">
+        <li
+          v-for="(varValue, varName) in variables"
+          :key="varName"
+          class="px-2 pb-2 pt-1 rounded-lg flex items-center border-2 space-x-2"
+        >
+          <ui-input
+            :model-value="varName"
+            :label="t('common.name')"
+            class="w-full"
+            placeholder="EMPTY"
+            readonly
+          />
+          <ui-input
+            :model-value="varValue"
+            label="Value"
+            class="w-full"
+            placeholder="EMPTY"
+            readonly
+          />
+        </li>
+      </ul>
+    </div>
+    <shared-codemirror
+      v-else
+      :model-value="JSON.stringify(variables, null, 2)"
+      class="mt-4"
+      lang="json"
+      readonly
+    />
+  </template>
+</template>
+<script setup>
+import { defineAsyncComponent, shallowReactive, computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+
+const SharedCodemirror = defineAsyncComponent(() =>
+  import('@/components/newtab/shared/SharedCodemirror.vue')
+);
+
+const props = defineProps({
+  currentLog: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+
+const { t } = useI18n();
+const state = shallowReactive({
+  activeTab: 'gui',
+});
+
+const variables = computed(() => props.currentLog.data?.variables || {});
+</script>

+ 1 - 20
src/components/newtab/shared/SharedCard.vue

@@ -56,21 +56,12 @@
     <div class="flex items-center text-gray-600 dark:text-gray-200">
       <p class="flex-1">{{ state.date }}</p>
       <slot name="footer-content" />
-      <v-remixicon
-        v-if="state.triggerText"
-        v-tooltip="state.triggerText"
-        :class="{ 'ml-2': $slots['footer-content'] }"
-        name="riFlashlightLine"
-        size="20"
-      />
     </div>
   </ui-card>
 </template>
 <script setup>
-import { onMounted, shallowReactive } from 'vue';
-import { useI18n } from 'vue-i18n';
+import { shallowReactive } from 'vue';
 import dayjs from '@/lib/dayjs';
-import triggerText from '@/utils/triggerText';
 
 const props = defineProps({
   data: {
@@ -93,18 +84,8 @@ const props = defineProps({
 
 defineEmits(['execute', 'click', 'menuSelected']);
 
-const { t } = useI18n();
-
 const state = shallowReactive({
   triggerText: null,
   date: dayjs(props.data.createdAt).fromNow(),
 });
-
-onMounted(async () => {
-  const { trigger, id } = props.data;
-
-  if (!trigger) return;
-
-  state.triggerText = await triggerText(trigger, t, id);
-});
 </script>

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

@@ -16,9 +16,10 @@ import { onMounted, ref, onBeforeUnmount, watch } from 'vue';
 import { json } from '@codemirror/lang-json';
 import { indentWithTab } from '@codemirror/commands';
 import { oneDark } from '@codemirror/theme-one-dark';
-import { EditorView, keymap } from '@codemirror/view';
+import { keymap } from '@codemirror/view';
 import { javascript } from '@codemirror/lang-javascript';
-import { EditorState, basicSetup } from '@codemirror/basic-setup';
+import { EditorState } from '@codemirror/state';
+import { EditorView, basicSetup } from 'codemirror';
 
 const props = defineProps({
   lang: {
@@ -109,7 +110,7 @@ onBeforeUnmount(() => {
 .cm-editor .cm-gutters,
 .cm-editor .cm-content,
 .cm-tooltip.cm-tooltip-autocomplete > ul {
-  font-family: JetBrains Mono, Fira code, Fira Mono, Consolas, Menlo, Courier,
+  font-family: 'Source Code Pro', Fira code, Fira Mono, Consolas, Menlo, Courier,
     monospace !important;
 }
 </style>

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

@@ -119,10 +119,8 @@ function getDefaultValues(items) {
   return items.map((item) => defaultValues[item]);
 }
 function filterValueTypes(index) {
-  const exclude = ['element#visible', 'element#invisible'];
-
   return conditionBuilder.valueTypes.reduce((acc, item) => {
-    if (index < 1 || !exclude.includes(item.id)) {
+    if (index < 1 || item.compareable) {
       (acc[item.category] = acc[item.category] || []).push(item);
     }
 

+ 128 - 44
src/components/newtab/shared/SharedLogsTable.vue

@@ -1,47 +1,108 @@
 <template>
-  <table>
-    <tbody class="divide-y dark:divide-gray-800">
-      <tr v-for="log in logs" :key="log.id" class="hoverable">
-        <slot name="item-prepend" :log="log" />
-        <td class="text-overflow" style="min-width: 140px; max-width: 330px">
-          <router-link
-            :to="`/logs/${log.id}`"
-            class="inline-block text-overflow w-full align-middle min-h"
-            style="min-height: 28px"
+  <div class="logs-table">
+    <transition-expand>
+      <div v-if="state.selected.length > 0" class="border-x border-t px-4 py-2">
+        <ui-button @click="stopSelectedWorkflow"> Stop selected </ui-button>
+      </div>
+    </transition-expand>
+    <table class="w-full">
+      <tbody class="divide-y dark:divide-gray-800">
+        <tr v-for="item in running" :key="item.id" class="p-2 border">
+          <td v-if="!hideSelect" class="w-8">
+            <ui-checkbox
+              :model-value="state.selected.includes(item.id)"
+              class="align-text-bottom"
+              @change="toggleSelectedLog($event, item.id)"
+            />
+          </td>
+          <td class="w-4/12">
+            <router-link
+              :to="`/logs/${item.id}/running`"
+              class="inline-block text-overflow w-full align-middle min-h"
+              style="min-height: 28px"
+            >
+              {{ item.state.name }}
+            </router-link>
+          </td>
+          <td
+            class="log-time w-2/12 dark:text-gray-200"
+            :title="t('log.duration')"
           >
-            {{ log.name }}
-          </router-link>
-        </td>
-        <td class="log-time dark:text-gray-200">
-          <v-remixicon
-            :title="t('log.startedDate')"
-            name="riCalendarLine"
-            class="mr-2 inline-block align-middle"
-          />
-          <span :title="formatDate(log.startedAt, 'DD MMM YYYY, hh:mm A')">
-            {{ formatDate(log.startedAt, 'relative') }}
-          </span>
-        </td>
-        <td class="log-time dark:text-gray-200" :title="t('log.duration')">
-          <v-remixicon name="riTimerLine"></v-remixicon>
-          <span>{{ countDuration(log.startedAt, log.endedAt) }}</span>
-        </td>
-        <td class="text-right">
-          <span
-            :class="statusColors[log.status]"
-            :title="log.status === 'error' ? getErrorMessage(log) : null"
-            class="inline-block py-1 w-16 text-center text-sm rounded-md dark:text-black"
+            <v-remixicon name="riTimerLine"></v-remixicon>
+            <span>{{
+              countDuration(item.state.startedTimestamp, Date.now())
+            }}</span>
+          </td>
+          <td title="Executing block" class="text-overflow">
+            <ui-spinner color="text-accent" size="20" />
+            <span class="align-middle inline-block ml-3 text-overflow">
+              {{ t(`workflow.blocks.${item.state.currentBlock[0].name}.name`) }}
+            </span>
+          </td>
+          <td class="text-right">
+            <span
+              class="inline-block py-1 w-16 text-center text-sm rounded-md dark:text-black bg-blue-300"
+            >
+              {{ t('common.running') }}
+            </span>
+          </td>
+          <td class="text-right">
+            <ui-button small class="text-sm" @click="stopWorkflow(item.id)">
+              {{ t('common.stop') }}
+            </ui-button>
+          </td>
+        </tr>
+        <tr v-for="log in logs" :key="log.id" class="hoverable">
+          <slot name="item-prepend" :log="log" />
+          <td
+            class="text-overflow w-4/12"
+            style="min-width: 140px; max-width: 330px"
           >
-            {{ t(`logStatus.${log.status}`) }}
-          </span>
-        </td>
-        <slot name="item-append" :log="log" />
-      </tr>
-    </tbody>
-  </table>
+            <router-link
+              :to="`/logs/${log.id}`"
+              class="inline-block text-overflow w-full align-middle min-h"
+              style="min-height: 28px"
+            >
+              {{ log.name }}
+            </router-link>
+          </td>
+          <td class="log-time w-3/12 dark:text-gray-200">
+            <v-remixicon
+              :title="t('log.startedDate')"
+              name="riCalendarLine"
+              class="mr-2 inline-block align-middle"
+            />
+            <span :title="formatDate(log.startedAt, 'DD MMM YYYY, hh:mm A')">
+              {{ formatDate(log.startedAt, 'relative') }}
+            </span>
+          </td>
+          <td
+            class="log-time w-2/12 dark:text-gray-200"
+            :title="t('log.duration')"
+          >
+            <v-remixicon name="riTimerLine"></v-remixicon>
+            <span>{{ countDuration(log.startedAt, log.endedAt) }}</span>
+          </td>
+          <td class="text-right">
+            <span
+              :class="statusColors[log.status]"
+              :title="log.status === 'error' ? getErrorMessage(log) : null"
+              class="inline-block py-1 w-16 text-center text-sm rounded-md dark:text-black"
+            >
+              {{ t(`logStatus.${log.status}`) }}
+            </span>
+          </td>
+          <slot name="item-append" :log="log" />
+        </tr>
+        <slot name="table:append" />
+      </tbody>
+    </table>
+  </div>
 </template>
 <script setup>
+import { reactive } from 'vue';
 import { useI18n } from 'vue-i18n';
+import { sendMessage } from '@/utils/message';
 import { countDuration } from '@/utils/helper';
 import dayjs from '@/lib/dayjs';
 
@@ -50,6 +111,11 @@ defineProps({
     type: Array,
     default: () => [],
   },
+  running: {
+    type: Array,
+    default: () => [],
+  },
+  hideSelect: Boolean,
 });
 
 const { t, te } = useI18n();
@@ -59,24 +125,42 @@ const statusColors = {
   success: 'bg-green-200 dark:bg-green-300',
   stopped: 'bg-yellow-200 dark:bg-yellow-300',
 };
+const state = reactive({
+  selected: [],
+});
+
+function stopWorkflow(stateId) {
+  sendMessage('workflow:stop', stateId, 'background');
+}
+function toggleSelectedLog(selected, id) {
+  if (selected) {
+    state.selected.push(id);
+    return;
+  }
+
+  const index = state.selected.indexOf(id);
 
+  if (index !== -1) state.selected.splice(index, 1);
+}
 function formatDate(date, format) {
   if (format === 'relative') return dayjs(date).fromNow();
 
   return dayjs(date).format(format);
 }
-function getErrorMessage({ history, message }) {
+function getErrorMessage({ message }) {
   const messagePath = `log.messages.${message}`;
 
   if (message && te(messagePath)) {
     return t(messagePath);
   }
 
-  const lastHistory = history[history.length - 1];
-
-  return lastHistory && lastHistory.type === 'error'
-    ? lastHistory.message
-    : null;
+  return '';
+}
+function stopSelectedWorkflow() {
+  state.selected.forEach((id) => {
+    stopWorkflow(id);
+  });
+  state.selected = [];
 }
 </script>
 <style scoped>

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

@@ -81,7 +81,7 @@ onBeforeUnmount(() => {
 <style>
 .ProseMirror pre,
 .ProseMirror code {
-  font-family: 'JetBrains Mono', monospace;
+  font-family: 'Source Code Pro', monospace;
 }
 .ProseMirror:focus {
   outline: none;

+ 62 - 19
src/components/newtab/workflow/WorkflowBuilder.vue

@@ -42,6 +42,7 @@
       v-model="contextMenu.show"
       :options="contextMenu.position"
       padding="p-3"
+      @close="clearContextMenu"
     >
       <ui-list class="space-y-1 w-52">
         <ui-list-item
@@ -114,7 +115,7 @@ export default {
       default: 'edit',
     },
   },
-  emits: ['load', 'deleteBlock', 'update', 'save'],
+  emits: ['load', 'loaded', 'deleteBlock', 'update', 'save'],
   setup(props, { emit }) {
     useGroupTooltip();
 
@@ -220,6 +221,20 @@ export default {
         active: nodeContent,
       });
     }
+    function getRelativePosToEditor(clientX, clientY) {
+      const { x, y } = editor.value.precanvas.getBoundingClientRect();
+      const { clientWidth, clientHeight } = editor.value.precanvas;
+      const { zoom } = editor.value;
+
+      const xPosition =
+        clientX * (clientWidth / (clientWidth * zoom)) -
+        x * (clientWidth / (clientWidth * zoom));
+      const yPosition =
+        clientY * (clientHeight / (clientHeight * zoom)) -
+        y * (clientHeight / (clientHeight * zoom));
+
+      return { xPosition, yPosition };
+    }
     function dropHandler({ dataTransfer, clientX, clientY, target }) {
       const block = JSON.parse(dataTransfer.getData('block') || null);
 
@@ -302,20 +317,7 @@ export default {
 
       if (block.fromBlockBasic) return;
 
-      const xPosition =
-        clientX *
-          (editor.value.precanvas.clientWidth /
-            (editor.value.precanvas.clientWidth * editor.value.zoom)) -
-        editor.value.precanvas.getBoundingClientRect().x *
-          (editor.value.precanvas.clientWidth /
-            (editor.value.precanvas.clientWidth * editor.value.zoom));
-      const yPosition =
-        clientY *
-          (editor.value.precanvas.clientHeight /
-            (editor.value.precanvas.clientHeight * editor.value.zoom)) -
-        editor.value.precanvas.getBoundingClientRect().y *
-          (editor.value.precanvas.clientHeight /
-            (editor.value.precanvas.clientHeight * editor.value.zoom));
+      const { xPosition, yPosition } = getRelativePosToEditor(clientX, clientY);
 
       const blockId = editor.value.addNode(
         block.id,
@@ -418,7 +420,9 @@ export default {
       activeNode = null;
     }
     function duplicateBlock(nodeId, isPaste = false) {
+      let initialPos = null;
       const nodes = new Map();
+
       const addNode = (id) => {
         const node = editor.value.getNodeFromId(id);
 
@@ -431,6 +435,15 @@ export default {
         store.state.copiedNodes.forEach((node) => {
           nodes.set(node.id, node);
         });
+
+        const pos = contextMenu?.position?.getReferenceClientRect?.() ?? null;
+        if (pos) {
+          const { xPosition, yPosition } = getRelativePosToEditor(
+            pos.left,
+            pos.top
+          );
+          initialPos = { x: xPosition, y: yPosition };
+        }
       } else {
         if (nodeId) addNode(nodeId);
         else if (activeNode) addNode(activeNode.id);
@@ -442,10 +455,12 @@ export default {
         });
       }
 
-      const nodesOutputs = [];
-
       clearSelectedElements();
 
+      const nodesOutputs = [];
+      let firstNodePos = null;
+      let index = 0;
+
       nodes.forEach((node) => {
         const { outputs, inputs } = tasks[node.name];
 
@@ -455,12 +470,28 @@ export default {
         const blockInputs = inputsLen || inputs;
         const blockOutputs = outputsLen || outputs;
 
+        let nodePosX = node.pos_x;
+        let nodePosY = node.pos_y;
+
+        if (initialPos && index === 0) {
+          firstNodePos = { x: nodePosX, y: nodePosY };
+
+          nodePosX = initialPos.x;
+          nodePosY = initialPos.y;
+        } else if (firstNodePos) {
+          const xDistance = nodePosX - firstNodePos.x;
+          const yDistance = nodePosY - firstNodePos.y;
+
+          nodePosX = initialPos.x + xDistance;
+          nodePosY = initialPos.y + yDistance;
+        }
+
         const newNodeId = editor.value.addNode(
           node.name,
           blockInputs,
           blockOutputs,
-          node.pos_x + 25,
-          node.pos_y + 70,
+          nodePosX + 25,
+          nodePosY + 70,
           node.name,
           node.data,
           node.html,
@@ -483,6 +514,8 @@ export default {
         if (outputsLen > 0) {
           nodesOutputs.push({ id: newNodeId, outputs: node.outputs });
         }
+
+        index += 1;
       });
 
       if (nodesOutputs.length < 1) return;
@@ -640,6 +673,14 @@ export default {
         selectedElements.push(nodeProperties);
       }
     }
+    function clearContextMenu() {
+      Object.assign(contextMenu, {
+        items: [],
+        data: null,
+        show: false,
+        position: {},
+      });
+    }
     function copyBlocks() {
       let nodes = selectedElements;
 
@@ -876,6 +917,7 @@ export default {
 
       checkWorkflowData();
       initSelectArea();
+      emit('loaded', editor.value);
     });
     onBeforeUnmount(() => {
       const element = document.querySelector('#drawflow');
@@ -896,6 +938,7 @@ export default {
       contextMenu,
       dropHandler,
       handleDragOver,
+      clearContextMenu,
       contextMenuHandler: {
         copyBlocks,
         deleteBlock,

+ 34 - 1
src/components/newtab/workflow/WorkflowBuilderSearchBlocks.vue

@@ -15,11 +15,13 @@
       <v-remixicon name="riSearch2Line" />
     </button>
     <ui-autocomplete
+      ref="autocompleteEl"
       :model-value="state.query"
       :items="state.autocompleteItems"
       :custom-filter="searchNodes"
       item-key="id"
       item-label="name"
+      @cancel="blurInput"
       @select="onSelectItem"
       @selected="onItemSelected"
     >
@@ -50,7 +52,8 @@
   </div>
 </template>
 <script setup>
-import { reactive } from 'vue';
+/* eslint-disable vue/no-mutating-props */
+import { reactive, ref } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useShortcut } from '@/composable/shortcut';
 
@@ -70,6 +73,8 @@ const initialState = {
   canvasX: 0,
   canvasY: 0,
 };
+
+const autocompleteEl = ref(null);
 const state = reactive({
   query: '',
   active: false,
@@ -115,6 +120,13 @@ function extractBlocks() {
       name: t(`workflow.blocks.${name}.name`),
     })
   );
+
+  props.editor.precanvas.style.transition = 'transform 300ms ease';
+}
+function clearHighlightedNodes() {
+  document.querySelectorAll('.search-select-node').forEach((el) => {
+    el.classList.remove('search-select-node');
+  });
 }
 function clearState() {
   if (!state.selected) {
@@ -133,6 +145,16 @@ function clearState() {
     canvasX: 0,
     canvasY: 0,
   });
+
+  autocompleteEl.value.state.showPopover = false;
+  clearHighlightedNodes();
+
+  setTimeout(() => {
+    props.editor.precanvas.style.transition = '';
+  }, 500);
+}
+function blurInput() {
+  document.querySelector('#search-blocks')?.blur();
 }
 function onSelectItem({ item }) {
   if (props.editor.zoom !== 1) {
@@ -141,6 +163,11 @@ function onSelectItem({ item }) {
     props.editor.zoom_refresh();
   }
 
+  clearHighlightedNodes();
+  document
+    .querySelector(`#node-${item.id}`)
+    ?.classList.add('search-select-node');
+
   const { rectX, rectY } = initialState;
   props.editor.translate_to(
     -(item.pos_x - rectX),
@@ -151,6 +178,7 @@ function onSelectItem({ item }) {
 function onItemSelected(event) {
   state.selected = true;
   onSelectItem(event);
+  blurInput();
 }
 </script>
 <style scoped>
@@ -158,3 +186,8 @@ input {
   transition: width 250ms ease;
 }
 </style>
+<style>
+.search-select-node .drawflow_content_node {
+  @apply ring-4;
+}
+</style>

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

@@ -107,6 +107,7 @@ export default {
         $date: '',
         $randint: '',
         $getLength: '',
+        $randData: '',
       },
     });
 

+ 28 - 6
src/components/newtab/workflow/edit/EditExportData.vue

@@ -63,14 +63,36 @@
         {{ type.name }}
       </option>
     </ui-select>
-    <ui-checkbox
+    <ui-expand
       v-if="data.type === 'csv'"
-      :model-value="data.addBOMHeader"
-      class="mt-2"
-      @change="updateData({ addBOMHeader: $event })"
+      hide-header-icon
+      header-class="flex items-center focus:ring-0 w-full"
     >
-      {{ t('workflow.blocks.export-data.bomHeader') }}
-    </ui-checkbox>
+      <template #header="{ show }">
+        <v-remixicon
+          :rotate="show ? 270 : 180"
+          name="riArrowLeftSLine"
+          class="transition-transform text-gray-600 dark:text-gray-300"
+        />
+        {{ t('common.options') }}
+      </template>
+      <div class="pl-6 mt-1">
+        <ui-checkbox
+          v-if="data.type === 'csv'"
+          :model-value="data.addBOMHeader"
+          @change="updateData({ addBOMHeader: $event })"
+        >
+          {{ t('workflow.blocks.export-data.bomHeader') }}
+        </ui-checkbox>
+        <ui-input
+          :model-value="data.csvDelimiter"
+          label="Delimiter"
+          class="mt-1"
+          placeholder=","
+          @change="updateData({ csvDelimiter: $event })"
+        />
+      </div>
+    </ui-expand>
   </div>
 </template>
 <script setup>

+ 32 - 3
src/components/newtab/workflow/edit/EditGoogleSheets.vue

@@ -20,7 +20,7 @@
         :model-value="data.spreadsheetId"
         class="w-full"
         placeholder="abcd123"
-        @change="updateData({ spreadsheetId: $event })"
+        @change="updateData({ spreadsheetId: $event }), checkPermission($event)"
       >
         <template #label>
           {{ t('workflow.blocks.google-sheets.spreadsheetId.label') }}*
@@ -35,6 +35,16 @@
         </template>
       </ui-input>
     </edit-autocomplete>
+    <a
+      v-if="!state.havePermission"
+      href="https://docs.automa.site/blocks/google-sheets.html#access-to-spreadsheet"
+      target="_blank"
+      rel="noopener"
+      class="text-sm leading-tight inline-block ml-1"
+    >
+      Automa doesn't have access to the spreadsheet
+      <v-remixicon name="riInformationLine" size="18" class="inline" />
+    </a>
     <edit-autocomplete>
       <ui-input
         :model-value="data.range"
@@ -195,8 +205,8 @@
 <script setup>
 import { shallowReactive, defineAsyncComponent } from 'vue';
 import { useI18n } from 'vue-i18n';
-import { googleSheets } from '@/utils/api';
-import { convert2DArrayToArrayObj } from '@/utils/helper';
+import { googleSheets, fetchApi } from '@/utils/api';
+import { convert2DArrayToArrayObj, debounce } from '@/utils/helper';
 import EditAutocomplete from './EditAutocomplete.vue';
 import InsertWorkflowData from './InsertWorkflowData.vue';
 
@@ -228,6 +238,25 @@ const customDataState = shallowReactive({
   showModal: false,
   data: props.data.customData,
 });
+const state = shallowReactive({
+  lastSheetId: null,
+  havePermission: true,
+});
+
+const checkPermission = debounce(async (value) => {
+  try {
+    if (state.lastSheetId === value) return;
+
+    const response = await fetchApi(
+      `/services/google-sheets/meta?spreadsheetId=${value}`
+    );
+
+    state.havePermission = response.status !== 403;
+    state.lastSheetId = value;
+  } catch (error) {
+    console.error(error);
+  }
+}, 1000);
 
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });

+ 2 - 2
src/components/newtab/workflow/edit/EditInsertData.vue

@@ -46,8 +46,8 @@
             </option>
           </ui-select>
         </div>
-        <div class="flex items-center">
-          <ui-input
+        <div class="flex items-start">
+          <ui-textarea
             v-model="item.value"
             placeholder="value"
             title="value"

+ 1 - 1
src/components/newtab/workflow/edit/EditNewTab.vue

@@ -12,8 +12,8 @@
       </label>
       <ui-textarea
         id="new-tab-url"
-        key="anu"
         :model-value="data.url"
+        rows="1"
         class="w-full"
         autocomplete="off"
         placeholder="http://example.com/"

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

@@ -41,6 +41,13 @@
         @change="updateData({ url: $event })"
       />
     </edit-autocomplete>
+    <ui-checkbox
+      :model-value="data.activeTab"
+      class="my-2"
+      @change="updateData({ activeTab: $event })"
+    >
+      {{ t('workflow.blocks.new-tab.activeTab') }}
+    </ui-checkbox>
   </div>
 </template>
 <script setup>

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

@@ -21,7 +21,6 @@
     </ui-select>
     <edit-autocomplete
       v-if="data.windowType === 'iframe'"
-      :items="autocomplete"
       :trigger-char="['{{', '}}']"
       block
       hide-empty

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

@@ -22,6 +22,7 @@
         :label="`${t('workflow.blocks.webhook.url')}*`"
         placeholder="http://api.example.com"
         class="w-full"
+        rows="1"
         autocomplete="off"
         required
         type="url"

+ 4 - 0
src/components/ui/UiAutocomplete.vue

@@ -360,6 +360,10 @@ onMounted(() => {
 onBeforeUnmount(() => {
   detachEvents();
 });
+
+defineExpose({
+  state,
+});
 </script>
 <style>
 .ui-autocomplete.block,

+ 14 - 1
src/components/ui/UiExpand.vue

@@ -1,5 +1,5 @@
 <template>
-  <div :aria-expanded="show" class="ui-expand">
+  <div :aria-expanded="show" :class="{ [activeClass]: show }" class="ui-expand">
     <button
       :class="[headerClass, { [headerActiveClass]: show }]"
       @click="toggleExpand"
@@ -45,10 +45,15 @@ const props = defineProps({
     type: String,
     default: '',
   },
+  activeClass: {
+    type: String,
+    default: '',
+  },
   hideHeaderIcon: {
     type: Boolean,
     default: false,
   },
+  disabled: Boolean,
   appendIcon: Boolean,
 });
 const emit = defineEmits(['update:modelValue']);
@@ -56,6 +61,8 @@ const emit = defineEmits(['update:modelValue']);
 const show = ref(false);
 
 function toggleExpand() {
+  if (props.disabled) return;
+
   show.value = !show.value;
 
   emit('update:modelValue', show.value);
@@ -70,4 +77,10 @@ watch(
   },
   { immediate: true }
 );
+watch(
+  () => props.disabled,
+  () => {
+    show.value = false;
+  }
+);
 </script>

+ 170 - 0
src/components/ui/UiTable.vue

@@ -0,0 +1,170 @@
+<template>
+  <table class="custom-table">
+    <thead>
+      <tr>
+        <th
+          v-for="header in table.headers"
+          :key="header.value"
+          :align="header.align"
+          class="relative"
+          v-bind="header.attrs"
+        >
+          <span
+            :class="{ 'cursor-pointer': header.sortable }"
+            class="inline-block"
+            @click="updateSort(header)"
+          >
+            {{ header.text }}
+          </span>
+          <span
+            v-if="header.sortable"
+            class="cursor-pointer ml-1 sort-icon"
+            @click="updateSort(header)"
+          >
+            <v-remixicon
+              v-if="sortState.id === header.value"
+              :rotate="sortState.order === 'asc' ? 90 : -90"
+              class="transition-transform"
+              size="20"
+              name="riArrowLeftLine"
+            />
+            <v-remixicon v-else name="riArrowUpDownLine" size="20" />
+          </span>
+        </th>
+      </tr>
+    </thead>
+    <tbody>
+      <tr v-for="item in sortedItems" :key="item[itemKey]">
+        <slot name="item-prepend" :item="item" />
+        <td
+          v-for="header in headers"
+          v-bind="header.rowAttrs"
+          :key="header.value"
+          :align="header.align"
+        >
+          <slot :name="`item-${header.value}`" :item="item">
+            {{ item[header.value] }}
+          </slot>
+        </td>
+        <slot name="item-append" :item="item" />
+      </tr>
+    </tbody>
+  </table>
+</template>
+<script setup>
+import { reactive, computed, watch } from 'vue';
+import { isObject } from '@/utils/helper';
+
+const props = defineProps({
+  headers: {
+    type: Array,
+    default: () => [],
+  },
+  items: {
+    type: Array,
+    default: () => [],
+  },
+  itemKey: {
+    type: String,
+    default: '',
+    required: true,
+  },
+  search: {
+    type: String,
+    default: '',
+  },
+  customFilter: {
+    type: Function,
+    default: null,
+  },
+});
+
+const table = reactive({
+  headers: [],
+  filterKeys: [],
+});
+const sortState = reactive({
+  id: '',
+  order: 'asc',
+});
+
+const filteredItems = computed(() => {
+  if (!props.search) return props.items;
+
+  const filterFunc =
+    props.customFilter ||
+    ((search, item) => {
+      return table.filterKeys.some((key) =>
+        item[key].toLocaleLowerCase().includes(search)
+      );
+    });
+
+  const search = props.search.toLocaleLowerCase();
+  return props.items.filter((item, index) => filterFunc(search, item, index));
+});
+const sortedItems = computed(() => {
+  if (sortState.id === '') return filteredItems.value;
+
+  return filteredItems.value.slice().sort((a, b) => {
+    let comparison = 0;
+    const itemA = a[sortState.id];
+    const itemB = b[sortState.id];
+
+    if (itemA > itemB) {
+      comparison = 1;
+    } else if (itemA < itemB) {
+      comparison = -1;
+    }
+
+    return sortState.order === 'desc' ? comparison * -1 : comparison;
+  });
+});
+
+function updateSort({ sortable, value }) {
+  if (!sortable) return;
+
+  if (sortState.id !== value) {
+    sortState.id = value;
+    sortState.order = 'asc';
+    return;
+  }
+
+  if (sortState.order === 'asc') {
+    sortState.order = 'desc';
+  } else {
+    sortState.id = '';
+  }
+}
+
+watch(
+  () => props.headers,
+  (newHeaders) => {
+    const filterKeys = new Set();
+
+    table.headers = newHeaders.map((header) => {
+      const headerObj = {
+        attrs: {},
+        rowAttrs: {},
+        align: 'left',
+        text: header,
+        value: header,
+        sortable: true,
+        filterable: false,
+      };
+
+      if (isObject(header)) Object.assign(headerObj, header);
+      if (headerObj.filterable) filterKeys.add(headerObj.value);
+
+      return headerObj;
+    });
+
+    table.filterKeys = Array.from(filterKeys);
+  },
+  { immediate: true }
+);
+</script>
+<style>
+.sort-icon svg {
+  @apply text-gray-600 dark:text-gray-300 inline-block;
+}
+</style>

+ 3 - 7
src/components/ui/UiTextarea.vue

@@ -1,11 +1,11 @@
 <template>
   <textarea
-    v-bind="{ value: modelValue, placeholder, maxlength: max }"
+    v-bind="{ placeholder, maxlength: max }"
     :id="textareaId"
     ref="textarea"
+    :value="modelValue"
     class="ui-textarea w-full ui-input rounded-lg px-4 py-2 transition bg-input"
     :class="{ 'overflow-hidden resize-none': autoresize }"
-    :style="{ height }"
     @input="emitValue"
     @keyup="$emit('keyup', $event)"
     @keydown="$emit('keydown', $event)"
@@ -35,10 +35,6 @@ export default {
       type: Boolean,
       default: false,
     },
-    height: {
-      type: [Number, String],
-      default: '',
-    },
     max: {
       type: [Number, String],
       default: null,
@@ -66,7 +62,7 @@ export default {
 
       emit('update:modelValue', value);
       emit('change', value);
-      calcHeight();
+      // calcHeight();
     }
 
     onMounted(calcHeight);

+ 6 - 0
src/composable/liveQuery.js

@@ -0,0 +1,6 @@
+import { liveQuery } from 'dexie';
+import { useObservable } from '@vueuse/rxjs';
+
+export function useLiveQuery(querier) {
+  return useObservable(liveQuery(querier));
+}

+ 13 - 5
src/composable/shortcut.js

@@ -10,19 +10,27 @@ const defaultShortcut = {
   },
   'page:workflows': {
     id: 'page:workflows',
-    combo: 'option+2',
+    combo: 'option+w',
+  },
+  'page:schedule': {
+    id: 'page:schedule',
+    combo: 'option+t',
   },
   'page:collections': {
     id: 'page:collections',
-    combo: 'option+3',
+    combo: 'option+c',
   },
   'page:logs': {
     id: 'page:logs',
-    combo: 'option+4',
+    combo: 'option+l',
+  },
+  'page:storage': {
+    id: 'page:storage',
+    combo: 'option+a',
   },
   'page:settings': {
     id: 'page:settings',
-    combo: 'option+5',
+    combo: 'option+s',
   },
   'action:search': {
     id: 'action:search',
@@ -69,7 +77,7 @@ export function getReadableShortcut(str) {
       mac: '⌘',
     },
   };
-  const regex = new RegExp('option|mod', 'g');
+  const regex = /option|mod/g;
   const replacedStr = str.replace(regex, (match) => {
     return list[match][os];
   });

+ 12 - 4
src/content/handleTestCondition.js → src/content/blocksHandler/handlerConditions.js

@@ -1,14 +1,22 @@
 import { customAlphabet } from 'nanoid/non-secure';
 import { visibleInViewport, isXPath } from '@/utils/helper';
-import FindElement from '@/utils/FindElement';
-import { automaRefDataStr } from './utils';
+import handleSelector from '../handleSelector';
+import { automaRefDataStr } from '../utils';
 
 const nanoid = customAlphabet('1234567890abcdef', 5);
 
-function handleConditionElement({ data, type }) {
+async function handleConditionElement({ data, type, id, frameSelector }) {
   const selectorType = isXPath(data.selector) ? 'xpath' : 'cssSelector';
 
-  const element = FindElement[selectorType](data);
+  const element = await handleSelector({
+    id,
+    data: {
+      ...data,
+      findBy: selectorType,
+    },
+    frameSelector,
+    type: selectorType,
+  });
   const { 1: actionType } = type.split('#');
 
   const elementActions = {

+ 13 - 2
src/content/blocksHandler/handlerEventClick.js

@@ -4,6 +4,14 @@ import handleSelector from '../handleSelector';
 
 function eventClick(block) {
   return new Promise((resolve, reject) => {
+    const dispatchClickEvents = (element, eventFn) => {
+      const eventOpts = { bubbles: true };
+
+      element.dispatchEvent(new MouseEvent('mousedown', eventOpts));
+      element.dispatchEvent(new MouseEvent('mouseup', eventOpts));
+      eventFn();
+    };
+
     handleSelector(block, {
       async onSelected(element) {
         if (block.debugMode) {
@@ -34,9 +42,12 @@ function eventClick(block) {
         }
 
         if (element.click) {
-          element.click();
+          dispatchClickEvents(element, () => element.click());
         } else {
-          element.dispatchEvent(new PointerEvent('click', { bubbles: true }));
+          dispatchClickEvents(
+            () => element,
+            element.dispatchEvent(new PointerEvent('click', { bubbles: true }))
+          );
         }
       },
       onError(error) {

+ 9 - 6
src/content/blocksHandler/handlerJavascriptCode.js

@@ -14,10 +14,10 @@ function automaSetVariable(name, value) {
   ${varName}.variables[name] = value;
 }
 function automaNextBlock(data, insert = true) {
-  window.dispatchEvent(new CustomEvent('__automa-next-block__', { detail: { data, insert, refData: ${varName} } }));
+  document.body.dispatchEvent(new CustomEvent('__automa-next-block__', { detail: { data, insert, refData: ${varName} } }));
 }
 function automaResetTimeout() {
- window.dispatchEvent(new CustomEvent('__automa-reset-timeout__'));
+ document.body.dispatchEvent(new CustomEvent('__automa-reset-timeout__'));
 }
   `;
 
@@ -127,10 +127,13 @@ function javascriptCode(block) {
           });
         };
 
-        window.addEventListener('__automa-next-block__', ({ detail }) => {
-          cleanUp(detail || {});
-        });
-        window.addEventListener('__automa-reset-timeout__', () => {
+        documentCtx.body.addEventListener(
+          '__automa-next-block__',
+          ({ detail }) => {
+            cleanUp(detail || {});
+          }
+        );
+        documentCtx.body.addEventListener('__automa-reset-timeout__', () => {
           clearTimeout(timeout);
 
           timeout = setTimeout(cleanUp, block.data.timeout);

+ 36 - 10
src/content/blocksHandler/handlerLoopData.js

@@ -1,19 +1,11 @@
 import { nanoid } from 'nanoid';
 import handleSelector from '../handleSelector';
 
-export default async function loopElements(block) {
-  const elements = await handleSelector(block);
+function generateLoopSelectors(elements, { max, attrId, frameSelector }) {
   const selectors = [];
-  const attrId = nanoid(5);
-
-  let frameSelector = '';
-
-  if (block.data.$frameSelector) {
-    frameSelector = `${block.data.$frameSelector} |> `;
-  }
 
   elements.forEach((el, index) => {
-    if (block.data.max > 0 && selectors.length - 1 > block.data.max) return;
+    if (max > 0 && selectors.length - 1 > max) return;
 
     const attrName = 'automa-loop';
     const attrValue = `${attrId}--${index}`;
@@ -24,3 +16,37 @@ export default async function loopElements(block) {
 
   return selectors;
 }
+
+export default async function loopElements(block) {
+  const elements = await handleSelector(block);
+  if (!elements) throw new Error('element-not-found');
+
+  let frameSelector = '';
+  if (block.data.$frameSelector) {
+    frameSelector = `${block.data.$frameSelector} |> `;
+  }
+
+  if (block.onlyGenerate) {
+    generateLoopSelectors(elements, {
+      ...block.data,
+      frameSelector,
+      attrId: block.data.loopId,
+    });
+
+    return {};
+  }
+
+  const attrId = nanoid(5);
+  const selectors = generateLoopSelectors(elements, {
+    ...block.data,
+    frameSelector,
+    attrId,
+  });
+  const { origin, pathname } = window.location;
+
+  return {
+    loopId: attrId,
+    elements: selectors,
+    url: origin + pathname,
+  };
+}

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

@@ -170,7 +170,7 @@ export default async function ({ tabId, options, data: { type, selector } }) {
     scrollPosition = newScrollPos;
     scrollableElement.scrollTo(0, newScrollPos);
 
-    await new Promise((resolve) => setTimeout(resolve, 1000));
+    await sleep(1000);
   }
 
   style.remove();

+ 30 - 7
src/content/index.js

@@ -3,7 +3,6 @@ import findSelector from '@/lib/findSelector';
 import { toCamelCase } from '@/utils/helper';
 import blocksHandler from './blocksHandler';
 import showExecutedBlock from './showExecutedBlock';
-import handleTestCondition from './handleTestCondition';
 import shortcutListener from './services/shortcutListener';
 // import elementObserver from './elementObserver';
 import { elementSelectorInstance } from './utils';
@@ -150,14 +149,38 @@ function messageListener({ data, source }) {
   browser.runtime.onMessage.addListener((data) => {
     return new Promise((resolve, reject) => {
       if (data.isBlock) {
-        executeBlock(data).then(resolve).catch(reject);
+        executeBlock(data)
+          .then(resolve)
+          .catch((error) => {
+            const elNotFound = error.message === 'element-not-found';
+            const selectLoopItem = data.data?.selector?.includes('automa-loop');
+            if (elNotFound && selectLoopItem) {
+              const findLoopEl = data.loopEls.find(({ url }) =>
+                window.location.href.includes(url)
+              );
+
+              const blockData = { ...data.data, ...findLoopEl, multiple: true };
+              const loopBlock = {
+                ...data,
+                onlyGenerate: true,
+                data: blockData,
+              };
+
+              blocksHandler
+                .loopData(loopBlock)
+                .then(() => {
+                  executeBlock(data).then(resolve).catch(reject);
+                })
+                .catch((blockError) => {
+                  reject(blockError);
+                });
+              return;
+            }
+
+            reject(error);
+          });
       } else {
         switch (data.type) {
-          case 'condition-builder':
-            handleTestCondition(data.data)
-              .then((result) => resolve(result))
-              .catch((error) => reject(error));
-            break;
           case 'content-script-exists':
             resolve(true);
             break;

+ 22 - 0
src/db/logs.js

@@ -0,0 +1,22 @@
+import Dexie from 'dexie';
+
+const dbLogs = new Dexie('logs');
+dbLogs.version(1).stores({
+  ctxData: '++id, logId',
+  logsData: '++id, logId',
+  histories: '++id, logId',
+  items: '++id, name, endedAt, workflowId, status, collectionId',
+});
+
+export const defaultLogItem = {
+  name: '',
+  endedAt: 0,
+  message: '',
+  startedAt: 0,
+  parentLog: null,
+  workflowId: null,
+  status: 'success',
+  collectionId: null,
+};
+
+export default dbLogs;

+ 6 - 0
src/lib/vRemixicon.js

@@ -19,6 +19,7 @@ import {
   riMoreLine,
   riStopLine,
   riSortDesc,
+  riTimeLine,
   riFlagLine,
   riGroupLine,
   riGuideLine,
@@ -52,6 +53,7 @@ import {
   riTwitterLine,
   riDiscordLine,
   riLinkUnlinkM,
+  riYoutubeLine,
   riSideBarLine,
   riSideBarFill,
   riWindow2Line,
@@ -86,6 +88,7 @@ import {
   riStrikethrough2,
   riFileUploadLine,
   riCodeSSlashLine,
+  riHardDrive2Line,
   riDeleteBin7Line,
   riArrowLeftSLine,
   riFullscreenLine,
@@ -131,6 +134,7 @@ export const icons = {
   riMoreLine,
   riStopLine,
   riSortDesc,
+  riTimeLine,
   riFlagLine,
   riGroupLine,
   riGuideLine,
@@ -164,6 +168,7 @@ export const icons = {
   riTwitterLine,
   riDiscordLine,
   riLinkUnlinkM,
+  riYoutubeLine,
   riSideBarLine,
   riSideBarFill,
   riWindow2Line,
@@ -198,6 +203,7 @@ export const icons = {
   riStrikethrough2,
   riFileUploadLine,
   riCodeSSlashLine,
+  riHardDrive2Line,
   riDeleteBin7Line,
   riArrowLeftSLine,
   riFullscreenLine,

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

@@ -5,6 +5,7 @@
     "collection": "Collection | Collections",
     "log": "Log | Logs",
     "block": "Block | Blocks",
+    "schedule": "Schedule",
     "folder": "Folder | Folders",
     "new": "New",
     "docs": "Documentation",
@@ -25,6 +26,7 @@
     "save": "Save",
     "data": "data",
     "stop": "Stop",
+    "storage": "Storage",
     "editor": "Editor",
     "running": "Running",
     "globalData": "Global data",

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

@@ -8,6 +8,20 @@
     "text": "Get started by reading the documentation or browsing workflows in the Automa Marketplace.",
     "marketplace": "Marketplace"
   },
+  "scheduledWorkflow": {
+    "title": "Scheduled workflows",
+    "nextRun": "Next run",
+    "active": "Active",
+    "refresh": "Refresh",
+    "schedule":{
+      "title": "Schedule",
+      "types": {
+        "everyDay": "Every day",
+        "general": "Every {time}",
+        "interval": "Every {time} minutes"
+      }
+    }
+  },
   "updateMessage": {
     "text1": "Automa has been updated to v{version},",
     "text2": "see what's new."
@@ -27,6 +41,13 @@
     "clickHere": "Click here",
     "text": "You need to be signed in before you can do that"
   },
+  "running": {
+    "start": "Started on {date}",
+    "message": "This only display the last 5 logs"
+  },
+  "storage": {
+    "title": "Storage"
+  },
   "settings": {
     "theme": "Theme",
     "shortcuts": {
@@ -182,7 +203,7 @@
       "executeBy": "Executed by: \"{name}\""
     },
     "table": {
-      "title": "Table",
+      "title": "Table | Tables",
       "placeholder": "Search or add column",
       "select": "Select column",
       "column": {

+ 2 - 1
src/manifest.firefox.json

@@ -69,5 +69,6 @@
     "/Inter-roman-latin.var.woff2",
     "/locales/*",
     "elementSelector.bundle.js"
-  ]
+  ],
+  "content_security_policy": "script-src 'self' https:; object-src 'self'"
 }

+ 46 - 31
src/newtab/App.vue

@@ -1,4 +1,8 @@
 <template>
+  <!-- <Head>
+    <link rel="icon" :href="icon" />
+  </Head> -->
+
   <template v-if="retrieved">
     <app-sidebar />
     <main class="pl-16">
@@ -80,25 +84,38 @@
   </ui-card>
 </template>
 <script setup>
+import iconFirefox from '@/assets/svg/logoFirefox.svg';
+import iconChrome from '@/assets/svg/logo.svg';
 import { ref, shallowReactive, computed } from 'vue';
 import { useStore } from 'vuex';
 import { useI18n } from 'vue-i18n';
-import { useRoute } from 'vue-router';
 import { compare } from 'compare-versions';
 import browser from 'webextension-polyfill';
+import dbLogs from '@/db/logs';
 import { useTheme } from '@/composable/theme';
 import { loadLocaleMessages, setI18nLanguage } from '@/lib/vueI18n';
 import { parseJSON } from '@/utils/helper';
 import { fetchApi, getSharedWorkflows, getUserWorkflows } from '@/utils/api';
 import dayjs from '@/lib/dayjs';
-import Log from '@/models/log';
 import Workflow from '@/models/workflow';
 import AppSidebar from '@/components/newtab/app/AppSidebar.vue';
+import dataMigration from '@/utils/dataMigration';
+
+let icon;
+if (window.location.protocol === 'moz-extension:') {
+  icon = iconFirefox;
+} else {
+  icon = iconChrome;
+}
+
+const iconElement = document.createElement('link');
+iconElement.rel = 'icon';
+iconElement.href = icon;
+document.head.appendChild(iconElement);
 
 const { t } = useI18n();
 const store = useStore();
 const theme = useTheme();
-const route = useRoute();
 
 theme.init();
 
@@ -238,22 +255,31 @@ async function fetchUserData() {
 /* eslint-disable-next-line */
 function autoDeleteLogs() {
   const deleteAfter = store.state.settings.deleteLogAfter;
-
   if (deleteAfter === 'never') return;
 
-  Log.delete(({ endedAt }) => {
-    const diff = dayjs().diff(dayjs(endedAt), 'day');
+  const lastCheck =
+    +localStorage.getItem('checkDeleteLogs') || Date.now() - 8.64e7;
+  const dayDiff = dayjs().diff(dayjs(lastCheck), 'day');
 
-    return diff >= deleteAfter;
-  });
-}
-function handleStorageChanged(change) {
-  if (change.logs) {
-    store.dispatch('entities/create', {
-      entity: 'logs',
-      data: change.logs.newValue,
+  if (dayDiff < 1) return;
+
+  const aDayInMs = 8.64e7;
+  const maxLogAge = Date.now() - aDayInMs * deleteAfter;
+
+  dbLogs.items
+    .where('endedAt')
+    .below(maxLogAge)
+    .toArray()
+    .then((values) => {
+      const ids = values.map(({ id }) => id);
+
+      dbLogs.items.bulkDelete(ids);
+      dbLogs.ctxData.where('logId').anyOf(ids).delete();
+      dbLogs.logsData.where('logId').anyOf(ids).delete();
+      dbLogs.histories.where('logId').anyOf(ids).delete();
+
+      localStorage.setItem('checkDeleteLogs', Date.now());
     });
-  }
 }
 function closeModal() {
   let value = true;
@@ -282,15 +308,8 @@ function checkModal() {
   }
 }
 
-browser.storage.onChanged.addListener(handleStorageChanged);
-
-window.addEventListener('beforeunload', () => {
-  browser.storage.onChanged.removeListener(handleStorageChanged);
-});
-
-const includeRoutes = ['home', 'workflows-details'];
 window.addEventListener('storage', ({ key, newValue }) => {
-  if (key !== 'workflowState' || !includeRoutes.includes(route.name)) return;
+  if (key !== 'workflowState') return;
 
   const states = parseJSON(newValue, {});
   store.commit('updateState', {
@@ -316,24 +335,20 @@ window.addEventListener('storage', ({ key, newValue }) => {
     }
 
     await Promise.allSettled([
-      store.dispatch('retrieve', [
-        'workflows',
-        'logs',
-        'collections',
-        'folders',
-      ]),
+      store.dispatch('retrieve', ['workflows', 'collections', 'folders']),
       store.dispatch('retrieveWorkflowState'),
     ]);
 
     await loadLocaleMessages(store.state.settings.locale, 'newtab');
     await setI18nLanguage(store.state.settings.locale);
 
+    await dataMigration();
+
     retrieved.value = true;
 
     await fetchUserData();
     await syncHostWorkflow();
-
-    // autoDeleteLogs();
+    autoDeleteLogs();
   } catch (error) {
     retrieved.value = true;
     console.error(error);

+ 12 - 4
src/newtab/pages/Collections.vue

@@ -1,9 +1,17 @@
 <template>
   <div class="container pt-8 pb-4">
-    <h1 class="text-2xl font-semibold">{{ t('common.collection', 2) }}</h1>
-    <p class="text-gray-600 dark:text-gray-200">
-      {{ t('collection.description') }}
-    </p>
+    <div class="flex items-center">
+      <div class="flex-grow">
+        <h1 class="text-2xl font-semibold">{{ t('common.collection', 2) }}</h1>
+        <p class="text-gray-600 dark:text-gray-200">
+          {{ t('collection.description') }}
+        </p>
+      </div>
+      <div class="flex items-center px-4 py-2 bg-red-400 text-white rounded-lg">
+        <v-remixicon name="riErrorWarningLine" class="-ml-1" />
+        <p class="ml-2">This feature will be removed in the future update</p>
+      </div>
+    </div>
     <div class="flex items-center my-6 space-x-4">
       <ui-input
         id="search-input"

+ 39 - 67
src/newtab/pages/Home.vue

@@ -1,59 +1,40 @@
 <template>
   <div class="container pt-8 pb-4">
     <h1 class="text-2xl font-semibold mb-8">{{ t('common.dashboard') }}</h1>
-    <div class="flex items-start">
-      <div class="w-8/12 mr-8">
-        <div class="grid gap-4 mb-8 2xl:grid-cols-4 grid-cols-3">
-          <p
-            v-if="workflows.length === 0"
-            class="text-center text-gray-600 dark:text-gray-200"
-          >
-            {{ t('message.noData') }}
-          </p>
-          <shared-card
-            v-for="workflow in workflows"
-            :key="workflow.id"
-            :data="workflow"
-            :show-details="false"
-            style="max-width: 250px"
-            @execute="executeWorkflow"
-            @click="$router.push(`/workflows/${$event.id}`)"
-          />
-        </div>
-        <div>
-          <div class="mb-2 flex items-center justify-between">
-            <p class="font-semibold inline-block">Logs</p>
-            <router-link
-              to="/logs"
-              class="text-gray-600 dark:text-gray-200 text-sm"
-            >
-              {{ t('home.viewAll') }}
-            </router-link>
-          </div>
-          <p
-            v-if="logs.length === 0"
-            class="text-center text-gray-600 dark:text-gray-200"
-          >
-            {{ t('message.noData') }}
-          </p>
-          <shared-logs-table :logs="logs" class="w-full" />
-        </div>
-      </div>
-      <div class="w-4/12 space-y-4">
-        <p
-          v-if="workflowState.length === 0"
-          class="text-center text-gray-600 dark:text-gray-200"
-        >
-          {{ t('message.noData') }}
-        </p>
-        <shared-workflow-state
-          v-for="item in workflowState"
-          v-bind="{ data: item }"
-          :key="item.id"
-          class="w-full"
-        />
-      </div>
+    <div class="grid gap-4 mb-8 2xl:grid-cols-5 grid-cols-4">
+      <p
+        v-if="workflows.length === 0"
+        class="text-center text-gray-600 dark:text-gray-200"
+      >
+        {{ t('message.noData') }}
+      </p>
+      <shared-card
+        v-for="workflow in workflows"
+        :key="workflow.id"
+        :data="workflow"
+        :show-details="false"
+        style="max-width: 250px"
+        @execute="executeWorkflow"
+        @click="$router.push(`/workflows/${$event.id}`)"
+      />
     </div>
+    <div class="mb-2 flex items-center justify-between">
+      <p class="font-semibold inline-block">{{ t('common.log', 2) }}</p>
+      <router-link to="/logs" class="text-gray-600 dark:text-gray-200 text-sm">
+        {{ t('home.viewAll') }}
+      </router-link>
+    </div>
+    <p
+      v-if="logs?.length === 0"
+      class="text-center text-gray-600 dark:text-gray-200"
+    >
+      {{ t('message.noData') }}
+    </p>
+    <shared-logs-table
+      :logs="logs || []"
+      :running="workflowState"
+      class="w-full"
+    />
   </div>
 </template>
 <script setup>
@@ -61,31 +42,22 @@ import { computed } from 'vue';
 import { useStore } from 'vuex';
 import { useI18n } from 'vue-i18n';
 import { sendMessage } from '@/utils/message';
-import Log from '@/models/log';
+import { useLiveQuery } from '@/composable/liveQuery';
+import dbLogs from '@/db/logs';
 import Workflow from '@/models/workflow';
 import SharedCard from '@/components/newtab/shared/SharedCard.vue';
 import SharedLogsTable from '@/components/newtab/shared/SharedLogsTable.vue';
-import SharedWorkflowState from '@/components/newtab/shared/SharedWorkflowState.vue';
 
 const { t } = useI18n();
 const store = useStore();
 
+const logs = useLiveQuery(() =>
+  dbLogs.items.orderBy('endedAt').reverse().limit(10).toArray()
+);
 const workflows = computed(() =>
   Workflow.query().orderBy('createdAt', 'desc').limit(3).get()
 );
-const logs = computed(() =>
-  Log.query()
-    .where(
-      ({ isInCollection, isChildLog, parentLog }) =>
-        !isInCollection && !isChildLog && !parentLog
-    )
-    .orderBy('startedAt', 'desc')
-    .limit(10)
-    .get()
-);
-const workflowState = computed(() =>
-  store.state.workflowState.filter(({ isInCollection }) => !isInCollection)
-);
+const workflowState = computed(() => store.state.workflowState);
 
 function executeWorkflow(workflow) {
   sendMessage('workflow:execute', workflow, 'background');

+ 49 - 60
src/newtab/pages/Logs.vue

@@ -8,8 +8,12 @@
       @updateSorts="sortsBuilder[$event.key] = $event.value"
       @updateFilters="filtersBuilder[$event.key] = $event.value"
     />
-    <div style="min-height: 320px">
-      <shared-logs-table :logs="logs" class="w-full">
+    <div v-if="logs" style="min-height: 320px">
+      <shared-logs-table
+        :logs="logs"
+        :running="$store.state.workflowState"
+        class="w-full"
+      >
         <template #item-prepend="{ log }">
           <td class="w-8">
             <ui-checkbox
@@ -20,23 +24,12 @@
           </td>
         </template>
         <template #item-append="{ log }">
-          <td class="ml-4">
-            <div class="flex items-center justify-end space-x-4">
-              <v-remixicon
-                v-if="Object.keys(log.data).length !== 0"
-                name="riFileTextLine"
-                class="cursor-pointer"
-                @click="
-                  exportDataModal.show = true;
-                  exportDataModal.log = log;
-                "
-              />
-              <v-remixicon
-                name="riDeleteBin7Line"
-                class="text-red-500 dark:text-red-400 cursor-pointer"
-                @click="deleteLog(log.id)"
-              />
-            </div>
+          <td class="ml-4 text-right">
+            <v-remixicon
+              name="riDeleteBin7Line"
+              class="text-red-500 inline-block dark:text-red-400 cursor-pointer"
+              @click="deleteLog(log.id)"
+            />
           </td>
         </template>
       </shared-logs-table>
@@ -65,7 +58,7 @@
         {{
           t(
             `log.${
-              selectedLogs.length >= logs.length ? 'deselectAll' : 'selectAll'
+              selectedLogs.length >= logs?.length ? 'deselectAll' : 'selectAll'
             }`
           )
         }}
@@ -87,17 +80,17 @@
 </template>
 <script setup>
 import { shallowReactive, ref, computed, watch } from 'vue';
-import { useStore } from 'vuex';
 import { useI18n } from 'vue-i18n';
 import { useDialog } from '@/composable/dialog';
-import Log from '@/models/log';
+import dbLogs from '@/db/logs';
+import { useLiveQuery } from '@/composable/liveQuery';
 import LogsFilters from '@/components/newtab/logs/LogsFilters.vue';
 import LogsDataViewer from '@/components/newtab/logs/LogsDataViewer.vue';
 import SharedLogsTable from '@/components/newtab/shared/SharedLogsTable.vue';
 
 const { t } = useI18n();
-const store = useStore();
 const dialog = useDialog();
+const storedlogs = useLiveQuery(() => dbLogs.items.toArray());
 
 const savedSorts = JSON.parse(localStorage.getItem('logs-sorts') || '{}');
 
@@ -113,41 +106,45 @@ const filtersBuilder = shallowReactive({
 });
 const sortsBuilder = shallowReactive({
   order: savedSorts.order || 'desc',
-  by: savedSorts.by || 'startedAt',
+  by: savedSorts.by || 'endedAt',
 });
 const exportDataModal = shallowReactive({
   show: false,
   log: {},
 });
 
-const filteredLogs = computed(() =>
-  Log.query()
-    .where(
-      ({ name, status, startedAt, isInCollection, isChildLog, parentLog }) => {
-        if (isInCollection || isChildLog || parentLog) return false;
-
-        let statusFilter = true;
-        let dateFilter = true;
-        const searchFilter = name
-          .toLocaleLowerCase()
-          .includes(filtersBuilder.query.toLocaleLowerCase());
+const filteredLogs = computed(() => {
+  if (!storedlogs.value) return [];
 
-        if (filtersBuilder.byStatus !== 'all') {
-          statusFilter = status === filtersBuilder.byStatus;
-        }
+  return storedlogs.value
+    .filter(({ name, status, endedAt }) => {
+      let dateFilter = true;
+      let statusFilter = true;
+      const searchFilter = name
+        .toLocaleLowerCase()
+        .includes(filtersBuilder.query.toLocaleLowerCase());
 
-        if (filtersBuilder.byDate > 0) {
-          const date = Date.now() - filtersBuilder.byDate * 24 * 60 * 60 * 1000;
+      if (filtersBuilder.byStatus !== 'all') {
+        statusFilter = status === filtersBuilder.byStatus;
+      }
 
-          dateFilter = date <= startedAt;
-        }
+      if (filtersBuilder.byDate > 0) {
+        const date = Date.now() - filtersBuilder.byDate * 24 * 60 * 60 * 1000;
 
-        return searchFilter && statusFilter && dateFilter;
+        dateFilter = date <= endedAt;
       }
-    )
-    .orderBy(sortsBuilder.by, sortsBuilder.order)
-    .get()
-);
+
+      return searchFilter && statusFilter && dateFilter;
+    })
+    .sort((a, b) => {
+      const valueA = a[sortsBuilder.by];
+      const valueB = b[sortsBuilder.by];
+
+      if (sortsBuilder.order === 'asc') return valueA > valueB ? 1 : -1;
+
+      return valueB > valueA ? 1 : -1;
+    });
+});
 const logs = computed(() =>
   filteredLogs.value.slice(
     (pagination.currentPage - 1) * pagination.perPage,
@@ -156,9 +153,7 @@ const logs = computed(() =>
 );
 
 function deleteLog(id) {
-  Log.delete(id).then(() => {
-    store.dispatch('saveToStorage', 'logs');
-  });
+  dbLogs.items.where('id').equals(id).delete();
 }
 function toggleSelectedLog(selected, logId) {
   if (selected) {
@@ -176,11 +171,8 @@ function deleteSelectedLogs() {
     okVariant: 'danger',
     body: t('log.delete.description'),
     onConfirm: () => {
-      const promises = selectedLogs.value.map((logId) => Log.delete(logId));
-
-      Promise.allSettled(promises).then(() => {
+      dbLogs.items.bulkDelete(selectedLogs.value).then(() => {
         selectedLogs.value = [];
-        store.dispatch('saveToStorage', 'logs');
       });
     },
   });
@@ -191,20 +183,17 @@ function clearLogs() {
     okVariant: 'danger',
     body: t('log.clearLogs.description'),
     onConfirm: () => {
-      Log.deleteAll().then(() => {
-        selectedLogs.value = [];
-        store.dispatch('saveToStorage', 'logs');
-      });
+      dbLogs.delete();
     },
   });
 }
 function selectAllLogs() {
-  if (selectedLogs.value.length >= logs.value.length) {
+  if (selectedLogs.value.length >= logs.value?.length) {
     selectedLogs.value = [];
     return;
   }
 
-  const logIds = logs.value.map(({ id }) => id);
+  const logIds = logs?.value.map(({ id }) => id);
 
   selectedLogs.value = logIds;
 }

+ 222 - 0
src/newtab/pages/ScheduledWorkflow.vue

@@ -0,0 +1,222 @@
+<template>
+  <div class="container pt-8 pb-4">
+    <h1 class="text-2xl font-semibold mb-8 capitalize">
+      {{ t('scheduledWorkflow.title', 2) }}
+    </h1>
+    <ui-input
+      v-model="state.query"
+      prepend-icon="riSearch2Line"
+      :placeholder="t('common.search')"
+    />
+    <ui-table
+      :headers="tableHeaders"
+      :items="triggers"
+      item-key="id"
+      class="w-full mt-4"
+    >
+      <template #item-name="{ item }">
+        <router-link
+          :to="`/workflows/${item.workflowId}`"
+          class="block h-full w-full"
+          style="min-height: 20px"
+        >
+          {{ item.name }}
+        </router-link>
+      </template>
+      <template #item-schedule="{ item }">
+        <p v-tooltip="{ content: item.scheduleDetail, allowHTML: true }">
+          {{ item.schedule }}
+        </p>
+      </template>
+      <template #item-active="{ item }">
+        <v-remixicon
+          v-if="item.active"
+          class="text-green-500 dark:text-green-400 inline-block"
+          name="riCheckLine"
+        />
+        <span v-else></span>
+      </template>
+      <template #item-action="{ item }">
+        <button
+          v-tooltip="t('scheduledWorkflow.refresh')"
+          class="rounded-md text-gray-600 dark:text-gray-300"
+          @click="refreshSchedule(item.id)"
+        >
+          <v-remixicon name="riRefreshLine" />
+        </button>
+      </template>
+    </ui-table>
+  </div>
+</template>
+<script setup>
+import { onMounted, reactive, computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+import dayjs from 'dayjs';
+import browser from 'webextension-polyfill';
+import { findTriggerBlock } from '@/utils/helper';
+import { registerWorkflowTrigger } from '@/utils/workflowTrigger';
+import Workflow from '@/models/workflow';
+
+const { t } = useI18n();
+
+const triggersData = {};
+const state = reactive({
+  query: '',
+  triggers: [],
+  activeTrigger: 'scheduled',
+});
+
+let rowId = 0;
+const scheduledTypes = ['interval', 'date', 'specific-day'];
+const tableHeaders = [
+  {
+    value: 'name',
+    filterable: true,
+    text: t('common.name'),
+    attrs: {
+      class: 'w-3/12',
+    },
+  },
+  {
+    value: 'schedule',
+    text: t('scheduledWorkflow.schedule.title'),
+    attrs: {
+      class: 'w-4/12',
+    },
+  },
+  {
+    value: 'nextRun',
+    text: t('scheduledWorkflow.nextRun'),
+  },
+  {
+    value: 'active',
+    align: 'center',
+    text: t('scheduledWorkflow.active'),
+  },
+  {
+    value: 'action',
+    text: '',
+    sortable: false,
+    align: 'right',
+  },
+];
+
+const triggers = computed(() =>
+  state.triggers.filter(({ name }) =>
+    name.toLocaleLowerCase().includes(state.query.toLocaleLowerCase())
+  )
+);
+
+function scheduleText(data) {
+  const text = {
+    schedule: '',
+    scheduleDetail: '',
+  };
+
+  switch (data.type) {
+    case 'specific-day': {
+      const days = data.days.map((item) => {
+        const day = t(`workflow.blocks.trigger.days.${item.id}`);
+        text.scheduleDetail += `${day}: ${item.times.join(', ')}<br>`;
+
+        return day;
+      });
+
+      text.schedule =
+        data.days.length >= 6
+          ? t('scheduledWorkflow.schedule.types.everyDay')
+          : t('scheduledWorkflow.schedule.types.general', {
+              time: days.join(', '),
+            });
+      break;
+    }
+    case 'interval':
+      text.schedule = t('scheduledWorkflow.schedule.types.interval', {
+        time: data.interval,
+      });
+      break;
+    case 'data':
+      dayjs(data.date).format('DD MMM YYYY, hh:mm:ss A');
+      break;
+    default:
+  }
+
+  return text;
+}
+async function getTriggerObj(trigger, { id, name }) {
+  if (!trigger || !scheduledTypes.includes(trigger.type)) return null;
+
+  rowId += 1;
+  const triggerObj = {
+    name,
+    id: rowId,
+    nextRun: '-',
+    schedule: '',
+    active: false,
+    type: trigger.type,
+    workflowId: id,
+  };
+
+  try {
+    const alarm = await browser.alarms.get(id);
+    if (alarm) {
+      triggerObj.active = true;
+      triggerObj.nextRun = dayjs(alarm.scheduledTime).format(
+        'DD MMM YYYY, hh:mm:ss A'
+      );
+    }
+
+    triggersData[rowId] = {
+      ...trigger,
+      workflow: { id, name },
+    };
+    Object.assign(triggerObj, scheduleText(trigger));
+
+    return triggerObj;
+  } catch (error) {
+    console.error(error);
+    return null;
+  }
+}
+async function refreshSchedule(id) {
+  try {
+    const triggerData = triggersData[id];
+    if (!triggerData) return;
+
+    await registerWorkflowTrigger(triggerData.workflow.id, {
+      data: triggerData,
+    });
+
+    const triggerObj = await getTriggerObj(triggerData, triggerData.workflow);
+    if (!triggerObj) return;
+
+    const triggerIndex = state.triggers.findIndex(
+      (trigger) => trigger.id === id
+    );
+    if (triggerIndex === -1) return;
+
+    state.triggers[triggerIndex] = triggerObj;
+  } catch (error) {
+    console.error(error);
+  }
+}
+
+onMounted(async () => {
+  const workflows = Workflow.all();
+
+  for (const workflow of workflows) {
+    let { trigger } = workflow;
+
+    if (!trigger) {
+      const drawflow =
+        typeof workflow.drawflow === 'string'
+          ? JSON.parse(workflow.drawflow)
+          : workflow.drawflow;
+      trigger = findTriggerBlock(drawflow)?.data;
+    }
+
+    const obj = await getTriggerObj(trigger, workflow);
+    if (obj) state.triggers.push(obj);
+  }
+});
+</script>

+ 49 - 28
src/newtab/pages/Welcome.vue

@@ -1,41 +1,45 @@
 <template>
   <div class="py-16 max-w-xl mx-auto">
-    <h1 class="font-semibold text-4xl mb-8">
+    <h1 class="font-semibold text-3xl mb-8">
       {{ t('welcome.title') }}
     </h1>
     <p class="text-lg">
-      {{ t('welcome.text') }}
+      Get started by reading the documentation or browsing workflows in the
+      Automa Marketplace. <br />
+      To learn how to use Automa, watch the tutorials on our YouTube Channel.
     </p>
     <div class="mt-8 space-x-2 flex items-center">
-      <ui-button
-        tag="a"
-        href="https://docs.automa.site"
-        target="_blank"
-        rel="noopener"
-      >
-        <v-remixicon name="riBook3Line" class="-ml-1 mr-2" />
-        {{ t('common.docs') }}
-      </ui-button>
-      <ui-button
-        tag="a"
-        href="https://automa.site/workflows"
-        target="_blank"
-        rel="noopener"
-      >
-        <v-remixicon name="riCompass3Line" class="-ml-1 mr-2" />
-        {{ t('welcome.marketplace') }}
-      </ui-button>
-      <ui-button
+      <a
         v-for="link in links"
         :key="link.name"
         :href="link.url"
-        icon
-        tag="a"
         target="_blank"
-        class="inline-block p-2 rounded-lg transition hoverable"
+        rel="noopener"
+        class="p-4 rounded-lg hoverable transition border-2 w-40"
       >
-        <v-remixicon :name="link.icon" />
-      </ui-button>
+        <v-remixicon size="28" :name="link.icon" />
+        <p class="mt-2">
+          {{ link.name }}
+        </p>
+      </a>
+    </div>
+    <div class="mt-8">
+      <p>{{ t('home.communities') }}</p>
+      <div class="space-x-2 flex items-center mt-2">
+        <a
+          v-for="link in communities"
+          :key="link.name"
+          :href="link.url"
+          target="_blank"
+          rel="noopener"
+          class="p-4 rounded-lg hoverable transition border-2 w-40"
+        >
+          <v-remixicon size="28" :name="link.icon" />
+          <p class="mt-2">
+            {{ link.name }}
+          </p>
+        </a>
+      </div>
     </div>
   </div>
 </template>
@@ -44,11 +48,11 @@ import { useI18n } from 'vue-i18n';
 
 const { t } = useI18n();
 
-const links = [
+const communities = [
   {
     name: 'GitHub',
     icon: 'riGithubFill',
-    url: 'https://github.com/kholid060/automa',
+    url: 'https://github.com/AutomaApp/automa',
   },
   {
     name: 'Twitter',
@@ -61,4 +65,21 @@ const links = [
     url: 'https://discord.gg/C6khwwTE84',
   },
 ];
+const links = [
+  {
+    name: t('common.docs'),
+    icon: 'riBook3Line',
+    url: 'https://docs.automa.site',
+  },
+  {
+    name: t('welcome.marketplace'),
+    icon: 'riCompass3Line',
+    url: 'https://www.automa.site/workflows',
+  },
+  {
+    name: 'YouTube',
+    icon: 'riYoutubeLine',
+    url: 'https://youtube.com/channel/UCL3qU64hW0fsIj2vOayOQUQ',
+  },
+];
 </script>

+ 11 - 14
src/newtab/pages/collections/[id].vue

@@ -208,7 +208,8 @@ import { useI18n } from 'vue-i18n';
 import Draggable from 'vuedraggable';
 import { useDialog } from '@/composable/dialog';
 import { sendMessage } from '@/utils/message';
-import Log from '@/models/log';
+import { useLiveQuery } from '@/composable/liveQuery';
+import dbLogs from '@/db/logs';
 import Workflow from '@/models/workflow';
 import Collection from '@/models/collection';
 import SharedLogsTable from '@/components/newtab/shared/SharedLogsTable.vue';
@@ -223,6 +224,14 @@ const store = useStore();
 const route = useRoute();
 const router = useRouter();
 const dialog = useDialog();
+const logs = useLiveQuery(() =>
+  dbLogs.items
+    .where('collectionId')
+    .equals(route.params.id)
+    .reverse()
+    .limit(15)
+    .sortBy('endedAt')
+);
 
 const blocks = {
   'export-result': {
@@ -254,16 +263,6 @@ const collectionOptions = shallowReactive({
 const runningCollection = computed(() =>
   store.state.workflowState.filter(({ id }) => id === route.params.id)
 );
-const logs = computed(() =>
-  Log.query()
-    .where(
-      ({ collectionId, isInCollection, isChildLog }) =>
-        collectionId === route.params.id && (!isInCollection || !isChildLog)
-    )
-    .orderBy('startedAt', 'desc')
-    .limit(10)
-    .get()
-);
 const workflows = computed(() =>
   Workflow.query()
     .where(({ name }) =>
@@ -284,9 +283,7 @@ const collectionFlow = computed(() => {
 });
 
 function deleteLog(logId) {
-  Log.delete(logId).then(() => {
-    store.dispatch('saveToStorage', 'logs');
-  });
+  dbLogs.items.where('id').equals(logId).delete();
 }
 function executeCollection() {
   sendMessage('collection:execute', collection.value, 'background');

+ 127 - 0
src/newtab/pages/logs/Running.vue

@@ -0,0 +1,127 @@
+<template>
+  <div v-if="running" class="container py-8">
+    <div class="flex items-center">
+      <div class="flex-grow overflow-hidden">
+        <h1 class="text-2xl max-w-md text-overflow font-semibold text-overflow">
+          {{ running.state.name }}
+        </h1>
+        <p>
+          {{
+            t('running.start', {
+              date: dayjs(running.state.startedTimestamp).format(
+                'DD MMM, hh:mm A'
+              ),
+            })
+          }}
+        </p>
+      </div>
+      <ui-button @click="stopWorkflow">
+        {{ t('common.stop') }}
+      </ui-button>
+    </div>
+    <div class="mt-8">
+      <logs-history
+        :current-log="{
+          history: running.state.logs,
+          workflowId: running.workflowId,
+        }"
+      >
+        <template #prepend>
+          <div class="mb-4 text-sm">
+            <h3 class="leading-tight">
+              {{ t('common.log', 2) }}
+            </h3>
+            <p class="leading-tight text-gray-600 dark:text-gray-300">
+              {{ t('running.message') }}
+            </p>
+          </div>
+        </template>
+        <template #append-items>
+          <div
+            v-for="block in running.state.currentBlock"
+            :key="block.id"
+            class="px-2 py-1 rounded-md w-full group hoverable flex items-center"
+          >
+            <span
+              :key="key"
+              :title="`Duration: ${Math.round(
+                (Date.now() - block.startedAt) / 1000
+              )}s`"
+              class="w-14 flex-shrink-0 text-overflow text-gray-400 ml-6"
+            >
+              {{ countDuration(block.startedAt, Date.now()) }}
+            </span>
+            <ui-spinner size="16" class="mr-2" color="text-accent" />
+            <p class="flex-1">
+              {{ t(`workflow.blocks.${block.name}.name`) }}
+            </p>
+            <router-link
+              :to="`/workflows/${running.workflowId}?block=${block.id}`"
+              title="Go to block"
+              class="invisible group-hover:visible"
+            >
+              <v-remixicon
+                name="riExternalLinkLine"
+                size="20"
+                title="Go to block"
+                class="text-gray-300 cursor-pointer ml-2 invisible group-hover:visible"
+              />
+            </router-link>
+          </div>
+        </template>
+      </logs-history>
+    </div>
+  </div>
+</template>
+<script setup>
+import { computed, watch, shallowRef, onBeforeUnmount } from 'vue';
+import { useStore } from 'vuex';
+import { useRoute, useRouter } from 'vue-router';
+import { useI18n } from 'vue-i18n';
+import { countDuration } from '@/utils/helper';
+import { sendMessage } from '@/utils/message';
+import dbLogs from '@/db/logs';
+import dayjs from '@/lib/dayjs';
+import LogsHistory from '@/components/newtab/logs/LogsHistory.vue';
+
+const { t } = useI18n();
+const store = useStore();
+const route = useRoute();
+const router = useRouter();
+
+const key = shallowRef(0);
+const interval = setInterval(() => {
+  key.value += 1;
+}, 1000);
+
+const running = computed(() =>
+  store.state.workflowState.find(({ id }) => id === route.params.id)
+);
+
+function stopWorkflow() {
+  sendMessage('workflow:stop', running.value.id, 'background');
+}
+
+watch(
+  running,
+  async () => {
+    if (!running.value && route.params.id) {
+      const log = await dbLogs.items
+        .where('id')
+        .equals(route.params.id)
+        .first();
+      let path = 'logs';
+
+      if (log) {
+        path = `/logs/${route.params.id}`;
+      }
+
+      router.replace(path);
+    }
+  },
+  { immediate: true }
+);
+onBeforeUnmount(() => {
+  clearInterval(interval);
+});
+</script>

+ 142 - 214
src/newtab/pages/logs/[id].vue

@@ -1,23 +1,38 @@
 <template>
-  <div v-if="activeLog" class="container pt-8 pb-4">
-    <div class="flex items-center mb-8">
+  <div v-if="currentLog.id" class="container pt-8 pb-4">
+    <div class="flex items-center">
+      <router-link
+        v-if="state.goBackBtn"
+        v-slot="{ navigate }"
+        :to="backHistory"
+        custom
+      >
+        <button
+          v-tooltip:bottom="t('workflow.blocks.go-back.name')"
+          role="button"
+          class="h-12 px-1 transition mr-2 bg-input rounded-lg dark:text-gray-300 text-gray-600"
+          @click="navigate"
+        >
+          <v-remixicon name="riArrowLeftSLine" />
+        </button>
+      </router-link>
       <div>
         <h1 class="text-2xl max-w-md text-overflow font-semibold">
-          {{ activeLog.name }}
+          {{ currentLog.name }}
         </h1>
         <p class="text-gray-600 dark:text-gray-200">
           {{
             t(`log.description.text`, {
-              status: t(`log.description.status.${activeLog.status}`),
-              date: dayjs(activeLog.startedAt).format('DD MMM'),
-              duration: countDuration(activeLog.startedAt, activeLog.endedAt),
+              status: t(`log.description.status.${currentLog.status}`),
+              date: dayjs(currentLog.startedAt).format('DD MMM'),
+              duration: countDuration(currentLog.startedAt, currentLog.endedAt),
             })
           }}
         </p>
       </div>
       <div class="flex-grow"></div>
       <ui-button
-        v-if="workflowExists"
+        v-if="state.workflowExists"
         v-tooltip="t('log.goWorkflow')"
         icon
         class="mr-4"
@@ -29,224 +44,92 @@
         {{ t('common.delete') }}
       </ui-button>
     </div>
-    <div class="flex items-start">
-      <div class="w-7/12 mr-6">
-        <ui-list>
-          <router-link
-            v-if="collectionLog"
-            :to="activeLog.parentLog?.id || activeLog.collectionLogId"
-            replace
-            class="mb-4 flex"
-          >
-            <v-remixicon name="riArrowLeftLine" class="mr-2" />
-            {{ t('log.goBack', { name: collectionLog.name }) }}
-          </router-link>
-          <ui-expand
-            v-for="(item, index) in history"
-            :key="item.id || index"
-            hide-header-icon
-            class="mb-1"
-            header-active-class="bg-box-transparent rounded-b-none"
-            header-class="flex items-center px-4 py-2 hoverable rounded-lg w-full text-left history-item focus:ring-0"
-          >
-            <template #header="{ show }">
-              <v-remixicon
-                :rotate="show ? 270 : 180"
-                size="20"
-                name="riArrowLeftSLine"
-                class="transition-transform dark:text-gray-200 text-gray-600 -ml-1 mr-2"
-              />
-              <span
-                :class="logsType[item.type]?.color"
-                class="p-1 rounded-lg align-middle inline-block mr-2 dark:text-black"
-              >
-                <v-remixicon :name="logsType[item.type]?.icon" size="20" />
-              </span>
-              <div class="flex-1 line-clamp pr-2">
-                <p class="w-full text-overflow leading-tight">
-                  {{ item.name }}
-                  <span
-                    v-show="item.description"
-                    :title="item.description"
-                    class="text-overflow text-gray-600 dark:text-gray-200 text-sm"
-                  >
-                    ({{ item.description }})
-                  </span>
-                </p>
-                <p
-                  v-if="item.message"
-                  :title="item.message"
-                  class="text-sm line-clamp text-gray-600 dark:text-gray-200"
-                >
-                  {{ item.message }}
-                </p>
-              </div>
-              <router-link
-                v-if="item.logId"
-                :to="'/logs/' + item.logId"
-                class="mr-4"
-                title="Open log detail"
-              >
-                <v-remixicon name="riExternalLinkLine" />
-              </router-link>
-              <code
-                v-show="item.workerId"
-                :title="t('log.flowId')"
-                class="text-xs mr-4 bg-box-transparent rounded-lg p-1 rounded-md"
-              >
-                {{ item.workerId }}
-              </code>
-              <p class="text-gray-600 dark:text-gray-200">
-                {{ countDuration(0, item.duration || 0) }}
-              </p>
-            </template>
-            <pre
-              class="text-sm px-4 max-h-52 overflow-auto scroll bg-box-transparent pb-2 rounded-b-lg"
-              >{{ ctxData[item.id] }}</pre
-            >
-          </ui-expand>
-        </ui-list>
-        <div
-          v-if="activeLog.history.length >= 10"
-          class="flex items-center justify-between mt-4"
-        >
-          <div>
-            {{ t('components.pagination.text1') }}
-            <select
-              v-model="pagination.perPage"
-              class="p-1 rounded-md bg-input"
-            >
-              <option
-                v-for="num in [10, 15, 25, 50, 100]"
-                :key="num"
-                :value="num"
-              >
-                {{ num }}
-              </option>
-            </select>
-            {{
-              t('components.pagination.text2', {
-                count: activeLog.history.length,
-              })
-            }}
-          </div>
-          <ui-pagination
-            v-model="pagination.currentPage"
-            :per-page="pagination.perPage"
-            :records="activeLog.history.length"
-          />
-        </div>
-      </div>
-      <div class="w-5/12 logs-details sticky top-10">
-        <logs-data-viewer :log="activeLog" />
-      </div>
-    </div>
+    <ui-tabs v-model="state.activeTab" class="mt-4" @change="onTabChange">
+      <ui-tab v-for="tab in tabs" :key="tab.id" class="mr-4" :value="tab.id">
+        {{ tab.name }}
+      </ui-tab>
+    </ui-tabs>
+    <ui-tab-panels
+      :model-value="state.activeTab"
+      class="mt-4 pb-4"
+      style="min-height: 500px"
+    >
+      <ui-tab-panel value="logs">
+        <logs-history
+          :current-log="currentLog"
+          :ctx-data="ctxData"
+          :parent-log="parentLog"
+        />
+      </ui-tab-panel>
+      <ui-tab-panel value="table">
+        <logs-table :current-log="currentLog" :table-data="tableData" />
+      </ui-tab-panel>
+      <ui-tab-panel value="variables">
+        <logs-variables :current-log="currentLog" />
+      </ui-tab-panel>
+    </ui-tab-panels>
   </div>
 </template>
 <script setup>
-import { computed, onMounted, shallowReactive, shallowRef } from 'vue';
+import { shallowReactive, shallowRef, watch } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 import { useI18n } from 'vue-i18n';
-import browser from 'webextension-polyfill';
-import Log from '@/models/log';
+import dbLogs from '@/db/logs';
 import Workflow from '@/models/workflow';
 import dayjs from '@/lib/dayjs';
-import { countDuration } from '@/utils/helper';
-import LogsDataViewer from '@/components/newtab/logs/LogsDataViewer.vue';
+import { countDuration, convertArrObjTo2DArr } from '@/utils/helper';
+import LogsTable from '@/components/newtab/logs/LogsTable.vue';
+import LogsHistory from '@/components/newtab/logs/LogsHistory.vue';
+import LogsVariables from '@/components/newtab/logs/LogsVariables.vue';
 
-const logsType = {
-  success: {
-    color: 'bg-green-200 dark:bg-green-300',
-    icon: 'riCheckLine',
-  },
-  stop: {
-    color: 'bg-yellow-200 dark:bg-yellow-300',
-    icon: 'riStopLine',
-  },
-  stopped: {
-    color: 'bg-yellow-200 dark:bg-yellow-300',
-    icon: 'riStopLine',
-  },
-  error: {
-    color: 'bg-red-200 dark:bg-red-300',
-    icon: 'riErrorWarningLine',
-  },
-  finish: {
-    color: 'bg-blue-200 dark:bg-blue-300',
-    icon: 'riFlagLine',
-  },
-};
-
-const { t, te } = useI18n();
+const { t } = useI18n();
 const route = useRoute();
 const router = useRouter();
 
 const ctxData = shallowRef({});
-const pagination = shallowReactive({
-  perPage: 10,
-  currentPage: 1,
+const parentLog = shallowRef(null);
+
+const backHistory = window.history.state.back;
+const tabs = [
+  { id: 'logs', name: t('common.log', 2) },
+  { id: 'table', name: t('workflow.table.title') },
+  { id: 'variables', name: t('workflow.variables.title', 2) },
+];
+
+const state = shallowReactive({
+  activeTab: 'logs',
+  workflowExists: false,
+  goBackBtn: ['/logs', '/workflows'].some((str) => backHistory?.includes(str)),
 });
-
-function translateLog(log) {
-  const copyLog = { ...log };
-  const getTranslatation = (path, def) => {
-    const params = typeof path === 'string' ? { path } : path;
-
-    return te(params.path) ? t(params.path, params.params) : def;
-  };
-
-  if (['finish', 'stop'].includes(log.type)) {
-    copyLog.name = t(`log.types.${log.type}`);
-  } else {
-    copyLog.name = getTranslatation(
-      `workflow.blocks.${log.name}.name`,
-      log.name
-    );
-  }
-
-  copyLog.message = getTranslatation(
-    { path: `log.messages.${log.message}`, params: log },
-    log.message
-  );
-
-  return copyLog;
-}
-
-const activeLog = computed(() => Log.find(route.params.id));
-const history = computed(() =>
-  activeLog.value.history
-    .slice(
-      (pagination.currentPage - 1) * pagination.perPage,
-      pagination.currentPage * pagination.perPage
-    )
-    .map(translateLog)
-);
-const collectionLog = computed(() => {
-  if (activeLog.value.parentLog) {
-    return Log.find(activeLog.value.parentLog.id);
-  }
-
-  return Log.find(activeLog.value.collectionLogId);
+const tableData = shallowReactive({
+  converted: false,
+  body: [],
+  header: [],
+});
+const currentLog = shallowRef({
+  history: [],
+  data: {
+    table: [],
+    variables: {},
+  },
 });
-const workflowExists = computed(() =>
-  Workflow.find(activeLog.value.workflowId)
-);
 
 function deleteLog() {
-  Log.delete(route.params.id).then(() => {
-    const backHistory = window.history.state.back;
-
-    if (backHistory.startsWith('/workflows')) {
-      router.replace(backHistory);
-      return;
-    }
-
-    router.replace('/logs');
-  });
+  dbLogs.items
+    .where('id')
+    .equals(route.params.id)
+    .delete()
+    .then(() => {
+      if (backHistory.startsWith('/workflows')) {
+        router.replace(backHistory);
+        return;
+      }
+
+      router.replace('/logs');
+    });
 }
 function goToWorkflow() {
-  const backHistory = window.history.state.back;
-  let path = `/workflows/${activeLog.value.workflowId}`;
+  let path = `/workflows/${currentLog.value.workflowId}`;
 
   if (backHistory.startsWith(path)) {
     path = backHistory;
@@ -254,16 +137,61 @@ function goToWorkflow() {
 
   router.push(path);
 }
+function convertToTableData() {
+  const data = currentLog.value.data?.table;
+  if (!data) return;
+
+  const [header] = convertArrObjTo2DArr(data);
+
+  tableData.converted = true;
+  tableData.body = data.map((item, index) => ({ ...item, id: index + 1 }));
+  tableData.header = header.map((name) => ({
+    text: name,
+    value: name,
+    filterable: true,
+  }));
+  tableData.header.unshift({ value: 'id', text: '', sortable: false });
+}
+function onTabChange(value) {
+  if (value === 'table' && !tableData.converted) {
+    convertToTableData();
+  }
+}
+async function fetchLog() {
+  const logId = route.params.id;
+  if (!logId) return;
 
-onMounted(async () => {
-  if (!activeLog.value) router.replace('/logs');
+  const logDetail = await dbLogs.items.where('id').equals(logId).last();
+  if (!logDetail) return;
 
-  const { logsCtxData } = await browser.storage.local.get('logsCtxData');
-  const logCtxData = logsCtxData && logsCtxData[route.params.id];
-  if (logCtxData) {
-    ctxData.value = logCtxData;
+  tableData.body = [];
+  tableData.header = [];
+  parentLog.value = null;
+  tableData.converted = false;
+
+  const [logCtxData, logHistory, logsData] = await Promise.all(
+    ['ctxData', 'histories', 'logsData'].map((key) =>
+      dbLogs[key].where('logId').equals(logId).last()
+    )
+  );
+
+  ctxData.value = logCtxData?.data || {};
+  currentLog.value = {
+    history: logHistory?.data || [],
+    data: logsData?.data || {},
+    ...logDetail,
+  };
+
+  state.workflowExists = Boolean(Workflow.find(logDetail.workflowId));
+
+  const parentLogId = logDetail.collectionLogId || logDetail.parentLog?.id;
+  if (parentLogId) {
+    parentLog.value =
+      (await dbLogs.items.where('id').equals(parentLogId).last()) || null;
   }
-});
+}
+
+watch(() => route.params, fetchLog, { immediate: true });
 </script>
 <style>
 .logs-details .cm-editor {

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

@@ -18,7 +18,7 @@
           class="p-0.5 rounded-lg"
         >
           <img
-            :src="require(`@/assets/images/${item.id}.png`).default"
+            :src="require(`@/assets/images/${item.id}.png`)"
             width="140"
             class="rounded-lg"
           />

+ 1 - 1
src/newtab/pages/settings/SettingsIndex.vue

@@ -14,7 +14,7 @@
           class="p-0.5 rounded-lg"
         >
           <img
-            :src="require(`@/assets/images/theme-${item.id}.png`).default"
+            :src="require(`@/assets/images/theme-${item.id}.png`)"
             width="140"
             class="rounded-lg"
           />

+ 6 - 15
src/newtab/pages/workflows/Host.vue

@@ -131,13 +131,14 @@ import { useStore } from 'vuex';
 import { useI18n } from 'vue-i18n';
 import { useRoute, useRouter } from 'vue-router';
 import browser from 'webextension-polyfill';
+import { useLiveQuery } from '@/composable/liveQuery';
 import { useDialog } from '@/composable/dialog';
 import { useShortcut } from '@/composable/shortcut';
 import { useGroupTooltip } from '@/composable/groupTooltip';
 import { parseJSON, findTriggerBlock } from '@/utils/helper';
 import { cleanWorkflowTriggers } from '@/utils/workflowTrigger';
 import { sendMessage } from '@/utils/message';
-import Log from '@/models/log';
+import dbLogs from '@/db/logs';
 import getTriggerText from '@/utils/triggerText';
 import WorkflowBuilder from '@/components/newtab/workflow/WorkflowBuilder.vue';
 import SharedLogsTable from '@/components/newtab/shared/SharedLogsTable.vue';
@@ -152,6 +153,9 @@ const router = useRouter();
 const dialog = useDialog();
 /* eslint-disable-next-line */
 const shortcut = useShortcut('editor:execute-workflow', executeWorkflow);
+const logs = useLiveQuery(() =>
+  dbLogs.items.query().where('workflowId').equals(route.params.id).toArray()
+);
 
 const workflowId = route.params.id;
 
@@ -166,17 +170,6 @@ const workflow = computed(() => store.state.workflowHosts[workflowId]);
 const workflowState = computed(() =>
   store.getters.getWorkflowState(workflowId)
 );
-const logs = computed(() =>
-  Log.query()
-    .where(
-      (item) =>
-        item.workflowId === workflowId &&
-        (!item.isInCollection || !item.isChildLog || !item.parentLog)
-    )
-    .limit(15)
-    .orderBy('startedAt', 'desc')
-    .get()
-);
 
 function syncWorkflow() {
   state.loadingSync = true;
@@ -230,9 +223,7 @@ function executeWorkflow() {
   sendMessage('workflow:execute', payload, 'background');
 }
 function deleteLog(logId) {
-  Log.delete(logId).then(() => {
-    store.dispatch('saveToStorage', 'logs');
-  });
+  dbLogs.items.where('id').equals(logId);
 }
 async function retrieveTriggerText() {
   const flow = parseJSON(workflow.value?.drawflow, null);

+ 47 - 43
src/newtab/pages/workflows/[id].vue

@@ -41,9 +41,8 @@
             />
           </button>
           <ui-tab value="editor">{{ t('common.editor') }}</ui-tab>
-          <ui-tab value="logs">{{ t('common.log', 2) }}</ui-tab>
-          <ui-tab value="running" class="flex items-center">
-            {{ t('common.running') }}
+          <ui-tab value="logs" class="flex items-center">
+            {{ t('common.log', 2) }}
             <span
               v-if="workflowState.length > 0"
               class="ml-2 p-1 text-center inline-block text-xs rounded-full bg-accent text-white dark:text-black"
@@ -91,6 +90,7 @@
           @save="saveWorkflow"
           @update="updateWorkflow"
           @load="editor = $event"
+          @loaded="onEditorLoaded"
           @deleteBlock="deleteBlock"
         >
           <ui-tabs
@@ -114,14 +114,22 @@
         </workflow-builder>
         <div v-else class="container pb-4 mt-24 px-4">
           <template v-if="activeTab === 'logs'">
-            <div v-if="logs.length === 0" class="text-center">
+            <div
+              v-if="(!logs || logs.length === 0) && workflowState.length === 0"
+              class="text-center"
+            >
               <img
                 src="@/assets/svg/files-and-folder.svg"
                 class="mx-auto max-w-sm"
               />
               <p class="text-xl font-semibold">{{ t('message.noData') }}</p>
             </div>
-            <shared-logs-table :logs="logs" class="w-full">
+            <shared-logs-table
+              :logs="logs"
+              :running="workflowState"
+              hide-select
+              class="w-full"
+            >
               <template #item-append="{ log: itemLog }">
                 <td class="text-right">
                   <v-remixicon
@@ -133,22 +141,6 @@
               </template>
             </shared-logs-table>
           </template>
-          <template v-else-if="activeTab === 'running'">
-            <div v-if="workflowState.length === 0" class="text-center">
-              <img
-                src="@/assets/svg/files-and-folder.svg"
-                class="mx-auto max-w-sm"
-              />
-              <p class="text-xl font-semibold">{{ t('message.noData') }}</p>
-            </div>
-            <div class="grid grid-cols-2 gap-4">
-              <shared-workflow-state
-                v-for="item in workflowState"
-                :key="item.id"
-                :data="item"
-              />
-            </div>
-          </template>
         </div>
       </keep-alive>
     </div>
@@ -222,7 +214,6 @@ import { useToast } from 'vue-toastification';
 import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router';
 import { useI18n } from 'vue-i18n';
 import defu from 'defu';
-import AES from 'crypto-js/aes';
 import browser from 'webextension-polyfill';
 import emitter from '@/lib/mitt';
 import { useDialog } from '@/composable/dialog';
@@ -238,8 +229,9 @@ import {
   parseJSON,
   throttle,
 } from '@/utils/helper';
-import Log from '@/models/log';
+import { useLiveQuery } from '@/composable/liveQuery';
 import decryptFlow, { getWorkflowPass } from '@/utils/decryptFlow';
+import dbLogs from '@/db/logs';
 import Workflow from '@/models/workflow';
 import workflowTrigger from '@/utils/workflowTrigger';
 import WorkflowShare from '@/components/newtab/workflow/WorkflowShare.vue';
@@ -252,7 +244,6 @@ import WorkflowGlobalData from '@/components/newtab/workflow/WorkflowGlobalData.
 import WorkflowDetailsCard from '@/components/newtab/workflow/WorkflowDetailsCard.vue';
 import WorkflowSharedActions from '@/components/newtab/workflow/WorkflowSharedActions.vue';
 import SharedLogsTable from '@/components/newtab/shared/SharedLogsTable.vue';
-import SharedWorkflowState from '@/components/newtab/shared/SharedWorkflowState.vue';
 
 const { t } = useI18n();
 const store = useStore();
@@ -261,6 +252,14 @@ const toast = useToast();
 const router = useRouter();
 const dialog = useDialog();
 const shortcut = useShortcut('editor:toggle-sidebar', toggleSidebar);
+const logs = useLiveQuery(() =>
+  dbLogs.items
+    .where('workflowId')
+    .equals(route.params.id)
+    .reverse()
+    .limit(15)
+    .sortBy('endedAt')
+);
 
 const activeTabQuery = route.query.tab || 'editor';
 
@@ -371,17 +370,6 @@ const workflowModal = computed(() => workflowModals[state.modalName] || {});
 const workflowState = computed(() =>
   store.getters.getWorkflowState(workflowId)
 );
-const logs = computed(() =>
-  Log.query()
-    .where(
-      (item) =>
-        item.workflowId === workflowId &&
-        (!item.isInCollection || !item.isChildLog || !item.parentLog)
-    )
-    .limit(15)
-    .orderBy('startedAt', 'desc')
-    .get()
-);
 
 const updateBlockData = debounce((data) => {
   let payload = data;
@@ -681,9 +669,7 @@ function shareWorkflow() {
   }
 }
 function deleteLog(logId) {
-  Log.delete(logId).then(() => {
-    store.dispatch('saveToStorage', 'logs');
-  });
+  dbLogs.items.where('id').equals(logId).delete();
 }
 function workflowExporter() {
   const currentWorkflow = { ...workflow.value };
@@ -753,14 +739,10 @@ async function saveWorkflow() {
   if (workflowData.active === 'shared') return;
 
   try {
-    let flow = JSON.stringify(editor.value.export());
+    const flow = JSON.stringify(editor.value.export());
     const [triggerBlockId] = editor.value.getNodesFromName('trigger');
     const triggerBlock = editor.value.getNodeFromId(triggerBlockId);
 
-    if (workflow.value.isProtected) {
-      flow = AES.encrypt(flow, getWorkflowPass(workflow.value.pass)).toString();
-    }
-
     updateWorkflow({ drawflow: flow, trigger: triggerBlock?.data }).then(() => {
       if (triggerBlock) {
         workflowTrigger.register(workflowId, triggerBlock);
@@ -821,6 +803,28 @@ function renameWorkflow() {
     description: workflow.value.description,
   });
 }
+function onEditorLoaded(editorInstance) {
+  const { blockId } = route.query;
+  if (!blockId) return;
+
+  const node = editorInstance.getNodeFromId(blockId);
+  if (!node) return;
+
+  if (editorInstance.zoom !== 1) {
+    editorInstance.zoom = 1;
+    editorInstance.zoom_refresh();
+  }
+
+  const { width, height } = editorInstance.container.getBoundingClientRect();
+  const rectX = width / 2;
+  const rectY = height / 2;
+
+  editorInstance.translate_to(
+    -(node.pos_x - rectX),
+    -(node.pos_y - rectY),
+    editorInstance.zoom
+  );
+}
 
 provide('workflow', {
   data: workflow,

+ 14 - 2
src/newtab/router.js

@@ -1,13 +1,14 @@
 import { createRouter, createWebHashHistory } from 'vue-router';
-import Home from './pages/Home.vue';
 import Welcome from './pages/Welcome.vue';
 import Workflows from './pages/Workflows.vue';
 import WorkflowHost from './pages/workflows/Host.vue';
 import WorkflowDetails from './pages/workflows/[id].vue';
+import ScheduledWorkflow from './pages/ScheduledWorkflow.vue';
 import Collections from './pages/Collections.vue';
 import CollectionsDetails from './pages/collections/[id].vue';
 import Logs from './pages/Logs.vue';
 import LogsDetails from './pages/logs/[id].vue';
+import LogsRunning from './pages/logs/Running.vue';
 import Settings from './pages/Settings.vue';
 import SettingsIndex from './pages/settings/SettingsIndex.vue';
 import SettingsAbout from './pages/settings/SettingsAbout.vue';
@@ -19,7 +20,8 @@ const routes = [
   {
     name: 'home',
     path: '/',
-    component: Home,
+    redirect: '/workflows',
+    component: Workflows,
   },
   {
     name: 'welcome',
@@ -31,6 +33,11 @@ const routes = [
     path: '/workflows',
     component: Workflows,
   },
+  {
+    name: 'schedule',
+    path: '/schedule',
+    component: ScheduledWorkflow,
+  },
   {
     name: 'workflows-details',
     path: '/workflows/:id',
@@ -61,6 +68,11 @@ const routes = [
     path: '/logs/:id',
     component: LogsDetails,
   },
+  {
+    name: 'logs-running',
+    path: '/logs/:id/running',
+    component: LogsRunning,
+  },
   {
     path: '/settings',
     component: Settings,

+ 42 - 17
src/store/index.js

@@ -6,14 +6,44 @@ import defu from 'defu';
 import * as models from '@/models';
 import { firstWorkflows } from '@/utils/shared';
 import { fetchApi } from '@/utils/api';
-import { findTriggerBlock } from '@/utils/helper';
+import { findTriggerBlock, parseJSON } from '@/utils/helper';
 import { registerWorkflowTrigger } from '@/utils/workflowTrigger';
 
 const store = createStore({
   plugins: [vuexORM(models)],
   state: () => ({
     user: null,
-    workflowState: [],
+    workflowState: [
+      {
+        id: '7F9HCTQXKMSDGlm_q_dVW',
+        state: {
+          activeTabUrl: '',
+          childWorkflowId: null,
+          tabIds: [null],
+          currentBlock: [
+            {
+              id: '1fb2464c-b94d-48f0-b40a-2903f2592428',
+              name: 'delay',
+              startedAt: 1655001148198,
+            },
+          ],
+          name: 'Child',
+          logs: [
+            {
+              type: 'success',
+              name: 'trigger',
+              blockId: '1991a5a0-a499-4c70-9040-03b37123b5df',
+              workerId: 'worker-1',
+              description: '',
+              duration: 1,
+              id: 1,
+            },
+          ],
+          startedTimestamp: 1655001148195,
+        },
+        workflowId: 'lPKjzF5cUfzckN3KgCdmX',
+      },
+    ],
     backupIds: [],
     contributors: null,
     hostWorkflows: {},
@@ -98,22 +128,17 @@ const store = createStore({
         throw error;
       }
     },
-    async retrieveWorkflowState({ commit }) {
-      try {
-        const { workflowState } = await browser.storage.local.get(
-          'workflowState'
-        );
+    retrieveWorkflowState({ commit }) {
+      const storedStates = localStorage.getItem('workflowState') || '{}';
+      const states = parseJSON(storedStates, {});
 
-        commit('updateState', {
-          key: 'workflowState',
-          value: Object.values(workflowState || {}).filter(
-            ({ isDestroyed, parentState }) =>
-              !isDestroyed && !parentState?.isCollection
-          ),
-        });
-      } catch (error) {
-        console.error(error);
-      }
+      commit('updateState', {
+        key: 'workflowState',
+        value: Object.values(states).filter(
+          ({ isDestroyed, parentState }) =>
+            !isDestroyed && !parentState?.isCollection
+        ),
+      });
     },
     saveToStorage({ getters }, key) {
       return new Promise((resolve, reject) => {

+ 2 - 2
src/utils/dataExporter.js

@@ -38,7 +38,7 @@ export function generateJSON(keys, data) {
 
 export default function (
   data,
-  { name, type, addBOMHeader, returnUrl },
+  { name, type, addBOMHeader, csvOptions, returnUrl },
   converted
 ) {
   let result = data;
@@ -48,7 +48,7 @@ export default function (
 
     result =
       type === 'csv'
-        ? Papa.unparse(jsonData)
+        ? Papa.unparse(jsonData, csvOptions || {})
         : JSON.stringify(jsonData, null, 2);
   } else if (type === 'plain-text') {
     const extractObj = (obj) => {

+ 56 - 0
src/utils/dataMigration.js

@@ -0,0 +1,56 @@
+import browser from 'webextension-polyfill';
+import dbLogs from '@/db/logs';
+
+export default async function () {
+  try {
+    const { logs, logsCtxData, migration } = await browser.storage.local.get([
+      'logs',
+      'migration',
+      'logsCtxData',
+    ]);
+    const hasMigrated = migration || {};
+    const backupData = {};
+
+    if (!hasMigrated.logs && logs) {
+      const ids = new Set();
+
+      const items = [];
+      const ctxData = [];
+      const logsData = [];
+      const histories = [];
+
+      for (let index = logs.length - 1; index > 0; index -= 1) {
+        const { data, history, ...item } = logs[index];
+        const logId = item.id;
+
+        if (!ids.has(logId) && ids.size < 500) {
+          items.push(item);
+          logsData.push({ logId, data });
+          histories.push({ logId, data: history });
+          ctxData.push({ logId, data: logsCtxData[logId] });
+
+          ids.add(logId);
+        }
+      }
+
+      await Promise.all([
+        dbLogs.items.bulkAdd(items),
+        dbLogs.ctxData.bulkAdd(ctxData),
+        dbLogs.logsData.bulkAdd(logsData),
+        dbLogs.histories.bulkAdd(histories),
+      ]);
+
+      backupData.logs = logs;
+      hasMigrated.logs = true;
+
+      await browser.storage.local.remove('logs');
+    }
+
+    await browser.storage.local.set({
+      migration: hasMigrated,
+      ...backupData,
+    });
+  } catch (error) {
+    console.error(error);
+  }
+}

+ 9 - 1
src/utils/handleFormElement.js

@@ -1,3 +1,4 @@
+import { sleep } from '@/utils/helper';
 import { keyDefinitions } from '@/utils/USKeyboardLayout';
 import simulateEvent from './simulateEvent';
 
@@ -50,6 +51,7 @@ function formEvent(element, data) {
   }
 }
 async function inputText({ data, element, isEditable }) {
+  element?.focus();
   const elementKey = isEditable ? 'textContent' : 'value';
 
   if (data.delay > 0 && !document.hidden) {
@@ -65,7 +67,7 @@ async function inputText({ data, element, isEditable }) {
         isEditable,
       });
 
-      await new Promise((r) => setTimeout(r, data.delay));
+      await sleep(data.delay);
     }
   } else {
     element[elementKey] += data.value;
@@ -82,6 +84,8 @@ async function inputText({ data, element, isEditable }) {
   element.dispatchEvent(
     new Event('change', { bubbles: true, cancelable: true })
   );
+
+  element?.blur();
 }
 
 export default async function (element, data) {
@@ -104,13 +108,17 @@ export default async function (element, data) {
   }
 
   if (data.type === 'checkbox' || data.type === 'radio') {
+    element?.focus();
     element.checked = data.selected;
     formEvent(element, { type: data.type, value: data.selected });
+    element?.blur();
     return;
   }
 
   if (data.type === 'select') {
+    element?.focus();
     element.value = data.value;
+    element?.blur();
     formEvent(element, data);
   }
 }

+ 5 - 1
src/utils/helper.js

@@ -21,7 +21,11 @@ export function visibleInViewport(element) {
 }
 
 export function sleep(timeout = 500) {
-  return new Promise((resolve) => setTimeout(resolve, timeout));
+  return new Promise((resolve) => {
+    setTimeout(() => {
+      resolve();
+    }, timeout);
+  });
 }
 
 export function findTriggerBlock(drawflow = {}) {

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

@@ -8,7 +8,7 @@ const refKeys = {
   dataColumns: 'table',
 };
 
-/* eslint-disable prefer-destructuring */
+/* eslint-disable prefer-destructuring, no-useless-escape */
 export const functions = {
   date(...args) {
     let date = new Date();
@@ -40,6 +40,39 @@ export const functions = {
 
     return value.length ?? value;
   },
+  randData(str) {
+    const getRand = (data) => data[Math.floor(Math.random() * data.length)];
+    const lowercase = 'abcdefghijklmnopqrstuvwxyz';
+    const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
+    const digits = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
+    const symbols = `!@#$%^&*()-_+={}[]|\;:'"<>,./?"`;
+    const mapSamples = {
+      l: () => getRand(lowercase),
+      u: () => getRand(uppercase),
+      d: () => getRand(digits),
+      s: () => getRand(symbols),
+      f() {
+        return this.l() + this.u();
+      },
+      n() {
+        return this.l() + this.d();
+      },
+      m() {
+        return this.u() + this.d();
+      },
+      i() {
+        return this.l() + this.u() + this.d();
+      },
+      a() {
+        return getRand(lowercase + uppercase + digits.join('') + symbols);
+      },
+    };
+
+    return `${str}`.replace(
+      /\?[a-zA-Z]/g,
+      (char) => mapSamples[char.at(-1)]?.() ?? char
+    );
+  },
 };
 
 export function extractStrFunction(str) {

+ 13 - 0
src/utils/shared.js

@@ -125,6 +125,7 @@ export const tasks = {
       description: '',
       url: '',
       matchPattern: '',
+      activeTab: true,
       createIfNoMatch: false,
     },
   },
@@ -367,6 +368,7 @@ export const tasks = {
       type: 'json',
       description: '',
       variableName: '',
+      csvDelimiter: ',',
       addBOMHeader: true,
       onConflict: 'uniquify',
       dataToExport: 'data-columns',
@@ -1118,6 +1120,11 @@ export const communities = [
     icon: 'riDiscordLine',
     url: 'https://discord.gg/C6khwwTE84',
   },
+  {
+    name: 'YouTube',
+    icon: 'riYoutubeLine',
+    url: 'https://www.youtube.com/channel/UCL3qU64hW0fsIj2vOayOQUQ',
+  },
 ];
 
 export const elementsHighlightData = {
@@ -1201,6 +1208,12 @@ export const conditionBuilder = {
   ],
   compareTypes: [
     { id: 'eq', name: 'Equals', needValue: true, category: 'basic' },
+    {
+      id: 'eqi',
+      name: 'Equals (case insensitive)',
+      needValue: true,
+      category: 'basic',
+    },
     { id: 'nq', name: 'Not equals', needValue: true, category: 'basic' },
     { id: 'gt', name: 'Greater than', needValue: true, category: 'number' },
     {

+ 1 - 0
src/utils/testConditions.js

@@ -11,6 +11,7 @@ const isBoolStr = (str) => {
 const isNumStr = (str) => (Number.isNaN(+str) ? str : +str);
 const comparisons = {
   eq: (a, b) => a === b,
+  eqi: (a, b) => a?.toLocaleLowerCase() === b?.toLocaleLowerCase(),
   nq: (a, b) => a !== b,
   gt: (a, b) => isNumStr(a) > isNumStr(b),
   gte: (a, b) => isNumStr(a) >= isNumStr(b),

+ 1 - 1
tailwind.config.js

@@ -25,7 +25,7 @@ module.exports = {
       },
       fontFamily: {
         sans: ['Poppins', 'sans-serif'],
-        mono: ['JetBrains Mono', 'monospace'],
+        mono: ['Source Code Pro', 'monospace'],
       },
       container: {
         center: true,

+ 24 - 15
utils/webserver.js

@@ -12,12 +12,11 @@ const env = require('./env');
 const options = config.chromeExtensionBoilerplate || {};
 const excludeEntriesToHotReload = options.notHotReload || [];
 
-/* eslint-disable-next-line */
 for (const entryName in config.entry) {
   if (excludeEntriesToHotReload.indexOf(entryName) === -1) {
     config.entry[entryName] = [
-      `webpack-dev-server/client?http://localhost:${env.PORT}`,
       'webpack/hot/dev-server',
+      `webpack-dev-server/client?hot=true&hostname=localhost&port=${env.PORT}`,
     ].concat(config.entry[entryName]);
   }
 }
@@ -30,22 +29,32 @@ delete config.chromeExtensionBoilerplate;
 
 const compiler = webpack(config);
 
-const server = new WebpackDevServer(compiler, {
-  https: false,
-  hot: true,
-  injectClient: false,
-  writeToDisk: true,
-  port: env.PORT,
-  contentBase: path.join(__dirname, '../build'),
-  publicPath: `http://localhost:${env.PORT}`,
-  headers: {
-    'Access-Control-Allow-Origin': '*',
+const server = new WebpackDevServer(
+  {
+    https: false,
+    hot: false,
+    client: false,
+    host: 'localhost',
+    port: env.PORT,
+    static: {
+      directory: path.join(__dirname, '../build'),
+    },
+    devMiddleware: {
+      publicPath: `http://localhost:${env.PORT}/`,
+      writeToDisk: true,
+    },
+    headers: {
+      'Access-Control-Allow-Origin': '*',
+    },
+    allowedHosts: 'all',
   },
-  disableHostCheck: true,
-});
+  compiler
+);
 
 if (process.env.NODE_ENV === 'development' && module.hot) {
   module.hot.accept();
 }
 
-server.listen(env.PORT);
+(async () => {
+  await server.start();
+})();

+ 8 - 11
webpack.config.js

@@ -84,6 +84,9 @@ const options = {
       {
         test: /\.vue$/,
         loader: 'vue-loader',
+        options: {
+          reactivityTransform: true,
+        },
       },
       {
         test: /\.css$/,
@@ -106,12 +109,11 @@ const options = {
       },
       {
         test: new RegExp(`.(${fileExtensions.join('|')})$`),
-        loader: 'file-loader',
-        type: 'javascript/auto',
-        options: {
-          name: '[name].[ext]',
+        type: 'asset/resource',
+        dependency: { not: [/node_modules/] },
+        generator: {
+          filename: '[name].[ext]',
         },
-        exclude: /node_modules/,
       },
       {
         test: /\.js$/,
@@ -142,8 +144,7 @@ const options = {
     new webpack.ProgressPlugin(),
     // clean the build folder
     new CleanWebpackPlugin({
-      verbose: true,
-      cleanStaleWebpackAssets: true,
+      verbose: false,
     }),
     // expose and write the allowed env vars on the compiled bundle
     new webpack.EnvironmentPlugin(['NODE_ENV']),
@@ -162,10 +163,6 @@ const options = {
             };
             const isChrome = env.BROWSER === 'chrome';
 
-            if (env.NODE_ENV === 'development' && !isChrome) {
-              manifestObj.content_security_policy =
-                "script-src 'self' 'unsafe-eval'; object-src 'self'";
-            }
             if (manifestObj.version.includes('-')) {
               const [version, preRelease] = manifestObj.version.split('-');
 

File diff suppressed because it is too large
+ 350 - 334
yarn.lock


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