Skip to main content

kobo_db_tools/
parser.rs

1use crate::{
2    get_bookmarks, AppEvent, AppEventKind, Book, Bookmark, Brightness, BrightnessEvent,
3    BrightnessHistory, ChargeCycle, ChargeCycleMetrics, CorrelatedAnalysis, CorrelatedSession,
4    DictionaryWord, NaturalLightHistory, OrphanEvents, ReadingSession, ReadingSessions,
5};
6use chrono::{DateTime, Duration, Utc};
7use rusqlite::{params_from_iter, Connection, OpenFlags};
8use std::collections::{HashMap, HashSet};
9use std::path::Path;
10use std::str::FromStr;
11use thiserror::Error;
12use uuid::Uuid;
13
14#[derive(Debug, Error)]
15pub enum ParseError {
16    #[error("Event is not valid")]
17    InvalidEventType,
18    #[error("Error during session completation")]
19    SessionCompletionFailed,
20    #[error("Error during deserialize")]
21    DeserializationError,
22}
23
24#[derive(serde::Deserialize, Clone)]
25struct ReadingSessionAttributes {
26    progress: String,
27    volumeid: Option<String>,
28    title: Option<String>,
29    #[serde(rename = "attribution")]
30    author: Option<String>,
31}
32
33#[derive(serde::Deserialize)]
34struct LeaveContentMetrics {
35    #[serde(rename = "ButtonPressCount")]
36    button_press_count: usize,
37    #[serde(rename = "SecondsRead")]
38    seconds_read: usize,
39    #[serde(rename = "PagesTurned")]
40    pages_turned: usize,
41}
42
43#[derive(serde::Deserialize)]
44struct LightAttributes {
45    #[serde(rename = "Method")]
46    method: String,
47}
48
49#[derive(serde::Deserialize)]
50struct LightMetrics {
51    #[serde(alias = "NewNaturalLight")]
52    #[serde(alias = "NewBrightness")]
53    new_light: u8,
54}
55
56#[derive(serde::Deserialize)]
57struct DictionaryAttributes {
58    #[serde(rename = "Dictionary")]
59    lang: String,
60    #[serde(rename = "Word")]
61    word: String,
62}
63
64struct TimedDictionaryWord {
65    timestamp: DateTime<Utc>,
66    word: DictionaryWord,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum ParseOption {
71    All,
72    ReadingSessions,
73    DictionaryLookups,
74    BrightnessHistory,
75    NaturalLightHistory,
76    Bookmarks,
77    AppStart,
78    PluggedIn,
79}
80
81#[derive(Debug, Default)]
82pub struct EventAnalysis {
83    pub sessions: Option<ReadingSessions>,
84    pub terms: Option<HashMap<DictionaryWord, usize>>,
85    pub brightness_history: Option<BrightnessHistory>,
86    pub natural_light_history: Option<NaturalLightHistory>,
87    pub bookmarks: Option<Vec<Bookmark>>,
88    pub books: Option<Vec<Book>>,
89    pub app_events: Option<Vec<AppEvent>>,
90}
91
92pub struct Parser;
93
94impl Parser {
95    pub fn parse_events(db: &Connection, option: ParseOption) -> rusqlite::Result<EventAnalysis> {
96        let mut analysis = EventAnalysis::default();
97        let event_types = event_types_for_option(option);
98        let include_bookmarks = matches!(option, ParseOption::All | ParseOption::Bookmarks);
99        let include_sessions = matches!(option, ParseOption::All | ParseOption::ReadingSessions);
100        let include_dictionary =
101            matches!(option, ParseOption::All | ParseOption::DictionaryLookups);
102        let include_brightness =
103            matches!(option, ParseOption::All | ParseOption::BrightnessHistory);
104        let include_natural_light =
105            matches!(option, ParseOption::All | ParseOption::NaturalLightHistory);
106        let include_app_events = matches!(
107            option,
108            ParseOption::All | ParseOption::AppStart | ParseOption::PluggedIn
109        );
110
111        if include_bookmarks {
112            analysis.bookmarks = Some(get_bookmarks(db)?);
113        }
114
115        if !event_types.is_empty() {
116            let q = build_event_query(event_types.len());
117
118            let mut stmt = db.prepare(&q)?;
119            let mut rows = stmt.query(params_from_iter(event_types.iter().copied()))?;
120
121            let mut current_session: Option<ReadingSession> = None;
122            let mut sessions_vec = ReadingSessions::new();
123            let mut terms_map = HashMap::new();
124            let mut brightness_hist = BrightnessHistory::new();
125            let mut natural_light_hist = NaturalLightHistory::new();
126            let mut volume_ids_to_query = HashSet::new();
127            let mut books_from_events = HashMap::new();
128            let mut app_events = Vec::new();
129
130            while let Some(row) = rows.next()? {
131                let event_id: String = row.get("Id")?;
132                let event_type: String = row.get("Type")?;
133                let ts_str: String = row.get("Timestamp")?;
134                let ts = parse_timestamp(&ts_str, 2)?;
135
136                match event_type.as_str() {
137                    "OpenContent" | "LeaveContent" if include_sessions => {
138                        let attr_json: String = row.get("Attributes")?;
139                        let attr: ReadingSessionAttributes = from_json(&attr_json, 1)?;
140                        let progress = attr.progress.parse::<u8>().unwrap_or(0);
141
142                        match (&attr.volumeid, &attr.title, &attr.author) {
143                            (None, Some(title), Some(author)) => {
144                                books_from_events.entry(title.clone()).or_insert_with(|| {
145                                    Book::new(author.clone(), title.clone(), None, String::new())
146                                });
147                            }
148                            (Some(volume_id), None, _) => {
149                                volume_ids_to_query.insert(volume_id.clone());
150                            }
151                            _ => {}
152                        }
153
154                        let metrics = if event_type == "LeaveContent" {
155                            let metr_json: String = row.get("Metrics")?;
156                            Some(from_json::<LeaveContentMetrics>(&metr_json, 2)?)
157                        } else {
158                            None
159                        };
160
161                        match handle_reading_session_event(
162                            &event_type,
163                            &event_id,
164                            &mut current_session,
165                            ts,
166                            progress,
167                            &attr,
168                            metrics,
169                        ) {
170                            Ok(Some(session)) => sessions_vec.add_session(session),
171                            Ok(None) => {}
172                            Err(e) => eprintln!("Errore evento {}: {:?}", &event_id, e),
173                        }
174                    }
175                    "DictionaryLookup" if include_dictionary => {
176                        let session_id = current_session.as_ref().map(|s| s.id);
177                        let attr_json: String = row.get("Attributes")?;
178                        *terms_map
179                            .entry(on_dictionary_lookup(&attr_json, session_id)?)
180                            .or_insert(0) += 1;
181                    }
182                    "BrightnessAdjusted" if include_brightness => {
183                        let attr_json: String = row.get("Attributes")?;
184                        let metr_json: String = row.get("Metrics")?;
185                        let event = on_light_adjusted(&attr_json, &metr_json, ts)?;
186                        brightness_hist.insert(event);
187                    }
188                    "NaturalLightAdjusted" if include_natural_light => {
189                        let attr_json: String = row.get("Attributes")?;
190                        let metr_json: String = row.get("Metrics")?;
191                        let event = on_light_adjusted(&attr_json, &metr_json, ts)?;
192                        natural_light_hist.insert(event);
193                    }
194                    "AppStart" | "PluggedIn" if include_app_events => {
195                        let attr_json: Option<String> = row.get("Attributes")?;
196                        let attributes = parse_optional_json_value(attr_json, 1)?;
197                        let kind = match event_type.as_str() {
198                            "AppStart" => AppEventKind::AppStart,
199                            "PluggedIn" => AppEventKind::PluggedIn,
200                            _ => continue,
201                        };
202                        app_events.push(AppEvent::new(kind, ts, attributes));
203                    }
204                    _ => {
205                        eprintln!("Unknown event: {}", event_type);
206                    }
207                }
208            }
209
210            let mut books_from_db = get_books_by_volume_id(db, &volume_ids_to_query)?;
211            books_from_db.extend(books_from_events);
212            analysis.books = Some(books_from_db.values().cloned().collect());
213
214            for session in sessions_vec.get_mut_sessions() {
215                if let Some(volume_id) = &session.volume_id {
216                    if let Some(book) = books_from_db.get(volume_id) {
217                        session.book_title = Some(book.title.clone());
218                    }
219                }
220            }
221
222            if include_sessions {
223                analysis.sessions = Some(sessions_vec);
224            }
225            if include_dictionary {
226                analysis.terms = Some(terms_map);
227            }
228            if include_brightness {
229                analysis.brightness_history = Some(brightness_hist);
230            }
231            if include_natural_light {
232                analysis.natural_light_history = Some(natural_light_hist);
233            }
234            if include_app_events {
235                analysis.app_events = Some(app_events);
236            }
237        }
238        Ok(analysis)
239    }
240
241    pub fn parse_correlated(db: &Connection) -> rusqlite::Result<CorrelatedAnalysis> {
242        const TOLERANCE_SECONDS: i64 = 30;
243        let tolerance = Duration::seconds(TOLERANCE_SECONDS);
244        let event_types = event_types_for_option(ParseOption::All);
245        let q = build_event_query(event_types.len());
246
247        let mut stmt = db.prepare(&q)?;
248        let mut rows = stmt.query(params_from_iter(event_types.iter().copied()))?;
249
250        let mut current_session: Option<ReadingSession> = None;
251        let mut sessions_vec = ReadingSessions::new();
252        let mut dictionary_events = Vec::new();
253        let mut brightness_events = Vec::new();
254        let mut natural_light_events = Vec::new();
255        let mut app_events = Vec::new();
256        let mut volume_ids_to_query = HashSet::new();
257        let mut books_from_events = HashMap::new();
258
259        while let Some(row) = rows.next()? {
260            let event_id: String = row.get("Id")?;
261            let event_type: String = row.get("Type")?;
262            let ts_str: String = row.get("Timestamp")?;
263            let ts = parse_timestamp(&ts_str, 2)?;
264
265            match event_type.as_str() {
266                "OpenContent" | "LeaveContent" => {
267                    let attr_json: String = row.get("Attributes")?;
268                    let attr: ReadingSessionAttributes = from_json(&attr_json, 1)?;
269                    let progress = attr.progress.parse::<u8>().unwrap_or(0);
270
271                    match (&attr.volumeid, &attr.title, &attr.author) {
272                        (None, Some(title), Some(author)) => {
273                            books_from_events.entry(title.clone()).or_insert_with(|| {
274                                Book::new(author.clone(), title.clone(), None, String::new())
275                            });
276                        }
277                        (Some(volume_id), None, _) => {
278                            volume_ids_to_query.insert(volume_id.clone());
279                        }
280                        _ => {}
281                    }
282
283                    let metrics = if event_type == "LeaveContent" {
284                        let metr_json: String = row.get("Metrics")?;
285                        Some(from_json::<LeaveContentMetrics>(&metr_json, 2)?)
286                    } else {
287                        None
288                    };
289
290                    match handle_reading_session_event(
291                        &event_type,
292                        &event_id,
293                        &mut current_session,
294                        ts,
295                        progress,
296                        &attr,
297                        metrics,
298                    ) {
299                        Ok(Some(session)) => sessions_vec.add_session(session),
300                        Ok(None) => {}
301                        Err(e) => eprintln!("Errore evento {}: {:?}", &event_id, e),
302                    }
303                }
304                "DictionaryLookup" => {
305                    let attr_json: String = row.get("Attributes")?;
306                    let word = on_dictionary_lookup(&attr_json, None)?;
307                    dictionary_events.push(TimedDictionaryWord {
308                        timestamp: ts,
309                        word,
310                    });
311                }
312                "BrightnessAdjusted" => {
313                    let attr_json: String = row.get("Attributes")?;
314                    let metr_json: String = row.get("Metrics")?;
315                    let event = on_light_adjusted(&attr_json, &metr_json, ts)?;
316                    brightness_events.push(event);
317                }
318                "NaturalLightAdjusted" => {
319                    let attr_json: String = row.get("Attributes")?;
320                    let metr_json: String = row.get("Metrics")?;
321                    let event = on_light_adjusted(&attr_json, &metr_json, ts)?;
322                    natural_light_events.push(event);
323                }
324                "AppStart" | "PluggedIn" => {
325                    let attr_json: Option<String> = row.get("Attributes")?;
326                    let attributes = parse_optional_json_value(attr_json, 1)?;
327                    let kind = match event_type.as_str() {
328                        "AppStart" => AppEventKind::AppStart,
329                        "PluggedIn" => AppEventKind::PluggedIn,
330                        _ => continue,
331                    };
332                    app_events.push(AppEvent::new(kind, ts, attributes));
333                }
334                _ => {}
335            }
336        }
337
338        let mut books_from_db = get_books_by_volume_id(db, &volume_ids_to_query)?;
339        books_from_db.extend(books_from_events);
340
341        for session in sessions_vec.get_mut_sessions() {
342            if let Some(volume_id) = &session.volume_id {
343                if let Some(book) = books_from_db.get(volume_id) {
344                    session.book_title = Some(book.title.clone());
345                }
346            }
347        }
348
349        let sessions = std::mem::take(sessions_vec.get_mut_sessions());
350        let mut correlated_sessions: Vec<CorrelatedSession> =
351            sessions.into_iter().map(CorrelatedSession::new).collect();
352
353        let mut orphan_dictionary = Vec::new();
354        for timed in dictionary_events {
355            if let Some(index) =
356                find_session_index(&correlated_sessions, timed.timestamp, tolerance)
357            {
358                let session_id = correlated_sessions[index].session.id;
359                let word = DictionaryWord::new(
360                    timed.word.term().to_string(),
361                    timed.word.lang().to_string(),
362                    Some(session_id),
363                );
364                correlated_sessions[index].dictionary.push(word);
365            } else {
366                orphan_dictionary.push(DictionaryWord::new(
367                    timed.word.term().to_string(),
368                    timed.word.lang().to_string(),
369                    None,
370                ));
371            }
372        }
373
374        let mut orphan_brightness = Vec::new();
375        for event in brightness_events {
376            if let Some(index) =
377                find_session_index(&correlated_sessions, event.timestamp, tolerance)
378            {
379                correlated_sessions[index].brightness.push(event);
380            } else {
381                orphan_brightness.push(event);
382            }
383        }
384
385        let mut orphan_natural_light = Vec::new();
386        for event in natural_light_events {
387            if let Some(index) =
388                find_session_index(&correlated_sessions, event.timestamp, tolerance)
389            {
390                correlated_sessions[index].natural_light.push(event);
391            } else {
392                orphan_natural_light.push(event);
393            }
394        }
395
396        let mut orphan_app_events = Vec::new();
397        for event in app_events {
398            if let Some(index) =
399                find_session_index(&correlated_sessions, event.timestamp, tolerance)
400            {
401                correlated_sessions[index].app_events.push(event);
402            } else {
403                orphan_app_events.push(event);
404            }
405        }
406
407        let orphans = OrphanEvents {
408            dictionary: orphan_dictionary,
409            brightness: orphan_brightness,
410            natural_light: orphan_natural_light,
411            app_events: orphan_app_events,
412        };
413
414        let mut all_app_events: Vec<AppEvent> = correlated_sessions
415            .iter()
416            .flat_map(|session| session.app_events.iter().cloned())
417            .collect();
418        all_app_events.extend(orphans.app_events.iter().cloned());
419
420        let cycles = build_charge_cycles(&correlated_sessions, &all_app_events);
421        let mut app_start_counts_by_day = HashMap::new();
422        for event in all_app_events
423            .iter()
424            .filter(|event| event.kind == AppEventKind::AppStart)
425        {
426            let day = event.timestamp.date_naive();
427            *app_start_counts_by_day.entry(day).or_insert(0) += 1;
428        }
429
430        Ok(CorrelatedAnalysis {
431            sessions: correlated_sessions,
432            orphans,
433            cycles,
434            app_start_counts_by_day,
435        })
436    }
437    pub fn parse_from_str<P: AsRef<Path>>(
438        path: P,
439        option: ParseOption,
440    ) -> rusqlite::Result<EventAnalysis> {
441        let path_ref = path.as_ref();
442        let conn = Connection::open(path_ref).or_else(|err| {
443            Connection::open_with_flags(path_ref, OpenFlags::SQLITE_OPEN_READ_ONLY).map_err(|_| err)
444        })?;
445        Self::parse_events(&conn, option)
446    }
447}
448
449fn handle_reading_session_event(
450    event_type: &str,
451    event_id: &str,
452    current_session: &mut Option<ReadingSession>,
453    ts: DateTime<Utc>,
454    progress: u8,
455    attr: &ReadingSessionAttributes,
456    metrics: Option<LeaveContentMetrics>,
457) -> Result<Option<ReadingSession>, ParseError> {
458    match event_type {
459        "OpenContent" => {
460            *current_session = Some(ReadingSession::new(
461                ts,
462                progress,
463                attr.title.clone(),
464                attr.volumeid.clone(),
465                event_id.to_string(),
466            ));
467            Ok(None)
468        }
469        "LeaveContent" => {
470            let mut session = current_session
471                .take()
472                .ok_or(ParseError::SessionCompletionFailed)?;
473            let m = metrics.ok_or(ParseError::SessionCompletionFailed)?;
474            if session
475                .complete_session(
476                    ts,
477                    progress,
478                    m.button_press_count as u64,
479                    m.seconds_read as u64,
480                    m.pages_turned as u64,
481                    event_id.to_string(),
482                )
483                .is_err()
484            {
485                *current_session = Some(session);
486                return Err(ParseError::SessionCompletionFailed);
487            }
488            Ok(Some(session))
489        }
490        _ => Err(ParseError::InvalidEventType),
491    }
492}
493
494fn on_dictionary_lookup(
495    attr_json: &str,
496    session_id: Option<Uuid>,
497) -> rusqlite::Result<DictionaryWord> {
498    let attr: DictionaryAttributes = from_json(attr_json, 1)?;
499    Ok(DictionaryWord::new(attr.word, attr.lang, session_id))
500}
501
502fn on_light_adjusted(
503    attr_json: &str,
504    metr_json: &str,
505    ts: DateTime<Utc>,
506) -> rusqlite::Result<BrightnessEvent> {
507    let attributes: LightAttributes = from_json(attr_json, 1)?;
508    let metrics: LightMetrics = from_json(metr_json, 1)?;
509    let brightness = Brightness::new(attributes.method, metrics.new_light);
510    Ok(BrightnessEvent::new(brightness, ts))
511}
512
513fn from_json<T: serde::de::DeserializeOwned>(
514    json: &str,
515    column_index: usize,
516) -> rusqlite::Result<T> {
517    serde_json::from_str(json).map_err(|e| {
518        rusqlite::Error::FromSqlConversionFailure(
519            column_index,
520            rusqlite::types::Type::Text,
521            Box::new(e),
522        )
523    })
524}
525
526fn parse_timestamp(ts: &str, column_index: usize) -> rusqlite::Result<DateTime<Utc>> {
527    DateTime::<Utc>::from_str(ts).map_err(|e| {
528        rusqlite::Error::FromSqlConversionFailure(
529            column_index,
530            rusqlite::types::Type::Text,
531            Box::new(e),
532        )
533    })
534}
535
536fn parse_optional_json_value(
537    json: Option<String>,
538    column_index: usize,
539) -> rusqlite::Result<Option<serde_json::Value>> {
540    match json {
541        None => Ok(None),
542        Some(raw) => {
543            let trimmed = raw.trim();
544            if trimmed.is_empty() {
545                return Ok(None);
546            }
547            serde_json::from_str(trimmed).map(Some).map_err(|e| {
548                rusqlite::Error::FromSqlConversionFailure(
549                    column_index,
550                    rusqlite::types::Type::Text,
551                    Box::new(e),
552                )
553            })
554        }
555    }
556}
557
558fn session_contains(session: &ReadingSession, ts: DateTime<Utc>, tolerance: Duration) -> bool {
559    match session.time_end {
560        Some(end) => ts >= session.time_start - tolerance && ts <= end + tolerance,
561        None => false,
562    }
563}
564
565fn find_session_index(
566    sessions: &[CorrelatedSession],
567    ts: DateTime<Utc>,
568    tolerance: Duration,
569) -> Option<usize> {
570    sessions
571        .iter()
572        .position(|session| session_contains(&session.session, ts, tolerance))
573}
574
575fn session_in_cycle(
576    session: &ReadingSession,
577    start: DateTime<Utc>,
578    end: Option<DateTime<Utc>>,
579) -> bool {
580    match end {
581        Some(end) => session.time_start >= start && session.time_start < end,
582        None => session.time_start >= start,
583    }
584}
585
586fn app_event_in_cycle(event: &AppEvent, start: DateTime<Utc>, end: Option<DateTime<Utc>>) -> bool {
587    match end {
588        Some(end) => event.timestamp >= start && event.timestamp < end,
589        None => event.timestamp >= start,
590    }
591}
592
593fn compute_cycle_metrics(
594    sessions: &[CorrelatedSession],
595    app_events: &[AppEvent],
596) -> ChargeCycleMetrics {
597    let total_seconds_read = sessions
598        .iter()
599        .map(|session| session.session.seconds_read.unwrap_or(0))
600        .sum();
601    let total_pages = sessions
602        .iter()
603        .map(|session| session.session.pages_turned.unwrap_or(0))
604        .sum();
605    let total_button_presses = sessions
606        .iter()
607        .map(|session| session.session.button_press_count.unwrap_or(0))
608        .sum();
609    let dictionary_lookups = sessions
610        .iter()
611        .map(|session| session.dictionary.len())
612        .sum();
613    let brightness_events = sessions
614        .iter()
615        .map(|session| session.brightness.len() + session.natural_light.len())
616        .sum();
617    let app_starts = app_events
618        .iter()
619        .filter(|event| event.kind == AppEventKind::AppStart)
620        .count();
621    ChargeCycleMetrics {
622        total_seconds_read,
623        total_pages,
624        total_button_presses,
625        dictionary_lookups,
626        brightness_events,
627        app_starts,
628    }
629}
630
631fn build_charge_cycles(
632    sessions: &[CorrelatedSession],
633    app_events: &[AppEvent],
634) -> Vec<ChargeCycle> {
635    let mut plug_events: Vec<AppEvent> = app_events
636        .iter()
637        .filter(|event| event.kind == AppEventKind::PluggedIn)
638        .cloned()
639        .collect();
640    plug_events.sort_by_key(|event| event.timestamp);
641
642    let mut cycles = Vec::new();
643    for (index, plug_event) in plug_events.iter().enumerate() {
644        let start = plug_event.timestamp;
645        let end = plug_events.get(index + 1).map(|event| event.timestamp);
646        let cycle_sessions: Vec<CorrelatedSession> = sessions
647            .iter()
648            .filter(|session| session_in_cycle(&session.session, start, end))
649            .cloned()
650            .collect();
651        let cycle_app_events: Vec<AppEvent> = app_events
652            .iter()
653            .filter(|event| app_event_in_cycle(event, start, end))
654            .cloned()
655            .collect();
656        let metrics = compute_cycle_metrics(&cycle_sessions, &cycle_app_events);
657        cycles.push(ChargeCycle {
658            start,
659            end,
660            sessions: cycle_sessions,
661            app_events: cycle_app_events,
662            metrics,
663        });
664    }
665    cycles
666}
667
668fn event_types_for_option(option: ParseOption) -> Vec<&'static str> {
669    match option {
670        ParseOption::All => vec![
671            "OpenContent",
672            "LeaveContent",
673            "DictionaryLookup",
674            "BrightnessAdjusted",
675            "NaturalLightAdjusted",
676            "AppStart",
677            "PluggedIn",
678        ],
679        ParseOption::ReadingSessions => vec!["OpenContent", "LeaveContent"],
680        ParseOption::DictionaryLookups => vec!["DictionaryLookup"],
681        ParseOption::BrightnessHistory => vec!["BrightnessAdjusted"],
682        ParseOption::NaturalLightHistory => vec!["NaturalLightAdjusted"],
683        ParseOption::AppStart => vec!["AppStart"],
684        ParseOption::PluggedIn => vec!["PluggedIn"],
685        ParseOption::Bookmarks => Vec::new(),
686    }
687}
688
689fn build_event_query(event_types_len: usize) -> String {
690    let placeholders = std::iter::repeat_n("?", event_types_len)
691        .collect::<Vec<_>>()
692        .join(", ");
693    format!(
694        "SELECT Id, Type, Timestamp, Attributes, Metrics FROM AnalyticsEvents WHERE Type IN ({}) ORDER BY Timestamp ASC;",
695        placeholders
696    )
697}
698
699fn get_books_by_volume_id(
700    db: &Connection,
701    volume_ids: &HashSet<String>,
702) -> rusqlite::Result<HashMap<String, Book>> {
703    let mut books = HashMap::new();
704    if volume_ids.is_empty() {
705        return Ok(books);
706    }
707    let mut stmt = db.prepare(
708        "SELECT BookID, Title, Attribution as Authors FROM content WHERE ContentType=6 AND (ContentID = ?1 OR BookID = ?1)",
709    )?;
710
711    for volume_id in volume_ids {
712        let mut rows = stmt.query([volume_id])?;
713        if let Some(row) = rows.next()? {
714            let title: String = row.get("Title")?;
715            let authors: String = row.get("Authors")?;
716            let book_id: Option<String> = row.get("BookID")?;
717            let book_id = book_id.unwrap_or_else(|| volume_id.clone());
718            books.insert(volume_id.clone(), Book::new(authors, title, None, book_id));
719        }
720    }
721
722    Ok(books)
723}
724
725#[cfg(test)]
726mod tests {
727    use super::{ParseOption, Parser};
728    use crate::AppEventKind;
729    use chrono::NaiveDate;
730    use rusqlite::Connection;
731
732    fn setup_test_db() -> Connection {
733        let conn = Connection::open_in_memory().unwrap();
734        conn.execute_batch(
735            "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                ContentType INTEGER,\n                Title TEXT,\n                Attribution TEXT,\n                BookID 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            );",
736        )
737        .unwrap();
738        conn
739    }
740
741    #[test]
742    fn test_parse_events_all() {
743        let db = setup_test_db();
744
745        // Insert sample data
746        db.execute(
747            "INSERT INTO AnalyticsEvents (Id, Type, Timestamp, Attributes, Metrics) VALUES (?, ?, ?, ?, ?)",
748            [
749                "session1_open",
750                "OpenContent",
751                "2023-01-01T10:00:00Z",
752                "{\"progress\":\"0\",\"volumeid\":\"book1\"}", ""
753            ],
754        ).unwrap();
755        db.execute(
756            "INSERT INTO AnalyticsEvents (Id, Type, Timestamp, Attributes, Metrics) VALUES (?, ?, ?, ?, ?)",
757            [
758                "session1_leave",
759                "LeaveContent",
760                "2023-01-01T10:05:00Z",
761                "{\"progress\":\"10\",\"volumeid\":\"book1\"}", "{\"ButtonPressCount\":10,\"SecondsRead\":300,\"PagesTurned\":5}"
762            ],
763        ).unwrap();
764        db.execute(
765            "INSERT INTO AnalyticsEvents (Id, Type, Timestamp, Attributes, Metrics) VALUES (?, ?, ?, ?, ?)",
766            [
767                "dict_lookup1",
768                "DictionaryLookup",
769                "2023-01-01T10:01:00Z",
770                "{\"Dictionary\":\"en\",\"Word\":\"test\"}", ""
771            ],
772        ).unwrap();
773        db.execute(
774            "INSERT INTO AnalyticsEvents (Id, Type, Timestamp, Attributes, Metrics) VALUES (?, ?, ?, ?, ?)",
775            [
776                "brightness_adj1",
777                "BrightnessAdjusted",
778                "2023-01-01T10:02:00Z",
779                "{\"Method\":\"manual\"}", "{\"NewBrightness\":50}"
780            ],
781        ).unwrap();
782        db.execute(
783            "INSERT INTO AnalyticsEvents (Id, Type, Timestamp, Attributes, Metrics) VALUES (?, ?, ?, ?, ?)",
784            [
785                "natural_light_adj1",
786                "NaturalLightAdjusted",
787                "2023-01-01T10:03:00Z",
788                "{\"Method\":\"auto\"}", "{\"NewNaturalLight\":70}"
789            ],
790        ).unwrap();
791        db.execute(
792            "INSERT INTO AnalyticsEvents (Id, Type, Timestamp, Attributes, Metrics) VALUES (?, ?, ?, ?, ?)",
793            [
794                "app_start1",
795                "AppStart",
796                "2023-01-01T09:59:00Z",
797                "{\"app\":\"nickel\"}",
798                ""
799            ],
800        ).unwrap();
801        db.execute(
802            "INSERT INTO AnalyticsEvents (Id, Type, Timestamp, Attributes, Metrics) VALUES (?, ?, ?, ?, ?)",
803            [
804                "plugged_in1",
805                "PluggedIn",
806                "2023-01-01T10:04:00Z",
807                "",
808                ""
809            ],
810        ).unwrap();
811
812        db.execute(
813            "INSERT INTO content (ContentID, Title, ContentType, Attribution, BookID) VALUES (?, ?, ?, ?, ?)",
814            ["book1", "Book One", "6", "Author One", "book1"],
815        )
816        .unwrap();
817        db.execute(
818            "INSERT INTO Bookmark (BookmarkID, Text, VolumeID, Color, ChapterProgress, DateCreated, DateModified) VALUES (?, ?, ?, ?, ?, ?, ?)",
819            ["bookmark1", "Some text", "book1", "1", "0.5", "2023-01-01T10:06:00Z", "2023-01-01T10:06:00Z"],
820        ).unwrap();
821
822        let analysis = Parser::parse_events(&db, ParseOption::All).unwrap();
823
824        assert!(analysis.sessions.is_some());
825        assert_eq!(analysis.sessions.unwrap().sessions_count(), 1);
826
827        assert!(analysis.terms.is_some());
828        assert_eq!(analysis.terms.unwrap().len(), 1);
829
830        assert!(analysis.brightness_history.is_some());
831        assert_eq!(analysis.brightness_history.unwrap().events.len(), 1);
832
833        assert!(analysis.natural_light_history.is_some());
834        assert_eq!(analysis.natural_light_history.unwrap().events.len(), 1);
835
836        assert!(analysis.bookmarks.is_some());
837        assert_eq!(analysis.bookmarks.unwrap().len(), 1);
838
839        assert!(analysis.books.is_some());
840        assert_eq!(analysis.books.unwrap().len(), 1);
841
842        assert!(analysis.app_events.is_some());
843        let app_events = analysis.app_events.unwrap();
844        assert_eq!(app_events.len(), 2);
845        assert!(app_events
846            .iter()
847            .any(|event| event.kind == AppEventKind::AppStart));
848        assert!(app_events
849            .iter()
850            .any(|event| event.kind == AppEventKind::PluggedIn));
851    }
852
853    #[test]
854    fn test_parse_events_reading_sessions() {
855        let db = setup_test_db();
856        db.execute(
857            "INSERT INTO AnalyticsEvents (Id, Type, Timestamp, Attributes, Metrics) VALUES (?, ?, ?, ?, ?)",
858            [
859                "session1_open",
860                "OpenContent",
861                "2023-01-01T10:00:00Z",
862                "{\"progress\":\"0\",\"volumeid\":\"book1\"}", ""
863            ],
864        ).unwrap();
865        db.execute(
866            "INSERT INTO AnalyticsEvents (Id, Type, Timestamp, Attributes, Metrics) VALUES (?, ?, ?, ?, ?)",
867            [
868                "session1_leave",
869                "LeaveContent",
870                "2023-01-01T10:05:00Z",
871                "{\"progress\":\"10\",\"volumeid\":\"book1\"}", "{\"ButtonPressCount\":10,\"SecondsRead\":300,\"PagesTurned\":5}"
872            ],
873        ).unwrap();
874
875        db.execute(
876            "INSERT INTO content (ContentID, Title, ContentType, Attribution, BookID) VALUES (?, ?, ?, ?, ?)",
877            ["book1", "The Real Book Title", "6", "Author One", "book1"],
878        )
879        .unwrap();
880
881        let analysis = Parser::parse_events(&db, ParseOption::ReadingSessions).unwrap();
882
883        assert!(analysis.sessions.is_some());
884        let sessions = analysis.sessions.unwrap();
885        assert_eq!(sessions.sessions_count(), 1);
886        let session = sessions.get_sessions().first().unwrap();
887        assert_eq!(session.book_title.as_deref(), Some("The Real Book Title"));
888        assert!(analysis.terms.is_none());
889        assert!(analysis.brightness_history.is_none());
890        assert!(analysis.natural_light_history.is_none());
891        assert!(analysis.bookmarks.is_none());
892        assert!(analysis.books.is_some());
893        assert_eq!(analysis.books.unwrap().len(), 1);
894        assert!(analysis.app_events.is_none());
895    }
896
897    #[test]
898    fn test_parse_events_bookmarks() {
899        let db = setup_test_db();
900        db.execute(
901            "INSERT INTO content (ContentID, Title, ContentType, Attribution, BookID) VALUES (?, ?, ?, ?, ?)",
902            ["book1", "Book One", "6", "Author One", "book1"],
903        )
904        .unwrap();
905        db.execute(
906            "INSERT INTO Bookmark (BookmarkID, Text, VolumeID, Color, ChapterProgress, DateCreated, DateModified) VALUES (?, ?, ?, ?, ?, ?, ?)",
907            ["bookmark1", "Some text", "book1", "1", "0.5", "2023-01-01T10:06:00Z", "2023-01-01T10:06:00Z"],
908        ).unwrap();
909
910        let analysis = Parser::parse_events(&db, ParseOption::Bookmarks).unwrap();
911
912        assert!(analysis.sessions.is_none());
913        assert!(analysis.terms.is_none());
914        assert!(analysis.brightness_history.is_none());
915        assert!(analysis.natural_light_history.is_none());
916        assert!(analysis.bookmarks.is_some());
917        assert_eq!(analysis.bookmarks.unwrap().len(), 1);
918        assert!(analysis.books.is_none());
919        assert!(analysis.app_events.is_none());
920    }
921
922    #[test]
923    fn test_parse_events_app_start() {
924        let db = setup_test_db();
925        db.execute(
926            "INSERT INTO AnalyticsEvents (Id, Type, Timestamp, Attributes, Metrics) VALUES (?, ?, ?, ?, ?)",
927            [
928                "app_start1",
929                "AppStart",
930                "2023-01-01T09:59:00Z",
931                "{\"app\":\"nickel\"}",
932                ""
933            ],
934        ).unwrap();
935        db.execute(
936            "INSERT INTO AnalyticsEvents (Id, Type, Timestamp, Attributes, Metrics) VALUES (?, ?, ?, ?, ?)",
937            [
938                "plugged_in1",
939                "PluggedIn",
940                "2023-01-01T10:04:00Z",
941                "",
942                ""
943            ],
944        ).unwrap();
945
946        let analysis = Parser::parse_events(&db, ParseOption::AppStart).unwrap();
947        assert!(analysis.app_events.is_some());
948        let app_events = analysis.app_events.unwrap();
949        assert_eq!(app_events.len(), 1);
950        assert_eq!(app_events[0].kind, AppEventKind::AppStart);
951    }
952
953    #[test]
954    fn test_parse_events_plugged_in() {
955        let db = setup_test_db();
956        db.execute(
957            "INSERT INTO AnalyticsEvents (Id, Type, Timestamp, Attributes, Metrics) VALUES (?, ?, ?, ?, ?)",
958            [
959                "app_start1",
960                "AppStart",
961                "2023-01-01T09:59:00Z",
962                "{\"app\":\"nickel\"}",
963                ""
964            ],
965        ).unwrap();
966        db.execute(
967            "INSERT INTO AnalyticsEvents (Id, Type, Timestamp, Attributes, Metrics) VALUES (?, ?, ?, ?, ?)",
968            [
969                "plugged_in1",
970                "PluggedIn",
971                "2023-01-01T10:04:00Z",
972                "",
973                ""
974            ],
975        ).unwrap();
976
977        let analysis = Parser::parse_events(&db, ParseOption::PluggedIn).unwrap();
978        assert!(analysis.app_events.is_some());
979        let app_events = analysis.app_events.unwrap();
980        assert_eq!(app_events.len(), 1);
981        assert_eq!(app_events[0].kind, AppEventKind::PluggedIn);
982    }
983
984    #[test]
985    fn test_parse_correlated_basic() {
986        let db = setup_test_db();
987        db.execute(
988            "INSERT INTO AnalyticsEvents (Id, Type, Timestamp, Attributes, Metrics) VALUES (?, ?, ?, ?, ?)",
989            [
990                "session1_open",
991                "OpenContent",
992                "2023-01-01T10:00:00Z",
993                "{\"progress\":\"0\",\"volumeid\":\"book1\"}",
994                "",
995            ],
996        )
997        .unwrap();
998        db.execute(
999            "INSERT INTO AnalyticsEvents (Id, Type, Timestamp, Attributes, Metrics) VALUES (?, ?, ?, ?, ?)",
1000            [
1001                "session1_leave",
1002                "LeaveContent",
1003                "2023-01-01T10:10:00Z",
1004                "{\"progress\":\"10\",\"volumeid\":\"book1\"}",
1005                "{\"ButtonPressCount\":10,\"SecondsRead\":600,\"PagesTurned\":5}",
1006            ],
1007        )
1008        .unwrap();
1009        db.execute(
1010            "INSERT INTO AnalyticsEvents (Id, Type, Timestamp, Attributes, Metrics) VALUES (?, ?, ?, ?, ?)",
1011            [
1012                "dict_lookup1",
1013                "DictionaryLookup",
1014                "2023-01-01T10:02:00Z",
1015                "{\"Dictionary\":\"en\",\"Word\":\"test\"}",
1016                "",
1017            ],
1018        )
1019        .unwrap();
1020        db.execute(
1021            "INSERT INTO AnalyticsEvents (Id, Type, Timestamp, Attributes, Metrics) VALUES (?, ?, ?, ?, ?)",
1022            [
1023                "dict_lookup2",
1024                "DictionaryLookup",
1025                "2023-01-01T11:00:00Z",
1026                "{\"Dictionary\":\"en\",\"Word\":\"orphan\"}",
1027                "",
1028            ],
1029        )
1030        .unwrap();
1031        db.execute(
1032            "INSERT INTO AnalyticsEvents (Id, Type, Timestamp, Attributes, Metrics) VALUES (?, ?, ?, ?, ?)",
1033            [
1034                "brightness_adj1",
1035                "BrightnessAdjusted",
1036                "2023-01-01T10:03:00Z",
1037                "{\"Method\":\"manual\"}",
1038                "{\"NewBrightness\":50}",
1039            ],
1040        )
1041        .unwrap();
1042        db.execute(
1043            "INSERT INTO AnalyticsEvents (Id, Type, Timestamp, Attributes, Metrics) VALUES (?, ?, ?, ?, ?)",
1044            [
1045                "natural_light_adj1",
1046                "NaturalLightAdjusted",
1047                "2023-01-01T10:04:00Z",
1048                "{\"Method\":\"auto\"}",
1049                "{\"NewNaturalLight\":70}",
1050            ],
1051        )
1052        .unwrap();
1053        db.execute(
1054            "INSERT INTO AnalyticsEvents (Id, Type, Timestamp, Attributes, Metrics) VALUES (?, ?, ?, ?, ?)",
1055            [
1056                "plugged_in1",
1057                "PluggedIn",
1058                "2023-01-01T09:50:00Z",
1059                "",
1060                "",
1061            ],
1062        )
1063        .unwrap();
1064        db.execute(
1065            "INSERT INTO AnalyticsEvents (Id, Type, Timestamp, Attributes, Metrics) VALUES (?, ?, ?, ?, ?)",
1066            [
1067                "app_start1",
1068                "AppStart",
1069                "2023-01-01T10:00:10Z",
1070                "{\"app\":\"nickel\"}",
1071                "",
1072            ],
1073        )
1074        .unwrap();
1075
1076        let analysis = Parser::parse_correlated(&db).unwrap();
1077
1078        assert_eq!(analysis.sessions.len(), 1);
1079        let session = &analysis.sessions[0];
1080        assert_eq!(session.dictionary.len(), 1);
1081        assert_eq!(session.brightness.len(), 1);
1082        assert_eq!(session.natural_light.len(), 1);
1083        assert_eq!(session.app_events.len(), 1);
1084
1085        assert_eq!(analysis.orphans.dictionary.len(), 1);
1086        assert_eq!(analysis.orphans.app_events.len(), 1);
1087
1088        assert_eq!(analysis.cycles.len(), 1);
1089        let cycle = &analysis.cycles[0];
1090        assert_eq!(cycle.metrics.total_seconds_read, 600);
1091        assert_eq!(cycle.metrics.total_pages, 5);
1092        assert_eq!(cycle.metrics.total_button_presses, 10);
1093        assert_eq!(cycle.metrics.dictionary_lookups, 1);
1094        assert_eq!(cycle.metrics.brightness_events, 2);
1095        assert_eq!(cycle.metrics.app_starts, 1);
1096        assert_eq!(
1097            analysis
1098                .app_start_counts_by_day
1099                .get(&NaiveDate::from_ymd_opt(2023, 1, 1).unwrap())
1100                .copied(),
1101            Some(1)
1102        );
1103    }
1104}