kobo-db-tools 0.0.13

A Rust library to parse and analyze Kobo e-reader SQLite databases.
Documentation
use chrono::{DateTime, SecondsFormat, Utc};
use serde::Serialize;

use crate::export::{Export, ExportError};
use crate::model::CorrelatedSession;

#[derive(Serialize)]
struct SessionExportRow {
    session_id: String,
    open_content_id: String,
    leave_content_id: Option<String>,
    start_time: String,
    end_time: Option<String>,
    duration_seconds: Option<i64>,
    start_progress: u8,
    end_progress: Option<u8>,
    progress_delta: Option<i16>,
    seconds_read: Option<u64>,
    pages_turned: Option<u64>,
    button_press_count: Option<u64>,
    book_title: Option<String>,
    volume_id: Option<String>,
    dictionary_lookups: usize,
    brightness_events: usize,
    natural_light_events: usize,
    app_events: usize,
}

fn format_time(ts: DateTime<Utc>) -> String {
    ts.to_rfc3339_opts(SecondsFormat::Secs, true)
}

fn to_export_row(session: &CorrelatedSession) -> SessionExportRow {
    let end_progress = session.session.end_progress;
    let progress_delta = end_progress.map(|end| end as i16 - session.session.start_progress as i16);
    SessionExportRow {
        session_id: session.session.id.to_string(),
        open_content_id: session.session.open_content_id.clone(),
        leave_content_id: session.session.leave_content_id.clone(),
        start_time: format_time(session.session.time_start),
        end_time: session.session.time_end.map(format_time),
        duration_seconds: session.session.duration().map(|d| d.num_seconds()),
        start_progress: session.session.start_progress,
        end_progress,
        progress_delta,
        seconds_read: session.session.seconds_read,
        pages_turned: session.session.pages_turned,
        button_press_count: session.session.button_press_count,
        book_title: session.session.book_title.clone(),
        volume_id: session.session.volume_id.clone(),
        dictionary_lookups: session.dictionary.len(),
        brightness_events: session.brightness.len(),
        natural_light_events: session.natural_light.len(),
        app_events: session.app_events.len(),
    }
}

impl Export for [CorrelatedSession] {
    fn to_csv(&self) -> Result<String, ExportError> {
        let mut wtr = csv::Writer::from_writer(vec![]);
        for session in self {
            wtr.serialize(to_export_row(session))?;
        }
        Ok(String::from_utf8(wtr.into_inner()?)?)
    }

    fn to_md(&self) -> Result<String, ExportError> {
        let mut buffer = Vec::new();
        use std::io::Write;

        writeln!(
            buffer,
            "| Start | End | Progress Δ | Pages | Buttons | Book | Dictionary | Brightness |"
        )?;
        writeln!(
            buffer,
            "|-------|-----|------------|-------|---------|------|------------|------------|"
        )?;

        for session in self {
            let row = to_export_row(session);
            let end_time = row.end_time.unwrap_or_else(|| "N/A".to_string());
            let progress_delta = row
                .progress_delta
                .map(|delta| delta.to_string())
                .unwrap_or_else(|| "N/A".to_string());
            let pages = row
                .pages_turned
                .map(|pages| pages.to_string())
                .unwrap_or_else(|| "N/A".to_string());
            let buttons = row
                .button_press_count
                .map(|count| count.to_string())
                .unwrap_or_else(|| "N/A".to_string());
            let book_title = row.book_title.unwrap_or_else(|| "N/A".to_string());
            let brightness_count = row.brightness_events + row.natural_light_events;

            writeln!(
                buffer,
                "| {} | {} | {} | {} | {} | {} | {} | {} |",
                row.start_time,
                end_time,
                progress_delta,
                pages,
                buttons,
                book_title,
                row.dictionary_lookups,
                brightness_count
            )?;
        }

        Ok(String::from_utf8(buffer)?)
    }

    fn to_json(&self) -> Result<String, ExportError> {
        let rows: Vec<SessionExportRow> = self.iter().map(to_export_row).collect();
        serde_json::to_string(&rows).map_err(ExportError::JsonToString)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{
        AppEvent, AppEventKind, Brightness, BrightnessEvent, CorrelatedSession, DictionaryWord,
        ReadingSession,
    };
    use chrono::{DateTime, Utc};
    use std::str::FromStr;
    use uuid::Uuid;

    fn build_session() -> CorrelatedSession {
        let start = DateTime::<Utc>::from_str("2023-01-01T10:00:00Z").unwrap();
        let end = DateTime::<Utc>::from_str("2023-01-01T10:10:00Z").unwrap();
        let mut session = ReadingSession::new(
            start,
            10,
            Some("Book Title".to_string()),
            Some("book1".to_string()),
            "open1".to_string(),
        );
        session.id = Uuid::nil();
        session
            .complete_session(end, 20, 3, 600, 5, "leave1".to_string())
            .unwrap();
        let mut correlated = CorrelatedSession::new(session);
        correlated.dictionary.push(DictionaryWord::new(
            "test".to_string(),
            "en".to_string(),
            Some(Uuid::nil()),
        ));
        correlated.brightness.push(BrightnessEvent::new(
            Brightness::new("manual".to_string(), 50),
            start,
        ));
        correlated.natural_light.push(BrightnessEvent::new(
            Brightness::new("auto".to_string(), 60),
            start,
        ));
        correlated
            .app_events
            .push(AppEvent::new(AppEventKind::AppStart, start, None));
        correlated
    }

    #[test]
    fn test_sessions_to_csv() {
        let sessions = [build_session()];
        let expected = [
            "session_id,open_content_id,leave_content_id,start_time,end_time,duration_seconds,start_progress,end_progress,progress_delta,seconds_read,pages_turned,button_press_count,book_title,volume_id,dictionary_lookups,brightness_events,natural_light_events,app_events",
            "00000000-0000-0000-0000-000000000000,open1,leave1,2023-01-01T10:00:00Z,2023-01-01T10:10:00Z,600,10,20,10,600,5,3,Book Title,book1,1,1,1,1",
            "",
        ]
        .join("\n");
        assert_eq!(sessions.to_csv().unwrap(), expected);
    }

    #[test]
    fn test_sessions_to_json() {
        let sessions = [build_session()];
        let json = sessions.to_json().unwrap();
        assert!(json.contains("\"session_id\":\"00000000-0000-0000-0000-000000000000\""));
        assert!(json.contains("\"dictionary_lookups\":1"));
    }
}