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 mut db = Database::new(":memory:").expect("failed to create in-memory database");
1028        db.init(0).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                mtime: None,
1054            },
1055            reader_info,
1056            added: Local::now().naive_local(),
1057            ..Default::default()
1058        }
1059    }
1060
1061    #[test]
1062    fn rekey_book_merges_duplicate_content_data() {
1063        let (db, libdb) = create_test_db();
1064        let library_a = libdb
1065            .register_library("/tmp/library-a", "Library A")
1066            .expect("failed to register library A");
1067        let library_b = libdb
1068            .register_library("/tmp/library-b", "Library B")
1069            .expect("failed to register library B");
1070
1071        let old_fp = Fp::from_u64(1);
1072        let new_fp = Fp::from_u64(2);
1073        let old_reader = ReaderInfo {
1074            current_page: 42,
1075            pages_count: 100,
1076            ..Default::default()
1077        };
1078        let old_toc = vec![SimpleTocEntry::Leaf(
1079            "Chapter 1".to_string(),
1080            TocLocation::Exact(1),
1081        )];
1082
1083        let old_info = create_info(
1084            "Old Copy",
1085            "Old Author",
1086            &["History"],
1087            "/tmp/library-a/book.epub",
1088            Some(old_reader.clone()),
1089        );
1090        let new_info = create_info("New Copy", "", &[], "/tmp/library-b/book.epub", None);
1091
1092        libdb
1093            .insert_book(library_a, old_fp, &old_info)
1094            .expect("failed to insert old book");
1095        libdb
1096            .insert_book(library_b, new_fp, &new_info)
1097            .expect("failed to insert new book");
1098        libdb
1099            .save_toc(old_fp, &old_toc)
1100            .expect("failed to save old toc");
1101        libdb
1102            .save_thumbnail(old_fp, b"old-thumbnail")
1103            .expect("failed to save old thumbnail");
1104
1105        RUNTIME.block_on(async {
1106            rekey_book(db.pool(), &old_fp.to_string(), &new_fp.to_string())
1107                .await
1108                .expect("failed to rekey duplicate book");
1109        });
1110
1111        let books_a = libdb
1112            .get_all_books(library_a)
1113            .expect("failed to load library A books");
1114        let books_b = libdb
1115            .get_all_books(library_b)
1116            .expect("failed to load library B books");
1117
1118        assert_eq!(books_a.len(), 1);
1119        assert_eq!(books_b.len(), 1);
1120        assert_eq!(books_a[0].fp, Some(new_fp));
1121        assert_eq!(books_b[0].fp, Some(new_fp));
1122
1123        let merged = &books_a[0];
1124        let merged_reader = merged
1125            .reader_info
1126            .as_ref()
1127            .expect("reading state should be preserved");
1128
1129        assert_eq!(merged.author, "Old Author");
1130        assert!(merged.categories.contains("History"));
1131        assert_eq!(merged_reader.current_page, old_reader.current_page);
1132        assert_eq!(merged_reader.pages_count, old_reader.pages_count);
1133        assert!(matches!(
1134            merged.toc.as_ref(),
1135            Some(toc) if matches!(toc.first(), Some(SimpleTocEntry::Leaf(title, TocLocation::Exact(1))) if title == "Chapter 1")
1136        ));
1137        assert_eq!(
1138            libdb
1139                .get_thumbnail(new_fp)
1140                .expect("failed to read thumbnail"),
1141            Some(b"old-thumbnail".to_vec())
1142        );
1143        assert_eq!(
1144            libdb
1145                .get_thumbnail(old_fp)
1146                .expect("failed to read old thumbnail"),
1147            None
1148        );
1149    }
1150
1151    #[test]
1152    fn rekey_book_keeps_existing_duplicate_data() {
1153        let (db, libdb) = create_test_db();
1154        let library_a = libdb
1155            .register_library("/tmp/library-c", "Library C")
1156            .expect("failed to register library C");
1157        let library_b = libdb
1158            .register_library("/tmp/library-d", "Library D")
1159            .expect("failed to register library D");
1160
1161        let old_fp = Fp::from_u64(3);
1162        let new_fp = Fp::from_u64(4);
1163        let old_toc = vec![SimpleTocEntry::Leaf(
1164            "Old Chapter".to_string(),
1165            TocLocation::Exact(1),
1166        )];
1167        let new_toc = vec![SimpleTocEntry::Leaf(
1168            "New Chapter".to_string(),
1169            TocLocation::Exact(2),
1170        )];
1171
1172        let old_info = create_info(
1173            "Old Copy",
1174            "Old Author",
1175            &["History"],
1176            "/tmp/library-c/book.epub",
1177            Some(ReaderInfo {
1178                current_page: 12,
1179                pages_count: 100,
1180                ..Default::default()
1181            }),
1182        );
1183        let new_info = create_info(
1184            "New Copy",
1185            "",
1186            &[],
1187            "/tmp/library-d/book.epub",
1188            Some(ReaderInfo {
1189                current_page: 88,
1190                pages_count: 200,
1191                ..Default::default()
1192            }),
1193        );
1194
1195        libdb
1196            .insert_book(library_a, old_fp, &old_info)
1197            .expect("failed to insert old book");
1198        libdb
1199            .insert_book(library_b, new_fp, &new_info)
1200            .expect("failed to insert new book");
1201        libdb
1202            .save_toc(old_fp, &old_toc)
1203            .expect("failed to save old toc");
1204        libdb
1205            .save_toc(new_fp, &new_toc)
1206            .expect("failed to save new toc");
1207        libdb
1208            .save_thumbnail(old_fp, b"old-thumbnail")
1209            .expect("failed to save old thumbnail");
1210        libdb
1211            .save_thumbnail(new_fp, b"new-thumbnail")
1212            .expect("failed to save new thumbnail");
1213
1214        RUNTIME.block_on(async {
1215            rekey_book(db.pool(), &old_fp.to_string(), &new_fp.to_string())
1216                .await
1217                .expect("failed to rekey duplicate book");
1218        });
1219
1220        let merged = libdb
1221            .get_all_books(library_a)
1222            .expect("failed to load merged books")
1223            .into_iter()
1224            .next()
1225            .expect("merged book should exist");
1226        let merged_reader = merged
1227            .reader_info
1228            .as_ref()
1229            .expect("reading state should exist");
1230
1231        assert_eq!(merged_reader.current_page, 88);
1232        assert!(matches!(
1233            merged.toc.as_ref(),
1234            Some(toc) if matches!(toc.first(), Some(SimpleTocEntry::Leaf(title, TocLocation::Exact(2))) if title == "New Chapter")
1235        ));
1236        assert_eq!(
1237            libdb
1238                .get_thumbnail(new_fp)
1239                .expect("failed to read thumbnail"),
1240            Some(b"new-thumbnail".to_vec())
1241        );
1242    }
1243
1244    #[test]
1245    fn rehash_fingerprints_canonicalizes_unrekeyed_legacy_fingerprints() {
1246        let (db, libdb) = create_test_db();
1247        let library_id = libdb
1248            .register_library("/tmp/library-legacy", "Legacy Library")
1249            .expect("failed to register legacy library");
1250        let legacy_fp = "0000000000000001";
1251        let legacy_fp_value =
1252            Fp::from_legacy_str(legacy_fp).expect("legacy fingerprint should parse");
1253        let canonical_fp = legacy_fp_value.to_string();
1254
1255        RUNTIME.block_on(async {
1256            let mut tx = db
1257                .pool()
1258                .begin()
1259                .await
1260                .expect("failed to begin legacy insert transaction");
1261
1262            ensure_stub_book(&mut tx, library_id, legacy_fp_value)
1263                .await
1264                .expect("failed to insert legacy book");
1265
1266            tx.commit()
1267                .await
1268                .expect("failed to commit legacy insert transaction");
1269
1270            rehash_fingerprints(db.pool())
1271                .await
1272                .expect("failed to run rehash migration");
1273        });
1274
1275        let books = libdb
1276            .get_all_books(library_id)
1277            .expect("canonicalized legacy books should load");
1278
1279        assert_eq!(books.len(), 1);
1280        assert_eq!(
1281            books[0].fp.map(|fp| fp.to_string()).as_deref(),
1282            Some(canonical_fp.as_str())
1283        );
1284
1285        RUNTIME.block_on(async {
1286            let old_row = sqlx::query_scalar!(
1287                "SELECT fingerprint FROM books WHERE fingerprint = ?",
1288                legacy_fp
1289            )
1290            .fetch_optional(db.pool())
1291            .await
1292            .expect("failed to query old fingerprint");
1293            let new_row = sqlx::query_scalar!(
1294                "SELECT fingerprint FROM books WHERE fingerprint = ?",
1295                canonical_fp
1296            )
1297            .fetch_optional(db.pool())
1298            .await
1299            .expect("failed to query canonical fingerprint");
1300
1301            assert!(old_row.is_none());
1302            assert_eq!(new_row.as_deref(), Some(canonical_fp.as_str()));
1303        });
1304    }
1305
1306    #[test]
1307    fn rehash_fingerprints_reads_absolute_path_from_library_books() {
1308        let temp = tempdir().expect("failed to create temp dir");
1309        let library_root = temp.path().join("library");
1310        std::fs::create_dir(&library_root).expect("failed to create library root");
1311        let book_path = library_root.join("book.epub");
1312        std::fs::write(&book_path, b"rehash me").expect("failed to write book file");
1313
1314        let (db, libdb) = create_test_db();
1315        let library_id = libdb
1316            .register_library(library_root.to_string_lossy().as_ref(), "Rehash Library")
1317            .expect("failed to register library");
1318        let legacy_fp = "00000000000000aa";
1319        let expected_fp = book_path.fingerprint().expect("failed to fingerprint file");
1320        let now = UnixTimestamp::now();
1321
1322        RUNTIME.block_on(async {
1323            sqlx::query(
1324                r#"
1325                INSERT INTO books (
1326                    fingerprint, title, subtitle, year, language, publisher,
1327                    series, edition, volume, number, identifier,
1328                    file_kind, file_size, added_at
1329                ) VALUES (?, ?, '', '', '', '', '', '', '', '', '', ?, ?, ?)
1330                "#,
1331            )
1332            .bind(legacy_fp)
1333            .bind("Legacy Book")
1334            .bind("epub")
1335            .bind(9_i64)
1336            .bind(now)
1337            .execute(db.pool())
1338            .await
1339            .expect("failed to insert legacy book");
1340
1341            sqlx::query(
1342                r#"
1343                INSERT INTO library_books (
1344                    library_id, book_fingerprint, added_to_library_at, file_path, absolute_path
1345                ) VALUES (?, ?, ?, ?, ?)
1346                "#,
1347            )
1348            .bind(library_id)
1349            .bind(legacy_fp)
1350            .bind(now)
1351            .bind("book.epub")
1352            .bind(book_path.to_string_lossy().as_ref())
1353            .execute(db.pool())
1354            .await
1355            .expect("failed to insert library book row");
1356
1357            rehash_fingerprints(db.pool())
1358                .await
1359                .expect("failed to run rehash migration");
1360        });
1361
1362        let books = libdb
1363            .get_all_books(library_id)
1364            .expect("rehash results should load");
1365
1366        assert_eq!(books.len(), 1);
1367        assert_eq!(books[0].fp, Some(expected_fp));
1368        assert_eq!(books[0].file.absolute_path, book_path);
1369
1370        RUNTIME.block_on(async {
1371            let expected_fp_str = expected_fp.to_string();
1372            let old_row = sqlx::query_scalar!(
1373                "SELECT fingerprint FROM books WHERE fingerprint = ?",
1374                legacy_fp
1375            )
1376            .fetch_optional(db.pool())
1377            .await
1378            .expect("failed to query legacy row");
1379            let new_row = sqlx::query_scalar!(
1380                "SELECT fingerprint FROM books WHERE fingerprint = ?",
1381                expected_fp_str
1382            )
1383            .fetch_optional(db.pool())
1384            .await
1385            .expect("failed to query rehashed row");
1386
1387            assert!(old_row.is_none());
1388            assert_eq!(new_row.as_deref(), Some(expected_fp_str.as_str()));
1389        });
1390    }
1391}