Ahmad Kholid 3 年 前
コミット
8f6d32f939
31 ファイル変更666 行追加351 行削除
  1. 1 0
      .eslintrc.js
  2. 1 1
      package.json
  3. BIN
      src/assets/images/theme-dark.png
  4. BIN
      src/assets/images/theme-light.png
  5. BIN
      src/assets/images/theme-system.png
  6. 39 32
      src/background/index.js
  7. 0 7
      src/background/workflow-engine/blocks-handler/handler-export-data.js
  8. 69 0
      src/background/workflow-engine/blocks-handler/handler-save-assets.js
  9. 3 2
      src/background/workflow-engine/engine.js
  10. 22 6
      src/components/newtab/app/AppSidebar.vue
  11. 1 1
      src/components/newtab/shared/SharedConditionBuilder/ConditionBuilderInputs.vue
  12. 0 37
      src/components/newtab/shared/SharedConditionBuilder/index.vue
  13. 13 7
      src/components/newtab/workflow/WorkflowDetailsCard.vue
  14. 110 50
      src/components/newtab/workflow/WorkflowSettings.vue
  15. 1 6
      src/components/newtab/workflow/edit/EditConditions.vue
  16. 2 0
      src/components/newtab/workflow/edit/EditInteractionBase.vue
  17. 81 0
      src/components/newtab/workflow/edit/EditSaveAssets.vue
  18. 1 1
      src/components/ui/UiSelect.vue
  19. 29 0
      src/content/blocks-handler/handler-save-assets.js
  20. 9 0
      src/content/element-selector/App.vue
  21. 160 158
      src/lib/v-remixicon.js
  22. 10 0
      src/locales/en/blocks.json
  23. 20 4
      src/locales/en/newtab.json
  24. 5 2
      src/locales/fr/newtab.json
  25. 8 2
      src/locales/zh/newtab.json
  26. 1 1
      src/models/workflow.js
  27. 2 15
      src/newtab/pages/settings/About.vue
  28. 23 9
      src/newtab/pages/settings/index.vue
  29. 2 1
      src/newtab/pages/workflows/[id].vue
  30. 46 1
      src/utils/shared.js
  31. 7 8
      src/utils/test-conditions.js

+ 1 - 0
.eslintrc.js

