//! TRACES: FR-CAT-10 | FR-CAT-11 | FR-NC-7a | FR-NC-7b //! The import page: state, and the callbacks that move it. //! //! Split from [`crate::import`] the way `library_ui` is split from `library`: //! that module knows how to bring a card in and nothing about windows, this one //! knows about windows and nothing about copying. //! //! # `render` is the only writer of window properties //! //! Every callback changes the controller and then calls [`render`], rather than //! setting the one property it touched. The settings page works the same way //! and for the same reason: several of these properties are derived from //! others — whether Import can be pressed depends on the source, the //! destination and whether a run is going — and a callback that updated only //! its own field would leave the derived ones describing a state that has //! passed. use std::cell::{Cell, RefCell}; use std::path::PathBuf; use std::rc::Rc; use slint::ComponentHandle; use dr_ingest::{DuplicatePolicy, Options, TransferMode, DEFAULT_TEMPLATE}; use dr_types::{FormatFilter, ImportSettings}; use crate::activity::{Activity, ActivityLog, Kind}; use crate::import::{self, Message, Request, Upload}; use crate::AppWindow; /// How often the drain runs while an import is going. /// /// The worker sends one message per file, and a card is two thousand files: at /// 120ms a burst of completions costs one redraw rather than a hundred, and a /// bar still looks live. The same interval the activity register publishes at. const DRAIN_INTERVAL: std::time::Duration = std::time::Duration::from_millis(120); /// What the caller has to supply that this module cannot know. /// /// A closure returning this rather than fields on the controller: an account /// can be signed out and a library re-opened while the page is on screen, and /// a value captured when the page was wired would be the *old* library. pub struct Context { /// The catalog, for duplicate detection (FR-CAT-11). pub catalog: PathBuf, /// The signed-in account, where there is one. `None` means the import is /// local-only, which is a perfectly good way to use the application. pub upload: Option, /// The library folder on the server. /// /// Shown on the page, because once an account is signed in "where do these /// end up" has two answers, and giving only the local one answers half the /// question the Import button is about to commit to. pub library_label: String, /// Which file types to take off a card. pub filter: FormatFilter, } /// Everything the import page renders from. pub struct ImportController { /// Persisted defaults, shared with the settings page so both write one /// file. An import's options are remembered between cards. settings: Rc, activity: Rc, volumes: RefCell>, /// Which volume is selected, or -1 for a typed path. selected: Cell, /// The source. Typed or chosen; a card the app did not find is still a card. card: RefCell, /// What the last survey found, `None` before one has run. survey: RefCell>, surveying: Cell, /// The server folder originals are uploaded to, as the page shows it. /// /// Cached when the page opens rather than fetched in `render`: the context /// closure reaches the account, and `render` runs on every keystroke. upload_target: RefCell, running: Cell, fraction: Cell, status: RefCell, error: RefCell, /// Set to stop the worker. Replaced per run. cancel: RefCell>, /// Held here rather than in the function that starts it: a `slint::Timer` /// stops the moment it is dropped, so a timer local to `start` would be /// collected before the first message arrived. timer: RefCell>, /// The register row, dropped when the run ends. See `activity`'s docs on /// why a job that stops reporting cannot hang the bar. job: RefCell>, } impl ImportController { pub fn new( settings: Rc, activity: Rc, ) -> Rc { Rc::new(Self { settings, activity, volumes: RefCell::new(Vec::new()), selected: Cell::new(-1), card: RefCell::new(String::new()), survey: RefCell::new(None), surveying: Cell::new(false), upload_target: RefCell::new(String::new()), running: Cell::new(false), fraction: Cell::new(0.0), status: RefCell::new(String::new()), error: RefCell::new(String::new()), cancel: RefCell::new(None), timer: RefCell::new(None), job: RefCell::new(None), }) } /// The import section of the stored settings. fn options(&self) -> ImportSettings { self.settings.settings.borrow().import.clone() } /// Change a stored option and write the file. fn edit(&self, f: impl FnOnce(&mut ImportSettings)) { let mut settings = self.settings.settings.borrow_mut(); f(&mut settings.import); let to_save = settings.clone(); drop(settings); if let Err(e) = self.settings.store.save(&to_save) { *self.error.borrow_mut() = format!("could not save the import settings: {e}"); } } /// Look for cards. Cheap, and safe to call whenever the page is shown. fn refresh_volumes(&self) { let found = dr_plat::volumes(); // Keep a typed path: a refresh must not discard what the user entered // because the app happened to find three other volumes. if self.card.borrow().is_empty() { if let Some(first) = found.iter().position(|v| v.is_likely_card()) { self.selected.set(first as i32); *self.card.borrow_mut() = found[first].path.display().to_string(); } } else { // Re-point the selection at whichever volume the path belongs to, // so a remembered path still highlights its row. let card = self.card.borrow().clone(); self.selected.set( found .iter() .position(|v| v.path.display().to_string() == card) .map(|i| i as i32) .unwrap_or(-1), ); } *self.volumes.borrow_mut() = found; } /// The transfer options a run is started with. fn ingest_options(&self) -> Options { let stored = self.options(); Options { folder_template: template_of(&stored), mode: if stored.move_from_card { TransferMode::Move } else { TransferMode::Copy }, on_duplicate: if stored.skip_duplicates { DuplicatePolicy::Skip } else { DuplicatePolicy::ImportAsNew }, verify: stored.verify, } } /// Whether Import can be pressed. /// /// Three things have to be true and all three are recoverable by the user, /// which is why the button is disabled rather than the press being refused /// with a message: a disabled button beside a filled-in form says which /// field is missing, where a dialogue says it after the fact. fn can_start(&self) -> bool { !self.running.get() && !self.card.borrow().is_empty() // An account, because the library is on the server and an import // with none has nowhere to go at all. && !self.upload_target.borrow().is_empty() && self.survey.borrow().map(|(n, _)| n > 0).unwrap_or(false) } } /// The template a run uses: the stored one, or the layout's default. /// /// Empty means "the default" rather than "no folders" — see `ImportSettings`. fn template_of(stored: &ImportSettings) -> String { if stored.folder_template.trim().is_empty() { DEFAULT_TEMPLATE.to_string() } else { stored.folder_template.clone() } } /// What the template does, shown against a real date. /// /// Uses *today* rather than a fixed example: a photographer importing today's /// shoot recognises today's folder, and a preview showing 2026-08-22 forever /// reads as a placeholder nobody has to check. fn preview(stored: &ImportSettings, now: i64) -> String { let shot = dr_ingest::Shot { captured_at: Some(now), make: Some("Canon".into()), model: Some("EOS R5".into()), ..Default::default() }; dr_ingest::expand(&template_of(stored), &shot).display() } /// Push the whole controller into the window. pub fn render(window: &AppWindow, ctl: &Rc) { let stored = ctl.options(); let volumes = ctl.volumes.borrow(); let labels: Vec = volumes.iter().map(|v| v.label.as_str().into()).collect(); let details: Vec = volumes.iter().map(|v| describe_volume(v).into()).collect(); window.set_import_volume_labels(slint::ModelRc::new(slint::VecModel::from(labels))); window.set_import_volume_details(slint::ModelRc::new(slint::VecModel::from(details))); window.set_import_volume_selected(ctl.selected.get()); let card = ctl.card.borrow().clone(); window.set_import_card_looks_right( !card.is_empty() && import::looks_like_a_card(std::path::Path::new(&card)), ); window.set_import_card_path(card.into()); window.set_import_surveying(ctl.surveying.get()); window.set_import_survey_summary( match *ctl.survey.borrow() { Some((0, _)) => "Nothing here to import".to_string(), Some((n, bytes)) => format!( "{n} photograph{} · {}", if n == 1 { "" } else { "s" }, crate::activity::describe_bytes(bytes) ), None => String::new(), } .into(), ); // The only destination there is. Empty means no account, which is what // keeps Import disabled — see `can_start`. window.set_import_upload_target(ctl.upload_target.borrow().as_str().into()); window.set_import_folder_template(stored.folder_template.as_str().into()); window.set_import_template_preview(preview(&stored, crate::library::now_secs()).into()); window.set_import_mode_labels(slint::ModelRc::new(slint::VecModel::from(vec![ slint::SharedString::from("Copy"), slint::SharedString::from("Move"), ]))); window.set_import_mode_selected(if stored.move_from_card { 1 } else { 0 }); window.set_import_mode_is_move(stored.move_from_card); window.set_import_duplicate_labels(slint::ModelRc::new(slint::VecModel::from(vec![ slint::SharedString::from("Skip"), slint::SharedString::from("Import again"), ]))); window.set_import_duplicate_selected(if stored.skip_duplicates { 0 } else { 1 }); window.set_import_verify(stored.verify); window.set_import_backup_on(!stored.backup.is_empty()); window.set_import_backup_path(stored.backup.as_str().into()); window.set_import_running(ctl.running.get()); window.set_import_fraction(ctl.fraction.get()); window.set_import_status(ctl.status.borrow().as_str().into()); window.set_import_error(ctl.error.borrow().as_str().into()); window.set_import_can_start(ctl.can_start()); } /// Why a volume is being offered. fn describe_volume(v: &dr_plat::Volume) -> String { let what = if v.has_dcim { "camera card" } else if v.removable { "removable volume" } else { "volume" }; format!("{what} · {}", v.path.display()) } /// Connect the page's callbacks. /// /// `context` is asked for afresh on every run rather than captured, because the /// account and library it describes can change while the page is open. /// `on_imported` runs after a successful import so the caller can rescan — this /// module does not catalogue what it wrote (see [`crate::import`]). pub fn wire(window: &AppWindow, ctl: Rc, context: C, on_imported: F) where C: Fn() -> Option + 'static, F: Fn() + 'static, { let context = Rc::new(context); let on_imported = Rc::new(on_imported); // --- opening and closing --------------------------------------------- { let weak = window.as_weak(); let ctl = ctl.clone(); let context = context.clone(); window.on_import_open(move || { let Some(w) = weak.upgrade() else { return }; // The button that reaches here is hidden where this is false, so // arriving anyway means a key binding or a stale window state. // Refused rather than shown empty. if !dr_plat::imports_supported() { return; } // Re-read the settings file for the same reason the settings page // does: another instance may have written it since. *ctl.settings.settings.borrow_mut() = ctl.settings.store.load(); *ctl.upload_target.borrow_mut() = context().map(|c| c.library_label).unwrap_or_default(); ctl.refresh_volumes(); *ctl.status.borrow_mut() = String::new(); *ctl.error.borrow_mut() = String::new(); survey(&w, &ctl, &context); render(&w, &ctl); w.set_show_import(true); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); window.on_import_close(move || { let Some(w) = weak.upgrade() else { return }; // The header disables Back while a run is going, so reaching here // mid-import would take the only view of the work off screen. if ctl.running.get() { return; } w.set_show_import(false); }); } // --- choosing a source ------------------------------------------------- { let weak = window.as_weak(); let ctl = ctl.clone(); let context = context.clone(); window.on_import_volume_picked(move |i| { let Some(w) = weak.upgrade() else { return }; let path = ctl .volumes .borrow() .get(i as usize) .map(|v| v.path.display().to_string()); if let Some(path) = path { ctl.selected.set(i); *ctl.card.borrow_mut() = path; survey(&w, &ctl, &context); } render(&w, &ctl); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); let context = context.clone(); window.on_import_card_path_changed(move |text| { let Some(w) = weak.upgrade() else { return }; *ctl.card.borrow_mut() = text.to_string(); ctl.selected.set(-1); survey(&w, &ctl, &context); render(&w, &ctl); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); window.on_import_refresh_volumes(move || { let Some(w) = weak.upgrade() else { return }; ctl.refresh_volumes(); render(&w, &ctl); }); } // --- options ----------------------------------------------------------- { let weak = window.as_weak(); let ctl = ctl.clone(); window.on_import_template_changed(move |text| { let Some(w) = weak.upgrade() else { return }; ctl.edit(|s| s.folder_template = text.to_string()); render(&w, &ctl); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); window.on_import_mode_picked(move |i| { let Some(w) = weak.upgrade() else { return }; ctl.edit(|s| s.move_from_card = i == 1); render(&w, &ctl); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); window.on_import_duplicate_picked(move |i| { let Some(w) = weak.upgrade() else { return }; ctl.edit(|s| s.skip_duplicates = i == 0); render(&w, &ctl); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); window.on_import_verify_toggled(move |on| { let Some(w) = weak.upgrade() else { return }; ctl.edit(|s| s.verify = on); render(&w, &ctl); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); window.on_import_backup_toggled(move |on| { let Some(w) = weak.upgrade() else { return }; // Turning it off clears the path rather than remembering it: a // second destination that reappears when the box is re-ticked is // a stale folder somebody unplugged months ago. ctl.edit(|s| { if !on { s.backup.clear(); } else if s.backup.is_empty() { s.backup = " ".into(); } }); render(&w, &ctl); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); window.on_import_backup_path_changed(move |text| { let Some(w) = weak.upgrade() else { return }; ctl.edit(|s| s.backup = text.to_string()); render(&w, &ctl); }); } // --- running ----------------------------------------------------------- { let weak = window.as_weak(); let ctl = ctl.clone(); let context = context.clone(); let on_imported = on_imported.clone(); window.on_import_start(move || { let Some(w) = weak.upgrade() else { return }; start(&w, &ctl, &context, &on_imported); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); window.on_import_stop(move || { let Some(w) = weak.upgrade() else { return }; if let Some(cancel) = ctl.cancel.borrow().as_ref() { cancel.store(true, std::sync::atomic::Ordering::Relaxed); } *ctl.status.borrow_mut() = "Stopping after the current file…".into(); render(&w, &ctl); }); } } /// Count what is on the card, without copying anything. /// /// Runs on the UI thread, which is defensible only because it is a directory /// walk with no reads: a full card is a couple of thousand `stat` calls. If a /// slow reader ever makes this visible, it moves to the worker beside the /// import — the message type already has a `Surveyed` variant for it. fn survey( window: &AppWindow, ctl: &Rc, context: &Rc Option>, ) { *ctl.survey.borrow_mut() = None; let card = ctl.card.borrow().clone(); if card.is_empty() { return; } let Some(context) = context() else { return }; let root = dr_types::RootId(9001); let storage = dr_plat::LocalStorage::with_root(root, PathBuf::from(&card)); match import::survey(&storage, root, &context.filter, &|| false) { Ok(found) => { let bytes = found.iter().map(|c| c.size).sum(); *ctl.survey.borrow_mut() = Some((found.len(), bytes)); } Err(e) => { *ctl.error.borrow_mut() = format!("could not read {card}: {e}"); } } render(window, ctl); } /// Begin an import. fn start( window: &AppWindow, ctl: &Rc, context: &Rc Option + 'static>, on_imported: &Rc, ) { if ctl.running.get() { return; } let Some(context) = context() else { *ctl.error.borrow_mut() = "no library is open".into(); render(window, ctl); return; }; let stored = ctl.options(); let backup = if stored.backup.trim().is_empty() { None } else { Some(PathBuf::from(stored.backup.trim())) }; let Some(upload) = context.upload else { *ctl.error.borrow_mut() = "Sign in first — the library this imports into is on the server.".into(); render(window, ctl); return; }; let request = Request { card: PathBuf::from(ctl.card.borrow().clone()), staging: upload.staging.clone(), backup, catalog: context.catalog.clone(), filter: context.filter.clone(), options: ctl.ingest_options(), upload, }; let cancel: import::Cancel = Default::default(); let rx = import::spawn(request, cancel.clone()); ctl.running.set(true); ctl.fraction.set(0.0); *ctl.cancel.borrow_mut() = Some(cancel); *ctl.error.borrow_mut() = String::new(); *ctl.status.borrow_mut() = "Reading the card…".into(); *ctl.job.borrow_mut() = Some(ctl.activity.begin(Kind::Download, "Importing")); render(window, ctl); let weak = window.as_weak(); let on_imported = on_imported.clone(); let timer = slint::Timer::default(); { // The closure owns its own handle: the timer outlives this function, // and `ctl` below still has to store it. let ctl = ctl.clone(); timer.start(slint::TimerMode::Repeated, DRAIN_INTERVAL, move || { let Some(w) = weak.upgrade() else { return }; drain(&w, &ctl, &rx, &on_imported); }); } *ctl.timer.borrow_mut() = Some(timer); } /// Take everything waiting on the channel and reflect it. fn drain( window: &AppWindow, ctl: &Rc, rx: &std::sync::mpsc::Receiver, on_imported: &Rc, ) { let mut finished = None; // `try_iter` rather than one message per tick: a card completes files // faster than the timer fires, and draining one at a time would leave the // bar minutes behind the work. for message in rx.try_iter() { match message { Message::Surveyed { files, bytes } => { *ctl.survey.borrow_mut() = Some((files, bytes)); ctl.surveying.set(false); if let Some(job) = ctl.job.borrow().as_ref() { job.total(files); } } Message::Progress { done, total, bytes, name, } => { ctl.fraction.set(if total == 0 { 0.0 } else { done as f32 / total as f32 }); *ctl.status.borrow_mut() = format!( "{done} of {total} — {name} ({})", crate::activity::describe_bytes(bytes) ); if let Some(job) = ctl.job.borrow().as_ref() { job.progress(done, total); } } Message::Uploading { done, total } => { ctl.fraction.set(if total == 0 { 0.0 } else { done as f32 / total as f32 }); *ctl.status.borrow_mut() = format!("Uploading {} of {total}…", done + 1); if let Some(job) = ctl.job.borrow().as_ref() { job.detail(format!("uploading {} of {total}", done + 1)); } } Message::Finished(outcome) => finished = Some(Ok(outcome)), Message::Failed(why) => finished = Some(Err(why)), } } let Some(result) = finished else { render(window, ctl); return; }; // Stop before reporting: the timer holds the receiver, and a tick after // the channel closes would spin for the rest of the session. ctl.timer.borrow_mut().take(); ctl.cancel.borrow_mut().take(); ctl.running.set(false); ctl.fraction.set(0.0); match result { Ok(outcome) => { let text = import::describe(&outcome); if let Some(job) = ctl.job.borrow_mut().take() { job.finish(text.clone()); } *ctl.status.borrow_mut() = text; // Only when something arrived: a run that imported nothing has // nothing for a scan to find, and a rescan of a large library is // not free. if outcome.imported > 0 { on_imported(); } } Err(why) => { if let Some(job) = ctl.job.borrow_mut().take() { job.fail(why.clone()); } *ctl.error.borrow_mut() = why; } } render(window, ctl); } #[cfg(test)] mod tests { use super::*; /// 2026-08-22T14:00:00Z. const AUG_22: i64 = 1_787_407_200; fn stored() -> ImportSettings { ImportSettings::default() } #[test] fn an_unset_template_falls_back_to_the_layouts_default() { // The default lives in one place — the expander — and empty here means // "whatever that is". assert_eq!(template_of(&stored()), DEFAULT_TEMPLATE); } #[test] fn a_chosen_template_is_used_as_written() { let s = ImportSettings { folder_template: "{yyyy}/{make}".into(), ..stored() }; assert_eq!(template_of(&s), "{yyyy}/{make}"); } #[test] fn whitespace_is_not_a_template() { // A field cleared by selecting and deleting often leaves a space // behind, and a folder called " " is not what anybody meant. let s = ImportSettings { folder_template: " ".into(), ..stored() }; assert_eq!(template_of(&s), DEFAULT_TEMPLATE); } #[test] fn the_preview_shows_what_the_template_will_do() { assert_eq!(preview(&stored(), AUG_22), "2026/2026-08-22"); let s = ImportSettings { folder_template: "{yyyy}/{model}".into(), ..stored() }; assert_eq!(preview(&s, AUG_22), "2026/EOS R5"); } #[test] fn an_import_needs_an_account_before_it_can_start() { // The library is on the server, so "where does this go" has no local // answer to fall back on. Import stays disabled rather than quietly // filling a folder nothing ever shows. let ctl = ImportController::new( crate::settings_ui::SettingsController::new(), crate::activity::ActivityLog::new(), ); *ctl.card.borrow_mut() = "/run/media/duncan/EOS DIGITAL".into(); *ctl.survey.borrow_mut() = Some((12, 1234)); assert!(!ctl.can_start(), "no account, yet Import was offered"); *ctl.upload_target.borrow_mut() = "PhotosRaw".into(); assert!(ctl.can_start()); } #[test] fn an_empty_card_cannot_be_imported_either() { let ctl = ImportController::new( crate::settings_ui::SettingsController::new(), crate::activity::ActivityLog::new(), ); *ctl.card.borrow_mut() = "/run/media/duncan/EOS DIGITAL".into(); *ctl.upload_target.borrow_mut() = "PhotosRaw".into(); *ctl.survey.borrow_mut() = Some((0, 0)); assert!(!ctl.can_start()); } #[test] fn a_volume_says_why_it_is_being_offered() { // "EOS DIGITAL" and "archive" look equally plausible in a list, and // only one of them is what was just plugged in. let card = dr_plat::Volume { label: "EOS DIGITAL".into(), path: "/run/media/duncan/EOS DIGITAL".into(), removable: true, has_dcim: true, }; assert!(describe_volume(&card).starts_with("camera card · ")); let drive = dr_plat::Volume { has_dcim: false, ..card.clone() }; assert!(describe_volume(&drive).starts_with("removable volume · ")); let fixed = dr_plat::Volume { removable: false, has_dcim: false, ..card }; assert!(describe_volume(&fixed).starts_with("volume · ")); } }