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:
DarkSky
2026-07-06 01:29:24 +08:00
committed by GitHub
parent 477015f064
commit 8d72e4dc29
106 changed files with 3329 additions and 11690 deletions
@@ -142,6 +142,55 @@ type AcceptTypes =
| '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'
) {
@@ -157,11 +206,21 @@ function canUseFileSystemAccessAPI(
);
}
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
multiple: boolean = true,
options?: OpenFileOptions
): Promise<File[] | null> {
const supportsFileSystemAccess =
options?.fileSystemAccess !== false &&
canUseFileSystemAccessAPI('showOpenFilePicker');
// If the File System Access API is supported…
@@ -180,15 +239,19 @@ export async function openFilesWith(
// Show the file picker, optionally allowing multiple files.
const handles = await window.showOpenFilePicker(pickerOpts);
return await Promise.all(handles.map(handle => handle.getFile()));
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 => {
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');
@@ -205,13 +268,27 @@ export async function openFilesWith(
document.body.append(input);
// The `change` event fires when the user interacts with the dialog.
input.addEventListener('change', () => {
// Remove the `<input type="file" multiple? />` again from the DOM.
input.remove();
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);
};
resolve(input.files ? Array.from(input.files) : null);
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', () => resolve(null));
input.addEventListener('cancel', () => {
input.remove();
resolve(null);
});
// Show the picker.
if ('showPicker' in HTMLInputElement.prototype) {
input.showPicker();
@@ -221,12 +298,18 @@ export async function openFilesWith(
});
}
export async function openDirectory(): Promise<File[] | null> {
export async function openDirectory(
options?: OpenFileOptions
): Promise<File[] | null> {
const supportsFileSystemAccess = canUseFileSystemAccessAPI(
'showDirectoryPicker'
);
if (supportsFileSystemAccess && window.showDirectoryPicker) {
if (
options?.fileSystemAccess !== false &&
supportsFileSystemAccess &&
window.showDirectoryPicker
) {
try {
const dirHandle = await window.showDirectoryPicker();
const files: File[] = [];
@@ -241,11 +324,15 @@ export async function openDirectory(): Promise<File[] | null> {
const fileHandle = handle as FileSystemFileHandle;
if (fileHandle.getFile) {
const file = await fileHandle.getFile();
Object.defineProperty(file, 'webkitRelativePath', {
value: relativePath,
writable: false,
});
files.push(file);
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(
@@ -259,12 +346,15 @@ export async function openDirectory(): Promise<File[] | null> {
await readDirectory(dirHandle, '');
return files;
} catch (err) {
if (options?.snapshot && !isAbortError(err)) {
throw err;
}
console.error(err);
return null;
}
}
return new Promise(resolve => {
return new Promise((resolve, reject) => {
const input = document.createElement('input');
input.classList.add('affine-upload-input');
input.style.display = 'none';
@@ -276,11 +366,26 @@ export async function openDirectory(): Promise<File[] | null> {
document.body.append(input);
input.addEventListener('change', () => {
input.remove();
resolve(input.files ? Array.from(input.files) : null);
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', () => resolve(null));
input.addEventListener('cancel', () => {
input.remove();
resolve(null);
});
if ('showPicker' in HTMLInputElement.prototype) {
input.showPicker();