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