roam-research-mcp 1.0.0 → 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.
@@ -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
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Smart Diff Algorithm
3
+ *
4
+ * This module provides a diff algorithm for computing minimal update operations
5
+ * when syncing markdown content to Roam Research. It preserves block UIDs where
6
+ * possible and generates efficient batch actions.
7
+ *
8
+ * Usage:
9
+ * ```typescript
10
+ * import {
11
+ * parseExistingBlocks,
12
+ * markdownToBlocks,
13
+ * diffBlockTrees,
14
+ * generateBatchActions,
15
+ * getDiffStats
16
+ * } from './diff/index.js';
17
+ *
18
+ * // 1. Parse existing page data
19
+ * const existing = parseExistingBlocks(pageData);
20
+ *
21
+ * // 2. Convert new markdown to block structure
22
+ * const newBlocks = markdownToBlocks(markdown, pageUid);
23
+ *
24
+ * // 3. Compute diff
25
+ * const diff = diffBlockTrees(existing, newBlocks, pageUid);
26
+ *
27
+ * // 4. Generate ordered batch actions
28
+ * const actions = generateBatchActions(diff);
29
+ *
30
+ * // 5. Check stats
31
+ * const stats = getDiffStats(diff);
32
+ * console.log(`Preserved ${stats.preserved} UIDs`);
33
+ * ```
34
+ */
35
+ export { getDiffStats, isDiffEmpty } from './types.js';
36
+ // Parser
37
+ export { parseExistingBlock, parseExistingBlocks, flattenExistingBlocks, markdownToBlocks, getBlockDepth, } from './parser.js';
38
+ // Matcher
39
+ export { normalizeText, normalizeForMatching, matchBlocks, groupByParent } from './matcher.js';
40
+ // Diff
41
+ export { diffBlockTrees, diffBlockLevel } from './diff.js';
42
+ // Actions
43
+ export { generateBatchActions, filterActions, groupActionsByType, summarizeActions, } from './actions.js';
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Block Matcher
3
+ *
4
+ * Matches new blocks to existing blocks using a three-phase strategy:
5
+ * 1. Exact text match
6
+ * 2. Normalized text match (removes list prefixes)
7
+ * 3. Position-based fallback (conservative, only for small sets)
8
+ */
9
+ /**
10
+ * Normalize text for exact matching.
11
+ * Trims whitespace only.
12
+ */
13
+ export function normalizeText(text) {
14
+ return text.trim();
15
+ }
16
+ /**
17
+ * Normalize text for fuzzy matching.
18
+ * Removes list prefixes (1. , 2. , etc.) in addition to trimming.
19
+ */
20
+ export function normalizeForMatching(text) {
21
+ return text.trim().replace(/^\d+\.\s+/, '');
22
+ }
23
+ /**
24
+ * Match new blocks to existing blocks using a three-phase strategy.
25
+ *
26
+ * Phase 1: Exact text match
27
+ * - Compare normalized text (trimmed whitespace)
28
+ * - When multiple candidates exist, prefer the one closest to same position
29
+ *
30
+ * Phase 2: Normalized text match
31
+ * - Remove list prefixes before matching
32
+ * - Handles cases where markdown adds numbering that Roam doesn't have
33
+ *
34
+ * Phase 3: Position-based fallback
35
+ * - Only used when ≤3 unmatched blocks remain on each side
36
+ * - Conservative to avoid incorrect matches
37
+ *
38
+ * @param existing - Flat array of existing blocks
39
+ * @param newBlocks - Array of new blocks to match
40
+ * @returns Map of newUid -> existingUid for matched blocks
41
+ */
42
+ export function matchBlocks(existing, newBlocks) {
43
+ const matches = new Map(); // newUid -> existingUid
44
+ const usedExisting = new Set();
45
+ // Build indices for efficient lookups
46
+ const existingByText = new Map();
47
+ const existingByNormalized = new Map();
48
+ for (const eb of existing) {
49
+ // Index by exact normalized text
50
+ const normText = normalizeText(eb.text);
51
+ if (!existingByText.has(normText)) {
52
+ existingByText.set(normText, []);
53
+ }
54
+ existingByText.get(normText).push(eb);
55
+ // Index by matching-normalized text (without list prefixes)
56
+ const matchText = normalizeForMatching(eb.text);
57
+ if (!existingByNormalized.has(matchText)) {
58
+ existingByNormalized.set(matchText, []);
59
+ }
60
+ existingByNormalized.get(matchText).push(eb);
61
+ }
62
+ // Phase 1: Exact text matches
63
+ newBlocks.forEach((newBlock, idx) => {
64
+ const normText = normalizeText(newBlock.text);
65
+ const candidates = (existingByText.get(normText) ?? []).filter((e) => !usedExisting.has(e.uid));
66
+ if (candidates.length > 0) {
67
+ // Prefer candidate closest to same position
68
+ const best = candidates.reduce((a, b) => Math.abs(a.order - idx) < Math.abs(b.order - idx) ? a : b);
69
+ matches.set(newBlock.ref.blockUid, best.uid);
70
+ usedExisting.add(best.uid);
71
+ }
72
+ });
73
+ // Phase 2: Normalized text matches (without list prefixes)
74
+ newBlocks.forEach((newBlock, idx) => {
75
+ if (matches.has(newBlock.ref.blockUid))
76
+ return;
77
+ const matchText = normalizeForMatching(newBlock.text);
78
+ const candidates = (existingByNormalized.get(matchText) ?? []).filter((e) => !usedExisting.has(e.uid));
79
+ if (candidates.length > 0) {
80
+ const best = candidates.reduce((a, b) => Math.abs(a.order - idx) < Math.abs(b.order - idx) ? a : b);
81
+ matches.set(newBlock.ref.blockUid, best.uid);
82
+ usedExisting.add(best.uid);
83
+ }
84
+ });
85
+ // Phase 3: Position-based fallback (conservative)
86
+ const unmatchedNew = newBlocks.filter((b) => !matches.has(b.ref.blockUid));
87
+ const unmatchedExisting = existing.filter((e) => !usedExisting.has(e.uid));
88
+ // Only use position matching when few blocks remain (avoid false matches)
89
+ if (unmatchedNew.length <= 3 && unmatchedExisting.length <= 3) {
90
+ const sortedExisting = [...unmatchedExisting].sort((a, b) => a.order - b.order);
91
+ unmatchedNew.forEach((newBlock, idx) => {
92
+ if (idx < sortedExisting.length) {
93
+ const existBlock = sortedExisting[idx];
94
+ matches.set(newBlock.ref.blockUid, existBlock.uid);
95
+ usedExisting.add(existBlock.uid);
96
+ }
97
+ });
98
+ }
99
+ return matches;
100
+ }
101
+ /**
102
+ * Group blocks by their parent UID for sibling analysis.
103
+ */
104
+ export function groupByParent(blocks) {
105
+ const groups = new Map();
106
+ for (const block of blocks) {
107
+ const parentKey = block.parentUid;
108
+ if (!groups.has(parentKey)) {
109
+ groups.set(parentKey, []);
110
+ }
111
+ groups.get(parentKey).push(block);
112
+ }
113
+ // Sort each group by order
114
+ for (const [, siblings] of groups) {
115
+ siblings.sort((a, b) => a.order - b.order);
116
+ }
117
+ return groups;
118
+ }
@@ -0,0 +1,198 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { normalizeText, normalizeForMatching, matchBlocks, groupByParent } from './matcher.js';
3
+ describe('normalizeText', () => {
4
+ it('trims whitespace', () => {
5
+ expect(normalizeText(' hello ')).toBe('hello');
6
+ expect(normalizeText('\thello\n')).toBe('hello');
7
+ });
8
+ it('preserves internal whitespace', () => {
9
+ expect(normalizeText('hello world')).toBe('hello world');
10
+ });
11
+ it('handles empty strings', () => {
12
+ expect(normalizeText('')).toBe('');
13
+ expect(normalizeText(' ')).toBe('');
14
+ });
15
+ });
16
+ describe('normalizeForMatching', () => {
17
+ it('removes numbered list prefixes', () => {
18
+ expect(normalizeForMatching('1. First item')).toBe('First item');
19
+ expect(normalizeForMatching('2. Second item')).toBe('Second item');
20
+ expect(normalizeForMatching('10. Tenth item')).toBe('Tenth item');
21
+ });
22
+ it('preserves non-list text', () => {
23
+ expect(normalizeForMatching('Regular text')).toBe('Regular text');
24
+ expect(normalizeForMatching('- Bullet point')).toBe('- Bullet point');
25
+ });
26
+ it('trims whitespace', () => {
27
+ expect(normalizeForMatching(' 1. Item ')).toBe('Item');
28
+ });
29
+ });
30
+ describe('matchBlocks', () => {
31
+ // Helper to create test blocks
32
+ function createExisting(uid, text, order) {
33
+ return { uid, text, order, heading: null, children: [], parentUid: null };
34
+ }
35
+ function createNew(blockUid, text, order) {
36
+ return {
37
+ ref: { blockUid },
38
+ text,
39
+ parentRef: null,
40
+ order,
41
+ open: true,
42
+ heading: null,
43
+ };
44
+ }
45
+ describe('Phase 1: Exact text match', () => {
46
+ it('matches blocks with identical text', () => {
47
+ const existing = [createExisting('uid1', 'Hello world', 0)];
48
+ const newBlocks = [createNew('new1', 'Hello world', 0)];
49
+ const matches = matchBlocks(existing, newBlocks);
50
+ expect(matches.get('new1')).toBe('uid1');
51
+ });
52
+ it('matches multiple blocks with same text by position', () => {
53
+ const existing = [
54
+ createExisting('uid1', 'Item', 0),
55
+ createExisting('uid2', 'Item', 1),
56
+ createExisting('uid3', 'Item', 2),
57
+ ];
58
+ const newBlocks = [
59
+ createNew('new1', 'Item', 0),
60
+ createNew('new2', 'Item', 1),
61
+ ];
62
+ const matches = matchBlocks(existing, newBlocks);
63
+ // Should prefer position-closest matches
64
+ expect(matches.get('new1')).toBe('uid1');
65
+ expect(matches.get('new2')).toBe('uid2');
66
+ });
67
+ it('handles no matches when too many unmatched for position fallback', () => {
68
+ // Need >3 on each side to avoid position-based fallback
69
+ const existing = [
70
+ createExisting('uid1', 'A', 0),
71
+ createExisting('uid2', 'B', 1),
72
+ createExisting('uid3', 'C', 2),
73
+ createExisting('uid4', 'D', 3),
74
+ ];
75
+ const newBlocks = [
76
+ createNew('new1', 'W', 0),
77
+ createNew('new2', 'X', 1),
78
+ createNew('new3', 'Y', 2),
79
+ createNew('new4', 'Z', 3),
80
+ ];
81
+ const matches = matchBlocks(existing, newBlocks);
82
+ expect(matches.size).toBe(0);
83
+ });
84
+ it('uses position fallback when ≤3 unmatched on each side', () => {
85
+ const existing = [createExisting('uid1', 'Hello', 0)];
86
+ const newBlocks = [createNew('new1', 'Goodbye', 0)];
87
+ const matches = matchBlocks(existing, newBlocks);
88
+ // Position fallback matches by position
89
+ expect(matches.size).toBe(1);
90
+ expect(matches.get('new1')).toBe('uid1');
91
+ });
92
+ });
93
+ describe('Phase 2: Normalized text match', () => {
94
+ it('matches blocks when list prefix differs', () => {
95
+ const existing = [createExisting('uid1', 'First item', 0)];
96
+ const newBlocks = [createNew('new1', '1. First item', 0)];
97
+ const matches = matchBlocks(existing, newBlocks);
98
+ expect(matches.get('new1')).toBe('uid1');
99
+ });
100
+ it('matches numbered list items to plain text', () => {
101
+ const existing = [
102
+ createExisting('uid1', 'Apple', 0),
103
+ createExisting('uid2', 'Banana', 1),
104
+ ];
105
+ const newBlocks = [
106
+ createNew('new1', '1. Apple', 0),
107
+ createNew('new2', '2. Banana', 1),
108
+ ];
109
+ const matches = matchBlocks(existing, newBlocks);
110
+ expect(matches.get('new1')).toBe('uid1');
111
+ expect(matches.get('new2')).toBe('uid2');
112
+ });
113
+ });
114
+ describe('Phase 3: Position-based fallback', () => {
115
+ it('matches by position when few unmatched blocks remain', () => {
116
+ const existing = [
117
+ createExisting('uid1', 'Completely different', 0),
118
+ createExisting('uid2', 'Also different', 1),
119
+ ];
120
+ const newBlocks = [
121
+ createNew('new1', 'Brand new text', 0),
122
+ createNew('new2', 'Another new text', 1),
123
+ ];
124
+ const matches = matchBlocks(existing, newBlocks);
125
+ // With ≤3 unmatched on each side, should use position fallback
126
+ expect(matches.get('new1')).toBe('uid1');
127
+ expect(matches.get('new2')).toBe('uid2');
128
+ });
129
+ it('does not use position fallback when too many unmatched', () => {
130
+ const existing = [
131
+ createExisting('uid1', 'A', 0),
132
+ createExisting('uid2', 'B', 1),
133
+ createExisting('uid3', 'C', 2),
134
+ createExisting('uid4', 'D', 3),
135
+ ];
136
+ const newBlocks = [
137
+ createNew('new1', 'W', 0),
138
+ createNew('new2', 'X', 1),
139
+ createNew('new3', 'Y', 2),
140
+ createNew('new4', 'Z', 3),
141
+ ];
142
+ const matches = matchBlocks(existing, newBlocks);
143
+ // With >3 unmatched, should not use position fallback
144
+ expect(matches.size).toBe(0);
145
+ });
146
+ });
147
+ describe('Mixed matching scenarios', () => {
148
+ it('prioritizes exact match over normalized match', () => {
149
+ const existing = [
150
+ createExisting('uid1', '1. Item', 0),
151
+ createExisting('uid2', 'Item', 1),
152
+ ];
153
+ const newBlocks = [createNew('new1', 'Item', 0)];
154
+ const matches = matchBlocks(existing, newBlocks);
155
+ // Should match exact text 'Item' not normalized '1. Item'
156
+ expect(matches.get('new1')).toBe('uid2');
157
+ });
158
+ it('uses each existing block only once', () => {
159
+ const existing = [createExisting('uid1', 'Same text', 0)];
160
+ const newBlocks = [
161
+ createNew('new1', 'Same text', 0),
162
+ createNew('new2', 'Same text', 1),
163
+ ];
164
+ const matches = matchBlocks(existing, newBlocks);
165
+ // Only one match should occur
166
+ expect(matches.size).toBe(1);
167
+ expect(matches.get('new1')).toBe('uid1');
168
+ expect(matches.has('new2')).toBe(false);
169
+ });
170
+ });
171
+ });
172
+ describe('groupByParent', () => {
173
+ function createExisting(uid, order, parentUid) {
174
+ return { uid, text: '', order, heading: null, children: [], parentUid };
175
+ }
176
+ it('groups blocks by parent UID', () => {
177
+ const blocks = [
178
+ createExisting('a', 0, null),
179
+ createExisting('b', 1, null),
180
+ createExisting('c', 0, 'parent1'),
181
+ createExisting('d', 1, 'parent1'),
182
+ createExisting('e', 0, 'parent2'),
183
+ ];
184
+ const groups = groupByParent(blocks);
185
+ expect(groups.get(null)?.map((b) => b.uid)).toEqual(['a', 'b']);
186
+ expect(groups.get('parent1')?.map((b) => b.uid)).toEqual(['c', 'd']);
187
+ expect(groups.get('parent2')?.map((b) => b.uid)).toEqual(['e']);
188
+ });
189
+ it('sorts blocks within each group by order', () => {
190
+ const blocks = [
191
+ createExisting('a', 2, null),
192
+ createExisting('b', 0, null),
193
+ createExisting('c', 1, null),
194
+ ];
195
+ const groups = groupByParent(blocks);
196
+ expect(groups.get(null)?.map((b) => b.uid)).toEqual(['b', 'c', 'a']);
197
+ });
198
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Block Parser
3
+ *
4
+ * Parses Roam API block data into ExistingBlock structures
5
+ * and provides utilities for flattening block trees.
6
+ */
7
+ import { generateBlockUid, parseMarkdown } from '../markdown-utils.js';
8
+ /**
9
+ * Parse a raw Roam API block into an ExistingBlock structure.
10
+ * Recursively processes children and sorts them by order.
11
+ *
12
+ * @param roamBlock - Raw block data from Roam API
13
+ * @param parentUid - UID of the parent block (null for page-level blocks)
14
+ * @returns Parsed ExistingBlock with normalized structure
15
+ */
16
+ export function parseExistingBlock(roamBlock, parentUid = null) {
17
+ const childrenRaw = roamBlock[':block/children'] ?? [];
18
+ const childrenSorted = [...childrenRaw].sort((a, b) => (a[':block/order'] ?? 0) - (b[':block/order'] ?? 0));
19
+ const uid = roamBlock[':block/uid'] ?? '';
20
+ const children = childrenSorted.map((c) => parseExistingBlock(c, uid));
21
+ return {
22
+ uid,
23
+ text: roamBlock[':block/string'] ?? '',
24
+ order: roamBlock[':block/order'] ?? 0,
25
+ heading: roamBlock[':block/heading'] ?? null,
26
+ children,
27
+ parentUid,
28
+ };
29
+ }
30
+ /**
31
+ * Parse all top-level blocks from a Roam page into ExistingBlock structures.
32
+ *
33
+ * @param pageData - Raw page data from Roam API (with :block/children)
34
+ * @returns Array of ExistingBlock for all top-level blocks
35
+ */
36
+ export function parseExistingBlocks(pageData) {
37
+ const childrenRaw = pageData[':block/children'] ?? [];
38
+ const childrenSorted = [...childrenRaw].sort((a, b) => (a[':block/order'] ?? 0) - (b[':block/order'] ?? 0));
39
+ return childrenSorted.map((c) => parseExistingBlock(c, null));
40
+ }
41
+ /**
42
+ * Flatten a tree of existing blocks into a single array.
43
+ * Preserves parent-child relationships through parentUid property.
44
+ *
45
+ * @param blocks - Array of ExistingBlock trees
46
+ * @returns Flat array of all blocks in depth-first order
47
+ */
48
+ export function flattenExistingBlocks(blocks) {
49
+ const result = [];
50
+ function flatten(block) {
51
+ result.push(block);
52
+ for (const child of block.children) {
53
+ flatten(child);
54
+ }
55
+ }
56
+ for (const block of blocks) {
57
+ flatten(block);
58
+ }
59
+ return result;
60
+ }
61
+ /**
62
+ * Convert markdown content into NewBlock structures ready for diffing.
63
+ *
64
+ * @param markdown - GFM markdown content
65
+ * @param pageUid - UID of the page (used as root parent)
66
+ * @returns Array of NewBlock structures representing desired state
67
+ */
68
+ export function markdownToBlocks(markdown, pageUid) {
69
+ // Parse markdown into nested structure
70
+ const nodes = parseMarkdown(markdown);
71
+ const blocks = [];
72
+ // Track parent refs by level for nesting
73
+ const parentRefByLevel = [null];
74
+ /**
75
+ * Recursively convert markdown nodes to NewBlock structures.
76
+ */
77
+ function processNode(node, parentRef, siblingIndex) {
78
+ const blockUid = generateBlockUid();
79
+ const ref = { blockUid };
80
+ const newBlock = {
81
+ ref,
82
+ text: node.content,
83
+ parentRef,
84
+ order: siblingIndex,
85
+ open: true,
86
+ heading: node.heading_level ?? null,
87
+ };
88
+ blocks.push(newBlock);
89
+ // Process children with this block as parent
90
+ node.children.forEach((child, idx) => {
91
+ processNode(child, ref, idx);
92
+ });
93
+ }
94
+ // Process all root nodes
95
+ nodes.forEach((node, idx) => {
96
+ processNode(node, { blockUid: pageUid }, idx);
97
+ });
98
+ return blocks;
99
+ }
100
+ /**
101
+ * Get the depth of a block in the tree (0 for root blocks).
102
+ */
103
+ export function getBlockDepth(block, blocks) {
104
+ let depth = 0;
105
+ let current = block;
106
+ while (current.parentRef) {
107
+ const parent = blocks.find((b) => b.ref.blockUid === current.parentRef?.blockUid);
108
+ if (!parent || parent === current)
109
+ break;
110
+ depth++;
111
+ current = parent;
112
+ }
113
+ return depth;
114
+ }