|
@@ -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;
|