wip: ingest

This commit is contained in:
2026-08-22 15:34:43 +02:00
parent 735683b849
commit 1526c957cf
9 changed files with 1570 additions and 26 deletions
+193 -19
View File
@@ -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]
+746
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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)