roam-research-mcp 1.6.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,14 @@
1
1
  import { Command } from 'commander';
2
- import { readFileSync } from 'fs';
2
+ import { existsSync, readFileSync } from 'fs';
3
3
  import { basename } from 'path';
4
- import { initializeGraph } from '@roam-research/roam-api-sdk';
5
- import { API_TOKEN, GRAPH_NAME } from '../../config/environment.js';
6
4
  import { PageOperations } from '../../tools/operations/pages.js';
7
- import { parseMarkdown } from '../../markdown-utils.js';
5
+ import { TodoOperations } from '../../tools/operations/todos.js';
6
+ import { BatchOperations } from '../../tools/operations/batch.js';
7
+ import { parseMarkdown, generateBlockUid, parseMarkdownHeadingLevel } from '../../markdown-utils.js';
8
8
  import { printDebug, exitWithError } from '../utils/output.js';
9
+ import { resolveGraph } from '../utils/graph.js';
10
+ import { formatRoamDate } from '../../utils/helpers.js';
11
+ import { q, createPage as roamCreatePage } from '@roam-research/roam-api-sdk';
9
12
  /**
10
13
  * Flatten nested MarkdownNode[] to flat array with absolute levels
11
14
  */
@@ -33,85 +36,459 @@ async function readStdin() {
33
36
  }
34
37
  return Buffer.concat(chunks).toString('utf-8');
35
38
  }
