2026-06-09 19:58:07 +08:00

845 lines
32 KiB
Rust

// FFI functions are called from generated UniFFI bindings (C-ABI),
// so Rust's dead_code analysis doesn't see the calls.
#![allow(dead_code)]
uniffi::setup_scaffolding!();
pub use zx_document_core::material_type::{MaterialType, PreviewMode};
pub use zx_document_core::image_meta::ImageMeta;
pub use zx_document_core::text::TextStats;
pub use zx_document_core::search::SearchResult;
pub use zx_document_core::anchors::NoteAnchor;
pub use zx_document_core::progress::ReadingPosition;
pub use zx_document_core::events::ReadingEvent;
pub use zx_document_core::reading_material::ReadingMaterialRef;
pub use zx_document_core::events_v2::{ReadingEventV2, ReadingEventTypeV2};
pub use zx_document_core::epub::{EpubMetadata, EpubChapter};
pub use zx_document_core::office::{OfficePreviewConfig, OfficePreviewStrategy};
pub use zx_document_core::pdf::{PdfMetadata, PdfPageText};
pub use zx_document_core::session_v2::{ReadingSessionV2, ReadingSessionStatus};
use zx_document_core::blocks as core_blocks;
// ── V2 Reading Session FFI ──
#[uniffi::export]
fn start_reading_session_v2(material: ReadingMaterialRef, timestamp_ms: i64) -> Result<String, String> {
zx_document_core::session_v2::start_reading_session_v2(material, timestamp_ms)
.map_err(|e| e.to_string())
}
#[uniffi::export]
fn pause_reading_session_v2(session_id: String) -> Result<(), String> {
zx_document_core::session_v2::pause_reading_session_v2(&session_id).map_err(|e| e.to_string())
}
#[uniffi::export]
fn resume_reading_session_v2(session_id: String) -> Result<(), String> {
zx_document_core::session_v2::resume_reading_session_v2(&session_id).map_err(|e| e.to_string())
}
#[uniffi::export]
fn close_reading_session_v2(session_id: String) -> Result<(), String> {
zx_document_core::session_v2::close_reading_session_v2(&session_id).map_err(|e| e.to_string())
}
// ── V2 Reading Event FFI ──
#[uniffi::export]
fn push_material_opened_v2(session_id: String, material_id: String, timestamp_ms: i64) -> Result<ReadingEventV2, String> {
zx_document_core::events_v2::push_material_opened_v2(&session_id, &material_id, timestamp_ms)
.map_err(|e| e.to_string())
}
#[uniffi::export]
fn push_material_closed_v2(session_id: String, material_id: String, active_seconds_delta: u32, timestamp_ms: i64) -> Result<ReadingEventV2, String> {
zx_document_core::events_v2::push_material_closed_v2(&session_id, &material_id, active_seconds_delta, timestamp_ms)
.map_err(|e| e.to_string())
}
#[uniffi::export]
fn push_position_changed_v2(session_id: String, material_id: String, position: ReadingPosition, timestamp_ms: i64) -> Result<ReadingEventV2, String> {
zx_document_core::events_v2::push_position_changed_v2(&session_id, &material_id, position, timestamp_ms)
.map_err(|e| e.to_string())
}
#[uniffi::export]
fn push_heartbeat_v2(session_id: String, material_id: String, active_seconds_delta: u32, position: Option<ReadingPosition>, timestamp_ms: i64) -> Result<ReadingEventV2, String> {
zx_document_core::events_v2::push_heartbeat_v2(&session_id, &material_id, active_seconds_delta, position, timestamp_ms)
.map_err(|e| e.to_string())
}
#[uniffi::export]
fn push_marked_as_read_v2(session_id: String, material_id: String, timestamp_ms: i64) -> Result<ReadingEventV2, String> {
zx_document_core::events_v2::push_marked_as_read_v2(&session_id, &material_id, timestamp_ms)
.map_err(|e| e.to_string())
}
// ── V2 Buffer Management FFI ──
#[uniffi::export]
fn export_pending_events_v2(limit: u32, timestamp_ms: i64) -> Vec<ReadingEventV2> {
zx_document_core::events_v2::export_pending_events_v2(limit, timestamp_ms)
}
#[uniffi::export]
fn reload_stale_events_v2() -> u32 {
zx_document_core::events_v2::reload_stale_events_v2()
}
#[uniffi::export]
fn ack_events_v2(event_ids: Vec<String>) -> u32 {
zx_document_core::events_v2::ack_events_v2(&event_ids)
}
#[uniffi::export]
fn mark_events_failed_v2(event_ids: Vec<String>) -> u32 {
zx_document_core::events_v2::mark_events_failed_v2(&event_ids)
}
// FFI-compatible DocumentBlock (tuple variants, UniFFI proc-macro)
#[derive(Debug, uniffi::Enum)]
pub enum DocumentBlock {
Heading(String, u8, String),
Paragraph(String, String),
List(String, bool, Vec<String>),
CodeBlock(String, Option<String>, String),
Quote(String, String),
Table(String, Vec<String>, Vec<Vec<String>>),
ImageBlock(String, String, Option<String>),
HorizontalRule(String),
}
impl From<core_blocks::DocumentBlock> for DocumentBlock {
fn from(b: core_blocks::DocumentBlock) -> Self {
match b {
core_blocks::DocumentBlock::Heading { id, level, text } => {
DocumentBlock::Heading(id, level, text)
}
core_blocks::DocumentBlock::Paragraph { id, text } => {
DocumentBlock::Paragraph(id, text)
}
core_blocks::DocumentBlock::List { id, ordered, items } => {
DocumentBlock::List(id, ordered, items)
}
core_blocks::DocumentBlock::CodeBlock { id, language, code } => {
DocumentBlock::CodeBlock(id, language, code)
}
core_blocks::DocumentBlock::Quote { id, text } => {
DocumentBlock::Quote(id, text)
}
core_blocks::DocumentBlock::Table { id, headers, rows } => {
DocumentBlock::Table(id, headers, rows)
}
core_blocks::DocumentBlock::Image { id, src, alt } => {
DocumentBlock::ImageBlock(id, src, alt)
}
core_blocks::DocumentBlock::HorizontalRule { id } => {
DocumentBlock::HorizontalRule(id)
}
}
}
}
#[derive(Debug, uniffi::Error)]
pub enum DocumentError {
FileNotFound,
UnsupportedFormat,
ParseError,
InvalidEncoding,
IoError,
}
impl std::fmt::Display for DocumentError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::FileNotFound => write!(f, "File not found"),
Self::UnsupportedFormat => write!(f, "Unsupported format"),
Self::ParseError => write!(f, "Parse error"),
Self::InvalidEncoding => write!(f, "Invalid encoding"),
Self::IoError => write!(f, "IO error"),
}
}
}
impl std::error::Error for DocumentError {}
impl From<zx_document_core::error::DocumentError> for DocumentError {
fn from(e: zx_document_core::error::DocumentError) -> Self {
match e {
zx_document_core::error::DocumentError::FileNotFound(_) => Self::FileNotFound,
zx_document_core::error::DocumentError::UnsupportedFormat(_) => Self::UnsupportedFormat,
zx_document_core::error::DocumentError::ParseError(_) => Self::ParseError,
zx_document_core::error::DocumentError::InvalidEncoding => Self::InvalidEncoding,
zx_document_core::error::DocumentError::IoError(_) => Self::IoError,
}
}
}
#[uniffi::export]
fn detect_material_type(file_path: String) -> Result<MaterialType, DocumentError> {
zx_document_core::material_type::detect_material_type(&file_path).map_err(Into::into)
}
#[uniffi::export]
fn read_image_meta(file_path: String) -> Result<ImageMeta, DocumentError> {
zx_document_core::image_meta::read_image_meta(&file_path).map_err(Into::into)
}
#[uniffi::export]
fn read_text_stats(file_path: String) -> Result<TextStats, DocumentError> {
let content = std::fs::read_to_string(&file_path).map_err(|_| DocumentError::FileNotFound)?;
Ok(zx_document_core::text::text_stats(&content))
}
#[uniffi::export]
fn parse_text(content: String) -> Result<Vec<DocumentBlock>, DocumentError> {
let blocks = zx_document_core::text::parse_text_content(&content);
let result: Vec<DocumentBlock> = blocks.into_iter().map(Into::into).collect();
Ok(result)
}
#[uniffi::export]
fn parse_markdown(content: String) -> Result<Vec<DocumentBlock>, DocumentError> {
let blocks = zx_document_core::markdown::parse_markdown(&content).map_err(|e| match e {
zx_document_core::error::DocumentError::ParseError(_) => DocumentError::ParseError,
_ => DocumentError::ParseError,
})?;
let result: Vec<DocumentBlock> = blocks.into_iter().map(Into::into).collect();
Ok(result)
}
/// Out-pointer free: avoids struct-passing ABI issues
#[no_mangle]
pub extern "C" fn ffi_zx_document_ffi_rustbuffer_free_separate(
capacity: u64,
len: u64,
data: *mut u8,
) {
if data.is_null() { return; }
unsafe {
let _v = Vec::from_raw_parts(data, len as usize, capacity as usize);
// _v drops here, freeing the memory
}
}
/// Workaround: receive raw bytes via separate len/data args, return via out-pointers
/// Avoids all struct-passing ABI issues on ARM64 iOS.
#[no_mangle]
pub extern "C" fn ffi_zx_document_ffi_rustbuffer_from_bytes_separate(
len: i32,
data: *const u8,
out_capacity: *mut u64,
out_len: *mut u64,
out_data: *mut *mut u8,
) {
let mut call_status = uniffi::RustCallStatus::default();
let buf = unsafe {
uniffi::ffi::uniffi_rustbuffer_from_bytes(
uniffi::ForeignBytes::from_raw_parts(data, len),
&mut call_status,
)
};
// Check if allocation succeeded
if call_status.code != uniffi::RustCallStatusCode::Success {
unsafe {
*out_capacity = 0;
*out_len = 0;
*out_data = std::ptr::null_mut();
}
return;
}
unsafe {
*out_capacity = buf.capacity() as u64;
*out_len = buf.len() as u64;
*out_data = buf.data_pointer() as *mut u8;
}
// Transfer ownership to caller — don't drop the buffer
std::mem::forget(buf);
}
/// Full parse_markdown via raw bytes, result via out-pointers — avoids all struct-passing ABI issues
#[no_mangle]
pub extern "C" fn ffi_zx_document_ffi_parse_markdown_separate(
len: i32,
data: *const u8,
out_capacity: *mut u64,
out_len: *mut u64,
out_data: *mut *mut u8,
out_error_code: *mut i8,
) {
let slice = unsafe { std::slice::from_raw_parts(data, len as usize) };
let content = match std::str::from_utf8(slice) {
Ok(s) => s.to_string(),
Err(_) => {
unsafe { *out_error_code = -1; }
return;
}
};
let result = crate::parse_markdown(content);
// Serialize result using UniFFI
use uniffi::LowerReturn;
let lowered = <Result<Vec<DocumentBlock>, DocumentError> as LowerReturn<UniFfiTag>>::lower_return(result);
match lowered {
Ok(buf) => {
unsafe {
*out_capacity = buf.capacity() as u64;
*out_len = buf.len() as u64;
*out_data = buf.data_pointer() as *mut u8;
*out_error_code = 0;
}
std::mem::forget(buf);
}
Err(e) => {
unsafe { *out_error_code = -1; }
}
}
}
#[uniffi::export]
fn search_markdown_blocks(blocks: Vec<DocumentBlock>, query: String) -> Vec<SearchResult> {
let core_blocks: Vec<core_blocks::DocumentBlock> = blocks.into_iter().map(core_block_from_ffi).collect();
zx_document_core::search::search_blocks(&core_blocks, &query)
}
#[uniffi::export]
fn search_text_content(content: String, query: String) -> Vec<SearchResult> {
zx_document_core::search::search_text(&content, &query)
}
#[uniffi::export]
fn search_pdf_pages(page_numbers: Vec<u32>, page_texts: Vec<String>, query: String) -> Vec<SearchResult> {
let pages: Vec<_> = page_numbers.iter().copied()
.zip(page_texts.iter().map(|s| s.as_str()))
.collect();
zx_document_core::search::search_pdf_text(&pages, &query)
}
#[uniffi::export]
fn search_epub_chapters_ffi(chapter_ids: Vec<String>, chapter_texts: Vec<String>, query: String) -> Vec<SearchResult> {
let chapters: Vec<_> = chapter_ids.iter().map(|s| s.clone())
.zip(chapter_texts.iter().map(|s| s.as_str()))
.collect();
zx_document_core::search::search_epub_chapters(&chapters, &query)
}
#[uniffi::export]
fn create_note_anchor(material_id: String, position: Option<ReadingPosition>) -> NoteAnchor {
zx_document_core::anchors::NoteAnchor::from_position(&material_id, position.as_ref())
}
#[uniffi::export]
fn create_note_anchor_from_search(material_id: String, result: SearchResult) -> NoteAnchor {
zx_document_core::anchors::NoteAnchor::from_search_result(&material_id, &result)
}
#[uniffi::export]
fn restore_position_from_anchor(anchor: NoteAnchor) -> Option<ReadingPosition> {
anchor.to_position()
}
#[uniffi::export]
fn read_pdf_metadata_ffi(file_path: String) -> Result<PdfMetadata, DocumentError> {
zx_document_core::pdf::read_pdf_metadata(std::path::Path::new(&file_path)).map_err(Into::into)
}
#[uniffi::export]
fn extract_pdf_text_ffi(file_path: String) -> Result<Vec<PdfPageText>, DocumentError> {
zx_document_core::pdf::extract_pdf_text(std::path::Path::new(&file_path)).map_err(Into::into)
}
#[uniffi::export]
fn read_epub_metadata_ffi(file_path: String) -> Result<EpubMetadata, DocumentError> {
zx_document_core::epub::read_epub_metadata(std::path::Path::new(&file_path)).map_err(Into::into)
}
#[uniffi::export]
fn read_epub_chapters_ffi(file_path: String) -> Result<Vec<EpubChapter>, DocumentError> {
zx_document_core::epub::read_epub_chapters(std::path::Path::new(&file_path)).map_err(Into::into)
}
#[uniffi::export]
fn get_office_preview_config_ffi(material_type: MaterialType, file_size: u64) -> Result<OfficePreviewConfig, DocumentError> {
zx_document_core::office::get_office_preview_config(&material_type, file_size).map_err(Into::into)
}
#[uniffi::export]
fn is_office_type_ffi(material_type: MaterialType) -> bool {
zx_document_core::office::is_office_type(&material_type)
}
#[uniffi::export]
fn cleanup_stale_sessions_ffi(now_ms: i64, max_age_ms: i64) -> u32 {
zx_document_core::session_v2::cleanup_stale_sessions_v2(now_ms, max_age_ms)
}
#[uniffi::export]
fn push_reading_event(event: ReadingEvent) {
zx_document_core::events::push_reading_event(event)
}
#[uniffi::export]
fn update_reading_position(material_id: String, position: ReadingPosition) {
zx_document_core::events::update_reading_position(&material_id, position)
}
#[uniffi::export]
fn export_pending_events() -> Vec<ReadingEvent> {
zx_document_core::events::export_pending_events()
}
#[uniffi::export]
fn clear_exported_events(count: u32) {
zx_document_core::events::clear_exported_events(count as usize)
}
/// Helper: serialize a Result<T, DocumentError> into out-pointers.
/// Generic over T; the RustBuffer methods are accessed via the concrete type
/// after the `lower_return` call.
macro_rules! write_result_to_out {
($result:expr, $out_capacity:ident, $out_len:ident, $out_data:ident, $out_error_code:ident) => {{
use uniffi::LowerReturn;
match <Result<_, DocumentError> as LowerReturn<UniFfiTag>>::lower_return($result) {
Ok(buf) => {
unsafe {
*$out_capacity = buf.capacity() as u64;
*$out_len = buf.len() as u64;
*$out_data = buf.data_pointer() as *mut u8;
*$out_error_code = 0;
}
std::mem::forget(buf);
}
Err(_) => {
unsafe { *$out_error_code = -1; }
}
}
}};
}
/// Helper: read a UTF-8 string from raw bytes, or set error and return false.
unsafe fn read_str_input(len: i32, data: *const u8, out_error_code: *mut i8) -> Option<String> {
let slice = std::slice::from_raw_parts(data, len as usize);
match std::str::from_utf8(slice) {
Ok(s) => Some(s.to_string()),
Err(_) => { *out_error_code = -1; None },
}
}
// ─── Batch 1 out-pointer functions: String input → Result<T, E> output ───
#[no_mangle]
pub extern "C" fn ffi_zx_document_ffi_detect_material_type_separate(
len: i32, data: *const u8,
out_capacity: *mut u64, out_len: *mut u64, out_data: *mut *mut u8, out_error_code: *mut i8,
) {
let file_path = match unsafe { read_str_input(len, data, out_error_code) } {
Some(s) => s, None => return,
};
let result = crate::detect_material_type(file_path);
write_result_to_out!(result, out_capacity, out_len, out_data, out_error_code);
}
#[no_mangle]
pub extern "C" fn ffi_zx_document_ffi_read_image_meta_separate(
len: i32, data: *const u8,
out_capacity: *mut u64, out_len: *mut u64, out_data: *mut *mut u8, out_error_code: *mut i8,
) {
let file_path = match unsafe { read_str_input(len, data, out_error_code) } {
Some(s) => s, None => return,
};
let result = crate::read_image_meta(file_path);
write_result_to_out!(result, out_capacity, out_len, out_data, out_error_code);
}
#[no_mangle]
pub extern "C" fn ffi_zx_document_ffi_read_text_stats_separate(
len: i32, data: *const u8,
out_capacity: *mut u64, out_len: *mut u64, out_data: *mut *mut u8, out_error_code: *mut i8,
) {
let file_path = match unsafe { read_str_input(len, data, out_error_code) } {
Some(s) => s, None => return,
};
let result = crate::read_text_stats(file_path);
write_result_to_out!(result, out_capacity, out_len, out_data, out_error_code);
}
#[no_mangle]
pub extern "C" fn ffi_zx_document_ffi_parse_text_separate(
len: i32, data: *const u8,
out_capacity: *mut u64, out_len: *mut u64, out_data: *mut *mut u8, out_error_code: *mut i8,
) {
let content = match unsafe { read_str_input(len, data, out_error_code) } {
Some(s) => s, None => return,
};
let result = crate::parse_text(content);
write_result_to_out!(result, out_capacity, out_len, out_data, out_error_code);
}
// ─── Helper: lift from raw buffer fields ───
macro_rules! lift_from_raw {
($T:ty, $capacity:expr, $len:expr, $data:expr) => {{
let v = unsafe { Vec::from_raw_parts($data as *mut u8, $len as usize, $capacity as usize) };
let buf = uniffi::RustBuffer::from_vec(v);
<$T as uniffi::Lift<UniFfiTag>>::try_lift(buf).expect(concat!("failed to lift ", stringify!($T)))
}};
}
// ─── Batch 2: complex type input/output ───
#[no_mangle]
pub extern "C" fn ffi_zx_document_ffi_push_reading_event_separate(
event_cap: u64, event_len: u64, event_data: *const u8,
) {
let event: ReadingEvent = lift_from_raw!(ReadingEvent, event_cap, event_len, event_data);
crate::push_reading_event(event);
}
#[no_mangle]
pub extern "C" fn ffi_zx_document_ffi_update_reading_position_separate(
mid_len: i32, mid_data: *const u8,
pos_cap: u64, pos_len: u64, pos_data: *const u8,
out_error_code: *mut i8,
) {
let material_id = match unsafe { read_str_input(mid_len, mid_data, out_error_code) } {
Some(s) => s, None => return,
};
unsafe { *out_error_code = 0; }
let position: ReadingPosition = lift_from_raw!(ReadingPosition, pos_cap, pos_len, pos_data);
crate::update_reading_position(material_id, position);
}
#[no_mangle]
pub extern "C" fn ffi_zx_document_ffi_export_pending_events_separate(
out_capacity: *mut u64, out_len: *mut u64, out_data: *mut *mut u8, out_error_code: *mut i8,
) {
let result: Vec<ReadingEvent> = crate::export_pending_events();
write_result_to_out!(Ok::<_, DocumentError>(result), out_capacity, out_len, out_data, out_error_code);
}
#[no_mangle]
pub extern "C" fn ffi_zx_document_ffi_create_note_anchor_separate(
mid_len: i32, mid_data: *const u8,
pos_cap: u64, pos_len: u64, pos_data: *const u8, pos_has_value: i8,
out_capacity: *mut u64, out_len: *mut u64, out_data: *mut *mut u8, out_error_code: *mut i8,
) {
let material_id = match unsafe { read_str_input(mid_len, mid_data, out_error_code) } {
Some(s) => s, None => return,
};
let position: Option<ReadingPosition> = if pos_has_value != 0 {
Some(lift_from_raw!(ReadingPosition, pos_cap, pos_len, pos_data))
} else {
None
};
let result: Result<NoteAnchor, DocumentError> = Ok(crate::create_note_anchor(material_id, position));
write_result_to_out!(result, out_capacity, out_len, out_data, out_error_code);
}
// ─── Batch 3: search functions ───
#[no_mangle]
pub extern "C" fn ffi_zx_document_ffi_search_markdown_blocks_separate(
blocks_cap: u64, blocks_len: u64, blocks_data: *const u8,
query_len: i32, query_data: *const u8,
out_capacity: *mut u64, out_len: *mut u64, out_data: *mut *mut u8, out_error_code: *mut i8,
) {
let query = match unsafe { read_str_input(query_len, query_data, out_error_code) } {
Some(s) => s, None => return,
};
let blocks: Vec<DocumentBlock> = lift_from_raw!(Vec<DocumentBlock>, blocks_cap, blocks_len, blocks_data);
let result: Vec<SearchResult> = crate::search_markdown_blocks(blocks, query);
write_result_to_out!(Ok::<_, DocumentError>(result), out_capacity, out_len, out_data, out_error_code);
}
#[no_mangle]
pub extern "C" fn ffi_zx_document_ffi_search_text_content_separate(
content_len: i32, content_data: *const u8,
query_len: i32, query_data: *const u8,
out_capacity: *mut u64, out_len: *mut u64, out_data: *mut *mut u8, out_error_code: *mut i8,
) {
let content = match unsafe { read_str_input(content_len, content_data, out_error_code) } {
Some(s) => s, None => return,
};
let query = match unsafe { read_str_input(query_len, query_data, out_error_code) } {
Some(s) => s, None => return,
};
let result: Vec<SearchResult> = crate::search_text_content(content, query);
write_result_to_out!(Ok::<_, DocumentError>(result), out_capacity, out_len, out_data, out_error_code);
}
#[no_mangle]
pub extern "C" fn ffi_zx_document_ffi_search_pdf_pages_separate(
page_numbers_cap: u64, page_numbers_len: u64, page_numbers_data: *const u8,
page_texts_cap: u64, page_texts_len: u64, page_texts_data: *const u8,
query_len: i32, query_data: *const u8,
out_capacity: *mut u64, out_len: *mut u64, out_data: *mut *mut u8, out_error_code: *mut i8,
) {
let query = match unsafe { read_str_input(query_len, query_data, out_error_code) } {
Some(s) => s, None => return,
};
let page_numbers: Vec<u32> = lift_from_raw!(Vec<u32>, page_numbers_cap, page_numbers_len, page_numbers_data);
let page_texts: Vec<String> = lift_from_raw!(Vec<String>, page_texts_cap, page_texts_len, page_texts_data);
let result: Vec<SearchResult> = crate::search_pdf_pages(page_numbers, page_texts, query);
write_result_to_out!(Ok::<_, DocumentError>(result), out_capacity, out_len, out_data, out_error_code);
}
#[no_mangle]
pub extern "C" fn ffi_zx_document_ffi_search_epub_chapters_ffi_separate(
chapter_ids_cap: u64, chapter_ids_len: u64, chapter_ids_data: *const u8,
chapter_texts_cap: u64, chapter_texts_len: u64, chapter_texts_data: *const u8,
query_len: i32, query_data: *const u8,
out_capacity: *mut u64, out_len: *mut u64, out_data: *mut *mut u8, out_error_code: *mut i8,
) {
let query = match unsafe { read_str_input(query_len, query_data, out_error_code) } {
Some(s) => s, None => return,
};
let chapter_ids: Vec<String> = lift_from_raw!(Vec<String>, chapter_ids_cap, chapter_ids_len, chapter_ids_data);
let chapter_texts: Vec<String> = lift_from_raw!(Vec<String>, chapter_texts_cap, chapter_texts_len, chapter_texts_data);
let result: Vec<SearchResult> = crate::search_epub_chapters_ffi(chapter_ids, chapter_texts, query);
write_result_to_out!(Ok::<_, DocumentError>(result), out_capacity, out_len, out_data, out_error_code);
}
#[no_mangle]
pub extern "C" fn ffi_zx_document_ffi_create_note_anchor_from_search_separate(
mid_len: i32, mid_data: *const u8,
result_cap: u64, result_len: u64, result_data: *const u8,
out_capacity: *mut u64, out_len: *mut u64, out_data: *mut *mut u8, out_error_code: *mut i8,
) {
let material_id = match unsafe { read_str_input(mid_len, mid_data, out_error_code) } {
Some(s) => s, None => return,
};
let search_result: SearchResult = lift_from_raw!(SearchResult, result_cap, result_len, result_data);
let anchor: NoteAnchor = crate::create_note_anchor_from_search(material_id, search_result);
write_result_to_out!(Ok::<_, DocumentError>(anchor), out_capacity, out_len, out_data, out_error_code);
}
// Reverse conversion: FFI DocumentBlock → core DocumentBlock, used by search.
fn core_block_from_ffi(block: DocumentBlock) -> core_blocks::DocumentBlock {
match block {
DocumentBlock::Heading(id, level, text) => {
core_blocks::DocumentBlock::Heading { id, level, text }
}
DocumentBlock::Paragraph(id, text) => {
core_blocks::DocumentBlock::Paragraph { id, text }
}
DocumentBlock::List(id, ordered, items) => {
core_blocks::DocumentBlock::List { id, ordered, items }
}
DocumentBlock::CodeBlock(id, language, code) => {
core_blocks::DocumentBlock::CodeBlock { id, language, code }
}
DocumentBlock::Quote(id, text) => {
core_blocks::DocumentBlock::Quote { id, text }
}
DocumentBlock::Table(id, headers, rows) => {
core_blocks::DocumentBlock::Table { id, headers, rows }
}
DocumentBlock::ImageBlock(id, src, alt) => {
core_blocks::DocumentBlock::Image { id, src, alt }
}
DocumentBlock::HorizontalRule(id) => {
core_blocks::DocumentBlock::HorizontalRule { id }
}
}
}
#[cfg(test)]
mod ffi_tests {
use super::*;
use zx_document_core::events_v2;
fn drain_buffer() {
loop {
let batch = export_pending_events_v2(1000, 0);
if batch.is_empty() { break; }
let ids: Vec<String> = batch.iter().map(|e| e.event_id.clone()).collect();
ack_events_v2(ids);
}
}
// ── V2 Event Pipeline ──
#[test]
fn test_v2_full_event_pipeline() {
drain_buffer();
events_v2::clear_all_events_v2();
let mat = ReadingMaterialRef::new("mat_ffi_test".to_string());
let sid = start_reading_session_v2(mat, 1000).unwrap();
assert!(!sid.is_empty());
let e1 = push_material_opened_v2(sid.clone(), "mat_ffi_test".to_string(), 1000).unwrap();
assert_eq!(e1.event_type, ReadingEventTypeV2::MaterialOpened);
assert_eq!(e1.sequence, 1);
let pos = ReadingPosition::Markdown { block_id: "intro".to_string(), scroll_progress: 0.25 };
let e2 = push_position_changed_v2(sid.clone(), "mat_ffi_test".to_string(), pos, 2000).unwrap();
assert_eq!(e2.event_type, ReadingEventTypeV2::PositionChanged);
assert_eq!(e2.sequence, 2);
let e3 = push_heartbeat_v2(sid.clone(), "mat_ffi_test".to_string(), 15, None, 5000).unwrap();
assert_eq!(e3.event_type, ReadingEventTypeV2::Heartbeat);
assert_eq!(e3.active_seconds_delta, 15);
let e4 = push_marked_as_read_v2(sid.clone(), "mat_ffi_test".to_string(), 10000).unwrap();
assert_eq!(e4.event_type, ReadingEventTypeV2::MarkedAsRead);
push_material_closed_v2(sid.clone(), "mat_ffi_test".to_string(), 0, 12000).unwrap();
close_reading_session_v2(sid.clone()).unwrap();
let exported = export_pending_events_v2(100, 13000);
assert!(exported.len() >= 4, "expected >=4, got {}", exported.len());
let types: Vec<ReadingEventTypeV2> = exported.iter().map(|e| e.event_type.clone()).collect();
assert!(types.contains(&ReadingEventTypeV2::MaterialOpened));
assert!(types.contains(&ReadingEventTypeV2::PositionChanged));
assert!(types.contains(&ReadingEventTypeV2::Heartbeat));
assert!(types.contains(&ReadingEventTypeV2::MarkedAsRead));
let ids: Vec<String> = exported.iter().map(|e| e.event_id.clone()).collect();
let acked = ack_events_v2(ids);
assert!(acked >= 4);
let _ = zx_document_core::session_v2::remove_session_v2(&sid);
}
// ── Session Lifecycle ──
#[test]
fn test_session_lifecycle() {
let mat = ReadingMaterialRef::new("mat_ffi_life".to_string());
let sid = start_reading_session_v2(mat, 0).unwrap();
pause_reading_session_v2(sid.clone()).unwrap();
resume_reading_session_v2(sid.clone()).unwrap();
close_reading_session_v2(sid.clone()).unwrap();
let _ = zx_document_core::session_v2::remove_session_v2(&sid);
}
// ── Buffer Recovery ──
#[test]
fn test_mark_failed_and_recover() {
drain_buffer();
events_v2::clear_all_events_v2();
let mat = ReadingMaterialRef::new("mat_ffi_recover".to_string());
let sid = start_reading_session_v2(mat, 0).unwrap();
let e = push_material_opened_v2(sid.clone(), "mat_ffi_recover".to_string(), 1000).unwrap();
let batch = export_pending_events_v2(100, 2000);
assert!(batch.iter().any(|ev| ev.event_id == e.event_id));
let marked = mark_events_failed_v2(vec![e.event_id.clone()]);
assert_eq!(marked, 1);
let retry = export_pending_events_v2(100, 3000);
assert!(retry.iter().any(|ev| ev.event_id == e.event_id));
ack_events_v2(vec![e.event_id.clone()]);
close_reading_session_v2(sid.clone()).unwrap();
let _ = zx_document_core::session_v2::remove_session_v2(&sid);
}
// ── Parse → Search → Anchor ──
#[test]
fn test_parse_search_anchor() {
let md = "# Hello\n\nParagraph with searchable text.\n\n## Section 2\n\nMore.";
let blocks = parse_markdown(md.to_string()).unwrap();
assert!(!blocks.is_empty());
let results = search_markdown_blocks(blocks, "searchable".to_string());
assert_eq!(results.len(), 1);
assert!(results[0].snippet.to_lowercase().contains("searchable"));
// Position → Anchor
let pos = ReadingPosition::Markdown { block_id: "h1".to_string(), scroll_progress: 0.5 };
let anchor = create_note_anchor("mat_ffi".to_string(), Some(pos));
match &anchor {
NoteAnchor::MarkdownBlock { material_id, block_id, .. } => {
assert_eq!(material_id, "mat_ffi");
assert_eq!(block_id, "h1");
}
_ => panic!("expected MarkdownBlock"),
}
// Anchor → Position (roundtrip)
let restored = restore_position_from_anchor(anchor);
assert!(restored.is_some());
// SearchResult → Anchor
let sr_anchor = create_note_anchor_from_search("mat_ffi".to_string(), results[0].clone());
match sr_anchor {
NoteAnchor::SearchResultAnchor { material_id, .. } => {
assert_eq!(material_id, "mat_ffi");
}
_ => panic!("expected SearchResultAnchor"),
}
}
// ── Text Search ──
#[test]
fn test_text_search() {
let results = search_text_content(
"Line one\nLine two with keyword\nLine three".to_string(),
"keyword".to_string(),
);
assert_eq!(results.len(), 1);
assert_eq!(results[0].line_number, Some(2));
}
// ── PDF Search ──
#[test]
fn test_pdf_search() {
let results = search_pdf_pages(
vec![1, 2, 3],
vec!["Page 1.".to_string(), "Page 2 with target.".to_string(), "Page 3.".to_string()],
"target".to_string(),
);
assert_eq!(results.len(), 1);
assert_eq!(results[0].page_number, Some(2));
}
// ── EPUB Search ──
#[test]
fn test_epub_search() {
let results = search_epub_chapters_ffi(
vec!["intro".to_string(), "ch1".to_string()],
vec!["Welcome.".to_string(), "Chapter one keyword here.".to_string()],
"keyword".to_string(),
);
assert_eq!(results.len(), 1);
assert_eq!(results[0].chapter_id, Some("ch1".to_string()));
}
// ── V1 Backward Compatibility ──
#[test]
fn test_v1_backward_compat() {
let event = ReadingEvent::MaterialOpened {
material_id: "mat_v1_ffi".to_string(),
timestamp_ms: 1000,
};
push_reading_event(event);
let exported = export_pending_events();
assert!(!exported.is_empty());
let found = exported.iter().any(|e| {
matches!(e, ReadingEvent::MaterialOpened { material_id, .. } if material_id == "mat_v1_ffi")
});
assert!(found, "V1 event should be exported");
clear_exported_events(exported.len() as u32);
}
// ── Error Handling ──
#[test]
fn test_session_not_found() {
assert!(close_reading_session_v2("nonexistent".to_string()).is_err());
}
}