cherry-studio/src/main/services/FileStorage.ts
fullex dc06c103e0
chore[lint]: add import type lint (#11091)
chore: add import type lint
2025-11-01 10:40:02 +08:00

1245 lines
38 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,
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 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 { 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')
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'
}
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
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<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)
})
}
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)
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<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 = 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<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 = 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<FileMetadata | null> => {
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<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
}
}
public readFile = async (
_: Electron.IpcMainInvokeEvent,
id: string,
detectEncoding: boolean = false
): Promise<string> => {
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<string> => {
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<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: 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<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: 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<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 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 = 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<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) => {
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> => {
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)
}
}
}
export const fileStorage = new FileStorage()