roam-research-mcp 1.0.0 → 1.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.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Simple in-memory cache for page title -> UID mappings.
3
+ * Pages are stable entities that rarely get deleted, making them safe to cache.
4
+ * This reduces redundant API queries when looking up the same page multiple times.
5
+ */
6
+ class PageUidCache {
7
+ constructor() {
8
+ this.cache = new Map(); // title (lowercase) -> UID
9
+ }
10
+ /**
11
+ * Get a cached page UID by title.
12
+ * @param title - Page title (case-insensitive)
13
+ * @returns The cached UID or undefined if not cached
14
+ */
15
+ get(title) {
16
+ return this.cache.get(title.toLowerCase());
17
+ }
18
+ /**
19
+ * Cache a page title -> UID mapping.
20
+ * @param title - Page title (will be stored lowercase)
21
+ * @param uid - Page UID
22
+ */
23
+ set(title, uid) {
24
+ this.cache.set(title.toLowerCase(), uid);
25
+ }
26
+ /**
27
+ * Check if a page title is cached.
28
+ * @param title - Page title (case-insensitive)
29
+ */
30
+ has(title) {
31
+ return this.cache.has(title.toLowerCase());
32
+ }
33
+ /**
34
+ * Called when a page is created - immediately add to cache.
35
+ * @param title - Page title
36
+ * @param uid - Page UID
37
+ */
38
+ onPageCreated(title, uid) {
39
+ this.set(title, uid);
40
+ }
41
+ /**
42
+ * Clear the cache (useful for testing or session reset).
43
+ */
44
+ clear() {
45
+ this.cache.clear();
46
+ }
47
+ /**
48
+ * Get the current cache size.
49
+ */
50
+ get size() {
51
+ return this.cache.size;
52
+ }
53
+ }
54
+ // Singleton instance - shared across all operations
55
+ export const pageUidCache = new PageUidCache();
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ import { initializeGraph } from '@roam-research/roam-api-sdk';
3
+ import { API_TOKEN, GRAPH_NAME } from '../config/environment.js';
4
+ import { PageOperations } from '../tools/operations/pages.js';
5
+ import { parseMarkdown } from '../markdown-utils.js';
6
+ /**
7
+ * Flatten nested MarkdownNode[] to flat array with absolute levels
8
+ */
9
+ function flattenNodes(nodes, baseLevel = 1) {
10
+ const result = [];
11
+ for (const node of nodes) {
12
+ result.push({
13
+ text: node.content,
14
+ level: baseLevel,
15
+ ...(node.heading_level && { heading: node.heading_level })
16
+ });
17
+ if (node.children.length > 0) {
18
+ result.push(...flattenNodes(node.children, baseLevel + 1));
19
+ }
20
+ }
21
+ return result;
22
+ }
23
+ /**
24
+ * Read all input from stdin
25
+ */
26
+ async function readStdin() {
27
+ const chunks = [];
28
+ for await (const chunk of process.stdin) {
29
+ chunks.push(chunk);
30
+ }
31
+ return Buffer.concat(chunks).toString('utf-8');
32
+ }
33
+ /**
34
+ * Show usage help
35
+ */
36
+ function showUsage() {
37
+ console.error('Usage: roam-import <page-title>');
38
+ console.error('');
39
+ console.error('Reads markdown from stdin and imports to Roam Research.');
40
+ console.error('');
41
+ console.error('Examples:');
42
+ console.error(' cat document.md | roam-import "Meeting Notes"');
43
+ console.error(' pbpaste | roam-import "Ideas"');
44
+ console.error(' echo "- Item 1\\n- Item 2" | roam-import "Quick Note"');
45
+ console.error('');
46
+ console.error('Environment variables required:');
47
+ console.error(' ROAM_API_TOKEN Your Roam Research API token');
48
+ console.error(' ROAM_GRAPH_NAME Your Roam graph name');
49
+ }
50
+ async function main() {
51
+ // Parse CLI arguments
52
+ const args = process.argv.slice(2);
53
+ const pageTitle = args[0];
54
+ if (!pageTitle || pageTitle === '--help' || pageTitle === '-h') {
55
+ showUsage();
56
+ process.exit(pageTitle ? 0 : 1);
57
+ }
58
+ // Check if stdin is a TTY (no input piped)
59
+ if (process.stdin.isTTY) {
60
+ console.error('Error: No input received. Pipe markdown content to this command.');
61
+ console.error('');
62
+ showUsage();
63
+ process.exit(1);
64
+ }
65
+ // Read markdown from stdin
66
+ const markdownContent = await readStdin();
67
+ if (!markdownContent.trim()) {
68
+ console.error('Error: Empty input received.');
69
+ process.exit(1);
70
+ }
71
+ // Initialize Roam graph
72
+ const graph = initializeGraph({
73
+ token: API_TOKEN,
74
+ graph: GRAPH_NAME
75
+ });
76
+ // Parse markdown to nodes
77
+ const nodes = parseMarkdown(markdownContent);
78
+ // Flatten nested structure to content blocks
79
+ const contentBlocks = flattenNodes(nodes);
80
+ if (contentBlocks.length === 0) {
81
+ console.error('Error: No content blocks parsed from input.');
82
+ process.exit(1);
83
+ }
84
+ // Create page with content
85
+ const pageOps = new PageOperations(graph);
86
+ const result = await pageOps.createPage(pageTitle, contentBlocks);
87
+ if (result.success) {
88
+ console.log(`Created page '${pageTitle}' (uid: ${result.uid})`);
89
+ }
90
+ else {
91
+ console.error(`Failed to create page '${pageTitle}'`);
92
+ process.exit(1);
93
+ }
94
+ }
95
+ main().catch((error) => {
96
+ console.error(`Error: ${error.message}`);
97
+ process.exit(1);
98
+ });
@@ -42,5 +42,7 @@ if (!API_TOKEN || !GRAPH_NAME) {
42
42
  ' ROAM_GRAPH_NAME=your-graph-name');
