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.
Files changed (53) hide show
  1. package/README.md +175 -667
  2. package/build/Roam_Markdown_Cheatsheet.md +138 -289
  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 +3 -8
  6. package/build/cli/commands/get.js +478 -60
  7. package/build/cli/commands/refs.js +51 -31
  8. package/build/cli/commands/save.js +61 -10
  9. package/build/cli/commands/search.js +63 -58
  10. package/build/cli/commands/status.js +3 -4
  11. package/build/cli/commands/update.js +71 -28
  12. package/build/cli/utils/graph.js +6 -2
  13. package/build/cli/utils/input.js +10 -0
  14. package/build/cli/utils/output.js +28 -5
  15. package/build/cli/utils/sort-group.js +110 -0
  16. package/build/config/graph-registry.js +31 -13
  17. package/build/config/graph-registry.test.js +42 -5
  18. package/build/markdown-utils.js +114 -4
  19. package/build/markdown-utils.test.js +125 -0
  20. package/build/query/generator.js +330 -0
  21. package/build/query/index.js +149 -0
  22. package/build/query/parser.js +319 -0
  23. package/build/query/parser.test.js +389 -0
  24. package/build/query/types.js +4 -0
  25. package/build/search/ancestor-rule.js +14 -0
  26. package/build/search/block-ref-search.js +1 -5
  27. package/build/search/hierarchy-search.js +5 -12
  28. package/build/search/index.js +1 -0
  29. package/build/search/status-search.js +10 -9
  30. package/build/search/tag-search.js +8 -24
  31. package/build/search/text-search.js +70 -27
  32. package/build/search/types.js +13 -0
  33. package/build/search/utils.js +71 -2
  34. package/build/server/roam-server.js +4 -3
  35. package/build/shared/index.js +2 -0
  36. package/build/shared/page-validator.js +233 -0
  37. package/build/shared/page-validator.test.js +128 -0
  38. package/build/shared/staged-batch.js +144 -0
  39. package/build/tools/helpers/batch-utils.js +57 -0
  40. package/build/tools/helpers/page-resolution.js +136 -0
  41. package/build/tools/helpers/refs.js +68 -0
  42. package/build/tools/operations/batch.js +75 -3
  43. package/build/tools/operations/block-retrieval.js +15 -4
  44. package/build/tools/operations/block-retrieval.test.js +87 -0
  45. package/build/tools/operations/blocks.js +1 -288
  46. package/build/tools/operations/memory.js +32 -90
  47. package/build/tools/operations/outline.js +38 -156
  48. package/build/tools/operations/pages.js +169 -122
  49. package/build/tools/operations/todos.js +5 -37
  50. package/build/tools/schemas.js +20 -9
  51. package/build/tools/tool-handlers.js +4 -4
  52. package/build/utils/helpers.js +27 -0
  53. package/package.json +1 -1
@@ -28,9 +28,13 @@ export function resolveGraph(options, isWriteOp = false) {
28
28
  const graphKey = options.graph ?? reg.defaultKey;
29
29
  if (!reg.isWriteAllowed(graphKey, options.writeKey)) {
30
30
  const config = reg.getConfig(graphKey);
31
- if (config?.write_key) {
31
+ if (config?.protected) {
32
+ const systemWriteKey = process.env.ROAM_SYSTEM_WRITE_KEY;
33
+ if (!systemWriteKey) {
34
+ throw new Error(`Write to protected graph "${graphKey}" failed: ROAM_SYSTEM_WRITE_KEY not configured.`);
35
+ }
32
36
  throw new Error(`Write to "${graphKey}" graph requires --write-key confirmation.\n` +
33
- `Use: --write-key "${config.write_key}"`);
37
+ `Use: --write-key "${systemWriteKey}"`);
34
38
  }
35
39
  }
36
40
  }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Read all input from stdin
3
+ */
4
+ export async function readStdin() {
5
+ const chunks = [];
6
+ for await (const chunk of process.stdin) {
7
+ chunks.push(chunk);
8
+ }
9
+ return Buffer.concat(chunks).toString('utf-8');
10
+ }
@@ -66,10 +66,9 @@ export function formatSearchResults(results, options) {
66
66
  return 'No results found.';
67
67
  }
68
68
  let output = `Found ${results.length} result(s):\n\n`;
