cadmus_core/library/db/
mod.rs

1pub mod conversion;
2pub mod models;
3
4use crate::db::runtime::RUNTIME;
5use crate::db::types::{OptionalUuid7, UnixTimestamp, Uuid7};
6use crate::db::Database;
7use crate::document::SimpleTocEntry;
8use crate::geom::Point;
9use crate::helpers::Fp;
10use crate::metadata::{
11    CroppingMargins, FileInfo, Info, ReaderInfo, ScrollMode, TextAlign, ZoomMode,
12};
13use anyhow::Error;
14use conversion::{
15    extract_authors, info_to_book_row, reader_info_to_reading_state_row, rows_to_toc_entries,
16};
17use models::TocEntryRow;
18use sqlx::sqlite::SqlitePool;
19use std::collections::{BTreeMap, BTreeSet, HashMap};
20use std::path::PathBuf;
21use std::str::FromStr;
22
23#[derive(Clone)]
24pub struct Db {
25    pool: SqlitePool,
26}
27
28impl Db {
29    pub fn new(database: &Database) -> Self {
30        Self {
31            pool: database.pool().clone(),
32        }
33    }
34
35    #[cfg_attr(feature = "otel", tracing::instrument(skip(self), fields(path = %path, name = %name)))]
36    pub fn register_library(&self, path: &str, name: &str) -> Result<i64, Error> {
37        tracing::debug!(path = %path, name = %name, "registering library");
38
39        RUNTIME.block_on(async {
40            let now = UnixTimestamp::now();
41
42            let result = sqlx::query!(
43                r#"
44                INSERT INTO libraries (path, name, created_at)
45                VALUES (?, ?, ?)
46                "#,
47                path,
48                name,
49                now
50            )
51            .execute(&self.pool)
52            .await?;
53
54            let library_id = result.last_insert_rowid();
55            tracing::info!(library_id, path = %path, name = %name, "library registered");
56            Ok(library_id)
57        })
58    }
59
60    #[cfg_attr(feature = "otel", tracing::instrument(skip(self), fields(path = %path)))]
61    pub fn get_library_by_path(&self, path: &str) -> Result<Option<i64>, Error> {
62        tracing::debug!(path = %path, "looking up library by path");
63
64        RUNTIME.block_on(async {
65            let id: Option<Option<i64>> =
66                sqlx::query_scalar!(r#"SELECT id FROM libraries WHERE path = ?"#, path)
67                    .fetch_optional(&self.pool)
68                    .await?;
69
70            Ok(id.flatten())
71        })
72    }
73
74    #[inline]
75    fn parse_zoom_mode(json: Option<&String>) -> Option<ZoomMode> {
76        match json {
77            Some(s) => match serde_json::from_str(s) {
78                Ok(v) => Some(v),
79                Err(e) => {
80                    tracing::warn!(error = %e, "failed to parse zoom_mode JSON field");
81                    None
82                }
83            },
84            None => None,
85        }
86    }
87
88    #[inline]
89    fn parse_scroll_mode(json: Option<&String>) -> Option<ScrollMode> {
90        match json {
91            Some(s) => match serde_json::from_str(s) {
92                Ok(v) => Some(v),
93                Err(e) => {
94                    tracing::warn!(error = %e, "failed to parse scroll_mode JSON field");
95                    None
96                }
97            },
98            None => None,
99        }
100    }
101
102    #[inline]
103    fn parse_text_align(json: Option<&String>) -> Option<TextAlign> {
104        match json {
105            Some(s) => match serde_json::from_str(s) {
106                Ok(v) => Some(v),
107                Err(e) => {
108                    tracing::warn!(error = %e, "failed to parse text_align JSON field");
109                    None
110                }
111            },
112            None => None,
113        }
114    }
115
116    #[inline]
117    fn parse_cropping_margins(json: Option<&String>) -> Option<CroppingMargins> {
118        match json {
119            Some(s) => match serde_json::from_str(s) {
120                Ok(v) => Some(v),
121                Err(e) => {
122                    tracing::warn!(error = %e, "failed to parse cropping_margins JSON field");
123                    None
124                }
125            },
126            None => None,
127        }
128    }
129
130    #[inline]
131    fn parse_page_names(json: Option<&String>) -> BTreeMap<usize, String> {
132        match json {
133            Some(s) => match serde_json::from_str(s) {
134                Ok(v) => v,
135                Err(e) => {
136                    tracing::warn!(error = %e, "failed to parse page_names JSON field");
137                    BTreeMap::default()
138                }
139            },
140            None => BTreeMap::default(),
141        }
142    }
143
144    #[inline]
145    fn parse_bookmarks(json: Option<&String>) -> BTreeSet<usize> {
146        match json {
147            Some(s) => match serde_json::from_str(s) {
148                Ok(v) => v,
149                Err(e) => {
150                    tracing::warn!(error = %e, "failed to parse bookmarks JSON field");
151                    BTreeSet::default()
152                }
153            },
154            None => BTreeSet::default(),
155        }
156    }
157
158    #[inline]
159    fn parse_annotations(json: Option<&String>) -> Vec<crate::metadata::Annotation> {
160        match json {
161            Some(s) => match serde_json::from_str(s) {
162                Ok(v) => v,
163                Err(e) => {
164                    tracing::warn!(error = %e, "failed to parse annotations JSON field");
165                    Vec::new()
166                }
167            },
168            None => Vec::new(),
169        }
170    }
171
172    #[inline]
173    fn parse_page_offset(x: Option<i64>, y: Option<i64>) -> Option<Point> {
174        match (x, y) {
175            (Some(x_val), Some(y_val)) => Some(Point::new(x_val as i32, y_val as i32)),
176            _ => None,
177        }
178    }
179
180    #[inline]
181    fn extract_authors(authors: Option<String>) -> String {
182        authors
183            .map(|s| s.split(',').collect::<Vec<_>>().join(", "))
184            .unwrap_or_default()
185    }
186
187    #[inline]
188    fn extract_categories(categories: Option<String>) -> BTreeSet<String> {
189        categories
190            .unwrap_or_default()
191            .split(',')
192            .filter(|s| !s.is_empty())
193            .map(|s| s.to_string())
194            .collect()
195    }
196
197    #[cfg_attr(feature = "otel", tracing::instrument(skip(conn, entries), fields(book_fingerprint = %book_fingerprint, parent_id = ?parent_id)))]
198    async fn insert_toc_entries(
199        conn: &mut sqlx::SqliteConnection,
200        book_fingerprint: &str,
201        entries: &[SimpleTocEntry],
202        parent_id: Option<Uuid7>,
203    ) -> Result<(), Error> {
204        for (position, entry) in entries.iter().enumerate() {
205            let (title, location, children) = match entry {
206                SimpleTocEntry::Leaf(t, loc) => (t.as_str(), loc, [].as_slice()),
207                SimpleTocEntry::Container(t, loc, ch) => (t.as_str(), loc, ch.as_slice()),
208            };
209
210            let (location_kind, location_exact, location_uri) =
211                conversion::encode_location(location);
212            let pos = position as i64;
213            let id = Uuid7::now();
214            let parent_id_str = parent_id.as_ref().map(|p| p.to_string());
215
216            sqlx::query!(
217                r#"
218                INSERT INTO toc_entries (id, book_fingerprint, parent_id, position, title, location_kind, location_exact, location_uri)
219                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
220                "#,
221                id,
222                book_fingerprint,
223                parent_id_str,
224                pos,
225                title,
226                location_kind,
227                location_exact,
228                location_uri,
229            )
230            .execute(&mut *conn)
231            .await?;
232
233            if !children.is_empty() {
234                Box::pin(Self::insert_toc_entries(
235                    conn,
236                    book_fingerprint,
237                    children,
238                    Some(id),
239                ))
240                .await?;
241            }
242        }
243
244        Ok(())
245    }
246
247    async fn fetch_all_toc_entries(
248        pool: &SqlitePool,
249        library_id: i64,
250    ) -> Result<HashMap<String, Vec<TocEntryRow>>, Error> {
251        let toc_rows: Vec<TocEntryRow> = sqlx::query_as!(
252            TocEntryRow,
253            r#"
254            SELECT
255                te.book_fingerprint,
256                te.id                as "id: Uuid7",
257                te.parent_id         as "parent_id!: OptionalUuid7",
258                te.position,
259                te.title,
260                te.location_kind,
261                te.location_exact,
262                te.location_uri
263            FROM toc_entries te
264            INNER JOIN library_books lb ON lb.book_fingerprint = te.book_fingerprint
265            WHERE lb.library_id = ?
266            ORDER BY te.book_fingerprint, te.id ASC
267            "#,
268            library_id
269        )
270        .fetch_all(pool)
271        .await?;
272
273        let mut map: HashMap<String, Vec<TocEntryRow>> = HashMap::new();
274
275        for row in toc_rows {
276            map.entry(row.book_fingerprint.clone())
277                .or_default()
278                .push(row);
279        }
280
281        Ok(map)
282    }
283
284    #[cfg_attr(feature = "otel", tracing::instrument(skip(self), fields(library_id)))]
285    pub fn get_all_books(&self, library_id: i64) -> Result<Vec<(Fp, Info)>, Error> {
286        tracing::debug!(library_id, "fetching all books from database");
287
288        RUNTIME.block_on(async {
289            let book_rows = sqlx::query!(
290                r#"
291                SELECT
292                    fingerprint,
293                    title,
294                    subtitle,
295                    year,
296                    language,
297                    publisher,
298                    series,
299                    edition,
300                    volume,
301                    number,
302                    identifier,
303                    file_path,
304                    absolute_path,
305                    file_kind,
306                    file_size,
307                    added_at              as "added_at: UnixTimestamp",
308                    opened                as "opened?: UnixTimestamp",
309                    current_page          as "current_page?: i64",
310                    pages_count           as "pages_count?: i64",
311                    finished              as "finished?: i64",
312                    dithered              as "dithered?: i64",
313                    zoom_mode             as "zoom_mode?: String",
314                    scroll_mode           as "scroll_mode?: String",
315                    page_offset_x         as "page_offset_x?: i64",
316                    page_offset_y         as "page_offset_y?: i64",
317                    rotation              as "rotation?: i64",
318                    cropping_margins_json as "cropping_margins_json?: String",
319                    margin_width          as "margin_width?: i64",
320                    screen_margin_width   as "screen_margin_width?: i64",
321                    font_family           as "font_family?: String",
322                    font_size             as "font_size?: f64",
323                    text_align            as "text_align?: String",
324                    line_height           as "line_height?: f64",
325                    contrast_exponent     as "contrast_exponent?: f64",
326                    contrast_gray         as "contrast_gray?: f64",
327                    page_names_json       as "page_names_json?: String",
328                    bookmarks_json        as "bookmarks_json?: String",
329                    annotations_json      as "annotations_json?: String",
330                    authors               as "authors?: String",
331                    categories            as "categories?: String"
332                FROM library_books_full_info
333                WHERE library_id = ?
334                ORDER BY added_at DESC
335                "#,
336                library_id
337            )
338            .fetch_all(&self.pool)
339            .await?;
340
341            let mut toc_by_fingerprint =
342                Self::fetch_all_toc_entries(&self.pool, library_id).await?;
343
344            let mut result = Vec::new();
345
346            for row in book_rows {
347                let fp = Fp::from_str(&row.fingerprint)?;
348
349                let toc = toc_by_fingerprint
350                    .remove(&row.fingerprint)
351                    .map(|rows| rows_to_toc_entries(&rows))
352                    .transpose()?;
353
354                let mut info = Info {
355                    title: row.title,
356                    subtitle: row.subtitle,
357                    author: Self::extract_authors(row.authors),
358                    year: row.year,
359                    language: row.language,
360                    publisher: row.publisher,
361                    series: row.series,
362                    edition: row.edition,
363                    volume: row.volume,
364                    number: row.number,
365                    identifier: row.identifier,
366                    categories: Self::extract_categories(row.categories),
367                    file: FileInfo {
368                        path: PathBuf::from(&row.file_path),
369                        absolute_path: PathBuf::from(&row.absolute_path),
370                        kind: row.file_kind,
371                        size: row.file_size as u64,
372                    },
373                    reader: None,
374                    reader_info: None,
375                    toc,
376                    added: row.added_at.into(),
377                };
378
379                if let Some(opened_ts) = row.opened {
380                    let reader_info = ReaderInfo {
381                        opened: opened_ts.into(),
382                        current_page: row.current_page.unwrap_or(0) as usize,
383                        pages_count: row.pages_count.unwrap_or(0) as usize,
384                        finished: row.finished.unwrap_or(0) == 1,
385                        dithered: row.dithered.unwrap_or(0) == 1,
386                        zoom_mode: Self::parse_zoom_mode(row.zoom_mode.as_ref()),
387                        scroll_mode: Self::parse_scroll_mode(row.scroll_mode.as_ref()),
388                        page_offset: Self::parse_page_offset(row.page_offset_x, row.page_offset_y),
389                        rotation: row.rotation.map(|r| r as i8),
390                        cropping_margins: Self::parse_cropping_margins(
391                            row.cropping_margins_json.as_ref(),
392                        ),
393                        margin_width: row.margin_width.map(|m| m as i32),
394                        screen_margin_width: row.screen_margin_width.map(|m| m as i32),
395                        font_family: row.font_family.clone(),
396                        font_size: row.font_size.map(|f| f as f32),
397                        text_align: Self::parse_text_align(row.text_align.as_ref()),
398                        line_height: row.line_height.map(|l| l as f32),
399                        contrast_exponent: row.contrast_exponent.map(|c| c as f32),
400                        contrast_gray: row.contrast_gray.map(|c| c as f32),
401                        page_names: Self::parse_page_names(row.page_names_json.as_ref()),
402                        bookmarks: Self::parse_bookmarks(row.bookmarks_json.as_ref()),
403                        annotations: Self::parse_annotations(row.annotations_json.as_ref()),
404                    };
405                    info.reader = Some(reader_info.clone());
406                    info.reader_info = Some(reader_info);
407                }
408
409                result.push((fp, info));
410            }
411
412            tracing::debug!(library_id, count = result.len(), "fetched all books");
413            Ok(result)
414        })
415    }
416
417    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, info), fields(fp = %fp, library_id)))]
418    pub fn insert_book(&self, library_id: i64, fp: Fp, info: &Info) -> Result<(), Error> {
419        tracing::debug!(fp = %fp, library_id, "inserting book into database");
420        let fp_str = fp.to_string();
421
422        RUNTIME.block_on(async {
423            let mut tx = self.pool.begin().await?;
424
425            let book_row = info_to_book_row(fp, info);
426
427            sqlx::query!(
428                r#"
429                INSERT OR IGNORE INTO books (
430                    fingerprint, title, subtitle, year, language, publisher,
431                    series, edition, volume, number, identifier,
432                    absolute_path, file_kind, file_size, added_at
433                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
434                "#,
435                book_row.fingerprint,
436                book_row.title,
437                book_row.subtitle,
438                book_row.year,
439                book_row.language,
440                book_row.publisher,
441                book_row.series,
442                book_row.edition,
443                book_row.volume,
444                book_row.number,
445                book_row.identifier,
446                book_row.absolute_path,
447                book_row.file_kind,
448                book_row.file_size,
449                book_row.added_at,
450            )
451            .execute(&mut *tx)
452            .await?;
453
454            sqlx::query!(
455                r#"
456                INSERT OR IGNORE INTO library_books (library_id, book_fingerprint, added_to_library_at, file_path)
457                VALUES (?, ?, ?, ?)
458                "#,
459                library_id,
460                fp_str,
461                book_row.added_at,
462                book_row.file_path,
463            )
464            .execute(&mut *tx)
465            .await?;
466
467            let authors = extract_authors(&info.author);
468            for (position, author_name) in authors.iter().enumerate() {
469                sqlx::query!(
470                    r#"INSERT OR IGNORE INTO authors (name) VALUES (?)"#,
471                    author_name
472                )
473                .execute(&mut *tx)
474                .await?;
475
476                let author_id: i64 = sqlx::query_scalar!(
477                    r#"SELECT id FROM authors WHERE name = ?"#,
478                    author_name
479                )
480                .fetch_one(&mut *tx)
481                .await?;
482
483                let pos = position as i64;
484                sqlx::query!(
485                    r#"
486                    INSERT OR IGNORE INTO book_authors (book_fingerprint, author_id, position)
487                    VALUES (?, ?, ?)
488                    "#,
489                    fp_str,
490                    author_id,
491                    pos
492                )
493                .execute(&mut *tx)
494                .await?;
495            }
496
497            for category_name in &info.categories {
498                sqlx::query!(
499                    r#"INSERT OR IGNORE INTO categories (name) VALUES (?)"#,
500                    category_name
501                )
502                .execute(&mut *tx)
503                .await?;
504
505                let category_id: i64 = sqlx::query_scalar!(
506                    r#"SELECT id FROM categories WHERE name = ?"#,
507                    category_name
508                )
509                .fetch_one(&mut *tx)
510                .await?;
511
512                sqlx::query!(
513                    r#"
514                    INSERT OR IGNORE INTO book_categories (book_fingerprint, category_id)
515                    VALUES (?, ?)
516                    "#,
517                    fp_str,
518                    category_id
519                )
520                .execute(&mut *tx)
521                .await?;
522            }
523
524            if let Some(reader_info) = &info.reader_info {
525                let rs_row = reader_info_to_reading_state_row(fp, reader_info);
526
527                sqlx::query!(
528                    r#"
529                    INSERT INTO reading_states (
530                        fingerprint, opened, current_page, pages_count, finished, dithered,
531                        zoom_mode, scroll_mode, page_offset_x, page_offset_y, rotation,
532                        cropping_margins_json, margin_width, screen_margin_width,
533                        font_family, font_size, text_align, line_height,
534                        contrast_exponent, contrast_gray,
535                        page_names_json, bookmarks_json, annotations_json
536                    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
537                    "#,
538                    rs_row.fingerprint,
539                    rs_row.opened,
540                    rs_row.current_page,
541                    rs_row.pages_count,
542                    rs_row.finished,
543                    rs_row.dithered,
544                    rs_row.zoom_mode,
545                    rs_row.scroll_mode,
546                    rs_row.page_offset_x,
547                    rs_row.page_offset_y,
548                    rs_row.rotation,
549                    rs_row.cropping_margins_json,
550                    rs_row.margin_width,
551                    rs_row.screen_margin_width,
552                    rs_row.font_family,
553                    rs_row.font_size,
554                    rs_row.text_align,
555                    rs_row.line_height,
556                    rs_row.contrast_exponent,
557                    rs_row.contrast_gray,
558                    rs_row.page_names_json,
559                    rs_row.bookmarks_json,
560                    rs_row.annotations_json,
561                )
562                .execute(&mut *tx)
563                .await?;
564            }
565
566            tx.commit().await?;
567
568            tracing::debug!(fp = %fp, "book insert complete");
569            Ok(())
570        })
571    }
572
573    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, info), fields(fp = %fp, library_id)))]
574    pub fn update_book(&self, library_id: i64, fp: Fp, info: &Info) -> Result<(), Error> {
575        tracing::debug!(fp = %fp, library_id, "updating book in database");
576        let fp_str = fp.to_string();
577
578        RUNTIME.block_on(async {
579            let mut tx = self.pool.begin().await?;
580
581            let book_row = info_to_book_row(fp, info);
582
583            sqlx::query!(
584                r#"
585                UPDATE books SET
586                    title = ?, subtitle = ?, year = ?, language = ?, publisher = ?,
587                    series = ?, edition = ?, volume = ?, number = ?, identifier = ?,
588                    absolute_path = ?, file_kind = ?, file_size = ?, added_at = ?
589                WHERE fingerprint = ?
590                "#,
591                book_row.title,
592                book_row.subtitle,
593                book_row.year,
594                book_row.language,
595                book_row.publisher,
596                book_row.series,
597                book_row.edition,
598                book_row.volume,
599                book_row.number,
600                book_row.identifier,
601                book_row.absolute_path,
602                book_row.file_kind,
603                book_row.file_size,
604                book_row.added_at,
605                fp_str,
606            )
607            .execute(&mut *tx)
608            .await?;
609
610            sqlx::query!(
611                r#"
612                UPDATE library_books SET file_path = ?
613                WHERE library_id = ? AND book_fingerprint = ?
614                "#,
615                book_row.file_path,
616                library_id,
617                fp_str,
618            )
619            .execute(&mut *tx)
620            .await?;
621
622            sqlx::query!(
623                r#"DELETE FROM book_authors WHERE book_fingerprint = ?"#,
624                fp_str
625            )
626            .execute(&mut *tx)
627            .await?;
628
629            let authors = extract_authors(&info.author);
630            for (position, author_name) in authors.iter().enumerate() {
631                sqlx::query!(
632                    r#"INSERT OR IGNORE INTO authors (name) VALUES (?)"#,
633                    author_name
634                )
635                .execute(&mut *tx)
636                .await?;
637
638                let author_id: i64 =
639                    sqlx::query_scalar!(r#"SELECT id FROM authors WHERE name = ?"#, author_name)
640                        .fetch_one(&mut *tx)
641                        .await?;
642
643                let pos = position as i64;
644                sqlx::query!(
645                    r#"
646                    INSERT INTO book_authors (book_fingerprint, author_id, position)
647                    VALUES (?, ?, ?)
648                    "#,
649                    fp_str,
650                    author_id,
651                    pos
652                )
653                .execute(&mut *tx)
654                .await?;
655            }
656
657            sqlx::query!(
658                r#"DELETE FROM book_categories WHERE book_fingerprint = ?"#,
659                fp_str
660            )
661            .execute(&mut *tx)
662            .await?;
663
664            for category_name in &info.categories {
665                sqlx::query!(
666                    r#"INSERT OR IGNORE INTO categories (name) VALUES (?)"#,
667                    category_name
668                )
669                .execute(&mut *tx)
670                .await?;
671
672                let category_id: i64 = sqlx::query_scalar!(
673                    r#"SELECT id FROM categories WHERE name = ?"#,
674                    category_name
675                )
676                .fetch_one(&mut *tx)
677                .await?;
678
679                sqlx::query!(
680                    r#"
681                    INSERT INTO book_categories (book_fingerprint, category_id)
682                    VALUES (?, ?)
683                    "#,
684                    fp_str,
685                    category_id
686                )
687                .execute(&mut *tx)
688                .await?;
689            }
690
691            tx.commit().await?;
692
693            tracing::debug!(fp = %fp, "book update complete");
694            Ok(())
695        })
696    }
697
698    #[cfg_attr(feature = "otel", tracing::instrument(skip(self), fields(fp = %fp)))]
699    pub fn delete_reading_state(&self, fp: Fp) -> Result<(), Error> {
700        tracing::debug!(fp = %fp, "deleting reading state from database");
701
702        RUNTIME.block_on(async {
703            let fp_str = fp.to_string();
704
705            sqlx::query!(
706                r#"DELETE FROM reading_states WHERE fingerprint = ?"#,
707                fp_str
708            )
709            .execute(&self.pool)
710            .await?;
711
712            Ok(())
713        })
714    }
715
716    #[cfg_attr(feature = "otel", tracing::instrument(skip(self), fields(fp = %fp, library_id)))]
717    pub fn delete_book(&self, library_id: i64, fp: Fp) -> Result<(), Error> {
718        tracing::debug!(fp = %fp, library_id, "deleting book from library");
719
720        RUNTIME.block_on(async {
721            let fp_str = fp.to_string();
722            let mut tx = self.pool.begin().await?;
723
724            sqlx::query!(
725                r#"DELETE FROM library_books WHERE library_id = ? AND book_fingerprint = ?"#,
726                library_id,
727                fp_str
728            )
729            .execute(&mut *tx)
730            .await?;
731
732            let remaining: i64 = sqlx::query_scalar!(
733                r#"SELECT COUNT(*) FROM library_books WHERE book_fingerprint = ?"#,
734                fp_str
735            )
736            .fetch_one(&mut *tx)
737            .await?;
738
739            if remaining == 0 {
740                tracing::debug!(fp = %fp, "book not in any library, deleting completely");
741                sqlx::query!(r#"DELETE FROM books WHERE fingerprint = ?"#, fp_str)
742                    .execute(&mut *tx)
743                    .await?;
744            }
745
746            tx.commit().await?;
747
748            tracing::debug!(fp = %fp, library_id, "book delete complete");
749            Ok(())
750        })
751    }
752
753    #[cfg_attr(feature = "otel", tracing::instrument(skip(self), fields(fp = %fp)))]
754    pub fn get_thumbnail(&self, fp: Fp) -> Result<Option<Vec<u8>>, Error> {
755        tracing::debug!(fp = %fp, "fetching thumbnail from database");
756        let fp_str = fp.to_string();
757
758        RUNTIME.block_on(async {
759            sqlx::query_scalar!(
760                "SELECT thumbnail_data FROM thumbnails WHERE fingerprint = ?",
761                fp_str
762            )
763            .fetch_optional(&self.pool)
764            .await
765            .map_err(Error::from)
766        })
767    }
768
769    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, data), fields(fp = %fp, size = data.len())))]
770    pub fn save_thumbnail(&self, fp: Fp, data: &[u8]) -> Result<(), Error> {
771        tracing::debug!(fp = %fp, size = data.len(), "saving thumbnail to database");
772        let fp_str = fp.to_string();
773
774        RUNTIME.block_on(async {
775            sqlx::query!(
776                r#"
777                INSERT INTO thumbnails (fingerprint, thumbnail_data)
778                VALUES (?, ?)
779                ON CONFLICT(fingerprint) DO UPDATE SET
780                    thumbnail_data = excluded.thumbnail_data
781                "#,
782                fp_str,
783                data,
784            )
785            .execute(&self.pool)
786            .await?;
787
788            tracing::debug!(fp = %fp, "thumbnail save complete");
789            Ok(())
790        })
791    }
792
793    #[cfg_attr(feature = "otel", tracing::instrument(skip(self), fields(fp = %fp)))]
794    pub fn delete_thumbnail(&self, fp: Fp) -> Result<(), Error> {
795        tracing::debug!(fp = %fp, "deleting thumbnail from database");
796        let fp_str = fp.to_string();
797
798        RUNTIME.block_on(async {
799            sqlx::query!("DELETE FROM thumbnails WHERE fingerprint = ?", fp_str)
800                .execute(&self.pool)
801                .await?;
802
803            tracing::debug!(fp = %fp, "thumbnail delete complete");
804            Ok(())
805        })
806    }
807
808    #[cfg_attr(feature = "otel", tracing::instrument(skip(self), fields(from = %from_fp, to = %to_fp)))]
809    pub fn move_thumbnail(&self, from_fp: Fp, to_fp: Fp) -> Result<(), Error> {
810        tracing::debug!(from = %from_fp, to = %to_fp, "moving thumbnail in database");
811        let from_fp_str = from_fp.to_string();
812        let to_fp_str = to_fp.to_string();
813
814        RUNTIME.block_on(async {
815            sqlx::query!(
816                r#"
817                UPDATE thumbnails
818                SET fingerprint = ?
819                WHERE fingerprint = ?
820                "#,
821                to_fp_str,
822                from_fp_str
823            )
824            .execute(&self.pool)
825            .await?;
826
827            Ok(())
828        })
829    }
830
831    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, reader_info), fields(fp = %fp)))]
832    pub fn save_reading_state(&self, fp: Fp, reader_info: &ReaderInfo) -> Result<(), Error> {
833        tracing::debug!(fp = %fp, "saving reading state to database");
834
835        RUNTIME.block_on(async {
836            let rs_row = reader_info_to_reading_state_row(fp, reader_info);
837
838            sqlx::query!(
839                r#"
840                INSERT INTO reading_states (
841                    fingerprint, opened, current_page, pages_count, finished, dithered,
842                    zoom_mode, scroll_mode, page_offset_x, page_offset_y, rotation,
843                    cropping_margins_json, margin_width, screen_margin_width,
844                    font_family, font_size, text_align, line_height,
845                    contrast_exponent, contrast_gray,
846                    page_names_json, bookmarks_json, annotations_json
847                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
848                ON CONFLICT(fingerprint) DO UPDATE SET
849                    opened = excluded.opened,
850                    current_page = excluded.current_page,
851                    pages_count = excluded.pages_count,
852                    finished = excluded.finished,
853                    dithered = excluded.dithered,
854                    zoom_mode = excluded.zoom_mode,
855                    scroll_mode = excluded.scroll_mode,
856                    page_offset_x = excluded.page_offset_x,
857                    page_offset_y = excluded.page_offset_y,
858                    rotation = excluded.rotation,
859                    cropping_margins_json = excluded.cropping_margins_json,
860                    margin_width = excluded.margin_width,
861                    screen_margin_width = excluded.screen_margin_width,
862                    font_family = excluded.font_family,
863                    font_size = excluded.font_size,
864                    text_align = excluded.text_align,
865                    line_height = excluded.line_height,
866                    contrast_exponent = excluded.contrast_exponent,
867                    contrast_gray = excluded.contrast_gray,
868                    page_names_json = excluded.page_names_json,
869                    bookmarks_json = excluded.bookmarks_json,
870                    annotations_json = excluded.annotations_json
871                "#,
872                rs_row.fingerprint,
873                rs_row.opened,
874                rs_row.current_page,
875                rs_row.pages_count,
876                rs_row.finished,
877                rs_row.dithered,
878                rs_row.zoom_mode,
879                rs_row.scroll_mode,
880                rs_row.page_offset_x,
881                rs_row.page_offset_y,
882                rs_row.rotation,
883                rs_row.cropping_margins_json,
884                rs_row.margin_width,
885                rs_row.screen_margin_width,
886                rs_row.font_family,
887                rs_row.font_size,
888                rs_row.text_align,
889                rs_row.line_height,
890                rs_row.contrast_exponent,
891                rs_row.contrast_gray,
892                rs_row.page_names_json,
893                rs_row.bookmarks_json,
894                rs_row.annotations_json,
895            )
896            .execute(&self.pool)
897            .await?;
898
899            tracing::debug!(fp = %fp, "reading state save complete");
900            Ok(())
901        })
902    }
903
904    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, toc), fields(fp = %fp, entry_count = toc.len())))]
905    pub fn save_toc(&self, fp: Fp, toc: &[SimpleTocEntry]) -> Result<(), Error> {
906        if toc.is_empty() {
907            return Ok(());
908        }
909
910        tracing::debug!(fp = %fp, entry_count = toc.len(), "saving TOC to database");
911        let fp_str = fp.to_string();
912
913        RUNTIME.block_on(async {
914            let mut tx = self.pool.begin().await?;
915
916            sqlx::query!("DELETE FROM toc_entries WHERE book_fingerprint = ?", fp_str)
917                .execute(&mut *tx)
918                .await?;
919
920            Self::insert_toc_entries(&mut tx, &fp_str, toc, None).await?;
921
922            tx.commit().await?;
923
924            tracing::debug!(fp = %fp, "TOC save complete");
925            Ok(())
926        })
927    }
928
929    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, books), fields(library_id, count = books.len())))]
930    pub fn batch_insert_books(&self, library_id: i64, books: &[(Fp, &Info)]) -> Result<(), Error> {
931        if books.is_empty() {
932            return Ok(());
933        }
934
935        tracing::debug!(library_id, count = books.len(), "batch inserting books");
936
937        RUNTIME.block_on(async {
938            let mut tx = self.pool.begin().await?;
939
940            for (fp, info) in books {
941                let fp_str = fp.to_string();
942                let book_row = info_to_book_row(*fp, info);
943
944                sqlx::query!(
945                    r#"
946                    INSERT OR IGNORE INTO books (
947                        fingerprint, title, subtitle, year, language, publisher,
948                        series, edition, volume, number, identifier,
949                        absolute_path, file_kind, file_size, added_at
950                    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
951                    "#,
952                    book_row.fingerprint,
953                    book_row.title,
954                    book_row.subtitle,
955                    book_row.year,
956                    book_row.language,
957                    book_row.publisher,
958                    book_row.series,
959                    book_row.edition,
960                    book_row.volume,
961                    book_row.number,
962                    book_row.identifier,
963                    book_row.absolute_path,
964                    book_row.file_kind,
965                    book_row.file_size,
966                    book_row.added_at,
967                )
968                .execute(&mut *tx)
969                .await?;
970
971                sqlx::query!(
972                    r#"
973                    INSERT OR IGNORE INTO library_books (library_id, book_fingerprint, added_to_library_at, file_path)
974                    VALUES (?, ?, ?, ?)
975                    "#,
976                    library_id,
977                    fp_str,
978                    book_row.added_at,
979                    book_row.file_path,
980                )
981                .execute(&mut *tx)
982                .await?;
983
984                let authors = extract_authors(&info.author);
985                for (position, author_name) in authors.iter().enumerate() {
986                    sqlx::query!(
987                        r#"INSERT OR IGNORE INTO authors (name) VALUES (?)"#,
988                        author_name
989                    )
990                    .execute(&mut *tx)
991                    .await?;
992
993                    let author_id: i64 = sqlx::query_scalar!(
994                        r#"SELECT id FROM authors WHERE name = ?"#,
995                        author_name
996                    )
997                    .fetch_one(&mut *tx)
998                    .await?;
999
1000                    let pos = position as i64;
1001                    sqlx::query!(
1002                        r#"
1003                        INSERT INTO book_authors (book_fingerprint, author_id, position)
1004                        VALUES (?, ?, ?)
1005                        "#,
1006                        fp_str,
1007                        author_id,
1008                        pos
1009                    )
1010                    .execute(&mut *tx)
1011                    .await?;
1012                }
1013
1014                for category_name in &info.categories {
1015                    sqlx::query!(
1016                        r#"INSERT OR IGNORE INTO categories (name) VALUES (?)"#,
1017                        category_name
1018                    )
1019                    .execute(&mut *tx)
1020                    .await?;
1021
1022                    let category_id: i64 = sqlx::query_scalar!(
1023                        r#"SELECT id FROM categories WHERE name = ?"#,
1024                        category_name
1025                    )
1026                    .fetch_one(&mut *tx)
1027                    .await?;
1028
1029                    sqlx::query!(
1030                        r#"
1031                        INSERT INTO book_categories (book_fingerprint, category_id)
1032                        VALUES (?, ?)
1033                        "#,
1034                        fp_str,
1035                        category_id
1036                    )
1037                    .execute(&mut *tx)
1038                    .await?;
1039                }
1040
1041                if let Some(reader_info) = &info.reader_info {
1042                    let rs_row = reader_info_to_reading_state_row(*fp, reader_info);
1043
1044                    sqlx::query!(
1045                        r#"
1046                        INSERT INTO reading_states (
1047                            fingerprint, opened, current_page, pages_count, finished, dithered,
1048                            zoom_mode, scroll_mode, page_offset_x, page_offset_y, rotation,
1049                            cropping_margins_json, margin_width, screen_margin_width,
1050                            font_family, font_size, text_align, line_height,
1051                            contrast_exponent, contrast_gray,
1052                            page_names_json, bookmarks_json, annotations_json
1053                        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1054                        "#,
1055                        rs_row.fingerprint,
1056                        rs_row.opened,
1057                        rs_row.current_page,
1058                        rs_row.pages_count,
1059                        rs_row.finished,
1060                        rs_row.dithered,
1061                        rs_row.zoom_mode,
1062                        rs_row.scroll_mode,
1063                        rs_row.page_offset_x,
1064                        rs_row.page_offset_y,
1065                        rs_row.rotation,
1066                        rs_row.cropping_margins_json,
1067                        rs_row.margin_width,
1068                        rs_row.screen_margin_width,
1069                        rs_row.font_family,
1070                        rs_row.font_size,
1071                        rs_row.text_align,
1072                        rs_row.line_height,
1073                        rs_row.contrast_exponent,
1074                        rs_row.contrast_gray,
1075                        rs_row.page_names_json,
1076                        rs_row.bookmarks_json,
1077                        rs_row.annotations_json,
1078                    )
1079                    .execute(&mut *tx)
1080                    .await?;
1081                }
1082
1083                if let Some(ref toc) = info.toc {
1084                    sqlx::query!("DELETE FROM toc_entries WHERE book_fingerprint = ?", fp_str)
1085                        .execute(&mut *tx)
1086                        .await?;
1087                    Self::insert_toc_entries(&mut tx, &fp_str, toc, None).await?;
1088                }
1089            }
1090
1091            tx.commit().await?;
1092
1093            tracing::debug!(count = books.len(), "batch insert complete");
1094            Ok(())
1095        })
1096    }
1097
1098    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, books), fields(library_id, count = books.len())))]
1099    pub fn batch_update_books(&self, library_id: i64, books: &[(Fp, &Info)]) -> Result<(), Error> {
1100        if books.is_empty() {
1101            return Ok(());
1102        }
1103
1104        tracing::debug!(library_id, count = books.len(), "batch updating books");
1105
1106        RUNTIME.block_on(async {
1107            let mut tx = self.pool.begin().await?;
1108
1109            for (fp, info) in books {
1110                let fp_str = fp.to_string();
1111
1112                let book_row = info_to_book_row(*fp, info);
1113
1114                sqlx::query!(
1115                    r#"
1116                    UPDATE books SET
1117                        title = ?, subtitle = ?, year = ?, language = ?, publisher = ?,
1118                        series = ?, edition = ?, volume = ?, number = ?, identifier = ?,
1119                        absolute_path = ?, file_kind = ?, file_size = ?, added_at = ?
1120                    WHERE fingerprint = ?
1121                    "#,
1122                    book_row.title,
1123                    book_row.subtitle,
1124                    book_row.year,
1125                    book_row.language,
1126                    book_row.publisher,
1127                    book_row.series,
1128                    book_row.edition,
1129                    book_row.volume,
1130                    book_row.number,
1131                    book_row.identifier,
1132                    book_row.absolute_path,
1133                    book_row.file_kind,
1134                    book_row.file_size,
1135                    book_row.added_at,
1136                    fp_str,
1137                )
1138                .execute(&mut *tx)
1139                .await?;
1140
1141                sqlx::query!(
1142                    r#"
1143                    UPDATE library_books SET file_path = ?
1144                    WHERE library_id = ? AND book_fingerprint = ?
1145                    "#,
1146                    book_row.file_path,
1147                    library_id,
1148                    fp_str,
1149                )
1150                .execute(&mut *tx)
1151                .await?;
1152
1153                sqlx::query!(
1154                    r#"DELETE FROM book_authors WHERE book_fingerprint = ?"#,
1155                    fp_str
1156                )
1157                .execute(&mut *tx)
1158                .await?;
1159
1160                let authors = extract_authors(&info.author);
1161                for (position, author_name) in authors.iter().enumerate() {
1162                    sqlx::query!(
1163                        r#"INSERT OR IGNORE INTO authors (name) VALUES (?)"#,
1164                        author_name
1165                    )
1166                    .execute(&mut *tx)
1167                    .await?;
1168
1169                    let author_id: i64 = sqlx::query_scalar!(
1170                        r#"SELECT id FROM authors WHERE name = ?"#,
1171                        author_name
1172                    )
1173                    .fetch_one(&mut *tx)
1174                    .await?;
1175
1176                    let pos = position as i64;
1177                    sqlx::query!(
1178                        r#"
1179                        INSERT INTO book_authors (book_fingerprint, author_id, position)
1180                        VALUES (?, ?, ?)
1181                        "#,
1182                        fp_str,
1183                        author_id,
1184                        pos
1185                    )
1186                    .execute(&mut *tx)
1187                    .await?;
1188                }
1189
1190                sqlx::query!(
1191                    r#"DELETE FROM book_categories WHERE book_fingerprint = ?"#,
1192                    fp_str
1193                )
1194                .execute(&mut *tx)
1195                .await?;
1196
1197                for category_name in &info.categories {
1198                    sqlx::query!(
1199                        r#"INSERT OR IGNORE INTO categories (name) VALUES (?)"#,
1200                        category_name
1201                    )
1202                    .execute(&mut *tx)
1203                    .await?;
1204
1205                    let category_id: i64 = sqlx::query_scalar!(
1206                        r#"SELECT id FROM categories WHERE name = ?"#,
1207                        category_name
1208                    )
1209                    .fetch_one(&mut *tx)
1210                    .await?;
1211
1212                    sqlx::query!(
1213                        r#"
1214                        INSERT INTO book_categories (book_fingerprint, category_id)
1215                        VALUES (?, ?)
1216                        "#,
1217                        fp_str,
1218                        category_id
1219                    )
1220                    .execute(&mut *tx)
1221                    .await?;
1222                }
1223
1224                if let Some(ref toc) = info.toc {
1225                    sqlx::query!("DELETE FROM toc_entries WHERE book_fingerprint = ?", fp_str)
1226                        .execute(&mut *tx)
1227                        .await?;
1228                    Self::insert_toc_entries(&mut tx, &fp_str, toc, None).await?;
1229                } else {
1230                    sqlx::query!("DELETE FROM toc_entries WHERE book_fingerprint = ?", fp_str)
1231                        .execute(&mut *tx)
1232                        .await?;
1233                }
1234            }
1235
1236            tx.commit().await?;
1237
1238            tracing::debug!(count = books.len(), "batch update complete");
1239            Ok(())
1240        })
1241    }
1242
1243    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, fps), fields(library_id, count = fps.len())))]
1244    pub fn batch_delete_books(&self, library_id: i64, fps: &[Fp]) -> Result<(), Error> {
1245        if fps.is_empty() {
1246            return Ok(());
1247        }
1248
1249        tracing::debug!(
1250            library_id,
1251            count = fps.len(),
1252            "batch deleting books from library"
1253        );
1254
1255        RUNTIME.block_on(async {
1256            let mut tx = self.pool.begin().await?;
1257
1258            for fp in fps {
1259                let fp_str = fp.to_string();
1260
1261                sqlx::query!(
1262                    r#"DELETE FROM library_books WHERE library_id = ? AND book_fingerprint = ?"#,
1263                    library_id,
1264                    fp_str
1265                )
1266                .execute(&mut *tx)
1267                .await?;
1268
1269                let ref_count: i64 = sqlx::query_scalar!(
1270                    r#"SELECT COUNT(*) FROM library_books WHERE book_fingerprint = ?"#,
1271                    fp_str
1272                )
1273                .fetch_one(&mut *tx)
1274                .await?;
1275
1276                if ref_count == 0 {
1277                    sqlx::query!(
1278                        r#"DELETE FROM books WHERE fingerprint = ?"#,
1279                        fp_str
1280                    )
1281                    .execute(&mut *tx)
1282                    .await?;
1283                    tracing::debug!(fp = %fp, "book removed from database (no more library references)");
1284                } else {
1285                    tracing::debug!(fp = %fp, ref_count, "book kept in database (still referenced by other libraries)");
1286                }
1287            }
1288
1289            tx.commit().await?;
1290
1291            tracing::debug!(count = fps.len(), "batch delete complete");
1292            Ok(())
1293        })
1294    }
1295}
1296
1297#[cfg(test)]
1298mod tests {
1299    use super::*;
1300    use crate::db::Database;
1301    use crate::metadata::ReaderInfo;
1302    use chrono::Local;
1303    use std::path::PathBuf;
1304    use std::str::FromStr;
1305
1306    fn create_test_db() -> (Database, Db) {
1307        let db = Database::new(":memory:").expect("failed to create in-memory database");
1308        db.migrate().expect("failed to run migrations");
1309        let libdb = Db::new(&db);
1310        (db, libdb)
1311    }
1312
1313    #[test]
1314    fn test_insert_and_get_book() {
1315        let (_db, libdb) = create_test_db();
1316        let fp = Fp::from_str("0000000000000001").unwrap();
1317
1318        let info = Info {
1319            title: "Test Book".to_string(),
1320            subtitle: "A Test".to_string(),
1321            author: "John Doe, Jane Smith".to_string(),
1322            year: "2024".to_string(),
1323            language: "en".to_string(),
1324            publisher: "Test Press".to_string(),
1325            series: "Test Series".to_string(),
1326            number: "1".to_string(),
1327            categories: vec!["Fiction".to_string(), "Science".to_string()]
1328                .into_iter()
1329                .collect(),
1330            file: FileInfo {
1331                path: PathBuf::from("/tmp/test.pdf"),
1332                kind: "pdf".to_string(),
1333                size: 1024,
1334                ..Default::default()
1335            },
1336            added: Local::now().naive_local(),
1337            ..Default::default()
1338        };
1339
1340        let library_id = libdb
1341            .register_library("/tmp/test_library", "Test Library")
1342            .expect("failed to register library");
1343        libdb
1344            .insert_book(library_id, fp, &info)
1345            .expect("failed to insert book");
1346
1347        let books = libdb
1348            .get_all_books(library_id)
1349            .expect("failed to get books");
1350        let retrieved_info = books.iter().find(|(f, _)| *f == fp).map(|(_, i)| i.clone());
1351        assert!(retrieved_info.is_some(), "book should exist in database");
1352
1353        let retrieved_info = retrieved_info.unwrap();
1354        assert_eq!(retrieved_info.title, "Test Book");
1355        assert_eq!(retrieved_info.subtitle, "A Test");
1356        assert_eq!(retrieved_info.author, "John Doe, Jane Smith");
1357        assert_eq!(retrieved_info.year, "2024");
1358        assert_eq!(retrieved_info.language, "en");
1359        assert_eq!(retrieved_info.publisher, "Test Press");
1360        assert_eq!(retrieved_info.series, "Test Series");
1361        assert_eq!(retrieved_info.number, "1");
1362        assert_eq!(retrieved_info.file.path, PathBuf::from("/tmp/test.pdf"));
1363        assert_eq!(retrieved_info.file.kind, "pdf");
1364        assert_eq!(retrieved_info.file.size, 1024);
1365    }
1366
1367    #[test]
1368    fn test_insert_book_with_reading_state() {
1369        let (_db, libdb) = create_test_db();
1370        let fp = Fp::from_str("0000000000000002").unwrap();
1371
1372        let reader_info = ReaderInfo {
1373            current_page: 42,
1374            pages_count: 100,
1375            ..Default::default()
1376        };
1377        let info = Info {
1378            title: "Book with Reading State".to_string(),
1379            author: "Test Author".to_string(),
1380            file: FileInfo {
1381                path: PathBuf::from("/tmp/test2.pdf"),
1382                kind: "pdf".to_string(),
1383                size: 2048,
1384                ..Default::default()
1385            },
1386            reader_info: Some(reader_info.clone()),
1387            ..Default::default()
1388        };
1389
1390        let library_id = libdb
1391            .register_library("/tmp/test_library2", "Test Library 2")
1392            .expect("failed to register library");
1393        libdb
1394            .insert_book(library_id, fp, &info)
1395            .expect("failed to insert book");
1396
1397        let books = libdb
1398            .get_all_books(library_id)
1399            .expect("failed to get books");
1400        let retrieved = books
1401            .iter()
1402            .find(|(f, _)| *f == fp)
1403            .map(|(_, i)| i.clone())
1404            .unwrap();
1405        assert_eq!(retrieved.title, "Book with Reading State");
1406
1407        assert!(
1408            retrieved.reader_info.is_some(),
1409            "reading state should exist"
1410        );
1411        let retrieved_reader = retrieved.reader_info.unwrap();
1412        assert_eq!(retrieved_reader.current_page, 42);
1413        assert_eq!(retrieved_reader.pages_count, 100);
1414        assert!(!retrieved_reader.finished);
1415        let (_db, libdb) = create_test_db();
1416        let fp = Fp::from_str("0000000000000003").unwrap();
1417
1418        let info = Info {
1419            title: "Book to Delete".to_string(),
1420            author: "Delete Author".to_string(),
1421            file: FileInfo {
1422                path: PathBuf::from("/tmp/delete.pdf"),
1423                kind: "pdf".to_string(),
1424                size: 512,
1425                ..Default::default()
1426            },
1427            ..Default::default()
1428        };
1429
1430        let library_id = libdb
1431            .register_library("/tmp/test_library3", "Test Library 3")
1432            .expect("failed to register library");
1433        libdb
1434            .insert_book(library_id, fp, &info)
1435            .expect("failed to insert book");
1436
1437        let books = libdb
1438            .get_all_books(library_id)
1439            .expect("failed to get books");
1440        assert!(
1441            books.iter().any(|(f, _)| *f == fp),
1442            "book should exist before delete"
1443        );
1444
1445        libdb
1446            .delete_book(library_id, fp)
1447            .expect("failed to delete book");
1448
1449        let books = libdb
1450            .get_all_books(library_id)
1451            .expect("failed to get books");
1452        assert!(
1453            !books.iter().any(|(f, _)| *f == fp),
1454            "book should not exist after delete"
1455        );
1456    }
1457
1458    #[test]
1459    fn test_multiple_books() {
1460        let (_db, libdb) = create_test_db();
1461        let library_id = libdb
1462            .register_library("/tmp/test_library4", "Test Library 4")
1463            .expect("failed to register library");
1464
1465        for i in 1..=5 {
1466            let fp = Fp::from_str(&format!("{:016X}", i)).unwrap();
1467            let info = Info {
1468                title: format!("Book {}", i),
1469                author: format!("Author {}", i),
1470                file: FileInfo {
1471                    path: PathBuf::from(format!("/tmp/book{}.pdf", i)),
1472                    kind: "pdf".to_string(),
1473                    size: (i * 100) as u64,
1474                    ..Default::default()
1475                },
1476                ..Default::default()
1477            };
1478
1479            libdb
1480                .insert_book(library_id, fp, &info)
1481                .expect("failed to insert book");
1482        }
1483
1484        let books = libdb
1485            .get_all_books(library_id)
1486            .expect("failed to get books");
1487        for i in 1..=5 {
1488            let fp = Fp::from_str(&format!("{:016X}", i)).unwrap();
1489            let retrieved = books
1490                .iter()
1491                .find(|(f, _)| *f == fp)
1492                .map(|(_, i)| i.clone())
1493                .unwrap();
1494            assert_eq!(retrieved.title, format!("Book {}", i));
1495            assert_eq!(retrieved.author, format!("Author {}", i));
1496        }
1497    }
1498
1499    #[test]
1500    fn test_update_book() {
1501        let (_db, libdb) = create_test_db();
1502        let fp = Fp::from_str("0000000000000004").unwrap();
1503
1504        let mut info = Info {
1505            title: "Original Title".to_string(),
1506            author: "Original Author".to_string(),
1507            file: FileInfo {
1508                path: PathBuf::from("/tmp/update.pdf"),
1509                kind: "pdf".to_string(),
1510                size: 1024,
1511                ..Default::default()
1512            },
1513            ..Default::default()
1514        };
1515
1516        let library_id = libdb
1517            .register_library("/tmp/test_library5", "Test Library 5")
1518            .expect("failed to register library");
1519        libdb
1520            .insert_book(library_id, fp, &info)
1521            .expect("failed to insert book");
1522
1523        info.title = "Updated Title".to_string();
1524        info.author = "Updated Author".to_string();
1525        info.year = "2025".to_string();
1526
1527        libdb
1528            .update_book(library_id, fp, &info)
1529            .expect("failed to update book");
1530
1531        let books = libdb
1532            .get_all_books(library_id)
1533            .expect("failed to get books");
1534        let updated = books
1535            .iter()
1536            .find(|(f, _)| *f == fp)
1537            .map(|(_, i)| i.clone())
1538            .unwrap();
1539        assert_eq!(updated.title, "Updated Title");
1540        assert_eq!(updated.author, "Updated Author");
1541        assert_eq!(updated.year, "2025");
1542    }
1543
1544    #[test]
1545    fn test_get_all_books() {
1546        let (_db, libdb) = create_test_db();
1547        let library_id = libdb
1548            .register_library("/tmp/test_library6", "Test Library 6")
1549            .expect("failed to register library");
1550
1551        for i in 1..=3 {
1552            let fp = Fp::from_str(&format!("{:016X}", i)).unwrap();
1553            let info = Info {
1554                title: format!("Book {}", i),
1555                author: format!("Author {}", i),
1556                file: FileInfo {
1557                    path: PathBuf::from(format!("/tmp/book{}.pdf", i)),
1558                    kind: "pdf".to_string(),
1559                    size: (i * 100) as u64,
1560                    ..Default::default()
1561                },
1562                ..Default::default()
1563            };
1564
1565            libdb
1566                .insert_book(library_id, fp, &info)
1567                .expect("failed to insert book");
1568        }
1569
1570        let all_books = libdb
1571            .get_all_books(library_id)
1572            .expect("failed to get all books");
1573        assert_eq!(all_books.len(), 3);
1574
1575        let titles: Vec<String> = all_books
1576            .iter()
1577            .map(|(_, info)| info.title.clone())
1578            .collect();
1579        assert!(titles.contains(&"Book 1".to_string()));
1580        assert!(titles.contains(&"Book 2".to_string()));
1581        assert!(titles.contains(&"Book 3".to_string()));
1582    }
1583
1584    #[test]
1585    fn test_reading_state_crud() {
1586        let (_db, libdb) = create_test_db();
1587        let fp = Fp::from_str("0000000000000005").unwrap();
1588
1589        let info = Info {
1590            title: "Book with State".to_string(),
1591            author: "State Author".to_string(),
1592            file: FileInfo {
1593                path: PathBuf::from("/tmp/state.pdf"),
1594                kind: "pdf".to_string(),
1595                size: 1024,
1596                ..Default::default()
1597            },
1598            ..Default::default()
1599        };
1600
1601        let library_id = libdb
1602            .register_library("/tmp/test_library7", "Test Library 7")
1603            .expect("failed to register library");
1604        libdb
1605            .insert_book(library_id, fp, &info)
1606            .expect("failed to insert book");
1607
1608        let mut reader_info = ReaderInfo {
1609            current_page: 50,
1610            pages_count: 200,
1611            ..Default::default()
1612        };
1613
1614        libdb
1615            .save_reading_state(fp, &reader_info)
1616            .expect("failed to save reading state");
1617
1618        let books = libdb
1619            .get_all_books(library_id)
1620            .expect("failed to get books");
1621        let retrieved = books
1622            .iter()
1623            .find(|(f, _)| *f == fp)
1624            .map(|(_, i)| i.clone())
1625            .unwrap();
1626        let retrieved_reader = retrieved.reader_info.unwrap();
1627
1628        assert_eq!(retrieved_reader.current_page, 50);
1629        assert_eq!(retrieved_reader.pages_count, 200);
1630        assert!(!retrieved_reader.finished);
1631        reader_info.current_page = 100;
1632        reader_info.finished = true;
1633
1634        libdb
1635            .save_reading_state(fp, &reader_info)
1636            .expect("failed to update reading state");
1637
1638        let books = libdb
1639            .get_all_books(library_id)
1640            .expect("failed to get books");
1641        let updated = books
1642            .iter()
1643            .find(|(f, _)| *f == fp)
1644            .map(|(_, i)| i.clone())
1645            .unwrap();
1646        let updated_reader = updated.reader_info.unwrap();
1647
1648        assert_eq!(updated_reader.current_page, 100);
1649        assert!(updated_reader.finished);
1650    }
1651
1652    #[test]
1653    fn test_batch_insert_books() {
1654        let (_db, libdb) = create_test_db();
1655        let library_id = libdb
1656            .register_library("/tmp/test_library8", "Test Library 8")
1657            .expect("failed to register library");
1658
1659        let mut books = Vec::new();
1660        for i in 1..=5 {
1661            let fp = Fp::from_str(&format!("{:016X}", i + 100)).unwrap();
1662            let info = Info {
1663                title: format!("Batch Book {}", i),
1664                author: format!("Batch Author {}, Co-Author {}", i, i + 1),
1665                year: format!("{}", 2020 + i),
1666                file: FileInfo {
1667                    path: PathBuf::from(format!("/tmp/batch{}.pdf", i)),
1668                    kind: "pdf".to_string(),
1669                    size: (i * 100) as u64,
1670                    ..Default::default()
1671                },
1672                ..Default::default()
1673            };
1674            books.push((fp, info));
1675        }
1676
1677        let book_refs: Vec<(Fp, &Info)> = books.iter().map(|(fp, info)| (*fp, info)).collect();
1678
1679        libdb
1680            .batch_insert_books(library_id, &book_refs)
1681            .expect("failed to batch insert books");
1682
1683        let all_books = libdb
1684            .get_all_books(library_id)
1685            .expect("failed to get books");
1686        for (fp, info) in &books {
1687            let retrieved = all_books
1688                .iter()
1689                .find(|(f, _)| *f == *fp)
1690                .map(|(_, i)| i.clone())
1691                .expect("book should exist");
1692            assert_eq!(retrieved.title, info.title);
1693            assert_eq!(retrieved.author, info.author);
1694            assert_eq!(retrieved.year, info.year);
1695        }
1696
1697        let all_books = libdb
1698            .get_all_books(library_id)
1699            .expect("failed to get all books");
1700        assert_eq!(all_books.len(), 5);
1701    }
1702
1703    #[test]
1704    fn test_batch_update_books() {
1705        let (_db, libdb) = create_test_db();
1706        let library_id = libdb
1707            .register_library("/tmp/test_library9", "Test Library 9")
1708            .expect("failed to register library");
1709
1710        let mut books = Vec::new();
1711        for i in 1..=3 {
1712            let fp = Fp::from_str(&format!("{:016X}", i + 200)).unwrap();
1713            let mut info = Info {
1714                title: format!("Original Book {}", i),
1715                author: format!("Original Author {}", i),
1716                file: FileInfo {
1717                    path: PathBuf::from(format!("/tmp/update{}.pdf", i)),
1718                    kind: "pdf".to_string(),
1719                    size: (i * 100) as u64,
1720                    ..Default::default()
1721                },
1722                ..Default::default()
1723            };
1724            libdb
1725                .insert_book(library_id, fp, &info)
1726                .expect("failed to insert book");
1727
1728            info.title = format!("Updated Book {}", i);
1729            info.author = format!("Updated Author {}", i);
1730            info.year = format!("{}", 2024 + i);
1731            books.push((fp, info));
1732        }
1733
1734        let book_refs: Vec<(Fp, &Info)> = books.iter().map(|(fp, info)| (*fp, info)).collect();
1735
1736        libdb
1737            .batch_update_books(library_id, &book_refs)
1738            .expect("failed to batch update books");
1739
1740        let all_books = libdb
1741            .get_all_books(library_id)
1742            .expect("failed to get books");
1743        for (fp, info) in &books {
1744            let retrieved = all_books
1745                .iter()
1746                .find(|(f, _)| *f == *fp)
1747                .map(|(_, i)| i.clone())
1748                .expect("book should exist");
1749            assert_eq!(retrieved.title, info.title);
1750            assert_eq!(retrieved.author, info.author);
1751            assert_eq!(retrieved.year, info.year);
1752        }
1753    }
1754
1755    #[test]
1756    fn test_batch_delete_books() {
1757        let (_db, libdb) = create_test_db();
1758        let library_id = libdb
1759            .register_library("/tmp/test_library10", "Test Library 10")
1760            .expect("failed to register library");
1761
1762        let mut fps = Vec::new();
1763        for i in 1..=4 {
1764            let fp = Fp::from_str(&format!("{:016X}", i + 300)).unwrap();
1765            let info = Info {
1766                title: format!("Delete Book {}", i),
1767                author: format!("Delete Author {}", i),
1768                file: FileInfo {
1769                    path: PathBuf::from(format!("/tmp/delete{}.pdf", i)),
1770                    kind: "pdf".to_string(),
1771                    size: (i * 100) as u64,
1772                    ..Default::default()
1773                },
1774                ..Default::default()
1775            };
1776            libdb
1777                .insert_book(library_id, fp, &info)
1778                .expect("failed to insert book");
1779            fps.push(fp);
1780        }
1781
1782        let all_before = libdb
1783            .get_all_books(library_id)
1784            .expect("failed to get all books");
1785        assert_eq!(all_before.len(), 4);
1786
1787        libdb
1788            .batch_delete_books(library_id, &fps)
1789            .expect("failed to batch delete books");
1790
1791        let all_after = libdb
1792            .get_all_books(library_id)
1793            .expect("failed to get all books");
1794        assert_eq!(all_after.len(), 0);
1795    }
1796
1797    #[test]
1798    fn test_batch_operations_with_empty_input() {
1799        let (_db, libdb) = create_test_db();
1800        let library_id = libdb
1801            .register_library("/tmp/test_library11", "Test Library 11")
1802            .expect("failed to register library");
1803
1804        let empty_books: Vec<(Fp, &Info)> = Vec::new();
1805        let empty_fps: Vec<Fp> = Vec::new();
1806
1807        libdb
1808            .batch_insert_books(library_id, &empty_books)
1809            .expect("empty batch insert should succeed");
1810        libdb
1811            .batch_update_books(library_id, &empty_books)
1812            .expect("empty batch update should succeed");
1813        libdb
1814            .batch_delete_books(library_id, &empty_fps)
1815            .expect("empty batch delete should succeed");
1816    }
1817
1818    #[test]
1819    fn test_categories_round_trip() {
1820        let (_db, libdb) = create_test_db();
1821        let fp = Fp::from_str("0000000000000099").unwrap();
1822
1823        let info = Info {
1824            title: "Categorized Book".to_string(),
1825            author: "Cat Author".to_string(),
1826            file: FileInfo {
1827                path: PathBuf::from("/tmp/cat.pdf"),
1828                kind: "pdf".to_string(),
1829                size: 512,
1830                ..Default::default()
1831            },
1832            categories: ["Fiction", "Science", "History"]
1833                .iter()
1834                .map(|s| s.to_string())
1835                .collect(),
1836            ..Default::default()
1837        };
1838
1839        let library_id = libdb
1840            .register_library("/tmp/test_library_cat", "Cat Library")
1841            .expect("failed to register library");
1842        libdb
1843            .insert_book(library_id, fp, &info)
1844            .expect("failed to insert book");
1845
1846        let books = libdb
1847            .get_all_books(library_id)
1848            .expect("failed to get books");
1849        let retrieved = books
1850            .iter()
1851            .find(|(f, _)| *f == fp)
1852            .map(|(_, i)| i.clone())
1853            .expect("book should exist");
1854
1855        assert_eq!(retrieved.categories, info.categories);
1856    }
1857
1858    #[test]
1859    fn test_categories_updated_on_update_book() {
1860        let (_db, libdb) = create_test_db();
1861        let fp = Fp::from_str("000000000000009A").unwrap();
1862
1863        let mut info = Info {
1864            title: "Updateable Book".to_string(),
1865            author: "Update Author".to_string(),
1866            file: FileInfo {
1867                path: PathBuf::from("/tmp/upd_cat.pdf"),
1868                kind: "pdf".to_string(),
1869                size: 512,
1870                ..Default::default()
1871            },
1872            categories: ["OldCat"].iter().map(|s| s.to_string()).collect(),
1873            ..Default::default()
1874        };
1875
1876        let library_id = libdb
1877            .register_library("/tmp/test_library_upd_cat", "Upd Cat Library")
1878            .expect("failed to register library");
1879        libdb
1880            .insert_book(library_id, fp, &info)
1881            .expect("failed to insert book");
1882
1883        info.categories = ["NewCat1", "NewCat2"]
1884            .iter()
1885            .map(|s| s.to_string())
1886            .collect();
1887        libdb
1888            .update_book(library_id, fp, &info)
1889            .expect("failed to update book");
1890
1891        let books = libdb
1892            .get_all_books(library_id)
1893            .expect("failed to get books");
1894        let retrieved = books
1895            .iter()
1896            .find(|(f, _)| *f == fp)
1897            .map(|(_, i)| i.clone())
1898            .expect("book should exist");
1899
1900        assert_eq!(retrieved.categories, info.categories);
1901    }
1902
1903    #[test]
1904    fn test_batch_insert_with_reading_state() {
1905        let (_db, libdb) = create_test_db();
1906        let library_id = libdb
1907            .register_library("/tmp/test_library12", "Test Library 12")
1908            .expect("failed to register library");
1909
1910        let mut books = Vec::new();
1911        for i in 1..=3 {
1912            let fp = Fp::from_str(&format!("{:016X}", i + 400)).unwrap();
1913            let reader_info = ReaderInfo {
1914                current_page: i * 10,
1915                pages_count: i * 100,
1916                finished: i % 2 == 0,
1917                ..Default::default()
1918            };
1919            let info = Info {
1920                title: format!("Book with State {}", i),
1921                author: format!("State Author {}", i),
1922                file: FileInfo {
1923                    path: PathBuf::from(format!("/tmp/state{}.pdf", i)),
1924                    kind: "pdf".to_string(),
1925                    size: (i * 100) as u64,
1926                    ..Default::default()
1927                },
1928                reader_info: Some(reader_info),
1929                ..Default::default()
1930            };
1931
1932            books.push((fp, info));
1933        }
1934
1935        let book_refs: Vec<(Fp, &Info)> = books.iter().map(|(fp, info)| (*fp, info)).collect();
1936
1937        libdb
1938            .batch_insert_books(library_id, &book_refs)
1939            .expect("failed to batch insert books with reading state");
1940
1941        let all_books = libdb
1942            .get_all_books(library_id)
1943            .expect("failed to get books");
1944        for (fp, info) in &books {
1945            let retrieved = all_books
1946                .iter()
1947                .find(|(f, _)| *f == *fp)
1948                .map(|(_, i)| i.clone())
1949                .expect("book should exist");
1950            assert_eq!(retrieved.title, info.title);
1951
1952            assert!(
1953                retrieved.reader_info.is_some(),
1954                "reading state should exist"
1955            );
1956            let retrieved_state = retrieved.reader_info.unwrap();
1957            let original_state = info.reader_info.as_ref().unwrap();
1958            assert_eq!(retrieved_state.current_page, original_state.current_page);
1959            assert_eq!(retrieved_state.pages_count, original_state.pages_count);
1960            assert_eq!(retrieved_state.finished, original_state.finished);
1961        }
1962    }
1963}