@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
text/typescript
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;
}
}
}