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 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}