From 37cf7427f992bcfffe7ffd289e5e275ceb174643 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Mon, 3 Mar 2025 22:32:55 +0800 Subject: [PATCH] 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 --- src/main/services/BackupManager.ts | 150 ++++++++++-------- .../src/components/Popups/RestorePopup.tsx | 100 ++++++++++++ src/renderer/src/i18n/locales/en-us.json | 14 ++ src/renderer/src/i18n/locales/ja-jp.json | 14 ++ src/renderer/src/i18n/locales/ru-ru.json | 14 ++ src/renderer/src/i18n/locales/zh-cn.json | 14 ++ src/renderer/src/i18n/locales/zh-tw.json | 14 ++ .../settings/DataSettings/DataSettings.tsx | 9 +- 8 files changed, 258 insertions(+), 71 deletions(-) create mode 100644 src/renderer/src/components/Popups/RestorePopup.tsx diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index c9cd847a..cbccdc90 100644 --- a/src/main/services/BackupManager.ts +++ b/src/main/services/BackupManager.ts @@ -125,6 +125,90 @@ class BackupManager { } } + async restore(_: Electron.IpcMainInvokeEvent, backupPath: string): Promise { + 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 { let size = 0 const items = await fs.readdir(dirPath, { withFileTypes: true }) @@ -162,72 +246,6 @@ class BackupManager { } } } - - async restore(_: Electron.IpcMainInvokeEvent, backupPath: string): Promise { - 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 diff --git a/src/renderer/src/components/Popups/RestorePopup.tsx b/src/renderer/src/components/Popups/RestorePopup.tsx new file mode 100644 index 00000000..2e6d166c --- /dev/null +++ b/src/renderer/src/components/Popups/RestorePopup.tsx @@ -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 = ({ resolve }) => { + const [open, setOpen] = useState(true) + const [progressData, setProgressData] = useState() + 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 ( + + {!progressData &&
{t('restore.content')}
} + {progressData && ( +
+ +
{getProgressText()}
+
+ )} +
+ ) +} + +const TopViewKey = 'RestorePopup' + +export default class RestorePopup { + static topviewId = 0 + static hide() { + TopView.hide(TopViewKey) + } + static show() { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + TopView.hide(TopViewKey) + }} + />, + TopViewKey + ) + }) + } +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 4ea98a25..33ebd744 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -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" + } } } } diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 6d0044af..f69aad3e 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -897,6 +897,20 @@ "compressing": "圧縮中...", "completed": "バックアップ完了" } + }, + "restore": { + "title": "データ復元", + "confirm": "データを復元しますか?", + "confirm.button": "バックアップファイルを選択", + "content": "復元操作は現在のアプリデータをバックアップデータで上書きします。復元処理には時間がかかる場合があります。", + "progress": { + "title": "復元進捗", + "preparing": "復元準備中...", + "extracting": "バックアップ解凍中...", + "reading_data": "データ読み込み中...", + "copying_files": "ファイルコピー中... {{progress}}%", + "completed": "復元完了" + } } } } diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 157006b3..0f574484 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -897,6 +897,20 @@ "compressing": "Сжатие файлов...", "completed": "Резервная копия создана" } + }, + "restore": { + "title": "Восстановление данных", + "confirm": "Вы уверены, что хотите восстановить данные?", + "confirm.button": "Выбрать файл резервной копии", + "content": "Операция восстановления перезапишет все текущие данные приложения данными из резервной копии. Это может занять некоторое время.", + "progress": { + "title": "Прогресс восстановления", + "preparing": "Подготовка к восстановлению...", + "extracting": "Распаковка резервной копии...", + "reading_data": "Чтение данных...", + "copying_files": "Копирование файлов... {{progress}}%", + "completed": "Восстановление завершено" + } } } } diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 8f0df233..58fa3df1 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -897,6 +897,20 @@ "compressing": "压缩文件...", "completed": "备份完成" } + }, + "restore": { + "title": "数据恢复", + "confirm": "确定要恢复数据吗?", + "confirm.button": "选择备份文件", + "content": "恢复操作将使用备份数据覆盖当前所有应用数据。请注意,恢复过程可能需要一些时间,感谢您的耐心等待。", + "progress": { + "title": "恢复进度", + "preparing": "准备恢复...", + "extracting": "解压备份...", + "reading_data": "读取数据...", + "copying_files": "复制文件... {{progress}}%", + "completed": "恢复完成" + } } } } diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 6e5c8754..3dbb667c 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -897,6 +897,20 @@ "compressing": "壓縮文件...", "completed": "備份完成" } + }, + "restore": { + "title": "資料復原", + "confirm": "確定要復原資料嗎?", + "confirm.button": "選擇備份檔案", + "content": "復原操作將使用備份資料覆蓋當前所有應用資料。請注意,復原過程可能需要一些時間,感謝您的耐心等待。", + "progress": { + "title": "復原進度", + "preparing": "準備復原...", + "extracting": "解壓備份...", + "reading_data": "讀取資料...", + "copying_files": "複製文件... {{progress}}%", + "completed": "復原完成" + } } } } diff --git a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx index f0e978cf..ee083837 100644 --- a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx @@ -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 ( @@ -343,10 +342,10 @@ const DataSettings: FC = () => { {t('settings.general.backup.title')} - -