Ahmad Kholid 3 年之前
父節點
當前提交
c4eb4c9ca1
共有 60 個文件被更改,包括 1315 次插入836 次删除
  1. 3 2
      package.json
  2. 3 8
      src/background/workflow-engine/blocks-handler/handler-conditions.js
  3. 49 0
      src/background/workflow-engine/blocks-handler/handler-google-sheets.js
  4. 14 2
      src/background/workflow-engine/blocks-handler/handler-loop-data.js
  5. 23 8
      src/background/workflow-engine/engine.js
  6. 3 2
      src/background/workflow-engine/execute-content-script.js
  7. 1 10
      src/components/block/BlockConditions.vue
  8. 1 10
      src/components/block/BlockElementExists.vue
  9. 1 9
      src/components/block/BlockGroup.vue
  10. 1 9
      src/components/block/BlockRepeatTask.vue
  11. 23 33
      src/components/newtab/app/AppSidebar.vue
  12. 10 1
      src/components/newtab/logs/LogsFilters.vue
  13. 9 1
      src/components/newtab/shared/SharedCodemirror.vue
  14. 19 12
      src/components/newtab/workflow/WorkflowActions.vue
  15. 70 10
      src/components/newtab/workflow/WorkflowBuilder.vue
  16. 12 19
      src/components/newtab/workflow/WorkflowDetailsCard.vue
  17. 29 11
      src/components/newtab/workflow/WorkflowSettings.vue
  18. 2 25
      src/components/newtab/workflow/edit/EditConditions.vue
  19. 144 0
      src/components/newtab/workflow/edit/EditGoogleSheets.vue
  20. 76 0
      src/components/newtab/workflow/edit/EditJavascriptCode.vue
  21. 8 1
      src/components/newtab/workflow/edit/EditLoopData.vue
  22. 1 1
      src/components/newtab/workflow/edit/EditTrigger.vue
  23. 1 8
      src/components/newtab/workflow/edit/EditWebhook.vue
  24. 1 8
      src/components/ui/UiCard.vue
  25. 2 18
      src/components/ui/UiCheckbox.vue
  26. 1 9
      src/components/ui/UiListItem.vue
  27. 1 12
      src/components/ui/UiModal.vue
  28. 1 9
      src/components/ui/UiPagination.vue
  29. 1 8
      src/components/ui/UiPopover.vue
  30. 2 18
      src/components/ui/UiRadio.vue
  31. 1 13
      src/components/ui/UiSelect.vue
  32. 81 0
      src/components/ui/UiSwitch.vue
  33. 2 19
      src/components/ui/UiTabs.vue
  34. 1 10
      src/components/ui/UiTextarea.vue
  35. 123 0
      src/composable/shortcut.js
  36. 2 2
      src/content/blocks-handler/handler-link.js
  37. 3 30
      src/content/element-selector/App.vue
  38. 0 0
      src/lib/v-remixicon.js
  39. 25 1
      src/locales/en/blocks.json
  40. 11 1
      src/locales/en/newtab.json
  41. 2 1
      src/models/workflow.js
  42. 28 0
      src/newtab/App.vue
  43. 10 1
      src/newtab/pages/Collections.vue
  44. 25 37
      src/newtab/pages/Settings.vue
  45. 11 11
      src/newtab/pages/Workflows.vue
  46. 4 38
      src/newtab/pages/collections/[id].vue
  47. 2 7
      src/newtab/pages/logs/[id].vue
  48. 49 0
      src/newtab/pages/settings/index.vue
  49. 3 10
      src/newtab/pages/workflows/[id].vue
  50. 2 1
      src/newtab/router.js
  51. 13 0
      src/utils/api.js
  52. 31 1
      src/utils/helper.js
  53. 0 74
      src/utils/reference-data.js
  54. 61 0
      src/utils/reference-data/index.js
  55. 28 0
      src/utils/reference-data/key-parser.js
  56. 37 0
      src/utils/reference-data/mustache-replacer.js
  57. 25 0
      src/utils/shared.js
  58. 4 3
      src/utils/workflow-data.js
  59. 3 4
      tailwind.config.js
  60. 216 308
      yarn.lock

