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 +6 -0
- package/build/Roam_Markdown_Cheatsheet.md +6 -0
- package/build/cli/batch/resolver.js +29 -2
- package/build/cli/batch/translator.js +4 -1
- package/build/cli/commands/batch.js +18 -3
- package/build/cli/commands/update.js +29 -11
- package/build/cli/commands/update.test.js +69 -0
- package/build/markdown-utils.js +50 -4
- package/build/markdown-utils.test.js +111 -1
- package/build/search/utils.js +12 -2
- package/build/tools/helpers/page-resolution.js +7 -1
- package/build/tools/operations/outline.js +26 -48
- package/build/utils/helpers.js +136 -3
- package/build/utils/helpers.test.js +128 -1
- package/package.json +2 -2
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
|
-
//
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
+
});
|
package/build/markdown-utils.js
CHANGED
|
@@ -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
|
|
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
|
});
|
package/build/search/utils.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
388
|
+
created_uids: blockInfoToNestedBlocks(blocks)
|
|
411
389
|
};
|
|
412
390
|
}
|
|
413
391
|
else {
|
package/build/utils/helpers.js
CHANGED
|
@@ -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
|
-
*
|
|
60
|
-
* Returns the
|
|
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.
|
|
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
|
+
}
|