roam-research-mcp 2.18.0 → 2.19.1

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 CHANGED
@@ -46,6 +46,12 @@ roam search --namespace "Convention" # Finds all Convention/* pages
46
46
  # Fetch a page by title
47
47
  roam get "Roam Research"
48
48
 
49
+ # Fetch daily pages using any date format (auto-normalized)
50
+ roam get today # Today's daily page
51
+ roam get 2026-03-21 # ISO date → "March 21st, 2026"
52
+ roam get "03/21/2026" # US date → "March 21st, 2026"
53
+ roam get "March 21" # Named (assumes current year)
54
+
49
55
  # Fetch a block with ancestors (parent chain to page root)
50
56
  roam get abc123def -a # Block + children + ancestors
51
57
  roam get abc123def -a -d 0 # Ancestors only, no children
@@ -184,6 +184,12 @@ CREATING:
184
184
  ├─ Memory → roam_remember
185
185
  └─ Todos → roam_add_todo
186
186
 
187
+ NESTING (roam_import_markdown): nests by BOTH indentation AND markdown heading
188
+ level — content and deeper headings fold under their heading (## under #, ### under
189
+ ##). Use it for heading-structured docs. roam_create_page nests ONLY by explicit
190
+ per-item `level` integers, so heading-structured markdown sent to create_page
191
+ imports flat. Don't use `---` dividers; they become horizontal-rule blocks.
192
+
187
193
  SEARCHING:
188
194
  ├─ By tag → roam_search_for_tag
189
195
  ├─ By text → roam_search_by_text
@@ -45,6 +45,21 @@ export async function resolveDailyPageUid(graph) {
45
45
  const dailyTitle = getDailyPageTitle();
46
46
  return resolvePageUid(graph, dailyTitle);
47
47
  }
48
+ /**
49
+ * Check if a string looks like a valid Roam UID (not a page title)
50
+ */
51
+ export function isUidFormat(ref) {
52
+ // 9 alphanumeric characters (standard block UID)
53
+ if (/^[a-zA-Z0-9_-]{9}$/.test(ref))
54
+ return true;
55
+ // MM-DD-YYYY daily page UID
56
+ if (/^\d{2}-\d{2}-\d{4}$/.test(ref))
57
+ return true;
58
+ // Placeholder {{name}}
59
+ if (/^\{\{[^}]+\}\}$/.test(ref))
60
+ return true;
61
+ return false;
62
+ }
48
63
  /**
49
64
  * Collect all unique page titles that need resolution from commands
50
65
  */
