UNPKG

@lobehub/chat

Version:

Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.

578 lines (504 loc) • 18.4 kB
import { exec, spawn } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import readline from 'node:readline'; import { promisify } from 'node:util'; import { FileResult, SearchOptions } from '@/types/fileSearch'; import { createLogger } from '@/utils/logger'; import { FileSearchImpl } from '../type'; const execPromise = promisify(exec); const statPromise = promisify(fs.stat); // Create logger const logger = createLogger('module:FileSearch:macOS'); export class MacOSSearchServiceImpl extends FileSearchImpl { /** * Perform file search * @param options Search options * @returns Promise of search result list */ async search(options: SearchOptions): Promise<FileResult[]> { // Build the command first, regardless of execution method const command = this.buildSearchCommand(options); logger.debug(`Executing command: ${command}`); // Use spawn for both live and non-live updates to handle large outputs return new Promise((resolve, reject) => { const [cmd, ...args] = command.split(' '); const childProcess = spawn(cmd, args); let results: string[] = []; // Store raw file paths let stderrData = ''; // Create a readline interface to process stdout line by line const rl = readline.createInterface({ crlfDelay: Infinity, input: childProcess.stdout, // Handle different line endings }); rl.on('line', (line) => { const trimmedLine = line.trim(); if (trimmedLine) { results.push(trimmedLine); // If we have a limit and we've reached it (in non-live mode), stop processing if (!options.liveUpdate && options.limit && results.length >= options.limit) { logger.debug(`Reached limit (${options.limit}), closing readline and killing process.`); rl.close(); // Stop reading lines childProcess.kill(); // Terminate the mdfind process } } }); childProcess.stderr.on('data', (data) => { const errorMsg = data.toString(); stderrData += errorMsg; logger.warn(`Search stderr: ${errorMsg}`); }); childProcess.on('error', (error) => { logger.error(`Search process error: ${error.message}`, error); reject(new Error(`Search process failed to start: ${error.message}`)); }); childProcess.on('close', async (code) => { logger.debug(`Search process exited with code ${code}`); // Even if the process was killed due to limit, code might be null or non-zero. // Process the results collected so far. if (code !== 0 && stderrData && results.length === 0) { // If exited with error code and we have stderr message and no results, reject. // Filter specific ignorable errors if necessary if (!stderrData.includes('Index is unavailable') && !stderrData.includes('kMD')) { // Avoid rejecting for common Spotlight query syntax errors or index issues if some results might still be valid reject(new Error(`Search process exited with code ${code}: ${stderrData}`)); return; } else { logger.warn( `Search process exited with code ${code} but contained potentially ignorable errors: ${stderrData}`, ); } } try { // Process the collected file paths // Ensure limit is applied again here in case killing the process didn't stop exactly at the limit const limitedResults = options.limit && results.length > options.limit ? results.slice(0, options.limit) : results; const processedResults = await this.processSearchResultsFromPaths( limitedResults, options, ); resolve(processedResults); } catch (processingError) { logger.error('Error processing search results:', processingError); reject(new Error(`Failed to process search results: ${processingError.message}`)); } }); // Handle live update specific logic (if needed in the future, e.g., sending initial batch) if (options.liveUpdate) { // For live update, we might want to resolve an initial batch // or rely purely on events sent elsewhere. // Current implementation resolves when the stream closes. // We could add a timeout to resolve with initial results if needed. logger.debug('Live update enabled, results will be processed on close.'); // Note: The previous `executeLiveSearch` logic is now integrated here. // If specific live update event emission is needed, it would be added here, // potentially calling a callback provided in options. } }); } /** * Check search service status * @returns Promise indicating if Spotlight service is available */ async checkSearchServiceStatus(): Promise<boolean> { return this.checkSpotlightStatus(); } /** * Update search index * @param path Optional specified path * @returns Promise indicating operation success */ async updateSearchIndex(path?: string): Promise<boolean> { return this.updateSpotlightIndex(path); } /** * Build mdfind command string * @param options Search options * @returns Complete command string */ private buildSearchCommand(options: SearchOptions): string { // Basic command let command = 'mdfind'; // Add options const mdFindOptions: string[] = []; // macOS mdfind doesn't support -limit parameter, we'll limit results in post-processing // Search in specific directory if (options.onlyIn) { mdFindOptions.push(`-onlyin "${options.onlyIn}"`); } // Live update if (options.liveUpdate) { mdFindOptions.push('-live'); } // Detailed metadata if (options.detailed) { mdFindOptions.push( '-attr kMDItemDisplayName kMDItemContentType kMDItemKind kMDItemFSSize kMDItemFSCreationDate kMDItemFSContentChangeDate', ); } // Build query expression let queryExpression = ''; // Basic query if (options.keywords) { // If the query string doesn't use Spotlight query syntax (doesn't contain kMDItem properties), // treat it as plain text search if (!options.keywords.includes('kMDItem')) { queryExpression = `"${options.keywords.replaceAll('"', '\\"')}"`; } else { queryExpression = options.keywords; } } // File content search if (options.contentContains) { if (queryExpression) { queryExpression = `${queryExpression} && kMDItemTextContent == "*${options.contentContains}*"cd`; } else { queryExpression = `kMDItemTextContent == "*${options.contentContains}*"cd`; } } // File type filtering if (options.fileTypes && options.fileTypes.length > 0) { const typeConditions = options.fileTypes .map((type) => `kMDItemContentType == "${type}"`) .join(' || '); if (queryExpression) { queryExpression = `${queryExpression} && (${typeConditions})`; } else { queryExpression = `(${typeConditions})`; } } // Date filtering - Modified date if (options.modifiedAfter || options.modifiedBefore) { let dateCondition = ''; if (options.modifiedAfter) { const dateString = options.modifiedAfter.toISOString().split('T')[0]; dateCondition += `kMDItemFSContentChangeDate >= $time.iso(${dateString})`; } if (options.modifiedBefore) { if (dateCondition) dateCondition += ' && '; const dateString = options.modifiedBefore.toISOString().split('T')[0]; dateCondition += `kMDItemFSContentChangeDate <= $time.iso(${dateString})`; } if (queryExpression) { queryExpression = `${queryExpression} && (${dateCondition})`; } else { queryExpression = dateCondition; } } // Date filtering - Creation date if (options.createdAfter || options.createdBefore) { let dateCondition = ''; if (options.createdAfter) { const dateString = options.createdAfter.toISOString().split('T')[0]; dateCondition += `kMDItemFSCreationDate >= $time.iso(${dateString})`; } if (options.createdBefore) { if (dateCondition) dateCondition += ' && '; const dateString = options.createdBefore.toISOString().split('T')[0]; dateCondition += `kMDItemFSCreationDate <= $time.iso(${dateString})`; } if (queryExpression) { queryExpression = `${queryExpression} && (${dateCondition})`; } else { queryExpression = dateCondition; } } // Combine complete command if (mdFindOptions.length > 0) { command += ' ' + mdFindOptions.join(' '); } // Finally add query expression command += ` ${queryExpression}`; return command; } /** * Execute live search, returns initial results and sets callback * @param command mdfind command * @param options Search options * @returns Promise of initial search results * @deprecated This logic is now integrated into the main search method using spawn. */ // private executeLiveSearch(command: string, options: SearchOptions): Promise<FileResult[]> { ... } // Remove or comment out the old executeLiveSearch method /** * Process search results from a list of file paths * @param filePaths Array of file path strings * @param options Search options * @returns Formatted file result list */ private async processSearchResultsFromPaths( filePaths: string[], options: SearchOptions, ): Promise<FileResult[]> { // Create a result object for each file path const resultPromises = filePaths.map(async (filePath) => { try { // Get file information const stats = await statPromise(filePath); // Create basic result object const result: FileResult = { createdTime: stats.birthtime, isDirectory: stats.isDirectory(), lastAccessTime: stats.atime, metadata: {}, modifiedTime: stats.mtime, name: path.basename(filePath), path: filePath, size: stats.size, type: path.extname(filePath).toLowerCase().replace('.', ''), }; // If detailed information is needed, get additional metadata if (options.detailed) { result.metadata = await this.getDetailedMetadata(filePath); } // Determine content type result.contentType = this.determineContentType(result.name, result.type); return result; } catch (error) { logger.warn(`Error processing file stats for ${filePath}: ${error.message}`, error); // Return partial information, even if unable to get complete file stats return { contentType: 'unknown', createdTime: new Date(), isDirectory: false, lastAccessTime: new Date(), modifiedTime: new Date(), name: path.basename(filePath), path: filePath, size: 0, type: path.extname(filePath).toLowerCase().replace('.', ''), }; } }); // Wait for all file information processing to complete let results = await Promise.all(resultPromises); // Sort results if (options.sortBy) { results = this.sortResults(results, options.sortBy, options.sortDirection); } // Apply limit here as mdfind doesn't support -limit parameter if (options.limit && options.limit > 0 && results.length > options.limit) { results = results.slice(0, options.limit); } return results; } /** * Process search results * @param stdout Command output (now unused directly, processing happens line by line) * @param options Search options * @returns Formatted file result list * @deprecated Use processSearchResultsFromPaths instead. */ // private async processSearchResults(stdout: string, options: SearchOptions): Promise<FileResult[]> { ... } // Remove or comment out the old processSearchResults method /** * Get detailed metadata for a file * @param filePath File path * @returns Metadata object */ private async getDetailedMetadata(filePath: string): Promise<Record<string, any>> { try { // Use mdls command to get all metadata const { stdout } = await execPromise(`mdls "${filePath}"`); // Parse mdls output const metadata: Record<string, any> = {}; const lines = stdout.split('\n'); let currentKey = ''; let isMultilineValue = false; let multilineValue: string[] = []; for (const line of lines) { if (isMultilineValue) { if (line.includes(')')) { // Multiline value ends multilineValue.push(line.trim()); metadata[currentKey] = multilineValue.join(' '); isMultilineValue = false; multilineValue = []; } else { // Continue collecting multiline value multilineValue.push(line.trim()); } continue; } const match = line.match(/^(\w+)\s+=\s+(.*)$/); if (match) { currentKey = match[1]; const value = match[2].trim(); // Check for multiline value start if (value.includes('(') && !value.includes(')')) { isMultilineValue = true; multilineValue = [value]; } else { // Process single line value metadata[currentKey] = this.parseMetadataValue(value); } } } return metadata; } catch (error) { logger.warn(`Error getting metadata for ${filePath}: ${error.message}`, error); return {}; } } /** * Parse metadata value * @param value Metadata raw value string * @returns Parsed value */ private parseMetadataValue(input: string): any { let value = input; // Remove quotes from mdls output if (value.startsWith('"') && value.endsWith('"')) { // eslint-disable-next-line unicorn/prefer-string-slice value = value.substring(1, value.length - 1); } // Handle special values if (value === '(null)') return null; if (value === 'Yes' || value === 'true') return true; if (value === 'No' || value === 'false') return false; // Try to parse date (format like "2023-05-16 14:30:45 +0000") const dateMatch = value.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [+-]\d{4})$/); if (dateMatch) { try { return new Date(value); } catch { // If date parsing fails, return original string return value; } } // Try to parse number if (/^-?\d+(\.\d+)?$/.test(value)) { return Number(value); } // Default return string return value; } /** * Determine file content type * @param fileName File name * @param extension File extension * @returns Content type description */ private determineContentType(fileName: string, extension: string): string { // Map common file extensions to content types const typeMap: Record<string, string> = { '7z': 'archive', 'aac': 'audio', // Others 'app': 'application', 'avi': 'video', 'c': 'code', 'cpp': 'code', 'css': 'code', 'dmg': 'disk-image', 'doc': 'document', 'docx': 'document', 'gif': 'image', 'gz': 'archive', 'heic': 'image', 'html': 'code', 'iso': 'disk-image', 'java': 'code', 'jpeg': 'image', // Images 'jpg': 'image', // Code 'js': 'code', 'json': 'code', 'mkv': 'video', 'mov': 'video', // Audio 'mp3': 'audio', // Video 'mp4': 'video', 'ogg': 'audio', // Documents 'pdf': 'document', 'png': 'image', 'ppt': 'presentation', 'pptx': 'presentation', 'py': 'code', 'rar': 'archive', 'rtf': 'text', 'svg': 'image', 'swift': 'code', 'tar': 'archive', 'ts': 'code', 'txt': 'text', 'wav': 'audio', 'webp': 'image', 'xls': 'spreadsheet', 'xlsx': 'spreadsheet', // Archive files 'zip': 'archive', }; // Find matching content type return typeMap[extension.toLowerCase()] || 'unknown'; } /** * Sort results * @param results Result list * @param sortBy Sort field * @param direction Sort direction * @returns Sorted result list */ private sortResults( results: FileResult[], sortBy: 'name' | 'date' | 'size', direction: 'asc' | 'desc' = 'asc', ): FileResult[] { const sortedResults = [...results]; sortedResults.sort((a, b) => { let comparison = 0; switch (sortBy) { case 'name': { comparison = a.name.localeCompare(b.name); break; } case 'date': { comparison = a.modifiedTime.getTime() - b.modifiedTime.getTime(); break; } case 'size': { comparison = a.size - b.size; break; } } return direction === 'asc' ? comparison : -comparison; }); return sortedResults; } /** * Check Spotlight service status * @returns Promise indicating if Spotlight is available */ private async checkSpotlightStatus(): Promise<boolean> { try { // Try to run a simple mdfind command - macOS doesn't support -limit parameter await execPromise('mdfind -name test -onlyin ~ -count'); return true; } catch (error) { logger.error(`Spotlight is not available: ${error.message}`, error); return false; } } /** * Update Spotlight index * @param path Optional specified path * @returns Promise indicating operation success */ private async updateSpotlightIndex(path?: string): Promise<boolean> { try { // mdutil command is used to manage Spotlight index const command = path ? `mdutil -E "${path}"` : 'mdutil -E /'; await execPromise(command); return true; } catch (error) { logger.error(`Failed to update Spotlight index: ${error.message}`, error); return false; } } }