roam-research-mcp 2.4.3 → 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 -669
- package/build/Roam_Markdown_Cheatsheet.md +24 -4
- package/build/cache/page-uid-cache.js +40 -2
- package/build/cli/batch/translator.js +1 -1
- package/build/cli/commands/batch.js +2 -0
- package/build/cli/commands/get.js +401 -14
- package/build/cli/commands/refs.js +2 -0
- package/build/cli/commands/save.js +56 -1
- package/build/cli/commands/search.js +45 -0
- package/build/cli/commands/status.js +3 -4
- package/build/cli/utils/graph.js +6 -2
- 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 +2 -1
- 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 +29 -91
- 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 +14 -8
- package/build/tools/tool-handlers.js +2 -2
- package/build/utils/helpers.js +27 -0
- package/package.json +1 -1
package/build/markdown-utils.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from 'crypto';
|
|
2
|
+
// Regex patterns for markdown elements
|
|
3
|
+
const NUMBERED_LIST_REGEX = /^(\s*)\d+\.\s+(.*)$/;
|
|
4
|
+
const HORIZONTAL_RULE_REGEX = /^(\s*)(-{3,}|\*{3,}|_{3,})\s*$/;
|
|
2
5
|
/**
|
|
3
6
|
* Check if text has a traditional markdown table
|
|
4
7
|
*/
|
|
@@ -61,6 +64,21 @@ function parseMarkdownHeadingLevel(text) {
|
|
|
61
64
|
};
|
|
62
65
|
}
|
|
63
66
|
function convertToRoamMarkdown(text) {
|
|
67
|
+
// Protect inline code and code blocks from transformation
|
|
68
|
+
const codeBlocks = [];
|
|
69
|
+
// Use null bytes to create a unique placeholder that won't be transformed
|
|
70
|
+
const PLACEHOLDER_START = '\x00\x01CB';
|
|
71
|
+
const PLACEHOLDER_END = '\x02\x00';
|
|
72
|
+
// Extract code blocks (``` ... ```) first
|
|
73
|
+
text = text.replace(/```[\s\S]*?```/g, (match) => {
|
|
74
|
+
codeBlocks.push(match);
|
|
75
|
+
return `${PLACEHOLDER_START}${codeBlocks.length - 1}${PLACEHOLDER_END}`;
|
|
76
|
+
});
|
|
77
|
+
// Extract inline code (` ... `)
|
|
78
|
+
text = text.replace(/`[^`]+`/g, (match) => {
|
|
79
|
+
codeBlocks.push(match);
|
|
80
|
+
return `${PLACEHOLDER_START}${codeBlocks.length - 1}${PLACEHOLDER_END}`;
|
|
81
|
+
});
|
|
64
82
|
// Handle double asterisks/underscores (bold)
|
|
65
83
|
text = text.replace(/\*\*(.+?)\*\*/g, '**$1**'); // Preserve double asterisks
|
|
66
84
|
// Handle single asterisks/underscores (italic)
|
|
@@ -73,6 +91,10 @@ function convertToRoamMarkdown(text) {
|
|
|
73
91
|
text = text.replace(/- \[x\]/g, '- {{[[DONE]]}}');
|
|
74
92
|
// Convert tables
|
|
75
93
|
text = convertAllTables(text);
|
|
94
|
+
// Restore protected code blocks
|
|
95
|
+
text = text.replace(new RegExp(`${PLACEHOLDER_START}(\\d+)${PLACEHOLDER_END}`, 'g'), (_, index) => {
|
|
96
|
+
return codeBlocks[parseInt(index, 10)];
|
|
97
|
+
});
|
|
76
98
|
return text;
|
|
77
99
|
}
|
|
78
100
|
function parseMarkdown(markdown) {
|
|
@@ -108,8 +130,13 @@ function parseMarkdown(markdown) {
|
|
|
108
130
|
}
|
|
109
131
|
if (inCodeBlockFirstPass || trimmedLine === '')
|
|
110
132
|
continue;
|
|
133
|
+
// Check for numbered list, bullet list, or plain line
|
|
134
|
+
const numberedMatch = line.match(NUMBERED_LIST_REGEX);
|
|
111
135
|
const bulletMatch = trimmedLine.match(/^(\s*)[-*+]\s+/);
|
|
112
|
-
if (
|
|
136
|
+
if (numberedMatch) {
|
|
137
|
+
indentationSet.add(numberedMatch[1].length);
|
|
138
|
+
}
|
|
139
|
+
else if (bulletMatch) {
|
|
113
140
|
indentationSet.add(bulletMatch[1].length);
|
|
114
141
|
}
|
|
115
142
|
else {
|
|
@@ -214,10 +241,42 @@ function parseMarkdown(markdown) {
|
|
|
214
241
|
if (trimmedLine === '') {
|
|
215
242
|
continue;
|
|
216
243
|
}
|
|
244
|
+
// Check for horizontal rule (---, ***, ___)
|
|
245
|
+
const hrMatch = line.match(HORIZONTAL_RULE_REGEX);
|
|
246
|
+
if (hrMatch) {
|
|
247
|
+
const hrIndentation = hrMatch[1].length;
|
|
248
|
+
const hrLevel = getLevel(hrIndentation);
|
|
249
|
+
const hrNode = {
|
|
250
|
+
content: '---', // Roam's HR format
|
|
251
|
+
level: hrLevel,
|
|
252
|
+
is_hr: true,
|
|
253
|
+
children: []
|
|
254
|
+
};
|
|
255
|
+
while (stack.length > hrLevel) {
|
|
256
|
+
stack.pop();
|
|
257
|
+
}
|
|
258
|
+
if (hrLevel === 0 || !stack[hrLevel - 1]) {
|
|
259
|
+
rootNodes.push(hrNode);
|
|
260
|
+
stack[0] = hrNode;
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
stack[hrLevel - 1].children.push(hrNode);
|
|
264
|
+
}
|
|
265
|
+
stack[hrLevel] = hrNode;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
217
268
|
let indentation;
|
|
218
269
|
let contentToParse;
|
|
270
|
+
let isNumberedItem = false;
|
|
271
|
+
// Check for numbered list item (1., 2., etc.)
|
|
272
|
+
const numberedMatch = line.match(NUMBERED_LIST_REGEX);
|
|
219
273
|
const bulletMatch = trimmedLine.match(/^(\s*)[-*+]\s+/);
|
|
220
|
-
if (
|
|
274
|
+
if (numberedMatch) {
|
|
275
|
+
indentation = numberedMatch[1].length;
|
|
276
|
+
contentToParse = numberedMatch[2];
|
|
277
|
+
isNumberedItem = true;
|
|
278
|
+
}
|
|
279
|
+
else if (bulletMatch) {
|
|
221
280
|
indentation = bulletMatch[1].length;
|
|
222
281
|
contentToParse = trimmedLine.substring(bulletMatch[0].length);
|
|
223
282
|
}
|
|
@@ -239,9 +298,16 @@ function parseMarkdown(markdown) {
|
|
|
239
298
|
if (level === 0 || !stack[level - 1]) {
|
|
240
299
|
rootNodes.push(node);
|
|
241
300
|
stack[0] = node;
|
|
301
|
+
// Root-level numbered items: no parent to set view type on
|
|
302
|
+
// They'll appear as regular blocks (Roam doesn't support numbered view at root)
|
|
242
303
|
}
|
|
243
304
|
else {
|
|
244
|
-
stack[level - 1]
|
|
305
|
+
const parent = stack[level - 1];
|
|
306
|
+
parent.children.push(node);
|
|
307
|
+
// If this is the first numbered item under a parent, set parent's view type
|
|
308
|
+
if (isNumberedItem && parent.children_view_type !== 'numbered') {
|
|
309
|
+
parent.children_view_type = 'numbered';
|
|
310
|
+
}
|
|
245
311
|
}
|
|
246
312
|
stack[level] = node;
|
|
247
313
|
}
|
|
@@ -302,6 +368,7 @@ function convertNodesToBlocks(nodes) {
|
|
|
302
368
|
uid: generateBlockUid(),
|
|
303
369
|
content: node.content,
|
|
304
370
|
...(node.heading_level && { heading_level: node.heading_level }), // Preserve heading level if present
|
|
371
|
+
...(node.children_view_type && { children_view_type: node.children_view_type }), // Preserve view type for numbered lists
|
|
305
372
|
children: convertNodesToBlocks(node.children)
|
|
306
373
|
}));
|
|
307
374
|
}
|
|
@@ -338,5 +405,48 @@ function convertToRoamActions(nodes, parentUid, order = 'last') {
|
|
|
338
405
|
createBlockActions(blocks, parentUid, order);
|
|
339
406
|
return actions;
|
|
340
407
|
}
|
|
408
|
+
/**
|
|
409
|
+
* Converts markdown nodes to Roam batch actions, grouped by nesting level.
|
|
410
|
+
* This ensures parent blocks exist before child blocks are created.
|
|
411
|
+
* Returns an array of action arrays, where index 0 contains root-level actions,
|
|
412
|
+
* index 1 contains first-level child actions, etc.
|
|
413
|
+
*/
|
|
414
|
+
function convertToRoamActionsStaged(nodes, parentUid, order = 'last') {
|
|
415
|
+
// First convert nodes to blocks with UIDs
|
|
416
|
+
const blocks = convertNodesToBlocks(nodes);
|
|
417
|
+
const actionsByLevel = [];
|
|
418
|
+
// Helper function to recursively create actions, tracking depth
|
|
419
|
+
function createBlockActions(blocks, parentUid, order, depth) {
|
|
420
|
+
// Ensure array exists for this depth
|
|
421
|
+
if (!actionsByLevel[depth]) {
|
|
422
|
+
actionsByLevel[depth] = [];
|
|
423
|
+
}
|
|
424
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
425
|
+
const block = blocks[i];
|
|
426
|
+
// Create the current block
|
|
427
|
+
const action = {
|
|
428
|
+
action: 'create-block',
|
|
429
|
+
location: {
|
|
430
|
+
'parent-uid': parentUid,
|
|
431
|
+
order: typeof order === 'number' ? order + i : i
|
|
432
|
+
},
|
|
433
|
+
block: {
|
|
434
|
+
uid: block.uid,
|
|
435
|
+
string: block.content,
|
|
436
|
+
...(block.heading_level && { heading: block.heading_level }),
|
|
437
|
+
...(block.children_view_type && { 'children-view-type': block.children_view_type })
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
actionsByLevel[depth].push(action);
|
|
441
|
+
// Create child blocks if any
|
|
442
|
+
if (block.children.length > 0) {
|
|
443
|
+
createBlockActions(block.children, block.uid, 'last', depth + 1);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
// Create all block actions starting at depth 0
|
|
448
|
+
createBlockActions(blocks, parentUid, order, 0);
|
|
449
|
+
return actionsByLevel;
|
|
450
|
+
}
|
|
341
451
|
// Export public functions and types
|
|
342
|
-
export { parseMarkdown, convertToRoamActions, hasMarkdownTable, convertAllTables, convertToRoamMarkdown, parseMarkdownHeadingLevel };
|
|
452
|
+
export { parseMarkdown, convertToRoamActions, convertToRoamActionsStaged, hasMarkdownTable, convertAllTables, convertToRoamMarkdown, parseMarkdownHeadingLevel };
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseMarkdown, convertToRoamActions } from './markdown-utils.js';
|
|
3
|
+
describe('markdown-utils', () => {
|
|
4
|
+
describe('parseMarkdown - numbered lists', () => {
|
|
5
|
+
it('should detect numbered list items and strip prefixes', () => {
|
|
6
|
+
const markdown = `1. First item
|
|
7
|
+
2. Second item
|
|
8
|
+
3. Third item`;
|
|
9
|
+
const nodes = parseMarkdown(markdown);
|
|
10
|
+
expect(nodes).toHaveLength(3);
|
|
11
|
+
expect(nodes[0].content).toBe('First item');
|
|
12
|
+
expect(nodes[1].content).toBe('Second item');
|
|
13
|
+
expect(nodes[2].content).toBe('Third item');
|
|
14
|
+
// Root-level numbered items don't get children_view_type (no parent)
|
|
15
|
+
expect(nodes[0].children_view_type).toBeUndefined();
|
|
16
|
+
});
|
|
17
|
+
it('should set children_view_type: numbered on parent of numbered items', () => {
|
|
18
|
+
const markdown = `Parent block
|
|
19
|
+
1. First numbered
|
|
20
|
+
2. Second numbered`;
|
|
21
|
+
const nodes = parseMarkdown(markdown);
|
|
22
|
+
expect(nodes).toHaveLength(1);
|
|
23
|
+
expect(nodes[0].content).toBe('Parent block');
|
|
24
|
+
expect(nodes[0].children_view_type).toBe('numbered');
|
|
25
|
+
expect(nodes[0].children).toHaveLength(2);
|
|
26
|
+
expect(nodes[0].children[0].content).toBe('First numbered');
|
|
27
|
+
expect(nodes[0].children[1].content).toBe('Second numbered');
|
|
28
|
+
});
|
|
29
|
+
it('should handle nested numbered lists', () => {
|
|
30
|
+
const markdown = `- Parent
|
|
31
|
+
1. First
|
|
32
|
+
2. Second
|
|
33
|
+
1. Nested first
|
|
34
|
+
2. Nested second`;
|
|
35
|
+
const nodes = parseMarkdown(markdown);
|
|
36
|
+
expect(nodes).toHaveLength(1);
|
|
37
|
+
expect(nodes[0].content).toBe('Parent');
|
|
38
|
+
expect(nodes[0].children_view_type).toBe('numbered');
|
|
39
|
+
expect(nodes[0].children).toHaveLength(2);
|
|
40
|
+
expect(nodes[0].children[1].children_view_type).toBe('numbered');
|
|
41
|
+
expect(nodes[0].children[1].children).toHaveLength(2);
|
|
42
|
+
});
|
|
43
|
+
it('should handle mixed bullet and numbered lists', () => {
|
|
44
|
+
const markdown = `- Bullet item
|
|
45
|
+
1. Numbered item
|
|
46
|
+
- Another bullet`;
|
|
47
|
+
const nodes = parseMarkdown(markdown);
|
|
48
|
+
expect(nodes).toHaveLength(3);
|
|
49
|
+
expect(nodes[0].content).toBe('Bullet item');
|
|
50
|
+
expect(nodes[1].content).toBe('Numbered item');
|
|
51
|
+
expect(nodes[2].content).toBe('Another bullet');
|
|
52
|
+
});
|
|
53
|
+
it('should handle double-digit numbers', () => {
|
|
54
|
+
const markdown = `10. Tenth item
|
|
55
|
+
11. Eleventh item
|
|
56
|
+
99. Ninety-ninth item`;
|
|
57
|
+
const nodes = parseMarkdown(markdown);
|
|
58
|
+
expect(nodes).toHaveLength(3);
|
|
59
|
+
expect(nodes[0].content).toBe('Tenth item');
|
|
60
|
+
expect(nodes[1].content).toBe('Eleventh item');
|
|
61
|
+
expect(nodes[2].content).toBe('Ninety-ninth item');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
describe('parseMarkdown - horizontal rules', () => {
|
|
65
|
+
it('should convert --- to Roam HR', () => {
|
|
66
|
+
const markdown = `Before
|
|
67
|
+
---
|
|
68
|
+
After`;
|
|
69
|
+
const nodes = parseMarkdown(markdown);
|
|
70
|
+
expect(nodes).toHaveLength(3);
|
|
71
|
+
expect(nodes[0].content).toBe('Before');
|
|
72
|
+
expect(nodes[1].content).toBe('---');
|
|
73
|
+
expect(nodes[1].is_hr).toBe(true);
|
|
74
|
+
expect(nodes[2].content).toBe('After');
|
|
75
|
+
});
|
|
76
|
+
it('should convert *** to Roam HR', () => {
|
|
77
|
+
const markdown = `Before
|
|
78
|
+
***
|
|
79
|
+
After`;
|
|
80
|
+
const nodes = parseMarkdown(markdown);
|
|
81
|
+
expect(nodes).toHaveLength(3);
|
|
82
|
+
expect(nodes[1].content).toBe('---');
|
|
83
|
+
expect(nodes[1].is_hr).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
it('should convert ___ to Roam HR', () => {
|
|
86
|
+
const markdown = `Before
|
|
87
|
+
___
|
|
88
|
+
After`;
|
|
89
|
+
const nodes = parseMarkdown(markdown);
|
|
90
|
+
expect(nodes).toHaveLength(3);
|
|
91
|
+
expect(nodes[1].content).toBe('---');
|
|
92
|
+
expect(nodes[1].is_hr).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
it('should handle longer HR variants', () => {
|
|
95
|
+
const markdown = `-----
|
|
96
|
+
*****
|
|
97
|
+
_______`;
|
|
98
|
+
const nodes = parseMarkdown(markdown);
|
|
99
|
+
expect(nodes).toHaveLength(3);
|
|
100
|
+
expect(nodes[0].content).toBe('---');
|
|
101
|
+
expect(nodes[1].content).toBe('---');
|
|
102
|
+
expect(nodes[2].content).toBe('---');
|
|
103
|
+
});
|
|
104
|
+
it('should not convert inline dashes', () => {
|
|
105
|
+
const markdown = `This has -- some dashes
|
|
106
|
+
And this--too`;
|
|
107
|
+
const nodes = parseMarkdown(markdown);
|
|
108
|
+
expect(nodes).toHaveLength(2);
|
|
109
|
+
expect(nodes[0].is_hr).toBeUndefined();
|
|
110
|
+
expect(nodes[1].is_hr).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
describe('convertToRoamActions - numbered lists', () => {
|
|
114
|
+
it('should include children-view-type in block action', () => {
|
|
115
|
+
const markdown = `Parent
|
|
116
|
+
1. First
|
|
117
|
+
2. Second`;
|
|
118
|
+
const nodes = parseMarkdown(markdown);
|
|
119
|
+
const actions = convertToRoamActions(nodes, 'test-page-uid');
|
|
120
|
+
// First action is the parent block
|
|
121
|
+
const parentAction = actions[0];
|
|
122
|
+
expect(parentAction.block['children-view-type']).toBe('numbered');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -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
|
+
}
|