roam-research-mcp 0.36.0 → 1.3.2
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 +95 -46
- package/build/Roam_Markdown_Cheatsheet.md +519 -175
- package/build/cache/page-uid-cache.js +55 -0
- package/build/cli/import-markdown.js +98 -0
- package/build/config/environment.js +5 -4
- package/build/index.js +4 -1
- package/build/markdown-utils.js +56 -7
- package/build/search/datomic-search.js +40 -1
- package/build/server/roam-server.js +92 -50
- package/build/shared/errors.js +84 -0
- package/build/shared/index.js +5 -0
- package/build/shared/validation.js +268 -0
- package/build/tools/operations/batch.js +165 -3
- package/build/tools/operations/memory.js +29 -19
- package/build/tools/operations/outline.js +110 -70
- package/build/tools/operations/pages.js +174 -62
- package/build/tools/operations/table.js +142 -0
- package/build/tools/schemas.js +121 -17
- package/build/tools/tool-handlers.js +35 -14
- package/package.json +5 -4
|
@@ -3,6 +3,9 @@ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
|
3
3
|
import { formatRoamDate } from '../../utils/helpers.js';
|
|
4
4
|
import { capitalizeWords } from '../helpers/text.js';
|
|
5
5
|
import { parseMarkdown, convertToRoamActions, convertToRoamMarkdown } from '../../markdown-utils.js';
|
|
6
|
+
import { pageUidCache } from '../../cache/page-uid-cache.js';
|
|
7
|
+
// Threshold for skipping child fetch during verification
|
|
8
|
+
const VERIFICATION_THRESHOLD = 5;
|
|
6
9
|
export class OutlineOperations {
|
|
7
10
|
constructor(graph) {
|
|
8
11
|
this.graph = graph;
|
|
@@ -14,49 +17,31 @@ export class OutlineOperations {
|
|
|
14
17
|
};
|
|
15
18
|
}
|
|
16
19
|
/**
|
|
17
|
-
* Helper function to find block with
|
|
20
|
+
* Helper function to find block with reduced retries for rate limit efficiency.
|
|
21
|
+
* Uses only the most reliable query strategy with 2 retries max.
|
|
18
22
|
*/
|
|
19
|
-
async findBlockWithRetry(pageUid, blockString, maxRetries =
|
|
20
|
-
//
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
[?b :block/order ?order]
|
|
28
|
-
[?b :block/uid ?b-uid]]`,
|
|
29
|
-
// Strategy 2: Parent-child relationship
|
|
30
|
-
`[:find ?b-uid ?order
|
|
31
|
-
:where [?p :block/uid "${pageUid}"]
|
|
32
|
-
[?b :block/parents ?p]
|
|
33
|
-
[?b :block/string "${blockString}"]
|
|
34
|
-
[?b :block/order ?order]
|
|
35
|
-
[?b :block/uid ?b-uid]]`,
|
|
36
|
-
// Strategy 3: Broader page relationship
|
|
37
|
-
`[:find ?b-uid ?order
|
|
38
|
-
:where [?p :block/uid "${pageUid}"]
|
|
39
|
-
[?b :block/page ?page]
|
|
40
|
-
[?p :block/page ?page]
|
|
41
|
-
[?b :block/string "${blockString}"]
|
|
42
|
-
[?b :block/order ?order]
|
|
43
|
-
[?b :block/uid ?b-uid]]`
|
|
44
|
-
];
|
|
23
|
+
async findBlockWithRetry(pageUid, blockString, maxRetries = 2, initialDelay = 1000) {
|
|
24
|
+
// Use only the most reliable query strategy (direct page and string match)
|
|
25
|
+
const query = `[:find ?b-uid ?order
|
|
26
|
+
:where [?p :block/uid "${pageUid}"]
|
|
27
|
+
[?b :block/page ?p]
|
|
28
|
+
[?b :block/string "${blockString}"]
|
|
29
|
+
[?b :block/order ?order]
|
|
30
|
+
[?b :block/uid ?b-uid]]`;
|
|
45
31
|
for (let retry = 0; retry < maxRetries; retry++) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
32
|
+
const blockResults = await q(this.graph, query, []);
|
|
33
|
+
if (blockResults && blockResults.length > 0) {
|
|
34
|
+
// Use the most recently created block (highest order)
|
|
35
|
+
const sorted = blockResults.sort((a, b) => b[1] - a[1]);
|
|
36
|
+
return sorted[0][0];
|
|
37
|
+
}
|
|
38
|
+
// Exponential backoff between retries
|
|
39
|
+
if (retry < maxRetries - 1) {
|
|
40
|
+
const delay = initialDelay * Math.pow(2, retry);
|
|
41
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
54
42
|
}
|
|
55
|
-
// Exponential backoff
|
|
56
|
-
const delay = initialDelay * Math.pow(2, retry);
|
|
57
|
-
await new Promise(resolve => setTimeout(resolve, delay));
|
|
58
43
|
}
|
|
59
|
-
throw new McpError(ErrorCode.InternalError, `Failed to find block "${blockString}" under page "${pageUid}" after
|
|
44
|
+
throw new McpError(ErrorCode.InternalError, `Failed to find block "${blockString}" under page "${pageUid}" after ${maxRetries} attempts`);
|
|
60
45
|
}
|
|
61
46
|
;
|
|
62
47
|
/**
|
|
@@ -224,21 +209,30 @@ export class OutlineOperations {
|
|
|
224
209
|
if (invalidItems.length > 0) {
|
|
225
210
|
throw new McpError(ErrorCode.InvalidRequest, 'outline contains invalid items - each item must have a level (1-10) and non-empty text');
|
|
226
211
|
}
|
|
227
|
-
// Helper function to find or create page with retries
|
|
212
|
+
// Helper function to find or create page with retries and caching
|
|
228
213
|
const findOrCreatePage = async (titleOrUid, maxRetries = 3, delayMs = 500) => {
|
|
229
|
-
// First try to find by title
|
|
230
|
-
const titleQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
|
|
231
214
|
const variations = [
|
|
232
215
|
titleOrUid, // Original
|
|
233
216
|
capitalizeWords(titleOrUid), // Each word capitalized
|
|
234
217
|
titleOrUid.toLowerCase() // All lowercase
|
|
235
218
|
];
|
|
219
|
+
// Check cache first for any variation
|
|
220
|
+
for (const variation of variations) {
|
|
221
|
+
const cachedUid = pageUidCache.get(variation);
|
|
222
|
+
if (cachedUid) {
|
|
223
|
+
return cachedUid;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const titleQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
|
|
236
227
|
for (let retry = 0; retry < maxRetries; retry++) {
|
|
237
228
|
// Try each case variation
|
|
238
229
|
for (const variation of variations) {
|
|
239
230
|
const findResults = await q(this.graph, titleQuery, [variation]);
|
|
240
231
|
if (findResults && findResults.length > 0) {
|
|
241
|
-
|
|
232
|
+
const uid = findResults[0][0];
|
|
233
|
+
// Cache the result
|
|
234
|
+
pageUidCache.set(titleOrUid, uid);
|
|
235
|
+
return uid;
|
|
242
236
|
}
|
|
243
237
|
}
|
|
244
238
|
// If not found as title, try as UID
|
|
@@ -264,6 +258,15 @@ export class OutlineOperations {
|
|
|
264
258
|
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
265
259
|
}
|
|
266
260
|
}
|
|
261
|
+
// One more attempt to find and cache after creation attempts
|
|
262
|
+
for (const variation of variations) {
|
|
263
|
+
const findResults = await q(this.graph, titleQuery, [variation]);
|
|
264
|
+
if (findResults && findResults.length > 0) {
|
|
265
|
+
const uid = findResults[0][0];
|
|
266
|
+
pageUidCache.onPageCreated(titleOrUid, uid);
|
|
267
|
+
return uid;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
267
270
|
throw new McpError(ErrorCode.InvalidRequest, `Failed to find or create page "${titleOrUid}" after multiple attempts`);
|
|
268
271
|
};
|
|
269
272
|
// Get or create the target page
|
|
@@ -359,18 +362,27 @@ export class OutlineOperations {
|
|
|
359
362
|
throw error;
|
|
360
363
|
throw new McpError(ErrorCode.InternalError, `Failed to create outline: ${error.message}`);
|
|
361
364
|
}
|
|
362
|
-
// Post-creation verification to get actual UIDs for top-level blocks
|
|
365
|
+
// Post-creation verification to get actual UIDs for top-level blocks
|
|
363
366
|
const createdBlocks = [];
|
|
364
367
|
// Only query for top-level blocks (level 1) based on the original outline input
|
|
365
368
|
const topLevelOutlineItems = validOutline.filter(item => item.level === 1);
|
|
369
|
+
// Skip recursive child fetching for large batches to reduce API calls
|
|
370
|
+
const skipChildFetch = topLevelOutlineItems.length > VERIFICATION_THRESHOLD;
|
|
366
371
|
for (const item of topLevelOutlineItems) {
|
|
367
372
|
try {
|
|
368
373
|
// Assert item.text is a string as it's filtered earlier to be non-undefined and non-empty
|
|
369
374
|
const foundUid = await this.findBlockWithRetry(targetParentUid, item.text);
|
|
370
375
|
if (foundUid) {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
createdBlocks.push(
|
|
376
|
+
if (skipChildFetch) {
|
|
377
|
+
// Large batch: just return parent UID, skip recursive child queries
|
|
378
|
+
createdBlocks.push({ uid: foundUid, text: item.text, level: 1, order: 0 });
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
// Small batch: full verification with children (current behavior)
|
|
382
|
+
const nestedBlock = await this.fetchBlockWithChildren(foundUid);
|
|
383
|
+
if (nestedBlock) {
|
|
384
|
+
createdBlocks.push(nestedBlock);
|
|
385
|
+
}
|
|
374
386
|
}
|
|
375
387
|
}
|
|
376
388
|
}
|
|
@@ -391,39 +403,56 @@ export class OutlineOperations {
|
|
|
391
403
|
// First get the page UID
|
|
392
404
|
let targetPageUid = page_uid;
|
|
393
405
|
if (!targetPageUid && page_title) {
|
|
394
|
-
|
|
395
|
-
const
|
|
396
|
-
if (
|
|
397
|
-
targetPageUid =
|
|
406
|
+
// Check cache first
|
|
407
|
+
const cachedUid = pageUidCache.get(page_title);
|
|
408
|
+
if (cachedUid) {
|
|
409
|
+
targetPageUid = cachedUid;
|
|
398
410
|
}
|
|
399
411
|
else {
|
|
400
|
-
|
|
412
|
+
const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
|
|
413
|
+
const findResults = await q(this.graph, findQuery, [page_title]);
|
|
414
|
+
if (findResults && findResults.length > 0) {
|
|
415
|
+
targetPageUid = findResults[0][0];
|
|
416
|
+
pageUidCache.set(page_title, targetPageUid);
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
throw new McpError(ErrorCode.InvalidRequest, `Page with title "${page_title}" not found`);
|
|
420
|
+
}
|
|
401
421
|
}
|
|
402
422
|
}
|
|
403
423
|
// If no page specified, use today's date page
|
|
404
424
|
if (!targetPageUid) {
|
|
405
425
|
const today = new Date();
|
|
406
426
|
const dateStr = formatRoamDate(today);
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
if (
|
|
410
|
-
targetPageUid =
|
|
427
|
+
// Check cache for today's page
|
|
428
|
+
const cachedDailyUid = pageUidCache.get(dateStr);
|
|
429
|
+
if (cachedDailyUid) {
|
|
430
|
+
targetPageUid = cachedDailyUid;
|
|
411
431
|
}
|
|
412
432
|
else {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
});
|
|
419
|
-
const results = await q(this.graph, findQuery, [dateStr]);
|
|
420
|
-
if (!results || results.length === 0) {
|
|
421
|
-
throw new McpError(ErrorCode.InternalError, 'Could not find created today\'s page');
|
|
422
|
-
}
|
|
423
|
-
targetPageUid = results[0][0];
|
|
433
|
+
const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
|
|
434
|
+
const findResults = await q(this.graph, findQuery, [dateStr]);
|
|
435
|
+
if (findResults && findResults.length > 0) {
|
|
436
|
+
targetPageUid = findResults[0][0];
|
|
437
|
+
pageUidCache.set(dateStr, targetPageUid);
|
|
424
438
|
}
|
|
425
|
-
|
|
426
|
-
|
|
439
|
+
else {
|
|
440
|
+
// Create today's page
|
|
441
|
+
try {
|
|
442
|
+
await createPage(this.graph, {
|
|
443
|
+
action: 'create-page',
|
|
444
|
+
page: { title: dateStr }
|
|
445
|
+
});
|
|
446
|
+
const results = await q(this.graph, findQuery, [dateStr]);
|
|
447
|
+
if (!results || results.length === 0) {
|
|
448
|
+
throw new McpError(ErrorCode.InternalError, 'Could not find created today\'s page');
|
|
449
|
+
}
|
|
450
|
+
targetPageUid = results[0][0];
|
|
451
|
+
pageUidCache.onPageCreated(dateStr, targetPageUid);
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
throw new McpError(ErrorCode.InternalError, `Failed to create today's page: ${error instanceof Error ? error.message : String(error)}`);
|
|
455
|
+
}
|
|
427
456
|
}
|
|
428
457
|
}
|
|
429
458
|
}
|
|
@@ -469,7 +498,18 @@ export class OutlineOperations {
|
|
|
469
498
|
if (!result) {
|
|
470
499
|
throw new McpError(ErrorCode.InternalError, 'Failed to import nested markdown content');
|
|
471
500
|
}
|
|
472
|
-
//
|
|
501
|
+
// Skip nested structure fetch for large imports to reduce API calls
|
|
502
|
+
const skipNestedFetch = actions.length > VERIFICATION_THRESHOLD;
|
|
503
|
+
if (skipNestedFetch) {
|
|
504
|
+
// Large import: return success with block count, skip recursive queries
|
|
505
|
+
return {
|
|
506
|
+
success: true,
|
|
507
|
+
page_uid: targetPageUid,
|
|
508
|
+
parent_uid: targetParentUid,
|
|
509
|
+
created_uids: []
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
// Small import: get all nested UIDs under the parent (current behavior)
|
|
473
513
|
const createdUids = await this.fetchNestedStructure(targetParentUid);
|
|
474
514
|
return {
|
|
475
515
|
success: true,
|
|
@@ -3,6 +3,9 @@ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
|
3
3
|
import { capitalizeWords } from '../helpers/text.js';
|
|
4
4
|
import { resolveRefs } from '../helpers/refs.js';
|
|
5
5
|
import { convertToRoamActions, convertToRoamMarkdown } from '../../markdown-utils.js';
|
|
6
|
+
import { pageUidCache } from '../../cache/page-uid-cache.js';
|
|
7
|
+
import { buildTableActions } from './table.js';
|
|
8
|
+
import { BatchOperations } from './batch.js';
|
|
6
9
|
// Helper to get ordinal suffix for dates
|
|
7
10
|
function getOrdinalSuffix(day) {
|
|
8
11
|
if (day > 3 && day < 21)
|
|
@@ -17,8 +20,9 @@ function getOrdinalSuffix(day) {
|
|
|
17
20
|
export class PageOperations {
|
|
18
21
|
constructor(graph) {
|
|
19
22
|
this.graph = graph;
|
|
23
|
+
this.batchOps = new BatchOperations(graph);
|
|
20
24
|
}
|
|
21
|
-
async findPagesModifiedToday(
|
|
25
|
+
async findPagesModifiedToday(limit = 50, offset = 0, sort_order = 'desc') {
|
|
22
26
|
// Define ancestor rule for traversing block hierarchy
|
|
23
27
|
const ancestorRule = `[
|
|
24
28
|
[ (ancestor ?b ?a)
|
|
@@ -31,15 +35,21 @@ export class PageOperations {
|
|
|
31
35
|
const startOfDay = new Date();
|
|
32
36
|
startOfDay.setHours(0, 0, 0, 0);
|
|
33
37
|
try {
|
|
34
|
-
// Query for pages modified today
|
|
35
|
-
|
|
38
|
+
// Query for pages modified today, including modification time for sorting
|
|
39
|
+
let query = `[:find ?title ?time
|
|
36
40
|
:in $ ?start_of_day %
|
|
37
41
|
:where
|
|
38
42
|
[?page :node/title ?title]
|
|
39
43
|
(ancestor ?block ?page)
|
|
40
44
|
[?block :edit/time ?time]
|
|
41
|
-
[(> ?time ?start_of_day)]]
|
|
42
|
-
|
|
45
|
+
[(> ?time ?start_of_day)]]`;
|
|
46
|
+
if (limit !== -1) {
|
|
47
|
+
query += ` :limit ${limit}`;
|
|
48
|
+
}
|
|
49
|
+
if (offset > 0) {
|
|
50
|
+
query += ` :offset ${offset}`;
|
|
51
|
+
}
|
|
52
|
+
const results = await q(this.graph, query, [startOfDay.getTime(), ancestorRule]);
|
|
43
53
|
if (!results || results.length === 0) {
|
|
44
54
|
return {
|
|
45
55
|
success: true,
|
|
@@ -47,7 +57,16 @@ export class PageOperations {
|
|
|
47
57
|
message: 'No pages have been modified today'
|
|
48
58
|
};
|
|
49
59
|
}
|
|
50
|
-
//
|
|
60
|
+
// Sort results by modification time
|
|
61
|
+
results.sort((a, b) => {
|
|
62
|
+
if (sort_order === 'desc') {
|
|
63
|
+
return b[1] - a[1]; // Newest first
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
return a[1] - b[1]; // Oldest first
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
// Extract unique page titles from sorted results
|
|
51
70
|
const uniquePages = Array.from(new Set(results.map(([title]) => title)));
|
|
52
71
|
return {
|
|
53
72
|
success: true,
|
|
@@ -62,72 +81,146 @@ export class PageOperations {
|
|
|
62
81
|
async createPage(title, content) {
|
|
63
82
|
// Ensure title is properly formatted
|
|
64
83
|
const pageTitle = String(title).trim();
|
|
65
|
-
// First try to find if the page exists
|
|
66
|
-
const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
|
|
67
|
-
const findResults = await q(this.graph, findQuery, [pageTitle]);
|
|
68
84
|
let pageUid;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
85
|
+
// Check cache first to avoid unnecessary query
|
|
86
|
+
const cachedUid = pageUidCache.get(pageTitle);
|
|
87
|
+
if (cachedUid) {
|
|
88
|
+
pageUid = cachedUid;
|
|
72
89
|
}
|
|
73
90
|
else {
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
91
|
+
// First try to find if the page exists
|
|
92
|
+
const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
|
|
93
|
+
const findResults = await q(this.graph, findQuery, [pageTitle]);
|
|
94
|
+
if (findResults && findResults.length > 0) {
|
|
95
|
+
// Page exists, use its UID and cache it
|
|
96
|
+
pageUid = findResults[0][0];
|
|
97
|
+
pageUidCache.set(pageTitle, pageUid);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
// Create new page
|
|
101
|
+
try {
|
|
102
|
+
await createRoamPage(this.graph, {
|
|
103
|
+
action: 'create-page',
|
|
104
|
+
page: {
|
|
105
|
+
title: pageTitle
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
// Get the new page's UID
|
|
109
|
+
const results = await q(this.graph, findQuery, [pageTitle]);
|
|
110
|
+
if (!results || results.length === 0) {
|
|
111
|
+
throw new Error('Could not find created page');
|
|
80
112
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
113
|
+
pageUid = results[0][0];
|
|
114
|
+
// Cache the newly created page
|
|
115
|
+
pageUidCache.onPageCreated(pageTitle, pageUid);
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
throw new McpError(ErrorCode.InternalError, `Failed to create page: ${error instanceof Error ? error.message : String(error)}`);
|
|
86
119
|
}
|
|
87
|
-
pageUid = results[0][0];
|
|
88
|
-
}
|
|
89
|
-
catch (error) {
|
|
90
|
-
throw new McpError(ErrorCode.InternalError, `Failed to create page: ${error instanceof Error ? error.message : String(error)}`);
|
|
91
120
|
}
|
|
92
121
|
}
|
|
93
122
|
// If content is provided, create blocks using batch operations
|
|
94
123
|
if (content && content.length > 0) {
|
|
95
124
|
try {
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
125
|
+
// Idempotency check: If page already has content, skip adding more
|
|
126
|
+
// This prevents duplicate content when the tool is called twice
|
|
127
|
+
const existingBlocksQuery = `[:find (count ?b) .
|
|
128
|
+
:where [?p :block/uid "${pageUid}"]
|
|
129
|
+
[?p :block/children ?b]]`;
|
|
130
|
+
const existingBlockCountResult = await q(this.graph, existingBlocksQuery, []);
|
|
131
|
+
const existingBlockCount = typeof existingBlockCountResult === 'number' ? existingBlockCountResult : 0;
|
|
132
|
+
if (existingBlockCount && existingBlockCount > 0) {
|
|
133
|
+
// Page already has content - this might be a duplicate call
|
|
134
|
+
// Return success without adding duplicate content
|
|
135
|
+
return { success: true, uid: pageUid };
|
|
136
|
+
}
|
|
137
|
+
// Separate text content from table content, maintaining order
|
|
138
|
+
const textItems = [];
|
|
139
|
+
const tableItems = [];
|
|
140
|
+
for (let i = 0; i < content.length; i++) {
|
|
141
|
+
const item = content[i];
|
|
142
|
+
if (item.type === 'table') {
|
|
143
|
+
tableItems.push({ index: i, item: item });
|
|
110
144
|
}
|
|
111
145
|
else {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
146
|
+
// Default to text type
|
|
147
|
+
textItems.push(item);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Process text blocks
|
|
151
|
+
const allActions = [];
|
|
152
|
+
if (textItems.length > 0) {
|
|
153
|
+
// Filter out empty blocks (empty or whitespace-only text) to prevent creating visual linebreaks
|
|
154
|
+
const nonEmptyContent = textItems.filter(block => block.text && block.text.trim().length > 0);
|
|
155
|
+
if (nonEmptyContent.length > 0) {
|
|
156
|
+
// Normalize levels to prevent gaps after filtering
|
|
157
|
+
const normalizedContent = [];
|
|
158
|
+
for (let i = 0; i < nonEmptyContent.length; i++) {
|
|
159
|
+
const block = nonEmptyContent[i];
|
|
160
|
+
if (i === 0) {
|
|
161
|
+
normalizedContent.push({ ...block, level: 1 });
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
const prevLevel = normalizedContent[i - 1].level;
|
|
165
|
+
const maxAllowedLevel = prevLevel + 1;
|
|
166
|
+
normalizedContent.push({
|
|
167
|
+
...block,
|
|
168
|
+
level: Math.min(block.level, maxAllowedLevel)
|
|
169
|
+
});
|
|
170
|
+
}
|
|
116
171
|
}
|
|
117
|
-
|
|
118
|
-
|
|
172
|
+
// Convert content array to MarkdownNode format expected by convertToRoamActions
|
|
173
|
+
const nodes = normalizedContent.map(block => ({
|
|
174
|
+
content: convertToRoamMarkdown(block.text.replace(/^#+\s*/, '')),
|
|
175
|
+
level: block.level,
|
|
176
|
+
...(block.heading && { heading_level: block.heading }),
|
|
177
|
+
children: []
|
|
178
|
+
}));
|
|
179
|
+
// Create hierarchical structure based on levels
|
|
180
|
+
const rootNodes = [];
|
|
181
|
+
const levelMap = {};
|
|
182
|
+
for (const node of nodes) {
|
|
183
|
+
if (node.level === 1) {
|
|
184
|
+
rootNodes.push(node);
|
|
185
|
+
levelMap[1] = node;
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
const parentLevel = node.level - 1;
|
|
189
|
+
const parent = levelMap[parentLevel];
|
|
190
|
+
if (!parent) {
|
|
191
|
+
throw new Error(`Invalid block hierarchy: level ${node.level} block has no parent`);
|
|
192
|
+
}
|
|
193
|
+
parent.children.push(node);
|
|
194
|
+
levelMap[node.level] = node;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Generate batch actions for text blocks
|
|
198
|
+
const textActions = convertToRoamActions(rootNodes, pageUid, 'last');
|
|
199
|
+
allActions.push(...textActions);
|
|
119
200
|
}
|
|
120
201
|
}
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
// Execute batch operation
|
|
124
|
-
if (actions.length > 0) {
|
|
202
|
+
// Execute text block actions first (no placeholders, use SDK directly)
|
|
203
|
+
if (allActions.length > 0) {
|
|
125
204
|
const batchResult = await batchActions(this.graph, {
|
|
126
205
|
action: 'batch-actions',
|
|
127
|
-
actions
|
|
206
|
+
actions: allActions
|
|
128
207
|
});
|
|
129
208
|
if (!batchResult) {
|
|
130
|
-
throw new Error('Failed to create blocks');
|
|
209
|
+
throw new Error('Failed to create text blocks');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Process table items separately (use BatchOperations to handle UID placeholders)
|
|
213
|
+
for (const { item } of tableItems) {
|
|
214
|
+
const tableActions = buildTableActions({
|
|
215
|
+
parent_uid: pageUid,
|
|
216
|
+
headers: item.headers,
|
|
217
|
+
rows: item.rows,
|
|
218
|
+
order: 'last'
|
|
219
|
+
});
|
|
220
|
+
// Use BatchOperations.processBatch to handle {{uid:*}} placeholders
|
|
221
|
+
const tableResult = await this.batchOps.processBatch(tableActions);
|
|
222
|
+
if (!tableResult.success) {
|
|
223
|
+
throw new Error(`Failed to create table: ${typeof tableResult.error === 'string' ? tableResult.error : tableResult.error?.message}`);
|
|
131
224
|
}
|
|
132
225
|
}
|
|
133
226
|
}
|
|
@@ -142,11 +235,18 @@ export class PageOperations {
|
|
|
142
235
|
const month = today.toLocaleString('en-US', { month: 'long' });
|
|
143
236
|
const year = today.getFullYear();
|
|
144
237
|
const formattedTodayTitle = `${month} ${day}${getOrdinalSuffix(day)}, ${year}`;
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
238
|
+
// Check cache for daily page
|
|
239
|
+
let dailyPageUid = pageUidCache.get(formattedTodayTitle);
|
|
240
|
+
if (!dailyPageUid) {
|
|
241
|
+
const dailyPageQuery = `[:find ?uid .
|
|
242
|
+
:where [?e :node/title "${formattedTodayTitle}"]
|
|
243
|
+
[?e :block/uid ?uid]]`;
|
|
244
|
+
const dailyPageResult = await q(this.graph, dailyPageQuery, []);
|
|
245
|
+
dailyPageUid = dailyPageResult ? String(dailyPageResult) : undefined;
|
|
246
|
+
if (dailyPageUid) {
|
|
247
|
+
pageUidCache.set(formattedTodayTitle, dailyPageUid);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
150
250
|
if (dailyPageUid) {
|
|
151
251
|
await createBlock(this.graph, {
|
|
152
252
|
action: 'create-block',
|
|
@@ -178,15 +278,27 @@ export class PageOperations {
|
|
|
178
278
|
capitalizeWords(title), // Each word capitalized
|
|
179
279
|
title.toLowerCase() // All lowercase
|
|
180
280
|
];
|
|
281
|
+
// Check cache first for any variation
|
|
181
282
|
let uid = null;
|
|
182
283
|
for (const variation of variations) {
|
|
284
|
+
const cachedUid = pageUidCache.get(variation);
|
|
285
|
+
if (cachedUid) {
|
|
286
|
+
uid = cachedUid;
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// If not cached, query the database
|
|
291
|
+
if (!uid) {
|
|
292
|
+
const orClause = variations.map(v => `[?e :node/title "${v}"]`).join(' ');
|
|
183
293
|
const searchQuery = `[:find ?uid .
|
|
184
|
-
:where [?e :
|
|
185
|
-
|
|
294
|
+
:where [?e :block/uid ?uid]
|
|
295
|
+
(or ${orClause})]`;
|
|
186
296
|
const result = await q(this.graph, searchQuery, []);
|
|
187
297
|
uid = (result === null || result === undefined) ? null : String(result);
|
|
188
|
-
|
|
189
|
-
|
|
298
|
+
// Cache the result for the original title
|
|
299
|
+
if (uid) {
|
|
300
|
+
pageUidCache.set(title, uid);
|
|
301
|
+
}
|
|
190
302
|
}
|
|
191
303
|
if (!uid) {
|
|
192
304
|
throw new McpError(ErrorCode.InvalidRequest, `Page with title "${title}" not found (tried original, capitalized words, and lowercase)`);
|