Explorar el Código

feat: add backup workflow to cloud

Ahmad Kholid hace 3 años
padre
commit
66657f0459

+ 424 - 0
src/components/newtab/settings/SettingsCloudBackup.vue

@@ -0,0 +1,424 @@
+<template>
+  <div class="bg-white dark:bg-gray-800 rounded-lg py-4 w-full max-w-3xl">
+    <div class="px-4 flex items-center">
+      <div class="flex-1 leading-tight">
+        <h1 class="text-xl font-semibold">
+          {{ t('settings.backupWorkflows.cloud.title') }}
+        </h1>
+        <p>
+          {{
+            t(
+              `settings.backupWorkflows.cloud.${
+                state.activeTab === 'local' ? 'selectText' : 'storedWorkflows'
+              }`
+            )
+          }}
+        </p>
+      </div>
+      <ui-button @click="$emit('close')">
+        {{ t('common.cancel') }}
+      </ui-button>
+      <ui-button
+        v-if="state.activeTab === 'local'"
+        :loading="state.isBackingUp"
+        variant="accent"
+        class="ml-2"
+        @click="backupWorkflowsToCloud"
+      >
+        {{ t('settings.backupWorkflows.backup.button') }}
+      </ui-button>
+      <ui-button
+        v-else
+        :disabled="state.deleteIds.length <= 0"
+        :loading="state.isDeletingBackup"
+        class="ml-2"
+        variant="danger"
+        @click="deleteBackup(null)"
+      >
+        {{ t('settings.backupWorkflows.cloud.delete') }}
+        ({{ state.deleteIds.length }})
+      </ui-button>
+    </div>
+    <div class="flex items-center px-4 mt-6">
+      <ui-tabs
+        v-model="state.activeTab"
+        type="fill"
+        style="background-color: transparent; padding: 0"
+        @change="onTabChange"
+      >
+        <ui-tab v-for="type in ['local', 'cloud']" :key="type" :value="type">
+          {{ t(`settings.backupWorkflows.cloud.buttons.${type}`) }}
+        </ui-tab>
+      </ui-tabs>
+      <div class="flex-grow"></div>
+      <ui-input
+        v-model="state.query"
+        :placeholder="t('common.search')"
+        prepend-icon="riSearch2Line"
+      />
+    </div>
+    <ui-tab-panels
+      v-model="state.activeTab"
+      class="overflow-auto scroll p-1 mt-2 px-4"
+      style="height: calc(100vh - 14rem)"
+    >
+      <ui-tab-panel value="local" class="grid grid-cols-2 gap-2">
+        <div
+          v-for="workflow in workflows"
+          :key="workflow.id"
+          :class="{
+            'is-selected bg-box-transparent': state.backupIds.includes(
+              workflow.id
+            ),
+          }"
+          class="border rounded-lg select-workflow p-4 cursor-pointer leading-tight hoverable flex items-start relative transition"
+          @click="toggleSelectWorkflow(workflow.id)"
+        >
+          <ui-img
+            v-if="workflow.icon?.startsWith('http')"
+            :src="workflow.icon"
+            style="height: 24px; width: 24px"
+            alt="Can not display"
+          />
+          <v-remixicon v-else :name="workflow.icon" />
+          <div class="flex-1 ml-2 overflow-hidden">
+            <p class="text-overflow">{{ workflow.name }}</p>
+            <p class="text-gray-600 dark:text-gray-200 text-overflow">
+              {{ workflow.description }}
+            </p>
+          </div>
+          <span
+            class="hidden select-icon p-1 rounded-full bg-accent dark:text-black text-gray-100"
+          >
+            <v-remixicon name="riCheckboxCircleLine" size="20" />
+          </span>
+        </div>
+      </ui-tab-panel>
+      <ui-tab-panel value="cloud">
+        <div v-if="state.loadingBackup" class="text-center py-4 col-span-2">
+          <ui-spinner color="text-accent" />
+        </div>
+        <template v-else>
+          <ui-list class="space-y-1">
+            <ui-list-item
+              v-for="workflow in backupWorkflows"
+              :key="workflow.id"
+              :class="{
+                'bg-box-transparent': state.deleteIds.includes(workflow.id),
+              }"
+              class="overflow-hidden"
+            >
+              <ui-checkbox
+                :model-value="state.deleteIds.includes(workflow.id)"
+                class="mr-4"
+                @change="toggleDeleteWorkflow($event, workflow.id)"
+              />
+              <ui-img
+                v-if="workflow.icon?.startsWith('http')"
+                :src="workflow.icon"
+                style="height: 24px; width: 24px"
+                alt="Can not display"
+              />
+              <v-remixicon v-else :name="workflow.icon" />
+              <p class="text-overflow flex-1 ml-2">{{ workflow.name }}</p>
+              <p
+                :title="`Last updated: ${formatDate(
+                  workflow,
+                  'DD MMMM YYYY, hh:mm A'
+                )}`"
+                class="ml-4 mr-8"
+              >
+                {{ formatDate(workflow, 'DD MMM YYYY') }}
+              </p>
+              <button
+                v-if="!state.isDeletingBackup"
+                :aria-label="t('settings.backupWorkflows.cloud.delete')"
+                @click="deleteBackup(workflow.id)"
+              >
+                <v-remixicon name="riDeleteBin7Line" />
+              </button>
+            </ui-list-item>
+          </ui-list>
+        </template>
+      </ui-tab-panel>
+    </ui-tab-panels>
+    <div class="mt-2 flex items-center px-4">
+      <button
+        v-if="state.activeTab === 'local'"
+        class="mr-2 flex items-center"
+        @click="selectAll"
+      >
+        <v-remixicon name="riCheckboxCircleLine" />
+        <p class="ml-2">
+          {{
+            t(
+              `settings.backupWorkflows.cloud.${
+                state.backupIds.length >= 40 ? 'deselectAll' : 'selectAll'
+              }`
+            )
+          }}
+        </p>
+      </button>
+      <label v-else class="mr-2 flex items-center">
+        <ui-checkbox
+          :model-value="state.deleteIds.length >= 40"
+          @change="selectAllDelIds"
+        />
+        <p class="ml-2">
+          {{
+            t(
+              `settings.backupWorkflows.cloud.${
+                state.deleteIds.length >= 40 ? 'deselectAll' : 'selectAll'
+              }`
+            )
+          }}
+        </p>
+      </label>
+      <div class="flex-grow"></div>
+      <p>
+        {{
+          state.activeTab === 'local'
+            ? state.backupIds.length
+            : state.cloudWorkflows.length
+        }}/40 {{ t('common.workflow', 2) }}
+      </p>
+    </div>
+  </div>
+</template>
+<script setup>
+import { computed, reactive, watch } from 'vue';
+import { useStore } from 'vuex';
+import { useI18n } from 'vue-i18n';
+import { useToast } from 'vue-toastification';
+import browser from 'webextension-polyfill';
+import { fetchApi, cacheApi } from '@/utils/api';
+import { convertWorkflow } from '@/utils/workflow-data';
+import { parseJSON } from '@/utils/helper';
+import dayjs from '@/lib/dayjs';
+import Workflow from '@/models/workflow';
+
+defineEmits(['close']);
+
+const { t } = useI18n();
+const store = useStore();
+const toast = useToast();
+
+const state = reactive({
+  query: '',
+  deleteIds: [],
+  backupIds: [],
+  activeTab: 'local',
+  cloudWorkflows: [],
+  isBackingUp: false,
+  loadingBackup: false,
+  backupRetrieved: false,
+  isDeletingBackup: false,
+});
+
+const workflows = computed(() =>
+  Workflow.query()
+    .where(({ name }) =>
+      name.toLocaleLowerCase().includes(state.query.toLowerCase())
+    )
+    .orderBy('createdAt', 'desc')
+    .get()
+);
+const backupWorkflows = computed(() =>
+  state.cloudWorkflows.filter(({ name }) =>
+    name.toLocaleLowerCase().includes(state.query.toLowerCase())
+  )
+);
+
+function formatDate(workflow, format) {
+  return dayjs(workflow.updatedAt || Date.now()).format(format);
+}
+function toggleDeleteWorkflow(value, workflowId) {
+  if (value) {
+    state.deleteIds.push(workflowId);
+  } else {
+    const index = state.deleteIds.indexOf(workflowId);
+
+    if (index !== -1) state.deleteIds.splice(index, 1);
+  }
+}
+async function deleteBackup(workflowId) {
+  try {
+    state.isDeletingBackup = true;
+
+    const ids = workflowId ? [workflowId] : state.deleteIds;
+    const response = await fetchApi(
+      `/me/workflows?id=${ids.join(',')}&type=backup`,
+      {
+        method: 'DELETE',
+      }
+    );
+
+    if (!response.ok) throw new Error(response.statusText);
+
+    const { backupIds } = await browser.storage.local.get('backupIds');
+
+    ids.forEach((id) => {
+      const index = state.cloudWorkflows.findIndex((item) => item.id === id);
+      if (index !== -1) state.cloudWorkflows.splice(index, 1);
+
+      const backupIndex = backupIds.indexOf(id);
+      if (backupIndex !== -1) backupIds.splice(backupIndex, 1);
+    });
+
+    await browser.storage.local.set({ backupIds });
+
+    state.backupIds = backupIds;
+    state.isDeletingBackup = false;
+    sessionStorage.removeItem('backup-workflows');
+  } catch (error) {
+    console.error(error);
+    state.isDeletingBackup = false;
+    toast.error(t('message.somethingWrong'));
+    state.isBackingUp = false;
+  }
+}
+async function onTabChange(value) {
+  if (value !== 'cloud' || state.backupRetrieved || state.loadingBackup) return;
+
+  state.deleteIds = [];
+
+  try {
+    state.loadingBackup = true;
+    const data = await cacheApi('backup-workflows', async () => {
+      const response = await fetchApi('/me/workflows?type=backup');
+
+      if (!response.ok) throw new Error(response.statusText);
+
+      const result = await response.json();
+
+      return result;
+    });
+
+    state.cloudWorkflows = data;
+    state.loadingBackup = false;
+  } catch (error) {
+    console.error(error);
+    state.loadingBackup = false;
+  }
+}
+function toggleSelectWorkflow(workflowId) {
+  if (state.backupIds.length >= 40) return;
+
+  const index = state.backupIds.indexOf(workflowId);
+
+  if (index !== -1) state.backupIds.splice(index, 1);
+  else state.backupIds.push(workflowId);
+}
+function selectAllDelIds(value) {
+  if (value) {
+    state.deleteIds = state.cloudWorkflows.map(({ id }) => id);
+  } else {
+    state.deleteIds = [];
+  }
+}
+function selectAll() {
+  let limit = state.backupIds.length;
+
+  if (limit >= 40) {
+    state.backupIds = [];
+    return;
+  }
+
+  Workflow.query()
+    .orderBy('createdAt', 'desc')
+    .get()
+    .forEach(({ id }) => {
+      if (limit >= 40 || state.backupIds.includes(id)) return;
+
+      state.backupIds.push(id);
+
+      limit += 1;
+    });
+}
+async function backupWorkflowsToCloud() {
+  if (state.isBackingUp) return;
+
+  if (state.backupIds.length === 0) {
+    toast.error(t('settings.backupWorkflows.cloud.needSelectWorkflow'), {
+      timeout: 7000,
+    });
+
+    return;
+  }
+
+  try {
+    state.isBackingUp = true;
+
+    const workflowsPayload = state.backupIds.reduce((acc, id) => {
+      const findWorkflow = Workflow.find(id);
+
+      if (!findWorkflow) return acc;
+
+      const workflow = convertWorkflow(findWorkflow, [
+        'dataColumns',
+        'id',
+        '__id',
+      ]);
+      delete workflow.extVersion;
+
+      acc.push(workflow);
+
+      return acc;
+    }, []);
+
+    const response = await fetchApi('/me/workflows?type=backup', {
+      method: 'POST',
+      body: JSON.stringify({ workflows: workflowsPayload }),
+    });
+
+    if (!response.ok) {
+      throw new Error(response.statusText);
+    }
+
+    const { lastBackup, data, ids } = await response.json();
+
+    state.isBackingUp = false;
+    state.lastBackup = lastBackup;
+    state.lastSync = lastBackup;
+
+    const userWorkflows = parseJSON('user-workflows', {
+      backup: [],
+      hosted: {},
+    });
+    userWorkflows.backup = data;
+    sessionStorage.setItem('user-workflows', JSON.stringify(userWorkflows));
+
+    state.cloudWorkflows = ids.map((id) => Workflow.find(id));
+
+    await Workflow.insertOrUpdate({ data });
+    await browser.storage.local.set({
+      lastBackup,
+      lastSync: lastBackup,
+      backupIds: ids,
+    });
+
+    sessionStorage.removeItem('backup-workflows');
+    sessionStorage.removeItem('user-workflows');
+    sessionStorage.removeItem('cache-time:backup-workflows');
+  } catch (error) {
+    console.error(error);
+    toast.error(t('message.somethingWrong'));
+    state.isBackingUp = false;
+  }
+}
+
+watch(
+  () => store.state.userDataRetrieved,
+  async () => {
+    const { backupIds } = await browser.storage.local.get('backupIds');
+
+    state.backupIds = backupIds || [];
+  },
+  { immediate: true }
+);
+</script>
+<style scoped>
+.select-workflow.is-selected .select-icon {
+  display: block;
+}
+</style>

+ 2 - 1
src/components/ui/UiTabs.vue

@@ -37,7 +37,7 @@ const props = defineProps({
   small: Boolean,
   fill: Boolean,
 });
-const emit = defineEmits(['update:modelValue']);
+const emit = defineEmits(['update:modelValue', 'change']);
 
 const tabTypes = {
   default: 'border-b',
@@ -48,6 +48,7 @@ const hoverIndicator = ref(null);
 const showHoverIndicator = ref(false);
 
 function updateActive(id) {
+  emit('change', id);
   emit('update:modelValue', id);
 }
 function hoverHandler({ target }) {

+ 3 - 1
src/content/services/record-workflow.js

@@ -144,11 +144,13 @@ function clickListener(event) {
     }
   }
 
+  const elText = target.innerText || target.ariaLabel || target.title;
+
   addBlock({
     isClickLink,
     id: 'event-click',
     data: { selector },
-    description: target.innerText.slice(0, 64) || selector,
+    description: elText.slice(0, 64) || selector,
   });
 }
 

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

@@ -27,14 +27,16 @@
       "reloadPage": "Reload the page to take effect"
     },
     "menu": {
+      "backup": "Backup Workflows",
       "general": "General",
       "shortcuts": "Shortcuts",
       "about": "About"
     },
     "backupWorkflows": {
-      "title": "Backup Workflows",
+      "title": "Local Backup",
       "invalidPassword": "Invalid password",
       "workflowsAdded": "{count} workflows have been added",
+      "name": "Backup workflows",
       "backup": {
         "button": "Backup",
         "encrypt": "Encrypt with password"
@@ -43,6 +45,24 @@
         "title": "Restore workflows",
         "button": "Restore",
         "update": "Update if the workflow exists"
+      },
+      "cloud": {
+        "buttons": {
+          "local": "Local",
+          "cloud": "Cloud"
+        },
+        "delete": "Delete backup",
+        "title": "Cloud Backup",
+        "sync": "Sync",
+        "lastSync": "Last sync",
+        "lastBackup": "Last backup",
+        "select": "Select workflows",
+        "storedWorkflows": "Workflows that are stored in the cloud",
+        "selected": "Selected",
+        "selectText": "Select workflows that you want to backup",
+        "selectAll": "Select all",
+        "deselectAll": "Deselect all",
+        "needSelectWorkflow": "You need to select workflows that you want to backup"
       }
     }
   },

+ 17 - 15
src/models/workflow.js

@@ -1,5 +1,6 @@
 import { Model } from '@vuex-orm/core';
 import { nanoid } from 'nanoid';
+import browser from 'webextension-polyfill';
 import Log from './log';
 import { cleanWorkflowTriggers } from '@/utils/workflow-trigger';
 import { fetchApi } from '@/utils/api';
@@ -14,6 +15,7 @@ class Workflow extends Model {
 
   static fields() {
     return {
+      __id: this.attr(null),
       id: this.uid(() => nanoid()),
       name: this.string(''),
       icon: this.string('riGlobalLine'),
@@ -66,26 +68,26 @@ class Workflow extends Model {
   static async afterDelete({ id }) {
     try {
       await cleanWorkflowTriggers(id);
+      const hostedWorkflow = this.store().state.hostWorkflows[id];
+      const { backupIds } = await browser.storage.local.get('backupIds');
+      const isBackup = (backupIds || []).includes(id);
 
-      try {
-        const hostedWorkflow = this.store().state.hostWorkflows[id];
+      if (hostedWorkflow || isBackup) {
+        const response = await fetchApi(`/me/workflows?id=${id}`, {
+          method: 'DELETE',
+        });
 
-        if (hostedWorkflow) {
-          const response = await fetchApi(
-            `/me/workflows/host?id=${hostedWorkflow.hostId}`,
-            {
-              method: 'DELETE',
-            }
-          );
+        if (!response.ok) {
+          throw new Error(response.statusText);
+        }
 
-          if (response.status !== 200) {
-            throw new Error(response.statusText);
-          }
+        if (isBackup) {
+          backupIds.splice(backupIds.indexOf(id), 1);
+          await browser.storage.local.set({ backupIds });
         }
-      } catch (error) {
-        console.error(error);
+
+        await browser.storage.local.set({ clearCache: true });
       }
-      /* delete host workflow */
     } catch (error) {
       console.error(error);
     }

+ 64 - 12
src/newtab/App.vue

@@ -58,7 +58,8 @@ import { compare } from 'compare-versions';
 import browser from 'webextension-polyfill';
 import { useTheme } from '@/composable/theme';
 import { loadLocaleMessages, setI18nLanguage } from '@/lib/vue-i18n';
-import { fetchApi, getSharedWorkflows, getHostWorkflows } from '@/utils/api';
+import { fetchApi, getSharedWorkflows, getUserWorkflows } from '@/utils/api';
+import Workflow from '@/models/workflow';
 import AppSidebar from '@/components/newtab/app/AppSidebar.vue';
 
 const { t } = useI18n();
@@ -100,11 +101,28 @@ async function fetchUserData() {
     if (response.status !== 200) {
       throw new Error(response.statusText);
     }
-    if (!user) {
-      sessionStorage.removeItem('shared-workflows');
-      sessionStorage.removeItem('host-workflows');
 
-      return;
+    const username = localStorage.getItem('username');
+
+    if (!user || username !== user.username) {
+      sessionStorage.removeItem('shared-workflows');
+      sessionStorage.removeItem('user-workflows');
+      sessionStorage.removeItem('backup-workflows');
+
+      await browser.storage.local.remove([
+        'backupIds',
+        'lastBackup',
+        'lastSync',
+      ]);
+
+      if (username !== user.username) {
+        await Workflow.update({
+          where: ({ __id }) => __id !== null,
+          data: { __id: null },
+        });
+      }
+
+      if (!user) return;
     }
 
     store.commit('updateState', {
@@ -112,18 +130,52 @@ async function fetchUserData() {
       value: user,
     });
 
-    const mapPromises = { 0: 'sharedWorkflows', 1: 'hostWorkflows' };
-    const promises = await Promise.allSettled([
+    const [sharedWorkflows, userWorkflows] = await Promise.allSettled([
       getSharedWorkflows(),
-      getHostWorkflows(),
+      getUserWorkflows(),
     ]);
-    promises.forEach(({ status, value }, index) => {
-      if (status !== 'fulfilled') return;
+    localStorage.setItem('username', user.username);
+
+    if (sharedWorkflows.status === 'fulfilled') {
+      store.commit('updateState', {
+        key: 'sharedWorkflows',
+        value: sharedWorkflows.value,
+      });
+    }
+
+    if (userWorkflows.status === 'fulfilled') {
+      console.log(userWorkflows);
+      const { backup, hosted } = userWorkflows.value;
 
       store.commit('updateState', {
-        value,
-        key: mapPromises[index],
+        key: 'hostWorkflows',
+        value: hosted,
       });
+
+      if (backup.length > 0) {
+        const { lastBackup } = browser.storage.local.get('lastBackup');
+        if (!lastBackup) {
+          const backupIds = backup.map(({ id }) => id);
+
+          store.commit('updateState', {
+            key: 'backupIds',
+            value: backupIds,
+          });
+          await browser.storage.local.set({
+            backupIds,
+            lastBackup: new Date().toISOString(),
+          });
+        }
+
+        await Workflow.insertOrUpdate({
+          data: backup,
+        });
+      }
+    }
+
+    store.commit('updateState', {
+      key: 'userDataRetrieved',
+      value: true,
     });
   } catch (error) {
     console.error(error);

+ 1 - 0
src/newtab/pages/Settings.vue

@@ -38,6 +38,7 @@ const { t } = useI18n();
 
 const menus = [
   { id: 'general', path: '/settings', icon: 'riSettings3Line' },
+  { id: 'backup', path: '/backup', icon: 'riDatabase2Line' },
   { id: 'shortcuts', path: '/shortcuts', icon: 'riKeyboardLine' },
   { id: 'about', path: '/about', icon: 'riInformationLine' },
 ];

+ 125 - 3
src/components/newtab/settings/SettingsBackup.vue → src/newtab/pages/settings/Backup.vue

@@ -1,5 +1,59 @@
 <template>
-  <div class="max-w-xl mt-10">
+  <div class="max-w-xl">
+    <ui-card v-if="$store.state.user" class="mb-12">
+      <h2 class="font-semibold mb-2">
+        {{ t('settings.backupWorkflows.cloud.title') }}
+      </h2>
+      <template v-if="$store.state.user.subscription !== 'free'">
+        <div
+          class="border dark:border-gray-700 p-4 rounded-lg flex items-center"
+        >
+          <span class="inline-block p-2 rounded-full bg-box-transparent">
+            <v-remixicon name="riUploadLine" />
+          </span>
+          <div class="flex-1 ml-4 leading-tight">
+            <p class="text-sm text-gray-600 dark:text-gray-200">
+              {{ t('settings.backupWorkflows.cloud.lastBackup') }}
+            </p>
+            <p>{{ formatDate(state.lastBackup) }}</p>
+          </div>
+          <ui-button
+            :loading="backupState.loading"
+            @click="backupState.modal = true"
+          >
+            {{ t('settings.backupWorkflows.backup.button') }}
+          </ui-button>
+        </div>
+        <div
+          class="border dark:border-gray-700 p-4 rounded-lg flex items-center mt-2"
+        >
+          <span class="inline-block p-2 rounded-full bg-box-transparent">
+            <v-remixicon name="riDownloadLine" />
+          </span>
+          <p class="flex-1 ml-4">
+            {{ t('settings.backupWorkflows.cloud.sync') }}
+          </p>
+          <ui-button
+            :loading="state.loadingSync"
+            class="ml-2"
+            @click="syncBackupWorkflows"
+          >
+            {{ t('settings.backupWorkflows.cloud.sync') }}
+          </ui-button>
+        </div>
+      </template>
+      <p v-else>
+        Upgrade to the
+        <a
+          href="https://automa.site/pricing"
+          target="_blank"
+          class="dark:text-yellow-300 text-yellow-500 underline"
+        >
+          pro plan
+        </a>
+        to start back up your workflows to the cloud
+      </p>
+    </ui-card>
     <h2 class="font-semibold mb-2">
       {{ t('settings.backupWorkflows.title') }}
     </h2>
@@ -32,28 +86,81 @@
       </div>
     </div>
   </div>
+  <ui-modal
+    v-model="backupState.modal"
+    content-class="max-w-4xl"
+    persist
+    blur
+    custom-content
+  >
+    <settings-cloud-backup
+      v-model:ids="backupState.ids"
+      @close="backupState.modal = false"
+    />
+  </ui-modal>
 </template>
 <script setup>
-import { shallowReactive } from 'vue';
+import { reactive, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
+import { useStore } from 'vuex';
 import { useToast } from 'vue-toastification';
 import dayjs from 'dayjs';
 import AES from 'crypto-js/aes';
 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 { getUserWorkflows } from '@/utils/api';
 import { fileSaver, openFilePicker, parseJSON } from '@/utils/helper';
 import Workflow from '@/models/workflow';
+import SettingsCloudBackup from '@/components/newtab/settings/SettingsCloudBackup.vue';
 
 const { t } = useI18n();
+const store = useStore();
 const toast = useToast();
 const dialog = useDialog();
 
-const state = shallowReactive({
+const state = reactive({
+  lastSync: null,
   encrypt: false,
+  lastBackup: null,
+  loadingSync: false,
   updateIfExists: false,
 });
+const backupState = reactive({
+  ids: [],
+  modal: false,
+  loading: false,
+});
 
+function formatDate(date) {
+  if (!date) return 'null';
+
+  return dayjs(date).format('DD MMMM YYYY, hh:mm A');
+}
+async function syncBackupWorkflows() {
+  try {
+    state.loadingSync = true;
+    const { backup, hosted } = await getUserWorkflows(false);
+
+    store.commit('updateState', {
+      key: 'hostWorkflows',
+      value: hosted,
+    });
+    await browser.storage.local.set({
+      lastBackup: new Date().toISOString(),
+    });
+    await Workflow.insertOrUpdate({
+      data: backup,
+    });
+
+    state.loadingSync = false;
+  } catch (error) {
+    console.error(error);
+    toast.error(t('message.somethingWrong'));
+    state.loadingSync = false;
+  }
+}
 function backupWorkflows() {
   const workflows = Workflow.all().reduce((acc, workflow) => {
     if (workflow.isProtected) return acc;
@@ -197,4 +304,19 @@ async function restoreWorkflows() {
     toast.error(error.message);
   }
 }
+
+watch(
+  () => store.state.userDataRetrieved,
+  async () => {
+    const { lastBackup, lastSync } = await browser.storage.local.get([
+      'backupIds',
+      'lastBackup',
+      'lastSync',
+    ]);
+
+    state.lastSync = lastSync;
+    state.lastBackup = lastBackup;
+  },
+  { immediate: true }
+);
 </script>

+ 0 - 2
src/newtab/pages/settings/index.vue

@@ -40,7 +40,6 @@
       {{ t('settings.language.reloadPage') }}
     </p>
   </div>
-  <settings-backup />
 </template>
 <script setup>
 import { computed, ref } from 'vue';
@@ -48,7 +47,6 @@ import { useStore } from 'vuex';
 import { useI18n } from 'vue-i18n';
 import { useTheme } from '@/composable/theme';
 import { supportLocales } from '@/utils/shared';
-import SettingsBackup from '@/components/newtab/settings/SettingsBackup.vue';
 
 const { t } = useI18n();
 const store = useStore();

+ 52 - 17
src/newtab/pages/workflows/[id].vue

@@ -248,6 +248,7 @@ import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router';
 import { useI18n } from 'vue-i18n';
 import defu from 'defu';
 import AES from 'crypto-js/aes';
+import browser from 'webextension-polyfill';
 import emitter from '@/lib/mitt';
 import { useDialog } from '@/composable/dialog';
 import { useShortcut } from '@/composable/shortcut';
@@ -288,6 +289,10 @@ const shortcut = useShortcut('editor:toggle-sidebar', toggleSidebar);
 
 const editor = shallowRef(null);
 const activeTab = shallowRef('editor');
+const workflowPayload = reactive({
+  data: {},
+  isUpdating: false,
+});
 const state = reactive({
   blockData: {},
   modalName: '',
@@ -362,8 +367,6 @@ const workflowModals = {
   },
 };
 
-let hostWorkflowPayload = {};
-
 const hostWorkflow = computed(() => store.state.hostWorkflows[workflowId]);
 const sharedWorkflow = computed(() => store.state.sharedWorkflows[workflowId]);
 const localWorkflow = computed(() => Workflow.find(workflowId));
@@ -424,26 +427,52 @@ const executeWorkflow = throttle(() => {
 }, 300);
 
 async function updateHostedWorkflow() {
-  if (!workflowData.isHost || Object.keys(hostWorkflowPayload).length === 0)
+  if (!store.state.user || workflowPayload.isUpdating) return;
+
+  const { backupIds } = await browser.storage.local.get('backupIds');
+  const isBackup = (backupIds || []).includes(workflowId);
+  const isExists = Workflow.query().where('id', workflowId).exists();
+
+  if (
+    (!isBackup && !workflowData.isHost) ||
+    !isExists ||
+    Object.keys(workflowPayload.data).length === 0
+  )
     return;
 
+  workflowPayload.isUpdating = true;
+
   try {
-    if (hostWorkflowPayload.drawflow) {
-      hostWorkflowPayload.drawflow = parseJSON(
-        hostWorkflowPayload.drawflow,
+    if (workflowPayload.data.drawflow) {
+      workflowPayload.data.drawflow = parseJSON(
+        workflowPayload.data.drawflow,
         null
       );
     }
 
-    await fetchApi(`/me/workflows/host?id=${hostWorkflow.value.hostId}`, {
+    const response = await fetchApi(`/me/workflows?id=${workflowId}`, {
       method: 'PUT',
       keepalive: true,
       body: JSON.stringify({
-        workflow: hostWorkflowPayload,
+        workflow: workflowPayload.data,
       }),
     });
+
+    if (!response.ok) throw new Error(response.statusText);
+
+    if (isBackup) {
+      const result = await response.json();
+
+      if (result.updatedAt) {
+        await browser.storage.local.set({ lastBackup: result.updatedAt });
+      }
+    }
+
+    workflowPayload.data = {};
+    workflowPayload.isUpdating = false;
   } catch (error) {
     console.error(error);
+    workflowPayload.isUpdating = false;
   }
 }
 function unpublishSharedWorkflow() {
@@ -589,7 +618,7 @@ async function setAsHostWorkflow(isHost) {
   workflowData.loadingHost = true;
 
   try {
-    let url = '/me/workflows/host';
+    let url = '/me/workflows';
     let payload = {};
 
     if (isHost) {
@@ -597,21 +626,22 @@ async function setAsHostWorkflow(isHost) {
       workflowPaylod.drawflow = parseJSON(workflow.value.drawflow, null);
       delete workflowPaylod.extVersion;
 
+      url += `?type=host`;
       payload = {
         method: 'POST',
         body: JSON.stringify({
-          workflow: workflowPaylod,
+          workflows: workflowPaylod,
         }),
       };
     } else {
-      url += `?id=${hostWorkflow.value?.hostId}`;
+      url += `?id=${workflowId}&type=host`;
       payload.method = 'DELETE';
     }
 
     const response = await fetchApi(url, payload);
     const result = await response.json();
 
-    if (response.status !== 200) {
+    if (!response.ok) {
       const error = new Error(response.statusText);
       error.data = result.data;
 
@@ -627,10 +657,12 @@ async function setAsHostWorkflow(isHost) {
       store.commit('deleteStateNested', `hostWorkflows.${workflowId}`);
     }
 
-    sessionStorage.setItem(
-      'host-workflows',
-      JSON.stringify(store.state.hostWorkflows)
-    );
+    const userWorkflows = parseJSON('user-workflows', {
+      backup: [],
+      hosted: {},
+    });
+    userWorkflows.hosted = store.state.hostWorkflows;
+    sessionStorage.setItem('user-workflows', JSON.stringify(userWorkflows));
 
     workflowData.isHost = isHost;
     workflowData.loadingHost = false;
@@ -723,7 +755,7 @@ function updateWorkflow(data) {
     delete data.isDisabled;
     delete data.isProtected;
 
-    hostWorkflowPayload = { ...hostWorkflowPayload, ...data };
+    workflowPayload.data = { ...workflowPayload.data, ...data };
 
     return event;
   });
@@ -801,6 +833,9 @@ provide('workflow', {
   },
 });
 
+watch(() => workflowPayload.data, throttle(updateHostedWorkflow, 5000), {
+  deep: true,
+});
 watch(
   () => workflowData.active,
   (value) => {

+ 2 - 0
src/newtab/router.js

@@ -12,6 +12,7 @@ import Settings from './pages/Settings.vue';
 import SettingsIndex from './pages/settings/index.vue';
 import SettingsAbout from './pages/settings/About.vue';
 import SettingsShortcuts from './pages/settings/Shortcuts.vue';
+import SettingsBackup from './pages/settings/Backup.vue';
 
 const routes = [
   {
@@ -65,6 +66,7 @@ const routes = [
     children: [
       { path: '', component: SettingsIndex },
       { path: '/about', component: SettingsAbout },
+      { path: '/backup', component: SettingsBackup },
       { path: '/shortcuts', component: SettingsShortcuts },
     ],
   },

+ 3 - 2
src/store/index.js

@@ -13,6 +13,7 @@ const store = createStore({
   state: () => ({
     user: null,
     workflowState: [],
+    backupIds: [],
     contributors: null,
     hostWorkflows: {},
     sharedWorkflows: {},
@@ -137,8 +138,8 @@ const store = createStore({
         method: 'POST',
         body: JSON.stringify({ hosts }),
       });
-
-      if (response.status !== 200) throw new Error(response.statusText);
+      console.log(response);
+      if (!response.ok) throw new Error(response.statusText);
 
       const result = await response.json();
       const newValue = JSON.parse(JSON.stringify(state.workflowHosts));

+ 84 - 43
src/utils/api.js

@@ -1,4 +1,5 @@
 import secrets from 'secrets';
+import browser from 'webextension-polyfill';
 import { parseJSON } from './helper';
 
 export function fetchApi(path, options) {
@@ -28,65 +29,105 @@ export const googleSheets = {
   },
 };
 
-async function cacheApi(key, callback) {
+export async function cacheApi(key, callback, useCache = true) {
+  const halfAnHour = 1000 * 60 * 15;
+  const halfAnHourAgo = Date.now() - halfAnHour;
+
+  const timerKey = `cache-time:${key}`;
   const cacheResult = parseJSON(sessionStorage.getItem(key), null);
+  const cacheTime = +sessionStorage.getItem(timerKey) || Date.now();
 
-  if (cacheResult && Object.keys(cacheResult).length > 0) {
+  if (useCache && cacheResult && halfAnHourAgo < cacheTime) {
     return cacheResult;
   }
 
   const result = await callback();
-  sessionStorage.setItem(key, JSON.stringify(result));
+  let cacheData = result;
+
+  if (result?.cacheData) {
+    cacheData = result?.cacheData;
+  }
+
+  sessionStorage.setItem(timerKey, Date.now());
+  sessionStorage.setItem(key, JSON.stringify(cacheData));
 
   return result;
 }
 
-export async function getSharedWorkflows() {
-  return cacheApi('shared-workflows', async () => {
-    try {
-      const response = await fetchApi('/me/workflows/shared?data=all');
+export async function getSharedWorkflows(useCache = true) {
+  return cacheApi(
+    'shared-workflows',
+    async () => {
+      try {
+        const response = await fetchApi('/me/workflows/shared?data=all');
 
-      if (response.status !== 200) throw new Error(response.statusText);
+        if (response.status !== 200) throw new Error(response.statusText);
 
-      const result = await response.json();
-      const sharedWorkflows = result.reduce((acc, item) => {
-        item.drawflow = JSON.stringify(item.drawflow);
-        item.table = item.table || item.dataColumns || [];
-        item.createdAt = new Date(item.createdAt || Date.now()).getTime();
+        const result = await response.json();
+        const sharedWorkflows = result.reduce((acc, item) => {
+          item.drawflow = JSON.stringify(item.drawflow);
+          item.table = item.table || item.dataColumns || [];
+          item.createdAt = new Date(item.createdAt || Date.now()).getTime();
 
-        acc[item.id] = item;
+          acc[item.id] = item;
 
-        return acc;
-      }, {});
+          return acc;
+        }, {});
 
-      return sharedWorkflows;
-    } catch (error) {
-      console.error(error);
+        return sharedWorkflows;
+      } catch (error) {
+        console.error(error);
 
-      return {};
-    }
-  });
+        return {};
+      }
+    },
+    useCache
+  );
 }
 
-export async function getHostWorkflows() {
-  return cacheApi('host-workflows', async () => {
-    try {
-      const response = await fetchApi('/me/workflows/host');
-
-      if (response.status !== 200) throw new Error(response.statusText);
-
-      const result = await response.json();
-      const hostWorkflows = result.reduce((acc, item) => {
-        acc[item.id] = item;
-
-        return acc;
-      }, {});
-
-      return hostWorkflows || {};
-    } catch (error) {
-      console.error(error);
-
-      return {};
-    }
-  });
+export async function getUserWorkflows(useCache = true) {
+  return cacheApi(
+    'user-workflows',
+    async () => {
+      try {
+        const { lastBackup } = await browser.storage.local.get('lastBackup');
+        const response = await fetchApi(
+          `/me/workflows?lastBackup=${(useCache && lastBackup) || null}`
+        );
+
+        if (!response.ok) throw new Error(response.statusText);
+
+        const result = await response.json();
+        const workflows = result.reduce(
+          (acc, workflow) => {
+            if (workflow.isHost) {
+              acc.hosted[workflow.id] = {
+                id: workflow.id,
+                hostId: workflow.hostId,
+              };
+            }
+
+            if (workflow.isBackup) {
+              acc.backup.push(workflow);
+            }
+
+            return acc;
+          },
+          { hosted: {}, backup: [] }
+        );
+
+        workflows.cacheData = {
+          backup: [],
+          hosted: workflows.hosted,
+        };
+
+        return workflows;
+      } catch (error) {
+        console.error(error);
+
+        return {};
+      }
+    },
+    useCache
+  );
 }

+ 3 - 3
src/utils/webhookUtil.js

@@ -45,7 +45,7 @@ export async function executeWebhook({
   method,
 }) {
   const controller = new AbortController();
-  const controllerId = setTimeout(() => {
+  const timeoutId = setTimeout(() => {
     controller.abort();
   }, timeout);
 
@@ -68,11 +68,11 @@ export async function executeWebhook({
 
     const response = await fetch(url, payload);
 
-    clearTimeout(controllerId);
+    clearTimeout(timeoutId);
 
     return response;
   } catch (error) {
-    clearTimeout(controllerId);
+    clearTimeout(timeoutId);
     throw error;
   }
 }