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
|
@@ -69,6 +69,13 @@ const x = 1;
|
|
|
69
69
|
### Calculator
|
|
70
70
|
`{{[[calc]]: 2 + 2}}`
|
|
71
71
|
|
|
72
|
+
### Codeblock
|
|
73
|
+
Roam uses "shell" not "bash". Other common languages okay, including "plain text".
|
|
74
|
+
```shell
|
|
75
|
+
echo "Hello world!"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
|
|
72
79
|
## Complex Structures
|
|
73
80
|
|
|
74
81
|
### Tables
|
|
@@ -250,6 +257,11 @@ ASK YOURSELF:
|
|
|
250
257
|
| `#single-word` | Simple, unambiguous category |
|
|
251
258
|
| Attribute `Type::` | Structured metadata for queries |
|
|
252
259
|
|
|
260
|
+
### WHEN creating Endnotes/Footnotes:
|
|
261
|
+
- Find/Create the block with heading "Footnotes::" and nest footnote item below. (Footnotes do not need to be on the same page as the block to which it references. Typically on the same page unless instructed otherwise.)
|
|
262
|
+
- If not known, retrieve the block_uid reference for this footnote item.
|
|
263
|
+
- In the block referencing the footnote, append the reference with footnote-item-block_id, example: "- <block_text> #ref ((block_uid))"
|
|
264
|
+
|
|
253
265
|
### Structural Tagging (Beyond Content)
|
|
254
266
|
|
|
255
267
|
Tag by **patterns and mechanisms**, not just subjects:
|
|
@@ -295,6 +307,9 @@ Always include 2-3 relevant hashtags after quotes.
|
|
|
295
307
|
```
|
|
296
308
|
|
|
297
309
|
### Scheduled Reviews
|
|
310
|
+
|
|
311
|
+
- Any block tagged with a date will show on that respective daily page.
|
|
312
|
+
|
|
298
313
|
```
|
|
299
314
|
[[For review]]: [[Date in ordinal format]]
|
|
300
315
|
```
|
|
@@ -321,6 +336,7 @@ When a tag would awkwardly affect sentence capitalization:
|
|
|
321
336
|
- **Use inconsistent capitalization** — Tags are lowercase unless proper nouns
|
|
322
337
|
- **Create orphan tags** — Check if existing page/tag serves the purpose
|
|
323
338
|
- **Bold Attributes** - ❌ `**Attribute**::`, ✅ `Attribute::` (Roam auto-formats)
|
|
339
|
+
- **Separators** - `---` Don't use them.
|
|
324
340
|
|
|
325
341
|
### DO
|
|
326
342
|
- **Think retrieval-first** — How will you search for this later?
|
|
@@ -345,7 +361,7 @@ CUSTOMIZE THIS SECTION with your specific conventions:
|
|
|
345
361
|
|
|
346
362
|
**Books:**
|
|
347
363
|
```
|
|
348
|
-
[[Book
|
|
364
|
+
[[Book/<title> | <author>]]
|
|
349
365
|
Type:: Book
|
|
350
366
|
Author:: [[Author Name]]
|
|
351
367
|
Status:: Reading | Completed | Abandoned
|
|
@@ -358,14 +374,18 @@ Rating:: X/5
|
|
|
358
374
|
Type:: Person
|
|
359
375
|
Context:: How I know them
|
|
360
376
|
```
|
|
361
|
-
|
|
377
|
+
- When linking bibliographic references —>
|
|
378
|
+
Example: `McAdams, D.P. (2001) [The Psychology of Life Stories](https://journals.sagepub.com/doi/10.1037/1089-2680.5.2.100) — foundational paper`
|
|
379
|
+
- [McAdams, D.P.]([[Dan McAdams]]) - author's name in the graph
|
|
380
|
+
- If source URL, link to source: [The Psychology of Life Stories](https://journals.sagepub.com/doi/10.1037/1089-2680.5.2.100)
|
|
381
|
+
- If notes page exists or will exist in Roam: append ` | [Notes]([[Article/The Psychology of Life Stories]]), if not, just leave it without link.
|
|
382
|
+
|
|
362
383
|
**Projects:**
|
|
363
384
|
```
|
|
364
|
-
[[Project
|
|
385
|
+
[[Project/<project anme>]]
|
|
365
386
|
Status:: Active | Paused | Completed
|
|
366
387
|
Start:: [[Date]]
|
|
367
388
|
```
|
|
368
|
-
|
|
369
389
|
---
|
|
370
390
|
|
|
371
391
|
## Integration Notes
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Simple in-memory cache for page title -> UID mappings.
|
|
2
|
+
* Simple in-memory cache for page title -> UID mappings and UID existence tracking.
|
|
3
3
|
* Pages are stable entities that rarely get deleted, making them safe to cache.
|
|
4
4
|
* This reduces redundant API queries when looking up the same page multiple times.
|
|
5
|
+
*
|
|
6
|
+
* The cache tracks two things:
|
|
7
|
+
* 1. Title -> UID mappings (for getPageUid lookups)
|
|
8
|
+
* 2. Known existing UIDs (for existence validation before batch operations)
|
|
5
9
|
*/
|
|
6
10
|
class PageUidCache {
|
|
7
11
|
constructor() {
|
|
8
12
|
this.cache = new Map(); // title (lowercase) -> UID
|
|
13
|
+
this.knownUids = new Set(); // UIDs confirmed to exist
|
|
9
14
|
}
|
|
10
15
|
/**
|
|
11
16
|
* Get a cached page UID by title.
|
|
@@ -17,11 +22,13 @@ class PageUidCache {
|
|
|
17
22
|
}
|
|
18
23
|
/**
|
|
19
24
|
* Cache a page title -> UID mapping.
|
|
25
|
+
* Also marks the UID as known to exist.
|
|
20
26
|
* @param title - Page title (will be stored lowercase)
|
|
21
27
|
* @param uid - Page UID
|
|
22
28
|
*/
|
|
23
29
|
set(title, uid) {
|
|
24
30
|
this.cache.set(title.toLowerCase(), uid);
|
|
31
|
+
this.knownUids.add(uid);
|
|
25
32
|
}
|
|
26
33
|
/**
|
|
27
34
|
* Check if a page title is cached.
|
|
@@ -30,6 +37,30 @@ class PageUidCache {
|
|
|
30
37
|
has(title) {
|
|
31
38
|
return this.cache.has(title.toLowerCase());
|
|
32
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Check if a UID is known to exist.
|
|
42
|
+
* @param uid - Page or block UID
|
|
43
|
+
*/
|
|
44
|
+
hasUid(uid) {
|
|
45
|
+
return this.knownUids.has(uid);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Mark a UID as known to exist (without title mapping).
|
|
49
|
+
* Use this when you've verified a UID exists but don't know/need its title.
|
|
50
|
+
* @param uid - Page or block UID
|
|
51
|
+
*/
|
|
52
|
+
addUid(uid) {
|
|
53
|
+
this.knownUids.add(uid);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Mark multiple UIDs as known to exist.
|
|
57
|
+
* @param uids - Array of page or block UIDs
|
|
58
|
+
*/
|
|
59
|
+
addUids(uids) {
|
|
60
|
+
for (const uid of uids) {
|
|
61
|
+
this.knownUids.add(uid);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
33
64
|
/**
|
|
34
65
|
* Called when a page is created - immediately add to cache.
|
|
35
66
|
* @param title - Page title
|
|
@@ -43,13 +74,20 @@ class PageUidCache {
|
|
|
43
74
|
*/
|
|
44
75
|
clear() {
|
|
45
76
|
this.cache.clear();
|
|
77
|
+
this.knownUids.clear();
|
|
46
78
|
}
|
|
47
79
|
/**
|
|
48
|
-
* Get the current cache size.
|
|
80
|
+
* Get the current title cache size.
|
|
49
81
|
*/
|
|
50
82
|
get size() {
|
|
51
83
|
return this.cache.size;
|
|
52
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Get the current known UIDs cache size.
|
|
87
|
+
*/
|
|
88
|
+
get uidCacheSize() {
|
|
89
|
+
return this.knownUids.size;
|
|
90
|
+
}
|
|
53
91
|
}
|
|
54
92
|
// Singleton instance - shared across all operations
|
|
55
93
|
export const pageUidCache = new PageUidCache();
|
|
@@ -296,7 +296,7 @@ function translateRemember(cmd, context) {
|
|
|
296
296
|
const tags = params.categories.map(cat => `#[[${cat}]]`).join(' ');
|
|
297
297
|
memoryText = `${params.text} ${tags}`;
|
|
298
298
|
}
|
|
299
|
-
// Add
|
|
299
|
+
// Add ROAM_MEMORIES_TAG if configured (we'll handle this in the CLI command)
|
|
300
300
|
// For now, just create the block
|
|
301
301
|
let parentUid;
|
|
302
302
|
// If heading is specified, we'd need to look it up or create it
|
|
@@ -160,6 +160,8 @@ Example:
|
|
|
160
160
|
{"command": "outline", "params": {"parent": "{{overview}}", "items": ["Goal 1", "Goal 2"]}},
|
|
161
161
|
{"command": "todo", "params": {"text": "Review project"}}
|
|
162
162
|
]
|
|
163
|
+
|
|
164
|
+
Output (JSON): { success, pages_created, actions_executed, uid_map? }
|
|
163
165
|
`)
|
|
164
166
|
.action(async (file, options) => {
|
|
165
167
|
try {
|
|
@@ -2,19 +2,24 @@ import { Command } from 'commander';
|
|
|
2
2
|
import { PageOperations } from '../../tools/operations/pages.js';
|
|
3
3
|
import { BlockRetrievalOperations } from '../../tools/operations/block-retrieval.js';
|
|
4
4
|
import { SearchOperations } from '../../tools/operations/search/index.js';
|
|
5
|
-
import { formatPageOutput, formatBlockOutput, formatTodoOutput, printDebug, exitWithError } from '../utils/output.js';
|
|
5
|
+
import { formatPageOutput, formatBlockOutput, formatTodoOutput, formatGroupedOutput, flattenBlocks, blocksToMarkdown, printDebug, exitWithError } from '../utils/output.js';
|
|
6
6
|
import { resolveGraph } from '../utils/graph.js';
|
|
7
7
|
import { readStdin } from '../utils/input.js';
|
|
8
8
|
import { resolveRefs } from '../../tools/helpers/refs.js';
|
|
9
|
-
import { resolveRelativeDate } from '../../utils/helpers.js';
|
|
9
|
+
import { resolveRelativeDate, parseRoamUrl, isRoamUid } from '../../utils/helpers.js';
|
|
10
|
+
import { SearchUtils } from '../../search/utils.js';
|
|
11
|
+
import { sortResults, groupResults, getDefaultDirection } from '../utils/sort-group.js';
|
|
10
12
|
// Block UID pattern: 9 alphanumeric characters, optionally wrapped in (( ))
|
|
11
13
|
const BLOCK_UID_PATTERN = /^(?:\(\()?([a-zA-Z0-9_-]{9})(?:\)\))?$/;
|
|
12
14
|
/**
|
|
13
15
|
* Recursively resolve block references in a RoamBlock tree
|
|
14
16
|
*/
|
|
15
|
-
async function
|
|
16
|
-
|
|
17
|
-
const
|
|
17
|
+
async function resolveBlockRefsInTree(graph, block, maxDepth) {
|
|
18
|
+
// Only resolve if string is valid
|
|
19
|
+
const resolvedString = typeof block.string === 'string'
|
|
20
|
+
? await resolveRefs(graph, block.string, 0, maxDepth)
|
|
21
|
+
: block.string || '';
|
|
22
|
+
const resolvedChildren = await Promise.all((block.children || []).map(child => resolveBlockRefsInTree(graph, child, maxDepth)));
|
|
18
23
|
return {
|
|
19
24
|
...block,
|
|
20
25
|
string: resolvedString,
|
|
@@ -24,22 +29,186 @@ async function resolveBlockRefs(graph, block, maxDepth) {
|
|
|
24
29
|
/**
|
|
25
30
|
* Resolve refs in an array of blocks
|
|
26
31
|
*/
|
|
27
|
-
async function
|
|
28
|
-
return Promise.all(blocks.map(block =>
|
|
32
|
+
async function resolveBlocksRefsInTree(graph, blocks, maxDepth) {
|
|
33
|
+
return Promise.all(blocks.map(block => resolveBlockRefsInTree(graph, block, maxDepth)));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Normalize a tag by stripping #, [[, ]] wrappers
|
|
37
|
+
*/
|
|
38
|
+
function normalizeTag(tag) {
|
|
39
|
+
return tag.replace(/^#?\[?\[?/, '').replace(/\]?\]?$/, '');
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Check if content contains a tag (handles #tag, [[tag]], #[[tag]] formats)
|
|
43
|
+
* Case-insensitive matching.
|
|
44
|
+
*/
|
|
45
|
+
function contentHasTag(content, tag) {
|
|
46
|
+
const normalized = normalizeTag(tag).toLowerCase();
|
|
47
|
+
const lowerContent = content.toLowerCase();
|
|
48
|
+
return (lowerContent.includes(`[[${normalized}]]`) ||
|
|
49
|
+
lowerContent.includes(`#${normalized}`) ||
|
|
50
|
+
lowerContent.includes(`#[[${normalized}]]`));
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Create the 'page' subcommand for explicit page retrieval
|
|
54
|
+
*/
|
|
55
|
+
function createPageSubcommand() {
|
|
56
|
+
return new Command('page')
|
|
57
|
+
.description('Fetch a page by UID, URL, or title')
|
|
58
|
+
.argument('<identifier>', 'Page UID, Roam URL, or page title')
|
|
59
|
+
.option('-j, --json', 'Output as JSON instead of markdown')
|
|
60
|
+
.option('-d, --depth <n>', 'Child levels to fetch (default: 4)', '4')
|
|
61
|
+
.option('-r, --refs [n]', 'Expand ((uid)) refs in output (default depth: 1, max: 4)')
|
|
62
|
+
.option('-f, --flat', 'Flatten hierarchy to single-level list')
|
|
63
|
+
.option('-u, --uid', 'Return only the page UID')
|
|
64
|
+
.option('-g, --graph <name>', 'Target graph key (multi-graph mode)')
|
|
65
|
+
.option('--debug', 'Show query metadata')
|
|
66
|
+
.addHelpText('after', `
|
|
67
|
+
Examples:
|
|
68
|
+
# By page title
|
|
69
|
+
roam get page "Project Notes"
|
|
70
|
+
roam get page "January 10th, 2026"
|
|
71
|
+
|
|
72
|
+
# By page UID
|
|
73
|
+
roam get page abc123def
|
|
74
|
+
|
|
75
|
+
# By Roam URL (copy from browser)
|
|
76
|
+
roam get page "https://roamresearch.com/#/app/my-graph/page/abc123def"
|
|
77
|
+
|
|
78
|
+
# Get just the page UID
|
|
79
|
+
roam get page "Project Notes" --uid
|
|
80
|
+
`)
|
|
81
|
+
.action(async (identifier, options) => {
|
|
82
|
+
try {
|
|
83
|
+
const graph = resolveGraph(options, false);
|
|
84
|
+
const depth = parseInt(options.depth || '4', 10);
|
|
85
|
+
const refsDepth = options.refs !== undefined
|
|
86
|
+
? Math.min(4, Math.max(1, parseInt(options.refs, 10) || 1))
|
|
87
|
+
: 0;
|
|
88
|
+
const outputOptions = {
|
|
89
|
+
json: options.json,
|
|
90
|
+
flat: options.flat,
|
|
91
|
+
debug: options.debug
|
|
92
|
+
};
|
|
93
|
+
if (options.debug) {
|
|
94
|
+
printDebug('Identifier', identifier);
|
|
95
|
+
printDebug('Graph', options.graph || 'default');
|
|
96
|
+
}
|
|
97
|
+
// Resolve identifier to page UID
|
|
98
|
+
let pageUid = null;
|
|
99
|
+
let pageTitle = null;
|
|
100
|
+
// 1. Check if it's a Roam URL
|
|
101
|
+
const urlParsed = parseRoamUrl(identifier);
|
|
102
|
+
if (urlParsed) {
|
|
103
|
+
pageUid = urlParsed.uid;
|
|
104
|
+
if (options.debug) {
|
|
105
|
+
printDebug('Parsed URL', { uid: pageUid, graph: urlParsed.graph });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// 2. Check if it's a direct UID
|
|
109
|
+
else if (isRoamUid(identifier)) {
|
|
110
|
+
pageUid = identifier;
|
|
111
|
+
if (options.debug) {
|
|
112
|
+
printDebug('Direct UID', pageUid);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// 3. Otherwise treat as page title
|
|
116
|
+
else {
|
|
117
|
+
pageTitle = resolveRelativeDate(identifier);
|
|
118
|
+
if (options.debug && pageTitle !== identifier) {
|
|
119
|
+
printDebug('Resolved date', `${identifier} → ${pageTitle}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const pageOps = new PageOperations(graph);
|
|
123
|
+
// If --uid flag, just return the UID
|
|
124
|
+
if (options.uid) {
|
|
125
|
+
if (pageUid) {
|
|
126
|
+
console.log(pageUid);
|
|
127
|
+
}
|
|
128
|
+
else if (pageTitle) {
|
|
129
|
+
const uid = await pageOps.getPageUid(pageTitle);
|
|
130
|
+
if (!uid) {
|
|
131
|
+
exitWithError(`Page "${pageTitle}" not found`);
|
|
132
|
+
}
|
|
133
|
+
console.log(uid);
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
// Fetch page content
|
|
138
|
+
let blocks;
|
|
139
|
+
let displayTitle;
|
|
140
|
+
if (pageUid) {
|
|
141
|
+
// Fetch by UID - first need to get page title for display
|
|
142
|
+
const result = await pageOps.fetchPageByUid(pageUid);
|
|
143
|
+
if (!result) {
|
|
144
|
+
exitWithError(`Page with UID "${pageUid}" not found`);
|
|
145
|
+
}
|
|
146
|
+
blocks = result.blocks;
|
|
147
|
+
displayTitle = result.title;
|
|
148
|
+
}
|
|
149
|
+
else if (pageTitle) {
|
|
150
|
+
// Fetch by title
|
|
151
|
+
const result = await pageOps.fetchPageByTitle(pageTitle, 'raw');
|
|
152
|
+
if (typeof result === 'string') {
|
|
153
|
+
try {
|
|
154
|
+
blocks = JSON.parse(result);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
exitWithError(result);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
blocks = result;
|
|
163
|
+
}
|
|
164
|
+
displayTitle = pageTitle;
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
exitWithError('Could not parse identifier');
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
// Resolve block references if requested
|
|
171
|
+
if (refsDepth > 0) {
|
|
172
|
+
blocks = await resolveBlocksRefsInTree(graph, blocks, refsDepth);
|
|
173
|
+
}
|
|
174
|
+
console.log(formatPageOutput(displayTitle, blocks, outputOptions));
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
178
|
+
exitWithError(message);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
29
181
|
}
|
|
30
182
|
export function createGetCommand() {
|
|
31
|
-
|
|
183
|
+
const cmd = new Command('get')
|
|
32
184
|
.description('Fetch pages, blocks, or TODO/DONE items with optional ref expansion')
|
|
33
185
|
.argument('[target]', 'Page title, block UID, or relative date. Reads from stdin if "-" or omitted.')
|
|
34
186
|
.option('-j, --json', 'Output as JSON instead of markdown')
|
|
35
187
|
.option('-d, --depth <n>', 'Child levels to fetch (default: 4)', '4')
|
|
36
188
|
.option('-r, --refs [n]', 'Expand ((uid)) refs in output (default depth: 1, max: 4)')
|
|
37
189
|
.option('-f, --flat', 'Flatten hierarchy to single-level list')
|
|
190
|
+
.option('-u, --uid', 'Return only the page UID (resolve title to UID)')
|
|
38
191
|
.option('--todo', 'Fetch TODO items')
|
|
39
192
|
.option('--done', 'Fetch DONE items')
|
|
40
|
-
.option('-p, --page <ref>', '
|
|
193
|
+
.option('-p, --page <ref>', 'Scope to page title or UID (for TODOs, tags, text)')
|
|
41
194
|
.option('-i, --include <terms>', 'Include items matching these terms (comma-separated)')
|
|
42
195
|
.option('-e, --exclude <terms>', 'Exclude items matching these terms (comma-separated)')
|
|
196
|
+
.option('--tag <tag>', 'Get blocks with tag (repeatable, comma-separated)', (val, prev) => {
|
|
197
|
+
const tags = val.split(',').map(t => t.trim()).filter(Boolean);
|
|
198
|
+
return prev ? [...prev, ...tags] : tags;
|
|
199
|
+
}, [])
|
|
200
|
+
.option('--text <text>', 'Get blocks containing text')
|
|
201
|
+
.option('--any', 'Use OR logic for multiple tags (default is AND)')
|
|
202
|
+
.option('--negtag <tag>', 'Exclude blocks with tag (repeatable, comma-separated)', (val, prev) => {
|
|
203
|
+
const tags = val.split(',').map(t => t.trim()).filter(Boolean);
|
|
204
|
+
return prev ? [...prev, ...tags] : tags;
|
|
205
|
+
}, [])
|
|
206
|
+
.option('-n, --limit <n>', 'Limit number of blocks fetched (default: 20 for tag/text)', '20')
|
|
207
|
+
.option('--showall', 'Show all results (no limit)')
|
|
208
|
+
.option('--sort <field>', 'Sort results by: created, modified, page')
|
|
209
|
+
.option('--asc', 'Sort ascending (default for page)')
|
|
210
|
+
.option('--desc', 'Sort descending (default for dates)')
|
|
211
|
+
.option('--group-by <field>', 'Group results by: page, tag')
|
|
43
212
|
.option('-g, --graph <name>', 'Target graph key (multi-graph mode)')
|
|
44
213
|
.option('--debug', 'Show query metadata')
|
|
45
214
|
.addHelpText('after', `
|
|
@@ -49,6 +218,14 @@ Examples:
|
|
|
49
218
|
roam get today # Today's daily page
|
|
50
219
|
roam get yesterday # Yesterday's daily page
|
|
51
220
|
|
|
221
|
+
# Fetch page by UID or URL (see 'roam get page --help')
|
|
222
|
+
roam get page abc123def # Page by UID
|
|
223
|
+
roam get page "https://roamresearch.com/#/app/my-graph/page/abc123def"
|
|
224
|
+
|
|
225
|
+
# Resolve page title to UID
|
|
226
|
+
roam get "Project Notes" --uid # Returns just the page UID
|
|
227
|
+
roam get today -u # Today's daily page UID
|
|
228
|
+
|
|
52
229
|
# Fetch blocks
|
|
53
230
|
roam get abc123def # Block by UID
|
|
54
231
|
roam get "((abc123def))" # UID with wrapper
|
|
@@ -69,6 +246,42 @@ Examples:
|
|
|
69
246
|
roam get --todo # All TODOs across graph
|
|
70
247
|
roam get --done # All completed items
|
|
71
248
|
roam get --todo -p "Work" # TODOs on "Work" page
|
|
249
|
+
|
|
250
|
+
# Tag-based retrieval (returns blocks with children)
|
|
251
|
+
roam get --tag TODO # Blocks tagged with #TODO
|
|
252
|
+
roam get --tag Project,Active # Blocks with both tags (AND)
|
|
253
|
+
roam get --tag Project --tag Active --any # Blocks with either tag (OR)
|
|
254
|
+
roam get --tag Task --negtag Done # Tasks excluding Done
|
|
255
|
+
roam get --tag Meeting -p "Work" # Meetings on Work page
|
|
256
|
+
|
|
257
|
+
# Text-based retrieval
|
|
258
|
+
roam get --text "urgent" # Blocks containing "urgent"
|
|
259
|
+
roam get --text "meeting" --tag Project # Combine text + tag filter
|
|
260
|
+
roam get --text "TODO" -p today # Text search on today's page
|
|
261
|
+
|
|
262
|
+
# Sorting
|
|
263
|
+
roam get --tag Convention --sort created # Sort by creation date (newest first)
|
|
264
|
+
roam get --todo --sort modified --asc # Sort by edit date (oldest first)
|
|
265
|
+
roam get --tag Project --sort page # Sort alphabetically by page
|
|
266
|
+
|
|
267
|
+
# Grouping
|
|
268
|
+
roam get --tag Convention --group-by page # Group by source page
|
|
269
|
+
roam get --tag Convention --group-by tag # Group by subtags (Convention/*)
|
|
270
|
+
|
|
271
|
+
# Combined
|
|
272
|
+
roam get --tag Convention --group-by tag --sort modified
|
|
273
|
+
|
|
274
|
+
Output format:
|
|
275
|
+
Markdown: Content with hierarchy (no UIDs). Use --json for UIDs.
|
|
276
|
+
JSON: Full block structure including uid field.
|
|
277
|
+
|
|
278
|
+
JSON output fields:
|
|
279
|
+
Page: { title, children: [Block...] }
|
|
280
|
+
Block: { uid, string, order, heading?, children: [Block...] }
|
|
281
|
+
TODO/DONE: [{ block_uid, content, page_title }]
|
|
282
|
+
Tag/Text: [{ uid, string, order, heading?, children: [...] }]
|
|
283
|
+
|
|
284
|
+
Note: For flat results with UIDs, use 'roam search' instead.
|
|
72
285
|
`)
|
|
73
286
|
.action(async (target, options) => {
|
|
74
287
|
try {
|
|
@@ -86,18 +299,189 @@ Examples:
|
|
|
86
299
|
if (options.debug) {
|
|
87
300
|
printDebug('Target', target || 'stdin');
|
|
88
301
|
printDebug('Graph', options.graph || 'default');
|
|
89
|
-
printDebug('Options', { depth, refs: refsDepth || 'off', ...outputOptions });
|
|
302
|
+
printDebug('Options', { depth, refs: refsDepth || 'off', uid: options.uid || false, ...outputOptions });
|
|
90
303
|
}
|
|
304
|
+
// Handle --uid flag: return just the page UID
|
|
305
|
+
if (options.uid) {
|
|
306
|
+
if (!target || target === '-') {
|
|
307
|
+
exitWithError('--uid requires a page title argument');
|
|
308
|
+
}
|
|
309
|
+
const resolvedTarget = resolveRelativeDate(target);
|
|
310
|
+
if (options.debug && resolvedTarget !== target) {
|
|
311
|
+
printDebug('Resolved date', `${target} → ${resolvedTarget}`);
|
|
312
|
+
}
|
|
313
|
+
// Check if target is already a block UID
|
|
314
|
+
const uidMatch = resolvedTarget.match(BLOCK_UID_PATTERN);
|
|
315
|
+
if (uidMatch) {
|
|
316
|
+
// Already a UID, just output it
|
|
317
|
+
console.log(uidMatch[1]);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const pageOps = new PageOperations(graph);
|
|
321
|
+
const pageUid = await pageOps.getPageUid(resolvedTarget);
|
|
322
|
+
if (!pageUid) {
|
|
323
|
+
exitWithError(`Page "${resolvedTarget}" not found`);
|
|
324
|
+
}
|
|
325
|
+
console.log(pageUid);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
// Parse sort/group options
|
|
329
|
+
const sortField = options.sort;
|
|
330
|
+
const groupByField = options.groupBy;
|
|
331
|
+
const sortDirection = sortField
|
|
332
|
+
? (options.asc ? 'asc' : options.desc ? 'desc' : getDefaultDirection(sortField))
|
|
333
|
+
: undefined;
|
|
91
334
|
// Handle --todo or --done flags (these ignore target arg usually, but could filter by page if target is used as page?)
|
|
92
335
|
// The help says "-p" is for page. So we strictly follow flags.
|
|
93
336
|
if (options.todo || options.done) {
|
|
94
337
|
const status = options.todo ? 'TODO' : 'DONE';
|
|
95
338
|
if (options.debug) {
|
|
96
339
|
printDebug('Status search', { status, page: options.page, include: options.include, exclude: options.exclude });
|
|
340
|
+
if (sortField)
|
|
341
|
+
printDebug('Sort', { field: sortField, direction: sortDirection });
|
|
342
|
+
if (groupByField)
|
|
343
|
+
printDebug('Group by', groupByField);
|
|
97
344
|
}
|
|
98
345
|
const searchOps = new SearchOperations(graph);
|
|
99
346
|
const result = await searchOps.searchByStatus(status, options.page, options.include, options.exclude);
|
|
100
|
-
|
|
347
|
+
let matches = result.matches;
|
|
348
|
+
// Apply sorting
|
|
349
|
+
if (sortField && sortDirection) {
|
|
350
|
+
matches = sortResults(matches, { field: sortField, direction: sortDirection });
|
|
351
|
+
}
|
|
352
|
+
// Apply grouping
|
|
353
|
+
if (groupByField) {
|
|
354
|
+
// For TODO/DONE, only page grouping makes sense (no tags on search results)
|
|
355
|
+
if (groupByField === 'tag') {
|
|
356
|
+
exitWithError('--group-by tag is not supported for TODO/DONE search. Use --group-by page instead.');
|
|
357
|
+
}
|
|
358
|
+
const grouped = groupResults(matches, { by: groupByField });
|
|
359
|
+
console.log(formatGroupedOutput(grouped, outputOptions));
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
console.log(formatTodoOutput(matches, status, outputOptions));
|
|
363
|
+
}
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
// Handle --tag and/or --text flags (search-based retrieval with full children)
|
|
367
|
+
const tags = options.tag || [];
|
|
368
|
+
if (tags.length > 0 || options.text) {
|
|
369
|
+
const searchOps = new SearchOperations(graph);
|
|
370
|
+
const blockOps = new BlockRetrievalOperations(graph);
|
|
371
|
+
const limit = options.showall ? Infinity : parseInt(options.limit || '20', 10);
|
|
372
|
+
const useOrLogic = options.any || false;
|
|
373
|
+
// Resolve page scope
|
|
374
|
+
const pageScope = options.page ? resolveRelativeDate(options.page) : undefined;
|
|
375
|
+
if (options.debug) {
|
|
376
|
+
printDebug('Tag/Text search', {
|
|
377
|
+
tags,
|
|
378
|
+
text: options.text,
|
|
379
|
+
page: pageScope,
|
|
380
|
+
any: useOrLogic,
|
|
381
|
+
negtag: options.negtag,
|
|
382
|
+
limit
|
|
383
|
+
});
|
|
384
|
+
if (sortField)
|
|
385
|
+
printDebug('Sort', { field: sortField, direction: sortDirection });
|
|
386
|
+
if (groupByField)
|
|
387
|
+
printDebug('Group by', groupByField);
|
|
388
|
+
}
|
|
389
|
+
// Get initial matches
|
|
390
|
+
let matches = [];
|
|
391
|
+
if (options.text) {
|
|
392
|
+
// Text search
|
|
393
|
+
const result = await searchOps.searchByText({
|
|
394
|
+
text: options.text,
|
|
395
|
+
page_title_uid: pageScope
|
|
396
|
+
});
|
|
397
|
+
matches = result.matches;
|
|
398
|
+
}
|
|
399
|
+
else if (tags.length > 0) {
|
|
400
|
+
// Tag search (use first tag as primary)
|
|
401
|
+
const normalizedTags = tags.map(normalizeTag);
|
|
402
|
+
const result = await searchOps.searchForTag(normalizedTags[0], pageScope);
|
|
403
|
+
matches = result.matches;
|
|
404
|
+
}
|
|
405
|
+
// Apply additional tag filters
|
|
406
|
+
if (tags.length > 0 && matches.length > 0) {
|
|
407
|
+
const normalizedTags = tags.map(normalizeTag);
|
|
408
|
+
// For text search with tags, filter by ALL tags
|
|
409
|
+
// For tag search with multiple tags, filter by remaining tags based on --any
|
|
410
|
+
if (options.text || normalizedTags.length > 1) {
|
|
411
|
+
matches = matches.filter(m => {
|
|
412
|
+
if (useOrLogic) {
|
|
413
|
+
return normalizedTags.some(tag => contentHasTag(m.content, tag));
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
return normalizedTags.every(tag => contentHasTag(m.content, tag));
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
// Apply negative tag filter
|
|
422
|
+
const negTags = options.negtag || [];
|
|
423
|
+
if (negTags.length > 0) {
|
|
424
|
+
const normalizedNegTags = negTags.map(normalizeTag);
|
|
425
|
+
matches = matches.filter(m => !normalizedNegTags.some(tag => contentHasTag(m.content, tag)));
|
|
426
|
+
}
|
|
427
|
+
// Apply sorting before limit (so we get the top N sorted items)
|
|
428
|
+
if (sortField && sortDirection) {
|
|
429
|
+
matches = sortResults(matches, { field: sortField, direction: sortDirection });
|
|
430
|
+
}
|
|
431
|
+
// Apply limit
|
|
432
|
+
const limitedMatches = matches.slice(0, limit);
|
|
433
|
+
if (limitedMatches.length === 0) {
|
|
434
|
+
console.log(options.json ? '[]' : 'No blocks found matching criteria.');
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
// For tag grouping, fetch all tags for matched blocks
|
|
438
|
+
if (groupByField === 'tag') {
|
|
439
|
+
const blockUids = limitedMatches.map(m => m.block_uid);
|
|
440
|
+
const tagMap = await SearchUtils.fetchBlockTags(graph, blockUids);
|
|
441
|
+
// Attach tags to matches
|
|
442
|
+
for (const match of limitedMatches) {
|
|
443
|
+
match.tags = tagMap.get(match.block_uid) || [];
|
|
444
|
+
}
|
|
445
|
+
// Group and output
|
|
446
|
+
const primaryTag = tags.length > 0 ? normalizeTag(tags[0]) : '';
|
|
447
|
+
const grouped = groupResults(limitedMatches, { by: 'tag', searchTag: primaryTag });
|
|
448
|
+
console.log(formatGroupedOutput(grouped, outputOptions));
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
// For page grouping, output grouped matches
|
|
452
|
+
if (groupByField === 'page') {
|
|
453
|
+
const grouped = groupResults(limitedMatches, { by: 'page' });
|
|
454
|
+
console.log(formatGroupedOutput(grouped, outputOptions));
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
// Standard output: fetch full blocks with children
|
|
458
|
+
const blocks = [];
|
|
459
|
+
for (const match of limitedMatches) {
|
|
460
|
+
let block = await blockOps.fetchBlockWithChildren(match.block_uid, depth);
|
|
461
|
+
if (block) {
|
|
462
|
+
// Resolve refs if requested (default: enabled for tag/text search)
|
|
463
|
+
const effectiveRefsDepth = refsDepth > 0 ? refsDepth : 1;
|
|
464
|
+
block = await resolveBlockRefsInTree(graph, block, effectiveRefsDepth);
|
|
465
|
+
blocks.push(block);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
// Output
|
|
469
|
+
if (options.json) {
|
|
470
|
+
const data = options.flat
|
|
471
|
+
? blocks.flatMap(b => flattenBlocks([b]))
|
|
472
|
+
: blocks;
|
|
473
|
+
console.log(JSON.stringify(data, null, 2));
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
const displayBlocks = options.flat
|
|
477
|
+
? blocks.flatMap(b => flattenBlocks([b]))
|
|
478
|
+
: blocks;
|
|
479
|
+
// Show count header
|
|
480
|
+
const countMsg = matches.length > limit
|
|
481
|
+
? `Found ${matches.length} blocks (showing first ${limit}):\n\n`
|
|
482
|
+
: `Found ${blocks.length} block(s):\n\n`;
|
|
483
|
+
console.log(countMsg + blocksToMarkdown(displayBlocks));
|
|
484
|
+
}
|
|
101
485
|
return;
|
|
102
486
|
}
|
|
103
487
|
// Determine targets
|
|
@@ -109,7 +493,7 @@ Examples:
|
|
|
109
493
|
// Read from stdin if no target or explicit '-'
|
|
110
494
|
if (process.stdin.isTTY && target !== '-') {
|
|
111
495
|
// If TTY and no target, show error
|
|
112
|
-
exitWithError('Target is required. Use: roam get <page-title>, roam get --todo, or pipe targets via stdin');
|
|
496
|
+
exitWithError('Target is required. Use: roam get <page-title>, roam get --todo, roam get --tag <tag>, roam get --text <text>, or pipe targets via stdin');
|
|
113
497
|
}
|
|
114
498
|
const input = await readStdin();
|
|
115
499
|
if (input) {
|
|
@@ -144,7 +528,7 @@ Examples:
|
|
|
144
528
|
}
|
|
145
529
|
// Resolve block references if requested
|
|
146
530
|
if (refsDepth > 0) {
|
|
147
|
-
block = await
|
|
531
|
+
block = await resolveBlockRefsInTree(graph, block, refsDepth);
|
|
148
532
|
}
|
|
149
533
|
return formatBlockOutput(block, outputOptions);
|
|
150
534
|
}
|
|
@@ -173,7 +557,7 @@ Examples:
|
|
|
173
557
|
}
|
|
174
558
|
// Resolve block references if requested
|
|
175
559
|
if (refsDepth > 0) {
|
|
176
|
-
blocks = await
|
|
560
|
+
blocks = await resolveBlocksRefsInTree(graph, blocks, refsDepth);
|
|
177
561
|
}
|
|
178
562
|
return formatPageOutput(resolvedTarget, blocks, outputOptions);
|
|
179
563
|
}
|
|
@@ -189,4 +573,7 @@ Examples:
|
|
|
189
573
|
exitWithError(message);
|
|
190
574
|
}
|
|
191
575
|
});
|
|
576
|
+
// Add subcommands
|
|
577
|
+
cmd.addCommand(createPageSubcommand());
|
|
578
|
+
return cmd;
|
|
192
579
|
}
|