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    /// Returns `(fingerprint, path)` pairs for every book that does not have a thumbnail cached.
2572    #[cfg_attr(
2573        feature = "tracing",
2574        tracing::instrument(skip(self), fields(library_id))
2575    )]
2576    pub fn books_without_thumbnails(&self, library_id: i64) -> Result<Vec<(Fp, PathBuf)>, Error> {
2577        RUNTIME.block_on(async {
2578            let rows = sqlx::query!(
2579                r#"
2580                SELECT lb.book_fingerprint AS "fingerprint!: Fp",
2581                       lb.file_path        AS "file_path!: String"
2582                FROM library_books lb
2583                LEFT JOIN thumbnails t ON lb.book_fingerprint = t.fingerprint
2584                WHERE lb.library_id = ? AND t.fingerprint IS NULL
2585                "#,
2586                library_id,
2587            )
2588            .fetch_all(&self.pool)
2589            .await?;
2590
2591            rows.into_iter()
2592                .map(|row| Ok((row.fingerprint, PathBuf::from(row.file_path))))
2593                .collect()
2594        })
2595    }
2596
2597    /// Updates both the relative and absolute path of a book in a single transaction.
2598    /// No-op if the book is not found in the library.
2599    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(library_id, fp = %fp)))]
2600    pub fn update_book_path(
2601        &self,
2602        library_id: i64,
2603        fp: Fp,
2604        rel_path: &Path,
2605        abs_path: &Path,
2606    ) -> Result<(), Error> {
2607        let fp_str = fp.to_string();
2608        let rel_str = rel_path.to_string_lossy().into_owned();
2609        let abs_str = abs_path.to_string_lossy().into_owned();
2610
2611        RUNTIME.block_on(async {
2612            let mut tx = self.pool.begin().await?;
2613
2614            sqlx::query!(
2615                r#"UPDATE library_books SET file_path = ?, absolute_path = ? WHERE library_id = ? AND book_fingerprint = ?"#,
2616                rel_str,
2617                abs_str,
2618                library_id,
2619                fp_str,
2620            )
2621            .execute(&mut *tx)
2622            .await?;
2623
2624            tx.commit().await?;
2625            Ok(())
2626        })
2627    }
2628
2629    /// Updates relative and absolute paths for multiple books in a single transaction,
2630    /// with one combined UPDATE per entry. Used by `import()` after directory scanning
2631    /// to record the final locations of books that were moved or renamed on disk.
2632    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, updates), fields(library_id, count = updates.len())))]
2633    pub fn batch_update_book_paths(
2634        &self,
2635        library_id: i64,
2636        updates: &[(Fp, PathBuf, PathBuf)],
2637    ) -> Result<(), Error> {
2638        if updates.is_empty() {
2639            return Ok(());
2640        }
2641
2642        tracing::debug!(
2643            library_id,
2644            count = updates.len(),
2645            "batch updating book paths in library"
2646        );
2647
2648        RUNTIME.block_on(async {
2649            let mut tx = self.pool.begin().await?;
2650
2651            for (fp, rel_path, abs_path) in updates {
2652                let fp_str = fp.to_string();
2653                let rel_str = rel_path.to_string_lossy().into_owned();
2654                let abs_str = abs_path.to_string_lossy().into_owned();
2655
2656                sqlx::query!(
2657                    r#"UPDATE library_books SET file_path = ?, absolute_path = ? WHERE library_id = ? AND book_fingerprint = ?"#,
2658                    rel_str,
2659                    abs_str,
2660                    library_id,
2661                    fp_str,
2662                )
2663                .execute(&mut *tx)
2664                .await?;
2665            }
2666
2667            tx.commit().await?;
2668            Ok(())
2669        })
2670    }
2671
2672    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, fps), fields(library_id, count = fps.len())))]
2673    pub fn batch_delete_books(&self, library_id: i64, fps: &[Fp]) -> Result<(), Error> {
2674        if fps.is_empty() {
2675            return Ok(());
2676        }
2677
2678        tracing::debug!(
2679            library_id,
2680            count = fps.len(),
2681            "batch deleting books from library"
2682        );
2683
2684        RUNTIME.block_on(async {
2685            let mut tx = self.pool.begin().await?;
2686
2687            for fp in fps {
2688                let fp_str = fp.to_string();
2689
2690                sqlx::query!(
2691                    r#"DELETE FROM library_books WHERE library_id = ? AND book_fingerprint = ?"#,
2692                    library_id,
2693                    fp_str
2694                )
2695                .execute(&mut *tx)
2696                .await?;
2697
2698                let ref_count: i64 = sqlx::query_scalar!(
2699                    r#"SELECT COUNT(*) FROM library_books WHERE book_fingerprint = ?"#,
2700                    fp_str
2701                )
2702                .fetch_one(&mut *tx)
2703                .await?;
2704
2705                if ref_count == 0 {
2706                    sqlx::query!(
2707                        r#"DELETE FROM books WHERE fingerprint = ?"#,
2708                        fp_str
2709                    )
2710                    .execute(&mut *tx)
2711                    .await?;
2712                    tracing::debug!(fp = %fp, "book removed from database (no more library references)");
2713                } else {
2714                    tracing::debug!(fp = %fp, ref_count, "book kept in database (still referenced by other libraries)");
2715                }
2716            }
2717
2718            tx.commit().await?;
2719
2720            tracing::debug!(count = fps.len(), "batch delete complete");
2721            Ok(())
2722        })
2723    }
2724
2725    /// Deletes all `library_books` rows for this library whose `file_kind` is not in
2726    /// `allowed_kinds`, cleans up orphaned `books` rows, and returns the fingerprints
2727    /// of removed entries so callers can purge thumbnails.
2728    ///
2729    /// Called at the start of every import so that books whose kind was later removed
2730    /// from `allowed_kinds` do not persist in the database.
2731    #[cfg_attr(
2732        feature = "tracing",
2733        tracing::instrument(skip(self, allowed_kinds), fields(library_id))
2734    )]
2735    pub fn delete_books_with_disallowed_kinds(
2736        &self,
2737        library_id: i64,
2738        allowed_kinds: &FxHashSet<FileExtension>,
2739    ) -> Result<Vec<Fp>, Error> {
2740        RUNTIME.block_on(async {
2741            let mut tx = self.pool.begin().await?;
2742
2743            let rows = sqlx::query!(
2744                r#"
2745                SELECT
2746                    lb.book_fingerprint AS "fingerprint!: Fp",
2747                    b.file_kind AS "file_kind!: FileExtension"
2748                FROM library_books lb
2749                INNER JOIN books b ON b.fingerprint = lb.book_fingerprint
2750                WHERE lb.library_id = ?
2751                "#,
2752                library_id,
2753            )
2754            .fetch_all(&mut *tx)
2755            .await?;
2756
2757            let mut purged: Vec<Fp> = Vec::new();
2758
2759            for row in rows {
2760                let kind = row.file_kind;
2761
2762                if allowed_kinds.contains(&kind) {
2763                    continue;
2764                }
2765
2766                sqlx::query!(
2767                    r#"DELETE FROM library_books WHERE library_id = ? AND book_fingerprint = ?"#,
2768                    library_id,
2769                    row.fingerprint,
2770                )
2771                .execute(&mut *tx)
2772                .await?;
2773
2774                let ref_count: i64 = sqlx::query_scalar!(
2775                    r#"SELECT COUNT(*) FROM library_books WHERE book_fingerprint = ?"#,
2776                    row.fingerprint,
2777                )
2778                .fetch_one(&mut *tx)
2779                .await?;
2780
2781                if ref_count == 0 {
2782                    sqlx::query!(
2783                        r#"DELETE FROM books WHERE fingerprint = ?"#,
2784                        row.fingerprint,
2785                    )
2786                    .execute(&mut *tx)
2787                    .await?;
2788                }
2789
2790                tracing::info!(fp = %row.fingerprint, kind = %kind, "removed disallowed book from library");
2791                purged.push(row.fingerprint);
2792            }
2793
2794            tx.commit().await?;
2795            tracing::debug!(count = purged.len(), "disallowed kind cleanup complete");
2796            Ok(purged)
2797        })
2798    }
2799}
2800
2801#[cfg(test)]
2802mod tests {
2803    use super::*;
2804    use crate::db::Database;
2805    use crate::metadata::ReaderInfo;
2806    use chrono::Local;
2807    use std::collections::BTreeSet;
2808    use std::path::{Path, PathBuf};
2809    use std::str::FromStr;
2810
2811    fn create_test_db() -> (Database, Db) {
2812        let db = Database::new(":memory:").expect("failed to create in-memory database");
2813        db.migrate().expect("failed to run migrations");
2814        let libdb = Db::new(&db);
2815        (db, libdb)
2816    }
2817
2818    fn register_test_library(libdb: &Db, path: &str, name: &str) -> i64 {
2819        libdb
2820            .register_library(path, name)
2821            .expect("failed to register library")
2822    }
2823
2824    fn make_info(path: &str, title: &str, author: &str) -> Info {
2825        Info {
2826            title: title.to_string(),
2827            author: author.to_string(),
2828            file: FileInfo {
2829                path: PathBuf::from(path),
2830                kind: "epub".to_string(),
2831                size: 1024,
2832                ..Default::default()
2833            },
2834            ..Default::default()
2835        }
2836    }
2837
2838    #[test]
2839    fn midpoint_rank_both_none_returns_stride() {
2840        assert_eq!(midpoint_rank(&[None, None], 0), Some(SORT_RANK_STRIDE));
2841    }
2842
2843    #[test]
2844    fn midpoint_rank_empty_slice_returns_stride() {
2845        assert_eq!(midpoint_rank(&[], 0), Some(SORT_RANK_STRIDE));
2846    }
2847
2848    #[test]
2849    fn midpoint_rank_left_none_right_some_bisects() {
2850        // pos=0 → left=None, right=Some(10) → 10/2 = 5
2851        assert_eq!(midpoint_rank(&[Some(10)], 0), Some(5));
2852    }
2853
2854    #[test]
2855    fn midpoint_rank_left_none_right_some_exactly_one_returns_none() {
2856        assert_eq!(midpoint_rank(&[Some(1)], 0), None);
2857    }
2858
2859    #[test]
2860    fn midpoint_rank_left_none_right_some_zero_returns_none() {
2861        assert_eq!(midpoint_rank(&[Some(0)], 0), None);
2862    }
2863
2864    #[test]
2865    fn midpoint_rank_left_some_right_none_adds_stride() {
2866        // pos=1 → left=Some(5), right=None → 5 + 1000
2867        assert_eq!(midpoint_rank(&[Some(5)], 1), Some(5 + SORT_RANK_STRIDE));
2868    }
2869
2870    #[test]
2871    fn midpoint_rank_left_some_right_some_bisects() {
2872        // pos=1 → left=Some(2), right=Some(10) → (2+10)/2 = 6
2873        assert_eq!(midpoint_rank(&[Some(2), Some(10)], 1), Some(6));
2874    }
2875
2876    #[test]
2877    fn midpoint_rank_adjacent_values_returns_none() {
2878        // pos=1 → left=Some(5), right=Some(6) → mid=5 which is not > l
2879        assert_eq!(midpoint_rank(&[Some(5), Some(6)], 1), None);
2880    }
2881
2882    #[test]
2883    fn midpoint_rank_equal_values_returns_none() {
2884        // pos=1 → left=Some(5), right=Some(5) → mid=5 which is not > l
2885        assert_eq!(midpoint_rank(&[Some(5), Some(5)], 1), None);
2886    }
2887
2888    #[test]
2889    fn midpoint_rank_none_slots_ignored_on_left_side() {
2890        // Slot at pos-1 is None → flattens to left=None, right=Some(20) → 20/2=10
2891        assert_eq!(midpoint_rank(&[None, Some(20)], 1), Some(10));
2892    }
2893
2894    #[test]
2895    fn midpoint_rank_pos_beyond_slice_uses_last_as_left() {
2896        // pos beyond length → right is None; left is the last element
2897        let ranks = vec![Some(500i64)];
2898        assert_eq!(midpoint_rank(&ranks, 1), Some(500 + SORT_RANK_STRIDE));
2899    }
2900
2901    #[test]
2902    fn test_insert_and_get_book() {
2903        let (_db, libdb) = create_test_db();
2904        let fp = Fp::from_u64(1);
2905
2906        let info = Info {
2907            title: "Test Book".to_string(),
2908            subtitle: "A Test".to_string(),
2909            author: "John Doe, Jane Smith".to_string(),
2910            year: "2024".to_string(),
2911            language: "en".to_string(),
2912            publisher: "Test Press".to_string(),
2913            series: "Test Series".to_string(),
2914            number: "1".to_string(),
2915            categories: vec!["Fiction".to_string(), "Science".to_string()]
2916                .into_iter()
2917                .collect(),
2918            file: FileInfo {
2919                path: PathBuf::from("/tmp/test.pdf"),
2920                kind: "pdf".to_string(),
2921                size: 1024,
2922                ..Default::default()
2923            },
2924            added: Local::now().naive_local(),
2925            ..Default::default()
2926        };
2927
2928        let library_id = register_test_library(&libdb, "/tmp/test_library", "Test Library");
2929        libdb
2930            .insert_book(library_id, fp, &info)
2931            .expect("failed to insert book");
2932
2933        let books = libdb
2934            .get_all_books(library_id)
2935            .expect("failed to get books");
2936        let retrieved_info = books.iter().find(|info| info.fp == Some(fp)).cloned();
2937        assert!(retrieved_info.is_some(), "book should exist in database");
2938
2939        let retrieved_info = retrieved_info.unwrap();
2940        assert_eq!(retrieved_info.title, "Test Book");
2941        assert_eq!(retrieved_info.subtitle, "A Test");
2942        assert_eq!(retrieved_info.author, "John Doe, Jane Smith");
2943        assert_eq!(retrieved_info.year, "2024");
2944        assert_eq!(retrieved_info.language, "en");
2945        assert_eq!(retrieved_info.publisher, "Test Press");
2946        assert_eq!(retrieved_info.series, "Test Series");
2947        assert_eq!(retrieved_info.number, "1");
2948        assert_eq!(retrieved_info.file.path, PathBuf::from("/tmp/test.pdf"));
2949        assert_eq!(retrieved_info.file.kind, "pdf");
2950        assert_eq!(retrieved_info.file.size, 1024);
2951    }
2952
2953    #[test]
2954    fn test_insert_book_with_reading_state() {
2955        let (_db, libdb) = create_test_db();
2956        let fp = Fp::from_u64(2);
2957
2958        let reader_info = ReaderInfo {
2959            current_page: 42,
2960            pages_count: 100,
2961            ..Default::default()
2962        };
2963        let info = Info {
2964            title: "Book with Reading State".to_string(),
2965            author: "Test Author".to_string(),
2966            file: FileInfo {
2967                path: PathBuf::from("/tmp/test2.pdf"),
2968                kind: "pdf".to_string(),
2969                size: 2048,
2970                ..Default::default()
2971            },
2972            reader_info: Some(reader_info.clone()),
2973            ..Default::default()
2974        };
2975
2976        let library_id = register_test_library(&libdb, "/tmp/test_library2", "Test Library 2");
2977        libdb
2978            .insert_book(library_id, fp, &info)
2979            .expect("failed to insert book");
2980
2981        let books = libdb
2982            .get_all_books(library_id)
2983            .expect("failed to get books");
2984        let retrieved = books
2985            .iter()
2986            .find(|info| info.fp == Some(fp))
2987            .cloned()
2988            .unwrap();
2989        assert_eq!(retrieved.title, "Book with Reading State");
2990
2991        assert!(
2992            retrieved.reader_info.is_some(),
2993            "reading state should exist"
2994        );
2995        let retrieved_reader = retrieved.reader_info.unwrap();
2996        assert_eq!(retrieved_reader.current_page, 42);
2997        assert_eq!(retrieved_reader.pages_count, 100);
2998        assert!(!retrieved_reader.finished);
2999    }
3000
3001    #[test]
3002    fn test_delete_book() {
3003        let (_db, libdb) = create_test_db();
3004        let fp = Fp::from_u64(3);
3005
3006        let info = Info {
3007            title: "Book to Delete".to_string(),
3008            author: "Delete Author".to_string(),
3009            file: FileInfo {
3010                path: PathBuf::from("/tmp/delete.pdf"),
3011                kind: "pdf".to_string(),
3012                size: 512,
3013                ..Default::default()
3014            },
3015            ..Default::default()
3016        };
3017
3018        let library_id = register_test_library(&libdb, "/tmp/test_library3", "Test Library 3");
3019        libdb
3020            .insert_book(library_id, fp, &info)
3021            .expect("failed to insert book");
3022
3023        let books = libdb
3024            .get_all_books(library_id)
3025            .expect("failed to get books");
3026        assert!(
3027            books.iter().any(|info| info.fp == Some(fp)),
3028            "book should exist before delete"
3029        );
3030
3031        libdb
3032            .delete_book(library_id, fp)
3033            .expect("failed to delete book");
3034
3035        let books = libdb
3036            .get_all_books(library_id)
3037            .expect("failed to get books");
3038        assert!(
3039            !books.iter().any(|info| info.fp == Some(fp)),
3040            "book should not exist after delete"
3041        );
3042    }
3043
3044    #[test]
3045    fn test_multiple_books() {
3046        let (_db, libdb) = create_test_db();
3047        let library_id = register_test_library(&libdb, "/tmp/test_library4", "Test Library 4");
3048
3049        for i in 1..=5 {
3050            let fp = Fp::from_u64(i as u64);
3051            let info = Info {
3052                title: format!("Book {}", i),
3053                author: format!("Author {}", i),
3054                file: FileInfo {
3055                    path: PathBuf::from(format!("/tmp/book{}.pdf", i)),
3056                    kind: "pdf".to_string(),
3057                    size: (i * 100) as u64,
3058                    ..Default::default()
3059                },
3060                ..Default::default()
3061            };
3062
3063            libdb
3064                .insert_book(library_id, fp, &info)
3065                .expect("failed to insert book");
3066        }
3067
3068        let books = libdb
3069            .get_all_books(library_id)
3070            .expect("failed to get books");
3071        for i in 1..=5 {
3072            let fp = Fp::from_u64(i as u64);
3073            let retrieved = books
3074                .iter()
3075                .find(|info| info.fp == Some(fp))
3076                .cloned()
3077                .unwrap();
3078            assert_eq!(retrieved.title, format!("Book {}", i));
3079            assert_eq!(retrieved.author, format!("Author {}", i));
3080        }
3081    }
3082
3083    #[test]
3084    fn test_update_book() {
3085        let (_db, libdb) = create_test_db();
3086        let fp = Fp::from_u64(4);
3087
3088        let mut info = Info {
3089            title: "Original Title".to_string(),
3090            author: "Original Author".to_string(),
3091            file: FileInfo {
3092                path: PathBuf::from("/tmp/update.pdf"),
3093                kind: "pdf".to_string(),
3094                size: 1024,
3095                ..Default::default()
3096            },
3097            ..Default::default()
3098        };
3099
3100        let library_id = register_test_library(&libdb, "/tmp/test_library5", "Test Library 5");
3101        libdb
3102            .insert_book(library_id, fp, &info)
3103            .expect("failed to insert book");
3104
3105        info.title = "Updated Title".to_string();
3106        info.author = "Updated Author".to_string();
3107        info.year = "2025".to_string();
3108
3109        libdb
3110            .update_book(library_id, fp, &info)
3111            .expect("failed to update book");
3112
3113        let books = libdb
3114            .get_all_books(library_id)
3115            .expect("failed to get books");
3116        let updated = books
3117            .iter()
3118            .find(|info| info.fp == Some(fp))
3119            .cloned()
3120            .unwrap();
3121        assert_eq!(updated.title, "Updated Title");
3122        assert_eq!(updated.author, "Updated Author");
3123        assert_eq!(updated.year, "2025");
3124    }
3125
3126    #[test]
3127    fn test_get_all_books() {
3128        let (_db, libdb) = create_test_db();
3129        let library_id = register_test_library(&libdb, "/tmp/test_library6", "Test Library 6");
3130
3131        for i in 1..=3 {
3132            let fp = Fp::from_u64(i as u64);
3133            let info = Info {
3134                title: format!("Book {}", i),
3135                author: format!("Author {}", i),
3136                file: FileInfo {
3137                    path: PathBuf::from(format!("/tmp/book{}.pdf", i)),
3138                    kind: "pdf".to_string(),
3139                    size: (i * 100) as u64,
3140                    ..Default::default()
3141                },
3142                ..Default::default()
3143            };
3144
3145            libdb
3146                .insert_book(library_id, fp, &info)
3147                .expect("failed to insert book");
3148        }
3149
3150        let all_books = libdb
3151            .get_all_books(library_id)
3152            .expect("failed to get all books");
3153        assert_eq!(all_books.len(), 3);
3154
3155        let titles: Vec<String> = all_books.iter().map(|info| info.title.clone()).collect();
3156        assert!(titles.contains(&"Book 1".to_string()));
3157        assert!(titles.contains(&"Book 2".to_string()));
3158        assert!(titles.contains(&"Book 3".to_string()));
3159    }
3160
3161    #[test]
3162    fn test_get_book_by_path_and_fingerprint() {
3163        let (_db, libdb) = create_test_db();
3164        let library_id =
3165            register_test_library(&libdb, "/tmp/test_library_lookup", "Lookup Library");
3166        let fp = Fp::from_str("00000000000000A1").unwrap();
3167
3168        let mut info = make_info("nested/book.pdf", "Lookup Book", "Lookup Author");
3169        info.reader_info = Some(ReaderInfo {
3170            current_page: 7,
3171            pages_count: 21,
3172            ..Default::default()
3173        });
3174
3175        libdb
3176            .insert_book(library_id, fp, &info)
3177            .expect("failed to insert book");
3178
3179        let by_path = libdb
3180            .get_book_by_path(library_id, Path::new("nested/book.pdf"))
3181            .expect("failed to get book by path")
3182            .expect("book should exist by path");
3183        assert_eq!(by_path.fp, Some(fp));
3184        assert_eq!(by_path.title, "Lookup Book");
3185        assert_eq!(by_path.file.path, PathBuf::from("nested/book.pdf"));
3186        assert_eq!(by_path.reader_info.unwrap().current_page, 7);
3187
3188        let by_fp = libdb
3189            .get_book_by_fingerprint(library_id, fp)
3190            .expect("failed to get book by fingerprint")
3191            .expect("book should exist by fingerprint");
3192        assert_eq!(by_fp.fp, Some(fp));
3193        assert_eq!(by_fp.title, "Lookup Book");
3194        assert_eq!(by_fp.file.path, PathBuf::from("nested/book.pdf"));
3195
3196        assert!(libdb
3197            .get_book_by_path(library_id, Path::new("missing.pdf"))
3198            .expect("lookup should succeed")
3199            .is_none());
3200        assert!(libdb
3201            .get_book_by_fingerprint(library_id, Fp::from_str("00000000000000FF").unwrap())
3202            .expect("lookup should succeed")
3203            .is_none());
3204    }
3205
3206    #[test]
3207    fn test_batch_get_books_by_fingerprints() {
3208        let (_db, libdb) = create_test_db();
3209        let library_id =
3210            register_test_library(&libdb, "/tmp/test_library_batch_lookup", "Batch Lookup");
3211
3212        let fp1 = Fp::from_str("00000000000000B1").unwrap();
3213        let fp2 = Fp::from_str("00000000000000B2").unwrap();
3214        let missing = Fp::from_str("00000000000000BF").unwrap();
3215
3216        libdb
3217            .insert_book(
3218                library_id,
3219                fp1,
3220                &make_info("a/book1.pdf", "Book 1", "Author 1"),
3221            )
3222            .expect("failed to insert first book");
3223        libdb
3224            .insert_book(
3225                library_id,
3226                fp2,
3227                &make_info("b/book2.pdf", "Book 2", "Author 2"),
3228            )
3229            .expect("failed to insert second book");
3230
3231        let books = libdb
3232            .batch_get_books_by_fingerprints(library_id, &[fp1, missing, fp2])
3233            .expect("failed to batch get books");
3234
3235        assert_eq!(books.len(), 2);
3236        assert_eq!(books.get(&fp1).expect("missing fp1").title, "Book 1");
3237        assert_eq!(books.get(&fp2).expect("missing fp2").title, "Book 2");
3238        assert!(!books.contains_key(&missing));
3239
3240        let empty = libdb
3241            .batch_get_books_by_fingerprints(library_id, &[])
3242            .expect("empty batch should succeed");
3243        assert!(empty.is_empty());
3244    }
3245
3246    #[test]
3247    fn test_count_books() {
3248        let (_db, libdb) = create_test_db();
3249        let library_id = register_test_library(&libdb, "/tmp/test_library_count", "Count Library");
3250
3251        assert_eq!(libdb.count_books(library_id).expect("count failed"), 0);
3252
3253        let fp1 = Fp::from_str("00000000000000C1").unwrap();
3254        let fp2 = Fp::from_str("00000000000000C2").unwrap();
3255
3256        libdb
3257            .insert_book(
3258                library_id,
3259                fp1,
3260                &make_info("count/one.pdf", "One", "Author"),
3261            )
3262            .expect("failed to insert first book");
3263        libdb
3264            .insert_book(
3265                library_id,
3266                fp2,
3267                &make_info("count/two.pdf", "Two", "Author"),
3268            )
3269            .expect("failed to insert second book");
3270
3271        assert_eq!(libdb.count_books(library_id).expect("count failed"), 2);
3272    }
3273
3274    #[test]
3275    fn test_list_books_under_prefix() {
3276        let (_db, libdb) = create_test_db();
3277        let library_id =
3278            register_test_library(&libdb, "/tmp/test_library_prefix_books", "Prefix Books");
3279
3280        let fp1 = Fp::from_str("00000000000000D1").unwrap();
3281        let fp2 = Fp::from_str("00000000000000D2").unwrap();
3282        let fp3 = Fp::from_str("00000000000000D3").unwrap();
3283
3284        libdb
3285            .insert_book(
3286                library_id,
3287                fp1,
3288                &make_info("dir1/book1.pdf", "Book 1", "Author 1"),
3289            )
3290            .expect("failed to insert book 1");
3291        libdb
3292            .insert_book(
3293                library_id,
3294                fp2,
3295                &make_info("dir1/sub/book2.pdf", "Book 2", "Author 2"),
3296            )
3297            .expect("failed to insert book 2");
3298        libdb
3299            .insert_book(
3300                library_id,
3301                fp3,
3302                &make_info("dir2/book3.pdf", "Book 3", "Author 3"),
3303            )
3304            .expect("failed to insert book 3");
3305
3306        let root_books = libdb
3307            .list_books_under_prefix(library_id, Path::new(""))
3308            .expect("root listing failed");
3309        assert_eq!(root_books.len(), 3);
3310
3311        let dir1_books = libdb
3312            .list_books_under_prefix(library_id, Path::new("dir1"))
3313            .expect("dir1 listing failed");
3314        let dir1_paths: BTreeSet<PathBuf> =
3315            dir1_books.into_iter().map(|info| info.file.path).collect();
3316        assert_eq!(
3317            dir1_paths,
3318            BTreeSet::from([
3319                PathBuf::from("dir1/book1.pdf"),
3320                PathBuf::from("dir1/sub/book2.pdf"),
3321            ])
3322        );
3323
3324        let exact_book = libdb
3325            .list_books_under_prefix(library_id, Path::new("dir2/book3.pdf"))
3326            .expect("exact listing failed");
3327        assert_eq!(exact_book.len(), 1);
3328        assert_eq!(exact_book[0].fp, Some(fp3));
3329    }
3330
3331    #[test]
3332    fn test_list_directories_under_prefix() {
3333        let (_db, libdb) = create_test_db();
3334        let library_id =
3335            register_test_library(&libdb, "/tmp/test_library_prefix_dirs", "Prefix Dirs");
3336
3337        libdb
3338            .insert_book(
3339                library_id,
3340                Fp::from_str("00000000000000E1").unwrap(),
3341                &make_info("dir1/book1.pdf", "Book 1", "Author 1"),
3342            )
3343            .expect("failed to insert book 1");
3344        libdb
3345            .insert_book(
3346                library_id,
3347                Fp::from_str("00000000000000E2").unwrap(),
3348                &make_info("dir1/sub/book2.pdf", "Book 2", "Author 2"),
3349            )
3350            .expect("failed to insert book 2");
3351        libdb
3352            .insert_book(
3353                library_id,
3354                Fp::from_str("00000000000000E3").unwrap(),
3355                &make_info("dir2/book3.pdf", "Book 3", "Author 3"),
3356            )
3357            .expect("failed to insert book 3");
3358
3359        let root_dirs = libdb
3360            .list_directories_under_prefix(library_id, Path::new(""))
3361            .expect("root dir listing failed");
3362        assert_eq!(
3363            root_dirs,
3364            BTreeSet::from([PathBuf::from("dir1"), PathBuf::from("dir2")])
3365        );
3366
3367        let dir1_dirs = libdb
3368            .list_directories_under_prefix(library_id, Path::new("dir1"))
3369            .expect("dir1 dir listing failed");
3370        assert_eq!(dir1_dirs, BTreeSet::from([PathBuf::from("sub")]));
3371
3372        let leaf_dirs = libdb
3373            .list_directories_under_prefix(library_id, Path::new("dir2"))
3374            .expect("leaf dir listing failed");
3375        assert!(leaf_dirs.is_empty());
3376    }
3377
3378    #[test]
3379    fn test_reading_state_crud() {
3380        let (_db, libdb) = create_test_db();
3381        let fp = Fp::from_u64(5);
3382
3383        let info = Info {
3384            title: "Book with State".to_string(),
3385            author: "State Author".to_string(),
3386            file: FileInfo {
3387                path: PathBuf::from("/tmp/state.pdf"),
3388                kind: "pdf".to_string(),
3389                size: 1024,
3390                ..Default::default()
3391            },
3392            ..Default::default()
3393        };
3394
3395        let library_id = register_test_library(&libdb, "/tmp/test_library7", "Test Library 7");
3396        libdb
3397            .insert_book(library_id, fp, &info)
3398            .expect("failed to insert book");
3399
3400        let mut reader_info = ReaderInfo {
3401            current_page: 50,
3402            pages_count: 200,
3403            ..Default::default()
3404        };
3405
3406        libdb
3407            .save_reading_state(fp, &reader_info)
3408            .expect("failed to save reading state");
3409
3410        let books = libdb
3411            .get_all_books(library_id)
3412            .expect("failed to get books");
3413        let retrieved = books
3414            .iter()
3415            .find(|info| info.fp == Some(fp))
3416            .cloned()
3417            .unwrap();
3418        let retrieved_reader = retrieved.reader_info.unwrap();
3419
3420        assert_eq!(retrieved_reader.current_page, 50);
3421        assert_eq!(retrieved_reader.pages_count, 200);
3422        assert!(!retrieved_reader.finished);
3423        reader_info.current_page = 100;
3424        reader_info.finished = true;
3425
3426        libdb
3427            .save_reading_state(fp, &reader_info)
3428            .expect("failed to update reading state");
3429
3430        let books = libdb
3431            .get_all_books(library_id)
3432            .expect("failed to get books");
3433        let updated = books
3434            .iter()
3435            .find(|info| info.fp == Some(fp))
3436            .cloned()
3437            .unwrap();
3438        let updated_reader = updated.reader_info.unwrap();
3439
3440        assert_eq!(updated_reader.current_page, 100);
3441        assert!(updated_reader.finished);
3442    }
3443
3444    #[test]
3445    fn test_batch_insert_books() {
3446        let (_db, libdb) = create_test_db();
3447        let library_id = register_test_library(&libdb, "/tmp/test_library8", "Test Library 8");
3448
3449        let mut books = Vec::new();
3450        for i in 1..=5 {
3451            let fp = Fp::from_u64((i + 100) as u64);
3452            let info = Info {
3453                title: format!("Batch Book {}", i),
3454                author: format!("Batch Author {}, Co-Author {}", i, i + 1),
3455                year: format!("{}", 2020 + i),
3456                file: FileInfo {
3457                    path: PathBuf::from(format!("/tmp/batch{}.pdf", i)),
3458                    kind: "pdf".to_string(),
3459                    size: (i * 100) as u64,
3460                    ..Default::default()
3461                },
3462                ..Default::default()
3463            };
3464            books.push((fp, info));
3465        }
3466
3467        let book_refs: Vec<(Fp, &Info)> = books.iter().map(|(fp, info)| (*fp, info)).collect();
3468
3469        libdb
3470            .batch_insert_books(library_id, &book_refs)
3471            .expect("failed to batch insert books");
3472
3473        let all_books = libdb
3474            .get_all_books(library_id)
3475            .expect("failed to get books");
3476        for (fp, info) in &books {
3477            let retrieved = all_books
3478                .iter()
3479                .find(|info| info.fp == Some(*fp))
3480                .cloned()
3481                .expect("book should exist");
3482            assert_eq!(retrieved.title, info.title);
3483            assert_eq!(retrieved.author, info.author);
3484            assert_eq!(retrieved.year, info.year);
3485        }
3486
3487        let all_books = libdb
3488            .get_all_books(library_id)
3489            .expect("failed to get all books");
3490        assert_eq!(all_books.len(), 5);
3491    }
3492
3493    #[test]
3494    fn test_batch_update_books() {
3495        let (_db, libdb) = create_test_db();
3496        let library_id = register_test_library(&libdb, "/tmp/test_library9", "Test Library 9");
3497
3498        let mut books = Vec::new();
3499        for i in 1..=3 {
3500            let fp = Fp::from_u64((i + 200) as u64);
3501            let mut info = Info {
3502                title: format!("Original Book {}", i),
3503                author: format!("Original Author {}", i),
3504                file: FileInfo {
3505                    path: PathBuf::from(format!("/tmp/update{}.pdf", i)),
3506                    kind: "pdf".to_string(),
3507                    size: (i * 100) as u64,
3508                    ..Default::default()
3509                },
3510                ..Default::default()
3511            };
3512            libdb
3513                .insert_book(library_id, fp, &info)
3514                .expect("failed to insert book");
3515
3516            info.title = format!("Updated Book {}", i);
3517            info.author = format!("Updated Author {}", i);
3518            books.push((fp, info));
3519        }
3520
3521        let book_refs: Vec<(Fp, &Info)> = books.iter().map(|(fp, info)| (*fp, info)).collect();
3522
3523        libdb
3524            .batch_update_books(library_id, &book_refs)
3525            .expect("failed to batch update books");
3526
3527        let all_books = libdb
3528            .get_all_books(library_id)
3529            .expect("failed to get books");
3530        for (fp, info) in &books {
3531            let retrieved = all_books
3532                .iter()
3533                .find(|info| info.fp == Some(*fp))
3534                .cloned()
3535                .expect("book should exist");
3536            assert_eq!(retrieved.title, info.title);
3537            assert_eq!(retrieved.author, info.author);
3538        }
3539    }
3540
3541    #[test]
3542    fn test_delete_reading_state() {
3543        let (_db, libdb) = create_test_db();
3544        let fp = Fp::from_str("0000000000000006").unwrap();
3545
3546        let info = Info {
3547            title: "Book".to_string(),
3548            author: "Author".to_string(),
3549            file: FileInfo {
3550                path: PathBuf::from("/tmp/book.pdf"),
3551                kind: "pdf".to_string(),
3552                size: 100,
3553                ..Default::default()
3554            },
3555            reader_info: Some(ReaderInfo {
3556                current_page: 10,
3557                pages_count: 50,
3558                ..Default::default()
3559            }),
3560            ..Default::default()
3561        };
3562
3563        let library_id = register_test_library(&libdb, "/tmp/test_library10", "Test Library 10");
3564        libdb
3565            .insert_book(library_id, fp, &info)
3566            .expect("failed to insert book");
3567
3568        let books = libdb
3569            .get_all_books(library_id)
3570            .expect("failed to get books");
3571        let retrieved = books
3572            .iter()
3573            .find(|info| info.fp == Some(fp))
3574            .cloned()
3575            .unwrap();
3576        assert!(retrieved.reader_info.is_some());
3577
3578        libdb
3579            .delete_reading_state(fp)
3580            .expect("failed to delete reading state");
3581
3582        let books = libdb
3583            .get_all_books(library_id)
3584            .expect("failed to get books");
3585        let retrieved = books
3586            .iter()
3587            .find(|info| info.fp == Some(fp))
3588            .cloned()
3589            .unwrap();
3590        assert!(retrieved.reader_info.is_none());
3591    }
3592
3593    #[test]
3594    fn test_thumbnail_crud() {
3595        let (_db, libdb) = create_test_db();
3596        let library_id =
3597            register_test_library(&libdb, "/tmp/test_library_thumbnails", "Thumbnail Library");
3598        let fp = Fp::from_str("0000000000000007").unwrap();
3599        let data = vec![1, 2, 3, 4, 5];
3600
3601        libdb
3602            .insert_book(
3603                library_id,
3604                fp,
3605                &make_info("thumbs/book.pdf", "Thumb Book", "Thumb Author"),
3606            )
3607            .expect("failed to insert book");
3608
3609        let thumbnail = libdb.get_thumbnail(fp).expect("failed to get thumbnail");
3610        assert!(thumbnail.is_none());
3611
3612        libdb
3613            .save_thumbnail(fp, &data)
3614            .expect("failed to save thumbnail");
3615
3616        let thumbnail = libdb.get_thumbnail(fp).expect("failed to get thumbnail");
3617        assert_eq!(thumbnail, Some(data.clone()));
3618
3619        libdb
3620            .delete_thumbnail(fp)
3621            .expect("failed to delete thumbnail");
3622
3623        let thumbnail = libdb.get_thumbnail(fp).expect("failed to get thumbnail");
3624        assert!(thumbnail.is_none());
3625    }
3626
3627    #[test]
3628    fn test_books_without_thumbnails() {
3629        let (_db, libdb) = create_test_db();
3630        let library_id = register_test_library(
3631            &libdb,
3632            "/tmp/test_library_thumbnails_missing",
3633            "Missing Thumbs Library",
3634        );
3635        let fp1 = Fp::from_str("0000000000000008").unwrap();
3636        let fp2 = Fp::from_str("0000000000000009").unwrap();
3637
3638        libdb
3639            .insert_book(
3640                library_id,
3641                fp1,
3642                &make_info("thumbs/book1.epub", "Thumb Book 1", "Thumb Author 1"),
3643            )
3644            .expect("failed to insert book 1");
3645
3646        libdb
3647            .insert_book(
3648                library_id,
3649                fp2,
3650                &make_info("thumbs/book2.epub", "Thumb Book 2", "Thumb Author 2"),
3651            )
3652            .expect("failed to insert book 2");
3653
3654        let missing = libdb.books_without_thumbnails(library_id).unwrap();
3655        assert_eq!(missing.len(), 2);
3656        assert!(missing.contains(&(fp1, PathBuf::from("thumbs/book1.epub"))));
3657        assert!(missing.contains(&(fp2, PathBuf::from("thumbs/book2.epub"))));
3658
3659        libdb.save_thumbnail(fp1, &[1, 2, 3]).unwrap();
3660
3661        let missing = libdb.books_without_thumbnails(library_id).unwrap();
3662        assert_eq!(missing.len(), 1);
3663        assert_eq!(missing[0], (fp2, PathBuf::from("thumbs/book2.epub")));
3664
3665        libdb.save_thumbnail(fp2, &[4, 5, 6]).unwrap();
3666
3667        let missing = libdb.books_without_thumbnails(library_id).unwrap();
3668        assert!(missing.is_empty());
3669    }
3670
3671    #[test]
3672    fn test_batch_delete_thumbnails() {
3673        let (_db, libdb) = create_test_db();
3674        let library_id = register_test_library(
3675            &libdb,
3676            "/tmp/test_library_batch_delete_thumbnails",
3677            "Batch Delete Thumbnails",
3678        );
3679        let fp1 = Fp::from_str("00000000000000F1").unwrap();
3680        let fp2 = Fp::from_str("00000000000000F2").unwrap();
3681        let fp3 = Fp::from_str("00000000000000F3").unwrap();
3682
3683        libdb
3684            .insert_book(
3685                library_id,
3686                fp1,
3687                &make_info("thumbs/one.pdf", "One", "Author One"),
3688            )
3689            .expect("failed to insert first book");
3690        libdb
3691            .insert_book(
3692                library_id,
3693                fp2,
3694                &make_info("thumbs/two.pdf", "Two", "Author Two"),
3695            )
3696            .expect("failed to insert second book");
3697
3698        libdb
3699            .save_thumbnail(fp1, &[1, 2, 3])
3700            .expect("failed to save thumbnail 1");
3701        libdb
3702            .save_thumbnail(fp2, &[4, 5, 6])
3703            .expect("failed to save thumbnail 2");
3704
3705        libdb
3706            .batch_delete_thumbnails(&[fp1, fp3])
3707            .expect("failed to batch delete thumbnails");
3708
3709        assert!(libdb
3710            .get_thumbnail(fp1)
3711            .expect("failed to get thumbnail 1")
3712            .is_none());
3713        assert_eq!(
3714            libdb.get_thumbnail(fp2).expect("failed to get thumbnail 2"),
3715            Some(vec![4, 5, 6])
3716        );
3717    }
3718
3719    #[test]
3720    fn test_move_thumbnail() {
3721        let (_db, libdb) = create_test_db();
3722        let library_id =
3723            register_test_library(&libdb, "/tmp/test_library_move_thumbnail", "Move Thumbnail");
3724        let from_fp = Fp::from_str("0000000000000008").unwrap();
3725        let to_fp = Fp::from_str("0000000000000009").unwrap();
3726        let data = vec![9, 8, 7, 6];
3727
3728        libdb
3729            .insert_book(
3730                library_id,
3731                from_fp,
3732                &make_info("thumbs/from.pdf", "From Book", "From Author"),
3733            )
3734            .expect("failed to insert source book");
3735        libdb
3736            .insert_book(
3737                library_id,
3738                to_fp,
3739                &make_info("thumbs/to.pdf", "To Book", "To Author"),
3740            )
3741            .expect("failed to insert destination book");
3742
3743        libdb
3744            .save_thumbnail(from_fp, &data)
3745            .expect("failed to save thumbnail");
3746
3747        libdb
3748            .move_thumbnail(from_fp, to_fp)
3749            .expect("failed to move thumbnail");
3750
3751        let old_thumbnail = libdb
3752            .get_thumbnail(from_fp)
3753            .expect("failed to get old thumbnail");
3754        assert!(old_thumbnail.is_none());
3755
3756        let new_thumbnail = libdb
3757            .get_thumbnail(to_fp)
3758            .expect("failed to get new thumbnail");
3759        assert_eq!(new_thumbnail, Some(data));
3760    }
3761
3762    #[test]
3763    fn test_batch_move_thumbnails() {
3764        let (_db, libdb) = create_test_db();
3765        let library_id = register_test_library(
3766            &libdb,
3767            "/tmp/test_library_batch_move_thumbnails",
3768            "Batch Move Thumbnails",
3769        );
3770        let from_fp1 = Fp::from_str("0000000000000101").unwrap();
3771        let to_fp1 = Fp::from_str("0000000000000102").unwrap();
3772        let from_fp2 = Fp::from_str("0000000000000103").unwrap();
3773        let to_fp2 = Fp::from_str("0000000000000104").unwrap();
3774
3775        libdb
3776            .insert_book(
3777                library_id,
3778                from_fp1,
3779                &make_info("thumbs/from1.pdf", "From 1", "Author 1"),
3780            )
3781            .expect("failed to insert source book 1");
3782        libdb
3783            .insert_book(
3784                library_id,
3785                to_fp1,
3786                &make_info("thumbs/to1.pdf", "To 1", "Author 1"),
3787            )
3788            .expect("failed to insert destination book 1");
3789        libdb
3790            .insert_book(
3791                library_id,
3792                from_fp2,
3793                &make_info("thumbs/from2.pdf", "From 2", "Author 2"),
3794            )
3795            .expect("failed to insert source book 2");
3796        libdb
3797            .insert_book(
3798                library_id,
3799                to_fp2,
3800                &make_info("thumbs/to2.pdf", "To 2", "Author 2"),
3801            )
3802            .expect("failed to insert destination book 2");
3803
3804        libdb
3805            .save_thumbnail(from_fp1, &[1, 1, 1])
3806            .expect("failed to save thumbnail 1");
3807        libdb
3808            .save_thumbnail(from_fp2, &[2, 2, 2])
3809            .expect("failed to save thumbnail 2");
3810
3811        libdb
3812            .batch_move_thumbnails(&[(from_fp1, to_fp1), (from_fp2, to_fp2)])
3813            .expect("failed to batch move thumbnails");
3814
3815        assert!(libdb
3816            .get_thumbnail(from_fp1)
3817            .expect("failed to get old thumbnail 1")
3818            .is_none());
3819        assert!(libdb
3820            .get_thumbnail(from_fp2)
3821            .expect("failed to get old thumbnail 2")
3822            .is_none());
3823        assert_eq!(
3824            libdb
3825                .get_thumbnail(to_fp1)
3826                .expect("failed to get new thumbnail 1"),
3827            Some(vec![1, 1, 1])
3828        );
3829        assert_eq!(
3830            libdb
3831                .get_thumbnail(to_fp2)
3832                .expect("failed to get new thumbnail 2"),
3833            Some(vec![2, 2, 2])
3834        );
3835    }
3836
3837    #[test]
3838    fn test_list_book_handles_and_update_book_path() {
3839        let (_db, libdb) = create_test_db();
3840        let library_id = register_test_library(&libdb, "/tmp/test_library_handles", "Handles");
3841
3842        let fp = Fp::from_str("0000000000000111").unwrap();
3843        libdb
3844            .insert_book(library_id, fp, &make_info("old/path.pdf", "Book", "Author"))
3845            .expect("failed to insert book");
3846
3847        let handles = libdb
3848            .list_book_handles(library_id)
3849            .expect("failed to list handles");
3850        assert_eq!(handles, vec![(fp, PathBuf::from("old/path.pdf"))]);
3851
3852        libdb
3853            .update_book_path(
3854                library_id,
3855                fp,
3856                Path::new("new/path.pdf"),
3857                Path::new("/abs/new/path.pdf"),
3858            )
3859            .expect("failed to update book path");
3860
3861        let updated = libdb
3862            .get_book_by_fingerprint(library_id, fp)
3863            .expect("failed to get updated book")
3864            .expect("book should exist");
3865        assert_eq!(updated.file.path, PathBuf::from("new/path.pdf"));
3866        assert_eq!(
3867            updated.file.absolute_path,
3868            PathBuf::from("/abs/new/path.pdf")
3869        );
3870
3871        let handles = libdb
3872            .list_book_handles(library_id)
3873            .expect("failed to list handles after update");
3874        assert_eq!(handles, vec![(fp, PathBuf::from("new/path.pdf"))]);
3875    }
3876
3877    #[test]
3878    fn test_batch_update_book_paths() {
3879        let (_db, libdb) = create_test_db();
3880        let library_id =
3881            register_test_library(&libdb, "/tmp/test_library_batch_paths", "Batch Paths");
3882
3883        let fp1 = Fp::from_str("0000000000000121").unwrap();
3884        let fp2 = Fp::from_str("0000000000000122").unwrap();
3885
3886        libdb
3887            .insert_book(library_id, fp1, &make_info("old/one.pdf", "One", "Author"))
3888            .expect("failed to insert first book");
3889        libdb
3890            .insert_book(library_id, fp2, &make_info("old/two.pdf", "Two", "Author"))
3891            .expect("failed to insert second book");
3892
3893        libdb
3894            .batch_update_book_paths(
3895                library_id,
3896                &[
3897                    (
3898                        fp1,
3899                        PathBuf::from("new/one.pdf"),
3900                        PathBuf::from("/abs/new/one.pdf"),
3901                    ),
3902                    (
3903                        fp2,
3904                        PathBuf::from("new/two.pdf"),
3905                        PathBuf::from("/abs/new/two.pdf"),
3906                    ),
3907                ],
3908            )
3909            .expect("failed to batch update book paths");
3910
3911        let updated1 = libdb
3912            .get_book_by_fingerprint(library_id, fp1)
3913            .expect("failed to get first updated book")
3914            .expect("first book should exist");
3915        let updated2 = libdb
3916            .get_book_by_fingerprint(library_id, fp2)
3917            .expect("failed to get second updated book")
3918            .expect("second book should exist");
3919
3920        assert_eq!(updated1.file.path, PathBuf::from("new/one.pdf"));
3921        assert_eq!(
3922            updated1.file.absolute_path,
3923            PathBuf::from("/abs/new/one.pdf")
3924        );
3925        assert_eq!(updated2.file.path, PathBuf::from("new/two.pdf"));
3926        assert_eq!(
3927            updated2.file.absolute_path,
3928            PathBuf::from("/abs/new/two.pdf")
3929        );
3930    }
3931
3932    #[test]
3933    fn test_batch_delete_books() {
3934        let (_db, libdb) = create_test_db();
3935        let library_id = register_test_library(&libdb, "/tmp/test_library11", "Test Library 11");
3936
3937        let mut fps = Vec::new();
3938        for i in 1..=4 {
3939            let fp = Fp::from_u64((i + 300) as u64);
3940            let info = Info {
3941                title: format!("Delete Book {}", i),
3942                author: format!("Delete Author {}", i),
3943                file: FileInfo {
3944                    path: PathBuf::from(format!("/tmp/delete{}.pdf", i)),
3945                    kind: "pdf".to_string(),
3946                    size: (i * 100) as u64,
3947                    ..Default::default()
3948                },
3949                ..Default::default()
3950            };
3951            libdb
3952                .insert_book(library_id, fp, &info)
3953                .expect("failed to insert book");
3954            fps.push(fp);
3955        }
3956
3957        let all_books = libdb
3958            .get_all_books(library_id)
3959            .expect("failed to get books");
3960        assert_eq!(all_books.len(), 4);
3961
3962        libdb
3963            .batch_delete_books(library_id, &fps[0..2])
3964            .expect("failed to batch delete books");
3965
3966        let remaining_books = libdb
3967            .get_all_books(library_id)
3968            .expect("failed to get books");
3969        assert_eq!(remaining_books.len(), 2);
3970        assert!(remaining_books.iter().all(|info| {
3971            let fp = info.fp.expect("book should have fingerprint");
3972            fp == fps[2] || fp == fps[3]
3973        }));
3974    }
3975
3976    #[test]
3977    fn test_batch_operations_with_empty_input() {
3978        let (_db, libdb) = create_test_db();
3979        let library_id = register_test_library(&libdb, "/tmp/test_library12", "Test Library 12");
3980
3981        let empty_books: Vec<(Fp, &Info)> = Vec::new();
3982        let empty_fps: Vec<Fp> = Vec::new();
3983
3984        libdb
3985            .batch_insert_books(library_id, &empty_books)
3986            .expect("empty batch insert should succeed");
3987        libdb
3988            .batch_update_books(library_id, &empty_books)
3989            .expect("empty batch update should succeed");
3990        libdb
3991            .batch_delete_books(library_id, &empty_fps)
3992            .expect("empty batch delete should succeed");
3993    }
3994
3995    #[test]
3996    fn test_categories_round_trip() {
3997        let (_db, libdb) = create_test_db();
3998        let fp = Fp::from_u64(0x99);
3999
4000        let info = Info {
4001            title: "Categorized Book".to_string(),
4002            author: "Cat Author".to_string(),
4003            file: FileInfo {
4004                path: PathBuf::from("/tmp/cat.pdf"),
4005                kind: "pdf".to_string(),
4006                size: 512,
4007                ..Default::default()
4008            },
4009            categories: ["Fiction", "Science", "History"]
4010                .iter()
4011                .map(|s| s.to_string())
4012                .collect(),
4013            ..Default::default()
4014        };
4015
4016        let library_id = libdb
4017            .register_library("/tmp/test_library_cat", "Cat Library")
4018            .expect("failed to register library");
4019        libdb
4020            .insert_book(library_id, fp, &info)
4021            .expect("failed to insert book");
4022
4023        let books = libdb
4024            .get_all_books(library_id)
4025            .expect("failed to get books");
4026        let retrieved = books
4027            .iter()
4028            .find(|info| info.fp == Some(fp))
4029            .cloned()
4030            .expect("book should exist");
4031
4032        assert_eq!(retrieved.categories, info.categories);
4033    }
4034
4035    #[test]
4036    fn test_categories_updated_on_update_book() {
4037        let (_db, libdb) = create_test_db();
4038        let fp = Fp::from_u64(0x9A);
4039
4040        let mut info = Info {
4041            title: "Updateable Book".to_string(),
4042            author: "Update Author".to_string(),
4043            file: FileInfo {
4044                path: PathBuf::from("/tmp/upd_cat.pdf"),
4045                kind: "pdf".to_string(),
4046                size: 512,
4047                ..Default::default()
4048            },
4049            categories: ["OldCat"].iter().map(|s| s.to_string()).collect(),
4050            ..Default::default()
4051        };
4052
4053        let library_id =
4054            register_test_library(&libdb, "/tmp/test_library_upd_cat", "Upd Cat Library");
4055        libdb
4056            .insert_book(library_id, fp, &info)
4057            .expect("failed to insert book");
4058
4059        info.categories = ["NewCat1", "NewCat2"]
4060            .iter()
4061            .map(|s| s.to_string())
4062            .collect();
4063        libdb
4064            .update_book(library_id, fp, &info)
4065            .expect("failed to update book");
4066
4067        let books = libdb
4068            .get_all_books(library_id)
4069            .expect("failed to get books");
4070        let retrieved = books
4071            .iter()
4072            .find(|info| info.fp == Some(fp))
4073            .cloned()
4074            .expect("book should exist");
4075
4076        assert_eq!(retrieved.categories, info.categories);
4077    }
4078
4079    #[test]
4080    fn most_recently_opened_reading_book_none_when_empty() {
4081        let (_db, libdb) = create_test_db();
4082        let library_id = register_test_library(&libdb, "/tmp/mro_empty", "MRO Empty");
4083        assert!(libdb
4084            .most_recently_opened_reading_book(library_id)
4085            .expect("query failed")
4086            .is_none());
4087    }
4088
4089    #[test]
4090    fn most_recently_opened_reading_book_none_when_only_finished() {
4091        let (_db, libdb) = create_test_db();
4092        let library_id = register_test_library(&libdb, "/tmp/mro_finished", "MRO Finished");
4093        let fp = Fp::from_str("AA00000000000001").unwrap();
4094        let mut info = make_info("mro/finished.pdf", "Finished", "Author");
4095        info.reader_info = Some(ReaderInfo {
4096            current_page: 100,
4097            pages_count: 100,
4098            finished: true,
4099            ..Default::default()
4100        });
4101        libdb.insert_book(library_id, fp, &info).unwrap();
4102
4103        assert!(libdb
4104            .most_recently_opened_reading_book(library_id)
4105            .expect("query failed")
4106            .is_none());
4107    }
4108
4109    #[test]
4110    fn most_recently_opened_reading_book_returns_unfinished() {
4111        let (_db, libdb) = create_test_db();
4112        let library_id = register_test_library(&libdb, "/tmp/mro_unfinished", "MRO Unfinished");
4113
4114        let fp1 = Fp::from_str("AA00000000000002").unwrap();
4115        let fp2 = Fp::from_str("AA00000000000003").unwrap();
4116
4117        let mut info1 = make_info("mro/a.pdf", "Older Book", "Author");
4118        info1.reader_info = Some(ReaderInfo {
4119            current_page: 10,
4120            pages_count: 200,
4121            ..Default::default()
4122        });
4123
4124        let mut info2 = make_info("mro/b.pdf", "Newer Book", "Author");
4125        // Sleep is not needed — the in-memory SQLite uses UnixTimestamp::now()
4126        // which has second granularity; we manipulate opened via save_reading_state.
4127        info2.reader_info = Some(ReaderInfo {
4128            current_page: 50,
4129            pages_count: 200,
4130            ..Default::default()
4131        });
4132
4133        libdb.insert_book(library_id, fp1, &info1).unwrap();
4134        libdb.insert_book(library_id, fp2, &info2).unwrap();
4135
4136        // Both unfinished — result should be one of them (not None).
4137        let result = libdb
4138            .most_recently_opened_reading_book(library_id)
4139            .expect("query failed");
4140        assert!(result.is_some());
4141        assert!(!result.unwrap().reader_info.unwrap().finished);
4142    }
4143
4144    #[test]
4145    fn most_recently_opened_reading_book_skips_never_opened() {
4146        let (_db, libdb) = create_test_db();
4147        let library_id = register_test_library(&libdb, "/tmp/mro_new", "MRO New");
4148
4149        // Book with no reading state (never opened).
4150        let fp = Fp::from_str("AA00000000000004").unwrap();
4151        libdb
4152            .insert_book(
4153                library_id,
4154                fp,
4155                &make_info("mro/new.pdf", "New Book", "Author"),
4156            )
4157            .unwrap();
4158
4159        assert!(libdb
4160            .most_recently_opened_reading_book(library_id)
4161            .expect("query failed")
4162            .is_none());
4163    }
4164
4165    #[test]
4166    fn compute_sort_keys_empty_library_is_noop() {
4167        let (_db, libdb) = create_test_db();
4168        let library_id = register_test_library(&libdb, "/tmp/sort_empty", "Sort Empty");
4169        libdb.compute_sort_keys(library_id).expect("compute failed");
4170    }
4171
4172    #[test]
4173    fn compute_sort_keys_assigns_ranks_to_all_books() {
4174        let (_db, libdb) = create_test_db();
4175        let library_id = register_test_library(&libdb, "/tmp/sort_assign", "Sort Assign");
4176
4177        for i in 1u64..=3 {
4178            let fp = Fp::from_str(&format!("BB{:014X}", i)).unwrap();
4179            libdb
4180                .insert_book(
4181                    library_id,
4182                    fp,
4183                    &make_info(&format!("s/{i}.pdf"), &format!("Book {i}"), "Author"),
4184                )
4185                .unwrap();
4186        }
4187
4188        libdb.compute_sort_keys(library_id).expect("compute failed");
4189
4190        // After compute, page_books by Title should return all 3 in order.
4191        let (books, total) = libdb
4192            .page_books(library_id, Path::new(""), SortMethod::Title, false, 10, 0)
4193            .expect("page_books failed");
4194        assert_eq!(total, 3);
4195        assert_eq!(books.len(), 3);
4196    }
4197
4198    #[test]
4199    fn insert_sort_rank_places_new_book_between_neighbours() {
4200        let (_db, libdb) = create_test_db();
4201        let library_id = register_test_library(&libdb, "/tmp/sort_insert", "Sort Insert");
4202
4203        // Insert two books and compute initial sort ranks.
4204        let fp_a = Fp::from_str("CC00000000000001").unwrap();
4205        let fp_z = Fp::from_str("CC00000000000002").unwrap();
4206        let info_a = make_info("s/aardvark.pdf", "Aardvark", "Author");
4207        let info_z = make_info("s/zebra.pdf", "Zebra", "Author");
4208
4209        libdb.insert_book(library_id, fp_a, &info_a).unwrap();
4210        libdb.insert_book(library_id, fp_z, &info_z).unwrap();
4211        libdb.compute_sort_keys(library_id).unwrap();
4212
4213        // Insert a book that should land between the two alphabetically.
4214        let fp_m = Fp::from_str("CC00000000000003").unwrap();
4215        let info_m = make_info("s/mango.pdf", "Mango", "Author");
4216        libdb.insert_book(library_id, fp_m, &info_m).unwrap();
4217        libdb.insert_sort_rank(library_id, fp_m, &info_m).unwrap();
4218
4219        let (books, _) = libdb
4220            .page_books(library_id, Path::new(""), SortMethod::Title, false, 10, 0)
4221            .expect("page_books failed");
4222
4223        let titles: Vec<&str> = books.iter().map(|b| b.title.as_str()).collect();
4224        assert_eq!(titles, vec!["Aardvark", "Mango", "Zebra"]);
4225    }
4226
4227    #[test]
4228    fn insert_sort_rank_falls_back_to_full_recompute_when_gaps_exhausted() {
4229        let (_db, libdb) = create_test_db();
4230        let library_id = register_test_library(&libdb, "/tmp/sort_exhaust", "Sort Exhaust");
4231
4232        // Seed two books with ranks 1 and 2 (no room for a midpoint).
4233        let fp_a = Fp::from_str("DD00000000000001").unwrap();
4234        let fp_b = Fp::from_str("DD00000000000002").unwrap();
4235        libdb
4236            .insert_book(library_id, fp_a, &make_info("s/a.pdf", "Alpha", "Author"))
4237            .unwrap();
4238        libdb
4239            .insert_book(library_id, fp_b, &make_info("s/b.pdf", "Beta", "Author"))
4240            .unwrap();
4241        libdb.compute_sort_keys(library_id).unwrap();
4242
4243        // Drain the gap between Alpha (1000) and Beta (2000) by inserting many
4244        // "Am*" books — each midpoint halves the gap until it exhausts.
4245        for i in 1u64..=12 {
4246            let fp = Fp::from_str(&format!("DD{:014X}", i + 10)).unwrap();
4247            let title = format!("Am{i:012}");
4248            let info = make_info(&format!("s/am{i}.pdf"), &title, "Author");
4249            libdb.insert_book(library_id, fp, &info).unwrap();
4250            // insert_sort_rank will eventually fall back; just verify it doesn't panic.
4251            libdb.insert_sort_rank(library_id, fp, &info).unwrap();
4252        }
4253
4254        let (books, _) = libdb
4255            .page_books(library_id, Path::new(""), SortMethod::Title, false, 20, 0)
4256            .expect("page_books failed");
4257        // All books are present and the first is still Alpha.
4258        assert_eq!(books[0].title, "Alpha");
4259    }
4260
4261    fn insert_books_for_paging(libdb: &Db, library_id: i64) {
4262        let books = [
4263            (
4264                "p/a.pdf", "Alpha", "Zelda", "2020", "epub", 500u64, 100usize,
4265            ),
4266            ("p/b.pdf", "Beta", "Alpha", "2019", "pdf", 300, 50),
4267            ("p/c.pdf", "Gamma", "Mia", "2021", "epub", 700, 200),
4268        ];
4269        for (i, (path, title, author, year, kind, size, pages)) in books.iter().enumerate() {
4270            let fp = Fp::from_str(&format!("EE{:014X}", i + 1)).unwrap();
4271            let mut info = make_info(path, title, author);
4272            info.year = year.to_string();
4273            info.file.kind = kind.to_string();
4274            info.file.size = *size;
4275            info.reader_info = Some(ReaderInfo {
4276                current_page: pages / 2,
4277                pages_count: *pages,
4278                ..Default::default()
4279            });
4280            libdb.insert_book(library_id, fp, &info).unwrap();
4281        }
4282        libdb.compute_sort_keys(library_id).unwrap();
4283    }
4284
4285    #[test]
4286    fn page_books_sort_by_author() {
4287        let (_db, libdb) = create_test_db();
4288        let library_id = register_test_library(&libdb, "/tmp/pb_author", "PB Author");
4289        insert_books_for_paging(&libdb, library_id);
4290
4291        let (books, total) = libdb
4292            .page_books(library_id, Path::new(""), SortMethod::Author, false, 10, 0)
4293            .unwrap();
4294        assert_eq!(total, 3);
4295        assert_eq!(books[0].author, "Alpha");
4296    }
4297
4298    #[test]
4299    fn page_books_sort_by_year() {
4300        let (_db, libdb) = create_test_db();
4301        let library_id = register_test_library(&libdb, "/tmp/pb_year", "PB Year");
4302        insert_books_for_paging(&libdb, library_id);
4303
4304        let (books, _) = libdb
4305            .page_books(library_id, Path::new(""), SortMethod::Year, false, 10, 0)
4306            .unwrap();
4307        assert_eq!(books[0].year, "2019");
4308    }
4309
4310    #[test]
4311    fn page_books_sort_by_size() {
4312        let (_db, libdb) = create_test_db();
4313        let library_id = register_test_library(&libdb, "/tmp/pb_size", "PB Size");
4314        insert_books_for_paging(&libdb, library_id);
4315
4316        let (books, _) = libdb
4317            .page_books(library_id, Path::new(""), SortMethod::Size, false, 10, 0)
4318            .unwrap();
4319        assert_eq!(books[0].file.size, 300);
4320    }
4321
4322    #[test]
4323    fn page_books_sort_by_kind() {
4324        let (_db, libdb) = create_test_db();
4325        let library_id = register_test_library(&libdb, "/tmp/pb_kind", "PB Kind");
4326        insert_books_for_paging(&libdb, library_id);
4327
4328        let (books, _) = libdb
4329            .page_books(library_id, Path::new(""), SortMethod::Kind, false, 10, 0)
4330            .unwrap();
4331        // epub < pdf alphabetically
4332        assert_eq!(books[0].file.kind, "epub");
4333    }
4334
4335    #[test]
4336    fn page_books_sort_by_pages() {
4337        let (_db, libdb) = create_test_db();
4338        let library_id = register_test_library(&libdb, "/tmp/pb_pages", "PB Pages");
4339        insert_books_for_paging(&libdb, library_id);
4340
4341        let (books, _) = libdb
4342            .page_books(library_id, Path::new(""), SortMethod::Pages, false, 10, 0)
4343            .unwrap();
4344        assert_eq!(books[0].reader_info.as_ref().unwrap().pages_count, 50);
4345    }
4346
4347    #[test]
4348    fn page_books_sort_by_opened() {
4349        let (_db, libdb) = create_test_db();
4350        let library_id = register_test_library(&libdb, "/tmp/pb_opened", "PB Opened");
4351        insert_books_for_paging(&libdb, library_id);
4352
4353        // Should not panic even when opened is NULL for some books.
4354        let (books, total) = libdb
4355            .page_books(library_id, Path::new(""), SortMethod::Opened, false, 10, 0)
4356            .unwrap();
4357        assert_eq!(total, 3);
4358        assert_eq!(books.len(), 3);
4359    }
4360
4361    #[test]
4362    fn page_books_sort_by_added() {
4363        let (_db, libdb) = create_test_db();
4364        let library_id = register_test_library(&libdb, "/tmp/pb_added", "PB Added");
4365        insert_books_for_paging(&libdb, library_id);
4366
4367        let (books, _) = libdb
4368            .page_books(library_id, Path::new(""), SortMethod::Added, false, 10, 0)
4369            .unwrap();
4370        assert_eq!(books.len(), 3);
4371    }
4372
4373    #[test]
4374    fn page_books_sort_by_status() {
4375        let (_db, libdb) = create_test_db();
4376        let library_id = register_test_library(&libdb, "/tmp/pb_status", "PB Status");
4377
4378        let fp_new = Fp::from_str("FF00000000000001").unwrap();
4379        let fp_reading = Fp::from_str("FF00000000000002").unwrap();
4380        let fp_finished = Fp::from_str("FF00000000000003").unwrap();
4381
4382        libdb
4383            .insert_book(library_id, fp_new, &make_info("s/new.pdf", "New", "A"))
4384            .unwrap();
4385
4386        let mut reading = make_info("s/reading.pdf", "Reading", "A");
4387        reading.reader_info = Some(ReaderInfo {
4388            current_page: 10,
4389            pages_count: 100,
4390            finished: false,
4391            ..Default::default()
4392        });
4393        libdb.insert_book(library_id, fp_reading, &reading).unwrap();
4394
4395        let mut finished = make_info("s/finished.pdf", "Finished", "A");
4396        finished.reader_info = Some(ReaderInfo {
4397            current_page: 100,
4398            pages_count: 100,
4399            finished: true,
4400            ..Default::default()
4401        });
4402        libdb
4403            .insert_book(library_id, fp_finished, &finished)
4404            .unwrap();
4405
4406        let (books, _) = libdb
4407            .page_books(library_id, Path::new(""), SortMethod::Status, false, 10, 0)
4408            .unwrap();
4409        assert_eq!(books.len(), 3);
4410        // Finished first in ASC order
4411        assert_eq!(books[0].title, "Finished");
4412    }
4413
4414    #[test]
4415    fn page_books_sort_by_progress() {
4416        let (_db, libdb) = create_test_db();
4417        let library_id = register_test_library(&libdb, "/tmp/pb_progress", "PB Progress");
4418
4419        let fp_finished = Fp::from_str("FE00000000000001").unwrap();
4420        let fp_reading = Fp::from_str("FE00000000000002").unwrap();
4421
4422        let mut finished = make_info("s/fin.pdf", "Finished", "A");
4423        finished.reader_info = Some(ReaderInfo {
4424            current_page: 100,
4425            pages_count: 100,
4426            finished: true,
4427            ..Default::default()
4428        });
4429        libdb
4430            .insert_book(library_id, fp_finished, &finished)
4431            .unwrap();
4432
4433        let mut reading = make_info("s/read.pdf", "Reading", "A");
4434        reading.reader_info = Some(ReaderInfo {
4435            current_page: 50,
4436            pages_count: 100,
4437            finished: false,
4438            ..Default::default()
4439        });
4440        libdb.insert_book(library_id, fp_reading, &reading).unwrap();
4441
4442        let (books, _) = libdb
4443            .page_books(
4444                library_id,
4445                Path::new(""),
4446                SortMethod::Progress,
4447                false,
4448                10,
4449                0,
4450            )
4451            .unwrap();
4452        assert_eq!(books.len(), 2);
4453        assert_eq!(books[0].title, "Finished");
4454    }
4455
4456    #[test]
4457    fn page_books_reverse_order() {
4458        let (_db, libdb) = create_test_db();
4459        let library_id = register_test_library(&libdb, "/tmp/pb_reverse", "PB Reverse");
4460        insert_books_for_paging(&libdb, library_id);
4461
4462        let (asc, _) = libdb
4463            .page_books(library_id, Path::new(""), SortMethod::Title, false, 10, 0)
4464            .unwrap();
4465        let (desc, _) = libdb
4466            .page_books(library_id, Path::new(""), SortMethod::Title, true, 10, 0)
4467            .unwrap();
4468
4469        assert_eq!(asc[0].title, desc[desc.len() - 1].title);
4470        assert_eq!(asc[asc.len() - 1].title, desc[0].title);
4471    }
4472
4473    #[test]
4474    fn page_books_pagination_offset() {
4475        let (_db, libdb) = create_test_db();
4476        let library_id = register_test_library(&libdb, "/tmp/pb_pagination", "PB Pagination");
4477        insert_books_for_paging(&libdb, library_id);
4478        libdb.compute_sort_keys(library_id).unwrap();
4479
4480        let (page1, total) = libdb
4481            .page_books(library_id, Path::new(""), SortMethod::Title, false, 2, 0)
4482            .unwrap();
4483        let (page2, _) = libdb
4484            .page_books(library_id, Path::new(""), SortMethod::Title, false, 2, 2)
4485            .unwrap();
4486
4487        assert_eq!(total, 3);
4488        assert_eq!(page1.len(), 2);
4489        assert_eq!(page2.len(), 1);
4490        assert_ne!(page1[0].title, page2[0].title);
4491    }
4492
4493    #[test]
4494    fn parse_zoom_mode_none_returns_none() {
4495        assert!(Db::parse_zoom_mode(None).is_none());
4496    }
4497
4498    #[test]
4499    fn parse_zoom_mode_invalid_json_returns_none() {
4500        assert!(Db::parse_zoom_mode(Some(&"not-valid-json".to_string())).is_none());
4501    }
4502
4503    #[test]
4504    fn parse_scroll_mode_none_returns_none() {
4505        assert!(Db::parse_scroll_mode(None).is_none());
4506    }
4507
4508    #[test]
4509    fn parse_scroll_mode_invalid_json_returns_none() {
4510        assert!(Db::parse_scroll_mode(Some(&"{{bad}}".to_string())).is_none());
4511    }
4512
4513    #[test]
4514    fn parse_text_align_none_returns_none() {
4515        assert!(Db::parse_text_align(None).is_none());
4516    }
4517
4518    #[test]
4519    fn parse_text_align_invalid_json_returns_none() {
4520        assert!(Db::parse_text_align(Some(&"???".to_string())).is_none());
4521    }
4522
4523    #[test]
4524    fn parse_cropping_margins_none_returns_none() {
4525        assert!(Db::parse_cropping_margins(None).is_none());
4526    }
4527
4528    #[test]
4529    fn parse_cropping_margins_invalid_json_returns_none() {
4530        assert!(Db::parse_cropping_margins(Some(&"bad".to_string())).is_none());
4531    }
4532
4533    #[test]
4534    fn parse_page_names_none_returns_empty_map() {
4535        assert!(Db::parse_page_names(None).is_empty());
4536    }
4537
4538    #[test]
4539    fn parse_page_names_invalid_json_returns_empty_map() {
4540        assert!(Db::parse_page_names(Some(&"!".to_string())).is_empty());
4541    }
4542
4543    #[test]
4544    fn parse_bookmarks_none_returns_empty_set() {
4545        assert!(Db::parse_bookmarks(None).is_empty());
4546    }
4547
4548    #[test]
4549    fn parse_bookmarks_invalid_json_returns_empty_set() {
4550        assert!(Db::parse_bookmarks(Some(&"!".to_string())).is_empty());
4551    }
4552
4553    #[test]
4554    fn parse_annotations_none_returns_empty_vec() {
4555        assert!(Db::parse_annotations(None).is_empty());
4556    }
4557
4558    #[test]
4559    fn parse_annotations_invalid_json_returns_empty_vec() {
4560        assert!(Db::parse_annotations(Some(&"!".to_string())).is_empty());
4561    }
4562
4563    #[test]
4564    fn parse_page_offset_both_some_returns_point() {
4565        let p = Db::parse_page_offset(Some(3), Some(7));
4566        assert!(p.is_some());
4567        let p = p.unwrap();
4568        assert_eq!(p.x, 3);
4569        assert_eq!(p.y, 7);
4570    }
4571
4572    #[test]
4573    fn parse_page_offset_one_none_returns_none() {
4574        assert!(Db::parse_page_offset(Some(1), None).is_none());
4575        assert!(Db::parse_page_offset(None, Some(1)).is_none());
4576        assert!(Db::parse_page_offset(None, None).is_none());
4577    }
4578
4579    #[test]
4580    fn extract_authors_none_returns_empty_string() {
4581        assert_eq!(Db::extract_authors(None), "");
4582    }
4583
4584    #[test]
4585    fn extract_authors_comma_separated_joins_with_space() {
4586        assert_eq!(
4587            Db::extract_authors(Some("Alice,Bob,Carol".to_string())),
4588            "Alice, Bob, Carol"
4589        );
4590    }
4591
4592    #[test]
4593    fn extract_categories_none_returns_empty_set() {
4594        assert!(Db::extract_categories(None).is_empty());
4595    }
4596
4597    #[test]
4598    fn extract_categories_filters_empty_strings() {
4599        let cats = Db::extract_categories(Some(",Fiction,,Science,".to_string()));
4600        assert_eq!(cats.len(), 2);
4601        assert!(cats.contains("Fiction"));
4602        assert!(cats.contains("Science"));
4603    }
4604
4605    #[test]
4606    fn test_batch_insert_with_reading_state() {
4607        let (_db, libdb) = create_test_db();
4608        let library_id = register_test_library(&libdb, "/tmp/test_library13", "Test Library 13");
4609
4610        let mut books = Vec::new();
4611        for i in 1..=3 {
4612            let fp = Fp::from_u64((i + 400) as u64);
4613            let reader_info = ReaderInfo {
4614                current_page: i * 10,
4615                pages_count: i * 100,
4616                finished: i % 2 == 0,
4617                ..Default::default()
4618            };
4619            let info = Info {
4620                title: format!("Book with State {}", i),
4621                author: format!("State Author {}", i),
4622                file: FileInfo {
4623                    path: PathBuf::from(format!("/tmp/state{}.pdf", i)),
4624                    kind: "pdf".to_string(),
4625                    size: (i * 100) as u64,
4626                    ..Default::default()
4627                },
4628                reader_info: Some(reader_info),
4629                ..Default::default()
4630            };
4631
4632            books.push((fp, info));
4633        }
4634
4635        let book_refs: Vec<(Fp, &Info)> = books.iter().map(|(fp, info)| (*fp, info)).collect();
4636
4637        libdb
4638            .batch_insert_books(library_id, &book_refs)
4639            .expect("failed to batch insert books with reading state");
4640
4641        let all_books = libdb
4642            .get_all_books(library_id)
4643            .expect("failed to get books");
4644        for (fp, info) in &books {
4645            let retrieved = all_books
4646                .iter()
4647                .find(|info| info.fp == Some(*fp))
4648                .cloned()
4649                .expect("book should exist");
4650            assert_eq!(retrieved.title, info.title);
4651
4652            assert!(
4653                retrieved.reader_info.is_some(),
4654                "reading state should exist"
4655            );
4656            let retrieved_state = retrieved.reader_info.unwrap();
4657            let original_state = info.reader_info.as_ref().unwrap();
4658            assert_eq!(retrieved_state.current_page, original_state.current_page);
4659            assert_eq!(retrieved_state.pages_count, original_state.pages_count);
4660            assert_eq!(retrieved_state.finished, original_state.finished);
4661        }
4662    }
4663
4664    #[test]
4665    fn delete_books_with_disallowed_kinds_removes_wrong_kind() {
4666        use crate::settings::FileExtension;
4667
4668        let (_db, libdb) = create_test_db();
4669        let library_id =
4670            register_test_library(&libdb, "/tmp/test_disallowed_kinds", "Disallowed Kinds");
4671
4672        let epub_fp = Fp::from_u64(9001);
4673        let pdf_fp = Fp::from_u64(9002);
4674
4675        let epub_info = Info {
4676            title: "Epub Book".to_string(),
4677            file: FileInfo {
4678                path: PathBuf::from("book.epub"),
4679                kind: "epub".to_string(),
4680                size: 100,
4681                ..Default::default()
4682            },
4683            ..Default::default()
4684        };
4685        let pdf_info = Info {
4686            title: "Pdf Book".to_string(),
4687            file: FileInfo {
4688                path: PathBuf::from("book.pdf"),
4689                kind: "pdf".to_string(),
4690                size: 200,
4691                ..Default::default()
4692            },
4693            ..Default::default()
4694        };
4695
4696        libdb
4697            .batch_insert_books(library_id, &[(epub_fp, &epub_info), (pdf_fp, &pdf_info)])
4698            .expect("insert books");
4699
4700        let mut allowed = FxHashSet::default();
4701        allowed.insert(FileExtension::Epub);
4702
4703        let purged = libdb
4704            .delete_books_with_disallowed_kinds(library_id, &allowed)
4705            .expect("purge disallowed");
4706
4707        assert_eq!(purged, vec![pdf_fp], "only pdf should be purged");
4708
4709        let handles = libdb.list_book_handles(library_id).expect("handles");
4710        let fps: Vec<Fp> = handles.iter().map(|(fp, _)| *fp).collect();
4711
4712        assert!(fps.contains(&epub_fp), "epub should remain");
4713        assert!(!fps.contains(&pdf_fp), "pdf should be gone");
4714    }
4715}