feat: implement local cloud directory backup functionality (#6353)

* feat: implement local backup functionality

- Added new IPC channels for local backup operations including backup, restore, list, delete, and set directory.
- Enhanced BackupManager with methods for handling local backups and integrated auto-sync capabilities.
- Updated settings to include local backup configurations and integrated UI components for managing local backups.
- Localized new features in English, Japanese, Russian, and Chinese.

* refactor: enhance BackupManager and LocalBackupModals for improved file handling

- Updated BackupManager to specify the type of result array for better type safety.
- Refactored showBackupModal in LocalBackupModals to use useCallback and generate a more descriptive default file name based on device type and timestamp.

* refactor: update localBackupDir path in BackupManager for consistency

- Changed localBackupDir to use the temp directory instead of userData for better alignment with backup storage practices.

* refactor: enforce localBackupDir parameter in BackupManager methods

- Updated BackupManager methods to require localBackupDir as a parameter, removing fallback to a default value for improved clarity and consistency in backup operations.
- Removed the localBackupDir property from the class, streamlining the backup management process.

* fix: update localization strings for improved clarity and consistency

- Revised English, Russian, Chinese, and Traditional Chinese localization strings for better user understanding.
- Adjusted phrases related to backup and restore processes to enhance clarity.
- Standardized terminology across different languages for consistency.

* fix: update Chinese localization strings for consistency and clarity

- Revised export menu strings in zh-cn.json to improve formatting and consistency.
- Removed spaces in phrases for a more streamlined appearance.

* feat(settings): add option to disable hardware acceleration

- Introduced a new setting to allow users to disable hardware acceleration.
- Added corresponding IPC channel and configuration management methods.
- Updated UI components to reflect the new setting and prompt for app restart.
- Localized confirmation messages for hardware acceleration changes in multiple languages.

* udpate migrate

* format code

* feat(i18n): add localized error messages for backup directory selection

- Introduced new error messages for selecting a backup directory in multiple languages, including English, Japanese, Russian, Simplified Chinese, and Traditional Chinese.
- Added checks in the LocalBackupSettings component to ensure the selected directory is not the same as the application data or installation paths, and that it has write permissions.

* format

* update migrate

* refactor(LocalBackup): streamline local backup directory validation and enhance settings UI

- Removed translation dependency in LocalBackupModals for error handling.
- Added comprehensive validation for local backup directory in LocalBackupSettings, including checks for app data path, install path, and write permissions.
- Introduced a clear directory button in the settings UI to reset the local backup directory.
- Updated the auto-sync logic to account for local backup settings.

* refactor(LocalBackupManager): remove redundant error messages for invalid local backup directory

- Eliminated repeated error message calls for invalid local backup directory in the LocalBackupManager component.
- Streamlined the validation logic to enhance code clarity and maintainability.
This commit is contained in:
beyondkmp
2025-07-08 14:04:29 +08:00
committed by GitHub
parent 00151f2c67
commit 115d2078b9
18 changed files with 1320 additions and 7 deletions

View File

@@ -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',

View File

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

View File

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

View File

@@ -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),

View File

@@ -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<void>
}
export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMethod }: LocalBackupManagerProps) {
const { t } = useTranslation()
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([])
const [loading, setLoading] = useState(false)
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
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: <ExclamationCircleOutlined />,
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: <ExclamationCircleOutlined />,
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: <ExclamationCircleOutlined />,
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) => (
<Tooltip placement="topLeft" title={fileName}>
{fileName}
</Tooltip>
)
},
{
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) => (
<>
<Button type="link" onClick={() => handleRestore(record.fileName)} disabled={restoring || deleting}>
{t('settings.data.local.backup.manager.restore.text')}
</Button>
<Button
type="link"
danger
onClick={() => handleDeleteSingle(record.fileName)}
disabled={deleting || restoring}>
{t('settings.data.local.backup.manager.delete.text')}
</Button>
</>
)
}
]
const rowSelection = {
selectedRowKeys,
onChange: (selectedRowKeys: React.Key[]) => {
setSelectedRowKeys(selectedRowKeys)
}
}
return (
<Modal
title={t('settings.data.local.backup.manager.title')}
open={visible}
onCancel={onClose}
width={800}
centered
transitionName="animation-move-down"
footer={[
<Button key="refresh" icon={<ReloadOutlined />} onClick={fetchBackupFiles} disabled={loading}>
{t('settings.data.local.backup.manager.refresh')}
</Button>,
<Button
key="delete"
danger
icon={<DeleteOutlined />}
onClick={handleDeleteSelected}
disabled={selectedRowKeys.length === 0 || deleting}
loading={deleting}>
{t('settings.data.local.backup.manager.delete.selected')} ({selectedRowKeys.length})
</Button>,
<Button key="close" onClick={onClose}>
{t('common.close')}
</Button>
]}>
<Table
rowKey="fileName"
columns={columns}
dataSource={backupFiles}
rowSelection={rowSelection}
pagination={pagination}
loading={loading}
onChange={handleTableChange}
size="middle"
/>
</Modal>
)
}

