diff --git a/package.json b/package.json index 5a443c39..a04e19e2 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "electron-window-state": "^5.0.3", "fs-extra": "^11.2.0", "html2canvas": "^1.4.1", - "unzipper": "^0.12.3" + "unzipper": "^0.12.3", + "webdav": "^5.7.1" }, "devDependencies": { "@anthropic-ai/sdk": "^0.24.3", diff --git a/src/renderer/src/i18n/en-us.json b/src/renderer/src/i18n/en-us.json index 9c357fc1..19fdfd46 100644 --- a/src/renderer/src/i18n/en-us.json +++ b/src/renderer/src/i18n/en-us.json @@ -180,6 +180,13 @@ "general.backup.title": "Data Backup and Recovery", "general.backup.button": "Backup", "general.restore.button": "Restore", + "general.webdav.title": "WebDAV", + "general.webdav.host": "WebDAV Host, e.g. http://localhost:8080", + "general.webdav.user": "WebDAV User", + "general.webdav.password": "WebDAV Password", + "general.webdav.path": "WebDAV Path, e.g. /backup", + "general.webdav.backup.button": "Backup to WebDAV", + "general.webdav.restore.button": "Restore from WebDAV", "general.reset.title": "Data Reset", "general.reset.button": "Reset", "general.check_update_setting": "Check for updates", diff --git a/src/renderer/src/i18n/zh-cn.json b/src/renderer/src/i18n/zh-cn.json index 21b3f112..eaf5f3c9 100644 --- a/src/renderer/src/i18n/zh-cn.json +++ b/src/renderer/src/i18n/zh-cn.json @@ -182,6 +182,13 @@ "general.restore.button": "恢复", "general.reset.title": "重置数据", "general.reset.button": "重置", + "general.webdav.title": "WebDAV", + "general.webdav.host": "WebDAV Host, e.g. http://localhost:8080", + "general.webdav.user": "WebDAV User", + "general.webdav.password": "WebDAV Password", + "general.webdav.path": "WebDAV Path, e.g. /backup", + "general.webdav.backup.button": "备份到 WebDAV", + "general.webdav.restore.button": "从 WebDAV 恢复", "general.check_update_setting": "更新设置", "general.manual_update_check": "手动检查更新", "general.auto_update_check": "自动检查更新", diff --git a/src/renderer/src/i18n/zh-tw.json b/src/renderer/src/i18n/zh-tw.json index 88b0b876..6af19d4d 100644 --- a/src/renderer/src/i18n/zh-tw.json +++ b/src/renderer/src/i18n/zh-tw.json @@ -180,6 +180,13 @@ "general.backup.title": "資料備份與復原", "general.backup.button": "備份", "general.restore.button": "復原", + "general.webdav.title": "WebDAV", + "general.webdav.host": "WebDAV Host, e.g. http://localhost:8080", + "general.webdav.user": "WebDAV User", + "general.webdav.password": "WebDAV Password", + "general.webdav.path": "WebDAV Path, e.g. /backup", + "general.webdav.backup.button": "從 WebDAV 備份", + "general.webdav.restore.button": "從 WebDAV 恢復", "general.reset.title": "資料重置", "general.reset.button": "重置", "general.check_update_setting": "更新設定", diff --git a/src/renderer/src/pages/settings/GeneralSettings.tsx b/src/renderer/src/pages/settings/GeneralSettings.tsx index fb048f67..6aedd008 100644 --- a/src/renderer/src/pages/settings/GeneralSettings.tsx +++ b/src/renderer/src/pages/settings/GeneralSettings.tsx @@ -3,10 +3,16 @@ import { HStack } from '@renderer/components/Layout' import { isMac } from '@renderer/config/constant' import { useSettings } from '@renderer/hooks/useSettings' import i18n from '@renderer/i18n' -import { backup, reset, restore } from '@renderer/services/backup' +import { backup, reset, restore, backupToWebdav, restoreFromWebdav } from '@renderer/services/backup' import { useAppDispatch } from '@renderer/store' import { setClickAssistantToShowTopic, setLanguage, setManualUpdateCheck } from '@renderer/store/settings' import { setProxyUrl as _setProxyUrl } from '@renderer/store/settings' +import { + setWebdavHost as _setWebdavHost, + setWebdavPass as _setWebdavPass, + setWebdavPath as _setWebdavPath, + setWebdavUser as _setWebdavUser +} from '@renderer/store/settings' import { ThemeMode } from '@renderer/types' import { isValidProxyUrl } from '@renderer/utils' import { Button, Input, Select, Switch } from 'antd' @@ -26,9 +32,19 @@ const GeneralSettings: FC = () => { manualUpdateCheck, setTheme, setWindowStyle, - setTopicPosition + setTopicPosition, + + webdavHost: webDAVHost, + webdavUser: webDAVUser, + webdavPass: webDAVPass, + webdavPath: webDAVPath } = useSettings() const [proxyUrl, setProxyUrl] = useState(storeProxyUrl) + const [webdavHost, setWebdavHost] = useState(webDAVHost) + const [webdavUser, setWebdavUser] = useState(webDAVUser) + const [webdavPass, setWebdavPass] = useState(webDAVPass) + const [webdavPath, setWebdavPath] = useState(webDAVPath) + const dispatch = useAppDispatch() const { t } = useTranslation() @@ -48,6 +64,20 @@ const GeneralSettings: FC = () => { window.api.setProxy(proxyUrl) } + const onSetWebdav = () => { + if (!webdavHost || !webdavUser || !webdavPass || !webdavPath) { + window.message.error({ content: t('message.error.invalid.webdav'), key: 'webdav-error' }) + return + } + + console.log('webdav', webdavHost, webdavUser, webdavPass, webdavPath) + + dispatch(_setWebdavHost(webdavHost)) + dispatch(_setWebdavUser(webdavUser)) + dispatch(_setWebdavPass(webdavPass)) + dispatch(_setWebdavPath(webdavPath)) + } + return ( {t('settings.general.title')} @@ -147,6 +177,42 @@ const GeneralSettings: FC = () => { /> + + {/* 把之前备份的文件定时上传到 webdav,首先先配置 webdav 的 host, port, user, pass, path */} + {t('settings.general.webdav.title')} + + setWebdavHost(e.target.value)} + style={{ width: 280 }} + type="url" + onBlur={onSetWebdav} + /> + setWebdavUser(e.target.value)} + style={{ width: 120 }} + onBlur={onSetWebdav} + /> + setWebdavPass(e.target.value)} + style={{ width: 140 }} + onBlur={onSetWebdav} + /> + setWebdavPath(e.target.value)} + style={{ width: 220 }} + onBlur={onSetWebdav} + /> + + + {t('settings.general.backup.title')} @@ -156,6 +222,13 @@ const GeneralSettings: FC = () => { + {/* 添加 在线备份 在线还原 按钮 */} + + diff --git a/src/renderer/src/services/backup.ts b/src/renderer/src/services/backup.ts index b77ddc84..44b8f10e 100644 --- a/src/renderer/src/services/backup.ts +++ b/src/renderer/src/services/backup.ts @@ -2,6 +2,9 @@ import db from '@renderer/databases' import i18n from '@renderer/i18n' import dayjs from 'dayjs' import localforage from 'localforage' +import store from '@renderer/store' + +import { createClient } from 'webdav' export async function backup() { const version = 3 @@ -40,33 +43,10 @@ export async function restore() { data = JSON.parse(await window.api.decompress(file.content)) } - if (data.version === 1) { - await clearDatabase() + // 处理文件内容 + console.log('Parsed file content:', data) - for (const { key, value } of data.indexedDB) { - if (key.startsWith('topic:')) { - await db.table('topics').add({ id: value.id, messages: value.messages }) - } - if (key === 'image://avatar') { - await db.table('settings').add({ id: key, value }) - } - } - - await localStorage.setItem('persist:cherry-studio', data.localStorage['persist:cherry-studio']) - window.message.success({ content: i18n.t('message.restore.success'), key: 'restore' }) - setTimeout(() => window.api.reload(), 1000) - return - } - - if (data.version >= 2) { - localStorage.setItem('persist:cherry-studio', data.localStorage['persist:cherry-studio']) - await restoreDatabase(data.indexedDB) - window.message.success({ content: i18n.t('message.restore.success'), key: 'restore' }) - setTimeout(() => window.api.reload(), 1000) - return - } - - window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' }) + await handleData(data) } catch (error) { console.error(error) window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' }) @@ -96,8 +76,158 @@ export async function reset() { }) } +// 备份到 webdav +export async function backupToWebdav() { + // 先走之前的 backup 流程,存储到临时文件 + const version = 3 + const time = new Date().getTime() + + const data = { + time, + version, + localStorage, + indexedDB: await backupDatabase() + } + + const filename = `cherry-studio.backup.json` + const fileContent = JSON.stringify(data) + + // 获取 userSetting 里的 WebDAV 配置 + const { webdavHost, webdavUser, webdavPass, webdavPath } = store.getState().settings + // console.log('backup.backupToWebdav', webdavHost, webdavUser, webdavPass, webdavPath) + + let host = webdavHost + if (!host.startsWith('http://') && !host.startsWith('https://')) { + host = `http://${host}` + } + console.log('backup.backupToWebdav', host) + + // 创建 WebDAV 客户端 + const client = createClient( + host, // WebDAV 服务器地址 + { + username: webdavUser, // 用户名 + password: webdavPass // 密码 + } + ) + + // 上传文件到 WebDAV + const remoteFilePath = `${webdavPath}/${filename}` + + // 先检查创建目录 + try { + if (!(await client.exists(webdavPath))) { + await client.createDirectory(webdavPath) + } + } catch (error) { + console.error('Error creating directory on WebDAV:', error) + } + + // 上传文件 + try { + await client.putFileContents(remoteFilePath, fileContent, { overwrite: true }) + console.log('File uploaded successfully!') + window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' }) + } catch (error) { + console.error('Error uploading file to WebDAV:', error) + } +} + +// 从 webdav 恢复 +export async function restoreFromWebdav() { + const filename = `cherry-studio.backup.json` + + // 获取 userSetting 里的 WebDAV 配置 + const { webdavHost, webdavUser, webdavPass, webdavPath } = store.getState().settings + // console.log('backup.restoreFromWebdav', webdavHost, webdavUser, webdavPass, webdavPath) + + let host = webdavHost + if (!host.startsWith('http://') && !host.startsWith('https://')) { + host = `http://${host}` + } + console.log('backup.restoreFromWebdav', host) + + // 创建 WebDAV 客户端 + const client = createClient( + host, // WebDAV 服务器地址 + { + username: webdavUser, // 用户名 + password: webdavPass // 密码 + } + ) + + // 上传文件到 WebDAV + const remoteFilePath = `${webdavPath}/${filename}` + + // 下载文件 + try { + // 下载文件内容 + const fileContent = await client.getFileContents(remoteFilePath, { format: 'text' }) + console.log('File downloaded successfully!', fileContent) + + // 处理文件内容 + const data = parseFileContent(fileContent.toString()) + console.log('Parsed file content:', data) + + await handleData(data) + } catch (error) { + console.error('Error downloading file from WebDAV:', error) + window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' }) + } +} + /************************************* Backup Utils ************************************** */ +function parseFileContent(fileContent: string | Buffer | { data: string | Buffer } | ArrayBuffer): any { + let fileContentString: string + + if (typeof fileContent === 'string') { + fileContentString = fileContent + } else if (Buffer.isBuffer(fileContent)) { + fileContentString = fileContent.toString('utf-8') + } else if (fileContent instanceof ArrayBuffer) { + fileContentString = Buffer.from(fileContent).toString('utf-8') + } else if (fileContent && typeof fileContent.data === 'string') { + fileContentString = fileContent.data + } else if (fileContent && Buffer.isBuffer(fileContent.data)) { + fileContentString = fileContent.data.toString('utf-8') + } else { + throw new Error('Unsupported file content type') + } + + return JSON.parse(fileContentString) +} + +async function handleData(data: any) { + if (data.version === 1) { + await clearDatabase() + + for (const { key, value } of data.indexedDB) { + if (key.startsWith('topic:')) { + await db.table('topics').add({ id: value.id, messages: value.messages }) + } + if (key === 'image://avatar') { + await db.table('settings').add({ id: key, value }) + } + } + + await localStorage.setItem('persist:cherry-studio', data.localStorage['persist:cherry-studio']) + window.message.success({ content: i18n.t('message.restore.success'), key: 'restore' }) + setTimeout(() => window.api.reload(), 1000) + return + } + + if (data.version >= 2) { + localStorage.setItem('persist:cherry-studio', data.localStorage['persist:cherry-studio']) + await restoreDatabase(data.indexedDB) + window.message.success({ content: i18n.t('message.restore.success'), key: 'restore' }) + setTimeout(() => window.api.reload(), 1000) + return + } + + window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' }) +} + async function backupDatabase() { const tables = db.tables const backup = {} diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index d6b95409..3603ccb0 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -20,6 +20,12 @@ export interface SettingsState { pasteLongTextAsFile: boolean clickAssistantToShowTopic: boolean manualUpdateCheck: boolean + + // webdav 配置 host, user, pass, path + webdavHost: string + webdavUser: string + webdavPass: string + webdavPath: string } const initialState: SettingsState = { @@ -38,7 +44,12 @@ const initialState: SettingsState = { topicPosition: 'right', pasteLongTextAsFile: true, clickAssistantToShowTopic: false, - manualUpdateCheck: false + manualUpdateCheck: false, + + webdavHost: '', + webdavUser: '', + webdavPass: '', + webdavPath: '/cherry-studio' } const settingsSlice = createSlice({ @@ -99,6 +110,18 @@ const settingsSlice = createSlice({ }, setManualUpdateCheck: (state, action: PayloadAction) => { state.manualUpdateCheck = action.payload + }, + setWebdavHost: (state, action: PayloadAction) => { + state.webdavHost = action.payload + }, + setWebdavUser: (state, action: PayloadAction) => { + state.webdavUser = action.payload + }, + setWebdavPass: (state, action: PayloadAction) => { + state.webdavPass = action.payload + }, + setWebdavPath: (state, action: PayloadAction) => { + state.webdavPath = action.payload } } }) @@ -121,7 +144,11 @@ export const { setTopicPosition, setPasteLongTextAsFile, setClickAssistantToShowTopic, - setManualUpdateCheck + setManualUpdateCheck, + setWebdavHost, + setWebdavUser, + setWebdavPass, + setWebdavPath } = settingsSlice.actions export default settingsSlice.reducer