feat: nutstore integration (#3461)

* feat(protocol): add custom protocol

* feat(webdav): add handler for checking webdav connection

* feat(webdav): abstract WebDAV modal components

* feat(nutstore): add nutstore sso

---------

Co-authored-by: shlroland <shlroland1995@gmail.com>
This commit is contained in:
nutstore-dev 2025-03-25 11:40:11 +08:00 committed by GitHub
parent b321169ca2
commit fd66881022
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1775 additions and 146 deletions

View File

@ -6,3 +6,4 @@ tsconfig.json
tsconfig.*.json
CHANGELOG*.md
agents.json
src/renderer/src/integration/nutstore/sso/lib

View File

@ -53,6 +53,13 @@ export default defineConfig([
}
],
{
ignores: ['node_modules/**', 'dist/**', 'out/**', 'local/**', '.gitignore', 'scripts/cloudflare-worker.js']
ignores: [
'node_modules/**',
'dist/**',
'out/**',
'.gitignore',
'scripts/cloudflare-worker.js',
'src/renderer/src/integration/nutstore/sso/lib/**'
]
}
])

View File

@ -76,6 +76,7 @@
"electron-updater": "^6.3.9",
"electron-window-state": "^5.0.3",
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
"fast-xml-parser": "^5.0.9",
"fetch-socks": "^1.3.2",
"fs-extra": "^11.2.0",
"markdown-it": "^14.1.0",

View File

@ -0,0 +1 @@
export const NUTSTORE_HOST = 'https://dav.jianguoyun.com/dav'

View File

@ -5,6 +5,7 @@ import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer'
import { registerIpc } from './ipc'
import { configManager } from './services/ConfigManager'
import { CHERRY_STUDIO_PROTOCOL, handleProtocolUrl, registerProtocolClient } from './services/ProtocolClient'
import { registerShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
@ -56,9 +57,30 @@ if (!app.requestSingleInstanceLock()) {
})
})
registerProtocolClient(app)
// macOS specific: handle protocol when app is already running
app.on('open-url', (event, url) => {
event.preventDefault()
handleProtocolUrl(url)
})
registerProtocolClient(app)
// macOS specific: handle protocol when app is already running
app.on('open-url', (event, url) => {
event.preventDefault()
handleProtocolUrl(url)
})
// Listen for second instance
app.on('second-instance', () => {
app.on('second-instance', (_event, argv) => {
windowService.showMainWindow()
// Protocol handler for Windows/Linux
// The commandLine is an array of strings where the last item might be the URL
const url = argv.find((arg) => arg.startsWith(CHERRY_STUDIO_PROTOCOL + '://'))
if (url) handleProtocolUrl(url)
})
app.on('browser-window-created', (_, window) => {

View File

@ -0,0 +1,8 @@
declare function decrypt(app: string, s: string): string;
interface Secret {
app: string;
}
declare function createOAuthUrl(secret: Secret): string;
export { type Secret, createOAuthUrl, decrypt };

File diff suppressed because one or more lines are too long

View File

@ -17,6 +17,7 @@ import FileStorage from './services/FileStorage'
import { GeminiService } from './services/GeminiService'
import KnowledgeService from './services/KnowledgeService'
import MCPService from './services/MCPService'
import * as NutstoreService from './services/NutstoreService'
import { ProxyConfig, proxyManager } from './services/ProxyManager'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
@ -164,6 +165,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle('backup:backupToWebdav', backupManager.backupToWebdav)
ipcMain.handle('backup:restoreFromWebdav', backupManager.restoreFromWebdav)
ipcMain.handle('backup:listWebdavFiles', backupManager.listWebdavFiles)
ipcMain.handle('backup:checkConnection', backupManager.checkConnection)
ipcMain.handle('backup:createDirectory', backupManager.createDirectory)
// file
ipcMain.handle('file:open', fileManager.open)
@ -296,4 +299,11 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle('copilot:get-token', CopilotService.getToken)
ipcMain.handle('copilot:logout', CopilotService.logout)
ipcMain.handle('copilot:get-user', CopilotService.getUser)
// nutstore
ipcMain.handle('nutstore:get-sso-url', NutstoreService.getNutstoreSSOUrl)
ipcMain.handle('nutstore:decrypt-token', (_, token: string) => NutstoreService.decryptToken(token))
ipcMain.handle('nutstore:get-directory-contents', (_, token: string, path: string) =>
NutstoreService.getDirectoryContents(token, path)
)
}

View File

@ -5,7 +5,7 @@ import { app } from 'electron'
import Logger from 'electron-log'
import * as fs from 'fs-extra'
import * as path from 'path'
import { createClient, FileStat } from 'webdav'
import { createClient, FileStat, CreateDirectoryOptions } from 'webdav'
import WebDav from './WebDav'
import { windowService } from './WindowService'
@ -15,6 +15,7 @@ class BackupManager {
private backupDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup')
constructor() {
this.checkConnection = this.checkConnection.bind(this)
this.backup = this.backup.bind(this)
this.restore = this.restore.bind(this)
this.backupToWebdav = this.backupToWebdav.bind(this)
@ -278,6 +279,21 @@ class BackupManager {
}
}
}
async checkConnection(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) {
const webdavClient = new WebDav(webdavConfig)
return await webdavClient.checkConnection()
}
async createDirectory(
_: Electron.IpcMainInvokeEvent,
webdavConfig: WebDavConfig,
path: string,
options?: CreateDirectoryOptions
) {
const webdavClient = new WebDav(webdavConfig)
return await webdavClient.createDirectory(path, options)
}
}
export default BackupManager

View File

@ -0,0 +1,134 @@
import path from 'node:path'
import { NUTSTORE_HOST } from '@shared/config/nutstore'
import { XMLParser } from 'fast-xml-parser'
import { isNil, partial } from 'lodash'
import { type FileStat } from 'webdav'
interface OAuthResponse {
username: string
userid: string
access_token: string
}
interface WebDAVResponse {
multistatus: {
response: Array<{
href: string
propstat: {
prop: {
displayname: string
resourcetype: { collection?: any }
getlastmodified?: string
getcontentlength?: string
getcontenttype?: string
}
status: string
}
}>
}
}
export async function getNutstoreSSOUrl() {
const { createOAuthUrl } = await import('../integration/nutstore/sso/lib')
const url = createOAuthUrl({
app: 'cherrystudio'
})
return url
}
export async function decryptToken(token: string) {
const { decrypt } = await import('../integration/nutstore/sso/lib')
try {
const decrypted = decrypt('cherrystudio', token)
return JSON.parse(decrypted) as OAuthResponse
} catch (error) {
console.error('解密失败:', error)
return null
}
}
export async function getDirectoryContents(token: string, target: string): Promise<FileStat[]> {
const contents: FileStat[] = []
if (!target.startsWith('/')) {
target = '/' + target
}
let currentUrl = `${NUTSTORE_HOST}${target}`
while (true) {
const response = await fetch(currentUrl, {
method: 'PROPFIND',
headers: {
Authorization: `Basic ${token}`,
'Content-Type': 'application/xml',
Depth: '1'
},
body: `<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:">
<prop>
<displayname/>
<resourcetype/>
<getlastmodified/>
<getcontentlength/>
<getcontenttype/>
</prop>
</propfind>`
})
const text = await response.text()
const result = parseXml<WebDAVResponse>(text)
const items = Array.isArray(result.multistatus.response)
? result.multistatus.response
: [result.multistatus.response]
// 跳过第一个条目(当前目录)
contents.push(...items.slice(1).map(partial(convertToFileStat, '/dav')))
const linkHeader = response.headers['link'] || response.headers['Link']
if (!linkHeader) {
break
}
const nextLink = extractNextLink(linkHeader)
if (!nextLink) {
break
}
currentUrl = decodeURI(nextLink)
}
return contents
}
function extractNextLink(linkHeader: string): string | null {
const matches = linkHeader.match(/<([^>]+)>;\s*rel="next"/)
return matches ? matches[1] : null
}
function convertToFileStat(serverBase: string, item: WebDAVResponse['multistatus']['response'][number]): FileStat {
const props = item.propstat.prop
const isDir = !isNil(props.resourcetype?.collection)
const href = decodeURIComponent(item.href)
const filename = serverBase === '/' ? href : path.join('/', href.replace(serverBase, ''))
return {
filename,
basename: path.basename(filename),
lastmod: props.getlastmodified || '',
size: props.getcontentlength ? parseInt(props.getcontentlength, 10) : 0,
type: isDir ? 'directory' : 'file',
etag: null,
mime: props.getcontenttype
}
}
function parseXml<T>(xml: string) {
const parser = new XMLParser({
attributeNamePrefix: '',
removeNSPrefix: true
})
return parser.parse(xml) as T
}

View File

@ -0,0 +1,34 @@
import { windowService } from './WindowService'
export const CHERRY_STUDIO_PROTOCOL = 'cherrystudio'
export function registerProtocolClient(app: Electron.App) {
if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient(CHERRY_STUDIO_PROTOCOL, process.execPath, [process.argv[1]])
}
}
app.setAsDefaultProtocolClient('cherrystudio')
}
export function handleProtocolUrl(url: string) {
if (!url) return
// Process the URL that was used to open the app
// The url will be in the format: cherrystudio://data?param1=value1&param2=value2
console.log('Received URL:', url)
// Parse the URL and extract parameters
const urlObj = new URL(url)
const params = new URLSearchParams(urlObj.search)
// You can send the data to your renderer process
const mainWindow = windowService.getMainWindow()
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('protocol-data', {
url,
params: Object.fromEntries(params.entries())
})
}
}

