roam-research-mcp
Version:
MCP server and CLI for Roam Research
175 lines (174 loc) • 6.22 kB
JavaScript
import { escapeBlockString, needsNewlineEscaping, ESCAPED_NEWLINES_MARKER } from '../../shared/block-escaping.js';
/**
* Convert RoamBlock hierarchy to markdown with proper indentation.
*
* `escape` mirrors `PageOperations.fetchPageByTitle`'s markdown branch: a
* block may contain a soft line break (Shift+Enter), and this renderer emits
* one `- ` line per block, so an unescaped newline spills onto a second
* physical line with no bullet, resetting the parser's indentation baseline
* and reparenting everything after it. Callers that render a full page
* compute whether escaping is needed and set this flag; callers that never
* feed their output back through `updatePageMarkdown` can leave it off.
*/
export function blocksToMarkdown(blocks, level = 0, escape = false) {
return blocks
.map(block => {
const indent = ' '.repeat(level);
let md;
const text = escape ? escapeBlockString(block.string) : block.string;
// Check block heading level and format accordingly
if (block.heading && block.heading > 0) {
const hashtags = '#'.repeat(block.heading);
md = `${indent}${hashtags} ${text}`;
}
else {
md = `${indent}- ${text}`;
}
if (block.children && block.children.length > 0) {
md += '\n' + blocksToMarkdown(block.children, level + 1, escape);
}
return md;
})
.join('\n');
}
/**
* Collect every block string in a tree, for deciding whether a page needs
* newline escaping at all (see `blocksToMarkdown`).
*/
function collectBlockStrings(blocks, out = []) {
for (const block of blocks) {
out.push(block.string);
if (block.children && block.children.length > 0) {
collectBlockStrings(block.children, out);
}
}
return out;
}
/**
* Flatten block hierarchy to single-level list
*/
export function flattenBlocks(blocks, result = []) {
for (const block of blocks) {
result.push({ ...block, children: [] });
if (block.children && block.children.length > 0) {
flattenBlocks(block.children, result);
}
}
return result;
}
/**
* Format page content for output
*/
export function formatPageOutput(title, blocks, options) {
if (options.json) {
const data = options.flat ? flattenBlocks(blocks) : blocks;
return JSON.stringify({ title, children: data }, null, 2);
}
const displayBlocks = options.flat ? flattenBlocks(blocks) : blocks;
const escape = needsNewlineEscaping(collectBlockStrings(displayBlocks));
const body = blocksToMarkdown(displayBlocks, 0, escape);
return escape
? `# ${title}\n${ESCAPED_NEWLINES_MARKER}\n\n${body}`
: `# ${title}\n\n${body}`;
}
/**
* Format block content for output
*/
export function formatBlockOutput(block, options) {
if (options.json) {
const data = options.flat ? flattenBlocks([block]) : block;
return JSON.stringify(data, null, 2);
}
const displayBlocks = options.flat ? flattenBlocks([block]) : [block];
const escape = needsNewlineEscaping(collectBlockStrings(displayBlocks));
const body = blocksToMarkdown(displayBlocks, 0, escape);
return escape ? `${ESCAPED_NEWLINES_MARKER}\n\n${body}` : body;
}
/**
* Format search results for output
*/
export function formatSearchResults(results, options) {
if (options.json) {
return JSON.stringify(results, null, 2);
}
if (results.length === 0) {
return 'No results found.';
}
let output = `Found ${results.length} result(s):\n\n`;
results.forEach((result) => {
const pageInfo = result.page_title ? ` — [[${result.page_title}]]` : '';
output += `[${result.block_uid}] ${result.content}${pageInfo}\n`;
});
return output.trim();
}
/**
* Format TODO/DONE search results for output
*/
export function formatTodoOutput(results, status, options) {
if (options.json) {
return JSON.stringify(results, null, 2);
}
if (results.length === 0) {
return `No ${status} items found.`;
}
// Group by page
const byPage = new Map();
for (const item of results) {
const page = item.page_title || 'Unknown Page';
if (!byPage.has(page)) {
byPage.set(page, []);
}
byPage.get(page).push(item);
}
let output = `Found ${results.length} ${status} item(s):\n`;
for (const [page, items] of byPage) {
output += `\n## ${page}\n`;
for (const item of items) {
// Strip {{[[TODO]]}}, {{TODO}}, {{[[DONE]]}}, or {{DONE}} markers for cleaner display
const cleanContent = item.content
.replace(/\{\{\[\[TODO\]\]\}\}\s*/g, '')
.replace(/\{\{TODO\}\}\s*/g, '')
.replace(/\{\{\[\[DONE\]\]\}\}\s*/g, '')
.replace(/\{\{DONE\}\}\s*/g, '');
output += `[${item.block_uid}] [${status === 'DONE' ? 'x' : ' '}] ${cleanContent}\n`;
}
}
return output.trim();
}
/**
* Format grouped search results for output
*/
export function formatGroupedOutput(grouped, options) {
if (options.json) {
return JSON.stringify(grouped, null, 2);
}
const { groups, meta } = grouped;
const groupKeys = Object.keys(groups);
if (groupKeys.length === 0) {
return 'No results found.';
}
let output = `Found ${meta.total} item(s) in ${meta.groups_count} group(s):\n`;
for (const groupName of groupKeys) {
const items = groups[groupName];
output += `\n## ${groupName}\n`;
for (const item of items) {
// Format: [uid] content with optional page reference
const pageRef = item.page_title ? ` — [[${item.page_title}]]` : '';
output += `[${item.block_uid}] ${item.content}${pageRef}\n`;
}
}
return output.trim();
}
/**
* Print debug information
*/
export function printDebug(label, data) {
console.error(`[DEBUG] ${label}:`, JSON.stringify(data, null, 2));
}
/**
* Print error message and exit
*/
export function exitWithError(message, code = 1) {
console.error(`Error: ${message}`);
process.exit(code);
}