Преглед изворни кода

Merge pull request #1361 from AutomaApp/dev

v1.28.9
Ahmad Kholid пре 1 година
родитељ
комит
326c78c311

+ 2 - 1
.vscode/settings.json

@@ -1,5 +1,6 @@
 {
   "i18n-ally.localesPaths": [
     "src/locales"
-  ]
+  ],
+  "i18n-ally.keystyle": "nested"
 }

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "1.28.8",
+  "version": "1.28.9",
   "description": "An extension for automating your browser by connecting blocks",
   "repository": {
     "type": "git",

+ 77 - 0
src/background/BackgroundEventsListeners.js

@@ -1,8 +1,80 @@
 import browser from 'webextension-polyfill';
 import { initElementSelector } from '@/newtab/utils/elementSelector';
+import dayjs from 'dayjs';
+import dbStorage from '@/db/storage';
+import cronParser from 'cron-parser';
 import BackgroundUtils from './BackgroundUtils';
 import BackgroundWorkflowTriggers from './BackgroundWorkflowTriggers';
 
+async function handleScheduleBackup() {
+  try {
+    const { localBackupSettings, workflows } = await browser.storage.local.get([
+      'localBackupSettings',
+      'workflows',
+    ]);
+    if (!localBackupSettings) return;
+
+    const workflowsData = Object.values(workflows || []).reduce(
+      (acc, workflow) => {
+        if (workflow.isProtected) return acc;
+
+        delete workflow.$id;
+        delete workflow.createdAt;
+        delete workflow.data;
+        delete workflow.isDisabled;
+        delete workflow.isProtected;
+
+        acc.push(workflow);
+
+        return acc;
+      },
+      []
+    );
+
+    const payload = {
+      workflows: JSON.stringify(workflowsData),
+    };
+
+    if (localBackupSettings.includedItems.includes('storage:table')) {
+      const tables = await dbStorage.tablesItems.toArray();
+      payload.storageTables = JSON.stringify(tables);
+    }
+    if (localBackupSettings.includedItems.includes('storage:variables')) {
+      const variables = await dbStorage.variables.toArray();
+      payload.storageVariables = JSON.stringify(variables);
+    }
+
+    const base64 = btoa(JSON.stringify(payload));
+    const filename = `${
+      localBackupSettings.folderName ? `${localBackupSettings.folderName}/` : ''
+    }${dayjs().format('DD-MMM-YYYY--HH-mm')}.json`;
+
+    await browser.downloads.download({
+      filename,
+      url: `data:application/json;base64,${base64}`,
+    });
+    await browser.storage.local.set({
+      localBackupSettings: {
+        ...localBackupSettings,
+        lastBackup: Date.now(),
+      },
+    });
+
+    const expression =
+      localBackupSettings.schedule === 'custom'
+        ? localBackupSettings.customSchedule
+        : localBackupSettings.schedule;
+    const parsedExpression = cronParser.parseExpression(expression).next();
+    if (!parsedExpression) return;
+
+    await browser.alarms.create('schedule-local-backup', {
+      when: parsedExpression.getTime(),
+    });
+  } catch (error) {
+    console.error(error);
+  }
+}
+
 class BackgroundEventsListeners {
   static onActionClicked() {
     BackgroundUtils.openDashboard();
@@ -17,6 +89,11 @@ class BackgroundEventsListeners {
   }
 
   static onAlarms(event) {
+    if (event.name === 'schedule-local-backup') {
+      handleScheduleBackup();
+      return;
+    }
+
     BackgroundWorkflowTriggers.scheduleWorkflow(event);
   }
 

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

@@ -9,7 +9,7 @@
         {{ label }}
       </slot>
     </label>
-    <div class="ui-select__content relative block flex w-full items-center">
+    <div class="ui-select__content relative flex w-full items-center">
       <v-remixicon
         v-if="prependIcon"
         size="20"

+ 56 - 14
src/content/services/recordWorkflow/recordEvents.js

@@ -10,8 +10,7 @@ let isMainFrame = true;
 const isAutomaInstance = (target) =>
   target.id === 'automa-recording' ||
   document.body.hasAttribute('automa-selecting');
-const textFieldEl = (el) =>
-  ['INPUT', 'TEXTAREA'].includes(el.tagName) || el.isContentEditable;
+const isTextFieldEl = (el) => ['INPUT', 'TEXTAREA'].includes(el.tagName);
 
 async function addBlock(detail) {
   try {
@@ -65,23 +64,17 @@ function onChange({ target }) {
         filePaths: [target.value],
       },
     };
-  } else if (textFieldEl(target) || isSelectEl) {
-    let description = '';
-
-    if (elementName && elementName.length < 12) {
-      description = `${isSelectEl ? 'Select' : 'Text field'} (${elementName})`;
-    }
-
+  } else if (isSelectEl) {
     block = {
       id: 'forms',
       data: {
         selector,
         delay: 100,
-        description,
+        type: 'select',
         clearValue: true,
         value: target.value,
         waitForSelector: true,
-        type: isSelectEl ? 'select' : 'text-field',
+        description: `Element Name (${elementName})`,
       },
     };
   } else {
@@ -121,13 +114,12 @@ function onChange({ target }) {
 async function onKeydown(event) {
   if (isAutomaInstance(event.target) || event.repeat) return;
 
-  const isTextField = textFieldEl(event.target);
+  const isTextField = isTextFieldEl(event.target);
   const enterKey = event.key === 'Enter';
   let isSubmitting = false;
 
   if (isTextField) {
     const inputInForm = event.target.form && event.target.tagName === 'INPUT';
-
     if (enterKey && inputInForm) {
       event.preventDefault();
 
@@ -183,7 +175,6 @@ async function onKeydown(event) {
 }
 function onClick(event) {
   const { target } = event;
-
   if (isAutomaInstance(target)) return;
 
   const isTextField =
@@ -294,6 +285,49 @@ const onScroll = debounce(({ target }) => {
   });
 }, 500);
 
+const onInputTextField = debounce(({ target }) => {
+  const selector = target.dataset.automaElSelector;
+  if (!selector) return;
+
+  addBlock((recording) => {
+    const lastFlow = recording.flows[recording.flows.length - 1];
+    if (
+      lastFlow &&
+      lastFlow.id === 'forms' &&
+      lastFlow.data.selector === selector
+    ) {
+      lastFlow.data.value = target.value;
+      return;
+    }
+
+    const elementName = (target.ariaLabel || target.name || '').slice(0, 12);
+    recording.flows.push({
+      id: 'forms',
+      data: {
+        selector,
+        delay: 100,
+        clearValue: true,
+        type: 'text-field',
+        value: target.value,
+        waitForSelector: true,
+        description: `Text field (${elementName})`,
+      },
+    });
+  });
+}, 300);
+
+function onFocusIn({ target }) {
+  if (!isTextFieldEl(target)) return;
+
+  target.setAttribute('data-automa-el-selector', findSelector(target));
+  target.addEventListener('input', onInputTextField);
+}
+function onFocusOut({ target }) {
+  if (!isTextFieldEl(target)) return;
+
+  target.removeEventListener('input', onInputTextField);
+}
+
 export function cleanUp() {
   if (isMainFrame) {
     window.removeEventListener('message', onMessage);
@@ -302,7 +336,9 @@ export function cleanUp() {
 
   document.removeEventListener('click', onClick, true);
   document.removeEventListener('change', onChange, true);
+  document.removeEventListener('focusin', onFocusIn, true);
   document.removeEventListener('keydown', onKeydown, true);
+  document.removeEventListener('focusout', onFocusOut, true);
 }
 
 export default async function (mainFrame) {
@@ -316,9 +352,15 @@ export default async function (mainFrame) {
       document.addEventListener('scroll', onScroll, true);
     }
 
+    if (isTextFieldEl(document.activeElement)) {
+      onFocusIn({ target: document.activeElement });
+    }
+
     document.addEventListener('click', onClick, true);
     document.addEventListener('change', onChange, true);
+    document.addEventListener('focusin', onFocusIn, true);
     document.addEventListener('keydown', onKeydown, true);
+    document.addEventListener('focusout', onFocusOut, true);
   }
 
   return cleanUp;

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

@@ -153,7 +153,9 @@
       "needSignin": "You need to sign in first",
       "backup": {
         "button": "Backup",
-        "encrypt": "Encrypt with password"
+        "settings": "Backup settings",
+        "encrypt": "Encrypt with password",
+        "schedule": "Schedule local backup"
       },
       "restore": {
         "title": "Restore workflows",

+ 235 - 60
src/newtab/pages/settings/SettingsBackup.vue

@@ -80,9 +80,103 @@
         <ui-checkbox v-model="state.encrypt" class="mt-12 mb-4">
           {{ t('settings.backupWorkflows.backup.encrypt') }}
         </ui-checkbox>
-        <ui-button class="w-full" @click="backupWorkflows">
-          {{ t('settings.backupWorkflows.backup.button') }}
-        </ui-button>
+        <div class="flex items-center gap-2">
+          <ui-popover @close="registerScheduleBackup">
+            <template #trigger>
+              <ui-button
+                v-tooltip="t('settings.backupWorkflows.backup.settings')"
+                icon
+                :class="{ 'text-primary': localBackupSchedule.schedule }"
+              >
+                <v-remixicon name="riSettings3Line" />
+              </ui-button>
+            </template>
+            <div class="w-64">
+              <p class="mb-2 font-semibold">
+                {{ t('settings.backupWorkflows.backup.settings') }}
+              </p>
+              <p>Also backup</p>
+              <div class="flex mt-1 flex-col gap-2">
+                <ui-checkbox
+                  v-for="item in BACKUP_ITEMS_INCLUDES"
+                  :key="item.id"
+                  :model-value="
+                    localBackupSchedule.includedItems.includes(item.id)
+                  "
+                  @change="
+                    $event
+                      ? localBackupSchedule.includedItems.push(item.id)
+                      : localBackupSchedule.includedItems.splice(
+                          localBackupSchedule.includedItems.indexOf(item.id),
+                          1
+                        )
+                  "
+                >
+                  {{ item.name }}
+                </ui-checkbox>
+              </div>
+              <p class="mt-4">
+                {{ t('settings.backupWorkflows.backup.schedule') }}
+              </p>
+              <template v-if="!downloadPermission.has.downloads">
+                <p class="text-gray-600 dark:text-gray-300 mt-1">
+                  Automa requires the "Downloads" permission for the schedule
+                  backup to work
+                </p>
+                <ui-button
+                  class="mt-2 w-full"
+                  @click="downloadPermission.request()"
+                >
+                  Allow "Downloads" permission
+                </ui-button>
+              </template>
+              <template v-else>
+                <ui-select
+                  v-model="localBackupSchedule.schedule"
+                  class="w-full mt-2"
+                >
+                  <option value="">Never</option>
+                  <option
+                    v-for="(value, key) in BACKUP_SCHEDULES"
+                    :key="key"
+                    :value="key"
+                  >
+                    {{ value }}
+                  </option>
+                  <option value="custom">Custom</option>
+                </ui-select>
+                <template v-if="localBackupSchedule.schedule === 'custom'">
+                  <ui-input
+                    v-model="localBackupSchedule.customSchedule"
+                    label="Cron Expression"
+                    class="w-full mt-2"
+                    placeholder="0 8 * * *"
+                  />
+                  <p className="text-sm text-gray-600 dark:text-gray-300">
+                    {{ getBackupScheduleCron() }}
+                  </p>
+                </template>
+                <ui-input
+                  v-if="localBackupSchedule.schedule !== ''"
+                  v-model="localBackupSchedule.folderName"
+                  label="Folder name"
+                  class="w-full mt-2"
+                  placeholder="backup-folder"
+                />
+                <p
+                  v-if="localBackupSchedule.lastBackup"
+                  class="text-gray-600 dark:text-gray-300 text-sm mt-4"
+                >
+                  Last backup:
+                  {{ dayjs(localBackupSchedule.lastBackup).fromNow() }}
+                </p>
+              </template>
+            </div>
+          </ui-popover>
+          <ui-button class="flex-1" @click="backupWorkflows">
+            {{ t('settings.backupWorkflows.backup.button') }}
+          </ui-button>
+        </div>
       </div>
       <div class="w-6/12 rounded-lg border p-4 dark:border-gray-700">
         <div class="text-center">
@@ -111,26 +205,40 @@
   </ui-modal>
 </template>
 <script setup>
-import { reactive, onMounted } from 'vue';
+import { reactive, toRaw, onMounted } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useToast } from 'vue-toastification';
 import dayjs from 'dayjs';
 import AES from 'crypto-js/aes';
+import cronParser from 'cron-parser';
+import dbStorage from '@/db/storage';
 import encUtf8 from 'crypto-js/enc-utf8';
 import browser from 'webextension-polyfill';
 import hmacSHA256 from 'crypto-js/hmac-sha256';
 import { useDialog } from '@/composable/dialog';
+import { readableCron } from '@/lib/cronstrue';
 import { useUserStore } from '@/stores/user';
 import { getUserWorkflows } from '@/utils/api';
 import { useWorkflowStore } from '@/stores/workflow';
+import { useHasPermissions } from '@/composable/hasPermissions';
 import { fileSaver, openFilePicker, parseJSON } from '@/utils/helper';
 import SettingsCloudBackup from '@/components/newtab/settings/SettingsCloudBackup.vue';
 
+const BACKUP_SCHEDULES = {
+  '0 8 * * *': 'Every day',
+  '0 8 * * 0': 'Every week',
+};
+const BACKUP_ITEMS_INCLUDES = [
+  { id: 'storage:table', name: 'Storage tables' },
+  { id: 'storage:variables', name: 'Storage variables' },
+];
+
 const { t } = useI18n();
 const toast = useToast();
 const dialog = useDialog();
 const userStore = useUserStore();
 const workflowStore = useWorkflowStore();
+const downloadPermission = useHasPermissions(['downloads']);
 
 const state = reactive({
   lastSync: null,
@@ -144,7 +252,47 @@ const backupState = reactive({
   modal: false,
   loading: false,
 });
+const localBackupSchedule = reactive({
+  schedule: '',
+  lastBackup: null,
+  includedItems: [],
+  customSchedule: '',
+  folderName: 'automa-backup',
+});
+
+async function registerScheduleBackup() {
+  try {
+    if (!localBackupSchedule.schedule.trim()) {
+      await browser.alarms.clear('schedule-local-backup');
+    } else {
+      const expression =
+        localBackupSchedule.schedule === 'custom'
+          ? localBackupSchedule.customSchedule
+          : localBackupSchedule.schedule;
+      const parsedExpression = cronParser.parseExpression(expression).next();
+      if (!parsedExpression) return;
 
+      await browser.alarms.create('schedule-local-backup', {
+        when: parsedExpression.getTime(),
+      });
+    }
+
+    browser.storage.local.set({
+      localBackupSettings: toRaw(localBackupSchedule),
+    });
+  } catch (error) {
+    console.error(error);
+  }
+}
+function getBackupScheduleCron() {
+  try {
+    const expression = localBackupSchedule.customSchedule;
+
+    return `${readableCron(expression)}`;
+  } catch (error) {
+    return error.message;
+  }
+}
 function formatDate(date) {
   if (!date) return 'null';
 
@@ -173,56 +321,70 @@ async function syncBackupWorkflows() {
     state.loadingSync = false;
   }
 }
-function backupWorkflows() {
-  const workflows = workflowStore.getWorkflows.reduce((acc, workflow) => {
-    if (workflow.isProtected) return acc;
-
-    delete workflow.$id;
-    delete workflow.createdAt;
-    delete workflow.data;
-    delete workflow.isDisabled;
-    delete workflow.isProtected;
-
-    acc.push(workflow);
-
-    return acc;
-  }, []);
-  const payload = {
-    isProtected: state.encrypt,
-    workflows: JSON.stringify(workflows),
-  };
-  const downloadFile = (data) => {
-    const fileName = `automa-${dayjs().format('DD-MM-YYYY')}.json`;
-    const blob = new Blob([JSON.stringify(data)], {
-      type: 'application/json',
-    });
-    const objectUrl = URL.createObjectURL(blob);
-
-    fileSaver(fileName, objectUrl);
-
-    URL.revokeObjectURL(objectUrl);
-  };
-
-  if (state.encrypt) {
-    dialog.prompt({
-      placeholder: t('common.password'),
-      title: t('settings.backupWorkflows.title'),
-      okText: t('settings.backupWorkflows.backup.button'),
-      inputType: 'password',
-      onConfirm: (password) => {
-        const encryptedWorkflows = AES.encrypt(
-          payload.workflows,
-          password
-        ).toString();
-        const hmac = hmacSHA256(encryptedWorkflows, password).toString();
-
-        payload.workflows = hmac + encryptedWorkflows;
-
-        downloadFile(payload);
-      },
-    });
-  } else {
-    downloadFile(payload);
+async function backupWorkflows() {
+  try {
+    const workflows = workflowStore.getWorkflows.reduce((acc, workflow) => {
+      if (workflow.isProtected) return acc;
+
+      delete workflow.$id;
+      delete workflow.createdAt;
+      delete workflow.data;
+      delete workflow.isDisabled;
+      delete workflow.isProtected;
+
+      acc.push(workflow);
+
+      return acc;
+    }, []);
+    const payload = {
+      isProtected: state.encrypt,
+      workflows: JSON.stringify(workflows),
+    };
+
+    if (localBackupSchedule.includedItems.includes('storage:table')) {
+      const tables = await dbStorage.tablesItems.toArray();
+      payload.storageTables = JSON.stringify(tables);
+    }
+    if (localBackupSchedule.includedItems.includes('storage:variables')) {
+      const variables = await dbStorage.variables.toArray();
+      payload.storageVariables = JSON.stringify(variables);
+    }
+
+    const downloadFile = (data) => {
+      const fileName = `automa-${dayjs().format('DD-MM-YYYY')}.json`;
+      const blob = new Blob([JSON.stringify(data)], {
+        type: 'application/json',
+      });
+      const objectUrl = URL.createObjectURL(blob);
+
+      fileSaver(fileName, objectUrl);
+
+      URL.revokeObjectURL(objectUrl);
+    };
+
+    if (state.encrypt) {
+      dialog.prompt({
+        placeholder: t('common.password'),
+        title: t('settings.backupWorkflows.title'),
+        okText: t('settings.backupWorkflows.backup.button'),
+        inputType: 'password',
+        onConfirm: (password) => {
+          const encryptedWorkflows = AES.encrypt(
+            payload.workflows,
+            password
+          ).toString();
+          const hmac = hmacSHA256(encryptedWorkflows, password).toString();
+
+          payload.workflows = hmac + encryptedWorkflows;
+
+          downloadFile(payload);
+        },
+      });
+    } else {
+      downloadFile(payload);
+    }
+  } catch (error) {
+    console.error(error);
   }
 }
 async function restoreWorkflows() {
@@ -241,7 +403,7 @@ async function restoreWorkflows() {
       const showMessage = (event) => {
         toast(
           t('settings.backupWorkflows.workflowsAdded', {
-            count: event.workflows.length,
+            count: Object.values(event).length,
           })
         );
       };
@@ -255,9 +417,18 @@ async function restoreWorkflows() {
 
     reader.onload = ({ target }) => {
       const payload = parseJSON(target.result, null);
-
       if (!payload) return;
 
+      const storageTables = parseJSON(payload.storageTables, null);
+      if (Array.isArray(storageTables)) {
+        dbStorage.tablesItems.bulkPut(storageTables);
+      }
+
+      const storageVariables = parseJSON(payload.storageVariables, null);
+      if (Array.isArray(storageVariables)) {
+        dbStorage.variables.bulkPut(storageVariables);
+      }
+
       if (payload.isProtected) {
         dialog.prompt({
           placeholder: t('common.password'),
@@ -301,10 +472,14 @@ async function restoreWorkflows() {
 }
 
 onMounted(async () => {
-  const { lastBackup, lastSync } = await browser.storage.local.get([
-    'lastBackup',
-    'lastSync',
-  ]);
+  const { lastBackup, lastSync, localBackupSettings } =
+    await browser.storage.local.get([
+      'lastSync',
+      'lastBackup',
+      'localBackupSettings',
+    ]);
+
+  Object.assign(localBackupSchedule, localBackupSettings || {});
 
   state.lastSync = lastSync;
   state.lastBackup = lastBackup;

+ 14 - 6
src/workflowEngine/utils/javascriptBlockUtil.js

@@ -152,13 +152,21 @@ export function jsContentHandler($blockData, $preloadScripts, $automaScript) {
         }
 
         onNextBlock = ({ detail }) => {
-          cleanUp(detail || {});
+          cleanUp();
+          if (!detail) {
+            resolve({ columns: {}, variables: {} });
+            return;
+          }
+
+          const payload = {
+            insert: detail.insert,
+            data: detail.data?.$error
+              ? detail.data
+              : JSON.stringify(detail?.data ?? {}),
+          };
           resolve({
-            columns: {
-              insert: detail?.insert,
-              data: JSON.stringify(detail?.data ?? {}),
-            },
-            variables: detail?.refData?.variables,
+            columns: payload,
+            variables: detail.refData?.variables,
           });
         };
         onResetTimeout = () => {