roam-research-mcp 1.0.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 improved relationship checks
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 = 5, initialDelay = 1000) {
20
- // Try multiple query strategies
21
- const queries = [
22
- // Strategy 1: Direct page and string match
23
- `[:find ?b-uid ?order
24
- :where [?p :block/uid "${pageUid}"]
25
- [?b :block/page ?p]
26
- [?b :block/string "${blockString}"]
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
- // Try each query strategy
47
- for (const queryStr of queries) {
48
- const blockResults = await q(this.graph, queryStr, []);
49
- if (blockResults && blockResults.length > 0) {
50
- // Use the most recently created block
51
- const sorted = blockResults.sort((a, b) => b[1] - a[1]);
52
- return sorted[0][0];
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 trying multiple strategies`);
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
- return findResults[0][0];
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 and their children
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
- const nestedBlock = await this.fetchBlockWithChildren(foundUid);
372
- if (nestedBlock) {
373
- createdBlocks.push(nestedBlock);
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
- const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
395
- const findResults = await q(this.graph, findQuery, [page_title]);
396
- if (findResults && findResults.length > 0) {
397
- targetPageUid = findResults[0][0];
406
+ // Check cache first
407
+ const cachedUid = pageUidCache.get(page_title);
408
+ if (cachedUid) {
409
+ targetPageUid = cachedUid;
398
410
  }
399
411
  else {
400
- throw new McpError(ErrorCode.InvalidRequest, `Page with title "${page_title}" not found`);
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
- const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
408
- const findResults = await q(this.graph, findQuery, [dateStr]);
409
- if (findResults && findResults.length > 0) {
410
- targetPageUid = findResults[0][0];
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
- // Create today's page
414
- try {
415
- await createPage(this.graph, {
416
- action: 'create-page',
417
- page: { title: dateStr }
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
- catch (error) {
426
- throw new McpError(ErrorCode.InternalError, `Failed to create today's page: ${error instanceof Error ? error.message : String(error)}`);
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
- // After successful batch action, get all nested UIDs under the parent
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,10 @@ 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';
9
+ import { parseExistingBlocks, markdownToBlocks, diffBlockTrees, generateBatchActions, getDiffStats, isDiffEmpty, summarizeActions, } from '../../diff/index.js';
6
10
  // Helper to get ordinal suffix for dates
7
11
  function getOrdinalSuffix(day) {
8
12
  if (day > 3 && day < 21)
@@ -17,6 +21,7 @@ function getOrdinalSuffix(day) {
17
21
  export class PageOperations {
18
22
  constructor(graph) {
19
23
  this.graph = graph;
24
+ this.batchOps = new BatchOperations(graph);
20
25
  }
21
26
  async findPagesModifiedToday(limit = 50, offset = 0, sort_order = 'desc') {
22
27
  // Define ancestor rule for traversing block hierarchy
@@ -77,72 +82,146 @@ export class PageOperations {
77
82
  async createPage(title, content) {
78
83
  // Ensure title is properly formatted
79
84
  const pageTitle = String(title).trim();
80
- // First try to find if the page exists
81
- const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
82
- const findResults = await q(this.graph, findQuery, [pageTitle]);
83
85
  let pageUid;
84
- if (findResults && findResults.length > 0) {
85
- // Page exists, use its UID
86
- pageUid = findResults[0][0];
86
+ // Check cache first to avoid unnecessary query
87
+ const cachedUid = pageUidCache.get(pageTitle);
88
+ if (cachedUid) {
89
+ pageUid = cachedUid;
87
90
  }
88
91
  else {
89
- // Create new page
90
- try {
91
- await createRoamPage(this.graph, {
92
- action: 'create-page',
93
- page: {
94
- title: pageTitle
92
+ // First try to find if the page exists
93
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
94
+ const findResults = await q(this.graph, findQuery, [pageTitle]);
95
+ if (findResults && findResults.length > 0) {
96
+ // Page exists, use its UID and cache it
97
+ pageUid = findResults[0][0];
98
+ pageUidCache.set(pageTitle, pageUid);
99
+ }
100
+ else {
101
+ // Create new page
102
+ try {
103
+ await createRoamPage(this.graph, {
104
+ action: 'create-page',
105
+ page: {
106
+ title: pageTitle
107
+ }
108
+ });
109
+ // Get the new page's UID
110
+ const results = await q(this.graph, findQuery, [pageTitle]);
111
+ if (!results || results.length === 0) {
112
+ throw new Error('Could not find created page');
95
113
  }
96
- });
97
- // Get the new page's UID
98
- const results = await q(this.graph, findQuery, [pageTitle]);
99
- if (!results || results.length === 0) {
100
- throw new Error('Could not find created page');
114
+ pageUid = results[0][0];
115
+ // Cache the newly created page
116
+ pageUidCache.onPageCreated(pageTitle, pageUid);
117
+ }
118
+ catch (error) {
119
+ throw new McpError(ErrorCode.InternalError, `Failed to create page: ${error instanceof Error ? error.message : String(error)}`);
101
120
  }
102
- pageUid = results[0][0];
103
- }
104
- catch (error) {
105
- throw new McpError(ErrorCode.InternalError, `Failed to create page: ${error instanceof Error ? error.message : String(error)}`);
106
121
  }
107
122
  }
108
123
  // If content is provided, create blocks using batch operations
109
124
  if (content && content.length > 0) {
110
125
  try {
111
- // Convert content array to MarkdownNode format expected by convertToRoamActions
112
- const nodes = content.map(block => ({
113
- content: convertToRoamMarkdown(block.text.replace(/^#+\s*/, '')),
114
- level: block.level,
115
- ...(block.heading && { heading_level: block.heading }),
116
- children: []
117
- }));
118
- // Create hierarchical structure based on levels
119
- const rootNodes = [];
120
- const levelMap = {};
121
- for (const node of nodes) {
122
- if (node.level === 1) {
123
- rootNodes.push(node);
124
- levelMap[1] = node;
126
+ // Idempotency check: If page already has content, skip adding more
127
+ // This prevents duplicate content when the tool is called twice
128
+ const existingBlocksQuery = `[:find (count ?b) .
129
+ :where [?p :block/uid "${pageUid}"]
130
+ [?p :block/children ?b]]`;
131
+ const existingBlockCountResult = await q(this.graph, existingBlocksQuery, []);
132
+ const existingBlockCount = typeof existingBlockCountResult === 'number' ? existingBlockCountResult : 0;
133
+ if (existingBlockCount && existingBlockCount > 0) {
134
+ // Page already has content - this might be a duplicate call
135
+ // Return success without adding duplicate content
136
+ return { success: true, uid: pageUid };
137
+ }
138
+ // Separate text content from table content, maintaining order
139
+ const textItems = [];
140
+ const tableItems = [];
141
+ for (let i = 0; i < content.length; i++) {
142
+ const item = content[i];
143
+ if (item.type === 'table') {
144
+ tableItems.push({ index: i, item: item });
125
145
  }
126
146
  else {
127
- const parentLevel = node.level - 1;
128
- const parent = levelMap[parentLevel];
129
- if (!parent) {
130
- throw new Error(`Invalid block hierarchy: level ${node.level} block has no parent`);
147
+ // Default to text type
148
+ textItems.push(item);
149
+ }
150
+ }
151
+ // Process text blocks
152
+ const allActions = [];
153
+ if (textItems.length > 0) {
154
+ // Filter out empty blocks (empty or whitespace-only text) to prevent creating visual linebreaks
155
+ const nonEmptyContent = textItems.filter(block => block.text && block.text.trim().length > 0);
156
+ if (nonEmptyContent.length > 0) {
157
+ // Normalize levels to prevent gaps after filtering
158
+ const normalizedContent = [];
159
+ for (let i = 0; i < nonEmptyContent.length; i++) {
160
+ const block = nonEmptyContent[i];
161
+ if (i === 0) {
162
+ normalizedContent.push({ ...block, level: 1 });
163
+ }
164
+ else {
165
+ const prevLevel = normalizedContent[i - 1].level;
166
+ const maxAllowedLevel = prevLevel + 1;
167
+ normalizedContent.push({
168
+ ...block,
169
+ level: Math.min(block.level, maxAllowedLevel)
170
+ });
171
+ }
131
172
  }
132
- parent.children.push(node);
133
- levelMap[node.level] = node;
173
+ // Convert content array to MarkdownNode format expected by convertToRoamActions
174
+ const nodes = normalizedContent.map(block => ({
175
+ content: convertToRoamMarkdown(block.text.replace(/^#+\s*/, '')),
176
+ level: block.level,
177
+ ...(block.heading && { heading_level: block.heading }),
178
+ children: []
179
+ }));
180
+ // Create hierarchical structure based on levels
181
+ const rootNodes = [];
182
+ const levelMap = {};
183
+ for (const node of nodes) {
184
+ if (node.level === 1) {
185
+ rootNodes.push(node);
186
+ levelMap[1] = node;
187
+ }
188
+ else {
189
+ const parentLevel = node.level - 1;
190
+ const parent = levelMap[parentLevel];
191
+ if (!parent) {
192
+ throw new Error(`Invalid block hierarchy: level ${node.level} block has no parent`);
193
+ }
194
+ parent.children.push(node);
195
+ levelMap[node.level] = node;
196
+ }
197
+ }
198
+ // Generate batch actions for text blocks
199
+ const textActions = convertToRoamActions(rootNodes, pageUid, 'last');
200
+ allActions.push(...textActions);
134
201
  }
135
202
  }
136
- // Generate batch actions for all blocks
137
- const actions = convertToRoamActions(rootNodes, pageUid, 'last');
138
- // Execute batch operation
139
- if (actions.length > 0) {
203
+ // Execute text block actions first (no placeholders, use SDK directly)
204
+ if (allActions.length > 0) {
140
205
  const batchResult = await batchActions(this.graph, {
141
206
  action: 'batch-actions',
142
- actions
207
+ actions: allActions
143
208
  });
144
209
  if (!batchResult) {
145
- throw new Error('Failed to create blocks');
210
+ throw new Error('Failed to create text blocks');
211
+ }
212
+ }
213
+ // Process table items separately (use BatchOperations to handle UID placeholders)
214
+ for (const { item } of tableItems) {
215
+ const tableActions = buildTableActions({
216
+ parent_uid: pageUid,
217
+ headers: item.headers,
218
+ rows: item.rows,
219
+ order: 'last'
220
+ });
221
+ // Use BatchOperations.processBatch to handle {{uid:*}} placeholders
222
+ const tableResult = await this.batchOps.processBatch(tableActions);
223
+ if (!tableResult.success) {
224
+ throw new Error(`Failed to create table: ${typeof tableResult.error === 'string' ? tableResult.error : tableResult.error?.message}`);
146
225
  }
147
226
  }
148
227
  }
@@ -157,11 +236,18 @@ export class PageOperations {
157
236
  const month = today.toLocaleString('en-US', { month: 'long' });
158
237
  const year = today.getFullYear();
159
238
  const formattedTodayTitle = `${month} ${day}${getOrdinalSuffix(day)}, ${year}`;
160
- const dailyPageQuery = `[:find ?uid .
161
- :where [?e :node/title "${formattedTodayTitle}"]
162
- [?e :block/uid ?uid]]`;
163
- const dailyPageResult = await q(this.graph, dailyPageQuery, []);
164
- const dailyPageUid = dailyPageResult ? String(dailyPageResult) : null;
239
+ // Check cache for daily page
240
+ let dailyPageUid = pageUidCache.get(formattedTodayTitle);
241
+ if (!dailyPageUid) {
242
+ const dailyPageQuery = `[:find ?uid .
243
+ :where [?e :node/title "${formattedTodayTitle}"]
244
+ [?e :block/uid ?uid]]`;
245
+ const dailyPageResult = await q(this.graph, dailyPageQuery, []);
246
+ dailyPageUid = dailyPageResult ? String(dailyPageResult) : undefined;
247
+ if (dailyPageUid) {
248
+ pageUidCache.set(formattedTodayTitle, dailyPageUid);
249
+ }
250
+ }
165
251
  if (dailyPageUid) {
166
252
  await createBlock(this.graph, {
167
253
  action: 'create-block',
@@ -188,19 +274,33 @@ export class PageOperations {
188
274
  throw new McpError(ErrorCode.InvalidRequest, 'title is required');
189
275
  }
190
276
  // Try different case variations
191
- // Generate variations to check
192
277
  const variations = [
193
278
  title, // Original
194
279
  capitalizeWords(title), // Each word capitalized
195
280
  title.toLowerCase() // All lowercase
196
281
  ];
197
- // Create OR clause for query
198
- const orClause = variations.map(v => `[?e :node/title "${v}"]`).join(' ');
199
- const searchQuery = `[:find ?uid .
200
- :where [?e :block/uid ?uid]
201
- (or ${orClause})]`;
202
- const result = await q(this.graph, searchQuery, []);
203
- const uid = (result === null || result === undefined) ? null : String(result);
282
+ // Check cache first for any variation
283
+ let uid = null;
284
+ for (const variation of variations) {
285
+ const cachedUid = pageUidCache.get(variation);
286
+ if (cachedUid) {
287
+ uid = cachedUid;
288
+ break;
289
+ }
290
+ }
291
+ // If not cached, query the database
292
+ if (!uid) {
293
+ const orClause = variations.map(v => `[?e :node/title "${v}"]`).join(' ');
294
+ const searchQuery = `[:find ?uid .
295
+ :where [?e :block/uid ?uid]
296
+ (or ${orClause})]`;
297
+ const result = await q(this.graph, searchQuery, []);
298
+ uid = (result === null || result === undefined) ? null : String(result);
299
+ // Cache the result for the original title
300
+ if (uid) {
301
+ pageUidCache.set(title, uid);
302
+ }
303
+ }
204
304
  if (!uid) {
205
305
  throw new McpError(ErrorCode.InvalidRequest, `Page with title "${title}" not found (tried original, capitalized words, and lowercase)`);
206
306
  }
@@ -311,4 +411,98 @@ export class PageOperations {
311
411
  };
312
412
  return `# ${title}\n\n${toMarkdown(rootBlocks)}`;
313
413
  }
414
+ /**
415
+ * Update an existing page with new markdown content using smart diff.
416
+ * Preserves block UIDs where possible and generates minimal changes.
417
+ *
418
+ * @param title - Title of the page to update
419
+ * @param markdown - New GFM markdown content
420
+ * @param dryRun - If true, returns actions without executing them
421
+ * @returns Result with actions, stats, and preserved UIDs
422
+ */
423
+ async updatePageMarkdown(title, markdown, dryRun = false) {
424
+ if (!title) {
425
+ throw new McpError(ErrorCode.InvalidRequest, 'title is required');
426
+ }
427
+ if (!markdown) {
428
+ throw new McpError(ErrorCode.InvalidRequest, 'markdown is required');
429
+ }
430
+ // 1. Fetch existing page with raw block data
431
+ const pageTitle = String(title).trim();
432
+ // Try different case variations
433
+ const variations = [
434
+ pageTitle,
435
+ capitalizeWords(pageTitle),
436
+ pageTitle.toLowerCase()
437
+ ];
438
+ let pageUid = null;
439
+ // Check cache first
440
+ for (const variation of variations) {
441
+ const cachedUid = pageUidCache.get(variation);
442
+ if (cachedUid) {
443
+ pageUid = cachedUid;
444
+ break;
445
+ }
446
+ }
447
+ // If not cached, query the database
448
+ if (!pageUid) {
449
+ const orClause = variations.map(v => `[?e :node/title "${v}"]`).join(' ');
450
+ const searchQuery = `[:find ?uid .
451
+ :where [?e :block/uid ?uid]
452
+ (or ${orClause})]`;
453
+ const result = await q(this.graph, searchQuery, []);
454
+ pageUid = (result === null || result === undefined) ? null : String(result);
455
+ if (pageUid) {
456
+ pageUidCache.set(pageTitle, pageUid);
457
+ }
458
+ }
459
+ if (!pageUid) {
460
+ throw new McpError(ErrorCode.InvalidRequest, `Page with title "${title}" not found`);
461
+ }
462
+ // 2. Fetch existing blocks with full hierarchy
463
+ const blocksQuery = `[:find (pull ?page [
464
+ :block/uid
465
+ :block/string
466
+ :block/order
467
+ :block/heading
468
+ {:block/children ...}
469
+ ]) .
470
+ :where [?page :block/uid "${pageUid}"]]`;
471
+ const pageData = await q(this.graph, blocksQuery, []);
472
+ if (!pageData) {
473
+ throw new McpError(ErrorCode.InternalError, `Failed to fetch page data for "${title}"`);
474
+ }
475
+ // 3. Parse existing blocks into our format
476
+ const existingBlocks = parseExistingBlocks(pageData);
477
+ // 4. Convert new markdown to block structure
478
+ const newBlocks = markdownToBlocks(markdown, pageUid);
479
+ // 5. Compute diff
480
+ const diff = diffBlockTrees(existingBlocks, newBlocks, pageUid);
481
+ // 6. Generate ordered batch actions
482
+ const actions = generateBatchActions(diff);
483
+ const stats = getDiffStats(diff);
484
+ const summary = isDiffEmpty(diff) ? 'No changes needed' : summarizeActions(actions);
485
+ // 7. Execute if not dry run and there are actions
486
+ if (!dryRun && actions.length > 0) {
487
+ try {
488
+ const batchResult = await batchActions(this.graph, {
489
+ action: 'batch-actions',
490
+ actions: actions
491
+ });
492
+ if (!batchResult) {
493
+ throw new Error('Batch actions returned no result');
494
+ }
495
+ }
496
+ catch (error) {
497
+ throw new McpError(ErrorCode.InternalError, `Failed to apply changes: ${error instanceof Error ? error.message : String(error)}`);
498
+ }
499
+ }
500
+ return {
501
+ success: true,
502
+ actions,
503
+ stats,
504
+ preservedUids: [...diff.preservedUids],
505
+ summary: dryRun ? `[DRY RUN] ${summary}` : summary
506
+ };
507
+ }
314
508
  }