diff --git a/apps/web/src/app-effects.tsx b/apps/web/src/app-effects.tsx index f80141f53..82a02d548 100644 --- a/apps/web/src/app-effects.tsx +++ b/apps/web/src/app-effects.tsx @@ -22,7 +22,11 @@ import { useStore } from "./stores/app-store"; import { useStore as useUserStore } from "./stores/user-store"; import { useEditorStore } from "./stores/editor-store"; import { useStore as useAnnouncementStore } from "./stores/announcement-store"; -import { resetNotices, scheduleBackups } from "./common/notices"; +import { + resetNotices, + scheduleBackups, + scheduleFullBackups +} from "./common/notices"; import { introduceFeatures, showUpgradeReminderDialogs } from "./common"; import { AppEventManager, AppEvents } from "./common/app-events"; import { db } from "./common/db"; @@ -90,6 +94,7 @@ export default function AppEffects({ setShow }: AppEffectsProps) { if (onboardingKey) await OnboardingDialog.show({ type: onboardingKey }); await FeatureDialog.show({ featureName: "highlights" }); await scheduleBackups(); + await scheduleFullBackups(); })(); return () => { diff --git a/apps/web/src/common/index.ts b/apps/web/src/common/index.ts index 1aa75ce39..88f80327d 100644 --- a/apps/web/src/common/index.ts +++ b/apps/web/src/common/index.ts @@ -82,9 +82,10 @@ export async function createBackup( options: { rescueMode?: boolean; noVerify?: boolean; - } = {} + mode?: "full" | "partial"; + } = { mode: "partial" } ) { - const { rescueMode, noVerify } = options; + const { rescueMode, noVerify, mode } = options; const { isLoggedIn } = useUserStore.getState(); const { encryptBackups, toggleEncryptBackups } = useSettingStore.getState(); if (!isLoggedIn && encryptBackups) toggleEncryptBackups(); @@ -103,7 +104,7 @@ export async function createBackup( type: "date-time", dateFormat: "YYYY-MM-DD", timeFormat: "24-hour" - })}-${new Date().getSeconds()}`, + })}-${new Date().getSeconds()}${mode === "full" ? "-full" : ""}`, { replacement: "-" } ); const directory = Config.get("backupStorageLocation", PATHS.backupsDirectory); @@ -123,10 +124,11 @@ export async function createBackup( start() {}, async pull(controller) { const { streamablefs } = await import("../interfaces/fs"); - for await (const output of db.backup!.export( - "web", - encryptedBackups - )) { + for await (const output of db.backup!.export({ + type: "web", + encrypt: encryptedBackups, + mode + })) { if (output.type === "file") { const file = output; report({ diff --git a/apps/web/src/common/notices.ts b/apps/web/src/common/notices.ts index d79f70ff1..57e1c2639 100644 --- a/apps/web/src/common/notices.ts +++ b/apps/web/src/common/notices.ts @@ -38,12 +38,18 @@ export type Notice = { params?: any; }; -export const BACKUP_CRON_EXPRESSIONS = [ - "", - "0 0 8 * * *", // daily at 8 AM - "0 0 8 * * 1", // Every monday at 8 AM - "0 0 0 1 * *" // 1st day of every month -]; +export const BACKUP_CRON_EXPRESSIONS = { + 0: "", + 1: "0 0 8 * * *", // daily at 8 AM + 2: "0 0 8 * * 1", // Every monday at 8 AM + 3: "0 0 0 1 * *" // 1st day of every month +}; + +export const FULL_BACKUP_CRON_EXPRESSIONS = { + 0: "", + 1: "0 0 8 * * 1", // Every monday at 8 AM + 2: "0 0 0 1 * *" // 1st day of every month +}; export async function scheduleBackups() { const backupInterval = Config.get("backupReminderOffset", 0); @@ -62,6 +68,23 @@ export async function scheduleBackups() { ); } +export async function scheduleFullBackups() { + const backupInterval = Config.get("fullBackupReminderOffset", 0); + + await TaskScheduler.stop("automatic-full-backups"); + if (!backupInterval) return false; + + console.log("Scheduling automatic full backups"); + await TaskScheduler.register( + "automatic-full-backups", + FULL_BACKUP_CRON_EXPRESSIONS[backupInterval], + () => { + console.log("Backing up automatically"); + saveBackup("full"); + } + ); +} + export function shouldAddAutoBackupsDisabledNotice() { const backupInterval = Config.get("backupReminderOffset", 0); if (!isUserPremium() && backupInterval) { @@ -175,9 +198,9 @@ function isIgnored(key: keyof typeof NoticesData) { } let openedToast: { hide: () => void } | null = null; -async function saveBackup() { +async function saveBackup(mode: "full" | "partial" = "partial") { if (IS_DESKTOP_APP) { - await createBackup({ noVerify: true }); + await createBackup({ noVerify: true, mode }); } else if (isUserPremium() && !IS_TESTING) { if (openedToast !== null) return; openedToast = showToast( @@ -196,7 +219,7 @@ async function saveBackup() { { text: "Download", onClick: async () => { - await createBackup(); + await createBackup({ mode }); openedToast?.hide(); openedToast = null; }, diff --git a/apps/web/src/dialogs/settings/backup-export-settings.ts b/apps/web/src/dialogs/settings/backup-export-settings.ts index 0d48c340c..2e1bd2fcc 100644 --- a/apps/web/src/dialogs/settings/backup-export-settings.ts +++ b/apps/web/src/dialogs/settings/backup-export-settings.ts @@ -37,14 +37,24 @@ export const BackupExportSettings: SettingsGroup[] = [ settings: [ { key: "create-backup", - title: "Backup now", - description: "Create a backup file containing all your data", + title: "Create backup", + description: + "Partial backups contain all your data except attachments. They are created from data already available on your device and do not require an Internet connection.", components: [ { - type: "button", - title: "Create backup", - action: createBackup, - variant: "secondary" + type: "dropdown", + options: [ + { value: "-", title: "Choose backup format" }, + { value: "partial", title: "Backup" }, + { value: "full", title: "Backup with attachments" } + ], + selectedOption: () => "-", + onSelectionChanged: async (value) => { + if (value === "-") return; + await createBackup({ + mode: value === "partial" ? "partial" : "full" + }); + } } ] }, @@ -67,10 +77,10 @@ export const BackupExportSettings: SettingsGroup[] = [ }, { key: "auto-backup", - title: IS_DESKTOP_APP ? "Automatic backups" : "Backup reminders", - description: IS_DESKTOP_APP - ? "Backup all your data automatically at a set interval." - : "You will be shown regular reminders to backup your data.", + title: "Automatic backups", + description: `Set the interval to create a backup automatically. + +Note: these backups do not contain attachments.`, isHidden: () => !isUserPremium(), onStateChange: (listener) => useSettingStore.subscribe((s) => s.backupReminderOffset, listener), @@ -97,6 +107,40 @@ export const BackupExportSettings: SettingsGroup[] = [ } ] }, + { + key: "auto-backup-with-attachments", + title: "Automatic backup with attachments", + description: `Set the interval to create a backup (with attachments) automatically. + +NOTE: Creating a backup with attachments can take a while, and also fail completely. The app will try to resume/restart the backup in case of interruptions.`, + isHidden: () => !isUserPremium(), + onStateChange: (listener) => + useSettingStore.subscribe( + (s) => s.fullBackupReminderOffset, + listener + ), + components: [ + { + type: "dropdown", + options: [ + { value: "0", title: "Never" }, + { value: "1", title: "Weekly" }, + { value: "2", title: "Monthly" } + ], + selectedOption: () => + useSettingStore.getState().fullBackupReminderOffset.toString(), + onSelectionChanged: async (value) => { + const verified = + useSettingStore.getState().encryptBackups || + (await verifyAccount()); + if (verified) + useSettingStore + .getState() + .setFullBackupReminderOffset(parseInt(value)); + } + } + ] + }, { key: "encrypt-backups", title: "Backup encryption", diff --git a/apps/web/src/stores/setting-store.ts b/apps/web/src/stores/setting-store.ts index 4394baccd..8af1ac842 100644 --- a/apps/web/src/stores/setting-store.ts +++ b/apps/web/src/stores/setting-store.ts @@ -32,6 +32,7 @@ import { Profile, TrashCleanupInterval } from "@notesnook/core"; class SettingStore extends BaseStore { encryptBackups = Config.get("encryptBackups", false); backupReminderOffset = Config.get("backupReminderOffset", 0); + fullBackupReminderOffset = Config.get("fullBackupReminderOffset", 0); backupStorageLocation = Config.get( "backupStorageLocation", PATHS.backupsDirectory @@ -149,6 +150,11 @@ class SettingStore extends BaseStore { this.set({ backupReminderOffset: offset }); }; + setFullBackupReminderOffset = (offset: number) => { + Config.set("fullBackupReminderOffset", offset); + this.set({ fullBackupReminderOffset: offset }); + }; + setBackupStorageLocation = (location: string) => { Config.set("backupStorageLocation", location); this.set({ backupStorageLocation: location }); diff --git a/apps/web/src/views/recovery.tsx b/apps/web/src/views/recovery.tsx index 341e91234..f4d16039b 100644 --- a/apps/web/src/views/recovery.tsx +++ b/apps/web/src/views/recovery.tsx @@ -455,7 +455,7 @@ function BackupData(props: BaseRecoveryComponentProps<"backup">) { "Please wait while we create a backup file for you to download." }} onSubmit={async () => { - await createBackup({ rescueMode: true }); + await createBackup({ rescueMode: true, mode: "full" }); navigate("new"); }} > diff --git a/packages/core/src/database/backup.ts b/packages/core/src/database/backup.ts index 62bda97ed..e15acd032 100644 --- a/packages/core/src/database/backup.ts +++ b/packages/core/src/database/backup.ts @@ -277,10 +277,11 @@ export default class Backup { await this.updateBackupTime(); } - async *export( - type: BackupPlatform, - encrypt = false - ): AsyncGenerator< + async *export(options: { + type: BackupPlatform; + encrypt?: boolean; + mode?: "full" | "partial"; + }): AsyncGenerator< | { type: "file"; path: string; @@ -296,6 +297,7 @@ export default class Backup { void, unknown > { + const { encrypt = false, type, mode = "partial" } = options; if (this.db.migrations.version === 5.9) { yield* this.exportLegacy(type, encrypt); return; @@ -343,6 +345,10 @@ export default class Backup { yield* this.backupCollection(this.db.vaults.collection, backupState); if (backupState.buffer.length > 0) yield* this.bufferToFile(backupState); + if (mode === "partial") { + await this.updateBackupTime(); + return; + } const total = await this.db.attachments.all.count(); if (total > 0 && user && user.attachmentsKey) {