浏览代码

feat: add execute workflow block

Ahmad Kholid 3 年之前
父节点
当前提交
b3e49a4b38

+ 72 - 0
src/background/workflow-engine/blocks-handler/handler-execute-workflow.js

@@ -0,0 +1,72 @@
+import browser from 'webextension-polyfill';
+import WorkflowEngine from '../index';
+import { getBlockConnection } from '../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 }) => {
+      if (status === 'error') {
+        const error = new Error(message);
+        error.data = { logId: id, name: currentBlock.name };
+
+        reject(error);
+        return;
+      }
+
+      resolve({ id, status, message });
+    });
+
+    options.events.onInit(engine);
+  });
+}
+
+async function executeWorkflow(block) {
+  const nextBlockId = getBlockConnection(block);
+  const { data } = block;
+
+  try {
+    if (data.workflowId === '') throw new Error('empty-workflow');
+
+    const { workflows } = await browser.storage.local.get('workflows');
+    const workflow = workflows.find(({ id }) => id === data.workflowId);
+
+    if (!workflow) {
+      const errorInstance = new Error('no-workflow');
+      errorInstance.data = { workflowId: data.workflowId };
+
+      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,
+    };
+
+    if (workflow.drawflow.includes(this.workflow.id)) {
+      throw new Error('workflow-infinite-loop');
+    }
+
+    const result = await workflowListener(workflow, options);
+
+    return {
+      data: '',
+      logId: result.id,
+      nextBlockId,
+    };
+  } catch (error) {
+    error.nextBlockId = nextBlockId;
+
+    throw error;
+  }
+}
+
+export default executeWorkflow;

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

