Skip to main content

cadmus_core/library/db/
mod.rs

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