View File

@ -1,7 +1,14 @@
import { WebDavConfig } from '@types'
import Logger from 'electron-log'
import Stream from 'stream'
import { BufferLike, createClient, GetFileContentsOptions, PutFileContentsOptions, WebDAVClient } from 'webdav'
import {
BufferLike,
createClient,
CreateDirectoryOptions,
GetFileContentsOptions,
PutFileContentsOptions,
WebDAVClient
} from 'webdav'
export default class WebDav {
public instance: WebDAVClient | undefined
private webdavPath: string
@ -18,6 +25,7 @@ export default class WebDav {
this.putFileContents = this.putFileContents.bind(this)
this.getFileContents = this.getFileContents.bind(this)
this.createDirectory = this.createDirectory.bind(this)
}
public putFileContents = async (
@ -64,4 +72,30 @@ export default class WebDav {
throw error
}
}
public checkConnection = async () => {
if (!this.instance) {
throw new Error('WebDAV client not initialized')
}
try {
return await this.instance.exists('/')
} catch (error) {
Logger.error('[WebDAV] Error checking connection:', error)
throw error
}
}
public createDirectory = async (path: string, options?: CreateDirectoryOptions) => {
if (!this.instance) {
throw new Error('WebDAV client not initialized')
}
try {
return await this.instance.createDirectory(path, options)
} catch (error) {
Logger.error('[WebDAV] Error creating directory on WebDAV:', error)
throw error
}
}
}

View File

@ -45,6 +45,8 @@ declare global {
backupToWebdav: (data: string, webdavConfig: WebDavConfig) => Promise<boolean>
restoreFromWebdav: (webdavConfig: WebDavConfig) => Promise<string>
listWebdavFiles: (webdavConfig: WebDavConfig) => Promise<BackupFile[]>
checkConnection: (webdavConfig: WebDavConfig) => Promise<boolean>
createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) => Promise<void>
}
file: {
select: (options?: OpenDialogOptions) => Promise<FileType[] | null>
@ -170,6 +172,14 @@ declare global {
getBinaryPath: (name: string) => Promise<string>
installUVBinary: () => Promise<void>
installBunBinary: () => Promise<void>
protocol: {
onReceiveData: (callback: (data: { url: string; params: any }) => void) => () => void
}
nutstore: {
getSSOUrl: () => Promise<string>
decryptToken: (token: string) => Promise<{ username: string; access_token: string }>
getDirectoryContents: (token: string, path: string) => Promise<any>
}
}
}
}

View File

@ -2,6 +2,7 @@ import { electronAPI } from '@electron-toolkit/preload'
import type { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, WebDavConfig } from '@types'
import { contextBridge, ipcRenderer, OpenDialogOptions, shell } from 'electron'
import { CreateDirectoryOptions } from 'webdav'
// Custom APIs for renderer
const api = {
@ -34,7 +35,10 @@ const api = {
backupToWebdav: (data: string, webdavConfig: WebDavConfig) =>
ipcRenderer.invoke('backup:backupToWebdav', data, webdavConfig),
restoreFromWebdav: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:restoreFromWebdav', webdavConfig),
listWebdavFiles: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:listWebdavFiles', webdavConfig)
listWebdavFiles: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:listWebdavFiles', webdavConfig),
checkConnection: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:checkConnection', webdavConfig),
createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) =>
ipcRenderer.invoke('backup:createDirectory', webdavConfig, path, options)
},
file: {
select: (options?: OpenDialogOptions) => ipcRenderer.invoke('file:select', options),
@ -143,7 +147,24 @@ const api = {
isBinaryExist: (name: string) => ipcRenderer.invoke('app:is-binary-exist', name),
getBinaryPath: (name: string) => ipcRenderer.invoke('app:get-binary-path', name),
installUVBinary: () => ipcRenderer.invoke('app:install-uv-binary'),
installBunBinary: () => ipcRenderer.invoke('app:install-bun-binary')
installBunBinary: () => ipcRenderer.invoke('app:install-bun-binary'),
protocol: {
onReceiveData: (callback: (data: { url: string; params: any }) => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: { url: string; params: any }) => {
callback(data)
}
ipcRenderer.on('protocol-data', listener)
return () => {
ipcRenderer.off('protocol-data', listener)
}
}
},
nutstore: {
getSSOUrl: () => ipcRenderer.invoke('nutstore:get-sso-url'),
decryptToken: (token: string) => ipcRenderer.invoke('nutstore:decrypt-token', token),
getDirectoryContents: (token: string, path: string) =>
ipcRenderer.invoke('nutstore:get-directory-contents', token, path)
}
}
// Use `contextBridge` APIs to expose Electron APIs to

View File

@ -39,5 +39,4 @@
<script type="module" src="/src/init.ts"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
</html>

View File

@ -0,0 +1,50 @@
import styled from 'styled-components'
const IconSpan = styled.span`
display: flex;
justify-content: center;
align-items: center;
height: 100%;
`
export function NutstoreIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<IconSpan>
<svg
{...props}
width="16px"
height="16px"
viewBox="0 0 20 20"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink">
<title>线</title>
<g id="线性单坚果" stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
<path
d="M10.1590439,0.886175571 C10.1753674,0.890326544 10.291709,0.910777855 10.428428,0.935202765 L10.6388345,0.973279488 C10.7074276,0.985937901 10.77116,0.998048871 10.8200766,1.00807156 C11.2437905,1.09488771 11.6662387,1.21011472 12.1133986,1.37210166 C13.2580363,1.78675499 14.3714894,2.43940777 15.4224927,3.39703693 L15.621,3.584 L15.6351722,3.57092562 C16.53166,2.76294504 17.6751418,2.31986999 18.4291849,2.58060734 L18.5580792,2.63399481 C18.9455012,2.81584984 19.2328582,3.16284846 19.437028,3.61729231 C19.5709871,3.91546021 19.6526725,4.21929758 19.6985752,4.50662941 C19.7148596,4.80478115 19.5904581,5.0358501 19.4098118,5.1582622 C19.3815042,5.17858714 19.3523426,5.19648783 19.3224017,5.21197531 C19.1152073,5.31915066 18.9086763,5.30466603 18.6939183,5.22086872 C18.6620576,5.20843687 18.6328325,5.19564599 18.6006654,5.18105502 C18.4394695,5.11546938 18.2846309,5.06753532 18.1365915,5.04232952 C17.7415971,4.96197402 17.3578102,5.06378907 17.051656,5.32621284 L17.046624,5.33098744 L17.1856424,5.55157847 C18.0964209,7.0577136 18.6880009,8.98631362 18.5914984,10.988329 L18.5672508,11.3423168 C18.518886,12.3590196 18.336046,13.2889191 17.9959883,14.1391815 C17.4227031,15.6418626 16.5311196,16.5912538 15.4105898,16.2529712 L15.278,16.207 C15.204042,16.2889459 15.1247235,16.3618831 15.0410669,16.4278107 L14.9126231,16.5212291 C13.2906651,17.9150353 10.9315401,19.0281897 7.99389616,19.2 L7.17106258,19.2 C3.43360072,19.2 1.02132454,17.63803 0.534391412,16.0333683 L0.513,15.954 L0.504265285,15.9449232 C-0.110228462,15.1972878 0.264421351,10.4760569 2.09599684,6.99794495 L2.22026541,6.76796973 C2.29571954,6.63016882 2.43695112,6.39220857 2.63659846,6.08729923 C2.9688861,5.57981633 3.34471126,5.07232148 3.75709487,4.59788661 C4.2749895,4.0020645 4.81413532,3.50121679 5.3386949,3.15177019 C5.36355777,3.12648036 5.4278064,3.07827062 5.50910569,3.02364741 L5.559,2.991 L5.5530361,2.96941337 C5.48899059,2.69876461 5.47862138,2.4784725 5.54146387,2.2521942 L5.58811106,2.11525813 C5.68308256,1.86409186 5.94349142,1.57994703 6.25873284,1.38755406 C6.58654657,1.18748816 7.23187921,0.95895859 7.69473739,0.883035787 C8.37505518,0.763266442 9.38159553,0.78076773 10.1590439,0.886175571 Z M6.59801776,3.85068129 C6.46732353,3.85068129 6.2240354,3.97828097 6.07844768,4.1001814 C5.59811888,4.42589962 5.12194443,4.87010868 4.65860433,5.40361803 C4.52372819,5.55892011 4.37448327,5.74624534 4.22515758,5.94252901 L4.04684241,6.18089332 C3.57610889,6.82012555 3.16307203,7.45661922 3.27592159,7.33459023 C1.39280393,10.7336939 1.18786427,14.1190682 1.66513528,15.5784041 C1.72944314,15.8645824 2.24255786,16.4352772 2.98506717,16.8902532 C4.03558482,17.5339627 5.43381914,17.9303112 7.15636912,17.9630362 L7.95282724,17.9633776 C10.5671194,17.8104156 12.6011819,16.8513512 14.1270746,15.5866906 L14.2005419,15.5269075 L14.2189125,15.5136158 C14.591184,15.2751975 14.6855045,14.9945722 14.5299888,14.3127204 C14.1480256,12.8500475 13.2023047,10.9705228 11.4802274,8.76564869 C10.6761315,7.73569508 9.84271439,6.77270459 8.9812637,5.88185595 C8.26651717,5.13999817 7.48191474,4.46126051 6.65303256,3.86947602 C6.6343697,3.85523851 6.62003281,3.85068129 6.59801776,3.85068129 Z M8.0520431,2.14478343 C7.34750556,2.24716005 6.81392621,2.48276912 6.75769294,2.58286729 C6.75315545,2.59094425 6.75172186,2.59912409 6.75788522,2.63367631 L6.761,2.653 C6.92447955,2.67441039 7.07755879,2.72514333 7.22081781,2.80306173 L7.36053304,2.88992896 C8.25106173,3.52400396 9.08393795,4.2496146 9.84209216,5.05104835 C10.7498631,5.98954517 11.620838,6.99715009 12.4127624,8.02643665 C14.2357617,10.3660968 15.255676,12.4067536 15.6810213,14.0171728 C15.7810435,14.3986973 15.8140553,14.7531702 15.7838468,15.0855202 L15.779624,15.1139874 L15.7923351,15.1170186 C16.0195271,15.1453183 16.2337261,14.9383655 16.4514,14.5090146 L16.5168229,14.3735502 C16.5998938,14.1934825 16.8522658,13.5389313 16.8131724,13.6336744 L16.800624,13.6629874 L16.8933423,13.4088509 C17.1021765,12.7846983 17.2487406,12.0003637 17.2861365,11.2776414 C17.4525549,9.34169753 16.8847303,7.51332101 15.9618076,5.9792161 C15.8725231,5.8278532 15.7620551,5.66138642 15.6942132,5.57820575 C14.7595226,4.31701776 13.5999579,3.42705248 12.3136888,2.84260842 C11.4827868,2.46507019 10.794487,2.2853603 10.1559862,2.18983638 C9.43796126,2.09113972 8.59553714,2.05880421 8.0520431,2.14478343 Z M16.4823653,4.32067121 L16.364,4.418 L16.393,4.454 L16.5100007,4.3621392 C17.0306065,3.97118443 17.6106194,3.7900296 18.1665334,3.88918284 L18.233,3.904 L18.2063581,3.87419362 C18.1376794,3.79892884 18.0675642,3.72412847 18.0165076,3.68190508 L17.972563,3.65173005 C17.800955,3.56958653 17.0606024,3.86572493 16.4823653,4.32067121 Z"
id="形状结合"
fill="currentColor"
fillRule="nonzero"></path>
</g>
</svg>
</IconSpan>
)
}
export function FolderIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<IconSpan>
<svg width="16px" height="16px" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" {...props}>
<title>folder</title>
<path
d="M396.5,185.7l22.7,27.2a36.1,36.1,0,0,0,27.7,12.7H906.8c29.4,0,53.2,22.8,53.2,50.9V800.1c0,28.1-23.8,50.9-53.2,50.9H117.2C87.8,851,64,828.2,64,800.1V223.9c0-28.1,23.8-50.9,53.2-50.9H368.8A36.1,36.1,0,0,1,396.5,185.7Z"
style={{ fill: '#9fddff' }}
/>
<path
d="M64,342.5V797.8c0,29.4,24,53.2,53.6,53.2H906.4c29.6,0,53.6-23.8,53.6-53.2V342.5Z"
style={{ fill: '#74c6ff' }}
/>
</svg>
</IconSpan>
)
}

