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.
- package/README.md +104 -17
- package/build/Roam_Markdown_Cheatsheet.md +528 -175
- package/build/cache/page-uid-cache.js +55 -0
- package/build/cli/import-markdown.js +98 -0
- package/build/config/environment.js +4 -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/markdown-utils.js +51 -5
- package/build/server/roam-server.js +76 -10
- package/build/shared/errors.js +84 -0
- package/build/shared/index.js +5 -0
- package/build/shared/validation.js +268 -0
- package/build/tools/operations/batch.js +165 -3
- package/build/tools/operations/memory.js +29 -19
- package/build/tools/operations/outline.js +110 -70
- package/build/tools/operations/pages.js +254 -60
- package/build/tools/operations/table.js +142 -0
- package/build/tools/schemas.js +110 -9
- package/build/tools/tool-handlers.js +12 -2
- package/package.json +9 -5
|
@@ -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
|
+
}
|