UNPKG

roam-research-mcp

Version:
136 lines (135 loc) • 6.24 kB
import { TagSearchHandlerImpl, BlockRefSearchHandlerImpl, HierarchySearchHandlerImpl, TextSearchHandlerImpl, StatusSearchHandlerImpl, DatomicSearchHandlerImpl } from './handlers.js'; import { collectHiddenUids, filterHiddenMatches } from '../../helpers/hidden.js'; export class SearchOperations { constructor(graph) { this.graph = graph; } /** * Withhold blocks the user tagged #.rm-hide / #.rm-private, and blocks nested * under them. * * Search results are flat, so unlike a page tree they carry no ancestry — * hence the UID closure from `collectHiddenUids`. Applied to every * content-search path below, and deliberately NOT to `executeDatomicQuery`: * raw Datalog reads the database directly and stays raw, matching the * official Roam toolset. That is also why these tags are a convenience * filter, not a security boundary. */ async withoutHidden(result) { if (!result?.matches?.length) return result; const hidden = await collectHiddenUids(this.graph); const matches = filterHiddenMatches(result.matches, hidden); const removed = result.matches.length - matches.length; if (removed === 0) return result; // Some handlers attach a total_count that isn't on SearchHandlerResult; // keep it consistent with the matches we actually return rather than // reporting a total that includes withheld blocks. const withCount = result; return { ...result, matches, ...(typeof withCount.total_count === 'number' ? { total_count: Math.max(0, withCount.total_count - removed) } : {}), }; } async searchByStatus(status, page_title_uid, include, exclude) { const handler = new StatusSearchHandlerImpl(this.graph, { status, page_title_uid, }); const result = await this.withoutHidden(await handler.execute()); // Post-process results with include/exclude filters let matches = result.matches; if (include) { const includeTerms = include.split(',').map(term => term.trim()); matches = matches.filter((match) => { const matchContent = match.content; const matchTitle = match.page_title; const terms = includeTerms; return terms.some(term => matchContent.includes(term) || (matchTitle && matchTitle.includes(term))); }); } if (exclude) { const excludeTerms = exclude.split(',').map(term => term.trim()); matches = matches.filter((match) => { const matchContent = match.content; const matchTitle = match.page_title; const terms = excludeTerms; return !terms.some(term => matchContent.includes(term) || (matchTitle && matchTitle.includes(term))); }); } return { success: true, matches, message: `Found ${matches.length} block(s) with status ${status}${include ? ` including "${include}"` : ''}${exclude ? ` excluding "${exclude}"` : ''}` }; } async searchForTag(primary_tag, page_title_uid, near_tag) { const handler = new TagSearchHandlerImpl(this.graph, { primary_tag, page_title_uid, near_tag, }); return this.withoutHidden(await handler.execute()); } async searchBlockRefs(params) { const handler = new BlockRefSearchHandlerImpl(this.graph, params); return this.withoutHidden(await handler.execute()); } async searchHierarchy(params) { const handler = new HierarchySearchHandlerImpl(this.graph, params); return this.withoutHidden(await handler.execute()); } async searchByText(params) { const handler = new TextSearchHandlerImpl(this.graph, params); return this.withoutHidden(await handler.execute()); } async executeDatomicQuery(params) { // Intentionally NOT filtered — raw Datalog reads the database directly and // stays raw. See the note on withoutHidden(): the hide tags are a // convenience filter for the content tools, not a security boundary. const handler = new DatomicSearchHandlerImpl(this.graph, params); return handler.execute(); } async searchByDate(params) { // Convert dates to timestamps const startTimestamp = new Date(`${params.start_date}T00:00:00`).getTime(); const endTimestamp = params.end_date ? new Date(`${params.end_date}T23:59:59`).getTime() : undefined; // Use text search handler for content-based filtering const handler = new TextSearchHandlerImpl(this.graph, { text: '', // Empty text to match all blocks }); // Filtered before the map below, which renames block_uid -> uid; after it // the hidden-UID closure would have nothing to match on. const result = await this.withoutHidden(await handler.execute()); // Filter results by date const matches = result.matches .filter(match => { const time = params.type === 'created' ? new Date(match.content || '').getTime() : // Use content date for creation time Date.now(); // Use current time for modification time (simplified) return time >= startTimestamp && (!endTimestamp || time <= endTimestamp); }) .map(match => ({ uid: match.block_uid, type: 'block', time: params.type === 'created' ? new Date(match.content || '').getTime() : Date.now(), ...(params.include_content && { content: match.content }), page_title: match.page_title })); // Sort by time const sortedMatches = matches.sort((a, b) => b.time - a.time); return { success: true, matches: sortedMatches, message: `Found ${sortedMatches.length} matches for the given date range and criteria` }; } }