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