Ahmad Kholid 3 years ago
parent
commit
dbe8f8eced
60 changed files with 1308 additions and 866 deletions
  1. 2 1
      package.json
  2. 17 11
      src/background/collection-engine/flow-handler.js
  3. 141 82
      src/background/collection-engine/index.js
  4. 95 92
      src/background/index.js
  5. 57 0
      src/background/message-listener.js
  6. 10 6
      src/background/workflow-engine/blocks-handler/handler-active-tab.js
  7. 5 7
      src/background/workflow-engine/blocks-handler/handler-close-tab.js
  8. 17 13
      src/background/workflow-engine/blocks-handler/handler-execute-workflow.js
  9. 10 3
      src/background/workflow-engine/blocks-handler/handler-export-data.js
  10. 2 2
      src/background/workflow-engine/blocks-handler/handler-forward-page.js
  11. 2 2
      src/background/workflow-engine/blocks-handler/handler-go-back.js
  12. 1 1
      src/background/workflow-engine/blocks-handler/handler-google-sheets.js
  13. 12 7
      src/background/workflow-engine/blocks-handler/handler-interaction-block.js
  14. 1 1
      src/background/workflow-engine/blocks-handler/handler-loop-breakpoint.js
  15. 6 7
      src/background/workflow-engine/blocks-handler/handler-loop-data.js
  16. 15 29
      src/background/workflow-engine/blocks-handler/handler-new-tab.js
  17. 6 7
      src/background/workflow-engine/blocks-handler/handler-switch-to.js
  18. 4 4
      src/background/workflow-engine/blocks-handler/handler-take-screenshot.js
  19. 2 2
      src/background/workflow-engine/blocks-handler/handler-trigger.js
  20. 245 302
      src/background/workflow-engine/engine.js
  21. 0 26
      src/background/workflow-engine/error-message.js
  22. 1 0
      src/background/workflow-engine/execute-content-script.js
  23. 0 8
      src/background/workflow-engine/index.js
  24. 16 0
      src/background/workflow-logger.js
  25. 102 44
      src/background/workflow-state.js
  26. 9 8
      src/components/newtab/logs/LogsDataViewer.vue
  27. 23 20
      src/components/newtab/shared/SharedWorkflowState.vue
  28. 68 11
      src/components/newtab/workflow/WorkflowBuilder.vue
  29. 6 10
      src/components/newtab/workflow/WorkflowSettings.vue
  30. 64 0
      src/components/newtab/workflow/edit/EditExportData.vue
  31. 6 0
      src/components/newtab/workflow/edit/EditGoogleSheets.vue
  32. 4 0
      src/composable/shortcut.js
  33. 14 10
      src/content/element-selector/App.vue
  34. 11 13
      src/content/element-selector/index.js
  35. 4 1
      src/content/element-selector/main.js
  36. 99 0
      src/content/executed-block.js
  37. 23 9
      src/content/index.js
  38. 14 0
      src/content/services/index.js
  39. 55 0
      src/content/services/shortcut-listener.js
  40. 9 62
      src/content/services/web-service.js
  41. 10 1
      src/locales/en/blocks.json
  42. 3 1
      src/locales/en/newtab.json
  43. 1 1
      src/manifest.json
  44. 1 0
      src/models/log.js
  45. 1 1
      src/models/workflow.js
  46. 4 1
      src/newtab/App.vue
  47. 15 7
      src/newtab/pages/Collections.vue
  48. 4 1
      src/newtab/pages/Home.vue
  49. 18 16
      src/newtab/pages/Logs.vue
  50. 14 7
      src/newtab/pages/Workflows.vue
  51. 1 3
      src/newtab/pages/collections/[id].vue
  52. 9 2
      src/newtab/pages/logs/[id].vue
  53. 5 3
      src/newtab/pages/workflows/[id].vue
  54. 4 1
      src/store/index.js
  55. 6 0
      src/utils/helper.js
  56. 7 6
      src/utils/reference-data/index.js
  57. 14 10
      src/utils/reference-data/mustache-replacer.js
  58. 6 2
      src/utils/shared.js
  59. 2 2
      webpack.config.js
  60. 5 0
      yarn.lock

