Compare commits
5 Commits
feat/sub_a
...
copilot/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4269a32cfb | ||
|
|
6493f1853d | ||
|
|
cbd4f418f6 | ||
|
|
7d6ffe472c | ||
|
|
f16b63bd69 |
@@ -11,6 +11,7 @@ import * as path from 'path'
|
||||
import type { CreateDirectoryOptions, FileStat } from 'webdav'
|
||||
|
||||
import { getDataPath } from '../utils'
|
||||
import { expandNotesPath } from '../utils/file'
|
||||
import S3Storage from './S3Storage'
|
||||
import WebDav from './WebDav'
|
||||
import { windowService } from './WindowService'
|
||||
@@ -240,11 +241,49 @@ class BackupManager {
|
||||
// 使用流式复制
|
||||
await this.copyDirWithProgress(sourcePath, tempDataDir, (size) => {
|
||||
copiedSize += size
|
||||
const progress = Math.min(50, Math.floor((copiedSize / totalSize) * 50))
|
||||
const progress = Math.min(45, Math.floor((copiedSize / totalSize) * 45))
|
||||
onProgress({ stage: 'copying_files', progress, total: 100 })
|
||||
})
|
||||
|
||||
await this.setWritableRecursive(tempDataDir)
|
||||
|
||||
// 检查并备份 notes 目录(如果配置在 Data 目录外)
|
||||
try {
|
||||
const backupData = JSON.parse(data)
|
||||
const persistData = JSON.parse(backupData.localStorage?.['persist:cherry-studio'] || '{}')
|
||||
const noteState = JSON.parse(persistData.note || '{}')
|
||||
const notesPath = noteState.notesPath
|
||||
|
||||
if (notesPath) {
|
||||
// 展开路径获取绝对路径
|
||||
const expandedNotesPath = expandNotesPath(notesPath)
|
||||
const dataPath = path.join(app.getPath('userData'), 'Data')
|
||||
const normalizedDataPath = path.normalize(dataPath)
|
||||
const normalizedNotesPath = path.normalize(expandedNotesPath)
|
||||
|
||||
// 检查 notes 是否在 Data 目录外
|
||||
const isOutsideData =
|
||||
!normalizedNotesPath.startsWith(normalizedDataPath + path.sep) &&
|
||||
normalizedNotesPath !== normalizedDataPath
|
||||
|
||||
if (isOutsideData && fs.existsSync(expandedNotesPath)) {
|
||||
logger.info(`Backing up notes from external location: ${expandedNotesPath}`)
|
||||
const tempNotesDir = path.join(this.tempDir, 'Notes')
|
||||
await this.copyDirWithProgress(expandedNotesPath, tempNotesDir, (size) => {
|
||||
// Notes backup progress from 45% to 50%
|
||||
copiedSize += size
|
||||
const notesProgress = 45 + Math.min(5, Math.floor((size / totalSize) * 5))
|
||||
onProgress({ stage: 'copying_notes', progress: notesProgress, total: 100 })
|
||||
})
|
||||
await this.setWritableRecursive(tempNotesDir)
|
||||
logger.info('External notes directory backed up successfully')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果解析失败或获取 notes 路径失败,继续备份其他内容
|
||||
logger.warn('Failed to parse notes path from backup data, skipping external notes backup', error as Error)
|
||||
}
|
||||
|
||||
onProgress({ stage: 'preparing_compression', progress: 50, total: 100 })
|
||||
} else {
|
||||
logger.debug('Skip the backup of the file')
|
||||
@@ -399,13 +438,52 @@ class BackupManager {
|
||||
// 使用流式复制
|
||||
await this.copyDirWithProgress(sourcePath, destPath, (size) => {
|
||||
copiedSize += size
|
||||
const progress = Math.min(85, 35 + Math.floor((copiedSize / totalSize) * 50))
|
||||
const progress = Math.min(75, 35 + Math.floor((copiedSize / totalSize) * 40))
|
||||
onProgress({ stage: 'copying_files', progress, total: 100 })
|
||||
})
|
||||
} else {
|
||||
logger.debug('skipBackupFile is true, skip restoring Data directory')
|
||||
}
|
||||
|
||||
// 检查并恢复外部 Notes 目录
|
||||
logger.debug('step 3.5: check and restore external Notes directory')
|
||||
const notesBackupPath = path.join(this.tempDir, 'Notes')
|
||||
const notesExists = await fs.pathExists(notesBackupPath)
|
||||
|
||||
if (notesExists) {
|
||||
try {
|
||||
// 从 data.json 中获取 notes 路径配置
|
||||
const backupData = JSON.parse(data)
|
||||
const persistData = JSON.parse(backupData.localStorage?.['persist:cherry-studio'] || '{}')
|
||||
const noteState = JSON.parse(persistData.note || '{}')
|
||||
const notesPath = noteState.notesPath
|
||||
|
||||
if (notesPath) {
|
||||
const expandedNotesPath = expandNotesPath(notesPath)
|
||||
logger.info(`Restoring notes to configured location: ${expandedNotesPath}`)
|
||||
|
||||
// 确保目标目录的父目录存在
|
||||
await fs.ensureDir(path.dirname(expandedNotesPath))
|
||||
|
||||
// 如果目标已存在,先删除
|
||||
if (await fs.pathExists(expandedNotesPath)) {
|
||||
await this.setWritableRecursive(expandedNotesPath)
|
||||
await fs.remove(expandedNotesPath)
|
||||
}
|
||||
|
||||
// 复制 Notes 目录
|
||||
await this.copyDirWithProgress(notesBackupPath, expandedNotesPath, (size) => {
|
||||
const progress = Math.min(85, 75 + Math.floor(size / 1000000))
|
||||
onProgress({ stage: 'copying_notes', progress, total: 100 })
|
||||
})
|
||||
|
||||
logger.info('External notes directory restored successfully')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to restore external notes directory', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('step 4: clean up temp directory')
|
||||
// 清理临时目录
|
||||
await this.setWritableRecursive(this.tempDir)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { loggerService } from '@logger'
|
||||
import {
|
||||
checkName,
|
||||
expandNotesPath,
|
||||
getFilesDir,
|
||||
getFileType,
|
||||
getName,
|
||||
getNotesDir,
|
||||
getNotesDirAbsolute,
|
||||
getTempDir,
|
||||
readTextFileWithAutoEncoding,
|
||||
scanDir
|
||||
@@ -56,7 +57,7 @@ const DEFAULT_WATCHER_CONFIG: Required<FileWatcherConfig> = {
|
||||
|
||||
class FileStorage {
|
||||
private storageDir = getFilesDir()
|
||||
private notesDir = getNotesDir()
|
||||
private notesDir = getNotesDirAbsolute()
|
||||
private tempDir = getTempDir()
|
||||
private watcher?: FSWatcher
|
||||
private watcherSender?: Electron.WebContents
|
||||
@@ -741,7 +742,9 @@ class FileStorage {
|
||||
|
||||
public getDirectoryStructure = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise<NotesTreeNode[]> => {
|
||||
try {
|
||||
return await scanDir(dirPath)
|
||||
// Expand relative paths before scanning
|
||||
const expandedPath = expandNotesPath(dirPath)
|
||||
return await scanDir(expandedPath)
|
||||
} catch (error) {
|
||||
logger.error('Failed to get directory structure:', error as Error)
|
||||
throw error
|
||||
@@ -754,8 +757,8 @@ class FileStorage {
|
||||
return false
|
||||
}
|
||||
|
||||
// Normalize path
|
||||
const normalizedPath = path.resolve(dirPath)
|
||||
// Expand and normalize path (handles ~, ., and .. paths)
|
||||
const normalizedPath = expandNotesPath(dirPath)
|
||||
|
||||
// Check if directory exists
|
||||
if (!fs.existsSync(normalizedPath)) {
|
||||
@@ -771,7 +774,7 @@ class FileStorage {
|
||||
// Get app paths to prevent selection of restricted directories
|
||||
const appDataPath = path.resolve(process.env.APPDATA || path.join(require('os').homedir(), '.config'))
|
||||
const filesDir = path.resolve(getFilesDir())
|
||||
const currentNotesDir = path.resolve(getNotesDir())
|
||||
const currentNotesDir = getNotesDirAbsolute()
|
||||
|
||||
// Prevent selecting app data directories
|
||||
if (
|
||||
@@ -1008,7 +1011,8 @@ class FileStorage {
|
||||
throw new Error('Directory path is required')
|
||||
}
|
||||
|
||||
const normalizedPath = path.resolve(dirPath.trim())
|
||||
// Expand relative paths before watching
|
||||
const normalizedPath = expandNotesPath(dirPath.trim())
|
||||
|
||||
if (!fs.existsSync(normalizedPath)) {
|
||||
throw new Error(`Directory does not exist: ${normalizedPath}`)
|
||||
|
||||
@@ -10,11 +10,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { readTextFileWithAutoEncoding } from '../file'
|
||||
import {
|
||||
expandNotesPath,
|
||||
getAllFiles,
|
||||
getAppConfigDir,
|
||||
getConfigDir,
|
||||
getFilesDir,
|
||||
getFileType,
|
||||
getNotesDir,
|
||||
getNotesDirAbsolute,
|
||||
getTempDir,
|
||||
isPathInside,
|
||||
untildify
|
||||
@@ -244,6 +247,20 @@ describe('file', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNotesDir', () => {
|
||||
it('should return relative path for portability', () => {
|
||||
const notesDir = getNotesDir()
|
||||
expect(notesDir).toBe('./Data/Notes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNotesDirAbsolute', () => {
|
||||
it('should return absolute notes directory path', () => {
|
||||
const notesDirAbsolute = getNotesDirAbsolute()
|
||||
expect(notesDirAbsolute).toBe('/mock/userData/Data/Notes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAppConfigDir', () => {
|
||||
it('should return correct app config directory path', () => {
|
||||
const appConfigDir = getAppConfigDir('test-app')
|
||||
@@ -331,6 +348,64 @@ describe('file', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('expandNotesPath', () => {
|
||||
beforeEach(() => {
|
||||
// Mock path.isAbsolute
|
||||
vi.mocked(path.isAbsolute).mockImplementation((p) => {
|
||||
return p.startsWith('/') || /^[A-Za-z]:/.test(p)
|
||||
})
|
||||
|
||||
// Mock path.resolve
|
||||
vi.mocked(path.resolve).mockImplementation((...args) => {
|
||||
const joined = args.join('/')
|
||||
return joined.startsWith('/') ? joined : `/${joined}`
|
||||
})
|
||||
|
||||
// Mock path.normalize
|
||||
vi.mocked(path.normalize).mockImplementation((p) => p.replace(/\/+/g, '/'))
|
||||
})
|
||||
|
||||
it('should expand tilde paths to home directory', () => {
|
||||
const result = expandNotesPath('~/Notes')
|
||||
expect(result).toBe('/mock/home/Notes')
|
||||
})
|
||||
|
||||
it('should expand relative paths using userData as base', () => {
|
||||
const result = expandNotesPath('./Notes')
|
||||
expect(result).toContain('userData')
|
||||
})
|
||||
|
||||
it('should return absolute paths unchanged', () => {
|
||||
const result = expandNotesPath('/absolute/path/Notes')
|
||||
expect(result).toBe('/absolute/path/Notes')
|
||||
})
|
||||
|
||||
it('should handle Windows absolute paths', () => {
|
||||
const result = expandNotesPath('C:\\Users\\Notes')
|
||||
expect(result).toBe('C:\\Users\\Notes')
|
||||
})
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const result = expandNotesPath('')
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should expand parent directory paths', () => {
|
||||
const result = expandNotesPath('../Notes')
|
||||
expect(result).toContain('userData')
|
||||
})
|
||||
|
||||
it('should use custom base path when provided', () => {
|
||||
const result = expandNotesPath('./Notes', '/custom/base')
|
||||
expect(result).toContain('/custom/base')
|
||||
})
|
||||
|
||||
it('should handle complex relative paths', () => {
|
||||
const result = expandNotesPath('../../Notes')
|
||||
expect(result).toContain('userData')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPathInside', () => {
|
||||
beforeEach(() => {
|
||||
// Mock path.resolve to simulate path resolution
|
||||
|
||||
@@ -38,6 +38,33 @@ export function untildify(pathWithTilde: string) {
|
||||
return pathWithTilde
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand relative paths to absolute paths.
|
||||
* Handles paths starting with ~, ., or ..
|
||||
* @param pathString - The path to expand
|
||||
* @param basePath - Optional base path for relative paths (defaults to userData directory)
|
||||
* @returns Absolute path
|
||||
*/
|
||||
export function expandNotesPath(pathString: string, basePath?: string): string {
|
||||
if (!pathString) {
|
||||
return pathString
|
||||
}
|
||||
|
||||
// First handle tilde expansion
|
||||
let expandedPath = untildify(pathString)
|
||||
|
||||
// If it's already an absolute path, return it
|
||||
if (path.isAbsolute(expandedPath)) {
|
||||
return path.normalize(expandedPath)
|
||||
}
|
||||
|
||||
// For relative paths, resolve against the base path (default to userData)
|
||||
const base = basePath || app.getPath('userData')
|
||||
expandedPath = path.resolve(base, expandedPath)
|
||||
|
||||
return path.normalize(expandedPath)
|
||||
}
|
||||
|
||||
export async function hasWritePermission(dir: string) {
|
||||
try {
|
||||
logger.info(`Checking write permission for ${dir}`)
|
||||
@@ -156,7 +183,12 @@ export function getNotesDir() {
|
||||
fs.mkdirSync(notesDir, { recursive: true })
|
||||
logger.info(`Notes directory created at: ${notesDir}`)
|
||||
}
|
||||
return notesDir
|
||||
// Return relative path for better portability across devices
|
||||
return './Data/Notes'
|
||||
}
|
||||
|
||||
export function getNotesDirAbsolute() {
|
||||
return path.join(app.getPath('userData'), 'Data', 'Notes')
|
||||
}
|
||||
|
||||
export function getConfigDir() {
|
||||
|
||||
@@ -2102,8 +2102,8 @@
|
||||
"select": "Select",
|
||||
"select_directory_failed": "Failed to select directory",
|
||||
"title": "Data Settings",
|
||||
"work_directory_description": "Work directory is where all note files are stored. Changing the work directory won't move existing files, please migrate files manually.",
|
||||
"work_directory_placeholder": "Select notes work directory"
|
||||
"work_directory_description": "Work directory is where all note files are stored. Supports relative paths like ~/Notes or ./Notes for multi-device sync. Changing the work directory won't move existing files, please migrate files manually.",
|
||||
"work_directory_placeholder": "Enter or select notes work directory (e.g., ~/Notes)"
|
||||
},
|
||||
"display": {
|
||||
"compress_content": "Content Compression",
|
||||
|
||||
@@ -2102,8 +2102,8 @@
|
||||
"select": "选择",
|
||||
"select_directory_failed": "选择目录失败",
|
||||
"title": "数据设置",
|
||||
"work_directory_description": "工作目录是存储所有笔记文件的位置。更改工作目录不会移动现有文件,请手动迁移文件。",
|
||||
"work_directory_placeholder": "选择笔记工作目录"
|
||||
"work_directory_description": "工作目录是存储所有笔记文件的位置。支持相对路径如 ~/笔记 或 ./笔记 以实现多设备同步。更改工作目录不会移动现有文件,请手动迁移文件。",
|
||||
"work_directory_placeholder": "输入或选择笔记工作目录(例如:~/笔记)"
|
||||
},
|
||||
"display": {
|
||||
"compress_content": "缩减栏宽",
|
||||
|
||||
@@ -2102,8 +2102,8 @@
|
||||
"select": "選擇",
|
||||
"select_directory_failed": "選擇目錄失敗",
|
||||
"title": "數據設置",
|
||||
"work_directory_description": "工作目錄是存儲所有筆記文件的位置。\n更改工作目錄不會移動現有文件,請手動遷移文件。",
|
||||
"work_directory_placeholder": "選擇筆記工作目錄"
|
||||
"work_directory_description": "工作目錄是存儲所有筆記文件的位置。支持相對路徑如 ~/筆記 或 ./筆記 以實現多設備同步。\n更改工作目錄不會移動現有文件,請手動遷移文件。",
|
||||
"work_directory_placeholder": "輸入或選擇筆記工作目錄(例如:~/筆記)"
|
||||
},
|
||||
"display": {
|
||||
"compress_content": "縮減欄寬",
|
||||
|
||||
@@ -105,7 +105,6 @@ const NotesSettings: FC = () => {
|
||||
value={tempPath}
|
||||
onChange={(e) => setTempPath(e.target.value)}
|
||||
placeholder={t('notes.settings.data.work_directory_placeholder')}
|
||||
readOnly
|
||||
/>
|
||||
<Button
|
||||
type="default"
|
||||
|
||||
Reference in New Issue
Block a user