mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-30 00:29:46 +08:00
feat(core): support Notion markdown zip imports (#14910)
## What changed - Markdown zip imports now resolve local `.md` links like `./test/2.md` into AFFiNE linked-page references when the target document exists in the same archive. - Added Notion Markdown `.zip` import support in the desktop import dialog, including nested zip traversal, Notion title extraction, hash-stripped folder names, attachments, and folder hierarchy integration. - Added i18n entries and adapter coverage for standard Markdown zip links and Notion Markdown zip imports. ## Why Markdown and Notion exports often contain links between notes using relative `.md` paths. Keeping those as plain URLs makes imported workspaces harder to navigate, so the importer now preassigns document ids and rewrites resolvable archive-local markdown links into linked pages. ## Notes The markdown zip folder hierarchy implementation now comes from latest `origin/canary`, so this PR only layers relative-link resolution and Notion Markdown zip support on top of that upstream behavior. ## Validation - `yarn vitest --run blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts` - `yarn tsc -b blocksuite/affine/all/tsconfig.json --verbose` - `git diff --check` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for importing Notion Markdown exports in `.zip` format, including subpages, attachments, and nested folders. * Internal links inside imported Markdown now resolve correctly between pages, preserving link text when available. * The import dialog now includes a dedicated “Notion (Markdown, .zip)” option. * **Bug Fixes** * Improved filename handling so non-Latin characters in ZIP imports are preserved correctly. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com> Co-authored-by: DarkSky <darksky2048@gmail.com>
This commit is contained in:
@@ -30,6 +30,7 @@ import type {
|
||||
} from '@blocksuite/store';
|
||||
import { AssetsManager, MemoryBlobCRUD, Schema } from '@blocksuite/store';
|
||||
import { TestWorkspace } from '@blocksuite/store/test';
|
||||
import * as fflate from 'fflate';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { AffineSchemas } from '../../schemas.js';
|
||||
@@ -64,6 +65,25 @@ function markdownFixture(relativePath: string): File {
|
||||
);
|
||||
}
|
||||
|
||||
function zipBytes(entries: Record<string, string | Uint8Array>) {
|
||||
return fflate.zipSync(
|
||||
Object.fromEntries(
|
||||
Object.entries(entries).map(([path, content]) => [
|
||||
path,
|
||||
typeof content === 'string' ? fflate.strToU8(content) : content,
|
||||
])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function zipFixture(entries: Record<string, string | Uint8Array>) {
|
||||
const zipped = zipBytes(entries);
|
||||
const buffer = new ArrayBuffer(zipped.byteLength);
|
||||
new Uint8Array(buffer).set(zipped);
|
||||
|
||||
return new Blob([buffer], { type: 'application/zip' });
|
||||
}
|
||||
|
||||
function exportSnapshot(doc: Store): DocSnapshot {
|
||||
const job = doc.getTransformer([
|
||||
docLinkBaseURLMiddleware(doc.workspace.id),
|
||||
@@ -74,6 +94,17 @@ function exportSnapshot(doc: Store): DocSnapshot {
|
||||
return snapshot!;
|
||||
}
|
||||
|
||||
function noteSnapshotByTitle(collection: TestWorkspace, title: string) {
|
||||
const meta = collection.meta.docMetas.find(meta => meta.title === title);
|
||||
expect(meta).toBeTruthy();
|
||||
const doc = collection.getDoc(meta!.id)?.getStore({ id: meta!.id });
|
||||
expect(doc).toBeTruthy();
|
||||
const snapshot = exportSnapshot(doc!);
|
||||
return snapshot.blocks.children.find(
|
||||
block => block.flavour === 'affine:note'
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDeltaForSnapshot(
|
||||
delta: DeltaInsert<AffineTextAttributes>[],
|
||||
titleById: ReadonlyMap<string, string>
|
||||
@@ -172,6 +203,21 @@ function snapshotDocByTitle(
|
||||
return simplifyBlockForSnapshot(exportSnapshot(doc!).blocks, titleById);
|
||||
}
|
||||
|
||||
function collectSimplifiedDeltas(
|
||||
block: Record<string, unknown>
|
||||
): Record<string, unknown>[] {
|
||||
const deltas = Array.isArray(block.delta)
|
||||
? (block.delta as Record<string, unknown>[])
|
||||
: [];
|
||||
const childDeltas = Array.isArray(block.children)
|
||||
? (block.children as Record<string, unknown>[]).flatMap(child =>
|
||||
collectSimplifiedDeltas(child)
|
||||
)
|
||||
: [];
|
||||
|
||||
return [...deltas, ...childDeltas];
|
||||
}
|
||||
|
||||
describe('snapshot to markdown', () => {
|
||||
test('code', async () => {
|
||||
const blockSnapshot: BlockSnapshot = {
|
||||
@@ -318,6 +364,275 @@ Hello world
|
||||
expect(exported.file).toContain('> \\- Oranges');
|
||||
});
|
||||
|
||||
test('imports notion markdown zip titles and folder names', async () => {
|
||||
const schema = new Schema().register(AffineSchemas);
|
||||
const collection = new TestWorkspace();
|
||||
collection.storeExtensions = testStoreExtensions;
|
||||
collection.meta.initialize();
|
||||
|
||||
const imported = zipFixture({
|
||||
'Notion Export/Workspace 11111111111111111111111111111111.md':
|
||||
'# Workspace\nRoot body',
|
||||
'Notion Export/Workspace 11111111111111111111111111111111/Nested Page 22222222222222222222222222222222.md':
|
||||
'# Nested Page\nNested body',
|
||||
});
|
||||
|
||||
const { docIds, folderHierarchy } =
|
||||
await MarkdownTransformer.importNotionMarkdownZip({
|
||||
collection,
|
||||
schema,
|
||||
imported,
|
||||
extensions: testStoreExtensions,
|
||||
});
|
||||
|
||||
expect(docIds).toHaveLength(2);
|
||||
expect(
|
||||
collection.meta.docMetas
|
||||
.map(meta => meta.title)
|
||||
.sort((a, b) => (a ?? '').localeCompare(b ?? ''))
|
||||
).toEqual(['Nested Page', 'Workspace']);
|
||||
|
||||
const nestedNote = noteSnapshotByTitle(collection, 'Nested Page');
|
||||
expect(JSON.stringify(nestedNote)).toContain('Nested body');
|
||||
expect(JSON.stringify(nestedNote)).not.toContain('Nested Page');
|
||||
|
||||
const [folder] = [...(folderHierarchy?.children.values() ?? [])];
|
||||
expect(folder?.name).toBe('Notion Export');
|
||||
const workspaceMeta = collection.meta.docMetas.find(
|
||||
meta => meta.title === 'Workspace'
|
||||
);
|
||||
expect([...folder!.children.values()]).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ pageId: workspaceMeta?.id }),
|
||||
])
|
||||
);
|
||||
const workspaceFolder = [...folder!.children.values()].find(
|
||||
child => child.name === 'Workspace'
|
||||
);
|
||||
const nestedMeta = collection.meta.docMetas.find(
|
||||
meta => meta.title === 'Nested Page'
|
||||
);
|
||||
expect([...workspaceFolder!.children.values()]).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ pageId: nestedMeta?.id }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
test('imports notion markdown zip folders with CJK names', async () => {
|
||||
const schema = new Schema().register(AffineSchemas);
|
||||
const collection = new TestWorkspace();
|
||||
collection.storeExtensions = testStoreExtensions;
|
||||
collection.meta.initialize();
|
||||
|
||||
const imported = zipFixture({
|
||||
'Export/工作 11111111111111111111111111111111.md': '# 工作\nRoot body',
|
||||
'Export/工作 11111111111111111111111111111111/SDK架构 22222222222222222222222222222222.md':
|
||||
'# SDK架构\nNested body',
|
||||
});
|
||||
|
||||
const { folderHierarchy } =
|
||||
await MarkdownTransformer.importNotionMarkdownZip({
|
||||
collection,
|
||||
schema,
|
||||
imported,
|
||||
extensions: testStoreExtensions,
|
||||
});
|
||||
|
||||
const [rootFolder] = [...(folderHierarchy?.children.values() ?? [])];
|
||||
expect(rootFolder?.name).toBe('Export');
|
||||
const workFolder = [...(rootFolder?.children.values() ?? [])].find(
|
||||
child => child.name === '工作'
|
||||
);
|
||||
expect(workFolder?.name).toBe('工作');
|
||||
expect([...workFolder!.children.values()]).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ pageId: expect.any(String) }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
test('imports notion markdown zip title from frontmatter when heading is absent', async () => {
|
||||
const schema = new Schema().register(AffineSchemas);
|
||||
const collection = new TestWorkspace();
|
||||
collection.storeExtensions = testStoreExtensions;
|
||||
collection.meta.initialize();
|
||||
|
||||
const imported = zipFixture({
|
||||
'Export/Fallback 11111111111111111111111111111111.md':
|
||||
'---\ntitle: Frontmatter Title\n---\nBody',
|
||||
});
|
||||
|
||||
const { docIds } = await MarkdownTransformer.importNotionMarkdownZip({
|
||||
collection,
|
||||
schema,
|
||||
imported,
|
||||
extensions: testStoreExtensions,
|
||||
});
|
||||
|
||||
expect(docIds).toHaveLength(1);
|
||||
expect(collection.meta.getDocMeta(docIds[0])?.title).toBe(
|
||||
'Frontmatter Title'
|
||||
);
|
||||
});
|
||||
|
||||
test('imports markdown zip relative doc links as linked pages', async () => {
|
||||
const schema = new Schema().register(AffineSchemas);
|
||||
const collection = new TestWorkspace();
|
||||
collection.storeExtensions = testStoreExtensions;
|
||||
collection.meta.initialize();
|
||||
|
||||
const imported = zipFixture({
|
||||
'entry.md': [
|
||||
'[引用](./test/2.md)',
|
||||
'[missing](./missing.md)',
|
||||
'[external](https://example.com/test.md)',
|
||||
].join('\n\n'),
|
||||
'test/2.md': 'target page',
|
||||
});
|
||||
|
||||
const { docIds } = await MarkdownTransformer.importMarkdownZip({
|
||||
collection,
|
||||
schema,
|
||||
imported,
|
||||
extensions: testStoreExtensions,
|
||||
});
|
||||
expect(docIds).toHaveLength(2);
|
||||
|
||||
const titleById = new Map(
|
||||
collection.meta.docMetas.map(meta => [
|
||||
meta.id,
|
||||
meta.title ?? '<untitled>',
|
||||
])
|
||||
);
|
||||
const entryDeltas = collectSimplifiedDeltas(
|
||||
snapshotDocByTitle(collection, 'entry', titleById)
|
||||
);
|
||||
|
||||
expect(entryDeltas).toContainEqual({
|
||||
insert: ' ',
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
page: '2',
|
||||
title: '引用',
|
||||
},
|
||||
});
|
||||
expect(entryDeltas).toContainEqual({
|
||||
insert: 'missing',
|
||||
link: './missing.md',
|
||||
});
|
||||
expect(entryDeltas).toContainEqual({
|
||||
insert: 'external',
|
||||
link: 'https://example.com/test.md',
|
||||
});
|
||||
});
|
||||
|
||||
test('imports notion markdown zip relative doc links as linked pages', async () => {
|
||||
const schema = new Schema().register(AffineSchemas);
|
||||
const collection = new TestWorkspace();
|
||||
collection.storeExtensions = testStoreExtensions;
|
||||
collection.meta.initialize();
|
||||
|
||||
const imported = zipFixture({
|
||||
'Workspace 11111111111111111111111111111111/Entry 22222222222222222222222222222222.md':
|
||||
'# Entry\n[引用](./test/Target%2033333333333333333333333333333333.md)',
|
||||
'Workspace 11111111111111111111111111111111/test/Target 33333333333333333333333333333333.md':
|
||||
'# Target\ntarget page',
|
||||
});
|
||||
|
||||
const { docIds } = await MarkdownTransformer.importNotionMarkdownZip({
|
||||
collection,
|
||||
schema,
|
||||
imported,
|
||||
extensions: testStoreExtensions,
|
||||
});
|
||||
expect(docIds).toHaveLength(2);
|
||||
|
||||
const titleById = new Map(
|
||||
collection.meta.docMetas.map(meta => [
|
||||
meta.id,
|
||||
meta.title ?? '<untitled>',
|
||||
])
|
||||
);
|
||||
const entryDeltas = collectSimplifiedDeltas(
|
||||
snapshotDocByTitle(collection, 'Entry', titleById)
|
||||
);
|
||||
|
||||
expect(entryDeltas).toContainEqual({
|
||||
insert: ' ',
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
page: 'Target',
|
||||
title: '引用',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('imports nested notion markdown zips with isolated relative links', async () => {
|
||||
const schema = new Schema().register(AffineSchemas);
|
||||
const collection = new TestWorkspace();
|
||||
collection.storeExtensions = testStoreExtensions;
|
||||
collection.meta.initialize();
|
||||
|
||||
const imported = zipFixture({
|
||||
'Export/Part A.zip': zipBytes({
|
||||
'Entry 11111111111111111111111111111111.md':
|
||||
'# Entry A\n[go](./Target%2022222222222222222222222222222222.md)',
|
||||
'Target 22222222222222222222222222222222.md': '# Target A\nA body',
|
||||
}),
|
||||
'Export/Part B.zip': zipBytes({
|
||||
'Entry 11111111111111111111111111111111.md':
|
||||
'# Entry B\n[go](./Target%2022222222222222222222222222222222.md)',
|
||||
'Target 22222222222222222222222222222222.md': '# Target B\nB body',
|
||||
}),
|
||||
});
|
||||
|
||||
const { docIds, folderHierarchy } =
|
||||
await MarkdownTransformer.importNotionMarkdownZip({
|
||||
collection,
|
||||
schema,
|
||||
imported,
|
||||
extensions: testStoreExtensions,
|
||||
});
|
||||
expect(docIds).toHaveLength(4);
|
||||
|
||||
const titleById = new Map(
|
||||
collection.meta.docMetas.map(meta => [
|
||||
meta.id,
|
||||
meta.title ?? '<untitled>',
|
||||
])
|
||||
);
|
||||
const entryADeltas = collectSimplifiedDeltas(
|
||||
snapshotDocByTitle(collection, 'Entry A', titleById)
|
||||
);
|
||||
const entryBDeltas = collectSimplifiedDeltas(
|
||||
snapshotDocByTitle(collection, 'Entry B', titleById)
|
||||
);
|
||||
|
||||
expect(entryADeltas).toContainEqual({
|
||||
insert: ' ',
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
page: 'Target A',
|
||||
title: 'go',
|
||||
},
|
||||
});
|
||||
expect(entryBDeltas).toContainEqual({
|
||||
insert: ' ',
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
page: 'Target B',
|
||||
title: 'go',
|
||||
},
|
||||
});
|
||||
|
||||
const [rootFolder] = [...(folderHierarchy?.children.values() ?? [])];
|
||||
expect(rootFolder?.name).toBe('Export');
|
||||
expect(
|
||||
[...(rootFolder?.children.values() ?? [])].map(node => node.name)
|
||||
).toEqual(expect.arrayContaining(['Part A', 'Part B']));
|
||||
});
|
||||
|
||||
test('imports obsidian vault fixtures', async () => {
|
||||
const schema = new Schema().register(AffineSchemas);
|
||||
const collection = new TestWorkspace();
|
||||
|
||||
Reference in New Issue
Block a user