kobo_db_tools/parser/
parser.rs

1use crate::{
2    get_bookmarks, Bookmark, Brightness, BrightnessEvent, BrightnessHistory, DictionaryWord,
3    NaturalLightHistory, ReadingSession, ReadingSessions,
4};
5use chrono::{DateTime, Utc};
6use rusqlite::Connection;
7use std::collections::HashMap;
8use std::str::FromStr;
9use thiserror::Error;
10
11#[derive(Debug, Error)]
12pub enum ParseError {
13    #[error("Event is not valid")]
14    InvalidEventType,
15    #[error("Error during session completation")]
16    SessionCompletionFailed,
17    #[error("Error during deserialize")]
18    DeserializationError,
19}
20
21#[derive(serde::Deserialize, Clone)]
22struct ReadingSessionAttributes {
23    progress: String,
24    volumeid: Option<String>,
25    title: Option<String>,
26}
27
28#[derive(serde::Deserialize)]
29struct LeaveContentMetrics {
30    #[serde(rename = "ButtonPressCount")]
31    button_press_count: usize,
32    #[serde(rename = "SecondsRead")]
33    seconds_read: usize,
34    #[serde(rename = "PagesTurned")]
35    pages_turned: usize,
36}
37
38#[derive(serde::Deserialize)]
39struct LightAttributes {
40    #[serde(rename = "Method")]
41    method: String,
42}
43
44#[derive(serde::Deserialize)]
45struct LightMetrics {
46    #[serde(alias = "NewNaturalLight")]
47    #[serde(alias = "NewBrightness")]
48    new_light: u8,
49}
50
51#[derive(serde::Deserialize)]
52struct DicitonaryAttributes {
53    #[serde(rename = "Dictionary")]
54    lang: String,
55    #[serde(rename = "Word")]
56    word: String,
57}
58
59#[derive(Debug)]
60pub struct EventAnalysis {
61    pub sessions: ReadingSessions,
62    pub terms: HashMap<DictionaryWord, usize>,
63    pub brightness_history: BrightnessHistory,
64    pub natural_light_history: NaturalLightHistory,
65    pub bookmarks: Vec<Bookmark>,
66}
67
68pub struct Parser;
69
70impl Parser {
71    pub fn parse_events(db: &Connection) -> rusqlite::Result<EventAnalysis> {
72        let q = "SELECT Id, Type, Timestamp, Attributes, Metrics\n                 FROM AnalyticsEvents\n                 WHERE Type IN\n                 (  'OpenContent', 'LeaveContent',\n                    'DictionaryLookup',\n                    'BrightnessAdjusted','NaturalLightAdjusted'\n                 )\n                 ORDER BY Timestamp ASC;";
73
74        let mut stmt = db.prepare(q)?;
75        let mut rows = stmt.query([])?;
76
77        let mut analysis = EventAnalysis {
78            sessions: ReadingSessions::new(),
79            terms: HashMap::new(),
80            brightness_history: BrightnessHistory::new(),
81            natural_light_history: NaturalLightHistory::new(),
82            bookmarks: get_bookmarks(db)?,
83        };
84
85        let mut current_session: Option<ReadingSession> = None;
86
87        while let Some(row) = rows.next()? {
88            let event_id: String = row.get("Id")?;
89            let event_type: String = row.get("Type")?;
90            let ts_str: String = row.get("Timestamp")?;
91            let ts = DateTime::<Utc>::from_str(&ts_str).map_err(|e| {
92                rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
93            })?;
94
95            match event_type.as_str() {
96                "OpenContent" | "LeaveContent" => {
97                    let attr_json: String = row.get("Attributes")?;
98                    let attr: ReadingSessionAttributes =
99                        serde_json::from_str(&attr_json).map_err(|e| {
100                            rusqlite::Error::FromSqlConversionFailure(
101                                1,
102                                rusqlite::types::Type::Text,
103                                Box::new(e),
104                            )
105                        })?;
106                    let progress = attr.progress.parse::<u8>().unwrap_or(0);
107
108                    let metrics = if event_type == "LeaveContent" {
109                        let metr_json: String = row.get("Metrics")?;
110                        Some(
111                            serde_json::from_str::<LeaveContentMetrics>(&metr_json).map_err(|e| {
112                                rusqlite::Error::FromSqlConversionFailure(
113                                    2,
114                                    rusqlite::types::Type::Text,
115                                    Box::new(e),
116                                )
117                            })?,
118                        )
119                    } else {
120                        None
121                    };
122
123                    match handle_reading_session_event(
124                        &event_type,
125                        &event_id,
126                        &mut current_session,
127                        ts,
128                        progress,
129                        &attr,
130                        metrics,
131                    ) {
132                        Ok(Some(session)) => analysis.sessions.add_session(session),
133                        Ok(None) => {}
134                        Err(e) => eprintln!("Errore evento {}: {:?}", &event_id, e),
135                    }
136                }
137                "DictionaryLookup" => {
138                    let attr_json: String = row.get("Attributes")?;
139                    *analysis
140                        .terms
141                        .entry(on_dictionary_lookup(attr_json)?)
142                        .or_insert(0) += 1;
143                }
144                "BrightnessAdjusted" => {
145                    let attr_json: String = row.get("Attributes")?;
146                    let metr_json: String = row.get("Metrics")?;
147                    let event = on_light_adjusted(attr_json, metr_json, ts)?;
148                    analysis.brightness_history.insert(event);
149                }
150                "NaturalLightAdjusted" => {
151                    let attr_json: String = row.get("Attributes")?;
152                    let metr_json: String = row.get("Metrics")?;
153                    let event = on_light_adjusted(attr_json, metr_json, ts)?;
154                    analysis.natural_light_history.insert(event);
155                }
156                _ => {
157                    eprintln!("Unknown event: {}", event_type);
158                }
159            }
160        }
161        Ok(analysis)
162    }
163}
164
165fn handle_reading_session_event(
166    event_type: &str,
167    event_id: &str,
168    current_session: &mut Option<ReadingSession>,
169    ts: DateTime<Utc>,
170    progress: u8,
171    attr: &ReadingSessionAttributes,
172    metrics: Option<LeaveContentMetrics>,
173) -> Result<Option<ReadingSession>, ParseError> {
174    match event_type {
175        "OpenContent" => {
176            *current_session = Some(ReadingSession::new(
177                ts,
178                progress,
179                attr.title.clone(),
180                attr.volumeid.clone(),
181                event_id.to_string(),
182            ));
183            Ok(None)
184        }
185        "LeaveContent" => {
186            if let Some(ref mut session) = current_session {
187                let _open_content_id = session.open_content_id.clone();
188                let m = metrics.ok_or(ParseError::SessionCompletionFailed)?;
189                session
190                    .complete_session(
191                        ts,
192                        progress,
193                        m.button_press_count as u64,
194                        m.seconds_read as u64,
195                        m.pages_turned as u64,
196                        event_id.to_string(),
197                    )
198                    .map_err(|_| ParseError::SessionCompletionFailed)?;
199
200                let completed = std::mem::take(session);
201                *current_session = None;
202                Ok(Some(completed))
203            } else {
204                Err(ParseError::SessionCompletionFailed)
205            }
206        }
207        _ => Err(ParseError::InvalidEventType),
208    }
209}
210
211fn on_dictionary_lookup(attr_json: String) -> rusqlite::Result<DictionaryWord> {
212    let attr: DicitonaryAttributes = serde_json::from_str(&attr_json).map_err(|e| {
213        rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e))
214    })?;
215    Ok(DictionaryWord::new(attr.word, attr.lang))
216}
217
218fn on_light_adjusted(
219    attr_json: String,
220    metr_json: String,
221    ts: DateTime<Utc>,
222) -> rusqlite::Result<BrightnessEvent> {
223    let attributes: LightAttributes = serde_json::from_str(&attr_json).map_err(|e| {
224        rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e))
225    })?;
226    let metrics: LightMetrics = serde_json::from_str(&metr_json).map_err(|e| {
227        rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e))
228    })?;
229    let brightness = Brightness::new(attributes.method, metrics.new_light);
230    Ok(BrightnessEvent::new(brightness, ts))
231}