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,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Datalog Generator for Roam Query AST
|
|
3
|
+
*
|
|
4
|
+
* Converts parsed query nodes into Datalog WHERE clauses
|
|
5
|
+
*/
|
|
6
|
+
export class DatalogGenerator {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.refCounter = 0;
|
|
9
|
+
this.inputCounter = 0;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Generate Datalog clauses from a query AST
|
|
13
|
+
*/
|
|
14
|
+
generate(node) {
|
|
15
|
+
this.refCounter = 0;
|
|
16
|
+
this.inputCounter = 0;
|
|
17
|
+
const clauses = this.generateNode(node, '?b');
|
|
18
|
+
return {
|
|
19
|
+
where: clauses.where,
|
|
20
|
+
inputs: clauses.inputs,
|
|
21
|
+
inputValues: clauses.inputValues
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
generateNode(node, blockVar) {
|
|
25
|
+
switch (node.type) {
|
|
26
|
+
case 'and':
|
|
27
|
+
return this.generateAnd(node.children, blockVar);
|
|
28
|
+
case 'or':
|
|
29
|
+
return this.generateOr(node.children, blockVar);
|
|
30
|
+
case 'not':
|
|
31
|
+
return this.generateNot(node.child, blockVar);
|
|
32
|
+
case 'between':
|
|
33
|
+
return this.generateBetween(node.startDate, node.endDate, blockVar);
|
|
34
|
+
case 'tag':
|
|
35
|
+
return this.generateTag(node.value, blockVar);
|
|
36
|
+
case 'block-ref':
|
|
37
|
+
return this.generateBlockRef(node.uid, blockVar);
|
|
38
|
+
case 'search':
|
|
39
|
+
return this.generateSearch(node.text, blockVar);
|
|
40
|
+
case 'daily-notes':
|
|
41
|
+
return this.generateDailyNotes(blockVar);
|
|
42
|
+
case 'by':
|
|
43
|
+
return this.generateBy(node.user, blockVar);
|
|
44
|
+
case 'created-by':
|
|
45
|
+
return this.generateCreatedBy(node.user, blockVar);
|
|
46
|
+
case 'edited-by':
|
|
47
|
+
return this.generateEditedBy(node.user, blockVar);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
generateAnd(children, blockVar) {
|
|
51
|
+
const where = [];
|
|
52
|
+
const inputs = [];
|
|
53
|
+
const inputValues = [];
|
|
54
|
+
for (const child of children) {
|
|
55
|
+
const childClauses = this.generateNode(child, blockVar);
|
|
56
|
+
where.push(...childClauses.where);
|
|
57
|
+
inputs.push(...childClauses.inputs);
|
|
58
|
+
inputValues.push(...childClauses.inputValues);
|
|
59
|
+
}
|
|
60
|
+
return { where, inputs, inputValues };
|
|
61
|
+
}
|
|
62
|
+
generateOr(children, blockVar) {
|
|
63
|
+
const inputs = [];
|
|
64
|
+
const inputValues = [];
|
|
65
|
+
// For OR, we need to wrap each child's clauses in (or-join ...)
|
|
66
|
+
// to properly scope the variable bindings
|
|
67
|
+
const orBranches = [];
|
|
68
|
+
for (const child of children) {
|
|
69
|
+
const childClauses = this.generateNode(child, blockVar);
|
|
70
|
+
inputs.push(...childClauses.inputs);
|
|
71
|
+
inputValues.push(...childClauses.inputValues);
|
|
72
|
+
// Wrap multiple clauses in (and ...)
|
|
73
|
+
if (childClauses.where.length === 1) {
|
|
74
|
+
orBranches.push(childClauses.where[0]);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
orBranches.push(`(and ${childClauses.where.join(' ')})`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const orClause = `(or-join [${blockVar}] ${orBranches.join(' ')})`;
|
|
81
|
+
return { where: [orClause], inputs, inputValues };
|
|
82
|
+
}
|
|
83
|
+
generateNot(child, blockVar) {
|
|
84
|
+
const childClauses = this.generateNode(child, blockVar);
|
|
85
|
+
// Wrap the child clauses in (not ...)
|
|
86
|
+
const notContent = childClauses.where.length === 1
|
|
87
|
+
? childClauses.where[0]
|
|
88
|
+
: `(and ${childClauses.where.join(' ')})`;
|
|
89
|
+
const notClause = `(not ${notContent})`;
|
|
90
|
+
return {
|
|
91
|
+
where: [notClause],
|
|
92
|
+
inputs: childClauses.inputs,
|
|
93
|
+
inputValues: childClauses.inputValues
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
generateBetween(startDate, endDate, blockVar) {
|
|
97
|
+
// between matches blocks on daily pages within the date range
|
|
98
|
+
// or blocks with :create/time in the range
|
|
99
|
+
const startVar = `?start-date-${this.inputCounter++}`;
|
|
100
|
+
const endVar = `?end-date-${this.inputCounter++}`;
|
|
101
|
+
// Convert Roam date format to timestamp for comparison
|
|
102
|
+
// This assumes dates are in "January 1st, 2026" format
|
|
103
|
+
const startTs = this.roamDateToTimestamp(startDate);
|
|
104
|
+
const endTs = this.roamDateToTimestamp(endDate) + (24 * 60 * 60 * 1000 - 1); // End of day
|
|
105
|
+
const where = [
|
|
106
|
+
`[${blockVar} :create/time ?create-time]`,
|
|
107
|
+
`[(>= ?create-time ${startVar})]`,
|
|
108
|
+
`[(<= ?create-time ${endVar})]`
|
|
109
|
+
];
|
|
110
|
+
return {
|
|
111
|
+
where,
|
|
112
|
+
inputs: [startVar, endVar],
|
|
113
|
+
inputValues: [startTs, endTs]
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
generateTag(tagName, blockVar) {
|
|
117
|
+
const refVar = `?ref-${this.refCounter++}`;
|
|
118
|
+
// A tag reference means the block has :block/refs pointing to a page with that title
|
|
119
|
+
const where = [
|
|
120
|
+
`[${refVar} :node/title "${this.escapeString(tagName)}"]`,
|
|
121
|
+
`[${blockVar} :block/refs ${refVar}]`
|
|
122
|
+
];
|
|
123
|
+
return { where, inputs: [], inputValues: [] };
|
|
124
|
+
}
|
|
125
|
+
generateBlockRef(uid, blockVar) {
|
|
126
|
+
// Block ref means block references another block via ((uid)) syntax
|
|
127
|
+
// This can be through :block/refs or embedded in the string
|
|
128
|
+
const refVar = `?block-ref-${this.refCounter++}`;
|
|
129
|
+
const where = [
|
|
130
|
+
`[${refVar} :block/uid "${this.escapeString(uid)}"]`,
|
|
131
|
+
`[${blockVar} :block/refs ${refVar}]`
|
|
132
|
+
];
|
|
133
|
+
return { where, inputs: [], inputValues: [] };
|
|
134
|
+
}
|
|
135
|
+
generateSearch(text, blockVar) {
|
|
136
|
+
// Search uses clojure.string/includes? to find text in block content
|
|
137
|
+
const where = [
|
|
138
|
+
`[(clojure.string/includes? ?block-str "${this.escapeString(text)}")]`
|
|
139
|
+
];
|
|
140
|
+
return { where, inputs: [], inputValues: [] };
|
|
141
|
+
}
|
|
142
|
+
generateDailyNotes(blockVar) {
|
|
143
|
+
// Daily notes pages have titles matching the date pattern
|
|
144
|
+
// "January 1st, 2026" format - we use regex matching
|
|
145
|
+
const where = [
|
|
146
|
+
`[${blockVar} :block/page ?daily-page]`,
|
|
147
|
+
`[?daily-page :node/title ?daily-title]`,
|
|
148
|
+
`[(re-find #"^(January|February|March|April|May|June|July|August|September|October|November|December) \\d{1,2}(st|nd|rd|th), \\d{4}$" ?daily-title)]`
|
|
149
|
+
];
|
|
150
|
+
return { where, inputs: [], inputValues: [] };
|
|
151
|
+
}
|
|
152
|
+
generateBy(user, blockVar) {
|
|
153
|
+
// "by" matches blocks created OR edited by the user
|
|
154
|
+
// Uses or-join to match either condition
|
|
155
|
+
const escapedUser = this.escapeString(user);
|
|
156
|
+
const where = [
|
|
157
|
+
`(or-join [${blockVar}]
|
|
158
|
+
(and [${blockVar} :create/user ?by-creator]
|
|
159
|
+
[?by-creator :user/display-name "${escapedUser}"])
|
|
160
|
+
(and [${blockVar} :edit/user ?by-editor]
|
|
161
|
+
[?by-editor :user/display-name "${escapedUser}"]))`
|
|
162
|
+
];
|
|
163
|
+
return { where, inputs: [], inputValues: [] };
|
|
164
|
+
}
|
|
165
|
+
generateUserClause(user, blockVar, attribute, varName) {
|
|
166
|
+
const where = [
|
|
167
|
+
`[${blockVar} ${attribute} ?${varName}]`,
|
|
168
|
+
`[?${varName} :user/display-name "${this.escapeString(user)}"]`
|
|
169
|
+
];
|
|
170
|
+
return { where, inputs: [], inputValues: [] };
|
|
171
|
+
}
|
|
172
|
+
generateCreatedBy(user, blockVar) {
|
|
173
|
+
return this.generateUserClause(user, blockVar, ':create/user', 'creator');
|
|
174
|
+
}
|
|
175
|
+
generateEditedBy(user, blockVar) {
|
|
176
|
+
return this.generateUserClause(user, blockVar, ':edit/user', 'editor');
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Convert Roam date string to Unix timestamp
|
|
180
|
+
* Handles:
|
|
181
|
+
* - Relative dates: "today", "yesterday", "last week", "last month", etc.
|
|
182
|
+
* - Roam format: "January 1st, 2026"
|
|
183
|
+
* - ISO format: "2026-01-01"
|
|
184
|
+
*/
|
|
185
|
+
roamDateToTimestamp(dateStr) {
|
|
186
|
+
const normalized = dateStr.toLowerCase().trim();
|
|
187
|
+
// Check for relative dates first
|
|
188
|
+
const relativeDate = this.parseRelativeDate(normalized);
|
|
189
|
+
if (relativeDate !== null) {
|
|
190
|
+
return relativeDate;
|
|
191
|
+
}
|
|
192
|
+
// Remove ordinal suffixes (st, nd, rd, th)
|
|
193
|
+
const cleaned = dateStr.replace(/(\d+)(st|nd|rd|th)/, '$1');
|
|
194
|
+
const date = new Date(cleaned);
|
|
195
|
+
if (isNaN(date.getTime())) {
|
|
196
|
+
// If parsing fails, try as ISO date
|
|
197
|
+
const isoDate = new Date(dateStr);
|
|
198
|
+
if (!isNaN(isoDate.getTime())) {
|
|
199
|
+
return isoDate.getTime();
|
|
200
|
+
}
|
|
201
|
+
throw new Error(`Cannot parse date: ${dateStr}`);
|
|
202
|
+
}
|
|
203
|
+
return date.getTime();
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Parse relative date strings like "today", "last week", "last month"
|
|
207
|
+
* Returns start-of-day timestamp or null if not a recognized relative date
|
|
208
|
+
*/
|
|
209
|
+
parseRelativeDate(dateStr) {
|
|
210
|
+
const now = new Date();
|
|
211
|
+
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
212
|
+
switch (dateStr) {
|
|
213
|
+
case 'today':
|
|
214
|
+
return startOfToday.getTime();
|
|
215
|
+
case 'yesterday':
|
|
216
|
+
return new Date(startOfToday.getTime() - 24 * 60 * 60 * 1000).getTime();
|
|
217
|
+
case 'tomorrow':
|
|
218
|
+
return new Date(startOfToday.getTime() + 24 * 60 * 60 * 1000).getTime();
|
|
219
|
+
case 'last week':
|
|
220
|
+
case 'a week ago':
|
|
221
|
+
return new Date(startOfToday.getTime() - 7 * 24 * 60 * 60 * 1000).getTime();
|
|
222
|
+
case 'this week': {
|
|
223
|
+
// Start of current week (Sunday)
|
|
224
|
+
const dayOfWeek = now.getDay();
|
|
225
|
+
return new Date(startOfToday.getTime() - dayOfWeek * 24 * 60 * 60 * 1000).getTime();
|
|
226
|
+
}
|
|
227
|
+
case 'next week':
|
|
228
|
+
return new Date(startOfToday.getTime() + 7 * 24 * 60 * 60 * 1000).getTime();
|
|
229
|
+
case 'last month':
|
|
230
|
+
case 'a month ago': {
|
|
231
|
+
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
|
|
232
|
+
return lastMonth.getTime();
|
|
233
|
+
}
|
|
234
|
+
case 'this month': {
|
|
235
|
+
// Start of current month
|
|
236
|
+
return new Date(now.getFullYear(), now.getMonth(), 1).getTime();
|
|
237
|
+
}
|
|
238
|
+
case 'next month': {
|
|
239
|
+
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
|
240
|
+
return nextMonth.getTime();
|
|
241
|
+
}
|
|
242
|
+
case 'last year':
|
|
243
|
+
case 'a year ago': {
|
|
244
|
+
const lastYear = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
|
|
245
|
+
return lastYear.getTime();
|
|
246
|
+
}
|
|
247
|
+
case 'this year': {
|
|
248
|
+
// Start of current year
|
|
249
|
+
return new Date(now.getFullYear(), 0, 1).getTime();
|
|
250
|
+
}
|
|
251
|
+
case 'next year': {
|
|
252
|
+
const nextYear = new Date(now.getFullYear() + 1, now.getMonth(), now.getDate());
|
|
253
|
+
return nextYear.getTime();
|
|
254
|
+
}
|
|
255
|
+
default:
|
|
256
|
+
// Check for "N days/weeks/months ago" pattern
|
|
257
|
+
const agoMatch = dateStr.match(/^(\d+)\s+(day|week|month|year)s?\s+ago$/);
|
|
258
|
+
if (agoMatch) {
|
|
259
|
+
const amount = parseInt(agoMatch[1], 10);
|
|
260
|
+
const unit = agoMatch[2];
|
|
261
|
+
return this.subtractFromDate(startOfToday, amount, unit);
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
subtractFromDate(date, amount, unit) {
|
|
267
|
+
switch (unit) {
|
|
268
|
+
case 'day':
|
|
269
|
+
return new Date(date.getTime() - amount * 24 * 60 * 60 * 1000).getTime();
|
|
270
|
+
case 'week':
|
|
271
|
+
return new Date(date.getTime() - amount * 7 * 24 * 60 * 60 * 1000).getTime();
|
|
272
|
+
case 'month':
|
|
273
|
+
return new Date(date.getFullYear(), date.getMonth() - amount, date.getDate()).getTime();
|
|
274
|
+
case 'year':
|
|
275
|
+
return new Date(date.getFullYear() - amount, date.getMonth(), date.getDate()).getTime();
|
|
276
|
+
default:
|
|
277
|
+
return date.getTime();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
escapeString(str) {
|
|
281
|
+
return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Build a complete Datalog query from generated clauses
|
|
286
|
+
*/
|
|
287
|
+
export function buildDatalogQuery(clauses, options = {}) {
|
|
288
|
+
const { select = ['?block-uid', '?block-str', '?page-title'], limit, offset = 0, orderBy, pageUid } = options;
|
|
289
|
+
// Build :in clause
|
|
290
|
+
let inClause = ':in $';
|
|
291
|
+
if (clauses.inputs.length > 0) {
|
|
292
|
+
inClause += ' ' + clauses.inputs.join(' ');
|
|
293
|
+
}
|
|
294
|
+
if (pageUid) {
|
|
295
|
+
inClause += ' ?target-page-uid';
|
|
296
|
+
}
|
|
297
|
+
// Build modifiers
|
|
298
|
+
const modifiers = [];
|
|
299
|
+
if (limit !== undefined && limit !== -1) {
|
|
300
|
+
modifiers.push(`:limit ${limit}`);
|
|
301
|
+
}
|
|
302
|
+
if (offset > 0) {
|
|
303
|
+
modifiers.push(`:offset ${offset}`);
|
|
304
|
+
}
|
|
305
|
+
if (orderBy) {
|
|
306
|
+
modifiers.push(`:order ${orderBy}`);
|
|
307
|
+
}
|
|
308
|
+
// Build base WHERE clauses
|
|
309
|
+
const baseClauses = [
|
|
310
|
+
'[?b :block/string ?block-str]',
|
|
311
|
+
'[?b :block/uid ?block-uid]',
|
|
312
|
+
'[?b :block/page ?p]',
|
|
313
|
+
'[?p :node/title ?page-title]'
|
|
314
|
+
];
|
|
315
|
+
if (pageUid) {
|
|
316
|
+
baseClauses.push('[?p :block/uid ?target-page-uid]');
|
|
317
|
+
}
|
|
318
|
+
// Combine all clauses
|
|
319
|
+
const allWhereClauses = [...baseClauses, ...clauses.where];
|
|
320
|
+
const query = `[:find ${select.join(' ')}
|
|
321
|
+
${inClause} ${modifiers.join(' ')}
|
|
322
|
+
:where
|
|
323
|
+
${allWhereClauses.join('\n ')}]`;
|
|
324
|
+
// Build args
|
|
325
|
+
const args = [...clauses.inputValues];
|
|
326
|
+
if (pageUid) {
|
|
327
|
+
args.push(pageUid);
|
|
328
|
+
}
|
|
329
|
+
return { query, args };
|
|
330
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Roam Query Block Parser and Executor
|
|
3
|
+
*
|
|
4
|
+
* Parses Roam query block syntax and executes as Datalog queries
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* import { QueryExecutor } from './query/index.js';
|
|
8
|
+
*
|
|
9
|
+
* const executor = new QueryExecutor(graph);
|
|
10
|
+
* const results = await executor.execute('{{[[query]]: {and: [[Project]] [[TODO]]}}}');
|
|
11
|
+
*/
|
|
12
|
+
export { QueryParser, QueryParseError } from './parser.js';
|
|
13
|
+
export { DatalogGenerator, buildDatalogQuery } from './generator.js';
|
|
14
|
+
import { q } from '@roam-research/roam-api-sdk';
|
|
15
|
+
import { QueryParser } from './parser.js';
|
|
16
|
+
import { DatalogGenerator, buildDatalogQuery } from './generator.js';
|
|
17
|
+
import { resolveRefs } from '../tools/helpers/refs.js';
|
|
18
|
+
export class QueryExecutor {
|
|
19
|
+
constructor(graph) {
|
|
20
|
+
this.graph = graph;
|
|
21
|
+
this.generator = new DatalogGenerator();
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Parse and execute a Roam query block
|
|
25
|
+
*
|
|
26
|
+
* @param queryBlock - The query block text, e.g., "{{[[query]]: {and: [[tag1]] [[tag2]]}}}"
|
|
27
|
+
* @param options - Execution options (limit, offset, pageUid)
|
|
28
|
+
* @returns Query results
|
|
29
|
+
*/
|
|
30
|
+
async execute(queryBlock, options = {}) {
|
|
31
|
+
try {
|
|
32
|
+
// Parse the query
|
|
33
|
+
const ast = QueryParser.parse(queryBlock);
|
|
34
|
+
// Generate Datalog clauses
|
|
35
|
+
const clauses = this.generator.generate(ast);
|
|
36
|
+
// Build the full query
|
|
37
|
+
const { query, args } = buildDatalogQuery(clauses, {
|
|
38
|
+
limit: options.limit,
|
|
39
|
+
offset: options.offset,
|
|
40
|
+
pageUid: options.pageUid,
|
|
41
|
+
orderBy: options.orderBy || '?block-uid asc'
|
|
42
|
+
});
|
|
43
|
+
// Execute query
|
|
44
|
+
const rawResults = await q(this.graph, query, args);
|
|
45
|
+
// Resolve block references in content
|
|
46
|
+
const resolvedResults = await Promise.all(rawResults.map(async ([uid, content, pageTitle]) => {
|
|
47
|
+
const resolvedContent = await resolveRefs(this.graph, content);
|
|
48
|
+
return {
|
|
49
|
+
block_uid: uid,
|
|
50
|
+
content: resolvedContent,
|
|
51
|
+
page_title: pageTitle
|
|
52
|
+
};
|
|
53
|
+
}));
|
|
54
|
+
// Get total count if pagination is used
|
|
55
|
+
let totalCount = resolvedResults.length;
|
|
56
|
+
if (options.limit !== undefined && options.limit !== -1) {
|
|
57
|
+
const countQuery = this.buildCountQuery(clauses, options.pageUid);
|
|
58
|
+
const countResults = await q(this.graph, countQuery.query, countQuery.args);
|
|
59
|
+
totalCount = countResults[0]?.[0] ?? 0;
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
success: true,
|
|
63
|
+
matches: resolvedResults,
|
|
64
|
+
message: `Found ${resolvedResults.length} block(s) matching query`,
|
|
65
|
+
total_count: totalCount,
|
|
66
|
+
query // Include for debugging
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
return {
|
|
71
|
+
success: false,
|
|
72
|
+
matches: [],
|
|
73
|
+
message: error instanceof Error ? error.message : String(error)
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Parse a query block without executing (for validation/debugging)
|
|
79
|
+
*/
|
|
80
|
+
parse(queryBlock) {
|
|
81
|
+
const ast = QueryParser.parse(queryBlock);
|
|
82
|
+
const clauses = this.generator.generate(ast);
|
|
83
|
+
const datalog = buildDatalogQuery(clauses);
|
|
84
|
+
return { ast, datalog };
|
|
85
|
+
}
|
|
86
|
+
buildCountQuery(clauses, pageUid) {
|
|
87
|
+
let inClause = ':in $';
|
|
88
|
+
if (clauses.inputs.length > 0) {
|
|
89
|
+
inClause += ' ' + clauses.inputs.join(' ');
|
|
90
|
+
}
|
|
91
|
+
if (pageUid) {
|
|
92
|
+
inClause += ' ?target-page-uid';
|
|
93
|
+
}
|
|
94
|
+
const baseClauses = [
|
|
95
|
+
'[?b :block/string ?block-str]',
|
|
96
|
+
'[?b :block/uid ?block-uid]',
|
|
97
|
+
'[?b :block/page ?p]'
|
|
98
|
+
];
|
|
99
|
+
if (pageUid) {
|
|
100
|
+
baseClauses.push('[?p :block/uid ?target-page-uid]');
|
|
101
|
+
}
|
|
102
|
+
const allWhereClauses = [...baseClauses, ...clauses.where];
|
|
103
|
+
const query = `[:find (count ?b)
|
|
104
|
+
${inClause}
|
|
105
|
+
:where
|
|
106
|
+
${allWhereClauses.join('\n ')}]`;
|
|
107
|
+
const args = [...clauses.inputValues];
|
|
108
|
+
if (pageUid) {
|
|
109
|
+
args.push(pageUid);
|
|
110
|
+
}
|
|
111
|
+
return { query, args };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Detect if a block string contains a query block
|
|
116
|
+
*/
|
|
117
|
+
export function isQueryBlock(text) {
|
|
118
|
+
return /^\s*\{\{\[\[query\]\]:/i.test(text);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Extract all query blocks from a string (handles nested braces)
|
|
122
|
+
*/
|
|
123
|
+
export function extractQueryBlocks(text) {
|
|
124
|
+
const matches = [];
|
|
125
|
+
const prefix = '{{[[query]]:';
|
|
126
|
+
let startIdx = 0;
|
|
127
|
+
while (startIdx < text.length) {
|
|
128
|
+
const foundIdx = text.indexOf(prefix, startIdx);
|
|
129
|
+
if (foundIdx === -1)
|
|
130
|
+
break;
|
|
131
|
+
// Find matching closing }}
|
|
132
|
+
let depth = 2; // We've seen {{
|
|
133
|
+
let pos = foundIdx + prefix.length;
|
|
134
|
+
while (pos < text.length && depth > 0) {
|
|
135
|
+
if (text[pos] === '{') {
|
|
136
|
+
depth++;
|
|
137
|
+
}
|
|
138
|
+
else if (text[pos] === '}') {
|
|
139
|
+
depth--;
|
|
140
|
+
}
|
|
141
|
+
pos++;
|
|
142
|
+
}
|
|
143
|
+
if (depth === 0) {
|
|
144
|
+
matches.push(text.slice(foundIdx, pos));
|
|
145
|
+
}
|
|
146
|
+
startIdx = pos;
|
|
147
|
+
}
|
|
148
|
+
return matches;
|
|
149
|
+
}
|