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 alphabetic_author, alphabetic_title, natural_cmp, sorter, CroppingMargins, FileInfo, Info,
12 ReaderInfo, ScrollMode, SortMethod, TextAlign, ZoomMode,
13};
14use crate::settings::FileExtension;
15use anyhow::Error;
16use conversion::{
17 extract_authors, info_to_book_row, reader_info_to_reading_state_row, rows_to_toc_entries,
18};
19use fxhash::{FxHashMap, FxHashSet};
20use models::TocEntryRow;
21use sqlx::sqlite::SqlitePool;
22use std::collections::{BTreeMap, BTreeSet, HashMap};
23use std::path::{Path, PathBuf};
24
25const SORT_RANK_STRIDE: i64 = 1_000;
31
32fn midpoint_rank(existing_ranks: &[Option<i64>], pos: usize) -> Option<i64> {
39 let left = if pos == 0 {
40 None
41 } else {
42 existing_ranks.get(pos - 1).copied().flatten()
43 };
44 let right = existing_ranks.get(pos).copied().flatten();
45
46 match (left, right) {
47 (None, None) => Some(SORT_RANK_STRIDE),
48 (None, Some(r)) => {
49 if r <= 1 {
50 None
51 } else {
52 Some(r / 2)
53 }
54 }
55 (Some(l), None) => Some(l + SORT_RANK_STRIDE),
56 (Some(l), Some(r)) => {
57 let mid = (l + r) / 2;
58 if mid <= l {
59 None
60 } else {
61 Some(mid)
62 }
63 }
64 }
65}
66
67#[derive(sqlx::FromRow)]
69struct TitleSortRow {
70 title: String,
71 language: String,
72 file_path: String,
73 sort_title: Option<i64>,
74}
75
76#[derive(sqlx::FromRow)]
78struct AuthorSortRow {
79 authors: Option<String>,
80 sort_author: Option<i64>,
81}
82
83#[derive(sqlx::FromRow)]
85struct FilePathSortRow {
86 file_path: String,
87 sort_filepath: Option<i64>,
88}
89
90#[derive(sqlx::FromRow)]
92struct FileNameSortRow {
93 file_path: String,
94 sort_filename: Option<i64>,
95}
96
97#[derive(sqlx::FromRow)]
99struct SeriesSortRow {
100 series: String,
101 number: String,
102 sort_series: Option<i64>,
103}
104
105#[derive(Debug, Clone, sqlx::FromRow)]
106struct StoredBookRow {
107 fingerprint: Fp,
108 title: String,
109 subtitle: String,
110 year: String,
111 language: String,
112 publisher: String,
113 series: String,
114 edition: String,
115 volume: String,
116 number: String,
117 identifier: String,
118 file_path: String,
119 absolute_path: String,
120 file_kind: String,
121 file_size: i64,
122 added_at: UnixTimestamp,
123 opened: Option<UnixTimestamp>,
124 current_page: Option<i64>,
125 pages_count: Option<i64>,
126 finished: Option<i64>,
127 dithered: Option<i64>,
128 zoom_mode: Option<String>,
129 scroll_mode: Option<String>,
130 page_offset_x: Option<i64>,
131 page_offset_y: Option<i64>,
132 rotation: Option<i64>,
133 cropping_margins_json: Option<String>,
134 margin_width: Option<i64>,
135 screen_margin_width: Option<i64>,
136 font_family: Option<String>,
137 font_size: Option<f64>,
138 text_align: Option<String>,
139 line_height: Option<f64>,
140 contrast_exponent: Option<f64>,
141 contrast_gray: Option<f64>,
142 page_names_json: Option<String>,
143 bookmarks_json: Option<String>,
144 annotations_json: Option<String>,
145 authors: Option<String>,
146 categories: Option<String>,
147}
148
149#[derive(Clone)]
150pub struct Db {
151 pool: SqlitePool,
152}
153
154impl Db {
155 pub fn new(database: &Database) -> Self {
156 Self {
157 pool: database.pool().clone(),
158 }
159 }
160
161 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(path = %path, name = %name)))]
162 pub fn register_library(&self, path: &str, name: &str) -> Result<i64, Error> {
163 tracing::debug!(path = %path, name = %name, "registering library");
164
165 RUNTIME.block_on(async {
166 let now = UnixTimestamp::now();
167
168 let result = sqlx::query!(
169 r#"
170 INSERT INTO libraries (path, name, created_at)
171 VALUES (?, ?, ?)
172 "#,
173 path,
174 name,
175 now
176 )
177 .execute(&self.pool)
178 .await?;
179
180 let library_id = result.last_insert_rowid();
181 tracing::info!(library_id, path = %path, name = %name, "library registered");
182 Ok(library_id)
183 })
184 }
185
186 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(path = %path)))]
187 pub fn get_library_by_path(&self, path: &str) -> Result<Option<i64>, Error> {
188 tracing::debug!(path = %path, "looking up library by path");
189
190 RUNTIME.block_on(async {
191 let id: Option<Option<i64>> =
192 sqlx::query_scalar!(r#"SELECT id FROM libraries WHERE path = ?"#, path)
193 .fetch_optional(&self.pool)
194 .await?;
195
196 Ok(id.flatten())
197 })
198 }
199
200 #[inline]
201 fn parse_zoom_mode(json: Option<&String>) -> Option<ZoomMode> {
202 match json {
203 Some(s) => match serde_json::from_str(s) {
204 Ok(v) => Some(v),
205 Err(e) => {
206 tracing::warn!(error = %e, "failed to parse zoom_mode JSON field");
207 None
208 }
209 },
210 None => None,
211 }
212 }
213
214 #[inline]
215 fn parse_scroll_mode(json: Option<&String>) -> Option<ScrollMode> {
216 match json {
217 Some(s) => match serde_json::from_str(s) {
218 Ok(v) => Some(v),
219 Err(e) => {
220 tracing::warn!(error = %e, "failed to parse scroll_mode JSON field");
221 None
222 }
223 },
224 None => None,
225 }
226 }
227
228 #[inline]
229 fn parse_text_align(json: Option<&String>) -> Option<TextAlign> {
230 match json {
231 Some(s) => match serde_json::from_str(s) {
232 Ok(v) => Some(v),
233 Err(e) => {
234 tracing::warn!(error = %e, "failed to parse text_align JSON field");
235 None
236 }
237 },
238 None => None,
239 }
240 }
241
242 #[inline]
243 fn parse_cropping_margins(json: Option<&String>) -> Option<CroppingMargins> {
244 match json {
245 Some(s) => match serde_json::from_str(s) {
246 Ok(v) => Some(v),
247 Err(e) => {
248 tracing::warn!(error = %e, "failed to parse cropping_margins JSON field");
249 None
250 }
251 },
252 None => None,
253 }
254 }
255
256 #[inline]
257 fn parse_page_names(json: Option<&String>) -> BTreeMap<usize, String> {
258 match json {
259 Some(s) => match serde_json::from_str(s) {
260 Ok(v) => v,
261 Err(e) => {
262 tracing::warn!(error = %e, "failed to parse page_names JSON field");
263 BTreeMap::default()
264 }
265 },
266 None => BTreeMap::default(),
267 }
268 }
269
270 #[inline]
271 fn parse_bookmarks(json: Option<&String>) -> BTreeSet<usize> {
272 match json {
273 Some(s) => match serde_json::from_str(s) {
274 Ok(v) => v,
275 Err(e) => {
276 tracing::warn!(error = %e, "failed to parse bookmarks JSON field");
277 BTreeSet::default()
278 }
279 },
280 None => BTreeSet::default(),
281 }
282 }
283
284 #[inline]
285 fn parse_annotations(json: Option<&String>) -> Vec<crate::metadata::Annotation> {
286 match json {
287 Some(s) => match serde_json::from_str(s) {
288 Ok(v) => v,
289 Err(e) => {
290 tracing::warn!(error = %e, "failed to parse annotations JSON field");
291 Vec::new()
292 }
293 },
294 None => Vec::new(),
295 }
296 }
297
298 #[inline]
299 fn parse_page_offset(x: Option<i64>, y: Option<i64>) -> Option<Point> {
300 match (x, y) {
301 (Some(x_val), Some(y_val)) => Some(Point::new(x_val as i32, y_val as i32)),
302 _ => None,
303 }
304 }
305
306 #[inline]
307 fn extract_authors(authors: Option<String>) -> String {
308 authors
309 .map(|s| s.split(',').collect::<Vec<_>>().join(", "))
310 .unwrap_or_default()
311 }
312
313 #[inline]
314 fn extract_categories(categories: Option<String>) -> BTreeSet<String> {
315 categories
316 .unwrap_or_default()
317 .split(',')
318 .filter(|s| !s.is_empty())
319 .map(|s| s.to_string())
320 .collect()
321 }
322
323 #[cfg_attr(feature = "tracing", tracing::instrument(skip(pool)))]
324 async fn fetch_toc_entries_for_book(
325 pool: &SqlitePool,
326 library_id: i64,
327 fingerprint: &str,
328 ) -> Result<Vec<TocEntryRow>, Error> {
329 let rows = sqlx::query_as!(
330 TocEntryRow,
331 r#"
332 SELECT
333 te.book_fingerprint,
334 te.id as "id: Uuid7",
335 te.parent_id as "parent_id!: OptionalUuid7",
336 te.position,
337 te.title,
338 te.location_kind,
339 te.location_exact,
340 te.location_uri
341 FROM toc_entries te
342 INNER JOIN library_books lb ON lb.book_fingerprint = te.book_fingerprint
343 WHERE lb.library_id = ? AND te.book_fingerprint = ?
344 ORDER BY te.id ASC
345 "#,
346 library_id,
347 fingerprint,
348 )
349 .fetch_all(pool)
350 .await?;
351
352 Ok(rows)
353 }
354
355 fn stored_book_row_to_info(
356 row: StoredBookRow,
357 toc: Option<Vec<SimpleTocEntry>>,
358 ) -> Result<Info, Error> {
359 let fp = row.fingerprint;
360
361 let mut info = Info {
362 title: row.title,
363 subtitle: row.subtitle,
364 author: Self::extract_authors(row.authors),
365 year: row.year,
366 language: row.language,
367 publisher: row.publisher,
368 series: row.series,
369 edition: row.edition,
370 volume: row.volume,
371 number: row.number,
372 identifier: row.identifier,
373 categories: Self::extract_categories(row.categories),
374 file: FileInfo {
375 path: PathBuf::from(&row.file_path),
376 absolute_path: PathBuf::from(&row.absolute_path),
377 kind: row.file_kind,
378 size: row.file_size as u64,
379 },
380 reader: None,
381 reader_info: None,
382 toc,
383 added: row.added_at.into(),
384 fp: Some(fp),
385 };
386
387 if let Some(opened_ts) = row.opened {
388 let reader_info = ReaderInfo {
389 opened: opened_ts.into(),
390 current_page: row.current_page.unwrap_or(0) as usize,
391 pages_count: row.pages_count.unwrap_or(0) as usize,
392 finished: row.finished.unwrap_or(0) == 1,
393 dithered: row.dithered.unwrap_or(0) == 1,
394 zoom_mode: Self::parse_zoom_mode(row.zoom_mode.as_ref()),
395 scroll_mode: Self::parse_scroll_mode(row.scroll_mode.as_ref()),
396 page_offset: Self::parse_page_offset(row.page_offset_x, row.page_offset_y),
397 rotation: row.rotation.map(|rotation| rotation as i8),
398 cropping_margins: Self::parse_cropping_margins(row.cropping_margins_json.as_ref()),
399 margin_width: row.margin_width.map(|margin| margin as i32),
400 screen_margin_width: row.screen_margin_width.map(|margin| margin as i32),
401 font_family: row.font_family,
402 font_size: row.font_size.map(|size| size as f32),
403 text_align: Self::parse_text_align(row.text_align.as_ref()),
404 line_height: row.line_height.map(|height| height as f32),
405 contrast_exponent: row.contrast_exponent.map(|contrast| contrast as f32),
406 contrast_gray: row.contrast_gray.map(|contrast| contrast as f32),
407 page_names: Self::parse_page_names(row.page_names_json.as_ref()),
408 bookmarks: Self::parse_bookmarks(row.bookmarks_json.as_ref()),
409 annotations: Self::parse_annotations(row.annotations_json.as_ref()),
410 };
411 info.reader = Some(reader_info.clone());
412 info.reader_info = Some(reader_info);
413 }
414
415 Ok(info)
416 }
417
418 #[cfg_attr(feature = "tracing", tracing::instrument(skip(conn, entries), fields(book_fingerprint = %book_fingerprint, parent_id = ?parent_id)))]
419 async fn insert_toc_entries(
420 conn: &mut sqlx::SqliteConnection,
421 book_fingerprint: &str,
422 entries: &[SimpleTocEntry],
423 parent_id: Option<Uuid7>,
424 ) -> Result<(), Error> {
425 for (position, entry) in entries.iter().enumerate() {
426 let (title, location, children) = match entry {
427 SimpleTocEntry::Leaf(t, loc) => (t.as_str(), loc, [].as_slice()),
428 SimpleTocEntry::Container(t, loc, ch) => (t.as_str(), loc, ch.as_slice()),
429 };
430
431 let (location_kind, location_exact, location_uri) =
432 conversion::encode_location(location);
433 let pos = position as i64;
434 let id = Uuid7::now();
435 let parent_id_str = parent_id.as_ref().map(|p| p.to_string());
436
437 sqlx::query!(
438 r#"
439 INSERT INTO toc_entries (id, book_fingerprint, parent_id, position, title, location_kind, location_exact, location_uri)
440 VALUES (?, ?, ?, ?, ?, ?, ?, ?)
441 "#,
442 id,
443 book_fingerprint,
444 parent_id_str,
445 pos,
446 title,
447 location_kind,
448 location_exact,
449 location_uri,
450 )
451 .execute(&mut *conn)
452 .await?;
453
454 if !children.is_empty() {
455 Box::pin(Self::insert_toc_entries(
456 conn,
457 book_fingerprint,
458 children,
459 Some(id),
460 ))
461 .await?;
462 }
463 }
464
465 Ok(())
466 }
467
468 async fn fetch_all_toc_entries(
469 pool: &SqlitePool,
470 library_id: i64,
471 ) -> Result<HashMap<String, Vec<TocEntryRow>>, Error> {
472 let toc_rows: Vec<TocEntryRow> = sqlx::query_as!(
473 TocEntryRow,
474 r#"
475 SELECT
476 te.book_fingerprint,
477 te.id as "id: Uuid7",
478 te.parent_id as "parent_id!: OptionalUuid7",
479 te.position,
480 te.title,
481 te.location_kind,
482 te.location_exact,
483 te.location_uri
484 FROM toc_entries te
485 INNER JOIN library_books lb ON lb.book_fingerprint = te.book_fingerprint
486 WHERE lb.library_id = ?
487 ORDER BY te.book_fingerprint, te.id ASC
488 "#,
489 library_id
490 )
491 .fetch_all(pool)
492 .await?;
493
494 let mut map: HashMap<String, Vec<TocEntryRow>> = HashMap::new();
495
496 for row in toc_rows {
497 map.entry(row.book_fingerprint.clone())
498 .or_default()
499 .push(row);
500 }
501
502 Ok(map)
503 }
504
505 #[cfg_attr(
506 feature = "tracing",
507 tracing::instrument(skip(self), fields(library_id))
508 )]
509 pub fn get_all_books(&self, library_id: i64) -> Result<Vec<Info>, Error> {
510 tracing::debug!(library_id, "fetching all books from database");
511
512 RUNTIME.block_on(async {
513 let book_rows = sqlx::query!(
514 r#"
515 SELECT
516 fingerprint as "fingerprint: Fp",
517 title,
518 subtitle,
519 year,
520 language,
521 publisher,
522 series,
523 edition,
524 volume,
525 number,
526 identifier,
527 file_path,
528 absolute_path,
529 file_kind,
530 file_size,
531 added_at as "added_at: UnixTimestamp",
532 opened as "opened?: UnixTimestamp",
533 current_page as "current_page?: i64",
534 pages_count as "pages_count?: i64",
535 finished as "finished?: i64",
536 dithered as "dithered?: i64",
537 zoom_mode as "zoom_mode?: String",
538 scroll_mode as "scroll_mode?: String",
539 page_offset_x as "page_offset_x?: i64",
540 page_offset_y as "page_offset_y?: i64",
541 rotation as "rotation?: i64",
542 cropping_margins_json as "cropping_margins_json?: String",
543 margin_width as "margin_width?: i64",
544 screen_margin_width as "screen_margin_width?: i64",
545 font_family as "font_family?: String",
546 font_size as "font_size?: f64",
547 text_align as "text_align?: String",
548 line_height as "line_height?: f64",
549 contrast_exponent as "contrast_exponent?: f64",
550 contrast_gray as "contrast_gray?: f64",
551 page_names_json as "page_names_json?: String",
552 bookmarks_json as "bookmarks_json?: String",
553 annotations_json as "annotations_json?: String",
554 authors as "authors?: String",
555 categories as "categories?: String"
556 FROM library_books_full_info
557 WHERE library_id = ?
558 ORDER BY added_at DESC
559 "#,
560 library_id
561 )
562 .fetch_all(&self.pool)
563 .await?;
564
565 let mut toc_by_fingerprint =
566 Self::fetch_all_toc_entries(&self.pool, library_id).await?;
567
568 let mut result = Vec::new();
569
570 for row in book_rows {
571 let fp = row.fingerprint;
572
573 let toc = toc_by_fingerprint
574 .remove(&fp.to_string())
575 .map(|rows| rows_to_toc_entries(&rows))
576 .transpose()?;
577
578 let mut info = Info {
579 title: row.title,
580 subtitle: row.subtitle,
581 author: Self::extract_authors(row.authors),
582 year: row.year,
583 language: row.language,
584 publisher: row.publisher,
585 series: row.series,
586 edition: row.edition,
587 volume: row.volume,
588 number: row.number,
589 identifier: row.identifier,
590 categories: Self::extract_categories(row.categories),
591 file: FileInfo {
592 path: PathBuf::from(&row.file_path),
593 absolute_path: PathBuf::from(&row.absolute_path),
594 kind: row.file_kind,
595 size: row.file_size as u64,
596 },
597 reader: None,
598 reader_info: None,
599 toc,
600 added: row.added_at.into(),
601 fp: Some(fp),
602 };
603 if let Some(opened_ts) = row.opened {
604 let reader_info = ReaderInfo {
605 opened: opened_ts.into(),
606 current_page: row.current_page.unwrap_or(0) as usize,
607 pages_count: row.pages_count.unwrap_or(0) as usize,
608 finished: row.finished.unwrap_or(0) == 1,
609 dithered: row.dithered.unwrap_or(0) == 1,
610 zoom_mode: Self::parse_zoom_mode(row.zoom_mode.as_ref()),
611 scroll_mode: Self::parse_scroll_mode(row.scroll_mode.as_ref()),
612 page_offset: Self::parse_page_offset(row.page_offset_x, row.page_offset_y),
613 rotation: row.rotation.map(|r| r as i8),
614 cropping_margins: Self::parse_cropping_margins(
615 row.cropping_margins_json.as_ref(),
616 ),
617 margin_width: row.margin_width.map(|m| m as i32),
618 screen_margin_width: row.screen_margin_width.map(|m| m as i32),
619 font_family: row.font_family.clone(),
620 font_size: row.font_size.map(|f| f as f32),
621 text_align: Self::parse_text_align(row.text_align.as_ref()),
622 line_height: row.line_height.map(|l| l as f32),
623 contrast_exponent: row.contrast_exponent.map(|c| c as f32),
624 contrast_gray: row.contrast_gray.map(|c| c as f32),
625 page_names: Self::parse_page_names(row.page_names_json.as_ref()),
626 bookmarks: Self::parse_bookmarks(row.bookmarks_json.as_ref()),
627 annotations: Self::parse_annotations(row.annotations_json.as_ref()),
628 };
629 info.reader = Some(reader_info.clone());
630 info.reader_info = Some(reader_info);
631 }
632
633 result.push(info);
634 }
635
636 tracing::debug!(library_id, count = result.len(), "fetched all books");
637 Ok(result)
638 })
639 }
640
641 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, path), fields(library_id, path = %path.display())))]
642 pub fn get_book_by_path(&self, library_id: i64, path: &Path) -> Result<Option<Info>, Error> {
643 let path = path.to_string_lossy().into_owned();
644
645 RUNTIME.block_on(async {
646 let row = sqlx::query_as!(
647 StoredBookRow,
648 r#"
649 SELECT
650 fingerprint as "fingerprint: Fp",
651 title,
652 subtitle,
653 year,
654 language,
655 publisher,
656 series,
657 edition,
658 volume,
659 number,
660 identifier,
661 file_path,
662 absolute_path,
663 file_kind,
664 file_size,
665 added_at as "added_at: UnixTimestamp",
666 opened as "opened?: UnixTimestamp",
667 current_page as "current_page?: i64",
668 pages_count as "pages_count?: i64",
669 finished as "finished?: i64",
670 dithered as "dithered?: i64",
671 zoom_mode as "zoom_mode?: String",
672 scroll_mode as "scroll_mode?: String",
673 page_offset_x as "page_offset_x?: i64",
674 page_offset_y as "page_offset_y?: i64",
675 rotation as "rotation?: i64",
676 cropping_margins_json as "cropping_margins_json?: String",
677 margin_width as "margin_width?: i64",
678 screen_margin_width as "screen_margin_width?: i64",
679 font_family as "font_family?: String",
680 font_size as "font_size?: f64",
681 text_align as "text_align?: String",
682 line_height as "line_height?: f64",
683 contrast_exponent as "contrast_exponent?: f64",
684 contrast_gray as "contrast_gray?: f64",
685 page_names_json as "page_names_json?: String",
686 bookmarks_json as "bookmarks_json?: String",
687 annotations_json as "annotations_json?: String",
688 authors as "authors?: String",
689 categories as "categories?: String"
690 FROM library_books_full_info
691 WHERE library_id = ? AND file_path = ?
692 LIMIT 1
693 "#,
694 library_id,
695 path,
696 )
697 .fetch_optional(&self.pool)
698 .await?;
699
700 let Some(row) = row else {
701 return Ok(None);
702 };
703
704 let toc_rows = Self::fetch_toc_entries_for_book(
705 &self.pool,
706 library_id,
707 &row.fingerprint.to_string(),
708 )
709 .await?;
710 let toc = (!toc_rows.is_empty())
711 .then(|| rows_to_toc_entries(&toc_rows))
712 .transpose()?;
713
714 Self::stored_book_row_to_info(row, toc).map(Some)
715 })
716 }
717
718 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(library_id, fp = %fp)))]
719 pub fn get_book_by_fingerprint(&self, library_id: i64, fp: Fp) -> Result<Option<Info>, Error> {
720 let fingerprint = fp.to_string();
721
722 RUNTIME.block_on(async {
723 let row = sqlx::query_as!(
724 StoredBookRow,
725 r#"
726 SELECT
727 fingerprint as "fingerprint: Fp",
728 title,
729 subtitle,
730 year,
731 language,
732 publisher,
733 series,
734 edition,
735 volume,
736 number,
737 identifier,
738 file_path,
739 absolute_path,
740 file_kind,
741 file_size,
742 added_at as "added_at: UnixTimestamp",
743 opened as "opened?: UnixTimestamp",
744 current_page as "current_page?: i64",
745 pages_count as "pages_count?: i64",
746 finished as "finished?: i64",
747 dithered as "dithered?: i64",
748 zoom_mode as "zoom_mode?: String",
749 scroll_mode as "scroll_mode?: String",
750 page_offset_x as "page_offset_x?: i64",
751 page_offset_y as "page_offset_y?: i64",
752 rotation as "rotation?: i64",
753 cropping_margins_json as "cropping_margins_json?: String",
754 margin_width as "margin_width?: i64",
755 screen_margin_width as "screen_margin_width?: i64",
756 font_family as "font_family?: String",
757 font_size as "font_size?: f64",
758 text_align as "text_align?: String",
759 line_height as "line_height?: f64",
760 contrast_exponent as "contrast_exponent?: f64",
761 contrast_gray as "contrast_gray?: f64",
762 page_names_json as "page_names_json?: String",
763 bookmarks_json as "bookmarks_json?: String",
764 annotations_json as "annotations_json?: String",
765 authors as "authors?: String",
766 categories as "categories?: String"
767 FROM library_books_full_info
768 WHERE library_id = ? AND fingerprint = ?
769 LIMIT 1
770 "#,
771 library_id,
772 fingerprint,
773 )
774 .fetch_optional(&self.pool)
775 .await?;
776
777 let Some(row) = row else {
778 return Ok(None);
779 };
780
781 let toc_rows = Self::fetch_toc_entries_for_book(
782 &self.pool,
783 library_id,
784 &row.fingerprint.to_string(),
785 )
786 .await?;
787 let toc = (!toc_rows.is_empty())
788 .then(|| rows_to_toc_entries(&toc_rows))
789 .transpose()?;
790
791 Self::stored_book_row_to_info(row, toc).map(Some)
792 })
793 }
794
795 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, fps), fields(library_id, count = fps.len())))]
801 pub fn batch_get_books_by_fingerprints(
802 &self,
803 library_id: i64,
804 fps: &[Fp],
805 ) -> Result<FxHashMap<Fp, Info>, Error> {
806 if fps.is_empty() {
807 return Ok(FxHashMap::default());
808 }
809
810 tracing::debug!(
811 library_id,
812 count = fps.len(),
813 "batch fetching books by fingerprints"
814 );
815
816 RUNTIME.block_on(async {
817 let mut result = FxHashMap::default();
818 let mut conn = self.pool.acquire().await?;
819
820 for fp in fps {
821 let fingerprint = fp.to_string();
822
823 let row = sqlx::query_as!(
824 StoredBookRow,
825 r#"
826 SELECT
827 fingerprint as "fingerprint: Fp",
828 title,
829 subtitle,
830 year,
831 language,
832 publisher,
833 series,
834 edition,
835 volume,
836 number,
837 identifier,
838 file_path,
839 absolute_path,
840 file_kind,
841 file_size,
842 added_at as "added_at: UnixTimestamp",
843 opened as "opened?: UnixTimestamp",
844 current_page as "current_page?: i64",
845 pages_count as "pages_count?: i64",
846 finished as "finished?: i64",
847 dithered as "dithered?: i64",
848 zoom_mode as "zoom_mode?: String",
849 scroll_mode as "scroll_mode?: String",
850 page_offset_x as "page_offset_x?: i64",
851 page_offset_y as "page_offset_y?: i64",
852 rotation as "rotation?: i64",
853 cropping_margins_json as "cropping_margins_json?: String",
854 margin_width as "margin_width?: i64",
855 screen_margin_width as "screen_margin_width?: i64",
856 font_family as "font_family?: String",
857 font_size as "font_size?: f64",
858 text_align as "text_align?: String",
859 line_height as "line_height?: f64",
860 contrast_exponent as "contrast_exponent?: f64",
861 contrast_gray as "contrast_gray?: f64",
862 page_names_json as "page_names_json?: String",
863 bookmarks_json as "bookmarks_json?: String",
864 annotations_json as "annotations_json?: String",
865 authors as "authors?: String",
866 categories as "categories?: String"
867 FROM library_books_full_info
868 WHERE library_id = ? AND fingerprint = ?
869 LIMIT 1
870 "#,
871 library_id,
872 fingerprint,
873 )
874 .fetch_optional(&mut *conn)
875 .await?;
876
877 let Some(row) = row else {
878 continue;
879 };
880
881 let toc_rows = Self::fetch_toc_entries_for_book(
882 &self.pool,
883 library_id,
884 &row.fingerprint.to_string(),
885 )
886 .await?;
887 let toc = (!toc_rows.is_empty())
888 .then(|| rows_to_toc_entries(&toc_rows))
889 .transpose()?;
890
891 if let Ok(info) = Self::stored_book_row_to_info(row, toc) {
892 result.insert(*fp, info);
893 }
894 }
895
896 Ok(result)
897 })
898 }
899
900 #[cfg_attr(
901 feature = "tracing",
902 tracing::instrument(skip(self), fields(library_id))
903 )]
904 pub fn count_books(&self, library_id: i64) -> Result<usize, Error> {
905 RUNTIME.block_on(async {
906 let count: i64 = sqlx::query_scalar!(
907 r#"SELECT COUNT(*) AS "count!: i64" FROM library_books WHERE library_id = ?"#,
908 library_id,
909 )
910 .fetch_one(&self.pool)
911 .await?;
912
913 Ok(count as usize)
914 })
915 }
916
917 #[cfg_attr(
918 feature = "tracing",
919 tracing::instrument(skip(self, prefix), fields(library_id))
920 )]
921 pub fn list_books_under_prefix(
922 &self,
923 library_id: i64,
924 prefix: &Path,
925 ) -> Result<Vec<Info>, Error> {
926 let prefix =
927 (!prefix.as_os_str().is_empty()).then(|| prefix.to_string_lossy().into_owned());
928
929 RUNTIME.block_on(async {
930 let rows: Vec<StoredBookRow> = sqlx::query_as!(
931 StoredBookRow,
932 r#"
933 SELECT
934 fingerprint as "fingerprint: Fp",
935 title,
936 subtitle,
937 year,
938 language,
939 publisher,
940 series,
941 edition,
942 volume,
943 number,
944 identifier,
945 file_path,
946 absolute_path,
947 file_kind,
948 file_size,
949 added_at as "added_at: UnixTimestamp",
950 opened as "opened?: UnixTimestamp",
951 current_page as "current_page?: i64",
952 pages_count as "pages_count?: i64",
953 finished as "finished?: i64",
954 dithered as "dithered?: i64",
955 zoom_mode as "zoom_mode?: String",
956 scroll_mode as "scroll_mode?: String",
957 page_offset_x as "page_offset_x?: i64",
958 page_offset_y as "page_offset_y?: i64",
959 rotation as "rotation?: i64",
960 cropping_margins_json as "cropping_margins_json?: String",
961 margin_width as "margin_width?: i64",
962 screen_margin_width as "screen_margin_width?: i64",
963 font_family as "font_family?: String",
964 font_size as "font_size?: f64",
965 text_align as "text_align?: String",
966 line_height as "line_height?: f64",
967 contrast_exponent as "contrast_exponent?: f64",
968 contrast_gray as "contrast_gray?: f64",
969 page_names_json as "page_names_json?: String",
970 bookmarks_json as "bookmarks_json?: String",
971 annotations_json as "annotations_json?: String",
972 authors as "authors?: String",
973 categories as "categories?: String"
974 FROM library_books_full_info
975 WHERE library_id = ?1
976 AND (?2 IS NULL OR file_path = ?2 OR file_path LIKE (?2 || '/%'))
977 "#,
978 library_id,
979 prefix,
980 )
981 .fetch_all(&self.pool)
982 .await?;
983
984 rows.into_iter()
985 .map(|row| Self::stored_book_row_to_info(row, None))
986 .collect()
987 })
988 }
989
990 pub fn most_recently_opened_reading_book(
991 &self,
992 library_id: i64,
993 ) -> Result<Option<Info>, Error> {
994 RUNTIME.block_on(async {
995 let row: Option<StoredBookRow> = sqlx::query_as!(
996 StoredBookRow,
997 r#"
998 SELECT
999 fingerprint as "fingerprint: Fp",
1000 title,
1001 subtitle,
1002 year,
1003 language,
1004 publisher,
1005 series,
1006 edition,
1007 volume,
1008 number,
1009 identifier,
1010 file_path,
1011 absolute_path,
1012 file_kind,
1013 file_size,
1014 added_at as "added_at: UnixTimestamp",
1015 opened as "opened?: UnixTimestamp",
1016 current_page as "current_page?: i64",
1017 pages_count as "pages_count?: i64",
1018 finished as "finished?: i64",
1019 dithered as "dithered?: i64",
1020 zoom_mode as "zoom_mode?: String",
1021 scroll_mode as "scroll_mode?: String",
1022 page_offset_x as "page_offset_x?: i64",
1023 page_offset_y as "page_offset_y?: i64",
1024 rotation as "rotation?: i64",
1025 cropping_margins_json as "cropping_margins_json?: String",
1026 margin_width as "margin_width?: i64",
1027 screen_margin_width as "screen_margin_width?: i64",
1028 font_family as "font_family?: String",
1029 font_size as "font_size?: f64",
1030 text_align as "text_align?: String",
1031 line_height as "line_height?: f64",
1032 contrast_exponent as "contrast_exponent?: f64",
1033 contrast_gray as "contrast_gray?: f64",
1034 page_names_json as "page_names_json?: String",
1035 bookmarks_json as "bookmarks_json?: String",
1036 annotations_json as "annotations_json?: String",
1037 authors as "authors?: String",
1038 categories as "categories?: String"
1039 FROM library_books_full_info
1040 WHERE library_id = ?1
1041 AND finished = 0
1042 AND opened IS NOT NULL
1043 ORDER BY opened DESC
1044 LIMIT 1
1045 "#,
1046 library_id,
1047 )
1048 .fetch_optional(&self.pool)
1049 .await?;
1050
1051 row.map(|r| Self::stored_book_row_to_info(r, None))
1052 .transpose()
1053 })
1054 }
1055
1056 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
1072 pub fn compute_sort_keys(&self, library_id: i64) -> Result<(), Error> {
1073 let books = self.get_all_books(library_id)?;
1074 if books.is_empty() {
1075 return Ok(());
1076 }
1077
1078 let methods: &[(SortMethod, &str)] = &[
1079 (SortMethod::Title, "sort_title"),
1080 (SortMethod::Author, "sort_author"),
1081 (SortMethod::FilePath, "sort_filepath"),
1082 (SortMethod::FileName, "sort_filename"),
1083 (SortMethod::Series, "sort_series"),
1084 ];
1085
1086 RUNTIME.block_on(async {
1087 let mut tx = self.pool.begin().await?;
1088
1089 for (method, col) in methods {
1090 let mut sorted = books.clone();
1091 sorted.sort_by(sorter(*method));
1092
1093 let sql = format!(
1094 "UPDATE library_books SET {col} = ? WHERE library_id = ? AND book_fingerprint = ?"
1095 );
1096 for (rank, info) in sorted.iter().enumerate() {
1097 let fp = info.fp.map(|f| f.to_string()).unwrap_or_default();
1098 let rank = (rank as i64 + 1) * SORT_RANK_STRIDE;
1101 sqlx::query(&sql)
1102 .bind(rank)
1103 .bind(library_id)
1104 .bind(&fp)
1105 .execute(&mut *tx)
1106 .await?;
1107 }
1108 }
1109
1110 tx.commit().await?;
1111 Ok(())
1112 })
1113 }
1114
1115 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, info)))]
1137 pub fn insert_sort_rank(&self, library_id: i64, fp: Fp, info: &Info) -> Result<(), Error> {
1138 let fp_str = fp.to_string();
1139 let needs_full_recompute = self.try_insert_sort_rank(library_id, &fp_str, info)?;
1140
1141 if needs_full_recompute {
1142 tracing::debug!(
1143 library_id,
1144 "sort rank gaps exhausted, falling back to full recompute"
1145 );
1146 self.compute_sort_keys(library_id)?;
1147 }
1148
1149 Ok(())
1150 }
1151
1152 fn try_insert_sort_rank(
1157 &self,
1158 library_id: i64,
1159 fp_str: &str,
1160 info: &Info,
1161 ) -> Result<bool, Error> {
1162 let title_rank = self.resolve_title_rank(library_id, fp_str, info)?;
1163 let author_rank = self.resolve_author_rank(library_id, fp_str, info)?;
1164 let filepath_rank = self.resolve_filepath_rank(library_id, fp_str, info)?;
1165 let filename_rank = self.resolve_filename_rank(library_id, fp_str, info)?;
1166 let series_rank = self.resolve_series_rank(library_id, fp_str, info)?;
1167
1168 if [
1169 title_rank,
1170 author_rank,
1171 filepath_rank,
1172 filename_rank,
1173 series_rank,
1174 ]
1175 .iter()
1176 .any(|r| r.is_none())
1177 {
1178 return Ok(true);
1179 }
1180
1181 RUNTIME.block_on(async {
1182 let mut tx = self.pool.begin().await?;
1183
1184 sqlx::query!(
1185 "UPDATE library_books SET sort_title = ? WHERE library_id = ? AND book_fingerprint = ?",
1186 title_rank, library_id, fp_str
1187 )
1188 .execute(&mut *tx)
1189 .await?;
1190
1191 sqlx::query!(
1192 "UPDATE library_books SET sort_author = ? WHERE library_id = ? AND book_fingerprint = ?",
1193 author_rank, library_id, fp_str
1194 )
1195 .execute(&mut *tx)
1196 .await?;
1197
1198 sqlx::query!(
1199 "UPDATE library_books SET sort_filepath = ? WHERE library_id = ? AND book_fingerprint = ?",
1200 filepath_rank, library_id, fp_str
1201 )
1202 .execute(&mut *tx)
1203 .await?;
1204
1205 sqlx::query!(
1206 "UPDATE library_books SET sort_filename = ? WHERE library_id = ? AND book_fingerprint = ?",
1207 filename_rank, library_id, fp_str
1208 )
1209 .execute(&mut *tx)
1210 .await?;
1211
1212 sqlx::query!(
1213 "UPDATE library_books SET sort_series = ? WHERE library_id = ? AND book_fingerprint = ?",
1214 series_rank, library_id, fp_str
1215 )
1216 .execute(&mut *tx)
1217 .await?;
1218
1219 tx.commit().await?;
1220 Ok(false)
1221 })
1222 }
1223
1224 fn resolve_title_rank(
1225 &self,
1226 library_id: i64,
1227 fp_str: &str,
1228 info: &Info,
1229 ) -> Result<Option<i64>, Error> {
1230 let key = {
1231 let t = info.alphabetic_title();
1232 if t.is_empty() {
1233 info.file_stem()
1234 } else {
1235 t.to_string()
1236 }
1237 };
1238 let rows = self.fetch_title_sort_rows(library_id, fp_str)?;
1239 let pos = rows.partition_point(|row| {
1240 let row_key = {
1241 let t = alphabetic_title(&row.title, &row.language);
1242 if t.is_empty() {
1243 Path::new(&row.file_path)
1244 .file_stem()
1245 .map(|s| s.to_string_lossy().into_owned())
1246 .unwrap_or_default()
1247 } else {
1248 t.to_string()
1249 }
1250 };
1251 matches!(natural_cmp(&row_key, &key), std::cmp::Ordering::Less)
1252 });
1253 Ok(midpoint_rank(
1254 &rows.iter().map(|r| r.sort_title).collect::<Vec<_>>(),
1255 pos,
1256 ))
1257 }
1258
1259 fn resolve_author_rank(
1260 &self,
1261 library_id: i64,
1262 fp_str: &str,
1263 info: &Info,
1264 ) -> Result<Option<i64>, Error> {
1265 let key = info.alphabetic_author().to_string();
1266 let rows = self.fetch_author_sort_rows(library_id, fp_str)?;
1267 let pos = rows.partition_point(|row| {
1268 alphabetic_author(row.authors.as_deref().unwrap_or_default()) < key.as_str()
1269 });
1270 Ok(midpoint_rank(
1271 &rows.iter().map(|r| r.sort_author).collect::<Vec<_>>(),
1272 pos,
1273 ))
1274 }
1275
1276 fn resolve_filepath_rank(
1277 &self,
1278 library_id: i64,
1279 fp_str: &str,
1280 info: &Info,
1281 ) -> Result<Option<i64>, Error> {
1282 let key = info.file.path.to_string_lossy().into_owned();
1283 let rows = self.fetch_filepath_sort_rows(library_id, fp_str)?;
1284 let pos = rows.partition_point(|row| {
1285 matches!(natural_cmp(&row.file_path, &key), std::cmp::Ordering::Less)
1286 });
1287 Ok(midpoint_rank(
1288 &rows.iter().map(|r| r.sort_filepath).collect::<Vec<_>>(),
1289 pos,
1290 ))
1291 }
1292
1293 fn resolve_filename_rank(
1294 &self,
1295 library_id: i64,
1296 fp_str: &str,
1297 info: &Info,
1298 ) -> Result<Option<i64>, Error> {
1299 let key = info
1300 .file
1301 .path
1302 .file_name()
1303 .map(|n| n.to_string_lossy().into_owned())
1304 .unwrap_or_default();
1305 let rows = self.fetch_filename_sort_rows(library_id, fp_str)?;
1306 let pos = rows.partition_point(|row| {
1307 let row_name = Path::new(&row.file_path)
1308 .file_name()
1309 .map(|n| n.to_string_lossy().into_owned())
1310 .unwrap_or_default();
1311 matches!(natural_cmp(&row_name, &key), std::cmp::Ordering::Less)
1312 });
1313 Ok(midpoint_rank(
1314 &rows.iter().map(|r| r.sort_filename).collect::<Vec<_>>(),
1315 pos,
1316 ))
1317 }
1318
1319 fn resolve_series_rank(
1320 &self,
1321 library_id: i64,
1322 fp_str: &str,
1323 info: &Info,
1324 ) -> Result<Option<i64>, Error> {
1325 let series_key = &info.series;
1326 let number_key = &info.number;
1327 let rows = self.fetch_series_sort_rows(library_id, fp_str)?;
1328 let pos = rows.partition_point(|row| {
1329 row.series.cmp(series_key).then_with(|| {
1330 row.number
1331 .parse::<usize>()
1332 .ok()
1333 .zip(number_key.parse::<usize>().ok())
1334 .map_or_else(|| row.number.cmp(number_key), |(a, b)| a.cmp(&b))
1335 }) == std::cmp::Ordering::Less
1336 });
1337 Ok(midpoint_rank(
1338 &rows.iter().map(|r| r.sort_series).collect::<Vec<_>>(),
1339 pos,
1340 ))
1341 }
1342
1343 fn fetch_title_sort_rows(
1344 &self,
1345 library_id: i64,
1346 fp_str: &str,
1347 ) -> Result<Vec<TitleSortRow>, Error> {
1348 RUNTIME.block_on(async {
1349 sqlx::query_as!(
1350 TitleSortRow,
1351 r#"
1352 SELECT title, language, file_path, sort_title as "sort_title?: i64"
1353 FROM library_books_full_info
1354 WHERE library_id = ? AND fingerprint != ?
1355 ORDER BY sort_title ASC NULLS LAST
1356 "#,
1357 library_id,
1358 fp_str,
1359 )
1360 .fetch_all(&self.pool)
1361 .await
1362 .map_err(Into::into)
1363 })
1364 }
1365
1366 fn fetch_author_sort_rows(
1367 &self,
1368 library_id: i64,
1369 fp_str: &str,
1370 ) -> Result<Vec<AuthorSortRow>, Error> {
1371 RUNTIME.block_on(async {
1372 sqlx::query_as!(
1373 AuthorSortRow,
1374 r#"
1375 SELECT authors as "authors?: String", sort_author as "sort_author?: i64"
1376 FROM library_books_full_info
1377 WHERE library_id = ? AND fingerprint != ?
1378 ORDER BY sort_author ASC NULLS LAST
1379 "#,
1380 library_id,
1381 fp_str,
1382 )
1383 .fetch_all(&self.pool)
1384 .await
1385 .map_err(Into::into)
1386 })
1387 }
1388
1389 fn fetch_filepath_sort_rows(
1390 &self,
1391 library_id: i64,
1392 fp_str: &str,
1393 ) -> Result<Vec<FilePathSortRow>, Error> {
1394 RUNTIME.block_on(async {
1395 sqlx::query_as!(
1396 FilePathSortRow,
1397 r#"
1398 SELECT file_path, sort_filepath as "sort_filepath?: i64"
1399 FROM library_books_full_info
1400 WHERE library_id = ? AND fingerprint != ?
1401 ORDER BY sort_filepath ASC NULLS LAST
1402 "#,
1403 library_id,
1404 fp_str,
1405 )
1406 .fetch_all(&self.pool)
1407 .await
1408 .map_err(Into::into)
1409 })
1410 }
1411
1412 fn fetch_filename_sort_rows(
1413 &self,
1414 library_id: i64,
1415 fp_str: &str,
1416 ) -> Result<Vec<FileNameSortRow>, Error> {
1417 RUNTIME.block_on(async {
1418 sqlx::query_as!(
1419 FileNameSortRow,
1420 r#"
1421 SELECT file_path, sort_filename as "sort_filename?: i64"
1422 FROM library_books_full_info
1423 WHERE library_id = ? AND fingerprint != ?
1424 ORDER BY sort_filename ASC NULLS LAST
1425 "#,
1426 library_id,
1427 fp_str,
1428 )
1429 .fetch_all(&self.pool)
1430 .await
1431 .map_err(Into::into)
1432 })
1433 }
1434
1435 fn fetch_series_sort_rows(
1436 &self,
1437 library_id: i64,
1438 fp_str: &str,
1439 ) -> Result<Vec<SeriesSortRow>, Error> {
1440 RUNTIME.block_on(async {
1441 sqlx::query_as!(
1442 SeriesSortRow,
1443 r#"
1444 SELECT series, number, sort_series as "sort_series?: i64"
1445 FROM library_books_full_info
1446 WHERE library_id = ? AND fingerprint != ?
1447 ORDER BY sort_series ASC NULLS LAST
1448 "#,
1449 library_id,
1450 fp_str,
1451 )
1452 .fetch_all(&self.pool)
1453 .await
1454 .map_err(Into::into)
1455 })
1456 }
1457
1458 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
1464 pub fn page_books(
1465 &self,
1466 library_id: i64,
1467 prefix: &Path,
1468 sort_method: SortMethod,
1469 reverse: bool,
1470 limit: i64,
1471 offset: i64,
1472 ) -> Result<(Vec<Info>, i64), Error> {
1473 let prefix_str =
1474 (!prefix.as_os_str().is_empty()).then(|| prefix.to_string_lossy().into_owned());
1475
1476 let dir = if reverse { "DESC" } else { "ASC" };
1477 let order_expr = match sort_method {
1478 SortMethod::Title => format!("sort_title {dir}"),
1479 SortMethod::Author => format!("sort_author {dir}"),
1480 SortMethod::FilePath => format!("sort_filepath {dir}"),
1481 SortMethod::FileName => format!("sort_filename {dir}"),
1482 SortMethod::Series => format!("sort_series {dir}"),
1483 SortMethod::Status => format!(
1486 "CASE WHEN finished = 1 THEN 0 WHEN finished = 0 THEN 2 ELSE 1 END {dir}, \
1487 COALESCE(opened, added_at) {dir}"
1488 ),
1489 SortMethod::Progress => format!(
1491 "CASE WHEN finished = 1 THEN 0 WHEN finished IS NULL THEN 1 ELSE 2 END {dir}, \
1492 CASE WHEN finished = 0 \
1493 THEN CAST(current_page AS REAL) / CAST(NULLIF(pages_count, 0) AS REAL) \
1494 ELSE NULL END {dir}"
1495 ),
1496 SortMethod::Opened => format!("opened {dir}"),
1497 SortMethod::Added => format!("added_at {dir}"),
1498 SortMethod::Year => format!("year {dir}"),
1499 SortMethod::Size => format!("file_size {dir}"),
1500 SortMethod::Kind => format!("file_kind {dir}"),
1501 SortMethod::Pages => format!("pages_count {dir}"),
1502 };
1503
1504 let data_sql = format!(
1505 r#"
1506 SELECT
1507 fingerprint,
1508 title,
1509 subtitle,
1510 year,
1511 language,
1512 publisher,
1513 series,
1514 edition,
1515 volume,
1516 number,
1517 identifier,
1518 file_path,
1519 absolute_path,
1520 file_kind,
1521 file_size,
1522 added_at,
1523 opened,
1524 current_page,
1525 pages_count,
1526 finished,
1527 dithered,
1528 zoom_mode,
1529 scroll_mode,
1530 page_offset_x,
1531 page_offset_y,
1532 rotation,
1533 cropping_margins_json,
1534 margin_width,
1535 screen_margin_width,
1536 font_family,
1537 font_size,
1538 text_align,
1539 line_height,
1540 contrast_exponent,
1541 contrast_gray,
1542 page_names_json,
1543 bookmarks_json,
1544 annotations_json,
1545 authors,
1546 categories
1547 FROM library_books_full_info
1548 WHERE library_id = ?
1549 AND (? IS NULL OR file_path = ? OR file_path LIKE (? || '/%'))
1550 ORDER BY {order_expr}
1551 LIMIT ? OFFSET ?
1552 "#
1553 );
1554
1555 RUNTIME.block_on(async {
1556 let total: i64 = sqlx::query_scalar!(
1557 r#"
1558 SELECT COUNT(*)
1559 FROM library_books_full_info
1560 WHERE library_id = ?
1561 AND (? IS NULL OR file_path = ? OR file_path LIKE (? || '/%'))
1562 "#,
1563 library_id,
1564 prefix_str,
1565 prefix_str,
1566 prefix_str,
1567 )
1568 .fetch_one(&self.pool)
1569 .await?;
1570
1571 let rows: Vec<StoredBookRow> = sqlx::query_as(&data_sql)
1572 .bind(library_id)
1573 .bind(&prefix_str)
1574 .bind(&prefix_str)
1575 .bind(&prefix_str)
1576 .bind(limit)
1577 .bind(offset)
1578 .fetch_all(&self.pool)
1579 .await?;
1580
1581 let books: Result<Vec<Info>, Error> = rows
1582 .into_iter()
1583 .map(|row| Self::stored_book_row_to_info(row, None))
1584 .collect();
1585
1586 Ok((books?, total))
1587 })
1588 }
1589
1590 #[cfg_attr(
1591 feature = "tracing",
1592 tracing::instrument(skip(self, prefix), fields(library_id))
1593 )]
1594 pub fn list_directories_under_prefix(
1595 &self,
1596 library_id: i64,
1597 prefix: &Path,
1598 ) -> Result<BTreeSet<PathBuf>, Error> {
1599 let prefix =
1600 (!prefix.as_os_str().is_empty()).then(|| prefix.to_string_lossy().into_owned());
1601
1602 RUNTIME.block_on(async {
1603 let children: Vec<String> = match prefix.as_deref() {
1604 Some(prefix) => {
1605 sqlx::query_scalar!(
1606 r#"
1607 SELECT DISTINCT
1608 substr(
1609 substr(lb.file_path, length(?2) + 2),
1610 1,
1611 instr(substr(lb.file_path, length(?2) + 2), '/') - 1
1612 ) AS "child!: String"
1613 FROM library_books lb
1614 WHERE lb.library_id = ?1
1615 AND lb.file_path LIKE (?2 || '/%/%')
1616 "#,
1617 library_id,
1618 prefix,
1619 )
1620 .fetch_all(&self.pool)
1621 .await?
1622 }
1623 None => {
1624 sqlx::query_scalar!(
1625 r#"
1626 SELECT DISTINCT
1627 substr(lb.file_path, 1, instr(lb.file_path, '/') - 1) AS "child!: String"
1628 FROM library_books lb
1629 WHERE lb.library_id = ?1
1630 AND lb.file_path LIKE '%/%'
1631 "#,
1632 library_id,
1633 )
1634 .fetch_all(&self.pool)
1635 .await?
1636 }
1637 };
1638
1639 Ok(children
1640 .into_iter()
1641 .map(|child| PathBuf::from(&child))
1642 .collect())
1643 })
1644 }
1645
1646 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, info), fields(fp = %fp, library_id)))]
1647 pub fn insert_book(&self, library_id: i64, fp: Fp, info: &Info) -> Result<(), Error> {
1648 tracing::debug!(fp = %fp, library_id, "inserting book into database");
1649 let fp_str = fp.to_string();
1650
1651 RUNTIME.block_on(async {
1652 let mut tx = self.pool.begin().await?;
1653
1654 let book_row = info_to_book_row(fp, info);
1655
1656 sqlx::query!(
1657 r#"
1658 INSERT OR IGNORE INTO books (
1659 fingerprint, title, subtitle, year, language, publisher,
1660 series, edition, volume, number, identifier,
1661 file_kind, file_size, added_at
1662 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1663 "#,
1664 book_row.fingerprint,
1665 book_row.title,
1666 book_row.subtitle,
1667 book_row.year,
1668 book_row.language,
1669 book_row.publisher,
1670 book_row.series,
1671 book_row.edition,
1672 book_row.volume,
1673 book_row.number,
1674 book_row.identifier,
1675 book_row.file_kind,
1676 book_row.file_size,
1677 book_row.added_at,
1678 )
1679 .execute(&mut *tx)
1680 .await?;
1681
1682 sqlx::query!(
1683 r#"
1684 INSERT OR IGNORE INTO library_books (library_id, book_fingerprint, added_to_library_at, file_path, absolute_path)
1685 VALUES (?, ?, ?, ?, ?)
1686 "#,
1687 library_id,
1688 fp_str,
1689 book_row.added_at,
1690 book_row.file_path,
1691 book_row.absolute_path,
1692 )
1693 .execute(&mut *tx)
1694 .await?;
1695
1696 let authors = extract_authors(&info.author);
1697 for (position, author_name) in authors.iter().enumerate() {
1698 sqlx::query!(
1699 r#"INSERT OR IGNORE INTO authors (name) VALUES (?)"#,
1700 author_name
1701 )
1702 .execute(&mut *tx)
1703 .await?;
1704
1705 let author_id: i64 = sqlx::query_scalar!(
1706 r#"SELECT id FROM authors WHERE name = ?"#,
1707 author_name
1708 )
1709 .fetch_one(&mut *tx)
1710 .await?;
1711
1712 let pos = position as i64;
1713 sqlx::query!(
1714 r#"
1715 INSERT OR IGNORE INTO book_authors (book_fingerprint, author_id, position)
1716 VALUES (?, ?, ?)
1717 "#,
1718 fp_str,
1719 author_id,
1720 pos
1721 )
1722 .execute(&mut *tx)
1723 .await?;
1724 }
1725
1726 for category_name in &info.categories {
1727 sqlx::query!(
1728 r#"INSERT OR IGNORE INTO categories (name) VALUES (?)"#,
1729 category_name
1730 )
1731 .execute(&mut *tx)
1732 .await?;
1733
1734 let category_id: i64 = sqlx::query_scalar!(
1735 r#"SELECT id FROM categories WHERE name = ?"#,
1736 category_name
1737 )
1738 .fetch_one(&mut *tx)
1739 .await?;
1740
1741 sqlx::query!(
1742 r#"
1743 INSERT OR IGNORE INTO book_categories (book_fingerprint, category_id)
1744 VALUES (?, ?)
1745 "#,
1746 fp_str,
1747 category_id
1748 )
1749 .execute(&mut *tx)
1750 .await?;
1751 }
1752
1753 if let Some(reader_info) = &info.reader_info {
1754 let rs_row = reader_info_to_reading_state_row(fp, reader_info);
1755
1756 sqlx::query!(
1757 r#"
1758 INSERT INTO reading_states (
1759 fingerprint, opened, current_page, pages_count, finished, dithered,
1760 zoom_mode, scroll_mode, page_offset_x, page_offset_y, rotation,
1761 cropping_margins_json, margin_width, screen_margin_width,
1762 font_family, font_size, text_align, line_height,
1763 contrast_exponent, contrast_gray,
1764 page_names_json, bookmarks_json, annotations_json
1765 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1766 "#,
1767 rs_row.fingerprint,
1768 rs_row.opened,
1769 rs_row.current_page,
1770 rs_row.pages_count,
1771 rs_row.finished,
1772 rs_row.dithered,
1773 rs_row.zoom_mode,
1774 rs_row.scroll_mode,
1775 rs_row.page_offset_x,
1776 rs_row.page_offset_y,
1777 rs_row.rotation,
1778 rs_row.cropping_margins_json,
1779 rs_row.margin_width,
1780 rs_row.screen_margin_width,
1781 rs_row.font_family,
1782 rs_row.font_size,
1783 rs_row.text_align,
1784 rs_row.line_height,
1785 rs_row.contrast_exponent,
1786 rs_row.contrast_gray,
1787 rs_row.page_names_json,
1788 rs_row.bookmarks_json,
1789 rs_row.annotations_json,
1790 )
1791 .execute(&mut *tx)
1792 .await?;
1793 }
1794
1795 tx.commit().await?;
1796
1797 tracing::debug!(fp = %fp, "book insert complete");
1798 Ok(())
1799 })
1800 }
1801
1802 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, info), fields(fp = %fp, library_id)))]
1804 pub fn update_book(&self, library_id: i64, fp: Fp, info: &Info) -> Result<(), Error> {
1805 tracing::debug!(fp = %fp, library_id, "updating book in database");
1806 let fp_str = fp.to_string();
1807
1808 RUNTIME.block_on(async {
1809 let mut tx = self.pool.begin().await?;
1810
1811 let book_row = info_to_book_row(fp, info);
1812
1813 sqlx::query!(
1814 r#"
1815 UPDATE books SET
1816 title = ?, subtitle = ?, year = ?, language = ?, publisher = ?,
1817 series = ?, edition = ?, volume = ?, number = ?, identifier = ?,
1818 file_kind = ?, file_size = ?, added_at = ?
1819 WHERE fingerprint = ?
1820 "#,
1821 book_row.title,
1822 book_row.subtitle,
1823 book_row.year,
1824 book_row.language,
1825 book_row.publisher,
1826 book_row.series,
1827 book_row.edition,
1828 book_row.volume,
1829 book_row.number,
1830 book_row.identifier,
1831 book_row.file_kind,
1832 book_row.file_size,
1833 book_row.added_at,
1834 fp_str,
1835 )
1836 .execute(&mut *tx)
1837 .await?;
1838
1839 sqlx::query!(
1840 r#"
1841 UPDATE library_books SET file_path = ?, absolute_path = ?
1842 WHERE library_id = ? AND book_fingerprint = ?
1843 "#,
1844 book_row.file_path,
1845 book_row.absolute_path,
1846 library_id,
1847 fp_str,
1848 )
1849 .execute(&mut *tx)
1850 .await?;
1851
1852 sqlx::query!(
1853 r#"DELETE FROM book_authors WHERE book_fingerprint = ?"#,
1854 fp_str
1855 )
1856 .execute(&mut *tx)
1857 .await?;
1858
1859 let authors = extract_authors(&info.author);
1860 for (position, author_name) in authors.iter().enumerate() {
1861 sqlx::query!(
1862 r#"INSERT OR IGNORE INTO authors (name) VALUES (?)"#,
1863 author_name
1864 )
1865 .execute(&mut *tx)
1866 .await?;
1867
1868 let author_id: i64 =
1869 sqlx::query_scalar!(r#"SELECT id FROM authors WHERE name = ?"#, author_name)
1870 .fetch_one(&mut *tx)
1871 .await?;
1872
1873 let pos = position as i64;
1874 sqlx::query!(
1875 r#"
1876 INSERT OR IGNORE INTO book_authors (book_fingerprint, author_id, position)
1877 VALUES (?, ?, ?)
1878 "#,
1879 fp_str,
1880 author_id,
1881 pos
1882 )
1883 .execute(&mut *tx)
1884 .await?;
1885 }
1886
1887 sqlx::query!(
1888 r#"DELETE FROM book_categories WHERE book_fingerprint = ?"#,
1889 fp_str
1890 )
1891 .execute(&mut *tx)
1892 .await?;
1893
1894 for category_name in &info.categories {
1895 sqlx::query!(
1896 r#"INSERT OR IGNORE INTO categories (name) VALUES (?)"#,
1897 category_name
1898 )
1899 .execute(&mut *tx)
1900 .await?;
1901
1902 let category_id: i64 = sqlx::query_scalar!(
1903 r#"SELECT id FROM categories WHERE name = ?"#,
1904 category_name
1905 )
1906 .fetch_one(&mut *tx)
1907 .await?;
1908
1909 sqlx::query!(
1910 r#"
1911 INSERT OR IGNORE INTO book_categories (book_fingerprint, category_id)
1912 VALUES (?, ?)
1913 "#,
1914 fp_str,
1915 category_id
1916 )
1917 .execute(&mut *tx)
1918 .await?;
1919 }
1920
1921 tx.commit().await?;
1922
1923 tracing::debug!(fp = %fp, "book update complete");
1924 Ok(())
1925 })
1926 }
1927
1928 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(fp = %fp)))]
1929 pub fn delete_reading_state(&self, fp: Fp) -> Result<(), Error> {
1930 tracing::debug!(fp = %fp, "deleting reading state from database");
1931
1932 RUNTIME.block_on(async {
1933 let fp_str = fp.to_string();
1934
1935 sqlx::query!(
1936 r#"DELETE FROM reading_states WHERE fingerprint = ?"#,
1937 fp_str
1938 )
1939 .execute(&self.pool)
1940 .await?;
1941
1942 Ok(())
1943 })
1944 }
1945
1946 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(fp = %fp, library_id)))]
1947 pub fn delete_book(&self, library_id: i64, fp: Fp) -> Result<(), Error> {
1948 tracing::debug!(fp = %fp, library_id, "deleting book from library");
1949
1950 RUNTIME.block_on(async {
1951 let fp_str = fp.to_string();
1952 let mut tx = self.pool.begin().await?;
1953
1954 sqlx::query!(
1955 r#"DELETE FROM library_books WHERE library_id = ? AND book_fingerprint = ?"#,
1956 library_id,
1957 fp_str
1958 )
1959 .execute(&mut *tx)
1960 .await?;
1961
1962 let remaining: i64 = sqlx::query_scalar!(
1963 r#"SELECT COUNT(*) FROM library_books WHERE book_fingerprint = ?"#,
1964 fp_str
1965 )
1966 .fetch_one(&mut *tx)
1967 .await?;
1968
1969 if remaining == 0 {
1970 tracing::debug!(fp = %fp, "book not in any library, deleting completely");
1971 sqlx::query!(r#"DELETE FROM books WHERE fingerprint = ?"#, fp_str)
1972 .execute(&mut *tx)
1973 .await?;
1974 }
1975
1976 tx.commit().await?;
1977
1978 tracing::debug!(fp = %fp, library_id, "book delete complete");
1979 Ok(())
1980 })
1981 }
1982
1983 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(fp = %fp)))]
1984 pub fn get_thumbnail(&self, fp: Fp) -> Result<Option<Vec<u8>>, Error> {
1985 tracing::debug!(fp = %fp, "fetching thumbnail from database");
1986 let fp_str = fp.to_string();
1987
1988 RUNTIME.block_on(async {
1989 sqlx::query_scalar!(
1990 "SELECT thumbnail_data FROM thumbnails WHERE fingerprint = ?",
1991 fp_str
1992 )
1993 .fetch_optional(&self.pool)
1994 .await
1995 .map_err(Error::from)
1996 })
1997 }
1998
1999 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(library_id, path = %path.display())))]
2000 pub fn get_thumbnail_by_path(
2001 &self,
2002 library_id: i64,
2003 path: &Path,
2004 ) -> Result<Option<Vec<u8>>, Error> {
2005 let path = path.to_string_lossy().into_owned();
2006 tracing::debug!(library_id, path, "fetching thumbnail by path from database");
2007
2008 RUNTIME.block_on(async {
2009 sqlx::query_scalar!(
2010 "SELECT t.thumbnail_data FROM library_books lb INNER JOIN thumbnails t ON lb.book_fingerprint = t.fingerprint WHERE lb.library_id = ? AND lb.file_path = ?",
2011 library_id,
2012 path
2013 )
2014 .fetch_optional(&self.pool)
2015 .await
2016 .map_err(Error::from)
2017 })
2018 }
2019
2020 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, data), fields(fp = %fp, size = data.len())))]
2021 pub fn save_thumbnail(&self, fp: Fp, data: &[u8]) -> Result<(), Error> {
2022 tracing::debug!(fp = %fp, size = data.len(), "saving thumbnail to database");
2023 let fp_str = fp.to_string();
2024
2025 RUNTIME.block_on(async {
2026 sqlx::query!(
2027 r#"
2028 INSERT INTO thumbnails (fingerprint, thumbnail_data)
2029 VALUES (?, ?)
2030 ON CONFLICT(fingerprint) DO UPDATE SET
2031 thumbnail_data = excluded.thumbnail_data
2032 "#,
2033 fp_str,
2034 data,
2035 )
2036 .execute(&self.pool)
2037 .await?;
2038
2039 tracing::debug!(fp = %fp, "thumbnail save complete");
2040 Ok(())
2041 })
2042 }
2043
2044 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(fp = %fp)))]
2045 pub fn delete_thumbnail(&self, fp: Fp) -> Result<(), Error> {
2046 tracing::debug!(fp = %fp, "deleting thumbnail from database");
2047 let fp_str = fp.to_string();
2048
2049 RUNTIME.block_on(async {
2050 sqlx::query!("DELETE FROM thumbnails WHERE fingerprint = ?", fp_str)
2051 .execute(&self.pool)
2052 .await?;
2053
2054 tracing::debug!(fp = %fp, "thumbnail delete complete");
2055 Ok(())
2056 })
2057 }
2058
2059 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, fps), fields(count = fps.len())))]
2060 pub fn batch_delete_thumbnails(&self, fps: &[Fp]) -> Result<(), Error> {
2061 if fps.is_empty() {
2062 return Ok(());
2063 }
2064
2065 tracing::debug!(count = fps.len(), "batch deleting thumbnails from database");
2066
2067 RUNTIME.block_on(async {
2068 let mut tx = self.pool.begin().await?;
2069
2070 for fp in fps {
2071 let fp_str = fp.to_string();
2072 sqlx::query!("DELETE FROM thumbnails WHERE fingerprint = ?", fp_str)
2073 .execute(&mut *tx)
2074 .await?;
2075 }
2076
2077 tx.commit().await?;
2078 Ok(())
2079 })
2080 }
2081
2082 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(from = %from_fp, to = %to_fp)))]
2083 pub fn move_thumbnail(&self, from_fp: Fp, to_fp: Fp) -> Result<(), Error> {
2084 tracing::debug!(from = %from_fp, to = %to_fp, "moving thumbnail in database");
2085 let from_fp_str = from_fp.to_string();
2086 let to_fp_str = to_fp.to_string();
2087
2088 RUNTIME.block_on(async {
2089 sqlx::query!(
2090 r#"
2091 UPDATE thumbnails
2092 SET fingerprint = ?
2093 WHERE fingerprint = ?
2094 "#,
2095 to_fp_str,
2096 from_fp_str
2097 )
2098 .execute(&self.pool)
2099 .await?;
2100
2101 Ok(())
2102 })
2103 }
2104
2105 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, moves), fields(count = moves.len())))]
2106 pub fn batch_move_thumbnails(&self, moves: &[(Fp, Fp)]) -> Result<(), Error> {
2107 if moves.is_empty() {
2108 return Ok(());
2109 }
2110
2111 tracing::debug!(count = moves.len(), "batch moving thumbnails in database");
2112
2113 RUNTIME.block_on(async {
2114 let mut tx = self.pool.begin().await?;
2115
2116 for (from_fp, to_fp) in moves {
2117 let from_str = from_fp.to_string();
2118 let to_str = to_fp.to_string();
2119
2120 sqlx::query!(
2121 r#"UPDATE thumbnails SET fingerprint = ? WHERE fingerprint = ?"#,
2122 to_str,
2123 from_str
2124 )
2125 .execute(&mut *tx)
2126 .await?;
2127 }
2128
2129 tx.commit().await?;
2130 Ok(())
2131 })
2132 }
2133
2134 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, reader_info), fields(fp = %fp)))]
2135 pub fn save_reading_state(&self, fp: Fp, reader_info: &ReaderInfo) -> Result<(), Error> {
2136 tracing::debug!(fp = %fp, "saving reading state to database");
2137
2138 RUNTIME.block_on(async {
2139 let rs_row = reader_info_to_reading_state_row(fp, reader_info);
2140
2141 sqlx::query!(
2142 r#"
2143 INSERT INTO reading_states (
2144 fingerprint, opened, current_page, pages_count, finished, dithered,
2145 zoom_mode, scroll_mode, page_offset_x, page_offset_y, rotation,
2146 cropping_margins_json, margin_width, screen_margin_width,
2147 font_family, font_size, text_align, line_height,
2148 contrast_exponent, contrast_gray,
2149 page_names_json, bookmarks_json, annotations_json
2150 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2151 ON CONFLICT(fingerprint) DO UPDATE SET
2152 opened = excluded.opened,
2153 current_page = excluded.current_page,
2154 pages_count = excluded.pages_count,
2155 finished = excluded.finished,
2156 dithered = excluded.dithered,
2157 zoom_mode = excluded.zoom_mode,
2158 scroll_mode = excluded.scroll_mode,
2159 page_offset_x = excluded.page_offset_x,
2160 page_offset_y = excluded.page_offset_y,
2161 rotation = excluded.rotation,
2162 cropping_margins_json = excluded.cropping_margins_json,
2163 margin_width = excluded.margin_width,
2164 screen_margin_width = excluded.screen_margin_width,
2165 font_family = excluded.font_family,
2166 font_size = excluded.font_size,
2167 text_align = excluded.text_align,
2168 line_height = excluded.line_height,
2169 contrast_exponent = excluded.contrast_exponent,
2170 contrast_gray = excluded.contrast_gray,
2171 page_names_json = excluded.page_names_json,
2172 bookmarks_json = excluded.bookmarks_json,
2173 annotations_json = excluded.annotations_json
2174 "#,
2175 rs_row.fingerprint,
2176 rs_row.opened,
2177 rs_row.current_page,
2178 rs_row.pages_count,
2179 rs_row.finished,
2180 rs_row.dithered,
2181 rs_row.zoom_mode,
2182 rs_row.scroll_mode,
2183 rs_row.page_offset_x,
2184 rs_row.page_offset_y,
2185 rs_row.rotation,
2186 rs_row.cropping_margins_json,
2187 rs_row.margin_width,
2188 rs_row.screen_margin_width,
2189 rs_row.font_family,
2190 rs_row.font_size,
2191 rs_row.text_align,
2192 rs_row.line_height,
2193 rs_row.contrast_exponent,
2194 rs_row.contrast_gray,
2195 rs_row.page_names_json,
2196 rs_row.bookmarks_json,
2197 rs_row.annotations_json,
2198 )
2199 .execute(&self.pool)
2200 .await?;
2201
2202 tracing::debug!(fp = %fp, "reading state save complete");
2203 Ok(())
2204 })
2205 }
2206
2207 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, toc), fields(fp = %fp, entry_count = toc.len())))]
2208 pub fn save_toc(&self, fp: Fp, toc: &[SimpleTocEntry]) -> Result<(), Error> {
2209 if toc.is_empty() {
2210 return Ok(());
2211 }
2212
2213 tracing::debug!(fp = %fp, entry_count = toc.len(), "saving TOC to database");
2214 let fp_str = fp.to_string();
2215
2216 RUNTIME.block_on(async {
2217 let mut tx = self.pool.begin().await?;
2218
2219 sqlx::query!("DELETE FROM toc_entries WHERE book_fingerprint = ?", fp_str)
2220 .execute(&mut *tx)
2221 .await?;
2222
2223 Self::insert_toc_entries(&mut tx, &fp_str, toc, None).await?;
2224
2225 tx.commit().await?;
2226
2227 tracing::debug!(fp = %fp, "TOC save complete");
2228 Ok(())
2229 })
2230 }
2231
2232 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, books), fields(library_id, count = books.len())))]
2233 pub fn batch_insert_books(&self, library_id: i64, books: &[(Fp, &Info)]) -> Result<(), Error> {
2234 if books.is_empty() {
2235 return Ok(());
2236 }
2237
2238 tracing::debug!(library_id, count = books.len(), "batch inserting books");
2239
2240 RUNTIME.block_on(async {
2241 let mut tx = self.pool.begin().await?;
2242
2243 for (fp, info) in books {
2244 let fp_str = fp.to_string();
2245 let book_row = info_to_book_row(*fp, info);
2246
2247 sqlx::query!(
2248 r#"
2249 INSERT OR IGNORE INTO books (
2250 fingerprint, title, subtitle, year, language, publisher,
2251 series, edition, volume, number, identifier,
2252 file_kind, file_size, added_at
2253 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2254 "#,
2255 book_row.fingerprint,
2256 book_row.title,
2257 book_row.subtitle,
2258 book_row.year,
2259 book_row.language,
2260 book_row.publisher,
2261 book_row.series,
2262 book_row.edition,
2263 book_row.volume,
2264 book_row.number,
2265 book_row.identifier,
2266 book_row.file_kind,
2267 book_row.file_size,
2268 book_row.added_at,
2269 )
2270 .execute(&mut *tx)
2271 .await?;
2272
2273 sqlx::query!(
2274 r#"
2275 INSERT OR IGNORE INTO library_books (library_id, book_fingerprint, added_to_library_at, file_path, absolute_path)
2276 VALUES (?, ?, ?, ?, ?)
2277 "#,
2278 library_id,
2279 fp_str,
2280 book_row.added_at,
2281 book_row.file_path,
2282 book_row.absolute_path,
2283 )
2284 .execute(&mut *tx)
2285 .await?;
2286
2287 let authors = extract_authors(&info.author);
2288 for (position, author_name) in authors.iter().enumerate() {
2289 sqlx::query!(
2290 r#"INSERT OR IGNORE INTO authors (name) VALUES (?)"#,
2291 author_name
2292 )
2293 .execute(&mut *tx)
2294 .await?;
2295
2296 let author_id: i64 = sqlx::query_scalar!(
2297 r#"SELECT id FROM authors WHERE name = ?"#,
2298 author_name
2299 )
2300 .fetch_one(&mut *tx)
2301 .await?;
2302
2303 let pos = position as i64;
2304 sqlx::query!(
2305 r#"
2306 INSERT OR IGNORE INTO book_authors (book_fingerprint, author_id, position)
2307 VALUES (?, ?, ?)
2308 "#,
2309 fp_str,
2310 author_id,
2311 pos
2312 )
2313 .execute(&mut *tx)
2314 .await?;
2315 }
2316
2317 for category_name in &info.categories {
2318 sqlx::query!(
2319 r#"INSERT OR IGNORE INTO categories (name) VALUES (?)"#,
2320 category_name
2321 )
2322 .execute(&mut *tx)
2323 .await?;
2324
2325 let category_id: i64 = sqlx::query_scalar!(
2326 r#"SELECT id FROM categories WHERE name = ?"#,
2327 category_name
2328 )
2329 .fetch_one(&mut *tx)
2330 .await?;
2331
2332 sqlx::query!(
2333 r#"
2334 INSERT OR IGNORE INTO book_categories (book_fingerprint, category_id)
2335 VALUES (?, ?)
2336 "#,
2337 fp_str,
2338 category_id
2339 )
2340 .execute(&mut *tx)
2341 .await?;
2342 }
2343
2344 if let Some(reader_info) = &info.reader_info {
2345 let rs_row = reader_info_to_reading_state_row(*fp, reader_info);
2346
2347 sqlx::query!(
2348 r#"
2349 INSERT INTO reading_states (
2350 fingerprint, opened, current_page, pages_count, finished, dithered,
2351 zoom_mode, scroll_mode, page_offset_x, page_offset_y, rotation,
2352 cropping_margins_json, margin_width, screen_margin_width,
2353 font_family, font_size, text_align, line_height,
2354 contrast_exponent, contrast_gray,
2355 page_names_json, bookmarks_json, annotations_json
2356 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2357 "#,
2358 rs_row.fingerprint,
2359 rs_row.opened,
2360 rs_row.current_page,
2361 rs_row.pages_count,
2362 rs_row.finished,
2363 rs_row.dithered,
2364 rs_row.zoom_mode,
2365 rs_row.scroll_mode,
2366 rs_row.page_offset_x,
2367 rs_row.page_offset_y,
2368 rs_row.rotation,
2369 rs_row.cropping_margins_json,
2370 rs_row.margin_width,
2371 rs_row.screen_margin_width,
2372 rs_row.font_family,
2373 rs_row.font_size,
2374 rs_row.text_align,
2375 rs_row.line_height,
2376 rs_row.contrast_exponent,
2377 rs_row.contrast_gray,
2378 rs_row.page_names_json,
2379 rs_row.bookmarks_json,
2380 rs_row.annotations_json,
2381 )
2382 .execute(&mut *tx)
2383 .await?;
2384 }
2385
2386 if let Some(ref toc) = info.toc {
2387 sqlx::query!("DELETE FROM toc_entries WHERE book_fingerprint = ?", fp_str)
2388 .execute(&mut *tx)
2389 .await?;
2390 Self::insert_toc_entries(&mut tx, &fp_str, toc, None).await?;
2391 }
2392 }
2393
2394 tx.commit().await?;
2395
2396 tracing::debug!(count = books.len(), "batch insert complete");
2397 Ok(())
2398 })
2399 }
2400
2401 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, books), fields(library_id, count = books.len())))]
2402 pub fn batch_update_books(&self, library_id: i64, books: &[(Fp, &Info)]) -> Result<(), Error> {
2403 if books.is_empty() {
2404 return Ok(());
2405 }
2406
2407 tracing::debug!(library_id, count = books.len(), "batch updating books");
2408
2409 RUNTIME.block_on(async {
2410 let mut tx = self.pool.begin().await?;
2411
2412 for (fp, info) in books {
2413 let fp_str = fp.to_string();
2414
2415 let book_row = info_to_book_row(*fp, info);
2416
2417 sqlx::query!(
2418 r#"
2419 UPDATE books SET
2420 title = ?, subtitle = ?, year = ?, language = ?, publisher = ?,
2421 series = ?, edition = ?, volume = ?, number = ?, identifier = ?,
2422 file_kind = ?, file_size = ?, added_at = ?
2423 WHERE fingerprint = ?
2424 "#,
2425 book_row.title,
2426 book_row.subtitle,
2427 book_row.year,
2428 book_row.language,
2429 book_row.publisher,
2430 book_row.series,
2431 book_row.edition,
2432 book_row.volume,
2433 book_row.number,
2434 book_row.identifier,
2435 book_row.file_kind,
2436 book_row.file_size,
2437 book_row.added_at,
2438 fp_str,
2439 )
2440 .execute(&mut *tx)
2441 .await?;
2442
2443 sqlx::query!(
2444 r#"
2445 UPDATE library_books SET file_path = ?, absolute_path = ?
2446 WHERE library_id = ? AND book_fingerprint = ?
2447 "#,
2448 book_row.file_path,
2449 book_row.absolute_path,
2450 library_id,
2451 fp_str,
2452 )
2453 .execute(&mut *tx)
2454 .await?;
2455
2456 sqlx::query!(
2457 r#"DELETE FROM book_authors WHERE book_fingerprint = ?"#,
2458 fp_str
2459 )
2460 .execute(&mut *tx)
2461 .await?;
2462
2463 let authors = extract_authors(&info.author);
2464 for (position, author_name) in authors.iter().enumerate() {
2465 sqlx::query!(
2466 r#"INSERT OR IGNORE INTO authors (name) VALUES (?)"#,
2467 author_name
2468 )
2469 .execute(&mut *tx)
2470 .await?;
2471
2472 let author_id: i64 = sqlx::query_scalar!(
2473 r#"SELECT id FROM authors WHERE name = ?"#,
2474 author_name
2475 )
2476 .fetch_one(&mut *tx)
2477 .await?;
2478
2479 let pos = position as i64;
2480 sqlx::query!(
2481 r#"
2482 INSERT INTO book_authors (book_fingerprint, author_id, position)
2483 VALUES (?, ?, ?)
2484 "#,
2485 fp_str,
2486 author_id,
2487 pos
2488 )
2489 .execute(&mut *tx)
2490 .await?;
2491 }
2492
2493 sqlx::query!(
2494 r#"DELETE FROM book_categories WHERE book_fingerprint = ?"#,
2495 fp_str
2496 )
2497 .execute(&mut *tx)
2498 .await?;
2499
2500 for category_name in &info.categories {
2501 sqlx::query!(
2502 r#"INSERT OR IGNORE INTO categories (name) VALUES (?)"#,
2503 category_name
2504 )
2505 .execute(&mut *tx)
2506 .await?;
2507
2508 let category_id: i64 = sqlx::query_scalar!(
2509 r#"SELECT id FROM categories WHERE name = ?"#,
2510 category_name
2511 )
2512 .fetch_one(&mut *tx)
2513 .await?;
2514
2515 sqlx::query!(
2516 r#"
2517 INSERT INTO book_categories (book_fingerprint, category_id)
2518 VALUES (?, ?)
2519 "#,
2520 fp_str,
2521 category_id
2522 )
2523 .execute(&mut *tx)
2524 .await?;
2525 }
2526
2527 if let Some(ref toc) = info.toc {
2528 sqlx::query!("DELETE FROM toc_entries WHERE book_fingerprint = ?", fp_str)
2529 .execute(&mut *tx)
2530 .await?;
2531 Self::insert_toc_entries(&mut tx, &fp_str, toc, None).await?;
2532 } else {
2533 sqlx::query!("DELETE FROM toc_entries WHERE book_fingerprint = ?", fp_str)
2534 .execute(&mut *tx)
2535 .await?;
2536 }
2537 }
2538
2539 tx.commit().await?;
2540
2541 tracing::debug!(count = books.len(), "batch update complete");
2542 Ok(())
2543 })
2544 }
2545
2546 #[cfg_attr(
2548 feature = "tracing",
2549 tracing::instrument(skip(self), fields(library_id))
2550 )]
2551 pub fn list_book_handles(&self, library_id: i64) -> Result<Vec<(Fp, PathBuf)>, Error> {
2552 RUNTIME.block_on(async {
2553 let rows = sqlx::query!(
2554 r#"
2555 SELECT lb.book_fingerprint AS "fingerprint!: Fp",
2556 lb.file_path AS "file_path!: String"
2557 FROM library_books lb
2558 WHERE lb.library_id = ?
2559 "#,
2560 library_id,
2561 )
2562 .fetch_all(&self.pool)
2563 .await?;
2564
2565 rows.into_iter()
2566 .map(|row| Ok((row.fingerprint, PathBuf::from(row.file_path))))
2567 .collect()
2568 })
2569 }
2570
2571 #[cfg_attr(
2573 feature = "tracing",
2574 tracing::instrument(skip(self), fields(library_id))
2575 )]
2576 pub fn books_without_thumbnails(&self, library_id: i64) -> Result<Vec<(Fp, PathBuf)>, Error> {
2577 RUNTIME.block_on(async {
2578 let rows = sqlx::query!(
2579 r#"
2580 SELECT lb.book_fingerprint AS "fingerprint!: Fp",
2581 lb.file_path AS "file_path!: String"
2582 FROM library_books lb
2583 LEFT JOIN thumbnails t ON lb.book_fingerprint = t.fingerprint
2584 WHERE lb.library_id = ? AND t.fingerprint IS NULL
2585 "#,
2586 library_id,
2587 )
2588 .fetch_all(&self.pool)
2589 .await?;
2590
2591 rows.into_iter()
2592 .map(|row| Ok((row.fingerprint, PathBuf::from(row.file_path))))
2593 .collect()
2594 })
2595 }
2596
2597 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(library_id, fp = %fp)))]
2600 pub fn update_book_path(
2601 &self,
2602 library_id: i64,
2603 fp: Fp,
2604 rel_path: &Path,
2605 abs_path: &Path,
2606 ) -> Result<(), Error> {
2607 let fp_str = fp.to_string();
2608 let rel_str = rel_path.to_string_lossy().into_owned();
2609 let abs_str = abs_path.to_string_lossy().into_owned();
2610
2611 RUNTIME.block_on(async {
2612 let mut tx = self.pool.begin().await?;
2613
2614 sqlx::query!(
2615 r#"UPDATE library_books SET file_path = ?, absolute_path = ? WHERE library_id = ? AND book_fingerprint = ?"#,
2616 rel_str,
2617 abs_str,
2618 library_id,
2619 fp_str,
2620 )
2621 .execute(&mut *tx)
2622 .await?;
2623
2624 tx.commit().await?;
2625 Ok(())
2626 })
2627 }
2628
2629 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, updates), fields(library_id, count = updates.len())))]
2633 pub fn batch_update_book_paths(
2634 &self,
2635 library_id: i64,
2636 updates: &[(Fp, PathBuf, PathBuf)],
2637 ) -> Result<(), Error> {
2638 if updates.is_empty() {
2639 return Ok(());
2640 }
2641
2642 tracing::debug!(
2643 library_id,
2644 count = updates.len(),
2645 "batch updating book paths in library"
2646 );
2647
2648 RUNTIME.block_on(async {
2649 let mut tx = self.pool.begin().await?;
2650
2651 for (fp, rel_path, abs_path) in updates {
2652 let fp_str = fp.to_string();
2653 let rel_str = rel_path.to_string_lossy().into_owned();
2654 let abs_str = abs_path.to_string_lossy().into_owned();
2655
2656 sqlx::query!(
2657 r#"UPDATE library_books SET file_path = ?, absolute_path = ? WHERE library_id = ? AND book_fingerprint = ?"#,
2658 rel_str,
2659 abs_str,
2660 library_id,
2661 fp_str,
2662 )
2663 .execute(&mut *tx)
2664 .await?;
2665 }
2666
2667 tx.commit().await?;
2668 Ok(())
2669 })
2670 }
2671
2672 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, fps), fields(library_id, count = fps.len())))]
2673 pub fn batch_delete_books(&self, library_id: i64, fps: &[Fp]) -> Result<(), Error> {
2674 if fps.is_empty() {
2675 return Ok(());
2676 }
2677
2678 tracing::debug!(
2679 library_id,
2680 count = fps.len(),
2681 "batch deleting books from library"
2682 );
2683
2684 RUNTIME.block_on(async {
2685 let mut tx = self.pool.begin().await?;
2686
2687 for fp in fps {
2688 let fp_str = fp.to_string();
2689
2690 sqlx::query!(
2691 r#"DELETE FROM library_books WHERE library_id = ? AND book_fingerprint = ?"#,
2692 library_id,
2693 fp_str
2694 )
2695 .execute(&mut *tx)
2696 .await?;
2697
2698 let ref_count: i64 = sqlx::query_scalar!(
2699 r#"SELECT COUNT(*) FROM library_books WHERE book_fingerprint = ?"#,
2700 fp_str
2701 )
2702 .fetch_one(&mut *tx)
2703 .await?;
2704
2705 if ref_count == 0 {
2706 sqlx::query!(
2707 r#"DELETE FROM books WHERE fingerprint = ?"#,
2708 fp_str
2709 )
2710 .execute(&mut *tx)
2711 .await?;
2712 tracing::debug!(fp = %fp, "book removed from database (no more library references)");
2713 } else {
2714 tracing::debug!(fp = %fp, ref_count, "book kept in database (still referenced by other libraries)");
2715 }
2716 }
2717
2718 tx.commit().await?;
2719
2720 tracing::debug!(count = fps.len(), "batch delete complete");
2721 Ok(())
2722 })
2723 }
2724
2725 #[cfg_attr(
2732 feature = "tracing",
2733 tracing::instrument(skip(self, allowed_kinds), fields(library_id))
2734 )]
2735 pub fn delete_books_with_disallowed_kinds(
2736 &self,
2737 library_id: i64,
2738 allowed_kinds: &FxHashSet<FileExtension>,
2739 ) -> Result<Vec<Fp>, Error> {
2740 RUNTIME.block_on(async {
2741 let mut tx = self.pool.begin().await?;
2742
2743 let rows = sqlx::query!(
2744 r#"
2745 SELECT
2746 lb.book_fingerprint AS "fingerprint!: Fp",
2747 b.file_kind AS "file_kind!: FileExtension"
2748 FROM library_books lb
2749 INNER JOIN books b ON b.fingerprint = lb.book_fingerprint
2750 WHERE lb.library_id = ?
2751 "#,
2752 library_id,
2753 )
2754 .fetch_all(&mut *tx)
2755 .await?;
2756
2757 let mut purged: Vec<Fp> = Vec::new();
2758
2759 for row in rows {
2760 let kind = row.file_kind;
2761
2762 if allowed_kinds.contains(&kind) {
2763 continue;
2764 }
2765
2766 sqlx::query!(
2767 r#"DELETE FROM library_books WHERE library_id = ? AND book_fingerprint = ?"#,
2768 library_id,
2769 row.fingerprint,
2770 )
2771 .execute(&mut *tx)
2772 .await?;
2773
2774 let ref_count: i64 = sqlx::query_scalar!(
2775 r#"SELECT COUNT(*) FROM library_books WHERE book_fingerprint = ?"#,
2776 row.fingerprint,
2777 )
2778 .fetch_one(&mut *tx)
2779 .await?;
2780
2781 if ref_count == 0 {
2782 sqlx::query!(
2783 r#"DELETE FROM books WHERE fingerprint = ?"#,
2784 row.fingerprint,
2785 )
2786 .execute(&mut *tx)
2787 .await?;
2788 }
2789
2790 tracing::info!(fp = %row.fingerprint, kind = %kind, "removed disallowed book from library");
2791 purged.push(row.fingerprint);
2792 }
2793
2794 tx.commit().await?;
2795 tracing::debug!(count = purged.len(), "disallowed kind cleanup complete");
2796 Ok(purged)
2797 })
2798 }
2799}
2800
2801#[cfg(test)]
2802mod tests {
2803 use super::*;
2804 use crate::db::Database;
2805 use crate::metadata::ReaderInfo;
2806 use chrono::Local;
2807 use std::collections::BTreeSet;
2808 use std::path::{Path, PathBuf};
2809 use std::str::FromStr;
2810
2811 fn create_test_db() -> (Database, Db) {
2812 let db = Database::new(":memory:").expect("failed to create in-memory database");
2813 db.migrate().expect("failed to run migrations");
2814 let libdb = Db::new(&db);
2815 (db, libdb)
2816 }
2817
2818 fn register_test_library(libdb: &Db, path: &str, name: &str) -> i64 {
2819 libdb
2820 .register_library(path, name)
2821 .expect("failed to register library")
2822 }
2823
2824 fn make_info(path: &str, title: &str, author: &str) -> Info {
2825 Info {
2826 title: title.to_string(),
2827 author: author.to_string(),
2828 file: FileInfo {
2829 path: PathBuf::from(path),
2830 kind: "epub".to_string(),
2831 size: 1024,
2832 ..Default::default()
2833 },
2834 ..Default::default()
2835 }
2836 }
2837
2838 #[test]
2839 fn midpoint_rank_both_none_returns_stride() {
2840 assert_eq!(midpoint_rank(&[None, None], 0), Some(SORT_RANK_STRIDE));
2841 }
2842
2843 #[test]
2844 fn midpoint_rank_empty_slice_returns_stride() {
2845 assert_eq!(midpoint_rank(&[], 0), Some(SORT_RANK_STRIDE));
2846 }
2847
2848 #[test]
2849 fn midpoint_rank_left_none_right_some_bisects() {
2850 assert_eq!(midpoint_rank(&[Some(10)], 0), Some(5));
2852 }
2853
2854 #[test]
2855 fn midpoint_rank_left_none_right_some_exactly_one_returns_none() {
2856 assert_eq!(midpoint_rank(&[Some(1)], 0), None);
2857 }
2858
2859 #[test]
2860 fn midpoint_rank_left_none_right_some_zero_returns_none() {
2861 assert_eq!(midpoint_rank(&[Some(0)], 0), None);
2862 }
2863
2864 #[test]
2865 fn midpoint_rank_left_some_right_none_adds_stride() {
2866 assert_eq!(midpoint_rank(&[Some(5)], 1), Some(5 + SORT_RANK_STRIDE));
2868 }
2869
2870 #[test]
2871 fn midpoint_rank_left_some_right_some_bisects() {
2872 assert_eq!(midpoint_rank(&[Some(2), Some(10)], 1), Some(6));
2874 }
2875
2876 #[test]
2877 fn midpoint_rank_adjacent_values_returns_none() {
2878 assert_eq!(midpoint_rank(&[Some(5), Some(6)], 1), None);
2880 }
2881
2882 #[test]
2883 fn midpoint_rank_equal_values_returns_none() {
2884 assert_eq!(midpoint_rank(&[Some(5), Some(5)], 1), None);
2886 }
2887
2888 #[test]
2889 fn midpoint_rank_none_slots_ignored_on_left_side() {
2890 assert_eq!(midpoint_rank(&[None, Some(20)], 1), Some(10));
2892 }
2893
2894 #[test]
2895 fn midpoint_rank_pos_beyond_slice_uses_last_as_left() {
2896 let ranks = vec![Some(500i64)];
2898 assert_eq!(midpoint_rank(&ranks, 1), Some(500 + SORT_RANK_STRIDE));
2899 }
2900
2901 #[test]
2902 fn test_insert_and_get_book() {
2903 let (_db, libdb) = create_test_db();
2904 let fp = Fp::from_u64(1);
2905
2906 let info = Info {
2907 title: "Test Book".to_string(),
2908 subtitle: "A Test".to_string(),
2909 author: "John Doe, Jane Smith".to_string(),
2910 year: "2024".to_string(),
2911 language: "en".to_string(),
2912 publisher: "Test Press".to_string(),
2913 series: "Test Series".to_string(),
2914 number: "1".to_string(),
2915 categories: vec!["Fiction".to_string(), "Science".to_string()]
2916 .into_iter()
2917 .collect(),
2918 file: FileInfo {
2919 path: PathBuf::from("/tmp/test.pdf"),
2920 kind: "pdf".to_string(),
2921 size: 1024,
2922 ..Default::default()
2923 },
2924 added: Local::now().naive_local(),
2925 ..Default::default()
2926 };
2927
2928 let library_id = register_test_library(&libdb, "/tmp/test_library", "Test Library");
2929 libdb
2930 .insert_book(library_id, fp, &info)
2931 .expect("failed to insert book");
2932
2933 let books = libdb
2934 .get_all_books(library_id)
2935 .expect("failed to get books");
2936 let retrieved_info = books.iter().find(|info| info.fp == Some(fp)).cloned();
2937 assert!(retrieved_info.is_some(), "book should exist in database");
2938
2939 let retrieved_info = retrieved_info.unwrap();
2940 assert_eq!(retrieved_info.title, "Test Book");
2941 assert_eq!(retrieved_info.subtitle, "A Test");
2942 assert_eq!(retrieved_info.author, "John Doe, Jane Smith");
2943 assert_eq!(retrieved_info.year, "2024");
2944 assert_eq!(retrieved_info.language, "en");
2945 assert_eq!(retrieved_info.publisher, "Test Press");
2946 assert_eq!(retrieved_info.series, "Test Series");
2947 assert_eq!(retrieved_info.number, "1");
2948 assert_eq!(retrieved_info.file.path, PathBuf::from("/tmp/test.pdf"));
2949 assert_eq!(retrieved_info.file.kind, "pdf");
2950 assert_eq!(retrieved_info.file.size, 1024);
2951 }
2952
2953 #[test]
2954 fn test_insert_book_with_reading_state() {
2955 let (_db, libdb) = create_test_db();
2956 let fp = Fp::from_u64(2);
2957
2958 let reader_info = ReaderInfo {
2959 current_page: 42,
2960 pages_count: 100,
2961 ..Default::default()
2962 };
2963 let info = Info {
2964 title: "Book with Reading State".to_string(),
2965 author: "Test Author".to_string(),
2966 file: FileInfo {
2967 path: PathBuf::from("/tmp/test2.pdf"),
2968 kind: "pdf".to_string(),
2969 size: 2048,
2970 ..Default::default()
2971 },
2972 reader_info: Some(reader_info.clone()),
2973 ..Default::default()
2974 };
2975
2976 let library_id = register_test_library(&libdb, "/tmp/test_library2", "Test Library 2");
2977 libdb
2978 .insert_book(library_id, fp, &info)
2979 .expect("failed to insert book");
2980
2981 let books = libdb
2982 .get_all_books(library_id)
2983 .expect("failed to get books");
2984 let retrieved = books
2985 .iter()
2986 .find(|info| info.fp == Some(fp))
2987 .cloned()
2988 .unwrap();
2989 assert_eq!(retrieved.title, "Book with Reading State");
2990
2991 assert!(
2992 retrieved.reader_info.is_some(),
2993 "reading state should exist"
2994 );
2995 let retrieved_reader = retrieved.reader_info.unwrap();
2996 assert_eq!(retrieved_reader.current_page, 42);
2997 assert_eq!(retrieved_reader.pages_count, 100);
2998 assert!(!retrieved_reader.finished);
2999 }
3000
3001 #[test]
3002 fn test_delete_book() {
3003 let (_db, libdb) = create_test_db();
3004 let fp = Fp::from_u64(3);
3005
3006 let info = Info {
3007 title: "Book to Delete".to_string(),
3008 author: "Delete Author".to_string(),
3009 file: FileInfo {
3010 path: PathBuf::from("/tmp/delete.pdf"),
3011 kind: "pdf".to_string(),
3012 size: 512,
3013 ..Default::default()
3014 },
3015 ..Default::default()
3016 };
3017
3018 let library_id = register_test_library(&libdb, "/tmp/test_library3", "Test Library 3");
3019 libdb
3020 .insert_book(library_id, fp, &info)
3021 .expect("failed to insert book");
3022
3023 let books = libdb
3024 .get_all_books(library_id)
3025 .expect("failed to get books");
3026 assert!(
3027 books.iter().any(|info| info.fp == Some(fp)),
3028 "book should exist before delete"
3029 );
3030
3031 libdb
3032 .delete_book(library_id, fp)
3033 .expect("failed to delete book");
3034
3035 let books = libdb
3036 .get_all_books(library_id)
3037 .expect("failed to get books");
3038 assert!(
3039 !books.iter().any(|info| info.fp == Some(fp)),
3040 "book should not exist after delete"
3041 );
3042 }
3043
3044 #[test]
3045 fn test_multiple_books() {
3046 let (_db, libdb) = create_test_db();
3047 let library_id = register_test_library(&libdb, "/tmp/test_library4", "Test Library 4");
3048
3049 for i in 1..=5 {
3050 let fp = Fp::from_u64(i as u64);
3051 let info = Info {
3052 title: format!("Book {}", i),
3053 author: format!("Author {}", i),
3054 file: FileInfo {
3055 path: PathBuf::from(format!("/tmp/book{}.pdf", i)),
3056 kind: "pdf".to_string(),
3057 size: (i * 100) as u64,
3058 ..Default::default()
3059 },
3060 ..Default::default()
3061 };
3062
3063 libdb
3064 .insert_book(library_id, fp, &info)
3065 .expect("failed to insert book");
3066 }
3067
3068 let books = libdb
3069 .get_all_books(library_id)
3070 .expect("failed to get books");
3071 for i in 1..=5 {
3072 let fp = Fp::from_u64(i as u64);
3073 let retrieved = books
3074 .iter()
3075 .find(|info| info.fp == Some(fp))
3076 .cloned()
3077 .unwrap();
3078 assert_eq!(retrieved.title, format!("Book {}", i));
3079 assert_eq!(retrieved.author, format!("Author {}", i));
3080 }
3081 }
3082
3083 #[test]
3084 fn test_update_book() {
3085 let (_db, libdb) = create_test_db();
3086 let fp = Fp::from_u64(4);
3087
3088 let mut info = Info {
3089 title: "Original Title".to_string(),
3090 author: "Original Author".to_string(),
3091 file: FileInfo {
3092 path: PathBuf::from("/tmp/update.pdf"),
3093 kind: "pdf".to_string(),
3094 size: 1024,
3095 ..Default::default()
3096 },
3097 ..Default::default()
3098 };
3099
3100 let library_id = register_test_library(&libdb, "/tmp/test_library5", "Test Library 5");
3101 libdb
3102 .insert_book(library_id, fp, &info)
3103 .expect("failed to insert book");
3104
3105 info.title = "Updated Title".to_string();
3106 info.author = "Updated Author".to_string();
3107 info.year = "2025".to_string();
3108
3109 libdb
3110 .update_book(library_id, fp, &info)
3111 .expect("failed to update book");
3112
3113 let books = libdb
3114 .get_all_books(library_id)
3115 .expect("failed to get books");
3116 let updated = books
3117 .iter()
3118 .find(|info| info.fp == Some(fp))
3119 .cloned()
3120 .unwrap();
3121 assert_eq!(updated.title, "Updated Title");
3122 assert_eq!(updated.author, "Updated Author");
3123 assert_eq!(updated.year, "2025");
3124 }
3125
3126 #[test]
3127 fn test_get_all_books() {
3128 let (_db, libdb) = create_test_db();
3129 let library_id = register_test_library(&libdb, "/tmp/test_library6", "Test Library 6");
3130
3131 for i in 1..=3 {
3132 let fp = Fp::from_u64(i as u64);
3133 let info = Info {
3134 title: format!("Book {}", i),
3135 author: format!("Author {}", i),
3136 file: FileInfo {
3137 path: PathBuf::from(format!("/tmp/book{}.pdf", i)),
3138 kind: "pdf".to_string(),
3139 size: (i * 100) as u64,
3140 ..Default::default()
3141 },
3142 ..Default::default()
3143 };
3144
3145 libdb
3146 .insert_book(library_id, fp, &info)
3147 .expect("failed to insert book");
3148 }
3149
3150 let all_books = libdb
3151 .get_all_books(library_id)
3152 .expect("failed to get all books");
3153 assert_eq!(all_books.len(), 3);
3154
3155 let titles: Vec<String> = all_books.iter().map(|info| info.title.clone()).collect();
3156 assert!(titles.contains(&"Book 1".to_string()));
3157 assert!(titles.contains(&"Book 2".to_string()));
3158 assert!(titles.contains(&"Book 3".to_string()));
3159 }
3160
3161 #[test]
3162 fn test_get_book_by_path_and_fingerprint() {
3163 let (_db, libdb) = create_test_db();
3164 let library_id =
3165 register_test_library(&libdb, "/tmp/test_library_lookup", "Lookup Library");
3166 let fp = Fp::from_str("00000000000000A1").unwrap();
3167
3168 let mut info = make_info("nested/book.pdf", "Lookup Book", "Lookup Author");
3169 info.reader_info = Some(ReaderInfo {
3170 current_page: 7,
3171 pages_count: 21,
3172 ..Default::default()
3173 });
3174
3175 libdb
3176 .insert_book(library_id, fp, &info)
3177 .expect("failed to insert book");
3178
3179 let by_path = libdb
3180 .get_book_by_path(library_id, Path::new("nested/book.pdf"))
3181 .expect("failed to get book by path")
3182 .expect("book should exist by path");
3183 assert_eq!(by_path.fp, Some(fp));
3184 assert_eq!(by_path.title, "Lookup Book");
3185 assert_eq!(by_path.file.path, PathBuf::from("nested/book.pdf"));
3186 assert_eq!(by_path.reader_info.unwrap().current_page, 7);
3187
3188 let by_fp = libdb
3189 .get_book_by_fingerprint(library_id, fp)
3190 .expect("failed to get book by fingerprint")
3191 .expect("book should exist by fingerprint");
3192 assert_eq!(by_fp.fp, Some(fp));
3193 assert_eq!(by_fp.title, "Lookup Book");
3194 assert_eq!(by_fp.file.path, PathBuf::from("nested/book.pdf"));
3195
3196 assert!(libdb
3197 .get_book_by_path(library_id, Path::new("missing.pdf"))
3198 .expect("lookup should succeed")
3199 .is_none());
3200 assert!(libdb
3201 .get_book_by_fingerprint(library_id, Fp::from_str("00000000000000FF").unwrap())
3202 .expect("lookup should succeed")
3203 .is_none());
3204 }
3205
3206 #[test]
3207 fn test_batch_get_books_by_fingerprints() {
3208 let (_db, libdb) = create_test_db();
3209 let library_id =
3210 register_test_library(&libdb, "/tmp/test_library_batch_lookup", "Batch Lookup");
3211
3212 let fp1 = Fp::from_str("00000000000000B1").unwrap();
3213 let fp2 = Fp::from_str("00000000000000B2").unwrap();
3214 let missing = Fp::from_str("00000000000000BF").unwrap();
3215
3216 libdb
3217 .insert_book(
3218 library_id,
3219 fp1,
3220 &make_info("a/book1.pdf", "Book 1", "Author 1"),
3221 )
3222 .expect("failed to insert first book");
3223 libdb
3224 .insert_book(
3225 library_id,
3226 fp2,
3227 &make_info("b/book2.pdf", "Book 2", "Author 2"),
3228 )
3229 .expect("failed to insert second book");
3230
3231 let books = libdb
3232 .batch_get_books_by_fingerprints(library_id, &[fp1, missing, fp2])
3233 .expect("failed to batch get books");
3234
3235 assert_eq!(books.len(), 2);
3236 assert_eq!(books.get(&fp1).expect("missing fp1").title, "Book 1");
3237 assert_eq!(books.get(&fp2).expect("missing fp2").title, "Book 2");
3238 assert!(!books.contains_key(&missing));
3239
3240 let empty = libdb
3241 .batch_get_books_by_fingerprints(library_id, &[])
3242 .expect("empty batch should succeed");
3243 assert!(empty.is_empty());
3244 }
3245
3246 #[test]
3247 fn test_count_books() {
3248 let (_db, libdb) = create_test_db();
3249 let library_id = register_test_library(&libdb, "/tmp/test_library_count", "Count Library");
3250
3251 assert_eq!(libdb.count_books(library_id).expect("count failed"), 0);
3252
3253 let fp1 = Fp::from_str("00000000000000C1").unwrap();
3254 let fp2 = Fp::from_str("00000000000000C2").unwrap();
3255
3256 libdb
3257 .insert_book(
3258 library_id,
3259 fp1,
3260 &make_info("count/one.pdf", "One", "Author"),
3261 )
3262 .expect("failed to insert first book");
3263 libdb
3264 .insert_book(
3265 library_id,
3266 fp2,
3267 &make_info("count/two.pdf", "Two", "Author"),
3268 )
3269 .expect("failed to insert second book");
3270
3271 assert_eq!(libdb.count_books(library_id).expect("count failed"), 2);
3272 }
3273
3274 #[test]
3275 fn test_list_books_under_prefix() {
3276 let (_db, libdb) = create_test_db();
3277 let library_id =
3278 register_test_library(&libdb, "/tmp/test_library_prefix_books", "Prefix Books");
3279
3280 let fp1 = Fp::from_str("00000000000000D1").unwrap();
3281 let fp2 = Fp::from_str("00000000000000D2").unwrap();
3282 let fp3 = Fp::from_str("00000000000000D3").unwrap();
3283
3284 libdb
3285 .insert_book(
3286 library_id,
3287 fp1,
3288 &make_info("dir1/book1.pdf", "Book 1", "Author 1"),
3289 )
3290 .expect("failed to insert book 1");
3291 libdb
3292 .insert_book(
3293 library_id,
3294 fp2,
3295 &make_info("dir1/sub/book2.pdf", "Book 2", "Author 2"),
3296 )
3297 .expect("failed to insert book 2");
3298 libdb
3299 .insert_book(
3300 library_id,
3301 fp3,
3302 &make_info("dir2/book3.pdf", "Book 3", "Author 3"),
3303 )
3304 .expect("failed to insert book 3");
3305
3306 let root_books = libdb
3307 .list_books_under_prefix(library_id, Path::new(""))
3308 .expect("root listing failed");
3309 assert_eq!(root_books.len(), 3);
3310
3311 let dir1_books = libdb
3312 .list_books_under_prefix(library_id, Path::new("dir1"))
3313 .expect("dir1 listing failed");
3314 let dir1_paths: BTreeSet<PathBuf> =
3315 dir1_books.into_iter().map(|info| info.file.path).collect();
3316 assert_eq!(
3317 dir1_paths,
3318 BTreeSet::from([
3319 PathBuf::from("dir1/book1.pdf"),
3320 PathBuf::from("dir1/sub/book2.pdf"),
3321 ])
3322 );
3323
3324 let exact_book = libdb
3325 .list_books_under_prefix(library_id, Path::new("dir2/book3.pdf"))
3326 .expect("exact listing failed");
3327 assert_eq!(exact_book.len(), 1);
3328 assert_eq!(exact_book[0].fp, Some(fp3));
3329 }
3330
3331 #[test]
3332 fn test_list_directories_under_prefix() {
3333 let (_db, libdb) = create_test_db();
3334 let library_id =
3335 register_test_library(&libdb, "/tmp/test_library_prefix_dirs", "Prefix Dirs");
3336
3337 libdb
3338 .insert_book(
3339 library_id,
3340 Fp::from_str("00000000000000E1").unwrap(),
3341 &make_info("dir1/book1.pdf", "Book 1", "Author 1"),
3342 )
3343 .expect("failed to insert book 1");
3344 libdb
3345 .insert_book(
3346 library_id,
3347 Fp::from_str("00000000000000E2").unwrap(),
3348 &make_info("dir1/sub/book2.pdf", "Book 2", "Author 2"),
3349 )
3350 .expect("failed to insert book 2");
3351 libdb
3352 .insert_book(
3353 library_id,
3354 Fp::from_str("00000000000000E3").unwrap(),
3355 &make_info("dir2/book3.pdf", "Book 3", "Author 3"),
3356 )
3357 .expect("failed to insert book 3");
3358
3359 let root_dirs = libdb
3360 .list_directories_under_prefix(library_id, Path::new(""))
3361 .expect("root dir listing failed");
3362 assert_eq!(
3363 root_dirs,
3364 BTreeSet::from([PathBuf::from("dir1"), PathBuf::from("dir2")])
3365 );
3366
3367 let dir1_dirs = libdb
3368 .list_directories_under_prefix(library_id, Path::new("dir1"))
3369 .expect("dir1 dir listing failed");
3370 assert_eq!(dir1_dirs, BTreeSet::from([PathBuf::from("sub")]));
3371
3372 let leaf_dirs = libdb
3373 .list_directories_under_prefix(library_id, Path::new("dir2"))
3374 .expect("leaf dir listing failed");
3375 assert!(leaf_dirs.is_empty());
3376 }
3377
3378 #[test]
3379 fn test_reading_state_crud() {
3380 let (_db, libdb) = create_test_db();
3381 let fp = Fp::from_u64(5);
3382
3383 let info = Info {
3384 title: "Book with State".to_string(),
3385 author: "State Author".to_string(),
3386 file: FileInfo {
3387 path: PathBuf::from("/tmp/state.pdf"),
3388 kind: "pdf".to_string(),
3389 size: 1024,
3390 ..Default::default()
3391 },
3392 ..Default::default()
3393 };
3394
3395 let library_id = register_test_library(&libdb, "/tmp/test_library7", "Test Library 7");
3396 libdb
3397 .insert_book(library_id, fp, &info)
3398 .expect("failed to insert book");
3399
3400 let mut reader_info = ReaderInfo {
3401 current_page: 50,
3402 pages_count: 200,
3403 ..Default::default()
3404 };
3405
3406 libdb
3407 .save_reading_state(fp, &reader_info)
3408 .expect("failed to save reading state");
3409
3410 let books = libdb
3411 .get_all_books(library_id)
3412 .expect("failed to get books");
3413 let retrieved = books
3414 .iter()
3415 .find(|info| info.fp == Some(fp))
3416 .cloned()
3417 .unwrap();
3418 let retrieved_reader = retrieved.reader_info.unwrap();
3419
3420 assert_eq!(retrieved_reader.current_page, 50);
3421 assert_eq!(retrieved_reader.pages_count, 200);
3422 assert!(!retrieved_reader.finished);
3423 reader_info.current_page = 100;
3424 reader_info.finished = true;
3425
3426 libdb
3427 .save_reading_state(fp, &reader_info)
3428 .expect("failed to update reading state");
3429
3430 let books = libdb
3431 .get_all_books(library_id)
3432 .expect("failed to get books");
3433 let updated = books
3434 .iter()
3435 .find(|info| info.fp == Some(fp))
3436 .cloned()
3437 .unwrap();
3438 let updated_reader = updated.reader_info.unwrap();
3439
3440 assert_eq!(updated_reader.current_page, 100);
3441 assert!(updated_reader.finished);
3442 }
3443
3444 #[test]
3445 fn test_batch_insert_books() {
3446 let (_db, libdb) = create_test_db();
3447 let library_id = register_test_library(&libdb, "/tmp/test_library8", "Test Library 8");
3448
3449 let mut books = Vec::new();
3450 for i in 1..=5 {
3451 let fp = Fp::from_u64((i + 100) as u64);
3452 let info = Info {
3453 title: format!("Batch Book {}", i),
3454 author: format!("Batch Author {}, Co-Author {}", i, i + 1),
3455 year: format!("{}", 2020 + i),
3456 file: FileInfo {
3457 path: PathBuf::from(format!("/tmp/batch{}.pdf", i)),
3458 kind: "pdf".to_string(),
3459 size: (i * 100) as u64,
3460 ..Default::default()
3461 },
3462 ..Default::default()
3463 };
3464 books.push((fp, info));
3465 }
3466
3467 let book_refs: Vec<(Fp, &Info)> = books.iter().map(|(fp, info)| (*fp, info)).collect();
3468
3469 libdb
3470 .batch_insert_books(library_id, &book_refs)
3471 .expect("failed to batch insert books");
3472
3473 let all_books = libdb
3474 .get_all_books(library_id)
3475 .expect("failed to get books");
3476 for (fp, info) in &books {
3477 let retrieved = all_books
3478 .iter()
3479 .find(|info| info.fp == Some(*fp))
3480 .cloned()
3481 .expect("book should exist");
3482 assert_eq!(retrieved.title, info.title);
3483 assert_eq!(retrieved.author, info.author);
3484 assert_eq!(retrieved.year, info.year);
3485 }
3486
3487 let all_books = libdb
3488 .get_all_books(library_id)
3489 .expect("failed to get all books");
3490 assert_eq!(all_books.len(), 5);
3491 }
3492
3493 #[test]
3494 fn test_batch_update_books() {
3495 let (_db, libdb) = create_test_db();
3496 let library_id = register_test_library(&libdb, "/tmp/test_library9", "Test Library 9");
3497
3498 let mut books = Vec::new();
3499 for i in 1..=3 {
3500 let fp = Fp::from_u64((i + 200) as u64);
3501 let mut info = Info {
3502 title: format!("Original Book {}", i),
3503 author: format!("Original Author {}", i),
3504 file: FileInfo {
3505 path: PathBuf::from(format!("/tmp/update{}.pdf", i)),
3506 kind: "pdf".to_string(),
3507 size: (i * 100) as u64,
3508 ..Default::default()
3509 },
3510 ..Default::default()
3511 };
3512 libdb
3513 .insert_book(library_id, fp, &info)
3514 .expect("failed to insert book");
3515
3516 info.title = format!("Updated Book {}", i);
3517 info.author = format!("Updated Author {}", i);
3518 books.push((fp, info));
3519 }
3520
3521 let book_refs: Vec<(Fp, &Info)> = books.iter().map(|(fp, info)| (*fp, info)).collect();
3522
3523 libdb
3524 .batch_update_books(library_id, &book_refs)
3525 .expect("failed to batch update books");
3526
3527 let all_books = libdb
3528 .get_all_books(library_id)
3529 .expect("failed to get books");
3530 for (fp, info) in &books {
3531 let retrieved = all_books
3532 .iter()
3533 .find(|info| info.fp == Some(*fp))
3534 .cloned()
3535 .expect("book should exist");
3536 assert_eq!(retrieved.title, info.title);
3537 assert_eq!(retrieved.author, info.author);
3538 }
3539 }
3540
3541 #[test]
3542 fn test_delete_reading_state() {
3543 let (_db, libdb) = create_test_db();
3544 let fp = Fp::from_str("0000000000000006").unwrap();
3545
3546 let info = Info {
3547 title: "Book".to_string(),
3548 author: "Author".to_string(),
3549 file: FileInfo {
3550 path: PathBuf::from("/tmp/book.pdf"),
3551 kind: "pdf".to_string(),
3552 size: 100,
3553 ..Default::default()
3554 },
3555 reader_info: Some(ReaderInfo {
3556 current_page: 10,
3557 pages_count: 50,
3558 ..Default::default()
3559 }),
3560 ..Default::default()
3561 };
3562
3563 let library_id = register_test_library(&libdb, "/tmp/test_library10", "Test Library 10");
3564 libdb
3565 .insert_book(library_id, fp, &info)
3566 .expect("failed to insert book");
3567
3568 let books = libdb
3569 .get_all_books(library_id)
3570 .expect("failed to get books");
3571 let retrieved = books
3572 .iter()
3573 .find(|info| info.fp == Some(fp))
3574 .cloned()
3575 .unwrap();
3576 assert!(retrieved.reader_info.is_some());
3577
3578 libdb
3579 .delete_reading_state(fp)
3580 .expect("failed to delete reading state");
3581
3582 let books = libdb
3583 .get_all_books(library_id)
3584 .expect("failed to get books");
3585 let retrieved = books
3586 .iter()
3587 .find(|info| info.fp == Some(fp))
3588 .cloned()
3589 .unwrap();
3590 assert!(retrieved.reader_info.is_none());
3591 }
3592
3593 #[test]
3594 fn test_thumbnail_crud() {
3595 let (_db, libdb) = create_test_db();
3596 let library_id =
3597 register_test_library(&libdb, "/tmp/test_library_thumbnails", "Thumbnail Library");
3598 let fp = Fp::from_str("0000000000000007").unwrap();
3599 let data = vec![1, 2, 3, 4, 5];
3600
3601 libdb
3602 .insert_book(
3603 library_id,
3604 fp,
3605 &make_info("thumbs/book.pdf", "Thumb Book", "Thumb Author"),
3606 )
3607 .expect("failed to insert book");
3608
3609 let thumbnail = libdb.get_thumbnail(fp).expect("failed to get thumbnail");
3610 assert!(thumbnail.is_none());
3611
3612 libdb
3613 .save_thumbnail(fp, &data)
3614 .expect("failed to save thumbnail");
3615
3616 let thumbnail = libdb.get_thumbnail(fp).expect("failed to get thumbnail");
3617 assert_eq!(thumbnail, Some(data.clone()));
3618
3619 libdb
3620 .delete_thumbnail(fp)
3621 .expect("failed to delete thumbnail");
3622
3623 let thumbnail = libdb.get_thumbnail(fp).expect("failed to get thumbnail");
3624 assert!(thumbnail.is_none());
3625 }
3626
3627 #[test]
3628 fn test_books_without_thumbnails() {
3629 let (_db, libdb) = create_test_db();
3630 let library_id = register_test_library(
3631 &libdb,
3632 "/tmp/test_library_thumbnails_missing",
3633 "Missing Thumbs Library",
3634 );
3635 let fp1 = Fp::from_str("0000000000000008").unwrap();
3636 let fp2 = Fp::from_str("0000000000000009").unwrap();
3637
3638 libdb
3639 .insert_book(
3640 library_id,
3641 fp1,
3642 &make_info("thumbs/book1.epub", "Thumb Book 1", "Thumb Author 1"),
3643 )
3644 .expect("failed to insert book 1");
3645
3646 libdb
3647 .insert_book(
3648 library_id,
3649 fp2,
3650 &make_info("thumbs/book2.epub", "Thumb Book 2", "Thumb Author 2"),
3651 )
3652 .expect("failed to insert book 2");
3653
3654 let missing = libdb.books_without_thumbnails(library_id).unwrap();
3655 assert_eq!(missing.len(), 2);
3656 assert!(missing.contains(&(fp1, PathBuf::from("thumbs/book1.epub"))));
3657 assert!(missing.contains(&(fp2, PathBuf::from("thumbs/book2.epub"))));
3658
3659 libdb.save_thumbnail(fp1, &[1, 2, 3]).unwrap();
3660
3661 let missing = libdb.books_without_thumbnails(library_id).unwrap();
3662 assert_eq!(missing.len(), 1);
3663 assert_eq!(missing[0], (fp2, PathBuf::from("thumbs/book2.epub")));
3664
3665 libdb.save_thumbnail(fp2, &[4, 5, 6]).unwrap();
3666
3667 let missing = libdb.books_without_thumbnails(library_id).unwrap();
3668 assert!(missing.is_empty());
3669 }
3670
3671 #[test]
3672 fn test_batch_delete_thumbnails() {
3673 let (_db, libdb) = create_test_db();
3674 let library_id = register_test_library(
3675 &libdb,
3676 "/tmp/test_library_batch_delete_thumbnails",
3677 "Batch Delete Thumbnails",
3678 );
3679 let fp1 = Fp::from_str("00000000000000F1").unwrap();
3680 let fp2 = Fp::from_str("00000000000000F2").unwrap();
3681 let fp3 = Fp::from_str("00000000000000F3").unwrap();
3682
3683 libdb
3684 .insert_book(
3685 library_id,
3686 fp1,
3687 &make_info("thumbs/one.pdf", "One", "Author One"),
3688 )
3689 .expect("failed to insert first book");
3690 libdb
3691 .insert_book(
3692 library_id,
3693 fp2,
3694 &make_info("thumbs/two.pdf", "Two", "Author Two"),
3695 )
3696 .expect("failed to insert second book");
3697
3698 libdb
3699 .save_thumbnail(fp1, &[1, 2, 3])
3700 .expect("failed to save thumbnail 1");
3701 libdb
3702 .save_thumbnail(fp2, &[4, 5, 6])
3703 .expect("failed to save thumbnail 2");
3704
3705 libdb
3706 .batch_delete_thumbnails(&[fp1, fp3])
3707 .expect("failed to batch delete thumbnails");
3708
3709 assert!(libdb
3710 .get_thumbnail(fp1)
3711 .expect("failed to get thumbnail 1")
3712 .is_none());
3713 assert_eq!(
3714 libdb.get_thumbnail(fp2).expect("failed to get thumbnail 2"),
3715 Some(vec![4, 5, 6])
3716 );
3717 }
3718
3719 #[test]
3720 fn test_move_thumbnail() {
3721 let (_db, libdb) = create_test_db();
3722 let library_id =
3723 register_test_library(&libdb, "/tmp/test_library_move_thumbnail", "Move Thumbnail");
3724 let from_fp = Fp::from_str("0000000000000008").unwrap();
3725 let to_fp = Fp::from_str("0000000000000009").unwrap();
3726 let data = vec![9, 8, 7, 6];
3727
3728 libdb
3729 .insert_book(
3730 library_id,
3731 from_fp,
3732 &make_info("thumbs/from.pdf", "From Book", "From Author"),
3733 )
3734 .expect("failed to insert source book");
3735 libdb
3736 .insert_book(
3737 library_id,
3738 to_fp,
3739 &make_info("thumbs/to.pdf", "To Book", "To Author"),
3740 )
3741 .expect("failed to insert destination book");
3742
3743 libdb
3744 .save_thumbnail(from_fp, &data)
3745 .expect("failed to save thumbnail");
3746
3747 libdb
3748 .move_thumbnail(from_fp, to_fp)
3749 .expect("failed to move thumbnail");
3750
3751 let old_thumbnail = libdb
3752 .get_thumbnail(from_fp)
3753 .expect("failed to get old thumbnail");
3754 assert!(old_thumbnail.is_none());
3755
3756 let new_thumbnail = libdb
3757 .get_thumbnail(to_fp)
3758 .expect("failed to get new thumbnail");
3759 assert_eq!(new_thumbnail, Some(data));
3760 }
3761
3762 #[test]
3763 fn test_batch_move_thumbnails() {
3764 let (_db, libdb) = create_test_db();
3765 let library_id = register_test_library(
3766 &libdb,
3767 "/tmp/test_library_batch_move_thumbnails",
3768 "Batch Move Thumbnails",
3769 );
3770 let from_fp1 = Fp::from_str("0000000000000101").unwrap();
3771 let to_fp1 = Fp::from_str("0000000000000102").unwrap();
3772 let from_fp2 = Fp::from_str("0000000000000103").unwrap();
3773 let to_fp2 = Fp::from_str("0000000000000104").unwrap();
3774
3775 libdb
3776 .insert_book(
3777 library_id,
3778 from_fp1,
3779 &make_info("thumbs/from1.pdf", "From 1", "Author 1"),
3780 )
3781 .expect("failed to insert source book 1");
3782 libdb
3783 .insert_book(
3784 library_id,
3785 to_fp1,
3786 &make_info("thumbs/to1.pdf", "To 1", "Author 1"),
3787 )
3788 .expect("failed to insert destination book 1");
3789 libdb
3790 .insert_book(
3791 library_id,
3792 from_fp2,
3793 &make_info("thumbs/from2.pdf", "From 2", "Author 2"),
3794 )
3795 .expect("failed to insert source book 2");
3796 libdb
3797 .insert_book(
3798 library_id,
3799 to_fp2,
3800 &make_info("thumbs/to2.pdf", "To 2", "Author 2"),
3801 )
3802 .expect("failed to insert destination book 2");
3803
3804 libdb
3805 .save_thumbnail(from_fp1, &[1, 1, 1])
3806 .expect("failed to save thumbnail 1");
3807 libdb
3808 .save_thumbnail(from_fp2, &[2, 2, 2])
3809 .expect("failed to save thumbnail 2");
3810
3811 libdb
3812 .batch_move_thumbnails(&[(from_fp1, to_fp1), (from_fp2, to_fp2)])
3813 .expect("failed to batch move thumbnails");
3814
3815 assert!(libdb
3816 .get_thumbnail(from_fp1)
3817 .expect("failed to get old thumbnail 1")
3818 .is_none());
3819 assert!(libdb
3820 .get_thumbnail(from_fp2)
3821 .expect("failed to get old thumbnail 2")
3822 .is_none());
3823 assert_eq!(
3824 libdb
3825 .get_thumbnail(to_fp1)
3826 .expect("failed to get new thumbnail 1"),
3827 Some(vec![1, 1, 1])
3828 );
3829 assert_eq!(
3830 libdb
3831 .get_thumbnail(to_fp2)
3832 .expect("failed to get new thumbnail 2"),
3833 Some(vec![2, 2, 2])
3834 );
3835 }
3836
3837 #[test]
3838 fn test_list_book_handles_and_update_book_path() {
3839 let (_db, libdb) = create_test_db();
3840 let library_id = register_test_library(&libdb, "/tmp/test_library_handles", "Handles");
3841
3842 let fp = Fp::from_str("0000000000000111").unwrap();
3843 libdb
3844 .insert_book(library_id, fp, &make_info("old/path.pdf", "Book", "Author"))
3845 .expect("failed to insert book");
3846
3847 let handles = libdb
3848 .list_book_handles(library_id)
3849 .expect("failed to list handles");
3850 assert_eq!(handles, vec![(fp, PathBuf::from("old/path.pdf"))]);
3851
3852 libdb
3853 .update_book_path(
3854 library_id,
3855 fp,
3856 Path::new("new/path.pdf"),
3857 Path::new("/abs/new/path.pdf"),
3858 )
3859 .expect("failed to update book path");
3860
3861 let updated = libdb
3862 .get_book_by_fingerprint(library_id, fp)
3863 .expect("failed to get updated book")
3864 .expect("book should exist");
3865 assert_eq!(updated.file.path, PathBuf::from("new/path.pdf"));
3866 assert_eq!(
3867 updated.file.absolute_path,
3868 PathBuf::from("/abs/new/path.pdf")
3869 );
3870
3871 let handles = libdb
3872 .list_book_handles(library_id)
3873 .expect("failed to list handles after update");
3874 assert_eq!(handles, vec![(fp, PathBuf::from("new/path.pdf"))]);
3875 }
3876
3877 #[test]
3878 fn test_batch_update_book_paths() {
3879 let (_db, libdb) = create_test_db();
3880 let library_id =
3881 register_test_library(&libdb, "/tmp/test_library_batch_paths", "Batch Paths");
3882
3883 let fp1 = Fp::from_str("0000000000000121").unwrap();
3884 let fp2 = Fp::from_str("0000000000000122").unwrap();
3885
3886 libdb
3887 .insert_book(library_id, fp1, &make_info("old/one.pdf", "One", "Author"))
3888 .expect("failed to insert first book");
3889 libdb
3890 .insert_book(library_id, fp2, &make_info("old/two.pdf", "Two", "Author"))
3891 .expect("failed to insert second book");
3892
3893 libdb
3894 .batch_update_book_paths(
3895 library_id,
3896 &[
3897 (
3898 fp1,
3899 PathBuf::from("new/one.pdf"),
3900 PathBuf::from("/abs/new/one.pdf"),
3901 ),
3902 (
3903 fp2,
3904 PathBuf::from("new/two.pdf"),
3905 PathBuf::from("/abs/new/two.pdf"),
3906 ),
3907 ],
3908 )
3909 .expect("failed to batch update book paths");
3910
3911 let updated1 = libdb
3912 .get_book_by_fingerprint(library_id, fp1)
3913 .expect("failed to get first updated book")
3914 .expect("first book should exist");
3915 let updated2 = libdb
3916 .get_book_by_fingerprint(library_id, fp2)
3917 .expect("failed to get second updated book")
3918 .expect("second book should exist");
3919
3920 assert_eq!(updated1.file.path, PathBuf::from("new/one.pdf"));
3921 assert_eq!(
3922 updated1.file.absolute_path,
3923 PathBuf::from("/abs/new/one.pdf")
3924 );
3925 assert_eq!(updated2.file.path, PathBuf::from("new/two.pdf"));
3926 assert_eq!(
3927 updated2.file.absolute_path,
3928 PathBuf::from("/abs/new/two.pdf")
3929 );
3930 }
3931
3932 #[test]
3933 fn test_batch_delete_books() {
3934 let (_db, libdb) = create_test_db();
3935 let library_id = register_test_library(&libdb, "/tmp/test_library11", "Test Library 11");
3936
3937 let mut fps = Vec::new();
3938 for i in 1..=4 {
3939 let fp = Fp::from_u64((i + 300) as u64);
3940 let info = Info {
3941 title: format!("Delete Book {}", i),
3942 author: format!("Delete Author {}", i),
3943 file: FileInfo {
3944 path: PathBuf::from(format!("/tmp/delete{}.pdf", i)),
3945 kind: "pdf".to_string(),
3946 size: (i * 100) as u64,
3947 ..Default::default()
3948 },
3949 ..Default::default()
3950 };
3951 libdb
3952 .insert_book(library_id, fp, &info)
3953 .expect("failed to insert book");
3954 fps.push(fp);
3955 }
3956
3957 let all_books = libdb
3958 .get_all_books(library_id)
3959 .expect("failed to get books");
3960 assert_eq!(all_books.len(), 4);
3961
3962 libdb
3963 .batch_delete_books(library_id, &fps[0..2])
3964 .expect("failed to batch delete books");
3965
3966 let remaining_books = libdb
3967 .get_all_books(library_id)
3968 .expect("failed to get books");
3969 assert_eq!(remaining_books.len(), 2);
3970 assert!(remaining_books.iter().all(|info| {
3971 let fp = info.fp.expect("book should have fingerprint");
3972 fp == fps[2] || fp == fps[3]
3973 }));
3974 }
3975
3976 #[test]
3977 fn test_batch_operations_with_empty_input() {
3978 let (_db, libdb) = create_test_db();
3979 let library_id = register_test_library(&libdb, "/tmp/test_library12", "Test Library 12");
3980
3981 let empty_books: Vec<(Fp, &Info)> = Vec::new();
3982 let empty_fps: Vec<Fp> = Vec::new();
3983
3984 libdb
3985 .batch_insert_books(library_id, &empty_books)
3986 .expect("empty batch insert should succeed");
3987 libdb
3988 .batch_update_books(library_id, &empty_books)
3989 .expect("empty batch update should succeed");
3990 libdb
3991 .batch_delete_books(library_id, &empty_fps)
3992 .expect("empty batch delete should succeed");
3993 }
3994
3995 #[test]
3996 fn test_categories_round_trip() {
3997 let (_db, libdb) = create_test_db();
3998 let fp = Fp::from_u64(0x99);
3999
4000 let info = Info {
4001 title: "Categorized Book".to_string(),
4002 author: "Cat Author".to_string(),
4003 file: FileInfo {
4004 path: PathBuf::from("/tmp/cat.pdf"),
4005 kind: "pdf".to_string(),
4006 size: 512,
4007 ..Default::default()
4008 },
4009 categories: ["Fiction", "Science", "History"]
4010 .iter()
4011 .map(|s| s.to_string())
4012 .collect(),
4013 ..Default::default()
4014 };
4015
4016 let library_id = libdb
4017 .register_library("/tmp/test_library_cat", "Cat Library")
4018 .expect("failed to register library");
4019 libdb
4020 .insert_book(library_id, fp, &info)
4021 .expect("failed to insert book");
4022
4023 let books = libdb
4024 .get_all_books(library_id)
4025 .expect("failed to get books");
4026 let retrieved = books
4027 .iter()
4028 .find(|info| info.fp == Some(fp))
4029 .cloned()
4030 .expect("book should exist");
4031
4032 assert_eq!(retrieved.categories, info.categories);
4033 }
4034
4035 #[test]
4036 fn test_categories_updated_on_update_book() {
4037 let (_db, libdb) = create_test_db();
4038 let fp = Fp::from_u64(0x9A);
4039
4040 let mut info = Info {
4041 title: "Updateable Book".to_string(),
4042 author: "Update Author".to_string(),
4043 file: FileInfo {
4044 path: PathBuf::from("/tmp/upd_cat.pdf"),
4045 kind: "pdf".to_string(),
4046 size: 512,
4047 ..Default::default()
4048 },
4049 categories: ["OldCat"].iter().map(|s| s.to_string()).collect(),
4050 ..Default::default()
4051 };
4052
4053 let library_id =
4054 register_test_library(&libdb, "/tmp/test_library_upd_cat", "Upd Cat Library");
4055 libdb
4056 .insert_book(library_id, fp, &info)
4057 .expect("failed to insert book");
4058
4059 info.categories = ["NewCat1", "NewCat2"]
4060 .iter()
4061 .map(|s| s.to_string())
4062 .collect();
4063 libdb
4064 .update_book(library_id, fp, &info)
4065 .expect("failed to update book");
4066
4067 let books = libdb
4068 .get_all_books(library_id)
4069 .expect("failed to get books");
4070 let retrieved = books
4071 .iter()
4072 .find(|info| info.fp == Some(fp))
4073 .cloned()
4074 .expect("book should exist");
4075
4076 assert_eq!(retrieved.categories, info.categories);
4077 }
4078
4079 #[test]
4080 fn most_recently_opened_reading_book_none_when_empty() {
4081 let (_db, libdb) = create_test_db();
4082 let library_id = register_test_library(&libdb, "/tmp/mro_empty", "MRO Empty");
4083 assert!(libdb
4084 .most_recently_opened_reading_book(library_id)
4085 .expect("query failed")
4086 .is_none());
4087 }
4088
4089 #[test]
4090 fn most_recently_opened_reading_book_none_when_only_finished() {
4091 let (_db, libdb) = create_test_db();
4092 let library_id = register_test_library(&libdb, "/tmp/mro_finished", "MRO Finished");
4093 let fp = Fp::from_str("AA00000000000001").unwrap();
4094 let mut info = make_info("mro/finished.pdf", "Finished", "Author");
4095 info.reader_info = Some(ReaderInfo {
4096 current_page: 100,
4097 pages_count: 100,
4098 finished: true,
4099 ..Default::default()
4100 });
4101 libdb.insert_book(library_id, fp, &info).unwrap();
4102
4103 assert!(libdb
4104 .most_recently_opened_reading_book(library_id)
4105 .expect("query failed")
4106 .is_none());
4107 }
4108
4109 #[test]
4110 fn most_recently_opened_reading_book_returns_unfinished() {
4111 let (_db, libdb) = create_test_db();
4112 let library_id = register_test_library(&libdb, "/tmp/mro_unfinished", "MRO Unfinished");
4113
4114 let fp1 = Fp::from_str("AA00000000000002").unwrap();
4115 let fp2 = Fp::from_str("AA00000000000003").unwrap();
4116
4117 let mut info1 = make_info("mro/a.pdf", "Older Book", "Author");
4118 info1.reader_info = Some(ReaderInfo {
4119 current_page: 10,
4120 pages_count: 200,
4121 ..Default::default()
4122 });
4123
4124 let mut info2 = make_info("mro/b.pdf", "Newer Book", "Author");
4125 info2.reader_info = Some(ReaderInfo {
4128 current_page: 50,
4129 pages_count: 200,
4130 ..Default::default()
4131 });
4132
4133 libdb.insert_book(library_id, fp1, &info1).unwrap();
4134 libdb.insert_book(library_id, fp2, &info2).unwrap();
4135
4136 let result = libdb
4138 .most_recently_opened_reading_book(library_id)
4139 .expect("query failed");
4140 assert!(result.is_some());
4141 assert!(!result.unwrap().reader_info.unwrap().finished);
4142 }
4143
4144 #[test]
4145 fn most_recently_opened_reading_book_skips_never_opened() {
4146 let (_db, libdb) = create_test_db();
4147 let library_id = register_test_library(&libdb, "/tmp/mro_new", "MRO New");
4148
4149 let fp = Fp::from_str("AA00000000000004").unwrap();
4151 libdb
4152 .insert_book(
4153 library_id,
4154 fp,
4155 &make_info("mro/new.pdf", "New Book", "Author"),
4156 )
4157 .unwrap();
4158
4159 assert!(libdb
4160 .most_recently_opened_reading_book(library_id)
4161 .expect("query failed")
4162 .is_none());
4163 }
4164
4165 #[test]
4166 fn compute_sort_keys_empty_library_is_noop() {
4167 let (_db, libdb) = create_test_db();
4168 let library_id = register_test_library(&libdb, "/tmp/sort_empty", "Sort Empty");
4169 libdb.compute_sort_keys(library_id).expect("compute failed");
4170 }
4171
4172 #[test]
4173 fn compute_sort_keys_assigns_ranks_to_all_books() {
4174 let (_db, libdb) = create_test_db();
4175 let library_id = register_test_library(&libdb, "/tmp/sort_assign", "Sort Assign");
4176
4177 for i in 1u64..=3 {
4178 let fp = Fp::from_str(&format!("BB{:014X}", i)).unwrap();
4179 libdb
4180 .insert_book(
4181 library_id,
4182 fp,
4183 &make_info(&format!("s/{i}.pdf"), &format!("Book {i}"), "Author"),
4184 )
4185 .unwrap();
4186 }
4187
4188 libdb.compute_sort_keys(library_id).expect("compute failed");
4189
4190 let (books, total) = libdb
4192 .page_books(library_id, Path::new(""), SortMethod::Title, false, 10, 0)
4193 .expect("page_books failed");
4194 assert_eq!(total, 3);
4195 assert_eq!(books.len(), 3);
4196 }
4197
4198 #[test]
4199 fn insert_sort_rank_places_new_book_between_neighbours() {
4200 let (_db, libdb) = create_test_db();
4201 let library_id = register_test_library(&libdb, "/tmp/sort_insert", "Sort Insert");
4202
4203 let fp_a = Fp::from_str("CC00000000000001").unwrap();
4205 let fp_z = Fp::from_str("CC00000000000002").unwrap();
4206 let info_a = make_info("s/aardvark.pdf", "Aardvark", "Author");
4207 let info_z = make_info("s/zebra.pdf", "Zebra", "Author");
4208
4209 libdb.insert_book(library_id, fp_a, &info_a).unwrap();
4210 libdb.insert_book(library_id, fp_z, &info_z).unwrap();
4211 libdb.compute_sort_keys(library_id).unwrap();
4212
4213 let fp_m = Fp::from_str("CC00000000000003").unwrap();
4215 let info_m = make_info("s/mango.pdf", "Mango", "Author");
4216 libdb.insert_book(library_id, fp_m, &info_m).unwrap();
4217 libdb.insert_sort_rank(library_id, fp_m, &info_m).unwrap();
4218
4219 let (books, _) = libdb
4220 .page_books(library_id, Path::new(""), SortMethod::Title, false, 10, 0)
4221 .expect("page_books failed");
4222
4223 let titles: Vec<&str> = books.iter().map(|b| b.title.as_str()).collect();
4224 assert_eq!(titles, vec!["Aardvark", "Mango", "Zebra"]);
4225 }
4226
4227 #[test]
4228 fn insert_sort_rank_falls_back_to_full_recompute_when_gaps_exhausted() {
4229 let (_db, libdb) = create_test_db();
4230 let library_id = register_test_library(&libdb, "/tmp/sort_exhaust", "Sort Exhaust");
4231
4232 let fp_a = Fp::from_str("DD00000000000001").unwrap();
4234 let fp_b = Fp::from_str("DD00000000000002").unwrap();
4235 libdb
4236 .insert_book(library_id, fp_a, &make_info("s/a.pdf", "Alpha", "Author"))
4237 .unwrap();
4238 libdb
4239 .insert_book(library_id, fp_b, &make_info("s/b.pdf", "Beta", "Author"))
4240 .unwrap();
4241 libdb.compute_sort_keys(library_id).unwrap();
4242
4243 for i in 1u64..=12 {
4246 let fp = Fp::from_str(&format!("DD{:014X}", i + 10)).unwrap();
4247 let title = format!("Am{i:012}");
4248 let info = make_info(&format!("s/am{i}.pdf"), &title, "Author");
4249 libdb.insert_book(library_id, fp, &info).unwrap();
4250 libdb.insert_sort_rank(library_id, fp, &info).unwrap();
4252 }
4253
4254 let (books, _) = libdb
4255 .page_books(library_id, Path::new(""), SortMethod::Title, false, 20, 0)
4256 .expect("page_books failed");
4257 assert_eq!(books[0].title, "Alpha");
4259 }
4260
4261 fn insert_books_for_paging(libdb: &Db, library_id: i64) {
4262 let books = [
4263 (
4264 "p/a.pdf", "Alpha", "Zelda", "2020", "epub", 500u64, 100usize,
4265 ),
4266 ("p/b.pdf", "Beta", "Alpha", "2019", "pdf", 300, 50),
4267 ("p/c.pdf", "Gamma", "Mia", "2021", "epub", 700, 200),
4268 ];
4269 for (i, (path, title, author, year, kind, size, pages)) in books.iter().enumerate() {
4270 let fp = Fp::from_str(&format!("EE{:014X}", i + 1)).unwrap();
4271 let mut info = make_info(path, title, author);
4272 info.year = year.to_string();
4273 info.file.kind = kind.to_string();
4274 info.file.size = *size;
4275 info.reader_info = Some(ReaderInfo {
4276 current_page: pages / 2,
4277 pages_count: *pages,
4278 ..Default::default()
4279 });
4280 libdb.insert_book(library_id, fp, &info).unwrap();
4281 }
4282 libdb.compute_sort_keys(library_id).unwrap();
4283 }
4284
4285 #[test]
4286 fn page_books_sort_by_author() {
4287 let (_db, libdb) = create_test_db();
4288 let library_id = register_test_library(&libdb, "/tmp/pb_author", "PB Author");
4289 insert_books_for_paging(&libdb, library_id);
4290
4291 let (books, total) = libdb
4292 .page_books(library_id, Path::new(""), SortMethod::Author, false, 10, 0)
4293 .unwrap();
4294 assert_eq!(total, 3);
4295 assert_eq!(books[0].author, "Alpha");
4296 }
4297
4298 #[test]
4299 fn page_books_sort_by_year() {
4300 let (_db, libdb) = create_test_db();
4301 let library_id = register_test_library(&libdb, "/tmp/pb_year", "PB Year");
4302 insert_books_for_paging(&libdb, library_id);
4303
4304 let (books, _) = libdb
4305 .page_books(library_id, Path::new(""), SortMethod::Year, false, 10, 0)
4306 .unwrap();
4307 assert_eq!(books[0].year, "2019");
4308 }
4309
4310 #[test]
4311 fn page_books_sort_by_size() {
4312 let (_db, libdb) = create_test_db();
4313 let library_id = register_test_library(&libdb, "/tmp/pb_size", "PB Size");
4314 insert_books_for_paging(&libdb, library_id);
4315
4316 let (books, _) = libdb
4317 .page_books(library_id, Path::new(""), SortMethod::Size, false, 10, 0)
4318 .unwrap();
4319 assert_eq!(books[0].file.size, 300);
4320 }
4321
4322 #[test]
4323 fn page_books_sort_by_kind() {
4324 let (_db, libdb) = create_test_db();
4325 let library_id = register_test_library(&libdb, "/tmp/pb_kind", "PB Kind");
4326 insert_books_for_paging(&libdb, library_id);
4327
4328 let (books, _) = libdb
4329 .page_books(library_id, Path::new(""), SortMethod::Kind, false, 10, 0)
4330 .unwrap();
4331 assert_eq!(books[0].file.kind, "epub");
4333 }
4334
4335 #[test]
4336 fn page_books_sort_by_pages() {
4337 let (_db, libdb) = create_test_db();
4338 let library_id = register_test_library(&libdb, "/tmp/pb_pages", "PB Pages");
4339 insert_books_for_paging(&libdb, library_id);
4340
4341 let (books, _) = libdb
4342 .page_books(library_id, Path::new(""), SortMethod::Pages, false, 10, 0)
4343 .unwrap();
4344 assert_eq!(books[0].reader_info.as_ref().unwrap().pages_count, 50);
4345 }
4346
4347 #[test]
4348 fn page_books_sort_by_opened() {
4349 let (_db, libdb) = create_test_db();
4350 let library_id = register_test_library(&libdb, "/tmp/pb_opened", "PB Opened");
4351 insert_books_for_paging(&libdb, library_id);
4352
4353 let (books, total) = libdb
4355 .page_books(library_id, Path::new(""), SortMethod::Opened, false, 10, 0)
4356 .unwrap();
4357 assert_eq!(total, 3);
4358 assert_eq!(books.len(), 3);
4359 }
4360
4361 #[test]
4362 fn page_books_sort_by_added() {
4363 let (_db, libdb) = create_test_db();
4364 let library_id = register_test_library(&libdb, "/tmp/pb_added", "PB Added");
4365 insert_books_for_paging(&libdb, library_id);
4366
4367 let (books, _) = libdb
4368 .page_books(library_id, Path::new(""), SortMethod::Added, false, 10, 0)
4369 .unwrap();
4370 assert_eq!(books.len(), 3);
4371 }
4372
4373 #[test]
4374 fn page_books_sort_by_status() {
4375 let (_db, libdb) = create_test_db();
4376 let library_id = register_test_library(&libdb, "/tmp/pb_status", "PB Status");
4377
4378 let fp_new = Fp::from_str("FF00000000000001").unwrap();
4379 let fp_reading = Fp::from_str("FF00000000000002").unwrap();
4380 let fp_finished = Fp::from_str("FF00000000000003").unwrap();
4381
4382 libdb
4383 .insert_book(library_id, fp_new, &make_info("s/new.pdf", "New", "A"))
4384 .unwrap();
4385
4386 let mut reading = make_info("s/reading.pdf", "Reading", "A");
4387 reading.reader_info = Some(ReaderInfo {
4388 current_page: 10,
4389 pages_count: 100,
4390 finished: false,
4391 ..Default::default()
4392 });
4393 libdb.insert_book(library_id, fp_reading, &reading).unwrap();
4394
4395 let mut finished = make_info("s/finished.pdf", "Finished", "A");
4396 finished.reader_info = Some(ReaderInfo {
4397 current_page: 100,
4398 pages_count: 100,
4399 finished: true,
4400 ..Default::default()
4401 });
4402 libdb
4403 .insert_book(library_id, fp_finished, &finished)
4404 .unwrap();
4405
4406 let (books, _) = libdb
4407 .page_books(library_id, Path::new(""), SortMethod::Status, false, 10, 0)
4408 .unwrap();
4409 assert_eq!(books.len(), 3);
4410 assert_eq!(books[0].title, "Finished");
4412 }
4413
4414 #[test]
4415 fn page_books_sort_by_progress() {
4416 let (_db, libdb) = create_test_db();
4417 let library_id = register_test_library(&libdb, "/tmp/pb_progress", "PB Progress");
4418
4419 let fp_finished = Fp::from_str("FE00000000000001").unwrap();
4420 let fp_reading = Fp::from_str("FE00000000000002").unwrap();
4421
4422 let mut finished = make_info("s/fin.pdf", "Finished", "A");
4423 finished.reader_info = Some(ReaderInfo {
4424 current_page: 100,
4425 pages_count: 100,
4426 finished: true,
4427 ..Default::default()
4428 });
4429 libdb
4430 .insert_book(library_id, fp_finished, &finished)
4431 .unwrap();
4432
4433 let mut reading = make_info("s/read.pdf", "Reading", "A");
4434 reading.reader_info = Some(ReaderInfo {
4435 current_page: 50,
4436 pages_count: 100,
4437 finished: false,
4438 ..Default::default()
4439 });
4440 libdb.insert_book(library_id, fp_reading, &reading).unwrap();
4441
4442 let (books, _) = libdb
4443 .page_books(
4444 library_id,
4445 Path::new(""),
4446 SortMethod::Progress,
4447 false,
4448 10,
4449 0,
4450 )
4451 .unwrap();
4452 assert_eq!(books.len(), 2);
4453 assert_eq!(books[0].title, "Finished");
4454 }
4455
4456 #[test]
4457 fn page_books_reverse_order() {
4458 let (_db, libdb) = create_test_db();
4459 let library_id = register_test_library(&libdb, "/tmp/pb_reverse", "PB Reverse");
4460 insert_books_for_paging(&libdb, library_id);
4461
4462 let (asc, _) = libdb
4463 .page_books(library_id, Path::new(""), SortMethod::Title, false, 10, 0)
4464 .unwrap();
4465 let (desc, _) = libdb
4466 .page_books(library_id, Path::new(""), SortMethod::Title, true, 10, 0)
4467 .unwrap();
4468
4469 assert_eq!(asc[0].title, desc[desc.len() - 1].title);
4470 assert_eq!(asc[asc.len() - 1].title, desc[0].title);
4471 }
4472
4473 #[test]
4474 fn page_books_pagination_offset() {
4475 let (_db, libdb) = create_test_db();
4476 let library_id = register_test_library(&libdb, "/tmp/pb_pagination", "PB Pagination");
4477 insert_books_for_paging(&libdb, library_id);
4478 libdb.compute_sort_keys(library_id).unwrap();
4479
4480 let (page1, total) = libdb
4481 .page_books(library_id, Path::new(""), SortMethod::Title, false, 2, 0)
4482 .unwrap();
4483 let (page2, _) = libdb
4484 .page_books(library_id, Path::new(""), SortMethod::Title, false, 2, 2)
4485 .unwrap();
4486
4487 assert_eq!(total, 3);
4488 assert_eq!(page1.len(), 2);
4489 assert_eq!(page2.len(), 1);
4490 assert_ne!(page1[0].title, page2[0].title);
4491 }
4492
4493 #[test]
4494 fn parse_zoom_mode_none_returns_none() {
4495 assert!(Db::parse_zoom_mode(None).is_none());
4496 }
4497
4498 #[test]
4499 fn parse_zoom_mode_invalid_json_returns_none() {
4500 assert!(Db::parse_zoom_mode(Some(&"not-valid-json".to_string())).is_none());
4501 }
4502
4503 #[test]
4504 fn parse_scroll_mode_none_returns_none() {
4505 assert!(Db::parse_scroll_mode(None).is_none());
4506 }
4507
4508 #[test]
4509 fn parse_scroll_mode_invalid_json_returns_none() {
4510 assert!(Db::parse_scroll_mode(Some(&"{{bad}}".to_string())).is_none());
4511 }
4512
4513 #[test]
4514 fn parse_text_align_none_returns_none() {
4515 assert!(Db::parse_text_align(None).is_none());
4516 }
4517
4518 #[test]
4519 fn parse_text_align_invalid_json_returns_none() {
4520 assert!(Db::parse_text_align(Some(&"???".to_string())).is_none());
4521 }
4522
4523 #[test]
4524 fn parse_cropping_margins_none_returns_none() {
4525 assert!(Db::parse_cropping_margins(None).is_none());
4526 }
4527
4528 #[test]
4529 fn parse_cropping_margins_invalid_json_returns_none() {
4530 assert!(Db::parse_cropping_margins(Some(&"bad".to_string())).is_none());
4531 }
4532
4533 #[test]
4534 fn parse_page_names_none_returns_empty_map() {
4535 assert!(Db::parse_page_names(None).is_empty());
4536 }
4537
4538 #[test]
4539 fn parse_page_names_invalid_json_returns_empty_map() {
4540 assert!(Db::parse_page_names(Some(&"!".to_string())).is_empty());
4541 }
4542
4543 #[test]
4544 fn parse_bookmarks_none_returns_empty_set() {
4545 assert!(Db::parse_bookmarks(None).is_empty());
4546 }
4547
4548 #[test]
4549 fn parse_bookmarks_invalid_json_returns_empty_set() {
4550 assert!(Db::parse_bookmarks(Some(&"!".to_string())).is_empty());
4551 }
4552
4553 #[test]
4554 fn parse_annotations_none_returns_empty_vec() {
4555 assert!(Db::parse_annotations(None).is_empty());
4556 }
4557
4558 #[test]
4559 fn parse_annotations_invalid_json_returns_empty_vec() {
4560 assert!(Db::parse_annotations(Some(&"!".to_string())).is_empty());
4561 }
4562
4563 #[test]
4564 fn parse_page_offset_both_some_returns_point() {
4565 let p = Db::parse_page_offset(Some(3), Some(7));
4566 assert!(p.is_some());
4567 let p = p.unwrap();
4568 assert_eq!(p.x, 3);
4569 assert_eq!(p.y, 7);
4570 }
4571
4572 #[test]
4573 fn parse_page_offset_one_none_returns_none() {
4574 assert!(Db::parse_page_offset(Some(1), None).is_none());
4575 assert!(Db::parse_page_offset(None, Some(1)).is_none());
4576 assert!(Db::parse_page_offset(None, None).is_none());
4577 }
4578
4579 #[test]
4580 fn extract_authors_none_returns_empty_string() {
4581 assert_eq!(Db::extract_authors(None), "");
4582 }
4583
4584 #[test]
4585 fn extract_authors_comma_separated_joins_with_space() {
4586 assert_eq!(
4587 Db::extract_authors(Some("Alice,Bob,Carol".to_string())),
4588 "Alice, Bob, Carol"
4589 );
4590 }
4591
4592 #[test]
4593 fn extract_categories_none_returns_empty_set() {
4594 assert!(Db::extract_categories(None).is_empty());
4595 }
4596
4597 #[test]
4598 fn extract_categories_filters_empty_strings() {
4599 let cats = Db::extract_categories(Some(",Fiction,,Science,".to_string()));
4600 assert_eq!(cats.len(), 2);
4601 assert!(cats.contains("Fiction"));
4602 assert!(cats.contains("Science"));
4603 }
4604
4605 #[test]
4606 fn test_batch_insert_with_reading_state() {
4607 let (_db, libdb) = create_test_db();
4608 let library_id = register_test_library(&libdb, "/tmp/test_library13", "Test Library 13");
4609
4610 let mut books = Vec::new();
4611 for i in 1..=3 {
4612 let fp = Fp::from_u64((i + 400) as u64);
4613 let reader_info = ReaderInfo {
4614 current_page: i * 10,
4615 pages_count: i * 100,
4616 finished: i % 2 == 0,
4617 ..Default::default()
4618 };
4619 let info = Info {
4620 title: format!("Book with State {}", i),
4621 author: format!("State Author {}", i),
4622 file: FileInfo {
4623 path: PathBuf::from(format!("/tmp/state{}.pdf", i)),
4624 kind: "pdf".to_string(),
4625 size: (i * 100) as u64,
4626 ..Default::default()
4627 },
4628 reader_info: Some(reader_info),
4629 ..Default::default()
4630 };
4631
4632 books.push((fp, info));
4633 }
4634
4635 let book_refs: Vec<(Fp, &Info)> = books.iter().map(|(fp, info)| (*fp, info)).collect();
4636
4637 libdb
4638 .batch_insert_books(library_id, &book_refs)
4639 .expect("failed to batch insert books with reading state");
4640
4641 let all_books = libdb
4642 .get_all_books(library_id)
4643 .expect("failed to get books");
4644 for (fp, info) in &books {
4645 let retrieved = all_books
4646 .iter()
4647 .find(|info| info.fp == Some(*fp))
4648 .cloned()
4649 .expect("book should exist");
4650 assert_eq!(retrieved.title, info.title);
4651
4652 assert!(
4653 retrieved.reader_info.is_some(),
4654 "reading state should exist"
4655 );
4656 let retrieved_state = retrieved.reader_info.unwrap();
4657 let original_state = info.reader_info.as_ref().unwrap();
4658 assert_eq!(retrieved_state.current_page, original_state.current_page);
4659 assert_eq!(retrieved_state.pages_count, original_state.pages_count);
4660 assert_eq!(retrieved_state.finished, original_state.finished);
4661 }
4662 }
4663
4664 #[test]
4665 fn delete_books_with_disallowed_kinds_removes_wrong_kind() {
4666 use crate::settings::FileExtension;
4667
4668 let (_db, libdb) = create_test_db();
4669 let library_id =
4670 register_test_library(&libdb, "/tmp/test_disallowed_kinds", "Disallowed Kinds");
4671
4672 let epub_fp = Fp::from_u64(9001);
4673 let pdf_fp = Fp::from_u64(9002);
4674
4675 let epub_info = Info {
4676 title: "Epub Book".to_string(),
4677 file: FileInfo {
4678 path: PathBuf::from("book.epub"),
4679 kind: "epub".to_string(),
4680 size: 100,
4681 ..Default::default()
4682 },
4683 ..Default::default()
4684 };
4685 let pdf_info = Info {
4686 title: "Pdf Book".to_string(),
4687 file: FileInfo {
4688 path: PathBuf::from("book.pdf"),
4689 kind: "pdf".to_string(),
4690 size: 200,
4691 ..Default::default()
4692 },
4693 ..Default::default()
4694 };
4695
4696 libdb
4697 .batch_insert_books(library_id, &[(epub_fp, &epub_info), (pdf_fp, &pdf_info)])
4698 .expect("insert books");
4699
4700 let mut allowed = FxHashSet::default();
4701 allowed.insert(FileExtension::Epub);
4702
4703 let purged = libdb
4704 .delete_books_with_disallowed_kinds(library_id, &allowed)
4705 .expect("purge disallowed");
4706
4707 assert_eq!(purged, vec![pdf_fp], "only pdf should be purged");
4708
4709 let handles = libdb.list_book_handles(library_id).expect("handles");
4710 let fps: Vec<Fp> = handles.iter().map(|(fp, _)| *fp).collect();
4711
4712 assert!(fps.contains(&epub_fp), "epub should remain");
4713 assert!(!fps.contains(&pdf_fp), "pdf should be gone");
4714 }
4715}