View File

@ -0,0 +1,250 @@
import { FolderIcon as NutstoreFolderIcon } from '@renderer/components/Icons/NutstoreIcons'
import { Button, Input } from 'antd'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { HStack } from './Layout'
interface NewFolderProps {
onConfirm: (name: string) => void
onCancel: () => void
className?: string
}
const NewFolderContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
padding: 0 4px;
`
const FolderIcon = styled(NutstoreFolderIcon)`
width: 40px;
height: 40px;
`
function NewFolder(props: NewFolderProps) {
const { onConfirm, onCancel } = props
const [name, setName] = useState('')
const { t } = useTranslation()
return (
<NewFolderContainer>
<FolderIcon className={props.className}></FolderIcon>
<Input type="text" style={{ flex: 1 }} autoFocus value={name} onChange={(e) => setName(e.target.value)} />
<Button type="primary" size="small" onClick={() => onConfirm(name)}>
{t('settings.data.nutstore.new_folder.button.confirm')}
</Button>
<Button type="default" size="small" onClick={() => onCancel()}>
{t('settings.data.nutstore.new_folder.button.cancel')}
</Button>
</NewFolderContainer>
)
}
interface FolderProps {
name: string
path: string
onClick: (path: string) => void
}
const FolderContainer = styled.div`
display: flex;
gap: 8px;
align-items: center;
max-width: 100%;
padding: 0 4px;
&:hover {
background-color: var(--color-background-soft);
}
.nutstore-pathname {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
`
function Folder(props: FolderProps) {
return (
<FolderContainer onClick={() => props.onClick(props.path)}>
<FolderIcon></FolderIcon>
<span className="nutstore-pathname">{props.name}</span>
</FolderContainer>
)
}
interface FileListProps {
path: string
fs: Nutstore.Fs
onClick: (file: Nutstore.FileStat) => void
}
function FileList(props: FileListProps) {
const [files, setFiles] = useState<Nutstore.FileStat[]>([])
const folders = files.filter((file) => file.isDir).sort((a, b) => a.basename.localeCompare(b.basename, ['zh']))
useEffect(() => {
async function fetchFiles() {
try {
const items = await props.fs.ls(props.path)
setFiles(items)
} catch (error) {
if (error instanceof Error) {
console.error(error)
window.modal.error({
content: error.message,
centered: true
})
}
}
}
fetchFiles()
}, [props.path, props.fs])
return (
<>
{folders.map((folder) => (
<Folder key={folder.path} name={folder.basename} path={folder.path} onClick={() => props.onClick(folder)} />
))}
</>
)
}
const SingleFileListContainer = styled.div`
height: 300px;
overflow: hidden;
.scroll-container {
height: 100%;
display: flex;
flex-direction: column;
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.new-folder {
margin-top: 4px;
}
`
const Container = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
.nutstore-current-path-container {
display: flex;
align-items: center;
gap: 8px;
.nutstore-current-path {
word-break: break-all;
}
}
.nutstore-path-operater {
display: flex;
align-items: center;
gap: 8px;
}
`
interface Props {
fs: Nutstore.Fs
onConfirm: (path: string) => void
onCancel: () => void
}
export function NutstorePathSelector(props: Props) {
const { t } = useTranslation()
const [stack, setStack] = useState<string[]>(['/'])
const [showNewFolder, setShowNewFolder] = useState(false)
const cwd = stack.at(-1)
const enter = useCallback((path: string) => {
setStack((prev) => [...prev, path])
}, [])
const pop = useCallback(() => {
setStack((prev) => (prev.length > 1 ? prev.slice(0, -1) : prev))
}, [])
const handleNewFolder = useCallback(
async (name: string) => {
const target = (cwd ?? '/') + (cwd && cwd !== '/' ? '/' : '') + name
await props.fs.mkdirs(target)
setShowNewFolder(false)
enter(target)
},
[cwd, props.fs, enter]
)
return (
<>
<Container>
<SingleFileListContainer>
<div className="scroll-container">
{showNewFolder && (
<NewFolder className="new-folder" onConfirm={handleNewFolder} onCancel={() => setShowNewFolder(false)} />
)}
<FileList path={cwd ?? ''} fs={props.fs} onClick={(f) => enter(f.path)} />
</div>
</SingleFileListContainer>
<div className="nutstore-current-path-container">
<span>{t('settings.data.nutstore.pathSelector.currentPath')}</span>
<span className="nutstore-current-path">{cwd ?? '/'}</span>
</div>
</Container>
<NustorePathSelectorFooter
returnPrev={pop}
mkdir={() => setShowNewFolder(true)}
cancel={props.onCancel}
confirm={() => props.onConfirm(cwd ?? '')}
/>
</>
)
}
const FooterContainer = styled(HStack)`
background: transparent;
margin-top: 12px;
padding: 0;
border-top: none;
border-radius: 0;
`
interface FooterProps {
returnPrev: () => void
mkdir: () => void
cancel: () => void
confirm: () => void
}
export function NustorePathSelectorFooter(props: FooterProps) {
const { t } = useTranslation()
return (
<FooterContainer justifyContent="space-between">
<HStack gap={8} alignItems="center">
<Button onClick={props.returnPrev}>{t('settings.data.nutstore.pathSelector.return')}</Button>
<Button size="small" type="link" onClick={props.mkdir}>
{t('settings.data.nutstore.new_folder.button')}
</Button>
</HStack>
<HStack gap={8} alignItems="center">
<Button type="default" onClick={props.cancel}>
{t('settings.data.nutstore.new_folder.button.cancel')}
</Button>
<Button type="primary" onClick={props.confirm}>
{t('backup.confirm.button')}
</Button>
</HStack>
</FooterContainer>
)
}

View File

@ -0,0 +1,60 @@
import { Modal } from 'antd'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { NutstorePathSelector } from '../NutstorePathSelector'
import { TopView } from '../TopView'
interface Props {
fs: Nutstore.Fs
resolve: (data: string | null) => void
}
const PopupContainer: React.FC<Props> = ({ resolve, fs }) => {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve(null)
}
return (
<Modal
open={open}
title={t('settings.data.nutstore.pathSelector.title')}
transitionName="ant-move-down"
afterClose={onClose}
onCancel={onClose}
footer={null}
centered>
<NutstorePathSelector fs={fs} onConfirm={resolve} onCancel={onCancel} />
</Modal>
)
}
const TopViewKey = 'NutstorePathPopup'
export default class NutstorePathPopup {
static topviewId = 0
static hide() {
TopView.hide(TopViewKey)
}
static show(fs: Nutstore.Fs) {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
fs={fs}
resolve={(v) => {
resolve(v)
TopView.hide(TopViewKey)
}}
/>,
TopViewKey
)
})
}
}

