roam-research-mcp 0.12.3 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -41,25 +41,134 @@ function convertAllTables(text) {
41
41
  return '\n' + convertTableToRoamFormat(match) + '\n';
42
42
  });
43
43
  }
44
+ /**
45
+ * Parse markdown heading syntax (e.g. "### Heading") and return the heading level (1-3) and content.
46
+ * Heading level is determined by the number of # characters (e.g. # = h1, ## = h2, ### = h3).
47
+ * Returns heading_level: 0 for non-heading content.
48
+ */
49
+ function parseMarkdownHeadingLevel(text) {
50
+ const match = text.match(/^(#{1,3})\s+(.+)$/);
51
+ if (match) {
52
+ return {
53
+ heading_level: match[1].length, // Number of # characters determines heading level
54
+ content: match[2].trim()
55
+ };
56
+ }
57
+ return {
58
+ heading_level: 0, // Not a heading
59
+ content: text.trim()
60
+ };
61
+ }
44
62
  function convertToRoamMarkdown(text) {
45
- // First handle double asterisks/underscores (bold)
63
+ // Handle double asterisks/underscores (bold)
46
64
  text = text.replace(/\*\*(.+?)\*\*/g, '**$1**'); // Preserve double asterisks
47
- // Then handle single asterisks/underscores (italic)
65
+ // Handle single asterisks/underscores (italic)
48
66
  text = text.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '__$1__'); // Single asterisk to double underscore
49
67
  text = text.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, '__$1__'); // Single underscore to double underscore
50
68
  // Handle highlights
51
69
  text = text.replace(/==(.+?)==/g, '^^$1^^');
70
+ // Convert tasks
71
+ text = text.replace(/- \[ \]/g, '- {{[[TODO]]}}');
72
+ text = text.replace(/- \[x\]/g, '- {{[[DONE]]}}');
52
73
  // Convert tables
53
74
  text = convertAllTables(text);
54
75
  return text;
55
76
  }
