roam-research-mcp 0.24.5 → 0.25.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.
- package/README.md +11 -3
- package/build/server/roam-server.js +19 -4
- package/build/tools/operations/blocks.js +58 -24
- package/build/tools/operations/pages.js +63 -25
- package/build/tools/schemas.js +38 -19
- package/build/tools/tool-handlers.js +2 -2
- package/package.json +1 -1
- package/build/test-addMarkdownText.js +0 -87
- package/build/test-queries.js +0 -116
- package/build/tools/operations/search.js +0 -285
package/README.md
CHANGED
|
@@ -26,6 +26,14 @@ npm install
|
|
|
26
26
|
npm run build
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
+
## To Test
|
|
30
|
+
|
|
31
|
+
Run [MCP Inspector](https://github.com/modelcontextprotocol/inspector) after build…
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
npx @modelcontextprotocol/inspector node build/index.js
|
|
35
|
+
```
|
|
36
|
+
|
|
29
37
|
## Features
|
|
30
38
|
|
|
31
39
|
The server provides powerful tools for interacting with Roam Research:
|
|
@@ -88,7 +96,7 @@ The server provides powerful tools for interacting with Roam Research:
|
|
|
88
96
|
"mcpServers": {
|
|
89
97
|
"roam-research": {
|
|
90
98
|
"command": "node",
|
|
91
|
-
"args": ["/path/to/roam-research/build/index.js"],
|
|
99
|
+
"args": ["/path/to/roam-research-mcp/build/index.js"],
|
|
92
100
|
"env": {
|
|
93
101
|
"ROAM_API_TOKEN": "your-api-token",
|
|
94
102
|
"ROAM_GRAPH_NAME": "your-graph-name",
|
|
@@ -101,9 +109,9 @@ The server provides powerful tools for interacting with Roam Research:
|
|
|
101
109
|
|
|
102
110
|
Note: The server will first try to load from .env file, then fall back to environment variables from MCP settings.
|
|
103
111
|
|
|
104
|
-
3. Build the server:
|
|
112
|
+
3. Build the server (make sure you're in the root directory of the MCP):
|
|
105
113
|
```bash
|
|
106
|
-
cd roam-research
|
|
114
|
+
cd roam-research-mcp
|
|
107
115
|
npm install
|
|
108
116
|
npm run build
|
|
109
117
|
```
|
|
@@ -17,7 +17,7 @@ export class RoamServer {
|
|
|
17
17
|
this.toolHandlers = new ToolHandlers(this.graph);
|
|
18
18
|
this.server = new Server({
|
|
19
19
|
name: 'roam-research',
|
|
20
|
-
version: '0.
|
|
20
|
+
version: '0.25.3',
|
|
21
21
|
}, {
|
|
22
22
|
capabilities: {
|
|
23
23
|
tools: {
|
|
@@ -81,8 +81,8 @@ export class RoamServer {
|
|
|
81
81
|
};
|
|
82
82
|
}
|
|
83
83
|
case 'roam_create_block': {
|
|
84
|
-
const { content, page_uid, title } = request.params.arguments;
|
|
85
|
-
const result = await this.toolHandlers.createBlock(content, page_uid, title);
|
|
84
|
+
const { content, page_uid, title, heading } = request.params.arguments;
|
|
85
|
+
const result = await this.toolHandlers.createBlock(content, page_uid, title, heading);
|
|
86
86
|
return {
|
|
87
87
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
88
88
|
};
|
|
@@ -131,6 +131,10 @@ export class RoamServer {
|
|
|
131
131
|
}
|
|
132
132
|
case 'roam_search_hierarchy': {
|
|
133
133
|
const params = request.params.arguments;
|
|
134
|
+
// Validate that either parent_uid or child_uid is provided, but not both
|
|
135
|
+
if ((!params.parent_uid && !params.child_uid) || (params.parent_uid && params.child_uid)) {
|
|
136
|
+
throw new McpError(ErrorCode.InvalidRequest, 'Either parent_uid or child_uid must be provided, but not both');
|
|
137
|
+
}
|
|
134
138
|
const result = await this.toolHandlers.searchHierarchy(params);
|
|
135
139
|
return {
|
|
136
140
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
@@ -159,11 +163,16 @@ export class RoamServer {
|
|
|
159
163
|
}
|
|
160
164
|
case 'roam_update_block': {
|
|
161
165
|
const { block_uid, content, transform_pattern } = request.params.arguments;
|
|
166
|
+
// Validate that either content or transform_pattern is provided, but not both or neither
|
|
167
|
+
if ((!content && !transform_pattern) || (content && transform_pattern)) {
|
|
168
|
+
throw new McpError(ErrorCode.InvalidRequest, 'Either content or transform_pattern must be provided, but not both or neither');
|
|
169
|
+
}
|
|
162
170
|
let result;
|
|
163
171
|
if (content) {
|
|
164
172
|
result = await this.toolHandlers.updateBlock(block_uid, content);
|
|
165
173
|
}
|
|
166
|
-
else
|
|
174
|
+
else {
|
|
175
|
+
// We know transform_pattern exists due to validation above
|
|
167
176
|
result = await this.toolHandlers.updateBlock(block_uid, undefined, (currentContent) => {
|
|
168
177
|
const regex = new RegExp(transform_pattern.find, transform_pattern.global !== false ? 'g' : '');
|
|
169
178
|
return currentContent.replace(regex, transform_pattern.replace);
|
|
@@ -182,6 +191,12 @@ export class RoamServer {
|
|
|
182
191
|
}
|
|
183
192
|
case 'roam_update_multiple_blocks': {
|
|
184
193
|
const { updates } = request.params.arguments;
|
|
194
|
+
// Validate that for each update, either content or transform is provided, but not both or neither
|
|
195
|
+
for (const update of updates) {
|
|
196
|
+
if ((!update.content && !update.transform) || (update.content && update.transform)) {
|
|
197
|
+
throw new McpError(ErrorCode.InvalidRequest, 'For each update, either content or transform must be provided, but not both or neither');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
185
200
|
const result = await this.toolHandlers.updateBlocks(updates);
|
|
186
201
|
return {
|
|
187
202
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { q,
|
|
1
|
+
import { q, updateBlock as updateRoamBlock, batchActions, createPage } from '@roam-research/roam-api-sdk';
|
|
2
2
|
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
3
3
|
import { formatRoamDate } from '../../utils/helpers.js';
|
|
4
4
|
import { parseMarkdown, convertToRoamActions, convertToRoamMarkdown } from '../../markdown-utils.js';
|
|
@@ -7,7 +7,7 @@ export class BlockOperations {
|
|
|
7
7
|
constructor(graph) {
|
|
8
8
|
this.graph = graph;
|
|
9
9
|
}
|
|
10
|
-
async createBlock(content, page_uid, title) {
|
|
10
|
+
async createBlock(content, page_uid, title, heading) {
|
|
11
11
|
// If page_uid provided, use it directly
|
|
12
12
|
let targetPageUid = page_uid;
|
|
13
13
|
// If no page_uid but title provided, search for page by title
|
|
@@ -68,9 +68,44 @@ export class BlockOperations {
|
|
|
68
68
|
try {
|
|
69
69
|
// If the content has multiple lines or is a table, use nested import
|
|
70
70
|
if (content.includes('\n')) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
let nodes;
|
|
72
|
+
// If heading parameter is provided, manually construct nodes to preserve heading
|
|
73
|
+
if (heading) {
|
|
74
|
+
const lines = content.split('\n');
|
|
75
|
+
const firstLine = lines[0].trim();
|
|
76
|
+
const remainingLines = lines.slice(1);
|
|
77
|
+
// Create the first node with heading formatting
|
|
78
|
+
const firstNode = {
|
|
79
|
+
content: firstLine,
|
|
80
|
+
level: 0,
|
|
81
|
+
heading_level: heading,
|
|
82
|
+
children: []
|
|
83
|
+
};
|
|
84
|
+
// If there are remaining lines, parse them as children or siblings
|
|
85
|
+
if (remainingLines.length > 0 && remainingLines.some(line => line.trim())) {
|
|
86
|
+
const remainingContent = remainingLines.join('\n');
|
|
87
|
+
const convertedRemainingContent = convertToRoamMarkdown(remainingContent);
|
|
88
|
+
const remainingNodes = parseMarkdown(convertedRemainingContent);
|
|
89
|
+
// Add remaining nodes as siblings to the first node
|
|
90
|
+
nodes = [firstNode, ...remainingNodes];
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
nodes = [firstNode];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
// No heading parameter, use original parsing logic
|
|
98
|
+
const convertedContent = convertToRoamMarkdown(content);
|
|
99
|
+
nodes = parseMarkdown(convertedContent);
|
|
100
|
+
// If we have simple newline-separated content (no markdown formatting),
|
|
101
|
+
// and parseMarkdown created nodes at level 0, reverse them to maintain original order
|
|
102
|
+
if (nodes.every(node => node.level === 0 && !node.heading_level)) {
|
|
103
|
+
const lines = content.split('\n').filter(line => line.trim());
|
|
104
|
+
if (lines.length === nodes.length) {
|
|
105
|
+
nodes.reverse();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
74
109
|
const actions = convertToRoamActions(nodes, targetPageUid, 'last');
|
|
75
110
|
// Execute batch actions to create the nested structure
|
|
76
111
|
const result = await batchActions(this.graph, {
|
|
@@ -88,27 +123,26 @@ export class BlockOperations {
|
|
|
88
123
|
};
|
|
89
124
|
}
|
|
90
125
|
else {
|
|
91
|
-
// For
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
|
|
126
|
+
// For single block content, use the same convertToRoamActions approach that works in roam_create_page
|
|
127
|
+
const nodes = [{
|
|
128
|
+
content: content,
|
|
129
|
+
level: 0,
|
|
130
|
+
...(heading && typeof heading === 'number' && heading > 0 && { heading_level: heading }),
|
|
131
|
+
children: []
|
|
132
|
+
}];
|
|
133
|
+
if (!targetPageUid) {
|
|
134
|
+
throw new McpError(ErrorCode.InternalError, 'targetPageUid is undefined');
|
|
135
|
+
}
|
|
136
|
+
const actions = convertToRoamActions(nodes, targetPageUid, 'last');
|
|
137
|
+
// Execute batch actions to create the block
|
|
138
|
+
const result = await batchActions(this.graph, {
|
|
139
|
+
action: 'batch-actions',
|
|
140
|
+
actions
|
|
99
141
|
});
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
:in $ ?parent ?string
|
|
103
|
-
:where [?b :block/uid ?uid]
|
|
104
|
-
[?b :block/string ?string]
|
|
105
|
-
[?b :block/parents ?p]
|
|
106
|
-
[?p :block/uid ?parent]]`;
|
|
107
|
-
const blockResults = await q(this.graph, findBlockQuery, [targetPageUid, content]);
|
|
108
|
-
if (!blockResults || blockResults.length === 0) {
|
|
109
|
-
throw new Error('Could not find created block');
|
|
142
|
+
if (!result) {
|
|
143
|
+
throw new Error('Failed to create block');
|
|
110
144
|
}
|
|
111
|
-
const blockUid =
|
|
145
|
+
const blockUid = result.created_uids?.[0];
|
|
112
146
|
return {
|
|
113
147
|
success: true,
|
|
114
148
|
block_uid: blockUid,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { q, createPage as createRoamPage, batchActions
|
|
1
|
+
import { q, createPage as createRoamPage, batchActions } 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 {
|
|
5
|
+
import { convertToRoamActions } from '../../markdown-utils.js';
|
|
6
6
|
export class PageOperations {
|
|
7
7
|
graph;
|
|
8
8
|
constructor(graph) {
|
|
@@ -80,34 +80,46 @@ export class PageOperations {
|
|
|
80
80
|
throw new McpError(ErrorCode.InternalError, `Failed to create page: ${error instanceof Error ? error.message : String(error)}`);
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
|
-
// If content is provided,
|
|
84
|
-
if (content) {
|
|
83
|
+
// If content is provided, create blocks using batch operations
|
|
84
|
+
if (content && content.length > 0) {
|
|
85
85
|
try {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
86
|
+
// Convert content array to MarkdownNode format expected by convertToRoamActions
|
|
87
|
+
const nodes = content.map(block => ({
|
|
88
|
+
content: block.text,
|
|
89
|
+
level: block.level,
|
|
90
|
+
...(block.heading && { heading_level: block.heading }),
|
|
91
|
+
children: []
|
|
92
|
+
}));
|
|
93
|
+
// Create hierarchical structure based on levels
|
|
94
|
+
const rootNodes = [];
|
|
95
|
+
const levelMap = {};
|
|
96
|
+
for (const node of nodes) {
|
|
97
|
+
if (node.level === 1) {
|
|
98
|
+
rootNodes.push(node);
|
|
99
|
+
levelMap[1] = node;
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
const parentLevel = node.level - 1;
|
|
103
|
+
const parent = levelMap[parentLevel];
|
|
104
|
+
if (!parent) {
|
|
105
|
+
throw new Error(`Invalid block hierarchy: level ${node.level} block has no parent`);
|
|
106
|
+
}
|
|
107
|
+
parent.children.push(node);
|
|
108
|
+
levelMap[node.level] = node;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Generate batch actions for all blocks
|
|
112
|
+
const actions = convertToRoamActions(rootNodes, pageUid, 'last');
|
|
113
|
+
// Execute batch operation
|
|
114
|
+
if (actions.length > 0) {
|
|
115
|
+
const batchResult = await batchActions(this.graph, {
|
|
93
116
|
action: 'batch-actions',
|
|
94
117
|
actions
|
|
95
118
|
});
|
|
96
|
-
if (!
|
|
97
|
-
throw new Error('Failed to
|
|
119
|
+
if (!batchResult) {
|
|
120
|
+
throw new Error('Failed to create blocks');
|
|
98
121
|
}
|
|
99
122
|
}
|
|
100
|
-
else {
|
|
101
|
-
// Create a simple block for non-nested content
|
|
102
|
-
await createBlock(this.graph, {
|
|
103
|
-
action: 'create-block',
|
|
104
|
-
location: {
|
|
105
|
-
"parent-uid": pageUid,
|
|
106
|
-
"order": "last"
|
|
107
|
-
},
|
|
108
|
-
block: { string: content }
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
123
|
}
|
|
112
124
|
catch (error) {
|
|
113
125
|
throw new McpError(ErrorCode.InternalError, `Failed to add content to page: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -160,6 +172,21 @@ export class PageOperations {
|
|
|
160
172
|
if (!blocks || blocks.length === 0) {
|
|
161
173
|
return `${title} (no content found)`;
|
|
162
174
|
}
|
|
175
|
+
// Get heading information for blocks that have it
|
|
176
|
+
const headingsQuery = `[:find ?block-uid ?heading
|
|
177
|
+
:in $ % ?page-title
|
|
178
|
+
:where [?page :node/title ?page-title]
|
|
179
|
+
[?block :block/uid ?block-uid]
|
|
180
|
+
[?block :block/heading ?heading]
|
|
181
|
+
(ancestor ?block ?page)]`;
|
|
182
|
+
const headings = await q(this.graph, headingsQuery, [ancestorRule, title]);
|
|
183
|
+
// Create a map of block UIDs to heading levels
|
|
184
|
+
const headingMap = new Map();
|
|
185
|
+
if (headings) {
|
|
186
|
+
for (const [blockUid, heading] of headings) {
|
|
187
|
+
headingMap.set(blockUid, heading);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
163
190
|
// Create a map of all blocks
|
|
164
191
|
const blockMap = new Map();
|
|
165
192
|
const rootBlocks = [];
|
|
@@ -170,6 +197,7 @@ export class PageOperations {
|
|
|
170
197
|
uid: blockUid,
|
|
171
198
|
string: resolvedString,
|
|
172
199
|
order: order,
|
|
200
|
+
heading: headingMap.get(blockUid) || null,
|
|
173
201
|
children: []
|
|
174
202
|
};
|
|
175
203
|
blockMap.set(blockUid, block);
|
|
@@ -202,7 +230,17 @@ export class PageOperations {
|
|
|
202
230
|
const toMarkdown = (blocks, level = 0) => {
|
|
203
231
|
return blocks.map(block => {
|
|
204
232
|
const indent = ' '.repeat(level);
|
|
205
|
-
let md
|
|
233
|
+
let md;
|
|
234
|
+
// Check block heading level and format accordingly
|
|
235
|
+
if (block.heading && block.heading > 0) {
|
|
236
|
+
// Format as heading with appropriate number of hashtags
|
|
237
|
+
const hashtags = '#'.repeat(block.heading);
|
|
238
|
+
md = `${indent}${hashtags} ${block.string}`;
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
// No heading, use bullet point (current behavior)
|
|
242
|
+
md = `${indent}- ${block.string}`;
|
|
243
|
+
}
|
|
206
244
|
if (block.children.length > 0) {
|
|
207
245
|
md += '\n' + toMarkdown(block.children, level + 1);
|
|
208
246
|
}
|
package/build/tools/schemas.js
CHANGED
|
@@ -34,7 +34,7 @@ export const toolSchemas = {
|
|
|
34
34
|
},
|
|
35
35
|
roam_create_page: {
|
|
36
36
|
name: 'roam_create_page',
|
|
37
|
-
description: 'Create
|
|
37
|
+
description: 'Create new standalone page in Roam with optional content using explicit nesting levels and headings (H1-H3). Best for:\n- Creating foundational concept pages that other pages will link to/from\n- Establishing new topic areas that need their own namespace\n- Setting up reference materials or documentation\n- Making permanent collections of information.',
|
|
38
38
|
inputSchema: {
|
|
39
39
|
type: 'object',
|
|
40
40
|
properties: {
|
|
@@ -43,8 +43,30 @@ export const toolSchemas = {
|
|
|
43
43
|
description: 'Title of the new page',
|
|
44
44
|
},
|
|
45
45
|
content: {
|
|
46
|
-
type: '
|
|
47
|
-
description: 'Initial content for the page
|
|
46
|
+
type: 'array',
|
|
47
|
+
description: 'Initial content for the page as an array of blocks with explicit nesting levels',
|
|
48
|
+
items: {
|
|
49
|
+
type: 'object',
|
|
50
|
+
properties: {
|
|
51
|
+
text: {
|
|
52
|
+
type: 'string',
|
|
53
|
+
description: 'Content of the block'
|
|
54
|
+
},
|
|
55
|
+
level: {
|
|
56
|
+
type: 'integer',
|
|
57
|
+
description: 'Indentation level (1-10, where 1 is top level)',
|
|
58
|
+
minimum: 1,
|
|
59
|
+
maximum: 10
|
|
60
|
+
},
|
|
61
|
+
heading: {
|
|
62
|
+
type: 'integer',
|
|
63
|
+
description: 'Optional: Heading formatting for this block (1-3)',
|
|
64
|
+
minimum: 1,
|
|
65
|
+
maximum: 3
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
required: ['text', 'level']
|
|
69
|
+
}
|
|
48
70
|
},
|
|
49
71
|
},
|
|
50
72
|
required: ['title'],
|
|
@@ -52,7 +74,7 @@ export const toolSchemas = {
|
|
|
52
74
|
},
|
|
53
75
|
roam_create_block: {
|
|
54
76
|
name: 'roam_create_block',
|
|
55
|
-
description: 'Add
|
|
77
|
+
description: 'Add new block to an existing Roam page. If no page specified, adds to today\'s daily note. Best for capturing immediate thoughts, additions to discussions, or content that doesn\'t warrant its own page. Can specify page by title or UID.\nNOTE on Roam-flavored markdown: For direct linking: use [[link]] syntax. For aliased linking, use [alias]([[link]]) syntax. Do not concatenate words in links/hashtags - correct: #[[multiple words]] #self-esteem (for typically hyphenated words).',
|
|
56
78
|
inputSchema: {
|
|
57
79
|
type: 'object',
|
|
58
80
|
properties: {
|
|
@@ -68,6 +90,12 @@ export const toolSchemas = {
|
|
|
68
90
|
type: 'string',
|
|
69
91
|
description: 'Optional: Title of the page to add block to (defaults to today\'s date if neither page_uid nor title provided)',
|
|
70
92
|
},
|
|
93
|
+
heading: {
|
|
94
|
+
type: 'integer',
|
|
95
|
+
description: 'Optional: Heading formatting for this block (1-3)',
|
|
96
|
+
minimum: 1,
|
|
97
|
+
maximum: 3
|
|
98
|
+
}
|
|
71
99
|
},
|
|
72
100
|
required: ['content'],
|
|
73
101
|
},
|
|
@@ -236,11 +264,8 @@ export const toolSchemas = {
|
|
|
236
264
|
minimum: 1,
|
|
237
265
|
maximum: 10
|
|
238
266
|
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
{ required: ['parent_uid'] },
|
|
242
|
-
{ required: ['child_uid'] }
|
|
243
|
-
]
|
|
267
|
+
}
|
|
268
|
+
// Note: Validation for either parent_uid or child_uid is handled in the server code
|
|
244
269
|
}
|
|
245
270
|
},
|
|
246
271
|
roam_find_pages_modified_today: {
|
|
@@ -310,11 +335,8 @@ export const toolSchemas = {
|
|
|
310
335
|
required: ['find', 'replace']
|
|
311
336
|
}
|
|
312
337
|
},
|
|
313
|
-
required: ['block_uid']
|
|
314
|
-
|
|
315
|
-
{ required: ['content'] },
|
|
316
|
-
{ required: ['transform_pattern'] }
|
|
317
|
-
]
|
|
338
|
+
required: ['block_uid']
|
|
339
|
+
// Note: Validation for either content or transform_pattern is handled in the server code
|
|
318
340
|
}
|
|
319
341
|
},
|
|
320
342
|
roam_update_multiple_blocks: {
|
|
@@ -358,11 +380,8 @@ export const toolSchemas = {
|
|
|
358
380
|
required: ['find', 'replace']
|
|
359
381
|
}
|
|
360
382
|
},
|
|
361
|
-
required: ['block_uid']
|
|
362
|
-
|
|
363
|
-
{ required: ['content'] },
|
|
364
|
-
{ required: ['transform'] }
|
|
365
|
-
]
|
|
383
|
+
required: ['block_uid']
|
|
384
|
+
// Note: Validation for either content or transform is handled in the server code
|
|
366
385
|
}
|
|
367
386
|
}
|
|
368
387
|
},
|
|
@@ -33,8 +33,8 @@ export class ToolHandlers {
|
|
|
33
33
|
return this.pageOps.fetchPageByTitle(title);
|
|
34
34
|
}
|
|
35
35
|
// Block Operations
|
|
36
|
-
async createBlock(content, page_uid, title) {
|
|
37
|
-
return this.blockOps.createBlock(content, page_uid, title);
|
|
36
|
+
async createBlock(content, page_uid, title, heading) {
|
|
37
|
+
return this.blockOps.createBlock(content, page_uid, title, heading);
|
|
38
38
|
}
|
|
39
39
|
async updateBlock(block_uid, content, transform) {
|
|
40
40
|
return this.blockOps.updateBlock(block_uid, content, transform);
|
package/package.json
CHANGED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
import { initializeGraph, createPage, batchActions, q } from '@roam-research/roam-api-sdk';
|
|
2
|
-
import { parseMarkdown, convertToRoamActions } from '../src/markdown-utils.js';
|
|
3
|
-
import * as dotenv from 'dotenv';
|
|
4
|
-
import { fileURLToPath } from 'url';
|
|
5
|
-
import { dirname, join } from 'path';
|
|
6
|
-
// Load environment variables
|
|
7
|
-
const scriptPath = fileURLToPath(import.meta.url);
|
|
8
|
-
const projectRoot = dirname(dirname(scriptPath));
|
|
9
|
-
const envPath = join(projectRoot, '.env');
|
|
10
|
-
dotenv.config({ path: envPath });
|
|
11
|
-
const API_TOKEN = process.env.ROAM_API_TOKEN;
|
|
12
|
-
const GRAPH_NAME = process.env.ROAM_GRAPH_NAME;
|
|
13
|
-
if (!API_TOKEN || !GRAPH_NAME) {
|
|
14
|
-
throw new Error('Missing required environment variables: ROAM_API_TOKEN and/or ROAM_GRAPH_NAME');
|
|
15
|
-
}
|
|
16
|
-
async function testAddMarkdownText() {
|
|
17
|
-
try {
|
|
18
|
-
// Initialize graph
|
|
19
|
-
console.log('Initializing graph...');
|
|
20
|
-
const graph = initializeGraph({
|
|
21
|
-
token: API_TOKEN,
|
|
22
|
-
graph: GRAPH_NAME,
|
|
23
|
-
});
|
|
24
|
-
// Test markdown content
|
|
25
|
-
const testPageTitle = `Test Markdown Import ${new Date().toISOString()}`;
|
|
26
|
-
console.log(`Using test page title: ${testPageTitle}`);
|
|
27
|
-
const markdownContent = `
|
|
28
|
-
| Month | Savings |
|
|
29
|
-
| -------- | ------- |
|
|
30
|
-
| January | $250 |
|
|
31
|
-
| February | $80 |
|
|
32
|
-
| March | $420 |
|
|
33
|
-
|
|
34
|
-
# Main Topic
|
|
35
|
-
- First point
|
|
36
|
-
- Nested point A
|
|
37
|
-
- Deep nested point
|
|
38
|
-
- Nested point B
|
|
39
|
-
- Second point
|
|
40
|
-
1. Numbered subpoint
|
|
41
|
-
2. Another numbered point
|
|
42
|
-
- Mixed list type
|
|
43
|
-
- Third point
|
|
44
|
-
- With some **bold** text
|
|
45
|
-
- And *italic* text
|
|
46
|
-
- And a [[Page Reference]]
|
|
47
|
-
- And a #[[Page Tag]]
|
|
48
|
-
`;
|
|
49
|
-
// First create the page
|
|
50
|
-
console.log('\nCreating page...');
|
|
51
|
-
const success = await createPage(graph, {
|
|
52
|
-
action: 'create-page',
|
|
53
|
-
page: {
|
|
54
|
-
title: testPageTitle
|
|
55
|
-
}
|
|
56
|
-
});
|
|
57
|
-
if (!success) {
|
|
58
|
-
throw new Error('Failed to create test page');
|
|
59
|
-
}
|
|
60
|
-
// Get the page UID
|
|
61
|
-
const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
|
|
62
|
-
const findResults = await q(graph, findQuery, [testPageTitle]);
|
|
63
|
-
if (!findResults || findResults.length === 0) {
|
|
64
|
-
throw new Error('Could not find created page');
|
|
65
|
-
}
|
|
66
|
-
const pageUid = findResults[0][0];
|
|
67
|
-
console.log('Page UID:', pageUid);
|
|
68
|
-
// Import markdown
|
|
69
|
-
console.log('\nImporting markdown...');
|
|
70
|
-
const nodes = parseMarkdown(markdownContent);
|
|
71
|
-
console.log('Parsed nodes:', JSON.stringify(nodes, null, 2));
|
|
72
|
-
// Convert and add markdown content
|
|
73
|
-
const actions = convertToRoamActions(nodes, pageUid, 'last');
|
|
74
|
-
const result = await batchActions(graph, {
|
|
75
|
-
action: 'batch-actions',
|
|
76
|
-
actions
|
|
77
|
-
});
|
|
78
|
-
console.log('\nImport result:', JSON.stringify(result, null, 2));
|
|
79
|
-
console.log('\nTest completed successfully!');
|
|
80
|
-
console.log(`Check your Roam graph for the page titled: ${testPageTitle}`);
|
|
81
|
-
}
|
|
82
|
-
catch (error) {
|
|
83
|
-
console.error('Error:', error);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
// Run the test
|
|
87
|
-
testAddMarkdownText();
|
package/build/test-queries.js
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { initializeGraph, q } from '@roam-research/roam-api-sdk';
|
|
3
|
-
import * as dotenv from 'dotenv';
|
|
4
|
-
// Load environment variables
|
|
5
|
-
dotenv.config();
|
|
6
|
-
const API_TOKEN = process.env.ROAM_API_TOKEN;
|
|
7
|
-
const GRAPH_NAME = process.env.ROAM_GRAPH_NAME;
|
|
8
|
-
if (!API_TOKEN || !GRAPH_NAME) {
|
|
9
|
-
throw new Error('Missing required environment variables: ROAM_API_TOKEN and ROAM_GRAPH_NAME');
|
|
10
|
-
}
|
|
11
|
-
async function main() {
|
|
12
|
-
const graph = initializeGraph({
|
|
13
|
-
token: API_TOKEN,
|
|
14
|
-
graph: GRAPH_NAME,
|
|
15
|
-
});
|
|
16
|
-
try {
|
|
17
|
-
// First verify we can find the page
|
|
18
|
-
console.log('Finding page...');
|
|
19
|
-
const searchQuery = `[:find ?uid .
|
|
20
|
-
:where [?e :node/title "December 18th, 2024"]
|
|
21
|
-
[?e :block/uid ?uid]]`;
|
|
22
|
-
const uid = await q(graph, searchQuery, []);
|
|
23
|
-
console.log('Page UID:', uid);
|
|
24
|
-
if (!uid) {
|
|
25
|
-
throw new Error('Page not found');
|
|
26
|
-
}
|
|
27
|
-
// Get all blocks under this page with their order
|
|
28
|
-
console.log('\nGetting blocks...');
|
|
29
|
-
const blocksQuery = `[:find ?block-uid ?block-str ?order ?parent-uid
|
|
30
|
-
:where [?p :block/uid "${uid}"]
|
|
31
|
-
[?b :block/page ?p]
|
|
32
|
-
[?b :block/uid ?block-uid]
|
|
33
|
-
[?b :block/string ?block-str]
|
|
34
|
-
[?b :block/order ?order]
|
|
35
|
-
[?b :block/parents ?parent]
|
|
36
|
-
[?parent :block/uid ?parent-uid]]`;
|
|
37
|
-
const blocks = await q(graph, blocksQuery, []);
|
|
38
|
-
console.log('Found', blocks.length, 'blocks');
|
|
39
|
-
// Create a map of all blocks
|
|
40
|
-
const blockMap = new Map();
|
|
41
|
-
blocks.forEach(([uid, string, order]) => {
|
|
42
|
-
if (!blockMap.has(uid)) {
|
|
43
|
-
blockMap.set(uid, {
|
|
44
|
-
uid,
|
|
45
|
-
string,
|
|
46
|
-
order: order,
|
|
47
|
-
children: []
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
});
|
|
51
|
-
console.log('Created block map with', blockMap.size, 'entries');
|
|
52
|
-
// Build parent-child relationships
|
|
53
|
-
let relationshipsBuilt = 0;
|
|
54
|
-
blocks.forEach(([childUid, _, __, parentUid]) => {
|
|
55
|
-
const child = blockMap.get(childUid);
|
|
56
|
-
const parent = blockMap.get(parentUid);
|
|
57
|
-
if (child && parent && !parent.children.includes(child)) {
|
|
58
|
-
parent.children.push(child);
|
|
59
|
-
relationshipsBuilt++;
|
|
60
|
-
}
|
|
61
|
-
});
|
|
62
|
-
console.log('Built', relationshipsBuilt, 'parent-child relationships');
|
|
63
|
-
// Get top-level blocks (those directly under the page)
|
|
64
|
-
console.log('\nGetting top-level blocks...');
|
|
65
|
-
const topQuery = `[:find ?block-uid ?block-str ?order
|
|
66
|
-
:where [?p :block/uid "${uid}"]
|
|
67
|
-
[?b :block/page ?p]
|
|
68
|
-
[?b :block/uid ?block-uid]
|
|
69
|
-
[?b :block/string ?block-str]
|
|
70
|
-
[?b :block/order ?order]
|
|
71
|
-
(not-join [?b]
|
|
72
|
-
[?b :block/parents ?parent]
|
|
73
|
-
[?parent :block/page ?p])]`;
|
|
74
|
-
const topBlocks = await q(graph, topQuery, []);
|
|
75
|
-
console.log('Found', topBlocks.length, 'top-level blocks');
|
|
76
|
-
// Create root blocks
|
|
77
|
-
const rootBlocks = topBlocks
|
|
78
|
-
.map(([uid, string, order]) => ({
|
|
79
|
-
uid,
|
|
80
|
-
string,
|
|
81
|
-
order: order,
|
|
82
|
-
children: blockMap.get(uid)?.children || []
|
|
83
|
-
}))
|
|
84
|
-
.sort((a, b) => a.order - b.order);
|
|
85
|
-
// Log block hierarchy
|
|
86
|
-
console.log('\nBlock hierarchy:');
|
|
87
|
-
const logHierarchy = (blocks, level = 0) => {
|
|
88
|
-
blocks.forEach(block => {
|
|
89
|
-
console.log(' '.repeat(level) + '- ' + block.string.substring(0, 50) + '...');
|
|
90
|
-
if (block.children.length > 0) {
|
|
91
|
-
logHierarchy(block.children.sort((a, b) => a.order - b.order), level + 1);
|
|
92
|
-
}
|
|
93
|
-
});
|
|
94
|
-
};
|
|
95
|
-
logHierarchy(rootBlocks);
|
|
96
|
-
// Convert to markdown
|
|
97
|
-
console.log('\nConverting to markdown...');
|
|
98
|
-
const toMarkdown = (blocks, level = 0) => {
|
|
99
|
-
return blocks.map(block => {
|
|
100
|
-
const indent = ' '.repeat(level);
|
|
101
|
-
let md = `${indent}- ${block.string}\n`;
|
|
102
|
-
if (block.children.length > 0) {
|
|
103
|
-
md += toMarkdown(block.children.sort((a, b) => a.order - b.order), level + 1);
|
|
104
|
-
}
|
|
105
|
-
return md;
|
|
106
|
-
}).join('');
|
|
107
|
-
};
|
|
108
|
-
const markdown = toMarkdown(rootBlocks);
|
|
109
|
-
console.log('\nMarkdown output:');
|
|
110
|
-
console.log(markdown);
|
|
111
|
-
}
|
|
112
|
-
catch (error) {
|
|
113
|
-
console.error('Error:', error);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
main().catch(console.error);
|
|
@@ -1,285 +0,0 @@
|
|
|
1
|
-
import { q } from '@roam-research/roam-api-sdk';
|
|
2
|
-
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
-
import { BlockRefSearchHandler, HierarchySearchHandler, TextSearchHandler } from '../../search/index.js';
|
|
4
|
-
export class SearchOperations {
|
|
5
|
-
graph;
|
|
6
|
-
constructor(graph) {
|
|
7
|
-
this.graph = graph;
|
|
8
|
-
}
|
|
9
|
-
async searchBlockRefs(params) {
|
|
10
|
-
const handler = new BlockRefSearchHandler(this.graph, params);
|
|
11
|
-
return handler.execute();
|
|
12
|
-
}
|
|
13
|
-
async searchHierarchy(params) {
|
|
14
|
-
const handler = new HierarchySearchHandler(this.graph, params);
|
|
15
|
-
return handler.execute();
|
|
16
|
-
}
|
|
17
|
-
async searchByText(params) {
|
|
18
|
-
const handler = new TextSearchHandler(this.graph, params);
|
|
19
|
-
return handler.execute();
|
|
20
|
-
}
|
|
21
|
-
async searchByStatus(status, page_title_uid, include, exclude, case_sensitive = true) {
|
|
22
|
-
// Get target page UID if provided
|
|
23
|
-
let targetPageUid;
|
|
24
|
-
if (page_title_uid) {
|
|
25
|
-
// Try to find page by title or UID
|
|
26
|
-
const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
|
|
27
|
-
const findResults = await q(this.graph, findQuery, [page_title_uid]);
|
|
28
|
-
if (findResults && findResults.length > 0) {
|
|
29
|
-
targetPageUid = findResults[0][0];
|
|
30
|
-
}
|
|
31
|
-
else {
|
|
32
|
-
// Try as UID
|
|
33
|
-
const uidQuery = `[:find ?uid :where [?e :block/uid "${page_title_uid}"] [?e :block/uid ?uid]]`;
|
|
34
|
-
const uidResults = await q(this.graph, uidQuery, []);
|
|
35
|
-
if (!uidResults || uidResults.length === 0) {
|
|
36
|
-
throw new McpError(ErrorCode.InvalidRequest, `Page with title/UID "${page_title_uid}" not found`);
|
|
37
|
-
}
|
|
38
|
-
targetPageUid = uidResults[0][0];
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
// Build query based on whether we're searching in a specific page
|
|
42
|
-
let queryStr;
|
|
43
|
-
let queryParams;
|
|
44
|
-
const statusPattern = `{{[[${status}]]}}`;
|
|
45
|
-
if (targetPageUid) {
|
|
46
|
-
queryStr = `[:find ?block-uid ?block-str
|
|
47
|
-
:in $ ?status-pattern ?page-uid
|
|
48
|
-
:where [?p :block/uid ?page-uid]
|
|
49
|
-
[?b :block/page ?p]
|
|
50
|
-
[?b :block/string ?block-str]
|
|
51
|
-
[?b :block/uid ?block-uid]
|
|
52
|
-
[(clojure.string/includes? ?block-str ?status-pattern)]]`;
|
|
53
|
-
queryParams = [statusPattern, targetPageUid];
|
|
54
|
-
}
|
|
55
|
-
else {
|
|
56
|
-
queryStr = `[:find ?block-uid ?block-str ?page-title
|
|
57
|
-
:in $ ?status-pattern
|
|
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
|
-
[(clojure.string/includes? ?block-str ?status-pattern)]]`;
|
|
63
|
-
queryParams = [statusPattern];
|
|
64
|
-
}
|
|
65
|
-
const results = await q(this.graph, queryStr, queryParams);
|
|
66
|
-
if (!results || results.length === 0) {
|
|
67
|
-
return {
|
|
68
|
-
success: true,
|
|
69
|
-
matches: [],
|
|
70
|
-
message: `No blocks found with status ${status}`
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
// Format initial results
|
|
74
|
-
let matches = results.map(result => {
|
|
75
|
-
const [uid, content, pageTitle] = result;
|
|
76
|
-
return {
|
|
77
|
-
block_uid: uid,
|
|
78
|
-
content,
|
|
79
|
-
...(pageTitle && { page_title: pageTitle })
|
|
80
|
-
};
|
|
81
|
-
});
|
|
82
|
-
// Post-query filtering with case sensitivity option
|
|
83
|
-
if (include) {
|
|
84
|
-
const includeTerms = include.split(',').map(term => term.trim());
|
|
85
|
-
matches = matches.filter(match => {
|
|
86
|
-
const matchContent = case_sensitive ? match.content : match.content.toLowerCase();
|
|
87
|
-
const matchTitle = match.page_title && (case_sensitive ? match.page_title : match.page_title.toLowerCase());
|
|
88
|
-
const terms = case_sensitive ? includeTerms : includeTerms.map(t => t.toLowerCase());
|
|
89
|
-
return terms.some(term => matchContent.includes(case_sensitive ? term : term.toLowerCase()) ||
|
|
90
|
-
(matchTitle && matchTitle.includes(case_sensitive ? term : term.toLowerCase())));
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
if (exclude) {
|
|
94
|
-
const excludeTerms = exclude.split(',').map(term => term.trim());
|
|
95
|
-
matches = matches.filter(match => {
|
|
96
|
-
const matchContent = case_sensitive ? match.content : match.content.toLowerCase();
|
|
97
|
-
const matchTitle = match.page_title && (case_sensitive ? match.page_title : match.page_title.toLowerCase());
|
|
98
|
-
const terms = case_sensitive ? excludeTerms : excludeTerms.map(t => t.toLowerCase());
|
|
99
|
-
return !terms.some(term => matchContent.includes(case_sensitive ? term : term.toLowerCase()) ||
|
|
100
|
-
(matchTitle && matchTitle.includes(case_sensitive ? term : term.toLowerCase())));
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
return {
|
|
104
|
-
success: true,
|
|
105
|
-
matches,
|
|
106
|
-
message: `Found ${matches.length} block(s) with status ${status}${include ? ` including "${include}"` : ''}${exclude ? ` excluding "${exclude}"` : ''}`
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
async searchForTag(primary_tag, page_title_uid, near_tag, case_sensitive = true) {
|
|
110
|
-
// Ensure tags are properly formatted with #
|
|
111
|
-
const formatTag = (tag) => {
|
|
112
|
-
return tag.replace(/^#/, '').replace(/^\[\[/, '').replace(/\]\]$/, '');
|
|
113
|
-
};
|
|
114
|
-
// Extract the tag text, removing any formatting
|
|
115
|
-
const primaryTagFormatted = formatTag(primary_tag);
|
|
116
|
-
const nearTagFormatted = near_tag ? formatTag(near_tag) : undefined;
|
|
117
|
-
// Get target page UID if provided
|
|
118
|
-
let targetPageUid;
|
|
119
|
-
if (page_title_uid) {
|
|
120
|
-
// Try to find page by title or UID
|
|
121
|
-
const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
|
|
122
|
-
const findResults = await q(this.graph, findQuery, [page_title_uid]);
|
|
123
|
-
if (findResults && findResults.length > 0) {
|
|
124
|
-
targetPageUid = findResults[0][0];
|
|
125
|
-
}
|
|
126
|
-
else {
|
|
127
|
-
// Try as UID
|
|
128
|
-
const uidQuery = `[:find ?uid :where [?e :block/uid "${page_title_uid}"] [?e :block/uid ?uid]]`;
|
|
129
|
-
const uidResults = await q(this.graph, uidQuery, []);
|
|
130
|
-
if (!uidResults || uidResults.length === 0) {
|
|
131
|
-
throw new McpError(ErrorCode.InvalidRequest, `Page with title/UID "${page_title_uid}" not found`);
|
|
132
|
-
}
|
|
133
|
-
targetPageUid = uidResults[0][0];
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
// Build query based on whether we're searching in a specific page and/or for a nearby tag
|
|
137
|
-
let queryStr;
|
|
138
|
-
let queryParams;
|
|
139
|
-
if (targetPageUid) {
|
|
140
|
-
if (nearTagFormatted) {
|
|
141
|
-
queryStr = `[:find ?block-uid ?block-str
|
|
142
|
-
:in $ ?primary-tag ?near-tag ?page-uid
|
|
143
|
-
:where [?p :block/uid ?page-uid]
|
|
144
|
-
[?b :block/page ?p]
|
|
145
|
-
[?b :block/string ?block-str]
|
|
146
|
-
[?b :block/uid ?block-uid]
|
|
147
|
-
[(clojure.string/includes?
|
|
148
|
-
${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
|
|
149
|
-
${case_sensitive ? '?primary-tag' : '(clojure.string/lower-case ?primary-tag)'})]
|
|
150
|
-
[(clojure.string/includes?
|
|
151
|
-
${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
|
|
152
|
-
${case_sensitive ? '?near-tag' : '(clojure.string/lower-case ?near-tag)'})]`;
|
|
153
|
-
queryParams = [primaryTagFormatted, nearTagFormatted, targetPageUid];
|
|
154
|
-
}
|
|
155
|
-
else {
|
|
156
|
-
queryStr = `[:find ?block-uid ?block-str
|
|
157
|
-
:in $ ?primary-tag ?page-uid
|
|
158
|
-
:where [?p :block/uid ?page-uid]
|
|
159
|
-
[?b :block/page ?p]
|
|
160
|
-
[?b :block/string ?block-str]
|
|
161
|
-
[?b :block/uid ?block-uid]
|
|
162
|
-
[(clojure.string/includes?
|
|
163
|
-
${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
|
|
164
|
-
${case_sensitive ? '?primary-tag' : '(clojure.string/lower-case ?primary-tag)'})]`;
|
|
165
|
-
queryParams = [primaryTagFormatted, targetPageUid];
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
else {
|
|
169
|
-
// Search across all pages
|
|
170
|
-
if (nearTagFormatted) {
|
|
171
|
-
queryStr = `[:find ?block-uid ?block-str ?page-title
|
|
172
|
-
:in $ ?primary-tag ?near-tag
|
|
173
|
-
:where [?b :block/string ?block-str]
|
|
174
|
-
[?b :block/uid ?block-uid]
|
|
175
|
-
[?b :block/page ?p]
|
|
176
|
-
[?p :node/title ?page-title]
|
|
177
|
-
[(clojure.string/includes?
|
|
178
|
-
${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
|
|
179
|
-
${case_sensitive ? '?primary-tag' : '(clojure.string/lower-case ?primary-tag)'})]
|
|
180
|
-
[(clojure.string/includes?
|
|
181
|
-
${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
|
|
182
|
-
${case_sensitive ? '?near-tag' : '(clojure.string/lower-case ?near-tag)'})]`;
|
|
183
|
-
queryParams = [primaryTagFormatted, nearTagFormatted];
|
|
184
|
-
}
|
|
185
|
-
else {
|
|
186
|
-
queryStr = `[:find ?block-uid ?block-str ?page-title
|
|
187
|
-
:in $ ?primary-tag
|
|
188
|
-
:where [?b :block/string ?block-str]
|
|
189
|
-
[?b :block/uid ?block-uid]
|
|
190
|
-
[?b :block/page ?p]
|
|
191
|
-
[?p :node/title ?page-title]
|
|
192
|
-
[(clojure.string/includes?
|
|
193
|
-
${case_sensitive ? '?block-str' : '(clojure.string/lower-case ?block-str)'}
|
|
194
|
-
${case_sensitive ? '?primary-tag' : '(clojure.string/lower-case ?primary-tag)'})]`;
|
|
195
|
-
queryParams = [primaryTagFormatted];
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
const results = await q(this.graph, queryStr, queryParams);
|
|
199
|
-
if (!results || results.length === 0) {
|
|
200
|
-
return {
|
|
201
|
-
success: true,
|
|
202
|
-
matches: [],
|
|
203
|
-
message: `No blocks found containing ${primaryTagFormatted}${nearTagFormatted ? ` near ${nearTagFormatted}` : ''}`
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
// Format results
|
|
207
|
-
const matches = results.map(([uid, content, pageTitle]) => ({
|
|
208
|
-
block_uid: uid,
|
|
209
|
-
content,
|
|
210
|
-
...(pageTitle && { page_title: pageTitle })
|
|
211
|
-
}));
|
|
212
|
-
return {
|
|
213
|
-
success: true,
|
|
214
|
-
matches,
|
|
215
|
-
message: `Found ${matches.length} block(s) containing ${primaryTagFormatted}${nearTagFormatted ? ` near ${nearTagFormatted}` : ''}`
|
|
216
|
-
};
|
|
217
|
-
}
|
|
218
|
-
async searchByDate(params) {
|
|
219
|
-
// Convert dates to timestamps
|
|
220
|
-
const startTimestamp = new Date(`${params.start_date}T00:00:00`).getTime();
|
|
221
|
-
const endTimestamp = params.end_date ? new Date(`${params.end_date}T23:59:59`).getTime() : undefined;
|
|
222
|
-
// Define rule for entity type
|
|
223
|
-
const entityRule = `[
|
|
224
|
-
[(block? ?e)
|
|
225
|
-
[?e :block/string]
|
|
226
|
-
[?e :block/page ?p]
|
|
227
|
-
[?p :node/title]]
|
|
228
|
-
[(page? ?e)
|
|
229
|
-
[?e :node/title]]
|
|
230
|
-
]`;
|
|
231
|
-
// Build query based on cheatsheet pattern
|
|
232
|
-
const timeAttr = params.type === 'created' ? ':create/time' : ':edit/time';
|
|
233
|
-
let queryStr = `[:find ?block-uid ?string ?time ?page-title
|
|
234
|
-
:in $ ?start-ts ${endTimestamp ? '?end-ts' : ''}
|
|
235
|
-
:where
|
|
236
|
-
[?b ${timeAttr} ?time]
|
|
237
|
-
[(>= ?time ?start-ts)]
|
|
238
|
-
${endTimestamp ? '[(<= ?time ?end-ts)]' : ''}
|
|
239
|
-
[?b :block/uid ?block-uid]
|
|
240
|
-
[?b :block/string ?string]
|
|
241
|
-
[?b :block/page ?p]
|
|
242
|
-
[?p :node/title ?page-title]]`;
|
|
243
|
-
// Execute query
|
|
244
|
-
const queryParams = endTimestamp ?
|
|
245
|
-
[startTimestamp, endTimestamp] :
|
|
246
|
-
[startTimestamp];
|
|
247
|
-
const results = await q(this.graph, queryStr, queryParams);
|
|
248
|
-
if (!results || results.length === 0) {
|
|
249
|
-
return {
|
|
250
|
-
success: true,
|
|
251
|
-
matches: [],
|
|
252
|
-
message: 'No matches found for the given date range and criteria'
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
// Process results - now we get [block-uid, string, time, page-title]
|
|
256
|
-
const matches = results.map(([uid, content, time, pageTitle]) => ({
|
|
257
|
-
uid,
|
|
258
|
-
type: 'block',
|
|
259
|
-
time,
|
|
260
|
-
...(params.include_content && { content }),
|
|
261
|
-
page_title: pageTitle
|
|
262
|
-
}));
|
|
263
|
-
// Apply case sensitivity if content is included
|
|
264
|
-
if (params.include_content) {
|
|
265
|
-
const case_sensitive = params.case_sensitive ?? true; // Default to true to match Roam's behavior
|
|
266
|
-
if (!case_sensitive) {
|
|
267
|
-
matches.forEach(match => {
|
|
268
|
-
if (match.content) {
|
|
269
|
-
match.content = match.content.toLowerCase();
|
|
270
|
-
}
|
|
271
|
-
if (match.page_title) {
|
|
272
|
-
match.page_title = match.page_title.toLowerCase();
|
|
273
|
-
}
|
|
274
|
-
});
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
// Sort by time
|
|
278
|
-
const sortedMatches = matches.sort((a, b) => b.time - a.time);
|
|
279
|
-
return {
|
|
280
|
-
success: true,
|
|
281
|
-
matches: sortedMatches,
|
|
282
|
-
message: `Found ${sortedMatches.length} matches for the given date range and criteria`
|
|
283
|
-
};
|
|
284
|
-
}
|
|
285
|
-
}
|