+ 2 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "0.12.1",
+  "version": "0.13.0",
   "description": "An extension for automating your browser by connecting blocks",
   "license": "MIT",
   "repository": {
@@ -29,6 +29,7 @@
     "@vuex-orm/core": "^0.36.4",
     "compare-versions": "^4.1.2",
     "dayjs": "^1.10.7",
+    "defu": "^5.0.0",
     "drawflow": "^0.0.51",
     "idb": "^7.0.0",
     "mousetrap": "^1.6.5",

+ 17 - 11
src/background/collection-engine/flow-handler.js

@@ -1,4 +1,5 @@
-import workflowEngine from '../workflow-engine';
+import WorkflowEngine from '../workflow-engine/engine';
+import blocksHandler from '../workflow-engine/blocks-handler';
 import dataExporter from '@/utils/data-exporter';
 
 export function workflow(flow) {
@@ -24,22 +25,27 @@ export function workflow(flow) {
     }
 
     const { globalData } = this.collection;
-    this.currentWorkflow = currentWorkflow;
 
-    const engine = workflowEngine(currentWorkflow, {
-      isInCollection: true,
-      collectionLogId: this.id,
-      collectionId: this.collection.id,
+    const engine = new WorkflowEngine(currentWorkflow, {
+      blocksHandler,
+      states: this.states,
+      logger: this.logger,
+      parentWorkflow: {
+        id: this.id,
+        isCollection: true,
+        name: this.collection.name,
+      },
       globalData: globalData.trim() === '' ? null : globalData,
     });
 
-    this.workflowEngine = engine;
+    this.executedWorkflow.data = {
+      id: engine.id,
+      name: currentWorkflow.name,
+      icon: currentWorkflow.icon,
+      workflowId: currentWorkflow.id,
+    };
 
     engine.init();
-    engine.on('update', (state) => {
-      this.workflowState = state;
-      this.updateState();
-    });
     engine.on('destroyed', ({ id, status, message }) => {
       this.data.push({
         id,

+ 141 - 82
src/background/collection-engine/index.js

@@ -2,23 +2,54 @@ import { nanoid } from 'nanoid';
 import browser from 'webextension-polyfill';
 import { toCamelCase } from '@/utils/helper';
 import * as flowHandler from './flow-handler';
-import workflowState from '../workflow-state';
-import workflowEngine from '../workflow-engine';
+import blocksHandler from '../workflow-engine/blocks-handler';
+import WorkflowEngine from '../workflow-engine/engine';
+
+const executedWorkflows = (workflows, options) => {
+  if (workflows.length === 0) return;
+
+  const workflow = workflows.shift();
+  const engine = new WorkflowEngine(workflow, options);
+
+  engine.init();
+
+  setTimeout(() => {
+    executedWorkflows(workflows, options);
+  }, 500);
+};
 
 class CollectionEngine {
-  constructor(collection) {
+  constructor(collection, { states, logger }) {
     this.id = nanoid();
+    this.states = states;
+    this.logger = logger;
     this.collection = collection;
-    this.workflows = [];
+
     this.data = [];
-    this.logs = [];
+    this.history = [];
+    this.workflows = [];
+
     this.isDestroyed = false;
     this.currentFlow = null;
-    this.workflowState = null;
-    this.workflowEngine = null;
-    this.currentWorkflow = null;
     this.currentIndex = 0;
     this.eventListeners = {};
+
+    this.executedWorkflow = {
+      data: null,
+      state: null,
+    };
+
+    this.onStatesUpdated = ({ data, id }) => {
+      if (id === this.executedWorkflow.data?.id) {
+        this.executedWorkflow.state = data.state;
+        this.states.update(this.id, { state: this.state });
+      }
+    };
+    this.onStatesStopped = (id) => {
+      if (id !== this.id) return;
+
+      this.stop();
+    };
   }
 
   async init() {
@@ -31,23 +62,35 @@ class CollectionEngine {
       this.startedTimestamp = Date.now();
 
       if (this.collection?.options.atOnce) {
-        this.collection.flow.forEach(({ itemId, type }) => {
-          if (type !== 'workflow') return;
+        const filteredWorkflows = this.collection.flow.reduce(
+          (acc, { itemId, type }) => {
+            if (type !== 'workflow') return acc;
 
-          const currentWorkflow = workflows.find(({ id }) => id === itemId);
+            const currentWorkflow = workflows.find(({ id }) => id === itemId);
 
-          if (currentWorkflow) {
-            const engine = workflowEngine(currentWorkflow, {});
+            if (currentWorkflow) {
+              acc.push(currentWorkflow);
+            }
 
-            engine.init();
-          }
+            return acc;
+          },
+          []
+        );
+
+        executedWorkflows(filteredWorkflows, {
+          blocksHandler,
+          states: this.states,
+          logger: this.logger,
         });
       } else {
-        await workflowState.add(this.id, {
+        await this.states.add(this.id, {
           state: this.state,
-          isCollection: true,
           collectionId: this.collection.id,
         });
+
+        this.states.on('stop', this.onStatesStopped);
+        this.states.on('update', this.onStatesUpdated);
+
         this._flowHandler(this.collection.flow[0]);
       }
     } catch (error) {
@@ -72,28 +115,35 @@ class CollectionEngine {
   }
 
   async destroy(status) {
-    this.isDestroyed = true;
-    this.dispatchEvent('destroyed', { id: this.id });
+    try {
+      if (this.isDestroyed) return;
 
-    const { logs } = await browser.storage.local.get('logs');
-    const { name, icon } = this.collection;
+      this.isDestroyed = true;
+      this.dispatchEvent('destroyed', { id: this.id });
 
-    logs.push({
-      name,
-      icon,
-      status,
-      id: this.id,
-      data: this.data,
-      history: this.logs,
-      endedAt: Date.now(),
-      collectionId: this.collection.id,
-      startedAt: this.startedTimestamp,
-    });
+      const { name, icon } = this.collection;
+
+      await this.logger.add({
+        name,
+        icon,
+        status,
+        id: this.id,
+        data: this.data,
+        endedAt: Date.now(),
+        history: this.history,
+        collectionId: this.collection.id,
+        startedAt: this.startedTimestamp,
+      });
+
+      await this.states.delete(this.id);
 
-    await browser.storage.local.set({ logs });
-    await workflowState.delete(this.id);
+      this.states.off('stop', this.onStatesStopped);
+      this.states.off('update', this.onStatesUpdated);
 
-    this.listeners = {};
+      this.listeners = {};
+    } catch (error) {
+      console.error(error);
+    }
   }
 
   nextFlow() {
@@ -113,36 +163,45 @@ class CollectionEngine {
       id: this.id,
       currentBlock: [],
       name: this.collection.name,
+      currentIndex: this.currentIndex,
+      executedWorkflow: this.executedWorkflow,
       startedTimestamp: this.startedTimestamp,
     };
 
-    if (this.currentWorkflow) {
-      const { name, icon } = this.currentWorkflow;
-
-      data.currentBlock.push({ name, icon });
+    if (this.executedWorkflow.data) {
+      data.currentBlock.push(this.executedWorkflow.data);
     }
 
-    if (this.workflowState) {
-      const { name } = this.workflowState.currentBlock;
-
-      data.currentBlock.push({ name });
+    if (this.executedWorkflow.state) {
+      data.currentBlock.push(this.executedWorkflow.state.currentBlock);
     }
 
     return data;
   }
 
-  updateState() {
-    workflowState.update(this.id, this.state);
+  async stop() {
+    try {
+      if (this.executedWorkflow.data) {
+        await this.states.stop(this.executedWorkflow.data.id);
+      }
+
+      setTimeout(() => {
+        this.destroy('stopped');
+      }, 1000);
+    } catch (error) {
+      console.error(error);
+    }
   }
 
-  stop() {
-    this.workflowEngine.stop();
+  async _flowHandler(flow) {
+    const currentState = await this.states.get(this.id);
 
-    this.destroy('stopped');
-  }
+    if (!currentState || currentState.isDestroyed) {
+      if (this.isDestroyed) return;
 
-  _flowHandler(flow) {
-    if (this.isDestroyed) return;
+      await this.destroy('stopped');
+      return;
+    }
 
     const handlerName =
       flow.type === 'workflow' ? 'workflow' : toCamelCase(flow.itemId);
@@ -150,41 +209,41 @@ class CollectionEngine {
     const started = Date.now();
 
     this.currentFlow = flow;
-    this.updateState();
+    await this.states.update(this.id, { state: this.state });
+
+    if (!handler) {
+      console.error(`"${flow.type}" flow doesn't have a handler`);
+      return;
+    }
 
-    if (handler) {
+    try {
       if (flow.type !== 'workflow') {
-        this.workflowState = null;
-        this.currentWorkflow = null;
-        this.workflowEngine = null;
+        this.executedWorkflow = {
+          data: null,
+          state: null,
+        };
       }
 
-      handler
-        .call(this, flow)
-        .then((data) => {
-          this.logs.push({
-            type: data.type || 'success',
-            name: data.name,
-            logId: data.id,
-            message: data.message,
-            duration: Math.round(Date.now() - started),
-          });
-
-          this.nextFlow();
-        })
-        .catch((error) => {
-          this.logs.push({
-            type: 'error',
-            name: error.name,
-            logId: error.id,
-            message: error.message,
-            duration: Math.round(Date.now() - started),
-          });
-
-          this.nextFlow();
-        });
-    } else {
-      console.error(`"${flow.type}" flow doesn't have a handler`);
+      const data = await handler.call(this, flow);
+
+      this.history.push({
+        type: data.type || 'success',
+        name: data.name,
+        logId: data.id,
+        message: data.message,
+        duration: Math.round(Date.now() - started),
+      });
+      this.nextFlow();
+    } catch (error) {
+      this.history.push({
+        type: 'error',
+        name: error.name,
+        logId: error.id,
+        message: error.message,
+        duration: Math.round(Date.now() - started),
+      });
+
+      this.nextFlow();
     }
   }
 }

+ 95 - 92
src/background/index.js

@@ -1,101 +1,113 @@
 import browser from 'webextension-polyfill';
-import { objectHasKey } from '@/utils/helper';
 import { MessageListener } from '@/utils/message';
 import { registerSpecificDay } from '../utils/workflow-trigger';
-import workflowState from './workflow-state';
-import workflowEngine from './workflow-engine';
+import WorkflowState from './workflow-state';
 import CollectionEngine from './collection-engine';
+import WorkflowEngine from './workflow-engine/engine';
+import blocksHandler from './workflow-engine/blocks-handler';
+import WorkflowLogger from './workflow-logger';
+
+const storage = {
+  async get(key) {
+    try {
+      const result = await browser.storage.local.get(key);
+
+      return result[key];
+    } catch (error) {
+      console.error(error);
+      return [];
+    }
+  },
+  async set(key, value) {
+    await browser.storage.local.set({ [key]: value });
 
-function getWorkflow(workflowId) {
-  return new Promise((resolve) => {
-    browser.storage.local.get('workflows').then(({ workflows }) => {
-      const workflow = workflows.find(({ id }) => id === workflowId);
-
-      resolve(workflow);
-    });
-  });
-}
-
-const runningWorkflows = {};
-const runningCollections = {};
-
-async function executeWorkflow(workflow, tabId) {
-  try {
-    const engine = workflowEngine(workflow, { tabId });
-
-    runningWorkflows[engine.id] = engine;
-
-    engine.init();
-    engine.on('destroyed', ({ id }) => {
-      delete runningWorkflows[id];
+    if (key === 'workflowState') {
+      sessionStorage.setItem(key, JSON.stringify(value));
+    }
+  },
+};
+const workflow = {
+  states: new WorkflowState({ storage }),
+  logger: new WorkflowLogger({ storage }),
+  async get(workflowId) {
+    const { workflows } = await browser.storage.local.get('workflows');
+    const findWorkflow = workflows.find(({ id }) => id === workflowId);
+
+    return findWorkflow;
+  },
+  execute(workflowData, options) {
+    const engine = new WorkflowEngine(workflowData, {
+      ...options,
+      blocksHandler,
+      logger: this.logger,
+      states: this.states,
     });
 
-    return true;
-  } catch (error) {
-    console.error(error);
-    return error;
-  }
-}
-function executeCollection(collection) {
-  const engine = new CollectionEngine(collection);
-
-  runningCollections[engine.id] = engine;
+    if (options?.resume) {
+      engine.resume(options.state);
+    } else {
+      engine.init();
+    }
 
-  engine.init();
-  engine.on('destroyed', (id) => {
-    delete runningWorkflows[id];
+    return engine;
+  },
+};
+
+async function checkWorkflowStates() {
+  const states = await workflow.states.get();
+  // const sessionStates = parseJSON(sessionStorage.getItem('workflowState'), {});
+
+  Object.values(states || {}).forEach((state) => {
+    /* Enable when using manifest 3 */
+    // const resumeWorkflow =
+    //   !state.isDestroyed && objectHasKey(sessionStates, state.id);
+
+    if (false) {
+      workflow.get(state.workflowId).then((workflowData) => {
+        workflow.execute(workflowData, {
+          state,
+          resume: true,
+        });
+      });
+    } else {
+      delete states[state.id];
+    }
   });
 
-  return true;
+  await storage.set('workflowState', states);
 }
-async function checkRunnigWorkflows() {
-  try {
-    const result = await browser.storage.local.get('workflowState');
+checkWorkflowStates();
+async function checkVisitWebTriggers(states, tab) {
+  const visitWebTriggers = await storage.get('visitWebTriggers');
+  const triggeredWorkflow = visitWebTriggers.find(({ url, isRegex }) => {
+    if (url.trim() === '') return false;
 
-    result.workflowState.forEach(({ id }, index) => {
-      if (objectHasKey(runningWorkflows, id)) return;
+    return tab.url.match(isRegex ? new RegExp(url, 'g') : url);
+  });
 
-      result.workflowState.splice(index, 1);
-    });
+  if (triggeredWorkflow) {
+    const workflowData = await workflow.get(triggeredWorkflow.id);
 
-    browser.storage.local.set({ workflowState: result.workflowState });
-  } catch (error) {
-    console.error(error);
+    if (workflowData) workflow.execute(workflowData);
   }
 }
-checkRunnigWorkflows();
-
 browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
   if (changeInfo.status === 'complete') {
-    const { visitWebTriggers } = await browser.storage.local.get(
-      'visitWebTriggers'
-    );
-    const trigger = visitWebTriggers.find(({ url, isRegex }) => {
-      if (url.trim() === '') return false;
-
-      return tab.url.match(isRegex ? new RegExp(url, 'g') : url);
-    });
-    const state = await workflowState.get((item) => item.state.tabId === tabId);
-
-    if (trigger && state.length === 0) {
-      const workflow = await getWorkflow(trigger.id);
-
-      if (workflow) executeWorkflow(workflow, tabId);
-    }
+    await checkVisitWebTriggers(null, tab);
   }
 });
 browser.alarms.onAlarm.addListener(({ name }) => {
-  getWorkflow(name).then((workflow) => {
-    if (!workflow) return;
+  workflow.get(name).then((currentWorkflow) => {
+    if (!currentWorkflow) return;
 
-    executeWorkflow(workflow);
+    workflow.execute(currentWorkflow);
 
     const triggerBlock = Object.values(
-      JSON.parse(workflow.drawflow).drawflow.Home.data
+      JSON.parse(currentWorkflow.drawflow).drawflow.Home.data
     ).find((block) => block.name === 'trigger');
 
     if (triggerBlock?.data.type === 'specific-day') {
-      registerSpecificDay(workflow.id, triggerBlock.data);
+      registerSpecificDay(currentWorkflow.id, triggerBlock.data);
     }
   });
 });
@@ -108,7 +120,7 @@ chrome.runtime.onInstalled.addListener((details) => {
         shortcuts: {},
         workflows: [],
         collections: [],
-        workflowState: [],
+        workflowState: {},
         isFirstTime: true,
         visitWebTriggers: [],
       })
@@ -157,28 +169,19 @@ message.on('get:sender', (_, sender) => {
   return sender;
 });
 
-message.on('collection:execute', executeCollection);
-message.on('collection:stop', (id) => {
-  const collection = runningCollections[id];
-  if (!collection) {
-    workflowState.delete(id);
-    return;
-  }
-
-  collection.stop();
+message.on('collection:execute', (collection) => {
+  const engine = new CollectionEngine(collection, {
+    states: workflow.states,
+    logger: workflow.logger,
+  });
+  engine.init();
 });
 
-message.on('workflow:check-state', checkRunnigWorkflows);
-message.on('workflow:execute', (workflow) => executeWorkflow(workflow));
-message.on('workflow:stop', (id) => {
-  const workflow = runningWorkflows[id];
-
-  if (!workflow) {
-    workflowState.delete(id);
-    return;
-  }
-
-  workflow.stop();
+message.on('workflow:execute', (param) => {
+  workflow.execute(param);
+});
+message.on('workflow:stop', async (id) => {
+  await workflow.states.stop(id);
 });
 
 browser.runtime.onMessage.addListener(message.listener());

+ 57 - 0
src/background/message-listener.js

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

+ 10 - 6
src/background/workflow-engine/blocks-handler/handler-active-tab.js

@@ -11,8 +11,8 @@ async function activeTab(block) {
       data: '',
     };
 
-    if (this.tabId) {
-      await browser.tabs.update(this.tabId, { active: true });
+    if (this.activeTab.id) {
+      await browser.tabs.update(this.activeTab.id, { active: true });
 
       return data;
     }
@@ -29,11 +29,15 @@ async function activeTab(block) {
       throw error;
     }
 
-    this.frames = await executeContentScript(tab.id);
+    const frames = await executeContentScript(tab.id);
 
-    this.frameId = 0;
-    this.tabId = tab.id;
-    this.activeTabUrl = tab.url;
+    this.activeTab = {
+      ...this.activeTab,
+      frames,
+      frameId: 0,
+      id: tab.id,
+      url: tab.url,
+    };
     this.windowId = tab.windowId;
 
     return data;

+ 5 - 7
src/background/workflow-engine/blocks-handler/handler-close-tab.js

@@ -1,15 +1,14 @@
 import browser from 'webextension-polyfill';
 import { getBlockConnection } from '../helper';
 
-async function closeTab(block) {
-  const nextBlockId = getBlockConnection(block);
+async function closeTab({ data, outputs }) {
+  const nextBlockId = getBlockConnection({ outputs });
 
   try {
-    const { data } = block;
     let tabIds;
 
-    if (data.activeTab && this.tabId) {
-      tabIds = this.tabId;
+    if (data.activeTab && this.activeTab.id) {
+      tabIds = this.activeTab.id;
     } else if (data.url) {
       tabIds = (await browser.tabs.query({ url: data.url })).map(
         (tab) => tab.id
@@ -23,8 +22,7 @@ async function closeTab(block) {
       data: '',
     };
   } catch (error) {
-    const errorInstance = typeof error === 'string' ? new Error(error) : error;
-    errorInstance.nextBlockId = nextBlockId;
+    error.nextBlockId = nextBlockId;
 
     throw error;
   }

+ 17 - 13
src/background/workflow-engine/blocks-handler/handler-execute-workflow.js

@@ -1,15 +1,16 @@
 import browser from 'webextension-polyfill';
-import WorkflowEngine from '../index';
+import WorkflowEngine from '../engine';
 import { getBlockConnection } from '../helper';
+import { isWhitespace } from '@/utils/helper';
 
 function workflowListener(workflow, options) {
   return new Promise((resolve, reject) => {
     const engine = new WorkflowEngine(workflow, options);
     engine.init();
-    engine.on('destroyed', ({ id, status, message, currentBlock }) => {
+    engine.on('destroyed', ({ id, status, message }) => {
       if (status === 'error') {
         const error = new Error(message);
-        error.data = { logId: id, name: currentBlock.name };
+        error.data = { logId: id };
 
         reject(error);
         return;
@@ -38,17 +39,20 @@ async function executeWorkflow(block) {
 
       throw errorInstance;
     }
-
-    const onInit = (engine) => {
-      this.childWorkflow = engine;
-    };
     const options = {
-      events: { onInit },
-      isChildWorkflow: true,
-      collectionLogId: this.id,
-      collectionId: this.workflow.id,
-      parentWorkflow: { name: this.workflow.name },
-      globalData: !/\S/g.test(data.globalData) ? null : data.globalData,
+      parentWorkflow: {
+        id: this.id,
+        name: this.workflow.name,
+      },
+      events: {
+        onInit: (engine) => {
+          this.childWorkflowId = engine.id;
+        },
+      },
+      states: this.states,
+      logger: this.logger,
+      blocksHandler: this.blocksHandler,
+      globalData: isWhitespace(data.globalData) ? null : data.globalData,
     };
 
     if (workflow.drawflow.includes(this.workflow.id)) {

+ 10 - 3
src/background/workflow-engine/blocks-handler/handler-export-data.js

@@ -1,13 +1,20 @@
 import { getBlockConnection } from '../helper';
 import dataExporter from '@/utils/data-exporter';
 
-function exportData(block) {
+function exportData({ data, outputs }) {
   return new Promise((resolve) => {
-    dataExporter(this.data, block.data);
+    const dataToExport = data.dataToExport || 'data-columns';
+    let payload = this.referenceData.dataColumns;
+
+    if (dataToExport === 'google-sheets') {
+      payload = this.referenceData.googleSheets[data.refKey] || [];
+    }
+
+    dataExporter(payload, data);
 
     resolve({
       data: '',
-      nextBlockId: getBlockConnection(block),
+      nextBlockId: getBlockConnection({ outputs }),
     });
   });
 }

+ 2 - 2
src/background/workflow-engine/blocks-handler/handler-forward-page.js

@@ -5,7 +5,7 @@ function forwardPage(block) {
   return new Promise((resolve, reject) => {
     const nextBlockId = getBlockConnection(block);
 
-    if (!this.tabId) {
+    if (!this.activeTab.id) {
       const error = new Error('no-tab');
       error.nextBlockId = nextBlockId;
 
@@ -15,7 +15,7 @@ function forwardPage(block) {
     }
 
     browser.tabs
-      .goForward(this.tabId)
+      .goForward(this.activeTab.id)
       .then(() => {
         resolve({
           nextBlockId,

+ 2 - 2
src/background/workflow-engine/blocks-handler/handler-go-back.js

@@ -5,7 +5,7 @@ export function goBack(block) {
   return new Promise((resolve, reject) => {
     const nextBlockId = getBlockConnection(block);
 
-    if (!this.tabId) {
+    if (!this.activeTab.id) {
       const error = new Error('no-tab');
       error.nextBlockId = nextBlockId;
 
@@ -15,7 +15,7 @@ export function goBack(block) {
     }
 
     browser.tabs
-      .goBack(this.tabId)
+      .goBack(this.activeTab.id)
       .then(() => {
         resolve({
           nextBlockId,

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

@@ -33,7 +33,7 @@ export default async function ({ data, outputs }) {
       result = spreadsheetValues;
 
       if (data.refKey && !isWhitespace(data.refKey)) {
-        this.googleSheets[data.refKey] = spreadsheetValues;
+        this.referenceData.googleSheets[data.refKey] = spreadsheetValues;
       }
     }
 

+ 12 - 7
src/background/workflow-engine/blocks-handler/handler-interaction-block.js

@@ -7,18 +7,23 @@ async function interactionHandler(block, { refData }) {
     ...block,
     refData,
     frameSelector: this.frameSelector,
+    executedBlockOnWeb: this.workflow.settings?.executedBlockOnWeb,
   };
 
   try {
     const data = await this._sendMessageToTab(messagePayload, {
-      frameId: this.frameId || 0,
+      frameId: this.activeTab.frameId || 0,
     });
 
     if (block.name === 'link')
       await new Promise((resolve) => setTimeout(resolve, 5000));
 
     if (objectHasKey(block.data, 'dataColumn')) {
-      if (!block.data.saveData)
+      const dontSaveData =
+        (block.name === 'forms' && !block.data.getValue) ||
+        !block.data.saveData;
+
+      if (dontSaveData)
         return {
           data,
           nextBlockId,
@@ -29,20 +34,20 @@ async function interactionHandler(block, { refData }) {
 
       if (Array.isArray(data) && currentColumnType !== 'array') {
         data.forEach((item) => {
-          this.addData(block.data.dataColumn, item);
+          this.addDataToColumn(block.data.dataColumn, item);
           if (objectHasKey(block.data, 'extraRowDataColumn')) {
             if (block.data.addExtraRow)
-              this.addData(
+              this.addDataToColumn(
                 block.data.extraRowDataColumn,
                 block.data.extraRowValue
               );
           }
         });
       } else {
-        this.addData(block.data.dataColumn, data);
+        this.addDataToColumn(block.data.dataColumn, data);
         if (objectHasKey(block.data, 'extraRowDataColumn')) {
           if (block.data.addExtraRow)
-            this.addData(
+            this.addDataToColumn(
               block.data.extraRowDataColumn,
               block.data.extraRowValue
             );
@@ -51,7 +56,7 @@ async function interactionHandler(block, { refData }) {
     } else if (block.name === 'javascript-code') {
       const arrData = Array.isArray(data) ? data : [data];
 
-      this.addData(arrData);
+      this.addDataToColumn(arrData);
     }
 
     return {

+ 1 - 1
src/background/workflow-engine/blocks-handler/handler-loop-breakpoint.js

@@ -20,7 +20,7 @@ function loopBreakpoint(block, { prevBlockData }) {
       });
     } else {
       delete this.loopList[block.data.loopId];
-      delete this.loopData[block.data.loopId];
+      delete this.referenceData.loopData[block.data.loopId];
 
       resolve({
         data: prevBlockData,

+ 6 - 7
src/background/workflow-engine/blocks-handler/handler-loop-data.js

@@ -1,4 +1,3 @@
-import { generateJSON } from '@/utils/data-exporter';
 import { getBlockConnection } from '../helper';
 
 function loopData(block) {
@@ -12,13 +11,13 @@ function loopData(block) {
       let currentLoopData;
 
       if (data.loopThrough === 'numbers') {
-        currentLoopData = this.loopData[data.loopId] + 1;
+        currentLoopData = this.referenceData.loopData[data.loopId] + 1;
       } else {
         currentLoopData =
           this.loopList[data.loopId].data[this.loopList[data.loopId].index];
       }
 
-      this.loopData[data.loopId] = currentLoopData;
+      this.referenceData.loopData[data.loopId] = currentLoopData;
     } else {
       let currLoopData;
 
@@ -27,10 +26,10 @@ function loopData(block) {
           currLoopData = data.fromNumber;
           break;
         case 'data-columns':
-          currLoopData = generateJSON(Object.keys(this.data), this.data);
+          currLoopData = this.referenceData.dataColumns;
           break;
         case 'google-sheets':
-          currLoopData = this.googleSheets[data.referenceKey];
+          currLoopData = this.referenceData.googleSheets[data.referenceKey];
           break;
         case 'custom-data':
           currLoopData = JSON.parse(data.loopData);
@@ -58,12 +57,12 @@ function loopData(block) {
             : data.maxLoop || currLoopData.length,
       };
       /* eslint-disable-next-line */
-      this.loopData[data.loopId] = data.loopThrough === 'numbers' ? data.fromNumber : currLoopData[0];
+      this.referenceData.loopData[data.loopId] = data.loopThrough === 'numbers' ? data.fromNumber : currLoopData[0];
     }
 
     resolve({
       nextBlockId,
-      data: this.loopData[data.loopId],
+      data: this.referenceData.loopData[data.loopId],
     });
   });
 }

+ 15 - 29
src/background/workflow-engine/blocks-handler/handler-new-tab.js

@@ -2,24 +2,6 @@ import browser from 'webextension-polyfill';
 import { getBlockConnection } from '../helper';
 import executeContentScript from '../execute-content-script';
 
-function tabUpdatedListener(tab) {
-  return new Promise((resolve) => {
-    this._listener({
-      name: 'tab-updated',
-      id: tab.id,
-      callback: async (tabId, changeInfo, deleteListener) => {
-        if (changeInfo.status !== 'complete') return;
-
-        const frames = await executeContentScript(tabId);
-
-        deleteListener();
-
-        resolve(frames);
-      },
-    });
-  });
-}
-
 async function newTab(block) {
   if (this.windowId) {
     try {
@@ -29,11 +11,13 @@ async function newTab(block) {
     }
   }
 
+  const nextBlockId = getBlockConnection(block);
+
   try {
     const { updatePrevTab, url, active, inGroup } = block.data;
 
-    if (updatePrevTab && this.tabId) {
-      await browser.tabs.update(this.tabId, { url, active });
+    if (updatePrevTab && this.activeTab.id) {
+      await browser.tabs.update(this.activeTab.id, { url, active });
     } else {
       const tab = await browser.tabs.create({
         url,
@@ -41,37 +25,39 @@ async function newTab(block) {
         windowId: this.windowId,
       });
 
-      this.tabId = tab.id;
-      this.activeTabUrl = url;
+      this.activeTab.id = tab.id;
+      this.activeTab.url = url;
       this.windowId = tab.windowId;
     }
 
     if (inGroup && !updatePrevTab) {
       const options = {
-        groupId: this.tabGroupId,
-        tabIds: this.tabId,
+        groupId: this.activeTab.groupId,
+        tabIds: this.activeTab.id,
       };
 
-      if (!this.tabGroupId) {
+      if (!this.activeTab.groupId) {
         options.createProperties = {
           windowId: this.windowId,
         };
       }
 
       chrome.tabs.group(options, (tabGroupId) => {
-        this.tabGroupId = tabGroupId;
+        this.activeTab.groupId = tabGroupId;
       });
     }
 
-    this.frameId = 0;
-    this.frames = await tabUpdatedListener.call(this, { id: this.tabId });
+    this.activeTab.frameId = 0;
+    this.activeTab.frames = await executeContentScript(this.activeTab.id);
 
     return {
       data: url,
-      nextBlockId: getBlockConnection(block),
+      nextBlockId,
     };
   } catch (error) {
     console.error(error);
+    error.nextBlockId = nextBlockId;
+
     throw error;
   }
 }

+ 6 - 7
src/background/workflow-engine/blocks-handler/handler-switch-to.js

@@ -1,13 +1,13 @@
 import { objectHasKey } from '@/utils/helper';
 import { getBlockConnection } from '../helper';
-import executeContentScript, { getFrames } from '../execute-content-script';
+import executeContentScript from '../execute-content-script';
 
 async function switchTo(block) {
   const nextBlockId = getBlockConnection(block);
 
   try {
     if (block.data.windowType === 'main-window') {
-      this.frameId = 0;
+      this.activeTab.frameId = 0;
 
       delete this.frameSelector;
 
@@ -17,7 +17,6 @@ async function switchTo(block) {
       };
     }
 
-    const frames = await getFrames(this.tabId);
     const { url, isSameOrigin } = await this._sendMessageToTab(block, {
       frameId: 0,
     });
@@ -31,14 +30,14 @@ async function switchTo(block) {
       };
     }
 
-    if (objectHasKey(frames, url)) {
-      this.frameId = this.frames[url];
+    if (objectHasKey(this.activeTab.frames, url)) {
+      this.activeTab.frameId = this.activeTab.frames[url];
 
-      await executeContentScript(this.tabId, this.frameId);
+      await executeContentScript(this.activeTab.id, this.activeTab.frameId);
       await new Promise((resolve) => setTimeout(resolve, 1000));
 
       return {
-        data: this.frameId,
+        data: this.activeTab.frameId,
         nextBlockId,
       };
     }

+ 4 - 4
src/background/workflow-engine/blocks-handler/handler-take-screenshot.js

@@ -37,7 +37,7 @@ async function takeScreenshot(block) {
     };
 
     if (captureActiveTab) {
-      if (!this.tabId) {
+      if (!this.activeTab.id) {
         throw new Error('no-tab');
       }
 
@@ -47,7 +47,7 @@ async function takeScreenshot(block) {
       });
 
       await browser.windows.update(this.windowId, { focused: true });
-      await browser.tabs.update(this.tabId, { active: true });
+      await browser.tabs.update(this.activeTab.id, { active: true });
 
       await new Promise((resolve) => setTimeout(resolve, 500));
 
@@ -58,12 +58,12 @@ async function takeScreenshot(block) {
         await browser.tabs.update(tab.id, { active: true });
       }
 
-      if (saveToColumn) this.addData(dataColumn, uri);
+      if (saveToColumn) this.addDataToColumn(dataColumn, uri);
       if (saveToComputer) saveImage({ fileName, uri, ext });
     } else {
       const uri = await browser.tabs.captureVisibleTab(options);
 
-      if (saveToColumn) this.addData(dataColumn, uri);
+      if (saveToColumn) this.addDataToColumn(dataColumn, uri);
       if (saveToComputer) saveImage({ fileName, uri, ext });
     }
 

+ 2 - 2
src/background/workflow-engine/blocks-handler/handler-trigger.js

@@ -5,8 +5,8 @@ async function trigger(block) {
   const nextBlockId = getBlockConnection(block);
 
   try {
-    if (block.data.type === 'visit-web' && this.tabId) {
-      this.frames = await executeContentScript(this.tabId);
+    if (block.data.type === 'visit-web' && this.activeTab.id) {
+      this.activeTab.frames = await executeContentScript(this.activeTab.id);
     }
 
     return { nextBlockId, data: '' };

+ 245 - 302
src/background/workflow-engine/engine.js

@@ -1,172 +1,142 @@
-/* eslint-disable no-underscore-dangle */
 import browser from 'webextension-polyfill';
 import { nanoid } from 'nanoid';
 import { tasks } from '@/utils/shared';
 import { convertData } from './helper';
-import { generateJSON } from '@/utils/data-exporter';
 import { toCamelCase, parseJSON, isObject, objectHasKey } from '@/utils/helper';
-import errorMessage from './error-message';
 import referenceData from '@/utils/reference-data';
-import workflowState from '../workflow-state';
 import executeContentScript from './execute-content-script';
 
-let reloadTimeout;
-
-function tabRemovedHandler(tabId) {
-  if (tabId !== this.tabId) return;
-
-  delete this.tabId;
-
-  if (
-    this.currentBlock.name === 'new-tab' ||
-    tasks[this.currentBlock.name].category === 'interaction'
-  ) {
-    this.destroy('error', 'active-tab-removed');
-  }
-
-  workflowState.update(this.id, this.state);
-}
-function tabUpdatedHandler(tabId, changeInfo, tab) {
-  const listener = this.tabUpdatedListeners[tabId];
-
-  if (listener) {
-    listener.callback(tabId, changeInfo, () => {
-      delete this.tabUpdatedListeners[tabId];
-    });
-  } else if (this.tabId === tabId) {
-    if (!reloadTimeout) {
-      reloadTimeout = setTimeout(() => {
-        this.isPaused = false;
-      }, 15000);
-    }
-
-    this.isPaused = true;
-
-    if (changeInfo.status === 'complete') {
-      clearTimeout(reloadTimeout);
-      reloadTimeout = null;
-
-      executeContentScript(tabId)
-        .then((frames) => {
-          this.tabId = tabId;
-          this.frames = frames;
-          this.isPaused = false;
-          this.activeTabUrl = tab?.url || '';
-        })
-        .catch((error) => {
-          console.error(error);
-          this.isPaused = false;
-        });
-    }
-  }
-}
-
 class WorkflowEngine {
   constructor(
     workflow,
-    {
-      globalData,
-      tabId = null,
-      isInCollection,
-      collectionLogId,
-      blocksHandler,
-      parentWorkflow,
-    }
+    { states, logger, blocksHandler, tabId, parentWorkflow, globalData }
   ) {
-    const globalDataVal = globalData || workflow.globalData;
-
     this.id = nanoid();
-    this.tabId = tabId;
+    this.states = states;
+    this.logger = logger;
     this.workflow = workflow;
     this.blocksHandler = blocksHandler;
-    this.isInCollection = isInCollection;
     this.parentWorkflow = parentWorkflow;
-    this.collectionLogId = collectionLogId;
-    this.globalData = parseJSON(globalDataVal, globalDataVal);
-    this.activeTabUrl = '';
-    this.columns = { column: { index: 0, type: 'any' } };
-    this.data = [];
-    this.logs = [];
-    this.blocks = {};
-    this.frames = {};
+    this.saveLog = workflow.settings?.saveLog ?? true;
+
     this.loopList = {};
-    this.loopData = {};
     this.repeatedTasks = {};
-    this.eventListeners = {};
-    this.isPaused = false;
-    this.isDestroyed = false;
-    this.isUsingProxy = false;
-    this.frameId = 0;
+
     this.windowId = null;
-    this.tabGroupId = null;
     this.currentBlock = null;
-    this.childWorkflow = null;
-    this.workflowTimeout = null;
+    this.childWorkflowId = null;
 
-    this.saveLog = workflow.settings?.saveLog ?? true;
+    this.isDestroyed = false;
+    this.isUsingProxy = false;
+
+    this.blocks = {};
+    this.history = [];
+    this.eventListeners = {};
+    this.columns = { column: { index: 0, type: 'any' } };
 
-    this.googleSheets = {};
+    const globalDataValue = globalData || workflow.globalData;
 
-    this.tabUpdatedListeners = {};
-    this.tabUpdatedHandler = tabUpdatedHandler.bind(this);
-    this.tabRemovedHandler = tabRemovedHandler.bind(this);
+    this.activeTab = {
+      url: '',
+      id: tabId,
+      frameId: 0,
+      frames: {},
+      groupId: null,
+    };
+    this.referenceData = {
+      loopData: {},
+      dataColumns: [],
+      googleSheets: {},
+      globalData: parseJSON(globalDataValue, globalDataValue),
+    };
+
+    this.onWorkflowStopped = (id) => {
+      if (this.id !== id || this.isDestroyed) return;
+      this.stop();
+    };
   }
 
-  init() {
+  init(currentBlock) {
     if (this.workflow.isDisabled) return;
 
-    const drawflowData =
-      typeof this.workflow.drawflow === 'string'
-        ? JSON.parse(this.workflow.drawflow || '{}')
-        : this.workflow.drawflow;
-    const blocks = drawflowData?.drawflow?.Home.data;
+    if (!this.states) {
+      console.error(`"${this.workflow.name}" workflow doesn't have states`);
+      this.destroy('error');
+      return;
+    }
+
+    const { drawflow } = this.workflow;
+    const flow =
+      typeof drawflow === 'string' ? parseJSON(drawflow, {}) : drawflow;
+    const blocks = flow?.drawflow?.Home.data;
 
     if (!blocks) {
-      console.error(errorMessage('no-block', this.workflow));
+      console.error(`${this.workflow.name} doesn't have blocks`);
       return;
     }
 
-    const blocksArr = Object.values(blocks);
-    const triggerBlock = blocksArr.find(({ name }) => name === 'trigger');
-
+    const triggerBlock = Object.values(blocks).find(
+      ({ name }) => name === 'trigger'
+    );
     if (!triggerBlock) {
-      console.error(errorMessage('no-trigger-block', this.workflow));
+      console.error(`${this.workflow.name} doesn't have a trigger block`);
       return;
     }
 
-    browser.tabs.onUpdated.addListener(this.tabUpdatedHandler);
-    browser.tabs.onRemoved.addListener(this.tabRemovedHandler);
-
     const dataColumns = Array.isArray(this.workflow.dataColumns)
       ? this.workflow.dataColumns
       : Object.values(this.workflow.dataColumns);
 
+    dataColumns.forEach(({ name, type }) => {
+      this.columns[name] = { index: 0, type };
+    });
+
     this.blocks = blocks;
     this.startedTimestamp = Date.now();
     this.workflow.dataColumns = dataColumns;
+    this.currentBlock = currentBlock || triggerBlock;
 
-    dataColumns.forEach(({ name, type }) => {
-      this.columns[name] = { index: 0, type };
-    });
+    this.states.on('stop', this.onWorkflowStopped);
 
-    workflowState
+    this.states
       .add(this.id, {
         state: this.state,
         workflowId: this.workflow.id,
-        isInCollection: this.isInCollection,
+        parentState: this.parentWorkflow,
       })
       .then(() => {
-        this._blockHandler(triggerBlock);
+        this.executeBlock(this.currentBlock);
       });
   }
 
-  addData(key, value) {
+  resume({ id, state }) {
+    this.id = id;
+
+    Object.keys(state).forEach((key) => {
+      this[key] = state[key];
+    });
+
+    this.init(state.currentBlock);
+  }
+
+  addLogHistory(detail) {
+    if (
+      !this.saveLog &&
+      (this.history.length >= 1001 || detail.name === 'blocks-group') &&
+      detail.type !== 'error'
+    )
+      return;
+
+    this.history.push(detail);
+  }
+
+  addDataToColumn(key, value) {
     if (Array.isArray(key)) {
       key.forEach((item) => {
         if (!isObject(item)) return;
 
         Object.entries(item).forEach(([itemKey, itemValue]) => {
-          this.addData(itemKey, itemValue);
+          this.addDataToColumn(itemKey, itemValue);
         });
       });
 
@@ -177,50 +147,22 @@ class WorkflowEngine {
     const currentColumn = this.columns[columnName];
     const convertedValue = convertData(value, currentColumn.type);
 
-    if (objectHasKey(this.data, currentColumn.index)) {
-      this.data[currentColumn.index][columnName] = convertedValue;
+    if (objectHasKey(this.referenceData.dataColumns, currentColumn.index)) {
+      this.referenceData.dataColumns[currentColumn.index][columnName] =
+        convertedValue;
     } else {
-      this.data.push({ [columnName]: convertedValue });
+      this.referenceData.dataColumns.push({ [columnName]: convertedValue });
     }
 
     currentColumn.index += 1;
   }
 
-  addLog(detail) {
-    if (
-      !this.saveLog &&
-      (this.logs.length >= 1001 || detail.name === 'blocks-group') &&
-      detail.type !== 'error'
-    )
-      return;
-
-    this.logs.push(detail);
-  }
-
-  on(name, listener) {
-    (this.eventListeners[name] = this.eventListeners[name] || []).push(
-      listener
-    );
-  }
-
-  pause(pause = true) {
-    this.isPaused = pause;
-
-    workflowState.update(this.id, this.state);
-  }
-
-  async stop(message) {
+  async stop() {
     try {
-      if (this.childWorkflow) {
-        await this.childWorkflow.stop();
+      if (this.childWorkflowId) {
+        await this.states.stop(this.childWorkflowId);
       }
 
-      this.addLog({
-        message,
-        type: 'stop',
-        name: 'stop',
-      });
-
       await this.destroy('stopped');
     } catch (error) {
       console.error(error);
@@ -229,55 +171,125 @@ class WorkflowEngine {
 
   async destroy(status, message) {
     try {
+      if (this.isDestroyed) return;
       if (this.isUsingProxy) chrome.proxy.settings.clear({});
 
-      await browser.tabs.onRemoved.removeListener(this.tabRemovedHandler);
-      await browser.tabs.onUpdated.removeListener(this.tabUpdatedHandler);
-
-      await workflowState.delete(this.id);
-
-      clearTimeout(this.workflowTimeout);
-      this.isDestroyed = true;
-      this.endedTimestamp = Date.now();
+      const endedTimestamp = Date.now();
 
       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);
+        const { name, id } = this.workflow;
 
-        logs.push({
+        await this.logger.add({
           name,
-          icon,
           status,
           message,
           id: this.id,
           workflowId: id,
-          data: jsonData,
-          history: this.logs,
-          endedAt: this.endedTimestamp,
+          history: this.history,
+          endedAt: endedTimestamp,
+          parentLog: this.parentWorkflow,
           startedAt: this.startedTimestamp,
-          isChildLog: !!this.parentWorkflow,
-          isInCollection: this.isInCollection,
-          collectionLogId: this.collectionLogId,
+          data: this.referenceData.dataColumns,
         });
-
-        await browser.storage.local.set({ logs });
       }
 
+      this.states.off('stop', this.onWorkflowStopped);
+      await this.states.delete(this.id);
+
       this.dispatchEvent('destroyed', {
-        id: this.id,
         status,
         message,
+        id: this.id,
         currentBlock: this.currentBlock,
       });
 
+      this.isDestroyed = true;
       this.eventListeners = {};
-      this.tabUpdatedListeners = {};
     } catch (error) {
       console.error(error);
     }
   }
 
+  async executeBlock(block, prevBlockData) {
+    const currentState = await this.states.get(this.id);
+
+    if (!currentState || currentState.isDestroyed) {
+      if (this.isDestroyed) return;
+
+      await this.destroy('stopped');
+      return;
+    }
+
+    this.currentBlock = block;
+    this.referenceData.prevBlockData = prevBlockData;
+
+    await this.states.update(this.id, { state: this.state });
+    this.dispatchEvent('update', { state: this.state });
+
+    const startExecutedTime = Date.now();
+    const blockHandler = this.blocksHandler[toCamelCase(block?.name)];
+    const handler =
+      !blockHandler && tasks[block.name].category === 'interaction'
+        ? this.blocksHandler.interactionBlock
+        : blockHandler;
+
+    if (!handler) {
+      console.error(`"${block.name}" block doesn't have a handler`);
+      this.destroy('stopped');
+      return;
+    }
+
+    const replacedBlock = referenceData({ block, data: this.referenceData });
+    const blockDelay = this.workflow.settings?.blockDelay || 0;
+
+    try {
+      const result = await handler.call(this, replacedBlock, {
+        prevBlockData,
+        refData: this.referenceData,
+      });
+
+      this.addLogHistory({
+        name: block.name,
+        logId: result.logId,
+        type: result.status || 'success',
+        duration: Math.round(Date.now() - startExecutedTime),
+      });
+
+      if (result.nextBlockId) {
+        setTimeout(() => {
+          this.executeBlock(this.blocks[result.nextBlockId], result.data);
+        }, blockDelay);
+      } else {
+        this.addLogHistory({
+          type: 'finish',
+          name: 'finish',
+        });
+        this.dispatchEvent('finish');
+        this.destroy('success');
+      }
+    } catch (error) {
+      this.addLogHistory({
+        type: 'error',
+        message: error.message,
+        name: block.name,
+        ...(error.data || {}),
+      });
+
+      if (
+        this.workflow.settings.onError === 'keep-running' &&
+        error.nextBlockId
+      ) {
+        setTimeout(() => {
+          this.executeBlock(this.blocks[error.nextBlockId], error.data || '');
+        }, blockDelay);
+      } else {
+        this.destroy('error', error.message);
+      }
+
+      console.error(`${block.name}:`, error);
+    }
+  }
+
   dispatchEvent(name, params) {
     const listeners = this.eventListeners[name];
 
@@ -288,157 +300,88 @@ class WorkflowEngine {
     });
   }
 
+  on(name, listener) {
+    (this.eventListeners[name] = this.eventListeners[name] || []).push(
+      listener
+    );
+  }
+
   get state() {
     const keys = [
-      'tabId',
-      'isPaused',
-      'isDestroyed',
+      'history',
+      'columns',
+      'activeTab',
+      'isUsingProxy',
       'currentBlock',
-      'isInCollection',
+      'referenceData',
+      'childWorkflowId',
       'startedTimestamp',
     ];
-    const state = keys.reduce((acc, key) => {
-      acc[key] = this[key];
-
-      return acc;
-    }, {});
-
-    state.name = this.workflow.name;
-    state.icon = this.workflow.icon;
+    const state = {
+      name: this.workflow.name,
+      icon: this.workflow.icon,
+    };
 
-    if (this.parentWorkflow) state.parentState = this.parentWorkflow;
+    keys.forEach((key) => {
+      state[key] = this[key];
+    });
 
     return state;
   }
 
-  _blockHandler(block, prevBlockData) {
-    if (this.isDestroyed) return;
-    if (this.isPaused) {
-      browser.tabs.get(this.tabId).then(({ status }) => {
-        this.isPaused = status !== 'complete';
-
-        setTimeout(() => {
-          this._blockHandler(block, prevBlockData);
-        }, 1000);
+  async _sendMessageToTab(payload, options = {}) {
+    const checkActiveTab = () => {
+      return new Promise((resolve, reject) => {
+        const activeTabStatus = () => {
+          browser.tabs
+            .get(this.activeTab.id)
+            .then((tab) => {
+              if (tab.status === 'loading') {
+                setTimeout(() => {
+                  activeTabStatus();
+                }, 500);
+                return;
+              }
+
+              resolve();
+            })
+            .catch(reject);
+        };
+
+        activeTabStatus();
       });
+    };
 
-      return;
-    }
-
-    const disableTimeoutKeys = ['delay', 'javascript-code', 'webhook'];
-
-    if (!disableTimeoutKeys.includes(block.name)) {
-      this.workflowTimeout = setTimeout(() => {
-        if (!this.isDestroyed) this.stop('stop-timeout');
-      }, this.workflow.settings.timeout || 120000);
-    }
-
-    this.currentBlock = block;
-
-    workflowState.update(this.id, this.state);
-    this.dispatchEvent('update', this.state);
-
-    const started = Date.now();
-    const blockHandler = this.blocksHandler[toCamelCase(block?.name)];
-    const handler =
-      !blockHandler && tasks[block.name].category === 'interaction'
-        ? this.blocksHandler.interactionBlock
-        : blockHandler;
-
-    if (handler) {
-      const refData = {
-        prevBlockData,
-        dataColumns: this.data,
-        loopData: this.loopData,
-        globalData: this.globalData,
-        googleSheets: this.googleSheets,
-        activeTabUrl: this.activeTabUrl,
-      };
-      const replacedBlock = referenceData({ block, data: refData });
-      const blockDelay =
-        block.name === 'trigger' ? 0 : this.workflow.settings?.blockDelay || 0;
-
-      handler
-        .call(this, replacedBlock, { prevBlockData, refData })
-        .then((result) => {
-          clearTimeout(this.workflowTimeout);
-          this.workflowTimeout = null;
-
-          this.addLog({
-            type: 'success',
-            name: block.name,
-            logId: result.logId,
-            duration: Math.round(Date.now() - started),
-          });
-
-          if (result.nextBlockId) {
-            setTimeout(() => {
-              this._blockHandler(this.blocks[result.nextBlockId], result.data);
-            }, blockDelay);
-          } else {
-            this.addLog({
-              type: 'finish',
-              name: 'finish',
-            });
-            this.dispatchEvent('finish');
-            this.destroy('success');
-          }
-        })
-        .catch((error) => {
-          this.addLog({
-            type: 'error',
-            message: error.message,
-            name: block.name,
-            ...(error.data || {}),
-          });
-
-          if (
-            this.workflow.settings.onError === 'keep-running' &&
-            error.nextBlockId
-          ) {
-            setTimeout(() => {
-              this._blockHandler(
-                this.blocks[error.nextBlockId],
-                error.data || ''
-              );
-            }, blockDelay);
-          } else {
-            this.destroy('error', error.message);
-          }
-
-          clearTimeout(this.workflowTimeout);
-          this.workflowTimeout = null;
-
-          console.error(error);
-        });
-    } else {
-      console.error(`"${block.name}" block doesn't have a handler`);
-    }
-  }
-
-  _sendMessageToTab(payload, options = {}) {
-    return new Promise((resolve, reject) => {
-      if (!this.tabId) {
+    try {
+      if (!this.activeTab.id) {
         const error = new Error('no-tab');
         error.workflowId = this.id;
 
-        reject(error);
-        return;
+        throw error;
       }
 
-      browser.tabs
-        .sendMessage(this.tabId, { isBlock: true, ...payload }, options)
-        .then(resolve)
-        .catch(reject);
-    });
-  }
+      await checkActiveTab();
 
-  _listener({ id, name, callback, once = true, ...options }) {
-    const listenerNames = {
-      event: 'eventListener',
-      'tab-updated': 'tabUpdatedListeners',
-    };
-    this[listenerNames[name]][id] = { callback, once, ...options };
+      this.activeTab.frames = await executeContentScript(
+        this.activeTab.id,
+        options.frameId || 0
+      );
+      const data = await browser.tabs.sendMessage(
+        this.activeTab.id,
+        { isBlock: true, ...payload },
+        options
+      );
+
+      return data;
+    } catch (error) {
+      if (error.message?.startsWith('Could not establish connection')) {
+        error.message = 'Could not establish connection to the active tab';
+      } else if (error.message?.startsWith('No tab')) {
+        error.message = 'active-tab-removed';
+      }
+
+      throw error;
+    }
   }
 }
 

+ 0 - 26
src/background/workflow-engine/error-message.js

@@ -1,26 +0,0 @@
-import { get } from 'object-path-immutable';
-import { replaceMustache } from '@/utils/helper';
-
-const messages = {
-  'no-trigger-block': '"{{name}}"" workflow doesn\'t have a trigger block.',
-  'no-block': '"{{name}}" workflow doesn\'t have any blocks.',
-  'no-iframe-id':
-    'Can\'t find Frame ID for the frame element with "{{selector}}" selector',
-  'no-tab':
-    'Can\'t connect to a tab, use "New tab" or "Active tab" block before using the "{{name}}" block.',
-};
-
-export default function (errorId, data) {
-  const message = messages[errorId];
-
-  if (!message) return `Can't find message for this error (${errorId})`;
-
-  const resultMessage = replaceMustache(message, (match) => {
-    const key = match.slice(2, -2);
-    const result = get(data, key);
-
-    return result ?? key;
-  });
-
-  return resultMessage;
-}

+ 1 - 0
src/background/workflow-engine/execute-content-script.js

@@ -39,6 +39,7 @@ export default async function (tabId, frameId = 0) {
 
     if (!isScriptExists) {
       await browser.tabs.executeScript(tabId, {
+        runAt: 'document_end',
         frameId: currentFrameId,
         file: './contentScript.bundle.js',
       });

+ 0 - 8
src/background/workflow-engine/index.js

@@ -1,8 +0,0 @@
-import Engine from './engine';
-import blocksHandler from './blocks-handler';
-
-export default function (workflow, options = {}) {
-  const engine = new Engine(workflow, { ...options, blocksHandler });
-
-  return engine;
-}

+ 16 - 0
src/background/workflow-logger.js

@@ -0,0 +1,16 @@
+class WorkflowLogger {
+  constructor({ storage, key = 'logs' }) {
+    this.key = key;
+    this.storage = storage;
+  }
+
+  async add(data) {
+    const logs = (await this.storage.get(this.key)) || [];
+
+    logs.unshift(data);
+
+    await this.storage.set(this.key, logs);
+  }
+}
+
+export default WorkflowLogger;

+ 102 - 44
src/background/workflow-state.js

@@ -1,32 +1,26 @@
 /* eslint-disable  no-param-reassign */
-import browser from 'webextension-polyfill';
 
-async function updater(callback, id) {
-  try {
-    const state = await this.get();
-    const index = id ? state.findIndex((item) => item.id === id) : -1;
-    const items = callback(state, index);
-
-    await browser.storage.local.set({ workflowState: items });
-
-    return items;
-  } catch (error) {
-    console.error(error);
+class WorkflowState {
+  constructor({ storage, key = 'workflowState' }) {
+    this.key = key;
+    this.storage = storage;
 
-    return [];
+    this.cache = null;
+    this.eventListeners = {};
   }
-}
 
-class WorkflowState {
-  static async get(filter) {
+  async _updater(callback, event) {
     try {
-      let { workflowState } = await browser.storage.local.get('workflowState');
+      const storageStates = await this.get();
+      const states = callback(storageStates);
 
-      if (workflowState && filter) {
-        workflowState = workflowState.filter(filter);
+      await this.storage.set(this.key, states);
+
+      if (event) {
+        this.dispatchEvent(event.name, event.params);
       }
 
-      return workflowState || [];
+      return states;
     } catch (error) {
       console.error(error);
 
@@ -34,38 +28,102 @@ class WorkflowState {
     }
   }
 
-  static add(id, data) {
-    return updater.call(this, (items) => {
-      items.unshift({ id, ...data });
+  dispatchEvent(name, params) {
+    const listeners = this.eventListeners[name];
+
+    if (!listeners) return;
 
-      return items;
+    listeners.forEach((callback) => {
+      callback(params);
     });
   }
 
-  static update(id, data = {}) {
-    return updater.call(
-      this,
-      (items, index) => {
-        if (typeof index === 'number' && index !== -1) {
-          items[index].state = { ...items[index].state, ...data };
-        }
-
-        return items;
-      },
-      id
+  on(name, listener) {
+    (this.eventListeners[name] = this.eventListeners[name] || []).push(
+      listener
     );
   }
 
-  static delete(id) {
-    return updater.call(
-      this,
-      (items, index) => {
-        if (index !== -1) items.splice(index, 1);
+  off(name, listener) {
+    const listeners = this.eventListeners[name];
+    if (!listeners) return;
 
-        return items;
-      },
-      id
-    );
+    const index = listeners.indexOf(listener);
+    if (index !== -1) listeners.splice(index, 1);
+  }
+
+  async get(stateId) {
+    try {
+      let states = this.cache ?? ((await this.storage.get(this.key)) || {});
+
+      if (Array.isArray(states)) {
+        states = {};
+        await this.storage.set(this.key, {});
+      }
+
+      if (typeof stateId === 'function') {
+        states = Object.values(states).find(stateId);
+      } else if (stateId) {
+        states = states[stateId];
+      } else {
+        this.cache = states;
+      }
+
+      return states;
+    } catch (error) {
+      console.error(error);
+
+      return null;
+    }
+  }
+
+  add(id, data = {}) {
+    return this._updater((states) => {
+      states[id] = {
+        id,
+        isPaused: false,
+        isDestroyed: false,
+        ...data,
+      };
+
+      return states;
+    });
+  }
+
+  async stop(id) {
+    await this.update(id, { isDestroyed: true });
+
+    this.dispatchEvent('stop', id);
+
+    return id;
+  }
+
+  update(id, data = {}) {
+    const event = {
+      name: 'update',
+      params: { id, data },
+    };
+
+    return this._updater((states) => {
+      if (states[id]) {
+        states[id] = { ...states[id], ...data };
+      }
+
+      return states;
+    }, event);
+  }
+
+  delete(id) {
+    const event = {
+      name: 'delete',
+      params: id,
+    };
+
+    return this._updater((states) => {
+      delete states[id];
+
+      return states;
+    }, event);
   }
 }
 

+ 9 - 8
src/components/newtab/logs/LogsDataViewer.vue

@@ -27,7 +27,7 @@
     </ui-popover>
   </div>
   <shared-codemirror
-    :model-value="jsonData"
+    :model-value="dataStr"
     :class="editorClass"
     lang="json"
     readonly
@@ -35,7 +35,7 @@
   />
 </template>
 <script setup>
-import { ref } from 'vue';
+import { ref, computed } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { dataExportTypes } from '@/utils/shared';
 import dataExporter, { generateJSON } from '@/utils/data-exporter';
@@ -54,12 +54,13 @@ const props = defineProps({
 
 const { t } = useI18n();
 
-const data = Array.isArray(props.log.data)
-  ? props.log.data
-  : generateJSON(Object.keys(props.log.data), props.log.data);
-const dataStr = JSON.stringify(data, null, 2);
-const jsonData =
-  dataStr.length >= 5e4 ? `${dataStr.slice(0, 5e4)}\n...` : dataStr;
+const dataStr = computed(() => {
+  const data = Array.isArray(props.log.data)
+    ? props.log.data
+    : generateJSON(Object.keys(props.log.data), props.log.data);
+
+  return JSON.stringify(data, null, 2);
+});
 
 const fileName = ref(props.log.name);
 

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

@@ -22,11 +22,7 @@
       >
         <v-remixicon name="riExternalLinkLine" />
       </ui-button>
-      <ui-button
-        variant="accent"
-        :disabled="!!data.state.parentState"
-        @click="stopWorkflow"
-      >
+      <ui-button variant="accent" @click="stopWorkflow">
         <v-remixicon name="riStopLine" class="mr-2 -ml-1" />
         <span>{{ t('common.stop') }}</span>
       </ui-button>
@@ -34,7 +30,7 @@
     <div class="divide-y bg-box-transparent divide-y px-4 rounded-lg">
       <div
         v-for="block in getBlock()"
-        :key="block.name"
+        :key="block.id || block.name"
         class="flex items-center py-2"
       >
         <v-remixicon :name="block.icon" />
@@ -43,10 +39,17 @@
       </div>
     </div>
     <div
-      v-if="data.state.parentState"
+      v-if="data.parentState"
       class="py-2 px-4 bg-yellow-200 rounded-lg mt-2 text-sm"
     >
-      {{ t('workflow.state.executeBy', { name: data.state.parentState.name }) }}
+      {{ t('workflow.state.executeBy', { name: data.parentState.name }) }}
+      <span class="lowercase">
+        {{
+          data.parentState.isCollection
+            ? t('common.collection')
+            : t('common.workflow')
+        }}
+      </span>
     </div>
   </ui-card>
 </template>
@@ -67,21 +70,21 @@ const props = defineProps({
 const { t } = useI18n();
 
 function getBlock() {
-  if (!props.data.state.currentBlock) return [];
+  const block = props.data.state.currentBlock;
+
+  if (!block) return [];
 
-  if (Array.isArray(props.data.state.currentBlock)) {
-    return props.data.state.currentBlock.map((item) => {
-      if (tasks[item.name])
-        return {
-          ...tasks[item.name],
-          name: t(`workflow.blocks.${item.name}.name`),
-        };
+  const blockArr = Array.isArray(block) ? block : [block];
 
-      return item;
-    });
-  }
+  return blockArr.map((item) => {
+    if (tasks[item.name] && item.outputs)
+      return {
+        ...tasks[item.name],
+        name: t(`workflow.blocks.${item.name}.name`),
+      };
 
-  return [tasks[props.data.state.currentBlock.name]];
+    return item;
+  });
 }
 function formatDate(date, format) {
   if (format === 'relative') return dayjs(date).fromNow();

+ 68 - 11
src/components/newtab/workflow/WorkflowBuilder.vue

@@ -3,7 +3,7 @@
     id="drawflow"
     class="parent-drawflow relative"
     @drop="dropHandler"
-    @dragover.prevent
+    @dragover.prevent="handleDragOver"
   >
     <slot></slot>
     <div class="absolute z-10 p-4 bottom-0 left-0">
@@ -64,6 +64,7 @@
 import { onMounted, shallowRef, reactive, getCurrentInstance } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { compare } from 'compare-versions';
+import defu from 'defu';
 import emitter from 'tiny-emitter/instance';
 import { useShortcut, getShortcut } from '@/composable/shortcut';
 import { tasks } from '@/utils/shared';
@@ -114,7 +115,24 @@ export default {
       position: {},
     });
 
-    function dropHandler({ dataTransfer, clientX, clientY }) {
+    const isConnectionEl = (el) =>
+      el.matches('path.main-path') ||
+      el.parentElement.classList.contains('connection');
+
+    let prevSelectedElement = null;
+    function handleDragOver({ target }) {
+      const dropInConnection = isConnectionEl(target);
+
+      if (dropInConnection) {
+        if (prevSelectedElement !== target)
+          target.classList.toggle('selected', true);
+      } else if (prevSelectedElement) {
+        prevSelectedElement.classList.toggle('selected', false);
+      }
+
+      prevSelectedElement = target;
+    }
+    function dropHandler({ dataTransfer, clientX, clientY, target }) {
       const block = JSON.parse(dataTransfer.getData('block') || null);
 
       if (!block) return;
@@ -140,7 +158,7 @@ export default {
           (editor.value.precanvas.clientHeight /
             (editor.value.precanvas.clientHeight * editor.value.zoom));
 
-      editor.value.addNode(
+      const blockId = editor.value.addNode(
         block.id,
         block.inputs,
         block.outputs,
@@ -151,6 +169,51 @@ export default {
         block.component,
         'vue'
       );
+
+      const dropInConnection = isConnectionEl(target);
+
+      if (dropInConnection) {
+        target.classList.remove('selected');
+
+        const classes = target.parentElement.classList.toString();
+        const result = {};
+        const items = [
+          { str: 'node_in_', key: 'inputId' },
+          { str: 'input_', key: 'inputClass' },
+          { str: 'node_out_', key: 'outputId' },
+          { str: 'output_', key: 'outputClass' },
+        ];
+
+        items.forEach(({ key, str }) => {
+          result[key] = classes
+            .match(new RegExp(`${str}[^\\s]*`))[0]
+            ?.replace(/node_in_node-|node_out_node-/, '');
+        });
+
+        try {
+          editor.value.removeSingleConnection(
+            result.outputId,
+            result.inputId,
+            result.outputClass,
+            result.inputClass
+          );
+          editor.value.addConnection(
+            result.outputId,
+            blockId,
+            result.outputClass,
+            'input_1'
+          );
+          editor.value.addConnection(
+            blockId,
+            result.inputId,
+            'output_1',
+            result.inputClass
+          );
+        } catch (error) {
+          // Do nothing
+        }
+      }
+
       emitter.emit('editor:data-changed');
     }
     function isInputAllowed(allowedInputs, input) {
@@ -225,14 +288,7 @@ export default {
           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,
-              },
-            };
+            const newBlockData = defu(value, tasks[value.name]);
 
             obj[key] = newBlockData;
 
@@ -326,6 +382,7 @@ export default {
       editor,
       contextMenu,
       dropHandler,
+      handleDragOver,
       contextMenuHandler: {
         deleteBlock,
         duplicateBlock: () => duplicateBlock(),

+ 6 - 10
src/components/newtab/workflow/WorkflowSettings.vue

@@ -15,14 +15,6 @@
         </ui-radio>
       </div>
     </div>
-    <div class="mb-6">
-      <p class="mb-1">{{ t('workflow.settings.timeout.title') }}</p>
-      <ui-input
-        v-model="settings.timeout"
-        type="number"
-        class="w-full max-w-sm"
-      />
-    </div>
     <div>
       <p class="mb-1">
         {{ t('workflow.settings.blockDelay.title') }}
@@ -38,7 +30,11 @@
     </div>
     <div class="flex mt-6">
       <ui-switch v-model="settings.saveLog" class="mr-4" />
-      <p>Save log</p>
+      <p>{{ t('workflow.settings.saveLog') }}</p>
+    </div>
+    <div class="flex mt-6">
+      <ui-switch v-model="settings.executedBlockOnWeb" class="mr-4" />
+      <p>{{ t('workflow.settings.executedBlockOnWeb') }}</p>
     </div>
   </div>
 </template>
@@ -70,8 +66,8 @@ const onError = [
 const settings = reactive({
   blockDelay: 0,
   saveLog: true,
-  timeout: 120000,
   onError: 'stop-workflow',
+  executedBlockOnWeb: false,
 });
 
 watch(

+ 64 - 0
src/components/newtab/workflow/edit/EditExportData.vue

@@ -0,0 +1,64 @@
+<template>
+  <div>
+    <ui-textarea
+      :model-value="data.description"
+      class="w-full"
+      :placeholder="t('common.description')"
+      @change="updateData({ description: $event })"
+    />
+    <ui-select
+      :model-value="data.dataToExport"
+      :placeholder="t('workflow.blocks.export-data.dataToExport.placeholder')"
+      class="w-full mt-2"
+      @change="updateData({ dataToExport: $event })"
+    >
+      <option v-for="option in dataToExport" :key="option" :value="option">
+        {{ t(`workflow.blocks.export-data.dataToExport.options.${option}`) }}
+      </option>
+    </ui-select>
+    <ui-input
+      :model-value="data.name"
+      class="w-full mt-2"
+      title="File name"
+      placeholder="File name"
+      @change="updateData({ name: $event })"
+    />
+    <ui-input
+      v-if="data.dataToExport === 'google-sheets'"
+      :model-value="data.refKey"
+      :title="t('workflow.blocks.export-data.refKey')"
+      :placeholder="t('workflow.blocks.export-data.refKey')"
+      class="w-full mt-2"
+      @change="updateData({ refKey: $event })"
+    />
+    <ui-select
+      :model-value="data.type"
+      :placeholder="t('workflow.blocks.export-data.exportAs')"
+      class="w-full mt-2"
+      @change="updateData({ type: $event })"
+    >
+      <option v-for="type in dataExportTypes" :key="type.id" :value="type.id">
+        {{ type.name }}
+      </option>
+    </ui-select>
+  </div>
+</template>
+<script setup>
+import { useI18n } from 'vue-i18n';
+import { dataExportTypes } from '@/utils/shared';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const { t } = useI18n();
+const dataToExport = ['data-columns', 'google-sheets'];
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+</script>

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

@@ -1,5 +1,11 @@
 <template>
   <div>
+    <ui-textarea
+      :model-value="data.description"
+      class="w-full mb-2"
+      :placeholder="t('common.description')"
+      @change="updateData({ description: $event })"
+    />
     <ui-select
       v-if="false"
       :model-value="data.type"

+ 4 - 0
src/composable/shortcut.js

@@ -27,6 +27,10 @@ export const mapShortcuts = {
     id: 'action:search',
     combo: 'mod+shift+f',
   },
+  'action:new': {
+    id: 'action:new',
+    combo: 'mod+option+n',
+  },
   'editor:duplicate-block': {
     id: 'editor:duplicate-block',
     combo: 'mod+option+d',

+ 14 - 10
src/content/element-selector/App.vue

@@ -268,17 +268,21 @@ const handleScroll = debounce(() => {
   lastScrollPosY = window.scrollY;
 }, 100);
 function destroy() {
-  window.removeEventListener('scroll', handleScroll);
-  window.removeEventListener('mouseup', handleMouseUp);
-  window.removeEventListener('mousemove', handleMouseMove);
-  document.removeEventListener('click', handleClick, true);
-
-  const automaElements = document.querySelectorAll('automa-element-selector');
-  automaElements.forEach((element) => {
-    element.remove();
+  rootElement.style.display = 'none';
+
+  Object.assign(state, {
+    activeTab: '',
+    elSelector: '',
+    isDragging: false,
+    isExecuting: false,
+    selectedElements: [],
+  });
+  Object.assign(hoverElementRect, {
+    x: 0,
+    y: 0,
+    height: 0,
+    width: 0,
   });
-
-  rootElement.remove();
 }
 
 window.addEventListener('scroll', handleScroll);

+ 11 - 13
src/content/element-selector/index.js

@@ -23,17 +23,21 @@ async function getStyles() {
     return '';
   }
 }
-function getLocale() {
-  return new Promise((resolve) => {
-    chrome.storage.local.get('settings', ({ settings }) => {
-      resolve(settings?.locale || 'en');
-    });
-  });
-}
 
 export default async function () {
   try {
+    const rootElementExist = document.querySelector(
+      '#app-container.automa-element-selector'
+    );
+
+    if (rootElementExist) {
+      rootElementExist.style.display = 'block';
+
+      return;
+    }
+
     const rootElement = document.createElement('div');
+    rootElement.setAttribute('id', 'app-container');
     rootElement.classList.add('automa-element-selector');
     rootElement.attachShadow({ mode: 'open' });
 
@@ -48,15 +52,9 @@ export default async function () {
       chrome.runtime.getURL('/elementSelector.bundle.js')
     );
 
-    const appContainer = document.createElement('div');
-    appContainer.setAttribute('data-id', chrome.runtime.id);
-    appContainer.setAttribute('data-locale', await getLocale());
-    appContainer.setAttribute('id', 'app');
-
     const appStyle = document.createElement('style');
     appStyle.innerHTML = await getStyles();
 
-    rootElement.shadowRoot.appendChild(appContainer);
     rootElement.shadowRoot.appendChild(appStyle);
     rootElement.shadowRoot.appendChild(scriptEl);
 

+ 4 - 1
src/content/element-selector/main.js

@@ -7,7 +7,10 @@ import vueI18n from './vue-i18n';
 import '@/assets/css/tailwind.css';
 
 const rootElement = document.querySelector('div.automa-element-selector');
-const appRoot = rootElement.shadowRoot.querySelector('#app');
+const appRoot = document.createElement('div');
+appRoot.setAttribute('id', 'app');
+
+rootElement.shadowRoot.appendChild(appRoot);
 
 createApp(App)
   .provide('rootElement', rootElement)

+ 99 - 0
src/content/executed-block.js

@@ -0,0 +1,99 @@
+import { tasks } from '@/utils/shared';
+
+function generateElement(block) {
+  return `
+    <div style="display: flex; align-items: center">
+      <svg
+        xmlns="http://www.w3.org/2000/svg"
+        id="spinner"
+        fill="transparent"
+        width="24"
+        height="24"
+        viewBox="0 0 24 24"
+      >
+        <circle
+          class="opacity-25"
+          cx="12"
+          cy="12"
+          r="10"
+          stroke="currentColor"
+          stroke-width="4"
+        ></circle>
+        <path
+          class="opacity-75"
+          fill="currentColor"
+          d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+        ></path>
+      </svg>
+      <p id="block-name">${block.name}</p>
+    </div>
+  `;
+}
+
+export default function (data, enable) {
+  if (!enable) {
+    return () => {};
+  }
+
+  const block = tasks[data.name];
+  let container = document.querySelector('.automa-executed-block');
+
+  if (!container) {
+    container = document.createElement('div');
+    container.classList.add('automa-executed-block');
+    document.body.appendChild(container);
+
+    const style = document.createElement('style');
+    style.classList.add('automa-executed-block');
+    style.innerHTML = `
+      @keyframes spin {
+        from {
+          transform: rotate(0deg);
+        }
+        to {
+          transform: rotate(360deg);
+        }
+      }
+
+      .automa-executed-block .opacity-25 {
+        opacity: 0.25;
+      }
+      .automa-executed-block .opacity-75 {
+        opacity: 0.75;
+      }
+      .automa-executed-block {
+        color: #18181b;
+        width: 250px;
+        position: fixed;
+        border-radius: 12px;
+        bottom: 12px;
+        right: 12px;
+        padding: 14px;
+        background-color: white;
+        font-size: 16px;
+        font-family: sans-serif;
+        box-shadow: box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
+        z-index: 99999
+      }
+      .automa-executed-block #spinner {
+        color: currentColor;
+        display: inline-block;
+        animation: spin 1s linear infinite;
+      }
+      .automa-executed-block p {
+        margin: 0;
+        padding: 0;
+        margin-left: 8px;
+      }
+    `;
+    document.body.appendChild(style);
+  }
+  container.innerHTML = generateElement(block);
+
+  return () => {
+    const elements = document.querySelectorAll('.automa-executed-block');
+    elements.forEach((el) => {
+      el.remove();
+    });
+  };
+}

+ 23 - 9
src/content/index.js

@@ -1,6 +1,7 @@
 import browser from 'webextension-polyfill';
 import { toCamelCase } from '@/utils/helper';
 import elementSelector from './element-selector';
+import executedBlock from './executed-block';
 import blocksHandler from './blocks-handler';
 
 (() => {
@@ -9,18 +10,31 @@ import blocksHandler from './blocks-handler';
   window.isAutomaInjected = true;
 
   browser.runtime.onMessage.addListener((data) => {
-    if (data.isBlock) {
-      const handler = blocksHandler[toCamelCase(data.name)];
+    return new Promise((resolve, reject) => {
+      if (data.isBlock) {
+        const removeExecutedBlock = executedBlock(
+          data,
+          data.executedBlockOnWeb
+        );
 
-      if (handler) {
-        return handler(data);
-      }
-      console.error(`"${data.name}" doesn't have a handler`);
+        const handler = blocksHandler[toCamelCase(data.name)];
+
+        if (handler) {
+          handler(data)
+            .then((result) => {
+              removeExecutedBlock();
+              resolve(result);
+            })
+            .catch(reject);
 
-      return Promise.resolve('');
-    }
+          return;
+        }
+        console.error(`"${data.name}" doesn't have a handler`);
+
+        resolve('');
+        return;
+      }
 
-    return new Promise((resolve) => {
       if (data.type === 'content-script-exists') {
         resolve(true);
       } else if (data.type === 'select-element') {

+ 14 - 0
src/content/services/index.js

@@ -0,0 +1,14 @@
+import browser from 'webextension-polyfill';
+import webService from './web-service';
+import shortcutListener from './shortcut-listener';
+
+(async () => {
+  try {
+    const { workflows } = await browser.storage.local.get('workflows');
+
+    await webService(workflows);
+    await shortcutListener(workflows);
+  } catch (error) {
+    console.error(error);
+  }
+})();

+ 55 - 0
src/content/services/shortcut-listener.js

@@ -0,0 +1,55 @@
+import Mousetrap from 'mousetrap';
+import browser from 'webextension-polyfill';
+import { sendMessage } from '@/utils/message';
+
+Mousetrap.prototype.stopCallback = function () {
+  return false;
+};
+
+function getTriggerBlock(workflow) {
+  const drawflow = JSON.parse(workflow?.drawflow || '{}');
+
+  if (!drawflow?.drawflow?.Home?.data) return null;
+
+  const blocks = Object.values(drawflow.drawflow.Home.data);
+  const trigger = blocks.find(({ name }) => name === 'trigger');
+
+  return trigger;
+}
+
+export default async function (workflows) {
+  try {
+    const { shortcuts } = await browser.storage.local.get('shortcuts');
+    const shortcutsArr = Object.entries(shortcuts || {});
+
+    if (shortcutsArr.length === 0) return;
+
+    const keyboardShortcuts = shortcutsArr.reduce((acc, [id, value]) => {
+      const workflow = [...workflows].find((item) => item.id === id);
+
+      (acc[value] = acc[value] || []).push({
+        id,
+        workflow,
+        activeInInput: getTriggerBlock(workflow)?.data?.activeInInput,
+      });
+
+      return acc;
+    }, {});
+
+    Mousetrap.bind(Object.keys(keyboardShortcuts), ({ target }, command) => {
+      const isInputElement =
+        ['INPUT', 'SELECT', 'TEXTAREA'].includes(target.tagName) ||
+        target?.contentEditable === 'true';
+
+      keyboardShortcuts[command].forEach((item) => {
+        if (!item.activeInInput && isInputElement) return;
+
+        sendMessage('workflow:execute', item.workflow, 'background');
+      });
+
+      return true;
+    });
+  } catch (error) {
+    console.error(error);
+  }
+}

+ 9 - 62
src/content/shortcut.js → src/content/services/web-service.js

@@ -1,26 +1,10 @@
 import { openDB } from 'idb';
 import { nanoid } from 'nanoid';
-import Mousetrap from 'mousetrap';
-import browser from 'webextension-polyfill';
 import secrets from 'secrets';
+import browser from 'webextension-polyfill';
 import { objectHasKey } from '@/utils/helper';
 import { sendMessage } from '@/utils/message';
 
-Mousetrap.prototype.stopCallback = function () {
-  return false;
-};
-
-function getTriggerBlock(workflow) {
-  const drawflow = JSON.parse(workflow?.drawflow || '{}');
-
-  if (!drawflow?.drawflow?.Home?.data) return null;
-
-  const blocks = Object.values(drawflow.drawflow.Home.data);
-  const trigger = blocks.find(({ name }) => name === 'trigger');
-
-  return trigger;
-}
-
 function initWebListener() {
   const listeners = {};
 
@@ -38,6 +22,7 @@ function initWebListener() {
 
   return { on };
 }
+
 async function listenWindowMessage(workflows) {
   try {
     if (secrets?.webOrigin !== window.location.origin) return;
@@ -78,49 +63,11 @@ async function listenWindowMessage(workflows) {
   }
 }
 
-(async () => {
-  try {
-    const { shortcuts, workflows } = await browser.storage.local.get([
-      'shortcuts',
-      'workflows',
-    ]);
-    const shortcutsArr = Object.entries(shortcuts || {});
-
-    listenWindowMessage(workflows);
-
-    document.body.setAttribute(
-      'data-atm-ext-installed',
-      browser.runtime.getManifest().version
-    );
+export default async function (workflows) {
+  await listenWindowMessage(workflows);
 
-    if (shortcutsArr.length === 0) return;
-
-    const keyboardShortcuts = shortcutsArr.reduce((acc, [id, value]) => {
-      const workflow = [...workflows].find((item) => item.id === id);
-
-      (acc[value] = acc[value] || []).push({
-        id,
-        workflow,
-        activeInInput: getTriggerBlock(workflow)?.data?.activeInInput,
-      });
-
-      return acc;
-    }, {});
-
-    Mousetrap.bind(Object.keys(keyboardShortcuts), ({ target }, command) => {
-      const isInputElement =
-        ['INPUT', 'SELECT', 'TEXTAREA'].includes(target.tagName) ||
-        target?.contentEditable === 'true';
-
-      keyboardShortcuts[command].forEach((item) => {
-        if (!item.activeInInput && isInputElement) return;
-
-        sendMessage('workflow:execute', item.workflow, 'background');
-      });
-
-      return true;
-    });
-  } catch (error) {
-    console.error(error);
-  }
-})();
+  document.body.setAttribute(
+    'data-atm-ext-installed',
+    browser.runtime.getManifest().version
+  );
+}

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

@@ -174,7 +174,16 @@
       },
       "export-data": {
         "name": "Export data",
-        "description": "Export workflow data columns"
+        "description": "Export workflow data columns",
+        "exportAs": "Export as",
+        "refKey": "Reference key",
+        "dataToExport": {
+          "placeholder": "Data to export",
+          "options": {
+            "data-columns": "Data columns",
+            "google-sheets": "Google sheets",
+          },
+        }
       },
       "element-scroll": {
         "name": "Scroll element",

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

@@ -14,7 +14,7 @@
     },
     "menu": {
       "general": "General"
-    }
+    },
   },
   "workflow": {
     "import": "Import workflow",
@@ -60,6 +60,8 @@
         "title": "Block delay (milliseconds)",
         "description": "Add delay before executing each of the blocks"
       },
+      "saveLog": "Save workflow log",
+      "executedBlockOnWeb": "Show executed block on web page"
     }
   },
   "collection": {

+ 1 - 1
src/manifest.json

@@ -20,7 +20,7 @@
         "<all_urls>"
       ],
       "js": [
-        "shortcut.bundle.js"
+        "services.bundle.js"
       ],
       "run_at": "document_end",
       "all_frames": false

+ 1 - 0
src/models/log.js

@@ -13,6 +13,7 @@ class Log extends Model {
       endedAt: this.number(0),
       message: this.string(''),
       startedAt: this.number(0),
+      parentLog: this.attr(null),
       workflowId: this.attr(null),
       collectionId: this.attr(null),
       status: this.string('success'),

+ 1 - 1
src/models/workflow.js

@@ -26,8 +26,8 @@ class Workflow extends Model {
       settings: this.attr({
         blockDelay: 0,
         saveLog: true,
-        timeout: 120000,
         onError: 'stop-workflow',
+        executedBlockOnWeb: false,
       }),
       logs: this.hasMany(Log, 'workflowId'),
     };

+ 4 - 1
src/newtab/App.vue

@@ -55,7 +55,10 @@ function handleStorageChanged(change) {
   if (change.workflowState) {
     store.commit('updateState', {
       key: 'workflowState',
-      value: change.workflowState.newValue,
+      value: Object.values(change.workflowState.newValue || {}).filter(
+        ({ isDestroyed, parentState }) =>
+          !isDestroyed && !parentState?.isCollection
+      ),
     });
   }
 }

+ 15 - 7
src/newtab/pages/Collections.vue

@@ -14,7 +14,11 @@
         prepend-icon="riSearch2Line"
         class="flex-1"
       />
-      <ui-button variant="accent" @click="newCollection">
+      <ui-button
+        :title="shortcut['action:new'].readable"
+        variant="accent"
+        @click="newCollection"
+      >
         {{ t('collection.new') }}
       </ui-button>
     </div>
@@ -41,7 +45,7 @@
         icon="riFolderLine"
         @click="$router.push(`/collections/${$event.id}`)"
         @execute="executeCollection"
-        @menuSelected="menuHandlers[$event.name]($event.data)"
+        @menuSelected="menuHandlers[$event.id]($event.data)"
       />
     </div>
   </div>
@@ -57,11 +61,6 @@ 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' },
@@ -124,5 +123,14 @@ function deleteCollection({ name, id }) {
   });
 }
 
+const shortcut = useShortcut(['action:search', 'action:new'], ({ id }) => {
+  if (id === 'action:search') {
+    const searchInput = document.querySelector('#search-input input');
+    searchInput?.focus();
+  } else {
+    newCollection();
+  }
+});
+
 const menuHandlers = { rename: renameCollection, delete: deleteCollection };
 </script>

+ 4 - 1
src/newtab/pages/Home.vue

@@ -66,7 +66,10 @@ const workflows = computed(() =>
 );
 const logs = computed(() =>
   Log.query()
-    .where(({ isInCollection, isChildLog }) => !isInCollection && !isChildLog)
+    .where(
+      ({ isInCollection, isChildLog, parentLog }) =>
+        !isInCollection && !isChildLog && !parentLog
+    )
     .orderBy('startedAt', 'desc')
     .limit(10)
     .get()

+ 18 - 16
src/newtab/pages/Logs.vue

@@ -121,27 +121,29 @@ const exportDataModal = shallowReactive({
 
 const filteredLogs = computed(() =>
   Log.query()
-    .where(({ name, status, startedAt, isInCollection, isChildLog }) => {
-      if (isInCollection || isChildLog) return false;
+    .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());
+        let statusFilter = true;
+        let dateFilter = true;
+        const searchFilter = name
+          .toLocaleLowerCase()
+          .includes(filtersBuilder.query.toLocaleLowerCase());
 
-      if (filtersBuilder.byStatus !== 'all') {
-        statusFilter = status === filtersBuilder.byStatus;
-      }
+        if (filtersBuilder.byStatus !== 'all') {
+          statusFilter = status === filtersBuilder.byStatus;
+        }
 
-      if (filtersBuilder.byDate > 0) {
-        const date = Date.now() - filtersBuilder.byDate * 24 * 60 * 60 * 1000;
+        if (filtersBuilder.byDate > 0) {
+          const date = Date.now() - filtersBuilder.byDate * 24 * 60 * 60 * 1000;
 
-        dateFilter = date <= startedAt;
-      }
+          dateFilter = date <= startedAt;
+        }
 
-      return searchFilter && statusFilter && dateFilter;
-    })
+        return searchFilter && statusFilter && dateFilter;
+      }
+    )
     .orderBy(sortsBuilder.by, sortsBuilder.order)
     .get()
 );

+ 14 - 7
src/newtab/pages/Workflows.vue

@@ -54,7 +54,11 @@
         <v-remixicon name="riUploadLine" class="mr-2 -ml-1" />
         {{ t('workflow.import') }}
       </ui-button>
-      <ui-button variant="accent" @click="newWorkflow">
+      <ui-button
+        :title="shortcut['action:new'].readable"
+        variant="accent"
+        @click="newWorkflow"
+      >
         {{ t('workflow.new') }}
       </ui-button>
     </div>
@@ -174,19 +178,14 @@
 import { computed, shallowReactive, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useDialog } from '@/composable/dialog';
+import { useShortcut } from '@/composable/shortcut';
 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 = [
@@ -287,6 +286,14 @@ async function handleWorkflowModal() {
   }
 }
 
+const shortcut = useShortcut(['action:search', 'action:new'], ({ id }) => {
+  if (id === 'action:search') {
+    const searchInput = document.querySelector('#search-input input');
+    searchInput?.focus();
+  } else {
+    newWorkflow();
+  }
+});
 const menuHandlers = {
   export: exportWorkflow,
   rename: renameWorkflow,

+ 1 - 3
src/newtab/pages/collections/[id].vue

@@ -243,9 +243,7 @@ const collectionOptions = shallowReactive({
 });
 
 const runningCollection = computed(() =>
-  store.state.workflowState.filter(
-    ({ collectionId }) => collectionId === route.params.id
-  )
+  store.state.workflowState.filter(({ id }) => id === route.params.id)
 );
 const logs = computed(() =>
   Log.query()

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

@@ -25,7 +25,7 @@
         <ui-list>
           <router-link
             v-if="collectionLog"
-            :to="activeLog.collectionLogId"
+            :to="activeLog.parentLog?.id || activeLog.collectionLogId"
             replace
             class="mb-4 flex"
           >
@@ -176,7 +176,14 @@ const history = computed(() =>
     )
     .map(translateLog)
 );
-const collectionLog = computed(() => Log.find(activeLog.value.collectionLogId));
+
+const collectionLog = computed(() => {
+  if (activeLog.value.parentLog) {
+    return Log.find(activeLog.value.parentLog.id);
+  }
+
+  return Log.find(activeLog.value.collectionLogId);
+});
 
 function deleteLog() {
   Log.delete(route.params.id).then(() => {

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

@@ -148,10 +148,12 @@ import {
 import { useStore } from 'vuex';
 import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router';
 import { useI18n } from 'vue-i18n';
+import defu from 'defu';
 import emitter from 'tiny-emitter/instance';
+import { useDialog } from '@/composable/dialog';
+import { tasks } from '@/utils/shared';
 import { sendMessage } from '@/utils/message';
 import { debounce, isObject } from '@/utils/helper';
-import { useDialog } from '@/composable/dialog';
 import { exportWorkflow } from '@/utils/workflow-data';
 import Log from '@/models/log';
 import Workflow from '@/models/workflow';
@@ -215,7 +217,7 @@ const logs = computed(() =>
     .where(
       (item) =>
         item.workflowId === workflowId &&
-        (!item.isInCollection || !item.isChildLog)
+        (!item.isInCollection || !item.isChildLog || !item.parentLog)
     )
     .orderBy('startedAt', 'desc')
     .get()
@@ -296,7 +298,7 @@ function saveWorkflow() {
 }
 function editBlock(data) {
   state.isEditBlock = true;
-  state.blockData = data;
+  state.blockData = defu(data, tasks[data.id] || {});
 }
 function executeWorkflow() {
   if (editor.value.getNodesFromName('trigger').length === 0) {

+ 4 - 1
src/store/index.js

@@ -84,7 +84,10 @@ const store = createStore({
 
         commit('updateState', {
           key: 'workflowState',
-          value: workflowState || [],
+          value: Object.values(workflowState || {}).filter(
+            ({ isDestroyed, parentState }) =>
+              !isDestroyed && !parentState?.isCollection
+          ),
         });
       } catch (error) {
         console.error(error);

+ 6 - 0
src/utils/helper.js

@@ -38,6 +38,12 @@ export function parseJSON(data, def) {
   }
 }
 
+export function parseFlow(flow) {
+  const obj = typeof flow === 'string' ? parseJSON(flow, {}) : flow;
+
+  return obj?.drawflow?.Home.data;
+}
+
 export function replaceMustache(str, replacer) {
   /* eslint-disable-next-line */
   return str.replace(/\{\{(.*?)\}\}/g, replacer);

+ 7 - 6
src/utils/reference-data/index.js

@@ -26,7 +26,7 @@ export const funcs = {
   },
 };
 
-export default function ({ block, data }) {
+export default function ({ block, data: refData }) {
   const replaceKeys = [
     'url',
     'name',
@@ -40,15 +40,16 @@ export default function ({ block, data }) {
     'extraRowValue',
   ];
   let replacedBlock = { ...block };
-  const refData = Object.assign(data, { funcs });
+  const data = Object.assign(refData, { funcs });
 
   replaceKeys.forEach((blockDataKey) => {
     if (!objectHasKey(block.data, blockDataKey)) return;
 
-    const newDataValue = mustacheReplacer(
-      replacedBlock.data[blockDataKey],
-      refData
-    );
+    const newDataValue = mustacheReplacer({
+      data,
+      block,
+      str: replacedBlock.data[blockDataKey],
+    });
 
     replacedBlock = setObjectPath(
       replacedBlock,

+ 14 - 10
src/utils/reference-data/mustache-replacer.js

@@ -1,5 +1,5 @@
 import { get as getObjectPath } from 'object-path-immutable';
-import { replaceMustache, isObject } from '../helper';
+import { replaceMustache } from '../helper';
 import keyParser from './key-parser';
 
 export function extractStrFunction(str) {
@@ -13,24 +13,28 @@ export function extractStrFunction(str) {
   };
 }
 
-export default function (str, data) {
+export default function ({ str, data, block }) {
   const replacedStr = replaceMustache(str, (match) => {
     const key = match.slice(2, -2).replace(/\s/g, '');
 
     if (!key) return '';
 
+    let result = '';
     const funcRef = extractStrFunction(key);
 
     if (funcRef && data.funcs[funcRef.name]) {
-      return data.funcs[funcRef.name]?.apply({ refData: data }, funcRef.params);
+      result = data.funcs[funcRef.name]?.apply(
+        { refData: data },
+        funcRef.params
+      );
+    } else {
+      const { dataKey, path } = keyParser(key);
+      result = getObjectPath(data[dataKey], path) ?? match;
     }
-
-    const { dataKey, path } = keyParser(key);
-    const result = getObjectPath(data[dataKey], path) ?? match;
-
-    return isObject(result) || Array.isArray(result)
-      ? JSON.stringify(result)
-      : result;
+    if (block.name === 'webhook') {
+      return JSON.stringify(result);
+    }
+    return typeof result === 'string' ? result : JSON.stringify(result);
   });
 
   return replacedStr;

+ 6 - 2
src/utils/shared.js

@@ -238,8 +238,8 @@ export const tasks = {
   'export-data': {
     name: 'Export data',
     icon: 'riDownloadLine',
-    component: 'BlockExportData',
-    editComponent: 'EditTrigger',
+    component: 'BlockBasic',
+    editComponent: 'EditExportData',
     category: 'general',
     inputs: 1,
     outputs: 1,
@@ -247,7 +247,10 @@ export const tasks = {
     maxConnection: 1,
     data: {
       name: '',
+      refKey: '',
       type: 'json',
+      description: '',
+      dataToExport: 'data-columns',
     },
   },
   'element-scroll': {
@@ -426,6 +429,7 @@ export const tasks = {
     allowedInputs: true,
     maxConnection: 1,
     data: {
+      description: '',
       spreadsheetId: '',
       type: 'get',
       range: '',

+ 2 - 2
webpack.config.js

@@ -43,7 +43,7 @@ const options = {
     popup: path.join(__dirname, 'src', 'popup', 'index.js'),
     background: path.join(__dirname, 'src', 'background', 'index.js'),
     contentScript: path.join(__dirname, 'src', 'content', 'index.js'),
-    shortcut: path.join(__dirname, 'src', 'content', 'shortcut.js'),
+    services: path.join(__dirname, 'src', 'content', 'services', 'index.js'),
     elementSelector: path.join(
       __dirname,
       'src',
@@ -53,7 +53,7 @@ const options = {
     ),
   },
   chromeExtensionBoilerplate: {
-    notHotReload: ['contentScript', 'shortcut', 'elementSelector'],
+    notHotReload: ['contentScript', 'services', 'elementSelector'],
   },
   output: {
     path: path.resolve(__dirname, 'build'),

+ 5 - 0
yarn.lock

@@ -2748,6 +2748,11 @@ defined@^1.0.0:
   resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.0.tgz#c98d9bcef75674188e110969151199e39b1fa693"
   integrity sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=
 
+defu@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/defu/-/defu-5.0.0.tgz#5768f0d402a555bfc4c267246b20f82ce8b5a10b"
+  integrity sha512-VHg73EDeRXlu7oYWRmmrNp/nl7QkdXUxkQQKig0Zk8daNmm84AbGoC8Be6/VVLJEKxn12hR0UBmz8O+xQiAPKQ==
+
 del@^4.1.1:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/del/-/del-4.1.1.tgz#9e8f117222ea44a31ff3a156c049b99052a9f0b4"