roam-research-mcp
Version:
MCP server and CLI for Roam Research
136 lines (135 loc) • 6.24 kB
JavaScript
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`
};
}
}