Files
obsidian-mcp-server/src/utils/search-utils.ts
Bill 48e429d59e fix: remove console.error from graceful error handlers
Removed console.error calls from error handlers that gracefully skip
problematic files and continue processing. These handlers catch errors
when reading or parsing files but successfully return fallback values,
so logging errors creates unnecessary noise during testing and deployment.

Changes:
- vault-tools.ts: Remove console.error from search and frontmatter extraction
- search-utils.ts: Remove console.error from file search handlers
- waypoint-utils.ts: Remove console.error from file read handler
- frontmatter-utils.ts: Remove console.error from YAML and Excalidraw parsing

Test updates:
- Remove test assertions checking for console.error calls since these
  are no longer emitted by graceful error handlers

All 709 tests pass with no console noise during error handling.
2025-10-26 12:44:00 -04:00

334 lines
7.7 KiB
TypeScript

import { TFile } from 'obsidian';
import { SearchMatch } from '../types/mcp-types';
import { GlobUtils } from './glob-utils';
import { IVaultAdapter } from '../adapters/interfaces';
export interface SearchOptions {
query: string;
isRegex?: boolean;
caseSensitive?: boolean;
includes?: string[];
excludes?: string[];
folder?: string;
returnSnippets?: boolean;
snippetLength?: number;
maxResults?: number;
}
export interface SearchStatistics {
filesSearched: number;
filesWithMatches: number;
totalMatches: number;
}
export class SearchUtils {
/**
* Search vault files with advanced filtering and regex support
*/
static async search(
vault: IVaultAdapter,
options: SearchOptions
): Promise<{ matches: SearchMatch[]; stats: SearchStatistics }> {
const {
query,
isRegex = false,
caseSensitive = false,
includes,
excludes,
folder,
returnSnippets = true,
snippetLength = 100,
maxResults = 100
} = options;
const matches: SearchMatch[] = [];
const filesWithMatches = new Set<string>();
let filesSearched = 0;
// Compile search pattern
let searchPattern: RegExp;
try {
if (isRegex) {
const flags = caseSensitive ? 'g' : 'gi';
searchPattern = new RegExp(query, flags);
} else {
// Escape special regex characters for literal search
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const flags = caseSensitive ? 'g' : 'gi';
searchPattern = new RegExp(escapedQuery, flags);
}
} catch (error) {
throw new Error(`Invalid regex pattern: ${(error as Error).message}`);
}
// Get files to search
let files = vault.getMarkdownFiles();
// Filter by folder if specified
if (folder) {
const folderPath = folder.endsWith('/') ? folder : folder + '/';
files = files.filter(file =>
file.path.startsWith(folderPath) || file.path === folder
);
}
// Apply glob filtering
if (includes || excludes) {
files = files.filter(file =>
GlobUtils.shouldInclude(file.path, includes, excludes)
);
}
// Search through files
for (const file of files) {
if (matches.length >= maxResults) {
break;
}
filesSearched++;
try {
const content = await vault.read(file);
const fileMatches = this.searchInFile(
file,
content,
searchPattern,
returnSnippets,
snippetLength,
maxResults - matches.length
);
if (fileMatches.length > 0) {
filesWithMatches.add(file.path);
matches.push(...fileMatches);
}
// Also search in filename
const filenameMatches = this.searchInFilename(
file,
searchPattern,
caseSensitive
);
if (filenameMatches.length > 0) {
filesWithMatches.add(file.path);
matches.push(...filenameMatches);
}
} catch (error) {
// Skip files that can't be read
}
}
return {
matches,
stats: {
filesSearched,
filesWithMatches: filesWithMatches.size,
totalMatches: matches.length
}
};
}
/**
* Search within a single file's content
*/
private static searchInFile(
file: TFile,
content: string,
pattern: RegExp,
returnSnippets: boolean,
snippetLength: number,
maxMatches: number
): SearchMatch[] {
const matches: SearchMatch[] = [];
const lines = content.split('\n');
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
if (matches.length >= maxMatches) {
break;
}
const line = lines[lineIndex];
// Reset regex lastIndex for global patterns
pattern.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(line)) !== null) {
if (matches.length >= maxMatches) {
break;
}
const columnIndex = match.index;
const matchText = match[0];
// Extract snippet with context
let snippet = line;
let snippetStart = 0;
let matchStart = columnIndex;
if (returnSnippets && line.length > snippetLength) {
// Calculate snippet boundaries
const halfSnippet = Math.floor(snippetLength / 2);
snippetStart = Math.max(0, columnIndex - halfSnippet);
const snippetEnd = Math.min(line.length, snippetStart + snippetLength);
// Adjust if we're at the end of the line
if (snippetEnd === line.length && line.length > snippetLength) {
snippetStart = Math.max(0, line.length - snippetLength);
}
snippet = line.substring(snippetStart, snippetEnd);
matchStart = columnIndex - snippetStart;
}
matches.push({
path: file.path,
line: lineIndex + 1, // 1-indexed
column: columnIndex + 1, // 1-indexed
snippet: snippet,
matchRanges: [{
start: matchStart,
end: matchStart + matchText.length
}]
});
// Prevent infinite loop for zero-width matches
if (match[0].length === 0) {
pattern.lastIndex++;
}
}
}
return matches;
}
/**
* Search in filename
*/
private static searchInFilename(
file: TFile,
pattern: RegExp,
caseSensitive: boolean
): SearchMatch[] {
const matches: SearchMatch[] = [];
const basename = file.basename;
// Reset regex lastIndex
pattern.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(basename)) !== null) {
const columnIndex = match.index;
const matchText = match[0];
matches.push({
path: file.path,
line: 0, // 0 indicates filename match
column: columnIndex + 1, // 1-indexed
snippet: basename,
matchRanges: [{
start: columnIndex,
end: columnIndex + matchText.length
}]
});
// Prevent infinite loop for zero-width matches
if (match[0].length === 0) {
pattern.lastIndex++;
}
}
return matches;
}
/**
* Search for Waypoint markers in vault
*/
static async searchWaypoints(
vault: IVaultAdapter,
folder?: string
): Promise<Array<{
path: string;
line: number;
waypointRange: { start: number; end: number };
content: string;
links: string[];
}>> {
const results: Array<{
path: string;
line: number;
waypointRange: { start: number; end: number };
content: string;
links: string[];
}> = [];
// Get files to search
let files = vault.getMarkdownFiles();
// Filter by folder if specified
if (folder) {
const folderPath = folder.endsWith('/') ? folder : folder + '/';
files = files.filter(file =>
file.path.startsWith(folderPath) || file.path === folder
);
}
// Search for waypoint markers
const waypointStartPattern = /%% Begin Waypoint %%/;
const waypointEndPattern = /%% End Waypoint %%/;
const linkPattern = /\[\[([^\]]+)\]\]/g;
for (const file of files) {
try {
const content = await vault.read(file);
const lines = content.split('\n');
let inWaypoint = false;
let waypointStart = -1;
let waypointContent: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (waypointStartPattern.test(line)) {
inWaypoint = true;
waypointStart = i + 1; // 1-indexed
waypointContent = [];
} else if (waypointEndPattern.test(line) && inWaypoint) {
// Extract links from waypoint content
const contentStr = waypointContent.join('\n');
const links: string[] = [];
let linkMatch: RegExpExecArray | null;
while ((linkMatch = linkPattern.exec(contentStr)) !== null) {
links.push(linkMatch[1]);
}
results.push({
path: file.path,
line: waypointStart,
waypointRange: {
start: waypointStart,
end: i + 1 // 1-indexed
},
content: contentStr,
links: links
});
inWaypoint = false;
waypointStart = -1;
waypointContent = [];
} else if (inWaypoint) {
waypointContent.push(line);
}
}
} catch (error) {
// Skip files that can't be searched
}
}
return results;
}
}