Files
AFFiNE/blocksuite/affine/shared/src/utils/file/filesys.ts
DarkSky 8d72e4dc29 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 -->
2026-07-06 01:29:24 +08:00

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,
};
}