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