43
43
  }
44
44
  const HTTP_STREAM_PORT = process.env.HTTP_STREAM_PORT || '8088'; // Default to 8088
45
- const CORS_ORIGIN = process.env.CORS_ORIGIN || 'http://localhost:5678';
46
- export { API_TOKEN, GRAPH_NAME, HTTP_STREAM_PORT, CORS_ORIGIN };
45
+ const CORS_ORIGINS = (process.env.CORS_ORIGIN || 'http://localhost:5678,https://roamresearch.com')
46
+ .split(',')
47
+ .map(origin => origin.trim());
48
+ export { API_TOKEN, GRAPH_NAME, HTTP_STREAM_PORT, CORS_ORIGINS };
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Action Generator
3
+ *
4
+ * Generates correctly ordered batch actions from a DiffResult.
5
+ * The order is critical for Roam API correctness:
6
+ * 1. Creates (top-down to ensure parents exist before children)
7
+ * 2. Moves (reposition existing blocks)
8
+ * 3. Updates (text/heading changes)
9
+ * 4. Deletes (bottom-up; reverse order to delete children before parents)
10
+ */
11
+ /**
12
+ * Generate a correctly ordered array of batch actions from a DiffResult.
13
+ *
14
+ * The ordering ensures:
15
+ * - Parent blocks are created before their children
16
+ * - Blocks are moved before text updates (in case moves affect siblings)
17
+ * - Children are deleted before their parents
18
+ *
19
+ * @param diff - The DiffResult containing categorized actions
20
+ * @returns Ordered array of batch actions ready for Roam API
21
+ */
22
+ export function generateBatchActions(diff) {
23
+ const actions = [];
24
+ // 1. Creates (in markdown order, parents before children)
25
+ // The creates are already in the correct order from diffBlockTrees
26
+ actions.push(...diff.creates);
27
+ // 2. Moves (reposition existing blocks)
28
+ actions.push(...diff.moves);
29
+ // 3. Updates (text/heading changes)
30
+ actions.push(...diff.updates);
31
+ // 4. Deletes (reversed to delete children before parents)
32
+ // This is important because Roam will fail if you try to delete
33
+ // a parent block while it still has children
34
+ actions.push(...[...diff.deletes].reverse());
35
+ return actions;
36
+ }
37
+ /**
38
+ * Filter actions to only include specific action types.
39
+ * Useful for dry-run analysis or debugging.
40
+ */
41
+ export function filterActions(actions, types) {
42
+ const typeSet = new Set(types);
43
+ return actions.filter((a) => typeSet.has(a.action));
44
+ }
45
+ /**
46
+ * Group actions by their type for analysis.
47
+ */
48
+ export function groupActionsByType(actions) {
49
+ const creates = [];
50
+ const updates = [];
51
+ const moves = [];
52
+ const deletes = [];
53
+ for (const action of actions) {
54
+ switch (action.action) {
55
+ case 'create-block':
56
+ creates.push(action);
57
+ break;
58
+ case 'update-block':
59
+ updates.push(action);
60
+ break;
61
+ case 'move-block':
62
+ moves.push(action);
63
+ break;
64
+ case 'delete-block':
65
+ deletes.push(action);
66
+ break;
67
+ }
68
+ }
69
+ return { creates, updates, moves, deletes };
70
+ }
71
+ /**
72
+ * Summarize actions for logging purposes.
73
+ */
74
+ export function summarizeActions(actions) {
75
+ const grouped = groupActionsByType(actions);
76
+ const parts = [];
77
+ if (grouped.creates.length > 0) {
78
+ parts.push(`${grouped.creates.length} create(s)`);
79
+ }
80
+ if (grouped.moves.length > 0) {
81
+ parts.push(`${grouped.moves.length} move(s)`);
82
+ }
83
+ if (grouped.updates.length > 0) {
84
+ parts.push(`${grouped.updates.length} update(s)`);
85
+ }
86
+ if (grouped.deletes.length > 0) {
87
+ parts.push(`${grouped.deletes.length} delete(s)`);
88
+ }
89
+ if (parts.length === 0) {
90
+ return 'No changes';
91
+ }
92
+ return parts.join(', ');
93
+ }
@@ -0,0 +1,125 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateBatchActions, filterActions, groupActionsByType, summarizeActions, } from './actions.js';
3
+ describe('generateBatchActions', () => {
4
+ function createDiffResult(creates = [], updates = [], moves = [], deletes = []) {
5
+ return {
6
+ creates,
7
+ updates,
8
+ moves,
9
+ deletes,
10
+ preservedUids: new Set(),
11
+ };
12
+ }
13
+ it('returns actions in correct order: creates, moves, updates, deletes', () => {
14
+ const diff = createDiffResult([{ action: 'create-block', block: { string: 'new' }, location: { 'parent-uid': 'p', order: 0 } }], [{ action: 'update-block', block: { uid: 'u1', string: 'updated' } }], [{ action: 'move-block', block: { uid: 'u2' }, location: { 'parent-uid': 'p', order: 1 } }], [{ action: 'delete-block', block: { uid: 'u3' } }]);
15
+ const actions = generateBatchActions(diff);
16
+ expect(actions[0].action).toBe('create-block');
17
+ expect(actions[1].action).toBe('move-block');
18
+ expect(actions[2].action).toBe('update-block');
19
+ expect(actions[3].action).toBe('delete-block');
20
+ });
21
+ it('reverses delete order for child-before-parent deletion', () => {
22
+ const diff = createDiffResult([], [], [], [
23
+ { action: 'delete-block', block: { uid: 'parent' } },
24
+ { action: 'delete-block', block: { uid: 'child' } },
25
+ ]);
26
+ const actions = generateBatchActions(diff);
27
+ // Deletes should be reversed
28
+ expect(actions[0].block.uid).toBe('child');
29
+ expect(actions[1].block.uid).toBe('parent');
30
+ });
31
+ it('returns empty array for empty diff', () => {
32
+ const diff = createDiffResult();
33
+ const actions = generateBatchActions(diff);
34
+ expect(actions).toEqual([]);
35
+ });
36
+ it('preserves all actions without modification', () => {
37
+ const createAction = {
38
+ action: 'create-block',
39
+ block: { uid: 'new1', string: 'Test', heading: 2 },
40
+ location: { 'parent-uid': 'page', order: 0 },
41
+ };
42
+ const diff = createDiffResult([createAction]);
43
+ const actions = generateBatchActions(diff);
44
+ expect(actions[0]).toEqual(createAction);
45
+ });
46
+ });
47
+ describe('filterActions', () => {
48
+ const allActions = [
49
+ { action: 'create-block', block: { string: 'new' }, location: { 'parent-uid': 'p', order: 0 } },
50
+ { action: 'update-block', block: { uid: 'u1', string: 'updated' } },
51
+ { action: 'move-block', block: { uid: 'u2' }, location: { 'parent-uid': 'p', order: 1 } },
52
+ { action: 'delete-block', block: { uid: 'u3' } },
53
+ ];
54
+ it('filters to only specified action types', () => {
55
+ const creates = filterActions(allActions, ['create-block']);
56
+ expect(creates.length).toBe(1);
57
+ expect(creates[0].action).toBe('create-block');
58
+ });
59
+ it('supports multiple action types', () => {
60
+ const modifying = filterActions(allActions, ['create-block', 'update-block']);
61
+ expect(modifying.length).toBe(2);
62
+ });
63
+ it('returns empty array when no matches', () => {
64
+ const emptyActions = [];
65
+ const result = filterActions(emptyActions, ['create-block']);
66
+ expect(result).toEqual([]);
67
+ });
68
+ });
69
+ describe('groupActionsByType', () => {
70
+ it('groups actions by their type', () => {
71
+ const actions = [
72
+ { action: 'create-block', block: { string: 'a' }, location: { 'parent-uid': 'p', order: 0 } },
73
+ { action: 'create-block', block: { string: 'b' }, location: { 'parent-uid': 'p', order: 1 } },
74
+ { action: 'update-block', block: { uid: 'u1', string: 'c' } },
75
+ { action: 'delete-block', block: { uid: 'u2' } },
76
+ ];
77
+ const grouped = groupActionsByType(actions);
78
+ expect(grouped.creates.length).toBe(2);
79
+ expect(grouped.updates.length).toBe(1);
80
+ expect(grouped.moves.length).toBe(0);
81
+ expect(grouped.deletes.length).toBe(1);
82
+ });
83
+ it('returns empty arrays for missing types', () => {
84
+ const actions = [];
85
+ const grouped = groupActionsByType(actions);
86
+ expect(grouped.creates).toEqual([]);
87
+ expect(grouped.updates).toEqual([]);
88
+ expect(grouped.moves).toEqual([]);
89
+ expect(grouped.deletes).toEqual([]);
90
+ });
91
+ });
92
+ describe('summarizeActions', () => {
93
+ it('summarizes action counts', () => {
94
+ const actions = [
95
+ { action: 'create-block', block: { string: 'a' }, location: { 'parent-uid': 'p', order: 0 } },
96
+ { action: 'create-block', block: { string: 'b' }, location: { 'parent-uid': 'p', order: 1 } },
97
+ { action: 'update-block', block: { uid: 'u1', string: 'c' } },
98
+ { action: 'delete-block', block: { uid: 'u2' } },
99
+ ];
100
+ const summary = summarizeActions(actions);
101
+ expect(summary).toBe('2 create(s), 1 update(s), 1 delete(s)');
102
+ });
103
+ it('returns "No changes" for empty actions', () => {
104
+ const summary = summarizeActions([]);
105
+ expect(summary).toBe('No changes');
106
+ });
107
+ it('only includes non-zero counts', () => {
108
+ const actions = [
109
+ { action: 'update-block', block: { uid: 'u1', string: 'updated' } },
110
+ ];
111
+ const summary = summarizeActions(actions);
112
+ expect(summary).toBe('1 update(s)');
113
+ expect(summary).not.toContain('create');
114
+ expect(summary).not.toContain('move');
115
+ expect(summary).not.toContain('delete');
116
+ });
117
+ it('includes moves when present', () => {
118
+ const actions = [
119
+ { action: 'move-block', block: { uid: 'u1' }, location: { 'parent-uid': 'p', order: 0 } },
120
+ { action: 'move-block', block: { uid: 'u2' }, location: { 'parent-uid': 'p', order: 1 } },
121
+ ];
122
+ const summary = summarizeActions(actions);
123
+ expect(summary).toBe('2 move(s)');
124
+ });
125
+ });
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Diff Computation
3
+ *
4
+ * Computes the minimal set of batch actions needed to transform
5
+ * existing blocks into the desired new block structure.
6
+ */
7
+ import { flattenExistingBlocks } from './parser.js';
8
+ import { matchBlocks, normalizeText } from './matcher.js';
9
+ /**
10
+ * Compute the diff between existing blocks and desired new blocks.
11
+ *
12
+ * This function:
13
+ * 1. Matches new blocks to existing blocks by content similarity
14
+ * 2. For matched blocks: generates update/move actions as needed
15
+ * 3. For unmatched new blocks: generates create actions
16
+ * 4. For unmatched existing blocks: generates delete actions
17
+ *
18
+ * @param existing - Array of existing block trees
19
+ * @param newBlocks - Flat array of new blocks (desired state)
20
+ * @param parentUid - UID of the parent page/block
21
+ * @returns DiffResult with categorized actions
22
+ */
23
+ export function diffBlockTrees(existing, newBlocks, parentUid) {
24
+ const result = {
25
+ creates: [],
26
+ updates: [],
27
+ moves: [],
28
+ deletes: [],
29
+ preservedUids: new Set(),
30
+ };
31
+ // Flatten existing blocks for matching
32
+ const existingFlat = flattenExistingBlocks(existing);
33
+ // Step 1: Match blocks by content
34
+ const matches = matchBlocks(existingFlat, newBlocks);
35
+ // Build uid -> ExistingBlock mapping
36
+ const existingByUid = new Map(existingFlat.map((eb) => [eb.uid, eb]));
37
+ /**
38
+ * Resolve the desired parent UID for a new block.
39
+ * If the parent was matched to an existing block, use that UID.
40
+ */
41
+ function desiredParentUid(newBlock) {
42
+ if (!newBlock.parentRef)
43
+ return parentUid;
44
+ const parentNewUid = newBlock.parentRef.blockUid;
45
+ if (parentNewUid === parentUid)
46
+ return parentUid;
47
+ // If parent is matched, target the existing parent UID
48
+ return matches.get(parentNewUid) ?? parentNewUid;
49
+ }
50
+ // Build desired structure: map each new block to its desired parent
51
+ // and group siblings by their desired parent
52
+ const newUidToDesiredParent = new Map();
53
+ const siblingsByDesiredParent = new Map();
54
+ for (const newBlock of newBlocks) {
55
+ const newUid = newBlock.ref.blockUid;
56
+ const targetUid = matches.get(newUid) ?? newUid;
57
+ const dParent = desiredParentUid(newBlock);
58
+ newUidToDesiredParent.set(newUid, dParent);
59
+ if (!siblingsByDesiredParent.has(dParent)) {
60
+ siblingsByDesiredParent.set(dParent, []);
61
+ }
62
+ siblingsByDesiredParent.get(dParent).push(targetUid);
63
+ }
64
+ // Step 2: Process each new block
65
+ for (const newBlock of newBlocks) {
66
+ const newUid = newBlock.ref.blockUid;
67
+ if (matches.has(newUid)) {
68
+ // Block matched to an existing block
69
+ const existUid = matches.get(newUid);
70
+ const existBlock = existingByUid.get(existUid);
71
+ const currentParent = existBlock.parentUid ?? parentUid;
72
+ const dParent = newUidToDesiredParent.get(newUid);
73
+ result.preservedUids.add(existUid);
74
+ // Check for text/heading changes -> update-block
75
+ let needsUpdate = false;
76
+ const updateAction = {
77
+ action: 'update-block',
78
+ block: { uid: existUid },
79
+ };
80
+ if (normalizeText(newBlock.text) !== normalizeText(existBlock.text)) {
81
+ updateAction.block.string = newBlock.text;
82
+ needsUpdate = true;
83
+ }
84
+ if (newBlock.heading !== existBlock.heading) {
85
+ if (newBlock.heading !== null) {
86
+ updateAction.block.heading = newBlock.heading;
87
+ needsUpdate = true;
88
+ }
89
+ else if (existBlock.heading !== null) {
90
+ // Remove heading by setting to 0
91
+ updateAction.block.heading = 0;
92
+ needsUpdate = true;
93
+ }
94
+ }
95
+ if (needsUpdate) {
96
+ result.updates.push(updateAction);
97
+ }
98
+ // Check for parent/order changes -> move-block
99
+ const desiredSiblings = siblingsByDesiredParent.get(dParent) ?? [];
100
+ const desiredOrder = desiredSiblings.indexOf(existUid);
101
+ if (currentParent !== dParent || existBlock.order !== desiredOrder) {
102
+ result.moves.push({
103
+ action: 'move-block',
104
+ block: { uid: existUid },
105
+ location: { 'parent-uid': dParent, order: desiredOrder },
106
+ });
107
+ }
108
+ }
109
+ else {
110
+ // No match -> create-block
111
+ const dParent = newUidToDesiredParent.get(newUid);
112
+ const desiredSiblings = siblingsByDesiredParent.get(dParent) ?? [];
113
+ const desiredOrder = desiredSiblings.indexOf(newUid);
114
+ const createAction = {
115
+ action: 'create-block',
116
+ location: {
117
+ 'parent-uid': dParent,
118
+ order: desiredOrder >= 0 ? desiredOrder : 'last',
119
+ },
120
+ block: {
121
+ uid: newBlock.ref.blockUid,
122
+ string: newBlock.text,
123
+ },
124
+ };
125
+ if (newBlock.heading !== null) {
126
+ createAction.block.heading = newBlock.heading;
127
+ }
128
+ if (newBlock.open !== undefined) {
129
+ createAction.block.open = newBlock.open;
130
+ }
131
+ result.creates.push(createAction);
132
+ }
133
+ }
134
+ // Step 3: Find unmatched existing blocks -> delete
135
+ const matchedExistingUids = new Set(matches.values());
136
+ for (const existBlock of existingFlat) {
137
+ if (!matchedExistingUids.has(existBlock.uid)) {
138
+ result.deletes.push({
139
+ action: 'delete-block',
140
+ block: { uid: existBlock.uid },
141
+ });
142
+ }
143
+ }
144
+ return result;
145
+ }
146
+ /**
147
+ * Diff blocks at a specific level (for hierarchical diffing).
148
+ * Used internally for recursive tree comparison.
149
+ */
150
+ export function diffBlockLevel(existing, newBlocks, parentUid) {
151
+ // Filter to only blocks at this level (direct children of parentUid)
152
+ const existingAtLevel = existing.filter((e) => e.parentUid === parentUid || e.parentUid === null);
153
+ const newAtLevel = newBlocks.filter((n) => n.parentRef?.blockUid === parentUid || (!n.parentRef && parentUid === parentUid));
154
+ return diffBlockTrees(existingAtLevel, newAtLevel, parentUid);
155
+ }