mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-08 20:57:08 +08:00
feat(core): markdown-diff & patch apply (#12844)
## New Features - **Markdown diff**: - Introduced block-level diffing for markdown content, enabling detection of insertions, deletions, and replacements between document versions. - Generate patch operations from markdown diff. - **Patch Renderer**: Transfer patch operations to a render diff which can be rendered into page body. - **Patch apply**:Added functionality to apply patch operations to documents, supporting block insertion, deletion, and content replacement. ## Refactors * Move `affine/shared/__tests__/utils` to `blocksuite/affine-shared/test-utils` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Introduced utilities for declarative creation and testing of document structures using template literals. * Added new functions and types for block-level markdown diffing and patch application. * Provided a utility to generate structured render diffs for markdown blocks. * Added a unified test-utils entry point for easier access to testing helpers. * **Bug Fixes** * Updated import paths in test files to use the new test-utils location. * **Documentation** * Improved example usage in documentation to reflect the new import paths for test utilities. * **Tests** * Added comprehensive test suites for markdown diffing, patch application, and render diff utilities. * **Chores** * Updated package dependencies and export maps to expose new test utilities. * Refactored internal test utilities organization for clarity and maintainability. <!-- end of auto-generated comment: release notes by coderabbit.ai --> > CLOSE AI-271 AI-272 AI-273
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { getInternalStoreExtensions } from '@blocksuite/affine/extensions/store';
|
||||
import { StoreExtensionManager } from '@blocksuite/affine-ext-loader';
|
||||
import { createAffineTemplate } 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';
|
||||
|
||||
const manager = new StoreExtensionManager(getInternalStoreExtensions());
|
||||
const { affine } = createAffineTemplate(manager.get('store'));
|
||||
|
||||
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', 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, {
|
||||
compareId: true,
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
id: 'paragraph-1',
|
||||
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, {
|
||||
compareId: true,
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
index: 2,
|
||||
block: {
|
||||
id: 'paragraph-3',
|
||||
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, {
|
||||
compareId: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { generateRenderDiff } from '../../../../blocksuite/ai/utils/apply-model/generate-render-diff';
|
||||
|
||||
describe('generateRenderDiff', () => {
|
||||
test('should handle block insertion', () => {
|
||||
const oldMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
`;
|
||||
const newMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
|
||||
<!-- block_id=block-002 flavour=paragraph -->
|
||||
This is a new paragraph.
|
||||
`;
|
||||
const diff = generateRenderDiff(oldMd, newMd);
|
||||
expect(diff).toEqual({
|
||||
deletes: [],
|
||||
inserts: {
|
||||
'block-001': [
|
||||
{
|
||||
id: 'block-002',
|
||||
type: 'paragraph',
|
||||
content: 'This is a new paragraph.',
|
||||
},
|
||||
],
|
||||
},
|
||||
updates: {},
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle block deletion', () => {
|
||||
const oldMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
|
||||
<!-- block_id=block-002 flavour=paragraph -->
|
||||
This paragraph will be deleted.
|
||||
`;
|
||||
const newMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
`;
|
||||
const diff = generateRenderDiff(oldMd, newMd);
|
||||
expect(diff).toEqual({
|
||||
deletes: ['block-002'],
|
||||
inserts: {},
|
||||
updates: {},
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle block replacement', () => {
|
||||
const oldMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Old Title
|
||||
`;
|
||||
const newMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# New Title
|
||||
`;
|
||||
const diff = generateRenderDiff(oldMd, newMd);
|
||||
expect(diff).toEqual({
|
||||
deletes: [],
|
||||
inserts: {},
|
||||
updates: {
|
||||
'block-001': '# New Title',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle mixed changes', () => {
|
||||
const oldMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
|
||||
<!-- block_id=block-002 flavour=paragraph -->
|
||||
Old paragraph.
|
||||
|
||||
<!-- block_id=block-003 flavour=paragraph -->
|
||||
To be deleted.
|
||||
`;
|
||||
const newMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
|
||||
<!-- block_id=block-002 flavour=paragraph -->
|
||||
Updated paragraph.
|
||||
|
||||
<!-- block_id=block-004 flavour=paragraph -->
|
||||
New paragraph.
|
||||
`;
|
||||
const diff = generateRenderDiff(oldMd, newMd);
|
||||
expect(diff).toEqual({
|
||||
deletes: ['block-003'],
|
||||
inserts: {
|
||||
'block-002': [
|
||||
{
|
||||
id: 'block-004',
|
||||
type: 'paragraph',
|
||||
content: 'New paragraph.',
|
||||
},
|
||||
],
|
||||
},
|
||||
updates: {
|
||||
'block-002': 'Updated paragraph.',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle consecutive block insertions', () => {
|
||||
const oldMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
`;
|
||||
const newMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
|
||||
<!-- block_id=block-002 flavour=paragraph -->
|
||||
First inserted paragraph.
|
||||
|
||||
<!-- block_id=block-003 flavour=paragraph -->
|
||||
Second inserted paragraph.
|
||||
`;
|
||||
const diff = generateRenderDiff(oldMd, newMd);
|
||||
expect(diff).toEqual({
|
||||
deletes: [],
|
||||
inserts: {
|
||||
'block-001': [
|
||||
{
|
||||
id: 'block-002',
|
||||
type: 'paragraph',
|
||||
content: 'First inserted paragraph.',
|
||||
},
|
||||
{
|
||||
id: 'block-003',
|
||||
type: 'paragraph',
|
||||
content: 'Second inserted paragraph.',
|
||||
},
|
||||
],
|
||||
},
|
||||
updates: {},
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle consecutive block deletions', () => {
|
||||
const oldMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
|
||||
<!-- block_id=block-002 flavour=paragraph -->
|
||||
First paragraph to be deleted.
|
||||
|
||||
<!-- block_id=block-003 flavour=paragraph -->
|
||||
Second paragraph to be deleted.
|
||||
`;
|
||||
const newMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
`;
|
||||
const diff = generateRenderDiff(oldMd, newMd);
|
||||
expect(diff).toEqual({
|
||||
deletes: ['block-002', 'block-003'],
|
||||
inserts: {},
|
||||
updates: {},
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle block insertion at the head', () => {
|
||||
const oldMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
`;
|
||||
const newMd = `
|
||||
<!-- block_id=block-000 flavour=paragraph -->
|
||||
Head paragraph.
|
||||
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
`;
|
||||
const diff = generateRenderDiff(oldMd, newMd);
|
||||
expect(diff).toEqual({
|
||||
deletes: [],
|
||||
inserts: {
|
||||
HEAD: [
|
||||
{
|
||||
id: 'block-000',
|
||||
type: 'paragraph',
|
||||
content: 'Head paragraph.',
|
||||
},
|
||||
],
|
||||
},
|
||||
updates: {},
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle block insertion at the tail', () => {
|
||||
const oldMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
`;
|
||||
const newMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
|
||||
<!-- block_id=block-002 flavour=paragraph -->
|
||||
Tail paragraph.
|
||||
`;
|
||||
const diff = generateRenderDiff(oldMd, newMd);
|
||||
expect(diff).toEqual({
|
||||
deletes: [],
|
||||
inserts: {
|
||||
'block-001': [
|
||||
{
|
||||
id: 'block-002',
|
||||
type: 'paragraph',
|
||||
content: 'Tail paragraph.',
|
||||
},
|
||||
],
|
||||
},
|
||||
updates: {},
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle delete then insert after', () => {
|
||||
const oldMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
|
||||
<!-- block_id=block-002 flavour=paragraph -->
|
||||
To be deleted.
|
||||
`;
|
||||
const newMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
|
||||
<!-- block_id=block-003 flavour=paragraph -->
|
||||
Inserted after delete.
|
||||
`;
|
||||
const diff = generateRenderDiff(oldMd, newMd);
|
||||
expect(diff).toEqual({
|
||||
deletes: ['block-002'],
|
||||
inserts: {
|
||||
'block-001': [
|
||||
{
|
||||
id: 'block-003',
|
||||
type: 'paragraph',
|
||||
content: 'Inserted after delete.',
|
||||
},
|
||||
],
|
||||
},
|
||||
updates: {},
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle consecutive insertions', () => {
|
||||
const oldMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
`;
|
||||
const newMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
|
||||
<!-- block_id=block-002 flavour=paragraph -->
|
||||
First insert.
|
||||
|
||||
<!-- block_id=block-003 flavour=paragraph -->
|
||||
Second insert.
|
||||
`;
|
||||
const diff = generateRenderDiff(oldMd, newMd);
|
||||
expect(diff).toEqual({
|
||||
deletes: [],
|
||||
inserts: {
|
||||
'block-001': [
|
||||
{
|
||||
id: 'block-002',
|
||||
type: 'paragraph',
|
||||
content: 'First insert.',
|
||||
},
|
||||
{
|
||||
id: 'block-003',
|
||||
type: 'paragraph',
|
||||
content: 'Second insert.',
|
||||
},
|
||||
],
|
||||
},
|
||||
updates: {},
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle interval insertions', () => {
|
||||
const oldMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
|
||||
<!-- block_id=block-002 flavour=paragraph -->
|
||||
Paragraph.
|
||||
`;
|
||||
const newMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
|
||||
<!-- block_id=block-003 flavour=paragraph -->
|
||||
Inserted between.
|
||||
|
||||
<!-- block_id=block-002 flavour=paragraph -->
|
||||
Paragraph.
|
||||
|
||||
<!-- block_id=block-004 flavour=paragraph -->
|
||||
Inserted at tail.
|
||||
`;
|
||||
const diff = generateRenderDiff(oldMd, newMd);
|
||||
expect(diff).toEqual({
|
||||
deletes: [],
|
||||
inserts: {
|
||||
'block-001': [
|
||||
{
|
||||
id: 'block-003',
|
||||
type: 'paragraph',
|
||||
content: 'Inserted between.',
|
||||
},
|
||||
],
|
||||
'block-002': [
|
||||
{
|
||||
id: 'block-004',
|
||||
type: 'paragraph',
|
||||
content: 'Inserted at tail.',
|
||||
},
|
||||
],
|
||||
},
|
||||
updates: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
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 flavour=title -->
|
||||
# Title
|
||||
`;
|
||||
const newMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
|
||||
<!-- block_id=block-002 flavour=paragraph -->
|
||||
This is a new paragraph.
|
||||
`;
|
||||
const { patches } = diffMarkdown(oldMd, newMd);
|
||||
expect(patches).toEqual([
|
||||
{
|
||||
op: 'insert',
|
||||
index: 1,
|
||||
block: {
|
||||
id: 'block-002',
|
||||
type: 'paragraph',
|
||||
content: 'This is a new paragraph.',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should diff block deletion', () => {
|
||||
// A block is deleted
|
||||
const oldMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
|
||||
<!-- block_id=block-002 flavour=paragraph -->
|
||||
This paragraph will be deleted.
|
||||
`;
|
||||
const newMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
`;
|
||||
const { patches } = diffMarkdown(oldMd, newMd);
|
||||
expect(patches).toEqual([
|
||||
{
|
||||
op: 'delete',
|
||||
id: 'block-002',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should diff block replacement', () => {
|
||||
// Only content of a block is changed
|
||||
const oldMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Old Title
|
||||
`;
|
||||
const newMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# New Title
|
||||
`;
|
||||
const { patches } = diffMarkdown(oldMd, newMd);
|
||||
expect(patches).toEqual([
|
||||
{
|
||||
op: 'replace',
|
||||
id: 'block-001',
|
||||
content: '# New Title',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should diff mixed changes', () => {
|
||||
// Mixed: delete, insert, replace
|
||||
const oldMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
|
||||
<!-- block_id=block-002 flavour=paragraph -->
|
||||
Old paragraph.
|
||||
|
||||
<!-- block_id=block-003 flavour=paragraph -->
|
||||
To be deleted.
|
||||
`;
|
||||
const newMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
|
||||
<!-- block_id=block-002 flavour=paragraph -->
|
||||
Updated paragraph.
|
||||
|
||||
<!-- block_id=block-004 flavour=paragraph -->
|
||||
New paragraph.
|
||||
`;
|
||||
const { patches } = diffMarkdown(oldMd, newMd);
|
||||
expect(patches).toEqual([
|
||||
{
|
||||
op: 'replace',
|
||||
id: 'block-002',
|
||||
content: 'Updated paragraph.',
|
||||
},
|
||||
{
|
||||
op: 'insert',
|
||||
index: 2,
|
||||
block: {
|
||||
id: 'block-004',
|
||||
type: 'paragraph',
|
||||
content: 'New paragraph.',
|
||||
},
|
||||
},
|
||||
{
|
||||
op: 'delete',
|
||||
id: 'block-003',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should diff consecutive block insertions', () => {
|
||||
// Two new blocks are inserted consecutively
|
||||
const oldMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
`;
|
||||
const newMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
|
||||
<!-- block_id=block-002 flavour=paragraph -->
|
||||
First inserted paragraph.
|
||||
|
||||
<!-- block_id=block-003 flavour=paragraph -->
|
||||
Second inserted paragraph.
|
||||
`;
|
||||
const { patches } = diffMarkdown(oldMd, newMd);
|
||||
expect(patches).toEqual([
|
||||
{
|
||||
op: 'insert',
|
||||
index: 1,
|
||||
block: {
|
||||
id: 'block-002',
|
||||
type: 'paragraph',
|
||||
content: 'First inserted paragraph.',
|
||||
},
|
||||
},
|
||||
{
|
||||
op: 'insert',
|
||||
index: 2,
|
||||
block: {
|
||||
id: 'block-003',
|
||||
type: 'paragraph',
|
||||
content: 'Second inserted paragraph.',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should diff consecutive block deletions', () => {
|
||||
// Two blocks are deleted consecutively
|
||||
const oldMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
|
||||
<!-- block_id=block-002 flavour=paragraph -->
|
||||
First paragraph to be deleted.
|
||||
|
||||
<!-- block_id=block-003 flavour=paragraph -->
|
||||
Second paragraph to be deleted.
|
||||
`;
|
||||
const newMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
`;
|
||||
const { patches } = diffMarkdown(oldMd, newMd);
|
||||
expect(patches).toEqual([
|
||||
{
|
||||
op: 'delete',
|
||||
id: 'block-002',
|
||||
},
|
||||
{
|
||||
op: 'delete',
|
||||
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 flavour=title -->
|
||||
# Title
|
||||
|
||||
<!-- block_id=block-002 flavour=paragraph -->
|
||||
This paragraph will be deleted
|
||||
|
||||
<!-- block_id=block-003 flavour=paragraph -->
|
||||
HelloWorld
|
||||
`;
|
||||
|
||||
const newMd = `
|
||||
<!-- block_id=block-001 flavour=title -->
|
||||
# Title
|
||||
|
||||
<!-- block_id=block-003 flavour=paragraph -->
|
||||
HelloWorld
|
||||
|
||||
<!-- block_id=block-004 flavour=paragraph -->
|
||||
This is a new paragraph inserted after deletion.
|
||||
`;
|
||||
const { patches } = diffMarkdown(oldMd, newMd);
|
||||
expect(patches).toEqual([
|
||||
{
|
||||
op: 'insert',
|
||||
index: 2,
|
||||
block: {
|
||||
id: 'block-004',
|
||||
type: 'paragraph',
|
||||
content: 'This is a new paragraph inserted after deletion.',
|
||||
},
|
||||
},
|
||||
{
|
||||
op: 'delete',
|
||||
id: 'block-002',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Store } from '@blocksuite/store';
|
||||
|
||||
import { insertFromMarkdown, replaceFromMarkdown } 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.id);
|
||||
} else if (op.op === 'replace') {
|
||||
const oldBlock = blockIdMap.get(op.id);
|
||||
if (!oldBlock) continue;
|
||||
const parentId = note.id;
|
||||
const index = note.children.findIndex(child => child.id === op.id);
|
||||
if (index === -1) continue;
|
||||
|
||||
await replaceFromMarkdown(
|
||||
undefined,
|
||||
op.content,
|
||||
doc,
|
||||
parentId,
|
||||
index,
|
||||
op.id
|
||||
);
|
||||
} else if (op.op === 'insert') {
|
||||
// Insert new block
|
||||
const parentId = note.id;
|
||||
const index = op.index;
|
||||
await insertFromMarkdown(
|
||||
undefined,
|
||||
op.block.content,
|
||||
doc,
|
||||
parentId,
|
||||
index,
|
||||
op.block.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { type Block, diffMarkdown } from './markdown-diff';
|
||||
|
||||
export interface RenderDiffs {
|
||||
deletes: string[];
|
||||
inserts: Record<string, Block[]>;
|
||||
updates: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Example:
|
||||
*
|
||||
* Old markdown:
|
||||
* ```md
|
||||
* <!-- block_id=001 flavour=paragraph -->
|
||||
* This is the first paragraph
|
||||
*
|
||||
* <!-- block_id=002 flavour=paragraph -->
|
||||
* This is the second paragraph
|
||||
*
|
||||
* <!-- block_id=003 flavour=paragraph -->
|
||||
* This is the third paragraph
|
||||
*
|
||||
* <!-- block_id=004 flavour=paragraph -->
|
||||
* This is the fourth paragraph
|
||||
* ```
|
||||
*
|
||||
* New markdown:
|
||||
* ```md
|
||||
* <!-- block_id=001 flavour=paragraph -->
|
||||
* This is the first paragraph
|
||||
*
|
||||
* <!-- block_id=003 flavour=paragraph -->
|
||||
* This is the 3rd paragraph
|
||||
*
|
||||
* <!-- block_id=005 flavour=paragraph -->
|
||||
* New inserted paragraph 1
|
||||
*
|
||||
* <!-- block_id=006 flavour=paragraph -->
|
||||
* New inserted paragraph 2
|
||||
* ```
|
||||
*
|
||||
* The generated patches:
|
||||
* ```js
|
||||
* [
|
||||
* { op: 'insert', index: 2, block: { id: '005', ... } },
|
||||
* { op: 'insert', index: 3, bthirdlock: { id: '006', ... } },
|
||||
* { op: 'update', id: '003', content: 'This is the 3rd paragraph' },
|
||||
* { op: 'delete', id: '002' },
|
||||
* { op: 'delete', id: '004' }
|
||||
* ]
|
||||
* ```
|
||||
*
|
||||
* UI expected:
|
||||
* ```
|
||||
* This is the first paragraph
|
||||
* [DELETE DIFF] This is the second paragraph
|
||||
* This is the third paragraph
|
||||
* [DELETE DIFF] This is the fourth paragraph
|
||||
* [INSERT DIFF] New inserted paragraph 1
|
||||
* [INSERT DIFF] New inserted paragraph 2
|
||||
* ```
|
||||
*
|
||||
* The resulting diffMap:
|
||||
* ```js
|
||||
* {
|
||||
* deletes: ['002', '004'],
|
||||
* inserts: { 3: [block_005, block_006] },
|
||||
* updates: {}
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function generateRenderDiff(
|
||||
originalMarkdown: string,
|
||||
changedMarkdown: string
|
||||
) {
|
||||
const { patches, oldBlocks } = diffMarkdown(
|
||||
originalMarkdown,
|
||||
changedMarkdown
|
||||
);
|
||||
|
||||
const diffMap: RenderDiffs = {
|
||||
deletes: [],
|
||||
inserts: {},
|
||||
updates: {},
|
||||
};
|
||||
|
||||
const indexToBlockId: Record<number, string> = {};
|
||||
oldBlocks.forEach((block, idx) => {
|
||||
indexToBlockId[idx] = block.id;
|
||||
});
|
||||
|
||||
function getPrevBlock(index: number) {
|
||||
let start = index - 1;
|
||||
while (!indexToBlockId[start] && start >= 0) {
|
||||
start--;
|
||||
}
|
||||
return indexToBlockId[start] || 'HEAD';
|
||||
}
|
||||
|
||||
const insertGroups: Record<string, Block[]> = {};
|
||||
let lastInsertKey: string | null = null;
|
||||
let lastInsertIndex: number | null = null;
|
||||
|
||||
for (const patch of patches) {
|
||||
switch (patch.op) {
|
||||
case 'delete':
|
||||
diffMap.deletes.push(patch.id);
|
||||
break;
|
||||
case 'insert': {
|
||||
const prevBlockId = getPrevBlock(patch.index);
|
||||
if (
|
||||
lastInsertKey !== null &&
|
||||
lastInsertIndex !== null &&
|
||||
patch.index === lastInsertIndex + 1
|
||||
) {
|
||||
insertGroups[lastInsertKey].push(patch.block);
|
||||
} else {
|
||||
insertGroups[prevBlockId] = [patch.block];
|
||||
lastInsertKey = prevBlockId;
|
||||
}
|
||||
lastInsertIndex = patch.index;
|
||||
break;
|
||||
}
|
||||
case 'replace':
|
||||
diffMap.updates[patch.id] = patch.content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
diffMap.inserts = insertGroups;
|
||||
|
||||
return diffMap;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
export type Block = {
|
||||
id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type PatchOp =
|
||||
| { op: 'replace'; id: string; content: string }
|
||||
| { op: 'delete'; id: string }
|
||||
| { op: 'insert'; index: number; block: Block };
|
||||
|
||||
const BLOCK_MATCH_REGEXP = /^\s*<!--\s*block_id=(.*?)\s+flavour=(.*?)\s*-->/;
|
||||
|
||||
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(BLOCK_MATCH_REGEXP);
|
||||
if (match) {
|
||||
// If there is a block being collected, push it into blocks first
|
||||
if (currentBlockId && currentType) {
|
||||
blocks.push({
|
||||
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({
|
||||
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.id, { block: b, index: i }));
|
||||
const newMap = new Map<string, { block: Block; index: number }>();
|
||||
newBlocks.forEach((b, i) => newMap.set(b.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.id);
|
||||
if (old) {
|
||||
handledOld.add(newBlock.id);
|
||||
if (old.block.content !== newBlock.content) {
|
||||
patch.push({
|
||||
op: 'replace',
|
||||
id: newBlock.id,
|
||||
content: newBlock.content,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
patch.push({
|
||||
op: 'insert',
|
||||
index: newIdx,
|
||||
block: {
|
||||
id: newBlock.id,
|
||||
type: newBlock.type,
|
||||
content: newBlock.content,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Then process deleted oldBlocks
|
||||
oldBlocks.forEach(oldBlock => {
|
||||
if (!newMap.has(oldBlock.id)) {
|
||||
patch.push({
|
||||
op: 'delete',
|
||||
id: oldBlock.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return patch;
|
||||
}
|
||||
|
||||
export function diffMarkdown(oldMarkdown: string, newMarkdown: string) {
|
||||
const oldBlocks = parseMarkdownToBlocks(oldMarkdown);
|
||||
const newBlocks = parseMarkdownToBlocks(newMarkdown);
|
||||
|
||||
const patches: PatchOp[] = diffBlockLists(oldBlocks, newBlocks);
|
||||
|
||||
return { patches, newBlocks, oldBlocks };
|
||||
}
|
||||
@@ -131,7 +131,8 @@ export async function insertFromMarkdown(
|
||||
markdown: string,
|
||||
doc: Store,
|
||||
parent?: string,
|
||||
index?: number
|
||||
index?: number,
|
||||
id?: string
|
||||
) {
|
||||
const { snapshot, transformer } = await markdownToSnapshot(
|
||||
markdown,
|
||||
@@ -144,6 +145,9 @@ export async function insertFromMarkdown(
|
||||
const models: BlockModel[] = [];
|
||||
for (let i = 0; i < snapshots.length; i++) {
|
||||
const blockSnapshot = snapshots[i];
|
||||
if (snapshots.length === 1 && id) {
|
||||
blockSnapshot.id = id;
|
||||
}
|
||||
const model = await transformer.snapshotToBlock(
|
||||
blockSnapshot,
|
||||
doc,
|
||||
@@ -158,6 +162,27 @@ export async function insertFromMarkdown(
|
||||
return models;
|
||||
}
|
||||
|
||||
export async function replaceFromMarkdown(
|
||||
host: EditorHost | undefined,
|
||||
markdown: string,
|
||||
doc: Store,
|
||||
parent: string,
|
||||
index: number,
|
||||
id: string
|
||||
) {
|
||||
doc.deleteBlock(id);
|
||||
const { snapshot, transformer } = await markdownToSnapshot(
|
||||
markdown,
|
||||
doc,
|
||||
host
|
||||
);
|
||||
|
||||
const snapshots = snapshot?.content.flatMap(x => x.children) ?? [];
|
||||
const blockSnapshot = snapshots[0];
|
||||
blockSnapshot.id = id;
|
||||
await transformer.snapshotToBlock(blockSnapshot, doc, parent, index);
|
||||
}
|
||||
|
||||
export async function markDownToDoc(
|
||||
provider: ServiceProvider,
|
||||
schema: Schema,
|
||||
|
||||
Reference in New Issue
Block a user