feat: add webdav settings component and backup user data files #69

This commit is contained in:
kangfenmao 2024-09-29 16:32:55 +08:00
parent 2e1b433365
commit 33b83bf242
16 changed files with 722 additions and 417 deletions

View File

@ -40,7 +40,7 @@
"fs-extra": "^11.2.0", "fs-extra": "^11.2.0",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"unzipper": "^0.12.3", "unzipper": "^0.12.3",
"webdav": "^5.7.1" "webdav": "4.11.4"
}, },
"devDependencies": { "devDependencies": {
"@anthropic-ai/sdk": "^0.24.3", "@anthropic-ai/sdk": "^0.24.3",

View File

@ -29,8 +29,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle('reload', () => mainWindow.reload()) 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:restore', backupManager.restore)
ipcMain.handle('backup:backupToWebdav', backupManager.backupToWebdav)
ipcMain.handle('backup:restoreFromWebdav', backupManager.restoreFromWebdav)
ipcMain.handle('file:open', fileManager.open) ipcMain.handle('file:open', fileManager.open)
ipcMain.handle('file:save', fileManager.save) ipcMain.handle('file:save', fileManager.save)

View File

@ -1,3 +1,4 @@
import { WebDavConfig } from '@types'
import archiver from 'archiver' import archiver from 'archiver'
import { app } from 'electron' import { app } from 'electron'
import Logger from 'electron-log' import Logger from 'electron-log'
@ -5,16 +6,25 @@ import * as fs from 'fs-extra'
import * as path from 'path' import * as path from 'path'
import * as unzipper from 'unzipper' import * as unzipper from 'unzipper'
import WebDav from './WebDav'
class BackupManager { 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() { constructor() {
this.tempDir = path.join(app.getPath('temp'), 'CherryStudio', 'backup')
this.backup = this.backup.bind(this) this.backup = this.backup.bind(this)
this.restore = this.restore.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 { try {
// 创建临时目录 // 创建临时目录
await fs.ensureDir(this.tempDir) await fs.ensureDir(this.tempDir)
@ -29,7 +39,7 @@ class BackupManager {
await fs.copy(sourcePath, tempDataDir) await fs.copy(sourcePath, tempDataDir)
// 创建 zip 文件 // 创建 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 } }) const archive = archiver('zip', { zlib: { level: 9 } })
archive.pipe(output) archive.pipe(output)
@ -40,42 +50,60 @@ class BackupManager {
await fs.remove(this.tempDir) await fs.remove(this.tempDir)
Logger.log('Backup completed successfully') Logger.log('Backup completed successfully')
const backupedFilePath = path.join(destinationPath, fileName)
return backupedFilePath
} catch (error) { } catch (error) {
Logger.error('Backup failed:', error) Logger.error('Backup failed:', error)
throw error throw error
} }
} }
async restore(_: Electron.IpcMainInvokeEvent, backupPath: string): Promise<{ data: string; success: boolean }> { async restore(_: Electron.IpcMainInvokeEvent, backupPath: string): Promise<string> {
try { // 创建临时目录
// 创建临时目录 await fs.ensureDir(this.tempDir)
await fs.ensureDir(this.tempDir)
// 解压备份文件到临时目录 // 解压备份文件到临时目录
await fs await fs
.createReadStream(backupPath) .createReadStream(backupPath)
.pipe(unzipper.Extract({ path: this.tempDir })) .pipe(unzipper.Extract({ path: this.tempDir }))
.promise() .promise()
// 读取 data.json // 读取 data.json
const dataPath = path.join(this.tempDir, 'data.json') const dataPath = path.join(this.tempDir, 'data.json')
const data = await fs.readFile(dataPath, 'utf-8') const data = await fs.readFile(dataPath, 'utf-8')
// 恢复 Data 目录 // 恢复 Data 目录
const sourcePath = path.join(this.tempDir, 'Data') const sourcePath = path.join(this.tempDir, 'Data')
const destPath = path.join(app.getPath('userData'), 'Data') const destPath = path.join(app.getPath('userData'), 'Data')
await fs.remove(destPath) await fs.remove(destPath)
await fs.copy(sourcePath, destPath) await fs.copy(sourcePath, destPath)
// 清理临时目录 // 清理临时目录
await fs.remove(this.tempDir) await fs.remove(this.tempDir)
Logger.log('Restore completed successfully') Logger.log('Restore completed successfully')
return { data, success: true }
} catch (error) { return data
Logger.error('Restore failed:', error) }
return { data: '', success: false }
} 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)
} }
} }

View 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
}
}
}