56
77
  function parseMarkdown(markdown) {
78
+ // Convert markdown syntax first
79
+ markdown = convertToRoamMarkdown(markdown);
57
80
  const lines = markdown.split('\n');
58
81
  const rootNodes = [];
59
82
  const stack = [];
83
+ let inCodeBlock = false;
84
+ let codeBlockContent = '';
85
+ let codeBlockIndentation = 0;
86
+ let codeBlockParentLevel = 0;
60
87
  for (let i = 0; i < lines.length; i++) {
61
88
  const line = lines[i];
62
89
  const trimmedLine = line.trimEnd();
90
+ // Handle code blocks
91
+ if (trimmedLine.match(/^(\s*)```/)) {
92
+ if (!inCodeBlock) {
93
+ // Start of code block
94
+ inCodeBlock = true;
95
+ // Store the opening backticks without indentation
96
+ codeBlockContent = trimmedLine.trimStart() + '\n';
97
+ codeBlockIndentation = line.match(/^\s*/)?.[0].length ?? 0;
98
+ // Save current parent level
99
+ codeBlockParentLevel = stack.length;
100
+ }
101
+ else {
102
+ // End of code block
103
+ inCodeBlock = false;
104
+ // Add closing backticks without indentation
105
+ codeBlockContent += trimmedLine.trimStart();
106
+ // Process the code block content to fix indentation
107
+ const lines = codeBlockContent.split('\n');
108
+ // Find the first non-empty code line to determine base indentation
109
+ let baseIndentation = '';
110
+ let codeStartIndex = -1;
111
+ for (let i = 1; i < lines.length - 1; i++) {
112
+ const line = lines[i];
113
+ if (line.trim().length > 0) {
114
+ const indentMatch = line.match(/^[\t ]*/);
115
+ if (indentMatch) {
116
+ baseIndentation = indentMatch[0];
117
+ codeStartIndex = i;
118
+ break;
119
+ }
120
+ }
121
+ }
122
+ // Process lines maintaining relative indentation from the first code line
123
+ const processedLines = lines.map((line, index) => {
124
+ // Keep backticks as is
125
+ if (index === 0 || index === lines.length - 1)
126
+ return line.trimStart();
127
+ // Empty lines should be completely trimmed
128
+ if (line.trim().length === 0)
129
+ return '';
130
+ // For code lines, remove only the base indentation
131
+ if (line.startsWith(baseIndentation)) {
132
+ return line.slice(baseIndentation.length);
133
+ }
134
+ // If line has less indentation than base, trim all leading whitespace
135
+ return line.trimStart();
136
+ });
137
+ // Create node for the entire code block
138
+ const level = Math.floor(codeBlockIndentation / 2);
139
+ const node = {
140
+ content: processedLines.join('\n'),
141
+ level,
142
+ children: []
143
+ };
144
+ // Restore to code block's parent level
145
+ while (stack.length > codeBlockParentLevel) {
146
+ stack.pop();
147
+ }
148
+ if (level === 0) {
149
+ rootNodes.push(node);
150
+ stack[0] = node;
151
+ }
152
+ else {
153
+ while (stack.length > level) {
154
+ stack.pop();
155
+ }
156
+ if (stack[level - 1]) {
157
+ stack[level - 1].children.push(node);
158
+ }
159
+ else {
160
+ rootNodes.push(node);
161
+ }
162
+ stack[level] = node;
163
+ }
164
+ codeBlockContent = '';
165
+ }
166
+ continue;
167
+ }
168
+ if (inCodeBlock) {
169
+ codeBlockContent += line + '\n';
170
+ continue;
171
+ }
63
172
  // Skip truly empty lines (no spaces)
64
173
  if (trimmedLine === '') {
65
174
  continue;
@@ -67,51 +176,54 @@ function parseMarkdown(markdown) {
67
176
  // Calculate indentation level (2 spaces = 1 level)
68
177
  const indentation = line.match(/^\s*/)?.[0].length ?? 0;
69
178
  let level = Math.floor(indentation / 2);
70
- // Extract content after bullet point or heading
71
- let content = trimmedLine;
72
- if (trimmedLine.startsWith('#') || trimmedLine.includes('{{table}}')) {
73
- // Remove bullet point if it precedes a table marker
74
- content = trimmedLine.replace(/^\s*[-*+]\s+/, '');
75
- level = 0;
76
- // Reset stack but keep heading/table as parent
77
- stack.length = 1; // Keep only the heading/table
179
+ // First check for headings
180
+ const { heading_level, content: headingContent } = parseMarkdownHeadingLevel(trimmedLine);
181
+ // Then handle bullet points if not a heading
182
+ let content;
183
+ if (heading_level > 0) {
184
+ content = headingContent; // Use clean heading content without # marks
185
+ level = 0; // Headings start at root level
186
+ stack.length = 1; // Reset stack but keep heading as parent
187
+ // Create heading node
188
+ const node = {
189
+ content,
190
+ level,
191
+ heading_level, // Store heading level in node
192
+ children: []
193
+ };
194
+ rootNodes.push(node);
195
+ stack[0] = node;
196
+ continue; // Skip to next line
78
197
  }
79
- else if (stack[0]?.content.startsWith('#') || stack[0]?.content.includes('{{table}}')) {
80
- // If previous node was a heading or table marker, increase level by 1
81
- level = Math.max(level, 1);
82
- // Remove bullet point
83
- content = trimmedLine.replace(/^\s*[-*+]\s+/, '');
198
+ // Handle non-heading content
199
+ const bulletMatch = trimmedLine.match(/^(\s*)[-*+]\s+/);
200
+ if (bulletMatch) {
201
+ // For bullet points, use the bullet's indentation for level
202
+ content = trimmedLine.substring(bulletMatch[0].length);
203
+ level = Math.floor(bulletMatch[1].length / 2);
84
204
  }
85
205
  else {
86
- // Remove bullet point
87
- content = trimmedLine.replace(/^\s*[-*+]\s+/, '');
206
+ content = trimmedLine;
88
207
  }
89
- // Create new node
208
+ // Create regular node
90
209
  const node = {
91
210
  content,
92
211
  level,
93
212
  children: []
94
213
  };
95
- // Find the appropriate parent for this node based on level
96
- if (level === 0) {
214
+ // Pop stack until we find the parent level
215
+ while (stack.length > level) {
216
+ stack.pop();
217
+ }
218
+ // Add to appropriate parent
219
+ if (level === 0 || !stack[level - 1]) {
97
220
  rootNodes.push(node);
98
221
  stack[0] = node;
99
222
  }
100
223
  else {
101
- // Pop stack until we find the parent level
102
- while (stack.length > level) {
103
- stack.pop();
104
- }
105
- // Add as child to parent
106
- if (stack[level - 1]) {
107
- stack[level - 1].children.push(node);
108
- }
109
- else {
110
- // If no parent found, treat as root node
111
- rootNodes.push(node);
112
- }
113
- stack[level] = node;
224
+ stack[level - 1].children.push(node);
114
225
  }
226
+ stack[level] = node;
115
227
  }
116
228
  return rootNodes;
117
229
  }
@@ -167,12 +279,13 @@ function convertNodesToBlocks(nodes) {
167
279
  return nodes.map(node => ({
168
280
  uid: generateBlockUid(),
169
281
  content: node.content,
282
+ ...(node.heading_level && { heading_level: node.heading_level }), // Preserve heading level if present
170
283
  children: convertNodesToBlocks(node.children)
171
284
  }));
172
285
  }
173
286
  function convertToRoamActions(nodes, parentUid, order = 'last') {
174
- // First convert nodes to blocks with UIDs
175
- const blocks = convertNodesToBlocks(nodes);
287
+ // First convert nodes to blocks with UIDs, reversing to maintain original order
288
+ const blocks = convertNodesToBlocks([...nodes].reverse());
176
289
  const actions = [];
177
290
  // Helper function to recursively create actions
178
291
  function createBlockActions(blocks, parentUid, order) {
@@ -186,7 +299,8 @@ function convertToRoamActions(nodes, parentUid, order = 'last') {
186
299
  },
187
300
  block: {
188
301
  uid: block.uid,
189
- string: block.content
302
+ string: block.content,
303
+ ...(block.heading_level && { heading: block.heading_level })
190
304
  }
191
305
  };
192
306
  actions.push(action);
@@ -201,4 +315,4 @@ function convertToRoamActions(nodes, parentUid, order = 'last') {
201
315
  return actions;
202
316
  }
203
317
  // Export public functions and types
204
- export { parseMarkdown, convertToRoamActions, hasMarkdownTable, convertAllTables, convertToRoamMarkdown };
318
+ export { parseMarkdown, convertToRoamActions, hasMarkdownTable, convertAllTables, convertToRoamMarkdown, parseMarkdownHeadingLevel };
@@ -0,0 +1,72 @@
1
+ import { q } from '@roam-research/roam-api-sdk';
2
+ import { BaseSearchHandler } from './types.js';
3
+ import { SearchUtils } from './utils.js';
4
+ export class BlockRefSearchHandler extends BaseSearchHandler {
5
+ params;
6
+ constructor(graph, params) {
7
+ super(graph);
8
+ this.params = params;
9
+ }
10
+ async execute() {
11
+ const { block_uid, page_title_uid } = this.params;
12
+ // Get target page UID if provided
13
+ let targetPageUid;
14
+ if (page_title_uid) {
15
+ targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid);
16
+ }
17
+ // Build query based on whether we're searching for references to a specific block
18
+ // or all block references within a page/graph
19
+ let queryStr;
20
+ let queryParams;
21
+ if (block_uid) {
22
+ // Search for references to a specific block
23
+ if (targetPageUid) {
24
+ queryStr = `[:find ?block-uid ?block-str
25
+ :in $ ?ref-uid ?page-uid
26
+ :where [?p :block/uid ?page-uid]
27
+ [?b :block/page ?p]
28
+ [?b :block/string ?block-str]
29
+ [?b :block/uid ?block-uid]
30
+ [(clojure.string/includes? ?block-str ?ref-uid)]]`;
31
+ queryParams = [`((${block_uid}))`, targetPageUid];
32
+ }
33
+ else {
34
+ queryStr = `[:find ?block-uid ?block-str ?page-title
35
+ :in $ ?ref-uid
36
+ :where [?b :block/string ?block-str]
37
+ [?b :block/uid ?block-uid]
38
+ [?b :block/page ?p]
39
+ [?p :node/title ?page-title]
40
+ [(clojure.string/includes? ?block-str ?ref-uid)]]`;
41
+ queryParams = [`((${block_uid}))`];
42
+ }
43
+ }
44
+ else {
45
+ // Search for any block references
46
+ if (targetPageUid) {
47
+ queryStr = `[:find ?block-uid ?block-str
48
+ :in $ ?page-uid
49
+ :where [?p :block/uid ?page-uid]
50
+ [?b :block/page ?p]
51
+ [?b :block/string ?block-str]
52
+ [?b :block/uid ?block-uid]
53
+ [(re-find #"\\(\\([^)]+\\)\\)" ?block-str)]]`;
54
+ queryParams = [targetPageUid];
55
+ }
56
+ else {
57
+ queryStr = `[:find ?block-uid ?block-str ?page-title
58
+ :where [?b :block/string ?block-str]
59
+ [?b :block/uid ?block-uid]
60
+ [?b :block/page ?p]
61
+ [?p :node/title ?page-title]
62
+ [(re-find #"\\(\\([^)]+\\)\\)" ?block-str)]]`;
63
+ queryParams = [];
64
+ }
65
+ }
66
+ const results = await q(this.graph, queryStr, queryParams);
67
+ const searchDescription = block_uid
68
+ ? `referencing block ((${block_uid}))`
69
+ : 'containing block references';
70
+ return SearchUtils.formatSearchResults(results, searchDescription, !targetPageUid);
71
+ }
72
+ }
@@ -0,0 +1,105 @@
1
+ import { q } from '@roam-research/roam-api-sdk';
2
+ import { BaseSearchHandler } from './types.js';
3
+ import { SearchUtils } from './utils.js';
4
+ export class HierarchySearchHandler extends BaseSearchHandler {
5
+ params;
6
+ constructor(graph, params) {
7
+ super(graph);
8
+ this.params = params;
9
+ }
10
+ async execute() {
11
+ const { parent_uid, child_uid, page_title_uid, max_depth = 1 } = this.params;
12
+ if (!parent_uid && !child_uid) {
13
+ return {
14
+ success: false,
15
+ matches: [],
16
+ message: 'Either parent_uid or child_uid must be provided'
17
+ };
18
+ }
19
+ // Get target page UID if provided
20
+ let targetPageUid;
21
+ if (page_title_uid) {
22
+ targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid);
23
+ }
24
+ // Define ancestor rule for recursive traversal
25
+ const ancestorRule = `[
26
+ [ (ancestor ?child ?parent)
27
+ [?parent :block/children ?child] ]
28
+ [ (ancestor ?child ?a)
29
+ [?parent :block/children ?child]
30
+ (ancestor ?parent ?a) ]
31
+ ]`;
32
+ let queryStr;
33
+ let queryParams;
34
+ if (parent_uid) {
35
+ // Search for all descendants using ancestor rule
36
+ if (targetPageUid) {
37
+ queryStr = `[:find ?block-uid ?block-str ?depth
38
+ :in $ % ?parent-uid ?page-uid
39
+ :where [?p :block/uid ?page-uid]
40
+ [?parent :block/uid ?parent-uid]
41
+ (ancestor ?b ?parent)
42
+ [?b :block/string ?block-str]
43
+ [?b :block/uid ?block-uid]
44
+ [?b :block/page ?p]
45
+ [(get-else $ ?b :block/path-length 1) ?depth]]`;
46
+ queryParams = [ancestorRule, parent_uid, targetPageUid];
47
+ }
48
+ else {
49
+ queryStr = `[:find ?block-uid ?block-str ?page-title ?depth
50
+ :in $ % ?parent-uid
51
+ :where [?parent :block/uid ?parent-uid]
52
+ (ancestor ?b ?parent)
53
+ [?b :block/string ?block-str]
54
+ [?b :block/uid ?block-uid]
55
+ [?b :block/page ?p]
56
+ [?p :node/title ?page-title]
57
+ [(get-else $ ?b :block/path-length 1) ?depth]]`;
58
+ queryParams = [ancestorRule, parent_uid];
59
+ }
60
+ }
61
+ else {
62
+ // Search for ancestors using the same rule
63
+ if (targetPageUid) {
64
+ queryStr = `[:find ?block-uid ?block-str ?depth
65
+ :in $ % ?child-uid ?page-uid
66
+ :where [?p :block/uid ?page-uid]
67
+ [?child :block/uid ?child-uid]
68
+ (ancestor ?child ?b)
69
+ [?b :block/string ?block-str]
70
+ [?b :block/uid ?block-uid]
71
+ [?b :block/page ?p]
72
+ [(get-else $ ?b :block/path-length 1) ?depth]]`;
73
+ queryParams = [ancestorRule, child_uid, targetPageUid];
74
+ }
75
+ else {
76
+ queryStr = `[:find ?block-uid ?block-str ?page-title ?depth
77
+ :in $ % ?child-uid
78
+ :where [?child :block/uid ?child-uid]
79
+ (ancestor ?child ?b)
80
+ [?b :block/string ?block-str]
81
+ [?b :block/uid ?block-uid]
82
+ [?b :block/page ?p]
83
+ [?p :node/title ?page-title]
84
+ [(get-else $ ?b :block/path-length 1) ?depth]]`;
85
+ queryParams = [ancestorRule, child_uid];
86
+ }
87
+ }
88
+ const results = await q(this.graph, queryStr, queryParams);
89
+ // Format results to include depth information
90
+ const matches = results.map(([uid, content, pageTitle, depth]) => ({
91
+ block_uid: uid,
92
+ content,
93
+ depth: depth || 1,
94
+ ...(pageTitle && { page_title: pageTitle })
95
+ }));
96
+ const searchDescription = parent_uid
97
+ ? `descendants of block ${parent_uid}`
98
+ : `ancestors of block ${child_uid}`;
99
+ return {
100
+ success: true,
101
+ matches,
102
+ message: `Found ${matches.length} block(s) as ${searchDescription}`
103
+ };
104
+ }
105
+ }
@@ -0,0 +1,7 @@
1
+ export * from './types.js';
2
+ export * from './utils.js';
3
+ export * from './tag-search.js';
4
+ export * from './status-search.js';
5
+ export * from './block-ref-search.js';
6
+ export * from './hierarchy-search.js';
7
+ export * from './text-search.js';
@@ -0,0 +1,43 @@
1
+ import { q } from '@roam-research/roam-api-sdk';
2
+ import { BaseSearchHandler } from './types.js';
3
+ import { SearchUtils } from './utils.js';
4
+ export class StatusSearchHandler extends BaseSearchHandler {
5
+ params;
6
+ constructor(graph, params) {
7
+ super(graph);
8
+ this.params = params;
9
+ }
10
+ async execute() {
11
+ const { status, page_title_uid } = this.params;
12
+ // Get target page UID if provided
13
+ let targetPageUid;
14
+ if (page_title_uid) {
15
+ targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid);
16
+ }
17
+ // Build query based on whether we're searching in a specific page
18
+ let queryStr;
19
+ let queryParams;
20
+ if (targetPageUid) {
21
+ queryStr = `[:find ?block-uid ?block-str
22
+ :in $ ?status ?page-uid
23
+ :where [?p :block/uid ?page-uid]
24
+ [?b :block/page ?p]
25
+ [?b :block/string ?block-str]
26
+ [?b :block/uid ?block-uid]
27
+ [(clojure.string/includes? ?block-str (str "{{[[" ?status "]]}}"))]]`;
28
+ queryParams = [status, targetPageUid];
29
+ }
30
+ else {
31
+ queryStr = `[:find ?block-uid ?block-str ?page-title
32
+ :in $ ?status
33
+ :where [?b :block/string ?block-str]
34
+ [?b :block/uid ?block-uid]
35
+ [?b :block/page ?p]
36
+ [?p :node/title ?page-title]
37
+ [(clojure.string/includes? ?block-str (str "{{[[" ?status "]]}}"))]]`;
38
+ queryParams = [status];
39
+ }
40
+ const results = await q(this.graph, queryStr, queryParams);
41
+ return SearchUtils.formatSearchResults(results, `with status ${status}`, !targetPageUid);
42
+ }
43
+ }
@@ -0,0 +1,35 @@
1
+ import { q } from '@roam-research/roam-api-sdk';
2
+ import { BaseSearchHandler } from './types.js';
3
+ import { SearchUtils } from './utils.js';
4
+ export class TagSearchHandler extends BaseSearchHandler {
5
+ params;
6
+ constructor(graph, params) {
7
+ super(graph);
8
+ this.params = params;
9
+ }
10
+ async execute() {
11
+ const { primary_tag, page_title_uid, near_tag, exclude_tag } = this.params;
12
+ // Get target page UID if provided for scoped search
13
+ let targetPageUid;
14
+ if (page_title_uid) {
15
+ targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid);
16
+ }
17
+ // Build query to find blocks referencing the page
18
+ const queryStr = `[:find ?block-uid ?block-str ?page-title
19
+ :in $ ?title
20
+ :where
21
+ [?ref-page :node/title ?title-match]
22
+ [(clojure.string/lower-case ?title-match) ?lower-title]
23
+ [(clojure.string/lower-case ?title) ?search-title]
24
+ [(= ?lower-title ?search-title)]
25
+ [?b :block/refs ?ref-page]
26
+ [?b :block/string ?block-str]
27
+ [?b :block/uid ?block-uid]
28
+ [?b :block/page ?p]
29
+ [?p :node/title ?page-title]]`;
30
+ const queryParams = [primary_tag];
31
+ const results = await q(this.graph, queryStr, queryParams);
32
+ const searchDescription = `referencing "${primary_tag}"`;
33
+ return SearchUtils.formatSearchResults(results, searchDescription, !targetPageUid);
34
+ }
35
+ }
@@ -0,0 +1,32 @@
1
+ import { q } from '@roam-research/roam-api-sdk';
2
+ import { BaseSearchHandler } from './types.js';
3
+ import { SearchUtils } from './utils.js';
4
+ export class TextSearchHandler extends BaseSearchHandler {
5
+ params;
6
+ constructor(graph, params) {
7
+ super(graph);
8
+ this.params = params;
9
+ }
10
+ async execute() {
11
+ const { text, page_title_uid, case_sensitive = false } = this.params;
12
+ // Get target page UID if provided for scoped search
13
+ let targetPageUid;
14
+ if (page_title_uid) {
15
+ targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid);
16
+ }
17
+ // Build query to find blocks containing the text
18
+ const queryStr = `[:find ?block-uid ?block-str ?page-title
19
+ :in $ ?search-text
20
+ :where
21
+ [?b :block/string ?block-str]
22
+ [(clojure.string/includes? ${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
23
+ ${case_sensitive ? '?search-text' : '(clojure.string/lower-case ?search-text)'})]
24
+ [?b :block/uid ?block-uid]
25
+ [?b :block/page ?p]
26
+ [?p :node/title ?page-title]]`;
27
+ const queryParams = [text];
28
+ const results = await q(this.graph, queryStr, queryParams);
29
+ const searchDescription = `containing "${text}"${case_sensitive ? ' (case sensitive)' : ''}`;
30
+ return SearchUtils.formatSearchResults(results, searchDescription, !targetPageUid);
31
+ }
32
+ }
@@ -0,0 +1,7 @@
1
+ // Base class for all search handlers
2
+ export class BaseSearchHandler {
3
+ graph;
4
+ constructor(graph) {
5
+ this.graph = graph;
6
+ }
7
+ }
@@ -0,0 +1,98 @@
1
+ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
2
+ import { q } from '@roam-research/roam-api-sdk';
3
+ export class SearchUtils {
4
+ /**
5
+ * Find a page by title or UID
6
+ */
7
+ static async findPageByTitleOrUid(graph, titleOrUid) {
8
+ // Try to find page by title
9
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
10
+ const findResults = await q(graph, findQuery, [titleOrUid]);
11
+ if (findResults && findResults.length > 0) {
12
+ return findResults[0][0];
13
+ }
14
+ // Try as UID
15
+ const uidQuery = `[:find ?uid :where [?e :block/uid "${titleOrUid}"] [?e :block/uid ?uid]]`;
16
+ const uidResults = await q(graph, uidQuery, []);
17
+ if (!uidResults || uidResults.length === 0) {
18
+ throw new McpError(ErrorCode.InvalidRequest, `Page with title/UID "${titleOrUid}" not found`);
19
+ }
20
+ return uidResults[0][0];
21
+ }
22
+ /**
23
+ * Format search results into a standard structure
24
+ */
25
+ static formatSearchResults(results, searchDescription, includePageTitle = true) {
26
+ if (!results || results.length === 0) {
27
+ return {
28
+ success: true,
29
+ matches: [],
30
+ message: `No blocks found ${searchDescription}`
31
+ };
32
+ }
33
+ const matches = results.map(([uid, content, pageTitle]) => ({
34
+ block_uid: uid,
35
+ content,
36
+ ...(includePageTitle && pageTitle && { page_title: pageTitle })
37
+ }));
38
+ return {
39
+ success: true,
40
+ matches,
41
+ message: `Found ${matches.length} block(s) ${searchDescription}`
42
+ };
43
+ }
44
+ /**
45
+ * Format a tag for searching, handling both # and [[]] formats
46
+ * @param tag Tag without prefix
47
+ * @returns Array of possible formats to search for
48
+ */
49
+ static formatTag(tag) {
50
+ // Remove any existing prefixes
51
+ const cleanTag = tag.replace(/^#|\[\[|\]\]$/g, '');
52
+ // Return both formats for comprehensive search
53
+ return [`#${cleanTag}`, `[[${cleanTag}]]`];
54
+ }
55
+ /**
56
+ * Parse a date string into a Roam-formatted date
57
+ */
58
+ static parseDate(dateStr) {
59
+ const date = new Date(dateStr);
60
+ const months = [
61
+ 'January', 'February', 'March', 'April', 'May', 'June',
62
+ 'July', 'August', 'September', 'October', 'November', 'December'
63
+ ];
64
+ // Adjust for timezone to ensure consistent date comparison
65
+ const utcDate = new Date(date.getTime() + date.getTimezoneOffset() * 60000);
66
+ return `${months[utcDate.getMonth()]} ${utcDate.getDate()}${this.getOrdinalSuffix(utcDate.getDate())}, ${utcDate.getFullYear()}`;
67
+ }
68
+ /**
69
+ * Parse a date string into a Roam-formatted date range
70
+ * Returns [startDate, endDate] with endDate being inclusive (end of day)
71
+ */
72
+ static parseDateRange(startStr, endStr) {
73
+ const startDate = new Date(startStr);
74
+ const endDate = new Date(endStr);
75
+ endDate.setHours(23, 59, 59, 999); // Make end date inclusive
76
+ const months = [
77
+ 'January', 'February', 'March', 'April', 'May', 'June',
78
+ 'July', 'August', 'September', 'October', 'November', 'December'
79
+ ];
80
+ // Adjust for timezone
81
+ const utcStart = new Date(startDate.getTime() + startDate.getTimezoneOffset() * 60000);
82
+ const utcEnd = new Date(endDate.getTime() + endDate.getTimezoneOffset() * 60000);
83
+ return [
84
+ `${months[utcStart.getMonth()]} ${utcStart.getDate()}${this.getOrdinalSuffix(utcStart.getDate())}, ${utcStart.getFullYear()}`,
85
+ `${months[utcEnd.getMonth()]} ${utcEnd.getDate()}${this.getOrdinalSuffix(utcEnd.getDate())}, ${utcEnd.getFullYear()}`
86
+ ];
87
+ }
88
+ static getOrdinalSuffix(day) {
89
+ if (day > 3 && day < 21)
90
+ return 'th';
91
+ switch (day % 10) {
92
+ case 1: return 'st';
93
+ case 2: return 'nd';
94
+ case 3: return 'rd';
95
+ default: return 'th';
96
+ }
97
+ }
98
+ }