roam-research-mcp 2.4.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +175 -667
  2. package/build/Roam_Markdown_Cheatsheet.md +138 -289
  3. package/build/cache/page-uid-cache.js +40 -2
  4. package/build/cli/batch/translator.js +1 -1
  5. package/build/cli/commands/batch.js +3 -8
  6. package/build/cli/commands/get.js +478 -60
  7. package/build/cli/commands/refs.js +51 -31
  8. package/build/cli/commands/save.js +61 -10
  9. package/build/cli/commands/search.js +63 -58
  10. package/build/cli/commands/status.js +3 -4
  11. package/build/cli/commands/update.js +71 -28
  12. package/build/cli/utils/graph.js +6 -2
  13. package/build/cli/utils/input.js +10 -0
  14. package/build/cli/utils/output.js +28 -5
  15. package/build/cli/utils/sort-group.js +110 -0
  16. package/build/config/graph-registry.js +31 -13
  17. package/build/config/graph-registry.test.js +42 -5
  18. package/build/markdown-utils.js +114 -4
  19. package/build/markdown-utils.test.js +125 -0
  20. package/build/query/generator.js +330 -0
  21. package/build/query/index.js +149 -0
  22. package/build/query/parser.js +319 -0
  23. package/build/query/parser.test.js +389 -0
  24. package/build/query/types.js +4 -0
  25. package/build/search/ancestor-rule.js +14 -0
  26. package/build/search/block-ref-search.js +1 -5
  27. package/build/search/hierarchy-search.js +5 -12
  28. package/build/search/index.js +1 -0
  29. package/build/search/status-search.js +10 -9
  30. package/build/search/tag-search.js +8 -24
  31. package/build/search/text-search.js +70 -27
  32. package/build/search/types.js +13 -0
  33. package/build/search/utils.js +71 -2
  34. package/build/server/roam-server.js +4 -3
  35. package/build/shared/index.js +2 -0
  36. package/build/shared/page-validator.js +233 -0
  37. package/build/shared/page-validator.test.js +128 -0
  38. package/build/shared/staged-batch.js +144 -0
  39. package/build/tools/helpers/batch-utils.js +57 -0
  40. package/build/tools/helpers/page-resolution.js +136 -0
  41. package/build/tools/helpers/refs.js +68 -0
  42. package/build/tools/operations/batch.js +75 -3
  43. package/build/tools/operations/block-retrieval.js +15 -4
  44. package/build/tools/operations/block-retrieval.test.js +87 -0
  45. package/build/tools/operations/blocks.js +1 -288
  46. package/build/tools/operations/memory.js +32 -90
  47. package/build/tools/operations/outline.js +38 -156
  48. package/build/tools/operations/pages.js +169 -122
  49. package/build/tools/operations/todos.js +5 -37
  50. package/build/tools/schemas.js +20 -9
  51. package/build/tools/tool-handlers.js +4 -4
  52. package/build/utils/helpers.js +27 -0
  53. package/package.json +1 -1
@@ -1,33 +1,23 @@
1
1
  import { q } from '@roam-research/roam-api-sdk';
2
2
  import { BaseSearchHandler } from './types.js';
3
3
  import { SearchUtils } from './utils.js';
