roam-research-mcp 1.3.2 → 1.4.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 +20 -0
- package/build/Roam_Markdown_Cheatsheet.md +11 -2
- 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/server/roam-server.js +7 -0
- package/build/tools/operations/pages.js +95 -0
- package/build/tools/schemas.js +23 -0
- package/build/tools/tool-handlers.js +4 -0
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -179,6 +179,7 @@ The server provides powerful tools for interacting with Roam Research:
|
|
|
179
179
|
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
180
|
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
181
|
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`.)
|
|
182
|
+
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
183
|
|
|
183
184
|
**Deprecated Tools**:
|
|
184
185
|
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 +290,25 @@ This demonstrates creating a new page with both text blocks and a table in a sin
|
|
|
289
290
|
- A conclusion section"
|
|
290
291
|
```
|
|
291
292
|
|
|
293
|
+
### Example 6: Updating a Page with Smart Diff
|
|
294
|
+
|
|
295
|
+
This demonstrates updating an existing page while preserving block UIDs (and therefore block references across the graph).
|
|
296
|
+
|
|
297
|
+
```
|
|
298
|
+
"Update the 'Project Alpha Planning' page with this revised content, preserving block references:
|
|
299
|
+
- Overview (keep existing UID)
|
|
300
|
+
- Updated Goals section
|
|
301
|
+
- Revised Scope with new details
|
|
302
|
+
- Team Members
|
|
303
|
+
- John Doe (Senior Dev)
|
|
304
|
+
- Jane Smith (PM)
|
|
305
|
+
- New hire: Bob Wilson
|
|
306
|
+
- Updated Timeline
|
|
307
|
+
- Remove the old 'Deadlines' section"
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
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.
|
|
311
|
+
|
|
292
312
|
---
|
|
293
313
|
|
|
294
314
|
## 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,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates correctly ordered batch actions from a DiffResult.
|
|
5
|
+
* The order is critical for Roam API correctness:
|
|
6
|
+
* 1. Creates (top-down to ensure parents exist before children)
|
|
7
|
+
* 2. Moves (reposition existing blocks)
|
|
8
|
+
* 3. Updates (text/heading changes)
|
|
9
|
+
* 4. Deletes (bottom-up; reverse order to delete children before parents)
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Generate a correctly ordered array of batch actions from a DiffResult.
|
|
13
|
+
*
|
|
14
|
+
* The ordering ensures:
|
|
15
|
+
* - Parent blocks are created before their children
|
|
16
|
+
* - Blocks are moved before text updates (in case moves affect siblings)
|
|
17
|
+
* - Children are deleted before their parents
|
|
18
|
+
*
|
|
19
|
+
* @param diff - The DiffResult containing categorized actions
|
|
20
|
+
* @returns Ordered array of batch actions ready for Roam API
|
|
21
|
+
*/
|
|
22
|
+
export function generateBatchActions(diff) {
|
|
23
|
+
const actions = [];
|
|
24
|
+
// 1. Creates (in markdown order, parents before children)
|
|
25
|
+
// The creates are already in the correct order from diffBlockTrees
|
|
26
|
+
actions.push(...diff.creates);
|
|
27
|
+
// 2. Moves (reposition existing blocks)
|
|
28
|
+
actions.push(...diff.moves);
|
|
29
|
+
// 3. Updates (text/heading changes)
|
|
30
|
+
actions.push(...diff.updates);
|
|
31
|
+
// 4. Deletes (reversed to delete children before parents)
|
|
32
|
+
// This is important because Roam will fail if you try to delete
|
|
33
|
+
// a parent block while it still has children
|
|
34
|
+
actions.push(...[...diff.deletes].reverse());
|
|
35
|
+
return actions;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Filter actions to only include specific action types.
|
|
39
|
+
* Useful for dry-run analysis or debugging.
|
|
40
|
+
*/
|
|
41
|
+
export function filterActions(actions, types) {
|
|
42
|
+
const typeSet = new Set(types);
|
|
43
|
+
return actions.filter((a) => typeSet.has(a.action));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Group actions by their type for analysis.
|
|
47
|
+
*/
|
|
48
|
+
export function groupActionsByType(actions) {
|
|
49
|
+
const creates = [];
|
|
50
|
+
const updates = [];
|
|
51
|
+
const moves = [];
|
|
52
|
+
const deletes = [];
|
|
53
|
+
for (const action of actions) {
|
|
54
|
+
switch (action.action) {
|
|
55
|
+
case 'create-block':
|
|
56
|
+
creates.push(action);
|
|
57
|
+
break;
|
|
58
|
+
case 'update-block':
|
|
59
|
+
updates.push(action);
|
|
60
|
+
break;
|
|
61
|
+
case 'move-block':
|
|
62
|
+
moves.push(action);
|
|
63
|
+
break;
|
|
64
|
+
case 'delete-block':
|
|
65
|
+
deletes.push(action);
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { creates, updates, moves, deletes };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Summarize actions for logging purposes.
|
|
73
|
+
*/
|
|
74
|
+
export function summarizeActions(actions) {
|
|
75
|
+
const grouped = groupActionsByType(actions);
|
|
76
|
+
const parts = [];
|
|
77
|
+
if (grouped.creates.length > 0) {
|
|
78
|
+
parts.push(`${grouped.creates.length} create(s)`);
|
|
79
|
+
}
|
|
80
|
+
if (grouped.moves.length > 0) {
|
|
81
|
+
parts.push(`${grouped.moves.length} move(s)`);
|
|
82
|
+
}
|
|
83
|
+
if (grouped.updates.length > 0) {
|
|
84
|
+
parts.push(`${grouped.updates.length} update(s)`);
|
|
85
|
+
}
|
|
86
|
+
if (grouped.deletes.length > 0) {
|
|
87
|
+
parts.push(`${grouped.deletes.length} delete(s)`);
|
|
88
|
+
}
|
|
89
|
+
if (parts.length === 0) {
|
|
90
|
+
return 'No changes';
|
|
91
|
+
}
|
|
92
|
+
return parts.join(', ');
|
|
93
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { generateBatchActions, filterActions, groupActionsByType, summarizeActions, } from './actions.js';
|
|
3
|
+
describe('generateBatchActions', () => {
|
|
4
|
+
function createDiffResult(creates = [], updates = [], moves = [], deletes = []) {
|
|
5
|
+
return {
|
|
6
|
+
creates,
|
|
7
|
+
updates,
|
|
8
|
+
moves,
|
|
9
|
+
deletes,
|
|
10
|
+
preservedUids: new Set(),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
it('returns actions in correct order: creates, moves, updates, deletes', () => {
|
|
14
|
+
const diff = createDiffResult([{ action: 'create-block', block: { string: 'new' }, location: { 'parent-uid': 'p', order: 0 } }], [{ action: 'update-block', block: { uid: 'u1', string: 'updated' } }], [{ action: 'move-block', block: { uid: 'u2' }, location: { 'parent-uid': 'p', order: 1 } }], [{ action: 'delete-block', block: { uid: 'u3' } }]);
|
|
15
|
+
const actions = generateBatchActions(diff);
|
|
16
|
+
expect(actions[0].action).toBe('create-block');
|
|
17
|
+
expect(actions[1].action).toBe('move-block');
|
|
18
|
+
expect(actions[2].action).toBe('update-block');
|
|
19
|
+
expect(actions[3].action).toBe('delete-block');
|
|
20
|
+
});
|
|
21
|
+
it('reverses delete order for child-before-parent deletion', () => {
|
|
22
|
+
const diff = createDiffResult([], [], [], [
|
|
23
|
+
{ action: 'delete-block', block: { uid: 'parent' } },
|
|
24
|
+
{ action: 'delete-block', block: { uid: 'child' } },
|
|
25
|
+
]);
|
|
26
|
+
const actions = generateBatchActions(diff);
|
|
27
|
+
// Deletes should be reversed
|
|
28
|
+
expect(actions[0].block.uid).toBe('child');
|
|
29
|
+
expect(actions[1].block.uid).toBe('parent');
|
|
30
|
+
});
|
|
31
|
+
it('returns empty array for empty diff', () => {
|
|
32
|
+
const diff = createDiffResult();
|
|
33
|
+
const actions = generateBatchActions(diff);
|
|
34
|
+
expect(actions).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
it('preserves all actions without modification', () => {
|
|
37
|
+
const createAction = {
|
|
38
|
+
action: 'create-block',
|
|
39
|
+
block: { uid: 'new1', string: 'Test', heading: 2 },
|
|
40
|
+
location: { 'parent-uid': 'page', order: 0 },
|
|
41
|
+
};
|
|
42
|
+
const diff = createDiffResult([createAction]);
|
|
43
|
+
const actions = generateBatchActions(diff);
|
|
44
|
+
expect(actions[0]).toEqual(createAction);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
describe('filterActions', () => {
|
|
48
|
+
const allActions = [
|
|
49
|
+
{ action: 'create-block', block: { string: 'new' }, location: { 'parent-uid': 'p', order: 0 } },
|
|
50
|
+
{ action: 'update-block', block: { uid: 'u1', string: 'updated' } },
|
|
51
|
+
{ action: 'move-block', block: { uid: 'u2' }, location: { 'parent-uid': 'p', order: 1 } },
|
|
52
|
+
{ action: 'delete-block', block: { uid: 'u3' } },
|
|
53
|
+
];
|
|
54
|
+
it('filters to only specified action types', () => {
|
|
55
|
+
const creates = filterActions(allActions, ['create-block']);
|
|
56
|
+
expect(creates.length).toBe(1);
|
|
57
|
+
expect(creates[0].action).toBe('create-block');
|
|
58
|
+
});
|
|
59
|
+
it('supports multiple action types', () => {
|
|
60
|
+
const modifying = filterActions(allActions, ['create-block', 'update-block']);
|
|
61
|
+
expect(modifying.length).toBe(2);
|
|
62
|
+
});
|
|
63
|
+
it('returns empty array when no matches', () => {
|
|
64
|
+
const emptyActions = [];
|
|
65
|
+
const result = filterActions(emptyActions, ['create-block']);
|
|
66
|
+
expect(result).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
describe('groupActionsByType', () => {
|
|
70
|
+
it('groups actions by their type', () => {
|
|
71
|
+
const actions = [
|
|
72
|
+
{ action: 'create-block', block: { string: 'a' }, location: { 'parent-uid': 'p', order: 0 } },
|
|
73
|
+
{ action: 'create-block', block: { string: 'b' }, location: { 'parent-uid': 'p', order: 1 } },
|
|
74
|
+
{ action: 'update-block', block: { uid: 'u1', string: 'c' } },
|
|
75
|
+
{ action: 'delete-block', block: { uid: 'u2' } },
|
|
76
|
+
];
|
|
77
|
+
const grouped = groupActionsByType(actions);
|
|
78
|
+
expect(grouped.creates.length).toBe(2);
|
|
79
|
+
expect(grouped.updates.length).toBe(1);
|
|
80
|
+
expect(grouped.moves.length).toBe(0);
|
|
81
|
+
expect(grouped.deletes.length).toBe(1);
|
|
82
|
+
});
|
|
83
|
+
it('returns empty arrays for missing types', () => {
|
|
84
|
+
const actions = [];
|
|
85
|
+
const grouped = groupActionsByType(actions);
|
|
86
|
+
expect(grouped.creates).toEqual([]);
|
|
87
|
+
expect(grouped.updates).toEqual([]);
|
|
88
|
+
expect(grouped.moves).toEqual([]);
|
|
89
|
+
expect(grouped.deletes).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
describe('summarizeActions', () => {
|
|
93
|
+
it('summarizes action counts', () => {
|
|
94
|
+
const actions = [
|
|
95
|
+
{ action: 'create-block', block: { string: 'a' }, location: { 'parent-uid': 'p', order: 0 } },
|
|
96
|
+
{ action: 'create-block', block: { string: 'b' }, location: { 'parent-uid': 'p', order: 1 } },
|
|
97
|
+
{ action: 'update-block', block: { uid: 'u1', string: 'c' } },
|
|
98
|
+
{ action: 'delete-block', block: { uid: 'u2' } },
|
|
99
|
+
];
|
|
100
|
+
const summary = summarizeActions(actions);
|
|
101
|
+
expect(summary).toBe('2 create(s), 1 update(s), 1 delete(s)');
|
|
102
|
+
});
|
|
103
|
+
it('returns "No changes" for empty actions', () => {
|
|
104
|
+
const summary = summarizeActions([]);
|
|
105
|
+
expect(summary).toBe('No changes');
|
|
106
|
+
});
|
|
107
|
+
it('only includes non-zero counts', () => {
|
|
108
|
+
const actions = [
|
|
109
|
+
{ action: 'update-block', block: { uid: 'u1', string: 'updated' } },
|
|
110
|
+
];
|
|
111
|
+
const summary = summarizeActions(actions);
|
|
112
|
+
expect(summary).toBe('1 update(s)');
|
|
113
|
+
expect(summary).not.toContain('create');
|
|
114
|
+
expect(summary).not.toContain('move');
|
|
115
|
+
expect(summary).not.toContain('delete');
|
|
116
|
+
});
|
|
117
|
+
it('includes moves when present', () => {
|
|
118
|
+
const actions = [
|
|
119
|
+
{ action: 'move-block', block: { uid: 'u1' }, location: { 'parent-uid': 'p', order: 0 } },
|
|
120
|
+
{ action: 'move-block', block: { uid: 'u2' }, location: { 'parent-uid': 'p', order: 1 } },
|
|
121
|
+
];
|
|
122
|
+
const summary = summarizeActions(actions);
|
|
123
|
+
expect(summary).toBe('2 move(s)');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diff Computation
|
|
3
|
+
*
|
|
4
|
+
* Computes the minimal set of batch actions needed to transform
|
|
5
|
+
* existing blocks into the desired new block structure.
|
|
6
|
+
*/
|
|
7
|
+
import { flattenExistingBlocks } from './parser.js';
|
|
8
|
+
import { matchBlocks, normalizeText } from './matcher.js';
|
|
9
|
+
/**
|
|
10
|
+
* Compute the diff between existing blocks and desired new blocks.
|
|
11
|
+
*
|
|
12
|
+
* This function:
|
|
13
|
+
* 1. Matches new blocks to existing blocks by content similarity
|
|
14
|
+
* 2. For matched blocks: generates update/move actions as needed
|
|
15
|
+
* 3. For unmatched new blocks: generates create actions
|
|
16
|
+
* 4. For unmatched existing blocks: generates delete actions
|
|
17
|
+
*
|
|
18
|
+
* @param existing - Array of existing block trees
|
|
19
|
+
* @param newBlocks - Flat array of new blocks (desired state)
|
|
20
|
+
* @param parentUid - UID of the parent page/block
|
|
21
|
+
* @returns DiffResult with categorized actions
|
|
22
|
+
*/
|
|
23
|
+
export function diffBlockTrees(existing, newBlocks, parentUid) {
|
|
24
|
+
const result = {
|
|
25
|
+
creates: [],
|
|
26
|
+
updates: [],
|
|
27
|
+
moves: [],
|
|
28
|
+
deletes: [],
|
|
29
|
+
preservedUids: new Set(),
|
|
30
|
+
};
|
|
31
|
+
// Flatten existing blocks for matching
|
|
32
|
+
const existingFlat = flattenExistingBlocks(existing);
|
|
33
|
+
// Step 1: Match blocks by content
|
|
34
|
+
const matches = matchBlocks(existingFlat, newBlocks);
|
|
35
|
+
// Build uid -> ExistingBlock mapping
|
|
36
|
+
const existingByUid = new Map(existingFlat.map((eb) => [eb.uid, eb]));
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the desired parent UID for a new block.
|
|
39
|
+
* If the parent was matched to an existing block, use that UID.
|
|
40
|
+
*/
|
|
41
|
+
function desiredParentUid(newBlock) {
|
|
42
|
+
if (!newBlock.parentRef)
|
|
43
|
+
return parentUid;
|
|
44
|
+
const parentNewUid = newBlock.parentRef.blockUid;
|
|
45
|
+
if (parentNewUid === parentUid)
|
|
46
|
+
return parentUid;
|
|
47
|
+
// If parent is matched, target the existing parent UID
|
|
48
|
+
return matches.get(parentNewUid) ?? parentNewUid;
|
|
49
|
+
}
|
|
50
|
+
// Build desired structure: map each new block to its desired parent
|
|
51
|
+
// and group siblings by their desired parent
|
|
52
|
+
const newUidToDesiredParent = new Map();
|
|
53
|
+
const siblingsByDesiredParent = new Map();
|
|
54
|
+
for (const newBlock of newBlocks) {
|
|
55
|
+
const newUid = newBlock.ref.blockUid;
|
|
56
|
+
const targetUid = matches.get(newUid) ?? newUid;
|
|
57
|
+
const dParent = desiredParentUid(newBlock);
|
|
58
|
+
newUidToDesiredParent.set(newUid, dParent);
|
|
59
|
+
if (!siblingsByDesiredParent.has(dParent)) {
|
|
60
|
+
siblingsByDesiredParent.set(dParent, []);
|
|
61
|
+
}
|
|
62
|
+
siblingsByDesiredParent.get(dParent).push(targetUid);
|
|
63
|
+
}
|
|
64
|
+
// Step 2: Process each new block
|
|
65
|
+
for (const newBlock of newBlocks) {
|
|
66
|
+
const newUid = newBlock.ref.blockUid;
|
|
67
|
+
if (matches.has(newUid)) {
|
|
68
|
+
// Block matched to an existing block
|
|
69
|
+
const existUid = matches.get(newUid);
|
|
70
|
+
const existBlock = existingByUid.get(existUid);
|
|
71
|
+
const currentParent = existBlock.parentUid ?? parentUid;
|
|
72
|
+
const dParent = newUidToDesiredParent.get(newUid);
|
|
73
|
+
result.preservedUids.add(existUid);
|
|
74
|
+
// Check for text/heading changes -> update-block
|
|
75
|
+
let needsUpdate = false;
|
|
76
|
+
const updateAction = {
|
|
77
|
+
action: 'update-block',
|
|
78
|
+
block: { uid: existUid },
|
|
79
|
+
};
|
|
80
|
+
if (normalizeText(newBlock.text) !== normalizeText(existBlock.text)) {
|
|
81
|
+
updateAction.block.string = newBlock.text;
|
|
82
|
+
needsUpdate = true;
|
|
83
|
+
}
|
|
84
|
+
if (newBlock.heading !== existBlock.heading) {
|
|
85
|
+
if (newBlock.heading !== null) {
|
|
86
|
+
updateAction.block.heading = newBlock.heading;
|
|
87
|
+
needsUpdate = true;
|
|
88
|
+
}
|
|
89
|
+
else if (existBlock.heading !== null) {
|
|
90
|
+
// Remove heading by setting to 0
|
|
91
|
+
updateAction.block.heading = 0;
|
|
92
|
+
needsUpdate = true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (needsUpdate) {
|
|
96
|
+
result.updates.push(updateAction);
|
|
97
|
+
}
|
|
98
|
+
// Check for parent/order changes -> move-block
|
|
99
|
+
const desiredSiblings = siblingsByDesiredParent.get(dParent) ?? [];
|
|
100
|
+
const desiredOrder = desiredSiblings.indexOf(existUid);
|
|
101
|
+
if (currentParent !== dParent || existBlock.order !== desiredOrder) {
|
|
102
|
+
result.moves.push({
|
|
103
|
+
action: 'move-block',
|
|
104
|
+
block: { uid: existUid },
|
|
105
|
+
location: { 'parent-uid': dParent, order: desiredOrder },
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
// No match -> create-block
|
|
111
|
+
const dParent = newUidToDesiredParent.get(newUid);
|
|
112
|
+
const desiredSiblings = siblingsByDesiredParent.get(dParent) ?? [];
|
|
113
|
+
const desiredOrder = desiredSiblings.indexOf(newUid);
|
|
114
|
+
const createAction = {
|
|
115
|
+
action: 'create-block',
|
|
116
|
+
location: {
|
|
117
|
+
'parent-uid': dParent,
|
|
118
|
+
order: desiredOrder >= 0 ? desiredOrder : 'last',
|
|
119
|
+
},
|
|
120
|
+
block: {
|
|
121
|
+
uid: newBlock.ref.blockUid,
|
|
122
|
+
string: newBlock.text,
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
if (newBlock.heading !== null) {
|
|
126
|
+
createAction.block.heading = newBlock.heading;
|
|
127
|
+
}
|
|
128
|
+
if (newBlock.open !== undefined) {
|
|
129
|
+
createAction.block.open = newBlock.open;
|
|
130
|
+
}
|
|
131
|
+
result.creates.push(createAction);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Step 3: Find unmatched existing blocks -> delete
|
|
135
|
+
const matchedExistingUids = new Set(matches.values());
|
|
136
|
+
for (const existBlock of existingFlat) {
|
|
137
|
+
if (!matchedExistingUids.has(existBlock.uid)) {
|
|
138
|
+
result.deletes.push({
|
|
139
|
+
action: 'delete-block',
|
|
140
|
+
block: { uid: existBlock.uid },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Diff blocks at a specific level (for hierarchical diffing).
|
|
148
|
+
* Used internally for recursive tree comparison.
|
|
149
|
+
*/
|
|
150
|
+
export function diffBlockLevel(existing, newBlocks, parentUid) {
|
|
151
|
+
// Filter to only blocks at this level (direct children of parentUid)
|
|
152
|
+
const existingAtLevel = existing.filter((e) => e.parentUid === parentUid || e.parentUid === null);
|
|
153
|
+
const newAtLevel = newBlocks.filter((n) => n.parentRef?.blockUid === parentUid || (!n.parentRef && parentUid === parentUid));
|
|
154
|
+
return diffBlockTrees(existingAtLevel, newAtLevel, parentUid);
|
|
155
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { diffBlockTrees } from './diff.js';
|
|
3
|
+
describe('diffBlockTrees', () => {
|
|
4
|
+
const pageUid = 'page123';
|
|
5
|
+
// Helper to create test blocks
|
|
6
|
+
function createExisting(uid, text, order, heading = null, children = []) {
|
|
7
|
+
return { uid, text, order, heading, children, parentUid: null };
|
|
8
|
+
}
|
|
9
|
+
function createNew(blockUid, text, order, heading = null) {
|
|
10
|
+
return {
|
|
11
|
+
ref: { blockUid },
|
|
12
|
+
text,
|
|
13
|
+
parentRef: { blockUid: pageUid },
|
|
14
|
+
order,
|
|
15
|
+
open: true,
|
|
16
|
+
heading,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
describe('No changes scenario', () => {
|
|
20
|
+
it('returns empty diff when content is identical', () => {
|
|
21
|
+
const existing = [
|
|
22
|
+
createExisting('uid1', 'First', 0),
|
|
23
|
+
createExisting('uid2', 'Second', 1),
|
|
24
|
+
];
|
|
25
|
+
const newBlocks = [createNew('new1', 'First', 0), createNew('new2', 'Second', 1)];
|
|
26
|
+
const diff = diffBlockTrees(existing, newBlocks, pageUid);
|
|
27
|
+
expect(diff.creates.length).toBe(0);
|
|
28
|
+
expect(diff.updates.length).toBe(0);
|
|
29
|
+
expect(diff.moves.length).toBe(0);
|
|
30
|
+
expect(diff.deletes.length).toBe(0);
|
|
31
|
+
expect(diff.preservedUids.size).toBe(2);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
describe('Create operations', () => {
|
|
35
|
+
it('generates create actions for new blocks', () => {
|
|
36
|
+
const existing = [];
|
|
37
|
+
const newBlocks = [createNew('new1', 'New block', 0)];
|
|
38
|
+
const diff = diffBlockTrees(existing, newBlocks, pageUid);
|
|
39
|
+
expect(diff.creates.length).toBe(1);
|
|
40
|
+
expect(diff.creates[0].action).toBe('create-block');
|
|
41
|
+
expect(diff.creates[0].block.string).toBe('New block');
|
|
42
|
+
});
|
|
43
|
+
it('generates creates for unmatched new blocks', () => {
|
|
44
|
+
const existing = [createExisting('uid1', 'Existing', 0)];
|
|
45
|
+
const newBlocks = [
|
|
46
|
+
createNew('new1', 'Existing', 0),
|
|
47
|
+
createNew('new2', 'Brand new', 1),
|
|
48
|
+
];
|
|
49
|
+
const diff = diffBlockTrees(existing, newBlocks, pageUid);
|
|
50
|
+
expect(diff.creates.length).toBe(1);
|
|
51
|
+
expect(diff.creates[0].block.string).toBe('Brand new');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
describe('Update operations', () => {
|
|
55
|
+
it('generates update when text changes', () => {
|
|
56
|
+
const existing = [createExisting('uid1', 'Old text', 0)];
|
|
57
|
+
// Position-based fallback will match with ≤3 blocks
|
|
58
|
+
const newBlocks = [createNew('new1', 'New text', 0)];
|
|
59
|
+
const diff = diffBlockTrees(existing, newBlocks, pageUid);
|
|
60
|
+
expect(diff.updates.length).toBe(1);
|
|
61
|
+
expect(diff.updates[0].action).toBe('update-block');
|
|
62
|
+
expect(diff.updates[0].block.uid).toBe('uid1');
|
|
63
|
+
expect(diff.updates[0].block.string).toBe('New text');
|
|
64
|
+
});
|
|
65
|
+
it('generates update when heading changes', () => {
|
|
66
|
+
const existing = [createExisting('uid1', 'Title', 0, null)];
|
|
67
|
+
const newBlocks = [createNew('new1', 'Title', 0, 2)];
|
|
68
|
+
const diff = diffBlockTrees(existing, newBlocks, pageUid);
|
|
69
|
+
expect(diff.updates.length).toBe(1);
|
|
70
|
+
expect(diff.updates[0].block.heading).toBe(2);
|
|
71
|
+
});
|
|
72
|
+
it('removes heading when changed to null', () => {
|
|
73
|
+
const existing = [createExisting('uid1', 'Title', 0, 2)];
|
|
74
|
+
const newBlocks = [createNew('new1', 'Title', 0, null)];
|
|
75
|
+
const diff = diffBlockTrees(existing, newBlocks, pageUid);
|
|
76
|
+
expect(diff.updates.length).toBe(1);
|
|
77
|
+
expect(diff.updates[0].block.heading).toBe(0); // 0 removes heading
|
|
78
|
+
});
|
|
79
|
+
it('does not generate update when content is same', () => {
|
|
80
|
+
const existing = [createExisting('uid1', 'Same', 0)];
|
|
81
|
+
const newBlocks = [createNew('new1', 'Same', 0)];
|
|
82
|
+
const diff = diffBlockTrees(existing, newBlocks, pageUid);
|
|
83
|
+
expect(diff.updates.length).toBe(0);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
describe('Move operations', () => {
|
|
87
|
+
it('generates move when order changes', () => {
|
|
88
|
+
const existing = [
|
|
89
|
+
createExisting('uid1', 'First', 0),
|
|
90
|
+
createExisting('uid2', 'Second', 1),
|
|
91
|
+
];
|
|
92
|
+
// Swap order
|
|
93
|
+
const newBlocks = [createNew('new1', 'Second', 0), createNew('new2', 'First', 1)];
|
|
94
|
+
const diff = diffBlockTrees(existing, newBlocks, pageUid);
|
|
95
|
+
// At least one move should be generated
|
|
96
|
+
expect(diff.moves.length).toBeGreaterThan(0);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
describe('Delete operations', () => {
|
|
100
|
+
it('generates delete for removed blocks', () => {
|
|
101
|
+
const existing = [
|
|
102
|
+
createExisting('uid1', 'Keep', 0),
|
|
103
|
+
createExisting('uid2', 'Remove', 1),
|
|
104
|
+
];
|
|
105
|
+
const newBlocks = [createNew('new1', 'Keep', 0)];
|
|
106
|
+
const diff = diffBlockTrees(existing, newBlocks, pageUid);
|
|
107
|
+
expect(diff.deletes.length).toBe(1);
|
|
108
|
+
expect(diff.deletes[0].action).toBe('delete-block');
|
|
109
|
+
expect(diff.deletes[0].block.uid).toBe('uid2');
|
|
110
|
+
});
|
|
111
|
+
it('generates deletes for all blocks when new content is empty', () => {
|
|
112
|
+
const existing = [
|
|
113
|
+
createExisting('uid1', 'First', 0),
|
|
114
|
+
createExisting('uid2', 'Second', 1),
|
|
115
|
+
];
|
|
116
|
+
const newBlocks = [];
|
|
117
|
+
const diff = diffBlockTrees(existing, newBlocks, pageUid);
|
|
118
|
+
expect(diff.deletes.length).toBe(2);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
describe('Preserved UIDs', () => {
|
|
122
|
+
it('tracks preserved UIDs for matched blocks', () => {
|
|
123
|
+
const existing = [
|
|
124
|
+
createExisting('uid1', 'Matched', 0),
|
|
125
|
+
createExisting('uid2', 'Also matched', 1),
|
|
126
|
+
];
|
|
127
|
+
const newBlocks = [
|
|
128
|
+
createNew('new1', 'Matched', 0),
|
|
129
|
+
createNew('new2', 'Also matched', 1),
|
|
130
|
+
];
|
|
131
|
+
const diff = diffBlockTrees(existing, newBlocks, pageUid);
|
|
132
|
+
expect(diff.preservedUids.has('uid1')).toBe(true);
|
|
133
|
+
expect(diff.preservedUids.has('uid2')).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
it('does not include unmatched blocks in preserved UIDs', () => {
|
|
136
|
+
const existing = [createExisting('uid1', 'Will be deleted', 0)];
|
|
137
|
+
const newBlocks = [createNew('new1', 'Completely different', 0)];
|
|
138
|
+
const diff = diffBlockTrees(existing, newBlocks, pageUid);
|
|
139
|
+
// Position fallback matches these (≤3), so uid1 is preserved
|
|
140
|
+
expect(diff.preservedUids.has('uid1')).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
describe('Mixed operations', () => {
|
|
144
|
+
it('handles text update with matching', () => {
|
|
145
|
+
const existing = [
|
|
146
|
+
createExisting('uid1', 'Keep this', 0),
|
|
147
|
+
createExisting('uid2', 'Update this', 1),
|
|
148
|
+
];
|
|
149
|
+
const newBlocks = [
|
|
150
|
+
createNew('new1', 'Keep this', 0),
|
|
151
|
+
createNew('new2', 'Update this - modified', 1),
|
|
152
|
+
];
|
|
153
|
+
const diff = diffBlockTrees(existing, newBlocks, pageUid);
|
|
154
|
+
// uid1 matches exactly, uid2 matched by position, gets updated
|
|
155
|
+
expect(diff.preservedUids.size).toBe(2);
|
|
156
|
+
expect(diff.updates.length).toBe(1);
|
|
157
|
+
expect(diff.updates[0].block.string).toBe('Update this - modified');
|
|
158
|
+
});
|
|
159
|
+
it('handles additions and deletions', () => {
|
|
160
|
+
// Use >3 blocks to avoid position fallback for unmatched
|
|
161
|
+
const existing = [
|
|
162
|
+
createExisting('uid1', 'Keep A', 0),
|
|
163
|
+
createExisting('uid2', 'Keep B', 1),
|
|
164
|
+
createExisting('uid3', 'Delete C', 2),
|
|
165
|
+
createExisting('uid4', 'Delete D', 3),
|
|
166
|
+
createExisting('uid5', 'Delete E', 4),
|
|
167
|
+
createExisting('uid6', 'Delete F', 5),
|
|
168
|
+
];
|
|
169
|
+
const newBlocks = [
|
|
170
|
+
createNew('new1', 'Keep A', 0),
|
|
171
|
+
createNew('new2', 'Keep B', 1),
|
|
172
|
+
createNew('new3', 'Add G', 2),
|
|
173
|
+
createNew('new4', 'Add H', 3),
|
|
174
|
+
createNew('new5', 'Add I', 4),
|
|
175
|
+
createNew('new6', 'Add J', 5),
|
|
176
|
+
];
|
|
177
|
+
const diff = diffBlockTrees(existing, newBlocks, pageUid);
|
|
178
|
+
// 2 matched (Keep A, Keep B), 4 new created, 4 old deleted
|
|
179
|
+
expect(diff.preservedUids.size).toBe(2);
|
|
180
|
+
expect(diff.creates.length).toBe(4);
|
|
181
|
+
expect(diff.deletes.length).toBe(4);
|
|
182
|
+
});
|
|
183
|
+
it('handles reordering with moves', () => {
|
|
184
|
+
const existing = [
|
|
185
|
+
createExisting('uid1', 'First', 0),
|
|
186
|
+
createExisting('uid2', 'Second', 1),
|
|
187
|
+
createExisting('uid3', 'Third', 2),
|
|
188
|
+
];
|
|
189
|
+
const newBlocks = [
|
|
190
|
+
createNew('new1', 'Third', 0), // uid3 -> 0
|
|
191
|
+
createNew('new2', 'First', 1), // uid1 -> 1
|
|
192
|
+
createNew('new3', 'Second', 2), // uid2 -> 2
|
|
193
|
+
];
|
|
194
|
+
const diff = diffBlockTrees(existing, newBlocks, pageUid);
|
|
195
|
+
expect(diff.preservedUids.size).toBe(3);
|
|
196
|
+
expect(diff.creates.length).toBe(0);
|
|
197
|
+
expect(diff.deletes.length).toBe(0);
|
|
198
|
+
// At least some moves expected for reordering
|
|
199
|
+
expect(diff.moves.length).toBeGreaterThan(0);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
});
|