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 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` | `**Term**:: Definition` | Only use for actual definitions you want to query |
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
- **Term**:: Definition text #definition #[[domain]]
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
+ });