feat: Implement comprehensive data restore functionality with progress tracking
- Added RestorePopup component for visualizing restore process - Enhanced BackupManager with detailed restore progress tracking - Implemented file copy progress and stage tracking during restore - Updated localization files with restore progress translations - Integrated restore progress reporting to renderer process
This commit is contained in:
parent
e69d0c89a6
commit
37cf7427f9
@ -125,6 +125,90 @@ class BackupManager {
|
||||
}
|
||||
}
|
||||
|
||||
async restore(_: Electron.IpcMainInvokeEvent, backupPath: string): Promise<string> {
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
|
||||
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
|
||||
mainWindow?.webContents.send('restore-progress', processData)
|
||||
Logger.log('[BackupManager] restore progress', processData)
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建临时目录
|
||||
await fs.ensureDir(this.tempDir)
|
||||
onProgress({ stage: 'preparing', progress: 0, total: 100 })
|
||||
|
||||
Logger.log('[backup] step 1: unzip backup file', this.tempDir)
|
||||
// 使用 adm-zip 解压
|
||||
const zip = new AdmZip(backupPath)
|
||||
zip.extractAllTo(this.tempDir, true) // true 表示覆盖已存在的文件
|
||||
onProgress({ stage: 'extracting', progress: 20, total: 100 })
|
||||
|
||||
Logger.log('[backup] step 2: read data.json')
|
||||
// 读取 data.json
|
||||
const dataPath = path.join(this.tempDir, 'data.json')
|
||||
const data = await fs.readFile(dataPath, 'utf-8')
|
||||
onProgress({ stage: 'reading_data', progress: 40, total: 100 })
|
||||
|
||||
Logger.log('[backup] step 3: restore Data directory')
|
||||
// 恢复 Data 目录
|
||||
const sourcePath = path.join(this.tempDir, 'Data')
|
||||
const destPath = path.join(app.getPath('userData'), 'Data')
|
||||
|
||||
// 获取源目录总大小
|
||||
const totalSize = await this.getDirSize(sourcePath)
|
||||
let copiedSize = 0
|
||||
|
||||
await this.setWritableRecursive(destPath)
|
||||
await fs.remove(destPath)
|
||||
|
||||
// 使用流式复制
|
||||
await this.copyDirWithProgress(sourcePath, destPath, (size) => {
|
||||
copiedSize += size
|
||||
const progress = Math.min(90, 40 + Math.floor((copiedSize / totalSize) * 50))
|
||||
onProgress({ stage: 'copying_files', progress, total: 100 })
|
||||
})
|
||||
|
||||
Logger.log('[backup] step 4: clean up temp directory')
|
||||
// 清理临时目录
|
||||
await this.setWritableRecursive(this.tempDir)
|
||||
await fs.remove(this.tempDir)
|
||||
onProgress({ stage: 'completed', progress: 100, total: 100 })
|
||||
|
||||
Logger.log('[backup] step 5: Restore completed successfully')
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
Logger.error('[backup] Restore failed:', error)
|
||||
await fs.remove(this.tempDir).catch(() => {})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async backupToWebdav(_: Electron.IpcMainInvokeEvent, data: string, webdavConfig: WebDavConfig) {
|
||||
const filename = 'cherry-studio.backup.zip'
|
||||
const backupedFilePath = await this.backup(_, filename, data)
|
||||
const webdavClient = new WebDav(webdavConfig)
|
||||
return await webdavClient.putFileContents(filename, fs.createReadStream(backupedFilePath), {
|
||||
overwrite: true
|
||||
})
|
||||
}
|
||||
|
||||
async restoreFromWebdav(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) {
|
||||
const filename = 'cherry-studio.backup.zip'
|
||||
const webdavClient = new WebDav(webdavConfig)
|
||||
const retrievedFile = await webdavClient.getFileContents(filename)
|
||||
const backupedFilePath = path.join(this.backupDir, filename)
|
||||
|
||||
if (!fs.existsSync(this.backupDir)) {
|
||||
fs.mkdirSync(this.backupDir, { recursive: true })
|
||||
}
|
||||
|
||||
await fs.writeFileSync(backupedFilePath, retrievedFile as Buffer)
|
||||
|
||||
return await this.restore(_, backupedFilePath)
|
||||
}
|
||||
|
||||
private async getDirSize(dirPath: string): Promise<number> {
|
||||
let size = 0
|
||||
const items = await fs.readdir(dirPath, { withFileTypes: true })
|
||||
@ -162,72 +246,6 @@ class BackupManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async restore(_: Electron.IpcMainInvokeEvent, backupPath: string): Promise<string> {
|
||||
try {
|
||||
// 创建临时目录
|
||||
await fs.ensureDir(this.tempDir)
|
||||
|
||||
Logger.log('[backup] step 1: unzip backup file', this.tempDir)
|
||||
|
||||
// 使用 adm-zip 解压
|
||||
const zip = new AdmZip(backupPath)
|
||||
zip.extractAllTo(this.tempDir, true) // true 表示覆盖已存在的文件
|
||||
|
||||
Logger.log('[backup] step 2: read data.json')
|
||||
|
||||
// 读取 data.json
|
||||
const dataPath = path.join(this.tempDir, 'data.json')
|
||||
const data = await fs.readFile(dataPath, 'utf-8')
|
||||
|
||||
Logger.log('[backup] step 3: restore Data directory')
|
||||
|
||||
// 恢复 Data 目录
|
||||
const sourcePath = path.join(this.tempDir, 'Data')
|
||||
const destPath = path.join(app.getPath('userData'), 'Data')
|
||||
await this.setWritableRecursive(destPath)
|
||||
await fs.remove(destPath)
|
||||
await fs.copy(sourcePath, destPath)
|
||||
|
||||
Logger.log('[backup] step 4: clean up temp directory')
|
||||
|
||||
// 清理临时目录
|
||||
await this.setWritableRecursive(this.tempDir)
|
||||
await fs.remove(this.tempDir)
|
||||
|
||||
Logger.log('[backup] step 5: Restore completed successfully')
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
Logger.error('[backup] Restore failed:', error)
|
||||
await fs.remove(this.tempDir).catch(() => {})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async backupToWebdav(_: Electron.IpcMainInvokeEvent, data: string, webdavConfig: WebDavConfig) {
|
||||
const filename = 'cherry-studio.backup.zip'
|
||||
const backupedFilePath = await this.backup(_, filename, data)
|
||||
const webdavClient = new WebDav(webdavConfig)
|
||||
return await webdavClient.putFileContents(filename, fs.createReadStream(backupedFilePath), {
|
||||
overwrite: true
|
||||
})
|
||||
}
|
||||
|
||||
async restoreFromWebdav(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) {
|
||||
const filename = 'cherry-studio.backup.zip'
|
||||
const webdavClient = new WebDav(webdavConfig)
|
||||
const retrievedFile = await webdavClient.getFileContents(filename)
|
||||
const backupedFilePath = path.join(this.backupDir, filename)
|
||||
|
||||
if (!fs.existsSync(this.backupDir)) {
|
||||
fs.mkdirSync(this.backupDir, { recursive: true })
|
||||
}
|
||||
|
||||
await fs.writeFileSync(backupedFilePath, retrievedFile as Buffer)
|
||||
|
||||
return await this.restore(_, backupedFilePath)
|
||||
}
|
||||
}
|
||||
|
||||
export default BackupManager
|
||||
|
||||
100
src/renderer/src/components/Popups/RestorePopup.tsx
Normal file
100
src/renderer/src/components/Popups/RestorePopup.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import { restore } from '@renderer/services/BackupService'
|
||||
import { Modal, Progress } from 'antd'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { TopView } from '../TopView'
|
||||
|
||||
interface Props {
|
||||
resolve: (data: any) => void
|
||||
}
|
||||
|
||||
interface ProgressData {
|
||||
stage: string
|
||||
progress: number
|
||||
total: number
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [progressData, setProgressData] = useState<ProgressData>()
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
const removeListener = window.electron.ipcRenderer.on('restore-progress', (_, data: ProgressData) => {
|
||||
setProgressData(data)
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeListener()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onOk = async () => {
|
||||
await restore()
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
resolve({})
|
||||
}
|
||||
|
||||
const getProgressText = () => {
|
||||
if (!progressData) return ''
|
||||
|
||||
if (progressData.stage === 'copying_files') {
|
||||
return t(`restore.progress.${progressData.stage}`, {
|
||||
progress: Math.floor(progressData.progress)
|
||||
})
|
||||
}
|
||||
return t(`restore.progress.${progressData.stage}`)
|
||||
}
|
||||
|
||||
RestorePopup.hide = onCancel
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('restore.title')}
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
transitionName="ant-move-down"
|
||||
okText={t('restore.confirm.button')}
|
||||
centered>
|
||||
{!progressData && <div>{t('restore.content')}</div>}
|
||||
{progressData && (
|
||||
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||
<Progress percent={Math.floor(progressData.progress)} strokeColor="var(--color-primary)" />
|
||||
<div style={{ marginTop: 16 }}>{getProgressText()}</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const TopViewKey = 'RestorePopup'
|
||||
|
||||
export default class RestorePopup {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
static show() {
|
||||
return new Promise<any>((resolve) => {
|
||||
TopView.show(
|
||||
<PopupContainer
|
||||
resolve={(v) => {
|
||||
resolve(v)
|
||||
TopView.hide(TopViewKey)
|
||||
}}
|
||||
/>,
|
||||
TopViewKey
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -897,6 +897,20 @@
|
||||
"compressing": "Compressing files...",
|
||||
"completed": "Backup completed"
|
||||
}
|
||||
},
|
||||
"restore": {
|
||||
"title": "Data Restore",
|
||||
"confirm": "Are you sure you want to restore data?",
|
||||
"confirm.button": "Select Backup File",
|
||||
"content": "Restore operation will overwrite all current application data with the backup data. Please note that the restore process may take some time, thank you for your patience.",
|
||||
"progress": {
|
||||
"title": "Restore Progress",
|
||||
"preparing": "Preparing restore...",
|
||||
"extracting": "Extracting backup...",
|
||||
"reading_data": "Reading data...",
|
||||
"copying_files": "Copying files... {{progress}}%",
|
||||
"completed": "Restore completed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -897,6 +897,20 @@
|
||||
"compressing": "圧縮中...",
|
||||
"completed": "バックアップ完了"
|
||||
}
|
||||
},
|
||||
"restore": {
|
||||
"title": "データ復元",
|
||||
"confirm": "データを復元しますか?",
|
||||
"confirm.button": "バックアップファイルを選択",
|
||||
"content": "復元操作は現在のアプリデータをバックアップデータで上書きします。復元処理には時間がかかる場合があります。",
|
||||
"progress": {
|
||||
"title": "復元進捗",
|
||||
"preparing": "復元準備中...",
|
||||
"extracting": "バックアップ解凍中...",
|
||||
"reading_data": "データ読み込み中...",
|
||||
"copying_files": "ファイルコピー中... {{progress}}%",
|
||||
"completed": "復元完了"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -897,6 +897,20 @@
|
||||
"compressing": "Сжатие файлов...",
|
||||
"completed": "Резервная копия создана"
|
||||
}
|
||||
},
|
||||
"restore": {
|
||||
"title": "Восстановление данных",
|
||||
"confirm": "Вы уверены, что хотите восстановить данные?",
|
||||
"confirm.button": "Выбрать файл резервной копии",
|
||||
"content": "Операция восстановления перезапишет все текущие данные приложения данными из резервной копии. Это может занять некоторое время.",
|
||||
"progress": {
|
||||
"title": "Прогресс восстановления",
|
||||
"preparing": "Подготовка к восстановлению...",
|
||||
"extracting": "Распаковка резервной копии...",
|
||||
"reading_data": "Чтение данных...",
|
||||
"copying_files": "Копирование файлов... {{progress}}%",
|
||||
"completed": "Восстановление завершено"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -897,6 +897,20 @@
|
||||
"compressing": "压缩文件...",
|
||||
"completed": "备份完成"
|
||||
}
|
||||
},
|
||||
"restore": {
|
||||
"title": "数据恢复",
|
||||
"confirm": "确定要恢复数据吗?",
|
||||
"confirm.button": "选择备份文件",
|
||||
"content": "恢复操作将使用备份数据覆盖当前所有应用数据。请注意,恢复过程可能需要一些时间,感谢您的耐心等待。",
|
||||
"progress": {
|
||||
"title": "恢复进度",
|
||||
"preparing": "准备恢复...",
|
||||
"extracting": "解压备份...",
|
||||
"reading_data": "读取数据...",
|
||||
"copying_files": "复制文件... {{progress}}%",
|
||||
"completed": "恢复完成"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -897,6 +897,20 @@
|
||||
"compressing": "壓縮文件...",
|
||||
"completed": "備份完成"
|
||||
}
|
||||
},
|
||||
"restore": {
|
||||
"title": "資料復原",
|
||||
"confirm": "確定要復原資料嗎?",
|
||||
"confirm.button": "選擇備份檔案",
|
||||
"content": "復原操作將使用備份資料覆蓋當前所有應用資料。請注意,復原過程可能需要一些時間,感謝您的耐心等待。",
|
||||
"progress": {
|
||||
"title": "復原進度",
|
||||
"preparing": "準備復原...",
|
||||
"extracting": "解壓備份...",
|
||||
"reading_data": "讀取資料...",
|
||||
"copying_files": "複製文件... {{progress}}%",
|
||||
"completed": "復原完成"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,8 +3,9 @@ import { Client } from '@notionhq/client'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import MinApp from '@renderer/components/MinApp'
|
||||
import BackupPopup from '@renderer/components/Popups/BackupPopup'
|
||||
import RestorePopup from '@renderer/components/Popups/RestorePopup'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { reset, restore } from '@renderer/services/BackupService'
|
||||
import { reset } from '@renderer/services/BackupService'
|
||||
import { RootState, useAppDispatch } from '@renderer/store'
|
||||
import {
|
||||
setNotionApiKey,
|
||||
@ -333,8 +334,6 @@ const DataSettings: FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleBackup = () => BackupPopup.show()
|
||||
|
||||
return (
|
||||
<SettingContainer theme={theme}>
|
||||
<SettingGroup theme={theme}>
|
||||
@ -343,10 +342,10 @@ const DataSettings: FC = () => {
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle>
|
||||
<HStack gap="5px" justifyContent="space-between">
|
||||
<Button onClick={handleBackup} icon={<SaveOutlined />}>
|
||||
<Button onClick={BackupPopup.show} icon={<SaveOutlined />}>
|
||||
{t('settings.general.backup.button')}
|
||||
</Button>
|
||||
<Button onClick={restore} icon={<FolderOpenOutlined />}>
|
||||
<Button onClick={RestorePopup.show} icon={<FolderOpenOutlined />}>
|
||||
{t('settings.general.restore.button')}
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user