4
- import { resolveRefs } from '../tools/helpers/refs.js';
5
4
  export class TextSearchHandler extends BaseSearchHandler {
6
5
  constructor(graph, params) {
7
6
  super(graph);
8
7
  this.params = params;
9
8
  }
10
9
  async execute() {
11
- const { text, page_title_uid, case_sensitive = false, limit = -1, offset = 0 } = this.params;
10
+ const { text, page_title_uid, case_sensitive = false, limit = -1, offset = 0, scope = 'blocks' } = this.params;
11
+ // Handle page_titles scope (namespace search)
12
+ if (scope === 'page_titles') {
13
+ return this.executePageTitleSearch();
14
+ }
12
15
  // Get target page UID if provided for scoped search
13
16
  let targetPageUid;
14
17
  if (page_title_uid) {
15
18
  targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid);
16
19
  }
17
- const searchTerms = [];
18
- if (case_sensitive) {
19
- searchTerms.push(text);
20
- }
21
- else {
22
- searchTerms.push(text);
23
- // Add capitalized version (e.g., "Hypnosis")
24
- searchTerms.push(text.charAt(0).toUpperCase() + text.slice(1));
25
- // Add all caps version (e.g., "HYPNOSIS")
26
- searchTerms.push(text.toUpperCase());
27
- // Add all lowercase version (e.g., "hypnosis")
28
- searchTerms.push(text.toLowerCase());
29
- }
30
- const whereClauses = searchTerms.map(term => `[(clojure.string/includes? ?block-str "${term}")]`).join(' ');
20
+ const textSearchClause = SearchUtils.buildTextSearchClause(text, '?block-str', case_sensitive);
31
21
  let queryStr;
32
22
  let queryParams = [];
33
23
  let queryLimit = limit === -1 ? '' : `:limit ${limit}`;
@@ -35,13 +25,15 @@ export class TextSearchHandler extends BaseSearchHandler {
35
25
  let queryOrder = `:order ?page-edit-time asc ?block-uid asc`; // Sort by page edit time, then block UID
36
26
  let baseQueryWhereClauses = `
37
27
  [?b :block/string ?block-str]
38
- (or ${whereClauses})
28
+ ${textSearchClause}
39
29
  [?b :block/uid ?block-uid]
40
30
  [?b :block/page ?p]
41
31
  [?p :node/title ?page-title]
42
- [?p :edit/time ?page-edit-time]`; // Fetch page edit time for sorting
32
+ [?p :edit/time ?page-edit-time]
33
+ [(get-else $ ?b :create/time 0) ?block-create-time]
34
+ [(get-else $ ?b :edit/time 0) ?block-edit-time]`; // Fetch page edit time for sorting, block timestamps for sort/group
43
35
  if (targetPageUid) {
44
- queryStr = `[:find ?block-uid ?block-str ?page-title
36
+ queryStr = `[:find ?block-uid ?block-str ?page-title ?block-create-time ?block-edit-time
45
37
  :in $ ?page-uid ${queryLimit} ${queryOffset} ${queryOrder}
46
38
  :where
47
39
  ${baseQueryWhereClauses}
@@ -49,27 +41,78 @@ export class TextSearchHandler extends BaseSearchHandler {
49
41
  queryParams = [targetPageUid];
50
42
  }
51
43
  else {
52
- queryStr = `[:find ?block-uid ?block-str ?page-title
44
+ queryStr = `[:find ?block-uid ?block-str ?page-title ?block-create-time ?block-edit-time
53
45
  :in $ ${queryLimit} ${queryOffset} ${queryOrder}
54
46
  :where
55
47
  ${baseQueryWhereClauses}]`;
56
48
  }
57
49
  const rawResults = await q(this.graph, queryStr, queryParams);
58
50
  // Query to get total count without limit
59
- const countQueryStr = `[:find (count ?b)
51
+ const baseCountWhere = baseQueryWhereClauses.replace(/\[\?p :edit\/time \?page-edit-time\]/, '');
52
+ let countQueryStr;
53
+ let countQueryParams = [];
54
+ if (targetPageUid) {
55
+ countQueryStr = `[:find (count ?b)
56
+ :in $ ?page-uid
57
+ :where
58
+ ${baseCountWhere}
59
+ [?p :block/uid ?page-uid]]`;
60
+ countQueryParams = [targetPageUid];
61
+ }
62
+ else {
63
+ countQueryStr = `[:find (count ?b)
60
64
  :in $
61
65
  :where
62
- ${baseQueryWhereClauses.replace(/\[\?p :edit\/time \?page-edit-time\]/, '')}]`; // Remove edit time for count query
63
- const totalCountResults = await q(this.graph, countQueryStr, queryParams);
66
+ ${baseCountWhere}]`;
67
+ }
68
+ const totalCountResults = await q(this.graph, countQueryStr, countQueryParams);
64
69
  const totalCount = totalCountResults[0] ? totalCountResults[0][0] : 0;
65
70
  // Resolve block references in content
66
- const resolvedResults = await Promise.all(rawResults.map(async ([uid, content, pageTitle]) => {
67
- const resolvedContent = await resolveRefs(this.graph, content);
68
- return [uid, resolvedContent, pageTitle];
69
- }));
71
+ const resolvedResults = await this.resolveBlockRefs(rawResults);
70
72
  const searchDescription = `containing "${text}"`;
71
73
  const formattedResults = SearchUtils.formatSearchResults(resolvedResults, searchDescription, !targetPageUid);
72
74
  formattedResults.total_count = totalCount;
73
75
  return formattedResults;
74
76
  }
77
+ /**
78
+ * Search for page titles matching a namespace prefix.
79
+ * Normalizes the search text to ensure trailing slash for prefix matching.
80
+ */
81
+ async executePageTitleSearch() {
82
+ const { text, limit = -1, offset = 0 } = this.params;
83
+ // Normalize namespace: ensure trailing slash for prefix matching
84
+ const namespace = text.endsWith('/') ? text : `${text}/`;
85
+ // Query for pages with titles starting with the namespace
86
+ const queryLimit = limit === -1 ? '' : `:limit ${limit}`;
87
+ const queryOffset = offset === 0 ? '' : `:offset ${offset}`;
88
+ const queryStr = `[:find ?title ?uid
89
+ :in $ ${queryLimit} ${queryOffset}
90
+ :where
91
+ [?e :node/title ?title]
92
+ [?e :block/uid ?uid]
93
+ [(clojure.string/starts-with? ?title "${namespace}")]]`;
94
+ const rawResults = await q(this.graph, queryStr, []);
95
+ // Get total count
96
+ const countQueryStr = `[:find (count ?e)
97
+ :in $
98
+ :where
99
+ [?e :node/title ?title]
100
+ [(clojure.string/starts-with? ?title "${namespace}")]]`;
101
+ const totalCountResults = await q(this.graph, countQueryStr, []);
102
+ const totalCount = totalCountResults[0] ? totalCountResults[0][0] : 0;
103
+ // Format results: page UID as block_uid, title as content
104
+ const matches = rawResults.map(([title, uid]) => ({
105
+ block_uid: uid,
106
+ content: title,
107
+ page_title: title
108
+ }));
109
+ // Sort alphabetically by title
110
+ matches.sort((a, b) => a.content.localeCompare(b.content));
111
+ return {
112
+ success: true,
113
+ matches,
114
+ message: `Found ${matches.length} page(s) with namespace "${namespace}"`,
115
+ total_count: totalCount
116
+ };
117
+ }
75
118
  }
