1use crate::db::types::{FileSize, UnixTimestamp};
2use crate::document::file_kind;
3use crate::fl;
4use crate::helpers::{Fingerprint, Fp, IsHidden};
5use crate::library::db::{Db as LibraryDb, PathUpdate};
6use crate::metadata::{FileInfo, Info, extract_metadata_from_document};
7use crate::settings::ImportSettings;
8use crate::task::ShutdownSignal;
9use crate::view::{Event, NotificationEvent, ViewId};
10use fxhash::FxHashMap;
11use std::path::{Path, PathBuf};
12use std::sync::mpsc::Sender;
13use std::time::{Duration, Instant, UNIX_EPOCH};
14use tracing::{debug, error, info};
15use walkdir::{DirEntry, WalkDir};
16
17enum PendingRelocation {
18 FingerprintChanged {
19 new_fp: Fp,
20 old_fp: Fp,
21 file_size: u64,
22 },
23}
24
25impl PendingRelocation {
26 fn old_fp(&self) -> Fp {
27 match self {
28 PendingRelocation::FingerprintChanged { old_fp, .. } => *old_fp,
29 }
30 }
31}
32
33struct ProgressTracker {
34 last_sent: Instant,
35 last_percent: u8,
36 first_tick: bool,
37}
38
39impl ProgressTracker {
40 const SEND_INTERVAL_SEC: u64 = 2;
41
42 fn new() -> Self {
43 Self {
44 last_sent: Instant::now(),
45 last_percent: 0,
46 first_tick: true,
47 }
48 }
49
50 fn should_send(&mut self, idx: usize, total: usize, now: Instant) -> Option<u8> {
51 let percent = ((idx + 1) * 100).checked_div(total)?;
52 let percent = percent.min(100) as u8;
53
54 if self.first_tick
55 || percent == 100
56 || now.checked_duration_since(self.last_sent)
57 >= Some(Duration::from_secs(Self::SEND_INTERVAL_SEC))
58 {
59 self.last_sent = now;
60 self.last_percent = percent;
61 self.first_tick = false;
62 Some(percent)
63 } else {
64 None
65 }
66 }
67}
68
69struct ScanContext<'a> {
70 hub: &'a Sender<Event>,
71 notif_id: ViewId,
72 shutdown: &'a ShutdownSignal,
73}
74
75struct ScanResult {
76 books_to_insert: Vec<(Fp, Info)>,
77 path_updates: Vec<PathUpdate>,
78 books_to_delete: Vec<Fp>,
79 pending_relocations: Vec<PendingRelocation>,
80 thumbnails_to_delete: Vec<Fp>,
81}
82
83#[cfg(feature = "emulator")]
84const IGNORED_TOP_LEVEL_DIRS: &[&str] = &["target", "node_modules", "thirdparty"];
85
86#[cfg_attr(feature = "tracing", tracing::instrument(skip(home)))]
87fn walk_files(home: &Path) -> Vec<DirEntry> {
88 WalkDir::new(home)
89 .min_depth(1)
90 .into_iter()
91 .filter_entry(|e| {
92 if e.is_hidden() {
93 return false;
94 }
95 #[cfg(feature = "emulator")]
96 if e.depth() == 1 && e.file_type().is_dir() {
97 if let Some(name) = e.file_name().to_str() {
98 if IGNORED_TOP_LEVEL_DIRS.contains(&name) {
99 return false;
100 }
101 }
102 }
103 true
104 })
105 .filter_map(|e| e.ok())
106 .filter(|e| !e.file_type().is_dir())
107 .collect()
108}
109
110#[cfg_attr(
111 feature = "tracing",
112 tracing::instrument(
113 skip(
114 home,
115 settings,
116 ctx,
117 tracker,
118 mtime_by_abs,
119 handles_by_fp,
120 handles_by_path
121 ),
122 fields(total)
123 )
124)]
125fn scan_entries(
126 home: &Path,
127 entries: &[DirEntry],
128 settings: &ImportSettings,
129 force: bool,
130 ctx: &ScanContext<'_>,
131 tracker: &mut ProgressTracker,
132 mtime_by_abs: &FxHashMap<PathBuf, (UnixTimestamp, FileSize)>,
133 handles_by_fp: &mut FxHashMap<Fp, (PathBuf, PathBuf)>,
134 handles_by_path: &mut FxHashMap<PathBuf, Fp>,
135) -> Option<ScanResult> {
136 let total = entries.len();
137 tracing::Span::current().record("total", total);
138 let mut skipped_count = 0u32;
139 let mut fingerprinted_count = 0u32;
140 let mut mtime_miss_count = 0u32;
141 debug!(mtime_map_size = mtime_by_abs.len(), "starting scan");
142
143 let mut books_to_insert: Vec<(Fp, Info)> = Vec::new();
144 let mut path_updates: Vec<PathUpdate> = Vec::new();
145 let mut books_to_delete: Vec<Fp> = Vec::new();
146 let mut pending_relocations: Vec<PendingRelocation> = Vec::new();
147 let mut thumbnails_to_delete: Vec<Fp> = Vec::new();
148
149 for (idx, entry) in entries.iter().enumerate() {
150 #[cfg(feature = "tracing")]
151 let _span = tracing::info_span!("procssing entry", entry = ?entry).entered();
152
153 if ctx.shutdown.should_stop() {
154 tracing::info!("import scan interrupted by shutdown");
155 return None;
156 }
157
158 let path = entry.path();
159 let relat = path.strip_prefix(home).unwrap_or(path);
160
161 let kind = file_kind(path);
162 let is_known_to_db = handles_by_path.contains_key(relat);
163 let allowed_kind = kind.filter(|k| settings.is_kind_allowed(*k));
164
165 if !is_known_to_db && allowed_kind.is_none() {
166 send_progress(ctx.hub, ctx.notif_id, tracker, idx, total);
167 continue;
168 }
169
170 let file_meta = match entry.metadata() {
171 Ok(m) => m,
172 Err(e) => {
173 error!(path = ?path, error = %e, "failed to read metadata, skipping");
174 send_progress(ctx.hub, ctx.notif_id, tracker, idx, total);
175 continue;
176 }
177 };
178
179 let current_size = FileSize::from(file_meta.len() as i64);
180 let current_mtime = file_meta
181 .modified()
182 .ok()
183 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
184 .map(|d| UnixTimestamp::from((d.as_secs().div_ceil(2) * 2) as i64));
185
186 if !force && let Some(mtime) = current_mtime {
187 match mtime_by_abs.get(path) {
188 Some(&(stored_mtime, stored_size)) => {
189 if stored_mtime == mtime && stored_size == current_size {
190 skipped_count += 1;
191 debug!(path = %relat.display(), "mtime and size unchanged, skipping fingerprint");
192 send_progress(ctx.hub, ctx.notif_id, tracker, idx, total);
193 continue;
194 }
195 }
196 None => {
197 mtime_miss_count += 1;
198 debug!(
199 path = %relat.display(),
200 abs = %path.display(),
201 "mtime lookup miss: file not in mtime_by_abs map"
202 );
203 }
204 }
205 }
206
207 let fp = match path.fingerprint() {
208 Ok(fp) => {
209 fingerprinted_count += 1;
210 fp
211 }
212 Err(e) => {
213 error!(path = ?path, error = %e, "failed to compute fingerprint, skipping");
214 send_progress(ctx.hub, ctx.notif_id, tracker, idx, total);
215 continue;
216 }
217 };
218
219 if handles_by_fp.contains_key(&fp) {
220 let (stored_relat, stored_abs) = handles_by_fp[&fp].clone();
221 let path_changed = relat != stored_relat;
222 let abs_stale = path != stored_abs;
223
224 if path_changed {
225 debug!(
226 fp = %fp,
227 old_path = %stored_relat.display(),
228 new_path = %relat.display(),
229 "updated book path"
230 );
231 handles_by_path.remove(&stored_relat);
232 handles_by_fp.insert(fp, (relat.to_path_buf(), path.to_path_buf()));
233 handles_by_path.insert(relat.to_path_buf(), fp);
234 } else if abs_stale {
235 debug!(
236 fp = %fp,
237 path = %relat.display(),
238 "healing stale absolute_path"
239 );
240 handles_by_fp.insert(fp, (relat.to_path_buf(), path.to_path_buf()));
241 }
242
243 path_updates.push(PathUpdate {
244 fp,
245 relat: relat.to_path_buf(),
246 abs: path.to_path_buf(),
247 mtime: current_mtime,
248 file_size: Some(current_size),
249 });
250
251 send_progress(ctx.hub, ctx.notif_id, tracker, idx, total);
252 continue;
253 }
254
255 if let Some(old_fp) = handles_by_path.get(relat).cloned() {
256 debug!(
257 path = %relat.display(),
258 old_fp = %old_fp,
259 new_fp = %fp,
260 "updated book fingerprint"
261 );
262
263 handles_by_fp.remove(&old_fp);
264 handles_by_path.remove(relat);
265 handles_by_fp.insert(fp, (relat.to_path_buf(), path.to_path_buf()));
266 handles_by_path.insert(relat.to_path_buf(), fp);
267 books_to_delete.push(old_fp);
268
269 pending_relocations.push(PendingRelocation::FingerprintChanged {
270 new_fp: fp,
271 old_fp,
272 file_size: i64::from(current_size) as u64,
273 });
274
275 thumbnails_to_delete.push(old_fp);
276 path_updates.push(PathUpdate {
277 fp,
278 relat: relat.to_path_buf(),
279 abs: path.to_path_buf(),
280 mtime: current_mtime,
281 file_size: Some(current_size),
282 });
283 send_progress(ctx.hub, ctx.notif_id, tracker, idx, total);
284 continue;
285 }
286
287 if let Some(kind) = allowed_kind {
288 info!(fp = %fp, path = %relat.display(), "added new entry");
289 let size = i64::from(current_size) as u64;
290 let mut book_info = Info {
291 file: FileInfo {
292 path: relat.to_path_buf(),
293 absolute_path: path.to_path_buf(),
294 kind: kind.as_str().to_owned(),
295 size,
296 mtime: current_mtime,
297 },
298 ..Default::default()
299 };
300 if settings.metadata_kinds.contains(&book_info.file.kind) {
301 extract_metadata_from_document(home, &mut book_info);
302 }
303 handles_by_fp.insert(fp, (relat.to_path_buf(), path.to_path_buf()));
304 handles_by_path.insert(relat.to_path_buf(), fp);
305 books_to_insert.push((fp, book_info));
306 }
307
308 send_progress(ctx.hub, ctx.notif_id, tracker, idx, total);
309 }
310
311 info!(
312 total,
313 skipped = skipped_count,
314 fingerprinted = fingerprinted_count,
315 mtime_misses = mtime_miss_count,
316 "scan complete"
317 );
318
319 Some(ScanResult {
320 books_to_insert,
321 path_updates,
322 books_to_delete,
323 pending_relocations,
324 thumbnails_to_delete,
325 })
326}
327
328fn send_progress(
329 hub: &Sender<Event>,
330 notif_id: ViewId,
331 tracker: &mut ProgressTracker,
332 idx: usize,
333 total: usize,
334) {
335 let Some(percent) = tracker.should_send(idx, total, Instant::now()) else {
336 return;
337 };
338 debug!(percent, "import progress");
339 hub.send(Event::Notification(NotificationEvent::UpdateProgress(
340 notif_id, percent,
341 )))
342 .ok();
343}
344
345#[cfg_attr(
346 feature = "tracing",
347 tracing::instrument(skip(db, home, settings, pending_relocations, books_to_insert))
348)]
349fn resolve_relocations(
350 db: &LibraryDb,
351 library_id: i64,
352 home: &Path,
353 settings: &ImportSettings,
354 pending_relocations: Vec<PendingRelocation>,
355 books_to_insert: &mut Vec<(Fp, Info)>,
356) {
357 let old_fps: Vec<Fp> = pending_relocations
358 .iter()
359 .map(PendingRelocation::old_fp)
360 .collect();
361
362 let mut fetched = db
363 .batch_get_books_by_fingerprints(library_id, &old_fps)
364 .unwrap_or_default();
365
366 for relocation in pending_relocations {
367 match relocation {
368 PendingRelocation::FingerprintChanged {
369 new_fp,
370 old_fp,
371 file_size,
372 } => {
373 if let Some(mut info) = fetched.remove(&old_fp) {
374 if settings.sync_metadata && settings.metadata_kinds.contains(&info.file.kind) {
375 extract_metadata_from_document(home, &mut info);
376 }
377 info.file.size = file_size;
378 books_to_insert.push((new_fp, info));
379 }
380 }
381 }
382 }
383}
384
385#[cfg_attr(feature = "tracing", tracing::instrument(skip(handles_by_fp, home)))]
386fn find_deleted_books(handles_by_fp: &FxHashMap<Fp, (PathBuf, PathBuf)>, home: &Path) -> Vec<Fp> {
387 handles_by_fp
388 .iter()
389 .filter(|(_, (relat, _))| relat.as_os_str().is_empty() || !home.join(relat).exists())
390 .map(|(fp, (relat, _))| {
391 info!(fp = %fp, path = %relat.display(), "removing deleted entry");
392 *fp
393 })
394 .collect()
395}
396
397#[cfg_attr(
398 feature = "tracing",
399 tracing::instrument(skip(
400 db,
401 books_to_insert,
402 path_updates,
403 books_to_delete,
404 thumbnails_to_delete
405 ))
406)]
407fn flush_to_db(
408 db: &LibraryDb,
409 library_id: i64,
410 books_to_insert: Vec<(Fp, Info)>,
411 path_updates: Vec<PathUpdate>,
412 books_to_delete: Vec<Fp>,
413 thumbnails_to_delete: Vec<Fp>,
414) {
415 if let Err(e) = db.batch_delete_thumbnails(&thumbnails_to_delete) {
416 error!(
417 error = %e,
418 count = thumbnails_to_delete.len(),
419 "batch delete thumbnails failed"
420 );
421 }
422
423 if !books_to_insert.is_empty() {
424 let book_refs: Vec<(Fp, &Info)> = books_to_insert
425 .iter()
426 .map(|(fp, info)| (*fp, info))
427 .collect();
428 if let Err(e) = db.batch_insert_books(library_id, &book_refs) {
429 error!(error = %e, count = book_refs.len(), "batch insert failed");
430 }
431 }
432
433 if let Err(e) = db.batch_update_book_paths(library_id, &path_updates) {
434 error!(
435 error = %e,
436 count = path_updates.len(),
437 "batch update book paths failed"
438 );
439 }
440
441 if !books_to_delete.is_empty() {
442 if let Err(e) = db.batch_delete_books(library_id, &books_to_delete) {
443 error!(error = %e, count = books_to_delete.len(), "batch delete failed");
444 }
445 }
446
447 if let Err(e) = db.compute_sort_keys(library_id) {
448 error!(error = %e, library_id, "failed to compute sort keys");
449 }
450}
451
452#[cfg_attr(
463 feature = "tracing",
464 tracing::instrument(skip(db, settings, hub, notif_id, shutdown))
465)]
466pub fn run(
467 db: &LibraryDb,
468 library_id: i64,
469 home: &Path,
470 settings: &ImportSettings,
471 force: bool,
472 hub: &Sender<Event>,
473 notif_id: ViewId,
474 shutdown: &ShutdownSignal,
475) {
476 hub.send(Event::Notification(NotificationEvent::ShowPinned(
477 notif_id,
478 fl!("importer-importing-library"),
479 )))
480 .ok();
481
482 let handles = match db.list_book_handles(library_id) {
483 Ok(h) => h,
484 Err(e) => {
485 error!(error = %e, "failed to load book handles for import");
486 hub.send(Event::Close(notif_id)).ok();
487 return;
488 }
489 };
490
491 let mut handles_by_fp: FxHashMap<Fp, (PathBuf, PathBuf)> = handles
492 .iter()
493 .map(|h| (h.fp, (h.relat.clone(), h.abs.clone())))
494 .collect();
495 let mut handles_by_path: FxHashMap<PathBuf, Fp> =
496 handles.iter().map(|h| (h.relat.clone(), h.fp)).collect();
497 let mtime_by_abs: FxHashMap<PathBuf, (UnixTimestamp, FileSize)> = handles
498 .iter()
499 .filter_map(|h| {
500 let mtime = h.mtime?;
501 let size = h.file_size?;
502 Some((h.abs.clone(), (mtime, size)))
503 })
504 .collect();
505
506 let purged_fps = db
507 .delete_books_with_disallowed_kinds(library_id, &settings.allowed_kinds)
508 .unwrap_or_else(|e| {
509 error!(error = %e, "failed to purge disallowed books");
510 Vec::new()
511 });
512
513 for fp in &purged_fps {
514 if let Some((relat, _abs)) = handles_by_fp.remove(fp) {
515 handles_by_path.remove(&relat);
516 }
517 }
518
519 if !purged_fps.is_empty() {
520 if let Err(e) = db.batch_delete_thumbnails(&purged_fps) {
521 error!(error = %e, count = purged_fps.len(), "failed to delete thumbnails for purged books");
522 }
523 }
524
525 let entries = walk_files(home);
526
527 let ctx = ScanContext {
528 hub,
529 notif_id,
530 shutdown,
531 };
532
533 let mut tracker = ProgressTracker::new();
534
535 let Some(mut result) = scan_entries(
536 home,
537 &entries,
538 settings,
539 force,
540 &ctx,
541 &mut tracker,
542 &mtime_by_abs,
543 &mut handles_by_fp,
544 &mut handles_by_path,
545 ) else {
546 hub.send(Event::Close(notif_id)).ok();
547 return;
548 };
549
550 let mut deleted = find_deleted_books(&handles_by_fp, home);
551 result.books_to_delete.append(&mut deleted);
552
553 if !result.pending_relocations.is_empty() {
554 resolve_relocations(
555 db,
556 library_id,
557 home,
558 settings,
559 result.pending_relocations,
560 &mut result.books_to_insert,
561 );
562 }
563
564 flush_to_db(
565 db,
566 library_id,
567 result.books_to_insert,
568 result.path_updates,
569 result.books_to_delete,
570 result.thumbnails_to_delete,
571 );
572
573 hub.send(Event::Close(notif_id)).ok();
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579 use crate::db::Database;
580 use crate::library::Library;
581 use crate::metadata::{FileInfo, Info};
582 use crate::settings::ImportSettings;
583 use crate::task::ShutdownSignal;
584 use crate::view::ViewId;
585 use std::sync::mpsc;
586
587 fn create_migrated_db() -> Database {
588 let mut db = Database::new(":memory:").expect("in-memory db");
589 db.init(0).expect("migrations");
590 db
591 }
592
593 fn run_import(dir: &Path, db: &Database, shutdown: &ShutdownSignal) -> Vec<Event> {
594 let lib = Library::new(dir, db, "test").expect("failed to create library");
595 let (tx, rx) = mpsc::channel();
596 let notif_id = ViewId::MessageNotif(0);
597 run(
598 &lib.db,
599 lib.library_id,
600 dir,
601 &ImportSettings::default(),
602 false,
603 &tx,
604 notif_id,
605 shutdown,
606 );
607 drop(tx);
608 rx.try_iter().collect()
609 }
610
611 #[test]
612 fn imports_files_when_not_shutdown() {
613 let dir = tempfile::tempdir().expect("tempdir");
614 let db = create_migrated_db();
615 std::fs::write(dir.path().join("book.epub"), b"epub content").expect("write");
616
617 let shutdown = ShutdownSignal::never();
618 let events = run_import(dir.path(), &db, &shutdown);
619
620 assert!(
621 events.iter().any(|e| matches!(e, Event::Close(_))),
622 "expected Close event on normal completion"
623 );
624 assert!(
625 !events.iter().any(|e| matches!(
626 e,
627 Event::Notification(crate::view::NotificationEvent::UpdateProgress(_, 0))
628 )),
629 "progress should advance past 0"
630 );
631 }
632
633 #[test]
634 fn stops_early_when_shutdown_requested() {
635 let dir = tempfile::tempdir().expect("tempdir");
636 let db = create_migrated_db();
637
638 for i in 0..20 {
639 std::fs::write(dir.path().join(format!("book{i}.epub")), b"epub content")
640 .expect("write");
641 }
642
643 let (shutdown_tx, shutdown_rx) = mpsc::channel();
644 let shutdown = ShutdownSignal::new_for_test(shutdown_rx);
645
646 shutdown_tx.send(()).expect("send shutdown");
648
649 let lib = Library::new(dir.path(), &db, "test").expect("library");
650 let (tx, rx) = mpsc::channel();
651 let notif_id = ViewId::MessageNotif(0);
652 run(
653 &lib.db,
654 lib.library_id,
655 dir.path(),
656 &ImportSettings::default(),
657 false,
658 &tx,
659 notif_id,
660 &shutdown,
661 );
662 drop(tx);
663 let events: Vec<Event> = rx.try_iter().collect();
664
665 assert!(
666 events.iter().any(|e| matches!(e, Event::Close(_))),
667 "notif must be closed even on early exit"
668 );
669
670 let progress_events: Vec<_> = events
671 .iter()
672 .filter(|e| {
673 matches!(
674 e,
675 Event::Notification(crate::view::NotificationEvent::UpdateProgress(_, _))
676 )
677 })
678 .collect();
679 assert!(
680 progress_events.len() < 20,
681 "shutdown should have cut the scan short (got {} progress events)",
682 progress_events.len()
683 );
684 }
685
686 #[test]
687 fn progress_sends_at_100_percent_immediately() {
688 let mut tracker = ProgressTracker::new();
689 let base = Instant::now();
690
691 let sent = (0..100)
692 .filter_map(|i| tracker.should_send(i, 100, base))
693 .collect::<Vec<_>>();
694 assert_eq!(
695 sent,
696 vec![1, 100],
697 "Only beginning and end when loop is fast"
698 );
699
700 assert_eq!(tracker.should_send(99, 100, base), Some(100));
701 }
702
703 #[test]
704 fn progress_throttled_within_two_seconds() {
705 let mut tracker = ProgressTracker::new();
706 let base = Instant::now();
707
708 assert_eq!(tracker.should_send(0, 200, base), Some(0));
709
710 assert_eq!(
711 tracker.should_send(50, 200, base + Duration::from_millis(500)),
712 None
713 );
714 assert_eq!(
715 tracker.should_send(100, 200, base + Duration::from_secs(1)),
716 None
717 );
718 }
719
720 #[test]
721 fn progress_sends_after_two_second_gap() {
722 let mut tracker = ProgressTracker::new();
723 let base = Instant::now();
724
725 assert_eq!(tracker.should_send(0, 200, base), Some(0));
726
727 assert_eq!(
728 tracker.should_send(50, 200, base + Duration::from_secs(2)),
729 Some(25)
730 );
731
732 assert_eq!(
733 tracker.should_send(75, 200, base + Duration::from_secs(3)),
734 None
735 );
736
737 assert_eq!(
738 tracker.should_send(150, 200, base + Duration::from_secs(5)),
739 Some(75)
740 );
741 }
742
743 #[test]
744 fn finds_deleted_books_when_file_path_is_empty() {
745 let dir = tempfile::tempdir().expect("tempdir");
746 let db = create_migrated_db();
747 let lib = Library::new(dir.path(), &db, "test").expect("library");
748 let fp = Fp::from_u64(1);
749 let info = Info {
750 title: "test".to_string(),
751 file: FileInfo {
752 path: PathBuf::new(),
753 absolute_path: dir.path().join("missing.epub"),
754 kind: "epub".to_string(),
755 size: 1,
756 mtime: None,
757 },
758 ..Default::default()
759 };
760
761 lib.db
762 .batch_insert_books(lib.library_id, &[(fp, &info)])
763 .expect("insert library book");
764
765 let handles = lib.db.list_book_handles(lib.library_id).expect("handles");
766 let handles_by_fp: FxHashMap<Fp, (PathBuf, PathBuf)> = handles
767 .into_iter()
768 .map(|h| (h.fp, (h.relat, h.abs)))
769 .collect();
770
771 assert_eq!(find_deleted_books(&handles_by_fp, dir.path()), vec![fp]);
772 }
773
774 #[test]
775 fn skips_fingerprinting_disallowed_new_files() {
776 use crate::settings::FileExtension;
777 use fxhash::FxHashSet;
778
779 let dir = tempfile::tempdir().expect("tempdir");
780 let db = create_migrated_db();
781
782 std::fs::write(dir.path().join("book.epub"), b"epub content").expect("write epub");
783 std::fs::write(dir.path().join("ignore.xyz"), b"unsupported content").expect("write xyz");
784
785 let mut allowed: FxHashSet<FileExtension> = FxHashSet::default();
786 allowed.insert(FileExtension::Epub);
787 let settings = ImportSettings {
788 allowed_kinds: allowed,
789 ..ImportSettings::default()
790 };
791
792 let lib = Library::new(dir.path(), &db, "test").expect("library");
793 let (tx, rx) = std::sync::mpsc::channel();
794 let notif_id = ViewId::MessageNotif(0);
795 let shutdown = ShutdownSignal::never();
796
797 run(
798 &lib.db,
799 lib.library_id,
800 dir.path(),
801 &settings,
802 false,
803 &tx,
804 notif_id,
805 &shutdown,
806 );
807 drop(tx);
808 let _events: Vec<Event> = rx.try_iter().collect();
809
810 let handles = lib.db.list_book_handles(lib.library_id).expect("handles");
811 let paths: Vec<_> = handles.iter().map(|h| h.relat.clone()).collect();
812
813 assert!(
814 paths.iter().any(|p| p.ends_with("book.epub")),
815 "epub should be imported"
816 );
817 assert!(
818 !paths.iter().any(|p| p.ends_with("ignore.xyz")),
819 "unsupported kind should not be imported"
820 );
821 }
822
823 #[test]
824 fn purges_disallowed_books_on_import() {
825 use crate::settings::FileExtension;
826 use fxhash::FxHashSet;
827
828 let dir = tempfile::tempdir().expect("tempdir");
829 let db = create_migrated_db();
830
831 std::fs::write(dir.path().join("book.epub"), b"epub content").expect("write epub");
832 std::fs::write(dir.path().join("doc.pdf"), b"pdf content").expect("write pdf");
833
834 let lib = Library::new(dir.path(), &db, "test").expect("library");
835 let (tx, rx) = std::sync::mpsc::channel();
836 let notif_id = ViewId::MessageNotif(0);
837 let shutdown = ShutdownSignal::never();
838
839 run(
840 &lib.db,
841 lib.library_id,
842 dir.path(),
843 &ImportSettings::default(),
844 false,
845 &tx,
846 notif_id,
847 &shutdown,
848 );
849 drop(tx);
850 let _: Vec<Event> = rx.try_iter().collect();
851
852 let handles = lib.db.list_book_handles(lib.library_id).expect("handles");
853 assert_eq!(handles.len(), 2, "both files should be imported initially");
854
855 let mut epub_only: FxHashSet<FileExtension> = FxHashSet::default();
856 epub_only.insert(FileExtension::Epub);
857
858 let settings = ImportSettings {
859 allowed_kinds: epub_only,
860 ..ImportSettings::default()
861 };
862
863 let (tx2, rx2) = std::sync::mpsc::channel();
864 run(
865 &lib.db,
866 lib.library_id,
867 dir.path(),
868 &settings,
869 false,
870 &tx2,
871 notif_id,
872 &shutdown,
873 );
874 drop(tx2);
875 let _: Vec<Event> = rx2.try_iter().collect();
876
877 let handles = lib
878 .db
879 .list_book_handles(lib.library_id)
880 .expect("handles after purge");
881 let paths: Vec<_> = handles.iter().map(|h| h.relat.clone()).collect();
882
883 assert_eq!(handles.len(), 1, "only epub should remain after purge");
884 assert!(
885 paths.iter().any(|p| p.ends_with("book.epub")),
886 "epub should still be present"
887 );
888 }
889}