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