From 994ffa224edc04420b28b2f85f4ee11c0d346a0a Mon Sep 17 00:00:00 2001 From: zhsama Date: Fri, 21 Mar 2025 11:13:44 +0800 Subject: [PATCH] feat: enhance WebDAV backup and restore functionality (#2522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: zhsama Co-authored-by: 亢奋猫 --- src/main/index.ts | 5 +- src/main/ipc.ts | 1 + src/main/services/BackupManager.ts | 48 ++++- src/preload/index.d.ts | 11 +- src/preload/index.ts | 6 +- src/renderer/src/i18n/locales/en-us.json | 6 + src/renderer/src/i18n/locales/zh-cn.json | 6 + .../settings/DataSettings/WebDavSettings.tsx | 169 ++++++++++++++---- src/renderer/src/services/BackupService.ts | 25 ++- src/renderer/src/types/index.ts | 1 + 10 files changed, 229 insertions(+), 49 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 19a121ad..e4a1e18b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,6 @@ import { electronApp, optimizer } from '@electron-toolkit/utils' +import { app, ipcMain } from 'electron' import { replaceDevtoolsFont } from '@main/utils/windowUtil' -import { app } from 'electron' import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer' import { registerIpc } from './ipc' @@ -44,6 +44,9 @@ if (!app.requestSingleInstanceLock()) { .then((name) => console.log(`Added Extension: ${name}`)) .catch((err) => console.log('An error occurred: ', err)) } + ipcMain.handle('system:getDeviceType', () => { + return process.platform === 'darwin' ? 'mac' : process.platform === 'win32' ? 'windows' : 'linux' + }) }) // Listen for second instance diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 0c7d23e7..279ce1ba 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -130,6 +130,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle('backup:restore', backupManager.restore) ipcMain.handle('backup:backupToWebdav', backupManager.backupToWebdav) ipcMain.handle('backup:restoreFromWebdav', backupManager.restoreFromWebdav) + ipcMain.handle('backup:listWebdavFiles', backupManager.listWebdavFiles) // file ipcMain.handle('file:open', fileManager.open) diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index cbccdc90..2d587776 100644 --- a/src/main/services/BackupManager.ts +++ b/src/main/services/BackupManager.ts @@ -5,6 +5,7 @@ import { app } from 'electron' import Logger from 'electron-log' import * as fs from 'fs-extra' import * as path from 'path' +import { createClient, FileStat } from 'webdav' import WebDav from './WebDav' import { windowService } from './WindowService' @@ -18,6 +19,7 @@ class BackupManager { this.restore = this.restore.bind(this) this.backupToWebdav = this.backupToWebdav.bind(this) this.restoreFromWebdav = this.restoreFromWebdav.bind(this) + this.listWebdavFiles = this.listWebdavFiles.bind(this) } private async setWritableRecursive(dirPath: string): Promise { @@ -186,7 +188,7 @@ class BackupManager { } async backupToWebdav(_: Electron.IpcMainInvokeEvent, data: string, webdavConfig: WebDavConfig) { - const filename = 'cherry-studio.backup.zip' + const filename = webdavConfig.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), { @@ -195,18 +197,48 @@ class BackupManager { } async restoreFromWebdav(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) { - const filename = 'cherry-studio.backup.zip' + const filename = webdavConfig.fileName || 'cherry-studio.backup.zip' const webdavClient = new WebDav(webdavConfig) - const retrievedFile = await webdavClient.getFileContents(filename) - const backupedFilePath = path.join(this.backupDir, filename) + try { + const retrievedFile = await webdavClient.getFileContents(filename) + const backupedFilePath = path.join(this.backupDir, filename) - if (!fs.existsSync(this.backupDir)) { - fs.mkdirSync(this.backupDir, { recursive: true }) + if (!fs.existsSync(this.backupDir)) { + fs.mkdirSync(this.backupDir, { recursive: true }) + } + + // sync为同步写,无须await + fs.writeFileSync(backupedFilePath, retrievedFile as Buffer) + + return await this.restore(_, backupedFilePath) + } catch (error: any) { + Logger.error('[backup] Failed to restore from WebDAV:', error) + throw new Error(error.message || 'Failed to restore backup file') } + } - await fs.writeFileSync(backupedFilePath, retrievedFile as Buffer) + listWebdavFiles = async (_: Electron.IpcMainInvokeEvent, config: WebDavConfig) => { + try { + const client = createClient(config.webdavHost, { + username: config.webdavUser, + password: config.webdavPass + }) - return await this.restore(_, backupedFilePath) + const response = await client.getDirectoryContents(config.webdavPath) + const files = Array.isArray(response) ? response : response.data + + return files + .filter((file: FileStat) => file.type === 'file' && file.basename.endsWith('.zip')) + .map((file: FileStat) => ({ + fileName: file.basename, + modifiedTime: file.lastmod, + size: file.size + })) + .sort((a, b) => new Date(b.modifiedTime).getTime() - new Date(a.modifiedTime).getTime()) + } catch (error: any) { + Logger.error('Failed to list WebDAV files:', error) + throw new Error(error.message || 'Failed to list backup files') + } } private async getDirSize(dirPath: string): Promise { diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 6aeb2ddf..4d0b8804 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -6,7 +6,12 @@ import { AppInfo, FileType, KnowledgeBaseParams, KnowledgeItem, LanguageVarious, import type { LoaderReturn } from '@shared/config/types' import type { OpenDialogOptions } from 'electron' import type { UpdateInfo } from 'electron-updater' -import { Readable } from 'stream' + +interface BackupFile { + fileName: string + modifiedTime: string + size: number +} declare global { interface Window { @@ -24,6 +29,9 @@ declare global { minApp: (options: { url: string; windowOptions?: Electron.BrowserWindowConstructorOptions }) => void reload: () => void clearCache: () => Promise<{ success: boolean; error?: string }> + system: { + getDeviceType: () => Promise<'mac' | 'windows' | 'linux'> + } zip: { compress: (text: string) => Promise decompress: (text: Buffer) => Promise @@ -33,6 +41,7 @@ declare global { restore: (backupPath: string) => Promise backupToWebdav: (data: string, webdavConfig: WebDavConfig) => Promise restoreFromWebdav: (webdavConfig: WebDavConfig) => Promise + listWebdavFiles: (webdavConfig: WebDavConfig) => Promise } file: { select: (options?: OpenDialogOptions) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 9965ea6d..831b59a7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -17,6 +17,9 @@ const api = { openWebsite: (url: string) => ipcRenderer.invoke('open:website', url), minApp: (url: string) => ipcRenderer.invoke('minapp', url), clearCache: () => ipcRenderer.invoke('app:clear-cache'), + system: { + getDeviceType: () => ipcRenderer.invoke('system:getDeviceType') + }, zip: { compress: (text: string) => ipcRenderer.invoke('zip:compress', text), decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text) @@ -27,7 +30,8 @@ const api = { restore: (backupPath: string) => ipcRenderer.invoke('backup:restore', backupPath), backupToWebdav: (data: string, webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:backupToWebdav', data, webdavConfig), - restoreFromWebdav: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:restoreFromWebdav', webdavConfig) + restoreFromWebdav: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:restoreFromWebdav', webdavConfig), + listWebdavFiles: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:listWebdavFiles', webdavConfig) }, file: { select: (options?: OpenDialogOptions) => ipcRenderer.invoke('file:select', options), diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 710c1e23..878a238e 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -762,6 +762,12 @@ "password": "WebDAV Password", "path": "WebDAV Path", "path.placeholder": "/backup", + "backup.modal.title": "Backup to WebDAV", + "backup.modal.filename.placeholder": "Please enter backup filename", + "restore.modal.title": "Restore from WebDAV", + "restore.modal.select.placeholder": "Please select a backup file to restore", + "restore.confirm.title": "Confirm Restore", + "restore.confirm.content": "Restoring from WebDAV will overwrite current data. Do you want to continue?", "restore.button": "Restore from WebDAV", "restore.content": "Restore from WebDAV will overwrite the current data, continue?", "restore.title": "Restore from WebDAV", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 78511cf3..81b2145e 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -762,6 +762,12 @@ "password": "WebDAV 密码", "path": "WebDAV 路径", "path.placeholder": "/backup", + "backup.modal.title": "备份到 WebDAV", + "backup.modal.filename.placeholder": "请输入备份文件名", + "restore.modal.title": "从 WebDAV 恢复", + "restore.modal.select.placeholder": "请选择要恢复的备份文件", + "restore.confirm.title": "确认恢复", + "restore.confirm.content": "从 WebDAV 恢复将会覆盖当前数据,是否继续?", "restore.button": "从 WebDAV 恢复", "restore.content": "从 WebDAV 恢复将覆盖当前数据,是否继续?", "restore.title": "从 WebDAV 恢复", diff --git a/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx b/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx index ba87dbe8..913af6f1 100644 --- a/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx @@ -13,13 +13,19 @@ import { setWebdavSyncInterval as _setWebdavSyncInterval, setWebdavUser as _setWebdavUser } from '@renderer/store/settings' -import { Button, Input, Select } from 'antd' +import { Button, Input, Modal, Select, Spin } from 'antd' import dayjs from 'dayjs' import { FC, useState } from 'react' import { useTranslation } from 'react-i18next' import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..' +interface BackupFile { + fileName: string + modifiedTime: string + size: number +} + const WebDavSettings: FC = () => { const { webdavHost: webDAVHost, @@ -38,6 +44,12 @@ const WebDavSettings: FC = () => { const [backuping, setBackuping] = useState(false) const [restoring, setRestoring] = useState(false) + const [isModalVisible, setIsModalVisible] = useState(false) + const [customFileName, setCustomFileName] = useState('') + const [isRestoreModalVisible, setIsRestoreModalVisible] = useState(false) + const [backupFiles, setBackupFiles] = useState([]) + const [selectedFile, setSelectedFile] = useState('') + const [loadingFiles, setLoadingFiles] = useState(false) const dispatch = useAppDispatch() const { theme } = useTheme() @@ -48,35 +60,6 @@ const WebDavSettings: FC = () => { // 把之前备份的文件定时上传到 webdav,首先先配置 webdav 的 host, port, user, pass, path - const onBackup = async () => { - if (!webdavHost) { - window.message.error({ content: t('message.error.invalid.webdav'), key: 'webdav-error' }) - return - } - setBackuping(true) - await backupToWebdav({ showMessage: true }) - setBackuping(false) - } - - const onRestore = async () => { - if (!webdavHost) { - window.message.error({ content: t('message.error.invalid.webdav'), key: 'webdav-error' }) - return - } - setRestoring(true) - await restoreFromWebdav() - setRestoring(false) - } - - const onPressRestore = () => { - window.modal.confirm({ - title: t('settings.data.webdav.restore.title'), - content: t('settings.data.webdav.restore.content'), - centered: true, - onOk: onRestore - }) - } - const onSyncIntervalChange = (value: number) => { setSyncInterval(value) dispatch(_setWebdavSyncInterval(value)) @@ -113,6 +96,88 @@ const WebDavSettings: FC = () => { ) } + const showBackupModal = async () => { + // 获取默认文件名 + const deviceType = await window.api.system.getDeviceType() + const timestamp = dayjs().format('YYYYMMDDHHmmss') + const defaultFileName = `cherry-studio.${timestamp}.${deviceType}.zip` + setCustomFileName(defaultFileName) + setIsModalVisible(true) + } + + const handleBackup = async () => { + setBackuping(true) + try { + await backupToWebdav({ showMessage: true, customFileName }) + } finally { + setBackuping(false) + setIsModalVisible(false) + } + } + + const handleCancel = () => { + setIsModalVisible(false) + } + + const showRestoreModal = async () => { + if (!webdavHost || !webdavUser || !webdavPass || !webdavPath) { + window.message.error({ content: t('message.error.invalid.webdav'), key: 'webdav-error' }) + return + } + + setIsRestoreModalVisible(true) + setLoadingFiles(true) + try { + const files = await window.api.backup.listWebdavFiles({ + webdavHost, + webdavUser, + webdavPass, + webdavPath + }) + setBackupFiles(files) + } catch (error: any) { + window.message.error({ content: error.message, key: 'list-files-error' }) + } finally { + setLoadingFiles(false) + } + } + + const handleRestore = async () => { + if (!selectedFile || !webdavHost || !webdavUser || !webdavPass || !webdavPath) { + window.message.error({ + content: !selectedFile ? t('message.error.no.file.selected') : t('message.error.invalid.webdav'), + key: 'restore-error' + }) + return + } + + window.modal.confirm({ + title: t('settings.data.webdav.restore.confirm.title'), + content: t('settings.data.webdav.restore.confirm.content'), + centered: true, + onOk: async () => { + setRestoring(true) + try { + await restoreFromWebdav(selectedFile) + setIsRestoreModalVisible(false) + } catch (error: any) { + window.message.error({ content: error.message, key: 'restore-error' }) + } finally { + setRestoring(false) + } + } + }) + } + + const formatFileOption = (file: BackupFile) => { + const date = dayjs(file.modifiedTime).format('YYYY-MM-DD HH:mm:ss') + const size = `${(file.size / 1024).toFixed(2)} KB` + return { + label: `${file.fileName} (${date}, ${size})`, + value: file.fileName + } + } + return ( {t('settings.data.webdav.title')} @@ -165,10 +230,10 @@ const WebDavSettings: FC = () => { {t('settings.general.backup.title')} - - @@ -198,6 +263,46 @@ const WebDavSettings: FC = () => { )} + <> + + setCustomFileName(e.target.value)} + placeholder={t('settings.data.webdav.backup.modal.filename.placeholder')} + /> + + + setIsRestoreModalVisible(false)} + okButtonProps={{ loading: restoring }} + width={600}> +
+