View File

@ -0,0 +1,237 @@
import { backupToWebdav, restoreFromWebdav } from '@renderer/services/BackupService'
import { formatFileSize } from '@renderer/utils'
import { Input, Modal, Select, Spin } from 'antd'
import dayjs from 'dayjs'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
interface BackupFile {
fileName: string
modifiedTime: string
size: number
}
interface WebdavModalProps {
isModalVisible: boolean
handleBackup: () => void
handleCancel: () => void
backuping: boolean
customFileName: string
setCustomFileName: (value: string) => void
}
export function useWebdavBackupModal({ backupMethod }: { backupMethod?: typeof backupToWebdav } = {}) {
const [customFileName, setCustomFileName] = useState('')
const [isModalVisible, setIsModalVisible] = useState(false)
const [backuping, setBackuping] = useState(false)
const handleBackup = async () => {
setBackuping(true)
try {
await (backupMethod ?? backupToWebdav)({ showMessage: true, customFileName })
} finally {
setBackuping(false)
setIsModalVisible(false)
}
}
const handleCancel = () => {
setIsModalVisible(false)
}
const showBackupModal = useCallback(async () => {
// 获取默认文件名
const deviceType = await window.api.system.getDeviceType()
const timestamp = dayjs().format('YYYYMMDDHHmmss')
const defaultFileName = `cherry-studio.${timestamp}.${deviceType}.zip`
setCustomFileName(defaultFileName)
setIsModalVisible(true)
}, [])
return {
isModalVisible,
handleBackup,
handleCancel,
backuping,
customFileName,
setCustomFileName,
showBackupModal
}
}
export function WebdavBackupModal({
isModalVisible,
handleBackup,
handleCancel,
backuping,
customFileName,
setCustomFileName
}: WebdavModalProps) {
const { t } = useTranslation()
return (
<Modal
title={t('settings.data.webdav.backup.modal.title')}
open={isModalVisible}
onOk={handleBackup}
onCancel={handleCancel}
okButtonProps={{ loading: backuping }}>
<Input
value={customFileName}
onChange={(e) => setCustomFileName(e.target.value)}
placeholder={t('settings.data.webdav.backup.modal.filename.placeholder')}
/>
</Modal>
)
}
interface WebdavRestoreModalProps {
isRestoreModalVisible: boolean
handleRestore: () => void
handleCancel: () => void
restoring: boolean
selectedFile: string | null
setSelectedFile: (value: string | null) => void
loadingFiles: boolean
backupFiles: BackupFile[]
}
interface UseWebdavRestoreModalProps {
webdavHost: string | undefined
webdavUser: string | undefined
webdavPass: string | undefined
webdavPath: string | undefined
restoreMethod?: typeof restoreFromWebdav
}
export function useWebdavRestoreModal({
webdavHost,
webdavUser,
webdavPass,
webdavPath,
restoreMethod
}: UseWebdavRestoreModalProps) {
const { t } = useTranslation()
const [isRestoreModalVisible, setIsRestoreModalVisible] = useState(false)
const [restoring, setRestoring] = useState(false)
const [selectedFile, setSelectedFile] = useState<string | null>(null)
const [loadingFiles, setLoadingFiles] = useState(false)
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([])
const showRestoreModal = useCallback(async () => {
if (!webdavHost || !webdavUser || !webdavPass || !webdavPath) {
window.message.error({ content: t('message.error.invalid.webdav'), key: 'webdav-error' })
return
}
setIsRestoreModalVisible(true)
setLoadingFiles(true)
try {
const files = await window.api.backup.listWebdavFiles({
webdavHost,
webdavUser,
webdavPass,
webdavPath
})
setBackupFiles(files)
} catch (error: any) {
window.message.error({ content: error.message, key: 'list-files-error' })
} finally {
setLoadingFiles(false)
}
}, [webdavHost, webdavUser, webdavPass, webdavPath, t])
const handleRestore = useCallback(async () => {
if (!selectedFile || !webdavHost || !webdavUser || !webdavPass || !webdavPath) {
window.message.error({
content: !selectedFile ? t('message.error.no.file.selected') : t('message.error.invalid.webdav'),
key: 'restore-error'
})
return
}
window.modal.confirm({
title: t('settings.data.webdav.restore.confirm.title'),
content: t('settings.data.webdav.restore.confirm.content'),
centered: true,
onOk: async () => {
setRestoring(true)
try {
await (restoreMethod ?? restoreFromWebdav)(selectedFile)
setIsRestoreModalVisible(false)
} catch (error: any) {
window.message.error({ content: error.message, key: 'restore-error' })
} finally {
setRestoring(false)
}
}
})
}, [selectedFile, webdavHost, webdavUser, webdavPass, webdavPath, t, restoreMethod])
const handleCancel = () => {
setIsRestoreModalVisible(false)
}
return {
isRestoreModalVisible,
handleRestore,
handleCancel,
restoring,
selectedFile,
setSelectedFile,
loadingFiles,
backupFiles,
showRestoreModal
}
}
export function WebdavRestoreModal({
isRestoreModalVisible,
handleRestore,
handleCancel,
restoring,
selectedFile,
setSelectedFile,
loadingFiles,
backupFiles
}: WebdavRestoreModalProps) {
const { t } = useTranslation()
return (
<Modal
title={t('settings.data.webdav.restore.modal.title')}
open={isRestoreModalVisible}
onOk={handleRestore}
onCancel={handleCancel}
okButtonProps={{ loading: restoring }}
width={600}>
<div style={{ position: 'relative' }}>
<Select
style={{ width: '100%' }}
placeholder={t('settings.data.webdav.restore.modal.select.placeholder')}
value={selectedFile}
onChange={setSelectedFile}
options={backupFiles.map(formatFileOption)}
loading={loadingFiles}
showSearch
filterOption={(input, option) => (option?.label ?? '').toLowerCase().includes(input.toLowerCase())}
/>
{loadingFiles && (
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}>
<Spin />
</div>
)}
</div>
</Modal>
)
}
function formatFileOption(file: BackupFile) {
const date = dayjs(file.modifiedTime).format('YYYY-MM-DD HH:mm:ss')
const size = formatFileSize(file.size)
return {
label: `${file.fileName} (${date}, ${size})`,
value: file.fileName
}
}

View File

@ -0,0 +1,24 @@
import { useCallback } from 'react'
export function useNutstoreSSO() {
const nutstoreSSOHandler = useCallback(() => {
return new Promise<string>((resolve, reject) => {
const removeListener = window.api.protocol.onReceiveData(async (data) => {
try {
const url = new URL(data.url)
const params = new URLSearchParams(url.search)
const encryptedToken = params.get('s')
if (!encryptedToken) return reject(null)
resolve(encryptedToken)
} catch (error) {
console.error('解析URL失败:', error)
reject(null)
} finally {
removeListener()
}
})
})
}, [])
return nutstoreSSOHandler
}

View File

@ -469,6 +469,8 @@
"error.invalid.webdav": "Invalid WebDAV settings",
"error.joplin.export": "Failed to export to Joplin. Please keep Joplin running and check connection status or configuration",
"error.joplin.no_config": "Joplin Authorization Token or URL is not configured",
"error.invalid.nutstore": "Invalid Nutstore settings",
"error.invalid.nutstore_token": "Invalid Nutstore Token",
"error.markdown.export.preconf": "Failed to export the Markdown file to the preconfigured path",
"error.markdown.export.specified": "Failed to export the Markdown file",
"error.notion.export": "Failed to export to Notion. Please check connection status and configuration according to documentation",
@ -841,6 +843,29 @@
"title": "Yuque Configuration",
"token": "Yuque Token",
"token_placeholder": "Please enter the Yuque Token"
},
"nutstore": {
"title": "Nutstore Configuration",
"isLogin": "Logged in",
"notLogin": "Not logged in",
"login.button": "Login",
"logout.button": "Logout",
"logout.title": "Are you sure you want to logout from Nutstore?",
"logout.content": "After logout, you will not be able to backup to Nutstore or restore from Nutstore.",
"checkConnection.name": "Check Connection",
"checkConnection.success": "Connected to Nutstore",
"checkConnection.fail": "Nutstore connection failed",
"username": "Nutstore Username",
"path": "Nutstore Storage Path",
"path.placeholder": "Enter Nutstore storage path",
"backup.button": "Backup to Nutstore",
"restore.button": "Restore from Nutstore",
"pathSelector.title": "Nutstore Storage Path",
"pathSelector.return": "Return",
"pathSelector.currentPath": "Current Path",
"new_folder.button.confirm": "Confirm",
"new_folder.button.cancel": "Cancel",
"new_folder.button": "New Folder"
}
},
"display.assistant.title": "Assistant Settings",

View File