39
+ /**
40
+ * Check if a string looks like a Roam block UID (9 alphanumeric chars with _ or -)
41
+ */
42
+ function isBlockUid(value) {
43
+ // Strip (( )) wrapper if present
44
+ const cleaned = value.replace(/^\(\(|\)\)$/g, '');
45
+ return /^[a-zA-Z0-9_-]{9}$/.test(cleaned);
46
+ }
47
+ /**
48
+ * Check if content looks like a JSON array
49
+ */
50
+ function looksLikeJsonArray(content) {
51
+ const trimmed = content.trim();
52
+ return trimmed.startsWith('[') && trimmed.endsWith(']');
53
+ }
54
+ /**
55
+ * Find or create a page by title, returns UID
56
+ */
57
+ async function findOrCreatePage(graph, title) {
58
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
59
+ const findResults = await q(graph, findQuery, [title]);
60
+ if (findResults && findResults.length > 0) {
61
+ return findResults[0][0];
62
+ }
63
+ // Create the page if it doesn't exist
64
+ await roamCreatePage(graph, {
65
+ action: 'create-page',
66
+ page: { title }
67
+ });
68
+ const results = await q(graph, findQuery, [title]);
69
+ if (!results || results.length === 0) {
70
+ throw new Error(`Could not find created page: ${title}`);
71
+ }
72
+ return results[0][0];
73
+ }
74
+ /**
75
+ * Get or create today's daily page UID
76
+ */
77
+ async function getDailyPageUid(graph) {
78
+ const today = new Date();
79
+ const dateStr = formatRoamDate(today);
80
+ return findOrCreatePage(graph, dateStr);
81
+ }
82
+ /**
83
+ * Find or create a heading block on a page
84
+ */
85
+ async function findOrCreateHeading(graph, pageUid, heading, headingLevel) {
86
+ // Search for existing heading block
87
+ const headingQuery = `[:find ?uid
88
+ :in $ ?page-uid ?text
89
+ :where
90
+ [?page :block/uid ?page-uid]
91
+ [?page :block/children ?block]
92
+ [?block :block/string ?text]
93
+ [?block :block/uid ?uid]]`;
94
+ const headingResults = await q(graph, headingQuery, [pageUid, heading]);
95
+ if (headingResults && headingResults.length > 0) {
96
+ return headingResults[0][0];
97
+ }
98
+ // Create the heading block
99
+ const batchOps = new BatchOperations(graph);
100
+ const headingUid = generateBlockUid();
101
+ await batchOps.processBatch([{
102
+ action: 'create-block',
103
+ location: { 'parent-uid': pageUid, order: 'last' },
104
+ string: heading,
105
+ uid: headingUid,
106
+ ...(headingLevel && { heading: headingLevel })
107
+ }]);
108
+ return headingUid;
109
+ }
110
+ /**
111
+ * Parse JSON content blocks
112
+ */
113
+ function parseJsonContent(content) {
114
+ const parsed = JSON.parse(content);
115
+ if (!Array.isArray(parsed)) {
116
+ throw new Error('JSON content must be an array of {text, level, heading?} objects');
117
+ }
118
+ return parsed.map((item, index) => {
119
+ if (typeof item.text !== 'string' || typeof item.level !== 'number') {
120
+ throw new Error(`Invalid item at index ${index}: must have "text" (string) and "level" (number)`);
121
+ }
122
+ // If heading not explicitly provided, detect from text and strip hashes
123
+ if (item.heading) {
124
+ return {
125
+ text: item.text,
126
+ level: item.level,
127
+ heading: item.heading
128
+ };
129
+ }
130
+ const { heading_level, content: strippedText } = parseMarkdownHeadingLevel(item.text);
131
+ return {
132
+ text: strippedText,
133
+ level: item.level,
134
+ ...(heading_level > 0 && { heading: heading_level })
135
+ };
136
+ });
137
+ }
36
138
  export function createSaveCommand() {
37
139
  return new Command('save')
38
- .description('Import markdown to Roam')
39
- .argument('[file]', 'Markdown file to import (or pipe content to stdin)')
40
- .option('--title <title>', 'Page title (defaults to filename without .md)')
41
- .option('--update', 'Update existing page using smart diff')
140
+ .description('Save text, files, or JSON to pages/blocks. Auto-detects format.')
141
+ .argument('[input]', 'Text, file path, or "-" for stdin (auto-detected)')
142
+ .option('--title <title>', 'Create a new page with this title')
143
+ .option('--update', 'Update existing page using smart diff (preserves block UIDs)')
144
+ .option('-p, --page <ref>', 'Target page by title or UID (default: daily page, creates if missing)')
145
+ .option('--parent <ref>', 'Nest under block UID ((uid)) or heading text (creates if missing). Use # prefix for heading level: "## Section"')
146
+ .option('-c, --categories <tags>', 'Comma-separated tags appended to first block')
147
+ .option('-t, --todo [text]', 'Add TODO item(s) to daily page. Accepts inline text or stdin')
148
+ .option('--json', 'Force JSON array format: [{text, level, heading?}, ...]')
149
+ .option('-g, --graph <name>', 'Target graph key (multi-graph mode)')
150
+ .option('--write-key <key>', 'Write confirmation key (non-default graphs)')
42
151
  .option('--debug', 'Show debug information')
43
- .action(async (file, options) => {
152
+ .addHelpText('after', `
153
+ Examples:
154
+ # Quick saves to daily page
155
+ roam save "Quick note" # Single block
156
+ roam save "# Important" -c "work,urgent" # H1 heading with tags
157
+ roam save --todo "Buy groceries" # TODO item
158
+
159
+ # Save under heading (creates if missing)
160
+ roam save --parent "## Notes" "My note" # Under H2 "Notes" heading
161
+ roam save --parent "((blockUid9))" "Child" # Under specific block
162
+
163
+ # Target specific page
164
+ roam save -p "Project X" "Status update" # By title (creates if missing)
165
+ roam save -p "pageUid123" "Note" # By UID
166
+
167
+ # File operations
168
+ roam save notes.md --title "My Notes" # Create page from file
169
+ roam save notes.md --title "My Notes" --update # Smart update (preserves UIDs)
170
+ cat data.json | roam save --json # Pipe JSON blocks
171
+
172
+ # Combine options
173
+ roam save -p "Work" --parent "## Today" "Done with task" -c "wins"
174
+
175
+ JSON format (--json):
176
+ Array of blocks with text, level, and optional heading:
177
+ [
178
+ {"text": "# Main Title", "level": 1}, # Auto-detects H1
179
+ {"text": "Subheading", "level": 1, "heading": 2}, # Explicit H2
180
+ {"text": "Nested content", "level": 2}, # Child block
181
+ {"text": "Sibling", "level": 2}
182
+ ]
183
+ `)
184
+ .action(async (input, options) => {
44
185
  try {
45
- let markdownContent;
46
- let pageTitle;
47
- if (file) {
48
- // Read from file
49
- try {
50
- markdownContent = readFileSync(file, 'utf-8');
186
+ // TODO mode: add a TODO item to today's daily page
187
+ if (options.todo !== undefined) {
188
+ let todoText;
189
+ if (typeof options.todo === 'string' && options.todo.length > 0) {
190
+ todoText = options.todo;
51
191
  }
52
- catch (err) {
53
- exitWithError(`Could not read file: ${file}`);
192
+ else {
193
+ if (process.stdin.isTTY) {
194
+ exitWithError('No TODO text. Use: roam save --todo "text" or echo "text" | roam save --todo');
195
+ }
196
+ todoText = (await readStdin()).trim();
197
+ }
198
+ if (!todoText) {
199
+ exitWithError('Empty TODO text');
200
+ }
201
+ const todos = todoText.split('\n').map(t => t.trim()).filter(Boolean);
202
+ if (options.debug) {
203
+ printDebug('TODO mode', true);
204
+ printDebug('Graph', options.graph || 'default');
205
+ printDebug('TODO items', todos);
206
+ }
207
+ const graph = resolveGraph(options, true);
208
+ const todoOps = new TodoOperations(graph);
209
+ const result = await todoOps.addTodos(todos);
210
+ if (result.success) {
211
+ console.log(`Added ${todos.length} TODO item(s) to today's daily page`);
212
+ }
213
+ else {
214
+ exitWithError('Failed to save TODO');
215
+ }
216
+ return;
217
+ }
218
+ // Determine content source: file, text argument, or stdin
219
+ let content;
220
+ let isFile = false;
221
+ let sourceFilename;
222
+ if (input) {
223
+ // Check if input is a file path that exists
224
+ if (existsSync(input)) {
225
+ isFile = true;
226
+ sourceFilename = input;
227
+ try {
228
+ content = readFileSync(input, 'utf-8');
229
+ }
230
+ catch (err) {
231
+ exitWithError(`Could not read file: ${input}`);
232
+ }
233
+ }
234
+ else {
235
+ // Treat as text content
236
+ content = input;
54
237
  }
55
- // Derive title from filename if not provided
56
- pageTitle = options.title || basename(file, '.md');
57
238
  }
58
239
  else {
59
240
  // Read from stdin
60
241
  if (process.stdin.isTTY) {
61
- exitWithError('No file specified and no input piped. Use: roam save <file.md> or cat file.md | roam save --title "Title"');
242
+ exitWithError('No input. Use: roam save "text", roam save <file>, or pipe content');
62
243
  }
63
- if (!options.title) {
64
- exitWithError('--title is required when piping from stdin');
244
+ content = await readStdin();
245
+ }
246
+ content = content.trim();
247
+ if (!content) {
248
+ exitWithError('Empty content');
249
+ }
250
+ // Determine format: JSON or markdown/text
251
+ const isJson = options.json || (isFile && sourceFilename?.endsWith('.json')) || looksLikeJsonArray(content);
252
+ // Parse content into blocks
253
+ let contentBlocks;
254
+ if (isJson) {
255
+ try {
256
+ contentBlocks = parseJsonContent(content);
257
+ }
258
+ catch (err) {
259
+ exitWithError(err instanceof Error ? err.message : 'Invalid JSON');
65
260
  }
66
- markdownContent = await readStdin();
67
- pageTitle = options.title;
68
261
  }
69
- if (!markdownContent.trim()) {
70
- exitWithError('Empty content received');
262
+ else if (isFile || content.includes('\n')) {
263
+ // Multi-line content: parse as markdown
264
+ const nodes = parseMarkdown(content);
265
+ contentBlocks = flattenNodes(nodes);
266
+ }
267
+ else {
268
+ // Single line text: detect heading syntax and strip hashes
269
+ const { heading_level, content: strippedContent } = parseMarkdownHeadingLevel(content);
270
+ contentBlocks = [{
271
+ text: strippedContent,
272
+ level: 1,
273
+ ...(heading_level > 0 && { heading: heading_level })
274
+ }];
275
+ }
276
+ if (contentBlocks.length === 0) {
277
+ exitWithError('No content blocks parsed');
278
+ }
279
+ // Parse categories
280
+ const categories = options.categories
281
+ ? options.categories.split(',').map(c => c.trim()).filter(Boolean)
282
+ : undefined;
283
+ // Determine parent type if specified
284
+ let parentUid;
285
+ let parentHeading;
286
+ let parentHeadingLevel;
287
+ if (options.parent) {
288
+ const cleanedParent = options.parent.replace(/^\(\(|\)\)$/g, '');
289
+ if (isBlockUid(cleanedParent)) {
290
+ parentUid = cleanedParent;
291
+ }
292
+ else {
293
+ // Parse heading syntax from parent text
294
+ const { heading_level, content } = parseMarkdownHeadingLevel(options.parent);
295
+ parentHeading = content;
296
+ if (heading_level > 0) {
297
+ parentHeadingLevel = heading_level;
298
+ }
299
+ }
71
300
  }
72
301
  if (options.debug) {
73
- printDebug('Page title', pageTitle);
74
- printDebug('Content length', markdownContent.length);
75
- printDebug('Update mode', options.update || false);
76
- }
77
- const graph = initializeGraph({
78
- token: API_TOKEN,
79
- graph: GRAPH_NAME
80
- });
81
- const pageOps = new PageOperations(graph);
82
- if (options.update) {
83
- // Use smart diff to update existing page
84
- const result = await pageOps.updatePageMarkdown(pageTitle, markdownContent, false // not dry run
85
- );
86
- if (result.success) {
87
- console.log(`Updated page '${pageTitle}'`);
88
- console.log(` ${result.summary}`);
89
- if (result.preservedUids.length > 0) {
90
- console.log(` Preserved ${result.preservedUids.length} block UID(s)`);
302
+ printDebug('Input', input || 'stdin');
303
+ printDebug('Is file', isFile);
304
+ printDebug('Is JSON', isJson);
305
+ printDebug('Graph', options.graph || 'default');
306
+ printDebug('Content blocks', contentBlocks.length);
307
+ printDebug('Parent UID', parentUid || 'none');
308
+ printDebug('Parent heading', parentHeading || 'none');
309
+ printDebug('Target page', options.page || 'daily page');
310
+ printDebug('Categories', categories || 'none');
311
+ printDebug('Title', options.title || 'none');
312
+ }
313
+ const graph = resolveGraph(options, true);
314
+ // Determine operation mode based on options
315
+ const hasParent = parentUid || parentHeading;
316
+ const hasTitle = options.title;
317
+ const wantsPage = hasTitle && !hasParent;
318
+ if (wantsPage || (isFile && !hasParent)) {
319
+ // PAGE MODE: create a page
320
+ const pageTitle = options.title || (sourceFilename ? basename(sourceFilename, '.md').replace('.json', '') : undefined);
321
+ if (!pageTitle) {
322
+ exitWithError('--title required for page creation from stdin');
323
+ }
324
+ const pageOps = new PageOperations(graph);
325
+ if (options.update) {
326
+ if (isJson) {
327
+ exitWithError('--update is not supported with JSON content');
328
+ }
329
+ const result = await pageOps.updatePageMarkdown(pageTitle, content, false);
330
+ if (result.success) {
331
+ console.log(`Updated page '${pageTitle}'`);
332
+ console.log(` ${result.summary}`);
333
+ if (result.preservedUids.length > 0) {
334
+ console.log(` Preserved ${result.preservedUids.length} block UID(s)`);
335
+ }
336
+ }
337
+ else {
338
+ exitWithError(`Failed to update page '${pageTitle}'`);
339
+ }
340
+ }
341
+ else {
342
+ const result = await pageOps.createPage(pageTitle, contentBlocks);
343
+ if (result.success) {
344
+ console.log(`Created page '${pageTitle}' (uid: ${result.uid})`);
345
+ }
346
+ else {
347
+ exitWithError(`Failed to create page '${pageTitle}'`);
348
+ }
349
+ }
350
+ return;
351
+ }
352
+ // BLOCK MODE: add content under parent or to daily page
353
+ if (parentUid) {
354
+ // Direct parent UID: use batch operations
355
+ const batchOps = new BatchOperations(graph);
356
+ // Build batch actions for all content blocks
357
+ const actions = [];
358
+ const uidMap = {};
359
+ for (let i = 0; i < contentBlocks.length; i++) {
360
+ const block = contentBlocks[i];
361
+ const uidPlaceholder = `block-${i}`;
362
+ uidMap[i] = uidPlaceholder;
363
+ // Determine parent for this block
364
+ let blockParent;
365
+ if (block.level === 1) {
366
+ blockParent = parentUid;
367
+ }
368
+ else {
369
+ // Find the closest ancestor at level - 1
370
+ let ancestorIndex = i - 1;
371
+ while (ancestorIndex >= 0 && contentBlocks[ancestorIndex].level >= block.level) {
372
+ ancestorIndex--;
373
+ }
374
+ if (ancestorIndex >= 0) {
375
+ blockParent = `{{uid:${uidMap[ancestorIndex]}}}`;
376
+ }
377
+ else {
378
+ blockParent = parentUid;
379
+ }
91
380
  }
381
+ actions.push({
382
+ action: 'create-block',
383
+ location: {
384
+ 'parent-uid': blockParent,
385
+ order: 'last'
386
+ },
387
+ string: block.text,
388
+ uid: `{{uid:${uidPlaceholder}}}`,
389
+ ...(block.heading && { heading: block.heading })
390
+ });
391
+ }
392
+ const result = await batchOps.processBatch(actions);
393
+ if (result.success && result.uid_map) {
394
+ // Output the first block's UID
395
+ console.log(result.uid_map['block-0']);
92
396
  }
93
397
  else {
94
- exitWithError(`Failed to update page '${pageTitle}'`);
398
+ const errorMsg = typeof result.error === 'string'
399
+ ? result.error
400
+ : result.error?.message || 'Unknown error';
401
+ exitWithError(`Failed to save: ${errorMsg}`);
95
402
  }
403
+ return;
404
+ }
405
+ // Parent heading or target page: get target page UID first
406
+ let pageUid;
407
+ if (options.page) {
408
+ // Strip (( )) wrapper if UID, but NOT [[ ]] (that's valid page title syntax)
409
+ const cleanedPage = options.page.replace(/^\(\(|\)\)$/g, '');
410
+ if (isBlockUid(cleanedPage)) {
411
+ pageUid = cleanedPage;
412
+ }
413
+ else {
414
+ pageUid = await findOrCreatePage(graph, options.page);
415
+ }
416
+ }
417
+ else {
418
+ pageUid = await getDailyPageUid(graph);
419
+ }
420
+ if (options.debug) {
421
+ printDebug('Target page UID', pageUid);
422
+ }
423
+ // Resolve heading to parent UID if specified
424
+ let targetParentUid;
425
+ if (parentHeading) {
426
+ targetParentUid = await findOrCreateHeading(graph, pageUid, parentHeading, parentHeadingLevel);
96
427
  }
97
428
  else {
98
- // Create new page (or add content to existing empty page)
99
- const nodes = parseMarkdown(markdownContent);
100
- const contentBlocks = flattenNodes(nodes);
101
- if (contentBlocks.length === 0) {
102
- exitWithError('No content blocks parsed from input');
429
+ targetParentUid = pageUid;
430
+ }
431
+ // Format categories as Roam tags if provided
432
+ const categoryTags = categories?.map(cat => {
433
+ return cat.includes(' ') ? `#[[${cat}]]` : `#${cat}`;
434
+ }).join(' ') || '';
435
+ // Create all blocks using batch operations
436
+ const batchOps = new BatchOperations(graph);
437
+ const actions = [];
438
+ const uidMap = {};
439
+ for (let i = 0; i < contentBlocks.length; i++) {
440
+ const block = contentBlocks[i];
441
+ const uidPlaceholder = `block-${i}`;
442
+ uidMap[i] = uidPlaceholder;
443
+ // Determine parent for this block
444
+ let blockParent;
445
+ if (block.level === 1) {
446
+ blockParent = targetParentUid;
103
447
  }
104
- if (options.debug) {
105
- printDebug('Parsed blocks', contentBlocks.length);
448
+ else {
449
+ // Find the closest ancestor at level - 1
450
+ let ancestorIndex = i - 1;
451
+ while (ancestorIndex >= 0 && contentBlocks[ancestorIndex].level >= block.level) {
452
+ ancestorIndex--;
453
+ }
454
+ if (ancestorIndex >= 0) {
455
+ blockParent = `{{uid:${uidMap[ancestorIndex]}}}`;
456
+ }
457
+ else {
458
+ blockParent = targetParentUid;
459
+ }
106
460
  }
107
- const result = await pageOps.createPage(pageTitle, contentBlocks);
108
- if (result.success) {
109
- console.log(`Created page '${pageTitle}' (uid: ${result.uid})`);
461
+ // Append category tags to first block only
462
+ const blockText = i === 0 && categoryTags
463
+ ? `${block.text} ${categoryTags}`
464
+ : block.text;
465
+ actions.push({
466
+ action: 'create-block',
467
+ location: {
468
+ 'parent-uid': blockParent,
469
+ order: 'last'
470
+ },
471
+ string: blockText,
472
+ uid: `{{uid:${uidPlaceholder}}}`,
473
+ ...(block.heading && { heading: block.heading })
474
+ });
475
+ }
476
+ const result = await batchOps.processBatch(actions);
477
+ if (result.success && result.uid_map) {
478
+ // Output first block UID (and parent if heading was used)
479
+ if (parentHeading) {
480
+ console.log(`${result.uid_map['block-0']} ${targetParentUid}`);
110
481
  }
111
482
  else {
112
- exitWithError(`Failed to create page '${pageTitle}'`);
483
+ console.log(result.uid_map['block-0']);
113
484
  }
114
485
  }
486
+ else {
487
+ const errorMsg = typeof result.error === 'string'
488
+ ? result.error
489
+ : result.error?.message || 'Unknown error';
490
+ exitWithError(`Failed to save: ${errorMsg}`);
491
+ }
115
492
  }
116
493
  catch (error) {
117
494
  const message = error instanceof Error ? error.message : String(error);