Revert "feat: Add S3 Backup (#6802)"

This reverts commit 3f5901766d.

# Conflicts:
#	src/renderer/src/i18n/locales/zh-cn.json
#	src/renderer/src/i18n/locales/zh-tw.json
This commit is contained in:
kangfenmao 2025-07-02 13:22:33 +08:00
parent 990ec5cd5c
commit 19212e576f
20 changed files with 122 additions and 1940 deletions

View File

@ -153,11 +153,6 @@ export enum IpcChannel {
Backup_CheckConnection = 'backup:checkConnection',
Backup_CreateDirectory = 'backup:createDirectory',
Backup_DeleteWebdavFile = 'backup:deleteWebdavFile',
Backup_BackupToS3 = 'backup:backupToS3',
Backup_RestoreFromS3 = 'backup:restoreFromS3',
Backup_ListS3Files = 'backup:listS3Files',
Backup_DeleteS3File = 'backup:deleteS3File',
Backup_CheckS3Connection = 'backup:checkS3Connection',
// zip
Zip_Compress = 'zip:compress',

View File

@ -344,11 +344,6 @@ 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_BackupToS3, backupManager.backupToS3)
ipcMain.handle(IpcChannel.Backup_RestoreFromS3, backupManager.restoreFromS3)
ipcMain.handle(IpcChannel.Backup_ListS3Files, backupManager.listS3Files)
ipcMain.handle(IpcChannel.Backup_DeleteS3File, backupManager.deleteS3File)
ipcMain.handle(IpcChannel.Backup_CheckS3Connection, backupManager.checkS3Connection)
// file
ipcMain.handle(IpcChannel.File_Open, fileManager.open)

View File

