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.
Files changed (51) hide show
  1. package/README.md +175 -669
  2. package/build/Roam_Markdown_Cheatsheet.md +24 -4
  3. package/build/cache/page-uid-cache.js +40 -2
  4. package/build/cli/batch/translator.js +1 -1
  5. package/build/cli/commands/batch.js +2 -0
  6. package/build/cli/commands/get.js +401 -14
  7. package/build/cli/commands/refs.js +2 -0
  8. package/build/cli/commands/save.js +56 -1
  9. package/build/cli/commands/search.js +45 -0
  10. package/build/cli/commands/status.js +3 -4
  11. package/build/cli/utils/graph.js +6 -2
  12. package/build/cli/utils/output.js +28 -5
  13. package/build/cli/utils/sort-group.js +110 -0
  14. package/build/config/graph-registry.js +31 -13
  15. package/build/config/graph-registry.test.js +42 -5
  16. package/build/markdown-utils.js +114 -4
  17. package/build/markdown-utils.test.js +125 -0
  18. package/build/query/generator.js +330 -0
  19. package/build/query/index.js +149 -0
  20. package/build/query/parser.js +319 -0
  21. package/build/query/parser.test.js +389 -0
  22. package/build/query/types.js +4 -0
  23. package/build/search/ancestor-rule.js +14 -0
  24. package/build/search/block-ref-search.js +1 -5
  25. package/build/search/hierarchy-search.js +5 -12
  26. package/build/search/index.js +1 -0
  27. package/build/search/status-search.js +10 -9
  28. package/build/search/tag-search.js +8 -24
  29. package/build/search/text-search.js +70 -27
  30. package/build/search/types.js +13 -0
  31. package/build/search/utils.js +71 -2
  32. package/build/server/roam-server.js +2 -1
  33. package/build/shared/index.js +2 -0
  34. package/build/shared/page-validator.js +233 -0
  35. package/build/shared/page-validator.test.js +128 -0
  36. package/build/shared/staged-batch.js +144 -0
  37. package/build/tools/helpers/batch-utils.js +57 -0
  38. package/build/tools/helpers/page-resolution.js +136 -0
  39. package/build/tools/helpers/refs.js +68 -0
  40. package/build/tools/operations/batch.js +75 -3
  41. package/build/tools/operations/block-retrieval.js +15 -4
  42. package/build/tools/operations/block-retrieval.test.js +87 -0
  43. package/build/tools/operations/blocks.js +1 -288
  44. package/build/tools/operations/memory.js +29 -91
  45. package/build/tools/operations/outline.js +38 -156
  46. package/build/tools/operations/pages.js +169 -122
  47. package/build/tools/operations/todos.js +5 -37
  48. package/build/tools/schemas.js +14 -8
  49. package/build/tools/tool-handlers.js +2 -2
  50. package/build/utils/helpers.js +27 -0
  51. 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: Title by Author]]
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: Name]]
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 MEMORIES_TAG if configured (we'll handle this in the CLI command)
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 resolveBlockRefs(graph, block, maxDepth) {
16
- const resolvedString = await resolveRefs(graph, block.string, 0, maxDepth);
17
- const resolvedChildren = await Promise.all((block.children || []).map(child => resolveBlockRefs(graph, child, maxDepth)));
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 resolveBlocksRefs(graph, blocks, maxDepth) {
28
- return Promise.all(blocks.map(block => resolveBlockRefs(graph, block, maxDepth)));
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
- return new Command('get')
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>', 'Filter TODOs/DONEs by page title or UID')
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
- console.log(formatTodoOutput(result.matches, status, outputOptions));
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 resolveBlockRefs(graph, block, refsDepth);
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 resolveBlocksRefs(graph, blocks, refsDepth);
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
  }