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::{bail, format_err, Error};
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 flush(&mut self) {}
617
618 pub fn is_empty(&self) -> Option<bool> {
619 self.db
620 .count_books(self.library_id)
621 .ok()
622 .map(|count| count == 0)
623 }
624
625 pub fn next_book_after(&self, fp: Fp) -> Option<Info> {
626 let mut books: Vec<Info> = self
627 .db
628 .list_books_under_prefix(self.library_id, Path::new(""))
629 .ok()?;
630
631 if books.is_empty() {
632 return None;
633 }
634
635 books.sort_by(|left, right| {
636 let ordering = sorter(self.sort_method)(left, right);
637 if self.reverse_order {
638 ordering.reverse()
639 } else {
640 ordering
641 }
642 });
643
644 let current_index = books
645 .iter()
646 .position(|candidate| candidate.fp == Some(fp))?;
647 books.into_iter().nth(current_index + 1)
648 }
649
650 pub fn most_recently_opened_reading_book(&self) -> Option<Info> {
651 self.db
652 .most_recently_opened_reading_book(self.library_id)
653 .map_err(|e| {
654 error!(error = %e, library_id = self.library_id, "failed to get most recently opened reading book");
655 })
656 .ok()
657 .flatten()
658 }
659
660 fn resolve_fingerprint(&self, path: &Path) -> Option<Fp> {
661 match self.db.get_book_by_path(self.library_id, path) {
662 Ok(Some(info)) => {
663 if let Some(fp) = info.fp {
664 return Some(fp);
665 }
666 }
667 Ok(None) => {}
668 Err(e) => {
669 error!(
670 path = %path.display(),
671 error = %e,
672 "failed to resolve fingerprint from database"
673 );
674 }
675 }
676
677 let full_path = self.home.join(path);
678
679 match full_path.fingerprint() {
680 Ok(fp) => Some(fp),
681 Err(e) => {
682 error!(path = %full_path.display(), error = %e, "failed to fingerprint path");
683 None
684 }
685 }
686 }
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692 use crate::db::Database;
693 use crate::geom::CycleDir;
694 use crate::metadata::FileInfo;
695 use crate::settings::ImportSettings;
696 use crate::task::ShutdownSignal;
697 use std::str::FromStr;
698 use std::sync::mpsc;
699
700 fn setup_library_with_book(
701 dir: &Path,
702 db: &Database,
703 name: &str,
704 filename: &str,
705 ) -> (Library, PathBuf) {
706 let lib = Library::new(dir, db, name).expect("failed to create library");
707 fs::write(dir.join(filename), b"dummy book content").expect("failed to write test file");
708 let (tx, _rx) = mpsc::channel();
709 let notif_id = crate::view::ViewId::MessageNotif(0);
710 let shutdown = ShutdownSignal::never();
711 importer::run(
712 &lib.db,
713 lib.library_id,
714 dir,
715 &ImportSettings::default(),
716 &tx,
717 notif_id,
718 &shutdown,
719 );
720 (lib, PathBuf::from(filename))
721 }
722
723 fn make_info(path: &str, title: &str, fp: Fp) -> Info {
724 Info {
725 title: title.to_string(),
726 file: FileInfo {
727 path: PathBuf::from(path),
728 absolute_path: PathBuf::from(format!("/library/{path}")),
729 kind: "pdf".to_string(),
730 size: 1024,
731 },
732 fp: Some(fp),
733 ..Default::default()
734 }
735 }
736
737 fn make_status_info(path: &str, title: &str, fp: Fp, status: SimpleStatus) -> Info {
738 let mut info = make_info(path, title, fp);
739 let reader = match status {
740 SimpleStatus::New => None,
741 SimpleStatus::Reading => Some(ReaderInfo {
742 current_page: 1,
743 pages_count: 10,
744 finished: false,
745 ..Default::default()
746 }),
747 SimpleStatus::Finished => Some(ReaderInfo {
748 current_page: 10,
749 pages_count: 10,
750 finished: true,
751 ..Default::default()
752 }),
753 };
754 info.reader = reader.clone();
755 info.reader_info = reader;
756 info
757 }
758
759 #[test]
760 fn copy_to_sets_absolute_path_in_destination() {
761 let src_dir = tempfile::tempdir().expect("failed to create src temp dir");
762 let dst_dir = tempfile::tempdir().expect("failed to create dst temp dir");
763 let db = Database::new(":memory:").expect("failed to create in-memory database");
764 db.migrate().expect("failed to run migrations");
765
766 let (mut src_lib, rel_path) =
767 setup_library_with_book(src_dir.path(), &db, "Source", "book.epub");
768 let mut dst_lib =
769 Library::new(dst_dir.path(), &db, "Destination").expect("failed to create dst lib");
770
771 let src_books = src_lib
772 .db
773 .get_all_books(src_lib.library_id)
774 .expect("failed to get src books");
775 assert!(
776 !src_books.is_empty(),
777 "source library should contain the book"
778 );
779
780 src_lib
781 .copy_to(&rel_path, &mut dst_lib)
782 .expect("copy_to failed");
783
784 let dst_books = dst_lib
785 .db
786 .get_all_books(dst_lib.library_id)
787 .expect("failed to get dst books");
788
789 let dst_info = dst_books
790 .into_iter()
791 .next()
792 .expect("destination library should contain the copied book");
793
794 let expected_abs = dst_dir.path().join(&dst_info.file.path);
795 assert_eq!(
796 dst_info.file.absolute_path, expected_abs,
797 "absolute_path should point to the destination file after copy_to"
798 );
799 assert!(
800 dst_info.file.absolute_path.exists(),
801 "absolute_path should point to an existing file"
802 );
803 }
804
805 #[test]
806 fn move_to_sets_absolute_path_in_destination() {
807 let src_dir = tempfile::tempdir().expect("failed to create src temp dir");
808 let dst_dir = tempfile::tempdir().expect("failed to create dst temp dir");
809 let db = Database::new(":memory:").expect("failed to create in-memory database");
810 db.migrate().expect("failed to run migrations");
811
812 let (mut src_lib, rel_path) =
813 setup_library_with_book(src_dir.path(), &db, "Source", "book.epub");
814 let mut dst_lib =
815 Library::new(dst_dir.path(), &db, "Destination").expect("failed to create dst lib");
816
817 let src_books = src_lib
818 .db
819 .get_all_books(src_lib.library_id)
820 .expect("failed to get src books");
821 assert!(
822 !src_books.is_empty(),
823 "source library should contain the book"
824 );
825
826 src_lib
827 .move_to(&rel_path, &mut dst_lib)
828 .expect("move_to failed");
829
830 let src_books_after = src_lib
831 .db
832 .get_all_books(src_lib.library_id)
833 .expect("failed to get src books after move");
834 assert!(
835 src_books_after.is_empty(),
836 "source library should no longer contain the book after move"
837 );
838
839 let dst_books = dst_lib
840 .db
841 .get_all_books(dst_lib.library_id)
842 .expect("failed to get dst books");
843
844 let dst_info = dst_books
845 .into_iter()
846 .next()
847 .expect("destination library should contain the moved book");
848
849 let expected_abs = dst_dir.path().join(&dst_info.file.path);
850 assert_eq!(
851 dst_info.file.absolute_path, expected_abs,
852 "absolute_path should point to the destination file after move_to"
853 );
854 assert!(
855 dst_info.file.absolute_path.exists(),
856 "absolute_path should point to an existing file"
857 );
858 }
859
860 #[test]
861 fn neighbor_status_change_page_finds_next_and_previous_boundaries() {
862 let dir = tempfile::tempdir().expect("failed to create temp dir");
863 let db = Database::new(":memory:").expect("failed to create in-memory database");
864 db.migrate().expect("failed to run migrations");
865
866 let lib =
867 Library::new(dir.path(), &db, "Status Library").expect("failed to create library");
868
869 let statuses = [
870 SimpleStatus::New,
871 SimpleStatus::New,
872 SimpleStatus::Reading,
873 SimpleStatus::Reading,
874 SimpleStatus::Finished,
875 SimpleStatus::Finished,
876 ];
877
878 for (index, status) in statuses.into_iter().enumerate() {
879 let fp = Fp::from_str(&format!("{:016X}", index + 1)).expect("invalid fingerprint");
880 let info = make_status_info(
881 &format!("book-{}.pdf", index + 1),
882 &format!("Book {}", index + 1),
883 fp,
884 status,
885 );
886 lib.db
887 .insert_book(lib.library_id, fp, &info)
888 .expect("failed to insert book");
889 }
890
891 assert_eq!(
892 lib.neighbor_status_change_page(dir.path(), None, 0, 2, CycleDir::Next)
893 .expect("next boundary lookup failed"),
894 Some(1)
895 );
896 assert_eq!(
897 lib.neighbor_status_change_page(dir.path(), None, 2, 2, CycleDir::Previous)
898 .expect("previous boundary lookup failed"),
899 Some(1)
900 );
901 assert_eq!(
902 lib.neighbor_status_change_page(dir.path(), None, 2, 2, CycleDir::Next)
903 .expect("terminal next lookup failed"),
904 None
905 );
906 assert_eq!(
907 lib.neighbor_status_change_page(dir.path(), None, 0, 0, CycleDir::Next)
908 .expect("zero page size lookup failed"),
909 None
910 );
911 }
912
913 #[test]
914 fn next_book_after_returns_following_book_in_title_order() {
915 let dir = tempfile::tempdir().expect("failed to create temp dir");
916 let db = Database::new(":memory:").expect("failed to create in-memory database");
917 db.migrate().expect("failed to run migrations");
918
919 let mut lib =
920 Library::new(dir.path(), &db, "Next Book Library").expect("failed to create library");
921 lib.sort_method = SortMethod::Title;
922 lib.reverse_order = false;
923
924 let alpha_fp = Fp::from_str("0000000000000101").expect("invalid alpha fingerprint");
925 let beta_fp = Fp::from_str("0000000000000102").expect("invalid beta fingerprint");
926 let gamma_fp = Fp::from_str("0000000000000103").expect("invalid gamma fingerprint");
927
928 for (fp, title, path) in [
929 (beta_fp, "Beta", "beta.pdf"),
930 (gamma_fp, "Gamma", "gamma.pdf"),
931 (alpha_fp, "Alpha", "alpha.pdf"),
932 ] {
933 let info = make_info(path, title, fp);
934 lib.db
935 .insert_book(lib.library_id, fp, &info)
936 .expect("failed to insert book");
937 }
938
939 let next = lib
940 .next_book_after(alpha_fp)
941 .expect("alpha should have a next book");
942 assert_eq!(next.fp, Some(beta_fp));
943 assert_eq!(next.title, "Beta");
944
945 let last = lib.next_book_after(gamma_fp);
946 assert!(last.is_none(), "last book should not have a successor");
947
948 let missing = lib.next_book_after(
949 Fp::from_str("00000000000001FF").expect("invalid missing fingerprint"),
950 );
951 assert!(missing.is_none(), "missing fingerprint should return none");
952 }
953
954 #[test]
955 fn compute_sort_keys_assigns_correct_title_ranks() {
956 let dir = tempfile::tempdir().expect("failed to create temp dir");
957 let db = Database::new(":memory:").expect("failed to create in-memory database");
958 db.migrate().expect("failed to run migrations");
959
960 let lib =
961 Library::new(dir.path(), &db, "Sort Keys Library").expect("failed to create library");
962
963 let fp_a = Fp::from_str("0000000000000301").expect("invalid fp");
964 let fp_b = Fp::from_str("0000000000000302").expect("invalid fp");
965 let fp_c = Fp::from_str("0000000000000303").expect("invalid fp");
966
967 for (fp, title, path) in [
969 (fp_c, "Zebra", "zebra.pdf"),
970 (fp_a, "Apple", "apple.pdf"),
971 (fp_b, "Mango", "mango.pdf"),
972 ] {
973 lib.db
974 .insert_book(lib.library_id, fp, &make_info(path, title, fp))
975 .expect("failed to insert book");
976 }
977
978 lib.db
979 .compute_sort_keys(lib.library_id)
980 .expect("compute_sort_keys failed");
981
982 let (books, total) = lib
984 .db
985 .page_books(
986 lib.library_id,
987 Path::new(""),
988 SortMethod::Title,
989 false,
990 10,
991 0,
992 )
993 .expect("page_books failed");
994
995 assert_eq!(total, 3);
996 assert_eq!(
997 books.iter().map(|b| b.title.as_str()).collect::<Vec<_>>(),
998 vec!["Apple", "Mango", "Zebra"]
999 );
1000 }
1001
1002 #[test]
1003 fn page_books_paginates_correctly() {
1004 let dir = tempfile::tempdir().expect("failed to create temp dir");
1005 let db = Database::new(":memory:").expect("failed to create in-memory database");
1006 db.migrate().expect("failed to run migrations");
1007
1008 let lib =
1009 Library::new(dir.path(), &db, "Pagination Library").expect("failed to create library");
1010
1011 for i in 1u8..=5 {
1012 let fp = Fp::from_str(&format!("{:016X}", i)).expect("invalid fingerprint");
1013 let title = format!("Book {:02}", i);
1014 let path = format!("book{i}.pdf");
1015 lib.db
1016 .insert_book(lib.library_id, fp, &make_info(&path, &title, fp))
1017 .expect("failed to insert book");
1018 }
1019
1020 lib.db
1021 .compute_sort_keys(lib.library_id)
1022 .expect("compute_sort_keys failed");
1023
1024 let (page0, total) = lib
1026 .db
1027 .page_books(
1028 lib.library_id,
1029 Path::new(""),
1030 SortMethod::Title,
1031 false,
1032 2,
1033 0,
1034 )
1035 .expect("page_books page 0 failed");
1036 assert_eq!(total, 5);
1037 assert_eq!(page0.len(), 2);
1038 assert_eq!(page0[0].title, "Book 01");
1039 assert_eq!(page0[1].title, "Book 02");
1040
1041 let (page1, _) = lib
1043 .db
1044 .page_books(
1045 lib.library_id,
1046 Path::new(""),
1047 SortMethod::Title,
1048 false,
1049 2,
1050 2,
1051 )
1052 .expect("page_books page 1 failed");
1053 assert_eq!(page1.len(), 2);
1054 assert_eq!(page1[0].title, "Book 03");
1055 assert_eq!(page1[1].title, "Book 04");
1056
1057 let (page2, _) = lib
1059 .db
1060 .page_books(
1061 lib.library_id,
1062 Path::new(""),
1063 SortMethod::Title,
1064 false,
1065 2,
1066 4,
1067 )
1068 .expect("page_books page 2 failed");
1069 assert_eq!(page2.len(), 1);
1070 assert_eq!(page2[0].title, "Book 05");
1071 }
1072
1073 #[test]
1074 fn page_books_reverse_order_reverses_results() {
1075 let dir = tempfile::tempdir().expect("failed to create temp dir");
1076 let db = Database::new(":memory:").expect("failed to create in-memory database");
1077 db.migrate().expect("failed to run migrations");
1078
1079 let lib =
1080 Library::new(dir.path(), &db, "Reverse Library").expect("failed to create library");
1081
1082 for (fp_hex, title, path) in [
1083 ("0000000000000401", "Alpha", "alpha.pdf"),
1084 ("0000000000000402", "Beta", "beta.pdf"),
1085 ("0000000000000403", "Gamma", "gamma.pdf"),
1086 ] {
1087 let fp = Fp::from_str(fp_hex).expect("invalid fp");
1088 lib.db
1089 .insert_book(lib.library_id, fp, &make_info(path, title, fp))
1090 .expect("failed to insert book");
1091 }
1092
1093 lib.db
1094 .compute_sort_keys(lib.library_id)
1095 .expect("compute_sort_keys failed");
1096
1097 let (books, _) = lib
1098 .db
1099 .page_books(
1100 lib.library_id,
1101 Path::new(""),
1102 SortMethod::Title,
1103 true,
1104 10,
1105 0,
1106 )
1107 .expect("page_books failed");
1108
1109 assert_eq!(
1110 books.iter().map(|b| b.title.as_str()).collect::<Vec<_>>(),
1111 vec!["Gamma", "Beta", "Alpha"]
1112 );
1113 }
1114
1115 #[test]
1116 fn page_method_uses_db_pagination_without_query() {
1117 let dir = tempfile::tempdir().expect("failed to create temp dir");
1118 let db = Database::new(":memory:").expect("failed to create in-memory database");
1119 db.migrate().expect("failed to run migrations");
1120
1121 let mut lib =
1122 Library::new(dir.path(), &db, "Page Method Library").expect("failed to create library");
1123 lib.sort_method = SortMethod::Title;
1124 lib.reverse_order = false;
1125
1126 for (fp_hex, title, path) in [
1127 ("0000000000000501", "Charlie", "c.pdf"),
1128 ("0000000000000502", "Alice", "a.pdf"),
1129 ("0000000000000503", "Bob", "b.pdf"),
1130 ] {
1131 let fp = Fp::from_str(fp_hex).expect("invalid fp");
1132 lib.db
1133 .insert_book(lib.library_id, fp, &make_info(path, title, fp))
1134 .expect("failed to insert book");
1135 }
1136
1137 lib.db
1138 .compute_sort_keys(lib.library_id)
1139 .expect("compute_sort_keys failed");
1140
1141 let result = lib.page(dir.path(), None, 0, 2).expect("page failed");
1142
1143 assert_eq!(result.total_count, 3);
1144 assert_eq!(result.books.len(), 2);
1145 assert_eq!(result.books[0].title, "Alice");
1146 assert_eq!(result.books[1].title, "Bob");
1147 }
1148
1149 #[test]
1150 fn list_subdirectory_returns_correct_absolute_paths() {
1151 let dir = tempfile::tempdir().expect("failed to create temp dir");
1152 let db = Database::new(":memory:").expect("failed to create in-memory database");
1153 db.migrate().expect("failed to run migrations");
1154
1155 let lib =
1156 Library::new(dir.path(), &db, "Dir Nav Library").expect("failed to create library");
1157
1158 for (fp_hex, path, title) in [
1160 (
1161 "0000000000001001",
1162 "fiction/fantasy/book1.pdf",
1163 "Fantasy One",
1164 ),
1165 ("0000000000001002", "fiction/scifi/book2.pdf", "SciFi One"),
1166 ("0000000000001003", "nonfiction/book3.pdf", "Nonfiction One"),
1167 ] {
1168 let fp = Fp::from_str(fp_hex).expect("invalid fp");
1169 lib.db
1170 .insert_book(lib.library_id, fp, &make_info(path, title, fp))
1171 .expect("failed to insert book");
1172 }
1173
1174 let (_, root_dirs) = lib.list(dir.path(), None, true);
1176 let root_dir_paths: Vec<_> = root_dirs.iter().collect();
1177 assert_eq!(root_dir_paths.len(), 2);
1178 assert!(root_dirs.contains(&dir.path().join("fiction")));
1179 assert!(root_dirs.contains(&dir.path().join("nonfiction")));
1180
1181 let fiction_prefix = dir.path().join("fiction");
1184 let (_, fiction_dirs) = lib.list(&fiction_prefix, None, true);
1185 assert_eq!(
1186 fiction_dirs.len(),
1187 2,
1188 "expected exactly 2 subdirs under fiction"
1189 );
1190 assert!(
1191 fiction_dirs.contains(&fiction_prefix.join("fantasy")),
1192 "expected fiction/fantasy, got: {fiction_dirs:?}"
1193 );
1194 assert!(
1195 fiction_dirs.contains(&fiction_prefix.join("scifi")),
1196 "expected fiction/scifi, got: {fiction_dirs:?}"
1197 );
1198 }
1199
1200 #[test]
1201 fn page_books_status_sort_orders_finished_new_reading() {
1202 let dir = tempfile::tempdir().expect("failed to create temp dir");
1203 let db = Database::new(":memory:").expect("failed to create in-memory database");
1204 db.migrate().expect("failed to run migrations");
1205
1206 let lib =
1207 Library::new(dir.path(), &db, "Status Sort Library").expect("failed to create library");
1208
1209 let fp_new = Fp::from_str("0000000000000601").expect("invalid fp");
1210 let fp_reading = Fp::from_str("0000000000000602").expect("invalid fp");
1211 let fp_finished = Fp::from_str("0000000000000603").expect("invalid fp");
1212
1213 for (fp, status, path, title) in [
1214 (fp_new, SimpleStatus::New, "new.pdf", "New Book"),
1215 (
1216 fp_reading,
1217 SimpleStatus::Reading,
1218 "reading.pdf",
1219 "Reading Book",
1220 ),
1221 (
1222 fp_finished,
1223 SimpleStatus::Finished,
1224 "finished.pdf",
1225 "Finished Book",
1226 ),
1227 ] {
1228 lib.db
1229 .insert_book(
1230 lib.library_id,
1231 fp,
1232 &make_status_info(path, title, fp, status),
1233 )
1234 .expect("failed to insert book");
1235 }
1236
1237 lib.db
1238 .compute_sort_keys(lib.library_id)
1239 .expect("compute_sort_keys failed");
1240
1241 let (books, total) = lib
1242 .db
1243 .page_books(
1244 lib.library_id,
1245 Path::new(""),
1246 SortMethod::Status,
1247 false,
1248 10,
1249 0,
1250 )
1251 .expect("page_books with Status sort failed");
1252
1253 assert_eq!(total, 3);
1254 assert_eq!(books[0].title, "Finished Book");
1256 assert_eq!(books[1].title, "New Book");
1257 assert_eq!(books[2].title, "Reading Book");
1258 }
1259
1260 #[test]
1261 fn page_books_progress_sort_orders_by_completion() {
1262 let dir = tempfile::tempdir().expect("failed to create temp dir");
1263 let db = Database::new(":memory:").expect("failed to create in-memory database");
1264 db.migrate().expect("failed to run migrations");
1265
1266 let lib = Library::new(dir.path(), &db, "Progress Sort Library")
1267 .expect("failed to create library");
1268
1269 let fp_new = Fp::from_str("0000000000000701").expect("invalid fp");
1270 let fp_halfway = Fp::from_str("0000000000000702").expect("invalid fp");
1271 let fp_finished = Fp::from_str("0000000000000703").expect("invalid fp");
1272
1273 let mut info_halfway = make_info("halfway.pdf", "Halfway Book", fp_halfway);
1274 info_halfway.reader_info = Some(ReaderInfo {
1275 current_page: 5,
1276 pages_count: 10,
1277 finished: false,
1278 ..Default::default()
1279 });
1280 info_halfway.reader = info_halfway.reader_info.clone();
1281
1282 for (fp, info) in [
1283 (
1284 fp_new,
1285 make_status_info("new.pdf", "New Book", fp_new, SimpleStatus::New),
1286 ),
1287 (fp_halfway, info_halfway),
1288 (
1289 fp_finished,
1290 make_status_info(
1291 "finished.pdf",
1292 "Finished Book",
1293 fp_finished,
1294 SimpleStatus::Finished,
1295 ),
1296 ),
1297 ] {
1298 lib.db
1299 .insert_book(lib.library_id, fp, &info)
1300 .expect("failed to insert book");
1301 }
1302
1303 lib.db
1304 .compute_sort_keys(lib.library_id)
1305 .expect("compute_sort_keys failed");
1306
1307 let (books, total) = lib
1308 .db
1309 .page_books(
1310 lib.library_id,
1311 Path::new(""),
1312 SortMethod::Progress,
1313 false,
1314 10,
1315 0,
1316 )
1317 .expect("page_books with Progress sort failed");
1318
1319 assert_eq!(total, 3);
1320 assert_eq!(books[0].title, "Finished Book");
1322 assert_eq!(books[1].title, "New Book");
1323 assert_eq!(books[2].title, "Halfway Book");
1324 }
1325
1326 #[test]
1327 fn page_books_pages_sort_orders_by_page_count() {
1328 let dir = tempfile::tempdir().expect("failed to create temp dir");
1329 let db = Database::new(":memory:").expect("failed to create in-memory database");
1330 db.migrate().expect("failed to run migrations");
1331
1332 let lib =
1333 Library::new(dir.path(), &db, "Pages Sort Library").expect("failed to create library");
1334
1335 for (fp_hex, path, title, pages) in [
1336 ("0000000000000801", "big.pdf", "Big Book", 500usize),
1337 ("0000000000000802", "tiny.pdf", "Tiny Book", 50),
1338 ("0000000000000803", "medium.pdf", "Medium Book", 200),
1339 ] {
1340 let fp = Fp::from_str(fp_hex).expect("invalid fp");
1341 let mut info = make_info(path, title, fp);
1342 info.reader_info = Some(ReaderInfo {
1343 pages_count: pages,
1344 ..Default::default()
1345 });
1346 info.reader = info.reader_info.clone();
1347 lib.db
1348 .insert_book(lib.library_id, fp, &info)
1349 .expect("failed to insert book");
1350 }
1351
1352 lib.db
1353 .compute_sort_keys(lib.library_id)
1354 .expect("compute_sort_keys failed");
1355
1356 let (books, total) = lib
1357 .db
1358 .page_books(
1359 lib.library_id,
1360 Path::new(""),
1361 SortMethod::Pages,
1362 false,
1363 10,
1364 0,
1365 )
1366 .expect("page_books with Pages sort failed");
1367
1368 assert_eq!(total, 3);
1369 assert_eq!(books[0].title, "Tiny Book");
1370 assert_eq!(books[1].title, "Medium Book");
1371 assert_eq!(books[2].title, "Big Book");
1372 }
1373
1374 #[test]
1375 fn page_books_size_sort_orders_by_file_size() {
1376 let dir = tempfile::tempdir().expect("failed to create temp dir");
1377 let db = Database::new(":memory:").expect("failed to create in-memory database");
1378 db.migrate().expect("failed to run migrations");
1379
1380 let lib =
1381 Library::new(dir.path(), &db, "Size Sort Library").expect("failed to create library");
1382
1383 for (fp_hex, path, title, size) in [
1384 ("0000000000000901", "big.pdf", "Big Book", 9000u64),
1385 ("0000000000000902", "tiny.pdf", "Tiny Book", 100),
1386 ("0000000000000903", "medium.pdf", "Medium Book", 4500),
1387 ] {
1388 let fp = Fp::from_str(fp_hex).expect("invalid fp");
1389 let mut info = make_info(path, title, fp);
1390 info.file.size = size;
1391 lib.db
1392 .insert_book(lib.library_id, fp, &info)
1393 .expect("failed to insert book");
1394 }
1395
1396 lib.db
1397 .compute_sort_keys(lib.library_id)
1398 .expect("compute_sort_keys failed");
1399
1400 let (books, total) = lib
1401 .db
1402 .page_books(
1403 lib.library_id,
1404 Path::new(""),
1405 SortMethod::Size,
1406 false,
1407 10,
1408 0,
1409 )
1410 .expect("page_books with Size sort failed");
1411
1412 assert_eq!(total, 3);
1413 assert_eq!(books[0].title, "Tiny Book");
1414 assert_eq!(books[1].title, "Medium Book");
1415 assert_eq!(books[2].title, "Big Book");
1416 }
1417
1418 #[test]
1419 fn page_books_kind_sort_orders_alphabetically_by_file_kind() {
1420 let dir = tempfile::tempdir().expect("failed to create temp dir");
1421 let db = Database::new(":memory:").expect("failed to create in-memory database");
1422 db.migrate().expect("failed to run migrations");
1423
1424 let lib =
1425 Library::new(dir.path(), &db, "Kind Sort Library").expect("failed to create library");
1426
1427 for (fp_hex, path, title, kind) in [
1428 ("0000000000000A01", "book.pdf", "PDF Book", "pdf"),
1429 ("0000000000000A02", "book.epub", "EPUB Book", "epub"),
1430 ("0000000000000A03", "book.cbz", "CBZ Book", "cbz"),
1431 ] {
1432 let fp = Fp::from_str(fp_hex).expect("invalid fp");
1433 let mut info = make_info(path, title, fp);
1434 info.file.kind = kind.to_string();
1435 lib.db
1436 .insert_book(lib.library_id, fp, &info)
1437 .expect("failed to insert book");
1438 }
1439
1440 lib.db
1441 .compute_sort_keys(lib.library_id)
1442 .expect("compute_sort_keys failed");
1443
1444 let (books, total) = lib
1445 .db
1446 .page_books(
1447 lib.library_id,
1448 Path::new(""),
1449 SortMethod::Kind,
1450 false,
1451 10,
1452 0,
1453 )
1454 .expect("page_books with Kind sort failed");
1455
1456 assert_eq!(total, 3);
1457 assert_eq!(books[0].title, "CBZ Book");
1459 assert_eq!(books[1].title, "EPUB Book");
1460 assert_eq!(books[2].title, "PDF Book");
1461 }
1462
1463 #[test]
1464 fn page_books_added_sort_orders_by_insertion_time() {
1465 use chrono::NaiveDateTime;
1466 let dir = tempfile::tempdir().expect("failed to create temp dir");
1467 let db = Database::new(":memory:").expect("failed to create in-memory database");
1468 db.migrate().expect("failed to run migrations");
1469
1470 let lib =
1471 Library::new(dir.path(), &db, "Added Sort Library").expect("failed to create library");
1472
1473 let t0 = NaiveDateTime::parse_from_str("2020-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
1474 .expect("invalid datetime");
1475 let t1 = NaiveDateTime::parse_from_str("2021-06-15 12:00:00", "%Y-%m-%d %H:%M:%S")
1476 .expect("invalid datetime");
1477 let t2 = NaiveDateTime::parse_from_str("2023-03-20 08:30:00", "%Y-%m-%d %H:%M:%S")
1478 .expect("invalid datetime");
1479
1480 for (fp_hex, path, title, added) in [
1481 ("0000000000000B01", "old.pdf", "Old Book", t0),
1482 ("0000000000000B02", "recent.pdf", "Recent Book", t2),
1483 ("0000000000000B03", "mid.pdf", "Middle Book", t1),
1484 ] {
1485 let fp = Fp::from_str(fp_hex).expect("invalid fp");
1486 let mut info = make_info(path, title, fp);
1487 info.added = added;
1488 lib.db
1489 .insert_book(lib.library_id, fp, &info)
1490 .expect("failed to insert book");
1491 }
1492
1493 lib.db
1494 .compute_sort_keys(lib.library_id)
1495 .expect("compute_sort_keys failed");
1496
1497 let (books, total) = lib
1498 .db
1499 .page_books(
1500 lib.library_id,
1501 Path::new(""),
1502 SortMethod::Added,
1503 false,
1504 10,
1505 0,
1506 )
1507 .expect("page_books with Added sort failed");
1508
1509 assert_eq!(total, 3);
1510 assert_eq!(books[0].title, "Old Book");
1511 assert_eq!(books[1].title, "Middle Book");
1512 assert_eq!(books[2].title, "Recent Book");
1513 }
1514
1515 #[test]
1516 fn page_books_opened_sort_orders_by_last_opened_time() {
1517 use chrono::NaiveDateTime;
1518 let dir = tempfile::tempdir().expect("failed to create temp dir");
1519 let db = Database::new(":memory:").expect("failed to create in-memory database");
1520 db.migrate().expect("failed to run migrations");
1521
1522 let lib =
1523 Library::new(dir.path(), &db, "Opened Sort Library").expect("failed to create library");
1524
1525 let t0 = NaiveDateTime::parse_from_str("2020-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
1526 .expect("invalid datetime");
1527 let t1 = NaiveDateTime::parse_from_str("2022-09-10 09:00:00", "%Y-%m-%d %H:%M:%S")
1528 .expect("invalid datetime");
1529 let t2 = NaiveDateTime::parse_from_str("2024-04-01 17:45:00", "%Y-%m-%d %H:%M:%S")
1530 .expect("invalid datetime");
1531
1532 for (fp_hex, path, title, opened) in [
1533 ("0000000000000C01", "oldest.pdf", "Oldest Opened", t0),
1534 ("0000000000000C02", "newest.pdf", "Newest Opened", t2),
1535 ("0000000000000C03", "middle.pdf", "Middle Opened", t1),
1536 ] {
1537 let fp = Fp::from_str(fp_hex).expect("invalid fp");
1538 let mut info = make_info(path, title, fp);
1539 info.reader_info = Some(ReaderInfo {
1540 opened,
1541 pages_count: 100,
1542 ..Default::default()
1543 });
1544 info.reader = info.reader_info.clone();
1545 lib.db
1546 .insert_book(lib.library_id, fp, &info)
1547 .expect("failed to insert book");
1548 }
1549
1550 lib.db
1551 .compute_sort_keys(lib.library_id)
1552 .expect("compute_sort_keys failed");
1553
1554 let (books, total) = lib
1555 .db
1556 .page_books(
1557 lib.library_id,
1558 Path::new(""),
1559 SortMethod::Opened,
1560 false,
1561 10,
1562 0,
1563 )
1564 .expect("page_books with Opened sort failed");
1565
1566 assert_eq!(total, 3);
1567 assert_eq!(books[0].title, "Oldest Opened");
1568 assert_eq!(books[1].title, "Middle Opened");
1569 assert_eq!(books[2].title, "Newest Opened");
1570 }
1571
1572 #[test]
1573 fn page_books_year_sort_orders_by_publication_year() {
1574 let dir = tempfile::tempdir().expect("failed to create temp dir");
1575 let db = Database::new(":memory:").expect("failed to create in-memory database");
1576 db.migrate().expect("failed to run migrations");
1577
1578 let lib =
1579 Library::new(dir.path(), &db, "Year Sort Library").expect("failed to create library");
1580
1581 for (fp_hex, path, title, year) in [
1582 ("0000000000000D01", "modern.pdf", "Modern Book", "2020"),
1583 ("0000000000000D02", "old.pdf", "Old Book", "1990"),
1584 ("0000000000000D03", "ancient.pdf", "Ancient Book", "1850"),
1585 ] {
1586 let fp = Fp::from_str(fp_hex).expect("invalid fp");
1587 let mut info = make_info(path, title, fp);
1588 info.year = year.to_string();
1589 lib.db
1590 .insert_book(lib.library_id, fp, &info)
1591 .expect("failed to insert book");
1592 }
1593
1594 lib.db
1595 .compute_sort_keys(lib.library_id)
1596 .expect("compute_sort_keys failed");
1597
1598 let (books, total) = lib
1599 .db
1600 .page_books(
1601 lib.library_id,
1602 Path::new(""),
1603 SortMethod::Year,
1604 false,
1605 10,
1606 0,
1607 )
1608 .expect("page_books with Year sort failed");
1609
1610 assert_eq!(total, 3);
1611 assert_eq!(books[0].title, "Ancient Book");
1612 assert_eq!(books[1].title, "Old Book");
1613 assert_eq!(books[2].title, "Modern Book");
1614 }
1615
1616 #[test]
1617 fn page_books_count_query_respects_prefix_filter() {
1618 let dir = tempfile::tempdir().expect("failed to create temp dir");
1619 let db = Database::new(":memory:").expect("failed to create in-memory database");
1620 db.migrate().expect("failed to run migrations");
1621
1622 let lib = Library::new(dir.path(), &db, "Prefix Count Library")
1623 .expect("failed to create library");
1624
1625 for (fp_hex, path, title) in [
1626 ("0000000000000E01", "fiction/book1.pdf", "Fiction One"),
1627 ("0000000000000E02", "fiction/book2.pdf", "Fiction Two"),
1628 ("0000000000000E03", "nonfiction/book3.pdf", "Nonfiction One"),
1629 ] {
1630 let fp = Fp::from_str(fp_hex).expect("invalid fp");
1631 lib.db
1632 .insert_book(lib.library_id, fp, &make_info(path, title, fp))
1633 .expect("failed to insert book");
1634 }
1635
1636 lib.db
1637 .compute_sort_keys(lib.library_id)
1638 .expect("compute_sort_keys failed");
1639
1640 let (books, total) = lib
1641 .db
1642 .page_books(
1643 lib.library_id,
1644 Path::new("fiction"),
1645 SortMethod::Title,
1646 false,
1647 10,
1648 0,
1649 )
1650 .expect("page_books with prefix filter failed");
1651
1652 assert_eq!(total, 2, "count query should reflect the prefix filter");
1653 assert_eq!(books.len(), 2);
1654 assert!(books.iter().all(|b| b.title.starts_with("Fiction")));
1655 }
1656
1657 #[test]
1658 fn resolve_fingerprint_prefers_db_and_falls_back_to_filesystem() {
1659 let dir = tempfile::tempdir().expect("failed to create temp dir");
1660 let db = Database::new(":memory:").expect("failed to create in-memory database");
1661 db.migrate().expect("failed to run migrations");
1662
1663 let lib =
1664 Library::new(dir.path(), &db, "Fingerprint Library").expect("failed to create library");
1665
1666 let stored_fp = Fp::from_str("0000000000000201").expect("invalid stored fingerprint");
1667 let stored_info = make_info("stored.pdf", "Stored", stored_fp);
1668 lib.db
1669 .insert_book(lib.library_id, stored_fp, &stored_info)
1670 .expect("failed to insert stored book");
1671
1672 assert_eq!(
1673 lib.resolve_fingerprint(Path::new("stored.pdf")),
1674 Some(stored_fp)
1675 );
1676
1677 let fallback_path = dir.path().join("fallback.pdf");
1678 fs::write(&fallback_path, b"fallback content").expect("failed to write fallback file");
1679 let expected_fallback_fp = fallback_path
1680 .fingerprint()
1681 .expect("failed to fingerprint fallback file");
1682
1683 assert_eq!(
1684 lib.resolve_fingerprint(Path::new("fallback.pdf")),
1685 Some(expected_fallback_fp)
1686 );
1687 assert_eq!(lib.resolve_fingerprint(Path::new("missing.pdf")), None);
1688 }
1689}