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