Explorar o código

feat: add record workflow

Ahmad Kholid %!s(int64=3) %!d(string=hai) anos
pai
achega
6a4d8b0708

+ 116 - 3
src/background/index.js

@@ -9,6 +9,7 @@ import blocksHandler from './workflow-engine/blocks-handler';
 import WorkflowLogger from './workflow-logger';
 import decryptFlow, { getWorkflowPass } from '@/utils/decrypt-flow';
 
+const validateUrl = (str) => str?.startsWith('http');
 const storage = {
   async get(key) {
     try {
@@ -65,6 +66,19 @@ const workflow = {
   },
 };
 
+async function updateRecording(callback) {
+  const { isRecording, recording } = await browser.storage.local.get([
+    'isRecording',
+    'recording',
+  ]);
+
+  if (!isRecording || !recording) return;
+
+  callback(recording);
+
+  await browser.storage.local.set({ recording });
+}
+
 async function checkWorkflowStates() {
   const states = await workflow.states.get();
   // const sessionStates = parseJSON(sessionStorage.getItem('workflowState'), {});
@@ -89,7 +103,9 @@ async function checkWorkflowStates() {
   await storage.set('workflowState', states);
 }
 checkWorkflowStates();
-async function checkVisitWebTriggers(states, tab) {
+async function checkVisitWebTriggers(changeInfo, tab) {
+  if (!changeInfo.status || changeInfo.status !== 'complete') return;
+
   const visitWebTriggers = await storage.get('visitWebTriggers');
   const triggeredWorkflow = visitWebTriggers.find(({ url, isRegex }) => {
     if (url.trim() === '') return false;
@@ -103,10 +119,107 @@ async function checkVisitWebTriggers(states, tab) {
     if (workflowData) workflow.execute(workflowData);
   }
 }
+async function checkRecordingWorkflow({ status }, { url, id }) {
+  if (status === 'complete' && validateUrl(url)) {
+    const { isRecording } = await browser.storage.local.get('isRecording');
+
+    if (!isRecording) return;
+
+    await browser.tabs.executeScript(id, {
+      file: 'recordWorkflow.bundle.js',
+    });
+  }
+}
 browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
-  if (changeInfo.status === 'complete') {
-    await checkVisitWebTriggers(null, tab);
+  checkRecordingWorkflow(changeInfo, tab);
+  checkVisitWebTriggers(changeInfo, tab);
+});
+browser.webNavigation.onCommitted.addListener(
+  ({ frameId, tabId, url, transitionType }) => {
+    const allowedType = ['link', 'typed', 'form_submit'];
+
+    if (frameId !== 0 || !allowedType.includes(transitionType)) return;
+
+    updateRecording((recording) => {
+      const lastFlow = recording.flows[recording.flows.length - 1];
+      const isClickSubmit =
+        lastFlow.id === 'event-click' && transitionType === 'form_submit';
+
+      if (isClickSubmit) return;
+
+      const isInvalidNewtabFlow =
+        lastFlow &&
+        lastFlow.id === 'new-tab' &&
+        !validateUrl(lastFlow.data.url);
+
+      if (isInvalidNewtabFlow) {
+        lastFlow.data.url = url;
+        lastFlow.description = url;
+      } else if (validateUrl(url)) {
+        if (lastFlow?.id !== 'link' || !lastFlow.isClickLink) {
+          recording.flows.push({
+            id: 'new-tab',
+            description: url,
+            data: {
+              url,
+              updatePrevTab: recording.activeTab.id === tabId,
+            },
+          });
+        }
+
+        recording.activeTab.id = tabId;
+        recording.activeTab.url = url;
+      }
+    });
   }
+);
+browser.tabs.onActivated.addListener(async ({ tabId }) => {
+  const { url, id } = await browser.tabs.get(tabId);
+
+  if (!validateUrl(url)) return;
+
+  updateRecording((recording) => {
+    recording.activeTab = { id, url };
+    recording.flows.push({
+      id: 'switch-tab',
+      description: url,
+      data: {
+        url,
+        matchPattern: url,
+        createIfNoMatch: true,
+      },
+    });
+  });
+});
+browser.tabs.onCreated.addListener(async (tab) => {
+  const { isRecording, recording } = await browser.storage.local.get([
+    'isRecording',
+    'recording',
+  ]);
+
+  if (!isRecording || !recording) return;
+
+  const url = tab.url || tab.pendingUrl;
+  const lastFlow = recording.flows[recording.flows.length - 1];
+  const invalidPrevFlow =
+    lastFlow && lastFlow.id === 'new-tab' && !validateUrl(lastFlow.data.url);
+
+  if (!invalidPrevFlow) {
+    const validUrl = validateUrl(url) ? url : '';
+
+    recording.flows.push({
+      id: 'new-tab',
+      description: validUrl,
+      data: { url: validUrl },
+    });
+  }
+
+  recording.activeTab = {
+    url,
+    id: tab.id,
+  };
+
+  await browser.storage.local.set({ recording });
 });
 browser.alarms.onAlarm.addListener(({ name }) => {
   workflow.get(name).then((currentWorkflow) => {

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

@@ -42,7 +42,7 @@ const defaultParams = shallowReactive({
   code: '',
   key: '',
   keyCode: 0,
-  repat: false,
+  repeat: false,
 });
 
 watch(

+ 211 - 0
src/content/services/record-workflow.js

@@ -0,0 +1,211 @@
+import { finder } from '@medv/finder';
+import browser from 'webextension-polyfill';
+import { debounce } from '@/utils/helper';
+
+const textFieldEl = (el) =>
+  ['INPUT', 'TEXTAREA'].includes(el.tagName) || el.isContentEditable;
+
+async function addBlock(detail) {
+  const { isRecording, recording } = await browser.storage.local.get([
+    'isRecording',
+    'recording',
+  ]);
+
+  if (!isRecording || !recording) return;
+
+  if (typeof detail === 'function') detail(recording);
+  else recording.flows.push(detail);
+
+  await browser.storage.local.set({ recording });
+}
+
+function changeListener({ target }) {
+  const isInputEl = target.tagName === 'INPUT';
+  const inputType = target.getAttribute('type');
+  const execludeInput = isInputEl && ['checkbox', 'radio'].includes(inputType);
+
+  if (execludeInput) return;
+
+  let block = null;
+  const selector = finder(target);
+  const isSelectEl = target.tagName === 'SELECT';
+  const elementName = target.ariaLabel || target.name || selector;
+
+  if (isInputEl && inputType === 'file') {
+    block = {
+      id: 'upload-file',
+      description: elementName,
+      data: {
+        selector,
+        filePaths: [target.value],
+      },
+    };
+  } else if (textFieldEl(target) || isSelectEl) {
+    block = {
+      id: 'forms',
+      description: `${isSelectEl ? 'Select' : 'Text field'} (${elementName})`,
+      data: {
+        selector,
+        delay: 100,
+        clearValue: true,
+        value: target.value,
+        type: isSelectEl ? 'select' : 'text-field',
+      },
+    };
+  } else {
+    block = {
+      id: 'trigger-event',
+      description: `Change event (${selector})`,
+      data: {
+        selector,
+        eventName: 'change',
+        eventType: 'event',
+        eventParams: { bubbles: true },
+      },
+    };
+  }
+
+  addBlock((recording) => {
+    const lastFlow = recording.flows.at(-1);
+    if (block.id === 'upload-file' && lastFlow.id === 'event-click') {
+      recording.flows.pop();
+    }
+
+    recording.flows.push(block);
+  });
+}
+function keyEventListener({
+  target,
+  code,
+  key,
+  keyCode,
+  altKey,
+  ctrlKey,
+  metaKey,
+  shiftKey,
+  type,
+  repeat,
+}) {
+  const isTextField = textFieldEl(target);
+
+  if (isTextField) return;
+
+  const selector = finder(target);
+
+  addBlock({
+    id: 'trigger-event',
+    description: `${type}(${key === ' ' ? 'Space' : key}): ${selector}`,
+    data: {
+      selector,
+      eventName: type,
+      eventType: 'keyboard-event',
+      eventParams: {
+        key,
+        code,
+        repeat,
+        altKey,
+        ctrlKey,
+        metaKey,
+        keyCode,
+        shiftKey,
+      },
+    },
+  });
+}
+function clickListener(event) {
+  const { target } = event;
+  let isClickLink = true;
+  const isTextField =
+    (target.tagName === 'INPUT' && target.getAttribute('type') === 'text') ||
+    ['SELECT', 'TEXTAREA'].includes(target.tagName);
+
+  if (isTextField) return;
+
+  const selector = finder(target);
+
+  if (target.tagName === 'A') {
+    if (event.ctrlKey || event.metaKey) return;
+
+    const openInNewTab = target.getAttribute('target') === '_blank';
+    isClickLink = true;
+
+    if (openInNewTab) {
+      event.preventDefault();
+
+      addBlock({
+        id: 'link',
+        data: { selector },
+        description: target.href,
+      });
+
+      window.open(event.target.href, '_blank');
+
+      return;
+    }
+  }
+
+  addBlock({
+    isClickLink,
+    id: 'event-click',
+    data: { selector },
+    description: target.innerText.slice(0, 64) || selector,
+  });
+}
+
+const scrollListener = debounce(({ target }) => {
+  const isDocument = target === document;
+  const element = isDocument ? document.documentElement : target;
+  const selector = isDocument ? 'html' : finder(target);
+
+  addBlock((recording) => {
+    const lastFlow = recording.flows[recording.flows.length - 1];
+    const verticalScroll = element.scrollTop || element.scrollY || 0;
+    const horizontalScroll = element.scrollLeft || element.scrollX || 0;
+
+    if (lastFlow.id === 'element-scroll') {
+      lastFlow.data.scrollY = verticalScroll;
+      lastFlow.data.scrollX = horizontalScroll;
+
+      return;
+    }
+
+    recording.flows.push({
+      id: 'element-scroll',
+      description: selector,
+      data: {
+        selector,
+        smooth: true,
+        scrollY: verticalScroll,
+        scrollX: horizontalScroll,
+      },
+    });
+  });
+}, 500);
+
+function cleanUp() {
+  document.removeEventListener('click', clickListener, true);
+  document.removeEventListener('change', changeListener, true);
+  document.removeEventListener('scroll', scrollListener, true);
+  document.removeEventListener('keyup', keyEventListener, true);
+  document.removeEventListener('keydown', keyEventListener, true);
+}
+function messageListener({ type }) {
+  if (type === 'recording:stop') {
+    cleanUp();
+    browser.runtime.onMessage.removeListener(messageListener);
+  }
+}
+
+(async () => {
+  const { isRecording } = await browser.storage.local.get('isRecording');
+
+  if (!isRecording) return;
+
+  document.addEventListener('click', clickListener, true);
+  document.addEventListener('scroll', scrollListener, true);
+  document.addEventListener('change', changeListener, true);
+  document.addEventListener('keyup', keyEventListener, true);
+  document.addEventListener('keydown', keyEventListener, true);
+
+  browser.runtime.onMessage.addListener(messageListener);
+})();

+ 9 - 0
src/locales/en/popup.json

@@ -1,5 +1,14 @@
 {
+  "recording": {
+    "stop": "Stop recording",
+    "title": "Recording"
+  },
   "home": {
+    "record": {
+      "title": "Record workflow",
+      "button": "Record",
+      "name": "Workflow name"
+    },
     "elementSelector": {
       "name": "Element selector",
       "noAccess": "Don't have access to this site"

+ 8 - 0
src/popup/App.vue

@@ -7,11 +7,19 @@
 <script setup>
 import { ref, onMounted } from 'vue';
 import { useStore } from 'vuex';
+import { useRouter } from 'vue-router';
+import browser from 'webextension-polyfill';
 import { loadLocaleMessages, setI18nLanguage } from '@/lib/vue-i18n';
 
 const store = useStore();
+const router = useRouter();
+
 const retrieved = ref(false);
 
+browser.storage.local.get('isRecording').then(({ isRecording }) => {
+  if (isRecording) router.push('/recording');
+});
+
 onMounted(async () => {
   try {
     await store.dispatch('retrieve');

+ 92 - 27
src/popup/pages/Home.vue

@@ -1,31 +1,44 @@
 <template>
-  <div class="bg-accent rounded-b-2xl absolute top-0 left-0 h-32 w-full"></div>
-  <div
-    class="flex dark placeholder-black text-white px-5 pt-8 mb-6 items-center"
-  >
-    <ui-input
-      v-model="query"
-      :placeholder="`${t('common.search')}...`"
-      autofocus
-      prepend-icon="riSearch2Line"
-      class="flex-1 search-input"
-    ></ui-input>
-    <ui-button
-      v-tooltip="t(`home.elementSelector.${haveAccess ? 'name' : 'noAccess'}`)"
-      icon
-      class="ml-3"
-      @click="selectElement"
-    >
-      <v-remixicon name="riFocus3Line" />
-    </ui-button>
-    <ui-button
-      icon
-      :title="t('common.dashboard')"
-      class="ml-3"
-      @click="openDashboard"
-    >
-      <v-remixicon name="riHome5Line" />
-    </ui-button>
+  <div class="bg-accent rounded-b-2xl absolute top-0 left-0 h-48 w-full"></div>
+  <div class="dark placeholder-black relative z-10 text-white px-5 pt-8 mb-6">
+    <div class="flex items-center mb-4">
+      <h1 class="text-xl font-semibold text-white">Automa</h1>
+      <div class="flex-grow"></div>
+      <ui-button
+        v-tooltip.group="t('home.record.title')"
+        icon
+        class="mr-2"
+        @click="recordWorkflow"
+      >
+        <v-remixicon name="riRecordCircleLine" />
+      </ui-button>
+      <ui-button
+        v-tooltip.group="
+          t(`home.elementSelector.${haveAccess ? 'name' : 'noAccess'}`)
+        "
+        icon
+        class="mr-2"
+        @click="selectElement"
+      >
+        <v-remixicon name="riFocus3Line" />
+      </ui-button>
+      <ui-button
+        v-tooltip.group="t('common.dashboard')"
+        icon
+        :title="t('common.dashboard')"
+        @click="openDashboard"
+      >
+        <v-remixicon name="riHome5Line" />
+      </ui-button>
+    </div>
+    <div class="flex">
+      <ui-input
+        v-model="query"
+        :placeholder="`${t('common.search')}...`"
+        prepend-icon="riSearch2Line"
+        class="w-full search-input"
+      />
+    </div>
   </div>
   <div class="px-5 pb-5 space-y-2">
     <ui-card v-if="Workflow.all().length === 0" class="text-center">
@@ -56,6 +69,7 @@ import { ref, computed, onMounted } from 'vue';
 import { useI18n } from 'vue-i18n';
 import browser from 'webextension-polyfill';
 import { useDialog } from '@/composable/dialog';
+import { useGroupTooltip } from '@/composable/groupTooltip';
 import { sendMessage } from '@/utils/message';
 import Workflow from '@/models/workflow';
 import HomeWorkflowCard from '@/components/popup/home/HomeWorkflowCard.vue';
@@ -63,6 +77,8 @@ import HomeWorkflowCard from '@/components/popup/home/HomeWorkflowCard.vue';
 const { t } = useI18n();
 const dialog = useDialog();
 
+useGroupTooltip();
+
 const query = ref('');
 const haveAccess = ref(true);
 
@@ -132,6 +148,55 @@ async function selectElement() {
     console.error(error);
   }
 }
+function recordWorkflow() {
+  dialog.prompt({
+    title: t('home.record.title'),
+    okText: t('home.record.button'),
+    placeholder: t('home.record.name'),
+    onConfirm: async (name) => {
+      const flows = [];
+      const [activeTab] = await browser.tabs.query({
+        active: true,
+        currentWindow: true,
+      });
+
+      if (activeTab && activeTab.url.startsWith('http')) {
+        flows.push({
+          id: 'new-tab',
+          description: activeTab.url,
+          data: { url: activeTab.url },
+        });
+      }
+
+      await browser.storage.local.set({
+        isRecording: true,
+        recording: {
+          flows,
+          activeTab: {
+            id: activeTab.id,
+            url: activeTab.url,
+          },
+          name: name || 'unnamed',
+        },
+      });
+      await browser.browserAction.setBadgeBackgroundColor({ color: '#ef4444' });
+      await browser.browserAction.setBadgeText({ text: ' ' });
+
+      const tabs = (await browser.tabs.query({})).filter(({ url }) =>
+        url.startsWith('http')
+      );
+      await Promise.allSettled(
+        tabs.map(({ id }) =>
+          browser.tabs.executeScript(id, {
+            file: 'recordWorkflow.bundle.js',
+          })
+        )
+      );
+
+      window.close();
+    },
+  });
+}
 
 onMounted(async () => {
   const [tab] = await browser.tabs.query({ active: true, currentWindow: true });

+ 176 - 0
src/popup/pages/Recording.vue

@@ -0,0 +1,176 @@
+<template>
+  <div class="p-5">
+    <div class="flex items-center">
+      <button
+        v-tooltip="t('recording.stop')"
+        class="h-12 w-12 rounded-full focus:ring-0 bg-red-400 relative flex items-center justify-center"
+        @click="stopRecording"
+      >
+        <span
+          class="absolute animate-ping bg-red-400 rounded-full"
+          style="height: 80%; width: 80%; animation-duration: 1.3s"
+        ></span>
+        <v-remixicon name="riStopLine" class="z-10 relative" />
+      </button>
+      <div class="ml-4 flex-1 overflow-hidden">
+        <p class="text-sm">{{ t('recording.title') }}</p>
+        <p class="font-semibold text-xl leading-tight text-overflow">
+          {{ state.name }}
+        </p>
+      </div>
+    </div>
+    <p class="font-semibold mt-6 mb-2">Flows</p>
+    <ui-list class="space-y-1">
+      <ui-list-item
+        v-for="(item, index) in state.flows"
+        :key="index"
+        class="group"
+        small
+      >
+        <v-remixicon :name="tasks[item.id].icon" />
+        <div class="overflow-hidden flex-1 mx-2">
+          <p class="leading-tight">
+            {{ t(`workflow.blocks.${item.id}.name`) }}
+          </p>
+          <p
+            :title="item.description"
+            class="text-overflow text-sm leading-tight text-gray-600"
+          >
+            {{ item.description }}
+          </p>
+        </div>
+        <v-remixicon
+          name="riDeleteBin7Line"
+          class="invisible group-hover:visible cursor-pointer"
+          @click="removeBlock(index)"
+        />
+      </ui-list-item>
+    </ui-list>
+  </div>
+</template>
+<script setup>
+import { onMounted, reactive, toRaw } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useRouter } from 'vue-router';
+import { nanoid } from 'nanoid';
+import defu from 'defu';
+import browser from 'webextension-polyfill';
+import { tasks } from '@/utils/shared';
+import Workflow from '@/models/workflow';
+
+const { t } = useI18n();
+const router = useRouter();
+
+const state = reactive({
+  name: '',
+  flows: [],
+  activeTab: {},
+});
+
+function generateDrawflow() {
+  let nextNodeId = nanoid();
+  const triggerId = nanoid();
+  let prevNodeId = triggerId;
+
+  const nodes = {
+    [triggerId]: {
+      pos_x: 50,
+      pos_y: 300,
+      inputs: {},
+      outputs: {
+        output_1: {
+          connections: [{ node: nextNodeId, output: 'input_1' }],
+        },
+      },
+      id: triggerId,
+      typenode: 'vue',
+      name: 'trigger',
+      class: 'trigger',
+      html: 'BlockBasic',
+      data: tasks.trigger.data,
+    },
+  };
+  const position = { x: 260, y: 300 };
+
+  state.flows.forEach((block, index) => {
+    const node = {
+      id: nextNodeId,
+      name: block.id,
+      class: block.id,
+      typenode: 'vue',
+      pos_x: position.x,
+      pos_y: position.y,
+      inputs: { input_1: { connections: [] } },
+      outputs: { output_1: { connections: [] } },
+      html: tasks[block.id].component,
+      data: defu(block.data, tasks[block.id].data),
+    };
+
+    node.inputs.input_1.connections.push({
+      node: prevNodeId,
+      input: 'output_1',
+    });
+
+    const isLastIndex = index === state.flows.length - 1;
+
+    prevNodeId = nextNodeId;
+    nextNodeId = nanoid();
+
+    if (!isLastIndex) {
+      node.outputs.output_1.connections.push({
+        node: nextNodeId,
+        output: 'input_1',
+      });
+    }
+
+    const inNewRow = (index + 1) % 5 === 0;
+    const blockNameLen = tasks[block.id].name.length * 11 + 120;
+    position.x = inNewRow ? 50 : position.x + blockNameLen;
+    position.y = inNewRow ? position.y + 150 : position.y;
+
+    nodes[node.id] = node;
+  });
+
+  return { drawflow: { Home: { data: nodes } } };
+}
+async function stopRecording() {
+  const drawflow = generateDrawflow();
+
+  await Workflow.insert({
+    data: {
+      name: state.name,
+      drawflow: JSON.stringify(drawflow),
+    },
+  });
+
+  await browser.storage.local.remove(['isRecording', 'recording']);
+  await browser.browserAction.setBadgeText({ text: '' });
+
+  const tabs = (await browser.tabs.query({})).filter((tab) =>
+    tab.url.startsWith('http')
+  );
+  await Promise.allSettled(
+    tabs.map(({ id }) =>
+      browser.tabs.sendMessage(id, { type: 'recording:stop' })
+    )
+  );
+
+  router.push('/');
+}
+function removeBlock(index) {
+  state.flows.splice(index, 1);
+
+  browser.storage.local.set({ recording: toRaw(state) });
+}
+
+onMounted(async () => {
+  const { recording, isRecording } = await browser.storage.local.get([
+    'recording',
+    'isRecording',
+  ]);
+
+  if (!isRecording && !recording) return;
+
+  Object.assign(state, recording);
+});
+</script>

+ 4 - 4
src/popup/router.js

@@ -1,6 +1,6 @@
 import { createRouter, createWebHashHistory } from 'vue-router';
 import Home from './pages/Home.vue';
-import WorkflowEdit from './pages/workflow/Edit.vue';
+import Recording from './pages/Recording.vue';
 
 const routes = [
   {
@@ -9,9 +9,9 @@ const routes = [
     component: Home,
   },
   {
-    path: '/workflow/:id/edit',
-    name: 'workflow-edit',
-    component: WorkflowEdit,
+    path: '/recording',
+    name: 'recording',
+    component: Recording,
   },
 ];
 

+ 10 - 2
webpack.config.js

@@ -43,6 +43,13 @@ 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'),
+    recordWorkflow: path.join(
+      __dirname,
+      'src',
+      'content',
+      'services',
+      'record-workflow.js'
+    ),
     shortcutListener: path.join(
       __dirname,
       'src',
@@ -67,10 +74,11 @@ const options = {
   },
   chromeExtensionBoilerplate: {
     notHotReload: [
-      'contentScript',
       'webService',
-      'shortcutListener',
+      'contentScript',
+      'recordWorkflow',
       'elementSelector',
+      'shortcutListener',
     ],
   },
   output: {