Ahmad Kholid 3 years ago
parent
commit
190ea40ade
31 changed files with 621 additions and 82 deletions
  1. 1 0
      .eslintrc.js
  2. 1 1
      package.json
  3. 45 0
      src/background/index.js
  4. 41 8
      src/background/workflow-engine/blocks-handler/handler-export-data.js
  5. 38 0
      src/background/workflow-engine/blocks-handler/handler-handle-dialog.js
  6. 98 0
      src/background/workflow-engine/blocks-handler/handler-handle-download.js
  7. 3 2
      src/background/workflow-engine/blocks-handler/handler-interaction-block.js
  8. 54 14
      src/background/workflow-engine/engine.js
  9. 3 1
      src/background/workflow-engine/helper.js
  10. 4 0
      src/components/newtab/workflow/WorkflowSettings.vue
  11. 0 4
      src/components/newtab/workflow/edit/EditBrowserEvent.vue
  12. 4 17
      src/components/newtab/workflow/edit/EditClipboard.vue
  13. 16 1
      src/components/newtab/workflow/edit/EditExportData.vue
  14. 44 0
      src/components/newtab/workflow/edit/EditHandleDialog.vue
  15. 80 0
      src/components/newtab/workflow/edit/EditHandleDownload.vue
  16. 20 12
      src/components/newtab/workflow/edit/TriggerEventMouse.vue
  17. 40 0
      src/composable/hasPermissions.js
  18. 0 1
      src/content/blocks-handler/handler-javascript-code.js
  19. 2 2
      src/content/blocks-handler/handler-trigger-event.js
  20. 18 1
      src/content/handle-selector.js
  21. 4 0
      src/lib/v-remixicon.js
  22. 24 0
      src/locales/en/blocks.json
  23. 2 1
      src/locales/en/common.json
  24. 1 0
      src/locales/en/newtab.json
  25. 1 1
      src/manifest.json
  26. 1 0
      src/models/workflow.js
  27. 10 6
      src/utils/data-exporter.js
  28. 2 2
      src/utils/helper.js
  29. 13 5
      src/utils/reference-data/index.js
  30. 6 2
      src/utils/reference-data/mustache-replacer.js
  31. 45 1
      src/utils/shared.js

+ 1 - 0
.eslintrc.js

