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
|
@@ -2,18 +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
|
+
import { readStdin } from '../utils/input.js';
|
|
7
8
|
import { resolveRefs } from '../../tools/helpers/refs.js';
|
|
8
|
-
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';
|
|
9
12
|
// Block UID pattern: 9 alphanumeric characters, optionally wrapped in (( ))
|
|
10
13
|
const BLOCK_UID_PATTERN = /^(?:\(\()?([a-zA-Z0-9_-]{9})(?:\)\))?$/;
|
|
11
14
|
/**
|
|
12
15
|
* Recursively resolve block references in a RoamBlock tree
|
|
13
16
|
*/
|
|
14
|
-
async function
|
|
15
|
-
|
|
16
|
-
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)));
|
|
17
23
|
return {
|
|
18
24
|
...block,
|
|
19
25
|
string: resolvedString,
|
|
@@ -23,22 +29,186 @@ async function resolveBlockRefs(graph, block, maxDepth) {
|
|
|
23
29
|
/**
|
|
24
30
|
* Resolve refs in an array of blocks
|
|
25
31
|
*/
|
|
26
|
-
async function
|
|
27
|
-
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
|
+
});
|
|
28
181
|
}
|
|
29
182
|
export function createGetCommand() {
|
|
30
|
-
|
|
183
|
+
const cmd = new Command('get')
|
|
31
184
|
.description('Fetch pages, blocks, or TODO/DONE items with optional ref expansion')
|
|
32
|
-
.argument('[target]', 'Page title, block UID, or relative date
|
|
185
|
+
.argument('[target]', 'Page title, block UID, or relative date. Reads from stdin if "-" or omitted.')
|
|
33
186
|
.option('-j, --json', 'Output as JSON instead of markdown')
|
|
34
187
|
.option('-d, --depth <n>', 'Child levels to fetch (default: 4)', '4')
|
|
35
188
|
.option('-r, --refs [n]', 'Expand ((uid)) refs in output (default depth: 1, max: 4)')
|
|
36
189
|
.option('-f, --flat', 'Flatten hierarchy to single-level list')
|
|
190
|
+
.option('-u, --uid', 'Return only the page UID (resolve title to UID)')
|
|
37
191
|
.option('--todo', 'Fetch TODO items')
|
|
38
192
|
.option('--done', 'Fetch DONE items')
|
|
39
|
-
.option('-p, --page <ref>', '
|
|
193
|
+
.option('-p, --page <ref>', 'Scope to page title or UID (for TODOs, tags, text)')
|
|
40
194
|
.option('-i, --include <terms>', 'Include items matching these terms (comma-separated)')
|
|
41
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')
|
|
42
212
|
.option('-g, --graph <name>', 'Target graph key (multi-graph mode)')
|
|
43
213
|
.option('--debug', 'Show query metadata')
|
|
44
214
|
.addHelpText('after', `
|
|
@@ -47,12 +217,24 @@ Examples:
|
|
|
47
217
|
roam get "Project Notes" # Page by title
|
|
48
218
|
roam get today # Today's daily page
|
|
49
219
|
roam get yesterday # Yesterday's daily page
|
|
50
|
-
|
|
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
|
|
51
228
|
|
|
52
229
|
# Fetch blocks
|
|
53
230
|
roam get abc123def # Block by UID
|
|
54
231
|
roam get "((abc123def))" # UID with wrapper
|
|
55
232
|
|
|
233
|
+
# Stdin / Batch Retrieval
|
|
234
|
+
echo "Project A" | roam get # Pipe page title
|
|
235
|
+
echo "abc123def" | roam get # Pipe block UID
|
|
236
|
+
cat uids.txt | roam get --json # Fetch multiple blocks (NDJSON output)
|
|
237
|
+
|
|
56
238
|
# Output options
|
|
57
239
|
roam get "Page" -j # JSON output
|
|
58
240
|
roam get "Page" -f # Flat list (no hierarchy)
|
|
@@ -64,8 +246,42 @@ Examples:
|
|
|
64
246
|
roam get --todo # All TODOs across graph
|
|
65
247
|
roam get --done # All completed items
|
|
66
248
|
roam get --todo -p "Work" # TODOs on "Work" page
|
|
67
|
-
|
|
68
|
-
|
|
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.
|
|
69
285
|
`)
|
|
70
286
|
.action(async (target, options) => {
|
|
71
287
|
try {
|
|
@@ -81,76 +297,275 @@ Examples:
|
|
|
81
297
|
debug: options.debug
|
|
82
298
|
};
|
|
83
299
|
if (options.debug) {
|
|
84
|
-
printDebug('Target', target);
|
|
300
|
+
printDebug('Target', target || 'stdin');
|
|
85
301
|
printDebug('Graph', options.graph || 'default');
|
|
86
|
-
printDebug('Options', { depth, refs: refsDepth || 'off', ...outputOptions });
|
|
302
|
+
printDebug('Options', { depth, refs: refsDepth || 'off', uid: options.uid || false, ...outputOptions });
|
|
87
303
|
}
|
|
88
|
-
// Handle --
|
|
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;
|
|
334
|
+
// Handle --todo or --done flags (these ignore target arg usually, but could filter by page if target is used as page?)
|
|
335
|
+
// The help says "-p" is for page. So we strictly follow flags.
|
|
89
336
|
if (options.todo || options.done) {
|
|
90
337
|
const status = options.todo ? 'TODO' : 'DONE';
|
|
91
338
|
if (options.debug) {
|
|
92
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);
|
|
93
344
|
}
|
|
94
345
|
const searchOps = new SearchOperations(graph);
|
|
95
346
|
const result = await searchOps.searchByStatus(status, options.page, options.include, options.exclude);
|
|
96
|
-
|
|
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
|
+
}
|
|
97
364
|
return;
|
|
98
365
|
}
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
// Check if target is a block UID
|
|
109
|
-
const uidMatch = resolvedTarget.match(BLOCK_UID_PATTERN);
|
|
110
|
-
if (uidMatch) {
|
|
111
|
-
// Fetch block by UID
|
|
112
|
-
const blockUid = uidMatch[1];
|
|
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;
|
|
113
375
|
if (options.debug) {
|
|
114
|
-
printDebug('
|
|
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);
|
|
115
388
|
}
|
|
116
|
-
|
|
117
|
-
let
|
|
118
|
-
if (
|
|
119
|
-
|
|
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;
|
|
120
450
|
}
|
|
121
|
-
//
|
|
122
|
-
if (
|
|
123
|
-
|
|
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;
|
|
124
456
|
}
|
|
125
|
-
|
|
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
|
+
}
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
// Determine targets
|
|
488
|
+
let targets = [];
|
|
489
|
+
if (target && target !== '-') {
|
|
490
|
+
targets = [target];
|
|
126
491
|
}
|
|
127
492
|
else {
|
|
128
|
-
//
|
|
129
|
-
if (
|
|
130
|
-
|
|
493
|
+
// Read from stdin if no target or explicit '-'
|
|
494
|
+
if (process.stdin.isTTY && target !== '-') {
|
|
495
|
+
// If TTY and no target, show error
|
|
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');
|
|
131
497
|
}
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
498
|
+
const input = await readStdin();
|
|
499
|
+
if (input) {
|
|
500
|
+
targets = input.split('\n').map(t => t.trim()).filter(Boolean);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (targets.length === 0) {
|
|
504
|
+
exitWithError('No targets provided');
|
|
505
|
+
}
|
|
506
|
+
// Helper to process a single target
|
|
507
|
+
const processTarget = async (item) => {
|
|
508
|
+
// Resolve relative date keywords (today, yesterday, tomorrow)
|
|
509
|
+
const resolvedTarget = resolveRelativeDate(item);
|
|
510
|
+
if (options.debug && resolvedTarget !== item) {
|
|
511
|
+
printDebug('Resolved date', `${item} → ${resolvedTarget}`);
|
|
512
|
+
}
|
|
513
|
+
// Check if target is a block UID
|
|
514
|
+
const uidMatch = resolvedTarget.match(BLOCK_UID_PATTERN);
|
|
515
|
+
if (uidMatch) {
|
|
516
|
+
// Fetch block by UID
|
|
517
|
+
const blockUid = uidMatch[1];
|
|
518
|
+
if (options.debug)
|
|
519
|
+
printDebug('Fetching block', { uid: blockUid });
|
|
520
|
+
const blockOps = new BlockRetrievalOperations(graph);
|
|
521
|
+
let block = await blockOps.fetchBlockWithChildren(blockUid, depth);
|
|
522
|
+
if (!block) {
|
|
523
|
+
// If fetching multiple, maybe warn instead of exit?
|
|
524
|
+
// For now, consistent behavior: print error message to stderr but continue?
|
|
525
|
+
// Or simpler: just return a "not found" string/object.
|
|
526
|
+
// formatBlockOutput doesn't handle null.
|
|
527
|
+
return options.json ? JSON.stringify({ error: `Block ${blockUid} not found` }) : `Block ${blockUid} not found`;
|
|
139
528
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
return;
|
|
529
|
+
// Resolve block references if requested
|
|
530
|
+
if (refsDepth > 0) {
|
|
531
|
+
block = await resolveBlockRefsInTree(graph, block, refsDepth);
|
|
144
532
|
}
|
|
533
|
+
return formatBlockOutput(block, outputOptions);
|
|
145
534
|
}
|
|
146
535
|
else {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
536
|
+
// Fetch page by title
|
|
537
|
+
if (options.debug)
|
|
538
|
+
printDebug('Fetching page', { title: resolvedTarget });
|
|
539
|
+
const pageOps = new PageOperations(graph);
|
|
540
|
+
const result = await pageOps.fetchPageByTitle(resolvedTarget, 'raw');
|
|
541
|
+
// Parse the raw result
|
|
542
|
+
let blocks;
|
|
543
|
+
if (typeof result === 'string') {
|
|
544
|
+
try {
|
|
545
|
+
blocks = JSON.parse(result);
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
// Result is already formatted as string (e.g., "Page Title (no content found)")
|
|
549
|
+
// But wait, fetchPageByTitle returns string if not found or empty?
|
|
550
|
+
// Actually fetchPageByTitle 'raw' returns JSON string of blocks OR empty array JSON string?
|
|
551
|
+
// Let's assume result is valid JSON or error message string.
|
|
552
|
+
return options.json ? JSON.stringify({ title: resolvedTarget, error: result }) : result;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
blocks = result;
|
|
557
|
+
}
|
|
558
|
+
// Resolve block references if requested
|
|
559
|
+
if (refsDepth > 0) {
|
|
560
|
+
blocks = await resolveBlocksRefsInTree(graph, blocks, refsDepth);
|
|
561
|
+
}
|
|
562
|
+
return formatPageOutput(resolvedTarget, blocks, outputOptions);
|
|
152
563
|
}
|
|
153
|
-
|
|
564
|
+
};
|
|
565
|
+
// Execute sequentially
|
|
566
|
+
for (const t of targets) {
|
|
567
|
+
const output = await processTarget(t);
|
|
568
|
+
console.log(output);
|
|
154
569
|
}
|
|
155
570
|
}
|
|
156
571
|
catch (error) {
|
|
@@ -158,4 +573,7 @@ Examples:
|
|
|
158
573
|
exitWithError(message);
|
|
159
574
|
}
|
|
160
575
|
});
|
|
576
|
+
// Add subcommands
|
|
577
|
+
cmd.addCommand(createPageSubcommand());
|
|
578
|
+
return cmd;
|
|
161
579
|
}
|