mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-03 18:40:00 +08:00
feat(core): onenote importer (#15198)
#### PR Dependency Tree * **PR #15198** 👈 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 OneNote import support for `.one`, `.onetoc2`, and `.onepkg`, including OneNote-to-markdown content conversion. * OneNote now appears in the import flow with a dedicated label and format tooltip, and file pickers recognize the new OneNote type. * **Bug Fixes** * Import options that are desktop-only are now disabled when not running in the desktop app, with clear messaging. * Improved imported asset handling by converting non-image embedded assets into attachments for more consistent results. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
use affine_importer::{
|
||||
ImportBatchLimits, ImportOptions, ImportRegistry, ImportSession, ImportSessionOptions, ImportSource,
|
||||
};
|
||||
use napi::{Status, bindgen_prelude::*};
|
||||
use napi_derive::napi;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
static IMPORT_SESSIONS: Lazy<Mutex<HashMap<String, ImportSession>>> = Lazy::new(|| Mutex::new(HashMap::new()));
|
||||
static NEXT_SESSION_ID: AtomicU64 = AtomicU64::new(1);
|
||||
static IMPORT_REGISTRY: Lazy<Arc<ImportRegistry>> = Lazy::new(|| {
|
||||
let mut registry = ImportRegistry::with_builtin();
|
||||
registry.register(super::OneNoteImportProvider::new());
|
||||
Arc::new(registry)
|
||||
});
|
||||
|
||||
#[napi(object)]
|
||||
pub struct CreateImportSessionOptions {
|
||||
pub format: String,
|
||||
pub source: CreateImportSessionSource,
|
||||
pub batch_limits: Option<CreateImportBatchLimits>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct CreateImportSessionSource {
|
||||
pub kind: String,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct CreateImportBatchLimits {
|
||||
pub max_docs: Option<u32>,
|
||||
pub max_blobs: Option<u32>,
|
||||
pub max_blob_bytes: Option<i64>,
|
||||
}
|
||||
|
||||
fn map_import_error(error: affine_importer::ImportError) -> Error {
|
||||
let status = match error {
|
||||
affine_importer::ImportError::Cancelled => Status::Cancelled,
|
||||
affine_importer::ImportError::UnsupportedFormat(_) | affine_importer::ImportError::InvalidSource(_) => {
|
||||
Status::InvalidArg
|
||||
}
|
||||
affine_importer::ImportError::Zip(_)
|
||||
| affine_importer::ImportError::Io(_)
|
||||
| affine_importer::ImportError::Document(_) => Status::GenericFailure,
|
||||
};
|
||||
Error::new(status, error.to_string())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn create_import_session(options: CreateImportSessionOptions) -> Result<String> {
|
||||
let source = match options.source.kind.as_str() {
|
||||
"filePath" => ImportSource::FilePath(PathBuf::from(options.source.path)),
|
||||
"directoryPath" => ImportSource::DirectoryPath(PathBuf::from(options.source.path)),
|
||||
kind => {
|
||||
return Err(Error::new(
|
||||
Status::InvalidArg,
|
||||
format!("unsupported import source kind: {kind}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
let mut batch_limits = ImportBatchLimits::default();
|
||||
if let Some(limits) = options.batch_limits {
|
||||
if let Some(max_docs) = limits.max_docs {
|
||||
batch_limits.max_docs = max_docs as usize;
|
||||
}
|
||||
if let Some(max_blobs) = limits.max_blobs {
|
||||
batch_limits.max_blobs = max_blobs as usize;
|
||||
}
|
||||
if let Some(max_blob_bytes) = limits.max_blob_bytes {
|
||||
if !(0..=100 * 1024 * 1024).contains(&max_blob_bytes) {
|
||||
return Err(Error::new(Status::InvalidArg, "maxBlobBytes not valid"));
|
||||
}
|
||||
batch_limits.max_blob_bytes = max_blob_bytes as u64;
|
||||
}
|
||||
}
|
||||
let session = ImportSession::create(
|
||||
ImportSessionOptions {
|
||||
format: options.format,
|
||||
source,
|
||||
import_options: ImportOptions::default(),
|
||||
batch_limits,
|
||||
},
|
||||
IMPORT_REGISTRY.clone(),
|
||||
)
|
||||
.map_err(map_import_error)?;
|
||||
let id = format!("import-session-{}", NEXT_SESSION_ID.fetch_add(1, Ordering::Relaxed));
|
||||
IMPORT_SESSIONS
|
||||
.lock()
|
||||
.map_err(|_| Error::new(Status::GenericFailure, "import session registry poisoned"))?
|
||||
.insert(id.clone(), session);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn next_import_batch(session_id: String) -> Result<Option<String>> {
|
||||
let mut sessions = IMPORT_SESSIONS
|
||||
.lock()
|
||||
.map_err(|_| Error::new(Status::GenericFailure, "import session registry poisoned"))?;
|
||||
let session = sessions
|
||||
.get_mut(&session_id)
|
||||
.ok_or_else(|| Error::new(Status::InvalidArg, format!("unknown import session: {session_id}")))?;
|
||||
session
|
||||
.next_batch()
|
||||
.map_err(map_import_error)?
|
||||
.map(|batch| serde_json::to_string(&batch).map_err(|err| Error::new(Status::GenericFailure, err.to_string())))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn cancel_import_session(session_id: String) -> Result<()> {
|
||||
let mut sessions = IMPORT_SESSIONS
|
||||
.lock()
|
||||
.map_err(|_| Error::new(Status::GenericFailure, "import session registry poisoned"))?;
|
||||
let session = sessions
|
||||
.get_mut(&session_id)
|
||||
.ok_or_else(|| Error::new(Status::InvalidArg, format!("unknown import session: {session_id}")))?;
|
||||
session.cancel();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn dispose_import_session(session_id: String) -> Result<()> {
|
||||
IMPORT_SESSIONS
|
||||
.lock()
|
||||
.map_err(|_| Error::new(Status::GenericFailure, "import session registry poisoned"))?
|
||||
.remove(&session_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
fs,
|
||||
io::Write,
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
use zip::{ZipWriter, write::SimpleFileOptions};
|
||||
|
||||
use super::*;
|
||||
|
||||
static NEXT_TEST_ARCHIVE_ID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
fn archive_path(entries: &[(&str, &[u8])]) -> PathBuf {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"affine-native-import-{}-{}.zip",
|
||||
std::process::id(),
|
||||
NEXT_TEST_ARCHIVE_ID.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let file = std::fs::File::create(&path).unwrap();
|
||||
let mut writer = ZipWriter::new(file);
|
||||
for (path, bytes) in entries {
|
||||
writer.start_file(path, SimpleFileOptions::default()).unwrap();
|
||||
writer.write_all(bytes).unwrap();
|
||||
}
|
||||
writer.finish().unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn directory_path(entries: &[(&str, &[u8])]) -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"affine-native-import-dir-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT_TEST_ARCHIVE_ID.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
for (path, bytes) in entries {
|
||||
let path = root.join(path);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, bytes).unwrap();
|
||||
}
|
||||
root
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_session_returns_one_serialized_batch() {
|
||||
let path = archive_path(&[("entry.md", b"entry")]);
|
||||
let id = create_import_session(CreateImportSessionOptions {
|
||||
format: "markdownZip".to_string(),
|
||||
source: CreateImportSessionSource {
|
||||
kind: "filePath".to_string(),
|
||||
path: path.to_string_lossy().to_string(),
|
||||
},
|
||||
batch_limits: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let batch = next_import_batch(id.clone()).unwrap().unwrap();
|
||||
assert!(batch.contains("\"docs\""));
|
||||
assert!(next_import_batch(id.clone()).unwrap().is_none());
|
||||
dispose_import_session(id).unwrap();
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_session_cancel_blocks_next_batch() {
|
||||
let path = archive_path(&[("entry.md", b"entry")]);
|
||||
let id = create_import_session(CreateImportSessionOptions {
|
||||
format: "markdownZip".to_string(),
|
||||
source: CreateImportSessionSource {
|
||||
kind: "filePath".to_string(),
|
||||
path: path.to_string_lossy().to_string(),
|
||||
},
|
||||
batch_limits: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
cancel_import_session(id.clone()).unwrap();
|
||||
assert!(next_import_batch(id.clone()).is_err());
|
||||
dispose_import_session(id).unwrap();
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_session_accepts_directory_source() {
|
||||
let path = directory_path(&[("entry.md", b"entry")]);
|
||||
let id = create_import_session(CreateImportSessionOptions {
|
||||
format: "obsidian".to_string(),
|
||||
source: CreateImportSessionSource {
|
||||
kind: "directoryPath".to_string(),
|
||||
path: path.to_string_lossy().to_string(),
|
||||
},
|
||||
batch_limits: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let batch = next_import_batch(id.clone()).unwrap().unwrap();
|
||||
assert!(batch.contains("\"docs\""));
|
||||
assert!(next_import_batch(id.clone()).unwrap().is_none());
|
||||
dispose_import_session(id).unwrap();
|
||||
let _ = fs::remove_dir_all(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_session_rejects_negative_blob_byte_limit() {
|
||||
let path = archive_path(&[("entry.md", b"entry")]);
|
||||
let result = create_import_session(CreateImportSessionOptions {
|
||||
format: "markdownZip".to_string(),
|
||||
source: CreateImportSessionSource {
|
||||
kind: "filePath".to_string(),
|
||||
path: path.to_string_lossy().to_string(),
|
||||
},
|
||||
batch_limits: Some(CreateImportBatchLimits {
|
||||
max_docs: None,
|
||||
max_blobs: None,
|
||||
max_blob_bytes: Some(-1),
|
||||
}),
|
||||
});
|
||||
|
||||
assert!(result.is_err());
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_session_routes_onenote_format_to_provider() {
|
||||
let path = archive_path(&[("entry.md", b"entry")]);
|
||||
let result = create_import_session(CreateImportSessionOptions {
|
||||
format: "oneNote".to_string(),
|
||||
source: CreateImportSessionSource {
|
||||
kind: "filePath".to_string(),
|
||||
path: path.to_string_lossy().to_string(),
|
||||
},
|
||||
batch_limits: None,
|
||||
});
|
||||
|
||||
let error = result.unwrap_err().to_string();
|
||||
assert!(error.contains("unsupported OneNote source"));
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user