roam-research-mcp 1.3.2 → 1.6.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 +187 -25
- package/build/Roam_Markdown_Cheatsheet.md +11 -2
- package/build/cli/commands/get.js +79 -0
- package/build/cli/commands/refs.js +122 -0
- package/build/cli/commands/save.js +121 -0
- package/build/cli/commands/search.js +79 -0
- package/build/cli/roam.js +18 -0
- package/build/cli/utils/output.js +88 -0
- package/build/diff/actions.js +93 -0
- package/build/diff/actions.test.js +125 -0
- package/build/diff/diff.js +155 -0
- package/build/diff/diff.test.js +202 -0
- package/build/diff/index.js +43 -0
- package/build/diff/matcher.js +118 -0
- package/build/diff/matcher.test.js +198 -0
- package/build/diff/parser.js +114 -0
- package/build/diff/parser.test.js +281 -0
- package/build/diff/types.js +27 -0
- package/build/diff/types.test.js +57 -0
- package/build/search/block-ref-search.js +34 -7
- package/build/server/roam-server.js +7 -0
- package/build/tools/operations/pages.js +95 -0
- package/build/tools/schemas.js +29 -2
- package/build/tools/tool-handlers.js +4 -0
- package/package.json +9 -5
- package/build/cli/import-markdown.js +0 -98
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
[](https://opensource.org/licenses/MIT)
|
|
8
8
|
[](https://github.com/2b3pro/roam-research-mcp/blob/main/LICENSE)
|
|
9
9
|
|
|
10
|
-
A Model Context Protocol (MCP) server that provides comprehensive access to Roam Research's API functionality.
|
|
10
|
+
A Model Context Protocol (MCP) server and standalone CLI that provides comprehensive access to Roam Research's API functionality. The MCP server enables AI assistants like Claude to interact with your Roam Research graph through a standardized interface, while the CLI (`roam`) lets you fetch, search, and import content directly from the command line. Supports standard input/output (stdio) and HTTP Stream communication. (A WORK-IN-PROGRESS, personal project not officially endorsed by Roam Research)
|
|
11
11
|
|
|
12
12
|
<a href="https://glama.ai/mcp/servers/fzfznyaflu"><img width="380" height="200" src="https://glama.ai/mcp/servers/fzfznyaflu/badge" alt="Roam Research MCP server" /></a>
|
|
13
13
|
<a href="https://mseep.ai/app/2b3pro-roam-research-mcp"><img width="380" height="200" src="https://mseep.net/pr/2b3pro-roam-research-mcp-badge.png" alt="MseeP.ai Security Assessment Badge" /></a>
|
|
@@ -83,21 +83,129 @@ Alternatively, if you have a `.env` file in the project root (which is copied in
|
|
|
83
83
|
docker run -p 3000:3000 -p 8088:8088 --env-file .env roam-research-mcp
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
-
## Standalone CLI: roam
|
|
86
|
+
## Standalone CLI: `roam`
|
|
87
87
|
|
|
88
|
-
A standalone command-line tool for
|
|
88
|
+
A standalone command-line tool for interacting with Roam Research directly, without running the MCP server. Provides four subcommands: `get`, `search`, `save`, and `refs`.
|
|
89
89
|
|
|
90
|
-
###
|
|
90
|
+
### Installation
|
|
91
|
+
|
|
92
|
+
After building the project, make the command globally available:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
npm link
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Or run directly without linking:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
node build/cli/roam.js <command> [options]
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Requirements
|
|
105
|
+
|
|
106
|
+
Same environment variables as the MCP server:
|
|
107
|
+
- `ROAM_API_TOKEN`: Your Roam Research API token
|
|
108
|
+
- `ROAM_GRAPH_NAME`: Your Roam graph name
|
|
109
|
+
|
|
110
|
+
Configure via `.env` file in the project root or set as environment variables.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
### `roam get` - Fetch pages or blocks
|
|
115
|
+
|
|
116
|
+
Fetch content from Roam and output as markdown or JSON.
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
# Fetch a page by title
|
|
120
|
+
roam get "Daily Notes"
|
|
121
|
+
|
|
122
|
+
# Fetch a block by UID
|
|
123
|
+
roam get "((AbCdEfGhI))"
|
|
124
|
+
roam get AbCdEfGhI
|
|
125
|
+
|
|
126
|
+
# Output as JSON
|
|
127
|
+
roam get "Daily Notes" --json
|
|
128
|
+
|
|
129
|
+
# Control child depth (default: 4)
|
|
130
|
+
roam get "Daily Notes" --depth 2
|
|
131
|
+
|
|
132
|
+
# Flatten hierarchy
|
|
133
|
+
roam get "Daily Notes" --flat
|
|
134
|
+
|
|
135
|
+
# Debug mode
|
|
136
|
+
roam get "Daily Notes" --debug
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
**Options:**
|
|
140
|
+
- `--json` - Output as JSON instead of markdown
|
|
141
|
+
- `--depth <n>` - Child levels to fetch (default: 4)
|
|
142
|
+
- `--refs <n>` - Block ref expansion depth (default: 1)
|
|
143
|
+
- `--flat` - Flatten hierarchy to single-level list
|
|
144
|
+
- `--debug` - Show query metadata
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
### `roam search` - Search content
|
|
149
|
+
|
|
150
|
+
Search for blocks containing text or tags.
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
# Full-text search
|
|
154
|
+
roam search "keyword"
|
|
155
|
+
|
|
156
|
+
# Multiple terms (AND logic)
|
|
157
|
+
roam search "term1" "term2"
|
|
158
|
+
|
|
159
|
+
# Tag-only search
|
|
160
|
+
roam search --tag "[[Project]]"
|
|
161
|
+
roam search --tag "#TODO"
|
|
162
|
+
|
|
163
|
+
# Text + tag filter
|
|
164
|
+
roam search "meeting" --tag "[[Work]]"
|
|
165
|
+
|
|
166
|
+
# Scope to a specific page
|
|
167
|
+
roam search "task" --page "Daily Notes"
|
|
168
|
+
|
|
169
|
+
# Case-insensitive search
|
|
170
|
+
roam search "keyword" -i
|
|
171
|
+
|
|
172
|
+
# Limit results (default: 20)
|
|
173
|
+
roam search "keyword" -n 50
|
|
174
|
+
|
|
175
|
+
# Output as JSON
|
|
176
|
+
roam search "keyword" --json
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
**Options:**
|
|
180
|
+
- `--tag <tag>` - Filter by tag (e.g., `#TODO` or `[[Project]]`)
|
|
181
|
+
- `--page <title>` - Scope search to a specific page
|
|
182
|
+
- `-i, --case-insensitive` - Case-insensitive search
|
|
183
|
+
- `-n, --limit <n>` - Limit number of results (default: 20)
|
|
184
|
+
- `--json` - Output as JSON
|
|
185
|
+
- `--debug` - Show query metadata
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
### `roam save` - Import markdown
|
|
190
|
+
|
|
191
|
+
Import markdown content to Roam, creating or updating pages.
|
|
91
192
|
|
|
92
193
|
```bash
|
|
93
|
-
# From a file
|
|
94
|
-
|
|
194
|
+
# From a file (title derived from filename)
|
|
195
|
+
roam save document.md
|
|
95
196
|
|
|
96
|
-
#
|
|
97
|
-
|
|
197
|
+
# With explicit title
|
|
198
|
+
roam save document.md --title "Meeting Notes"
|
|
199
|
+
|
|
200
|
+
# Update existing page with smart diff (preserves block UIDs)
|
|
201
|
+
roam save document.md --update
|
|
202
|
+
|
|
203
|
+
# From stdin (requires --title)
|
|
204
|
+
cat notes.md | roam save --title "Quick Notes"
|
|
205
|
+
pbpaste | roam save --title "Clipboard Content"
|
|
98
206
|
|
|
99
207
|
# From here-doc
|
|
100
|
-
roam
|
|
208
|
+
roam save --title "Quick Note" << EOF
|
|
101
209
|
# Heading
|
|
102
210
|
- Item 1
|
|
103
211
|
- Item 2
|
|
@@ -105,34 +213,68 @@ roam-import "Quick Note" << EOF
|
|
|
105
213
|
EOF
|
|
106
214
|
```
|
|
107
215
|
|
|
108
|
-
|
|
216
|
+
**Options:**
|
|
217
|
+
- `--title <title>` - Page title (defaults to filename without `.md`)
|
|
218
|
+
- `--update` - Update existing page using smart diff (preserves block UIDs)
|
|
219
|
+
- `--debug` - Show debug information
|
|
109
220
|
|
|
110
|
-
|
|
221
|
+
**Features:**
|
|
111
222
|
- Creates a new page with the specified title (or appends to existing page)
|
|
112
223
|
- Automatically links the new page from today's daily page
|
|
113
|
-
- Converts standard markdown to Roam-flavored markdown
|
|
224
|
+
- Converts standard markdown to Roam-flavored markdown
|
|
225
|
+
- Smart diff mode (`--update`) preserves block UIDs for existing content
|
|
114
226
|
|
|
115
|
-
|
|
227
|
+
---
|
|
116
228
|
|
|
117
|
-
|
|
229
|
+
### `roam refs` - Find references
|
|
230
|
+
|
|
231
|
+
Find blocks that reference a page or block (backlinks).
|
|
118
232
|
|
|
119
233
|
```bash
|
|
120
|
-
|
|
121
|
-
|
|
234
|
+
# Find references to a page
|
|
235
|
+
roam refs "Project Alpha"
|
|
236
|
+
roam refs "December 30th, 2025"
|
|
122
237
|
|
|
123
|
-
|
|
238
|
+
# Find references to a tag
|
|
239
|
+
roam refs "#TODO"
|
|
240
|
+
roam refs "[[Meeting Notes]]"
|
|
124
241
|
|
|
125
|
-
|
|
126
|
-
|
|
242
|
+
# Find references to a block
|
|
243
|
+
roam refs "((AbCdEfGhI))"
|
|
244
|
+
|
|
245
|
+
# Limit results
|
|
246
|
+
roam refs "My Page" -n 100
|
|
247
|
+
|
|
248
|
+
# Output as JSON (for LLM/programmatic use)
|
|
249
|
+
roam refs "My Page" --json
|
|
250
|
+
|
|
251
|
+
# Raw output (for piping)
|
|
252
|
+
roam refs "My Page" --raw
|
|
127
253
|
```
|
|
128
254
|
|
|
129
|
-
|
|
255
|
+
**Options:**
|
|
256
|
+
- `-n, --limit <n>` - Limit number of results (default: 50)
|
|
257
|
+
- `--json` - Output as JSON array
|
|
258
|
+
- `--raw` - Output raw UID + content lines (no grouping)
|
|
259
|
+
- `--debug` - Show query metadata
|
|
130
260
|
|
|
131
|
-
|
|
132
|
-
- `ROAM_API_TOKEN`: Your Roam Research API token
|
|
133
|
-
- `ROAM_GRAPH_NAME`: Your Roam graph name
|
|
261
|
+
**Output Formats:**
|
|
134
262
|
|
|
135
|
-
|
|
263
|
+
Default output groups results by page:
|
|
264
|
+
```
|
|
265
|
+
[[Reading List: Inbox]]
|
|
266
|
+
tiTqNBvYA Date Captured:: [[December 30th, 2025]]
|
|
267
|
+
|
|
268
|
+
[[Week 53, 2025]]
|
|
269
|
+
g0ur1z7Bs [Sun 28]([[December 28th, 2025]]) | [Mon 29](...
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
JSON output for programmatic use:
|
|
273
|
+
```json
|
|
274
|
+
[
|
|
275
|
+
{"uid": "tiTqNBvYA", "content": "Date Captured:: [[December 30th, 2025]]", "page": "Reading List: Inbox"}
|
|
276
|
+
]
|
|
277
|
+
```
|
|
136
278
|
|
|
137
279
|
---
|
|
138
280
|
|
|
@@ -167,7 +309,7 @@ The server provides powerful tools for interacting with Roam Research:
|
|
|
167
309
|
5. `roam_import_markdown`: Import nested markdown content under a specific block. (Internally uses `roam_process_batch_actions`.)
|
|
168
310
|
6. `roam_add_todo`: Add a list of todo items to today's daily page. (Internally uses `roam_process_batch_actions`.)
|
|
169
311
|
7. `roam_create_outline`: Add a structured outline to an existing page or block, with support for `children_view_type`. Best for simpler, sequential outlines. For complex nesting (e.g., tables), consider `roam_process_batch_actions`. If `page_title_uid` and `block_text_uid` are both blank, content defaults to the daily page. (Internally uses `roam_process_batch_actions`.)
|
|
170
|
-
8. `roam_search_block_refs`: Search for block references within a page or across the entire graph.
|
|
312
|
+
8. `roam_search_block_refs`: Search for block references within a page or across the entire graph. Now supports `title` parameter to find blocks referencing a page title using `:block/refs` (captures `[[page]]` and `#tag` links semantically).
|
|
171
313
|
9. `roam_search_hierarchy`: Search for parent or child blocks in the block hierarchy.
|
|
172
314
|
10. `roam_find_pages_modified_today`: Find pages that have been modified today (since midnight), with pagination and sorting options.
|
|
173
315
|
11. `roam_search_by_text`: Search for blocks containing specific text across all pages or within a specific page. This tool supports pagination via the `limit` and `offset` parameters.
|
|
@@ -179,6 +321,7 @@ The server provides powerful tools for interacting with Roam Research:
|
|
|
179
321
|
17. `roam_datomic_query`: Execute a custom Datomic query on the Roam graph for advanced data retrieval beyond the available search tools. Now supports client-side regex filtering for enhanced post-query processing. Optimal for complex filtering (including regex), highly complex boolean logic, arbitrary sorting criteria, and proximity search.
|
|
180
322
|
18. `roam_markdown_cheatsheet`: Provides the content of the Roam Markdown Cheatsheet resource, optionally concatenated with custom instructions if `CUSTOM_INSTRUCTIONS_PATH` environment variable is set.
|
|
181
323
|
19. `roam_process_batch_actions`: Execute a sequence of low-level block actions (create, update, move, delete) in a single, non-transactional batch. Provides granular control for complex nesting like tables. **Now includes pre-validation** that catches errors before API execution, with structured error responses and automatic rate limit retry with exponential backoff. (Note: For actions on existing blocks or within a specific page context, it is often necessary to first obtain valid page or block UIDs using tools like `roam_fetch_page_by_title`.)
|
|
324
|
+
20. `roam_update_page_markdown`: Update an existing page with new markdown content using smart diff. **Preserves block UIDs** where possible, keeping references intact across the graph. Uses three-phase matching (exact text → normalized → position fallback) to generate minimal operations. Supports `dry_run` mode to preview changes. Ideal for syncing external markdown files, AI-assisted content updates, and batch modifications without losing block references.
|
|
182
325
|
|
|
183
326
|
**Deprecated Tools**:
|
|
184
327
|
The following tools have been deprecated as of `v0.36.2` in favor of the more powerful and flexible `roam_process_batch_actions`:
|
|
@@ -289,6 +432,25 @@ This demonstrates creating a new page with both text blocks and a table in a sin
|
|
|
289
432
|
- A conclusion section"
|
|
290
433
|
```
|
|
291
434
|
|
|
435
|
+
### Example 6: Updating a Page with Smart Diff
|
|
436
|
+
|
|
437
|
+
This demonstrates updating an existing page while preserving block UIDs (and therefore block references across the graph).
|
|
438
|
+
|
|
439
|
+
```
|
|
440
|
+
"Update the 'Project Alpha Planning' page with this revised content, preserving block references:
|
|
441
|
+
- Overview (keep existing UID)
|
|
442
|
+
- Updated Goals section
|
|
443
|
+
- Revised Scope with new details
|
|
444
|
+
- Team Members
|
|
445
|
+
- John Doe (Senior Dev)
|
|
446
|
+
- Jane Smith (PM)
|
|
447
|
+
- New hire: Bob Wilson
|
|
448
|
+
- Updated Timeline
|
|
449
|
+
- Remove the old 'Deadlines' section"
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
The tool will match existing blocks by content, update changed text, add new blocks, and remove deleted ones - all while keeping UIDs stable for blocks that still exist.
|
|
453
|
+
|
|
292
454
|
---
|
|
293
455
|
|
|
294
456
|
## Setup
|
|
@@ -71,10 +71,13 @@ Source:: https://example.com
|
|
|
71
71
|
| `Step 1:: Do this thing` | `**Step 1:** Do this thing` | Step numbers are page-specific, not queryable concepts |
|
|
72
72
|
| `Note:: Some observation` | Just write the text, or use `#note` | One-off labels don't need attribute syntax |
|
|
73
73
|
| `Summary:: The main point` | `**Summary:** The main point` | Section headers are formatting, not metadata |
|
|
74
|
-
| `Definition:: Some text` |
|
|
74
|
+
| `Definition:: Some text` | `Term:: Definition` | Only use for actual definitions you want to query |
|
|
75
|
+
| `Implementation Tier 3 (Societal Restructuring):: Some text` | `** Implementation Tier 3 (Societal Restructuring)**: Some text` | Label is specific to current concept |
|
|
75
76
|
|
|
76
77
|
⚠️ **The Test**: Ask yourself: "Will I ever query for all blocks with this attribute across my graph?" If no, use **bold formatting** (`**Label:**`) instead of `::` syntax.
|
|
77
78
|
|
|
79
|
+
NOTE: Never combine bold markdown formatting with `::`. Roam formats attributes in bold by default. ✅ `<attribute>::` ❌ `**<attribute>**::`
|
|
80
|
+
|
|
78
81
|
---
|
|
79
82
|
|
|
80
83
|
## Block Structures
|
|
@@ -328,7 +331,7 @@ Empty blocks and decorative dividers create clutter. Roam's outliner structure p
|
|
|
328
331
|
|
|
329
332
|
### Definitions
|
|
330
333
|
```
|
|
331
|
-
|
|
334
|
+
Term:: Definition text #definition #[[domain]]
|
|
332
335
|
```
|
|
333
336
|
|
|
334
337
|
### Questions for Future
|
|
@@ -456,6 +459,11 @@ When a tag would awkwardly affect sentence capitalization:
|
|
|
456
459
|
[Cognitive biases]([[cognitive biases]]) affect decision-making...
|
|
457
460
|
```
|
|
458
461
|
|
|
462
|
+
### Definitions (OVERRIDE)
|
|
463
|
+
```
|
|
464
|
+
#def [[<term>]] : <definition>
|
|
465
|
+
```
|
|
466
|
+
|
|
459
467
|
---
|
|
460
468
|
|
|
461
469
|
## Constraints & Guardrails
|
|
@@ -465,6 +473,7 @@ When a tag would awkwardly affect sentence capitalization:
|
|
|
465
473
|
- **Tag obvious/redundant** — If parent block is tagged, children inherit context
|
|
466
474
|
- **Use inconsistent capitalization** — Tags are lowercase unless proper nouns
|
|
467
475
|
- **Create orphan tags** — Check if existing page/tag serves the purpose
|
|
476
|
+
- **Bold Attributes** - ❌ `**Attribute**::`, ✅ `Attribute::` (Roam auto-formats)
|
|
468
477
|
|
|
469
478
|
### DO
|
|
470
479
|
- **Think retrieval-first** — How will you search for this later?
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { initializeGraph } from '@roam-research/roam-api-sdk';
|
|
3
|
+
import { API_TOKEN, GRAPH_NAME } from '../../config/environment.js';
|
|
4
|
+
import { PageOperations } from '../../tools/operations/pages.js';
|
|
5
|
+
import { BlockRetrievalOperations } from '../../tools/operations/block-retrieval.js';
|
|
6
|
+
import { formatPageOutput, formatBlockOutput, printDebug, exitWithError } from '../utils/output.js';
|
|
7
|
+
// Block UID pattern: 9 alphanumeric characters, optionally wrapped in (( ))
|
|
8
|
+
const BLOCK_UID_PATTERN = /^(?:\(\()?([a-zA-Z0-9_-]{9})(?:\)\))?$/;
|
|
9
|
+
export function createGetCommand() {
|
|
10
|
+
return new Command('get')
|
|
11
|
+
.description('Fetch a page or block from Roam')
|
|
12
|
+
.argument('<target>', 'Page title or block UID (e.g., "Page Title" or "((AbCdEfGhI))")')
|
|
13
|
+
.option('--json', 'Output as JSON instead of markdown')
|
|
14
|
+
.option('--depth <n>', 'Child levels to fetch (default: 4)', '4')
|
|
15
|
+
.option('--refs <n>', 'Block ref expansion depth (default: 1)', '1')
|
|
16
|
+
.option('--flat', 'Flatten hierarchy to single-level list')
|
|
17
|
+
.option('--debug', 'Show query metadata')
|
|
18
|
+
.action(async (target, options) => {
|
|
19
|
+
try {
|
|
20
|
+
const graph = initializeGraph({
|
|
21
|
+
token: API_TOKEN,
|
|
22
|
+
graph: GRAPH_NAME
|
|
23
|
+
});
|
|
24
|
+
const depth = parseInt(options.depth || '4', 10);
|
|
25
|
+
const outputOptions = {
|
|
26
|
+
json: options.json,
|
|
27
|
+
flat: options.flat,
|
|
28
|
+
debug: options.debug
|
|
29
|
+
};
|
|
30
|
+
if (options.debug) {
|
|
31
|
+
printDebug('Target', target);
|
|
32
|
+
printDebug('Options', { depth, refs: options.refs, ...outputOptions });
|
|
33
|
+
}
|
|
34
|
+
// Check if target is a block UID
|
|
35
|
+
const uidMatch = target.match(BLOCK_UID_PATTERN);
|
|
36
|
+
if (uidMatch) {
|
|
37
|
+
// Fetch block by UID
|
|
38
|
+
const blockUid = uidMatch[1];
|
|
39
|
+
if (options.debug) {
|
|
40
|
+
printDebug('Fetching block', { uid: blockUid, depth });
|
|
41
|
+
}
|
|
42
|
+
const blockOps = new BlockRetrievalOperations(graph);
|
|
43
|
+
const block = await blockOps.fetchBlockWithChildren(blockUid, depth);
|
|
44
|
+
if (!block) {
|
|
45
|
+
exitWithError(`Block with UID "${blockUid}" not found`);
|
|
46
|
+
}
|
|
47
|
+
console.log(formatBlockOutput(block, outputOptions));
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
// Fetch page by title
|
|
51
|
+
if (options.debug) {
|
|
52
|
+
printDebug('Fetching page', { title: target, depth });
|
|
53
|
+
}
|
|
54
|
+
const pageOps = new PageOperations(graph);
|
|
55
|
+
const result = await pageOps.fetchPageByTitle(target, 'raw');
|
|
56
|
+
// Parse the raw result
|
|
57
|
+
let blocks;
|
|
58
|
+
if (typeof result === 'string') {
|
|
59
|
+
try {
|
|
60
|
+
blocks = JSON.parse(result);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Result is already formatted as string (e.g., "Page Title (no content found)")
|
|
64
|
+
console.log(result);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
blocks = result;
|
|
70
|
+
}
|
|
71
|
+
console.log(formatPageOutput(target, blocks, outputOptions));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
76
|
+
exitWithError(message);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { initializeGraph } from '@roam-research/roam-api-sdk';
|
|
3
|
+
import { API_TOKEN, GRAPH_NAME } from '../../config/environment.js';
|
|
4
|
+
import { SearchOperations } from '../../tools/operations/search/index.js';
|
|
5
|
+
import { printDebug, exitWithError } from '../utils/output.js';
|
|
6
|
+
/**
|
|
7
|
+
* Format results grouped by page (default output)
|
|
8
|
+
*/
|
|
9
|
+
function formatGrouped(matches, maxContentLength = 60) {
|
|
10
|
+
if (matches.length === 0) {
|
|
11
|
+
return 'No references found.';
|
|
12
|
+
}
|
|
13
|
+
// Group by page title
|
|
14
|
+
const byPage = new Map();
|
|
15
|
+
for (const match of matches) {
|
|
16
|
+
const pageTitle = match.page_title || 'Unknown Page';
|
|
17
|
+
if (!byPage.has(pageTitle)) {
|
|
18
|
+
byPage.set(pageTitle, []);
|
|
19
|
+
}
|
|
20
|
+
byPage.get(pageTitle).push(match);
|
|
21
|
+
}
|
|
22
|
+
// Format output
|
|
23
|
+
const lines = [];
|
|
24
|
+
for (const [pageTitle, pageMatches] of byPage) {
|
|
25
|
+
lines.push(`[[${pageTitle}]]`);
|
|
26
|
+
for (const match of pageMatches) {
|
|
27
|
+
const truncated = match.content.length > maxContentLength
|
|
28
|
+
? match.content.slice(0, maxContentLength) + '...'
|
|
29
|
+
: match.content;
|
|
30
|
+
lines.push(` ${match.block_uid} ${truncated}`);
|
|
31
|
+
}
|
|
32
|
+
lines.push('');
|
|
33
|
+
}
|
|
34
|
+
return lines.join('\n').trim();
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Format results as raw lines (UID + content)
|
|
38
|
+
*/
|
|
39
|
+
function formatRaw(matches, maxContentLength = 60) {
|
|
40
|
+
if (matches.length === 0) {
|
|
41
|
+
return 'No references found.';
|
|
42
|
+
}
|
|
43
|
+
return matches
|
|
44
|
+
.map(match => {
|
|
45
|
+
const truncated = match.content.length > maxContentLength
|
|
46
|
+
? match.content.slice(0, maxContentLength) + '...'
|
|
47
|
+
: match.content;
|
|
48
|
+
return `${match.block_uid} ${truncated}`;
|
|
49
|
+
})
|
|
50
|
+
.join('\n');
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Parse identifier to determine if it's a block UID or page title
|
|
54
|
+
*/
|
|
55
|
+
function parseIdentifier(identifier) {
|
|
56
|
+
// Check for ((uid)) format
|
|
57
|
+
const blockRefMatch = identifier.match(/^\(\(([^)]+)\)\)$/);
|
|
58
|
+
if (blockRefMatch) {
|
|
59
|
+
return { block_uid: blockRefMatch[1] };
|
|
60
|
+
}
|
|
61
|
+
// Check for [[page]] or #[[page]] format - extract page title
|
|
62
|
+
const pageRefMatch = identifier.match(/^#?\[\[(.+)\]\]$/);
|
|
63
|
+
if (pageRefMatch) {
|
|
64
|
+
return { title: pageRefMatch[1] };
|
|
65
|
+
}
|
|
66
|
+
// Check for #tag format
|
|
67
|
+
if (identifier.startsWith('#')) {
|
|
68
|
+
return { title: identifier.slice(1) };
|
|
69
|
+
}
|
|
70
|
+
// Default: treat as page title
|
|
71
|
+
return { title: identifier };
|
|
72
|
+
}
|
|
73
|
+
export function createRefsCommand() {
|
|
74
|
+
return new Command('refs')
|
|
75
|
+
.description('Find blocks referencing a page or block')
|
|
76
|
+
.argument('<identifier>', 'Page title or block UID (use ((uid)) for block refs)')
|
|
77
|
+
.option('-n, --limit <n>', 'Limit number of results', '50')
|
|
78
|
+
.option('--json', 'Output as JSON array')
|
|
79
|
+
.option('--raw', 'Output raw UID + content lines (no grouping)')
|
|
80
|
+
.option('--debug', 'Show query metadata')
|
|
81
|
+
.action(async (identifier, options) => {
|
|
82
|
+
try {
|
|
83
|
+
const graph = initializeGraph({
|
|
84
|
+
token: API_TOKEN,
|
|
85
|
+
graph: GRAPH_NAME
|
|
86
|
+
});
|
|
87
|
+
const limit = parseInt(options.limit || '50', 10);
|
|
88
|
+
const { block_uid, title } = parseIdentifier(identifier);
|
|
89
|
+
if (options.debug) {
|
|
90
|
+
printDebug('Identifier', identifier);
|
|
91
|
+
printDebug('Parsed', { block_uid, title });
|
|
92
|
+
printDebug('Options', options);
|
|
93
|
+
}
|
|
94
|
+
const searchOps = new SearchOperations(graph);
|
|
95
|
+
const result = await searchOps.searchBlockRefs({ block_uid, title });
|
|
96
|
+
if (options.debug) {
|
|
97
|
+
printDebug('Total matches', result.matches.length);
|
|
98
|
+
}
|
|
99
|
+
// Apply limit
|
|
100
|
+
const limitedMatches = result.matches.slice(0, limit);
|
|
101
|
+
// Format output
|
|
102
|
+
if (options.json) {
|
|
103
|
+
const jsonOutput = limitedMatches.map(m => ({
|
|
104
|
+
uid: m.block_uid,
|
|
105
|
+
content: m.content,
|
|
106
|
+
page: m.page_title
|
|
107
|
+
}));
|
|
108
|
+
console.log(JSON.stringify(jsonOutput, null, 2));
|
|
109
|
+
}
|
|
110
|
+
else if (options.raw) {
|
|
111
|
+
console.log(formatRaw(limitedMatches));
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
console.log(formatGrouped(limitedMatches));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
119
|
+
exitWithError(message);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { basename } from 'path';
|
|
4
|
+
import { initializeGraph } from '@roam-research/roam-api-sdk';
|
|
5
|
+
import { API_TOKEN, GRAPH_NAME } from '../../config/environment.js';
|
|
6
|
+
import { PageOperations } from '../../tools/operations/pages.js';
|
|
7
|
+
import { parseMarkdown } from '../../markdown-utils.js';
|
|
8
|
+
import { printDebug, exitWithError } from '../utils/output.js';
|
|
9
|
+
/**
|
|
10
|
+
* Flatten nested MarkdownNode[] to flat array with absolute levels
|
|
11
|
+
*/
|
|
12
|
+
function flattenNodes(nodes, baseLevel = 1) {
|
|
13
|
+
const result = [];
|
|
14
|
+
for (const node of nodes) {
|
|
15
|
+
result.push({
|
|
16
|
+
text: node.content,
|
|
17
|
+
level: baseLevel,
|
|
18
|
+
...(node.heading_level && { heading: node.heading_level })
|
|
19
|
+
});
|
|
20
|
+
if (node.children.length > 0) {
|
|
21
|
+
result.push(...flattenNodes(node.children, baseLevel + 1));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Read all input from stdin
|
|
28
|
+
*/
|
|
29
|
+
async function readStdin() {
|
|
30
|
+
const chunks = [];
|
|
31
|
+
for await (const chunk of process.stdin) {
|
|
32
|
+
chunks.push(chunk);
|
|
33
|
+
}
|
|
34
|
+
return Buffer.concat(chunks).toString('utf-8');
|
|
35
|
+
}
|
|
36
|
+
export function createSaveCommand() {
|
|
37
|
+
return new Command('save')
|
|
38
|
+
.description('Import markdown to Roam')
|
|
39
|
+
.argument('[file]', 'Markdown file to import (or pipe content to stdin)')
|
|
40
|
+
.option('--title <title>', 'Page title (defaults to filename without .md)')
|
|
41
|
+
.option('--update', 'Update existing page using smart diff')
|
|
42
|
+
.option('--debug', 'Show debug information')
|
|
43
|
+
.action(async (file, options) => {
|
|
44
|
+
try {
|
|
45
|
+
let markdownContent;
|
|
46
|
+
let pageTitle;
|
|
47
|
+
if (file) {
|
|
48
|
+
// Read from file
|
|
49
|
+
try {
|
|
50
|
+
markdownContent = readFileSync(file, 'utf-8');
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
exitWithError(`Could not read file: ${file}`);
|
|
54
|
+
}
|
|
55
|
+
// Derive title from filename if not provided
|
|
56
|
+
pageTitle = options.title || basename(file, '.md');
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
// Read from stdin
|
|
60
|
+
if (process.stdin.isTTY) {
|
|
61
|
+
exitWithError('No file specified and no input piped. Use: roam save <file.md> or cat file.md | roam save --title "Title"');
|
|
62
|
+
}
|
|
63
|
+
if (!options.title) {
|
|
64
|
+
exitWithError('--title is required when piping from stdin');
|
|
65
|
+
}
|
|
66
|
+
markdownContent = await readStdin();
|
|
67
|
+
pageTitle = options.title;
|
|
68
|
+
}
|
|
69
|
+
if (!markdownContent.trim()) {
|
|
70
|
+
exitWithError('Empty content received');
|
|
71
|
+
}
|
|
72
|
+
if (options.debug) {
|
|
73
|
+
printDebug('Page title', pageTitle);
|
|
74
|
+
printDebug('Content length', markdownContent.length);
|
|
75
|
+
printDebug('Update mode', options.update || false);
|
|
76
|
+
}
|
|
77
|
+
const graph = initializeGraph({
|
|
78
|
+
token: API_TOKEN,
|
|
79
|
+
graph: GRAPH_NAME
|
|
80
|
+
});
|
|
81
|
+
const pageOps = new PageOperations(graph);
|
|
82
|
+
if (options.update) {
|
|
83
|
+
// Use smart diff to update existing page
|
|
84
|
+
const result = await pageOps.updatePageMarkdown(pageTitle, markdownContent, false // not dry run
|
|
85
|
+
);
|
|
86
|
+
if (result.success) {
|
|
87
|
+
console.log(`Updated page '${pageTitle}'`);
|
|
88
|
+
console.log(` ${result.summary}`);
|
|
89
|
+
if (result.preservedUids.length > 0) {
|
|
90
|
+
console.log(` Preserved ${result.preservedUids.length} block UID(s)`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
exitWithError(`Failed to update page '${pageTitle}'`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
// Create new page (or add content to existing empty page)
|
|
99
|
+
const nodes = parseMarkdown(markdownContent);
|
|
100
|
+
const contentBlocks = flattenNodes(nodes);
|
|
101
|
+
if (contentBlocks.length === 0) {
|
|
102
|
+
exitWithError('No content blocks parsed from input');
|
|
103
|
+
}
|
|
104
|
+
if (options.debug) {
|
|
105
|
+
printDebug('Parsed blocks', contentBlocks.length);
|
|
106
|
+
}
|
|
107
|
+
const result = await pageOps.createPage(pageTitle, contentBlocks);
|
|
108
|
+
if (result.success) {
|
|
109
|
+
console.log(`Created page '${pageTitle}' (uid: ${result.uid})`);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
exitWithError(`Failed to create page '${pageTitle}'`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
118
|
+
exitWithError(message);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|