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::path::Path;
9use std::str::FromStr;
10use thiserror::Error;
11
12#[derive(Debug, Error)]
13pub enum ParseError {
14    #[error("Event is not valid")]
15    InvalidEventType,
16    #[error("Error during session completation")]
17    SessionCompletionFailed,
18    #[error("Error during deserialize")]
19    DeserializationError,
20}
21
22#[derive(serde::Deserialize, Clone)]
23struct ReadingSessionAttributes {
24    progress: String,
25    volumeid: Option<String>,
26    title: Option<String>,
27}
28
29#[derive(serde::Deserialize)]
30struct LeaveContentMetrics {
31    #[serde(rename = "ButtonPressCount")]
32    button_press_count: usize,
33    #[serde(rename = "SecondsRead")]
34    seconds_read: usize,
35    #[serde(rename = "PagesTurned")]
36    pages_turned: usize,
37}
38
39#[derive(serde::Deserialize)]
40struct LightAttributes {
41    #[serde(rename = "Method")]
42    method: String,
43}
44
45#[derive(serde::Deserialize)]
46struct LightMetrics {
47    #[serde(alias = "NewNaturalLight")]
48    #[serde(alias = "NewBrightness")]
49    new_light: u8,
50}
51
52#[derive(serde::Deserialize)]
53struct DicitonaryAttributes {
54    #[serde(rename = "Dictionary")]
55    lang: String,
56    #[serde(rename = "Word")]
57    word: String,
58}
59
60#[derive(Debug, PartialEq)]
61pub enum ParseOption {
62    All,
63    ReadingSessions,
64    DictionaryLookups,
65    BrightnessHistory,
66    NaturalLightHistory,
67    Bookmarks,
68}
69
70#[derive(Debug, Default)]
71pub struct EventAnalysis {
72    pub sessions: Option<ReadingSessions>,
73    pub terms: Option<HashMap<DictionaryWord, usize>>,
74    pub brightness_history: Option<BrightnessHistory>,
75    pub natural_light_history: Option<NaturalLightHistory>,
76    pub bookmarks: Option<Vec<Bookmark>>,
77}
78
79pub struct Parser;
80
81impl Parser {
82    pub fn parse_events(db: &Connection, option: ParseOption) -> rusqlite::Result<EventAnalysis> {
83        let mut analysis = EventAnalysis::default();
84
85        let mut event_types_to_query = Vec::new();
86        let mut get_bookmarks_flag = false;
87
88        match option {
89            ParseOption::All => {
90                event_types_to_query.extend_from_slice(&[
91                    "'OpenContent'",
92                    "'LeaveContent'",
93                    "'DictionaryLookup'",
94                    "'BrightnessAdjusted'",
95                    "'NaturalLightAdjusted'",
96                ]);
97                get_bookmarks_flag = true;
98            }
99            ParseOption::ReadingSessions => {
100                event_types_to_query.extend_from_slice(&["'OpenContent'", "'LeaveContent'"]);
101            }
102            ParseOption::DictionaryLookups => {
103                event_types_to_query.push("'DictionaryLookup'");
104            }
105            ParseOption::BrightnessHistory => {
106                event_types_to_query.push("'BrightnessAdjusted'");
107            }
108            ParseOption::NaturalLightHistory => {
109                event_types_to_query.push("'NaturalLightAdjusted'");
110            }
111            ParseOption::Bookmarks => {
112                get_bookmarks_flag = true;
113            }
114        }
115
116        if get_bookmarks_flag {
117            analysis.bookmarks = Some(get_bookmarks(db)?);
118        }
119
120        if !event_types_to_query.is_empty() {
121            let q = format!(
122                "SELECT Id, Type, Timestamp, Attributes, Metrics FROM AnalyticsEvents WHERE Type IN ({}) ORDER BY Timestamp ASC;",
123                event_types_to_query.join(", ")
124            );
125
126            let mut stmt = db.prepare(&q)?;
127            let mut rows = stmt.query([])?;
128
129            let mut current_session: Option<ReadingSession> = None;
130            let mut sessions_vec = ReadingSessions::new();
131            let mut terms_map = HashMap::new();
132            let mut brightness_hist = BrightnessHistory::new();
133            let mut natural_light_hist = NaturalLightHistory::new();
134
135            while let Some(row) = rows.next()? {
136                let event_id: String = row.get("Id")?;
137                let event_type: String = row.get("Type")?;
138                let ts_str: String = row.get("Timestamp")?;
139                let ts = DateTime::<Utc>::from_str(&ts_str).map_err(|e| {
140                    rusqlite::Error::FromSqlConversionFailure(
141                        0,
142                        rusqlite::types::Type::Text,
143                        Box::new(e),
144                    )
145                })?;
146
147                match event_type.as_str() {
148                    "OpenContent" | "LeaveContent" => {
149                        if option == ParseOption::All || option == ParseOption::ReadingSessions {
150                            let attr_json: String = row.get("Attributes")?;
151                            let attr: ReadingSessionAttributes = serde_json::from_str(&attr_json)
152                                .map_err(|e| {
153                                rusqlite::Error::FromSqlConversionFailure(
154                                    1,
155                                    rusqlite::types::Type::Text,
156                                    Box::new(e),
157                                )
158                            })?;
159                            let progress = attr.progress.parse::<u8>().unwrap_or(0);
160
161                            let metrics = if event_type == "LeaveContent" {
162                                let metr_json: String = row.get("Metrics")?;
163                                Some(
164                                    serde_json::from_str::<LeaveContentMetrics>(&metr_json)
165                                        .map_err(|e| {
166                                            rusqlite::Error::FromSqlConversionFailure(
167                                                2,
168                                                rusqlite::types::Type::Text,
169                                                Box::new(e),
170                                            )
171                                        })?,
172                                )
173                            } else {
174                                None
175                            };
176
177                            match handle_reading_session_event(
178                                &event_type,
179                                &event_id,
180                                &mut current_session,
181                                ts,
182                                progress,
183                                &attr,
184                                metrics,
185                            ) {
186                                Ok(Some(session)) => sessions_vec.add_session(session),
187                                Ok(None) => {}
188                                Err(e) => eprintln!("Errore evento {}: {:?}", &event_id, e),
189                            }
190                        }
191                    }
192                    "DictionaryLookup" => {
193                        if option == ParseOption::All || option == ParseOption::DictionaryLookups {
194                            let attr_json: String = row.get("Attributes")?;
195                            *terms_map
196                                .entry(on_dictionary_lookup(attr_json)?)
197                                .or_insert(0) += 1;
198                        }
199                    }
200                    "BrightnessAdjusted" => {
201                        if option == ParseOption::All || option == ParseOption::BrightnessHistory {
202                            let attr_json: String = row.get("Attributes")?;
203                            let metr_json: String = row.get("Metrics")?;
204                            let event = on_light_adjusted(attr_json, metr_json, ts)?;
205                            brightness_hist.insert(event);
206                        }
207                    }
208                    "NaturalLightAdjusted" => {
209                        if option == ParseOption::All || option == ParseOption::NaturalLightHistory
210                        {
211                            let attr_json: String = row.get("Attributes")?;
212                            let metr_json: String = row.get("Metrics")?;
213                            let event = on_light_adjusted(attr_json, metr_json, ts)?;
214                            natural_light_hist.insert(event);
215                        }
216                    }
217                    _ => {
218                        eprintln!("Unknown event: {}", event_type);
219                    }
220                }
221            }
222            if option == ParseOption::All || option == ParseOption::ReadingSessions {
223                analysis.sessions = Some(sessions_vec);
224            }
225            if option == ParseOption::All || option == ParseOption::DictionaryLookups {
226                analysis.terms = Some(terms_map);
227            }
228            if option == ParseOption::All || option == ParseOption::BrightnessHistory {
229                analysis.brightness_history = Some(brightness_hist);
230            }
231            if option == ParseOption::All || option == ParseOption::NaturalLightHistory {
232                analysis.natural_light_history = Some(natural_light_hist);
233            }
234        }
235        Ok(analysis)
236    }
237    pub fn parse_from_str<P: AsRef<Path>>(
238        path: P,
239        option: ParseOption,
240    ) -> rusqlite::Result<EventAnalysis> {
241        let conn = Connection::open(path)?;
242        Self::parse_events(&conn, option)
243    }
244}
245
246fn handle_reading_session_event(
247    event_type: &str,
248    event_id: &str,
249    current_session: &mut Option<ReadingSession>,
250    ts: DateTime<Utc>,
251    progress: u8,
252    attr: &ReadingSessionAttributes,
253    metrics: Option<LeaveContentMetrics>,
254) -> Result<Option<ReadingSession>, ParseError> {
255    match event_type {
256        "OpenContent" => {
257            *current_session = Some(ReadingSession::new(
258                ts,
259                progress,
260                attr.title.clone(),
261                attr.volumeid.clone(),
262                event_id.to_string(),
263            ));
264            Ok(None)
265        }
266        "LeaveContent" => {
267            if let Some(ref mut session) = current_session {
268                let _open_content_id = session.open_content_id.clone();
269                let m = metrics.ok_or(ParseError::SessionCompletionFailed)?;
270                session
271                    .complete_session(
272                        ts,
273                        progress,
274                        m.button_press_count as u64,
275                        m.seconds_read as u64,
276                        m.pages_turned as u64,
277                        event_id.to_string(),
278                    )
279                    .map_err(|_| ParseError::SessionCompletionFailed)?;
280
281                let completed = std::mem::take(session);
282                *current_session = None;
283                Ok(Some(completed))
284            } else {
285                Err(ParseError::SessionCompletionFailed)
286            }
287        }
288        _ => Err(ParseError::InvalidEventType),
289    }
290}
291
292fn on_dictionary_lookup(attr_json: String) -> rusqlite::Result<DictionaryWord> {
293    let attr: DicitonaryAttributes = serde_json::from_str(&attr_json).map_err(|e| {
294        rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e))
295    })?;
296    Ok(DictionaryWord::new(attr.word, attr.lang))
297}
298
299fn on_light_adjusted(
300    attr_json: String,
301    metr_json: String,
302    ts: DateTime<Utc>,
303) -> rusqlite::Result<BrightnessEvent> {
304    let attributes: LightAttributes = serde_json::from_str(&attr_json).map_err(|e| {
305        rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e))
306    })?;
307    let metrics: LightMetrics = serde_json::from_str(&metr_json).map_err(|e| {
308        rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e))
309    })?;
310    let brightness = Brightness::new(attributes.method, metrics.new_light);
311    Ok(BrightnessEvent::new(brightness, ts))
312}
313
314#[cfg(test)]
315mod tests {
316    use super::{Parser, ParseOption};
317    use rusqlite::Connection;
318
319    fn setup_test_db() -> Connection {
320        let conn = Connection::open_in_memory().unwrap();
321        conn.execute_batch(
322            "CREATE TABLE AnalyticsEvents (\n                Id TEXT PRIMARY KEY,\n                Type TEXT NOT NULL,\n                Timestamp TEXT NOT NULL,\n                Attributes TEXT,\n                Metrics TEXT\n            );\n            CREATE TABLE content (\n                ContentID TEXT PRIMARY KEY,\n                Title TEXT\n            );\n            CREATE TABLE Bookmark (\n                BookmarkID TEXT PRIMARY KEY,\n                Text TEXT,\n                VolumeID TEXT,\n                Color INTEGER,\n                ChapterProgress REAL,\n                DateCreated TEXT,\n                DateModified TEXT\n            );",
323        )
324        .unwrap();
325        conn
326    }
327
328    #[test]
329    fn test_parse_events_all() {
330        let db = setup_test_db();
331
332        // Insert sample data
333        db.execute(
334            "INSERT INTO AnalyticsEvents (Id, Type, Timestamp, Attributes, Metrics) VALUES (?, ?, ?, ?, ?)",
335            &[
336                "session1_open",
337                "OpenContent",
338                "2023-01-01T10:00:00Z",
339                "{\"progress\":\"0\",\"volumeid\":\"book1\",\"title\":\"Book One\"}", ""
340            ],
341        ).unwrap();
342        db.execute(
343            "INSERT INTO AnalyticsEvents (Id, Type, Timestamp, Attributes, Metrics) VALUES (?, ?, ?, ?, ?)",
344            &[
345                "session1_leave",
346                "LeaveContent",
347                "2023-01-01T10:05:00Z",
348                "{\"progress\":\"10\",\"volumeid\":\"book1\",\"title\":\"Book One\"}", "{\"ButtonPressCount\":10,\"SecondsRead\":300,\"PagesTurned\":5}"
349            ],
350        ).unwrap();
351        db.execute(
352            "INSERT INTO AnalyticsEvents (Id, Type, Timestamp, Attributes, Metrics) VALUES (?, ?, ?, ?, ?)",
353            &[
354                "dict_lookup1",
355                "DictionaryLookup",
356                "2023-01-01T10:01:00Z",
357                "{\"Dictionary\":\"en\",\"Word\":\"test\"}", ""
358            ],
359        ).unwrap();
360        db.execute(
361            "INSERT INTO AnalyticsEvents (Id, Type, Timestamp, Attributes, Metrics) VALUES (?, ?, ?, ?, ?)",
362            &[
363                "brightness_adj1",
364                "BrightnessAdjusted",
365                "2023-01-01T10:02:00Z",
366                "{\"Method\":\"manual\"}", "{\"NewBrightness\":50}"
367            ],
368        ).unwrap();
369        db.execute(
370            "INSERT INTO AnalyticsEvents (Id, Type, Timestamp, Attributes, Metrics) VALUES (?, ?, ?, ?, ?)",
371            &[
372                "natural_light_adj1",
373                "NaturalLightAdjusted",
374                "2023-01-01T10:03:00Z",
375                "{\"Method\":\"auto\"}", "{\"NewNaturalLight\":70}"
376            ],
377        ).unwrap();
378
379        db.execute(
380            "INSERT INTO content (ContentID, Title) VALUES (?, ?)",
381            &["book1", "Book One"],
382        )
383        .unwrap();
384        db.execute(
385            "INSERT INTO Bookmark (BookmarkID, Text, VolumeID, Color, ChapterProgress, DateCreated, DateModified) VALUES (?, ?, ?, ?, ?, ?, ?)",
386            &["bookmark1", "Some text", "book1", "1", "0.5", "2023-01-01T10:06:00Z", "2023-01-01T10:06:00Z"],
387        ).unwrap();
388
389        let analysis = Parser::parse_events(&db, ParseOption::All).unwrap();
390
391        assert!(analysis.sessions.is_some());
392        assert_eq!(analysis.sessions.unwrap().sessions_count(), 1);
393
394        assert!(analysis.terms.is_some());
395        assert_eq!(analysis.terms.unwrap().len(), 1);
396
397        assert!(analysis.brightness_history.is_some());
398        assert_eq!(analysis.brightness_history.unwrap().events.len(), 1);
399
400        assert!(analysis.natural_light_history.is_some());
401        assert_eq!(analysis.natural_light_history.unwrap().events.len(), 1);
402
403        assert!(analysis.bookmarks.is_some());
404        assert_eq!(analysis.bookmarks.unwrap().len(), 1);
405    }
406
407    #[test]
408    fn test_parse_events_reading_sessions() {
409        let db = setup_test_db();
410        db.execute(
411            "INSERT INTO AnalyticsEvents (Id, Type, Timestamp, Attributes, Metrics) VALUES (?, ?, ?, ?, ?)",
412            &[
413                "session1_open",
414                "OpenContent",
415                "2023-01-01T10:00:00Z",
416                "{\"progress\":\"0\",\"volumeid\":\"book1\",\"title\":\"Book One\"}", ""
417            ],
418        ).unwrap();
419        db.execute(
420            "INSERT INTO AnalyticsEvents (Id, Type, Timestamp, Attributes, Metrics) VALUES (?, ?, ?, ?, ?)",
421            &[
422                "session1_leave",
423                "LeaveContent",
424                "2023-01-01T10:05:00Z",
425                "{\"progress\":\"10\",\"volumeid\":\"book1\",\"title\":\"Book One\"}", "{\"ButtonPressCount\":10,\"SecondsRead\":300,\"PagesTurned\":5}"
426            ],
427        ).unwrap();
428
429        let analysis = Parser::parse_events(&db, ParseOption::ReadingSessions).unwrap();
430
431        assert!(analysis.sessions.is_some());
432        assert_eq!(analysis.sessions.unwrap().sessions_count(), 1);
433        assert!(analysis.terms.is_none());
434        assert!(analysis.brightness_history.is_none());
435        assert!(analysis.natural_light_history.is_none());
436        assert!(analysis.bookmarks.is_none());
437    }
438
439    #[test]
440    fn test_parse_events_bookmarks() {
441        let db = setup_test_db();
442        db.execute(
443            "INSERT INTO content (ContentID, Title) VALUES (?, ?)",
444            &["book1", "Book One"],
445        )
446        .unwrap();
447        db.execute(
448            "INSERT INTO Bookmark (BookmarkID, Text, VolumeID, Color, ChapterProgress, DateCreated, DateModified) VALUES (?, ?, ?, ?, ?, ?, ?)",
449            &["bookmark1", "Some text", "book1", "1", "0.5", "2023-01-01T10:06:00Z", "2023-01-01T10:06:00Z"],
450        ).unwrap();
451
452        let analysis = Parser::parse_events(&db, ParseOption::Bookmarks).unwrap();
453
454        assert!(analysis.sessions.is_none());
455        assert!(analysis.terms.is_none());
456        assert!(analysis.brightness_history.is_none());
457        assert!(analysis.natural_light_history.is_none());
458        assert!(analysis.bookmarks.is_some());
459        assert_eq!(analysis.bookmarks.unwrap().len(), 1);
460    }
461}