#### 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 -->
503 lines
13 KiB
TypeScript
503 lines
13 KiB
TypeScript
// Polyfill for `showOpenFilePicker` API
|
|
// See https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/wicg-file-system-access/index.d.ts
|
|
// See also https://caniuse.com/?search=showOpenFilePicker
|
|
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
|
|
|
interface OpenFilePickerOptions {
|
|
types?:
|
|
| {
|
|
description?: string | undefined;
|
|
accept: Record<string, string | string[]>;
|
|
}[]
|
|
| undefined;
|
|
excludeAcceptAllOption?: boolean | undefined;
|
|
multiple?: boolean | undefined;
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
// Window API: showOpenFilePicker
|
|
showOpenFilePicker?: (
|
|
options?: OpenFilePickerOptions
|
|
) => Promise<FileSystemFileHandle[]>;
|
|
// Window API: showDirectoryPicker
|
|
showDirectoryPicker?: (options?: {
|
|
id?: string;
|
|
mode?: 'read' | 'readwrite';
|
|
startIn?: FileSystemHandle | string;
|
|
}) => Promise<FileSystemDirectoryHandle>;
|
|
}
|
|
}
|
|
|
|
// Minimal polyfill for FileSystemDirectoryHandle to iterate over files
|
|
interface FileSystemDirectoryHandle {
|
|
kind: 'directory';
|
|
name: string;
|
|
values(): AsyncIterableIterator<
|
|
FileSystemFileHandle | FileSystemDirectoryHandle
|
|
>;
|
|
}
|
|
|
|
interface FileSystemFileHandle {
|
|
kind: 'file';
|
|
name: string;
|
|
getFile(): Promise<File>;
|
|
}
|
|
|
|
// See [Common MIME types](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types)
|
|
const FileTypes: NonNullable<OpenFilePickerOptions['types']> = [
|
|
{
|
|
description: 'Images',
|
|
accept: {
|
|
'image/*': [
|
|
'.avif',
|
|
'.gif',
|
|
// '.ico',
|
|
'.jpeg',
|
|
'.jpg',
|
|
'.png',
|
|
'.tif',
|
|
'.tiff',
|
|
// '.svg',
|
|
'.webp',
|
|
],
|
|
},
|
|
},
|
|
{
|
|
description: 'Videos',
|
|
accept: {
|
|
'video/*': [
|
|
'.avi',
|
|
'.mp4',
|
|
'.mpeg',
|
|
'.ogg',
|
|
// '.ts',
|
|
'.webm',
|
|
'.3gp',
|
|
'.3g2',
|
|
],
|
|
},
|
|
},
|
|
{
|
|
description: 'Audios',
|
|
accept: {
|
|
'audio/*': [
|
|
'.aac',
|
|
'.mid',
|
|
'.midi',
|
|
'.mp3',
|
|
'.oga',
|
|
'.opus',
|
|
'.wav',
|
|
'.weba',
|
|
'.3gp',
|
|
'.3g2',
|
|
],
|
|
},
|
|
},
|
|
{
|
|
description: 'Markdown',
|
|
accept: {
|
|
'text/markdown': ['.md', '.markdown'],
|
|
},
|
|
},
|
|
{
|
|
description: 'Html',
|
|
accept: {
|
|
'text/html': ['.html', '.htm'],
|
|
},
|
|
},
|
|
{
|
|
description: 'Zip',
|
|
accept: {
|
|
'application/zip': ['.zip'],
|
|
},
|
|
},
|
|
{
|
|
description: 'Docx',
|
|
accept: {
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
|
|
['.docx'],
|
|
},
|
|
},
|
|
{
|
|
description: 'MindMap',
|
|
accept: {
|
|
'text/xml': ['.mm', '.opml', '.xml'],
|
|
},
|
|
},
|
|
];
|
|
|
|
/**
|
|
* See https://web.dev/patterns/files/open-one-or-multiple-files/
|
|
*/
|
|
type AcceptTypes =
|
|
| 'Any'
|
|
| 'Images'
|
|
| 'Videos'
|
|
| 'Audios'
|
|
| 'Markdown'
|
|
| 'Html'
|
|
| 'Zip'
|
|
| 'Docx'
|
|
| 'MindMap';
|
|
|
|
type OpenFileOptions = {
|
|
fileSystemAccess?: boolean;
|
|
snapshot?: boolean;
|
|
};
|
|
|
|
function copyToArrayBuffer(bytes: Uint8Array) {
|
|
const copy = new Uint8Array(bytes.byteLength);
|
|
copy.set(bytes);
|
|
return copy.buffer;
|
|
}
|
|
|
|
function sleep(ms: number) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
export async function snapshotFile(file: File, relativePath?: string) {
|
|
let bytes: Uint8Array | null = null;
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
try {
|
|
bytes = new Uint8Array(await file.arrayBuffer());
|
|
break;
|
|
} catch (error) {
|
|
if (!isNotReadableError(error) || attempt === 2) {
|
|
throw error;
|
|
}
|
|
await sleep(100 * (attempt + 1));
|
|
}
|
|
}
|
|
if (!bytes) {
|
|
throw new DOMException('File could not be read', 'NotReadableError');
|
|
}
|
|
const snapshot = new File([copyToArrayBuffer(bytes)], file.name, {
|
|
type: file.type,
|
|
lastModified: file.lastModified,
|
|
});
|
|
const path = relativePath ?? file.webkitRelativePath;
|
|
if (path) {
|
|
Object.defineProperty(snapshot, 'webkitRelativePath', {
|
|
value: path,
|
|
writable: false,
|
|
});
|
|
}
|
|
return snapshot;
|
|
}
|
|
|
|
export async function snapshotFiles(files: File[]) {
|
|
return Promise.all(files.map(file => snapshotFile(file)));
|
|
}
|
|
|
|
function canUseFileSystemAccessAPI(
|
|
api: 'showOpenFilePicker' | 'showDirectoryPicker'
|
|
) {
|
|
return (
|
|
api in window &&
|
|
(() => {
|
|
try {
|
|
return window.self === window.top;
|
|
} catch {
|
|
return false;
|
|
}
|
|
})()
|
|
);
|
|
}
|
|
|
|
function isAbortError(error: unknown) {
|
|
return error instanceof Error && error.name === 'AbortError';
|
|
}
|
|
|
|
function isNotReadableError(error: unknown) {
|
|
return error instanceof Error && error.name === 'NotReadableError';
|
|
}
|
|
|
|
export async function openFilesWith(
|
|
acceptType: AcceptTypes = 'Any',
|
|
multiple: boolean = true,
|
|
options?: OpenFileOptions
|
|
): Promise<File[] | null> {
|
|
const supportsFileSystemAccess =
|
|
options?.fileSystemAccess !== false &&
|
|
canUseFileSystemAccessAPI('showOpenFilePicker');
|
|
|
|
// If the File System Access API is supported…
|
|
if (supportsFileSystemAccess && window.showOpenFilePicker) {
|
|
try {
|
|
const fileType = FileTypes.find(i => i.description === acceptType);
|
|
if (acceptType !== 'Any' && !fileType)
|
|
throw new BlockSuiteError(
|
|
ErrorCode.DefaultRuntimeError,
|
|
`Unexpected acceptType "${acceptType}"`
|
|
);
|
|
const pickerOpts = {
|
|
types: fileType ? [fileType] : undefined,
|
|
multiple,
|
|
} satisfies OpenFilePickerOptions;
|
|
// Show the file picker, optionally allowing multiple files.
|
|
const handles = await window.showOpenFilePicker(pickerOpts);
|
|
|
|
const files = await Promise.all(handles.map(handle => handle.getFile()));
|
|
return options?.snapshot ? await snapshotFiles(files) : files;
|
|
} catch (err) {
|
|
if (options?.snapshot && !isAbortError(err)) {
|
|
throw err;
|
|
}
|
|
console.error(err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Fallback if the File System Access API is not supported.
|
|
return new Promise((resolve, reject) => {
|
|
// Append a new `<input type="file" multiple? />` and hide it.
|
|
const input = document.createElement('input');
|
|
input.classList.add('affine-upload-input');
|
|
input.style.display = 'none';
|
|
input.type = 'file';
|
|
input.multiple = multiple;
|
|
|
|
if (acceptType !== 'Any') {
|
|
// For example, `accept="image/*"` or `accept="video/*,audio/*"`.
|
|
input.accept = Object.keys(
|
|
FileTypes.find(i => i.description === acceptType)?.accept ?? ''
|
|
).join(',');
|
|
}
|
|
document.body.append(input);
|
|
// The `change` event fires when the user interacts with the dialog.
|
|
input.addEventListener('change', () => {
|
|
const files = input.files ? Array.from(input.files) : null;
|
|
const finish = (result: File[] | null) => {
|
|
// Remove the `<input type="file" multiple? />` again from the DOM.
|
|
input.remove();
|
|
resolve(result);
|
|
};
|
|
|
|
if (files && options?.snapshot) {
|
|
snapshotFiles(files).then(finish, error => {
|
|
input.remove();
|
|
reject(error);
|
|
});
|
|
} else {
|
|
finish(files);
|
|
}
|
|
});
|
|
// The `cancel` event fires when the user cancels the dialog.
|
|
input.addEventListener('cancel', () => {
|
|
input.remove();
|
|
resolve(null);
|
|
});
|
|
// Show the picker.
|
|
if ('showPicker' in HTMLInputElement.prototype) {
|
|
input.showPicker();
|
|
} else {
|
|
input.click();
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function openDirectory(
|
|
options?: OpenFileOptions
|
|
): Promise<File[] | null> {
|
|
const supportsFileSystemAccess = canUseFileSystemAccessAPI(
|
|
'showDirectoryPicker'
|
|
);
|
|
|
|
if (
|
|
options?.fileSystemAccess !== false &&
|
|
supportsFileSystemAccess &&
|
|
window.showDirectoryPicker
|
|
) {
|
|
try {
|
|
const dirHandle = await window.showDirectoryPicker();
|
|
const files: File[] = [];
|
|
|
|
const readDirectory = async (
|
|
directoryHandle: FileSystemDirectoryHandle,
|
|
path: string
|
|
) => {
|
|
for await (const handle of directoryHandle.values()) {
|
|
const relativePath = path ? `${path}/${handle.name}` : handle.name;
|
|
if (handle.kind === 'file') {
|
|
const fileHandle = handle as FileSystemFileHandle;
|
|
if (fileHandle.getFile) {
|
|
const file = await fileHandle.getFile();
|
|
if (options?.snapshot) {
|
|
files.push(await snapshotFile(file, relativePath));
|
|
} else {
|
|
Object.defineProperty(file, 'webkitRelativePath', {
|
|
value: relativePath,
|
|
writable: false,
|
|
});
|
|
files.push(file);
|
|
}
|
|
}
|
|
} else if (handle.kind === 'directory') {
|
|
await readDirectory(
|
|
handle as FileSystemDirectoryHandle,
|
|
relativePath
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
await readDirectory(dirHandle, '');
|
|
return files;
|
|
} catch (err) {
|
|
if (options?.snapshot && !isAbortError(err)) {
|
|
throw err;
|
|
}
|
|
console.error(err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const input = document.createElement('input');
|
|
input.classList.add('affine-upload-input');
|
|
input.style.display = 'none';
|
|
input.type = 'file';
|
|
|
|
input.setAttribute('webkitdirectory', '');
|
|
input.setAttribute('directory', '');
|
|
|
|
document.body.append(input);
|
|
|
|
input.addEventListener('change', () => {
|
|
const files = input.files ? Array.from(input.files) : null;
|
|
const finish = (result: File[] | null) => {
|
|
input.remove();
|
|
resolve(result);
|
|
};
|
|
|
|
if (files && options?.snapshot) {
|
|
snapshotFiles(files).then(finish, error => {
|
|
input.remove();
|
|
reject(error);
|
|
});
|
|
} else {
|
|
finish(files);
|
|
}
|
|
});
|
|
|
|
input.addEventListener('cancel', () => {
|
|
input.remove();
|
|
resolve(null);
|
|
});
|
|
|
|
if ('showPicker' in HTMLInputElement.prototype) {
|
|
input.showPicker();
|
|
} else {
|
|
input.click();
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function openSingleFileWith(
|
|
acceptType?: AcceptTypes
|
|
): Promise<File | null> {
|
|
const files = await openFilesWith(acceptType, false);
|
|
return files?.at(0) ?? null;
|
|
}
|
|
|
|
export async function getImageFilesFromLocal() {
|
|
const files = await openFilesWith('Images');
|
|
return files ?? [];
|
|
}
|
|
|
|
export function downloadBlob(blob: Blob, name: string) {
|
|
const dataURL = URL.createObjectURL(blob);
|
|
const tmpLink = document.createElement('a');
|
|
const event = new MouseEvent('click');
|
|
tmpLink.download = name;
|
|
tmpLink.href = dataURL;
|
|
tmpLink.dispatchEvent(event);
|
|
|
|
tmpLink.remove();
|
|
URL.revokeObjectURL(dataURL);
|
|
}
|
|
|
|
// Use lru strategy is a better choice, but it's just a temporary solution.
|
|
const MAX_TEMP_DATA_SIZE = 100;
|
|
/**
|
|
* TODO @Saul-Mirone use some other way to store the temp data
|
|
*
|
|
* @deprecated Waiting for migration
|
|
*/
|
|
const tempAttachmentMap = new Map<
|
|
string,
|
|
{
|
|
// name for the attachment
|
|
name: string;
|
|
}
|
|
>();
|
|
const tempImageMap = new Map<
|
|
string,
|
|
{
|
|
// This information comes from pictures.
|
|
// If the user switches between pictures and attachments,
|
|
// this information should be retained.
|
|
width: number | undefined;
|
|
height: number | undefined;
|
|
}
|
|
>();
|
|
|
|
/**
|
|
* Because the image block and attachment block have different props.
|
|
* We need to save some data temporarily when converting between them to ensure no data is lost.
|
|
*
|
|
* For example, before converting from an image block to an attachment block,
|
|
* we need to save the image's width and height.
|
|
*
|
|
* Similarly, when converting from an attachment block to an image block,
|
|
* we need to save the attachment's name.
|
|
*
|
|
* See also https://github.com/toeverything/blocksuite/pull/4583#pullrequestreview-1610662677
|
|
*
|
|
* @internal
|
|
*/
|
|
export function withTempBlobData() {
|
|
const saveAttachmentData = (sourceId: string, data: { name: string }) => {
|
|
if (tempAttachmentMap.size > MAX_TEMP_DATA_SIZE) {
|
|
console.warn(
|
|
'Clear the temp attachment data. It may cause filename loss when converting between image and attachment.'
|
|
);
|
|
tempAttachmentMap.clear();
|
|
}
|
|
|
|
tempAttachmentMap.set(sourceId, data);
|
|
};
|
|
const getAttachmentData = (blockId: string) => {
|
|
const data = tempAttachmentMap.get(blockId);
|
|
tempAttachmentMap.delete(blockId);
|
|
return data;
|
|
};
|
|
|
|
const saveImageData = (
|
|
sourceId: string,
|
|
data: { width: number | undefined; height: number | undefined }
|
|
) => {
|
|
if (tempImageMap.size > MAX_TEMP_DATA_SIZE) {
|
|
console.warn(
|
|
'Clear temp image data. It may cause image width and height loss when converting between image and attachment.'
|
|
);
|
|
tempImageMap.clear();
|
|
}
|
|
|
|
tempImageMap.set(sourceId, data);
|
|
};
|
|
const getImageData = (blockId: string) => {
|
|
const data = tempImageMap.get(blockId);
|
|
tempImageMap.delete(blockId);
|
|
return data;
|
|
};
|
|
return {
|
|
saveAttachmentData,
|
|
getAttachmentData,
|
|
saveImageData,
|
|
getImageData,
|
|
};
|
|
}
|