Skip to main content

cadmus_core/library/db/
mod.rs

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