roam-research-mcp 1.6.0 → 2.4.3

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.
@@ -1,8 +1,8 @@
1
- import { q, createPage as createRoamPage, batchActions, createBlock } from '@roam-research/roam-api-sdk';
1
+ import { q, createPage as createRoamPage, batchActions, updatePage } from '@roam-research/roam-api-sdk';
2
2
  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
- import { convertToRoamActions, convertToRoamMarkdown } from '../../markdown-utils.js';
5
+ import { convertToRoamMarkdown, generateBlockUid } from '../../markdown-utils.js';
6
6
  import { pageUidCache } from '../../cache/page-uid-cache.js';
7
7
  import { buildTableActions } from './table.js';
8
8
  import { BatchOperations } from './batch.js';
@@ -135,137 +135,179 @@ export class PageOperations {
135
135
  // Return success without adding duplicate content
136
136
  return { success: true, uid: pageUid };
137
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 });
138
+ // Process content items in order, tracking position for correct placement
139
+ // Tables and text blocks are interleaved at their original positions
140
+ // Tables can be nested under text blocks based on their level
141
+ let currentOrder = 0;
142
+ let pendingTextItems = [];
143
+ // Track last block UID at each level for nesting tables
144
+ const levelToLastUid = {};
145
+ // Helper to assign UIDs to nodes and track level mapping
146
+ const assignUidsToNodes = (nodes) => {
147
+ return nodes.map(node => {
148
+ const uid = generateBlockUid();
149
+ levelToLastUid[node.level] = uid;
150
+ return {
151
+ ...node,
152
+ uid,
153
+ children: assignUidsToNodes(node.children)
154
+ };
155
+ });
156
+ };
157
+ // Helper to build batch actions from nodes with pre-assigned UIDs
158
+ const buildActionsFromNodes = (nodes, parentUid, startOrder) => {
159
+ const actions = [];
160
+ for (let i = 0; i < nodes.length; i++) {
161
+ const node = nodes[i];
162
+ actions.push({
163
+ action: 'create-block',
164
+ location: { 'parent-uid': parentUid, order: startOrder + i },
165
+ block: {
166
+ uid: node.uid,
167
+ string: node.content,
168
+ ...(node.heading_level && { heading: node.heading_level })
169
+ }
170
+ });
171
+ if (node.children.length > 0) {
172
+ actions.push(...buildActionsFromNodes(node.children, node.uid, 0));
173
+ }
145
174
  }
146
- else {
147
- // Default to text type
148
- textItems.push(item);
175
+ return actions;
176
+ };
177
+ // Helper to flush pending text items as a batch
178
+ const flushTextItems = async (startOrder) => {
179
+ if (pendingTextItems.length === 0)
180
+ return startOrder;
181
+ // Filter out empty blocks
182
+ const nonEmptyContent = pendingTextItems.filter(block => block.text && block.text.trim().length > 0);
183
+ if (nonEmptyContent.length === 0) {
184
+ pendingTextItems = [];
185
+ return startOrder;
149
186
  }
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
- }
187
+ // Normalize levels to prevent gaps after filtering
188
+ const normalizedContent = [];
189
+ for (let i = 0; i < nonEmptyContent.length; i++) {
190
+ const block = nonEmptyContent[i];
191
+ if (i === 0) {
192
+ normalizedContent.push({ ...block, level: 1 });
172
193
  }
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;
194
+ else {
195
+ const prevLevel = normalizedContent[i - 1].level;
196
+ const maxAllowedLevel = prevLevel + 1;
197
+ normalizedContent.push({
198
+ ...block,
199
+ level: Math.min(block.level, maxAllowedLevel)
200
+ });
201
+ }
202
+ }
203
+ // Convert to node format with level info
204
+ const nodes = normalizedContent.map(block => ({
205
+ content: convertToRoamMarkdown(block.text.replace(/^#+\s*/, '')),
206
+ level: block.level,
207
+ ...(block.heading && { heading_level: block.heading }),
208
+ children: []
209
+ }));
210
+ // Create hierarchical structure based on levels
211
+ const rootNodes = [];
212
+ const levelMap = {};
213
+ for (const node of nodes) {
214
+ if (node.level === 1) {
215
+ rootNodes.push(node);
216
+ levelMap[1] = node;
217
+ }
218
+ else {
219
+ const parentLevel = node.level - 1;
220
+ const parent = levelMap[parentLevel];
221
+ if (!parent) {
222
+ throw new Error(`Invalid block hierarchy: level ${node.level} block has no parent`);
196
223
  }
224
+ parent.children.push(node);
225
+ levelMap[node.level] = node;
197
226
  }
198
- // Generate batch actions for text blocks
199
- const textActions = convertToRoamActions(rootNodes, pageUid, 'last');
200
- allActions.push(...textActions);
201
227
  }
202
- }
203
- // Execute text block actions first (no placeholders, use SDK directly)
204
- if (allActions.length > 0) {
205
- const batchResult = await batchActions(this.graph, {
206
- action: 'batch-actions',
207
- actions: allActions
208
- });
209
- if (!batchResult) {
210
- throw new Error('Failed to create text blocks');
228
+ // Assign UIDs to all nodes and track level->UID mapping
229
+ const nodesWithUids = assignUidsToNodes(rootNodes);
230
+ // Build batch actions from nodes with UIDs
231
+ const textActions = buildActionsFromNodes(nodesWithUids, pageUid, startOrder);
232
+ if (textActions.length > 0) {
233
+ const batchResult = await batchActions(this.graph, {
234
+ action: 'batch-actions',
235
+ actions: textActions
236
+ });
237
+ if (!batchResult) {
238
+ throw new Error('Failed to create text blocks');
239
+ }
211
240
  }
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}`);
241
+ // Return the next order position (number of root-level blocks added)
242
+ const nextOrder = startOrder + rootNodes.length;
243
+ pendingTextItems = [];
244
+ return nextOrder;
245
+ };
246
+ // Process content items in order
247
+ for (let i = 0; i < content.length; i++) {
248
+ const item = content[i];
249
+ if (item.type === 'table') {
250
+ // Flush any pending text items first
251
+ currentOrder = await flushTextItems(currentOrder);
252
+ // Process table - determine parent based on level
253
+ const tableItem = item;
254
+ const tableLevel = tableItem.level || 1;
255
+ let tableParentUid = pageUid;
256
+ let tableOrder = currentOrder;
257
+ if (tableLevel > 1) {
258
+ // Nested table - find parent block at level-1
259
+ const parentLevel = tableLevel - 1;
260
+ if (levelToLastUid[parentLevel]) {
261
+ tableParentUid = levelToLastUid[parentLevel];
262
+ tableOrder = 'last'; // Append to parent's children
263
+ }
264
+ // If no parent found, fall back to page level
265
+ }
266
+ const tableActions = buildTableActions({
267
+ parent_uid: tableParentUid,
268
+ headers: tableItem.headers,
269
+ rows: tableItem.rows,
270
+ order: tableOrder
271
+ });
272
+ const tableResult = await this.batchOps.processBatch(tableActions);
273
+ if (!tableResult.success) {
274
+ throw new Error(`Failed to create table: ${typeof tableResult.error === 'string' ? tableResult.error : tableResult.error?.message}`);
275
+ }
276
+ // Only increment top-level order for level 1 tables
277
+ if (tableLevel === 1) {
278
+ currentOrder++;
279
+ }
280
+ }
281
+ else {
282
+ // Accumulate text items
283
+ pendingTextItems.push(item);
225
284
  }
226
285
  }
286
+ // Flush any remaining text items
287
+ await flushTextItems(currentOrder);
227
288
  }
228
289
  catch (error) {
229
290
  throw new McpError(ErrorCode.InternalError, `Failed to add content to page: ${error instanceof Error ? error.message : String(error)}`);
230
291
  }
231
292
  }
232
- // Add a link to the created page on today's daily page
293
+ // Add a "Processed: [[date]]" block as the last block of the newly created page
233
294
  try {
234
295
  const today = new Date();
235
296
  const day = today.getDate();
236
297
  const month = today.toLocaleString('en-US', { month: 'long' });
237
298
  const year = today.getFullYear();
238
299
  const formattedTodayTitle = `${month} ${day}${getOrdinalSuffix(day)}, ${year}`;
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
- }
251
- if (dailyPageUid) {
252
- await createBlock(this.graph, {
253
- action: 'create-block',
254
- block: {
255
- string: `Created page: [[${pageTitle}]]`
256
- },
257
- location: {
258
- 'parent-uid': dailyPageUid,
259
- order: 'last'
260
- }
261
- });
262
- }
263
- else {
264
- console.warn(`Could not find daily page with title: ${formattedTodayTitle}. Link to created page not added.`);
265
- }
300
+ await batchActions(this.graph, {
301
+ action: 'batch-actions',
302
+ actions: [{
303
+ action: 'create-block',
304
+ location: { 'parent-uid': pageUid, order: 'last' },
305
+ block: { string: `Processed: [[${formattedTodayTitle}]]` }
306
+ }]
307
+ });
266
308
  }
267
309
  catch (error) {
268
- console.error(`Failed to add link to daily page: ${error instanceof Error ? error.message : String(error)}`);
310
+ console.error(`Failed to add Processed block: ${error instanceof Error ? error.message : String(error)}`);
269
311
  }
270
312
  return { success: true, uid: pageUid };
271
313
  }
@@ -505,4 +547,37 @@ export class PageOperations {
505
547
  summary: dryRun ? `[DRY RUN] ${summary}` : summary
506
548
  };
507
549
  }
550
+ /**
551
+ * Rename a page by updating its title
552
+ */
553
+ async renamePage(params) {
554
+ const { old_title, uid, new_title } = params;
555
+ if (!old_title && !uid) {
556
+ throw new McpError(ErrorCode.InvalidParams, 'Either old_title or uid must be provided to identify the page');
557
+ }
558
+ // Build the page identifier
559
+ const pageIdentifier = uid ? { uid } : { title: old_title };
560
+ try {
561
+ const success = await updatePage(this.graph, {
562
+ page: pageIdentifier,
563
+ title: new_title
564
+ });
565
+ if (success) {
566
+ const identifier = uid ? `((${uid}))` : `"${old_title}"`;
567
+ return {
568
+ success: true,
569
+ message: `Renamed ${identifier} → "${new_title}"`
570
+ };
571
+ }
572
+ else {
573
+ return {
574
+ success: false,
575
+ message: 'Failed to rename page (API returned false)'
576
+ };
577
+ }
578
+ }
579
+ catch (error) {
580
+ throw new McpError(ErrorCode.InternalError, `Failed to rename page: ${error instanceof Error ? error.message : String(error)}`);
581
+ }
582
+ }
508
583
  }
@@ -1,4 +1,4 @@
1
- import { TagSearchHandlerImpl, BlockRefSearchHandlerImpl, HierarchySearchHandlerImpl, TextSearchHandlerImpl, StatusSearchHandlerImpl } from './handlers.js';
1
+ import { TagSearchHandlerImpl, BlockRefSearchHandlerImpl, HierarchySearchHandlerImpl, TextSearchHandlerImpl, StatusSearchHandlerImpl, DatomicSearchHandlerImpl } from './handlers.js';
2
2
  export class SearchOperations {
3
3
  constructor(graph) {
4
4
  this.graph = graph;
@@ -57,6 +57,10 @@ export class SearchOperations {
57
57
  const handler = new TextSearchHandlerImpl(this.graph, params);
58
58
  return handler.execute();
59
59
  }
60
+ async executeDatomicQuery(params) {
61
+ const handler = new DatomicSearchHandlerImpl(this.graph, params);
62
+ return handler.execute();
63
+ }
60
64
  async searchByDate(params) {
61
65
  // Convert dates to timestamps
62
66
  const startTimestamp = new Date(`${params.start_date}T00:00:00`).getTime();
@@ -37,7 +37,7 @@ export class TodoOperations {
37
37
  throw new Error('Failed to create today\'s page');
38
38
  }
39
39
  }
40
- const todo_tag = "{{TODO}}";
40
+ const todo_tag = "{{[[TODO]]}}";
41
41
  const actions = todos.map((todo, index) => ({
42
42
  action: 'create-block',
43
43
  location: {