import { loggerService } from '@logger' import { checkName, getFilesDir, getFileType, getName, getNotesDir, getTempDir, readTextFileWithAutoEncoding, scanDir } from '@main/utils/file' import { documentExts, imageExts, KB, MB } from '@shared/config/constant' import { FileMetadata, NotesTreeNode } from '@types' import chardet from 'chardet' import chokidar, { FSWatcher } from 'chokidar' import * as crypto from 'crypto' import { dialog, net, OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue, 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') 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 = { 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' } 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 = DEFAULT_WATCHER_CONFIG 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.storageDir, { 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 => { 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) }) } findDuplicateFile = async (filePath: string): Promise => { 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) return { id, origin_name: file, name: file + ext, path: storedFilePath, created_at: storedStats.birthtime.toISOString(), size: storedStats.size, ext, type: getFileType(ext), count: 2 } } } } return null } public selectFile = async ( _: Electron.IpcMainInvokeEvent, options?: OpenDialogOptions ): Promise => { 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 = getFileType(ext) 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 { 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 => { 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 = getFileType(ext) 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 => { if (!fs.existsSync(filePath)) { return null } const stats = fs.statSync(filePath) const ext = path.extname(filePath) const fileType = getFileType(ext) 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 } } // @TraceProperty({ spanName: 'deleteFile', tag: 'FileStorage' }) public deleteFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 } } public readFile = async ( _: Electron.IpcMainInvokeEvent, id: string, detectEncoding: boolean = false ): Promise => { const filePath = path.join(this.storageDir, id) 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 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 file:', error as Error) throw new Error(`Failed to read file: ${filePath}.`) } } public readExternalFile = async ( _: Electron.IpcMainInvokeEvent, filePath: string, detectEncoding: boolean = false ): Promise => { if (!fs.existsSync(filePath)) { throw new Error(`File does not exist: ${filePath}`) } 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 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 file:', error as Error) throw new Error(`Failed to read file: ${filePath}.`) } } public createTempFile = async (_: Electron.IpcMainInvokeEvent, fileName: string): Promise => { 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 => { 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 => { 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 => { 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: getFileType(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 => { 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: getFileType(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 { 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 => { 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 => { await fs.promises.rm(this.storageDir, { recursive: true }) this.initStorageDir() } public clearTemp = async (): Promise => { 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 => { shell.openPath(path).catch((err) => logger.error('[IPC - Error] Failed to open file:', err)) } /** * 通过相对路径打开文件,跨设备时使用 * @param _ * @param file */ public openFileWithRelativePath = async (_: Electron.IpcMainInvokeEvent, file: FileMetadata): Promise => { 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 => { try { return await scanDir(dirPath) } catch (error) { logger.error('Failed to get directory structure:', error as Error) throw error } } public validateNotesDirectory = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise => { 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 => { 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 => { 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 => { 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 => { 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 = getFileType(ext) 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 => { 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 => { 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 => { 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) => { 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 => { 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 => { 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 } } } export const fileStorage = new FileStorage()