Skip to main content

cadmus_core/library/
migrations.rs

1//! Runtime migrations for the library subsystem.
2//!
3//! Each migration is registered automatically at startup via the [`crate::migration!`]
4//! macro and tracked in the `_cadmus_migrations` table.
5//!
6//! # Registered migrations
7//!
8//! | Module | Migration ID |
9//! |---|---|
10//! | [`import_legacy_filesystem_data::MIGRATION_ID`] | `v1_import_legacy_filesystem_data` |
11//! | [`rehash_fingerprints::MIGRATION_ID`] | `v2_rehash_fingerprints` |
12
13use crate::db::types::OptionalUuid7;
14use crate::db::types::UnixTimestamp;
15use crate::db::types::Uuid7;
16use crate::device::CURRENT_DEVICE;
17use crate::document::SimpleTocEntry;
18use crate::helpers::{Fingerprint, Fp};
19#[cfg(not(feature = "test"))]
20use crate::library::THUMBNAIL_PREVIEWS_DIRNAME;
21use crate::library::db::conversion::{
22    encode_location, extract_authors, info_to_book_row, reader_info_to_reading_state_row,
23    rows_to_toc_entries,
24};
25use crate::library::db::models::TocEntryRow;
26use crate::library::{METADATA_FILENAME, READING_STATES_DIRNAME};
27use crate::metadata::Info;
28use crate::settings::versioned::SettingsManager;
29use crate::version::get_current_version;
30use fxhash::FxBuildHasher;
31use indexmap::IndexMap;
32use sqlx::{Row, Sqlite, Transaction};
33use std::collections::HashSet;
34use std::path::{Path, PathBuf};
35use std::str::FromStr;
36use tokio::fs;
37use tracing::{error, info, warn};
38
39crate::migration!(
40    /// Imports book metadata from `.metadata.json` and reading progress from
41    /// `.reading-states/<fingerprint>.json` into SQLite for every library path
42    /// listed in `Settings.toml`. Covers both legacy library modes:
43    ///
44    /// - Database mode: had `.metadata.json` keyed by fingerprint.
45    /// - Filesystem mode: no `.metadata.json`; only `.reading-states/` files.
46    ///   Stub book rows are inserted to satisfy the foreign key before reading
47    ///   states are written. A follow-up migration prunes stubs for missing files.
48    ///
49    /// The migration is idempotent (all inserts use `ON CONFLICT … DO NOTHING`).
50    "v1_import_legacy_filesystem_data",
51    async fn import_legacy_filesystem_data(pool: &SqlitePool) {
52        let settings = SettingsManager::new(CURRENT_DEVICE.data_dir(), get_current_version()).load();
53
54        if settings.libraries.is_empty() {
55            info!("no libraries in settings, skipping legacy data import");
56            return Ok(());
57        }
58
59        for lib in &settings.libraries {
60            let library_path = &lib.path;
61            let library_name = &lib.name;
62            let path_str = library_path.to_string_lossy();
63
64            info!(path = %path_str, name = %library_name, "importing legacy data for library");
65
66            let library_id = ensure_library(pool, &path_str, library_name).await;
67
68            let library_id = match library_id {
69                Ok(id) => id,
70                Err(e) => {
71                    error!(path = %path_str, error = %e, "failed to register library, skipping");
72                    continue;
73                }
74            };
75
76            let (book_count, state_count) = import_library(pool, library_id, library_path).await;
77
78            info!(
79                path = %path_str,
80                books_imported = book_count,
81                reading_states_imported = state_count,
82                "library import complete"
83            );
84        }
85
86        Ok(())
87    }
88);
89
90crate::migration!(
91    /// Re-fingerprints every book in all libraries using BLAKE3 content hashing.
92    ///
93    /// The old fingerprint was derived from file metadata (mtime + size relative to
94    /// the FAT32 epoch), which was unstable across timestamp changes. This migration
95    /// computes a new content-based fingerprint for each file that is still present
96    /// on disk and re-keys all associated database rows (reading states, thumbnails,
97    /// TOC entries, authors, categories) to the new fingerprint, preserving user
98    /// progress data.
99    ///
100    /// Files that are no longer present on disk keep a canonicalized legacy
101    /// fingerprint in the database so their data remains readable until the next
102    /// `import()` scan removes them as orphans.
103    "v2_rehash_fingerprints",
104    async fn rehash_fingerprints(pool: &SqlitePool) {
105        let books: Vec<(String, Option<String>)> = sqlx::query(
106                r#"
107                SELECT
108                    b.fingerprint,
109                    (
110                        SELECT lb.absolute_path
111                        FROM library_books lb
112                        WHERE lb.book_fingerprint = b.fingerprint
113                          AND lb.absolute_path != ''
114                        ORDER BY lb.absolute_path ASC, lb.library_id ASC
115                        LIMIT 1
116                    ) AS "absolute_path?: String"
117                FROM books b
118                "#
119            )
120            .fetch_all(pool)
121            .await?
122            .into_iter()
123            .map(|row| {
124                (
125                    row.get::<String, _>("fingerprint"),
126                    row.get::<Option<String>, _>("absolute_path?: String"),
127                )
128            })
129            .collect();
130
131        for (old_fp_str, absolute_path) in &books {
132            let Some(absolute_path) = absolute_path.as_ref() else {
133                continue;
134            };
135
136            let abs_path = PathBuf::from(absolute_path);
137
138            if !abs_path.exists() {
139                continue;
140            }
141
142            let new_fp = match abs_path.fingerprint() {
143                Ok(fp) => fp,
144                Err(e) => {
145                    error!(path = ?abs_path, error = %e, "failed to compute BLAKE3 fingerprint, skipping");
146                    continue;
147                }
148            };
149
150            let new_fp_str = new_fp.to_string();
151
152            if new_fp_str == *old_fp_str {
153                continue;
154            }
155
156            if let Err(e) = rekey_book(pool, old_fp_str, &new_fp_str).await {
157                error!(
158                    old_fp = %old_fp_str,
159                    new_fp = %new_fp_str,
160                    error = %e,
161                    "failed to re-key book, skipping"
162                );
163            }
164        }
165
166        canonicalize_legacy_fingerprints(pool, &books).await?;
167
168        Ok(())
169    }
170);
171
172#[cfg_attr(feature = "tracing", tracing::instrument(skip(pool, books)))]
173async fn canonicalize_legacy_fingerprints(
174    pool: &sqlx::SqlitePool,
175    books: &[(String, Option<String>)],
176) -> Result<(), anyhow::Error> {
177    for (old_fp_str, _) in books {
178        if old_fp_str.len() == 64 {
179            continue;
180        }
181
182        let canonical_fp = match Fp::from_legacy_str(old_fp_str) {
183            Ok(fp) => fp.to_string(),
184            Err(_) => {
185                warn!(
186                    fingerprint = %old_fp_str,
187                    "deleting malformed legacy fingerprint that cannot be canonicalized"
188                );
189
190                sqlx::query!("DELETE FROM books WHERE fingerprint = ?", old_fp_str)
191                    .execute(pool)
192                    .await?;
193
194                continue;
195            }
196        };
197
198        if let Err(e) = rekey_book(pool, old_fp_str, &canonical_fp).await {
199            error!(
200                old_fp = %old_fp_str,
201                new_fp = %canonical_fp,
202                error = %e,
203                "failed to canonicalize legacy fingerprint"
204            );
205        }
206    }
207
208    Ok(())
209}
210
211/// Re-keys a single book row from `old_fp` to `new_fp`, preserving all
212/// associated data (reading state, thumbnails, TOC, authors, categories).
213///
214/// All child tables use `ON DELETE CASCADE`, so deleting the old `books` row
215/// at the end cascades cleanly. We update each child table explicitly first
216/// to transfer data to the new fingerprint before the cascade fires.
217///
218/// The `library_books` UPDATE is intentionally global (not scoped to a single
219/// library) because the subsequent DELETE cascades globally — scoping the
220/// UPDATE would silently drop other libraries' associations.
221#[cfg_attr(feature = "tracing", tracing::instrument(skip(pool), fields(old_fp = %old_fp, new_fp = %new_fp)))]
222async fn rekey_book(
223    pool: &sqlx::SqlitePool,
224    old_fp: &str,
225    new_fp: &str,
226) -> Result<(), anyhow::Error> {
227    let mut tx = pool.begin().await?;
228
229    let already_exists: Option<String> = sqlx::query_scalar!(
230        "SELECT fingerprint FROM books WHERE fingerprint = ?",
231        new_fp
232    )
233    .fetch_optional(&mut *tx)
234    .await?;
235
236    if already_exists.is_some() {
237        merge_duplicate_book_data(&mut tx, old_fp, new_fp).await?;
238
239        sqlx::query!("DELETE FROM books WHERE fingerprint = ?", old_fp)
240            .execute(&mut *tx)
241            .await?;
242
243        tx.commit().await?;
244        return Ok(());
245    }
246
247    insert_rekeyed_book(&mut tx, old_fp, new_fp).await?;
248    move_rekeyed_book_data(&mut tx, old_fp, new_fp).await?;
249
250    sqlx::query!("DELETE FROM books WHERE fingerprint = ?", old_fp)
251        .execute(&mut *tx)
252        .await?;
253
254    tx.commit().await?;
255
256    Ok(())
257}
258
259#[cfg_attr(feature = "tracing", tracing::instrument(skip(tx), fields(old_fp = %old_fp, new_fp = %new_fp)))]
260async fn merge_duplicate_book_data(
261    tx: &mut Transaction<'_, Sqlite>,
262    old_fp: &str,
263    new_fp: &str,
264) -> Result<(), anyhow::Error> {
265    merge_library_books(tx, old_fp, new_fp).await?;
266    merge_reading_states(tx, old_fp, new_fp).await?;
267    merge_thumbnails(tx, old_fp, new_fp).await?;
268    merge_toc_entries(tx, old_fp, new_fp).await?;
269    merge_book_authors(tx, old_fp, new_fp).await?;
270    merge_book_categories(tx, old_fp, new_fp).await?;
271
272    Ok(())
273}
274
275#[cfg_attr(feature = "tracing", tracing::instrument(skip(tx), fields(old_fp = %old_fp, new_fp = %new_fp)))]
276async fn merge_library_books(
277    tx: &mut Transaction<'_, Sqlite>,
278    old_fp: &str,
279    new_fp: &str,
280) -> Result<(), anyhow::Error> {
281    sqlx::query(
282        "INSERT OR IGNORE INTO library_books (library_id, book_fingerprint, added_to_library_at, file_path, absolute_path)
283         SELECT library_id, ?, added_to_library_at, file_path, absolute_path
284         FROM library_books
285         WHERE book_fingerprint = ?",
286    )
287    .bind(new_fp)
288    .bind(old_fp)
289    .execute(&mut **tx)
290    .await?;
291
292    Ok(())
293}
294
295#[cfg_attr(feature = "tracing", tracing::instrument(skip(tx), fields(old_fp = %old_fp, new_fp = %new_fp)))]
296async fn merge_reading_states(
297    tx: &mut Transaction<'_, Sqlite>,
298    old_fp: &str,
299    new_fp: &str,
300) -> Result<(), anyhow::Error> {
301    sqlx::query!(
302        r#"
303        INSERT OR IGNORE INTO reading_states (
304            fingerprint, opened, current_page, pages_count, finished, dithered,
305            zoom_mode, scroll_mode, page_offset_x, page_offset_y, rotation,
306            cropping_margins_json, margin_width, screen_margin_width,
307            font_family, font_size, text_align, line_height,
308            contrast_exponent, contrast_gray,
309            page_names_json, bookmarks_json, annotations_json
310        )
311        SELECT
312            ?, opened, current_page, pages_count, finished, dithered,
313            zoom_mode, scroll_mode, page_offset_x, page_offset_y, rotation,
314            cropping_margins_json, margin_width, screen_margin_width,
315            font_family, font_size, text_align, line_height,
316            contrast_exponent, contrast_gray,
317            page_names_json, bookmarks_json, annotations_json
318        FROM reading_states
319        WHERE fingerprint = ?
320        "#,
321        new_fp,
322        old_fp,
323    )
324    .execute(&mut **tx)
325    .await?;
326
327    Ok(())
328}
329
330#[cfg_attr(feature = "tracing", tracing::instrument(skip(tx), fields(old_fp = %old_fp, new_fp = %new_fp)))]
331async fn merge_thumbnails(
332    tx: &mut Transaction<'_, Sqlite>,
333    old_fp: &str,
334    new_fp: &str,
335) -> Result<(), anyhow::Error> {
336    sqlx::query!(
337        "INSERT OR IGNORE INTO thumbnails (fingerprint, thumbnail_data)
338         SELECT ?, thumbnail_data
339         FROM thumbnails
340         WHERE fingerprint = ?",
341        new_fp,
342        old_fp,
343    )
344    .execute(&mut **tx)
345    .await?;
346
347    Ok(())
348}
349
350#[cfg_attr(feature = "tracing", tracing::instrument(skip(tx), fields(old_fp = %old_fp, new_fp = %new_fp)))]
351async fn merge_toc_entries(
352    tx: &mut Transaction<'_, Sqlite>,
353    old_fp: &str,
354    new_fp: &str,
355) -> Result<(), anyhow::Error> {
356    let existing_count: i64 = sqlx::query_scalar!(
357        "SELECT COUNT(*) FROM toc_entries WHERE book_fingerprint = ?",
358        new_fp,
359    )
360    .fetch_one(&mut **tx)
361    .await?;
362
363    if existing_count > 0 {
364        return Ok(());
365    }
366
367    let old_rows = sqlx::query_as!(
368        TocEntryRow,
369        r#"
370        SELECT
371            book_fingerprint,
372            id                as "id: Uuid7",
373            parent_id         as "parent_id!: OptionalUuid7",
374            position,
375            title,
376            location_kind,
377            location_exact,
378            location_uri
379        FROM toc_entries
380        WHERE book_fingerprint = ?
381        ORDER BY id ASC
382        "#,
383        old_fp,
384    )
385    .fetch_all(&mut **tx)
386    .await?;
387
388    if old_rows.is_empty() {
389        return Ok(());
390    }
391
392    let toc_entries = rows_to_toc_entries(&old_rows)?;
393    insert_toc_entries(tx, new_fp, &toc_entries, None).await?;
394
395    Ok(())
396}
397
398#[cfg_attr(feature = "tracing", tracing::instrument(skip(tx, entries), fields(book_fingerprint = %book_fingerprint)))]
399async fn insert_toc_entries(
400    tx: &mut Transaction<'_, Sqlite>,
401    book_fingerprint: &str,
402    entries: &[SimpleTocEntry],
403    parent_id: Option<Uuid7>,
404) -> Result<(), anyhow::Error> {
405    for (position, entry) in entries.iter().enumerate() {
406        let (title, location, children) = match entry {
407            SimpleTocEntry::Leaf(title, location) => (title.as_str(), location, [].as_slice()),
408            SimpleTocEntry::Container(title, location, children) => {
409                (title.as_str(), location, children.as_slice())
410            }
411        };
412
413        let (location_kind, location_exact, location_uri) = encode_location(location);
414        let id = Uuid7::now();
415        let position = position as i64;
416        let parent_id_str = parent_id.as_ref().map(ToString::to_string);
417
418        sqlx::query!(
419            r#"
420            INSERT INTO toc_entries (
421                id, book_fingerprint, parent_id, position, title, location_kind, location_exact, location_uri
422            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
423            "#,
424            id,
425            book_fingerprint,
426            parent_id_str,
427            position,
428            title,
429            location_kind,
430            location_exact,
431            location_uri,
432        )
433        .execute(&mut **tx)
434        .await?;
435
436        if !children.is_empty() {
437            Box::pin(insert_toc_entries(tx, book_fingerprint, children, Some(id))).await?;
438        }
439    }
440
441    Ok(())
442}
443
444#[cfg_attr(feature = "tracing", tracing::instrument(skip(tx), fields(old_fp = %old_fp, new_fp = %new_fp)))]
445async fn merge_book_authors(
446    tx: &mut Transaction<'_, Sqlite>,
447    old_fp: &str,
448    new_fp: &str,
449) -> Result<(), anyhow::Error> {
450    sqlx::query!(
451        "INSERT OR IGNORE INTO book_authors (book_fingerprint, author_id, position)
452         SELECT ?, author_id, position
453         FROM book_authors
454         WHERE book_fingerprint = ?",
455        new_fp,
456        old_fp,
457    )
458    .execute(&mut **tx)
459    .await?;
460
461    Ok(())
462}
463
464#[cfg_attr(feature = "tracing", tracing::instrument(skip(tx), fields(old_fp = %old_fp, new_fp = %new_fp)))]
465async fn merge_book_categories(
466    tx: &mut Transaction<'_, Sqlite>,
467    old_fp: &str,
468    new_fp: &str,
469) -> Result<(), anyhow::Error> {
470    sqlx::query!(
471        "INSERT OR IGNORE INTO book_categories (book_fingerprint, category_id)
472         SELECT ?, category_id
473         FROM book_categories
474         WHERE book_fingerprint = ?",
475        new_fp,
476        old_fp,
477    )
478    .execute(&mut **tx)
479    .await?;
480
481    Ok(())
482}
483
484#[cfg_attr(feature = "tracing", tracing::instrument(skip(tx), fields(old_fp = %old_fp, new_fp = %new_fp)))]
485async fn insert_rekeyed_book(
486    tx: &mut Transaction<'_, Sqlite>,
487    old_fp: &str,
488    new_fp: &str,
489) -> Result<(), anyhow::Error> {
490    sqlx::query(
491        r#"
492        INSERT INTO books (
493            fingerprint, title, subtitle, year, language, publisher,
494            series, edition, volume, number, identifier,
495            file_kind, file_size, added_at
496        )
497        SELECT
498            ?, title, subtitle, year, language, publisher,
499            series, edition, volume, number, identifier,
500            file_kind, file_size, added_at
501        FROM books WHERE fingerprint = ?
502        "#,
503    )
504    .bind(new_fp)
505    .bind(old_fp)
506    .execute(&mut **tx)
507    .await?;
508
509    Ok(())
510}
511
512#[cfg_attr(feature = "tracing", tracing::instrument(skip(tx), fields(old_fp = %old_fp, new_fp = %new_fp)))]
513async fn move_rekeyed_book_data(
514    tx: &mut Transaction<'_, Sqlite>,
515    old_fp: &str,
516    new_fp: &str,
517) -> Result<(), anyhow::Error> {
518    sqlx::query!(
519        "UPDATE reading_states SET fingerprint = ? WHERE fingerprint = ?",
520        new_fp,
521        old_fp,
522    )
523    .execute(&mut **tx)
524    .await?;
525
526    sqlx::query!(
527        "UPDATE thumbnails SET fingerprint = ? WHERE fingerprint = ?",
528        new_fp,
529        old_fp,
530    )
531    .execute(&mut **tx)
532    .await?;
533
534    sqlx::query!(
535        "UPDATE toc_entries SET book_fingerprint = ? WHERE book_fingerprint = ?",
536        new_fp,
537        old_fp,
538    )
539    .execute(&mut **tx)
540    .await?;
541
542    sqlx::query!(
543        "UPDATE book_authors SET book_fingerprint = ? WHERE book_fingerprint = ?",
544        new_fp,
545        old_fp,
546    )
547    .execute(&mut **tx)
548    .await?;
549
550    sqlx::query!(
551        "UPDATE book_categories SET book_fingerprint = ? WHERE book_fingerprint = ?",
552        new_fp,
553        old_fp,
554    )
555    .execute(&mut **tx)
556    .await?;
557
558    sqlx::query!(
559        "UPDATE library_books SET book_fingerprint = ? WHERE book_fingerprint = ?",
560        new_fp,
561        old_fp,
562    )
563    .execute(&mut **tx)
564    .await?;
565
566    Ok(())
567}
568
569/// Ensures the library row exists and returns its id.
570#[cfg_attr(feature = "tracing", tracing::instrument(skip(pool), fields(path = %path, name = %name), ret(level = tracing::Level::TRACE)))]
571async fn ensure_library(
572    pool: &sqlx::SqlitePool,
573    path: &str,
574    name: &str,
575) -> Result<i64, anyhow::Error> {
576    let existing: Option<i64> =
577        sqlx::query_scalar!("SELECT id FROM libraries WHERE path = ?", path)
578            .fetch_optional(pool)
579            .await?
580            .flatten();
581
582    if let Some(id) = existing {
583        return Ok(id);
584    }
585
586    let now = UnixTimestamp::now();
587    let result = sqlx::query!(
588        "INSERT INTO libraries (path, name, created_at) VALUES (?, ?, ?)",
589        path,
590        name,
591        now
592    )
593    .execute(pool)
594    .await?;
595
596    Ok(result.last_insert_rowid())
597}
598
599/// Imports all books and reading states from a single library directory.
600///
601/// Loads `.metadata.json` and the `.reading-states/` directory, inserts all
602/// entries into the database within a single transaction, then renames the
603/// legacy files and removes `.thumbnail-previews/`.
604///
605/// Returns `(books_imported, reading_states_imported)`.
606#[cfg_attr(feature = "tracing", tracing::instrument(skip(pool), fields(library_id = library_id, path = ?library_path)))]
607async fn import_library(
608    pool: &sqlx::SqlitePool,
609    library_id: i64,
610    library_path: &Path,
611) -> (usize, usize) {
612    let mut tx = match pool.begin().await {
613        Ok(tx) => tx,
614        Err(e) => {
615            error!(path = ?library_path, error = %e, "failed to begin transaction for library import");
616            return (0, 0);
617        }
618    };
619
620    let metadata_path = library_path.join(METADATA_FILENAME);
621    let metadata = load_metadata(&metadata_path).await;
622
623    let (books_imported, states_from_metadata, metadata_fps) =
624        import_metadata_entries(&mut tx, library_id, metadata).await;
625
626    let reading_states_dir = library_path.join(READING_STATES_DIRNAME);
627    let states_from_dir =
628        import_orphan_reading_states(&mut tx, library_id, &reading_states_dir, &metadata_fps).await;
629
630    if let Err(e) = tx.commit().await {
631        error!(path = ?library_path, error = %e, "failed to commit library import transaction");
632        return (0, 0);
633    }
634
635    #[cfg(not(feature = "test"))]
636    {
637        delete_thumbnail_previews(library_path).await;
638    }
639
640    (books_imported, states_from_metadata + states_from_dir)
641}
642
643/// Imports all entries from a `.metadata.json` file into the database.
644///
645/// Returns `(books_imported, reading_states_imported, fingerprints_seen)`.
646/// The fingerprint set is passed to [`import_orphan_reading_states`] to skip
647/// books whose reading state was already written from this file.
648#[cfg_attr(feature = "tracing", tracing::instrument(skip(tx, metadata), fields(library_id = library_id)))]
649async fn import_metadata_entries(
650    tx: &mut Transaction<'_, Sqlite>,
651    library_id: i64,
652    metadata: Option<IndexMap<Fp, Info, FxBuildHasher>>,
653) -> (usize, usize, HashSet<Fp>) {
654    let mut books_imported: usize = 0;
655    let mut states_imported: usize = 0;
656    let mut seen_fps: HashSet<Fp> = HashSet::new();
657
658    let entries = match metadata {
659        Some(e) => e,
660        None => return (0, 0, seen_fps),
661    };
662
663    for (fp, info) in &entries {
664        if let Err(e) = insert_book(tx, library_id, *fp, info).await {
665            error!(fp = %fp, error = %e, "failed to insert book from metadata");
666            continue;
667        }
668        books_imported += 1;
669
670        if let Some(reader_info) = info.reader_info.as_ref().or(info.reader.as_ref()) {
671            seen_fps.insert(*fp);
672            if let Err(e) = insert_reading_state(tx, *fp, reader_info).await {
673                error!(fp = %fp, error = %e, "failed to insert reading state from metadata");
674            } else {
675                states_imported += 1;
676            }
677        }
678    }
679
680    (books_imported, states_imported, seen_fps)
681}
682
683/// Imports reading states from `.reading-states/` that are not in `already_imported`.
684///
685/// The `already_imported` set contains fingerprints whose reading state was
686/// already written from `.metadata.json`. Skipping those keeps the migration
687/// idempotent and ensures the metadata file's version takes precedence.
688///
689/// For each fingerprint not yet imported, a stub `books` row is inserted first
690/// to satisfy the foreign key constraint. A follow-up migration is responsible
691/// for cleaning up any stub rows whose files are no longer on disk.
692///
693/// Returns the number of reading states imported.
694#[cfg_attr(feature = "tracing", tracing::instrument(skip(tx, already_imported), fields(library_id = library_id, path = ?reading_states_dir)))]
695async fn import_orphan_reading_states(
696    tx: &mut Transaction<'_, Sqlite>,
697    library_id: i64,
698    reading_states_dir: &Path,
699    already_imported: &HashSet<Fp>,
700) -> usize {
701    if !reading_states_dir.exists() {
702        return 0;
703    }
704
705    let mut dir_entries = match fs::read_dir(reading_states_dir).await {
706        Ok(d) => d,
707        Err(e) => {
708            error!(path = ?reading_states_dir, error = %e, "failed to read .reading-states dir");
709            return 0;
710        }
711    };
712
713    let mut states_imported: usize = 0;
714
715    loop {
716        let entry = match dir_entries.next_entry().await {
717            Ok(Some(e)) => e,
718            Ok(None) => break,
719            Err(e) => {
720                error!(path = ?reading_states_dir, error = %e, "failed to read directory entry");
721                break;
722            }
723        };
724
725        let path = entry.path();
726
727        let fp = match path
728            .file_stem()
729            .and_then(|s| s.to_str())
730            .and_then(|s| Fp::from_str(s).ok().or_else(|| Fp::from_legacy_str(s).ok()))
731        {
732            Some(fp) => fp,
733            None => {
734                warn!(path = ?path, "skipping unrecognised reading-state filename");
735                continue;
736            }
737        };
738
739        if already_imported.contains(&fp) {
740            continue;
741        }
742
743        let content = match fs::read_to_string(&path).await {
744            Ok(c) => c,
745            Err(e) => {
746                error!(fp = %fp, path = ?path, error = %e, "failed to read reading-state file");
747                continue;
748            }
749        };
750
751        let reader_info: crate::metadata::ReaderInfo = match serde_json::from_str(&content) {
752            Ok(r) => r,
753            Err(e) => {
754                error!(fp = %fp, error = %e, "failed to parse reading-state JSON");
755                continue;
756            }
757        };
758
759        if let Err(e) = ensure_stub_book(tx, library_id, fp).await {
760            error!(fp = %fp, error = %e, "failed to insert stub book for orphan reading state, skipping");
761            continue;
762        }
763
764        if let Err(e) = insert_reading_state(tx, fp, &reader_info).await {
765            error!(fp = %fp, error = %e, "failed to insert orphan reading state");
766        } else {
767            states_imported += 1;
768        }
769    }
770
771    states_imported
772}
773
774/// Inserts a stub `books` row and a `library_books` association for `fp` if
775/// they do not already exist.
776///
777/// The stub has empty strings and zero for the file fields. A follow-up
778/// migration is responsible for pruning stub rows whose files are no longer
779/// present on disk. `Library::import()` will fill in the real values for files
780/// that are still present.
781#[cfg_attr(feature = "tracing", tracing::instrument(skip(tx), fields(library_id = library_id, fp = %fp)))]
782async fn ensure_stub_book(
783    tx: &mut Transaction<'_, Sqlite>,
784    library_id: i64,
785    fp: Fp,
786) -> Result<(), anyhow::Error> {
787    let fp_str = fp.to_string();
788    let now = UnixTimestamp::now();
789
790    sqlx::query!(
791        r#"
792        INSERT OR IGNORE INTO books (fingerprint, file_kind, file_size, added_at)
793        VALUES (?, '', 0, ?)
794        "#,
795        fp_str,
796        now,
797    )
798    .execute(&mut **tx)
799    .await?;
800
801    sqlx::query!(
802        r#"
803        INSERT OR IGNORE INTO library_books (library_id, book_fingerprint, added_to_library_at)
804        VALUES (?, ?, ?)
805        "#,
806        library_id,
807        fp_str,
808        now,
809    )
810    .execute(&mut **tx)
811    .await?;
812
813    Ok(())
814}
815
816/// Removes `.thumbnail-previews/` from the library directory.
817///
818/// Thumbnails will be regenerated and stored in the database, so the legacy
819/// directory is no longer needed after migration.
820#[cfg(not(feature = "test"))]
821#[cfg_attr(feature = "tracing", tracing::instrument(fields(path = ?library_path)))]
822async fn delete_thumbnail_previews(library_path: &Path) {
823    let previews_dir = library_path.join(THUMBNAIL_PREVIEWS_DIRNAME);
824
825    if !previews_dir.exists() {
826        return;
827    }
828
829    if let Err(e) = fs::remove_dir_all(&previews_dir).await {
830        warn!(path = ?previews_dir, error = %e, "failed to delete .thumbnail-previews after import");
831    }
832}
833
834#[cfg_attr(feature = "tracing", tracing::instrument(fields(path = ?path), ret(level = tracing::Level::TRACE)))]
835async fn load_metadata(path: &Path) -> Option<IndexMap<Fp, Info, FxBuildHasher>> {
836    if !path.exists() {
837        return None;
838    }
839
840    let content = match fs::read_to_string(path).await {
841        Ok(c) => c,
842        Err(e) => {
843            error!(path = ?path, error = %e, "failed to read .metadata.json");
844            return None;
845        }
846    };
847
848    match serde_json::from_str(&content) {
849        Ok(m) => Some(m),
850        Err(e) => {
851            error!(path = ?path, error = %e, "failed to parse .metadata.json");
852            None
853        }
854    }
855}
856
857#[cfg_attr(feature = "tracing", tracing::instrument(skip(tx, info), fields(library_id = library_id, fp = %fp)))]
858async fn insert_book(
859    tx: &mut Transaction<'_, Sqlite>,
860    library_id: i64,
861    fp: Fp,
862    info: &Info,
863) -> Result<(), anyhow::Error> {
864    let book_row = info_to_book_row(fp, info);
865    let fp_str = fp.to_string();
866
867    sqlx::query!(
868        r#"
869        INSERT OR IGNORE INTO books (
870            fingerprint, title, subtitle, year, language, publisher,
871            series, edition, volume, number, identifier,
872            file_kind, file_size, added_at
873        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
874        "#,
875        book_row.fingerprint,
876        book_row.title,
877        book_row.subtitle,
878        book_row.year,
879        book_row.language,
880        book_row.publisher,
881        book_row.series,
882        book_row.edition,
883        book_row.volume,
884        book_row.number,
885        book_row.identifier,
886        book_row.file_kind,
887        book_row.file_size,
888        book_row.added_at,
889    )
890    .execute(&mut **tx)
891    .await?;
892
893    sqlx::query!(
894        r#"
895        INSERT OR IGNORE INTO library_books (library_id, book_fingerprint, added_to_library_at, file_path, absolute_path)
896        VALUES (?, ?, ?, ?, ?)
897        "#,
898        library_id,
899        fp_str,
900        book_row.added_at,
901        book_row.file_path,
902        book_row.absolute_path,
903    )
904    .execute(&mut **tx)
905    .await?;
906
907    let authors = extract_authors(&info.author);
908    for (position, author_name) in authors.iter().enumerate() {
909        sqlx::query!(
910            r#"INSERT OR IGNORE INTO authors (name) VALUES (?)"#,
911            author_name
912        )
913        .execute(&mut **tx)
914        .await?;
915
916        let author_id: i64 =
917            sqlx::query_scalar!(r#"SELECT id FROM authors WHERE name = ?"#, author_name)
918                .fetch_one(&mut **tx)
919                .await?;
920
921        let pos = position as i64;
922        sqlx::query!(
923            r#"
924            INSERT OR IGNORE INTO book_authors (book_fingerprint, author_id, position)
925            VALUES (?, ?, ?)
926            "#,
927            fp_str,
928            author_id,
929            pos,
930        )
931        .execute(&mut **tx)
932        .await?;
933    }
934
935    for category_name in &info.categories {
936        sqlx::query!(
937            r#"INSERT OR IGNORE INTO categories (name) VALUES (?)"#,
938            category_name
939        )
940        .execute(&mut **tx)
941        .await?;
942
943        let category_id: i64 =
944            sqlx::query_scalar!(r#"SELECT id FROM categories WHERE name = ?"#, category_name)
945                .fetch_one(&mut **tx)
946                .await?;
947
948        sqlx::query!(
949            r#"
950            INSERT OR IGNORE INTO book_categories (book_fingerprint, category_id)
951            VALUES (?, ?)
952            "#,
953            fp_str,
954            category_id,
955        )
956        .execute(&mut **tx)
957        .await?;
958    }
959
960    Ok(())
961}
962
963#[cfg_attr(feature = "tracing", tracing::instrument(skip(tx, reader_info), fields(fp = %fp)))]
964async fn insert_reading_state(
965    tx: &mut Transaction<'_, Sqlite>,
966    fp: Fp,
967    reader_info: &crate::metadata::ReaderInfo,
968) -> Result<(), anyhow::Error> {
969    let rs = reader_info_to_reading_state_row(fp, reader_info);
970
971    sqlx::query!(
972        r#"
973        INSERT INTO reading_states (
974            fingerprint, opened, current_page, pages_count, finished, dithered,
975            zoom_mode, scroll_mode, page_offset_x, page_offset_y, rotation,
976            cropping_margins_json, margin_width, screen_margin_width,
977            font_family, font_size, text_align, line_height,
978            contrast_exponent, contrast_gray,
979            page_names_json, bookmarks_json, annotations_json
980        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
981        ON CONFLICT(fingerprint) DO NOTHING
982        "#,
983        rs.fingerprint,
984        rs.opened,
985        rs.current_page,
986        rs.pages_count,
987        rs.finished,
988        rs.dithered,
989        rs.zoom_mode,
990        rs.scroll_mode,
991        rs.page_offset_x,
992        rs.page_offset_y,
993        rs.rotation,
994        rs.cropping_margins_json,
995        rs.margin_width,
996        rs.screen_margin_width,
997        rs.font_family,
998        rs.font_size,
999        rs.text_align,
1000        rs.line_height,
1001        rs.contrast_exponent,
1002        rs.contrast_gray,
1003        rs.page_names_json,
1004        rs.bookmarks_json,
1005        rs.annotations_json,
1006    )
1007    .execute(&mut **tx)
1008    .await?;
1009
1010    Ok(())
1011}
1012
1013#[cfg(test)]
1014mod tests {
1015    use super::*;
1016    use crate::db::Database;
1017    use crate::db::runtime::RUNTIME;
1018    use crate::document::{SimpleTocEntry, TocLocation};
1019    use crate::library::db::Db;
1020    use crate::metadata::{FileInfo, ReaderInfo};
1021    use chrono::Local;
1022    use std::collections::BTreeSet;
1023    use std::path::PathBuf;
1024    use tempfile::tempdir;
1025
1026    fn create_test_db() -> (Database, Db) {
1027        let db = Database::new(":memory:").expect("failed to create in-memory database");
1028        db.migrate().expect("failed to run migrations");
1029        let libdb = Db::new(&db);
1030
1031        (db, libdb)
1032    }
1033
1034    fn create_info(
1035        title: &str,
1036        author: &str,
1037        categories: &[&str],
1038        path: &str,
1039        reader_info: Option<ReaderInfo>,
1040    ) -> Info {
1041        Info {
1042            title: title.to_string(),
1043            author: author.to_string(),
1044            categories: categories
1045                .iter()
1046                .map(|category| category.to_string())
1047                .collect::<BTreeSet<_>>(),
1048            file: FileInfo {
1049                path: PathBuf::from(path),
1050                absolute_path: PathBuf::from(path),
1051                kind: "epub".to_string(),
1052                size: 1024,
1053            },
1054            reader_info,
1055            added: Local::now().naive_local(),
1056            ..Default::default()
1057        }
1058    }
1059
1060    #[test]
1061    fn rekey_book_merges_duplicate_content_data() {
1062        let (db, libdb) = create_test_db();
1063        let library_a = libdb
1064            .register_library("/tmp/library-a", "Library A")
1065            .expect("failed to register library A");
1066        let library_b = libdb
1067            .register_library("/tmp/library-b", "Library B")
1068            .expect("failed to register library B");
1069
1070        let old_fp = Fp::from_u64(1);
1071        let new_fp = Fp::from_u64(2);
1072        let old_reader = ReaderInfo {
1073            current_page: 42,
1074            pages_count: 100,
1075            ..Default::default()
1076        };
1077        let old_toc = vec![SimpleTocEntry::Leaf(
1078            "Chapter 1".to_string(),
1079            TocLocation::Exact(1),
1080        )];
1081
1082        let old_info = create_info(
1083            "Old Copy",
1084            "Old Author",
1085            &["History"],
1086            "/tmp/library-a/book.epub",
1087            Some(old_reader.clone()),
1088        );
1089        let new_info = create_info("New Copy", "", &[], "/tmp/library-b/book.epub", None);
1090
1091        libdb
1092            .insert_book(library_a, old_fp, &old_info)
1093            .expect("failed to insert old book");
1094        libdb
1095            .insert_book(library_b, new_fp, &new_info)
1096            .expect("failed to insert new book");
1097        libdb
1098            .save_toc(old_fp, &old_toc)
1099            .expect("failed to save old toc");
1100        libdb
1101            .save_thumbnail(old_fp, b"old-thumbnail")
1102            .expect("failed to save old thumbnail");
1103
1104        RUNTIME.block_on(async {
1105            rekey_book(db.pool(), &old_fp.to_string(), &new_fp.to_string())
1106                .await
1107                .expect("failed to rekey duplicate book");
1108        });
1109
1110        let books_a = libdb
1111            .get_all_books(library_a)
1112            .expect("failed to load library A books");
1113        let books_b = libdb
1114            .get_all_books(library_b)
1115            .expect("failed to load library B books");
1116
1117        assert_eq!(books_a.len(), 1);
1118        assert_eq!(books_b.len(), 1);
1119        assert_eq!(books_a[0].fp, Some(new_fp));
1120        assert_eq!(books_b[0].fp, Some(new_fp));
1121
1122        let merged = &books_a[0];
1123        let merged_reader = merged
1124            .reader_info
1125            .as_ref()
1126            .expect("reading state should be preserved");
1127
1128        assert_eq!(merged.author, "Old Author");
1129        assert!(merged.categories.contains("History"));
1130        assert_eq!(merged_reader.current_page, old_reader.current_page);
1131        assert_eq!(merged_reader.pages_count, old_reader.pages_count);
1132        assert!(matches!(
1133            merged.toc.as_ref(),
1134            Some(toc) if matches!(toc.first(), Some(SimpleTocEntry::Leaf(title, TocLocation::Exact(1))) if title == "Chapter 1")
1135        ));
1136        assert_eq!(
1137            libdb
1138                .get_thumbnail(new_fp)
1139                .expect("failed to read thumbnail"),
1140            Some(b"old-thumbnail".to_vec())
1141        );
1142        assert_eq!(
1143            libdb
1144                .get_thumbnail(old_fp)
1145                .expect("failed to read old thumbnail"),
1146            None
1147        );
1148    }
1149
1150    #[test]
1151    fn rekey_book_keeps_existing_duplicate_data() {
1152        let (db, libdb) = create_test_db();
1153        let library_a = libdb
1154            .register_library("/tmp/library-c", "Library C")
1155            .expect("failed to register library C");
1156        let library_b = libdb
1157            .register_library("/tmp/library-d", "Library D")
1158            .expect("failed to register library D");
1159
1160        let old_fp = Fp::from_u64(3);
1161        let new_fp = Fp::from_u64(4);
1162        let old_toc = vec![SimpleTocEntry::Leaf(
1163            "Old Chapter".to_string(),
1164            TocLocation::Exact(1),
1165        )];
1166        let new_toc = vec![SimpleTocEntry::Leaf(
1167            "New Chapter".to_string(),
1168            TocLocation::Exact(2),
1169        )];
1170
1171        let old_info = create_info(
1172            "Old Copy",
1173            "Old Author",
1174            &["History"],
1175            "/tmp/library-c/book.epub",
1176            Some(ReaderInfo {
1177                current_page: 12,
1178                pages_count: 100,
1179                ..Default::default()
1180            }),
1181        );
1182        let new_info = create_info(
1183            "New Copy",
1184            "",
1185            &[],
1186            "/tmp/library-d/book.epub",
1187            Some(ReaderInfo {
1188                current_page: 88,
1189                pages_count: 200,
1190                ..Default::default()
1191            }),
1192        );
1193
1194        libdb
1195            .insert_book(library_a, old_fp, &old_info)
1196            .expect("failed to insert old book");
1197        libdb
1198            .insert_book(library_b, new_fp, &new_info)
1199            .expect("failed to insert new book");
1200        libdb
1201            .save_toc(old_fp, &old_toc)
1202            .expect("failed to save old toc");
1203        libdb
1204            .save_toc(new_fp, &new_toc)
1205            .expect("failed to save new toc");
1206        libdb
1207            .save_thumbnail(old_fp, b"old-thumbnail")
1208            .expect("failed to save old thumbnail");
1209        libdb
1210            .save_thumbnail(new_fp, b"new-thumbnail")
1211            .expect("failed to save new thumbnail");
1212
1213        RUNTIME.block_on(async {
1214            rekey_book(db.pool(), &old_fp.to_string(), &new_fp.to_string())
1215                .await
1216                .expect("failed to rekey duplicate book");
1217        });
1218
1219        let merged = libdb
1220            .get_all_books(library_a)
1221            .expect("failed to load merged books")
1222            .into_iter()
1223            .next()
1224            .expect("merged book should exist");
1225        let merged_reader = merged
1226            .reader_info
1227            .as_ref()
1228            .expect("reading state should exist");
1229
1230        assert_eq!(merged_reader.current_page, 88);
1231        assert!(matches!(
1232            merged.toc.as_ref(),
1233            Some(toc) if matches!(toc.first(), Some(SimpleTocEntry::Leaf(title, TocLocation::Exact(2))) if title == "New Chapter")
1234        ));
1235        assert_eq!(
1236            libdb
1237                .get_thumbnail(new_fp)
1238                .expect("failed to read thumbnail"),
1239            Some(b"new-thumbnail".to_vec())
1240        );
1241    }
1242
1243    #[test]
1244    fn rehash_fingerprints_canonicalizes_unrekeyed_legacy_fingerprints() {
1245        let (db, libdb) = create_test_db();
1246        let library_id = libdb
1247            .register_library("/tmp/library-legacy", "Legacy Library")
1248            .expect("failed to register legacy library");
1249        let legacy_fp = "0000000000000001";
1250        let legacy_fp_value =
1251            Fp::from_legacy_str(legacy_fp).expect("legacy fingerprint should parse");
1252        let canonical_fp = legacy_fp_value.to_string();
1253
1254        RUNTIME.block_on(async {
1255            let mut tx = db
1256                .pool()
1257                .begin()
1258                .await
1259                .expect("failed to begin legacy insert transaction");
1260
1261            ensure_stub_book(&mut tx, library_id, legacy_fp_value)
1262                .await
1263                .expect("failed to insert legacy book");
1264
1265            tx.commit()
1266                .await
1267                .expect("failed to commit legacy insert transaction");
1268
1269            rehash_fingerprints(db.pool())
1270                .await
1271                .expect("failed to run rehash migration");
1272        });
1273
1274        let books = libdb
1275            .get_all_books(library_id)
1276            .expect("canonicalized legacy books should load");
1277
1278        assert_eq!(books.len(), 1);
1279        assert_eq!(
1280            books[0].fp.map(|fp| fp.to_string()).as_deref(),
1281            Some(canonical_fp.as_str())
1282        );
1283
1284        RUNTIME.block_on(async {
1285            let old_row = sqlx::query_scalar!(
1286                "SELECT fingerprint FROM books WHERE fingerprint = ?",
1287                legacy_fp
1288            )
1289            .fetch_optional(db.pool())
1290            .await
1291            .expect("failed to query old fingerprint");
1292            let new_row = sqlx::query_scalar!(
1293                "SELECT fingerprint FROM books WHERE fingerprint = ?",
1294                canonical_fp
1295            )
1296            .fetch_optional(db.pool())
1297            .await
1298            .expect("failed to query canonical fingerprint");
1299
1300            assert!(old_row.is_none());
1301            assert_eq!(new_row.as_deref(), Some(canonical_fp.as_str()));
1302        });
1303    }
1304
1305    #[test]
1306    fn rehash_fingerprints_reads_absolute_path_from_library_books() {
1307        let temp = tempdir().expect("failed to create temp dir");
1308        let library_root = temp.path().join("library");
1309        std::fs::create_dir(&library_root).expect("failed to create library root");
1310        let book_path = library_root.join("book.epub");
1311        std::fs::write(&book_path, b"rehash me").expect("failed to write book file");
1312
1313        let (db, libdb) = create_test_db();
1314        let library_id = libdb
1315            .register_library(library_root.to_string_lossy().as_ref(), "Rehash Library")
1316            .expect("failed to register library");
1317        let legacy_fp = "00000000000000aa";
1318        let expected_fp = book_path.fingerprint().expect("failed to fingerprint file");
1319        let now = UnixTimestamp::now();
1320
1321        RUNTIME.block_on(async {
1322            sqlx::query(
1323                r#"
1324                INSERT INTO books (
1325                    fingerprint, title, subtitle, year, language, publisher,
1326                    series, edition, volume, number, identifier,
1327                    file_kind, file_size, added_at
1328                ) VALUES (?, ?, '', '', '', '', '', '', '', '', '', ?, ?, ?)
1329                "#,
1330            )
1331            .bind(legacy_fp)
1332            .bind("Legacy Book")
1333            .bind("epub")
1334            .bind(9_i64)
1335            .bind(now)
1336            .execute(db.pool())
1337            .await
1338            .expect("failed to insert legacy book");
1339
1340            sqlx::query(
1341                r#"
1342                INSERT INTO library_books (
1343                    library_id, book_fingerprint, added_to_library_at, file_path, absolute_path
1344                ) VALUES (?, ?, ?, ?, ?)
1345                "#,
1346            )
1347            .bind(library_id)
1348            .bind(legacy_fp)
1349            .bind(now)
1350            .bind("book.epub")
1351            .bind(book_path.to_string_lossy().as_ref())
1352            .execute(db.pool())
1353            .await
1354            .expect("failed to insert library book row");
1355
1356            rehash_fingerprints(db.pool())
1357                .await
1358                .expect("failed to run rehash migration");
1359        });
1360
1361        let books = libdb
1362            .get_all_books(library_id)
1363            .expect("rehash results should load");
1364
1365        assert_eq!(books.len(), 1);
1366        assert_eq!(books[0].fp, Some(expected_fp));
1367        assert_eq!(books[0].file.absolute_path, book_path);
1368
1369        RUNTIME.block_on(async {
1370            let expected_fp_str = expected_fp.to_string();
1371            let old_row = sqlx::query_scalar!(
1372                "SELECT fingerprint FROM books WHERE fingerprint = ?",
1373                legacy_fp
1374            )
1375            .fetch_optional(db.pool())
1376            .await
1377            .expect("failed to query legacy row");
1378            let new_row = sqlx::query_scalar!(
1379                "SELECT fingerprint FROM books WHERE fingerprint = ?",
1380                expected_fp_str
1381            )
1382            .fetch_optional(db.pool())
1383            .await
1384            .expect("failed to query rehashed row");
1385
1386            assert!(old_row.is_none());
1387            assert_eq!(new_row.as_deref(), Some(expected_fp_str.as_str()));
1388        });
1389    }
1390}