UNPKG

roam-research-mcp

Version:
318 lines (317 loc) • 15.5 kB
import { q } from '@roam-research/roam-api-sdk'; import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; import { getPageUid as getPageUidHelper } from '../helpers/page-resolution.js'; import { resolveRefs } from '../helpers/refs.js'; import { fetchChildrenByDepth } from '../helpers/fetch-children.js'; import { collectHiddenUids, pruneHiddenBlocks, isHiddenBlockString } from '../helpers/hidden.js'; import { escapeBlockString } from '../../shared/block-escaping.js'; export class FullPageViewOperations { constructor(graph, pageOps) { this.graph = graph; this.pageOps = pageOps; } async fetchPageFullView(title, children_depth = 4, max_references = 200) { // 1. Get page UID const pageUid = await getPageUidHelper(this.graph, title); if (!pageUid) { throw new McpError(ErrorCode.InvalidRequest, `Page "${title}" not found`); } // 2. Fetch page's own blocks (reuse PageOperations to avoid duplication) const pageResult = await this.pageOps.fetchPageByUid(pageUid); const pageBlocks = pageResult?.blocks ?? []; // 3. Fetch all referring blocks (backlinks) const refResults = await this.fetchReferringBlocks(title); // Deduplicate by block_uid, then cap at max_references const seenUids = new Set(); const dedupedRefs = refResults.filter(r => { if (seenUids.has(r.block_uid)) return false; seenUids.add(r.block_uid); return true; }); // Backlinks come from other pages, so the page's own prune (pageBlocks // above, already filtered by fetchPageByUid) does not cover them. A // referring block may itself be tagged, or sit under a tagged parent // elsewhere — hence the UID closure rather than a text check alone. // Filtered BEFORE the max_references cap so truncation counts what the // caller can actually see. const hiddenUids = await collectHiddenUids(this.graph); const allUniqueRefs = dedupedRefs.filter(r => !hiddenUids.has(r.block_uid) && !isHiddenBlockString(r.block_str)); const truncated = allUniqueRefs.length > max_references; const uniqueRefs = truncated ? allUniqueRefs.slice(0, max_references) : allUniqueRefs; const refBlockUids = uniqueRefs.map(r => r.block_uid); // 4. Fetch breadcrumbs (up) and children (down) in parallel const [breadcrumbsMap, childrenMap] = await Promise.all([ this.fetchBreadcrumbs(refBlockUids), fetchChildrenByDepth(this.graph, refBlockUids, children_depth) ]); // 5. Resolve ((uid)) refs in all text before rendering // Page blocks const allPageBlocks = []; const collectBlocks = (blocks) => { for (const b of blocks) { allPageBlocks.push(b); collectBlocks(b.children); } }; collectBlocks(pageBlocks); await Promise.all(allPageBlocks.map(async (b) => { b.string = await resolveRefs(this.graph, b.string); })); // Referring block strings const resolvedRefStrings = new Map(); await Promise.all(uniqueRefs.map(async (r) => { resolvedRefStrings.set(r.block_uid, await resolveRefs(this.graph, r.block_str)); })); // Breadcrumb strings for (const uid of refBlockUids) { const crumbs = breadcrumbsMap[uid] || []; await Promise.all(crumbs.map(async (crumb) => { crumb.string = await resolveRefs(this.graph, crumb.string); })); } // Children strings const allChildBlocks = []; for (const uid of refBlockUids) { const traverse = (blocks) => { for (const b of blocks) { allChildBlocks.push(b); traverse(b.children); } }; traverse(childrenMap[uid] || []); } await Promise.all(allChildBlocks.map(async (b) => { b.string = await resolveRefs(this.graph, b.string); })); // 6. Group by source page const groupMap = new Map(); for (const ref of uniqueRefs) { const key = ref.source_page_uid; if (!groupMap.has(key)) { groupMap.set(key, { source_page_title: ref.source_page_title, source_page_uid: ref.source_page_uid, references: [] }); } const refBlock = { uid: ref.block_uid, string: resolvedRefStrings.get(ref.block_uid) || ref.block_str, order: 0, // Children of a visible backlink can still contain tagged subtrees. children: pruneHiddenBlocks(childrenMap[ref.block_uid] || []) }; groupMap.get(key).references.push({ breadcrumbs: breadcrumbsMap[ref.block_uid] || [], block: refBlock }); } const linkedReferenceGroups = Array.from(groupMap.values()); // 7. Render as markdown. This output is display-only — never valid // `roam_update_page_markdown` input, and nothing decodes it — so // `renderBlocks` applies the `⏎` sentinel to every block string // unconditionally (it's the identity for newline-free text) rather than // gating on a per-page predicate. return this.renderMarkdown(title, pageBlocks, linkedReferenceGroups, truncated ? allUniqueRefs.length : undefined); } // ─── Private: fetch all blocks that reference this page ────────────────────── async fetchReferringBlocks(title) { const query = `[:find ?block-uid ?block-str ?page-title ?page-uid :in $ ?target-title :where [?target :node/title ?target-title] [?b :block/refs ?target] [?b :block/uid ?block-uid] [?b :block/string ?block-str] [?b :block/page ?p] [?p :node/title ?page-title] [?p :block/uid ?page-uid]]`; const results = await q(this.graph, query, [title]); if (!results || results.length === 0) return []; return results.map(([block_uid, block_str, source_page_title, source_page_uid]) => ({ block_uid, block_str, source_page_title, source_page_uid })); } // ─── Private: walk UP the parent chain to collect breadcrumbs ──────────────── // // Batches all UIDs at the same depth into a single query per level. // Page nodes have :node/title not :block/string → they won't match → natural stop. async fetchBreadcrumbs(blockUids) { if (blockUids.length === 0) return {}; const parentQuery = `[:find ?child-uid ?parent-uid ?parent-str :in $ [?child-uid ...] :where [?child :block/uid ?child-uid] [?parent :block/children ?child] [?parent :block/uid ?parent-uid] [?parent :block/string ?parent-str]]`; // child → { uid, string } of its block parent const childToParent = new Map(); let currentLevel = [...blockUids]; while (currentLevel.length > 0) { const results = await q(this.graph, parentQuery, [currentLevel]); if (!results || results.length === 0) break; const nextLevel = []; for (const [childUid, parentUid, parentStr] of results) { if (!childToParent.has(childUid)) { childToParent.set(childUid, { uid: parentUid, string: parentStr }); nextLevel.push(parentUid); } } currentLevel = nextLevel; } // Reconstruct root-first breadcrumb chains per referring block const result = {}; for (const refUid of blockUids) { const chain = []; let current = refUid; const seen = new Set(); while (childToParent.has(current) && !seen.has(current)) { seen.add(current); const parent = childToParent.get(current); chain.unshift({ uid: parent.uid, string: parent.string }); // prepend = root first current = parent.uid; } result[refUid] = chain; } return result; } // ─── Public: fetch sub-pages (namespace children) ──────────────────────────── async fetchSubPages(prefix, filter_tag, include_content = false) { const normalizedPrefix = prefix.endsWith('/') ? prefix : `${prefix}/`; let pages; if (filter_tag) { const query = `[:find ?title ?uid :in $ ?prefix ?tag :where [?p :node/title ?title] [?p :block/uid ?uid] [(clojure.string/starts-with? ?title ?prefix)] [?tag-page :node/title ?tag] [?b :block/refs ?tag-page] [?b :block/page ?p]]`; pages = await q(this.graph, query, [normalizedPrefix, filter_tag]); } else { const query = `[:find ?title ?uid :in $ ?prefix :where [?p :node/title ?title] [?p :block/uid ?uid] [(clojure.string/starts-with? ?title ?prefix)]]`; pages = await q(this.graph, query, [normalizedPrefix]); } if (!pages || pages.length === 0) { return `No sub-pages found for "${normalizedPrefix}"${filter_tag ? ` with tag "[[${filter_tag}]]"` : ''}.`; } pages.sort((a, b) => a[0].localeCompare(b[0])); const lines = []; lines.push(`# Sub-pages of "${prefix}" (${pages.length})`); if (filter_tag) lines.push(`*Filtered by tag: [[${filter_tag}]]*`); lines.push(''); if (include_content) { for (const [title, uid] of pages) { lines.push(`## [[${title}]]`); lines.push(''); const pageData = await this.pageOps.fetchPageByUid(uid); const blocks = pageData?.blocks ?? []; if (blocks.length > 0) { lines.push(this.renderBlocks(blocks, 0)); } else { lines.push('*(no content)*'); } lines.push(''); } } else { for (const [title] of pages) { lines.push(`- [[${title}]]`); } } return lines.join('\n'); } // ─── Private: render the full view as markdown ─────────────────────────────── // NOTE: Unit tests for renderMarkdown, renderBlocks, and the breadcrumb chain // reconstruction logic were considered but not added. Doing so would require // extracting these private methods into exported module-level functions, which // changes the class encapsulation. Left as a decision for the project owner. renderMarkdown(title, pageBlocks, linkedRefs, totalAvailable) { const lines = []; // Page header and own content lines.push(`# ${title}`); lines.push(''); if (pageBlocks.length > 0) { lines.push(this.renderBlocks(pageBlocks, 0)); } else { lines.push('*(no content)*'); } // Linked references section const totalRefs = linkedRefs.reduce((sum, g) => sum + g.references.length, 0); const truncationNote = totalAvailable !== undefined ? ` — *capped at ${totalRefs} of ${totalAvailable}; use max_references to increase*` : ''; lines.push(''); lines.push('---'); lines.push(''); lines.push(`## Linked References (${totalRefs} reference${totalRefs !== 1 ? 's' : ''} from ${linkedRefs.length} page${linkedRefs.length !== 1 ? 's' : ''}${truncationNote})`); if (totalRefs === 0) { lines.push(''); lines.push('*(no linked references)*'); } else { for (const group of linkedRefs) { lines.push(''); lines.push('---'); lines.push(''); lines.push(`### [[${group.source_page_title}]]`); lines.push(''); for (const ref of group.references) { // Breadcrumbs rendered as nested blockquotes (one > per level) // This mirrors Roam's ancestor context display for (let i = 0; i < ref.breadcrumbs.length; i++) { const prefix = '> '.repeat(i + 1); // Same reasoning as `renderBlocks` below: a breadcrumb string can // itself carry a soft line break, and an unescaped one spills onto // a bare physical line with no `> ` prefix at all. lines.push(`${prefix}${escapeBlockString(ref.breadcrumbs[i].string)}`); } // The referring block itself, indented to sit visually under its // breadcrumbs. Escaped for the same reason `renderBlocks` escapes // every other block string in this file: an unescaped newline spills // the rest of the block onto a bare physical line with no bullet. const refIndent = ' '.repeat(ref.breadcrumbs.length); lines.push(`${refIndent}- ${escapeBlockString(ref.block.string)}`); // Children of the referring block if (ref.block.children.length > 0) { lines.push(this.renderBlocks(ref.block.children, ref.breadcrumbs.length + 1)); } lines.push(''); } } } return lines.join('\n'); } renderBlocks(blocks, baseIndent) { const renderBlock = (block, depth) => { const indent = ' '.repeat(depth); // Unconditional: `escapeBlockString` is the identity for newline-free // text, so pages with no soft line breaks render byte-identical to how // they did before this feature existed — no predicate needed. const text = escapeBlockString(block.string); let line; if (block.heading && block.heading > 0) { const hashes = '#'.repeat(block.heading); line = `${indent}${hashes} ${text}`; } else { line = `${indent}- ${text}`; } const childLines = block.children.map(c => renderBlock(c, depth + 1)).join('\n'); return childLines ? `${line}\n${childLines}` : line; }; return blocks.map(b => renderBlock(b, baseIndent)).join('\n'); } }