@@ -56,6 +71,13 @@ export function collectPageTitles(commands) {
56
71
  if ('page' in params && typeof params.page === 'string') {
57
72
  titles.add(params.page);
58
73
  }
74
+ // Commands that can have 'parent' param — if it looks like a page title, resolve it
75
+ if ('parent' in params && typeof params.parent === 'string') {
76
+ const parent = params.parent;
77
+ if (!isUidFormat(parent)) {
78
+ titles.add(parent);
79
+ }
80
+ }
59
81
  // Remember command can have heading that needs parent page resolution
60
82
  // But heading lookup is handled separately
61
83
  // Todo/remember without explicit page need daily page
@@ -126,8 +148,13 @@ export function resolveParentRef(ref, context) {
126
148
  if (context.pageUids.has(ref)) {
127
149
  return context.pageUids.get(ref);
128
150
  }
129
- // Assume it's a direct UID
130
- return ref;
151
+ // If it looks like a UID, return as-is
152
+ if (isUidFormat(ref)) {
153
+ return ref;
154
+ }
155
+ // Not a UID and not resolved — this is a page title that wasn't collected/resolved
156
+ // Return null so callers can handle it (should not happen if collectPageTitles is correct)
157
+ return null;
131
158
  }
132
159
  /**
133
160
  * Generate a placeholder UID for tracking
@@ -39,7 +39,10 @@ export function translateCommand(command, context) {
39
39
  function getParentUid(params, context) {
40
40
  // Direct parent UID or placeholder
41
41
  if (params.parent) {
42
- return resolveParentRef(params.parent, context) || params.parent;
42
+ const resolved = resolveParentRef(params.parent, context);
43
+ if (resolved)
44
+ return resolved;
45
+ throw new Error(`Parent "${params.parent}" could not be resolved to a UID. Page may not exist.`);
43
46
  }
44
47
  // Page UID
45
48
  if (params.pageUid) {
@@ -227,7 +227,7 @@ Output (JSON): { success, pages_created, actions_executed, uid_map? }
227
227
  return;
228
228
  }
229
229
  const graph = resolveGraph(options, true);
230
- // Phase 1: Collect and resolve page titles
230
+ // Phase 1: Collect and resolve page titles (from 'page' AND 'parent' params)
231
231
  const context = createResolutionContext();
232
232
  const pageTitles = collectPageTitles(commands);
233
233
  if (pageTitles.size > 0) {
@@ -238,10 +238,25 @@ Output (JSON): { success, pages_created, actions_executed, uid_map? }
238
238
  for (const [title, uid] of resolved) {
239
239
  context.pageUids.set(title, uid);
240
240
  }
241
- // Check for unresolved pages
241
+ // Auto-create unresolved pages (e.g., parent: "Page Title" for a page that doesn't exist yet)
242
242
  const unresolved = Array.from(pageTitles).filter(t => !context.pageUids.has(t));
243
243
  if (unresolved.length > 0) {
244
- exitWithError(`Page(s) not found: ${unresolved.map(t => `"${t}"`).join(', ')}`);
244
+ if (options.debug) {
245
+ printDebug('Auto-creating pages', unresolved);
246
+ }
247
+ const pageOps = new PageOperations(graph);
248
+ for (const title of unresolved) {
249
+ const result = await pageOps.createPage(title);
250
+ if (result.success && result.uid) {
251
+ context.pageUids.set(title, result.uid);
252
+ if (options.debug) {
253
+ printDebug(`Auto-created "${title}"`, result.uid);
254
+ }
255
+ }
256
+ else {
257
+ exitWithError(`Failed to create page "${title}"`);
258
+ }
259
+ }
245
260
  }
246
261
  if (options.debug) {
247
262
  printDebug('Resolved pages', Object.fromEntries(context.pageUids));
@@ -15,7 +15,7 @@ const ANY_STATUS_PATTERN = /\{\{(\[\[)?(TODO|DONE)(\]\])?\}\}\s*/g;
15
15
  * - If opposite status exists, replace it
16
16
  * - If no status exists, prepend
17
17
  */
18
- function applyStatus(content, status) {
18
+ export function applyStatus(content, status) {
19
19
  const marker = `{{[[${status}]]}} `;
20
20
  const hasStatus = status === 'TODO'
21
21
  ? TODO_PATTERN.test(content)
@@ -38,9 +38,30 @@ function applyStatus(content, status) {
38
38
  /**
39
39
  * Remove any TODO/DONE status from content
40
40
  */
41
- function clearStatus(content) {
41
+ export function clearStatus(content) {
42
42
  return content.replace(ANY_STATUS_PATTERN, '').trim();
43
43
  }
44
+ /**
45
+ * Resolve the "new text" for an update from the positional content arg and stdin.
46
+ *
47
+ * Returns `undefined` when no real text was supplied — INCLUDING the case where
48
+ * stdin was readable but empty. A subprocess invoked without a stdin pipe (e.g.
49
+ * `Bun.spawn(['roam', 'update', uid, '--done'])`) has stdin = /dev/null: non-TTY,
50
+ * but empty. The caller treats `undefined` as "leave the existing block text and
51
+ * only apply metadata/status changes". Returning `''` here would make a status-only
52
+ * update erase the block (it would skip the fetch-existing-text path and prepend the
53
+ * marker to an empty string). See update.test.ts.
54
+ */
55
+ export async function resolveUpdateContent(contentArg, readPipedStdin, stdinIsTTY) {
56
+ if (contentArg && contentArg !== '-') {
57
+ return contentArg;
58
+ }
59
+ if (contentArg === '-' || (!contentArg && !stdinIsTTY)) {
60
+ const piped = (await readPipedStdin()).trim();
61
+ return piped.length > 0 ? piped : undefined;
62
+ }
63
+ return undefined;
64
+ }
44
65
  export function createUpdateCommand() {
45
66
  return new Command('update')
46
67
  .description('Update block content, heading, open/closed state, or TODO/DONE status')
@@ -85,15 +106,12 @@ Examples:
85
106
  // Strip (( )) wrapper if present
86
107
  const blockUid = uid.replace(/^\(\(|\)\)$/g, '');
87
108
  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
- }
109
+ // 1. Determine new content from args or stdin.
110
+ // Empty/whitespace stdin resolves to `undefined` (not ''), so a
111
+ // status-only update like `roam update <uid> --done` from a subprocess
112
+ // falls through to the fetch-existing-text path below instead of
113
+ // erasing the block. See resolveUpdateContent + update.test.ts.
114
+ let finalContent = await resolveUpdateContent(content, readStdin, Boolean(process.stdin.isTTY));
97
115
  // 2. Identify if we need to fetch existing content
98
116
  const isStatusUpdate = options.todo || options.done || options.clearStatus;
99
117
  const isHeadingUpdate = options.heading !== undefined;
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { resolveUpdateContent, applyStatus, clearStatus } from './update.js';
3
+ /**
4
+ * Regression: `roam update <uid> --done` (and --todo / --clear-status) invoked as a
5
+ * subprocess used to ERASE the block text, replacing it with a bare "{{[[DONE]]}} ".
6
+ *
7
+ * Cause: a subprocess has stdin = /dev/null, which is non-TTY but empty. The old
8
+ * code read stdin and stored the empty string as the new content. Because '' !== undefined,
9
+ * the "fetch existing block text" path was skipped, and applyStatus prepended the
10
+ * marker to an empty string. The fix maps empty stdin to `undefined`.
11
+ */
12
+ describe('resolveUpdateContent — empty stdin must not become content', () => {
13
+ it('maps empty stdin (subprocess /dev/null) to undefined, not ""', async () => {
14
+ const result = await resolveUpdateContent(undefined, async () => '', false);
15
+ expect(result).toBeUndefined(); // undefined => caller fetches existing text; '' would erase the block
16
+ });
17
+ it('treats whitespace-only stdin as no content', async () => {
18
+ const result = await resolveUpdateContent(undefined, async () => ' \n\t ', false);
19
+ expect(result).toBeUndefined();
20
+ });
21
+ it('uses real piped stdin content when present', async () => {
22
+ const result = await resolveUpdateContent(undefined, async () => 'piped text', false);
23
+ expect(result).toBe('piped text');
24
+ });
25
+ it('prefers an explicit content argument over stdin', async () => {
26
+ const result = await resolveUpdateContent('explicit', async () => 'piped', false);
27
+ expect(result).toBe('explicit');
28
+ });
29
+ it('does not read stdin from an interactive TTY when no content arg given', async () => {
30
+ let read = false;
31
+ const result = await resolveUpdateContent(undefined, async () => { read = true; return 'x'; }, true);
32
+ expect(result).toBeUndefined();
33
+ expect(read).toBe(false);
34
+ });
35
+ it('reads stdin when content arg is the explicit "-" sentinel, even on a TTY', async () => {
36
+ const result = await resolveUpdateContent('-', async () => 'from dash', true);
37
+ expect(result).toBe('from dash');
38
+ });
39
+ it('explicit "-" with empty stdin still resolves to undefined (does not erase)', async () => {
40
+ const result = await resolveUpdateContent('-', async () => '', true);
41
+ expect(result).toBeUndefined();
42
+ });
43
+ });
44
+ describe('applyStatus — find-replace preserves block text', () => {
45
+ it('converts an existing {{[[TODO]]}} marker to DONE in place, keeping text', () => {
46
+ expect(applyStatus('{{[[TODO]]}} Buy milk', 'DONE')).toBe('{{[[DONE]]}} Buy milk');
47
+ });
48
+ it('supports the {{TODO}} (non-page-ref) marker form', () => {
49
+ expect(applyStatus('{{TODO}} Buy milk', 'DONE')).toBe('{{[[DONE]]}} Buy milk');
50
+ });
51
+ it('prepends DONE when no marker is present, keeping the text', () => {
52
+ expect(applyStatus('Buy milk', 'DONE')).toBe('{{[[DONE]]}} Buy milk');
53
+ });
54
+ it('is a no-op when the target status is already present', () => {
55
+ expect(applyStatus('{{[[DONE]]}} Buy milk', 'DONE')).toBe('{{[[DONE]]}} Buy milk');
56
+ });
57
+ it('converts DONE back to TODO in place', () => {
58
+ expect(applyStatus('{{[[DONE]]}} Buy milk', 'TODO')).toBe('{{[[TODO]]}} Buy milk');
59
+ });
60
+ it('documents the failure mode: empty content + DONE yields a bare marker — which is exactly why resolveUpdateContent must not pass ""', () => {
61
+ expect(applyStatus('', 'DONE')).toBe('{{[[DONE]]}} ');
62
+ });
63
+ });
64
+ describe('clearStatus', () => {
65
+ it('removes a TODO/DONE marker and trims, keeping the text', () => {
66
+ expect(clearStatus('{{[[DONE]]}} Buy milk')).toBe('Buy milk');
67
+ expect(clearStatus('{{TODO}} Buy milk')).toBe('Buy milk');
68
+ });
69
+ });
@@ -363,6 +363,40 @@ export function generateBlockUid() {
363
363
  }
364
364
  return uid;
365
365
  }
366
+ /**
367
+ * Reorganise a parsed markdown node list into a "section tree" based on heading
368
+ * levels: each heading (e.g. ##) becomes a parent, and the blocks that follow it
369
+ * nest under it until a heading of equal-or-higher level closes the section;
370
+ * deeper headings (###) nest further. Blocks before the first heading — and
371
+ * inputs with no headings at all — are returned unchanged, so indentation-only
372
+ * markdown and intentional flat lists are unaffected.
373
+ *
374
+ * This fixes flush-left, heading-structured markdown (e.g. report skeletons)
375
+ * that would otherwise import as a flat list of root-level siblings, because
376
+ * neither indentation-based nesting nor explicit levels capture the document
377
+ * outline encoded in the heading syntax.
378
+ */
379
+ function nestUnderHeadings(nodes) {
380
+ const result = [];
381
+ const stack = []; // currently-open heading nodes, shallow → deep
382
+ for (const original of nodes) {
383
+ // Recurse first so headings appearing inside indented structure nest too
384
+ const node = { ...original, children: nestUnderHeadings(original.children) };
385
+ const level = node.heading_level ?? 0;
386
+ if (level > 0) {
387
+ // A heading at the same or shallower level closes open equal/deeper sections
388
+ while (stack.length > 0 && (stack[stack.length - 1].heading_level ?? 0) >= level) {
389
+ stack.pop();
390
+ }
391
+ }
392
+ // Attach under the deepest open heading, or at root when no section is open
393
+ (stack.length > 0 ? stack[stack.length - 1].children : result).push(node);
394
+ if (level > 0) {
395
+ stack.push(node);
396
+ }
397
+ }
398
+ return result;
399
+ }
366
400
  function convertNodesToBlocks(nodes) {
367
401
  return nodes.map(node => ({
368
402
  uid: generateBlockUid(),
@@ -372,9 +406,7 @@ function convertNodesToBlocks(nodes) {
372
406
  children: convertNodesToBlocks(node.children)
373
407
  }));
374
408
  }
375
- function convertToRoamActions(nodes, parentUid, order = 'last') {
376
- // First convert nodes to blocks with UIDs
377
- const blocks = convertNodesToBlocks(nodes);
409
+ function buildActionsFromBlocks(blocks, parentUid, order = 'last') {
378
410
  const actions = [];
379
411
  // Helper function to recursively create actions
380
412
  function createBlockActions(blocks, parentUid, order) {
@@ -405,6 +437,20 @@ function convertToRoamActions(nodes, parentUid, order = 'last') {
405
437
  createBlockActions(blocks, parentUid, order);
406
438
  return actions;
407
439
  }
440
+ function convertToRoamActions(nodes, parentUid, order = 'last') {
441
+ // First convert nodes to blocks with UIDs
442
+ return buildActionsFromBlocks(convertNodesToBlocks(nodes), parentUid, order);
443
+ }
444
+ /**
445
+ * Like convertToRoamActions, but also returns the minted block tree (with the
446
+ * same client-generated UIDs used in the actions). Lets callers report exactly
447
+ * what was created WITHOUT a post-write re-query — Roam's write API does not
448
+ * echo back transacted UIDs, and we already know them because we minted them.
449
+ */
450
+ function convertToRoamActionsWithBlocks(nodes, parentUid, order = 'last') {
451
+ const blocks = convertNodesToBlocks(nodes);
452
+ return { actions: buildActionsFromBlocks(blocks, parentUid, order), blocks };
453
+ }
408
454
  /**
409
455
  * Converts markdown nodes to Roam batch actions, grouped by nesting level.
410
456
  * This ensures parent blocks exist before child blocks are created.
@@ -449,4 +495,4 @@ function convertToRoamActionsStaged(nodes, parentUid, order = 'last') {
449
495
  return actionsByLevel;
450
496
  }
451
497
  // Export public functions and types
452
- export { parseMarkdown, convertToRoamActions, convertToRoamActionsStaged, hasMarkdownTable, convertAllTables, convertToRoamMarkdown, parseMarkdownHeadingLevel };
498
+ export { parseMarkdown, nestUnderHeadings, convertToRoamActions, convertToRoamActionsWithBlocks, convertToRoamActionsStaged, hasMarkdownTable, convertAllTables, convertToRoamMarkdown, parseMarkdownHeadingLevel };
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { parseMarkdown, convertToRoamActions } from './markdown-utils.js';
2
+ import { parseMarkdown, convertToRoamActions, convertToRoamActionsWithBlocks, nestUnderHeadings } from './markdown-utils.js';
3
3
  describe('markdown-utils', () => {
4
4
  describe('parseMarkdown - numbered lists', () => {
5
5
  it('should detect numbered list items and strip prefixes', () => {
@@ -122,4 +122,114 @@ And this--too`;
122
122
  expect(parentAction.block['children-view-type']).toBe('numbered');
123
123
  });
124
124
  });
125
+ describe('convertToRoamActionsWithBlocks', () => {
126
+ it('returns a minted block tree whose UIDs match the create-block actions, across 3 nested levels', () => {
127
+ const markdown = `- Level 1 — Alpha
128
+ - Level 2 — Alpha.1
129
+ - Level 3 — Alpha.1.a
130
+ - Level 3 — Alpha.1.b
131
+ - Level 2 — Alpha.2
132
+ - Level 3 — Alpha.2.a
133
+ - Level 1 — Beta
134
+ - Level 2 — Beta.1
135
+ - Level 3 — Beta.1.a`;
136
+ const nodes = parseMarkdown(markdown);
137
+ const { actions, blocks } = convertToRoamActionsWithBlocks(nodes, 'PARENT123', 'last');
138
+ // 9 source bullets → more than the old VERIFICATION_THRESHOLD of 5
139
+ expect(actions.length).toBe(9);
140
+ // Collect every UID from the returned block tree
141
+ const treeUids = new Set();
142
+ const walk = (bs) => bs.forEach(b => { treeUids.add(b.uid); walk(b.children); });
143
+ walk(blocks);
144
+ // The tree UIDs must be exactly the action UIDs (no re-query, same minted ids)
145
+ const actionUids = actions.map(a => a.block.uid);
146
+ expect(treeUids.size).toBe(actions.length);
147
+ actionUids.forEach(uid => expect(treeUids.has(uid)).toBe(true));
148
+ // Nesting is preserved end-to-end
149
+ expect(blocks).toHaveLength(2);
150
+ expect(blocks[0].content).toBe('Level 1 — Alpha');
151
+ expect(blocks[0].children).toHaveLength(2); // Alpha.1, Alpha.2
152
+ expect(blocks[0].children[0].children).toHaveLength(2); // Alpha.1.a, Alpha.1.b
153
+ expect(blocks[1].children[0].children).toHaveLength(1); // Beta.1.a
154
+ });
155
+ });
156
+ describe('nestUnderHeadings', () => {
157
+ const node = (content, heading_level = 0, children = []) => ({ content, level: 1, heading_level, children });
158
+ it('nests content and deeper headings under their section heading', () => {
159
+ const flat = [
160
+ node('Section A', 2),
161
+ node('Body of A'),
162
+ node('Sub A1', 3),
163
+ node('Body of A1'),
164
+ node('Section B', 2),
165
+ node('Body of B'),
166
+ ];
167
+ const nested = nestUnderHeadings(flat);
168
+ // Two H2 sections at root
169
+ expect(nested).toHaveLength(2);
170
+ expect(nested[0].content).toBe('Section A');
171
+ // Section A absorbs its body AND the deeper H3 section
172
+ expect(nested[0].children.map(c => c.content)).toEqual(['Body of A', 'Sub A1']);
173
+ const subA1 = nested[0].children[1];
174
+ expect(subA1.heading_level).toBe(3);
175
+ expect(subA1.children.map(c => c.content)).toEqual(['Body of A1']);
176
+ expect(nested[1].content).toBe('Section B');
177
+ expect(nested[1].children.map(c => c.content)).toEqual(['Body of B']);
178
+ });
179
+ it('keeps content before the first heading at root', () => {
180
+ const flat = [
181
+ node('Date:: [[May 30th, 2026]]'),
182
+ node('Section A', 2),
183
+ node('Body'),
184
+ ];
185
+ const nested = nestUnderHeadings(flat);
186
+ expect(nested.map(c => c.content)).toEqual(['Date:: [[May 30th, 2026]]', 'Section A']);
187
+ expect(nested[1].children.map(c => c.content)).toEqual(['Body']);
188
+ });
189
+ it('treats same-level headings as siblings, deeper levels as nested', () => {
190
+ const flat = [
191
+ node('H2 first', 2),
192
+ node('H2 second', 2),
193
+ ];
194
+ const nested = nestUnderHeadings(flat);
195
+ expect(nested.map(c => c.content)).toEqual(['H2 first', 'H2 second']);
196
+ expect(nested[0].children).toHaveLength(0);
197
+ });
198
+ it('leaves heading-free indented structure unchanged (backward compatible)', () => {
199
+ const original = [
200
+ node('Parent', 0, [node('Child', 0, [node('Grandchild')])]),
201
+ ];
202
+ const nested = nestUnderHeadings(original);
203
+ expect(nested).toHaveLength(1);
204
+ expect(nested[0].content).toBe('Parent');
205
+ expect(nested[0].children).toHaveLength(1);
206
+ expect(nested[0].children[0].children.map(c => c.content)).toEqual(['Grandchild']);
207
+ });
208
+ it('end-to-end: flush-left heading markdown parses + nests into a section tree (reproduces the pilot-page bug input)', () => {
209
+ const markdown = [
210
+ '## TL;DR',
211
+ 'continue InjectionGate',
212
+ '## Telemetry rollup',
213
+ '### Day-by-day',
214
+ '2026-05-25 — 293 calls',
215
+ '### By caller',
216
+ 'AutoWorkCreation',
217
+ '## Recommendation',
218
+ 'continue all',
219
+ ].join('\n');
220
+ const nested = nestUnderHeadings(parseMarkdown(markdown));
221
+ // Top level is exactly the three H2 sections — not nine flat siblings
222
+ expect(nested.map(n => n.content)).toEqual(['TL;DR', 'Telemetry rollup', 'Recommendation']);
223
+ expect(nested.every(n => n.heading_level === 2)).toBe(true);
224
+ // TL;DR holds its body line
225
+ expect(nested[0].children.map(c => c.content)).toEqual(['continue InjectionGate']);
226
+ // Telemetry holds two H3 subsections, each nesting its own body line
227
+ const telemetry = nested[1];
228
+ expect(telemetry.children.map(c => c.content)).toEqual(['Day-by-day', 'By caller']);
229
+ expect(telemetry.children[0].children.map(c => c.content)).toEqual(['2026-05-25 — 293 calls']);
230
+ expect(telemetry.children[1].children.map(c => c.content)).toEqual(['AutoWorkCreation']);
231
+ // Recommendation holds its body
232
+ expect(nested[2].children.map(c => c.content)).toEqual(['continue all']);
233
+ });
234
+ });
125
235
  });
@@ -1,16 +1,26 @@
1
1
  import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
2
2
  import { q } from '@roam-research/roam-api-sdk';
3
+ import { normalizeToRoamDate } from '../utils/helpers.js';
3
4
  export class SearchUtils {
4
5
  /**
5
- * Find a page by title or UID
6
+ * Find a page by title or UID.
7
+ * Automatically normalizes date-like strings to Roam format.
6
8
  */
7
9
  static async findPageByTitleOrUid(graph, titleOrUid) {
8
- // Try to find page by title
10
+ // Try to find page by title (original input first)
9
11
  const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
10
12
  const findResults = await q(graph, findQuery, [titleOrUid]);
11
13
  if (findResults && findResults.length > 0) {
12
14
  return findResults[0][0];
13
15
  }
16
+ // If input looks like a date, try normalized Roam format
17
+ const roamDate = normalizeToRoamDate(titleOrUid);
18
+ if (roamDate && roamDate !== titleOrUid) {
19
+ const dateResults = await q(graph, findQuery, [roamDate]);
20
+ if (dateResults && dateResults.length > 0) {
21
+ return dateResults[0][0];
22
+ }
23
+ }
14
24
  // Try as UID
15
25
  const uidQuery = `[:find ?uid :where [?e :block/uid "${titleOrUid}"] [?e :block/uid ?uid]]`;
16
26
  const uidResults = await q(graph, uidQuery, []);
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { q, createPage } from '@roam-research/roam-api-sdk';
5
5
  import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
6
- import { formatRoamDate } from '../../utils/helpers.js';
6
+ import { formatRoamDate, normalizeToRoamDate } from '../../utils/helpers.js';
7
7
  import { pageUidCache } from '../../cache/page-uid-cache.js';
8
8
  import { capitalizeWords } from './text.js';
9
9
  /**
@@ -15,8 +15,11 @@ export async function getPageUid(graph, title) {
15
15
  if (!title) {
16
16
  return null;
17
17
  }
18
+ // If the title looks like a date, normalize to Roam format
19
+ const roamDate = normalizeToRoamDate(title);
18
20
  const variations = [
19
21
  title,
22
+ ...(roamDate && roamDate !== title ? [roamDate] : []),
20
23
  capitalizeWords(title),
21
24
  title.toLowerCase()
22
25
  ];
@@ -79,8 +82,11 @@ export async function getOrCreateTodayPage(graph) {
79
82
  */
80
83
  export async function findOrCreatePage(graph, titleOrUid, options = {}) {
81
84
  const { maxRetries = 3, delayMs = 500 } = options;
85
+ // If the input looks like a date, normalize to Roam format
86
+ const roamDate = normalizeToRoamDate(titleOrUid);
82
87
  const variations = [
83
88
  titleOrUid,
89
+ ...(roamDate && roamDate !== titleOrUid ? [roamDate] : []),
84
90
  capitalizeWords(titleOrUid),
85
91
  titleOrUid.toLowerCase()
86
92
  ];
@@ -3,10 +3,24 @@ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
3
3
  import { formatRoamDate } from '../../utils/helpers.js';
4
4
  import { findOrCreatePage, getPageUid, getOrCreateTodayPage } from '../helpers/page-resolution.js';
5
5
  import { executeBatch } from '../helpers/batch-utils.js';
6
- import { parseMarkdown, convertToRoamActions, convertToRoamMarkdown } from '../../markdown-utils.js';
6
+ import { parseMarkdown, nestUnderHeadings, convertToRoamActions, convertToRoamActionsWithBlocks, convertToRoamMarkdown } from '../../markdown-utils.js';
7
7
  import { executeStagedBatch } from '../../shared/staged-batch.js';
8
8
  // Threshold for skipping child fetch during verification
9
9
  const VERIFICATION_THRESHOLD = 5;
10
+ /**
11
+ * Map the client-minted block tree (from convertToRoamActionsWithBlocks) into
12
+ * the NestedBlock shape returned to callers. No API calls — the UIDs are the
13
+ * ones we generated and sent in the create-block actions.
14
+ */
15
+ function blockInfoToNestedBlocks(blocks, level = 0) {
16
+ return blocks.map((block, index) => ({
17
+ uid: block.uid,
18
+ text: block.content,
19
+ level,
20
+ order: index,
21
+ children: block.children.length > 0 ? blockInfoToNestedBlocks(block.children, level + 1) : undefined
22
+ }));
23
+ }
10
24
  export class OutlineOperations {
11
25
  constructor(graph) {
12
26
  this.graph = graph;
@@ -140,35 +154,6 @@ export class OutlineOperations {
140
154
  }
141
155
  }
142
156
  ;
143
- /**
144
- * Recursively fetches a nested structure of blocks under a given root block UID.
145
- */
146
- async fetchNestedStructure(rootUid) {
147
- const query = `[:find ?child-uid ?child-string ?child-order
148
- :in $ ?parent-uid
149
- :where
150
- [?parent :block/uid ?parent-uid]
151
- [?parent :block/children ?child]
152
- [?child :block/uid ?child-uid]
153
- [?child :block/string ?child-string]
154
- [?child :block/order ?child-order]]`;
155
- const directChildrenResult = await q(this.graph, query, [rootUid]);
156
- if (directChildrenResult.length === 0) {
157
- return [];
158
- }
159
- const nestedBlocks = [];
160
- for (const [childUid, childString, childOrder] of directChildrenResult) {
161
- const children = await this.fetchNestedStructure(childUid);
162
- nestedBlocks.push({
163
- uid: childUid,
164
- text: childString,
165
- level: 0, // Level is not easily determined here, so we set it to 0
166
- children: children,
167
- order: childOrder
168
- });
169
- }
170
- return nestedBlocks.sort((a, b) => a.order - b.order);
171
- }
172
157
  /**
173
158
  * Creates an outline structure on a Roam Research page, optionally under a specific block.
174
159
  *
@@ -383,31 +368,24 @@ export class OutlineOperations {
383
368
  // Always use parseMarkdown for content with multiple lines or any markdown formatting
384
369
  const isMultilined = content.includes('\n');
385
370
  if (isMultilined) {
386
- // Parse markdown into hierarchical structure
371
+ // Parse markdown into hierarchical structure, then nest by heading level so
372
+ // flush-left, heading-structured markdown (e.g. report skeletons) imports as
373
+ // a proper outline instead of a flat list of root-level siblings.
387
374
  const convertedContent = convertToRoamMarkdown(content);
388
- const nodes = parseMarkdown(convertedContent);
389
- // Convert markdown nodes to batch actions
390
- const actions = convertToRoamActions(nodes, targetParentUid, order);
375
+ const nodes = nestUnderHeadings(parseMarkdown(convertedContent));
376
+ // Convert markdown nodes to batch actions, keeping the minted block tree.
377
+ // Roam's write API does not echo back transacted UIDs, so we report the
378
+ // UIDs we generated client-side rather than issuing a fragile post-write
379
+ // re-query (which was also skipped entirely past VERIFICATION_THRESHOLD,
380
+ // leaving created_uids empty for larger imports).
381
+ const { actions, blocks } = convertToRoamActionsWithBlocks(nodes, targetParentUid, order);
391
382
  // Execute batch actions to add content
392
383
  await executeBatch(this.graph, actions, 'import nested markdown content');
393
- // Skip nested structure fetch for large imports to reduce API calls
394
- const skipNestedFetch = actions.length > VERIFICATION_THRESHOLD;
395
- if (skipNestedFetch) {
396
- // Large import: return success with block count, skip recursive queries
397
- return {
398
- success: true,
399
- page_uid: targetPageUid,
400
- parent_uid: targetParentUid,
401
- created_uids: []
402
- };
403
- }
404
- // Small import: get all nested UIDs under the parent (current behavior)
405
- const createdUids = await this.fetchNestedStructure(targetParentUid);
406
384
  return {
407
385
  success: true,
408
386
  page_uid: targetPageUid,
409
387
  parent_uid: targetParentUid,
410
- created_uids: createdUids
388
+ created_uids: blockInfoToNestedBlocks(blocks)
411
389
  };
412
390
  }
413
391
  else {
@@ -55,9 +55,142 @@ export function sanitizeTagName(tag) {
55
55
  cleaned = cleaned.replace(/^\[\[/, '').replace(/\]\]$/, '');
56
56
  return cleaned.trim();
57
57
  }
58
+ const MONTHS_LONG = [
59
+ 'January', 'February', 'March', 'April', 'May', 'June',
60
+ 'July', 'August', 'September', 'October', 'November', 'December'
61
+ ];
62
+ const MONTHS_SHORT = {
63
+ jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5,
64
+ jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11
65
+ };
58
66
  /**
59
- * Resolve relative date keywords to Roam date format.
60
- * Returns the original string if not a recognized keyword.
67
+ * Attempt to parse a string as a date and return Roam format ("March 21st, 2026").
68
+ * Returns null if the string doesn't look like a date.
69
+ *
70
+ * Handles:
71
+ * - ISO: 2026-03-21
72
+ * - US slash/dash: 03/21/2026, 3/21/2026, 03-21-2026
73
+ * - Named: March 21, 2026 / Mar 21, 2026 / March 21 2026
74
+ * - Named w/ ord: March 21st, 2026 (already correct — returned as-is)
75
+ * - No year: March 21 / Mar 21 (assumes current year)
76
+ * - EU day-first: 21 March 2026 / 21 Mar 2026 / 21-Mar-2026 / 21 March
77
+ * - Roam UID style: MM-DD-YYYY (daily page UIDs, also caught by US dash)
78
+ */
79
+ export function normalizeToRoamDate(input) {
80
+ const s = input.trim();
81
+ if (!s)
82
+ return null;
83
+ let month;
84
+ let day;
85
+ let year;
86
+ // Already in Roam format? (e.g., "March 21st, 2026")
87
+ const roamFmt = /^([A-Z][a-z]+)\s+(\d{1,2})(st|nd|rd|th),\s*(\d{4})$/;
88
+ const roamMatch = s.match(roamFmt);
89
+ if (roamMatch) {
90
+ const mi = MONTHS_LONG.indexOf(roamMatch[1]);
91
+ if (mi !== -1)
92
+ return s; // already correct
93
+ }
94
+ // ISO: 2026-03-21
95
+ const iso = /^(\d{4})-(\d{1,2})-(\d{1,2})$/;
96
+ const isoMatch = s.match(iso);
97
+ if (isoMatch) {
98
+ year = parseInt(isoMatch[1]);
99
+ month = parseInt(isoMatch[2]) - 1;
100
+ day = parseInt(isoMatch[3]);
101
+ }
102
+ // Numeric slash or dash: 03/21/2026, 14/03/2025, 03-21-2026
103
+ // If first number > 12, it can't be a month → unambiguously EU (DD/MM/YYYY)
104
+ // Otherwise assume US (MM/DD/YYYY)
105
+ if (month === undefined) {
106
+ const numeric = /^(\d{1,2})[/-](\d{1,2})[/-](\d{4})$/;
107
+ const numMatch = s.match(numeric);
108
+ if (numMatch) {
109
+ const a = parseInt(numMatch[1]);
110
+ const b = parseInt(numMatch[2]);
111
+ year = parseInt(numMatch[3]);
112
+ if (a > 12 && b <= 12) {
113
+ // Unambiguously EU: DD/MM/YYYY (first number can't be a month)
114
+ day = a;
115
+ month = b - 1;
116
+ }
117
+ else {
118
+ // US default: MM/DD/YYYY
119
+ month = a - 1;
120
+ day = b;
121
+ }
122
+ }
123
+ }
124
+ // Named month with day and year: "March 21, 2026" / "Mar 21 2026"
125
+ if (month === undefined) {
126
+ const named = /^([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?,?\s*(\d{4})$/;
127
+ const namedMatch = s.match(named);
128
+ if (namedMatch) {
129
+ const mi = MONTHS_SHORT[namedMatch[1].toLowerCase().slice(0, 3)];
130
+ if (mi !== undefined) {
131
+ month = mi;
132
+ day = parseInt(namedMatch[2]);
133
+ year = parseInt(namedMatch[3]);
134
+ }
135
+ }
136
+ }
137
+ // EU day-first with named month and year: "21 March 2026" / "21 Mar 2026" / "21-Mar-2026"
138
+ if (month === undefined) {
139
+ const eu = /^(\d{1,2})[\s-]([A-Za-z]+)[\s-](\d{4})$/;
140
+ const euMatch = s.match(eu);
141
+ if (euMatch) {
142
+ const mi = MONTHS_SHORT[euMatch[2].toLowerCase().slice(0, 3)];
143
+ if (mi !== undefined) {
144
+ day = parseInt(euMatch[1]);
145
+ month = mi;
146
+ year = parseInt(euMatch[3]);
147
+ }
148
+ }
149
+ }
150
+ // EU day-first with named month, no year: "21 March" / "21 Mar"
151
+ if (month === undefined) {
152
+ const euNoYear = /^(\d{1,2})[\s-]([A-Za-z]+)$/;
153
+ const euNoYearMatch = s.match(euNoYear);
154
+ if (euNoYearMatch) {
155
+ const mi = MONTHS_SHORT[euNoYearMatch[2].toLowerCase().slice(0, 3)];
156
+ if (mi !== undefined) {
157
+ day = parseInt(euNoYearMatch[1]);
158
+ month = mi;
159
+ year = new Date().getFullYear();
160
+ }
161
+ }
162
+ }
163
+ // Named month with day, no year: "March 21" / "Mar 21"
164
+ if (month === undefined) {
165
+ const noYear = /^([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?$/;
166
+ const noYearMatch = s.match(noYear);
167
+ if (noYearMatch) {
168
+ const mi = MONTHS_SHORT[noYearMatch[1].toLowerCase().slice(0, 3)];
169
+ if (mi !== undefined) {
170
+ month = mi;
171
+ day = parseInt(noYearMatch[2]);
172
+ year = new Date().getFullYear();
173
+ }
174
+ }
175
+ }
176
+ // Validate parsed values
177
+ if (month === undefined || day === undefined || year === undefined)
178
+ return null;
179
+ if (month < 0 || month > 11)
180
+ return null;
181
+ if (day < 1 || day > 31)
182
+ return null;
183
+ if (year < 1900 || year > 2100)
184
+ return null;
185
+ // Extra validation: ensure the date is real (e.g., Feb 30 is invalid)
186
+ const dateObj = new Date(year, month, day);
187
+ if (dateObj.getMonth() !== month || dateObj.getDate() !== day)
188
+ return null;
189
+ return `${MONTHS_LONG[month]} ${day}${getOrdinalSuffix(day)}, ${year}`;
190
+ }
191
+ /**
192
+ * Resolve relative date keywords and common date formats to Roam date format.
193
+ * Returns the original string if not a recognized date pattern.
61
194
  */
62
195
  export function resolveRelativeDate(input) {
63
196
  const lower = input.toLowerCase().trim();
@@ -74,6 +207,6 @@ export function resolveRelativeDate(input) {
74
207
  tomorrow.setDate(today.getDate() + 1);
75
208
  return formatRoamDate(tomorrow);
76
209
  default:
77
- return input;
210
+ return normalizeToRoamDate(input) ?? input;
78
211
  }
79
212
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { sanitizeTagName } from './helpers.js';
2
+ import { sanitizeTagName, normalizeToRoamDate, resolveRelativeDate, formatRoamDate } from './helpers.js';
3
3
  describe('sanitizeTagName', () => {
4
4
  it('passes through bare tag names unchanged', () => {
5
5
  expect(sanitizeTagName('Type/Procedural')).toBe('Type/Procedural');
@@ -24,3 +24,130 @@ describe('sanitizeTagName', () => {
24
24
  expect(sanitizeTagName('#Memories')).toBe('Memories');
25
25
  });
26
26
  });
27
+ describe('normalizeToRoamDate', () => {
28
+ it('returns null for non-date strings', () => {
29
+ expect(normalizeToRoamDate('My Page Title')).toBeNull();
30
+ expect(normalizeToRoamDate('abc123def')).toBeNull();
31
+ expect(normalizeToRoamDate('')).toBeNull();
32
+ expect(normalizeToRoamDate('hello')).toBeNull();
33
+ });
34
+ it('passes through already-correct Roam dates', () => {
35
+ expect(normalizeToRoamDate('March 21st, 2026')).toBe('March 21st, 2026');
36
+ expect(normalizeToRoamDate('January 1st, 2024')).toBe('January 1st, 2024');
37
+ expect(normalizeToRoamDate('December 25th, 2025')).toBe('December 25th, 2025');
38
+ expect(normalizeToRoamDate('February 2nd, 2026')).toBe('February 2nd, 2026');
39
+ expect(normalizeToRoamDate('March 3rd, 2026')).toBe('March 3rd, 2026');
40
+ });
41
+ it('normalizes ISO dates (YYYY-MM-DD)', () => {
42
+ expect(normalizeToRoamDate('2026-03-21')).toBe('March 21st, 2026');
43
+ expect(normalizeToRoamDate('2026-01-01')).toBe('January 1st, 2026');
44
+ expect(normalizeToRoamDate('2025-12-25')).toBe('December 25th, 2025');
45
+ expect(normalizeToRoamDate('2026-02-02')).toBe('February 2nd, 2026');
46
+ expect(normalizeToRoamDate('2026-03-03')).toBe('March 3rd, 2026');
47
+ expect(normalizeToRoamDate('2026-11-11')).toBe('November 11th, 2026');
48
+ expect(normalizeToRoamDate('2026-04-12')).toBe('April 12th, 2026');
49
+ expect(normalizeToRoamDate('2026-04-13')).toBe('April 13th, 2026');
50
+ });
51
+ it('normalizes US slash dates (MM/DD/YYYY)', () => {
52
+ expect(normalizeToRoamDate('03/21/2026')).toBe('March 21st, 2026');
53
+ expect(normalizeToRoamDate('3/21/2026')).toBe('March 21st, 2026');
54
+ expect(normalizeToRoamDate('12/25/2025')).toBe('December 25th, 2025');
55
+ expect(normalizeToRoamDate('1/1/2024')).toBe('January 1st, 2024');
56
+ });
57
+ it('detects unambiguous EU numeric dates (DD/MM/YYYY where DD > 12)', () => {
58
+ expect(normalizeToRoamDate('14/03/2025')).toBe('March 14th, 2025');
59
+ expect(normalizeToRoamDate('21/01/2026')).toBe('January 21st, 2026');
60
+ expect(normalizeToRoamDate('25/12/2025')).toBe('December 25th, 2025');
61
+ expect(normalizeToRoamDate('31/01/2026')).toBe('January 31st, 2026');
62
+ expect(normalizeToRoamDate('13/06/2026')).toBe('June 13th, 2026');
63
+ });
64
+ it('treats ambiguous numeric dates as US (MM/DD/YYYY when both ≤ 12)', () => {
65
+ // 03/04/2026 is ambiguous — defaults to US: March 4th
66
+ expect(normalizeToRoamDate('03/04/2026')).toBe('March 4th, 2026');
67
+ expect(normalizeToRoamDate('01/02/2026')).toBe('January 2nd, 2026');
68
+ });
69
+ it('normalizes US dash dates (MM-DD-YYYY)', () => {
70
+ expect(normalizeToRoamDate('03-21-2026')).toBe('March 21st, 2026');
71
+ expect(normalizeToRoamDate('01-25-2026')).toBe('January 25th, 2026');
72
+ });
73
+ it('normalizes named month dates without ordinal', () => {
74
+ expect(normalizeToRoamDate('March 21, 2026')).toBe('March 21st, 2026');
75
+ expect(normalizeToRoamDate('January 1, 2024')).toBe('January 1st, 2024');
76
+ expect(normalizeToRoamDate('December 25 2025')).toBe('December 25th, 2025');
77
+ });
78
+ it('normalizes abbreviated month names', () => {
79
+ expect(normalizeToRoamDate('Mar 21, 2026')).toBe('March 21st, 2026');
80
+ expect(normalizeToRoamDate('Jan 1, 2024')).toBe('January 1st, 2024');
81
+ expect(normalizeToRoamDate('Dec 25, 2025')).toBe('December 25th, 2025');
82
+ });
83
+ it('normalizes named month with day only (no year, assumes current year)', () => {
84
+ const year = new Date().getFullYear();
85
+ expect(normalizeToRoamDate('March 21')).toBe(`March 21st, ${year}`);
86
+ expect(normalizeToRoamDate('Jan 1')).toBe(`January 1st, ${year}`);
87
+ expect(normalizeToRoamDate('Dec 25')).toBe(`December 25th, ${year}`);
88
+ });
89
+ it('handles named dates with existing ordinal suffix', () => {
90
+ expect(normalizeToRoamDate('March 21st, 2026')).toBe('March 21st, 2026');
91
+ expect(normalizeToRoamDate('January 2nd, 2026')).toBe('January 2nd, 2026');
92
+ expect(normalizeToRoamDate('April 3rd 2026')).toBe('April 3rd, 2026');
93
+ });
94
+ it('normalizes EU day-first dates with named month', () => {
95
+ expect(normalizeToRoamDate('21 March 2026')).toBe('March 21st, 2026');
96
+ expect(normalizeToRoamDate('21 Mar 2026')).toBe('March 21st, 2026');
97
+ expect(normalizeToRoamDate('1 January 2024')).toBe('January 1st, 2024');
98
+ expect(normalizeToRoamDate('25 Dec 2025')).toBe('December 25th, 2025');
99
+ expect(normalizeToRoamDate('21-Mar-2026')).toBe('March 21st, 2026');
100
+ expect(normalizeToRoamDate('2-Feb-2026')).toBe('February 2nd, 2026');
101
+ });
102
+ it('normalizes EU day-first dates without year (assumes current year)', () => {
103
+ const year = new Date().getFullYear();
104
+ expect(normalizeToRoamDate('21 March')).toBe(`March 21st, ${year}`);
105
+ expect(normalizeToRoamDate('21 Mar')).toBe(`March 21st, ${year}`);
106
+ expect(normalizeToRoamDate('1 Jan')).toBe(`January 1st, ${year}`);
107
+ expect(normalizeToRoamDate('25-Dec')).toBe(`December 25th, ${year}`);
108
+ });
109
+ it('rejects invalid dates', () => {
110
+ expect(normalizeToRoamDate('2026-02-30')).toBeNull(); // Feb 30 doesn't exist
111
+ expect(normalizeToRoamDate('2026-13-01')).toBeNull(); // Month 13
112
+ expect(normalizeToRoamDate('2026-00-01')).toBeNull(); // Month 0
113
+ expect(normalizeToRoamDate('13/32/2026')).toBeNull(); // Invalid month/day
114
+ });
115
+ it('handles all ordinal suffixes correctly', () => {
116
+ expect(normalizeToRoamDate('2026-03-01')).toBe('March 1st, 2026');
117
+ expect(normalizeToRoamDate('2026-03-02')).toBe('March 2nd, 2026');
118
+ expect(normalizeToRoamDate('2026-03-03')).toBe('March 3rd, 2026');
119
+ expect(normalizeToRoamDate('2026-03-04')).toBe('March 4th, 2026');
120
+ expect(normalizeToRoamDate('2026-03-11')).toBe('March 11th, 2026');
121
+ expect(normalizeToRoamDate('2026-03-12')).toBe('March 12th, 2026');
122
+ expect(normalizeToRoamDate('2026-03-13')).toBe('March 13th, 2026');
123
+ expect(normalizeToRoamDate('2026-03-21')).toBe('March 21st, 2026');
124
+ expect(normalizeToRoamDate('2026-03-22')).toBe('March 22nd, 2026');
125
+ expect(normalizeToRoamDate('2026-03-23')).toBe('March 23rd, 2026');
126
+ expect(normalizeToRoamDate('2026-03-31')).toBe('March 31st, 2026');
127
+ });
128
+ });
129
+ describe('resolveRelativeDate', () => {
130
+ it('resolves "today" to current date in Roam format', () => {
131
+ expect(resolveRelativeDate('today')).toBe(formatRoamDate(new Date()));
132
+ });
133
+ it('resolves "yesterday"', () => {
134
+ const y = new Date();
135
+ y.setDate(y.getDate() - 1);
136
+ expect(resolveRelativeDate('yesterday')).toBe(formatRoamDate(y));
137
+ });
138
+ it('resolves "tomorrow"', () => {
139
+ const t = new Date();
140
+ t.setDate(t.getDate() + 1);
141
+ expect(resolveRelativeDate('tomorrow')).toBe(formatRoamDate(t));
142
+ });
143
+ it('normalizes ISO dates via fallback', () => {
144
+ expect(resolveRelativeDate('2026-03-21')).toBe('March 21st, 2026');
145
+ });
146
+ it('normalizes slash dates via fallback', () => {
147
+ expect(resolveRelativeDate('03/21/2026')).toBe('March 21st, 2026');
148
+ });
149
+ it('returns non-date strings unchanged', () => {
150
+ expect(resolveRelativeDate('My Page')).toBe('My Page');
151
+ expect(resolveRelativeDate('Recipes')).toBe('Recipes');
152
+ });
153
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roam-research-mcp",
3
- "version": "2.18.0",
3
+ "version": "2.19.1",
4
4
  "description": "MCP server and CLI for Roam Research",
5
5
  "private": false,
6
6
  "repository": {
@@ -56,4 +56,4 @@
56
56
  "typescript": "^5.3.3",
57
57
  "vitest": "^3.2.4"
58
58
  }
59
- }
59
+ }