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,6 +2,7 @@ import { Command } from 'commander';
|
|
|
2
2
|
import { SearchOperations } from '../../tools/operations/search/index.js';
|
|
3
3
|
import { printDebug, exitWithError } from '../utils/output.js';
|
|
4
4
|
import { resolveGraph } from '../utils/graph.js';
|
|
5
|
+
import { readStdin } from '../utils/input.js';
|
|
5
6
|
/**
|
|
6
7
|
* Format results grouped by page (default output)
|
|
7
8
|
*/
|
|
@@ -72,7 +73,7 @@ function parseIdentifier(identifier) {
|
|
|
72
73
|
export function createRefsCommand() {
|
|
73
74
|
return new Command('refs')
|
|
74
75
|
.description('Find all blocks that reference a page, tag, or block')
|
|
75
|
-
.argument('
|
|
76
|
+
.argument('[identifier]', 'Page title, #tag, [[Page]], or ((block-uid)). Reads from stdin if "-" or omitted.')
|
|
76
77
|
.option('-n, --limit <n>', 'Limit number of results', '50')
|
|
77
78
|
.option('--json', 'Output as JSON array')
|
|
78
79
|
.option('--raw', 'Output raw UID + content lines (no grouping)')
|
|
@@ -82,49 +83,68 @@ export function createRefsCommand() {
|
|
|
82
83
|
Examples:
|
|
83
84
|
# Page references
|
|
84
85
|
roam refs "Project Alpha" # Blocks linking to page
|
|
85
|
-
roam refs "[[Meeting Notes]]" # With bracket syntax
|
|
86
86
|
roam refs "#TODO" # Blocks with #TODO tag
|
|
87
87
|
|
|
88
|
+
# Stdin / Batch references
|
|
89
|
+
echo "Project A" | roam refs # Pipe page title
|
|
90
|
+
cat uids.txt | roam refs --json # Find refs for multiple UIDs
|
|
91
|
+
|
|
88
92
|
# Block references
|
|
89
93
|
roam refs "((abc123def))" # Blocks embedding this block
|
|
90
94
|
|
|
91
|
-
|
|
92
|
-
roam refs "Work" --json # JSON array output
|
|
93
|
-
roam refs "Ideas" --raw # Raw UID + content (no grouping)
|
|
94
|
-
roam refs "Tasks" -n 100 # Limit to 100 results
|
|
95
|
+
JSON output fields: uid, content, page
|
|
95
96
|
`)
|
|
96
97
|
.action(async (identifier, options) => {
|
|
97
98
|
try {
|
|
98
99
|
const graph = resolveGraph(options, false);
|
|
99
100
|
const limit = parseInt(options.limit || '50', 10);
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
printDebug('Parsed', { block_uid, title });
|
|
105
|
-
printDebug('Options', options);
|
|
106
|
-
}
|
|
107
|
-
const searchOps = new SearchOperations(graph);
|
|
108
|
-
const result = await searchOps.searchBlockRefs({ block_uid, title });
|
|
109
|
-
if (options.debug) {
|
|
110
|
-
printDebug('Total matches', result.matches.length);
|
|
101
|
+
// Determine identifiers
|
|
102
|
+
let identifiers = [];
|
|
103
|
+
if (identifier && identifier !== '-') {
|
|
104
|
+
identifiers = [identifier];
|
|
111
105
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}));
|
|
121
|
-
console.log(JSON.stringify(jsonOutput, null, 2));
|
|
106
|
+
else {
|
|
107
|
+
if (process.stdin.isTTY && identifier !== '-') {
|
|
108
|
+
exitWithError('Identifier is required. Use: roam refs <title> or pipe identifiers via stdin');
|
|
109
|
+
}
|
|
110
|
+
const input = await readStdin();
|
|
111
|
+
if (input) {
|
|
112
|
+
identifiers = input.split('\n').map(t => t.trim()).filter(Boolean);
|
|
113
|
+
}
|
|
122
114
|
}
|
|
123
|
-
|
|
124
|
-
|
|
115
|
+
if (identifiers.length === 0) {
|
|
116
|
+
exitWithError('No identifiers provided');
|
|
125
117
|
}
|
|
126
|
-
|
|
127
|
-
|
|
118
|
+
const searchOps = new SearchOperations(graph);
|
|
119
|
+
// Helper to process a single identifier
|
|
120
|
+
const processIdentifier = async (id) => {
|
|
121
|
+
const { block_uid, title } = parseIdentifier(id);
|
|
122
|
+
if (options.debug) {
|
|
123
|
+
printDebug('Identifier', id);
|
|
124
|
+
printDebug('Parsed', { block_uid, title });
|
|
125
|
+
}
|
|
126
|
+
const result = await searchOps.searchBlockRefs({ block_uid, title });
|
|
127
|
+
const limitedMatches = result.matches.slice(0, limit);
|
|
128
|
+
if (options.json) {
|
|
129
|
+
return JSON.stringify(limitedMatches.map(m => ({
|
|
130
|
+
uid: m.block_uid,
|
|
131
|
+
content: m.content,
|
|
132
|
+
page: m.page_title
|
|
133
|
+
})));
|
|
134
|
+
}
|
|
135
|
+
else if (options.raw) {
|
|
136
|
+
return formatRaw(limitedMatches);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
return formatGrouped(limitedMatches);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
// Execute
|
|
143
|
+
for (const id of identifiers) {
|
|
144
|
+
const output = await processIdentifier(id);
|
|
145
|
+
console.log(output);
|
|
146
|
+
if (identifiers.length > 1 && !options.json)
|
|
147
|
+
console.log('\n---\n');
|
|
128
148
|
}
|
|
129
149
|
}
|
|
130
150
|
catch (error) {
|
|
@@ -7,6 +7,7 @@ import { BatchOperations } from '../../tools/operations/batch.js';
|
|
|
7
7
|
import { parseMarkdown, generateBlockUid, parseMarkdownHeadingLevel } from '../../markdown-utils.js';
|
|
8
8
|
import { printDebug, exitWithError } from '../utils/output.js';
|
|
9
9
|
import { resolveGraph } from '../utils/graph.js';
|
|
10
|
+
import { readStdin } from '../utils/input.js';
|
|
10
11
|
import { formatRoamDate } from '../../utils/helpers.js';
|
|
11
12
|
import { q, createPage as roamCreatePage } from '@roam-research/roam-api-sdk';
|
|
12
13
|
/**
|
|
@@ -27,14 +28,53 @@ function flattenNodes(nodes, baseLevel = 1) {
|
|
|
27
28
|
return result;
|
|
28
29
|
}
|
|
29
30
|
/**
|
|
30
|
-
*
|
|
31
|
+
* Infer hierarchy from heading levels when all blocks are at the same level.
|
|
32
|
+
* This handles prose-style markdown where headings (# ## ###) define structure
|
|
33
|
+
* without explicit indentation.
|
|
34
|
+
*
|
|
35
|
+
* Example: "# Title\n## Chapter\nContent" becomes:
|
|
36
|
+
* - Title (level 1, H1)
|
|
37
|
+
* - Chapter (level 2, H2)
|
|
38
|
+
* - Content (level 3)
|
|
31
39
|
*/
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
function adjustLevelsForHeadingHierarchy(blocks) {
|
|
41
|
+
if (blocks.length === 0)
|
|
42
|
+
return blocks;
|
|
43
|
+
// Only apply heading-based adjustment when:
|
|
44
|
+
// 1. All blocks are at the same level (no indentation-based hierarchy)
|
|
45
|
+
// 2. There are headings present
|
|
46
|
+
const allSameLevel = blocks.every(b => b.level === blocks[0].level);
|
|
47
|
+
const hasHeadings = blocks.some(b => b.heading);
|
|
48
|
+
if (!allSameLevel || !hasHeadings) {
|
|
49
|
+
// Indentation-based hierarchy exists, preserve it
|
|
50
|
+
return blocks;
|
|
36
51
|
}
|
|
37
|
-
|
|
52
|
+
const result = [];
|
|
53
|
+
// Track heading stack: each entry is { headingLevel: 1|2|3, adjustedLevel: number }
|
|
54
|
+
const headingStack = [];
|
|
55
|
+
for (const block of blocks) {
|
|
56
|
+
if (block.heading) {
|
|
57
|
+
// Pop headings of same or lower priority (higher h-number)
|
|
58
|
+
while (headingStack.length > 0 &&
|
|
59
|
+
headingStack[headingStack.length - 1].headingLevel >= block.heading) {
|
|
60
|
+
headingStack.pop();
|
|
61
|
+
}
|
|
62
|
+
// New heading level is one deeper than parent heading (or 1 if no parent)
|
|
63
|
+
const adjustedLevel = headingStack.length > 0
|
|
64
|
+
? headingStack[headingStack.length - 1].adjustedLevel + 1
|
|
65
|
+
: 1;
|
|
66
|
+
headingStack.push({ headingLevel: block.heading, adjustedLevel });
|
|
67
|
+
result.push({ ...block, level: adjustedLevel });
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
// Content: nest under current heading context
|
|
71
|
+
const adjustedLevel = headingStack.length > 0
|
|
72
|
+
? headingStack[headingStack.length - 1].adjustedLevel + 1
|
|
73
|
+
: 1;
|
|
74
|
+
result.push({ ...block, level: adjustedLevel });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
38
78
|
}
|
|
39
79
|
/**
|
|
40
80
|
* Check if a string looks like a Roam block UID (9 alphanumeric chars with _ or -)
|
|
@@ -65,6 +105,8 @@ async function findOrCreatePage(graph, title) {
|
|
|
65
105
|
action: 'create-page',
|
|
66
106
|
page: { title }
|
|
67
107
|
});
|
|
108
|
+
// Small delay for new page to be fully available as parent in Roam
|
|
109
|
+
await new Promise(resolve => setTimeout(resolve, 400));
|
|
68
110
|
const results = await q(graph, findQuery, [title]);
|
|
69
111
|
if (!results || results.length === 0) {
|
|
70
112
|
throw new Error(`Could not find created page: ${title}`);
|
|
@@ -146,6 +188,7 @@ export function createSaveCommand() {
|
|
|
146
188
|
.option('-c, --categories <tags>', 'Comma-separated tags appended to first block')
|
|
147
189
|
.option('-t, --todo [text]', 'Add TODO item(s) to daily page. Accepts inline text or stdin')
|
|
148
190
|
.option('--json', 'Force JSON array format: [{text, level, heading?}, ...]')
|
|
191
|
+
.option('--flatten', 'Disable heading hierarchy inference (all blocks at root level)')
|
|
149
192
|
.option('-g, --graph <name>', 'Target graph key (multi-graph mode)')
|
|
150
193
|
.option('--write-key <key>', 'Write confirmation key (non-default graphs)')
|
|
151
194
|
.option('--debug', 'Show debug information')
|
|
@@ -169,6 +212,11 @@ Examples:
|
|
|
169
212
|
roam save notes.md --title "My Notes" --update # Smart update (preserves UIDs)
|
|
170
213
|
cat data.json | roam save --json # Pipe JSON blocks
|
|
171
214
|
|
|
215
|
+
# Stdin operations
|
|
216
|
+
echo "Task from CLI" | roam save --todo # Pipe to TODO
|
|
217
|
+
cat note.md | roam save --title "From Pipe" # Pipe file content to new page
|
|
218
|
+
echo "Quick capture" | roam save -p "Inbox" # Pipe to specific page
|
|
219
|
+
|
|
172
220
|
# Combine options
|
|
173
221
|
roam save -p "Work" --parent "## Today" "Done with task" -c "wins"
|
|
174
222
|
|
|
@@ -219,7 +267,7 @@ JSON format (--json):
|
|
|
219
267
|
let content;
|
|
220
268
|
let isFile = false;
|
|
221
269
|
let sourceFilename;
|
|
222
|
-
if (input) {
|
|
270
|
+
if (input && input !== '-') {
|
|
223
271
|
// Check if input is a file path that exists
|
|
224
272
|
if (existsSync(input)) {
|
|
225
273
|
isFile = true;
|
|
@@ -237,8 +285,8 @@ JSON format (--json):
|
|
|
237
285
|
}
|
|
238
286
|
}
|
|
239
287
|
else {
|
|
240
|
-
// Read from stdin
|
|
241
|
-
if (process.stdin.isTTY) {
|
|
288
|
+
// Read from stdin (or if input is explicit '-')
|
|
289
|
+
if (process.stdin.isTTY && input !== '-') {
|
|
242
290
|
exitWithError('No input. Use: roam save "text", roam save <file>, or pipe content');
|
|
243
291
|
}
|
|
244
292
|
content = await readStdin();
|
|
@@ -262,7 +310,9 @@ JSON format (--json):
|
|
|
262
310
|
else if (isFile || content.includes('\n')) {
|
|
263
311
|
// Multi-line content: parse as markdown
|
|
264
312
|
const nodes = parseMarkdown(content);
|
|
265
|
-
|
|
313
|
+
const flattened = flattenNodes(nodes);
|
|
314
|
+
// Apply heading hierarchy unless --flatten is specified
|
|
315
|
+
contentBlocks = options.flatten ? flattened : adjustLevelsForHeadingHierarchy(flattened);
|
|
266
316
|
}
|
|
267
317
|
else {
|
|
268
318
|
// Single line text: detect heading syntax and strip hashes
|
|
@@ -302,6 +352,7 @@ JSON format (--json):
|
|
|
302
352
|
printDebug('Input', input || 'stdin');
|
|
303
353
|
printDebug('Is file', isFile);
|
|
304
354
|
printDebug('Is JSON', isJson);
|
|
355
|
+
printDebug('Flatten mode', options.flatten || false);
|
|
305
356
|
printDebug('Graph', options.graph || 'default');
|
|
306
357
|
printDebug('Content blocks', contentBlocks.length);
|
|
307
358
|
printDebug('Parent UID', parentUid || 'none');
|
|
@@ -2,6 +2,7 @@ import { Command } from 'commander';
|
|
|
2
2
|
import { SearchOperations } from '../../tools/operations/search/index.js';
|
|
3
3
|
import { formatSearchResults, printDebug, exitWithError } from '../utils/output.js';
|
|
4
4
|
import { resolveGraph } from '../utils/graph.js';
|
|
5
|
+
import { readStdin } from '../utils/input.js';
|
|
5
6
|
/**
|
|
6
7
|
* Normalize a tag by stripping #, [[, ]] wrappers
|
|
7
8
|
*/
|
|
@@ -20,7 +21,7 @@ function contentHasTag(content, tag) {
|
|
|
20
21
|
export function createSearchCommand() {
|
|
21
22
|
return new Command('search')
|
|
22
23
|
.description('Search blocks by text, tags, Datalog queries, or within specific pages')
|
|
23
|
-
.argument('[terms...]', 'Search terms (multiple terms use AND logic)')
|
|
24
|
+
.argument('[terms...]', 'Search terms (multiple terms use AND logic). Reads from stdin if omitted.')
|
|
24
25
|
.option('--tag <tag>', 'Filter by tag (repeatable, comma-separated). Default: AND logic', (val, prev) => {
|
|
25
26
|
// Support both comma-separated and multiple flags
|
|
26
27
|
const tags = val.split(',').map(t => t.trim()).filter(Boolean);
|
|
@@ -41,43 +42,36 @@ export function createSearchCommand() {
|
|
|
41
42
|
.option('--inputs <json>', 'JSON array of inputs for Datalog query')
|
|
42
43
|
.option('--regex <pattern>', 'Client-side regex filter on Datalog results')
|
|
43
44
|
.option('--regex-flags <flags>', 'Regex flags (e.g., "i" for case-insensitive)')
|
|
45
|
+
.option('--namespace <prefix>', 'Search for pages by namespace prefix (e.g., "Convention" finds "Convention/*")')
|
|
44
46
|
.addHelpText('after', `
|
|
45
47
|
Examples:
|
|
46
48
|
# Text search
|
|
47
49
|
roam search "meeting notes" # Find blocks containing text
|
|
48
50
|
roam search api integration # Multiple terms (AND logic)
|
|
49
|
-
|
|
51
|
+
|
|
52
|
+
# Namespace search (find pages by title prefix)
|
|
53
|
+
roam search --namespace Convention # Find all Convention/* pages
|
|
54
|
+
roam search --namespace "Convention/" # Same (trailing slash optional)
|
|
55
|
+
|
|
56
|
+
# Stdin search
|
|
57
|
+
echo "urgent project" | roam search # Pipe terms
|
|
58
|
+
roam get today | roam search TODO # Search within output
|
|
50
59
|
|
|
51
60
|
# Tag search
|
|
52
61
|
roam search --tag TODO # All blocks with #TODO
|
|
53
62
|
roam search --tag "[[Project Alpha]]" # Blocks with page reference
|
|
54
|
-
roam search --tag work --page "January 3rd, 2026" # Tag on specific page
|
|
55
|
-
|
|
56
|
-
# Multiple tags
|
|
57
|
-
roam search --tag TODO --tag urgent # Blocks with BOTH tags (AND)
|
|
58
|
-
roam search --tag "TODO,urgent,blocked" # Comma-separated (AND)
|
|
59
|
-
roam search --tag TODO --tag urgent --any # Blocks with ANY tag (OR)
|
|
60
|
-
|
|
61
|
-
# Exclude tags
|
|
62
|
-
roam search --tag TODO --negtag done # TODOs excluding #done
|
|
63
|
-
roam search --tag TODO --negtag "someday,maybe" # Exclude multiple
|
|
64
63
|
|
|
65
|
-
#
|
|
66
|
-
roam search
|
|
67
|
-
roam search "review" --page "Work" # Search within page
|
|
64
|
+
# Datalog queries (advanced)
|
|
65
|
+
roam search -q '[:find ?uid ?s :where [?b :block/uid ?uid] [?b :block/string ?s]]' --regex "meeting"
|
|
68
66
|
|
|
69
|
-
#
|
|
70
|
-
roam search
|
|
71
|
-
roam search "api" --json # JSON output
|
|
67
|
+
# Chaining with jq
|
|
68
|
+
roam search TODO --json | jq '.[].block_uid'
|
|
72
69
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
roam search -q '[:find ?uid ?s :where [?b :block/uid ?uid] [?b :block/string ?s]]' --regex "meeting" --regex-flags "i"
|
|
70
|
+
Output format:
|
|
71
|
+
Markdown: Flat results with UIDs and content (no hierarchy).
|
|
72
|
+
JSON: [{ block_uid, content, page_title }] or [{ page_uid, page_title }] for namespace
|
|
77
73
|
|
|
78
|
-
|
|
79
|
-
Common attributes: :node/title, :block/string, :block/uid, :block/page, :block/children
|
|
80
|
-
Predicates: clojure.string/includes?, clojure.string/starts-with?, <, >, =
|
|
74
|
+
Note: For hierarchical output with children, use 'roam get --tag/--text' instead.
|
|
81
75
|
`)
|
|
82
76
|
.action(async (terms, options) => {
|
|
83
77
|
try {
|
|
@@ -87,14 +81,52 @@ Datalog tips:
|
|
|
87
81
|
json: options.json,
|
|
88
82
|
debug: options.debug
|
|
89
83
|
};
|
|
84
|
+
let searchTerms = terms;
|
|
85
|
+
// If no terms provided as args, try stdin
|
|
86
|
+
if (searchTerms.length === 0 && !process.stdin.isTTY && !options.query && (options.tag?.length === 0)) {
|
|
87
|
+
const input = await readStdin();
|
|
88
|
+
if (input) {
|
|
89
|
+
searchTerms = input.trim().split(/\s+/);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
90
92
|
if (options.debug) {
|
|
91
|
-
printDebug('Search terms',
|
|
93
|
+
printDebug('Search terms', searchTerms);
|
|
92
94
|
printDebug('Graph', options.graph || 'default');
|
|
93
95
|
printDebug('Options', options);
|
|
94
96
|
}
|
|
95
97
|
const searchOps = new SearchOperations(graph);
|
|
98
|
+
// Namespace search mode (search page titles by prefix)
|
|
99
|
+
if (options.namespace) {
|
|
100
|
+
const result = await searchOps.searchByText({
|
|
101
|
+
text: options.namespace,
|
|
102
|
+
scope: 'page_titles'
|
|
103
|
+
});
|
|
104
|
+
if (!result.success) {
|
|
105
|
+
exitWithError(result.message || 'Namespace search failed');
|
|
106
|
+
}
|
|
107
|
+
let matches = result.matches.slice(0, limit);
|
|
108
|
+
if (options.json) {
|
|
109
|
+
// For JSON output, return page_uid and page_title
|
|
110
|
+
const jsonMatches = matches.map(m => ({
|
|
111
|
+
page_uid: m.block_uid,
|
|
112
|
+
page_title: m.page_title
|
|
113
|
+
}));
|
|
114
|
+
console.log(JSON.stringify(jsonMatches, null, 2));
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
if (matches.length === 0) {
|
|
118
|
+
console.log('No pages found.');
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
console.log(`Found ${result.matches.length} page(s)${result.matches.length > limit ? ` (showing first ${limit})` : ''}:\n`);
|
|
122
|
+
for (const match of matches) {
|
|
123
|
+
console.log(`- ${match.page_title} (${match.block_uid})`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
96
129
|
// Datalog query mode (bypasses other search options)
|
|
97
|
-
// See for query construction - Roam_Research_Datalog_Cheatsheet.md
|
|
98
130
|
if (options.query) {
|
|
99
131
|
// Parse inputs if provided
|
|
100
132
|
let inputs;
|
|
@@ -109,11 +141,6 @@ Datalog tips:
|
|
|
109
141
|
exitWithError('Invalid JSON in --inputs');
|
|
110
142
|
}
|
|
111
143
|
}
|
|
112
|
-
if (options.debug) {
|
|
113
|
-
printDebug('Datalog query', options.query);
|
|
114
|
-
printDebug('Inputs', inputs || 'none');
|
|
115
|
-
printDebug('Regex filter', options.regex || 'none');
|
|
116
|
-
}
|
|
117
144
|
const result = await searchOps.executeDatomicQuery({
|
|
118
145
|
query: options.query,
|
|
119
146
|
inputs,
|
|
@@ -126,7 +153,6 @@ Datalog tips:
|
|
|
126
153
|
// Apply limit and format output
|
|
127
154
|
const limitedMatches = result.matches.slice(0, limit);
|
|
128
155
|
if (options.json) {
|
|
129
|
-
// For JSON output, parse the content back to objects
|
|
130
156
|
const parsed = limitedMatches.map(m => {
|
|
131
157
|
try {
|
|
132
158
|
return JSON.parse(m.content);
|
|
@@ -138,7 +164,6 @@ Datalog tips:
|
|
|
138
164
|
console.log(JSON.stringify(parsed, null, 2));
|
|
139
165
|
}
|
|
140
166
|
else {
|
|
141
|
-
// For text output, show raw results
|
|
142
167
|
if (limitedMatches.length === 0) {
|
|
143
168
|
console.log('No results found.');
|
|
144
169
|
}
|
|
@@ -153,34 +178,22 @@ Datalog tips:
|
|
|
153
178
|
}
|
|
154
179
|
// Determine search type based on options
|
|
155
180
|
const tags = options.tag || [];
|
|
156
|
-
if (tags.length > 0 &&
|
|
181
|
+
if (tags.length > 0 && searchTerms.length === 0) {
|
|
157
182
|
// Tag-only search
|
|
158
183
|
const normalizedTags = tags.map(normalizeTag);
|
|
159
184
|
const useOrLogic = options.any || false;
|
|
160
|
-
if (options.debug) {
|
|
161
|
-
printDebug('Tag search', {
|
|
162
|
-
tags: normalizedTags,
|
|
163
|
-
logic: useOrLogic ? 'OR' : 'AND',
|
|
164
|
-
page: options.page
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
// Search for first tag, then filter by additional tags
|
|
168
185
|
const result = await searchOps.searchForTag(normalizedTags[0], options.page);
|
|
169
186
|
let matches = result.matches;
|
|
170
|
-
// Apply multi-tag filter if more than one tag
|
|
171
187
|
if (normalizedTags.length > 1) {
|
|
172
188
|
matches = matches.filter(m => {
|
|
173
189
|
if (useOrLogic) {
|
|
174
|
-
// OR: has at least one tag
|
|
175
190
|
return normalizedTags.some(tag => contentHasTag(m.content, tag));
|
|
176
191
|
}
|
|
177
192
|
else {
|
|
178
|
-
// AND: has all tags
|
|
179
193
|
return normalizedTags.every(tag => contentHasTag(m.content, tag));
|
|
180
194
|
}
|
|
181
195
|
});
|
|
182
196
|
}
|
|
183
|
-
// Apply negtag filter (exclude blocks with any of these tags)
|
|
184
197
|
const negTags = options.negtag || [];
|
|
185
198
|
if (negTags.length > 0) {
|
|
186
199
|
const normalizedNegTags = negTags.map(normalizeTag);
|
|
@@ -189,24 +202,18 @@ Datalog tips:
|
|
|
189
202
|
const limitedMatches = matches.slice(0, limit);
|
|
190
203
|
console.log(formatSearchResults(limitedMatches, outputOptions));
|
|
191
204
|
}
|
|
192
|
-
else if (
|
|
193
|
-
// Text search
|
|
194
|
-
const searchText =
|
|
195
|
-
if (options.debug) {
|
|
196
|
-
printDebug('Text search', { text: searchText, page: options.page, tag: options.tag });
|
|
197
|
-
}
|
|
205
|
+
else if (searchTerms.length > 0) {
|
|
206
|
+
// Text search
|
|
207
|
+
const searchText = searchTerms.join(' ');
|
|
198
208
|
const result = await searchOps.searchByText({
|
|
199
209
|
text: searchText,
|
|
200
210
|
page_title_uid: options.page
|
|
201
211
|
});
|
|
202
|
-
// Apply client-side filters
|
|
203
212
|
let matches = result.matches;
|
|
204
|
-
// Case-insensitive filter if requested
|
|
205
213
|
if (options.caseInsensitive) {
|
|
206
214
|
const lowerSearchText = searchText.toLowerCase();
|
|
207
215
|
matches = matches.filter(m => m.content.toLowerCase().includes(lowerSearchText));
|
|
208
216
|
}
|
|
209
|
-
// Tag filter if provided
|
|
210
217
|
if (tags.length > 0) {
|
|
211
218
|
const normalizedTags = tags.map(normalizeTag);
|
|
212
219
|
const useOrLogic = options.any || false;
|
|
@@ -219,13 +226,11 @@ Datalog tips:
|
|
|
219
226
|
}
|
|
220
227
|
});
|
|
221
228
|
}
|
|
222
|
-
// Negtag filter (exclude blocks with any of these tags)
|
|
223
229
|
const negTags = options.negtag || [];
|
|
224
230
|
if (negTags.length > 0) {
|
|
225
231
|
const normalizedNegTags = negTags.map(normalizeTag);
|
|
226
232
|
matches = matches.filter(m => !normalizedNegTags.some(tag => contentHasTag(m.content, tag)));
|
|
227
233
|
}
|
|
228
|
-
// Apply limit
|
|
229
234
|
console.log(formatSearchResults(matches.slice(0, limit), outputOptions));
|
|
230
235
|
}
|
|
231
236
|
else {
|
|
@@ -25,6 +25,8 @@ Examples:
|
|
|
25
25
|
|
|
26
26
|
# JSON output for scripting
|
|
27
27
|
roam status --json
|
|
28
|
+
|
|
29
|
+
JSON output fields: { version, graphs: [{ name, default, protected, connected?, error? }] }
|
|
28
30
|
`)
|
|
29
31
|
.action(async (options) => {
|
|
30
32
|
try {
|
|
@@ -34,15 +36,12 @@ Examples:
|
|
|
34
36
|
for (const key of graphKeys) {
|
|
35
37
|
const config = registry.getConfig(key);
|
|
36
38
|
const isDefault = key === registry.defaultKey;
|
|
37
|
-
const isProtected = !!config.
|
|
39
|
+
const isProtected = !!config.protected;
|
|
38
40
|
const status = {
|
|
39
41
|
name: key,
|
|
40
42
|
default: isDefault,
|
|
41
43
|
protected: isProtected,
|
|
42
44
|
};
|
|
43
|
-
if (isProtected) {
|
|
44
|
-
status.writeKey = config.write_key;
|
|
45
|
-
}
|
|
46
45
|
if (options.ping) {
|
|
47
46
|
try {
|
|
48
47
|
const graph = registry.getGraph(key);
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { BatchOperations } from '../../tools/operations/batch.js';
|
|
3
|
+
import { BlockRetrievalOperations } from '../../tools/operations/block-retrieval.js';
|
|
3
4
|
import { parseMarkdownHeadingLevel } from '../../markdown-utils.js';
|
|
4
5
|
import { printDebug, exitWithError } from '../utils/output.js';
|
|
5
6
|
import { resolveGraph } from '../utils/graph.js';
|
|
7
|
+
import { readStdin } from '../utils/input.js';
|
|
6
8
|
// Patterns for TODO/DONE markers (both {{TODO}} and {{[[TODO]]}} formats)
|
|
7
9
|
const TODO_PATTERN = /\{\{(\[\[)?TODO(\]\])?\}\}\s*/g;
|
|
8
10
|
const DONE_PATTERN = /\{\{(\[\[)?DONE(\]\])?\}\}\s*/g;
|
|
@@ -43,7 +45,7 @@ export function createUpdateCommand() {
|
|
|
43
45
|
return new Command('update')
|
|
44
46
|
.description('Update block content, heading, open/closed state, or TODO/DONE status')
|
|
45
47
|
.argument('<uid>', 'Block UID to update (accepts ((uid)) wrapper)')
|
|
46
|
-
.argument('
|
|
48
|
+
.argument('[content]', 'New content. Use # prefix for heading: "# Title" sets H1. Reads from stdin if "-" or omitted (when piped).')
|
|
47
49
|
.option('-H, --heading <level>', 'Set heading level (1-3), or 0 to remove')
|
|
48
50
|
.option('-o, --open', 'Expand block (show children)')
|
|
49
51
|
.option('-c, --closed', 'Collapse block (hide children)')
|
|
@@ -72,30 +74,82 @@ Examples:
|
|
|
72
74
|
roam update abc123def "Task" -T # Set as TODO
|
|
73
75
|
roam update abc123def "Task" -D # Mark as DONE
|
|
74
76
|
roam update abc123def "Task" --clear-status # Remove status marker
|
|
77
|
+
|
|
78
|
+
# Stdin / Partial Updates
|
|
79
|
+
echo "New text" | roam update abc123def # Pipe content
|
|
80
|
+
roam update abc123def -T # Add TODO (fetches existing text)
|
|
81
|
+
roam update abc123def -o # Expand block (keeps text)
|
|
75
82
|
`)
|
|
76
83
|
.action(async (uid, content, options) => {
|
|
77
84
|
try {
|
|
78
85
|
// Strip (( )) wrapper if present
|
|
79
86
|
const blockUid = uid.replace(/^\(\(|\)\)$/g, '');
|
|
80
|
-
|
|
81
|
-
let finalContent
|
|
87
|
+
const graph = resolveGraph(options, true); // This is a write operation
|
|
88
|
+
let finalContent;
|
|
89
|
+
// 1. Determine new content from args or stdin
|
|
90
|
+
if (content && content !== '-') {
|
|
91
|
+
finalContent = content;
|
|
92
|
+
}
|
|
93
|
+
else if (content === '-' || (!content && !process.stdin.isTTY)) {
|
|
94
|
+
finalContent = await readStdin();
|
|
95
|
+
finalContent = finalContent.trim();
|
|
96
|
+
}
|
|
97
|
+
// 2. Identify if we need to fetch existing content
|
|
98
|
+
const isStatusUpdate = options.todo || options.done || options.clearStatus;
|
|
99
|
+
const isHeadingUpdate = options.heading !== undefined;
|
|
100
|
+
const isStateUpdate = options.open || options.closed;
|
|
101
|
+
if (finalContent === undefined) {
|
|
102
|
+
if (isStatusUpdate) {
|
|
103
|
+
// Must fetch to apply status safely
|
|
104
|
+
const blockRetrieval = new BlockRetrievalOperations(graph);
|
|
105
|
+
const block = await blockRetrieval.fetchBlockWithChildren(blockUid, 0);
|
|
106
|
+
if (!block) {
|
|
107
|
+
exitWithError(`Block ${blockUid} not found`);
|
|
108
|
+
}
|
|
109
|
+
finalContent = block.string;
|
|
110
|
+
}
|
|
111
|
+
else if (!isHeadingUpdate && !isStateUpdate) {
|
|
112
|
+
exitWithError('No content or update options provided.');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// 3. Process content if we have it
|
|
82
116
|
let headingLevel;
|
|
83
|
-
if (
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
117
|
+
if (finalContent !== undefined) {
|
|
118
|
+
// Handle explicit heading option
|
|
119
|
+
if (options.heading !== undefined) {
|
|
120
|
+
const level = parseInt(options.heading, 10);
|
|
121
|
+
if (level >= 0 && level <= 3) {
|
|
122
|
+
headingLevel = level === 0 ? undefined : level;
|
|
123
|
+
const { content: stripped } = parseMarkdownHeadingLevel(finalContent);
|
|
124
|
+
finalContent = stripped;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
// Auto-detect heading from content
|
|
129
|
+
const { heading_level, content: stripped } = parseMarkdownHeadingLevel(finalContent);
|
|
130
|
+
if (heading_level > 0) {
|
|
131
|
+
headingLevel = heading_level;
|
|
132
|
+
finalContent = stripped;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Handle TODO/DONE status
|
|
136
|
+
if (options.clearStatus) {
|
|
137
|
+
finalContent = clearStatus(finalContent);
|
|
138
|
+
}
|
|
139
|
+
else if (options.todo) {
|
|
140
|
+
finalContent = applyStatus(finalContent, 'TODO');
|
|
141
|
+
}
|
|
142
|
+
else if (options.done) {
|
|
143
|
+
finalContent = applyStatus(finalContent, 'DONE');
|
|
91
144
|
}
|
|
92
145
|
}
|
|
93
146
|
else {
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
147
|
+
// No content update, just metadata
|
|
148
|
+
if (options.heading !== undefined) {
|
|
149
|
+
const level = parseInt(options.heading, 10);
|
|
150
|
+
if (level >= 0 && level <= 3) {
|
|
151
|
+
headingLevel = level === 0 ? undefined : level;
|
|
152
|
+
}
|
|
99
153
|
}
|
|
100
154
|
}
|
|
101
155
|
// Handle open/closed state
|
|
@@ -106,25 +160,14 @@ Examples:
|
|
|
106
160
|
else if (options.closed) {
|
|
107
161
|
openState = false;
|
|
108
162
|
}
|
|
109
|
-
// Handle TODO/DONE status
|
|
110
|
-
if (options.clearStatus) {
|
|
111
|
-
finalContent = clearStatus(finalContent);
|
|
112
|
-
}
|
|
113
|
-
else if (options.todo) {
|
|
114
|
-
finalContent = applyStatus(finalContent, 'TODO');
|
|
115
|
-
}
|
|
116
|
-
else if (options.done) {
|
|
117
|
-
finalContent = applyStatus(finalContent, 'DONE');
|
|
118
|
-
}
|
|
119
163
|
if (options.debug) {
|
|
120
164
|
printDebug('Block UID', blockUid);
|
|
121
165
|
printDebug('Graph', options.graph || 'default');
|
|
122
|
-
printDebug('Content', finalContent);
|
|
166
|
+
printDebug('Content', finalContent !== undefined ? finalContent : '(no change)');
|
|
123
167
|
printDebug('Heading level', headingLevel ?? 'none');
|
|
124
168
|
printDebug('Open state', openState ?? 'unchanged');
|
|
125
169
|
printDebug('Status', options.todo ? 'TODO' : options.done ? 'DONE' : options.clearStatus ? 'cleared' : 'unchanged');
|
|
126
170
|
}
|
|
127
|
-
const graph = resolveGraph(options, true); // This is a write operation
|
|
128
171
|
const batchOps = new BatchOperations(graph);
|
|
129
172
|
const result = await batchOps.processBatch([{
|
|
130
173
|
action: 'update-block',
|