mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-07 05:39:05 +08:00
* 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>
2030 lines
64 KiB
TypeScript
2030 lines
64 KiB
TypeScript
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()
|