roam-research-mcp 1.3.2 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +187 -25
- package/build/Roam_Markdown_Cheatsheet.md +11 -2
- package/build/cli/commands/get.js +79 -0
- package/build/cli/commands/refs.js +122 -0
- package/build/cli/commands/save.js +121 -0
- package/build/cli/commands/search.js +79 -0
- package/build/cli/roam.js +18 -0
- package/build/cli/utils/output.js +88 -0
- package/build/diff/actions.js +93 -0
- package/build/diff/actions.test.js +125 -0
- package/build/diff/diff.js +155 -0
- package/build/diff/diff.test.js +202 -0
- package/build/diff/index.js +43 -0
- package/build/diff/matcher.js +118 -0
- package/build/diff/matcher.test.js +198 -0
- package/build/diff/parser.js +114 -0
- package/build/diff/parser.test.js +281 -0
- package/build/diff/types.js +27 -0
- package/build/diff/types.test.js +57 -0
- package/build/search/block-ref-search.js +34 -7
- package/build/server/roam-server.js +7 -0
- package/build/tools/operations/pages.js +95 -0
- package/build/tools/schemas.js +29 -2
- package/build/tools/tool-handlers.js +4 -0
- package/package.json +9 -5
- package/build/cli/import-markdown.js +0 -98
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { initializeGraph } from '@roam-research/roam-api-sdk';
|
|
3
|
+
import { API_TOKEN, GRAPH_NAME } from '../../config/environment.js';
|
|
4
|
+
import { SearchOperations } from '../../tools/operations/search/index.js';
|
|
5
|
+
import { formatSearchResults, printDebug, exitWithError } from '../utils/output.js';
|
|
6
|
+
export function createSearchCommand() {
|
|
7
|
+
return new Command('search')
|
|
8
|
+
.description('Search for content in Roam')
|
|
9
|
+
.argument('[terms...]', 'Search terms (multiple terms use AND logic)')
|
|
10
|
+
.option('--tag <tag>', 'Filter by tag (e.g., "#TODO" or "[[Project]]")')
|
|
11
|
+
.option('--page <title>', 'Scope search to a specific page')
|
|
12
|
+
.option('-i, --case-insensitive', 'Case-insensitive search')
|
|
13
|
+
.option('-n, --limit <n>', 'Limit number of results (default: 20)', '20')
|
|
14
|
+
.option('--json', 'Output as JSON')
|
|
15
|
+
.option('--debug', 'Show query metadata')
|
|
16
|
+
.action(async (terms, options) => {
|
|
17
|
+
try {
|
|
18
|
+
const graph = initializeGraph({
|
|
19
|
+
token: API_TOKEN,
|
|
20
|
+
graph: GRAPH_NAME
|
|
21
|
+
});
|
|
22
|
+
const limit = parseInt(options.limit || '20', 10);
|
|
23
|
+
const outputOptions = {
|
|
24
|
+
json: options.json,
|
|
25
|
+
debug: options.debug
|
|
26
|
+
};
|
|
27
|
+
if (options.debug) {
|
|
28
|
+
printDebug('Search terms', terms);
|
|
29
|
+
printDebug('Options', options);
|
|
30
|
+
}
|
|
31
|
+
const searchOps = new SearchOperations(graph);
|
|
32
|
+
// Determine search type based on options
|
|
33
|
+
if (options.tag && terms.length === 0) {
|
|
34
|
+
// Tag-only search
|
|
35
|
+
const tagName = options.tag.replace(/^#?\[?\[?/, '').replace(/\]?\]?$/, '');
|
|
36
|
+
if (options.debug) {
|
|
37
|
+
printDebug('Tag search', { tag: tagName, page: options.page });
|
|
38
|
+
}
|
|
39
|
+
const result = await searchOps.searchForTag(tagName, options.page);
|
|
40
|
+
const limitedMatches = result.matches.slice(0, limit);
|
|
41
|
+
console.log(formatSearchResults(limitedMatches, outputOptions));
|
|
42
|
+
}
|
|
43
|
+
else if (terms.length > 0) {
|
|
44
|
+
// Text search (with optional tag filter)
|
|
45
|
+
const searchText = terms.join(' ');
|
|
46
|
+
if (options.debug) {
|
|
47
|
+
printDebug('Text search', { text: searchText, page: options.page, tag: options.tag });
|
|
48
|
+
}
|
|
49
|
+
const result = await searchOps.searchByText({
|
|
50
|
+
text: searchText,
|
|
51
|
+
page_title_uid: options.page
|
|
52
|
+
});
|
|
53
|
+
// Apply client-side filters
|
|
54
|
+
let matches = result.matches;
|
|
55
|
+
// Case-insensitive filter if requested
|
|
56
|
+
if (options.caseInsensitive) {
|
|
57
|
+
const lowerSearchText = searchText.toLowerCase();
|
|
58
|
+
matches = matches.filter(m => m.content.toLowerCase().includes(lowerSearchText));
|
|
59
|
+
}
|
|
60
|
+
// Tag filter if provided
|
|
61
|
+
if (options.tag) {
|
|
62
|
+
const tagPattern = options.tag.replace(/^#?\[?\[?/, '').replace(/\]?\]?$/, '');
|
|
63
|
+
matches = matches.filter(m => m.content.includes(`[[${tagPattern}]]`) ||
|
|
64
|
+
m.content.includes(`#${tagPattern}`) ||
|
|
65
|
+
m.content.includes(`#[[${tagPattern}]]`));
|
|
66
|
+
}
|
|
67
|
+
// Apply limit
|
|
68
|
+
console.log(formatSearchResults(matches.slice(0, limit), outputOptions));
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
exitWithError('Please provide search terms or use --tag to search by tag');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
76
|
+
exitWithError(message);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { createGetCommand } from './commands/get.js';
|
|
4
|
+
import { createSearchCommand } from './commands/search.js';
|
|
5
|
+
import { createSaveCommand } from './commands/save.js';
|
|
6
|
+
import { createRefsCommand } from './commands/refs.js';
|
|
7
|
+
const program = new Command();
|
|
8
|
+
program
|
|
9
|
+
.name('roam')
|
|
10
|
+
.description('CLI for Roam Research')
|
|
11
|
+
.version('1.6.0');
|
|
12
|
+
// Register subcommands
|
|
13
|
+
program.addCommand(createGetCommand());
|
|
14
|
+
program.addCommand(createSearchCommand());
|
|
15
|
+
program.addCommand(createSaveCommand());
|
|
16
|
+
program.addCommand(createRefsCommand());
|
|
17
|
+
// Parse arguments
|
|
18
|
+
program.parse();
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert RoamBlock hierarchy to markdown with proper indentation
|
|
3
|
+
*/
|
|
4
|
+
export function blocksToMarkdown(blocks, level = 0) {
|
|
5
|
+
return blocks
|
|
6
|
+
.map(block => {
|
|
7
|
+
const indent = ' '.repeat(level);
|
|
8
|
+
let md;
|
|
9
|
+
// Check block heading level and format accordingly
|
|
10
|
+
if (block.heading && block.heading > 0) {
|
|
11
|
+
const hashtags = '#'.repeat(block.heading);
|
|
12
|
+
md = `${indent}${hashtags} ${block.string}`;
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
md = `${indent}- ${block.string}`;
|
|
16
|
+
}
|
|
17
|
+
if (block.children && block.children.length > 0) {
|
|
18
|
+
md += '\n' + blocksToMarkdown(block.children, level + 1);
|
|
19
|
+
}
|
|
20
|
+
return md;
|
|
21
|
+
})
|
|
22
|
+
.join('\n');
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Flatten block hierarchy to single-level list
|
|
26
|
+
*/
|
|
27
|
+
export function flattenBlocks(blocks, result = []) {
|
|
28
|
+
for (const block of blocks) {
|
|
29
|
+
result.push({ ...block, children: [] });
|
|
30
|
+
if (block.children && block.children.length > 0) {
|
|
31
|
+
flattenBlocks(block.children, result);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Format page content for output
|
|
38
|
+
*/
|
|
39
|
+
export function formatPageOutput(title, blocks, options) {
|
|
40
|
+
if (options.json) {
|
|
41
|
+
const data = options.flat ? flattenBlocks(blocks) : blocks;
|
|
42
|
+
return JSON.stringify({ title, children: data }, null, 2);
|
|
43
|
+
}
|
|
44
|
+
const displayBlocks = options.flat ? flattenBlocks(blocks) : blocks;
|
|
45
|
+
return `# ${title}\n\n${blocksToMarkdown(displayBlocks)}`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Format block content for output
|
|
49
|
+
*/
|
|
50
|
+
export function formatBlockOutput(block, options) {
|
|
51
|
+
if (options.json) {
|
|
52
|
+
const data = options.flat ? flattenBlocks([block]) : block;
|
|
53
|
+
return JSON.stringify(data, null, 2);
|
|
54
|
+
}
|
|
55
|
+
const displayBlocks = options.flat ? flattenBlocks([block]) : [block];
|
|
56
|
+
return blocksToMarkdown(displayBlocks);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Format search results for output
|
|
60
|
+
*/
|
|
61
|
+
export function formatSearchResults(results, options) {
|
|
62
|
+
if (options.json) {
|
|
63
|
+
return JSON.stringify(results, null, 2);
|
|
64
|
+
}
|
|
65
|
+
if (results.length === 0) {
|
|
66
|
+
return 'No results found.';
|
|
67
|
+
}
|
|
68
|
+
let output = `Found ${results.length} result(s):\n\n`;
|
|
69
|
+
results.forEach((result, index) => {
|
|
70
|
+
const pageInfo = result.page_title ? ` (${result.page_title})` : '';
|
|
71
|
+
output += `[${index + 1}] ${result.block_uid}${pageInfo}\n`;
|
|
72
|
+
output += ` ${result.content}\n\n`;
|
|
73
|
+
});
|
|
74
|
+
return output.trim();
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Print debug information
|
|
78
|
+
*/
|
|
79
|
+
export function printDebug(label, data) {
|
|
80
|
+
console.error(`[DEBUG] ${label}:`, JSON.stringify(data, null, 2));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Print error message and exit
|
|
84
|
+
*/
|
|
85
|
+
export function exitWithError(message, code = 1) {
|
|
86
|
+
console.error(`Error: ${message}`);
|
|
87
|
+
process.exit(code);
|
|
88
|
+
}
|
|
@@ -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
|
+
}
|