roam-research-mcp 1.6.0 → 2.4.3
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 +202 -13
- package/build/Roam_Markdown_Cheatsheet.md +116 -269
- 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 +345 -0
- package/build/cli/commands/get.js +156 -43
- package/build/cli/commands/refs.js +63 -32
- package/build/cli/commands/rename.js +58 -0
- package/build/cli/commands/save.js +436 -63
- package/build/cli/commands/search.js +152 -31
- package/build/cli/commands/status.js +91 -0
- package/build/cli/commands/update.js +194 -0
- package/build/cli/roam.js +18 -1
- package/build/cli/utils/graph.js +56 -0
- package/build/cli/utils/input.js +10 -0
- package/build/cli/utils/output.js +34 -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/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 +59 -9
- 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 +123 -42
- package/build/tools/tool-handlers.js +9 -2
- package/build/utils/helpers.js +22 -0
- package/package.json +8 -5
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import { readFileSync } from 'fs';
|
|
2
|
+
import { existsSync, readFileSync } from 'fs';
|
|
3
3
|
import { basename } from 'path';
|
|
4
|
-
import { initializeGraph } from '@roam-research/roam-api-sdk';
|
|
5
|
-
import { API_TOKEN, GRAPH_NAME } from '../../config/environment.js';
|
|
6
4
|
import { PageOperations } from '../../tools/operations/pages.js';
|
|
7
|
-
import {
|
|
5
|
+
import { TodoOperations } from '../../tools/operations/todos.js';
|
|
6
|
+
import { BatchOperations } from '../../tools/operations/batch.js';
|
|
7
|
+
import { parseMarkdown, generateBlockUid, parseMarkdownHeadingLevel } from '../../markdown-utils.js';
|
|
8
8
|
import { printDebug, exitWithError } from '../utils/output.js';
|
|
9
|
+
import { resolveGraph } from '../utils/graph.js';
|
|
10
|
+
import { readStdin } from '../utils/input.js';
|
|
11
|
+
import { formatRoamDate } from '../../utils/helpers.js';
|
|
12
|
+
import { q, createPage as roamCreatePage } from '@roam-research/roam-api-sdk';
|
|
9
13
|
/**
|
|
10
14
|
* Flatten nested MarkdownNode[] to flat array with absolute levels
|
|
11
15
|
*/
|
|
@@ -24,94 +28,463 @@ function flattenNodes(nodes, baseLevel = 1) {
|
|
|
24
28
|
return result;
|
|
25
29
|
}
|
|
26
30
|
/**
|
|
27
|
-
*
|
|
31
|
+
* Check if a string looks like a Roam block UID (9 alphanumeric chars with _ or -)
|
|
28
32
|
*/
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
function isBlockUid(value) {
|
|
34
|
+
// Strip (( )) wrapper if present
|
|
35
|
+
const cleaned = value.replace(/^\(\(|\)\)$/g, '');
|
|
36
|
+
return /^[a-zA-Z0-9_-]{9}$/.test(cleaned);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Check if content looks like a JSON array
|
|
40
|
+
*/
|
|
41
|
+
function looksLikeJsonArray(content) {
|
|
42
|
+
const trimmed = content.trim();
|
|
43
|
+
return trimmed.startsWith('[') && trimmed.endsWith(']');
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Find or create a page by title, returns UID
|
|
47
|
+
*/
|
|
48
|
+
async function findOrCreatePage(graph, title) {
|
|
49
|
+
const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
|
|
50
|
+
const findResults = await q(graph, findQuery, [title]);
|
|
51
|
+
if (findResults && findResults.length > 0) {
|
|
52
|
+
return findResults[0][0];
|
|
33
53
|
}
|
|
34
|
-
|
|
54
|
+
// Create the page if it doesn't exist
|
|
55
|
+
await roamCreatePage(graph, {
|
|
56
|
+
action: 'create-page',
|
|
57
|
+
page: { title }
|
|
58
|
+
});
|
|
59
|
+
const results = await q(graph, findQuery, [title]);
|
|
60
|
+
if (!results || results.length === 0) {
|
|
61
|
+
throw new Error(`Could not find created page: ${title}`);
|
|
62
|
+
}
|
|
63
|
+
return results[0][0];
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Get or create today's daily page UID
|
|
67
|
+
*/
|
|
68
|
+
async function getDailyPageUid(graph) {
|
|
69
|
+
const today = new Date();
|
|
70
|
+
const dateStr = formatRoamDate(today);
|
|
71
|
+
return findOrCreatePage(graph, dateStr);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Find or create a heading block on a page
|
|
75
|
+
*/
|
|
76
|
+
async function findOrCreateHeading(graph, pageUid, heading, headingLevel) {
|
|
77
|
+
// Search for existing heading block
|
|
78
|
+
const headingQuery = `[:find ?uid
|
|
79
|
+
:in $ ?page-uid ?text
|
|
80
|
+
:where
|
|
81
|
+
[?page :block/uid ?page-uid]
|
|
82
|
+
[?page :block/children ?block]
|
|
83
|
+
[?block :block/string ?text]
|
|
84
|
+
[?block :block/uid ?uid]]`;
|
|
85
|
+
const headingResults = await q(graph, headingQuery, [pageUid, heading]);
|
|
86
|
+
if (headingResults && headingResults.length > 0) {
|
|
87
|
+
return headingResults[0][0];
|
|
88
|
+
}
|
|
89
|
+
// Create the heading block
|
|
90
|
+
const batchOps = new BatchOperations(graph);
|
|
91
|
+
const headingUid = generateBlockUid();
|
|
92
|
+
await batchOps.processBatch([{
|
|
93
|
+
action: 'create-block',
|
|
94
|
+
location: { 'parent-uid': pageUid, order: 'last' },
|
|
95
|
+
string: heading,
|
|
96
|
+
uid: headingUid,
|
|
97
|
+
...(headingLevel && { heading: headingLevel })
|
|
98
|
+
}]);
|
|
99
|
+
return headingUid;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Parse JSON content blocks
|
|
103
|
+
*/
|
|
104
|
+
function parseJsonContent(content) {
|
|
105
|
+
const parsed = JSON.parse(content);
|
|
106
|
+
if (!Array.isArray(parsed)) {
|
|
107
|
+
throw new Error('JSON content must be an array of {text, level, heading?} objects');
|
|
108
|
+
}
|
|
109
|
+
return parsed.map((item, index) => {
|
|
110
|
+
if (typeof item.text !== 'string' || typeof item.level !== 'number') {
|
|
111
|
+
throw new Error(`Invalid item at index ${index}: must have "text" (string) and "level" (number)`);
|
|
112
|
+
}
|
|
113
|
+
// If heading not explicitly provided, detect from text and strip hashes
|
|
114
|
+
if (item.heading) {
|
|
115
|
+
return {
|
|
116
|
+
text: item.text,
|
|
117
|
+
level: item.level,
|
|
118
|
+
heading: item.heading
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const { heading_level, content: strippedText } = parseMarkdownHeadingLevel(item.text);
|
|
122
|
+
return {
|
|
123
|
+
text: strippedText,
|
|
124
|
+
level: item.level,
|
|
125
|
+
...(heading_level > 0 && { heading: heading_level })
|
|
126
|
+
};
|
|
127
|
+
});
|
|
35
128
|
}
|
|
36
129
|
export function createSaveCommand() {
|
|
37
130
|
return new Command('save')
|
|
38
|
-
.description('
|
|
39
|
-
.argument('[
|
|
40
|
-
.option('--title <title>', '
|
|
41
|
-
.option('--update', 'Update existing page using smart diff')
|
|
131
|
+
.description('Save text, files, or JSON to pages/blocks. Auto-detects format.')
|
|
132
|
+
.argument('[input]', 'Text, file path, or "-" for stdin (auto-detected)')
|
|
133
|
+
.option('--title <title>', 'Create a new page with this title')
|
|
134
|
+
.option('--update', 'Update existing page using smart diff (preserves block UIDs)')
|
|
135
|
+
.option('-p, --page <ref>', 'Target page by title or UID (default: daily page, creates if missing)')
|
|
136
|
+
.option('--parent <ref>', 'Nest under block UID ((uid)) or heading text (creates if missing). Use # prefix for heading level: "## Section"')
|
|
137
|
+
.option('-c, --categories <tags>', 'Comma-separated tags appended to first block')
|
|
138
|
+
.option('-t, --todo [text]', 'Add TODO item(s) to daily page. Accepts inline text or stdin')
|
|
139
|
+
.option('--json', 'Force JSON array format: [{text, level, heading?}, ...]')
|
|
140
|
+
.option('-g, --graph <name>', 'Target graph key (multi-graph mode)')
|
|
141
|
+
.option('--write-key <key>', 'Write confirmation key (non-default graphs)')
|
|
42
142
|
.option('--debug', 'Show debug information')
|
|
43
|
-
.
|
|
143
|
+
.addHelpText('after', `
|
|
144
|
+
Examples:
|
|
145
|
+
# Quick saves to daily page
|
|
146
|
+
roam save "Quick note" # Single block
|
|
147
|
+
roam save "# Important" -c "work,urgent" # H1 heading with tags
|
|
148
|
+
roam save --todo "Buy groceries" # TODO item
|
|
149
|
+
|
|
150
|
+
# Save under heading (creates if missing)
|
|
151
|
+
roam save --parent "## Notes" "My note" # Under H2 "Notes" heading
|
|
152
|
+
roam save --parent "((blockUid9))" "Child" # Under specific block
|
|
153
|
+
|
|
154
|
+
# Target specific page
|
|
155
|
+
roam save -p "Project X" "Status update" # By title (creates if missing)
|
|
156
|
+
roam save -p "pageUid123" "Note" # By UID
|
|
157
|
+
|
|
158
|
+
# File operations
|
|
159
|
+
roam save notes.md --title "My Notes" # Create page from file
|
|
160
|
+
roam save notes.md --title "My Notes" --update # Smart update (preserves UIDs)
|
|
161
|
+
cat data.json | roam save --json # Pipe JSON blocks
|
|
162
|
+
|
|
163
|
+
# Stdin operations
|
|
164
|
+
echo "Task from CLI" | roam save --todo # Pipe to TODO
|
|
165
|
+
cat note.md | roam save --title "From Pipe" # Pipe file content to new page
|
|
166
|
+
echo "Quick capture" | roam save -p "Inbox" # Pipe to specific page
|
|
167
|
+
|
|
168
|
+
# Combine options
|
|
169
|
+
roam save -p "Work" --parent "## Today" "Done with task" -c "wins"
|
|
170
|
+
|
|
171
|
+
JSON format (--json):
|
|
172
|
+
Array of blocks with text, level, and optional heading:
|
|
173
|
+
[
|
|
174
|
+
{"text": "# Main Title", "level": 1}, # Auto-detects H1
|
|
175
|
+
{"text": "Subheading", "level": 1, "heading": 2}, # Explicit H2
|
|
176
|
+
{"text": "Nested content", "level": 2}, # Child block
|
|
177
|
+
{"text": "Sibling", "level": 2}
|
|
178
|
+
]
|
|
179
|
+
`)
|
|
180
|
+
.action(async (input, options) => {
|
|
44
181
|
try {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
182
|
+
// TODO mode: add a TODO item to today's daily page
|
|
183
|
+
if (options.todo !== undefined) {
|
|
184
|
+
let todoText;
|
|
185
|
+
if (typeof options.todo === 'string' && options.todo.length > 0) {
|
|
186
|
+
todoText = options.todo;
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
if (process.stdin.isTTY) {
|
|
190
|
+
exitWithError('No TODO text. Use: roam save --todo "text" or echo "text" | roam save --todo');
|
|
191
|
+
}
|
|
192
|
+
todoText = (await readStdin()).trim();
|
|
193
|
+
}
|
|
194
|
+
if (!todoText) {
|
|
195
|
+
exitWithError('Empty TODO text');
|
|
196
|
+
}
|
|
197
|
+
const todos = todoText.split('\n').map(t => t.trim()).filter(Boolean);
|
|
198
|
+
if (options.debug) {
|
|
199
|
+
printDebug('TODO mode', true);
|
|
200
|
+
printDebug('Graph', options.graph || 'default');
|
|
201
|
+
printDebug('TODO items', todos);
|
|
202
|
+
}
|
|
203
|
+
const graph = resolveGraph(options, true);
|
|
204
|
+
const todoOps = new TodoOperations(graph);
|
|
205
|
+
const result = await todoOps.addTodos(todos);
|
|
206
|
+
if (result.success) {
|
|
207
|
+
console.log(`Added ${todos.length} TODO item(s) to today's daily page`);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
exitWithError('Failed to save TODO');
|
|
211
|
+
}
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
// Determine content source: file, text argument, or stdin
|
|
215
|
+
let content;
|
|
216
|
+
let isFile = false;
|
|
217
|
+
let sourceFilename;
|
|
218
|
+
if (input && input !== '-') {
|
|
219
|
+
// Check if input is a file path that exists
|
|
220
|
+
if (existsSync(input)) {
|
|
221
|
+
isFile = true;
|
|
222
|
+
sourceFilename = input;
|
|
223
|
+
try {
|
|
224
|
+
content = readFileSync(input, 'utf-8');
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
exitWithError(`Could not read file: ${input}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
// Treat as text content
|
|
232
|
+
content = input;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
// Read from stdin (or if input is explicit '-')
|
|
237
|
+
if (process.stdin.isTTY && input !== '-') {
|
|
238
|
+
exitWithError('No input. Use: roam save "text", roam save <file>, or pipe content');
|
|
239
|
+
}
|
|
240
|
+
content = await readStdin();
|
|
241
|
+
}
|
|
242
|
+
content = content.trim();
|
|
243
|
+
if (!content) {
|
|
244
|
+
exitWithError('Empty content');
|
|
245
|
+
}
|
|
246
|
+
// Determine format: JSON or markdown/text
|
|
247
|
+
const isJson = options.json || (isFile && sourceFilename?.endsWith('.json')) || looksLikeJsonArray(content);
|
|
248
|
+
// Parse content into blocks
|
|
249
|
+
let contentBlocks;
|
|
250
|
+
if (isJson) {
|
|
49
251
|
try {
|
|
50
|
-
|
|
252
|
+
contentBlocks = parseJsonContent(content);
|
|
51
253
|
}
|
|
52
254
|
catch (err) {
|
|
53
|
-
exitWithError(
|
|
255
|
+
exitWithError(err instanceof Error ? err.message : 'Invalid JSON');
|
|
54
256
|
}
|
|
55
|
-
|
|
56
|
-
|
|
257
|
+
}
|
|
258
|
+
else if (isFile || content.includes('\n')) {
|
|
259
|
+
// Multi-line content: parse as markdown
|
|
260
|
+
const nodes = parseMarkdown(content);
|
|
261
|
+
contentBlocks = flattenNodes(nodes);
|
|
57
262
|
}
|
|
58
263
|
else {
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
264
|
+
// Single line text: detect heading syntax and strip hashes
|
|
265
|
+
const { heading_level, content: strippedContent } = parseMarkdownHeadingLevel(content);
|
|
266
|
+
contentBlocks = [{
|
|
267
|
+
text: strippedContent,
|
|
268
|
+
level: 1,
|
|
269
|
+
...(heading_level > 0 && { heading: heading_level })
|
|
270
|
+
}];
|
|
271
|
+
}
|
|
272
|
+
if (contentBlocks.length === 0) {
|
|
273
|
+
exitWithError('No content blocks parsed');
|
|
274
|
+
}
|
|
275
|
+
// Parse categories
|
|
276
|
+
const categories = options.categories
|
|
277
|
+
? options.categories.split(',').map(c => c.trim()).filter(Boolean)
|
|
278
|
+
: undefined;
|
|
279
|
+
// Determine parent type if specified
|
|
280
|
+
let parentUid;
|
|
281
|
+
let parentHeading;
|
|
282
|
+
let parentHeadingLevel;
|
|
283
|
+
if (options.parent) {
|
|
284
|
+
const cleanedParent = options.parent.replace(/^\(\(|\)\)$/g, '');
|
|
285
|
+
if (isBlockUid(cleanedParent)) {
|
|
286
|
+
parentUid = cleanedParent;
|
|
62
287
|
}
|
|
63
|
-
|
|
64
|
-
|
|
288
|
+
else {
|
|
289
|
+
// Parse heading syntax from parent text
|
|
290
|
+
const { heading_level, content } = parseMarkdownHeadingLevel(options.parent);
|
|
291
|
+
parentHeading = content;
|
|
292
|
+
if (heading_level > 0) {
|
|
293
|
+
parentHeadingLevel = heading_level;
|
|
294
|
+
}
|
|
65
295
|
}
|
|
66
|
-
markdownContent = await readStdin();
|
|
67
|
-
pageTitle = options.title;
|
|
68
|
-
}
|
|
69
|
-
if (!markdownContent.trim()) {
|
|
70
|
-
exitWithError('Empty content received');
|
|
71
296
|
}
|
|
72
297
|
if (options.debug) {
|
|
73
|
-
printDebug('
|
|
74
|
-
printDebug('
|
|
75
|
-
printDebug('
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
298
|
+
printDebug('Input', input || 'stdin');
|
|
299
|
+
printDebug('Is file', isFile);
|
|
300
|
+
printDebug('Is JSON', isJson);
|
|
301
|
+
printDebug('Graph', options.graph || 'default');
|
|
302
|
+
printDebug('Content blocks', contentBlocks.length);
|
|
303
|
+
printDebug('Parent UID', parentUid || 'none');
|
|
304
|
+
printDebug('Parent heading', parentHeading || 'none');
|
|
305
|
+
printDebug('Target page', options.page || 'daily page');
|
|
306
|
+
printDebug('Categories', categories || 'none');
|
|
307
|
+
printDebug('Title', options.title || 'none');
|
|
308
|
+
}
|
|
309
|
+
const graph = resolveGraph(options, true);
|
|
310
|
+
// Determine operation mode based on options
|
|
311
|
+
const hasParent = parentUid || parentHeading;
|
|
312
|
+
const hasTitle = options.title;
|
|
313
|
+
const wantsPage = hasTitle && !hasParent;
|
|
314
|
+
if (wantsPage || (isFile && !hasParent)) {
|
|
315
|
+
// PAGE MODE: create a page
|
|
316
|
+
const pageTitle = options.title || (sourceFilename ? basename(sourceFilename, '.md').replace('.json', '') : undefined);
|
|
317
|
+
if (!pageTitle) {
|
|
318
|
+
exitWithError('--title required for page creation from stdin');
|
|
319
|
+
}
|
|
320
|
+
const pageOps = new PageOperations(graph);
|
|
321
|
+
if (options.update) {
|
|
322
|
+
if (isJson) {
|
|
323
|
+
exitWithError('--update is not supported with JSON content');
|
|
324
|
+
}
|
|
325
|
+
const result = await pageOps.updatePageMarkdown(pageTitle, content, false);
|
|
326
|
+
if (result.success) {
|
|
327
|
+
console.log(`Updated page '${pageTitle}'`);
|
|
328
|
+
console.log(` ${result.summary}`);
|
|
329
|
+
if (result.preservedUids.length > 0) {
|
|
330
|
+
console.log(` Preserved ${result.preservedUids.length} block UID(s)`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
exitWithError(`Failed to update page '${pageTitle}'`);
|
|
91
335
|
}
|
|
92
336
|
}
|
|
93
337
|
else {
|
|
94
|
-
|
|
338
|
+
const result = await pageOps.createPage(pageTitle, contentBlocks);
|
|
339
|
+
if (result.success) {
|
|
340
|
+
console.log(`Created page '${pageTitle}' (uid: ${result.uid})`);
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
exitWithError(`Failed to create page '${pageTitle}'`);
|
|
344
|
+
}
|
|
95
345
|
}
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
// BLOCK MODE: add content under parent or to daily page
|
|
349
|
+
if (parentUid) {
|
|
350
|
+
// Direct parent UID: use batch operations
|
|
351
|
+
const batchOps = new BatchOperations(graph);
|
|
352
|
+
// Build batch actions for all content blocks
|
|
353
|
+
const actions = [];
|
|
354
|
+
const uidMap = {};
|
|
355
|
+
for (let i = 0; i < contentBlocks.length; i++) {
|
|
356
|
+
const block = contentBlocks[i];
|
|
357
|
+
const uidPlaceholder = `block-${i}`;
|
|
358
|
+
uidMap[i] = uidPlaceholder;
|
|
359
|
+
// Determine parent for this block
|
|
360
|
+
let blockParent;
|
|
361
|
+
if (block.level === 1) {
|
|
362
|
+
blockParent = parentUid;
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
// Find the closest ancestor at level - 1
|
|
366
|
+
let ancestorIndex = i - 1;
|
|
367
|
+
while (ancestorIndex >= 0 && contentBlocks[ancestorIndex].level >= block.level) {
|
|
368
|
+
ancestorIndex--;
|
|
369
|
+
}
|
|
370
|
+
if (ancestorIndex >= 0) {
|
|
371
|
+
blockParent = `{{uid:${uidMap[ancestorIndex]}}}`;
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
blockParent = parentUid;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
actions.push({
|
|
378
|
+
action: 'create-block',
|
|
379
|
+
location: {
|
|
380
|
+
'parent-uid': blockParent,
|
|
381
|
+
order: 'last'
|
|
382
|
+
},
|
|
383
|
+
string: block.text,
|
|
384
|
+
uid: `{{uid:${uidPlaceholder}}}`,
|
|
385
|
+
...(block.heading && { heading: block.heading })
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
const result = await batchOps.processBatch(actions);
|
|
389
|
+
if (result.success && result.uid_map) {
|
|
390
|
+
// Output the first block's UID
|
|
391
|
+
console.log(result.uid_map['block-0']);
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
const errorMsg = typeof result.error === 'string'
|
|
395
|
+
? result.error
|
|
396
|
+
: result.error?.message || 'Unknown error';
|
|
397
|
+
exitWithError(`Failed to save: ${errorMsg}`);
|
|
398
|
+
}
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
// Parent heading or target page: get target page UID first
|
|
402
|
+
let pageUid;
|
|
403
|
+
if (options.page) {
|
|
404
|
+
// Strip (( )) wrapper if UID, but NOT [[ ]] (that's valid page title syntax)
|
|
405
|
+
const cleanedPage = options.page.replace(/^\(\(|\)\)$/g, '');
|
|
406
|
+
if (isBlockUid(cleanedPage)) {
|
|
407
|
+
pageUid = cleanedPage;
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
pageUid = await findOrCreatePage(graph, options.page);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
pageUid = await getDailyPageUid(graph);
|
|
415
|
+
}
|
|
416
|
+
if (options.debug) {
|
|
417
|
+
printDebug('Target page UID', pageUid);
|
|
418
|
+
}
|
|
419
|
+
// Resolve heading to parent UID if specified
|
|
420
|
+
let targetParentUid;
|
|
421
|
+
if (parentHeading) {
|
|
422
|
+
targetParentUid = await findOrCreateHeading(graph, pageUid, parentHeading, parentHeadingLevel);
|
|
96
423
|
}
|
|
97
424
|
else {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
425
|
+
targetParentUid = pageUid;
|
|
426
|
+
}
|
|
427
|
+
// Format categories as Roam tags if provided
|
|
428
|
+
const categoryTags = categories?.map(cat => {
|
|
429
|
+
return cat.includes(' ') ? `#[[${cat}]]` : `#${cat}`;
|
|
430
|
+
}).join(' ') || '';
|
|
431
|
+
// Create all blocks using batch operations
|
|
432
|
+
const batchOps = new BatchOperations(graph);
|
|
433
|
+
const actions = [];
|
|
434
|
+
const uidMap = {};
|
|
435
|
+
for (let i = 0; i < contentBlocks.length; i++) {
|
|
436
|
+
const block = contentBlocks[i];
|
|
437
|
+
const uidPlaceholder = `block-${i}`;
|
|
438
|
+
uidMap[i] = uidPlaceholder;
|
|
439
|
+
// Determine parent for this block
|
|
440
|
+
let blockParent;
|
|
441
|
+
if (block.level === 1) {
|
|
442
|
+
blockParent = targetParentUid;
|
|
103
443
|
}
|
|
104
|
-
|
|
105
|
-
|
|
444
|
+
else {
|
|
445
|
+
// Find the closest ancestor at level - 1
|
|
446
|
+
let ancestorIndex = i - 1;
|
|
447
|
+
while (ancestorIndex >= 0 && contentBlocks[ancestorIndex].level >= block.level) {
|
|
448
|
+
ancestorIndex--;
|
|
449
|
+
}
|
|
450
|
+
if (ancestorIndex >= 0) {
|
|
451
|
+
blockParent = `{{uid:${uidMap[ancestorIndex]}}}`;
|
|
452
|
+
}
|
|
453
|
+
else {
|
|
454
|
+
blockParent = targetParentUid;
|
|
455
|
+
}
|
|
106
456
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
457
|
+
// Append category tags to first block only
|
|
458
|
+
const blockText = i === 0 && categoryTags
|
|
459
|
+
? `${block.text} ${categoryTags}`
|
|
460
|
+
: block.text;
|
|
461
|
+
actions.push({
|
|
462
|
+
action: 'create-block',
|
|
463
|
+
location: {
|
|
464
|
+
'parent-uid': blockParent,
|
|
465
|
+
order: 'last'
|
|
466
|
+
},
|
|
467
|
+
string: blockText,
|
|
468
|
+
uid: `{{uid:${uidPlaceholder}}}`,
|
|
469
|
+
...(block.heading && { heading: block.heading })
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
const result = await batchOps.processBatch(actions);
|
|
473
|
+
if (result.success && result.uid_map) {
|
|
474
|
+
// Output first block UID (and parent if heading was used)
|
|
475
|
+
if (parentHeading) {
|
|
476
|
+
console.log(`${result.uid_map['block-0']} ${targetParentUid}`);
|
|
110
477
|
}
|
|
111
478
|
else {
|
|
112
|
-
|
|
479
|
+
console.log(result.uid_map['block-0']);
|
|
113
480
|
}
|
|
114
481
|
}
|
|
482
|
+
else {
|
|
483
|
+
const errorMsg = typeof result.error === 'string'
|
|
484
|
+
? result.error
|
|
485
|
+
: result.error?.message || 'Unknown error';
|
|
486
|
+
exitWithError(`Failed to save: ${errorMsg}`);
|
|
487
|
+
}
|
|
115
488
|
}
|
|
116
489
|
catch (error) {
|
|
117
490
|
const message = error instanceof Error ? error.message : String(error);
|