roam-research-mcp 0.19.0 → 0.22.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 +91 -20
- package/build/search/text-search.js +3 -4
- package/build/server/roam-server.js +20 -10
- package/build/tools/helpers/refs.js +47 -0
- package/build/tools/helpers/text.js +6 -0
- package/build/tools/operations/blocks.js +269 -0
- package/build/tools/operations/memory.js +102 -0
- package/build/tools/operations/outline.js +347 -0
- package/build/tools/operations/pages.js +213 -0
- package/build/tools/operations/search/handlers.js +56 -0
- package/build/tools/operations/search/index.js +95 -0
- package/build/tools/operations/search/types.js +1 -0
- package/build/tools/operations/search.js +285 -0
- package/build/tools/operations/todos.js +82 -0
- package/build/tools/schemas.js +36 -24
- package/build/tools/tool-handlers.js +58 -1145
- package/build/tools/types/index.js +1 -0
- package/package.json +1 -1
|
@@ -1,1168 +1,81 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
return str.split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
|
|
8
|
-
};
|
|
9
|
-
// Helper function to collect all referenced block UIDs from text
|
|
10
|
-
const collectRefs = (text, depth = 0, refs = new Set()) => {
|
|
11
|
-
if (depth >= 4)
|
|
12
|
-
return refs; // Max recursion depth
|
|
13
|
-
const refRegex = /\(\(([a-zA-Z0-9_-]+)\)\)/g;
|
|
14
|
-
let match;
|
|
15
|
-
while ((match = refRegex.exec(text)) !== null) {
|
|
16
|
-
const [_, uid] = match;
|
|
17
|
-
refs.add(uid);
|
|
18
|
-
}
|
|
19
|
-
return refs;
|
|
20
|
-
};
|
|
21
|
-
// Helper function to resolve block references
|
|
22
|
-
const resolveRefs = async (graph, text, depth = 0) => {
|
|
23
|
-
if (depth >= 4)
|
|
24
|
-
return text; // Max recursion depth
|
|
25
|
-
const refs = collectRefs(text, depth);
|
|
26
|
-
if (refs.size === 0)
|
|
27
|
-
return text;
|
|
28
|
-
// Get referenced block contents
|
|
29
|
-
const refQuery = `[:find ?uid ?string
|
|
30
|
-
:in $ [?uid ...]
|
|
31
|
-
:where [?b :block/uid ?uid]
|
|
32
|
-
[?b :block/string ?string]]`;
|
|
33
|
-
const refResults = await q(graph, refQuery, [Array.from(refs)]);
|
|
34
|
-
// Create lookup map of uid -> string
|
|
35
|
-
const refMap = new Map();
|
|
36
|
-
refResults.forEach(([uid, string]) => {
|
|
37
|
-
refMap.set(uid, string);
|
|
38
|
-
});
|
|
39
|
-
// Replace references with their content
|
|
40
|
-
let resolvedText = text;
|
|
41
|
-
for (const uid of refs) {
|
|
42
|
-
const refContent = refMap.get(uid);
|
|
43
|
-
if (refContent) {
|
|
44
|
-
// Recursively resolve nested references
|
|
45
|
-
const resolvedContent = await resolveRefs(graph, refContent, depth + 1);
|
|
46
|
-
resolvedText = resolvedText.replace(new RegExp(`\\(\\(${uid}\\)\\)`, 'g'), resolvedContent);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
return resolvedText;
|
|
50
|
-
};
|
|
1
|
+
import { PageOperations } from './operations/pages.js';
|
|
2
|
+
import { BlockOperations } from './operations/blocks.js';
|
|
3
|
+
import { SearchOperations } from './operations/search/index.js';
|
|
4
|
+
import { MemoryOperations } from './operations/memory.js';
|
|
5
|
+
import { TodoOperations } from './operations/todos.js';
|
|
6
|
+
import { OutlineOperations } from './operations/outline.js';
|
|
51
7
|
export class ToolHandlers {
|
|
52
8
|
graph;
|
|
9
|
+
pageOps;
|
|
10
|
+
blockOps;
|
|
11
|
+
searchOps;
|
|
12
|
+
memoryOps;
|
|
13
|
+
todoOps;
|
|
14
|
+
outlineOps;
|
|
53
15
|
constructor(graph) {
|
|
54
16
|
this.graph = graph;
|
|
17
|
+
this.pageOps = new PageOperations(graph);
|
|
18
|
+
this.blockOps = new BlockOperations(graph);
|
|
19
|
+
this.searchOps = new SearchOperations(graph);
|
|
20
|
+
this.memoryOps = new MemoryOperations(graph);
|
|
21
|
+
this.todoOps = new TodoOperations(graph);
|
|
22
|
+
this.outlineOps = new OutlineOperations(graph);
|
|
55
23
|
}
|
|
24
|
+
// Page Operations
|
|
56
25
|
async findPagesModifiedToday() {
|
|
57
|
-
|
|
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
|
-
}
|
|
26
|
+
return this.pageOps.findPagesModifiedToday();
|
|
95
27
|
}
|
|
96
|
-
async
|
|
97
|
-
|
|
98
|
-
if (!Array.isArray(outline) || outline.length === 0) {
|
|
99
|
-
throw new McpError(ErrorCode.InvalidRequest, 'outline must be a non-empty array');
|
|
100
|
-
}
|
|
101
|
-
// Filter out items with undefined text
|
|
102
|
-
const validOutline = outline.filter(item => item.text !== undefined);
|
|
103
|
-
if (validOutline.length === 0) {
|
|
104
|
-
throw new McpError(ErrorCode.InvalidRequest, 'outline must contain at least one item with text');
|
|
105
|
-
}
|
|
106
|
-
// Validate outline structure
|
|
107
|
-
const invalidItems = validOutline.filter(item => typeof item.level !== 'number' ||
|
|
108
|
-
item.level < 1 ||
|
|
109
|
-
item.level > 10 ||
|
|
110
|
-
typeof item.text !== 'string' ||
|
|
111
|
-
item.text.trim().length === 0);
|
|
112
|
-
if (invalidItems.length > 0) {
|
|
113
|
-
throw new McpError(ErrorCode.InvalidRequest, 'outline contains invalid items - each item must have a level (1-10) and non-empty text');
|
|
114
|
-
}
|
|
115
|
-
// Helper function to find or create page with retries
|
|
116
|
-
const findOrCreatePage = async (titleOrUid, maxRetries = 3, delayMs = 500) => {
|
|
117
|
-
// First try to find by title
|
|
118
|
-
const titleQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
|
|
119
|
-
const variations = [
|
|
120
|
-
titleOrUid, // Original
|
|
121
|
-
capitalizeWords(titleOrUid), // Each word capitalized
|
|
122
|
-
titleOrUid.toLowerCase() // All lowercase
|
|
123
|
-
];
|
|
124
|
-
for (let retry = 0; retry < maxRetries; retry++) {
|
|
125
|
-
// Try each case variation
|
|
126
|
-
for (const variation of variations) {
|
|
127
|
-
const findResults = await q(this.graph, titleQuery, [variation]);
|
|
128
|
-
if (findResults && findResults.length > 0) {
|
|
129
|
-
return findResults[0][0];
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
// If not found as title, try as UID
|
|
133
|
-
const uidQuery = `[:find ?uid
|
|
134
|
-
:where [?e :block/uid "${titleOrUid}"]
|
|
135
|
-
[?e :block/uid ?uid]]`;
|
|
136
|
-
const uidResult = await q(this.graph, uidQuery, []);
|
|
137
|
-
if (uidResult && uidResult.length > 0) {
|
|
138
|
-
return uidResult[0][0];
|
|
139
|
-
}
|
|
140
|
-
// If still not found and this is the first retry, try to create the page
|
|
141
|
-
if (retry === 0) {
|
|
142
|
-
const success = await createPage(this.graph, {
|
|
143
|
-
action: 'create-page',
|
|
144
|
-
page: { title: titleOrUid }
|
|
145
|
-
});
|
|
146
|
-
// Even if createPage returns false, the page might still have been created
|
|
147
|
-
// Wait a bit and continue to next retry
|
|
148
|
-
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
if (retry < maxRetries - 1) {
|
|
152
|
-
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
throw new McpError(ErrorCode.InvalidRequest, `Failed to find or create page "${titleOrUid}" after multiple attempts`);
|
|
156
|
-
};
|
|
157
|
-
// Get or create the target page
|
|
158
|
-
const targetPageUid = await findOrCreatePage(page_title_uid || formatRoamDate(new Date()));
|
|
159
|
-
// Helper function to find block with improved relationship checks
|
|
160
|
-
const findBlockWithRetry = async (pageUid, blockString, maxRetries = 5, initialDelay = 1000, case_sensitive = false) => {
|
|
161
|
-
// Try multiple query strategies
|
|
162
|
-
const queries = [
|
|
163
|
-
// Strategy 1: Direct page and string match
|
|
164
|
-
`[:find ?b-uid ?order
|
|
165
|
-
:where [?p :block/uid "${pageUid}"]
|
|
166
|
-
[?b :block/page ?p]
|
|
167
|
-
[?b :block/string ?block-str]
|
|
168
|
-
[(${case_sensitive ? '=' : 'clojure.string/equals-ignore-case'} ?block-str "${blockString}")]
|
|
169
|
-
[?b :block/order ?order]
|
|
170
|
-
[?b :block/uid ?b-uid]]`,
|
|
171
|
-
// Strategy 2: Parent-child relationship
|
|
172
|
-
`[:find ?b-uid ?order
|
|
173
|
-
:where [?p :block/uid "${pageUid}"]
|
|
174
|
-
[?b :block/parents ?p]
|
|
175
|
-
[?b :block/string ?block-str]
|
|
176
|
-
[(${case_sensitive ? '=' : 'clojure.string/equals-ignore-case'} ?block-str "${blockString}")]
|
|
177
|
-
[?b :block/order ?order]
|
|
178
|
-
[?b :block/uid ?b-uid]]`,
|
|
179
|
-
// Strategy 3: Broader page relationship
|
|
180
|
-
`[:find ?b-uid ?order
|
|
181
|
-
:where [?p :block/uid "${pageUid}"]
|
|
182
|
-
[?b :block/page ?page]
|
|
183
|
-
[?p :block/page ?page]
|
|
184
|
-
[?b :block/string ?block-str]
|
|
185
|
-
[(${case_sensitive ? '=' : 'clojure.string/equals-ignore-case'} ?block-str "${blockString}")]
|
|
186
|
-
[?b :block/order ?order]
|
|
187
|
-
[?b :block/uid ?b-uid]]`
|
|
188
|
-
];
|
|
189
|
-
for (let retry = 0; retry < maxRetries; retry++) {
|
|
190
|
-
// Try each query strategy
|
|
191
|
-
for (const queryStr of queries) {
|
|
192
|
-
const blockResults = await q(this.graph, queryStr, []);
|
|
193
|
-
if (blockResults && blockResults.length > 0) {
|
|
194
|
-
// Use the most recently created block
|
|
195
|
-
const sorted = blockResults.sort((a, b) => b[1] - a[1]);
|
|
196
|
-
return sorted[0][0];
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
// Exponential backoff
|
|
200
|
-
const delay = initialDelay * Math.pow(2, retry);
|
|
201
|
-
await new Promise(resolve => setTimeout(resolve, delay));
|
|
202
|
-
console.log(`Retry ${retry + 1}/${maxRetries} finding block "${blockString}" under "${pageUid}"`);
|
|
203
|
-
}
|
|
204
|
-
throw new McpError(ErrorCode.InternalError, `Failed to find block "${blockString}" under page "${pageUid}" after trying multiple strategies`);
|
|
205
|
-
};
|
|
206
|
-
// Helper function to create and verify block with improved error handling
|
|
207
|
-
const createAndVerifyBlock = async (content, parentUid, maxRetries = 5, initialDelay = 1000, isRetry = false, case_sensitive = false) => {
|
|
208
|
-
try {
|
|
209
|
-
// Initial delay before any operations
|
|
210
|
-
if (!isRetry) {
|
|
211
|
-
await new Promise(resolve => setTimeout(resolve, initialDelay));
|
|
212
|
-
}
|
|
213
|
-
for (let retry = 0; retry < maxRetries; retry++) {
|
|
214
|
-
console.log(`Attempt ${retry + 1}/${maxRetries} to create block "${content}" under "${parentUid}"`);
|
|
215
|
-
// Create block
|
|
216
|
-
const success = await createBlock(this.graph, {
|
|
217
|
-
action: 'create-block',
|
|
218
|
-
location: {
|
|
219
|
-
'parent-uid': parentUid,
|
|
220
|
-
order: 'last'
|
|
221
|
-
},
|
|
222
|
-
block: { string: content }
|
|
223
|
-
});
|
|
224
|
-
// Wait with exponential backoff
|
|
225
|
-
const delay = initialDelay * Math.pow(2, retry);
|
|
226
|
-
await new Promise(resolve => setTimeout(resolve, delay));
|
|
227
|
-
try {
|
|
228
|
-
// Try to find the block using our improved findBlockWithRetry
|
|
229
|
-
return await findBlockWithRetry(parentUid, content, maxRetries, initialDelay, case_sensitive);
|
|
230
|
-
}
|
|
231
|
-
catch (error) {
|
|
232
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
233
|
-
console.log(`Failed to find block on attempt ${retry + 1}: ${errorMessage}`);
|
|
234
|
-
if (retry === maxRetries - 1)
|
|
235
|
-
throw error;
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
throw new McpError(ErrorCode.InternalError, `Failed to create and verify block "${content}" after ${maxRetries} attempts`);
|
|
239
|
-
}
|
|
240
|
-
catch (error) {
|
|
241
|
-
// If this is already a retry, throw the error
|
|
242
|
-
if (isRetry)
|
|
243
|
-
throw error;
|
|
244
|
-
// Otherwise, try one more time with a clean slate
|
|
245
|
-
console.log(`Retrying block creation for "${content}" with fresh attempt`);
|
|
246
|
-
await new Promise(resolve => setTimeout(resolve, initialDelay * 2));
|
|
247
|
-
return createAndVerifyBlock(content, parentUid, maxRetries, initialDelay, true, case_sensitive);
|
|
248
|
-
}
|
|
249
|
-
};
|
|
250
|
-
// Get or create the parent block
|
|
251
|
-
let targetParentUid;
|
|
252
|
-
if (!block_text_uid) {
|
|
253
|
-
targetParentUid = targetPageUid;
|
|
254
|
-
}
|
|
255
|
-
else {
|
|
256
|
-
try {
|
|
257
|
-
// Create header block and get its UID
|
|
258
|
-
targetParentUid = await createAndVerifyBlock(block_text_uid, targetPageUid);
|
|
259
|
-
}
|
|
260
|
-
catch (error) {
|
|
261
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
262
|
-
throw new McpError(ErrorCode.InternalError, `Failed to create header block "${block_text_uid}": ${errorMessage}`);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
// Initialize result variable
|
|
266
|
-
let result;
|
|
267
|
-
try {
|
|
268
|
-
// Validate level sequence
|
|
269
|
-
let prevLevel = 0;
|
|
270
|
-
for (const item of validOutline) {
|
|
271
|
-
// Level should not increase by more than 1 at a time
|
|
272
|
-
if (item.level > prevLevel + 1) {
|
|
273
|
-
throw new McpError(ErrorCode.InvalidRequest, `Invalid outline structure - level ${item.level} follows level ${prevLevel}`);
|
|
274
|
-
}
|
|
275
|
-
prevLevel = item.level;
|
|
276
|
-
}
|
|
277
|
-
// Convert outline items to markdown-like structure
|
|
278
|
-
const markdownContent = validOutline
|
|
279
|
-
.map(item => {
|
|
280
|
-
const indent = ' '.repeat(item.level - 1);
|
|
281
|
-
return `${indent}- ${item.text?.trim()}`;
|
|
282
|
-
})
|
|
283
|
-
.join('\n');
|
|
284
|
-
// Convert to Roam markdown format
|
|
285
|
-
const convertedContent = convertToRoamMarkdown(markdownContent);
|
|
286
|
-
// Parse markdown into hierarchical structure
|
|
287
|
-
const nodes = parseMarkdown(convertedContent);
|
|
288
|
-
// Convert nodes to batch actions
|
|
289
|
-
const actions = convertToRoamActions(nodes, targetParentUid, 'last');
|
|
290
|
-
if (actions.length === 0) {
|
|
291
|
-
throw new McpError(ErrorCode.InvalidRequest, 'No valid actions generated from outline');
|
|
292
|
-
}
|
|
293
|
-
// Execute batch actions to create the outline
|
|
294
|
-
result = await batchActions(this.graph, {
|
|
295
|
-
action: 'batch-actions',
|
|
296
|
-
actions
|
|
297
|
-
}).catch(error => {
|
|
298
|
-
throw new McpError(ErrorCode.InternalError, `Failed to create outline blocks: ${error.message}`);
|
|
299
|
-
});
|
|
300
|
-
if (!result) {
|
|
301
|
-
throw new McpError(ErrorCode.InternalError, 'Failed to create outline blocks - no result returned');
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
catch (error) {
|
|
305
|
-
if (error instanceof McpError)
|
|
306
|
-
throw error;
|
|
307
|
-
throw new McpError(ErrorCode.InternalError, `Failed to create outline: ${error.message}`);
|
|
308
|
-
}
|
|
309
|
-
// Get the created block UIDs
|
|
310
|
-
const createdUids = result?.created_uids || [];
|
|
311
|
-
return {
|
|
312
|
-
success: true,
|
|
313
|
-
page_uid: targetPageUid,
|
|
314
|
-
parent_uid: targetParentUid,
|
|
315
|
-
created_uids: createdUids
|
|
316
|
-
};
|
|
28
|
+
async createPage(title, content) {
|
|
29
|
+
return this.pageOps.createPage(title, content);
|
|
317
30
|
}
|
|
318
31
|
async fetchPageByTitle(title) {
|
|
319
|
-
|
|
320
|
-
throw new McpError(ErrorCode.InvalidRequest, 'title is required');
|
|
321
|
-
}
|
|
322
|
-
// Try different case variations
|
|
323
|
-
const variations = [
|
|
324
|
-
title, // Original
|
|
325
|
-
capitalizeWords(title), // Each word capitalized
|
|
326
|
-
title.toLowerCase() // All lowercase
|
|
327
|
-
];
|
|
328
|
-
let uid = null;
|
|
329
|
-
for (const variation of variations) {
|
|
330
|
-
const searchQuery = `[:find ?uid .
|
|
331
|
-
:where [?e :node/title "${variation}"]
|
|
332
|
-
[?e :block/uid ?uid]]`;
|
|
333
|
-
const result = await q(this.graph, searchQuery, []);
|
|
334
|
-
uid = (result === null || result === undefined) ? null : String(result);
|
|
335
|
-
if (uid)
|
|
336
|
-
break;
|
|
337
|
-
}
|
|
338
|
-
if (!uid) {
|
|
339
|
-
throw new McpError(ErrorCode.InvalidRequest, `Page with title "${title}" not found (tried original, capitalized words, and lowercase)`);
|
|
340
|
-
}
|
|
341
|
-
// Define ancestor rule for traversing block hierarchy
|
|
342
|
-
const ancestorRule = `[
|
|
343
|
-
[ (ancestor ?b ?a)
|
|
344
|
-
[?a :block/children ?b] ]
|
|
345
|
-
[ (ancestor ?b ?a)
|
|
346
|
-
[?parent :block/children ?b]
|
|
347
|
-
(ancestor ?parent ?a) ]
|
|
348
|
-
]`;
|
|
349
|
-
// Get all blocks under this page using ancestor rule
|
|
350
|
-
const blocksQuery = `[:find ?block-uid ?block-str ?order ?parent-uid
|
|
351
|
-
:in $ % ?page-title
|
|
352
|
-
:where [?page :node/title ?page-title]
|
|
353
|
-
[?block :block/string ?block-str]
|
|
354
|
-
[?block :block/uid ?block-uid]
|
|
355
|
-
[?block :block/order ?order]
|
|
356
|
-
(ancestor ?block ?page)
|
|
357
|
-
[?parent :block/children ?block]
|
|
358
|
-
[?parent :block/uid ?parent-uid]]`;
|
|
359
|
-
const blocks = await q(this.graph, blocksQuery, [ancestorRule, title]);
|
|
360
|
-
if (!blocks || blocks.length === 0) {
|
|
361
|
-
return `${title} (no content found)`;
|
|
362
|
-
}
|
|
363
|
-
// Create a map of all blocks
|
|
364
|
-
const blockMap = new Map();
|
|
365
|
-
const rootBlocks = [];
|
|
366
|
-
// First pass: Create all block objects
|
|
367
|
-
for (const [blockUid, blockStr, order, parentUid] of blocks) {
|
|
368
|
-
const resolvedString = await resolveRefs(this.graph, blockStr);
|
|
369
|
-
const block = {
|
|
370
|
-
uid: blockUid,
|
|
371
|
-
string: resolvedString,
|
|
372
|
-
order: order,
|
|
373
|
-
children: []
|
|
374
|
-
};
|
|
375
|
-
blockMap.set(blockUid, block);
|
|
376
|
-
// If no parent or parent is the page itself, it's a root block
|
|
377
|
-
if (!parentUid || parentUid === uid) {
|
|
378
|
-
rootBlocks.push(block);
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
// Second pass: Build parent-child relationships
|
|
382
|
-
for (const [blockUid, _, __, parentUid] of blocks) {
|
|
383
|
-
if (parentUid && parentUid !== uid) {
|
|
384
|
-
const child = blockMap.get(blockUid);
|
|
385
|
-
const parent = blockMap.get(parentUid);
|
|
386
|
-
if (child && parent && !parent.children.includes(child)) {
|
|
387
|
-
parent.children.push(child);
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
// Sort blocks recursively
|
|
392
|
-
const sortBlocks = (blocks) => {
|
|
393
|
-
blocks.sort((a, b) => a.order - b.order);
|
|
394
|
-
blocks.forEach(block => {
|
|
395
|
-
if (block.children.length > 0) {
|
|
396
|
-
sortBlocks(block.children);
|
|
397
|
-
}
|
|
398
|
-
});
|
|
399
|
-
};
|
|
400
|
-
sortBlocks(rootBlocks);
|
|
401
|
-
// Convert to markdown with proper nesting
|
|
402
|
-
const toMarkdown = (blocks, level = 0) => {
|
|
403
|
-
return blocks.map(block => {
|
|
404
|
-
const indent = ' '.repeat(level);
|
|
405
|
-
let md = `${indent}- ${block.string}`;
|
|
406
|
-
if (block.children.length > 0) {
|
|
407
|
-
md += '\n' + toMarkdown(block.children, level + 1);
|
|
408
|
-
}
|
|
409
|
-
return md;
|
|
410
|
-
}).join('\n');
|
|
411
|
-
};
|
|
412
|
-
return `# ${title}\n\n${toMarkdown(rootBlocks)}`;
|
|
413
|
-
}
|
|
414
|
-
async createPage(title, content) {
|
|
415
|
-
// Ensure title is properly formatted
|
|
416
|
-
const pageTitle = String(title).trim();
|
|
417
|
-
// First try to find if the page exists
|
|
418
|
-
const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
|
|
419
|
-
const findResults = await q(this.graph, findQuery, [pageTitle]);
|
|
420
|
-
let pageUid;
|
|
421
|
-
if (findResults && findResults.length > 0) {
|
|
422
|
-
// Page exists, use its UID
|
|
423
|
-
pageUid = findResults[0][0];
|
|
424
|
-
}
|
|
425
|
-
else {
|
|
426
|
-
// Create new page
|
|
427
|
-
const success = await createPage(this.graph, {
|
|
428
|
-
action: 'create-page',
|
|
429
|
-
page: {
|
|
430
|
-
title: pageTitle
|
|
431
|
-
}
|
|
432
|
-
});
|
|
433
|
-
if (!success) {
|
|
434
|
-
throw new Error('Failed to create page');
|
|
435
|
-
}
|
|
436
|
-
// Get the new page's UID
|
|
437
|
-
const results = await q(this.graph, findQuery, [pageTitle]);
|
|
438
|
-
if (!results || results.length === 0) {
|
|
439
|
-
throw new Error('Could not find created page');
|
|
440
|
-
}
|
|
441
|
-
pageUid = results[0][0];
|
|
442
|
-
}
|
|
443
|
-
// If content is provided, check if it looks like nested markdown
|
|
444
|
-
if (content) {
|
|
445
|
-
const isMultilined = content.includes('\n') || hasMarkdownTable(content);
|
|
446
|
-
if (isMultilined) {
|
|
447
|
-
// Use import_nested_markdown functionality
|
|
448
|
-
const convertedContent = convertToRoamMarkdown(content);
|
|
449
|
-
const nodes = parseMarkdown(convertedContent);
|
|
450
|
-
const actions = convertToRoamActions(nodes, pageUid, 'last');
|
|
451
|
-
const result = await batchActions(this.graph, {
|
|
452
|
-
action: 'batch-actions',
|
|
453
|
-
actions
|
|
454
|
-
});
|
|
455
|
-
if (!result) {
|
|
456
|
-
throw new Error('Failed to import nested markdown content');
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
else {
|
|
460
|
-
// Create a simple block for non-nested content
|
|
461
|
-
const blockSuccess = await createBlock(this.graph, {
|
|
462
|
-
action: 'create-block',
|
|
463
|
-
location: {
|
|
464
|
-
"parent-uid": pageUid,
|
|
465
|
-
"order": "last"
|
|
466
|
-
},
|
|
467
|
-
block: { string: content }
|
|
468
|
-
});
|
|
469
|
-
if (!blockSuccess) {
|
|
470
|
-
throw new Error('Failed to create content block');
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
return { success: true, uid: pageUid };
|
|
32
|
+
return this.pageOps.fetchPageByTitle(title);
|
|
475
33
|
}
|
|
34
|
+
// Block Operations
|
|
476
35
|
async createBlock(content, page_uid, title) {
|
|
477
|
-
|
|
478
|
-
let targetPageUid = page_uid;
|
|
479
|
-
// If no page_uid but title provided, search for page by title
|
|
480
|
-
if (!targetPageUid && title) {
|
|
481
|
-
const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
|
|
482
|
-
const findResults = await q(this.graph, findQuery, [title]);
|
|
483
|
-
if (findResults && findResults.length > 0) {
|
|
484
|
-
targetPageUid = findResults[0][0];
|
|
485
|
-
}
|
|
486
|
-
else {
|
|
487
|
-
// Create page with provided title if it doesn't exist
|
|
488
|
-
const success = await createPage(this.graph, {
|
|
489
|
-
action: 'create-page',
|
|
490
|
-
page: { title }
|
|
491
|
-
});
|
|
492
|
-
if (!success) {
|
|
493
|
-
throw new Error('Failed to create page with provided title');
|
|
494
|
-
}
|
|
495
|
-
// Get the new page's UID
|
|
496
|
-
const results = await q(this.graph, findQuery, [title]);
|
|
497
|
-
if (!results || results.length === 0) {
|
|
498
|
-
throw new Error('Could not find created page');
|
|
499
|
-
}
|
|
500
|
-
targetPageUid = results[0][0];
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
// If neither page_uid nor title provided, use today's date page
|
|
504
|
-
if (!targetPageUid) {
|
|
505
|
-
const today = new Date();
|
|
506
|
-
const dateStr = formatRoamDate(today);
|
|
507
|
-
// Try to find today's page
|
|
508
|
-
const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
|
|
509
|
-
const findResults = await q(this.graph, findQuery, [dateStr]);
|
|
510
|
-
if (findResults && findResults.length > 0) {
|
|
511
|
-
targetPageUid = findResults[0][0];
|
|
512
|
-
}
|
|
513
|
-
else {
|
|
514
|
-
// Create today's page if it doesn't exist
|
|
515
|
-
const success = await createPage(this.graph, {
|
|
516
|
-
action: 'create-page',
|
|
517
|
-
page: { title: dateStr }
|
|
518
|
-
});
|
|
519
|
-
if (!success) {
|
|
520
|
-
throw new Error('Failed to create today\'s page');
|
|
521
|
-
}
|
|
522
|
-
// Get the new page's UID
|
|
523
|
-
const results = await q(this.graph, findQuery, [dateStr]);
|
|
524
|
-
if (!results || results.length === 0) {
|
|
525
|
-
throw new Error('Could not find created today\'s page');
|
|
526
|
-
}
|
|
527
|
-
targetPageUid = results[0][0];
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
// If the content has multiple lines or is a table, use nested import
|
|
531
|
-
if (content.includes('\n')) {
|
|
532
|
-
// Parse and import the nested content
|
|
533
|
-
const convertedContent = convertToRoamMarkdown(content);
|
|
534
|
-
const nodes = parseMarkdown(convertedContent);
|
|
535
|
-
const actions = convertToRoamActions(nodes, targetPageUid, 'last');
|
|
536
|
-
// Execute batch actions to create the nested structure
|
|
537
|
-
const result = await batchActions(this.graph, {
|
|
538
|
-
action: 'batch-actions',
|
|
539
|
-
actions
|
|
540
|
-
});
|
|
541
|
-
if (!result) {
|
|
542
|
-
throw new Error('Failed to create nested blocks');
|
|
543
|
-
}
|
|
544
|
-
const blockUid = result.created_uids?.[0];
|
|
545
|
-
return {
|
|
546
|
-
success: true,
|
|
547
|
-
block_uid: blockUid,
|
|
548
|
-
parent_uid: targetPageUid
|
|
549
|
-
};
|
|
550
|
-
}
|
|
551
|
-
else {
|
|
552
|
-
// For non-table content, create a simple block
|
|
553
|
-
const result = await createBlock(this.graph, {
|
|
554
|
-
action: 'create-block',
|
|
555
|
-
location: {
|
|
556
|
-
"parent-uid": targetPageUid,
|
|
557
|
-
"order": "last"
|
|
558
|
-
},
|
|
559
|
-
block: { string: content }
|
|
560
|
-
});
|
|
561
|
-
if (!result) {
|
|
562
|
-
throw new Error('Failed to create block');
|
|
563
|
-
}
|
|
564
|
-
// Get the block's UID
|
|
565
|
-
const findBlockQuery = `[:find ?uid
|
|
566
|
-
:in $ ?parent ?string
|
|
567
|
-
:where [?b :block/uid ?uid]
|
|
568
|
-
[?b :block/string ?string]
|
|
569
|
-
[?b :block/parents ?p]
|
|
570
|
-
[?p :block/uid ?parent]]`;
|
|
571
|
-
const blockResults = await q(this.graph, findBlockQuery, [targetPageUid, content]);
|
|
572
|
-
if (!blockResults || blockResults.length === 0) {
|
|
573
|
-
throw new Error('Could not find created block');
|
|
574
|
-
}
|
|
575
|
-
const blockUid = blockResults[0][0];
|
|
576
|
-
return {
|
|
577
|
-
success: true,
|
|
578
|
-
block_uid: blockUid,
|
|
579
|
-
parent_uid: targetPageUid
|
|
580
|
-
};
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
async importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order = 'first') {
|
|
584
|
-
// First get the page UID
|
|
585
|
-
let targetPageUid = page_uid;
|
|
586
|
-
if (!targetPageUid && page_title) {
|
|
587
|
-
const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
|
|
588
|
-
const findResults = await q(this.graph, findQuery, [page_title]);
|
|
589
|
-
if (findResults && findResults.length > 0) {
|
|
590
|
-
targetPageUid = findResults[0][0];
|
|
591
|
-
}
|
|
592
|
-
else {
|
|
593
|
-
throw new McpError(ErrorCode.InvalidRequest, `Page with title "${page_title}" not found`);
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
// If no page specified, use today's date page
|
|
597
|
-
if (!targetPageUid) {
|
|
598
|
-
const today = new Date();
|
|
599
|
-
const dateStr = formatRoamDate(today);
|
|
600
|
-
const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
|
|
601
|
-
const findResults = await q(this.graph, findQuery, [dateStr]);
|
|
602
|
-
if (findResults && findResults.length > 0) {
|
|
603
|
-
targetPageUid = findResults[0][0];
|
|
604
|
-
}
|
|
605
|
-
else {
|
|
606
|
-
// Create today's page
|
|
607
|
-
const success = await createPage(this.graph, {
|
|
608
|
-
action: 'create-page',
|
|
609
|
-
page: { title: dateStr }
|
|
610
|
-
});
|
|
611
|
-
if (!success) {
|
|
612
|
-
throw new McpError(ErrorCode.InternalError, 'Failed to create today\'s page');
|
|
613
|
-
}
|
|
614
|
-
const results = await q(this.graph, findQuery, [dateStr]);
|
|
615
|
-
if (!results || results.length === 0) {
|
|
616
|
-
throw new McpError(ErrorCode.InternalError, 'Could not find created today\'s page');
|
|
617
|
-
}
|
|
618
|
-
targetPageUid = results[0][0];
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
// Now get the parent block UID
|
|
622
|
-
let targetParentUid = parent_uid;
|
|
623
|
-
if (!targetParentUid && parent_string) {
|
|
624
|
-
if (!targetPageUid) {
|
|
625
|
-
throw new McpError(ErrorCode.InvalidRequest, 'Must provide either page_uid or page_title when using parent_string');
|
|
626
|
-
}
|
|
627
|
-
// Find block by exact string match within the page
|
|
628
|
-
const findBlockQuery = `[:find ?uid
|
|
629
|
-
:where [?p :block/uid "${targetPageUid}"]
|
|
630
|
-
[?b :block/page ?p]
|
|
631
|
-
[?b :block/string "${parent_string}"]]`;
|
|
632
|
-
const blockResults = await q(this.graph, findBlockQuery, []);
|
|
633
|
-
if (!blockResults || blockResults.length === 0) {
|
|
634
|
-
throw new McpError(ErrorCode.InvalidRequest, `Block with content "${parent_string}" not found on specified page`);
|
|
635
|
-
}
|
|
636
|
-
targetParentUid = blockResults[0][0];
|
|
637
|
-
}
|
|
638
|
-
// If no parent specified, use page as parent
|
|
639
|
-
if (!targetParentUid) {
|
|
640
|
-
targetParentUid = targetPageUid;
|
|
641
|
-
}
|
|
642
|
-
// Always use parseMarkdown for content with multiple lines or any markdown formatting
|
|
643
|
-
const isMultilined = content.includes('\n');
|
|
644
|
-
if (isMultilined) {
|
|
645
|
-
// Parse markdown into hierarchical structure
|
|
646
|
-
const convertedContent = convertToRoamMarkdown(content);
|
|
647
|
-
const nodes = parseMarkdown(convertedContent);
|
|
648
|
-
// Convert markdown nodes to batch actions
|
|
649
|
-
const actions = convertToRoamActions(nodes, targetParentUid, order);
|
|
650
|
-
// Execute batch actions to add content
|
|
651
|
-
const result = await batchActions(this.graph, {
|
|
652
|
-
action: 'batch-actions',
|
|
653
|
-
actions
|
|
654
|
-
});
|
|
655
|
-
if (!result) {
|
|
656
|
-
throw new McpError(ErrorCode.InternalError, 'Failed to import nested markdown content');
|
|
657
|
-
}
|
|
658
|
-
// Get the created block UIDs
|
|
659
|
-
const createdUids = result.created_uids || [];
|
|
660
|
-
return {
|
|
661
|
-
success: true,
|
|
662
|
-
page_uid: targetPageUid,
|
|
663
|
-
parent_uid: targetParentUid,
|
|
664
|
-
created_uids: createdUids
|
|
665
|
-
};
|
|
666
|
-
}
|
|
667
|
-
else {
|
|
668
|
-
// Create a simple block for non-nested content
|
|
669
|
-
const blockSuccess = await createBlock(this.graph, {
|
|
670
|
-
action: 'create-block',
|
|
671
|
-
location: {
|
|
672
|
-
"parent-uid": targetParentUid,
|
|
673
|
-
order
|
|
674
|
-
},
|
|
675
|
-
block: { string: content }
|
|
676
|
-
});
|
|
677
|
-
if (!blockSuccess) {
|
|
678
|
-
throw new McpError(ErrorCode.InternalError, 'Failed to create content block');
|
|
679
|
-
}
|
|
680
|
-
return {
|
|
681
|
-
success: true,
|
|
682
|
-
page_uid: targetPageUid,
|
|
683
|
-
parent_uid: targetParentUid
|
|
684
|
-
};
|
|
685
|
-
}
|
|
36
|
+
return this.blockOps.createBlock(content, page_uid, title);
|
|
686
37
|
}
|
|
687
38
|
async updateBlock(block_uid, content, transform) {
|
|
688
|
-
|
|
689
|
-
throw new McpError(ErrorCode.InvalidRequest, 'block_uid is required');
|
|
690
|
-
}
|
|
691
|
-
// Get current block content
|
|
692
|
-
const blockQuery = `[:find ?string .
|
|
693
|
-
:where [?b :block/uid "${block_uid}"]
|
|
694
|
-
[?b :block/string ?string]]`;
|
|
695
|
-
const result = await q(this.graph, blockQuery, []);
|
|
696
|
-
if (result === null || result === undefined) {
|
|
697
|
-
throw new McpError(ErrorCode.InvalidRequest, `Block with UID "${block_uid}" not found`);
|
|
698
|
-
}
|
|
699
|
-
const currentContent = String(result);
|
|
700
|
-
if (currentContent === null || currentContent === undefined) {
|
|
701
|
-
throw new McpError(ErrorCode.InvalidRequest, `Block with UID "${block_uid}" not found`);
|
|
702
|
-
}
|
|
703
|
-
// Determine new content
|
|
704
|
-
let newContent;
|
|
705
|
-
if (content) {
|
|
706
|
-
newContent = content;
|
|
707
|
-
}
|
|
708
|
-
else if (transform) {
|
|
709
|
-
newContent = transform(currentContent);
|
|
710
|
-
}
|
|
711
|
-
else {
|
|
712
|
-
throw new McpError(ErrorCode.InvalidRequest, 'Either content or transform function must be provided');
|
|
713
|
-
}
|
|
714
|
-
try {
|
|
715
|
-
const success = await updateBlock(this.graph, {
|
|
716
|
-
action: 'update-block',
|
|
717
|
-
block: {
|
|
718
|
-
uid: block_uid,
|
|
719
|
-
string: newContent
|
|
720
|
-
}
|
|
721
|
-
});
|
|
722
|
-
if (!success) {
|
|
723
|
-
throw new Error('Failed to update block');
|
|
724
|
-
}
|
|
725
|
-
return {
|
|
726
|
-
success: true,
|
|
727
|
-
content: newContent
|
|
728
|
-
};
|
|
729
|
-
}
|
|
730
|
-
catch (error) {
|
|
731
|
-
throw new McpError(ErrorCode.InternalError, `Failed to update block: ${error.message}`);
|
|
732
|
-
}
|
|
39
|
+
return this.blockOps.updateBlock(block_uid, content, transform);
|
|
733
40
|
}
|
|
734
41
|
async updateBlocks(updates) {
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
if (!update.block_uid) {
|
|
741
|
-
throw new McpError(ErrorCode.InvalidRequest, `Update at index ${index} missing block_uid`);
|
|
742
|
-
}
|
|
743
|
-
if (!update.content && !update.transform) {
|
|
744
|
-
throw new McpError(ErrorCode.InvalidRequest, `Update at index ${index} must have either content or transform`);
|
|
745
|
-
}
|
|
746
|
-
});
|
|
747
|
-
// Get current content for all blocks
|
|
748
|
-
const blockUids = updates.map(u => u.block_uid);
|
|
749
|
-
const blockQuery = `[:find ?uid ?string
|
|
750
|
-
:in $ [?uid ...]
|
|
751
|
-
:where [?b :block/uid ?uid]
|
|
752
|
-
[?b :block/string ?string]]`;
|
|
753
|
-
const blockResults = await q(this.graph, blockQuery, [blockUids]);
|
|
754
|
-
// Create map of uid -> current content
|
|
755
|
-
const contentMap = new Map();
|
|
756
|
-
blockResults.forEach(([uid, string]) => {
|
|
757
|
-
contentMap.set(uid, string);
|
|
758
|
-
});
|
|
759
|
-
// Prepare batch actions
|
|
760
|
-
const actions = [];
|
|
761
|
-
const results = [];
|
|
762
|
-
for (const update of updates) {
|
|
763
|
-
try {
|
|
764
|
-
const currentContent = contentMap.get(update.block_uid);
|
|
765
|
-
if (!currentContent) {
|
|
766
|
-
results.push({
|
|
767
|
-
block_uid: update.block_uid,
|
|
768
|
-
content: '',
|
|
769
|
-
success: false,
|
|
770
|
-
error: `Block with UID "${update.block_uid}" not found`
|
|
771
|
-
});
|
|
772
|
-
continue;
|
|
773
|
-
}
|
|
774
|
-
// Determine new content
|
|
775
|
-
let newContent;
|
|
776
|
-
if (update.content) {
|
|
777
|
-
newContent = update.content;
|
|
778
|
-
}
|
|
779
|
-
else if (update.transform) {
|
|
780
|
-
const regex = new RegExp(update.transform.find, update.transform.global ? 'g' : '');
|
|
781
|
-
newContent = currentContent.replace(regex, update.transform.replace);
|
|
782
|
-
}
|
|
783
|
-
else {
|
|
784
|
-
// This shouldn't happen due to earlier validation
|
|
785
|
-
throw new Error('Invalid update configuration');
|
|
786
|
-
}
|
|
787
|
-
// Add to batch actions
|
|
788
|
-
actions.push({
|
|
789
|
-
action: 'update-block',
|
|
790
|
-
block: {
|
|
791
|
-
uid: update.block_uid,
|
|
792
|
-
string: newContent
|
|
793
|
-
}
|
|
794
|
-
});
|
|
795
|
-
results.push({
|
|
796
|
-
block_uid: update.block_uid,
|
|
797
|
-
content: newContent,
|
|
798
|
-
success: true
|
|
799
|
-
});
|
|
800
|
-
}
|
|
801
|
-
catch (error) {
|
|
802
|
-
results.push({
|
|
803
|
-
block_uid: update.block_uid,
|
|
804
|
-
content: contentMap.get(update.block_uid) || '',
|
|
805
|
-
success: false,
|
|
806
|
-
error: error.message
|
|
807
|
-
});
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
// Execute batch update if we have any valid actions
|
|
811
|
-
if (actions.length > 0) {
|
|
812
|
-
try {
|
|
813
|
-
const batchResult = await batchActions(this.graph, {
|
|
814
|
-
action: 'batch-actions',
|
|
815
|
-
actions
|
|
816
|
-
});
|
|
817
|
-
if (!batchResult) {
|
|
818
|
-
throw new Error('Batch update failed');
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
catch (error) {
|
|
822
|
-
// Mark all previously successful results as failed
|
|
823
|
-
results.forEach(result => {
|
|
824
|
-
if (result.success) {
|
|
825
|
-
result.success = false;
|
|
826
|
-
result.error = `Batch update failed: ${error.message}`;
|
|
827
|
-
}
|
|
828
|
-
});
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
return {
|
|
832
|
-
success: results.every(r => r.success),
|
|
833
|
-
results
|
|
834
|
-
};
|
|
42
|
+
return this.blockOps.updateBlocks(updates);
|
|
43
|
+
}
|
|
44
|
+
// Search Operations
|
|
45
|
+
async searchByStatus(status, page_title_uid, include, exclude) {
|
|
46
|
+
return this.searchOps.searchByStatus(status, page_title_uid, include, exclude);
|
|
835
47
|
}
|
|
836
|
-
async
|
|
837
|
-
|
|
838
|
-
// Get target page UID if provided
|
|
839
|
-
let targetPageUid;
|
|
840
|
-
if (page_title_uid) {
|
|
841
|
-
// Try to find page by title or UID
|
|
842
|
-
const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
|
|
843
|
-
const findResults = await q(this.graph, findQuery, [page_title_uid]);
|
|
844
|
-
if (findResults && findResults.length > 0) {
|
|
845
|
-
targetPageUid = findResults[0][0];
|
|
846
|
-
}
|
|
847
|
-
else {
|
|
848
|
-
// Try as UID
|
|
849
|
-
const uidQuery = `[:find ?uid :where [?e :block/uid "${page_title_uid}"] [?e :block/uid ?uid]]`;
|
|
850
|
-
const uidResults = await q(this.graph, uidQuery, []);
|
|
851
|
-
if (!uidResults || uidResults.length === 0) {
|
|
852
|
-
throw new McpError(ErrorCode.InvalidRequest, `Page with title/UID "${page_title_uid}" not found`);
|
|
853
|
-
}
|
|
854
|
-
targetPageUid = uidResults[0][0];
|
|
855
|
-
}
|
|
856
|
-
}
|
|
857
|
-
// Build query based on whether we're searching in a specific page
|
|
858
|
-
let queryStr;
|
|
859
|
-
let queryParams;
|
|
860
|
-
const statusPattern = `{{[[${status}]]}}`;
|
|
861
|
-
if (targetPageUid) {
|
|
862
|
-
queryStr = `[:find ?block-uid ?block-str
|
|
863
|
-
:in $ ?status-pattern ?page-uid
|
|
864
|
-
:where [?p :block/uid ?page-uid]
|
|
865
|
-
[?b :block/page ?p]
|
|
866
|
-
[?b :block/string ?block-str]
|
|
867
|
-
[?b :block/uid ?block-uid]
|
|
868
|
-
[(clojure.string/includes? ?block-str ?status-pattern)]]`;
|
|
869
|
-
queryParams = [statusPattern, targetPageUid];
|
|
870
|
-
}
|
|
871
|
-
else {
|
|
872
|
-
queryStr = `[:find ?block-uid ?block-str ?page-title
|
|
873
|
-
:in $ ?status-pattern
|
|
874
|
-
:where [?b :block/string ?block-str]
|
|
875
|
-
[?b :block/uid ?block-uid]
|
|
876
|
-
[?b :block/page ?p]
|
|
877
|
-
[?p :node/title ?page-title]
|
|
878
|
-
[(clojure.string/includes? ?block-str ?status-pattern)]]`;
|
|
879
|
-
queryParams = [statusPattern];
|
|
880
|
-
}
|
|
881
|
-
const results = await q(this.graph, queryStr, queryParams);
|
|
882
|
-
if (!results || results.length === 0) {
|
|
883
|
-
return {
|
|
884
|
-
success: true,
|
|
885
|
-
matches: [],
|
|
886
|
-
message: `No blocks found with status ${status}`
|
|
887
|
-
};
|
|
888
|
-
}
|
|
889
|
-
// Format initial results
|
|
890
|
-
let matches = results.map(result => {
|
|
891
|
-
const [uid, content, pageTitle] = result;
|
|
892
|
-
return {
|
|
893
|
-
block_uid: uid,
|
|
894
|
-
content,
|
|
895
|
-
...(pageTitle && { page_title: pageTitle })
|
|
896
|
-
};
|
|
897
|
-
});
|
|
898
|
-
// Post-query filtering with case sensitivity option
|
|
899
|
-
if (include) {
|
|
900
|
-
const includeTerms = include.split(',').map(term => term.trim());
|
|
901
|
-
matches = matches.filter(match => {
|
|
902
|
-
const matchContent = case_sensitive ? match.content : match.content.toLowerCase();
|
|
903
|
-
const matchTitle = match.page_title && (case_sensitive ? match.page_title : match.page_title.toLowerCase());
|
|
904
|
-
const terms = case_sensitive ? includeTerms : includeTerms.map(t => t.toLowerCase());
|
|
905
|
-
return terms.some(term => matchContent.includes(case_sensitive ? term : term.toLowerCase()) ||
|
|
906
|
-
(matchTitle && matchTitle.includes(case_sensitive ? term : term.toLowerCase())));
|
|
907
|
-
});
|
|
908
|
-
}
|
|
909
|
-
if (exclude) {
|
|
910
|
-
const excludeTerms = exclude.split(',').map(term => term.trim());
|
|
911
|
-
matches = matches.filter(match => {
|
|
912
|
-
const matchContent = case_sensitive ? match.content : match.content.toLowerCase();
|
|
913
|
-
const matchTitle = match.page_title && (case_sensitive ? match.page_title : match.page_title.toLowerCase());
|
|
914
|
-
const terms = case_sensitive ? excludeTerms : excludeTerms.map(t => t.toLowerCase());
|
|
915
|
-
return !terms.some(term => matchContent.includes(case_sensitive ? term : term.toLowerCase()) ||
|
|
916
|
-
(matchTitle && matchTitle.includes(case_sensitive ? term : term.toLowerCase())));
|
|
917
|
-
});
|
|
918
|
-
}
|
|
919
|
-
return {
|
|
920
|
-
success: true,
|
|
921
|
-
matches,
|
|
922
|
-
message: `Found ${matches.length} block(s) with status ${status}${include ? ` including "${include}"` : ''}${exclude ? ` excluding "${exclude}"` : ''}`
|
|
923
|
-
};
|
|
48
|
+
async searchForTag(primary_tag, page_title_uid, near_tag) {
|
|
49
|
+
return this.searchOps.searchForTag(primary_tag, page_title_uid, near_tag);
|
|
924
50
|
}
|
|
925
|
-
async
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
if (page_title_uid) {
|
|
934
|
-
// Try to find page by title or UID
|
|
935
|
-
const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
|
|
936
|
-
const findResults = await q(this.graph, findQuery, [page_title_uid]);
|
|
937
|
-
if (findResults && findResults.length > 0) {
|
|
938
|
-
targetPageUid = findResults[0][0];
|
|
939
|
-
}
|
|
940
|
-
else {
|
|
941
|
-
// Try as UID
|
|
942
|
-
const uidQuery = `[:find ?uid :where [?e :block/uid "${page_title_uid}"] [?e :block/uid ?uid]]`;
|
|
943
|
-
const uidResults = await q(this.graph, uidQuery, []);
|
|
944
|
-
if (!uidResults || uidResults.length === 0) {
|
|
945
|
-
throw new McpError(ErrorCode.InvalidRequest, `Page with title/UID "${page_title_uid}" not found`);
|
|
946
|
-
}
|
|
947
|
-
targetPageUid = uidResults[0][0];
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
// Build query based on whether we're searching in a specific page and/or for a nearby tag
|
|
951
|
-
let queryStr;
|
|
952
|
-
let queryParams;
|
|
953
|
-
if (targetPageUid) {
|
|
954
|
-
if (nearTagFormatted) {
|
|
955
|
-
queryStr = `[:find ?block-uid ?block-str
|
|
956
|
-
:in $ ?primary-tag ?near-tag ?page-uid
|
|
957
|
-
:where [?p :block/uid ?page-uid]
|
|
958
|
-
[?b :block/page ?p]
|
|
959
|
-
[?b :block/string ?block-str]
|
|
960
|
-
[?b :block/uid ?block-uid]
|
|
961
|
-
[(clojure.string/includes?
|
|
962
|
-
${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
|
|
963
|
-
${case_sensitive ? '?primary-tag' : '(clojure.string/lower-case ?primary-tag)'})]
|
|
964
|
-
[(clojure.string/includes?
|
|
965
|
-
${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
|
|
966
|
-
${case_sensitive ? '?near-tag' : '(clojure.string/lower-case ?near-tag)'})]`;
|
|
967
|
-
queryParams = [primaryTagFormatted, nearTagFormatted, targetPageUid];
|
|
968
|
-
}
|
|
969
|
-
else {
|
|
970
|
-
queryStr = `[:find ?block-uid ?block-str
|
|
971
|
-
:in $ ?primary-tag ?page-uid
|
|
972
|
-
:where [?p :block/uid ?page-uid]
|
|
973
|
-
[?b :block/page ?p]
|
|
974
|
-
[?b :block/string ?block-str]
|
|
975
|
-
[?b :block/uid ?block-uid]
|
|
976
|
-
[(clojure.string/includes?
|
|
977
|
-
${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
|
|
978
|
-
${case_sensitive ? '?primary-tag' : '(clojure.string/lower-case ?primary-tag)'})]`;
|
|
979
|
-
queryParams = [primaryTagFormatted, targetPageUid];
|
|
980
|
-
}
|
|
981
|
-
}
|
|
982
|
-
else {
|
|
983
|
-
// Search across all pages
|
|
984
|
-
if (nearTagFormatted) {
|
|
985
|
-
queryStr = `[:find ?block-uid ?block-str ?page-title
|
|
986
|
-
:in $ ?primary-tag ?near-tag
|
|
987
|
-
:where [?b :block/string ?block-str]
|
|
988
|
-
[?b :block/uid ?block-uid]
|
|
989
|
-
[?b :block/page ?p]
|
|
990
|
-
[?p :node/title ?page-title]
|
|
991
|
-
[(clojure.string/includes?
|
|
992
|
-
${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
|
|
993
|
-
${case_sensitive ? '?primary-tag' : '(clojure.string/lower-case ?primary-tag)'})]
|
|
994
|
-
[(clojure.string/includes?
|
|
995
|
-
${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
|
|
996
|
-
${case_sensitive ? '?near-tag' : '(clojure.string/lower-case ?near-tag)'})]`;
|
|
997
|
-
queryParams = [primaryTagFormatted, nearTagFormatted];
|
|
998
|
-
}
|
|
999
|
-
else {
|
|
1000
|
-
queryStr = `[:find ?block-uid ?block-str ?page-title
|
|
1001
|
-
:in $ ?primary-tag
|
|
1002
|
-
:where [?b :block/string ?block-str]
|
|
1003
|
-
[?b :block/uid ?block-uid]
|
|
1004
|
-
[?b :block/page ?p]
|
|
1005
|
-
[?p :node/title ?page-title]
|
|
1006
|
-
[(clojure.string/includes?
|
|
1007
|
-
${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
|
|
1008
|
-
${case_sensitive ? '?primary-tag' : '(clojure.string/lower-case ?primary-tag)'})]`;
|
|
1009
|
-
queryParams = [primaryTagFormatted];
|
|
1010
|
-
}
|
|
1011
|
-
}
|
|
1012
|
-
const results = await q(this.graph, queryStr, queryParams);
|
|
1013
|
-
if (!results || results.length === 0) {
|
|
1014
|
-
return {
|
|
1015
|
-
success: true,
|
|
1016
|
-
matches: [],
|
|
1017
|
-
message: `No blocks found containing ${primaryTagFormatted}${nearTagFormatted ? ` near ${nearTagFormatted}` : ''}`
|
|
1018
|
-
};
|
|
1019
|
-
}
|
|
1020
|
-
// Format results
|
|
1021
|
-
const matches = results.map(([uid, content, pageTitle]) => ({
|
|
1022
|
-
block_uid: uid,
|
|
1023
|
-
content,
|
|
1024
|
-
...(pageTitle && { page_title: pageTitle })
|
|
1025
|
-
}));
|
|
1026
|
-
return {
|
|
1027
|
-
success: true,
|
|
1028
|
-
matches,
|
|
1029
|
-
message: `Found ${matches.length} block(s) containing ${primaryTagFormatted}${nearTagFormatted ? ` near ${nearTagFormatted}` : ''}`
|
|
1030
|
-
};
|
|
51
|
+
async searchBlockRefs(params) {
|
|
52
|
+
return this.searchOps.searchBlockRefs(params);
|
|
53
|
+
}
|
|
54
|
+
async searchHierarchy(params) {
|
|
55
|
+
return this.searchOps.searchHierarchy(params);
|
|
56
|
+
}
|
|
57
|
+
async searchByText(params) {
|
|
58
|
+
return this.searchOps.searchByText(params);
|
|
1031
59
|
}
|
|
1032
60
|
async searchByDate(params) {
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
[?p :node/title]]
|
|
1042
|
-
[(page? ?e)
|
|
1043
|
-
[?e :node/title]]
|
|
1044
|
-
]`;
|
|
1045
|
-
// Build query based on cheatsheet pattern
|
|
1046
|
-
const timeAttr = params.type === 'created' ? ':create/time' : ':edit/time';
|
|
1047
|
-
let queryStr = `[:find ?block-uid ?string ?time ?page-title
|
|
1048
|
-
:in $ ?start-ts ${endTimestamp ? '?end-ts' : ''}
|
|
1049
|
-
:where
|
|
1050
|
-
[?b ${timeAttr} ?time]
|
|
1051
|
-
[(>= ?time ?start-ts)]
|
|
1052
|
-
${endTimestamp ? '[(<= ?time ?end-ts)]' : ''}
|
|
1053
|
-
[?b :block/uid ?block-uid]
|
|
1054
|
-
[?b :block/string ?string]
|
|
1055
|
-
[?b :block/page ?p]
|
|
1056
|
-
[?p :node/title ?page-title]]`;
|
|
1057
|
-
// Execute query
|
|
1058
|
-
const queryParams = endTimestamp ?
|
|
1059
|
-
[startTimestamp, endTimestamp] :
|
|
1060
|
-
[startTimestamp];
|
|
1061
|
-
const results = await q(this.graph, queryStr, queryParams);
|
|
1062
|
-
if (!results || results.length === 0) {
|
|
1063
|
-
return {
|
|
1064
|
-
success: true,
|
|
1065
|
-
matches: [],
|
|
1066
|
-
message: 'No matches found for the given date range and criteria'
|
|
1067
|
-
};
|
|
1068
|
-
}
|
|
1069
|
-
// Process results - now we get [block-uid, string, time, page-title]
|
|
1070
|
-
const matches = results.map(([uid, content, time, pageTitle]) => ({
|
|
1071
|
-
uid,
|
|
1072
|
-
type: 'block',
|
|
1073
|
-
time,
|
|
1074
|
-
...(params.include_content && { content }),
|
|
1075
|
-
page_title: pageTitle
|
|
1076
|
-
}));
|
|
1077
|
-
// Apply case sensitivity if content is included
|
|
1078
|
-
if (params.include_content) {
|
|
1079
|
-
const case_sensitive = params.case_sensitive ?? true; // Default to true to match Roam's behavior
|
|
1080
|
-
if (!case_sensitive) {
|
|
1081
|
-
matches.forEach(match => {
|
|
1082
|
-
if (match.content) {
|
|
1083
|
-
match.content = match.content.toLowerCase();
|
|
1084
|
-
}
|
|
1085
|
-
if (match.page_title) {
|
|
1086
|
-
match.page_title = match.page_title.toLowerCase();
|
|
1087
|
-
}
|
|
1088
|
-
});
|
|
1089
|
-
}
|
|
1090
|
-
}
|
|
1091
|
-
// Sort by time
|
|
1092
|
-
const sortedMatches = matches.sort((a, b) => b.time - a.time);
|
|
1093
|
-
return {
|
|
1094
|
-
success: true,
|
|
1095
|
-
matches: sortedMatches,
|
|
1096
|
-
message: `Found ${sortedMatches.length} matches for the given date range and criteria`
|
|
1097
|
-
};
|
|
61
|
+
return this.searchOps.searchByDate(params);
|
|
62
|
+
}
|
|
63
|
+
// Memory Operations
|
|
64
|
+
async remember(memory, categories) {
|
|
65
|
+
return this.memoryOps.remember(memory, categories);
|
|
66
|
+
}
|
|
67
|
+
async recall() {
|
|
68
|
+
return this.memoryOps.recall();
|
|
1098
69
|
}
|
|
70
|
+
// Todo Operations
|
|
1099
71
|
async addTodos(todos) {
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
const findResults = await q(this.graph, findQuery, [dateStr]);
|
|
1109
|
-
let targetPageUid;
|
|
1110
|
-
if (findResults && findResults.length > 0) {
|
|
1111
|
-
targetPageUid = findResults[0][0];
|
|
1112
|
-
}
|
|
1113
|
-
else {
|
|
1114
|
-
// Create today's page if it doesn't exist
|
|
1115
|
-
const success = await createPage(this.graph, {
|
|
1116
|
-
action: 'create-page',
|
|
1117
|
-
page: { title: dateStr }
|
|
1118
|
-
});
|
|
1119
|
-
if (!success) {
|
|
1120
|
-
throw new Error('Failed to create today\'s page');
|
|
1121
|
-
}
|
|
1122
|
-
// Get the new page's UID
|
|
1123
|
-
const results = await q(this.graph, findQuery, [dateStr]);
|
|
1124
|
-
if (!results || results.length === 0) {
|
|
1125
|
-
throw new Error('Could not find created today\'s page');
|
|
1126
|
-
}
|
|
1127
|
-
targetPageUid = results[0][0];
|
|
1128
|
-
}
|
|
1129
|
-
// If more than 10 todos, use batch actions
|
|
1130
|
-
const todo_tag = "{{TODO}}";
|
|
1131
|
-
if (todos.length > 10) {
|
|
1132
|
-
const actions = todos.map((todo, index) => ({
|
|
1133
|
-
action: 'create-block',
|
|
1134
|
-
location: {
|
|
1135
|
-
'parent-uid': targetPageUid,
|
|
1136
|
-
order: index
|
|
1137
|
-
},
|
|
1138
|
-
block: {
|
|
1139
|
-
string: `${todo_tag} ${todo}`
|
|
1140
|
-
}
|
|
1141
|
-
}));
|
|
1142
|
-
const result = await batchActions(this.graph, {
|
|
1143
|
-
action: 'batch-actions',
|
|
1144
|
-
actions
|
|
1145
|
-
});
|
|
1146
|
-
if (!result) {
|
|
1147
|
-
throw new Error('Failed to create todo blocks');
|
|
1148
|
-
}
|
|
1149
|
-
}
|
|
1150
|
-
else {
|
|
1151
|
-
// Create todos sequentially
|
|
1152
|
-
for (const todo of todos) {
|
|
1153
|
-
const success = await createBlock(this.graph, {
|
|
1154
|
-
action: 'create-block',
|
|
1155
|
-
location: {
|
|
1156
|
-
"parent-uid": targetPageUid,
|
|
1157
|
-
"order": "last"
|
|
1158
|
-
},
|
|
1159
|
-
block: { string: `${todo_tag} ${todo}` }
|
|
1160
|
-
});
|
|
1161
|
-
if (!success) {
|
|
1162
|
-
throw new Error('Failed to create todo block');
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
}
|
|
1166
|
-
return { success: true };
|
|
72
|
+
return this.todoOps.addTodos(todos);
|
|
73
|
+
}
|
|
74
|
+
// Outline Operations
|
|
75
|
+
async createOutline(outline, page_title_uid, block_text_uid) {
|
|
76
|
+
return this.outlineOps.createOutline(outline, page_title_uid, block_text_uid);
|
|
77
|
+
}
|
|
78
|
+
async importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order = 'first') {
|
|
79
|
+
return this.outlineOps.importMarkdown(content, page_uid, page_title, parent_uid, parent_string, order);
|
|
1167
80
|
}
|
|
1168
81
|
}
|