View File

@@ -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 (
<Modal
title={t('settings.data.local.backup.modal.title')}
open={isModalVisible}
onOk={handleBackup}
onCancel={handleCancel}
footer={[
<Button key="back" onClick={handleCancel}>
{t('common.cancel')}
</Button>,
<Button key="submit" type="primary" loading={backuping} onClick={handleBackup}>
{t('common.confirm')}
</Button>
]}>
<Input
value={customFileName}
onChange={(e) => setCustomFileName(e.target.value)}
placeholder={t('settings.data.local.backup.modal.filename.placeholder')}
/>
</Modal>
)
}
// 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
}
}

View File

@@ -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": {

View File

@@ -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": "画像としてエクスポート",

View File

@@ -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": "Экспорт как изображение",

View File

@@ -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": "导出为图片",

View File

@@ -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": "匯出為圖片",

View File

@@ -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)
}

View File

@@ -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: <FolderCog size={16} /> },
{ key: 'divider_1', isDivider: true, text: t('settings.data.divider.cloud_storage') },
{ key: 'local_backup', title: 'settings.data.local.title', icon: <FolderCog size={16} /> },
{ key: 'webdav', title: 'settings.data.webdav.title', icon: <CloudSyncOutlined style={{ fontSize: 16 }} /> },
{ key: 'nutstore', title: 'settings.data.nutstore.title', icon: <NutstoreIcon /> },
{ key: 's3', title: 'settings.data.s3.title', icon: <CloudServerOutlined style={{ fontSize: 16 }} /> },
@@ -665,6 +667,7 @@ const DataSettings: FC = () => {
{menu === 'obsidian' && <ObsidianSettings />}
{menu === 'siyuan' && <SiyuanSettings />}
{menu === 'agentssubscribe_url' && <AgentsSubscribeUrlSettings />}
{menu === 'local_backup' && <LocalBackupSettings />}
</SettingContainer>
</Container>
)

View File