@@ -1,6 +1,19 @@
1
+ import { resolveRefs } from '../tools/helpers/refs.js';
1
2
  // Base class for all search handlers
2
3
  export class BaseSearchHandler {
3
4
  constructor(graph) {
4
5
  this.graph = graph;
5
6
  }
7
+ /**
8
+ * Resolve block references in search results.
9
+ * Handles both 5-tuple [uid, content, pageTitle?, created?, modified?]
10
+ * and 3-tuple [uid, content, pageTitle?] formats.
11
+ */
12
+ async resolveBlockRefs(results) {
13
+ return Promise.all(results.map(async (result) => {
14
+ const [uid, content, ...rest] = result;
15
+ const resolvedContent = await resolveRefs(this.graph, content);
16
+ return [uid, resolvedContent, ...rest];
17
+ }));
18
+ }
6
19
  }
@@ -21,6 +21,7 @@ export class SearchUtils {
21
21
  }
22
22
  /**
23
23
  * Format search results into a standard structure
24
+ * Supports both basic [uid, content, pageTitle?] and extended [uid, content, pageTitle?, created?, modified?] formats
24
25
  */
25
26
  static formatSearchResults(results, searchDescription, includePageTitle = true) {
26
27
  if (!results || results.length === 0) {
@@ -30,10 +31,12 @@ export class SearchUtils {
30
31
  message: `No blocks found ${searchDescription}`
31
32
  };
32
33
  }
33
- const matches = results.map(([uid, content, pageTitle]) => ({
34
+ const matches = results.map(([uid, content, pageTitle, created, modified]) => ({
34
35
  block_uid: uid,
35
36
  content,
36
- ...(includePageTitle && pageTitle && { page_title: pageTitle })
37
+ ...(includePageTitle && pageTitle && { page_title: pageTitle }),
38
+ ...(created && { created }),
39
+ ...(modified && { modified })
37
40
  }));
38
41
  return {
39
42
  success: true,
@@ -95,4 +98,70 @@ export class SearchUtils {
95
98
  default: return 'th';
96
99
  }
97
100
  }
101
+ /**
102
+ * Fetch all tag references for a set of block UIDs
103
+ * Returns a map of block_uid -> array of tag titles
104
+ */
105
+ static async fetchBlockTags(graph, blockUids) {
106
+ if (blockUids.length === 0) {
107
+ return new Map();
108
+ }
109
+ // Build OR clause for all UIDs
110
+ const uidClauses = blockUids.map(uid => `[?b :block/uid "${uid}"]`).join(' ');
111
+ const queryStr = `[:find ?block-uid ?tag-title
112
+ :where
113
+ (or ${uidClauses})
114
+ [?b :block/uid ?block-uid]
115
+ [?b :block/refs ?ref]
116
+ [?ref :node/title ?tag-title]]`;
117
+ const results = await q(graph, queryStr, []);
118
+ // Group tags by block UID
119
+ const tagMap = new Map();
120
+ for (const [uid, tag] of results) {
121
+ if (!tagMap.has(uid)) {
122
+ tagMap.set(uid, []);
123
+ }
124
+ tagMap.get(uid).push(tag);
125
+ }
126
+ return tagMap;
127
+ }
128
+ /**
129
+ * Generate case variations for case-insensitive search.
130
+ * Returns: [original, capitalized first letter, ALL CAPS, all lowercase]
131
+ */
132
+ static getCaseVariations(text, caseSensitive = false) {
133
+ if (caseSensitive) {
134
+ return [text];
135
+ }
136
+ return [
137
+ text,
138
+ text.charAt(0).toUpperCase() + text.slice(1),
139
+ text.toUpperCase(),
140
+ text.toLowerCase()
141
+ ];
142
+ }
143
+ /**
144
+ * Build OR where clause for case-insensitive text search.
145
+ * @param text - The search text
146
+ * @param variable - The Datomic variable name (e.g., "?block-str")
147
+ * @param caseSensitive - Whether to use case-sensitive matching
148
+ * @returns Datomic OR clause string
149
+ */
150
+ static buildTextSearchClause(text, variable, caseSensitive = false) {
151
+ const variations = this.getCaseVariations(text, caseSensitive);
152
+ const clauses = variations.map(term => `[(clojure.string/includes? ${variable} "${term}")]`);
153
+ return `(or ${clauses.join(' ')})`;
154
+ }
155
+ /**
156
+ * Build OR where clause for case-insensitive tag/page title matching.
157
+ * @param tag - The tag/page title to match
158
+ * @param variable - The Datomic variable name (e.g., "?ref-page")
159
+ * @param caseSensitive - Whether to use case-sensitive matching
160
+ * @returns Datomic OR clause string
161
+ */
162
+ static buildTagMatchClause(tag, variable, caseSensitive = false) {
163
+ const variations = this.getCaseVariations(tag, caseSensitive);
164
+ const clauses = variations.map(t => `[${variable} :node/title "${t}"]`);
165
+ return `(or ${clauses.join(' ')})`;
166
+ }
98
167
  }
@@ -44,7 +44,8 @@ export class RoamServer {
44
44
  if (cached) {
45
45
  return cached;
46
46
  }
47
- const handlers = new ToolHandlers(graph);
47
+ const memoriesTag = this.registry.getMemoriesTag(graphKey);
48
+ const handlers = new ToolHandlers(graph, memoriesTag);
48
49
  this.toolHandlersCache.set(graphKey, handlers);
49
50
  return handlers;
50
51
  }
@@ -120,8 +121,8 @@ export class RoamServer {
120
121
  };
121
122
  }
122
123
  case 'roam_remember': {
123
- const { memory, categories, heading, parent_uid } = cleanedArgs;
124
- const result = await toolHandlers.remember(memory, categories, heading, parent_uid);
124
+ const { memory, categories, heading, parent_uid, include_memories_tag } = cleanedArgs;
125
+ const result = await toolHandlers.remember(memory, categories, heading, parent_uid, include_memories_tag);
125
126
  return {
126
127
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
127
128
  };
@@ -3,3 +3,5 @@
3
3
  */
4
4
  export * from './validation.js';
5
5
  export * from './errors.js';
6
+ export * from './staged-batch.js';
7
+ export * from './page-validator.js';
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Page existence validator for batch operations.
3
+ *
4
+ * Provides rate-limit-friendly validation of parent UIDs before batch execution.
5
+ * Uses caching and batched queries to minimize API calls.
6
+ *
7
+ * Key features:
8
+ * - Session-scoped cache for known UIDs (no stale data across restarts)
9
+ * - Single batched Datomic query to check multiple UIDs
10
+ * - Auto-creation of daily pages (detected by MM-DD-YYYY format)
11
+ * - Clear error messages for missing non-daily pages
12
+ */
13
+ import { q, createPage as createRoamPage } from '@roam-research/roam-api-sdk';
14
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
15
+ import { pageUidCache } from '../cache/page-uid-cache.js';
16
+ import { isRateLimitError } from './errors.js';
17
+ // Daily page UID format: MM-DD-YYYY (e.g., "01-25-2026")
18
+ const DAILY_PAGE_UID_REGEX = /^(\d{2})-(\d{2})-(\d{4})$/;
19
+ const DEFAULT_RATE_LIMIT_CONFIG = {
20
+ maxRetries: 3,
21
+ initialDelayMs: 1000,
22
+ maxDelayMs: 30000,
23
+ backoffMultiplier: 2
24
+ };
25
+ /**
26
+ * Sleep helper for delays.
27
+ */
28
+ function sleep(ms) {
29
+ return new Promise(resolve => setTimeout(resolve, ms));
30
+ }
31
+ /**
32
+ * Check if a UID matches the daily page format (MM-DD-YYYY).
33
+ * These pages can be safely auto-created.
34
+ */
35
+ export function isDailyPageUid(uid) {
36
+ const match = uid.match(DAILY_PAGE_UID_REGEX);
37
+ if (!match)
38
+ return false;
39
+ const [, month, day, year] = match;
40
+ const m = parseInt(month, 10);
41
+ const d = parseInt(day, 10);
42
+ const y = parseInt(year, 10);
43
+ // Basic validation: month 1-12, day 1-31, year reasonable
44
+ return m >= 1 && m <= 12 && d >= 1 && d <= 31 && y >= 2000 && y <= 2100;
45
+ }
46
+ /**
47
+ * Convert daily page UID (MM-DD-YYYY) to Roam date title format.
48
+ * Example: "01-25-2026" -> "January 25th, 2026"
49
+ */
50
+ export function dailyUidToTitle(uid) {
51
+ const match = uid.match(DAILY_PAGE_UID_REGEX);
52
+ if (!match) {
53
+ throw new Error(`Invalid daily page UID format: ${uid}`);
54
+ }
55
+ const [, month, day, year] = match;
56
+ const date = new Date(parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day, 10));
57
+ const monthName = date.toLocaleString('en-US', { month: 'long' });
58
+ const dayNum = date.getDate();
59
+ const yearNum = date.getFullYear();
60
+ // Get ordinal suffix
61
+ const j = dayNum % 10;
62
+ const k = dayNum % 100;
63
+ let suffix;
64
+ if (j === 1 && k !== 11)
65
+ suffix = 'st';
66
+ else if (j === 2 && k !== 12)
67
+ suffix = 'nd';
68
+ else if (j === 3 && k !== 13)
69
+ suffix = 'rd';
70
+ else
71
+ suffix = 'th';
72
+ return `${monthName} ${dayNum}${suffix}, ${yearNum}`;
73
+ }
74
+ /**
75
+ * Batch check existence of multiple UIDs with a single Datomic query.
76
+ * Returns the set of UIDs that exist.
77
+ */
78
+ async function batchCheckExistence(graph, uids, config = DEFAULT_RATE_LIMIT_CONFIG) {
79
+ if (uids.length === 0)
80
+ return new Set();
81
+ // Build query with IN clause for multiple UIDs
82
+ // This checks for any entity (page or block) with the given UID
83
+ const query = `[:find ?uid
84
+ :in $ [?uid ...]
85
+ :where [?e :block/uid ?uid]]`;
86
+ let lastError;
87
+ let delay = config.initialDelayMs;
88
+ for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
89
+ try {
90
+ const results = await q(graph, query, [uids]);
91
+ const existingUids = new Set(results.map(([uid]) => uid));
92
+ return existingUids;
93
+ }
94
+ catch (error) {
95
+ if (!isRateLimitError(error)) {
96
+ throw error;
97
+ }
98
+ lastError = error;
99
+ if (attempt < config.maxRetries) {
100
+ const waitTime = Math.min(delay, config.maxDelayMs);
101
+ console.log(`[page-validator] Rate limited on existence check, retrying in ${waitTime}ms (attempt ${attempt + 1}/${config.maxRetries})`);
102
+ await sleep(waitTime);
103
+ delay *= config.backoffMultiplier;
104
+ }
105
+ }
106
+ }
107
+ throw new McpError(ErrorCode.InternalError, `Rate limit exceeded checking page existence after ${config.maxRetries} retries: ${lastError?.message || 'Unknown error'}`);
108
+ }
109
+ /**
110
+ * Batch create daily pages that don't exist.
111
+ * Uses Roam's createPage API.
112
+ */
113
+ async function batchCreateDailyPages(graph, uids, config = DEFAULT_RATE_LIMIT_CONFIG) {
114
+ if (uids.length === 0)
115
+ return;
116
+ // Create pages sequentially with small delays to avoid rate limits
117
+ // Roam's createPage doesn't support true batching
118
+ for (const uid of uids) {
119
+ const title = dailyUidToTitle(uid);
120
+ let lastError;
121
+ let delay = config.initialDelayMs;
122
+ for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
123
+ try {
124
+ await createRoamPage(graph, {
125
+ action: 'create-page',
126
+ page: { title, uid }
127
+ });
128
+ // Cache the created page
129
+ pageUidCache.onPageCreated(title, uid);
130
+ console.log(`[page-validator] Created daily page: ${title} (${uid})`);
131
+ // Small delay between page creations to be rate-limit friendly
132
+ if (uids.indexOf(uid) < uids.length - 1) {
133
+ await sleep(200);
134
+ }
135
+ break; // Success, move to next page
136
+ }
137
+ catch (error) {
138
+ if (!isRateLimitError(error)) {
139
+ // Non-rate-limit error - might be page already exists, which is OK
140
+ const msg = error instanceof Error ? error.message : String(error);
141
+ if (msg.includes('already exists') || msg.includes('duplicate')) {
142
+ pageUidCache.addUid(uid);
143
+ break;
144
+ }
145
+ throw error;
146
+ }
147
+ lastError = error;
148
+ if (attempt < config.maxRetries) {
149
+ const waitTime = Math.min(delay, config.maxDelayMs);
150
+ console.log(`[page-validator] Rate limited creating ${uid}, retrying in ${waitTime}ms (attempt ${attempt + 1}/${config.maxRetries})`);
151
+ await sleep(waitTime);
152
+ delay *= config.backoffMultiplier;
153
+ }
154
+ }
155
+ }
156
+ if (lastError && !pageUidCache.hasUid(uid)) {
157
+ throw new McpError(ErrorCode.InternalError, `Rate limit exceeded creating daily page ${uid} after ${config.maxRetries} retries: ${lastError.message}`);
158
+ }
159
+ }
160
+ // Final delay to let Roam's eventual consistency settle
161
+ if (uids.length > 0) {
162
+ await sleep(400);
163
+ }
164
+ }
165
+ /**
166
+ * Extract all unique parent-uid values from batch actions.
167
+ */
168
+ export function extractParentUids(actions) {
169
+ const uids = new Set();
170
+ for (const action of actions) {
171
+ // Handle standard location format
172
+ const parentUid = action.location?.['parent-uid'];
173
+ if (parentUid && typeof parentUid === 'string') {
174
+ // Skip UID placeholders like {{uid:parent1}}
175
+ if (!parentUid.startsWith('{{uid:')) {
176
+ uids.add(parentUid);
177
+ }
178
+ }
179
+ }
180
+ return [...uids];
181
+ }
182
+ /**
183
+ * Validates all parent UIDs in a batch, auto-creating daily pages as needed.
184
+ * Returns after all targets are guaranteed to exist.
185
+ *
186
+ * This is the main entry point for batch operation validation.
187
+ *
188
+ * @param graph - Roam graph connection
189
+ * @param actions - Array of batch actions to validate
190
+ * @param config - Optional rate limit configuration
191
+ * @throws McpError if non-daily pages are missing
192
+ */
193
+ export async function ensurePagesExist(graph, actions, config = DEFAULT_RATE_LIMIT_CONFIG) {
194
+ // 1. Extract unique parent UIDs from actions
195
+ const parentUids = extractParentUids(actions);
196
+ if (parentUids.length === 0) {
197
+ return { checked: 0, created: 0, cached: 0 };
198
+ }
199
+ // 2. Filter by cache - skip UIDs we know exist
200
+ const uncachedUids = parentUids.filter(uid => !pageUidCache.hasUid(uid));
201
+ const cachedCount = parentUids.length - uncachedUids.length;
202
+ if (uncachedUids.length === 0) {
203
+ // All UIDs are cached, no API calls needed
204
+ return { checked: 0, created: 0, cached: cachedCount };
205
+ }
206
+ // 3. Single batched query to check existence
207
+ const existingUids = await batchCheckExistence(graph, uncachedUids, config);
208
+ // 4. Update cache with found UIDs
209
+ pageUidCache.addUids([...existingUids]);
210
+ // 5. Determine what's missing
211
+ const missingUids = uncachedUids.filter(uid => !existingUids.has(uid));
212
+ if (missingUids.length === 0) {
213
+ return { checked: uncachedUids.length, created: 0, cached: cachedCount };
214
+ }
215
+ // 6. Separate daily pages from other pages
216
+ const dailyMissing = missingUids.filter(isDailyPageUid);
217
+ const otherMissing = missingUids.filter(uid => !isDailyPageUid(uid));
218
+ // 7. Fail fast if non-daily pages are missing
219
+ if (otherMissing.length > 0) {
220
+ const examples = otherMissing.slice(0, 3).join(', ');
221
+ const more = otherMissing.length > 3 ? ` and ${otherMissing.length - 3} more` : '';
222
+ throw new McpError(ErrorCode.InvalidParams, `Parent page(s) do not exist: ${examples}${more}. Create them first with roam_create_page or use a valid existing page UID.`);
223
+ }
224
+ // 8. Auto-create missing daily pages
225
+ if (dailyMissing.length > 0) {
226
+ await batchCreateDailyPages(graph, dailyMissing, config);
227
+ }
228
+ return {
229
+ checked: uncachedUids.length,
230
+ created: dailyMissing.length,
231
+ cached: cachedCount
232
+ };
233
+ }
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { isDailyPageUid, dailyUidToTitle, extractParentUids } from './page-validator.js';
3
+ import { pageUidCache } from '../cache/page-uid-cache.js';
4
+ describe('page-validator', () => {
5
+ beforeEach(() => {
6
+ pageUidCache.clear();
7
+ });
8
+ describe('isDailyPageUid', () => {
9
+ it('should recognize valid daily page UIDs', () => {
10
+ expect(isDailyPageUid('01-25-2026')).toBe(true);
11
+ expect(isDailyPageUid('12-31-2024')).toBe(true);
12
+ expect(isDailyPageUid('01-01-2000')).toBe(true);
13
+ expect(isDailyPageUid('06-15-2050')).toBe(true);
14
+ });
15
+ it('should reject invalid formats', () => {
16
+ // Wrong separators
17
+ expect(isDailyPageUid('01/25/2026')).toBe(false);
18
+ expect(isDailyPageUid('01.25.2026')).toBe(false);
19
+ // Wrong order (YYYY-MM-DD)
20
+ expect(isDailyPageUid('2026-01-25')).toBe(false);
21
+ // Standard 9-char block UID
22
+ expect(isDailyPageUid('abc123def')).toBe(false);
23
+ expect(isDailyPageUid('XrXmQJ-vO')).toBe(false);
24
+ // Too short/long
25
+ expect(isDailyPageUid('1-25-2026')).toBe(false);
26
+ expect(isDailyPageUid('001-25-2026')).toBe(false);
27
+ });
28
+ it('should reject invalid month/day values', () => {
29
+ expect(isDailyPageUid('13-25-2026')).toBe(false); // Month > 12
30
+ expect(isDailyPageUid('00-25-2026')).toBe(false); // Month = 0
31
+ expect(isDailyPageUid('01-32-2026')).toBe(false); // Day > 31
32
+ expect(isDailyPageUid('01-00-2026')).toBe(false); // Day = 0
33
+ });
34
+ it('should reject years outside reasonable range', () => {
35
+ expect(isDailyPageUid('01-25-1999')).toBe(false); // Year < 2000
36
+ expect(isDailyPageUid('01-25-2101')).toBe(false); // Year > 2100
37
+ });
38
+ });
39
+ describe('dailyUidToTitle', () => {
40
+ it('should convert UID to Roam date title format', () => {
41
+ expect(dailyUidToTitle('01-25-2026')).toBe('January 25th, 2026');
42
+ expect(dailyUidToTitle('12-31-2024')).toBe('December 31st, 2024');
43
+ expect(dailyUidToTitle('01-01-2025')).toBe('January 1st, 2025');
44
+ expect(dailyUidToTitle('02-02-2025')).toBe('February 2nd, 2025');
45
+ expect(dailyUidToTitle('03-03-2025')).toBe('March 3rd, 2025');
46
+ expect(dailyUidToTitle('04-04-2025')).toBe('April 4th, 2025');
47
+ expect(dailyUidToTitle('05-11-2025')).toBe('May 11th, 2025'); // 11th exception
48
+ expect(dailyUidToTitle('06-12-2025')).toBe('June 12th, 2025'); // 12th exception
49
+ expect(dailyUidToTitle('07-13-2025')).toBe('July 13th, 2025'); // 13th exception
50
+ expect(dailyUidToTitle('08-21-2025')).toBe('August 21st, 2025');
51
+ expect(dailyUidToTitle('09-22-2025')).toBe('September 22nd, 2025');
52
+ expect(dailyUidToTitle('10-23-2025')).toBe('October 23rd, 2025');
53
+ });
54
+ it('should throw for invalid format', () => {
55
+ expect(() => dailyUidToTitle('abc123def')).toThrow('Invalid daily page UID format');
56
+ expect(() => dailyUidToTitle('2026-01-25')).toThrow('Invalid daily page UID format');
57
+ });
58
+ });
59
+ describe('extractParentUids', () => {
60
+ it('should extract parent-uid from actions', () => {
61
+ const actions = [
62
+ { action: 'create-block', location: { 'parent-uid': 'abc123def' }, block: { string: 'test' } },
63
+ { action: 'create-block', location: { 'parent-uid': 'xyz789uvw' }, block: { string: 'test2' } }
64
+ ];
65
+ const uids = extractParentUids(actions);
66
+ expect(uids).toHaveLength(2);
67
+ expect(uids).toContain('abc123def');
68
+ expect(uids).toContain('xyz789uvw');
69
+ });
70
+ it('should deduplicate UIDs', () => {
71
+ const actions = [
72
+ { action: 'create-block', location: { 'parent-uid': 'abc123def' }, block: { string: 'test' } },
73
+ { action: 'create-block', location: { 'parent-uid': 'abc123def' }, block: { string: 'test2' } }
74
+ ];
75
+ const uids = extractParentUids(actions);
76
+ expect(uids).toHaveLength(1);
77
+ expect(uids[0]).toBe('abc123def');
78
+ });
79
+ it('should skip UID placeholders', () => {
80
+ const actions = [
81
+ { action: 'create-block', location: { 'parent-uid': '{{uid:parent1}}' }, block: { string: 'test' } },
82
+ { action: 'create-block', location: { 'parent-uid': 'abc123def' }, block: { string: 'test2' } }
83
+ ];
84
+ const uids = extractParentUids(actions);
85
+ expect(uids).toHaveLength(1);
86
+ expect(uids[0]).toBe('abc123def');
87
+ });
88
+ it('should handle actions without location', () => {
89
+ const actions = [
90
+ { action: 'update-block', uid: 'abc123def', block: { string: 'test' } },
91
+ { action: 'delete-block', uid: 'xyz789uvw' }
92
+ ];
93
+ const uids = extractParentUids(actions);
94
+ expect(uids).toHaveLength(0);
95
+ });
96
+ it('should handle empty actions', () => {
97
+ const uids = extractParentUids([]);
98
+ expect(uids).toHaveLength(0);
99
+ });
100
+ });
101
+ describe('pageUidCache integration', () => {
102
+ it('should track known UIDs', () => {
103
+ expect(pageUidCache.hasUid('abc123def')).toBe(false);
104
+ pageUidCache.addUid('abc123def');
105
+ expect(pageUidCache.hasUid('abc123def')).toBe(true);
106
+ });
107
+ it('should track multiple UIDs at once', () => {
108
+ pageUidCache.addUids(['uid1', 'uid2', 'uid3']);
109
+ expect(pageUidCache.hasUid('uid1')).toBe(true);
110
+ expect(pageUidCache.hasUid('uid2')).toBe(true);
111
+ expect(pageUidCache.hasUid('uid3')).toBe(true);
112
+ expect(pageUidCache.hasUid('uid4')).toBe(false);
113
+ });
114
+ it('should auto-add UID when setting title mapping', () => {
115
+ pageUidCache.set('Test Page', 'testuid12');
116
+ expect(pageUidCache.hasUid('testuid12')).toBe(true);
117
+ });
118
+ it('should clear both caches', () => {
119
+ pageUidCache.set('Test Page', 'testuid12');
120
+ pageUidCache.addUid('otheruid99');
121
+ expect(pageUidCache.size).toBe(1);
122
+ expect(pageUidCache.uidCacheSize).toBe(2);
123
+ pageUidCache.clear();
124
+ expect(pageUidCache.size).toBe(0);
125
+ expect(pageUidCache.uidCacheSize).toBe(0);
126
+ });
127
+ });
128
+ });