@ -469,6 +469,8 @@
"error.invalid.webdav": "無効なWebDAV設定",
"error.joplin.export": "Joplin へのエクスポートに失敗しました。Joplin が実行中であることを確認してください",
"error.joplin.no_config": "Joplin 認証トークン または URL が設定されていません",
"error.invalid.nutstore": "無効なNutstore設定です",
"error.invalid.nutstore_token": "無効なNutstoreトークンです",
"error.markdown.export.preconf": "Markdown ファイルを事前設定されたパスにエクスポートできませんでした",
"error.markdown.export.specified": "Markdown ファイルのエクスポートに失敗しました",
"error.notion.export": "Notionへのエクスポートに失敗しました。接続状態と設定を確認してください",
@ -842,6 +844,29 @@
"title": "Yuque設定",
"token": "Yuqueトークン",
"token_placeholder": "Yuqueトークンを入力してください"
},
"nutstore": {
"title": "Nutstore設定",
"isLogin": "ログイン済み",
"notLogin": "未ログイン",
"login.button": "ログイン",
"logout.button": "ログアウト",
"logout.title": "Nutstoreからログアウトしますか",
"logout.content": "ログアウト後、Nutstoreへのバックアップや復元ができなくなります。",
"checkConnection.name": "接続確認",
"checkConnection.success": "Nutstoreに接続しました",
"checkConnection.fail": "Nutstore接続に失敗しました",
"username": "Nutstoreユーザー名",
"path": "Nutstoreストレージパス",
"path.placeholder": "Nutstoreストレージパスを入力",
"backup.button": "Nutstoreにバックアップ",
"restore.button": "Nutstoreから復元",
"pathSelector.title": "Nutstoreストレージパス",
"pathSelector.return": "戻る",
"pathSelector.currentPath": "現在のパス",
"new_folder.button.confirm": "確認",
"new_folder.button.cancel": "キャンセル",
"new_folder.button": "新しいフォルダ"
}
},
"display.assistant.title": "アシスタント設定",

View File

@ -469,6 +469,8 @@
"error.invalid.webdav": "Неверные настройки WebDAV",
"error.joplin.export": "Не удалось экспортировать в Joplin, пожалуйста, убедитесь, что Joplin запущен и проверьте состояние подключения или настройки",
"error.joplin.no_config": "Joplin Authorization Token или URL не настроен",
"error.invalid.nutstore": "Неверные настройки Nutstore",
"error.invalid.nutstore_token": "Неверный Nutstore токен",
"error.markdown.export.preconf": "Не удалось экспортировать файл Markdown в предуказанный путь",
"error.markdown.export.specified": "Не удалось экспортировать файл Markdown",
"error.notion.export": "Ошибка экспорта в Notion, пожалуйста, проверьте состояние подключения и настройки в документации",
@ -842,6 +844,29 @@
"title": "Настройка Yuque",
"token": "Токен Yuque",
"token_placeholder": "Введите токен Yuque"
},
"nutstore": {
"title": "Настройки Nutstore",
"isLogin": "Выполнен вход",
"notLogin": "Вход не выполнен",
"login.button": "Войти",
"logout.button": "Выйти",
"logout.title": "Вы уверены, что хотите выйти из Nutstore?",
"logout.content": "После выхода вы не сможете создавать резервные копии в Nutstore или восстанавливать данные из Nutstore.",
"checkConnection.name": "Проверить соединение",
"checkConnection.success": "Подключение к Nutstore установлено",
"checkConnection.fail": "Ошибка подключения к Nutstore",
"username": "Имя пользователя Nutstore",
"path": "Путь хранения Nutstore",
"path.placeholder": "Введите путь хранения Nutstore",
"backup.button": "Резервное копирование в Nutstore",
"restore.button": "Восстановление из Nutstore",
"pathSelector.title": "Путь хранения Nutstore",
"pathSelector.return": "Назад",
"pathSelector.currentPath": "Текущий путь",
"new_folder.button.confirm": "Подтвердить",
"new_folder.button.cancel": "Отмена",
"new_folder.button": "Новая папка"
}
},
"display.assistant.title": "Настройки ассистентов",

View File

@ -469,6 +469,8 @@
"error.invalid.webdav": "无效的 WebDAV 设置",
"error.joplin.export": "导出 Joplin 失败,请保持 Joplin 已运行并检查连接状态或检查配置",
"error.joplin.no_config": "未配置 Joplin 授权令牌 或 URL",
"error.invalid.nutstore": "无效的坚果云设置",
"error.invalid.nutstore_token": "无效的坚果云 Token",
"error.markdown.export.preconf": "导出Markdown文件到预先设定的路径失败",
"error.markdown.export.specified": "导出Markdown文件失败",
"error.notion.export": "导出 Notion 错误,请检查连接状态并对照文档检查配置",
@ -842,6 +844,29 @@
"title": "语雀配置",
"token": "语雀 Token",
"token_placeholder": "请输入语雀Token"
},
"nutstore": {
"title": "坚果云配置",
"isLogin": "已登录",
"notLogin": "未登录",
"login.button": "登录",
"logout.button": "退出登录",
"logout.title": "确定要退出坚果云登录?",
"logout.content": "退出后将无法备份至坚果云和从坚果云恢复",
"checkConnection.name": "检查连接",
"checkConnection.success": "已连接坚果云",
"checkConnection.fail": "坚果云连接失败",
"username": "坚果云用户名",
"path": "坚果云存储路径",
"path.placeholder": "请输入坚果云的存储路径",
"backup.button": "备份到坚果云",
"restore.button": "从坚果云恢复",
"pathSelector.title": "坚果云存储路径",
"pathSelector.return": "返回",
"pathSelector.currentPath": "当前路径",
"new_folder.button.confirm": "确定",
"new_folder.button.cancel": "取消",
"new_folder.button": "新建文件夹"
}
},
"display.assistant.title": "助手设置",

View File

@ -469,6 +469,8 @@
"error.invalid.webdav": "無效的 WebDAV 設定",
"error.joplin.export": "匯出 Joplin 失敗,請保持 Joplin 已運行並檢查連接狀態或檢查設定",
"error.joplin.no_config": "未設定 Joplin 授權Token 或 URL",
"error.invalid.nutstore": "無效的坚果云設定",
"error.invalid.nutstore_token": "無效的坚果云 Token",
"error.markdown.export.preconf": "導出 Markdown 文件到預先設定的路徑失敗",
"error.markdown.export.specified": "導出 Markdown 文件失敗",
"error.notion.export": "匯出 Notion 錯誤,請檢查連接狀態並對照文件檢查設定",
@ -842,6 +844,29 @@
"title": "語雀設定",
"token": "語雀 Token",
"token_placeholder": "請輸入語雀 Token"
},
"nutstore": {
"title": "堅果雲設定",
"isLogin": "已登入",
"notLogin": "未登入",
"login.button": "登入",
"logout.button": "退出登入",
"logout.title": "確定要退出堅果雲登入?",
"logout.content": "退出後將無法備份至堅果雲和從堅果雲恢復",
"checkConnection.name": "檢查連接",
"checkConnection.success": "已連接堅果雲",
"checkConnection.fail": "堅果雲連接失敗",
"username": "堅果雲用戶名",
"path": "堅果雲存儲路徑",
"path.placeholder": "請輸入堅果雲的存儲路徑",
"backup.button": "備份到堅果雲",
"restore.button": "從堅果雲恢復",
"pathSelector.title": "堅果雲存儲路徑",
"pathSelector.return": "返回",
"pathSelector.currentPath": "當前路徑",
"new_folder.button.confirm": "確定",
"new_folder.button.cancel": "取消",
"new_folder.button": "新建文件夾"
}
},
"display.assistant.title": "助手設定",

View File

