roam-research-mcp 0.14.0 → 0.17.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.
- package/README.md +124 -1
- package/build/markdown-utils.js +151 -38
- package/build/search/hierarchy-search.js +28 -24
- package/build/search/index.js +1 -0
- package/build/search/tag-search.js +16 -70
- package/build/search/text-search.js +32 -0
- package/build/server/roam-server.js +37 -4
- package/build/tools/schemas.js +73 -0
- package/build/tools/{handlers.js → tool-handlers.js} +153 -68
- package/package.json +1 -1
- package/build/search/date-search.js +0 -38
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@ npm run build
|
|
|
27
27
|
|
|
28
28
|
## Features
|
|
29
29
|
|
|
30
|
-
The server provides
|
|
30
|
+
The server provides eleven powerful tools for interacting with Roam Research:
|
|
31
31
|
|
|
32
32
|
1. `roam_fetch_page_by_title`: Fetch and read a page's content by title, recursively resolving block references up to 4 levels deep
|
|
33
33
|
2. `roam_create_page`: Create new pages with optional content
|
|
@@ -37,6 +37,9 @@ The server provides eight powerful tools for interacting with Roam Research:
|
|
|
37
37
|
6. `roam_create_outline`: Create hierarchical outlines with proper nesting and structure
|
|
38
38
|
7. `roam_search_block_refs`: Search for block references within pages or across the graph
|
|
39
39
|
8. `roam_search_hierarchy`: Navigate and search through block parent-child relationships
|
|
40
|
+
9. `find_pages_modified_today`: Find all pages that have been modified since midnight today
|
|
41
|
+
10. `roam_search_by_text`: Search for blocks containing specific text across all pages or within a specific page
|
|
42
|
+
11. `roam_update_block`: Update block content with direct text or pattern-based transformations
|
|
40
43
|
|
|
41
44
|
## Setup
|
|
42
45
|
|
|
@@ -322,6 +325,126 @@ Returns:
|
|
|
322
325
|
}
|
|
323
326
|
```
|
|
324
327
|
|
|
328
|
+
### Search By Text
|
|
329
|
+
|
|
330
|
+
Search for blocks containing specific text across all pages or within a specific page:
|
|
331
|
+
|
|
332
|
+
```typescript
|
|
333
|
+
use_mcp_tool roam-research roam_search_by_text {
|
|
334
|
+
"text": "search text",
|
|
335
|
+
"page_title_uid": "optional-page-title-or-uid",
|
|
336
|
+
"case_sensitive": false
|
|
337
|
+
}
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Features:
|
|
341
|
+
|
|
342
|
+
- Search for any text across all blocks in the graph
|
|
343
|
+
- Optional page-scoped search
|
|
344
|
+
- Case-sensitive or case-insensitive search
|
|
345
|
+
- Returns block content with page context
|
|
346
|
+
- Efficient text matching using Datalog queries
|
|
347
|
+
|
|
348
|
+
Parameters:
|
|
349
|
+
|
|
350
|
+
- `text`: The text to search for (required)
|
|
351
|
+
- `page_title_uid`: Title or UID of the page to search in (optional)
|
|
352
|
+
- `case_sensitive`: Whether to perform a case-sensitive search (optional, default: false)
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
|
|
356
|
+
```json
|
|
357
|
+
{
|
|
358
|
+
"success": true,
|
|
359
|
+
"matches": [
|
|
360
|
+
{
|
|
361
|
+
"block_uid": "matching-block-uid",
|
|
362
|
+
"content": "Block content containing search text",
|
|
363
|
+
"page_title": "Page containing block"
|
|
364
|
+
}
|
|
365
|
+
],
|
|
366
|
+
"message": "Found N block(s) containing \"search text\""
|
|
367
|
+
}
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
### Update Block Content
|
|
371
|
+
|
|
372
|
+
Update a block's content using either direct text replacement or pattern-based transformations:
|
|
373
|
+
|
|
374
|
+
```typescript
|
|
375
|
+
use_mcp_tool roam-research roam_update_block {
|
|
376
|
+
"block_uid": "target-block-uid",
|
|
377
|
+
"content": "New block content"
|
|
378
|
+
}
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
Or use pattern-based transformation:
|
|
382
|
+
|
|
383
|
+
```typescript
|
|
384
|
+
use_mcp_tool roam-research roam_update_block {
|
|
385
|
+
"block_uid": "target-block-uid",
|
|
386
|
+
"transform_pattern": {
|
|
387
|
+
"find": "\\bPython\\b",
|
|
388
|
+
"replace": "[[Python]]",
|
|
389
|
+
"global": true
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
Features:
|
|
395
|
+
|
|
396
|
+
- Two update modes:
|
|
397
|
+
- Direct content replacement
|
|
398
|
+
- Pattern-based transformation using regex
|
|
399
|
+
- Verify block existence before updating
|
|
400
|
+
- Return updated content in response
|
|
401
|
+
- Support for global or single-match replacements
|
|
402
|
+
- Preserve block relationships and metadata
|
|
403
|
+
|
|
404
|
+
Parameters:
|
|
405
|
+
|
|
406
|
+
- `block_uid`: UID of the block to update (required)
|
|
407
|
+
- `content`: New content for the block (if using direct replacement)
|
|
408
|
+
- `transform_pattern`: Pattern for transforming existing content:
|
|
409
|
+
- `find`: Text or regex pattern to find
|
|
410
|
+
- `replace`: Text to replace with
|
|
411
|
+
- `global`: Whether to replace all occurrences (default: true)
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
|
|
415
|
+
```json
|
|
416
|
+
{
|
|
417
|
+
"success": true,
|
|
418
|
+
"content": "Updated block content"
|
|
419
|
+
}
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
### Find Pages Modified Today
|
|
423
|
+
|
|
424
|
+
Find all pages that have been modified since midnight today:
|
|
425
|
+
|
|
426
|
+
```typescript
|
|
427
|
+
use_mcp_tool roam-research find_pages_modified_today {}
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
Features:
|
|
431
|
+
|
|
432
|
+
- Tracks all modifications made to pages since midnight
|
|
433
|
+
- Detects changes at any level in the block hierarchy
|
|
434
|
+
- Returns unique list of modified page titles
|
|
435
|
+
- Includes count of modified pages
|
|
436
|
+
- No parameters required
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
|
|
440
|
+
```json
|
|
441
|
+
{
|
|
442
|
+
"success": true,
|
|
443
|
+
"pages": ["Page 1", "Page 2"],
|
|
444
|
+
"message": "Found 2 page(s) modified today"
|
|
445
|
+
}
|
|
446
|
+
```
|
|
447
|
+
|
|
325
448
|
### Search Block Hierarchy
|
|
326
449
|
|
|
327
450
|
Navigate and search through block parent-child relationships:
|
package/build/markdown-utils.js
CHANGED
|
@@ -41,25 +41,134 @@ function convertAllTables(text) {
|
|
|
41
41
|
return '\n' + convertTableToRoamFormat(match) + '\n';
|
|
42
42
|
});
|
|
43
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Parse markdown heading syntax (e.g. "### Heading") and return the heading level (1-3) and content.
|
|
46
|
+
* Heading level is determined by the number of # characters (e.g. # = h1, ## = h2, ### = h3).
|
|
47
|
+
* Returns heading_level: 0 for non-heading content.
|
|
48
|
+
*/
|
|
49
|
+
function parseMarkdownHeadingLevel(text) {
|
|
50
|
+
const match = text.match(/^(#{1,3})\s+(.+)$/);
|
|
51
|
+
if (match) {
|
|
52
|
+
return {
|
|
53
|
+
heading_level: match[1].length, // Number of # characters determines heading level
|
|
54
|
+
content: match[2].trim()
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
heading_level: 0, // Not a heading
|
|
59
|
+
content: text.trim()
|
|
60
|
+
};
|
|
61
|
+
}
|
|
44
62
|
function convertToRoamMarkdown(text) {
|
|
45
|
-
//
|
|
63
|
+
// Handle double asterisks/underscores (bold)
|
|
46
64
|
text = text.replace(/\*\*(.+?)\*\*/g, '**$1**'); // Preserve double asterisks
|
|
47
|
-
//
|
|
65
|
+
// Handle single asterisks/underscores (italic)
|
|
48
66
|
text = text.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '__$1__'); // Single asterisk to double underscore
|
|
49
67
|
text = text.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, '__$1__'); // Single underscore to double underscore
|
|
50
68
|
// Handle highlights
|
|
51
69
|
text = text.replace(/==(.+?)==/g, '^^$1^^');
|
|
70
|
+
// Convert tasks
|
|
71
|
+
text = text.replace(/- \[ \]/g, '- {{[[TODO]]}}');
|
|
72
|
+
text = text.replace(/- \[x\]/g, '- {{[[DONE]]}}');
|
|
52
73
|
// Convert tables
|
|
53
74
|
text = convertAllTables(text);
|
|
54
75
|
return text;
|
|
55
76
|
}
|
|
56
77
|
function parseMarkdown(markdown) {
|
|
78
|
+
// Convert markdown syntax first
|
|
79
|
+
markdown = convertToRoamMarkdown(markdown);
|
|
57
80
|
const lines = markdown.split('\n');
|
|
58
81
|
const rootNodes = [];
|
|
59
82
|
const stack = [];
|
|
83
|
+
let inCodeBlock = false;
|
|
84
|
+
let codeBlockContent = '';
|
|
85
|
+
let codeBlockIndentation = 0;
|
|
86
|
+
let codeBlockParentLevel = 0;
|
|
60
87
|
for (let i = 0; i < lines.length; i++) {
|
|
61
88
|
const line = lines[i];
|
|
62
89
|
const trimmedLine = line.trimEnd();
|
|
90
|
+
// Handle code blocks
|
|
91
|
+
if (trimmedLine.match(/^(\s*)```/)) {
|
|
92
|
+
if (!inCodeBlock) {
|
|
93
|
+
// Start of code block
|
|
94
|
+
inCodeBlock = true;
|
|
95
|
+
// Store the opening backticks without indentation
|
|
96
|
+
codeBlockContent = trimmedLine.trimStart() + '\n';
|
|
97
|
+
codeBlockIndentation = line.match(/^\s*/)?.[0].length ?? 0;
|
|
98
|
+
// Save current parent level
|
|
99
|
+
codeBlockParentLevel = stack.length;
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
// End of code block
|
|
103
|
+
inCodeBlock = false;
|
|
104
|
+
// Add closing backticks without indentation
|
|
105
|
+
codeBlockContent += trimmedLine.trimStart();
|
|
106
|
+
// Process the code block content to fix indentation
|
|
107
|
+
const lines = codeBlockContent.split('\n');
|
|
108
|
+
// Find the first non-empty code line to determine base indentation
|
|
109
|
+
let baseIndentation = '';
|
|
110
|
+
let codeStartIndex = -1;
|
|
111
|
+
for (let i = 1; i < lines.length - 1; i++) {
|
|
112
|
+
const line = lines[i];
|
|
113
|
+
if (line.trim().length > 0) {
|
|
114
|
+
const indentMatch = line.match(/^[\t ]*/);
|
|
115
|
+
if (indentMatch) {
|
|
116
|
+
baseIndentation = indentMatch[0];
|
|
117
|
+
codeStartIndex = i;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Process lines maintaining relative indentation from the first code line
|
|
123
|
+
const processedLines = lines.map((line, index) => {
|
|
124
|
+
// Keep backticks as is
|
|
125
|
+
if (index === 0 || index === lines.length - 1)
|
|
126
|
+
return line.trimStart();
|
|
127
|
+
// Empty lines should be completely trimmed
|
|
128
|
+
if (line.trim().length === 0)
|
|
129
|
+
return '';
|
|
130
|
+
// For code lines, remove only the base indentation
|
|
131
|
+
if (line.startsWith(baseIndentation)) {
|
|
132
|
+
return line.slice(baseIndentation.length);
|
|
133
|
+
}
|
|
134
|
+
// If line has less indentation than base, trim all leading whitespace
|
|
135
|
+
return line.trimStart();
|
|
136
|
+
});
|
|
137
|
+
// Create node for the entire code block
|
|
138
|
+
const level = Math.floor(codeBlockIndentation / 2);
|
|
139
|
+
const node = {
|
|
140
|
+
content: processedLines.join('\n'),
|
|
141
|
+
level,
|
|
142
|
+
children: []
|
|
143
|
+
};
|
|
144
|
+
// Restore to code block's parent level
|
|
145
|
+
while (stack.length > codeBlockParentLevel) {
|
|
146
|
+
stack.pop();
|
|
147
|
+
}
|
|
148
|
+
if (level === 0) {
|
|
149
|
+
rootNodes.push(node);
|
|
150
|
+
stack[0] = node;
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
while (stack.length > level) {
|
|
154
|
+
stack.pop();
|
|
155
|
+
}
|
|
156
|
+
if (stack[level - 1]) {
|
|
157
|
+
stack[level - 1].children.push(node);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
rootNodes.push(node);
|
|
161
|
+
}
|
|
162
|
+
stack[level] = node;
|
|
163
|
+
}
|
|
164
|
+
codeBlockContent = '';
|
|
165
|
+
}
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (inCodeBlock) {
|
|
169
|
+
codeBlockContent += line + '\n';
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
63
172
|
// Skip truly empty lines (no spaces)
|
|
64
173
|
if (trimmedLine === '') {
|
|
65
174
|
continue;
|
|
@@ -67,52 +176,54 @@ function parseMarkdown(markdown) {
|
|
|
67
176
|
// Calculate indentation level (2 spaces = 1 level)
|
|
68
177
|
const indentation = line.match(/^\s*/)?.[0].length ?? 0;
|
|
69
178
|
let level = Math.floor(indentation / 2);
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
// content
|
|
76
|
-
level = 0;
|
|
77
|
-
// Reset stack but keep heading
|
|
78
|
-
|
|
179
|
+
// First check for headings
|
|
180
|
+
const { heading_level, content: headingContent } = parseMarkdownHeadingLevel(trimmedLine);
|
|
181
|
+
// Then handle bullet points if not a heading
|
|
182
|
+
let content;
|
|
183
|
+
if (heading_level > 0) {
|
|
184
|
+
content = headingContent; // Use clean heading content without # marks
|
|
185
|
+
level = 0; // Headings start at root level
|
|
186
|
+
stack.length = 1; // Reset stack but keep heading as parent
|
|
187
|
+
// Create heading node
|
|
188
|
+
const node = {
|
|
189
|
+
content,
|
|
190
|
+
level,
|
|
191
|
+
heading_level, // Store heading level in node
|
|
192
|
+
children: []
|
|
193
|
+
};
|
|
194
|
+
rootNodes.push(node);
|
|
195
|
+
stack[0] = node;
|
|
196
|
+
continue; // Skip to next line
|
|
79
197
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
//
|
|
84
|
-
|
|
198
|
+
// Handle non-heading content
|
|
199
|
+
const bulletMatch = trimmedLine.match(/^(\s*)[-*+]\s+/);
|
|
200
|
+
if (bulletMatch) {
|
|
201
|
+
// For bullet points, use the bullet's indentation for level
|
|
202
|
+
content = trimmedLine.substring(bulletMatch[0].length);
|
|
203
|
+
level = Math.floor(bulletMatch[1].length / 2);
|
|
85
204
|
}
|
|
86
205
|
else {
|
|
87
|
-
|
|
88
|
-
content = trimmedLine.replace(/^\s*[-*+]\s+/, '');
|
|
206
|
+
content = trimmedLine;
|
|
89
207
|
}
|
|
90
|
-
// Create
|
|
208
|
+
// Create regular node
|
|
91
209
|
const node = {
|
|
92
210
|
content,
|
|
93
211
|
level,
|
|
94
212
|
children: []
|
|
95
213
|
};
|
|
96
|
-
//
|
|
97
|
-
|
|
214
|
+
// Pop stack until we find the parent level
|
|
215
|
+
while (stack.length > level) {
|
|
216
|
+
stack.pop();
|
|
217
|
+
}
|
|
218
|
+
// Add to appropriate parent
|
|
219
|
+
if (level === 0 || !stack[level - 1]) {
|
|
98
220
|
rootNodes.push(node);
|
|
99
221
|
stack[0] = node;
|
|
100
222
|
}
|
|
101
223
|
else {
|
|
102
|
-
|
|
103
|
-
while (stack.length > level) {
|
|
104
|
-
stack.pop();
|
|
105
|
-
}
|
|
106
|
-
// Add as child to parent
|
|
107
|
-
if (stack[level - 1]) {
|
|
108
|
-
stack[level - 1].children.push(node);
|
|
109
|
-
}
|
|
110
|
-
else {
|
|
111
|
-
// If no parent found, treat as root node
|
|
112
|
-
rootNodes.push(node);
|
|
113
|
-
}
|
|
114
|
-
stack[level] = node;
|
|
224
|
+
stack[level - 1].children.push(node);
|
|
115
225
|
}
|
|
226
|
+
stack[level] = node;
|
|
116
227
|
}
|
|
117
228
|
return rootNodes;
|
|
118
229
|
}
|
|
@@ -168,12 +279,13 @@ function convertNodesToBlocks(nodes) {
|
|
|
168
279
|
return nodes.map(node => ({
|
|
169
280
|
uid: generateBlockUid(),
|
|
170
281
|
content: node.content,
|
|
282
|
+
...(node.heading_level && { heading_level: node.heading_level }), // Preserve heading level if present
|
|
171
283
|
children: convertNodesToBlocks(node.children)
|
|
172
284
|
}));
|
|
173
285
|
}
|
|
174
286
|
function convertToRoamActions(nodes, parentUid, order = 'last') {
|
|
175
|
-
// First convert nodes to blocks with UIDs
|
|
176
|
-
const blocks = convertNodesToBlocks(nodes);
|
|
287
|
+
// First convert nodes to blocks with UIDs, reversing to maintain original order
|
|
288
|
+
const blocks = convertNodesToBlocks([...nodes].reverse());
|
|
177
289
|
const actions = [];
|
|
178
290
|
// Helper function to recursively create actions
|
|
179
291
|
function createBlockActions(blocks, parentUid, order) {
|
|
@@ -187,7 +299,8 @@ function convertToRoamActions(nodes, parentUid, order = 'last') {
|
|
|
187
299
|
},
|
|
188
300
|
block: {
|
|
189
301
|
uid: block.uid,
|
|
190
|
-
string: block.content
|
|
302
|
+
string: block.content,
|
|
303
|
+
...(block.heading_level && { heading: block.heading_level })
|
|
191
304
|
}
|
|
192
305
|
};
|
|
193
306
|
actions.push(action);
|
|
@@ -202,4 +315,4 @@ function convertToRoamActions(nodes, parentUid, order = 'last') {
|
|
|
202
315
|
return actions;
|
|
203
316
|
}
|
|
204
317
|
// Export public functions and types
|
|
205
|
-
export { parseMarkdown, convertToRoamActions, hasMarkdownTable, convertAllTables, convertToRoamMarkdown };
|
|
318
|
+
export { parseMarkdown, convertToRoamActions, hasMarkdownTable, convertAllTables, convertToRoamMarkdown, parseMarkdownHeadingLevel };
|
|
@@ -21,64 +21,68 @@ export class HierarchySearchHandler extends BaseSearchHandler {
|
|
|
21
21
|
if (page_title_uid) {
|
|
22
22
|
targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid);
|
|
23
23
|
}
|
|
24
|
+
// Define ancestor rule for recursive traversal
|
|
25
|
+
const ancestorRule = `[
|
|
26
|
+
[ (ancestor ?child ?parent)
|
|
27
|
+
[?parent :block/children ?child] ]
|
|
28
|
+
[ (ancestor ?child ?a)
|
|
29
|
+
[?parent :block/children ?child]
|
|
30
|
+
(ancestor ?parent ?a) ]
|
|
31
|
+
]`;
|
|
24
32
|
let queryStr;
|
|
25
33
|
let queryParams;
|
|
26
34
|
if (parent_uid) {
|
|
27
|
-
// Search for
|
|
35
|
+
// Search for all descendants using ancestor rule
|
|
28
36
|
if (targetPageUid) {
|
|
29
37
|
queryStr = `[:find ?block-uid ?block-str ?depth
|
|
30
|
-
:in $ ?parent-uid ?page-uid
|
|
38
|
+
:in $ % ?parent-uid ?page-uid
|
|
31
39
|
:where [?p :block/uid ?page-uid]
|
|
32
40
|
[?parent :block/uid ?parent-uid]
|
|
33
|
-
|
|
41
|
+
(ancestor ?b ?parent)
|
|
34
42
|
[?b :block/string ?block-str]
|
|
35
43
|
[?b :block/uid ?block-uid]
|
|
36
44
|
[?b :block/page ?p]
|
|
37
|
-
[(get-else $ ?b :block/path-length 1) ?depth]
|
|
38
|
-
|
|
39
|
-
queryParams = [parent_uid, targetPageUid, max_depth];
|
|
45
|
+
[(get-else $ ?b :block/path-length 1) ?depth]]`;
|
|
46
|
+
queryParams = [ancestorRule, parent_uid, targetPageUid];
|
|
40
47
|
}
|
|
41
48
|
else {
|
|
42
49
|
queryStr = `[:find ?block-uid ?block-str ?page-title ?depth
|
|
43
|
-
:in $ ?parent-uid
|
|
50
|
+
:in $ % ?parent-uid
|
|
44
51
|
:where [?parent :block/uid ?parent-uid]
|
|
45
|
-
|
|
52
|
+
(ancestor ?b ?parent)
|
|
46
53
|
[?b :block/string ?block-str]
|
|
47
54
|
[?b :block/uid ?block-uid]
|
|
48
55
|
[?b :block/page ?p]
|
|
49
56
|
[?p :node/title ?page-title]
|
|
50
|
-
[(get-else $ ?b :block/path-length 1) ?depth]
|
|
51
|
-
|
|
52
|
-
queryParams = [parent_uid, max_depth];
|
|
57
|
+
[(get-else $ ?b :block/path-length 1) ?depth]]`;
|
|
58
|
+
queryParams = [ancestorRule, parent_uid];
|
|
53
59
|
}
|
|
54
60
|
}
|
|
55
61
|
else {
|
|
56
|
-
// Search for
|
|
62
|
+
// Search for ancestors using the same rule
|
|
57
63
|
if (targetPageUid) {
|
|
58
64
|
queryStr = `[:find ?block-uid ?block-str ?depth
|
|
59
|
-
:in $ ?child-uid ?page-uid
|
|
65
|
+
:in $ % ?child-uid ?page-uid
|
|
60
66
|
:where [?p :block/uid ?page-uid]
|
|
61
67
|
[?child :block/uid ?child-uid]
|
|
62
|
-
|
|
68
|
+
(ancestor ?child ?b)
|
|
63
69
|
[?b :block/string ?block-str]
|
|
64
70
|
[?b :block/uid ?block-uid]
|
|
65
71
|
[?b :block/page ?p]
|
|
66
|
-
[(get-else $ ?b :block/path-length 1) ?depth]
|
|
67
|
-
|
|
68
|
-
queryParams = [child_uid, targetPageUid, max_depth];
|
|
72
|
+
[(get-else $ ?b :block/path-length 1) ?depth]]`;
|
|
73
|
+
queryParams = [ancestorRule, child_uid, targetPageUid];
|
|
69
74
|
}
|
|
70
75
|
else {
|
|
71
76
|
queryStr = `[:find ?block-uid ?block-str ?page-title ?depth
|
|
72
|
-
:in $ ?child-uid
|
|
77
|
+
:in $ % ?child-uid
|
|
73
78
|
:where [?child :block/uid ?child-uid]
|
|
74
|
-
|
|
79
|
+
(ancestor ?child ?b)
|
|
75
80
|
[?b :block/string ?block-str]
|
|
76
81
|
[?b :block/uid ?block-uid]
|
|
77
82
|
[?b :block/page ?p]
|
|
78
83
|
[?p :node/title ?page-title]
|
|
79
|
-
[(get-else $ ?b :block/path-length 1) ?depth]
|
|
80
|
-
|
|
81
|
-
queryParams = [child_uid, max_depth];
|
|
84
|
+
[(get-else $ ?b :block/path-length 1) ?depth]]`;
|
|
85
|
+
queryParams = [ancestorRule, child_uid];
|
|
82
86
|
}
|
|
83
87
|
}
|
|
84
88
|
const results = await q(this.graph, queryStr, queryParams);
|
|
@@ -90,8 +94,8 @@ export class HierarchySearchHandler extends BaseSearchHandler {
|
|
|
90
94
|
...(pageTitle && { page_title: pageTitle })
|
|
91
95
|
}));
|
|
92
96
|
const searchDescription = parent_uid
|
|
93
|
-
? `
|
|
94
|
-
: `
|
|
97
|
+
? `descendants of block ${parent_uid}`
|
|
98
|
+
: `ancestors of block ${child_uid}`;
|
|
95
99
|
return {
|
|
96
100
|
success: true,
|
|
97
101
|
matches,
|
package/build/search/index.js
CHANGED
|
@@ -9,81 +9,27 @@ export class TagSearchHandler extends BaseSearchHandler {
|
|
|
9
9
|
}
|
|
10
10
|
async execute() {
|
|
11
11
|
const { primary_tag, page_title_uid, near_tag, exclude_tag } = this.params;
|
|
12
|
-
//
|
|
13
|
-
const primaryTagFormats = SearchUtils.formatTag(primary_tag);
|
|
14
|
-
const nearTagFormats = near_tag ? SearchUtils.formatTag(near_tag) : undefined;
|
|
15
|
-
const excludeTagFormats = exclude_tag ? SearchUtils.formatTag(exclude_tag) : undefined;
|
|
16
|
-
// Get target page UID if provided
|
|
12
|
+
// Get target page UID if provided for scoped search
|
|
17
13
|
let targetPageUid;
|
|
18
14
|
if (page_title_uid) {
|
|
19
15
|
targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid);
|
|
20
16
|
}
|
|
21
|
-
// Build query
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
[(clojure.string/includes? ?block-str ?near-tag2)])
|
|
36
|
-
(not (or [(clojure.string/includes? ?block-str ?exclude-tag1)]
|
|
37
|
-
[(clojure.string/includes? ?block-str ?exclude-tag2)]))]`;
|
|
38
|
-
queryParams = [primaryTagFormats, nearTagFormats, excludeTagFormats || ['', ''], targetPageUid];
|
|
39
|
-
}
|
|
40
|
-
else {
|
|
41
|
-
queryStr = `[:find ?block-uid ?block-str
|
|
42
|
-
:in $ [?primary-tag1 ?primary-tag2] [?exclude-tag1 ?exclude-tag2] ?page-uid
|
|
43
|
-
:where [?p :block/uid ?page-uid]
|
|
44
|
-
[?b :block/page ?p]
|
|
45
|
-
[?b :block/string ?block-str]
|
|
46
|
-
[?b :block/uid ?block-uid]
|
|
47
|
-
(or [(clojure.string/includes? ?block-str ?primary-tag1)]
|
|
48
|
-
[(clojure.string/includes? ?block-str ?primary-tag2)])
|
|
49
|
-
(not (or [(clojure.string/includes? ?block-str ?exclude-tag1)]
|
|
50
|
-
[(clojure.string/includes? ?block-str ?exclude-tag2)]))]`;
|
|
51
|
-
queryParams = [primaryTagFormats, excludeTagFormats || ['', ''], targetPageUid];
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
else {
|
|
55
|
-
// Search across all pages
|
|
56
|
-
if (nearTagFormats) {
|
|
57
|
-
queryStr = `[:find ?block-uid ?block-str ?page-title
|
|
58
|
-
:in $ [?primary-tag1 ?primary-tag2] [?near-tag1 ?near-tag2] [?exclude-tag1 ?exclude-tag2]
|
|
59
|
-
:where [?b :block/string ?block-str]
|
|
60
|
-
[?b :block/uid ?block-uid]
|
|
61
|
-
[?b :block/page ?p]
|
|
62
|
-
[?p :node/title ?page-title]
|
|
63
|
-
(or [(clojure.string/includes? ?block-str ?primary-tag1)]
|
|
64
|
-
[(clojure.string/includes? ?block-str ?primary-tag2)])
|
|
65
|
-
(or [(clojure.string/includes? ?block-str ?near-tag1)]
|
|
66
|
-
[(clojure.string/includes? ?block-str ?near-tag2)])
|
|
67
|
-
(not (or [(clojure.string/includes? ?block-str ?exclude-tag1)]
|
|
68
|
-
[(clojure.string/includes? ?block-str ?exclude-tag2)]))]`;
|
|
69
|
-
queryParams = [primaryTagFormats, nearTagFormats, excludeTagFormats || ['', '']];
|
|
70
|
-
}
|
|
71
|
-
else {
|
|
72
|
-
queryStr = `[:find ?block-uid ?block-str ?page-title
|
|
73
|
-
:in $ [?primary-tag1 ?primary-tag2] [?exclude-tag1 ?exclude-tag2]
|
|
74
|
-
:where [?b :block/string ?block-str]
|
|
75
|
-
[?b :block/uid ?block-uid]
|
|
76
|
-
[?b :block/page ?p]
|
|
77
|
-
[?p :node/title ?page-title]
|
|
78
|
-
(or [(clojure.string/includes? ?block-str ?primary-tag1)]
|
|
79
|
-
[(clojure.string/includes? ?block-str ?primary-tag2)])
|
|
80
|
-
(not (or [(clojure.string/includes? ?block-str ?exclude-tag1)]
|
|
81
|
-
[(clojure.string/includes? ?block-str ?exclude-tag2)]))]`;
|
|
82
|
-
queryParams = [primaryTagFormats, excludeTagFormats || ['', '']];
|
|
83
|
-
}
|
|
84
|
-
}
|
|
17
|
+
// Build query to find blocks referencing the page
|
|
18
|
+
const queryStr = `[:find ?block-uid ?block-str ?page-title
|
|
19
|
+
:in $ ?title
|
|
20
|
+
:where
|
|
21
|
+
[?ref-page :node/title ?title-match]
|
|
22
|
+
[(clojure.string/lower-case ?title-match) ?lower-title]
|
|
23
|
+
[(clojure.string/lower-case ?title) ?search-title]
|
|
24
|
+
[(= ?lower-title ?search-title)]
|
|
25
|
+
[?b :block/refs ?ref-page]
|
|
26
|
+
[?b :block/string ?block-str]
|
|
27
|
+
[?b :block/uid ?block-uid]
|
|
28
|
+
[?b :block/page ?p]
|
|
29
|
+
[?p :node/title ?page-title]]`;
|
|
30
|
+
const queryParams = [primary_tag];
|
|
85
31
|
const results = await q(this.graph, queryStr, queryParams);
|
|
86
|
-
const searchDescription = `
|
|
32
|
+
const searchDescription = `referencing "${primary_tag}"`;
|
|
87
33
|
return SearchUtils.formatSearchResults(results, searchDescription, !targetPageUid);
|
|
88
34
|
}
|
|
89
35
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { q } from '@roam-research/roam-api-sdk';
|
|
2
|
+
import { BaseSearchHandler } from './types.js';
|
|
3
|
+
import { SearchUtils } from './utils.js';
|
|
4
|
+
export class TextSearchHandler extends BaseSearchHandler {
|
|
5
|
+
params;
|
|
6
|
+
constructor(graph, params) {
|
|
7
|
+
super(graph);
|
|
8
|
+
this.params = params;
|
|
9
|
+
}
|
|
10
|
+
async execute() {
|
|
11
|
+
const { text, page_title_uid, case_sensitive = false } = this.params;
|
|
12
|
+
// Get target page UID if provided for scoped search
|
|
13
|
+
let targetPageUid;
|
|
14
|
+
if (page_title_uid) {
|
|
15
|
+
targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid);
|
|
16
|
+
}
|
|
17
|
+
// Build query to find blocks containing the text
|
|
18
|
+
const queryStr = `[:find ?block-uid ?block-str ?page-title
|
|
19
|
+
:in $ ?search-text
|
|
20
|
+
:where
|
|
21
|
+
[?b :block/string ?block-str]
|
|
22
|
+
[(clojure.string/includes? ${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
|
|
23
|
+
${case_sensitive ? '?search-text' : '(clojure.string/lower-case ?search-text)'})]
|
|
24
|
+
[?b :block/uid ?block-uid]
|
|
25
|
+
[?b :block/page ?p]
|
|
26
|
+
[?p :node/title ?page-title]]`;
|
|
27
|
+
const queryParams = [text];
|
|
28
|
+
const results = await q(this.graph, queryStr, queryParams);
|
|
29
|
+
const searchDescription = `containing "${text}"${case_sensitive ? ' (case sensitive)' : ''}`;
|
|
30
|
+
return SearchUtils.formatSearchResults(results, searchDescription, !targetPageUid);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -4,8 +4,8 @@ import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } f
|
|
|
4
4
|
import { initializeGraph } from '@roam-research/roam-api-sdk';
|
|
5
5
|
import { API_TOKEN, GRAPH_NAME } from '../config/environment.js';
|
|
6
6
|
import { toolSchemas } from '../tools/schemas.js';
|
|
7
|
-
import { ToolHandlers } from '../tools/handlers.js';
|
|
8
|
-
import { TagSearchHandler, BlockRefSearchHandler, HierarchySearchHandler } from '../search/index.js';
|
|
7
|
+
import { ToolHandlers } from '../tools/tool-handlers.js';
|
|
8
|
+
import { TagSearchHandler, BlockRefSearchHandler, HierarchySearchHandler, TextSearchHandler } from '../search/index.js';
|
|
9
9
|
export class RoamServer {
|
|
10
10
|
server;
|
|
11
11
|
toolHandlers;
|
|
@@ -18,7 +18,7 @@ export class RoamServer {
|
|
|
18
18
|
this.toolHandlers = new ToolHandlers(this.graph);
|
|
19
19
|
this.server = new Server({
|
|
20
20
|
name: 'roam-research',
|
|
21
|
-
version: '0.
|
|
21
|
+
version: '0.16.0',
|
|
22
22
|
}, {
|
|
23
23
|
capabilities: {
|
|
24
24
|
tools: {
|
|
@@ -31,7 +31,10 @@ export class RoamServer {
|
|
|
31
31
|
roam_search_for_tag: {},
|
|
32
32
|
roam_search_by_status: {},
|
|
33
33
|
roam_search_block_refs: {},
|
|
34
|
-
roam_search_hierarchy: {}
|
|
34
|
+
roam_search_hierarchy: {},
|
|
35
|
+
find_pages_modified_today: {},
|
|
36
|
+
roam_search_by_text: {},
|
|
37
|
+
roam_update_block: {}
|
|
35
38
|
},
|
|
36
39
|
},
|
|
37
40
|
});
|
|
@@ -125,6 +128,36 @@ export class RoamServer {
|
|
|
125
128
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
126
129
|
};
|
|
127
130
|
}
|
|
131
|
+
case 'find_pages_modified_today': {
|
|
132
|
+
const result = await this.toolHandlers.findPagesModifiedToday();
|
|
133
|
+
return {
|
|
134
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
case 'roam_search_by_text': {
|
|
138
|
+
const params = request.params.arguments;
|
|
139
|
+
const handler = new TextSearchHandler(this.graph, params);
|
|
140
|
+
const result = await handler.execute();
|
|
141
|
+
return {
|
|
142
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
case 'roam_update_block': {
|
|
146
|
+
const { block_uid, content, transform_pattern } = request.params.arguments;
|
|
147
|
+
let result;
|
|
148
|
+
if (content) {
|
|
149
|
+
result = await this.toolHandlers.updateBlock(block_uid, content);
|
|
150
|
+
}
|
|
151
|
+
else if (transform_pattern) {
|
|
152
|
+
result = await this.toolHandlers.updateBlock(block_uid, undefined, (currentContent) => {
|
|
153
|
+
const regex = new RegExp(transform_pattern.find, transform_pattern.global !== false ? 'g' : '');
|
|
154
|
+
return currentContent.replace(regex, transform_pattern.replace);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
128
161
|
default:
|
|
129
162
|
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
|
|
130
163
|
}
|
package/build/tools/schemas.js
CHANGED
|
@@ -240,5 +240,78 @@ export const toolSchemas = {
|
|
|
240
240
|
{ required: ['child_uid'] }
|
|
241
241
|
]
|
|
242
242
|
}
|
|
243
|
+
},
|
|
244
|
+
find_pages_modified_today: {
|
|
245
|
+
name: 'find_pages_modified_today',
|
|
246
|
+
description: 'Find all pages that have been modified today (since midnight).',
|
|
247
|
+
inputSchema: {
|
|
248
|
+
type: 'object',
|
|
249
|
+
properties: {},
|
|
250
|
+
required: []
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
roam_search_by_text: {
|
|
254
|
+
name: 'roam_search_by_text',
|
|
255
|
+
description: 'Search for blocks containing specific text across all pages or within a specific page.',
|
|
256
|
+
inputSchema: {
|
|
257
|
+
type: 'object',
|
|
258
|
+
properties: {
|
|
259
|
+
text: {
|
|
260
|
+
type: 'string',
|
|
261
|
+
description: 'The text to search for'
|
|
262
|
+
},
|
|
263
|
+
page_title_uid: {
|
|
264
|
+
type: 'string',
|
|
265
|
+
description: 'Optional: Title or UID of the page to search in. If not provided, searches across all pages'
|
|
266
|
+
},
|
|
267
|
+
case_sensitive: {
|
|
268
|
+
type: 'boolean',
|
|
269
|
+
description: 'Optional: Whether to perform a case-sensitive search (default: false)'
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
required: ['text']
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
roam_update_block: {
|
|
276
|
+
name: 'roam_update_block',
|
|
277
|
+
description: 'Update the content of an existing block identified by its UID. Can either provide new content directly or use a transform pattern to modify existing content.',
|
|
278
|
+
inputSchema: {
|
|
279
|
+
type: 'object',
|
|
280
|
+
properties: {
|
|
281
|
+
block_uid: {
|
|
282
|
+
type: 'string',
|
|
283
|
+
description: 'UID of the block to update'
|
|
284
|
+
},
|
|
285
|
+
content: {
|
|
286
|
+
type: 'string',
|
|
287
|
+
description: 'New content for the block. If not provided, transform_pattern will be used.'
|
|
288
|
+
},
|
|
289
|
+
transform_pattern: {
|
|
290
|
+
type: 'object',
|
|
291
|
+
description: 'Pattern to transform the current content. Used if content is not provided.',
|
|
292
|
+
properties: {
|
|
293
|
+
find: {
|
|
294
|
+
type: 'string',
|
|
295
|
+
description: 'Text or regex pattern to find'
|
|
296
|
+
},
|
|
297
|
+
replace: {
|
|
298
|
+
type: 'string',
|
|
299
|
+
description: 'Text to replace with'
|
|
300
|
+
},
|
|
301
|
+
global: {
|
|
302
|
+
type: 'boolean',
|
|
303
|
+
description: 'Whether to replace all occurrences',
|
|
304
|
+
default: true
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
required: ['find', 'replace']
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
required: ['block_uid'],
|
|
311
|
+
oneOf: [
|
|
312
|
+
{ required: ['content'] },
|
|
313
|
+
{ required: ['transform_pattern'] }
|
|
314
|
+
]
|
|
315
|
+
}
|
|
243
316
|
}
|
|
244
317
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { q, createPage, createBlock, batchActions, updateBlock } from '@roam-research/roam-api-sdk';
|
|
2
|
+
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
3
3
|
import { formatRoamDate } from '../utils/helpers.js';
|
|
4
4
|
import { parseMarkdown, convertToRoamActions, convertToRoamMarkdown, hasMarkdownTable } from '../markdown-utils.js';
|
|
5
5
|
// Helper function to capitalize each word
|
|
@@ -53,6 +53,46 @@ export class ToolHandlers {
|
|
|
53
53
|
constructor(graph) {
|
|
54
54
|
this.graph = graph;
|
|
55
55
|
}
|
|
56
|
+
async findPagesModifiedToday() {
|
|
57
|
+
// Define ancestor rule for traversing block hierarchy
|
|
58
|
+
const ancestorRule = `[
|
|
59
|
+
[ (ancestor ?b ?a)
|
|
60
|
+
[?a :block/children ?b] ]
|
|
61
|
+
[ (ancestor ?b ?a)
|
|
62
|
+
[?parent :block/children ?b]
|
|
63
|
+
(ancestor ?parent ?a) ]
|
|
64
|
+
]`;
|
|
65
|
+
// Get start of today
|
|
66
|
+
const startOfDay = new Date();
|
|
67
|
+
startOfDay.setHours(0, 0, 0, 0);
|
|
68
|
+
try {
|
|
69
|
+
// Query for pages modified today
|
|
70
|
+
const results = await q(this.graph, `[:find ?title
|
|
71
|
+
:in $ ?start_of_day %
|
|
72
|
+
:where
|
|
73
|
+
[?page :node/title ?title]
|
|
74
|
+
(ancestor ?block ?page)
|
|
75
|
+
[?block :edit/time ?time]
|
|
76
|
+
[(> ?time ?start_of_day)]]`, [startOfDay.getTime(), ancestorRule]);
|
|
77
|
+
if (!results || results.length === 0) {
|
|
78
|
+
return {
|
|
79
|
+
success: true,
|
|
80
|
+
pages: [],
|
|
81
|
+
message: 'No pages have been modified today'
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
// Extract unique page titles
|
|
85
|
+
const uniquePages = [...new Set(results.map(([title]) => title))];
|
|
86
|
+
return {
|
|
87
|
+
success: true,
|
|
88
|
+
pages: uniquePages,
|
|
89
|
+
message: `Found ${uniquePages.length} page(s) modified today`
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
throw new McpError(ErrorCode.InternalError, `Failed to find modified pages: ${error.message}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
56
96
|
async createOutline(outline, page_title_uid, block_text_uid) {
|
|
57
97
|
// Validate input
|
|
58
98
|
if (!Array.isArray(outline) || outline.length === 0) {
|
|
@@ -295,71 +335,78 @@ export class ToolHandlers {
|
|
|
295
335
|
if (!uid) {
|
|
296
336
|
throw new McpError(ErrorCode.InvalidRequest, `Page with title "${title}" not found (tried original, capitalized words, and lowercase)`);
|
|
297
337
|
}
|
|
298
|
-
//
|
|
338
|
+
// Define ancestor rule for traversing block hierarchy
|
|
339
|
+
const ancestorRule = `[
|
|
340
|
+
[ (ancestor ?b ?a)
|
|
341
|
+
[?a :block/children ?b] ]
|
|
342
|
+
[ (ancestor ?b ?a)
|
|
343
|
+
[?parent :block/children ?b]
|
|
344
|
+
(ancestor ?parent ?a) ]
|
|
345
|
+
]`;
|
|
346
|
+
// Get all blocks under this page using ancestor rule
|
|
299
347
|
const blocksQuery = `[:find ?block-uid ?block-str ?order ?parent-uid
|
|
300
|
-
:
|
|
301
|
-
|
|
302
|
-
[?
|
|
303
|
-
[?
|
|
304
|
-
[?
|
|
305
|
-
|
|
348
|
+
:in $ % ?page-title
|
|
349
|
+
:where [?page :node/title ?page-title]
|
|
350
|
+
[?block :block/string ?block-str]
|
|
351
|
+
[?block :block/uid ?block-uid]
|
|
352
|
+
[?block :block/order ?order]
|
|
353
|
+
(ancestor ?block ?page)
|
|
354
|
+
[?parent :block/children ?block]
|
|
306
355
|
[?parent :block/uid ?parent-uid]]`;
|
|
307
|
-
const blocks = await q(this.graph, blocksQuery, []);
|
|
308
|
-
if (blocks.length
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
356
|
+
const blocks = await q(this.graph, blocksQuery, [ancestorRule, title]);
|
|
357
|
+
if (!blocks || blocks.length === 0) {
|
|
358
|
+
return `${title} (no content found)`;
|
|
359
|
+
}
|
|
360
|
+
// Create a map of all blocks
|
|
361
|
+
const blockMap = new Map();
|
|
362
|
+
const rootBlocks = [];
|
|
363
|
+
// First pass: Create all block objects
|
|
364
|
+
for (const [blockUid, blockStr, order, parentUid] of blocks) {
|
|
365
|
+
const resolvedString = await resolveRefs(this.graph, blockStr);
|
|
366
|
+
const block = {
|
|
367
|
+
uid: blockUid,
|
|
368
|
+
string: resolvedString,
|
|
369
|
+
order: order,
|
|
370
|
+
children: []
|
|
371
|
+
};
|
|
372
|
+
blockMap.set(blockUid, block);
|
|
373
|
+
// If no parent or parent is the page itself, it's a root block
|
|
374
|
+
if (!parentUid || parentUid === uid) {
|
|
375
|
+
rootBlocks.push(block);
|
|
320
376
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
377
|
+
}
|
|
378
|
+
// Second pass: Build parent-child relationships
|
|
379
|
+
for (const [blockUid, _, __, parentUid] of blocks) {
|
|
380
|
+
if (parentUid && parentUid !== uid) {
|
|
381
|
+
const child = blockMap.get(blockUid);
|
|
324
382
|
const parent = blockMap.get(parentUid);
|
|
325
383
|
if (child && parent && !parent.children.includes(child)) {
|
|
326
384
|
parent.children.push(child);
|
|
327
385
|
}
|
|
328
|
-
}
|
|
329
|
-
// Get top-level blocks
|
|
330
|
-
const topQuery = `[:find ?block-uid ?block-str ?order
|
|
331
|
-
:where [?p :block/uid "${uid}"]
|
|
332
|
-
[?b :block/page ?p]
|
|
333
|
-
[?b :block/uid ?block-uid]
|
|
334
|
-
[?b :block/string ?block-str]
|
|
335
|
-
[?b :block/order ?order]
|
|
336
|
-
(not-join [?b]
|
|
337
|
-
[?b :block/parents ?parent]
|
|
338
|
-
[?parent :block/page ?p])]`;
|
|
339
|
-
const topBlocks = await q(this.graph, topQuery, []);
|
|
340
|
-
// Create root blocks
|
|
341
|
-
const rootBlocks = topBlocks
|
|
342
|
-
.map(([uid, string, order]) => ({
|
|
343
|
-
uid,
|
|
344
|
-
string,
|
|
345
|
-
order: order,
|
|
346
|
-
children: blockMap.get(uid)?.children || []
|
|
347
|
-
}))
|
|
348
|
-
.sort((a, b) => a.order - b.order);
|
|
349
|
-
// Convert to markdown
|
|
350
|
-
const toMarkdown = (blocks, level = 0) => {
|
|
351
|
-
return blocks.map(block => {
|
|
352
|
-
const indent = ' '.repeat(level);
|
|
353
|
-
let md = `${indent}- ${block.string}\n`;
|
|
354
|
-
if (block.children.length > 0) {
|
|
355
|
-
md += toMarkdown(block.children.sort((a, b) => a.order - b.order), level + 1);
|
|
356
|
-
}
|
|
357
|
-
return md;
|
|
358
|
-
}).join('');
|
|
359
|
-
};
|
|
360
|
-
return `# ${title}\n\n${toMarkdown(rootBlocks)}`;
|
|
386
|
+
}
|
|
361
387
|
}
|
|
362
|
-
|
|
388
|
+
// Sort blocks recursively
|
|
389
|
+
const sortBlocks = (blocks) => {
|
|
390
|
+
blocks.sort((a, b) => a.order - b.order);
|
|
391
|
+
blocks.forEach(block => {
|
|
392
|
+
if (block.children.length > 0) {
|
|
393
|
+
sortBlocks(block.children);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
};
|
|
397
|
+
sortBlocks(rootBlocks);
|
|
398
|
+
// Convert to markdown with proper nesting
|
|
399
|
+
const toMarkdown = (blocks, level = 0) => {
|
|
400
|
+
return blocks.map(block => {
|
|
401
|
+
const indent = ' '.repeat(level);
|
|
402
|
+
let md = `${indent}- ${block.string}`;
|
|
403
|
+
if (block.children.length > 0) {
|
|
404
|
+
md += '\n' + toMarkdown(block.children, level + 1);
|
|
405
|
+
}
|
|
406
|
+
return md;
|
|
407
|
+
}).join('\n');
|
|
408
|
+
};
|
|
409
|
+
return `# ${title}\n\n${toMarkdown(rootBlocks)}`;
|
|
363
410
|
}
|
|
364
411
|
async createPage(title, content) {
|
|
365
412
|
// Ensure title is properly formatted
|
|
@@ -634,6 +681,53 @@ export class ToolHandlers {
|
|
|
634
681
|
};
|
|
635
682
|
}
|
|
636
683
|
}
|
|
684
|
+
async updateBlock(block_uid, content, transform) {
|
|
685
|
+
if (!block_uid) {
|
|
686
|
+
throw new McpError(ErrorCode.InvalidRequest, 'block_uid is required');
|
|
687
|
+
}
|
|
688
|
+
// Get current block content
|
|
689
|
+
const blockQuery = `[:find ?string .
|
|
690
|
+
:where [?b :block/uid "${block_uid}"]
|
|
691
|
+
[?b :block/string ?string]]`;
|
|
692
|
+
const result = await q(this.graph, blockQuery, []);
|
|
693
|
+
if (result === null || result === undefined) {
|
|
694
|
+
throw new McpError(ErrorCode.InvalidRequest, `Block with UID "${block_uid}" not found`);
|
|
695
|
+
}
|
|
696
|
+
const currentContent = String(result);
|
|
697
|
+
if (currentContent === null || currentContent === undefined) {
|
|
698
|
+
throw new McpError(ErrorCode.InvalidRequest, `Block with UID "${block_uid}" not found`);
|
|
699
|
+
}
|
|
700
|
+
// Determine new content
|
|
701
|
+
let newContent;
|
|
702
|
+
if (content) {
|
|
703
|
+
newContent = content;
|
|
704
|
+
}
|
|
705
|
+
else if (transform) {
|
|
706
|
+
newContent = transform(currentContent);
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
throw new McpError(ErrorCode.InvalidRequest, 'Either content or transform function must be provided');
|
|
710
|
+
}
|
|
711
|
+
try {
|
|
712
|
+
const success = await updateBlock(this.graph, {
|
|
713
|
+
action: 'update-block',
|
|
714
|
+
block: {
|
|
715
|
+
uid: block_uid,
|
|
716
|
+
string: newContent
|
|
717
|
+
}
|
|
718
|
+
});
|
|
719
|
+
if (!success) {
|
|
720
|
+
throw new Error('Failed to update block');
|
|
721
|
+
}
|
|
722
|
+
return {
|
|
723
|
+
success: true,
|
|
724
|
+
content: newContent
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
catch (error) {
|
|
728
|
+
throw new McpError(ErrorCode.InternalError, `Failed to update block: ${error.message}`);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
637
731
|
async searchByStatus(status, page_title_uid, include, exclude) {
|
|
638
732
|
// Get target page UID if provided
|
|
639
733
|
let targetPageUid;
|
|
@@ -658,15 +752,6 @@ export class ToolHandlers {
|
|
|
658
752
|
let queryStr;
|
|
659
753
|
let queryParams;
|
|
660
754
|
const statusPattern = `{{[[${status}]]}}`;
|
|
661
|
-
// Helper function to get parent block content
|
|
662
|
-
const getParentContent = async (blockUid) => {
|
|
663
|
-
const parentQuery = `[:find ?parent-str .
|
|
664
|
-
:where [?b :block/uid "${blockUid}"]
|
|
665
|
-
[?b :block/parents ?parent]
|
|
666
|
-
[?parent :block/string ?parent-str]]`;
|
|
667
|
-
const result = await q(this.graph, parentQuery, []);
|
|
668
|
-
return result ? String(result) : null;
|
|
669
|
-
};
|
|
670
755
|
if (targetPageUid) {
|
|
671
756
|
queryStr = `[:find ?block-uid ?block-str
|
|
672
757
|
:in $ ?status-pattern ?page-uid
|
package/package.json
CHANGED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { q } from '@roam-research/roam-api-sdk';
|
|
2
|
-
import { BaseSearchHandler } from './types.js';
|
|
3
|
-
import { SearchUtils } from './utils.js';
|
|
4
|
-
export class DateSearchHandler extends BaseSearchHandler {
|
|
5
|
-
params;
|
|
6
|
-
constructor(graph, params) {
|
|
7
|
-
super(graph);
|
|
8
|
-
this.params = params;
|
|
9
|
-
}
|
|
10
|
-
async execute() {
|
|
11
|
-
const { start_date, end_date, filter_tag } = this.params;
|
|
12
|
-
const [startDateFormatted, endDateFormatted] = SearchUtils.parseDateRange(start_date, end_date);
|
|
13
|
-
const filterTagFormatted = filter_tag ? `[[${filter_tag}]]` : undefined;
|
|
14
|
-
// Build Roam query string
|
|
15
|
-
const dateQuery = `{between: [[${startDateFormatted}]] [[${endDateFormatted}]]}`;
|
|
16
|
-
const query = filterTagFormatted
|
|
17
|
-
? `{{query: {and: ${filterTagFormatted} ${dateQuery}}}}`
|
|
18
|
-
: `{{query: ${dateQuery}}}`;
|
|
19
|
-
// Log the query for debugging
|
|
20
|
-
console.log('Roam query:', query);
|
|
21
|
-
// Find blocks matching the query
|
|
22
|
-
const queryStr = `[:find ?block-uid ?block-str ?page-title
|
|
23
|
-
:in $ ?query-str
|
|
24
|
-
:where [?b :block/string ?block-str]
|
|
25
|
-
[?b :block/uid ?block-uid]
|
|
26
|
-
[?b :block/page ?p]
|
|
27
|
-
[?p :node/title ?page-title]
|
|
28
|
-
[(clojure.string/includes? ?block-str ?query-str)]]`;
|
|
29
|
-
const results = await q(this.graph, queryStr, [query]);
|
|
30
|
-
const dateRange = start_date === end_date
|
|
31
|
-
? `on ${SearchUtils.parseDate(start_date)}`
|
|
32
|
-
: `between ${SearchUtils.parseDate(start_date)} and ${SearchUtils.parseDate(end_date)}`;
|
|
33
|
-
const searchDescription = filterTagFormatted
|
|
34
|
-
? `${dateRange} containing ${filterTagFormatted}`
|
|
35
|
-
: dateRange;
|
|
36
|
-
return SearchUtils.formatSearchResults(results, searchDescription, true);
|
|
37
|
-
}
|
|
38
|
-
}
|