1pub(crate) mod db;
2pub mod importer;
3mod migrations;
4
5use crate::db::Database;
6use crate::document::SimpleTocEntry;
7use crate::helpers::{Fingerprint, Fp};
8use crate::library::db::Db as LibraryDb;
9use crate::metadata::sorter;
10use crate::metadata::{BookQuery, Info, ReaderInfo, SimpleStatus, SortMethod};
11use anyhow::{Error, bail, format_err};
12use chrono::Local;
13use std::collections::BTreeSet;
14use std::fs;
15use std::io::ErrorKind;
16use std::path::{Path, PathBuf};
17use tracing::{debug, error, info};
18
19pub(crate) const METADATA_FILENAME: &str = ".metadata.json";
20pub(crate) const READING_STATES_DIRNAME: &str = ".reading-states";
21#[cfg(not(feature = "test"))]
22pub(crate) const THUMBNAIL_PREVIEWS_DIRNAME: &str = ".thumbnail-previews";
23
24#[derive(Debug, Clone, Default)]
25pub struct PageResult {
26 pub books: Vec<Info>,
27 pub total_count: usize,
28}
29
30pub struct Library {
31 pub home: PathBuf,
32 pub db: LibraryDb,
33 pub library_id: i64,
34 pub sort_method: SortMethod,
35 pub reverse_order: bool,
36 pub show_hidden: bool,
37}
38
39impl Library {
40 #[cfg_attr(feature = "tracing", tracing::instrument())]
41 pub fn new<P: AsRef<Path> + std::fmt::Debug>(
42 home: P,
43 database: &Database,
44 name: &str,
45 ) -> Result<Self, Error> {
46 let db = LibraryDb::new(database);
47
48 if let Err(e) = fs::create_dir(&home) {
49 if e.kind() != ErrorKind::AlreadyExists {
50 bail!(e);
51 }
52 }
53
54 let home_path = home.as_ref().to_path_buf();
55 let home_path_str = home_path.to_string_lossy();
56
57 let library_id = if let Some(id) = db.get_library_by_path(&home_path_str)? {
58 info!(library_id = id, path = ?home_path, "found existing library");
59 id
60 } else {
61 let id = db.register_library(&home_path_str, name)?;
62 info!(library_id = id, path = ?home_path, name = %name, "registered new library");
63 id
64 };
65
66 let sort_method = SortMethod::Opened;
67
68 Ok(Library {
69 home: home.as_ref().to_path_buf(),
70 db,
71 library_id,
72 sort_method,
73 reverse_order: sort_method.reverse_order(),
74 show_hidden: false,
75 })
76 }
77
78 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, query, prefix)))]
79 pub fn list<P: AsRef<Path>>(
80 &self,
81 prefix: P,
82 query: Option<&BookQuery>,
83 skip_files: bool,
84 ) -> (Vec<Info>, BTreeSet<PathBuf>) {
85 self.list_by(
86 prefix,
87 query,
88 self.sort_method,
89 self.reverse_order,
90 skip_files,
91 )
92 }
93
94 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, query, prefix)))]
99 pub fn list_by<P: AsRef<Path>>(
100 &self,
101 prefix: P,
102 query: Option<&BookQuery>,
103 sort_method: SortMethod,
104 reverse_order: bool,
105 skip_files: bool,
106 ) -> (Vec<Info>, BTreeSet<PathBuf>) {
107 let relat_prefix = prefix
108 .as_ref()
109 .strip_prefix(&self.home)
110 .unwrap_or_else(|_| prefix.as_ref());
111
112 let dirs = self
113 .db
114 .list_directories_under_prefix(self.library_id, relat_prefix)
115 .map_err(|e| {
116 error!(error = %e, library_id = self.library_id, "failed to list directories");
117 })
118 .unwrap_or_default()
119 .into_iter()
120 .map(|path| prefix.as_ref().join(path))
121 .collect();
122
123 if skip_files {
124 return (Vec::new(), dirs);
125 }
126
127 let files = if query.is_none() {
128 self.db
129 .page_books(
130 self.library_id,
131 relat_prefix,
132 sort_method,
133 reverse_order,
134 i64::MAX,
135 0,
136 )
137 .map_err(|e| {
138 error!(error = %e, library_id = self.library_id, "failed to list books");
139 })
140 .map(|(books, _)| books)
141 .unwrap_or_default()
142 } else {
143 let cmp = sorter(sort_method);
144 let mut books: Vec<Info> = self
145 .db
146 .list_books_under_prefix(self.library_id, relat_prefix)
147 .map_err(|e| {
148 error!(error = %e, library_id = self.library_id, "failed to list books");
149 })
150 .unwrap_or_default()
151 .into_iter()
152 .filter(|info| query.is_none_or(|q| q.is_match(info)))
153 .collect();
154 if reverse_order {
155 books.sort_by(|a, b| cmp(b, a));
156 } else {
157 books.sort_by(cmp);
158 }
159 books
160 };
161
162 (files, dirs)
163 }
164
165 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, prefix, query)))]
166 pub fn page<P: AsRef<Path>>(
167 &self,
168 prefix: P,
169 query: Option<&BookQuery>,
170 page: usize,
171 page_size: usize,
172 ) -> Result<PageResult, Error> {
173 if page_size == 0 {
174 return Ok(PageResult::default());
175 }
176
177 if query.is_some() {
178 let (files, _) = self.list(prefix, query, false);
179 let total_count = files.len();
180 let start = page.saturating_mul(page_size);
181 let books = files.into_iter().skip(start).take(page_size).collect();
182 return Ok(PageResult { books, total_count });
183 }
184
185 let relat_prefix = prefix
186 .as_ref()
187 .strip_prefix(&self.home)
188 .unwrap_or_else(|_| prefix.as_ref());
189 let offset = (page.saturating_mul(page_size)) as i64;
190 let limit = page_size as i64;
191
192 let (books, total_count) = self.db.page_books(
193 self.library_id,
194 relat_prefix,
195 self.sort_method,
196 self.reverse_order,
197 limit,
198 offset,
199 )?;
200
201 Ok(PageResult {
202 books,
203 total_count: total_count as usize,
204 })
205 }
206
207 pub fn neighbor_status_change_page<P: AsRef<Path>>(
247 &self,
248 prefix: P,
249 query: Option<&BookQuery>,
250 current_page: usize,
251 page_size: usize,
252 dir: crate::geom::CycleDir,
253 ) -> Result<Option<usize>, Error> {
254 if page_size == 0 {
255 return Ok(None);
256 }
257
258 let (files, _) = self.list(prefix, query, false);
259
260 if files.is_empty() || current_page >= files.len().div_ceil(page_size) {
261 return Ok(None);
262 }
263
264 let index_lower = current_page.saturating_mul(page_size);
265 let index_upper = (index_lower + page_size).min(files.len());
266 if index_lower >= files.len() || index_upper == 0 {
267 return Ok(None);
268 }
269
270 let book_index = match dir {
271 crate::geom::CycleDir::Next => index_upper.saturating_sub(1),
272 crate::geom::CycleDir::Previous => index_lower,
273 };
274 let status = files[book_index].simple_status();
275
276 let page = match dir {
277 crate::geom::CycleDir::Next => files[book_index + 1..]
278 .iter()
279 .position(|info| info.simple_status() != status)
280 .map(|delta| current_page + 1 + delta / page_size),
281 crate::geom::CycleDir::Previous => files[..book_index]
282 .iter()
283 .rev()
284 .position(|info| info.simple_status() != status)
285 .map(|delta| current_page.saturating_sub(1 + delta / page_size)),
286 };
287
288 Ok(page)
289 }
290
291 pub fn resolve_id(&self) -> (i64, &std::path::Path) {
292 (self.library_id, &self.home)
293 }
294
295 pub fn add_document(&mut self, info: Info) {
296 let path = self.home.join(&info.file.path);
297 let fp = match path.fingerprint() {
298 Ok(fp) => fp,
299 Err(e) => {
300 error!(path = %path.display(), error = %e, "failed to fingerprint document");
301 return;
302 }
303 };
304
305 if let Err(e) = self.db.insert_book(self.library_id, fp, &info) {
306 error!(fp = %fp, error = %e, "failed to insert book into database");
307 return;
308 }
309
310 debug!(fp = %fp, title = %info.title, "book inserted into database");
311
312 if let Err(e) = self.db.insert_sort_rank(self.library_id, fp, &info) {
313 error!(fp = %fp, error = %e, "failed to insert sort rank for new book");
314 }
315 }
316
317 pub fn rename<P: AsRef<Path>>(&mut self, path: P, file_name: &str) -> Result<(), Error> {
318 let src = self.home.join(path.as_ref());
319
320 let fp = self
321 .resolve_fingerprint(path.as_ref())
322 .ok_or_else(|| format_err!("can't get fingerprint of {}", path.as_ref().display()))?;
323
324 let mut dest = src.clone();
325 dest.set_file_name(file_name);
326 fs::rename(&src, &dest)?;
327
328 let new_path = dest.strip_prefix(&self.home)?;
329
330 if let Some(mut info) = self.db.get_book_by_fingerprint(self.library_id, fp)? {
331 info.file.path = new_path.to_path_buf();
332 info.file.absolute_path = dest.clone();
333
334 if let Err(e) = self.db.update_book(self.library_id, fp, &info) {
335 error!(fp = %fp, error = %e, "failed to update book path in database");
336 } else {
337 debug!(fp = %fp, new_path = %new_path.display(), "book path updated in database");
338
339 if let Err(e) = self.db.insert_sort_rank(self.library_id, fp, &info) {
340 error!(fp = %fp, error = %e, "failed to update sort rank after rename");
341 }
342 }
343 }
344
345 Ok(())
346 }
347
348 pub fn remove<P: AsRef<Path>>(&mut self, path: P) -> Result<(), Error> {
349 let full_path = self.home.join(path.as_ref());
350
351 let fp = self
352 .resolve_fingerprint(path.as_ref())
353 .ok_or_else(|| format_err!("can't get fingerprint of {}", path.as_ref().display()))?;
354
355 if full_path.exists() {
356 fs::remove_file(&full_path)?;
357 }
358
359 if let Some(parent) = full_path.parent() {
360 if parent != self.home {
361 fs::remove_dir(parent).ok();
362 }
363 }
364
365 self.db.delete_thumbnail(fp).ok();
366
367 if let Err(e) = self.db.delete_book(self.library_id, fp) {
368 error!(fp = %fp, error = %e, "failed to delete book from database");
369 } else {
370 debug!(fp = %fp, "book deleted from database");
371 }
372
373 Ok(())
374 }
375
376 pub fn copy_to<P: AsRef<Path>>(&mut self, path: P, other: &mut Library) -> Result<(), Error> {
377 let src = self.home.join(path.as_ref());
378
379 if !src.exists() {
380 return Err(format_err!(
381 "can't copy non-existing file {}",
382 path.as_ref().display()
383 ));
384 }
385
386 let fp = self
387 .resolve_fingerprint(path.as_ref())
388 .ok_or_else(|| format_err!("can't get fingerprint of {}", path.as_ref().display()))?;
389
390 let mut dest = other.home.join(path.as_ref());
391 if let Some(parent) = dest.parent() {
392 fs::create_dir_all(parent)?;
393 }
394
395 if dest.exists() {
396 let prefix = Local::now().format("%Y%m%d_%H%M%S ");
397 let name = dest
398 .file_name()
399 .and_then(|name| name.to_str())
400 .map(|name| prefix.to_string() + name)
401 .ok_or_else(|| format_err!("can't compute new name for {}", dest.display()))?;
402 dest.set_file_name(name);
403 }
404
405 fs::copy(&src, &dest)?;
406
407 if let Ok(Some(thumbnail_data)) = self.db.get_thumbnail(fp) {
408 other.db.save_thumbnail(fp, &thumbnail_data).ok();
409 }
410
411 if let Some(mut info) = self.db.get_book_by_fingerprint(self.library_id, fp)? {
412 let dest_path = dest.strip_prefix(&other.home)?;
413 info.file.path = dest_path.to_path_buf();
414 info.file.absolute_path = dest.clone();
415
416 if let Err(e) = other.db.insert_book(other.library_id, fp, &info) {
417 error!(fp = %fp, error = %e, "failed to insert copied book into target database");
418 } else {
419 debug!(fp = %fp, "book copied to target database");
420
421 if let Err(e) = other.db.insert_sort_rank(other.library_id, fp, &info) {
422 error!(fp = %fp, error = %e, "failed to insert sort rank for copied book");
423 }
424 }
425 }
426
427 Ok(())
428 }
429
430 pub fn move_to<P: AsRef<Path>>(&mut self, path: P, other: &mut Library) -> Result<(), Error> {
431 let src = self.home.join(path.as_ref());
432
433 if !src.exists() {
434 return Err(format_err!(
435 "can't move non-existing file {}",
436 path.as_ref().display()
437 ));
438 }
439
440 let fp = self
441 .resolve_fingerprint(path.as_ref())
442 .ok_or_else(|| format_err!("can't get fingerprint of {}", path.as_ref().display()))?;
443
444 let src = self.home.join(path.as_ref());
445 let mut dest = other.home.join(path.as_ref());
446 if let Some(parent) = dest.parent() {
447 fs::create_dir_all(parent)?;
448 }
449
450 if dest.exists() {
451 let prefix = Local::now().format("%Y%m%d_%H%M%S ");
452 let name = dest
453 .file_name()
454 .and_then(|name| name.to_str())
455 .map(|name| prefix.to_string() + name)
456 .ok_or_else(|| format_err!("can't compute new name for {}", dest.display()))?;
457 dest.set_file_name(name);
458 }
459
460 fs::rename(&src, &dest)?;
461
462 let thumbnail_data = self.db.get_thumbnail(fp).ok().flatten();
463
464 if let Some(mut info) = self.db.get_book_by_fingerprint(self.library_id, fp)? {
465 let dest_path = dest.strip_prefix(&other.home)?;
466 info.file.path = dest_path.to_path_buf();
467 info.file.absolute_path = dest.clone();
468
469 if let Err(e) = other.db.insert_book(other.library_id, fp, &info) {
470 error!(fp = %fp, error = %e, "failed to insert moved book into target database");
471 } else {
472 debug!(fp = %fp, "book moved to target database");
473
474 if let Err(e) = other.db.insert_sort_rank(other.library_id, fp, &info) {
475 error!(fp = %fp, error = %e, "failed to insert sort rank for moved book");
476 }
477 }
478
479 if let Some(thumbnail_data) = thumbnail_data {
480 other.db.save_thumbnail(fp, &thumbnail_data).ok();
481 }
482
483 if let Err(e) = self.db.delete_book(self.library_id, fp) {
484 error!(fp = %fp, error = %e, "failed to delete moved book from source database");
485 }
486 }
487
488 Ok(())
489 }
490
491 pub fn clean_up(&mut self) {}
493
494 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
495 pub fn set_sort(&mut self, sort_method: SortMethod, reverse_order: bool) {
496 self.sort_method = sort_method;
497 self.reverse_order = reverse_order;
498 }
499
500 pub fn apply<F>(&mut self, f: F)
501 where
502 F: Fn(&Path, &mut Info),
503 {
504 let books = match self.db.get_all_books(self.library_id) {
505 Ok(b) => b,
506 Err(e) => {
507 error!(error = %e, "failed to load books for apply");
508 return;
509 }
510 };
511
512 let updated: Vec<Info> = books
513 .into_iter()
514 .map(|mut info| {
515 f(&self.home, &mut info);
516 info
517 })
518 .collect();
519
520 let refs: Vec<(Fp, &Info)> = updated
521 .iter()
522 .filter_map(|info| info.fp.map(|fp| (fp, info)))
523 .collect();
524
525 if let Err(e) = self.db.batch_update_books(self.library_id, &refs) {
526 error!(error = %e, "failed to persist apply changes to database");
527 }
528 }
529
530 pub fn sync_reader_info<P: AsRef<Path>>(&mut self, path: P, reader: &ReaderInfo) {
531 let path = path.as_ref();
532 let Some(fp) = self.resolve_fingerprint(path) else {
533 return;
534 };
535
536 if let Err(e) = self.db.save_reading_state(fp, reader) {
537 error!(fp = %fp, error = %e, "failed to save reading state to database");
538 } else {
539 debug!(fp = %fp, "reading state saved to database");
540 }
541 }
542
543 pub fn sync_toc<P: AsRef<Path>>(&mut self, path: P, toc: Vec<SimpleTocEntry>) {
548 let path = path.as_ref();
549 let Some(fp) = self.resolve_fingerprint(path) else {
550 return;
551 };
552
553 if let Err(e) = self.db.save_toc(fp, &toc) {
554 error!(fp = %fp, error = %e, "failed to save TOC to database");
555 } else {
556 debug!(fp = %fp, entry_count = toc.len(), "TOC saved to database");
557 }
558 }
559
560 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
561 pub fn thumbnail_preview<P: AsRef<Path> + std::fmt::Debug>(
562 &self,
563 path: P,
564 ) -> Option<crate::framebuffer::Pixmap> {
565 match self
566 .db
567 .get_thumbnail_by_path(self.library_id, path.as_ref())
568 {
569 Ok(Some(data)) => crate::framebuffer::Pixmap::from_png_bytes(&data).ok(),
570 Ok(None) => None,
571 Err(e) => {
572 error!(library_id = self.library_id, path = %path.as_ref().display(), error = %e, "failed to load thumbnail from database");
573 None
574 }
575 }
576 }
577
578 pub fn set_status<P: AsRef<Path>>(&mut self, path: P, status: SimpleStatus) {
579 let path = path.as_ref();
580 let Some(fp) = self.resolve_fingerprint(path) else {
581 return;
582 };
583
584 match status {
585 SimpleStatus::New => {
586 if let Err(e) = self.db.delete_reading_state(fp) {
587 error!(fp = %fp, error = %e, "failed to delete reading state from database");
588 }
589 }
590 SimpleStatus::Reading | SimpleStatus::Finished => {
591 let current_info = self
592 .db
593 .get_book_by_fingerprint(self.library_id, fp)
594 .ok()
595 .flatten();
596
597 let mut reader_info = current_info
598 .and_then(|info| info.reader)
599 .unwrap_or_default();
600
601 reader_info.finished = status == SimpleStatus::Finished;
602
603 if let Err(e) = self.db.save_reading_state(fp, &reader_info) {
604 error!(fp = %fp, error = %e, "failed to save reading state to database");
605 } else {
606 debug!(fp = %fp, finished = reader_info.finished, "reading state updated in database");
607 }
608 }
609 }
610 }
611
612 pub fn reload(&mut self) {}
614
615 pub fn is_empty(&self) -> Option<bool> {
616 self.db
617 .count_books(self.library_id)
618 .ok()
619 .map(|count| count == 0)
620 }
621
622 pub fn next_book_after(&self, fp: Fp) -> Option<Info> {
623 let mut books: Vec<Info> = self
624 .db
625 .list_books_under_prefix(self.library_id, Path::new(""))
626 .ok()?;
627
628 if books.is_empty() {
629 return None;
630 }
631
632 books.sort_by(|left, right| {
633 let ordering = sorter(self.sort_method)(left, right);
634 if self.reverse_order {
635 ordering.reverse()
636 } else {
637 ordering
638 }
639 });
640
641 let current_index = books
642 .iter()
643 .position(|candidate| candidate.fp == Some(fp))?;
644 books.into_iter().nth(current_index + 1)
645 }
646
647 pub fn most_recently_opened_reading_book(&self) -> Option<Info> {
648 self.db
649 .most_recently_opened_reading_book(self.library_id)
650 .map_err(|e| {
651 error!(error = %e, library_id = self.library_id, "failed to get most recently opened reading book");
652 })
653 .ok()
654 .flatten()
655 }
656
657 fn resolve_fingerprint(&self, path: &Path) -> Option<Fp> {
658 match self.db.get_book_by_path(self.library_id, path) {
659 Ok(Some(info)) => {
660 if let Some(fp) = info.fp {
661 return Some(fp);
662 }
663 }
664 Ok(None) => {}
665 Err(e) => {
666 error!(
667 path = %path.display(),
668 error = %e,
669 "failed to resolve fingerprint from database"
670 );
671 }
672 }
673
674 let full_path = self.home.join(path);
675
676 match full_path.fingerprint() {
677 Ok(fp) => Some(fp),
678 Err(e) => {
679 error!(path = %full_path.display(), error = %e, "failed to fingerprint path");
680 None
681 }
682 }
683 }
684}
685
686#[cfg(test)]
687mod tests {
688 use super::*;
689 use crate::db::Database;
690 use crate::geom::CycleDir;
691 use crate::metadata::FileInfo;
692 use crate::settings::ImportSettings;
693 use crate::task::ShutdownSignal;
694 use std::str::FromStr;
695 use std::sync::mpsc;
696
697 fn setup_library_with_book(
698 dir: &Path,
699 db: &Database,
700 name: &str,
701 filename: &str,
702 ) -> (Library, PathBuf) {
703 let lib = Library::new(dir, db, name).expect("failed to create library");
704 fs::write(dir.join(filename), b"dummy book content").expect("failed to write test file");
705 let (tx, _rx) = mpsc::channel();
706 let notif_id = crate::view::ViewId::MessageNotif(0);
707 let shutdown = ShutdownSignal::never();
708 importer::run(
709 &lib.db,
710 lib.library_id,
711 dir,
712 &ImportSettings::default(),
713 false,
714 &tx,
715 notif_id,
716 &shutdown,
717 );
718 (lib, PathBuf::from(filename))
719 }
720
721 fn make_info(path: &str, title: &str, fp: Fp) -> Info {
722 Info {
723 title: title.to_string(),
724 file: FileInfo {
725 path: PathBuf::from(path),
726 absolute_path: PathBuf::from(format!("/library/{path}")),
727 kind: "pdf".to_string(),
728 size: 1024,
729 mtime: None,
730 },
731 fp: Some(fp),
732 ..Default::default()
733 }
734 }
735
736 fn make_status_info(path: &str, title: &str, fp: Fp, status: SimpleStatus) -> Info {
737 let mut info = make_info(path, title, fp);
738 let reader = match status {
739 SimpleStatus::New => None,
740 SimpleStatus::Reading => Some(ReaderInfo {
741 current_page: 1,
742 pages_count: 10,
743 finished: false,
744 ..Default::default()
745 }),
746 SimpleStatus::Finished => Some(ReaderInfo {
747 current_page: 10,
748 pages_count: 10,
749 finished: true,
750 ..Default::default()
751 }),
752 };
753 info.reader = reader.clone();
754 info.reader_info = reader;
755 info
756 }
757
758 #[test]
759 fn copy_to_sets_absolute_path_in_destination() {
760 let src_dir = tempfile::tempdir().expect("failed to create src temp dir");
761 let dst_dir = tempfile::tempdir().expect("failed to create dst temp dir");
762 let mut db = Database::new(":memory:").expect("failed to create in-memory database");
763 db.init(0).expect("failed to run migrations");
764
765 let (mut src_lib, rel_path) =
766 setup_library_with_book(src_dir.path(), &db, "Source", "book.epub");
767 let mut dst_lib =
768 Library::new(dst_dir.path(), &db, "Destination").expect("failed to create dst lib");
769
770 let src_books = src_lib
771 .db
772 .get_all_books(src_lib.library_id)
773 .expect("failed to get src books");
774 assert!(
775 !src_books.is_empty(),
776 "source library should contain the book"
777 );
778
779 src_lib
780 .copy_to(&rel_path, &mut dst_lib)
781 .expect("copy_to failed");
782
783 let dst_books = dst_lib
784 .db
785 .get_all_books(dst_lib.library_id)
786 .expect("failed to get dst books");
787
788 let dst_info = dst_books
789 .into_iter()
790 .next()
791 .expect("destination library should contain the copied book");
792
793 let expected_abs = dst_dir.path().join(&dst_info.file.path);
794 assert_eq!(
795 dst_info.file.absolute_path, expected_abs,
796 "absolute_path should point to the destination file after copy_to"
797 );
798 assert!(
799 dst_info.file.absolute_path.exists(),
800 "absolute_path should point to an existing file"
801 );
802 }
803
804 #[test]
805 fn move_to_sets_absolute_path_in_destination() {
806 let src_dir = tempfile::tempdir().expect("failed to create src temp dir");
807 let dst_dir = tempfile::tempdir().expect("failed to create dst temp dir");
808 let mut db = Database::new(":memory:").expect("failed to create in-memory database");
809 db.init(0).expect("failed to run migrations");
810
811 let (mut src_lib, rel_path) =
812 setup_library_with_book(src_dir.path(), &db, "Source", "book.epub");
813 let mut dst_lib =
814 Library::new(dst_dir.path(), &db, "Destination").expect("failed to create dst lib");
815
816 let src_books = src_lib
817 .db
818 .get_all_books(src_lib.library_id)
819 .expect("failed to get src books");
820 assert!(
821 !src_books.is_empty(),
822 "source library should contain the book"
823 );
824
825 src_lib
826 .move_to(&rel_path, &mut dst_lib)
827 .expect("move_to failed");
828
829 let src_books_after = src_lib
830 .db
831 .get_all_books(src_lib.library_id)
832 .expect("failed to get src books after move");
833 assert!(
834 src_books_after.is_empty(),
835 "source library should no longer contain the book after move"
836 );
837
838 let dst_books = dst_lib
839 .db
840 .get_all_books(dst_lib.library_id)
841 .expect("failed to get dst books");
842
843 let dst_info = dst_books
844 .into_iter()
845 .next()
846 .expect("destination library should contain the moved book");
847
848 let expected_abs = dst_dir.path().join(&dst_info.file.path);
849 assert_eq!(
850 dst_info.file.absolute_path, expected_abs,
851 "absolute_path should point to the destination file after move_to"
852 );
853 assert!(
854 dst_info.file.absolute_path.exists(),
855 "absolute_path should point to an existing file"
856 );
857 }
858
859 #[test]
860 fn neighbor_status_change_page_finds_next_and_previous_boundaries() {
861 let dir = tempfile::tempdir().expect("failed to create temp dir");
862 let mut db = Database::new(":memory:").expect("failed to create in-memory database");
863 db.init(0).expect("failed to run migrations");
864
865 let lib =
866 Library::new(dir.path(), &db, "Status Library").expect("failed to create library");
867
868 let statuses = [
869 SimpleStatus::New,
870 SimpleStatus::New,
871 SimpleStatus::Reading,
872 SimpleStatus::Reading,
873 SimpleStatus::Finished,
874 SimpleStatus::Finished,
875 ];
876
877 for (index, status) in statuses.into_iter().enumerate() {
878 let fp = Fp::from_str(&format!("{:016X}", index + 1)).expect("invalid fingerprint");
879 let info = make_status_info(
880 &format!("book-{}.pdf", index + 1),
881 &format!("Book {}", index + 1),
882 fp,
883 status,
884 );
885 lib.db
886 .insert_book(lib.library_id, fp, &info)
887 .expect("failed to insert book");
888 }
889
890 assert_eq!(
891 lib.neighbor_status_change_page(dir.path(), None, 0, 2, CycleDir::Next)
892 .expect("next boundary lookup failed"),
893 Some(1)
894 );
895 assert_eq!(
896 lib.neighbor_status_change_page(dir.path(), None, 2, 2, CycleDir::Previous)
897 .expect("previous boundary lookup failed"),
898 Some(1)
899 );
900 assert_eq!(
901 lib.neighbor_status_change_page(dir.path(), None, 2, 2, CycleDir::Next)
902 .expect("terminal next lookup failed"),
903 None
904 );
905 assert_eq!(
906 lib.neighbor_status_change_page(dir.path(), None, 0, 0, CycleDir::Next)
907 .expect("zero page size lookup failed"),
908 None
909 );
910 }
911
912 #[test]
913 fn next_book_after_returns_following_book_in_title_order() {
914 let dir = tempfile::tempdir().expect("failed to create temp dir");
915 let mut db = Database::new(":memory:").expect("failed to create in-memory database");
916 db.init(0).expect("failed to run migrations");
917
918 let mut lib =
919 Library::new(dir.path(), &db, "Next Book Library").expect("failed to create library");
920 lib.sort_method = SortMethod::Title;
921 lib.reverse_order = false;
922
923 let alpha_fp = Fp::from_str("0000000000000101").expect("invalid alpha fingerprint");
924 let beta_fp = Fp::from_str("0000000000000102").expect("invalid beta fingerprint");
925 let gamma_fp = Fp::from_str("0000000000000103").expect("invalid gamma fingerprint");
926
927 for (fp, title, path) in [
928 (beta_fp, "Beta", "beta.pdf"),
929 (gamma_fp, "Gamma", "gamma.pdf"),
930 (alpha_fp, "Alpha", "alpha.pdf"),
931 ] {
932 let info = make_info(path, title, fp);
933 lib.db
934 .insert_book(lib.library_id, fp, &info)
935 .expect("failed to insert book");
936 }
937
938 let next = lib
939 .next_book_after(alpha_fp)
940 .expect("alpha should have a next book");
941 assert_eq!(next.fp, Some(beta_fp));
942 assert_eq!(next.title, "Beta");
943
944 let last = lib.next_book_after(gamma_fp);
945 assert!(last.is_none(), "last book should not have a successor");
946
947 let missing = lib.next_book_after(
948 Fp::from_str("00000000000001FF").expect("invalid missing fingerprint"),
949 );
950 assert!(missing.is_none(), "missing fingerprint should return none");
951 }
952
953 #[test]
954 fn compute_sort_keys_assigns_correct_title_ranks() {
955 let dir = tempfile::tempdir().expect("failed to create temp dir");
956 let mut db = Database::new(":memory:").expect("failed to create in-memory database");
957 db.init(0).expect("failed to run migrations");
958
959 let lib =
960 Library::new(dir.path(), &db, "Sort Keys Library").expect("failed to create library");
961
962 let fp_a = Fp::from_str("0000000000000301").expect("invalid fp");
963 let fp_b = Fp::from_str("0000000000000302").expect("invalid fp");
964 let fp_c = Fp::from_str("0000000000000303").expect("invalid fp");
965
966 for (fp, title, path) in [
968 (fp_c, "Zebra", "zebra.pdf"),
969 (fp_a, "Apple", "apple.pdf"),
970 (fp_b, "Mango", "mango.pdf"),
971 ] {
972 lib.db
973 .insert_book(lib.library_id, fp, &make_info(path, title, fp))
974 .expect("failed to insert book");
975 }
976
977 lib.db
978 .compute_sort_keys(lib.library_id)
979 .expect("compute_sort_keys failed");
980
981 let (books, total) = lib
983 .db
984 .page_books(
985 lib.library_id,
986 Path::new(""),
987 SortMethod::Title,
988 false,
989 10,
990 0,
991 )
992 .expect("page_books failed");
993
994 assert_eq!(total, 3);
995 assert_eq!(
996 books.iter().map(|b| b.title.as_str()).collect::<Vec<_>>(),
997 vec!["Apple", "Mango", "Zebra"]
998 );
999 }
1000
1001 #[test]
1002 fn page_books_paginates_correctly() {
1003 let dir = tempfile::tempdir().expect("failed to create temp dir");
1004 let mut db = Database::new(":memory:").expect("failed to create in-memory database");
1005 db.init(0).expect("failed to run migrations");
1006
1007 let lib =
1008 Library::new(dir.path(), &db, "Pagination Library").expect("failed to create library");
1009
1010 for i in 1u8..=5 {
1011 let fp = Fp::from_str(&format!("{:016X}", i)).expect("invalid fingerprint");
1012 let title = format!("Book {:02}", i);
1013 let path = format!("book{i}.pdf");
1014 lib.db
1015 .insert_book(lib.library_id, fp, &make_info(&path, &title, fp))
1016 .expect("failed to insert book");
1017 }
1018
1019 lib.db
1020 .compute_sort_keys(lib.library_id)
1021 .expect("compute_sort_keys failed");
1022
1023 let (page0, total) = lib
1025 .db
1026 .page_books(
1027 lib.library_id,
1028 Path::new(""),
1029 SortMethod::Title,
1030 false,
1031 2,
1032 0,
1033 )
1034 .expect("page_books page 0 failed");
1035 assert_eq!(total, 5);
1036 assert_eq!(page0.len(), 2);
1037 assert_eq!(page0[0].title, "Book 01");
1038 assert_eq!(page0[1].title, "Book 02");
1039
1040 let (page1, _) = lib
1042 .db
1043 .page_books(
1044 lib.library_id,
1045 Path::new(""),
1046 SortMethod::Title,
1047 false,
1048 2,
1049 2,
1050 )
1051 .expect("page_books page 1 failed");
1052 assert_eq!(page1.len(), 2);
1053 assert_eq!(page1[0].title, "Book 03");
1054 assert_eq!(page1[1].title, "Book 04");
1055
1056 let (page2, _) = lib
1058 .db
1059 .page_books(
1060 lib.library_id,
1061 Path::new(""),
1062 SortMethod::Title,
1063 false,
1064 2,
1065 4,
1066 )
1067 .expect("page_books page 2 failed");
1068 assert_eq!(page2.len(), 1);
1069 assert_eq!(page2[0].title, "Book 05");
1070 }
1071
1072 #[test]
1073 fn page_books_reverse_order_reverses_results() {
1074 let dir = tempfile::tempdir().expect("failed to create temp dir");
1075 let mut db = Database::new(":memory:").expect("failed to create in-memory database");
1076 db.init(0).expect("failed to run migrations");
1077
1078 let lib =
1079 Library::new(dir.path(), &db, "Reverse Library").expect("failed to create library");
1080
1081 for (fp_hex, title, path) in [
1082 ("0000000000000401", "Alpha", "alpha.pdf"),
1083 ("0000000000000402", "Beta", "beta.pdf"),
1084 ("0000000000000403", "Gamma", "gamma.pdf"),
1085 ] {
1086 let fp = Fp::from_str(fp_hex).expect("invalid fp");
1087 lib.db
1088 .insert_book(lib.library_id, fp, &make_info(path, title, fp))
1089 .expect("failed to insert book");
1090 }
1091
1092 lib.db
1093 .compute_sort_keys(lib.library_id)
1094 .expect("compute_sort_keys failed");
1095
1096 let (books, _) = lib
1097 .db
1098 .page_books(
1099 lib.library_id,
1100 Path::new(""),
1101 SortMethod::Title,
1102 true,
1103 10,
1104 0,
1105 )
1106 .expect("page_books failed");
1107
1108 assert_eq!(
1109 books.iter().map(|b| b.title.as_str()).collect::<Vec<_>>(),
1110 vec!["Gamma", "Beta", "Alpha"]
1111 );
1112 }
1113
1114 #[test]
1115 fn page_method_uses_db_pagination_without_query() {
1116 let dir = tempfile::tempdir().expect("failed to create temp dir");
1117 let mut db = Database::new(":memory:").expect("failed to create in-memory database");
1118 db.init(0).expect("failed to run migrations");
1119
1120 let mut lib =
1121 Library::new(dir.path(), &db, "Page Method Library").expect("failed to create library");
1122 lib.sort_method = SortMethod::Title;
1123 lib.reverse_order = false;
1124
1125 for (fp_hex, title, path) in [
1126 ("0000000000000501", "Charlie", "c.pdf"),
1127 ("0000000000000502", "Alice", "a.pdf"),
1128 ("0000000000000503", "Bob", "b.pdf"),
1129 ] {
1130 let fp = Fp::from_str(fp_hex).expect("invalid fp");
1131 lib.db
1132 .insert_book(lib.library_id, fp, &make_info(path, title, fp))
1133 .expect("failed to insert book");
1134 }
1135
1136 lib.db
1137 .compute_sort_keys(lib.library_id)
1138 .expect("compute_sort_keys failed");
1139
1140 let result = lib.page(dir.path(), None, 0, 2).expect("page failed");
1141
1142 assert_eq!(result.total_count, 3);
1143 assert_eq!(result.books.len(), 2);
1144 assert_eq!(result.books[0].title, "Alice");
1145 assert_eq!(result.books[1].title, "Bob");
1146 }
1147
1148 #[test]
1149 fn list_subdirectory_returns_correct_absolute_paths() {
1150 let dir = tempfile::tempdir().expect("failed to create temp dir");
1151 let mut db = Database::new(":memory:").expect("failed to create in-memory database");
1152 db.init(0).expect("failed to run migrations");
1153
1154 let lib =
1155 Library::new(dir.path(), &db, "Dir Nav Library").expect("failed to create library");
1156
1157 for (fp_hex, path, title) in [
1159 (
1160 "0000000000001001",
1161 "fiction/fantasy/book1.pdf",
1162 "Fantasy One",
1163 ),
1164 ("0000000000001002", "fiction/scifi/book2.pdf", "SciFi One"),
1165 ("0000000000001003", "nonfiction/book3.pdf", "Nonfiction One"),
1166 ] {
1167 let fp = Fp::from_str(fp_hex).expect("invalid fp");
1168 lib.db
1169 .insert_book(lib.library_id, fp, &make_info(path, title, fp))
1170 .expect("failed to insert book");
1171 }
1172
1173 let (_, root_dirs) = lib.list(dir.path(), None, true);
1175 let root_dir_paths: Vec<_> = root_dirs.iter().collect();
1176 assert_eq!(root_dir_paths.len(), 2);
1177 assert!(root_dirs.contains(&dir.path().join("fiction")));
1178 assert!(root_dirs.contains(&dir.path().join("nonfiction")));
1179
1180 let fiction_prefix = dir.path().join("fiction");
1183 let (_, fiction_dirs) = lib.list(&fiction_prefix, None, true);
1184 assert_eq!(
1185 fiction_dirs.len(),
1186 2,
1187 "expected exactly 2 subdirs under fiction"
1188 );
1189 assert!(
1190 fiction_dirs.contains(&fiction_prefix.join("fantasy")),
1191 "expected fiction/fantasy, got: {fiction_dirs:?}"
1192 );
1193 assert!(
1194 fiction_dirs.contains(&fiction_prefix.join("scifi")),
1195 "expected fiction/scifi, got: {fiction_dirs:?}"
1196 );
1197 }
1198
1199 #[test]
1200 fn page_books_status_sort_orders_finished_new_reading() {
1201 let dir = tempfile::tempdir().expect("failed to create temp dir");
1202 let mut db = Database::new(":memory:").expect("failed to create in-memory database");
1203 db.init(0).expect("failed to run migrations");
1204
1205 let lib =
1206 Library::new(dir.path(), &db, "Status Sort Library").expect("failed to create library");
1207
1208 let fp_new = Fp::from_str("0000000000000601").expect("invalid fp");
1209 let fp_reading = Fp::from_str("0000000000000602").expect("invalid fp");
1210 let fp_finished = Fp::from_str("0000000000000603").expect("invalid fp");
1211
1212 for (fp, status, path, title) in [
1213 (fp_new, SimpleStatus::New, "new.pdf", "New Book"),
1214 (
1215 fp_reading,
1216 SimpleStatus::Reading,
1217 "reading.pdf",
1218 "Reading Book",
1219 ),
1220 (
1221 fp_finished,
1222 SimpleStatus::Finished,
1223 "finished.pdf",
1224 "Finished Book",
1225 ),
1226 ] {
1227 lib.db
1228 .insert_book(
1229 lib.library_id,
1230 fp,
1231 &make_status_info(path, title, fp, status),
1232 )
1233 .expect("failed to insert book");
1234 }
1235
1236 lib.db
1237 .compute_sort_keys(lib.library_id)
1238 .expect("compute_sort_keys failed");
1239
1240 let (books, total) = lib
1241 .db
1242 .page_books(
1243 lib.library_id,
1244 Path::new(""),
1245 SortMethod::Status,
1246 false,
1247 10,
1248 0,
1249 )
1250 .expect("page_books with Status sort failed");
1251
1252 assert_eq!(total, 3);
1253 assert_eq!(books[0].title, "Finished Book");
1255 assert_eq!(books[1].title, "New Book");
1256 assert_eq!(books[2].title, "Reading Book");
1257 }
1258
1259 #[test]
1260 fn page_books_progress_sort_orders_by_completion() {
1261 let dir = tempfile::tempdir().expect("failed to create temp dir");
1262 let mut db = Database::new(":memory:").expect("failed to create in-memory database");
1263 db.init(0).expect("failed to run migrations");
1264
1265 let lib = Library::new(dir.path(), &db, "Progress Sort Library")
1266 .expect("failed to create library");
1267
1268 let fp_new = Fp::from_str("0000000000000701").expect("invalid fp");
1269 let fp_halfway = Fp::from_str("0000000000000702").expect("invalid fp");
1270 let fp_finished = Fp::from_str("0000000000000703").expect("invalid fp");
1271
1272 let mut info_halfway = make_info("halfway.pdf", "Halfway Book", fp_halfway);
1273 info_halfway.reader_info = Some(ReaderInfo {
1274 current_page: 5,
1275 pages_count: 10,
1276 finished: false,
1277 ..Default::default()
1278 });
1279 info_halfway.reader = info_halfway.reader_info.clone();
1280
1281 for (fp, info) in [
1282 (
1283 fp_new,
1284 make_status_info("new.pdf", "New Book", fp_new, SimpleStatus::New),
1285 ),
1286 (fp_halfway, info_halfway),
1287 (
1288 fp_finished,
1289 make_status_info(
1290 "finished.pdf",
1291 "Finished Book",
1292 fp_finished,
1293 SimpleStatus::Finished,
1294 ),
1295 ),
1296 ] {
1297 lib.db
1298 .insert_book(lib.library_id, fp, &info)
1299 .expect("failed to insert book");
1300 }
1301
1302 lib.db
1303 .compute_sort_keys(lib.library_id)
1304 .expect("compute_sort_keys failed");
1305
1306 let (books, total) = lib
1307 .db
1308 .page_books(
1309 lib.library_id,
1310 Path::new(""),
1311 SortMethod::Progress,
1312 false,
1313 10,
1314 0,
1315 )
1316 .expect("page_books with Progress sort failed");
1317
1318 assert_eq!(total, 3);
1319 assert_eq!(books[0].title, "Finished Book");
1321 assert_eq!(books[1].title, "New Book");
1322 assert_eq!(books[2].title, "Halfway Book");
1323 }
1324
1325 #[test]
1326 fn page_books_pages_sort_orders_by_page_count() {
1327 let dir = tempfile::tempdir().expect("failed to create temp dir");
1328 let mut db = Database::new(":memory:").expect("failed to create in-memory database");
1329 db.init(0).expect("failed to run migrations");
1330
1331 let lib =
1332 Library::new(dir.path(), &db, "Pages Sort Library").expect("failed to create library");
1333
1334 for (fp_hex, path, title, pages) in [
1335 ("0000000000000801", "big.pdf", "Big Book", 500usize),
1336 ("0000000000000802", "tiny.pdf", "Tiny Book", 50),
1337 ("0000000000000803", "medium.pdf", "Medium Book", 200),
1338 ] {
1339 let fp = Fp::from_str(fp_hex).expect("invalid fp");
1340 let mut info = make_info(path, title, fp);
1341 info.reader_info = Some(ReaderInfo {
1342 pages_count: pages,
1343 ..Default::default()
1344 });
1345 info.reader = info.reader_info.clone();
1346 lib.db
1347 .insert_book(lib.library_id, fp, &info)
1348 .expect("failed to insert book");
1349 }
1350
1351 lib.db
1352 .compute_sort_keys(lib.library_id)
1353 .expect("compute_sort_keys failed");
1354
1355 let (books, total) = lib
1356 .db
1357 .page_books(
1358 lib.library_id,
1359 Path::new(""),
1360 SortMethod::Pages,
1361 false,
1362 10,
1363 0,
1364 )
1365 .expect("page_books with Pages sort failed");
1366
1367 assert_eq!(total, 3);
1368 assert_eq!(books[0].title, "Tiny Book");
1369 assert_eq!(books[1].title, "Medium Book");
1370 assert_eq!(books[2].title, "Big Book");
1371 }
1372
1373 #[test]
1374 fn page_books_size_sort_orders_by_file_size() {
1375 let dir = tempfile::tempdir().expect("failed to create temp dir");
1376 let mut db = Database::new(":memory:").expect("failed to create in-memory database");
1377 db.init(0).expect("failed to run migrations");
1378
1379 let lib =
1380 Library::new(dir.path(), &db, "Size Sort Library").expect("failed to create library");
1381
1382 for (fp_hex, path, title, size) in [
1383 ("0000000000000901", "big.pdf", "Big Book", 9000u64),
1384 ("0000000000000902", "tiny.pdf", "Tiny Book", 100),
1385 ("0000000000000903", "medium.pdf", "Medium Book", 4500),
1386 ] {
1387 let fp = Fp::from_str(fp_hex).expect("invalid fp");
1388 let mut info = make_info(path, title, fp);
1389 info.file.size = size;
1390 lib.db
1391 .insert_book(lib.library_id, fp, &info)
1392 .expect("failed to insert book");
1393 }
1394
1395 lib.db
1396 .compute_sort_keys(lib.library_id)
1397 .expect("compute_sort_keys failed");
1398
1399 let (books, total) = lib
1400 .db
1401 .page_books(
1402 lib.library_id,
1403 Path::new(""),
1404 SortMethod::Size,
1405 false,
1406 10,
1407 0,
1408 )
1409 .expect("page_books with Size sort failed");
1410
1411 assert_eq!(total, 3);
1412 assert_eq!(books[0].title, "Tiny Book");
1413 assert_eq!(books[1].title, "Medium Book");
1414 assert_eq!(books[2].title, "Big Book");
1415 }
1416
1417 #[test]
1418 fn page_books_kind_sort_orders_alphabetically_by_file_kind() {
1419 let dir = tempfile::tempdir().expect("failed to create temp dir");
1420 let mut db = Database::new(":memory:").expect("failed to create in-memory database");
1421 db.init(0).expect("failed to run migrations");
1422
1423 let lib =
1424 Library::new(dir.path(), &db, "Kind Sort Library").expect("failed to create library");
1425
1426 for (fp_hex, path, title, kind) in [
1427 ("0000000000000A01", "book.pdf", "PDF Book", "pdf"),
1428 ("0000000000000A02", "book.epub", "EPUB Book", "epub"),
1429 ("0000000000000A03", "book.cbz", "CBZ Book", "cbz"),
1430 ] {
1431 let fp = Fp::from_str(fp_hex).expect("invalid fp");
1432 let mut info = make_info(path, title, fp);
1433 info.file.kind = kind.to_string();
1434 lib.db
1435 .insert_book(lib.library_id, fp, &info)
1436 .expect("failed to insert book");
1437 }
1438
1439 lib.db
1440 .compute_sort_keys(lib.library_id)
1441 .expect("compute_sort_keys failed");
1442
1443 let (books, total) = lib
1444 .db
1445 .page_books(
1446 lib.library_id,
1447 Path::new(""),
1448 SortMethod::Kind,
1449 false,
1450 10,
1451 0,
1452 )
1453 .expect("page_books with Kind sort failed");
1454
1455 assert_eq!(total, 3);
1456 assert_eq!(books[0].title, "CBZ Book");
1458 assert_eq!(books[1].title, "EPUB Book");
1459 assert_eq!(books[2].title, "PDF Book");
1460 }
1461
1462 #[test]
1463 fn page_books_added_sort_orders_by_insertion_time() {
1464 use chrono::NaiveDateTime;
1465 let dir = tempfile::tempdir().expect("failed to create temp dir");
1466 let mut db = Database::new(":memory:").expect("failed to create in-memory database");
1467 db.init(0).expect("failed to run migrations");
1468
1469 let lib =
1470 Library::new(dir.path(), &db, "Added Sort Library").expect("failed to create library");
1471
1472 let t0 = NaiveDateTime::parse_from_str("2020-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
1473 .expect("invalid datetime");
1474 let t1 = NaiveDateTime::parse_from_str("2021-06-15 12:00:00", "%Y-%m-%d %H:%M:%S")
1475 .expect("invalid datetime");
1476 let t2 = NaiveDateTime::parse_from_str("2023-03-20 08:30:00", "%Y-%m-%d %H:%M:%S")
1477 .expect("invalid datetime");
1478
1479 for (fp_hex, path, title, added) in [
1480 ("0000000000000B01", "old.pdf", "Old Book", t0),
1481 ("0000000000000B02", "recent.pdf", "Recent Book", t2),
1482 ("0000000000000B03", "mid.pdf", "Middle Book", t1),
1483 ] {
1484 let fp = Fp::from_str(fp_hex).expect("invalid fp");
1485 let mut info = make_info(path, title, fp);
1486 info.added = added;
1487 lib.db
1488 .insert_book(lib.library_id, fp, &info)
1489 .expect("failed to insert book");
1490 }
1491
1492 lib.db
1493 .compute_sort_keys(lib.library_id)
1494 .expect("compute_sort_keys failed");
1495
1496 let (books, total) = lib
1497 .db
1498 .page_books(
1499 lib.library_id,
1500 Path::new(""),
1501 SortMethod::Added,
1502 false,
1503 10,
1504 0,
1505 )
1506 .expect("page_books with Added sort failed");
1507
1508 assert_eq!(total, 3);
1509 assert_eq!(books[0].title, "Old Book");
1510 assert_eq!(books[1].title, "Middle Book");
1511 assert_eq!(books[2].title, "Recent Book");
1512 }
1513
1514 #[test]
1515 fn page_books_opened_sort_orders_by_last_opened_time() {
1516 use chrono::NaiveDateTime;
1517 let dir = tempfile::tempdir().expect("failed to create temp dir");
1518 let mut db = Database::new(":memory:").expect("failed to create in-memory database");
1519 db.init(0).expect("failed to run migrations");
1520
1521 let lib =
1522 Library::new(dir.path(), &db, "Opened Sort Library").expect("failed to create library");
1523
1524 let t0 = NaiveDateTime::parse_from_str("2020-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
1525 .expect("invalid datetime");
1526 let t1 = NaiveDateTime::parse_from_str("2022-09-10 09:00:00", "%Y-%m-%d %H:%M:%S")
1527 .expect("invalid datetime");
1528 let t2 = NaiveDateTime::parse_from_str("2024-04-01 17:45:00", "%Y-%m-%d %H:%M:%S")
1529 .expect("invalid datetime");
1530
1531 for (fp_hex, path, title, opened) in [
1532 ("0000000000000C01", "oldest.pdf", "Oldest Opened", t0),
1533 ("0000000000000C02", "newest.pdf", "Newest Opened", t2),
1534 ("0000000000000C03", "middle.pdf", "Middle Opened", t1),
1535 ] {
1536 let fp = Fp::from_str(fp_hex).expect("invalid fp");
1537 let mut info = make_info(path, title, fp);
1538 info.reader_info = Some(ReaderInfo {
1539 opened,
1540 pages_count: 100,
1541 ..Default::default()
1542 });
1543 info.reader = info.reader_info.clone();
1544 lib.db
1545 .insert_book(lib.library_id, fp, &info)
1546 .expect("failed to insert book");
1547 }
1548
1549 lib.db
1550 .compute_sort_keys(lib.library_id)
1551 .expect("compute_sort_keys failed");
1552
1553 let (books, total) = lib
1554 .db
1555 .page_books(
1556 lib.library_id,
1557 Path::new(""),
1558 SortMethod::Opened,
1559 false,
1560 10,
1561 0,
1562 )
1563 .expect("page_books with Opened sort failed");
1564
1565 assert_eq!(total, 3);
1566 assert_eq!(books[0].title, "Oldest Opened");
1567 assert_eq!(books[1].title, "Middle Opened");
1568 assert_eq!(books[2].title, "Newest Opened");
1569 }
1570
1571 #[test]
1572 fn page_books_year_sort_orders_by_publication_year() {
1573 let dir = tempfile::tempdir().expect("failed to create temp dir");
1574 let mut db = Database::new(":memory:").expect("failed to create in-memory database");
1575 db.init(0).expect("failed to run migrations");
1576
1577 let lib =
1578 Library::new(dir.path(), &db, "Year Sort Library").expect("failed to create library");
1579
1580 for (fp_hex, path, title, year) in [
1581 ("0000000000000D01", "modern.pdf", "Modern Book", "2020"),
1582 ("0000000000000D02", "old.pdf", "Old Book", "1990"),
1583 ("0000000000000D03", "ancient.pdf", "Ancient Book", "1850"),
1584 ] {
1585 let fp = Fp::from_str(fp_hex).expect("invalid fp");
1586 let mut info = make_info(path, title, fp);
1587 info.year = year.to_string();
1588 lib.db
1589 .insert_book(lib.library_id, fp, &info)
1590 .expect("failed to insert book");
1591 }
1592
1593 lib.db
1594 .compute_sort_keys(lib.library_id)
1595 .expect("compute_sort_keys failed");
1596
1597 let (books, total) = lib
1598 .db
1599 .page_books(
1600 lib.library_id,
1601 Path::new(""),
1602 SortMethod::Year,
1603 false,
1604 10,
1605 0,
1606 )
1607 .expect("page_books with Year sort failed");
1608
1609 assert_eq!(total, 3);
1610 assert_eq!(books[0].title, "Ancient Book");
1611 assert_eq!(books[1].title, "Old Book");
1612 assert_eq!(books[2].title, "Modern Book");
1613 }
1614
1615 #[test]
1616 fn page_books_count_query_respects_prefix_filter() {
1617 let dir = tempfile::tempdir().expect("failed to create temp dir");
1618 let mut db = Database::new(":memory:").expect("failed to create in-memory database");
1619 db.init(0).expect("failed to run migrations");
1620
1621 let lib = Library::new(dir.path(), &db, "Prefix Count Library")
1622 .expect("failed to create library");
1623
1624 for (fp_hex, path, title) in [
1625 ("0000000000000E01", "fiction/book1.pdf", "Fiction One"),
1626 ("0000000000000E02", "fiction/book2.pdf", "Fiction Two"),
1627 ("0000000000000E03", "nonfiction/book3.pdf", "Nonfiction One"),
1628 ] {
1629 let fp = Fp::from_str(fp_hex).expect("invalid fp");
1630 lib.db
1631 .insert_book(lib.library_id, fp, &make_info(path, title, fp))
1632 .expect("failed to insert book");
1633 }
1634
1635 lib.db
1636 .compute_sort_keys(lib.library_id)
1637 .expect("compute_sort_keys failed");
1638
1639 let (books, total) = lib
1640 .db
1641 .page_books(
1642 lib.library_id,
1643 Path::new("fiction"),
1644 SortMethod::Title,
1645 false,
1646 10,
1647 0,
1648 )
1649 .expect("page_books with prefix filter failed");
1650
1651 assert_eq!(total, 2, "count query should reflect the prefix filter");
1652 assert_eq!(books.len(), 2);
1653 assert!(books.iter().all(|b| b.title.starts_with("Fiction")));
1654 }
1655
1656 #[test]
1657 fn resolve_fingerprint_prefers_db_and_falls_back_to_filesystem() {
1658 let dir = tempfile::tempdir().expect("failed to create temp dir");
1659 let mut db = Database::new(":memory:").expect("failed to create in-memory database");
1660 db.init(0).expect("failed to run migrations");
1661
1662 let lib =
1663 Library::new(dir.path(), &db, "Fingerprint Library").expect("failed to create library");
1664
1665 let stored_fp = Fp::from_str("0000000000000201").expect("invalid stored fingerprint");
1666 let stored_info = make_info("stored.pdf", "Stored", stored_fp);
1667 lib.db
1668 .insert_book(lib.library_id, stored_fp, &stored_info)
1669 .expect("failed to insert stored book");
1670
1671 assert_eq!(
1672 lib.resolve_fingerprint(Path::new("stored.pdf")),
1673 Some(stored_fp)
1674 );
1675
1676 let fallback_path = dir.path().join("fallback.pdf");
1677 fs::write(&fallback_path, b"fallback content").expect("failed to write fallback file");
1678 let expected_fallback_fp = fallback_path
1679 .fingerprint()
1680 .expect("failed to fingerprint fallback file");
1681
1682 assert_eq!(
1683 lib.resolve_fingerprint(Path::new("fallback.pdf")),
1684 Some(expected_fallback_fp)
1685 );
1686 assert_eq!(lib.resolve_fingerprint(Path::new("missing.pdf")), None);
1687 }
1688}