cherry-studio/src/main/services/FileStorage.ts
beyondkmp bc9eeb9f30
feat: add fuzzy search for file list with relevance scoring (#12131)
* feat: add fuzzy search for file list with relevance scoring

- Add fuzzy option to DirectoryListOptions (default: true)
- Implement isFuzzyMatch for subsequence matching
- Add getFuzzyMatchScore for relevance-based sorting
- Remove searchByContent method (content-based search)
- Increase maxDepth to 10 and maxEntries to 20

* perf: optimize fuzzy search with ripgrep glob pre-filtering

- Add queryToGlobPattern to convert query to glob pattern
- Use ripgrep --iglob for initial filtering instead of loading all files
- Reduces memory footprint and improves performance for large directories

* feat: add greedy substring match fallback for fuzzy search

- Add isGreedySubstringMatch for flexible matching
- Fallback to greedy match when glob pre-filter returns empty
- Allows 'updatercontroller' to match 'updateController.ts'

* fix: improve greedy substring match algorithm

- Search from longest to shortest substring for better matching
- Fix issue where 'updatercontroller' couldn't match 'updateController'

* docs: add fuzzy search documentation (en/zh)

* refactor: extract MAX_ENTRIES_PER_SEARCH constant

* refactor: use logarithmic scaling for path length penalty

- Replace linear penalty (0.8 * length) with logarithmic scaling
- Prevents long paths from dominating the score
- Add PATH_LENGTH_PENALTY_FACTOR constant with explanation

* refactor: extract scoring constants with documentation

- Add named constants for scoring factors (SCORE_SEGMENT_MATCH, etc.)
- Update en/zh documentation with scoring strategy explanation

* refactor: move PATH_LENGTH_PENALTY_FACTOR to class level constant

* refactor: extract buildRipgrepBaseArgs helper method

- Reduce code duplication for ripgrep argument building
- Consolidate directory exclusion patterns and depth handling

* refactor: rename MAX_ENTRIES_PER_SEARCH to MAX_SEARCH_RESULTS

* fix: escape ! character in glob pattern for negation support

* fix: avoid duplicate scoring for filename starts and contains

* docs: clarify fuzzy search filtering and scoring strategies

* fix: limit word boundary bonus to single match

* fix: add dedicated scoring for greedy substring match

- Add getGreedyMatchScore function that rewards fewer fragments and tighter matches
- Add isFuzzyMatch validation before scoring in fuzzy glob path
- Use greedy scoring for fallback path to properly rank longest matches first

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2025-12-30 19:42:56 +08:00

2030 lines
64 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { loggerService } from '@logger'
import {
checkName,
getFilesDir,
getFileType as getFileTypeByExt,
getName,
getNotesDir,
getTempDir,
readTextFileWithAutoEncoding,
scanDir
} from '@main/utils/file'
import { documentExts, imageExts, KB, MB } from '@shared/config/constant'
import type { FileMetadata, NotesTreeNode } from '@types'
import { FileTypes } from '@types'
import chardet from 'chardet'
import type { FSWatcher } from 'chokidar'
import chokidar from 'chokidar'
import * as crypto from 'crypto'
import type { OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from 'electron'
import { app, dialog, net, shell } from 'electron'
import * as fs from 'fs'
import { writeFileSync } from 'fs'
import { readFile } from 'fs/promises'
import { isBinaryFile } from 'isbinaryfile'
import officeParser from 'officeparser'
import * as path from 'path'
import { PDFDocument } from 'pdf-lib'
import { chdir } from 'process'
import { v4 as uuidv4 } from 'uuid'
import WordExtractor from 'word-extractor'
const logger = loggerService.withContext('FileStorage')
// Get ripgrep binary path
const getRipgrepBinaryPath = (): string | null => {
try {
const arch = process.arch === 'arm64' ? 'arm64' : 'x64'
const platform = process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'win32' : 'linux'
let ripgrepBinaryPath = path.join(
__dirname,
'../../node_modules/@anthropic-ai/claude-agent-sdk/vendor/ripgrep',
`${arch}-${platform}`,
process.platform === 'win32' ? 'rg.exe' : 'rg'
)
if (app.isPackaged) {
ripgrepBinaryPath = ripgrepBinaryPath.replace(/\.asar([\\/])/, '.asar.unpacked$1')
}
if (fs.existsSync(ripgrepBinaryPath)) {
return ripgrepBinaryPath
}
return null
} catch (error) {
logger.error('Failed to locate ripgrep binary:', error as Error)
return null
}
}
/**
* Execute ripgrep with captured output
*/
function executeRipgrep(args: string[]): Promise<{ exitCode: number; output: string }> {
return new Promise((resolve, reject) => {
const ripgrepBinaryPath = getRipgrepBinaryPath()
if (!ripgrepBinaryPath) {
reject(new Error('Ripgrep binary not available'))
return
}
const { spawn } = require('child_process')
const child = spawn(ripgrepBinaryPath, ['--no-config', '--ignore-case', ...args], {
stdio: ['pipe', 'pipe', 'pipe']
})
let output = ''
let errorOutput = ''
child.stdout.on('data', (data: Buffer) => {
output += data.toString()
})
child.stderr.on('data', (data: Buffer) => {
errorOutput += data.toString()
})
child.on('close', (code: number) => {
resolve({
exitCode: code || 0,
output: output || errorOutput
})
})
child.on('error', (error: Error) => {
reject(error)
})
})
}
interface FileWatcherConfig {
watchExtensions?: string[]
ignoredPatterns?: (string | RegExp)[]
debounceMs?: number
maxDepth?: number
usePolling?: boolean
retryOnError?: boolean
retryDelayMs?: number
stabilityThreshold?: number
eventChannel?: string
}
const DEFAULT_WATCHER_CONFIG: Required<FileWatcherConfig> = {
watchExtensions: ['.md', '.markdown', '.txt'],
ignoredPatterns: [/(^|[/\\])\../, '**/node_modules/**', '**/.git/**', '**/*.tmp', '**/*.temp', '**/.DS_Store'],
debounceMs: 1000,
maxDepth: 10,
usePolling: false,
retryOnError: true,
retryDelayMs: 5000,
stabilityThreshold: 500,
eventChannel: 'file-change'
}
interface DirectoryListOptions {
recursive?: boolean
maxDepth?: number
includeHidden?: boolean
includeFiles?: boolean
includeDirectories?: boolean
maxEntries?: number
searchPattern?: string
fuzzy?: boolean
}
const DEFAULT_DIRECTORY_LIST_OPTIONS: Required<DirectoryListOptions> = {
recursive: true,
maxDepth: 10,
includeHidden: false,
includeFiles: true,
includeDirectories: true,
maxEntries: 20,
searchPattern: '.',
fuzzy: true
}
class FileStorage {
private storageDir = getFilesDir()
private notesDir = getNotesDir()
private tempDir = getTempDir()
private watcher?: FSWatcher
private watcherSender?: Electron.WebContents
private currentWatchPath?: string
private debounceTimer?: NodeJS.Timeout
private watcherConfig: Required<FileWatcherConfig> = DEFAULT_WATCHER_CONFIG
private isPaused = false
constructor() {
this.initStorageDir()
}
private initStorageDir = (): void => {
try {
if (!fs.existsSync(this.storageDir)) {
fs.mkdirSync(this.storageDir, { recursive: true })
}
if (!fs.existsSync(this.notesDir)) {
fs.mkdirSync(this.notesDir, { recursive: true })
}
if (!fs.existsSync(this.tempDir)) {
fs.mkdirSync(this.tempDir, { recursive: true })
}
} catch (error) {
logger.error('Failed to initialize storage directories:', error as Error)
throw error
}
}
// @TraceProperty({ spanName: 'getFileHash', tag: 'FileStorage' })
private getFileHash = async (filePath: string): Promise<string> => {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('md5')
const stream = fs.createReadStream(filePath)
stream.on('data', (data) => hash.update(data))
stream.on('end', () => resolve(hash.digest('hex')))
stream.on('error', reject)
})
}
private findDuplicateFile = async (filePath: string): Promise<FileMetadata | null> => {
const stats = fs.statSync(filePath)
logger.debug(`stats: ${stats}, filePath: ${filePath}`)
const fileSize = stats.size
const files = await fs.promises.readdir(this.storageDir)
for (const file of files) {
const storedFilePath = path.join(this.storageDir, file)
const storedStats = fs.statSync(storedFilePath)
if (storedStats.size === fileSize) {
const [originalHash, storedHash] = await Promise.all([
this.getFileHash(filePath),
this.getFileHash(storedFilePath)
])
if (originalHash === storedHash) {
const ext = path.extname(file)
const id = path.basename(file, ext)
const type = await this.getFileType(filePath)
return {
id,
origin_name: file,
name: file + ext,
path: storedFilePath,
created_at: storedStats.birthtime.toISOString(),
size: storedStats.size,
ext,
type,
count: 2
}
}
}
}
return null
}
public getFileType = async (filePath: string): Promise<FileTypes> => {
const ext = path.extname(filePath)
const fileType = getFileTypeByExt(ext)
return fileType === FileTypes.OTHER && (await this._isTextFile(filePath)) ? FileTypes.TEXT : fileType
}
public selectFile = async (
_: Electron.IpcMainInvokeEvent,
options?: OpenDialogOptions
): Promise<FileMetadata[] | null> => {
const defaultOptions: OpenDialogOptions = {
properties: ['openFile']
}
const dialogOptions = { ...defaultOptions, ...options }
const result = await dialog.showOpenDialog(dialogOptions)
if (result.canceled || result.filePaths.length === 0) {
return null
}
const fileMetadataPromises = result.filePaths.map(async (filePath) => {
const stats = fs.statSync(filePath)
const ext = path.extname(filePath)
const fileType = await this.getFileType(filePath)
return {
id: uuidv4(),
origin_name: path.basename(filePath),
name: path.basename(filePath),
path: filePath,
created_at: stats.birthtime.toISOString(),
size: stats.size,
ext: ext,
type: fileType,
count: 1
}
})
return Promise.all(fileMetadataPromises)
}
private async compressImage(sourcePath: string, destPath: string): Promise<void> {
try {
const stats = fs.statSync(sourcePath)
const fileSizeInMB = stats.size / MB
// 如果图片大于1MB才进行压缩
if (fileSizeInMB > 1) {
try {
await fs.promises.copyFile(sourcePath, destPath)
logger.debug(`Image compressed successfully: ${sourcePath}`)
} catch (jimpError) {
logger.error('Image compression failed:', jimpError as Error)
await fs.promises.copyFile(sourcePath, destPath)
}
} else {
// 小图片直接复制
await fs.promises.copyFile(sourcePath, destPath)
}
} catch (error) {
logger.error('Image handling failed:', error as Error)
// 错误情况下直接复制原文件
await fs.promises.copyFile(sourcePath, destPath)
}
}
public uploadFile = async (_: Electron.IpcMainInvokeEvent, file: FileMetadata): Promise<FileMetadata> => {
const filePath = file.path
const duplicateFile = await this.findDuplicateFile(filePath)
if (duplicateFile) {
return duplicateFile
}
const uuid = uuidv4()
const origin_name = path.basename(file.path)
const ext = path.extname(origin_name).toLowerCase()
const destPath = path.join(this.storageDir, uuid + ext)
logger.info(`[FileStorage] Uploading file: ${filePath}`)
// 根据文件类型选择处理方式
if (imageExts.includes(ext)) {
await this.compressImage(filePath, destPath)
} else {
await fs.promises.copyFile(filePath, destPath)
}
const stats = await fs.promises.stat(destPath)
const fileType = await this.getFileType(destPath)
const fileMetadata: FileMetadata = {
id: uuid,
origin_name,
name: uuid + ext,
path: destPath,
created_at: stats.birthtime.toISOString(),
size: stats.size,
ext: ext,
type: fileType,
count: 1
}
logger.debug(`File uploaded: ${fileMetadata}`)
return fileMetadata
}
public getFile = async (_: Electron.IpcMainInvokeEvent, filePath: string): Promise<FileMetadata | null> => {
if (!fs.existsSync(filePath)) {
return null
}
const stats = fs.statSync(filePath)
const fileType = await this.getFileType(filePath)
return {
id: uuidv4(),
origin_name: path.basename(filePath),
name: path.basename(filePath),
path: filePath,
created_at: stats.birthtime.toISOString(),
size: stats.size,
ext: path.extname(filePath),
type: fileType,
count: 1
}
}
// @TraceProperty({ spanName: 'deleteFile', tag: 'FileStorage' })
public deleteFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<void> => {
if (!fs.existsSync(path.join(this.storageDir, id))) {
return
}
await fs.promises.unlink(path.join(this.storageDir, id))
}
public deleteDir = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<void> => {
if (!fs.existsSync(path.join(this.storageDir, id))) {
return
}
await fs.promises.rm(path.join(this.storageDir, id), { recursive: true })
}
public deleteExternalFile = async (_: Electron.IpcMainInvokeEvent, filePath: string): Promise<void> => {
try {
if (!fs.existsSync(filePath)) {
return
}
await fs.promises.rm(filePath, { force: true })
logger.debug(`External file deleted successfully: ${filePath}`)
} catch (error) {
logger.error('Failed to delete external file:', error as Error)
throw error
}
}
public deleteExternalDir = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise<void> => {
try {
if (!fs.existsSync(dirPath)) {
return
}
await fs.promises.rm(dirPath, { recursive: true, force: true })
logger.debug(`External directory deleted successfully: ${dirPath}`)
} catch (error) {
logger.error('Failed to delete external directory:', error as Error)
throw error
}
}
public moveFile = async (_: Electron.IpcMainInvokeEvent, filePath: string, newPath: string): Promise<void> => {
try {
if (!fs.existsSync(filePath)) {
throw new Error(`Source file does not exist: ${filePath}`)
}
// 确保目标目录存在
const destDir = path.dirname(newPath)
if (!fs.existsSync(destDir)) {
await fs.promises.mkdir(destDir, { recursive: true })
}
// 移动文件
await fs.promises.rename(filePath, newPath)
logger.debug(`File moved successfully: ${filePath} to ${newPath}`)
} catch (error) {
logger.error('Move file failed:', error as Error)
throw error
}
}
public moveDir = async (_: Electron.IpcMainInvokeEvent, dirPath: string, newDirPath: string): Promise<void> => {
try {
if (!fs.existsSync(dirPath)) {
throw new Error(`Source directory does not exist: ${dirPath}`)
}
// 确保目标父目录存在
const parentDir = path.dirname(newDirPath)
if (!fs.existsSync(parentDir)) {
await fs.promises.mkdir(parentDir, { recursive: true })
}
// 移动目录
await fs.promises.rename(dirPath, newDirPath)
logger.debug(`Directory moved successfully: ${dirPath} to ${newDirPath}`)
} catch (error) {
logger.error('Move directory failed:', error as Error)
throw error
}
}
public renameFile = async (_: Electron.IpcMainInvokeEvent, filePath: string, newName: string): Promise<void> => {
try {
if (!fs.existsSync(filePath)) {
throw new Error(`Source file does not exist: ${filePath}`)
}
const dirPath = path.dirname(filePath)
const newFilePath = path.join(dirPath, newName + '.md')
// 如果目标文件已存在,抛出错误
if (fs.existsSync(newFilePath)) {
throw new Error(`Target file already exists: ${newFilePath}`)
}
// 重命名文件
await fs.promises.rename(filePath, newFilePath)
logger.debug(`File renamed successfully: ${filePath} to ${newFilePath}`)
} catch (error) {
logger.error('Rename file failed:', error as Error)
throw error
}
}
public renameDir = async (_: Electron.IpcMainInvokeEvent, dirPath: string, newName: string): Promise<void> => {
try {
if (!fs.existsSync(dirPath)) {
throw new Error(`Source directory does not exist: ${dirPath}`)
}
const parentDir = path.dirname(dirPath)
const newDirPath = path.join(parentDir, newName)
// 如果目标目录已存在,抛出错误
if (fs.existsSync(newDirPath)) {
throw new Error(`Target directory already exists: ${newDirPath}`)
}
// 重命名目录
await fs.promises.rename(dirPath, newDirPath)
logger.debug(`Directory renamed successfully: ${dirPath} to ${newDirPath}`)
} catch (error) {
logger.error('Rename directory failed:', error as Error)
throw error
}
}
/**
* Core file reading logic that handles both documents and text files.
*
* @private
* @param filePath - Full path to the file
* @param detectEncoding - Whether to auto-detect text file encoding
* @returns Promise resolving to the extracted text content
* @throws Error if file reading fails
*/
private async readFileCore(filePath: string, detectEncoding: boolean = false): Promise<string> {
const fileExtension = path.extname(filePath)
if (documentExts.includes(fileExtension)) {
const originalCwd = process.cwd()
try {
chdir(this.tempDir)
if (fileExtension === '.doc') {
const extractor = new WordExtractor()
const extracted = await extractor.extract(filePath)
chdir(originalCwd)
return extracted.getBody()
}
const data = await officeParser.parseOfficeAsync(filePath)
chdir(originalCwd)
return data
} catch (error) {
chdir(originalCwd)
logger.error('Failed to read document file:', error as Error)
throw error
}
}
try {
if (detectEncoding) {
return readTextFileWithAutoEncoding(filePath)
} else {
return fs.readFileSync(filePath, 'utf-8')
}
} catch (error) {
logger.error('Failed to read text file:', error as Error)
throw new Error(`Failed to read file: ${filePath}.`)
}
}
/**
* Reads and extracts content from a stored file.
*
* Supports multiple file formats including:
* - Complex documents: .pdf, .doc, .docx, .pptx, .xlsx, .odt, .odp, .ods
* - Text files: .txt, .md, .json, .csv, etc.
* - Code files: .js, .ts, .py, .java, etc.
*
* For document formats, extracts text content using specialized parsers:
* - .doc files: Uses word-extractor library
* - Other Office formats: Uses officeparser library
*
* For text files, can optionally detect encoding automatically.
*
* @param _ - Electron IPC invoke event (unused)
* @param id - File identifier with extension (e.g., "uuid.docx")
* @param detectEncoding - Whether to auto-detect text file encoding (default: false)
* @returns Promise resolving to the extracted text content of the file
* @throws Error if file reading fails or file is not found
*
* @example
* // Read a DOCX file
* const content = await readFile(event, "document.docx");
*
* @example
* // Read a text file with encoding detection
* const content = await readFile(event, "text.txt", true);
*
* @example
* // Read a PDF file
* const content = await readFile(event, "manual.pdf");
*/
public readFile = async (
_: Electron.IpcMainInvokeEvent,
id: string,
detectEncoding: boolean = false
): Promise<string> => {
const filePath = path.join(this.storageDir, id)
return this.readFileCore(filePath, detectEncoding)
}
/**
* Reads and extracts content from an external file path.
*
* Similar to readFile, but operates on external file paths instead of stored files.
* Supports the same file formats including complex documents and text files.
*
* @param _ - Electron IPC invoke event (unused)
* @param filePath - Absolute path to the external file
* @param detectEncoding - Whether to auto-detect text file encoding (default: false)
* @returns Promise resolving to the extracted text content of the file
* @throws Error if file does not exist or reading fails
*
* @example
* // Read an external DOCX file
* const content = await readExternalFile(event, "/path/to/document.docx");
*
* @example
* // Read an external text file with encoding detection
* const content = await readExternalFile(event, "/path/to/text.txt", true);
*/
public readExternalFile = async (
_: Electron.IpcMainInvokeEvent,
filePath: string,
detectEncoding: boolean = false
): Promise<string> => {
if (!fs.existsSync(filePath)) {
throw new Error(`File does not exist: ${filePath}`)
}
return this.readFileCore(filePath, detectEncoding)
}
public createTempFile = async (_: Electron.IpcMainInvokeEvent, fileName: string): Promise<string> => {
if (!fs.existsSync(this.tempDir)) {
fs.mkdirSync(this.tempDir, { recursive: true })
}
return path.join(this.tempDir, `temp_file_${uuidv4()}_${fileName}`)
}
public writeFile = async (
_: Electron.IpcMainInvokeEvent,
filePath: string,
data: Uint8Array | string
): Promise<void> => {
await fs.promises.writeFile(filePath, data)
}
public fileNameGuard = async (
_: Electron.IpcMainInvokeEvent,
dirPath: string,
fileName: string,
isFile: boolean
): Promise<{ safeName: string; exists: boolean }> => {
const safeName = checkName(fileName)
const finalName = getName(dirPath, safeName, isFile)
const fullPath = path.join(dirPath, finalName + (isFile ? '.md' : ''))
const exists = fs.existsSync(fullPath)
logger.debug(`File name guard: ${fileName} -> ${finalName}, exists: ${exists}`)
return { safeName: finalName, exists }
}
public mkdir = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise<string> => {
try {
logger.debug(`Attempting to create directory: ${dirPath}`)
await fs.promises.mkdir(dirPath, { recursive: true })
return dirPath
} catch (error) {
logger.error('Failed to create directory:', error as Error)
throw new Error(`Failed to create directory: ${dirPath}. Error: ${(error as Error).message}`)
}
}
public base64Image = async (
_: Electron.IpcMainInvokeEvent,
id: string
): Promise<{ mime: string; base64: string; data: string }> => {
const filePath = path.join(this.storageDir, id)
const data = await fs.promises.readFile(filePath)
const base64 = data.toString('base64')
const ext = path.extname(filePath).slice(1) == 'jpg' ? 'jpeg' : path.extname(filePath).slice(1)
const mime = `image/${ext}`
return {
mime,
base64,
data: `data:${mime};base64,${base64}`
}
}
public saveBase64Image = async (_: Electron.IpcMainInvokeEvent, base64Data: string): Promise<FileMetadata> => {
try {
if (!base64Data) {
throw new Error('Base64 data is required')
}
// 移除 base64 头部信息(如果存在)
const base64String = base64Data.replace(/^data:.*;base64,/, '')
const buffer = Buffer.from(base64String, 'base64')
const uuid = uuidv4()
const ext = '.png'
const destPath = path.join(this.storageDir, uuid + ext)
logger.debug('Saving base64 image:', {
storageDir: this.storageDir,
destPath,
bufferSize: buffer.length
})
// 确保目录存在
if (!fs.existsSync(this.storageDir)) {
fs.mkdirSync(this.storageDir, { recursive: true })
}
await fs.promises.writeFile(destPath, buffer)
return {
id: uuid,
origin_name: uuid + ext,
name: uuid + ext,
path: destPath,
created_at: new Date().toISOString(),
size: buffer.length,
ext: ext.slice(1),
type: getFileTypeByExt(ext),
count: 1
}
} catch (error) {
logger.error('Failed to save base64 image:', error as Error)
throw error
}
}
public savePastedImage = async (
_: Electron.IpcMainInvokeEvent,
imageData: Uint8Array | Buffer,
extension?: string
): Promise<FileMetadata> => {
try {
const uuid = uuidv4()
const ext = extension || '.png'
const destPath = path.join(this.storageDir, uuid + ext)
logger.debug('Saving pasted image:', {
storageDir: this.storageDir,
destPath,
bufferSize: imageData.length
})
// 确保目录存在
if (!fs.existsSync(this.storageDir)) {
fs.mkdirSync(this.storageDir, { recursive: true })
}
// 确保 imageData 是 Buffer
const buffer = Buffer.isBuffer(imageData) ? imageData : Buffer.from(imageData)
// 如果图片大于1MB进行压缩处理
if (buffer.length > MB) {
await this.compressImageBuffer(buffer, destPath, ext)
} else {
await fs.promises.writeFile(destPath, buffer)
}
const stats = await fs.promises.stat(destPath)
return {
id: uuid,
origin_name: `pasted_image_${uuid}${ext}`,
name: uuid + ext,
path: destPath,
created_at: new Date().toISOString(),
size: stats.size,
ext: ext.slice(1),
type: getFileTypeByExt(ext),
count: 1
}
} catch (error) {
logger.error('Failed to save pasted image:', error as Error)
throw error
}
}
private async compressImageBuffer(imageBuffer: Buffer, destPath: string, ext: string): Promise<void> {
try {
// 创建临时文件
const tempPath = path.join(this.tempDir, `temp_${uuidv4()}${ext}`)
await fs.promises.writeFile(tempPath, imageBuffer)
// 使用现有的压缩方法
await this.compressImage(tempPath, destPath)
// 清理临时文件
try {
await fs.promises.unlink(tempPath)
} catch (error) {
logger.warn('Failed to cleanup temp file:', error as Error)
}
} catch (error) {
logger.error('Image buffer compression failed, saving original:', error as Error)
// 压缩失败时保存原始文件
await fs.promises.writeFile(destPath, imageBuffer)
}
}
public base64File = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<{ data: string; mime: string }> => {
const filePath = path.join(this.storageDir, id)
const buffer = await fs.promises.readFile(filePath)
const base64 = buffer.toString('base64')
const mime = `application/${path.extname(filePath).slice(1)}`
return { data: base64, mime }
}
public pdfPageCount = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<number> => {
const filePath = path.join(this.storageDir, id)
const buffer = await fs.promises.readFile(filePath)
const pdfDoc = await PDFDocument.load(buffer)
return pdfDoc.getPageCount()
}
public binaryImage = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<{ data: Buffer; mime: string }> => {
const filePath = path.join(this.storageDir, id)
const data = await fs.promises.readFile(filePath)
const mime = `image/${path.extname(filePath).slice(1)}`
return { data, mime }
}
public clear = async (): Promise<void> => {
await fs.promises.rm(this.storageDir, { recursive: true })
this.initStorageDir()
}
public clearTemp = async (): Promise<void> => {
await fs.promises.rm(this.tempDir, { recursive: true })
await fs.promises.mkdir(this.tempDir, { recursive: true })
}
public open = async (
_: Electron.IpcMainInvokeEvent,
options: OpenDialogOptions
): Promise<{ fileName: string; filePath: string; content?: Buffer; size: number } | null> => {
try {
const result: OpenDialogReturnValue = await dialog.showOpenDialog({
title: '打开文件',
properties: ['openFile'],
filters: [{ name: '所有文件', extensions: ['*'] }],
...options
})
if (!result.canceled && result.filePaths.length > 0) {
const filePath = result.filePaths[0]
const fileName = filePath.split('/').pop() || ''
const stats = await fs.promises.stat(filePath)
// If the file is less than 2GB, read the content
if (stats.size < 2 * 1024 * 1024 * 1024) {
const content = await readFile(filePath)
return { fileName, filePath, content, size: stats.size }
}
// For large files, only return file information, do not read content
return { fileName, filePath, size: stats.size }
}
return null
} catch (err) {
logger.error('[IPC - Error] An error occurred opening the file:', err as Error)
return null
}
}
public openPath = async (_: Electron.IpcMainInvokeEvent, path: string): Promise<void> => {
const resolved = await shell.openPath(path)
if (resolved !== '') {
throw new Error(resolved)
}
}
/**
* 通过相对路径打开文件,跨设备时使用
* @param _
* @param file
*/
public openFileWithRelativePath = async (_: Electron.IpcMainInvokeEvent, file: FileMetadata): Promise<void> => {
const filePath = path.join(this.storageDir, file.name)
if (fs.existsSync(filePath)) {
shell.openPath(filePath).catch((err) => logger.error('[IPC - Error] Failed to open file:', err))
} else {
logger.warn(`[IPC - Warning] File does not exist: ${filePath}`)
}
}
public getDirectoryStructure = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise<NotesTreeNode[]> => {
try {
return await scanDir(dirPath)
} catch (error) {
logger.error('Failed to get directory structure:', error as Error)
throw error
}
}
public listDirectory = async (
_: Electron.IpcMainInvokeEvent,
dirPath: string,
options?: DirectoryListOptions
): Promise<string[]> => {
const mergedOptions: Required<DirectoryListOptions> = {
...DEFAULT_DIRECTORY_LIST_OPTIONS,
...options
}
const resolvedPath = path.resolve(dirPath)
const stat = await fs.promises.stat(resolvedPath).catch((error) => {
logger.error(`[IPC - Error] Failed to access directory: ${resolvedPath}`, error as Error)
throw error
})
if (!stat.isDirectory()) {
throw new Error(`Path is not a directory: ${resolvedPath}`)
}
// Use ripgrep for file listing with relevance-based sorting
if (!getRipgrepBinaryPath()) {
throw new Error('Ripgrep binary not available')
}
return await this.listDirectoryWithRipgrep(resolvedPath, mergedOptions)
}
/**
* Search directories by name pattern
*/
private async searchDirectories(
resolvedPath: string,
options: Required<DirectoryListOptions>,
currentDepth: number = 0
): Promise<string[]> {
if (!options.includeDirectories) return []
if (!options.recursive && currentDepth > 0) return []
if (options.maxDepth > 0 && currentDepth >= options.maxDepth) return []
const directories: string[] = []
const excludedDirs = new Set([
'node_modules',
'.git',
'.idea',
'.vscode',
'dist',
'build',
'.next',
'.nuxt',
'coverage',
'.cache'
])
try {
const entries = await fs.promises.readdir(resolvedPath, { withFileTypes: true })
const searchPatternLower = options.searchPattern.toLowerCase()
for (const entry of entries) {
if (!entry.isDirectory()) continue
// Skip hidden directories unless explicitly included
if (!options.includeHidden && entry.name.startsWith('.')) continue
// Skip excluded directories
if (excludedDirs.has(entry.name)) continue
const fullPath = path.join(resolvedPath, entry.name).replace(/\\/g, '/')
// Check if directory name matches search pattern
if (options.searchPattern === '.' || entry.name.toLowerCase().includes(searchPatternLower)) {
directories.push(fullPath)
}
// Recursively search subdirectories
if (options.recursive && currentDepth < options.maxDepth) {
const subDirs = await this.searchDirectories(fullPath, options, currentDepth + 1)
directories.push(...subDirs)
}
}
} catch (error) {
logger.warn(`Failed to search directories in: ${resolvedPath}`, error as Error)
}
return directories
}
/**
* Search files by filename pattern
*/
private async searchByFilename(resolvedPath: string, options: Required<DirectoryListOptions>): Promise<string[]> {
const files: string[] = []
const directories: string[] = []
// Search for files using ripgrep
if (options.includeFiles) {
const args: string[] = ['--files']
// Handle hidden files
if (!options.includeHidden) {
args.push('--glob', '!.*')
}
// Use --iglob to let ripgrep filter filenames (case-insensitive)
if (options.searchPattern && options.searchPattern !== '.') {
args.push('--iglob', `*${options.searchPattern}*`)
}
// Exclude common hidden directories and large directories
args.push('-g', '!**/node_modules/**')
args.push('-g', '!**/.git/**')
args.push('-g', '!**/.idea/**')
args.push('-g', '!**/.vscode/**')
args.push('-g', '!**/.DS_Store')
args.push('-g', '!**/dist/**')
args.push('-g', '!**/build/**')
args.push('-g', '!**/.next/**')
args.push('-g', '!**/.nuxt/**')
args.push('-g', '!**/coverage/**')
args.push('-g', '!**/.cache/**')
// Handle max depth
if (!options.recursive) {
args.push('--max-depth', '1')
} else if (options.maxDepth > 0) {
args.push('--max-depth', options.maxDepth.toString())
}
// Add the directory path
args.push(resolvedPath)
const { exitCode, output } = await executeRipgrep(args)
// Exit code 0 means files found, 1 means no files found (still success), 2+ means error
if (exitCode >= 2) {
throw new Error(`Ripgrep failed with exit code ${exitCode}: ${output}`)
}
// Parse ripgrep output (no need to filter by filename - ripgrep already did it)
files.push(
...output
.split('\n')
.filter((line) => line.trim())
.map((line) => line.replace(/\\/g, '/'))
)
}
// Search for directories
if (options.includeDirectories) {
directories.push(...(await this.searchDirectories(resolvedPath, options)))
}
// Combine and sort: directories first (alphabetically), then files (alphabetically)
const sortedDirectories = directories.sort((a, b) => {
const aName = path.basename(a)
const bName = path.basename(b)
return aName.localeCompare(bName)
})
const sortedFiles = files.sort((a, b) => {
const aName = path.basename(a)
const bName = path.basename(b)
return aName.localeCompare(bName)
})
return [...sortedDirectories, ...sortedFiles].slice(0, options.maxEntries)
}
/**
* Fuzzy match: checks if all characters in query appear in text in order (case-insensitive)
* Example: "updater" matches "packages/update/src/node/updateController.ts"
*/
private isFuzzyMatch(text: string, query: string): boolean {
let i = 0 // text index
let j = 0 // query index
const textLower = text.toLowerCase()
const queryLower = query.toLowerCase()
while (i < textLower.length && j < queryLower.length) {
if (textLower[i] === queryLower[j]) {
j++
}
i++
}
return j === queryLower.length
}
/**
* Scoring constants for fuzzy match relevance ranking
* Higher values = higher priority in search results
*/
private static readonly SCORE_SEGMENT_MATCH = 60 // Per path segment that matches query
private static readonly SCORE_FILENAME_CONTAINS = 80 // Filename contains exact query substring
private static readonly SCORE_FILENAME_STARTS = 100 // Filename starts with query (highest priority)
private static readonly SCORE_CONSECUTIVE_CHAR = 15 // Per consecutive character match
private static readonly SCORE_WORD_BOUNDARY = 20 // Query matches start of a word
private static readonly PATH_LENGTH_PENALTY_FACTOR = 4 // Logarithmic penalty multiplier for longer paths
/**
* Calculate fuzzy match score (higher is better)
* Scoring factors:
* - Consecutive character matches (bonus)
* - Match at word boundaries (bonus)
* - Shorter path length (bonus)
* - Match in filename vs directory (bonus)
*/
private getFuzzyMatchScore(filePath: string, query: string): number {
const pathLower = filePath.toLowerCase()
const queryLower = query.toLowerCase()
const fileName = filePath.split('/').pop() || ''
const fileNameLower = fileName.toLowerCase()
let score = 0
// Count how many times query-related words appear in path segments
const pathSegments = pathLower.split(/[/\\]/)
let segmentMatchCount = 0
for (const segment of pathSegments) {
if (this.isFuzzyMatch(segment, queryLower)) {
segmentMatchCount++
}
}
score += segmentMatchCount * FileStorage.SCORE_SEGMENT_MATCH
// Bonus for filename starting with query (stronger than generic "contains")
if (fileNameLower.startsWith(queryLower)) {
score += FileStorage.SCORE_FILENAME_STARTS
} else if (fileNameLower.includes(queryLower)) {
// Bonus for exact substring match in filename (e.g., "updater" in "RCUpdater.js")
score += FileStorage.SCORE_FILENAME_CONTAINS
}
// Calculate consecutive match bonus
let i = 0
let j = 0
let consecutiveCount = 0
let maxConsecutive = 0
while (i < pathLower.length && j < queryLower.length) {
if (pathLower[i] === queryLower[j]) {
consecutiveCount++
maxConsecutive = Math.max(maxConsecutive, consecutiveCount)
j++
} else {
consecutiveCount = 0
}
i++
}
score += maxConsecutive * FileStorage.SCORE_CONSECUTIVE_CHAR
// Bonus for word boundary matches (e.g., "upd" matches start of "update")
// Only count once to avoid inflating scores for paths with repeated patterns
const boundaryPrefix = queryLower.slice(0, Math.min(3, queryLower.length))
const words = pathLower.split(/[/\\._-]/)
for (const word of words) {
if (word.startsWith(boundaryPrefix)) {
score += FileStorage.SCORE_WORD_BOUNDARY
break
}
}
// Penalty for longer paths (prefer shorter, more specific matches)
// Use logarithmic scaling to prevent long paths from dominating the score
// A 50-char path gets ~-16 penalty, 100-char gets ~-18, 200-char gets ~-21
score -= Math.log(filePath.length + 1) * FileStorage.PATH_LENGTH_PENALTY_FACTOR
return score
}
/**
* Convert query to glob pattern for ripgrep pre-filtering
* e.g., "updater" -> "*u*p*d*a*t*e*r*"
*/
private queryToGlobPattern(query: string): string {
// Escape special glob characters (including ! for negation)
const escaped = query.replace(/[[\]{}()*+?.,\\^$|#!]/g, '\\$&')
// Convert to fuzzy glob: each char separated by *
return '*' + escaped.split('').join('*') + '*'
}
/**
* Greedy substring match: check if all characters in query can be matched
* by finding consecutive substrings in text (not necessarily single chars)
* e.g., "updatercontroller" matches "updateController" by:
* "update" + "r" (from Controller) + "controller"
*/
private isGreedySubstringMatch(text: string, query: string): boolean {
const textLower = text.toLowerCase()
const queryLower = query.toLowerCase()
let queryIndex = 0
let searchStart = 0
while (queryIndex < queryLower.length) {
// Try to find the longest matching substring starting at queryIndex
let bestMatchLen = 0
let bestMatchPos = -1
for (let len = queryLower.length - queryIndex; len >= 1; len--) {
const substr = queryLower.slice(queryIndex, queryIndex + len)
const foundAt = textLower.indexOf(substr, searchStart)
if (foundAt !== -1) {
bestMatchLen = len
bestMatchPos = foundAt
break // Found longest possible match
}
}
if (bestMatchLen === 0) {
// No substring match found, query cannot be matched
return false
}
queryIndex += bestMatchLen
searchStart = bestMatchPos + bestMatchLen
}
return true
}
/**
* Calculate greedy substring match score (higher is better)
* Rewards: fewer match fragments, shorter match span, matches in filename
*/
private getGreedyMatchScore(filePath: string, query: string): number {
const textLower = filePath.toLowerCase()
const queryLower = query.toLowerCase()
const fileName = filePath.split('/').pop() || ''
const fileNameLower = fileName.toLowerCase()
let queryIndex = 0
let searchStart = 0
let fragmentCount = 0
let firstMatchPos = -1
let lastMatchEnd = 0
while (queryIndex < queryLower.length) {
let bestMatchLen = 0
let bestMatchPos = -1
for (let len = queryLower.length - queryIndex; len >= 1; len--) {
const substr = queryLower.slice(queryIndex, queryIndex + len)
const foundAt = textLower.indexOf(substr, searchStart)
if (foundAt !== -1) {
bestMatchLen = len
bestMatchPos = foundAt
break
}
}
if (bestMatchLen === 0) {
return -Infinity // No match
}
fragmentCount++
if (firstMatchPos === -1) firstMatchPos = bestMatchPos
lastMatchEnd = bestMatchPos + bestMatchLen
queryIndex += bestMatchLen
searchStart = lastMatchEnd
}
const matchSpan = lastMatchEnd - firstMatchPos
let score = 0
// Fewer fragments = better (single continuous match is best)
// Max bonus when fragmentCount=1, decreases as fragments increase
score += Math.max(0, 100 - (fragmentCount - 1) * 30)
// Shorter span relative to query length = better (tighter match)
// Perfect match: span equals query length
const spanRatio = queryLower.length / matchSpan
score += spanRatio * 50
// Bonus for match in filename
if (this.isGreedySubstringMatch(fileNameLower, queryLower)) {
score += 80
}
// Penalty for longer paths
score -= Math.log(filePath.length + 1) * 4
return score
}
/**
* Build common ripgrep arguments for file listing
*/
private buildRipgrepBaseArgs(options: Required<DirectoryListOptions>, resolvedPath: string): string[] {
const args: string[] = ['--files']
// Handle hidden files
if (!options.includeHidden) {
args.push('--glob', '!.*')
}
// Exclude common hidden directories and large directories
args.push('-g', '!**/node_modules/**')
args.push('-g', '!**/.git/**')
args.push('-g', '!**/.idea/**')
args.push('-g', '!**/.vscode/**')
args.push('-g', '!**/.DS_Store')
args.push('-g', '!**/dist/**')
args.push('-g', '!**/build/**')
args.push('-g', '!**/.next/**')
args.push('-g', '!**/.nuxt/**')
args.push('-g', '!**/coverage/**')
args.push('-g', '!**/.cache/**')
// Handle max depth
if (!options.recursive) {
args.push('--max-depth', '1')
} else if (options.maxDepth > 0) {
args.push('--max-depth', options.maxDepth.toString())
}
args.push(resolvedPath)
return args
}
private async listDirectoryWithRipgrep(
resolvedPath: string,
options: Required<DirectoryListOptions>
): Promise<string[]> {
// Fuzzy search mode: use ripgrep glob for pre-filtering, then score in JS
if (options.fuzzy && options.searchPattern && options.searchPattern !== '.') {
const args = this.buildRipgrepBaseArgs(options, resolvedPath)
// Insert glob pattern before the path (last element)
const globPattern = this.queryToGlobPattern(options.searchPattern)
args.splice(args.length - 1, 0, '--iglob', globPattern)
const { exitCode, output } = await executeRipgrep(args)
if (exitCode >= 2) {
throw new Error(`Ripgrep failed with exit code ${exitCode}: ${output}`)
}
const filteredFiles = output
.split('\n')
.filter((line) => line.trim())
.map((line) => line.replace(/\\/g, '/'))
// If fuzzy glob found results, validate fuzzy match, sort and return
if (filteredFiles.length > 0) {
return filteredFiles
.filter((file) => this.isFuzzyMatch(file, options.searchPattern))
.map((file) => ({ file, score: this.getFuzzyMatchScore(file, options.searchPattern) }))
.sort((a, b) => b.score - a.score)
.slice(0, options.maxEntries)
.map((item) => item.file)
}
// Fallback: if no results, try greedy substring match on all files
logger.debug('Fuzzy glob returned no results, falling back to greedy substring match')
const fallbackArgs = this.buildRipgrepBaseArgs(options, resolvedPath)
const fallbackResult = await executeRipgrep(fallbackArgs)
if (fallbackResult.exitCode >= 2) {
return []
}
const allFiles = fallbackResult.output
.split('\n')
.filter((line) => line.trim())
.map((line) => line.replace(/\\/g, '/'))
const greedyMatched = allFiles.filter((file) => this.isGreedySubstringMatch(file, options.searchPattern))
return greedyMatched
.map((file) => ({ file, score: this.getGreedyMatchScore(file, options.searchPattern) }))
.sort((a, b) => b.score - a.score)
.slice(0, options.maxEntries)
.map((item) => item.file)
}
// Fallback: search by filename only (non-fuzzy mode)
logger.debug('Searching by filename pattern', { pattern: options.searchPattern, path: resolvedPath })
const filenameResults = await this.searchByFilename(resolvedPath, options)
logger.debug('Found matches by filename', { count: filenameResults.length })
return filenameResults.slice(0, options.maxEntries)
}
public validateNotesDirectory = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise<boolean> => {
try {
if (!dirPath || typeof dirPath !== 'string') {
return false
}
// Normalize path
const normalizedPath = path.resolve(dirPath)
// Check if directory exists
if (!fs.existsSync(normalizedPath)) {
return false
}
// Check if it's actually a directory
const stats = fs.statSync(normalizedPath)
if (!stats.isDirectory()) {
return false
}
// 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())
// Prevent selecting app data directories
if (
normalizedPath.startsWith(filesDir) ||
normalizedPath.startsWith(appDataPath) ||
normalizedPath === currentNotesDir
) {
logger.warn(`Invalid directory selection: ${normalizedPath} (app data directory)`)
return false
}
// Prevent selecting system root directories
const isSystemRoot =
process.platform === 'win32'
? /^[a-zA-Z]:[\\/]?$/.test(normalizedPath)
: normalizedPath === '/' ||
normalizedPath === '/usr' ||
normalizedPath === '/etc' ||
normalizedPath === '/System'
if (isSystemRoot) {
logger.warn(`Invalid directory selection: ${normalizedPath} (system root directory)`)
return false
}
// Check write permissions
try {
fs.accessSync(normalizedPath, fs.constants.W_OK)
} catch (error) {
logger.warn(`Directory not writable: ${normalizedPath}`)
return false
}
return true
} catch (error) {
logger.error('Failed to validate notes directory:', error as Error)
return false
}
}
public save = async (
_: Electron.IpcMainInvokeEvent,
fileName: string,
content: string,
options?: SaveDialogOptions
): Promise<string> => {
try {
const result: SaveDialogReturnValue = await dialog.showSaveDialog({
title: '保存文件',
defaultPath: fileName,
...options
})
if (result.canceled) {
return Promise.reject(new Error('User canceled the save dialog'))
}
if (!result.canceled && result.filePath) {
writeFileSync(result.filePath, content, { encoding: 'utf-8' })
}
return result.filePath
} catch (err: any) {
logger.error('[IPC - Error] An error occurred saving the file:', err as Error)
return Promise.reject('An error occurred saving the file: ' + err?.message)
}
}
public saveImage = async (_: Electron.IpcMainInvokeEvent, name: string, data: string): Promise<void> => {
try {
const filePath = dialog.showSaveDialogSync({
defaultPath: `${name}.png`,
filters: [{ name: 'PNG Image', extensions: ['png'] }]
})
if (filePath) {
const base64Data = data.replace(/^data:image\/png;base64,/, '')
fs.writeFileSync(filePath, base64Data, 'base64')
}
} catch (error) {
logger.error('[IPC - Error] An error occurred saving the image:', error as Error)
}
}
public selectFolder = async (_: Electron.IpcMainInvokeEvent, options: OpenDialogOptions): Promise<string | null> => {
try {
const result: OpenDialogReturnValue = await dialog.showOpenDialog({
title: '选择文件夹',
properties: ['openDirectory'],
...options
})
if (!result.canceled && result.filePaths.length > 0) {
return result.filePaths[0]
}
return null
} catch (err) {
logger.error('[IPC - Error] An error occurred selecting the folder:', err as Error)
return null
}
}
public downloadFile = async (
_: Electron.IpcMainInvokeEvent,
url: string,
isUseContentType?: boolean
): Promise<FileMetadata> => {
try {
const response = await net.fetch(url)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
// 尝试从Content-Disposition获取文件名
const contentDisposition = response.headers.get('Content-Disposition')
let filename = 'download'
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i)
if (filenameMatch) {
filename = filenameMatch[1]
}
}
// 如果URL中有文件名使用URL中的文件名
const urlFilename = url.split('/').pop()?.split('?')[0]
if (urlFilename && urlFilename.includes('.')) {
filename = urlFilename
}
// 如果文件名没有后缀根据Content-Type添加后缀
if (isUseContentType || !filename.includes('.')) {
const contentType = response.headers.get('Content-Type')
const ext = this.getExtensionFromMimeType(contentType)
filename += ext
}
const uuid = uuidv4()
const ext = path.extname(filename)
const destPath = path.join(this.storageDir, uuid + ext)
// 将响应内容写入文件
const buffer = Buffer.from(await response.arrayBuffer())
await fs.promises.writeFile(destPath, buffer)
const stats = await fs.promises.stat(destPath)
const fileType = await this.getFileType(destPath)
return {
id: uuid,
origin_name: filename,
name: uuid + ext,
path: destPath,
created_at: stats.birthtime.toISOString(),
size: stats.size,
ext: ext,
type: fileType,
count: 1
}
} catch (error) {
logger.error('Download file error:', error as Error)
throw error
}
}
private getExtensionFromMimeType(mimeType: string | null): string {
if (!mimeType) return '.bin'
const mimeToExtension: { [key: string]: string } = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'application/pdf': '.pdf',
'text/plain': '.txt',
'application/msword': '.doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
'application/zip': '.zip',
'application/x-zip-compressed': '.zip',
'application/octet-stream': '.bin'
}
return mimeToExtension[mimeType] || '.bin'
}
// @TraceProperty({ spanName: 'copyFile', tag: 'FileStorage' })
public copyFile = async (_: Electron.IpcMainInvokeEvent, id: string, destPath: string): Promise<void> => {
try {
const sourcePath = path.join(this.storageDir, id)
// 确保目标目录存在
const destDir = path.dirname(destPath)
if (!fs.existsSync(destDir)) {
await fs.promises.mkdir(destDir, { recursive: true })
}
// 复制文件
await fs.promises.copyFile(sourcePath, destPath)
logger.debug(`File copied successfully: ${sourcePath} to ${destPath}`)
} catch (error) {
logger.error('Copy file failed:', error as Error)
throw error
}
}
public writeFileWithId = async (_: Electron.IpcMainInvokeEvent, id: string, content: string): Promise<void> => {
try {
const filePath = path.join(this.storageDir, id)
logger.debug(`Writing file: ${filePath}`)
// 确保目录存在
if (!fs.existsSync(this.storageDir)) {
logger.debug(`Creating storage directory: ${this.storageDir}`)
fs.mkdirSync(this.storageDir, { recursive: true })
}
await fs.promises.writeFile(filePath, content, 'utf8')
logger.debug(`File written successfully: ${filePath}`)
} catch (error) {
logger.error('Failed to write file:', error as Error)
throw error
}
}
public startFileWatcher = async (
event: Electron.IpcMainInvokeEvent,
dirPath: string,
config?: FileWatcherConfig
): Promise<void> => {
try {
this.watcherConfig = { ...DEFAULT_WATCHER_CONFIG, ...config }
if (!dirPath?.trim()) {
throw new Error('Directory path is required')
}
const normalizedPath = path.resolve(dirPath.trim())
if (!fs.existsSync(normalizedPath)) {
throw new Error(`Directory does not exist: ${normalizedPath}`)
}
const stats = fs.statSync(normalizedPath)
if (!stats.isDirectory()) {
throw new Error(`Path is not a directory: ${normalizedPath}`)
}
if (this.currentWatchPath === normalizedPath && this.watcher) {
this.watcherSender = event.sender
logger.debug('Already watching directory, updated sender', { path: normalizedPath })
return
}
await this.stopFileWatcher()
logger.info('Starting file watcher', {
path: normalizedPath,
config: {
extensions: this.watcherConfig.watchExtensions,
debounceMs: this.watcherConfig.debounceMs,
maxDepth: this.watcherConfig.maxDepth
}
})
this.currentWatchPath = normalizedPath
this.watcherSender = event.sender
const watchOptions = {
ignored: this.watcherConfig.ignoredPatterns,
persistent: true,
ignoreInitial: true,
depth: this.watcherConfig.maxDepth,
usePolling: this.watcherConfig.usePolling,
awaitWriteFinish: {
stabilityThreshold: this.watcherConfig.stabilityThreshold,
pollInterval: 100
},
alwaysStat: false,
atomic: true
}
this.watcher = chokidar.watch(normalizedPath, watchOptions)
const handleChange = this.createChangeHandler()
this.watcher
.on('add', (filePath: string) => handleChange('add', filePath))
.on('unlink', (filePath: string) => handleChange('unlink', filePath))
.on('addDir', (dirPath: string) => handleChange('addDir', dirPath))
.on('unlinkDir', (dirPath: string) => handleChange('unlinkDir', dirPath))
.on('error', (error: unknown) => {
logger.error('File watcher error', { error: error as Error, path: normalizedPath })
if (this.watcherConfig.retryOnError) {
this.handleWatcherError(error as Error)
}
})
.on('ready', () => {
logger.debug('File watcher ready', { path: normalizedPath })
})
logger.info('File watcher started successfully')
} catch (error) {
logger.error('Failed to start file watcher', error as Error)
this.cleanup()
throw error
}
}
private createChangeHandler() {
return (eventType: string, filePath: string) => {
// Skip processing if watcher is paused
if (this.isPaused) {
logger.debug('File change ignored (watcher paused)', { eventType, filePath })
return
}
if (!this.shouldWatchFile(filePath, eventType)) {
return
}
logger.debug('File change detected', { eventType, filePath, path: this.currentWatchPath })
// 对于目录操作,立即触发同步,不使用防抖
if (eventType === 'addDir' || eventType === 'unlinkDir') {
logger.debug('Directory operation detected, triggering immediate sync', { eventType, filePath })
this.notifyChange(eventType, filePath)
return
}
// 对于文件操作,使用防抖机制
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
}
this.debounceTimer = setTimeout(() => {
this.notifyChange(eventType, filePath)
this.debounceTimer = undefined
}, this.watcherConfig.debounceMs)
}
}
private shouldWatchFile(filePath: string, eventType: string): boolean {
if (eventType.includes('Dir')) {
return true
}
const ext = path.extname(filePath).toLowerCase()
return this.watcherConfig.watchExtensions.includes(ext)
}
private notifyChange(eventType: string, filePath: string) {
try {
if (!this.watcherSender || this.watcherSender.isDestroyed()) {
logger.warn('Sender destroyed, stopping watcher')
this.stopFileWatcher()
return
}
logger.debug('Sending file change event', {
eventType,
filePath,
channel: this.watcherConfig.eventChannel,
senderExists: !!this.watcherSender,
senderDestroyed: this.watcherSender.isDestroyed()
})
this.watcherSender.send(this.watcherConfig.eventChannel, {
eventType,
filePath,
watchPath: this.currentWatchPath
})
logger.debug('File change event sent successfully')
} catch (error) {
logger.error('Failed to send notification', error as Error)
}
}
private handleWatcherError(error: Error) {
const retryableErrors = ['EMFILE', 'ENFILE', 'ENOSPC']
const isRetryable = retryableErrors.some((code) => error.message.includes(code))
if (isRetryable && this.currentWatchPath && this.watcherSender && !this.watcherSender.isDestroyed()) {
logger.warn('Attempting restart due to recoverable error', { error: error.message })
setTimeout(async () => {
try {
if (this.currentWatchPath && this.watcherSender && !this.watcherSender.isDestroyed()) {
const mockEvent = { sender: this.watcherSender } as Electron.IpcMainInvokeEvent
await this.startFileWatcher(mockEvent, this.currentWatchPath, this.watcherConfig)
}
} catch (retryError) {
logger.error('Restart failed', retryError as Error)
}
}, this.watcherConfig.retryDelayMs)
}
}
private cleanup() {
this.currentWatchPath = undefined
this.watcherSender = undefined
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
this.debounceTimer = undefined
}
}
public stopFileWatcher = async (): Promise<void> => {
try {
if (this.watcher) {
logger.info('Stopping file watcher', { path: this.currentWatchPath })
await this.watcher.close()
this.watcher = undefined
logger.debug('File watcher stopped')
}
this.cleanup()
} catch (error) {
logger.error('Failed to stop file watcher', error as Error)
this.watcher = undefined
this.cleanup()
}
}
public getWatcherStatus(): { isActive: boolean; watchPath?: string; hasValidSender: boolean } {
return {
isActive: !!this.watcher,
watchPath: this.currentWatchPath,
hasValidSender: !!this.watcherSender && !this.watcherSender.isDestroyed()
}
}
public getFilePathById(file: FileMetadata): string {
return path.join(this.storageDir, file.id + file.ext)
}
public isTextFile = async (_: Electron.IpcMainInvokeEvent, filePath: string): Promise<boolean> => {
return this._isTextFile(filePath)
}
private _isTextFile = async (filePath: string): Promise<boolean> => {
try {
const isBinary = await isBinaryFile(filePath)
if (isBinary) {
return false
}
const length = 8 * KB
const fileHandle = await fs.promises.open(filePath, 'r')
const buffer = Buffer.alloc(length)
const { bytesRead } = await fileHandle.read(buffer, 0, length, 0)
await fileHandle.close()
const sampleBuffer = buffer.subarray(0, bytesRead)
const matches = chardet.analyse(sampleBuffer)
// 如果检测到的编码置信度较高,认为是文本文件
if (matches.length > 0 && matches[0].confidence > 0.8) {
return true
}
return false
} catch (error) {
logger.error('Failed to check if file is text:', error as Error)
return false
}
}
public showInFolder = async (_: Electron.IpcMainInvokeEvent, path: string): Promise<void> => {
if (!fs.existsSync(path)) {
const msg = `File or folder does not exist: ${path}`
logger.error(msg)
throw new Error(msg)
}
try {
shell.showItemInFolder(path)
} catch (error) {
logger.error('Failed to show item in folder:', error as Error)
}
}
/**
* Batch upload markdown files from native File objects
* This handles all I/O operations in the Main process to avoid blocking Renderer
*/
public batchUploadMarkdownFiles = async (
_: Electron.IpcMainInvokeEvent,
filePaths: string[],
targetPath: string
): Promise<{
fileCount: number
folderCount: number
skippedFiles: number
}> => {
try {
logger.info('Starting batch upload', { fileCount: filePaths.length, targetPath })
const basePath = path.resolve(targetPath)
const MARKDOWN_EXTS = ['.md', '.markdown']
// Filter markdown files
const markdownFiles = filePaths.filter((filePath) => {
const ext = path.extname(filePath).toLowerCase()
return MARKDOWN_EXTS.includes(ext)
})
const skippedFiles = filePaths.length - markdownFiles.length
if (markdownFiles.length === 0) {
return { fileCount: 0, folderCount: 0, skippedFiles }
}
// Collect unique folders needed
const foldersSet = new Set<string>()
const fileOperations: Array<{ sourcePath: string; targetPath: string }> = []
for (const filePath of markdownFiles) {
try {
// Get relative path if file is from a directory upload
const fileName = path.basename(filePath)
const relativePath = path.dirname(filePath)
// Determine target directory structure
let targetDir = basePath
const folderParts: string[] = []
// Extract folder structure from file path for nested uploads
// This is a simplified version - in real scenario we'd need the original directory structure
if (relativePath && relativePath !== '.') {
const parts = relativePath.split(path.sep)
// Get the last few parts that represent the folder structure within upload
const relevantParts = parts.slice(Math.max(0, parts.length - 3))
folderParts.push(...relevantParts)
}
// Build target directory path
for (const part of folderParts) {
targetDir = path.join(targetDir, part)
foldersSet.add(targetDir)
}
// Determine final file name
const nameWithoutExt = fileName.endsWith('.md')
? fileName.slice(0, -3)
: fileName.endsWith('.markdown')
? fileName.slice(0, -9)
: fileName
const { safeName } = await this.fileNameGuard(_, targetDir, nameWithoutExt, true)
const finalPath = path.join(targetDir, safeName + '.md')
fileOperations.push({ sourcePath: filePath, targetPath: finalPath })
} catch (error) {
logger.error('Failed to prepare file operation:', error as Error, { filePath })
}
}
// Create folders in order (shallow to deep)
const sortedFolders = Array.from(foldersSet).sort((a, b) => a.length - b.length)
for (const folder of sortedFolders) {
try {
if (!fs.existsSync(folder)) {
await fs.promises.mkdir(folder, { recursive: true })
}
} catch (error) {
logger.debug('Folder already exists or creation failed', { folder, error: (error as Error).message })
}
}
// Process files in batches
const BATCH_SIZE = 10 // Higher batch size since we're in Main process
let successCount = 0
for (let i = 0; i < fileOperations.length; i += BATCH_SIZE) {
const batch = fileOperations.slice(i, i + BATCH_SIZE)
const results = await Promise.allSettled(
batch.map(async (op) => {
// Read from source and write to target in Main process
const content = await fs.promises.readFile(op.sourcePath, 'utf-8')
await fs.promises.writeFile(op.targetPath, content, 'utf-8')
return true
})
)
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
successCount++
} else {
logger.error('Failed to upload file:', result.reason, {
file: batch[index].sourcePath
})
}
})
}
logger.info('Batch upload completed', {
successCount,
folderCount: foldersSet.size,
skippedFiles
})
return {
fileCount: successCount,
folderCount: foldersSet.size,
skippedFiles
}
} catch (error) {
logger.error('Batch upload failed:', error as Error)
throw error
}
}
/**
* Pause file watcher to prevent events during batch operations
*/
public pauseFileWatcher = async (): Promise<void> => {
if (this.watcher) {
logger.debug('Pausing file watcher')
this.isPaused = true
// Clear any pending debounced notifications
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
this.debounceTimer = undefined
}
}
}
/**
* Resume file watcher and trigger a refresh
*/
public resumeFileWatcher = async (): Promise<void> => {
if (this.watcher && this.currentWatchPath) {
logger.debug('Resuming file watcher')
this.isPaused = false
// Send a synthetic refresh event to trigger tree reload
this.notifyChange('refresh', this.currentWatchPath)
}
}
}
export const fileStorage = new FileStorage()