feat: add fingerprint & stable_id, update iOS FFI bindings

This commit is contained in:
wangdl 2026-07-08 19:34:16 +08:00
parent f51e913e58
commit 38d6b60937
24 changed files with 1637 additions and 259 deletions

View File

@ -8,12 +8,13 @@
<key>BinaryPath</key>
<string>libzx_document_ffi.a</string>
<key>LibraryIdentifier</key>
<string>ios-arm64-simulator</string>
<string>ios-arm64_x86_64-simulator</string>
<key>LibraryPath</key>
<string>libzx_document_ffi.a</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
<string>x86_64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -248,6 +248,11 @@ typedef void (*UniffiForeignFutureCompleteVoid)(uint64_t, UniffiForeignFutureRes
uint32_t uniffi_zx_document_ffi_fn_func_ack_events_v2(RustBuffer event_ids, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_BUILD_REPRESENTATION_FINGERPRINT
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_BUILD_REPRESENTATION_FINGERPRINT
RustBuffer uniffi_zx_document_ffi_fn_func_build_representation_fingerprint(RustBuffer source_fingerprint, RustBuffer renderer_kind, RustBuffer parser_version, uint32_t schema_version, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLEANUP_STALE_SESSIONS_FFI
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLEANUP_STALE_SESSIONS_FFI
uint32_t uniffi_zx_document_ffi_fn_func_cleanup_stale_sessions_ffi(int64_t now_ms, int64_t max_age_ms, RustCallStatus *_Nonnull out_status
@ -260,7 +265,12 @@ void uniffi_zx_document_ffi_fn_func_clear_exported_events(uint32_t count, RustCa
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLOSE_READING_SESSION_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLOSE_READING_SESSION_V2
void uniffi_zx_document_ffi_fn_func_close_reading_session_v2(RustBuffer session_id, RustCallStatus *_Nonnull out_status
void uniffi_zx_document_ffi_fn_func_close_reading_session_v2(RustBuffer session_id, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_COMPUTE_FILE_FINGERPRINT
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_COMPUTE_FILE_FINGERPRINT
RustBuffer uniffi_zx_document_ffi_fn_func_compute_file_fingerprint(RustBuffer file_path, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CREATE_NOTE_ANCHOR
@ -292,6 +302,17 @@ RustBuffer uniffi_zx_document_ffi_fn_func_export_pending_events_v2(uint32_t limi
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_EXTRACT_PDF_TEXT_FFI
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_EXTRACT_PDF_TEXT_FFI
RustBuffer uniffi_zx_document_ffi_fn_func_extract_pdf_text_ffi(RustBuffer file_path, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_ACTIVE_SESSION_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_ACTIVE_SESSION_V2
RustBuffer uniffi_zx_document_ffi_fn_func_get_active_session_v2(RustBuffer material_id, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_EVENT_BUFFER_STATE_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_EVENT_BUFFER_STATE_V2
RustBuffer uniffi_zx_document_ffi_fn_func_get_event_buffer_state_v2(RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_OFFICE_PREVIEW_CONFIG_FFI
@ -299,6 +320,11 @@ RustBuffer uniffi_zx_document_ffi_fn_func_extract_pdf_text_ffi(RustBuffer file_p
RustBuffer uniffi_zx_document_ffi_fn_func_get_office_preview_config_ffi(RustBuffer material_type, uint64_t file_size, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_SESSION_V2_FFI
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_SESSION_V2_FFI
RustBuffer uniffi_zx_document_ffi_fn_func_get_session_v2_ffi(RustBuffer session_id, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_IS_OFFICE_TYPE_FFI
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_IS_OFFICE_TYPE_FFI
int8_t uniffi_zx_document_ffi_fn_func_is_office_type_ffi(RustBuffer material_type, RustCallStatus *_Nonnull out_status
@ -309,6 +335,16 @@ int8_t uniffi_zx_document_ffi_fn_func_is_office_type_ffi(RustBuffer material_typ
uint32_t uniffi_zx_document_ffi_fn_func_mark_events_failed_v2(RustBuffer event_ids, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_MARK_SESSION_FAILED_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_MARK_SESSION_FAILED_V2
void uniffi_zx_document_ffi_fn_func_mark_session_failed_v2(RustBuffer session_id, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_MARK_SESSION_INTERRUPTED_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_MARK_SESSION_INTERRUPTED_V2
void uniffi_zx_document_ffi_fn_func_mark_session_interrupted_v2(RustBuffer session_id, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PARSE_MARKDOWN
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PARSE_MARKDOWN
RustBuffer uniffi_zx_document_ffi_fn_func_parse_markdown(RustBuffer content, RustCallStatus *_Nonnull out_status
@ -324,6 +360,16 @@ RustBuffer uniffi_zx_document_ffi_fn_func_parse_text(RustBuffer content, RustCal
void uniffi_zx_document_ffi_fn_func_pause_reading_session_v2(RustBuffer session_id, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_CHECKPOINT_CHANGED_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_CHECKPOINT_CHANGED_V2
RustBuffer uniffi_zx_document_ffi_fn_func_push_checkpoint_changed_v2(RustBuffer session_id, RustBuffer material_id, RustBuffer checkpoint, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_HEARTBEAT_CHECKPOINT_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_HEARTBEAT_CHECKPOINT_V2
RustBuffer uniffi_zx_document_ffi_fn_func_push_heartbeat_checkpoint_v2(RustBuffer session_id, RustBuffer material_id, uint32_t active_seconds_delta, RustBuffer checkpoint, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_HEARTBEAT_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_HEARTBEAT_V2
RustBuffer uniffi_zx_document_ffi_fn_func_push_heartbeat_v2(RustBuffer session_id, RustBuffer material_id, uint32_t active_seconds_delta, RustBuffer position, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
@ -415,6 +461,11 @@ RustBuffer uniffi_zx_document_ffi_fn_func_search_pdf_pages(RustBuffer page_numbe
RustBuffer uniffi_zx_document_ffi_fn_func_search_text_content(RustBuffer content, RustBuffer query, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_SET_SESSION_CLOSED_AT_MS_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_SET_SESSION_CLOSED_AT_MS_V2
void uniffi_zx_document_ffi_fn_func_set_session_closed_at_ms_v2(RustBuffer session_id, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_START_READING_SESSION_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_START_READING_SESSION_V2
RustBuffer uniffi_zx_document_ffi_fn_func_start_reading_session_v2(RustBuffer material, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
@ -689,6 +740,12 @@ void ffi_zx_document_ffi_rust_future_complete_void(uint64_t handle, RustCallStat
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_ACK_EVENTS_V2
uint16_t uniffi_zx_document_ffi_checksum_func_ack_events_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_BUILD_REPRESENTATION_FINGERPRINT
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_BUILD_REPRESENTATION_FINGERPRINT
uint16_t uniffi_zx_document_ffi_checksum_func_build_representation_fingerprint(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CLEANUP_STALE_SESSIONS_FFI
@ -707,6 +764,12 @@ uint16_t uniffi_zx_document_ffi_checksum_func_clear_exported_events(void
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CLOSE_READING_SESSION_V2
uint16_t uniffi_zx_document_ffi_checksum_func_close_reading_session_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_COMPUTE_FILE_FINGERPRINT
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_COMPUTE_FILE_FINGERPRINT
uint16_t uniffi_zx_document_ffi_checksum_func_compute_file_fingerprint(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CREATE_NOTE_ANCHOR
@ -743,12 +806,30 @@ uint16_t uniffi_zx_document_ffi_checksum_func_export_pending_events_v2(void
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_EXTRACT_PDF_TEXT_FFI
uint16_t uniffi_zx_document_ffi_checksum_func_extract_pdf_text_ffi(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_ACTIVE_SESSION_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_ACTIVE_SESSION_V2
uint16_t uniffi_zx_document_ffi_checksum_func_get_active_session_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_EVENT_BUFFER_STATE_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_EVENT_BUFFER_STATE_V2
uint16_t uniffi_zx_document_ffi_checksum_func_get_event_buffer_state_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_OFFICE_PREVIEW_CONFIG_FFI
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_OFFICE_PREVIEW_CONFIG_FFI
uint16_t uniffi_zx_document_ffi_checksum_func_get_office_preview_config_ffi(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_SESSION_V2_FFI
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_SESSION_V2_FFI
uint16_t uniffi_zx_document_ffi_checksum_func_get_session_v2_ffi(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_IS_OFFICE_TYPE_FFI
@ -761,6 +842,18 @@ uint16_t uniffi_zx_document_ffi_checksum_func_is_office_type_ffi(void
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_MARK_EVENTS_FAILED_V2
uint16_t uniffi_zx_document_ffi_checksum_func_mark_events_failed_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_MARK_SESSION_FAILED_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_MARK_SESSION_FAILED_V2
uint16_t uniffi_zx_document_ffi_checksum_func_mark_session_failed_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_MARK_SESSION_INTERRUPTED_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_MARK_SESSION_INTERRUPTED_V2
uint16_t uniffi_zx_document_ffi_checksum_func_mark_session_interrupted_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PARSE_MARKDOWN
@ -779,6 +872,18 @@ uint16_t uniffi_zx_document_ffi_checksum_func_parse_text(void
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PAUSE_READING_SESSION_V2
uint16_t uniffi_zx_document_ffi_checksum_func_pause_reading_session_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_CHECKPOINT_CHANGED_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_CHECKPOINT_CHANGED_V2
uint16_t uniffi_zx_document_ffi_checksum_func_push_checkpoint_changed_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_HEARTBEAT_CHECKPOINT_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_HEARTBEAT_CHECKPOINT_V2
uint16_t uniffi_zx_document_ffi_checksum_func_push_heartbeat_checkpoint_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_HEARTBEAT_V2
@ -887,6 +992,12 @@ uint16_t uniffi_zx_document_ffi_checksum_func_search_pdf_pages(void
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_SEARCH_TEXT_CONTENT
uint16_t uniffi_zx_document_ffi_checksum_func_search_text_content(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_SET_SESSION_CLOSED_AT_MS_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_SET_SESSION_CLOSED_AT_MS_V2
uint16_t uniffi_zx_document_ffi_checksum_func_set_session_closed_at_ms_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_START_READING_SESSION_V2

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -13,6 +13,7 @@ uuid = { version = "1", features = ["v4"] }
zip = "2"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
uniffi = "0.31"
sha2 = "0.10"
[build-dependencies]
uniffi = { version = "0.31", features = ["build"] }

View File

@ -56,6 +56,13 @@ pub enum NoteAnchor {
impl NoteAnchor {
pub fn from_position(material_id: &str, position: Option<&ReadingPosition>) -> Self {
match position {
Some(pos @ ReadingPosition::Block { block_id, .. }) => {
NoteAnchor::MarkdownBlock {
material_id: material_id.to_string(),
block_id: block_id.clone(),
position_snapshot: Some(pos.clone()),
}
}
Some(pos @ ReadingPosition::Markdown { block_id, .. }) => {
NoteAnchor::MarkdownBlock {
material_id: material_id.to_string(),
@ -77,7 +84,17 @@ impl NoteAnchor {
position_snapshot: Some(pos.clone()),
}
}
Some(pos @ ReadingPosition::Image { .. }) => NoteAnchor::Image {
Some(pos @ ReadingPosition::PdfViewport { page_number, .. }) => {
NoteAnchor::PdfPage {
material_id: material_id.to_string(),
page_number: *page_number,
position_snapshot: Some(pos.clone()),
}
}
Some(pos @ ReadingPosition::Image { .. })
| Some(pos @ ReadingPosition::ImageViewport { .. })
| Some(pos @ ReadingPosition::Slide { .. })
| Some(pos @ ReadingPosition::Workbook { .. }) => NoteAnchor::Image {
material_id: material_id.to_string(),
position_snapshot: Some(pos.clone()),
},

View File

@ -2,7 +2,7 @@ use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use crate::progress::ReadingPosition;
use crate::progress::{ReadingCheckpoint, ReadingPosition};
use crate::session_v2;
const MAX_BUFFER_SIZE: usize = 1000;
@ -50,6 +50,7 @@ pub struct ReadingEventV2 {
pub material_id: String,
pub event_type: ReadingEventTypeV2,
pub position: Option<ReadingPosition>,
pub checkpoint: Option<ReadingCheckpoint>,
pub active_seconds_delta: u32,
pub timestamp_ms: i64,
pub sequence: u64,
@ -62,14 +63,16 @@ fn push_event(
material_id: &str,
event_type: ReadingEventTypeV2,
position: Option<ReadingPosition>,
checkpoint: Option<ReadingCheckpoint>,
active_seconds_delta: u32,
timestamp_ms: i64,
) -> Result<ReadingEventV2, session_v2::SessionError> {
let seq = session_v2::record_session_event_v2(
let seq = session_v2::record_session_event_with_checkpoint_v2(
session_id,
timestamp_ms,
active_seconds_delta,
position.clone(),
checkpoint.clone(),
)?;
let event = ReadingEventV2 {
@ -78,6 +81,7 @@ fn push_event(
material_id: material_id.to_string(),
event_type,
position: position.map(|p| p.normalized()),
checkpoint: checkpoint.map(|c| c.normalized()),
active_seconds_delta,
timestamp_ms,
sequence: seq,
@ -115,7 +119,7 @@ pub fn push_material_opened_v2(
material_id: &str,
timestamp_ms: i64,
) -> Result<ReadingEventV2, session_v2::SessionError> {
push_event(session_id, material_id, ReadingEventTypeV2::MaterialOpened, None, 0, timestamp_ms)
push_event(session_id, material_id, ReadingEventTypeV2::MaterialOpened, None, None, 0, timestamp_ms)
}
pub fn push_material_closed_v2(
@ -124,7 +128,7 @@ pub fn push_material_closed_v2(
active_seconds_delta: u32,
timestamp_ms: i64,
) -> Result<ReadingEventV2, session_v2::SessionError> {
push_event(session_id, material_id, ReadingEventTypeV2::MaterialClosed, None, active_seconds_delta, timestamp_ms)
push_event(session_id, material_id, ReadingEventTypeV2::MaterialClosed, None, None, active_seconds_delta, timestamp_ms)
}
pub fn push_position_changed_v2(
@ -133,7 +137,24 @@ pub fn push_position_changed_v2(
position: ReadingPosition,
timestamp_ms: i64,
) -> Result<ReadingEventV2, session_v2::SessionError> {
push_event(session_id, material_id, ReadingEventTypeV2::PositionChanged, Some(position), 0, timestamp_ms)
push_event(session_id, material_id, ReadingEventTypeV2::PositionChanged, Some(position), None, 0, timestamp_ms)
}
pub fn push_checkpoint_changed_v2(
session_id: &str,
material_id: &str,
checkpoint: ReadingCheckpoint,
timestamp_ms: i64,
) -> Result<ReadingEventV2, session_v2::SessionError> {
push_event(
session_id,
material_id,
ReadingEventTypeV2::PositionChanged,
Some(checkpoint.legacy_position()),
Some(checkpoint),
0,
timestamp_ms,
)
}
pub fn push_heartbeat_v2(
@ -143,7 +164,26 @@ pub fn push_heartbeat_v2(
position: Option<ReadingPosition>,
timestamp_ms: i64,
) -> Result<ReadingEventV2, session_v2::SessionError> {
push_event(session_id, material_id, ReadingEventTypeV2::Heartbeat, position, active_seconds_delta, timestamp_ms)
push_event(session_id, material_id, ReadingEventTypeV2::Heartbeat, position, None, active_seconds_delta, timestamp_ms)
}
pub fn push_heartbeat_checkpoint_v2(
session_id: &str,
material_id: &str,
active_seconds_delta: u32,
checkpoint: Option<ReadingCheckpoint>,
timestamp_ms: i64,
) -> Result<ReadingEventV2, session_v2::SessionError> {
let legacy_position = checkpoint.as_ref().map(ReadingCheckpoint::legacy_position);
push_event(
session_id,
material_id,
ReadingEventTypeV2::Heartbeat,
legacy_position,
checkpoint,
active_seconds_delta,
timestamp_ms,
)
}
pub fn push_marked_as_read_v2(
@ -151,7 +191,7 @@ pub fn push_marked_as_read_v2(
material_id: &str,
timestamp_ms: i64,
) -> Result<ReadingEventV2, session_v2::SessionError> {
push_event(session_id, material_id, ReadingEventTypeV2::MarkedAsRead, None, 0, timestamp_ms)
push_event(session_id, material_id, ReadingEventTypeV2::MarkedAsRead, None, None, 0, timestamp_ms)
}
// ── Buffer Management (V2 with ack) ──

View File

@ -0,0 +1,54 @@
use sha2::{Digest, Sha256};
use crate::error::DocumentError;
use crate::progress::RendererKind;
pub const BLOCK_PARSER_VERSION: &str = "block-parser-v1";
pub const WORKBOOK_PARSER_VERSION: &str = "workbook-parser-v1";
pub const SLIDE_PARSER_VERSION: &str = "slide-parser-v1";
pub const PDF_LOCATOR_SCHEMA_VERSION: u32 = 1;
pub const IMAGE_LOCATOR_SCHEMA_VERSION: u32 = 1;
pub fn compute_file_fingerprint(file_path: &str) -> Result<String, DocumentError> {
let bytes = std::fs::read(file_path)?;
Ok(hex_sha256(&bytes))
}
pub fn build_representation_fingerprint(
source_fingerprint: &str,
renderer_kind: &RendererKind,
parser_version: &str,
schema_version: u32,
) -> String {
let payload = format!(
"{source_fingerprint}:{renderer_kind}:{parser_version}:schema-v{schema_version}"
);
hex_sha256(payload.as_bytes())
}
fn hex_sha256(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
format!("{:x}", hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn representation_fingerprint_is_stable() {
let a = build_representation_fingerprint("abc", &RendererKind::Block, BLOCK_PARSER_VERSION, 2);
let b = build_representation_fingerprint("abc", &RendererKind::Block, BLOCK_PARSER_VERSION, 2);
assert_eq!(a, b);
}
#[test]
fn representation_fingerprint_changes_on_inputs() {
let base = build_representation_fingerprint("abc", &RendererKind::Block, BLOCK_PARSER_VERSION, 2);
assert_ne!(base, build_representation_fingerprint("def", &RendererKind::Block, BLOCK_PARSER_VERSION, 2));
assert_ne!(base, build_representation_fingerprint("abc", &RendererKind::Pdf, BLOCK_PARSER_VERSION, 2));
assert_ne!(base, build_representation_fingerprint("abc", &RendererKind::Block, "block-parser-v2", 2));
assert_ne!(base, build_representation_fingerprint("abc", &RendererKind::Block, BLOCK_PARSER_VERSION, 3));
}
}

View File

@ -7,6 +7,7 @@ pub mod epub;
pub mod error;
pub mod events;
pub mod events_v2;
pub mod fingerprint;
pub mod image_meta;
pub mod markdown;
pub mod material_type;
@ -16,5 +17,6 @@ pub mod progress;
pub mod reading_material;
pub mod search;
pub mod session_v2;
pub mod stable_id;
pub mod text;
pub mod time_tracker;

View File

@ -1,12 +1,19 @@
use comrak::nodes::{AstNode, NodeValue};
use comrak::{Arena, ComrakOptions};
use uuid::Uuid;
use crate::blocks::DocumentBlock;
use crate::error::DocumentError;
use crate::stable_id::stable_content_id;
fn block_id() -> String {
Uuid::new_v4().to_string()
/// Generate a stable, deterministic block ID from the block's text content
/// and its zero-based position within the document.
///
/// Uses djb2 — a simple, fixed-algorithm hash — so the same `(text, position)`
/// pair always produces the same hex string **across app launches / processes**.
/// This is critical for reading-position restore: the saved `lastPosition.blockId`
/// must match the id of the same block when the document is re-parsed later.
fn block_id(text: &str, position: usize) -> String {
stable_content_id("markdown", position, text)
}
fn gfm_options() -> ComrakOptions<'static> {
@ -20,12 +27,14 @@ fn gfm_options() -> ComrakOptions<'static> {
/// Parse a Markdown string into a list of DocumentBlock.
pub fn parse_markdown(md_content: &str) -> Result<Vec<DocumentBlock>, DocumentError> {
let start = std::time::Instant::now();
let arena = Arena::new();
let options = gfm_options();
let root = comrak::parse_document(&arena, md_content, &options);
let mut blocks = Vec::new();
collect_blocks(root, &mut blocks);
eprintln!("[Rust] parse_markdown — len={} block_count={} elapsed_ms={}", md_content.len(), blocks.len(), start.elapsed().as_millis());
Ok(blocks)
}
@ -35,14 +44,17 @@ fn collect_blocks<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<DocumentBlock>) {
let value = &data.value;
let block = match value {
NodeValue::Heading(heading) => Some(DocumentBlock::Heading {
id: block_id(),
level: heading.level,
text: collect_text(child),
}),
NodeValue::Heading(heading) => {
let text = collect_text(child);
Some(DocumentBlock::Heading {
id: block_id(&text, blocks.len()),
level: heading.level,
text,
})
}
NodeValue::Paragraph => {
// Check for inline image — extract as standalone Image block
if let Some(image_block) = extract_image_from_paragraph(child) {
if let Some(image_block) = extract_image_from_paragraph(child, blocks.len()) {
Some(image_block)
} else {
let text = collect_text(child);
@ -50,7 +62,7 @@ fn collect_blocks<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<DocumentBlock>) {
None
} else {
Some(DocumentBlock::Paragraph {
id: block_id(),
id: block_id(&text, blocks.len()),
text,
})
}
@ -71,14 +83,14 @@ fn collect_blocks<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<DocumentBlock>) {
None
} else {
Some(DocumentBlock::List {
id: block_id(),
id: block_id(&items.join("\n"), blocks.len()),
ordered: list.list_type == comrak::nodes::ListType::Ordered,
items,
})
}
}
NodeValue::CodeBlock(code) => Some(DocumentBlock::CodeBlock {
id: block_id(),
id: block_id(&code.literal, blocks.len()),
language: if code.info.is_empty() {
None
} else {
@ -92,7 +104,7 @@ fn collect_blocks<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<DocumentBlock>) {
None
} else {
Some(DocumentBlock::Quote {
id: block_id(),
id: block_id(&text, blocks.len()),
text,
})
}
@ -124,7 +136,7 @@ fn collect_blocks<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<DocumentBlock>) {
None
} else {
Some(DocumentBlock::Table {
id: block_id(),
id: block_id(&headers.join("|"), blocks.len()),
headers,
rows,
})
@ -133,7 +145,7 @@ fn collect_blocks<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<DocumentBlock>) {
NodeValue::Image(image) => {
let alt_text = collect_text(child);
Some(DocumentBlock::Image {
id: block_id(),
id: block_id(&image.url, blocks.len()),
src: image.url.clone(),
alt: if alt_text.is_empty() {
None
@ -143,7 +155,7 @@ fn collect_blocks<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<DocumentBlock>) {
})
}
NodeValue::ThematicBreak => Some(DocumentBlock::HorizontalRule {
id: block_id(),
id: block_id("---", blocks.len()),
}),
// Recurse into containers: Document, Item, TableCell, TableRow, etc.
NodeValue::HtmlBlock(block) => {
@ -152,7 +164,7 @@ fn collect_blocks<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<DocumentBlock>) {
None
} else {
Some(DocumentBlock::CodeBlock {
id: block_id(),
id: block_id(&html, blocks.len()),
language: Some("html".into()),
code: html,
})
@ -163,7 +175,7 @@ fn collect_blocks<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<DocumentBlock>) {
None
} else {
Some(DocumentBlock::CodeBlock {
id: block_id(),
id: block_id(&content, blocks.len()),
language: Some("yaml".into()),
code: content.clone(),
})
@ -174,7 +186,7 @@ fn collect_blocks<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<DocumentBlock>) {
if text.is_empty() {
None
} else {
Some(DocumentBlock::Paragraph { id: block_id(), text })
Some(DocumentBlock::Paragraph { id: block_id(&text, blocks.len()), text })
}
}
NodeValue::Document
@ -198,7 +210,7 @@ fn collect_blocks<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<DocumentBlock>) {
}
/// Check if a paragraph contains only an image, and extract it.
fn extract_image_from_paragraph<'a>(node: &'a AstNode<'a>) -> Option<DocumentBlock> {
fn extract_image_from_paragraph<'a>(node: &'a AstNode<'a>, position: usize) -> Option<DocumentBlock> {
let mut image_node: Option<&AstNode> = None;
let mut has_other_content = false;
@ -222,7 +234,7 @@ fn extract_image_from_paragraph<'a>(node: &'a AstNode<'a>) -> Option<DocumentBlo
if let NodeValue::Image(image) = &data.value {
let alt_text = collect_text(img);
return Some(DocumentBlock::Image {
id: block_id(),
id: block_id(&image.url, position),
src: image.url.clone(),
alt: if alt_text.is_empty() {
None
@ -260,6 +272,19 @@ fn collect_text_inner<'a>(node: &'a AstNode<'a>, buf: &mut String) {
mod tests {
use super::*;
fn block_id_str(b: &DocumentBlock) -> &str {
match b {
DocumentBlock::Heading { id, .. } => id,
DocumentBlock::Paragraph { id, .. } => id,
DocumentBlock::List { id, .. } => id,
DocumentBlock::CodeBlock { id, .. } => id,
DocumentBlock::Quote { id, .. } => id,
DocumentBlock::Table { id, .. } => id,
DocumentBlock::Image { id, .. } => id,
DocumentBlock::HorizontalRule { id, .. } => id,
}
}
#[test]
fn test_empty_markdown() {
let blocks = parse_markdown("").unwrap();
@ -516,6 +541,20 @@ mod tests {
assert!(blocks.iter().any(|b| matches!(b, DocumentBlock::List { .. })));
}
#[test]
fn test_block_ids_are_deterministic_across_parses() {
let md = "# Title\n\nHello world.\n\n- item 1\n- item 2\n\n```rust\nfn main() {}\n```\n\n> Quote";
let blocks1 = parse_markdown(md).unwrap();
let blocks2 = parse_markdown(md).unwrap();
assert_eq!(blocks1.len(), blocks2.len());
for (a, b) in blocks1.iter().zip(blocks2.iter()) {
let id_a = block_id_str(a);
let id_b = block_id_str(b);
assert_eq!(id_a, id_b, "block_id mismatch: {:?} vs {:?}", a, b);
}
}
#[test]
fn test_all_eight_block_types() {
let md = "# H\n\nPara.\n\n- item\n\n```rs\nlet x=1;\n```\n\n> Quote\n\n|A|B|\n|---|---|\n|1|2|\n\n![alt](x.png)\n\n---";

View File

@ -2,14 +2,52 @@ use serde::{Deserialize, Serialize, Serializer};
/// Clamp a progress value to 0..1. NaN→0, <0→0, >1→1.
pub fn clamp_progress(v: f32) -> f32 {
if v.is_nan() || v < 0.0 { return 0.0; }
if v > 1.0 { return 1.0; }
if v.is_nan() || v < 0.0 {
return 0.0;
}
if v > 1.0 {
return 1.0;
}
v
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, uniffi::Enum)]
#[serde(rename_all = "camelCase")]
pub enum RendererKind {
Block,
Pdf,
Image,
Workbook,
Slide,
Epub,
Unknown,
}
impl std::fmt::Display for RendererKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Block => write!(f, "block"),
Self::Pdf => write!(f, "pdf"),
Self::Image => write!(f, "image"),
Self::Workbook => write!(f, "workbook"),
Self::Slide => write!(f, "slide"),
Self::Epub => write!(f, "epub"),
Self::Unknown => write!(f, "unknown"),
}
}
}
#[derive(Debug, Clone, PartialEq, Deserialize, uniffi::Enum)]
#[serde(tag = "type")]
pub enum ReadingPosition {
Block {
#[serde(rename = "blockId")]
block_id: String,
#[serde(rename = "blockProgress")]
block_progress: f32,
#[serde(rename = "overallProgress")]
overall_progress: f32,
},
Markdown {
#[serde(rename = "blockId")]
block_id: String,
@ -30,6 +68,18 @@ pub enum ReadingPosition {
#[serde(rename = "overallProgress")]
overall_progress: f32,
},
PdfViewport {
#[serde(rename = "pageNumber")]
page_number: u32,
#[serde(rename = "anchorX")]
anchor_x: f32,
#[serde(rename = "anchorY")]
anchor_y: f32,
#[serde(rename = "zoomScale")]
zoom_scale: f32,
#[serde(rename = "overallProgress")]
overall_progress: f32,
},
Image {
#[serde(rename = "zoomScale")]
zoom_scale: f32,
@ -38,6 +88,40 @@ pub enum ReadingPosition {
#[serde(rename = "offsetY")]
offset_y: f32,
},
ImageViewport {
#[serde(rename = "zoomScale")]
zoom_scale: f32,
#[serde(rename = "anchorX")]
anchor_x: f32,
#[serde(rename = "anchorY")]
anchor_y: f32,
},
Workbook {
#[serde(rename = "sheetId")]
sheet_id: String,
#[serde(rename = "topRow")]
top_row: u32,
#[serde(rename = "leftColumn")]
left_column: u32,
#[serde(rename = "selectedCell")]
selected_cell: Option<String>,
#[serde(rename = "zoomScale")]
zoom_scale: f32,
#[serde(rename = "overallProgress")]
overall_progress: Option<f32>,
},
Slide {
#[serde(rename = "slideIndex")]
slide_index: u32,
#[serde(rename = "zoomScale")]
zoom_scale: f32,
#[serde(rename = "anchorX")]
anchor_x: f32,
#[serde(rename = "anchorY")]
anchor_y: f32,
#[serde(rename = "overallProgress")]
overall_progress: f32,
},
Epub {
#[serde(rename = "chapterId")]
chapter_id: String,
@ -49,47 +133,127 @@ pub enum ReadingPosition {
Unknown,
}
// Manual Serialize to enforce camelCase field names + clamped progress values.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, uniffi::Record)]
#[serde(rename_all = "camelCase")]
pub struct ReadingCheckpoint {
pub schema_version: u32,
pub material_id: String,
pub representation_fingerprint: String,
pub renderer_kind: RendererKind,
pub captured_at_ms: u64,
pub device_id: String,
pub device_sequence: u64,
pub position: ReadingPosition,
}
impl ReadingCheckpoint {
pub fn normalized(&self) -> Self {
Self {
schema_version: self.schema_version,
material_id: self.material_id.clone(),
representation_fingerprint: self.representation_fingerprint.clone(),
renderer_kind: self.renderer_kind.clone(),
captured_at_ms: self.captured_at_ms,
device_id: self.device_id.clone(),
device_sequence: self.device_sequence,
position: self.position.normalized(),
}
}
pub fn legacy_position(&self) -> ReadingPosition {
self.position.clone()
}
}
// Manual Serialize to enforce camelCase field names + normalized values.
impl Serialize for ReadingPosition {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
match self {
match self.normalized() {
Self::Block { block_id, block_progress, overall_progress } => {
let mut st = s.serialize_struct("Block", 4)?;
st.serialize_field("type", "Block")?;
st.serialize_field("blockId", &block_id)?;
st.serialize_field("blockProgress", &block_progress)?;
st.serialize_field("overallProgress", &overall_progress)?;
st.end()
}
Self::Markdown { block_id, scroll_progress } => {
let mut st = s.serialize_struct("Markdown", 3)?;
st.serialize_field("type", "Markdown")?;
st.serialize_field("blockId", block_id)?;
st.serialize_field("scrollProgress", &clamp_progress(*scroll_progress))?;
st.serialize_field("blockId", &block_id)?;
st.serialize_field("scrollProgress", &scroll_progress)?;
st.end()
}
Self::Text { line_number, scroll_progress } => {
let mut st = s.serialize_struct("Text", 3)?;
st.serialize_field("type", "Text")?;
st.serialize_field("lineNumber", line_number)?;
st.serialize_field("scrollProgress", &clamp_progress(*scroll_progress))?;
st.serialize_field("lineNumber", &line_number)?;
st.serialize_field("scrollProgress", &scroll_progress)?;
st.end()
}
Self::Pdf { page_number, page_progress, overall_progress } => {
let mut st = s.serialize_struct("Pdf", 4)?;
st.serialize_field("type", "Pdf")?;
st.serialize_field("pageNumber", page_number)?;
st.serialize_field("pageProgress", &clamp_progress(*page_progress))?;
st.serialize_field("overallProgress", &clamp_progress(*overall_progress))?;
st.serialize_field("pageNumber", &page_number)?;
st.serialize_field("pageProgress", &page_progress)?;
st.serialize_field("overallProgress", &overall_progress)?;
st.end()
}
Self::PdfViewport { page_number, anchor_x, anchor_y, zoom_scale, overall_progress } => {
let mut st = s.serialize_struct("PdfViewport", 6)?;
st.serialize_field("type", "PdfViewport")?;
st.serialize_field("pageNumber", &page_number)?;
st.serialize_field("anchorX", &anchor_x)?;
st.serialize_field("anchorY", &anchor_y)?;
st.serialize_field("zoomScale", &zoom_scale)?;
st.serialize_field("overallProgress", &overall_progress)?;
st.end()
}
Self::Image { zoom_scale, offset_x, offset_y } => {
let mut st = s.serialize_struct("Image", 4)?;
st.serialize_field("type", "Image")?;
st.serialize_field("zoomScale", zoom_scale)?;
st.serialize_field("offsetX", offset_x)?;
st.serialize_field("offsetY", offset_y)?;
st.serialize_field("zoomScale", &zoom_scale)?;
st.serialize_field("offsetX", &offset_x)?;
st.serialize_field("offsetY", &offset_y)?;
st.end()
}
Self::ImageViewport { zoom_scale, anchor_x, anchor_y } => {
let mut st = s.serialize_struct("ImageViewport", 4)?;
st.serialize_field("type", "ImageViewport")?;
st.serialize_field("zoomScale", &zoom_scale)?;
st.serialize_field("anchorX", &anchor_x)?;
st.serialize_field("anchorY", &anchor_y)?;
st.end()
}
Self::Workbook { sheet_id, top_row, left_column, selected_cell, zoom_scale, overall_progress } => {
let mut st = s.serialize_struct("Workbook", 7)?;
st.serialize_field("type", "Workbook")?;
st.serialize_field("sheetId", &sheet_id)?;
st.serialize_field("topRow", &top_row)?;
st.serialize_field("leftColumn", &left_column)?;
st.serialize_field("selectedCell", &selected_cell)?;
st.serialize_field("zoomScale", &zoom_scale)?;
st.serialize_field("overallProgress", &overall_progress)?;
st.end()
}
Self::Slide { slide_index, zoom_scale, anchor_x, anchor_y, overall_progress } => {
let mut st = s.serialize_struct("Slide", 6)?;
st.serialize_field("type", "Slide")?;
st.serialize_field("slideIndex", &slide_index)?;
st.serialize_field("zoomScale", &zoom_scale)?;
st.serialize_field("anchorX", &anchor_x)?;
st.serialize_field("anchorY", &anchor_y)?;
st.serialize_field("overallProgress", &overall_progress)?;
st.end()
}
Self::Epub { chapter_id, chapter_progress, overall_progress } => {
let mut st = s.serialize_struct("Epub", 4)?;
st.serialize_field("type", "Epub")?;
st.serialize_field("chapterId", chapter_id)?;
st.serialize_field("chapterProgress", &clamp_progress(*chapter_progress))?;
st.serialize_field("overallProgress", &clamp_progress(*overall_progress))?;
st.serialize_field("chapterId", &chapter_id)?;
st.serialize_field("chapterProgress", &chapter_progress)?;
st.serialize_field("overallProgress", &overall_progress)?;
st.end()
}
Self::Unknown => {
@ -105,6 +269,11 @@ impl ReadingPosition {
/// Return a normalized copy with all progress fields clamped to 0..1.
pub fn normalized(&self) -> Self {
match self {
Self::Block { block_id, block_progress, overall_progress } => Self::Block {
block_id: block_id.clone(),
block_progress: clamp_progress(*block_progress),
overall_progress: clamp_progress(*overall_progress),
},
Self::Markdown { block_id, scroll_progress } => Self::Markdown {
block_id: block_id.clone(),
scroll_progress: clamp_progress(*scroll_progress),
@ -118,8 +287,37 @@ impl ReadingPosition {
page_progress: clamp_progress(*page_progress),
overall_progress: clamp_progress(*overall_progress),
},
Self::PdfViewport { page_number, anchor_x, anchor_y, zoom_scale, overall_progress } => Self::PdfViewport {
page_number: *page_number,
anchor_x: clamp_progress(*anchor_x),
anchor_y: clamp_progress(*anchor_y),
zoom_scale: *zoom_scale,
overall_progress: clamp_progress(*overall_progress),
},
Self::Image { zoom_scale, offset_x, offset_y } => Self::Image {
zoom_scale: *zoom_scale, offset_x: *offset_x, offset_y: *offset_y,
zoom_scale: *zoom_scale,
offset_x: *offset_x,
offset_y: *offset_y,
},
Self::ImageViewport { zoom_scale, anchor_x, anchor_y } => Self::ImageViewport {
zoom_scale: *zoom_scale,
anchor_x: clamp_progress(*anchor_x),
anchor_y: clamp_progress(*anchor_y),
},
Self::Workbook { sheet_id, top_row, left_column, selected_cell, zoom_scale, overall_progress } => Self::Workbook {
sheet_id: sheet_id.clone(),
top_row: *top_row,
left_column: *left_column,
selected_cell: selected_cell.clone(),
zoom_scale: *zoom_scale,
overall_progress: overall_progress.map(clamp_progress),
},
Self::Slide { slide_index, zoom_scale, anchor_x, anchor_y, overall_progress } => Self::Slide {
slide_index: *slide_index,
zoom_scale: *zoom_scale,
anchor_x: clamp_progress(*anchor_x),
anchor_y: clamp_progress(*anchor_y),
overall_progress: clamp_progress(*overall_progress),
},
Self::Epub { chapter_id, chapter_progress, overall_progress } => Self::Epub {
chapter_id: chapter_id.clone(),
@ -135,10 +333,17 @@ impl ReadingPosition {
use crate::material_type::MaterialType;
matches!(
(self, mt),
(ReadingPosition::Markdown { .. }, MaterialType::Markdown)
(ReadingPosition::Block { .. }, MaterialType::Markdown)
| (ReadingPosition::Block { .. }, MaterialType::Text)
| (ReadingPosition::Block { .. }, MaterialType::Word)
| (ReadingPosition::Markdown { .. }, MaterialType::Markdown)
| (ReadingPosition::Text { .. }, MaterialType::Text)
| (ReadingPosition::Pdf { .. }, MaterialType::Pdf)
| (ReadingPosition::PdfViewport { .. }, MaterialType::Pdf)
| (ReadingPosition::Image { .. }, MaterialType::Image)
| (ReadingPosition::ImageViewport { .. }, MaterialType::Image)
| (ReadingPosition::Workbook { .. }, MaterialType::Excel)
| (ReadingPosition::Slide { .. }, MaterialType::PowerPoint)
| (ReadingPosition::Epub { .. }, MaterialType::Epub)
| (ReadingPosition::Unknown, _)
)
@ -146,11 +351,15 @@ impl ReadingPosition {
pub fn progress_value(&self) -> Option<f32> {
match self {
Self::Block { overall_progress, .. } => Some(clamp_progress(*overall_progress)),
Self::Markdown { scroll_progress, .. } => Some(clamp_progress(*scroll_progress)),
Self::Text { scroll_progress, .. } => Some(clamp_progress(*scroll_progress)),
Self::Pdf { overall_progress, .. } => Some(clamp_progress(*overall_progress)),
Self::PdfViewport { overall_progress, .. } => Some(clamp_progress(*overall_progress)),
Self::Workbook { overall_progress, .. } => overall_progress.map(clamp_progress),
Self::Slide { overall_progress, .. } => Some(clamp_progress(*overall_progress)),
Self::Epub { overall_progress, .. } => Some(clamp_progress(*overall_progress)),
Self::Image { .. } | Self::Unknown => None,
Self::Image { .. } | Self::ImageViewport { .. } | Self::Unknown => None,
}
}
}
@ -160,154 +369,89 @@ mod tests {
use super::*;
#[test]
fn test_markdown_camel_case() {
fn test_block_roundtrip() {
let pos = ReadingPosition::Block {
block_id: "b1".into(),
block_progress: 0.2,
overall_progress: 0.4,
};
let json = serde_json::to_string(&pos).unwrap();
assert!(json.contains("\"type\":\"Block\""));
assert!(json.contains("\"blockId\":\"b1\""));
let back: ReadingPosition = serde_json::from_str(&json).unwrap();
assert_eq!(back, pos);
}
#[test]
fn test_pdf_viewport_clamps_anchor_and_progress() {
let pos = ReadingPosition::PdfViewport {
page_number: 1,
anchor_x: 2.0,
anchor_y: -1.0,
zoom_scale: 1.5,
overall_progress: f32::NAN,
};
let normalized = pos.normalized();
assert_eq!(
normalized,
ReadingPosition::PdfViewport {
page_number: 1,
anchor_x: 1.0,
anchor_y: 0.0,
zoom_scale: 1.5,
overall_progress: 0.0,
}
);
}
#[test]
fn test_legacy_markdown_camel_case() {
let pos = ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: 0.5 };
let json = serde_json::to_string(&pos).unwrap();
eprintln!("SERIALIZED: {json}");
assert!(json.contains("\"type\":\"Markdown\""), "missing type: {json}");
assert!(json.contains("\"blockId\":\"h1\""), "missing blockId: {json}");
assert!(json.contains("\"scrollProgress\":0.5"), "missing progress: {json}");
assert!(json.contains("\"blockId\":\"h1\""));
assert!(json.contains("\"scrollProgress\":0.5"));
}
#[test]
fn test_pdf_camel_case() {
let pos = ReadingPosition::Pdf { page_number: 7, page_progress: 0.8, overall_progress: 0.35 };
let json = serde_json::to_string(&pos).unwrap();
assert!(json.contains("\"pageNumber\":7"));
assert!(json.contains("\"pageProgress\":0.8"));
assert!(json.contains("\"overallProgress\":0.35"));
fn test_checkpoint_roundtrip() {
let checkpoint = ReadingCheckpoint {
schema_version: 2,
material_id: "mat-1".into(),
representation_fingerprint: "fp-1".into(),
renderer_kind: RendererKind::Block,
captured_at_ms: 100,
device_id: "device-1".into(),
device_sequence: 7,
position: ReadingPosition::Block {
block_id: "b1".into(),
block_progress: 0.25,
overall_progress: 0.5,
},
};
let json = serde_json::to_string(&checkpoint).unwrap();
assert!(json.contains("\"rendererKind\":\"block\""));
let back: ReadingCheckpoint = serde_json::from_str(&json).unwrap();
assert_eq!(back, checkpoint);
}
#[test]
fn test_clamp_nan() {
let pos = ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: f32::NAN };
let json = serde_json::to_string(&pos).unwrap();
assert!(json.contains("\"scrollProgress\":0.0"));
}
#[test]
fn test_clamp_negative() {
let pos = ReadingPosition::Text { line_number: 1, scroll_progress: -0.5 };
let json = serde_json::to_string(&pos).unwrap();
assert!(json.contains("\"scrollProgress\":0.0"));
}
#[test]
fn test_clamp_above_one() {
let pos = ReadingPosition::Pdf { page_number: 1, page_progress: 2.5, overall_progress: 10.0 };
let json = serde_json::to_string(&pos).unwrap();
assert!(json.contains("\"pageProgress\":1.0"));
assert!(json.contains("\"overallProgress\":1.0"));
}
#[test]
fn test_is_compatible() {
use crate::material_type::MaterialType;
let md = ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: 0.5 };
assert!(md.is_compatible_with(&MaterialType::Markdown));
assert!(!md.is_compatible_with(&MaterialType::Pdf));
assert!(ReadingPosition::Unknown.is_compatible_with(&MaterialType::Pdf));
assert!(ReadingPosition::Unknown.is_compatible_with(&MaterialType::Markdown)); // Unknown = compatible with all
}
#[test]
fn test_progress_value() {
assert_eq!(ReadingPosition::Unknown.progress_value(), None);
let md = ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: 0.75 };
assert_eq!(md.progress_value(), Some(0.75));
}
#[test]
fn test_roundtrip() {
let positions = vec![
ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: 0.5 },
ReadingPosition::Pdf { page_number: 7, page_progress: 0.8, overall_progress: 0.35 },
ReadingPosition::Unknown,
];
for pos in &positions {
let json = serde_json::to_string(pos).unwrap();
eprintln!("ROUNDTRIP JSON: {json}");
let back: ReadingPosition = serde_json::from_str(&json).unwrap();
assert_eq!(&back, pos);
}
}
#[test]
fn test_deserialize_from_camel_case_json() {
// Simulate JSON from iOS app / API server: all camelCase field names
let json = r#"{"type":"Markdown","blockId":"h1","scrollProgress":0.5}"#;
let pos: ReadingPosition = serde_json::from_str(json).unwrap();
fn test_deserialize_legacy_and_new_json() {
let md_json = r#"{"type":"Markdown","blockId":"h1","scrollProgress":0.5}"#;
let md: ReadingPosition = serde_json::from_str(md_json).unwrap();
assert_eq!(
pos,
md,
ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: 0.5 }
);
let json = r#"{"type":"Text","lineNumber":5,"scrollProgress":0.3}"#;
let pos: ReadingPosition = serde_json::from_str(json).unwrap();
let block_json = r#"{"type":"Block","blockId":"b1","blockProgress":0.3,"overallProgress":0.6}"#;
let block: ReadingPosition = serde_json::from_str(block_json).unwrap();
assert_eq!(
pos,
ReadingPosition::Text { line_number: 5, scroll_progress: 0.3 }
block,
ReadingPosition::Block {
block_id: "b1".into(),
block_progress: 0.3,
overall_progress: 0.6,
}
);
let json = r#"{"type":"Pdf","pageNumber":3,"pageProgress":0.8,"overallProgress":0.35}"#;
let pos: ReadingPosition = serde_json::from_str(json).unwrap();
assert_eq!(
pos,
ReadingPosition::Pdf { page_number: 3, page_progress: 0.8, overall_progress: 0.35 }
);
let json = r#"{"type":"Image","zoomScale":1.5,"offsetX":10.0,"offsetY":20.0}"#;
let pos: ReadingPosition = serde_json::from_str(json).unwrap();
assert_eq!(
pos,
ReadingPosition::Image { zoom_scale: 1.5, offset_x: 10.0, offset_y: 20.0 }
);
let json = r#"{"type":"Epub","chapterId":"ch3","chapterProgress":0.6,"overallProgress":0.3}"#;
let pos: ReadingPosition = serde_json::from_str(json).unwrap();
assert_eq!(
pos,
ReadingPosition::Epub { chapter_id: "ch3".into(), chapter_progress: 0.6, overall_progress: 0.3 }
);
let json = r#"{"type":"Unknown"}"#;
let pos: ReadingPosition = serde_json::from_str(json).unwrap();
assert_eq!(pos, ReadingPosition::Unknown);
}
#[test]
fn test_deserialize_serialize_roundtrip_all_variants() {
// Deserialize from camelCase → re-serialize → deserialize again → verify identical
let positions = vec![
r#"{"type":"Markdown","blockId":"intro","scrollProgress":0.25}"#,
r#"{"type":"Text","lineNumber":42,"scrollProgress":0.5}"#,
r#"{"type":"Pdf","pageNumber":7,"pageProgress":0.8,"overallProgress":0.35}"#,
r#"{"type":"Image","zoomScale":2.0,"offsetX":0.0,"offsetY":100.0}"#,
r#"{"type":"Epub","chapterId":"ch1","chapterProgress":0.6,"overallProgress":0.3}"#,
r#"{"type":"Unknown"}"#,
];
for json_str in &positions {
let pos1: ReadingPosition = serde_json::from_str(json_str).unwrap();
let re_serialized = serde_json::to_string(&pos1).unwrap();
let pos2: ReadingPosition = serde_json::from_str(&re_serialized).unwrap();
assert_eq!(pos1, pos2, "roundtrip failed for: {json_str}");
}
}
#[test]
fn test_rename_attrs_not_overridden_by_uniffi() {
// Verify #[serde(rename)] works correctly alongside #[derive(uniffi::Enum)]:
// serde uses renamed camelCase names, not Rust snake_case names.
// If uniffi overrode the renames, "blockId" would NOT be recognized.
// 1. snake_case should FAIL (field is renamed)
let json_snake = r#"{"type":"Markdown","block_id":"h1","scroll_progress":0.5}"#;
let result: Result<ReadingPosition, _> = serde_json::from_str(json_snake);
assert!(result.is_err(), "snake_case should fail: rename=blockId means block_id is unknown");
// 2. camelCase should SUCCEED
let json_camel = r#"{"type":"Markdown","blockId":"h1","scrollProgress":0.5}"#;
let result: Result<ReadingPosition, _> = serde_json::from_str(json_camel);
assert!(result.is_ok(), "camelCase should succeed: rename=blockId is the expected name");
}
}

View File

@ -1,7 +1,7 @@
use std::collections::HashMap;
use std::sync::Mutex;
use crate::progress::ReadingPosition;
use crate::progress::{ReadingCheckpoint, ReadingPosition};
use crate::reading_material::ReadingMaterialRefV2;
use crate::time_tracker::ActiveTimeTracker;
@ -36,6 +36,7 @@ pub struct ReadingSessionV2 {
pub next_sequence: u64,
pub total_active_seconds: u32,
pub last_position: Option<ReadingPosition>,
pub last_checkpoint: Option<ReadingCheckpoint>,
pub status: ReadingSessionStatus,
}
@ -58,6 +59,7 @@ pub fn start_reading_session_v2(
next_sequence: 1,
total_active_seconds: 0,
last_position: None,
last_checkpoint: None,
status: ReadingSessionStatus::Active,
};
@ -168,13 +170,23 @@ pub fn get_active_session_v2(material_id: &str) -> Option<ReadingSessionV2> {
}
}
/// Record an event on this session: bumps sequence and updates last_event_at_ms.
/// Returns the sequence number that should be assigned to the event.
pub fn record_session_event_v2(
session_id: &str,
timestamp_ms: i64,
active_seconds_delta: u32,
position: Option<ReadingPosition>,
) -> Result<u64, SessionError> {
record_session_event_with_checkpoint_v2(session_id, timestamp_ms, active_seconds_delta, position, None)
}
/// Record an event on this session: bumps sequence and updates last_event_at_ms.
/// Returns the sequence number that should be assigned to the event.
pub fn record_session_event_with_checkpoint_v2(
session_id: &str,
timestamp_ms: i64,
active_seconds_delta: u32,
position: Option<ReadingPosition>,
checkpoint: Option<ReadingCheckpoint>,
) -> Result<u64, SessionError> {
let seq = with_session_mut(session_id, |s| {
if s.status == ReadingSessionStatus::Closed {
@ -186,8 +198,12 @@ pub fn record_session_event_v2(
let seq = s.next_sequence;
s.next_sequence += 1;
s.last_event_at_ms = timestamp_ms;
if position.is_some() {
s.last_position = position;
if let Some(position) = position {
s.last_position = Some(position);
}
if let Some(checkpoint) = checkpoint {
s.last_position = Some(checkpoint.legacy_position());
s.last_checkpoint = Some(checkpoint);
}
Ok(seq)
})?;
@ -318,11 +334,26 @@ impl std::fmt::Display for SessionError {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard, OnceLock};
fn test_material() -> ReadingMaterialRefV2 {
ReadingMaterialRefV2::new("test_mat_001", "/tmp/test.md", "test.md", "markdown")
}
fn test_guard() -> MutexGuard<'static, ()> {
static GUARD: OnceLock<Mutex<()>> = OnceLock::new();
GUARD.get_or_init(|| Mutex::new(())).lock().unwrap()
}
fn reset_state() {
if let Ok(mut map) = sessions().lock() {
map.clear();
}
if let Ok(mut tmap) = trackers().lock() {
tmap.clear();
}
}
fn teardown(id: &str) {
let _ = close_reading_session_v2(id, 0);
let _ = remove_session_v2(id);
@ -330,6 +361,8 @@ mod tests {
#[test]
fn test_start_session() {
let _guard = test_guard();
reset_state();
let id = start_reading_session_v2(test_material(), 1000).unwrap();
assert!(!id.is_empty());
let s = get_session_v2(&id).unwrap();
@ -341,6 +374,8 @@ mod tests {
#[test]
fn test_pause_resume_close() {
let _guard = test_guard();
reset_state();
let id = start_reading_session_v2(test_material(), 1000).unwrap();
pause_reading_session_v2(&id).unwrap();
assert_eq!(get_session_v2(&id).unwrap().status, ReadingSessionStatus::Paused);
@ -353,6 +388,8 @@ mod tests {
#[test]
fn test_remove_refuses_active() {
let _guard = test_guard();
reset_state();
let id = start_reading_session_v2(test_material(), 1000).unwrap();
assert!(remove_session_v2(&id).is_err()); // Not closed
teardown(&id);
@ -360,6 +397,8 @@ mod tests {
#[test]
fn test_sequence_increments() {
let _guard = test_guard();
reset_state();
let id = start_reading_session_v2(test_material(), 1000).unwrap();
let s1 = record_session_event_v2(&id, 2000, 0, None).unwrap(); // tracker not used for 0 delta
let s2 = record_session_event_v2(&id, 3000, 0, None).unwrap();
@ -370,6 +409,8 @@ mod tests {
#[test]
fn test_closed_rejects_events() {
let _guard = test_guard();
reset_state();
let id = start_reading_session_v2(test_material(), 1000).unwrap();
close_reading_session_v2(&id, 0).unwrap();
assert!(record_session_event_v2(&id, 2000, 10, None).is_err());
@ -379,6 +420,8 @@ mod tests {
#[test]
fn test_paused_rejects_active_seconds() {
let _guard = test_guard();
reset_state();
let id = start_reading_session_v2(test_material(), 1000).unwrap();
pause_reading_session_v2(&id).unwrap();
// PositionChanged (delta=0) should be allowed when paused
@ -391,12 +434,16 @@ mod tests {
#[test]
fn test_not_found() {
let _guard = test_guard();
reset_state();
assert!(get_session_v2("nonexistent").is_err());
assert!(close_reading_session_v2("nonexistent", 0).is_err());
}
#[test]
fn test_two_sessions_independent() {
let _guard = test_guard();
reset_state();
let a = start_reading_session_v2(test_material(), 1000).unwrap();
let b = start_reading_session_v2(ReadingMaterialRefV2::new("mat_b", "/tmp/b.md", "b.md", "markdown"), 2000).unwrap();
assert_ne!(a, b);
@ -408,6 +455,8 @@ mod tests {
#[test]
fn test_total_active_seconds_accumulates() {
let _guard = test_guard();
reset_state();
let id = start_reading_session_v2(test_material(), 0).unwrap();
// Simulate iOS calling tick() and passing deltas to session events
record_session_event_v2(&id, 15_000, 15, None).unwrap();
@ -420,6 +469,8 @@ mod tests {
#[test]
fn test_sequence_and_seconds_independent() {
let _guard = test_guard();
reset_state();
let a = start_reading_session_v2(test_material(), 0).unwrap();
let b = start_reading_session_v2(ReadingMaterialRefV2::new("mat_b", "/tmp/b.md", "b.md", "markdown"), 0).unwrap();
record_session_event_v2(&a, 10_000, 10, None).unwrap();
@ -435,6 +486,8 @@ mod tests {
#[test]
fn test_last_position_updated() {
let _guard = test_guard();
reset_state();
let id = start_reading_session_v2(test_material(), 0).unwrap();
let pos1 = ReadingPosition::Markdown { block_id: "intro".into(), scroll_progress: 0.3 };
let pos2 = ReadingPosition::Markdown { block_id: "ch1".into(), scroll_progress: 0.8 };
@ -446,6 +499,8 @@ mod tests {
#[test]
fn test_cleanup_stale_removes_old_sessions() {
let _guard = test_guard();
reset_state();
// Old session: last event at t=1000
let old = start_reading_session_v2(test_material(), 0).unwrap();
record_session_event_v2(&old, 1000, 0, None).unwrap();
@ -468,6 +523,8 @@ mod tests {
#[test]
fn test_cleanup_stale_removes_orphaned_active_session() {
let _guard = test_guard();
reset_state();
// Simulate a crash: active session not closed, last event at t=5000
let orphaned = start_reading_session_v2(test_material(), 0).unwrap();
record_session_event_v2(&orphaned, 5000, 10, None).unwrap();
@ -482,6 +539,8 @@ mod tests {
#[test]
fn test_cleanup_stale_zero_when_all_recent() {
let _guard = test_guard();
reset_state();
let active = start_reading_session_v2(test_material(), 0).unwrap();
// Last event at t=9500, now at t=10000, max_age=1000
// 10000-9500=500 < 1000 → NOT stale
@ -498,6 +557,8 @@ mod tests {
#[test]
fn test_mark_interrupted() {
let _guard = test_guard();
reset_state();
let id = start_reading_session_v2(test_material(), 1000).unwrap();
mark_session_interrupted_v2(&id).unwrap();
assert_eq!(get_session_v2(&id).unwrap().status, ReadingSessionStatus::Interrupted);
@ -508,6 +569,8 @@ mod tests {
#[test]
fn test_mark_failed() {
let _guard = test_guard();
reset_state();
let id = start_reading_session_v2(test_material(), 1000).unwrap();
mark_session_failed_v2(&id).unwrap();
assert_eq!(get_session_v2(&id).unwrap().status, ReadingSessionStatus::Failed);
@ -518,6 +581,8 @@ mod tests {
#[test]
fn test_closed_rejects_interrupted() {
let _guard = test_guard();
reset_state();
let id = start_reading_session_v2(test_material(), 1000).unwrap();
close_reading_session_v2(&id, 0).unwrap();
assert!(mark_session_interrupted_v2(&id).is_err());
@ -527,6 +592,8 @@ mod tests {
#[test]
fn test_close_sets_closed_at_ms() {
let _guard = test_guard();
reset_state();
let id = start_reading_session_v2(test_material(), 1000).unwrap();
assert!(get_session_v2(&id).unwrap().closed_at_ms.is_none());
close_reading_session_v2(&id, 5000).unwrap();
@ -536,6 +603,8 @@ mod tests {
#[test]
fn test_set_closed_at_ms_override() {
let _guard = test_guard();
reset_state();
let id = start_reading_session_v2(test_material(), 1000).unwrap();
close_reading_session_v2(&id, 5000).unwrap();
// set_session_closed_at_ms can override if needed
@ -546,6 +615,8 @@ mod tests {
#[test]
fn test_get_active_session_by_material() {
let _guard = test_guard();
reset_state();
let mat_a = ReadingMaterialRefV2::new("mat_a", "/tmp/a.md", "a.md", "markdown");
let mat_b = ReadingMaterialRefV2::new("mat_b", "/tmp/b.md", "b.md", "markdown");
@ -565,6 +636,8 @@ mod tests {
#[test]
fn test_session_does_not_store_business_fields() {
let _guard = test_guard();
reset_state();
let id = start_reading_session_v2(test_material(), 1000).unwrap();
let s = get_session_v2(&id).unwrap();
// Verify the material ref has no business fields
@ -576,6 +649,8 @@ mod tests {
#[test]
fn test_sequence_monotonic_in_session() {
let _guard = test_guard();
reset_state();
let id = start_reading_session_v2(test_material(), 1000).unwrap();
let s1 = record_session_event_v2(&id, 2000, 0, None).unwrap();
let s2 = record_session_event_v2(&id, 3000, 0, None).unwrap();

View File

@ -0,0 +1,32 @@
pub fn stable_content_id(namespace: &str, index: usize, content: &str) -> String {
let mut hash: u64 = 5381;
for byte in namespace
.bytes()
.chain(index.to_string().bytes())
.chain(content.bytes())
{
hash = ((hash << 5).wrapping_add(hash)).wrapping_add(byte as u64);
}
format!("{namespace}_{hash:016x}")
}
#[cfg(test)]
mod tests {
use super::stable_content_id;
#[test]
fn stable_content_id_is_deterministic() {
let a = stable_content_id("text", 3, "hello");
let b = stable_content_id("text", 3, "hello");
assert_eq!(a, b);
}
#[test]
fn stable_content_id_changes_with_namespace_and_index() {
let base = stable_content_id("text", 3, "hello");
assert_ne!(base, stable_content_id("markdown", 3, "hello"));
assert_ne!(base, stable_content_id("text", 4, "hello"));
}
}

View File

@ -1,6 +1,6 @@
use crate::blocks::DocumentBlock;
use crate::error::DocumentError;
use uuid::Uuid;
use crate::stable_id::stable_content_id;
/// Read a TXT file and return its content as paragraph blocks.
pub fn read_text(file_path: &str) -> Result<Vec<DocumentBlock>, DocumentError> {
@ -18,11 +18,12 @@ pub fn parse_text_content(content: &str) -> Vec<DocumentBlock> {
continue;
}
blocks.push(DocumentBlock::Paragraph {
id: Uuid::new_v4().to_string(),
id: stable_content_id("text", blocks.len(), &text),
text,
});
}
eprintln!("[Rust] parse_text — len={} block_count={}", content.len(), blocks.len());
blocks
}
@ -91,4 +92,11 @@ mod tests {
let result = read_text("/nonexistent/file.txt");
assert!(result.is_err());
}
#[test]
fn test_ids_are_stable() {
let first = parse_text_content("Hello\n\nWorld");
let second = parse_text_content("Hello\n\nWorld");
assert_eq!(first, second);
}
}

View File

@ -10,7 +10,7 @@ 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::progress::{ReadingCheckpoint, ReadingPosition, RendererKind};
pub use zx_document_core::events::ReadingEvent;
#[allow(deprecated)]
pub use zx_document_core::reading_material::ReadingMaterialRef;
@ -26,39 +26,42 @@ use zx_document_core::blocks as core_blocks;
// ── V2 Reading Session FFI ──
#[uniffi::export]
fn start_reading_session_v2(material: ReadingMaterialRefV2, timestamp_ms: i64) -> Result<String, String> {
zx_document_core::session_v2::start_reading_session_v2(material, timestamp_ms)
.map_err(|e| e.to_string())
fn start_reading_session_v2(material: ReadingMaterialRefV2, timestamp_ms: i64) -> Result<String, DocumentError> {
let r = zx_document_core::session_v2::start_reading_session_v2(material, timestamp_ms)
.map_err(Into::into);
match &r { Ok(sid) => eprintln!("[Rust] start_session OK — sid={}", sid), Err(e) => eprintln!("[Rust] start_session FAILED — {:?}", e) }
r
}
#[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())
fn pause_reading_session_v2(session_id: String) -> Result<(), DocumentError> {
zx_document_core::session_v2::pause_reading_session_v2(&session_id).map_err(Into::into)
}
#[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())
fn resume_reading_session_v2(session_id: String) -> Result<(), DocumentError> {
zx_document_core::session_v2::resume_reading_session_v2(&session_id).map_err(Into::into)
}
#[uniffi::export]
fn close_reading_session_v2(session_id: String, timestamp_ms: i64) -> Result<(), String> {
zx_document_core::session_v2::close_reading_session_v2(&session_id, timestamp_ms).map_err(|e| e.to_string())
fn close_reading_session_v2(session_id: String, timestamp_ms: i64) -> Result<(), DocumentError> {
eprintln!("[Rust] close_session — sid={}", session_id);
zx_document_core::session_v2::close_reading_session_v2(&session_id, timestamp_ms).map_err(Into::into)
}
#[uniffi::export]
fn set_session_closed_at_ms_v2(session_id: String, timestamp_ms: i64) -> Result<(), String> {
zx_document_core::session_v2::set_session_closed_at_ms(&session_id, timestamp_ms).map_err(|e| e.to_string())
fn set_session_closed_at_ms_v2(session_id: String, timestamp_ms: i64) -> Result<(), DocumentError> {
zx_document_core::session_v2::set_session_closed_at_ms(&session_id, timestamp_ms).map_err(Into::into)
}
#[uniffi::export]
fn mark_session_interrupted_v2(session_id: String) -> Result<(), String> {
zx_document_core::session_v2::mark_session_interrupted_v2(&session_id).map_err(|e| e.to_string())
fn mark_session_interrupted_v2(session_id: String) -> Result<(), DocumentError> {
zx_document_core::session_v2::mark_session_interrupted_v2(&session_id).map_err(Into::into)
}
#[uniffi::export]
fn mark_session_failed_v2(session_id: String) -> Result<(), String> {
zx_document_core::session_v2::mark_session_failed_v2(&session_id).map_err(|e| e.to_string())
fn mark_session_failed_v2(session_id: String) -> Result<(), DocumentError> {
zx_document_core::session_v2::mark_session_failed_v2(&session_id).map_err(Into::into)
}
#[uniffi::export]
@ -74,45 +77,59 @@ fn get_session_v2_ffi(session_id: String) -> Option<ReadingSessionV2> {
// ── 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())
fn push_material_opened_v2(session_id: String, material_id: String, timestamp_ms: i64) -> Result<ReadingEventV2, DocumentError> {
eprintln!("[Rust] push_material_opened — mid={}", material_id);
zx_document_core::events_v2::push_material_opened_v2(&session_id, &material_id, timestamp_ms).map_err(Into::into)
}
#[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())
fn push_material_closed_v2(session_id: String, material_id: String, active_seconds_delta: u32, timestamp_ms: i64) -> Result<ReadingEventV2, DocumentError> {
eprintln!("[Rust] push_material_closed — mid={} active_s={}", material_id, active_seconds_delta);
zx_document_core::events_v2::push_material_closed_v2(&session_id, &material_id, active_seconds_delta, timestamp_ms).map_err(Into::into)
}
#[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())
fn push_position_changed_v2(session_id: String, material_id: String, position: ReadingPosition, timestamp_ms: i64) -> Result<ReadingEventV2, DocumentError> {
eprintln!("[Rust] push_position_changed — mid={}", material_id);
zx_document_core::events_v2::push_position_changed_v2(&session_id, &material_id, position, timestamp_ms).map_err(Into::into)
}
#[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())
fn push_heartbeat_v2(session_id: String, material_id: String, active_seconds_delta: u32, position: Option<ReadingPosition>, timestamp_ms: i64) -> Result<ReadingEventV2, DocumentError> {
zx_document_core::events_v2::push_heartbeat_v2(&session_id, &material_id, active_seconds_delta, position, timestamp_ms).map_err(Into::into)
}
#[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())
fn push_checkpoint_changed_v2(session_id: String, material_id: String, checkpoint: ReadingCheckpoint, timestamp_ms: i64) -> Result<ReadingEventV2, DocumentError> {
eprintln!("[Rust] push_checkpoint_changed — mid={}", material_id);
zx_document_core::events_v2::push_checkpoint_changed_v2(&session_id, &material_id, checkpoint, timestamp_ms).map_err(Into::into)
}
#[uniffi::export]
fn push_heartbeat_checkpoint_v2(session_id: String, material_id: String, active_seconds_delta: u32, checkpoint: Option<ReadingCheckpoint>, timestamp_ms: i64) -> Result<ReadingEventV2, DocumentError> {
zx_document_core::events_v2::push_heartbeat_checkpoint_v2(&session_id, &material_id, active_seconds_delta, checkpoint, timestamp_ms).map_err(Into::into)
}
#[uniffi::export]
fn push_marked_as_read_v2(session_id: String, material_id: String, timestamp_ms: i64) -> Result<ReadingEventV2, DocumentError> {
eprintln!("[Rust] push_marked_as_read — mid={}", material_id);
zx_document_core::events_v2::push_marked_as_read_v2(&session_id, &material_id, timestamp_ms).map_err(Into::into)
}
// ── 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)
let events = zx_document_core::events_v2::export_pending_events_v2(limit, timestamp_ms);
if !events.is_empty() { eprintln!("[Rust] export_events — count={}", events.len()); }
events
}
#[uniffi::export]
fn reload_stale_events_v2() -> u32 {
zx_document_core::events_v2::reload_stale_events_v2()
let n = zx_document_core::events_v2::reload_stale_events_v2();
if n > 0 { eprintln!("[Rust] reload_stale — count={}", n); }
n
}
#[uniffi::export]
@ -122,14 +139,26 @@ fn get_event_buffer_state_v2() -> EventBufferStateV2 {
#[uniffi::export]
fn ack_events_v2(event_ids: Vec<String>) -> u32 {
eprintln!("[Rust] ack_events — count={}", event_ids.len());
zx_document_core::events_v2::ack_events_v2(&event_ids)
}
#[uniffi::export]
fn mark_events_failed_v2(event_ids: Vec<String>) -> u32 {
eprintln!("[Rust] mark_failed — count={}", event_ids.len());
zx_document_core::events_v2::mark_events_failed_v2(&event_ids)
}
#[uniffi::export]
fn compute_file_fingerprint(file_path: String) -> Result<String, DocumentError> {
zx_document_core::fingerprint::compute_file_fingerprint(&file_path).map_err(Into::into)
}
#[uniffi::export]
fn build_representation_fingerprint(source_fingerprint: String, renderer_kind: RendererKind, parser_version: String, schema_version: u32) -> String {
zx_document_core::fingerprint::build_representation_fingerprint(&source_fingerprint, &renderer_kind, &parser_version, schema_version)
}
// FFI-compatible DocumentBlock (tuple variants, UniFFI proc-macro)
#[derive(Debug, uniffi::Enum)]
pub enum DocumentBlock {
@ -181,6 +210,11 @@ pub enum DocumentError {
ParseError,
InvalidEncoding,
IoError,
SessionNotFound,
SessionAlreadyActive,
SessionNotActive,
BufferOverflow,
Unsupported,
}
impl std::fmt::Display for DocumentError {
@ -191,6 +225,11 @@ impl std::fmt::Display for DocumentError {
Self::ParseError => write!(f, "Parse error"),
Self::InvalidEncoding => write!(f, "Invalid encoding"),
Self::IoError => write!(f, "IO error"),
Self::SessionNotFound => write!(f, "Session not found"),
Self::SessionAlreadyActive => write!(f, "Session already active"),
Self::SessionNotActive => write!(f, "Session not active"),
Self::BufferOverflow => write!(f, "Buffer overflow"),
Self::Unsupported => write!(f, "Unsupported"),
}
}
}
@ -209,6 +248,20 @@ impl From<zx_document_core::error::DocumentError> for DocumentError {
}
}
impl From<zx_document_core::session_v2::SessionError> for DocumentError {
fn from(e: zx_document_core::session_v2::SessionError) -> Self {
match e {
zx_document_core::session_v2::SessionError::NotFound => Self::SessionNotFound,
zx_document_core::session_v2::SessionError::AlreadyClosed
| zx_document_core::session_v2::SessionError::AlreadyInterrupted
| zx_document_core::session_v2::SessionError::AlreadyFailed => Self::SessionAlreadyActive,
zx_document_core::session_v2::SessionError::NotActive
| zx_document_core::session_v2::SessionError::NotClosed => Self::SessionNotActive,
zx_document_core::session_v2::SessionError::LockPoisoned => Self::Unsupported,
}
}
}
#[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)

View File

@ -49,7 +49,7 @@ namespace zx_document {
// ── V2 Reading Session ──
[Throws=DocumentError]
string start_reading_session_v2(ReadingMaterialRef material, i64 timestamp_ms);
string start_reading_session_v2(ReadingMaterialRefV2 material, i64 timestamp_ms);
[Throws=DocumentError]
void pause_reading_session_v2([ByRef] string session_id);
@ -58,7 +58,20 @@ namespace zx_document {
void resume_reading_session_v2([ByRef] string session_id);
[Throws=DocumentError]
void close_reading_session_v2([ByRef] string session_id);
void close_reading_session_v2([ByRef] string session_id, i64 timestamp_ms);
ReadingSessionV2? get_active_session_v2([ByRef] string material_id);
ReadingSessionV2? get_session_v2_ffi([ByRef] string session_id);
[Throws=DocumentError]
void mark_session_interrupted_v2([ByRef] string session_id);
[Throws=DocumentError]
void mark_session_failed_v2([ByRef] string session_id);
[Throws=DocumentError]
void set_session_closed_at_ms_v2([ByRef] string session_id, i64 timestamp_ms);
// ── V2 Reading Events ──
[Throws=DocumentError]
@ -73,6 +86,12 @@ namespace zx_document {
[Throws=DocumentError]
ReadingEventV2 push_heartbeat_v2([ByRef] string session_id, [ByRef] string material_id, u32 active_seconds_delta, ReadingPosition? position, i64 timestamp_ms);
[Throws=DocumentError]
ReadingEventV2 push_checkpoint_changed_v2([ByRef] string session_id, [ByRef] string material_id, ReadingCheckpoint checkpoint, i64 timestamp_ms);
[Throws=DocumentError]
ReadingEventV2 push_heartbeat_checkpoint_v2([ByRef] string session_id, [ByRef] string material_id, u32 active_seconds_delta, ReadingCheckpoint? checkpoint, i64 timestamp_ms);
[Throws=DocumentError]
ReadingEventV2 push_marked_as_read_v2([ByRef] string session_id, [ByRef] string material_id, i64 timestamp_ms);
@ -83,8 +102,15 @@ namespace zx_document {
u32 mark_events_failed_v2(sequence<string> event_ids);
[Throws=DocumentError]
string compute_file_fingerprint([ByRef] string file_path);
string build_representation_fingerprint([ByRef] string source_fingerprint, RendererKind renderer_kind, [ByRef] string parser_version, u32 schema_version);
u32 reload_stale_events_v2();
EventBufferStateV2 get_event_buffer_state_v2();
// ── V1 (deprecated) ──
void push_reading_event(ReadingEvent event);
void update_reading_position([ByRef] string material_id, ReadingPosition position);
@ -99,6 +125,11 @@ enum DocumentError {
"ParseError",
"InvalidEncoding",
"IoError",
"SessionNotFound",
"SessionAlreadyActive",
"SessionNotActive",
"BufferOverflow",
"Unsupported",
};
// ── V2 Types ──
@ -107,24 +138,46 @@ dictionary ReadingMaterialRef {
string material_id;
};
dictionary ReadingMaterialRefV2 {
string material_id;
string file_path;
string file_name;
string file_type;
string? mime_type;
u64? file_size;
string? content_hash;
i64? created_at_ms;
};
[Enum]
interface ReadingSessionStatus {
Active();
Paused();
Closed();
Interrupted();
Failed();
};
dictionary ReadingSessionV2 {
string client_session_id;
ReadingMaterialRef material;
ReadingMaterialRefV2 material;
i64 started_at_ms;
i64? closed_at_ms;
i64 last_event_at_ms;
u64 next_sequence;
u32 total_active_seconds;
ReadingPosition? last_position;
ReadingCheckpoint? last_checkpoint;
ReadingSessionStatus status;
};
dictionary EventBufferStateV2 {
u32 total;
u32 pending;
u32 exported;
u32 failed;
};
[Enum]
interface ReadingEventTypeV2 {
MaterialOpened();
@ -140,11 +193,34 @@ dictionary ReadingEventV2 {
string material_id;
ReadingEventTypeV2 event_type;
ReadingPosition? position;
ReadingCheckpoint? checkpoint;
u32 active_seconds_delta;
i64 timestamp_ms;
u64 sequence;
};
[Enum]
interface RendererKind {
Block();
Pdf();
Image();
Workbook();
Slide();
Epub();
Unknown();
};
dictionary ReadingCheckpoint {
u32 schema_version;
string material_id;
string representation_fingerprint;
RendererKind renderer_kind;
u64 captured_at_ms;
string device_id;
u64 device_sequence;
ReadingPosition position;
};
[Enum]
interface MaterialType {
Markdown();
@ -188,10 +264,15 @@ dictionary DocumentInfo {
[Enum]
interface ReadingPosition {
Block(string block_id, f32 block_progress, f32 overall_progress);
Markdown(string block_id, f32 scroll_progress);
Text(u32 line_number, f32 scroll_progress);
Pdf(u32 page_number, f32 page_progress, f32 overall_progress);
PdfViewport(u32 page_number, f32 anchor_x, f32 anchor_y, f32 zoom_scale, f32 overall_progress);
Image(f32 zoom_scale, f32 offset_x, f32 offset_y);
ImageViewport(f32 zoom_scale, f32 anchor_x, f32 anchor_y);
Workbook(string sheet_id, u32 top_row, u32 left_column, string? selected_cell, f32 zoom_scale, f32? overall_progress);
Slide(u32 slide_index, f32 zoom_scale, f32 anchor_x, f32 anchor_y, f32 overall_progress);
Epub(string chapter_id, f32 chapter_progress, f32 overall_progress);
Unknown();
};

View File

@ -7,7 +7,7 @@ RUST_DIR="$(cd "$(dirname "$0")/.." && pwd)"
OUT_DIR="$RUST_DIR/bindings/ios"
LIB_NAME="libzx_document_ffi.a"
rustup target add aarch64-apple-ios aarch64-apple-ios-sim
rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
echo "==> Building for iOS device (arm64)..."
cargo build --release --target aarch64-apple-ios -p zx_document_ffi
@ -15,11 +15,21 @@ cargo build --release --target aarch64-apple-ios -p zx_document_ffi
echo "==> Building for iOS simulator (arm64)..."
cargo build --release --target aarch64-apple-ios-sim -p zx_document_ffi
echo "==> Building for iOS simulator (x86_64)..."
cargo build --release --target x86_64-apple-ios -p zx_document_ffi
rm -rf "$OUT_DIR/ZxDocumentRuntime.xcframework"
mkdir -p "$OUT_DIR/device" "$OUT_DIR/simulator" "$OUT_DIR/generated"
cp "target/aarch64-apple-ios/release/$LIB_NAME" "$OUT_DIR/device/"
cp "target/aarch64-apple-ios-sim/release/$LIB_NAME" "$OUT_DIR/simulator/"
cp "target/aarch64-apple-ios-sim/release/$LIB_NAME" "$OUT_DIR/simulator/libzx_document_ffi_arm64.a"
cp "target/x86_64-apple-ios/release/$LIB_NAME" "$OUT_DIR/simulator/libzx_document_ffi_x86_64.a"
echo "==> Creating universal iOS simulator static library..."
lipo -create \
"$OUT_DIR/simulator/libzx_document_ffi_arm64.a" \
"$OUT_DIR/simulator/libzx_document_ffi_x86_64.a" \
-output "$OUT_DIR/simulator/$LIB_NAME"
echo "==> Generating Swift bindings (UDL bindgen)..."
rm -f "$OUT_DIR/generated/zx_document.swift"

View File

@ -38,6 +38,27 @@ check uniffi_zx_document_ffi_fn_func_update_reading_position
check uniffi_zx_document_ffi_fn_func_export_pending_events
check uniffi_zx_document_ffi_fn_func_clear_exported_events
# V2 session lifecycle
check uniffi_zx_document_ffi_fn_func_start_reading_session_v2
check uniffi_zx_document_ffi_fn_func_pause_reading_session_v2
check uniffi_zx_document_ffi_fn_func_resume_reading_session_v2
check uniffi_zx_document_ffi_fn_func_close_reading_session_v2
# V2 event push
check uniffi_zx_document_ffi_fn_func_push_material_opened_v2
check uniffi_zx_document_ffi_fn_func_push_material_closed_v2
check uniffi_zx_document_ffi_fn_func_push_position_changed_v2
check uniffi_zx_document_ffi_fn_func_push_heartbeat_v2
check uniffi_zx_document_ffi_fn_func_push_marked_as_read_v2
# V2 buffer management
check uniffi_zx_document_ffi_fn_func_export_pending_events_v2
check uniffi_zx_document_ffi_fn_func_ack_events_v2
check uniffi_zx_document_ffi_fn_func_mark_events_failed_v2
check uniffi_zx_document_ffi_fn_func_reload_stale_events_v2
check uniffi_zx_document_ffi_fn_func_get_event_buffer_state_v2
check uniffi_zx_document_ffi_fn_func_cleanup_stale_sessions_ffi
# Buffer management
check ffi_zx_document_ffi_rustbuffer_alloc
check ffi_zx_document_ffi_rustbuffer_from_bytes

View File

@ -12,11 +12,13 @@ c = c.replace(
"fileprivate extension Data {\n init(rustBuffer: RustBuffer) {\n let buf = UnsafeRawBufferPointer(start: rustBuffer.data, count: Int(rustBuffer.len))\n self.init(buf)\n }\n}"
)
# Patch 2: Fix UnsafeMutableRawPointer cast for iOS 26 SDK
c = c.replace(
"let bytes = UnsafeBufferPointer<UInt8>(start: value.data!, count: Int(value.len))",
"let bytes = UnsafeBufferPointer<UInt8>(start: value.data!.assumingMemoryBound(to: UInt8.self), count: Int(value.len))"
)
# Patch 2: removed — `RustBuffer.data` is now `UnsafeMutablePointer<UInt8>?` on current SDKs,
# so `.assumingMemoryBound(to:)` is not needed (and would cause a compile error).
# Kept as comment in case an older SDK needs the raw-pointer cast.
# c = c.replace(
# "let bytes = UnsafeBufferPointer<UInt8>(start: value.data!, count: Int(value.len))",
# "let bytes = UnsafeBufferPointer<UInt8>(start: value.data!.assumingMemoryBound(to: UInt8.self), count: Int(value.len))"
# )
# Patch 3: Remove checksum initialization
# UniFFI 0.28 UDL scaffolding does not export checksum functions as #[no_mangle],