roam-research-mcp
Version:
MCP server and CLI for Roam Research
371 lines (370 loc) • 11.9 kB
JavaScript
/**
* Translator: converts CLI-style commands to Roam batch actions
*/
import { resolveParentRef, generatePlaceholder } from './resolver.js';
import { sanitizeTagName } from '../../utils/helpers.js';
/**
* Translate a single command to batch actions
* May return multiple actions (e.g., table, outline)
*/
export function translateCommand(command, context) {
switch (command.command) {
case 'create':
return translateCreate(command, context);
case 'update':
return translateUpdate(command);
case 'delete':
return translateDelete(command);
case 'move':
return translateMove(command);
case 'todo':
return translateTodo(command, context);
case 'table':
return translateTable(command, context);
case 'outline':
return translateOutline(command, context);
case 'remember':
return translateRemember(command, context);
case 'page':
return translatePage(command, context);
case 'codeblock':
return translateCodeblock(command, context);
default:
throw new Error(`Unknown command: ${command.command}`);
}
}
/**
* Resolve parent UID from command params
*/
function getParentUid(params, context) {
// Direct parent UID or placeholder
if (params.parent) {
const resolved = resolveParentRef(params.parent, context);
if (resolved)
return resolved;
throw new Error(`Parent "${params.parent}" could not be resolved to a UID. Page may not exist.`);
}
// Page UID
if (params.pageUid) {
return params.pageUid;
}
// Page title -> resolved UID
if (params.page) {
const uid = context.pageUids.get(params.page);
if (!uid) {
throw new Error(`Page "${params.page}" not found`);
}
return uid;
}
// Default to daily page
if (context.dailyPageUid) {
return context.dailyPageUid;
}
throw new Error('No parent specified and daily page not resolved');
}
/**
* Register a placeholder in the context
*/
function registerPlaceholder(name, context) {
const placeholder = generatePlaceholder(name);
context.placeholders.set(name, placeholder);
return placeholder;
}
/**
* Update level stack for hierarchy tracking
*/
function updateLevelStack(level, uid, context) {
// Ensure stack is long enough
while (context.levelStack.length < level) {
context.levelStack.push('');
}
context.levelStack[level - 1] = uid;
// Truncate stack above current level
context.levelStack.length = level;
}
/**
* Get parent from level stack for hierarchical nesting
*/
function getParentFromLevel(level, context) {
if (level <= 1) {
return context.currentParent;
}
const parentLevel = level - 1;
if (parentLevel <= context.levelStack.length && context.levelStack[parentLevel - 1]) {
return context.levelStack[parentLevel - 1];
}
return context.currentParent;
}
// --- Command translators ---
function translateCreate(cmd, context) {
const { params } = cmd;
let parentUid;
// Handle level-based hierarchy
if (params.level !== undefined && params.level > 1) {
const levelParent = getParentFromLevel(params.level, context);
if (levelParent) {
parentUid = levelParent;
}
else {
parentUid = getParentUid(params, context);
}
}
else {
parentUid = getParentUid(params, context);
// Set as current parent for level-based children
if (params.level === 1 || params.level === undefined) {
context.currentParent = parentUid;
}
}
const action = {
action: 'create-block',
string: params.text,
location: {
'parent-uid': parentUid,
order: params.order ?? 'last'
}
};
// Register placeholder if 'as' is specified
if (params.as) {
action.uid = registerPlaceholder(params.as, context);
}
// Optional properties
if (params.heading) {
action.heading = params.heading;
}
if (params['children-view-type']) {
action['children-view-type'] = params['children-view-type'];
}
// Update level stack if level is specified
if (params.level !== undefined && params.as) {
updateLevelStack(params.level, `{{uid:${params.as}}}`, context);
}
return [action];
}
function translateUpdate(cmd) {
const { params } = cmd;
const action = {
action: 'update-block',
uid: params.uid,
string: params.text
};
if (params.heading !== undefined) {
action.heading = params.heading;
}
if (params.open !== undefined) {
action.open = params.open;
}
if (params['text-align']) {
action['text-align'] = params['text-align'];
}
if (params['children-view-type']) {
action['children-view-type'] = params['children-view-type'];
}
return [action];
}
function translateDelete(cmd) {
return [{
action: 'delete-block',
uid: cmd.params.uid
}];
}
function translateMove(cmd) {
const { params } = cmd;
return [{
action: 'move-block',
uid: params.uid,
location: {
'parent-uid': params.parent,
order: params.order ?? 'last'
}
}];
}
function translateTodo(cmd, context) {
const { params } = cmd;
const parentUid = getParentUid(params, context);
const action = {
action: 'create-block',
string: `{{[[TODO]]}} ${params.text}`,
location: {
'parent-uid': parentUid,
order: params.order ?? 'last'
}
};
if (params.as) {
action.uid = registerPlaceholder(params.as, context);
}
return [action];
}
function translateTable(cmd, context) {
const { params } = cmd;
const parentUid = getParentUid(params, context);
const actions = [];
// Table container
const tableContainerPlaceholder = params.as
? registerPlaceholder(params.as, context)
: registerPlaceholder(`_table_${Date.now()}`, context);
actions.push({
action: 'create-block',
uid: tableContainerPlaceholder,
string: '{{[[table]]}}',
location: {
'parent-uid': parentUid,
order: params.order ?? 'last'
}
});
// Create columns (headers)
const columnPlaceholders = [];
for (let i = 0; i < params.headers.length; i++) {
const colPlaceholder = registerPlaceholder(`_col_${i}_${Date.now()}`, context);
columnPlaceholders.push(colPlaceholder);
actions.push({
action: 'create-block',
uid: colPlaceholder,
string: params.headers[i] || ' ',
location: {
'parent-uid': tableContainerPlaceholder,
order: i
}
});
}
// Create rows under each column
for (let rowIdx = 0; rowIdx < params.rows.length; rowIdx++) {
const row = params.rows[rowIdx];
// First column gets the row label
actions.push({
action: 'create-block',
string: row.label || ' ',
location: {
'parent-uid': columnPlaceholders[0],
order: rowIdx
}
});
// Remaining columns get the cells
for (let cellIdx = 0; cellIdx < row.cells.length; cellIdx++) {
actions.push({
action: 'create-block',
string: row.cells[cellIdx] || ' ',
location: {
'parent-uid': columnPlaceholders[cellIdx + 1],
order: rowIdx
}
});
}
}
return actions;
}
function translateOutline(cmd, context) {
const { params } = cmd;
const parentUid = getParentUid(params, context);
const actions = [];
// Reset level stack with outline parent
context.currentParent = parentUid;
context.levelStack = [];
for (const item of params.items) {
let itemParentUid;
if (item.level === 1) {
itemParentUid = parentUid;
}
else {
const levelParent = getParentFromLevel(item.level, context);
if (!levelParent) {
throw new Error(`Invalid outline hierarchy: level ${item.level} has no parent`);
}
itemParentUid = levelParent;
}
const action = {
action: 'create-block',
string: item.text,
location: {
'parent-uid': itemParentUid,
order: 'last'
}
};
// Register placeholder
const placeholderName = item.as || `_outline_${actions.length}_${Date.now()}`;
action.uid = registerPlaceholder(placeholderName, context);
if (item.heading) {
action.heading = item.heading;
}
// Update level stack
updateLevelStack(item.level, action.uid, context);
actions.push(action);
}
return actions;
}
function translateRemember(cmd, context) {
const { params } = cmd;
// Build memory text with categories (strip any pre-existing # or [[ ]] wrappers)
let memoryText = params.text;
if (params.categories && params.categories.length > 0) {
const tags = params.categories.map(raw => {
const cat = sanitizeTagName(raw);
return `#[[${cat}]]`;
}).join(' ');
memoryText = `${params.text} ${tags}`;
}
// Add ROAM_MEMORIES_TAG if configured (we'll handle this in the CLI command)
// For now, just create the block
let parentUid;
// If heading is specified, we'd need to look it up or create it
// For simplicity, require parent UID when heading is used
// TODO: Add heading resolution support
if (params.heading && !params.parent) {
throw new Error('remember with --heading requires --parent or heading resolution not yet implemented');
}
parentUid = getParentUid(params, context);
const action = {
action: 'create-block',
string: memoryText,
location: {
'parent-uid': parentUid,
order: params.order ?? 'last'
}
};
if (params.as) {
action.uid = registerPlaceholder(params.as, context);
}
return [action];
}
function translatePage(cmd, context) {
// Note: Page creation uses create-page API, not batch actions
// We'll handle this specially in the batch command
// For now, throw an error to indicate this needs special handling
throw new Error('page command requires special handling outside batch actions');
}
function translateCodeblock(cmd, context) {
const { params } = cmd;
const parentUid = getParentUid(params, context);
// Format code with triple backticks
const codeContent = '```' + params.language + '\n' + params.code + '\n```';
const action = {
action: 'create-block',
string: codeContent,
location: {
'parent-uid': parentUid,
order: params.order ?? 'last'
}
};
if (params.as) {
action.uid = registerPlaceholder(params.as, context);
}
return [action];
}
/**
* Translate all commands to batch actions
*/
export function translateAllCommands(commands, context) {
const actions = [];
const pageCommands = [];
for (const cmd of commands) {
if (cmd.command === 'page') {
// Collect page commands for special handling
pageCommands.push(cmd);
}
else {
const cmdActions = translateCommand(cmd, context);
actions.push(...cmdActions);
}
}
return { actions, pageCommands };
}