wip: ingest
This commit is contained in:
@@ -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};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+193
-19
@@ -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<Upload>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// 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<Message>) -> 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<Message>) -> 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<Message>,
|
||||
) -> 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<Vec<u8>, 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<i64> = 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]
|
||||
|
||||
@@ -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<Upload>,
|
||||
/// 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<crate::settings_ui::SettingsController>,
|
||||
activity: Rc<ActivityLog>,
|
||||
|
||||
volumes: RefCell<Vec<dr_plat::Volume>>,
|
||||
/// Which volume is selected, or -1 for a typed path.
|
||||
selected: Cell<i32>,
|
||||
/// The source. Typed or chosen; a card the app did not find is still a card.
|
||||
card: RefCell<String>,
|
||||
|
||||
/// What the last survey found, `None` before one has run.
|
||||
survey: RefCell<Option<(usize, u64)>>,
|
||||
surveying: Cell<bool>,
|
||||
|
||||
running: Cell<bool>,
|
||||
fraction: Cell<f32>,
|
||||
status: RefCell<String>,
|
||||
error: RefCell<String>,
|
||||
|
||||
/// Set to stop the worker. Replaced per run.
|
||||
cancel: RefCell<Option<import::Cancel>>,
|
||||
/// 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<Option<slint::Timer>>,
|
||||
/// 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<Option<Activity>>,
|
||||
}
|
||||
|
||||
impl ImportController {
|
||||
pub fn new(
|
||||
settings: Rc<crate::settings_ui::SettingsController>,
|
||||
activity: Rc<ActivityLog>,
|
||||
) -> Rc<Self> {
|
||||
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<ImportController>) {
|
||||
let stored = ctl.options();
|
||||
let volumes = ctl.volumes.borrow();
|
||||
|
||||
let labels: Vec<slint::SharedString> =
|
||||
volumes.iter().map(|v| v.label.as_str().into()).collect();
|
||||
let details: Vec<slint::SharedString> =
|
||||
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<C, F>(window: &AppWindow, ctl: Rc<ImportController>, context: C, on_imported: F)
|
||||
where
|
||||
C: Fn() -> Option<Context> + '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<PathBuf> {
|
||||
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<ImportController>,
|
||||
context: &Rc<impl Fn() -> Option<Context>>,
|
||||
) {
|
||||
*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<ImportController>,
|
||||
context: &Rc<impl Fn() -> Option<Context> + 'static>,
|
||||
on_imported: &Rc<impl Fn() + 'static>,
|
||||
) {
|
||||
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<ImportController>,
|
||||
rx: &std::sync::mpsc::Receiver<Message>,
|
||||
on_imported: &Rc<impl Fn()>,
|
||||
) {
|
||||
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 · "));
|
||||
}
|
||||
}
|
||||
+47
-1
@@ -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<PathBuf>) -> 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.
|
||||
|
||||
@@ -2832,7 +2832,7 @@ pub fn total_images(catalog: &Catalog) -> Result<usize, dr_catalog::CatalogError
|
||||
Ok(n as usize)
|
||||
}
|
||||
|
||||
fn now_secs() -> i64 {
|
||||
pub fn now_secs() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
|
||||
+101
-4
@@ -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 <bool> 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 <bool> show-import: false;
|
||||
|
||||
in property <[string]> import-volume-labels;
|
||||
in property <[string]> import-volume-details;
|
||||
in property <int> import-volume-selected: -1;
|
||||
in property <string> import-card-path: "";
|
||||
in property <bool> import-card-looks-right: false;
|
||||
in property <bool> import-surveying: false;
|
||||
in property <string> import-survey-summary: "";
|
||||
in property <string> import-destination: "";
|
||||
in property <string> import-folder-template: "";
|
||||
in property <string> import-template-preview: "";
|
||||
in property <[string]> import-mode-labels;
|
||||
in property <int> import-mode-selected: 0;
|
||||
in property <bool> import-mode-is-move: false;
|
||||
in property <[string]> import-duplicate-labels;
|
||||
in property <int> import-duplicate-selected: 0;
|
||||
in property <bool> import-verify: true;
|
||||
in property <bool> import-backup-on: false;
|
||||
in property <string> import-backup-path: "";
|
||||
in property <bool> import-running: false;
|
||||
in property <float> import-fraction: 0.0;
|
||||
in property <string> import-status: "";
|
||||
in property <string> import-error: "";
|
||||
in property <bool> 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 <string> settings-original-budget: "";
|
||||
in property <bool> settings-original-unlimited: false;
|
||||
in property <string> settings-thumbnail-budget: "";
|
||||
@@ -1000,7 +1050,53 @@ in property <bool> 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 <bool> 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 <bool> 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 <bool> 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 <bool> 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%;
|
||||
|
||||
|
||||
@@ -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 <string> label;
|
||||
in property <string> detail;
|
||||
in property <bool> 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 <int> 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 <string> card-path;
|
||||
/// Whether the source holds a DCIM folder. Advisory: plenty of legitimate
|
||||
/// sources do not, so this informs and never blocks.
|
||||
in property <bool> card-looks-right: false;
|
||||
|
||||
callback volume-picked(int);
|
||||
callback card-path-changed(string);
|
||||
callback refresh-volumes();
|
||||
|
||||
// --- what is on it ---------------------------------------------------
|
||||
in property <bool> surveying: false;
|
||||
/// "1,847 photographs · 61.2 GB". Empty before anything has been counted.
|
||||
in property <string> survey-summary;
|
||||
|
||||
// --- where they are going --------------------------------------------
|
||||
/// The library root. Shown, not editable — see the header comment.
|
||||
in property <string> destination;
|
||||
in-out property <string> 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 <string> template-preview;
|
||||
|
||||
in property <[string]> mode-labels;
|
||||
in property <int> mode-selected: 0;
|
||||
/// Whether the chosen mode will eventually erase the card.
|
||||
in property <bool> mode-is-move: false;
|
||||
|
||||
in property <[string]> duplicate-labels;
|
||||
in property <int> duplicate-selected: 0;
|
||||
in property <bool> verify: true;
|
||||
in property <bool> backup-on: false;
|
||||
in-out property <string> 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 <bool> running: false;
|
||||
in property <float> fraction: 0;
|
||||
/// What it is doing now, or what it did — one line, always present once
|
||||
/// something has happened.
|
||||
in property <string> status;
|
||||
in property <string> error;
|
||||
/// Whether Import can be pressed at all.
|
||||
in property <bool> 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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user