View File

@ -1,6 +1,8 @@
import { ElectronAPI } from '@electron-toolkit/preload' import { ElectronAPI } from '@electron-toolkit/preload'
import { FileType } from '@renderer/types' import { FileType } from '@renderer/types'
import { WebDavConfig } from '@renderer/types'
import type { OpenDialogOptions } from 'electron' import type { OpenDialogOptions } from 'electron'
import { Readable } from 'stream'
declare global { declare global {
interface Window { interface Window {
@ -20,8 +22,10 @@ declare global {
compress: (text: string) => Promise<Buffer> compress: (text: string) => Promise<Buffer>
decompress: (text: Buffer) => Promise<string> decompress: (text: Buffer) => Promise<string>
backup: { backup: {
save: (data: string, fileName: string, destinationPath: string) => Promise<void> backup: (fileName: string, data: string, destinationPath?: string) => Promise<Readable>
restore: (backupPath: string) => Promise<{ data: string; success: boolean }> restore: (backupPath: string) => Promise<string>
backupToWebdav: (data: string, webdavConfig: WebDavConfig) => Promise<boolean>
restoreFromWebdav: (webdavConfig: WebDavConfig) => Promise<string>
} }
file: { file: {
select: (options?: OpenDialogOptions) => Promise<FileType[] | null> select: (options?: OpenDialogOptions) => Promise<FileType[] | null>

View File

@ -1,4 +1,5 @@
import { electronAPI } from '@electron-toolkit/preload' import { electronAPI } from '@electron-toolkit/preload'
import { WebDavConfig } from '@types'
import { contextBridge, ipcRenderer, OpenDialogOptions } from 'electron' import { contextBridge, ipcRenderer, OpenDialogOptions } from 'electron'
// Custom APIs for renderer // Custom APIs for renderer
@ -11,10 +12,12 @@ const api = {
minApp: (url: string) => ipcRenderer.invoke('minapp', url), minApp: (url: string) => ipcRenderer.invoke('minapp', url),
reload: () => ipcRenderer.invoke('reload'), reload: () => ipcRenderer.invoke('reload'),
backup: { backup: {
save: (data: string, fileName: string, destinationPath: string) => { backup: (fileName: string, data: string, destinationPath?: string) =>
ipcRenderer.invoke('backup:save', data, fileName, destinationPath) ipcRenderer.invoke('backup:backup', fileName, data, destinationPath),
}, restore: (backupPath: string) => ipcRenderer.invoke('backup:restore', backupPath),
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: { file: {
select: (options?: OpenDialogOptions) => ipcRenderer.invoke('file:select', options), select: (options?: OpenDialogOptions) => ipcRenderer.invoke('file:select', options),

View File

@ -46,12 +46,14 @@
"error.enter.api.host": "Please enter your API host first", "error.enter.api.host": "Please enter your API host first",
"error.enter.model": "Please select a model first", "error.enter.model": "Please select a model first",
"error.invalid.proxy.url": "Invalid proxy URL", "error.invalid.proxy.url": "Invalid proxy URL",
"error.invalid.webdav": "Invalid WebDAV settings",
"api.connection.failed": "Connection failed", "api.connection.failed": "Connection failed",
"api.connection.success": "Connection successful", "api.connection.success": "Connection successful",
"chat.completion.paused": "Chat completion paused", "chat.completion.paused": "Chat completion paused",
"switch.disabled": "Switching is disabled while the assistant is generating", "switch.disabled": "Switching is disabled while the assistant is generating",
"restore.success": "Restored successfully", "restore.success": "Restored successfully",
"backup.success": "Backup successful", "backup.success": "Backup successful",
"backup.failed": "Backup failed",
"reset.confirm.content": "Are you sure you want to clear all data?", "reset.confirm.content": "Are you sure you want to clear all data?",
"reset.double.confirm.title": "DATA LOST !!!", "reset.double.confirm.title": "DATA LOST !!!",
"reset.double.confirm.content": "All data will be lost, do you want to continue?", "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.title": "Data Backup and Recovery",
"general.backup.button": "Backup", "general.backup.button": "Backup",
"general.restore.button": "Restore", "general.restore.button": "Restore",
"general.view_webdav_settings": "View WebDAV settings",
"general.webdav.title": "WebDAV", "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.user": "WebDAV User",
"general.webdav.password": "WebDAV Password", "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.backup.button": "Backup to WebDAV",
"general.webdav.restore.button": "Restore from WebDAV", "general.webdav.restore.button": "Restore from WebDAV",
"general.reset.title": "Data Reset", "general.reset.title": "Data Reset",

View File

@ -46,12 +46,14 @@
"error.enter.api.host": "请输入您的 API 地址", "error.enter.api.host": "请输入您的 API 地址",
"error.enter.model": "请选择一个模型", "error.enter.model": "请选择一个模型",
"error.invalid.proxy.url": "无效的代理地址", "error.invalid.proxy.url": "无效的代理地址",
"error.invalid.webdav": "无效的 WebDAV 设置",
"api.connection.failed": "连接失败", "api.connection.failed": "连接失败",
"api.connection.success": "连接成功", "api.connection.success": "连接成功",
"chat.completion.paused": "会话已停止", "chat.completion.paused": "会话已停止",
"switch.disabled": "模型回复完成后才能切换", "switch.disabled": "模型回复完成后才能切换",
"restore.success": "恢复成功", "restore.success": "恢复成功",
"backup.success": "备份成功", "backup.success": "备份成功",
"backup.failed": "备份失败",
"reset.confirm.content": "确定要重置所有数据吗?", "reset.confirm.content": "确定要重置所有数据吗?",
"reset.double.confirm.title": "数据丢失!!!", "reset.double.confirm.title": "数据丢失!!!",
"reset.double.confirm.content": "你的全部数据都会丢失,如果没有备份数据,将无法恢复,确定要继续吗?", "reset.double.confirm.content": "你的全部数据都会丢失,如果没有备份数据,将无法恢复,确定要继续吗?",
@ -182,11 +184,14 @@
"general.restore.button": "恢复", "general.restore.button": "恢复",
"general.reset.title": "重置数据", "general.reset.title": "重置数据",
"general.reset.button": "重置", "general.reset.button": "重置",
"general.view_webdav_settings": "查看 WebDAV 设置",
"general.webdav.title": "WebDAV", "general.webdav.title": "WebDAV",
"general.webdav.host": "WebDAV Host, e.g. http://localhost:8080", "general.webdav.host": "WebDAV 地址",
"general.webdav.user": "WebDAV User", "general.webdav.host.placeholder": "http://localhost:8080",
"general.webdav.password": "WebDAV Password", "general.webdav.user": "WebDAV 用户名",
"general.webdav.path": "WebDAV Path, e.g. /backup", "general.webdav.password": "WebDAV 密码",
"general.webdav.path": "WebDAV 路径",
"general.webdav.path.placeholder": "/backup",
"general.webdav.backup.button": "备份到 WebDAV", "general.webdav.backup.button": "备份到 WebDAV",
"general.webdav.restore.button": "从 WebDAV 恢复", "general.webdav.restore.button": "从 WebDAV 恢复",
"general.check_update_setting": "更新设置", "general.check_update_setting": "更新设置",

View File

@ -46,12 +46,14 @@
"error.enter.api.host": "請先輸入您的 API 主機地址", "error.enter.api.host": "請先輸入您的 API 主機地址",
"error.enter.model": "請先選擇一個模型", "error.enter.model": "請先選擇一個模型",
"error.invalid.proxy.url": "無效的代理 URL", "error.invalid.proxy.url": "無效的代理 URL",
"error.invalid.webdav": "無效的 WebDAV 設定",
"api.connection.failed": "連接失敗", "api.connection.failed": "連接失敗",
"api.connection.success": "連接成功", "api.connection.success": "連接成功",
"chat.completion.paused": "聊天完成已暫停", "chat.completion.paused": "聊天完成已暫停",
"switch.disabled": "助手生成回覆時無法切換", "switch.disabled": "助手生成回覆時無法切換",
"restore.success": "恢復成功", "restore.success": "恢復成功",
"backup.success": "備份成功", "backup.success": "備份成功",
"backup.failed": "備份失敗",
"reset.confirm.content": "確定要清除所有資料嗎?", "reset.confirm.content": "確定要清除所有資料嗎?",
"reset.double.confirm.title": "資料將會丟失!!!", "reset.double.confirm.title": "資料將會丟失!!!",
"reset.double.confirm.content": "所有資料將會被清除,您確定要繼續嗎?", "reset.double.confirm.content": "所有資料將會被清除,您確定要繼續嗎?",
@ -180,11 +182,14 @@
"general.backup.title": "資料備份與復原", "general.backup.title": "資料備份與復原",
"general.backup.button": "備份", "general.backup.button": "備份",
"general.restore.button": "復原", "general.restore.button": "復原",
"general.view_webdav_settings": "查看 WebDAV 設定",
"general.webdav.title": "WebDAV", "general.webdav.title": "WebDAV",
"general.webdav.host": "WebDAV Host, e.g. http://localhost:8080", "general.webdav.host": "WebDAV 主機位址",
"general.webdav.user": "WebDAV User", "general.webdav.host.placeholder": "http://localhost:8080",
"general.webdav.password": "WebDAV Password", "general.webdav.user": "WebDAV 使用者名稱",
"general.webdav.path": "WebDAV Path, e.g. /backup", "general.webdav.password": "WebDAV 密碼",
"general.webdav.path": "WebDAV Path",
"general.webdav.path.placeholder": "/backup",
"general.webdav.backup.button": "從 WebDAV 備份", "general.webdav.backup.button": "從 WebDAV 備份",
"general.webdav.restore.button": "從 WebDAV 恢復", "general.webdav.restore.button": "從 WebDAV 恢復",
"general.reset.title": "資料重置", "general.reset.title": "資料重置",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -8,7 +8,7 @@ import styled from 'styled-components'
import AboutSettings from './AboutSettings' import AboutSettings from './AboutSettings'
import AssistantSettings from './AssistantSettings' import AssistantSettings from './AssistantSettings'
import GeneralSettings from './GeneralSettings' import GeneralSettings from './GeneralSettings/GeneralSettings'
import ModelSettings from './ModelSettings' import ModelSettings from './ModelSettings'
import ProvidersList from './ProviderSettings' import ProvidersList from './ProviderSettings'
@ -16,7 +16,7 @@ const SettingsPage: FC = () => {
const { pathname } = useLocation() const { pathname } = useLocation()
const { t } = useTranslation() const { t } = useTranslation()
const isRoute = (path: string): string => (pathname === path ? 'active' : '') const isRoute = (path: string): string => (pathname.startsWith(path) ? 'active' : '')
return ( return (
<Container> <Container>
@ -65,7 +65,7 @@ const SettingsPage: FC = () => {
<Route path="provider" element={<ProvidersList />} /> <Route path="provider" element={<ProvidersList />} />
<Route path="model" element={<ModelSettings />} /> <Route path="model" element={<ModelSettings />} />
<Route path="assistant" element={<AssistantSettings />} /> <Route path="assistant" element={<AssistantSettings />} />
<Route path="general" element={<GeneralSettings />} /> <Route path="general/*" element={<GeneralSettings />} />
<Route path="about" element={<AboutSettings />} /> <Route path="about" element={<AboutSettings />} />
</Routes> </Routes>
</SettingContent> </SettingContent>

View File

@ -1,29 +1,15 @@
import db from '@renderer/databases' import db from '@renderer/databases'
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
import store from '@renderer/store'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import localforage from 'localforage' import localforage from 'localforage'
import store from '@renderer/store'
import { createClient } from 'webdav'
export async function backup() { export async function backup() {
const version = 3 const filename = `cherry-studio.${dayjs().format('YYYYMMDDHHmm')}.zip`
const time = new Date().getTime() const fileContnet = await getBackupData()
const data = {
time,
version,
localStorage,
indexedDB: await backupDatabase()
}
const filename = `cherry-studio.${dayjs().format('YYYYMMDDHHmm')}`
const fileContnet = JSON.stringify(data)
const selectFolder = await window.api.file.selectFolder() const selectFolder = await window.api.file.selectFolder()
if (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' }) window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' })
} }
} }
@ -38,7 +24,7 @@ export async function restore() {
// zip backup file // zip backup file
if (file?.fileName.endsWith('.zip')) { if (file?.fileName.endsWith('.zip')) {
const restoreData = await window.api.backup.restore(file.filePath) const restoreData = await window.api.backup.restore(file.filePath)
data = JSON.parse(restoreData.data) data = JSON.parse(restoreData)
} else { } else {
data = JSON.parse(await window.api.decompress(file.content)) data = JSON.parse(await window.api.decompress(file.content))
} }
@ -78,127 +64,73 @@ export async function reset() {
// 备份到 webdav // 备份到 webdav
export async function backupToWebdav() { 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 const { webdavHost, webdavUser, webdavPass, webdavPath } = store.getState().settings
// console.log('backup.backupToWebdav', webdavHost, webdavUser, webdavPass, webdavPath)
let host = webdavHost const backupData = await getBackupData()
if (!host.startsWith('http://') && !host.startsWith('https://')) {
host = `http://${host}`
}
console.log('backup.backupToWebdav', host)
// 创建 WebDAV 客户端 console.debug({
const client = createClient( webdavHost,
host, // WebDAV 服务器地址 webdavUser,
{ webdavPass,
username: webdavUser, // 用户名 webdavPath
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 { try {
await client.putFileContents(remoteFilePath, fileContent, { overwrite: true }) const success = await window.api.backup.backupToWebdav(backupData, {
console.log('File uploaded successfully!') webdavHost,
window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' }) webdavUser,
} catch (error) { webdavPass,
console.error('Error uploading file to WebDAV:', error) 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 恢复 // 从 webdav 恢复
export async function restoreFromWebdav() { export async function restoreFromWebdav() {
const filename = `cherry-studio.backup.json`
// 获取 userSetting 里的 WebDAV 配置
const { webdavHost, webdavUser, webdavPass, webdavPath } = store.getState().settings 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 { try {
// 下载文件内容 data = await window.api.backup.restoreFromWebdav({ webdavHost, webdavUser, webdavPass, webdavPath })
const fileContent = await client.getFileContents(remoteFilePath, { format: 'text' }) } catch (error: any) {
console.log('File downloaded successfully!', fileContent) console.error('[backup] restoreFromWebdav: Error downloading file from WebDAV:', error)
window.modal.error({
title: i18n.t('message.restore.failed'),
content: error.message
})
}
// 处理文件内容 try {
const data = parseFileContent(fileContent.toString()) await handleData(JSON.parse(data))
console.log('Parsed file content:', data)
await handleData(data)
} catch (error) { } 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' }) window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' })
} }
} }
/************************************* Backup Utils ************************************** */ async function getBackupData() {
return JSON.stringify({
function parseFileContent(fileContent: string | Buffer | { data: string | Buffer } | ArrayBuffer): any { time: new Date().getTime(),
let fileContentString: string version: 3,
localStorage,
if (typeof fileContent === 'string') { indexedDB: await backupDatabase()
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) { /************************************* Backup Utils ************************************** */
async function handleData(data: Record<string, any>) {
if (data.version === 1) { if (data.version === 1) {
await clearDatabase() await clearDatabase()

View File

@ -116,3 +116,10 @@ export enum ThemeMode {
dark = 'dark', dark = 'dark',
auto = 'auto' auto = 'auto'
} }
export type WebDavConfig = {
webdavHost: string
webdavUser: string
webdavPass: string
webdavPath: string
}

166
yarn.lock
View File

@ -2292,6 +2292,7 @@ __metadata:
unzipper: "npm:^0.12.3" unzipper: "npm:^0.12.3"
uuid: "npm:^10.0.0" uuid: "npm:^10.0.0"
vite: "npm:^5.0.12" vite: "npm:^5.0.12"
webdav: "npm:4.11.4"
peerDependencies: peerDependencies:
react: ^17.0.0 || ^18.0.0 react: ^17.0.0 || ^18.0.0
react-dom: ^17.0.0 || ^18.0.0 react-dom: ^17.0.0 || ^18.0.0
@ -2826,6 +2827,16 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "axios@npm:^1.7.3":
version: 1.7.7 version: 1.7.7
resolution: "axios@npm:1.7.7" resolution: "axios@npm:1.7.7"
@ -2865,6 +2876,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "base64-arraybuffer@npm:^1.0.2":
version: 1.0.2 version: 1.0.2
resolution: "base64-arraybuffer@npm:1.0.2" resolution: "base64-arraybuffer@npm:1.0.2"
@ -3075,6 +3093,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "cac@npm:^6.7.14":
version: 6.7.14 version: 6.7.14
resolution: "cac@npm:6.7.14" resolution: "cac@npm:6.7.14"
@ -3265,6 +3290,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "chokidar@npm:>=3.0.0 <4.0.0":
version: 3.6.0 version: 3.6.0
resolution: "chokidar@npm:3.6.0" resolution: "chokidar@npm:3.6.0"
@ -3586,6 +3618,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "css-box-model@npm:^1.2.1":
version: 1.2.1 version: 1.2.1
resolution: "css-box-model@npm:1.2.1" resolution: "css-box-model@npm:1.2.1"
@ -4825,6 +4864,17 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "fastq@npm:^1.6.0":
version: 1.17.1 version: 1.17.1
resolution: "fastq@npm:1.17.1" resolution: "fastq@npm:1.17.1"
@ -4944,7 +4994,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"follow-redirects@npm:^1.15.6": "follow-redirects@npm:^1.14.9, follow-redirects@npm:^1.15.6":
version: 1.15.9 version: 1.15.9
resolution: "follow-redirects@npm:1.15.9" resolution: "follow-redirects@npm:1.15.9"
peerDependenciesMeta: peerDependenciesMeta:
@ -5627,6 +5677,15 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "highlight.js@npm:^10.4.1, highlight.js@npm:~10.7.0":
version: 10.7.3 version: 10.7.3
resolution: "highlight.js@npm:10.7.3" resolution: "highlight.js@npm:10.7.3"
@ -5659,6 +5718,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "html-parse-stringify@npm:^3.0.1":
version: 3.0.1 version: 3.0.1
resolution: "html-parse-stringify@npm:3.0.1" resolution: "html-parse-stringify@npm:3.0.1"
@ -6015,6 +6081,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "is-callable@npm:^1.1.3, is-callable@npm:^1.1.4, is-callable@npm:^1.2.7":
version: 1.2.7 version: 1.2.7
resolution: "is-callable@npm:1.2.7" resolution: "is-callable@npm:1.2.7"
@ -6655,6 +6728,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "lazy-val@npm:^1.0.4, lazy-val@npm:^1.0.5":
version: 1.0.5 version: 1.0.5
resolution: "lazy-val@npm:1.0.5" resolution: "lazy-val@npm:1.0.5"
@ -6905,6 +6985,17 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "mdast-util-find-and-replace@npm:^3.0.0":
version: 3.0.1 version: 3.0.1
resolution: "mdast-util-find-and-replace@npm:3.0.1" resolution: "mdast-util-find-and-replace@npm:3.0.1"
@ -7768,6 +7859,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "node-addon-api@npm:^1.6.3":
version: 1.7.2 version: 1.7.2
resolution: "node-addon-api@npm:1.7.2" resolution: "node-addon-api@npm:1.7.2"
@ -8236,6 +8334,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "path-scurry@npm:^1.11.1":
version: 1.11.1 version: 1.11.1
resolution: "path-scurry@npm:1.11.1" resolution: "path-scurry@npm:1.11.1"
@ -8603,6 +8708,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "queue-microtask@npm:^1.2.2":
version: 1.2.3 version: 1.2.3
resolution: "queue-microtask@npm:1.2.3" resolution: "queue-microtask@npm:1.2.3"
@ -9668,6 +9780,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "reselect@npm:^5.1.0":
version: 5.1.1 version: 5.1.1
resolution: "reselect@npm:5.1.1" resolution: "reselect@npm:5.1.1"
@ -10445,6 +10564,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "strtok3@npm:^6.2.4":
version: 6.3.0 version: 6.3.0
resolution: "strtok3@npm:6.3.0" resolution: "strtok3@npm:6.3.0"
@ -11083,6 +11209,23 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "use-memo-one@npm:^1.1.3":
version: 1.1.3 version: 1.1.3
resolution: "use-memo-one@npm:1.1.3" resolution: "use-memo-one@npm:1.1.3"
@ -11300,6 +11443,27 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "webidl-conversions@npm:^3.0.0":
version: 3.0.1 version: 3.0.1
resolution: "webidl-conversions@npm:3.0.1" resolution: "webidl-conversions@npm:3.0.1"