@@ -35,6 +35,7 @@ module.exports = {
     'no-underscore-dangle': 'off',
     'func-names': 'off',
     'import/no-named-default': 'off',
+    'no-restricted-syntax': 'off',
     'import/extensions': [
       'error',
       'always',

+ 1 - 1
package.json

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

BIN
src/assets/images/theme-dark.png


BIN
src/assets/images/theme-light.png


BIN
src/assets/images/theme-system.png


+ 39 - 32
src/background/index.js

@@ -3,7 +3,10 @@ import { MessageListener } from '@/utils/message';
 import { parseJSON, findTriggerBlock } from '@/utils/helper';
 import getFile from '@/utils/get-file';
 import decryptFlow, { getWorkflowPass } from '@/utils/decrypt-flow';
-import { registerSpecificDay } from '../utils/workflow-trigger';
+import {
+  registerSpecificDay,
+  registerWorkflowTrigger,
+} from '../utils/workflow-trigger';
 import WorkflowState from './workflow-state';
 import CollectionEngine from './collection-engine';
 import WorkflowEngine from './workflow-engine/engine';
@@ -297,10 +300,10 @@ browser.alarms.onAlarm.addListener(async ({ name }) => {
   }
 });
 
-chrome.runtime.onInstalled.addListener((details) => {
-  if (details.reason === 'install') {
-    browser.storage.local
-      .set({
+chrome.runtime.onInstalled.addListener(async ({ reason }) => {
+  try {
+    if (reason === 'install') {
+      await browser.storage.local.set({
         logs: [],
         shortcuts: {},
         workflows: [],
@@ -308,17 +311,34 @@ chrome.runtime.onInstalled.addListener((details) => {
         workflowState: {},
         isFirstTime: true,
         visitWebTriggers: [],
-      })
-      .then(() => {
-        browser.tabs
-          .create({
-            active: true,
-            url: browser.runtime.getURL('newtab.html#/welcome'),
-          })
-          .catch((error) => {
-            console.error(error);
-          });
       });
+      await browser.tabs.create({
+        active: true,
+        url: browser.runtime.getURL('newtab.html#/welcome'),
+      });
+
+      return;
+    }
+
+    if (reason === 'update') {
+      const { workflows } = await browser.storage.local.get('workflows');
+      const alarmTypes = ['specific-day', 'date', 'interval'];
+
+      for (const { trigger, drawflow, id } of workflows) {
+        let workflowTrigger = trigger?.data || trigger;
+
+        if (!trigger) {
+          const flows = parseJSON(drawflow, drawflow);
+          workflowTrigger = findTriggerBlock(flows)?.data;
+        }
+
+        if (!alarmTypes.includes(workflowTrigger.type)) return;
+
+        registerWorkflowTrigger(id, { data: workflowTrigger });
+      }
+    }
+  } catch (error) {
+    console.error(error);
   }
 });
 chrome.runtime.onStartup.addListener(async () => {
@@ -342,27 +362,11 @@ chrome.runtime.onStartup.addListener(async () => {
 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;
+    if (!suggestion) return true;
 
     const hasFileExt = getFileExtension(suggestion.filename);
 
@@ -372,6 +376,7 @@ if (chrome.downloads) {
     }
 
     if (!suggestion.waitForDownload) delete filesname[item.id];
+
     sessionStorage.setItem(
       'rename-downloaded-files',
       JSON.stringify(filesname)
@@ -381,6 +386,8 @@ if (chrome.downloads) {
       filename: suggestion.filename,
       conflictAction: suggestion.onConflict,
     });
+
+    return false;
   });
 }
 

+ 0 - 7
src/background/workflow-engine/blocks-handler/handler-export-data.js

@@ -23,18 +23,11 @@ async function exportData({ data, outputs }) {
 
     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',
       };
 
-      filesname[blobId] = options;
-      sessionStorage.setItem('export-filesname', JSON.stringify(filesname));
-
       await browser.downloads.download({
         ...options,
         url: blobUrl,

+ 69 - 0
src/background/workflow-engine/blocks-handler/handler-save-assets.js

@@ -0,0 +1,69 @@
+import browser from 'webextension-polyfill';
+import { getBlockConnection } from '../helper';
+
+function getFilename(url) {
+  try {
+    const filename = new URL(url).pathname.split('/').pop();
+    const hasExtension = /\.[0-9a-z]+$/i.test(filename);
+
+    if (!hasExtension) return null;
+
+    return filename;
+  } catch (e) {
+    return null;
+  }
+}
+
+export default async function ({ data, id, name, outputs }) {
+  const nextBlockId = getBlockConnection({ outputs });
+
+  try {
+    const hasPermission = await browser.permissions.contains({
+      permissions: ['downloads'],
+    });
+
+    if (!hasPermission) {
+      throw new Error('no-permission');
+    }
+
+    let sources = [data.url];
+    let index = 0;
+    const downloadFile = (url) => {
+      const options = { url, conflictAction: data.onConflict };
+      let filename = data.filename || getFilename(url);
+
+      if (filename) {
+        if (data.onConflict === 'overwrite' && index !== 0) {
+          filename = `(${index}) ${filename}`;
+        }
+
+        options.filename = filename;
+        index += 1;
+      }
+
+      return browser.downloads.download(options);
+    };
+
+    if (data.type === 'element') {
+      sources = await this._sendMessageToTab({
+        id,
+        name,
+        data,
+        tabId: this.activeTab.id,
+      });
+
+      await Promise.allSettled(sources.map((url) => downloadFile(url)));
+    } else if (data.type === 'url') {
+      await downloadFile(data.url);
+    }
+
+    return {
+      nextBlockId,
+      data: sources,
+    };
+  } catch (error) {
+    error.nextBlockId = nextBlockId;
+
+    throw error;
+  }
+}

+ 3 - 2
src/background/workflow-engine/engine.js

@@ -214,13 +214,14 @@ class WorkflowEngine {
       detail.replacedValue ||
       (tasks[detail.name]?.refDataKeys && this.saveLog)
     ) {
-      const { activeTabUrl, loopData, prevBlockData } = JSON.parse(
+      const { activeTabUrl, variables, loopData, prevBlockData } = JSON.parse(
         JSON.stringify(this.referenceData)
       );
 
       this.historyCtxData[historyId] = {
         referenceData: {
           loopData,
+          variables,
           activeTabUrl,
           prevBlockData,
         },
@@ -545,7 +546,7 @@ class WorkflowEngine {
       const data = await browser.tabs.sendMessage(
         this.activeTab.id,
         messagePayload,
-        { ...options, frameId: this.activeTab.frameId }
+        { frameId: this.activeTab.frameId, ...options }
       );
 
       return data;

+ 22 - 6
src/components/newtab/app/AppSidebar.vue

@@ -41,12 +41,7 @@
       </router-link>
     </div>
     <div class="flex-grow"></div>
-    <ui-popover
-      v-if="store.state.user"
-      trigger="mouseenter"
-      placement="right"
-      class="mb-4"
-    >
+    <ui-popover v-if="store.state.user" trigger="mouseenter" placement="right">
       <template #trigger>
         <span class="inline-block p-1 bg-box-transparent rounded-full">
           <img
@@ -59,6 +54,26 @@
       </template>
       {{ store.state.user.username }}
     </ui-popover>
+    <ui-popover trigger="mouseenter" placement="right" class="my-4">
+      <template #trigger>
+        <v-remixicon name="riGroupLine" />
+      </template>
+      <p class="mb-2">{{ t('home.communities') }}</p>
+      <ui-list class="w-40">
+        <ui-list-item
+          v-for="item in communities"
+          :key="item.name"
+          :href="item.url"
+          small
+          tag="a"
+          target="_blank"
+          rel="noopener"
+        >
+          <v-remixicon :name="item.icon" class="mr-2" />
+          {{ item.name }}
+        </ui-list-item>
+      </ui-list>
+    </ui-popover>
     <router-link v-tooltip:right.group="t('settings.menu.about')" to="/about">
       <v-remixicon class="cursor-pointer" name="riInformationLine" />
     </router-link>
@@ -71,6 +86,7 @@ import { useI18n } from 'vue-i18n';
 import { useRouter } from 'vue-router';
 import { useShortcut, getShortcut } from '@/composable/shortcut';
 import { useGroupTooltip } from '@/composable/groupTooltip';
+import { communities } from '@/utils/shared';
 
 useGroupTooltip();
 

+ 1 - 1
src/components/newtab/shared/SharedConditionBuilder/ConditionBuilderInputs.vue

@@ -6,7 +6,7 @@
   >
     <div
       v-if="item.category === 'value'"
-      class="space-y-1 flex items-end space-x-2 flex-wrap"
+      class="flex items-end space-x-2 flex-wrap"
     >
       <ui-select
         :model-value="item.type"

+ 0 - 37
src/components/newtab/shared/SharedConditionBuilder/index.vue

@@ -103,43 +103,6 @@ const { t } = useI18n();
 
 const conditions = ref(JSON.parse(JSON.stringify(props.modelValue)));
 
-// const conditions = ref([
-//   {
-//     id: nanoid(),
-//     conditions: [
-//       {
-//         id: nanoid(),
-//         items: [
-//           { id: nanoid(), type: 'value', category: 'value', data: { value: '' } },
-//           { id: nanoid(), category: 'compare', type: 'eq' },
-//           { id: nanoid(), type: 'value', category: 'value', data: { value: '' } },
-//         ],
-//       },
-//       {
-//         id: nanoid(),
-//         items: [
-//           { id: nanoid(), type: 'value', category: 'value', data: { value: '' } },
-//           { id: nanoid(), category: 'compare', type: 'lt' },
-//           { id: nanoid(), type: 'value', category: 'value', data: { value: '' } },
-//         ]
-//       }
-//     ],
-//   },
-//   {
-//     id: nanoid(),
-//     conditions: [
-//       {
-//         id: nanoid(),
-//         items: [
-//           { id: nanoid(), type: 'value', category: 'value', data: { value: '' } },
-//           { id: nanoid(), category: 'compare', type: 'eq' },
-//           { id: nanoid(), type: 'value', category: 'value', data: { value: '' } },
-//         ],
-//       }
-//     ]
-//   }
-// ]);
-
 function getDefaultValues(items = ['value', 'compare', 'value']) {
   const defaultValues = {
     value: {

+ 13 - 7
src/components/newtab/workflow/WorkflowDetailsCard.vue

@@ -86,13 +86,7 @@
         <div
           v-for="block in items"
           :key="block.id"
-          :title="
-            t(
-              `workflow.blocks.${block.id}.${
-                block.description ? 'description' : 'name'
-              }`
-            )
-          "
+          :title="getBlockTitle(block)"
           draggable="true"
           class="transform select-none cursor-move relative p-4 rounded-lg bg-input transition group"
           @dragstart="
@@ -195,4 +189,16 @@ function updateWorkflowIcon(value) {
 
   emit('update', { icon: iconUrl });
 }
+function getBlockTitle({ description, id }) {
+  const blockPath = `workflow.blocks.${id}`;
+  let blockDescription = t(
+    `${blockPath}.${description ? 'description' : 'name'}`
+  );
+
+  if (description) {
+    blockDescription = `[${t(`${blockPath}.name`)}]\n${blockDescription}`;
+  }
+
+  return blockDescription;
+}
 </script>

+ 110 - 50
src/components/newtab/workflow/WorkflowSettings.vue

@@ -1,67 +1,83 @@
 <template>
-  <div class="workflow-settings">
-    <div class="mb-4 flex">
-      <div class="flex-1">
-        <p class="mb-1 capitalize">
+  <div
+    class="workflow-settings space-y-4 divide-y dark:divide-gray-700 divide-gray-100"
+  >
+    <div class="flex items-center">
+      <div class="mr-4 flex-1">
+        <p>
           {{ t('workflow.settings.onError.title') }}
         </p>
-        <ui-select v-model="settings.onError" class="w-full max-w-sm">
-          <option v-for="item in onError" :key="item.id" :value="item.id">
-            {{ t(`workflow.settings.onError.items.${item.name}`) }}
-          </option>
-        </ui-select>
-      </div>
-      <label v-if="settings.onError === 'restart-workflow'" class="ml-2">
-        <p class="mb-1 capitalize">
-          {{ t('workflow.settings.restartWorkflow.for') }}
+        <p class="text-gray-600 dark:text-gray-200 text-sm leading-tight">
+          {{ t('workflow.settings.onError.description') }}
         </p>
-        <div class="flex items-center bg-input transition-colors rounded-lg">
-          <input
-            v-model.number="settings.restartTimes"
-            type="number"
-            class="py-2 px-4 w-32 rounded-lg bg-transparent"
-          />
-          <span class="px-2">
-            {{ t('workflow.settings.restartWorkflow.times') }}
-          </span>
-        </div>
-      </label>
-    </div>
-    <div>
-      <p class="mb-1 capitalize">
-        {{ t('workflow.settings.blockDelay.title') }}
-        <span :title="t('workflow.settings.blockDelay.description')">
-          &#128712;
+      </div>
+      <ui-select v-model="settings.onError">
+        <option v-for="item in onError" :key="item.id" :value="item.id">
+          {{ t(`workflow.settings.onError.items.${item.name}`) }}
+        </option>
+      </ui-select>
+      <div
+        v-if="settings.onError === 'restart-workflow'"
+        :title="t('workflow.settings.restartWorkflow.description')"
+        class="flex items-center bg-input transition-colors rounded-lg ml-4"
+      >
+        <input
+          v-model.number="settings.restartTimes"
+          type="number"
+          class="py-2 pl-2 text-right appearance-none w-12 rounded-lg bg-transparent"
+        />
+        <span class="px-2 text-sm">
+          {{ t('workflow.settings.restartWorkflow.times') }}
         </span>
-      </p>
-      <ui-input
-        v-model.number="settings.blockDelay"
-        type="number"
-        class="w-full max-w-sm"
-      />
-    </div>
-    <div class="flex mt-6">
-      <ui-switch v-model="settings.debugMode" class="mr-4" />
-      <p class="capitalize">{{ t('workflow.settings.debugMode') }}</p>
+      </div>
     </div>
-    <div class="flex mt-6">
-      <ui-switch v-model="settings.reuseLastState" class="mr-4" />
-      <p class="capitalize">{{ t('workflow.settings.reuseLastState') }}</p>
+    <div class="flex items-center pt-4">
+      <div class="mr-4 flex-1">
+        <p>
+          {{ t('workflow.settings.blockDelay.title') }}
+        </p>
+        <p class="text-gray-600 dark:text-gray-200 text-sm leading-tight">
+          {{ t('workflow.settings.blockDelay.description') }}
+        </p>
+      </div>
+      <ui-input v-model.number="settings.blockDelay" type="number" />
     </div>
-    <div class="flex mt-6">
-      <ui-switch v-model="settings.saveLog" class="mr-4" />
-      <p class="capitalize">{{ t('workflow.settings.saveLog') }}</p>
+    <div
+      v-for="item in settingItems"
+      :key="item.id"
+      class="flex items-center pt-4"
+    >
+      <div class="mr-4 flex-1">
+        <p>
+          {{ item.name }}
+        </p>
+        <p class="text-gray-600 dark:text-gray-200 text-sm leading-tight">
+          {{ item.description }}
+        </p>
+      </div>
+      <ui-switch v-model="settings[item.id]" class="mr-4" />
     </div>
-    <div class="flex mt-6">
-      <ui-switch v-model="settings.executedBlockOnWeb" class="mr-4" />
-      <p class="capitalize">{{ t('workflow.settings.executedBlockOnWeb') }}</p>
+    <div class="flex items-center pt-4">
+      <div class="mr-4 flex-1">
+        <p>
+          {{ t('workflow.settings.clearCache.title') }}
+        </p>
+        <p class="text-gray-600 dark:text-gray-200 text-sm leading-tight">
+          {{ t('workflow.settings.clearCache.description') }}
+        </p>
+      </div>
+      <ui-button @click="clearCache">
+        {{ t('workflow.settings.clearCache.btn') }}
+      </ui-button>
     </div>
   </div>
 </template>
 <script setup>
 import { onMounted, reactive, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
-import { debounce } from '@/utils/helper';
+import { useToast } from 'vue-toastification';
+import browser from 'webextension-polyfill';
+import { debounce, parseJSON } from '@/utils/helper';
 
 const props = defineProps({
   workflow: {
@@ -72,6 +88,7 @@ const props = defineProps({
 const emit = defineEmits(['update']);
 
 const { t } = useI18n();
+const toast = useToast();
 
 const onError = [
   {
@@ -87,11 +104,54 @@ const onError = [
     name: 'restartWorkflow',
   },
 ];
+const settingItems = [
+  {
+    id: 'debugMode',
+    name: t('workflow.settings.debugMode.title'),
+    description: t('workflow.settings.debugMode.description'),
+  },
+  {
+    id: 'reuseLastState',
+    name: t('workflow.settings.reuseLastState.title'),
+    description: t('workflow.settings.reuseLastState.description'),
+  },
+  {
+    id: 'saveLog',
+    name: t('workflow.settings.saveLog'),
+    description: '',
+  },
+  {
+    id: 'executedBlockOnWeb',
+    name: t('workflow.settings.executedBlockOnWeb'),
+    description: '',
+  },
+];
 
 const settings = reactive({
   restartTimes: 3,
 });
 
+async function clearCache() {
+  try {
+    await browser.storage.local.remove(`last-state:${props.workflow.id}`);
+
+    const flows = parseJSON(props.workflow.drawflow, null);
+    const blocks = flows && flows.drawflow.Home.data;
+
+    if (blocks) {
+      Object.values(blocks).forEach(({ name, id }) => {
+        if (name !== 'loop-data') return;
+
+        localStorage.removeItem(`index:${id}`);
+      });
+    }
+
+    toast(t('workflow.settings.clearCache.info'));
+  } catch (error) {
+    console.error(error);
+  }
+}
+
 watch(
   settings,
   debounce((newSettings) => {

+ 1 - 6
src/components/newtab/workflow/edit/EditConditions.vue

@@ -111,12 +111,7 @@ function addCondition() {
   conditions.value.push({
     id: nanoid(),
     name: `Path ${conditions.value.length + 1}`,
-    conditions: [
-      {
-        id: nanoid(),
-        conditions: [],
-      },
-    ],
+    conditions: [],
   });
 }
 function deleteCondition(index) {

+ 2 - 0
src/components/newtab/workflow/edit/EditInteractionBase.vue

@@ -8,7 +8,9 @@
         class="w-full mb-2"
         @change="updateData({ description: $event })"
       />
+      <slot name="prepend:selector" />
       <ui-select
+        v-if="!hideSelector"
         :model-value="data.findBy || 'cssSelector'"
         :placeholder="t('workflow.blocks.base.findElement.placeholder')"
         class="w-full mb-2"

+ 81 - 0
src/components/newtab/workflow/edit/EditSaveAssets.vue

@@ -0,0 +1,81 @@
+<template>
+  <edit-interaction-base
+    :data="data"
+    :hide="!permission.has.downloads"
+    :hide-selector="data.type !== 'element'"
+    @change="updateData"
+  >
+    <template #prepend:selector>
+      <ui-select
+        class="mb-4"
+        :model-value="data.type"
+        :label="t('workflow.blocks.save-assets.contentTypes.title')"
+        @change="updateData({ type: $event })"
+      >
+        <option v-for="type in types" :key="type" :value="type">
+          {{ t(`workflow.blocks.save-assets.contentTypes.${type}`) }}
+        </option>
+      </ui-select>
+    </template>
+    <template #prepend>
+      <template v-if="!permission.has.downloads">
+        <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>
+    </template>
+    <ui-input
+      v-if="data.type === 'url'"
+      :model-value="data.url"
+      label="URL"
+      class="w-full"
+      placeholder="https://example.com/picture.png"
+      @change="updateData({ url: $event })"
+    />
+    <template v-if="permission.has.downloads">
+      <ui-input
+        :model-value="data.filename"
+        :label="t('workflow.blocks.save-assets.filename')"
+        class="w-full mt-4"
+        placeholder="image.jpeg"
+        @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>
+    </template>
+  </edit-interaction-base>
+</template>
+<script setup>
+import { useI18n } from 'vue-i18n';
+import { useHasPermissions } from '@/composable/hasPermissions';
+import EditInteractionBase from './EditInteractionBase.vue';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const { t } = useI18n();
+const permission = useHasPermissions(['downloads']);
+
+const types = ['element', 'url'];
+const onConflict = ['uniquify', 'overwrite', 'prompt'];
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+</script>

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

@@ -3,7 +3,7 @@
     <label
       v-if="label || $slots.label"
       :for="selectId"
-      class="text-gray-600 dark:text-gray-200 text-sm ml-2"
+      class="text-gray-600 dark:text-gray-200 text-sm ml-1"
     >
       <slot name="label">
         {{ label }}

+ 29 - 0
src/content/blocks-handler/handler-save-assets.js

@@ -0,0 +1,29 @@
+import handleSelector from '../handle-selector';
+
+async function saveAssets(block) {
+  let elements = await handleSelector(block, { returnElement: true });
+
+  if (!elements) {
+    throw new Error('element-not-found');
+  }
+
+  elements = block.data.multiple ? Array.from(elements) : [elements];
+
+  const srcs = elements.reduce((acc, element) => {
+    const tag = element.tagName;
+
+    if ((tag === 'AUDIO' || tag === 'VIDEO') && !tag.src) {
+      const sourceEl = element.querySelector('source');
+
+      if (sourceEl && sourceEl.src) acc.push(sourceEl.src);
+    } else if (element.src) {
+      acc.push(element.src);
+    }
+
+    return acc;
+  }, []);
+
+  return srcs;
+}
+
+export default saveAssets;

+ 9 - 0
src/content/element-selector/App.vue

@@ -164,6 +164,7 @@ const selectedElement = {
 };
 let lastScrollPosY = window.scrollY;
 let lastScrollPosX = window.scrollX;
+const originalFontSize = document.documentElement.style.fontSize;
 
 const rootElement = inject('rootElement');
 
@@ -425,6 +426,8 @@ function destroy() {
     height: 0,
     width: 0,
   });
+
+  document.documentElement.style.fontSize = originalFontSize;
 }
 
 window.addEventListener('scroll', handleScroll);
@@ -448,6 +451,12 @@ nextTick(() => {
     cardRect.y = 20;
     cardRect.width = width;
     cardRect.height = height;
+
+    document.documentElement.style.setProperty(
+      'font-size',
+      '16px',
+      'important'
+    );
   }, 250);
 });
 </script>

+ 160 - 158
src/lib/v-remixicon.js

@@ -2,213 +2,215 @@ import vRemixicon from 'v-remixicon';
 import {
   riH1,
   riH2,
+  riAB,
+  riBold,
+  riLink,
   riLinkM,
-  riTwitterLine,
+  riItalic,
+  riTable2,
+  riEyeLine,
+  riAddLine,
+  riSortAsc,
+  riKey2Line,
+  riTBoxLine,
+  riSaveLine,
+  riPlayLine,
+  riMoreLine,
+  riStopLine,
+  riSortDesc,
+  riFlagLine,
+  riGroupLine,
   riGuideLine,
   riChat3Line,
-  riDiscordLine,
   riEarthLine,
-  riClipboardLine,
   riLock2Line,
-  riBaseStationLine,
-  riKeyboardLine,
-  riLinkUnlinkM,
-  riFileEditLine,
-  riBold,
-  riItalic,
-  riStrikethrough2,
-  riDoubleQuotesL,
   riHome5Line,
   riShareLine,
-  riTable2,
-  riArrowLeftRightLine,
-  riFileUploadLine,
-  riLightbulbLine,
-  riSideBarLine,
-  riSideBarFill,
-  riFolderZipLine,
-  riHandHeartLine,
-  riCompass3Line,
-  riFileCopyLine,
-  riShieldKeyholeLine,
+  riBook3Line,
+  riPauseLine,
+  riFlowChart,
+  riMore2Line,
+  riMouseLine,
+  riFocusLine,
+  riParagraph,
+  riImageLine,
+  riCloseLine,
+  riCheckLine,
+  riTimerLine,
   riToggleLine,
   riFolderLine,
-  riInformationLine,
+  riGithubFill,
+  riEyeOffLine,
+  riWindowLine,
+  riPencilLine,
+  riGlobalLine,
+  riCursorLine,
+  riUploadLine,
+  riFocus3Line,
+  riTwitterLine,
+  riDiscordLine,
+  riLinkUnlinkM,
+  riSideBarLine,
+  riSideBarFill,
   riWindow2Line,
-  riArrowUpDownLine,
   riRefreshLine,
   riRefreshFill,
-  riBook3Line,
-  riGithubFill,
-  riCodeSSlashLine,
-  riRecordCircleLine,
-  riErrorWarningLine,
-  riEyeLine,
-  riEyeOffLine,
-  riCalendarLine,
-  riFileTextLine,
   riFilter2Line,
-  riArrowGoBackLine,
-  riArrowGoForwardLine,
-  riDatabase2Line,
-  riSettings3Line,
-  riWindowLine,
-  riKey2Line,
   riRestartLine,
-  riTBoxLine,
-  riAB,
-  riSaveLine,
-  riSubtractLine,
-  riPlayLine,
-  riPauseLine,
   riSearch2Line,
-  riMoreLine,
-  riDeleteBin7Line,
-  riPencilLine,
-  riExternalLinkLine,
-  riLink,
-  riArrowLeftSLine,
-  riArrowLeftLine,
   riEditBoxLine,
-  riStopLine,
-  riCheckboxCircleLine,
-  riFlowChart,
   riHistoryLine,
-  riArrowDropDownLine,
-  riAddLine,
-  riFullscreenLine,
-  riSortAsc,
-  riSortDesc,
-  riGlobalLine,
-  riMore2Line,
-  riInputCursorMove,
   riRepeat2Line,
-  riMouseLine,
+  riCommandLine,
+  riKeyboardLine,
+  riFileEditLine,
+  riCompass3Line,
+  riFileCopyLine,
+  riCalendarLine,
+  riFileTextLine,
+  riSubtractLine,
   riBracketsLine,
-  riEqualizerLine,
-  riFocusLine,
-  riCursorLine,
   riDownloadLine,
-  riFileDownloadLine,
-  riUploadLine,
-  riCommandLine,
-  riParagraph,
-  riImageLine,
-  riCloseLine,
-  riCloseCircleLine,
   riDragDropLine,
-  riCheckLine,
-  riFocus3Line,
-  riTimerLine,
-  riLightbulbFlashLine,
+  riClipboardLine,
+  riDoubleQuotesL,
+  riLightbulbLine,
+  riFolderZipLine,
+  riHandHeartLine,
+  riDatabase2Line,
+  riSettings3Line,
+  riArrowLeftLine,
+  riEqualizerLine,
+  riStrikethrough2,
+  riFileUploadLine,
+  riCodeSSlashLine,
+  riDeleteBin7Line,
+  riArrowLeftSLine,
+  riFullscreenLine,
   riFlashlightLine,
-  riFlagLine,
+  riBaseStationLine,
+  riInformationLine,
+  riArrowUpDownLine,
+  riArrowGoBackLine,
+  riInputCursorMove,
+  riCloseCircleLine,
+  riRecordCircleLine,
+  riErrorWarningLine,
+  riExternalLinkLine,
+  riFileDownloadLine,
+  riShieldKeyholeLine,
+  riArrowDropDownLine,
+  riArrowLeftRightLine,
+  riArrowGoForwardLine,
+  riCheckboxCircleLine,
+  riLightbulbFlashLine,
 } from 'v-remixicon/icons';
 
 export const icons = {
   riH1,
   riH2,
+  riAB,
+  riBold,
+  riLink,
   riLinkM,
-  riTwitterLine,
+  riItalic,
+  riTable2,
+  riEyeLine,
+  riAddLine,
+  riSortAsc,
+  riKey2Line,
+  riTBoxLine,
+  riSaveLine,
+  riPlayLine,
+  riMoreLine,
+  riStopLine,
+  riSortDesc,
+  riFlagLine,
+  riGroupLine,
   riGuideLine,
   riChat3Line,
-  riDiscordLine,
   riEarthLine,
-  riClipboardLine,
   riLock2Line,
-  riBaseStationLine,
-  riKeyboardLine,
-  riLinkUnlinkM,
-  riFileEditLine,
-  riBold,
-  riItalic,
-  riStrikethrough2,
-  riDoubleQuotesL,
   riHome5Line,
   riShareLine,
-  riTable2,
-  riArrowLeftRightLine,
-  riFileUploadLine,
-  riLightbulbLine,
-  riSideBarLine,
-  riSideBarFill,
-  riFolderZipLine,
-  riHandHeartLine,
-  riCompass3Line,
-  riFileCopyLine,
-  riShieldKeyholeLine,
+  riBook3Line,
+  riPauseLine,
+  riFlowChart,
+  riMore2Line,
+  riMouseLine,
+  riFocusLine,
+  riParagraph,
+  riImageLine,
+  riCloseLine,
+  riCheckLine,
+  riTimerLine,
   riToggleLine,
   riFolderLine,
-  riInformationLine,
+  riGithubFill,
+  riEyeOffLine,
+  riWindowLine,
+  riPencilLine,
+  riGlobalLine,
+  riCursorLine,
+  riUploadLine,
+  riFocus3Line,
+  riTwitterLine,
+  riDiscordLine,
+  riLinkUnlinkM,
+  riSideBarLine,
+  riSideBarFill,
   riWindow2Line,
-  riArrowUpDownLine,
   riRefreshLine,
   riRefreshFill,
-  riBook3Line,
-  riGithubFill,
-  riCodeSSlashLine,
-  riRecordCircleLine,
-  riErrorWarningLine,
-  riEyeLine,
-  riEyeOffLine,
-  riCalendarLine,
-  riFileTextLine,
   riFilter2Line,
-  riArrowGoBackLine,
-  riArrowGoForwardLine,
-  riDatabase2Line,
-  riSettings3Line,
-  riWindowLine,
-  riKey2Line,
   riRestartLine,
-  riTBoxLine,
-  riAB,
-  riSaveLine,
-  riSubtractLine,
-  riPlayLine,
-  riPauseLine,
   riSearch2Line,
-  riMoreLine,
-  riDeleteBin7Line,
-  riPencilLine,
-  riExternalLinkLine,
-  riLink,
-  riArrowLeftSLine,
-  riArrowLeftLine,
   riEditBoxLine,
-  riStopLine,
-  riCheckboxCircleLine,
-  riFlowChart,
   riHistoryLine,
-  riArrowDropDownLine,
-  riAddLine,
-  riFullscreenLine,
-  riSortAsc,
-  riSortDesc,
-  riGlobalLine,
-  riMore2Line,
-  riInputCursorMove,
   riRepeat2Line,
-  riMouseLine,
+  riCommandLine,
+  riKeyboardLine,
+  riFileEditLine,
+  riCompass3Line,
+  riFileCopyLine,
+  riCalendarLine,
+  riFileTextLine,
+  riSubtractLine,
   riBracketsLine,
-  riEqualizerLine,
-  riFocusLine,
-  riCursorLine,
   riDownloadLine,
-  riFileDownloadLine,
-  riUploadLine,
-  riCommandLine,
-  riParagraph,
-  riImageLine,
-  riCloseLine,
-  riCloseCircleLine,
   riDragDropLine,
-  riCheckLine,
-  riFocus3Line,
-  riTimerLine,
-  riLightbulbFlashLine,
+  riClipboardLine,
+  riDoubleQuotesL,
+  riLightbulbLine,
+  riFolderZipLine,
+  riHandHeartLine,
+  riDatabase2Line,
+  riSettings3Line,
+  riArrowLeftLine,
+  riEqualizerLine,
+  riStrikethrough2,
+  riFileUploadLine,
+  riCodeSSlashLine,
+  riDeleteBin7Line,
+  riArrowLeftSLine,
+  riFullscreenLine,
   riFlashlightLine,
-  riFlagLine,
+  riBaseStationLine,
+  riInformationLine,
+  riArrowUpDownLine,
+  riArrowGoBackLine,
+  riInputCursorMove,
+  riCloseCircleLine,
+  riRecordCircleLine,
+  riErrorWarningLine,
+  riExternalLinkLine,
+  riFileDownloadLine,
+  riShieldKeyholeLine,
+  riArrowDropDownLine,
+  riArrowLeftRightLine,
+  riArrowGoForwardLine,
+  riCheckboxCircleLine,
+  riLightbulbFlashLine,
   mdiEqual: 'M19,10H5V8H19V10M19,16H5V14H19V16Z',
   mdiVariable:
     'M20.41,3C21.8,5.71 22.35,8.84 22,12C21.8,15.16 20.7,18.29 18.83,21L17.3,20C18.91,17.57 19.85,14.8 20,12C20.34,9.2 19.89,6.43 18.7,4L20.41,3M5.17,3L6.7,4C5.09,6.43 4.15,9.2 4,12C3.66,14.8 4.12,17.57 5.3,20L3.61,21C2.21,18.29 1.65,15.17 2,12C2.2,8.84 3.3,5.71 5.17,3M12.08,10.68L14.4,7.45H16.93L13.15,12.45L15.35,17.37H13.09L11.71,14L9.28,17.33H6.76L10.66,12.21L8.53,7.45H10.8L12.08,10.68Z',

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

@@ -48,6 +48,16 @@
           }
         }
       },
+      "save-assets": {
+        "name": "Save assets",
+        "description": "Save assets (image, video, audio, or file) from an element or URL",
+        "filename": "Filename (optional)",
+        "contentTypes": {
+          "title": "Type",
+          "element": "Media element (image, audio, or video)",
+          "url": "URL"
+        }
+      },
       "handle-dialog": {
         "name": "Handle dialog",
         "description": "Accepts or dismisses a JavaScript initiated dialog (alert, confirm, prompt, or onbeforeunload).",

+ 20 - 4
src/locales/en/newtab.json

@@ -1,6 +1,7 @@
 {
   "home": {
-    "viewAll": "View all"
+    "viewAll": "View all",
+    "communities": "Communities"
   },
   "welcome": {
     "title": "Welcome to Automa! 🎉",
@@ -162,15 +163,29 @@
     },
     "settings": {
       "saveLog": "Save workflow log",
-      "reuseLastState": "Reuse last workflow state",
       "executedBlockOnWeb": "Show executed block on web page",
-      "debugMode": "Debug mode",
+      "clearCache": {
+        "title": "Clear cache",
+        "description": "Clear cache (state and loop index) of the workflow",
+        "info": "Successful clear workflow cache",
+        "btn": "Clear"
+      },
+      "reuseLastState": {
+        "title": "Reuse last workflow state",
+        "description": "Use the state data (table, variables, and global data) from the last executed workflow"
+      },
+      "debugMode": {
+        "title": "Debug mode",
+        "description": "Execute the workflow using the Chrome DevTools Protocol"
+      },
       "restartWorkflow": {
         "for": "Restart for",
-        "times": "Times"
+        "times": "Times",
+        "description": "Max how many times the workflow will restart"
       },
       "onError": {
         "title": "On workflow error",
+        "description": "Set what to do when an error occurs in the workflow",
         "items": {
           "keepRunning": "Keep running",
           "stopWorkflow": "Stop workflow",
@@ -222,6 +237,7 @@
     "messages": {
       "url-empty": "URL is empty",
       "invalid-url": "URL is not valid",
+      "no-permission": "Don't have permission",
       "conditions-empty": "Conditions is empty",
       "invalid-proxy-host": "Invalid proxy host",
       "workflow-disabled": "Workflow is disabled",

+ 5 - 2
src/locales/fr/newtab.json

@@ -157,7 +157,10 @@
     "settings": {
       "saveLog": "Enregistrer les logs du workflow",
       "executedBlockOnWeb": "Afficher le bloc exécuté sur la page Web",
-      "debugMode": "Mode debug",
+      "debugMode": {
+        "title": "Mode debug",
+        "description": "Execute the workflow using the Chrome DevTools Protocol"
+      },
       "restartWorkflow": {
         "for": "Redémarrez pour",
         "times": "Fois"
@@ -282,4 +285,4 @@
       "of": "sur {page}"
     }
   }
-}
+}

+ 8 - 2
src/locales/zh/newtab.json

@@ -156,9 +156,15 @@
     },
     "settings": {
       "saveLog": "保存工作流日志",
-      "reuseLastState": "重用上一个工作流状态",
+      "reuseLastState": {
+        "title": "重用上一个工作流状态",
+        "description": "Use the state data (table, variables, and global data) from the last executed"
+      },
       "executedBlockOnWeb": "在网页上显示已执行的模块",
-      "debugMode": "调试模式",
+      "debugMode": {
+        "title": "调试模式",
+        "description": "Execute the workflow using the Chrome DevTools Protocol"
+      },
       "restartWorkflow": {
         "for": "重新启动",
         "times": "次数"

+ 1 - 1
src/models/workflow.js

@@ -45,7 +45,7 @@ class Workflow extends Model {
   }
 
   static beforeCreate(model) {
-    if (model.dataColumns.length > 0) {
+    if (model.dataColumns?.length > 0) {
       model.table = model.dataColumns;
       model.dataColumns = [];
     }

+ 2 - 15
src/newtab/pages/settings/About.vue

@@ -51,27 +51,14 @@
 import { onMounted } from 'vue';
 import { useStore } from 'vuex';
 import { useGroupTooltip } from '@/composable/groupTooltip';
+import { communities } from '@/utils/shared';
 
 useGroupTooltip();
 const store = useStore();
 
 const extensionVersion = chrome.runtime.getManifest().version;
 const links = [
-  {
-    name: 'GitHub',
-    icon: 'riGithubFill',
-    url: 'https://github.com/kholid060/automa',
-  },
-  {
-    name: 'Twitter',
-    icon: 'riTwitterLine',
-    url: 'https://twitter.com/AutomaApp',
-  },
-  {
-    name: 'Discord',
-    icon: 'riDiscordLine',
-    url: 'https://discord.gg/C6khwwTE84',
-  },
+  ...communities,
   { name: 'Website', icon: 'riGlobalLine', url: 'https://www.automa.site' },
   {
     name: 'Documentation',

+ 23 - 9
src/newtab/pages/settings/index.vue

@@ -1,15 +1,29 @@
 <template>
   <div class="mb-8">
     <p class="font-semibold mb-1">{{ t('settings.theme') }}</p>
-    <ui-select
-      :model-value="theme.activeTheme.value"
-      class="w-80"
-      @change="theme.set($event)"
-    >
-      <option v-for="item in theme.themes" :key="item.id" :value="item.id">
-        {{ item.name }}
-      </option>
-    </ui-select>
+    <div class="flex items-center space-x-4">
+      <div
+        v-for="item in theme.themes"
+        :key="item.id"
+        class="cursor-pointer"
+        role="button"
+        @click="theme.set(item.id)"
+      >
+        <div
+          :class="{ 'ring ring-accent': item.id === theme.activeTheme.value }"
+          class="p-0.5 rounded-lg"
+        >
+          <img
+            :src="require(`@/assets/images/theme-${item.id}.png`).default"
+            width="140"
+            class="rounded-lg"
+          />
+        </div>
+        <span class="text-sm text-gray-600 dark:text-gray-200 ml-1">
+          {{ item.name }}
+        </span>
+      </div>
+    </div>
   </div>
   <div class="flex items-center">
     <div id="languages">

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

@@ -334,6 +334,7 @@ const workflowModals = {
     docs: 'https://docs.automa.site/api-reference/global-data.html',
   },
   settings: {
+    width: 'max-w-2xl',
     icon: 'riSettings3Line',
     component: WorkflowSettings,
     title: t('common.settings'),
@@ -569,7 +570,7 @@ function fetchLocalWorkflow() {
 }
 function insertToLocal() {
   const copy = {
-    ...props.workflow,
+    ...workflow.value,
     createdAt: Date.now(),
     version: chrome.runtime.getManifest().version,
   };

+ 46 - 1
src/utils/shared.js

@@ -527,7 +527,7 @@ export const tasks = {
       description: '',
       spreadsheetId: '',
       firstRowAsKey: false,
-      keysAsFirstRow: false,
+      keysAsFirstRow: true,
       valueInputOption: 'RAW',
       dataFrom: 'data-columns',
     },
@@ -764,6 +764,33 @@ export const tasks = {
       multiple: false,
     },
   },
+  'save-assets': {
+    name: 'Save assets',
+    description:
+      'Save assets (image, video, audio, or file) from an element or URL',
+    icon: 'riImageLine',
+    component: 'BlockBasic',
+    editComponent: 'EditSaveAssets',
+    category: 'interaction',
+    inputs: 1,
+    outputs: 1,
+    allowedInputs: true,
+    maxConnection: 1,
+    refDataKeys: ['selector', 'url'],
+    data: {
+      description: '',
+      findBy: 'cssSelector',
+      waitForSelector: false,
+      waitSelectorTimeout: 5000,
+      selector: '',
+      markEl: false,
+      multiple: false,
+      type: 'element',
+      url: '',
+      filename: '',
+      onConflict: 'uniquify',
+    },
+  },
   'handle-dialog': {
     name: 'Handle dialog',
     description:
@@ -897,6 +924,24 @@ export const supportLocales = [
   { id: 'fr', name: 'Français' },
 ];
 
+export const communities = [
+  {
+    name: 'GitHub',
+    icon: 'riGithubFill',
+    url: 'https://github.com/kholid060/automa',
+  },
+  {
+    name: 'Twitter',
+    icon: 'riTwitterLine',
+    url: 'https://twitter.com/AutomaApp',
+  },
+  {
+    name: 'Discord',
+    icon: 'riDiscordLine',
+    url: 'https://discord.gg/C6khwwTE84',
+  },
+];
+
 export const conditionBuilder = {
   valueTypes: [
     {

+ 7 - 8
src/utils/test-conditions.js

@@ -1,4 +1,3 @@
-/* eslint-disable no-restricted-syntax */
 import mustacheReplacer from './reference-data/mustache-replacer';
 import { conditionBuilder } from './shared';
 
@@ -36,7 +35,7 @@ export default async function (conditionsArr, workflowData) {
         workflowData.refData
       );
 
-      copyData[key] = value;
+      copyData[key] = value ?? '';
       Object.assign(result.replacedValue, list);
     });
 
@@ -67,11 +66,11 @@ export default async function (conditionsArr, workflowData) {
       if (!conditionResult) return conditionResult;
 
       if (category === 'compare') {
-        const isNeedValue = conditionBuilder.compareTypes.find(
+        const { needValue } = conditionBuilder.compareTypes.find(
           ({ id }) => id === type
-        ).needValue;
+        );
 
-        if (!isNeedValue) {
+        if (!needValue) {
           conditionResult = comparisons[type](condition.value);
 
           return conditionResult;
@@ -80,11 +79,11 @@ export default async function (conditionsArr, workflowData) {
         condition.operator = type;
       } else if (category === 'value') {
         const conditionValue = await getConditionItemValue({ data, type });
-        const isCompareable = conditionBuilder.valueTypes.find(
+        const { compareable } = conditionBuilder.valueTypes.find(
           ({ id }) => id === type
-        ).compareable;
+        );
 
-        if (!isCompareable) {
+        if (!compareable) {
           conditionResult = conditionValue;
         } else if (condition.operator) {
           conditionResult = comparisons[condition.operator](