feat: add webdav settings component and backup user data files #69
This commit is contained in:
parent
2e1b433365
commit
33b83bf242
@ -40,7 +40,7 @@
|
||||
"fs-extra": "^11.2.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"unzipper": "^0.12.3",
|
||||
"webdav": "^5.7.1"
|
||||
"webdav": "4.11.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "^0.24.3",
|
||||
|
||||
@ -29,8 +29,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
|
||||
ipcMain.handle('reload', () => mainWindow.reload())
|
||||
|
||||
ipcMain.handle('backup:save', backupManager.backup)
|
||||
ipcMain.handle('backup:backup', backupManager.backup)
|
||||
ipcMain.handle('backup:restore', backupManager.restore)
|
||||
ipcMain.handle('backup:backupToWebdav', backupManager.backupToWebdav)
|
||||
ipcMain.handle('backup:restoreFromWebdav', backupManager.restoreFromWebdav)
|
||||
|
||||
ipcMain.handle('file:open', fileManager.open)
|
||||
ipcMain.handle('file:save', fileManager.save)
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { WebDavConfig } from '@types'
|
||||
import archiver from 'archiver'
|
||||
import { app } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
@ -5,16 +6,25 @@ import * as fs from 'fs-extra'
|
||||
import * as path from 'path'
|
||||
import * as unzipper from 'unzipper'
|
||||
|
||||
import WebDav from './WebDav'
|
||||
|
||||
class BackupManager {
|
||||
private tempDir: string
|
||||
private tempDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup', 'temp')
|
||||
private backupDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup')
|
||||
|
||||
constructor() {
|
||||
this.tempDir = path.join(app.getPath('temp'), 'CherryStudio', 'backup')
|
||||
this.backup = this.backup.bind(this)
|
||||
this.restore = this.restore.bind(this)
|
||||
this.backupToWebdav = this.backupToWebdav.bind(this)
|
||||
this.restoreFromWebdav = this.restoreFromWebdav.bind(this)
|
||||
}
|
||||
|
||||
async backup(_: Electron.IpcMainInvokeEvent, data: string, fileName: string, destinationPath: string): Promise<void> {
|
||||
async backup(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
fileName: string,
|
||||
data: string,
|
||||
destinationPath: string = this.backupDir
|
||||
): Promise<string> {
|
||||
try {
|
||||
// 创建临时目录
|
||||
await fs.ensureDir(this.tempDir)
|
||||
@ -29,7 +39,7 @@ class BackupManager {
|
||||
await fs.copy(sourcePath, tempDataDir)
|
||||
|
||||
// 创建 zip 文件
|
||||
const output = fs.createWriteStream(path.join(destinationPath, `${fileName}.zip`))
|
||||
const output = fs.createWriteStream(path.join(destinationPath, fileName))
|
||||
const archive = archiver('zip', { zlib: { level: 9 } })
|
||||
|
||||
archive.pipe(output)
|
||||
@ -40,42 +50,60 @@ class BackupManager {
|
||||
await fs.remove(this.tempDir)
|
||||
|
||||
Logger.log('Backup completed successfully')
|
||||
|
||||
const backupedFilePath = path.join(destinationPath, fileName)
|
||||
|
||||
return backupedFilePath
|
||||
} catch (error) {
|
||||
Logger.error('Backup failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async restore(_: Electron.IpcMainInvokeEvent, backupPath: string): Promise<{ data: string; success: boolean }> {
|
||||
try {
|
||||
// 创建临时目录
|
||||
await fs.ensureDir(this.tempDir)
|
||||
async restore(_: Electron.IpcMainInvokeEvent, backupPath: string): Promise<string> {
|
||||
// 创建临时目录
|
||||
await fs.ensureDir(this.tempDir)
|
||||
|
||||
// 解压备份文件到临时目录
|
||||
await fs
|
||||
.createReadStream(backupPath)
|
||||
.pipe(unzipper.Extract({ path: this.tempDir }))
|
||||
.promise()
|
||||
// 解压备份文件到临时目录
|
||||
await fs
|
||||
.createReadStream(backupPath)
|
||||
.pipe(unzipper.Extract({ path: this.tempDir }))
|
||||
.promise()
|
||||
|
||||
// 读取 data.json
|
||||
const dataPath = path.join(this.tempDir, 'data.json')
|
||||
const data = await fs.readFile(dataPath, 'utf-8')
|
||||
// 读取 data.json
|
||||
const dataPath = path.join(this.tempDir, 'data.json')
|
||||
const data = await fs.readFile(dataPath, 'utf-8')
|
||||
|
||||
// 恢复 Data 目录
|
||||
const sourcePath = path.join(this.tempDir, 'Data')
|
||||
const destPath = path.join(app.getPath('userData'), 'Data')
|
||||
await fs.remove(destPath)
|
||||
await fs.copy(sourcePath, destPath)
|
||||
// 恢复 Data 目录
|
||||
const sourcePath = path.join(this.tempDir, 'Data')
|
||||
const destPath = path.join(app.getPath('userData'), 'Data')
|
||||
await fs.remove(destPath)
|
||||
await fs.copy(sourcePath, destPath)
|
||||
|
||||
// 清理临时目录
|
||||
await fs.remove(this.tempDir)
|
||||
// 清理临时目录
|
||||
await fs.remove(this.tempDir)
|
||||
|
||||
Logger.log('Restore completed successfully')
|
||||
return { data, success: true }
|
||||
} catch (error) {
|
||||
Logger.error('Restore failed:', error)
|
||||
return { data: '', success: false }
|
||||
}
|
||||
Logger.log('Restore completed successfully')
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
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)
|
||||
await fs.writeFileSync(backupedFilePath, retrievedFile as Buffer)
|
||||
return await this.restore(_, backupedFilePath)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
66
src/main/services/WebDav.ts
Normal file
66
src/main/services/WebDav.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { WebDavConfig } from '@types'
|
||||
import Logger from 'electron-log'
|
||||
import Stream from 'stream'
|
||||
import { BufferLike, createClient, GetFileContentsOptions, PutFileContentsOptions, WebDAVClient } from 'webdav'
|
||||
|
||||
export default class WebDav {
|
||||
public instance: WebDAVClient | undefined
|
||||
private webdavPath: string
|
||||
|
||||
constructor(params: WebDavConfig) {
|
||||
this.webdavPath = params.webdavPath.replace('/', '')
|
||||
|
||||
this.instance = createClient(params.webdavHost, {
|
||||
username: params.webdavUser,
|
||||
password: params.webdavPass
|
||||
})
|
||||
|
||||
this.putFileContents = this.putFileContents.bind(this)
|
||||
this.getFileContents = this.getFileContents.bind(this)
|
||||
}
|
||||
|
||||
public putFileContents = async (
|
||||
filename: string,
|
||||
data: string | BufferLike | Stream.Readable,
|
||||
options?: PutFileContentsOptions
|
||||
) => {
|
||||
if (!this.instance) {
|
||||
return new Error('WebDAV client not initialized')
|
||||
}
|
||||
|
||||
try {
|
||||
if (!(await this.instance.exists(this.webdavPath))) {
|
||||
await this.instance.createDirectory(this.webdavPath, {
|
||||
recursive: true
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('[WebDAV] Error creating directory on WebDAV:', error)
|
||||
throw error
|
||||
}
|
||||
|
||||
const remoteFilePath = `${this.webdavPath}/${filename}`
|
||||
|
||||
try {
|
||||
return await this.instance.putFileContents(remoteFilePath, data, options)
|
||||
} catch (error) {
|
||||
Logger.error('[WebDAV] Error putting file contents on WebDAV:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public getFileContents = async (filename: string, options?: GetFileContentsOptions) => {
|
||||
if (!this.instance) {
|
||||
throw new Error('WebDAV client not initialized')
|
||||
}
|
||||
|
||||
const remoteFilePath = `${this.webdavPath}/${filename}`
|
||||
|
||||
try {
|
||||
return await this.instance.getFileContents(remoteFilePath, options)
|
||||
} catch (error) {
|
||||
Logger.error('[WebDAV] Error getting file contents on WebDAV:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/preload/index.d.ts
vendored
8
src/preload/index.d.ts
vendored
@ -1,6 +1,8 @@
|
||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||
import { FileType } from '@renderer/types'
|
||||
import { WebDavConfig } from '@renderer/types'
|
||||
import type { OpenDialogOptions } from 'electron'
|
||||
import { Readable } from 'stream'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@ -20,8 +22,10 @@ declare global {
|
||||
compress: (text: string) => Promise<Buffer>
|
||||
decompress: (text: Buffer) => Promise<string>
|
||||
backup: {
|
||||
save: (data: string, fileName: string, destinationPath: string) => Promise<void>
|
||||
restore: (backupPath: string) => Promise<{ data: string; success: boolean }>
|
||||
backup: (fileName: string, data: string, destinationPath?: string) => Promise<Readable>
|
||||
restore: (backupPath: string) => Promise<string>
|
||||
backupToWebdav: (data: string, webdavConfig: WebDavConfig) => Promise<boolean>
|
||||
restoreFromWebdav: (webdavConfig: WebDavConfig) => Promise<string>
|
||||
}
|
||||
file: {
|
||||
select: (options?: OpenDialogOptions) => Promise<FileType[] | null>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import { WebDavConfig } from '@types'
|
||||
import { contextBridge, ipcRenderer, OpenDialogOptions } from 'electron'
|
||||
|
||||
// Custom APIs for renderer
|
||||
@ -11,10 +12,12 @@ const api = {
|
||||
minApp: (url: string) => ipcRenderer.invoke('minapp', url),
|
||||
reload: () => ipcRenderer.invoke('reload'),
|
||||
backup: {
|
||||
save: (data: string, fileName: string, destinationPath: string) => {
|
||||
ipcRenderer.invoke('backup:save', data, fileName, destinationPath)
|
||||
},
|
||||
restore: (backupPath: string) => ipcRenderer.invoke('backup:restore', backupPath)
|
||||
backup: (fileName: string, data: string, destinationPath?: string) =>
|
||||
ipcRenderer.invoke('backup:backup', fileName, data, destinationPath),
|
||||
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)
|
||||
},
|
||||
file: {
|
||||
select: (options?: OpenDialogOptions) => ipcRenderer.invoke('file:select', options),
|
||||
|
||||
@ -46,12 +46,14 @@
|
||||
"error.enter.api.host": "Please enter your API host first",
|
||||
"error.enter.model": "Please select a model first",
|
||||
"error.invalid.proxy.url": "Invalid proxy URL",
|
||||
"error.invalid.webdav": "Invalid WebDAV settings",
|
||||
"api.connection.failed": "Connection failed",
|
||||
"api.connection.success": "Connection successful",
|
||||
"chat.completion.paused": "Chat completion paused",
|
||||
"switch.disabled": "Switching is disabled while the assistant is generating",
|
||||
"restore.success": "Restored successfully",
|
||||
"backup.success": "Backup successful",
|
||||
"backup.failed": "Backup failed",
|
||||
"reset.confirm.content": "Are you sure you want to clear all data?",
|
||||
"reset.double.confirm.title": "DATA LOST !!!",
|
||||
"reset.double.confirm.content": "All data will be lost, do you want to continue?",
|
||||
@ -180,11 +182,14 @@
|
||||
"general.backup.title": "Data Backup and Recovery",
|
||||
"general.backup.button": "Backup",
|
||||
"general.restore.button": "Restore",
|
||||
"general.view_webdav_settings": "View WebDAV settings",
|
||||
"general.webdav.title": "WebDAV",
|
||||
"general.webdav.host": "WebDAV Host, e.g. http://localhost:8080",
|
||||
"general.webdav.host": "WebDAV Host",
|
||||
"general.webdav.host.placeholder": "http://localhost:8080",
|
||||
"general.webdav.user": "WebDAV User",
|
||||
"general.webdav.password": "WebDAV Password",
|
||||
"general.webdav.path": "WebDAV Path, e.g. /backup",
|
||||
"general.webdav.path": "WebDAV Path",
|
||||
"general.webdav.path.placeholder": "/backup",
|
||||
"general.webdav.backup.button": "Backup to WebDAV",
|
||||
"general.webdav.restore.button": "Restore from WebDAV",
|
||||
"general.reset.title": "Data Reset",
|
||||
|
||||
@ -46,12 +46,14 @@
|
||||
"error.enter.api.host": "请输入您的 API 地址",
|
||||
"error.enter.model": "请选择一个模型",
|
||||
"error.invalid.proxy.url": "无效的代理地址",
|
||||
"error.invalid.webdav": "无效的 WebDAV 设置",
|
||||
"api.connection.failed": "连接失败",
|
||||
"api.connection.success": "连接成功",
|
||||
"chat.completion.paused": "会话已停止",
|
||||
"switch.disabled": "模型回复完成后才能切换",
|
||||
"restore.success": "恢复成功",
|
||||
"backup.success": "备份成功",
|
||||
"backup.failed": "备份失败",
|
||||
"reset.confirm.content": "确定要重置所有数据吗?",
|
||||
"reset.double.confirm.title": "数据丢失!!!",
|
||||
"reset.double.confirm.content": "你的全部数据都会丢失,如果没有备份数据,将无法恢复,确定要继续吗?",
|
||||
@ -182,11 +184,14 @@
|
||||
"general.restore.button": "恢复",
|
||||
"general.reset.title": "重置数据",
|
||||
"general.reset.button": "重置",
|
||||
"general.view_webdav_settings": "查看 WebDAV 设置",
|
||||
"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.host": "WebDAV 地址",
|
||||
"general.webdav.host.placeholder": "http://localhost:8080",
|
||||
"general.webdav.user": "WebDAV 用户名",
|
||||
"general.webdav.password": "WebDAV 密码",
|
||||
"general.webdav.path": "WebDAV 路径",
|
||||
"general.webdav.path.placeholder": "/backup",
|
||||
"general.webdav.backup.button": "备份到 WebDAV",
|
||||
"general.webdav.restore.button": "从 WebDAV 恢复",
|
||||
"general.check_update_setting": "更新设置",
|
||||
|
||||
@ -46,12 +46,14 @@
|
||||
"error.enter.api.host": "請先輸入您的 API 主機地址",
|
||||
"error.enter.model": "請先選擇一個模型",
|
||||
"error.invalid.proxy.url": "無效的代理 URL",
|
||||
"error.invalid.webdav": "無效的 WebDAV 設定",
|
||||
"api.connection.failed": "連接失敗",
|
||||
"api.connection.success": "連接成功",
|
||||
"chat.completion.paused": "聊天完成已暫停",
|
||||
"switch.disabled": "助手生成回覆時無法切換",
|
||||
"restore.success": "恢復成功",
|
||||
"backup.success": "備份成功",
|
||||
"backup.failed": "備份失敗",
|
||||
"reset.confirm.content": "確定要清除所有資料嗎?",
|
||||
"reset.double.confirm.title": "資料將會丟失!!!",
|
||||
"reset.double.confirm.content": "所有資料將會被清除,您確定要繼續嗎?",
|
||||
@ -180,11 +182,14 @@
|
||||
"general.backup.title": "資料備份與復原",
|
||||
"general.backup.button": "備份",
|
||||
"general.restore.button": "復原",
|
||||
"general.view_webdav_settings": "查看 WebDAV 設定",
|
||||
"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.host": "WebDAV 主機位址",
|
||||
"general.webdav.host.placeholder": "http://localhost:8080",
|
||||
"general.webdav.user": "WebDAV 使用者名稱",
|
||||
"general.webdav.password": "WebDAV 密碼",
|
||||
"general.webdav.path": "WebDAV Path",
|
||||
"general.webdav.path.placeholder": "/backup",
|
||||
"general.webdav.backup.button": "從 WebDAV 備份",
|
||||
"general.webdav.restore.button": "從 WebDAV 恢復",
|
||||
"general.reset.title": "資料重置",
|
||||
|
||||
@ -1,248 +0,0 @@
|
||||
import { FolderOpenOutlined, SaveOutlined } from '@ant-design/icons'
|
||||
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, 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'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SettingContainer, SettingDivider, SettingRow, SettingRowTitle, SettingTitle } from '.'
|
||||
|
||||
const GeneralSettings: FC = () => {
|
||||
const {
|
||||
language,
|
||||
proxyUrl: storeProxyUrl,
|
||||
theme,
|
||||
windowStyle,
|
||||
topicPosition,
|
||||
clickAssistantToShowTopic,
|
||||
manualUpdateCheck,
|
||||
setTheme,
|
||||
setWindowStyle,
|
||||
setTopicPosition,
|
||||
|
||||
webdavHost: webDAVHost,
|
||||
webdavUser: webDAVUser,
|
||||
webdavPass: webDAVPass,
|
||||
webdavPath: webDAVPath
|
||||
} = useSettings()
|
||||
const [proxyUrl, setProxyUrl] = useState<string | undefined>(storeProxyUrl)
|
||||
const [webdavHost, setWebdavHost] = useState<string | undefined>(webDAVHost)
|
||||
const [webdavUser, setWebdavUser] = useState<string | undefined>(webDAVUser)
|
||||
const [webdavPass, setWebdavPass] = useState<string | undefined>(webDAVPass)
|
||||
const [webdavPath, setWebdavPath] = useState<string | undefined>(webDAVPath)
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const onSelectLanguage = (value: string) => {
|
||||
dispatch(setLanguage(value))
|
||||
localStorage.setItem('language', value)
|
||||
i18n.changeLanguage(value)
|
||||
}
|
||||
|
||||
const onSetProxyUrl = () => {
|
||||
if (proxyUrl && !isValidProxyUrl(proxyUrl)) {
|
||||
window.message.error({ content: t('message.error.invalid.proxy.url'), key: 'proxy-error' })
|
||||
return
|
||||
}
|
||||
|
||||
dispatch(_setProxyUrl(proxyUrl))
|
||||
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 (
|
||||
<SettingContainer>
|
||||
<SettingTitle>{t('settings.general.title')}</SettingTitle>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('common.language')}</SettingRowTitle>
|
||||
<Select
|
||||
defaultValue={language || 'en-US'}
|
||||
style={{ width: 180 }}
|
||||
onChange={onSelectLanguage}
|
||||
options={[
|
||||
{ value: 'zh-CN', label: '中文' },
|
||||
{ value: 'zh-TW', label: '中文(繁体)' },
|
||||
{ value: 'en-US', label: 'English' }
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.theme.title')}</SettingRowTitle>
|
||||
<Select
|
||||
defaultValue={theme}
|
||||
style={{ width: 180 }}
|
||||
onChange={setTheme}
|
||||
options={[
|
||||
{ value: ThemeMode.light, label: t('settings.theme.light') },
|
||||
{ value: ThemeMode.dark, label: t('settings.theme.dark') },
|
||||
{ value: ThemeMode.auto, label: t('settings.theme.auto') }
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
{isMac && (
|
||||
<>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.theme.window.style.title')}</SettingRowTitle>
|
||||
<Select
|
||||
defaultValue={windowStyle || 'opaque'}
|
||||
style={{ width: 180 }}
|
||||
onChange={setWindowStyle}
|
||||
options={[
|
||||
{ value: 'transparent', label: t('settings.theme.window.style.transparent') },
|
||||
{ value: 'opaque', label: t('settings.theme.window.style.opaque') }
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.topic.position')}</SettingRowTitle>
|
||||
<Select
|
||||
defaultValue={topicPosition || 'right'}
|
||||
style={{ width: 180 }}
|
||||
onChange={setTopicPosition}
|
||||
options={[
|
||||
{ value: 'left', label: t('settings.topic.position.left') },
|
||||
{ value: 'right', label: t('settings.topic.position.right') }
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
{topicPosition === 'left' && (
|
||||
<>
|
||||
<SettingRow style={{ minHeight: 32 }}>
|
||||
<SettingRowTitle>{t('settings.advanced.click_assistant_switch_to_topics')}</SettingRowTitle>
|
||||
<Switch
|
||||
checked={clickAssistantToShowTopic}
|
||||
onChange={(checked) => dispatch(setClickAssistantToShowTopic(checked))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
</>
|
||||
)}
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.general.check_update_setting')}</SettingRowTitle>
|
||||
<Select
|
||||
defaultValue={manualUpdateCheck ?? false}
|
||||
style={{ width: 180 }}
|
||||
onChange={(v) => dispatch(setManualUpdateCheck(v))}
|
||||
options={[
|
||||
{ value: false, label: t('settings.general.auto_update_check') },
|
||||
{ value: true, label: t('settings.general.manual_update_check') }
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.proxy.title')}</SettingRowTitle>
|
||||
<Input
|
||||
placeholder="socks5://127.0.0.1:6153"
|
||||
value={proxyUrl}
|
||||
onChange={(e) => setProxyUrl(e.target.value)}
|
||||
style={{ width: 180 }}
|
||||
onBlur={() => onSetProxyUrl()}
|
||||
type="url"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
{/* 把之前备份的文件定时上传到 webdav,首先先配置 webdav 的 host, port, user, pass, path */}
|
||||
<SettingRowTitle>{t('settings.general.webdav.title')}</SettingRowTitle>
|
||||
<HStack gap="5px">
|
||||
<Input
|
||||
placeholder={t('settings.general.webdav.host')}
|
||||
value={webdavHost}
|
||||
onChange={(e) => setWebdavHost(e.target.value)}
|
||||
style={{ width: 280 }}
|
||||
type="url"
|
||||
onBlur={onSetWebdav}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('settings.general.webdav.user')}
|
||||
value={webdavUser}
|
||||
onChange={(e) => setWebdavUser(e.target.value)}
|
||||
style={{ width: 120 }}
|
||||
onBlur={onSetWebdav}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('settings.general.webdav.password')}
|
||||
value={webdavPass}
|
||||
onChange={(e) => setWebdavPass(e.target.value)}
|
||||
style={{ width: 140 }}
|
||||
onBlur={onSetWebdav}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('settings.general.webdav.path')}
|
||||
value={webdavPath}
|
||||
onChange={(e) => setWebdavPath(e.target.value)}
|
||||
style={{ width: 220 }}
|
||||
onBlur={onSetWebdav}
|
||||
/>
|
||||
</HStack>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle>
|
||||
<HStack gap="5px" justifyContent="space-between">
|
||||
<Button onClick={backup} icon={<SaveOutlined />}>
|
||||
{t('settings.general.backup.button')}
|
||||
</Button>
|
||||
<Button onClick={restore} icon={<FolderOpenOutlined />}>
|
||||
{t('settings.general.restore.button')}
|
||||
</Button>
|
||||
{/* 添加 在线备份 在线还原 按钮 */}
|
||||
<Button onClick={backupToWebdav} icon={<SaveOutlined />}>
|
||||
{t('settings.general.webdav.backup.button')}
|
||||
</Button>
|
||||
<Button onClick={restoreFromWebdav} icon={<FolderOpenOutlined />}>
|
||||
{t('settings.general.webdav.restore.button')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.general.reset.title')}</SettingRowTitle>
|
||||
<HStack gap="5px">
|
||||
<Button onClick={reset} danger>
|
||||
{t('settings.general.reset.button')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
</SettingContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default GeneralSettings
|
||||
@ -0,0 +1,195 @@
|
||||
import { FolderOpenOutlined, SaveOutlined } from '@ant-design/icons'
|
||||
import { HStack, VStack } 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 { useAppDispatch } from '@renderer/store'
|
||||
import { setClickAssistantToShowTopic, setLanguage, setManualUpdateCheck } from '@renderer/store/settings'
|
||||
import { setProxyUrl as _setProxyUrl } from '@renderer/store/settings'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { isValidProxyUrl } from '@renderer/utils'
|
||||
import { Button, Input, Select, Switch } from 'antd'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, Route, Routes } from 'react-router-dom'
|
||||
|
||||
import { SettingContainer, SettingDivider, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||
import WebDavSettings from './WebDavSettings'
|
||||
|
||||
const GeneralSettings: FC = () => {
|
||||
const {
|
||||
language,
|
||||
proxyUrl: storeProxyUrl,
|
||||
theme,
|
||||
windowStyle,
|
||||
topicPosition,
|
||||
clickAssistantToShowTopic,
|
||||
manualUpdateCheck,
|
||||
setTheme,
|
||||
setWindowStyle,
|
||||
setTopicPosition
|
||||
} = useSettings()
|
||||
const [proxyUrl, setProxyUrl] = useState<string | undefined>(storeProxyUrl)
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const onSelectLanguage = (value: string) => {
|
||||
dispatch(setLanguage(value))
|
||||
localStorage.setItem('language', value)
|
||||
i18n.changeLanguage(value)
|
||||
}
|
||||
|
||||
const onSetProxyUrl = () => {
|
||||
if (proxyUrl && !isValidProxyUrl(proxyUrl)) {
|
||||
window.message.error({ content: t('message.error.invalid.proxy.url'), key: 'proxy-error' })
|
||||
return
|
||||
}
|
||||
|
||||
dispatch(_setProxyUrl(proxyUrl))
|
||||
window.api.setProxy(proxyUrl)
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<SettingContainer>
|
||||
<SettingTitle>{t('settings.general.title')}</SettingTitle>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('common.language')}</SettingRowTitle>
|
||||
<Select
|
||||
defaultValue={language || 'en-US'}
|
||||
style={{ width: 180 }}
|
||||
onChange={onSelectLanguage}
|
||||
options={[
|
||||
{ value: 'zh-CN', label: '中文' },
|
||||
{ value: 'zh-TW', label: '中文(繁体)' },
|
||||
{ value: 'en-US', label: 'English' }
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.theme.title')}</SettingRowTitle>
|
||||
<Select
|
||||
defaultValue={theme}
|
||||
style={{ width: 180 }}
|
||||
onChange={setTheme}
|
||||
options={[
|
||||
{ value: ThemeMode.light, label: t('settings.theme.light') },
|
||||
{ value: ThemeMode.dark, label: t('settings.theme.dark') },
|
||||
{ value: ThemeMode.auto, label: t('settings.theme.auto') }
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
{isMac && (
|
||||
<>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.theme.window.style.title')}</SettingRowTitle>
|
||||
<Select
|
||||
defaultValue={windowStyle || 'opaque'}
|
||||
style={{ width: 180 }}
|
||||
onChange={setWindowStyle}
|
||||
options={[
|
||||
{ value: 'transparent', label: t('settings.theme.window.style.transparent') },
|
||||
{ value: 'opaque', label: t('settings.theme.window.style.opaque') }
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.topic.position')}</SettingRowTitle>
|
||||
<Select
|
||||
defaultValue={topicPosition || 'right'}
|
||||
style={{ width: 180 }}
|
||||
onChange={setTopicPosition}
|
||||
options={[
|
||||
{ value: 'left', label: t('settings.topic.position.left') },
|
||||
{ value: 'right', label: t('settings.topic.position.right') }
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
{topicPosition === 'left' && (
|
||||
<>
|
||||
<SettingRow style={{ minHeight: 32 }}>
|
||||
<SettingRowTitle>{t('settings.advanced.click_assistant_switch_to_topics')}</SettingRowTitle>
|
||||
<Switch
|
||||
checked={clickAssistantToShowTopic}
|
||||
onChange={(checked) => dispatch(setClickAssistantToShowTopic(checked))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
</>
|
||||
)}
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.general.check_update_setting')}</SettingRowTitle>
|
||||
<Select
|
||||
defaultValue={manualUpdateCheck ?? false}
|
||||
style={{ width: 180 }}
|
||||
onChange={(v) => dispatch(setManualUpdateCheck(v))}
|
||||
options={[
|
||||
{ value: false, label: t('settings.general.auto_update_check') },
|
||||
{ value: true, label: t('settings.general.manual_update_check') }
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.proxy.title')}</SettingRowTitle>
|
||||
<Input
|
||||
placeholder="socks5://127.0.0.1:6153"
|
||||
value={proxyUrl}
|
||||
onChange={(e) => setProxyUrl(e.target.value)}
|
||||
style={{ width: 180 }}
|
||||
onBlur={() => onSetProxyUrl()}
|
||||
type="url"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow style={{ minHeight: 32 }}>
|
||||
<SettingRowTitle>{t('settings.general.webdav.title')}</SettingRowTitle>
|
||||
<VStack gap="5px">
|
||||
<Link to="/settings/general/webdav" style={{ color: 'var(--color-text-2)' }}>
|
||||
{t('settings.general.view_webdav_settings')}
|
||||
</Link>
|
||||
</VStack>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle>
|
||||
<HStack gap="5px" justifyContent="space-between">
|
||||
<Button onClick={backup} icon={<SaveOutlined />}>
|
||||
{t('settings.general.backup.button')}
|
||||
</Button>
|
||||
<Button onClick={restore} icon={<FolderOpenOutlined />}>
|
||||
{t('settings.general.restore.button')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.general.reset.title')}</SettingRowTitle>
|
||||
<HStack gap="5px">
|
||||
<Button onClick={reset} danger>
|
||||
{t('settings.general.reset.button')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
</SettingContainer>
|
||||
}
|
||||
/>
|
||||
<Route path="webdav" element={<WebDavSettings />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export default GeneralSettings
|
||||
@ -0,0 +1,137 @@
|
||||
import { FolderOpenOutlined, SaveOutlined } from '@ant-design/icons'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { backupToWebdav, restoreFromWebdav } from '@renderer/services/backup'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import {
|
||||
setWebdavHost as _setWebdavHost,
|
||||
setWebdavPass as _setWebdavPass,
|
||||
setWebdavPath as _setWebdavPath,
|
||||
setWebdavUser as _setWebdavUser
|
||||
} from '@renderer/store/settings'
|
||||
import { Breadcrumb, Button, Input } from 'antd'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SettingContainer, SettingDivider, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||
|
||||
const WebDavSettings: FC = () => {
|
||||
const {
|
||||
webdavHost: webDAVHost,
|
||||
webdavUser: webDAVUser,
|
||||
webdavPass: webDAVPass,
|
||||
webdavPath: webDAVPath
|
||||
} = useSettings()
|
||||
|
||||
const [webdavHost, setWebdavHost] = useState<string | undefined>(webDAVHost)
|
||||
const [webdavUser, setWebdavUser] = useState<string | undefined>(webDAVUser)
|
||||
const [webdavPass, setWebdavPass] = useState<string | undefined>(webDAVPass)
|
||||
const [webdavPath, setWebdavPath] = useState<string | undefined>(webDAVPath)
|
||||
|
||||
const [backuping, setBackuping] = useState(false)
|
||||
const [restoring, setRestoring] = useState(false)
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
// 把之前备份的文件定时上传到 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()
|
||||
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)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingContainer>
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{
|
||||
title: t('settings.general.title'),
|
||||
href: '#/settings/general'
|
||||
},
|
||||
{
|
||||
title: t('settings.general.webdav.title')
|
||||
}
|
||||
]}
|
||||
/>
|
||||
<SettingTitle style={{ marginTop: 20 }}>{t('settings.general.webdav.title')}</SettingTitle>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.general.webdav.host')}</SettingRowTitle>
|
||||
<Input
|
||||
placeholder={t('settings.general.webdav.host.placeholder')}
|
||||
value={webdavHost}
|
||||
onChange={(e) => setWebdavHost(e.target.value)}
|
||||
style={{ width: 250 }}
|
||||
type="url"
|
||||
onBlur={() => dispatch(_setWebdavHost(webdavHost || ''))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.general.webdav.user')}</SettingRowTitle>
|
||||
<Input
|
||||
placeholder={t('settings.general.webdav.user')}
|
||||
value={webdavUser}
|
||||
onChange={(e) => setWebdavUser(e.target.value)}
|
||||
style={{ width: 250 }}
|
||||
onBlur={() => dispatch(_setWebdavUser(webdavUser || ''))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.general.webdav.password')}</SettingRowTitle>
|
||||
<Input.Password
|
||||
placeholder={t('settings.general.webdav.password')}
|
||||
value={webdavPass}
|
||||
onChange={(e) => setWebdavPass(e.target.value)}
|
||||
style={{ width: 250 }}
|
||||
onBlur={() => dispatch(_setWebdavPass(webdavPass || ''))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.general.webdav.path')}</SettingRowTitle>
|
||||
<Input
|
||||
placeholder={t('settings.general.webdav.path.placeholder')}
|
||||
value={webdavPath}
|
||||
onChange={(e) => setWebdavPath(e.target.value)}
|
||||
style={{ width: 250 }}
|
||||
onBlur={() => dispatch(_setWebdavPath(webdavPath || ''))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle>
|
||||
<HStack gap="5px" justifyContent="space-between">
|
||||
{/* 添加 在线备份 在线还原 按钮 */}
|
||||
<Button onClick={onBackup} icon={<SaveOutlined />} loading={backuping}>
|
||||
{t('settings.general.webdav.backup.button')}
|
||||
</Button>
|
||||
<Button onClick={onRestore} icon={<FolderOpenOutlined />} loading={restoring}>
|
||||
{t('settings.general.webdav.restore.button')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
</SettingContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default WebDavSettings
|
||||
@ -8,7 +8,7 @@ import styled from 'styled-components'
|
||||
|
||||
import AboutSettings from './AboutSettings'
|
||||
import AssistantSettings from './AssistantSettings'
|
||||
import GeneralSettings from './GeneralSettings'
|
||||
import GeneralSettings from './GeneralSettings/GeneralSettings'
|
||||
import ModelSettings from './ModelSettings'
|
||||
import ProvidersList from './ProviderSettings'
|
||||
|
||||
@ -16,7 +16,7 @@ const SettingsPage: FC = () => {
|
||||
const { pathname } = useLocation()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const isRoute = (path: string): string => (pathname === path ? 'active' : '')
|
||||
const isRoute = (path: string): string => (pathname.startsWith(path) ? 'active' : '')
|
||||
|
||||
return (
|
||||
<Container>
|
||||
@ -65,7 +65,7 @@ const SettingsPage: FC = () => {
|
||||
<Route path="provider" element={<ProvidersList />} />
|
||||
<Route path="model" element={<ModelSettings />} />
|
||||
<Route path="assistant" element={<AssistantSettings />} />
|
||||
<Route path="general" element={<GeneralSettings />} />
|
||||
<Route path="general/*" element={<GeneralSettings />} />
|
||||
<Route path="about" element={<AboutSettings />} />
|
||||
</Routes>
|
||||
</SettingContent>
|
||||
|
||||
@ -1,29 +1,15 @@
|
||||
import db from '@renderer/databases'
|
||||
import i18n from '@renderer/i18n'
|
||||
import store from '@renderer/store'
|
||||
import dayjs from 'dayjs'
|
||||
import localforage from 'localforage'
|
||||
import store from '@renderer/store'
|
||||
|
||||
import { createClient } from 'webdav'
|
||||
|
||||
export async function backup() {
|
||||
const version = 3
|
||||
const time = new Date().getTime()
|
||||
|
||||
const data = {
|
||||
time,
|
||||
version,
|
||||
localStorage,
|
||||
indexedDB: await backupDatabase()
|
||||
}
|
||||
|
||||
const filename = `cherry-studio.${dayjs().format('YYYYMMDDHHmm')}`
|
||||
const fileContnet = JSON.stringify(data)
|
||||
|
||||
const filename = `cherry-studio.${dayjs().format('YYYYMMDDHHmm')}.zip`
|
||||
const fileContnet = await getBackupData()
|
||||
const selectFolder = await window.api.file.selectFolder()
|
||||
|
||||
if (selectFolder) {
|
||||
await window.api.backup.save(fileContnet, filename, selectFolder)
|
||||
await window.api.backup.backup(filename, fileContnet, selectFolder)
|
||||
window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' })
|
||||
}
|
||||
}
|
||||
@ -38,7 +24,7 @@ export async function restore() {
|
||||
// zip backup file
|
||||
if (file?.fileName.endsWith('.zip')) {
|
||||
const restoreData = await window.api.backup.restore(file.filePath)
|
||||
data = JSON.parse(restoreData.data)
|
||||
data = JSON.parse(restoreData)
|
||||
} else {
|
||||
data = JSON.parse(await window.api.decompress(file.content))
|
||||
}
|
||||
@ -78,127 +64,73 @@ 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)
|
||||
const backupData = await getBackupData()
|
||||
|
||||
// 创建 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)
|
||||
}
|
||||
console.debug({
|
||||
webdavHost,
|
||||
webdavUser,
|
||||
webdavPass,
|
||||
webdavPath
|
||||
})
|
||||
|
||||
// 上传文件
|
||||
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)
|
||||
const success = await window.api.backup.backupToWebdav(backupData, {
|
||||
webdavHost,
|
||||
webdavUser,
|
||||
webdavPass,
|
||||
webdavPath
|
||||
})
|
||||
if (success) {
|
||||
window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' })
|
||||
} else {
|
||||
window.message.error({ content: i18n.t('message.backup.failed'), key: 'backup' })
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('[backup] backupToWebdav: Error uploading file to WebDAV:', error)
|
||||
window.modal.error({
|
||||
title: i18n.t('message.backup.failed'),
|
||||
content: error.message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 从 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 data = ''
|
||||
|
||||
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)
|
||||
data = await window.api.backup.restoreFromWebdav({ webdavHost, webdavUser, webdavPass, webdavPath })
|
||||
} catch (error: any) {
|
||||
console.error('[backup] restoreFromWebdav: Error downloading file from WebDAV:', error)
|
||||
window.modal.error({
|
||||
title: i18n.t('message.restore.failed'),
|
||||
content: error.message
|
||||
})
|
||||
}
|
||||
|
||||
// 处理文件内容
|
||||
const data = parseFileContent(fileContent.toString())
|
||||
console.log('Parsed file content:', data)
|
||||
|
||||
await handleData(data)
|
||||
try {
|
||||
await handleData(JSON.parse(data))
|
||||
} catch (error) {
|
||||
console.error('Error downloading file from WebDAV:', error)
|
||||
console.error('[backup] 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 getBackupData() {
|
||||
return JSON.stringify({
|
||||
time: new Date().getTime(),
|
||||
version: 3,
|
||||
localStorage,
|
||||
indexedDB: await backupDatabase()
|
||||
})
|
||||
}
|
||||
|
||||
async function handleData(data: any) {
|
||||
/************************************* Backup Utils ************************************** */
|
||||
async function handleData(data: Record<string, any>) {
|
||||
if (data.version === 1) {
|
||||
await clearDatabase()
|
||||
|
||||
|
||||
@ -116,3 +116,10 @@ export enum ThemeMode {
|
||||
dark = 'dark',
|
||||
auto = 'auto'
|
||||
}
|
||||
|
||||
export type WebDavConfig = {
|
||||
webdavHost: string
|
||||
webdavUser: string
|
||||
webdavPass: string
|
||||
webdavPath: string
|
||||
}
|
||||
|
||||
166
yarn.lock
166
yarn.lock
@ -2292,6 +2292,7 @@ __metadata:
|
||||
unzipper: "npm:^0.12.3"
|
||||
uuid: "npm:^10.0.0"
|
||||
vite: "npm:^5.0.12"
|
||||
webdav: "npm:4.11.4"
|
||||
peerDependencies:
|
||||
react: ^17.0.0 || ^18.0.0
|
||||
react-dom: ^17.0.0 || ^18.0.0
|
||||
@ -2826,6 +2827,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"axios@npm:^0.27.2":
|
||||
version: 0.27.2
|
||||
resolution: "axios@npm:0.27.2"
|
||||
dependencies:
|
||||
follow-redirects: "npm:^1.14.9"
|
||||
form-data: "npm:^4.0.0"
|
||||
checksum: 10c0/76d673d2a90629944b44d6f345f01e58e9174690f635115d5ffd4aca495d99bcd8f95c590d5ccb473513f5ebc1d1a6e8934580d0c57cdd0498c3a101313ef771
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"axios@npm:^1.7.3":
|
||||
version: 1.7.7
|
||||
resolution: "axios@npm:1.7.7"
|
||||
@ -2865,6 +2876,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"base-64@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "base-64@npm:1.0.0"
|
||||
checksum: 10c0/d886cb3236cee0bed9f7075675748b59b32fad623ddb8ce1793c790306aa0f76a03238cad4b3fb398abda6527ce08a5588388533a4ccade0b97e82b9da660e28
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"base64-arraybuffer@npm:^1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "base64-arraybuffer@npm:1.0.2"
|
||||
@ -3075,6 +3093,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"byte-length@npm:^1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "byte-length@npm:1.0.2"
|
||||
checksum: 10c0/98778b938318494c2eadedf83b415e63da406d905575101eb102b7eefb5fafbbe21fbe83001914283664bb2fb93bd46f99245af2e8420a928ba90ffbb58041a1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"cac@npm:^6.7.14":
|
||||
version: 6.7.14
|
||||
resolution: "cac@npm:6.7.14"
|
||||
@ -3265,6 +3290,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"charenc@npm:0.0.2":
|
||||
version: 0.0.2
|
||||
resolution: "charenc@npm:0.0.2"
|
||||
checksum: 10c0/a45ec39363a16799d0f9365c8dd0c78e711415113c6f14787a22462ef451f5013efae8a28f1c058f81fc01f2a6a16955f7a5fd0cd56247ce94a45349c89877d8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"chokidar@npm:>=3.0.0 <4.0.0":
|
||||
version: 3.6.0
|
||||
resolution: "chokidar@npm:3.6.0"
|
||||
@ -3586,6 +3618,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"crypt@npm:0.0.2":
|
||||
version: 0.0.2
|
||||
resolution: "crypt@npm:0.0.2"
|
||||
checksum: 10c0/adbf263441dd801665d5425f044647533f39f4612544071b1471962209d235042fb703c27eea2795c7c53e1dfc242405173003f83cf4f4761a633d11f9653f18
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"css-box-model@npm:^1.2.1":
|
||||
version: 1.2.1
|
||||
resolution: "css-box-model@npm:1.2.1"
|
||||
@ -4825,6 +4864,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fast-xml-parser@npm:^4.2.4":
|
||||
version: 4.5.0
|
||||
resolution: "fast-xml-parser@npm:4.5.0"
|
||||
dependencies:
|
||||
strnum: "npm:^1.0.5"
|
||||
bin:
|
||||
fxparser: src/cli/cli.js
|
||||
checksum: 10c0/71d206c9e137f5c26af88d27dde0108068a5d074401901d643c500c36e95dfd828333a98bda020846c41f5b9b364e2b0e9be5b19b0bdcab5cf31559c07b80a95
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fastq@npm:^1.6.0":
|
||||
version: 1.17.1
|
||||
resolution: "fastq@npm:1.17.1"
|
||||
@ -4944,7 +4994,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"follow-redirects@npm:^1.15.6":
|
||||
"follow-redirects@npm:^1.14.9, follow-redirects@npm:^1.15.6":
|
||||
version: 1.15.9
|
||||
resolution: "follow-redirects@npm:1.15.9"
|
||||
peerDependenciesMeta:
|
||||
@ -5627,6 +5677,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"he@npm:^1.2.0":
|
||||
version: 1.2.0
|
||||
resolution: "he@npm:1.2.0"
|
||||
bin:
|
||||
he: bin/he
|
||||
checksum: 10c0/a27d478befe3c8192f006cdd0639a66798979dfa6e2125c6ac582a19a5ebfec62ad83e8382e6036170d873f46e4536a7e795bf8b95bf7c247f4cc0825ccc8c17
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"highlight.js@npm:^10.4.1, highlight.js@npm:~10.7.0":
|
||||
version: 10.7.3
|
||||
resolution: "highlight.js@npm:10.7.3"
|
||||
@ -5659,6 +5718,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"hot-patcher@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "hot-patcher@npm:1.0.0"
|
||||
checksum: 10c0/0c3ee3e1cb45f8b09ecb6d9af11b35b05f94b0767e09a303d89a7b6073b55ee98bd5c9b563ff17bfa1add55bbe3ff7598bbbb8c035578e05dd12631f2351cdb6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"html-parse-stringify@npm:^3.0.1":
|
||||
version: 3.0.1
|
||||
resolution: "html-parse-stringify@npm:3.0.1"
|
||||
@ -6015,6 +6081,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"is-buffer@npm:~1.1.6":
|
||||
version: 1.1.6
|
||||
resolution: "is-buffer@npm:1.1.6"
|
||||
checksum: 10c0/ae18aa0b6e113d6c490ad1db5e8df9bdb57758382b313f5a22c9c61084875c6396d50bbf49315f5b1926d142d74dfb8d31b40d993a383e0a158b15fea7a82234
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"is-callable@npm:^1.1.3, is-callable@npm:^1.1.4, is-callable@npm:^1.2.7":
|
||||
version: 1.2.7
|
||||
resolution: "is-callable@npm:1.2.7"
|
||||
@ -6655,6 +6728,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"layerr@npm:^0.1.2":
|
||||
version: 0.1.2
|
||||
resolution: "layerr@npm:0.1.2"
|
||||
checksum: 10c0/e329ec13a31cd676c2fdf2127d43b794dab692991d7fa64cfd752d36e0c17799341e208b4727d944373d0a8c91fdd263023d66498e3152f8672238de47f9c602
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lazy-val@npm:^1.0.4, lazy-val@npm:^1.0.5":
|
||||
version: 1.0.5
|
||||
resolution: "lazy-val@npm:1.0.5"
|
||||
@ -6905,6 +6985,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"md5@npm:^2.3.0":
|
||||
version: 2.3.0
|
||||
resolution: "md5@npm:2.3.0"
|
||||
dependencies:
|
||||
charenc: "npm:0.0.2"
|
||||
crypt: "npm:0.0.2"
|
||||
is-buffer: "npm:~1.1.6"
|
||||
checksum: 10c0/14a21d597d92e5b738255fbe7fe379905b8cb97e0a49d44a20b58526a646ec5518c337b817ce0094ca94d3e81a3313879c4c7b510d250c282d53afbbdede9110
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mdast-util-find-and-replace@npm:^3.0.0":
|
||||
version: 3.0.1
|
||||
resolution: "mdast-util-find-and-replace@npm:3.0.1"
|
||||
@ -7768,6 +7859,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"nested-property@npm:^4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "nested-property@npm:4.0.0"
|
||||
checksum: 10c0/7bc0514f3d10460cc07ea27a39ce75f81471a28b8b019d4bfd9eda41dcd92c1fcb291598d7e168ae8bf1324109b36325e3a563d6aa2537d13015bea9258b8b72
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"node-addon-api@npm:^1.6.3":
|
||||
version: 1.7.2
|
||||
resolution: "node-addon-api@npm:1.7.2"
|
||||
@ -8236,6 +8334,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"path-posix@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "path-posix@npm:1.0.0"
|
||||
checksum: 10c0/00fbadb9b60fb513f316f92e0b5535e55d832f4f20067586d151f6d7bed57178dec31b1a0f514694500a9a1f2b69798c066a3cdcf0b0289cfee63e39845bfd02
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"path-scurry@npm:^1.11.1":
|
||||
version: 1.11.1
|
||||
resolution: "path-scurry@npm:1.11.1"
|
||||
@ -8603,6 +8708,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"querystringify@npm:^2.1.1":
|
||||
version: 2.2.0
|
||||
resolution: "querystringify@npm:2.2.0"
|
||||
checksum: 10c0/3258bc3dbdf322ff2663619afe5947c7926a6ef5fb78ad7d384602974c467fadfc8272af44f5eb8cddd0d011aae8fabf3a929a8eee4b86edcc0a21e6bd10f9aa
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"queue-microtask@npm:^1.2.2":
|
||||
version: 1.2.3
|
||||
resolution: "queue-microtask@npm:1.2.3"
|
||||
@ -9668,6 +9780,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"requires-port@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "requires-port@npm:1.0.0"
|
||||
checksum: 10c0/b2bfdd09db16c082c4326e573a82c0771daaf7b53b9ce8ad60ea46aa6e30aaf475fe9b164800b89f93b748d2c234d8abff945d2551ba47bf5698e04cd7713267
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"reselect@npm:^5.1.0":
|
||||
version: 5.1.1
|
||||
resolution: "reselect@npm:5.1.1"
|
||||
@ -10445,6 +10564,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"strnum@npm:^1.0.5":
|
||||
version: 1.0.5
|
||||
resolution: "strnum@npm:1.0.5"
|
||||
checksum: 10c0/64fb8cc2effbd585a6821faa73ad97d4b553c8927e49086a162ffd2cc818787643390b89d567460a8e74300148d11ac052e21c921ef2049f2987f4b1b89a7ff1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"strtok3@npm:^6.2.4":
|
||||
version: 6.3.0
|
||||
resolution: "strtok3@npm:6.3.0"
|
||||
@ -11083,6 +11209,23 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"url-join@npm:^4.0.1":
|
||||
version: 4.0.1
|
||||
resolution: "url-join@npm:4.0.1"
|
||||
checksum: 10c0/ac65e2c7c562d7b49b68edddcf55385d3e922bc1dd5d90419ea40b53b6de1607d1e45ceb71efb9d60da02c681d13c6cb3a1aa8b13fc0c989dfc219df97ee992d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"url-parse@npm:^1.5.10":
|
||||
version: 1.5.10
|
||||
resolution: "url-parse@npm:1.5.10"
|
||||
dependencies:
|
||||
querystringify: "npm:^2.1.1"
|
||||
requires-port: "npm:^1.0.0"
|
||||
checksum: 10c0/bd5aa9389f896974beb851c112f63b466505a04b4807cea2e5a3b7092f6fbb75316f0491ea84e44f66fed55f1b440df5195d7e3a8203f64fcefa19d182f5be87
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"use-memo-one@npm:^1.1.3":
|
||||
version: 1.1.3
|
||||
resolution: "use-memo-one@npm:1.1.3"
|
||||
@ -11300,6 +11443,27 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"webdav@npm:4.11.4":
|
||||
version: 4.11.4
|
||||
resolution: "webdav@npm:4.11.4"
|
||||
dependencies:
|
||||
axios: "npm:^0.27.2"
|
||||
base-64: "npm:^1.0.0"
|
||||
byte-length: "npm:^1.0.2"
|
||||
fast-xml-parser: "npm:^4.2.4"
|
||||
he: "npm:^1.2.0"
|
||||
hot-patcher: "npm:^1.0.0"
|
||||
layerr: "npm:^0.1.2"
|
||||
md5: "npm:^2.3.0"
|
||||
minimatch: "npm:^5.1.0"
|
||||
nested-property: "npm:^4.0.0"
|
||||
path-posix: "npm:^1.0.0"
|
||||
url-join: "npm:^4.0.1"
|
||||
url-parse: "npm:^1.5.10"
|
||||
checksum: 10c0/8b1ae47c4df6c3ee832ff1abe05ca8c03c5284519a07b41ae816e417688de0aae6f9c5ced04ecedd14daae5ec7367c6af010fda55d3458b424e51a95a817133e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"webidl-conversions@npm:^3.0.0":
|
||||
version: 3.0.1
|
||||
resolution: "webidl-conversions@npm:3.0.1"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user