fix(editor): support relative image reference path when importing zip with images (#12264)

Closes: [BS-3385](https://linear.app/affine-design/issue/BS-3385/markdown类型的导入,支持media文件和md文件不在同目录的情况)

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

- **New Features**
  - Added utility functions to resolve and normalize image file paths in markdown and HTML imports.
  - Introduced middleware to provide full file path context during file import and transformation.
  - Added new types for improved asset and file management in zip imports.

- **Refactor**
  - Centralized and simplified image processing logic across HTML, Markdown, and Notion HTML adapters for improved maintainability.
  - Enhanced type safety and clarity in file and asset handling during zip imports.

- **Tests**
  - Added comprehensive tests for image file path resolution utility.

- **Documentation**
  - Improved inline code comments explaining file path resolution logic.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
donteatfriedrice
2025-05-14 02:30:30 +00:00
parent 2f8d8dbc1e
commit 26ece014f1
13 changed files with 320 additions and 215 deletions

View File

@@ -2,6 +2,7 @@ import {
defaultImageProxyMiddleware,
docLinkBaseURLMiddleware,
fileNameMiddleware,
filePathMiddleware,
HtmlAdapter,
titleMiddleware,
} from '@blocksuite/affine-shared/adapters';
@@ -15,6 +16,7 @@ import type {
} from '@blocksuite/store';
import { extMimeMap, Transformer } from '@blocksuite/store';
import type { AssetMap, ImportedFileEntry, PathBlobIdMap } from './type.js';
import { createAssetsArchive, download, Unzip } from './utils.js';
type ImportHTMLToDocOptions = {
@@ -143,9 +145,9 @@ async function importHTMLZip({
await unzip.load(imported);
const docIds: string[] = [];
const pendingAssets = new Map<string, File>();
const pendingPathBlobIdMap = new Map<string, string>();
const htmlBlobs: [string, Blob][] = [];
const pendingAssets: AssetMap = new Map();
const pendingPathBlobIdMap: PathBlobIdMap = new Map();
const htmlBlobs: ImportedFileEntry[] = [];
for (const { path, content: blob } of unzip) {
if (path.includes('__MACOSX') || path.includes('.DS_Store')) {
@@ -154,7 +156,11 @@ async function importHTMLZip({
const fileName = path.split('/').pop() ?? '';
if (fileName.endsWith('.html')) {
htmlBlobs.push([fileName, blob]);
htmlBlobs.push({
filename: fileName,
contentBlob: blob,
fullPath: path,
});
} else {
const ext = path.split('.').at(-1) ?? '';
const mime = extMimeMap.get(ext) ?? '';
@@ -165,8 +171,9 @@ async function importHTMLZip({
}
await Promise.all(
htmlBlobs.map(async ([fileName, blob]) => {
const fileNameWithoutExt = fileName.replace(/\.[^/.]+$/, '');
htmlBlobs.map(async htmlFile => {
const { filename, contentBlob, fullPath } = htmlFile;
const fileNameWithoutExt = filename.replace(/\.[^/.]+$/, '');
const job = new Transformer({
schema,
blobCRUD: collection.blobSync,
@@ -179,18 +186,19 @@ async function importHTMLZip({
defaultImageProxyMiddleware,
fileNameMiddleware(fileNameWithoutExt),
docLinkBaseURLMiddleware(collection.id),
filePathMiddleware(fullPath),
],
});
const assets = job.assets;
const pathBlobIdMap = job.assetsManager.getPathBlobIdMap();
for (const [key, value] of pendingAssets.entries()) {
assets.set(key, value);
}
for (const [key, value] of pendingPathBlobIdMap.entries()) {
pathBlobIdMap.set(key, value);
for (const [assetPath, key] of pendingPathBlobIdMap.entries()) {
pathBlobIdMap.set(assetPath, key);
if (pendingAssets.get(key)) {
assets.set(key, pendingAssets.get(key)!);
}
}
const htmlAdapter = new HtmlAdapter(job, provider);
const html = await blob.text();
const html = await contentBlob.text();
const doc = await htmlAdapter.toDoc({
file: html,
assets: job.assetsManager,

View File

@@ -2,6 +2,7 @@ import {
defaultImageProxyMiddleware,
docLinkBaseURLMiddleware,
fileNameMiddleware,
filePathMiddleware,
MarkdownAdapter,
titleMiddleware,
} from '@blocksuite/affine-shared/adapters';
@@ -16,6 +17,7 @@ import type {
} from '@blocksuite/store';
import { extMimeMap, Transformer } from '@blocksuite/store';
import type { AssetMap, ImportedFileEntry, PathBlobIdMap } from './type.js';
import { createAssetsArchive, download, Unzip } from './utils.js';
function getProvider(extensions: ExtensionType[]) {
@@ -196,19 +198,28 @@ async function importMarkdownZip({
await unzip.load(imported);
const docIds: string[] = [];
const pendingAssets = new Map<string, File>();
const pendingPathBlobIdMap = new Map<string, string>();
const markdownBlobs: [string, Blob][] = [];
const pendingAssets: AssetMap = new Map();
const pendingPathBlobIdMap: PathBlobIdMap = new Map();
const markdownBlobs: ImportedFileEntry[] = [];
// Iterate over all files in the zip
for (const { path, content: blob } of unzip) {
// Skip the files that are not markdown files
if (path.includes('__MACOSX') || path.includes('.DS_Store')) {
continue;
}
// Get the file name
const fileName = path.split('/').pop() ?? '';
// If the file is a markdown file, store it to markdownBlobs
if (fileName.endsWith('.md')) {
markdownBlobs.push([fileName, blob]);
markdownBlobs.push({
filename: fileName,
contentBlob: blob,
fullPath: path,
});
} else {
// If the file is not a markdown file, store it to pendingAssets
const ext = path.split('.').at(-1) ?? '';
const mime = extMimeMap.get(ext) ?? '';
const key = await sha(await blob.arrayBuffer());
@@ -218,8 +229,9 @@ async function importMarkdownZip({
}
await Promise.all(
markdownBlobs.map(async ([fileName, blob]) => {
const fileNameWithoutExt = fileName.replace(/\.[^/.]+$/, '');
markdownBlobs.map(async markdownFile => {
const { filename, contentBlob, fullPath } = markdownFile;
const fileNameWithoutExt = filename.replace(/\.[^/.]+$/, '');
const job = new Transformer({
schema,
blobCRUD: collection.blobSync,
@@ -232,18 +244,25 @@ async function importMarkdownZip({
defaultImageProxyMiddleware,
fileNameMiddleware(fileNameWithoutExt),
docLinkBaseURLMiddleware(collection.id),
filePathMiddleware(fullPath),
],
});
const assets = job.assets;
const pathBlobIdMap = job.assetsManager.getPathBlobIdMap();
for (const [key, value] of pendingAssets.entries()) {
assets.set(key, value);
}
for (const [key, value] of pendingPathBlobIdMap.entries()) {
pathBlobIdMap.set(key, value);
// Iterate over all assets to be imported
for (const [assetPath, key] of pendingPathBlobIdMap.entries()) {
// Get the relative path of the asset to the markdown file
// Store the path to blobId map
pathBlobIdMap.set(assetPath, key);
// Store the asset to assets, the key is the blobId, the value is the file object
// In block adapter, it will use the blobId to get the file object
if (pendingAssets.get(key)) {
assets.set(key, pendingAssets.get(key)!);
}
}
const mdAdapter = new MarkdownAdapter(job, provider);
const markdown = await blob.text();
const markdown = await contentBlob.text();
const doc = await mdAdapter.toDoc({
file: markdown,
assets: job.assetsManager,

View File

@@ -0,0 +1,25 @@
/**
* Represents an imported file entry in the zip archive
*/
export type ImportedFileEntry = {
/** The filename of the file (e.g. "document.md", "document.html") */
filename: string;
/** The blob containing the file content */
contentBlob: Blob;
/** The full path of the file in the zip archive */
fullPath: string;
};
/**
* Map of asset hash to File object for all media files in the zip
* Key: SHA hash of the file content (blobId)
* Value: File object containing the actual media data
*/
export type AssetMap = Map<string, File>;
/**
* Map of file paths to their corresponding asset hashes
* Key: Original file path in the zip
* Value: SHA hash of the file content (blobId)
*/
export type PathBlobIdMap = Map<string, string>;