@@ -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<string | undefined>(localBackupDirSetting)
const [localBackupSkipBackupFile, setLocalBackupSkipBackupFile] = useState<boolean>(localBackupSkipBackupFileSetting)
const [backupManagerVisible, setBackupManagerVisible] = useState(false)
const [syncInterval, setSyncInterval] = useState<number>(localBackupSyncIntervalSetting)
const [maxBackups, setMaxBackups] = useState<number>(localBackupMaxBackupsSetting)
const [appInfo, setAppInfo] = useState<AppInfo>()
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 <span style={{ color: 'var(--text-secondary)' }}>{t('settings.data.local.noSync')}</span>
}
return (
<HStack gap="5px" alignItems="center">
{localBackupSync.syncing && <SyncOutlined spin />}
{!localBackupSync.syncing && localBackupSync.lastSyncError && (
<Tooltip title={`${t('settings.data.local.syncError')}: ${localBackupSync.lastSyncError}`}>
<WarningOutlined style={{ color: 'red' }} />
</Tooltip>
)}
{localBackupSync.lastSyncTime && (
<span style={{ color: 'var(--text-secondary)' }}>
{t('settings.data.local.lastSync')}: {dayjs(localBackupSync.lastSyncTime).format('HH:mm:ss')}
</span>
)}
</HStack>
)
}
const { isModalVisible, handleBackup, handleCancel, backuping, customFileName, setCustomFileName, showBackupModal } =
useLocalBackupModal(localBackupDir)
const showBackupManager = () => {
setBackupManagerVisible(true)
}
const closeBackupManager = () => {
setBackupManagerVisible(false)
}
return (
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.data.local.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.local.directory')}</SettingRowTitle>
<HStack gap="5px">
<Input
value={localBackupDir}
readOnly
style={{ width: 250 }}
placeholder={t('settings.data.local.directory.placeholder')}
/>
<Button icon={<FolderOpenOutlined />} onClick={handleBrowseDirectory}>
{t('common.browse')}
</Button>
<Button icon={<DeleteOutlined />} onClick={handleClearDirectory} disabled={!localBackupDir} danger>
{t('common.clear')}
</Button>
</HStack>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle>
<HStack gap="5px" justifyContent="space-between">
<Button onClick={showBackupModal} icon={<SaveOutlined />} loading={backuping} disabled={!localBackupDir}>
{t('settings.data.local.backup.button')}
</Button>
<Button onClick={showBackupManager} icon={<FolderOpenOutlined />} disabled={!localBackupDir}>
{t('settings.data.local.restore.button')}
</Button>
</HStack>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.local.autoSync')}</SettingRowTitle>
<Select value={syncInterval} onChange={onSyncIntervalChange} disabled={!localBackupDir} style={{ width: 120 }}>
<Select.Option value={0}>{t('settings.data.local.autoSync.off')}</Select.Option>
<Select.Option value={1}>{t('settings.data.local.minute_interval', { count: 1 })}</Select.Option>
<Select.Option value={5}>{t('settings.data.local.minute_interval', { count: 5 })}</Select.Option>
<Select.Option value={15}>{t('settings.data.local.minute_interval', { count: 15 })}</Select.Option>
<Select.Option value={30}>{t('settings.data.local.minute_interval', { count: 30 })}</Select.Option>
<Select.Option value={60}>{t('settings.data.local.hour_interval', { count: 1 })}</Select.Option>
<Select.Option value={120}>{t('settings.data.local.hour_interval', { count: 2 })}</Select.Option>
<Select.Option value={360}>{t('settings.data.local.hour_interval', { count: 6 })}</Select.Option>
<Select.Option value={720}>{t('settings.data.local.hour_interval', { count: 12 })}</Select.Option>
<Select.Option value={1440}>{t('settings.data.local.hour_interval', { count: 24 })}</Select.Option>
</Select>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.local.maxBackups')}</SettingRowTitle>
<Select value={maxBackups} onChange={onMaxBackupsChange} disabled={!localBackupDir} style={{ width: 120 }}>
<Select.Option value={0}>{t('settings.data.local.maxBackups.unlimited')}</Select.Option>
<Select.Option value={1}>1</Select.Option>
<Select.Option value={3}>3</Select.Option>
<Select.Option value={5}>5</Select.Option>
<Select.Option value={10}>10</Select.Option>
<Select.Option value={20}>20</Select.Option>
<Select.Option value={50}>50</Select.Option>
</Select>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.backup.skip_file_data_title')}</SettingRowTitle>
<Switch checked={localBackupSkipBackupFile} onChange={onSkipBackupFilesChange} />
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.backup.skip_file_data_help')}</SettingHelpText>
</SettingRow>
{localBackupSync && syncInterval > 0 && (
<>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.local.syncStatus')}</SettingRowTitle>
{renderSyncStatus()}
</SettingRow>
</>
)}
<>
<LocalBackupModal
isModalVisible={isModalVisible}
handleBackup={handleBackup}
handleCancel={handleCancel}
backuping={backuping}
customFileName={customFileName}
setCustomFileName={setCustomFileName}
/>
<LocalBackupManager
visible={backupManagerVisible}
onClose={closeBackupManager}
localBackupDir={localBackupDir}
/>
</>
</SettingGroup>
)
}
export default LocalBackupSettings

View File

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

View File

@@ -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<Partial<RemoteSyncState>>) => {
state.webdavSync = { ...state.webdavSync, ...action.payload }
},
setLocalBackupSyncState: (state, action: PayloadAction<Partial<RemoteSyncState>>) => {
state.localBackupSync = { ...state.localBackupSync, ...action.payload }
},
setS3SyncState: (state, action: PayloadAction<Partial<RemoteSyncState>>) => {
state.s3Sync = { ...state.s3Sync, ...action.payload }
}
}
})
export const { setWebDAVSyncState, setS3SyncState } = backupSlice.actions
export const { setWebDAVSyncState, setLocalBackupSyncState, setS3SyncState } = backupSlice.actions
export default backupSlice.reducer

View File

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

View File

@@ -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<SettingsState['notification']>) => {
state.notification = action.payload
},
// Local backup settings
setLocalBackupDir: (state, action: PayloadAction<string>) => {
state.localBackupDir = action.payload
},
setLocalBackupAutoSync: (state, action: PayloadAction<boolean>) => {
state.localBackupAutoSync = action.payload
},
setLocalBackupSyncInterval: (state, action: PayloadAction<number>) => {
state.localBackupSyncInterval = action.payload
},
setLocalBackupMaxBackups: (state, action: PayloadAction<number>) => {
state.localBackupMaxBackups = action.payload
},
setLocalBackupSkipBackupFile: (state, action: PayloadAction<boolean>) => {
state.localBackupSkipBackupFile = action.payload
},
setDefaultPaintingProvider: (state, action: PayloadAction<PaintingProvider>) => {
state.defaultPaintingProvider = action.payload
},
@@ -832,6 +860,12 @@ export const {
setOpenAISummaryText,
setOpenAIServiceTier,
setNotificationSettings,
// Local backup settings
setLocalBackupDir,
setLocalBackupAutoSync,
setLocalBackupSyncInterval,
setLocalBackupMaxBackups,
setLocalBackupSkipBackupFile,
setDefaultPaintingProvider,
setS3,
setS3Partial