roam-research-mcp 2.4.0 → 2.13.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.
- package/README.md +175 -667
- package/build/Roam_Markdown_Cheatsheet.md +138 -289
- package/build/cache/page-uid-cache.js +40 -2
- package/build/cli/batch/translator.js +1 -1
- package/build/cli/commands/batch.js +3 -8
- package/build/cli/commands/get.js +478 -60
- package/build/cli/commands/refs.js +51 -31
- package/build/cli/commands/save.js +61 -10
- package/build/cli/commands/search.js +63 -58
- package/build/cli/commands/status.js +3 -4
- package/build/cli/commands/update.js +71 -28
- package/build/cli/utils/graph.js +6 -2
- package/build/cli/utils/input.js +10 -0
- package/build/cli/utils/output.js +28 -5
- package/build/cli/utils/sort-group.js +110 -0
- package/build/config/graph-registry.js +31 -13
- package/build/config/graph-registry.test.js +42 -5
- package/build/markdown-utils.js +114 -4
- package/build/markdown-utils.test.js +125 -0
- package/build/query/generator.js +330 -0
- package/build/query/index.js +149 -0
- package/build/query/parser.js +319 -0
- package/build/query/parser.test.js +389 -0
- package/build/query/types.js +4 -0
- package/build/search/ancestor-rule.js +14 -0
- package/build/search/block-ref-search.js +1 -5
- package/build/search/hierarchy-search.js +5 -12
- package/build/search/index.js +1 -0
- package/build/search/status-search.js +10 -9
- package/build/search/tag-search.js +8 -24
- package/build/search/text-search.js +70 -27
- package/build/search/types.js +13 -0
- package/build/search/utils.js +71 -2
- package/build/server/roam-server.js +4 -3
- package/build/shared/index.js +2 -0
- package/build/shared/page-validator.js +233 -0
- package/build/shared/page-validator.test.js +128 -0
- package/build/shared/staged-batch.js +144 -0
- package/build/tools/helpers/batch-utils.js +57 -0
- package/build/tools/helpers/page-resolution.js +136 -0
- package/build/tools/helpers/refs.js +68 -0
- package/build/tools/operations/batch.js +75 -3
- package/build/tools/operations/block-retrieval.js +15 -4
- package/build/tools/operations/block-retrieval.test.js +87 -0
- package/build/tools/operations/blocks.js +1 -288
- package/build/tools/operations/memory.js +32 -90
- package/build/tools/operations/outline.js +38 -156
- package/build/tools/operations/pages.js +169 -122
- package/build/tools/operations/todos.js +5 -37
- package/build/tools/schemas.js +20 -9
- package/build/tools/tool-handlers.js +4 -4
- package/build/utils/helpers.js +27 -0
- package/package.json +1 -1
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for Roam Query Block syntax
|
|
3
|
+
*
|
|
4
|
+
* Parses queries like:
|
|
5
|
+
* {{[[query]]: {and: [[tag1]] [[tag2]]}}}
|
|
6
|
+
* {{[[query]]: {or: [[a]] {not: [[b]]}}}}
|
|
7
|
+
* {{[[query]]: {and: {between: [[January 1st, 2026]] [[January 31st, 2026]]} [[Project]]}}}
|
|
8
|
+
*/
|
|
9
|
+
export class QueryParseError extends Error {
|
|
10
|
+
constructor(message, position) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.position = position;
|
|
13
|
+
this.name = 'QueryParseError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class QueryParser {
|
|
17
|
+
constructor(input) {
|
|
18
|
+
this.input = input;
|
|
19
|
+
this.pos = 0;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Parse a Roam query block string into an AST
|
|
23
|
+
* Accepts either the full block syntax or just the query expression
|
|
24
|
+
*/
|
|
25
|
+
static parse(input) {
|
|
26
|
+
const result = QueryParser.parseWithName(input);
|
|
27
|
+
return result.query;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Parse a Roam query block string into an AST with optional name
|
|
31
|
+
* Returns both the name (if present) and the query AST
|
|
32
|
+
*/
|
|
33
|
+
static parseWithName(input) {
|
|
34
|
+
const parser = new QueryParser(input);
|
|
35
|
+
return parser.parseQueryWithName();
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Extract query expression from a full query block
|
|
39
|
+
* {{[[query]]: expression}} -> expression
|
|
40
|
+
* {{[[query]]: "name" expression}} -> expression (name extracted separately)
|
|
41
|
+
*/
|
|
42
|
+
extractQueryExpression() {
|
|
43
|
+
const trimmed = this.input.trim();
|
|
44
|
+
// Full query block format
|
|
45
|
+
const fullMatch = trimmed.match(/^\{\{\[\[query\]\]:\s*(.+)\}\}$/s);
|
|
46
|
+
if (fullMatch) {
|
|
47
|
+
return fullMatch[1].trim();
|
|
48
|
+
}
|
|
49
|
+
// Already just the expression
|
|
50
|
+
return trimmed;
|
|
51
|
+
}
|
|
52
|
+
parseQueryWithName() {
|
|
53
|
+
this.input = this.extractQueryExpression();
|
|
54
|
+
this.pos = 0;
|
|
55
|
+
this.skipWhitespace();
|
|
56
|
+
// Check for optional name prefix (quoted string)
|
|
57
|
+
let name;
|
|
58
|
+
if (this.peek() === '"') {
|
|
59
|
+
name = this.parseQuotedString();
|
|
60
|
+
this.skipWhitespace();
|
|
61
|
+
}
|
|
62
|
+
const query = this.parseExpression();
|
|
63
|
+
this.skipWhitespace();
|
|
64
|
+
if (this.pos < this.input.length) {
|
|
65
|
+
throw new QueryParseError(`Unexpected content after query: "${this.input.slice(this.pos)}"`, this.pos);
|
|
66
|
+
}
|
|
67
|
+
return { name, query };
|
|
68
|
+
}
|
|
69
|
+
parseQuery() {
|
|
70
|
+
return this.parseQueryWithName().query;
|
|
71
|
+
}
|
|
72
|
+
parseQuotedString() {
|
|
73
|
+
this.expect('"');
|
|
74
|
+
let value = '';
|
|
75
|
+
while (this.pos < this.input.length && this.peek() !== '"') {
|
|
76
|
+
if (this.peek() === '\\' && this.input[this.pos + 1] === '"') {
|
|
77
|
+
value += '"';
|
|
78
|
+
this.pos += 2;
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
value += this.input[this.pos];
|
|
82
|
+
this.pos++;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (this.peek() === '"') {
|
|
86
|
+
this.pos++; // Skip closing quote
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
throw new QueryParseError('Unclosed quoted string', this.pos);
|
|
90
|
+
}
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
parseExpression() {
|
|
94
|
+
this.skipWhitespace();
|
|
95
|
+
if (this.peek() === '{') {
|
|
96
|
+
return this.parseOperator();
|
|
97
|
+
}
|
|
98
|
+
else if (this.input.slice(this.pos, this.pos + 2) === '[[') {
|
|
99
|
+
return this.parseTag();
|
|
100
|
+
}
|
|
101
|
+
else if (this.input.slice(this.pos, this.pos + 2) === '((') {
|
|
102
|
+
return this.parseBlockRef();
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
throw new QueryParseError(`Expected '{', '[[', or '((' at position ${this.pos}, found: "${this.input.slice(this.pos, this.pos + 10)}..."`, this.pos);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
parseOperator() {
|
|
109
|
+
this.expect('{');
|
|
110
|
+
this.skipWhitespace();
|
|
111
|
+
const operator = this.parseOperatorName();
|
|
112
|
+
this.skipWhitespace();
|
|
113
|
+
this.expect(':');
|
|
114
|
+
this.skipWhitespace();
|
|
115
|
+
let node;
|
|
116
|
+
switch (operator.toLowerCase()) {
|
|
117
|
+
case 'and':
|
|
118
|
+
node = this.parseAndOr('and');
|
|
119
|
+
break;
|
|
120
|
+
case 'or':
|
|
121
|
+
node = this.parseAndOr('or');
|
|
122
|
+
break;
|
|
123
|
+
case 'not':
|
|
124
|
+
node = this.parseNot();
|
|
125
|
+
break;
|
|
126
|
+
case 'between':
|
|
127
|
+
node = this.parseBetween();
|
|
128
|
+
break;
|
|
129
|
+
case 'search':
|
|
130
|
+
node = this.parseSearch();
|
|
131
|
+
break;
|
|
132
|
+
case 'daily notes':
|
|
133
|
+
node = this.parseDailyNotes();
|
|
134
|
+
break;
|
|
135
|
+
case 'by':
|
|
136
|
+
node = this.parseUserClause('by');
|
|
137
|
+
break;
|
|
138
|
+
case 'created by':
|
|
139
|
+
node = this.parseUserClause('created-by');
|
|
140
|
+
break;
|
|
141
|
+
case 'edited by':
|
|
142
|
+
node = this.parseUserClause('edited-by');
|
|
143
|
+
break;
|
|
144
|
+
default:
|
|
145
|
+
throw new QueryParseError(`Unknown operator: ${operator}`, this.pos);
|
|
146
|
+
}
|
|
147
|
+
this.skipWhitespace();
|
|
148
|
+
this.expect('}');
|
|
149
|
+
return node;
|
|
150
|
+
}
|
|
151
|
+
parseAndOr(type) {
|
|
152
|
+
const children = [];
|
|
153
|
+
this.skipWhitespace();
|
|
154
|
+
while (this.pos < this.input.length && this.peek() !== '}') {
|
|
155
|
+
children.push(this.parseExpression());
|
|
156
|
+
this.skipWhitespace();
|
|
157
|
+
}
|
|
158
|
+
if (children.length === 0) {
|
|
159
|
+
throw new QueryParseError(`${type} operator requires at least one child`, this.pos);
|
|
160
|
+
}
|
|
161
|
+
return { type, children };
|
|
162
|
+
}
|
|
163
|
+
parseNot() {
|
|
164
|
+
this.skipWhitespace();
|
|
165
|
+
const child = this.parseExpression();
|
|
166
|
+
return { type: 'not', child };
|
|
167
|
+
}
|
|
168
|
+
parseBetween() {
|
|
169
|
+
this.skipWhitespace();
|
|
170
|
+
// Parse first date
|
|
171
|
+
const startTag = this.parseTag();
|
|
172
|
+
this.skipWhitespace();
|
|
173
|
+
// Parse second date
|
|
174
|
+
const endTag = this.parseTag();
|
|
175
|
+
return {
|
|
176
|
+
type: 'between',
|
|
177
|
+
startDate: startTag.value,
|
|
178
|
+
endDate: endTag.value
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
parseTag() {
|
|
182
|
+
this.expect('[');
|
|
183
|
+
this.expect('[');
|
|
184
|
+
let value = '';
|
|
185
|
+
let depth = 1;
|
|
186
|
+
while (this.pos < this.input.length && depth > 0) {
|
|
187
|
+
if (this.input.slice(this.pos, this.pos + 2) === ']]') {
|
|
188
|
+
depth--;
|
|
189
|
+
if (depth === 0) {
|
|
190
|
+
this.pos += 2;
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
value += ']]';
|
|
194
|
+
this.pos += 2;
|
|
195
|
+
}
|
|
196
|
+
else if (this.input.slice(this.pos, this.pos + 2) === '[[') {
|
|
197
|
+
depth++;
|
|
198
|
+
value += '[[';
|
|
199
|
+
this.pos += 2;
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
value += this.input[this.pos];
|
|
203
|
+
this.pos++;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (depth !== 0) {
|
|
207
|
+
throw new QueryParseError('Unclosed tag reference', this.pos);
|
|
208
|
+
}
|
|
209
|
+
return { type: 'tag', value: value.trim() };
|
|
210
|
+
}
|
|
211
|
+
parseBlockRef() {
|
|
212
|
+
this.expect('(');
|
|
213
|
+
this.expect('(');
|
|
214
|
+
let uid = '';
|
|
215
|
+
while (this.pos < this.input.length) {
|
|
216
|
+
if (this.input.slice(this.pos, this.pos + 2) === '))') {
|
|
217
|
+
this.pos += 2;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
uid += this.input[this.pos];
|
|
221
|
+
this.pos++;
|
|
222
|
+
}
|
|
223
|
+
if (!uid) {
|
|
224
|
+
throw new QueryParseError('Empty block reference', this.pos);
|
|
225
|
+
}
|
|
226
|
+
return { type: 'block-ref', uid: uid.trim() };
|
|
227
|
+
}
|
|
228
|
+
parseSearch() {
|
|
229
|
+
// Search text is typically in quotes or just plain text until }
|
|
230
|
+
this.skipWhitespace();
|
|
231
|
+
let text = '';
|
|
232
|
+
// Check if quoted
|
|
233
|
+
if (this.peek() === '"') {
|
|
234
|
+
this.pos++; // Skip opening quote
|
|
235
|
+
while (this.pos < this.input.length && this.peek() !== '"') {
|
|
236
|
+
if (this.peek() === '\\' && this.input[this.pos + 1] === '"') {
|
|
237
|
+
text += '"';
|
|
238
|
+
this.pos += 2;
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
text += this.input[this.pos];
|
|
242
|
+
this.pos++;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (this.peek() === '"') {
|
|
246
|
+
this.pos++; // Skip closing quote
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
// Unquoted - read until }
|
|
251
|
+
while (this.pos < this.input.length && this.peek() !== '}') {
|
|
252
|
+
text += this.input[this.pos];
|
|
253
|
+
this.pos++;
|
|
254
|
+
}
|
|
255
|
+
text = text.trim();
|
|
256
|
+
}
|
|
257
|
+
return { type: 'search', text };
|
|
258
|
+
}
|
|
259
|
+
parseDailyNotes() {
|
|
260
|
+
// Daily notes clause has no arguments, just empty or whitespace until }
|
|
261
|
+
return { type: 'daily-notes' };
|
|
262
|
+
}
|
|
263
|
+
parseUserClause(type) {
|
|
264
|
+
this.skipWhitespace();
|
|
265
|
+
const user = this.parseUserIdentifier();
|
|
266
|
+
return { type, user };
|
|
267
|
+
}
|
|
268
|
+
parseUserIdentifier() {
|
|
269
|
+
// User can be in [[display name]] format or plain text
|
|
270
|
+
if (this.input.slice(this.pos, this.pos + 2) === '[[') {
|
|
271
|
+
const tag = this.parseTag();
|
|
272
|
+
return tag.value;
|
|
273
|
+
}
|
|
274
|
+
// Plain text until }
|
|
275
|
+
let user = '';
|
|
276
|
+
while (this.pos < this.input.length && this.peek() !== '}') {
|
|
277
|
+
user += this.input[this.pos];
|
|
278
|
+
this.pos++;
|
|
279
|
+
}
|
|
280
|
+
return user.trim();
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Parse operator name, handling multi-word operators like "created by"
|
|
284
|
+
*/
|
|
285
|
+
parseOperatorName() {
|
|
286
|
+
const knownMultiWord = ['created by', 'edited by', 'daily notes'];
|
|
287
|
+
const remaining = this.input.slice(this.pos).toLowerCase();
|
|
288
|
+
for (const op of knownMultiWord) {
|
|
289
|
+
if (remaining.startsWith(op)) {
|
|
290
|
+
this.pos += op.length;
|
|
291
|
+
return op;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// Single word operator
|
|
295
|
+
return this.parseIdentifier();
|
|
296
|
+
}
|
|
297
|
+
parseIdentifier() {
|
|
298
|
+
let id = '';
|
|
299
|
+
while (this.pos < this.input.length && /[a-zA-Z]/.test(this.input[this.pos])) {
|
|
300
|
+
id += this.input[this.pos];
|
|
301
|
+
this.pos++;
|
|
302
|
+
}
|
|
303
|
+
return id;
|
|
304
|
+
}
|
|
305
|
+
skipWhitespace() {
|
|
306
|
+
while (this.pos < this.input.length && /\s/.test(this.input[this.pos])) {
|
|
307
|
+
this.pos++;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
peek() {
|
|
311
|
+
return this.input[this.pos];
|
|
312
|
+
}
|
|
313
|
+
expect(char) {
|
|
314
|
+
if (this.input[this.pos] !== char) {
|
|
315
|
+
throw new QueryParseError(`Expected '${char}' at position ${this.pos}, found '${this.input[this.pos] || 'EOF'}'`, this.pos);
|
|
316
|
+
}
|
|
317
|
+
this.pos++;
|
|
318
|
+
}
|
|
319
|
+
}
|