diff --git a/crates/zx_document_core/src/lib.rs b/crates/zx_document_core/src/lib.rs index cdc78ed..60101ae 100644 --- a/crates/zx_document_core/src/lib.rs +++ b/crates/zx_document_core/src/lib.rs @@ -13,4 +13,5 @@ pub mod pdf; pub mod progress; pub mod reading_material; pub mod search; +pub mod session_v2; pub mod text; diff --git a/crates/zx_document_core/src/session_v2.rs b/crates/zx_document_core/src/session_v2.rs new file mode 100644 index 0000000..676dbd4 --- /dev/null +++ b/crates/zx_document_core/src/session_v2.rs @@ -0,0 +1,254 @@ +use std::collections::HashMap; +use std::sync::Mutex; + +use crate::progress::ReadingPosition; +use crate::reading_material::ReadingMaterialRef; + +fn sessions() -> &'static Mutex> { + use std::sync::OnceLock; + static SESSIONS: OnceLock>> = OnceLock::new(); + SESSIONS.get_or_init(|| Mutex::new(HashMap::new())) +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ReadingSessionStatus { + Active, + Paused, + Closed, +} + +#[derive(Debug, Clone)] +pub struct ReadingSessionV2 { + pub client_session_id: String, + pub material: ReadingMaterialRef, + pub started_at_ms: i64, + pub last_event_at_ms: i64, + pub next_sequence: u64, + pub total_active_seconds: u32, + pub last_position: Option, + pub status: ReadingSessionStatus, +} + +/// Start a new reading session. Returns the client_session_id as handle. +pub fn start_reading_session_v2( + material: ReadingMaterialRef, + timestamp_ms: i64, +) -> Result { + let session_id = uuid::Uuid::new_v4().to_string(); + + let session = ReadingSessionV2 { + client_session_id: session_id.clone(), + material, + started_at_ms: timestamp_ms, + last_event_at_ms: timestamp_ms, + next_sequence: 1, + total_active_seconds: 0, + last_position: None, + status: ReadingSessionStatus::Active, + }; + + match sessions().lock() { + Ok(mut map) => { + map.insert(session_id.clone(), session); + Ok(session_id) + } + Err(_) => Err(SessionError::LockPoisoned), + } +} + +/// Pause the session. Stops active time tracking. +pub fn pause_reading_session_v2(session_id: &str) -> Result<(), SessionError> { + with_session_mut(session_id, |s| { + if s.status == ReadingSessionStatus::Closed { + return Err(SessionError::AlreadyClosed); + } + s.status = ReadingSessionStatus::Paused; + Ok(()) + }) +} + +/// Resume a paused session. +pub fn resume_reading_session_v2(session_id: &str) -> Result<(), SessionError> { + with_session_mut(session_id, |s| { + if s.status == ReadingSessionStatus::Closed { + return Err(SessionError::AlreadyClosed); + } + s.status = ReadingSessionStatus::Active; + Ok(()) + }) +} + +/// Close the session. No more events allowed. +pub fn close_reading_session_v2(session_id: &str) -> Result<(), SessionError> { + with_session_mut(session_id, |s| { + if s.status == ReadingSessionStatus::Closed { + return Err(SessionError::AlreadyClosed); + } + s.status = ReadingSessionStatus::Closed; + Ok(()) + }) +} + +/// 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, +) -> Result { + with_session_mut(session_id, |s| { + if s.status == ReadingSessionStatus::Closed { + return Err(SessionError::AlreadyClosed); + } + if s.status != ReadingSessionStatus::Active && active_seconds_delta > 0 { + return Err(SessionError::NotActive); + } + let seq = s.next_sequence; + s.next_sequence += 1; + s.last_event_at_ms = timestamp_ms; + if s.status == ReadingSessionStatus::Active { + s.total_active_seconds += active_seconds_delta; + } + if position.is_some() { + s.last_position = position; + } + Ok(seq) + }) +} + +/// Get a copy of the session info. +pub fn get_session_v2(session_id: &str) -> Result { + match sessions().lock() { + Ok(map) => map.get(session_id).cloned().ok_or(SessionError::NotFound), + Err(_) => Err(SessionError::LockPoisoned), + } +} + +/// Remove a closed session from memory. +pub fn remove_session_v2(session_id: &str) -> Result<(), SessionError> { + match sessions().lock() { + Ok(mut map) => { + map.remove(session_id).ok_or(SessionError::NotFound)?; + Ok(()) + } + Err(_) => Err(SessionError::LockPoisoned), + } +} + +// ── helpers ── + +fn with_session_mut(session_id: &str, f: F) -> Result +where + F: FnOnce(&mut ReadingSessionV2) -> Result, +{ + match sessions().lock() { + Ok(mut map) => { + let session = map.get_mut(session_id).ok_or(SessionError::NotFound)?; + f(session) + } + Err(_) => Err(SessionError::LockPoisoned), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SessionError { + NotFound, + AlreadyClosed, + NotActive, + LockPoisoned, +} + +impl std::fmt::Display for SessionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotFound => write!(f, "Session not found"), + Self::AlreadyClosed => write!(f, "Session already closed"), + Self::NotActive => write!(f, "Session is not active (paused or closed)"), + Self::LockPoisoned => write!(f, "Session lock poisoned"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_material() -> ReadingMaterialRef { + ReadingMaterialRef::new("test_mat_001") + } + + #[test] + fn test_start_session() { + let id = start_reading_session_v2(test_material(), 1000).unwrap(); + assert!(!id.is_empty()); + let s = get_session_v2(&id).unwrap(); + assert_eq!(s.material.material_id, "test_mat_001"); + assert_eq!(s.status, ReadingSessionStatus::Active); + assert_eq!(s.next_sequence, 1); + remove_session_v2(&id).unwrap(); + } + + #[test] + fn test_pause_resume_close() { + 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); + resume_reading_session_v2(&id).unwrap(); + assert_eq!(get_session_v2(&id).unwrap().status, ReadingSessionStatus::Active); + close_reading_session_v2(&id).unwrap(); + assert_eq!(get_session_v2(&id).unwrap().status, ReadingSessionStatus::Closed); + remove_session_v2(&id).unwrap(); + } + + #[test] + fn test_sequence_increments() { + let id = start_reading_session_v2(test_material(), 1000).unwrap(); + let s1 = record_session_event_v2(&id, 2000, 15, None).unwrap(); + let s2 = record_session_event_v2(&id, 3000, 15, None).unwrap(); + assert_eq!(s1, 1); + assert_eq!(s2, 2); + assert_eq!(get_session_v2(&id).unwrap().total_active_seconds, 30); + remove_session_v2(&id).unwrap(); + } + + #[test] + fn test_closed_rejects_events() { + let id = start_reading_session_v2(test_material(), 1000).unwrap(); + close_reading_session_v2(&id).unwrap(); + assert!(record_session_event_v2(&id, 2000, 10, None).is_err()); + assert!(close_reading_session_v2(&id).is_err()); + remove_session_v2(&id).unwrap(); + } + + #[test] + fn test_paused_rejects_active_seconds() { + let id = start_reading_session_v2(test_material(), 1000).unwrap(); + pause_reading_session_v2(&id).unwrap(); + // PositionChanged (delta=0) should be allowed when paused + let seq = record_session_event_v2(&id, 2000, 0, None).unwrap(); + assert_eq!(seq, 1); + // Heartbeat (delta>0) should be rejected when paused + assert!(record_session_event_v2(&id, 3000, 15, None).is_err()); + remove_session_v2(&id).unwrap(); + } + + #[test] + fn test_not_found() { + assert!(get_session_v2("nonexistent").is_err()); + assert!(close_reading_session_v2("nonexistent").is_err()); + } + + #[test] + fn test_two_sessions_independent() { + let a = start_reading_session_v2(test_material(), 1000).unwrap(); + let b = start_reading_session_v2(ReadingMaterialRef::new("mat_b"), 2000).unwrap(); + assert_ne!(a, b); + record_session_event_v2(&a, 3000, 10, None).unwrap(); + record_session_event_v2(&b, 4000, 20, None).unwrap(); + assert_eq!(get_session_v2(&a).unwrap().total_active_seconds, 10); + assert_eq!(get_session_v2(&b).unwrap().total_active_seconds, 20); + remove_session_v2(&a).unwrap(); + remove_session_v2(&b).unwrap(); + } +}