@ -1,6 +1,5 @@
import { IpcChannel } from '@shared/IpcChannel'
import { WebDavConfig } from '@types'
import { S3Config } from '@types'
import archiver from 'archiver'
import { exec } from 'child_process'
import { app } from 'electron'
@ -11,7 +10,6 @@ import * as path from 'path'
import { CreateDirectoryOptions, FileStat } from 'webdav'
import { getDataPath } from '../utils'
import S3Storage from './RemoteStorage'
import WebDav from './WebDav'
import { windowService } from './WindowService'
@ -27,11 +25,6 @@ class BackupManager {
this.restoreFromWebdav = this.restoreFromWebdav.bind(this)
this.listWebdavFiles = this.listWebdavFiles.bind(this)
this.deleteWebdavFile = this.deleteWebdavFile.bind(this)
this.backupToS3 = this.backupToS3.bind(this)
this.restoreFromS3 = this.restoreFromS3.bind(this)
this.listS3Files = this.listS3Files.bind(this)
this.deleteS3File = this.deleteS3File.bind(this)
this.checkS3Connection = this.checkS3Connection.bind(this)
}
private async setWritableRecursive(dirPath: string): Promise<void> {
@ -92,11 +85,7 @@ class BackupManager {
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
mainWindow?.webContents.send(IpcChannel.BackupProgress, processData)
// 只在关键阶段记录日志:开始、结束和主要阶段转换点
const logStages = ['preparing', 'writing_data', 'preparing_compression', 'completed']
if (logStages.includes(processData.stage) || processData.progress === 100) {
Logger.log('[BackupManager] backup progress', processData)
}
Logger.log('[BackupManager] backup progress', processData)
}
try {
@ -158,23 +147,18 @@ class BackupManager {
let totalBytes = 0
let processedBytes = 0
// 首先计算总文件数和总大小,但不记录详细日志
// 首先计算总文件数和总大小
const calculateTotals = async (dirPath: string) => {
try {
const items = await fs.readdir(dirPath, { withFileTypes: true })
for (const item of items) {
const fullPath = path.join(dirPath, item.name)
if (item.isDirectory()) {
await calculateTotals(fullPath)
} else {
totalEntries++
const stats = await fs.stat(fullPath)
totalBytes += stats.size
}
const items = await fs.readdir(dirPath, { withFileTypes: true })
for (const item of items) {
const fullPath = path.join(dirPath, item.name)
if (item.isDirectory()) {
await calculateTotals(fullPath)
} else {
totalEntries++
const stats = await fs.stat(fullPath)
totalBytes += stats.size
}
} catch (error) {
// 仅在出错时记录日志
Logger.error('[BackupManager] Error calculating totals:', error)
}
}
@ -246,11 +230,7 @@ class BackupManager {
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
mainWindow?.webContents.send(IpcChannel.RestoreProgress, processData)
// 只在关键阶段记录日志
const logStages = ['preparing', 'extracting', 'extracted', 'reading_data', 'completed']
if (logStages.includes(processData.stage) || processData.progress === 100) {
Logger.log('[BackupManager] restore progress', processData)
}
Logger.log('[BackupManager] restore progress', processData)
}
try {
@ -402,54 +382,21 @@ class BackupManager {
destination: string,
onProgress: (size: number) => void
): Promise<void> {
// 先统计总文件数
let totalFiles = 0
let processedFiles = 0
let lastProgressReported = 0
const items = await fs.readdir(source, { withFileTypes: true })
// 计算总文件数
const countFiles = async (dir: string): Promise<number> => {
let count = 0
const items = await fs.readdir(dir, { withFileTypes: true })
for (const item of items) {
if (item.isDirectory()) {
count += await countFiles(path.join(dir, item.name))
} else {
count++
}
}
return count
}
for (const item of items) {
const sourcePath = path.join(source, item.name)
const destPath = path.join(destination, item.name)
totalFiles = await countFiles(source)
// 复制文件并更新进度
const copyDir = async (src: string, dest: string): Promise<void> => {
const items = await fs.readdir(src, { withFileTypes: true })
for (const item of items) {
const sourcePath = path.join(src, item.name)
const destPath = path.join(dest, item.name)
if (item.isDirectory()) {
await fs.ensureDir(destPath)
await copyDir(sourcePath, destPath)
} else {
const stats = await fs.stat(sourcePath)
await fs.copy(sourcePath, destPath)
processedFiles++
// 只在进度变化超过5%时报告进度
const currentProgress = Math.floor((processedFiles / totalFiles) * 100)
if (currentProgress - lastProgressReported >= 5 || processedFiles === totalFiles) {
lastProgressReported = currentProgress
onProgress(stats.size)
}
}
if (item.isDirectory()) {
await fs.ensureDir(destPath)
await this.copyDirWithProgress(sourcePath, destPath, onProgress)
} else {
const stats = await fs.stat(sourcePath)
await fs.copy(sourcePath, destPath)
onProgress(stats.size)
}
}
await copyDir(source, destination)
}
async checkConnection(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) {
@ -476,141 +423,6 @@ class BackupManager {
throw new Error(error.message || 'Failed to delete backup file')
}
}
async backupToS3(_: Electron.IpcMainInvokeEvent, data: string, s3Config: S3Config) {
// 获取设备名
const os = require('os')
const deviceName = os.hostname ? os.hostname() : 'device'
const timestamp = new Date()
.toISOString()
.replace(/[-:T.Z]/g, '')
.slice(0, 14)
const filename = s3Config.fileName || `cherry-studio.backup.${deviceName}.${timestamp}.zip`
// 不记录详细日志,只记录开始和结束
Logger.log(`[BackupManager] Starting S3 backup to ${filename}`)
const backupedFilePath = await this.backup(_, filename, data, undefined, s3Config.skipBackupFile)
const s3Client = new S3Storage('s3', {
endpoint: s3Config.endpoint,
region: s3Config.region,
bucket: s3Config.bucket,
access_key_id: s3Config.access_key_id,
secret_access_key: s3Config.secret_access_key,
root: s3Config.root || ''
})
try {
const fileBuffer = await fs.promises.readFile(backupedFilePath)
const result = await s3Client.putFileContents(filename, fileBuffer)
await fs.remove(backupedFilePath)
Logger.log(`[BackupManager] S3 backup completed successfully: ${filename}`)
return result
} catch (error) {
Logger.error(`[BackupManager] S3 backup failed:`, error)
await fs.remove(backupedFilePath)
throw error
}
}
async restoreFromS3(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) {
const filename = s3Config.fileName || 'cherry-studio.backup.zip'
// 只记录开始和结束或错误
Logger.log(`[BackupManager] Starting restore from S3: ${filename}`)
const s3Client = new S3Storage('s3', {
endpoint: s3Config.endpoint,
region: s3Config.region,
bucket: s3Config.bucket,
access_key_id: s3Config.access_key_id,
secret_access_key: s3Config.secret_access_key,
root: s3Config.root || ''
})
try {
const retrievedFile = await s3Client.getFileContents(filename)
const backupedFilePath = path.join(this.backupDir, filename)
if (!fs.existsSync(this.backupDir)) {
fs.mkdirSync(this.backupDir, { recursive: true })
}
await new Promise<void>((resolve, reject) => {
const writeStream = fs.createWriteStream(backupedFilePath)
writeStream.write(retrievedFile as Buffer)
writeStream.end()
writeStream.on('finish', () => resolve())
writeStream.on('error', (error) => reject(error))
})
Logger.log(`[BackupManager] S3 restore file downloaded successfully: ${filename}`)
return await this.restore(_, backupedFilePath)
} catch (error: any) {
Logger.error('[BackupManager] Failed to restore from S3:', error)
throw new Error(error.message || 'Failed to restore backup file')
}
}
listS3Files = async (_: Electron.IpcMainInvokeEvent, s3Config: S3Config) => {
try {
const s3Client = new S3Storage('s3', {
endpoint: s3Config.endpoint,
region: s3Config.region,
bucket: s3Config.bucket,
access_key_id: s3Config.access_key_id,
secret_access_key: s3Config.secret_access_key,
root: s3Config.root || ''
})
const entries = await s3Client.instance?.list('/')
const files: Array<{ fileName: string; modifiedTime: string; size: number }> = []
if (entries) {
for await (const entry of entries) {
const path = entry.path()
if (path.endsWith('.zip')) {
const meta = await s3Client.instance!.stat(path)
if (meta.isFile()) {
files.push({
fileName: path.replace(/^\/+/, ''),
modifiedTime: meta.lastModified || '',
size: Number(meta.contentLength || 0n)
})
}
}
}
}
return files.sort((a, b) => new Date(b.modifiedTime).getTime() - new Date(a.modifiedTime).getTime())
} catch (error: any) {
Logger.error('Failed to list S3 files:', error)
throw new Error(error.message || 'Failed to list backup files')
}
}
async deleteS3File(_: Electron.IpcMainInvokeEvent, fileName: string, s3Config: S3Config) {
try {
const s3Client = new S3Storage('s3', {
endpoint: s3Config.endpoint,
region: s3Config.region,
bucket: s3Config.bucket,
access_key_id: s3Config.access_key_id,
secret_access_key: s3Config.secret_access_key,
root: s3Config.root || ''
})
return await s3Client.deleteFile(fileName)
} catch (error: any) {
Logger.error('Failed to delete S3 file:', error)
throw new Error(error.message || 'Failed to delete backup file')
}
}
async checkS3Connection(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) {
const s3Client = new S3Storage('s3', {
endpoint: s3Config.endpoint,
region: s3Config.region,
bucket: s3Config.bucket,
access_key_id: s3Config.access_key_id,
secret_access_key: s3Config.secret_access_key,
root: s3Config.root || ''
})
return await s3Client.checkConnection()
}
}
export default BackupManager

View File

@ -1,83 +1,57 @@
import Logger from 'electron-log'
import type { Operator as OperatorType } from 'opendal'
const { Operator } = require('opendal')
// import Logger from 'electron-log'
// import { Operator } from 'opendal'
export default class S3Storage {
public instance: OperatorType | undefined
// export default class RemoteStorage {
// public instance: Operator | undefined
/**
*
* @param scheme is the scheme for opendal services. Available value includes "azblob", "azdls", "cos", "gcs", "obs", "oss", "s3", "webdav", "webhdfs", "aliyun-drive", "alluxio", "azfile", "dropbox", "gdrive", "onedrive", "postgresql", "mysql", "redis", "swift", "mongodb", "alluxio", "b2", "seafile", "upyun", "koofr", "yandex-disk"
* @param options is the options for given opendal services. Valid options depend on the scheme. Checkout https://docs.rs/opendal/latest/opendal/services/index.html for all valid options.
*
* For example, use minio as remote storage:
*
* ```typescript
* const storage = new S3Storage('s3', {
* endpoint: 'http://localhost:9000',
* region: 'us-east-1',
* bucket: 'testbucket',
* access_key_id: 'user',
* secret_access_key: 'password',
* root: '/path/to/basepath',
* })
* ```
*/
constructor(scheme: string, options?: Record<string, string> | undefined | null) {
this.instance = new Operator(scheme, options)
// /**
// *
// * @param scheme is the scheme for opendal services. Available value includes "azblob", "azdls", "cos", "gcs", "obs", "oss", "s3", "webdav", "webhdfs", "aliyun-drive", "alluxio", "azfile", "dropbox", "gdrive", "onedrive", "postgresql", "mysql", "redis", "swift", "mongodb", "alluxio", "b2", "seafile", "upyun", "koofr", "yandex-disk"
// * @param options is the options for given opendal services. Valid options depend on the scheme. Checkout https://docs.rs/opendal/latest/opendal/services/index.html for all valid options.
// *
// * For example, use minio as remote storage:
// *
// * ```typescript
// * const storage = new RemoteStorage('s3', {
// * endpoint: 'http://localhost:9000',
// * region: 'us-east-1',
// * bucket: 'testbucket',
// * access_key_id: 'user',
// * secret_access_key: 'password',
// * root: '/path/to/basepath',
// * })
// * ```
// */
// constructor(scheme: string, options?: Record<string, string> | undefined | null) {
// this.instance = new Operator(scheme, options)
this.putFileContents = this.putFileContents.bind(this)
this.getFileContents = this.getFileContents.bind(this)
}
// this.putFileContents = this.putFileContents.bind(this)
// this.getFileContents = this.getFileContents.bind(this)
// }
public putFileContents = async (filename: string, data: string | Buffer) => {
if (!this.instance) {
return new Error('RemoteStorage client not initialized')
}
// public putFileContents = async (filename: string, data: string | Buffer) => {
// if (!this.instance) {
// return new Error('RemoteStorage client not initialized')
// }
try {
return await this.instance.write(filename, data)
} catch (error) {
Logger.error('[RemoteStorage] Error putting file contents:', error)
throw error
}
}
// try {
// return await this.instance.write(filename, data)
// } catch (error) {
// Logger.error('[RemoteStorage] Error putting file contents:', error)
// throw error
// }
// }
public getFileContents = async (filename: string) => {
if (!this.instance) {
throw new Error('RemoteStorage client not initialized')
}
// public getFileContents = async (filename: string) => {
// if (!this.instance) {
// throw new Error('RemoteStorage client not initialized')
// }
try {
return await this.instance.read(filename)
} catch (error) {
Logger.error('[RemoteStorage] Error getting file contents:', error)
throw error
}
}
public deleteFile = async (filename: string) => {
if (!this.instance) {
throw new Error('RemoteStorage client not initialized')
}
try {
return await this.instance.delete(filename)
} catch (error) {
Logger.error('[RemoteStorage] Error deleting file:', error)
throw error
}
}
public checkConnection = async () => {
if (!this.instance) {
throw new Error('RemoteStorage client not initialized')
}
try {
// 检查根目录是否可访问
return await this.instance.stat('/')
} catch (error) {
Logger.error('[RemoteStorage] Error checking connection:', error)
throw error
}
}
}
// try {
// return await this.instance.read(filename)
// } catch (error) {
// Logger.error('[RemoteStorage] Error getting file contents:', error)
// throw error
// }
// }
// }

View File

@ -2,16 +2,7 @@ import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { electronAPI } from '@electron-toolkit/preload'
import { UpgradeChannel } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import {
FileType,
KnowledgeBaseParams,
KnowledgeItem,
MCPServer,
S3Config,
Shortcut,
ThemeMode,
WebDavConfig
} from '@types'
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, ThemeMode, WebDavConfig } from '@types'
import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron'
import { Notification } from 'src/renderer/src/types/notification'
import { CreateDirectoryOptions } from 'webdav'
@ -80,13 +71,7 @@ const api = {
createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) =>
ipcRenderer.invoke(IpcChannel.Backup_CreateDirectory, webdavConfig, path, options),
deleteWebdavFile: (fileName: string, webdavConfig: WebDavConfig) =>
ipcRenderer.invoke(IpcChannel.Backup_DeleteWebdavFile, fileName, webdavConfig),
backupToS3: (data: string, s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_BackupToS3, data, s3Config),
restoreFromS3: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_RestoreFromS3, s3Config),
listS3Files: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_ListS3Files, s3Config),
deleteS3File: (fileName: string, s3Config: S3Config) =>
ipcRenderer.invoke(IpcChannel.Backup_DeleteS3File, fileName, s3Config),
checkS3Connection: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_CheckS3Connection, s3Config)
ipcRenderer.invoke(IpcChannel.Backup_DeleteWebdavFile, fileName, webdavConfig)
},
file: {
select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options),

View File

@ -1,298 +0,0 @@
import { DeleteOutlined, ExclamationCircleOutlined, ReloadOutlined } from '@ant-design/icons'
import { restoreFromS3 } from '@renderer/services/BackupService'
import { formatFileSize } from '@renderer/utils'
import { Button, 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 S3Config {
endpoint: string
region: string
bucket: string
access_key_id: string
secret_access_key: string
root?: string
}
interface S3BackupManagerProps {
visible: boolean
onClose: () => void
s3Config: {
endpoint?: string
region?: string
bucket?: string
access_key_id?: string
secret_access_key?: string
root?: string
}
restoreMethod?: (fileName: string) => Promise<void>
}
export function S3BackupManager({ visible, onClose, s3Config, restoreMethod }: S3BackupManagerProps) {
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 { t } = useTranslation()
const { endpoint, region, bucket, access_key_id, secret_access_key, root } = s3Config
const fetchBackupFiles = useCallback(async () => {
if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) {
window.message.error(t('settings.data.s3.manager.config.incomplete'))
return
}
setLoading(true)
try {
const files = await window.api.backup.listS3Files({
endpoint,
region,
bucket,
access_key_id,
secret_access_key,
root
} as S3Config)
setBackupFiles(files)
setPagination((prev) => ({
...prev,
total: files.length
}))
} catch (error: any) {
window.message.error(t('settings.data.s3.manager.files.fetch.error', { message: error.message }))
} finally {
setLoading(false)
}
}, [endpoint, region, bucket, access_key_id, secret_access_key, root, 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) {
window.message.warning(t('settings.data.s3.manager.select.warning'))
return
}
if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) {
window.message.error(t('settings.data.s3.manager.config.incomplete'))
return
}
window.modal.confirm({
title: t('settings.data.s3.manager.delete.confirm.title'),
icon: <ExclamationCircleOutlined />,
content: t('settings.data.s3.manager.delete.confirm.multiple', { count: selectedRowKeys.length }),
okText: t('settings.data.s3.manager.delete.confirm.title'),
cancelText: t('common.cancel'),
centered: true,
onOk: async () => {
setDeleting(true)
try {
// 依次删除选中的文件
for (const key of selectedRowKeys) {
await window.api.backup.deleteS3File(key.toString(), {
endpoint,
region,
bucket,
access_key_id,
secret_access_key,
root
} as S3Config)
}
window.message.success(
t('settings.data.s3.manager.delete.success.multiple', { count: selectedRowKeys.length })
)
setSelectedRowKeys([])
await fetchBackupFiles()
} catch (error: any) {
window.message.error(t('settings.data.s3.manager.delete.error', { message: error.message }))
} finally {
setDeleting(false)
}
}
})
}
const handleDeleteSingle = async (fileName: string) => {
if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) {
window.message.error(t('settings.data.s3.manager.config.incomplete'))
return
}
window.modal.confirm({
title: t('settings.data.s3.manager.delete.confirm.title'),
icon: <ExclamationCircleOutlined />,
content: t('settings.data.s3.manager.delete.confirm.single', { fileName }),
okText: t('settings.data.s3.manager.delete.confirm.title'),
cancelText: t('common.cancel'),
centered: true,
onOk: async () => {
setDeleting(true)
try {
await window.api.backup.deleteS3File(fileName, {
endpoint,
region,
bucket,
access_key_id,
secret_access_key,
root
} as S3Config)
window.message.success(t('settings.data.s3.manager.delete.success.single'))
await fetchBackupFiles()
} catch (error: any) {
window.message.error(t('settings.data.s3.manager.delete.error', { message: error.message }))
} finally {
setDeleting(false)
}
}
})
}
const handleRestore = async (fileName: string) => {
if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) {
window.message.error(t('settings.data.s3.manager.config.incomplete'))
return
}
window.modal.confirm({
title: t('settings.data.s3.restore.confirm.title'),
icon: <ExclamationCircleOutlined />,
content: t('settings.data.s3.restore.confirm.content'),
okText: t('settings.data.s3.restore.confirm.ok'),
cancelText: t('settings.data.s3.restore.confirm.cancel'),
centered: true,
onOk: async () => {
setRestoring(true)
try {
await (restoreMethod || restoreFromS3)(fileName)
window.message.success(t('settings.data.s3.restore.success'))
onClose() // 关闭模态框
} catch (error: any) {
window.message.error(t('settings.data.s3.restore.error', { message: error.message }))
} finally {
setRestoring(false)
}
}
})
}
const columns = [
{
title: t('settings.data.s3.manager.columns.fileName'),
dataIndex: 'fileName',
key: 'fileName',
ellipsis: {
showTitle: false
},
render: (fileName: string) => (
<Tooltip placement="topLeft" title={fileName}>
{fileName}
</Tooltip>
)
},
{
title: t('settings.data.s3.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.s3.manager.columns.size'),
dataIndex: 'size',
key: 'size',
width: 120,
render: (size: number) => formatFileSize(size)
},
{
title: t('settings.data.s3.manager.columns.actions'),
key: 'action',
width: 160,
render: (_: any, record: BackupFile) => (
<>
<Button type="link" onClick={() => handleRestore(record.fileName)} disabled={restoring || deleting}>
{t('settings.data.s3.manager.restore')}
</Button>
<Button
type="link"
danger
onClick={() => handleDeleteSingle(record.fileName)}
disabled={deleting || restoring}>
{t('settings.data.s3.manager.delete')}
</Button>
</>
)
}
]
const rowSelection = {
selectedRowKeys,
onChange: (selectedRowKeys: React.Key[]) => {
setSelectedRowKeys(selectedRowKeys)
}
}
return (
<Modal
title={t('settings.data.s3.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.s3.manager.refresh')}
</Button>,
<Button
key="delete"
danger
icon={<DeleteOutlined />}
onClick={handleDeleteSelected}
disabled={selectedRowKeys.length === 0 || deleting}
loading={deleting}>
{t('settings.data.s3.manager.delete.selected', { count: selectedRowKeys.length })}
</Button>,
<Button key="close" onClick={onClose}>
{t('settings.data.s3.manager.close')}
</Button>
]}>
<Table
rowKey="fileName"
columns={columns}
dataSource={backupFiles}
rowSelection={rowSelection}
pagination={pagination}
loading={loading}
onChange={handleTableChange}
size="middle"
/>
</Modal>
)
}

