UNPKG

roam-research-mcp

Version:
363 lines (357 loc) • 15.8 kB
import { Command } from 'commander'; import { readFileSync } from 'fs'; import { BatchOperations } from '../../tools/operations/batch.js'; import { PageOperations } from '../../tools/operations/pages.js'; import { printDebug, exitWithError } from '../utils/output.js'; import { resolveGraph } from '../utils/graph.js'; import { collectPageTitles, resolveAllPages, resolveDailyPageUid, needsDailyPage, createResolutionContext, getDailyPageTitle } from '../batch/resolver.js'; import { translateAllCommands } from '../batch/translator.js'; import { readStdin } from '../utils/input.js'; // Required params per command type const REQUIRED_PARAMS = { todo: ['text'], create: ['parent', 'text'], update: ['uid'], delete: ['uid'], move: ['uid', 'parent'], page: ['title'], outline: ['parent', 'items'], table: ['parent', 'headers', 'rows'], remember: ['text'], codeblock: ['parent', 'code'] }; const VALID_COMMAND_TYPES = Object.keys(REQUIRED_PARAMS); /** * Validate command structure and required params */ function validateCommands(commands) { const validated = []; for (let i = 0; i < commands.length; i++) { const cmd = commands[i]; if (!cmd || typeof cmd !== 'object') { throw new Error(`[${i}] Command must be an object`); } if (!cmd.command || typeof cmd.command !== 'string') { throw new Error(`[${i}] Missing 'command' field`); } const cmdType = cmd.command; if (!VALID_COMMAND_TYPES.includes(cmdType)) { throw new Error(`[${i}] Unknown command type '${cmdType}'. Valid: ${VALID_COMMAND_TYPES.join(', ')}`); } if (!cmd.params || typeof cmd.params !== 'object') { throw new Error(`[${i}] Missing 'params' field`); } // Check required params const params = cmd.params; const required = REQUIRED_PARAMS[cmdType]; for (const param of required) { if (params[param] === undefined) { throw new Error(`[${i}] ${cmdType}: missing required param '${param}'`); } } validated.push(cmd); } return validated; } /** * Validate placeholder references are defined before use * Returns list of errors (empty = valid) */ function validatePlaceholders(commands) { const errors = []; const definedPlaceholders = new Set(); for (let i = 0; i < commands.length; i++) { const cmd = commands[i]; const params = cmd.params; // Collect placeholder definitions from 'as' params if (typeof params.as === 'string') { definedPlaceholders.add(params.as); } // Check placeholder references in 'parent' param if (typeof params.parent === 'string') { const match = params.parent.match(/^\{\{(\w+)\}\}$/); if (match) { const refName = match[1]; if (!definedPlaceholders.has(refName)) { errors.push(`[${i}] ${cmd.command}: placeholder "{{${refName}}}" used before definition`); } } } } return errors; } /** * Output partial results when a failure occurs mid-batch * Helps user know what was created and may need manual cleanup */ function outputPartialResults(pageResults, failedPage, batchError) { const output = { success: false, partial: true, pages_created: pageResults.length, ...(failedPage && { failed_at: `page: ${failedPage}` }), ...(batchError && { failed_at: `batch: ${batchError}` }) }; if (pageResults.length > 0) { output.created_pages = pageResults.map(p => ({ title: p.title, uid: p.uid })); output.cleanup_hint = 'Pages listed above were created before failure. Delete manually if needed.'; } console.error(JSON.stringify(output, null, 2)); process.exit(1); } /** Format action for dry-run display */ function formatAction(action, index) { const lines = [` ${index + 1}. ${action.action}`]; if (action.string) { const text = String(action.string); const preview = text.length > 60 ? text.slice(0, 60) + '...' : text; lines.push(` text: "${preview}"`); } if (action.location) { const loc = action.location; lines.push(` parent: ${loc['parent-uid']}`); } if (action.uid) { lines.push(` uid: ${action.uid}`); } return lines.join('\n'); } export function createBatchCommand() { return new Command('batch') .description('Execute multiple block operations efficiently in a single API call') .argument('[file]', 'JSON file with commands (or pipe via stdin)') .option('--debug', 'Show debug information') .option('--dry-run', 'Validate and show planned actions without executing') .option('--simulate', 'Validate structure offline (no API calls)') .option('-g, --graph <name>', 'Target graph key (for multi-graph mode)') .option('--write-key <key>', 'Write confirmation key (for non-default graphs)') .addHelpText('after', ` Examples: # From file roam batch commands.json # Execute commands from file roam batch commands.json --dry-run # Preview without executing (resolves pages) roam batch commands.json --simulate # Validate offline (no API calls) # From stdin cat commands.json | roam batch # Pipe commands echo '[{"command":"todo","params":{"text":"Task 1"}}]' | roam batch Command schemas: todo: {text} create: {parent, text, as?, heading?, order?} update: {uid, text?, heading?, open?} delete: {uid} move: {uid, parent, order?} page: {title, as?, content?: [{text, level, heading?}...]} outline: {parent, items: [string...]} table: {parent, headers: [string...], rows: [{label, cells: [string...]}...]} remember: {text, categories?: [string...]} codeblock: {parent, code, language?} Parent accepts: block UID, "daily", page title, or {{placeholder}} Example: [ {"command": "page", "params": {"title": "Project X", "as": "proj"}}, {"command": "create", "params": {"parent": "{{proj}}", "text": "# Overview", "as": "overview"}}, {"command": "outline", "params": {"parent": "{{overview}}", "items": ["Goal 1", "Goal 2"]}}, {"command": "todo", "params": {"text": "Review project"}} ] Output (JSON): { success, pages_created, actions_executed, uid_map? } `) .action(async (file, options) => { try { // Read input let rawInput; if (file) { try { rawInput = readFileSync(file, 'utf-8'); } catch { exitWithError(`Could not read file: ${file}`); } } else if (process.stdin.isTTY) { exitWithError('No file specified and no input piped. Use: roam batch commands.json or cat commands.json | roam batch'); } else { rawInput = await readStdin(); } // Parse JSON let parsed; try { parsed = JSON.parse(rawInput); } catch (err) { exitWithError(`Invalid JSON: ${err instanceof SyntaxError ? err.message : 'parse error'}`); } if (!Array.isArray(parsed)) { exitWithError('Input must be a JSON array of commands'); } if (parsed.length === 0) { console.log('No commands to execute'); return; } // Validate and get typed commands const commands = validateCommands(parsed); // Upfront validation: check placeholder references const placeholderErrors = validatePlaceholders(commands); if (placeholderErrors.length > 0) { exitWithError(`Placeholder validation failed:\n ${placeholderErrors.join('\n ')}`); } if (options.debug) { printDebug('Commands', commands.length); printDebug('Graph', options.graph || 'default'); printDebug('Mode', options.simulate ? 'simulate' : options.dryRun ? 'dry-run' : 'execute'); } // Simulate mode: validate structure without connecting to Roam if (options.simulate) { const context = createResolutionContext(); const { actions, pageCommands } = translateAllCommands(commands, context); console.log('\n[SIMULATE] Validation passed\n'); console.log(`Commands: ${commands.length}`); console.log(` Pages to create: ${pageCommands.length}`); console.log(` Batch actions: ${actions.length}`); if (pageCommands.length > 0) { console.log('\nPage creations:'); for (const pc of pageCommands) { const as = pc.params.as ? ` → {{${pc.params.as}}}` : ''; console.log(` - "${pc.params.title}"${as}`); } } console.log('\nNo API calls made. Use --dry-run to resolve page UIDs.'); return; } const graph = resolveGraph(options, true); // Phase 1: Collect and resolve page titles (from 'page' AND 'parent' params) const context = createResolutionContext(); const pageTitles = collectPageTitles(commands); if (pageTitles.size > 0) { if (options.debug) { printDebug('Pages to resolve', Array.from(pageTitles)); } const resolved = await resolveAllPages(graph, pageTitles); for (const [title, uid] of resolved) { context.pageUids.set(title, uid); } // Auto-create unresolved pages (e.g., parent: "Page Title" for a page that doesn't exist yet) const unresolved = Array.from(pageTitles).filter(t => !context.pageUids.has(t)); if (unresolved.length > 0) { if (options.debug) { printDebug('Auto-creating pages', unresolved); } const pageOps = new PageOperations(graph); for (const title of unresolved) { const result = await pageOps.createPage(title); if (result.success && result.page_uid) { context.pageUids.set(title, result.page_uid); if (options.debug) { printDebug(`Auto-created "${title}"`, result.page_uid); } } else { exitWithError(`Failed to create page "${title}"`); } } } if (options.debug) { printDebug('Resolved pages', Object.fromEntries(context.pageUids)); } } // Resolve daily page if needed if (needsDailyPage(commands)) { const dailyUid = await resolveDailyPageUid(graph); if (!dailyUid) { exitWithError(`Daily page not found: "${getDailyPageTitle()}"`); } context.dailyPageUid = dailyUid; context.pageUids.set(getDailyPageTitle(), dailyUid); if (options.debug) { printDebug('Daily page UID', dailyUid); } } // Phase 2: Translate commands to batch actions const { actions, pageCommands } = translateAllCommands(commands, context); if (options.debug) { printDebug('Batch actions', actions.length); printDebug('Page commands', pageCommands.length); } // Dry run: show actions and exit if (options.dryRun) { console.log('\n[DRY RUN] Planned actions:\n'); if (pageCommands.length > 0) { console.log('Page creations:'); for (const pc of pageCommands) { const as = pc.params.as ? ` (as: {{${pc.params.as}}})` : ''; console.log(` - "${pc.params.title}"${as}`); } console.log(''); } if (actions.length > 0) { console.log('Batch actions:'); console.log(actions.map((a, i) => formatAction(a, i)).join('\n')); } console.log(`\nTotal: ${pageCommands.length} page(s), ${actions.length} action(s)`); return; } // Phase 3: Execute page commands (in parallel where possible) const pageResults = []; if (pageCommands.length > 0) { const pageOps = new PageOperations(graph); // Execute page creations - must be sequential if they reference each other for (const pc of pageCommands) { const result = await pageOps.createPage(pc.params.title, pc.params.content?.map(item => ({ text: item.text, level: item.level, heading: item.heading }))); if (!result.success) { // Report partial results before exiting outputPartialResults(pageResults, pc.params.title); } pageResults.push({ title: pc.params.title, uid: result.page_uid }); if (pc.params.as) { context.placeholders.set(pc.params.as, result.page_uid); } if (options.debug) { printDebug(`Created "${pc.params.title}"`, result.page_uid); } } } // Phase 4: Execute batch actions let batchResult = { success: true, actions_attempted: 0 }; if (actions.length > 0) { const batchOps = new BatchOperations(graph); batchResult = await batchOps.processBatch(actions); if (!batchResult.success) { const errorMsg = typeof batchResult.error === 'string' ? batchResult.error : batchResult.error?.message || 'Unknown error'; // Report partial results (pages created before batch failed) outputPartialResults(pageResults, undefined, errorMsg); } } // Build output const uidMap = {}; for (const pr of pageResults) { const pageCmd = pageCommands.find(pc => pc.params.title === pr.title); if (pageCmd?.params.as) { uidMap[pageCmd.params.as] = pr.uid; } } if (batchResult.uid_map) { Object.assign(uidMap, batchResult.uid_map); } const output = { success: true, pages_created: pageResults.length, actions_executed: actions.length, ...(Object.keys(uidMap).length > 0 && { uid_map: uidMap }) }; console.log(JSON.stringify(output, null, 2)); } catch (error) { const message = error instanceof Error ? error.message : String(error); exitWithError(message); } }); }