feat(editor): add Bear backup import and markdown zip folder hierarchy (#14599)

## Summary

- Add Bear `.bear2bk` backup importer (TextBundle-based zip format)
- Enhance markdown zip import to preserve folder structure from zip
paths
- Add colored highlight (`<mark data-color="...">`) support to HTML
adapter

### Bear Import Details

Bear backups are zip archives of TextBundle directories. The importer:
- Parses Bear-specific markdown (highlights `==text==`, callouts `>
[!NOTE]`, inline tags `#tag`)
- Extracts creation/modification dates from `info.json` metadata
- Filters out trashed notes
- Converts Bear tags to AFFiNE tags (consolidated by root segment)
- Builds folder hierarchy from nested tag paths (e.g.,
`#work/projects/alpha`)
- Uses JSZip for lazy decompression to handle large backups without OOM

### Markdown Zip Folder Hierarchy

`importMarkdownZip` now returns `{ docIds, folderHierarchy }` instead of
just `docIds[]`, enabling the UI to recreate the zip's directory
structure as AFFiNE folders.

## Related Issues

- Implements the TextBundle-based import approach suggested in #14115 /
Discussion #14142
- Addresses folder structure preservation requested in #10003
- Partially addresses frontmatter metadata import from #11286

## Test Plan

- [ ] Import a Bear `.bear2bk` backup file via the import dialog
- [ ] Verify tags are created and assigned to documents
- [ ] Verify folder hierarchy matches Bear's nested tag structure
- [ ] Verify creation/modification dates are preserved
- [ ] Verify highlighted text and callouts render correctly
- [ ] Verify images and attachments are imported
- [ ] Import a markdown zip with nested folders, verify folder structure
is recreated
- [ ] Verify trashed Bear notes are excluded

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Bear (.bear2bk) backup import: bulk import notes, convert/dedupe tags,
create nested folders, and return imported doc IDs plus folder
hierarchy; UI import option and progress integrated.
* Markdown ZIP import now returns an optional folder hierarchy alongside
created doc IDs.

* **Bug Fixes / Improvements**
* Highlighting: mark elements validate color names, default safely, and
apply consistent background styling.

* **Chores**
  * Added runtime dependency for ZIP handling.

* **Documentation**
  * Added localization strings and i18n accessors for Bear import UI.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
This commit is contained in:
karl-kaefer
2026-05-07 05:22:44 +02:00
committed by DarkSky
parent 429e7f495d
commit ac37d07e74
10 changed files with 847 additions and 45 deletions

View File

@@ -15,6 +15,7 @@ import {
} from '@affine/core/modules/dialogs';
import { ExplorerIconService } from '@affine/core/modules/explorer-icon/services/explorer-icon';
import { OrganizeService } from '@affine/core/modules/organize';
import { TagService } from '@affine/core/modules/tag';
import { UrlService } from '@affine/core/modules/url';
import {
getAFFiNEWorkspaceSchema,
@@ -27,6 +28,7 @@ import track from '@affine/track';
import { openDirectory, openFilesWith } from '@blocksuite/affine/shared/utils';
import type { Workspace } from '@blocksuite/affine/store';
import {
BearTransformer,
DocxTransformer,
HtmlTransformer,
MarkdownTransformer,
@@ -188,11 +190,49 @@ function createFolderStructure(
return { folderId: rootFolderId, docLinks };
}
/**
* Creates the folder tree described by {@link folderHierarchy} via
* {@link OrganizeService} and links every document into its folder.
* Returns the root folder ID on success, or `undefined` if the
* hierarchy is empty or an error occurs.
*
* When {@link explorerIconService} is provided, document icons from the
* hierarchy (e.g. Notion page emojis) are applied. Callers that do not
* need icon support can omit it safely.
*/
function applyFolderHierarchy(
organizeService: OrganizeService,
folderHierarchy: FolderHierarchy,
explorerIconService?: ExplorerIconService
): string | undefined {
if (folderHierarchy.children.size === 0) return undefined;
try {
const { folderId, docLinks } = createFolderStructure(
organizeService,
folderHierarchy,
null,
explorerIconService
);
for (const { folderId, docId } of docLinks) {
const folder = organizeService.folderTree.folderNode$(folderId).value;
if (folder) {
const index = folder.indexAt('after');
folder.createLink('doc', docId, index);
}
}
return folderId || undefined;
} catch (error) {
logger.warn('Failed to create folder structure:', error);
return undefined;
}
}
type ImportType =
| 'markdown'
| 'markdownZip'
| 'notion'
| 'obsidian'
| 'bear'
| 'snapshot'
| 'html'
| 'docx'
@@ -218,7 +258,8 @@ type ImportConfig = {
files: File[],
handleImportAffineFile: () => Promise<WorkspaceMetadata | undefined>,
organizeService?: OrganizeService,
explorerIconService?: ExplorerIconService
explorerIconService?: ExplorerIconService,
tagService?: TagService
) => Promise<ImportResult>;
};
@@ -290,6 +331,19 @@ const importOptions = [
testId: 'editor-option-menu-import-obsidian',
type: 'obsidian' as ImportType,
},
{
key: 'bear',
label: 'com.affine.import.bear',
prefixIcon: (
<FileIcon color={cssVarV2('icon/primary')} width={20} height={20} />
),
suffixIcon: (
<HelpIcon color={cssVarV2('icon/primary')} width={20} height={20} />
),
suffixTooltip: 'com.affine.import.bear.tooltip',
testId: 'editor-option-menu-import-bear',
type: 'bear' as ImportType,
},
{
key: 'docx',
label: 'com.affine.import.docx',
@@ -365,21 +419,29 @@ const importConfigs: Record<ImportType, ImportConfig> = {
docCollection,
files,
_handleImportAffineFile,
_organizeService,
organizeService,
_explorerIconService
) => {
const file = files.length === 1 ? files[0] : null;
if (!file) {
throw new Error('Expected a single zip file for markdownZip import');
}
const docIds = await MarkdownTransformer.importMarkdownZip({
collection: docCollection,
schema: getAFFiNEWorkspaceSchema(),
imported: file,
extensions: getStoreManager().config.init().value.get('store'),
});
const { docIds, folderHierarchy } =
await MarkdownTransformer.importMarkdownZip({
collection: docCollection,
schema: getAFFiNEWorkspaceSchema(),
imported: file,
extensions: getStoreManager().config.init().value.get('store'),
});
const rootFolderId =
folderHierarchy && organizeService
? applyFolderHierarchy(organizeService, folderHierarchy)
: undefined;
return {
docIds,
rootFolderId,
};
},
},
@@ -431,37 +493,14 @@ const importConfigs: Record<ImportType, ImportConfig> = {
extensions: getStoreManager().config.init().value.get('store'),
});
let rootFolderId: string | undefined;
// Create folder structure if hierarchy exists and OrganizeService is available
if (
folderHierarchy &&
organizeService &&
folderHierarchy.children.size > 0
) {
try {
const { folderId, docLinks } = createFolderStructure(
organizeService,
folderHierarchy,
null,
explorerIconService
);
rootFolderId = folderId || undefined;
// Create links for all documents to their respective folders
for (const { folderId, docId } of docLinks) {
const folder =
organizeService.folderTree.folderNode$(folderId).value;
if (folder) {
const index = folder.indexAt('after');
folder.createLink('doc', docId, index);
}
}
} catch (error) {
logger.warn('Failed to create folder structure:', error);
// Continue with import even if folder creation fails
}
}
const rootFolderId =
folderHierarchy && organizeService
? applyFolderHierarchy(
organizeService,
folderHierarchy,
explorerIconService
)
: undefined;
return {
docIds: pageIds,
@@ -501,6 +540,114 @@ const importConfigs: Record<ImportType, ImportConfig> = {
return { docIds };
},
},
bear: {
fileOptions: { acceptType: 'Zip', multiple: false },
importFunction: async (
docCollection,
files,
_handleImportAffineFile,
organizeService,
_explorerIconService,
tagService
) => {
const file = files.length === 1 ? files[0] : null;
if (!file) {
throw new Error('Expected a single .bear2bk file for Bear import');
}
let docIds: string[];
let tags: Map<string, string[]>;
let folderHierarchy: FolderHierarchy;
try {
const result = await BearTransformer.importBearBackup({
collection: docCollection,
schema: getAFFiNEWorkspaceSchema(),
imported: file,
extensions: getStoreManager().config.init().value.get('store'),
});
docIds = result.docIds;
tags = result.tags;
folderHierarchy = result.folderHierarchy;
} catch (err) {
logger.error('Bear import failed:', err);
throw err instanceof Error
? err
: new Error(String(err) || 'Bear import failed');
}
// Create AFFiNE tags from Bear tags
if (tagService && tags.size > 0) {
try {
// Get existing tags for deduplication
const existingTags = tagService.tagList.tags$.value;
const existingTagMap = new Map<string, string>(); // lowercase name → tag id
for (const tag of existingTags) {
const name = tag.value$.value.toLowerCase();
existingTagMap.set(name, tag.id);
}
// Consolidate tags by root segment (e.g., "privat/bike" → "privat").
// Keyed by lowercase root for case-insensitive dedup, but the
// original capitalization of the first occurrence is preserved
// so new AFFiNE tags are created with the user's casing.
const rootTagDocMap = new Map<
string,
{ displayName: string; docs: Set<string> }
>();
for (const [tagName, tagDocIds] of tags) {
const originalRoot = tagName.split('/')[0];
const key = originalRoot.toLowerCase();
let entry = rootTagDocMap.get(key);
if (!entry) {
entry = { displayName: originalRoot, docs: new Set<string>() };
rootTagDocMap.set(key, entry);
}
for (const docId of tagDocIds) {
entry.docs.add(docId);
}
}
for (const [
rootTagKey,
{ displayName, docs: docIdSet },
] of rootTagDocMap) {
// Check if tag already exists (case-insensitive)
let tagId = existingTagMap.get(rootTagKey);
if (!tagId) {
const newTag = tagService.tagList.createTag(
displayName,
tagService.randomTagColor()
);
tagId = newTag.id;
existingTagMap.set(rootTagKey, tagId);
}
// Assign tag to each doc
for (const docId of docIdSet) {
const doc = docCollection.getDoc(docId);
const currentTags = doc?.meta?.tags ?? [];
if (!currentTags.includes(tagId)) {
docCollection.meta.setDocMeta(docId, {
tags: [...currentTags, tagId],
});
}
}
}
} catch (error) {
logger.warn('Failed to create Bear tags:', error);
}
}
const rootFolderId =
folderHierarchy && organizeService
? applyFolderHierarchy(organizeService, folderHierarchy)
: undefined;
return {
docIds,
rootFolderId,
};
},
},
docx: {
fileOptions: { acceptType: 'Docx', multiple: false },
importFunction: async (docCollection, file) => {
@@ -735,6 +882,7 @@ export const ImportDialog = ({
const docCollection = workspace.docCollection;
const organizeService = useService(OrganizeService);
const explorerIconService = useService(ExplorerIconService);
const tagService = useService(TagService);
const globalDialogService = useService(GlobalDialogService);
@@ -824,7 +972,8 @@ export const ImportDialog = ({
files,
handleImportAffineFile,
organizeService,
explorerIconService
explorerIconService,
tagService
);
setImportResult({
@@ -863,6 +1012,7 @@ export const ImportDialog = ({
explorerIconService,
handleImportAffineFile,
organizeService,
tagService,
t,
]
);

View File

@@ -2462,6 +2462,14 @@ export function useAFFiNEI18N(): {
* `AFFiNE workspace data`
*/
["com.affine.import.affine-workspace-data"](): string;
/**
* `Bear (.bear2bk)`
*/
["com.affine.import.bear"](): string;
/**
* `Import your Bear note backup. Tags will be converted to AFFiNE tags and folders.`
*/
["com.affine.import.bear.tooltip"](): string;
/**
* `Docx`
*/

View File

@@ -614,6 +614,8 @@
"com.affine.import-clipper.dialog.errorLoad": "Failed to load content, please try again.",
"com.affine.import_file": "Support Markdown/Notion",
"com.affine.import.affine-workspace-data": "AFFiNE workspace data",
"com.affine.import.bear": "Bear (.bear2bk)",
"com.affine.import.bear.tooltip": "Import your Bear note backup. Tags will be converted to AFFiNE tags and folders.",
"com.affine.import.docx": "Docx",
"com.affine.import.docx.tooltip": "Import your .docx file.",
"com.affine.import.html-files": "HTML",