UNPKG

roam-research-mcp

Version:
150 lines (149 loc) • 5.27 kB
/** * Roam Query Block Parser and Executor * * Parses Roam query block syntax and executes as Datalog queries * * Usage: * import { QueryExecutor } from './query/index.js'; * * const executor = new QueryExecutor(graph); * const results = await executor.execute('{{[[query]]: {and: [[Project]] [[TODO]]}}}'); */ export { QueryParser, QueryParseError } from './parser.js'; export { DatalogGenerator, buildDatalogQuery } from './generator.js'; import { q } from '@roam-research/roam-api-sdk'; import { QueryParser } from './parser.js'; import { DatalogGenerator, buildDatalogQuery } from './generator.js'; import { resolveRefs } from '../tools/helpers/refs.js'; export class QueryExecutor { constructor(graph) { this.graph = graph; this.generator = new DatalogGenerator(); } /** * Parse and execute a Roam query block * * @param queryBlock - The query block text, e.g., "{{[[query]]: {and: [[tag1]] [[tag2]]}}}" * @param options - Execution options (limit, offset, pageUid) * @returns Query results */ async execute(queryBlock, options = {}) { try { // Parse the query const ast = QueryParser.parse(queryBlock); // Generate Datalog clauses const clauses = this.generator.generate(ast); // Build the full query const { query, args } = buildDatalogQuery(clauses, { limit: options.limit, offset: options.offset, pageUid: options.pageUid, orderBy: options.orderBy || '?block-uid asc' }); // Execute query const rawResults = await q(this.graph, query, args); // Resolve block references in content const resolvedResults = await Promise.all(rawResults.map(async ([uid, content, pageTitle]) => { const resolvedContent = await resolveRefs(this.graph, content); return { block_uid: uid, content: resolvedContent, page_title: pageTitle }; })); // Get total count if pagination is used let totalCount = resolvedResults.length; if (options.limit !== undefined && options.limit !== -1) { const countQuery = this.buildCountQuery(clauses, options.pageUid); const countResults = await q(this.graph, countQuery.query, countQuery.args); totalCount = countResults[0]?.[0] ?? 0; } return { success: true, matches: resolvedResults, message: `Found ${resolvedResults.length} block(s) matching query`, total_count: totalCount, query // Include for debugging }; } catch (error) { return { success: false, matches: [], message: error instanceof Error ? error.message : String(error) }; } } /** * Parse a query block without executing (for validation/debugging) */ parse(queryBlock) { const ast = QueryParser.parse(queryBlock); const clauses = this.generator.generate(ast); const datalog = buildDatalogQuery(clauses); return { ast, datalog }; } buildCountQuery(clauses, pageUid) { let inClause = ':in $'; if (clauses.inputs.length > 0) { inClause += ' ' + clauses.inputs.join(' '); } if (pageUid) { inClause += ' ?target-page-uid'; } const baseClauses = [ '[?b :block/string ?block-str]', '[?b :block/uid ?block-uid]', '[?b :block/page ?p]' ]; if (pageUid) { baseClauses.push('[?p :block/uid ?target-page-uid]'); } const allWhereClauses = [...baseClauses, ...clauses.where]; const query = `[:find (count ?b) ${inClause} :where ${allWhereClauses.join('\n ')}]`; const args = [...clauses.inputValues]; if (pageUid) { args.push(pageUid); } return { query, args }; } } /** * Detect if a block string contains a query block */ export function isQueryBlock(text) { return /^\s*\{\{\[\[query\]\]:/i.test(text); } /** * Extract all query blocks from a string (handles nested braces) */ export function extractQueryBlocks(text) { const matches = []; const prefix = '{{[[query]]:'; let startIdx = 0; while (startIdx < text.length) { const foundIdx = text.indexOf(prefix, startIdx); if (foundIdx === -1) break; // Find matching closing }} let depth = 2; // We've seen {{ let pos = foundIdx + prefix.length; while (pos < text.length && depth > 0) { if (text[pos] === '{') { depth++; } else if (text[pos] === '}') { depth--; } pos++; } if (depth === 0) { matches.push(text.slice(foundIdx, pos)); } startIdx = pos; } return matches; }