UNPKG

roam-research-mcp

Version:
679 lines (661 loc) • 32.8 kB
import { Command } from 'commander'; import { PageOperations } from '../../tools/operations/pages.js'; import { BlockRetrievalOperations } from '../../tools/operations/block-retrieval.js'; import { SearchOperations } from '../../tools/operations/search/index.js'; import { FullPageViewOperations } from '../../tools/operations/full-page-view.js'; import { formatPageOutput, formatBlockOutput, formatTodoOutput, formatGroupedOutput, flattenBlocks, blocksToMarkdown, printDebug, exitWithError } from '../utils/output.js'; import { resolveGraph } from '../utils/graph.js'; import { readStdin } from '../utils/input.js'; import { resolveRefs } from '../../tools/helpers/refs.js'; import { resolveRelativeDate, parseRoamUrl, isRoamUid } from '../../utils/helpers.js'; import { SearchUtils } from '../../search/utils.js'; import { sortResults, groupResults, getDefaultDirection } from '../utils/sort-group.js'; // Block UID pattern: 9 alphanumeric characters, optionally wrapped in (( )) const BLOCK_UID_PATTERN = /^(?:\(\()?([a-zA-Z0-9_-]{9})(?:\)\))?$/; /** * Recursively resolve block references in a RoamBlock tree */ async function resolveBlockRefsInTree(graph, block, maxDepth) { // Only resolve if string is valid const resolvedString = typeof block.string === 'string' ? await resolveRefs(graph, block.string, 0, maxDepth) : block.string || ''; const resolvedChildren = await Promise.all((block.children || []).map(child => resolveBlockRefsInTree(graph, child, maxDepth))); return { ...block, string: resolvedString, children: resolvedChildren }; } /** * Resolve refs in an array of blocks */ async function resolveBlocksRefsInTree(graph, blocks, maxDepth) { return Promise.all(blocks.map(block => resolveBlockRefsInTree(graph, block, maxDepth))); } /** * Normalize a tag by stripping #, [[, ]] wrappers */ function normalizeTag(tag) { return tag.replace(/^#?\[?\[?/, '').replace(/\]?\]?$/, ''); } /** * Check if content contains a tag (handles #tag, [[tag]], #[[tag]] formats) * Case-insensitive matching. */ function contentHasTag(content, tag) { const normalized = normalizeTag(tag).toLowerCase(); const lowerContent = content.toLowerCase(); return (lowerContent.includes(`[[${normalized}]]`) || lowerContent.includes(`#${normalized}`) || lowerContent.includes(`#[[${normalized}]]`)); } /** * Create the 'page' subcommand for explicit page retrieval */ function createPageSubcommand() { return new Command('page') .description('Fetch a page by UID, URL, or title') .argument('<identifier>', 'Page UID, Roam URL, or page title') .option('-j, --json', 'Output as JSON instead of markdown') .option('-d, --depth <n>', 'Child levels to fetch (default: 4)', '4') .option('-r, --refs [n]', 'Expand ((uid)) refs in output (default depth: 1, max: 4)') .option('-f, --flat', 'Flatten hierarchy to single-level list') .option('-u, --uid', 'Return only the page UID') .option('-g, --graph <name>', 'Target graph key (multi-graph mode)') .option('--debug', 'Show query metadata') .addHelpText('after', ` Examples: # By page title roam get page "Project Notes" roam get page "January 10th, 2026" # By page UID roam get page abc123def # By Roam URL (copy from browser) roam get page "https://roamresearch.com/#/app/my-graph/page/abc123def" # Get just the page UID roam get page "Project Notes" --uid `) .action(async (identifier, options) => { try { const graph = resolveGraph(options, false); const depth = parseInt(options.depth || '4', 10); const refsDepth = options.refs !== undefined ? Math.min(4, Math.max(1, parseInt(options.refs, 10) || 1)) : 0; const outputOptions = { json: options.json, flat: options.flat, debug: options.debug }; if (options.debug) { printDebug('Identifier', identifier); printDebug('Graph', options.graph || 'default'); } // Resolve identifier to page UID let pageUid = null; let pageTitle = null; // 1. Check if it's a Roam URL const urlParsed = parseRoamUrl(identifier); if (urlParsed) { pageUid = urlParsed.uid; if (options.debug) { printDebug('Parsed URL', { uid: pageUid, graph: urlParsed.graph }); } } // 2. Check if it's a direct UID else if (isRoamUid(identifier)) { pageUid = identifier; if (options.debug) { printDebug('Direct UID', pageUid); } } // 3. Otherwise treat as page title else { pageTitle = resolveRelativeDate(identifier); if (options.debug && pageTitle !== identifier) { printDebug('Resolved date', `${identifier} → ${pageTitle}`); } } const pageOps = new PageOperations(graph); // If --uid flag, just return the UID if (options.uid) { if (pageUid) { console.log(pageUid); } else if (pageTitle) { const uid = await pageOps.getPageUid(pageTitle); if (!uid) { exitWithError(`Page "${pageTitle}" not found`); } console.log(uid); } return; } // Fetch page content let blocks; let displayTitle; if (pageUid) { // Fetch by UID - first need to get page title for display const result = await pageOps.fetchPageByUid(pageUid); if (!result) { exitWithError(`Page with UID "${pageUid}" not found`); } blocks = result.blocks; displayTitle = result.title; } else if (pageTitle) { // Fetch by title const result = await pageOps.fetchPageByTitle(pageTitle, 'raw'); if (typeof result === 'string') { try { blocks = JSON.parse(result); } catch { exitWithError(result); return; } } else { blocks = result; } displayTitle = pageTitle; } else { exitWithError('Could not parse identifier'); return; } // Resolve block references if requested if (refsDepth > 0) { blocks = await resolveBlocksRefsInTree(graph, blocks, refsDepth); } console.log(formatPageOutput(displayTitle, blocks, outputOptions)); } catch (error) { const message = error instanceof Error ? error.message : String(error); exitWithError(message); } }); } /** * Create the 'full' subcommand for full page view (content + linked references) */ function createFullSubcommand() { return new Command('full') .description('Fetch full page view: content + linked references with breadcrumb context') .argument('<title>', 'Page title (for date pages use "January 2nd, 2025")') .option('-d, --depth <n>', 'Child depth for referring blocks (default: 4)', '4') .option('-n, --max-refs <n>', 'Max linked references (default: 200)', '200') .option('-g, --graph <name>', 'Target graph key (multi-graph mode)') .option('--debug', 'Show debug information') .addHelpText('after', ` Examples: roam get full "Project Notes" # Full view with backlinks roam get full "TODO" -n 50 # Cap references at 50 roam get full "Meeting Notes" -d 2 # Shallow children depth `) .action(async (title, options) => { try { const resolvedTitle = resolveRelativeDate(title); const depth = parseInt(options.depth || '4', 10); const maxRefs = parseInt(options.maxRefs || '200', 10); if (options.debug) { printDebug('Title', resolvedTitle); printDebug('Children depth', depth); printDebug('Max references', maxRefs); printDebug('Graph', options.graph || 'default'); } const graph = resolveGraph(options, false); const pageOps = new PageOperations(graph); const fullViewOps = new FullPageViewOperations(graph, pageOps); const result = await fullViewOps.fetchPageFullView(resolvedTitle, depth, maxRefs); console.log(result); } catch (error) { const message = error instanceof Error ? error.message : String(error); exitWithError(message); } }); } /** * Create the 'subpages' subcommand for namespace-based sub-page listing */ function createSubpagesSubcommand() { return new Command('subpages') .description('List sub-pages under a namespace prefix (e.g. "Project/", "Framework/")') .argument('<prefix>', 'Namespace prefix (trailing "/" added automatically)') .option('--filter-tag <tag>', 'Only sub-pages containing this tag') .option('--content', 'Include each sub-page\'s block content') .option('-g, --graph <name>', 'Target graph key (multi-graph mode)') .option('--debug', 'Show debug information') .addHelpText('after', ` Examples: roam get subpages "Project" # List all Project/* pages roam get subpages "Framework" --content # With block content roam get subpages "Project" --filter-tag active # Only active projects `) .action(async (prefix, options) => { try { if (options.debug) { printDebug('Prefix', prefix); printDebug('Filter tag', options.filterTag || 'none'); printDebug('Include content', options.content || false); printDebug('Graph', options.graph || 'default'); } const graph = resolveGraph(options, false); const pageOps = new PageOperations(graph); const fullViewOps = new FullPageViewOperations(graph, pageOps); const result = await fullViewOps.fetchSubPages(prefix, options.filterTag, options.content); console.log(result); } catch (error) { const message = error instanceof Error ? error.message : String(error); exitWithError(message); } }); } export function createGetCommand() { const cmd = new Command('get') .description('Fetch pages, blocks, or TODO/DONE items with optional ref expansion') .argument('[target]', 'Page title, block UID, or relative date. Reads from stdin if "-" or omitted.') .option('-j, --json', 'Output as JSON instead of markdown') .option('-s, --structure', 'Output flattened structure with UIDs and order (for surgical updates)') .option('-d, --depth <n>', 'Child levels to fetch (default: 4, 0 = no children)', '4') .option('-a, --ancestors', 'Include ancestor chain up to page root') .option('-r, --refs [n]', 'Expand ((uid)) refs in output (default depth: 1, max: 4)') .option('-f, --flat', 'Flatten hierarchy to single-level list') .option('-u, --uid', 'Return only the page UID (resolve title to UID)') .option('--todo', 'Fetch TODO items') .option('--done', 'Fetch DONE items') .option('-p, --page <ref>', 'Scope to page title or UID (for TODOs, tags, text)') .option('-i, --include <terms>', 'Include items matching these terms (comma-separated)') .option('-e, --exclude <terms>', 'Exclude items matching these terms (comma-separated)') .option('--tag <tag>', 'Get blocks with tag (repeatable, comma-separated)', (val, prev) => { const tags = val.split(',').map(t => t.trim()).filter(Boolean); return prev ? [...prev, ...tags] : tags; }, []) .option('--text <text>', 'Get blocks containing text') .option('--any', 'Use OR logic for multiple tags (default is AND)') .option('--negtag <tag>', 'Exclude blocks with tag (repeatable, comma-separated)', (val, prev) => { const tags = val.split(',').map(t => t.trim()).filter(Boolean); return prev ? [...prev, ...tags] : tags; }, []) .option('-n, --limit <n>', 'Limit number of blocks fetched (default: 20 for tag/text)', '20') .option('--showall', 'Show all results (no limit)') .option('--sort <field>', 'Sort results by: created, modified, page') .option('--asc', 'Sort ascending (default for page)') .option('--desc', 'Sort descending (default for dates)') .option('--group-by <field>', 'Group results by: page, tag') .option('-g, --graph <name>', 'Target graph key (multi-graph mode)') .option('--debug', 'Show query metadata') .addHelpText('after', ` Examples: # Fetch pages roam get "Project Notes" # Page by title roam get today # Today's daily page roam get yesterday # Yesterday's daily page # Fetch page by UID or URL (see 'roam get page --help') roam get page abc123def # Page by UID roam get page "https://roamresearch.com/#/app/my-graph/page/abc123def" # Resolve page title to UID roam get "Project Notes" --uid # Returns just the page UID roam get today -u # Today's daily page UID # Fetch blocks roam get abc123def # Block by UID roam get "((abc123def))" # UID with wrapper # Stdin / Batch Retrieval echo "Project A" | roam get # Pipe page title echo "abc123def" | roam get # Pipe block UID cat uids.txt | roam get --json # Fetch multiple blocks (NDJSON output) # Output options roam get "Page" -j # JSON output roam get "Page" -f # Flat list (no hierarchy) roam get abc123def -d 2 # Limit depth to 2 levels roam get abc123def -a # Include ancestors up to page root roam get abc123def -a -d 0 # Ancestors only, no children roam get "Page" -r # Expand block refs (depth 1) roam get "Page" -r 3 # Expand refs up to 3 levels deep # TODO/DONE items (refs auto-expanded) roam get --todo # All TODOs across graph roam get --done # All completed items roam get --todo -p "Work" # TODOs on "Work" page # Tag-based retrieval (returns blocks with children) roam get --tag TODO # Blocks tagged with #TODO roam get --tag Project,Active # Blocks with both tags (AND) roam get --tag Project --tag Active --any # Blocks with either tag (OR) roam get --tag Task --negtag Done # Tasks excluding Done roam get --tag Meeting -p "Work" # Meetings on Work page # Text-based retrieval roam get --text "urgent" # Blocks containing "urgent" roam get --text "meeting" --tag Project # Combine text + tag filter roam get --text "TODO" -p today # Text search on today's page # Sorting roam get --tag Convention --sort created # Sort by creation date (newest first) roam get --todo --sort modified --asc # Sort by edit date (oldest first) roam get --tag Project --sort page # Sort alphabetically by page # Grouping roam get --tag Convention --group-by page # Group by source page roam get --tag Convention --group-by tag # Group by subtags (Convention/*) # Combined roam get --tag Convention --group-by tag --sort modified Output format: Markdown: Content with hierarchy (no UIDs). Use --json for UIDs. JSON: Full block structure including uid field. JSON output fields: Page: { title, children: [Block...] } Block: { uid, string, order, heading?, children: [Block...] } TODO/DONE: [{ block_uid, content, page_title }] Tag/Text: [{ uid, string, order, heading?, children: [...] }] Note: For flat results with UIDs, use 'roam search' instead. `) .action(async (target, options) => { try { const graph = resolveGraph(options, false); const depth = parseInt(options.depth || '4', 10); const ancestors = options.ancestors || false; // Parse refs: true/string means enabled, number sets max depth (default 1, max 4) const refsDepth = options.refs !== undefined ? Math.min(4, Math.max(1, parseInt(options.refs, 10) || 1)) : 0; const outputOptions = { json: options.json, flat: options.flat, debug: options.debug }; if (options.debug) { printDebug('Target', target || 'stdin'); printDebug('Graph', options.graph || 'default'); printDebug('Options', { depth, refs: refsDepth || 'off', uid: options.uid || false, ...outputOptions }); } // Handle --uid flag: return just the page UID if (options.uid) { if (!target || target === '-') { exitWithError('--uid requires a page title argument'); } const resolvedTarget = resolveRelativeDate(target); if (options.debug && resolvedTarget !== target) { printDebug('Resolved date', `${target} → ${resolvedTarget}`); } // Check if target is already a block UID const uidMatch = resolvedTarget.match(BLOCK_UID_PATTERN); if (uidMatch) { // Already a UID, just output it console.log(uidMatch[1]); return; } const pageOps = new PageOperations(graph); const pageUid = await pageOps.getPageUid(resolvedTarget); if (!pageUid) { exitWithError(`Page "${resolvedTarget}" not found`); } console.log(pageUid); return; } // Handle --structure flag: return flattened structure for surgical updates if (options.structure) { if (!target || target === '-') { exitWithError('--structure requires a page title argument'); } const resolvedTarget = resolveRelativeDate(target); if (options.debug && resolvedTarget !== target) { printDebug('Resolved date', `${target} → ${resolvedTarget}`); } const pageOps = new PageOperations(graph); const result = await pageOps.fetchPageByTitle(resolvedTarget, 'structure'); console.log(result); return; } // Parse sort/group options const sortField = options.sort; const groupByField = options.groupBy; const sortDirection = sortField ? (options.asc ? 'asc' : options.desc ? 'desc' : getDefaultDirection(sortField)) : undefined; // Handle --todo or --done flags (these ignore target arg usually, but could filter by page if target is used as page?) // The help says "-p" is for page. So we strictly follow flags. if (options.todo || options.done) { const status = options.todo ? 'TODO' : 'DONE'; if (options.debug) { printDebug('Status search', { status, page: options.page, include: options.include, exclude: options.exclude }); if (sortField) printDebug('Sort', { field: sortField, direction: sortDirection }); if (groupByField) printDebug('Group by', groupByField); } const searchOps = new SearchOperations(graph); const result = await searchOps.searchByStatus(status, options.page, options.include, options.exclude); let matches = result.matches; // Apply sorting if (sortField && sortDirection) { matches = sortResults(matches, { field: sortField, direction: sortDirection }); } // Apply grouping if (groupByField) { // For TODO/DONE, only page grouping makes sense (no tags on search results) if (groupByField === 'tag') { exitWithError('--group-by tag is not supported for TODO/DONE search. Use --group-by page instead.'); } const grouped = groupResults(matches, { by: groupByField }); console.log(formatGroupedOutput(grouped, outputOptions)); } else { console.log(formatTodoOutput(matches, status, outputOptions)); } return; } // Handle --tag and/or --text flags (search-based retrieval with full children) const tags = options.tag || []; if (tags.length > 0 || options.text) { const searchOps = new SearchOperations(graph); const blockOps = new BlockRetrievalOperations(graph); const limit = options.showall ? Infinity : parseInt(options.limit || '20', 10); const useOrLogic = options.any || false; // Resolve page scope const pageScope = options.page ? resolveRelativeDate(options.page) : undefined; if (options.debug) { printDebug('Tag/Text search', { tags, text: options.text, page: pageScope, any: useOrLogic, negtag: options.negtag, limit }); if (sortField) printDebug('Sort', { field: sortField, direction: sortDirection }); if (groupByField) printDebug('Group by', groupByField); } // Get initial matches let matches = []; if (options.text) { // Text search const result = await searchOps.searchByText({ text: options.text, page_title_uid: pageScope }); matches = result.matches; } else if (tags.length > 0) { // Tag search (use first tag as primary) const normalizedTags = tags.map(normalizeTag); const result = await searchOps.searchForTag(normalizedTags[0], pageScope); matches = result.matches; } // Apply additional tag filters if (tags.length > 0 && matches.length > 0) { const normalizedTags = tags.map(normalizeTag); // For text search with tags, filter by ALL tags // For tag search with multiple tags, filter by remaining tags based on --any if (options.text || normalizedTags.length > 1) { matches = matches.filter(m => { if (useOrLogic) { return normalizedTags.some(tag => contentHasTag(m.content, tag)); } else { return normalizedTags.every(tag => contentHasTag(m.content, tag)); } }); } } // Apply negative tag filter const negTags = options.negtag || []; if (negTags.length > 0) { const normalizedNegTags = negTags.map(normalizeTag); matches = matches.filter(m => !normalizedNegTags.some(tag => contentHasTag(m.content, tag))); } // Apply sorting before limit (so we get the top N sorted items) if (sortField && sortDirection) { matches = sortResults(matches, { field: sortField, direction: sortDirection }); } // Apply limit const limitedMatches = matches.slice(0, limit); if (limitedMatches.length === 0) { console.log(options.json ? '[]' : 'No blocks found matching criteria.'); return; } // For tag grouping, fetch all tags for matched blocks if (groupByField === 'tag') { const blockUids = limitedMatches.map(m => m.block_uid); const tagMap = await SearchUtils.fetchBlockTags(graph, blockUids); // Attach tags to matches for (const match of limitedMatches) { match.tags = tagMap.get(match.block_uid) || []; } // Group and output const primaryTag = tags.length > 0 ? normalizeTag(tags[0]) : ''; const grouped = groupResults(limitedMatches, { by: 'tag', searchTag: primaryTag }); console.log(formatGroupedOutput(grouped, outputOptions)); return; } // For page grouping, output grouped matches if (groupByField === 'page') { const grouped = groupResults(limitedMatches, { by: 'page' }); console.log(formatGroupedOutput(grouped, outputOptions)); return; } // Standard output: fetch full blocks with children const blocks = []; for (const match of limitedMatches) { let block = await blockOps.fetchBlock(match.block_uid, depth, ancestors); if (block) { // Resolve refs if requested (default: enabled for tag/text search) const effectiveRefsDepth = refsDepth > 0 ? refsDepth : 1; block = await resolveBlockRefsInTree(graph, block, effectiveRefsDepth); blocks.push(block); } } // Output if (options.json) { const data = options.flat ? blocks.flatMap(b => flattenBlocks([b])) : blocks; console.log(JSON.stringify(data, null, 2)); } else { const displayBlocks = options.flat ? blocks.flatMap(b => flattenBlocks([b])) : blocks; // Show count header const countMsg = matches.length > limit ? `Found ${matches.length} blocks (showing first ${limit}):\n\n` : `Found ${blocks.length} block(s):\n\n`; console.log(countMsg + blocksToMarkdown(displayBlocks)); } return; } // Determine targets let targets = []; if (target && target !== '-') { targets = [target]; } else { // Read from stdin if no target or explicit '-' if (process.stdin.isTTY && target !== '-') { // If TTY and no target, show error exitWithError('Target is required. Use: roam get <page-title>, roam get --todo, roam get --tag <tag>, roam get --text <text>, or pipe targets via stdin'); } const input = await readStdin(); if (input) { targets = input.split('\n').map(t => t.trim()).filter(Boolean); } } if (targets.length === 0) { exitWithError('No targets provided'); } // Helper to process a single target const processTarget = async (item) => { // Resolve relative date keywords (today, yesterday, tomorrow) const resolvedTarget = resolveRelativeDate(item); if (options.debug && resolvedTarget !== item) { printDebug('Resolved date', `${item} → ${resolvedTarget}`); } // Check if target is a block UID const uidMatch = resolvedTarget.match(BLOCK_UID_PATTERN); if (uidMatch) { // Fetch block by UID const blockUid = uidMatch[1]; if (options.debug) printDebug('Fetching block', { uid: blockUid }); const blockOps = new BlockRetrievalOperations(graph); let block = await blockOps.fetchBlock(blockUid, depth, ancestors); if (!block) { // If fetching multiple, maybe warn instead of exit? // For now, consistent behavior: print error message to stderr but continue? // Or simpler: just return a "not found" string/object. // formatBlockOutput doesn't handle null. return options.json ? JSON.stringify({ error: `Block ${blockUid} not found` }) : `Block ${blockUid} not found`; } // Resolve block references if requested if (refsDepth > 0) { block = await resolveBlockRefsInTree(graph, block, refsDepth); } return formatBlockOutput(block, outputOptions); } else { // Fetch page by title if (options.debug) printDebug('Fetching page', { title: resolvedTarget }); const pageOps = new PageOperations(graph); const result = await pageOps.fetchPageByTitle(resolvedTarget, 'raw'); // Parse the raw result let blocks; if (typeof result === 'string') { try { blocks = JSON.parse(result); } catch { // Result is already formatted as string (e.g., "Page Title (no content found)") // But wait, fetchPageByTitle returns string if not found or empty? // Actually fetchPageByTitle 'raw' returns JSON string of blocks OR empty array JSON string? // Let's assume result is valid JSON or error message string. return options.json ? JSON.stringify({ title: resolvedTarget, error: result }) : result; } } else { blocks = result; } // Resolve block references if requested if (refsDepth > 0) { blocks = await resolveBlocksRefsInTree(graph, blocks, refsDepth); } return formatPageOutput(resolvedTarget, blocks, outputOptions); } }; // Execute sequentially for (const t of targets) { const output = await processTarget(t); console.log(output); } } catch (error) { const message = error instanceof Error ? error.message : String(error); exitWithError(message); } }); // Add subcommands cmd.addCommand(createPageSubcommand()); cmd.addCommand(createFullSubcommand()); cmd.addCommand(createSubpagesSubcommand()); return cmd; }