1use 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 "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 "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#[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#[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#[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#[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#[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#[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#[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#[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}