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.
- package/README.md +175 -667
- package/build/Roam_Markdown_Cheatsheet.md +138 -289
- package/build/cache/page-uid-cache.js +40 -2
- package/build/cli/batch/translator.js +1 -1
- package/build/cli/commands/batch.js +3 -8
- package/build/cli/commands/get.js +478 -60
- package/build/cli/commands/refs.js +51 -31
- package/build/cli/commands/save.js +61 -10
- package/build/cli/commands/search.js +63 -58
- package/build/cli/commands/status.js +3 -4
- package/build/cli/commands/update.js +71 -28
- package/build/cli/utils/graph.js +6 -2
- package/build/cli/utils/input.js +10 -0
- package/build/cli/utils/output.js +28 -5
- package/build/cli/utils/sort-group.js +110 -0
- package/build/config/graph-registry.js +31 -13
- package/build/config/graph-registry.test.js +42 -5
- package/build/markdown-utils.js +114 -4
- package/build/markdown-utils.test.js +125 -0
- package/build/query/generator.js +330 -0
- package/build/query/index.js +149 -0
- package/build/query/parser.js +319 -0
- package/build/query/parser.test.js +389 -0
- package/build/query/types.js +4 -0
- package/build/search/ancestor-rule.js +14 -0
- package/build/search/block-ref-search.js +1 -5
- package/build/search/hierarchy-search.js +5 -12
- package/build/search/index.js +1 -0
- package/build/search/status-search.js +10 -9
- package/build/search/tag-search.js +8 -24
- package/build/search/text-search.js +70 -27
- package/build/search/types.js +13 -0
- package/build/search/utils.js +71 -2
- package/build/server/roam-server.js +4 -3
- package/build/shared/index.js +2 -0
- package/build/shared/page-validator.js +233 -0
- package/build/shared/page-validator.test.js +128 -0
- package/build/shared/staged-batch.js +144 -0
- package/build/tools/helpers/batch-utils.js +57 -0
- package/build/tools/helpers/page-resolution.js +136 -0
- package/build/tools/helpers/refs.js +68 -0
- package/build/tools/operations/batch.js +75 -3
- package/build/tools/operations/block-retrieval.js +15 -4
- package/build/tools/operations/block-retrieval.test.js +87 -0
- package/build/tools/operations/blocks.js +1 -288
- package/build/tools/operations/memory.js +32 -90
- package/build/tools/operations/outline.js +38 -156
- package/build/tools/operations/pages.js +169 -122
- package/build/tools/operations/todos.js +5 -37
- package/build/tools/schemas.js +20 -9
- package/build/tools/tool-handlers.js +4 -4
- package/build/utils/helpers.js +27 -0
- 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
|
|
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
|
-
|
|
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]
|
|
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
|
|
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
|
-
${
|
|
63
|
-
|
|
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
|
|
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
|
}
|
package/build/search/types.js
CHANGED
|
@@ -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
|
}
|
package/build/search/utils.js
CHANGED
|
@@ -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
|
|
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
|
};
|
package/build/shared/index.js
CHANGED
|
@@ -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
|
+
});
|