1pub mod conversion;
2pub mod models;
3
4use crate::db::Database;
5use crate::db::runtime::RUNTIME;
6use crate::db::types::{FileSize, OptionalUuid7, UnixTimestamp, Uuid7};
7use crate::document::SimpleTocEntry;
8use crate::geom::Point;
9use crate::helpers::Fp;
10use crate::metadata::{
11 CroppingMargins, FileInfo, Info, ReaderInfo, ScrollMode, SortMethod, TextAlign, ZoomMode,
12 alphabetic_author, alphabetic_title, natural_cmp, sorter,
13};
14use crate::settings::FileExtension;
15use anyhow::Error;
16use conversion::{
17 extract_authors, info_to_book_row, reader_info_to_reading_state_row, rows_to_toc_entries,
18};
19use fxhash::{FxHashMap, FxHashSet};
20use models::TocEntryRow;
21use sqlx::sqlite::SqlitePool;
22use std::collections::{BTreeMap, BTreeSet, HashMap};
23use std::path::{Path, PathBuf};
24
25pub use models::{BookHandle, PathUpdate};
26
27const SORT_RANK_STRIDE: i64 = 1_000;
33
34fn midpoint_rank(existing_ranks: &[Option<i64>], pos: usize) -> Option<i64> {
41 let left = if pos == 0 {
42 None
43 } else {
44 existing_ranks.get(pos - 1).copied().flatten()
45 };
46 let right = existing_ranks.get(pos).copied().flatten();
47
48 match (left, right) {
49 (None, None) => Some(SORT_RANK_STRIDE),
50 (None, Some(r)) => {
51 if r <= 1 {
52 None
53 } else {
54 Some(r / 2)
55 }
56 }
57 (Some(l), None) => Some(l + SORT_RANK_STRIDE),
58 (Some(l), Some(r)) => {
59 let mid = (l + r) / 2;
60 if mid <= l { None } else { Some(mid) }
61 }
62 }
63}
64
65#[derive(sqlx::FromRow)]
67struct TitleSortRow {
68 title: String,
69 language: String,
70 file_path: String,
71 sort_title: Option<i64>,
72}
73
74#[derive(sqlx::FromRow)]
76struct AuthorSortRow {
77 authors: Option<String>,
78 sort_author: Option<i64>,
79}
80
81#[derive(sqlx::FromRow)]
83struct FilePathSortRow {
84 file_path: String,
85 sort_filepath: Option<i64>,
86}
87
88#[derive(sqlx::FromRow)]
90struct FileNameSortRow {
91 file_path: String,
92 sort_filename: Option<i64>,
93}
94
95#[derive(sqlx::FromRow)]
97struct SeriesSortRow {
98 series: String,
99 number: String,
100 sort_series: Option<i64>,
101}
102
103#[derive(Debug, Clone, sqlx::FromRow)]
104struct StoredBookRow {
105 fingerprint: Fp,
106 title: String,
107 subtitle: String,
108 year: String,
109 language: String,
110 publisher: String,
111 series: String,
112 edition: String,
113 volume: String,
114 number: String,
115 identifier: String,
116 file_path: String,
117 absolute_path: String,
118 file_kind: String,
119 file_size: i64,
120 added_at: UnixTimestamp,
121 opened: Option<UnixTimestamp>,
122 current_page: Option<i64>,
123 pages_count: Option<i64>,
124 finished: Option<i64>,
125 dithered: Option<i64>,
126 zoom_mode: Option<String>,
127 scroll_mode: Option<String>,
128 page_offset_x: Option<i64>,
129 page_offset_y: Option<i64>,
130 rotation: Option<i64>,
131 cropping_margins_json: Option<String>,
132 margin_width: Option<i64>,
133 screen_margin_width: Option<i64>,
134 font_family: Option<String>,
135 font_size: Option<f64>,
136 text_align: Option<String>,
137 line_height: Option<f64>,
138 contrast_exponent: Option<f64>,
139 contrast_gray: Option<f64>,
140 page_names_json: Option<String>,
141 bookmarks_json: Option<String>,
142 annotations_json: Option<String>,
143 authors: Option<String>,
144 categories: Option<String>,
145}
146
147#[derive(Clone)]
148pub struct Db {
149 pool: SqlitePool,
150}
151
152impl Db {
153 pub fn new(database: &Database) -> Self {
154 Self {
155 pool: database.pool().clone(),
156 }
157 }
158
159 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(path = %path, name = %name)))]
160 pub fn register_library(&self, path: &str, name: &str) -> Result<i64, Error> {
161 tracing::debug!(path = %path, name = %name, "registering library");
162
163 RUNTIME.block_on(async {
164 let now = UnixTimestamp::now();
165
166 let result = sqlx::query!(
167 r#"
168 INSERT INTO libraries (path, name, created_at)
169 VALUES (?, ?, ?)
170 "#,
171 path,
172 name,
173 now
174 )
175 .execute(&self.pool)
176 .await?;
177
178 let library_id = result.last_insert_rowid();
179 tracing::info!(library_id, path = %path, name = %name, "library registered");
180 Ok(library_id)
181 })
182 }
183
184 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(path = %path)))]
185 pub fn get_library_by_path(&self, path: &str) -> Result<Option<i64>, Error> {
186 tracing::debug!(path = %path, "looking up library by path");
187
188 RUNTIME.block_on(async {
189 let id: Option<Option<i64>> =
190 sqlx::query_scalar!(r#"SELECT id FROM libraries WHERE path = ?"#, path)
191 .fetch_optional(&self.pool)
192 .await?;
193
194 Ok(id.flatten())
195 })
196 }
197
198 #[inline]
199 fn parse_zoom_mode(json: Option<&String>) -> Option<ZoomMode> {
200 match json {
201 Some(s) => match serde_json::from_str(s) {
202 Ok(v) => Some(v),
203 Err(e) => {
204 tracing::warn!(error = %e, "failed to parse zoom_mode JSON field");
205 None
206 }
207 },
208 None => None,
209 }
210 }
211
212 #[inline]
213 fn parse_scroll_mode(json: Option<&String>) -> Option<ScrollMode> {
214 match json {
215 Some(s) => match serde_json::from_str(s) {
216 Ok(v) => Some(v),
217 Err(e) => {
218 tracing::warn!(error = %e, "failed to parse scroll_mode JSON field");
219 None
220 }
221 },
222 None => None,
223 }
224 }
225
226 #[inline]
227 fn parse_text_align(json: Option<&String>) -> Option<TextAlign> {
228 match json {
229 Some(s) => match serde_json::from_str(s) {
230 Ok(v) => Some(v),
231 Err(e) => {
232 tracing::warn!(error = %e, "failed to parse text_align JSON field");
233 None
234 }
235 },
236 None => None,
237 }
238 }
239
240 #[inline]
241 fn parse_cropping_margins(json: Option<&String>) -> Option<CroppingMargins> {
242 match json {
243 Some(s) => match serde_json::from_str(s) {
244 Ok(v) => Some(v),
245 Err(e) => {
246 tracing::warn!(error = %e, "failed to parse cropping_margins JSON field");
247 None
248 }
249 },
250 None => None,
251 }
252 }
253
254 #[inline]
255 fn parse_page_names(json: Option<&String>) -> BTreeMap<usize, String> {
256 match json {
257 Some(s) => match serde_json::from_str(s) {
258 Ok(v) => v,
259 Err(e) => {
260 tracing::warn!(error = %e, "failed to parse page_names JSON field");
261 BTreeMap::default()
262 }
263 },
264 None => BTreeMap::default(),
265 }
266 }
267
268 #[inline]
269 fn parse_bookmarks(json: Option<&String>) -> BTreeSet<usize> {
270 match json {
271 Some(s) => match serde_json::from_str(s) {
272 Ok(v) => v,
273 Err(e) => {
274 tracing::warn!(error = %e, "failed to parse bookmarks JSON field");
275 BTreeSet::default()
276 }
277 },
278 None => BTreeSet::default(),
279 }
280 }
281
282 #[inline]
283 fn parse_annotations(json: Option<&String>) -> Vec<crate::metadata::Annotation> {
284 match json {
285 Some(s) => match serde_json::from_str(s) {
286 Ok(v) => v,
287 Err(e) => {
288 tracing::warn!(error = %e, "failed to parse annotations JSON field");
289 Vec::new()
290 }
291 },
292 None => Vec::new(),
293 }
294 }
295
296 #[inline]
297 fn parse_page_offset(x: Option<i64>, y: Option<i64>) -> Option<Point> {
298 match (x, y) {
299 (Some(x_val), Some(y_val)) => Some(Point::new(x_val as i32, y_val as i32)),
300 _ => None,
301 }
302 }
303
304 #[inline]
305 fn extract_authors(authors: Option<String>) -> String {
306 authors
307 .map(|s| s.split(',').collect::<Vec<_>>().join(", "))
308 .unwrap_or_default()
309 }
310
311 #[inline]
312 fn extract_categories(categories: Option<String>) -> BTreeSet<String> {
313 categories
314 .unwrap_or_default()
315 .split(',')
316 .filter(|s| !s.is_empty())
317 .map(|s| s.to_string())
318 .collect()
319 }
320
321 #[cfg_attr(feature = "tracing", tracing::instrument(skip(pool)))]
322 async fn fetch_toc_entries_for_book(
323 pool: &SqlitePool,
324 library_id: i64,
325 fingerprint: &str,
326 ) -> Result<Vec<TocEntryRow>, Error> {
327 let rows = sqlx::query_as!(
328 TocEntryRow,
329 r#"
330 SELECT
331 te.book_fingerprint,
332 te.id as "id: Uuid7",
333 te.parent_id as "parent_id!: OptionalUuid7",
334 te.position,
335 te.title,
336 te.location_kind,
337 te.location_exact,
338 te.location_uri
339 FROM toc_entries te
340 INNER JOIN library_books lb ON lb.book_fingerprint = te.book_fingerprint
341 WHERE lb.library_id = ? AND te.book_fingerprint = ?
342 ORDER BY te.id ASC
343 "#,
344 library_id,
345 fingerprint,
346 )
347 .fetch_all(pool)
348 .await?;
349
350 Ok(rows)
351 }
352
353 fn stored_book_row_to_info(
354 row: StoredBookRow,
355 toc: Option<Vec<SimpleTocEntry>>,
356 ) -> Result<Info, Error> {
357 let fp = row.fingerprint;
358
359 let mut info = Info {
360 title: row.title,
361 subtitle: row.subtitle,
362 author: Self::extract_authors(row.authors),
363 year: row.year,
364 language: row.language,
365 publisher: row.publisher,
366 series: row.series,
367 edition: row.edition,
368 volume: row.volume,
369 number: row.number,
370 identifier: row.identifier,
371 categories: Self::extract_categories(row.categories),
372 file: FileInfo {
373 path: PathBuf::from(&row.file_path),
374 absolute_path: PathBuf::from(&row.absolute_path),
375 kind: row.file_kind,
376 size: row.file_size as u64,
377 mtime: None,
378 },
379 reader: None,
380 reader_info: None,
381 toc,
382 added: row.added_at.into(),
383 fp: Some(fp),
384 };
385
386 if let Some(opened_ts) = row.opened {
387 let reader_info = ReaderInfo {
388 opened: opened_ts.into(),
389 current_page: row.current_page.unwrap_or(0) as usize,
390 pages_count: row.pages_count.unwrap_or(0) as usize,
391 finished: row.finished.unwrap_or(0) == 1,
392 dithered: row.dithered.unwrap_or(0) == 1,
393 zoom_mode: Self::parse_zoom_mode(row.zoom_mode.as_ref()),
394 scroll_mode: Self::parse_scroll_mode(row.scroll_mode.as_ref()),
395 page_offset: Self::parse_page_offset(row.page_offset_x, row.page_offset_y),
396 rotation: row.rotation.map(|rotation| rotation as i8),
397 cropping_margins: Self::parse_cropping_margins(row.cropping_margins_json.as_ref()),
398 margin_width: row.margin_width.map(|margin| margin as i32),
399 screen_margin_width: row.screen_margin_width.map(|margin| margin as i32),
400 font_family: row.font_family,
401 font_size: row.font_size.map(|size| size as f32),
402 text_align: Self::parse_text_align(row.text_align.as_ref()),
403 line_height: row.line_height.map(|height| height as f32),
404 contrast_exponent: row.contrast_exponent.map(|contrast| contrast as f32),
405 contrast_gray: row.contrast_gray.map(|contrast| contrast as f32),
406 page_names: Self::parse_page_names(row.page_names_json.as_ref()),
407 bookmarks: Self::parse_bookmarks(row.bookmarks_json.as_ref()),
408 annotations: Self::parse_annotations(row.annotations_json.as_ref()),
409 };
410 info.reader = Some(reader_info.clone());
411 info.reader_info = Some(reader_info);
412 }
413
414 Ok(info)
415 }
416
417 #[cfg_attr(feature = "tracing", tracing::instrument(skip(conn, entries), fields(book_fingerprint = %book_fingerprint, parent_id = ?parent_id)))]
418 async fn insert_toc_entries(
419 conn: &mut sqlx::SqliteConnection,
420 book_fingerprint: &str,
421 entries: &[SimpleTocEntry],
422 parent_id: Option<Uuid7>,
423 ) -> Result<(), Error> {
424 for (position, entry) in entries.iter().enumerate() {
425 let (title, location, children) = match entry {
426 SimpleTocEntry::Leaf(t, loc) => (t.as_str(), loc, [].as_slice()),
427 SimpleTocEntry::Container(t, loc, ch) => (t.as_str(), loc, ch.as_slice()),
428 };
429
430 let (location_kind, location_exact, location_uri) =
431 conversion::encode_location(location);
432 let pos = position as i64;
433 let id = Uuid7::now();
434 let parent_id_str = parent_id.as_ref().map(|p| p.to_string());
435
436 sqlx::query!(
437 r#"
438 INSERT INTO toc_entries (id, book_fingerprint, parent_id, position, title, location_kind, location_exact, location_uri)
439 VALUES (?, ?, ?, ?, ?, ?, ?, ?)
440 "#,
441 id,
442 book_fingerprint,
443 parent_id_str,
444 pos,
445 title,
446 location_kind,
447 location_exact,
448 location_uri,
449 )
450 .execute(&mut *conn)
451 .await?;
452
453 if !children.is_empty() {
454 Box::pin(Self::insert_toc_entries(
455 conn,
456 book_fingerprint,
457 children,
458 Some(id),
459 ))
460 .await?;
461 }
462 }
463
464 Ok(())
465 }
466
467 async fn fetch_all_toc_entries(
468 pool: &SqlitePool,
469 library_id: i64,
470 ) -> Result<HashMap<String, Vec<TocEntryRow>>, Error> {
471 let toc_rows: Vec<TocEntryRow> = sqlx::query_as!(
472 TocEntryRow,
473 r#"
474 SELECT
475 te.book_fingerprint,
476 te.id as "id: Uuid7",
477 te.parent_id as "parent_id!: OptionalUuid7",
478 te.position,
479 te.title,
480 te.location_kind,
481 te.location_exact,
482 te.location_uri
483 FROM toc_entries te
484 INNER JOIN library_books lb ON lb.book_fingerprint = te.book_fingerprint
485 WHERE lb.library_id = ?
486 ORDER BY te.book_fingerprint, te.id ASC
487 "#,
488 library_id
489 )
490 .fetch_all(pool)
491 .await?;
492
493 let mut map: HashMap<String, Vec<TocEntryRow>> = HashMap::new();
494
495 for row in toc_rows {
496 map.entry(row.book_fingerprint.clone())
497 .or_default()
498 .push(row);
499 }
500
501 Ok(map)
502 }
503
504 #[cfg_attr(
505 feature = "tracing",
506 tracing::instrument(skip(self), fields(library_id))
507 )]
508 pub fn get_all_books(&self, library_id: i64) -> Result<Vec<Info>, Error> {
509 tracing::debug!(library_id, "fetching all books from database");
510
511 RUNTIME.block_on(async {
512 let book_rows = sqlx::query!(
513 r#"
514 SELECT
515 fingerprint as "fingerprint: Fp",
516 title,
517 subtitle,
518 year,
519 language,
520 publisher,
521 series,
522 edition,
523 volume,
524 number,
525 identifier,
526 file_path,
527 absolute_path,
528 file_kind,
529 file_size,
530 added_at as "added_at: UnixTimestamp",
531 opened as "opened?: UnixTimestamp",
532 current_page as "current_page?: i64",
533 pages_count as "pages_count?: i64",
534 finished as "finished?: i64",
535 dithered as "dithered?: i64",
536 zoom_mode as "zoom_mode?: String",
537 scroll_mode as "scroll_mode?: String",
538 page_offset_x as "page_offset_x?: i64",
539 page_offset_y as "page_offset_y?: i64",
540 rotation as "rotation?: i64",
541 cropping_margins_json as "cropping_margins_json?: String",
542 margin_width as "margin_width?: i64",
543 screen_margin_width as "screen_margin_width?: i64",
544 font_family as "font_family?: String",
545 font_size as "font_size?: f64",
546 text_align as "text_align?: String",
547 line_height as "line_height?: f64",
548 contrast_exponent as "contrast_exponent?: f64",
549 contrast_gray as "contrast_gray?: f64",
550 page_names_json as "page_names_json?: String",
551 bookmarks_json as "bookmarks_json?: String",
552 annotations_json as "annotations_json?: String",
553 authors as "authors?: String",
554 categories as "categories?: String"
555 FROM library_books_full_info
556 WHERE library_id = ?
557 ORDER BY added_at DESC
558 "#,
559 library_id
560 )
561 .fetch_all(&self.pool)
562 .await?;
563
564 let mut toc_by_fingerprint =
565 Self::fetch_all_toc_entries(&self.pool, library_id).await?;
566
567 let mut result = Vec::new();
568
569 for row in book_rows {
570 let fp = row.fingerprint;
571
572 let toc = toc_by_fingerprint
573 .remove(&fp.to_string())
574 .map(|rows| rows_to_toc_entries(&rows))
575 .transpose()?;
576
577 let mut info = Info {
578 title: row.title,
579 subtitle: row.subtitle,
580 author: Self::extract_authors(row.authors),
581 year: row.year,
582 language: row.language,
583 publisher: row.publisher,
584 series: row.series,
585 edition: row.edition,
586 volume: row.volume,
587 number: row.number,
588 identifier: row.identifier,
589 categories: Self::extract_categories(row.categories),
590 file: FileInfo {
591 path: PathBuf::from(&row.file_path),
592 absolute_path: PathBuf::from(&row.absolute_path),
593 kind: row.file_kind,
594 size: row.file_size as u64,
595 mtime: None,
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 let file_size_i64 = info.file.size as i64;
1683
1684 sqlx::query!(
1685 r#"
1686 INSERT OR IGNORE INTO library_books (library_id, book_fingerprint, added_to_library_at, file_path, absolute_path, mtime, file_size)
1687 VALUES (?, ?, ?, ?, ?, ?, ?)
1688 "#,
1689 library_id,
1690 fp_str,
1691 book_row.added_at,
1692 book_row.file_path,
1693 book_row.absolute_path,
1694 info.file.mtime,
1695 file_size_i64,
1696 )
1697 .execute(&mut *tx)
1698 .await?;
1699
1700 let authors = extract_authors(&info.author);
1701 for (position, author_name) in authors.iter().enumerate() {
1702 sqlx::query!(
1703 r#"INSERT OR IGNORE INTO authors (name) VALUES (?)"#,
1704 author_name
1705 )
1706 .execute(&mut *tx)
1707 .await?;
1708
1709 let author_id: i64 = sqlx::query_scalar!(
1710 r#"SELECT id FROM authors WHERE name = ?"#,
1711 author_name
1712 )
1713 .fetch_one(&mut *tx)
1714 .await?;
1715
1716 let pos = position as i64;
1717 sqlx::query!(
1718 r#"
1719 INSERT OR IGNORE INTO book_authors (book_fingerprint, author_id, position)
1720 VALUES (?, ?, ?)
1721 "#,
1722 fp_str,
1723 author_id,
1724 pos
1725 )
1726 .execute(&mut *tx)
1727 .await?;
1728 }
1729
1730 for category_name in &info.categories {
1731 sqlx::query!(
1732 r#"INSERT OR IGNORE INTO categories (name) VALUES (?)"#,
1733 category_name
1734 )
1735 .execute(&mut *tx)
1736 .await?;
1737
1738 let category_id: i64 = sqlx::query_scalar!(
1739 r#"SELECT id FROM categories WHERE name = ?"#,
1740 category_name
1741 )
1742 .fetch_one(&mut *tx)
1743 .await?;
1744
1745 sqlx::query!(
1746 r#"
1747 INSERT OR IGNORE INTO book_categories (book_fingerprint, category_id)
1748 VALUES (?, ?)
1749 "#,
1750 fp_str,
1751 category_id
1752 )
1753 .execute(&mut *tx)
1754 .await?;
1755 }
1756
1757 if let Some(reader_info) = &info.reader_info {
1758 let rs_row = reader_info_to_reading_state_row(fp, reader_info);
1759
1760 sqlx::query!(
1761 r#"
1762 INSERT INTO reading_states (
1763 fingerprint, opened, current_page, pages_count, finished, dithered,
1764 zoom_mode, scroll_mode, page_offset_x, page_offset_y, rotation,
1765 cropping_margins_json, margin_width, screen_margin_width,
1766 font_family, font_size, text_align, line_height,
1767 contrast_exponent, contrast_gray,
1768 page_names_json, bookmarks_json, annotations_json
1769 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1770 "#,
1771 rs_row.fingerprint,
1772 rs_row.opened,
1773 rs_row.current_page,
1774 rs_row.pages_count,
1775 rs_row.finished,
1776 rs_row.dithered,
1777 rs_row.zoom_mode,
1778 rs_row.scroll_mode,
1779 rs_row.page_offset_x,
1780 rs_row.page_offset_y,
1781 rs_row.rotation,
1782 rs_row.cropping_margins_json,
1783 rs_row.margin_width,
1784 rs_row.screen_margin_width,
1785 rs_row.font_family,
1786 rs_row.font_size,
1787 rs_row.text_align,
1788 rs_row.line_height,
1789 rs_row.contrast_exponent,
1790 rs_row.contrast_gray,
1791 rs_row.page_names_json,
1792 rs_row.bookmarks_json,
1793 rs_row.annotations_json,
1794 )
1795 .execute(&mut *tx)
1796 .await?;
1797 }
1798
1799 tx.commit().await?;
1800
1801 tracing::debug!(fp = %fp, "book insert complete");
1802 Ok(())
1803 })
1804 }
1805
1806 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, info), fields(fp = %fp, library_id)))]
1808 pub fn update_book(&self, library_id: i64, fp: Fp, info: &Info) -> Result<(), Error> {
1809 tracing::debug!(fp = %fp, library_id, "updating book in database");
1810 let fp_str = fp.to_string();
1811
1812 RUNTIME.block_on(async {
1813 let mut tx = self.pool.begin().await?;
1814
1815 let book_row = info_to_book_row(fp, info);
1816
1817 sqlx::query!(
1818 r#"
1819 UPDATE books SET
1820 title = ?, subtitle = ?, year = ?, language = ?, publisher = ?,
1821 series = ?, edition = ?, volume = ?, number = ?, identifier = ?,
1822 file_kind = ?, file_size = ?, added_at = ?
1823 WHERE fingerprint = ?
1824 "#,
1825 book_row.title,
1826 book_row.subtitle,
1827 book_row.year,
1828 book_row.language,
1829 book_row.publisher,
1830 book_row.series,
1831 book_row.edition,
1832 book_row.volume,
1833 book_row.number,
1834 book_row.identifier,
1835 book_row.file_kind,
1836 book_row.file_size,
1837 book_row.added_at,
1838 fp_str,
1839 )
1840 .execute(&mut *tx)
1841 .await?;
1842
1843 sqlx::query!(
1844 r#"
1845 UPDATE library_books SET file_path = ?, absolute_path = ?
1846 WHERE library_id = ? AND book_fingerprint = ?
1847 "#,
1848 book_row.file_path,
1849 book_row.absolute_path,
1850 library_id,
1851 fp_str,
1852 )
1853 .execute(&mut *tx)
1854 .await?;
1855
1856 sqlx::query!(
1857 r#"DELETE FROM book_authors WHERE book_fingerprint = ?"#,
1858 fp_str
1859 )
1860 .execute(&mut *tx)
1861 .await?;
1862
1863 let authors = extract_authors(&info.author);
1864 for (position, author_name) in authors.iter().enumerate() {
1865 sqlx::query!(
1866 r#"INSERT OR IGNORE INTO authors (name) VALUES (?)"#,
1867 author_name
1868 )
1869 .execute(&mut *tx)
1870 .await?;
1871
1872 let author_id: i64 =
1873 sqlx::query_scalar!(r#"SELECT id FROM authors WHERE name = ?"#, author_name)
1874 .fetch_one(&mut *tx)
1875 .await?;
1876
1877 let pos = position as i64;
1878 sqlx::query!(
1879 r#"
1880 INSERT OR IGNORE INTO book_authors (book_fingerprint, author_id, position)
1881 VALUES (?, ?, ?)
1882 "#,
1883 fp_str,
1884 author_id,
1885 pos
1886 )
1887 .execute(&mut *tx)
1888 .await?;
1889 }
1890
1891 sqlx::query!(
1892 r#"DELETE FROM book_categories WHERE book_fingerprint = ?"#,
1893 fp_str
1894 )
1895 .execute(&mut *tx)
1896 .await?;
1897
1898 for category_name in &info.categories {
1899 sqlx::query!(
1900 r#"INSERT OR IGNORE INTO categories (name) VALUES (?)"#,
1901 category_name
1902 )
1903 .execute(&mut *tx)
1904 .await?;
1905
1906 let category_id: i64 = sqlx::query_scalar!(
1907 r#"SELECT id FROM categories WHERE name = ?"#,
1908 category_name
1909 )
1910 .fetch_one(&mut *tx)
1911 .await?;
1912
1913 sqlx::query!(
1914 r#"
1915 INSERT OR IGNORE INTO book_categories (book_fingerprint, category_id)
1916 VALUES (?, ?)
1917 "#,
1918 fp_str,
1919 category_id
1920 )
1921 .execute(&mut *tx)
1922 .await?;
1923 }
1924
1925 tx.commit().await?;
1926
1927 tracing::debug!(fp = %fp, "book update complete");
1928 Ok(())
1929 })
1930 }
1931
1932 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(fp = %fp)))]
1933 pub fn delete_reading_state(&self, fp: Fp) -> Result<(), Error> {
1934 tracing::debug!(fp = %fp, "deleting reading state from database");
1935
1936 RUNTIME.block_on(async {
1937 let fp_str = fp.to_string();
1938
1939 sqlx::query!(
1940 r#"DELETE FROM reading_states WHERE fingerprint = ?"#,
1941 fp_str
1942 )
1943 .execute(&self.pool)
1944 .await?;
1945
1946 Ok(())
1947 })
1948 }
1949
1950 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(fp = %fp, library_id)))]
1951 pub fn delete_book(&self, library_id: i64, fp: Fp) -> Result<(), Error> {
1952 tracing::debug!(fp = %fp, library_id, "deleting book from library");
1953
1954 RUNTIME.block_on(async {
1955 let fp_str = fp.to_string();
1956 let mut tx = self.pool.begin().await?;
1957
1958 sqlx::query!(
1959 r#"DELETE FROM library_books WHERE library_id = ? AND book_fingerprint = ?"#,
1960 library_id,
1961 fp_str
1962 )
1963 .execute(&mut *tx)
1964 .await?;
1965
1966 let remaining: i64 = sqlx::query_scalar!(
1967 r#"SELECT COUNT(*) FROM library_books WHERE book_fingerprint = ?"#,
1968 fp_str
1969 )
1970 .fetch_one(&mut *tx)
1971 .await?;
1972
1973 if remaining == 0 {
1974 tracing::debug!(fp = %fp, "book not in any library, deleting completely");
1975 sqlx::query!(r#"DELETE FROM books WHERE fingerprint = ?"#, fp_str)
1976 .execute(&mut *tx)
1977 .await?;
1978 }
1979
1980 tx.commit().await?;
1981
1982 tracing::debug!(fp = %fp, library_id, "book delete complete");
1983 Ok(())
1984 })
1985 }
1986
1987 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(fp = %fp)))]
1988 pub fn get_thumbnail(&self, fp: Fp) -> Result<Option<Vec<u8>>, Error> {
1989 tracing::debug!(fp = %fp, "fetching thumbnail from database");
1990 let fp_str = fp.to_string();
1991
1992 RUNTIME.block_on(async {
1993 sqlx::query_scalar!(
1994 "SELECT thumbnail_data FROM thumbnails WHERE fingerprint = ?",
1995 fp_str
1996 )
1997 .fetch_optional(&self.pool)
1998 .await
1999 .map_err(Error::from)
2000 })
2001 }
2002
2003 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(library_id, path = %path.display())))]
2004 pub fn get_thumbnail_by_path(
2005 &self,
2006 library_id: i64,
2007 path: &Path,
2008 ) -> Result<Option<Vec<u8>>, Error> {
2009 let path = path.to_string_lossy().into_owned();
2010 tracing::debug!(library_id, path, "fetching thumbnail by path from database");
2011
2012 RUNTIME.block_on(async {
2013 sqlx::query_scalar!(
2014 "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 = ?",
2015 library_id,
2016 path
2017 )
2018 .fetch_optional(&self.pool)
2019 .await
2020 .map_err(Error::from)
2021 })
2022 }
2023
2024 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, data), fields(fp = %fp, size = data.len())))]
2025 pub fn save_thumbnail(&self, fp: Fp, data: &[u8]) -> Result<(), Error> {
2026 tracing::debug!(fp = %fp, size = data.len(), "saving thumbnail to database");
2027 let fp_str = fp.to_string();
2028
2029 RUNTIME.block_on(async {
2030 sqlx::query!(
2031 r#"
2032 INSERT INTO thumbnails (fingerprint, thumbnail_data)
2033 VALUES (?, ?)
2034 ON CONFLICT(fingerprint) DO UPDATE SET
2035 thumbnail_data = excluded.thumbnail_data
2036 "#,
2037 fp_str,
2038 data,
2039 )
2040 .execute(&self.pool)
2041 .await?;
2042
2043 tracing::debug!(fp = %fp, "thumbnail save complete");
2044 Ok(())
2045 })
2046 }
2047
2048 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(fp = %fp)))]
2049 pub fn delete_thumbnail(&self, fp: Fp) -> Result<(), Error> {
2050 tracing::debug!(fp = %fp, "deleting thumbnail from database");
2051 let fp_str = fp.to_string();
2052
2053 RUNTIME.block_on(async {
2054 sqlx::query!("DELETE FROM thumbnails WHERE fingerprint = ?", fp_str)
2055 .execute(&self.pool)
2056 .await?;
2057
2058 tracing::debug!(fp = %fp, "thumbnail delete complete");
2059 Ok(())
2060 })
2061 }
2062
2063 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, fps), fields(count = fps.len())))]
2064 pub fn batch_delete_thumbnails(&self, fps: &[Fp]) -> Result<(), Error> {
2065 if fps.is_empty() {
2066 return Ok(());
2067 }
2068
2069 tracing::debug!(count = fps.len(), "batch deleting thumbnails from database");
2070
2071 RUNTIME.block_on(async {
2072 let mut tx = self.pool.begin().await?;
2073
2074 for fp in fps {
2075 let fp_str = fp.to_string();
2076 sqlx::query!("DELETE FROM thumbnails WHERE fingerprint = ?", fp_str)
2077 .execute(&mut *tx)
2078 .await?;
2079 }
2080
2081 tx.commit().await?;
2082 Ok(())
2083 })
2084 }
2085
2086 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(from = %from_fp, to = %to_fp)))]
2087 pub fn move_thumbnail(&self, from_fp: Fp, to_fp: Fp) -> Result<(), Error> {
2088 tracing::debug!(from = %from_fp, to = %to_fp, "moving thumbnail in database");
2089 let from_fp_str = from_fp.to_string();
2090 let to_fp_str = to_fp.to_string();
2091
2092 RUNTIME.block_on(async {
2093 sqlx::query!(
2094 r#"
2095 UPDATE thumbnails
2096 SET fingerprint = ?
2097 WHERE fingerprint = ?
2098 "#,
2099 to_fp_str,
2100 from_fp_str
2101 )
2102 .execute(&self.pool)
2103 .await?;
2104
2105 Ok(())
2106 })
2107 }
2108
2109 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, moves), fields(count = moves.len())))]
2110 pub fn batch_move_thumbnails(&self, moves: &[(Fp, Fp)]) -> Result<(), Error> {
2111 if moves.is_empty() {
2112 return Ok(());
2113 }
2114
2115 tracing::debug!(count = moves.len(), "batch moving thumbnails in database");
2116
2117 RUNTIME.block_on(async {
2118 let mut tx = self.pool.begin().await?;
2119
2120 for (from_fp, to_fp) in moves {
2121 let from_str = from_fp.to_string();
2122 let to_str = to_fp.to_string();
2123
2124 sqlx::query!(
2125 r#"UPDATE thumbnails SET fingerprint = ? WHERE fingerprint = ?"#,
2126 to_str,
2127 from_str
2128 )
2129 .execute(&mut *tx)
2130 .await?;
2131 }
2132
2133 tx.commit().await?;
2134 Ok(())
2135 })
2136 }
2137
2138 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, reader_info), fields(fp = %fp)))]
2139 pub fn save_reading_state(&self, fp: Fp, reader_info: &ReaderInfo) -> Result<(), Error> {
2140 tracing::debug!(fp = %fp, "saving reading state to database");
2141
2142 RUNTIME.block_on(async {
2143 let rs_row = reader_info_to_reading_state_row(fp, reader_info);
2144
2145 sqlx::query!(
2146 r#"
2147 INSERT INTO reading_states (
2148 fingerprint, opened, current_page, pages_count, finished, dithered,
2149 zoom_mode, scroll_mode, page_offset_x, page_offset_y, rotation,
2150 cropping_margins_json, margin_width, screen_margin_width,
2151 font_family, font_size, text_align, line_height,
2152 contrast_exponent, contrast_gray,
2153 page_names_json, bookmarks_json, annotations_json
2154 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2155 ON CONFLICT(fingerprint) DO UPDATE SET
2156 opened = excluded.opened,
2157 current_page = excluded.current_page,
2158 pages_count = excluded.pages_count,
2159 finished = excluded.finished,
2160 dithered = excluded.dithered,
2161 zoom_mode = excluded.zoom_mode,
2162 scroll_mode = excluded.scroll_mode,
2163 page_offset_x = excluded.page_offset_x,
2164 page_offset_y = excluded.page_offset_y,
2165 rotation = excluded.rotation,
2166 cropping_margins_json = excluded.cropping_margins_json,
2167 margin_width = excluded.margin_width,
2168 screen_margin_width = excluded.screen_margin_width,
2169 font_family = excluded.font_family,
2170 font_size = excluded.font_size,
2171 text_align = excluded.text_align,
2172 line_height = excluded.line_height,
2173 contrast_exponent = excluded.contrast_exponent,
2174 contrast_gray = excluded.contrast_gray,
2175 page_names_json = excluded.page_names_json,
2176 bookmarks_json = excluded.bookmarks_json,
2177 annotations_json = excluded.annotations_json
2178 "#,
2179 rs_row.fingerprint,
2180 rs_row.opened,
2181 rs_row.current_page,
2182 rs_row.pages_count,
2183 rs_row.finished,
2184 rs_row.dithered,
2185 rs_row.zoom_mode,
2186 rs_row.scroll_mode,
2187 rs_row.page_offset_x,
2188 rs_row.page_offset_y,
2189 rs_row.rotation,
2190 rs_row.cropping_margins_json,
2191 rs_row.margin_width,
2192 rs_row.screen_margin_width,
2193 rs_row.font_family,
2194 rs_row.font_size,
2195 rs_row.text_align,
2196 rs_row.line_height,
2197 rs_row.contrast_exponent,
2198 rs_row.contrast_gray,
2199 rs_row.page_names_json,
2200 rs_row.bookmarks_json,
2201 rs_row.annotations_json,
2202 )
2203 .execute(&self.pool)
2204 .await?;
2205
2206 tracing::debug!(fp = %fp, "reading state save complete");
2207 Ok(())
2208 })
2209 }
2210
2211 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, toc), fields(fp = %fp, entry_count = toc.len())))]
2212 pub fn save_toc(&self, fp: Fp, toc: &[SimpleTocEntry]) -> Result<(), Error> {
2213 if toc.is_empty() {
2214 return Ok(());
2215 }
2216
2217 tracing::debug!(fp = %fp, entry_count = toc.len(), "saving TOC to database");
2218 let fp_str = fp.to_string();
2219
2220 RUNTIME.block_on(async {
2221 let mut tx = self.pool.begin().await?;
2222
2223 sqlx::query!("DELETE FROM toc_entries WHERE book_fingerprint = ?", fp_str)
2224 .execute(&mut *tx)
2225 .await?;
2226
2227 Self::insert_toc_entries(&mut tx, &fp_str, toc, None).await?;
2228
2229 tx.commit().await?;
2230
2231 tracing::debug!(fp = %fp, "TOC save complete");
2232 Ok(())
2233 })
2234 }
2235
2236 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, books), fields(library_id, count = books.len())))]
2237 pub fn batch_insert_books(&self, library_id: i64, books: &[(Fp, &Info)]) -> Result<(), Error> {
2238 if books.is_empty() {
2239 return Ok(());
2240 }
2241
2242 tracing::debug!(library_id, count = books.len(), "batch inserting books");
2243
2244 RUNTIME.block_on(async {
2245 let mut tx = self.pool.begin().await?;
2246
2247 for (fp, info) in books {
2248 let fp_str = fp.to_string();
2249 let book_row = info_to_book_row(*fp, info);
2250
2251 sqlx::query!(
2252 r#"
2253 INSERT OR IGNORE INTO books (
2254 fingerprint, title, subtitle, year, language, publisher,
2255 series, edition, volume, number, identifier,
2256 file_kind, file_size, added_at
2257 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2258 "#,
2259 book_row.fingerprint,
2260 book_row.title,
2261 book_row.subtitle,
2262 book_row.year,
2263 book_row.language,
2264 book_row.publisher,
2265 book_row.series,
2266 book_row.edition,
2267 book_row.volume,
2268 book_row.number,
2269 book_row.identifier,
2270 book_row.file_kind,
2271 book_row.file_size,
2272 book_row.added_at,
2273 )
2274 .execute(&mut *tx)
2275 .await?;
2276
2277 let file_size_i64 = info.file.size as i64;
2278
2279 sqlx::query!(
2280 r#"
2281 INSERT OR IGNORE INTO library_books (library_id, book_fingerprint, added_to_library_at, file_path, absolute_path, mtime, file_size)
2282 VALUES (?, ?, ?, ?, ?, ?, ?)
2283 "#,
2284 library_id,
2285 fp_str,
2286 book_row.added_at,
2287 book_row.file_path,
2288 book_row.absolute_path,
2289 info.file.mtime,
2290 file_size_i64,
2291 )
2292 .execute(&mut *tx)
2293 .await?;
2294
2295 let authors = extract_authors(&info.author);
2296 for (position, author_name) in authors.iter().enumerate() {
2297 sqlx::query!(
2298 r#"INSERT OR IGNORE INTO authors (name) VALUES (?)"#,
2299 author_name
2300 )
2301 .execute(&mut *tx)
2302 .await?;
2303
2304 let author_id: i64 = sqlx::query_scalar!(
2305 r#"SELECT id FROM authors WHERE name = ?"#,
2306 author_name
2307 )
2308 .fetch_one(&mut *tx)
2309 .await?;
2310
2311 let pos = position as i64;
2312 sqlx::query!(
2313 r#"
2314 INSERT OR IGNORE INTO book_authors (book_fingerprint, author_id, position)
2315 VALUES (?, ?, ?)
2316 "#,
2317 fp_str,
2318 author_id,
2319 pos
2320 )
2321 .execute(&mut *tx)
2322 .await?;
2323 }
2324
2325 for category_name in &info.categories {
2326 sqlx::query!(
2327 r#"INSERT OR IGNORE INTO categories (name) VALUES (?)"#,
2328 category_name
2329 )
2330 .execute(&mut *tx)
2331 .await?;
2332
2333 let category_id: i64 = sqlx::query_scalar!(
2334 r#"SELECT id FROM categories WHERE name = ?"#,
2335 category_name
2336 )
2337 .fetch_one(&mut *tx)
2338 .await?;
2339
2340 sqlx::query!(
2341 r#"
2342 INSERT OR IGNORE INTO book_categories (book_fingerprint, category_id)
2343 VALUES (?, ?)
2344 "#,
2345 fp_str,
2346 category_id
2347 )
2348 .execute(&mut *tx)
2349 .await?;
2350 }
2351
2352 if let Some(reader_info) = &info.reader_info {
2353 let rs_row = reader_info_to_reading_state_row(*fp, reader_info);
2354
2355 sqlx::query!(
2356 r#"
2357 INSERT INTO reading_states (
2358 fingerprint, opened, current_page, pages_count, finished, dithered,
2359 zoom_mode, scroll_mode, page_offset_x, page_offset_y, rotation,
2360 cropping_margins_json, margin_width, screen_margin_width,
2361 font_family, font_size, text_align, line_height,
2362 contrast_exponent, contrast_gray,
2363 page_names_json, bookmarks_json, annotations_json
2364 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2365 "#,
2366 rs_row.fingerprint,
2367 rs_row.opened,
2368 rs_row.current_page,
2369 rs_row.pages_count,
2370 rs_row.finished,
2371 rs_row.dithered,
2372 rs_row.zoom_mode,
2373 rs_row.scroll_mode,
2374 rs_row.page_offset_x,
2375 rs_row.page_offset_y,
2376 rs_row.rotation,
2377 rs_row.cropping_margins_json,
2378 rs_row.margin_width,
2379 rs_row.screen_margin_width,
2380 rs_row.font_family,
2381 rs_row.font_size,
2382 rs_row.text_align,
2383 rs_row.line_height,
2384 rs_row.contrast_exponent,
2385 rs_row.contrast_gray,
2386 rs_row.page_names_json,
2387 rs_row.bookmarks_json,
2388 rs_row.annotations_json,
2389 )
2390 .execute(&mut *tx)
2391 .await?;
2392 }
2393
2394 if let Some(ref toc) = info.toc {
2395 sqlx::query!("DELETE FROM toc_entries WHERE book_fingerprint = ?", fp_str)
2396 .execute(&mut *tx)
2397 .await?;
2398 Self::insert_toc_entries(&mut tx, &fp_str, toc, None).await?;
2399 }
2400 }
2401
2402 tx.commit().await?;
2403
2404 tracing::debug!(count = books.len(), "batch insert complete");
2405 Ok(())
2406 })
2407 }
2408
2409 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, books), fields(library_id, count = books.len())))]
2410 pub fn batch_update_books(&self, library_id: i64, books: &[(Fp, &Info)]) -> Result<(), Error> {
2411 if books.is_empty() {
2412 return Ok(());
2413 }
2414
2415 tracing::debug!(library_id, count = books.len(), "batch updating books");
2416
2417 RUNTIME.block_on(async {
2418 let mut tx = self.pool.begin().await?;
2419
2420 for (fp, info) in books {
2421 let fp_str = fp.to_string();
2422
2423 let book_row = info_to_book_row(*fp, info);
2424
2425 sqlx::query!(
2426 r#"
2427 UPDATE books SET
2428 title = ?, subtitle = ?, year = ?, language = ?, publisher = ?,
2429 series = ?, edition = ?, volume = ?, number = ?, identifier = ?,
2430 file_kind = ?, file_size = ?, added_at = ?
2431 WHERE fingerprint = ?
2432 "#,
2433 book_row.title,
2434 book_row.subtitle,
2435 book_row.year,
2436 book_row.language,
2437 book_row.publisher,
2438 book_row.series,
2439 book_row.edition,
2440 book_row.volume,
2441 book_row.number,
2442 book_row.identifier,
2443 book_row.file_kind,
2444 book_row.file_size,
2445 book_row.added_at,
2446 fp_str,
2447 )
2448 .execute(&mut *tx)
2449 .await?;
2450
2451 sqlx::query!(
2452 r#"
2453 UPDATE library_books SET file_path = ?, absolute_path = ?
2454 WHERE library_id = ? AND book_fingerprint = ?
2455 "#,
2456 book_row.file_path,
2457 book_row.absolute_path,
2458 library_id,
2459 fp_str,
2460 )
2461 .execute(&mut *tx)
2462 .await?;
2463
2464 sqlx::query!(
2465 r#"DELETE FROM book_authors WHERE book_fingerprint = ?"#,
2466 fp_str
2467 )
2468 .execute(&mut *tx)
2469 .await?;
2470
2471 let authors = extract_authors(&info.author);
2472 for (position, author_name) in authors.iter().enumerate() {
2473 sqlx::query!(
2474 r#"INSERT OR IGNORE INTO authors (name) VALUES (?)"#,
2475 author_name
2476 )
2477 .execute(&mut *tx)
2478 .await?;
2479
2480 let author_id: i64 = sqlx::query_scalar!(
2481 r#"SELECT id FROM authors WHERE name = ?"#,
2482 author_name
2483 )
2484 .fetch_one(&mut *tx)
2485 .await?;
2486
2487 let pos = position as i64;
2488 sqlx::query!(
2489 r#"
2490 INSERT INTO book_authors (book_fingerprint, author_id, position)
2491 VALUES (?, ?, ?)
2492 "#,
2493 fp_str,
2494 author_id,
2495 pos
2496 )
2497 .execute(&mut *tx)
2498 .await?;
2499 }
2500
2501 sqlx::query!(
2502 r#"DELETE FROM book_categories WHERE book_fingerprint = ?"#,
2503 fp_str
2504 )
2505 .execute(&mut *tx)
2506 .await?;
2507
2508 for category_name in &info.categories {
2509 sqlx::query!(
2510 r#"INSERT OR IGNORE INTO categories (name) VALUES (?)"#,
2511 category_name
2512 )
2513 .execute(&mut *tx)
2514 .await?;
2515
2516 let category_id: i64 = sqlx::query_scalar!(
2517 r#"SELECT id FROM categories WHERE name = ?"#,
2518 category_name
2519 )
2520 .fetch_one(&mut *tx)
2521 .await?;
2522
2523 sqlx::query!(
2524 r#"
2525 INSERT INTO book_categories (book_fingerprint, category_id)
2526 VALUES (?, ?)
2527 "#,
2528 fp_str,
2529 category_id
2530 )
2531 .execute(&mut *tx)
2532 .await?;
2533 }
2534
2535 if let Some(ref toc) = info.toc {
2536 sqlx::query!("DELETE FROM toc_entries WHERE book_fingerprint = ?", fp_str)
2537 .execute(&mut *tx)
2538 .await?;
2539 Self::insert_toc_entries(&mut tx, &fp_str, toc, None).await?;
2540 } else {
2541 sqlx::query!("DELETE FROM toc_entries WHERE book_fingerprint = ?", fp_str)
2542 .execute(&mut *tx)
2543 .await?;
2544 }
2545 }
2546
2547 tx.commit().await?;
2548
2549 tracing::debug!(count = books.len(), "batch update complete");
2550 Ok(())
2551 })
2552 }
2553
2554 #[cfg_attr(
2560 feature = "tracing",
2561 tracing::instrument(skip(self), fields(library_id))
2562 )]
2563 pub fn list_book_handles(&self, library_id: i64) -> Result<Vec<BookHandle>, Error> {
2564 RUNTIME.block_on(async {
2565 let rows = sqlx::query!(
2566 r#"
2567 SELECT lb.book_fingerprint AS "fingerprint!: Fp",
2568 lb.file_path AS "file_path!: String",
2569 lb.absolute_path AS "absolute_path!: String",
2570 lb.mtime AS "mtime?: UnixTimestamp",
2571 lb.file_size AS "file_size?: FileSize"
2572 FROM library_books lb
2573 WHERE lb.library_id = ?
2574 "#,
2575 library_id,
2576 )
2577 .fetch_all(&self.pool)
2578 .await?;
2579
2580 Ok(rows
2581 .into_iter()
2582 .map(|row| BookHandle {
2583 fp: row.fingerprint,
2584 relat: PathBuf::from(row.file_path),
2585 abs: PathBuf::from(row.absolute_path),
2586 mtime: row.mtime,
2587 file_size: row.file_size,
2588 })
2589 .collect())
2590 })
2591 }
2592
2593 #[cfg_attr(
2595 feature = "tracing",
2596 tracing::instrument(skip(self), fields(library_id))
2597 )]
2598 pub fn books_without_thumbnails(&self, library_id: i64) -> Result<Vec<(Fp, PathBuf)>, Error> {
2599 RUNTIME.block_on(async {
2600 let rows = sqlx::query!(
2601 r#"
2602 SELECT lb.book_fingerprint AS "fingerprint!: Fp",
2603 lb.file_path AS "file_path!: String"
2604 FROM library_books lb
2605 LEFT JOIN thumbnails t ON lb.book_fingerprint = t.fingerprint
2606 WHERE lb.library_id = ? AND t.fingerprint IS NULL
2607 "#,
2608 library_id,
2609 )
2610 .fetch_all(&self.pool)
2611 .await?;
2612
2613 rows.into_iter()
2614 .map(|row| Ok((row.fingerprint, PathBuf::from(row.file_path))))
2615 .collect()
2616 })
2617 }
2618
2619 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(library_id, fp = %fp)))]
2622 pub fn update_book_path(
2623 &self,
2624 library_id: i64,
2625 fp: Fp,
2626 rel_path: &Path,
2627 abs_path: &Path,
2628 ) -> Result<(), Error> {
2629 let fp_str = fp.to_string();
2630 let rel_str = rel_path.to_string_lossy().into_owned();
2631 let abs_str = abs_path.to_string_lossy().into_owned();
2632
2633 RUNTIME.block_on(async {
2634 let mut tx = self.pool.begin().await?;
2635
2636 sqlx::query!(
2637 r#"UPDATE library_books SET file_path = ?, absolute_path = ? WHERE library_id = ? AND book_fingerprint = ?"#,
2638 rel_str,
2639 abs_str,
2640 library_id,
2641 fp_str,
2642 )
2643 .execute(&mut *tx)
2644 .await?;
2645
2646 tx.commit().await?;
2647 Ok(())
2648 })
2649 }
2650
2651 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, updates), fields(library_id, count = updates.len())))]
2655 pub fn batch_update_book_paths(
2656 &self,
2657 library_id: i64,
2658 updates: &[PathUpdate],
2659 ) -> Result<(), Error> {
2660 if updates.is_empty() {
2661 return Ok(());
2662 }
2663
2664 tracing::debug!(
2665 library_id,
2666 count = updates.len(),
2667 "batch updating book paths in library"
2668 );
2669
2670 RUNTIME.block_on(async {
2671 let mut tx = self.pool.begin().await?;
2672
2673 for update in updates {
2674 let fp_str = update.fp.to_string();
2675 let rel_str = update.relat.to_string_lossy().into_owned();
2676 let abs_str = update.abs.to_string_lossy().into_owned();
2677
2678 sqlx::query!(
2679 r#"UPDATE library_books
2680 SET file_path = ?, absolute_path = ?, mtime = ?, file_size = ?
2681 WHERE library_id = ? AND book_fingerprint = ?"#,
2682 rel_str,
2683 abs_str,
2684 update.mtime,
2685 update.file_size,
2686 library_id,
2687 fp_str,
2688 )
2689 .execute(&mut *tx)
2690 .await?;
2691 }
2692
2693 tx.commit().await?;
2694 Ok(())
2695 })
2696 }
2697
2698 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, fps), fields(library_id, count = fps.len())))]
2699 pub fn batch_delete_books(&self, library_id: i64, fps: &[Fp]) -> Result<(), Error> {
2700 if fps.is_empty() {
2701 return Ok(());
2702 }
2703
2704 tracing::debug!(
2705 library_id,
2706 count = fps.len(),
2707 "batch deleting books from library"
2708 );
2709
2710 RUNTIME.block_on(async {
2711 let mut tx = self.pool.begin().await?;
2712
2713 for fp in fps {
2714 let fp_str = fp.to_string();
2715
2716 sqlx::query!(
2717 r#"DELETE FROM library_books WHERE library_id = ? AND book_fingerprint = ?"#,
2718 library_id,
2719 fp_str
2720 )
2721 .execute(&mut *tx)
2722 .await?;
2723
2724 let ref_count: i64 = sqlx::query_scalar!(
2725 r#"SELECT COUNT(*) FROM library_books WHERE book_fingerprint = ?"#,
2726 fp_str
2727 )
2728 .fetch_one(&mut *tx)
2729 .await?;
2730
2731 if ref_count == 0 {
2732 sqlx::query!(
2733 r#"DELETE FROM books WHERE fingerprint = ?"#,
2734 fp_str
2735 )
2736 .execute(&mut *tx)
2737 .await?;
2738 tracing::debug!(fp = %fp, "book removed from database (no more library references)");
2739 } else {
2740 tracing::debug!(fp = %fp, ref_count, "book kept in database (still referenced by other libraries)");
2741 }
2742 }
2743
2744 tx.commit().await?;
2745
2746 tracing::debug!(count = fps.len(), "batch delete complete");
2747 Ok(())
2748 })
2749 }
2750
2751 #[cfg_attr(
2758 feature = "tracing",
2759 tracing::instrument(skip(self, allowed_kinds), fields(library_id))
2760 )]
2761 pub fn delete_books_with_disallowed_kinds(
2762 &self,
2763 library_id: i64,
2764 allowed_kinds: &FxHashSet<FileExtension>,
2765 ) -> Result<Vec<Fp>, Error> {
2766 RUNTIME.block_on(async {
2767 let mut tx = self.pool.begin().await?;
2768
2769 let rows = sqlx::query!(
2770 r#"
2771 SELECT
2772 lb.book_fingerprint AS "fingerprint!: Fp",
2773 b.file_kind AS "file_kind!: FileExtension"
2774 FROM library_books lb
2775 INNER JOIN books b ON b.fingerprint = lb.book_fingerprint
2776 WHERE lb.library_id = ?
2777 "#,
2778 library_id,
2779 )
2780 .fetch_all(&mut *tx)
2781 .await?;
2782
2783 let mut purged: Vec<Fp> = Vec::new();
2784
2785 for row in rows {
2786 let kind = row.file_kind;
2787
2788 if allowed_kinds.contains(&kind) {
2789 continue;
2790 }
2791
2792 sqlx::query!(
2793 r#"DELETE FROM library_books WHERE library_id = ? AND book_fingerprint = ?"#,
2794 library_id,
2795 row.fingerprint,
2796 )
2797 .execute(&mut *tx)
2798 .await?;
2799
2800 let ref_count: i64 = sqlx::query_scalar!(
2801 r#"SELECT COUNT(*) FROM library_books WHERE book_fingerprint = ?"#,
2802 row.fingerprint,
2803 )
2804 .fetch_one(&mut *tx)
2805 .await?;
2806
2807 if ref_count == 0 {
2808 sqlx::query!(
2809 r#"DELETE FROM books WHERE fingerprint = ?"#,
2810 row.fingerprint,
2811 )
2812 .execute(&mut *tx)
2813 .await?;
2814 }
2815
2816 tracing::info!(fp = %row.fingerprint, kind = %kind, "removed disallowed book from library");
2817 purged.push(row.fingerprint);
2818 }
2819
2820 tx.commit().await?;
2821 tracing::debug!(count = purged.len(), "disallowed kind cleanup complete");
2822 Ok(purged)
2823 })
2824 }
2825}
2826
2827#[cfg(test)]
2828mod tests {
2829 use super::*;
2830 use crate::db::Database;
2831 use crate::metadata::ReaderInfo;
2832 use chrono::Local;
2833 use std::collections::BTreeSet;
2834 use std::path::{Path, PathBuf};
2835 use std::str::FromStr;
2836
2837 fn create_test_db() -> (Database, Db) {
2838 let db = Database::new(":memory:").expect("failed to create in-memory database");
2839 db.migrate().expect("failed to run migrations");
2840 let libdb = Db::new(&db);
2841 (db, libdb)
2842 }
2843
2844 fn register_test_library(libdb: &Db, path: &str, name: &str) -> i64 {
2845 libdb
2846 .register_library(path, name)
2847 .expect("failed to register library")
2848 }
2849
2850 fn make_info(path: &str, title: &str, author: &str) -> Info {
2851 Info {
2852 title: title.to_string(),
2853 author: author.to_string(),
2854 file: FileInfo {
2855 path: PathBuf::from(path),
2856 kind: "epub".to_string(),
2857 size: 1024,
2858 ..Default::default()
2859 },
2860 ..Default::default()
2861 }
2862 }
2863
2864 #[test]
2865 fn midpoint_rank_both_none_returns_stride() {
2866 assert_eq!(midpoint_rank(&[None, None], 0), Some(SORT_RANK_STRIDE));
2867 }
2868
2869 #[test]
2870 fn midpoint_rank_empty_slice_returns_stride() {
2871 assert_eq!(midpoint_rank(&[], 0), Some(SORT_RANK_STRIDE));
2872 }
2873
2874 #[test]
2875 fn midpoint_rank_left_none_right_some_bisects() {
2876 assert_eq!(midpoint_rank(&[Some(10)], 0), Some(5));
2878 }
2879
2880 #[test]
2881 fn midpoint_rank_left_none_right_some_exactly_one_returns_none() {
2882 assert_eq!(midpoint_rank(&[Some(1)], 0), None);
2883 }
2884
2885 #[test]
2886 fn midpoint_rank_left_none_right_some_zero_returns_none() {
2887 assert_eq!(midpoint_rank(&[Some(0)], 0), None);
2888 }
2889
2890 #[test]
2891 fn midpoint_rank_left_some_right_none_adds_stride() {
2892 assert_eq!(midpoint_rank(&[Some(5)], 1), Some(5 + SORT_RANK_STRIDE));
2894 }
2895
2896 #[test]
2897 fn midpoint_rank_left_some_right_some_bisects() {
2898 assert_eq!(midpoint_rank(&[Some(2), Some(10)], 1), Some(6));
2900 }
2901
2902 #[test]
2903 fn midpoint_rank_adjacent_values_returns_none() {
2904 assert_eq!(midpoint_rank(&[Some(5), Some(6)], 1), None);
2906 }
2907
2908 #[test]
2909 fn midpoint_rank_equal_values_returns_none() {
2910 assert_eq!(midpoint_rank(&[Some(5), Some(5)], 1), None);
2912 }
2913
2914 #[test]
2915 fn midpoint_rank_none_slots_ignored_on_left_side() {
2916 assert_eq!(midpoint_rank(&[None, Some(20)], 1), Some(10));
2918 }
2919
2920 #[test]
2921 fn midpoint_rank_pos_beyond_slice_uses_last_as_left() {
2922 let ranks = vec![Some(500i64)];
2924 assert_eq!(midpoint_rank(&ranks, 1), Some(500 + SORT_RANK_STRIDE));
2925 }
2926
2927 #[test]
2928 fn test_insert_and_get_book() {
2929 let (_db, libdb) = create_test_db();
2930 let fp = Fp::from_u64(1);
2931
2932 let info = Info {
2933 title: "Test Book".to_string(),
2934 subtitle: "A Test".to_string(),
2935 author: "John Doe, Jane Smith".to_string(),
2936 year: "2024".to_string(),
2937 language: "en".to_string(),
2938 publisher: "Test Press".to_string(),
2939 series: "Test Series".to_string(),
2940 number: "1".to_string(),
2941 categories: vec!["Fiction".to_string(), "Science".to_string()]
2942 .into_iter()
2943 .collect(),
2944 file: FileInfo {
2945 path: PathBuf::from("/tmp/test.pdf"),
2946 kind: "pdf".to_string(),
2947 size: 1024,
2948 ..Default::default()
2949 },
2950 added: Local::now().naive_local(),
2951 ..Default::default()
2952 };
2953
2954 let library_id = register_test_library(&libdb, "/tmp/test_library", "Test Library");
2955 libdb
2956 .insert_book(library_id, fp, &info)
2957 .expect("failed to insert book");
2958
2959 let books = libdb
2960 .get_all_books(library_id)
2961 .expect("failed to get books");
2962 let retrieved_info = books.iter().find(|info| info.fp == Some(fp)).cloned();
2963 assert!(retrieved_info.is_some(), "book should exist in database");
2964
2965 let retrieved_info = retrieved_info.unwrap();
2966 assert_eq!(retrieved_info.title, "Test Book");
2967 assert_eq!(retrieved_info.subtitle, "A Test");
2968 assert_eq!(retrieved_info.author, "John Doe, Jane Smith");
2969 assert_eq!(retrieved_info.year, "2024");
2970 assert_eq!(retrieved_info.language, "en");
2971 assert_eq!(retrieved_info.publisher, "Test Press");
2972 assert_eq!(retrieved_info.series, "Test Series");
2973 assert_eq!(retrieved_info.number, "1");
2974 assert_eq!(retrieved_info.file.path, PathBuf::from("/tmp/test.pdf"));
2975 assert_eq!(retrieved_info.file.kind, "pdf");
2976 assert_eq!(retrieved_info.file.size, 1024);
2977 }
2978
2979 #[test]
2980 fn test_insert_book_with_reading_state() {
2981 let (_db, libdb) = create_test_db();
2982 let fp = Fp::from_u64(2);
2983
2984 let reader_info = ReaderInfo {
2985 current_page: 42,
2986 pages_count: 100,
2987 ..Default::default()
2988 };
2989 let info = Info {
2990 title: "Book with Reading State".to_string(),
2991 author: "Test Author".to_string(),
2992 file: FileInfo {
2993 path: PathBuf::from("/tmp/test2.pdf"),
2994 kind: "pdf".to_string(),
2995 size: 2048,
2996 ..Default::default()
2997 },
2998 reader_info: Some(reader_info.clone()),
2999 ..Default::default()
3000 };
3001
3002 let library_id = register_test_library(&libdb, "/tmp/test_library2", "Test Library 2");
3003 libdb
3004 .insert_book(library_id, fp, &info)
3005 .expect("failed to insert book");
3006
3007 let books = libdb
3008 .get_all_books(library_id)
3009 .expect("failed to get books");
3010 let retrieved = books
3011 .iter()
3012 .find(|info| info.fp == Some(fp))
3013 .cloned()
3014 .unwrap();
3015 assert_eq!(retrieved.title, "Book with Reading State");
3016
3017 assert!(
3018 retrieved.reader_info.is_some(),
3019 "reading state should exist"
3020 );
3021 let retrieved_reader = retrieved.reader_info.unwrap();
3022 assert_eq!(retrieved_reader.current_page, 42);
3023 assert_eq!(retrieved_reader.pages_count, 100);
3024 assert!(!retrieved_reader.finished);
3025 }
3026
3027 #[test]
3028 fn test_delete_book() {
3029 let (_db, libdb) = create_test_db();
3030 let fp = Fp::from_u64(3);
3031
3032 let info = Info {
3033 title: "Book to Delete".to_string(),
3034 author: "Delete Author".to_string(),
3035 file: FileInfo {
3036 path: PathBuf::from("/tmp/delete.pdf"),
3037 kind: "pdf".to_string(),
3038 size: 512,
3039 ..Default::default()
3040 },
3041 ..Default::default()
3042 };
3043
3044 let library_id = register_test_library(&libdb, "/tmp/test_library3", "Test Library 3");
3045 libdb
3046 .insert_book(library_id, fp, &info)
3047 .expect("failed to insert book");
3048
3049 let books = libdb
3050 .get_all_books(library_id)
3051 .expect("failed to get books");
3052 assert!(
3053 books.iter().any(|info| info.fp == Some(fp)),
3054 "book should exist before delete"
3055 );
3056
3057 libdb
3058 .delete_book(library_id, fp)
3059 .expect("failed to delete book");
3060
3061 let books = libdb
3062 .get_all_books(library_id)
3063 .expect("failed to get books");
3064 assert!(
3065 !books.iter().any(|info| info.fp == Some(fp)),
3066 "book should not exist after delete"
3067 );
3068 }
3069
3070 #[test]
3071 fn test_multiple_books() {
3072 let (_db, libdb) = create_test_db();
3073 let library_id = register_test_library(&libdb, "/tmp/test_library4", "Test Library 4");
3074
3075 for i in 1..=5 {
3076 let fp = Fp::from_u64(i as u64);
3077 let info = Info {
3078 title: format!("Book {}", i),
3079 author: format!("Author {}", i),
3080 file: FileInfo {
3081 path: PathBuf::from(format!("/tmp/book{}.pdf", i)),
3082 kind: "pdf".to_string(),
3083 size: (i * 100) as u64,
3084 ..Default::default()
3085 },
3086 ..Default::default()
3087 };
3088
3089 libdb
3090 .insert_book(library_id, fp, &info)
3091 .expect("failed to insert book");
3092 }
3093
3094 let books = libdb
3095 .get_all_books(library_id)
3096 .expect("failed to get books");
3097 for i in 1..=5 {
3098 let fp = Fp::from_u64(i as u64);
3099 let retrieved = books
3100 .iter()
3101 .find(|info| info.fp == Some(fp))
3102 .cloned()
3103 .unwrap();
3104 assert_eq!(retrieved.title, format!("Book {}", i));
3105 assert_eq!(retrieved.author, format!("Author {}", i));
3106 }
3107 }
3108
3109 #[test]
3110 fn test_update_book() {
3111 let (_db, libdb) = create_test_db();
3112 let fp = Fp::from_u64(4);
3113
3114 let mut info = Info {
3115 title: "Original Title".to_string(),
3116 author: "Original Author".to_string(),
3117 file: FileInfo {
3118 path: PathBuf::from("/tmp/update.pdf"),
3119 kind: "pdf".to_string(),
3120 size: 1024,
3121 ..Default::default()
3122 },
3123 ..Default::default()
3124 };
3125
3126 let library_id = register_test_library(&libdb, "/tmp/test_library5", "Test Library 5");
3127 libdb
3128 .insert_book(library_id, fp, &info)
3129 .expect("failed to insert book");
3130
3131 info.title = "Updated Title".to_string();
3132 info.author = "Updated Author".to_string();
3133 info.year = "2025".to_string();
3134
3135 libdb
3136 .update_book(library_id, fp, &info)
3137 .expect("failed to update book");
3138
3139 let books = libdb
3140 .get_all_books(library_id)
3141 .expect("failed to get books");
3142 let updated = books
3143 .iter()
3144 .find(|info| info.fp == Some(fp))
3145 .cloned()
3146 .unwrap();
3147 assert_eq!(updated.title, "Updated Title");
3148 assert_eq!(updated.author, "Updated Author");
3149 assert_eq!(updated.year, "2025");
3150 }
3151
3152 #[test]
3153 fn test_get_all_books() {
3154 let (_db, libdb) = create_test_db();
3155 let library_id = register_test_library(&libdb, "/tmp/test_library6", "Test Library 6");
3156
3157 for i in 1..=3 {
3158 let fp = Fp::from_u64(i as u64);
3159 let info = Info {
3160 title: format!("Book {}", i),
3161 author: format!("Author {}", i),
3162 file: FileInfo {
3163 path: PathBuf::from(format!("/tmp/book{}.pdf", i)),
3164 kind: "pdf".to_string(),
3165 size: (i * 100) as u64,
3166 ..Default::default()
3167 },
3168 ..Default::default()
3169 };
3170
3171 libdb
3172 .insert_book(library_id, fp, &info)
3173 .expect("failed to insert book");
3174 }
3175
3176 let all_books = libdb
3177 .get_all_books(library_id)
3178 .expect("failed to get all books");
3179 assert_eq!(all_books.len(), 3);
3180
3181 let titles: Vec<String> = all_books.iter().map(|info| info.title.clone()).collect();
3182 assert!(titles.contains(&"Book 1".to_string()));
3183 assert!(titles.contains(&"Book 2".to_string()));
3184 assert!(titles.contains(&"Book 3".to_string()));
3185 }
3186
3187 #[test]
3188 fn test_get_book_by_path_and_fingerprint() {
3189 let (_db, libdb) = create_test_db();
3190 let library_id =
3191 register_test_library(&libdb, "/tmp/test_library_lookup", "Lookup Library");
3192 let fp = Fp::from_str("00000000000000A1").unwrap();
3193
3194 let mut info = make_info("nested/book.pdf", "Lookup Book", "Lookup Author");
3195 info.reader_info = Some(ReaderInfo {
3196 current_page: 7,
3197 pages_count: 21,
3198 ..Default::default()
3199 });
3200
3201 libdb
3202 .insert_book(library_id, fp, &info)
3203 .expect("failed to insert book");
3204
3205 let by_path = libdb
3206 .get_book_by_path(library_id, Path::new("nested/book.pdf"))
3207 .expect("failed to get book by path")
3208 .expect("book should exist by path");
3209 assert_eq!(by_path.fp, Some(fp));
3210 assert_eq!(by_path.title, "Lookup Book");
3211 assert_eq!(by_path.file.path, PathBuf::from("nested/book.pdf"));
3212 assert_eq!(by_path.reader_info.unwrap().current_page, 7);
3213
3214 let by_fp = libdb
3215 .get_book_by_fingerprint(library_id, fp)
3216 .expect("failed to get book by fingerprint")
3217 .expect("book should exist by fingerprint");
3218 assert_eq!(by_fp.fp, Some(fp));
3219 assert_eq!(by_fp.title, "Lookup Book");
3220 assert_eq!(by_fp.file.path, PathBuf::from("nested/book.pdf"));
3221
3222 assert!(
3223 libdb
3224 .get_book_by_path(library_id, Path::new("missing.pdf"))
3225 .expect("lookup should succeed")
3226 .is_none()
3227 );
3228 assert!(
3229 libdb
3230 .get_book_by_fingerprint(library_id, Fp::from_str("00000000000000FF").unwrap())
3231 .expect("lookup should succeed")
3232 .is_none()
3233 );
3234 }
3235
3236 #[test]
3237 fn test_batch_get_books_by_fingerprints() {
3238 let (_db, libdb) = create_test_db();
3239 let library_id =
3240 register_test_library(&libdb, "/tmp/test_library_batch_lookup", "Batch Lookup");
3241
3242 let fp1 = Fp::from_str("00000000000000B1").unwrap();
3243 let fp2 = Fp::from_str("00000000000000B2").unwrap();
3244 let missing = Fp::from_str("00000000000000BF").unwrap();
3245
3246 libdb
3247 .insert_book(
3248 library_id,
3249 fp1,
3250 &make_info("a/book1.pdf", "Book 1", "Author 1"),
3251 )
3252 .expect("failed to insert first book");
3253 libdb
3254 .insert_book(
3255 library_id,
3256 fp2,
3257 &make_info("b/book2.pdf", "Book 2", "Author 2"),
3258 )
3259 .expect("failed to insert second book");
3260
3261 let books = libdb
3262 .batch_get_books_by_fingerprints(library_id, &[fp1, missing, fp2])
3263 .expect("failed to batch get books");
3264
3265 assert_eq!(books.len(), 2);
3266 assert_eq!(books.get(&fp1).expect("missing fp1").title, "Book 1");
3267 assert_eq!(books.get(&fp2).expect("missing fp2").title, "Book 2");
3268 assert!(!books.contains_key(&missing));
3269
3270 let empty = libdb
3271 .batch_get_books_by_fingerprints(library_id, &[])
3272 .expect("empty batch should succeed");
3273 assert!(empty.is_empty());
3274 }
3275
3276 #[test]
3277 fn test_count_books() {
3278 let (_db, libdb) = create_test_db();
3279 let library_id = register_test_library(&libdb, "/tmp/test_library_count", "Count Library");
3280
3281 assert_eq!(libdb.count_books(library_id).expect("count failed"), 0);
3282
3283 let fp1 = Fp::from_str("00000000000000C1").unwrap();
3284 let fp2 = Fp::from_str("00000000000000C2").unwrap();
3285
3286 libdb
3287 .insert_book(
3288 library_id,
3289 fp1,
3290 &make_info("count/one.pdf", "One", "Author"),
3291 )
3292 .expect("failed to insert first book");
3293 libdb
3294 .insert_book(
3295 library_id,
3296 fp2,
3297 &make_info("count/two.pdf", "Two", "Author"),
3298 )
3299 .expect("failed to insert second book");
3300
3301 assert_eq!(libdb.count_books(library_id).expect("count failed"), 2);
3302 }
3303
3304 #[test]
3305 fn test_list_books_under_prefix() {
3306 let (_db, libdb) = create_test_db();
3307 let library_id =
3308 register_test_library(&libdb, "/tmp/test_library_prefix_books", "Prefix Books");
3309
3310 let fp1 = Fp::from_str("00000000000000D1").unwrap();
3311 let fp2 = Fp::from_str("00000000000000D2").unwrap();
3312 let fp3 = Fp::from_str("00000000000000D3").unwrap();
3313
3314 libdb
3315 .insert_book(
3316 library_id,
3317 fp1,
3318 &make_info("dir1/book1.pdf", "Book 1", "Author 1"),
3319 )
3320 .expect("failed to insert book 1");
3321 libdb
3322 .insert_book(
3323 library_id,
3324 fp2,
3325 &make_info("dir1/sub/book2.pdf", "Book 2", "Author 2"),
3326 )
3327 .expect("failed to insert book 2");
3328 libdb
3329 .insert_book(
3330 library_id,
3331 fp3,
3332 &make_info("dir2/book3.pdf", "Book 3", "Author 3"),
3333 )
3334 .expect("failed to insert book 3");
3335
3336 let root_books = libdb
3337 .list_books_under_prefix(library_id, Path::new(""))
3338 .expect("root listing failed");
3339 assert_eq!(root_books.len(), 3);
3340
3341 let dir1_books = libdb
3342 .list_books_under_prefix(library_id, Path::new("dir1"))
3343 .expect("dir1 listing failed");
3344 let dir1_paths: BTreeSet<PathBuf> =
3345 dir1_books.into_iter().map(|info| info.file.path).collect();
3346 assert_eq!(
3347 dir1_paths,
3348 BTreeSet::from([
3349 PathBuf::from("dir1/book1.pdf"),
3350 PathBuf::from("dir1/sub/book2.pdf"),
3351 ])
3352 );
3353
3354 let exact_book = libdb
3355 .list_books_under_prefix(library_id, Path::new("dir2/book3.pdf"))
3356 .expect("exact listing failed");
3357 assert_eq!(exact_book.len(), 1);
3358 assert_eq!(exact_book[0].fp, Some(fp3));
3359 }
3360
3361 #[test]
3362 fn test_list_directories_under_prefix() {
3363 let (_db, libdb) = create_test_db();
3364 let library_id =
3365 register_test_library(&libdb, "/tmp/test_library_prefix_dirs", "Prefix Dirs");
3366
3367 libdb
3368 .insert_book(
3369 library_id,
3370 Fp::from_str("00000000000000E1").unwrap(),
3371 &make_info("dir1/book1.pdf", "Book 1", "Author 1"),
3372 )
3373 .expect("failed to insert book 1");
3374 libdb
3375 .insert_book(
3376 library_id,
3377 Fp::from_str("00000000000000E2").unwrap(),
3378 &make_info("dir1/sub/book2.pdf", "Book 2", "Author 2"),
3379 )
3380 .expect("failed to insert book 2");
3381 libdb
3382 .insert_book(
3383 library_id,
3384 Fp::from_str("00000000000000E3").unwrap(),
3385 &make_info("dir2/book3.pdf", "Book 3", "Author 3"),
3386 )
3387 .expect("failed to insert book 3");
3388
3389 let root_dirs = libdb
3390 .list_directories_under_prefix(library_id, Path::new(""))
3391 .expect("root dir listing failed");
3392 assert_eq!(
3393 root_dirs,
3394 BTreeSet::from([PathBuf::from("dir1"), PathBuf::from("dir2")])
3395 );
3396
3397 let dir1_dirs = libdb
3398 .list_directories_under_prefix(library_id, Path::new("dir1"))
3399 .expect("dir1 dir listing failed");
3400 assert_eq!(dir1_dirs, BTreeSet::from([PathBuf::from("sub")]));
3401
3402 let leaf_dirs = libdb
3403 .list_directories_under_prefix(library_id, Path::new("dir2"))
3404 .expect("leaf dir listing failed");
3405 assert!(leaf_dirs.is_empty());
3406 }
3407
3408 #[test]
3409 fn test_reading_state_crud() {
3410 let (_db, libdb) = create_test_db();
3411 let fp = Fp::from_u64(5);
3412
3413 let info = Info {
3414 title: "Book with State".to_string(),
3415 author: "State Author".to_string(),
3416 file: FileInfo {
3417 path: PathBuf::from("/tmp/state.pdf"),
3418 kind: "pdf".to_string(),
3419 size: 1024,
3420 ..Default::default()
3421 },
3422 ..Default::default()
3423 };
3424
3425 let library_id = register_test_library(&libdb, "/tmp/test_library7", "Test Library 7");
3426 libdb
3427 .insert_book(library_id, fp, &info)
3428 .expect("failed to insert book");
3429
3430 let mut reader_info = ReaderInfo {
3431 current_page: 50,
3432 pages_count: 200,
3433 ..Default::default()
3434 };
3435
3436 libdb
3437 .save_reading_state(fp, &reader_info)
3438 .expect("failed to save reading state");
3439
3440 let books = libdb
3441 .get_all_books(library_id)
3442 .expect("failed to get books");
3443 let retrieved = books
3444 .iter()
3445 .find(|info| info.fp == Some(fp))
3446 .cloned()
3447 .unwrap();
3448 let retrieved_reader = retrieved.reader_info.unwrap();
3449
3450 assert_eq!(retrieved_reader.current_page, 50);
3451 assert_eq!(retrieved_reader.pages_count, 200);
3452 assert!(!retrieved_reader.finished);
3453 reader_info.current_page = 100;
3454 reader_info.finished = true;
3455
3456 libdb
3457 .save_reading_state(fp, &reader_info)
3458 .expect("failed to update reading state");
3459
3460 let books = libdb
3461 .get_all_books(library_id)
3462 .expect("failed to get books");
3463 let updated = books
3464 .iter()
3465 .find(|info| info.fp == Some(fp))
3466 .cloned()
3467 .unwrap();
3468 let updated_reader = updated.reader_info.unwrap();
3469
3470 assert_eq!(updated_reader.current_page, 100);
3471 assert!(updated_reader.finished);
3472 }
3473
3474 #[test]
3475 fn test_batch_insert_books() {
3476 let (_db, libdb) = create_test_db();
3477 let library_id = register_test_library(&libdb, "/tmp/test_library8", "Test Library 8");
3478
3479 let mut books = Vec::new();
3480 for i in 1..=5 {
3481 let fp = Fp::from_u64((i + 100) as u64);
3482 let info = Info {
3483 title: format!("Batch Book {}", i),
3484 author: format!("Batch Author {}, Co-Author {}", i, i + 1),
3485 year: format!("{}", 2020 + i),
3486 file: FileInfo {
3487 path: PathBuf::from(format!("/tmp/batch{}.pdf", i)),
3488 kind: "pdf".to_string(),
3489 size: (i * 100) as u64,
3490 ..Default::default()
3491 },
3492 ..Default::default()
3493 };
3494 books.push((fp, info));
3495 }
3496
3497 let book_refs: Vec<(Fp, &Info)> = books.iter().map(|(fp, info)| (*fp, info)).collect();
3498
3499 libdb
3500 .batch_insert_books(library_id, &book_refs)
3501 .expect("failed to batch insert books");
3502
3503 let all_books = libdb
3504 .get_all_books(library_id)
3505 .expect("failed to get books");
3506 for (fp, info) in &books {
3507 let retrieved = all_books
3508 .iter()
3509 .find(|info| info.fp == Some(*fp))
3510 .cloned()
3511 .expect("book should exist");
3512 assert_eq!(retrieved.title, info.title);
3513 assert_eq!(retrieved.author, info.author);
3514 assert_eq!(retrieved.year, info.year);
3515 }
3516
3517 let all_books = libdb
3518 .get_all_books(library_id)
3519 .expect("failed to get all books");
3520 assert_eq!(all_books.len(), 5);
3521 }
3522
3523 #[test]
3524 fn test_batch_update_books() {
3525 let (_db, libdb) = create_test_db();
3526 let library_id = register_test_library(&libdb, "/tmp/test_library9", "Test Library 9");
3527
3528 let mut books = Vec::new();
3529 for i in 1..=3 {
3530 let fp = Fp::from_u64((i + 200) as u64);
3531 let mut info = Info {
3532 title: format!("Original Book {}", i),
3533 author: format!("Original Author {}", i),
3534 file: FileInfo {
3535 path: PathBuf::from(format!("/tmp/update{}.pdf", i)),
3536 kind: "pdf".to_string(),
3537 size: (i * 100) as u64,
3538 ..Default::default()
3539 },
3540 ..Default::default()
3541 };
3542 libdb
3543 .insert_book(library_id, fp, &info)
3544 .expect("failed to insert book");
3545
3546 info.title = format!("Updated Book {}", i);
3547 info.author = format!("Updated Author {}", i);
3548 books.push((fp, info));
3549 }
3550
3551 let book_refs: Vec<(Fp, &Info)> = books.iter().map(|(fp, info)| (*fp, info)).collect();
3552
3553 libdb
3554 .batch_update_books(library_id, &book_refs)
3555 .expect("failed to batch update books");
3556
3557 let all_books = libdb
3558 .get_all_books(library_id)
3559 .expect("failed to get books");
3560 for (fp, info) in &books {
3561 let retrieved = all_books
3562 .iter()
3563 .find(|info| info.fp == Some(*fp))
3564 .cloned()
3565 .expect("book should exist");
3566 assert_eq!(retrieved.title, info.title);
3567 assert_eq!(retrieved.author, info.author);
3568 }
3569 }
3570
3571 #[test]
3572 fn test_delete_reading_state() {
3573 let (_db, libdb) = create_test_db();
3574 let fp = Fp::from_str("0000000000000006").unwrap();
3575
3576 let info = Info {
3577 title: "Book".to_string(),
3578 author: "Author".to_string(),
3579 file: FileInfo {
3580 path: PathBuf::from("/tmp/book.pdf"),
3581 kind: "pdf".to_string(),
3582 size: 100,
3583 ..Default::default()
3584 },
3585 reader_info: Some(ReaderInfo {
3586 current_page: 10,
3587 pages_count: 50,
3588 ..Default::default()
3589 }),
3590 ..Default::default()
3591 };
3592
3593 let library_id = register_test_library(&libdb, "/tmp/test_library10", "Test Library 10");
3594 libdb
3595 .insert_book(library_id, fp, &info)
3596 .expect("failed to insert book");
3597
3598 let books = libdb
3599 .get_all_books(library_id)
3600 .expect("failed to get books");
3601 let retrieved = books
3602 .iter()
3603 .find(|info| info.fp == Some(fp))
3604 .cloned()
3605 .unwrap();
3606 assert!(retrieved.reader_info.is_some());
3607
3608 libdb
3609 .delete_reading_state(fp)
3610 .expect("failed to delete reading state");
3611
3612 let books = libdb
3613 .get_all_books(library_id)
3614 .expect("failed to get books");
3615 let retrieved = books
3616 .iter()
3617 .find(|info| info.fp == Some(fp))
3618 .cloned()
3619 .unwrap();
3620 assert!(retrieved.reader_info.is_none());
3621 }
3622
3623 #[test]
3624 fn test_thumbnail_crud() {
3625 let (_db, libdb) = create_test_db();
3626 let library_id =
3627 register_test_library(&libdb, "/tmp/test_library_thumbnails", "Thumbnail Library");
3628 let fp = Fp::from_str("0000000000000007").unwrap();
3629 let data = vec![1, 2, 3, 4, 5];
3630
3631 libdb
3632 .insert_book(
3633 library_id,
3634 fp,
3635 &make_info("thumbs/book.pdf", "Thumb Book", "Thumb Author"),
3636 )
3637 .expect("failed to insert book");
3638
3639 let thumbnail = libdb.get_thumbnail(fp).expect("failed to get thumbnail");
3640 assert!(thumbnail.is_none());
3641
3642 libdb
3643 .save_thumbnail(fp, &data)
3644 .expect("failed to save thumbnail");
3645
3646 let thumbnail = libdb.get_thumbnail(fp).expect("failed to get thumbnail");
3647 assert_eq!(thumbnail, Some(data.clone()));
3648
3649 libdb
3650 .delete_thumbnail(fp)
3651 .expect("failed to delete thumbnail");
3652
3653 let thumbnail = libdb.get_thumbnail(fp).expect("failed to get thumbnail");
3654 assert!(thumbnail.is_none());
3655 }
3656
3657 #[test]
3658 fn test_books_without_thumbnails() {
3659 let (_db, libdb) = create_test_db();
3660 let library_id = register_test_library(
3661 &libdb,
3662 "/tmp/test_library_thumbnails_missing",
3663 "Missing Thumbs Library",
3664 );
3665 let fp1 = Fp::from_str("0000000000000008").unwrap();
3666 let fp2 = Fp::from_str("0000000000000009").unwrap();
3667
3668 libdb
3669 .insert_book(
3670 library_id,
3671 fp1,
3672 &make_info("thumbs/book1.epub", "Thumb Book 1", "Thumb Author 1"),
3673 )
3674 .expect("failed to insert book 1");
3675
3676 libdb
3677 .insert_book(
3678 library_id,
3679 fp2,
3680 &make_info("thumbs/book2.epub", "Thumb Book 2", "Thumb Author 2"),
3681 )
3682 .expect("failed to insert book 2");
3683
3684 let missing = libdb.books_without_thumbnails(library_id).unwrap();
3685 assert_eq!(missing.len(), 2);
3686 assert!(missing.contains(&(fp1, PathBuf::from("thumbs/book1.epub"))));
3687 assert!(missing.contains(&(fp2, PathBuf::from("thumbs/book2.epub"))));
3688
3689 libdb.save_thumbnail(fp1, &[1, 2, 3]).unwrap();
3690
3691 let missing = libdb.books_without_thumbnails(library_id).unwrap();
3692 assert_eq!(missing.len(), 1);
3693 assert_eq!(missing[0], (fp2, PathBuf::from("thumbs/book2.epub")));
3694
3695 libdb.save_thumbnail(fp2, &[4, 5, 6]).unwrap();
3696
3697 let missing = libdb.books_without_thumbnails(library_id).unwrap();
3698 assert!(missing.is_empty());
3699 }
3700
3701 #[test]
3702 fn test_batch_delete_thumbnails() {
3703 let (_db, libdb) = create_test_db();
3704 let library_id = register_test_library(
3705 &libdb,
3706 "/tmp/test_library_batch_delete_thumbnails",
3707 "Batch Delete Thumbnails",
3708 );
3709 let fp1 = Fp::from_str("00000000000000F1").unwrap();
3710 let fp2 = Fp::from_str("00000000000000F2").unwrap();
3711 let fp3 = Fp::from_str("00000000000000F3").unwrap();
3712
3713 libdb
3714 .insert_book(
3715 library_id,
3716 fp1,
3717 &make_info("thumbs/one.pdf", "One", "Author One"),
3718 )
3719 .expect("failed to insert first book");
3720 libdb
3721 .insert_book(
3722 library_id,
3723 fp2,
3724 &make_info("thumbs/two.pdf", "Two", "Author Two"),
3725 )
3726 .expect("failed to insert second book");
3727
3728 libdb
3729 .save_thumbnail(fp1, &[1, 2, 3])
3730 .expect("failed to save thumbnail 1");
3731 libdb
3732 .save_thumbnail(fp2, &[4, 5, 6])
3733 .expect("failed to save thumbnail 2");
3734
3735 libdb
3736 .batch_delete_thumbnails(&[fp1, fp3])
3737 .expect("failed to batch delete thumbnails");
3738
3739 assert!(
3740 libdb
3741 .get_thumbnail(fp1)
3742 .expect("failed to get thumbnail 1")
3743 .is_none()
3744 );
3745 assert_eq!(
3746 libdb.get_thumbnail(fp2).expect("failed to get thumbnail 2"),
3747 Some(vec![4, 5, 6])
3748 );
3749 }
3750
3751 #[test]
3752 fn test_move_thumbnail() {
3753 let (_db, libdb) = create_test_db();
3754 let library_id =
3755 register_test_library(&libdb, "/tmp/test_library_move_thumbnail", "Move Thumbnail");
3756 let from_fp = Fp::from_str("0000000000000008").unwrap();
3757 let to_fp = Fp::from_str("0000000000000009").unwrap();
3758 let data = vec![9, 8, 7, 6];
3759
3760 libdb
3761 .insert_book(
3762 library_id,
3763 from_fp,
3764 &make_info("thumbs/from.pdf", "From Book", "From Author"),
3765 )
3766 .expect("failed to insert source book");
3767 libdb
3768 .insert_book(
3769 library_id,
3770 to_fp,
3771 &make_info("thumbs/to.pdf", "To Book", "To Author"),
3772 )
3773 .expect("failed to insert destination book");
3774
3775 libdb
3776 .save_thumbnail(from_fp, &data)
3777 .expect("failed to save thumbnail");
3778
3779 libdb
3780 .move_thumbnail(from_fp, to_fp)
3781 .expect("failed to move thumbnail");
3782
3783 let old_thumbnail = libdb
3784 .get_thumbnail(from_fp)
3785 .expect("failed to get old thumbnail");
3786 assert!(old_thumbnail.is_none());
3787
3788 let new_thumbnail = libdb
3789 .get_thumbnail(to_fp)
3790 .expect("failed to get new thumbnail");
3791 assert_eq!(new_thumbnail, Some(data));
3792 }
3793
3794 #[test]
3795 fn test_batch_move_thumbnails() {
3796 let (_db, libdb) = create_test_db();
3797 let library_id = register_test_library(
3798 &libdb,
3799 "/tmp/test_library_batch_move_thumbnails",
3800 "Batch Move Thumbnails",
3801 );
3802 let from_fp1 = Fp::from_str("0000000000000101").unwrap();
3803 let to_fp1 = Fp::from_str("0000000000000102").unwrap();
3804 let from_fp2 = Fp::from_str("0000000000000103").unwrap();
3805 let to_fp2 = Fp::from_str("0000000000000104").unwrap();
3806
3807 libdb
3808 .insert_book(
3809 library_id,
3810 from_fp1,
3811 &make_info("thumbs/from1.pdf", "From 1", "Author 1"),
3812 )
3813 .expect("failed to insert source book 1");
3814 libdb
3815 .insert_book(
3816 library_id,
3817 to_fp1,
3818 &make_info("thumbs/to1.pdf", "To 1", "Author 1"),
3819 )
3820 .expect("failed to insert destination book 1");
3821 libdb
3822 .insert_book(
3823 library_id,
3824 from_fp2,
3825 &make_info("thumbs/from2.pdf", "From 2", "Author 2"),
3826 )
3827 .expect("failed to insert source book 2");
3828 libdb
3829 .insert_book(
3830 library_id,
3831 to_fp2,
3832 &make_info("thumbs/to2.pdf", "To 2", "Author 2"),
3833 )
3834 .expect("failed to insert destination book 2");
3835
3836 libdb
3837 .save_thumbnail(from_fp1, &[1, 1, 1])
3838 .expect("failed to save thumbnail 1");
3839 libdb
3840 .save_thumbnail(from_fp2, &[2, 2, 2])
3841 .expect("failed to save thumbnail 2");
3842
3843 libdb
3844 .batch_move_thumbnails(&[(from_fp1, to_fp1), (from_fp2, to_fp2)])
3845 .expect("failed to batch move thumbnails");
3846
3847 assert!(
3848 libdb
3849 .get_thumbnail(from_fp1)
3850 .expect("failed to get old thumbnail 1")
3851 .is_none()
3852 );
3853 assert!(
3854 libdb
3855 .get_thumbnail(from_fp2)
3856 .expect("failed to get old thumbnail 2")
3857 .is_none()
3858 );
3859 assert_eq!(
3860 libdb
3861 .get_thumbnail(to_fp1)
3862 .expect("failed to get new thumbnail 1"),
3863 Some(vec![1, 1, 1])
3864 );
3865 assert_eq!(
3866 libdb
3867 .get_thumbnail(to_fp2)
3868 .expect("failed to get new thumbnail 2"),
3869 Some(vec![2, 2, 2])
3870 );
3871 }
3872
3873 #[test]
3874 fn test_list_book_handles_and_update_book_path() {
3875 let (_db, libdb) = create_test_db();
3876 let library_id = register_test_library(&libdb, "/tmp/test_library_handles", "Handles");
3877
3878 let fp = Fp::from_str("0000000000000111").unwrap();
3879 libdb
3880 .insert_book(library_id, fp, &make_info("old/path.pdf", "Book", "Author"))
3881 .expect("failed to insert book");
3882
3883 let handles = libdb
3884 .list_book_handles(library_id)
3885 .expect("failed to list handles");
3886 assert_eq!(handles.len(), 1);
3887 assert_eq!(handles[0].fp, fp);
3888 assert_eq!(handles[0].relat, PathBuf::from("old/path.pdf"));
3889 assert_eq!(handles[0].abs, PathBuf::from(""));
3890
3891 libdb
3892 .update_book_path(
3893 library_id,
3894 fp,
3895 Path::new("new/path.pdf"),
3896 Path::new("/abs/new/path.pdf"),
3897 )
3898 .expect("failed to update book path");
3899
3900 let updated = libdb
3901 .get_book_by_fingerprint(library_id, fp)
3902 .expect("failed to get updated book")
3903 .expect("book should exist");
3904 assert_eq!(updated.file.path, PathBuf::from("new/path.pdf"));
3905 assert_eq!(
3906 updated.file.absolute_path,
3907 PathBuf::from("/abs/new/path.pdf")
3908 );
3909
3910 let handles = libdb
3911 .list_book_handles(library_id)
3912 .expect("failed to list handles after update");
3913 assert_eq!(handles.len(), 1);
3914 assert_eq!(handles[0].fp, fp);
3915 assert_eq!(handles[0].relat, PathBuf::from("new/path.pdf"));
3916 assert_eq!(handles[0].abs, PathBuf::from("/abs/new/path.pdf"));
3917 }
3918
3919 #[test]
3920 fn test_batch_update_book_paths() {
3921 let (_db, libdb) = create_test_db();
3922 let library_id =
3923 register_test_library(&libdb, "/tmp/test_library_batch_paths", "Batch Paths");
3924
3925 let fp1 = Fp::from_str("0000000000000121").unwrap();
3926 let fp2 = Fp::from_str("0000000000000122").unwrap();
3927
3928 libdb
3929 .insert_book(library_id, fp1, &make_info("old/one.pdf", "One", "Author"))
3930 .expect("failed to insert first book");
3931 libdb
3932 .insert_book(library_id, fp2, &make_info("old/two.pdf", "Two", "Author"))
3933 .expect("failed to insert second book");
3934
3935 let fp1_mtime = UnixTimestamp::from(1_700_000_001);
3936 let fp1_size = FileSize::from(111);
3937 let fp2_mtime = UnixTimestamp::from(1_700_000_002);
3938 let fp2_size = FileSize::from(222);
3939
3940 libdb
3941 .batch_update_book_paths(
3942 library_id,
3943 &[
3944 PathUpdate {
3945 fp: fp1,
3946 relat: PathBuf::from("new/one.pdf"),
3947 abs: PathBuf::from("/abs/new/one.pdf"),
3948 mtime: Some(fp1_mtime),
3949 file_size: Some(fp1_size),
3950 },
3951 PathUpdate {
3952 fp: fp2,
3953 relat: PathBuf::from("new/two.pdf"),
3954 abs: PathBuf::from("/abs/new/two.pdf"),
3955 mtime: Some(fp2_mtime),
3956 file_size: Some(fp2_size),
3957 },
3958 ],
3959 )
3960 .expect("failed to batch update book paths");
3961
3962 let updated1 = libdb
3963 .get_book_by_fingerprint(library_id, fp1)
3964 .expect("failed to get first updated book")
3965 .expect("first book should exist");
3966 let updated2 = libdb
3967 .get_book_by_fingerprint(library_id, fp2)
3968 .expect("failed to get second updated book")
3969 .expect("second book should exist");
3970
3971 assert_eq!(updated1.file.path, PathBuf::from("new/one.pdf"));
3972 assert_eq!(
3973 updated1.file.absolute_path,
3974 PathBuf::from("/abs/new/one.pdf")
3975 );
3976 assert_eq!(updated2.file.path, PathBuf::from("new/two.pdf"));
3977 assert_eq!(
3978 updated2.file.absolute_path,
3979 PathBuf::from("/abs/new/two.pdf")
3980 );
3981
3982 let handles = libdb
3983 .list_book_handles(library_id)
3984 .expect("failed to list handles");
3985 let h1 = handles.iter().find(|h| h.fp == fp1).expect("missing fp1");
3986 let h2 = handles.iter().find(|h| h.fp == fp2).expect("missing fp2");
3987 assert_eq!(h1.mtime, Some(fp1_mtime));
3988 assert_eq!(h1.file_size, Some(fp1_size));
3989 assert_eq!(h2.mtime, Some(fp2_mtime));
3990 assert_eq!(h2.file_size, Some(fp2_size));
3991 }
3992
3993 #[test]
3994 fn test_batch_delete_books() {
3995 let (_db, libdb) = create_test_db();
3996 let library_id = register_test_library(&libdb, "/tmp/test_library11", "Test Library 11");
3997
3998 let mut fps = Vec::new();
3999 for i in 1..=4 {
4000 let fp = Fp::from_u64((i + 300) as u64);
4001 let info = Info {
4002 title: format!("Delete Book {}", i),
4003 author: format!("Delete Author {}", i),
4004 file: FileInfo {
4005 path: PathBuf::from(format!("/tmp/delete{}.pdf", i)),
4006 kind: "pdf".to_string(),
4007 size: (i * 100) as u64,
4008 ..Default::default()
4009 },
4010 ..Default::default()
4011 };
4012 libdb
4013 .insert_book(library_id, fp, &info)
4014 .expect("failed to insert book");
4015 fps.push(fp);
4016 }
4017
4018 let all_books = libdb
4019 .get_all_books(library_id)
4020 .expect("failed to get books");
4021 assert_eq!(all_books.len(), 4);
4022
4023 libdb
4024 .batch_delete_books(library_id, &fps[0..2])
4025 .expect("failed to batch delete books");
4026
4027 let remaining_books = libdb
4028 .get_all_books(library_id)
4029 .expect("failed to get books");
4030 assert_eq!(remaining_books.len(), 2);
4031 assert!(remaining_books.iter().all(|info| {
4032 let fp = info.fp.expect("book should have fingerprint");
4033 fp == fps[2] || fp == fps[3]
4034 }));
4035 }
4036
4037 #[test]
4038 fn test_batch_operations_with_empty_input() {
4039 let (_db, libdb) = create_test_db();
4040 let library_id = register_test_library(&libdb, "/tmp/test_library12", "Test Library 12");
4041
4042 let empty_books: Vec<(Fp, &Info)> = Vec::new();
4043 let empty_fps: Vec<Fp> = Vec::new();
4044
4045 libdb
4046 .batch_insert_books(library_id, &empty_books)
4047 .expect("empty batch insert should succeed");
4048 libdb
4049 .batch_update_books(library_id, &empty_books)
4050 .expect("empty batch update should succeed");
4051 libdb
4052 .batch_delete_books(library_id, &empty_fps)
4053 .expect("empty batch delete should succeed");
4054 }
4055
4056 #[test]
4057 fn test_categories_round_trip() {
4058 let (_db, libdb) = create_test_db();
4059 let fp = Fp::from_u64(0x99);
4060
4061 let info = Info {
4062 title: "Categorized Book".to_string(),
4063 author: "Cat Author".to_string(),
4064 file: FileInfo {
4065 path: PathBuf::from("/tmp/cat.pdf"),
4066 kind: "pdf".to_string(),
4067 size: 512,
4068 ..Default::default()
4069 },
4070 categories: ["Fiction", "Science", "History"]
4071 .iter()
4072 .map(|s| s.to_string())
4073 .collect(),
4074 ..Default::default()
4075 };
4076
4077 let library_id = libdb
4078 .register_library("/tmp/test_library_cat", "Cat Library")
4079 .expect("failed to register library");
4080 libdb
4081 .insert_book(library_id, fp, &info)
4082 .expect("failed to insert book");
4083
4084 let books = libdb
4085 .get_all_books(library_id)
4086 .expect("failed to get books");
4087 let retrieved = books
4088 .iter()
4089 .find(|info| info.fp == Some(fp))
4090 .cloned()
4091 .expect("book should exist");
4092
4093 assert_eq!(retrieved.categories, info.categories);
4094 }
4095
4096 #[test]
4097 fn test_categories_updated_on_update_book() {
4098 let (_db, libdb) = create_test_db();
4099 let fp = Fp::from_u64(0x9A);
4100
4101 let mut info = Info {
4102 title: "Updateable Book".to_string(),
4103 author: "Update Author".to_string(),
4104 file: FileInfo {
4105 path: PathBuf::from("/tmp/upd_cat.pdf"),
4106 kind: "pdf".to_string(),
4107 size: 512,
4108 ..Default::default()
4109 },
4110 categories: ["OldCat"].iter().map(|s| s.to_string()).collect(),
4111 ..Default::default()
4112 };
4113
4114 let library_id =
4115 register_test_library(&libdb, "/tmp/test_library_upd_cat", "Upd Cat Library");
4116 libdb
4117 .insert_book(library_id, fp, &info)
4118 .expect("failed to insert book");
4119
4120 info.categories = ["NewCat1", "NewCat2"]
4121 .iter()
4122 .map(|s| s.to_string())
4123 .collect();
4124 libdb
4125 .update_book(library_id, fp, &info)
4126 .expect("failed to update book");
4127
4128 let books = libdb
4129 .get_all_books(library_id)
4130 .expect("failed to get books");
4131 let retrieved = books
4132 .iter()
4133 .find(|info| info.fp == Some(fp))
4134 .cloned()
4135 .expect("book should exist");
4136
4137 assert_eq!(retrieved.categories, info.categories);
4138 }
4139
4140 #[test]
4141 fn most_recently_opened_reading_book_none_when_empty() {
4142 let (_db, libdb) = create_test_db();
4143 let library_id = register_test_library(&libdb, "/tmp/mro_empty", "MRO Empty");
4144 assert!(
4145 libdb
4146 .most_recently_opened_reading_book(library_id)
4147 .expect("query failed")
4148 .is_none()
4149 );
4150 }
4151
4152 #[test]
4153 fn most_recently_opened_reading_book_none_when_only_finished() {
4154 let (_db, libdb) = create_test_db();
4155 let library_id = register_test_library(&libdb, "/tmp/mro_finished", "MRO Finished");
4156 let fp = Fp::from_str("AA00000000000001").unwrap();
4157 let mut info = make_info("mro/finished.pdf", "Finished", "Author");
4158 info.reader_info = Some(ReaderInfo {
4159 current_page: 100,
4160 pages_count: 100,
4161 finished: true,
4162 ..Default::default()
4163 });
4164 libdb.insert_book(library_id, fp, &info).unwrap();
4165
4166 assert!(
4167 libdb
4168 .most_recently_opened_reading_book(library_id)
4169 .expect("query failed")
4170 .is_none()
4171 );
4172 }
4173
4174 #[test]
4175 fn most_recently_opened_reading_book_returns_unfinished() {
4176 let (_db, libdb) = create_test_db();
4177 let library_id = register_test_library(&libdb, "/tmp/mro_unfinished", "MRO Unfinished");
4178
4179 let fp1 = Fp::from_str("AA00000000000002").unwrap();
4180 let fp2 = Fp::from_str("AA00000000000003").unwrap();
4181
4182 let mut info1 = make_info("mro/a.pdf", "Older Book", "Author");
4183 info1.reader_info = Some(ReaderInfo {
4184 current_page: 10,
4185 pages_count: 200,
4186 ..Default::default()
4187 });
4188
4189 let mut info2 = make_info("mro/b.pdf", "Newer Book", "Author");
4190 info2.reader_info = Some(ReaderInfo {
4193 current_page: 50,
4194 pages_count: 200,
4195 ..Default::default()
4196 });
4197
4198 libdb.insert_book(library_id, fp1, &info1).unwrap();
4199 libdb.insert_book(library_id, fp2, &info2).unwrap();
4200
4201 let result = libdb
4203 .most_recently_opened_reading_book(library_id)
4204 .expect("query failed");
4205 assert!(result.is_some());
4206 assert!(!result.unwrap().reader_info.unwrap().finished);
4207 }
4208
4209 #[test]
4210 fn most_recently_opened_reading_book_skips_never_opened() {
4211 let (_db, libdb) = create_test_db();
4212 let library_id = register_test_library(&libdb, "/tmp/mro_new", "MRO New");
4213
4214 let fp = Fp::from_str("AA00000000000004").unwrap();
4216 libdb
4217 .insert_book(
4218 library_id,
4219 fp,
4220 &make_info("mro/new.pdf", "New Book", "Author"),
4221 )
4222 .unwrap();
4223
4224 assert!(
4225 libdb
4226 .most_recently_opened_reading_book(library_id)
4227 .expect("query failed")
4228 .is_none()
4229 );
4230 }
4231
4232 #[test]
4233 fn compute_sort_keys_empty_library_is_noop() {
4234 let (_db, libdb) = create_test_db();
4235 let library_id = register_test_library(&libdb, "/tmp/sort_empty", "Sort Empty");
4236 libdb.compute_sort_keys(library_id).expect("compute failed");
4237 }
4238
4239 #[test]
4240 fn compute_sort_keys_assigns_ranks_to_all_books() {
4241 let (_db, libdb) = create_test_db();
4242 let library_id = register_test_library(&libdb, "/tmp/sort_assign", "Sort Assign");
4243
4244 for i in 1u64..=3 {
4245 let fp = Fp::from_str(&format!("BB{:014X}", i)).unwrap();
4246 libdb
4247 .insert_book(
4248 library_id,
4249 fp,
4250 &make_info(&format!("s/{i}.pdf"), &format!("Book {i}"), "Author"),
4251 )
4252 .unwrap();
4253 }
4254
4255 libdb.compute_sort_keys(library_id).expect("compute failed");
4256
4257 let (books, total) = libdb
4259 .page_books(library_id, Path::new(""), SortMethod::Title, false, 10, 0)
4260 .expect("page_books failed");
4261 assert_eq!(total, 3);
4262 assert_eq!(books.len(), 3);
4263 }
4264
4265 #[test]
4266 fn insert_sort_rank_places_new_book_between_neighbours() {
4267 let (_db, libdb) = create_test_db();
4268 let library_id = register_test_library(&libdb, "/tmp/sort_insert", "Sort Insert");
4269
4270 let fp_a = Fp::from_str("CC00000000000001").unwrap();
4272 let fp_z = Fp::from_str("CC00000000000002").unwrap();
4273 let info_a = make_info("s/aardvark.pdf", "Aardvark", "Author");
4274 let info_z = make_info("s/zebra.pdf", "Zebra", "Author");
4275
4276 libdb.insert_book(library_id, fp_a, &info_a).unwrap();
4277 libdb.insert_book(library_id, fp_z, &info_z).unwrap();
4278 libdb.compute_sort_keys(library_id).unwrap();
4279
4280 let fp_m = Fp::from_str("CC00000000000003").unwrap();
4282 let info_m = make_info("s/mango.pdf", "Mango", "Author");
4283 libdb.insert_book(library_id, fp_m, &info_m).unwrap();
4284 libdb.insert_sort_rank(library_id, fp_m, &info_m).unwrap();
4285
4286 let (books, _) = libdb
4287 .page_books(library_id, Path::new(""), SortMethod::Title, false, 10, 0)
4288 .expect("page_books failed");
4289
4290 let titles: Vec<&str> = books.iter().map(|b| b.title.as_str()).collect();
4291 assert_eq!(titles, vec!["Aardvark", "Mango", "Zebra"]);
4292 }
4293
4294 #[test]
4295 fn insert_sort_rank_falls_back_to_full_recompute_when_gaps_exhausted() {
4296 let (_db, libdb) = create_test_db();
4297 let library_id = register_test_library(&libdb, "/tmp/sort_exhaust", "Sort Exhaust");
4298
4299 let fp_a = Fp::from_str("DD00000000000001").unwrap();
4301 let fp_b = Fp::from_str("DD00000000000002").unwrap();
4302 libdb
4303 .insert_book(library_id, fp_a, &make_info("s/a.pdf", "Alpha", "Author"))
4304 .unwrap();
4305 libdb
4306 .insert_book(library_id, fp_b, &make_info("s/b.pdf", "Beta", "Author"))
4307 .unwrap();
4308 libdb.compute_sort_keys(library_id).unwrap();
4309
4310 for i in 1u64..=12 {
4313 let fp = Fp::from_str(&format!("DD{:014X}", i + 10)).unwrap();
4314 let title = format!("Am{i:012}");
4315 let info = make_info(&format!("s/am{i}.pdf"), &title, "Author");
4316 libdb.insert_book(library_id, fp, &info).unwrap();
4317 libdb.insert_sort_rank(library_id, fp, &info).unwrap();
4319 }
4320
4321 let (books, _) = libdb
4322 .page_books(library_id, Path::new(""), SortMethod::Title, false, 20, 0)
4323 .expect("page_books failed");
4324 assert_eq!(books[0].title, "Alpha");
4326 }
4327
4328 fn insert_books_for_paging(libdb: &Db, library_id: i64) {
4329 let books = [
4330 (
4331 "p/a.pdf", "Alpha", "Zelda", "2020", "epub", 500u64, 100usize,
4332 ),
4333 ("p/b.pdf", "Beta", "Alpha", "2019", "pdf", 300, 50),
4334 ("p/c.pdf", "Gamma", "Mia", "2021", "epub", 700, 200),
4335 ];
4336 for (i, (path, title, author, year, kind, size, pages)) in books.iter().enumerate() {
4337 let fp = Fp::from_str(&format!("EE{:014X}", i + 1)).unwrap();
4338 let mut info = make_info(path, title, author);
4339 info.year = year.to_string();
4340 info.file.kind = kind.to_string();
4341 info.file.size = *size;
4342 info.reader_info = Some(ReaderInfo {
4343 current_page: pages / 2,
4344 pages_count: *pages,
4345 ..Default::default()
4346 });
4347 libdb.insert_book(library_id, fp, &info).unwrap();
4348 }
4349 libdb.compute_sort_keys(library_id).unwrap();
4350 }
4351
4352 #[test]
4353 fn page_books_sort_by_author() {
4354 let (_db, libdb) = create_test_db();
4355 let library_id = register_test_library(&libdb, "/tmp/pb_author", "PB Author");
4356 insert_books_for_paging(&libdb, library_id);
4357
4358 let (books, total) = libdb
4359 .page_books(library_id, Path::new(""), SortMethod::Author, false, 10, 0)
4360 .unwrap();
4361 assert_eq!(total, 3);
4362 assert_eq!(books[0].author, "Alpha");
4363 }
4364
4365 #[test]
4366 fn page_books_sort_by_year() {
4367 let (_db, libdb) = create_test_db();
4368 let library_id = register_test_library(&libdb, "/tmp/pb_year", "PB Year");
4369 insert_books_for_paging(&libdb, library_id);
4370
4371 let (books, _) = libdb
4372 .page_books(library_id, Path::new(""), SortMethod::Year, false, 10, 0)
4373 .unwrap();
4374 assert_eq!(books[0].year, "2019");
4375 }
4376
4377 #[test]
4378 fn page_books_sort_by_size() {
4379 let (_db, libdb) = create_test_db();
4380 let library_id = register_test_library(&libdb, "/tmp/pb_size", "PB Size");
4381 insert_books_for_paging(&libdb, library_id);
4382
4383 let (books, _) = libdb
4384 .page_books(library_id, Path::new(""), SortMethod::Size, false, 10, 0)
4385 .unwrap();
4386 assert_eq!(books[0].file.size, 300);
4387 }
4388
4389 #[test]
4390 fn page_books_sort_by_kind() {
4391 let (_db, libdb) = create_test_db();
4392 let library_id = register_test_library(&libdb, "/tmp/pb_kind", "PB Kind");
4393 insert_books_for_paging(&libdb, library_id);
4394
4395 let (books, _) = libdb
4396 .page_books(library_id, Path::new(""), SortMethod::Kind, false, 10, 0)
4397 .unwrap();
4398 assert_eq!(books[0].file.kind, "epub");
4400 }
4401
4402 #[test]
4403 fn page_books_sort_by_pages() {
4404 let (_db, libdb) = create_test_db();
4405 let library_id = register_test_library(&libdb, "/tmp/pb_pages", "PB Pages");
4406 insert_books_for_paging(&libdb, library_id);
4407
4408 let (books, _) = libdb
4409 .page_books(library_id, Path::new(""), SortMethod::Pages, false, 10, 0)
4410 .unwrap();
4411 assert_eq!(books[0].reader_info.as_ref().unwrap().pages_count, 50);
4412 }
4413
4414 #[test]
4415 fn page_books_sort_by_opened() {
4416 let (_db, libdb) = create_test_db();
4417 let library_id = register_test_library(&libdb, "/tmp/pb_opened", "PB Opened");
4418 insert_books_for_paging(&libdb, library_id);
4419
4420 let (books, total) = libdb
4422 .page_books(library_id, Path::new(""), SortMethod::Opened, false, 10, 0)
4423 .unwrap();
4424 assert_eq!(total, 3);
4425 assert_eq!(books.len(), 3);
4426 }
4427
4428 #[test]
4429 fn page_books_sort_by_added() {
4430 let (_db, libdb) = create_test_db();
4431 let library_id = register_test_library(&libdb, "/tmp/pb_added", "PB Added");
4432 insert_books_for_paging(&libdb, library_id);
4433
4434 let (books, _) = libdb
4435 .page_books(library_id, Path::new(""), SortMethod::Added, false, 10, 0)
4436 .unwrap();
4437 assert_eq!(books.len(), 3);
4438 }
4439
4440 #[test]
4441 fn page_books_sort_by_status() {
4442 let (_db, libdb) = create_test_db();
4443 let library_id = register_test_library(&libdb, "/tmp/pb_status", "PB Status");
4444
4445 let fp_new = Fp::from_str("FF00000000000001").unwrap();
4446 let fp_reading = Fp::from_str("FF00000000000002").unwrap();
4447 let fp_finished = Fp::from_str("FF00000000000003").unwrap();
4448
4449 libdb
4450 .insert_book(library_id, fp_new, &make_info("s/new.pdf", "New", "A"))
4451 .unwrap();
4452
4453 let mut reading = make_info("s/reading.pdf", "Reading", "A");
4454 reading.reader_info = Some(ReaderInfo {
4455 current_page: 10,
4456 pages_count: 100,
4457 finished: false,
4458 ..Default::default()
4459 });
4460 libdb.insert_book(library_id, fp_reading, &reading).unwrap();
4461
4462 let mut finished = make_info("s/finished.pdf", "Finished", "A");
4463 finished.reader_info = Some(ReaderInfo {
4464 current_page: 100,
4465 pages_count: 100,
4466 finished: true,
4467 ..Default::default()
4468 });
4469 libdb
4470 .insert_book(library_id, fp_finished, &finished)
4471 .unwrap();
4472
4473 let (books, _) = libdb
4474 .page_books(library_id, Path::new(""), SortMethod::Status, false, 10, 0)
4475 .unwrap();
4476 assert_eq!(books.len(), 3);
4477 assert_eq!(books[0].title, "Finished");
4479 }
4480
4481 #[test]
4482 fn page_books_sort_by_progress() {
4483 let (_db, libdb) = create_test_db();
4484 let library_id = register_test_library(&libdb, "/tmp/pb_progress", "PB Progress");
4485
4486 let fp_finished = Fp::from_str("FE00000000000001").unwrap();
4487 let fp_reading = Fp::from_str("FE00000000000002").unwrap();
4488
4489 let mut finished = make_info("s/fin.pdf", "Finished", "A");
4490 finished.reader_info = Some(ReaderInfo {
4491 current_page: 100,
4492 pages_count: 100,
4493 finished: true,
4494 ..Default::default()
4495 });
4496 libdb
4497 .insert_book(library_id, fp_finished, &finished)
4498 .unwrap();
4499
4500 let mut reading = make_info("s/read.pdf", "Reading", "A");
4501 reading.reader_info = Some(ReaderInfo {
4502 current_page: 50,
4503 pages_count: 100,
4504 finished: false,
4505 ..Default::default()
4506 });
4507 libdb.insert_book(library_id, fp_reading, &reading).unwrap();
4508
4509 let (books, _) = libdb
4510 .page_books(
4511 library_id,
4512 Path::new(""),
4513 SortMethod::Progress,
4514 false,
4515 10,
4516 0,
4517 )
4518 .unwrap();
4519 assert_eq!(books.len(), 2);
4520 assert_eq!(books[0].title, "Finished");
4521 }
4522
4523 #[test]
4524 fn page_books_reverse_order() {
4525 let (_db, libdb) = create_test_db();
4526 let library_id = register_test_library(&libdb, "/tmp/pb_reverse", "PB Reverse");
4527 insert_books_for_paging(&libdb, library_id);
4528
4529 let (asc, _) = libdb
4530 .page_books(library_id, Path::new(""), SortMethod::Title, false, 10, 0)
4531 .unwrap();
4532 let (desc, _) = libdb
4533 .page_books(library_id, Path::new(""), SortMethod::Title, true, 10, 0)
4534 .unwrap();
4535
4536 assert_eq!(asc[0].title, desc[desc.len() - 1].title);
4537 assert_eq!(asc[asc.len() - 1].title, desc[0].title);
4538 }
4539
4540 #[test]
4541 fn page_books_pagination_offset() {
4542 let (_db, libdb) = create_test_db();
4543 let library_id = register_test_library(&libdb, "/tmp/pb_pagination", "PB Pagination");
4544 insert_books_for_paging(&libdb, library_id);
4545 libdb.compute_sort_keys(library_id).unwrap();
4546
4547 let (page1, total) = libdb
4548 .page_books(library_id, Path::new(""), SortMethod::Title, false, 2, 0)
4549 .unwrap();
4550 let (page2, _) = libdb
4551 .page_books(library_id, Path::new(""), SortMethod::Title, false, 2, 2)
4552 .unwrap();
4553
4554 assert_eq!(total, 3);
4555 assert_eq!(page1.len(), 2);
4556 assert_eq!(page2.len(), 1);
4557 assert_ne!(page1[0].title, page2[0].title);
4558 }
4559
4560 #[test]
4561 fn parse_zoom_mode_none_returns_none() {
4562 assert!(Db::parse_zoom_mode(None).is_none());
4563 }
4564
4565 #[test]
4566 fn parse_zoom_mode_invalid_json_returns_none() {
4567 assert!(Db::parse_zoom_mode(Some(&"not-valid-json".to_string())).is_none());
4568 }
4569
4570 #[test]
4571 fn parse_scroll_mode_none_returns_none() {
4572 assert!(Db::parse_scroll_mode(None).is_none());
4573 }
4574
4575 #[test]
4576 fn parse_scroll_mode_invalid_json_returns_none() {
4577 assert!(Db::parse_scroll_mode(Some(&"{{bad}}".to_string())).is_none());
4578 }
4579
4580 #[test]
4581 fn parse_text_align_none_returns_none() {
4582 assert!(Db::parse_text_align(None).is_none());
4583 }
4584
4585 #[test]
4586 fn parse_text_align_invalid_json_returns_none() {
4587 assert!(Db::parse_text_align(Some(&"???".to_string())).is_none());
4588 }
4589
4590 #[test]
4591 fn parse_cropping_margins_none_returns_none() {
4592 assert!(Db::parse_cropping_margins(None).is_none());
4593 }
4594
4595 #[test]
4596 fn parse_cropping_margins_invalid_json_returns_none() {
4597 assert!(Db::parse_cropping_margins(Some(&"bad".to_string())).is_none());
4598 }
4599
4600 #[test]
4601 fn parse_page_names_none_returns_empty_map() {
4602 assert!(Db::parse_page_names(None).is_empty());
4603 }
4604
4605 #[test]
4606 fn parse_page_names_invalid_json_returns_empty_map() {
4607 assert!(Db::parse_page_names(Some(&"!".to_string())).is_empty());
4608 }
4609
4610 #[test]
4611 fn parse_bookmarks_none_returns_empty_set() {
4612 assert!(Db::parse_bookmarks(None).is_empty());
4613 }
4614
4615 #[test]
4616 fn parse_bookmarks_invalid_json_returns_empty_set() {
4617 assert!(Db::parse_bookmarks(Some(&"!".to_string())).is_empty());
4618 }
4619
4620 #[test]
4621 fn parse_annotations_none_returns_empty_vec() {
4622 assert!(Db::parse_annotations(None).is_empty());
4623 }
4624
4625 #[test]
4626 fn parse_annotations_invalid_json_returns_empty_vec() {
4627 assert!(Db::parse_annotations(Some(&"!".to_string())).is_empty());
4628 }
4629
4630 #[test]
4631 fn parse_page_offset_both_some_returns_point() {
4632 let p = Db::parse_page_offset(Some(3), Some(7));
4633 assert!(p.is_some());
4634 let p = p.unwrap();
4635 assert_eq!(p.x, 3);
4636 assert_eq!(p.y, 7);
4637 }
4638
4639 #[test]
4640 fn parse_page_offset_one_none_returns_none() {
4641 assert!(Db::parse_page_offset(Some(1), None).is_none());
4642 assert!(Db::parse_page_offset(None, Some(1)).is_none());
4643 assert!(Db::parse_page_offset(None, None).is_none());
4644 }
4645
4646 #[test]
4647 fn extract_authors_none_returns_empty_string() {
4648 assert_eq!(Db::extract_authors(None), "");
4649 }
4650
4651 #[test]
4652 fn extract_authors_comma_separated_joins_with_space() {
4653 assert_eq!(
4654 Db::extract_authors(Some("Alice,Bob,Carol".to_string())),
4655 "Alice, Bob, Carol"
4656 );
4657 }
4658
4659 #[test]
4660 fn extract_categories_none_returns_empty_set() {
4661 assert!(Db::extract_categories(None).is_empty());
4662 }
4663
4664 #[test]
4665 fn extract_categories_filters_empty_strings() {
4666 let cats = Db::extract_categories(Some(",Fiction,,Science,".to_string()));
4667 assert_eq!(cats.len(), 2);
4668 assert!(cats.contains("Fiction"));
4669 assert!(cats.contains("Science"));
4670 }
4671
4672 #[test]
4673 fn test_batch_insert_with_reading_state() {
4674 let (_db, libdb) = create_test_db();
4675 let library_id = register_test_library(&libdb, "/tmp/test_library13", "Test Library 13");
4676
4677 let mut books = Vec::new();
4678 for i in 1..=3 {
4679 let fp = Fp::from_u64((i + 400) as u64);
4680 let reader_info = ReaderInfo {
4681 current_page: i * 10,
4682 pages_count: i * 100,
4683 finished: i % 2 == 0,
4684 ..Default::default()
4685 };
4686 let info = Info {
4687 title: format!("Book with State {}", i),
4688 author: format!("State Author {}", i),
4689 file: FileInfo {
4690 path: PathBuf::from(format!("/tmp/state{}.pdf", i)),
4691 kind: "pdf".to_string(),
4692 size: (i * 100) as u64,
4693 ..Default::default()
4694 },
4695 reader_info: Some(reader_info),
4696 ..Default::default()
4697 };
4698
4699 books.push((fp, info));
4700 }
4701
4702 let book_refs: Vec<(Fp, &Info)> = books.iter().map(|(fp, info)| (*fp, info)).collect();
4703
4704 libdb
4705 .batch_insert_books(library_id, &book_refs)
4706 .expect("failed to batch insert books with reading state");
4707
4708 let all_books = libdb
4709 .get_all_books(library_id)
4710 .expect("failed to get books");
4711 for (fp, info) in &books {
4712 let retrieved = all_books
4713 .iter()
4714 .find(|info| info.fp == Some(*fp))
4715 .cloned()
4716 .expect("book should exist");
4717 assert_eq!(retrieved.title, info.title);
4718
4719 assert!(
4720 retrieved.reader_info.is_some(),
4721 "reading state should exist"
4722 );
4723 let retrieved_state = retrieved.reader_info.unwrap();
4724 let original_state = info.reader_info.as_ref().unwrap();
4725 assert_eq!(retrieved_state.current_page, original_state.current_page);
4726 assert_eq!(retrieved_state.pages_count, original_state.pages_count);
4727 assert_eq!(retrieved_state.finished, original_state.finished);
4728 }
4729 }
4730
4731 #[test]
4732 fn delete_books_with_disallowed_kinds_removes_wrong_kind() {
4733 use crate::settings::FileExtension;
4734
4735 let (_db, libdb) = create_test_db();
4736 let library_id =
4737 register_test_library(&libdb, "/tmp/test_disallowed_kinds", "Disallowed Kinds");
4738
4739 let epub_fp = Fp::from_u64(9001);
4740 let pdf_fp = Fp::from_u64(9002);
4741
4742 let epub_info = Info {
4743 title: "Epub Book".to_string(),
4744 file: FileInfo {
4745 path: PathBuf::from("book.epub"),
4746 kind: "epub".to_string(),
4747 size: 100,
4748 ..Default::default()
4749 },
4750 ..Default::default()
4751 };
4752 let pdf_info = Info {
4753 title: "Pdf Book".to_string(),
4754 file: FileInfo {
4755 path: PathBuf::from("book.pdf"),
4756 kind: "pdf".to_string(),
4757 size: 200,
4758 ..Default::default()
4759 },
4760 ..Default::default()
4761 };
4762
4763 libdb
4764 .batch_insert_books(library_id, &[(epub_fp, &epub_info), (pdf_fp, &pdf_info)])
4765 .expect("insert books");
4766
4767 let mut allowed = FxHashSet::default();
4768 allowed.insert(FileExtension::Epub);
4769
4770 let purged = libdb
4771 .delete_books_with_disallowed_kinds(library_id, &allowed)
4772 .expect("purge disallowed");
4773
4774 assert_eq!(purged, vec![pdf_fp], "only pdf should be purged");
4775
4776 let handles = libdb.list_book_handles(library_id).expect("handles");
4777 let fps: Vec<Fp> = handles.iter().map(|h| h.fp).collect();
4778
4779 assert!(fps.contains(&epub_fp), "epub should remain");
4780 assert!(!fps.contains(&pdf_fp), "pdf should be gone");
4781 }
4782}