feat(core): markdown-diff & patch apply

This commit is contained in:
yoyoyohamapi
2025-06-18 11:12:39 +08:00
parent a1abb60dec
commit e8d774a2ad
17 changed files with 536 additions and 35 deletions
+1
View File
@@ -19,6 +19,7 @@
"@affine/templates": "workspace:*",
"@affine/track": "workspace:*",
"@blocksuite/affine": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/icons": "^2.2.13",
"@blocksuite/std": "workspace:*",
"@dotlottie/player-component": "^2.7.12",
@@ -0,0 +1,99 @@
/**
* @vitest-environment happy-dom
*/
import { affine } from '@blocksuite/affine-shared/test-utils';
import { describe, expect, it } from 'vitest';
import { applyPatchToDoc } from '../../../../blocksuite/ai/utils/apply-model/apply-patch-to-doc';
import type { PatchOp } from '../../../../blocksuite/ai/utils/apply-model/markdown-diff';
describe('applyPatchToDoc', () => {
it('should delete a block', async () => {
const host = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-1">Hello</affine-paragraph>
<affine-paragraph id="paragraph-2">World</affine-paragraph>
</affine-note>
</affine-page>
`;
const patch: PatchOp[] = [{ op: 'delete', block_id: 'paragraph-1' }];
await applyPatchToDoc(host.store, patch);
const expected = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-2">World</affine-paragraph>
</affine-note>
</affine-page>
`;
expect(host.store).toEqualDoc(expected.store);
});
it('should replace a block', async () => {
const host = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-1">Hello</affine-paragraph>
<affine-paragraph id="paragraph-2">World</affine-paragraph>
</affine-note>
</affine-page>
`;
const patch: PatchOp[] = [
{
op: 'replace',
block_id: 'paragraph-1',
new_content: 'New content',
},
];
await applyPatchToDoc(host.store, patch);
const expected = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-1">New content</affine-paragraph>
<affine-paragraph id="paragraph-2">World</affine-paragraph>
</affine-note>
</affine-page>
`;
expect(host.store).toEqualDoc(expected.store);
});
it('should insert a block at index', async () => {
const host = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-1">Hello</affine-paragraph>
<affine-paragraph id="paragraph-2">World</affine-paragraph>
</affine-note>
</affine-page>
`;
const patch: PatchOp[] = [
{
op: 'insert_at',
index: 2,
new_block: { type: 'affine:paragraph', content: 'Inserted' },
},
];
await applyPatchToDoc(host.store, patch);
const expected = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-1">Hello</affine-paragraph>
<affine-paragraph id="paragraph-2">World</affine-paragraph>
<affine-paragraph id="paragraph-3">Inserted</affine-paragraph>
</affine-note>
</affine-page>
`;
expect(host.store).toEqualDoc(expected.store);
});
});
@@ -0,0 +1,223 @@
import { describe, expect, test } from 'vitest';
import { diffMarkdown } from '../../../../blocksuite/ai/utils/apply-model/markdown-diff';
describe('diffMarkdown', () => {
test('should diff block insertion', () => {
// Only a new block is inserted
const oldMd = `
<!-- block_id=block-001 type=title -->
# Title
`;
const newMd = `
<!-- block_id=block-001 type=title -->
# Title
<!-- block_id=block-002 type=paragraph -->
This is a new paragraph.
`;
const patch = diffMarkdown(oldMd, newMd);
expect(patch).toEqual([
{
op: 'insert_at',
index: 1,
new_block: {
type: 'paragraph',
content: 'This is a new paragraph.',
},
},
]);
});
test('should diff block deletion', () => {
// A block is deleted
const oldMd = `
<!-- block_id=block-001 type=title -->
# Title
<!-- block_id=block-002 type=paragraph -->
This paragraph will be deleted.
`;
const newMd = `
<!-- block_id=block-001 type=title -->
# Title
`;
const patch = diffMarkdown(oldMd, newMd);
expect(patch).toEqual([
{
op: 'delete',
block_id: 'block-002',
},
]);
});
test('should diff block replacement', () => {
// Only content of a block is changed
const oldMd = `
<!-- block_id=block-001 type=title -->
# Old Title
`;
const newMd = `
<!-- block_id=block-001 type=title -->
# New Title
`;
const patch = diffMarkdown(oldMd, newMd);
expect(patch).toEqual([
{
op: 'replace',
block_id: 'block-001',
new_content: '# New Title',
},
]);
});
test('should diff mixed changes', () => {
// Mixed: delete, insert, replace
const oldMd = `
<!-- block_id=block-001 type=title -->
# Title
<!-- block_id=block-002 type=paragraph -->
Old paragraph.
<!-- block_id=block-003 type=paragraph -->
To be deleted.
`;
const newMd = `
<!-- block_id=block-001 type=title -->
# Title
<!-- block_id=block-002 type=paragraph -->
Updated paragraph.
<!-- block_id=block-004 type=paragraph -->
New paragraph.
`;
const patch = diffMarkdown(oldMd, newMd);
expect(patch).toEqual([
{
op: 'replace',
block_id: 'block-002',
new_content: 'Updated paragraph.',
},
{
op: 'insert_at',
index: 2,
new_block: {
type: 'paragraph',
content: 'New paragraph.',
},
},
{
op: 'delete',
block_id: 'block-003',
},
]);
});
test('should diff consecutive block insertions', () => {
// Two new blocks are inserted consecutively
const oldMd = `
<!-- block_id=block-001 type=title -->
# Title
`;
const newMd = `
<!-- block_id=block-001 type=title -->
# Title
<!-- block_id=block-002 type=paragraph -->
First inserted paragraph.
<!-- block_id=block-003 type=paragraph -->
Second inserted paragraph.
`;
const patch = diffMarkdown(oldMd, newMd);
expect(patch).toEqual([
{
op: 'insert_at',
index: 1,
new_block: {
type: 'paragraph',
content: 'First inserted paragraph.',
},
},
{
op: 'insert_at',
index: 2,
new_block: {
type: 'paragraph',
content: 'Second inserted paragraph.',
},
},
]);
});
test('should diff consecutive block deletions', () => {
// Two blocks are deleted consecutively
const oldMd = `
<!-- block_id=block-001 type=title -->
# Title
<!-- block_id=block-002 type=paragraph -->
First paragraph to be deleted.
<!-- block_id=block-003 type=paragraph -->
Second paragraph to be deleted.
`;
const newMd = `
<!-- block_id=block-001 type=title -->
# Title
`;
const patch = diffMarkdown(oldMd, newMd);
expect(patch).toEqual([
{
op: 'delete',
block_id: 'block-002',
},
{
op: 'delete',
block_id: 'block-003',
},
]);
});
test('should diff deletion followed by insertion at the same position', () => {
// A block is deleted and a new block is inserted at the end
const oldMd = `
<!-- block_id=block-001 type=title -->
# Title
<!-- block_id=block-002 type=paragraph -->
This paragraph will be deleted
<!-- block_id=block-003 type=paragraph -->
HelloWorld
`;
const newMd = `
<!-- block_id=block-001 type=title -->
# Title
<!-- block_id=block-003 type=paragraph -->
HelloWorld
<!-- block_id=block-004 type=paragraph -->
This is a new paragraph inserted after deletion.
`;
const patch = diffMarkdown(oldMd, newMd);
expect(patch).toEqual([
{
op: 'insert_at',
index: 2,
new_block: {
type: 'paragraph',
content: 'This is a new paragraph inserted after deletion.',
},
},
{
op: 'delete',
block_id: 'block-002',
},
]);
});
});
@@ -0,0 +1,54 @@
import type { Store } from '@blocksuite/store';
import { insertFromMarkdown } from '../../../utils';
import type { PatchOp } from './markdown-diff';
/**
* Apply a list of PatchOp to the page doc (children of the first note block)
* @param doc The page document Store
* @param patch Array of PatchOp
*/
export async function applyPatchToDoc(
doc: Store,
patch: PatchOp[]
): Promise<void> {
// Get all note blocks
const notes = doc.getBlocksByFlavour('affine:note');
if (notes.length === 0) return;
// Only handle the first note block
const note = notes[0].model;
// Build a map from block_id to BlockModel for quick lookup
const blockIdMap = new Map<string, any>();
note.children.forEach(child => {
blockIdMap.set(child.id, child);
});
for (const op of patch) {
if (op.op === 'delete') {
// Delete block
doc.deleteBlock(op.block_id);
} else if (op.op === 'replace') {
// Replace block: delete then insert
const oldBlock = blockIdMap.get(op.block_id);
if (!oldBlock) continue;
const parentId = note.id;
const index = note.children.findIndex(child => child.id === op.block_id);
if (index === -1) continue;
doc.deleteBlock(op.block_id);
// Insert new content
await insertFromMarkdown(undefined, op.new_content, doc, parentId, index);
} else if (op.op === 'insert_at') {
// Insert new block
const parentId = note.id;
const index = op.index;
await insertFromMarkdown(
undefined,
op.new_block.content,
doc,
parentId,
index
);
}
}
}
@@ -0,0 +1,111 @@
export type Block = {
block_id: string;
type: string;
content: string;
};
type NewBlock = Omit<Block, 'block_id'>;
export type PatchOp =
| { op: 'replace'; block_id: string; new_content: string }
| { op: 'delete'; block_id: string }
| { op: 'insert_at'; index: number; new_block: NewBlock };
export function parseMarkdownToBlocks(markdown: string): Block[] {
const lines = markdown.split(/\r?\n/);
const blocks: Block[] = [];
let currentBlockId: string | null = null;
let currentType: string | null = null;
let currentContent: string[] = [];
for (const line of lines) {
const match = line.match(/^<!--\s*block_id=(.*?)\s+type=(.*?)\s*-->/);
if (match) {
// If there is a block being collected, push it into blocks first
if (currentBlockId && currentType) {
blocks.push({
block_id: currentBlockId,
type: currentType,
content: currentContent.join('\n').trim(),
});
}
// Start a new block
currentBlockId = match[1];
currentType = match[2];
currentContent = [];
} else {
// Collect content
if (currentBlockId && currentType) {
currentContent.push(line);
}
}
}
// Collect the last block
if (currentBlockId && currentType) {
blocks.push({
block_id: currentBlockId,
type: currentType,
content: currentContent.join('\n').trim(),
});
}
return blocks;
}
function diffBlockLists(oldBlocks: Block[], newBlocks: Block[]): PatchOp[] {
const patch: PatchOp[] = [];
const oldMap = new Map<string, { block: Block; index: number }>();
oldBlocks.forEach((b, i) => oldMap.set(b.block_id, { block: b, index: i }));
const newMap = new Map<string, { block: Block; index: number }>();
newBlocks.forEach((b, i) => newMap.set(b.block_id, { block: b, index: i }));
// Mark old blocks that have been handled
const handledOld = new Set<string>();
// First process newBlocks in order
newBlocks.forEach((newBlock, newIdx) => {
const old = oldMap.get(newBlock.block_id);
if (old) {
handledOld.add(newBlock.block_id);
if (old.block.content !== newBlock.content) {
patch.push({
op: 'replace',
block_id: newBlock.block_id,
new_content: newBlock.content,
});
}
} else {
patch.push({
op: 'insert_at',
index: newIdx,
new_block: {
type: newBlock.type,
content: newBlock.content,
},
});
}
});
// Then process deleted oldBlocks
oldBlocks.forEach(oldBlock => {
if (!newMap.has(oldBlock.block_id)) {
patch.push({
op: 'delete',
block_id: oldBlock.block_id,
});
}
});
return patch;
}
export function diffMarkdown(
oldMarkdown: string,
newMarkdown: string
): PatchOp[] {
const oldBlocks = parseMarkdownToBlocks(oldMarkdown);
const newBlocks = parseMarkdownToBlocks(newMarkdown);
const patch: PatchOp[] = diffBlockLists(oldBlocks, newBlocks);
return patch;
}