69
- results.forEach((result, index) => {
70
- const pageInfo = result.page_title ? ` (${result.page_title})` : '';
71
- output += `[${index + 1}] ${result.block_uid}${pageInfo}\n`;
72
- output += ` ${result.content}\n\n`;
69
+ results.forEach((result) => {
70
+ const pageInfo = result.page_title ? ` — [[${result.page_title}]]` : '';
71
+ output += `[${result.block_uid}] ${result.content}${pageInfo}\n`;
73
72
  });
74
73
  return output.trim();
75
74
  }
@@ -102,7 +101,31 @@ export function formatTodoOutput(results, status, options) {
102
101
  .replace(/\{\{TODO\}\}\s*/g, '')
103
102
  .replace(/\{\{\[\[DONE\]\]\}\}\s*/g, '')
104
103
  .replace(/\{\{DONE\}\}\s*/g, '');
105
- output += `- [${status === 'DONE' ? 'x' : ' '}] ${cleanContent} (${item.block_uid})\n`;
104
+ output += `[${item.block_uid}] [${status === 'DONE' ? 'x' : ' '}] ${cleanContent}\n`;
105
+ }
106
+ }
107
+ return output.trim();
108
+ }
109
+ /**
110
+ * Format grouped search results for output
111
+ */
112
+ export function formatGroupedOutput(grouped, options) {
113
+ if (options.json) {
114
+ return JSON.stringify(grouped, null, 2);
115
+ }
116
+ const { groups, meta } = grouped;
117
+ const groupKeys = Object.keys(groups);
118
+ if (groupKeys.length === 0) {
119
+ return 'No results found.';
120
+ }
121
+ let output = `Found ${meta.total} item(s) in ${meta.groups_count} group(s):\n`;
122
+ for (const groupName of groupKeys) {
123
+ const items = groups[groupName];
124
+ output += `\n## ${groupName}\n`;
125
+ for (const item of items) {
126
+ // Format: [uid] content with optional page reference
127
+ const pageRef = item.page_title ? ` — [[${item.page_title}]]` : '';
128
+ output += `[${item.block_uid}] ${item.content}${pageRef}\n`;
106
129
  }
107
130
  }
108
131
  return output.trim();
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Sort search results by a specified field and direction
3
+ */
4
+ export function sortResults(matches, options) {
5
+ const { field, direction } = options;
6
+ const multiplier = direction === 'asc' ? 1 : -1;
7
+ return [...matches].sort((a, b) => {
8
+ let comparison = 0;
9
+ switch (field) {
10
+ case 'created':
11
+ comparison = (a.created || 0) - (b.created || 0);
12
+ break;
13
+ case 'modified':
14
+ comparison = (a.modified || 0) - (b.modified || 0);
15
+ break;
16
+ case 'page':
17
+ comparison = (a.page_title || '').localeCompare(b.page_title || '');
18
+ break;
19
+ }
20
+ return comparison * multiplier;
21
+ });
22
+ }
23
+ /**
24
+ * Group search results by page title
25
+ */
26
+ export function groupByPage(matches) {
27
+ const groups = {};
28
+ for (const match of matches) {
29
+ const key = match.page_title || '(No Page)';
30
+ if (!groups[key]) {
31
+ groups[key] = [];
32
+ }
33
+ groups[key].push(match);
34
+ }
35
+ return {
36
+ groups,
37
+ meta: {
38
+ total: matches.length,
39
+ groups_count: Object.keys(groups).length
40
+ }
41
+ };
42
+ }
43
+ /**
44
+ * Group search results by tag
45
+ * Matches are grouped by the most specific matching subtag of the search tag
46
+ * Each match appears only once, under its most specific matching tag
47
+ */
48
+ export function groupByTag(matches, searchTag) {
49
+ const groups = {};
50
+ const normalizedSearchTag = normalizeTagName(searchTag);
51
+ for (const match of matches) {
52
+ const tags = match.tags || [];
53
+ // Find the most specific matching tag (longest path that starts with searchTag)
54
+ let bestTag = searchTag; // Default to the search tag itself
55
+ let bestLength = 0;
56
+ for (const tag of tags) {
57
+ const normalizedTag = normalizeTagName(tag);
58
+ // Check if this tag matches or is a subtag of the search tag
59
+ if (normalizedTag === normalizedSearchTag ||
60
+ normalizedTag.startsWith(normalizedSearchTag + '/')) {
61
+ if (normalizedTag.length > bestLength) {
62
+ bestTag = tag;
63
+ bestLength = normalizedTag.length;
64
+ }
65
+ }
66
+ }
67
+ // Group under the best matching tag
68
+ if (!groups[bestTag]) {
69
+ groups[bestTag] = [];
70
+ }
71
+ groups[bestTag].push(match);
72
+ }
73
+ // Sort groups by tag name for consistent output
74
+ const sortedGroups = {};
75
+ const sortedKeys = Object.keys(groups).sort();
76
+ for (const key of sortedKeys) {
77
+ sortedGroups[key] = groups[key];
78
+ }
79
+ return {
80
+ groups: sortedGroups,
81
+ meta: {
82
+ total: matches.length,
83
+ groups_count: Object.keys(sortedGroups).length
84
+ }
85
+ };
86
+ }
87
+ /**
88
+ * Group results by specified field
89
+ */
90
+ export function groupResults(matches, options) {
91
+ switch (options.by) {
92
+ case 'page':
93
+ return groupByPage(matches);
94
+ case 'tag':
95
+ return groupByTag(matches, options.searchTag || '');
96
+ }
97
+ }
98
+ /**
99
+ * Normalize tag name for comparison (lowercase, trim whitespace)
100
+ */
101
+ function normalizeTagName(tag) {
102
+ return tag.toLowerCase().trim();
103
+ }
104
+ /**
105
+ * Get default sort direction for a field
106
+ */
107
+ export function getDefaultDirection(field) {
108
+ // Dates default to descending (newest first), alphabetical to ascending
109
+ return field === 'page' ? 'asc' : 'desc';
110
+ }
@@ -4,7 +4,7 @@
4
4
  * Supports:
5
5
  * - Multiple graph configurations via ROAM_GRAPHS env var
6
6
  * - Backwards compatibility with single graph via ROAM_API_TOKEN/ROAM_GRAPH_NAME
7
- * - Write protection for non-default graphs via write_key confirmation
7
+ * - Write protection via protected: true flag + ROAM_SYSTEM_WRITE_KEY env var
8
8
  * - Lazy graph initialization (connects only when first accessed)
9
9
  */
10
10
  import { initializeGraph } from '@roam-research/roam-api-sdk';
@@ -54,6 +54,21 @@ export class GraphRegistry {
54
54
  getAvailableGraphs() {
55
55
  return Array.from(this.configs.keys());
56
56
  }
57
+ /**
58
+ * Get the memories tag for a graph
59
+ * Priority: graph config > ROAM_MEMORIES_TAG env var > "Memories"
60
+ * Returns null if explicitly disabled (memoriesTag: false)
61
+ */
62
+ getMemoriesTag(key) {
63
+ const resolvedKey = key ?? this.defaultKey;
64
+ const config = this.configs.get(resolvedKey);
65
+ // If explicitly disabled, return null
66
+ if (config?.memoriesTag === false) {
67
+ return null;
68
+ }
69
+ // Priority: per-graph config > env var > default
70
+ return config?.memoriesTag ?? process.env.ROAM_MEMORIES_TAG ?? 'Memories';
71
+ }
57
72
  /**
58
73
  * Get an initialized Graph instance, creating it lazily if needed
59
74
  * @param key - Graph key from config. Defaults to defaultKey if not specified.
@@ -84,8 +99,8 @@ export class GraphRegistry {
84
99
  * Rules:
85
100
  * - Writes to default graph are always allowed
86
101
  * - Writes to non-default graphs require:
87
- * - If write_key is configured: must provide matching write_key
88
- * - If no write_key configured: writes are allowed
102
+ * - If protected: true, must provide matching ROAM_SYSTEM_WRITE_KEY
103
+ * - If not protected: writes are allowed
89
104
  */
90
105
  isWriteAllowed(graphKey, providedWriteKey) {
91
106
  const resolvedKey = graphKey ?? this.defaultKey;
@@ -97,12 +112,13 @@ export class GraphRegistry {
97
112
  if (!config) {
98
113
  return false; // Unknown graph
99
114
  }
100
- // If no write_key is configured, allow writes
101
- if (!config.write_key) {
115
+ // If graph is not protected, allow writes
116
+ if (!config.protected) {
102
117
  return true;
103
118
  }
104
- // Check if provided key matches
105
- return providedWriteKey === config.write_key;
119
+ // Check if provided key matches ROAM_SYSTEM_WRITE_KEY
120
+ const systemWriteKey = process.env.ROAM_SYSTEM_WRITE_KEY;
121
+ return !!systemWriteKey && providedWriteKey === systemWriteKey;
106
122
  }
107
123
  /**
108
124
  * Validate write access and return an informative error if denied
@@ -117,9 +133,13 @@ export class GraphRegistry {
117
133
  if (!config) {
118
134
  throw new McpError(ErrorCode.InvalidParams, `Unknown graph: "${resolvedKey}". Available graphs: ${this.getAvailableGraphs().join(', ')}`);
119
135
  }
136
+ const systemWriteKey = process.env.ROAM_SYSTEM_WRITE_KEY;
137
+ if (!systemWriteKey) {
138
+ throw new McpError(ErrorCode.InvalidParams, `Write to protected graph "${resolvedKey}" failed: ROAM_SYSTEM_WRITE_KEY not configured.`);
139
+ }
120
140
  // Provide informative error with the required key
121
141
  throw new McpError(ErrorCode.InvalidParams, `Write to "${resolvedKey}" graph requires write_key confirmation.\n` +
122
- `Provide write_key: "${config.write_key}" to proceed.`);
142
+ `Provide write_key: "${systemWriteKey}" to proceed.`);
123
143
  }
124
144
  }
125
145
  /**
@@ -151,15 +171,13 @@ export class GraphRegistry {
151
171
  for (const key of graphKeys) {
152
172
  const config = this.configs.get(key);
153
173
  const isDefault = key === this.defaultKey;
154
- const isProtected = !!config.write_key;
174
+ const isProtected = !!config.protected;
155
175
  const defaultCol = isDefault ? '✓' : '';
156
- const protectedCol = isProtected
157
- ? `Yes (requires \`write_key: "${config.write_key}"\`)`
158
- : 'No';
176
+ const protectedCol = isProtected ? 'Yes' : 'No';
159
177
  lines.push(`| ${key} | ${defaultCol} | ${protectedCol} |`);
160
178
  }
161
179
  lines.push('');
162
- lines.push('> **Note:** Write operations to protected graphs require the `write_key` parameter.');
180
+ lines.push('> **Note:** Write operations to protected graphs require the `write_key` parameter. The key will be shown in the error message if omitted.');
163
181
  lines.push('');
164
182
  lines.push('---');
165
183
  lines.push('');
@@ -1,6 +1,43 @@
1
- import { describe, it, expect } from 'vitest';
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
2
  import { GraphRegistry } from './graph-registry.js';
3
3
  describe('GraphRegistry', () => {
4
+ describe('getMemoriesTag', () => {
5
+ const originalEnv = process.env.ROAM_MEMORIES_TAG;
6
+ afterEach(() => {
7
+ // Restore original env
8
+ if (originalEnv !== undefined) {
9
+ process.env.ROAM_MEMORIES_TAG = originalEnv;
10
+ }
11
+ else {
12
+ delete process.env.ROAM_MEMORIES_TAG;
13
+ }
14
+ });
15
+ it('returns per-graph memoriesTag when configured', () => {
16
+ const registry = new GraphRegistry({
17
+ personal: { token: 't1', graph: 'g1', memoriesTag: '#PersonalMemories' },
18
+ system: { token: 't2', graph: 'g2', memoriesTag: '#[[PAI/Memories]]' },
19
+ }, 'personal');
20
+ expect(registry.getMemoriesTag('personal')).toBe('#PersonalMemories');
21
+ expect(registry.getMemoriesTag('system')).toBe('#[[PAI/Memories]]');
22
+ });
23
+ it('falls back to ROAM_MEMORIES_TAG env var when not configured per-graph', () => {
24
+ process.env.ROAM_MEMORIES_TAG = '#EnvMemories';
25
+ const registry = new GraphRegistry({ default: { token: 't', graph: 'g' } }, 'default');
26
+ expect(registry.getMemoriesTag()).toBe('#EnvMemories');
27
+ });
28
+ it('falls back to "Memories" when neither per-graph nor env configured', () => {
29
+ delete process.env.ROAM_MEMORIES_TAG;
30
+ const registry = new GraphRegistry({ default: { token: 't', graph: 'g' } }, 'default');
31
+ expect(registry.getMemoriesTag()).toBe('Memories');
32
+ });
33
+ it('uses default graph when key not specified', () => {
34
+ const registry = new GraphRegistry({
35
+ personal: { token: 't1', graph: 'g1', memoriesTag: '#Personal' },
36
+ work: { token: 't2', graph: 'g2', memoriesTag: '#Work' },
37
+ }, 'personal');
38
+ expect(registry.getMemoriesTag()).toBe('#Personal');
39
+ });
40
+ });
4
41
  describe('getGraphInfoMarkdown', () => {
5
42
  it('returns empty string for single-graph mode with default key', () => {
6
43
  const registry = new GraphRegistry({ default: { token: 'token', graph: 'graph' } }, 'default');
@@ -9,21 +46,21 @@ describe('GraphRegistry', () => {
9
46
  it('returns markdown table for multi-graph mode', () => {
10
47
  const registry = new GraphRegistry({
11
48
  personal: { token: 'token1', graph: 'personal-graph' },
12
- work: { token: 'token2', graph: 'work-graph', write_key: 'confirm' },
49
+ work: { token: 'token2', graph: 'work-graph', protected: true },
13
50
  }, 'personal');
14
51
  const markdown = registry.getGraphInfoMarkdown();
15
52
  expect(markdown).toContain('## Available Graphs');
16
53
  expect(markdown).toContain('| personal | ✓ | No |');
17
- expect(markdown).toContain('| work | | Yes (requires `write_key: "confirm"`) |');
54
+ expect(markdown).toContain('| work | | Yes |');
18
55
  expect(markdown).toContain('> **Note:** Write operations to protected graphs');
19
56
  });
20
57
  it('shows write protection for default graph if configured', () => {
21
58
  const registry = new GraphRegistry({
22
- main: { token: 'token1', graph: 'main-graph', write_key: 'secret' },
59
+ main: { token: 'token1', graph: 'main-graph', protected: true },
23
60
  backup: { token: 'token2', graph: 'backup-graph' },
24
61
  }, 'main');
25
62
  const markdown = registry.getGraphInfoMarkdown();
26
- expect(markdown).toContain('| main | ✓ | Yes (requires `write_key: "secret"`) |');
63
+ expect(markdown).toContain('| main | ✓ | Yes |');
27
64
  expect(markdown).toContain('| backup | | No |');
28
65
  });
29
66
  });
@@ -1,4 +1,7 @@
1
1
  import { randomBytes } from 'crypto';
2
+ // Regex patterns for markdown elements
3
+ const NUMBERED_LIST_REGEX = /^(\s*)\d+\.\s+(.*)$/;
4
+ const HORIZONTAL_RULE_REGEX = /^(\s*)(-{3,}|\*{3,}|_{3,})\s*$/;
2
5
  /**
3
6
  * Check if text has a traditional markdown table
4
7
  */
@@ -61,6 +64,21 @@ function parseMarkdownHeadingLevel(text) {
61
64
  };
62
65
  }
63
66
  function convertToRoamMarkdown(text) {
67
+ // Protect inline code and code blocks from transformation
68
+ const codeBlocks = [];
69
+ // Use null bytes to create a unique placeholder that won't be transformed
70
+ const PLACEHOLDER_START = '\x00\x01CB';
71
+ const PLACEHOLDER_END = '\x02\x00';
72
+ // Extract code blocks (``` ... ```) first
73
+ text = text.replace(/```[\s\S]*?```/g, (match) => {
74
+ codeBlocks.push(match);
75
+ return `${PLACEHOLDER_START}${codeBlocks.length - 1}${PLACEHOLDER_END}`;
76
+ });
77
+ // Extract inline code (` ... `)
78
+ text = text.replace(/`[^`]+`/g, (match) => {
79
+ codeBlocks.push(match);
80
+ return `${PLACEHOLDER_START}${codeBlocks.length - 1}${PLACEHOLDER_END}`;
81
+ });
64
82
  // Handle double asterisks/underscores (bold)
65
83
  text = text.replace(/\*\*(.+?)\*\*/g, '**$1**'); // Preserve double asterisks
66
84
  // Handle single asterisks/underscores (italic)
@@ -73,6 +91,10 @@ function convertToRoamMarkdown(text) {
73
91
  text = text.replace(/- \[x\]/g, '- {{[[DONE]]}}');
74
92
  // Convert tables
75
93
  text = convertAllTables(text);
94
+ // Restore protected code blocks
95
+ text = text.replace(new RegExp(`${PLACEHOLDER_START}(\\d+)${PLACEHOLDER_END}`, 'g'), (_, index) => {
96
+ return codeBlocks[parseInt(index, 10)];
97
+ });
76
98
  return text;
77
99
  }
78
100
  function parseMarkdown(markdown) {
@@ -108,8 +130,13 @@ function parseMarkdown(markdown) {
108
130
  }
109
131
  if (inCodeBlockFirstPass || trimmedLine === '')
110
132
  continue;
133
+ // Check for numbered list, bullet list, or plain line
134
+ const numberedMatch = line.match(NUMBERED_LIST_REGEX);
111
135
  const bulletMatch = trimmedLine.match(/^(\s*)[-*+]\s+/);
112
- if (bulletMatch) {
136
+ if (numberedMatch) {
137
+ indentationSet.add(numberedMatch[1].length);
138
+ }
139
+ else if (bulletMatch) {
113
140
  indentationSet.add(bulletMatch[1].length);
114
141
  }
115
142
  else {
@@ -214,10 +241,42 @@ function parseMarkdown(markdown) {
214
241
  if (trimmedLine === '') {
215
242
  continue;
216
243
  }
244
+ // Check for horizontal rule (---, ***, ___)
245
+ const hrMatch = line.match(HORIZONTAL_RULE_REGEX);
246
+ if (hrMatch) {
247
+ const hrIndentation = hrMatch[1].length;
248
+ const hrLevel = getLevel(hrIndentation);
249
+ const hrNode = {
250
+ content: '---', // Roam's HR format
251
+ level: hrLevel,
252
+ is_hr: true,
253
+ children: []
254
+ };
255
+ while (stack.length > hrLevel) {
256
+ stack.pop();
257
+ }
258
+ if (hrLevel === 0 || !stack[hrLevel - 1]) {
259
+ rootNodes.push(hrNode);
260
+ stack[0] = hrNode;
261
+ }
262
+ else {
263
+ stack[hrLevel - 1].children.push(hrNode);
264
+ }
265
+ stack[hrLevel] = hrNode;
266
+ continue;
267
+ }
217
268
  let indentation;
218
269
  let contentToParse;
270
+ let isNumberedItem = false;
271
+ // Check for numbered list item (1., 2., etc.)
272
+ const numberedMatch = line.match(NUMBERED_LIST_REGEX);
219
273
  const bulletMatch = trimmedLine.match(/^(\s*)[-*+]\s+/);
220
- if (bulletMatch) {
274
+ if (numberedMatch) {
275
+ indentation = numberedMatch[1].length;
276
+ contentToParse = numberedMatch[2];
277
+ isNumberedItem = true;
278
+ }
279
+ else if (bulletMatch) {
221
280
  indentation = bulletMatch[1].length;
222
281
  contentToParse = trimmedLine.substring(bulletMatch[0].length);
223
282
  }
@@ -239,9 +298,16 @@ function parseMarkdown(markdown) {
239
298
  if (level === 0 || !stack[level - 1]) {
240
299
  rootNodes.push(node);
241
300
  stack[0] = node;
301
+ // Root-level numbered items: no parent to set view type on
302
+ // They'll appear as regular blocks (Roam doesn't support numbered view at root)
242
303
  }
243
304
  else {
244
- stack[level - 1].children.push(node);
305
+ const parent = stack[level - 1];
306
+ parent.children.push(node);
307
+ // If this is the first numbered item under a parent, set parent's view type
308
+ if (isNumberedItem && parent.children_view_type !== 'numbered') {
309
+ parent.children_view_type = 'numbered';
310
+ }
245
311
  }
246
312
  stack[level] = node;
247
313
  }
@@ -302,6 +368,7 @@ function convertNodesToBlocks(nodes) {
302
368
  uid: generateBlockUid(),
303
369
  content: node.content,
304
370
  ...(node.heading_level && { heading_level: node.heading_level }), // Preserve heading level if present
371
+ ...(node.children_view_type && { children_view_type: node.children_view_type }), // Preserve view type for numbered lists
305
372
  children: convertNodesToBlocks(node.children)
306
373
  }));
307
374
  }
@@ -338,5 +405,48 @@ function convertToRoamActions(nodes, parentUid, order = 'last') {
338
405
  createBlockActions(blocks, parentUid, order);
339
406
  return actions;
340
407
  }
408
+ /**
409
+ * Converts markdown nodes to Roam batch actions, grouped by nesting level.
410
+ * This ensures parent blocks exist before child blocks are created.
411
+ * Returns an array of action arrays, where index 0 contains root-level actions,
412
+ * index 1 contains first-level child actions, etc.
413
+ */
414
+ function convertToRoamActionsStaged(nodes, parentUid, order = 'last') {
415
+ // First convert nodes to blocks with UIDs
416
+ const blocks = convertNodesToBlocks(nodes);
417
+ const actionsByLevel = [];
418
+ // Helper function to recursively create actions, tracking depth
419
+ function createBlockActions(blocks, parentUid, order, depth) {
420
+ // Ensure array exists for this depth
421
+ if (!actionsByLevel[depth]) {
422
+ actionsByLevel[depth] = [];
423
+ }
424
+ for (let i = 0; i < blocks.length; i++) {
425
+ const block = blocks[i];
426
+ // Create the current block
427
+ const action = {
428
+ action: 'create-block',
429
+ location: {
430
+ 'parent-uid': parentUid,
431
+ order: typeof order === 'number' ? order + i : i
432
+ },
433
+ block: {
434
+ uid: block.uid,
435
+ string: block.content,
436
+ ...(block.heading_level && { heading: block.heading_level }),
437
+ ...(block.children_view_type && { 'children-view-type': block.children_view_type })
438
+ }
439
+ };
440
+ actionsByLevel[depth].push(action);
441
+ // Create child blocks if any
442
+ if (block.children.length > 0) {
443
+ createBlockActions(block.children, block.uid, 'last', depth + 1);
444
+ }
445
+ }
446
+ }
447
+ // Create all block actions starting at depth 0
448
+ createBlockActions(blocks, parentUid, order, 0);
449
+ return actionsByLevel;
450
+ }
341
451
  // Export public functions and types
342
- export { parseMarkdown, convertToRoamActions, hasMarkdownTable, convertAllTables, convertToRoamMarkdown, parseMarkdownHeadingLevel };
452
+ export { parseMarkdown, convertToRoamActions, convertToRoamActionsStaged, hasMarkdownTable, convertAllTables, convertToRoamMarkdown, parseMarkdownHeadingLevel };
@@ -0,0 +1,125 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseMarkdown, convertToRoamActions } from './markdown-utils.js';
3
+ describe('markdown-utils', () => {
4
+ describe('parseMarkdown - numbered lists', () => {
5
+ it('should detect numbered list items and strip prefixes', () => {
6
+ const markdown = `1. First item
7
+ 2. Second item
8
+ 3. Third item`;
9
+ const nodes = parseMarkdown(markdown);
10
+ expect(nodes).toHaveLength(3);
11
+ expect(nodes[0].content).toBe('First item');
12
+ expect(nodes[1].content).toBe('Second item');
13
+ expect(nodes[2].content).toBe('Third item');
14
+ // Root-level numbered items don't get children_view_type (no parent)
15
+ expect(nodes[0].children_view_type).toBeUndefined();
16
+ });
17
+ it('should set children_view_type: numbered on parent of numbered items', () => {
18
+ const markdown = `Parent block
19
+ 1. First numbered
20
+ 2. Second numbered`;
21
+ const nodes = parseMarkdown(markdown);
22
+ expect(nodes).toHaveLength(1);
23
+ expect(nodes[0].content).toBe('Parent block');
24
+ expect(nodes[0].children_view_type).toBe('numbered');
25
+ expect(nodes[0].children).toHaveLength(2);
26
+ expect(nodes[0].children[0].content).toBe('First numbered');
27
+ expect(nodes[0].children[1].content).toBe('Second numbered');
28
+ });
29
+ it('should handle nested numbered lists', () => {
30
+ const markdown = `- Parent
31
+ 1. First
32
+ 2. Second
33
+ 1. Nested first
34
+ 2. Nested second`;
35
+ const nodes = parseMarkdown(markdown);
36
+ expect(nodes).toHaveLength(1);
37
+ expect(nodes[0].content).toBe('Parent');
38
+ expect(nodes[0].children_view_type).toBe('numbered');
39
+ expect(nodes[0].children).toHaveLength(2);
40
+ expect(nodes[0].children[1].children_view_type).toBe('numbered');
41
+ expect(nodes[0].children[1].children).toHaveLength(2);
42
+ });
43
+ it('should handle mixed bullet and numbered lists', () => {
44
+ const markdown = `- Bullet item
45
+ 1. Numbered item
46
+ - Another bullet`;
47
+ const nodes = parseMarkdown(markdown);
48
+ expect(nodes).toHaveLength(3);
49
+ expect(nodes[0].content).toBe('Bullet item');
50
+ expect(nodes[1].content).toBe('Numbered item');
51
+ expect(nodes[2].content).toBe('Another bullet');
52
+ });
53
+ it('should handle double-digit numbers', () => {
54
+ const markdown = `10. Tenth item
55
+ 11. Eleventh item
56
+ 99. Ninety-ninth item`;
57
+ const nodes = parseMarkdown(markdown);
58
+ expect(nodes).toHaveLength(3);
59
+ expect(nodes[0].content).toBe('Tenth item');
60
+ expect(nodes[1].content).toBe('Eleventh item');
61
+ expect(nodes[2].content).toBe('Ninety-ninth item');
62
+ });
63
+ });
64
+ describe('parseMarkdown - horizontal rules', () => {
65
+ it('should convert --- to Roam HR', () => {
66
+ const markdown = `Before
67
+ ---
68
+ After`;
69
+ const nodes = parseMarkdown(markdown);
70
+ expect(nodes).toHaveLength(3);
71
+ expect(nodes[0].content).toBe('Before');
72
+ expect(nodes[1].content).toBe('---');
73
+ expect(nodes[1].is_hr).toBe(true);
74
+ expect(nodes[2].content).toBe('After');
75
+ });
76
+ it('should convert *** to Roam HR', () => {
77
+ const markdown = `Before
78
+ ***
79
+ After`;
80
+ const nodes = parseMarkdown(markdown);
81
+ expect(nodes).toHaveLength(3);
82
+ expect(nodes[1].content).toBe('---');
83
+ expect(nodes[1].is_hr).toBe(true);
84
+ });
85
+ it('should convert ___ to Roam HR', () => {
86
+ const markdown = `Before
87
+ ___
88
+ After`;
89
+ const nodes = parseMarkdown(markdown);
90
+ expect(nodes).toHaveLength(3);
91
+ expect(nodes[1].content).toBe('---');
92
+ expect(nodes[1].is_hr).toBe(true);
93
+ });
94
+ it('should handle longer HR variants', () => {
95
+ const markdown = `-----
96
+ *****
97
+ _______`;
98
+ const nodes = parseMarkdown(markdown);
99
+ expect(nodes).toHaveLength(3);
100
+ expect(nodes[0].content).toBe('---');
101
+ expect(nodes[1].content).toBe('---');
102
+ expect(nodes[2].content).toBe('---');
103
+ });
104
+ it('should not convert inline dashes', () => {
105
+ const markdown = `This has -- some dashes
106
+ And this--too`;
107
+ const nodes = parseMarkdown(markdown);
108
+ expect(nodes).toHaveLength(2);
109
+ expect(nodes[0].is_hr).toBeUndefined();
110
+ expect(nodes[1].is_hr).toBeUndefined();
111
+ });
112
+ });
113
+ describe('convertToRoamActions - numbered lists', () => {
114
+ it('should include children-view-type in block action', () => {
115
+ const markdown = `Parent
116
+ 1. First
117
+ 2. Second`;
118
+ const nodes = parseMarkdown(markdown);
119
+ const actions = convertToRoamActions(nodes, 'test-page-uid');
120
+ // First action is the parent block
121
+ const parentAction = actions[0];
122
+ expect(parentAction.block['children-view-type']).toBe('numbered');
123
+ });
124
+ });
125
+ });