+ 3 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "0.11.0",
+  "version": "0.12.0",
   "description": "An extension for automating your browser by connecting blocks",
   "license": "MIT",
   "repository": {
@@ -27,6 +27,7 @@
     "@codemirror/theme-one-dark": "^0.19.1",
     "@medv/finder": "^2.1.0",
     "@vuex-orm/core": "^0.36.4",
+    "compare-versions": "^4.1.2",
     "dayjs": "^1.10.7",
     "drawflow": "^0.0.51",
     "idb": "^7.0.0",
@@ -80,7 +81,7 @@
     "simple-git-hooks": "^2.6.1",
     "source-map-loader": "3.0.0",
     "style-loader": "3.3.0",
-    "tailwindcss": "2.2.19",
+    "tailwindcss": "^3.0.7",
     "terser-webpack-plugin": "5.2.4",
     "vue-loader": "16.8.1",
     "webpack": "5.55.1",

+ 3 - 8
src/background/workflow-engine/blocks-handler/handler-conditions.js

@@ -1,7 +1,6 @@
 import { getBlockConnection } from '../helper';
-import { replaceMustache } from '@/utils/helper';
-import { replaceMustacheHandler } from '@/utils/reference-data';
 import compareBlockValue from '@/utils/compare-block-value';
+import mustacheReplacer from '@/utils/reference-data/mustache-replacer';
 
 function conditions({ data, outputs }, { prevBlockData, refData }) {
   return new Promise((resolve, reject) => {
@@ -13,7 +12,6 @@ function conditions({ data, outputs }, { prevBlockData, refData }) {
     let resultData = '';
     let isConditionMatch = false;
     let outputIndex = data.conditions.length + 1;
-    const handleMustache = (match) => replaceMustacheHandler(match, refData);
     const prevData = Array.isArray(prevBlockData)
       ? prevBlockData[0]
       : prevBlockData;
@@ -21,11 +19,8 @@ function conditions({ data, outputs }, { prevBlockData, refData }) {
     data.conditions.forEach(({ type, value, compareValue }, index) => {
       if (isConditionMatch) return;
 
-      const firstValue = replaceMustache(
-        compareValue ?? prevData,
-        handleMustache
-      );
-      const secondValue = replaceMustache(value, handleMustache);
+      const firstValue = mustacheReplacer(compareValue ?? prevData, refData);
+      const secondValue = mustacheReplacer(value, refData);
 
       const isMatch = compareBlockValue(type, firstValue, secondValue);
 

+ 49 - 0
src/background/workflow-engine/blocks-handler/handler-google-sheets.js

@@ -0,0 +1,49 @@
+import { getBlockConnection } from '../helper';
+import { getGoogleSheetsValue } from '@/utils/api';
+import { convert2DArrayToArrayObj, isWhitespace } from '@/utils/helper';
+
+async function getSpreadsheetValues(data) {
+  const response = await getGoogleSheetsValue(data.spreadsheetId, data.range);
+
+  if (response.status !== 200) {
+    throw new Error(response.statusText);
+  }
+
+  const { values } = await response.json();
+  const sheetsData = data.firstRowAsKey
+    ? convert2DArrayToArrayObj(values)
+    : values;
+
+  return sheetsData;
+}
+
+export default async function ({ data, outputs }) {
+  const nextBlockId = getBlockConnection({ outputs });
+
+  try {
+    if (isWhitespace(data.spreadsheetId))
+      throw new Error('empty-spreadsheet-id');
+    if (isWhitespace(data.range)) throw new Error('empty-spreadsheet-range');
+
+    let result = [];
+
+    if (data.type === 'get') {
+      const spreadsheetValues = await getSpreadsheetValues(data);
+
+      result = spreadsheetValues;
+
+      if (data.refKey && !isWhitespace(data.refKey)) {
+        this.googleSheets[data.refKey] = spreadsheetValues;
+      }
+    }
+
+    return {
+      nextBlockId,
+      data: result,
+    };
+  } catch (error) {
+    error.nextBlockId = nextBlockId;
+
+    throw error;
+  }
+}

+ 14 - 2
src/background/workflow-engine/blocks-handler/handler-loop-data.js

@@ -2,8 +2,9 @@ import { generateJSON } from '@/utils/data-exporter';
 import { getBlockConnection } from '../helper';
 
 function loopData(block) {
-  return new Promise((resolve) => {
+  return new Promise((resolve, reject) => {
     const { data } = block;
+    const nextBlockId = getBlockConnection(block);
 
     if (this.loopList[data.loopId]) {
       this.loopList[data.loopId].index += 1;
@@ -28,12 +29,23 @@ function loopData(block) {
         case 'data-columns':
           currLoopData = generateJSON(Object.keys(this.data), this.data);
           break;
+        case 'google-sheets':
+          currLoopData = this.googleSheets[data.referenceKey];
+          break;
         case 'custom-data':
           currLoopData = JSON.parse(data.loopData);
           break;
         default:
       }
 
+      if (data.loopThrough !== 'number' && !Array.isArray(currLoopData)) {
+        const error = new Error('invalid-loop-data');
+        error.nextBlockId = nextBlockId;
+
+        reject(error);
+        return;
+      }
+
       this.loopList[data.loopId] = {
         index: 0,
         data: currLoopData,
@@ -50,8 +62,8 @@ function loopData(block) {
     }
 
     resolve({
+      nextBlockId,
       data: this.loopData[data.loopId],
-      nextBlockId: getBlockConnection(block),
     });
   });
 }

+ 23 - 8
src/background/workflow-engine/engine.js

@@ -46,7 +46,7 @@ function tabUpdatedHandler(tabId, changeInfo, tab) {
       clearTimeout(reloadTimeout);
       reloadTimeout = null;
 
-      executeContentScript(tabId, 'update tab')
+      executeContentScript(tabId)
         .then((frames) => {
           this.tabId = tabId;
           this.frames = frames;
@@ -96,13 +96,17 @@ class WorkflowEngine {
     this.isPaused = false;
     this.isDestroyed = false;
     this.isUsingProxy = false;
-    this.frameId = null;
+    this.frameId = 0;
     this.windowId = null;
     this.tabGroupId = null;
     this.currentBlock = null;
     this.childWorkflow = null;
     this.workflowTimeout = null;
 
+    this.saveLog = workflow.settings?.saveLog ?? true;
+
+    this.googleSheets = {};
+
     this.tabUpdatedListeners = {};
     this.tabUpdatedHandler = tabUpdatedHandler.bind(this);
     this.tabRemovedHandler = tabRemovedHandler.bind(this);
@@ -183,7 +187,12 @@ class WorkflowEngine {
   }
 
   addLog(detail) {
-    if (this.logs.length >= 1001 || detail.name === 'blocks-group') return;
+    if (
+      !this.saveLog &&
+      (this.logs.length >= 1001 || detail.name === 'blocks-group') &&
+      detail.type !== 'error'
+    )
+      return;
 
     this.logs.push(detail);
   }
@@ -231,7 +240,7 @@ class WorkflowEngine {
       this.isDestroyed = true;
       this.endedTimestamp = Date.now();
 
-      if (!this.workflow.isTesting) {
+      if (!this.workflow.isTesting && this.saveLog) {
         const { logs } = await browser.storage.local.get('logs');
         const { name, icon, id } = this.workflow;
         const jsonData = generateJSON(Object.keys(this.data), this.data);
@@ -305,9 +314,13 @@ class WorkflowEngine {
   _blockHandler(block, prevBlockData) {
     if (this.isDestroyed) return;
     if (this.isPaused) {
-      setTimeout(() => {
-        this._blockHandler(block, prevBlockData);
-      }, 1000);
+      browser.tabs.get(this.tabId).then(({ status }) => {
+        this.isPaused = status !== 'complete';
+
+        setTimeout(() => {
+          this._blockHandler(block, prevBlockData);
+        }, 1000);
+      });
 
       return;
     }
@@ -338,9 +351,10 @@ class WorkflowEngine {
         dataColumns: this.data,
         loopData: this.loopData,
         globalData: this.globalData,
+        googleSheets: this.googleSheets,
         activeTabUrl: this.activeTabUrl,
       };
-      const replacedBlock = referenceData(block, refData);
+      const replacedBlock = referenceData({ block, data: refData });
       const blockDelay =
         block.name === 'trigger' ? 0 : this.workflow.settings?.blockDelay || 0;
 
@@ -349,6 +363,7 @@ class WorkflowEngine {
         .then((result) => {
           clearTimeout(this.workflowTimeout);
           this.workflowTimeout = null;
+
           this.addLog({
             type: 'success',
             name: block.name,

+ 3 - 2
src/background/workflow-engine/execute-content-script.js

@@ -34,11 +34,12 @@ async function contentScriptExist(tabId, frameId = 0) {
 
 export default async function (tabId, frameId = 0) {
   try {
-    const isScriptExists = await contentScriptExist(tabId, frameId);
+    const currentFrameId = typeof frameId !== 'number' ? 0 : frameId;
+    const isScriptExists = await contentScriptExist(tabId, currentFrameId);
 
     if (!isScriptExists) {
       await browser.tabs.executeScript(tabId, {
-        frameId,
+        frameId: currentFrameId,
         file: './contentScript.bundle.js',
       });
     }

+ 1 - 10
src/components/block/BlockConditions.vue

@@ -35,16 +35,7 @@
           @click="deleteCondition(index)"
         />
         <div
-          class="
-            flex
-            items-center
-            flex-1
-            p-2
-            bg-box-transparent
-            rounded-lg
-            overflow-hidden
-            w-44
-          "
+          class="flex items-center flex-1 p-2 bg-box-transparent rounded-lg overflow-hidden w-44"
         >
           <p class="w-5/12 text-overflow text-right">
             {{ item.compareValue || '_____' }}

+ 1 - 10
src/components/block/BlockElementExists.vue

@@ -14,16 +14,7 @@
     </div>
     <p
       :title="t('workflow.blocks.element-exists.selector')"
-      class="
-        text-overflow
-        p-2
-        rounded-lg
-        bg-box-transparent
-        text-sm
-        font-mono
-        text-right
-        mb-2
-      "
+      class="text-overflow p-2 rounded-lg bg-box-transparent text-sm font-mono text-right mb-2"
       style="max-width: 200px"
     >
       {{ block.data.selector || t('workflow.blocks.element-exists.selector') }}

+ 1 - 9
src/components/block/BlockGroup.vue

@@ -74,15 +74,7 @@
       </template>
       <template #footer>
         <div
-          class="
-            p-2
-            rounded-lg
-            text-gray-600
-            dark:text-gray-200
-            border
-            text-center
-            border-dashed
-          "
+          class="p-2 rounded-lg text-gray-600 dark:text-gray-200 border text-center border-dashed"
         >
           {{ t('workflow.blocks.blocks-group.dropText') }}
         </div>

+ 1 - 9
src/components/block/BlockRepeatTask.vue

@@ -16,15 +16,7 @@
       />
     </div>
     <label
-      class="
-        mb-2
-        block
-        bg-input
-        focus-within:bg-input
-        pr-4
-        transition
-        rounded-lg
-      "
+      class="mb-2 block bg-input focus-within:bg-input pr-4 transition rounded-lg"
     >
       <input
         :value="block.data.repeatFor || 0"

+ 23 - 33
src/components/newtab/app/AppSidebar.vue

@@ -1,17 +1,6 @@
 <template>
   <aside
-    class="
-      fixed
-      flex flex-col
-      items-center
-      h-screen
-      left-0
-      top-0
-      w-16
-      py-6
-      bg-white
-      z-50
-    "
+    class="fixed flex flex-col items-center h-screen left-0 top-0 w-16 py-6 bg-white z-50"
   >
     <img src="@/assets/svg/logo.svg" class="w-10 mb-4 mx-auto" />
     <div
@@ -21,16 +10,7 @@
       <div
         v-show="showHoverIndicator"
         ref="hoverIndicator"
-        class="
-          rounded-lg
-          h-10
-          w-10
-          absolute
-          left-1/2
-          bg-box-transparent
-          transition-transform
-          duration-200
-        "
+        class="rounded-lg h-10 w-10 absolute left-1/2 bg-box-transparent transition-transform duration-200"
         style="transform: translate(-50%, 0)"
       ></div>
       <router-link
@@ -41,19 +21,12 @@
         custom
       >
         <a
-          v-tooltip:right.group="t(`common.${tab.id}`, 2)"
+          v-tooltip:right.group="
+            `${t(`common.${tab.id}`, 2)} (${tab.shortcut.readable})`
+          "
           :class="{ 'is-active': isActive }"
           :href="href"
-          class="
-            z-10
-            relative
-            w-full
-            flex
-            items-center
-            justify-center
-            tab
-            relative
-          "
+          class="z-10 relative w-full flex items-center justify-center tab relative"
           @click="navigate"
           @mouseenter="hoverHandler"
         >
@@ -87,10 +60,13 @@
 <script setup>
 import { ref } from 'vue';
 import { useI18n } from 'vue-i18n';
+import { useRouter } from 'vue-router';
+import { useShortcut, getShortcut } from '@/composable/shortcut';
 import { useGroupTooltip } from '@/composable/groupTooltip';
 
 useGroupTooltip();
 const { t } = useI18n();
+const router = useRouter();
 
 const links = [
   {
@@ -114,31 +90,45 @@ const tabs = [
     id: 'dashboard',
     icon: 'riHome5Line',
     path: '/',
+    shortcut: getShortcut('page:dashboard', '/'),
   },
   {
     id: 'workflow',
     icon: 'riFlowChart',
     path: '/workflows',
+    shortcut: getShortcut('page:workflows', '/workflows'),
   },
   {
     id: 'collection',
     icon: 'riFolderLine',
     path: '/collections',
+    shortcut: getShortcut('page:collections', '/collections'),
   },
   {
     id: 'log',
     icon: 'riHistoryLine',
     path: '/logs',
+    shortcut: getShortcut('page:logs', '/logs'),
   },
   {
     id: 'settings',
     icon: 'riSettings3Line',
     path: '/settings',
+    shortcut: getShortcut('page:settings', '/settings'),
   },
 ];
 const hoverIndicator = ref(null);
 const showHoverIndicator = ref(false);
 
+useShortcut(
+  tabs.map(({ shortcut }) => shortcut),
+  ({ data }) => {
+    if (!data) return;
+
+    router.push(data);
+  }
+);
+
 function hoverHandler({ target }) {
   showHoverIndicator.value = true;
   hoverIndicator.value.style.transform = `translate(-50%, ${target.offsetTop}px)`;

+ 10 - 1
src/components/newtab/logs/LogsFilters.vue

@@ -1,8 +1,11 @@
 <template>
   <div class="flex items-center mb-6 space-x-4">
     <ui-input
+      id="search-input"
       :model-value="filters.query"
-      :placeholder="`${t('common.search')}...`"
+      :placeholder="`${t('common.search')}... (${
+        shortcut['action:search'].readable
+      })`"
       prepend-icon="riSearch2Line"
       class="flex-1"
       @change="updateFilters('query', $event)"
@@ -67,6 +70,7 @@
 </template>
 <script setup>
 import { useI18n } from 'vue-i18n';
+import { useShortcut } from '@/composable/shortcut';
 
 defineProps({
   filters: {
@@ -81,6 +85,11 @@ defineProps({
 const emit = defineEmits(['updateSorts', 'updateFilters']);
 
 const { t } = useI18n();
+const shortcut = useShortcut('action:search', () => {
+  const searchInput = document.querySelector('#search-input input');
+
+  searchInput?.focus();
+});
 
 const filterByStatus = [
   { id: 'all', name: t('common.all') },

+ 9 - 1
src/components/newtab/shared/SharedCodemirror.vue

@@ -2,7 +2,7 @@
   <div
     ref="containerEl"
     :class="{ 'hide-gutters': !lineNumbers }"
-    class="codemirror relative"
+    class="codemirror relative overflow-auto"
   ></div>
 </template>
 <script setup>
@@ -31,6 +31,10 @@ const props = defineProps({
     type: Boolean,
     default: true,
   },
+  extensions: {
+    type: [Object, Array],
+    default: () => [],
+  },
 });
 const emit = defineEmits(['change', 'update:modelValue']);
 
@@ -48,6 +52,9 @@ const updateListener = EditorView.updateListener.of((event) => {
   }
 });
 
+const customExtension = Array.isArray(props.extensions)
+  ? props.extensions
+  : [props.extensions];
 const state = EditorState.create({
   doc: props.modelValue,
   extensions: [
@@ -58,6 +65,7 @@ const state = EditorState.create({
     keymap.of([indentWithTab]),
     EditorState.readOnly.of(props.readonly),
     props.lang === 'javascript' ? javascript() : json(),
+    ...customExtension,
   ],
 });
 

+ 19 - 12
src/components/newtab/workflow/WorkflowActions.vue

@@ -14,6 +14,7 @@
     <button
       v-if="!workflow.isDisabled"
       v-tooltip.group="t('common.execute')"
+      :title="shortcuts['editor:execute-workflow'].readable"
       icon
       class="hoverable p-2 rounded-lg"
       @click="$emit('execute')"
@@ -56,22 +57,18 @@
         </ui-list-item>
       </ui-list>
     </ui-popover>
-    <ui-button variant="accent" class="relative" @click="$emit('save')">
+    <ui-button
+      :title="shortcuts['editor:save'].readable"
+      variant="accent"
+      class="relative"
+      @click="$emit('save')"
+    >
       <span
         v-if="isDataChanged"
         class="flex h-3 w-3 absolute top-0 left-0 -ml-1 -mt-1"
       >
         <span
-          class="
-            animate-ping
-            absolute
-            inline-flex
-            h-full
-            w-full
-            rounded-full
-            bg-primary
-            opacity-75
-          "
+          class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"
         ></span>
         <span
           class="relative inline-flex rounded-full h-3 w-3 bg-blue-600"
@@ -85,6 +82,7 @@
 <script setup>
 import { useI18n } from 'vue-i18n';
 import { useGroupTooltip } from '@/composable/groupTooltip';
+import { useShortcut, getShortcut } from '@/composable/shortcut';
 
 defineProps({
   isDataChanged: {
@@ -96,7 +94,7 @@ defineProps({
     default: () => ({}),
   },
 });
-defineEmits([
+const emit = defineEmits([
   'showModal',
   'execute',
   'rename',
@@ -108,6 +106,15 @@ defineEmits([
 
 useGroupTooltip();
 const { t } = useI18n();
+const shortcuts = useShortcut(
+  [
+    getShortcut('editor:save', 'save'),
+    getShortcut('editor:execute-workflow', 'execute'),
+  ],
+  ({ data }) => {
+    emit(data);
+  }
+);
 
 const modalActions = [
   {

+ 70 - 10
src/components/newtab/workflow/WorkflowBuilder.vue

@@ -37,16 +37,23 @@
       :options="contextMenu.position"
       padding="p-3"
     >
-      <ui-list class="w-36 space-y-1">
+      <ui-list class="space-y-1 w-52">
         <ui-list-item
           v-for="item in contextMenu.items"
           :key="item.id"
           v-close-popover
-          class="cursor-pointer"
+          class="cursor-pointer justify-between"
           @click="contextMenuHandler[item.event]"
         >
-          <v-remixicon :name="item.icon" class="mr-2 -ml-1" />
-          <span>{{ item.name }}</span>
+          <span>
+            {{ item.name }}
+          </span>
+          <span
+            v-if="item.shortcut"
+            class="text-sm capitalize text-gray-600 dark:text-gray-200"
+          >
+            {{ item.shortcut }}
+          </span>
         </ui-list-item>
       </ui-list>
     </ui-popover>
@@ -55,8 +62,10 @@
 <script>
 /* eslint-disable camelcase */
 import { onMounted, shallowRef, reactive, getCurrentInstance } from 'vue';
-import emitter from 'tiny-emitter/instance';
 import { useI18n } from 'vue-i18n';
+import { compare } from 'compare-versions';
+import emitter from 'tiny-emitter/instance';
+import { useShortcut, getShortcut } from '@/composable/shortcut';
 import { tasks } from '@/utils/shared';
 import { parseJSON } from '@/utils/helper';
 import { useGroupTooltip } from '@/composable/groupTooltip';
@@ -68,8 +77,12 @@ export default {
       type: [Object, String],
       default: null,
     },
+    version: {
+      type: String,
+      default: '',
+    },
   },
-  emits: ['load', 'deleteBlock'],
+  emits: ['load', 'deleteBlock', 'update'],
   setup(props, { emit }) {
     useGroupTooltip();
     const { t } = useI18n();
@@ -81,12 +94,14 @@ export default {
           name: t('workflow.editor.duplicate'),
           icon: 'riFileCopyLine',
           event: 'duplicateBlock',
+          shortcut: getShortcut('editor:duplicate-block').readable,
         },
         {
           id: 'delete',
           name: t('common.delete'),
           icon: 'riDeleteBin7Line',
           event: 'deleteBlock',
+          shortcut: 'Del',
         },
       ],
     };
@@ -152,9 +167,9 @@ export default {
     function deleteBlock() {
       editor.value.removeNodeId(contextMenu.data);
     }
-    function duplicateBlock() {
+    function duplicateBlock(id) {
       const { name, pos_x, pos_y, data, html } = editor.value.getNodeFromId(
-        contextMenu.data.substr(5)
+        id || contextMenu.data.substr(5)
       );
 
       if (name === 'trigger') return;
@@ -174,6 +189,14 @@ export default {
       );
     }
 
+    useShortcut('editor:duplicate-block', () => {
+      const selectedElement = document.querySelector('.drawflow-node.selected');
+
+      if (!selectedElement) return;
+
+      duplicateBlock(selectedElement.id.substr(5));
+    });
+
     onMounted(() => {
       const context = getCurrentInstance().appContext.app._context;
       const element = document.querySelector('#drawflow');
@@ -184,13 +207,50 @@ export default {
       emit('load', editor.value);
 
       if (props.data) {
-        const data =
+        let data =
           typeof props.data === 'string'
             ? parseJSON(props.data.replace(/BlockNewTab/g, 'BlockBasic'), null)
             : props.data;
 
         if (!data) return;
 
+        const currentExtVersion = chrome.runtime.getManifest().version;
+        const isOldWorkflow = compare(
+          currentExtVersion,
+          props.version || '0.0.0',
+          '>'
+        );
+
+        if (isOldWorkflow) {
+          const newDrawflowData = Object.entries(
+            data.drawflow.Home.data
+          ).reduce((obj, [key, value]) => {
+            const newBlockData = {
+              ...tasks[value.name],
+              ...value,
+              data: {
+                ...tasks[value.name].data,
+                ...value.data,
+              },
+            };
+
+            obj[key] = newBlockData;
+
+            return obj;
+          }, {});
+
+          const drawflowData = {
+            drawflow: { Home: { data: newDrawflowData } },
+          };
+
+          data = drawflowData;
+
+          emit('update', {
+            version: currentExtVersion,
+            drawflow: JSON.stringify(drawflowData),
+          });
+        }
+
         editor.value.import(data);
       } else {
         editor.value.addNode(
@@ -268,7 +328,7 @@ export default {
       dropHandler,
       contextMenuHandler: {
         deleteBlock,
-        duplicateBlock,
+        duplicateBlock: () => duplicateBlock(),
       },
     };
   },

+ 12 - 19
src/components/newtab/workflow/WorkflowDetailsCard.vue

@@ -20,14 +20,7 @@
           <span
             v-for="icon in icons"
             :key="icon"
-            class="
-              cursor-pointer
-              rounded-lg
-              inline-block
-              text-center
-              p-2
-              hoverable
-            "
+            class="cursor-pointer rounded-lg inline-block text-center p-2 hoverable"
             @click="$emit('update', { icon })"
           >
             <v-remixicon :name="icon" />
@@ -52,8 +45,11 @@
     </div>
   </div>
   <ui-input
+    id="search-input"
     v-model="query"
-    :placeholder="`${t('common.search')}...`"
+    :placeholder="`${t('common.search')}... (${
+      shortcut['action:search'].readable
+    })`"
     prepend-icon="riSearch2Line"
     class="px-4 mt-4 mb-2"
   />
@@ -78,16 +74,7 @@
             )
           "
           draggable="true"
-          class="
-            transform
-            select-none
-            cursor-move
-            relative
-            p-4
-            rounded-lg
-            bg-input
-            transition
-          "
+          class="transform select-none cursor-move relative p-4 rounded-lg bg-input transition"
           @dragstart="
             $event.dataTransfer.setData('block', JSON.stringify(block))
           "
@@ -114,6 +101,7 @@
 <script setup>
 import { computed, ref } from 'vue';
 import { useI18n } from 'vue-i18n';
+import { useShortcut } from '@/composable/shortcut';
 import { tasks, categories } from '@/utils/shared';
 
 defineProps({
@@ -129,6 +117,11 @@ defineProps({
 const emit = defineEmits(['update']);
 
 const { t } = useI18n();
+const shortcut = useShortcut('action:search', () => {
+  const searchInput = document.querySelector('#search-input input');
+
+  searchInput?.focus();
+});
 
 const icons = [
   'riGlobalLine',

+ 29 - 11
src/components/newtab/workflow/WorkflowSettings.vue

@@ -6,10 +6,10 @@
         <ui-radio
           v-for="item in onError"
           :key="item.id"
-          :model-value="workflow.settings.onError"
+          :model-value="settings.onError"
           :value="item.id"
           class="mr-4"
-          @change="updateWorkflow({ onError: $event })"
+          @change="settings.onError = $event"
         >
           {{ item.name }}
         </ui-radio>
@@ -18,10 +18,9 @@
     <div class="mb-6">
       <p class="mb-1">{{ t('workflow.settings.timeout.title') }}</p>
       <ui-input
-        :model-value="workflow.settings.timeout"
+        v-model="settings.timeout"
         type="number"
         class="w-full max-w-sm"
-        @change="updateWorkflow({ timeout: +$event })"
       />
     </div>
     <div>
@@ -32,15 +31,19 @@
         </span>
       </p>
       <ui-input
-        :model-value="workflow.settings.blockDelay"
+        v-model.number="settings.blockDelay"
         type="number"
         class="w-full max-w-sm"
-        @change="updateWorkflow({ blockDelay: +$event })"
       />
     </div>
+    <div class="flex mt-6">
+      <ui-switch v-model="settings.saveLog" class="mr-4" />
+      <p>Save log</p>
+    </div>
   </div>
 </template>
 <script setup>
+import { onMounted, reactive, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
 
 const props = defineProps({
@@ -64,9 +67,24 @@ const onError = [
   },
 ];
 
-function updateWorkflow(data) {
-  emit('update', {
-    settings: { ...props.workflow.settings, ...data },
-  });
-}
+const settings = reactive({
+  blockDelay: 0,
+  saveLog: true,
+  timeout: 120000,
+  onError: 'stop-workflow',
+});
+
+watch(
+  settings,
+  (newSettings) => {
+    emit('update', {
+      settings: newSettings,
+    });
+  },
+  { deep: true }
+);
+
+onMounted(() => {
+  Object.assign(settings, props.workflow.settings);
+});
 </script>

+ 2 - 25
src/components/newtab/workflow/edit/EditConditions.vue

@@ -21,16 +21,7 @@
           class="py-2 px-4 w-full transition rounded-lg bg-transparent"
         />
         <button
-          class="
-            bg-white
-            absolute
-            top-1/2
-            right-4
-            p-2
-            rounded-lg
-            -translate-y-1/2
-            group-hover:right-14
-          "
+          class="bg-white absolute top-1/2 right-4 p-2 rounded-lg -translate-y-1/2 group-hover:right-14"
           @click="deleteCondition(index)"
         >
           <v-remixicon size="20" name="riDeleteBin7Line" />
@@ -38,21 +29,7 @@
         <select
           v-model="condition.type"
           :title="getTitle(index)"
-          class="
-            bg-white
-            absolute
-            right-4
-            font-mono
-            z-10
-            p-2
-            top-1/2
-            leading-tight
-            -translate-y-1/2
-            text-center
-            transition
-            rounded-lg
-            appearance-none
-          "
+          class="bg-white absolute right-4 font-mono z-10 p-2 top-1/2 leading-tight -translate-y-1/2 text-center transition rounded-lg appearance-none"
         >
           <option
             v-for="(name, type) in conditionTypes"

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

@@ -0,0 +1,144 @@
+<template>
+  <div>
+    <ui-select
+      v-if="false"
+      :model-value="data.type"
+      class="w-full mb-2"
+      @change="updateData({ type: $event })"
+    >
+      <option value="get">
+        {{ t('workflow.blocks.google-sheets.select.get') }}
+      </option>
+      <option value="write">
+        {{ t('workflow.blocks.google-sheets.select.write') }}
+      </option>
+    </ui-select>
+    <ui-input
+      :model-value="data.spreadsheetId"
+      class="w-full"
+      placeholder="abcd123"
+      @change="updateData({ spreadsheetId: $event })"
+    >
+      <template #label>
+        {{ t('workflow.blocks.google-sheets.spreadsheetId.label') }}
+        <a
+          href="https://github.com/Kholid060/automa/wiki/Blocks#spreadsheet-id"
+          target="_blank"
+          rel="noopener"
+          :title="t('workflow.blocks.google-sheets.spreadsheetId.link')"
+        >
+          <v-remixicon name="riInformationLine" size="18" class="inline" />
+        </a>
+      </template>
+    </ui-input>
+    <ui-input
+      :model-value="data.range"
+      class="w-full mt-1"
+      placeholder="Sheet1!A1:B2"
+      @change="updateData({ range: $event })"
+    >
+      <template #label>
+        {{ t('workflow.blocks.google-sheets.range.label') }}
+        <a
+          href="https://github.com/Kholid060/automa/wiki/Blocks#range"
+          target="_blank"
+          rel="noopener"
+          :title="t('workflow.blocks.google-sheets.range.link')"
+        >
+          <v-remixicon name="riInformationLine" size="18" class="inline" />
+        </a>
+      </template>
+    </ui-input>
+    <template v-if="data.type === 'get'">
+      <ui-input
+        :model-value="data.refKey"
+        :label="t('workflow.blocks.google-sheets.refKey.label')"
+        :placeholder="t('workflow.blocks.google-sheets.refKey.placeholder')"
+        class="mt-1 w-full"
+        @change="updateData({ refKey: $event })"
+      />
+      <ui-checkbox
+        :model-value="data.firstRowAsKey"
+        class="mt-3"
+        @change="updateData({ firstRowAsKey: $event })"
+      >
+        {{ t('workflow.blocks.google-sheets.firstRow') }}
+      </ui-checkbox>
+      <ui-button
+        :loading="previewDataState.status === 'loading'"
+        variant="accent"
+        class="mt-3"
+        @click="previewData"
+      >
+        {{ t('workflow.blocks.google-sheets.previewData') }}
+      </ui-button>
+      <p v-if="previewDataState.status === 'error'" class="text-red-500">
+        {{ previewDataState.errorMessage }}
+      </p>
+      <shared-codemirror
+        v-if="previewDataState.data"
+        :model-value="previewDataState.data"
+        readonly
+        class="mt-4 max-h-96"
+      />
+    </template>
+    <template v-else-if="data.type === 'write'">
+      <pre>
+        halo
+      </pre>
+    </template>
+  </div>
+</template>
+<script setup>
+import { shallowReactive } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { getGoogleSheetsValue } from '@/utils/api';
+import { convert2DArrayToArrayObj } from '@/utils/helper';
+import SharedCodemirror from '@/components/newtab/shared/SharedCodemirror.vue';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const { t } = useI18n();
+
+const previewDataState = shallowReactive({
+  status: 'idle',
+  errorMessage: '',
+  data: '',
+});
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+async function previewData() {
+  try {
+    previewDataState.status = 'loading';
+    const response = await getGoogleSheetsValue(
+      props.data.spreadsheetId,
+      props.data.range
+    );
+
+    if (response.status !== 200) {
+      throw new Error(response.statusText);
+    }
+
+    const { values } = await response.json();
+    const sheetsData = props.data.firstRowAsKey
+      ? convert2DArrayToArrayObj(values)
+      : values;
+
+    previewDataState.data = JSON.stringify(sheetsData, null, 2);
+
+    previewDataState.status = 'idle';
+  } catch (error) {
+    console.error(error);
+    previewDataState.status = 'error';
+    previewDataState.errorMessage = error.message;
+  }
+}
+</script>

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

@@ -40,6 +40,7 @@
         <ui-tab-panel value="code" class="h-full">
           <shared-codemirror
             v-model="state.code"
+            :extensions="codemirrorExts"
             class="overflow-auto"
             style="height: 87%"
           />
@@ -92,6 +93,8 @@
 <script setup>
 import { watch, reactive } from 'vue';
 import { useI18n } from 'vue-i18n';
+import { syntaxTree } from '@codemirror/language';
+import { autocompletion, snippet } from '@codemirror/autocomplete';
 import SharedCodemirror from '@/components/newtab/shared/SharedCodemirror.vue';
 
 const props = defineProps({
@@ -123,6 +126,79 @@ function updateData(value) {
 function addScript() {
   state.preloadScripts.push({ src: '', removeAfterExec: true });
 }
+const dontCompleteIn = [
+  'String',
+  'TemplateString',
+  'LineComment',
+  'BlockComment',
+  'VariableDefinition',
+  'PropertyDefinition',
+];
+/* eslint-disable no-template-curly-in-string */
+function automaFuncsCompletion(context) {
+  const word = context.matchBefore(/\w*/);
+  const nodeBefore = syntaxTree(context.state).resolveInner(context.pos, -1);
+
+  if (
+    (word.from === word.to && !context.explicit) ||
+    dontCompleteIn.includes(nodeBefore.name)
+  )
+    return null;
+
+  return {
+    from: word.from,
+    options: [
+      {
+        label: 'automaNextBlock',
+        type: 'function',
+        apply: snippet('automaNextBlock(${data})'),
+        info: () => {
+          const container = document.createElement('div');
+
+          container.innerHTML = `
+            <code>automaNextBlock(<i>data</i>)</code>
+            <p class="mt-2">
+              Execute the next block
+              <a href="https://github.com/Kholid060/automa/wiki/Blocks#automanextblockdata" target="_blank" class="underline">
+                Read more
+              </a>
+            </p>
+          `;
+
+          return container;
+        },
+      },
+      {
+        label: 'automaRefData',
+        type: 'function',
+        apply: snippet("automaRefData('${keyword}', '${path}')"),
+        info: () => {
+          const container = document.createElement('div');
+
+          container.innerHTML = `
+            <code>automaRefData(<i>keyword</i>, <i>path</i>)</code>
+            <p class="mt-2">
+              Use this function to
+              <a href="https://github.com/Kholid060/automa/wiki/Features#reference-data" target="_blank" class="underline">
+                reference data
+              </a>
+            </p>
+          `;
+
+          return container;
+        },
+      },
+      {
+        label: 'automaResetTimeout',
+        type: 'function',
+        info: 'Reset javascript execution timeout',
+        apply: 'automaResetTimeout()',
+      },
+    ],
+  };
+}
+
+const codemirrorExts = [autocompletion({ override: [automaFuncsCompletion] })];
 
 watch(
   () => state.code,

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

@@ -46,6 +46,13 @@
     >
       {{ t('workflow.blocks.loop-data.buttons.insert') }}
     </ui-button>
+    <ui-input
+      v-else-if="data.loopThrough === 'google-sheets'"
+      :model-value="data.referenceKey"
+      :label="t('workflow.blocks.loop-data.refKey')"
+      class="w-full"
+      @change="updateData({ referenceKey: $event })"
+    />
     <div
       v-else-if="data.loopThrough === 'numbers'"
       class="flex items-center space-x-2"
@@ -135,7 +142,7 @@ const emit = defineEmits(['update:data']);
 const { t } = useI18n();
 
 const maxFileSize = 1024 * 1024;
-const loopTypes = ['data-columns', 'numbers', 'custom-data'];
+const loopTypes = ['data-columns', 'numbers', 'google-sheets', 'custom-data'];
 
 const state = shallowReactive({
   showOptions: false,

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

@@ -203,7 +203,7 @@ function handleKeydownEvent(event) {
   const { ctrlKey, altKey, metaKey, shiftKey, key } = event;
 
   if (ctrlKey || metaKey) keys.push('mod');
-  if (altKey) keys.push('alt');
+  if (altKey) keys.push('option');
   if (shiftKey) keys.push('shift');
 
   const isValidKey = !!allowedKeys[key] || /^[a-z0-9,./;'[\]\-=`]$/i.test(key);

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

@@ -76,14 +76,7 @@
       <ui-tab-panel value="body">
         <pre
           v-if="!showContentModalRef"
-          class="
-            rounded-lg
-            text-gray-200
-            p-4
-            max-h-80
-            bg-gray-900
-            overflow-auto
-          "
+          class="rounded-lg text-gray-200 p-4 max-h-80 bg-gray-900 overflow-auto"
           @click="showContentModalRef = true"
           v-text="data.body"
         />

+ 1 - 8
src/components/ui/UiCard.vue

@@ -2,14 +2,7 @@
   <component
     :is="tag"
     v-bind="$attrs"
-    class="
-      bg-white
-      dark:bg-gray-800
-      transform
-      rounded-lg
-      transition-transform
-      ui-card
-    "
+    class="bg-white dark:bg-gray-800 transform rounded-lg transition-transform ui-card"
     :class="[padding, { 'hover:shadow-xl hover:-translate-y-1': hover }]"
   >
     <slot></slot>

+ 2 - 18
src/components/ui/UiCheckbox.vue

@@ -2,14 +2,7 @@
   <label class="checkbox-ui inline-flex items-center">
     <div
       :class="{ 'pointer-events-none opacity-75': disabled }"
-      class="
-        relative
-        h-5
-        w-5
-        inline-block
-        focus-within:ring-2 focus-within:ring-accent
-        rounded
-      "
+      class="relative h-5 w-5 inline-block focus-within:ring-2 focus-within:ring-accent rounded"
     >
       <input
         type="checkbox"
@@ -19,16 +12,7 @@
         @change="changeHandler"
       />
       <div
-        class="
-          border
-          rounded
-          absolute
-          top-0
-          left-0
-          bg-input
-          checkbox-ui__mark
-          cursor-pointer
-        "
+        class="border rounded absolute top-0 left-0 bg-input checkbox-ui__mark cursor-pointer"
       >
         <v-remixicon
           name="riCheckLine"

+ 1 - 9
src/components/ui/UiListItem.vue

@@ -1,15 +1,7 @@
 <template>
   <component
     :is="tag"
-    class="
-      ui-list-item
-      rounded-lg
-      flex
-      items-center
-      transition
-      w-full
-      focus:outline-none
-    "
+    class="ui-list-item rounded-lg flex items-center transition w-full focus:outline-none"
     role="listitem"
     :class="[
       active ? color : 'hoverable',

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

@@ -7,18 +7,7 @@
       <transition name="modal" mode="out-in">
         <div
           v-if="show"
-          class="
-            bg-black
-            p-5
-            overflow-y-auto
-            bg-opacity-20
-            modal-ui__content-container
-            z-50
-            flex
-            justify-center
-            items-end
-            md:items-center
-          "
+          class="bg-black p-5 overflow-y-auto bg-opacity-20 modal-ui__content-container z-50 flex justify-center items-end md:items-center"
           :style="{ 'backdrop-filter': blur && 'blur(2px)' }"
           @click.self="closeModal"
         >

+ 1 - 9
src/components/ui/UiPagination.vue

@@ -15,15 +15,7 @@
         :value="modelValue"
         :max="maxPage"
         min="0"
-        class="
-          p-2
-          text-center
-          transition
-          w-10
-          appearance-none
-          bg-input
-          rounded-lg
-        "
+        class="p-2 text-center transition w-10 appearance-none bg-input rounded-lg"
         type="number"
         @click="$event.target.select()"
         @input="updatePage(+$event.target.value, $event.target)"

+ 1 - 8
src/components/ui/UiPopover.vue

@@ -5,14 +5,7 @@
     </div>
     <div
       ref="content"
-      class="
-        ui-popover__content
-        bg-white
-        dark:bg-gray-800
-        rounded-lg
-        shadow-xl
-        border
-      "
+      class="ui-popover__content bg-white dark:bg-gray-800 rounded-lg shadow-xl border"
       :class="[padding]"
     >
       <slot v-bind="{ isShow }"></slot>

+ 2 - 18
src/components/ui/UiRadio.vue

@@ -1,14 +1,7 @@
 <template>
   <label class="radio-ui inline-flex items-center">
     <div
-      class="
-        relative
-        h-5
-        w-5
-        inline-block
-        focus-within:ring-2 focus-within:ring-accent
-        rounded-full
-      "
+      class="relative h-5 w-5 inline-block focus-within:ring-2 focus-within:ring-accent rounded-full"
     >
       <input
         type="radio"
@@ -18,16 +11,7 @@
         @change="changeHandler"
       />
       <div
-        class="
-          border
-          rounded-full
-          absolute
-          top-0
-          left-0
-          bg-input
-          radio-ui__mark
-          cursor-pointer
-        "
+        class="border rounded-full absolute top-0 left-0 bg-input radio-ui__mark cursor-pointer"
       ></div>
     </div>
     <span v-if="$slots.default" class="ml-2 inline-block">

+ 1 - 13
src/components/ui/UiSelect.vue

@@ -18,19 +18,7 @@
         :id="selectId"
         :class="{ 'pl-8': prependIcon }"
         :value="modelValue"
-        class="
-          px-4
-          pr-10
-          transition
-          rounded-lg
-          bg-input bg-transparent
-          py-2
-          z-10
-          appearance-none
-          w-full
-          h-full
-          appearance-none
-        "
+        class="px-4 pr-10 transition rounded-lg bg-input bg-transparent py-2 z-10 appearance-none w-full h-full appearance-none"
         @change="emitValue"
       >
         <option v-if="placeholder" value="" disabled selected>

+ 81 - 0
src/components/ui/UiSwitch.vue

@@ -0,0 +1,81 @@
+<template>
+  <div
+    class="ui-switch relative inline-flex h-6 w-12 justify-center items-center bg-input p-1 rounded-full"
+    :class="{ 'pointer-events-none opacity-50': disabled }"
+  >
+    <input
+      :checked="modelValue"
+      type="checkbox"
+      class="absolute h-full w-full opacity-0 cursor-pointer left-0 top-0 z-50"
+      v-bind="{ disabled, readonly: disabled || null }"
+      @input="emitEvent"
+    />
+    <div
+      class="ui-switch__ball z-40 rounded-full absolute h-4 w-4 shadow-xl bg-white flex justify-center items-center"
+    >
+      <slot v-if="$slots.ball" name="ball"></slot>
+    </div>
+    <div
+      class="ui-switch__background absolute h-full rounded-md w-full left-0 top-0 bg-accent"
+    ></div>
+  </div>
+</template>
+<script>
+export default {
+  props: {
+    modelValue: {
+      type: Boolean,
+      default: false,
+    },
+    disabled: Boolean,
+  },
+  emits: ['update:modelValue', 'change'],
+  setup(props, { emit }) {
+    return {
+      emitEvent: () => {
+        const newValue = !props.modelValue;
+
+        emit('change', newValue);
+        emit('update:modelValue', newValue);
+      },
+    };
+  },
+};
+</script>
+<style scoped>
+.ui-switch {
+  overflow: hidden;
+  transition: all 250ms ease;
+}
+
+.ui-switch:active {
+  transform: scale(0.93);
+}
+
+.ui-switch__ball {
+  transition: all 250ms ease;
+  left: 6px;
+}
+
+.ui-switch__background {
+  transition: all 250ms ease;
+  margin-left: -100%;
+}
+
+.ui-switch:hover .ui-switch__ball {
+  transform: scale(1.1);
+}
+
+.ui-switch input:focus ~ .ui-switch__ball {
+  transform: scale(1.1);
+}
+
+.ui-switch input:checked ~ .ui-switch__ball {
+  background-color: white;
+  left: calc(100% - 21px);
+}
+
+.ui-switch input:checked ~ .ui-switch__background {
+  margin-left: 0;
+}
+</style>

+ 2 - 19
src/components/ui/UiTabs.vue

@@ -1,30 +1,13 @@
 <template>
   <div
     aria-role="tablist"
-    class="
-      ui-tabs
-      text-gray-600
-      dark:text-gray-200
-      border-b
-      flex
-      space-x-1
-      items-center
-      relative
-    "
+    class="ui-tabs text-gray-600 dark:text-gray-200 border-b flex space-x-1 items-center relative"
     @mouseleave="showHoverIndicator = false"
   >
     <div
       v-show="showHoverIndicator"
       ref="hoverIndicator"
-      class="
-        ui-tabs__indicator
-        z-0
-        top-[5px]
-        absolute
-        left-0
-        rounded-lg
-        bg-box-transparent
-      "
+      class="ui-tabs__indicator z-0 top-[5px] absolute left-0 rounded-lg bg-box-transparent"
     ></div>
     <slot></slot>
   </div>

+ 1 - 10
src/components/ui/UiTextarea.vue

@@ -6,16 +6,7 @@
     <textarea
       ref="textarea"
       v-bind="{ value: modelValue, placeholder, maxlength: max }"
-      class="
-        ui-textarea
-        w-full
-        ui-input
-        rounded-lg
-        px-4
-        py-2
-        transition
-        bg-input
-      "
+      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"

+ 123 - 0
src/composable/shortcut.js

@@ -0,0 +1,123 @@
+import { onUnmounted, onMounted } from 'vue';
+import Mousetrap from 'mousetrap';
+import { isObject } from '@/utils/helper';
+
+export const mapShortcuts = {
+  'page:dashboard': {
+    id: 'page:dashboard',
+    combo: 'option+1',
+  },
+  'page:workflows': {
+    id: 'page:workflows',
+    combo: 'option+2',
+  },
+  'page:collections': {
+    id: 'page:collections',
+    combo: 'option+3',
+  },
+  'page:logs': {
+    id: 'page:logs',
+    combo: 'option+4',
+  },
+  'page:settings': {
+    id: 'page:settings',
+    combo: 'option+5',
+  },
+  'action:search': {
+    id: 'action:search',
+    combo: 'mod+shift+f',
+  },
+  'editor:duplicate-block': {
+    id: 'editor:duplicate-block',
+    combo: 'mod+option+d',
+  },
+  'editor:save': {
+    id: 'editor:save',
+    combo: 'mod+shift+s',
+  },
+  'editor:execute-workflow': {
+    id: 'editor:execute-workflow',
+    combo: 'option+enter',
+  },
+};
+
+const os = navigator.appVersion.indexOf('Win') !== -1 ? 'win' : 'mac';
+export function getReadableShortcut(str) {
+  const list = {
+    option: {
+      win: 'alt',
+      mac: 'option',
+    },
+    mod: {
+      win: 'ctrl',
+      mac: '⌘',
+    },
+  };
+  const regex = new RegExp(Object.keys(list).join('|'), 'g');
+  const replacedStr = str.replace(regex, (match) => {
+    return list[match][os];
+  });
+
+  return replacedStr;
+}
+
+export function getShortcut(id, data) {
+  const shortcut = mapShortcuts[id] || {};
+
+  if (data) shortcut.data = data;
+  if (!shortcut.readable) {
+    shortcut.readable = getReadableShortcut(shortcut.combo);
+  }
+
+  return shortcut;
+}
+
+export function useShortcut(shortcuts, handler) {
+  Mousetrap.prototype.stopCallback = () => false;
+
+  const extractedShortcuts = {
+    ids: {},
+    keys: [],
+    data: {},
+  };
+  const handleShortcut = (event, combo) => {
+    const shortcutId = extractedShortcuts.ids[combo];
+    const params = {
+      event,
+      ...extractedShortcuts.data[shortcutId],
+    };
+
+    if (typeof params.data === 'function') {
+      params.data(params);
+    } else {
+      handler?.(params);
+    }
+  };
+  const addShortcutData = ({ combo, id, readable, ...rest }) => {
+    extractedShortcuts.ids[combo] = id;
+    extractedShortcuts.keys.push(combo);
+    extractedShortcuts.data[id] = { combo, id, readable, ...rest };
+  };
+
+  if (isObject(shortcuts)) {
+    addShortcutData(getShortcut(shortcuts.id, shortcuts.data));
+  } else if (typeof shortcuts === 'string') {
+    addShortcutData(getShortcut(shortcuts));
+  } else {
+    shortcuts.forEach((item) => {
+      const currentShortcut =
+        typeof item === 'string' ? getShortcut(item) : item;
+
+      addShortcutData(currentShortcut);
+    });
+  }
+
+  onMounted(() => {
+    Mousetrap.bind(extractedShortcuts.keys, handleShortcut);
+  });
+  onUnmounted(() => {
+    Mousetrap.unbind(extractedShortcuts.keys);
+  });
+
+  return extractedShortcuts.data;
+}

+ 2 - 2
src/content/blocks-handler/handler-link.js

@@ -1,8 +1,8 @@
-import { markElement } from '../helper';
+import { handleElement, markElement } from '../helper';
 
 function link(block) {
   return new Promise((resolve, reject) => {
-    const element = document.querySelector(block.data.selector);
+    const element = handleElement(block, { returnElement: true });
 
     if (!element) {
       reject(new Error('element-not-found'));

+ 3 - 30
src/content/element-selector/App.vue

@@ -4,44 +4,17 @@
       'select-none': state.isDragging,
       'bg-black bg-opacity-30': !state.hide,
     }"
-    class="
-      root
-      fixed
-      h-full
-      w-full
-      pointer-events-none
-      top-0
-      text-gray-900
-      left-0
-    "
+    class="root fixed h-full w-full pointer-events-none top-0 text-gray-900 left-0"
     style="z-index: 9999999999; font-family: Inter, sans-serif; font-size: 16px"
   >
     <div
       ref="cardEl"
       :style="{ transform: `translate(${cardRect.x}px, ${cardRect.y}px)` }"
       style="width: 320px"
-      class="
-        absolute
-        root-card
-        bg-white
-        shadow-xl
-        z-50
-        p-4
-        pointer-events-auto
-        rounded-lg
-      "
+      class="absolute root-card bg-white shadow-xl z-50 p-4 pointer-events-auto rounded-lg"
     >
       <div
-        class="
-          absolute
-          p-2
-          drag-button
-          shadow-xl
-          bg-white
-          p-1
-          cursor-move
-          rounded-lg
-        "
+        class="absolute p-2 drag-button shadow-xl bg-white p-1 cursor-move rounded-lg"
         style="top: -15px; left: -15px"
       >
         <v-remixicon

文件差異過大導致無法顯示
+ 0 - 0
src/lib/v-remixicon.js


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

@@ -76,6 +76,28 @@
         "select": "Select workflow",
         "description": ""
       },
+      "google-sheets": {
+        "name": "Google sheets",
+        "description": "Read Google Sheets data",
+        "previewData": "Preview data",
+        "firstRow": "Use the first row as keys",
+        "refKey": {
+          "label": "Reference key",
+          "placeholder": "Key name"
+        },
+        "spreadsheetId": {
+          "label": "Spreadsheet Id",
+          "link": "See how to get spreadsheet Id"
+        },
+        "range": {
+          "label": "Range",
+          "link": "Click to see more example"
+        },
+        "select": {
+          "get": "Get spreadsheet data",
+          "write": "Write spreadsheet data"
+        }
+      },
       "active-tab": {
         "name": "Active tab",
         "description": "Set current tab that you're in as an active tab"
@@ -292,6 +314,7 @@
         "name": "Loop data",
         "description": "Iterate through data columns or your custom data",
         "loopId": "Loop ID",
+        "refKey": "Reference key",
         "modal": {
           "fileTooLarge": "File too large to edit",
           "maxFile": "Max file size is 1MB",
@@ -315,7 +338,8 @@
           "options": {
             "numbers": "Numbers",
             "data-columns": "Data columns",
-            "custom-data": "Custom data"
+            "custom-data": "Custom data",
+            "google-sheets": "Google sheets"
           }
         }
       },

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

@@ -2,11 +2,18 @@
   "home": {
     "viewAll": "View all"
   },
+  "updateMessage": {
+    "text1": "Automa has been updated to v{version},",
+    "text2": "see what's new."
+  },
   "settings": {
     "language": {
       "label": "Language",
       "helpTranslate": "Can't find your language? Help translate.",
       "reloadPage": "Reload the page to take effect"
+    },
+    "menu": {
+      "general": "General"
     }
   },
   "workflow": {
@@ -89,11 +96,14 @@
       "invalid-url": "URL is not valid",
       "conditions-empty": "Conditions is empty",
       "invalid-proxy-host": "Invalid proxy host",
-      "invalid-body": "Content body is not valid",
       "workflow-disabled": "Workflow is disabled",
       "selector-empty": "Element selector is empty",
+      "invalid-body": "Content body is not valid JSON",
       "invalid-active-tab": "\"{url}\" is invalid URL",
+      "empty-spreadsheet-id": "Spreadsheet Id is empty",
+      "invalid-loop-data": "Invalid data to loop through",
       "empty-workflow": "You must select a workflow first",
+      "empty-spreadsheet-range": "Spreadsheet range is empty",
       "active-tab-removed": "Workflow active tab is removed",
       "stop-timeout": "Workflow is stopped because of timeout",
       "no-workflow": "Can't find workflow with \"{workflowId}\" ID",

+ 2 - 1
src/models/workflow.js

@@ -19,12 +19,13 @@ class Workflow extends Model {
       drawflow: this.attr(''),
       dataColumns: this.attr([]),
       description: this.string(''),
+      version: this.string(''),
       globalData: this.string('[{ "key": "value" }]'),
-      lastRunAt: this.number(),
       createdAt: this.number(),
       isDisabled: this.boolean(false),
       settings: this.attr({
         blockDelay: 0,
+        saveLog: true,
         timeout: 120000,
         onError: 'stop-workflow',
       }),

+ 28 - 0
src/newtab/App.vue

@@ -5,19 +5,45 @@
       <router-view />
     </main>
     <ui-dialog />
+    <div
+      v-if="isUpdated"
+      class="p-4 shadow-2xl z-50 fixed bottom-8 left-1/2 -translate-x-1/2 rounded-lg bg-accent text-white flex items-center"
+    >
+      <v-remixicon name="riInformationLine" class="mr-3" />
+      <p>
+        {{ t('updateMessage.text1', { version: currentVersion }) }}
+      </p>
+      <a
+        :href="`https://github.com/Kholid060/automa/releases/tag/v${currentVersion}`"
+        class="underline ml-1"
+      >
+        {{ t('updateMessage.text2') }}
+      </a>
+      <button class="ml-6 text-gray-300" @click="isUpdated = false">
+        <v-remixicon size="20" name="riCloseLine" />
+      </button>
+    </div>
   </template>
 </template>
 <script setup>
 import { ref, onMounted } from 'vue';
 import { useStore } from 'vuex';
+import { useI18n } from 'vue-i18n';
+import { compare } from 'compare-versions';
 import browser from 'webextension-polyfill';
 import { loadLocaleMessages, setI18nLanguage } from '@/lib/vue-i18n';
 import AppSidebar from '@/components/newtab/app/AppSidebar.vue';
 import { sendMessage } from '@/utils/message';
 
 const store = useStore();
+const { t } = useI18n();
+
 const retrieved = ref(false);
 
+const currentVersion = browser.runtime.getManifest().version;
+const prevVersion = localStorage.getItem('ext-version') || '0.0.0';
+const isUpdated = ref(compare(currentVersion, prevVersion, '>'));
+
 function handleStorageChanged(change) {
   if (change.logs) {
     store.dispatch('entities/create', {
@@ -55,5 +81,7 @@ onMounted(async () => {
     retrieved.value = true;
     console.error(error);
   }
+
+  localStorage.setItem('ext-version', currentVersion);
 });
 </script>

+ 10 - 1
src/newtab/pages/Collections.vue

@@ -6,8 +6,11 @@
     </p>
     <div class="flex items-center my-6 space-x-4">
       <ui-input
+        id="search-input"
         v-model="query"
-        :placeholder="`${t('common.search')}...`"
+        :placeholder="`${t('common.search')}... (${
+          shortcut['action:search'].readable
+        })`"
         prepend-icon="riSearch2Line"
         class="flex-1"
       />
@@ -48,11 +51,17 @@ import { ref, computed } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { sendMessage } from '@/utils/message';
 import { useDialog } from '@/composable/dialog';
+import { useShortcut } from '@/composable/shortcut';
 import Collection from '@/models/collection';
 import SharedCard from '@/components/newtab/shared/SharedCard.vue';
 
 const dialog = useDialog();
 const { t } = useI18n();
+const shortcut = useShortcut('action:search', () => {
+  const searchInput = document.querySelector('#search-input input');
+
+  searchInput?.focus();
+});
 
 const collectionCardMenu = [
   { id: 'rename', name: t('common.rename'), icon: 'riPencilLine' },

+ 25 - 37
src/newtab/pages/Settings.vue

@@ -1,52 +1,40 @@
 <template>
   <div class="container pt-8 pb-4">
     <h1 class="text-2xl font-semibold mb-10">{{ t('common.settings') }}</h1>
-    <div class="flex items-center">
-      <div id="languages">
-        <ui-select
-          :model-value="settings.locale"
-          :label="t('settings.language.label')"
-          class="w-80"
-          @change="updateLanguage"
+    <div class="flex items-start">
+      <ui-list class="w-64 mr-12 space-y-2">
+        <router-link
+          v-for="menu in menus"
+          :key="menu.id"
+          v-slot="{ href, navigate, isActive }"
+          custom
+          :to="menu.path"
         >
-          <option
-            v-for="locale in supportLocales"
-            :key="locale.id"
-            :value="locale.id"
+          <ui-list-item
+            :href="href"
+            :class="[
+              isActive
+                ? 'bg-box-transparent'
+                : 'text-gray-600 dark:text-gray-600',
+            ]"
+            tag="a"
+            @click="navigate"
           >
-            {{ locale.name }}
-          </option>
-        </ui-select>
-        <a
-          class="block text-sm text-gray-600 dark:text-gray-200 ml-1"
-          href="https://github.com/Kholid060/automa/wiki/Help-Translate"
-          target="_blank"
-          rel="noopener"
-        >
-          {{ t('settings.language.helpTranslate') }}
-        </a>
+            <v-remixicon :name="menu.icon" class="mr-2 -ml-1" />
+            {{ t(`settings.menu.${menu.id}`) }}
+          </ui-list-item>
+        </router-link>
+      </ui-list>
+      <div class="settings-content">
+        <router-view />
       </div>
-      <p v-if="isLangChange" class="inline-block ml-4">
-        {{ t('settings.language.reloadPage') }}
-      </p>
     </div>
   </div>
 </template>
 <script setup>
-import { computed, ref } from 'vue';
-import { useStore } from 'vuex';
 import { useI18n } from 'vue-i18n';
-import { supportLocales } from '@/utils/shared';
 
 const { t } = useI18n();
-const store = useStore();
-
-const isLangChange = ref(false);
-const settings = computed(() => store.state.settings);
-
-async function updateLanguage(value) {
-  isLangChange.value = true;
 
-  store.dispatch('updateSettings', { locale: value });
-}
+const menus = [{ id: 'general', path: '/settings', icon: 'riSettings3Line' }];
 </script>

+ 11 - 11
src/newtab/pages/Workflows.vue

@@ -5,9 +5,12 @@
     </h1>
     <div class="flex items-center mb-6 space-x-4">
       <ui-input
+        id="search-input"
         v-model="state.query"
+        :placeholder="`${t(`common.search`)}... (${
+          shortcut['action:search'].readable
+        })`"
         prepend-icon="riSearch2Line"
-        :placeholder="`${t(`common.search`)}...`"
         class="flex-1"
       />
       <div class="flex items-center workflow-sort">
@@ -38,16 +41,7 @@
           class="flex h-3 w-3 absolute top-0 right-0 -mr-1 -mt-1"
         >
           <span
-            class="
-              animate-ping
-              absolute
-              inline-flex
-              h-full
-              w-full
-              rounded-full
-              bg-primary
-              opacity-75
-            "
+            class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"
           ></span>
           <span
             class="relative inline-flex rounded-full h-3 w-3 bg-blue-600"
@@ -182,11 +176,17 @@ import { useI18n } from 'vue-i18n';
 import { useDialog } from '@/composable/dialog';
 import { sendMessage } from '@/utils/message';
 import { exportWorkflow, importWorkflow } from '@/utils/workflow-data';
+import { useShortcut } from '@/composable/shortcut';
 import SharedCard from '@/components/newtab/shared/SharedCard.vue';
 import Workflow from '@/models/workflow';
 
 const dialog = useDialog();
 const { t } = useI18n();
+const shortcut = useShortcut('action:search', () => {
+  const searchInput = document.querySelector('#search-input input');
+
+  searchInput?.focus();
+});
 
 const sorts = ['name', 'createdAt'];
 const menu = [

+ 4 - 38
src/newtab/pages/collections/[id].vue

@@ -4,12 +4,7 @@
       <input
         :value="collection.name"
         placeholder="Collection name"
-        class="
-          text-2xl
-          hover:ring-2 hover:ring-accent
-          font-semibold
-          bg-transparent
-        "
+        class="text-2xl hover:ring-2 hover:ring-accent font-semibold bg-transparent"
         @blur="updateCollection({ name: $event.target.value || 'Unnamed' })"
       />
       <div class="flex-grow"></div>
@@ -67,16 +62,7 @@
                 {{ t('common.running') }}
                 <span
                   v-if="runningCollection.length > 0"
-                  class="
-                    ml-2
-                    p-1
-                    text-center
-                    inline-block
-                    text-xs
-                    rounded-full
-                    bg-black
-                    text-white
-                  "
+                  class="ml-2 p-1 text-center inline-block text-xs rounded-full bg-black text-white"
                   style="min-width: 25px"
                 >
                   {{ runningCollection.length }}
@@ -98,19 +84,7 @@
           <ui-tab-panel class="relative" value="flow">
             <div
               v-if="collection.flow.length === 0"
-              class="
-                border
-                text-gray-600
-                absolute
-                top-0
-                w-full
-                z-0
-                dark:text-gray-200
-                rounded-lg
-                border-dashed
-                text-center
-                p-4
-              "
+              class="border text-gray-600 absolute top-0 w-full z-0 dark:text-gray-200 rounded-lg border-dashed text-center p-4"
             >
               {{ t('collection.dragDropText') }}
             </div>
@@ -123,15 +97,7 @@
             >
               <template #item="{ element, index }">
                 <ui-card
-                  class="
-                    group
-                    flex
-                    cursor-move
-                    mb-2
-                    items-center
-                    relative
-                    overflow-hidden
-                  "
+                  class="group flex cursor-move mb-2 items-center relative overflow-hidden"
                 >
                   <span
                     :class="[

+ 2 - 7
src/newtab/pages/logs/[id].vue

@@ -46,12 +46,7 @@
               <p
                 v-if="item.message"
                 :title="item.message"
-                class="
-                  text-sm
-                  leading-tight
-                  text-overflow text-gray-600
-                  dark:text-gray-200
-                "
+                class="text-sm leading-tight text-overflow text-gray-600 dark:text-gray-200"
               >
                 {{ item.message }}
               </p>
@@ -166,7 +161,7 @@ function translateLog(log) {
 
   copyLog.message = getTranslatation(
     { path: `log.messages.${log.message}`, params: log },
-    ''
+    log.message
   );
 
   return copyLog;

+ 49 - 0
src/newtab/pages/settings/index.vue

@@ -0,0 +1,49 @@
+<template>
+  <div class="flex items-center">
+    <div id="languages">
+      <ui-select
+        :model-value="settings.locale"
+        :label="t('settings.language.label')"
+        class="w-80"
+        @change="updateLanguage"
+      >
+        <option
+          v-for="locale in supportLocales"
+          :key="locale.id"
+          :value="locale.id"
+        >
+          {{ locale.name }}
+        </option>
+      </ui-select>
+      <a
+        class="block text-sm text-gray-600 dark:text-gray-200 ml-1"
+        href="https://github.com/Kholid060/automa/wiki/Help-Translate"
+        target="_blank"
+        rel="noopener"
+      >
+        {{ t('settings.language.helpTranslate') }}
+      </a>
+    </div>
+    <p v-if="isLangChange" class="inline-block ml-4">
+      {{ t('settings.language.reloadPage') }}
+    </p>
+  </div>
+</template>
+<script setup>
+import { computed, ref } from 'vue';
+import { useStore } from 'vuex';
+import { useI18n } from 'vue-i18n';
+import { supportLocales } from '@/utils/shared';
+
+const { t } = useI18n();
+const store = useStore();
+
+const isLangChange = ref(false);
+const settings = computed(() => store.state.settings);
+
+async function updateLanguage(value) {
+  isLangChange.value = true;
+
+  store.dispatch('updateSettings', { locale: value });
+}
+</script>

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

@@ -27,16 +27,7 @@
             {{ t('common.running') }}
             <span
               v-if="workflowState.length > 0"
-              class="
-                ml-2
-                p-1
-                text-center
-                inline-block
-                text-xs
-                rounded-full
-                bg-black
-                text-white
-              "
+              class="ml-2 p-1 text-center inline-block text-xs rounded-full bg-black text-white"
               style="min-width: 25px"
             >
               {{ workflowState.length }}
@@ -61,6 +52,8 @@
           v-if="activeTab === 'editor'"
           class="h-full w-full"
           :data="workflow.drawflow"
+          :version="workflow.version"
+          @update="updateWorkflow"
           @load="editor = $event"
           @deleteBlock="deleteBlock"
         />

+ 2 - 1
src/newtab/router.js

@@ -7,6 +7,7 @@ import CollectionsDetails from './pages/collections/[id].vue';
 import Logs from './pages/Logs.vue';
 import LogsDetails from './pages/logs/[id].vue';
 import Settings from './pages/Settings.vue';
+import SettingsIndex from './pages/settings/index.vue';
 
 const routes = [
   {
@@ -45,9 +46,9 @@ const routes = [
     component: LogsDetails,
   },
   {
-    name: 'settings',
     path: '/settings',
     component: Settings,
+    children: [{ path: '', component: SettingsIndex }],
   },
 ];
 

+ 13 - 0
src/utils/api.js

@@ -0,0 +1,13 @@
+import secrets from 'secrets';
+
+export function fetchApi(path, options) {
+  const urlPath = path.startsWith('/') ? path : `/${path}`;
+
+  return fetch(`${secrets.baseApiUrl}${urlPath}`, options);
+}
+
+export function getGoogleSheetsValue(spreadsheetId, range) {
+  const url = `/services/google-sheets?spreadsheetId=${spreadsheetId}&range=${range}`;
+
+  return fetchApi(url);
+}

+ 31 - 1
src/utils/helper.js

@@ -1,3 +1,33 @@
+export function convert2DArrayToArrayObj(values) {
+  let keyIndex = 0;
+  const keys = values.shift();
+  const result = [];
+
+  for (let columnIndex = 0; columnIndex < values.length; columnIndex += 1) {
+    const currentColumn = {};
+
+    for (
+      let rowIndex = 0;
+      rowIndex < values[columnIndex].length;
+      rowIndex += 1
+    ) {
+      let key = keys[rowIndex];
+
+      if (!key) {
+        keyIndex += 1;
+        key = `_row${keyIndex}`;
+        keys.push(key);
+      }
+
+      currentColumn[key] = values[columnIndex][rowIndex];
+
+      result.push(currentColumn);
+    }
+  }
+
+  return result;
+}
+
 export function parseJSON(data, def) {
   try {
     const result = JSON.parse(data);
@@ -66,7 +96,7 @@ export function toCamelCase(str) {
 }
 
 export function isObject(obj) {
-  return typeof obj === 'object' && obj !== null;
+  return typeof obj === 'object' && obj !== null && !Array.isArray(obj);
 }
 
 export function objectHasKey(obj, key) {

+ 0 - 74
src/utils/reference-data.js

@@ -1,74 +0,0 @@
-import { get, set } from 'object-path-immutable';
-import { isObject, objectHasKey, replaceMustache } from '@/utils/helper';
-
-const objectPath = { get, set };
-const refKeys = [
-  { name: 'dataColumn', key: 'dataColumns' },
-  { name: 'dataColumns', key: 'dataColumns' },
-];
-
-export function parseKey(key) {
-  /* eslint-disable-next-line */
-  let [dataKey, path] = key.split('@');
-
-  dataKey =
-    (refKeys.find((item) => item.name === dataKey) || {}).key || dataKey;
-
-  if (dataKey !== 'dataColumns') return { dataKey, path: path || '' };
-
-  const pathArr = path?.split('.') ?? [];
-  let dataPath = path;
-
-  if (pathArr.length === 1) {
-    dataPath = `0.${pathArr[0]}`;
-  } else if (typeof +pathArr[0] !== 'number' || Number.isNaN(+pathArr[0])) {
-    dataPath = `0.${pathArr.join('.')}`;
-  }
-
-  if (dataPath.endsWith('.')) dataPath = dataPath.slice(0, -1);
-
-  return { dataKey: 'dataColumns', path: dataPath };
-}
-export function replaceMustacheHandler(match, data) {
-  const key = match.slice(2, -2).replace(/\s/g, '');
-
-  if (!key) return '';
-
-  const { dataKey, path } = parseKey(key);
-  const result = objectPath.get(data[dataKey], path) ?? match;
-
-  return isObject(result) ? JSON.stringify(result) : result;
-}
-
-export default function (block, data) {
-  const replaceKeys = [
-    'url',
-    'name',
-    'body',
-    'value',
-    'fileName',
-    'selector',
-    'prefixText',
-    'globalData',
-    'suffixText',
-    'extraRowValue',
-  ];
-  let replacedBlock = block;
-
-  replaceKeys.forEach((blockDataKey) => {
-    if (!objectHasKey(block.data, blockDataKey)) return;
-
-    const newDataValue = replaceMustache(
-      replacedBlock.data[blockDataKey],
-      (match) => replaceMustacheHandler(match, data)
-    );
-
-    replacedBlock = objectPath.set(
-      replacedBlock,
-      `data.${blockDataKey}`,
-      newDataValue
-    );
-  });
-
-  return replacedBlock;
-}

+ 61 - 0
src/utils/reference-data/index.js

@@ -0,0 +1,61 @@
+import { set as setObjectPath } from 'object-path-immutable';
+import dayjs from 'dayjs';
+import { objectHasKey } from '@/utils/helper';
+import mustacheReplacer from './mustache-replacer';
+
+export const funcs = {
+  date(...args) {
+    let date = new Date();
+    let dateFormat = 'DD-MM-YYYY';
+
+    const getDateFormat = (value) =>
+      value ? value?.replace(/['"]/g, '') : dateFormat;
+
+    if (args.length === 1) {
+      dateFormat = getDateFormat(args[0]);
+    } else if (args.length >= 2) {
+      date = new Date(args[0]);
+      dateFormat = getDateFormat(args[1]);
+    }
+
+    /* eslint-disable-next-line */
+    const isValidDate = date instanceof Date && !isNaN(date);
+    const result = dayjs(isValidDate ? date : Date.now()).format(dateFormat);
+
+    return result;
+  },
+};
+
+export default function ({ block, data }) {
+  const replaceKeys = [
+    'url',
+    'name',
+    'body',
+    'value',
+    'fileName',
+    'selector',
+    'prefixText',
+    'globalData',
+    'suffixText',
+    'extraRowValue',
+  ];
+  let replacedBlock = { ...block };
+  const refData = Object.assign(data, { funcs });
+
+  replaceKeys.forEach((blockDataKey) => {
+    if (!objectHasKey(block.data, blockDataKey)) return;
+
+    const newDataValue = mustacheReplacer(
+      replacedBlock.data[blockDataKey],
+      refData
+    );
+
+    replacedBlock = setObjectPath(
+      replacedBlock,
+      `data.${blockDataKey}`,
+      newDataValue
+    );
+  });
+
+  return replacedBlock;
+}

+ 28 - 0
src/utils/reference-data/key-parser.js

@@ -0,0 +1,28 @@
+import { objectHasKey } from '../helper';
+
+const refKeys = {
+  dataColumn: 'dataColumns',
+  dataColumns: 'dataColumns',
+};
+
+export default function (key) {
+  /* eslint-disable-next-line */
+  let [dataKey, path] = key.split('@');
+
+  dataKey = objectHasKey(refKeys, dataKey) ? refKeys[dataKey] : dataKey;
+
+  if (dataKey !== 'dataColumns') return { dataKey, path: path || '' };
+
+  const pathArr = path?.split('.') ?? [];
+  let dataPath = path;
+
+  if (pathArr.length === 1) {
+    dataPath = `0.${pathArr[0]}`;
+  } else if (typeof +pathArr[0] !== 'number' || Number.isNaN(+pathArr[0])) {
+    dataPath = `0.${pathArr.join('.')}`;
+  }
+
+  if (dataPath.endsWith('.')) dataPath = dataPath.slice(0, -1);
+
+  return { dataKey: 'dataColumns', path: dataPath };
+}

+ 37 - 0
src/utils/reference-data/mustache-replacer.js

@@ -0,0 +1,37 @@
+import { get as getObjectPath } from 'object-path-immutable';
+import { replaceMustache, isObject } from '../helper';
+import keyParser from './key-parser';
+
+export function extractStrFunction(str) {
+  const extractedStr = /^\$\s*(\w+)\s*\((.*)\)/.exec(str.trim());
+
+  if (!extractedStr) return null;
+
+  return {
+    name: extractedStr[1],
+    params: extractedStr[2].split(','),
+  };
+}
+
+export default function (str, data) {
+  const replacedStr = replaceMustache(str, (match) => {
+    const key = match.slice(2, -2).replace(/\s/g, '');
+
+    if (!key) return '';
+
+    const funcRef = extractStrFunction(key);
+
+    if (funcRef && data.funcs[funcRef.name]) {
+      return data.funcs[funcRef.name]?.apply({ refData: data }, funcRef.params);
+    }
+
+    const { dataKey, path } = keyParser(key);
+    const result = getObjectPath(data[dataKey], path) ?? match;
+
+    return isObject(result) || Array.isArray(result)
+      ? JSON.stringify(result)
+      : result;
+  });
+
+  return replacedStr;
+}

+ 25 - 0
src/utils/shared.js

@@ -413,6 +413,26 @@ export const tasks = {
       eventParams: { bubbles: true, cancelable: false },
     },
   },
+  'google-sheets': {
+    name: 'Google sheets',
+    description: 'Read Google Sheets data',
+    icon: 'mdiGoogleSheet',
+    component: 'BlockBasic',
+    editComponent: 'EditGoogleSheets',
+    category: 'onlineServices',
+    inputs: 1,
+    outputs: 1,
+    docs: true,
+    allowedInputs: true,
+    maxConnection: 1,
+    data: {
+      spreadsheetId: '',
+      type: 'get',
+      range: '',
+      firstRowAsKey: false,
+      refKey: '',
+    },
+  },
   conditions: {
     name: 'Conditions',
     description: 'Conditional block',
@@ -485,6 +505,7 @@ export const tasks = {
       toNumber: 10,
       loopData: '[]',
       description: '',
+      referenceKey: '',
       loopThrough: 'data-columns',
     },
   },
@@ -552,6 +573,10 @@ export const categories = {
     name: 'General',
     color: 'bg-yellow-200',
   },
+  onlineServices: {
+    name: 'Online services',
+    color: 'bg-red-200',
+  },
   conditions: {
     name: 'Conditions',
     color: 'bg-blue-200',

+ 4 - 3
src/utils/workflow-data.js

@@ -21,13 +21,14 @@ export function importWorkflow() {
 
 export function exportWorkflow(workflow) {
   const keys = [
-    'dataColumns',
-    'drawflow',
-    'icon',
     'name',
+    'icon',
+    'version',
+    'drawflow',
     'settings',
     'globalData',
     'description',
+    'dataColumns',
   ];
   const content = {
     extVersion: chrome.runtime.getManifest().version,

+ 3 - 4
tailwind.config.js

@@ -1,16 +1,15 @@
 const colors = require('tailwindcss/colors');
 
 module.exports = {
-  mode: 'jit',
-  purge: ['./src/**/*.{js,jsx,ts,tsx,vue}'],
+  content: ['./src/**/*.{js,jsx,ts,tsx,vue}'],
   darkMode: 'class', // or 'media' or 'class'
   theme: {
     extend: {
       colors: {
         primary: colors.blue['500'],
         secondary: colors.blue['400'],
-        accent: colors.gray['900'],
-        gray: colors.gray,
+        accent: colors.zinc['900'],
+        gray: colors.zinc,
         orange: colors.orange,
       },
       fontFamily: {

文件差異過大導致無法顯示
+ 216 - 308
yarn.lock


部分文件因文件數量過多而無法顯示