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
@@ -7,6 +7,7 @@ import { byokStorageHandlers } from './byok-storage/handlers';
import { clipboardHandlers } from './clipboard';
import { configStorageHandlers } from './config-storage';
import { findInPageHandlers } from './find-in-page';
import { importHandlers } from './import';
import { getLogFilePath, logger, revealLogFile } from './logger';
import { recordingHandlers } from './recording';
import { checkSource } from './security-restrictions';
@@ -39,6 +40,7 @@ export const allHandlers = {
updater: updaterHandlers,
configStorage: configStorageHandlers,
findInPage: findInPageHandlers,
import: importHandlers,
sharedStorage: sharedStorageHandlers,
worker: workerHandlers,
recording: recordingHandlers,
@@ -0,0 +1,39 @@
import type { CreateImportSessionOptions } from '@affine/native';
import {
cancelImportSession,
createImportSession,
disposeImportSession,
nextImportBatch,
} from '@affine/native';
export const importHandlers = {
createImportSession: (
event: Electron.IpcMainInvokeEvent,
options: CreateImportSessionOptions
) => {
void event;
return createImportSession({
format: options.format,
source: options.source,
batchLimits: options.batchLimits,
});
},
nextImportBatch: (event: Electron.IpcMainInvokeEvent, sessionId: string) => {
void event;
return nextImportBatch(sessionId);
},
cancelImportSession: (
event: Electron.IpcMainInvokeEvent,
sessionId: string
) => {
void event;
return cancelImportSession(sessionId);
},
disposeImportSession: (
event: Electron.IpcMainInvokeEvent,
sessionId: string
) => {
void event;
return disposeImportSession(sessionId);
},
};
@@ -0,0 +1 @@
export { importHandlers } from './handlers';
@@ -3,10 +3,14 @@ import type { MessagePort } from 'node:worker_threads';
import type { EventBasedChannel } from 'async-call-rpc';
import { AsyncCall } from 'async-call-rpc';
import { ipcRenderer } from 'electron';
import { ipcRenderer, webUtils } from 'electron';
import { Subject } from 'rxjs';
import { z } from 'zod';
import type {
CreateImportSessionFromSourceOptions,
NativeImportBrowserSource,
} from '../shared/import';
import {
AFFINE_API_CHANNEL_NAME,
AFFINE_EVENT_CHANNEL_NAME,
@@ -275,9 +279,61 @@ function getHelperAPIs() {
const mainAPIs = getMainAPIs();
const helperAPIs = getHelperAPIs();
type DirectoryImportFile = File & { webkitRelativePath?: string };
function filePathFromFile(file: File) {
return webUtils.getPathForFile(file);
}
function directoryPathFromFiles(files: File[]) {
const first = files.find(
(file): file is DirectoryImportFile =>
!!(file as DirectoryImportFile).webkitRelativePath
);
if (!first) return null;
const filePath = filePathFromFile(first);
if (!filePath) return null;
const relativePath = first.webkitRelativePath;
if (!relativePath) return null;
const relativeParts = relativePath.split('/');
let rootPath = filePath.replaceAll('\\', '/');
for (let i = relativeParts.length - 1; i > 0; i--) {
const part = relativeParts[i];
if (part && rootPath.endsWith(`/${part}`)) {
rootPath = rootPath.slice(0, -part.length - 1);
}
}
return rootPath || null;
}
function resolveNativeImportSource(source: NativeImportBrowserSource) {
if (source.kind === 'file') {
const path = filePathFromFile(source.file);
return path ? { kind: 'filePath', path } : null;
}
const path = directoryPathFromFiles(source.files);
return path ? { kind: 'directoryPath', path } : null;
}
export const apis = {
...mainAPIs.apis,
...helperAPIs.apis,
import: {
...mainAPIs.apis.import,
createImportSessionFromSource(
options: CreateImportSessionFromSourceOptions
) {
const source = resolveNativeImportSource(options.source);
if (!source) {
throw new Error('Native import requires a local file source');
}
return mainAPIs.apis.import.createImportSession({
format: options.format,
source,
batchLimits: options.batchLimits,
});
},
},
};
export const events = {
@@ -0,0 +1,28 @@
export type NativeImportFormat =
| 'markdownZip'
| 'notionZip'
| 'obsidian'
| 'bearZip';
export type NativeImportBrowserSource =
| { kind: 'file'; file: File }
| { kind: 'directory'; files: File[] };
export type CreateImportSessionFromSourceOptions = {
format: NativeImportFormat;
source: NativeImportBrowserSource;
batchLimits?: {
maxDocs?: number;
maxBlobs?: number;
maxBlobBytes?: number;
};
};
export type NativeImportSessionHandlers = {
createImportSession(
options: CreateImportSessionFromSourceOptions
): Promise<string> | string;
nextImportBatch(sessionId: string): Promise<string | null> | string | null;
cancelImportSession(sessionId: string): Promise<void> | void;
disposeImportSession(sessionId: string): Promise<void> | void;
};