diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 66475c50fa..6dd3849786 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -165,6 +165,11 @@ export enum IpcChannel { Backup_CheckConnection = 'backup:checkConnection', Backup_CreateDirectory = 'backup:createDirectory', Backup_DeleteWebdavFile = 'backup:deleteWebdavFile', + Backup_BackupToLocalDir = 'backup:backupToLocalDir', + Backup_RestoreFromLocalBackup = 'backup:restoreFromLocalBackup', + Backup_ListLocalBackupFiles = 'backup:listLocalBackupFiles', + Backup_DeleteLocalBackupFile = 'backup:deleteLocalBackupFile', + Backup_SetLocalBackupDir = 'backup:setLocalBackupDir', Backup_BackupToS3 = 'backup:backupToS3', Backup_RestoreFromS3 = 'backup:restoreFromS3', Backup_ListS3Files = 'backup:listS3Files', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index f0cbb44549..c3ca33b0f4 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -365,6 +365,11 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.Backup_CheckConnection, backupManager.checkConnection) ipcMain.handle(IpcChannel.Backup_CreateDirectory, backupManager.createDirectory) ipcMain.handle(IpcChannel.Backup_DeleteWebdavFile, backupManager.deleteWebdavFile) + ipcMain.handle(IpcChannel.Backup_BackupToLocalDir, backupManager.backupToLocalDir) + ipcMain.handle(IpcChannel.Backup_RestoreFromLocalBackup, backupManager.restoreFromLocalBackup) + ipcMain.handle(IpcChannel.Backup_ListLocalBackupFiles, backupManager.listLocalBackupFiles) + ipcMain.handle(IpcChannel.Backup_DeleteLocalBackupFile, backupManager.deleteLocalBackupFile) + ipcMain.handle(IpcChannel.Backup_SetLocalBackupDir, backupManager.setLocalBackupDir) ipcMain.handle(IpcChannel.Backup_BackupToS3, backupManager.backupToS3) ipcMain.handle(IpcChannel.Backup_RestoreFromS3, backupManager.restoreFromS3) ipcMain.handle(IpcChannel.Backup_ListS3Files, backupManager.listS3Files) diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index 576f004188..6087cb6a2a 100644 --- a/src/main/services/BackupManager.ts +++ b/src/main/services/BackupManager.ts @@ -27,6 +27,11 @@ class BackupManager { this.restoreFromWebdav = this.restoreFromWebdav.bind(this) this.listWebdavFiles = this.listWebdavFiles.bind(this) this.deleteWebdavFile = this.deleteWebdavFile.bind(this) + this.listLocalBackupFiles = this.listLocalBackupFiles.bind(this) + this.deleteLocalBackupFile = this.deleteLocalBackupFile.bind(this) + this.backupToLocalDir = this.backupToLocalDir.bind(this) + this.restoreFromLocalBackup = this.restoreFromLocalBackup.bind(this) + this.setLocalBackupDir = this.setLocalBackupDir.bind(this) this.backupToS3 = this.backupToS3.bind(this) this.restoreFromS3 = this.restoreFromS3.bind(this) this.listS3Files = this.listS3Files.bind(this) @@ -477,6 +482,28 @@ class BackupManager { } } + async backupToLocalDir( + _: Electron.IpcMainInvokeEvent, + data: string, + fileName: string, + localConfig: { + localBackupDir: string + skipBackupFile: boolean + } + ) { + try { + const backupDir = localConfig.localBackupDir + // Create backup directory if it doesn't exist + await fs.ensureDir(backupDir) + + const backupedFilePath = await this.backup(_, fileName, data, backupDir, localConfig.skipBackupFile) + return backupedFilePath + } catch (error) { + Logger.error('[BackupManager] Local backup failed:', error) + throw error + } + } + async backupToS3(_: Electron.IpcMainInvokeEvent, data: string, s3Config: S3Config) { const os = require('os') const deviceName = os.hostname ? os.hostname() : 'device' @@ -504,6 +531,75 @@ class BackupManager { } } + async restoreFromLocalBackup(_: Electron.IpcMainInvokeEvent, fileName: string, localBackupDir: string) { + try { + const backupDir = localBackupDir + const backupPath = path.join(backupDir, fileName) + + if (!fs.existsSync(backupPath)) { + throw new Error(`Backup file not found: ${backupPath}`) + } + + return await this.restore(_, backupPath) + } catch (error) { + Logger.error('[BackupManager] Local restore failed:', error) + throw error + } + } + + async listLocalBackupFiles(_: Electron.IpcMainInvokeEvent, localBackupDir: string) { + try { + const files = await fs.readdir(localBackupDir) + const result: Array<{ fileName: string; modifiedTime: string; size: number }> = [] + + for (const file of files) { + const filePath = path.join(localBackupDir, file) + const stat = await fs.stat(filePath) + + if (stat.isFile() && file.endsWith('.zip')) { + result.push({ + fileName: file, + modifiedTime: stat.mtime.toISOString(), + size: stat.size + }) + } + } + + // Sort by modified time, newest first + return result.sort((a, b) => new Date(b.modifiedTime).getTime() - new Date(a.modifiedTime).getTime()) + } catch (error) { + Logger.error('[BackupManager] List local backup files failed:', error) + throw error + } + } + + async deleteLocalBackupFile(_: Electron.IpcMainInvokeEvent, fileName: string, localBackupDir: string) { + try { + const filePath = path.join(localBackupDir, fileName) + + if (!fs.existsSync(filePath)) { + throw new Error(`Backup file not found: ${filePath}`) + } + + await fs.remove(filePath) + return true + } catch (error) { + Logger.error('[BackupManager] Delete local backup file failed:', error) + throw error + } + } + + async setLocalBackupDir(_: Electron.IpcMainInvokeEvent, dirPath: string) { + try { + // Check if directory exists + await fs.ensureDir(dirPath) + return true + } catch (error) { + Logger.error('[BackupManager] Set local backup directory failed:', error) + throw error + } + } + async restoreFromS3(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) { const filename = s3Config.fileName || 'cherry-studio.backup.zip' diff --git a/src/preload/index.ts b/src/preload/index.ts index ea081645b2..91e3e02aee 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -88,6 +88,18 @@ const api = { ipcRenderer.invoke(IpcChannel.Backup_CreateDirectory, webdavConfig, path, options), deleteWebdavFile: (fileName: string, webdavConfig: WebDavConfig) => ipcRenderer.invoke(IpcChannel.Backup_DeleteWebdavFile, fileName, webdavConfig), + backupToLocalDir: ( + data: string, + fileName: string, + localConfig: { localBackupDir?: string; skipBackupFile?: boolean } + ) => ipcRenderer.invoke(IpcChannel.Backup_BackupToLocalDir, data, fileName, localConfig), + restoreFromLocalBackup: (fileName: string, localBackupDir?: string) => + ipcRenderer.invoke(IpcChannel.Backup_RestoreFromLocalBackup, fileName, localBackupDir), + listLocalBackupFiles: (localBackupDir?: string) => + ipcRenderer.invoke(IpcChannel.Backup_ListLocalBackupFiles, localBackupDir), + deleteLocalBackupFile: (fileName: string, localBackupDir?: string) => + ipcRenderer.invoke(IpcChannel.Backup_DeleteLocalBackupFile, fileName, localBackupDir), + setLocalBackupDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.Backup_SetLocalBackupDir, dirPath), checkWebdavConnection: (webdavConfig: WebDavConfig) => ipcRenderer.invoke(IpcChannel.Backup_CheckConnection, webdavConfig), diff --git a/src/renderer/src/components/LocalBackupManager.tsx b/src/renderer/src/components/LocalBackupManager.tsx new file mode 100644 index 0000000000..2b4f949b38 --- /dev/null +++ b/src/renderer/src/components/LocalBackupManager.tsx @@ -0,0 +1,255 @@ +import { DeleteOutlined, ExclamationCircleOutlined, ReloadOutlined } from '@ant-design/icons' +import { restoreFromLocalBackup } from '@renderer/services/BackupService' +import { formatFileSize } from '@renderer/utils' +import { Button, message, Modal, Table, Tooltip } from 'antd' +import dayjs from 'dayjs' +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +interface BackupFile { + fileName: string + modifiedTime: string + size: number +} + +interface LocalBackupManagerProps { + visible: boolean + onClose: () => void + localBackupDir?: string + restoreMethod?: (fileName: string) => Promise +} + +export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMethod }: LocalBackupManagerProps) { + const { t } = useTranslation() + const [backupFiles, setBackupFiles] = useState([]) + const [loading, setLoading] = useState(false) + const [selectedRowKeys, setSelectedRowKeys] = useState([]) + const [deleting, setDeleting] = useState(false) + const [restoring, setRestoring] = useState(false) + const [pagination, setPagination] = useState({ + current: 1, + pageSize: 5, + total: 0 + }) + + const fetchBackupFiles = useCallback(async () => { + if (!localBackupDir) { + return + } + + setLoading(true) + try { + const files = await window.api.backup.listLocalBackupFiles(localBackupDir) + setBackupFiles(files) + setPagination((prev) => ({ + ...prev, + total: files.length + })) + } catch (error: any) { + message.error(`${t('settings.data.local.backup.manager.fetch.error')}: ${error.message}`) + } finally { + setLoading(false) + } + }, [localBackupDir, t]) + + useEffect(() => { + if (visible) { + fetchBackupFiles() + setSelectedRowKeys([]) + setPagination((prev) => ({ + ...prev, + current: 1 + })) + } + }, [visible, fetchBackupFiles]) + + const handleTableChange = (pagination: any) => { + setPagination(pagination) + } + + const handleDeleteSelected = async () => { + if (selectedRowKeys.length === 0) { + message.warning(t('settings.data.local.backup.manager.select.files.delete')) + return + } + + if (!localBackupDir) { + return + } + + window.modal.confirm({ + title: t('settings.data.local.backup.manager.delete.confirm.title'), + icon: , + content: t('settings.data.local.backup.manager.delete.confirm.multiple', { count: selectedRowKeys.length }), + okText: t('common.confirm'), + cancelText: t('common.cancel'), + centered: true, + onOk: async () => { + setDeleting(true) + try { + // Delete selected files one by one + for (const key of selectedRowKeys) { + await window.api.backup.deleteLocalBackupFile(key.toString(), localBackupDir) + } + message.success( + t('settings.data.local.backup.manager.delete.success.multiple', { count: selectedRowKeys.length }) + ) + setSelectedRowKeys([]) + await fetchBackupFiles() + } catch (error: any) { + message.error(`${t('settings.data.local.backup.manager.delete.error')}: ${error.message}`) + } finally { + setDeleting(false) + } + } + }) + } + + const handleDeleteSingle = async (fileName: string) => { + if (!localBackupDir) { + return + } + + window.modal.confirm({ + title: t('settings.data.local.backup.manager.delete.confirm.title'), + icon: , + content: t('settings.data.local.backup.manager.delete.confirm.single', { fileName }), + okText: t('common.confirm'), + cancelText: t('common.cancel'), + centered: true, + onOk: async () => { + setDeleting(true) + try { + await window.api.backup.deleteLocalBackupFile(fileName, localBackupDir) + message.success(t('settings.data.local.backup.manager.delete.success.single')) + await fetchBackupFiles() + } catch (error: any) { + message.error(`${t('settings.data.local.backup.manager.delete.error')}: ${error.message}`) + } finally { + setDeleting(false) + } + } + }) + } + + const handleRestore = async (fileName: string) => { + if (!localBackupDir) { + return + } + + window.modal.confirm({ + title: t('settings.data.local.restore.confirm.title'), + icon: , + content: t('settings.data.local.restore.confirm.content'), + okText: t('common.confirm'), + cancelText: t('common.cancel'), + centered: true, + onOk: async () => { + setRestoring(true) + try { + await (restoreMethod || restoreFromLocalBackup)(fileName) + message.success(t('settings.data.local.backup.manager.restore.success')) + onClose() // Close the modal + } catch (error: any) { + message.error(`${t('settings.data.local.backup.manager.restore.error')}: ${error.message}`) + } finally { + setRestoring(false) + } + } + }) + } + + const columns = [ + { + title: t('settings.data.local.backup.manager.columns.fileName'), + dataIndex: 'fileName', + key: 'fileName', + ellipsis: { + showTitle: false + }, + render: (fileName: string) => ( + + {fileName} + + ) + }, + { + title: t('settings.data.local.backup.manager.columns.modifiedTime'), + dataIndex: 'modifiedTime', + key: 'modifiedTime', + width: 180, + render: (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss') + }, + { + title: t('settings.data.local.backup.manager.columns.size'), + dataIndex: 'size', + key: 'size', + width: 120, + render: (size: number) => formatFileSize(size) + }, + { + title: t('settings.data.local.backup.manager.columns.actions'), + key: 'action', + width: 160, + render: (_: any, record: BackupFile) => ( + <> + + + + ) + } + ] + + const rowSelection = { + selectedRowKeys, + onChange: (selectedRowKeys: React.Key[]) => { + setSelectedRowKeys(selectedRowKeys) + } + } + + return ( + } onClick={fetchBackupFiles} disabled={loading}> + {t('settings.data.local.backup.manager.refresh')} + , + , + + ]}> + + + ) +} diff --git a/src/renderer/src/components/LocalBackupModals.tsx b/src/renderer/src/components/LocalBackupModals.tsx new file mode 100644 index 0000000000..9f2a700dd2 --- /dev/null +++ b/src/renderer/src/components/LocalBackupModals.tsx @@ -0,0 +1,98 @@ +import { backupToLocalDir } from '@renderer/services/BackupService' +import { Button, Input, Modal } from 'antd' +import dayjs from 'dayjs' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' + +interface LocalBackupModalProps { + isModalVisible: boolean + handleBackup: () => void + handleCancel: () => void + backuping: boolean + customFileName: string + setCustomFileName: (value: string) => void +} + +export function LocalBackupModal({ + isModalVisible, + handleBackup, + handleCancel, + backuping, + customFileName, + setCustomFileName +}: LocalBackupModalProps) { + const { t } = useTranslation() + + return ( + + {t('common.cancel')} + , + + ]}> + setCustomFileName(e.target.value)} + placeholder={t('settings.data.local.backup.modal.filename.placeholder')} + /> + + ) +} + +// Hook for backup modal +export function useLocalBackupModal(localBackupDir: string | undefined) { + const [isModalVisible, setIsModalVisible] = useState(false) + const [backuping, setBackuping] = useState(false) + const [customFileName, setCustomFileName] = useState('') + + const handleCancel = () => { + setIsModalVisible(false) + } + + const showBackupModal = useCallback(async () => { + // 获取默认文件名 + const deviceType = await window.api.system.getDeviceType() + const hostname = await window.api.system.getHostname() + const timestamp = dayjs().format('YYYYMMDDHHmmss') + const defaultFileName = `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip` + setCustomFileName(defaultFileName) + setIsModalVisible(true) + }, []) + + const handleBackup = async () => { + if (!localBackupDir) { + setIsModalVisible(false) + return + } + + setBackuping(true) + try { + await backupToLocalDir({ + showMessage: true, + customFileName + }) + setIsModalVisible(false) + } catch (error) { + console.error('[LocalBackupModal] Backup failed:', error) + } finally { + setBackuping(false) + } + } + + return { + isModalVisible, + handleBackup, + handleCancel, + backuping, + customFileName, + setCustomFileName, + showBackupModal + } +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index a627ce3725..79a9b0236c 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -374,6 +374,7 @@ "assistant": "Assistant", "avatar": "Avatar", "back": "Back", + "browse": "Browse", "cancel": "Cancel", "chat": "Chat", "clear": "Clear", @@ -1266,7 +1267,52 @@ "syncStatus": "Backup Status", "title": "WebDAV", "user": "WebDAV User", - "maxBackups": "Maximum Backups", + "maxBackups": "Maximum Backups" + }, + "local": { + "autoSync": "Auto Backup", + "autoSync.off": "Off", + "backup.button": "Backup to Local", + "backup.modal.filename.placeholder": "Please enter backup filename", + "backup.modal.title": "Backup to Local Directory", + "backup.manager.title": "Local Backup Manager", + "backup.manager.refresh": "Refresh", + "backup.manager.delete.selected": "Delete Selected", + "backup.manager.delete.text": "Delete", + "backup.manager.restore.text": "Restore", + "backup.manager.restore.success": "Restore successful, application will refresh shortly", + "backup.manager.restore.error": "Restore failed", + "backup.manager.delete.confirm.title": "Confirm Delete", + "backup.manager.delete.confirm.single": "Are you sure you want to delete backup file \"{{fileName}}\"? This action cannot be undone.", + "backup.manager.delete.confirm.multiple": "Are you sure you want to delete {{count}} selected backup files? This action cannot be undone.", + "backup.manager.delete.success.single": "Deleted successfully", + "backup.manager.delete.success.multiple": "Successfully deleted {{count}} backup files", + "backup.manager.delete.error": "Delete failed", + "backup.manager.fetch.error": "Failed to get backup files", + "backup.manager.select.files.delete": "Please select backup files to delete", + "backup.manager.columns.fileName": "Filename", + "backup.manager.columns.modifiedTime": "Modified Time", + "backup.manager.columns.size": "Size", + "backup.manager.columns.actions": "Actions", + "directory.select_error_app_data_path": "New path cannot be the same as the application data path", + "directory.select_error_in_app_install_path": "New path cannot be the same as the application installation path", + "directory.select_error_write_permission": "New path does not have write permission", + "directory.select_title": "Select Backup Directory", + "directory": "Local Backup Directory", + "directory.placeholder": "Select a directory for local backups", + "hour_interval_one": "{{count}} hour", + "hour_interval_other": "{{count}} hours", + "lastSync": "Last Backup", + "minute_interval_one": "{{count}} minute", + "minute_interval_other": "{{count}} minutes", + "noSync": "Waiting for next backup", + "restore.button": "Restore from Local", + "restore.confirm.content": "Restoring from local backup will replace current data. Do you want to continue?", + "restore.confirm.title": "Confirm Restore", + "syncError": "Backup Error", + "syncStatus": "Backup Status", + "title": "Local Backup", + "maxBackups": "Maximum backups", "maxBackups.unlimited": "Unlimited" }, "s3": { diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 46959d3ccd..e31ecb4a38 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -374,6 +374,7 @@ "assistant": "アシスタント", "avatar": "アバター", "back": "戻る", + "browse": "参照", "cancel": "キャンセル", "chat": "チャット", "clear": "クリア", @@ -1157,6 +1158,50 @@ "divider.third_party": "サードパーティー連携", "hour_interval_one": "{{count}} 時間", "hour_interval_other": "{{count}} 時間", + "local": { + "title": "ローカルバックアップ", + "directory": "バックアップディレクトリ", + "directory.placeholder": "バックアップディレクトリを選択してください", + "backup.button": "ローカルにバックアップ", + "backup.modal.title": "ローカルにバックアップ", + "backup.modal.filename.placeholder": "バックアップファイル名を入力してください", + "restore.button": "バックアップファイル管理", + "autoSync": "自動バックアップ", + "autoSync.off": "オフ", + "lastSync": "最終バックアップ", + "noSync": "次回のバックアップを待機中", + "syncError": "バックアップエラー", + "syncStatus": "バックアップ状態", + "minute_interval_one": "{{count}} 分", + "minute_interval_other": "{{count}} 分", + "hour_interval_one": "{{count}} 時間", + "hour_interval_other": "{{count}} 時間", + "maxBackups": "最大バックアップ数", + "maxBackups.unlimited": "無制限", + "backup.manager.title": "バックアップファイル管理", + "backup.manager.refresh": "更新", + "backup.manager.delete.selected": "選択したものを削除", + "backup.manager.delete.text": "削除", + "backup.manager.restore.text": "復元", + "backup.manager.restore.success": "復元が成功しました、アプリケーションは間もなく更新されます", + "backup.manager.restore.error": "復元に失敗しました", + "backup.manager.delete.confirm.title": "削除の確認", + "backup.manager.delete.confirm.single": "バックアップファイル \"{{fileName}}\" を削除してもよろしいですか?この操作は元に戻せません。", + "backup.manager.delete.confirm.multiple": "選択した {{count}} 個のバックアップファイルを削除してもよろしいですか?この操作は元に戻せません。", + "backup.manager.delete.success.single": "削除が成功しました", + "backup.manager.delete.success.multiple": "{{count}} 個のバックアップファイルを削除しました", + "backup.manager.delete.error": "削除に失敗しました", + "backup.manager.fetch.error": "バックアップファイルの取得に失敗しました", + "backup.manager.select.files.delete": "削除するバックアップファイルを選択してください", + "backup.manager.columns.fileName": "ファイル名", + "backup.manager.columns.modifiedTime": "更新日時", + "backup.manager.columns.size": "サイズ", + "backup.manager.columns.actions": "操作", + "directory.select_error_app_data_path": "新パスはアプリデータパスと同じです。別のパスを選択してください", + "directory.select_error_in_app_install_path": "新パスはアプリインストールパスと同じです。別のパスを選択してください", + "directory.select_error_write_permission": "新パスに書き込み権限がありません", + "directory.select_title": "バックアップディレクトリを選択" + }, "export_menu": { "title": "エクスポートメニュー設定", "image": "画像としてエクスポート", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 8cb7c3bc98..63a23000f3 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -374,6 +374,7 @@ "assistant": "Ассистент", "avatar": "Аватар", "back": "Назад", + "browse": "Обзор", "cancel": "Отмена", "chat": "Чат", "clear": "Очистить", @@ -1157,6 +1158,52 @@ "divider.third_party": "Сторонние подключения", "hour_interval_one": "{{count}} час", "hour_interval_other": "{{count}} часов", + "local": { + "title": "Локальное резервное копирование", + "directory": "Каталог резервных копий", + "directory.placeholder": "Выберите каталог для резервных копий", + "backup.button": "Создать резервную копию", + "backup.modal.title": "Локальное резервное копирование", + "backup.modal.filename.placeholder": "Введите имя файла резервной копии", + "restore.button": "Управление резервными копиями", + "autoSync": "Автоматическое резервное копирование", + "autoSync.off": "Выключено", + "lastSync": "Последнее копирование", + "noSync": "Ожидание следующего копирования", + "syncError": "Ошибка копирования", + "syncStatus": "Статус копирования", + "minute_interval_one": "{{count}} минута", + "minute_interval_few": "{{count}} минуты", + "minute_interval_many": "{{count}} минут", + "hour_interval_one": "{{count}} час", + "hour_interval_few": "{{count}} часа", + "hour_interval_many": "{{count}} часов", + "maxBackups": "Максимальное количество резервных копий", + "maxBackups.unlimited": "Без ограничений", + "backup.manager.title": "Управление резервными копиями", + "backup.manager.refresh": "Обновить", + "backup.manager.delete.selected": "Удалить выбранное", + "backup.manager.delete.text": "Удалить", + "backup.manager.restore.text": "Восстановить", + "backup.manager.restore.success": "Восстановление успешно, приложение скоро обновится", + "backup.manager.restore.error": "Ошибка восстановления", + "backup.manager.delete.confirm.title": "Подтверждение удаления", + "backup.manager.delete.confirm.single": "Вы действительно хотите удалить файл резервной копии \"{{fileName}}\"? Это действие нельзя отменить.", + "backup.manager.delete.confirm.multiple": "Вы действительно хотите удалить выбранные {{count}} файла(ов) резервных копий? Это действие нельзя отменить.", + "backup.manager.delete.success.single": "Успешно удалено", + "backup.manager.delete.success.multiple": "Удалено {{count}} файла(ов) резервных копий", + "backup.manager.delete.error": "Ошибка удаления", + "backup.manager.fetch.error": "Ошибка получения файлов резервных копий", + "backup.manager.select.files.delete": "Выберите файлы резервных копий для удаления", + "backup.manager.columns.fileName": "Имя файла", + "backup.manager.columns.modifiedTime": "Время изменения", + "backup.manager.columns.size": "Размер", + "backup.manager.columns.actions": "Действия", + "directory.select_error_app_data_path": "Новый путь не может совпадать с путем данных приложения", + "directory.select_error_in_app_install_path": "Новый путь не может совпадать с путем установки приложения", + "directory.select_error_write_permission": "Новый путь не имеет разрешения на запись", + "directory.select_title": "Выберите каталог для резервных копий" + }, "export_menu": { "title": "Настройки меню экспорта", "image": "Экспорт как изображение", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 82c16170ee..6b26c45588 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -374,6 +374,7 @@ "assistant": "智能体", "avatar": "头像", "back": "返回", + "browse": "浏览", "cancel": "取消", "chat": "聊天", "clear": "清除", @@ -1159,6 +1160,50 @@ "divider.third_party": "第三方连接", "hour_interval_one": "{{count}} 小时", "hour_interval_other": "{{count}} 小时", + "local": { + "title": "本地备份", + "directory": "备份目录", + "directory.placeholder": "请选择备份目录", + "backup.button": "本地备份", + "backup.modal.title": "本地备份", + "backup.modal.filename.placeholder": "请输入备份文件名", + "restore.button": "备份文件管理", + "autoSync": "自动备份", + "autoSync.off": "关闭", + "lastSync": "上次备份", + "noSync": "等待下次备份", + "syncError": "备份错误", + "syncStatus": "备份状态", + "minute_interval_one": "{{count}} 分钟", + "minute_interval_other": "{{count}} 分钟", + "hour_interval_one": "{{count}} 小时", + "hour_interval_other": "{{count}} 小时", + "maxBackups": "最大备份数", + "maxBackups.unlimited": "无限制", + "backup.manager.title": "备份文件管理", + "backup.manager.refresh": "刷新", + "backup.manager.delete.selected": "删除选中", + "backup.manager.delete.text": "删除", + "backup.manager.restore.text": "恢复", + "backup.manager.restore.success": "恢复成功,应用将很快刷新", + "backup.manager.restore.error": "恢复失败", + "backup.manager.delete.confirm.title": "确认删除", + "backup.manager.delete.confirm.single": "确定要删除备份文件 \"{{fileName}}\" 吗?此操作无法撤销。", + "backup.manager.delete.confirm.multiple": "确定要删除选中的 {{count}} 个备份文件吗?此操作无法撤销。", + "backup.manager.delete.success.single": "删除成功", + "backup.manager.delete.success.multiple": "已删除 {{count}} 个备份文件", + "backup.manager.delete.error": "删除失败", + "backup.manager.fetch.error": "获取备份文件失败", + "backup.manager.select.files.delete": "请选择要删除的备份文件", + "backup.manager.columns.fileName": "文件名", + "backup.manager.columns.modifiedTime": "修改时间", + "backup.manager.columns.size": "大小", + "backup.manager.columns.actions": "操作", + "directory.select_error_app_data_path": "新路径不能与应用数据路径相同", + "directory.select_error_in_app_install_path": "新路径不能与应用安装路径相同", + "directory.select_error_write_permission": "新路径没有写入权限", + "directory.select_title": "选择备份目录" + }, "export_menu": { "title": "导出菜单设置", "image": "导出为图片", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 0b5f272148..2346e15de3 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -374,6 +374,7 @@ "assistant": "智慧代理人", "avatar": "頭像", "back": "返回", + "browse": "瀏覽", "cancel": "取消", "chat": "聊天", "clear": "清除", @@ -1159,6 +1160,50 @@ "divider.third_party": "第三方連接", "hour_interval_one": "{{count}} 小時", "hour_interval_other": "{{count}} 小時", + "local": { + "title": "本地備份", + "directory": "備份目錄", + "directory.placeholder": "請選擇備份目錄", + "backup.button": "本地備份", + "backup.modal.title": "本地備份", + "backup.modal.filename.placeholder": "請輸入備份文件名", + "restore.button": "備份文件管理", + "autoSync": "自動備份", + "autoSync.off": "關閉", + "lastSync": "上次備份", + "noSync": "等待下次備份", + "syncError": "備份錯誤", + "syncStatus": "備份狀態", + "minute_interval_one": "{{count}} 分鐘", + "minute_interval_other": "{{count}} 分鐘", + "hour_interval_one": "{{count}} 小時", + "hour_interval_other": "{{count}} 小時", + "maxBackups": "最大備份數", + "maxBackups.unlimited": "無限制", + "backup.manager.title": "備份文件管理", + "backup.manager.refresh": "刷新", + "backup.manager.delete.selected": "刪除選中", + "backup.manager.delete.text": "刪除", + "backup.manager.restore.text": "恢復", + "backup.manager.restore.success": "恢復成功,應用將很快刷新", + "backup.manager.restore.error": "恢復失敗", + "backup.manager.delete.confirm.title": "確認刪除", + "backup.manager.delete.confirm.single": "確定要刪除備份文件 \"{{fileName}}\" 嗎?此操作無法撤銷。", + "backup.manager.delete.confirm.multiple": "確定要刪除選中的 {{count}} 個備份文件嗎?此操作無法撤銷。", + "backup.manager.delete.success.single": "刪除成功", + "backup.manager.delete.success.multiple": "已刪除 {{count}} 個備份文件", + "backup.manager.delete.error": "刪除失敗", + "backup.manager.fetch.error": "獲取備份文件失敗", + "backup.manager.select.files.delete": "請選擇要刪除的備份文件", + "backup.manager.columns.fileName": "文件名", + "backup.manager.columns.modifiedTime": "修改時間", + "backup.manager.columns.size": "大小", + "backup.manager.columns.actions": "操作", + "directory.select_error_app_data_path": "新路徑不能與應用數據路徑相同", + "directory.select_error_in_app_install_path": "新路徑不能與應用安裝路徑相同", + "directory.select_error_write_permission": "新路徑沒有寫入權限", + "directory.select_title": "選擇備份目錄" + }, "export_menu": { "title": "匯出選單設定", "image": "匯出為圖片", diff --git a/src/renderer/src/init.ts b/src/renderer/src/init.ts index c5d969b6fd..91e5e54c65 100644 --- a/src/renderer/src/init.ts +++ b/src/renderer/src/init.ts @@ -1,6 +1,6 @@ import KeyvStorage from '@kangfenmao/keyv-storage' -import { startAutoSync } from './services/BackupService' +import { startAutoSync, startLocalBackupAutoSync } from './services/BackupService' import { startNutstoreAutoSync } from './services/NutstoreService' import storeSyncService from './services/StoreSyncService' import store from './store' @@ -12,7 +12,7 @@ function initKeyv() { function initAutoSync() { setTimeout(() => { - const { webdavAutoSync, s3 } = store.getState().settings + const { webdavAutoSync, localBackupAutoSync, s3 } = store.getState().settings const { nutstoreAutoSync } = store.getState().nutstore if (webdavAutoSync || (s3 && s3.autoSync)) { startAutoSync() @@ -20,6 +20,9 @@ function initAutoSync() { if (nutstoreAutoSync) { startNutstoreAutoSync() } + if (localBackupAutoSync) { + startLocalBackupAutoSync() + } }, 8000) } diff --git a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx index 1ef101157c..3ef10704b7 100644 --- a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx @@ -39,6 +39,7 @@ import { import AgentsSubscribeUrlSettings from './AgentsSubscribeUrlSettings' import ExportMenuOptions from './ExportMenuSettings' import JoplinSettings from './JoplinSettings' +import LocalBackupSettings from './LocalBackupSettings' import MarkdownExportSettings from './MarkdownExportSettings' import NotionSettings from './NotionSettings' import NutstoreSettings from './NutstoreSettings' @@ -88,6 +89,7 @@ const DataSettings: FC = () => { { key: 'divider_0', isDivider: true, text: t('settings.data.divider.basic') }, { key: 'data', title: 'settings.data.data.title', icon: }, { key: 'divider_1', isDivider: true, text: t('settings.data.divider.cloud_storage') }, + { key: 'local_backup', title: 'settings.data.local.title', icon: }, { key: 'webdav', title: 'settings.data.webdav.title', icon: }, { key: 'nutstore', title: 'settings.data.nutstore.title', icon: }, { key: 's3', title: 'settings.data.s3.title', icon: }, @@ -665,6 +667,7 @@ const DataSettings: FC = () => { {menu === 'obsidian' && } {menu === 'siyuan' && } {menu === 'agentssubscribe_url' && } + {menu === 'local_backup' && } ) diff --git a/src/renderer/src/pages/settings/DataSettings/LocalBackupSettings.tsx b/src/renderer/src/pages/settings/DataSettings/LocalBackupSettings.tsx new file mode 100644 index 0000000000..1defa7549a --- /dev/null +++ b/src/renderer/src/pages/settings/DataSettings/LocalBackupSettings.tsx @@ -0,0 +1,279 @@ +import { DeleteOutlined, FolderOpenOutlined, SaveOutlined, SyncOutlined, WarningOutlined } from '@ant-design/icons' +import { HStack } from '@renderer/components/Layout' +import { LocalBackupManager } from '@renderer/components/LocalBackupManager' +import { LocalBackupModal, useLocalBackupModal } from '@renderer/components/LocalBackupModals' +import { useTheme } from '@renderer/context/ThemeProvider' +import { useSettings } from '@renderer/hooks/useSettings' +import { startLocalBackupAutoSync, stopLocalBackupAutoSync } from '@renderer/services/BackupService' +import { useAppDispatch, useAppSelector } from '@renderer/store' +import { + setLocalBackupAutoSync, + setLocalBackupDir as _setLocalBackupDir, + setLocalBackupMaxBackups as _setLocalBackupMaxBackups, + setLocalBackupSkipBackupFile as _setLocalBackupSkipBackupFile, + setLocalBackupSyncInterval as _setLocalBackupSyncInterval +} from '@renderer/store/settings' +import { AppInfo } from '@renderer/types' +import { Button, Input, Select, Switch, Tooltip } from 'antd' +import dayjs from 'dayjs' +import { FC, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..' + +const LocalBackupSettings: FC = () => { + const { + localBackupDir: localBackupDirSetting, + localBackupSyncInterval: localBackupSyncIntervalSetting, + localBackupMaxBackups: localBackupMaxBackupsSetting, + localBackupSkipBackupFile: localBackupSkipBackupFileSetting + } = useSettings() + + const [localBackupDir, setLocalBackupDir] = useState(localBackupDirSetting) + const [localBackupSkipBackupFile, setLocalBackupSkipBackupFile] = useState(localBackupSkipBackupFileSetting) + const [backupManagerVisible, setBackupManagerVisible] = useState(false) + + const [syncInterval, setSyncInterval] = useState(localBackupSyncIntervalSetting) + const [maxBackups, setMaxBackups] = useState(localBackupMaxBackupsSetting) + + const [appInfo, setAppInfo] = useState() + + useEffect(() => { + window.api.getAppInfo().then(setAppInfo) + }, []) + + const dispatch = useAppDispatch() + const { theme } = useTheme() + + const { t } = useTranslation() + + const { localBackupSync } = useAppSelector((state) => state.backup) + + const onSyncIntervalChange = (value: number) => { + setSyncInterval(value) + dispatch(_setLocalBackupSyncInterval(value)) + if (value === 0) { + dispatch(setLocalBackupAutoSync(false)) + stopLocalBackupAutoSync() + } else { + dispatch(setLocalBackupAutoSync(true)) + startLocalBackupAutoSync() + } + } + + const checkLocalBackupDirValid = async (dir: string) => { + if (dir === '') { + return false + } + + // check new local backup dir is not in app data path + // if is in app data path, show error + if (dir.startsWith(appInfo!.appDataPath)) { + window.message.error(t('settings.data.local.directory.select_error_app_data_path')) + return false + } + + // check new local backup dir is not in app install path + // if is in app install path, show error + if (dir.startsWith(appInfo!.installPath)) { + window.message.error(t('settings.data.local.directory.select_error_in_app_install_path')) + return false + } + + // check new app data path has write permission + const hasWritePermission = await window.api.hasWritePermission(dir) + if (!hasWritePermission) { + window.message.error(t('settings.data.local.directory.select_error_write_permission')) + return false + } + + return true + } + + const handleLocalBackupDirChange = async (value: string) => { + if (await checkLocalBackupDirValid(value)) { + setLocalBackupDir(value) + dispatch(_setLocalBackupDir(value)) + // Create directory if it doesn't exist and set it in the backend + await window.api.backup.setLocalBackupDir(value) + + dispatch(setLocalBackupAutoSync(true)) + startLocalBackupAutoSync(true) + return + } + + setLocalBackupDir('') + dispatch(_setLocalBackupDir('')) + dispatch(setLocalBackupAutoSync(false)) + stopLocalBackupAutoSync() + } + + const onMaxBackupsChange = (value: number) => { + setMaxBackups(value) + dispatch(_setLocalBackupMaxBackups(value)) + } + + const onSkipBackupFilesChange = (value: boolean) => { + setLocalBackupSkipBackupFile(value) + dispatch(_setLocalBackupSkipBackupFile(value)) + } + + const handleBrowseDirectory = async () => { + try { + const newLocalBackupDir = await window.api.select({ + properties: ['openDirectory', 'createDirectory'], + title: t('settings.data.local.directory.select_title') + }) + + if (!newLocalBackupDir) { + return + } + + handleLocalBackupDirChange(newLocalBackupDir) + } catch (error) { + console.error('Failed to select directory:', error) + } + } + + const handleClearDirectory = () => { + setLocalBackupDir('') + dispatch(_setLocalBackupDir('')) + dispatch(setLocalBackupAutoSync(false)) + stopLocalBackupAutoSync() + } + + const renderSyncStatus = () => { + if (!localBackupDir) return null + + if (!localBackupSync.lastSyncTime && !localBackupSync.syncing && !localBackupSync.lastSyncError) { + return {t('settings.data.local.noSync')} + } + + return ( + + {localBackupSync.syncing && } + {!localBackupSync.syncing && localBackupSync.lastSyncError && ( + + + + )} + {localBackupSync.lastSyncTime && ( + + {t('settings.data.local.lastSync')}: {dayjs(localBackupSync.lastSyncTime).format('HH:mm:ss')} + + )} + + ) + } + + const { isModalVisible, handleBackup, handleCancel, backuping, customFileName, setCustomFileName, showBackupModal } = + useLocalBackupModal(localBackupDir) + + const showBackupManager = () => { + setBackupManagerVisible(true) + } + + const closeBackupManager = () => { + setBackupManagerVisible(false) + } + + return ( + + {t('settings.data.local.title')} + + + {t('settings.data.local.directory')} + + + + + + + + + {t('settings.general.backup.title')} + + + + + + + + {t('settings.data.local.autoSync')} + + + + + {t('settings.data.local.maxBackups')} + + + + + {t('settings.data.backup.skip_file_data_title')} + + + + {t('settings.data.backup.skip_file_data_help')} + + {localBackupSync && syncInterval > 0 && ( + <> + + + {t('settings.data.local.syncStatus')} + {renderSyncStatus()} + + + )} + <> + + + + + + ) +} + +export default LocalBackupSettings diff --git a/src/renderer/src/services/BackupService.ts b/src/renderer/src/services/BackupService.ts index 4bb92f38b0..88bb8be394 100644 --- a/src/renderer/src/services/BackupService.ts +++ b/src/renderer/src/services/BackupService.ts @@ -3,8 +3,7 @@ import db from '@renderer/databases' import { upgradeToV7, upgradeToV8 } from '@renderer/databases/upgrades' import i18n from '@renderer/i18n' import store from '@renderer/store' -import { setWebDAVSyncState } from '@renderer/store/backup' -import { setS3SyncState } from '@renderer/store/backup' +import { setLocalBackupSyncState, setS3SyncState, setWebDAVSyncState } from '@renderer/store/backup' import { S3Config, WebDavConfig } from '@renderer/types' import { uuid } from '@renderer/utils' import dayjs from 'dayjs' @@ -469,11 +468,15 @@ export function startAutoSync(immediate = false) { const s3AutoSync = s3Settings?.autoSync const s3Endpoint = s3Settings?.endpoint + const localBackupAutoSync = settings.localBackupAutoSync + const localBackupDir = settings.localBackupDir + // 检查WebDAV或S3自动同步配置 const hasWebdavConfig = webdavAutoSync && webdavHost const hasS3Config = s3AutoSync && s3Endpoint + const hasLocalConfig = localBackupAutoSync && localBackupDir - if (!hasWebdavConfig && !hasS3Config) { + if (!hasWebdavConfig && !hasS3Config && !hasLocalConfig) { Logger.log('[AutoSync] Invalid sync settings, auto sync disabled') return } @@ -717,3 +720,279 @@ async function clearDatabase() { } }) } + +/** + * Backup to local directory + */ +export async function backupToLocalDir({ + showMessage = false, + customFileName = '', + autoBackupProcess = false +}: { showMessage?: boolean; customFileName?: string; autoBackupProcess?: boolean } = {}) { + const notificationService = NotificationService.getInstance() + if (isManualBackupRunning) { + Logger.log('[Backup] Manual backup already in progress') + return + } + // force set showMessage to false when auto backup process + if (autoBackupProcess) { + showMessage = false + } + + isManualBackupRunning = true + + store.dispatch(setLocalBackupSyncState({ syncing: true, lastSyncError: null })) + + const { localBackupDir, localBackupMaxBackups, localBackupSkipBackupFile } = store.getState().settings + let deviceType = 'unknown' + let hostname = 'unknown' + try { + deviceType = (await window.api.system.getDeviceType()) || 'unknown' + hostname = (await window.api.system.getHostname()) || 'unknown' + } catch (error) { + Logger.error('[Backup] Failed to get device type or hostname:', error) + } + const timestamp = dayjs().format('YYYYMMDDHHmmss') + const backupFileName = customFileName || `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip` + const finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.zip` + const backupData = await getBackupData() + + try { + const result = await window.api.backup.backupToLocalDir(backupData, finalFileName, { + localBackupDir, + skipBackupFile: localBackupSkipBackupFile + }) + + if (result) { + store.dispatch( + setLocalBackupSyncState({ + lastSyncError: null + }) + ) + + if (showMessage) { + notificationService.send({ + id: uuid(), + type: 'success', + title: i18n.t('common.success'), + message: i18n.t('message.backup.success'), + silent: false, + timestamp: Date.now(), + source: 'backup' + }) + } + + // Clean up old backups if maxBackups is set + if (localBackupMaxBackups > 0) { + try { + // Get all backup files + const files = await window.api.backup.listLocalBackupFiles(localBackupDir) + + // Filter backups for current device + const currentDeviceFiles = files.filter((file) => { + return file.fileName.includes(deviceType) && file.fileName.includes(hostname) + }) + + if (currentDeviceFiles.length > localBackupMaxBackups) { + // Sort by modified time (oldest first) + const filesToDelete = currentDeviceFiles + .sort((a, b) => new Date(a.modifiedTime).getTime() - new Date(b.modifiedTime).getTime()) + .slice(0, currentDeviceFiles.length - localBackupMaxBackups) + + // Delete older backups + for (const file of filesToDelete) { + Logger.log(`[LocalBackup] Deleting old backup: ${file.fileName}`) + await window.api.backup.deleteLocalBackupFile(file.fileName, localBackupDir) + } + } + } catch (error) { + Logger.error('[LocalBackup] Failed to clean up old backups:', error) + } + } + } + + return result + } catch (error: any) { + Logger.error('[LocalBackup] Backup failed:', error) + + store.dispatch( + setLocalBackupSyncState({ + lastSyncError: error.message || 'Unknown error' + }) + ) + + if (showMessage) { + window.modal.error({ + title: i18n.t('message.backup.failed'), + content: error.message || 'Unknown error' + }) + } + + throw error + } finally { + if (!autoBackupProcess) { + store.dispatch( + setLocalBackupSyncState({ + lastSyncTime: Date.now(), + syncing: false + }) + ) + } + isManualBackupRunning = false + } +} + +export async function restoreFromLocalBackup(fileName: string) { + try { + const { localBackupDir } = store.getState().settings + await window.api.backup.restoreFromLocalBackup(fileName, localBackupDir) + return true + } catch (error) { + Logger.error('[LocalBackup] Restore failed:', error) + throw error + } +} + +// Local backup auto sync +let localBackupAutoSyncStarted = false +let localBackupSyncTimeout: NodeJS.Timeout | null = null +let isLocalBackupAutoRunning = false + +export function startLocalBackupAutoSync(immediate = false) { + if (localBackupAutoSyncStarted) { + return + } + + const { localBackupAutoSync, localBackupDir } = store.getState().settings + + if (!localBackupAutoSync || !localBackupDir) { + Logger.log('[LocalBackupAutoSync] Invalid sync settings, auto sync disabled') + return + } + + localBackupAutoSyncStarted = true + + stopLocalBackupAutoSync() + + scheduleNextBackup(immediate ? 'immediate' : 'fromLastSyncTime') + + /** + * @param type 'immediate' | 'fromLastSyncTime' | 'fromNow' + * 'immediate', first backup right now + * 'fromLastSyncTime', schedule next backup from last sync time + * 'fromNow', schedule next backup from now + */ + function scheduleNextBackup(type: 'immediate' | 'fromLastSyncTime' | 'fromNow' = 'fromLastSyncTime') { + if (localBackupSyncTimeout) { + clearTimeout(localBackupSyncTimeout) + localBackupSyncTimeout = null + } + + const { localBackupSyncInterval } = store.getState().settings + const { localBackupSync } = store.getState().backup + + if (localBackupSyncInterval <= 0) { + Logger.log('[LocalBackupAutoSync] Invalid sync interval, auto sync disabled') + stopLocalBackupAutoSync() + return + } + + // User specified auto backup interval (milliseconds) + const requiredInterval = localBackupSyncInterval * 60 * 1000 + + let timeUntilNextSync = 1000 // immediate by default + switch (type) { + case 'fromLastSyncTime': // If last sync time exists, use it as reference + timeUntilNextSync = Math.max(1000, (localBackupSync?.lastSyncTime || 0) + requiredInterval - Date.now()) + break + case 'fromNow': + timeUntilNextSync = requiredInterval + break + } + + localBackupSyncTimeout = setTimeout(performAutoBackup, timeUntilNextSync) + + Logger.log( + `[LocalBackupAutoSync] Next sync scheduled in ${Math.floor(timeUntilNextSync / 1000 / 60)} minutes ${Math.floor( + (timeUntilNextSync / 1000) % 60 + )} seconds` + ) + } + + async function performAutoBackup() { + if (isLocalBackupAutoRunning || isManualBackupRunning) { + Logger.log('[LocalBackupAutoSync] Backup already in progress, rescheduling') + scheduleNextBackup() + return + } + + isLocalBackupAutoRunning = true + const maxRetries = 4 + let retryCount = 0 + + while (retryCount < maxRetries) { + try { + Logger.log(`[LocalBackupAutoSync] Starting auto backup... (attempt ${retryCount + 1}/${maxRetries})`) + + await backupToLocalDir({ autoBackupProcess: true }) + + store.dispatch( + setLocalBackupSyncState({ + lastSyncError: null, + lastSyncTime: Date.now(), + syncing: false + }) + ) + + isLocalBackupAutoRunning = false + scheduleNextBackup() + + break + } catch (error: any) { + retryCount++ + if (retryCount === maxRetries) { + Logger.error('[LocalBackupAutoSync] Auto backup failed after all retries:', error) + + store.dispatch( + setLocalBackupSyncState({ + lastSyncError: 'Auto backup failed', + lastSyncTime: Date.now(), + syncing: false + }) + ) + + // Only show error modal once and wait for user acknowledgment + await window.modal.error({ + title: i18n.t('message.backup.failed'), + content: `[Local Backup Auto Backup] ${new Date().toLocaleString()} ` + error.message + }) + + scheduleNextBackup('fromNow') + isLocalBackupAutoRunning = false + } else { + // Exponential Backoff with Base 2: 7s, 17s, 37s + const backoffDelay = Math.pow(2, retryCount - 1) * 10000 - 3000 + Logger.log(`[LocalBackupAutoSync] Failed, retry ${retryCount}/${maxRetries} after ${backoffDelay / 1000}s`) + + await new Promise((resolve) => setTimeout(resolve, backoffDelay)) + + // Check if auto backup was stopped by user + if (!isLocalBackupAutoRunning) { + Logger.log('[LocalBackupAutoSync] retry cancelled by user, exit') + break + } + } + } + } + } +} + +export function stopLocalBackupAutoSync() { + if (localBackupSyncTimeout) { + Logger.log('[LocalBackupAutoSync] Stopping auto sync') + clearTimeout(localBackupSyncTimeout) + localBackupSyncTimeout = null + } + isLocalBackupAutoRunning = false + localBackupAutoSyncStarted = false +} diff --git a/src/renderer/src/store/backup.ts b/src/renderer/src/store/backup.ts index 0418e5ab96..cf5d8e6e30 100644 --- a/src/renderer/src/store/backup.ts +++ b/src/renderer/src/store/backup.ts @@ -8,6 +8,7 @@ export interface RemoteSyncState { export interface BackupState { webdavSync: RemoteSyncState + localBackupSync: RemoteSyncState s3Sync: RemoteSyncState } @@ -17,6 +18,11 @@ const initialState: BackupState = { syncing: false, lastSyncError: null }, + localBackupSync: { + lastSyncTime: null, + syncing: false, + lastSyncError: null + }, s3Sync: { lastSyncTime: null, syncing: false, @@ -31,11 +37,14 @@ const backupSlice = createSlice({ setWebDAVSyncState: (state, action: PayloadAction>) => { state.webdavSync = { ...state.webdavSync, ...action.payload } }, + setLocalBackupSyncState: (state, action: PayloadAction>) => { + state.localBackupSync = { ...state.localBackupSync, ...action.payload } + }, setS3SyncState: (state, action: PayloadAction>) => { state.s3Sync = { ...state.s3Sync, ...action.payload } } } }) -export const { setWebDAVSyncState, setS3SyncState } = backupSlice.actions +export const { setWebDAVSyncState, setLocalBackupSyncState, setS3SyncState } = backupSlice.actions export default backupSlice.reducer diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 2f35546f32..6c0900fd91 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1723,6 +1723,7 @@ const migrateConfig = { addProvider(state, 'new-api') state.llm.providers = moveProvider(state.llm.providers, 'new-api', 16) state.settings.disableHardwareAcceleration = false + return state } catch (error) { return state @@ -1746,6 +1747,12 @@ const migrateConfig = { const newLang = langMap[origin] if (newLang) state.settings.targetLanguage = newLang else state.settings.targetLanguage = 'en-us' + + state.settings.localBackupMaxBackups = 0 + state.settings.localBackupSkipBackupFile = false + state.settings.localBackupDir = '' + state.settings.localBackupAutoSync = false + state.settings.localBackupSyncInterval = 0 return state } catch (error) { return state diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 71b99b9463..be1fe8a794 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -189,6 +189,12 @@ export interface SettingsState { backup: boolean knowledge: boolean } + // Local backup settings + localBackupDir: string + localBackupAutoSync: boolean + localBackupSyncInterval: number + localBackupMaxBackups: number + localBackupSkipBackupFile: boolean defaultPaintingProvider: PaintingProvider s3: S3Config } @@ -338,6 +344,12 @@ export const initialState: SettingsState = { backup: false, knowledge: false }, + // Local backup settings + localBackupDir: '', + localBackupAutoSync: false, + localBackupSyncInterval: 0, + localBackupMaxBackups: 0, + localBackupSkipBackupFile: false, defaultPaintingProvider: 'aihubmix', s3: { endpoint: '', @@ -715,6 +727,22 @@ const settingsSlice = createSlice({ setNotificationSettings: (state, action: PayloadAction) => { state.notification = action.payload }, + // Local backup settings + setLocalBackupDir: (state, action: PayloadAction) => { + state.localBackupDir = action.payload + }, + setLocalBackupAutoSync: (state, action: PayloadAction) => { + state.localBackupAutoSync = action.payload + }, + setLocalBackupSyncInterval: (state, action: PayloadAction) => { + state.localBackupSyncInterval = action.payload + }, + setLocalBackupMaxBackups: (state, action: PayloadAction) => { + state.localBackupMaxBackups = action.payload + }, + setLocalBackupSkipBackupFile: (state, action: PayloadAction) => { + state.localBackupSkipBackupFile = action.payload + }, setDefaultPaintingProvider: (state, action: PayloadAction) => { state.defaultPaintingProvider = action.payload }, @@ -832,6 +860,12 @@ export const { setOpenAISummaryText, setOpenAIServiceTier, setNotificationSettings, + // Local backup settings + setLocalBackupDir, + setLocalBackupAutoSync, + setLocalBackupSyncInterval, + setLocalBackupMaxBackups, + setLocalBackupSkipBackupFile, setDefaultPaintingProvider, setS3, setS3Partial