roam-research-mcp 1.4.0 → 2.4.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 +360 -31
- package/build/Roam_Markdown_Cheatsheet.md +30 -12
- package/build/cli/batch/resolver.js +138 -0
- package/build/cli/batch/translator.js +363 -0
- package/build/cli/batch/types.js +4 -0
- package/build/cli/commands/batch.js +352 -0
- package/build/cli/commands/get.js +161 -0
- package/build/cli/commands/refs.js +135 -0
- package/build/cli/commands/rename.js +58 -0
- package/build/cli/commands/save.js +498 -0
- package/build/cli/commands/search.js +240 -0
- package/build/cli/commands/status.js +91 -0
- package/build/cli/commands/update.js +151 -0
- package/build/cli/roam.js +35 -0
- package/build/cli/utils/graph.js +56 -0
- package/build/cli/utils/output.js +122 -0
- package/build/config/environment.js +70 -34
- package/build/config/graph-registry.js +221 -0
- package/build/config/graph-registry.test.js +30 -0
- package/build/search/block-ref-search.js +34 -7
- package/build/search/status-search.js +5 -4
- package/build/server/roam-server.js +98 -53
- package/build/shared/validation.js +10 -5
- package/build/tools/helpers/refs.js +50 -31
- package/build/tools/operations/blocks.js +38 -1
- package/build/tools/operations/memory.js +51 -5
- package/build/tools/operations/pages.js +186 -111
- package/build/tools/operations/search/index.js +5 -1
- package/build/tools/operations/todos.js +1 -1
- package/build/tools/schemas.js +121 -41
- package/build/tools/tool-handlers.js +9 -2
- package/build/utils/helpers.js +22 -0
- package/package.json +11 -7
- package/build/cli/import-markdown.js +0 -98
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { BatchOperations } from '../../tools/operations/batch.js';
|
|
4
|
+
import { PageOperations } from '../../tools/operations/pages.js';
|
|
5
|
+
import { printDebug, exitWithError } from '../utils/output.js';
|
|
6
|
+
import { resolveGraph } from '../utils/graph.js';
|
|
7
|
+
import { collectPageTitles, resolveAllPages, resolveDailyPageUid, needsDailyPage, createResolutionContext, getDailyPageTitle } from '../batch/resolver.js';
|
|
8
|
+
import { translateAllCommands } from '../batch/translator.js';
|
|
9
|
+
/** Read all input from stdin */
|
|
10
|
+
async function readStdin() {
|
|
11
|
+
const chunks = [];
|
|
12
|
+
for await (const chunk of process.stdin) {
|
|
13
|
+
chunks.push(chunk);
|
|
14
|
+
}
|
|
15
|
+
return Buffer.concat(chunks).toString('utf-8');
|
|
16
|
+
}
|
|
17
|
+
// Required params per command type
|
|
18
|
+
const REQUIRED_PARAMS = {
|
|
19
|
+
todo: ['text'],
|
|
20
|
+
create: ['parent', 'text'],
|
|
21
|
+
update: ['uid'],
|
|
22
|
+
delete: ['uid'],
|
|
23
|
+
move: ['uid', 'parent'],
|
|
24
|
+
page: ['title'],
|
|
25
|
+
outline: ['parent', 'items'],
|
|
26
|
+
table: ['parent', 'headers', 'rows'],
|
|
27
|
+
remember: ['text'],
|
|
28
|
+
codeblock: ['parent', 'code']
|
|
29
|
+
};
|
|
30
|
+
const VALID_COMMAND_TYPES = Object.keys(REQUIRED_PARAMS);
|
|
31
|
+
/**
|
|
32
|
+
* Validate command structure and required params
|
|
33
|
+
*/
|
|
34
|
+
function validateCommands(commands) {
|
|
35
|
+
const validated = [];
|
|
36
|
+
for (let i = 0; i < commands.length; i++) {
|
|
37
|
+
const cmd = commands[i];
|
|
38
|
+
if (!cmd || typeof cmd !== 'object') {
|
|
39
|
+
throw new Error(`[${i}] Command must be an object`);
|
|
40
|
+
}
|
|
41
|
+
if (!cmd.command || typeof cmd.command !== 'string') {
|
|
42
|
+
throw new Error(`[${i}] Missing 'command' field`);
|
|
43
|
+
}
|
|
44
|
+
const cmdType = cmd.command;
|
|
45
|
+
if (!VALID_COMMAND_TYPES.includes(cmdType)) {
|
|
46
|
+
throw new Error(`[${i}] Unknown command type '${cmdType}'. Valid: ${VALID_COMMAND_TYPES.join(', ')}`);
|
|
47
|
+
}
|
|
48
|
+
if (!cmd.params || typeof cmd.params !== 'object') {
|
|
49
|
+
throw new Error(`[${i}] Missing 'params' field`);
|
|
50
|
+
}
|
|
51
|
+
// Check required params
|
|
52
|
+
const params = cmd.params;
|
|
53
|
+
const required = REQUIRED_PARAMS[cmdType];
|
|
54
|
+
for (const param of required) {
|
|
55
|
+
if (params[param] === undefined) {
|
|
56
|
+
throw new Error(`[${i}] ${cmdType}: missing required param '${param}'`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
validated.push(cmd);
|
|
60
|
+
}
|
|
61
|
+
return validated;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Validate placeholder references are defined before use
|
|
65
|
+
* Returns list of errors (empty = valid)
|
|
66
|
+
*/
|
|
67
|
+
function validatePlaceholders(commands) {
|
|
68
|
+
const errors = [];
|
|
69
|
+
const definedPlaceholders = new Set();
|
|
70
|
+
for (let i = 0; i < commands.length; i++) {
|
|
71
|
+
const cmd = commands[i];
|
|
72
|
+
const params = cmd.params;
|
|
73
|
+
// Collect placeholder definitions from 'as' params
|
|
74
|
+
if (typeof params.as === 'string') {
|
|
75
|
+
definedPlaceholders.add(params.as);
|
|
76
|
+
}
|
|
77
|
+
// Check placeholder references in 'parent' param
|
|
78
|
+
if (typeof params.parent === 'string') {
|
|
79
|
+
const match = params.parent.match(/^\{\{(\w+)\}\}$/);
|
|
80
|
+
if (match) {
|
|
81
|
+
const refName = match[1];
|
|
82
|
+
if (!definedPlaceholders.has(refName)) {
|
|
83
|
+
errors.push(`[${i}] ${cmd.command}: placeholder "{{${refName}}}" used before definition`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return errors;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Output partial results when a failure occurs mid-batch
|
|
92
|
+
* Helps user know what was created and may need manual cleanup
|
|
93
|
+
*/
|
|
94
|
+
function outputPartialResults(pageResults, failedPage, batchError) {
|
|
95
|
+
const output = {
|
|
96
|
+
success: false,
|
|
97
|
+
partial: true,
|
|
98
|
+
pages_created: pageResults.length,
|
|
99
|
+
...(failedPage && { failed_at: `page: ${failedPage}` }),
|
|
100
|
+
...(batchError && { failed_at: `batch: ${batchError}` })
|
|
101
|
+
};
|
|
102
|
+
if (pageResults.length > 0) {
|
|
103
|
+
output.created_pages = pageResults.map(p => ({
|
|
104
|
+
title: p.title,
|
|
105
|
+
uid: p.uid
|
|
106
|
+
}));
|
|
107
|
+
output.cleanup_hint = 'Pages listed above were created before failure. Delete manually if needed.';
|
|
108
|
+
}
|
|
109
|
+
console.error(JSON.stringify(output, null, 2));
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
/** Format action for dry-run display */
|
|
113
|
+
function formatAction(action, index) {
|
|
114
|
+
const lines = [` ${index + 1}. ${action.action}`];
|
|
115
|
+
if (action.string) {
|
|
116
|
+
const text = String(action.string);
|
|
117
|
+
const preview = text.length > 60 ? text.slice(0, 60) + '...' : text;
|
|
118
|
+
lines.push(` text: "${preview}"`);
|
|
119
|
+
}
|
|
120
|
+
if (action.location) {
|
|
121
|
+
const loc = action.location;
|
|
122
|
+
lines.push(` parent: ${loc['parent-uid']}`);
|
|
123
|
+
}
|
|
124
|
+
if (action.uid) {
|
|
125
|
+
lines.push(` uid: ${action.uid}`);
|
|
126
|
+
}
|
|
127
|
+
return lines.join('\n');
|
|
128
|
+
}
|
|
129
|
+
export function createBatchCommand() {
|
|
130
|
+
return new Command('batch')
|
|
131
|
+
.description('Execute multiple block operations efficiently in a single API call')
|
|
132
|
+
.argument('[file]', 'JSON file with commands (or pipe via stdin)')
|
|
133
|
+
.option('--debug', 'Show debug information')
|
|
134
|
+
.option('--dry-run', 'Validate and show planned actions without executing')
|
|
135
|
+
.option('--simulate', 'Validate structure offline (no API calls)')
|
|
136
|
+
.option('-g, --graph <name>', 'Target graph key (for multi-graph mode)')
|
|
137
|
+
.option('--write-key <key>', 'Write confirmation key (for non-default graphs)')
|
|
138
|
+
.addHelpText('after', `
|
|
139
|
+
Examples:
|
|
140
|
+
# From file
|
|
141
|
+
roam batch commands.json # Execute commands from file
|
|
142
|
+
roam batch commands.json --dry-run # Preview without executing (resolves pages)
|
|
143
|
+
roam batch commands.json --simulate # Validate offline (no API calls)
|
|
144
|
+
|
|
145
|
+
# From stdin
|
|
146
|
+
cat commands.json | roam batch # Pipe commands
|
|
147
|
+
echo '[{"command":"todo","params":{"text":"Task 1"}}]' | roam batch
|
|
148
|
+
|
|
149
|
+
Command schemas:
|
|
150
|
+
todo: {text}
|
|
151
|
+
create: {parent, text, as?, heading?, order?}
|
|
152
|
+
update: {uid, text?, heading?, open?}
|
|
153
|
+
delete: {uid}
|
|
154
|
+
move: {uid, parent, order?}
|
|
155
|
+
page: {title, as?, content?: [{text, level, heading?}...]}
|
|
156
|
+
outline: {parent, items: [string...]}
|
|
157
|
+
table: {parent, headers: [string...], rows: [{label, cells: [string...]}...]}
|
|
158
|
+
remember: {text, categories?: [string...]}
|
|
159
|
+
codeblock: {parent, code, language?}
|
|
160
|
+
|
|
161
|
+
Parent accepts: block UID, "daily", page title, or {{placeholder}}
|
|
162
|
+
|
|
163
|
+
Example:
|
|
164
|
+
[
|
|
165
|
+
{"command": "page", "params": {"title": "Project X", "as": "proj"}},
|
|
166
|
+
{"command": "create", "params": {"parent": "{{proj}}", "text": "# Overview", "as": "overview"}},
|
|
167
|
+
{"command": "outline", "params": {"parent": "{{overview}}", "items": ["Goal 1", "Goal 2"]}},
|
|
168
|
+
{"command": "todo", "params": {"text": "Review project"}}
|
|
169
|
+
]
|
|
170
|
+
`)
|
|
171
|
+
.action(async (file, options) => {
|
|
172
|
+
try {
|
|
173
|
+
// Read input
|
|
174
|
+
let rawInput;
|
|
175
|
+
if (file) {
|
|
176
|
+
try {
|
|
177
|
+
rawInput = readFileSync(file, 'utf-8');
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
exitWithError(`Could not read file: ${file}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
else if (process.stdin.isTTY) {
|
|
184
|
+
exitWithError('No file specified and no input piped. Use: roam batch commands.json or cat commands.json | roam batch');
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
rawInput = await readStdin();
|
|
188
|
+
}
|
|
189
|
+
// Parse JSON
|
|
190
|
+
let parsed;
|
|
191
|
+
try {
|
|
192
|
+
parsed = JSON.parse(rawInput);
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
exitWithError(`Invalid JSON: ${err instanceof SyntaxError ? err.message : 'parse error'}`);
|
|
196
|
+
}
|
|
197
|
+
if (!Array.isArray(parsed)) {
|
|
198
|
+
exitWithError('Input must be a JSON array of commands');
|
|
199
|
+
}
|
|
200
|
+
if (parsed.length === 0) {
|
|
201
|
+
console.log('No commands to execute');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
// Validate and get typed commands
|
|
205
|
+
const commands = validateCommands(parsed);
|
|
206
|
+
// Upfront validation: check placeholder references
|
|
207
|
+
const placeholderErrors = validatePlaceholders(commands);
|
|
208
|
+
if (placeholderErrors.length > 0) {
|
|
209
|
+
exitWithError(`Placeholder validation failed:\n ${placeholderErrors.join('\n ')}`);
|
|
210
|
+
}
|
|
211
|
+
if (options.debug) {
|
|
212
|
+
printDebug('Commands', commands.length);
|
|
213
|
+
printDebug('Graph', options.graph || 'default');
|
|
214
|
+
printDebug('Mode', options.simulate ? 'simulate' : options.dryRun ? 'dry-run' : 'execute');
|
|
215
|
+
}
|
|
216
|
+
// Simulate mode: validate structure without connecting to Roam
|
|
217
|
+
if (options.simulate) {
|
|
218
|
+
const context = createResolutionContext();
|
|
219
|
+
const { actions, pageCommands } = translateAllCommands(commands, context);
|
|
220
|
+
console.log('\n[SIMULATE] Validation passed\n');
|
|
221
|
+
console.log(`Commands: ${commands.length}`);
|
|
222
|
+
console.log(` Pages to create: ${pageCommands.length}`);
|
|
223
|
+
console.log(` Batch actions: ${actions.length}`);
|
|
224
|
+
if (pageCommands.length > 0) {
|
|
225
|
+
console.log('\nPage creations:');
|
|
226
|
+
for (const pc of pageCommands) {
|
|
227
|
+
const as = pc.params.as ? ` → {{${pc.params.as}}}` : '';
|
|
228
|
+
console.log(` - "${pc.params.title}"${as}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
console.log('\nNo API calls made. Use --dry-run to resolve page UIDs.');
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const graph = resolveGraph(options, true);
|
|
235
|
+
// Phase 1: Collect and resolve page titles
|
|
236
|
+
const context = createResolutionContext();
|
|
237
|
+
const pageTitles = collectPageTitles(commands);
|
|
238
|
+
if (pageTitles.size > 0) {
|
|
239
|
+
if (options.debug) {
|
|
240
|
+
printDebug('Pages to resolve', Array.from(pageTitles));
|
|
241
|
+
}
|
|
242
|
+
const resolved = await resolveAllPages(graph, pageTitles);
|
|
243
|
+
for (const [title, uid] of resolved) {
|
|
244
|
+
context.pageUids.set(title, uid);
|
|
245
|
+
}
|
|
246
|
+
// Check for unresolved pages
|
|
247
|
+
const unresolved = Array.from(pageTitles).filter(t => !context.pageUids.has(t));
|
|
248
|
+
if (unresolved.length > 0) {
|
|
249
|
+
exitWithError(`Page(s) not found: ${unresolved.map(t => `"${t}"`).join(', ')}`);
|
|
250
|
+
}
|
|
251
|
+
if (options.debug) {
|
|
252
|
+
printDebug('Resolved pages', Object.fromEntries(context.pageUids));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// Resolve daily page if needed
|
|
256
|
+
if (needsDailyPage(commands)) {
|
|
257
|
+
const dailyUid = await resolveDailyPageUid(graph);
|
|
258
|
+
if (!dailyUid) {
|
|
259
|
+
exitWithError(`Daily page not found: "${getDailyPageTitle()}"`);
|
|
260
|
+
}
|
|
261
|
+
context.dailyPageUid = dailyUid;
|
|
262
|
+
context.pageUids.set(getDailyPageTitle(), dailyUid);
|
|
263
|
+
if (options.debug) {
|
|
264
|
+
printDebug('Daily page UID', dailyUid);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// Phase 2: Translate commands to batch actions
|
|
268
|
+
const { actions, pageCommands } = translateAllCommands(commands, context);
|
|
269
|
+
if (options.debug) {
|
|
270
|
+
printDebug('Batch actions', actions.length);
|
|
271
|
+
printDebug('Page commands', pageCommands.length);
|
|
272
|
+
}
|
|
273
|
+
// Dry run: show actions and exit
|
|
274
|
+
if (options.dryRun) {
|
|
275
|
+
console.log('\n[DRY RUN] Planned actions:\n');
|
|
276
|
+
if (pageCommands.length > 0) {
|
|
277
|
+
console.log('Page creations:');
|
|
278
|
+
for (const pc of pageCommands) {
|
|
279
|
+
const as = pc.params.as ? ` (as: {{${pc.params.as}}})` : '';
|
|
280
|
+
console.log(` - "${pc.params.title}"${as}`);
|
|
281
|
+
}
|
|
282
|
+
console.log('');
|
|
283
|
+
}
|
|
284
|
+
if (actions.length > 0) {
|
|
285
|
+
console.log('Batch actions:');
|
|
286
|
+
console.log(actions.map((a, i) => formatAction(a, i)).join('\n'));
|
|
287
|
+
}
|
|
288
|
+
console.log(`\nTotal: ${pageCommands.length} page(s), ${actions.length} action(s)`);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
// Phase 3: Execute page commands (in parallel where possible)
|
|
292
|
+
const pageResults = [];
|
|
293
|
+
if (pageCommands.length > 0) {
|
|
294
|
+
const pageOps = new PageOperations(graph);
|
|
295
|
+
// Execute page creations - must be sequential if they reference each other
|
|
296
|
+
for (const pc of pageCommands) {
|
|
297
|
+
const result = await pageOps.createPage(pc.params.title, pc.params.content?.map(item => ({
|
|
298
|
+
text: item.text,
|
|
299
|
+
level: item.level,
|
|
300
|
+
heading: item.heading
|
|
301
|
+
})));
|
|
302
|
+
if (!result.success) {
|
|
303
|
+
// Report partial results before exiting
|
|
304
|
+
outputPartialResults(pageResults, pc.params.title);
|
|
305
|
+
}
|
|
306
|
+
pageResults.push({ title: pc.params.title, uid: result.uid });
|
|
307
|
+
if (pc.params.as) {
|
|
308
|
+
context.placeholders.set(pc.params.as, result.uid);
|
|
309
|
+
}
|
|
310
|
+
if (options.debug) {
|
|
311
|
+
printDebug(`Created "${pc.params.title}"`, result.uid);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// Phase 4: Execute batch actions
|
|
316
|
+
let batchResult = { success: true, actions_attempted: 0 };
|
|
317
|
+
if (actions.length > 0) {
|
|
318
|
+
const batchOps = new BatchOperations(graph);
|
|
319
|
+
batchResult = await batchOps.processBatch(actions);
|
|
320
|
+
if (!batchResult.success) {
|
|
321
|
+
const errorMsg = typeof batchResult.error === 'string'
|
|
322
|
+
? batchResult.error
|
|
323
|
+
: batchResult.error?.message || 'Unknown error';
|
|
324
|
+
// Report partial results (pages created before batch failed)
|
|
325
|
+
outputPartialResults(pageResults, undefined, errorMsg);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// Build output
|
|
329
|
+
const uidMap = {};
|
|
330
|
+
for (const pr of pageResults) {
|
|
331
|
+
const pageCmd = pageCommands.find(pc => pc.params.title === pr.title);
|
|
332
|
+
if (pageCmd?.params.as) {
|
|
333
|
+
uidMap[pageCmd.params.as] = pr.uid;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (batchResult.uid_map) {
|
|
337
|
+
Object.assign(uidMap, batchResult.uid_map);
|
|
338
|
+
}
|
|
339
|
+
const output = {
|
|
340
|
+
success: true,
|
|
341
|
+
pages_created: pageResults.length,
|
|
342
|
+
actions_executed: actions.length,
|
|
343
|
+
...(Object.keys(uidMap).length > 0 && { uid_map: uidMap })
|
|
344
|
+
};
|
|
345
|
+
console.log(JSON.stringify(output, null, 2));
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
349
|
+
exitWithError(message);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { PageOperations } from '../../tools/operations/pages.js';
|
|
3
|
+
import { BlockRetrievalOperations } from '../../tools/operations/block-retrieval.js';
|
|
4
|
+
import { SearchOperations } from '../../tools/operations/search/index.js';
|
|
5
|
+
import { formatPageOutput, formatBlockOutput, formatTodoOutput, printDebug, exitWithError } from '../utils/output.js';
|
|
6
|
+
import { resolveGraph } from '../utils/graph.js';
|
|
7
|
+
import { resolveRefs } from '../../tools/helpers/refs.js';
|
|
8
|
+
import { resolveRelativeDate } from '../../utils/helpers.js';
|
|
9
|
+
// Block UID pattern: 9 alphanumeric characters, optionally wrapped in (( ))
|
|
10
|
+
const BLOCK_UID_PATTERN = /^(?:\(\()?([a-zA-Z0-9_-]{9})(?:\)\))?$/;
|
|
11
|
+
/**
|
|
12
|
+
* Recursively resolve block references in a RoamBlock tree
|
|
13
|
+
*/
|
|
14
|
+
async function resolveBlockRefs(graph, block, maxDepth) {
|
|
15
|
+
const resolvedString = await resolveRefs(graph, block.string, 0, maxDepth);
|
|
16
|
+
const resolvedChildren = await Promise.all((block.children || []).map(child => resolveBlockRefs(graph, child, maxDepth)));
|
|
17
|
+
return {
|
|
18
|
+
...block,
|
|
19
|
+
string: resolvedString,
|
|
20
|
+
children: resolvedChildren
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Resolve refs in an array of blocks
|
|
25
|
+
*/
|
|
26
|
+
async function resolveBlocksRefs(graph, blocks, maxDepth) {
|
|
27
|
+
return Promise.all(blocks.map(block => resolveBlockRefs(graph, block, maxDepth)));
|
|
28
|
+
}
|
|
29
|
+
export function createGetCommand() {
|
|
30
|
+
return new Command('get')
|
|
31
|
+
.description('Fetch pages, blocks, or TODO/DONE items with optional ref expansion')
|
|
32
|
+
.argument('[target]', 'Page title, block UID, or relative date (today/yesterday/tomorrow)')
|
|
33
|
+
.option('-j, --json', 'Output as JSON instead of markdown')
|
|
34
|
+
.option('-d, --depth <n>', 'Child levels to fetch (default: 4)', '4')
|
|
35
|
+
.option('-r, --refs [n]', 'Expand ((uid)) refs in output (default depth: 1, max: 4)')
|
|
36
|
+
.option('-f, --flat', 'Flatten hierarchy to single-level list')
|
|
37
|
+
.option('--todo', 'Fetch TODO items')
|
|
38
|
+
.option('--done', 'Fetch DONE items')
|
|
39
|
+
.option('-p, --page <ref>', 'Filter TODOs/DONEs by page title or UID')
|
|
40
|
+
.option('-i, --include <terms>', 'Include items matching these terms (comma-separated)')
|
|
41
|
+
.option('-e, --exclude <terms>', 'Exclude items matching these terms (comma-separated)')
|
|
42
|
+
.option('-g, --graph <name>', 'Target graph key (multi-graph mode)')
|
|
43
|
+
.option('--debug', 'Show query metadata')
|
|
44
|
+
.addHelpText('after', `
|
|
45
|
+
Examples:
|
|
46
|
+
# Fetch pages
|
|
47
|
+
roam get "Project Notes" # Page by title
|
|
48
|
+
roam get today # Today's daily page
|
|
49
|
+
roam get yesterday # Yesterday's daily page
|
|
50
|
+
roam get tomorrow # Tomorrow's daily page
|
|
51
|
+
|
|
52
|
+
# Fetch blocks
|
|
53
|
+
roam get abc123def # Block by UID
|
|
54
|
+
roam get "((abc123def))" # UID with wrapper
|
|
55
|
+
|
|
56
|
+
# Output options
|
|
57
|
+
roam get "Page" -j # JSON output
|
|
58
|
+
roam get "Page" -f # Flat list (no hierarchy)
|
|
59
|
+
roam get abc123def -d 2 # Limit depth to 2 levels
|
|
60
|
+
roam get "Page" -r # Expand block refs (depth 1)
|
|
61
|
+
roam get "Page" -r 3 # Expand refs up to 3 levels deep
|
|
62
|
+
|
|
63
|
+
# TODO/DONE items (refs auto-expanded)
|
|
64
|
+
roam get --todo # All TODOs across graph
|
|
65
|
+
roam get --done # All completed items
|
|
66
|
+
roam get --todo -p "Work" # TODOs on "Work" page
|
|
67
|
+
roam get --todo -i "urgent,blocker" # TODOs containing these terms
|
|
68
|
+
roam get --todo -e "someday,maybe" # Exclude items with terms
|
|
69
|
+
`)
|
|
70
|
+
.action(async (target, options) => {
|
|
71
|
+
try {
|
|
72
|
+
const graph = resolveGraph(options, false);
|
|
73
|
+
const depth = parseInt(options.depth || '4', 10);
|
|
74
|
+
// Parse refs: true/string means enabled, number sets max depth (default 1, max 4)
|
|
75
|
+
const refsDepth = options.refs !== undefined
|
|
76
|
+
? Math.min(4, Math.max(1, parseInt(options.refs, 10) || 1))
|
|
77
|
+
: 0;
|
|
78
|
+
const outputOptions = {
|
|
79
|
+
json: options.json,
|
|
80
|
+
flat: options.flat,
|
|
81
|
+
debug: options.debug
|
|
82
|
+
};
|
|
83
|
+
if (options.debug) {
|
|
84
|
+
printDebug('Target', target);
|
|
85
|
+
printDebug('Graph', options.graph || 'default');
|
|
86
|
+
printDebug('Options', { depth, refs: refsDepth || 'off', ...outputOptions });
|
|
87
|
+
}
|
|
88
|
+
// Handle --todo or --done flags
|
|
89
|
+
if (options.todo || options.done) {
|
|
90
|
+
const status = options.todo ? 'TODO' : 'DONE';
|
|
91
|
+
if (options.debug) {
|
|
92
|
+
printDebug('Status search', { status, page: options.page, include: options.include, exclude: options.exclude });
|
|
93
|
+
}
|
|
94
|
+
const searchOps = new SearchOperations(graph);
|
|
95
|
+
const result = await searchOps.searchByStatus(status, options.page, options.include, options.exclude);
|
|
96
|
+
console.log(formatTodoOutput(result.matches, status, outputOptions));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// For page/block fetching, target is required
|
|
100
|
+
if (!target) {
|
|
101
|
+
exitWithError('Target is required. Use: roam get <page-title> or roam get --todo');
|
|
102
|
+
}
|
|
103
|
+
// Resolve relative date keywords (today, yesterday, tomorrow)
|
|
104
|
+
const resolvedTarget = resolveRelativeDate(target);
|
|
105
|
+
if (options.debug && resolvedTarget !== target) {
|
|
106
|
+
printDebug('Resolved date', `${target} → ${resolvedTarget}`);
|
|
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];
|
|
113
|
+
if (options.debug) {
|
|
114
|
+
printDebug('Fetching block', { uid: blockUid, depth });
|
|
115
|
+
}
|
|
116
|
+
const blockOps = new BlockRetrievalOperations(graph);
|
|
117
|
+
let block = await blockOps.fetchBlockWithChildren(blockUid, depth);
|
|
118
|
+
if (!block) {
|
|
119
|
+
exitWithError(`Block with UID "${blockUid}" not found`);
|
|
120
|
+
}
|
|
121
|
+
// Resolve block references if requested
|
|
122
|
+
if (refsDepth > 0) {
|
|
123
|
+
block = await resolveBlockRefs(graph, block, refsDepth);
|
|
124
|
+
}
|
|
125
|
+
console.log(formatBlockOutput(block, outputOptions));
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
// Fetch page by title
|
|
129
|
+
if (options.debug) {
|
|
130
|
+
printDebug('Fetching page', { title: resolvedTarget, depth });
|
|
131
|
+
}
|
|
132
|
+
const pageOps = new PageOperations(graph);
|
|
133
|
+
const result = await pageOps.fetchPageByTitle(resolvedTarget, 'raw');
|
|
134
|
+
// Parse the raw result
|
|
135
|
+
let blocks;
|
|
136
|
+
if (typeof result === 'string') {
|
|
137
|
+
try {
|
|
138
|
+
blocks = JSON.parse(result);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// Result is already formatted as string (e.g., "Page Title (no content found)")
|
|
142
|
+
console.log(result);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
blocks = result;
|
|
148
|
+
}
|
|
149
|
+
// Resolve block references if requested
|
|
150
|
+
if (refsDepth > 0) {
|
|
151
|
+
blocks = await resolveBlocksRefs(graph, blocks, refsDepth);
|
|
152
|
+
}
|
|
153
|
+
console.log(formatPageOutput(resolvedTarget, blocks, outputOptions));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
158
|
+
exitWithError(message);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { SearchOperations } from '../../tools/operations/search/index.js';
|
|
3
|
+
import { printDebug, exitWithError } from '../utils/output.js';
|
|
4
|
+
import { resolveGraph } from '../utils/graph.js';
|
|
5
|
+
/**
|
|
6
|
+
* Format results grouped by page (default output)
|
|
7
|
+
*/
|
|
8
|
+
function formatGrouped(matches, maxContentLength = 60) {
|
|
9
|
+
if (matches.length === 0) {
|
|
10
|
+
return 'No references found.';
|
|
11
|
+
}
|
|
12
|
+
// Group by page title
|
|
13
|
+
const byPage = new Map();
|
|
14
|
+
for (const match of matches) {
|
|
15
|
+
const pageTitle = match.page_title || 'Unknown Page';
|
|
16
|
+
if (!byPage.has(pageTitle)) {
|
|
17
|
+
byPage.set(pageTitle, []);
|
|
18
|
+
}
|
|
19
|
+
byPage.get(pageTitle).push(match);
|
|
20
|
+
}
|
|
21
|
+
// Format output
|
|
22
|
+
const lines = [];
|
|
23
|
+
for (const [pageTitle, pageMatches] of byPage) {
|
|
24
|
+
lines.push(`[[${pageTitle}]]`);
|
|
25
|
+
for (const match of pageMatches) {
|
|
26
|
+
const truncated = match.content.length > maxContentLength
|
|
27
|
+
? match.content.slice(0, maxContentLength) + '...'
|
|
28
|
+
: match.content;
|
|
29
|
+
lines.push(` ${match.block_uid} ${truncated}`);
|
|
30
|
+
}
|
|
31
|
+
lines.push('');
|
|
32
|
+
}
|
|
33
|
+
return lines.join('\n').trim();
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Format results as raw lines (UID + content)
|
|
37
|
+
*/
|
|
38
|
+
function formatRaw(matches, maxContentLength = 60) {
|
|
39
|
+
if (matches.length === 0) {
|
|
40
|
+
return 'No references found.';
|
|
41
|
+
}
|
|
42
|
+
return matches
|
|
43
|
+
.map(match => {
|
|
44
|
+
const truncated = match.content.length > maxContentLength
|
|
45
|
+
? match.content.slice(0, maxContentLength) + '...'
|
|
46
|
+
: match.content;
|
|
47
|
+
return `${match.block_uid} ${truncated}`;
|
|
48
|
+
})
|
|
49
|
+
.join('\n');
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Parse identifier to determine if it's a block UID or page title
|
|
53
|
+
*/
|
|
54
|
+
function parseIdentifier(identifier) {
|
|
55
|
+
// Check for ((uid)) format
|
|
56
|
+
const blockRefMatch = identifier.match(/^\(\(([^)]+)\)\)$/);
|
|
57
|
+
if (blockRefMatch) {
|
|
58
|
+
return { block_uid: blockRefMatch[1] };
|
|
59
|
+
}
|
|
60
|
+
// Check for [[page]] or #[[page]] format - extract page title
|
|
61
|
+
const pageRefMatch = identifier.match(/^#?\[\[(.+)\]\]$/);
|
|
62
|
+
if (pageRefMatch) {
|
|
63
|
+
return { title: pageRefMatch[1] };
|
|
64
|
+
}
|
|
65
|
+
// Check for #tag format
|
|
66
|
+
if (identifier.startsWith('#')) {
|
|
67
|
+
return { title: identifier.slice(1) };
|
|
68
|
+
}
|
|
69
|
+
// Default: treat as page title
|
|
70
|
+
return { title: identifier };
|
|
71
|
+
}
|
|
72
|
+
export function createRefsCommand() {
|
|
73
|
+
return new Command('refs')
|
|
74
|
+
.description('Find all blocks that reference a page, tag, or block')
|
|
75
|
+
.argument('<identifier>', 'Page title, #tag, [[Page]], or ((block-uid))')
|
|
76
|
+
.option('-n, --limit <n>', 'Limit number of results', '50')
|
|
77
|
+
.option('--json', 'Output as JSON array')
|
|
78
|
+
.option('--raw', 'Output raw UID + content lines (no grouping)')
|
|
79
|
+
.option('--debug', 'Show query metadata')
|
|
80
|
+
.option('-g, --graph <name>', 'Target graph key (for multi-graph mode)')
|
|
81
|
+
.addHelpText('after', `
|
|
82
|
+
Examples:
|
|
83
|
+
# Page references
|
|
84
|
+
roam refs "Project Alpha" # Blocks linking to page
|
|
85
|
+
roam refs "[[Meeting Notes]]" # With bracket syntax
|
|
86
|
+
roam refs "#TODO" # Blocks with #TODO tag
|
|
87
|
+
|
|
88
|
+
# Block references
|
|
89
|
+
roam refs "((abc123def))" # Blocks embedding this block
|
|
90
|
+
|
|
91
|
+
# Output options
|
|
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
|
+
`)
|
|
96
|
+
.action(async (identifier, options) => {
|
|
97
|
+
try {
|
|
98
|
+
const graph = resolveGraph(options, false);
|
|
99
|
+
const limit = parseInt(options.limit || '50', 10);
|
|
100
|
+
const { block_uid, title } = parseIdentifier(identifier);
|
|
101
|
+
if (options.debug) {
|
|
102
|
+
printDebug('Identifier', identifier);
|
|
103
|
+
printDebug('Graph', options.graph || 'default');
|
|
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);
|
|
111
|
+
}
|
|
112
|
+
// Apply limit
|
|
113
|
+
const limitedMatches = result.matches.slice(0, limit);
|
|
114
|
+
// Format output
|
|
115
|
+
if (options.json) {
|
|
116
|
+
const jsonOutput = limitedMatches.map(m => ({
|
|
117
|
+
uid: m.block_uid,
|
|
118
|
+
content: m.content,
|
|
119
|
+
page: m.page_title
|
|
120
|
+
}));
|
|
121
|
+
console.log(JSON.stringify(jsonOutput, null, 2));
|
|
122
|
+
}
|
|
123
|
+
else if (options.raw) {
|
|
124
|
+
console.log(formatRaw(limitedMatches));
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
console.log(formatGrouped(limitedMatches));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
132
|
+
exitWithError(message);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|