diff --git a/core/dr-types/src/lib.rs b/core/dr-types/src/lib.rs index 717e434..4b155fc 100644 --- a/core/dr-types/src/lib.rs +++ b/core/dr-types/src/lib.rs @@ -17,7 +17,7 @@ pub use colour::{Chromaticities, Transfer}; pub use selector::{ColourLabel, DateSelector, FlagState, Selector, Tier}; pub use settings::{ CacheSettings, CollisionPolicy, ColourSpace, DevelopSettings, ExportFormat, ExportSettings, - ExportTarget, OutputSharpening, Settings, SizingMode, + ExportTarget, ImportSettings, OutputSharpening, Settings, SizingMode, }; pub use time::{civil_from_unix, civil_from_unix_at, format_date, Civil}; diff --git a/core/dr-types/src/settings.rs b/core/dr-types/src/settings.rs index e2eb157..f102d5d 100644 --- a/core/dr-types/src/settings.rs +++ b/core/dr-types/src/settings.rs @@ -52,6 +52,72 @@ pub struct Settings { pub cache: CacheSettings, pub export: ExportSettings, pub develop: DevelopSettings, + pub import: ImportSettings, +} + +// --------------------------------------------------------------------------- +// Import +// --------------------------------------------------------------------------- + +/// TRACES: FR-CAT-10 | FR-NC-7a +/// What an import defaults to, remembered between cards. +/// +/// A photographer imports the same way every time — same folders, same +/// destination, same answer about the card — and retyping a template on every +/// card is the kind of friction that makes people stop using the importer. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct ImportSettings { + /// The folder layout (FR-NC-7a). + /// + /// **Empty means the layout module's default**, rather than this file + /// repeating `{yyyy}/{yyyy}-{mm}-{dd}`. Two spellings of a default is one + /// that goes stale, and the expander is where the token vocabulary is + /// defined — so it owns the default too, and this records only a + /// deliberate departure from it. + pub folder_template: String, + /// Where originals are copied to on this machine. Empty until chosen. + /// + /// A local folder even on a library that lives on a server: FR-NC-7b has + /// the bytes land and be verified here first, and the upload follow. + pub destination: String, + /// The optional second copy (FR-CAT-10). Empty means none. + pub backup: String, + /// Whether to send imported originals on to the server (FR-NC-7b). + pub upload: bool, + /// Whether the card is to be emptied once everything is confirmed. + /// + /// **Defaults to false and is the one setting worth not remembering + /// eagerly.** A move-import is the only irreversible thing this + /// application does, and a preference that quietly persists "yes, erase + /// the card" across sessions turns a considered decision into a default. + pub move_from_card: bool, + /// Whether each copy is read back and compared (FR-CAT-10). + pub verify: bool, + /// Whether photographs already in the catalog are skipped (FR-CAT-11). + pub skip_duplicates: bool, +} + +/// Written out rather than derived, because three of these must not be +/// `false`. +/// +/// `#[serde(default)]` uses this for a field a stored file is missing *and* +/// for the whole section when a settings file predates it — which every +/// existing file does. A derived `Default` would silently turn verification +/// off and duplicate skipping off for every user who already has one. +impl Default for ImportSettings { + fn default() -> Self { + Self { + folder_template: String::new(), + destination: String::new(), + backup: String::new(), + upload: true, + // The one irreversible thing this application does starts off. + move_from_card: false, + verify: true, + skip_duplicates: true, + } + } } // --------------------------------------------------------------------------- @@ -1014,3 +1080,41 @@ mod tests { assert_eq!(budget::label(None), "Unlimited"); } } + +#[cfg(test)] +mod import_settings_tests { + use super::*; + + #[test] + fn an_existing_settings_file_gains_the_safe_defaults() { + // Every settings file on disk predates this section. Coming back with + // verification off would silently remove the check that catches a + // dying card, on precisely the machines that have been running + // longest. + let stored = r#"{"cache":{},"export":{},"develop":{}}"#; + let s: Settings = serde_json::from_str(stored).unwrap(); + assert!(s.import.verify); + assert!(s.import.skip_duplicates); + assert!(s.import.upload); + // And the irreversible one stays off. + assert!(!s.import.move_from_card); + } + + #[test] + fn an_unset_template_defers_to_the_layout_rather_than_repeating_it() { + // Empty is meaningful: it means "whatever the expander's default is", + // so the default lives in exactly one place. + assert_eq!(Settings::default().import.folder_template, ""); + } + + #[test] + fn a_deliberate_choice_survives_a_round_trip() { + let mut s = Settings::default(); + s.import.folder_template = "{yyyy}/{make}".into(); + s.import.move_from_card = true; + s.import.verify = false; + let text = serde_json::to_string(&s).unwrap(); + let back: Settings = serde_json::from_str(&text).unwrap(); + assert_eq!(back.import, s.import); + } +} diff --git a/ui/dr-ui/src/import.rs b/ui/dr-ui/src/import.rs index fd1d807..bd2eb97 100644 --- a/ui/dr-ui/src/import.rs +++ b/ui/dr-ui/src/import.rs @@ -35,6 +35,7 @@ use std::sync::Arc; use dr_ingest::{Candidate, DupKey, Imported, Ingest, Options, Report, Shot}; use dr_plat::{DirRef, LocalStorage, Storage, WritableStorage}; +use dr_sync_nextcloud::{AppCredentials, NextcloudBackend}; use dr_types::{FormatFilter, RootId}; /// Which root the card is granted as, and which the library is. @@ -63,13 +64,37 @@ pub struct Request { /// A path rather than a connection: `rusqlite::Connection` is not `Sync`, /// and every other worker in this crate opens its own for the same reason. pub catalog: PathBuf, - /// The library's root id *in the catalog*, which is what - /// [`dr_catalog::set_content_hash`] matches on. Unrelated to [`LIBRARY`], - /// which is this module's handle on the same folder. - pub catalog_root: u64, /// Which file types to take off the card. pub filter: FormatFilter, pub options: Options, + /// Where to send the originals afterwards, if anywhere (FR-NC-7b). + pub upload: Option, +} + +/// TRACES: FR-NC-7a | FR-NC-7b +/// Sending the imported originals on to the server. +/// +/// Deliberately a second phase rather than a destination the copy writes +/// straight to. FR-NC-7b: files are copied locally and verified *first*, and +/// only then queued for upload — so a network that fails costs an upload, not +/// an import, and the photographs exist on disk either way. +#[derive(Clone)] +pub struct Upload { + pub credentials: AppCredentials, + pub user_id: String, + /// The library folder on the server. The dated folders from the template + /// are created beneath it, the same ones the local copy went into. + pub library: String, +} + +impl std::fmt::Debug for Upload { + /// Hand-written so a credential cannot reach a log through a `{:?}`. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Upload") + .field("user_id", &self.user_id) + .field("library", &self.library) + .finish_non_exhaustive() + } } /// What the worker sends back. @@ -84,6 +109,8 @@ pub enum Message { bytes: u64, name: String, }, + /// Originals going up, after every one of them is safely on disk. + Uploading { done: usize, total: usize }, /// The run ended. Always the last message. Finished(Outcome), /// The run could not start at all. @@ -107,9 +134,19 @@ pub struct Outcome { pub folders: Vec, /// Card files that may now be deleted, for a move-import (FR-NC-7b). /// - /// Carried out rather than acted on here: this crate knows the local write - /// succeeded, and not whether the upload did. + /// Only files that are *both* verified on disk and, where an upload was + /// asked for, confirmed on the server. A card erased against a failed + /// upload is unrecoverable, so this is the one count computed + /// pessimistically. pub retirable: usize, + /// Originals confirmed on the server. + pub uploaded: usize, + /// Originals that stayed local because the upload did not go through. + /// + /// Not a failure of the import: the photographs are on disk and verified, + /// and the card has not been touched. Reported so the user knows the + /// server does not have them yet. + pub upload_failed: usize, } /// Start an import. @@ -160,7 +197,9 @@ fn run(request: Request, cancel: &Cancel, tx: &Sender) -> Result<(), St source: &card, dest: &library, dest_root: &library_root, - backup: backup.as_ref().map(|b| (b as &dyn WritableStorage, &backup_root)), + backup: backup + .as_ref() + .map(|b| (b as &dyn WritableStorage, &backup_root)), options: request.options.clone(), }; @@ -179,16 +218,115 @@ fn run(request: Request, cancel: &Cancel, tx: &Sender) -> Result<(), St }, ); - // The digests, so the *next* import can answer the content tier without - // reading anything. Best-effort and after the fact: the rows do not exist - // until a scan has caught up, and a hash that misses its row costs one - // wasted transfer next time rather than a failure now. - record_digests(catalog.connection(), request.catalog_root, &report.imported); + let mut outcome = summarise(&report); - let _ = tx.send(Message::Finished(summarise(&report))); + // FR-NC-7b. Everything above has already finished: every file is on disk + // and its digest checked, so nothing from here on can cost the user a + // photograph — only a transfer. + if let Some(upload) = &request.upload { + let sent = upload_all(upload, &library, &report.imported, cancel, tx); + outcome.uploaded = sent.len(); + outcome.upload_failed = report.imported.len() - sent.len(); + // The card keeps its copies of anything the server did not take. + outcome.retirable = outcome.retirable.min(outcome.uploaded); + + // The digests, so the *next* import can answer the content tier + // without reading anything. + // + // Recorded against the **remote** path rather than the local one, + // because the catalog holds the server's library: the local copy has + // no row and never will. Best-effort and after the fact — the row does + // not exist until a sync has seen the upload, and a hash that misses + // its row costs one wasted transfer next time rather than a failure + // now. The metadata tier covers the interval, which is why this is not + // worth waiting for a sync to do properly. + record_digests(catalog.connection(), &upload.library, &sent); + } + + let _ = tx.send(Message::Finished(outcome)); Ok(()) } +/// Send the imported originals to the server, in their dated folders. +/// +/// Returns the remote path and digest of each original the server confirmed. +/// A failure here is reported +/// and not propagated: the import succeeded, and turning "the network was +/// slow" into a failed import would misdescribe what happened to the +/// photographs and, on a move-import, would be the difference between a card +/// kept and a card emptied. +fn upload_all( + upload: &Upload, + library: &LocalStorage, + imported: &[Imported], + cancel: &Cancel, + tx: &Sender, +) -> Vec<(String, String)> { + let rt = match crate::net_runtime::build() { + Ok(rt) => rt, + Err(e) => { + log::warn!("no runtime for the upload: {e}"); + return Vec::new(); + } + }; + + rt.block_on(async { + let backend = match NextcloudBackend::new(&upload.credentials, &upload.user_id) { + Ok(b) => b, + Err(e) => { + log::warn!("connecting to upload: {e}"); + return Vec::new(); + } + }; + let root = dr_sync::RemotePath::new(&upload.library); + + let mut sent = Vec::new(); + for (i, image) in imported.iter().enumerate() { + if cancel.load(Ordering::Relaxed) { + // Everything not yet sent stays local, and the card keeps its + // copies of all of it. + break; + } + let _ = tx.send(Message::Uploading { + done: i, + total: imported.len(), + }); + + // Read from the library rather than the card: this is the copy + // that was verified, and the card may already be unplugged. + let bytes = match read_all(library, image) { + Ok(b) => b, + Err(e) => { + log::warn!("reading {} to upload it: {e}", image.name); + continue; + } + }; + + // The same folder segments the local copy went into, so the two + // libraries have the same shape (FR-NC-7a). + match dr_sync::upload_original(&backend, &root, &image.folders, &image.name, bytes) + .await + { + Ok((path, _)) => { + log::info!("uploaded {path}"); + sent.push((path.as_str().to_string(), image.digest.clone())); + } + Err(e) => log::warn!("uploading {}: {e}", image.name), + } + } + sent + }) +} + +/// Read an imported file back out of the library. +fn read_all(library: &LocalStorage, image: &Imported) -> Result, String> { + use std::io::Read; + let mut stream = library.open(&image.written).map_err(|e| e.to_string())?; + let mut bytes = Vec::with_capacity(image.size as usize); + stream.read_to_end(&mut bytes).map_err(|e| e.to_string())?; + Ok(bytes) +} + /// Walk a card for files worth importing. /// /// Recursive rather than `DCIM`-only: a card carries `DCIM/100CANON`, a phone @@ -289,11 +427,27 @@ fn is_duplicate(conn: &rusqlite::Connection, key: &DupKey) -> bool { } /// Store what the import learned that a scan cannot. -fn record_digests(conn: &rusqlite::Connection, root: u64, imported: &[Imported]) { - for image in imported { - if let Err(e) = dr_catalog::set_content_hash(conn, root, image.written.key(), &image.digest) - { - log::warn!("recording the digest of {}: {e}", image.name); +/// +/// A scan never reads a whole file, so `content_hash` is only ever filled in +/// by something that had a reason to read every byte — which an import did. +fn record_digests(conn: &rusqlite::Connection, library: &str, sent: &[(String, String)]) { + // The library's row, resolved the same way the remote scan resolves it: + // one root per library folder, keyed by label. + let root: Option = conn + .query_row( + "SELECT id FROM roots WHERE label = ?1 AND kind = 'remote'", + [library], + |r| r.get(0), + ) + .ok(); + let Some(root) = root else { + // No root yet means no sync has run against this library, so there is + // nothing to attach a digest to. Not an error. + return; + }; + for (path, digest) in sent { + if let Err(e) = dr_catalog::set_content_hash(conn, root as u64, path, digest) { + log::warn!("recording the digest of {path}: {e}"); } } } @@ -317,6 +471,9 @@ pub fn summarise(report: &Report) -> Outcome { cancelled: report.cancelled, folders, retirable: report.retirable.len(), + // Filled in by the upload phase, which runs after this. + uploaded: 0, + upload_failed: 0, } } @@ -361,6 +518,19 @@ pub fn describe(outcome: &Outcome) -> String { n => out.push_str(&format!(" into {n} folders")), } + // What the server got. Said plainly rather than folded into the import + // count: "imported" and "uploaded" are different promises, and a user on a + // failing connection needs to know which one held. + if outcome.uploaded > 0 || outcome.upload_failed > 0 { + out.push_str(&format!(". {} uploaded", outcome.uploaded)); + if outcome.upload_failed > 0 { + out.push_str(&format!( + ", {} still only on this computer", + outcome.upload_failed + )); + } + } + // FR-NC-7a: the mtime fallback is reported rather than silent. if !outcome.undated.is_empty() { out.push_str(&format!( @@ -537,7 +707,11 @@ mod tests { cancelled: true, ..outcome() }; - assert!(describe(&o).starts_with("Stopped — 3 imported"), "{}", describe(&o)); + assert!( + describe(&o).starts_with("Stopped — 3 imported"), + "{}", + describe(&o) + ); } #[test] diff --git a/ui/dr-ui/src/import_ui.rs b/ui/dr-ui/src/import_ui.rs new file mode 100644 index 0000000..caba755 --- /dev/null +++ b/ui/dr-ui/src/import_ui.rs @@ -0,0 +1,746 @@ +//! 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, Outcome, 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, for the label the page shows. + 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, + + 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), + 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() + && !self.options().destination.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(), + ); + + window.set_import_destination(stored.destination.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 }; + // 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(); + + // A destination the user has never chosen defaults to somewhere + // that exists, rather than to an empty field they must decode. + if ctl.options().destination.is_empty() { + if let Some(home) = default_destination() { + ctl.edit(|s| s.destination = home.display().to_string()); + } + } + let _ = context(); + 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); + }); + } +} + +/// Somewhere sensible for a first import to go. +fn default_destination() -> Option { + let home = std::env::var_os("HOME").map(PathBuf::from)?; + let pictures = home.join("Pictures"); + // Only if it exists: inventing a folder the user has not asked for, in a + // field they may not read, is how photographs end up somewhere nobody + // looks. An empty field keeps Import disabled until they choose. + pictures.exists().then_some(pictures) +} + +/// 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 = stored + .backup + .trim() + .is_empty() + .then_some(None) + .unwrap_or_else(|| Some(PathBuf::from(stored.backup.trim()))); + + let request = Request { + card: PathBuf::from(ctl.card.borrow().clone()), + library: PathBuf::from(&stored.destination), + backup, + catalog: context.catalog.clone(), + filter: context.filter.clone(), + options: ctl.ingest_options(), + upload: stored.upload.then_some(context.upload).flatten(), + }; + + 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 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 · ")); + } +} diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index 606401b..e22853a 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -27,14 +27,15 @@ mod export; mod gradient; mod histogram; mod import; +mod import_ui; mod labels; mod library; mod library_ui; #[cfg(live_style)] mod live_style; +mod masks_ui; mod net_runtime; mod presets; -mod masks_ui; mod segmentation; mod settings_store; mod settings_ui; @@ -895,6 +896,51 @@ pub fn run(paths: Vec) -> Result<()> { // the settings page edits, and a controller scoped to the wiring block // would be gone by the time that callback is built. let settings = settings_ui::SettingsController::new(); + + // TRACES: FR-CAT-10 | FR-CAT-11 | FR-NC-7a | FR-NC-7b + // Import: a card into the library, and on to the server. + // + // Wired after the settings controller exists because it shares it — an + // import's options are stored in the same file as everything else, and two + // controllers each holding their own copy would each save over the other. + // + // The account is fetched afresh inside the closure rather than captured: + // this runs once at startup, where a library can be opened, closed and + // re-opened for the whole life of the window. + { + let import = import_ui::ImportController::new(settings.clone(), activity.clone()); + let library_for_context = library.clone(); + let weak = window.as_weak(); + import_ui::wire( + &window, + import, + move || { + let (creds, session) = library_for_context.session()?; + Some(import_ui::Context { + catalog: library::catalog_path(&session.server, &session.user_id), + library_label: session.root.clone(), + // The same formats the scan looks for. An import that took + // types the library then ignores would copy files off the + // card that never appear in the grid. + filter: session.format_filter(), + upload: Some(import::Upload { + credentials: creds, + user_id: session.user_id.clone(), + library: session.root.clone(), + }), + }) + }, + move || { + // The import wrote files into a folder on this machine and, if + // the account allowed it, into the library on the server. Only + // the second is what the grid shows, so this asks for the scan + // that finds them rather than inserting rows itself. + if let Some(w) = weak.upgrade() { + w.invoke_library_rescan(); + } + }, + ); + } { // What the cache actually holds, so the ceiling above it is a figure // the user can judge rather than an abstract one. diff --git a/ui/dr-ui/src/library.rs b/ui/dr-ui/src/library.rs index a25a035..f3bcfde 100644 --- a/ui/dr-ui/src/library.rs +++ b/ui/dr-ui/src/library.rs @@ -2832,7 +2832,7 @@ pub fn total_images(catalog: &Catalog) -> Result i64 { +pub fn now_secs() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index ce62ec6..a4e9967 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -7,6 +7,7 @@ import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, Progres import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint"; import { HistogramPanel, HistogramView } from "histogram.slint"; import { SettingsPage } from "settings.slint"; +import { ImportPage } from "import.slint"; export { LibraryCell, TimelineBar, CollectionRow, ActivityRow, HistogramView } export { ViewMode, GradientHandle, HandleRole } @@ -729,6 +730,55 @@ export component AppWindow inherits Window { // dirty state here and nothing to confirm on the way out. in-out property show-settings: false; + // --- import (FR-CAT-10, FR-CAT-11, FR-NC-7a) ------------------------- + // + // A page like Settings, and outermost with it: an import is reachable from + // the library, and it writes into the library, so it must not be nested + // inside the view it is about to change under. + // + // Unlike Settings, this page holds a *decision* rather than a set of saved + // preferences, which is why it carries `import-can-start` and a running + // state: everything here exists to be committed or abandoned. + in-out property show-import: false; + + in property <[string]> import-volume-labels; + in property <[string]> import-volume-details; + in property import-volume-selected: -1; + in property import-card-path: ""; + in property import-card-looks-right: false; + in property import-surveying: false; + in property import-survey-summary: ""; + in property import-destination: ""; + in property import-folder-template: ""; + in property import-template-preview: ""; + in property <[string]> import-mode-labels; + in property import-mode-selected: 0; + in property import-mode-is-move: false; + in property <[string]> import-duplicate-labels; + in property import-duplicate-selected: 0; + in property import-verify: true; + in property import-backup-on: false; + in property import-backup-path: ""; + in property import-running: false; + in property import-fraction: 0.0; + in property import-status: ""; + in property import-error: ""; + in property import-can-start: false; + + callback import-open(); + callback import-close(); + callback import-volume-picked(int); + callback import-card-path-changed(string); + callback import-refresh-volumes(); + callback import-template-changed(string); + callback import-mode-picked(int); + callback import-duplicate-picked(int); + callback import-verify-toggled(bool); + callback import-backup-toggled(bool); + callback import-backup-path-changed(string); + callback import-start(); + callback import-stop(); + in property settings-original-budget: ""; in property settings-original-unlimited: false; in property settings-thumbnail-budget: ""; @@ -1000,7 +1050,53 @@ in property panel-visible: true; // Settings, over everything. First in the file and first in z-order so the // conditions below can be read as "and settings is not open". - if root.show-settings: SettingsPage { + // Outermost with the settings page, and before it: both are reachable + // from the library and from develop, so guarding either with + // `!show-library` would make which view you came from decide whether + // it appears. + if root.show-import: ImportPage { + width: 100%; + height: 100%; + + volume-labels: root.import-volume-labels; + volume-details: root.import-volume-details; + volume-selected: root.import-volume-selected; + card-path: root.import-card-path; + card-looks-right: root.import-card-looks-right; + surveying: root.import-surveying; + survey-summary: root.import-survey-summary; + destination: root.import-destination; + folder-template: root.import-folder-template; + template-preview: root.import-template-preview; + mode-labels: root.import-mode-labels; + mode-selected: root.import-mode-selected; + mode-is-move: root.import-mode-is-move; + duplicate-labels: root.import-duplicate-labels; + duplicate-selected: root.import-duplicate-selected; + verify: root.import-verify; + backup-on: root.import-backup-on; + backup-path: root.import-backup-path; + running: root.import-running; + fraction: root.import-fraction; + status: root.import-status; + error: root.import-error; + can-start: root.import-can-start; + + volume-picked(i) => { root.import-volume-picked(i); } + card-path-changed(t) => { root.import-card-path-changed(t); } + refresh-volumes() => { root.import-refresh-volumes(); } + template-changed(t) => { root.import-template-changed(t); } + mode-picked(i) => { root.import-mode-picked(i); } + duplicate-picked(i) => { root.import-duplicate-picked(i); } + verify-toggled(on) => { root.import-verify-toggled(on); } + backup-toggled(on) => { root.import-backup-toggled(on); } + backup-path-changed(t) => { root.import-backup-path-changed(t); } + begin() => { root.import-start(); } + stop() => { root.import-stop(); } + close() => { root.import-close(); } + } + + if !root.show-import && root.show-settings: SettingsPage { width: 100%; height: 100%; @@ -1084,7 +1180,7 @@ in property panel-visible: true; // The launch screen replaces the whole window rather than overlaying it: // there is no library to look at until an account is configured. - if !root.show-settings && root.show-launch: LaunchScreen { + if !root.show-import && !root.show-settings && root.show-launch: LaunchScreen { width: 100%; height: 100%; signed-in: root.launch-signed-in; @@ -1124,7 +1220,7 @@ in property panel-visible: true; // has been opened but no image chosen yet. The collections sidebar and the // grid are siblings here rather than the sidebar living inside the grid, // because the drag that connects them has to be owned above both. - if !root.show-settings && !root.show-launch && root.show-library: Rectangle { + if !root.show-import && !root.show-settings && !root.show-launch && root.show-library: Rectangle { width: 100%; height: 100%; background: Theme.ground; @@ -1253,6 +1349,7 @@ in property panel-visible: true; cell-clicked(i) => { root.library-cell-clicked(i); } rescan() => { root.library-rescan(); } change-library() => { root.library-change(); } + open-import() => { root.import-open(); } open-settings() => { root.settings-open(); } cell-pressed(i, ctrl, shift) => { @@ -1315,7 +1412,7 @@ in property panel-visible: true; } } - if !root.show-settings && !root.show-launch && !root.show-library: VerticalLayout { + if !root.show-import && !root.show-settings && !root.show-launch && !root.show-library: VerticalLayout { width: 100%; height: 100%; diff --git a/ui/dr-ui/ui/import.slint b/ui/dr-ui/ui/import.slint new file mode 100644 index 0000000..0cd4725 --- /dev/null +++ b/ui/dr-ui/ui/import.slint @@ -0,0 +1,361 @@ +import { Theme } from "theme.slint"; +import { Button, PanelHeading, Label, Value, Caption, Panel, ProgressBar } from "widgets.slint"; +import { Segmented, TextRow, Check, ChoiceChip } from "controls.slint"; + +// TRACES: FR-CAT-10 | FR-CAT-11 | FR-NC-7a +// Import: bringing a card into the library. +// +// A full-window page, the same shape as Settings and for the first of the same +// reasons — it refers to the library constantly, and a modal that has to be +// dismissed to check where something went is a dialogue arguing with itself. +// +// The second reason does not apply and produces the one structural difference: +// settings save on change and have nothing to confirm, where an import is a +// decision. So this page has a commit button, and everything above it is the +// sentence that button completes: take *these* files, from *there*, and put +// them *here*. +// +// # Why the destination is shown and not editable +// +// A photograph goes into the library, and the library is chosen at launch. +// Offering a second place to set it here would let the two disagree, and the +// disagreement would be discovered as a folder of photographs the grid does +// not show. What *is* editable is the layout within it (FR-NC-7a), which is a +// different question with a visible answer right beside it. + +// One volume the app found, as a row that can be picked. +// +// The subtitle carries why it is being offered — a camera card, a removable +// drive — because "EOS DIGITAL" and "archive" look equally plausible in a list +// and only one of them is what the user just plugged in. +component VolumeRow inherits Rectangle { + in property label; + in property detail; + in property selected: false; + callback clicked(); + + height: 44px; + border-radius: Theme.radius-sm; + background: root.selected ? Theme.selected + : (touch.pressed ? Theme.pressed + : (touch.has-hover ? Theme.hover : transparent)); + border-width: root.selected ? 1px : 0px; + border-color: Theme.selected-ring; + + touch := TouchArea { + clicked => { root.clicked(); } + } + + HorizontalLayout { + padding-left: Theme.gap; + padding-right: Theme.gap; + spacing: Theme.gap; + + VerticalLayout { + alignment: center; + spacing: 1px; + Label { text: root.label; body: true; } + Caption { text: root.detail; } + } + + Rectangle { horizontal-stretch: 1; } + } +} + +export component ImportPage inherits Rectangle { + // --- where the photographs are coming from --------------------------- + in property <[string]> volume-labels; + in property <[string]> volume-details; + /// -1 while nothing is chosen, which is what the page opens on. + in property volume-selected: -1; + /// The chosen source, also typable — a card mounted somewhere this app + /// does not think to look is still a card. + in-out property card-path; + /// Whether the source holds a DCIM folder. Advisory: plenty of legitimate + /// sources do not, so this informs and never blocks. + in property card-looks-right: false; + + callback volume-picked(int); + callback card-path-changed(string); + callback refresh-volumes(); + + // --- what is on it --------------------------------------------------- + in property surveying: false; + /// "1,847 photographs · 61.2 GB". Empty before anything has been counted. + in property survey-summary; + + // --- where they are going -------------------------------------------- + /// The library root. Shown, not editable — see the header comment. + in property destination; + in-out property folder-template; + /// The template expanded against a real date, so the answer to "what will + /// this do" is on screen rather than in the manual. + in property template-preview; + + in property <[string]> mode-labels; + in property mode-selected: 0; + /// Whether the chosen mode will eventually erase the card. + in property mode-is-move: false; + + in property <[string]> duplicate-labels; + in property duplicate-selected: 0; + in property verify: true; + in property backup-on: false; + in-out property backup-path; + + callback template-changed(string); + callback mode-picked(int); + callback duplicate-picked(int); + callback verify-toggled(bool); + callback backup-toggled(bool); + callback backup-path-changed(string); + + // --- doing it -------------------------------------------------------- + in property running: false; + in property fraction: 0; + /// What it is doing now, or what it did — one line, always present once + /// something has happened. + in property status; + in property error; + /// Whether Import can be pressed at all. + in property can-start: false; + + callback begin(); + callback stop(); + callback close(); + + background: Theme.ground; + + // Somewhere for a key to start from, so Android's back gesture closes the + // page rather than the application. Same reasoning as the settings page. + FocusScope { + width: 0px; + height: 0px; + init => { self.focus(); } + } + + VerticalLayout { + // --- header ------------------------------------------------------ + Rectangle { + height: 44px; + background: Theme.surface; + + HorizontalLayout { + padding-left: Theme.gap; + padding-right: Theme.gap; + spacing: Theme.gap; + + Button { + text: "‹ Back"; + y: (parent.height - self.height) / 2; + // Leaving mid-import would abandon a worker with no way + // back to it: the page is the only thing that reports what + // it is doing. + enabled: !root.running; + clicked => { root.close(); } + } + + Value { text: "Import"; } + + Rectangle { horizontal-stretch: 1; } + + if root.running: Button { + text: "Stop"; + y: (parent.height - self.height) / 2; + clicked => { root.stop(); } + } + + if !root.running: Button { + text: "Import"; + y: (parent.height - self.height) / 2; + enabled: root.can-start; + clicked => { root.begin(); } + } + } + + Rectangle { + y: parent.height - 1px; + height: 1px; + background: Theme.rule; + } + } + + // The bar sits directly under the header while a run is going, which + // is where every other progress bar in this application is. + if root.running: ProgressBar { + fraction: root.fraction; + indeterminate: root.surveying; + } + + if root.error != "": Rectangle { + height: 32px; + background: Theme.surface; + HorizontalLayout { + padding-left: Theme.gap; + padding-right: Theme.gap; + Caption { text: root.error; warn: true; overflow: elide; } + } + } + + // Named child plus an explicit viewport height, as the settings page + // does it: a Flickable cannot measure an anonymous layout, and scrolls + // nowhere without being told how tall its contents are. + Flickable { + vertical-stretch: 1; + viewport-height: content.preferred-height; + + content := VerticalLayout { + width: 100%; + padding: Theme.gap; + spacing: Theme.gap; + alignment: start; + + // --- source ------------------------------------------------ + Panel { + PanelHeading { text: "From"; } + + if root.volume-labels.length == 0: Caption { + text: "No removable volume found. Type where the card is mounted, or plug it in and refresh."; + wrap: word-wrap; + } + + for label[i] in root.volume-labels: VolumeRow { + label: label; + detail: root.volume-details[i]; + selected: i == root.volume-selected; + clicked => { root.volume-picked(i); } + } + + TextRow { + label: "Location"; + text <=> root.card-path; + placeholder: "/run/media/…"; + field-width: 320px; + enabled: !root.running; + accepted(t) => { root.card-path-changed(t); } + } + + HorizontalLayout { + spacing: Theme.gap; + Button { + text: "Refresh"; + enabled: !root.running; + clicked => { root.refresh-volumes(); } + } + // Says what was found the moment it is known, so the + // user is not asked to press Import on faith. + Caption { + text: root.surveying ? "Looking…" + : (root.survey-summary != "" ? root.survey-summary : ""); + vertical-alignment: center; + } + Rectangle { horizontal-stretch: 1; } + } + + if root.card-path != "" && !root.card-looks-right && !root.surveying: Caption { + // Advisory, not a block: an archive folder is a + // legitimate source and has no DCIM either. + text: "No DCIM folder here — check this is the card and not somewhere else."; + wrap: word-wrap; + } + } + + // --- destination ------------------------------------------- + Panel { + PanelHeading { text: "Into"; } + + HorizontalLayout { + spacing: Theme.gap; + Label { text: "Library"; } + Value { + text: root.destination; + overflow: elide; + horizontal-stretch: 1; + } + } + + TextRow { + label: "Folders"; + text <=> root.folder-template; + placeholder: "{yyyy}/{yyyy}-{mm}-{dd}"; + field-width: 320px; + hint: "Tokens: {yyyy} {yy} {mm} {dd} {date} {make} {model}"; + enabled: !root.running; + accepted(t) => { root.template-changed(t); } + } + + // The template's answer, against a real photograph's date. + // A template language nobody can predict the output of is + // a template language nobody will change. + HorizontalLayout { + spacing: Theme.gap; + Label { text: "Example"; } + Value { text: root.template-preview; } + Rectangle { horizontal-stretch: 1; } + } + } + + // --- how ---------------------------------------------------- + Panel { + PanelHeading { text: "How"; } + + Segmented { + label: "Transfer"; + options: root.mode-labels; + selected: root.mode-selected; + enabled: !root.running; + picked(i) => { root.mode-picked(i); } + } + + if root.mode-is-move: Caption { + // The one operation in this application with no undo, + // and the only honest place to say so is beside the + // control that arms it (FR-NC-7b). + text: "The card is emptied only after every file is verified in the library — and, on a library that syncs, after it has reached the server."; + wrap: word-wrap; + } + + Segmented { + label: "Already in the library"; + options: root.duplicate-labels; + selected: root.duplicate-selected; + enabled: !root.running; + picked(i) => { root.duplicate-picked(i); } + } + + Check { + label: "Verify every file"; + hint: "Reads each copy back and compares it. Catches a dying card, which is otherwise invisible until the photograph is opened."; + checked: root.verify; + // `Check` has no enabled property, so the guard is on + // the callback: a toggle mid-run would change what the + // running worker was told to do, which it cannot hear. + toggled(v) => { if (!root.running) { root.verify-toggled(v); } } + } + + Check { + label: "Second copy"; + hint: "Written from the same read as the first, so it is a check on the copy rather than a copy of it."; + checked: root.backup-on; + toggled(v) => { if (!root.running) { root.backup-toggled(v); } } + } + + if root.backup-on: TextRow { + label: "Second copy in"; + text <=> root.backup-path; + placeholder: "/mnt/backup/Photos"; + field-width: 320px; + enabled: !root.running; + accepted(t) => { root.backup-path-changed(t); } + } + } + + // --- what happened ----------------------------------------- + if root.status != "": Panel { + PanelHeading { text: root.running ? "Working" : "Result"; } + Caption { text: root.status; wrap: word-wrap; } + } + } + } + } +} diff --git a/ui/dr-ui/ui/library.slint b/ui/dr-ui/ui/library.slint index 5089a45..af6c800 100644 --- a/ui/dr-ui/ui/library.slint +++ b/ui/dr-ui/ui/library.slint @@ -661,6 +661,7 @@ component HeaderActions inherits HorizontalLayout { callback toggle-pin-scope(); callback sync-now(); callback rescan(); + callback open-import(); callback open-settings(); spacing: Theme.gap; @@ -789,6 +790,18 @@ component HeaderActions inherits HorizontalLayout { clicked => { root.rescan(); } } + // TRACES: FR-CAT-10 + // Beside Rescan rather than beside Settings: both put photographs into the + // library, where Settings is about the application. Hidden during a scan + // for the same reason Rescan is — an import writes files the running scan + // would then half-see, and the two disagreeing about what is in a folder + // is a worse outcome than waiting. + if !root.scanning: Button { + text: "Import"; + y: root.centred ? (root.row-height - self.height) / 2 : 0; + clicked => { root.open-import(); } + } + // Last in the row, and unconditional. The buttons before it come and go // with what the grid is showing; settings is always reachable, and a // control that moved as its neighbours appeared would be hunted for each @@ -925,6 +938,7 @@ export component LibraryGrid inherits Rectangle { /// Back to the launch screen, to change library or account. callback change-library(); /// Open the settings page. + callback open-import(); callback open-settings(); // --- selection and drag --- @@ -1359,6 +1373,7 @@ export component LibraryGrid inherits Rectangle { toggle-pin-scope => { root.toggle-pin-scope(); } sync-now => { root.sync-now(); } rescan => { root.rescan(); } + open-import => { root.open-import(); } open-settings => { root.open-settings(); } } @@ -1433,6 +1448,7 @@ export component LibraryGrid inherits Rectangle { toggle-pin-scope => { root.toggle-pin-scope(); } sync-now => { root.sync-now(); } rescan => { root.rescan(); } + open-import => { root.open-import(); } open-settings => { root.open-settings(); } } }