Compare commits

...

5 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
4269a32cfb fix: Include notes directory in backup when configured outside Data folder
- Parse notes path from backup data during backup process
- If notes are outside {userData}/Data, backup them separately to Notes folder
- On restore, restore Notes folder to configured location
- Handles both default and custom notes paths correctly

This ensures users don't lose their notes when using custom paths.

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
2025-11-12 10:54:27 +00:00
copilot-swe-agent[bot]
6493f1853d refactor: Store default notes path as relative for portability
- Changed getNotesDir() to return './Data/Notes' instead of absolute path
- Added getNotesDirAbsolute() for cases requiring absolute paths
- Updated FileStorage to use getNotesDirAbsolute()
- Added tests for both functions

This allows the default notes path to be portable across devices
while externally selected paths remain absolute.

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
2025-11-12 09:20:09 +00:00
copilot-swe-agent[bot]
cbd4f418f6 test: Add tests for expandNotesPath function
- Added comprehensive test cases for tilde expansion
- Added tests for relative path expansion
- Added tests for absolute path handling
- Added tests for edge cases and custom base paths

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
2025-11-12 09:03:09 +00:00
copilot-swe-agent[bot]
7d6ffe472c feat: Add support for relative paths in notes working directory
- Added expandNotesPath utility function to handle ~, ., and .. paths
- Updated validateNotesDirectory to expand relative paths
- Updated getDirectoryStructure to expand paths before scanning
- Updated startFileWatcher to expand paths before watching
- Made notes path input field editable in settings UI
- Updated locale files to explain relative path support

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
2025-11-12 09:01:28 +00:00
copilot-swe-agent[bot]
f16b63bd69 Initial plan 2025-11-12 08:53:30 +00:00
8 changed files with 205 additions and 17 deletions

View File

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

View File

@@ -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}`)

View File

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

View File

@@ -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() {

View File

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

View File

@@ -2102,8 +2102,8 @@
"select": "选择",
"select_directory_failed": "选择目录失败",
"title": "数据设置",
"work_directory_description": "工作目录是存储所有笔记文件的位置。更改工作目录不会移动现有文件,请手动迁移文件。",
"work_directory_placeholder": "选择笔记工作目录"
"work_directory_description": "工作目录是存储所有笔记文件的位置。支持相对路径如 ~/笔记 或 ./笔记 以实现多设备同步。更改工作目录不会移动现有文件,请手动迁移文件。",
"work_directory_placeholder": "输入或选择笔记工作目录(例如:~/笔记)"
},
"display": {
"compress_content": "缩减栏宽",

View File

@@ -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": "縮減欄寬",

View File

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