roam-research-mcp 0.12.3 → 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 +270 -1
- package/build/config/environment.js +44 -0
- package/build/index.js +1 -823
- package/build/markdown-utils.js +151 -37
- package/build/search/block-ref-search.js +72 -0
- package/build/search/hierarchy-search.js +105 -0
- package/build/search/index.js +7 -0
- package/build/search/status-search.js +43 -0
- package/build/search/tag-search.js +35 -0
- package/build/search/text-search.js +32 -0
- package/build/search/types.js +7 -0
- package/build/search/utils.js +98 -0
- package/build/server/roam-server.js +178 -0
- package/build/test-addMarkdownText.js +1 -1
- package/build/tools/schemas.js +317 -0
- package/build/tools/tool-handlers.js +972 -0
- package/build/types/roam.js +1 -0
- package/build/utils/helpers.js +19 -0
- package/package.json +1 -1
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,51 +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
|
-
if
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
stack.length = 1; //
|
|
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
|
|
78
197
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
//
|
|
83
|
-
content = trimmedLine.
|
|
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);
|
|
84
204
|
}
|
|
85
205
|
else {
|
|
86
|
-
|
|
87
|
-
content = trimmedLine.replace(/^\s*[-*+]\s+/, '');
|
|
206
|
+
content = trimmedLine;
|
|
88
207
|
}
|
|
89
|
-
// Create
|
|
208
|
+
// Create regular node
|
|
90
209
|
const node = {
|
|
91
210
|
content,
|
|
92
211
|
level,
|
|
93
212
|
children: []
|
|
94
213
|
};
|
|
95
|
-
//
|
|
96
|
-
|
|
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]) {
|
|
97
220
|
rootNodes.push(node);
|
|
98
221
|
stack[0] = node;
|
|
99
222
|
}
|
|
100
223
|
else {
|
|
101
|
-
|
|
102
|
-
while (stack.length > level) {
|
|
103
|
-
stack.pop();
|
|
104
|
-
}
|
|
105
|
-
// Add as child to parent
|
|
106
|
-
if (stack[level - 1]) {
|
|
107
|
-
stack[level - 1].children.push(node);
|
|
108
|
-
}
|
|
109
|
-
else {
|
|
110
|
-
// If no parent found, treat as root node
|
|
111
|
-
rootNodes.push(node);
|
|
112
|
-
}
|
|
113
|
-
stack[level] = node;
|
|
224
|
+
stack[level - 1].children.push(node);
|
|
114
225
|
}
|
|
226
|
+
stack[level] = node;
|
|
115
227
|
}
|
|
116
228
|
return rootNodes;
|
|
117
229
|
}
|
|
@@ -167,12 +279,13 @@ function convertNodesToBlocks(nodes) {
|
|
|
167
279
|
return nodes.map(node => ({
|
|
168
280
|
uid: generateBlockUid(),
|
|
169
281
|
content: node.content,
|
|
282
|
+
...(node.heading_level && { heading_level: node.heading_level }), // Preserve heading level if present
|
|
170
283
|
children: convertNodesToBlocks(node.children)
|
|
171
284
|
}));
|
|
172
285
|
}
|
|
173
286
|
function convertToRoamActions(nodes, parentUid, order = 'last') {
|
|
174
|
-
// First convert nodes to blocks with UIDs
|
|
175
|
-
const blocks = convertNodesToBlocks(nodes);
|
|
287
|
+
// First convert nodes to blocks with UIDs, reversing to maintain original order
|
|
288
|
+
const blocks = convertNodesToBlocks([...nodes].reverse());
|
|
176
289
|
const actions = [];
|
|
177
290
|
// Helper function to recursively create actions
|
|
178
291
|
function createBlockActions(blocks, parentUid, order) {
|
|
@@ -186,7 +299,8 @@ function convertToRoamActions(nodes, parentUid, order = 'last') {
|
|
|
186
299
|
},
|
|
187
300
|
block: {
|
|
188
301
|
uid: block.uid,
|
|
189
|
-
string: block.content
|
|
302
|
+
string: block.content,
|
|
303
|
+
...(block.heading_level && { heading: block.heading_level })
|
|
190
304
|
}
|
|
191
305
|
};
|
|
192
306
|
actions.push(action);
|
|
@@ -201,4 +315,4 @@ function convertToRoamActions(nodes, parentUid, order = 'last') {
|
|
|
201
315
|
return actions;
|
|
202
316
|
}
|
|
203
317
|
// Export public functions and types
|
|
204
|
-
export { parseMarkdown, convertToRoamActions, hasMarkdownTable, convertAllTables, convertToRoamMarkdown };
|
|
318
|
+
export { parseMarkdown, convertToRoamActions, hasMarkdownTable, convertAllTables, convertToRoamMarkdown, parseMarkdownHeadingLevel };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { q } from '@roam-research/roam-api-sdk';
|
|
2
|
+
import { BaseSearchHandler } from './types.js';
|
|
3
|
+
import { SearchUtils } from './utils.js';
|
|
4
|
+
export class BlockRefSearchHandler extends BaseSearchHandler {
|
|
5
|
+
params;
|
|
6
|
+
constructor(graph, params) {
|
|
7
|
+
super(graph);
|
|
8
|
+
this.params = params;
|
|
9
|
+
}
|
|
10
|
+
async execute() {
|
|
11
|
+
const { block_uid, page_title_uid } = this.params;
|
|
12
|
+
// Get target page UID if provided
|
|
13
|
+
let targetPageUid;
|
|
14
|
+
if (page_title_uid) {
|
|
15
|
+
targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid);
|
|
16
|
+
}
|
|
17
|
+
// Build query based on whether we're searching for references to a specific block
|
|
18
|
+
// or all block references within a page/graph
|
|
19
|
+
let queryStr;
|
|
20
|
+
let queryParams;
|
|
21
|
+
if (block_uid) {
|
|
22
|
+
// Search for references to a specific block
|
|
23
|
+
if (targetPageUid) {
|
|
24
|
+
queryStr = `[:find ?block-uid ?block-str
|
|
25
|
+
:in $ ?ref-uid ?page-uid
|
|
26
|
+
:where [?p :block/uid ?page-uid]
|
|
27
|
+
[?b :block/page ?p]
|
|
28
|
+
[?b :block/string ?block-str]
|
|
29
|
+
[?b :block/uid ?block-uid]
|
|
30
|
+
[(clojure.string/includes? ?block-str ?ref-uid)]]`;
|
|
31
|
+
queryParams = [`((${block_uid}))`, targetPageUid];
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
queryStr = `[:find ?block-uid ?block-str ?page-title
|
|
35
|
+
:in $ ?ref-uid
|
|
36
|
+
:where [?b :block/string ?block-str]
|
|
37
|
+
[?b :block/uid ?block-uid]
|
|
38
|
+
[?b :block/page ?p]
|
|
39
|
+
[?p :node/title ?page-title]
|
|
40
|
+
[(clojure.string/includes? ?block-str ?ref-uid)]]`;
|
|
41
|
+
queryParams = [`((${block_uid}))`];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
// Search for any block references
|
|
46
|
+
if (targetPageUid) {
|
|
47
|
+
queryStr = `[:find ?block-uid ?block-str
|
|
48
|
+
:in $ ?page-uid
|
|
49
|
+
:where [?p :block/uid ?page-uid]
|
|
50
|
+
[?b :block/page ?p]
|
|
51
|
+
[?b :block/string ?block-str]
|
|
52
|
+
[?b :block/uid ?block-uid]
|
|
53
|
+
[(re-find #"\\(\\([^)]+\\)\\)" ?block-str)]]`;
|
|
54
|
+
queryParams = [targetPageUid];
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
queryStr = `[:find ?block-uid ?block-str ?page-title
|
|
58
|
+
:where [?b :block/string ?block-str]
|
|
59
|
+
[?b :block/uid ?block-uid]
|
|
60
|
+
[?b :block/page ?p]
|
|
61
|
+
[?p :node/title ?page-title]
|
|
62
|
+
[(re-find #"\\(\\([^)]+\\)\\)" ?block-str)]]`;
|
|
63
|
+
queryParams = [];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const results = await q(this.graph, queryStr, queryParams);
|
|
67
|
+
const searchDescription = block_uid
|
|
68
|
+
? `referencing block ((${block_uid}))`
|
|
69
|
+
: 'containing block references';
|
|
70
|
+
return SearchUtils.formatSearchResults(results, searchDescription, !targetPageUid);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { q } from '@roam-research/roam-api-sdk';
|
|
2
|
+
import { BaseSearchHandler } from './types.js';
|
|
3
|
+
import { SearchUtils } from './utils.js';
|
|
4
|
+
export class HierarchySearchHandler extends BaseSearchHandler {
|
|
5
|
+
params;
|
|
6
|
+
constructor(graph, params) {
|
|
7
|
+
super(graph);
|
|
8
|
+
this.params = params;
|
|
9
|
+
}
|
|
10
|
+
async execute() {
|
|
11
|
+
const { parent_uid, child_uid, page_title_uid, max_depth = 1 } = this.params;
|
|
12
|
+
if (!parent_uid && !child_uid) {
|
|
13
|
+
return {
|
|
14
|
+
success: false,
|
|
15
|
+
matches: [],
|
|
16
|
+
message: 'Either parent_uid or child_uid must be provided'
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
// Get target page UID if provided
|
|
20
|
+
let targetPageUid;
|
|
21
|
+
if (page_title_uid) {
|
|
22
|
+
targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid);
|
|
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
|
+
]`;
|
|
32
|
+
let queryStr;
|
|
33
|
+
let queryParams;
|
|
34
|
+
if (parent_uid) {
|
|
35
|
+
// Search for all descendants using ancestor rule
|
|
36
|
+
if (targetPageUid) {
|
|
37
|
+
queryStr = `[:find ?block-uid ?block-str ?depth
|
|
38
|
+
:in $ % ?parent-uid ?page-uid
|
|
39
|
+
:where [?p :block/uid ?page-uid]
|
|
40
|
+
[?parent :block/uid ?parent-uid]
|
|
41
|
+
(ancestor ?b ?parent)
|
|
42
|
+
[?b :block/string ?block-str]
|
|
43
|
+
[?b :block/uid ?block-uid]
|
|
44
|
+
[?b :block/page ?p]
|
|
45
|
+
[(get-else $ ?b :block/path-length 1) ?depth]]`;
|
|
46
|
+
queryParams = [ancestorRule, parent_uid, targetPageUid];
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
queryStr = `[:find ?block-uid ?block-str ?page-title ?depth
|
|
50
|
+
:in $ % ?parent-uid
|
|
51
|
+
:where [?parent :block/uid ?parent-uid]
|
|
52
|
+
(ancestor ?b ?parent)
|
|
53
|
+
[?b :block/string ?block-str]
|
|
54
|
+
[?b :block/uid ?block-uid]
|
|
55
|
+
[?b :block/page ?p]
|
|
56
|
+
[?p :node/title ?page-title]
|
|
57
|
+
[(get-else $ ?b :block/path-length 1) ?depth]]`;
|
|
58
|
+
queryParams = [ancestorRule, parent_uid];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
// Search for ancestors using the same rule
|
|
63
|
+
if (targetPageUid) {
|
|
64
|
+
queryStr = `[:find ?block-uid ?block-str ?depth
|
|
65
|
+
:in $ % ?child-uid ?page-uid
|
|
66
|
+
:where [?p :block/uid ?page-uid]
|
|
67
|
+
[?child :block/uid ?child-uid]
|
|
68
|
+
(ancestor ?child ?b)
|
|
69
|
+
[?b :block/string ?block-str]
|
|
70
|
+
[?b :block/uid ?block-uid]
|
|
71
|
+
[?b :block/page ?p]
|
|
72
|
+
[(get-else $ ?b :block/path-length 1) ?depth]]`;
|
|
73
|
+
queryParams = [ancestorRule, child_uid, targetPageUid];
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
queryStr = `[:find ?block-uid ?block-str ?page-title ?depth
|
|
77
|
+
:in $ % ?child-uid
|
|
78
|
+
:where [?child :block/uid ?child-uid]
|
|
79
|
+
(ancestor ?child ?b)
|
|
80
|
+
[?b :block/string ?block-str]
|
|
81
|
+
[?b :block/uid ?block-uid]
|
|
82
|
+
[?b :block/page ?p]
|
|
83
|
+
[?p :node/title ?page-title]
|
|
84
|
+
[(get-else $ ?b :block/path-length 1) ?depth]]`;
|
|
85
|
+
queryParams = [ancestorRule, child_uid];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const results = await q(this.graph, queryStr, queryParams);
|
|
89
|
+
// Format results to include depth information
|
|
90
|
+
const matches = results.map(([uid, content, pageTitle, depth]) => ({
|
|
91
|
+
block_uid: uid,
|
|
92
|
+
content,
|
|
93
|
+
depth: depth || 1,
|
|
94
|
+
...(pageTitle && { page_title: pageTitle })
|
|
95
|
+
}));
|
|
96
|
+
const searchDescription = parent_uid
|
|
97
|
+
? `descendants of block ${parent_uid}`
|
|
98
|
+
: `ancestors of block ${child_uid}`;
|
|
99
|
+
return {
|
|
100
|
+
success: true,
|
|
101
|
+
matches,
|
|
102
|
+
message: `Found ${matches.length} block(s) as ${searchDescription}`
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { q } from '@roam-research/roam-api-sdk';
|
|
2
|
+
import { BaseSearchHandler } from './types.js';
|
|
3
|
+
import { SearchUtils } from './utils.js';
|
|
4
|
+
export class StatusSearchHandler extends BaseSearchHandler {
|
|
5
|
+
params;
|
|
6
|
+
constructor(graph, params) {
|
|
7
|
+
super(graph);
|
|
8
|
+
this.params = params;
|
|
9
|
+
}
|
|
10
|
+
async execute() {
|
|
11
|
+
const { status, page_title_uid } = this.params;
|
|
12
|
+
// Get target page UID if provided
|
|
13
|
+
let targetPageUid;
|
|
14
|
+
if (page_title_uid) {
|
|
15
|
+
targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid);
|
|
16
|
+
}
|
|
17
|
+
// Build query based on whether we're searching in a specific page
|
|
18
|
+
let queryStr;
|
|
19
|
+
let queryParams;
|
|
20
|
+
if (targetPageUid) {
|
|
21
|
+
queryStr = `[:find ?block-uid ?block-str
|
|
22
|
+
:in $ ?status ?page-uid
|
|
23
|
+
:where [?p :block/uid ?page-uid]
|
|
24
|
+
[?b :block/page ?p]
|
|
25
|
+
[?b :block/string ?block-str]
|
|
26
|
+
[?b :block/uid ?block-uid]
|
|
27
|
+
[(clojure.string/includes? ?block-str (str "{{[[" ?status "]]}}"))]]`;
|
|
28
|
+
queryParams = [status, targetPageUid];
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
queryStr = `[:find ?block-uid ?block-str ?page-title
|
|
32
|
+
:in $ ?status
|
|
33
|
+
:where [?b :block/string ?block-str]
|
|
34
|
+
[?b :block/uid ?block-uid]
|
|
35
|
+
[?b :block/page ?p]
|
|
36
|
+
[?p :node/title ?page-title]
|
|
37
|
+
[(clojure.string/includes? ?block-str (str "{{[[" ?status "]]}}"))]]`;
|
|
38
|
+
queryParams = [status];
|
|
39
|
+
}
|
|
40
|
+
const results = await q(this.graph, queryStr, queryParams);
|
|
41
|
+
return SearchUtils.formatSearchResults(results, `with status ${status}`, !targetPageUid);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { q } from '@roam-research/roam-api-sdk';
|
|
2
|
+
import { BaseSearchHandler } from './types.js';
|
|
3
|
+
import { SearchUtils } from './utils.js';
|
|
4
|
+
export class TagSearchHandler extends BaseSearchHandler {
|
|
5
|
+
params;
|
|
6
|
+
constructor(graph, params) {
|
|
7
|
+
super(graph);
|
|
8
|
+
this.params = params;
|
|
9
|
+
}
|
|
10
|
+
async execute() {
|
|
11
|
+
const { primary_tag, page_title_uid, near_tag, exclude_tag } = 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 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];
|
|
31
|
+
const results = await q(this.graph, queryStr, queryParams);
|
|
32
|
+
const searchDescription = `referencing "${primary_tag}"`;
|
|
33
|
+
return SearchUtils.formatSearchResults(results, searchDescription, !targetPageUid);
|
|
34
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { q } from '@roam-research/roam-api-sdk';
|
|
3
|
+
export class SearchUtils {
|
|
4
|
+
/**
|
|
5
|
+
* Find a page by title or UID
|
|
6
|
+
*/
|
|
7
|
+
static async findPageByTitleOrUid(graph, titleOrUid) {
|
|
8
|
+
// Try to find page by title
|
|
9
|
+
const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
|
|
10
|
+
const findResults = await q(graph, findQuery, [titleOrUid]);
|
|
11
|
+
if (findResults && findResults.length > 0) {
|
|
12
|
+
return findResults[0][0];
|
|
13
|
+
}
|
|
14
|
+
// Try as UID
|
|
15
|
+
const uidQuery = `[:find ?uid :where [?e :block/uid "${titleOrUid}"] [?e :block/uid ?uid]]`;
|
|
16
|
+
const uidResults = await q(graph, uidQuery, []);
|
|
17
|
+
if (!uidResults || uidResults.length === 0) {
|
|
18
|
+
throw new McpError(ErrorCode.InvalidRequest, `Page with title/UID "${titleOrUid}" not found`);
|
|
19
|
+
}
|
|
20
|
+
return uidResults[0][0];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Format search results into a standard structure
|
|
24
|
+
*/
|
|
25
|
+
static formatSearchResults(results, searchDescription, includePageTitle = true) {
|
|
26
|
+
if (!results || results.length === 0) {
|
|
27
|
+
return {
|
|
28
|
+
success: true,
|
|
29
|
+
matches: [],
|
|
30
|
+
message: `No blocks found ${searchDescription}`
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const matches = results.map(([uid, content, pageTitle]) => ({
|
|
34
|
+
block_uid: uid,
|
|
35
|
+
content,
|
|
36
|
+
...(includePageTitle && pageTitle && { page_title: pageTitle })
|
|
37
|
+
}));
|
|
38
|
+
return {
|
|
39
|
+
success: true,
|
|
40
|
+
matches,
|
|
41
|
+
message: `Found ${matches.length} block(s) ${searchDescription}`
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Format a tag for searching, handling both # and [[]] formats
|
|
46
|
+
* @param tag Tag without prefix
|
|
47
|
+
* @returns Array of possible formats to search for
|
|
48
|
+
*/
|
|
49
|
+
static formatTag(tag) {
|
|
50
|
+
// Remove any existing prefixes
|
|
51
|
+
const cleanTag = tag.replace(/^#|\[\[|\]\]$/g, '');
|
|
52
|
+
// Return both formats for comprehensive search
|
|
53
|
+
return [`#${cleanTag}`, `[[${cleanTag}]]`];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Parse a date string into a Roam-formatted date
|
|
57
|
+
*/
|
|
58
|
+
static parseDate(dateStr) {
|
|
59
|
+
const date = new Date(dateStr);
|
|
60
|
+
const months = [
|
|
61
|
+
'January', 'February', 'March', 'April', 'May', 'June',
|
|
62
|
+
'July', 'August', 'September', 'October', 'November', 'December'
|
|
63
|
+
];
|
|
64
|
+
// Adjust for timezone to ensure consistent date comparison
|
|
65
|
+
const utcDate = new Date(date.getTime() + date.getTimezoneOffset() * 60000);
|
|
66
|
+
return `${months[utcDate.getMonth()]} ${utcDate.getDate()}${this.getOrdinalSuffix(utcDate.getDate())}, ${utcDate.getFullYear()}`;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Parse a date string into a Roam-formatted date range
|
|
70
|
+
* Returns [startDate, endDate] with endDate being inclusive (end of day)
|
|
71
|
+
*/
|
|
72
|
+
static parseDateRange(startStr, endStr) {
|
|
73
|
+
const startDate = new Date(startStr);
|
|
74
|
+
const endDate = new Date(endStr);
|
|
75
|
+
endDate.setHours(23, 59, 59, 999); // Make end date inclusive
|
|
76
|
+
const months = [
|
|
77
|
+
'January', 'February', 'March', 'April', 'May', 'June',
|
|
78
|
+
'July', 'August', 'September', 'October', 'November', 'December'
|
|
79
|
+
];
|
|
80
|
+
// Adjust for timezone
|
|
81
|
+
const utcStart = new Date(startDate.getTime() + startDate.getTimezoneOffset() * 60000);
|
|
82
|
+
const utcEnd = new Date(endDate.getTime() + endDate.getTimezoneOffset() * 60000);
|
|
83
|
+
return [
|
|
84
|
+
`${months[utcStart.getMonth()]} ${utcStart.getDate()}${this.getOrdinalSuffix(utcStart.getDate())}, ${utcStart.getFullYear()}`,
|
|
85
|
+
`${months[utcEnd.getMonth()]} ${utcEnd.getDate()}${this.getOrdinalSuffix(utcEnd.getDate())}, ${utcEnd.getFullYear()}`
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
static getOrdinalSuffix(day) {
|
|
89
|
+
if (day > 3 && day < 21)
|
|
90
|
+
return 'th';
|
|
91
|
+
switch (day % 10) {
|
|
92
|
+
case 1: return 'st';
|
|
93
|
+
case 2: return 'nd';
|
|
94
|
+
case 3: return 'rd';
|
|
95
|
+
default: return 'th';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|