mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-29 16:19:43 +08:00
feat(core): import progress & perf (#15197)
#### PR Dependency Tree * **PR #15197** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a new import pipeline with “plan then commit” batch handling for Markdown, Notion HTML, Obsidian, and Bear backups. * Enabled native import sessions with progress, cancellation, and batch-by-batch committing (including assets, folders, icons, and tags). * Added web preflight limits for ZIP and multi-file imports. * **Bug Fixes** * Improved import error/warning reporting and continued processing when some items fail. * Strengthened snapshot-based file/directory picking to preserve paths. * **Chores** * Updated project packaging/configuration for the new import workflow. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -7,17 +7,30 @@ import {
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import { Container } from '@blocksuite/global/di';
|
||||
import { sha } from '@blocksuite/global/utils';
|
||||
import type { ExtensionType, Schema, Workspace } from '@blocksuite/store';
|
||||
import type {
|
||||
DocSnapshot,
|
||||
ExtensionType,
|
||||
Schema,
|
||||
Workspace,
|
||||
} from '@blocksuite/store';
|
||||
import { extMimeMap, Transformer } from '@blocksuite/store';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
import {
|
||||
blobsFromAssets,
|
||||
type ImportBatch,
|
||||
type ImportDoc,
|
||||
type ImportFolder,
|
||||
type ImportTag,
|
||||
type ImportWarning,
|
||||
} from './import-batch.js';
|
||||
import { createCollectionDocCRUD } from './markdown.js';
|
||||
|
||||
/** Recursive tree node representing a tag-based folder hierarchy. */
|
||||
type FolderHierarchy = {
|
||||
export type BearFolderHierarchy = {
|
||||
name: string;
|
||||
path: string;
|
||||
children: Map<string, FolderHierarchy>;
|
||||
children: Map<string, BearFolderHierarchy>;
|
||||
pageId?: string;
|
||||
parentPath?: string;
|
||||
};
|
||||
@@ -29,10 +42,11 @@ type BearImportOptions = {
|
||||
extensions: ExtensionType[];
|
||||
};
|
||||
|
||||
type BearImportResult = {
|
||||
export type PlanBearBackupResult = {
|
||||
docIds: string[];
|
||||
tags: Map<string, string[]>;
|
||||
folderHierarchy: FolderHierarchy;
|
||||
folderHierarchy: BearFolderHierarchy;
|
||||
batch: ImportBatch;
|
||||
};
|
||||
|
||||
type BundleEntry = {
|
||||
@@ -182,8 +196,8 @@ function deduplicateTags(tags: string[]): string[] {
|
||||
*/
|
||||
function buildFolderHierarchyFromTags(
|
||||
tagDocMap: Map<string, string[]>
|
||||
): FolderHierarchy {
|
||||
const root: FolderHierarchy = {
|
||||
): BearFolderHierarchy {
|
||||
const root: BearFolderHierarchy = {
|
||||
name: '',
|
||||
path: '',
|
||||
children: new Map(),
|
||||
@@ -226,6 +240,34 @@ function buildFolderHierarchyFromTags(
|
||||
return root;
|
||||
}
|
||||
|
||||
function flattenFolderHierarchy(root: BearFolderHierarchy): ImportFolder[] {
|
||||
const folders: ImportFolder[] = [];
|
||||
|
||||
const visit = (node: BearFolderHierarchy) => {
|
||||
if (node.name) {
|
||||
folders.push({
|
||||
path: node.path,
|
||||
name: node.name,
|
||||
parentPath: node.parentPath,
|
||||
pageId: node.pageId,
|
||||
});
|
||||
}
|
||||
for (const child of node.children.values()) {
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
|
||||
for (const child of root.children.values()) {
|
||||
visit(child);
|
||||
}
|
||||
|
||||
return folders;
|
||||
}
|
||||
|
||||
function tagsToBatchTags(tags: Map<string, string[]>): ImportTag[] {
|
||||
return Array.from(tags, ([name, docIds]) => ({ name, docIds }));
|
||||
}
|
||||
|
||||
const GFM_CALLOUT_MAP: Record<string, string> = {
|
||||
IMPORTANT: '\u26A0',
|
||||
NOTE: '\uD83D\uDCDD',
|
||||
@@ -250,9 +292,9 @@ function convertGfmCallouts(markdown: string): string {
|
||||
if (!inCodeBlock) {
|
||||
lines[i] = lines[i].replace(
|
||||
/^(>\s*)\[!(\w+)\]/,
|
||||
(_match, prefix: string, type: string) => {
|
||||
(match, prefix: string, type: string) => {
|
||||
const emoji = GFM_CALLOUT_MAP[type.toUpperCase()];
|
||||
return emoji ? `${prefix}[!${emoji}]` : _match;
|
||||
return emoji ? `${prefix}[!${emoji}]` : match;
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -306,7 +348,7 @@ function convertHighlights(markdown: string): string {
|
||||
if (!inCodeBlock) {
|
||||
lines[i] = lines[i].replace(
|
||||
/==(\S(?:[^=]|=[^=])*?)==/g,
|
||||
(_match, content: string) => {
|
||||
(...[, content]) => {
|
||||
const firstChar = String.fromCodePoint(content.codePointAt(0)!);
|
||||
const color = HIGHLIGHT_COLOR_MAP[firstChar];
|
||||
if (color) {
|
||||
@@ -340,16 +382,24 @@ function extractTitle(markdown: string, bundleName: string): string {
|
||||
return bundleName.replace(/\.textbundle$/i, '') || 'Untitled';
|
||||
}
|
||||
|
||||
function applySnapshotTitle(snapshot: DocSnapshot, title: string) {
|
||||
snapshot.meta.title = title;
|
||||
snapshot.blocks.props.title = {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: [{ insert: title }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a Bear .bear2bk backup file.
|
||||
* Uses JSZip for lazy/streaming decompression to handle large backups.
|
||||
*/
|
||||
async function importBearBackup({
|
||||
async function planBearBackup({
|
||||
collection,
|
||||
schema,
|
||||
imported,
|
||||
extensions,
|
||||
}: BearImportOptions): Promise<BearImportResult> {
|
||||
}: BearImportOptions): Promise<PlanBearBackupResult> {
|
||||
const provider = getProvider(extensions);
|
||||
|
||||
// JSZip reads the zip directory without decompressing all entries
|
||||
@@ -358,7 +408,7 @@ async function importBearBackup({
|
||||
// Scan entries and group by textbundle
|
||||
const bundleMap = new Map<string, BundleEntry>();
|
||||
|
||||
zip.forEach((path, _entry) => {
|
||||
zip.forEach(path => {
|
||||
if (path.includes('__MACOSX') || path.includes('.DS_Store')) return;
|
||||
|
||||
const tbMatch = path.match(/^(.+?\.textbundle)\/(.*)/i);
|
||||
@@ -421,6 +471,12 @@ async function importBearBackup({
|
||||
}
|
||||
|
||||
const docIds: string[] = [];
|
||||
const docs: ImportDoc[] = [];
|
||||
const importBlobs = new Map<
|
||||
string,
|
||||
Awaited<ReturnType<typeof blobsFromAssets>>[0]
|
||||
>();
|
||||
const warnings: ImportWarning[] = [];
|
||||
const tagDocMap = new Map<string, string[]>();
|
||||
|
||||
// Process bundles sequentially to limit memory.
|
||||
@@ -497,44 +553,70 @@ async function importBearBackup({
|
||||
}
|
||||
|
||||
const mdAdapter = new MarkdownAdapter(job, provider);
|
||||
const doc = await mdAdapter.toDoc({
|
||||
const snapshot = await mdAdapter.toDocSnapshot({
|
||||
file: markdown,
|
||||
assets: job.assetsManager,
|
||||
});
|
||||
|
||||
if (doc) {
|
||||
docIds.push(doc.id);
|
||||
|
||||
const metaPatch: Record<string, unknown> = {};
|
||||
if (bearMeta?.creationDate) {
|
||||
const ts = Date.parse(String(bearMeta.creationDate));
|
||||
if (!isNaN(ts)) metaPatch.createDate = ts;
|
||||
}
|
||||
if (bearMeta?.modificationDate) {
|
||||
const ts = Date.parse(String(bearMeta.modificationDate));
|
||||
if (!isNaN(ts)) metaPatch.updatedDate = ts;
|
||||
}
|
||||
if (Object.keys(metaPatch).length) {
|
||||
collection.meta.setDocMeta(doc.id, metaPatch);
|
||||
}
|
||||
|
||||
for (const tag of tags) {
|
||||
if (!tagDocMap.has(tag)) {
|
||||
tagDocMap.set(tag, []);
|
||||
}
|
||||
tagDocMap.get(tag)!.push(doc.id);
|
||||
}
|
||||
const docId = collection.idGenerator();
|
||||
snapshot.meta.id = docId;
|
||||
applySnapshotTitle(snapshot, title);
|
||||
const meta: ImportDoc['meta'] = { title };
|
||||
if (bearMeta?.creationDate) {
|
||||
const ts = Date.parse(String(bearMeta.creationDate));
|
||||
if (!isNaN(ts)) meta.createDate = ts;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Failed to import bundle: ${entry.bundlePath}`, err);
|
||||
if (bearMeta?.modificationDate) {
|
||||
const ts = Date.parse(String(bearMeta.modificationDate));
|
||||
if (!isNaN(ts)) meta.updatedDate = ts;
|
||||
}
|
||||
|
||||
docs.push({
|
||||
id: docId,
|
||||
snapshot,
|
||||
meta,
|
||||
});
|
||||
docIds.push(docId);
|
||||
|
||||
for (const tag of tags) {
|
||||
if (!tagDocMap.has(tag)) {
|
||||
tagDocMap.set(tag, []);
|
||||
}
|
||||
tagDocMap.get(tag)!.push(docId);
|
||||
}
|
||||
|
||||
for (const blob of await blobsFromAssets(
|
||||
pendingAssets,
|
||||
pendingPathBlobIdMap
|
||||
)) {
|
||||
importBlobs.set(blob.blobId, blob);
|
||||
}
|
||||
} catch {
|
||||
warnings.push({
|
||||
code: 'bear-bundle-import-failed',
|
||||
message: `Failed to import bundle: ${entry.bundlePath}`,
|
||||
sourcePath: entry.bundlePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const folderHierarchy = buildFolderHierarchyFromTags(tagDocMap);
|
||||
return { docIds, tags: tagDocMap, folderHierarchy };
|
||||
return {
|
||||
docIds,
|
||||
tags: tagDocMap,
|
||||
folderHierarchy,
|
||||
batch: {
|
||||
docs,
|
||||
blobs: Array.from(importBlobs.values()),
|
||||
folders: flattenFolderHierarchy(folderHierarchy),
|
||||
tags: tagsToBatchTags(tagDocMap),
|
||||
warnings,
|
||||
done: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Public API for importing Bear .bear2bk backup archives. */
|
||||
export const BearTransformer = {
|
||||
importBearBackup,
|
||||
planBearBackup,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
type DocMeta,
|
||||
type DocSnapshot,
|
||||
type Schema,
|
||||
Transformer,
|
||||
type Workspace,
|
||||
} from '@blocksuite/store';
|
||||
|
||||
export type ImportBlob = {
|
||||
blobId: string;
|
||||
sourcePath: string;
|
||||
fileName: string;
|
||||
mime: string;
|
||||
bytes: Uint8Array;
|
||||
};
|
||||
|
||||
export type ImportIconData =
|
||||
| {
|
||||
type: 'emoji';
|
||||
unicode: string;
|
||||
}
|
||||
| {
|
||||
type: 'image';
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type ImportFolder = {
|
||||
path: string;
|
||||
name: string;
|
||||
parentPath?: string;
|
||||
pageId?: string;
|
||||
icon?: ImportIconData;
|
||||
};
|
||||
|
||||
export type ImportDoc = {
|
||||
id: string;
|
||||
sourcePath?: string;
|
||||
snapshot: DocSnapshot;
|
||||
meta?: Partial<
|
||||
Pick<
|
||||
DocMeta,
|
||||
'title' | 'createDate' | 'updatedDate' | 'tags' | 'favorite' | 'trash'
|
||||
>
|
||||
>;
|
||||
};
|
||||
|
||||
export type ImportTag = {
|
||||
name: string;
|
||||
docIds: string[];
|
||||
};
|
||||
|
||||
export type ImportIcon = {
|
||||
docId: string;
|
||||
icon: ImportIconData;
|
||||
};
|
||||
|
||||
export type ImportWarning = {
|
||||
code: string;
|
||||
message: string;
|
||||
sourcePath?: string;
|
||||
};
|
||||
|
||||
export type ImportBatch = {
|
||||
docs: ImportDoc[];
|
||||
blobs: ImportBlob[];
|
||||
folders?: ImportFolder[];
|
||||
tags?: ImportTag[];
|
||||
icons?: ImportIcon[];
|
||||
warnings?: ImportWarning[];
|
||||
progress?: {
|
||||
completed: number;
|
||||
total: number;
|
||||
};
|
||||
entryId?: string;
|
||||
isWorkspaceFile?: boolean;
|
||||
done: boolean;
|
||||
};
|
||||
|
||||
export type ImportCommitResult = {
|
||||
docIds: string[];
|
||||
entryId?: string;
|
||||
isWorkspaceFile?: boolean;
|
||||
rootFolderId?: string;
|
||||
warnings: ImportWarning[];
|
||||
};
|
||||
|
||||
export async function blobsFromAssets(
|
||||
assets: ReadonlyMap<string, File>,
|
||||
pathBlobIdMap: ReadonlyMap<string, string> = new Map()
|
||||
) {
|
||||
const sourcePathByBlobId = new Map<string, string>();
|
||||
for (const [path, blobId] of pathBlobIdMap) {
|
||||
sourcePathByBlobId.set(blobId, path);
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
Array.from(
|
||||
assets,
|
||||
async ([blobId, file]): Promise<ImportBlob> => ({
|
||||
blobId,
|
||||
sourcePath: sourcePathByBlobId.get(blobId) ?? file.name,
|
||||
fileName: file.name,
|
||||
mime: file.type,
|
||||
bytes: new Uint8Array(await file.arrayBuffer()),
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function copyToArrayBuffer(bytes: Uint8Array) {
|
||||
const copy = new Uint8Array(bytes.byteLength);
|
||||
copy.set(bytes);
|
||||
return copy.buffer;
|
||||
}
|
||||
|
||||
export async function commitImportBatchToWorkspace(
|
||||
collection: Workspace,
|
||||
schema: Schema,
|
||||
batch: ImportBatch
|
||||
): Promise<ImportCommitResult> {
|
||||
for (const blob of batch.blobs) {
|
||||
await collection.blobSync.set(
|
||||
blob.blobId,
|
||||
new File([copyToArrayBuffer(blob.bytes)], blob.fileName, {
|
||||
type: blob.mime,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const transformer = new Transformer({
|
||||
schema,
|
||||
blobCRUD: collection.blobSync,
|
||||
docCRUD: {
|
||||
create: (id: string) => collection.createDoc(id).getStore({ id }),
|
||||
get: (id: string) => collection.getDoc(id)?.getStore({ id }) ?? null,
|
||||
delete: (id: string) => collection.removeDoc(id),
|
||||
},
|
||||
middlewares: [],
|
||||
});
|
||||
|
||||
const docIds: string[] = [];
|
||||
for (const doc of batch.docs) {
|
||||
const store = await transformer.snapshotToDoc(doc.snapshot);
|
||||
if (!store) continue;
|
||||
docIds.push(store.id);
|
||||
if (doc.meta && Object.keys(doc.meta).length) {
|
||||
collection.meta.setDocMeta(store.id, doc.meta);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
docIds,
|
||||
entryId: batch.entryId,
|
||||
isWorkspaceFile: batch.isWorkspaceFile,
|
||||
warnings: batch.warnings ?? [],
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,22 @@
|
||||
export { BearTransformer } from './bear.js';
|
||||
export { DocxTransformer } from './docx.js';
|
||||
export { HtmlTransformer } from './html.js';
|
||||
export {
|
||||
blobsFromAssets,
|
||||
commitImportBatchToWorkspace,
|
||||
type ImportBatch,
|
||||
type ImportBlob,
|
||||
type ImportCommitResult,
|
||||
type ImportDoc,
|
||||
type ImportFolder,
|
||||
type ImportIcon,
|
||||
type ImportIconData,
|
||||
type ImportTag,
|
||||
type ImportWarning,
|
||||
} from './import-batch.js';
|
||||
export { MarkdownTransformer } from './markdown.js';
|
||||
export { NotionHtmlTransformer } from './notion-html.js';
|
||||
export { ObsidianTransformer } from './obsidian.js';
|
||||
export { PdfTransformer } from './pdf.js';
|
||||
export { createAssetsArchive, download } from './utils.js';
|
||||
export { createAssetsArchive, download, Unzip } from './utils.js';
|
||||
export { ZipTransformer } from './zip.js';
|
||||
|
||||
@@ -16,6 +16,7 @@ import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { sha } from '@blocksuite/global/utils';
|
||||
import type {
|
||||
DocMeta,
|
||||
DocSnapshot,
|
||||
ExtensionType,
|
||||
Schema,
|
||||
Store,
|
||||
@@ -23,6 +24,12 @@ import type {
|
||||
} from '@blocksuite/store';
|
||||
import { extMimeMap, Transformer } from '@blocksuite/store';
|
||||
|
||||
import {
|
||||
blobsFromAssets,
|
||||
type ImportBatch,
|
||||
type ImportDoc,
|
||||
type ImportFolder,
|
||||
} from './import-batch.js';
|
||||
import type { AssetMap, ImportedFileEntry, PathBlobIdMap } from './type.js';
|
||||
import { createAssetsArchive, download, parseMatter, Unzip } from './utils.js';
|
||||
|
||||
@@ -438,6 +445,14 @@ function prepareNotionMarkdownFile({
|
||||
};
|
||||
}
|
||||
|
||||
function applySnapshotTitle(snapshot: DocSnapshot, title: string) {
|
||||
snapshot.meta.title = title;
|
||||
snapshot.blocks.props.title = {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: [{ insert: title }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters hidden/system entries that should never participate in imports.
|
||||
*/
|
||||
@@ -685,7 +700,7 @@ async function importMarkdownToDoc({
|
||||
* @param options.imported The zip file as a Blob
|
||||
* @returns A Promise that resolves to an array of IDs of the newly created docs
|
||||
*/
|
||||
type FolderHierarchy = {
|
||||
export type FolderHierarchy = {
|
||||
name: string;
|
||||
path: string;
|
||||
children: Map<string, FolderHierarchy>;
|
||||
@@ -693,18 +708,19 @@ type FolderHierarchy = {
|
||||
parentPath?: string;
|
||||
};
|
||||
|
||||
export type ImportMarkdownZipResult = {
|
||||
export type PlanMarkdownZipResult = {
|
||||
docIds: string[];
|
||||
folderHierarchy?: FolderHierarchy;
|
||||
batch: ImportBatch;
|
||||
};
|
||||
|
||||
async function importMarkdownZip({
|
||||
async function planMarkdownZip({
|
||||
collection,
|
||||
schema,
|
||||
imported,
|
||||
extensions,
|
||||
}: ImportMarkdownZipOptions): Promise<ImportMarkdownZipResult> {
|
||||
return importMarkdownZipInternal({
|
||||
}: ImportMarkdownZipOptions): Promise<PlanMarkdownZipResult> {
|
||||
return planMarkdownZipInternal({
|
||||
collection,
|
||||
schema,
|
||||
imported,
|
||||
@@ -712,13 +728,13 @@ async function importMarkdownZip({
|
||||
});
|
||||
}
|
||||
|
||||
async function importNotionMarkdownZip({
|
||||
async function planNotionMarkdownZip({
|
||||
collection,
|
||||
schema,
|
||||
imported,
|
||||
extensions,
|
||||
}: ImportMarkdownZipOptions): Promise<ImportMarkdownZipResult> {
|
||||
return importMarkdownZipInternal({
|
||||
}: ImportMarkdownZipOptions): Promise<PlanMarkdownZipResult> {
|
||||
return planMarkdownZipInternal({
|
||||
collection,
|
||||
schema,
|
||||
imported,
|
||||
@@ -731,7 +747,7 @@ async function importNotionMarkdownZip({
|
||||
});
|
||||
}
|
||||
|
||||
async function importMarkdownZipInternal({
|
||||
async function planMarkdownZipInternal({
|
||||
collection,
|
||||
schema,
|
||||
imported,
|
||||
@@ -741,12 +757,13 @@ async function importMarkdownZipInternal({
|
||||
prepareMarkdownFile = prepareDefaultMarkdownFile,
|
||||
preserveCommonRoot = false,
|
||||
recursiveZip = false,
|
||||
}: ImportMarkdownZipInternalOptions): Promise<ImportMarkdownZipResult> {
|
||||
}: ImportMarkdownZipInternalOptions): Promise<PlanMarkdownZipResult> {
|
||||
const provider = getProvider([
|
||||
markdownZipDocLinkToDeltaMatcher,
|
||||
...extensions,
|
||||
]);
|
||||
const docIds: string[] = [];
|
||||
const docs: ImportDoc[] = [];
|
||||
const pendingAssets: AssetMap = new Map();
|
||||
const pendingPathBlobIdMap: PathBlobIdMap = new Map();
|
||||
const docPathMap: Array<{ fullPath: string; docId: string }> = [];
|
||||
@@ -812,16 +829,20 @@ async function importMarkdownZipInternal({
|
||||
assets: job.assetsManager,
|
||||
});
|
||||
snapshot.meta.id = pageId;
|
||||
const doc = await job.snapshotToDoc(snapshot);
|
||||
if (doc) {
|
||||
applyMetaPatch(collection, doc.id, meta);
|
||||
docIds.push(doc.id);
|
||||
docPathMap.push({ fullPath, docId: doc.id });
|
||||
}
|
||||
applySnapshotTitle(snapshot, preferredTitle);
|
||||
docs.push({
|
||||
id: pageId,
|
||||
snapshot,
|
||||
meta: {
|
||||
...meta,
|
||||
title: preferredTitle,
|
||||
},
|
||||
});
|
||||
docIds.push(pageId);
|
||||
docPathMap.push({ fullPath, docId: pageId });
|
||||
})
|
||||
);
|
||||
|
||||
// Build folder hierarchy from zip paths
|
||||
const folderHierarchy = buildMarkdownZipFolderHierarchy(
|
||||
docPathMap,
|
||||
normalizeFolderName,
|
||||
@@ -829,7 +850,18 @@ async function importMarkdownZipInternal({
|
||||
createRootFolderForTopLevelDocs
|
||||
);
|
||||
|
||||
return { docIds, folderHierarchy };
|
||||
return {
|
||||
docIds,
|
||||
folderHierarchy,
|
||||
batch: {
|
||||
docs,
|
||||
blobs: await blobsFromAssets(pendingAssets, pendingPathBlobIdMap),
|
||||
folders: folderHierarchy
|
||||
? flattenFolderHierarchy(folderHierarchy)
|
||||
: undefined,
|
||||
done: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -921,10 +953,34 @@ function buildMarkdownZipFolderHierarchy(
|
||||
return root.children.size > 0 ? root : undefined;
|
||||
}
|
||||
|
||||
function flattenFolderHierarchy(root: FolderHierarchy): ImportFolder[] {
|
||||
const folders: ImportFolder[] = [];
|
||||
|
||||
const visit = (node: FolderHierarchy) => {
|
||||
if (node.name) {
|
||||
folders.push({
|
||||
path: node.path,
|
||||
name: node.name,
|
||||
parentPath: node.parentPath,
|
||||
pageId: node.pageId,
|
||||
});
|
||||
}
|
||||
for (const child of node.children.values()) {
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
|
||||
for (const child of root.children.values()) {
|
||||
visit(child);
|
||||
}
|
||||
|
||||
return folders;
|
||||
}
|
||||
|
||||
export const MarkdownTransformer = {
|
||||
exportDoc,
|
||||
importMarkdownToBlock,
|
||||
importMarkdownToDoc,
|
||||
importMarkdownZip,
|
||||
importNotionMarkdownZip,
|
||||
planMarkdownZip,
|
||||
planNotionMarkdownZip,
|
||||
};
|
||||
|
||||
@@ -12,6 +12,14 @@ import {
|
||||
type Workspace,
|
||||
} from '@blocksuite/store';
|
||||
|
||||
import {
|
||||
blobsFromAssets,
|
||||
type ImportBatch,
|
||||
type ImportDoc,
|
||||
type ImportFolder,
|
||||
type ImportIconData,
|
||||
type ImportWarning,
|
||||
} from './import-batch.js';
|
||||
import { Unzip } from './utils.js';
|
||||
|
||||
type ImportNotionZipOptions = {
|
||||
@@ -21,12 +29,12 @@ type ImportNotionZipOptions = {
|
||||
extensions: ExtensionType[];
|
||||
};
|
||||
|
||||
type PageIcon = {
|
||||
export type PageIcon = {
|
||||
type: 'emoji' | 'image';
|
||||
content: string; // emoji unicode or image URL/data
|
||||
content: string;
|
||||
};
|
||||
|
||||
type FolderHierarchy = {
|
||||
export type FolderHierarchy = {
|
||||
name: string;
|
||||
path: string;
|
||||
children: Map<string, FolderHierarchy>;
|
||||
@@ -35,12 +43,13 @@ type FolderHierarchy = {
|
||||
icon?: PageIcon;
|
||||
};
|
||||
|
||||
type ImportNotionZipResult = {
|
||||
export type PlanNotionHtmlZipResult = {
|
||||
entryId: string | undefined;
|
||||
pageIds: string[];
|
||||
isWorkspaceFile: boolean;
|
||||
hasMarkdown: boolean;
|
||||
folderHierarchy?: FolderHierarchy;
|
||||
batch: ImportBatch;
|
||||
};
|
||||
|
||||
function getProvider(extensions: ExtensionType[]) {
|
||||
@@ -61,22 +70,9 @@ function parseFolderPath(filePath: string): {
|
||||
}
|
||||
|
||||
function extractPageIcon(doc: Document): PageIcon | undefined {
|
||||
// Look for Notion page icon in the HTML
|
||||
// Notion export format: <div class="page-header-icon undefined"><span class="icon">✅</span></div>
|
||||
|
||||
console.log('=== Extracting page icon ===');
|
||||
|
||||
// Check if there's a head section with title for debugging
|
||||
const headTitle = doc.querySelector('head title');
|
||||
if (headTitle) {
|
||||
console.log('Page title from head:', headTitle.textContent);
|
||||
}
|
||||
|
||||
// Look for the exact Notion export structure: .page-header-icon .icon
|
||||
const notionIconSpan = doc.querySelector('.page-header-icon .icon');
|
||||
if (notionIconSpan && notionIconSpan.textContent) {
|
||||
const iconContent = notionIconSpan.textContent.trim();
|
||||
console.log('Found Notion icon (.page-header-icon .icon):', iconContent);
|
||||
if (/\p{Emoji}/u.test(iconContent)) {
|
||||
return {
|
||||
type: 'emoji',
|
||||
@@ -85,50 +81,29 @@ function extractPageIcon(doc: Document): PageIcon | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
// Look for page header area for debugging
|
||||
const pageHeader = doc.querySelector('.page-header-icon');
|
||||
if (pageHeader) {
|
||||
console.log(
|
||||
'Found .page-header-icon:',
|
||||
pageHeader.outerHTML.substring(0, 300) + '...'
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: try to find emoji icons with older selectors
|
||||
const emojiIcon = doc.querySelector('.page-header-icon .notion-emoji');
|
||||
if (emojiIcon && emojiIcon.textContent) {
|
||||
console.log(
|
||||
'Found emoji icon (.page-header-icon .notion-emoji):',
|
||||
emojiIcon.textContent
|
||||
);
|
||||
return {
|
||||
type: 'emoji',
|
||||
content: emojiIcon.textContent.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// Try alternative emoji selectors
|
||||
const altEmojiIcon = doc.querySelector('[role="img"][aria-label]');
|
||||
if (
|
||||
altEmojiIcon &&
|
||||
altEmojiIcon.textContent &&
|
||||
/\p{Emoji}/u.test(altEmojiIcon.textContent)
|
||||
) {
|
||||
console.log(
|
||||
'Found emoji icon ([role="img"][aria-label]):',
|
||||
altEmojiIcon.textContent
|
||||
);
|
||||
return {
|
||||
type: 'emoji',
|
||||
content: altEmojiIcon.textContent.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// Look for image icons in the page header
|
||||
const imageIcon = doc.querySelector('.page-header-icon img');
|
||||
if (imageIcon) {
|
||||
const src = imageIcon.getAttribute('src');
|
||||
console.log('Found image icon (.page-header-icon img):', src);
|
||||
if (src) {
|
||||
return {
|
||||
type: 'image',
|
||||
@@ -137,27 +112,15 @@ function extractPageIcon(doc: Document): PageIcon | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Look for any span with emoji class "icon" in page header area
|
||||
const iconSpans = doc.querySelectorAll('span.icon');
|
||||
for (const span of iconSpans) {
|
||||
if (span.textContent && /\p{Emoji}/u.test(span.textContent.trim())) {
|
||||
const parent = span.parentElement;
|
||||
console.log(
|
||||
'Found emoji in span.icon:',
|
||||
span.textContent,
|
||||
'parent classes:',
|
||||
parent?.className
|
||||
);
|
||||
// Check if this is in a page header context
|
||||
if (
|
||||
parent &&
|
||||
(parent.classList.contains('page-header-icon') ||
|
||||
parent.closest('.page-header-icon'))
|
||||
) {
|
||||
console.log(
|
||||
'Using emoji from span.icon in page header:',
|
||||
span.textContent
|
||||
);
|
||||
return {
|
||||
type: 'emoji',
|
||||
content: span.textContent.trim(),
|
||||
@@ -166,15 +129,11 @@ function extractPageIcon(doc: Document): PageIcon | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Try to find icons in the page title area that might contain emoji
|
||||
const pageTitle = doc.querySelector('.page-title, h1');
|
||||
if (pageTitle && pageTitle.textContent) {
|
||||
console.log('Page title element found:', pageTitle.textContent);
|
||||
const text = pageTitle.textContent.trim();
|
||||
// Check if the title starts with an emoji
|
||||
const emojiMatch = text.match(/^(\p{Emoji}+)/u);
|
||||
if (emojiMatch) {
|
||||
console.log('Found emoji in title:', emojiMatch[1]);
|
||||
return {
|
||||
type: 'emoji',
|
||||
content: emojiMatch[1],
|
||||
@@ -182,7 +141,6 @@ function extractPageIcon(doc: Document): PageIcon | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
console.log('No page icon found');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -200,7 +158,6 @@ function buildFolderHierarchy(
|
||||
let current = root;
|
||||
let currentPath = '';
|
||||
|
||||
// Navigate/create folder structure
|
||||
for (const folderName of folderParts) {
|
||||
const parentPath = currentPath;
|
||||
currentPath = currentPath ? `${currentPath}/${folderName}` : folderName;
|
||||
@@ -216,7 +173,6 @@ function buildFolderHierarchy(
|
||||
current = current.children.get(folderName)!;
|
||||
}
|
||||
|
||||
// If this is a page file, associate it with the current folder
|
||||
if (fileName.endsWith('.html') && !fileName.startsWith('index.html')) {
|
||||
const pageName = fileName.replace(/\.html$/, '');
|
||||
if (!current.children.has(pageName)) {
|
||||
@@ -229,7 +185,6 @@ function buildFolderHierarchy(
|
||||
icon: icon,
|
||||
});
|
||||
} else {
|
||||
// Update existing entry with pageId and icon
|
||||
const existingPage = current.children.get(pageName)!;
|
||||
existingPage.pageId = pageId;
|
||||
if (icon) {
|
||||
@@ -242,29 +197,59 @@ function buildFolderHierarchy(
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports a Notion zip file into the BlockSuite collection.
|
||||
*
|
||||
* @param options - The options for importing.
|
||||
* @param options.collection - The BlockSuite document collection.
|
||||
* @param options.schema - The schema of the BlockSuite document collection.
|
||||
* @param options.imported - The imported zip file as a Blob.
|
||||
*
|
||||
* @returns A promise that resolves to an object containing:
|
||||
* - entryId: The ID of the entry page (if any).
|
||||
* - pageIds: An array of imported page IDs.
|
||||
* - isWorkspaceFile: Whether the imported file is a workspace file.
|
||||
* - hasMarkdown: Whether the zip contains markdown files.
|
||||
* - folderHierarchy: The parsed folder hierarchy from the Notion export.
|
||||
*/
|
||||
async function importNotionZip({
|
||||
function toImportIconData(icon?: PageIcon): ImportIconData | undefined {
|
||||
if (!icon) return undefined;
|
||||
if (icon.type === 'emoji') {
|
||||
return {
|
||||
type: 'emoji',
|
||||
unicode: icon.content,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'image',
|
||||
content: icon.content,
|
||||
};
|
||||
}
|
||||
|
||||
function flattenFolderHierarchy(root: FolderHierarchy): ImportFolder[] {
|
||||
const folders: ImportFolder[] = [];
|
||||
|
||||
const visit = (node: FolderHierarchy) => {
|
||||
if (node.name) {
|
||||
folders.push({
|
||||
path: node.path,
|
||||
name: node.name,
|
||||
parentPath: node.parentPath,
|
||||
pageId: node.pageId,
|
||||
icon: toImportIconData(node.icon),
|
||||
});
|
||||
}
|
||||
for (const child of node.children.values()) {
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
|
||||
for (const child of root.children.values()) {
|
||||
visit(child);
|
||||
}
|
||||
|
||||
return folders;
|
||||
}
|
||||
|
||||
async function planNotionHtmlZip({
|
||||
collection,
|
||||
schema,
|
||||
imported,
|
||||
extensions,
|
||||
}: ImportNotionZipOptions): Promise<ImportNotionZipResult> {
|
||||
}: ImportNotionZipOptions): Promise<PlanNotionHtmlZipResult> {
|
||||
const provider = getProvider(extensions);
|
||||
const pageIds: string[] = [];
|
||||
const docs: ImportDoc[] = [];
|
||||
const blobs = new Map<
|
||||
string,
|
||||
Awaited<ReturnType<typeof blobsFromAssets>>[0]
|
||||
>();
|
||||
const warnings: ImportWarning[] = [];
|
||||
let isWorkspaceFile = false;
|
||||
let hasMarkdown = false;
|
||||
let entryId: string | undefined;
|
||||
@@ -280,7 +265,7 @@ async function importNotionZip({
|
||||
const pageMap = new Map<string, string>();
|
||||
const pagePaths: string[] = [];
|
||||
const promises: Promise<void>[] = [];
|
||||
const pendingAssets = new Map<string, Blob>();
|
||||
const pendingAssets = new Map<string, File>();
|
||||
const pendingPathBlobIdMap = new Map<string, string>();
|
||||
for (const { path, content, index } of unzip) {
|
||||
if (path.startsWith('__MACOSX/')) continue;
|
||||
@@ -305,11 +290,7 @@ async function importNotionZip({
|
||||
const text = await content.text();
|
||||
const doc = new DOMParser().parseFromString(text, 'text/html');
|
||||
const pageBody = doc.querySelector('.page-body');
|
||||
if (pageBody && pageBody.children.length === 0) {
|
||||
// Skip empty pages
|
||||
continue;
|
||||
}
|
||||
// Extract page icon from the HTML
|
||||
if (pageBody && pageBody.children.length === 0) continue;
|
||||
pageIcon = extractPageIcon(doc);
|
||||
}
|
||||
|
||||
@@ -327,10 +308,12 @@ async function importNotionZip({
|
||||
continue;
|
||||
}
|
||||
if (index === 0 && fileName.endsWith('.csv')) {
|
||||
window.open(
|
||||
'https://affine.pro/blog/import-your-data-from-notion-into-affine',
|
||||
'_blank'
|
||||
);
|
||||
warnings.push({
|
||||
code: 'notion-csv-export',
|
||||
message:
|
||||
'The imported Notion export appears to be CSV instead of HTML.',
|
||||
sourcePath: path,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (fileName.endsWith('.zip')) {
|
||||
@@ -342,7 +325,7 @@ async function importNotionZip({
|
||||
}
|
||||
const blob = content;
|
||||
const ext = path.split('.').at(-1) ?? '';
|
||||
const mime = extMimeMap.get(ext) ?? '';
|
||||
const mime = extMimeMap.get(ext.toLowerCase()) ?? '';
|
||||
const key = await sha(await blob.arrayBuffer());
|
||||
const filePathSplit = path.split('/');
|
||||
while (filePathSplit.length > 1) {
|
||||
@@ -375,32 +358,57 @@ async function importNotionZip({
|
||||
pathBlobIdMap.set(key, value);
|
||||
}
|
||||
}
|
||||
const page = await htmlAdapter.toDoc({
|
||||
const snapshot = await htmlAdapter.toDocSnapshot({
|
||||
file: await zipFile.get(path)!.text(),
|
||||
pageId: pageMap.get(path),
|
||||
pageMap,
|
||||
assets: job.assetsManager,
|
||||
});
|
||||
if (page) {
|
||||
pageIds.push(page.id);
|
||||
}
|
||||
docs.push({
|
||||
id: snapshot.meta.id,
|
||||
snapshot,
|
||||
});
|
||||
pageIds.push(snapshot.meta.id);
|
||||
});
|
||||
promises.push(...pagePromises);
|
||||
promises.push(
|
||||
blobsFromAssets(pendingAssets, pendingPathBlobIdMap).then(importBlobs => {
|
||||
for (const blob of importBlobs) {
|
||||
blobs.set(blob.blobId, blob);
|
||||
}
|
||||
})
|
||||
);
|
||||
return promises;
|
||||
};
|
||||
const allPromises = await parseZipFile(imported);
|
||||
await Promise.all(allPromises.flat());
|
||||
entryId = entryId ?? pageIds[0];
|
||||
|
||||
// Build folder hierarchy from collected paths
|
||||
const folderHierarchy =
|
||||
pagePathsWithIds.length > 0
|
||||
? buildFolderHierarchy(pagePathsWithIds)
|
||||
: undefined;
|
||||
|
||||
return { entryId, pageIds, isWorkspaceFile, hasMarkdown, folderHierarchy };
|
||||
return {
|
||||
entryId,
|
||||
pageIds,
|
||||
isWorkspaceFile,
|
||||
hasMarkdown,
|
||||
folderHierarchy,
|
||||
batch: {
|
||||
docs,
|
||||
blobs: Array.from(blobs.values()),
|
||||
folders: folderHierarchy
|
||||
? flattenFolderHierarchy(folderHierarchy)
|
||||
: undefined,
|
||||
warnings,
|
||||
entryId,
|
||||
isWorkspaceFile,
|
||||
done: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const NotionHtmlTransformer = {
|
||||
importNotionZip,
|
||||
planNotionHtmlZip,
|
||||
};
|
||||
|
||||
@@ -20,7 +20,11 @@ import { extMimeMap, nanoid } from '@blocksuite/store';
|
||||
import type { Html, Text } from 'mdast';
|
||||
|
||||
import {
|
||||
applyMetaPatch,
|
||||
blobsFromAssets,
|
||||
type ImportBatch,
|
||||
type ImportDoc,
|
||||
} from './import-batch.js';
|
||||
import {
|
||||
bindImportedAssetsToJob,
|
||||
createMarkdownImportJob,
|
||||
getProvider,
|
||||
@@ -129,8 +133,8 @@ function preprocessTitleHeader(markdown: string): string {
|
||||
|
||||
function preprocessObsidianCallouts(markdown: string): string {
|
||||
return markdown.replace(
|
||||
/^(> *)\[!([^\]\n]+)\]([+-]?)([^\n]*)/gm,
|
||||
(_, prefix, type, _fold, rest) => {
|
||||
/^(> *)\[!([^\]\n]+)\](?:[+-]?)([^\n]*)/gm,
|
||||
(_, prefix, type, rest) => {
|
||||
const calloutToken =
|
||||
CALLOUT_TYPE_MAP[type.trim().toLowerCase()] ?? DEFAULT_CALLOUT_EMOJI;
|
||||
const title = rest.trim();
|
||||
@@ -688,12 +692,16 @@ export type ImportObsidianVaultResult = {
|
||||
docEmojis: Map<string, string>;
|
||||
};
|
||||
|
||||
export async function importObsidianVault({
|
||||
export type PlanObsidianVaultResult = ImportObsidianVaultResult & {
|
||||
batch: ImportBatch;
|
||||
};
|
||||
|
||||
export async function planObsidianVault({
|
||||
collection,
|
||||
schema,
|
||||
importedFiles,
|
||||
extensions,
|
||||
}: ImportObsidianVaultOptions): Promise<ImportObsidianVaultResult> {
|
||||
}: ImportObsidianVaultOptions): Promise<PlanObsidianVaultResult> {
|
||||
const provider = getProvider([
|
||||
obsidianWikilinkToDeltaMatcher,
|
||||
obsidianAttachmentEmbedMarkdownAdapterMatcher,
|
||||
@@ -701,6 +709,7 @@ export async function importObsidianVault({
|
||||
]);
|
||||
|
||||
const docIds: string[] = [];
|
||||
const docs: ImportDoc[] = [];
|
||||
const docEmojis = new Map<string, string>();
|
||||
const pendingAssets: AssetMap = new Map();
|
||||
const pendingPathBlobIdMap: PathBlobIdMap = new Map();
|
||||
@@ -813,22 +822,31 @@ export async function importObsidianVault({
|
||||
|
||||
if (snapshot) {
|
||||
snapshot.meta.id = predefinedId;
|
||||
const doc = await job.snapshotToDoc(snapshot);
|
||||
if (doc) {
|
||||
applyMetaPatch(collection, doc.id, {
|
||||
...meta,
|
||||
title: preferredTitle,
|
||||
trash: false,
|
||||
});
|
||||
docIds.push(doc.id);
|
||||
}
|
||||
docs.push({
|
||||
id: predefinedId,
|
||||
snapshot,
|
||||
meta: { ...meta, title: preferredTitle, trash: false },
|
||||
});
|
||||
docIds.push(predefinedId);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return { docIds, docEmojis };
|
||||
return {
|
||||
docIds,
|
||||
docEmojis,
|
||||
batch: {
|
||||
docs,
|
||||
blobs: await blobsFromAssets(pendingAssets, pendingPathBlobIdMap),
|
||||
icons: Array.from(docEmojis, ([docId, emoji]) => ({
|
||||
docId,
|
||||
icon: { type: 'emoji', unicode: emoji },
|
||||
})),
|
||||
done: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const ObsidianTransformer = {
|
||||
importObsidianVault,
|
||||
planObsidianVault,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user