1pub mod conversion;
2pub mod models;
3
4use crate::db::runtime::RUNTIME;
5use crate::db::types::{OptionalUuid7, UnixTimestamp, Uuid7};
6use crate::db::Database;
7use crate::document::SimpleTocEntry;
8use crate::geom::Point;
9use crate::helpers::Fp;
10use crate::metadata::{
11 CroppingMargins, FileInfo, Info, ReaderInfo, ScrollMode, TextAlign, ZoomMode,
12};
13use anyhow::Error;
14use conversion::{
15 extract_authors, info_to_book_row, reader_info_to_reading_state_row, rows_to_toc_entries,
16};
17use models::TocEntryRow;
18use sqlx::sqlite::SqlitePool;
19use std::collections::{BTreeMap, BTreeSet, HashMap};
20use std::path::PathBuf;
21use std::str::FromStr;
22
23#[derive(Clone)]
24pub struct Db {
25 pool: SqlitePool,
26}
27
28impl Db {
29 pub fn new(database: &Database) -> Self {
30 Self {
31 pool: database.pool().clone(),
32 }
33 }
34
35 #[cfg_attr(feature = "otel", tracing::instrument(skip(self), fields(path = %path, name = %name)))]
36 pub fn register_library(&self, path: &str, name: &str) -> Result<i64, Error> {
37 tracing::debug!(path = %path, name = %name, "registering library");
38
39 RUNTIME.block_on(async {
40 let now = UnixTimestamp::now();
41
42 let result = sqlx::query!(
43 r#"
44 INSERT INTO libraries (path, name, created_at)
45 VALUES (?, ?, ?)
46 "#,
47 path,
48 name,
49 now
50 )
51 .execute(&self.pool)
52 .await?;
53
54 let library_id = result.last_insert_rowid();
55 tracing::info!(library_id, path = %path, name = %name, "library registered");
56 Ok(library_id)
57 })
58 }
59
60 #[cfg_attr(feature = "otel", tracing::instrument(skip(self), fields(path = %path)))]
61 pub fn get_library_by_path(&self, path: &str) -> Result<Option<i64>, Error> {
62 tracing::debug!(path = %path, "looking up library by path");
63
64 RUNTIME.block_on(async {
65 let id: Option<Option<i64>> =
66 sqlx::query_scalar!(r#"SELECT id FROM libraries WHERE path = ?"#, path)
67 .fetch_optional(&self.pool)
68 .await?;
69
70 Ok(id.flatten())
71 })
72 }
73
74 #[inline]
75 fn parse_zoom_mode(json: Option<&String>) -> Option<ZoomMode> {
76 match json {
77 Some(s) => match serde_json::from_str(s) {
78 Ok(v) => Some(v),
79 Err(e) => {
80 tracing::warn!(error = %e, "failed to parse zoom_mode JSON field");
81 None
82 }
83 },
84 None => None,
85 }
86 }
87
88 #[inline]
89 fn parse_scroll_mode(json: Option<&String>) -> Option<ScrollMode> {
90 match json {
91 Some(s) => match serde_json::from_str(s) {
92 Ok(v) => Some(v),
93 Err(e) => {
94 tracing::warn!(error = %e, "failed to parse scroll_mode JSON field");
95 None
96 }
97 },
98 None => None,
99 }
100 }
101
102 #[inline]
103 fn parse_text_align(json: Option<&String>) -> Option<TextAlign> {
104 match json {
105 Some(s) => match serde_json::from_str(s) {
106 Ok(v) => Some(v),
107 Err(e) => {
108 tracing::warn!(error = %e, "failed to parse text_align JSON field");
109 None
110 }
111 },
112 None => None,
113 }
114 }
115
116 #[inline]
117 fn parse_cropping_margins(json: Option<&String>) -> Option<CroppingMargins> {
118 match json {
119 Some(s) => match serde_json::from_str(s) {
120 Ok(v) => Some(v),
121 Err(e) => {
122 tracing::warn!(error = %e, "failed to parse cropping_margins JSON field");
123 None
124 }
125 },
126 None => None,
127 }
128 }
129
130 #[inline]
131 fn parse_page_names(json: Option<&String>) -> BTreeMap<usize, String> {
132 match json {
133 Some(s) => match serde_json::from_str(s) {
134 Ok(v) => v,
135 Err(e) => {
136 tracing::warn!(error = %e, "failed to parse page_names JSON field");
137 BTreeMap::default()
138 }
139 },
140 None => BTreeMap::default(),
141 }
142 }
143
144 #[inline]
145 fn parse_bookmarks(json: Option<&String>) -> BTreeSet<usize> {
146 match json {
147 Some(s) => match serde_json::from_str(s) {
148 Ok(v) => v,
149 Err(e) => {
150 tracing::warn!(error = %e, "failed to parse bookmarks JSON field");
151 BTreeSet::default()
152 }
153 },
154 None => BTreeSet::default(),
155 }
156 }
157
158 #[inline]
159 fn parse_annotations(json: Option<&String>) -> Vec<crate::metadata::Annotation> {
160 match json {
161 Some(s) => match serde_json::from_str(s) {
162 Ok(v) => v,
163 Err(e) => {
164 tracing::warn!(error = %e, "failed to parse annotations JSON field");
165 Vec::new()
166 }
167 },
168 None => Vec::new(),
169 }
170 }
171
172 #[inline]
173 fn parse_page_offset(x: Option<i64>, y: Option<i64>) -> Option<Point> {
174 match (x, y) {
175 (Some(x_val), Some(y_val)) => Some(Point::new(x_val as i32, y_val as i32)),
176 _ => None,
177 }
178 }
179
180 #[inline]
181 fn extract_authors(authors: Option<String>) -> String {
182 authors
183 .map(|s| s.split(',').collect::<Vec<_>>().join(", "))
184 .unwrap_or_default()
185 }
186
187 #[inline]
188 fn extract_categories(categories: Option<String>) -> BTreeSet<String> {
189 categories
190 .unwrap_or_default()
191 .split(',')
192 .filter(|s| !s.is_empty())
193 .map(|s| s.to_string())
194 .collect()
195 }
196
197 #[cfg_attr(feature = "otel", tracing::instrument(skip(conn, entries), fields(book_fingerprint = %book_fingerprint, parent_id = ?parent_id)))]
198 async fn insert_toc_entries(
199 conn: &mut sqlx::SqliteConnection,
200 book_fingerprint: &str,
201 entries: &[SimpleTocEntry],
202 parent_id: Option<Uuid7>,
203 ) -> Result<(), Error> {
204 for (position, entry) in entries.iter().enumerate() {
205 let (title, location, children) = match entry {
206 SimpleTocEntry::Leaf(t, loc) => (t.as_str(), loc, [].as_slice()),
207 SimpleTocEntry::Container(t, loc, ch) => (t.as_str(), loc, ch.as_slice()),
208 };
209
210 let (location_kind, location_exact, location_uri) =
211 conversion::encode_location(location);
212 let pos = position as i64;
213 let id = Uuid7::now();
214 let parent_id_str = parent_id.as_ref().map(|p| p.to_string());
215
216 sqlx::query!(
217 r#"
218 INSERT INTO toc_entries (id, book_fingerprint, parent_id, position, title, location_kind, location_exact, location_uri)
219 VALUES (?, ?, ?, ?, ?, ?, ?, ?)
220 "#,
221 id,
222 book_fingerprint,
223 parent_id_str,
224 pos,
225 title,
226 location_kind,
227 location_exact,
228 location_uri,
229 )
230 .execute(&mut *conn)
231 .await?;
232
233 if !children.is_empty() {
234 Box::pin(Self::insert_toc_entries(
235 conn,
236 book_fingerprint,
237 children,
238 Some(id),
239 ))
240 .await?;
241 }
242 }
243
244 Ok(())
245 }
246
247 async fn fetch_all_toc_entries(
248 pool: &SqlitePool,
249 library_id: i64,
250 ) -> Result<HashMap<String, Vec<TocEntryRow>>, Error> {
251 let toc_rows: Vec<TocEntryRow> = sqlx::query_as!(
252 TocEntryRow,
253 r#"
254 SELECT
255 te.book_fingerprint,
256 te.id as "id: Uuid7",
257 te.parent_id as "parent_id!: OptionalUuid7",
258 te.position,
259 te.title,
260 te.location_kind,
261 te.location_exact,
262 te.location_uri
263 FROM toc_entries te
264 INNER JOIN library_books lb ON lb.book_fingerprint = te.book_fingerprint
265 WHERE lb.library_id = ?
266 ORDER BY te.book_fingerprint, te.id ASC
267 "#,
268 library_id
269 )
270 .fetch_all(pool)
271 .await?;
272
273 let mut map: HashMap<String, Vec<TocEntryRow>> = HashMap::new();
274
275 for row in toc_rows {
276 map.entry(row.book_fingerprint.clone())
277 .or_default()
278 .push(row);
279 }
280
281 Ok(map)
282 }
283
284 #[cfg_attr(feature = "otel", tracing::instrument(skip(self), fields(library_id)))]
285 pub fn get_all_books(&self, library_id: i64) -> Result<Vec<(Fp, Info)>, Error> {
286 tracing::debug!(library_id, "fetching all books from database");
287
288 RUNTIME.block_on(async {
289 let book_rows = sqlx::query!(
290 r#"
291 SELECT
292 fingerprint,
293 title,
294 subtitle,
295 year,
296 language,
297 publisher,
298 series,
299 edition,
300 volume,
301 number,
302 identifier,
303 file_path,
304 absolute_path,
305 file_kind,
306 file_size,
307 added_at as "added_at: UnixTimestamp",
308 opened as "opened?: UnixTimestamp",
309 current_page as "current_page?: i64",
310 pages_count as "pages_count?: i64",
311 finished as "finished?: i64",
312 dithered as "dithered?: i64",
313 zoom_mode as "zoom_mode?: String",
314 scroll_mode as "scroll_mode?: String",
315 page_offset_x as "page_offset_x?: i64",
316 page_offset_y as "page_offset_y?: i64",
317 rotation as "rotation?: i64",
318 cropping_margins_json as "cropping_margins_json?: String",
319 margin_width as "margin_width?: i64",
320 screen_margin_width as "screen_margin_width?: i64",
321 font_family as "font_family?: String",
322 font_size as "font_size?: f64",
323 text_align as "text_align?: String",
324 line_height as "line_height?: f64",
325 contrast_exponent as "contrast_exponent?: f64",
326 contrast_gray as "contrast_gray?: f64",
327 page_names_json as "page_names_json?: String",
328 bookmarks_json as "bookmarks_json?: String",
329 annotations_json as "annotations_json?: String",
330 authors as "authors?: String",
331 categories as "categories?: String"
332 FROM library_books_full_info
333 WHERE library_id = ?
334 ORDER BY added_at DESC
335 "#,
336 library_id
337 )
338 .fetch_all(&self.pool)
339 .await?;
340
341 let mut toc_by_fingerprint =
342 Self::fetch_all_toc_entries(&self.pool, library_id).await?;
343
344 let mut result = Vec::new();
345
346 for row in book_rows {
347 let fp = Fp::from_str(&row.fingerprint)?;
348
349 let toc = toc_by_fingerprint
350 .remove(&row.fingerprint)
351 .map(|rows| rows_to_toc_entries(&rows))
352 .transpose()?;
353
354 let mut info = Info {
355 title: row.title,
356 subtitle: row.subtitle,
357 author: Self::extract_authors(row.authors),
358 year: row.year,
359 language: row.language,
360 publisher: row.publisher,
361 series: row.series,
362 edition: row.edition,
363 volume: row.volume,
364 number: row.number,
365 identifier: row.identifier,
366 categories: Self::extract_categories(row.categories),
367 file: FileInfo {
368 path: PathBuf::from(&row.file_path),
369 absolute_path: PathBuf::from(&row.absolute_path),
370 kind: row.file_kind,
371 size: row.file_size as u64,
372 },
373 reader: None,
374 reader_info: None,
375 toc,
376 added: row.added_at.into(),
377 };
378
379 if let Some(opened_ts) = row.opened {
380 let reader_info = ReaderInfo {
381 opened: opened_ts.into(),
382 current_page: row.current_page.unwrap_or(0) as usize,
383 pages_count: row.pages_count.unwrap_or(0) as usize,
384 finished: row.finished.unwrap_or(0) == 1,
385 dithered: row.dithered.unwrap_or(0) == 1,
386 zoom_mode: Self::parse_zoom_mode(row.zoom_mode.as_ref()),
387 scroll_mode: Self::parse_scroll_mode(row.scroll_mode.as_ref()),
388 page_offset: Self::parse_page_offset(row.page_offset_x, row.page_offset_y),
389 rotation: row.rotation.map(|r| r as i8),
390 cropping_margins: Self::parse_cropping_margins(
391 row.cropping_margins_json.as_ref(),
392 ),
393 margin_width: row.margin_width.map(|m| m as i32),
394 screen_margin_width: row.screen_margin_width.map(|m| m as i32),
395 font_family: row.font_family.clone(),
396 font_size: row.font_size.map(|f| f as f32),
397 text_align: Self::parse_text_align(row.text_align.as_ref()),
398 line_height: row.line_height.map(|l| l as f32),
399 contrast_exponent: row.contrast_exponent.map(|c| c as f32),
400 contrast_gray: row.contrast_gray.map(|c| c as f32),
401 page_names: Self::parse_page_names(row.page_names_json.as_ref()),
402 bookmarks: Self::parse_bookmarks(row.bookmarks_json.as_ref()),
403 annotations: Self::parse_annotations(row.annotations_json.as_ref()),
404 };
405 info.reader = Some(reader_info.clone());
406 info.reader_info = Some(reader_info);
407 }
408
409 result.push((fp, info));
410 }
411
412 tracing::debug!(library_id, count = result.len(), "fetched all books");
413 Ok(result)
414 })
415 }
416
417 #[cfg_attr(feature = "otel", tracing::instrument(skip(self, info), fields(fp = %fp, library_id)))]
418 pub fn insert_book(&self, library_id: i64, fp: Fp, info: &Info) -> Result<(), Error> {
419 tracing::debug!(fp = %fp, library_id, "inserting book into database");
420 let fp_str = fp.to_string();
421
422 RUNTIME.block_on(async {
423 let mut tx = self.pool.begin().await?;
424
425 let book_row = info_to_book_row(fp, info);
426
427 sqlx::query!(
428 r#"
429 INSERT OR IGNORE INTO books (
430 fingerprint, title, subtitle, year, language, publisher,
431 series, edition, volume, number, identifier,
432 absolute_path, file_kind, file_size, added_at
433 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
434 "#,
435 book_row.fingerprint,
436 book_row.title,
437 book_row.subtitle,
438 book_row.year,
439 book_row.language,
440 book_row.publisher,
441 book_row.series,
442 book_row.edition,
443 book_row.volume,
444 book_row.number,
445 book_row.identifier,
446 book_row.absolute_path,
447 book_row.file_kind,
448 book_row.file_size,
449 book_row.added_at,
450 )
451 .execute(&mut *tx)
452 .await?;
453
454 sqlx::query!(
455 r#"
456 INSERT OR IGNORE INTO library_books (library_id, book_fingerprint, added_to_library_at, file_path)
457 VALUES (?, ?, ?, ?)
458 "#,
459 library_id,
460 fp_str,
461 book_row.added_at,
462 book_row.file_path,
463 )
464 .execute(&mut *tx)
465 .await?;
466
467 let authors = extract_authors(&info.author);
468 for (position, author_name) in authors.iter().enumerate() {
469 sqlx::query!(
470 r#"INSERT OR IGNORE INTO authors (name) VALUES (?)"#,
471 author_name
472 )
473 .execute(&mut *tx)
474 .await?;
475
476 let author_id: i64 = sqlx::query_scalar!(
477 r#"SELECT id FROM authors WHERE name = ?"#,
478 author_name
479 )
480 .fetch_one(&mut *tx)
481 .await?;
482
483 let pos = position as i64;
484 sqlx::query!(
485 r#"
486 INSERT OR IGNORE INTO book_authors (book_fingerprint, author_id, position)
487 VALUES (?, ?, ?)
488 "#,
489 fp_str,
490 author_id,
491 pos
492 )
493 .execute(&mut *tx)
494 .await?;
495 }
496
497 for category_name in &info.categories {
498 sqlx::query!(
499 r#"INSERT OR IGNORE INTO categories (name) VALUES (?)"#,
500 category_name
501 )
502 .execute(&mut *tx)
503 .await?;
504
505 let category_id: i64 = sqlx::query_scalar!(
506 r#"SELECT id FROM categories WHERE name = ?"#,
507 category_name
508 )
509 .fetch_one(&mut *tx)
510 .await?;
511
512 sqlx::query!(
513 r#"
514 INSERT OR IGNORE INTO book_categories (book_fingerprint, category_id)
515 VALUES (?, ?)
516 "#,
517 fp_str,
518 category_id
519 )
520 .execute(&mut *tx)
521 .await?;
522 }
523
524 if let Some(reader_info) = &info.reader_info {
525 let rs_row = reader_info_to_reading_state_row(fp, reader_info);
526
527 sqlx::query!(
528 r#"
529 INSERT INTO reading_states (
530 fingerprint, opened, current_page, pages_count, finished, dithered,
531 zoom_mode, scroll_mode, page_offset_x, page_offset_y, rotation,
532 cropping_margins_json, margin_width, screen_margin_width,
533 font_family, font_size, text_align, line_height,
534 contrast_exponent, contrast_gray,
535 page_names_json, bookmarks_json, annotations_json
536 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
537 "#,
538 rs_row.fingerprint,
539 rs_row.opened,
540 rs_row.current_page,
541 rs_row.pages_count,
542 rs_row.finished,
543 rs_row.dithered,
544 rs_row.zoom_mode,
545 rs_row.scroll_mode,
546 rs_row.page_offset_x,
547 rs_row.page_offset_y,
548 rs_row.rotation,
549 rs_row.cropping_margins_json,
550 rs_row.margin_width,
551 rs_row.screen_margin_width,
552 rs_row.font_family,
553 rs_row.font_size,
554 rs_row.text_align,
555 rs_row.line_height,
556 rs_row.contrast_exponent,
557 rs_row.contrast_gray,
558 rs_row.page_names_json,
559 rs_row.bookmarks_json,
560 rs_row.annotations_json,
561 )
562 .execute(&mut *tx)
563 .await?;
564 }
565
566 tx.commit().await?;
567
568 tracing::debug!(fp = %fp, "book insert complete");
569 Ok(())
570 })
571 }
572
573 #[cfg_attr(feature = "otel", tracing::instrument(skip(self, info), fields(fp = %fp, library_id)))]
574 pub fn update_book(&self, library_id: i64, fp: Fp, info: &Info) -> Result<(), Error> {
575 tracing::debug!(fp = %fp, library_id, "updating book in database");
576 let fp_str = fp.to_string();
577
578 RUNTIME.block_on(async {
579 let mut tx = self.pool.begin().await?;
580
581 let book_row = info_to_book_row(fp, info);
582
583 sqlx::query!(
584 r#"
585 UPDATE books SET
586 title = ?, subtitle = ?, year = ?, language = ?, publisher = ?,
587 series = ?, edition = ?, volume = ?, number = ?, identifier = ?,
588 absolute_path = ?, file_kind = ?, file_size = ?, added_at = ?
589 WHERE fingerprint = ?
590 "#,
591 book_row.title,
592 book_row.subtitle,
593 book_row.year,
594 book_row.language,
595 book_row.publisher,
596 book_row.series,
597 book_row.edition,
598 book_row.volume,
599 book_row.number,
600 book_row.identifier,
601 book_row.absolute_path,
602 book_row.file_kind,
603 book_row.file_size,
604 book_row.added_at,
605 fp_str,
606 )
607 .execute(&mut *tx)
608 .await?;
609
610 sqlx::query!(
611 r#"
612 UPDATE library_books SET file_path = ?
613 WHERE library_id = ? AND book_fingerprint = ?
614 "#,
615 book_row.file_path,
616 library_id,
617 fp_str,
618 )
619 .execute(&mut *tx)
620 .await?;
621
622 sqlx::query!(
623 r#"DELETE FROM book_authors WHERE book_fingerprint = ?"#,
624 fp_str
625 )
626 .execute(&mut *tx)
627 .await?;
628
629 let authors = extract_authors(&info.author);
630 for (position, author_name) in authors.iter().enumerate() {
631 sqlx::query!(
632 r#"INSERT OR IGNORE INTO authors (name) VALUES (?)"#,
633 author_name
634 )
635 .execute(&mut *tx)
636 .await?;
637
638 let author_id: i64 =
639 sqlx::query_scalar!(r#"SELECT id FROM authors WHERE name = ?"#, author_name)
640 .fetch_one(&mut *tx)
641 .await?;
642
643 let pos = position as i64;
644 sqlx::query!(
645 r#"
646 INSERT INTO book_authors (book_fingerprint, author_id, position)
647 VALUES (?, ?, ?)
648 "#,
649 fp_str,
650 author_id,
651 pos
652 )
653 .execute(&mut *tx)
654 .await?;
655 }
656
657 sqlx::query!(
658 r#"DELETE FROM book_categories WHERE book_fingerprint = ?"#,
659 fp_str
660 )
661 .execute(&mut *tx)
662 .await?;
663
664 for category_name in &info.categories {
665 sqlx::query!(
666 r#"INSERT OR IGNORE INTO categories (name) VALUES (?)"#,
667 category_name
668 )
669 .execute(&mut *tx)
670 .await?;
671
672 let category_id: i64 = sqlx::query_scalar!(
673 r#"SELECT id FROM categories WHERE name = ?"#,
674 category_name
675 )
676 .fetch_one(&mut *tx)
677 .await?;
678
679 sqlx::query!(
680 r#"
681 INSERT INTO book_categories (book_fingerprint, category_id)
682 VALUES (?, ?)
683 "#,
684 fp_str,
685 category_id
686 )
687 .execute(&mut *tx)
688 .await?;
689 }
690
691 tx.commit().await?;
692
693 tracing::debug!(fp = %fp, "book update complete");
694 Ok(())
695 })
696 }
697
698 #[cfg_attr(feature = "otel", tracing::instrument(skip(self), fields(fp = %fp)))]
699 pub fn delete_reading_state(&self, fp: Fp) -> Result<(), Error> {
700 tracing::debug!(fp = %fp, "deleting reading state from database");
701
702 RUNTIME.block_on(async {
703 let fp_str = fp.to_string();
704
705 sqlx::query!(
706 r#"DELETE FROM reading_states WHERE fingerprint = ?"#,
707 fp_str
708 )
709 .execute(&self.pool)
710 .await?;
711
712 Ok(())
713 })
714 }
715
716 #[cfg_attr(feature = "otel", tracing::instrument(skip(self), fields(fp = %fp, library_id)))]
717 pub fn delete_book(&self, library_id: i64, fp: Fp) -> Result<(), Error> {
718 tracing::debug!(fp = %fp, library_id, "deleting book from library");
719
720 RUNTIME.block_on(async {
721 let fp_str = fp.to_string();
722 let mut tx = self.pool.begin().await?;
723
724 sqlx::query!(
725 r#"DELETE FROM library_books WHERE library_id = ? AND book_fingerprint = ?"#,
726 library_id,
727 fp_str
728 )
729 .execute(&mut *tx)
730 .await?;
731
732 let remaining: i64 = sqlx::query_scalar!(
733 r#"SELECT COUNT(*) FROM library_books WHERE book_fingerprint = ?"#,
734 fp_str
735 )
736 .fetch_one(&mut *tx)
737 .await?;
738
739 if remaining == 0 {
740 tracing::debug!(fp = %fp, "book not in any library, deleting completely");
741 sqlx::query!(r#"DELETE FROM books WHERE fingerprint = ?"#, fp_str)
742 .execute(&mut *tx)
743 .await?;
744 }
745
746 tx.commit().await?;
747
748 tracing::debug!(fp = %fp, library_id, "book delete complete");
749 Ok(())
750 })
751 }
752
753 #[cfg_attr(feature = "otel", tracing::instrument(skip(self), fields(fp = %fp)))]
754 pub fn get_thumbnail(&self, fp: Fp) -> Result<Option<Vec<u8>>, Error> {
755 tracing::debug!(fp = %fp, "fetching thumbnail from database");
756 let fp_str = fp.to_string();
757
758 RUNTIME.block_on(async {
759 sqlx::query_scalar!(
760 "SELECT thumbnail_data FROM thumbnails WHERE fingerprint = ?",
761 fp_str
762 )
763 .fetch_optional(&self.pool)
764 .await
765 .map_err(Error::from)
766 })
767 }
768
769 #[cfg_attr(feature = "otel", tracing::instrument(skip(self, data), fields(fp = %fp, size = data.len())))]
770 pub fn save_thumbnail(&self, fp: Fp, data: &[u8]) -> Result<(), Error> {
771 tracing::debug!(fp = %fp, size = data.len(), "saving thumbnail to database");
772 let fp_str = fp.to_string();
773
774 RUNTIME.block_on(async {
775 sqlx::query!(
776 r#"
777 INSERT INTO thumbnails (fingerprint, thumbnail_data)
778 VALUES (?, ?)
779 ON CONFLICT(fingerprint) DO UPDATE SET
780 thumbnail_data = excluded.thumbnail_data
781 "#,
782 fp_str,
783 data,
784 )
785 .execute(&self.pool)
786 .await?;
787
788 tracing::debug!(fp = %fp, "thumbnail save complete");
789 Ok(())
790 })
791 }
792
793 #[cfg_attr(feature = "otel", tracing::instrument(skip(self), fields(fp = %fp)))]
794 pub fn delete_thumbnail(&self, fp: Fp) -> Result<(), Error> {
795 tracing::debug!(fp = %fp, "deleting thumbnail from database");
796 let fp_str = fp.to_string();
797
798 RUNTIME.block_on(async {
799 sqlx::query!("DELETE FROM thumbnails WHERE fingerprint = ?", fp_str)
800 .execute(&self.pool)
801 .await?;
802
803 tracing::debug!(fp = %fp, "thumbnail delete complete");
804 Ok(())
805 })
806 }
807
808 #[cfg_attr(feature = "otel", tracing::instrument(skip(self), fields(from = %from_fp, to = %to_fp)))]
809 pub fn move_thumbnail(&self, from_fp: Fp, to_fp: Fp) -> Result<(), Error> {
810 tracing::debug!(from = %from_fp, to = %to_fp, "moving thumbnail in database");
811 let from_fp_str = from_fp.to_string();
812 let to_fp_str = to_fp.to_string();
813
814 RUNTIME.block_on(async {
815 sqlx::query!(
816 r#"
817 UPDATE thumbnails
818 SET fingerprint = ?
819 WHERE fingerprint = ?
820 "#,
821 to_fp_str,
822 from_fp_str
823 )
824 .execute(&self.pool)
825 .await?;
826
827 Ok(())
828 })
829 }
830
831 #[cfg_attr(feature = "otel", tracing::instrument(skip(self, reader_info), fields(fp = %fp)))]
832 pub fn save_reading_state(&self, fp: Fp, reader_info: &ReaderInfo) -> Result<(), Error> {
833 tracing::debug!(fp = %fp, "saving reading state to database");
834
835 RUNTIME.block_on(async {
836 let rs_row = reader_info_to_reading_state_row(fp, reader_info);
837
838 sqlx::query!(
839 r#"
840 INSERT INTO reading_states (
841 fingerprint, opened, current_page, pages_count, finished, dithered,
842 zoom_mode, scroll_mode, page_offset_x, page_offset_y, rotation,
843 cropping_margins_json, margin_width, screen_margin_width,
844 font_family, font_size, text_align, line_height,
845 contrast_exponent, contrast_gray,
846 page_names_json, bookmarks_json, annotations_json
847 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
848 ON CONFLICT(fingerprint) DO UPDATE SET
849 opened = excluded.opened,
850 current_page = excluded.current_page,
851 pages_count = excluded.pages_count,
852 finished = excluded.finished,
853 dithered = excluded.dithered,
854 zoom_mode = excluded.zoom_mode,
855 scroll_mode = excluded.scroll_mode,
856 page_offset_x = excluded.page_offset_x,
857 page_offset_y = excluded.page_offset_y,
858 rotation = excluded.rotation,
859 cropping_margins_json = excluded.cropping_margins_json,
860 margin_width = excluded.margin_width,
861 screen_margin_width = excluded.screen_margin_width,
862 font_family = excluded.font_family,
863 font_size = excluded.font_size,
864 text_align = excluded.text_align,
865 line_height = excluded.line_height,
866 contrast_exponent = excluded.contrast_exponent,
867 contrast_gray = excluded.contrast_gray,
868 page_names_json = excluded.page_names_json,
869 bookmarks_json = excluded.bookmarks_json,
870 annotations_json = excluded.annotations_json
871 "#,
872 rs_row.fingerprint,
873 rs_row.opened,
874 rs_row.current_page,
875 rs_row.pages_count,
876 rs_row.finished,
877 rs_row.dithered,
878 rs_row.zoom_mode,
879 rs_row.scroll_mode,
880 rs_row.page_offset_x,
881 rs_row.page_offset_y,
882 rs_row.rotation,
883 rs_row.cropping_margins_json,
884 rs_row.margin_width,
885 rs_row.screen_margin_width,
886 rs_row.font_family,
887 rs_row.font_size,
888 rs_row.text_align,
889 rs_row.line_height,
890 rs_row.contrast_exponent,
891 rs_row.contrast_gray,
892 rs_row.page_names_json,
893 rs_row.bookmarks_json,
894 rs_row.annotations_json,
895 )
896 .execute(&self.pool)
897 .await?;
898
899 tracing::debug!(fp = %fp, "reading state save complete");
900 Ok(())
901 })
902 }
903
904 #[cfg_attr(feature = "otel", tracing::instrument(skip(self, toc), fields(fp = %fp, entry_count = toc.len())))]
905 pub fn save_toc(&self, fp: Fp, toc: &[SimpleTocEntry]) -> Result<(), Error> {
906 if toc.is_empty() {
907 return Ok(());
908 }
909
910 tracing::debug!(fp = %fp, entry_count = toc.len(), "saving TOC to database");
911 let fp_str = fp.to_string();
912
913 RUNTIME.block_on(async {
914 let mut tx = self.pool.begin().await?;
915
916 sqlx::query!("DELETE FROM toc_entries WHERE book_fingerprint = ?", fp_str)
917 .execute(&mut *tx)
918 .await?;
919
920 Self::insert_toc_entries(&mut tx, &fp_str, toc, None).await?;
921
922 tx.commit().await?;
923
924 tracing::debug!(fp = %fp, "TOC save complete");
925 Ok(())
926 })
927 }
928
929 #[cfg_attr(feature = "otel", tracing::instrument(skip(self, books), fields(library_id, count = books.len())))]
930 pub fn batch_insert_books(&self, library_id: i64, books: &[(Fp, &Info)]) -> Result<(), Error> {
931 if books.is_empty() {
932 return Ok(());
933 }
934
935 tracing::debug!(library_id, count = books.len(), "batch inserting books");
936
937 RUNTIME.block_on(async {
938 let mut tx = self.pool.begin().await?;
939
940 for (fp, info) in books {
941 let fp_str = fp.to_string();
942 let book_row = info_to_book_row(*fp, info);
943
944 sqlx::query!(
945 r#"
946 INSERT OR IGNORE INTO books (
947 fingerprint, title, subtitle, year, language, publisher,
948 series, edition, volume, number, identifier,
949 absolute_path, file_kind, file_size, added_at
950 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
951 "#,
952 book_row.fingerprint,
953 book_row.title,
954 book_row.subtitle,
955 book_row.year,
956 book_row.language,
957 book_row.publisher,
958 book_row.series,
959 book_row.edition,
960 book_row.volume,
961 book_row.number,
962 book_row.identifier,
963 book_row.absolute_path,
964 book_row.file_kind,
965 book_row.file_size,
966 book_row.added_at,
967 )
968 .execute(&mut *tx)
969 .await?;
970
971 sqlx::query!(
972 r#"
973 INSERT OR IGNORE INTO library_books (library_id, book_fingerprint, added_to_library_at, file_path)
974 VALUES (?, ?, ?, ?)
975 "#,
976 library_id,
977 fp_str,
978 book_row.added_at,
979 book_row.file_path,
980 )
981 .execute(&mut *tx)
982 .await?;
983
984 let authors = extract_authors(&info.author);
985 for (position, author_name) in authors.iter().enumerate() {
986 sqlx::query!(
987 r#"INSERT OR IGNORE INTO authors (name) VALUES (?)"#,
988 author_name
989 )
990 .execute(&mut *tx)
991 .await?;
992
993 let author_id: i64 = sqlx::query_scalar!(
994 r#"SELECT id FROM authors WHERE name = ?"#,
995 author_name
996 )
997 .fetch_one(&mut *tx)
998 .await?;
999
1000 let pos = position as i64;
1001 sqlx::query!(
1002 r#"
1003 INSERT INTO book_authors (book_fingerprint, author_id, position)
1004 VALUES (?, ?, ?)
1005 "#,
1006 fp_str,
1007 author_id,
1008 pos
1009 )
1010 .execute(&mut *tx)
1011 .await?;
1012 }
1013
1014 for category_name in &info.categories {
1015 sqlx::query!(
1016 r#"INSERT OR IGNORE INTO categories (name) VALUES (?)"#,
1017 category_name
1018 )
1019 .execute(&mut *tx)
1020 .await?;
1021
1022 let category_id: i64 = sqlx::query_scalar!(
1023 r#"SELECT id FROM categories WHERE name = ?"#,
1024 category_name
1025 )
1026 .fetch_one(&mut *tx)
1027 .await?;
1028
1029 sqlx::query!(
1030 r#"
1031 INSERT INTO book_categories (book_fingerprint, category_id)
1032 VALUES (?, ?)
1033 "#,
1034 fp_str,
1035 category_id
1036 )
1037 .execute(&mut *tx)
1038 .await?;
1039 }
1040
1041 if let Some(reader_info) = &info.reader_info {
1042 let rs_row = reader_info_to_reading_state_row(*fp, reader_info);
1043
1044 sqlx::query!(
1045 r#"
1046 INSERT INTO reading_states (
1047 fingerprint, opened, current_page, pages_count, finished, dithered,
1048 zoom_mode, scroll_mode, page_offset_x, page_offset_y, rotation,
1049 cropping_margins_json, margin_width, screen_margin_width,
1050 font_family, font_size, text_align, line_height,
1051 contrast_exponent, contrast_gray,
1052 page_names_json, bookmarks_json, annotations_json
1053 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1054 "#,
1055 rs_row.fingerprint,
1056 rs_row.opened,
1057 rs_row.current_page,
1058 rs_row.pages_count,
1059 rs_row.finished,
1060 rs_row.dithered,
1061 rs_row.zoom_mode,
1062 rs_row.scroll_mode,
1063 rs_row.page_offset_x,
1064 rs_row.page_offset_y,
1065 rs_row.rotation,
1066 rs_row.cropping_margins_json,
1067 rs_row.margin_width,
1068 rs_row.screen_margin_width,
1069 rs_row.font_family,
1070 rs_row.font_size,
1071 rs_row.text_align,
1072 rs_row.line_height,
1073 rs_row.contrast_exponent,
1074 rs_row.contrast_gray,
1075 rs_row.page_names_json,
1076 rs_row.bookmarks_json,
1077 rs_row.annotations_json,
1078 )
1079 .execute(&mut *tx)
1080 .await?;
1081 }
1082
1083 if let Some(ref toc) = info.toc {
1084 sqlx::query!("DELETE FROM toc_entries WHERE book_fingerprint = ?", fp_str)
1085 .execute(&mut *tx)
1086 .await?;
1087 Self::insert_toc_entries(&mut tx, &fp_str, toc, None).await?;
1088 }
1089 }
1090
1091 tx.commit().await?;
1092
1093 tracing::debug!(count = books.len(), "batch insert complete");
1094 Ok(())
1095 })
1096 }
1097
1098 #[cfg_attr(feature = "otel", tracing::instrument(skip(self, books), fields(library_id, count = books.len())))]
1099 pub fn batch_update_books(&self, library_id: i64, books: &[(Fp, &Info)]) -> Result<(), Error> {
1100 if books.is_empty() {
1101 return Ok(());
1102 }
1103
1104 tracing::debug!(library_id, count = books.len(), "batch updating books");
1105
1106 RUNTIME.block_on(async {
1107 let mut tx = self.pool.begin().await?;
1108
1109 for (fp, info) in books {
1110 let fp_str = fp.to_string();
1111
1112 let book_row = info_to_book_row(*fp, info);
1113
1114 sqlx::query!(
1115 r#"
1116 UPDATE books SET
1117 title = ?, subtitle = ?, year = ?, language = ?, publisher = ?,
1118 series = ?, edition = ?, volume = ?, number = ?, identifier = ?,
1119 absolute_path = ?, file_kind = ?, file_size = ?, added_at = ?
1120 WHERE fingerprint = ?
1121 "#,
1122 book_row.title,
1123 book_row.subtitle,
1124 book_row.year,
1125 book_row.language,
1126 book_row.publisher,
1127 book_row.series,
1128 book_row.edition,
1129 book_row.volume,
1130 book_row.number,
1131 book_row.identifier,
1132 book_row.absolute_path,
1133 book_row.file_kind,
1134 book_row.file_size,
1135 book_row.added_at,
1136 fp_str,
1137 )
1138 .execute(&mut *tx)
1139 .await?;
1140
1141 sqlx::query!(
1142 r#"
1143 UPDATE library_books SET file_path = ?
1144 WHERE library_id = ? AND book_fingerprint = ?
1145 "#,
1146 book_row.file_path,
1147 library_id,
1148 fp_str,
1149 )
1150 .execute(&mut *tx)
1151 .await?;
1152
1153 sqlx::query!(
1154 r#"DELETE FROM book_authors WHERE book_fingerprint = ?"#,
1155 fp_str
1156 )
1157 .execute(&mut *tx)
1158 .await?;
1159
1160 let authors = extract_authors(&info.author);
1161 for (position, author_name) in authors.iter().enumerate() {
1162 sqlx::query!(
1163 r#"INSERT OR IGNORE INTO authors (name) VALUES (?)"#,
1164 author_name
1165 )
1166 .execute(&mut *tx)
1167 .await?;
1168
1169 let author_id: i64 = sqlx::query_scalar!(
1170 r#"SELECT id FROM authors WHERE name = ?"#,
1171 author_name
1172 )
1173 .fetch_one(&mut *tx)
1174 .await?;
1175
1176 let pos = position as i64;
1177 sqlx::query!(
1178 r#"
1179 INSERT INTO book_authors (book_fingerprint, author_id, position)
1180 VALUES (?, ?, ?)
1181 "#,
1182 fp_str,
1183 author_id,
1184 pos
1185 )
1186 .execute(&mut *tx)
1187 .await?;
1188 }
1189
1190 sqlx::query!(
1191 r#"DELETE FROM book_categories WHERE book_fingerprint = ?"#,
1192 fp_str
1193 )
1194 .execute(&mut *tx)
1195 .await?;
1196
1197 for category_name in &info.categories {
1198 sqlx::query!(
1199 r#"INSERT OR IGNORE INTO categories (name) VALUES (?)"#,
1200 category_name
1201 )
1202 .execute(&mut *tx)
1203 .await?;
1204
1205 let category_id: i64 = sqlx::query_scalar!(
1206 r#"SELECT id FROM categories WHERE name = ?"#,
1207 category_name
1208 )
1209 .fetch_one(&mut *tx)
1210 .await?;
1211
1212 sqlx::query!(
1213 r#"
1214 INSERT INTO book_categories (book_fingerprint, category_id)
1215 VALUES (?, ?)
1216 "#,
1217 fp_str,
1218 category_id
1219 )
1220 .execute(&mut *tx)
1221 .await?;
1222 }
1223
1224 if let Some(ref toc) = info.toc {
1225 sqlx::query!("DELETE FROM toc_entries WHERE book_fingerprint = ?", fp_str)
1226 .execute(&mut *tx)
1227 .await?;
1228 Self::insert_toc_entries(&mut tx, &fp_str, toc, None).await?;
1229 } else {
1230 sqlx::query!("DELETE FROM toc_entries WHERE book_fingerprint = ?", fp_str)
1231 .execute(&mut *tx)
1232 .await?;
1233 }
1234 }
1235
1236 tx.commit().await?;
1237
1238 tracing::debug!(count = books.len(), "batch update complete");
1239 Ok(())
1240 })
1241 }
1242
1243 #[cfg_attr(feature = "otel", tracing::instrument(skip(self, fps), fields(library_id, count = fps.len())))]
1244 pub fn batch_delete_books(&self, library_id: i64, fps: &[Fp]) -> Result<(), Error> {
1245 if fps.is_empty() {
1246 return Ok(());
1247 }
1248
1249 tracing::debug!(
1250 library_id,
1251 count = fps.len(),
1252 "batch deleting books from library"
1253 );
1254
1255 RUNTIME.block_on(async {
1256 let mut tx = self.pool.begin().await?;
1257
1258 for fp in fps {
1259 let fp_str = fp.to_string();
1260
1261 sqlx::query!(
1262 r#"DELETE FROM library_books WHERE library_id = ? AND book_fingerprint = ?"#,
1263 library_id,
1264 fp_str
1265 )
1266 .execute(&mut *tx)
1267 .await?;
1268
1269 let ref_count: i64 = sqlx::query_scalar!(
1270 r#"SELECT COUNT(*) FROM library_books WHERE book_fingerprint = ?"#,
1271 fp_str
1272 )
1273 .fetch_one(&mut *tx)
1274 .await?;
1275
1276 if ref_count == 0 {
1277 sqlx::query!(
1278 r#"DELETE FROM books WHERE fingerprint = ?"#,
1279 fp_str
1280 )
1281 .execute(&mut *tx)
1282 .await?;
1283 tracing::debug!(fp = %fp, "book removed from database (no more library references)");
1284 } else {
1285 tracing::debug!(fp = %fp, ref_count, "book kept in database (still referenced by other libraries)");
1286 }
1287 }
1288
1289 tx.commit().await?;
1290
1291 tracing::debug!(count = fps.len(), "batch delete complete");
1292 Ok(())
1293 })
1294 }
1295}
1296
1297#[cfg(test)]
1298mod tests {
1299 use super::*;
1300 use crate::db::Database;
1301 use crate::metadata::ReaderInfo;
1302 use chrono::Local;
1303 use std::path::PathBuf;
1304 use std::str::FromStr;
1305
1306 fn create_test_db() -> (Database, Db) {
1307 let db = Database::new(":memory:").expect("failed to create in-memory database");
1308 db.migrate().expect("failed to run migrations");
1309 let libdb = Db::new(&db);
1310 (db, libdb)
1311 }
1312
1313 #[test]
1314 fn test_insert_and_get_book() {
1315 let (_db, libdb) = create_test_db();
1316 let fp = Fp::from_str("0000000000000001").unwrap();
1317
1318 let info = Info {
1319 title: "Test Book".to_string(),
1320 subtitle: "A Test".to_string(),
1321 author: "John Doe, Jane Smith".to_string(),
1322 year: "2024".to_string(),
1323 language: "en".to_string(),
1324 publisher: "Test Press".to_string(),
1325 series: "Test Series".to_string(),
1326 number: "1".to_string(),
1327 categories: vec!["Fiction".to_string(), "Science".to_string()]
1328 .into_iter()
1329 .collect(),
1330 file: FileInfo {
1331 path: PathBuf::from("/tmp/test.pdf"),
1332 kind: "pdf".to_string(),
1333 size: 1024,
1334 ..Default::default()
1335 },
1336 added: Local::now().naive_local(),
1337 ..Default::default()
1338 };
1339
1340 let library_id = libdb
1341 .register_library("/tmp/test_library", "Test Library")
1342 .expect("failed to register library");
1343 libdb
1344 .insert_book(library_id, fp, &info)
1345 .expect("failed to insert book");
1346
1347 let books = libdb
1348 .get_all_books(library_id)
1349 .expect("failed to get books");
1350 let retrieved_info = books.iter().find(|(f, _)| *f == fp).map(|(_, i)| i.clone());
1351 assert!(retrieved_info.is_some(), "book should exist in database");
1352
1353 let retrieved_info = retrieved_info.unwrap();
1354 assert_eq!(retrieved_info.title, "Test Book");
1355 assert_eq!(retrieved_info.subtitle, "A Test");
1356 assert_eq!(retrieved_info.author, "John Doe, Jane Smith");
1357 assert_eq!(retrieved_info.year, "2024");
1358 assert_eq!(retrieved_info.language, "en");
1359 assert_eq!(retrieved_info.publisher, "Test Press");
1360 assert_eq!(retrieved_info.series, "Test Series");
1361 assert_eq!(retrieved_info.number, "1");
1362 assert_eq!(retrieved_info.file.path, PathBuf::from("/tmp/test.pdf"));
1363 assert_eq!(retrieved_info.file.kind, "pdf");
1364 assert_eq!(retrieved_info.file.size, 1024);
1365 }
1366
1367 #[test]
1368 fn test_insert_book_with_reading_state() {
1369 let (_db, libdb) = create_test_db();
1370 let fp = Fp::from_str("0000000000000002").unwrap();
1371
1372 let reader_info = ReaderInfo {
1373 current_page: 42,
1374 pages_count: 100,
1375 ..Default::default()
1376 };
1377 let info = Info {
1378 title: "Book with Reading State".to_string(),
1379 author: "Test Author".to_string(),
1380 file: FileInfo {
1381 path: PathBuf::from("/tmp/test2.pdf"),
1382 kind: "pdf".to_string(),
1383 size: 2048,
1384 ..Default::default()
1385 },
1386 reader_info: Some(reader_info.clone()),
1387 ..Default::default()
1388 };
1389
1390 let library_id = libdb
1391 .register_library("/tmp/test_library2", "Test Library 2")
1392 .expect("failed to register library");
1393 libdb
1394 .insert_book(library_id, fp, &info)
1395 .expect("failed to insert book");
1396
1397 let books = libdb
1398 .get_all_books(library_id)
1399 .expect("failed to get books");
1400 let retrieved = books
1401 .iter()
1402 .find(|(f, _)| *f == fp)
1403 .map(|(_, i)| i.clone())
1404 .unwrap();
1405 assert_eq!(retrieved.title, "Book with Reading State");
1406
1407 assert!(
1408 retrieved.reader_info.is_some(),
1409 "reading state should exist"
1410 );
1411 let retrieved_reader = retrieved.reader_info.unwrap();
1412 assert_eq!(retrieved_reader.current_page, 42);
1413 assert_eq!(retrieved_reader.pages_count, 100);
1414 assert!(!retrieved_reader.finished);
1415 let (_db, libdb) = create_test_db();
1416 let fp = Fp::from_str("0000000000000003").unwrap();
1417
1418 let info = Info {
1419 title: "Book to Delete".to_string(),
1420 author: "Delete Author".to_string(),
1421 file: FileInfo {
1422 path: PathBuf::from("/tmp/delete.pdf"),
1423 kind: "pdf".to_string(),
1424 size: 512,
1425 ..Default::default()
1426 },
1427 ..Default::default()
1428 };
1429
1430 let library_id = libdb
1431 .register_library("/tmp/test_library3", "Test Library 3")
1432 .expect("failed to register library");
1433 libdb
1434 .insert_book(library_id, fp, &info)
1435 .expect("failed to insert book");
1436
1437 let books = libdb
1438 .get_all_books(library_id)
1439 .expect("failed to get books");
1440 assert!(
1441 books.iter().any(|(f, _)| *f == fp),
1442 "book should exist before delete"
1443 );
1444
1445 libdb
1446 .delete_book(library_id, fp)
1447 .expect("failed to delete book");
1448
1449 let books = libdb
1450 .get_all_books(library_id)
1451 .expect("failed to get books");
1452 assert!(
1453 !books.iter().any(|(f, _)| *f == fp),
1454 "book should not exist after delete"
1455 );
1456 }
1457
1458 #[test]
1459 fn test_multiple_books() {
1460 let (_db, libdb) = create_test_db();
1461 let library_id = libdb
1462 .register_library("/tmp/test_library4", "Test Library 4")
1463 .expect("failed to register library");
1464
1465 for i in 1..=5 {
1466 let fp = Fp::from_str(&format!("{:016X}", i)).unwrap();
1467 let info = Info {
1468 title: format!("Book {}", i),
1469 author: format!("Author {}", i),
1470 file: FileInfo {
1471 path: PathBuf::from(format!("/tmp/book{}.pdf", i)),
1472 kind: "pdf".to_string(),
1473 size: (i * 100) as u64,
1474 ..Default::default()
1475 },
1476 ..Default::default()
1477 };
1478
1479 libdb
1480 .insert_book(library_id, fp, &info)
1481 .expect("failed to insert book");
1482 }
1483
1484 let books = libdb
1485 .get_all_books(library_id)
1486 .expect("failed to get books");
1487 for i in 1..=5 {
1488 let fp = Fp::from_str(&format!("{:016X}", i)).unwrap();
1489 let retrieved = books
1490 .iter()
1491 .find(|(f, _)| *f == fp)
1492 .map(|(_, i)| i.clone())
1493 .unwrap();
1494 assert_eq!(retrieved.title, format!("Book {}", i));
1495 assert_eq!(retrieved.author, format!("Author {}", i));
1496 }
1497 }
1498
1499 #[test]
1500 fn test_update_book() {
1501 let (_db, libdb) = create_test_db();
1502 let fp = Fp::from_str("0000000000000004").unwrap();
1503
1504 let mut info = Info {
1505 title: "Original Title".to_string(),
1506 author: "Original Author".to_string(),
1507 file: FileInfo {
1508 path: PathBuf::from("/tmp/update.pdf"),
1509 kind: "pdf".to_string(),
1510 size: 1024,
1511 ..Default::default()
1512 },
1513 ..Default::default()
1514 };
1515
1516 let library_id = libdb
1517 .register_library("/tmp/test_library5", "Test Library 5")
1518 .expect("failed to register library");
1519 libdb
1520 .insert_book(library_id, fp, &info)
1521 .expect("failed to insert book");
1522
1523 info.title = "Updated Title".to_string();
1524 info.author = "Updated Author".to_string();
1525 info.year = "2025".to_string();
1526
1527 libdb
1528 .update_book(library_id, fp, &info)
1529 .expect("failed to update book");
1530
1531 let books = libdb
1532 .get_all_books(library_id)
1533 .expect("failed to get books");
1534 let updated = books
1535 .iter()
1536 .find(|(f, _)| *f == fp)
1537 .map(|(_, i)| i.clone())
1538 .unwrap();
1539 assert_eq!(updated.title, "Updated Title");
1540 assert_eq!(updated.author, "Updated Author");
1541 assert_eq!(updated.year, "2025");
1542 }
1543
1544 #[test]
1545 fn test_get_all_books() {
1546 let (_db, libdb) = create_test_db();
1547 let library_id = libdb
1548 .register_library("/tmp/test_library6", "Test Library 6")
1549 .expect("failed to register library");
1550
1551 for i in 1..=3 {
1552 let fp = Fp::from_str(&format!("{:016X}", i)).unwrap();
1553 let info = Info {
1554 title: format!("Book {}", i),
1555 author: format!("Author {}", i),
1556 file: FileInfo {
1557 path: PathBuf::from(format!("/tmp/book{}.pdf", i)),
1558 kind: "pdf".to_string(),
1559 size: (i * 100) as u64,
1560 ..Default::default()
1561 },
1562 ..Default::default()
1563 };
1564
1565 libdb
1566 .insert_book(library_id, fp, &info)
1567 .expect("failed to insert book");
1568 }
1569
1570 let all_books = libdb
1571 .get_all_books(library_id)
1572 .expect("failed to get all books");
1573 assert_eq!(all_books.len(), 3);
1574
1575 let titles: Vec<String> = all_books
1576 .iter()
1577 .map(|(_, info)| info.title.clone())
1578 .collect();
1579 assert!(titles.contains(&"Book 1".to_string()));
1580 assert!(titles.contains(&"Book 2".to_string()));
1581 assert!(titles.contains(&"Book 3".to_string()));
1582 }
1583
1584 #[test]
1585 fn test_reading_state_crud() {
1586 let (_db, libdb) = create_test_db();
1587 let fp = Fp::from_str("0000000000000005").unwrap();
1588
1589 let info = Info {
1590 title: "Book with State".to_string(),
1591 author: "State Author".to_string(),
1592 file: FileInfo {
1593 path: PathBuf::from("/tmp/state.pdf"),
1594 kind: "pdf".to_string(),
1595 size: 1024,
1596 ..Default::default()
1597 },
1598 ..Default::default()
1599 };
1600
1601 let library_id = libdb
1602 .register_library("/tmp/test_library7", "Test Library 7")
1603 .expect("failed to register library");
1604 libdb
1605 .insert_book(library_id, fp, &info)
1606 .expect("failed to insert book");
1607
1608 let mut reader_info = ReaderInfo {
1609 current_page: 50,
1610 pages_count: 200,
1611 ..Default::default()
1612 };
1613
1614 libdb
1615 .save_reading_state(fp, &reader_info)
1616 .expect("failed to save reading state");
1617
1618 let books = libdb
1619 .get_all_books(library_id)
1620 .expect("failed to get books");
1621 let retrieved = books
1622 .iter()
1623 .find(|(f, _)| *f == fp)
1624 .map(|(_, i)| i.clone())
1625 .unwrap();
1626 let retrieved_reader = retrieved.reader_info.unwrap();
1627
1628 assert_eq!(retrieved_reader.current_page, 50);
1629 assert_eq!(retrieved_reader.pages_count, 200);
1630 assert!(!retrieved_reader.finished);
1631 reader_info.current_page = 100;
1632 reader_info.finished = true;
1633
1634 libdb
1635 .save_reading_state(fp, &reader_info)
1636 .expect("failed to update reading state");
1637
1638 let books = libdb
1639 .get_all_books(library_id)
1640 .expect("failed to get books");
1641 let updated = books
1642 .iter()
1643 .find(|(f, _)| *f == fp)
1644 .map(|(_, i)| i.clone())
1645 .unwrap();
1646 let updated_reader = updated.reader_info.unwrap();
1647
1648 assert_eq!(updated_reader.current_page, 100);
1649 assert!(updated_reader.finished);
1650 }
1651
1652 #[test]
1653 fn test_batch_insert_books() {
1654 let (_db, libdb) = create_test_db();
1655 let library_id = libdb
1656 .register_library("/tmp/test_library8", "Test Library 8")
1657 .expect("failed to register library");
1658
1659 let mut books = Vec::new();
1660 for i in 1..=5 {
1661 let fp = Fp::from_str(&format!("{:016X}", i + 100)).unwrap();
1662 let info = Info {
1663 title: format!("Batch Book {}", i),
1664 author: format!("Batch Author {}, Co-Author {}", i, i + 1),
1665 year: format!("{}", 2020 + i),
1666 file: FileInfo {
1667 path: PathBuf::from(format!("/tmp/batch{}.pdf", i)),
1668 kind: "pdf".to_string(),
1669 size: (i * 100) as u64,
1670 ..Default::default()
1671 },
1672 ..Default::default()
1673 };
1674 books.push((fp, info));
1675 }
1676
1677 let book_refs: Vec<(Fp, &Info)> = books.iter().map(|(fp, info)| (*fp, info)).collect();
1678
1679 libdb
1680 .batch_insert_books(library_id, &book_refs)
1681 .expect("failed to batch insert books");
1682
1683 let all_books = libdb
1684 .get_all_books(library_id)
1685 .expect("failed to get books");
1686 for (fp, info) in &books {
1687 let retrieved = all_books
1688 .iter()
1689 .find(|(f, _)| *f == *fp)
1690 .map(|(_, i)| i.clone())
1691 .expect("book should exist");
1692 assert_eq!(retrieved.title, info.title);
1693 assert_eq!(retrieved.author, info.author);
1694 assert_eq!(retrieved.year, info.year);
1695 }
1696
1697 let all_books = libdb
1698 .get_all_books(library_id)
1699 .expect("failed to get all books");
1700 assert_eq!(all_books.len(), 5);
1701 }
1702
1703 #[test]
1704 fn test_batch_update_books() {
1705 let (_db, libdb) = create_test_db();
1706 let library_id = libdb
1707 .register_library("/tmp/test_library9", "Test Library 9")
1708 .expect("failed to register library");
1709
1710 let mut books = Vec::new();
1711 for i in 1..=3 {
1712 let fp = Fp::from_str(&format!("{:016X}", i + 200)).unwrap();
1713 let mut info = Info {
1714 title: format!("Original Book {}", i),
1715 author: format!("Original Author {}", i),
1716 file: FileInfo {
1717 path: PathBuf::from(format!("/tmp/update{}.pdf", i)),
1718 kind: "pdf".to_string(),
1719 size: (i * 100) as u64,
1720 ..Default::default()
1721 },
1722 ..Default::default()
1723 };
1724 libdb
1725 .insert_book(library_id, fp, &info)
1726 .expect("failed to insert book");
1727
1728 info.title = format!("Updated Book {}", i);
1729 info.author = format!("Updated Author {}", i);
1730 info.year = format!("{}", 2024 + i);
1731 books.push((fp, info));
1732 }
1733
1734 let book_refs: Vec<(Fp, &Info)> = books.iter().map(|(fp, info)| (*fp, info)).collect();
1735
1736 libdb
1737 .batch_update_books(library_id, &book_refs)
1738 .expect("failed to batch update books");
1739
1740 let all_books = libdb
1741 .get_all_books(library_id)
1742 .expect("failed to get books");
1743 for (fp, info) in &books {
1744 let retrieved = all_books
1745 .iter()
1746 .find(|(f, _)| *f == *fp)
1747 .map(|(_, i)| i.clone())
1748 .expect("book should exist");
1749 assert_eq!(retrieved.title, info.title);
1750 assert_eq!(retrieved.author, info.author);
1751 assert_eq!(retrieved.year, info.year);
1752 }
1753 }
1754
1755 #[test]
1756 fn test_batch_delete_books() {
1757 let (_db, libdb) = create_test_db();
1758 let library_id = libdb
1759 .register_library("/tmp/test_library10", "Test Library 10")
1760 .expect("failed to register library");
1761
1762 let mut fps = Vec::new();
1763 for i in 1..=4 {
1764 let fp = Fp::from_str(&format!("{:016X}", i + 300)).unwrap();
1765 let info = Info {
1766 title: format!("Delete Book {}", i),
1767 author: format!("Delete Author {}", i),
1768 file: FileInfo {
1769 path: PathBuf::from(format!("/tmp/delete{}.pdf", i)),
1770 kind: "pdf".to_string(),
1771 size: (i * 100) as u64,
1772 ..Default::default()
1773 },
1774 ..Default::default()
1775 };
1776 libdb
1777 .insert_book(library_id, fp, &info)
1778 .expect("failed to insert book");
1779 fps.push(fp);
1780 }
1781
1782 let all_before = libdb
1783 .get_all_books(library_id)
1784 .expect("failed to get all books");
1785 assert_eq!(all_before.len(), 4);
1786
1787 libdb
1788 .batch_delete_books(library_id, &fps)
1789 .expect("failed to batch delete books");
1790
1791 let all_after = libdb
1792 .get_all_books(library_id)
1793 .expect("failed to get all books");
1794 assert_eq!(all_after.len(), 0);
1795 }
1796
1797 #[test]
1798 fn test_batch_operations_with_empty_input() {
1799 let (_db, libdb) = create_test_db();
1800 let library_id = libdb
1801 .register_library("/tmp/test_library11", "Test Library 11")
1802 .expect("failed to register library");
1803
1804 let empty_books: Vec<(Fp, &Info)> = Vec::new();
1805 let empty_fps: Vec<Fp> = Vec::new();
1806
1807 libdb
1808 .batch_insert_books(library_id, &empty_books)
1809 .expect("empty batch insert should succeed");
1810 libdb
1811 .batch_update_books(library_id, &empty_books)
1812 .expect("empty batch update should succeed");
1813 libdb
1814 .batch_delete_books(library_id, &empty_fps)
1815 .expect("empty batch delete should succeed");
1816 }
1817
1818 #[test]
1819 fn test_categories_round_trip() {
1820 let (_db, libdb) = create_test_db();
1821 let fp = Fp::from_str("0000000000000099").unwrap();
1822
1823 let info = Info {
1824 title: "Categorized Book".to_string(),
1825 author: "Cat Author".to_string(),
1826 file: FileInfo {
1827 path: PathBuf::from("/tmp/cat.pdf"),
1828 kind: "pdf".to_string(),
1829 size: 512,
1830 ..Default::default()
1831 },
1832 categories: ["Fiction", "Science", "History"]
1833 .iter()
1834 .map(|s| s.to_string())
1835 .collect(),
1836 ..Default::default()
1837 };
1838
1839 let library_id = libdb
1840 .register_library("/tmp/test_library_cat", "Cat Library")
1841 .expect("failed to register library");
1842 libdb
1843 .insert_book(library_id, fp, &info)
1844 .expect("failed to insert book");
1845
1846 let books = libdb
1847 .get_all_books(library_id)
1848 .expect("failed to get books");
1849 let retrieved = books
1850 .iter()
1851 .find(|(f, _)| *f == fp)
1852 .map(|(_, i)| i.clone())
1853 .expect("book should exist");
1854
1855 assert_eq!(retrieved.categories, info.categories);
1856 }
1857
1858 #[test]
1859 fn test_categories_updated_on_update_book() {
1860 let (_db, libdb) = create_test_db();
1861 let fp = Fp::from_str("000000000000009A").unwrap();
1862
1863 let mut info = Info {
1864 title: "Updateable Book".to_string(),
1865 author: "Update Author".to_string(),
1866 file: FileInfo {
1867 path: PathBuf::from("/tmp/upd_cat.pdf"),
1868 kind: "pdf".to_string(),
1869 size: 512,
1870 ..Default::default()
1871 },
1872 categories: ["OldCat"].iter().map(|s| s.to_string()).collect(),
1873 ..Default::default()
1874 };
1875
1876 let library_id = libdb
1877 .register_library("/tmp/test_library_upd_cat", "Upd Cat Library")
1878 .expect("failed to register library");
1879 libdb
1880 .insert_book(library_id, fp, &info)
1881 .expect("failed to insert book");
1882
1883 info.categories = ["NewCat1", "NewCat2"]
1884 .iter()
1885 .map(|s| s.to_string())
1886 .collect();
1887 libdb
1888 .update_book(library_id, fp, &info)
1889 .expect("failed to update book");
1890
1891 let books = libdb
1892 .get_all_books(library_id)
1893 .expect("failed to get books");
1894 let retrieved = books
1895 .iter()
1896 .find(|(f, _)| *f == fp)
1897 .map(|(_, i)| i.clone())
1898 .expect("book should exist");
1899
1900 assert_eq!(retrieved.categories, info.categories);
1901 }
1902
1903 #[test]
1904 fn test_batch_insert_with_reading_state() {
1905 let (_db, libdb) = create_test_db();
1906 let library_id = libdb
1907 .register_library("/tmp/test_library12", "Test Library 12")
1908 .expect("failed to register library");
1909
1910 let mut books = Vec::new();
1911 for i in 1..=3 {
1912 let fp = Fp::from_str(&format!("{:016X}", i + 400)).unwrap();
1913 let reader_info = ReaderInfo {
1914 current_page: i * 10,
1915 pages_count: i * 100,
1916 finished: i % 2 == 0,
1917 ..Default::default()
1918 };
1919 let info = Info {
1920 title: format!("Book with State {}", i),
1921 author: format!("State Author {}", i),
1922 file: FileInfo {
1923 path: PathBuf::from(format!("/tmp/state{}.pdf", i)),
1924 kind: "pdf".to_string(),
1925 size: (i * 100) as u64,
1926 ..Default::default()
1927 },
1928 reader_info: Some(reader_info),
1929 ..Default::default()
1930 };
1931
1932 books.push((fp, info));
1933 }
1934
1935 let book_refs: Vec<(Fp, &Info)> = books.iter().map(|(fp, info)| (*fp, info)).collect();
1936
1937 libdb
1938 .batch_insert_books(library_id, &book_refs)
1939 .expect("failed to batch insert books with reading state");
1940
1941 let all_books = libdb
1942 .get_all_books(library_id)
1943 .expect("failed to get books");
1944 for (fp, info) in &books {
1945 let retrieved = all_books
1946 .iter()
1947 .find(|(f, _)| *f == *fp)
1948 .map(|(_, i)| i.clone())
1949 .expect("book should exist");
1950 assert_eq!(retrieved.title, info.title);
1951
1952 assert!(
1953 retrieved.reader_info.is_some(),
1954 "reading state should exist"
1955 );
1956 let retrieved_state = retrieved.reader_info.unwrap();
1957 let original_state = info.reader_info.as_ref().unwrap();
1958 assert_eq!(retrieved_state.current_page, original_state.current_page);
1959 assert_eq!(retrieved_state.pages_count, original_state.pages_count);
1960 assert_eq!(retrieved_state.finished, original_state.finished);
1961 }
1962 }
1963}