roam-research-mcp
Version:
MCP server and CLI for Roam Research
143 lines (142 loc) • 5.29 kB
JavaScript
import { BatchOperations } from './batch.js';
/**
* Validates table input before building actions.
*/
export function validateTableInput(input) {
const errors = [];
if (!input.parent_uid) {
errors.push({ field: 'parent_uid', message: 'parent_uid is required' });
}
if (!input.headers || !Array.isArray(input.headers)) {
errors.push({ field: 'headers', message: 'headers must be an array' });
}
else if (input.headers.length === 0) {
errors.push({ field: 'headers', message: 'At least one header is required' });
}
if (!input.rows || !Array.isArray(input.rows)) {
errors.push({ field: 'rows', message: 'rows must be an array' });
}
else {
const expectedCells = (input.headers?.length || 1) - 1;
for (let i = 0; i < input.rows.length; i++) {
const row = input.rows[i];
if (!row.label && row.label !== '') {
errors.push({
field: `rows[${i}].label`,
message: 'Row label is required (use empty string for blank)'
});
}
if (!Array.isArray(row.cells)) {
errors.push({
field: `rows[${i}].cells`,
message: 'Row cells must be an array'
});
}
else if (row.cells.length !== expectedCells) {
errors.push({
field: `rows[${i}].cells`,
message: `Expected ${expectedCells} cells, got ${row.cells.length}`
});
}
}
}
return { valid: errors.length === 0, errors };
}
/**
* Builds batch actions for creating a Roam table structure.
*
* Roam tables have a specific nested structure:
* - The table container block contains {{[[table]]}}
* - Header row is nested deeply (each column nested under the previous)
* - Data rows follow the same nesting pattern
*/
export function buildTableActions(input) {
const actions = [];
// Create table container
actions.push({
action: 'create-block',
uid: '{{uid:table}}',
string: '{{[[table]]}}',
location: { 'parent-uid': input.parent_uid, order: input.order ?? 'last' }
});
// Create header row with nested structure
// In Roam tables, each column is nested under the previous column
let headerParent = '{{uid:table}}';
for (let i = 0; i < input.headers.length; i++) {
const uid = `{{uid:header_${i}}}`;
const headerText = input.headers[i] || ' '; // Convert empty to space
actions.push({
action: 'create-block',
uid,
string: headerText,
location: { 'parent-uid': headerParent, order: 0 }
});
headerParent = uid;
}
// Create data rows
// Each row starts as a child of the table, with cells nested under each other
for (let rowIdx = 0; rowIdx < input.rows.length; rowIdx++) {
const row = input.rows[rowIdx];
// Row label (first column) - child of table at position rowIdx + 1 (after header)
const labelUid = `{{uid:row_${rowIdx}_label}}`;
const labelText = row.label || ' '; // Convert empty to space
actions.push({
action: 'create-block',
uid: labelUid,
string: labelText,
location: { 'parent-uid': '{{uid:table}}', order: rowIdx + 1 }
});
// Row cells - each cell is nested under the previous
let cellParent = labelUid;
for (let cellIdx = 0; cellIdx < row.cells.length; cellIdx++) {
const cellUid = `{{uid:row_${rowIdx}_cell_${cellIdx}}}`;
const cellText = row.cells[cellIdx] || ' '; // Convert empty to space
actions.push({
action: 'create-block',
uid: cellUid,
string: cellText,
location: { 'parent-uid': cellParent, order: 0 }
});
cellParent = cellUid;
}
}
return actions;
}
export class TableOperations {
constructor(graph) {
this.batchOps = new BatchOperations(graph);
}
/**
* Creates a table in Roam with the specified headers and rows.
*
* @param input Table configuration including parent_uid, headers, and rows
* @returns Result including success status and table block UID
*/
async createTable(input) {
// Validate input
const validation = validateTableInput(input);
if (!validation.valid) {
return {
success: false,
error: {
code: 'VALIDATION_ERROR',
message: validation.errors.map(e => `[${e.field}] ${e.message}`).join('; ')
},
validation_passed: false,
actions_attempted: 0
};
}
// Build table actions
const actions = buildTableActions(input);
// Execute batch
const result = await this.batchOps.processBatch(actions);
// Add table_uid if successful
if (result.success && result.uid_map) {
return {
...result,
table_uid: result.uid_map['table']
};
}
return result;
}
}