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:
parent
b321169ca2
commit
fd66881022
@ -6,3 +6,4 @@ tsconfig.json
|
||||
tsconfig.*.json
|
||||
CHANGELOG*.md
|
||||
agents.json
|
||||
src/renderer/src/integration/nutstore/sso/lib
|
||||
|
||||
@ -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/**'
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
@ -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",
|
||||
|
||||
1
packages/shared/config/nutstore.ts
Normal file
1
packages/shared/config/nutstore.ts
Normal file
@ -0,0 +1 @@
|
||||
export const NUTSTORE_HOST = 'https://dav.jianguoyun.com/dav'
|
||||
@ -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) => {
|
||||
|
||||
8
src/main/integration/nutstore/sso/lib/index.d.ts
vendored
Normal file
8
src/main/integration/nutstore/sso/lib/index.d.ts
vendored
Normal 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 };
|
||||
9
src/main/integration/nutstore/sso/lib/index.js
Normal file
9
src/main/integration/nutstore/sso/lib/index.js
Normal file
File diff suppressed because one or more lines are too long
@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
134
src/main/services/NutstoreService.ts
Normal file
134
src/main/services/NutstoreService.ts
Normal 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
|
||||
}
|
||||
34
src/main/services/ProtocolClient.ts
Normal file
34
src/main/services/ProtocolClient.ts
Normal 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¶m2=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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
10
src/preload/index.d.ts
vendored
10
src/preload/index.d.ts
vendored
@ -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>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -39,5 +39,4 @@
|
||||
<script type="module" src="/src/init.ts"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
50
src/renderer/src/components/Icons/NutstoreIcons.tsx
Normal file
50
src/renderer/src/components/Icons/NutstoreIcons.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
250
src/renderer/src/components/NutstorePathSelector.tsx
Normal file
250
src/renderer/src/components/NutstorePathSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
60
src/renderer/src/components/Popups/NutsorePathPopup.tsx
Normal file
60
src/renderer/src/components/Popups/NutsorePathPopup.tsx
Normal 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
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
237
src/renderer/src/components/WebdavModals.tsx
Normal file
237
src/renderer/src/components/WebdavModals.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
24
src/renderer/src/hooks/useNutstoreSSO.ts
Normal file
24
src/renderer/src/hooks/useNutstoreSSO.ts
Normal 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
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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": "アシスタント設定",
|
||||
|
||||
@ -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": "Настройки ассистентов",
|
||||
|
||||
@ -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": "助手设置",
|
||||
|
||||
@ -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": "助手設定",
|
||||
|
||||
@ -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 />}
|
||||
|
||||
@ -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
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
229
src/renderer/src/services/NutstoreService.ts
Normal file
229
src/renderer/src/services/NutstoreService.ts
Normal 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)
|
||||
}
|
||||
@ -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,
|
||||
|
||||
53
src/renderer/src/store/nutstore.ts
Normal file
53
src/renderer/src/store/nutstore.ts
Normal 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
|
||||
@ -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
14
src/renderer/src/types/nutstore.d.ts
vendored
Normal 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>
|
||||
}
|
||||
}
|
||||
@ -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": {
|
||||
|
||||
19
yarn.lock
19
yarn.lock
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user