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:
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
255
src/renderer/src/components/LocalBackupManager.tsx
Normal file
255
src/renderer/src/components/LocalBackupManager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
98
src/renderer/src/components/LocalBackupModals.tsx
Normal file
98
src/renderer/src/components/LocalBackupModals.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "画像としてエクスポート",
|
||||
|
||||
@@ -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": "Экспорт как изображение",
|
||||
|
||||
@@ -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": "导出为图片",
|
||||
|
||||
@@ -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": "匯出為圖片",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user