@ -7,6 +7,7 @@ import {
SaveOutlined,
YuqueOutlined
} from '@ant-design/icons'
import { NutstoreIcon } from '@renderer/components/Icons/NutstoreIcons'
import { HStack } from '@renderer/components/Layout'
import ListItem from '@renderer/components/ListItem'
import BackupPopup from '@renderer/components/Popups/BackupPopup'
@ -25,6 +26,7 @@ import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowT
import JoplinSettings from './JoplinSettings'
import MarkdownExportSettings from './MarkdownExportSettings'
import NotionSettings from './NotionSettings'
import NutstoreSettings from './NutstoreSettings'
import ObsidianSettings from './ObsidianSettings'
import WebDavSettings from './WebDavSettings'
import YuqueSettings from './YuqueSettings'
@ -46,6 +48,7 @@ const DataSettings: FC = () => {
const menuItems = [
{ key: 'data', title: 'settings.data.data.title', icon: <DatabaseOutlined style={{ fontSize: 16 }} /> },
{ key: 'webdav', title: 'settings.data.webdav.title', icon: <CloudSyncOutlined style={{ fontSize: 16 }} /> },
{ key: 'nutstore', title: 'settings.data.nutstore.title', icon: <NutstoreIcon /> },
{
key: 'markdown_export',
title: 'settings.data.markdown_export.title',
@ -201,6 +204,7 @@ const DataSettings: FC = () => {
</>
)}
{menu === 'webdav' && <WebDavSettings />}
{menu === 'nutstore' && <NutstoreSettings />}
{menu === 'markdown_export' && <MarkdownExportSettings />}
{menu === 'notion' && <NotionSettings />}
{menu === 'yuque' && <YuqueSettings />}

View File

@ -0,0 +1,347 @@
import { CheckOutlined, FolderOutlined, LoadingOutlined, SyncOutlined, WarningOutlined } from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout'
import NutstorePathPopup from '@renderer/components/Popups/NutsorePathPopup'
import {
useWebdavBackupModal,
useWebdavRestoreModal,
WebdavBackupModal,
WebdavRestoreModal
} from '@renderer/components/WebdavModals'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useNutstoreSSO } from '@renderer/hooks/useNutstoreSSO'
import {
backupToNutstore,
checkConnection,
createDirectory,
restoreFromNutstore,
startNutstoreAutoSync,
stopNutstoreAutoSync
} from '@renderer/services/NutstoreService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import {
setNutstoreAutoSync,
setNutstorePath,
setNutstoreSyncInterval,
setNutstoreToken
} from '@renderer/store/nutstore'
import { modalConfirm } from '@renderer/utils'
import { NUTSTORE_HOST } from '@shared/config/nutstore'
import { Button, Input, Select, Tooltip, Typography } from 'antd'
import dayjs from 'dayjs'
import { FC, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { type FileStat } from 'webdav'
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
const NutstoreSettings: FC = () => {
const { theme } = useTheme()
const { t } = useTranslation()
const { nutstoreToken, nutstorePath, nutstoreSyncInterval, nutstoreAutoSync, nutstoreSyncState } = useAppSelector(
(state) => state.nutstore
)
const dispatch = useAppDispatch()
const [nutstoreUsername, setNutstoreUsername] = useState<string | undefined>(undefined)
const [nutstorePass, setNutstorePass] = useState<string | undefined>(undefined)
const [storagePath, setStoragePath] = useState<string | undefined>(nutstorePath)
const [checkConnectionLoading, setCheckConnectionLoading] = useState(false)
const [nsConnected, setNsConnected] = useState<boolean>(false)
const [syncInterval, setSyncInterval] = useState<number>(nutstoreSyncInterval)
const nutstoreSSOHandler = useNutstoreSSO()
const handleClickNutstoreSSO = useCallback(async () => {
const ssoUrl = await window.api.nutstore.getSSOUrl()
window.open(ssoUrl, '_blank')
const nutstoreToken = await nutstoreSSOHandler()
dispatch(setNutstoreToken(nutstoreToken))
}, [dispatch, nutstoreSSOHandler])
useEffect(() => {
async function decryptTokenEffect() {
if (nutstoreToken) {
const decrypted = await window.api.nutstore.decryptToken(nutstoreToken)
if (decrypted) {
setNutstoreUsername(decrypted.username)
setNutstorePass(decrypted.access_token)
if (!nutstorePath) {
dispatch(setNutstorePath('/cherry-studio'))
setStoragePath('/cherry-studio')
}
}
}
}
decryptTokenEffect()
}, [nutstoreToken, dispatch, nutstorePath])
const handleLayout = useCallback(async () => {
const confirmedLogout = await modalConfirm({
title: t('settings.data.nutstore.logout.title'),
content: t('settings.data.nutstore.logout.content')
})
if (confirmedLogout) {
dispatch(setNutstoreToken(''))
dispatch(setNutstorePath(''))
setNutstoreUsername('')
setStoragePath(undefined)
}
}, [dispatch, t])
const handleCheckConnection = async () => {
if (!nutstoreToken) return
setCheckConnectionLoading(true)
const isConnectedToNutstore = await checkConnection()
window.message[isConnectedToNutstore ? 'success' : 'error']({
key: 'api-check',
style: { marginTop: '3vh' },
duration: 2,
content: isConnectedToNutstore
? t('settings.data.nutstore.checkConnection.success')
: t('settings.data.nutstore.checkConnection.fail')
})
setNsConnected(isConnectedToNutstore)
setCheckConnectionLoading(false)
setTimeout(() => setNsConnected(false), 3000)
}
const { isModalVisible, handleBackup, handleCancel, backuping, customFileName, setCustomFileName, showBackupModal } =
useWebdavBackupModal({
backupMethod: backupToNutstore
})
const {
isRestoreModalVisible,
handleRestore,
handleCancel: handleCancelRestore,
restoring,
selectedFile,
setSelectedFile,
loadingFiles,
backupFiles,
showRestoreModal
} = useWebdavRestoreModal({
restoreMethod: restoreFromNutstore,
webdavHost: NUTSTORE_HOST,
webdavUser: nutstoreUsername,
webdavPass: nutstorePass,
webdavPath: storagePath
})
const onSyncIntervalChange = (value: number) => {
setSyncInterval(value)
dispatch(setNutstoreSyncInterval(value))
if (value === 0) {
dispatch(setNutstoreAutoSync(false))
stopNutstoreAutoSync()
} else {
dispatch(setNutstoreAutoSync(true))
startNutstoreAutoSync()
}
}
const handleClickPathChange = async () => {
if (!nutstoreToken) {
return
}
const result = await window.api.nutstore.decryptToken(nutstoreToken)
if (!result) {
return
}
const targetPath = await NutstorePathPopup.show({
ls: async (target: string) => {
const { username, access_token } = result
const token = window.btoa(`${username}:${access_token}`)
const items = await window.api.nutstore.getDirectoryContents(token, target)
return items.map(fileStatToStatModel)
},
mkdirs: async (path) => {
await createDirectory(path)
}
})
if (!targetPath) {
return
}
setStoragePath(targetPath)
dispatch(setNutstorePath(targetPath))
}
const renderSyncStatus = () => {
if (!nutstoreToken) return null
if (!nutstoreSyncState.lastSyncTime && !nutstoreSyncState.syncing && !nutstoreSyncState.lastSyncError) {
return <span style={{ color: 'var(--text-secondary)' }}>{t('settings.data.webdav.noSync')}</span>
}
return (
<HStack gap="5px" alignItems="center">
{nutstoreSyncState.syncing && <SyncOutlined spin />}
{!nutstoreSyncState.syncing && nutstoreSyncState.lastSyncError && (
<Tooltip title={`${t('settings.data.webdav.syncError')}: ${nutstoreSyncState.lastSyncError}`}>
<WarningOutlined style={{ color: 'red' }} />
</Tooltip>
)}
{nutstoreSyncState.lastSyncTime && (
<span style={{ color: 'var(--text-secondary)' }}>
{t('settings.data.webdav.lastSync')}: {dayjs(nutstoreSyncState.lastSyncTime).format('HH:mm:ss')}
</span>
)}
</HStack>
)
}
const isLogin = nutstoreToken && nutstoreUsername
return (
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.data.nutstore.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle>
{isLogin ? t('settings.data.nutstore.isLogin') : t('settings.data.nutstore.notLogin')}
</SettingRowTitle>
{isLogin ? (
<HStack gap="5px" justifyContent="space-between" alignItems="center">
<Button
type={nsConnected ? 'primary' : 'default'}
ghost={nsConnected}
onClick={handleCheckConnection}
loading={checkConnectionLoading}>
{checkConnectionLoading ? (
<LoadingOutlined spin />
) : nsConnected ? (
<CheckOutlined />
) : (
t('settings.data.nutstore.checkConnection.name')
)}
</Button>
<Button type="primary" danger onClick={handleLayout}>
{t('settings.data.nutstore.logout.button')}
</Button>
</HStack>
) : (
<Button onClick={handleClickNutstoreSSO}>{t('settings.data.nutstore.login.button')}</Button>
)}
</SettingRow>
<SettingDivider />
{isLogin && (
<>
<SettingRow>
<SettingRowTitle>{t('settings.data.nutstore.username')}</SettingRowTitle>
<Typography.Text style={{ color: 'var(--color-text-3)' }}>{nutstoreUsername}</Typography.Text>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.nutstore.path')}</SettingRowTitle>
<HStack gap="4px" justifyContent="space-between">
<Input
placeholder={t('settings.data.nutstore.path.placeholder')}
style={{ width: 250 }}
value={nutstorePath}
onChange={(e) => setStoragePath(e.target.value)}
onBlur={() => dispatch(setNutstorePath(storagePath || ''))}
/>
<Button type="default" onClick={handleClickPathChange}>
<FolderOutlined />
</Button>
</HStack>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle>
<HStack gap="5px" justifyContent="space-between">
<Button onClick={showBackupModal} loading={backuping}>
{t('settings.data.nutstore.backup.button')}
</Button>
<Button onClick={showRestoreModal} loading={restoring}>
{t('settings.data.nutstore.restore.button')}
</Button>
</HStack>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.webdav.autoSync')}</SettingRowTitle>
<Select value={syncInterval} onChange={onSyncIntervalChange} style={{ width: 120 }}>
<Select.Option value={0}>{t('settings.data.webdav.autoSync.off')}</Select.Option>
<Select.Option value={1}>{t('settings.data.webdav.minute_interval', { count: 1 })}</Select.Option>
<Select.Option value={5}>{t('settings.data.webdav.minute_interval', { count: 5 })}</Select.Option>
<Select.Option value={15}>{t('settings.data.webdav.minute_interval', { count: 15 })}</Select.Option>
<Select.Option value={30}>{t('settings.data.webdav.minute_interval', { count: 30 })}</Select.Option>
<Select.Option value={60}>{t('settings.data.webdav.hour_interval', { count: 1 })}</Select.Option>
<Select.Option value={120}>{t('settings.data.webdav.hour_interval', { count: 2 })}</Select.Option>
<Select.Option value={360}>{t('settings.data.webdav.hour_interval', { count: 6 })}</Select.Option>
<Select.Option value={720}>{t('settings.data.webdav.hour_interval', { count: 12 })}</Select.Option>
<Select.Option value={1440}>{t('settings.data.webdav.hour_interval', { count: 24 })}</Select.Option>
</Select>
</SettingRow>
{nutstoreAutoSync && syncInterval > 0 && (
<>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.webdav.syncStatus')}</SettingRowTitle>
{renderSyncStatus()}
</SettingRow>
</>
)}
</>
)}
<>
<WebdavBackupModal
isModalVisible={isModalVisible}
handleBackup={handleBackup}
handleCancel={handleCancel}
backuping={backuping}
customFileName={customFileName}
setCustomFileName={setCustomFileName}
/>
<WebdavRestoreModal
isRestoreModalVisible={isRestoreModalVisible}
handleRestore={handleRestore}
handleCancel={handleCancelRestore}
restoring={restoring}
selectedFile={selectedFile}
setSelectedFile={setSelectedFile}
loadingFiles={loadingFiles}
backupFiles={backupFiles}
/>
</>
</SettingGroup>
)
}
export interface StatModel {
path: string
basename: string
isDir: boolean
isDeleted: boolean
mtime: number
size: number
}
function fileStatToStatModel(from: FileStat): StatModel {
return {
path: from.filename,
basename: from.basename,
isDir: from.type === 'directory',
isDeleted: false,
mtime: new Date(from.lastmod).valueOf(),
size: from.size
}
}
export default NutstoreSettings

View File

@ -1,8 +1,14 @@
import { FolderOpenOutlined, SaveOutlined, SyncOutlined, WarningOutlined } from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout'
import {
useWebdavBackupModal,
useWebdavRestoreModal,
WebdavBackupModal,
WebdavRestoreModal
} from '@renderer/components/WebdavModals'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useSettings } from '@renderer/hooks/useSettings'
import { backupToWebdav, restoreFromWebdav, startAutoSync, stopAutoSync } from '@renderer/services/BackupService'
import { startAutoSync, stopAutoSync } from '@renderer/services/BackupService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import {
setWebdavAutoSync,
@ -12,20 +18,13 @@ import {
setWebdavSyncInterval as _setWebdavSyncInterval,
setWebdavUser as _setWebdavUser
} from '@renderer/store/settings'
import { formatFileSize } from '@renderer/utils'
import { Button, Input, Modal, Select, Spin, Tooltip } from 'antd'
import { Button, Input, Select, Tooltip } from 'antd'
import dayjs from 'dayjs'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
interface BackupFile {
fileName: string
modifiedTime: string
size: number
}
const WebDavSettings: FC = () => {
const {
webdavHost: webDAVHost,
@ -42,15 +41,6 @@ const WebDavSettings: FC = () => {
const [syncInterval, setSyncInterval] = useState<number>(webDAVSyncInterval)
const [backuping, setBackuping] = useState(false)
const [restoring, setRestoring] = useState(false)
const [isModalVisible, setIsModalVisible] = useState(false)
const [customFileName, setCustomFileName] = useState('')
const [isRestoreModalVisible, setIsRestoreModalVisible] = useState(false)
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([])
const [selectedFile, setSelectedFile] = useState<string>('')
const [loadingFiles, setLoadingFiles] = useState(false)
const dispatch = useAppDispatch()
const { theme } = useTheme()
@ -96,87 +86,20 @@ const WebDavSettings: FC = () => {
)
}
const showBackupModal = async () => {
// 获取默认文件名
const deviceType = await window.api.system.getDeviceType()
const timestamp = dayjs().format('YYYYMMDDHHmmss')
const defaultFileName = `cherry-studio.${timestamp}.${deviceType}.zip`
setCustomFileName(defaultFileName)
setIsModalVisible(true)
}
const { isModalVisible, handleBackup, handleCancel, backuping, customFileName, setCustomFileName, showBackupModal } =
useWebdavBackupModal()
const handleBackup = async () => {
setBackuping(true)
try {
await backupToWebdav({ showMessage: true, customFileName })
} finally {
setBackuping(false)
setIsModalVisible(false)
}
}
const handleCancel = () => {
setIsModalVisible(false)
}
const showRestoreModal = async () => {
if (!webdavHost || !webdavUser || !webdavPass || !webdavPath) {
window.message.error({ content: t('message.error.invalid.webdav'), key: 'webdav-error' })
return
}
setIsRestoreModalVisible(true)
setLoadingFiles(true)
try {
const files = await window.api.backup.listWebdavFiles({
webdavHost,
webdavUser,
webdavPass,
webdavPath
})
setBackupFiles(files)
} catch (error: any) {
window.message.error({ content: error.message, key: 'list-files-error' })
} finally {
setLoadingFiles(false)
}
}
const handleRestore = async () => {
if (!selectedFile || !webdavHost || !webdavUser || !webdavPass || !webdavPath) {
window.message.error({
content: !selectedFile ? t('message.error.no.file.selected') : t('message.error.invalid.webdav'),
key: 'restore-error'
})
return
}
window.modal.confirm({
title: t('settings.data.webdav.restore.confirm.title'),
content: t('settings.data.webdav.restore.confirm.content'),
centered: true,
onOk: async () => {
setRestoring(true)
try {
await restoreFromWebdav(selectedFile)
setIsRestoreModalVisible(false)
} catch (error: any) {
window.message.error({ content: error.message, key: 'restore-error' })
} finally {
setRestoring(false)
}
}
})
}
const formatFileOption = (file: BackupFile) => {
const date = dayjs(file.modifiedTime).format('YYYY-MM-DD HH:mm:ss')
const size = formatFileSize(file.size)
return {
label: `${file.fileName} (${date}, ${size})`,
value: file.fileName
}
}
const {
isRestoreModalVisible,
handleRestore,
handleCancel: handleCancelRestore,
restoring,
selectedFile,
setSelectedFile,
loadingFiles,
backupFiles,
showRestoreModal
} = useWebdavRestoreModal({ webdavHost, webdavUser, webdavPass, webdavPath })
return (
<SettingGroup theme={theme}>
@ -264,44 +187,25 @@ const WebDavSettings: FC = () => {
</>
)}
<>
<Modal
title={t('settings.data.webdav.backup.modal.title')}
open={isModalVisible}
onOk={handleBackup}
onCancel={handleCancel}
okButtonProps={{ loading: backuping }}>
<Input
value={customFileName}
onChange={(e) => setCustomFileName(e.target.value)}
placeholder={t('settings.data.webdav.backup.modal.filename.placeholder')}
/>
</Modal>
<WebdavBackupModal
isModalVisible={isModalVisible}
handleBackup={handleBackup}
handleCancel={handleCancel}
backuping={backuping}
customFileName={customFileName}
setCustomFileName={setCustomFileName}
/>
<Modal
title={t('settings.data.webdav.restore.modal.title')}
open={isRestoreModalVisible}
onOk={handleRestore}
onCancel={() => setIsRestoreModalVisible(false)}
okButtonProps={{ loading: restoring }}
width={600}>
<div style={{ position: 'relative' }}>
<Select
style={{ width: '100%' }}
placeholder={t('settings.data.webdav.restore.modal.select.placeholder')}
value={selectedFile}
onChange={setSelectedFile}
options={backupFiles.map(formatFileOption)}
loading={loadingFiles}
showSearch
filterOption={(input, option) => (option?.label ?? '').toLowerCase().includes(input.toLowerCase())}
/>
{loadingFiles && (
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}>
<Spin />
</div>
)}
</div>
</Modal>
<WebdavRestoreModal
isRestoreModalVisible={isRestoreModalVisible}
handleRestore={handleRestore}
handleCancel={handleCancelRestore}
restoring={restoring}
selectedFile={selectedFile}
setSelectedFile={setSelectedFile}
loadingFiles={loadingFiles}
backupFiles={backupFiles}
/>
</>
</SettingGroup>
)