View File

@ -1,258 +0,0 @@
import { backupToS3, handleData } from '@renderer/services/BackupService'
import { formatFileSize } from '@renderer/utils'
import { Input, Modal, Select, Spin } from 'antd'
import dayjs from 'dayjs'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
interface BackupFile {
fileName: string
modifiedTime: string
size: number
}
export function useS3BackupModal() {
const [customFileName, setCustomFileName] = useState('')
const [isModalVisible, setIsModalVisible] = useState(false)
const [backuping, setBackuping] = useState(false)
const handleBackup = async () => {
setBackuping(true)
try {
await backupToS3({ customFileName, showMessage: true })
} finally {
setBackuping(false)
setIsModalVisible(false)
}
}
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)
}, [])
return {
isModalVisible,
handleBackup,
handleCancel,
backuping,
customFileName,
setCustomFileName,
showBackupModal
}
}
type S3BackupModalProps = {
isModalVisible: boolean
handleBackup: () => Promise<void>
handleCancel: () => void
backuping: boolean
customFileName: string
setCustomFileName: (value: string) => void
}
export function S3BackupModal({
isModalVisible,
handleBackup,
handleCancel,
backuping,
customFileName,
setCustomFileName
}: S3BackupModalProps) {
const { t } = useTranslation()
return (
<Modal
title={t('settings.data.s3.backup.modal.title')}
open={isModalVisible}
onOk={handleBackup}
onCancel={handleCancel}
okButtonProps={{ loading: backuping }}
transitionName="animation-move-down"
centered>
<Input
value={customFileName}
onChange={(e) => setCustomFileName(e.target.value)}
placeholder={t('settings.data.s3.backup.modal.filename.placeholder')}
/>
</Modal>
)
}
interface UseS3RestoreModalProps {
endpoint: string | undefined
region: string | undefined
bucket: string | undefined
access_key_id: string | undefined
secret_access_key: string | undefined
root?: string | undefined
}
export function useS3RestoreModal({
endpoint,
region,
bucket,
access_key_id,
secret_access_key,
root
}: UseS3RestoreModalProps) {
const [isRestoreModalVisible, setIsRestoreModalVisible] = useState(false)
const [restoring, setRestoring] = useState(false)
const [selectedFile, setSelectedFile] = useState<string | null>(null)
const [loadingFiles, setLoadingFiles] = useState(false)
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([])
const { t } = useTranslation()
const showRestoreModal = useCallback(async () => {
if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) {
window.message.error({ content: t('settings.data.s3.manager.config.incomplete'), key: 's3-error' })
return
}
setIsRestoreModalVisible(true)
setLoadingFiles(true)
try {
const files = await window.api.backup.listS3Files({
endpoint,
region,
bucket,
access_key_id,
secret_access_key,
root
})
setBackupFiles(files)
} catch (error: any) {
window.message.error({
content: t('settings.data.s3.manager.files.fetch.error', { message: error.message }),
key: 'list-files-error'
})
} finally {
setLoadingFiles(false)
}
}, [endpoint, region, bucket, access_key_id, secret_access_key, root, t])
const handleRestore = useCallback(async () => {
if (!selectedFile || !endpoint || !region || !bucket || !access_key_id || !secret_access_key) {
window.message.error({
content: !selectedFile
? t('settings.data.s3.restore.file.required')
: t('settings.data.s3.restore.config.incomplete'),
key: 'restore-error'
})
return
}
window.modal.confirm({
title: t('settings.data.s3.restore.confirm.title'),
content: t('settings.data.s3.restore.confirm.content'),
okText: t('settings.data.s3.restore.confirm.ok'),
cancelText: t('settings.data.s3.restore.confirm.cancel'),
centered: true,
onOk: async () => {
setRestoring(true)
try {
const data = await window.api.backup.restoreFromS3({
endpoint,
region,
bucket,
access_key_id,
secret_access_key,
root,
fileName: selectedFile
})
await handleData(JSON.parse(data))
window.message.success(t('settings.data.s3.restore.success'))
setIsRestoreModalVisible(false)
} catch (error: any) {
window.message.error({
content: t('settings.data.s3.restore.error', { message: error.message }),
key: 'restore-error'
})
} finally {
setRestoring(false)
}
}
})
}, [selectedFile, endpoint, region, bucket, access_key_id, secret_access_key, root, t])
const handleCancel = () => {
setIsRestoreModalVisible(false)
}
return {
isRestoreModalVisible,
handleRestore,
handleCancel,
restoring,
selectedFile,
setSelectedFile,
loadingFiles,
backupFiles,
showRestoreModal
}
}
type S3RestoreModalProps = ReturnType<typeof useS3RestoreModal>
export function S3RestoreModal({
isRestoreModalVisible,
handleRestore,
handleCancel,
restoring,
selectedFile,
setSelectedFile,
loadingFiles,
backupFiles
}: S3RestoreModalProps) {
const { t } = useTranslation()
return (
<Modal
title={t('settings.data.s3.restore.modal.title')}
open={isRestoreModalVisible}
onOk={handleRestore}
onCancel={handleCancel}
okButtonProps={{ loading: restoring }}
width={600}
transitionName="animation-move-down"
centered>
<div style={{ position: 'relative' }}>
<Select
style={{ width: '100%' }}
placeholder={t('settings.data.s3.restore.modal.select.placeholder')}
value={selectedFile}
onChange={setSelectedFile}
options={backupFiles.map(formatFileOption)}
loading={loadingFiles}
showSearch
filterOption={(input, option) =>
typeof option?.label === 'string' ? option.label.toLowerCase().includes(input.toLowerCase()) : false
}
/>
{loadingFiles && (
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}>
<Spin />
</div>
)}
</div>
</Modal>
)
}
function formatFileOption(file: BackupFile) {
const date = dayjs(file.modifiedTime).format('YYYY-MM-DD HH:mm:ss')
const size = formatFileSize(file.size)
return {
label: `${file.fileName} (${date}, ${size})`,
value: file.fileName
}
}

View File