@@ -34,6 +34,7 @@ module.exports = {
     'no-console': ['warn', { allow: ['warn', 'error'] }],
     'no-underscore-dangle': 'off',
     'func-names': 'off',
+    'import/no-named-default': 'off',
     'import/extensions': [
       'error',
       'always',

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "1.4.4",
+  "version": "1.5.0",
   "description": "An extension for automating your browser by connecting blocks",
   "license": "MIT",
   "repository": {

+ 45 - 0
src/background/index.js

@@ -339,6 +339,51 @@ chrome.runtime.onStartup.addListener(async () => {
   await browser.storage.local.set({ onStartupTriggers });
 });
 
+if (chrome.downloads) {
+  const getFileExtension = (str) => /(?:\.([^.]+))?$/.exec(str)[1];
+  chrome.downloads.onDeterminingFilename.addListener((item, suggest) => {
+    if (item.byExtensionId === chrome.runtime.id) {
+      const filesname =
+        JSON.parse(sessionStorage.getItem('export-filesname')) || {};
+      const blobId = item.url.replace('blob:chrome-extension://', '');
+      const suggestion = filesname[blobId];
+
+      if (suggestion) {
+        delete filesname[blobId];
+
+        suggest(suggestion);
+        sessionStorage.setItem('export-filesname', JSON.stringify(filesname));
+      }
+
+      return;
+    }
+
+    const filesname =
+      JSON.parse(sessionStorage.getItem('rename-downloaded-files')) || {};
+    const suggestion = filesname[item.id];
+
+    if (!suggestion) return;
+
+    const hasFileExt = getFileExtension(suggestion.filename);
+
+    if (!hasFileExt) {
+      const filExtension = getFileExtension(item.filename);
+      suggestion.filename += `.${filExtension}`;
+    }
+
+    if (!suggestion.waitForDownload) delete filesname[item.id];
+    sessionStorage.setItem(
+      'rename-downloaded-files',
+      JSON.stringify(filesname)
+    );
+
+    suggest({
+      filename: suggestion.filename,
+      conflictAction: suggestion.onConflict,
+    });
+  });
+}
+
 const message = new MessageListener('background');
 
 message.on('fetch:text', (url) => {

+ 41 - 8
src/background/workflow-engine/blocks-handler/handler-export-data.js

@@ -1,8 +1,11 @@
+import browser from 'webextension-polyfill';
 import { getBlockConnection } from '../helper';
-import dataExporter from '@/utils/data-exporter';
+import { default as dataExporter, files } from '@/utils/data-exporter';
 
-function exportData({ data, outputs }) {
-  return new Promise((resolve) => {
+async function exportData({ data, outputs }) {
+  const nextBlockId = getBlockConnection({ outputs });
+
+  try {
     const dataToExport = data.dataToExport || 'data-columns';
     let payload = this.referenceData.table;
 
@@ -10,13 +13,43 @@ function exportData({ data, outputs }) {
       payload = this.referenceData.googleSheets[data.refKey] || [];
     }
 
-    dataExporter(payload, data);
+    const hasDownloadAccess = await browser.permissions.contains({
+      permissions: ['downloads'],
+    });
+    const blobUrl = dataExporter(payload, {
+      ...data,
+      returnUrl: hasDownloadAccess,
+    });
+
+    if (hasDownloadAccess) {
+      const filename = `${data.name}${files[data.type].ext}`;
+      const blobId = blobUrl.replace('blob:chrome-extension://', '');
+      const filesname =
+        JSON.parse(sessionStorage.getItem('export-filesname')) || {};
+
+      const options = {
+        filename,
+        conflictAction: data.onConflict || 'uniquify',
+      };
 
-    resolve({
+      filesname[blobId] = options;
+      sessionStorage.setItem('export-filesname', JSON.stringify(filesname));
+
+      await browser.downloads.download({
+        ...options,
+        url: blobUrl,
+      });
+    }
+
+    return {
       data: '',
-      nextBlockId: getBlockConnection({ outputs }),
-    });
-  });
+      nextBlockId,
+    };
+  } catch (error) {
+    error.nextBlockId = nextBlockId;
+
+    throw error;
+  }
 }
 
 export default exportData;

+ 38 - 0
src/background/workflow-engine/blocks-handler/handler-handle-dialog.js

@@ -0,0 +1,38 @@
+import { getBlockConnection, sendDebugCommand } from '../helper';
+
+function handleDialog({ data, outputs }) {
+  const nextBlockId = getBlockConnection({ outputs });
+
+  return new Promise((resolve, reject) => {
+    if (!this.workflow.settings.debugMode) {
+      const error = new Error('not-debug-mode');
+      error.nextBlockId = nextBlockId;
+
+      reject(error);
+      return;
+    }
+
+    this.dialogParams = {
+      accept: data.accept,
+      promptText: data.promptText,
+    };
+
+    const methodName = 'Page.javascriptDialogOpening';
+    if (!this.eventListeners[methodName]) {
+      this.on(methodName, () => {
+        sendDebugCommand(
+          this.activeTab.id,
+          'Page.handleJavaScriptDialog',
+          this.dialogParams
+        );
+      });
+    }
+
+    resolve({
+      data: '',
+      nextBlockId,
+    });
+  });
+}
+
+export default handleDialog;

+ 98 - 0
src/background/workflow-engine/blocks-handler/handler-handle-download.js

@@ -0,0 +1,98 @@
+import browser from 'webextension-polyfill';
+import { getBlockConnection } from '../helper';
+
+function handleDownload({ data, outputs }) {
+  const nextBlockId = getBlockConnection({ outputs });
+  const getFilesname = () =>
+    JSON.parse(sessionStorage.getItem('rename-downloaded-files')) || {};
+
+  return new Promise((resolve) => {
+    if (!this.activeTab.id) throw new Error('no-tab');
+
+    let downloadId = null;
+    const handleCreated = ({ id }) => {
+      if (downloadId) return;
+
+      const names = getFilesname();
+
+      downloadId = id;
+      names[id] = data;
+      sessionStorage.setItem('rename-downloaded-files', JSON.stringify(names));
+
+      browser.downloads.onCreated.removeListener(handleCreated);
+    };
+    browser.downloads.onCreated.addListener(handleCreated);
+
+    if (!data.waitForDownload) {
+      resolve({
+        nextBlockId,
+        data: data.filename,
+      });
+
+      return;
+    }
+
+    let isResolved = false;
+    let currentFilename = data.filename;
+
+    const timeout = setTimeout(() => {
+      if (isResolved) return;
+
+      isResolved = true;
+
+      resolve({
+        nextBlockId,
+        data: currentFilename,
+      });
+    }, data.timeout);
+
+    const resolvePromise = (id) => {
+      if (data.saveData) {
+        this.addDataToColumn(data.dataColumn, currentFilename);
+      }
+      if (data.assignVariable) {
+        this.referenceData.variables[data.variableName] = currentFilename;
+      }
+
+      clearTimeout(timeout);
+      isResolved = true;
+
+      const filesname = getFilesname();
+      delete filesname[id];
+      sessionStorage.setItem(
+        'rename-downloaded-files',
+        JSON.stringify(filesname)
+      );
+
+      resolve({
+        nextBlockId,
+        data: currentFilename,
+      });
+    };
+
+    const handleChanged = ({ state, id, filename }) => {
+      if (this.isDestroyed || isResolved) {
+        browser.downloads.onChanged.removeListener(handleChanged);
+        return;
+      }
+
+      if (downloadId !== id) return;
+
+      if (filename) currentFilename = filename.current;
+
+      if (state && state.current === 'complete') {
+        resolvePromise(id);
+      } else {
+        browser.downloads.search({ id }).then(([download]) => {
+          if (!download || !download.endTime) return;
+
+          resolvePromise(id);
+        });
+      }
+    };
+
+    browser.downloads.onChanged.addListener(handleChanged);
+  });
+}
+
+export default handleDownload;

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

@@ -86,10 +86,11 @@ async function interactionHandler(block, { refData }) {
       }
 
       if (data?.columns.insert) {
-        const arrData = Array.isArray(data.columns.data)
+        const params = Array.isArray(data.columns.data)
           ? data.columns.data
           : [data.columns.data];
-        this.addDataToColumn(arrData);
+
+        this.addDataToColumn(params);
       }
     }
 

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

@@ -38,8 +38,9 @@ class WorkflowEngine {
 
     this.blocks = {};
     this.history = [];
+    this.columnsId = {};
     this.eventListeners = {};
-    this.columns = { column: { index: 0, type: 'any' } };
+    this.columns = { column: { index: 0, name: 'column', type: 'any' } };
 
     let variables = {};
     let { globalData } = workflow;
@@ -70,6 +71,13 @@ class WorkflowEngine {
       globalData: parseJSON(globalData, globalData),
     };
 
+    this.onDebugEvent = ({ tabId }, method, params) => {
+      if (tabId !== this.activeTab.id) return;
+
+      (this.eventListeners[method] || []).forEach((listener) => {
+        listener(params);
+      });
+    };
     this.onWorkflowStopped = (id) => {
       if (this.id !== id || this.isDestroyed) return;
       this.stop();
@@ -88,7 +96,7 @@ class WorkflowEngine {
     this.isUsingProxy = false;
 
     this.history = [];
-    this.columns = { column: { index: 0, type: 'any' } };
+    this.columns = { column: { index: 0, name: 'column', type: 'any' } };
 
     this.activeTab = {
       url: '',
@@ -116,10 +124,9 @@ class WorkflowEngine {
       return;
     }
 
-    const { drawflow } = this.workflow;
-    const flow =
-      typeof drawflow === 'string' ? parseJSON(drawflow, {}) : drawflow;
-    const blocks = flow?.drawflow?.Home.data;
+    const flow = this.workflow.drawflow;
+    const parsedFlow = typeof flow === 'string' ? parseJSON(flow, {}) : flow;
+    const blocks = parsedFlow?.drawflow?.Home.data;
 
     if (!blocks) {
       console.error(`${this.workflow.name} doesn't have blocks`);
@@ -140,9 +147,27 @@ class WorkflowEngine {
       : Object.values(workflowTable);
 
     columns.forEach(({ name, type, id }) => {
-      this.columns[id || name] = { index: 0, name, type };
+      const columnId = id || name;
+
+      this.columnsId[name] = columnId;
+      this.columns[columnId] = { index: 0, name, type };
     });
 
+    if (this.workflow.settings.debugMode) {
+      chrome.debugger.onEvent.addListener(this.onDebugEvent);
+    }
+    if (this.workflow.settings.reuseLastState) {
+      const lastStateKey = `last-state:${this.workflow.id}`;
+      browser.storage.local.get(lastStateKey).then((value) => {
+        const lastState = value[lastStateKey];
+
+        if (!lastState) return;
+
+        this.columns = lastState.columns;
+        Object.assign(this.referenceData, lastState.referenceData);
+      });
+    }
+
     this.blocks = blocks;
     this.startedTimestamp = Date.now();
     this.workflow.table = columns;
@@ -195,8 +220,9 @@ class WorkflowEngine {
       return;
     }
 
-    const columnKey = objectHasKey(this.columns, key) ? key : 'column';
-    const currentColumn = this.columns[columnKey];
+    const columnId =
+      (this.columns[key] ? key : this.columnsId[key]) || 'column';
+    const currentColumn = this.columns[columnId];
     const columnName = currentColumn.name || 'column';
     const convertedValue = convertData(value, currentColumn.type);
 
@@ -244,10 +270,13 @@ class WorkflowEngine {
     try {
       if (this.isDestroyed) return;
       if (this.isUsingProxy) chrome.proxy.settings.clear({});
-      if (this.workflow.settings.debugMode && this.activeTab.id) {
-        await sleep(1000);
+      if (this.workflow.settings.debugMode) {
+        chrome.debugger.onEvent.removeListener(this.onDebugEvent);
 
-        chrome.debugger.detach({ tabId: this.activeTab.id });
+        if (this.activeTab.id) {
+          await sleep(1000);
+          chrome.debugger.detach({ tabId: this.activeTab.id });
+        }
       }
 
       const endedTimestamp = Date.now();
@@ -283,6 +312,17 @@ class WorkflowEngine {
         currentBlock: this.currentBlock,
       });
 
+      browser.storage.local.set({
+        [`last-state:${this.workflow.id}`]: {
+          columns: this.columns,
+          referenceData: {
+            table: this.referenceData.table,
+            variables: this.referenceData.variables,
+            globalData: this.referenceData.globalData,
+          },
+        },
+      });
+
       this.isDestroyed = true;
       this.eventListeners = {};
     } catch (error) {
@@ -307,7 +347,7 @@ class WorkflowEngine {
     await this.states.update(this.id, { state: this.state });
     this.dispatchEvent('update', { state: this.state });
 
-    const startExecutedTime = Date.now();
+    const startExecuteTime = Date.now();
 
     const blockHandler = this.blocksHandler[toCamelCase(block.name)];
     const handler =
@@ -338,7 +378,7 @@ class WorkflowEngine {
         name: block.name,
         logId: result.logId,
         type: result.status || 'success',
-        duration: Math.round(Date.now() - startExecutedTime),
+        duration: Math.round(Date.now() - startExecuteTime),
       });
 
       if (result.nextBlockId) {

+ 3 - 1
src/background/workflow-engine/helper.js

@@ -9,7 +9,9 @@ export function attachDebugger(tabId, prevTab) {
     if (prevTab && tabId !== prevTab)
       chrome.debugger.detach({ tabId: prevTab });
 
-    chrome.debugger.attach({ tabId }, '1.3', resolve);
+    chrome.debugger.attach({ tabId }, '1.3', () => {
+      chrome.debugger.sendCommand({ tabId }, 'Page.enable', resolve);
+    });
   });
 }
 

+ 4 - 0
src/components/newtab/workflow/WorkflowSettings.vue

@@ -44,6 +44,10 @@
       <ui-switch v-model="settings.debugMode" class="mr-4" />
       <p class="capitalize">{{ t('workflow.settings.debugMode') }}</p>
     </div>
+    <div class="flex mt-6">
+      <ui-switch v-model="settings.reuseLastState" class="mr-4" />
+      <p class="capitalize">{{ t('workflow.settings.reuseLastState') }}</p>
+    </div>
     <div class="flex mt-6">
       <ui-switch v-model="settings.saveLog" class="mr-4" />
       <p class="capitalize">{{ t('workflow.settings.saveLog') }}</p>

+ 0 - 4
src/components/newtab/workflow/edit/EditBrowserEvent.vue

@@ -100,10 +100,6 @@ const browserEvents = {
     { id: 'tab:loaded', name: 'Tab loaded' },
     { id: 'tab:create', name: 'Tab created' },
   ],
-  // 'Downloads': [
-  //   { id: 'download:start', name: 'Download started' },
-  //   { id: 'download:complete', name: 'Download complete' },
-  // ],
   Window: [
     { id: 'window:create', name: 'Window created' },
     { id: 'window:close', name: 'Window closed' },

+ 4 - 17
src/components/newtab/workflow/edit/EditClipboard.vue

@@ -6,7 +6,7 @@
       :placeholder="t('common.description')"
       @change="updateData({ description: $event })"
     />
-    <template v-if="hasPermission">
+    <template v-if="permission.has.clipboardRead">
       <p class="mt-4">
         {{ t('workflow.blocks.clipboard.data') }}
       </p>
@@ -16,17 +16,16 @@
       <p class="mt-4">
         {{ t('workflow.blocks.clipboard.noPermission') }}
       </p>
-      <ui-button variant="accent" class="mt-2" @click="requestPermission">
+      <ui-button variant="accent" class="mt-2" @click="permission.request">
         {{ t('workflow.blocks.clipboard.grantPermission') }}
       </ui-button>
     </template>
   </div>
 </template>
 <script setup>
-import { ref, onMounted } from 'vue';
 import { useI18n } from 'vue-i18n';
-import browser from 'webextension-polyfill';
 import InsertWorkflowData from './InsertWorkflowData.vue';
+import { useHasPermissions } from '@/composable/hasPermissions';
 
 const props = defineProps({
   data: {
@@ -36,22 +35,10 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
-const permission = { permissions: ['clipboardRead'] };
 const { t } = useI18n();
+const permission = useHasPermissions(['clipboardRead']);
 
-const hasPermission = ref(false);
-
-function handlePermission(status) {
-  hasPermission.value = status;
-}
-function requestPermission() {
-  browser.permissions.request(permission).then(handlePermission);
-}
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });
 }
-
-onMounted(() => {
-  browser.permissions.contains(permission).then(handlePermission);
-});
 </script>

+ 16 - 1
src/components/newtab/workflow/edit/EditExportData.vue

@@ -23,6 +23,17 @@
       placeholder="unnamed"
       @change="updateData({ name: $event })"
     />
+    <ui-select
+      v-if="permission.has.downloads"
+      :model-value="data.onConflict"
+      :label="t('workflow.blocks.handle-download.onConflict')"
+      class="mt-2 w-full"
+      @change="updateData({ onConflict: $event })"
+    >
+      <option v-for="item in onConflict" :key="item" :value="item">
+        {{ t(`workflow.blocks.base.downloads.onConflict.${item}`) }}
+      </option>
+    </ui-select>
     <ui-input
       v-if="data.dataToExport === 'google-sheets'"
       :model-value="data.refKey"
@@ -54,6 +65,7 @@
 <script setup>
 import { useI18n } from 'vue-i18n';
 import { dataExportTypes } from '@/utils/shared';
+import { useHasPermissions } from '@/composable/hasPermissions';
 
 const props = defineProps({
   data: {
@@ -63,8 +75,11 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
-const { t } = useI18n();
 const dataToExport = ['data-columns', 'google-sheets'];
+const onConflict = ['uniquify', 'overwrite', 'prompt'];
+
+const { t } = useI18n();
+const permission = useHasPermissions(['downloads']);
 
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });

+ 44 - 0
src/components/newtab/workflow/edit/EditHandleDialog.vue

@@ -0,0 +1,44 @@
+<template>
+  <div>
+    <ui-textarea
+      :model-value="data.description"
+      class="w-full"
+      :placeholder="t('common.description')"
+      @change="updateData({ description: $event })"
+    />
+    <ui-checkbox
+      :model-value="data.accept"
+      block
+      class="mt-4"
+      @change="updateData({ accept: $event })"
+    >
+      {{ t('workflow.blocks.handle-dialog.accept') }}
+    </ui-checkbox>
+    <ui-input
+      v-if="data.accept"
+      :model-value="data.promptText"
+      :label="t('workflow.blocks.handle-dialog.promptText.label')"
+      :title="t('workflow.blocks.handle-dialog.promptText.description')"
+      placeholder="Text"
+      class="w-full mt-1"
+      @change="updateData({ promptText: $event })"
+    />
+  </div>
+</template>
+<script setup>
+import { useI18n } from 'vue-i18n';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const { t } = useI18n();
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+</script>

+ 80 - 0
src/components/newtab/workflow/edit/EditHandleDownload.vue

@@ -0,0 +1,80 @@
+<template>
+  <div>
+    <template v-if="permission.has.downloads">
+      <ui-textarea
+        :model-value="data.description"
+        class="w-full"
+        :placeholder="t('common.description')"
+        @change="updateData({ description: $event })"
+      />
+      <ui-input
+        :model-value="data.timeout"
+        :label="t('workflow.blocks.handle-download.timeout')"
+        placeholder="1000"
+        type="number"
+        class="w-full mt-2"
+        @change="updateData({ timeout: +$event || 1000 })"
+      />
+      <ui-input
+        :model-value="data.filename"
+        :label="`${t('common.fileName')} (${t('common.optional')})`"
+        placeholder="file"
+        class="mt-2 w-full"
+        @change="updateData({ filename: $event })"
+      />
+      <ui-select
+        :model-value="data.onConflict"
+        :label="t('workflow.blocks.handle-download.onConflict')"
+        class="mt-2 w-full"
+        @change="updateData({ onConflict: $event })"
+      >
+        <option v-for="item in onConflict" :key="item" :value="item">
+          {{ t(`workflow.blocks.base.downloads.onConflict.${item}`) }}
+        </option>
+      </ui-select>
+      <ui-checkbox
+        :model-value="data.waitForDownload"
+        class="mt-4"
+        @change="updateData({ waitForDownload: $event })"
+      >
+        {{ t('workflow.blocks.handle-download.waitFile') }}
+      </ui-checkbox>
+      <insert-workflow-data
+        v-if="data.waitForDownload"
+        :data="data"
+        variables
+        @update="updateData"
+      />
+    </template>
+    <template v-else>
+      <p class="mt-4">
+        {{ t('workflow.blocks.handle-download.noPermission') }}
+      </p>
+      <ui-button variant="accent" class="mt-2" @click="permission.request">
+        {{ t('workflow.blocks.clipboard.grantPermission') }}
+      </ui-button>
+    </template>
+  </div>
+</template>
+<script setup>
+import { useI18n } from 'vue-i18n';
+import { useHasPermissions } from '@/composable/hasPermissions';
+import InsertWorkflowData from './InsertWorkflowData.vue';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const permission = useHasPermissions(['downloads']);
+const onConflict = ['uniquify', 'overwrite', 'prompt'];
+
+const { t } = useI18n();
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+</script>

+ 20 - 12
src/components/newtab/workflow/edit/TriggerEventMouse.vue

@@ -22,18 +22,26 @@
     :key="items[0]"
     class="flex items-center space-x-2 mt-2"
   >
-    <ui-input
-      v-model.number="defaultParams[items[0]]"
-      type="number"
-      class="flex-1"
-      :label="items[0]"
-    />
-    <ui-input
-      v-model.number="defaultParams[items[1]]"
-      type="number"
-      class="flex-1"
-      :label="items[1]"
-    />
+    <template v-if="items[0].startsWith('client')">
+      <ui-input
+        v-for="item in items"
+        :key="item"
+        :model-value="defaultParams[item]"
+        :label="item"
+        class="flex-1"
+        @change="defaultParams[item] = +$event || $event"
+      />
+    </template>
+    <template v-else>
+      <ui-input
+        v-for="item in items"
+        :key="item"
+        v-model.number="defaultParams[item]"
+        type="number"
+        class="flex-1"
+        :label="item"
+      />
+    </template>
   </div>
 </template>
 <script setup>

+ 40 - 0
src/composable/hasPermissions.js

@@ -0,0 +1,40 @@
+import { onMounted, shallowReactive } from 'vue';
+import browser from 'webextension-polyfill';
+
+export function useHasPermissions(permissions) {
+  const hasPermissions = shallowReactive({});
+
+  function handlePermission(name, status) {
+    hasPermissions[name] = status;
+  }
+  function request() {
+    const reqPermissions = permissions.filter(
+      (permission) => !hasPermissions[permission]
+    );
+
+    browser.permissions
+      .request({ permissions: reqPermissions })
+      .then((status) => {
+        if (!status) return;
+
+        reqPermissions.forEach((permission) => {
+          handlePermission(permission, true);
+        });
+      });
+  }
+
+  onMounted(() => {
+    permissions.forEach((permission) => {
+      browser.permissions
+        .contains({ permissions: [permission] })
+        .then((status) => {
+          handlePermission(permission, status);
+        });
+    });
+  });
+
+  return {
+    request,
+    has: hasPermissions,
+  };
+}

+ 0 - 1
src/content/blocks-handler/handler-javascript-code.js

@@ -125,7 +125,6 @@ function javascriptCode(block) {
         preloadScripts.forEach((item) => {
           if (item.removeAfterExec) item.script.remove();
         });
-        sessionStorage.removeItem(storageKey);
 
         resolve({ columns, variables: storageRefData?.variables });
       };

+ 2 - 2
src/content/blocks-handler/handler-trigger-event.js

@@ -20,8 +20,8 @@ const eventHandlers = {
       button: mouseButtons[params.button]?.name || 'left',
     };
 
-    if (params.clientX) commandParams.x = params.clientX;
-    if (params.clientY) commandParams.y = params.clientY;
+    if (params.clientX) commandParams.x = +params.clientX;
+    if (params.clientY) commandParams.y = +params.clientY;
 
     Object.keys(modifiers).forEach((key) => {
       if (commandParams.modifiers) return;

+ 18 - 1
src/content/handle-selector.js

@@ -36,8 +36,23 @@ export function waitForSelector({
   });
 }
 
+function scrollIfNeeded(debugMode, element) {
+  if (!debugMode) return;
+
+  const { top, left, bottom, right } = element.getBoundingClientRect();
+  const isInViewport =
+    top >= 0 &&
+    left >= 0 &&
+    bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
+    right <= (window.innerWidth || document.documentElement.clientWidth);
+
+  if (!isInViewport) {
+    element.scrollIntoView();
+  }
+}
+
 export default async function (
-  { data, id, frameSelector },
+  { data, id, frameSelector, debugMode },
   { onSelected, onError, onSuccess, returnElement }
 ) {
   if (!data || !data.selector) {
@@ -94,11 +109,13 @@ export default async function (
       await Promise.allSettled(
         Array.from(element).map((el) => {
           markElement(el, { id, data });
+          scrollIfNeeded(debugMode, el);
           return onSelected(el);
         })
       );
     } else if (element) {
       markElement(element, { id, data });
+      scrollIfNeeded(debugMode, element);
       await onSelected(element);
     }
 

+ 4 - 0
src/lib/v-remixicon.js

@@ -4,6 +4,7 @@ import {
   riH2,
   riLinkM,
   riTwitterLine,
+  riChat3Line,
   riDiscordLine,
   riEarthLine,
   riClipboardLine,
@@ -86,6 +87,7 @@ import {
   riFocusLine,
   riCursorLine,
   riDownloadLine,
+  riFileDownloadLine,
   riUploadLine,
   riCommandLine,
   riParagraph,
@@ -106,6 +108,7 @@ export const icons = {
   riH2,
   riLinkM,
   riTwitterLine,
+  riChat3Line,
   riDiscordLine,
   riEarthLine,
   riClipboardLine,
@@ -188,6 +191,7 @@ export const icons = {
   riFocusLine,
   riCursorLine,
   riDownloadLine,
+  riFileDownloadLine,
   riUploadLine,
   riCommandLine,
   riParagraph,

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

@@ -39,8 +39,32 @@
         "waitSelector": {
           "title": "Wait for selector",
           "timeout": "Selector timeout (ms)"
+        },
+        "downloads": {
+          "onConflict": {
+            "uniquify": "Uniquify",
+            "overwrite": "Overwrite",
+            "prompt": "Prompt"
+          }
+        }
+      },
+      "handle-dialog": {
+        "name": "Handle dialog",
+        "description": "Accepts or dismisses a JavaScript initiated dialog (alert, confirm, prompt, or onbeforeunload).",
+        "accept": "Accept dialog",
+        "promptText": {
+          "label": "Prompt text",
+          "description": "The text to enter into the dialog prompt before accepting"
         }
       },
+      "handle-download": {
+        "name": "Handle download",
+        "description": "Handle downloaded file",
+        "timeout": "Timeout (milliseconds)",
+        "noPermission": "Don't have permission to access the downloads",
+        "onConflict": "On conflict",
+        "waitFile": "Wait for the file to be downloaded"
+      },
       "insert-data": {
         "name": "Insert data",
         "description": "Insert data into table or variable"

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

@@ -35,7 +35,8 @@
     "update": "Update",
     "duplicate": "Duplicate",
     "password": "Password",
-    "category": "Category"
+    "category": "Category",
+    "optional": "Optional"
   },
   "message": {
     "noBlock": "No block",

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

@@ -156,6 +156,7 @@
     },
     "settings": {
       "saveLog": "Save workflow log",
+      "reuseLastState": "Reuse last workflow state",
       "executedBlockOnWeb": "Show executed block on web page",
       "debugMode": "Debug mode",
       "restartWorkflow": {

+ 1 - 1
src/manifest.json

@@ -47,7 +47,7 @@
   ],
   "optional_permissions": [
     "clipboardRead",
-    "debugger"
+    "downloads"
   ],
   "permissions": [
     "tabs",

+ 1 - 0
src/models/workflow.js

@@ -35,6 +35,7 @@ class Workflow extends Model {
         saveLog: true,
         debugMode: false,
         restartTimes: 3,
+        reuseLastState: false,
         onError: 'stop-workflow',
         executedBlockOnWeb: false,
       }),

+ 10 - 6
src/utils/data-exporter.js

@@ -1,7 +1,7 @@
 import Papa from 'papaparse';
 import { fileSaver } from './helper';
 
-const files = {
+export const files = {
   'plain-text': {
     mime: 'text/plain',
     ext: '.txt',
@@ -36,7 +36,11 @@ export function generateJSON(keys, data) {
   return result;
 }
 
-export default function (data, { name, type, addBOMHeader }, converted) {
+export default function (
+  data,
+  { name, type, addBOMHeader, returnUrl },
+  converted
+) {
   let result = data;
 
   if (type === 'csv' || type === 'json') {
@@ -61,9 +65,9 @@ export default function (data, { name, type, addBOMHeader }, converted) {
   }
 
   const { mime, ext } = files[type];
-  const blob = new Blob(payload, {
-    type: mime,
-  });
+  const blobUrl = URL.createObjectURL(new Blob(payload, { type: mime }));
+
+  if (!returnUrl) fileSaver(`${name || 'unnamed'}${ext}`, blobUrl);
 
-  fileSaver(`${name || 'unnamed'}${ext}`, URL.createObjectURL(blob));
+  return blobUrl;
 }

+ 2 - 2
src/utils/helper.js

@@ -130,9 +130,9 @@ export function openFilePicker(acceptedFileTypes = [], attrs = {}) {
   });
 }
 
-export function fileSaver(fileName, data) {
+export function fileSaver(filename, data) {
   const anchor = document.createElement('a');
-  anchor.download = fileName;
+  anchor.download = filename;
   anchor.href = data;
 
   anchor.dispatchEvent(new MouseEvent('click'));

+ 13 - 5
src/utils/reference-data/index.js

@@ -1,4 +1,4 @@
-import { objectHasKey } from '@/utils/helper';
+import objectPath from 'object-path';
 import mustacheReplacer from './mustache-replacer';
 
 export default function ({ block, refKeys, data }) {
@@ -7,16 +7,24 @@ export default function ({ block, refKeys, data }) {
   const copyBlock = JSON.parse(JSON.stringify(block));
 
   refKeys.forEach((blockDataKey) => {
-    if (!objectHasKey(block.data, blockDataKey)) return;
+    const currentData = objectPath.get(copyBlock.data, blockDataKey);
 
-    const currentData = copyBlock.data[blockDataKey];
+    if (!currentData) return;
 
     if (Array.isArray(currentData)) {
       currentData.forEach((str, index) => {
-        currentData[index] = mustacheReplacer(str, data);
+        objectPath.set(
+          copyBlock.data,
+          `${blockDataKey}.${index}`,
+          mustacheReplacer(str, data)
+        );
       });
     } else if (typeof currentData === 'string') {
-      copyBlock.data[blockDataKey] = mustacheReplacer(currentData, data);
+      objectPath.set(
+        copyBlock.data,
+        blockDataKey,
+        mustacheReplacer(currentData, data)
+      );
     }
   });
 

+ 6 - 2
src/utils/reference-data/mustache-replacer.js

@@ -34,13 +34,17 @@ export const functions = {
   randint(min = 0, max = 100) {
     return Math.round(Math.random() * (+max - +min) + +min);
   },
+  getLength(str) {
+    return str.length ?? str;
+  },
 };
 
 export function extractStrFunction(str) {
-  const extractedStr = /^\$\s*(\w+)\s*\((.*)\)/.exec(str.trim());
+  const extractedStr = /^\$\s*(\w+)\s*\((.*)\)/.exec(
+    str.trim().replace(/\r?\n|\r/g, '')
+  );
 
   if (!extractedStr) return null;
-
   const { 1: name, 2: funcParams } = extractedStr;
   const params = funcParams
     .split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/)

+ 45 - 1
src/utils/shared.js

@@ -321,6 +321,7 @@ export const tasks = {
       type: 'json',
       description: '',
       addBOMHeader: false,
+      onConflict: 'uniquify',
       dataToExport: 'data-columns',
     },
   },
@@ -491,7 +492,7 @@ export const tasks = {
     outputs: 1,
     allowedInputs: true,
     maxConnection: 1,
-    refDataKeys: ['selector'],
+    refDataKeys: ['selector', 'eventParams.clientX', 'eventParams.clientY'],
     data: {
       description: '',
       findBy: 'cssSelector',
@@ -746,6 +747,49 @@ export const tasks = {
       multiple: false,
     },
   },
+  'handle-dialog': {
+    name: 'Handle dialog',
+    description:
+      'Accepts or dismisses a JavaScript initiated dialog (alert, confirm, prompt, or onbeforeunload).',
+    icon: 'riChat3Line',
+    component: 'BlockBasic',
+    editComponent: 'EditHandleDialog',
+    category: 'browser',
+    inputs: 1,
+    outputs: 1,
+    allowedInputs: true,
+    maxConnection: 1,
+    refDataKeys: ['promptText'],
+    data: {
+      description: '',
+      accept: true,
+      promptText: '',
+    },
+  },
+  'handle-download': {
+    name: 'Handle download',
+    description: 'Handle downloaded file',
+    icon: 'riFileDownloadLine',
+    component: 'BlockBasic',
+    editComponent: 'EditHandleDownload',
+    category: 'browser',
+    inputs: 1,
+    outputs: 1,
+    allowedInputs: true,
+    maxConnection: 1,
+    refDataKeys: ['promptText'],
+    data: {
+      description: '',
+      filename: '',
+      timeout: 20000,
+      onConflict: 'uniquify',
+      waitForDownload: true,
+      dataColumn: '',
+      saveData: true,
+      assignVariable: false,
+      variableName: '',
+    },
+  },
 };
 
 export const categories = {