View File

@ -229,7 +229,7 @@ export function stopAutoSync() {
autoSyncStarted = false
}
async function getBackupData() {
export async function getBackupData() {
return JSON.stringify({
time: new Date().getTime(),
version: 3,
@ -239,7 +239,7 @@ async function getBackupData() {
}
/************************************* Backup Utils ************************************** */
async function handleData(data: Record<string, any>) {
export async function handleData(data: Record<string, any>) {
if (data.version === 1) {
await clearDatabase()

View File

@ -0,0 +1,229 @@
import i18n from '@renderer/i18n'
import store from '@renderer/store'
import { setNutstoreSyncState } from '@renderer/store/nutstore'
import { WebDavConfig } from '@renderer/types'
import { NUTSTORE_HOST } from '@shared/config/nutstore'
import { type CreateDirectoryOptions } from 'webdav'
import { getBackupData, handleData } from './BackupService'
function getNutstoreToken() {
const nutstoreToken = store.getState().nutstore.nutstoreToken
if (!nutstoreToken) {
window.message.error({ content: i18n.t('error.invalid.nutstore_token'), key: 'nutstore' })
return null
}
return nutstoreToken
}
async function createNutstoreConfig(nutstoreToken: string): Promise<WebDavConfig | null> {
const result = await window.api.nutstore.decryptToken(nutstoreToken)
if (!result) {
console.log('Invalid nutstore token')
return null
}
const nutstorePath = store.getState().nutstore.nutstorePath
const { username, access_token } = result
return {
webdavHost: NUTSTORE_HOST,
webdavUser: username,
webdavPass: access_token,
webdavPath: nutstorePath
}
}
export async function checkConnection() {
const nutstoreToken = getNutstoreToken()
if (!nutstoreToken) {
return false
}
const config = await createNutstoreConfig(nutstoreToken)
if (!config) {
return false
}
const isSuccess = await window.api.backup.checkConnection({
...config,
webdavPath: '/'
})
return isSuccess
}
let autoSyncStarted = false
let syncTimeout: NodeJS.Timeout | null = null
let isAutoBackupRunning = false
let isManualBackupRunning = false
export async function backupToNutstore(options: { showMessage?: boolean } = {}) {
const nutstoreToken = getNutstoreToken()
if (!nutstoreToken) {
return
}
const { showMessage = false } = options
if (isManualBackupRunning) {
console.log('Backup already in progress')
return
}
const config = await createNutstoreConfig(nutstoreToken)
if (!config) {
return
}
isManualBackupRunning = true
store.dispatch(setNutstoreSyncState({ syncing: true, lastSyncError: null }))
const backupData = await getBackupData()
try {
const isSuccess = await window.api.backup.backupToWebdav(backupData, config)
if (isSuccess) {
store.dispatch(
setNutstoreSyncState({
lastSyncError: null
})
)
showMessage && window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' })
} else {
store.dispatch(setNutstoreSyncState({ lastSyncError: 'Backup failed' }))
window.message.error({ content: i18n.t('message.backup.failed'), key: 'backup' })
}
} catch (error) {
store.dispatch(setNutstoreSyncState({ lastSyncError: 'Backup failed' }))
console.error('[Nutstore] Backup failed:', error)
window.message.error({ content: i18n.t('message.backup.failed'), key: 'backup' })
} finally {
store.dispatch(setNutstoreSyncState({ lastSyncTime: Date.now(), syncing: false }))
isManualBackupRunning = false
}
}
export async function restoreFromNutstore() {
const nutstoreToken = getNutstoreToken()
if (!nutstoreToken) {
return
}
const config = await createNutstoreConfig(nutstoreToken)
if (!config) {
return
}
let data = ''
try {
data = await window.api.backup.restoreFromWebdav(config)
} 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
})
}
try {
await handleData(JSON.parse(data))
} catch (error) {
console.error('[backup] Error downloading file from WebDAV:', error)
window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' })
}
}
export async function startNutstoreAutoSync() {
if (autoSyncStarted) {
return
}
const nutstoreToken = getNutstoreToken()
if (!nutstoreToken) {
window.message.error({ content: i18n.t('error.invalid.nutstore_token'), key: 'nutstore' })
return
}
autoSyncStarted = true
stopNutstoreAutoSync()
scheduleNextBackup()
function scheduleNextBackup() {
if (syncTimeout) {
clearTimeout(syncTimeout)
syncTimeout = null
}
const { nutstoreSyncInterval, nutstoreSyncState } = store.getState().nutstore
if (nutstoreSyncInterval <= 0) {
console.log('[Nutstore AutoSync] Invalid sync interval, nutstore auto sync disabled')
stopNutstoreAutoSync()
return
}
// 用户指定的自动备份时间间隔(毫秒)
const requiredInterval = nutstoreSyncInterval * 60 * 1000
// 如果存在最后一次同步WebDAV的时间以它为参考计算下一次同步的时间
const timeUntilNextSync = nutstoreSyncState?.lastSyncTime
? Math.max(1000, nutstoreSyncState.lastSyncTime + requiredInterval - Date.now())
: requiredInterval
syncTimeout = setTimeout(performAutoBackup, timeUntilNextSync)
console.log(
`[Nutstore AutoSync] Next sync scheduled in ${Math.floor(timeUntilNextSync / 1000 / 60)} minutes ${Math.floor(
(timeUntilNextSync / 1000) % 60
)} seconds`
)
}
async function performAutoBackup() {
if (isAutoBackupRunning || isManualBackupRunning) {
console.log('[Nutstore AutoSync] Backup already in progress, rescheduling')
scheduleNextBackup()
return
}
isAutoBackupRunning = true
try {
console.log('[Nutstore AutoSync] Starting auto backup...')
await backupToNutstore({ showMessage: false })
} catch (error) {
console.error('[Nutstore AutoSync] Auto backup failed:', error)
} finally {
isAutoBackupRunning = false
scheduleNextBackup()
}
}
}
export function stopNutstoreAutoSync() {
if (syncTimeout) {
console.log('[Nutstore AutoSync] Stopping nutstore auto sync')
clearTimeout(syncTimeout)
syncTimeout = null
}
isAutoBackupRunning = false
autoSyncStarted = false
}
export async function createDirectory(path: string, options?: CreateDirectoryOptions) {
const nutstoreToken = getNutstoreToken()
if (!nutstoreToken) {
return
}
const config = await createNutstoreConfig(nutstoreToken)
if (!config) {
return
}
await window.api.backup.createDirectory(config, path, options)
}

View File

@ -13,6 +13,7 @@ import mcp from './mcp'
import messagesReducer from './messages'
import migrate from './migrate'
import minapps from './minapps'
import nutstore from './nutstore'
import paintings from './paintings'
import runtime from './runtime'
import settings from './settings'
@ -23,6 +24,7 @@ const rootReducer = combineReducers({
assistants,
agents,
backup,
nutstore,
paintings,
llm,
settings,

View File

@ -0,0 +1,53 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { WebDAVSyncState } from './backup'
export interface NutstoreSyncState extends WebDAVSyncState {}
export interface NutstoreState {
nutstoreToken: string | null
nutstorePath: string
nutstoreAutoSync: boolean
nutstoreSyncInterval: number
nutstoreSyncState: NutstoreSyncState
}
const initialState: NutstoreState = {
nutstoreToken: '',
nutstorePath: '/cherry-studio',
nutstoreAutoSync: false,
nutstoreSyncInterval: 0,
nutstoreSyncState: {
lastSyncTime: null,
syncing: false,
lastSyncError: null
}
}
const nutstoreSlice = createSlice({
name: 'nutstore',
initialState,
reducers: {
setNutstoreToken: (state, action: PayloadAction<string>) => {
state.nutstoreToken = action.payload
},
setNutstorePath: (state, action: PayloadAction<string>) => {
console.log(state, action.payload)
state.nutstorePath = action.payload
},
setNutstoreAutoSync: (state, action: PayloadAction<boolean>) => {
state.nutstoreAutoSync = action.payload
},
setNutstoreSyncInterval: (state, action: PayloadAction<number>) => {
state.nutstoreSyncInterval = action.payload
},
setNutstoreSyncState: (state, action: PayloadAction<Partial<WebDAVSyncState>>) => {
state.nutstoreSyncState = { ...state.nutstoreSyncState, ...action.payload }
}
}
})
export const { setNutstoreToken, setNutstorePath, setNutstoreAutoSync, setNutstoreSyncInterval, setNutstoreSyncState } =
nutstoreSlice.actions
export default nutstoreSlice.reducer

View File

@ -2,6 +2,8 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { TRANSLATE_PROMPT } from '@renderer/config/prompts'
import { CodeStyleVarious, LanguageVarious, ThemeMode, TranslateLanguageVarious } from '@renderer/types'
import { WebDAVSyncState } from './backup'
export type SendMessageShortcut = 'Enter' | 'Shift+Enter' | 'Ctrl+Enter' | 'Command+Enter'
export type SidebarIcon = 'assistants' | 'agents' | 'paintings' | 'translate' | 'minapp' | 'knowledge' | 'files'
@ -16,6 +18,8 @@ export const DEFAULT_SIDEBAR_ICONS: SidebarIcon[] = [
'files'
]
export interface NutstoreSyncRuntime extends WebDAVSyncState {}
export interface SettingsState {
showAssistants: boolean
showTopics: boolean

14
src/renderer/src/types/nutstore.d.ts vendored Normal file
View File

@ -0,0 +1,14 @@
declare namespace Nutstore {
export interface FileStat {
path: string
basename: string
isDir: boolean
}
type MaybePromise<T> = Promise<T> | T
export interface Fs {
ls: (path: string) => MaybePromise<FileStat[]>
mkdirs: (path: string) => Promise<void>
}
}

View File

@ -5,7 +5,7 @@
"src/main/**/*",
"src/preload/**/*",
"src/main/env.d.ts",
"src/renderer/src/types/index.ts",
"src/renderer/src/types/*",
"packages/shared/**/*"
],
"compilerOptions": {

View File

@ -3868,6 +3868,7 @@ __metadata:
eslint-plugin-react-hooks: "npm:^5.2.0"
eslint-plugin-simple-import-sort: "npm:^12.1.1"
eslint-plugin-unused-imports: "npm:^4.1.4"
fast-xml-parser: "npm:^5.0.9"
fetch-socks: "npm:^1.3.2"
fs-extra: "npm:^11.2.0"
html-to-image: "npm:^1.11.13"
@ -7307,6 +7308,17 @@ __metadata:
languageName: node
linkType: hard
"fast-xml-parser@npm:^5.0.9":
version: 5.0.9
resolution: "fast-xml-parser@npm:5.0.9"
dependencies:
strnum: "npm:^2.0.5"
bin:
fxparser: src/cli/cli.js
checksum: 10c0/29aaa74cb5224ddf755c2777fefce41961514fb525ce153ba9a8cbfd03292c93b67c0c19f3f4fdb5d8fa96a4b70c42dc31504eefc6477a668cea71a11999bc45
languageName: node
linkType: hard
"fastq@npm:^1.6.0":
version: 1.19.1
resolution: "fastq@npm:1.19.1"
@ -14920,6 +14932,13 @@ __metadata:
languageName: node
linkType: hard
"strnum@npm:^2.0.5":
version: 2.0.5
resolution: "strnum@npm:2.0.5"
checksum: 10c0/856026ef65eaf15359d340a313ece25822b6472377b3029201b00f2657a1a3fa1cd7a7ce349dad35afdd00faf451344153dbb3d8478f082b7af8c17a64799ea6
languageName: node
linkType: hard
"strtok3@npm:^6.2.4":
version: 6.3.0
resolution: "strtok3@npm:6.3.0"