@ -1248,70 +1248,6 @@
"maxBackups": "Maximum Backups",
"maxBackups.unlimited": "Unlimited"
},
"s3": {
"title": "S3 Compatible Storage",
"title.help": "Object storage services compatible with AWS S3 API, such as AWS S3, Cloudflare R2, Alibaba Cloud OSS, Tencent Cloud COS, etc.",
"endpoint": "API Endpoint",
"endpoint.placeholder": "https://s3.example.com",
"region": "Region",
"region.placeholder": "Region, e.g: us-east-1",
"bucket": "Bucket",
"bucket.placeholder": "Bucket, e.g: example",
"accessKeyId": "Access Key ID",
"accessKeyId.placeholder": "Access Key ID",
"secretAccessKey": "Secret Access Key",
"secretAccessKey.placeholder": "Secret Access Key",
"root": "Backup Directory (Optional)",
"root.placeholder": "e.g: /cherry-studio",
"backup.operation": "Backup Operation",
"backup.button": "Backup Now",
"backup.manager.button": "Manage Backups",
"backup.modal.title": "S3 Backup",
"backup.modal.filename.placeholder": "Please enter backup filename",
"backup.success": "S3 backup successful",
"backup.error": "S3 backup failed: {{message}}",
"autoSync": "Auto Sync",
"autoSync.off": "Off",
"autoSync.minute": "Every {{count}} minute",
"autoSync.hour": "Every {{count}} hour",
"maxBackups": "Maximum Backups",
"maxBackups.unlimited": "Unlimited",
"skipBackupFile": "Lightweight Backup",
"skipBackupFile.help": "When enabled, file data will be skipped during backup, only configuration information will be backed up, significantly reducing backup file size",
"syncStatus": "Sync Status",
"syncStatus.noSync": "Not synced",
"syncStatus.error": "Sync error: {{message}}",
"syncStatus.lastSync": "Last sync: {{time}}",
"manager.title": "S3 Backup File Manager",
"manager.refresh": "Refresh",
"manager.delete.selected": "Delete Selected ({{count}})",
"manager.close": "Close",
"manager.columns.fileName": "File Name",
"manager.columns.modifiedTime": "Modified Time",
"manager.columns.size": "File Size",
"manager.columns.actions": "Actions",
"manager.restore": "Restore",
"manager.delete": "Delete",
"manager.config.incomplete": "Please fill in complete S3 configuration",
"manager.files.fetch.error": "Failed to fetch backup file list: {{message}}",
"manager.delete.confirm.title": "Confirm Delete",
"manager.delete.confirm.multiple": "Are you sure you want to delete {{count}} selected backup files? This action cannot be undone.",
"manager.delete.confirm.single": "Are you sure you want to delete backup file \"{{fileName}}\"? This action cannot be undone.",
"manager.delete.success.multiple": "Successfully deleted {{count}} backup files",
"manager.delete.success.single": "Backup file deleted successfully",
"manager.delete.error": "Failed to delete backup file: {{message}}",
"manager.select.warning": "Please select backup files to delete",
"restore.modal.title": "S3 Data Restore",
"restore.modal.select.placeholder": "Please select backup file to restore",
"restore.confirm.title": "Confirm Restore Data",
"restore.confirm.content": "Restoring data will overwrite all current data. This action cannot be undone. Are you sure you want to continue?",
"restore.confirm.ok": "Confirm Restore",
"restore.confirm.cancel": "Cancel",
"restore.success": "Data restore successful",
"restore.error": "Data restore failed: {{message}}",
"restore.config.incomplete": "Please fill in complete S3 configuration",
"restore.file.required": "Please select backup file to restore"
},
"yuque": {
"check": {
"button": "Check",

View File

@ -1228,70 +1228,6 @@
"maxBackups": "最大バックアップ数",
"maxBackups.unlimited": "無制限"
},
"s3": {
"title": "S3互換ストレージ",
"title.help": "AWS S3 APIと互換性のあるオブジェクトストレージサービスAWS S3、Cloudflare R2、Alibaba Cloud OSS、Tencent Cloud COSなど",
"endpoint": "APIエンドポイント",
"endpoint.placeholder": "https://s3.example.com",
"region": "リージョン",
"region.placeholder": "Region、例: us-east-1",
"bucket": "バケット",
"bucket.placeholder": "Bucket、例: example",
"accessKeyId": "Access Key ID",
"accessKeyId.placeholder": "Access Key ID",
"secretAccessKey": "Secret Access Key",
"secretAccessKey.placeholder": "Secret Access Key",
"root": "バックアップディレクトリ(オプション)",
"root.placeholder": "例:/cherry-studio",
"backup.operation": "バックアップ操作",
"backup.button": "今すぐバックアップ",
"backup.manager.button": "バックアップ管理",
"backup.modal.title": "S3バックアップ",
"backup.modal.filename.placeholder": "バックアップファイル名を入力してください",
"backup.success": "S3バックアップ成功",
"backup.error": "S3バックアップ失敗: {{message}}",
"autoSync": "自動同期",
"autoSync.off": "オフ",
"autoSync.minute": "{{count}}分毎",
"autoSync.hour": "{{count}}時間毎",
"maxBackups": "最大バックアップ数",
"maxBackups.unlimited": "無制限",
"skipBackupFile": "軽量バックアップ",
"skipBackupFile.help": "有効にすると、バックアップ時にファイルデータがスキップされ、設定情報のみがバックアップされ、バックアップファイルのサイズが大幅に削減されます。",
"syncStatus": "同期ステータス",
"syncStatus.noSync": "未同期",
"syncStatus.error": "同期エラー: {{message}}",
"syncStatus.lastSync": "最終同期: {{time}}",
"manager.title": "S3バックアップファイルマネージャー",
"manager.refresh": "更新",
"manager.delete.selected": "選択項目を削除 ({{count}})",
"manager.close": "閉じる",
"manager.columns.fileName": "ファイル名",
"manager.columns.modifiedTime": "変更日時",
"manager.columns.size": "ファイルサイズ",
"manager.columns.actions": "操作",
"manager.restore": "復元",
"manager.delete": "削除",
"manager.config.incomplete": "完全なS3設定情報を入力してください",
"manager.files.fetch.error": "バックアップファイルリストの取得に失敗しました: {{message}}",
"manager.delete.confirm.title": "削除の確認",
"manager.delete.confirm.multiple": "選択した{{count}}個のバックアップファイルを削除してもよろしいですか?この操作は元に戻せません。",
"manager.delete.confirm.single": "バックアップファイル「{{fileName}}」を削除してもよろしいですか?この操作は元に戻せません。",
"manager.delete.success.multiple": "{{count}}個のバックアップファイルを正常に削除しました",
"manager.delete.success.single": "バックアップファイルの削除に成功しました",
"manager.delete.error": "バックアップファイルの削除に失敗しました: {{message}}",
"manager.select.warning": "削除するバックアップファイルを選択してください",
"restore.modal.title": "S3データ復元",
"restore.modal.select.placeholder": "復元するバックアップファイルを選択してください",
"restore.confirm.title": "データ復元の確認",
"restore.confirm.content": "データを復元すると、現在のすべてのデータが上書きされます。この操作は元に戻せません。続行してもよろしいですか?",
"restore.confirm.ok": "復元を確認",
"restore.confirm.cancel": "キャンセル",
"restore.success": "データの復元に成功しました",
"restore.error": "データの復元に失敗しました: {{message}}",
"restore.config.incomplete": "完全なS3設定情報を入力してください",
"restore.file.required": "復元するバックアップファイルを選択してください"
},
"yuque": {
"check": {
"button": "接続確認",

View File

@ -1246,70 +1246,6 @@
"maxBackups": "Максимальное количество резервных копий",
"maxBackups.unlimited": "Без ограничений"
},
"s3": {
"title": "S3-совместимое хранилище",
"title.help": "Сервисы объектного хранения, совместимые с AWS S3 API, такие как AWS S3, Cloudflare R2, Alibaba Cloud OSS, Tencent Cloud COS и т.д.",
"endpoint": "Конечная точка API",
"endpoint.placeholder": "https://s3.example.com",
"region": "Регион",
"region.placeholder": "Регион, например: us-east-1",
"bucket": "Корзина",
"bucket.placeholder": "Корзина, например: example",
"accessKeyId": "Access Key ID",
"accessKeyId.placeholder": "Access Key ID",
"secretAccessKey": "Secret Access Key",
"secretAccessKey.placeholder": "Secret Access Key",
"root": "Каталог резервных копий (необязательно)",
"root.placeholder": "например: /cherry-studio",
"backup.operation": "Операция резервного копирования",
"backup.button": "Создать резервную копию сейчас",
"backup.manager.button": "Управление резервными копиями",
"backup.modal.title": "Резервное копирование S3",
"backup.modal.filename.placeholder": "Пожалуйста, введите имя файла резервной копии",
"backup.success": "Резервное копирование S3 успешно",
"backup.error": "Ошибка резервного копирования S3: {{message}}",
"autoSync": "Автосинхронизация",
"autoSync.off": "Выкл.",
"autoSync.minute": "Каждые {{count}} мин.",
"autoSync.hour": "Каждые {{count}} ч.",
"maxBackups": "Макс. резервных копий",
"maxBackups.unlimited": "Неограниченно",
"skipBackupFile": "Облегченное резервное копирование",
"skipBackupFile.help": "Если включено, данные файлов будут пропущены во время резервного копирования, будет скопирована только информация о конфигурации, что значительно уменьшит размер файла резервной копии.",
"syncStatus": "Статус синхронизации",
"syncStatus.noSync": "Не синхронизировано",
"syncStatus.error": "Ошибка синхронизации: {{message}}",
"syncStatus.lastSync": "Последняя синхронизация: {{time}}",
"manager.title": "Менеджер файлов резервных копий S3",
"manager.refresh": "Обновить",
"manager.delete.selected": "Удалить выбранные ({{count}})",
"manager.close": "Закрыть",
"manager.columns.fileName": "Имя файла",
"manager.columns.modifiedTime": "Время изменения",
"manager.columns.size": "Размер файла",
"manager.columns.actions": "Действия",
"manager.restore": "Восстановить",
"manager.delete": "Удалить",
"manager.config.incomplete": "Пожалуйста, заполните полную конфигурацию S3",
"manager.files.fetch.error": "Не удалось получить список файлов резервных копий: {{message}}",
"manager.delete.confirm.title": "Подтвердить удаление",
"manager.delete.confirm.multiple": "Вы уверены, что хотите удалить {{count}} выбранных файлов резервных копий? Это действие нельзя отменить.",
"manager.delete.confirm.single": "Вы уверены, что хотите удалить файл резервной копии \"{{fileName}}\"? Это действие нельзя отменить.",
"manager.delete.success.multiple": "Успешно удалено {{count}} файлов резервных копий",
"manager.delete.success.single": "Файл резервной копии успешно удален",
"manager.delete.error": "Не удалось удалить файл резервной копии: {{message}}",
"manager.select.warning": "Пожалуйста, выберите файлы резервных копий для удаления",
"restore.modal.title": "Восстановление данных S3",
"restore.modal.select.placeholder": "Пожалуйста, выберите файл резервной копии для восстановления",
"restore.confirm.title": "Подтвердить восстановление данных",
"restore.confirm.content": "Восстановление данных перезапишет все текущие данные. Это действие нельзя отменить. Вы уверены, что хотите продолжить?",
"restore.confirm.ok": "Подтвердить восстановление",
"restore.confirm.cancel": "Отмена",
"restore.success": "Восстановление данных успешно",
"restore.error": "Ошибка восстановления данных: {{message}}",
"restore.config.incomplete": "Пожалуйста, заполните полную конфигурацию S3",
"restore.file.required": "Пожалуйста, выберите файл резервной копии для восстановления"
},
"yuque": {
"check": {
"button": "Проверить",

View File

@ -1248,71 +1248,7 @@
"title": "WebDAV",
"user": "WebDAV 用户名",
"maxBackups": "最大备份数",
"maxBackups.unlimited": "不限"
},
"s3": {
"title": "S3 兼容存储",
"title.help": "与 AWS S3 API 兼容的对象存储服务,例如 AWS S3, Cloudflare R2, 阿里云 OSS, 腾讯云 COS 等",
"endpoint": "API 地址",
"endpoint.placeholder": "https://s3.example.com",
"region": "区域",
"region.placeholder": "Region, 例如: us-east-1",
"bucket": "存储桶",
"bucket.placeholder": "Bucket, 例如: example",
"accessKeyId": "Access Key ID",
"accessKeyId.placeholder": "Access Key ID",
"secretAccessKey": "Secret Access Key",
"secretAccessKey.placeholder": "Secret Access Key",
"root": "备份目录(可选)",
"root.placeholder": "例如:/cherry-studio",
"backup.operation": "备份操作",
"backup.button": "立即备份",
"backup.manager.button": "管理备份",
"backup.modal.title": "S3 备份",
"backup.modal.filename.placeholder": "请输入备份文件名",
"backup.success": "S3 备份成功",
"backup.error": "S3 备份失败: {{message}}",
"autoSync": "自动同步",
"autoSync.off": "关闭",
"autoSync.minute": "每 {{count}} 分钟",
"autoSync.hour": "每 {{count}} 小时",
"maxBackups": "最大备份数",
"maxBackups.unlimited": "不限",
"skipBackupFile": "精简备份",
"skipBackupFile.help": "开启后备份时将跳过文件数据,仅备份配置信息,显著减小备份文件体积",
"syncStatus": "同步状态",
"syncStatus.noSync": "未同步",
"syncStatus.error": "同步错误: {{message}}",
"syncStatus.lastSync": "上次同步: {{time}}",
"manager.title": "S3 备份文件管理",
"manager.refresh": "刷新",
"manager.delete.selected": "删除选中 ({{count}})",
"manager.close": "关闭",
"manager.columns.fileName": "文件名",
"manager.columns.modifiedTime": "修改时间",
"manager.columns.size": "文件大小",
"manager.columns.actions": "操作",
"manager.restore": "恢复",
"manager.delete": "删除",
"manager.config.incomplete": "请填写完整的 S3 配置信息",
"manager.files.fetch.error": "获取备份文件列表失败: {{message}}",
"manager.delete.confirm.title": "确认删除",
"manager.delete.confirm.multiple": "确定要删除选中的 {{count}} 个备份文件吗?此操作不可撤销。",
"manager.delete.confirm.single": "确定要删除备份文件 \"{{fileName}}\" 吗?此操作不可撤销。",
"manager.delete.success.multiple": "成功删除 {{count}} 个备份文件",
"manager.delete.success.single": "删除备份文件成功",
"manager.delete.error": "删除备份文件失败: {{message}}",
"manager.select.warning": "请选择要删除的备份文件",
"restore.modal.title": "S3 数据恢复",
"restore.modal.select.placeholder": "请选择要恢复的备份文件",
"restore.confirm.title": "确认恢复数据",
"restore.confirm.content": "恢复数据将覆盖当前所有数据,此操作不可撤销。确定要继续吗?",
"restore.confirm.ok": "确认恢复",
"restore.confirm.cancel": "取消",
"restore.success": "数据恢复成功",
"restore.error": "数据恢复失败: {{message}}",
"restore.config.incomplete": "请填写完整的 S3 配置信息",
"restore.file.required": "请选择要恢复的备份文件"
"maxBackups.unlimited": "无限制"
},
"yuque": {
"check": {

View File

@ -1246,71 +1246,7 @@
"title": "WebDAV",
"user": "WebDAV 使用者名稱",
"maxBackups": "最大備份數量",
"maxBackups.unlimited": "不限"
},
"s3": {
"title": "S3 相容儲存",
"title.help": "與 AWS S3 API 相容的物件儲存服務,例如 AWS S3、Cloudflare R2、阿里雲 OSS、騰訊雲 COS 等",
"endpoint": "API 位址",
"endpoint.placeholder": "https://s3.example.com",
"region": "區域",
"region.placeholder": "Region例如: us-east-1",
"bucket": "儲存桶",
"bucket.placeholder": "Bucket例如: example",
"accessKeyId": "Access Key ID",
"accessKeyId.placeholder": "Access Key ID",
"secretAccessKey": "Secret Access Key",
"secretAccessKey.placeholder": "Secret Access Key",
"root": "備份目錄(可選)",
"root.placeholder": "例如:/cherry-studio",
"backup.operation": "備份操作",
"backup.button": "立即備份",
"backup.manager.button": "管理備份",
"backup.modal.title": "S3 備份",
"backup.modal.filename.placeholder": "請輸入備份檔案名稱",
"backup.success": "S3 備份成功",
"backup.error": "S3 備份失敗: {{message}}",
"autoSync": "自動同步",
"autoSync.off": "關閉",
"autoSync.minute": "每 {{count}} 分鐘",
"autoSync.hour": "每 {{count}} 小時",
"maxBackups": "最大備份數",
"maxBackups.unlimited": "不限",
"skipBackupFile": "精簡備份",
"skipBackupFile.help": "開啟後備份時將跳過檔案資料,僅備份設定資訊,顯著減小備份檔案體積",
"syncStatus": "同步狀態",
"syncStatus.noSync": "未同步",
"syncStatus.error": "同步錯誤: {{message}}",
"syncStatus.lastSync": "上次同步: {{time}}",
"manager.title": "S3 備份檔案管理",
"manager.refresh": "重新整理",
"manager.delete.selected": "刪除選中 ({{count}})",
"manager.close": "關閉",
"manager.columns.fileName": "檔案名稱",
"manager.columns.modifiedTime": "修改時間",
"manager.columns.size": "檔案大小",
"manager.columns.actions": "操作",
"manager.restore": "恢復",
"manager.delete": "刪除",
"manager.config.incomplete": "請填寫完整的 S3 設定資訊",
"manager.files.fetch.error": "取得備份檔案清單失敗: {{message}}",
"manager.delete.confirm.title": "確認刪除",
"manager.delete.confirm.multiple": "確定要刪除選中的 {{count}} 個備份檔案嗎?此操作不可撤銷。",
"manager.delete.confirm.single": "確定要刪除備份檔案 \"{{fileName}}\" 嗎?此操作不可撤銷。",
"manager.delete.success.multiple": "成功刪除 {{count}} 個備份檔案",
"manager.delete.success.single": "刪除備份檔案成功",
"manager.delete.error": "刪除備份檔案失敗: {{message}}",
"manager.select.warning": "請選擇要刪除的備份檔案",
"restore.modal.title": "S3 資料恢復",
"restore.modal.select.placeholder": "請選擇要恢復的備份檔案",
"restore.confirm.title": "確認恢復資料",
"restore.confirm.content": "恢復資料將覆寫當前所有資料,此操作不可撤銷。確定要繼續嗎?",
"restore.confirm.ok": "確認恢復",
"restore.confirm.cancel": "取消",
"restore.success": "資料恢復成功",
"restore.error": "資料恢復失敗: {{message}}",
"restore.config.incomplete": "請填寫完整的 S3 設定資訊",
"restore.file.required": "請選擇要恢復的備份檔案"
"maxBackups.unlimited": "無限制"
},
"yuque": {
"check": {

View File

@ -12,9 +12,9 @@ function initKeyv() {
function initAutoSync() {
setTimeout(() => {
const { webdavAutoSync, s3 } = store.getState().settings
const { webdavAutoSync } = store.getState().settings
const { nutstoreAutoSync } = store.getState().nutstore
if (webdavAutoSync || (s3 && s3.autoSync)) {
if (webdavAutoSync) {
startAutoSync()
}
if (nutstoreAutoSync) {

View File

@ -1,5 +1,4 @@
import {
CloudServerOutlined,
CloudSyncOutlined,
FileSearchOutlined,
FolderOpenOutlined,
@ -43,7 +42,6 @@ import MarkdownExportSettings from './MarkdownExportSettings'
import NotionSettings from './NotionSettings'
import NutstoreSettings from './NutstoreSettings'
import ObsidianSettings from './ObsidianSettings'
import S3Settings from './S3Settings'
import SiyuanSettings from './SiyuanSettings'
import WebDavSettings from './WebDavSettings'
import YuqueSettings from './YuqueSettings'
@ -90,7 +88,6 @@ const DataSettings: FC = () => {
{ key: 'divider_1', isDivider: true, text: t('settings.data.divider.cloud_storage') },
{ 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 }} /> },
{ key: 'divider_2', isDivider: true, text: t('settings.data.divider.export_settings') },
{
key: 'export_menu',
@ -656,7 +653,6 @@ const DataSettings: FC = () => {
)}
{menu === 'webdav' && <WebDavSettings />}
{menu === 'nutstore' && <NutstoreSettings />}
{menu === 's3' && <S3Settings />}
{menu === 'export_menu' && <ExportMenuOptions />}
{menu === 'markdown_export' && <MarkdownExportSettings />}
{menu === 'notion' && <NotionSettings />}
@ -690,12 +686,8 @@ const MenuList = styled.div`
gap: 5px;
width: var(--settings-width);
padding: 12px;
padding-bottom: 48px;
border-right: 0.5px solid var(--color-border);
height: 100vh;
overflow: auto;
box-sizing: border-box;
min-height: 0;
height: 100%;
.iconfont {
color: var(--color-text-2);
line-height: 16px;

View File

@ -1,276 +0,0 @@
import { FolderOpenOutlined, SaveOutlined, SyncOutlined, WarningOutlined } from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout'
import { S3BackupManager } from '@renderer/components/S3BackupManager'
import { S3BackupModal, useS3BackupModal } from '@renderer/components/S3Modals'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useSettings } from '@renderer/hooks/useSettings'
import { startAutoSync, stopAutoSync } from '@renderer/services/BackupService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { S3Config, setS3 } from '@renderer/store/settings'
import { Button, Input, Select, Switch, Tooltip } from 'antd'
import dayjs from 'dayjs'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
const S3Settings: FC = () => {
const { s3 = {} as S3Config } = useSettings()
const {
endpoint: s3EndpointInit = '',
region: s3RegionInit = '',
bucket: s3BucketInit = '',
accessKeyId: s3AccessKeyIdInit = '',
secretAccessKey: s3SecretAccessKeyInit = '',
root: s3RootInit = '',
syncInterval: s3SyncIntervalInit = 0,
maxBackups: s3MaxBackupsInit = 5,
skipBackupFile: s3SkipBackupFileInit = false
} = s3
const [endpoint, setEndpoint] = useState<string | undefined>(s3EndpointInit)
const [region, setRegion] = useState<string | undefined>(s3RegionInit)
const [bucket, setBucket] = useState<string | undefined>(s3BucketInit)
const [accessKeyId, setAccessKeyId] = useState<string | undefined>(s3AccessKeyIdInit)
const [secretAccessKey, setSecretAccessKey] = useState<string | undefined>(s3SecretAccessKeyInit)
const [root, setRoot] = useState<string | undefined>(s3RootInit)
const [skipBackupFile, setSkipBackupFile] = useState<boolean>(s3SkipBackupFileInit)
const [backupManagerVisible, setBackupManagerVisible] = useState(false)
const [syncInterval, setSyncInterval] = useState<number>(s3SyncIntervalInit)
const [maxBackups, setMaxBackups] = useState<number>(s3MaxBackupsInit)
const dispatch = useAppDispatch()
const { theme } = useTheme()
const { t } = useTranslation()
const { s3Sync } = useAppSelector((state) => state.backup)
const onSyncIntervalChange = (value: number) => {
setSyncInterval(value)
dispatch(setS3({ ...s3, syncInterval: value, autoSync: value !== 0 }))
if (value === 0) {
stopAutoSync()
} else {
startAutoSync()
}
}
const onMaxBackupsChange = (value: number) => {
setMaxBackups(value)
dispatch(setS3({ ...s3, maxBackups: value }))
}
const onSkipBackupFilesChange = (value: boolean) => {
setSkipBackupFile(value)
dispatch(setS3({ ...s3, skipBackupFile: value }))
}
const renderSyncStatus = () => {
if (!endpoint) return null
if (!s3Sync?.lastSyncTime && !s3Sync?.syncing && !s3Sync?.lastSyncError) {
return <span style={{ color: 'var(--text-secondary)' }}>{t('settings.data.s3.syncStatus.noSync')}</span>
}
return (
<HStack gap="5px" alignItems="center">
{s3Sync?.syncing && <SyncOutlined spin />}
{!s3Sync?.syncing && s3Sync?.lastSyncError && (
<Tooltip title={t('settings.data.s3.syncStatus.error', { message: s3Sync.lastSyncError })}>
<WarningOutlined style={{ color: 'red' }} />
</Tooltip>
)}
{s3Sync?.lastSyncTime && (
<span style={{ color: 'var(--text-secondary)' }}>
{t('settings.data.s3.syncStatus.lastSync', { time: dayjs(s3Sync.lastSyncTime).format('HH:mm:ss') })}
</span>
)}
</HStack>
)
}
const { isModalVisible, handleBackup, handleCancel, backuping, customFileName, setCustomFileName, showBackupModal } =
useS3BackupModal()
const showBackupManager = () => {
setBackupManagerVisible(true)
}
const closeBackupManager = () => {
setBackupManagerVisible(false)
}
return (
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.data.s3.title')}</SettingTitle>
<SettingHelpText>{t('settings.data.s3.title.help')}</SettingHelpText>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.endpoint')}</SettingRowTitle>
<Input
placeholder={t('settings.data.s3.endpoint.placeholder')}
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
style={{ width: 250 }}
type="url"
onBlur={() => dispatch(setS3({ ...s3, endpoint: endpoint || '' }))}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.region')}</SettingRowTitle>
<Input
placeholder={t('settings.data.s3.region.placeholder')}
value={region}
onChange={(e) => setRegion(e.target.value)}
style={{ width: 250 }}
onBlur={() => dispatch(setS3({ ...s3, region: region || '' }))}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.bucket')}</SettingRowTitle>
<Input
placeholder={t('settings.data.s3.bucket.placeholder')}
value={bucket}
onChange={(e) => setBucket(e.target.value)}
style={{ width: 250 }}
onBlur={() => dispatch(setS3({ ...s3, bucket: bucket || '' }))}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.accessKeyId')}</SettingRowTitle>
<Input
placeholder={t('settings.data.s3.accessKeyId.placeholder')}
value={accessKeyId}
onChange={(e) => setAccessKeyId(e.target.value)}
style={{ width: 250 }}
onBlur={() => dispatch(setS3({ ...s3, accessKeyId: accessKeyId || '' }))}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.secretAccessKey')}</SettingRowTitle>
<Input.Password
placeholder={t('settings.data.s3.secretAccessKey.placeholder')}
value={secretAccessKey}
onChange={(e) => setSecretAccessKey(e.target.value)}
style={{ width: 250 }}
onBlur={() => dispatch(setS3({ ...s3, secretAccessKey: secretAccessKey || '' }))}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.root')}</SettingRowTitle>
<Input
placeholder={t('settings.data.s3.root.placeholder')}
value={root}
onChange={(e) => setRoot(e.target.value)}
style={{ width: 250 }}
onBlur={() => dispatch(setS3({ ...s3, root: root || '' }))}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.backup.operation')}</SettingRowTitle>
<HStack gap="5px" justifyContent="space-between">
<Button
onClick={showBackupModal}
icon={<SaveOutlined />}
loading={backuping}
disabled={!accessKeyId || !secretAccessKey}>
{t('settings.data.s3.backup.button')}
</Button>
<Button
onClick={showBackupManager}
icon={<FolderOpenOutlined />}
disabled={!endpoint || !region || !bucket || !accessKeyId || !secretAccessKey}>
{t('settings.data.s3.backup.manager.button')}
</Button>
</HStack>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.autoSync')}</SettingRowTitle>
<Select
value={syncInterval}
onChange={onSyncIntervalChange}
disabled={!endpoint || !accessKeyId || !secretAccessKey}
style={{ width: 120 }}>
<Select.Option value={0}>{t('settings.data.s3.autoSync.off')}</Select.Option>
<Select.Option value={1}>{t('settings.data.s3.autoSync.minute', { count: 1 })}</Select.Option>
<Select.Option value={5}>{t('settings.data.s3.autoSync.minute', { count: 5 })}</Select.Option>
<Select.Option value={15}>{t('settings.data.s3.autoSync.minute', { count: 15 })}</Select.Option>
<Select.Option value={30}>{t('settings.data.s3.autoSync.minute', { count: 30 })}</Select.Option>
<Select.Option value={60}>{t('settings.data.s3.autoSync.hour', { count: 1 })}</Select.Option>
<Select.Option value={120}>{t('settings.data.s3.autoSync.hour', { count: 2 })}</Select.Option>
<Select.Option value={360}>{t('settings.data.s3.autoSync.hour', { count: 6 })}</Select.Option>
<Select.Option value={720}>{t('settings.data.s3.autoSync.hour', { count: 12 })}</Select.Option>
<Select.Option value={1440}>{t('settings.data.s3.autoSync.hour', { count: 24 })}</Select.Option>
</Select>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.maxBackups')}</SettingRowTitle>
<Select
value={maxBackups}
onChange={onMaxBackupsChange}
disabled={!endpoint || !accessKeyId || !secretAccessKey}
style={{ width: 120 }}>
<Select.Option value={0}>{t('settings.data.s3.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.s3.skipBackupFile')}</SettingRowTitle>
<Switch checked={skipBackupFile} onChange={onSkipBackupFilesChange} />
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.s3.skipBackupFile.help')}</SettingHelpText>
</SettingRow>
{syncInterval > 0 && (
<>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.syncStatus')}</SettingRowTitle>
{renderSyncStatus()}
</SettingRow>
</>
)}
<>
<S3BackupModal
isModalVisible={isModalVisible}
handleBackup={handleBackup}
handleCancel={handleCancel}
backuping={backuping}
customFileName={customFileName}
setCustomFileName={setCustomFileName}
/>
<S3BackupManager
visible={backupManagerVisible}
onClose={closeBackupManager}
s3Config={{
endpoint,
region,
bucket,
access_key_id: accessKeyId,
secret_access_key: secretAccessKey,
root
}}
/>
</>
</SettingGroup>
)
}
export default S3Settings

View File

@ -4,62 +4,11 @@ import { upgradeToV7 } 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 { uuid } from '@renderer/utils'
import dayjs from 'dayjs'
import { NotificationService } from './NotificationService'
// 重试删除S3文件的辅助函数
async function deleteS3FileWithRetry(fileName: string, s3Config: any, maxRetries = 3) {
let lastError: Error | null = null
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await window.api.backup.deleteS3File(fileName, s3Config)
Logger.log(`[Backup] Successfully deleted old backup file: ${fileName} (attempt ${attempt})`)
return true
} catch (error: any) {
lastError = error
Logger.warn(`[Backup] Delete attempt ${attempt}/${maxRetries} failed for ${fileName}:`, error.message)
// 如果不是最后一次尝试,等待一段时间再重试
if (attempt < maxRetries) {
const delay = attempt * 1000 + Math.random() * 1000 // 1-2秒的随机延迟
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
}
Logger.error(`[Backup] Failed to delete old backup file after ${maxRetries} attempts: ${fileName}`, lastError)
return false
}
// 重试删除WebDAV文件的辅助函数
async function deleteWebdavFileWithRetry(fileName: string, webdavConfig: any, maxRetries = 3) {
let lastError: Error | null = null
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await window.api.backup.deleteWebdavFile(fileName, webdavConfig)
Logger.log(`[Backup] Successfully deleted old backup file: ${fileName} (attempt ${attempt})`)
return true
} catch (error: any) {
lastError = error
Logger.warn(`[Backup] Delete attempt ${attempt}/${maxRetries} failed for ${fileName}:`, error.message)
// 如果不是最后一次尝试,等待一段时间再重试
if (attempt < maxRetries) {
const delay = attempt * 1000 + Math.random() * 1000 // 1-2秒的随机延迟
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
}
Logger.error(`[Backup] Failed to delete old backup file after ${maxRetries} attempts: ${fileName}`, lastError)
return false
}
export async function backup(skipBackupFile: boolean) {
const filename = `cherry-studio.${dayjs().format('YYYYMMDDHHmm')}.zip`
const fileContnet = await getBackupData()
@ -212,21 +161,17 @@ export async function backupToWebdav({
// 文件已按修改时间降序排序,所以最旧的文件在末尾
const filesToDelete = currentDeviceFiles.slice(webdavMaxBackups)
Logger.log(`[Backup] Cleaning up ${filesToDelete.length} old backup files`)
// 串行删除文件,避免并发请求导致的问题
for (let i = 0; i < filesToDelete.length; i++) {
const file = filesToDelete[i]
await deleteWebdavFileWithRetry(file.fileName, {
webdavHost,
webdavUser,
webdavPass,
webdavPath
})
// 在删除操作之间添加短暂延迟,避免请求过于频繁
if (i < filesToDelete.length - 1) {
await new Promise((resolve) => setTimeout(resolve, 500))
for (const file of filesToDelete) {
try {
await window.api.backup.deleteWebdavFile(file.fileName, {
webdavHost,
webdavUser,
webdavPass,
webdavPath
})
Logger.log(`[Backup] Deleted old backup file: ${file.fileName}`)
} catch (error) {
Logger.error(`[Backup] Failed to delete old backup file: ${file.fileName}`, error)
}
}
}
@ -297,201 +242,6 @@ export async function restoreFromWebdav(fileName?: string) {
}
}
// 备份到 S3
export async function backupToS3({
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(setS3SyncState({ syncing: true, lastSyncError: null }))
const {
s3: {
endpoint: s3Endpoint,
region: s3Region,
bucket: s3Bucket,
accessKeyId: s3AccessKeyId,
secretAccessKey: s3SecretAccessKey,
root: s3Root,
maxBackups: s3MaxBackups,
skipBackupFile: s3SkipBackupFile
}
} = 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 {
await window.api.backup.backupToS3(backupData, {
endpoint: s3Endpoint,
region: s3Region,
bucket: s3Bucket,
access_key_id: s3AccessKeyId,
secret_access_key: s3SecretAccessKey,
root: s3Root,
fileName: finalFileName,
skipBackupFile: s3SkipBackupFile
})
// S3上传成功
store.dispatch(
setS3SyncState({
lastSyncError: null
})
)
notificationService.send({
id: uuid(),
type: 'success',
title: i18n.t('common.success'),
message: i18n.t('message.backup.success'),
silent: false,
timestamp: Date.now(),
source: 'backup'
})
showMessage && window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' })
// 清理旧备份文件
if (s3MaxBackups > 0) {
try {
// 获取所有备份文件
const files = await window.api.backup.listS3Files({
endpoint: s3Endpoint,
region: s3Region,
bucket: s3Bucket,
access_key_id: s3AccessKeyId,
secret_access_key: s3SecretAccessKey,
root: s3Root
})
// 筛选当前设备的备份文件
const currentDeviceFiles = files.filter((file) => {
// 检查文件名是否包含当前设备的标识信息
return file.fileName.includes(deviceType) && file.fileName.includes(hostname)
})
// 如果当前设备的备份文件数量超过最大保留数量,删除最旧的文件
if (currentDeviceFiles.length > s3MaxBackups) {
// 文件已按修改时间降序排序,所以最旧的文件在末尾
const filesToDelete = currentDeviceFiles.slice(s3MaxBackups)
Logger.log(`[Backup] Cleaning up ${filesToDelete.length} old backup files`)
// 串行删除文件,避免并发请求导致的问题
for (let i = 0; i < filesToDelete.length; i++) {
const file = filesToDelete[i]
await deleteS3FileWithRetry(file.fileName, {
endpoint: s3Endpoint,
region: s3Region,
bucket: s3Bucket,
access_key_id: s3AccessKeyId,
secret_access_key: s3SecretAccessKey,
root: s3Root
})
// 在删除操作之间添加短暂延迟,避免请求过于频繁
if (i < filesToDelete.length - 1) {
await new Promise((resolve) => setTimeout(resolve, 500))
}
}
}
} catch (error) {
Logger.error('[Backup] Failed to clean up old backup files:', error)
}
}
} catch (error: any) {
// if auto backup process, throw error
if (autoBackupProcess) {
throw error
}
notificationService.send({
id: uuid(),
type: 'error',
title: i18n.t('message.backup.failed'),
message: error.message,
silent: false,
timestamp: Date.now(),
source: 'backup'
})
store.dispatch(setS3SyncState({ lastSyncError: error.message }))
console.error('[Backup] backupToS3: Error uploading file to S3:', error)
showMessage && window.message.error({ content: i18n.t('message.backup.failed'), key: 'backup' })
throw error
} finally {
if (!autoBackupProcess) {
store.dispatch(
setS3SyncState({
lastSyncTime: Date.now(),
syncing: false
})
)
}
isManualBackupRunning = false
}
}
// 从 S3 恢复
export async function restoreFromS3(fileName?: string) {
const {
s3: {
endpoint: s3Endpoint,
region: s3Region,
bucket: s3Bucket,
accessKeyId: s3AccessKeyId,
secretAccessKey: s3SecretAccessKey,
root: s3Root
}
} = store.getState().settings
let data = ''
try {
data = await window.api.backup.restoreFromS3({
endpoint: s3Endpoint,
region: s3Region,
bucket: s3Bucket,
access_key_id: s3AccessKeyId,
secret_access_key: s3SecretAccessKey,
root: s3Root,
fileName
})
} catch (error: any) {
console.error('[Backup] restoreFromS3: Error downloading file from S3:', error)
window.modal.error({
title: i18n.t('message.restore.failed'),
content: error.message
})
}
try {
await handleData(JSON.parse(data))
} catch (error) {
console.error('[Backup] Error downloading file from S3:', error)
window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' })
}
}
let autoSyncStarted = false
let syncTimeout: NodeJS.Timeout | null = null
let isAutoBackupRunning = false
@ -502,17 +252,9 @@ export function startAutoSync(immediate = false) {
return
}
const {
webdavAutoSync,
webdavHost,
s3: { autoSync: s3AutoSync, endpoint: s3Endpoint }
} = store.getState().settings
const { webdavAutoSync, webdavHost } = store.getState().settings
// 检查WebDAV或S3自动同步配置
const hasWebdavConfig = webdavAutoSync && webdavHost
const hasS3Config = s3AutoSync && s3Endpoint
if (!hasWebdavConfig && !hasS3Config) {
if (!webdavAutoSync || !webdavHost) {
Logger.log('[AutoSync] Invalid sync settings, auto sync disabled')
return
}
@ -535,29 +277,22 @@ export function startAutoSync(immediate = false) {
syncTimeout = null
}
const {
webdavSyncInterval: _webdavSyncInterval,
s3: { syncInterval: _s3SyncInterval }
} = store.getState().settings
const { webdavSync, s3Sync } = store.getState().backup
const { webdavSyncInterval } = store.getState().settings
const { webdavSync } = store.getState().backup
// 使用当前激活的同步配置
const syncInterval = hasWebdavConfig ? _webdavSyncInterval : _s3SyncInterval
const lastSyncTime = hasWebdavConfig ? webdavSync?.lastSyncTime : s3Sync?.lastSyncTime
if (syncInterval <= 0) {
if (webdavSyncInterval <= 0) {
Logger.log('[AutoSync] Invalid sync interval, auto sync disabled')
stopAutoSync()
return
}
// 用户指定的自动备份时间间隔(毫秒)
const requiredInterval = syncInterval * 60 * 1000
const requiredInterval = webdavSyncInterval * 60 * 1000
let timeUntilNextSync = 1000 //also immediate
switch (type) {
case 'fromLastSyncTime': // 如果存在最后一次同步的时间,以它为参考计算下一次同步的时间
timeUntilNextSync = Math.max(1000, (lastSyncTime || 0) + requiredInterval - Date.now())
case 'fromLastSyncTime': // 如果存在最后一次同步WebDAV的时间,以它为参考计算下一次同步的时间
timeUntilNextSync = Math.max(1000, (webdavSync?.lastSyncTime || 0) + requiredInterval - Date.now())
break
case 'fromNow':
timeUntilNextSync = requiredInterval
@ -566,9 +301,8 @@ export function startAutoSync(immediate = false) {
syncTimeout = setTimeout(performAutoBackup, timeUntilNextSync)
const backupType = hasWebdavConfig ? 'WebDAV' : 'S3'
Logger.log(
`[AutoSync] Next ${backupType} sync scheduled in ${Math.floor(timeUntilNextSync / 1000 / 60)} minutes ${Math.floor(
`[AutoSync] Next sync scheduled in ${Math.floor(timeUntilNextSync / 1000 / 60)} minutes ${Math.floor(
(timeUntilNextSync / 1000) % 60
)} seconds`
)
@ -587,28 +321,17 @@ export function startAutoSync(immediate = false) {
while (retryCount < maxRetries) {
try {
const backupType = hasWebdavConfig ? 'WebDAV' : 'S3'
Logger.log(`[AutoSync] Starting auto ${backupType} backup... (attempt ${retryCount + 1}/${maxRetries})`)
Logger.log(`[AutoSync] Starting auto backup... (attempt ${retryCount + 1}/${maxRetries})`)
if (hasWebdavConfig) {
await backupToWebdav({ autoBackupProcess: true })
store.dispatch(
setWebDAVSyncState({
lastSyncError: null,
lastSyncTime: Date.now(),
syncing: false
})
)
} else if (hasS3Config) {
await backupToS3({ autoBackupProcess: true })
store.dispatch(
setS3SyncState({
lastSyncError: null,
lastSyncTime: Date.now(),
syncing: false
})
)
}
await backupToWebdav({ autoBackupProcess: true })
store.dispatch(
setWebDAVSyncState({
lastSyncError: null,
lastSyncTime: Date.now(),
syncing: false
})
)
isAutoBackupRunning = false
scheduleNextBackup()
@ -617,31 +340,20 @@ export function startAutoSync(immediate = false) {
} catch (error: any) {
retryCount++
if (retryCount === maxRetries) {
const backupType = hasWebdavConfig ? 'WebDAV' : 'S3'
Logger.error(`[AutoSync] Auto ${backupType} backup failed after all retries:`, error)
Logger.error('[AutoSync] Auto backup failed after all retries:', error)
if (hasWebdavConfig) {
store.dispatch(
setWebDAVSyncState({
lastSyncError: 'Auto backup failed',
lastSyncTime: Date.now(),
syncing: false
})
)
} else if (hasS3Config) {
store.dispatch(
setS3SyncState({
lastSyncError: 'Auto backup failed',
lastSyncTime: Date.now(),
syncing: false
})
)
}
store.dispatch(
setWebDAVSyncState({
lastSyncError: 'Auto backup failed',
lastSyncTime: Date.now(),
syncing: false
})
)
//only show 1 time error modal, and autoback stopped until user click ok
await window.modal.error({
title: i18n.t('message.backup.failed'),
content: `[${backupType} Auto Backup] ${new Date().toLocaleString()} ` + error.message
content: `[WebDAV Auto Backup] ${new Date().toLocaleString()} ` + error.message
})
scheduleNextBackup('fromNow')

View File

@ -8,7 +8,6 @@ export interface WebDAVSyncState {
export interface BackupState {
webdavSync: WebDAVSyncState
s3Sync: WebDAVSyncState
}
const initialState: BackupState = {
@ -16,11 +15,6 @@ const initialState: BackupState = {
lastSyncTime: null,
syncing: false,
lastSyncError: null
},
s3Sync: {
lastSyncTime: null,
syncing: false,
lastSyncError: null
}
}
@ -30,12 +24,9 @@ const backupSlice = createSlice({
reducers: {
setWebDAVSyncState: (state, action: PayloadAction<Partial<WebDAVSyncState>>) => {
state.webdavSync = { ...state.webdavSync, ...action.payload }
},
setS3SyncState: (state, action: PayloadAction<Partial<WebDAVSyncState>>) => {
state.s3Sync = { ...state.s3Sync, ...action.payload }
}
}
})
export const { setWebDAVSyncState, setS3SyncState } = backupSlice.actions
export const { setWebDAVSyncState } = backupSlice.actions
export default backupSlice.reducer

View File

@ -37,19 +37,6 @@ export type UserTheme = {
colorPrimary: string
}
export interface S3Config {
endpoint: string
region: string
bucket: string
accessKeyId: string
secretAccessKey: string
root: string
autoSync: boolean
syncInterval: number
maxBackups: number
skipBackupFile: boolean
}
export interface SettingsState {
showAssistants: boolean
showTopics: boolean
@ -198,7 +185,6 @@ export interface SettingsState {
knowledgeEmbed: boolean
}
defaultPaintingProvider: PaintingProvider
s3: S3Config
}
export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid'
@ -343,19 +329,7 @@ export const initialState: SettingsState = {
backup: false,
knowledgeEmbed: false
},
defaultPaintingProvider: 'aihubmix',
s3: {
endpoint: '',
region: '',
bucket: '',
accessKeyId: '',
secretAccessKey: '',
root: '',
autoSync: false,
syncInterval: 0,
maxBackups: 0,
skipBackupFile: false
}
defaultPaintingProvider: 'aihubmix'
}
const settingsSlice = createSlice({
@ -719,9 +693,6 @@ const settingsSlice = createSlice({
},
setDefaultPaintingProvider: (state, action: PayloadAction<PaintingProvider>) => {
state.defaultPaintingProvider = action.payload
},
setS3: (state, action: PayloadAction<S3Config>) => {
state.s3 = action.payload
}
}
})
@ -830,8 +801,7 @@ export const {
setOpenAISummaryText,
setOpenAIServiceTier,
setNotificationSettings,
setDefaultPaintingProvider,
setS3
setDefaultPaintingProvider
} = settingsSlice.actions
export default settingsSlice.reducer

View File

@ -730,16 +730,4 @@ export interface StoreSyncAction {
export type OpenAISummaryText = 'auto' | 'concise' | 'detailed' | 'off'
export type OpenAIServiceTier = 'auto' | 'default' | 'flex'
export type S3Config = {
endpoint: string
region: string
bucket: string
access_key_id: string
secret_access_key: string
root?: string
fileName?: string
skipBackupFile?: boolean
}
export type { Message } from './newMessage'

View File

@ -3198,55 +3198,6 @@ __metadata:
languageName: node
linkType: hard
"@opendal/lib-darwin-arm64@npm:0.47.11":
version: 0.47.11
resolution: "@opendal/lib-darwin-arm64@npm:0.47.11"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@opendal/lib-darwin-x64@npm:0.47.11":
version: 0.47.11
resolution: "@opendal/lib-darwin-x64@npm:0.47.11"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@opendal/lib-linux-arm64-gnu@npm:0.47.11":
version: 0.47.11
resolution: "@opendal/lib-linux-arm64-gnu@npm:0.47.11"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@opendal/lib-linux-arm64-musl@npm:0.47.11":
version: 0.47.11
resolution: "@opendal/lib-linux-arm64-musl@npm:0.47.11"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@opendal/lib-linux-x64-gnu@npm:0.47.11":
version: 0.47.11
resolution: "@opendal/lib-linux-x64-gnu@npm:0.47.11"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@opendal/lib-win32-arm64-msvc@npm:0.47.11":
version: 0.47.11
resolution: "@opendal/lib-win32-arm64-msvc@npm:0.47.11"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@opendal/lib-win32-x64-msvc@npm:0.47.11":
version: 0.47.11
resolution: "@opendal/lib-win32-x64-msvc@npm:0.47.11"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@parcel/watcher-android-arm64@npm:2.5.1":
version: 2.5.1
resolution: "@parcel/watcher-android-arm64@npm:2.5.1"
@ -5761,7 +5712,6 @@ __metadata:
npx-scope-finder: "npm:^1.2.0"
officeparser: "npm:^4.1.1"
openai: "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch"
opendal: "npm:0.47.11"
os-proxy-config: "npm:^1.1.2"
p-queue: "npm:^8.1.0"
playwright: "npm:^1.52.0"
@ -14246,36 +14196,6 @@ __metadata:
languageName: node
linkType: hard
"opendal@npm:0.47.11":
version: 0.47.11
resolution: "opendal@npm:0.47.11"
dependencies:
"@opendal/lib-darwin-arm64": "npm:0.47.11"
"@opendal/lib-darwin-x64": "npm:0.47.11"
"@opendal/lib-linux-arm64-gnu": "npm:0.47.11"
"@opendal/lib-linux-arm64-musl": "npm:0.47.11"
"@opendal/lib-linux-x64-gnu": "npm:0.47.11"
"@opendal/lib-win32-arm64-msvc": "npm:0.47.11"
"@opendal/lib-win32-x64-msvc": "npm:0.47.11"
dependenciesMeta:
"@opendal/lib-darwin-arm64":
optional: true
"@opendal/lib-darwin-x64":
optional: true
"@opendal/lib-linux-arm64-gnu":
optional: true
"@opendal/lib-linux-arm64-musl":
optional: true
"@opendal/lib-linux-x64-gnu":
optional: true
"@opendal/lib-win32-arm64-msvc":
optional: true
"@opendal/lib-win32-x64-msvc":
optional: true
checksum: 10c0/0783da2651bb27ac693ce38938d12b00124530fb965364517eef3de17b3ff898cdecf06260a79a7d70745d57c2ba952a753a4bab52e0831aa7232c3a69120225
languageName: node
linkType: hard
"option@npm:~0.2.1":
version: 0.2.4
resolution: "option@npm:0.2.4"