@@ -1,4 +1,4 @@
-import { objectHasKey } from '@/utils/helper';
+import { objectHasKey, isObject } from '@/utils/helper';
 import { getBlockConnection, convertData } from '../helper';
 
 async function interactionHandler(block, prevBlockData) {

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

@@ -2,7 +2,7 @@ import browser from 'webextension-polyfill';
 import { getBlockConnection } from '../helper';
 import { fileSaver } from '@/utils/helper';
 
-function saveImage(filename, fileName, uri) {
+function saveImage({ fileName, uri, ext }) {
   const image = new Image();
 
   image.onload = () => {
@@ -52,11 +52,11 @@ async function takeScreenshot(block) {
         await browser.tabs.update(tab.id, { active: true });
       }
 
-      saveImage(fileName, uri);
+      saveImage({ fileName, uri, ext });
     } else {
       const uri = await browser.tabs.captureVisibleTab(options);
 
-      saveImage(fileName, uri);
+      saveImage({ fileName, uri, ext });
     }
 
     return { data: '', nextBlockId };

+ 47 - 14
src/background/workflow-engine/engine.js

@@ -63,7 +63,14 @@ function tabUpdatedHandler(tabId, changeInfo, tab) {
 class WorkflowEngine {
   constructor(
     workflow,
-    { globalData, tabId = null, isInCollection, collectionLogId, blocksHandler }
+    {
+      globalData,
+      tabId = null,
+      isInCollection,
+      collectionLogId,
+      blocksHandler,
+      parentWorkflow,
+    }
   ) {
     const globalDataVal = globalData || workflow.globalData;
 
@@ -72,6 +79,7 @@ class WorkflowEngine {
     this.workflow = workflow;
     this.blocksHandler = blocksHandler;
     this.isInCollection = isInCollection;
+    this.parentWorkflow = parentWorkflow;
     this.collectionLogId = collectionLogId;
     this.globalData = parseJSON(globalDataVal, globalDataVal);
     this.activeTabUrl = '';
@@ -89,6 +97,7 @@ class WorkflowEngine {
     this.windowId = null;
     this.tabGroupId = null;
     this.currentBlock = null;
+    this.childWorkflow = null;
     this.workflowTimeout = null;
 
     this.tabUpdatedListeners = {};
@@ -160,22 +169,27 @@ class WorkflowEngine {
     workflowState.update(this.id, this.state);
   }
 
-  stop(message) {
-    this.logs.push({
-      message,
-      type: 'stop',
-      name: 'stop',
-    });
-    this.destroy('stopped');
+  async stop(message) {
+    try {
+      if (this.childWorkflow) {
+        console.log('setop', this.childWorkflow);
+        await this.childWorkflow.stop();
+      }
+
+      this.logs.push({
+        message,
+        type: 'stop',
+        name: 'stop',
+      });
+
+      await this.destroy('stopped');
+    } catch (error) {
+      console.error(error);
+    }
   }
 
   async destroy(status, message) {
     try {
-      this.dispatchEvent('destroyed', { id: this.id, status, message });
-
-      this.eventListeners = {};
-      this.tabUpdatedListeners = {};
-
       await browser.tabs.onRemoved.removeListener(this.tabRemovedHandler);
       await browser.tabs.onUpdated.removeListener(this.tabUpdatedHandler);
 
@@ -200,12 +214,23 @@ class WorkflowEngine {
           history: this.logs,
           endedAt: this.endedTimestamp,
           startedAt: this.startedTimestamp,
+          isChildLog: !!this.parentWorkflow,
           isInCollection: this.isInCollection,
           collectionLogId: this.collectionLogId,
         });
 
         await browser.storage.local.set({ logs });
       }
+
+      this.dispatchEvent('destroyed', {
+        id: this.id,
+        status,
+        message,
+        currentBlock: this.currentBlock,
+      });
+
+      this.eventListeners = {};
+      this.tabUpdatedListeners = {};
     } catch (error) {
       console.error(error);
     }
@@ -239,6 +264,8 @@ class WorkflowEngine {
     state.name = this.workflow.name;
     state.icon = this.workflow.icon;
 
+    if (this.parentWorkflow) state.parentState = this.parentWorkflow;
+
     return state;
   }
 
@@ -256,6 +283,7 @@ class WorkflowEngine {
 
     if (!disableTimeoutKeys.includes(block.name)) {
       this.workflowTimeout = setTimeout(() => {
+        alert('timeout');
         if (!this.isDestroyed) this.stop('stop-timeout');
       }, this.workflow.settings.timeout || 120000);
     }
@@ -289,6 +317,7 @@ class WorkflowEngine {
           this.logs.push({
             type: 'success',
             name: block.name,
+            logId: result.logId,
             duration: Math.round(Date.now() - started),
           });
 
@@ -308,6 +337,7 @@ class WorkflowEngine {
             type: 'error',
             message: error.message,
             name: block.name,
+            ...(error.data || {}),
           });
 
           if (
@@ -335,7 +365,10 @@ class WorkflowEngine {
   _sendMessageToTab(payload, options = {}) {
     return new Promise((resolve, reject) => {
       if (!this.tabId) {
-        reject(new Error('no-tab'));
+        const error = new Error('no-tab');
+        error.workflowId = this.id;
+
+        reject(error);
         return;
       }
 

+ 11 - 1
src/components/newtab/shared/SharedWorkflowState.vue

@@ -22,7 +22,11 @@
       >
         <v-remixicon name="riExternalLinkLine" />
       </ui-button>
-      <ui-button variant="accent" @click="stopWorkflow">
+      <ui-button
+        variant="accent"
+        :disabled="!!data.state.parentState"
+        @click="stopWorkflow"
+      >
         <v-remixicon name="riStopLine" class="mr-2 -ml-1" />
         <span>{{ t('common.stop') }}</span>
       </ui-button>
@@ -38,6 +42,12 @@
         <ui-spinner color="text-accnet" size="20" />
       </div>
     </div>
+    <div
+      v-if="data.state.parentState"
+      class="py-2 px-4 bg-yellow-200 rounded-lg mt-2 text-sm"
+    >
+      {{ t('workflow.state.executeBy', { name: data.state.parentState.name }) }}
+    </div>
   </ui-card>
 </template>
 <script setup>

+ 89 - 0
src/components/newtab/workflow/edit/EditExecuteWorkflow.vue

@@ -0,0 +1,89 @@
+<template>
+  <div>
+    <ui-select
+      :model-value="data.workflowId"
+      :placeholder="t('workflow.blocks.execute-workflow.select')"
+      class="w-full mb-4"
+      @change="updateData({ workflowId: $event })"
+    >
+      <option
+        v-for="workflow in workflows"
+        :key="workflow.id"
+        :value="workflow.id"
+      >
+        {{ workflow.name }}
+      </option>
+    </ui-select>
+    <p>{{ t('common.globalData') }}</p>
+    <prism-editor
+      v-if="!state.showGlobalData"
+      :model-value="data.globalData"
+      :highlight="highlighter('json')"
+      readonly
+      class="p-4 max-h-80"
+      @click="state.showGlobalData = true"
+    />
+    <ui-modal
+      v-model="state.showGlobalData"
+      title="Global data"
+      content-class="max-w-xl"
+    >
+      <p>{{ t('workflow.blocks.execute-workflow.overwriteNote') }}</p>
+      <prism-editor
+        :model-value="state.globalData"
+        :highlight="highlighter('json')"
+        class="w-full scroll"
+        style="height: calc(100vh - 10rem)"
+        @input="updateGlobalData"
+      />
+    </ui-modal>
+  </div>
+</template>
+<script setup>
+import { computed, shallowReactive } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useRoute } from 'vue-router';
+import { PrismEditor } from 'vue-prism-editor';
+import { highlighter } from '@/lib/prism';
+import Workflow from '@/models/workflow';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+  hideBase: {
+    type: Boolean,
+    default: false,
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const { t } = useI18n();
+const route = useRoute();
+
+const state = shallowReactive({
+  showGlobalData: false,
+  globalData: `${props.data.globalData}`,
+});
+
+const workflows = computed(() =>
+  Workflow.query()
+    .where(
+      ({ id, drawflow }) =>
+        id !== route.params.id && !drawflow.includes(route.params.id)
+    )
+    .orderBy('name', 'asc')
+    .get()
+);
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+function updateGlobalData(event) {
+  const { value } = event.target;
+
+  state.globalData = value;
+  updateData({ globalData: value });
+}
+</script>

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

@@ -95,8 +95,8 @@
 </template>
 <script setup>
 import { watch, reactive } from 'vue';
-import { PrismEditor } from 'vue-prism-editor';
 import { useI18n } from 'vue-i18n';
+import { PrismEditor } from 'vue-prism-editor';
 import { highlighter } from '@/lib/prism';
 
 const props = defineProps({

+ 6 - 0
src/locales/en/blocks.json

@@ -57,6 +57,12 @@
           "keyboard-shortcut": "Keyboard shortcut"
         }
       },
+      "execute-workflow": {
+        "name": "Execute workflow",
+        "overwriteNote": "This will overwrite the global data of the selected workflow",
+        "select": "Select workflow",
+        "description": ""
+      },
       "active-tab": {
         "name": "Active tab",
         "description": "Set current tab that you're in as an active tab"

+ 13 - 7
src/locales/en/newtab.json

@@ -7,7 +7,7 @@
       "label": "Language",
       "helpTranslate": "Can't find your language? Help translate.",
       "reloadPage": "Reload the page to take effect"
-    },
+    }
   },
   "workflow": {
     "import": "Import workflow",
@@ -17,6 +17,9 @@
     "rename": "Rename workflow",
     "add": "Add workflow",
     "clickToEnable": "Click to enable",
+    "state": {
+      "executeBy": "Executed by: \"{name}\""
+    },
     "dataColumns": {
       "title": "Data columns",
       "placeholder": "Search or add column",
@@ -80,7 +83,10 @@
       "workflow-disabled": "Workflow is disabled",
       "stop-timeout": "Workflow is stopped because of timeout",
       "no-iframe-id": "Can't find Frame ID for the iframe element with \"{selector}\" selector",
-      "no-tab": "Can't connect to a tab, use \"New tab\" or \"Active tab\" block before using the \"{name}\" block."
+      "no-tab": "Can't connect to a tab, use \"New tab\" or \"Active tab\" block before using the \"{name}\" block.",
+      "empty-workflow": "You must select a workflow first",
+      "no-workflow": "Can't find workflow with \"{workflowId}\" ID",
+      "workflow-infinite-loop": "Can't execute the workflow to prevent an infinite loop"
     },
     "description": {
       "text": "{status} on {date} in {duration}",
@@ -100,7 +106,7 @@
         "json": "JSON",
         "csv": "CSV",
         "plain-text": "Plain text"
-      },
+      }
     },
     "filter": {
       "title": "Filter",
@@ -110,10 +116,10 @@
         "items": {
           "lastDay": "Last day",
           "last7Days": "Last seven days",
-          "last30Days": "Last thirty days",
+          "last30Days": "Last thirty days"
         }
-      },
-    },
+      }
+    }
   },
   "components": {
     "pagination": {
@@ -124,5 +130,5 @@
       "prevPage": "Previous page",
       "of": "of {page}"
     }
-  },
+  }
 }

+ 1 - 0
src/models/log.js

@@ -15,6 +15,7 @@ class Log extends Model {
       workflowId: this.attr(null),
       collectionId: this.attr(null),
       status: this.string('success'),
+      isChildLog: this.boolean(false),
       collectionLogId: this.attr(null),
       icon: this.string('riGlobalLine'),
       isInCollection: this.boolean(false),

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

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

+ 4 - 2
src/newtab/pages/Logs.vue

@@ -121,7 +121,9 @@ const exportDataModal = shallowReactive({
 
 const filteredLogs = computed(() =>
   Log.query()
-    .where(({ name, status, startedAt, isInCollection }) => {
+    .where(({ name, status, startedAt, isInCollection, isChildLog }) => {
+      if (isInCollection || isChildLog) return false;
+
       let statusFilter = true;
       let dateFilter = true;
       const searchFilter = name
@@ -138,7 +140,7 @@ const filteredLogs = computed(() =>
         dateFilter = date <= startedAt;
       }
 
-      return !isInCollection && searchFilter && statusFilter && dateFilter;
+      return searchFilter && statusFilter && dateFilter;
     })
     .orderBy(sortsBuilder.by, sortsBuilder.order)
     .get()

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

@@ -285,8 +285,8 @@ const runningCollection = computed(() =>
 const logs = computed(() =>
   Log.query()
     .where(
-      ({ collectionId, isInCollection }) =>
-        collectionId === route.params.id && !isInCollection
+      ({ collectionId, isInCollection, isChildLog }) =>
+        collectionId === route.params.id && (!isInCollection || !isChildLog)
     )
     .orderBy('startedAt', 'desc')
     .limit(10)

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

@@ -148,25 +148,28 @@ const pagination = shallowReactive({
 });
 
 function translateLog(log) {
-  const { name, message, type } = log;
+  const copyLog = { ...log };
   const getTranslatation = (path, def) => {
     const params = typeof path === 'string' ? { path } : path;
 
     return te(params.path) ? t(params.path, params.params) : def;
   };
 
-  if (['finish', 'stop'].includes(type)) {
-    log.name = t(`log.types.${type}`);
+  if (['finish', 'stop'].includes(log.type)) {
+    copyLog.name = t(`log.types.${log.type}`);
   } else {
-    log.name = getTranslatation(`workflow.blocks.${name}.name`, name);
+    copyLog.name = getTranslatation(
+      `workflow.blocks.${log.name}.name`,
+      log.name
+    );
   }
 
-  log.message = getTranslatation(
-    { path: `log.messages.${message}`, params: log },
+  copyLog.message = getTranslatation(
+    { path: `log.messages.${log.message}`, params: log },
     ''
   );
 
-  return log;
+  return copyLog;
 }
 
 const activeLog = computed(() => Log.find(route.params.id));

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

@@ -187,7 +187,11 @@ const workflowState = computed(() =>
 const workflow = computed(() => Workflow.find(workflowId) || {});
 const logs = computed(() =>
   Log.query()
-    .where((item) => item.workflowId === workflowId && !item.isInCollection)
+    .where(
+      (item) =>
+        item.workflowId === workflowId &&
+        (!item.isInCollection || !item.isChildLog)
+    )
     .orderBy('startedAt', 'desc')
     .get()
 );

+ 16 - 0
src/utils/shared.js

@@ -27,6 +27,22 @@ export const tasks = {
       days: [],
     },
   },
+  'execute-workflow': {
+    name: 'Execute workflow',
+    description: '',
+    icon: 'riFlowChart',
+    component: 'BlockBasic',
+    category: 'general',
+    editComponent: 'EditExecuteWorkflow',
+    inputs: 1,
+    outputs: 1,
+    allowedInputs: true,
+    maxConnection: 1,
+    data: {
+      workflowId: '',
+      globalData: '',
+    },
+  },
   'active-tab': {
     name: 'Active tab',
     description: "Set current tab that you're in as an active tab",