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:
Jachin
2026-07-04 16:42:48 -04:00
committed by GitHub
parent 8063af2da8
commit 477015f064
7 changed files with 725 additions and 52 deletions
@@ -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();